@cosmicdrift/kumiko-framework 0.362.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -1,4 +1,5 @@
1
1
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { Queue, UnrecoverableError, Worker } from "bullmq";
3
4
  import { Redis } from "ioredis";
4
5
  import { requestContext } from "../api/request-context.js";
@@ -24,12 +25,11 @@ import { cronRepeatOptions } from "./cron-repeat-options.js";
24
25
  // run proceeds without it rather than failing over what would be a framework
25
26
  // bug, not untrusted input.
26
27
  function isJobTriggerEvent(value) {
27
- if (typeof value !== "object" || value === null)
28
+ if (!isPlainObject(value))
28
29
  return false;
29
- const candidate = value;
30
- if (typeof candidate["id"] !== "string")
30
+ if (typeof value["id"] !== "string")
31
31
  return false;
32
- const headers = candidate["headers"];
32
+ const headers = value["headers"];
33
33
  if (typeof headers !== "object" || headers === null)
34
34
  return false;
35
35
  return Object.values(headers).every((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
@@ -611,12 +611,16 @@ export function createJobRunner(options) {
611
611
  });
612
612
  }
613
613
  }
614
- async function announceDropped(jobName, jobDef, bullJobId) {
614
+ async function announceDropped(jobName, jobDef, bullJobId, data) {
615
615
  // skip: job did not opt in or no logger wired
616
616
  if (!jobDef.tenantVisibleRun || !options.onJobDropped)
617
617
  return;
618
+ const tenantId = tenantIdOfJobData(data);
619
+ // skip: tenant-less run — announceQueued never recorded a row for it
620
+ if (tenantId === SYSTEM_TENANT_ID)
621
+ return;
618
622
  try {
619
- await options.onJobDropped(jobName, bullJobId);
623
+ await options.onJobDropped(jobName, bullJobId, { tenantId });
620
624
  }
621
625
  catch (err) {
622
626
  errorLogger.warn("tenant-visible queued state not cleared", {
@@ -732,7 +736,7 @@ export function createJobRunner(options) {
732
736
  ...buildRetryBullOpts(jobDef, priorAttempts),
733
737
  });
734
738
  if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
735
- await announceDropped(jobName, jobDef, bullJob.id);
739
+ await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
736
740
  }
737
741
  await announceQueued(jobName, jobDef, requeued.id, requeuedData);
738
742
  // skip: lock taken, work re-enqueued with delay, current invocation done
@@ -834,7 +838,7 @@ export function createJobRunner(options) {
834
838
  // must resolve for jobs the same way they do for write-handlers, or an
835
839
  // app-author job calling ctx.notify(...)/ctx.config(...) hits a TypeError
836
840
  // at runtime the write-handler path never would (framework#1532).
837
- const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner);
841
+ const notify = context._notifyFactory?.(jobSystemUser, tenantId, selfRunner, undefined, context._fileProviderResolver);
838
842
  const configDb = context.db; // @cast-boundary db-operator
839
843
  // Shared by the config accessor and ctx.derivatives below — both need the
840
844
  // same tenant-scoped db, and building it twice would let the two calls
@@ -1298,7 +1302,7 @@ export function createJobRunner(options) {
1298
1302
  for (const j of waiting) {
1299
1303
  if (j.name === jobName && j.id) {
1300
1304
  await j.remove();
1301
- await announceDropped(jobName, jobDef, j.id);
1305
+ await announceDropped(jobName, jobDef, j.id, j.data);
1302
1306
  }
1303
1307
  }
1304
1308
  break;
@@ -11,7 +11,7 @@ export declare function createEscapeHatchReportWindow(opts?: {
11
11
  }): EscapeHatchReportWindow;
12
12
  export type EscapeHatchProcessDedup = {
13
13
  readonly kinds: ReadonlySet<EscapeHatchKind>;
14
- readonly seen: Set<string>;
14
+ readonly lastAuditedAt: Map<string, number>;
15
15
  };
16
16
  export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
17
17
  export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
@@ -27,8 +27,11 @@ export function createEscapeHatchReportWindow(opts) {
27
27
  },
28
28
  };
29
29
  }
30
+ // A long-lived worker must leave an audit event inside every retention window
31
+ // (90 days), or its crons vanish from the log once the first event is pruned.
32
+ const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
30
33
  export function createEscapeHatchProcessDedup(kinds) {
31
- return { kinds: new Set(kinds), seen: new Set() };
34
+ return { kinds: new Set(kinds), lastAuditedAt: new Map() };
32
35
  }
33
36
  // Default window for createEscapeHatchReporter callers that don't pass their own.
34
37
  const fallbackReportWindow = createEscapeHatchReportWindow();
@@ -105,11 +108,20 @@ export function createEscapeHatchReporter(opts) {
105
108
  emitEscapeHatchUse(opts.meter, opts.handler, kind);
106
109
  const { processDedup } = opts;
107
110
  if (processDedup?.kinds.has(kind)) {
108
- const processKey = JSON.stringify([opts.handler, kind, opts.tenantId]);
109
- // skip: this (handler, kind, tenant) was already audited by this process
110
- if (processDedup.seen.has(processKey))
111
+ const processKey = JSON.stringify([
112
+ opts.handler,
113
+ kind,
114
+ opts.tenantId,
115
+ target?.id ?? null,
116
+ target?.tenantId ?? null,
117
+ ]);
118
+ const nowMs = now();
119
+ const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
120
+ // skip: audited by this process within the re-audit interval
121
+ if (lastAuditedAt !== undefined && nowMs - lastAuditedAt < PROCESS_REAUDIT_INTERVAL_MS) {
111
122
  return;
112
- processDedup.seen.add(processKey);
123
+ }
124
+ processDedup.lastAuditedAt.set(processKey, nowMs);
113
125
  reportEscapeHatchUse(eventFor(kind, reason, target), deps);
114
126
  // skip: audited above, the window dedup below only serves the other kinds
115
127
  return;
@@ -52,10 +52,13 @@ export function createCascadeDeleteHook(registry, tables) {
52
52
  // block the parent delete here instead of changing with it. The error names
53
53
  // the parent only: the caller may delete the parent without reading the child.
54
54
  if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
55
- const childLock = registry.getEntity(relation.target)?.lockedWhen;
56
- if (childLock !== undefined) {
55
+ const childEntity = registry.getEntity(relation.target);
56
+ const childLock = childEntity?.lockedWhen;
57
+ if (childLock !== undefined && payload.lockGuardBypassed !== true) {
58
+ // A soft-deleted child is invisible to the caller and must not block.
57
59
  const children = await selectMany(db, targetTable, {
58
60
  [relation.foreignKey]: payload.id,
61
+ ...(childEntity?.softDelete === true && { isDeleted: false }),
59
62
  });
60
63
  if (children.some((child) => isRecordLocked(childLock, child))) {
61
64
  throw new RecordLockedError({ entityName, entityId: payload.id });
@@ -1,10 +1,10 @@
1
1
  import { requestContext, runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { transaction } from "../db/query.js";
3
- import { InternalError, toWriteErrorInfo, writeFailure } from "../errors/index.js";
3
+ import { InternalError, toWriteErrorInfo, UnprocessableError, writeFailure, } from "../errors/index.js";
4
4
  import { createFallbackLogger } from "../logging/utils.js";
5
5
  import { parseJsonSafe } from "../utils/safe-json.js";
6
6
  import { resolveDbSource } from "./dispatch-shared.js";
7
- import { executeNestedWrite } from "./dispatch-write.js";
7
+ import { executeNestedWrite, executeWrite } from "./dispatch-write.js";
8
8
  import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
9
9
  import { reserveBeforeTransaction, runAsCommand, } from "./pre-transaction-reservations.js";
10
10
  import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
@@ -22,15 +22,44 @@ function toReservedScope(reservations, index, cmd) {
22
22
  const reservation = reservations.reservedCommands.get(index);
23
23
  return reservation && { type: cmd.type, reservation };
24
24
  }
25
+ function isPerCommandHandler(ctx, type) {
26
+ return ctx.registry.getWriteHandler(type)?.transaction === "perCommand";
27
+ }
25
28
  async function runReservedCommand(scope, cmd, index, tx) {
26
29
  const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
27
30
  const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
28
31
  origins.push(origin);
29
32
  const hookStart = afterCommitHooks.length;
30
- const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
33
+ // Nested-write children would run inline with the in-transaction scope, so a perCommand command skips the expansion.
34
+ const execute = isPerCommandHandler(ctx, cmd.type) ? executeWrite : executeNestedWrite;
35
+ const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => execute(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
31
36
  rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
32
37
  return res;
33
38
  }
39
+ // Sequential fallback without an outer transaction. A failure result and a throw both end the
40
+ // batch, so both give the reserved capacity back.
41
+ async function runCommandsWithoutTransaction(scope, commands, results) {
42
+ try {
43
+ for (let i = 0; i < commands.length; i++) {
44
+ const cmd = commands[i];
45
+ if (!cmd)
46
+ continue;
47
+ const res = await runReservedCommand(scope, cmd, i, undefined);
48
+ results.push(res);
49
+ if (!res.isSuccess) {
50
+ // No tx means no rollback — but we still drop afterCommit hooks,
51
+ // matching the semantic "failure = side-effects don't fire".
52
+ await scope.reservations.releaseAll();
53
+ return { index: i, error: res.error };
54
+ }
55
+ }
56
+ }
57
+ catch (e) {
58
+ await scope.reservations.releaseAll();
59
+ throw e;
60
+ }
61
+ return undefined;
62
+ }
34
63
  // Core batch logic extracted so write() and command() can reuse it
35
64
  // (a single write = batch of one, running in its own transaction).
36
65
  export async function runBatch(ctx, commands, user, requestId, inheritedOrigin) {
@@ -43,6 +72,30 @@ export async function runBatch(ctx, commands, user, requestId, inheritedOrigin)
43
72
  const { signal: _signal, ...withoutSignal } = current;
44
73
  return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId, inheritedOrigin));
45
74
  }
75
+ function perCommandBatchRejection(ctx, commands) {
76
+ const index = commands.findIndex((cmd) => isPerCommandHandler(ctx, cmd.type));
77
+ const command = commands[index];
78
+ if (!command || commands.length < 2)
79
+ return undefined;
80
+ return {
81
+ isSuccess: false,
82
+ error: toWriteErrorInfo(new UnprocessableError("per_command_handler_in_batch", {
83
+ message: `handler "${command.type}" runs without a transaction (transaction: "perCommand") and cannot be part of a multi-command batch`,
84
+ })),
85
+ failedIndex: index,
86
+ results: [],
87
+ };
88
+ }
89
+ async function checkBatchIdempotency(ctx, user, requestId) {
90
+ if (!requestId || !ctx.idempotency)
91
+ return {};
92
+ const checked = await ctx.idempotency.check(user.tenantId, user.id, requestId);
93
+ if (checked.status !== "cached")
94
+ return { token: checked.token };
95
+ const parsed = parseJsonSafe(checked.result, null);
96
+ // corrupted cache entry — treat as miss, let the request re-run
97
+ return parsed ? { cached: parsed } : {};
98
+ }
46
99
  async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
47
100
  const { idempotency, lifecycle, appContext: context } = ctx;
48
101
  if (commands.length === 0) {
@@ -53,19 +106,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
53
106
  // idempotencyToken is only set when we actually acquired the lock — the
54
107
  // corrupted-cache fallthrough below leaves it unset, so finalize() skips
55
108
  // store() rather than writing over an entry it never owned.
56
- let idempotencyToken;
57
- if (requestId && idempotency) {
58
- const checked = await idempotency.check(user.tenantId, user.id, requestId);
59
- if (checked.status === "cached") {
60
- const parsed = parseJsonSafe(checked.result, null);
61
- if (parsed)
62
- return parsed;
63
- // corrupted cache entry — treat as miss, let the request re-run
64
- }
65
- else {
66
- idempotencyToken = checked.token;
67
- }
68
- }
109
+ const idempotencyCheck = await checkBatchIdempotency(ctx, user, requestId);
110
+ if (idempotencyCheck.cached)
111
+ return idempotencyCheck.cached;
112
+ const idempotencyToken = idempotencyCheck.token;
69
113
  // Hooks already ran on the plaintext results; what is returned and cached
70
114
  // for retries must not carry writeOnly values.
71
115
  const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
@@ -148,6 +192,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
148
192
  return;
149
193
  await runWithWriteOrigin(strictest, flushBatchHooksInner);
150
194
  };
195
+ const perCommandRejection = perCommandBatchRejection(ctx, commands);
196
+ if (perCommandRejection)
197
+ return releaseOrFinalize(perCommandRejection, false);
198
+ const runsWithoutTransaction = commands.some((cmd) => isPerCommandHandler(ctx, cmd.type));
151
199
  // Reserve before the transaction opens so no extra connection is held while the handler tx is
152
200
  // open; the release below runs only after that tx has ended without committing.
153
201
  const reservations = await reserveBeforeTransaction(ctx, commands, user, inheritedOrigin);
@@ -162,25 +210,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
162
210
  // batch() opens its own outer transaction — needs the top-level
163
211
  // connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
164
212
  const db = resolveDbSource(ctx, undefined);
165
- if (!db) {
166
- // Without a DB connection there is no transaction to open. Fall back to
213
+ if (!db || runsWithoutTransaction) {
214
+ // Without a DB connection (or for a perCommand handler, whose writes open their own transactions)
215
+ // there is no transaction to open. Fall back to
167
216
  // sequential execution — useful for unit tests that don't touch the DB.
168
217
  // Each command runs independently; a failure stops the batch.
169
- for (let i = 0; i < commands.length; i++) {
170
- const cmd = commands[i];
171
- if (!cmd)
172
- continue;
173
- const res = await runReservedCommand({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, cmd, i, undefined);
174
- results.push(res);
175
- if (!res.isSuccess) {
176
- // No tx means no rollback — but we still drop afterCommit hooks,
177
- // matching the semantic "failure = side-effects don't fire".
178
- await reservations.releaseAll();
179
- return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
180
- }
218
+ const failed = await runCommandsWithoutTransaction({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, commands, results);
219
+ if (failed) {
220
+ return finalize({
221
+ isSuccess: false,
222
+ error: failed.error,
223
+ failedIndex: failed.index,
224
+ results,
225
+ });
181
226
  }
182
227
  await flushAfterCommit();
183
- await flushBatchHooks();
228
+ // The inner commands of a perCommand handler already fired their own batch hooks.
229
+ if (!runsWithoutTransaction)
230
+ await flushBatchHooks();
184
231
  return finalize({ isSuccess: true, results });
185
232
  }
186
233
  let transactionCallbackCompleted = false;
@@ -201,6 +248,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
201
248
  }
202
249
  catch (e) {
203
250
  // Rollback or failed COMMIT: the transaction is over, so the reserved capacity goes back.
251
+ // The release is outcome-safe: it only gives back a reservation row the handler tx did not delete.
204
252
  await reservations.releaseAll();
205
253
  if (e instanceof BatchRollback) {
206
254
  // Thrown inside the callback, so the tx rolled back. A 4xx is
@@ -1,3 +1,4 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { hasAccess } from "../engine/access.js";
2
3
  import { tenantOverrideDenied } from "../engine/cross-tenant.js";
3
4
  import { filterReadFields } from "../engine/field-access.js";
@@ -49,9 +50,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
49
50
  // schema (and custom query schemas that opt in) carries `includeDeleted`,
50
51
  // so other handlers never see the flag. Visibility filters still apply
51
52
  // downstream (see HandlerContext.includeDeleted) — safe from raw input.
52
- const includeDeleted = typeof parsed.data === "object" &&
53
- parsed.data !== null &&
54
- parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
53
+ const includeDeleted = propertyOf(parsed.data, "includeDeleted") === true;
55
54
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
56
55
  return isMemberResolutionPrincipal(user)
57
56
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
@@ -6,7 +6,7 @@ import type { defineTransitions } from "../engine/state-machine.js";
6
6
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
7
7
  import type { TenantId } from "../engine/types/identifiers.js";
8
8
  import type { AppContext, HandlerContext, JobRunnerRef, PayloadRateLimitOption, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
9
- import { type WriteErrorInfo } from "../errors/index.js";
9
+ import { InternalError, type WriteErrorInfo } from "../errors/index.js";
10
10
  import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
11
11
  import { type AfterCommitHook } from "./dispatcher-utils.js";
12
12
  import type { IdempotencyGuard } from "./idempotency.js";
@@ -53,7 +53,12 @@ export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
53
53
  export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
54
54
  export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
55
55
  export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
56
- export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, isPostCommit?: boolean): Promise<HandlerContext>;
56
+ export declare function perCommandNestedDenied(targetType: string): InternalError;
57
+ export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
58
+ export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
59
+ export type HandlerTransactionScope = "inTransaction" | "withoutTransaction" | "postCommit";
60
+ export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope): Promise<HandlerContext>;
61
+ export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
57
62
  export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
58
63
  export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
59
64
  export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;