mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-03 10:27:48 +00:00
PUT-1868 (#3940)
This commit is contained in:
@@ -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`',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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 $$;
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> }).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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}.`;
|
||||
|
||||
@@ -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<SubdomainRow[]> {
|
||||
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<number>();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user