From 41e4b582d3966623eee96049743d4e8926646cca Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 20 Oct 2026 12:31:40 -0700 Subject: [PATCH] fix(subdomains): emit subdomain.delete for sites deleted with an app (#4273) Sites and workers tied to an app go with it by FK cascade, on app delete and on deleting the app owner's account. Only caches were dropped for them, so the subdomain.delete listeners (CDN purge, worker script teardown, worker sessions, site cleanup) never ran. --- .../stores/subdomain/SubdomainStore.test.ts | 57 ++++++++++++++++++- .../stores/subdomain/SubdomainStore.ts | 36 ++++++++---- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts index 8c1994eb8..af5dd10a5 100644 --- a/src/backend/stores/subdomain/SubdomainStore.test.ts +++ b/src/backend/stores/subdomain/SubdomainStore.test.ts @@ -535,8 +535,16 @@ describe('SubdomainStore app_owner filtering', () => { rows.map((row) => row.subdomain).sort(); expect(await store.listAppCascade({ appId: app.id })).toEqual( expect.arrayContaining([ - { subdomain: owned.subdomain, root_dir_id: null }, - { subdomain: associated.subdomain, root_dir_id: 7 }, + { + uuid: owned.uuid, + subdomain: owned.subdomain, + root_dir_id: null, + }, + { + uuid: associated.uuid, + subdomain: associated.subdomain, + root_dir_id: 7, + }, ]), ); expect(names(await store.listAppCascade({ appId: app.id }))).toEqual( @@ -547,6 +555,51 @@ describe('SubdomainStore app_owner filtering', () => { ).toEqual(names([owned, associated, siblings])); }); + it('emits subdomain.delete for each row an app delete cascades to', async () => { + const developer = await makeUser(); + const siteOwner = await makeUser(); + const app = await makeApp(developer); + const name = (tag: string) => + `sds-cascade-ev-${tag}-${Math.random().toString(36).slice(2, 8)}`; + const owned = await store.create({ + userId: siteOwner, + subdomain: name('owned'), + appOwner: app.id, + }); + const associated = await store.create({ + userId: siteOwner, + subdomain: name('associated'), + associatedAppId: app.id, + }); + + const events: Array<{ subdomain: string; uid?: string }> = []; + const listener = ( + _key: string, + data: { subdomain: string; uid?: string }, + ) => { + events.push(data); + }; + server.clients.event.on('subdomain.delete', listener); + try { + expect(await server.stores.app.delete(app.id)).toBe(true); + await vi.waitFor(() => expect(events).toHaveLength(2)); + } finally { + server.clients.event.off('subdomain.delete', listener); + } + + expect(events).toEqual( + expect.arrayContaining([ + { subdomain: owned.subdomain, uid: owned.uuid }, + { subdomain: associated.subdomain, uid: associated.uuid }, + ]), + ); + // SQLite reuses the deleted app's id; drop anything it left behind. + await server.clients.db.write( + 'DELETE FROM `subdomains` WHERE `uuid` IN (?, ?)', + [owned.uuid, associated.uuid], + ); + }); + it('lists an app cascade past one page', async () => { const userId = await makeUser(); const app = await makeApp(userId); diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index 395136179..e75173fcd 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -45,6 +45,12 @@ export interface SubdomainRow { [key: string]: unknown; } +/** A row deleted by the database along with an app. */ +export type CascadeRow = Pick< + SubdomainRow, + 'uuid' | 'subdomain' | 'root_dir_id' +>; + // Columns that may not be set through an `update` patch map. Defence-in-depth // against future callers (admin routes, extensions, new REST handlers) that // might forward `req.body` straight into the store: the driver's update @@ -759,7 +765,7 @@ export class SubdomainStore extends PuterStore { */ async listAppCascade( apps: { appId: number } | { ownerUserId: number }, - ): Promise>> { + ): Promise { const [match, param] = 'appId' in apps ? ['= ?', apps.appId] @@ -767,17 +773,17 @@ export class SubdomainStore extends PuterStore { 'IN (SELECT `id` FROM `apps` WHERE `owner_user_id` = ?)', apps.ownerUserId, ]; - const out: Array> = []; + const out: CascadeRow[] = []; let afterId = 0; for (;;) { const rows = (await this.clients.db.pread( - `SELECT \`id\`, \`subdomain\`, \`root_dir_id\` FROM \`subdomains\` + `SELECT \`id\`, \`uuid\`, \`subdomain\`, \`root_dir_id\` FROM \`subdomains\` WHERE (\`app_owner\` ${match} OR \`associated_app_id\` ${match}) AND \`id\` > ? ORDER BY \`id\` LIMIT ?`, [param, param, afterId, BULK_QUERY_CHUNK_SIZE], - )) as Array>; - for (const { subdomain, root_dir_id } of rows) { - out.push({ subdomain, root_dir_id }); + )) as Array & CascadeRow>; + for (const { uuid, subdomain, root_dir_id } of rows) { + out.push({ uuid, subdomain, root_dir_id }); } if (rows.length < BULK_QUERY_CHUNK_SIZE) return out; afterId = Number(rows[rows.length - 1].id); @@ -786,11 +792,10 @@ export class SubdomainStore extends PuterStore { /** * Drop cached state, here and on peers, for rows deleted outside this - * store. + * store, and emit `subdomain.delete` for each as the driver does, so CDN + * purges, deployed-worker teardown and other site cleanup still run. */ - async forgetCascadeDeleted( - rows: Array>, - ): Promise { + async forgetCascadeDeleted(rows: CascadeRow[]): Promise { if (rows.length === 0) return; await this.#publishChanged( [], @@ -804,6 +809,17 @@ export class SubdomainStore extends PuterStore { await runWithConcurrencyLimit(rootDirIds, 8, (id) => this.#invalidateRootDirEntry(id), ); + for (const row of rows) { + try { + this.clients.event.emit( + 'subdomain.delete', + { subdomain: row.subdomain, uid: String(row.uuid) }, + {}, + ); + } catch { + // Non-critical. + } + } } // -- Internals ----------------------------------------------------