@cosmicdrift/kumiko-framework 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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,7 +1,7 @@
1
1
  import { validateChangelog } from "./feature-changelog.js";
2
2
  const BLOCK_RE = /<!--\s*kumiko-changes\s*\n([\s\S]*?)\n\s*-->/g;
3
3
  const TYPE_VALUES = new Set(["breaking", "improvement", "fix"]);
4
- const KEY_RE = /^(feature|type|title|detail|migration|codemod):(?:\s(.*))?$/;
4
+ const KEY_RE = /^(feature|type|title|detail|migration|codemod|manualAfterCodemod):(?:\s(.*))?$/;
5
5
  function requiredField(fields, field, source) {
6
6
  const value = fields.get(field)?.trim();
7
7
  if (!value)
@@ -70,6 +70,53 @@ function assertBlockNotClosedEarly(markdown, match, source) {
70
70
  throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
71
71
  }
72
72
  }
73
+ function resolveTitleAndDetail(fields, prose, source) {
74
+ const explicitTitle = fields.get("title")?.trim();
75
+ const title = explicitTitle || prose[0];
76
+ if (!title)
77
+ throw new Error(`${source}: kumiko-changes title is required`);
78
+ // The first prose line is the title's source only when no title was given or
79
+ // the explicit one restates it; otherwise it is genuine detail.
80
+ const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
81
+ const detail = fields.get("detail")?.trim() ||
82
+ prose
83
+ .slice(firstLineIsTitle ? 1 : 0)
84
+ .join("\n")
85
+ .trim();
86
+ return { title, detail };
87
+ }
88
+ function parseManualAfterCodemod(fields, source) {
89
+ const raw = fields.get("manualAfterCodemod")?.trim();
90
+ if (raw !== undefined && raw !== "true" && raw !== "false") {
91
+ throw new Error(`${source}: kumiko-changes manualAfterCodemod must be true or false`);
92
+ }
93
+ return raw === "true";
94
+ }
95
+ function buildChange(fields, prose, source) {
96
+ const feature = requiredField(fields, "feature", source);
97
+ const rawType = requiredField(fields, "type", source);
98
+ if (!TYPE_VALUES.has(rawType)) {
99
+ throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
100
+ }
101
+ const { title, detail } = resolveTitleAndDetail(fields, prose, source);
102
+ const migration = fields.get("migration")?.trim();
103
+ const codemod = fields.get("codemod")?.trim();
104
+ const manualAfterCodemod = parseManualAfterCodemod(fields, source);
105
+ const change = {
106
+ feature,
107
+ type: rawType,
108
+ title,
109
+ ...(detail ? { detail } : {}),
110
+ ...(migration ? { migration } : {}),
111
+ ...(codemod ? { codemod } : {}),
112
+ ...(manualAfterCodemod ? { manualAfterCodemod: true } : {}),
113
+ source,
114
+ };
115
+ const validation = validateChangelog({ version: "0.0.0", ...change });
116
+ if (validation.length > 0)
117
+ throw new Error(`${source}: ${validation.join("; ")}`);
118
+ return change;
119
+ }
73
120
  /** Parse structured upgrade metadata embedded in a Changeset body. */
