From 07a11240eada8fa12c433b3a4834c91869fa72ac Mon Sep 17 00:00:00 2001 From: Neal Shah <30693865+ProgrammerIn-wonderland@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:30:47 -0400 Subject: [PATCH] PUT-1868 (#3940) --- .../database/SqliteDatabaseClient.test.ts | 64 ++++++++++++++- .../clients/database/SqliteDatabaseClient.ts | 1 + .../migrations/mysql/mysql_mig_41.sql | 75 +++++++++++++++++ .../migrations/postgres/postgres_mig_30.sql | 59 +++++++++++++ .../0086_subdomains-app-owner-cascade.sql | 52 ++++++++++++ src/backend/drivers/apps/AppDriver.js | 6 ++ src/backend/drivers/apps/AppDriver.test.ts | 82 ++++++++++++++++++- .../workers/WorkerDriver.cloudflare.test.ts | 56 +++++++++++++ src/backend/drivers/workers/WorkerDriver.ts | 4 + .../localworker/LocalWorkerService.ts | 2 + .../stores/subdomain/SubdomainStore.test.ts | 23 ++++++ .../stores/subdomain/SubdomainStore.ts | 31 +++++++ 12 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_41.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_30.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0086_subdomains-app-owner-cascade.sql diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index b265b4b45..904aa8bef 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 81; +const CURRENT_SCHEMA_VERSION = 82; /** * These suites migrate real files on disk. Idle they finish in well under a @@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI ]); }); + it('deletes the subdomain rows an app owns when the app is deleted', async () => { + const [user] = (await client.read( + 'SELECT MIN(`id`) AS id FROM `user`', + )) as { id: number }[]; + await client.write( + 'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' + + 'VALUES (?, ?, ?, ?, ?)', + [ + 'app-cascade-test', + user.id, + 'cascade-test', + 'cascade-test', + 'https://cascade.test/', + ], + ); + const [app] = (await client.read( + 'SELECT `id` FROM `apps` WHERE `uid` = ?', + ['app-cascade-test'], + )) as { id: number }[]; + const insertSubdomain = ( + uuid: string, + name: string, + appOwner: number | null, + ) => + client.write( + 'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' + + 'VALUES (?, ?, ?, ?)', + [uuid, name, user.id, appOwner], + ); + await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id); + await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null); + + await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]); + + await expect( + client.read( + 'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`', + ['cascade-%'], + ), + ).resolves.toEqual([{ subdomain: 'cascade-unowned' }]); + }); + + it('keeps every subdomains column through the 0086 rebuild', async () => { + const columns = (await client.read( + "SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`", + )) as { name: string }[]; + expect(columns.map((c) => c.name)).toEqual([ + 'id', + 'uuid', + 'subdomain', + 'user_id', + 'root_dir_id', + 'associated_app_id', + 'ts', + 'app_owner', + 'protected', + 'domain', + 'database_id', + 'preamble_version', + ]); + }); + it('constrains team shares that the user-holder index cannot', async () => { const [user] = (await client.read( 'SELECT MIN(`id`) AS id FROM `user`', diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index f5b32a8b4..9c13d96b4 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -115,6 +115,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [78, ['0083_team-directory.sql']], [79, ['0084_share-anyone-with-link.sql']], [80, ['0085_event-subscriptions-include-value.sql']], + [81, ['0086_subdomains-app-owner-cascade.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_41.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_41.sql new file mode 100644 index 000000000..0ab212f96 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_41.sql @@ -0,0 +1,75 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app +-- delete instead of nulling. The existing constraint is found by column +-- rather than by name, since an older database need not carry the name +-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees +-- DELETE_RULE already CASCADE and does nothing. + +DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade; + +DELIMITER // +CREATE PROCEDURE _puter_subdomains_app_owner_cascade() +BEGIN + DECLARE fk_name VARCHAR(64) DEFAULT NULL; + + -- A subquery, not SELECT ... INTO: no matching row yields NULL quietly. + SET fk_name = ( + SELECT rc.CONSTRAINT_NAME + FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA + AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME + AND kcu.TABLE_NAME = rc.TABLE_NAME + WHERE rc.CONSTRAINT_SCHEMA = DATABASE() + AND rc.TABLE_NAME = 'subdomains' + AND rc.REFERENCED_TABLE_NAME = 'apps' + AND kcu.COLUMN_NAME = 'app_owner' + AND rc.DELETE_RULE <> 'CASCADE' + LIMIT 1 + ); + + IF fk_name IS NOT NULL THEN + SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`'); + PREPARE stmt FROM @s; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA + AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME + AND kcu.TABLE_NAME = rc.TABLE_NAME + WHERE rc.CONSTRAINT_SCHEMA = DATABASE() + AND rc.TABLE_NAME = 'subdomains' + AND rc.REFERENCED_TABLE_NAME = 'apps' + AND kcu.COLUMN_NAME = 'app_owner' + AND rc.DELETE_RULE = 'CASCADE' + ) THEN + ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner` + FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END// +DELIMITER ; + +CALL _puter_subdomains_app_owner_cascade(); +DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_30.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_30.sql new file mode 100644 index 000000000..a6e1f4c0a --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_30.sql @@ -0,0 +1,59 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app +-- delete instead of nulling. The existing constraint is found by column, not +-- by name. Idempotent: there is no per-file applied-state tracking, and a +-- replay sees the cascade already in place and does nothing. + +DO $$ +DECLARE + fk_name text; +BEGIN + SELECT c.conname INTO fk_name + FROM pg_constraint c + JOIN pg_attribute a + ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'subdomains'::regclass + AND c.contype = 'f' + AND c.confrelid = 'apps'::regclass + AND a.attname = 'app_owner' + AND c.confdeltype <> 'c' + LIMIT 1; + + IF fk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a + ON a.attrelid = c.conrelid + AND a.attnum = ANY (c.conkey) + WHERE c.conrelid = 'subdomains'::regclass + AND c.contype = 'f' + AND c.confrelid = 'apps'::regclass + AND a.attname = 'app_owner' + AND c.confdeltype = 'c' + ) THEN + ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey + FOREIGN KEY (app_owner) REFERENCES apps (id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/src/backend/clients/database/migrations/sqlite/0086_subdomains-app-owner-cascade.sql b/src/backend/clients/database/migrations/sqlite/0086_subdomains-app-owner-cascade.sql new file mode 100644 index 000000000..1e662b77b --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0086_subdomains-app-owner-cascade.sql @@ -0,0 +1,52 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- A deleted app takes the subdomain rows it owns (its hosted sites and its +-- workers) with it. `SET NULL` left them behind as unowned rows, and an +-- unowned worker row is redeployed with the account's own credential. +-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does. + +PRAGMA foreign_keys = OFF; + +CREATE TABLE `subdomains_new` ( + `id` INTEGER PRIMARY KEY, + `uuid` varchar(40) DEFAULT NULL, + `subdomain` varchar(64) NOT NULL, + `user_id` int(10) NOT NULL, + `root_dir_id` int(10) DEFAULT NULL, + `associated_app_id` int(10) DEFAULT NULL, + `ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `app_owner` int(10) DEFAULT NULL, + `protected` tinyint(1) DEFAULT '0', + `domain` varchar(256) DEFAULT NULL, + `database_id` varchar(40) DEFAULT NULL, + `preamble_version` varchar(64) DEFAULT NULL, + FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +); + +INSERT INTO `subdomains_new` + (`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`, + `app_owner`, `protected`, `domain`, `database_id`, `preamble_version`) +SELECT + `id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`, + `app_owner`, `protected`, `domain`, `database_id`, `preamble_version` +FROM `subdomains`; + +DROP TABLE `subdomains`; +ALTER TABLE `subdomains_new` RENAME TO `subdomains`; + +PRAGMA foreign_keys = ON; diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 1e4134487..07308a24a 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -1451,6 +1451,12 @@ export class AppDriver extends PuterDriver { }); const sourceApp = await this.appStore.getByUid(sourceAppUid); if (sourceApp) { + // The source app's sites and workers follow it into the + // joined row; `app_owner` cascades on delete otherwise. + await this.stores.subdomain.reassignAppOwner( + sourceApp.id, + appToJoin.id, + ); await this.appStore.delete(sourceApp.id); this.#emitAppChanged({ app: null, diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 57f5b5406..6adbeb17b 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -515,6 +515,40 @@ describe('AppDriver.delete', () => { ).toBeNull(); }); + it('deletes the subdomain rows the app owns and leaves the rest', async () => { + const { actor, userId } = await makeUser(); + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('own'), + title: 't', + index_url: uniqueIndexUrl(), + }, + }), + ); + const app = (await server.stores.app.getByUid(created.uid as string))!; + const prefix = `appdel-${Math.random().toString(36).slice(2, 8)}`; + await server.stores.subdomain.create({ + userId, + subdomain: `${prefix}-owned`, + appOwner: app.id, + }); + await server.stores.subdomain.create({ + userId, + subdomain: `${prefix}-unowned`, + }); + + await withActor(actor, () => driver.delete({ uid: created.uid })); + + const remaining = await server.stores.subdomain.listByUserIdAndPrefix( + userId, + prefix, + ); + expect(remaining.map((r) => r.subdomain)).toEqual([ + `${prefix}-unowned`, + ]); + }); + it("refuses to delete another user's app with 403", async () => { const a = await makeUser(); const b = await makeUser(); @@ -1319,6 +1353,7 @@ describe('AppDriver.isNameAvailable additional branches', () => { describe('AppDriver alias-group index_url merge', () => { const aliasHostA = `alias-a-${Math.random().toString(36).slice(2, 10)}.test`; const aliasHostB = `alias-b-${Math.random().toString(36).slice(2, 10)}.test`; + const aliasHostC = `alias-c-${Math.random().toString(36).slice(2, 10)}.test`; // `config` is protected on PuterDriver; reach in to toggle the alias // groups for this block only. `#getOriginAliasGroups` reads config at @@ -1327,7 +1362,11 @@ describe('AppDriver alias-group index_url merge', () => { (driver as unknown as { config: Record }).config; beforeAll(() => { - driverConfig().app_origin_aliases = [[aliasHostA], [aliasHostB]]; + driverConfig().app_origin_aliases = [ + [aliasHostA], + [aliasHostB], + [aliasHostC], + ]; }); afterAll(() => { @@ -1413,6 +1452,47 @@ describe('AppDriver alias-group index_url merge', () => { expect(viaOldUid.uid).toBe(stubUid); }); + it("update merge hands the source app's owned subdomain rows to the joined app", async () => { + const { actor, userId } = await makeUser(); + const stubUid = await makeBootstrapStub(aliasHostC); + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('alias-own'), + title: 't', + index_url: uniqueIndexUrl(), + }, + }), + ); + const sourceApp = (await server.stores.app.getByUid( + created.uid as string, + ))!; + const subdomain = `merge-${Math.random().toString(36).slice(2, 8)}`; + await server.stores.subdomain.create({ + userId, + subdomain, + appOwner: sourceApp.id, + }); + + const updated = await withActor(actor, () => + driver.update({ + uid: created.uid, + object: { index_url: `https://${aliasHostC}/` }, + }), + ); + expect(updated.uid).toBe(stubUid); + + // The source row is gone; its subdomain row survives under the joined + // app rather than cascading away with the source. + const stub = (await server.stores.app.getByUid(stubUid))!; + const [row] = await server.stores.subdomain.listByUserIdAndPrefix( + userId, + subdomain, + ); + expect(row).toBeTruthy(); + expect(Number(row!.app_owner)).toBe(stub.id); + }); + it('leaves unrelated custom domains untouched (no alias group, no conflict check)', async () => { const a = await makeUser(); const b = await makeUser(); diff --git a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts index edb74fb61..40dbd3f3b 100644 --- a/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts +++ b/src/backend/drivers/workers/WorkerDriver.cloudflare.test.ts @@ -748,4 +748,60 @@ describe('WorkerDriver hot reload', () => { await new Promise((r) => setTimeout(r, 120)); expect(fetchSpy).not.toHaveBeenCalled(); }); + + // Deleting the app a worker is bound to deletes the worker row with it. + // Were the row to survive unbound, the next source write would redeploy + // it with an account-scoped token — a child app is enough to set that up. + it('does not redeploy a worker whose app was deleted, and never falls back to an account-scoped token', async () => { + const { user, actor } = await makeUser(); + const builder = await server.stores.app.create( + { + name: `builder-${user.username}`, + title: 'builder', + index_url: `https://builder-${user.username}.example.com/`, + }, + { ownerUserId: user.id }, + ); + const child = await server.stores.app.create( + { + name: `child-${user.username}`, + title: 'child', + index_url: `https://child-${user.username}.example.com/`, + }, + { ownerUserId: user.id, appOwner: builder.id }, + ); + const path = `/${user.username}/bound.js`; + await writeSource(actor, user.id, path, 'v1'); + const name = `bound-${user.username}`; + await inCtx(actor, () => + target.create({ + appId: child.uid, + workerName: name, + filePath: path, + }), + ); + + // Uncached listing: the by-name cache would still serve the row. + const rowsFor = () => + server.stores.subdomain.listByUserIdAndPrefix( + user.id, + `workers.puter.${name}`, + ); + const [row] = await rowsFor(); + expect(Number(row!.app_owner)).toBe(child.id); + + await server.stores.app.delete(child.id); + expect(await rowsFor()).toEqual([]); + + fetchSpy.mockClear(); + const sessionMint = vi.spyOn( + server.services.auth, + 'createWorkerSessionToken', + ); + await writeSource(actor, user.id, path, 'v2'); + + await new Promise((r) => setTimeout(r, 120)); + expect(putCalls()).toHaveLength(0); + expect(sessionMint).not.toHaveBeenCalled(); + }); }); diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index 858451ee2..96e5bbd5a 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -1130,6 +1130,10 @@ export class WorkerDriver extends PuterDriver { // (user, app_uid, worker_name) so a hot-reload reuses // the same row across reloads and the long-lived token // stays stable for the worker's whole lifetime. + // + // A null `app_owner` means the worker was never bound to + // an app: a deleted app takes its rows with it, so this + // row cannot be a binding that was lost. const appOwnerId = row.app_owner as number | null; let authorization: string; if (appOwnerId) { diff --git a/src/backend/services/localworker/LocalWorkerService.ts b/src/backend/services/localworker/LocalWorkerService.ts index 5b554974f..600e76eae 100644 --- a/src/backend/services/localworker/LocalWorkerService.ts +++ b/src/backend/services/localworker/LocalWorkerService.ts @@ -227,6 +227,8 @@ export class LocalWorkerService extends PuterService { } } async reconstructDeployArgs(workerName: string, row: SubdomainRow) { + // A null `app_owner` means the worker was never bound to an app: a + // deleted app takes its rows with it. const appOwnerId = row.app_owner as number | null; let authorization: string; const ownerUser = await this.stores.user.getById(row.user_id); diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts index 645488e8a..8456922aa 100644 --- a/src/backend/stores/subdomain/SubdomainStore.test.ts +++ b/src/backend/stores/subdomain/SubdomainStore.test.ts @@ -158,6 +158,29 @@ describe('SubdomainStore app_owner filtering', () => { return subdomain; }; + it('reassignAppOwner moves only the source app rows and refreshes their cache', async () => { + const userId = await makeUser(); + const prefix = `sds-move-${Math.random().toString(36).slice(2, 6)}.`; + const from = await makeApp(userId); + const to = await makeApp(userId); + const other = await makeApp(userId); + + const moved = await seed(userId, prefix, from.id); + const kept = await seed(userId, prefix, other.id); + + const rows = await store.reassignAppOwner(from.id, to.id); + expect(rows.map((r) => r.subdomain)).toEqual([moved]); + + // The by-name read must not serve the pre-move owner from cache. + expect(Number((await store.getBySubdomain(moved))!.app_owner)).toBe( + to.id, + ); + expect(Number((await store.getBySubdomain(kept))!.app_owner)).toBe( + other.id, + ); + expect((await store.listAll({ appOwner: from.id })).length).toBe(0); + }); + it('matches rows owned by any app in `appIds`', async () => { const userId = await makeUser(); const prefix = `sds-multi-${Math.random().toString(36).slice(2, 6)}.`; diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index 33d62a6c8..c60a9e65c 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -593,6 +593,37 @@ export class SubdomainStore extends PuterStore { } } + /** + * Hand every row `fromAppId` owns to `toAppId`. For merging one app row + * into another: `app_owner` cascades on app delete, so the rows have to + * change hands before the source app row goes. Returns the moved rows. + */ + async reassignAppOwner( + fromAppId: number, + toAppId: number, + ): Promise { + const rows = (await this.listAll({ + appOwner: fromAppId, + })) as unknown as SubdomainRow[]; + if (rows.length === 0) return rows; + + await this.clients.db.write( + 'UPDATE `subdomains` SET `app_owner` = ? WHERE `app_owner` = ?', + [toAppId, fromAppId], + ); + + const userIds = new Set(); + for (const row of rows) { + row.app_owner = toAppId; + await this.#refreshCache(row); + if (row.user_id != null) userIds.add(Number(row.user_id)); + } + for (const userId of userIds) { + await this.#invalidatePrefixListsForUser(userId); + } + return rows; + } + // -- Internals ---------------------------------------------------- #cacheKey(subdomain: string) {