@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,609 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/currencies-global. Do not edit. -->
2
+
3
+ # currencies-global
4
+
5
+ Shared money fields across tenants. Commas still matter.
6
+
7
+ ## README
8
+
9
+ # Currencies Global
10
+
11
+ Multi-currency money with a global rate table.
12
+
13
+ ## What it shows
14
+
15
+ - Money field type + global FX rates
16
+ - Conversion helpers shared across tenants
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-global
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Money Sample — Integration Test
37
+ // Proves: money fields store correctly, per-tenant currency validation works,
38
+ // reference data seeding, custom currencies, assertExistsIn from framework
39
+ //
40
+ // ┌──────────┬────────────────────────────┬──────────────────────────┐
41
+ // │ │ Tenant 1 │ Tenant 2 │
42
+ // ├──────────┼────────────────────────────┼──────────────────────────┤
43
+ // │ Standard │ EUR, USD │ USD, GBP, JPY │
44
+ // │ Custom │ BHD, XYZ │ TRY, KRW, BRL │
45
+ // │ Inactive │ — │ KRW (isActive: false) │
46
+ // ├──────────┼────────────────────────────┼──────────────────────────┤
47
+ // │ Allowed │ EUR, USD, BHD, XYZ │ USD, GBP, JPY, TRY, BRL │
48
+ // │ Denied │ GBP, TRY, KRW, ... │ EUR, BHD, XYZ, KRW, ... │
49
+ // └──────────┴────────────────────────────┴──────────────────────────┘
50
+ // XYZ is NOT in DEFAULT_CURRENCIES — proves custom currencies work end-to-end
51
+
52
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
53
+ import { seedReferenceData } from "@cosmicdrift/kumiko-framework/db";
54
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
55
+ import {
56
+ createTestUser,
57
+ setupTestStack,
58
+ type TestStack,
59
+ TestUsers,
60
+ unsafeCreateEntityTable,
61
+ } from "@cosmicdrift/kumiko-framework/stack";
62
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
63
+ import { currencyEntity, currencyTable } from "../entities/currency";
64
+ import { invoiceEntity } from "../entities/invoice";
65
+ import { tenantCurrencyEntity } from "../entities/tenant-currency";
66
+ import { currenciesGlobalFeature } from "../feature";
67
+
68
+ // The executor returns the rehydrated read-form: money is the combined
69
+ // { amount, currency }, not the flat amount/amountCurrency columns insertOne leaked.
70
+ const asMoney = (v: unknown) => v as { amount: number; currency: string };
71
+
72
+ let stack: TestStack;
73
+
74
+ const tenant1Admin = TestUsers.admin;
75
+ const tenant2Admin = createTestUser({ id: 2, tenantId: "00000000-0000-4000-8000-000000000002" });
76
+
77
+ beforeAll(async () => {
78
+ stack = await setupTestStack({
79
+ features: [currenciesGlobalFeature],
80
+ systemHooks: [],
81
+ });
82
+
83
+ await unsafeCreateEntityTable(stack.db, currencyEntity);
84
+ await unsafeCreateEntityTable(stack.db, tenantCurrencyEntity);
85
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
86
+
87
+ // Seed global currency table — same as r.referenceData() does at boot
88
+ const tables = new Map([["currency", currencyTable]]);
89
+ await seedReferenceData(currenciesGlobalFeature.referenceData, tables, stack.db);
90
+
91
+ // --- Assign currencies to tenant 1: EUR, USD (standard) + BHD, XYZ (custom) ---
92
+ for (const currencyCode of ["EUR", "USD", "BHD", "XYZ"]) {
93
+ await stack.http.writeOk<SaveContext>(
94
+ "currencies-global:write:tenant-currency:assign",
95
+ { currencyCode },
96
+ tenant1Admin,
97
+ );
98
+ }
99
+
100
+ // --- Assign currencies to tenant 2: USD, GBP, JPY (standard) + TRY, KRW, BRL (custom) ---
101
+ for (const currencyCode of ["USD", "GBP", "JPY", "TRY", "KRW", "BRL"]) {
102
+ await stack.http.writeOk<SaveContext>(
103
+ "currencies-global:write:tenant-currency:assign",
104
+ { currencyCode, isActive: currencyCode !== "KRW" },
105
+ tenant2Admin,
106
+ );
107
+ }
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await stack.cleanup();
112
+ });
113
+
114
+ // --- Tenant 1: EUR, USD, BHD, XYZ allowed ---
115
+
116
+ describe("tenant 1: allowed currencies", () => {
117
+ test("create invoice with EUR (standard) succeeds", async () => {
118
+ const data = await stack.http.writeOk<SaveContext>(
119
+ "currencies-global:write:invoice:create",
120
+ { title: "Invoice T1-EUR", amount: 150050, amountCurrency: "EUR" },
121
+ tenant1Admin,
122
+ );
123
+ expect(data.isNew).toBe(true);
124
+ expect(asMoney(data.data["amount"]).amount).toBe(150050);
125
+ expect(asMoney(data.data["amount"]).currency).toBe("EUR");
126
+ });
127
+
128
+ test("create invoice with BHD (custom, in DEFAULT_CURRENCIES) succeeds", async () => {
129
+ const data = await stack.http.writeOk<SaveContext>(
130
+ "currencies-global:write:invoice:create",
131
+ { title: "Invoice T1-BHD", amount: 25075, amountCurrency: "BHD" },
132
+ tenant1Admin,
133
+ );
134
+ expect(data.isNew).toBe(true);
135
+ expect(asMoney(data.data["amount"]).currency).toBe("BHD");
136
+ });
137
+
138
+ test("create invoice with XYZ (NOT in DEFAULT_CURRENCIES) succeeds", async () => {
139
+ const data = await stack.http.writeOk<SaveContext>(
140
+ "currencies-global:write:invoice:create",
141
+ { title: "Invoice T1-XYZ", amount: 42, amountCurrency: "XYZ" },
142
+ tenant1Admin,
143
+ );
144
+ expect(data.isNew).toBe(true);
145
+ expect(asMoney(data.data["amount"]).currency).toBe("XYZ");
146
+ });
147
+
148
+ test("create invoice with shipping cost in different currency", async () => {
149
+ const data = await stack.http.writeOk<SaveContext>(
150
+ "currencies-global:write:invoice:create",
151
+ {
152
+ title: "Invoice with shipping",
153
+ amount: 1000,
154
+ amountCurrency: "EUR",
155
+ shippingCost: 4999,
156
+ shippingCostCurrency: "USD",
157
+ },
158
+ tenant1Admin,
159
+ );
160
+ expect(data.isNew).toBe(true);
161
+ expect(asMoney(data.data["amount"]).currency).toBe("EUR");
162
+ expect(asMoney(data.data["shippingCost"]).currency).toBe("USD");
163
+ });
164
+ });
165
+
166
+ // --- Tenant 1: denied currencies ---
167
+
168
+ describe("tenant 1: denied currencies", () => {
169
+ test("create invoice with GBP fails (not assigned to tenant 1)", async () => {
170
+ const error = await stack.http.writeErr(
171
+ "currencies-global:write:invoice:create",
172
+ { title: "Should fail", amount: 100, amountCurrency: "GBP" },
173
+ tenant1Admin,
174
+ );
175
+ expectErrorIncludes(error, "currency_not_allowed");
176
+ });
177
+
178
+ test("create invoice with TRY fails (not assigned to tenant 1)", async () => {
179
+ const error = await stack.http.writeErr(
180
+ "currencies-global:write:invoice:create",
181
+ { title: "Should fail", amount: 100, amountCurrency: "TRY" },
182
+ tenant1Admin,
183
+ );
184
+ expectErrorIncludes(error, "currency_not_allowed");
185
+ });
186
+ });
187
+
188
+ // --- Tenant 2: USD, GBP, JPY, TRY, BRL allowed (KRW inactive) ---
189
+
190
+ describe("tenant 2: allowed currencies", () => {
191
+ test("create invoice with GBP succeeds", async () => {
192
+ const data = await stack.http.writeOk<SaveContext>(
193
+ "currencies-global:write:invoice:create",
194
+ { title: "Invoice T2-GBP", amount: 99999, amountCurrency: "GBP" },
195
+ tenant2Admin,
196
+ );
197
+ expect(data.isNew).toBe(true);
198
+ expect(asMoney(data.data["amount"]).currency).toBe("GBP");
199
+ });
200
+
201
+ test("create invoice with TRY (custom) succeeds", async () => {
202
+ const data = await stack.http.writeOk<SaveContext>(
203
+ "currencies-global:write:invoice:create",
204
+ { title: "Invoice T2-TRY", amount: 50000, amountCurrency: "TRY" },
205
+ tenant2Admin,
206
+ );
207
+ expect(data.isNew).toBe(true);
208
+ expect(asMoney(data.data["amount"]).currency).toBe("TRY");
209
+ });
210
+ });
211
+
212
+ // --- Tenant 2: denied currencies ---
213
+
214
+ describe("tenant 2: denied currencies", () => {
215
+ test("create invoice with EUR fails (not assigned to tenant 2)", async () => {
216
+ const error = await stack.http.writeErr(
217
+ "currencies-global:write:invoice:create",
218
+ { title: "Should fail", amount: 100, amountCurrency: "EUR" },
219
+ tenant2Admin,
220
+ );
221
+ expectErrorIncludes(error, "currency_not_allowed");
222
+ });
223
+
224
+ test("create invoice with KRW fails (inactive in tenant 2)", async () => {
225
+ const error = await stack.http.writeErr(
226
+ "currencies-global:write:invoice:create",
227
+ { title: "Should fail", amount: 100, amountCurrency: "KRW" },
228
+ tenant2Admin,
229
+ );
230
+ expectErrorIncludes(error, "currency_not_allowed");
231
+ });
232
+
233
+ test("create invoice with BHD fails (not assigned to tenant 2)", async () => {
234
+ const error = await stack.http.writeErr(
235
+ "currencies-global:write:invoice:create",
236
+ { title: "Should fail", amount: 100, amountCurrency: "BHD" },
237
+ tenant2Admin,
238
+ );
239
+ expectErrorIncludes(error, "currency_not_allowed");
240
+ });
241
+ });
242
+
243
+ // --- Cross-tenant isolation ---
244
+
245
+ describe("tenant isolation", () => {
246
+ test("tenant 2 cannot use tenant 1's BHD", async () => {
247
+ const error = await stack.http.writeErr(
248
+ "currencies-global:write:invoice:create",
249
+ { title: "Cross-tenant", amount: 100, amountCurrency: "BHD" },
250
+ tenant2Admin,
251
+ );
252
+ expectErrorIncludes(error, "currency_not_allowed");
253
+ });
254
+
255
+ test("tenant 2 cannot use tenant 1's XYZ (not in DEFAULT_CURRENCIES)", async () => {
256
+ const error = await stack.http.writeErr(
257
+ "currencies-global:write:invoice:create",
258
+ { title: "Cross-tenant", amount: 100, amountCurrency: "XYZ" },
259
+ tenant2Admin,
260
+ );
261
+ expectErrorIncludes(error, "currency_not_allowed");
262
+ });
263
+
264
+ test("tenant 1 cannot use tenant 2's TRY", async () => {
265
+ const error = await stack.http.writeErr(
266
+ "currencies-global:write:invoice:create",
267
+ { title: "Cross-tenant", amount: 100, amountCurrency: "TRY" },
268
+ tenant1Admin,
269
+ );
270
+ expectErrorIncludes(error, "currency_not_allowed");
271
+ });
272
+ });
273
+
274
+ // --- Shipping currency validation ---
275
+
276
+ describe("shipping currency validation", () => {
277
+ test("valid amount + invalid shipping currency fails", async () => {
278
+ const error = await stack.http.writeErr(
279
+ "currencies-global:write:invoice:create",
280
+ {
281
+ title: "Bad shipping",
282
+ amount: 100,
283
+ amountCurrency: "EUR",
284
+ shippingCost: 10,
285
+ shippingCostCurrency: "GBP", // not assigned to tenant 1
286
+ },
287
+ tenant1Admin,
288
+ );
289
+ expectErrorIncludes(error, "currency_not_allowed");
290
+ });
291
+ });
292
+
293
+ // --- Detail query ---
294
+
295
+ describe("invoice detail", () => {
296
+ test("detail returns the correct invoice by id", async () => {
297
+ const _first = await stack.http.writeOk<SaveContext>(
298
+ "currencies-global:write:invoice:create",
299
+ { title: "Detail-First", amount: 100, amountCurrency: "EUR" },
300
+ tenant1Admin,
301
+ );
302
+ const second = await stack.http.writeOk<SaveContext>(
303
+ "currencies-global:write:invoice:create",
304
+ { title: "Detail-Second", amount: 200, amountCurrency: "USD" },
305
+ tenant1Admin,
306
+ );
307
+
308
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
309
+ "currencies-global:query:invoice:detail",
310
+ { id: second.id },
311
+ tenant1Admin,
312
+ );
313
+ expect(detail["id"]).toBe(second.id);
314
+ expect(detail["title"]).toBe("Detail-Second");
315
+ expect(detail["amountCurrency"]).toBe("USD");
316
+ });
317
+
318
+ test("detail returns null for other tenant's invoice", async () => {
319
+ const created = await stack.http.writeOk<SaveContext>(
320
+ "currencies-global:write:invoice:create",
321
+ { title: "Tenant1-Only", amount: 50, amountCurrency: "EUR" },
322
+ tenant1Admin,
323
+ );
324
+
325
+ const detail = await stack.http.queryOk<null>(
326
+ "currencies-global:query:invoice:detail",
327
+ { id: created.id },
328
+ tenant2Admin,
329
+ );
330
+ expect(detail).toBeNull();
331
+ });
332
+ });
333
+ ```
334
+
335
+ ### src/entities/currency.ts
336
+
337
+ ```ts
338
+ // Currency — global reference data table
339
+ // Seeded from CURRENCY_CATALOG in feature.ts via r.referenceData()
340
+
341
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
342
+ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
343
+
344
+ export const currencyEntity = createEntity({
345
+ table: "read_sample_currencies",
346
+ fields: {
347
+ code: createTextField({ personal: false, reason: "is_reference_data", required: true }),
348
+ name: createTextField({ personal: false, reason: "is_reference_data", required: true }),
349
+ },
350
+ });
351
+
352
+ export const currencyTable = buildEntityTable("currency", currencyEntity);
353
+ ```
354
+
355
+ ### src/entities/invoice.ts
356
+
357
+ ```ts
358
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
359
+ import {
360
+ createEntity,
361
+ createMoneyField,
362
+ createTextField,
363
+ } from "@cosmicdrift/kumiko-framework/engine";
364
+
365
+ export const invoiceEntity = createEntity({
366
+ table: "read_sample_invoices",
367
+ fields: {
368
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
369
+ amount: createMoneyField({ required: true }),
370
+ shippingCost: createMoneyField(),
371
+ },
372
+ defaultCurrency: "EUR",
373
+ });
374
+
375
+ export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
376
+ ```
377
+
378
+ ### src/entities/tenant-currency.ts
379
+
380
+ ```ts
381
+ // TenantCurrency — which currencies each tenant is allowed to use
382
+ // References currency.code, adds isActive flag per tenant
383
+
384
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
385
+ import {
386
+ createBooleanField,
387
+ createEntity,
388
+ createTextField,
389
+ } from "@cosmicdrift/kumiko-framework/engine";
390
+
391
+ export const tenantCurrencyEntity = createEntity({
392
+ table: "read_sample_tenant_currencies",
393
+ fields: {
394
+ currencyCode: createTextField({ personal: false, reason: "is_reference_data", required: true }),
395
+ isActive: createBooleanField({ default: true }),
396
+ },
397
+ });
398
+
399
+ export const tenantCurrencyTable = buildEntityTable("tenant-currency", tenantCurrencyEntity);
400
+ ```
401
+
402
+ ### src/feature.ts
403
+
404
+ ```ts
405
+ // Money Shared Sample
406
+ // Shows: Shared (global) currencies via r.referenceData(), per-tenant assignment
407
+ //
408
+ // Pattern: Currencies are global master data — tenants pick which ones they use.
409
+ // Use this when: All tenants share the same currency catalog (ISO 4217 etc.)
410
+ //
411
+ // Tables:
412
+ // currency — global reference data (seeded from CURRENCY_CATALOG below)
413
+ // tenantCurrency — which currencies each tenant may use (with isActive flag)
414
+ // invoice — money fields validated against tenantCurrency
415
+
416
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
417
+ import { currencyEntity } from "./entities/currency";
418
+ import { invoiceEntity } from "./entities/invoice";
419
+ import { tenantCurrencyEntity } from "./entities/tenant-currency";
420
+ import { invoiceCreate } from "./handlers/invoice-create.write";
421
+ import { invoiceDetail } from "./handlers/invoice-detail.query";
422
+ import { tenantCurrencyAssign } from "./handlers/tenant-currency-assign.write";
423
+
424
+ export { currencyEntity } from "./entities/currency";
425
+ export { invoiceEntity } from "./entities/invoice";
426
+ export { tenantCurrencyEntity } from "./entities/tenant-currency";
427
+
428
+ // All currencies available in this app — seeded as global reference data
429
+ const CURRENCY_CATALOG = [
430
+ // Standard currencies
431
+ { code: "EUR", name: "Euro" },
432
+ { code: "USD", name: "US Dollar" },
433
+ { code: "GBP", name: "British Pound" },
434
+ { code: "CHF", name: "Swiss Franc" },
435
+ { code: "JPY", name: "Japanese Yen" },
436
+ { code: "SEK", name: "Swedish Krona" },
437
+ { code: "NOK", name: "Norwegian Krone" },
438
+ { code: "DKK", name: "Danish Krone" },
439
+ { code: "PLN", name: "Polish Zloty" },
440
+ { code: "CZK", name: "Czech Koruna" },
441
+ { code: "CAD", name: "Canadian Dollar" },
442
+ { code: "AUD", name: "Australian Dollar" },
443
+ { code: "NZD", name: "New Zealand Dollar" },
444
+ { code: "CNY", name: "Chinese Yuan" },
445
+ { code: "INR", name: "Indian Rupee" },
446
+ // Custom currencies added by this app
447
+ { code: "BHD", name: "Bahraini Dinar" },
448
+ { code: "SAR", name: "Saudi Riyal" },
449
+ { code: "TRY", name: "Turkish Lira" },
450
+ { code: "KRW", name: "South Korean Won" },
451
+ { code: "BRL", name: "Brazilian Real" },
452
+ { code: "XYZ", name: "Custom Token" },
453
+ ] as const;
454
+
455
+ export const currenciesGlobalFeature = defineFeature("currenciesGlobal", (r) => {
456
+ const currency = r.entity("currency", currencyEntity);
457
+ r.entity("tenant-currency", tenantCurrencyEntity);
458
+ r.entity("invoice", invoiceEntity);
459
+
460
+ // Seed global currency table — available for all tenants to pick from
461
+ r.referenceData(currency, [...CURRENCY_CATALOG], { upsertKey: "code" });
462
+
463
+ r.writeHandler(tenantCurrencyAssign);
464
+ r.writeHandler(invoiceCreate);
465
+ r.queryHandler(invoiceDetail);
466
+ });
467
+ ```
468
+
469
+ ### src/handlers/invoice-create.write.ts
470
+
471
+ ```ts
472
+ import { assertExistsIn, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
473
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
474
+ import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
475
+ import { z } from "zod";
476
+ import { invoiceEntity, invoiceTable } from "../entities/invoice";
477
+ import { tenantCurrencyTable } from "../entities/tenant-currency";
478
+
479
+ const invoiceCrud = createEventStoreExecutor(invoiceTable, invoiceEntity, {
480
+ entityName: "invoice",
481
+ });
482
+
483
+ export const invoiceCreate = defineWriteHandler({
484
+ name: "invoice:create",
485
+ schema: z.object({
486
+ title: z.string().min(1),
487
+ amount: z.number(),
488
+ amountCurrency: z.string().length(3),
489
+ shippingCost: z.number().optional(),
490
+ shippingCostCurrency: z.string().length(3).optional(),
491
+ }),
492
+ access: { roles: ["Admin"] },
493
+ handler: async (event, ctx) => {
494
+ const { title, amount, amountCurrency, shippingCost, shippingCostCurrency } = event.payload;
495
+
496
+ // Validate amount currency against tenant's allowed currencies. This is a
497
+ // business-rule violation (currency not permitted here), not a missing
498
+ // entity, so 422 + reason="currency_not_allowed" is the right shape.
499
+ const amountMissing = await assertExistsIn(ctx.db, tenantCurrencyTable, {
500
+ field: "currencyCode",
501
+ value: amountCurrency,
502
+ where: { isActive: true },
503
+ });
504
+ if (amountMissing) {
505
+ return failUnprocessable("currency_not_allowed", {
506
+ field: "amountCurrency",
507
+ value: amountCurrency,
508
+ });
509
+ }
510
+
511
+ if (shippingCostCurrency) {
512
+ const shippingMissing = await assertExistsIn(ctx.db, tenantCurrencyTable, {
513
+ field: "currencyCode",
514
+ value: shippingCostCurrency,
515
+ where: { isActive: true },
516
+ });
517
+ if (shippingMissing) {
518
+ return failUnprocessable("currency_not_allowed", {
519
+ field: "shippingCostCurrency",
520
+ value: shippingCostCurrency,
521
+ });
522
+ }
523
+ }
524
+
525
+ // Money fields go in as the combined { amount, currency } API form; the
526
+ // executor flattens them into the invoice's own amount/amountCurrency columns.
527
+ return invoiceCrud.create(
528
+ {
529
+ title,
530
+ amount: { amount, currency: amountCurrency },
531
+ ...(shippingCost !== undefined
532
+ ? { shippingCost: { amount: shippingCost, currency: shippingCostCurrency } }
533
+ : {}),
534
+ },
535
+ event.user,
536
+ ctx.db,
537
+ );
538
+ },
539
+ });
540
+ ```
541
+
542
+ ### src/handlers/invoice-detail.query.ts
543
+
544
+ ```ts
545
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
546
+ import { z } from "zod";
547
+ import { invoiceTable } from "../entities/invoice";
548
+
549
+ export const invoiceDetail = defineQueryHandler({
550
+ name: "invoice:detail",
551
+ schema: z.object({ id: z.uuid() }),
552
+ access: {
553
+ openToAll: {
554
+ reason:
555
+ "demo recipe: any signed-in user may look up any invoice by id; the sample " +
556
+ "focuses on currency formatting, not access control",
557
+ },
558
+ },
559
+ handler: async (query, ctx) => {
560
+ const [row] = await ctx.db.selectMany(invoiceTable, { id: query.payload.id });
561
+ return (row as Record<string, unknown>) ?? null;
562
+ },
563
+ });
564
+ ```
565
+
566
+ ### src/handlers/tenant-currency-assign.write.ts
567
+
568
+ ```ts
569
+ // Assigns an existing currency to a tenant (creates tenantCurrency entry)
570
+ // The currency must exist in the global currency table
571
+
572
+ import { assertExistsIn, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
573
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
574
+ import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
575
+ import { z } from "zod";
576
+ import { currencyTable } from "../entities/currency";
577
+ import { tenantCurrencyEntity, tenantCurrencyTable } from "../entities/tenant-currency";
578
+
579
+ const tenantCurrencyCrud = createEventStoreExecutor(tenantCurrencyTable, tenantCurrencyEntity, {
580
+ entityName: "tenant-currency",
581
+ });
582
+
583
+ export const tenantCurrencyAssign = defineWriteHandler({
584
+ name: "tenant-currency:assign",
585
+ schema: z.object({
586
+ currencyCode: z.string().length(3),
587
+ isActive: z.boolean().optional(),
588
+ }),
589
+ access: { roles: ["Admin"] },
590
+ handler: async (event, ctx) => {
591
+ // Validate that the currency exists in the global reference table (no tenantId — global data)
592
+ const notFound = await assertExistsIn(ctx.db, currencyTable, {
593
+ field: "code",
594
+ value: event.payload.currencyCode,
595
+ entityName: "currency",
596
+ });
597
+ if (notFound) return writeFailure(notFound);
598
+
599
+ return tenantCurrencyCrud.create(
600
+ {
601
+ currencyCode: event.payload.currencyCode,
602
+ isActive: event.payload.isActive ?? true,
603
+ },
604
+ event.user,
605
+ ctx.db,
606
+ );
607
+ },
608
+ });
609
+ ```