@cosmicdrift/kumiko-bundled-features 0.353.0 → 0.354.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 (181) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +9 -3
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/tool-dispatch.js +1 -1
  9. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  10. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  11. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  12. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  13. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  14. package/dist/auth-email-password/handlers/login.write.js +1 -0
  15. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  16. package/dist/auth-email-password/handlers/signup-confirm.write.js +4 -1
  17. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  18. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  19. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +2 -0
  22. package/dist/auth-email-password/seeding.d.ts +2 -0
  23. package/dist/auth-email-password/seeding.js +30 -8
  24. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  25. package/dist/auth-email-password/signup-activation.js +41 -0
  26. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  27. package/dist/auth-email-password/signup-token-store.js +8 -3
  28. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  29. package/dist/auth-email-password/web/auth-gate.js +5 -5
  30. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  31. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  32. package/dist/auth-email-password/web/login-screen.js +2 -2
  33. package/dist/auth-email-password/web/user-menu.js +1 -1
  34. package/dist/auth-mfa/feature.js +5 -0
  35. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  36. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  37. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  38. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  39. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  40. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  41. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  42. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  43. package/dist/channel-in-app/in-app-channel.js +1 -1
  44. package/dist/channel-in-app/tables.js +4 -2
  45. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  46. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  47. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  48. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  49. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  50. package/dist/delivery/delivery-service.js +60 -12
  51. package/dist/delivery/unsubscribe.js +66 -14
  52. package/dist/feature-toggles/feature.js +2 -3
  53. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  54. package/dist/feature-toggles/toggle-runtime.js +30 -6
  55. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  56. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  57. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  58. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  59. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  60. package/dist/files-tenant-data/hooks.js +58 -22
  61. package/dist/folders/handlers/delete-folder.write.js +31 -4
  62. package/dist/foundation-shared/index.d.ts +1 -1
  63. package/dist/foundation-shared/index.js +1 -1
  64. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  65. package/dist/foundation-shared/mail-host-policy.js +13 -4
  66. package/dist/inbound-provider-imap/feature.js +2 -0
  67. package/dist/inbound-provider-imap/imap-client.js +3 -4
  68. package/dist/jobs/constants.d.ts +1 -0
  69. package/dist/jobs/constants.js +1 -0
  70. package/dist/jobs/db/queries/retention.d.ts +1 -0
  71. package/dist/jobs/db/queries/retention.js +5 -0
  72. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  73. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  74. package/dist/jobs/feature.js +7 -1
  75. package/dist/jobs/handlers/list.query.js +1 -1
  76. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  77. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  78. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  79. package/dist/jobs/index.d.ts +2 -0
  80. package/dist/jobs/index.js +1 -0
  81. package/dist/jobs/job-run-logger.d.ts +2 -2
  82. package/dist/jobs/job-run-logger.js +98 -9
  83. package/dist/jobs/tenant-job-failure-table.js +15 -2
  84. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  85. package/dist/jobs/tenant-job-run-table.js +34 -0
  86. package/dist/ledger/entity.d.ts +3 -0
  87. package/dist/ledger/entity.js +13 -5
  88. package/dist/ledger/feature.js +3 -0
  89. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  90. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  91. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  92. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  93. package/dist/ledger/schedule-subject-pair.js +24 -0
  94. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  95. package/dist/mail-transport-smtp/feature.js +4 -13
  96. package/dist/notes-history/entity.js +4 -0
  97. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  98. package/dist/sessions/feature.d.ts +1 -0
  99. package/dist/sessions/feature.js +9 -8
  100. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  101. package/dist/sessions/handlers/detail.query.js +2 -2
  102. package/dist/sessions/handlers/list.query.d.ts +1 -1
  103. package/dist/sessions/handlers/list.query.js +5 -3
  104. package/dist/sessions/screens.d.ts +2 -2
  105. package/dist/sessions/screens.js +6 -7
  106. package/dist/shared/encrypt-for-direct-write.js +10 -2
  107. package/dist/shared/index.d.ts +2 -2
  108. package/dist/shared/index.js +2 -2
  109. package/dist/shared/parent-visibility.d.ts +2 -1
  110. package/dist/shared/parent-visibility.js +21 -0
  111. package/dist/shared/row-bound-grant.js +16 -2
  112. package/dist/shared/session-locale-field.d.ts +1 -0
  113. package/dist/shared/session-locale-field.js +6 -0
  114. package/dist/template-resolver/api.d.ts +2 -2
  115. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  116. package/dist/template-resolver/handlers/list.query.js +2 -2
  117. package/dist/template-resolver/table.d.ts +8 -0
  118. package/dist/template-resolver/user-content-table.d.ts +4 -0
  119. package/dist/tenant/feature.js +7 -1
  120. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  121. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +40 -0
  123. package/dist/tenant/invitation-table.js +6 -1
  124. package/dist/tenant/invited-membership.d.ts +8 -6
  125. package/dist/tenant/invited-membership.js +28 -7
  126. package/dist/tenant/schema/tenant.d.ts +2 -0
  127. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  128. package/dist/tenant-handover/transfer-graph.js +8 -75
  129. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  130. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  131. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  132. package/dist/tenant-lifecycle/constants.js +5 -0
  133. package/dist/tenant-lifecycle/events.d.ts +5 -0
  134. package/dist/tenant-lifecycle/events.js +5 -0
  135. package/dist/tenant-lifecycle/feature.js +5 -3
  136. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
  137. package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
  138. package/dist/tenant-lifecycle/stages.d.ts +6 -1
  139. package/dist/tenant-lifecycle/stages.js +26 -3
  140. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  141. package/dist/tenant-lifecycle/testing.js +42 -0
  142. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  143. package/dist/user/handlers/detail.query.js +2 -0
  144. package/dist/user/handlers/list.query.js +4 -0
  145. package/dist/user/handlers/update.write.js +1 -0
  146. package/dist/user/schema/user.d.ts +2 -0
  147. package/dist/user/seeding.d.ts +1 -0
  148. package/dist/user/seeding.js +20 -0
  149. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  150. package/dist/user-data-rights/index.d.ts +1 -0
  151. package/dist/user-data-rights/index.js +1 -0
  152. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  153. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  154. package/dist/user-profile/handlers/change-email.write.js +8 -2
  155. package/dist/waitlist/entity.d.ts +2 -0
  156. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  157. package/dist/workflow-runner/event-trigger.js +60 -15
  158. package/dist/workflow-runner/handlers/resume-run.write.js +5 -2
  159. package/dist/workflow-runner/runner.d.ts +2 -1
  160. package/dist/workflow-runner/runner.js +5 -1
  161. package/package.json +35 -10
  162. package/src/auth-email-password/changes.json +24 -0
  163. package/src/auth-mfa/changes.json +7 -0
  164. package/src/billing-foundation/changes.json +6 -0
  165. package/src/channel-in-app/changes.json +8 -1
  166. package/src/crypto-shredding/changes.json +12 -0
  167. package/src/delivery/changes.json +6 -0
  168. package/src/files/changes.json +24 -0
  169. package/src/files-tenant-data/changes.json +5 -0
  170. package/src/folders/changes.json +6 -0
  171. package/src/inbound-provider-imap/changes.json +6 -0
  172. package/src/jobs/changes.json +30 -0
  173. package/src/ledger/changes.json +23 -0
  174. package/src/notes-history/changes.json +13 -0
  175. package/src/template-resolver/changes.json +5 -0
  176. package/src/tenant/changes.json +12 -0
  177. package/src/tenant-handover/changes.json +6 -0
  178. package/src/tenant-lifecycle/changes.json +22 -0
  179. package/src/tier-engine/changes.json +5 -0
  180. package/src/user/changes.json +15 -0
  181. package/src/user-data-rights/changes.json +6 -0
