@cosmicdrift/kumiko-bundled-features 0.352.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 (259) 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 +17 -5
  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/manifest-deny-qns.d.ts +3 -0
  9. package/dist/agent-tools/manifest-deny-qns.js +17 -0
  10. package/dist/agent-tools/tool-catalog.d.ts +2 -1
  11. package/dist/agent-tools/tool-catalog.js +4 -1
  12. package/dist/agent-tools/tool-dispatch.js +1 -1
  13. package/dist/audit/handlers/list.query.js +13 -8
  14. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  15. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  16. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  17. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  18. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  19. package/dist/auth-email-password/handlers/login.write.js +1 -0
  20. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  21. package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
  22. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  23. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  24. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  25. package/dist/auth-email-password/index.d.ts +2 -0
  26. package/dist/auth-email-password/index.js +2 -0
  27. package/dist/auth-email-password/seeding.d.ts +2 -0
  28. package/dist/auth-email-password/seeding.js +30 -8
  29. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  30. package/dist/auth-email-password/signup-activation.js +41 -0
  31. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  32. package/dist/auth-email-password/signup-token-store.js +8 -3
  33. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  34. package/dist/auth-email-password/web/auth-gate.js +5 -5
  35. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  36. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  37. package/dist/auth-email-password/web/login-screen.js +2 -2
  38. package/dist/auth-email-password/web/user-menu.js +1 -1
  39. package/dist/auth-mfa/feature.js +5 -0
  40. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  41. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  42. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  43. package/dist/auth-mfa/screens.js +2 -2
  44. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  45. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  46. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  47. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  48. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  49. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  50. package/dist/billing-foundation/checkout-core.d.ts +7 -0
  51. package/dist/billing-foundation/checkout-core.js +7 -2
  52. package/dist/billing-foundation/entities.d.ts +8 -0
  53. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
  54. package/dist/billing-foundation/projection.d.ts +8 -0
  55. package/dist/cap-counter/entity.d.ts +1 -0
  56. package/dist/cap-overview/access.js +2 -9
  57. package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
  58. package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
  59. package/dist/channel-in-app/in-app-channel.js +1 -1
  60. package/dist/channel-in-app/tables.js +4 -2
  61. package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
  62. package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
  63. package/dist/config/handlers/values.query.d.ts +3 -1
  64. package/dist/config/handlers/values.query.js +7 -2
  65. package/dist/config/table.d.ts +6 -0
  66. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  67. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  68. package/dist/custom-fields/entity.d.ts +4 -0
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  71. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  72. package/dist/delivery/delivery-service.js +60 -12
  73. package/dist/delivery/public-names.js +1 -1
  74. package/dist/delivery/tables.d.ts +6 -0
  75. package/dist/delivery/unsubscribe.js +66 -14
  76. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  77. package/dist/feature-toggles/feature.js +2 -3
  78. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  79. package/dist/feature-toggles/toggle-runtime.js +30 -6
  80. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  81. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  82. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  83. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  84. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  85. package/dist/files-tenant-data/hooks.js +61 -24
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/feature.js +2 -10
  88. package/dist/folders/handlers/delete-folder.write.js +31 -4
  89. package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
  90. package/dist/form-draft/db/queries/cleanup.js +4 -3
  91. package/dist/form-draft/entity.d.ts +2 -0
  92. package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
  93. package/dist/form-draft/handlers/cleanup.job.js +11 -5
  94. package/dist/foundation-shared/index.d.ts +1 -1
  95. package/dist/foundation-shared/index.js +1 -1
  96. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  97. package/dist/foundation-shared/mail-host-policy.js +13 -4
  98. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  99. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  100. package/dist/inbound-provider-imap/feature.js +2 -0
  101. package/dist/inbound-provider-imap/imap-client.js +3 -4
  102. package/dist/jobs/constants.d.ts +1 -0
  103. package/dist/jobs/constants.js +1 -0
  104. package/dist/jobs/db/queries/retention.d.ts +1 -0
  105. package/dist/jobs/db/queries/retention.js +5 -0
  106. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  107. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  108. package/dist/jobs/feature.js +7 -1
  109. package/dist/jobs/handlers/list.query.d.ts +2 -1
  110. package/dist/jobs/handlers/list.query.js +40 -15
  111. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  112. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  113. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  114. package/dist/jobs/i18n.js +1 -0
  115. package/dist/jobs/index.d.ts +2 -0
  116. package/dist/jobs/index.js +1 -0
  117. package/dist/jobs/job-run-logger.d.ts +2 -2
  118. package/dist/jobs/job-run-logger.js +98 -9
  119. package/dist/jobs/job-run-table.d.ts +6 -0
  120. package/dist/jobs/tenant-job-failure-table.js +15 -2
  121. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  122. package/dist/jobs/tenant-job-run-table.js +34 -0
  123. package/dist/ledger/entity.d.ts +24 -8
  124. package/dist/ledger/entity.js +17 -5
  125. package/dist/ledger/feature.js +3 -0
  126. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  127. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  128. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  129. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  130. package/dist/ledger/schedule-subject-pair.js +24 -0
  131. package/dist/ledger/schemas.js +8 -2
  132. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  133. package/dist/mail-transport-smtp/feature.js +4 -13
  134. package/dist/managed-pages/feature.js +28 -10
  135. package/dist/managed-pages/table.d.ts +12 -0
  136. package/dist/notes-history/entity.d.ts +12 -0
  137. package/dist/notes-history/entity.js +4 -0
  138. package/dist/notes-history/feature.js +2 -10
  139. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  140. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  141. package/dist/personal-access-tokens/screens.js +14 -3
  142. package/dist/secrets/table.d.ts +1 -0
  143. package/dist/seo/feature.js +28 -6
  144. package/dist/sessions/feature.d.ts +1 -0
  145. package/dist/sessions/feature.js +9 -8
  146. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  147. package/dist/sessions/handlers/detail.query.js +2 -2
  148. package/dist/sessions/handlers/list.query.d.ts +1 -1
  149. package/dist/sessions/handlers/list.query.js +5 -3
  150. package/dist/sessions/schema/user-session.d.ts +4 -0
  151. package/dist/sessions/screens.d.ts +2 -2
  152. package/dist/sessions/screens.js +6 -7
  153. package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
  154. package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
  155. package/dist/shared/encrypt-for-direct-write.js +10 -2
  156. package/dist/shared/index.d.ts +4 -3
  157. package/dist/shared/index.js +4 -3
  158. package/dist/shared/parent-visibility.d.ts +2 -1
  159. package/dist/shared/parent-visibility.js +21 -0
  160. package/dist/shared/rate-limited-text-response.d.ts +2 -0
  161. package/dist/shared/rate-limited-text-response.js +9 -0
  162. package/dist/shared/row-bound-grant.js +16 -2
  163. package/dist/shared/session-locale-field.d.ts +1 -0
  164. package/dist/shared/session-locale-field.js +6 -0
  165. package/dist/tags/entity.d.ts +9 -0
  166. package/dist/tags/feature.js +2 -10
  167. package/dist/template-resolver/api.d.ts +2 -2
  168. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  169. package/dist/template-resolver/handlers/list.query.js +2 -2
  170. package/dist/template-resolver/table.d.ts +48 -30
  171. package/dist/template-resolver/user-content-table.d.ts +34 -20
  172. package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
  173. package/dist/tenant/decrypt-tenant-names.js +10 -0
  174. package/dist/tenant/feature.js +7 -1
  175. package/dist/tenant/handlers/me.query.js +5 -1
  176. package/dist/tenant/handlers/memberships.query.js +3 -2
  177. package/dist/tenant/handlers/tenant-directory.query.js +9 -3
  178. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  179. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  180. package/dist/tenant/index.d.ts +1 -0
  181. package/dist/tenant/index.js +1 -0
  182. package/dist/tenant/invitation-table.d.ts +54 -8
  183. package/dist/tenant/invitation-table.js +6 -1
  184. package/dist/tenant/invited-membership.d.ts +8 -6
  185. package/dist/tenant/invited-membership.js +28 -7
  186. package/dist/tenant/membership-table.d.ts +4 -0
  187. package/dist/tenant/schema/tenant.d.ts +18 -10
  188. package/dist/tenant/schema/tenant.js +4 -6
  189. package/dist/tenant/screens.js +1 -1
  190. package/dist/tenant-handover/move-entity-graph.js +11 -18
  191. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  192. package/dist/tenant-handover/transfer-graph.js +8 -75
  193. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  194. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  195. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  196. package/dist/tenant-lifecycle/constants.js +5 -0
  197. package/dist/tenant-lifecycle/events.d.ts +5 -0
  198. package/dist/tenant-lifecycle/events.js +5 -0
  199. package/dist/tenant-lifecycle/feature.js +6 -3
  200. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
  201. package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
  202. package/dist/tenant-lifecycle/stages.d.ts +8 -1
  203. package/dist/tenant-lifecycle/stages.js +33 -4
  204. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  205. package/dist/tenant-lifecycle/testing.js +42 -0
  206. package/dist/tier-engine/entity.d.ts +2 -0
  207. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  208. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  209. package/dist/user/handlers/detail.query.js +2 -0
  210. package/dist/user/handlers/list.query.js +6 -2
  211. package/dist/user/handlers/update.write.js +1 -0
  212. package/dist/user/schema/user.d.ts +22 -8
  213. package/dist/user/seeding.d.ts +1 -0
  214. package/dist/user/seeding.js +20 -0
  215. package/dist/user-data-rights/boot-checks.js +5 -0
  216. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
  217. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  218. package/dist/user-data-rights/index.d.ts +1 -0
  219. package/dist/user-data-rights/index.js +1 -0
  220. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  221. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  222. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  223. package/dist/user-data-rights/schema/export-job.d.ts +16 -8
  224. package/dist/user-profile/handlers/change-email.write.js +8 -2
  225. package/dist/waitlist/entity.d.ts +22 -6
  226. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  227. package/dist/workflow-runner/event-trigger.js +63 -15
  228. package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
  229. package/dist/workflow-runner/runner.d.ts +2 -1
  230. package/dist/workflow-runner/runner.js +5 -1
  231. package/package.json +35 -10
  232. package/src/agent-tools/changes.json +12 -0
  233. package/src/audit/changes.json +6 -0
  234. package/src/auth-email-password/changes.json +24 -0
  235. package/src/auth-mfa/changes.json +7 -0
  236. package/src/billing-foundation/changes.json +6 -0
  237. package/src/channel-in-app/changes.json +8 -1
  238. package/src/crypto-shredding/changes.json +12 -0
  239. package/src/delivery/changes.json +6 -0
  240. package/src/files/changes.json +24 -0
  241. package/src/files-tenant-data/changes.json +5 -0
  242. package/src/folders/changes.json +6 -0
  243. package/src/form-draft/changes.json +7 -1
  244. package/src/inbound-provider-imap/changes.json +6 -0
  245. package/src/jobs/changes.json +30 -0
  246. package/src/ledger/changes.json +23 -0
  247. package/src/managed-pages/changes.json +6 -0
  248. package/src/notes-history/changes.json +13 -0
  249. package/src/seo/changes.json +5 -0
  250. package/src/template-resolver/changes.json +5 -0
  251. package/src/tenant/changes.json +19 -0
  252. package/src/tenant-handover/changes.json +12 -0
  253. package/src/tenant-lifecycle/changes.json +22 -0
  254. package/src/tier-engine/changes.json +5 -0
  255. package/src/user/changes.json +15 -0
  256. package/src/user-data-rights/changes.json +6 -0
  257. package/src/workflow-runner/changes.json +5 -0
  258. package/dist/shared/has-where-rule.d.ts +0 -2
  259. package/dist/shared/has-where-rule.js +0 -10
