@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,46 @@
1
+ import { z } from "zod";
2
+
3
+ export const destructionRequestedSchema = z.object({
4
+ requestedBy: z.uuid(),
5
+ gracePeriodEnd: z.string().datetime(),
6
+ });
7
+
8
+ export const destructionCancelledSchema = z.object({
9
+ cancelledBy: z.uuid(),
10
+ });
11
+
12
+ export const tenantDestructionStartedSchema = z.object({
13
+ startedAt: z.string().datetime(),
14
+ });
15
+
16
+ export const tenantDestructionStageStartedSchema = z.object({
17
+ stage: z.string().min(1),
18
+ attempts: z.number().int().positive(),
19
+ });
20
+
21
+ export const tenantDestructionStageSucceededSchema = z.object({
22
+ stage: z.string().min(1),
23
+ attempts: z.number().int().positive(),
24
+ });
25
+
26
+ export const tenantDestructionStageFailedSchema = z.object({
27
+ stage: z.string().min(1),
28
+ attempts: z.number().int().positive(),
29
+ error: z.string().min(1),
30
+ });
31
+
32
+ export const tenantDestructionStageAbandonedSchema = z.object({
33
+ stage: z.string().min(1),
34
+ attempts: z.number().int().positive(),
35
+ error: z.string().min(1),
36
+ });
37
+
38
+ export const tenantDestructionCompletedSchema = z.object({
39
+ destroyedAt: z.string().datetime(),
40
+ });
41
+
42
+ export const tenantDestructionFailedSchema = z.object({
43
+ stage: z.string().min(1),
44
+ error: z.string().min(1),
45
+ failedAt: z.string().datetime(),
46
+ });
@@ -0,0 +1,104 @@
1
+ import {
2
+ defineFeature,
3
+ EXT_EXTERNAL_RESOURCE,
4
+ EXT_INFRA_RESOURCE,
5
+ EXT_SEARCH_ADAPTER,
6
+ EXT_STORAGE_PROVIDER,
7
+ EXT_TENANT_DATA,
8
+ type FeatureDefinition,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ DESTRUCTION_CANCELLED_EVENT_SHORT,
12
+ DESTRUCTION_REQUESTED_EVENT_SHORT,
13
+ TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT,
14
+ TENANT_DESTRUCTION_FAILED_EVENT_SHORT,
15
+ TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
16
+ TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT,
17
+ TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
18
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
19
+ TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
20
+ } from "./constants";
21
+ import {
22
+ destructionCancelledSchema,
23
+ destructionRequestedSchema,
24
+ tenantDestructionCompletedSchema,
25
+ tenantDestructionFailedSchema,
26
+ tenantDestructionStageAbandonedSchema,
27
+ tenantDestructionStageFailedSchema,
28
+ tenantDestructionStageStartedSchema,
29
+ tenantDestructionStageSucceededSchema,
30
+ tenantDestructionStartedSchema,
31
+ } from "./events";
32
+ import { cancelDestructionWrite } from "./handlers/cancel-destruction.write";
33
+ import { requestDestructionWrite } from "./handlers/request-destruction.write";
34
+ import { runTenantDestructionSweep } from "./run-tenant-destroy";
35
+
36
+ export function createTenantLifecycleFeature(): FeatureDefinition {
37
+ return defineFeature("tenant-lifecycle", (r) => {
38
+ r.describe(
39
+ "Tenant-destroy lifecycle: request/cancel destruction with compliance-profile grace, auth 410 gate for teardown states, cron trigger after grace, and staged destroy runner (extension fan-out, subject-key erase, tenant tombstone).",
40
+ );
41
+ r.uiHints({
42
+ displayLabel: "Tenant Lifecycle · Destroy",
43
+ category: "compliance",
44
+ recommended: false,
45
+ });
46
+ r.requires("tenant", "compliance-profiles");
47
+ r.optionalRequires("sessions");
48
+ r.usesApi("compliance.forTenant");
49
+
50
+ r.extendsRegistrar(EXT_TENANT_DATA, {});
51
+ r.extendsRegistrar(EXT_SEARCH_ADAPTER, {});
52
+ r.extendsRegistrar(EXT_EXTERNAL_RESOURCE, {});
53
+ r.extendsRegistrar(EXT_STORAGE_PROVIDER, {});
54
+ r.extendsRegistrar(EXT_INFRA_RESOURCE, {});
55
+
56
+ r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema);
57
+ r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema);
58
+ r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema);
59
+ r.defineEvent(
60
+ TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
61
+ tenantDestructionStageStartedSchema,
62
+ );
63
+ r.defineEvent(
64
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
65
+ tenantDestructionStageSucceededSchema,
66
+ );
67
+ r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema);
68
+ r.defineEvent(
69
+ TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
70
+ tenantDestructionStageAbandonedSchema,
71
+ );
72
+ r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema);
73
+ r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema);
74
+
75
+ r.writeHandler(requestDestructionWrite);
76
+ r.writeHandler(cancelDestructionWrite);
77
+
78
+ r.job(
79
+ "run-tenant-destruction",
80
+ { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
81
+ async (_payload, ctx) => {
82
+ if (!ctx.db || !ctx.registry) {
83
+ throw new Error(
84
+ "run-tenant-destruction: ctx.db + ctx.registry required (JobContext incomplete)",
85
+ );
86
+ }
87
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
88
+ await runTenantDestructionSweep({
89
+ db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection,
90
+ registry: ctx.registry,
91
+ now: T.Now.instant(),
92
+ });
93
+ },
94
+ );
95
+
96
+ r.exposesApi("tenantLifecycle.runDestroySweep");
97
+ });
98
+ }
99
+
100
+ export {
101
+ TENANT_LIFECYCLE_FEATURE,
102
+ TenantLifecycleHandlers,
103
+ } from "./constants";
104
+ export { resolveTenantLifecycleGate, runTenantDestructionSweep } from "./run-tenant-destroy";
@@ -0,0 +1,79 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
3
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
5
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
+ import { z } from "zod";
7
+ import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
8
+ import { DESTRUCTION_CANCELLED_EVENT_QN } from "../constants";
9
+
10
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
11
+
12
+ type TenantLifecycleRow = {
13
+ status: TenantLifecycleStatus;
14
+ gracePeriodEnd: Temporal.Instant | null;
15
+ };
16
+
17
+ export const cancelDestructionWrite = defineWriteHandler({
18
+ name: "cancel-destruction",
19
+ schema: z.object({}),
20
+ access: { roles: ["TenantOwner", "Admin"] },
21
+ handler: async (event, ctx) => {
22
+ const tenantId = event.user.tenantId;
23
+ const row = await fetchOne<TenantLifecycleRow>(ctx.db.raw, tenantTable, { id: tenantId });
24
+ if (!row) {
25
+ return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
26
+ }
27
+ if (row.status !== "destroyRequested") {
28
+ return writeFailure(
29
+ new UnprocessableError("no_pending_destruction", {
30
+ details: { status: row.status },
31
+ }),
32
+ );
33
+ }
34
+
35
+ const gracePeriodEnd = row.gracePeriodEnd;
36
+ const inGrace =
37
+ gracePeriodEnd != null &&
38
+ Temporal.Instant.compare(gracePeriodEnd, getTemporal().Now.instant()) > 0;
39
+ if (!inGrace) {
40
+ return writeFailure(
41
+ new UnprocessableError("grace_period_expired", {
42
+ details: { reason: "grace_period_expired" },
43
+ }),
44
+ );
45
+ }
46
+
47
+ const update = await crud.update(
48
+ {
49
+ id: tenantId,
50
+ changes: {
51
+ status: "active",
52
+ destroyRequestedAt: null,
53
+ destroyRequestedBy: null,
54
+ gracePeriodEnd: null,
55
+ },
56
+ },
57
+ event.user,
58
+ ctx.db,
59
+ { skipOptimisticLock: true },
60
+ );
61
+ if (!update.isSuccess) return update;
62
+
63
+ await ctx.unsafeAppendEvent({
64
+ aggregateId: tenantId,
65
+ aggregateType: "tenant",
66
+ type: DESTRUCTION_CANCELLED_EVENT_QN,
67
+ payload: { cancelledBy: event.user.id },
68
+ });
69
+
70
+ return {
71
+ isSuccess: true as const,
72
+ data: {
73
+ tenantId,
74
+ status: "active" as const,
75
+ gracePeriodEnd: null as string | null,
76
+ },
77
+ };
78
+ },
79
+ });
@@ -0,0 +1,81 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
3
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
4
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
7
+ import { z } from "zod";
8
+ import { resolveProfileForTenant } from "../../compliance-profiles";
9
+ import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
10
+ import { DESTRUCTION_REQUESTED_EVENT_QN } from "../constants";
11
+ import { revokeTenantSessions } from "../lib/revoke-tenant-sessions";
12
+
13
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
14
+
15
+ type TenantLifecycleRow = {
16
+ status: TenantLifecycleStatus;
17
+ gracePeriodEnd: Temporal.Instant | null;
18
+ };
19
+
20
+ export const requestDestructionWrite = defineWriteHandler({
21
+ name: "request-destruction",
22
+ schema: z.object({}),
23
+ access: { roles: ["TenantOwner", "Admin"] },
24
+ handler: async (event, ctx) => {
25
+ const tenantId = event.user.tenantId;
26
+ const row = await fetchOne<TenantLifecycleRow>(ctx.db.raw, tenantTable, { id: tenantId });
27
+ if (!row) {
28
+ return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
29
+ }
30
+ if (row.status !== "active") {
31
+ return writeFailure(
32
+ new UnprocessableError("tenant_not_active", {
33
+ details: { status: row.status },
34
+ }),
35
+ );
36
+ }
37
+
38
+ const { profile } = await resolveProfileForTenant({ db: ctx.db.raw, tenantId });
39
+ const T = getTemporal();
40
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), profile.tenantDestroyGracePeriod);
41
+
42
+ const update = await crud.update(
43
+ {
44
+ id: tenantId,
45
+ changes: {
46
+ status: "destroyRequested",
47
+ destroyRequestedAt: T.Now.instant(),
48
+ destroyRequestedBy: event.user.id,
49
+ gracePeriodEnd,
50
+ },
51
+ },
52
+ event.user,
53
+ ctx.db,
54
+ { skipOptimisticLock: true },
55
+ );
56
+ if (!update.isSuccess) return update;
57
+
58
+ await ctx.unsafeAppendEvent({
59
+ aggregateId: tenantId,
60
+ aggregateType: "tenant",
61
+ type: DESTRUCTION_REQUESTED_EVENT_QN,
62
+ payload: {
63
+ requestedBy: event.user.id,
64
+ gracePeriodEnd: gracePeriodEnd.toString(),
65
+ },
66
+ });
67
+
68
+ if (ctx.hasFeature("sessions")) {
69
+ await revokeTenantSessions(ctx.db.raw, tenantId);
70
+ }
71
+
72
+ return {
73
+ isSuccess: true as const,
74
+ data: {
75
+ tenantId,
76
+ status: "destroyRequested" as const,
77
+ gracePeriodEnd: gracePeriodEnd.toString(),
78
+ },
79
+ };
80
+ },
81
+ });
@@ -0,0 +1,7 @@
1
+ export {
2
+ TENANT_DESTRUCTION_STAGES,
3
+ TENANT_LIFECYCLE_FEATURE,
4
+ TenantLifecycleHandlers,
5
+ } from "./constants";
6
+ export { createTenantLifecycleFeature } from "./feature";
7
+ export { resolveTenantLifecycleGate } from "./run-tenant-destroy";
@@ -0,0 +1,16 @@
1
+ import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
5
+ import { userSessionTable } from "../../sessions";
6
+
7
+ /** Revoke all live sessions scoped to a tenant (destruction-request gate). */
8
+ export async function revokeTenantSessions(db: DbRunner, tenantId: TenantId): Promise<number> {
9
+ const updated = await updateMany(
10
+ db,
11
+ userSessionTable,
12
+ { revokedAt: getTemporal().Now.instant() },
13
+ { tenantId, revokedAt: null },
14
+ );
15
+ return updated.length;
16
+ }
@@ -0,0 +1,357 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
+ import {
5
+ createSystemUser,
6
+ type Registry,
7
+ type TenantId,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ append,
11
+ loadAggregate,
12
+ VersionConflictError,
13
+ } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
15
+ import { tenantEntity, tenantTable } from "../tenant";
16
+ import {
17
+ TENANT_AGGREGATE_TYPE,
18
+ TENANT_DESTRUCTION_COMPLETED_EVENT_QN,
19
+ TENANT_DESTRUCTION_FAILED_EVENT_QN,
20
+ TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
21
+ TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT,
22
+ TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
23
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
24
+ TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
25
+ } from "./constants";
26
+ import { type DestructionStageCtx, isDestructionPipelineComplete, pickNextStage } from "./stages";
27
+
28
+ const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
29
+
30
+ const FEATURE_PREFIX = "tenant-lifecycle:event:";
31
+
32
+ type StageEventName =
33
+ | typeof TENANT_DESTRUCTION_STARTED_EVENT_SHORT
34
+ | typeof TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT
35
+ | typeof TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT
36
+ | typeof TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT
37
+ | typeof TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT;
38
+
39
+ function qualified(short: StageEventName): string {
40
+ return `${FEATURE_PREFIX}${short}`;
41
+ }
42
+
43
+ function replayStageState(
44
+ events: ReadonlyArray<{ type: string; payload: Record<string, unknown> }>,
45
+ ): {
46
+ completed: Set<string>;
47
+ abandoned: Set<string>;
48
+ attemptsByStage: Map<string, number>;
49
+ } {
50
+ const completed = new Set<string>();
51
+ const abandoned = new Set<string>();
52
+ const attemptsByStage = new Map<string, number>();
53
+ for (const event of events) {
54
+ const payload = event.payload;
55
+ const stage = String(payload["stage"] ?? "");
56
+ if (event.type === qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT)) {
57
+ completed.add(stage);
58
+ continue;
59
+ }
60
+ if (event.type === qualified(TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT)) {
61
+ abandoned.add(stage);
62
+ continue;
63
+ }
64
+ if (event.type === qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT)) {
65
+ const prev = attemptsByStage.get(stage) ?? 0;
66
+ attemptsByStage.set(stage, Math.max(prev, Number(payload["attempts"] ?? prev + 1)));
67
+ }
68
+ }
69
+ return { completed, abandoned, attemptsByStage };
70
+ }
71
+
72
+ function lastEventVersion(events: ReadonlyArray<{ version: number }>): number {
73
+ const last = events.at(-1);
74
+ return last?.version ?? 0;
75
+ }
76
+
77
+ function stageOutcomeRecorded(
78
+ events: ReadonlyArray<{ type: string; payload: Record<string, unknown> }>,
79
+ stageName: string,
80
+ outcomeShort:
81
+ | typeof TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT
82
+ | typeof TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT
83
+ | typeof TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
84
+ ): boolean {
85
+ return events.some(
86
+ (event) => event.type === qualified(outcomeShort) && event.payload["stage"] === stageName,
87
+ );
88
+ }
89
+
90
+ async function appendTenantStageEvent(
91
+ db: DbRunner,
92
+ tenantId: TenantId,
93
+ type: string,
94
+ payload: Record<string, unknown>,
95
+ expectedVersion: number,
96
+ ): Promise<void> {
97
+ await append(db, {
98
+ aggregateId: tenantId,
99
+ aggregateType: TENANT_AGGREGATE_TYPE,
100
+ tenantId,
101
+ expectedVersion,
102
+ type,
103
+ payload,
104
+ metadata: {
105
+ userId: "system",
106
+ requestId: `tenant-lifecycle:${type}`,
107
+ },
108
+ });
109
+ }
110
+
111
+ async function appendTenantStageEventIdempotent(
112
+ db: DbRunner,
113
+ tenantId: TenantId,
114
+ type: string,
115
+ payload: Record<string, unknown>,
116
+ expectedVersion: number,
117
+ alreadyRecorded: boolean,
118
+ ): Promise<number> {
119
+ if (alreadyRecorded) {
120
+ const events = await loadAggregate(db, tenantId, tenantId);
121
+ return lastEventVersion(events);
122
+ }
123
+ try {
124
+ await appendTenantStageEvent(db, tenantId, type, payload, expectedVersion);
125
+ } catch (err) {
126
+ if (!(err instanceof VersionConflictError)) throw err;
127
+ const events = await loadAggregate(db, tenantId, tenantId);
128
+ const stage = String(payload["stage"] ?? "");
129
+ if (stageOutcomeRecorded(events, stage, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT)) {
130
+ // skip: idempotent retry — stage already recorded before version conflict
131
+ return lastEventVersion(events);
132
+ }
133
+ throw err;
134
+ }
135
+ const events = await loadAggregate(db, tenantId, tenantId);
136
+ return lastEventVersion(events);
137
+ }
138
+
139
+ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promise<void> {
140
+ const user = createSystemUser(tenantId);
141
+ const scopedDb = createTenantDb(db, tenantId, "system");
142
+ await tenantCrud.update(
143
+ {
144
+ id: tenantId,
145
+ changes: { status: "destroyFailed" },
146
+ },
147
+ user,
148
+ scopedDb,
149
+ { skipOptimisticLock: true },
150
+ );
151
+ }
152
+
153
+ async function haltPipelineOnAbandon(args: {
154
+ readonly db: DbRunner;
155
+ readonly tenantId: TenantId;
156
+ readonly stage: string;
157
+ readonly attempts: number;
158
+ readonly error: string;
159
+ readonly expectedVersion: number;
160
+ }): Promise<void> {
161
+ const T = getTemporal();
162
+ const failedAt = T.Now.instant();
163
+ await appendTenantStageEvent(
164
+ args.db,
165
+ args.tenantId,
166
+ qualified(TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT),
167
+ { stage: args.stage, attempts: args.attempts, error: args.error },
168
+ args.expectedVersion,
169
+ );
170
+ const eventsAfterAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
171
+ const versionAfterAbandon = lastEventVersion(eventsAfterAbandon);
172
+ await appendTenantStageEvent(
173
+ args.db,
174
+ args.tenantId,
175
+ TENANT_DESTRUCTION_FAILED_EVENT_QN,
176
+ { stage: args.stage, error: args.error, failedAt: failedAt.toString() },
177
+ versionAfterAbandon,
178
+ );
179
+ await markTenantDestroyFailed(args.db, args.tenantId);
180
+ }
181
+
182
+ async function maybeAppendDestructionCompleted(
183
+ db: DbRunner,
184
+ tenantId: TenantId,
185
+ completed: ReadonlySet<string>,
186
+ ): Promise<void> {
187
+ if (!isDestructionPipelineComplete(completed)) {
188
+ // skip: pipeline still has pending stages
189
+ return;
190
+ }
191
+ const events = await loadAggregate(db, tenantId, tenantId);
192
+ if (events.some((event) => event.type === TENANT_DESTRUCTION_COMPLETED_EVENT_QN)) {
193
+ // skip: idempotent — completion event already recorded
194
+ return;
195
+ }
196
+ const T = getTemporal();
197
+ await appendTenantStageEvent(
198
+ db,
199
+ tenantId,
200
+ TENANT_DESTRUCTION_COMPLETED_EVENT_QN,
201
+ { destroyedAt: T.Now.instant().toString() },
202
+ lastEventVersion(events),
203
+ );
204
+ }
205
+
206
+ export async function runNextDestructionStage(args: {
207
+ readonly db: DbRunner;
208
+ readonly registry: Registry;
209
+ readonly tenantId: TenantId;
210
+ readonly log?: (message: string) => void;
211
+ }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
212
+ const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
213
+ const { completed, abandoned, attemptsByStage } = replayStageState(events);
214
+
215
+ if (abandoned.size > 0) {
216
+ return { done: false, error: "pipeline_abandoned", halted: true };
217
+ }
218
+
219
+ const next = pickNextStage(completed, abandoned);
220
+ if (!next) {
221
+ await maybeAppendDestructionCompleted(args.db, args.tenantId, completed);
222
+ return { done: isDestructionPipelineComplete(completed) };
223
+ }
224
+
225
+ const priorAttempts = attemptsByStage.get(next.name) ?? 0;
226
+ const attempt = priorAttempts + 1;
227
+ const ctx: DestructionStageCtx = {
228
+ db: args.db,
229
+ registry: args.registry,
230
+ tenantId: args.tenantId,
231
+ log: args.log,
232
+ };
233
+
234
+ let version = lastEventVersion(events);
235
+ version = await appendTenantStageEventIdempotent(
236
+ args.db,
237
+ args.tenantId,
238
+ qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
239
+ { stage: next.name, attempts: attempt },
240
+ version,
241
+ stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
242
+ );
243
+
244
+ try {
245
+ await next.run(ctx);
246
+ version = await appendTenantStageEventIdempotent(
247
+ args.db,
248
+ args.tenantId,
249
+ qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
250
+ { stage: next.name, attempts: attempt },
251
+ version,
252
+ stageOutcomeRecorded(
253
+ await loadAggregate(args.db, args.tenantId, args.tenantId),
254
+ next.name,
255
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
256
+ ),
257
+ );
258
+
259
+ const completedAfter = new Set([...completed, next.name]);
260
+ await maybeAppendDestructionCompleted(args.db, args.tenantId, completedAfter);
261
+ return { done: isDestructionPipelineComplete(completedAfter) };
262
+ } catch (err) {
263
+ const message = err instanceof Error ? err.message : String(err);
264
+ const isFinal = attempt >= next.maxAttempts;
265
+ if (isFinal) {
266
+ await haltPipelineOnAbandon({
267
+ db: args.db,
268
+ tenantId: args.tenantId,
269
+ stage: next.name,
270
+ attempts: attempt,
271
+ error: message,
272
+ expectedVersion: version,
273
+ });
274
+ return { done: false, error: message, halted: true };
275
+ }
276
+ await appendTenantStageEvent(
277
+ args.db,
278
+ args.tenantId,
279
+ qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
280
+ { stage: next.name, attempts: attempt, error: message },
281
+ version,
282
+ );
283
+ return { done: false, error: message };
284
+ }
285
+ }
286
+
287
+ /** Cron entry: start destruction for tenants past grace, then advance stages. */
288
+ export async function runTenantDestructionSweep(args: {
289
+ readonly db: DbRunner;
290
+ readonly registry: Registry;
291
+ readonly now?: Temporal.Instant;
292
+ }): Promise<{ readonly triggered: number; readonly advanced: number }> {
293
+ const T = getTemporal();
294
+ const now = args.now ?? T.Now.instant();
295
+ const due = await selectMany<{ id: string }>(args.db, tenantTable, {
296
+ status: "destroyRequested",
297
+ gracePeriodEnd: { lte: now },
298
+ });
299
+
300
+ let triggered = 0;
301
+ for (const row of due) {
302
+ const tenantId = row.id as TenantId;
303
+ const events = await loadAggregate(args.db, tenantId, tenantId);
304
+ const version = lastEventVersion(events);
305
+ await appendTenantStageEvent(
306
+ args.db,
307
+ tenantId,
308
+ qualified(TENANT_DESTRUCTION_STARTED_EVENT_SHORT),
309
+ { startedAt: now.toString() },
310
+ version,
311
+ );
312
+ const user = createSystemUser(tenantId);
313
+ const scopedDb = createTenantDb(args.db, tenantId, "system");
314
+ await tenantCrud.update(
315
+ {
316
+ id: tenantId,
317
+ changes: { status: "destroying", destroyStartedAt: now },
318
+ },
319
+ user,
320
+ scopedDb,
321
+ { skipOptimisticLock: true },
322
+ );
323
+ triggered++;
324
+ }
325
+
326
+ let advanced = 0;
327
+ const destroying = await selectMany<{ id: string }>(args.db, tenantTable, {
328
+ status: "destroying",
329
+ });
330
+ for (const row of destroying) {
331
+ const result = await runNextDestructionStage({
332
+ db: args.db,
333
+ registry: args.registry,
334
+ tenantId: row.id as TenantId,
335
+ });
336
+ if (!result.error && !result.halted) advanced++;
337
+ }
338
+
339
+ return { triggered, advanced };
340
+ }
341
+
342
+ export async function resolveTenantLifecycleGate(
343
+ db: DbRunner,
344
+ tenantId: TenantId,
345
+ ): Promise<{ status: string; gracePeriodEnd: string | null } | null> {
346
+ const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
347
+ db,
348
+ tenantTable,
349
+ { id: tenantId },
350
+ );
351
+ const row = rows[0];
352
+ if (!row) return null;
353
+ return {
354
+ status: row.status,
355
+ gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
356
+ };
357
+ }