@@ -9,6 +9,15 @@ import { findForbiddenMembershipRole, reservedMembershipRoleError } from "./memb
9
9
  import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table.js";
10
10
  import { seedTenantMembership } from "./seeding.js";
11
11
  const membershipExecutor = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, { entityName: "tenant-membership" });
12
+ const MEMBERSHIP_CREATED_VERSION = 1;
13
+ // 0 = the user has no membership in the tenant yet.
14
+ export async function membershipVersionOf(db, tenantId, userId) {
15
+ const membership = await db.fetchOne(tenantMembershipsTable, {
16
+ tenantId,
17
+ userId,
18
+ });
19
+ return membership?.version ?? 0;
20
+ }
12
21
  // Add-only, so unlike updateMemberRoles no session revoke or last-TenantAdmin check.
13
22
  // Read unfiltered: tenant:query:memberships hides disabled tenants.
14
23
  export async function grantInvitedMembershipRole(db, options) {
@@ -28,7 +37,7 @@ export async function grantInvitedMembershipRole(db, options) {
28
37
  return grantedMembership(false, [options.role]);
29
38
  }
30
39
  // A membership decision made after the invitation was issued wins over it.
31
- if (isMembershipChangedAfter(existing, options.invitationIssuedAt)) {
40
+ if (isSupersededByMembership(existing, options.invitation)) {
32
41
  return writeFailure(invitationSupersededError());
33
42
  }
34
43
  const currentRoles = parseRoles(existing.roles);
@@ -40,13 +49,25 @@ export async function grantInvitedMembershipRole(db, options) {
40
49
  return updateResult;
41
50
  return grantedMembership(true, roles);
42
51
  }
43
- // Resend reuses the invitation row, so its last modification is the latest issuance.
44
- export function invitationIssuedAt(invitation) {
45
- return invitation.modifiedAt ?? invitation.insertedAt;
46
- }
47
- function isMembershipChangedAfter(membership, instant) {
52
+ // The pinned version is the membership's version when the invitation was issued
53
+ // (0 = none yet). A different version now means the membership was decided after
54
+ // the invite. Pin 0: a membership that appeared afterwards is an additive grant
55
+ // only while it is untouched since its creation (version 1). A role change bumps
56
+ // the version above 1 and counts as a decision. A removal in between hard-deletes
57
+ // the row and cancels the invitation (remove-member), so it never reaches this check.
58
+ // Rows issued before the pin existed (null) fall back to wall-clock timestamps,
59
+ // which can misjudge changes within the same millisecond but is all they carry.
60
+ function isSupersededByMembership(membership, invitation) {
61
+ if (invitation.membershipVersion !== null) {
62
+ if (invitation.membershipVersion === 0) {
63
+ return membership.version > MEMBERSHIP_CREATED_VERSION;
64
+ }
65
+ return membership.version !== invitation.membershipVersion;
66
+ }
67
+ // Resend reuses the invitation row, so its last modification is the latest issuance.
68
+ const issuedAt = invitation.modifiedAt ?? invitation.insertedAt;
48
69
  const lastChangedAt = membership.modifiedAt ?? membership.insertedAt;
49
- return Temporal.Instant.compare(lastChangedAt, instant) > 0;
70
+ return Temporal.Instant.compare(lastChangedAt, issuedAt) > 0;
50
71
  }
51
72
  function invitationSupersededError() {
52
73
  return new ConflictError({
@@ -69,6 +69,7 @@ export declare const tenantEntity: import("@cosmicdrift/kumiko-types/fields").En
69
69
  readonly optionsQuery?: string;
70
70
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
71
71
  readonly optionsAvailabilityQuery?: string;
72
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"active" | "destroyFailed" | "destroyRequested" | "destroyed" | "destroying">[] | undefined;
72
73
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
73
74
  required: true;
74
75
  };
@@ -224,6 +225,7 @@ export declare const tenantTable: import("@cosmicdrift/kumiko-framework/db").Ent
224
225
  readonly optionsQuery?: string;
225
226
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
226
227
  readonly optionsAvailabilityQuery?: string;
228
+ readonly conditionalOptions?: readonly import("@cosmicdrift/kumiko-types/fields").SelectConditionalOptions<"active" | "destroyFailed" | "destroyRequested" | "destroyed" | "destroying">[] | undefined;
227
229
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
228
230
  required: true;
229
231
  };
@@ -1,23 +1,4 @@
1
- import { type EntityDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
- export type TransferEdgeLink = {
3
- readonly kind: "parentRef";
4
- readonly typeField: string;
5
- readonly idField: string;
6
- } | {
7
- readonly kind: "reference";
8
- readonly field: string;
9
- };
10
- export type TransferEdge = {
11
- readonly entityName: string;
12
- readonly entity: EntityDefinition;
13
- /** The already-resolved type this edge hangs off — its moved row ids are
14
- * the parent ids the mover matches this edge's rows against. */
15
- readonly parentEntityName: string;
16
- readonly link: TransferEdgeLink;
17
- };
1
+ import { type EntityDefinition, type Registry, type TransferAdjacency } from "@cosmicdrift/kumiko-framework/engine";
2
+ export type { TransferAdjacency, TransferEdge, TransferEdgeLink, } from "@cosmicdrift/kumiko-framework/engine";
18
3
  export declare function resolveTransferableRoot(registry: Registry, entityType: string): EntityDefinition | undefined;
19
- /** Outgoing edges per type: keyed by the type an edge hangs off, so the mover
20
- * can ask "what hangs off the rows I just moved" for any type it reaches, at
21
- * any depth, as often as it reaches it. */
22
- export type TransferAdjacency = ReadonlyMap<string, readonly TransferEdge[]>;
23
4
  export declare function resolveTransferAdjacency(registry: Registry, rootEntityType: string): TransferAdjacency;
@@ -4,90 +4,23 @@
4
4
  // the graph depth is read off the existing declarations, never a hand-
5
5
  // maintained per-app list).
6
6
  //
7
- // Two edge kinds carry a graph (#3088):
8
- // - `parentRef`, the polymorphic join-row link (entityTypeField +
9
- // entityIdField). One level deep by construction — the boot validator at
10
- // engine/boot-validator/parent-ref.ts rejects a host that itself declares
11
- // a parentRef.
12
- // - a plain `reference` field, whose `entity` names its target directly.
13
- // These nest freely.
14
- //
15
- // What comes out is static adjacency, not a walk: which edges lead away from
16
- // each type, read straight off the declarations. The mover drives the actual
17
- // traversal from the row ids it moves (#3131), because how deep a type sits
18
- // depends on the rows found, not on the schema alone.
7
+ // The edge definition (parentRef + single-valued reference fields, minus
8
+ // `handover: "stay"`) lives in the framework so the boot validator's depth
9
+ // limit and the mover's traversal measure the same graph. What comes out is
10
+ // static adjacency, not a walk: the mover drives the actual traversal from the
11
+ // row ids it moves (#3131), because how deep a type sits depends on the rows
12
+ // found, not on the schema alone.
19
13
  //
20
14
  // `transferable: true` stays the only gate on what actually moves, and it is
21
15
  // enforced by the caller against edges that turn out to have rows rather than
22
16
  // here: a declaration names which types an edge accepts, never proof that any
23
17
  // row currently travels it. Narrowing here would need the very query the caller
24
18
  // is about to run anyway.
25
- import { parseRefTargetEntityName, } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { resolveEntityTransferAdjacency, } from "@cosmicdrift/kumiko-framework/engine";
26
20
  export function resolveTransferableRoot(registry, entityType) {
27
21
  const entity = registry.getAllEntities().get(entityType);
28
22
  return entity?.transferable === true ? entity : undefined;
29
23
  }
30
- // A `multiple` reference stores a jsonb array rather than a single id column,
31
- // which the mover's `= ANY($ids)` match cannot address. The boot validator
32
- // (engine/boot-validator/transfer-graph.ts) rejects that combination up front,
33
- // so skipping it here can only ever hit an entity that is not transferable —
34
- // never a silent partial move.
35
- function referenceEdgesFrom(entityName, entity) {
36
- const edges = [];
37
- for (const [fieldName, field] of Object.entries(entity.fields)) {
38
- if (field.type !== "reference")
39
- continue;
40
- if (field.multiple === true)
41
- continue;
42
- edges.push({
43
- entityName,
44
- entity,
45
- parentEntityName: parseRefTargetEntityName(field.entity),
46
- link: { kind: "reference", field: fieldName },
47
- });
48
- }
49
- return edges;
50
- }
51
- // A parentRef names the types it accepts; without `allowedTypes` it accepts
52
- // any of them, so the edge is recorded against every registered type.
53
- function parentRefEdgesFrom(entityName, entity, allEntityNames) {
54
- const parentRef = entity.parentRef;
55
- if (!parentRef)
56
- return [];
57
- const link = {
58
- kind: "parentRef",
59
- typeField: parentRef.entityTypeField,
60
- idField: parentRef.entityIdField,
61
- };
62
- return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
63
- entityName,
64
- entity,
65
- parentEntityName,
66
- link,
67
- }));
68
- }
69
- // A type can legitimately be reached by more than one edge — `note.vehicleId`
70
- // and `note.campaignId` are distinct, and collapsing them by entity name would
71
- // leave one edge's rows behind, the silent partial move #3088 exists to end.
72
- // Both are kept here; the mover runs each against whatever parent ids it has.
73
24
  export function resolveTransferAdjacency(registry, rootEntityType) {
74
- const entities = registry.getAllEntities();
75
- const allEntityNames = [...entities.keys()];
76
- const byParent = new Map();
77
- for (const [entityName, entity] of entities) {
78
- // skip: the root is never collected as someone else's descendant. A self
79
- // reference would otherwise drag unrelated rows of the root's own type
80
- // across the tenant boundary along with the one that was claimed.
81
- if (entityName === rootEntityType)
82
- continue;
83
- for (const edge of [
84
- ...parentRefEdgesFrom(entityName, entity, allEntityNames),
85
- ...referenceEdgesFrom(entityName, entity),
86
- ]) {
87
- const edges = byParent.get(edge.parentEntityName) ?? [];
88
- edges.push(edge);
89
- byParent.set(edge.parentEntityName, edges);
90
- }
91
- }
92
- return byParent;
25
+ return resolveEntityTransferAdjacency(registry.getAllEntities(), rootEntityType);
93
26
  }
@@ -1,3 +1,4 @@
1
1
  import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void;
3
3
  export declare function validateTenantDestroyHookShapes(features: readonly FeatureDefinition[]): void;
4
+ export declare function warnIfFilesMountedWithoutTenantData(features: readonly FeatureDefinition[]): void;
@@ -1,5 +1,7 @@
1
1
  import { EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
2
3
  import { entitiesOf } from "../shared/index.js";
4
+ const log = createFallbackLogger("kumiko:boot");
3
5
  // V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
4
6
  // (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
5
7
  // feature's own `r.bootCheck()` (#1314, moved off the framework-internal
@@ -60,3 +62,14 @@ export function validateTenantDestroyHookShapes(features) {
60
62
  }
61
63
  }
62
64
  }
65
+ // `files-tenant-data` is opt-in (files stays usable without tenant-lifecycle),
66
+ // so a tenant-lifecycle app that mounts `files` but forgets it destroys the
67
+ // tenant's fileRef rows and storage binaries never — only a warning, since
68
+ // some apps legitimately handle file erasure themselves.
69
+ export function warnIfFilesMountedWithoutTenantData(features) {
70
+ const mounted = new Set(features.map((f) => f.name));
71
+ // skip: nothing to cover (no files) or already covered.
72
+ if (!mounted.has("files") || mounted.has("files-tenant-data"))
73
+ return;
74
+ log.warn('feature "files" is mounted with "tenant-lifecycle" but "files-tenant-data" is not — tenant destroy will leave fileRef rows and stored file binaries behind. Mount createFilesTenantDataFeature() to cover them.');
75
+ }
@@ -5,6 +5,7 @@ export declare const DESTRUCTION_CANCELLED_EVENT_SHORT: "destruction-cancelled";
5
5
  export declare const TENANT_DESTRUCTION_STARTED_EVENT_SHORT: "tenant-destruction-started";
6
6
  export declare const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT: "tenant-destruction-stage-started";
7
7
  export declare const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT: "tenant-destruction-stage-succeeded";
8
+ export declare const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT: "tenant-destruction-stage-progressed";
8
9
  export declare const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT: "tenant-destruction-stage-failed";
9
10
  export declare const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT: "tenant-destruction-stage-abandoned";
10
11
  export declare const TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT: "tenant-destruction-completed";
@@ -13,6 +14,7 @@ export declare const DESTRUCTION_REQUESTED_EVENT_QN: "tenant-lifecycle:event:des
13
14
  export declare const DESTRUCTION_CANCELLED_EVENT_QN: "tenant-lifecycle:event:destruction-cancelled";
14
15
  export declare const TENANT_DESTRUCTION_STARTED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-started";
15
16
  export declare const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-succeeded";
17
+ export declare const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-progressed";
16
18
  export declare const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-failed";
17
19
  export declare const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-stage-abandoned";
18
20
  export declare const TENANT_DESTRUCTION_COMPLETED_EVENT_QN: "tenant-lifecycle:event:tenant-destruction-completed";
@@ -24,3 +26,4 @@ export declare const TenantLifecycleHandlers: {
24
26
  };
25
27
  export declare const TENANT_DESTRUCTION_STAGES: readonly ["external-resources", "search-indices", "cache", "app-data", "subject-keys", "files", "infra-resources", "tenant-row"];
26
28
  export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
29
+ export declare const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30000;
@@ -6,6 +6,7 @@ export const DESTRUCTION_CANCELLED_EVENT_SHORT = "destruction-cancelled";
6
6
  export const TENANT_DESTRUCTION_STARTED_EVENT_SHORT = "tenant-destruction-started";
7
7
  export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT = "tenant-destruction-stage-started";
8
8
  export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT = "tenant-destruction-stage-succeeded";
9
+ export const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT = "tenant-destruction-stage-progressed";
9
10
  export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT = "tenant-destruction-stage-failed";
10
11
  export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT = "tenant-destruction-stage-abandoned";
11
12
  export const TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT = "tenant-destruction-completed";
@@ -14,6 +15,7 @@ export const DESTRUCTION_REQUESTED_EVENT_QN = `${EVENT_PREFIX}${DESTRUCTION_REQU
14
15
  export const DESTRUCTION_CANCELLED_EVENT_QN = `${EVENT_PREFIX}${DESTRUCTION_CANCELLED_EVENT_SHORT}`;
15
16
  export const TENANT_DESTRUCTION_STARTED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STARTED_EVENT_SHORT}`;
16
17
  export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT}`;
18
+ export const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT}`;
17
19
  export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT}`;
18
20
  export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT}`;
