@cosmicdrift/kumiko-samples 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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
  5. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  6. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  7. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  8. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  9. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  10. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  11. package/packages/bundled-features/src/audit/changes.json +6 -0
  12. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  13. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  18. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  19. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  21. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  23. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  24. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  25. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  26. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  27. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  30. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  31. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  32. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  33. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  34. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  35. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  36. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  37. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  39. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  40. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  41. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
  42. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  43. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  44. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  45. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  46. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  48. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  49. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  50. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  51. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  52. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  53. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  54. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  55. package/packages/bundled-features/src/delivery/changes.json +6 -0
  56. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  57. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  58. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  59. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  60. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  61. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  62. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  63. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  64. package/packages/bundled-features/src/files/changes.json +24 -0
  65. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  66. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  67. package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
  68. package/packages/bundled-features/src/folders/changes.json +6 -0
  69. package/packages/bundled-features/src/folders/feature.ts +2 -12
  70. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  71. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  72. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  73. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  74. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  75. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  76. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  77. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  78. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  79. package/packages/bundled-features/src/jobs/changes.json +30 -0
  80. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  81. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  82. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  83. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  84. package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
  85. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  86. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  87. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  88. package/packages/bundled-features/src/jobs/index.ts +2 -0
  89. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  90. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  91. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  92. package/packages/bundled-features/src/ledger/changes.json +23 -0
  93. package/packages/bundled-features/src/ledger/entity.ts +17 -5
  94. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  95. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  96. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  97. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  98. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  99. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  100. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  101. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  102. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  103. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  104. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  105. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  106. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  107. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  108. package/packages/bundled-features/src/seo/changes.json +5 -0
  109. package/packages/bundled-features/src/seo/feature.ts +23 -7
  110. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  111. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  112. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  113. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  114. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  115. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  116. package/packages/bundled-features/src/shared/index.ts +4 -2
  117. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  118. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  119. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  120. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  121. package/packages/bundled-features/src/tags/feature.ts +2 -12
  122. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  123. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  125. package/packages/bundled-features/src/tenant/changes.json +19 -0
  126. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  127. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  128. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  129. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  130. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  131. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  132. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  133. package/packages/bundled-features/src/tenant/index.ts +1 -0
  134. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  135. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  136. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  137. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  138. package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
  139. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  140. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  141. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  145. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
  146. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
  147. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
  148. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  149. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  150. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  151. package/packages/bundled-features/src/user/changes.json +15 -0
  152. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  153. package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
  154. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  155. package/packages/bundled-features/src/user/seeding.ts +34 -0
  156. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  157. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  158. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  159. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  160. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  161. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  162. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  163. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  164. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  165. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
  166. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
  167. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  168. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  169. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  170. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  171. package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
  172. package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
  173. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  174. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  175. package/samples/apps/ui-walkthrough/package.json +3 -0
  176. package/samples/apps/use-all-bundled/README.md +1 -2
  177. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  178. package/samples/apps/user-data-rights-demo/README.md +1 -3
  179. package/samples/recipes/legal-pages/README.md +3 -4
  180. package/samples/recipes/managed-pages/README.md +2 -2
  181. package/samples/recipes/public-share-token/README.md +4 -5
  182. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  183. package/samples/recipes/security-baseline/README.md +6 -0
  184. package/samples/recipes/seed-migration/README.md +0 -2
  185. package/samples/recipes/tenant-settings/README.md +1 -1
  186. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -268,7 +268,7 @@ export async function moveTransferGraph(args: {
268
268
  { entityName: rootEntityName, rowIds: [rootRowId] },
269
269
  ];
270
270
 
271
- for (let round = 0; round < MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
271
+ for (let round = 0; round <= MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
272
272
  // Merged per type so an edge leaving a type runs once per round, not once
273
273
  // per edge that reached it.
274
274
  const discovered = new Map<string, string[]>();
@@ -330,25 +330,18 @@ export async function moveTransferGraph(args: {
330
330
  }
331
331
  }
332
332
 
333
- pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
334
- }
333
+ // The extra round past the limit is a probe: it only exists to find out
334
+ // whether rows still hang below the last allowed hop. Its moves are rolled
335
+ // back with the transaction when this throws.
336
+ const [strandedEntityName] = discovered.keys();
337
+ if (round === MAX_TRANSFER_DEPTH && strandedEntityName !== undefined) {
338
+ throw new UnprocessableError("transfer_graph_too_deep", {
339
+ i18nKey: "errors.tenantHandover.transferGraphTooDeep",
340
+ details: { entityName: strandedEntityName },
341
+ });
342
+ }
335
343
 
336
- // The depth limit is a safety net, not a licence to move part of a graph: if
337
- // rounds ran out while rows remain whose type still leads somewhere, the
338
- // declaration reaches further than the mover does, and returning quietly
339
- // would leave exactly the orphaned rows #3088 is about.
340
- //
341
- // Rounds count hops of both edge kinds, while the boot validator measures
342
- // reference chains between transferable entities only. A graph that fills the
343
- // limit with reference hops and then adds a parentRef child therefore boots
344
- // clean and fails here instead — the residual the validator cannot see, and
345
- // the reason this check has to exist rather than trusting boot alone.
346
- const stranded = pending.find(({ entityName }) => (adjacency.get(entityName) ?? []).length > 0);
347
- if (stranded !== undefined) {
348
- throw new UnprocessableError("transfer_graph_too_deep", {
349
- i18nKey: "errors.tenantHandover.transferGraphTooDeep",
350
- details: { entityName: stranded.entityName },
351
- });
344
+ pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
352
345
  }
353
346
 
354
347
  return movedCounts;
@@ -4,18 +4,12 @@
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
@@ -25,22 +19,16 @@
25
19
 
26
20
  import {
27
21
  type EntityDefinition,
28
- parseRefTargetEntityName,
29
22
  type Registry,
23
+ resolveEntityTransferAdjacency,
24
+ type TransferAdjacency,
30
25
  } from "@cosmicdrift/kumiko-framework/engine";
31
26
 
32
- export type TransferEdgeLink =
33
- | { readonly kind: "parentRef"; readonly typeField: string; readonly idField: string }
34
- | { readonly kind: "reference"; readonly field: string };
35
-
36
- export type TransferEdge = {
37
- readonly entityName: string;
38
- readonly entity: EntityDefinition;
39
- /** The already-resolved type this edge hangs off — its moved row ids are
40
- * the parent ids the mover matches this edge's rows against. */
41
- readonly parentEntityName: string;
42
- readonly link: TransferEdgeLink;
43
- };
27
+ export type {
28
+ TransferAdjacency,
29
+ TransferEdge,
30
+ TransferEdgeLink,
31
+ } from "@cosmicdrift/kumiko-framework/engine";
44
32
 
45
33
  export function resolveTransferableRoot(
46
34
  registry: Registry,
@@ -50,79 +38,9 @@ export function resolveTransferableRoot(
50
38
  return entity?.transferable === true ? entity : undefined;
51
39
  }
52
40
 
53
- // A `multiple` reference stores a jsonb array rather than a single id column,
54
- // which the mover's `= ANY($ids)` match cannot address. The boot validator
55
- // (engine/boot-validator/transfer-graph.ts) rejects that combination up front,
56
- // so skipping it here can only ever hit an entity that is not transferable —
57
- // never a silent partial move.
58
- function referenceEdgesFrom(entityName: string, entity: EntityDefinition): readonly TransferEdge[] {
59
- const edges: TransferEdge[] = [];
60
- for (const [fieldName, field] of Object.entries(entity.fields)) {
61
- if (field.type !== "reference") continue;
62
- if (field.multiple === true) continue;
63
- edges.push({
64
- entityName,
65
- entity,
66
- parentEntityName: parseRefTargetEntityName(field.entity),
67
- link: { kind: "reference", field: fieldName },
68
- });
69
- }
70
- return edges;
71
- }
72
-
73
- // A parentRef names the types it accepts; without `allowedTypes` it accepts
74
- // any of them, so the edge is recorded against every registered type.
75
- function parentRefEdgesFrom(
76
- entityName: string,
77
- entity: EntityDefinition,
78
- allEntityNames: readonly string[],
79
- ): readonly TransferEdge[] {
80
- const parentRef = entity.parentRef;
81
- if (!parentRef) return [];
82
- const link: TransferEdgeLink = {
83
- kind: "parentRef",
84
- typeField: parentRef.entityTypeField,
85
- idField: parentRef.entityIdField,
86
- };
87
- return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
88
- entityName,
89
- entity,
90
- parentEntityName,
91
- link,
92
- }));
93
- }
94
-
95
- /** Outgoing edges per type: keyed by the type an edge hangs off, so the mover
96
- * can ask "what hangs off the rows I just moved" for any type it reaches, at
97
- * any depth, as often as it reaches it. */
98
- export type TransferAdjacency = ReadonlyMap<string, readonly TransferEdge[]>;
99
-
100
- // A type can legitimately be reached by more than one edge — `note.vehicleId`
101
- // and `note.campaignId` are distinct, and collapsing them by entity name would
102
- // leave one edge's rows behind, the silent partial move #3088 exists to end.
103
- // Both are kept here; the mover runs each against whatever parent ids it has.
104
41
  export function resolveTransferAdjacency(
105
42
  registry: Registry,
106
43
  rootEntityType: string,
107
44
  ): TransferAdjacency {
108
- const entities = registry.getAllEntities();
109
- const allEntityNames = [...entities.keys()];
110
- const byParent = new Map<string, TransferEdge[]>();
111
-
112
- for (const [entityName, entity] of entities) {
113
- // skip: the root is never collected as someone else's descendant. A self
114
- // reference would otherwise drag unrelated rows of the root's own type
115
- // across the tenant boundary along with the one that was claimed.
116
- if (entityName === rootEntityType) continue;
117
- for (const edge of [
118
- ...parentRefEdgesFrom(entityName, entity, allEntityNames),
119
- ...referenceEdgesFrom(entityName, entity),
120
- ]) {
121
- const edges = byParent.get(edge.parentEntityName) ?? [];
122
- edges.push(edge);
123
- byParent.set(edge.parentEntityName, edges);
124
- }
125
- }
126
-
127
- return byParent;
45
+ return resolveEntityTransferAdjacency(registry.getAllEntities(), rootEntityType);
128
46
  }
