@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import {
4
+ createEventStoreExecutor,
5
+ createTenantDb,
6
+ type DbRunner,
7
+ deleteMany,
8
+ type EntityTableMeta,
9
+ } from "@cosmicdrift/kumiko-framework/db";
10
+ import {
11
+ createSystemUser,
12
+ EXT_EXTERNAL_RESOURCE,
13
+ EXT_INFRA_RESOURCE,
14
+ EXT_SEARCH_ADAPTER,
15
+ EXT_STORAGE_PROVIDER,
16
+ EXT_TENANT_DATA,
17
+ type Registry,
18
+ type TenantId,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
21
+ import { tenantEntity, tenantMembershipsTable, tenantTable } from "../tenant";
22
+ import type { TenantDestructionStageName } from "./constants";
23
+
24
+ export type DestructionStageCtx = {
25
+ readonly db: DbRunner;
26
+ readonly registry: Registry;
27
+ readonly tenantId: TenantId;
28
+ readonly log?: (message: string) => void;
29
+ };
30
+
31
+ export type DestructionStage = {
32
+ readonly name: TenantDestructionStageName;
33
+ readonly maxAttempts: number;
34
+ readonly run: (ctx: DestructionStageCtx) => Promise<void>;
35
+ };
36
+
37
+ const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
38
+
39
+ async function runExtensionDestroyHooks(
40
+ registry: Registry,
41
+ extensionName: string,
42
+ hookKey: "destroyTenant",
43
+ ctx: DestructionStageCtx,
44
+ ): Promise<void> {
45
+ const usages = registry.getExtensionUsages(extensionName);
46
+ for (const usage of usages) {
47
+ const hook = usage.options?.[hookKey];
48
+ if (typeof hook !== "function") continue;
49
+ await (hook as (tenantId: TenantId, hookCtx: DestructionStageCtx) => Promise<void>)(
50
+ ctx.tenantId,
51
+ ctx,
52
+ );
53
+ }
54
+ }
55
+
56
+ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
57
+ const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
58
+ for (const usage of usages) {
59
+ const destroy = usage.options?.["destroy"] as
60
+ | ((ctx: DestructionStageCtx) => Promise<void>)
61
+ | undefined;
62
+ if (!destroy) continue;
63
+ await destroy(ctx);
64
+ }
65
+ }
66
+
67
+ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
68
+ const kms = configuredPiiSubjectKms();
69
+ if (!kms) {
70
+ ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
71
+ // skip: KMS optional — apps without crypto-shredding still run other destroy stages
72
+ return;
73
+ }
74
+ const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
75
+ tenantId: ctx.tenantId,
76
+ });
77
+ const subjects: SubjectId[] = [
78
+ { kind: "tenant", tenantId: ctx.tenantId },
79
+ ...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
80
+ ];
81
+ for (const subject of subjects) {
82
+ await kms.eraseKey(subject, {
83
+ requestId: `tenant-lifecycle:destroy:${ctx.tenantId}`,
84
+ eraseReason: "tenant-destroy stage subject-keys",
85
+ });
86
+ }
87
+ }
88
+
89
+ async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
90
+ // ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
91
+ // then this stage is a documented no-op (no cache layer in test stack).
92
+ ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
93
+ }
94
+
95
+ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
96
+ const now = getTemporal().Now.instant();
97
+ const user = createSystemUser(ctx.tenantId);
98
+ const db = createTenantDb(ctx.db, ctx.tenantId, "system");
99
+ await tenantCrud.update(
100
+ {
101
+ id: ctx.tenantId,
102
+ changes: {
103
+ status: "destroyed",
104
+ destroyedAt: now,
105
+ isEnabled: false,
106
+ },
107
+ },
108
+ user,
109
+ db,
110
+ { skipOptimisticLock: true },
111
+ );
112
+ await deleteMany(ctx.db, tenantMembershipsTable as EntityTableMeta, {
113
+ tenantId: ctx.tenantId,
114
+ });
115
+ }
116
+
117
+ export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
118
+ {
119
+ name: "external-resources",
120
+ maxAttempts: 3,
121
+ run: (ctx) =>
122
+ runExtensionDestroyHooks(ctx.registry, EXT_EXTERNAL_RESOURCE, "destroyTenant", ctx),
123
+ },
124
+ {
125
+ name: "search-indices",
126
+ maxAttempts: 3,
127
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_SEARCH_ADAPTER, "destroyTenant", ctx),
128
+ },
129
+ {
130
+ name: "cache",
131
+ maxAttempts: 1,
132
+ run: purgeTenantCache,
133
+ },
134
+ {
135
+ name: "app-data",
136
+ maxAttempts: 3,
137
+ run: runTenantDataHooks,
138
+ },
139
+ {
140
+ name: "subject-keys",
141
+ maxAttempts: 3,
142
+ run: eraseSubjectKeys,
143
+ },
144
+ {
145
+ name: "files",
146
+ maxAttempts: 3,
147
+ run: (ctx) =>
148
+ runExtensionDestroyHooks(ctx.registry, EXT_STORAGE_PROVIDER, "destroyTenant", ctx),
149
+ },
150
+ {
151
+ name: "infra-resources",
152
+ maxAttempts: 3,
153
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_INFRA_RESOURCE, "destroyTenant", ctx),
154
+ },
155
+ {
156
+ name: "tenant-row",
157
+ maxAttempts: 1,
158
+ run: tombstoneTenantRow,
159
+ },
160
+ ];
161
+
162
+ export function pickNextStage(
163
+ completedStages: ReadonlySet<string>,
164
+ abandonedStages: ReadonlySet<string>,
165
+ ): DestructionStage | null {
166
+ if (abandonedStages.size > 0) return null;
167
+ for (const stage of DESTRUCTION_STAGES) {
168
+ if (completedStages.has(stage.name)) continue;
169
+ return stage;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ export function isDestructionPipelineComplete(completedStages: ReadonlySet<string>): boolean {
175
+ return DESTRUCTION_STAGES.every((stage) => completedStages.has(stage.name));
176
+ }