@@ -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
  });
@@ -85,6 +87,7 @@ export function createTenantLifecycleFeature() {
85
87
  now,
86
88
  log: (message) => ctx.log?.warn(message),
87
89
  fileProviderResolver: ctx._fileProviderResolver,
90
+ searchAdapter: ctx.searchAdapter,
88
91
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
89
92
  actor: ctx.systemUser.id,
90
93
  });
@@ -2,19 +2,23 @@
2
2
  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
+ 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
+ };
5
12
  export declare function runNextDestructionStage(args: {
6
13
  readonly db: DbRunner;
7
14
  readonly registry: Registry;
8
15
  readonly tenantId: TenantId;
9
16
  readonly log?: (message: string) => void;
10
17
  readonly fileProviderResolver?: FileProviderResolver;
18
+ readonly searchAdapter?: SearchAdapter;
11
19
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
12
20
  readonly actor?: string;
13
- }): Promise<{
14
- readonly done: boolean;
15
- readonly error?: string;
16
- readonly halted?: boolean;
17
- }>;
21
+ }): Promise<StageTickResult>;
18
22
  export declare function hasTenantDestructionWork(reads: Pick<TenantDb, "count">, now: Temporal.Instant): Promise<boolean>;
19
23
  /** Cron entry: start destruction for tenants past grace, then advance stages. */