19
21
  export const TENANT_DESTRUCTION_COMPLETED_EVENT_QN = `${EVENT_PREFIX}${TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT}`;
@@ -33,3 +35,6 @@ export const TENANT_DESTRUCTION_STAGES = [
33
35
  "infra-resources",
34
36
  "tenant-row",
35
37
  ];
38
+ // The sweep cron fires every minute with concurrency "skip", so a tick must
39
+ // finish well inside that window or the next one is skipped.
40
+ export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
@@ -17,6 +17,11 @@ export declare const tenantDestructionStageSucceededSchema: z.ZodObject<{
17
17
  stage: z.ZodString;
18
18
  attempts: z.ZodNumber;
19
19
  }, z.core.$strip>;
20
+ export declare const tenantDestructionStageProgressedSchema: z.ZodObject<{
21
+ stage: z.ZodString;
22
+ attempts: z.ZodNumber;
23
+ processed: z.ZodNumber;
24
+ }, z.core.$strip>;
20
25
  export declare const tenantDestructionStageFailedSchema: z.ZodObject<{
21
26
  stage: z.ZodString;
22
27
  attempts: z.ZodNumber;
@@ -17,6 +17,11 @@ export const tenantDestructionStageSucceededSchema = z.object({
17
17
  stage: z.string().min(1),
18
18
  attempts: z.number().int().positive(),
19
19
  });
20
+ export const tenantDestructionStageProgressedSchema = z.object({
21
+ stage: z.string().min(1),
22
+ attempts: z.number().int().positive(),
23
+ processed: z.number().int().nonnegative(),
24
+ });
20
25
  export const tenantDestructionStageFailedSchema = z.object({
21
26
  stage: z.string().min(1),
22
27
  attempts: z.number().int().positive(),
@@ -1,7 +1,7 @@
1
1
  import { defineFeature, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, } from "@cosmicdrift/kumiko-framework/engine";
2
- import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes } from "./boot-checks.js";
3
- import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, TENANT_DESTRUCTION_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
4
- import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
2
+ import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes, warnIfFilesMountedWithoutTenantData, } from "./boot-checks.js";
3
+ import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, TENANT_DESTRUCTION_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
4
+ import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageProgressedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
5
5
  import { cancelDestructionWrite } from "./handlers/cancel-destruction.write.js";
6
6
  import { requestDestructionWrite } from "./handlers/request-destruction.write.js";
7
7
  import { hasTenantDestructionWork, resolveTenantLifecycleGate, runTenantDestructionSweep, } from "./run-tenant-destroy.js";
@@ -37,6 +37,7 @@ export function createTenantLifecycleFeature() {
37
37
  // guard's "tenant-lifecycle mounted" gate exactly.
38
38
  r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
39
39
  r.bootCheck(({ features }) => validateTenantDestroyHookShapes(features));
40
+ r.bootCheck(({ features }) => warnIfFilesMountedWithoutTenantData(features));
40
41
  r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
41
42
  piiFields: "none",
42
43
  });
