@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
@@ -0,0 +1,434 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/api-worker-split. Do not edit. -->
2
+
3
+ # api-worker-split
4
+
5
+ Split deploy topology: API process (runSingleInstance:false, enqueue-only) + worker process (consumes worker-lane jobs, applies multiStreamProjections, writes results back through the worker dispatcher).
6
+
7
+ ## README
8
+
9
+ # API/Worker-Split
10
+
11
+ Run Kumiko as two separate processes — an HTTP API that only enqueues jobs,
12
+ and a worker that executes them and applies the read models the API skips.
13
+ This is the deploy topology behind `runSingleInstance: false` +
14
+ `createWorkerEntrypoint`.
15
+
16
+ ## What it shows
17
+
18
+ - **Two entrypoints, one feature** — `bin/api.ts` starts the API process
19
+ (`runProdApp({ runSingleInstance: false })`), `bin/worker.ts` the worker
20
+ (`runWorkerApp`). Both build the same `orders` feature; they differ only
21
+ in what they run.
22
+ - **API = enqueuer-only** — the API writes events and pushes `runIn:
23
+ "worker"` jobs onto the BullMQ queue. It consumes nothing and applies no
24
+ multiStreamProjections.
25
+ - **Worker = the read side** — the worker's event-dispatcher applies the
26
+ `order-activity` projection and its BullMQ runner executes
27
+ `process-order`.
28
+ - **The sharp edge, live** — with `runSingleInstance: false` the API
29
+ process applies no multiStreamProjections. No worker running means the
30
+ `order-activity` read side stays empty, silently (the 2026-06-11 incident
31
+ class). The integration test asserts exactly this.
32
+ - **Result written back through the worker dispatcher** — `JobContext` has
33
+ no `write`/`query` (fw#1717). `bin/worker.ts` wires a write-bridge from
34
+ `dispatchSystemWrite` and the job uses it to create a `fulfillment` row
35
+ (same pattern as `inbound-mail-foundation/watch-supervisor.ts`).
36
+
37
+ ## Run
38
+
39
+ Requires Postgres + Redis (env: `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`).
40
+
41
+ ```bash
42
+ bun install
43
+ bun run schema:apply # creates framework + entity tables (kumiko/migrations)
44
+ bun run api # terminal 1 — HTTP + enqueue
45
+ bun run worker # terminal 2 — consumes jobs, applies projections
46
+ ```
47
+
48
+ Prove the topology. `orders:write:order:create` declares `openToAll`, which
49
+ still requires a valid JWT (any authenticated user, no specific role) — grab
50
+ one the same way `src/__tests__/feature.integration.test.ts` does, via
51
+ `api.jwt.sign(adminUser)` on the entrypoint returned by
52
+ `createApiEntrypoint`:
53
+
54
+ ```bash
55
+ curl -X POST http://localhost:3000/api/write \
56
+ -H 'Content-Type: application/json' \
57
+ -H "Authorization: Bearer $TOKEN" \
58
+ -d '{"type":"orders:write:order:create","payload":{"customerName":"Acme GmbH","amount":499}}'
59
+ ```
60
+
61
+ With only the API running, `read_orders` gains the row but
62
+ `read_order_activity` stays empty. Start the worker and both the activity
63
+ row and the `read_fulfillments` row appear.
64
+
65
+ ## Test
66
+
67
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
68
+
69
+ ```bash
70
+ bun test src/__tests__/feature.integration.test.ts
71
+ ```
72
+
73
+ Runs both entrypoints in-process against real Postgres + Redis, at the
74
+ `createApiEntrypoint`/`createWorkerEntrypoint` level (not `bin/api.ts`/
75
+ `bin/worker.ts` themselves), and asserts the API-only read side stays empty
76
+ before the worker picks up the job and writes the fulfillment back.
77
+
78
+ ## Source files
79
+
80
+ ### src/__tests__/feature.integration.test.ts
81
+
82
+ ```ts
83
+ // API/Worker-Split Sample — integration test
84
+ //
85
+ // Proves the split topology against real Postgres + Redis at the
86
+ // entrypoint level: `createApiEntrypoint`/`createWorkerEntrypoint` directly,
87
+ // not `bin/api.ts`/`bin/worker.ts` (`runProdApp`/`runWorkerApp`) themselves —
88
+ // notably, the write-bridge below is wired before `worker.start()`, while
89
+ // `bin/worker.ts`'s `wireComponents` runs after the entrypoint is up.
90
+ //
91
+ // 1. API-only: the write lands and read_orders has its row (entity rows
92
+ // are written synchronously by the executor), but read_order_activity
93
+ // stays EMPTY — `runSingleInstance:false` means the API applies no
94
+ // multiStreamProjections. The 2026-06-11 sharp edge, live.
95
+ // 2. Worker joins: it consumes the worker-lane job, applies the
96
+ // projection, and writes the fulfillment back through the WORKER's
97
+ // dispatcher (dispatchSystemWrite → orders:write:fulfillment:create).
98
+
99
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
100
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
101
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
102
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
103
+ import {
104
+ createApiEntrypoint,
105
+ createWorkerEntrypoint,
106
+ } from "@cosmicdrift/kumiko-framework/entrypoint";
107
+ import {
108
+ createArchivedStreamsTable,
109
+ createEventsTable,
110
+ } from "@cosmicdrift/kumiko-framework/event-store";
111
+ import { createEventConsumerStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
112
+ import {
113
+ createTestDb,
114
+ createTestRedis,
115
+ type TestDb,
116
+ type TestRedis,
117
+ TestUsers,
118
+ unsafeCreateEntityTable,
119
+ unsafePushTables,
120
+ } from "@cosmicdrift/kumiko-framework/stack";
121
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
122
+ import { makeDispatchSystemWrite } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
123
+ import {
124
+ createApiWorkerSplitFeature,
125
+ fulfillmentEntity,
126
+ orderActivityTable,
127
+ orderEntity,
128
+ setOrderFulfillWrite,
129
+ } from "../feature";
130
+
131
+ const JWT = "api-worker-split-sample-secret-minimum-32-chars!";
132
+ const adminUser = TestUsers.admin;
133
+
134
+ let testDb: TestDb;
135
+ let testRedis: TestRedis;
136
+
137
+ const orderTable = buildEntityTable("order", orderEntity);
138
+ const fulfillmentTable = buildEntityTable("fulfillment", fulfillmentEntity);
139
+
140
+ beforeAll(async () => {
141
+ [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
142
+ await createEventsTable(testDb.db);
143
+ await createArchivedStreamsTable(testDb.db);
144
+ await createEventConsumerStateTable(testDb.db);
145
+ await unsafeCreateEntityTable(testDb.db, orderEntity, "order");
146
+ await unsafeCreateEntityTable(testDb.db, fulfillmentEntity, "fulfillment");
147
+ await unsafePushTables(testDb.db, { orderActivity: orderActivityTable });
148
+ });
149
+
150
+ afterAll(async () => {
151
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
152
+ });
153
+
154
+ function redisUrl(): string {
155
+ return `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
156
+ }
157
+
158
+ function uniquePrefix(): string {
159
+ return `aws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
160
+ }
161
+
162
+ async function writeOrder(
163
+ api: Awaited<ReturnType<typeof createApiEntrypoint>>,
164
+ customerName: string,
165
+ amount: number,
166
+ ): Promise<boolean> {
167
+ const token = await api.jwt.sign(adminUser);
168
+ const res = await api.app.request("/api/write", {
169
+ method: "POST",
170
+ headers: {
171
+ "Content-Type": "application/json",
172
+ Authorization: `Bearer ${token}`,
173
+ },
174
+ body: JSON.stringify({
175
+ type: "orders:write:order:create",
176
+ payload: { customerName, amount },
177
+ }),
178
+ });
179
+ const result = (await res.json()) as { isSuccess: boolean };
180
+ return result.isSuccess;
181
+ }
182
+
183
+ describe("api-worker-split", () => {
184
+ test("API-only: write lands, but the worker-applied read side stays empty", async () => {
185
+ const prefix = uniquePrefix();
186
+ const api = createApiEntrypoint({
187
+ registry: createRegistry([createApiWorkerSplitFeature()]),
188
+ context: { db: testDb.db, redis: testRedis.redis },
189
+ jwtSecret: JWT,
190
+ jobs: { redisUrl: redisUrl(), queueNamePrefix: prefix },
191
+ });
192
+ await api.start();
193
+ try {
194
+ expect(await writeOrder(api, "Acme GmbH", 499)).toBe(true);
195
+
196
+ // Entity rows are written synchronously by the event-store executor —
197
+ // present even in split mode.
198
+ await waitFor(async () => {
199
+ const orders = await selectMany<{ customer_name: string }>(testDb.db, orderTable, {});
200
+ expect(orders).toHaveLength(1);
201
+ });
202
+
203
+ // The async read model never applies: no local dispatcher, no local
204
+ // runner. Without a worker this table stays empty, silently.
205
+ await Bun.sleep(500);
206
+ const activity = await selectMany(testDb.db, orderActivityTable, {});
207
+ const fulfillments = await selectMany(testDb.db, fulfillmentTable, {});
208
+ expect(activity).toHaveLength(0);
209
+ expect(fulfillments).toHaveLength(0);
210
+ } finally {
211
+ await api.stop();
212
+ }
213
+ });
214
+
215
+ test("worker joins: consumes the job, applies the projection, writes the result back", async () => {
216
+ const prefix = uniquePrefix();
217
+ const api = createApiEntrypoint({
218
+ registry: createRegistry([createApiWorkerSplitFeature()]),
219
+ context: { db: testDb.db, redis: testRedis.redis },
220
+ jwtSecret: JWT,
221
+ jobs: { redisUrl: redisUrl(), queueNamePrefix: prefix },
222
+ });
223
+ const worker = createWorkerEntrypoint({
224
+ registry: createRegistry([createApiWorkerSplitFeature()]),
225
+ context: { db: testDb.db, redis: testRedis.redis },
226
+ jwtSecret: JWT,
227
+ redisUrl: redisUrl(),
228
+ queueNamePrefix: prefix,
229
+ });
230
+ setOrderFulfillWrite(makeDispatchSystemWrite(worker.dispatcher));
231
+
232
+ await Promise.all([api.start(), worker.start()]);
233
+ try {
234
+ expect(await writeOrder(api, "Globex Ltd", 1299)).toBe(true);
235
+
236
+ // Worker's dispatcher applied the projection the API skipped…
237
+ await waitFor(async () => {
238
+ const activity = await selectMany<{ orderKey: string }>(testDb.db, orderActivityTable, {
239
+ tenantId: adminUser.tenantId,
240
+ });
241
+ expect(activity.some((row) => row.orderKey === "Globex Ltd")).toBe(true);
242
+ });
243
+
244
+ // …and the job wrote the fulfillment back through the WORKER's
245
+ // dispatcher (JobContext has no write/query), scoped to the tenant
246
+ // that fired the triggering order — an unfiltered query would still
247
+ // pass even if the fulfillment landed in the wrong tenant (fw#1823).
248
+ await waitFor(async () => {
249
+ const fulfillments = await selectMany<{ orderKey: string; carrier: string }>(
250
+ testDb.db,
251
+ fulfillmentTable,
252
+ { tenantId: adminUser.tenantId },
253
+ );
254
+ expect(fulfillments.some((row) => row.orderKey === "Globex Ltd")).toBe(true);
255
+ expect(fulfillments[0]?.carrier).toBe("DHL");
256
+ });
257
+ } finally {
258
+ await Promise.all([api.stop(), worker.stop()]);
259
+ }
260
+ });
261
+ });
262
+ ```
263
+
264
+ ### src/feature.ts
265
+
266
+ ```ts
267
+ // API/Worker-Split Sample
268
+ //
269
+ // Proves the split deployment topology end-to-end:
270
+ //
271
+ // - the API process runs `runSingleInstance: false` — it serves HTTP,
272
+ // writes events, and ENQUEUES worker-lane jobs. It applies no
273
+ // multiStreamProjections and consumes no jobs.
274
+ // - the worker process consumes the worker-lane queue and applies the
275
+ // multiStreamProjections the API skipped (the 2026-06-11 sharp edge:
276
+ // without a worker the read-side stays empty, silently).
277
+ // - a worker-lane job writes its RESULT back through the WORKER's
278
+ // dispatcher — JobContext has no write/query (fw#1717), so the app
279
+ // wires a write-bridge at boot (see setOrderFulfillWrite below).
280
+
281
+ import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
282
+ import { table, text, uuid } from "@cosmicdrift/kumiko-framework/db";
283
+ import {
284
+ createEntity,
285
+ createNumberField,
286
+ createTextField,
287
+ defineFeature,
288
+ type WriteResult,
289
+ } from "@cosmicdrift/kumiko-framework/engine";
290
+ import { z } from "zod";
291
+
292
+ const openAccess = {
293
+ access: {
294
+ openToAll: {
295
+ reason:
296
+ "demo recipe: any signed-in user manages every order and fulfillment; there " +
297
+ "is no per-user ownership in this sample",
298
+ },
299
+ },
300
+ } as const;
301
+
302
+ // --- Entities ------------------------------------------------------------
303
+ // Both are plain event-sourced CRUD entities. The write handler rows are
304
+ // written synchronously by the event-store executor — the API process
305
+ // always sees them, split or not.
306
+
307
+ export const orderEntity = createEntity({
308
+ table: "read_orders",
309
+ fields: {
310
+ customerName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
311
+ amount: createNumberField({ required: true }),
312
+ status: createTextField({ personal: false, reason: "is_business_data", default: "pending" }),
313
+ },
314
+ });
315
+
316
+ export const fulfillmentEntity = createEntity({
317
+ table: "read_fulfillments",
318
+ fields: {
319
+ orderKey: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
320
+ carrier: createTextField({ personal: false, reason: "is_business_data", required: true }),
321
+ label: createTextField({ personal: false, reason: "is_business_data", required: true }),
322
+ },
323
+ });
324
+
325
+ // --- Worker-applied read side (the sharp edge) ----------------------------
326
+ // This projection is applied by the event-dispatcher of the WORKER process
327
+ // only. `runSingleInstance: false` turns the API's local dispatcher off, so
328
+ // this table stays empty until a worker runs — exactly the 2026-06-11
329
+ // incident class (#512).
330
+ export const orderActivityTable = table("read_order_activity", {
331
+ id: uuid("id").primaryKey().defaultRandom(),
332
+ tenantId: uuid("tenant_id").notNull(),
333
+ orderKey: text("order_key").notNull(),
334
+ });
335
+
336
+ // --- The write-bridge (fw#1717) -------------------------------------------
337
+ // JobContext deliberately has no write/query — a background job must not
338
+ // bypass the write path (idempotency, PII, transition guards). The app
339
+ // therefore wires a component with the worker's dispatcher at boot and the
340
+ // job calls it. Same pattern as inbound-mail-foundation's watch-supervisor.
341
+ type FulfillWrite = (args: {
342
+ readonly handlerQn: string;
343
+ readonly payload: Record<string, unknown>;
344
+ readonly tenantId: string;
345
+ }) => Promise<WriteResult>;
346
+
347
+ let fulfillWrite: FulfillWrite | undefined;
348
+
349
+ /** Called by bin/worker.ts (wireComponents) with dispatchSystemWrite. */
350
+ export function setOrderFulfillWrite(fn: FulfillWrite): void {
351
+ fulfillWrite = fn;
352
+ }
353
+
354
+ // Job payloads arrive as `Record<string, unknown>` (JobHandlerFn) — an
355
+ // unchecked `as string` cast here would let a missing/non-string field
356
+ // through silently, writing `orderKey: undefined` / `label: "label-
357
+ // undefined"` instead of failing loudly. This recipe is a copy-paste
358
+ // template, so the cast would get copied along with it.
359
+ const processOrderPayloadSchema = z.object({
360
+ customerName: z.string().min(1),
361
+ });
362
+
363
+ export function createApiWorkerSplitFeature() {
364
+ return defineFeature("orders", (r) => {
365
+ r.crud("order", orderEntity, { write: openAccess, read: openAccess });
366
+ r.crud("fulfillment", fulfillmentEntity, { write: openAccess, read: openAccess });
367
+
368
+ // Heavy follow-up work, pinned to the worker lane: the API process only
369
+ // enqueues (its BullMQ client, no consumer), the worker executes.
370
+ r.job(
371
+ "process-order",
372
+ {
373
+ trigger: { on: "orders:write:order:create" },
374
+ runIn: "worker",
375
+ },
376
+ async (payload, context) => {
377
+ if (fulfillWrite === undefined) {
378
+ throw new Error(
379
+ "process-order: no fulfill-write bridge wired — bin/worker.ts must call setOrderFulfillWrite at boot",
380
+ );
381
+ }
382
+ // Every event-triggered job carries the tenant of the write that
383
+ // fired it (job-runner sets `_tenantId` from the triggering user)
384
+ // — reuse it instead of a fixed tenant, or a job triggered by one
385
+ // tenant's write silently fulfills into another tenant.
386
+ const tenantId = context.triggeredBy?.tenantId;
387
+ if (tenantId === undefined) {
388
+ throw new Error(
389
+ "process-order: job context has no triggeredBy.tenantId — expected an order.created-triggered job to always carry the triggering write's tenant",
390
+ );
391
+ }
392
+ const { customerName } = processOrderPayloadSchema.parse(payload);
393
+ const result = await fulfillWrite({
394
+ handlerQn: "orders:write:fulfillment:create",
395
+ payload: {
396
+ orderKey: customerName,
397
+ carrier: "DHL",
398
+ label: `label-${customerName}`,
399
+ },
400
+ tenantId,
401
+ });
402
+ if (!result.isSuccess) {
403
+ // Throwing lets BullMQ retry the job — a silently dropped
404
+ // fulfillment would leave the order stuck "pending" forever.
405
+ throw new Error(
406
+ `process-order: fulfillment create failed for order "${customerName}": ${result.error?.code ?? "unknown"}`,
407
+ );
408
+ }
409
+ },
410
+ );
411
+
412
+ // Async read model, applied by the WORKER's event-dispatcher. Entity ids
413
+ // are generated at write time and are not part of the job payload — the
414
+ // projection keys off the natural key (customerName), the same way a
415
+ // background write-back would. Note the event type: the write-handler QN
416
+ // ("orders:write:order:create") triggers jobs synchronously, but the
417
+ // STORED event is the entity event "order.created" — that's what the
418
+ // async dispatcher routes on.
419
+ r.multiStreamProjection({
420
+ name: "order-activity",
421
+ table: orderActivityTable,
422
+ apply: {
423
+ "order.created": async (event, tx) => {
424
+ const payload = event.payload as { customerName: string };
425
+ await insertOne(tx, orderActivityTable, {
426
+ tenantId: event.tenantId,
427
+ orderKey: payload.customerName,
428
+ });
429
+ },
430
+ },
431
+ });
432
+ });
433
+ }
434
+ ```