74
121
  export function parseChangesetChanges(markdown, source) {
75
122
  const changes = [];
@@ -85,38 +132,7 @@ export function parseChangesetChanges(markdown, source) {
85
132
  assertBlockNotClosedEarly(markdown, match, source);
86
133
  if (!block)
87
134
  throw new Error(`${source}: empty kumiko-changes block`);
88
- const fields = parseBlock(block, source);
89
- const feature = requiredField(fields, "feature", source);
90
- const rawType = requiredField(fields, "type", source);
91
- if (!TYPE_VALUES.has(rawType)) {
92
- throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
93
- }
94
- const explicitTitle = fields.get("title")?.trim();
95
- const title = explicitTitle || prose[0];
96
- if (!title)
97
- throw new Error(`${source}: kumiko-changes title is required`);
98
- // The first prose line is the title's source only when no title was given or
99
- // the explicit one restates it; otherwise it is genuine detail.
100
- const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
101
- const detail = fields.get("detail")?.trim() ||
102
- prose
103
- .slice(firstLineIsTitle ? 1 : 0)
104
- .join("\n")
105
- .trim();
106
- const migration = fields.get("migration")?.trim();
107
- const codemod = fields.get("codemod")?.trim();
108
- const change = {
109
- feature,
110
- type: rawType,
111
- title,
112
- ...(detail ? { detail } : {}),
113
- ...(migration ? { migration } : {}),
114
- ...(codemod ? { codemod } : {}),
115
- source,
116
- };
117
- const validation = validateChangelog({ version: "0.0.0", ...change });
118
- if (validation.length > 0)
119
- throw new Error(`${source}: ${validation.join("; ")}`);
135
+ const change = buildChange(parseBlock(block, source), prose, source);
120
136
  changes.push(change);
121
137
  match = BLOCK_RE.exec(markdown);
122
138
  }
@@ -6,15 +6,31 @@ function dedupeOptionsShallowEqual(a, b) {
6
6
  }
7
7
  return true;
8
8
  }
9
+ function sameKeys(a, b) {
10
+ const aKeys = Object.keys(a ?? {});
11
+ const bKeys = new Set(Object.keys(b ?? {}));
12
+ return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
13
+ }
14
+ // dedupeOptions is a declaration, not proof: two same-named features that both
15
+ // carry `{}` but register different surface must still clash, not silently drop one.
16
+ function sameRegisteredSurface(a, b) {
17
+ return (sameKeys(a.entities, b.entities) &&
18
+ sameKeys(a.writeHandlers, b.writeHandlers) &&
19
+ sameKeys(a.queryHandlers, b.queryHandlers) &&
20
+ sameKeys(a.streamHandlers, b.streamHandlers));
21
+ }
9
22
  function isInterchangeable(existing, next) {
10
23
  if (existing === next)
11
24
  return true;
12
25
  if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
13
- return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions);
26
+ return (dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions) &&
27
+ sameRegisteredSurface(existing, next));
14
28
  }
15
29
  return false;
16
30
  }
17
31
  // First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