@@ -50,6 +51,7 @@ export function createTenantLifecycleFeature() {
50
51
  piiFields: "none",
51
52
  });
52
53
  r.defineEvent(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, tenantDestructionStageSucceededSchema, { piiFields: "none" });
54
+ r.defineEvent(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, tenantDestructionStageProgressedSchema, { piiFields: "none" });
53
55
  r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema, {
54
56
  piiFields: "none",
55
57
  });
@@ -3,6 +3,12 @@ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { type EscapeHatchAuditSink, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
5
5
  import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
6
+ type StageTickResult = {
7
+ readonly done: boolean;
8
+ readonly error?: string;
9
+ readonly halted?: boolean;
10
+ readonly progressed?: boolean;
11
+ };
6
12
  export declare function runNextDestructionStage(args: {
7
13
  readonly db: DbRunner;
8
14
  readonly registry: Registry;
@@ -12,11 +18,7 @@ export declare function runNextDestructionStage(args: {
12
18
  readonly searchAdapter?: SearchAdapter;
13
19
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
14
20
  readonly actor?: string;
15
- }): Promise<{
16
- readonly done: boolean;
17
- readonly error?: string;
18
- readonly halted?: boolean;
19
- }>;
21
+ }): Promise<StageTickResult>;
20
22
  export declare function hasTenantDestructionWork(reads: Pick<TenantDb, "count">, now: Temporal.Instant): Promise<boolean>;
21
23
  /** Cron entry: start destruction for tenants past grace, then advance stages. */
22
24
  export declare function runTenantDestructionSweep(args: {
@@ -5,7 +5,7 @@ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumiko-framework/event-store";
6
6
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
7
7
  import { tenantEntity, tenantTable } from "../tenant/index.js";
8
- import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
8
+ import { DESTRUCTION_STAGE_TICK_BUDGET_MS, TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
9
9
  import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
10
10
  import { isDestructionPipelineComplete, pickNextStage, } from "./stages.js";
11
11
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
@@ -35,6 +35,17 @@ function replayStageState(events) {
35
35
  }
36
36
  return { completed, abandoned, attemptsByStage };
37
37
  }
38
+ // A progressed event as the stage's latest lifecycle event means the previous
39
+ // tick handed over mid-stage; its stage-started is still the open one.
40
+ function isStageContinuation(events, stageName) {
41
+ const stageEventTypes = new Set([
42
+ qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
43
+ qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT),
44
+ qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
45
+ ]);
46
+ const lastStageEvent = events.findLast((event) => stageEventTypes.has(event.type) && event.payload["stage"] === stageName);
47
+ return lastStageEvent?.type === qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT);
48
+ }
38
49
  function lastEventVersion(events) {
39
50
  const last = events.at(-1);
40
51
  return last?.version ?? 0;
@@ -114,6 +125,22 @@ async function maybeAppendDestructionCompleted(db, tenantId, completed) {
114
125
  const T = getTemporal();
115
126
  await appendTenantStageEvent(db, tenantId, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, { destroyedAt: T.Now.instant().toString() }, lastEventVersion(events));
116
127
  }
128
+ // `version` differs per caller: the catch path appends against the pre-run
129
+ // version, the no-progress path against the version reloaded after run().
130
+ async function recordFailedStageAttempt(args) {
131
+ if (args.attempt >= args.stage.maxAttempts) {
132
+ await haltPipelineOnAbandon({
133
+ db: args.db,
134
+ tenantId: args.tenantId,
135
+ stage: args.stage.name,
136
+ attempts: args.attempt,
137
+ error: args.error,
138
+ });
139
+ return { done: false, error: args.error, halted: true };
140
+ }
141
+ await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT), { stage: args.stage.name, attempts: args.attempt, error: args.error }, args.version);
142
+ return { done: false, error: args.error };
143
+ }
117
144
  export async function runNextDestructionStage(args) {
118
145
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
119
146
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
@@ -136,36 +163,49 @@ export async function runNextDestructionStage(args) {
136
163
  searchAdapter: args.searchAdapter,
137
164
  escapeHatchAuditSink: args.escapeHatchAuditSink,
138
165
  actor: args.actor,
166
+ deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
139
167
  };
140
168
  let version = lastEventVersion(events);
141
- version = await appendTenantStageEventIdempotent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT), { stage: next.name, attempts: attempt }, version, stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT));
169
+ if (!isStageContinuation(events, next.name)) {
170
+ version = await appendTenantStageEventIdempotent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT), { stage: next.name, attempts: attempt }, version, stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT));
171
+ }
142
172
  try {
143
- await next.run(ctx);
173
+ const outcome = await next.run(ctx);
144
174
  // next.run() may itself have appended events to this aggregate (e.g.
145
175
  // tombstoneTenantRow updates the tenant row on the same stream) — reload
146
176
  // the version fresh instead of trusting the pre-run `version`, or this
147
177
  // append throws VersionConflictError on every stage whose run() writes.
148
178
  const eventsAfterRun = await loadAggregate(args.db, args.tenantId, args.tenantId);
179
+ // A tick that neither finishes nor processes anything would otherwise loop
180
+ // forever, because progressed events do not count as attempts.
181
+ if (!outcome.done && outcome.processed === 0) {
182
+ return await recordFailedStageAttempt({
183
+ db: args.db,
184
+ tenantId: args.tenantId,
185
+ stage: next,
186
+ attempt,
187
+ error: `stage "${next.name}" made no progress`,
188
+ version: lastEventVersion(eventsAfterRun),
189
+ });
190
+ }
191
+ if (!outcome.done) {
192
+ await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT), { stage: next.name, attempts: attempt, processed: outcome.processed }, lastEventVersion(eventsAfterRun));
193
+ return { done: false, progressed: true };
194
+ }
149
195
  version = await appendTenantStageEventIdempotent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT), { stage: next.name, attempts: attempt }, lastEventVersion(eventsAfterRun), stageOutcomeRecorded(eventsAfterRun, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT));