20
24
  export declare function runTenantDestructionSweep(args: {
@@ -23,6 +27,7 @@ export declare function runTenantDestructionSweep(args: {
23
27
  readonly now?: Temporal.Instant;
24
28
  readonly log?: (message: string) => void;
25
29
  readonly fileProviderResolver?: FileProviderResolver;
30
+ readonly searchAdapter?: SearchAdapter;
26
31
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
27
32
  readonly actor?: string;
28
33
  }): Promise<{
@@ -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);
@@ -133,38 +160,52 @@ export async function runNextDestructionStage(args) {
133
160
  tenantId: args.tenantId,
134
161
  log: args.log,
135
162
  fileProviderResolver: args.fileProviderResolver,
163
+ searchAdapter: args.searchAdapter,
136
164
  escapeHatchAuditSink: args.escapeHatchAuditSink,
137
165
  actor: args.actor,
166
+ deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
138
167
  };
139
168
  let version = lastEventVersion(events);
140
- 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
+ }
141
172
  try {
142
- await next.run(ctx);
173
+ const outcome = await next.run(ctx);
143
174
  // next.run() may itself have appended events to this aggregate (e.g.
144
175
  // tombstoneTenantRow updates the tenant row on the same stream) — reload
145
176
  // the version fresh instead of trusting the pre-run `version`, or this
146
177
  // append throws VersionConflictError on every stage whose run() writes.
147
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
+ }
148
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));
149
196
  const completedAfter = new Set([...completed, next.name]);
