@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,453 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/currencies-per-tenant. Do not edit. -->
2
+
3
+ # currencies-per-tenant
4
+
5
+ Tenant-scoped money fields. Commas matter.
6
+
7
+ ## README
8
+
9
+ # Currencies Per Tenant
10
+
11
+ Multi-currency money with per-tenant rate tables.
12
+
13
+ ## What it shows
14
+
15
+ - Tenant-scoped FX rates
16
+ - Same money field API as the global recipe
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/currencies-per-tenant
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Money Tenant Sample — Integration Test
37
+ // Proves: tenant-owned currencies, each tenant has their own list,
38
+ // no shared data, isActive filter, cross-tenant isolation
39
+ //
40
+ // ┌──────────┬─────────────────────────┬─────────────────────────┐
41
+ // │ │ Tenant 1 │ Tenant 2 │
42
+ // ├──────────┼─────────────────────────┼─────────────────────────┤
43
+ // │ Active │ EUR, USD, XYZ │ GBP, JPY, TRY │
44
+ // │ Inactive │ — │ KRW │
45
+ // ├──────────┼─────────────────────────┼─────────────────────────┤
46
+ // │ Allowed │ EUR, USD, XYZ │ GBP, JPY, TRY │
47
+ // │ Denied │ GBP, JPY, TRY, KRW │ EUR, USD, XYZ, KRW │
48
+ // └──────────┴─────────────────────────┴─────────────────────────┘
49
+ // XYZ is a custom currency — not ISO, created by tenant 1
50
+
51
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
52
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
53
+ import {
54
+ createTestUser,
55
+ setupTestStack,
56
+ type TestStack,
57
+ TestUsers,
58
+ unsafeCreateEntityTable,
59
+ } from "@cosmicdrift/kumiko-framework/stack";
60
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
61
+ import { currencyEntity } from "../entities/currency";
62
+ import { invoiceEntity } from "../entities/invoice";
63
+ import { currenciesPerTenantFeature } from "../feature";
64
+
65
+ // The executor returns the rehydrated read-form: money is the combined
66
+ // { amount, currency }, not the flat amount/amountCurrency columns insertOne leaked.
67
+ const asMoney = (v: unknown) => v as { amount: number; currency: string };
68
+
69
+ let stack: TestStack;
70
+
71
+ const tenant1Admin = TestUsers.admin;
72
+ const tenant2Admin = createTestUser({ id: 2, tenantId: "00000000-0000-4000-8000-000000000002" });
73
+
74
+ beforeAll(async () => {
75
+ stack = await setupTestStack({
76
+ features: [currenciesPerTenantFeature],
77
+ systemHooks: [],
78
+ });
79
+
80
+ await unsafeCreateEntityTable(stack.db, currencyEntity);
81
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
82
+
83
+ // Tenant 1 creates their own currencies: EUR, USD, XYZ
84
+ for (const { code, name } of [
85
+ { code: "EUR", name: "Euro" },
86
+ { code: "USD", name: "US Dollar" },
87
+ { code: "XYZ", name: "Custom Token" },
88
+ ]) {
89
+ await stack.http.writeOk<SaveContext>(
90
+ "currencies-per-tenant:write:currency:create",
91
+ { code, name },
92
+ tenant1Admin,
93
+ );
94
+ }
95
+
96
+ // Tenant 2 creates their own currencies: GBP, JPY, TRY, KRW (inactive)
97
+ for (const { code, name, isActive } of [
98
+ { code: "GBP", name: "British Pound", isActive: true },
99
+ { code: "JPY", name: "Japanese Yen", isActive: true },
100
+ { code: "TRY", name: "Turkish Lira", isActive: true },
101
+ { code: "KRW", name: "Korean Won", isActive: false },
102
+ ]) {
103
+ await stack.http.writeOk<SaveContext>(
104
+ "currencies-per-tenant:write:currency:create",
105
+ { code, name, isActive },
106
+ tenant2Admin,
107
+ );
108
+ }
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await stack.cleanup();
113
+ });
114
+
115
+ // --- Tenant 1 ---
116
+
117
+ describe("tenant 1: allowed currencies", () => {
118
+ test("create invoice with EUR succeeds", async () => {
119
+ const data = await stack.http.writeOk<SaveContext>(
120
+ "currencies-per-tenant:write:invoice:create",
121
+ { title: "Invoice T1-EUR", amount: 150050, amountCurrency: "EUR" },
122
+ tenant1Admin,
123
+ );
124
+ expect(data.isNew).toBe(true);
125
+ expect(asMoney(data.data["amount"]).amount).toBe(150050);
126
+ expect(asMoney(data.data["amount"]).currency).toBe("EUR");
127
+ });
128
+
129
+ test("create invoice with XYZ (custom tenant currency) succeeds", async () => {
130
+ const data = await stack.http.writeOk<SaveContext>(
131
+ "currencies-per-tenant:write:invoice:create",
132
+ { title: "Invoice T1-XYZ", amount: 42, amountCurrency: "XYZ" },
133
+ tenant1Admin,
134
+ );
135
+ expect(data.isNew).toBe(true);
136
+ expect(asMoney(data.data["amount"]).currency).toBe("XYZ");
137
+ });
138
+ });
139
+
140
+ describe("tenant 1: denied currencies", () => {
141
+ test("GBP fails (tenant 1 never created it)", async () => {
142
+ const error = await stack.http.writeErr(
143
+ "currencies-per-tenant:write:invoice:create",
144
+ { title: "Fail", amount: 100, amountCurrency: "GBP" },
145
+ tenant1Admin,
146
+ );
147
+ expectErrorIncludes(error, "currency_not_allowed");
148
+ });
149
+ });
150
+
151
+ // --- Tenant 2 ---
152
+
153
+ describe("tenant 2: allowed currencies", () => {
154
+ test("create invoice with GBP succeeds", async () => {
155
+ const data = await stack.http.writeOk<SaveContext>(
156
+ "currencies-per-tenant:write:invoice:create",
157
+ { title: "Invoice T2-GBP", amount: 99999, amountCurrency: "GBP" },
158
+ tenant2Admin,
159
+ );
160
+ expect(data.isNew).toBe(true);
161
+ expect(asMoney(data.data["amount"]).currency).toBe("GBP");
162
+ });
163
+ });
164
+
165
+ describe("tenant 2: denied currencies", () => {
166
+ test("EUR fails (tenant 2 never created it)", async () => {
167
+ const error = await stack.http.writeErr(
168
+ "currencies-per-tenant:write:invoice:create",
169
+ { title: "Fail", amount: 100, amountCurrency: "EUR" },
170
+ tenant2Admin,
171
+ );
172
+ expectErrorIncludes(error, "currency_not_allowed");
173
+ });
174
+
175
+ test("KRW fails (inactive)", async () => {
176
+ const error = await stack.http.writeErr(
177
+ "currencies-per-tenant:write:invoice:create",
178
+ { title: "Fail", amount: 100, amountCurrency: "KRW" },
179
+ tenant2Admin,
180
+ );
181
+ expectErrorIncludes(error, "currency_not_allowed");
182
+ });
183
+ });
184
+
185
+ // --- Cross-tenant isolation ---
186
+
187
+ describe("tenant isolation", () => {
188
+ test("tenant 2 cannot use tenant 1's XYZ", async () => {
189
+ const error = await stack.http.writeErr(
190
+ "currencies-per-tenant:write:invoice:create",
191
+ { title: "Cross-tenant", amount: 100, amountCurrency: "XYZ" },
192
+ tenant2Admin,
193
+ );
194
+ expectErrorIncludes(error, "currency_not_allowed");
195
+ });
196
+
197
+ test("tenant 1 cannot use tenant 2's TRY", async () => {
198
+ const error = await stack.http.writeErr(
199
+ "currencies-per-tenant:write:invoice:create",
200
+ { title: "Cross-tenant", amount: 100, amountCurrency: "TRY" },
201
+ tenant1Admin,
202
+ );
203
+ expectErrorIncludes(error, "currency_not_allowed");
204
+ });
205
+ });
206
+
207
+ // --- Detail query ---
208
+
209
+ describe("invoice detail", () => {
210
+ test("detail returns the correct invoice by id", async () => {
211
+ const _first = await stack.http.writeOk<SaveContext>(
212
+ "currencies-per-tenant:write:invoice:create",
213
+ { title: "Detail-First", amount: 100, amountCurrency: "EUR" },
214
+ tenant1Admin,
215
+ );
216
+ const second = await stack.http.writeOk<SaveContext>(
217
+ "currencies-per-tenant:write:invoice:create",
218
+ { title: "Detail-Second", amount: 200, amountCurrency: "USD" },
219
+ tenant1Admin,
220
+ );
221
+
222
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
223
+ "currencies-per-tenant:query:invoice:detail",
224
+ { id: second.id },
225
+ tenant1Admin,
226
+ );
227
+ expect(detail["id"]).toBe(second.id);
228
+ expect(detail["title"]).toBe("Detail-Second");
229
+ expect(detail["amountCurrency"]).toBe("USD");
230
+ });
231
+
232
+ test("detail returns null for other tenant's invoice", async () => {
233
+ const created = await stack.http.writeOk<SaveContext>(
234
+ "currencies-per-tenant:write:invoice:create",
235
+ { title: "Tenant1-Only", amount: 50, amountCurrency: "EUR" },
236
+ tenant1Admin,
237
+ );
238
+
239
+ const detail = await stack.http.queryOk<null>(
240
+ "currencies-per-tenant:query:invoice:detail",
241
+ { id: created.id },
242
+ tenant2Admin,
243
+ );
244
+ expect(detail).toBeNull();
245
+ });
246
+ });
247
+ ```
248
+
249
+ ### src/entities/currency.ts
250
+
251
+ ```ts
252
+ // Currency — tenant-owned, each tenant manages their own currency list
253
+ // No global reference data — tenants create currencies themselves
254
+
255
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
256
+ import {
257
+ createBooleanField,
258
+ createEntity,
259
+ createTextField,
260
+ } from "@cosmicdrift/kumiko-framework/engine";
261
+
262
+ export const currencyEntity = createEntity({
263
+ table: "read_sample_mt_currencies",
264
+ fields: {
265
+ code: createTextField({ personal: false, reason: "is_reference_data", required: true }),
266
+ name: createTextField({ personal: false, reason: "is_reference_data", required: true }),
267
+ isActive: createBooleanField({ default: true }),
268
+ },
269
+ });
270
+
271
+ export const currencyTable = buildEntityTable("currency", currencyEntity);
272
+ ```
273
+
274
+ ### src/entities/invoice.ts
275
+
276
+ ```ts
277
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
278
+ import {
279
+ createEntity,
280
+ createMoneyField,
281
+ createTextField,
282
+ } from "@cosmicdrift/kumiko-framework/engine";
283
+
284
+ export const invoiceEntity = createEntity({
285
+ table: "read_sample_mt_invoices",
286
+ fields: {
287
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
288
+ amount: createMoneyField({ required: true }),
289
+ shippingCost: createMoneyField(),
290
+ },
291
+ defaultCurrency: "EUR",
292
+ });
293
+
294
+ export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
295
+ ```
296
+
297
+ ### src/feature.ts
298
+
299
+ ```ts
300
+ // Money Tenant Sample
301
+ // Shows: Tenant-owned currencies — each tenant manages their own currency list
302
+ //
303
+ // Pattern: No global currency catalog. Each tenant creates currencies themselves.
304
+ // Use this when: Tenants need custom currencies (loyalty points, crypto, internal units)
305
+ //
306
+ // Tables:
307
+ // currency — tenant-owned, created by tenant admin (with isActive flag)
308
+ // invoice — money fields validated against tenant's currency table
309
+
310
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
311
+ import { currencyEntity } from "./entities/currency";
312
+ import { invoiceEntity } from "./entities/invoice";
313
+ import { currencyCreate } from "./handlers/currency-create.write";
314
+ import { invoiceCreate } from "./handlers/invoice-create.write";
315
+ import { invoiceDetail } from "./handlers/invoice-detail.query";
316
+
317
+ export { currencyEntity } from "./entities/currency";
318
+ export { invoiceEntity } from "./entities/invoice";
319
+
320
+ export const currenciesPerTenantFeature = defineFeature("currenciesPerTenant", (r) => {
321
+ r.entity("currency", currencyEntity);
322
+ r.entity("invoice", invoiceEntity);
323
+
324
+ // No r.referenceData() — tenants create currencies themselves
325
+ r.writeHandler(currencyCreate);
326
+ r.writeHandler(invoiceCreate);
327
+ r.queryHandler(invoiceDetail);
328
+ });
329
+ ```
330
+
331
+ ### src/handlers/currency-create.write.ts
332
+
333
+ ```ts
334
+ // Tenant creates their own currency — no global table needed
335
+
336
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
337
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
338
+ import { z } from "zod";
339
+ import { currencyEntity, currencyTable } from "../entities/currency";
340
+
341
+ const currencyCrud = createEventStoreExecutor(currencyTable, currencyEntity, {
342
+ entityName: "currency",
343
+ });
344
+
345
+ export const currencyCreate = defineWriteHandler({
346
+ name: "currency:create",
347
+ schema: z.object({
348
+ code: z.string().length(3),
349
+ name: z.string().min(1),
350
+ isActive: z.boolean().optional(),
351
+ }),
352
+ access: { roles: ["Admin"] },
353
+ // Event-sourced create — the executor appends `currency.created` + applies the
354
+ // projection in one TX (id + audit columns are set for us). A direct insertOne
355
+ // would drift the row past its event stream and a rebuild would wipe it.
356
+ handler: async (event, ctx) => currencyCrud.create(event.payload, event.user, ctx.db),
357
+ });
358
+ ```
359
+
360
+ ### src/handlers/invoice-create.write.ts
361
+
362
+ ```ts
363
+ import { assertExistsIn, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
364
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
365
+ import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
366
+ import { z } from "zod";
367
+ import { currencyTable } from "../entities/currency";
368
+ import { invoiceEntity, invoiceTable } from "../entities/invoice";
369
+
370
+ const invoiceCrud = createEventStoreExecutor(invoiceTable, invoiceEntity, {
371
+ entityName: "invoice",
372
+ });
373
+
374
+ export const invoiceCreate = defineWriteHandler({
375
+ name: "invoice:create",
376
+ schema: z.object({
377
+ title: z.string().min(1),
378
+ amount: z.number(),
379
+ amountCurrency: z.string().length(3),
380
+ shippingCost: z.number().optional(),
381
+ shippingCostCurrency: z.string().length(3).optional(),
382
+ }),
383
+ access: { roles: ["Admin"] },
384
+ handler: async (event, ctx) => {
385
+ const { title, amount, amountCurrency, shippingCost, shippingCostCurrency } = event.payload;
386
+
387
+ // Validate currency against tenant's own currency list
388
+ const amountMissing = await assertExistsIn(ctx.db, currencyTable, {
389
+ field: "code",
390
+ value: amountCurrency,
391
+ where: { isActive: true },
392
+ });
393
+ if (amountMissing) {
394
+ return failUnprocessable("currency_not_allowed", {
395
+ field: "amountCurrency",
396
+ value: amountCurrency,
397
+ });
398
+ }
399
+
400
+ if (shippingCostCurrency) {
401
+ const shippingMissing = await assertExistsIn(ctx.db, currencyTable, {
402
+ field: "code",
403
+ value: shippingCostCurrency,
404
+ where: { isActive: true },
405
+ });
406
+ if (shippingMissing) {
407
+ return failUnprocessable("currency_not_allowed", {
408
+ field: "shippingCostCurrency",
409
+ value: shippingCostCurrency,
410
+ });
411
+ }
412
+ }
413
+
414
+ // Money fields go in as the combined { amount, currency } API form; the
415
+ // executor flattens them into the invoice's own amount/amountCurrency columns.
416
+ return invoiceCrud.create(
417
+ {
418
+ title,
419
+ amount: { amount, currency: amountCurrency },
420
+ ...(shippingCost !== undefined
421
+ ? { shippingCost: { amount: shippingCost, currency: shippingCostCurrency } }
422
+ : {}),
423
+ },
424
+ event.user,
425
+ ctx.db,
426
+ );
427
+ },
428
+ });
429
+ ```
430
+
431
+ ### src/handlers/invoice-detail.query.ts
432
+
433
+ ```ts
434
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
435
+ import { z } from "zod";
436
+ import { invoiceTable } from "../entities/invoice";
437
+
438
+ export const invoiceDetail = defineQueryHandler({
439
+ name: "invoice:detail",
440
+ schema: z.object({ id: z.uuid() }),
441
+ access: {
442
+ openToAll: {
443
+ reason:
444
+ "demo recipe: any signed-in user may look up any invoice in their own tenant " +
445
+ "by id; the sample focuses on currency formatting, not access control",
446
+ },
447
+ },
448
+ handler: async (query, ctx) => {
449
+ const [row] = await ctx.db.selectMany(invoiceTable, { id: query.payload.id });
450
+ return (row as Record<string, unknown>) ?? null;
451
+ },
452
+ });
453
+ ```