150
196
  const completedAfter = new Set([...completed, next.name]);
151
197
  await maybeAppendDestructionCompleted(args.db, args.tenantId, completedAfter);
152
198
  return { done: isDestructionPipelineComplete(completedAfter) };
153
199
  }
154
200
  catch (err) {
155
- const message = err instanceof Error ? err.message : String(err);
156
- const isFinal = attempt >= next.maxAttempts;
157
- if (isFinal) {
158
- await haltPipelineOnAbandon({
159
- db: args.db,
160
- tenantId: args.tenantId,
161
- stage: next.name,
162
- attempts: attempt,
163
- error: message,
164
- });
165
- return { done: false, error: message, halted: true };
166
- }
167
- await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT), { stage: next.name, attempts: attempt, error: message }, version);
168
- return { done: false, error: message };
201
+ return await recordFailedStageAttempt({
202
+ db: args.db,
203
+ tenantId: args.tenantId,
204
+ stage: next,
205
+ attempt,
206
+ error: err instanceof Error ? err.message : String(err),
207
+ version,
208
+ });
169
209
  }
170
210
  }
171
211
  function destroyRequestedDueWhere(now) {
@@ -12,11 +12,16 @@ export type DestructionStageCtx = {
12
12
  readonly searchAdapter?: SearchAdapter;
13
13
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
14
14
  readonly actor?: string;
15
+ readonly deadlineAt: number;
16
+ };
17
+ export type StageRunOutcome = {
18
+ readonly done: boolean;
19
+ readonly processed: number;
15
20
  };
16
21
  export type DestructionStage = {
17
22
  readonly name: TenantDestructionStageName;
18
23
  readonly maxAttempts: number;
19
- readonly run: (ctx: DestructionStageCtx) => Promise<void>;
24
+ readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
20
25
  };
21
26
  export declare const DESTRUCTION_STAGES: readonly DestructionStage[];
22
27
  export declare function pickNextStage(completedStages: ReadonlySet<string>, abandonedStages: ReadonlySet<string>): DestructionStage | null;
@@ -7,21 +7,39 @@ import { purgeSearchDocumentsForSubject, } from "@cosmicdrift/kumiko-framework/s
7
7
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
8
8
  import { tenantEntity, tenantMembershipEntity, tenantMembershipsTable, tenantTable, } from "../tenant/index.js";
9
9
  import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
10
+ const STAGE_DONE = { done: true, processed: 0 };
10
11
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
11
12
  const tenantMembershipCrud = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
12
13
  entityName: "tenant-membership",
13
14
  });
