@cosmicdrift/kumiko-framework 0.353.0 → 0.354.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +67 -25
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +4 -0
  29. package/dist/db/queries/event-store.js +40 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/screen-helpers.d.ts +13 -0
  83. package/dist/engine/screen-helpers.js +31 -0
  84. package/dist/engine/soft-delete-cleanup.js +3 -2
  85. package/dist/engine/steps/_read-source.d.ts +4 -0
  86. package/dist/engine/steps/_read-source.js +22 -0
  87. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  88. package/dist/engine/steps/read-find-many.js +2 -1
  89. package/dist/engine/steps/read-find-one.js +2 -1
  90. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  91. package/dist/engine/steps/workflow-step-error.js +13 -0
  92. package/dist/engine/transfer-adjacency.d.ts +24 -0
  93. package/dist/engine/transfer-adjacency.js +107 -0
  94. package/dist/entrypoint/index.d.ts +2 -0
  95. package/dist/entrypoint/index.js +2 -0
  96. package/dist/env/index.js +45 -23
  97. package/dist/errors/classes.d.ts +8 -0
  98. package/dist/errors/classes.js +13 -0
  99. package/dist/errors/index.d.ts +2 -1
  100. package/dist/errors/index.js +2 -1
  101. package/dist/errors/reasons.d.ts +1 -0
  102. package/dist/errors/reasons.js +3 -0
  103. package/dist/errors/subject-erased.d.ts +2 -0
  104. package/dist/errors/subject-erased.js +9 -0
  105. package/dist/event-store/admin-api.js +14 -10
  106. package/dist/event-store/event-store.js +11 -9
  107. package/dist/event-store/events-schema.js +2 -1
  108. package/dist/files/file-handle.d.ts +1 -1
  109. package/dist/files/file-handle.js +30 -6
  110. package/dist/files/file-routes.js +10 -4
  111. package/dist/files/resolve-content-type.d.ts +2 -0
  112. package/dist/files/resolve-content-type.js +8 -0
  113. package/dist/i18n/required-surface-keys.d.ts +2 -0
  114. package/dist/i18n/required-surface-keys.js +8 -0
  115. package/dist/jobs/index.d.ts +1 -1
  116. package/dist/jobs/job-runner.d.ts +13 -0
  117. package/dist/jobs/job-runner.js +128 -22
  118. package/dist/migrations/pii-event-backfill.js +46 -25
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/standard-metrics.d.ts +4 -0
  122. package/dist/observability/standard-metrics.js +12 -0
  123. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  124. package/dist/pipeline/dispatch-shared.js +74 -9
  125. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  126. package/dist/pipeline/dispatch-stream.js +19 -12
  127. package/dist/pipeline/dispatcher.d.ts +3 -1
  128. package/dist/pipeline/dispatcher.js +10 -15
  129. package/dist/pipeline/event-consumer-state.js +0 -26
  130. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  131. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  132. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  133. package/dist/pipeline/event-dispatcher.js +48 -7
  134. package/dist/pipeline/index.d.ts +2 -0
  135. package/dist/pipeline/index.js +1 -0
  136. package/dist/pipeline/member-read-only-transaction.js +25 -5
  137. package/dist/pipeline/msp-rebuild.js +3 -3
  138. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  139. package/dist/pipeline/pending-gap-ranges.js +22 -0
  140. package/dist/pipeline/system-hooks.js +4 -4
  141. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  142. package/dist/pipeline/system-identity-switch.js +5 -4
  143. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  144. package/dist/redis/cache-sync-topics.d.ts +1 -0
  145. package/dist/redis/cache-sync-topics.js +1 -0
  146. package/dist/redis/client.d.ts +1 -0
  147. package/dist/redis/client.js +7 -0
  148. package/dist/redis/index.d.ts +1 -1
  149. package/dist/redis/index.js +1 -1
  150. package/dist/redis/pubsub-signal.d.ts +1 -0
  151. package/dist/redis/pubsub-signal.js +3 -0
  152. package/dist/search/meilisearch-adapter.js +34 -4
  153. package/dist/stack/test-stack.d.ts +4 -1
  154. package/dist/stack/test-stack.js +9 -2
  155. package/dist/testing/e2e-generator.js +7 -2
  156. package/dist/ui-types/index.d.ts +3 -3
  157. package/dist/ui-types/index.js +2 -2
  158. package/dist/upgrade-cli.js +7 -2
  159. package/package.json +12 -4
  160. package/src/changes.json +198 -0
  161. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -108,10 +108,9 @@ export function userCanReadFieldRow(user, accessMap, row) {
108
108
  const rule = accessMap[role];
109
109
  if (!rule)
110
110
  continue;
111
- // where-rules are SQL predicates (buildOwnershipClause); matchesRule
112
- // can't evaluate them in-memory and throws. Read maps may legitimately
113
- // carry one — it just belongs on the SQL path, so an in-memory read
114
- // check denies rather than crashing.
111
+ // where-rules are SQL predicates; matchesRule can't evaluate them in-memory.
112
+ // The boot validator rejects them on field maps, so this only fails closed
113
+ // for maps that bypassed boot validation.
115
114
  // skip: where-rules are SQL-layer only — fail closed instead of throwing.
116
115
  if (rule !== "all" && rule.kind === "where")
117
116
  continue;
@@ -443,7 +443,9 @@ function dropUnreachableCreateScreen(screen, feature, keptScreenQns) {
443
443
  if (isScreenTargetKept(feature.featureName, screen.createScreen, keptScreenQns))
444
444
  return screen;
445
445
  const { createScreen: _unreachable, ...rest } = screen;
446
- return rest;
446
+ // Marker, not a bare delete: the renderer treats a missing createScreen as
447
+ // "not set" and would fall back to the entity's edit form.
448
+ return { ...rest, createUnavailable: true };
447
449
  }
448
450
  function projectListScreen(original, feature, indices, keptScreenQns) {
449
451
  const screen = dropUnreachableCreateScreen(original, feature, keptScreenQns);
@@ -7,6 +7,7 @@ export declare function populateJobsAndNotifications(state: RegistryState, featu
7
7
  export declare function populateEvents(state: RegistryState, feature: FeatureDefinition): void;
8
8
  export declare function populateSystemEvents(state: RegistryState): void;
9
9
  export declare function populateTranslations(state: RegistryState, feature: FeatureDefinition): void;
10
+ export declare function qualifyTranslationKey(featureName: string, key: string): string;
10
11
  export declare function populateHooks(state: RegistryState, feature: FeatureDefinition): void;
11
12
  export declare function populateExtensionsAndSeeds(state: RegistryState, feature: FeatureDefinition): void;
12
13
  export declare function populateMetricsAndSecrets(state: RegistryState, feature: FeatureDefinition): void;
@@ -1,5 +1,6 @@
1
1
  import { resolveTableName } from "../db/entity-table-meta.js";
2
2
  import { buildMetricName, validateMetricName } from "../observability/index.js";
3
+ import { toKebab } from "./qualified-name.js";
3
4
  import { mergeHookList, mergeHookListQualified, qualify } from "./registry-state.js";
4
5
  import { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
5
6
  // Feature registration + entities (globally-unique, physical-table-checked) + relations
@@ -149,12 +150,16 @@ export function populateTranslations(state, feature) {
149
150
  // bare and qualified spellings for the same key are accepted; if a
150
151
  // feature defines both, whichever is declared last wins (Object.entries
151
152
  // = insertion order, not "qualified always wins").
152
- const prefix = `${feature.name}:`;
153
153
  for (const [key, value] of Object.entries(feature.translations ?? {})) {
154
- const qualifiedKey = key.startsWith(prefix) ? key : `${prefix}${key}`;
155
- state.mergedTranslations[qualifiedKey] = value;
154
+ state.mergedTranslations[qualifyTranslationKey(feature.name, key)] = value;
156
155
  }
157
156
  }
157
+ // Handler QNs use the kebab scope, so a camelCase feature's full QN key
158
+ // (`my-feature:write:x:title`) must count as qualified too.
159
+ export function qualifyTranslationKey(featureName, key) {
160
+ const isQualified = key.startsWith(`${featureName}:`) || key.startsWith(`${toKebab(featureName)}:`);
161
+ return isQualified ? key : `${featureName}:${key}`;
162
+ }
158
163
  // Lifecycle hooks (handler-targeted, qualified) + entity hooks (entity-targeted,
159
164
  // unprefixed) + search-payload-extensions (additive per entity).
160
165
  export function populateHooks(state, feature) {
@@ -25,6 +25,7 @@ import { getStep } from "./define-step.js";
25
25
  import { buildPipelineSteps } from "./pipeline.js";
26
26
  import { SUSPEND_SENTINEL } from "./steps/_step-dispatch-constants.js";
27
27
  import { RETURN_RESULT_KEY } from "./steps/return.js";
28
+ import { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-step-error.js";
28
29
  export async function runPipeline(pipelineDef, event, handlerCtx, workflow, resumeFrom) {
29
30
  const steps = buildPipelineSteps(pipelineDef, event);
30
31
  const stepsAcc = {};
@@ -92,7 +93,17 @@ export async function runStepList(steps, event, handlerCtx, stepsAcc, scopeAcc,
92
93
  workflow: { ...workflow, stepIndex: i },
93
94
  }),
94
95
  };
95
- const value = await stepDef.run(instance.args, pipelineCtx);
96
+ let value;
97
+ try {
98
+ value = await stepDef.run(instance.args, pipelineCtx);
99
+ }
100
+ catch (error) {
101
+ // Workflow runs only: plain handler pipelines must keep surfacing the raw error to the
102
+ // dispatcher's error mapping. Nested lists re-wrap, so the outermost index wins.
103
+ if (!workflow)
104
+ throw error;
105
+ throw new WorkflowStepError(i, unwrapWorkflowStepError(error));
106
+ }
96
107
  // Tier-3 suspension: the step wrote a waiting event and returned
97
108
  // SUSPEND_SENTINEL to signal the pipeline should stop. The caller
98
109
  // (defineWorkflow/workflow-engine) persists the suspension state.
@@ -1,3 +1,4 @@
1
+ import type { FieldDefinition, SelectFieldDef } from "./types/fields.js";
1
2
  import type { ActionFormScreenDefinition, ConfigEditScreenDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, FieldCondition, FormatSpec, ListColumnSpec, ProjectionDetailScreenDefinition, ScreenDefinition, SecretMintScreenDefinition } from "./types/screen.js";
2
3
  export declare function explicitListScreenId(screen: ScreenDefinition): string | undefined;
3
4
  export declare function resolveNavParentScreen(screens: readonly ScreenDefinition[], detail: ScreenDefinition, getId: (screen: ScreenDefinition) => string): ScreenDefinition | undefined;
@@ -24,4 +25,16 @@ export declare function normalizeListColumn(c: ListColumnSpec): Exclude<ListColu
24
25
  * (form-condition closures) reuse it; three hand-rolled copies had
25
26
  * already drifted in shape. */
26
27
  export declare function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean;
28
+ type SelectOptionRules = Pick<SelectFieldDef, "options" | "conditionalOptions">;
29
+ export type UnavailableSelectOption = {
30
+ readonly field: string;
31
+ readonly value: string;
32
+ readonly allowed: readonly string[];
33
+ };
34
+ /** Options of a select field that are offered for the given row values, in declaration order. */
35
+ export declare function availableSelectOptions(field: SelectOptionRules, values: Record<string, unknown>): readonly string[];
36
+ /** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
37
+ * checked when it or one of its condition fields changed, so legacy rows stay editable. */
38
+ export declare function findUnavailableSelectOptions(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): readonly UnavailableSelectOption[];
27
39
  export declare function normalizeEditField(f: EditFieldSpec): Exclude<EditFieldSpec, string>;
40
+ export {};
@@ -110,6 +110,37 @@ export function evalFieldCondition(cond, values) {
110
110
  return !cond.notIn.includes(val);
111
111
  return val !== cond.ne;
112
112
  }
113
+ /** Options of a select field that are offered for the given row values, in declaration order. */
114
+ export function availableSelectOptions(field, values) {
115
+ const rules = field.conditionalOptions;
116
+ if (!rules || rules.length === 0)
117
+ return field.options;
118
+ return field.options.filter((option) => rules.every((rule) => !rule.options.includes(option) || evalFieldCondition(rule.when, values)));
119
+ }
120
+ function isSameValue(a, b) {
121
+ return JSON.stringify(a) === JSON.stringify(b);
122
+ }
123
+ /** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
124
+ * checked when it or one of its condition fields changed, so legacy rows stay editable. */
125
+ export function findUnavailableSelectOptions(fields, values, previous) {
126
+ const unavailable = [];
127
+ for (const [name, def] of Object.entries(fields)) {
128
+ if (def.type !== "select" || !def.conditionalOptions)
129
+ continue;
130
+ const value = values[name];
131
+ if (typeof value !== "string" || value === "")
132
+ continue;
133
+ if (previous) {
134
+ const watched = [name, ...def.conditionalOptions.map((rule) => rule.when.field)];
135
+ if (watched.every((key) => isSameValue(values[key], previous[key])))
136
+ continue;
137
+ }
138
+ const allowed = availableSelectOptions(def, values);
139
+ if (!allowed.includes(value))
140
+ unavailable.push({ field: name, value, allowed });
141
+ }
142
+ return unavailable;
143
+ }
113
144
  export function normalizeEditField(f) {
114
145
  return typeof f === "string" ? { field: f } : f;
115
146
  }
@@ -101,7 +101,8 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
101
101
  continue;
102
102
  const where = { isDeleted: true, deletedAt: { lt: cutoff } };
103
103
  if (isGlobalTenancyTable(proj.table)) {
104
- await db.global(proj.table).deleteMany(where);
104
+ // db.global() rejects writes on executor-managed tables; a retention purge has no event to append.
105
+ await deleteMany(db.unsafeRaw(), proj.table, where);
105
106
  }
106
107
  else {
107
108
  await deleteMany(db, proj.table, where);
@@ -127,7 +128,7 @@ export function buildSoftDeleteCleanupSystemJob() {
127
128
  runIn: "worker",
128
129
  escapeHatch: {
129
130
  reason: "hard-deletes expired soft-deleted rows of global entities (e.g. user) once, system-wide",
130
- grants: ["globalWrites"],
131
+ grants: ["unsafeRaw"],
131
132
  },
132
133
  };
133
134
  }
@@ -1,6 +1,10 @@
1
1
  import type { DbRunner } from "../../db/connection.js";
2
+ import type { WhereObject } from "../../db/query.js";
2
3
  import { type TenantDb } from "../../db/tenant-db.js";
3
4
  import type { PipelineCtx } from "../types/step.js";
4
5
  export declare function readSourceFor(ctx: PipelineCtx, unsafeAllTenants: {
5
6
  readonly reason: string;
6
7
  } | undefined): TenantDb | DbRunner;
8
+ export declare function warnOnNarrowedForeignTenantFilter(ctx: PipelineCtx, stepKind: string, stepName: string, where: WhereObject | undefined, unsafeAllTenants: {
9
+ readonly reason: string;
10
+ } | undefined): void;
@@ -1,5 +1,6 @@
1
1
  import { unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
2
2
  import { InternalError } from "../../errors/classes.js";
3
+ import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
3
4
  // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
4
5
  export function readSourceFor(ctx, unsafeAllTenants) {
5
6
  if (!unsafeAllTenants)
@@ -13,3 +14,24 @@ export function readSourceFor(ctx, unsafeAllTenants) {
13
14
  }
14
15
  return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
15
16
  }
17
+ // TenantDb silently narrows a foreign where.tenantId to the caller's scope; the step keeps that
18
+ // behavior but makes the dropped filter visible, since it usually means a cross-tenant read was intended.
19
+ export function warnOnNarrowedForeignTenantFilter(ctx, stepKind, stepName, where, unsafeAllTenants) {
20
+ // skip: explicit cross-tenant read, nothing was narrowed.
21
+ if (unsafeAllTenants)
22
+ return;
23
+ const requested = where?.["tenantId"];
24
+ // skip: no tenantId filter requested.
25
+ if (requested === undefined)
26
+ return;
27
+ const allowed = [ctx.db.tenantId, SYSTEM_TENANT_ID];
28
+ const requestedList = Array.isArray(requested) ? requested : [requested];
29
+ // skip: every requested tenant is within the caller's scope.
30
+ if (requestedList.every((t) => typeof t === "string" && allowed.includes(t)))
31
+ return;
32
+ ctx.log?.warn(`${stepKind} "${stepName}": where.tenantId is outside the caller's tenant scope and was narrowed`, {
33
+ step: stepKind,
34
+ name: stepName,
35
+ hint: "pass unsafeAllTenants: { reason } (with an escapeHatch on the handler) to read across tenants",
36
+ });
37
+ }
@@ -1,6 +1,8 @@
1
1
  // Workflow events (run-failed, retry.scheduled) outlive an Art. 17 erase, and a
2
2
  // raw error message can echo recipients or payload fragments. Events carry only
3
3
  // this generic text; the raw error goes to the log.
4
+ import { unwrapWorkflowStepError } from "./workflow-step-error.js";
4
5
  export function describeWorkflowStepError(error) {
5
- return `workflow step failed (${error instanceof Error ? error.name : "unknown error"})`;
6
+ const original = unwrapWorkflowStepError(error);
7
+ return `workflow step failed (${original instanceof Error ? original.name : "unknown error"})`;
6
8
  }
@@ -11,7 +11,7 @@
11
11
  // `limit` explicitly when the row-count could grow without bound.
12
12
  import { selectMany } from "../../db/query.js";
13
13
  import { defineStep } from "../define-step.js";
14
- import { readSourceFor } from "./_read-source.js";
14
+ import { readSourceFor, warnOnNarrowedForeignTenantFilter } from "./_read-source.js";
15
15
  import { resolveOptional } from "./_resolver-utils.js";
16
16
  defineStep({
17
17
  kind: "read.findMany",
@@ -19,6 +19,7 @@ defineStep({
19
19
  resultKey: (args) => args.name,
20
20
  run: async (args, ctx) => {
21
21
  const where = resolveOptional(args.where, ctx);
22
+ warnOnNarrowedForeignTenantFilter(ctx, "read.findMany", args.name, where, args.unsafeAllTenants);
22
23
  const source = readSourceFor(ctx, args.unsafeAllTenants);
23
24
  const rows = await selectMany(source, args.table, where, args.limit !== undefined ? { limit: args.limit } : undefined);
24
25
  return rows;
@@ -20,7 +20,7 @@
20
20
  // runtime check; reviewer responsibility.
21
21
  import { selectMany } from "../../db/query.js";
22
22
  import { defineStep } from "../define-step.js";
23
- import { readSourceFor } from "./_read-source.js";
23
+ import { readSourceFor, warnOnNarrowedForeignTenantFilter } from "./_read-source.js";
24
24
  import { resolveRequired } from "./_resolver-utils.js";
25
25
  defineStep({
26
26
  kind: "read.findOne",
@@ -28,6 +28,7 @@ defineStep({
28
28
  resultKey: (args) => args.name,
29
29
  run: async (args, ctx) => {
30
30
  const where = resolveRequired(args.where, ctx);
31
+ warnOnNarrowedForeignTenantFilter(ctx, "read.findOne", args.name, where, args.unsafeAllTenants);
31
32
  const source = readSourceFor(ctx, args.unsafeAllTenants);
32
33
  const rows = await selectMany(source, args.table, where, { limit: 1 });
33
34
  return rows[0] ?? null;
@@ -0,0 +1,5 @@
1
+ export declare class WorkflowStepError extends Error {
2
+ readonly stepIndex: number;
3
+ constructor(stepIndex: number, cause: unknown);
4
+ }
5
+ export declare function unwrapWorkflowStepError(error: unknown): unknown;
@@ -0,0 +1,13 @@
1
+ // Carries the top-level step index a workflow run failed at, so run-failed
2
+ // events don't have to guess it. `cause` stays the original throw.
3
+ export class WorkflowStepError extends Error {
4
+ stepIndex;
5
+ constructor(stepIndex, cause) {
6
+ super(`workflow step ${stepIndex} failed`, { cause });
7
+ this.stepIndex = stepIndex;
8
+ this.name = "WorkflowStepError";
9
+ }
10
+ }
11
+ export function unwrapWorkflowStepError(error) {
12
+ return error instanceof WorkflowStepError ? error.cause : error;
13
+ }
@@ -0,0 +1,24 @@
1
+ import type { EntityDefinition } from "./types/index.js";
2
+ export declare const MAX_TRANSFER_DEPTH = 5;
3
+ export type TransferEdgeLink = {
4
+ readonly kind: "parentRef";
5
+ readonly typeField: string;
6
+ readonly idField: string;
7
+ } | {
8
+ readonly kind: "reference";
9
+ readonly field: string;
10
+ };
11
+ export type TransferEdge = {
12
+ readonly entityName: string;
13
+ readonly entity: EntityDefinition;
14
+ /** The already-resolved type this edge hangs off — its moved row ids are
15
+ * the parent ids the mover matches this edge's rows against. */
16
+ readonly parentEntityName: string;
17
+ readonly link: TransferEdgeLink;
18
+ };
19
+ /** Outgoing edges per type: keyed by the type an edge hangs off, so the mover
20
+ * can ask "what hangs off the rows I just moved" for any type it reaches, at
21
+ * any depth, as often as it reaches it. */
22
+ export type TransferAdjacency = ReadonlyMap<string, readonly TransferEdge[]>;
23
+ export declare function resolveTransferAdjacency(entities: ReadonlyMap<string, EntityDefinition>, rootEntityType?: string): TransferAdjacency;
24
+ export declare function findOverDeepTransferChain(adjacency: TransferAdjacency, onPath: readonly string[]): readonly string[] | undefined;
@@ -0,0 +1,107 @@
1
+ import { parseRefTargetEntityName } from "./parse-ref-target.js";
2
+ // The single definition of the limit. It lives here rather than next to the
3
+ // resolver in bundled-features because the dependency only runs that way:
4
+ // bundled-features imports from the framework, never the reverse.
5
+ //
6
+ // Guards against a schema whose reference edges span more levels than anyone
7
+ // intended — a runaway graph would move rows an operator never associated with
8
+ // the handover. Deliberately a constant and not per-feature config: the limit
9
+ // is a safety net, and a configurable one gets raised by whoever trips it
10
+ // instead of prompting them to reconsider their graph.
11
+ export const MAX_TRANSFER_DEPTH = 5;
12
+ // A `multiple` reference stores a jsonb array rather than a single id column,
13
+ // which the mover's `= ANY($ids)` match cannot address. The boot validator
14
+ // (boot-validator/transfer-graph.ts) rejects that combination on transferable
15
+ // entities, so skipping it here can only ever hit an entity that is not
16
+ // transferable — never a silent partial move.
17
+ // `handover: "stay"` is the declared opt-out: the reference crosses the tenant
18
+ // boundary after a handover on purpose, so the edge is not walked.
19
+ function referenceEdgesFrom(entityName, entity) {
20
+ const edges = [];
21
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
22
+ if (field.type !== "reference")
23
+ continue;
24
+ if (field.multiple === true)
25
+ continue;
26
+ if (field.handover === "stay")
27
+ continue;
28
+ edges.push({
29
+ entityName,
30
+ entity,
31
+ parentEntityName: parseRefTargetEntityName(field.entity),
32
+ link: { kind: "reference", field: fieldName },
33
+ });
34
+ }
35
+ return edges;
36
+ }
37
+ // A parentRef names the types it accepts; without `allowedTypes` it accepts
38
+ // any of them, so the edge is recorded against every registered type.
39
+ function parentRefEdgesFrom(entityName, entity, allEntityNames) {
40
+ const parentRef = entity.parentRef;
41
+ if (!parentRef)
42
+ return [];
43
+ const link = {
44
+ kind: "parentRef",
45
+ typeField: parentRef.entityTypeField,
46
+ idField: parentRef.entityIdField,
47
+ };
48
+ return (parentRef.allowedTypes ?? allEntityNames).map((parentEntityName) => ({
49
+ entityName,
50
+ entity,
51
+ parentEntityName,
52
+ link,
53
+ }));
54
+ }
55
+ // The one definition of the transfer graph, shared by the boot validator
56
+ // (depth limit) and the tenant-handover mover (traversal), so the limit that is
57
+ // checked at boot is measured over exactly the edges that are walked at claim
58
+ // time. Edges are kept for every entity, transferable or not: a
59
+ // non-transferable node in the middle of a chain is still a hop the mover
60
+ // takes before it fails the claim.
61
+ //
62
+ // A type can legitimately be reached by more than one edge — `note.vehicleId`
63
+ // and `note.campaignId` are distinct, and collapsing them by entity name would
64
+ // leave one edge's rows behind, the silent partial move #3088 exists to end.
65
+ export function resolveTransferAdjacency(entities, rootEntityType) {
66
+ const allEntityNames = [...entities.keys()];
67
+ const byParent = new Map();
68
+ for (const [entityName, entity] of entities) {
69
+ // skip: the root is never collected as someone else's descendant. A self
70
+ // reference would otherwise drag unrelated rows of the root's own type
71
+ // across the tenant boundary along with the one that was claimed.
72
+ if (entityName === rootEntityType)
73
+ continue;
74
+ for (const edge of [
75
+ ...parentRefEdgesFrom(entityName, entity, allEntityNames),
76
+ ...referenceEdgesFrom(entityName, entity),
77
+ ]) {
78
+ const edges = byParent.get(edge.parentEntityName) ?? [];
79
+ edges.push(edge);
80
+ byParent.set(edge.parentEntityName, edges);
81
+ }
82
+ }
83
+ return byParent;
84
+ }
85
+ // `onPath` counts NODES, the limit counts EDGES: a chain of exactly
86
+ // MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
87
+ // mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
88
+ // chain whole. Returns the first path that exceeds the limit.
89
+ export function findOverDeepTransferChain(adjacency, onPath) {
90
+ if (onPath.length > MAX_TRANSFER_DEPTH + 1)
91
+ return onPath;
92
+ const startName = onPath[onPath.length - 1];
93
+ if (startName === undefined)
94
+ return undefined;
95
+ for (const edge of adjacency.get(startName) ?? []) {
96
+ // skip: a cycle revisits a type already on this path, and schema depth
97
+ // cannot measure how far it actually runs — that depends on the rows, not
98
+ // the declaration. The mover carries this one instead, failing with
99
+ // `transfer_graph_too_deep` when its rounds run out (#3131).
100
+ if (onPath.includes(edge.entityName))
101
+ continue;
102
+ const deeper = findOverDeepTransferChain(adjacency, [...onPath, edge.entityName]);
103
+ if (deeper !== undefined)
104
+ return deeper;
105
+ }
106
+ return undefined;
107
+ }
@@ -30,6 +30,8 @@ type JobsBlock = {
30
30
  readonly onJobStart?: JobRunnerOptions["onJobStart"];
31
31
  readonly onJobComplete?: JobRunnerOptions["onJobComplete"];
32
32
  readonly onJobFailed?: JobRunnerOptions["onJobFailed"];
33
+ readonly onJobQueued?: JobRunnerOptions["onJobQueued"];
34
+ readonly onJobDropped?: JobRunnerOptions["onJobDropped"];
33
35
  };
34
36
  export type ApiEntrypointOptions = BaseEntrypointOptions & {
35
37
  readonly auth?: AuthRoutesConfig;
@@ -176,6 +176,8 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
176
176
  onJobStart: jobs.onJobStart,
177
177
  onJobComplete: jobs.onJobComplete,
178
178
  onJobFailed: jobs.onJobFailed,
179
+ onJobQueued: jobs.onJobQueued,
180
+ onJobDropped: jobs.onJobDropped,
179
181
  });
180
182
  lifecycle.registerShutdownHook(hookName, async () => {
181
183
  await jobRunner.stop();
package/dist/env/index.js CHANGED
@@ -135,6 +135,42 @@ export function withOptionalEnvKeys(schema, keys) {
135
135
  }
136
136
  return Object.keys(relaxed).length === 0 ? schema : schema.safeExtend(relaxed);
137
137
  }
138
+ // The very same field instance is a shared fragment (several features read one
139
+ // variable), not a conflict; the first declaring feature owns it.
140
+ function isSharedFeatureFragment(featureFields, key, field) {
141
+ return featureFields.get(key) === field;
142
+ }
143
+ // Required beats optional, independent of feature order: a feature outside
144
+ // optionalFeatures must not end up with a silently optional slot.
145
+ function keepStrictestOfSharedFragment(merged, key, field, declaringFeatureIsOptional) {
146
+ if (!declaringFeatureIsOptional)
147
+ merged[key] = field;
148
+ }
149
+ function envVarConflictError(key, firstOwner, secondOwner) {
150
+ return new KumikoBootError([
151
+ {
152
+ name: key,
153
+ kind: "invalid",
154
+ message: `env-var conflict: "${key}" declared by both ` +
155
+ `"${firstOwner}" and "${secondOwner}" — pick one owner.`,
156
+ },
157
+ ]);
158
+ }
159
+ // The twin follows from the slot, so a versioned family (`…_V2`) needs no
160
+ // second declaration. An already declared twin (apps that predate this) stays.
161
+ function declareKmsCiphertextTwins(merged, sources, kmsFields) {
162
+ for (const { name, source } of kmsFields) {
163
+ const twin = `${name}_CIPHERTEXT`;
164
+ if (merged[twin] !== undefined)
165
+ continue;
166
+ merged[twin] = z
167
+ .string()
168
+ .min(1)
169
+ .optional()
170
+ .describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
171
+ sources[twin] = source;
172
+ }
173
+ }
138
174
  export function composeEnvSchema(options) {
139
175
  const optionalSet = new Set(options.optionalFeatures ?? []);
140
176
  const merged = {};
@@ -157,22 +193,20 @@ export function composeEnvSchema(options) {
157
193
  noteKms(key, field, "framework-core");
158
194
  }
159
195
  }
196
+ const featureFields = new Map();
160
197
  for (const feature of options.features) {
161
198
  if (!feature.envSchema)
162
199
  continue;
163
- const shape = zodShape(feature.envSchema);
164
200
  const wrap = optionalSet.has(feature.name);
165
- for (const [key, field] of Object.entries(shape)) {
201
+ for (const [key, field] of Object.entries(zodShape(feature.envSchema))) {
166
202
  if (merged[key] !== undefined) {
167
- throw new KumikoBootError([
168
- {
169
- name: key,
170
- kind: "invalid",
171
- message: `env-var conflict: "${key}" declared by both ` +
172
- `"${sources[key]}" and "${feature.name}" — pick one owner.`,
173
- },
174
- ]);
203
+ if (!isSharedFeatureFragment(featureFields, key, field)) {
204
+ throw envVarConflictError(key, sources[key], feature.name);
205
+ }
206
+ keepStrictestOfSharedFragment(merged, key, field, wrap);
207
+ continue;
175
208
  }
209
+ featureFields.set(key, field);
176
210
  merged[key] = wrap ? optionalKeepingKmsMeta(field) : field;
177
211
  sources[key] = feature.name;
178
212
  noteKms(key, field, feature.name);
@@ -195,19 +229,7 @@ export function composeEnvSchema(options) {
195
229
  noteKms(key, field, "app");
196
230
  }
197
231
  }
198
- // The twin follows from the slot, so a versioned family (`…_V2`) needs no
199
- // second declaration. An already declared twin (apps that predate this) stays.
200
- for (const { name, source } of kmsFields) {
201
- const twin = `${name}_CIPHERTEXT`;
202
- if (merged[twin] !== undefined)
203
- continue;
204
- merged[twin] = z
205
- .string()
206
- .min(1)
207
- .optional()
208
- .describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
209
- sources[twin] = source;
210
- }
232
+ declareKmsCiphertextTwins(merged, sources, kmsFields);
211
233
  return {
212
234
  schema: z.object(merged),
213
235
  sources,
@@ -98,6 +98,14 @@ export declare class InternalError extends KumikoError {
98
98
  readonly httpStatus = 500;
99
99
  constructor(opts?: Pick<ErrorOpts, "message" | "i18nKey" | "i18nParams" | "details" | "cause">);
100
100
  }
101
+ export declare class MemberReadTimeoutError extends KumikoError {
102
+ readonly code = "member_read_timeout";
103
+ readonly httpStatus = 504;
104
+ constructor(opts: {
105
+ readonly timeoutMs: number;
106
+ readonly cause?: Error;
107
+ });
108
+ }
101
109
  export type RateLimitDetails = {
102
110
  readonly bucket: string;
103
111
  readonly limit: number;
@@ -189,6 +189,19 @@ export class InternalError extends KumikoError {
189
189
  });
190
190
  }
191
191
  }
192
+ // A ctx.queryAsMember read ran into its statement timeout (SQLSTATE 57014). Reuses the generic
193
+ // errors.internal i18n key: the client can do nothing but retry, and the code is for log/alert routing.
194
+ export class MemberReadTimeoutError extends KumikoError {
195
+ code = "member_read_timeout";
196
+ httpStatus = 504;
197
+ constructor(opts) {
198
+ super({
199
+ message: `ctx.queryAsMember read exceeded its ${opts.timeoutMs}ms statement timeout`,
200
+ i18nKey: "errors.internal",
201
+ ...(opts.cause && { cause: opts.cause }),
202
+ });
203
+ }
204
+ }
192
205
  export class RateLimitError extends KumikoError {
193
206
  code = "rate_limited";
194
207
  httpStatus = 429;
@@ -1,5 +1,5 @@
1
1
  export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
2
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
3
3
  export type { ErrorDocsConfig } from "./docs-url.js";
4
4
  export { resolveErrorDocsUrl } from "./docs-url.js";
5
5
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
@@ -9,6 +9,7 @@ export type { AgentReason, FrameworkReason } from "./reasons.js";
9
9
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
10
10
  export type { ErrorLogEntry, ErrorResponseBody } from "./serialize.js";
11
11
  export { buildErrorLog, serializeError } from "./serialize.js";
12
+ export { subjectErasedConflict } from "./subject-erased.js";
12
13
  export { toKumikoError } from "./to-kumiko-error.js";
13
14
  export type { InvalidTransitionDetails } from "./transition-details.js";
14
15
  export { buildInvalidTransitionDetails } from "./transition-details.js";
@@ -1,9 +1,10 @@
1
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
1
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, MemberReadTimeoutError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
2
  export { resolveErrorDocsUrl } from "./docs-url.js";
3
3
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
4
4
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
5
5
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
6
6
  export { buildErrorLog, serializeError } from "./serialize.js";
7
+ export { subjectErasedConflict } from "./subject-erased.js";
7
8
  export { toKumikoError } from "./to-kumiko-error.js";
8
9
  export { buildInvalidTransitionDetails } from "./transition-details.js";
9
10
  export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
@@ -12,6 +12,7 @@ export declare const FrameworkReasons: {
12
12
  readonly publicIntakeRequired: "public_intake_required";
13
13
  readonly irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk";
14
14
  readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
15
+ readonly subjectErased: "subject_erased";
15
16
  };
16
17
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
17
18
  export declare const AgentReasons: {
@@ -61,6 +61,9 @@ export const FrameworkReasons = {
61
61
  // delegation via ctx.write/writeAs from a lower-risk handler does not
62
62
  // inherit the gate.
63
63
  instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk",
64
+ // ConflictError: a write touched a personal-data field of a data subject whose
65
+ // key was erased (crypto-shredding); the subject can no longer receive data.
66
+ subjectErased: "subject_erased",
64
67
  };
65
68
  // Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
66
69
  // sister catalog to FrameworkReasons instead of extending it — see the
@@ -0,0 +1,2 @@
1
+ import { ConflictError } from "./classes.js";
2
+ export declare function subjectErasedConflict(cause?: unknown): ConflictError;
@@ -0,0 +1,9 @@
1
+ import { ConflictError } from "./classes.js";
2
+ import { FrameworkReasons } from "./reasons.js";
3
+ export function subjectErasedConflict(cause) {
4
+ return new ConflictError({
5
+ message: "the data subject was erased — its personal data can no longer be written",
6
+ details: { reason: FrameworkReasons.subjectErased },
7
+ ...(cause instanceof Error && { cause }),
8
+ });
9
+ }