32
+ // An app holding its own handle to a later, dropped instance would bind state the registry never
33
+ // runs — resolve the kept instance from the composed list (`features.find(name)`), not a local handle.
18
34
  export function dedupeFeatures(features) {
19
35
  const byName = new Map();
20
36
  const result = [];
@@ -22,7 +22,18 @@ import { unwrapArrayForm } from "./object-form.js";
22
22
  function resolveFeatureNamesArgs(args) {
23
23
  return unwrapArrayForm(args, "features");
24
24
  }
25
+ // dedupe-features compares options with Object.is per key, so a nested object
26
+ // would make two structurally identical mounts look different and fail the boot
27
+ // with an unobvious "mounted twice with different options".
28
+ function assertDedupeOptionsComparable(featureName, options) {
29
+ for (const [key, value] of Object.entries(options ?? {})) {
30
+ if (typeof value === "object" && value !== null) {
31
+ throw new Error(`[Feature ${featureName}] dedupeOptions.${key} must be a primitive or a stable function reference; dedupeOptions are compared shallowly, so a nested object never matches a second mount`);
32
+ }
33
+ }
34
+ }
25
35
  export function defineFeature(name, setup, settings) {
36
+ assertDedupeOptionsComparable(name, settings?.dedupeOptions);
26
37
  const state = createInitialFeatureBuilderState();
27
38
  const registrar = {
28
39
  systemScope() {
@@ -4,8 +4,6 @@ import { type EntityTable } from "../db/table-builder.js";
4
4
  import type { EntityCrudRegistrar, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions } from "./types/entity-handlers.js";
5
5
  import type { AccessRule, EntityDefinition, EscapeHatchDeclaration, QueryHandlerDef, WriteHandlerDef } from "./types/index.js";
6
6
  export type { EntityCrudHandlerDefaults, EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "./types/entity-handlers.js";
7
- export declare function isDeprecatedCrossTenantHandler(handler: object): boolean;
8
- export declare const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
9
7
  export declare const MAX_LIST_LIMIT = 200;
10
8
  export declare const entityListSchema: z.ZodObject<{
11
9
  cursor: z.ZodOptional<z.ZodString>;
@@ -60,7 +58,9 @@ export declare function createEntityExecutor(entityName: string, entity: EntityD
60
58
  };
61
59
  export declare function defineProjectionQueryHandler(name: string, projectionQualifiedName: string, options: {
62
60
  access: AccessRule;
63
- unsafeAllTenants?: boolean;
61
+ unsafeAllTenants?: boolean | {
62
+ readonly reason: string;
63
+ };
64
64
  escapeHatch?: EscapeHatchDeclaration;
65
65
  }): QueryHandlerDef;
66
66
  /** Register standard entity CRUD handlers in one call. Access stays explicit — no openToAll default. */
@@ -3,13 +3,15 @@ import * as z from "zod";
3
3
  import { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "../db/eagerload.js";
4
4
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
5
5
  import { buildEntityTable } from "../db/table-builder.js";
6
- import { acknowledgeConventionCrossTenant } from "../db/tenant-db.js";
6
+ import { acknowledgeConventionCrossTenant, reportConventionCrossTenantUse, } from "../db/tenant-db.js";
7
7
  import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
8
8
  import { assertUnreachable } from "../utils/index.js";
9
9
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
10
+ import { codemodPlaceholderReasonProblem } from "./escape-hatch-reason.js";
10
11
  import { dropEmptyWriteOnlyValues } from "./field-access.js";
11
12
  import { instructionFieldNames } from "./instruction-fields.js";
12
13
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
14
+ import { parseTenantId } from "./types/identifiers.js";
13
15
  // Convention-based handler factories for event-sourced aggregates.
14
16
  //
15
17
  // You register one handler per call (no auto-generation of a whole CRUD set),
@@ -46,26 +48,27 @@ import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
46
48
  const WRITE_VERBS = ["create", "update", "delete", "restore"];
47
49
  const QUERY_VERBS = ["list", "detail"];
48
50
  const idSchema = z.object({ id: z.uuid() });
49
- // Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
50
- const deprecatedCrossTenantHandlers = new WeakSet();
51
- export function isDeprecatedCrossTenantHandler(handler) {
52
- return deprecatedCrossTenantHandlers.has(handler);
51
+ const SYSTEM_ADMIN_ROLE = "SystemAdmin";
52
+ // A write handler with escapeHatch reads/writes rows of every tenant, so only an operator
53
+ // role may reach it — otherwise any tenant-level role holder could mutate foreign tenants.
54
+ function isSystemAdminOnly(access) {
55
+ return ("roles" in access &&
56
+ access.roles.length > 0 &&
57
+ access.roles.every((role) => role === SYSTEM_ADMIN_ROLE));
53
58
  }
54
- export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
55
- function resolveCrossTenantReason(name, options) {
56
- if (options.escapeHatch !== undefined && options.crossTenant === true) {
57
- throw new Error(`"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`);
59
+ function resolveCrossTenantReason(name, options, kind) {
60
+ if (options.escapeHatch === undefined)
61
+ return undefined;
62
+ if (options.escapeHatch.reason.trim().length === 0) {
63
+ throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
58
64
  }
59
- if (options.escapeHatch !== undefined) {
60
- if (options.escapeHatch.reason.trim().length === 0) {
61
- throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
62
- }
63
- return options.escapeHatch.reason;
64
- }
65
- if (options.crossTenant === true) {
66
- return `deprecated crossTenant: true on entity convention handler ${name}`;
65
+ const placeholderProblem = codemodPlaceholderReasonProblem(options.escapeHatch.reason);
66
+ if (placeholderProblem !== undefined)
67
+ throw new Error(`"${name}": ${placeholderProblem}`);
68
+ if (kind === "write" && !isSystemAdminOnly(options.access)) {
69
+ throw new Error(`"${name}": a write handler with escapeHatch reaches across every tenant, so its access must be SystemAdmin-only ({ roles: ["${SYSTEM_ADMIN_ROLE}"] }); got ${JSON.stringify(options.access)}.`);
67
70
  }
68
- return undefined;
71
+ return options.escapeHatch.reason;
69
72
  }
70
73
  // Upper bound on entity-list `limit`: unbounded/fractional values ran
71
74
  // straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
@@ -194,29 +197,43 @@ export function defineEntityWriteHandler(name, entity, options) {
194
197
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
195
198
  // TenantDb for these handlers before the ctx.db cutover.
196
199
  //
197
- // crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
200
+ // crossTenantReason (escapeHatch) mirrors the
198
201
  // query-handler branch below: this ONE write handler reads/writes across
199
202
  // every tenant without making the whole feature r.systemScope().
200
- const crossTenantReason = resolveCrossTenantReason(name, options);
203
+ const crossTenantReason = resolveCrossTenantReason(name, options, "write");
204
+ // create always writes into the acting user's own tenant, so its FK,
205
+ // unique and preSave lookups must stay tenant-filtered even when the
206
+ // handler declares cross-tenant access for the row-addressing verbs.
201
207
  const dbFor = (ctx) => {
202
208
  if (ctx.systemDb) {
203
209
  return ctx.systemDb.acknowledgeCrossTenant(`entity convention handler for r.systemScope() feature (${name})`);
204
210
  }
205
- return crossTenantReason !== undefined
206
- ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
211
+ return crossTenantReason !== undefined && verb !== "create"
212
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason, {
213
+ deferReport: reportsPerTouchedRow(ctx),
214
+ })
207
215
  : ctx.db;
208
216
  };
217
+ // actingUserFor reads the row it is about to touch anyway, so for these calls it reports the
218
+ // cross-tenant use per row (with that row as target) instead of dbFor reporting once up front.
219
+ const reportsPerTouchedRow = (ctx) => ctx.systemDb === undefined && crossTenantReason !== undefined && !entity.systemStream;
209
220
  // The event stream is keyed by the acting user's tenantId (streamTenantFor in
210
221
  // event-store-executor-context.ts), not by the db handed to the executor — an
211
222
  // unfiltered "system"-mode db alone reads the foreign row but appends to an
212
223
  // empty stream in the operator's own tenant, surfacing as a bogus
213
224
  // version_conflict. Rewriting the acting tenant also keeps PII envelope keys
214
225
  // derived from the row's tenant rather than the operator's.
215
- const actingUserFor = async (user, id, db) => {
226
+ const actingUserFor = async (user, id, db, ctx) => {
216
227
  if (crossTenantReason === undefined || entity.systemStream)
217
228
  return user;
218
229
  const row = await db.fetchOne(table, { id });
219
230
  const rowTenantId = row?.["tenantId"];
231
+ if (reportsPerTouchedRow(ctx)) {
232
+ const targetTenantId = parseTenantId(rowTenantId);
233
+ reportConventionCrossTenantUse(db, targetTenantId !== null && typeof id === "string"
234
+ ? { id, tenantId: targetTenantId }
235
+ : undefined);
236
+ }
220
237
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
221
238
  return user;
222
239
  return { ...user, tenantId: rowTenantId };
@@ -255,7 +272,7 @@ export function defineEntityWriteHandler(name, entity, options) {
255
272
  ...submitted,
256
273
  changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
257
274
  };
258
- const user = await actingUserFor(event.user, payload.id, db);
275
+ const user = await actingUserFor(event.user, payload.id, db, ctx);
259
276
  // skipUnchanged (#464): API-driven updates diff against the stored
260
277
  // row so a resubmitted-but-identical field doesn't force a fresh
261
278
  // pii/encrypted ciphertext. Direct executor.update() callers (e.g.
@@ -274,7 +291,7 @@ export function defineEntityWriteHandler(name, entity, options) {
274
291
  handler = async (event, ctx) => {
275
292
  const db = dbFor(ctx);
276
293
  const payload = event.payload; // @cast-boundary engine-payload
277
- const user = await actingUserFor(event.user, payload.id, db);
294
+ const user = await actingUserFor(event.user, payload.id, db, ctx);
278
295
  return executor.delete(payload, user, db);
279
296
  };
280
297
  break;
@@ -283,15 +300,13 @@ export function defineEntityWriteHandler(name, entity, options) {
283
300
  handler = async (event, ctx) => {
284
301
  const db = dbFor(ctx);
285
302
  const payload = event.payload; // @cast-boundary engine-payload
286
- const user = await actingUserFor(event.user, payload.id, db);
303
+ const user = await actingUserFor(event.user, payload.id, db, ctx);
287
304
  return executor.restore(payload, user, db);
288
305
  };
289
306
  break;
290
307
  default:
291
308
  assertUnreachable(verb, "write verb");
292
309
  }
293
- if (options.crossTenant === true)
294
- deprecatedCrossTenantHandlers.add(handler);
295
310
  // escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
296
311
  return {
297
312
  name,
@@ -335,13 +350,13 @@ export function defineEntityQueryHandler(name, entity, options) {
335
350
  // useReferenceLookup stays as a fallback (for apps that write custom
336
351
  // handlers by hand without this wrapper).
337
352
  const hasRefFields = collectReferenceFields(entity).length > 0;
338
- const crossTenantReason = resolveCrossTenantReason(name, options);
353
+ const crossTenantReason = resolveCrossTenantReason(name, options, "query");
339
354
  // Preference order:
340
355
  // 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
341
356
  // cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
342
357
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
343
358
  // TenantDb for these handlers before that cutover.
344
- // 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
359
+ // 2. crossTenantReason (escapeHatch) — this
345
360
  // ONE handler reads across every tenant (e.g. a SystemAdmin-only
346
361
  // operator inspector) without making the whole feature r.systemScope()
347
362
  // — that would drop tenant isolation from every OTHER handler the
@@ -386,8 +401,12 @@ export function defineEntityQueryHandler(name, entity, options) {
386
401
  },
387
402
  parentVisibility: { entities: ctx.registry.getAllEntities() },
388
403
  });
404
+ const eagerLoadViewer = {
405
+ user: query.user,
406
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
407
+ };
389
408
  const enrichedRows = hasRefFields
390
- ? await enrichWithReferences(result.rows, entity, (name) => ctx.registry.getEntity(name), db)
409
+ ? await enrichWithReferences(result.rows, entity, (name) => ctx.registry.getEntity(name), db, eagerLoadViewer)
391
410
  : result.rows;
392
411
  return { ...result, rows: augmentDerivedFields(enrichedRows, entity) };
393
412
  };
@@ -401,14 +420,15 @@ export function defineEntityQueryHandler(name, entity, options) {
401
420
  }); // @cast-boundary engine-payload
402
421
  if (row === null || !hasRefFields)
403
422
  return row;
404
- return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db);
423
+ return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db, {
424
+ user: query.user,
425
+ parentVisibility: { entities: ctx.registry.getAllEntities() },
426
+ });
405
427
  };
406
428
  break;
407
429
  default:
408
430
  assertUnreachable(verb, "query verb");
409
431
  }
410
- if (options.crossTenant === true)
411
- deprecatedCrossTenantHandlers.add(handler);
412
432
  // escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
413
433
  return {
414
434
  name,
@@ -497,6 +517,10 @@ export function createEntityExecutor(entityName, entity) {
497
517
  // For anything more involved (filters, joins, custom shaping), write the
498
518
  // query-handler explicitly with ctx.queryProjection or a raw select.
499
519
  export function defineProjectionQueryHandler(name, projectionQualifiedName, options) {
520
+ const escapeHatch = options.escapeHatch ??
521
+ (typeof options.unsafeAllTenants === "object"
522
+ ? { reason: options.unsafeAllTenants.reason }
523
+ : undefined);
500
524
  return {
501
525
  name,
502
526
  schema: z.object({}),
@@ -506,7 +530,7 @@ export function defineProjectionQueryHandler(name, projectionQualifiedName, opti
506
530
  // pagination envelopes or added metadata.
507
531
  handler: async (_query, ctx) => ctx.queryProjection(projectionQualifiedName, options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined), // @wrapper-known semantic-alias
508
532
  access: options.access,
509
- ...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
533
+ ...(escapeHatch && { escapeHatch }),
510
534
  };
511
535
  }
512
536
  function defaultCrudVerbs(entity) {
@@ -539,18 +563,24 @@ export function registerEntityCrud(r, entityName, entity, options) {
539
563
  };
540
564
  const resolveWriteOpts = (verb) => {
541
565
  const excludeFields = verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
566
+ const verbAgent = options?.agents?.[verb];
542
567
  return {
543
568
  ...writeOpts,
544
569
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
545
570
  description: options?.descriptions?.[verb] ?? writeOpts?.description,
571
+ ...(verbAgent !== undefined && { agent: verbAgent }),
546
572
  ...(excludeFields !== undefined && { excludeFields }),
547
573
  };
548
574
  };
549
- const resolveReadOpts = (verb) => ({
550
- ...readOpts,
551
- access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
552
- description: options?.descriptions?.[verb] ?? readOpts?.description,
553
- });
575
+ const resolveReadOpts = (verb) => {
576
+ const verbAgent = options?.agents?.[verb];
577
+ return {
578
+ ...readOpts,
579
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
580
+ description: options?.descriptions?.[verb] ?? readOpts?.description,
581
+ ...(verbAgent !== undefined && { agent: verbAgent }),
582
+ };
583
+ };
554
584
  if (verbs.create) {
555
585
  r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
556
586
  }
@@ -0,0 +1,2 @@
1
+ export declare const CODEMOD_PLACEHOLDER_REASON_MARKER = "state the operator use case here";
2
+ export declare function codemodPlaceholderReasonProblem(reason: string): string | undefined;
@@ -0,0 +1,8 @@
1
+ // Shared by the migrate-cross-tenant codemod (which writes it) and the escapeHatch reason
2
+ // validation (which rejects it), so an unedited codemod reason can never reach a running app.
3
+ export const CODEMOD_PLACEHOLDER_REASON_MARKER = "state the operator use case here";
4
+ export function codemodPlaceholderReasonProblem(reason) {
5
+ if (!reason.includes(CODEMOD_PLACEHOLDER_REASON_MARKER))
6
+ return undefined;
7
+ return `the reason still contains the migrate-cross-tenant codemod placeholder ("${CODEMOD_PLACEHOLDER_REASON_MARKER}") — replace the codemod placeholder with the real operator use case`;
8
+ }
@@ -23,19 +23,18 @@ export declare const EXT_USER_DATA_ORDER: {
23
23
  readonly DEFAULT: 0;
24
24
  };
25
25
  /**
26
- * `tenantData` — Tenant-Destroy-Hooks pro Entity (DSGVO + AVV-Beendigung).
26
+ * `tenantData` — per-entity tenant-destroy hooks (GDPR + end of data processing agreement).
27
27
  *
28
- * Erwartete Hook-Methoden:
29
- * - `destroy(tenantId, ctx) => Promise<void>`
28
+ * Expected hook method: `destroy(ctx) => Promise<TenantDestroyHookResult | void>`.
30
29
  *
31
- * Registriert von: `tenant-lifecycle` (Sprint 5).
32
- * Genutzt von: jedes Feature mit tenantId-Field.
30
+ * Registered by: `tenant-lifecycle`.
31
+ * Consumed by: every feature with a tenantId field.
33
32
  */
34
33
  export declare const EXT_TENANT_DATA: "tenantData";
35
34
  /**
36
35
  * `storageProvider` — file-storage-plugin tenant-destroy hook.
37
36
  *
38
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
37
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
39
38
  *
40
39
  * Registered by: `files-tenant-data`.
41
40
  * Consumed by: pluggable providers (Local, MinIO, S3, R2).
@@ -113,7 +112,7 @@ export declare const EXT_DERIVATIVE_OVERLAY_RESOLVER: "derivativeOverlayResolver
113
112
  * `searchAdapter` — search-index tenant-destroy hook (Meilisearch index
114
113
  * cleanup on tenant-destroy).
115
114
  *
116
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
115
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
117
116
  *
118
117
  * Consumed by: Meilisearch and other search-adapter implementations.
119
118
  */
@@ -122,14 +121,14 @@ export declare const EXT_SEARCH_ADAPTER: "searchAdapter";
122
121
  * `externalResource` — external-service tenant-destroy hook (webhook
123
122
  * subscriptions, Brevo recipient lists, provider customer accounts).
124
123
  *
125
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
124
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
126
125
  */
127
126
  export declare const EXT_EXTERNAL_RESOURCE: "externalResource";
128
127
  /**
129
128
  * `infraResource` — Pulumi-managed per-tenant resource tenant-destroy hook
130
129
  * (custom domain, cert-manager issuer, dedicated pod/volume).
131
130
  *
132
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
131
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
133
132
  */
134
133
  export declare const EXT_INFRA_RESOURCE: "infraResource";
135
134
  /**
@@ -42,19 +42,18 @@ export const EXT_USER_DATA_ORDER = {
42
42
  DEFAULT: 0,
43
43
  };
44
44
  /**
45
- * `tenantData` — Tenant-Destroy-Hooks pro Entity (DSGVO + AVV-Beendigung).
45
+ * `tenantData` — per-entity tenant-destroy hooks (GDPR + end of data processing agreement).
46
46
  *
47
- * Erwartete Hook-Methoden:
48
- * - `destroy(tenantId, ctx) => Promise<void>`
47
+ * Expected hook method: `destroy(ctx) => Promise<TenantDestroyHookResult | void>`.
49
48
  *
50
- * Registriert von: `tenant-lifecycle` (Sprint 5).
51
- * Genutzt von: jedes Feature mit tenantId-Field.
49
+ * Registered by: `tenant-lifecycle`.
50
+ * Consumed by: every feature with a tenantId field.
52
51
  */
53
52
  export const EXT_TENANT_DATA = "tenantData";
54
53
  /**
55
54
  * `storageProvider` — file-storage-plugin tenant-destroy hook.
56
55
  *
57
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
56
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
58
57
  *
59
58
  * Registered by: `files-tenant-data`.
60
59
  * Consumed by: pluggable providers (Local, MinIO, S3, R2).
@@ -142,7 +141,7 @@ export const EXT_DERIVATIVE_OVERLAY_RESOLVER = "derivativeOverlayResolver";
142
141
  * `searchAdapter` — search-index tenant-destroy hook (Meilisearch index
143
142
  * cleanup on tenant-destroy).
144
143
  *
145
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
144
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
146
145
  *
147
146
  * Consumed by: Meilisearch and other search-adapter implementations.
148
147
  */
@@ -151,14 +150,14 @@ export const EXT_SEARCH_ADAPTER = "searchAdapter";
151
150
  * `externalResource` — external-service tenant-destroy hook (webhook
152
151
  * subscriptions, Brevo recipient lists, provider customer accounts).
153
152
  *
154
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
153
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
155
154
  */
156
155
  export const EXT_EXTERNAL_RESOURCE = "externalResource";
157
156
  /**
158
157
  * `infraResource` — Pulumi-managed per-tenant resource tenant-destroy hook
159
158
  * (custom domain, cert-manager issuer, dedicated pod/volume).
160
159
  *
161
- * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
160
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
162
161
  */
163
162
  export const EXT_INFRA_RESOURCE = "infraResource";
164
163
  /**
@@ -1,14 +1,16 @@
1
1
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
2
2
  import type { TenantDb } from "../../db/tenant-db.js";
3
3
  import type { Registry, TenantId } from "../types/index.js";
4
+ import type { TenantDestroyHookResult } from "./tenant-resource.js";
4
5
  export interface TenantDataHookCtx {
5
6
  readonly db: TenantDb;
6
7
  readonly registry: Registry;
7
8
  readonly tenantId: TenantId;
9
+ readonly deadlineAt?: number;
8
10
  readonly fileProviderResolver?: FileProviderResolver;
9
11
  readonly log?: (message: string) => void;
10
12
  }
11
- export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
13
+ export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<TenantDestroyHookResult | void>;
12
14
  export interface TenantDataExtensionHooks {
13
15
  readonly destroy: TenantDataDestroyHook;
14
16
  }
@@ -1,13 +1,21 @@
1
1
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
2
2
  import type { DbRunner } from "../../db/connection.js";
3
3
  import type { TenantId } from "../types/index.js";
4
+ export type TenantDestroyHookResult = {
5
+ readonly done: true;
6
+ readonly processed?: number;
7
+ } | {
8
+ readonly done: false;
9
+ readonly processed: number;
10
+ };
4
11
  export interface TenantResourceHookCtx {
5
12
  readonly tenantId: TenantId;
13
+ readonly deadlineAt?: number;
6
14
  readonly db: DbRunner;
7
15
  readonly fileProviderResolver?: FileProviderResolver;
8
16
  readonly log?: (message: string) => void;
9
17
  }
10
- export type TenantResourceDestroyHook = (tenantId: TenantId, ctx: TenantResourceHookCtx) => Promise<void>;
18
+ export type TenantResourceDestroyHook = (tenantId: TenantId, ctx: TenantResourceHookCtx) => Promise<TenantDestroyHookResult | void>;
11
19
  export interface TenantResourceExtensionHooks {
12
20
  readonly destroyTenant: TenantResourceDestroyHook;
13
21
  }
@@ -35,7 +35,7 @@ export declare function createBooleanField<R extends true | false = false>(overr
35
35
  };
36
36
  export declare function createSelectField<const TOptions extends readonly string[], R extends true | false = false>(opts: {
37
37
  options: TOptions;
38
- } & Partial<Omit<SelectFieldDef<TOptions>, "type" | "options" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
38
+ } & Partial<Omit<SelectFieldDef<NoInfer<TOptions>>, "type" | "options" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
39
39
  required?: R;
40
40
  }): SelectFieldDef<TOptions> & {
41
41
  required: R;
@@ -1,8 +1,7 @@
1
1
  import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
2
2
  import type * as z from "zod";
3
- import type { ZodType } from "zod";
4
3
  import type { HttpRouteDefinition } from "./types/http-route.js";
5
- import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
4
+ import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
6
5
  import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
7
6
  import type { ScreenDefinition } from "./types/screen.js";
8
7
  import type { WorkspaceDefinition } from "./types/workspace.js";
@@ -23,11 +22,7 @@ export type FeatureBuilderState = {
23
22
  configKeys: Record<string, ConfigKeyDefinition>;
24
23
  configSeeds: ConfigSeedDef[];
25
24
  jobs: Record<string, JobDefinition>;
26
- events: Record<string, {
27
- name: string;
28
- schema: ZodType;
29
- version: number;
30
- }>;
25
+ events: Record<string, EventDef>;
31
26
  eventMigrations: Record<string, EventMigrationDef[]>;
32
27
  configReads: string[];
33
28
  entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
@@ -8,6 +8,8 @@ export type ChangelogEntry = {
8
8
  readonly migration?: string;
9
9
  /** Path (relative to packages/framework/src/, under scripts/codemod/) run by `kumiko upgrade --apply`. */
10
10
  readonly codemod?: string;
11
+ /** The codemod only covers part of the migration: the entry is also recorded as pending manual work after it ran. */
12
+ readonly manualAfterCodemod?: boolean;
11
13
  };
12
14
  export type FeatureChangelog = {
13
15
  readonly feature: string;
@@ -33,6 +33,9 @@ function isChangelogEntry(value) {
33
33
  return false;
34
34
  if (typeof obj["title"] !== "string")
35
35
  return false;
36
+ if (obj["manualAfterCodemod"] !== undefined && typeof obj["manualAfterCodemod"] !== "boolean") {
37
+ return false;
38
+ }
36
39
  return true;
37
40
  }
38
41
  export function validateChangelog(entry) {
@@ -43,6 +46,9 @@ export function validateChangelog(entry) {
43
46
  if (entry.type === "breaking" && entry.migration?.trim() === "") {
44
47
  errors.push(`breaking change "${entry.title}" has empty migration field`);
45
48
  }
49
+ if (entry.manualAfterCodemod && !entry.codemod) {
50
+ errors.push(`change "${entry.title}" sets manualAfterCodemod without a codemod field`);
51
+ }
46
52
  return errors;
47
53
  }
48
54
  export function compareVersions(a, b) {