15
+ // Every hook still runs each tick (they are idempotent), so a later hook is not
16
+ // starved by an earlier one that keeps reporting done:false.
17
+ function newOutcomeAccumulator() {
18
+ let allDone = true;
19
+ let processed = 0;
20
+ return {
21
+ add: (result) => {
22
+ if (result?.done === false)
23
+ allDone = false;
24
+ processed += result?.processed ?? 0;
25
+ },
26
+ outcome: () => ({ done: allDone, processed }),
27
+ };
28
+ }
14
29
  async function runExtensionDestroyHooks(registry, extensionName, ctx) {
15
30
  const usages = registry.getExtensionUsages(extensionName);
31
+ const total = newOutcomeAccumulator();
16
32
  for (const usage of usages) {
17
33
  if (!isTenantResourceExtensionHooks(usage.options)) {
18
34
  throw new Error(`${extensionName} registration for "${usage.entityName}" has no destroy function`);
19
35
  }
20
- await usage.options.destroyTenant(ctx.tenantId, ctx);
36
+ total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
21
37
  }
38
+ return total.outcome();
22
39
  }
23
40
  async function runTenantDataHooks(ctx) {
24
41
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
42
+ const total = newOutcomeAccumulator();
25
43
  for (const usage of usages) {
26
44
  if (!isTenantDataExtensionHooks(usage.options)) {
27
45
  throw new Error(`${EXT_TENANT_DATA} registration for "${usage.entityName}" has no destroy function`);
@@ -41,18 +59,20 @@ async function runTenantDataHooks(ctx) {
41
59
  }),
42
60
  registry: ctx.registry,
43
61
  tenantId: ctx.tenantId,
62
+ deadlineAt: ctx.deadlineAt,
44
63
  fileProviderResolver: ctx.fileProviderResolver,
45
64
  log: ctx.log,
46
65
  };
47
- await destroy(hookCtx);
66
+ total.add(await destroy(hookCtx));
48
67
  }