150
197
  await maybeAppendDestructionCompleted(args.db, args.tenantId, completedAfter);
151
198
  return { done: isDestructionPipelineComplete(completedAfter) };
152
199
  }
153
200
  catch (err) {
154
- const message = err instanceof Error ? err.message : String(err);
155
- const isFinal = attempt >= next.maxAttempts;
156
- if (isFinal) {
157
- await haltPipelineOnAbandon({
158
- db: args.db,
159
- tenantId: args.tenantId,
160
- stage: next.name,
161
- attempts: attempt,
162
- error: message,
163
- });
164
- return { done: false, error: message, halted: true };
165
- }
166
- await appendTenantStageEvent(args.db, args.tenantId, qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT), { stage: next.name, attempts: attempt, error: message }, version);
167
- 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
+ });
168
209
  }
169
210
  }
170
211
  function destroyRequestedDueWhere(now) {
@@ -215,6 +256,7 @@ export async function runTenantDestructionSweep(args) {
215
256
  tenantId: row.id,
216
257
  log: args.log,
217
258
  fileProviderResolver: args.fileProviderResolver,
259
+ searchAdapter: args.searchAdapter,
218
260
  escapeHatchAuditSink: args.escapeHatchAuditSink,
219
261
  actor: args.actor,
220
262
  });
