@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.
- package/package.json +7 -6
- package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
- package/src/audit/i18n.ts +9 -0
- package/src/audit/web/audit-log-screen.tsx +112 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
- package/src/billing-foundation/entities.ts +28 -4
- package/src/billing-foundation/events.ts +6 -2
- package/src/billing-foundation/feature.ts +9 -1
- package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
- package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
- package/src/billing-foundation/handlers/process-event.write.ts +39 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
- package/src/cap-counter/entity.ts +1 -1
- package/src/cap-counter/feature.ts +10 -2
- package/src/cap-counter/i18n.ts +12 -0
- package/src/compliance-profiles/feature.ts +3 -0
- package/src/compliance-profiles/i18n.ts +6 -0
- package/src/config/feature.ts +3 -0
- package/src/config/i18n.ts +9 -0
- package/src/delivery/feature.ts +3 -0
- package/src/delivery/i18n.ts +6 -0
- package/src/feature-toggles/feature.ts +3 -0
- package/src/feature-toggles/i18n.ts +6 -0
- package/src/jobs/i18n.ts +4 -0
- package/src/jobs/web/job-runs-screen.tsx +9 -9
- package/src/managed-pages/i18n.ts +5 -0
- package/src/managed-pages/screens/page-screens.ts +5 -1
- package/src/personal-access-tokens/feature.ts +2 -0
- package/src/personal-access-tokens/i18n.ts +5 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
- package/src/subscription-stripe/feature.ts +2 -0
- package/src/tags/feature.ts +2 -0
- package/src/tags/i18n.ts +5 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
- package/src/tenant/feature.ts +4 -1
- package/src/tenant/handlers/members.query.ts +19 -4
- package/src/tenant/i18n.ts +28 -0
- package/src/tenant/index.ts +7 -2
- package/src/tenant/schema/tenant.ts +27 -2
- package/src/tenant/screens.ts +3 -2
- package/src/tenant/web/members-screen.tsx +8 -3
- package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
- package/src/tenant-lifecycle/constants.ts +56 -0
- package/src/tenant-lifecycle/events.ts +46 -0
- package/src/tenant-lifecycle/feature.ts +105 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
- package/src/tenant-lifecycle/index.ts +7 -0
- package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
- package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
- package/src/tenant-lifecycle/stages.ts +206 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/i18n.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
- package/src/user/feature.ts +3 -0
- package/src/user/i18n.ts +20 -0
- package/src/user/schema/user.ts +1 -0
- package/src/user/screens.ts +3 -15
- package/src/user-data-rights/feature.ts +17 -0
- package/src/user-data-rights/i18n.ts +32 -33
- package/src/user-data-rights/schema/export-job.ts +2 -0
- package/src/user-data-rights/screens.ts +2 -1
- 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,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";
|