68
+ return total.outcome();
49
69
  }
50
70
  async function eraseSubjectKeys(ctx) {
51
71
  const kms = configuredPiiSubjectKms();
52
72
  if (!kms) {
53
73
  ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
54
74
  // skip: KMS optional — apps without crypto-shredding still run other destroy stages
55
- return;
75
+ return STAGE_DONE;
56
76
  }
57
77
  const memberships = await selectMany(ctx.db, tenantMembershipsTable, {
58
78
  tenantId: ctx.tenantId,
@@ -72,11 +92,13 @@ async function eraseSubjectKeys(ctx) {
72
92
  if (ctx.searchAdapter) {
73
93
  await purgeSearchDocumentsForSubject(ctx.db, ctx.registry.features, ctx.searchAdapter, subjectIdToKey(tenantRecordSubject), tenantRecordSubject);
74
94
  }
95
+ return STAGE_DONE;
75
96
  }
76
97
  async function purgeTenantCache(ctx) {
77
98
  // ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
78
99
  // then this stage is a documented no-op (no cache layer in test stack).
79
100
  ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
101
+ return STAGE_DONE;
80
102
  }
81
103
  async function tombstoneTenantRow(ctx) {
82
104
  const now = getTemporal().Now.instant();
@@ -110,6 +132,7 @@ async function tombstoneTenantRow(ctx) {
110
132
  },
111
133
  }, user, db, { skipOptimisticLock: true });
112
134
  invalidateTenantLifecycleGate(ctx.tenantId);
135
+ return STAGE_DONE;
113
136
  }
114
137
  export const DESTRUCTION_STAGES = [
115
138
  {
@@ -0,0 +1,8 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
4
+ import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
5
+ import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
6
+ export declare function seedDestroyingTenant(db: DbConnection, tenantId: TenantId): Promise<void>;
7
+ export declare function driveDestructionToCompletion(stack: TestStack, db: DbConnection, tenantId: TenantId, searchAdapter?: SearchAdapter): Promise<string>;
8
+ export { runTenantDestructionSweep };
@@ -0,0 +1,42 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
3
+ import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
5
+ import { tenantTable } from "../tenant/index.js";
6
+ import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_STARTED_EVENT_QN } from "./constants.js";
7
+ import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
8
+ // Sidesteps the `request-destruction` write handler (needs user/auth/sessions
9
+ // features wired) by seeding the same "destroying" state it would produce.
10
+ export async function seedDestroyingTenant(db, tenantId) {
11
+ const now = getTemporal().Now.instant();
12
+ await updateRows(db, tenantTable, { status: "destroying", destroyStartedAt: now }, { id: tenantId });
13
+ await append(db, {
14
+ aggregateId: tenantId,
15
+ aggregateType: TENANT_AGGREGATE_TYPE,
16
+ tenantId,
17
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
18
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
19
+ payload: { startedAt: now.toString() },
20
+ metadata: { userId: "system", requestId: "test:destruction-started" },
21
+ });
22
+ }
23
+ export async function driveDestructionToCompletion(stack, db, tenantId, searchAdapter) {
24
+ const farFuture = getTemporal()
25
+ .Now.instant()
26
+ .add({ hours: 24 * 3650 });
27
+ let status = "";
28
+ for (let i = 0; i < 20; i++) {
29
+ await runTenantDestructionSweep({
30
+ db: stack.db,
31
+ registry: stack.registry,
32
+ now: farFuture,
33
+ searchAdapter,
34
+ });
35
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
36
+ status = String(rows[0]?.["status"]);
37
+ if (status === "destroyed" || status === "destroyFailed")
38
+ break;
39
+ }
40
+ return status;
41
+ }
42
+ export { runTenantDestructionSweep };
@@ -45,6 +45,7 @@ export function createSetTenantTierWrite(opts = {}) {
45
45
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
46
46
  }),
47
47
  access: { roles: ["SystemAdmin"] },
48
+ agent: { risk: "high" },
48
49
  escapeHatch: {
49
50
  grants: ["unsafeRaw"],
50
51
  reason: SET_TENANT_TIER_REASON,