@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.127.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.
Files changed (69) hide show
  1. package/package.json +7 -6
  2. package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
  3. package/src/audit/i18n.ts +9 -0
  4. package/src/audit/web/audit-log-screen.tsx +112 -2
  5. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
  6. package/src/billing-foundation/entities.ts +28 -4
  7. package/src/billing-foundation/events.ts +6 -2
  8. package/src/billing-foundation/feature.ts +9 -1
  9. package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
  10. package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
  11. package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
  12. package/src/billing-foundation/handlers/process-event.write.ts +39 -3
  13. package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
  14. package/src/cap-counter/entity.ts +1 -1
  15. package/src/cap-counter/feature.ts +10 -2
  16. package/src/cap-counter/i18n.ts +12 -0
  17. package/src/compliance-profiles/feature.ts +3 -0
  18. package/src/compliance-profiles/i18n.ts +6 -0
  19. package/src/config/feature.ts +3 -0
  20. package/src/config/i18n.ts +9 -0
  21. package/src/delivery/feature.ts +3 -0
  22. package/src/delivery/i18n.ts +6 -0
  23. package/src/feature-toggles/feature.ts +3 -0
  24. package/src/feature-toggles/i18n.ts +6 -0
  25. package/src/jobs/i18n.ts +4 -0
  26. package/src/jobs/web/job-runs-screen.tsx +9 -9
  27. package/src/managed-pages/i18n.ts +5 -0
  28. package/src/managed-pages/screens/page-screens.ts +5 -1
  29. package/src/personal-access-tokens/feature.ts +2 -0
  30. package/src/personal-access-tokens/i18n.ts +5 -0
  31. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
  32. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
  33. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
  34. package/src/subscription-stripe/feature.ts +2 -0
  35. package/src/tags/feature.ts +2 -0
  36. package/src/tags/i18n.ts +5 -0
  37. package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
  38. package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
  39. package/src/tenant/feature.ts +4 -1
  40. package/src/tenant/handlers/members.query.ts +19 -4
  41. package/src/tenant/i18n.ts +28 -0
  42. package/src/tenant/index.ts +7 -2
  43. package/src/tenant/schema/tenant.ts +27 -2
  44. package/src/tenant/screens.ts +3 -2
  45. package/src/tenant/web/members-screen.tsx +8 -3
  46. package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
  47. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
  48. package/src/tenant-lifecycle/constants.ts +56 -0
  49. package/src/tenant-lifecycle/events.ts +46 -0
  50. package/src/tenant-lifecycle/feature.ts +105 -0
  51. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
  52. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
  53. package/src/tenant-lifecycle/index.ts +7 -0
  54. package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
  55. package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
  56. package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
  57. package/src/tenant-lifecycle/stages.ts +206 -0
  58. package/src/tier-engine/feature.ts +2 -0
  59. package/src/tier-engine/i18n.ts +8 -0
  60. package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
  61. package/src/user/feature.ts +3 -0
  62. package/src/user/i18n.ts +20 -0
  63. package/src/user/schema/user.ts +1 -0
  64. package/src/user/screens.ts +3 -15
  65. package/src/user-data-rights/feature.ts +17 -0
  66. package/src/user-data-rights/i18n.ts +32 -33
  67. package/src/user-data-rights/schema/export-job.ts +2 -0
  68. package/src/user-data-rights/screens.ts +2 -1
  69. package/src/user-data-rights/web/i18n.ts +9 -0
