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