@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -80,13 +80,20 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
80
80
  if (isSystemScopedSoftDeleteTable(proj.table, entity))
81
81
  continue;
82
82
  const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
83
- await purgeExpiredRows(db, proj.source, proj.table, where, ctx);
83
+ await purgeExpiredRows(db, proj.source, proj.table, where, cutoff, ctx);
84
84
  }
85
85
  };
86
- async function purgeExpiredRows(db, source, table, where, ctx) {
86
+ async function purgeExpiredRows(db, source, table, where, cutoff, ctx) {
87
87
  if (source === FILE_REF_ENTITY_NAME) {
88
88
  // The row is the only record of the storageKey, so its bytes go first.
89
- await purgeExpiredFileRefsWithBytes({ db, table, where, files: ctx.files, log: ctx.log });
89
+ await purgeExpiredFileRefsWithBytes({
90
+ db,
91
+ table,
92
+ where,
93
+ expiredBefore: cutoff,
94
+ files: ctx.files,
95
+ log: ctx.log,
96
+ });
90
97
  }
91
98
  else {
92
99
  await deleteMany(db, table, where);
@@ -1,4 +1,4 @@
1
- import { unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
1
+ import { requestedTenantIds, unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
2
2
  import { InternalError } from "../../errors/classes.js";
3
3
  import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
4
4
  // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
@@ -25,7 +25,10 @@ export function warnOnNarrowedForeignTenantFilter(ctx, stepKind, stepName, where
25
25
  if (requested === undefined)
26
26
  return;
27
27
  const allowed = [ctx.db.tenantId, SYSTEM_TENANT_ID];
28
- const requestedList = Array.isArray(requested) ? requested : [requested];
28
+ const requestedList = requestedTenantIds(requested);
29
+ // skip: unrecognised operator form, narrowing cannot be judged.
30
+ if (requestedList === undefined)
31
+ return;
29
32
  // skip: every requested tenant is within the caller's scope.
30
33
  if (requestedList.every((t) => typeof t === "string" && allowed.includes(t)))
31
34
  return;
@@ -11,6 +11,7 @@ import { defineStep } from "../define-step.js";
11
11
  import { runStepList } from "../run-pipeline.js";
12
12
  import { SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, } from "./_step-dispatch-constants.js";
13
13
  import { describeWorkflowStepError } from "./describe-workflow-step-error.js";
14
+ import { unwrapWorkflowStepError } from "./workflow-step-error.js";
14
15
  const log = createFallbackLogger("workflow-retry");
15
16
  defineStep({
16
17
  kind: "workflow.retry",
@@ -38,7 +39,7 @@ defineStep({
38
39
  workflowName: ctx.workflow.workflowName,
39
40
  stepIndex: ctx.workflow.stepIndex,
40
41
  attempt,
41
- error: String(error),
42
+ error: String(unwrapWorkflowStepError(error)),
42
43
  });
43
44
  const backoffMs = calculateBackoff(attempt, args.backoff);
44
45
  const wakeAt = Temporal.Now.instant().add({ milliseconds: backoffMs }).toString();
@@ -16,7 +16,7 @@
16
16
  // must commit in the same TX as the aggregate-mutation that triggered
17
17
  // it (stronger consistency than an async projection). Reviewer judges.
18
18
  import { deleteMany } from "../../db/query.js";
19
- import { tenantDbRunner } from "../../db/tenant-db-runner.js";
19
+ import { tenantDbWriteRunner } from "../../db/tenant-db-runner.js";
20
20
  import { defineStep } from "../define-step.js";
21
21
  import { resolveRequired } from "./_resolver-utils.js";
22
22
  defineStep({
@@ -27,7 +27,7 @@ defineStep({
27
27
  // The `unsafe*`-step IS the sanctioned direct projection write — it exists
28
28
  // to bypass the executor deliberately (boot-validated allowlist + reviewer
29
29
  // gate). The brand-strip cast makes that explicit at the one blessed seam.
30
- await deleteMany(tenantDbRunner(ctx.db), args.table, where);
30
+ await deleteMany(tenantDbWriteRunner(ctx.db, "unsafe projection delete"), args.table, where);
31
31
  },
32
32
  });
33
33
  export function buildUnsafeProjectionDeleteStep(args) {
@@ -13,7 +13,7 @@
13
13
  import { extractTableName } from "../../db/index.js";
14
14
  import { executeRawQuery } from "../../db/queries/raw-sql.js";
15
15
  import { columnSqlNameOf } from "../../db/table-introspection.js";
16
- import { tenantDbRunner } from "../../db/tenant-db-runner.js";
16
+ import { tenantDbWriteRunner } from "../../db/tenant-db-runner.js";
17
17
  import { defineStep } from "../define-step.js";
18
18
  import { resolveRequired } from "./_resolver-utils.js";
19
19
  function resolveColumnName(table, field) {
@@ -54,7 +54,7 @@ defineStep({
54
54
  }
55
55
  const sqlText = `INSERT INTO ${quoteIdent(tableName)} (${colNames.join(", ")}) VALUES (${placeholders.join(", ")}) ` +
56
56
  `ON CONFLICT (${conflictCols}) DO UPDATE SET ${setClauses.join(", ")}`;
57
- await executeRawQuery(tenantDbRunner(ctx.db), sqlText, params);
57
+ await executeRawQuery(tenantDbWriteRunner(ctx.db, "unsafe projection upsert"), sqlText, params);
58
58
  },
59
59
  });
60
60
  export function buildUnsafeProjectionUpsertStep(args) {
@@ -11,6 +11,9 @@ export declare const templateVariable: {
11
11
  readonly link: (opts?: CommonOptions & {
12
12
  readonly example?: string;
13
13
  }) => TemplateScalarVariableDef;
14
+ readonly richText: (opts?: CommonOptions & {
15
+ readonly example?: string;
16
+ }) => TemplateScalarVariableDef;
14
17
  readonly money: (opts?: CommonOptions & {
15
18
  readonly example?: number | {
16
19
  readonly amount: number;
@@ -10,6 +10,7 @@ export const templateVariable = {
10
10
  type: "link",
11
11
  ...opts,
12
12
  }),
13
+ richText: (opts = {}) => ({ type: "richText", ...opts }),
13
14
  money: (opts = {}) => ({ type: "money", ...opts }),
14
15
  date: (opts = {}) => ({
15
16
  type: "date",
@@ -39,6 +40,8 @@ function scalarToZod(def) {
39
40
  switch (def.type) {
40
41
  case "text":
41
42
  return z.string();
43
+ case "richText":
44
+ return z.string();
42
45
  case "link":
43
46
  return linkSchema;
44
47
  case "money":
@@ -61,4 +61,5 @@ export type TierResolverPlugin = {
61
61
  * plugin shape — the only thing every implementation must provide is the
62
62
  * `build` factory. */
63
63
  export declare function isTierResolverPlugin(v: unknown): v is TierResolverPlugin;
64
+ export declare function requireTierResolverPlugin(usage: RegistrarExtensionRegistration): TierResolverPlugin;
64
65
  export declare function findTierResolverUsage(features: readonly FeatureDefinition[]): RegistrarExtensionRegistration | undefined;
@@ -42,6 +42,12 @@ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
42
42
  export function isTierResolverPlugin(v) {
43
43
  return isPlainObject(v) && typeof v["build"] === "function";
44
44
  }
45
+ export function requireTierResolverPlugin(usage) {
46
+ if (!isTierResolverPlugin(usage.options)) {
47
+ throw new Error("tier-resolver registration options must provide a build() factory");
48
+ }
49
+ return usage.options;
50
+ }
45
51
  export function findTierResolverUsage(features) {
46
52
  for (const feature of features) {
47
53
  for (const usage of feature.extensionUsages) {
@@ -7,7 +7,7 @@ export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsM
7
7
  export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
9
  export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
- export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
10
+ export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
13
13
  export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
@@ -3,7 +3,7 @@ import type { AuthRoutesConfig } from "../api/auth-routes.js";
3
3
  import type { JwtHelper, JwtKeyring } from "../api/jwt.js";
4
4
  import type { ServerOptions } from "../api/server.js";
5
5
  import type { SseBroker } from "../api/sse-broker.js";
6
- import type { PgClient } from "../db/connection.js";
6
+ import type { PgClient, PgListenClient } from "../db/connection.js";
7
7
  import type { AppContext, Registry } from "../engine/types/index.js";
8
8
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner.js";
9
9
  import type { Lifecycle } from "../lifecycle/index.js";
@@ -82,4 +82,4 @@ export type AllInOneEntrypoint = Omit<ApiEntrypoint, "mode"> & Omit<WorkerEntryp
82
82
  export declare function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint;
83
83
  export declare function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint;
84
84
  export declare function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint;
85
- export type { PgClient };
85
+ export type { PgClient, PgListenClient };
@@ -2,6 +2,7 @@ import type * as z from "zod";
2
2
  export type ZodDef = {
3
3
  readonly innerType?: z.ZodType;
4
4
  readonly in?: z.ZodType;
5
+ readonly element?: z.ZodType;
5
6
  readonly defaultValue?: unknown;
6
7
  };
7
8
  /** Read Zod v4 `.meta()` value. Undefined when no meta is set. */
@@ -223,6 +223,7 @@ export class RateLimitError extends KumikoError {
223
223
  message: `rate limited: ${details.bucket} (limit ${details.limit} per ${details.windowSeconds}s)`,
224
224
  i18nKey: opts?.i18nKey ?? "errors.rate_limited",
225
225
  details,
226
+ retryAfterSeconds: details.retryAfterSeconds,
226
227
  ...(opts?.cause && { cause: opts.cause }),
227
228
  });
228
229
  this.details = details;
@@ -20,17 +20,17 @@ function isCoveredByConfig(reason, errorDocs) {
20
20
  function errorsUrl(baseUrl, slug) {
21
21
  return `${baseUrl}/errors/${encodeURIComponent(slug)}`;
22
22
  }
23
- // A reason the framework documents (or the error code itself) links to the framework docs,
24
- // overridable via KUMIKO_DOCS_URL. An app's own reason links only where `errorDocs` covers it —
25
- // otherwise the framework docs would have no page for it.
23
+ // A reason the framework documents (or no reason, or the code itself) links to the framework docs, overridable via
24
+ // KUMIKO_DOCS_URL. An app's own reason links to the app docs where `errorDocs` covers it; every
25
+ // other reason falls back to the framework page of the error code, which exists for each code.
26
26
  export function resolveErrorDocsUrl(err, errorDocs) {
27
27
  const reason = reasonOf(err);
28
+ const frameworkBaseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
28
29
  if (reason === undefined || reason === err.code || frameworkReasons.has(reason)) {
29
- const baseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
30
- return errorsUrl(baseUrl, reason ?? err.code);
30
+ return errorsUrl(frameworkBaseUrl, reason ?? err.code);
31
31
  }
32
32
  if (errorDocs && isCoveredByConfig(reason, errorDocs)) {
33
33
  return errorsUrl(errorDocs.baseUrl, reason);
34
34
  }
35
- return undefined;
35
+ return errorsUrl(frameworkBaseUrl, err.code);
36
36
  }
@@ -9,7 +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
+ export { isSubjectErasedConflict, subjectErasedConflict } from "./subject-erased.js";
13
13
  export { toKumikoError } from "./to-kumiko-error.js";
14
14
  export type { InvalidTransitionDetails } from "./transition-details.js";
15
15
  export { buildInvalidTransitionDetails } from "./transition-details.js";
@@ -4,7 +4,7 @@ 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
+ export { isSubjectErasedConflict, subjectErasedConflict } from "./subject-erased.js";
8
8
  export { toKumikoError } from "./to-kumiko-error.js";
9
9
  export { buildInvalidTransitionDetails } from "./transition-details.js";
10
10
  export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
@@ -11,6 +11,7 @@ export type ErrorCtorInput = {
11
11
  readonly i18nParams?: Readonly<Record<string, unknown>>;
12
12
  readonly details?: unknown;
13
13
  readonly cause?: Error;
14
+ readonly retryAfterSeconds?: number;
14
15
  };
15
16
  export declare abstract class KumikoError extends Error {
16
17
  abstract readonly code: string;
@@ -18,6 +19,7 @@ export declare abstract class KumikoError extends Error {
18
19
  readonly i18nKey: string;
19
20
  readonly i18nParams: Readonly<Record<string, unknown>> | undefined;
20
21
  readonly details: unknown;
22
+ readonly retryAfterSeconds: number | undefined;
21
23
  constructor(input: ErrorCtorInput);
22
24
  }
23
25
  export declare function isKumikoError(e: unknown): e is KumikoError;
@@ -6,12 +6,14 @@ export class KumikoError extends Error {
6
6
  i18nKey;
7
7
  i18nParams;
8
8
  details;
9
+ retryAfterSeconds;
9
10
  constructor(input) {
10
11
  super(input.message, input.cause ? { cause: input.cause } : undefined);
11
12
  this.name = new.target.name;
12
13
  this.i18nKey = input.i18nKey;
13
14
  this.i18nParams = input.i18nParams;
14
15
  this.details = input.details;
16
+ this.retryAfterSeconds = input.retryAfterSeconds;
15
17
  }
16
18
  }
17
19
  export function isKumikoError(e) {
@@ -1,2 +1,3 @@
1
1
  import { ConflictError } from "./classes.js";
2
2
  export declare function subjectErasedConflict(cause?: unknown): ConflictError;
3
+ export declare function isSubjectErasedConflict(e: unknown): e is ConflictError;
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { ConflictError } from "./classes.js";
2
3
  import { FrameworkReasons } from "./reasons.js";
3
4
  export function subjectErasedConflict(cause) {
@@ -7,3 +8,8 @@ export function subjectErasedConflict(cause) {
7
8
  ...(cause instanceof Error && { cause }),
8
9
  });
9
10
  }
11
+ export function isSubjectErasedConflict(e) {
12
+ return (e instanceof ConflictError &&
13
+ isPlainObject(e.details) &&
14
+ e.details["reason"] === FrameworkReasons.subjectErased);
15
+ }
@@ -69,6 +69,7 @@ export function toWriteErrorInfo(err) {
69
69
  message: err.message,
70
70
  ...(err.i18nParams && { i18nParams: err.i18nParams }),
71
71
  ...(effectiveDetails !== undefined && { details: effectiveDetails }),
72
+ ...(err.retryAfterSeconds !== undefined && { retryAfterSeconds: err.retryAfterSeconds }),
72
73
  };
73
74
  if (err.cause instanceof Error)
74
75
  causeByWriteErrorInfo.set(info, err.cause);
@@ -91,6 +92,7 @@ class ReraisedError extends KumikoError {
91
92
  i18nKey: info.i18nKey,
92
93
  ...(info.i18nParams && { i18nParams: info.i18nParams }),
93
94
  ...(info.details !== undefined && { details: info.details }),
95
+ ...(info.retryAfterSeconds !== undefined && { retryAfterSeconds: info.retryAfterSeconds }),
94
96
  ...(cause && { cause }),
95
97
  });
96
98
  this.code = info.code;
@@ -7,6 +7,7 @@
7
7
  // Guard-Script `scripts/guard-admin-api.ts` blockt Aufrufe aus App-Code —
8
8
  // Allowlist: samples/*/migration/, scripts/migrations/, die Definition
9
9
  // selbst, das Guard-Script selbst.
10
+ import { instantToTimestamptzParam } from "../bun-db/query.js";
10
11
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
11
12
  import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
12
13
  import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
@@ -53,7 +54,7 @@ function rawEventParams(event, newVersion, eventVersion) {
53
54
  eventVersion,
54
55
  payload: event.payload,
55
56
  metadata: event.metadata,
56
- createdAt: event.createdAt.toString(),
57
+ createdAt: instantToTimestamptzParam(event.createdAt),
57
58
  createdBy: event.createdBy,
58
59
  };
59
60
  }
@@ -88,7 +89,7 @@ export async function appendRawBatch(runner, events) {
88
89
  const newVersion = e.expectedVersion + 1;
89
90
  const eventVersion = e.eventVersion ?? 1;
90
91
  const baseIdx = params.length;
91
- params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, e.createdAt.toString(), e.createdBy);
92
+ params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, instantToTimestamptzParam(e.createdAt), e.createdBy);
92
93
  return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
93
94
  });
94
95
  try {
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  export declare class VersionConflictError extends Error {
2
3
  readonly aggregateId: string;
3
4
  readonly expectedVersion: number;
@@ -8,6 +9,14 @@ export declare class IdempotentAppendConflictError extends Error {
8
9
  readonly idempotencyKey: string;
9
10
  constructor(tenantId: string, idempotencyKey: string);
10
11
  }
12
+ export declare class SeedTimeOrderError extends Error {
13
+ readonly aggregateType: string;
14
+ readonly aggregateId: string;
15
+ readonly version: number;
16
+ readonly seedCreatedAt: Temporal.Instant;
17
+ readonly predecessorCreatedAt: Temporal.Instant;
18
+ constructor(aggregateType: string, aggregateId: string, version: number, seedCreatedAt: Temporal.Instant, predecessorCreatedAt: Temporal.Instant);
19
+ }
11
20
  export declare class ArchivedStreamError extends Error {
12
21
  readonly tenantId: string;
13
22
  readonly aggregateId: string;
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // Failure modes of the event-store's append() path. Surfaced as typed
2
3
  // errors so the executor layer can map them to the framework's
3
4
  // WriteResult error contract (version_conflict).
@@ -31,6 +32,25 @@ export class IdempotentAppendConflictError extends Error {
31
32
  this.idempotencyKey = idempotencyKey;
32
33
  }
33
34
  }
35
+ // Thrown when a back-dated seed write would put a stream's next version
36
+ // before its predecessor in time. loadAggregateAsOf and projection rebuilds
37
+ // order by created_at, so such a stream would read and replay wrongly.
38
+ export class SeedTimeOrderError extends Error {
39
+ aggregateType;
40
+ aggregateId;
41
+ version;
42
+ seedCreatedAt;
43
+ predecessorCreatedAt;
44
+ constructor(aggregateType, aggregateId, version, seedCreatedAt, predecessorCreatedAt) {
45
+ super(`Seed write for ${aggregateType} ${aggregateId} v${version} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessorCreatedAt.toString()}). Write a stream's events in ascending time.`);
46
+ this.name = "SeedTimeOrderError";
47
+ this.aggregateType = aggregateType;
48
+ this.aggregateId = aggregateId;
49
+ this.version = version;
50
+ this.seedCreatedAt = seedCreatedAt;
51
+ this.predecessorCreatedAt = predecessorCreatedAt;
52
+ }
53
+ }
34
54
  // Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
35
55
  // are read-only — restoreStream() makes them writable again. The archive
36
56
  // state is not carried on the events themselves; it lives on the sparse
@@ -20,6 +20,12 @@ export declare function loadAggregate(db: DbRunner, aggregateId: string, tenantI
20
20
  readonly includeArchived?: boolean;
21
21
  readonly aggregateType?: string;
22
22
  }): Promise<readonly StoredEvent[]>;
23
+ export declare function loadAggregateForWriting(db: DbRunner, aggregateId: string, tenantId: TenantId, options: {
24
+ readonly aggregateType: string;
25
+ }): Promise<{
26
+ readonly events: readonly StoredEvent[];
27
+ readonly streamVersion: number;
28
+ }>;
23
29
  export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, tenantId: TenantId, asOf: Temporal.Instant, options?: {
24
30
  readonly includeArchived?: boolean;
25
31
  }): Promise<readonly StoredEvent[]>;
@@ -2,12 +2,13 @@
2
2
  import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
+ import { instantToTimestamptzParam } from "../bun-db/query.js";
5
6
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
6
7
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
7
8
  import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
8
9
  import { insertOne, selectMany } from "../db/query.js";
9
10
  import { isStreamArchived } from "./archive.js";
10
- import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
11
+ import { IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
11
12
  import { eventsTable } from "./events-schema.js";
12
13
  import { toStoredEvent } from "./row-to-stored-event.js";
13
14
  import { currentSeedCreatedAt } from "./seed-clock.js";
@@ -44,14 +45,16 @@ export async function append(db, event) {
44
45
  });
45
46
  const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
46
47
  const seedCreatedAt = currentSeedCreatedAt();
47
- if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
48
- await assertSeedTimeNotBeforePredecessor(db, toStore, seedCreatedAt);
49
- }
50
48
  const newVersion = toStore.expectedVersion + 1;
51
49
  const eventVersion = toStore.eventVersion ?? 1;
52
50
  try {
53
51
  const row = await runAtomicEventWrite(db, async (runner) => {
54
52
  await claimXactId(runner);
53
+ // Inside the write transaction so a concurrent live append cannot slip
54
+ // between the check and the insert.
55
+ if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
56
+ await assertSeedTimeNotBeforePredecessor(runner, toStore, seedCreatedAt);
57
+ }
55
58
  const inserted = toStore.expectedVersion === 0
56
59
  ? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
57
60
  : await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
@@ -105,7 +108,7 @@ async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
105
108
  version: event.expectedVersion,
106
109
  });
107
110
  if (predecessor && predecessor.createdAt.epochMilliseconds > seedCreatedAt.epochMilliseconds) {
108
- throw new Error(`Seed write for ${event.aggregateType} ${event.aggregateId} v${event.expectedVersion + 1} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessor.createdAt.toString()}). Write a stream's events in ascending time.`);
111
+ throw new SeedTimeOrderError(event.aggregateType, event.aggregateId, event.expectedVersion + 1, seedCreatedAt, predecessor.createdAt);
109
112
  }
110
113
  }
111
114
  async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
@@ -141,7 +144,7 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
141
144
  metadata: event.metadata,
142
145
  createdBy: event.metadata.userId,
143
146
  expectedVersion: event.expectedVersion,
144
- createdAt: seedCreatedAt?.toString(),
147
+ createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
145
148
  });
146
149
  if (!row)
147
150
  throw new VersionConflictError(event.aggregateId, event.expectedVersion);
@@ -201,6 +204,25 @@ export async function loadAggregate(db, aggregateId, tenantId, options) {
201
204
  : { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
202
205
  return rows.map(toStoredEvent);
203
206
  }
207
+ // Events and the stream's latest version from ONE read, so both come from the same
208
+ // snapshot: a commit landing between two separate statements would leave the caller
209
+ // pinning a version its loaded events never covered. The aggregateType filter is applied
210
+ // after the read because a foreign type sharing the aggregateId still occupies versions
211
+ // the next append has to stack on. Archived streams load empty but keep their real version.
212
+ export async function loadAggregateForWriting(db, aggregateId, tenantId, options) {
213
+ if (options.aggregateType === "") {
214
+ throw new Error("loadAggregateForWriting: aggregateType must be a non-empty string");
215
+ }
216
+ if (await isStreamArchived(db, tenantId, aggregateId)) {
217
+ return { events: [], streamVersion: await getStreamVersion(db, aggregateId, tenantId) };
218
+ }
219
+ const rows = await selectMany(db, eventsTable, { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
220
+ const all = rows.map(toStoredEvent);
221
+ return {
222
+ events: all.filter((e) => e.aggregateType === options.aggregateType),
223
+ streamVersion: all.at(-1)?.version ?? 0,
224
+ };
225
+ }
204
226
  // Load events up to a point in time. Used for asOf queries that reconstruct
205
227
  // historical state. Includes events whose created_at is <= asOf. Same
206
228
  // archive semantics as loadAggregate.
@@ -1,7 +1,7 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
3
3
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
- export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
4
+ export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
5
  export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
@@ -1,7 +1,7 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
3
3
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
- export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
4
+ export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
5
  export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent } from "./provenance-append.js";
@@ -1,11 +1,11 @@
1
- import { tenantDbRunner } from "../db/tenant-db-runner.js";
1
+ import { tenantDbRunner, tenantDbWriteRunner } from "../db/tenant-db-runner.js";
2
2
  import { runProjectionsForEvent } from "../pipeline/projections-runner.js";
3
3
  import { append, getStreamVersion } from "./event-store.js";
4
4
  // Appends on the connection or transaction the TenantDb is bound to. The tenant is always the
5
5
  // TenantDb's own, so a caller cannot write into another tenant's stream; a TenantDb the framework
6
6
  // did not build (or a fail-closed systemScope guard) is rejected by tenantDbRunner.
7
7
  export async function appendEventInTenantDb(tenantDb, event, options = {}) {
8
- const runner = tenantDbRunner(tenantDb);
8
+ const runner = tenantDbWriteRunner(tenantDb, "appendEventInTenantDb");
9
9
  const stored = await append(runner, { ...event, tenantId: tenantDb.tenantId });
10
10
  if (options.registry)
11
11
  await runProjectionsForEvent(stored, options.registry, runner);
@@ -1,8 +1,7 @@
1
- import type { TenantDb } from "../db/tenant-db.js";
2
- import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
1
+ import { type Registry, type SaveContext } from "../engine/types/index.js";
2
+ import type { FileRefRows } from "./load-save-file-refs.js";
3
3
  export declare function assertFileRefsBelongToTenant(args: {
4
4
  readonly registry: Registry;
5
5
  readonly result: SaveContext;
6
- readonly user: SessionUser;
7
- readonly db: TenantDb;
8
- }): Promise<void>;
6
+ readonly fileRefRows: FileRefRows;
7
+ }): void;
@@ -1,16 +1,17 @@
1
- import { isFileField, isUuid, } from "../engine/types/index.js";
1
+ import { isFileField } from "../engine/types/index.js";
2
2
  import { ValidationError } from "../errors/index.js";
3
3
  import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
- import { getFileRefExecutor } from "./create-file-ref.js";
5
4
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
5
  // A file field only stores an id, and the file routes authorize by tenant at
7
6
  // read time: without this check a writer could name another tenant's fileRef
8
7
  // and make the record point at it. Only newly written values are checked (all
9
8
  // of a create, `changes` of an update), so existing data is left alone. The
10
9
  // error is the same for "missing" and "belongs to another tenant" so the id
11
- // cannot be probed across tenants.
12
- export async function assertFileRefsBelongToTenant(args) {
13
- const { registry, result, user, db } = args;
10
+ // cannot be probed across tenants. `fileRefRows` holds only the writer's own
11
+ // tenant's live rows, so a missing entry is exactly those two cases. Values
12
+ // that are not UUIDs are skipped by fileRefIdsOf and never reach this check.
13
+ export function assertFileRefsBelongToTenant(args) {
14
+ const { registry, result, fileRefRows } = args;
14
15
  // skip: saves of unregistered aggregates (custom executors) declare no file fields
15
16
  if (result.entityName === undefined)
16
17
  return;
@@ -21,12 +22,11 @@ export async function assertFileRefsBelongToTenant(args) {
21
22
  // skip: without the files feature the file_refs table is not part of the schema
22
23
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
23
24
  return;
24
- const executor = getFileRefExecutor();
25
25
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
26
26
  if (!isFileField(fieldDef))
27
27
  continue;
28
28
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
29
- if (await isFileRefOfTenant(executor, fileRefId, user, db))
29
+ if (fileRefRows.has(fileRefId))
30
30
  continue;
31
31
  throw new ValidationError({
32
32
  fields: [
@@ -40,11 +40,3 @@ export async function assertFileRefsBelongToTenant(args) {
40
40
  }
41
41
  }
42
42
  }
43
- async function isFileRefOfTenant(executor, fileRefId, user, db) {
44
- if (!isUuid(fileRefId))
45
- return false;
46
- // detail() widens its tenant filter to (self, SYSTEM); only the writer's own
47
- // tenant counts here, same as GET /files/:id/meta.
48
- const row = await executor.detail({ id: fileRefId }, user, db);
49
- return row !== null && row["tenantId"] === user.tenantId;
50
- }
@@ -1,9 +1,11 @@
1
1
  import type { TenantDb } from "../db/tenant-db.js";
2
2
  import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
3
+ import type { FileRefRows } from "./load-save-file-refs.js";
3
4
  export declare function fileRefIdsOf(value: unknown): readonly string[];
4
5
  export declare function bindPendingFileRefs(args: {
5
6
  readonly registry: Registry;
6
7
  readonly result: SaveContext;
7
8
  readonly user: SessionUser;
8
9
  readonly db: TenantDb;
10
+ readonly fileRefRows: FileRefRows;
9
11
  }): Promise<void>;
@@ -23,7 +23,7 @@ function isPendingUploadFor(row, entityName, fieldName, user) {
23
23
  // someone else's upload id in a save payload must not pull that file into a
24
24
  // record the caller controls.
25
25
  export async function bindPendingFileRefs(args) {
26
- const { registry, result, user, db } = args;
26
+ const { registry, result, user, db, fileRefRows } = args;
27
27
  // skip: saves of unnamed aggregates declare no file fields
28
28
  if (result.entityName === undefined)
29
29
  return;
@@ -42,8 +42,8 @@ export async function bindPendingFileRefs(args) {
42
42
  // changes, not data: an update's row carries every stored file id, including
43
43
  // ones bound earlier; only the ids this save names can be pending uploads.
44
44
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
45
- const row = await executor.detail({ id: fileRefId }, user, db);
46
- if (row === null || !isPendingUploadFor(row, entityName, fieldName, user))
45
+ const row = fileRefRows.get(fileRefId);
46
+ if (row === undefined || !isPendingUploadFor(row, entityName, fieldName, user))
47
47
  continue;
48
48
  const version = typeof row["version"] === "number" ? row["version"] : undefined;
49
49
  const bound = await executor.update({ id: fileRefId, version, changes: { entityId: String(result.id) } }, user, db);