@@ -9,8 +9,11 @@ import {
9
9
  isTenantResourceExtensionHooks,
10
10
  type ResolvedPiiFlags,
11
11
  } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
12
13
  import { entitiesOf } from "../shared/index.js";
13
14
 
15
+ const log = createFallbackLogger("kumiko:boot");
16
+
14
17
  // V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
15
18
  // (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
16
19
  // feature's own `r.bootCheck()` (#1314, moved off the framework-internal
@@ -77,3 +80,16 @@ export function validateTenantDestroyHookShapes(features: readonly FeatureDefini
77
80
  }
78
81
  }
79
82
  }
83
+
84
+ // `files-tenant-data` is opt-in (files stays usable without tenant-lifecycle),
85
+ // so a tenant-lifecycle app that mounts `files` but forgets it destroys the
86
+ // tenant's fileRef rows and storage binaries never — only a warning, since
87
+ // some apps legitimately handle file erasure themselves.
88
+ export function warnIfFilesMountedWithoutTenantData(features: readonly FeatureDefinition[]): void {
89
+ const mounted = new Set(features.map((f) => f.name));
90
+ // skip: nothing to cover (no files) or already covered.
91
+ if (!mounted.has("files") || mounted.has("files-tenant-data")) return;
92
+ log.warn(
93
+ '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.',
94
+ );
95
+ }
@@ -1,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "improvement",
5
+ "title": "tenant-lifecycle/testing exports runTenantDestructionSweep, seedDestroyingTenant and driveDestructionToCompletion"
6
+ },
7
+ {
8
+ "version": "0.354.0",
9
+ "type": "improvement",
10
+ "title": "Tenant destroy stages resume partial progress next tick; hooks may return { done: false } and get deadlineAt; file hooks work in chunks",
11
+ "detail": "Tenant destroy stages resume partial progress in the next sweep tick instead of burning failed attempts\nA tenant with tens of thousands of files used to exhaust the stage's three attempts and leave the DSGVO destroy stuck. Destroy hooks (`destroyTenant`, EXT_TENANT_DATA `destroy`) may now return `{ done: false, processed }` and receive `deadlineAt` in their ctx; the runner records a `tenant-destruction-stage-progressed` event in the tenant stream and continues the stage next tick without counting a failed attempt. Hooks returning nothing stay valid. The `fileRef` row purge and the storage prefix wipe work in chunks against that deadline."
12
+ },
13
+ {
14
+ "version": "0.354.0",
15
+ "type": "improvement",
16
+ "title": "Boot warns when files is mounted without files-tenant-data",
17
+ "detail": "files-tenant-data is opt-in, so an app with tenant-lifecycle and files that forgot it silently left fileRef rows and stored binaries behind on tenant destroy. tenant-lifecycle now logs a boot warning in that case. Action: mount createFilesTenantDataFeature(), or ignore the warning if the app erases files itself."
18
+ },
19
+ {
20
+ "version": "0.354.0",
21
+ "type": "improvement",
22
+ "title": "Tenant destroy: a stage tick without progress counts as a failed attempt; done:false requires processed"
23
+ },
2
24
  {
3
25
  "version": "0.337.0",
4
26
  "type": "fix",
@@ -11,6 +11,8 @@ export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT =
11
11
  "tenant-destruction-stage-started" as const;
12
12
  export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT =
13
13
  "tenant-destruction-stage-succeeded" as const;
14
+ export const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT =
15
+ "tenant-destruction-stage-progressed" as const;
14
16
  export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT =
15
17
  "tenant-destruction-stage-failed" as const;
16
18
  export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT =
@@ -26,6 +28,8 @@ export const TENANT_DESTRUCTION_STARTED_EVENT_QN =
26
28
  `${EVENT_PREFIX}${TENANT_DESTRUCTION_STARTED_EVENT_SHORT}` as const;
27
29
  export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN =
28
30
  `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT}` as const;
31
+ export const TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_QN =
32
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT}` as const;
29
33
  export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN =
30
34
  `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT}` as const;
31
35
  export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN =
@@ -54,3 +58,7 @@ export const TENANT_DESTRUCTION_STAGES = [
54
58
  ] as const;
55
59
 
56
60
  export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
61
+
62
+ // The sweep cron fires every minute with concurrency "skip", so a tick must
63
+ // finish well inside that window or the next one is skipped.
64
+ export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
@@ -23,6 +23,12 @@ export const tenantDestructionStageSucceededSchema = z.object({
23
23
  attempts: z.number().int().positive(),
24
24
  });
25
25
 
26
+ export const tenantDestructionStageProgressedSchema = z.object({
27
+ stage: z.string().min(1),
28
+ attempts: z.number().int().positive(),
29
+ processed: z.number().int().nonnegative(),
30
+ });
31
+
26
32
  export const tenantDestructionStageFailedSchema = z.object({
27
33
  stage: z.string().min(1),
28
34
  attempts: z.number().int().positive(),
@@ -9,7 +9,11 @@ import {
9
9
  type FeatureDefinition,
10
10
  type TenantLifecycleStatusPlugin,
11
11
  } from "@cosmicdrift/kumiko-framework/engine";
12
- import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes } from "./boot-checks.js";
12
+ import {
13
+ validateTenantDataHookCoverage,
14
+ validateTenantDestroyHookShapes,
15
+ warnIfFilesMountedWithoutTenantData,
16
+ } from "./boot-checks.js";
13
17
  import {
14
18
  DESTRUCTION_CANCELLED_EVENT_SHORT,
15
19
  DESTRUCTION_REQUESTED_EVENT_SHORT,
@@ -17,6 +21,7 @@ import {
17
21
  TENANT_DESTRUCTION_FAILED_EVENT_SHORT,
18
22
  TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
19
23
  TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT,
24
+ TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT,
20
25
  TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
21
26
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
22
27
  TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
@@ -28,6 +33,7 @@ import {
28
33
  tenantDestructionFailedSchema,
29
34
  tenantDestructionStageAbandonedSchema,
30
35
  tenantDestructionStageFailedSchema,
36
+ tenantDestructionStageProgressedSchema,
31
37
  tenantDestructionStageStartedSchema,
32
38
  tenantDestructionStageSucceededSchema,
33
39
  tenantDestructionStartedSchema,
@@ -78,6 +84,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
78
84
  // guard's "tenant-lifecycle mounted" gate exactly.
79
85
  r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
80
86
  r.bootCheck(({ features }) => validateTenantDestroyHookShapes(features));
87
+ r.bootCheck(({ features }) => warnIfFilesMountedWithoutTenantData(features));
81
88
 
82
89
  r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
83
90
  piiFields: "none",
@@ -100,6 +107,11 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
100
107
  tenantDestructionStageSucceededSchema,
101
108
  { piiFields: "none" },
102
109
  );
110
+ r.defineEvent(
111
+ TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT,
112
+ tenantDestructionStageProgressedSchema,
113
+ { piiFields: "none" },
114
+ );
103
115
  r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema, {
104
116
  piiFields: "none",
105
117
  });
@@ -141,6 +153,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
141
153
  now,
142
154
  log: (message) => ctx.log?.warn(message),
143
155
  fileProviderResolver: ctx._fileProviderResolver,
156
+ searchAdapter: ctx.searchAdapter,
144
157
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
145
158
  actor: ctx.systemUser.id,
146
159
  });
@@ -14,20 +14,24 @@ import {
14
14
  VersionConflictError,
15
15
  } from "@cosmicdrift/kumiko-framework/event-store";
16
16
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
17
+ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
17
18
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
18
19
  import { tenantEntity, tenantTable } from "../tenant/index.js";
19
20
  import {
21
+ DESTRUCTION_STAGE_TICK_BUDGET_MS,
20
22
  TENANT_AGGREGATE_TYPE,
21
23
  TENANT_DESTRUCTION_COMPLETED_EVENT_QN,
22
24
  TENANT_DESTRUCTION_FAILED_EVENT_QN,
23
25
  TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
24
26
  TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT,
27
+ TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT,
25
28
  TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
26
29
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
27
30
  TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
28
31
  } from "./constants.js";
29
32
  import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
30
33
  import {
34
+ type DestructionStage,
31
35
  type DestructionStageCtx,
32
36
  isDestructionPipelineComplete,
33
37
  pickNextStage,
@@ -42,6 +46,7 @@ type StageEventName =
42
46
  | typeof TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT
43
47
  | typeof TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT
44
48
  | typeof TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT
49
+ | typeof TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT
45
50
  | typeof TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT;
46
51
 
47
52
  function qualified(short: StageEventName): string {
@@ -77,6 +82,23 @@ function replayStageState(
77
82
  return { completed, abandoned, attemptsByStage };
78
83
  }
79
84
 
85
+ // A progressed event as the stage's latest lifecycle event means the previous
86
+ // tick handed over mid-stage; its stage-started is still the open one.
87
+ function isStageContinuation(
88
+ events: ReadonlyArray<{ type: string; payload: Record<string, unknown> }>,
89
+ stageName: string,
90
+ ): boolean {
91
+ const stageEventTypes = new Set([
92
+ qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
93
+ qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT),
94
+ qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
95
+ ]);
96
+ const lastStageEvent = events.findLast(
97
+ (event) => stageEventTypes.has(event.type) && event.payload["stage"] === stageName,
98
+ );
99
+ return lastStageEvent?.type === qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT);
100
+ }
101
+
80
102
  function lastEventVersion(events: ReadonlyArray<{ version: number }>): number {
81
103
  const last = events.at(-1);
82
104
  return last?.version ?? 0;
@@ -216,15 +238,53 @@ async function maybeAppendDestructionCompleted(
216
238
  );
217
239
  }
218
240
 
241
+ type StageTickResult = {
242
+ readonly done: boolean;
243
+ readonly error?: string;
244
+ readonly halted?: boolean;
245
+ readonly progressed?: boolean;
246
+ };
247
+
248
+ // `version` differs per caller: the catch path appends against the pre-run
249
+ // version, the no-progress path against the version reloaded after run().
250
+ async function recordFailedStageAttempt(args: {
251
+ readonly db: DbRunner;
252
+ readonly tenantId: TenantId;
253
+ readonly stage: DestructionStage;
254
+ readonly attempt: number;
255
+ readonly error: string;
256
+ readonly version: number;
257
+ }): Promise<StageTickResult> {
258
+ if (args.attempt >= args.stage.maxAttempts) {
259
+ await haltPipelineOnAbandon({
260
+ db: args.db,
261
+ tenantId: args.tenantId,
262
+ stage: args.stage.name,
263
+ attempts: args.attempt,
264
+ error: args.error,
265
+ });
266
+ return { done: false, error: args.error, halted: true };
267
+ }
268
+ await appendTenantStageEvent(
269
+ args.db,
270
+ args.tenantId,
271
+ qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
272
+ { stage: args.stage.name, attempts: args.attempt, error: args.error },
273
+ args.version,
274
+ );
275
+ return { done: false, error: args.error };
276
+ }
277
+
219
278
  export async function runNextDestructionStage(args: {
220
279
  readonly db: DbRunner;
221
280
  readonly registry: Registry;
222
281
  readonly tenantId: TenantId;
223
282
  readonly log?: (message: string) => void;
224
283
  readonly fileProviderResolver?: FileProviderResolver;
284
+ readonly searchAdapter?: SearchAdapter;
225
285
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
226
286
  readonly actor?: string;
227
- }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
287
+ }): Promise<StageTickResult> {
228
288
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
229
289
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
230
290
 
@@ -246,27 +306,53 @@ export async function runNextDestructionStage(args: {
246
306
  tenantId: args.tenantId,
247
307
  log: args.log,
248
308
  fileProviderResolver: args.fileProviderResolver,
309
+ searchAdapter: args.searchAdapter,
249
310
  escapeHatchAuditSink: args.escapeHatchAuditSink,
250
311
  actor: args.actor,
312
+ deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
251
313
  };
252
314
 
253
315
  let version = lastEventVersion(events);
254
- version = await appendTenantStageEventIdempotent(
255
- args.db,
256
- args.tenantId,
257
- qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
258
- { stage: next.name, attempts: attempt },
259
- version,
260
- stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
261
- );
316
+ if (!isStageContinuation(events, next.name)) {
317
+ version = await appendTenantStageEventIdempotent(
318
+ args.db,
319
+ args.tenantId,
320
+ qualified(TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT),
321
+ { stage: next.name, attempts: attempt },
322
+ version,
323
+ stageOutcomeRecorded(events, next.name, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
324
+ );
325
+ }
262
326
 
263
327
  try {
264
- await next.run(ctx);
328
+ const outcome = await next.run(ctx);
265
329
  // next.run() may itself have appended events to this aggregate (e.g.
266
330
  // tombstoneTenantRow updates the tenant row on the same stream) — reload
267
331
  // the version fresh instead of trusting the pre-run `version`, or this
268
332
  // append throws VersionConflictError on every stage whose run() writes.
269
333
  const eventsAfterRun = await loadAggregate(args.db, args.tenantId, args.tenantId);
334
+ // A tick that neither finishes nor processes anything would otherwise loop
335
+ // forever, because progressed events do not count as attempts.
336
+ if (!outcome.done && outcome.processed === 0) {
337
+ return await recordFailedStageAttempt({
338
+ db: args.db,
339
+ tenantId: args.tenantId,
340
+ stage: next,
341
+ attempt,
342
+ error: `stage "${next.name}" made no progress`,
343
+ version: lastEventVersion(eventsAfterRun),
344
+ });
345
+ }
346
+ if (!outcome.done) {
347
+ await appendTenantStageEvent(
348
+ args.db,
349
+ args.tenantId,
350
+ qualified(TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT),
351
+ { stage: next.name, attempts: attempt, processed: outcome.processed },
352
+ lastEventVersion(eventsAfterRun),
353
+ );
354
+ return { done: false, progressed: true };
355
+ }
270
356
  version = await appendTenantStageEventIdempotent(
271
357
  args.db,
272
358
  args.tenantId,
@@ -284,26 +370,14 @@ export async function runNextDestructionStage(args: {
284
370
  await maybeAppendDestructionCompleted(args.db, args.tenantId, completedAfter);
285
371
  return { done: isDestructionPipelineComplete(completedAfter) };
286
372
  } catch (err) {
287
- const message = err instanceof Error ? err.message : String(err);
288
- const isFinal = attempt >= next.maxAttempts;
289
- if (isFinal) {
290
- await haltPipelineOnAbandon({
291
- db: args.db,
292
- tenantId: args.tenantId,
293
- stage: next.name,
294
- attempts: attempt,
295
- error: message,
296
- });
297
- return { done: false, error: message, halted: true };
298
- }
299
- await appendTenantStageEvent(
300
- args.db,
301
- args.tenantId,
302
- qualified(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT),
303
- { stage: next.name, attempts: attempt, error: message },
373
+ return await recordFailedStageAttempt({
374
+ db: args.db,
375
+ tenantId: args.tenantId,
376
+ stage: next,
377
+ attempt,
378
+ error: err instanceof Error ? err.message : String(err),
304
379
  version,
305
- );
306
- return { done: false, error: message };
380
+ });
307
381
  }
308
382
  }
309
383
 
@@ -334,6 +408,7 @@ export async function runTenantDestructionSweep(args: {
334
408
  readonly now?: Temporal.Instant;
335
409
  readonly log?: (message: string) => void;
336
410
  readonly fileProviderResolver?: FileProviderResolver;
411
+ readonly searchAdapter?: SearchAdapter;
337
412
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
338
413
  readonly actor?: string;
339
414
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
@@ -380,6 +455,7 @@ export async function runTenantDestructionSweep(args: {
380
455
  tenantId: row.id as TenantId,
381
456
  log: args.log,
382
457
  fileProviderResolver: args.fileProviderResolver,
458
+ searchAdapter: args.searchAdapter,
383
459
  escapeHatchAuditSink: args.escapeHatchAuditSink,
384
460
  actor: args.actor,
385
461
  });