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) {