@@ -0,0 +1,81 @@
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
+ import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
10
+
11
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
12
+
13
+ type TenantLifecycleRow = {
14
+ status: TenantLifecycleStatus;
15
+ gracePeriodEnd: Temporal.Instant | null;
16
+ };
17
+
18
+ export const cancelDestructionWrite = defineWriteHandler({
19
+ name: "cancel-destruction",
20
+ schema: z.object({}),
21
+ access: { roles: ["TenantOwner", "Admin"] },
22
+ handler: async (event, ctx) => {
23
+ const tenantId = event.user.tenantId;
24
+ const row = await fetchOne<TenantLifecycleRow>(ctx.db.raw, tenantTable, { id: tenantId });
25
+ if (!row) {
26
+ return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
27
+ }
28
+ if (row.status !== "destroyRequested") {
29
+ return writeFailure(
30
+ new UnprocessableError("no_pending_destruction", {
31
+ details: { status: row.status },
32
+ }),
33
+ );
34
+ }
35
+
36
+ const gracePeriodEnd = row.gracePeriodEnd;
37
+ const inGrace =
38
+ gracePeriodEnd != null &&
39
+ Temporal.Instant.compare(gracePeriodEnd, getTemporal().Now.instant()) > 0;
40
+ if (!inGrace) {
41
+ return writeFailure(
42
+ new UnprocessableError("grace_period_expired", {
43
+ details: { reason: "grace_period_expired" },
44
+ }),
45
+ );
46
+ }
47
+
48
+ const update = await crud.update(
49
+ {
50
+ id: tenantId,
51
+ changes: {
52
+ status: "active",
53
+ destroyRequestedAt: null,
54
+ destroyRequestedBy: null,
55
+ gracePeriodEnd: null,
56
+ },
57
+ },
58
+ event.user,
59
+ ctx.db,
60
+ { skipOptimisticLock: true },
61
+ );
62
+ if (!update.isSuccess) return update;
63
+ invalidateTenantLifecycleGate(tenantId);
64
+
65
+ await ctx.unsafeAppendEvent({
66
+ aggregateId: tenantId,
67
+ aggregateType: "tenant",
68
+ type: DESTRUCTION_CANCELLED_EVENT_QN,
69
+ payload: { cancelledBy: event.user.id },
70
+ });
71
+
72
+ return {
73
+ isSuccess: true as const,
74
+ data: {
75
+ tenantId,
76
+ status: "active" as const,
77
+ gracePeriodEnd: null as string | null,
78
+ },
79
+ };
80
+ },
81
+ });
@@ -0,0 +1,83 @@
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
+ import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
13
+
14
+ const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
15
+
16
+ type TenantLifecycleRow = {
17
+ status: TenantLifecycleStatus;
18
+ gracePeriodEnd: Temporal.Instant | null;
19
+ };
20
+
21
+ export const requestDestructionWrite = defineWriteHandler({
22
+ name: "request-destruction",
23
+ schema: z.object({}),
24
+ access: { roles: ["TenantOwner", "Admin"] },
25
+ handler: async (event, ctx) => {
26
+ const tenantId = event.user.tenantId;
27
+ const row = await fetchOne<TenantLifecycleRow>(ctx.db.raw, tenantTable, { id: tenantId });
28
+ if (!row) {
29
+ return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
30
+ }
31
+ if (row.status !== "active") {
32
+ return writeFailure(
33
+ new UnprocessableError("tenant_not_active", {
34
+ details: { status: row.status },
35
+ }),
36
+ );
37
+ }
38
+
39
+ const { profile } = await resolveProfileForTenant({ db: ctx.db.raw, tenantId });
40
+ const T = getTemporal();
41
+ const gracePeriodEnd = addDurationSpec(T.Now.instant(), profile.tenantDestroyGracePeriod);
42
+
43
+ const update = await crud.update(
44
+ {
45
+ id: tenantId,
46
+ changes: {
47
+ status: "destroyRequested",
48
+ destroyRequestedAt: T.Now.instant(),
49
+ destroyRequestedBy: event.user.id,
50
+ gracePeriodEnd,
51
+ },
52
+ },
53
+ event.user,
54
+ ctx.db,
55
+ { skipOptimisticLock: true },
56
+ );
57
+ if (!update.isSuccess) return update;
58
+ invalidateTenantLifecycleGate(tenantId);
59
+
60
+ await ctx.unsafeAppendEvent({
61
+ aggregateId: tenantId,
62
+ aggregateType: "tenant",
63
+ type: DESTRUCTION_REQUESTED_EVENT_QN,
64
+ payload: {
65
+ requestedBy: event.user.id,
66
+ gracePeriodEnd: gracePeriodEnd.toString(),
67
+ },
68
+ });
69
+
70
+ if (ctx.hasFeature("sessions")) {
71
+ await revokeTenantSessions(ctx.db.raw, tenantId);
72
+ }
73
+
74
+ return {
75
+ isSuccess: true as const,
76
+ data: {
77
+ tenantId,
78
+ status: "destroyRequested" as const,
79
+ gracePeriodEnd: gracePeriodEnd.toString(),
80
+ },
81
+ };
82
+ },
83
+ });
@@ -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,58 @@
1
+ import { selectMany } 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 { tenantTable } from "../tenant";
5
+
6
+ export type TenantLifecycleGate = {
7
+ readonly status: string;
8
+ readonly gracePeriodEnd: string | null;
9
+ };
10
+
11
+ // Every authenticated + anonymous request consults this (via auth-middleware's
12
+ // resolveTenantLifecycleStatus) — a per-request DB round-trip otherwise. No
13
+ // TTL: status must be observable the instant it changes (a stale "active"
14
+ // read after tombstoning would let a request slip through the 410 gate), so
15
+ // this is invalidated explicitly by every status-changing write —
16
+ // request/cancel-destruction, the sweep, and tombstoneTenantRow all call
17
+ // invalidateTenantLifecycleGate(tenantId) right after their update succeeds.
18
+ // Split into its own module (not run-tenant-destroy.ts) so stages.ts can
19
+ // invalidate too without a stages.ts <-> run-tenant-destroy.ts import cycle.
20
+ // ponytail: unbounded Map — fine while tenant counts stay in the thousands (a
21
+ // few bytes/entry); swap for a bounded LRU if that ever changes.
22
+ const gateCache = new Map<TenantId, TenantLifecycleGate | null>();
23
+
24
+ export function invalidateTenantLifecycleGate(tenantId: TenantId): void {
25
+ gateCache.delete(tenantId);
26
+ }
27
+
28
+ /** @internal test-only — resetTestTables/resetTestTable wipe rows directly
29
+ * via SQL, bypassing the write handlers that invalidate normally. Tests
30
+ * that reset tenantTable (and reuse a fixed tenantId across cases) must
31
+ * call this in beforeEach or a later test can read an earlier test's
32
+ * cached status for the same tenantId. */
33
+ export function resetTenantLifecycleGateCacheForTests(): void {
34
+ gateCache.clear();
35
+ }
36
+
37
+ export async function resolveTenantLifecycleGate(
38
+ db: DbRunner,
39
+ tenantId: TenantId,
40
+ ): Promise<TenantLifecycleGate | null> {
41
+ if (gateCache.has(tenantId)) return gateCache.get(tenantId) ?? null;
42
+ const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
43
+ db,
44
+ tenantTable,
45
+ { id: tenantId },
46
+ );
47
+ const row = rows[0];
48
+ if (!row) {
49
+ gateCache.set(tenantId, null);
50
+ return null;
51
+ }
52
+ const gate: TenantLifecycleGate = {
53
+ status: row.status,
54
+ gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
55
+ };
56
+ gateCache.set(tenantId, gate);
57
+ return gate;
58
+ }
@@ -0,0 +1,368 @@
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 { invalidateTenantLifecycleGate } from "./lifecycle-gate";
27
+ import { type DestructionStageCtx, isDestructionPipelineComplete, pickNextStage } from "./stages";
28
+
29
+ const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
30
+
31
+ const FEATURE_PREFIX = "tenant-lifecycle:event:";
32
+
33
+ type StageEventName =
34
+ | typeof TENANT_DESTRUCTION_STARTED_EVENT_SHORT
35
+ | typeof TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT
36
+ | typeof TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT
37
+ | typeof TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT
38
+ | typeof TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT;
39
+
40
+ function qualified(short: StageEventName): string {
41
+ return `${FEATURE_PREFIX}${short}`;
42
+ }
43
+
44
+ function replayStageState(
45
+ events: ReadonlyArray<{ type: string; payload: Record<string, unknown> }>,
46
+ ): {
47
+ completed: Set<string>;
48
+ abandoned: Set<string>;
49
+ attemptsByStage: Map<string, number>;
50
+ } {
51
+ const completed = new Set<string>();
52
+ const abandoned = new Set<string>();
53
+ const attemptsByStage = new Map<string, number>();
54
+ for (const event of events) {
55
+ const payload = event.payload;
56
+ const stage = String(payload["stage"] ?? "");
57
+ if (event.type === qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT)) {
58
+ completed.add(stage);
59
+ continue;
60
+ }
61
+ if (event.type === qualified(TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT)) {
62
+ abandoned.add(stage);
63
+ continue;
64
+ }
65
+ if (event.type === qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT)) {
66
+ const prev = attemptsByStage.get(stage) ?? 0;
67
+ attemptsByStage.set(stage, Math.max(prev, Number(payload["attempts"] ?? prev + 1)));
68
+ }
69
+ }
70
+ return { completed, abandoned, attemptsByStage };
71
+ }
72
+
73
+ function lastEventVersion(events: ReadonlyArray<{ version: number }>): number {
74
+ const last = events.at(-1);
75
+ return last?.version ?? 0;
76
+ }
77
+
78
+ function stageOutcomeRecorded(
79
+ events: ReadonlyArray<{ type: string; payload: Record<string, unknown> }>,
80
+ stageName: string,
81
+ outcomeShort:
82
+ | typeof TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT
83
+ | typeof TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT
84
+ | typeof TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
85
+ ): boolean {
86
+ return events.some(
87
+ (event) => event.type === qualified(outcomeShort) && event.payload["stage"] === stageName,
88
+ );
89
+ }
90
+
91
+ async function appendTenantStageEvent(
92
+ db: DbRunner,
93
+ tenantId: TenantId,
94
+ type: string,
95
+ payload: Record<string, unknown>,
96
+ expectedVersion: number,
97
+ ): Promise<void> {
98
+ await append(db, {
99
+ aggregateId: tenantId,
100
+ aggregateType: TENANT_AGGREGATE_TYPE,
101
+ tenantId,
102
+ expectedVersion,
103
+ type,
104
+ payload,
105
+ metadata: {
106
+ userId: "system",
107
+ requestId: `tenant-lifecycle:${type}`,
108
+ },
109
+ });
110
+ }
111
+
112
+ async function appendTenantStageEventIdempotent(
113
+ db: DbRunner,
114
+ tenantId: TenantId,
115
+ type: string,
116
+ payload: Record<string, unknown>,
117
+ expectedVersion: number,
118
+ alreadyRecorded: boolean,
119
+ ): Promise<number> {
120
+ if (alreadyRecorded) {
121
+ const events = await loadAggregate(db, tenantId, tenantId);
122
+ return lastEventVersion(events);
123
+ }
124
+ try {
125
+ await appendTenantStageEvent(db, tenantId, type, payload, expectedVersion);
126
+ } catch (err) {
127
+ if (!(err instanceof VersionConflictError)) throw err;
128
+ const events = await loadAggregate(db, tenantId, tenantId);
129
+ const stage = String(payload["stage"] ?? "");
130
+ if (stageOutcomeRecorded(events, stage, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT)) {
131
+ // skip: idempotent retry — stage already recorded before version conflict
132
+ return lastEventVersion(events);
133
+ }
134
+ throw err;
135
+ }
136
+ const events = await loadAggregate(db, tenantId, tenantId);
137
+ return lastEventVersion(events);
138
+ }
139
+
140
+ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promise<void> {
141
+ const user = createSystemUser(tenantId);
142
+ const scopedDb = createTenantDb(db, tenantId, "system");
143
+ await tenantCrud.update(
144
+ {
145
+ id: tenantId,
146
+ changes: { status: "destroyFailed" },
147
+ },
148
+ user,
149
+ scopedDb,
150
+ { skipOptimisticLock: true },
151
+ );
152
+ invalidateTenantLifecycleGate(tenantId);
153
+ }
154
+
155
+ async function haltPipelineOnAbandon(args: {
156
+ readonly db: DbRunner;
157
+ readonly tenantId: TenantId;
158
+ readonly stage: string;
159
+ readonly attempts: number;
160
+ readonly error: string;
161
+ }): Promise<void> {
162
+ const T = getTemporal();
163
+ const failedAt = T.Now.instant();
164
+ // Reload fresh instead of trusting a caller-supplied version: the failed
165
+ // stage's own run() may have appended to this same aggregate (e.g.
166
+ // tombstoneTenantRow's tenantCrud.update) before throwing, so any version
167
+ // captured earlier in the pipeline is stale and would VersionConflict here.
168
+ const eventsBeforeAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
169
+ await appendTenantStageEvent(
170
+ args.db,
171
+ args.tenantId,
172
+ qualified(TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT),
173
+ { stage: args.stage, attempts: args.attempts, error: args.error },
174
+ lastEventVersion(eventsBeforeAbandon),
175
+ );
176
+ const eventsAfterAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
177
+ const versionAfterAbandon = lastEventVersion(eventsAfterAbandon);
178
+ await appendTenantStageEvent(
179
+ args.db,
180
+ args.tenantId,
181
+ TENANT_DESTRUCTION_FAILED_EVENT_QN,
182
+ { stage: args.stage, error: args.error, failedAt: failedAt.toString() },
183
+ versionAfterAbandon,
184
+ );
185
+ await markTenantDestroyFailed(args.db, args.tenantId);
186
+ }
187
+
188
+ async function maybeAppendDestructionCompleted(
189
+ db: DbRunner,
190
+ tenantId: TenantId,
191
+ completed: ReadonlySet<string>,
192
+ ): Promise<void> {
193
+ if (!isDestructionPipelineComplete(completed)) {
194
+ // skip: pipeline still has pending stages
195
+ return;
196
+ }
197
+ const events = await loadAggregate(db, tenantId, tenantId);
198
+ if (events.some((event) => event.type === TENANT_DESTRUCTION_COMPLETED_EVENT_QN)) {
199
+ // skip: idempotent — completion event already recorded
200
+ return;
201
+ }
202
+ const T = getTemporal();
203
+ await appendTenantStageEvent(
204
+ db,
205
+ tenantId,
206
+ TENANT_DESTRUCTION_COMPLETED_EVENT_QN,
207
+ { destroyedAt: T.Now.instant().toString() },
208
+ lastEventVersion(events),
209
+ );
210
+ }
211
+
212
+ export async function runNextDestructionStage(args: {
213
+ readonly db: DbRunner;
214
+ readonly registry: Registry;
215
+ readonly tenantId: TenantId;
216
+ readonly log?: (message: string) => void;
217
+ }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
218
+ const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
219
+ const { completed, abandoned, attemptsByStage } = replayStageState(events);
220
+
221
+ if (abandoned.size > 0) {
222
+ return { done: false, error: "pipeline_abandoned", halted: true };
223
+ }
224
+
225
+ const next = pickNextStage(completed, abandoned);
226
+ if (!next) {
227
+ await maybeAppendDestructionCompleted(args.db, args.tenantId, completed);
228
+ return { done: isDestructionPipelineComplete(completed) };
229
+ }
230
+
231
+ const priorAttempts = attemptsByStage.get(next.name) ?? 0;
232
+ const attempt = priorAttempts + 1;
233
+ const ctx: DestructionStageCtx = {
234
+ db: args.db,
235
+ registry: args.registry,
236
+ tenantId: args.tenantId,
237
+ log: args.log,
238
+ };
239
+
240
+ let version = lastEventVersion(events);
241
+ version = await appendTenantStageEventIdempotent(
242
+ args.db,
243
+ args.tenantId,
244
+ qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
245
+ { stage: next.name, attempts: attempt },
246
+ version,
247
+ stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
248
+ );
249
+
250
+ try {
251
+ await next.run(ctx);
252
+ // next.run() may itself have appended events to this aggregate (e.g.
253
+ // tombstoneTenantRow updates the tenant row on the same stream) — reload
254
+ // the version fresh instead of trusting the pre-run `version`, or this
255
+ // append throws VersionConflictError on every stage whose run() writes.
256
+ const eventsAfterRun = await loadAggregate(args.db, args.tenantId, args.tenantId);
257
+ version = await appendTenantStageEventIdempotent(
258
+ args.db,
259
+ args.tenantId,
260
+ qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
261
+ { stage: next.name, attempts: attempt },
262
+ lastEventVersion(eventsAfterRun),
263
+ stageOutcomeRecorded(
264
+ eventsAfterRun,
265
+ next.name,
266
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
267
+ ),
268
+ );
269
+
270
+ const completedAfter = new Set([...completed, next.name]);
271
+ await maybeAppendDestructionCompleted(args.db, args.tenantId, completedAfter);
272
+ return { done: isDestructionPipelineComplete(completedAfter) };
273
+ } catch (err) {
274
+ const message = err instanceof Error ? err.message : String(err);
275
+ const isFinal = attempt >= next.maxAttempts;
276
+ if (isFinal) {
277
+ await haltPipelineOnAbandon({
278
+ db: args.db,
279
+ tenantId: args.tenantId,
280
+ stage: next.name,
281
+ attempts: attempt,
282
+ error: message,
283
+ });
284
+ return { done: false, error: message, halted: true };
285
+ }
286
+ await appendTenantStageEvent(
287
+ args.db,
288
+ args.tenantId,
289
+ qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
290
+ { stage: next.name, attempts: attempt, error: message },
291
+ version,
292
+ );
293
+ return { done: false, error: message };
294
+ }
295
+ }
296
+
297
+ /** Cron entry: start destruction for tenants past grace, then advance stages. */
298
+ export async function runTenantDestructionSweep(args: {
299
+ readonly db: DbRunner;
300
+ readonly registry: Registry;
301
+ readonly now?: Temporal.Instant;
302
+ readonly log?: (message: string) => void;
303
+ }): Promise<{ readonly triggered: number; readonly advanced: number }> {
304
+ const T = getTemporal();
305
+ const now = args.now ?? T.Now.instant();
306
+ const due = await selectMany<{ id: string }>(args.db, tenantTable, {
307
+ status: "destroyRequested",
308
+ gracePeriodEnd: { lte: now },
309
+ });
310
+
311
+ let triggered = 0;
312
+ for (const row of due) {
313
+ const tenantId = row.id as TenantId;
314
+ const events = await loadAggregate(args.db, tenantId, tenantId);
315
+ const version = lastEventVersion(events);
316
+ const user = createSystemUser(tenantId);
317
+ const scopedDb = createTenantDb(args.db, tenantId, "system");
318
+ // Version-guarded (no skipOptimisticLock): a concurrent cancel-destruction
319
+ // between the `due` select above and this write also appends to the same
320
+ // tenant stream, so an unguarded overwrite here would silently revert the
321
+ // cancellation. A version conflict means we lost that race — skip, the
322
+ // tenant is no longer due and the next tick re-evaluates it.
323
+ const result = await tenantCrud.update(
324
+ { id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } },
325
+ user,
326
+ scopedDb,
327
+ );
328
+ if (!result.isSuccess) continue;
329
+ invalidateTenantLifecycleGate(tenantId);
330
+ await appendTenantStageEvent(
331
+ args.db,
332
+ tenantId,
333
+ qualified(TENANT_DESTRUCTION_STARTED_EVENT_SHORT),
334
+ { startedAt: now.toString() },
335
+ version + 1,
336
+ );
337
+ triggered++;
338
+ }
339
+
340
+ let advanced = 0;
341
+ const destroying = await selectMany<{ id: string }>(args.db, tenantTable, {
342
+ status: "destroying",
343
+ });
344
+ for (const row of destroying) {
345
+ try {
346
+ const result = await runNextDestructionStage({
347
+ db: args.db,
348
+ registry: args.registry,
349
+ tenantId: row.id as TenantId,
350
+ log: args.log,
351
+ });
352
+ if (!result.error && !result.halted) advanced++;
353
+ } catch (err) {
354
+ // Isolate the failure to this tenant — an uncaught throw here must not
355
+ // abort the whole sweep tick and starve every other "destroying" tenant
356
+ // of progress. The tenant stays "destroying" and the next tick retries.
357
+ args.log?.(
358
+ `[tenant-lifecycle] sweep: unexpected error advancing tenant ${row.id}: ${
359
+ err instanceof Error ? err.message : String(err)
360
+ }`,
361
+ );
362
+ }
363
+ }
364
+
365
+ return { triggered, advanced };
366
+ }
367
+
368
+ export { invalidateTenantLifecycleGate, resolveTenantLifecycleGate } from "./lifecycle-gate";