@@ -1,6 +1,7 @@
1
1
  import { type DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { type EscapeHatchAuditSink, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
4
+ import { type SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
4
5
  import type { TenantDestructionStageName } from "./constants.js";
5
6
  export type DestructionStageCtx = {
6
7
  readonly db: DbRunner;
@@ -8,13 +9,19 @@ export type DestructionStageCtx = {
8
9
  readonly tenantId: TenantId;
9
10
  readonly log?: (message: string) => void;
10
11
  readonly fileProviderResolver?: FileProviderResolver;
12
+ readonly searchAdapter?: SearchAdapter;
11
13
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
12
14
  readonly actor?: string;
15
+ readonly deadlineAt: number;
16
+ };
17
+ export type StageRunOutcome = {
18
+ readonly done: boolean;
19
+ readonly processed: number;
13
20
  };
14
21
  export type DestructionStage = {
15
22
  readonly name: TenantDestructionStageName;
16
23
  readonly maxAttempts: number;
17
- readonly run: (ctx: DestructionStageCtx) => Promise<void>;
24
+ readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
18
25
  };
19
26
  export declare const DESTRUCTION_STAGES: readonly DestructionStage[];
20
27
  export declare function pickNextStage(completedStages: ReadonlySet<string>, abandonedStages: ReadonlySet<string>): DestructionStage | null;
@@ -1,26 +1,45 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { configuredPiiSubjectKms, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
3
3
  import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
4
4
  import { createSystemUser, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, extensionUsageEscapeHatchReason, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { createEscapeHatchReporter, UNATTRIBUTED_ACTOR, } from "@cosmicdrift/kumiko-framework/pipeline";
6
+ import { purgeSearchDocumentsForSubject, } from "@cosmicdrift/kumiko-framework/search";
6
7
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
7
8
  import { tenantEntity, tenantMembershipEntity, tenantMembershipsTable, tenantTable, } from "../tenant/index.js";
8
9
  import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
10
+ const STAGE_DONE = { done: true, processed: 0 };
9
11
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
10
12
  const tenantMembershipCrud = createEventStoreExecutor(tenantMembershipsTable, tenantMembershipEntity, {
11
13
  entityName: "tenant-membership",
12
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
+ }
13
29
  async function runExtensionDestroyHooks(registry, extensionName, ctx) {
14
30
  const usages = registry.getExtensionUsages(extensionName);
31
+ const total = newOutcomeAccumulator();
15
32
  for (const usage of usages) {
16
33
  if (!isTenantResourceExtensionHooks(usage.options)) {
17
34
  throw new Error(`${extensionName} registration for "${usage.entityName}" has no destroy function`);
18
35
  }
19
- await usage.options.destroyTenant(ctx.tenantId, ctx);
36
+ total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
20
37
  }
38
+ return total.outcome();
21
39
  }
22
40
  async function runTenantDataHooks(ctx) {
23
41
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
42
+ const total = newOutcomeAccumulator();
24
43
  for (const usage of usages) {
25
44
  if (!isTenantDataExtensionHooks(usage.options)) {
26
45
  throw new Error(`${EXT_TENANT_DATA} registration for "${usage.entityName}" has no destroy function`);
@@ -40,24 +59,28 @@ async function runTenantDataHooks(ctx) {
40
59
  }),
41
60
  registry: ctx.registry,
42
61
  tenantId: ctx.tenantId,
62
+ deadlineAt: ctx.deadlineAt,
43
63
  fileProviderResolver: ctx.fileProviderResolver,
44
64
  log: ctx.log,
45
65
  };
46
- await destroy(hookCtx);
66
+ total.add(await destroy(hookCtx));
47
67
  }
68
+ return total.outcome();
48
69
  }
49
70
  async function eraseSubjectKeys(ctx) {
50
71
  const kms = configuredPiiSubjectKms();
51
72
  if (!kms) {
52
73
  ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
53
74
  // skip: KMS optional — apps without crypto-shredding still run other destroy stages
54
- return;
75
+ return STAGE_DONE;
55
76
  }
56
77
  const memberships = await selectMany(ctx.db, tenantMembershipsTable, {
57
78
  tenantId: ctx.tenantId,
58
79
  });
80
+ const tenantRecordSubject = { kind: "record", entity: "tenant", id: ctx.tenantId };
59
81
  const subjects = [
60
82
  { kind: "tenant", tenantId: ctx.tenantId },
83
+ tenantRecordSubject,
61
84
  ...memberships.map((m) => ({ kind: "user", userId: m.userId })),
62
85
  ];
63
86
  for (const subject of subjects) {
@@ -66,11 +89,16 @@ async function eraseSubjectKeys(ctx) {
66
89
  eraseReason: "tenant-destroy stage subject-keys",
67
90
  });
68
91
  }
92
+ if (ctx.searchAdapter) {
93
+ await purgeSearchDocumentsForSubject(ctx.db, ctx.registry.features, ctx.searchAdapter, subjectIdToKey(tenantRecordSubject), tenantRecordSubject);
94
+ }
95
+ return STAGE_DONE;
69
96
  }
70
97
  async function purgeTenantCache(ctx) {
71
98
  // ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
72
99
  // then this stage is a documented no-op (no cache layer in test stack).
73
100
  ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
101
+ return STAGE_DONE;
74
102
  }
75
103
  async function tombstoneTenantRow(ctx) {
76
104
  const now = getTemporal().Now.instant();
@@ -104,6 +132,7 @@ async function tombstoneTenantRow(ctx) {
104
132
  },
105
133
  }, user, db, { skipOptimisticLock: true });
106
134
  invalidateTenantLifecycleGate(ctx.tenantId);
135
+ return STAGE_DONE;
107
136
  }
108
137
  export const DESTRUCTION_STAGES = [
109
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 };
@@ -3,6 +3,7 @@ export declare const tierAssignmentEntity: import("@cosmicdrift/kumiko-types/fie
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const tierAssignmentEntity: import("@cosmicdrift/kumiko-types/fie
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -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,
@@ -11,6 +11,7 @@ export declare function createTierResolver<TTier extends string, TCaps>(deps: Ti
11
11
  readonly type: "text";
12
12
  readonly description?: string;
13
13
  readonly maxLength?: number;
14
+ readonly minLength?: number;
14
15
  readonly required?: boolean;
15
16
  readonly searchable?: boolean;
16
17
  readonly sortable?: boolean;
@@ -32,6 +33,7 @@ export declare function createTierResolver<TTier extends string, TCaps>(deps: Ti
32
33
  readonly type: "text";
33
34
  readonly description?: string;
34
35
  readonly maxLength?: number;
36
+ readonly minLength?: number;
35
37
  readonly required?: boolean;
36
38
  readonly searchable?: boolean;
37
39
  readonly sortable?: boolean;
@@ -16,6 +16,8 @@ export const detailQuery = {
16
16
  const result = await baseDetail.handler?.(query, ctx);
17
17
  if (result === null || typeof result !== "object")
18
18
  return result;
19
+ if (!ctx.registry.features.has("tenant"))
20
+ return result;
19
21
  const [enriched] = await attachTenantLabels([result], dbForList(ctx));
20
22
  return enriched ?? result;
21
23
  },