@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,246 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/lane-routing. Do not edit. -->
2
+
3
+ # lane-routing
4
+
5
+ runIn lane-routing: r.job pinned to api/worker deploy-lanes, event-chain via ctx.appendEvent, verified end-to-end through createAllInOneEntrypoint.
6
+
7
+ ## README
8
+
9
+ # Lane Routing
10
+
11
+ Pin jobs to deploy lanes (`api` | `worker`) with event-triggered fan-out.
12
+
13
+ ## What it shows
14
+
15
+ - `r.job({ runIn: "worker" })` after an HTTP write
16
+ - End-to-end verification via `createAllInOneEntrypoint`
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
25
+
26
+ ```bash
27
+ cd samples/recipes/lane-routing
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Lane-Routing Sample — End-to-End Proof
37
+ //
38
+ // What we're proving (via createAllInOneEntrypoint + BullMQ + Hono — no
39
+ // framework-internal shortcuts):
40
+ // 1. A write lands on the HTTP surface.
41
+ // 2. The command-dispatcher fires `jobRunner.handleEvent` as an
42
+ // afterCommit-hook (welle-2.5-gap fix, auto-wired by the entrypoint).
43
+ // 3. Both `runIn: "worker"` jobs fan out onto the `kumiko-jobs-worker`
44
+ // queue and execute — the worker-side BullMQ worker picks them up,
45
+ // the api-side one does not (even in all-in-one, the lane-scoped
46
+ // dispatch() routes the job to the right queue).
47
+ //
48
+ // If the feature were wired with `runLocalJobs: true`-style api-lane
49
+ // jobs, the scaling.md pick-guide would apply — this sample stays
50
+ // worker-only because that's the prod default.
51
+
52
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
53
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
54
+ import { createAllInOneEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
55
+ import {
56
+ createArchivedStreamsTable,
57
+ createEventsTable,
58
+ } from "@cosmicdrift/kumiko-framework/event-store";
59
+ import { createEventConsumerStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
60
+ import {
61
+ createTestDb,
62
+ createTestRedis,
63
+ type TestDb,
64
+ type TestRedis,
65
+ TestUsers,
66
+ } from "@cosmicdrift/kumiko-framework/stack";
67
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
68
+ import { createLaneRoutingFeature, renderedReceipts, sentConfirmations } from "../feature";
69
+
70
+ const JWT = "lane-routing-sample-secret-minimum-32-chars!";
71
+ const adminUser = TestUsers.admin;
72
+
73
+ let testDb: TestDb;
74
+ let testRedis: TestRedis;
75
+
76
+ beforeAll(async () => {
77
+ [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
78
+ await createEventsTable(testDb.db);
79
+ await createArchivedStreamsTable(testDb.db);
80
+ await createEventConsumerStateTable(testDb.db);
81
+ });
82
+
83
+ afterAll(async () => {
84
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
85
+ });
86
+
87
+ afterEach(() => {
88
+ renderedReceipts.length = 0;
89
+ sentConfirmations.length = 0;
90
+ });
91
+
92
+ describe("lane-routing sample", () => {
93
+ test("order.create fans out to render-receipt + send-confirmation on the worker lane", async () => {
94
+ const registry = createRegistry([createLaneRoutingFeature()]);
95
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
96
+ const entry = createAllInOneEntrypoint({
97
+ registry,
98
+ context: { db: testDb.db, redis: testRedis.redis },
99
+ jwtSecret: JWT,
100
+ redisUrl,
101
+ queueNamePrefix: `lane-routing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
102
+ });
103
+ await entry.start();
104
+ try {
105
+ const token = await entry.jwt.sign(adminUser);
106
+ const res = await entry.app.request("/api/write", {
107
+ method: "POST",
108
+ headers: {
109
+ "Content-Type": "application/json",
110
+ Authorization: `Bearer ${token}`,
111
+ },
112
+ body: JSON.stringify({
113
+ type: "orders:write:order:create",
114
+ payload: { customerName: "Acme GmbH", amount: 299 },
115
+ }),
116
+ });
117
+ const result = (await res.json()) as { isSuccess: boolean };
118
+ expect(result.isSuccess).toBe(true);
119
+
120
+ // Both jobs are declared runIn:"worker" — they enqueue into
121
+ // kumiko-jobs-worker-*, and the all-in-one worker runner consumes
122
+ // them. Without welle-2.6 the enqueue would drop silently (welle-2.5
123
+ // gap) or the routing would hit the wrong queue.
124
+ await waitFor(() => {
125
+ expect(renderedReceipts).toHaveLength(1);
126
+ expect(sentConfirmations).toHaveLength(1);
127
+ expect(renderedReceipts[0]?.customerName).toBe("Acme GmbH");
128
+ expect(sentConfirmations[0]?.amount).toBe(299);
129
+ });
130
+ } finally {
131
+ await entry.stop();
132
+ }
133
+ });
134
+ });
135
+ ```
136
+
137
+ ### src/feature.ts
138
+
139
+ ```ts
140
+ // Lane-Routing Sample
141
+ //
142
+ // Shows `r.job({ runIn: "worker" })` end-to-end: an HTTP write emits an
143
+ // event, the event fans out to two worker-lane jobs via BullMQ, and the
144
+ // jobs actually execute on the worker lane (kumiko-jobs-worker queue).
145
+ //
146
+ // Deploy-shape: any of the three entrypoints. The test uses
147
+ // createAllInOneEntrypoint so the Single Integration-Test covers the full
148
+ // path without having to coordinate two processes.
149
+
150
+ import {
151
+ createEntity,
152
+ createNumberField,
153
+ createTextField,
154
+ defineFeature,
155
+ type FeatureDefinition,
156
+ } from "@cosmicdrift/kumiko-framework/engine";
157
+ import { z } from "zod";
158
+
159
+ // In-memory collectors so the integration-test can assert both jobs ran
160
+ // and got the right payload. A real app would do DB writes, HTTP calls,
161
+ // PDF rendering, mail sending — whatever the side-effect is, it's
162
+ // opaque to the framework.
163
+ export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
164
+ export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
165
+
166
+ // Entity-Dekl sichert den qualified-name-Prefix "orders:write:order:create"
167
+ // für den Write-Handler unten und damit den Job-Trigger auf diesem Event.
168
+ // Persistenz passiert im Custom-Handler nicht (id ist hardcoded für den
169
+ // Sample-Test) — in einer echten App würde ein entityWriteHandler in die DB
170
+ // schreiben.
171
+ const orderEntity = createEntity({
172
+ table: "read_orders",
173
+ fields: {
174
+ customerName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
175
+ amount: createNumberField({ required: true }),
176
+ },
177
+ });
178
+
179
+ export function createLaneRoutingFeature(): FeatureDefinition {
180
+ return defineFeature("orders", (r) => {
181
+ r.entity("order", orderEntity);
182
+
183
+ // Write handler — runs in the API process (or wherever the command
184
+ // lands). Event "orders:write:order:create" is the auto-generated
185
+ // write-event both jobs trigger on.
186
+ r.writeHandler(
187
+ "order:create",
188
+ z.object({
189
+ customerName: z.string().min(1),
190
+ amount: z.number().positive(),
191
+ }),
192
+ async (event) => ({
193
+ isSuccess: true as const,
194
+ data: {
195
+ id: 1,
196
+ customerName: event.payload.customerName,
197
+ amount: event.payload.amount,
198
+ },
199
+ }),
200
+ {
201
+ access: {
202
+ openToAll: {
203
+ reason:
204
+ "demo recipe: any signed-in user may place an order; the handler is a stub with no persistence",
205
+ },
206
+ },
207
+ },
208
+ );
209
+
210
+ // Receipt-rendering: heavy CPU work. Pinned to `worker` so the
211
+ // BullMQ consumer on the API process never picks it up — even if the
212
+ // API happens to run runLocalJobs for something else. `runIn` is the
213
+ // Welle-2.6 contract that makes this stable across deploy shapes.
214
+ r.job(
215
+ "render-receipt",
216
+ {
217
+ trigger: { on: "orders:write:order:create" },
218
+ runIn: "worker",
219
+ },
220
+ async (payload) => {
221
+ renderedReceipts.push({
222
+ customerName: payload["customerName"] as string,
223
+ amount: payload["amount"] as number,
224
+ });
225
+ },
226
+ );
227
+
228
+ // Confirmation-mail: external I/O. Also worker-lane. Both jobs fan
229
+ // out from the same event in parallel — BullMQ handles their order
230
+ // independently.
231
+ r.job(
232
+ "send-confirmation",
233
+ {
234
+ trigger: { on: "orders:write:order:create" },
235
+ runIn: "worker",
236
+ },
237
+ async (payload) => {
238
+ sentConfirmations.push({
239
+ customerName: payload["customerName"] as string,
240
+ amount: payload["amount"] as number,
241
+ });
242
+ },
243
+ );
244
+ });
245
+ }
246
+ ```
@@ -0,0 +1,214 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/ledger-banking. Do not edit. -->
2
+
3
+ # ledger-banking
4
+
5
+ Bank accounts + transfers on the ledger primitive — balances are pure queries, never stored.
6
+
7
+ ## README
8
+
9
+ # Ledger Banking
10
+
11
+ Model bank accounts and transfers on the `ledger` primitive. This is the
12
+ simplest double-entry case: every account is an `asset`, a transfer is **one
13
+ balanced transaction**, and a balance is a **pure query** over the postings —
14
+ never a stored field you keep in sync.
15
+
16
+ ## What it shows
17
+
18
+ A transfer moves money between two bank accounts as a single balanced transaction, and every balance is a pure query over the postings rather than a stored field you keep in sync.
19
+
20
+ - **A transfer is one transaction, not two updates** — moving €200 from
21
+ checking to savings is a single `ledger:write:create-transaction` that debits
22
+ savings (`+20000`) and credits checking (`−20000`). The two lines sum to 0,
23
+ which is what makes the entry _balanced_; the framework rejects anything else.
24
+ - **Balances are derived, never stored** — there is no `balance` column on an
25
+ account. `ledger:query:report:balances` sums the postings on the fly and
26
+ applies each account's natural sign (asset = debit-normal).
27
+ - **The trial balance is the safety net** — because every entry sums to 0, the
28
+ sum of _all_ postings is always 0. The flow asserts it after the transfers:
29
+ if it ever drifts, the books are inconsistent.
30
+ - **Cents are signed integers** — amounts are integer minor units; `+` is a
31
+ debit, `−` is a credit. No floats, no rounding drift.
32
+
33
+ ## The banking flow
34
+
35
+ The flow below is embedded from `usage.ts` and is run end-to-end against the
36
+ real dispatcher + DB by this recipe's integration test (`the documented
37
+ bankingFlow …`):
38
+
39
+ ```ts file=<rootDir>/_samples/recipes-ledger-banking/src/usage.ts
40
+ ```
41
+
42
+ ## Why no `feature.ts`?
43
+
44
+ Unlike most recipes, banking mounts no host feature — the `ledger` bundle _is_
45
+ the host. Accounts and transactions are its own entities, so the recipe just
46
+ dispatches `ledger:*` handlers. Mount it once with `createLedgerFeature()` and
47
+ the whole chart of accounts + journal is available.
48
+
49
+ ## Run
50
+
51
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
52
+
53
+ ```bash
54
+ bun test src/__tests__/feature.integration.test.ts
55
+ ```
56
+
57
+ ## Source files
58
+
59
+ ### src/__tests__/feature.integration.test.ts
60
+
61
+ ```ts
62
+ // Ledger Banking — integration test.
63
+ //
64
+ // Proves the documented bankingFlow end-to-end via the real dispatcher + DB:
65
+ // two asset accounts funded from equity, one transfer between them, and the
66
+ // balances read back as a pure query. The trial balance stays 0 — the golden
67
+ // double-entry invariant.
68
+
69
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
70
+ import {
71
+ accountEntity,
72
+ createLedgerFeature,
73
+ transactionEntity,
74
+ } from "@cosmicdrift/kumiko-bundled-features/ledger";
75
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
76
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
77
+ import {
78
+ createTestUser,
79
+ setupTestStack,
80
+ type TestStack,
81
+ unsafeCreateEntityTable,
82
+ } from "@cosmicdrift/kumiko-framework/stack";
83
+ import { bankingFlow, type LedgerClient } from "../usage";
84
+
85
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
86
+ const ledger = createLedgerFeature();
87
+
88
+ let stack: TestStack;
89
+
90
+ // Adapt the test stack to the host-facing LedgerClient the docs embed uses.
91
+ const client: LedgerClient = {
92
+ write: <T>(type: string, payload: unknown) => stack.http.writeOk<T>(type, payload, admin),
93
+ query: <T>(type: string, payload: unknown) => stack.http.queryOk<T>(type, payload, admin),
94
+ };
95
+
96
+ beforeAll(async () => {
97
+ stack = await setupTestStack({ features: [ledger] });
98
+ await unsafeCreateEntityTable(stack.db, accountEntity);
99
+ await unsafeCreateEntityTable(stack.db, transactionEntity);
100
+ await createEventsTable(stack.db);
101
+ });
102
+
103
+ afterAll(async () => {
104
+ await stack.cleanup();
105
+ });
106
+
107
+ beforeEach(async () => {
108
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
109
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
110
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
111
+ });
112
+
113
+ describe("ledger-banking recipe — transfer + derived balance", () => {
114
+ test("the documented bankingFlow funds, transfers, and reads balances back", async () => {
115
+ const result = await bankingFlow(client);
116
+
117
+ expect(result.checking).toBe(80000);
118
+ expect(result.savings).toBe(20000);
119
+ // A transfer never destroys value — the books stay consistent.
120
+ expect(result.trialBalance).toBe(0);
121
+ });
122
+ });
123
+ ```
124
+
125
+ ### src/usage.ts
126
+
127
+ ```ts
128
+ // Ledger Banking — money on accounts, moved by balanced transactions.
129
+ //
130
+ // Banking is the simplest double-entry case: each bank account is an `asset`,
131
+ // and a transfer is ONE transaction that debits the receiving account (+) and
132
+ // credits the sending one (−). The two lines sum to 0 — that is what makes the
133
+ // entry "balanced". No balance is ever stored; it's a pure query over the
134
+ // postings (ledger:query:report:balances).
135
+ //
136
+ // This recipe's integration test runs `bankingFlow` below against the real
137
+ // dispatcher + DB.
138
+
139
+ // The bundle exports its qualified handler/query names — use them instead of
140
+ // hardcoding the wire strings.
141
+ import {
142
+ type AccountType,
143
+ LedgerHandlers,
144
+ LedgerQueries,
145
+ } from "@cosmicdrift/kumiko-bundled-features/ledger";
146
+
147
+ // The minimal surface the flow needs from a host dispatcher. An app's client
148
+ // satisfies this; the integration test adapts the test stack to it.
149
+ export type LedgerClient = {
150
+ write: <T>(type: string, payload: unknown) => Promise<T>;
151
+ query: <T>(type: string, payload: unknown) => Promise<T>;
152
+ };
153
+
154
+ type BalancesReport = {
155
+ accounts: Array<{ id: string; name: string; balance: number }>;
156
+ trialBalance: number;
157
+ };
158
+
159
+ // Open an account in the tenant's chart of accounts → returns its id.
160
+ async function openAccount(client: LedgerClient, name: string, type: AccountType): Promise<string> {
161
+ const { id } = await client.write<{ id: string }>(LedgerHandlers.createAccount, { name, type });
162
+ return id;
163
+ }
164
+
165
+ // A transfer is one balanced transaction: debit the receiver (+amount), credit
166
+ // the sender (−amount). Amounts are signed integer minor units (cents).
167
+ async function transfer(
168
+ client: LedgerClient,
169
+ opts: { from: string; to: string; amount: number; description: string; date: string },
170
+ ): Promise<void> {
171
+ await client.write(LedgerHandlers.createTransaction, {
172
+ date: opts.date,
173
+ description: opts.description,
174
+ lines: [
175
+ { accountId: opts.to, amount: opts.amount },
176
+ { accountId: opts.from, amount: -opts.amount },
177
+ ],
178
+ });
179
+ }
180
+
181
+ // Fund a checking account from equity, move €200 of it to savings, then read
182
+ // the closing balances back. Returns the balances + the trial balance (Σ of all
183
+ // raw postings — 0 on a consistent ledger).
184
+ export async function bankingFlow(client: LedgerClient) {
185
+ const opening = await openAccount(client, "Anfangsbestand", "equity");
186
+ const checking = await openAccount(client, "Girokonto", "asset");
187
+ const savings = await openAccount(client, "Sparkonto", "asset");
188
+
189
+ await transfer(client, {
190
+ from: opening,
191
+ to: checking,
192
+ amount: 100000,
193
+ description: "Anfangsbestand Girokonto",
194
+ date: "2026-01-01",
195
+ });
196
+
197
+ await transfer(client, {
198
+ from: checking,
199
+ to: savings,
200
+ amount: 20000,
201
+ description: "Sparrate Januar",
202
+ date: "2026-01-05",
203
+ });
204
+
205
+ const report = await client.query<BalancesReport>(LedgerQueries.reportBalances, {});
206
+ const balanceOf = (id: string) => report.accounts.find((a) => a.id === id)?.balance ?? 0;
207
+
208
+ return {
209
+ checking: balanceOf(checking), // 80000 — €1,000 funded − €200 moved out
210
+ savings: balanceOf(savings), // 20000 — €200 moved in
211
+ trialBalance: report.trialBalance, // 0 — the books are consistent
212
+ };
213
+ }
214
+ ```
@@ -0,0 +1,235 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/ledger-invoicing. Do not edit. -->
2
+
3
+ # ledger-invoicing
4
+
5
+ Accrual invoicing on the ledger primitive — revenue at invoice time, VAT as a liability, receivable cleared on payment.
6
+
7
+ ## README
8
+
9
+ # Ledger Invoicing
10
+
11
+ Model accrual invoicing (Rechnungswesen) on the `ledger` primitive. Issuing an
12
+ invoice and receiving its payment are **two separate transactions**, and
13
+ revenue is recognised when the invoice is **issued** — not when the cash
14
+ arrives. This is the difference between banking (cash moves) and accounting
15
+ (obligations are recorded as they arise).
16
+
17
+ ## What it shows
18
+
19
+ Accrual accounting recognises revenue when an invoice is issued, not when the cash arrives: the invoice and its payment are two separate transactions, VAT is held as a liability, and the receivable clears to zero once paid.
20
+
21
+ - **Accrual: revenue at invoice time** — issuing the invoice debits Forderungen
22
+ (accounts receivable) and credits Erlöse (income). The revenue is on the P&L
23
+ immediately; the flow asserts it via `report:income-statement` _before_ any
24
+ payment exists.
25
+ - **VAT is a liability, not income** — the gross €1,190 splits into €1,000 net
26
+ revenue and €190 Umsatzsteuer. The VAT is money you owe the tax office, so it
27
+ lands on a `liability` account, never inflating revenue.
28
+ - **The receivable has a lifecycle** — it's raised at the gross amount on
29
+ invoice and cleared to 0 on payment (debit Bank / credit Forderungen). An open
30
+ receivable balance _is_ your "unpaid invoices" total, with no extra bookkeeping.
31
+ - **Reports re-derive, payment doesn't double-count income** — booking the
32
+ payment moves cash and settles the receivable; it touches no income account,
33
+ so revenue stays €1,000.
34
+
35
+ ## The invoicing flow
36
+
37
+ The flow below is embedded from `usage.ts` and is run end-to-end against the
38
+ real dispatcher + DB by this recipe's integration test (`the documented
39
+ invoicingFlow …`):
40
+
41
+ ```ts file=<rootDir>/_samples/recipes-ledger-invoicing/src/usage.ts
42
+ ```
43
+
44
+ ## Banking vs. invoicing
45
+
46
+ Both recipes use the same primitive — accounts + balanced transactions — but
47
+ record different things:
48
+
49
+ | | [ledger-banking](../ledger-banking/) | ledger-invoicing |
50
+ |---|---|---|
51
+ | Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
52
+ | A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
53
+ | Revenue recognised | n/a | at invoice time (accrual) |
54
+ | Reports surfaced | balances | balances + P&L |
55
+
56
+ ## Run
57
+
58
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
59
+
60
+ ```bash
61
+ bun test src/__tests__/feature.integration.test.ts
62
+ ```
63
+
64
+ ## Source files
65
+
66
+ ### src/__tests__/feature.integration.test.ts
67
+
68
+ ```ts
69
+ // Ledger Invoicing — integration test.
70
+ //
71
+ // Proves the documented invoicingFlow end-to-end via the real dispatcher + DB:
72
+ // - issuing an invoice recognises revenue immediately (accrual), splits VAT
73
+ // off as a liability, and raises a receivable for the gross amount
74
+ // - the payment clears the receivable and brings in the cash — without
75
+ // touching the P&L a second time
76
+ // - the trial balance stays 0 throughout
77
+
78
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
79
+ import {
80
+ accountEntity,
81
+ createLedgerFeature,
82
+ transactionEntity,
83
+ } from "@cosmicdrift/kumiko-bundled-features/ledger";
84
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
85
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
86
+ import {
87
+ createTestUser,
88
+ setupTestStack,
89
+ type TestStack,
90
+ unsafeCreateEntityTable,
91
+ } from "@cosmicdrift/kumiko-framework/stack";
92
+ import { invoicingFlow, type LedgerClient } from "../usage";
93
+
94
+ const admin = createTestUser({ roles: ["TenantAdmin"] });
95
+ const ledger = createLedgerFeature();
96
+
97
+ let stack: TestStack;
98
+
99
+ const client: LedgerClient = {
100
+ write: <T>(type: string, payload: unknown) => stack.http.writeOk<T>(type, payload, admin),
101
+ query: <T>(type: string, payload: unknown) => stack.http.queryOk<T>(type, payload, admin),
102
+ };
103
+
104
+ beforeAll(async () => {
105
+ stack = await setupTestStack({ features: [ledger] });
106
+ await unsafeCreateEntityTable(stack.db, accountEntity);
107
+ await unsafeCreateEntityTable(stack.db, transactionEntity);
108
+ await createEventsTable(stack.db);
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await stack.cleanup();
113
+ });
114
+
115
+ beforeEach(async () => {
116
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
117
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
118
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
119
+ });
120
+
121
+ describe("ledger-invoicing recipe — accrual + receivable lifecycle", () => {
122
+ test("the documented invoicingFlow recognises revenue at invoice time, clears AR on payment", async () => {
123
+ const result = await invoicingFlow(client);
124
+
125
+ // Revenue recognised when invoiced — before any cash moved.
126
+ expect(result.revenueBeforePayment).toBe(100000);
127
+ // Payment doesn't double-count income — revenue is still €1,000 afterwards.
128
+ expect(result.revenueAfterPayment).toBe(100000);
129
+ // Receivable settled once paid.
130
+ expect(result.receivablesAfterPayment).toBe(0);
131
+ // Gross cash in; VAT held as a liability owed to the tax office.
132
+ expect(result.bank).toBe(119000);
133
+ expect(result.vatOwed).toBe(19000);
134
+ expect(result.trialBalance).toBe(0);
135
+ });
136
+ });
137
+ ```
138
+
139
+ ### src/usage.ts
140
+
141
+ ```ts
142
+ // Ledger Invoicing — accrual accounting (Rechnungswesen).
143
+ //
144
+ // Issuing an invoice and receiving its payment are TWO separate transactions,
145
+ // and revenue is recognised when the invoice is ISSUED (accrual), not when the
146
+ // cash arrives:
147
+ //
148
+ // 1. Issue: debit Forderungen (asset, accounts receivable) for the GROSS
149
+ // amount, credit Erlöse (income) for the NET and Umsatzsteuer
150
+ // (liability) for the VAT. Revenue hits the P&L now.
151
+ // 2. Payment: debit Bank (asset), credit Forderungen — the receivable nets
152
+ // back to 0 once the customer has paid.
153
+ //
154
+ // This recipe's integration test runs `invoicingFlow` below against the real
155
+ // dispatcher + DB.
156
+
157
+ import {
158
+ type AccountType,
159
+ LedgerHandlers,
160
+ LedgerQueries,
161
+ } from "@cosmicdrift/kumiko-bundled-features/ledger";
162
+
163
+ export type LedgerClient = {
164
+ write: <T>(type: string, payload: unknown) => Promise<T>;
165
+ query: <T>(type: string, payload: unknown) => Promise<T>;
166
+ };
167
+
168
+ type BalancesReport = {
169
+ accounts: Array<{ id: string; name: string; balance: number }>;
170
+ trialBalance: number;
171
+ };
172
+
173
+ async function openAccount(client: LedgerClient, name: string, type: AccountType): Promise<string> {
174
+ const { id } = await client.write<{ id: string }>(LedgerHandlers.createAccount, { name, type });
175
+ return id;
176
+ }
177
+
178
+ // Issue an invoice, observe that the revenue is already on the P&L before any
179
+ // cash moves, then book the incoming payment. Returns the figures the test
180
+ // asserts: accrual revenue, the cleared receivable, cash in, VAT owed.
181
+ export async function invoicingFlow(client: LedgerClient) {
182
+ const receivables = await openAccount(client, "Forderungen", "asset");
183
+ const revenue = await openAccount(client, "Erlöse", "income");
184
+ const vat = await openAccount(client, "Umsatzsteuer", "liability");
185
+ const bank = await openAccount(client, "Bank", "asset");
186
+
187
+ // Invoice: net €1,000 + 19% VAT = €1,190 gross. One balanced 3-line entry.
188
+ await client.write(LedgerHandlers.createTransaction, {
189
+ date: "2026-02-01",
190
+ description: "Rechnung 2026-001",
191
+ reference: "INV-2026-001",
192
+ lines: [
193
+ { accountId: receivables, amount: 119000 }, // gross owed to us
194
+ { accountId: revenue, amount: -100000 }, // net revenue → income
195
+ { accountId: vat, amount: -19000 }, // VAT collected → liability
196
+ ],
197
+ });
198
+
199
+ // Revenue is recognised at invoice time — it's on the P&L before any payment.
200
+ const beforePayment = await client.query<{ income: number; netIncome: number }>(
201
+ LedgerQueries.reportIncomeStatement,
202
+ {},
203
+ );
204
+
205
+ // Payment arrives → Bank up, receivable cleared. Income does NOT change here.
206
+ await client.write(LedgerHandlers.createTransaction, {
207
+ date: "2026-02-20",
208
+ description: "Zahlung Rechnung 2026-001",
209
+ reference: "INV-2026-001",
210
+ lines: [
211
+ { accountId: bank, amount: 119000 },
212
+ { accountId: receivables, amount: -119000 },
213
+ ],
214
+ });
215
+
216
+ // The payment moved cash and cleared the receivable — it touched no income
217
+ // account, so the recognised revenue is unchanged.
218
+ const afterPayment = await client.query<{ income: number }>(
219
+ LedgerQueries.reportIncomeStatement,
220
+ {},
221
+ );
222
+
223
+ const balances = await client.query<BalancesReport>(LedgerQueries.reportBalances, {});
224
+ const balanceOf = (id: string) => balances.accounts.find((a) => a.id === id)?.balance ?? 0;
225
+
226
+ return {
227
+ revenueBeforePayment: beforePayment.income, // 100000 — accrual, cash-independent
228
+ revenueAfterPayment: afterPayment.income, // 100000 — payment doesn't double-count
229
+ receivablesAfterPayment: balanceOf(receivables), // 0 — invoice settled
230
+ bank: balanceOf(bank), // 119000 — gross cash received
231
+ vatOwed: balanceOf(vat), // 19000 — liability to the tax office
232
+ trialBalance: balances.trialBalance, // 0
233
+ };
234
+ }
235
+ ```