@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.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- 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
|
+
```
|