@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,366 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/timezones. Do not edit. -->
2
+
3
+ # timezones
4
+
5
+ Every time-aware field type on one entity, plus the ctx.tz handler API.
6
+
7
+ ## README
8
+
9
+ # Timezones
10
+
11
+ Every time-aware field type on one entity, plus the `ctx.tz` handler API. The
12
+ recipe ships a `delivery` entity that carries four different kinds of time and
13
+ two feature-level queries that reach for `ctx.tz` directly.
14
+
15
+ Dates are the field type people get wrong most often — "10:00" means nothing
16
+ without a zone, a birthday has no time at all, and "now" is a single instant
17
+ seen differently around the world. The framework gives each of those its own
18
+ field type so the ambiguity is gone at the schema, not patched in handlers.
19
+
20
+ ## What it shows
21
+
22
+ - **Four time field types**, each for a distinct shape of time:
23
+ - `createLocatedTimestampField()` — a **wall-clock time at a place**
24
+ (`pickup`). One schema field becomes two columns (`pickup_utc` +
25
+ `pickup_tz`) and three read fields (`{ at, tz, utc }`). Write `{ at, tz }`
26
+ and the framework computes the UTC instant; read it back and you get the
27
+ original wall-clock, the zone, **and** the UTC instant.
28
+ - `createDateField()` — a **calendar date** with no time and no zone
29
+ (`dropoffOn`). A birthday, a due date.
30
+ - `createTimestampField()` — a **UTC instant**, a single point on the
31
+ timeline (`bookedAt`). When something happened.
32
+ - `createTzField()` — a bare **IANA zone name** (`homeZone`), e.g.
33
+ `"Europe/Berlin"`.
34
+ - **IANA validation at the write boundary** — an invalid zone name (in a `tz`
35
+ field or in a located field's `tz`) is rejected with a `validation_error`
36
+ before it ever reaches the database, via `Intl.supportedValuesOf`.
37
+ - **`ctx.tz` in handlers** — the time API feature code uses instead of
38
+ `new Date()`:
39
+ - `ctx.tz.todayRange(zone)` / `ctx.tz.today(zone)` — the `day-window` query
40
+ returns today's UTC range for a zone, ready to drop into a `BETWEEN` over
41
+ the `pickup_utc` column. It crosses DST and the date line correctly.
42
+ - `ctx.tz.fromCoordinates(coords)` — the `zone-at` query resolves lat/lng to
43
+ an IANA zone through an injected `GeoTzProvider`.
44
+
45
+ ## The located field, end to end
46
+
47
+ ```
48
+ write { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" }
49
+ stored pickup_utc = 2026-04-15T09:00:00Z pickup_tz = "Europe/Lisbon"
50
+ read { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" }
51
+ ```
52
+
53
+ Lisbon is on summer time (UTC+1) on that date, so 10:00 local is 09:00 UTC —
54
+ and the recipe's integration test pins exactly that, proving the conversion is
55
+ DST-aware rather than a fixed offset. The wall-clock you wrote comes back
56
+ unchanged; the `utc` field is there when you need to sort or range-query.
57
+
58
+ The renderer pairs this field with a date-time + zone picker out of the box, so
59
+ a form binding to `pickup` gets the combined control with no extra wiring — see
60
+ the `use-all-bundled` sample app for the live picker.
61
+
62
+ ## The GeoTzProvider seam
63
+
64
+ `ctx.tz.fromCoordinates` / `ctx.tz.fromAddress` delegate to an optional
65
+ provider you inject via the app context — there is no built-in geocoder, so v1
66
+ ships nothing that phones home:
67
+
68
+ ```ts illustration
69
+ runProdApp({ /* … */, extraContext: { geoTzProvider: myProvider } })
70
+ // or buildServer({ context: { geoTzProvider: myProvider } })
71
+ ```
72
+
73
+ `fromCoordinates` is the primary method because offline geo-tz libraries
74
+ resolve **lat/lng**, not postal addresses; `fromAddress` is optional, for
75
+ geocoding-API providers. With no provider configured, both throw a clear,
76
+ actionable error. The recipe's test injects a tiny fake provider to exercise
77
+ the seam end to end.
78
+
79
+ ## Flow
80
+
81
+ 1. Define the entity with `createEntity({ fields: { … } })` mixing the four
82
+ time field types.
83
+ 2. Register CRUD via `defineEntity*Handler` — create/update accept the located
84
+ field as `{ at, tz }`; the write boundary computes UTC.
85
+ 3. Add feature-level queries with `r.queryHandler` that call `ctx.tz` —
86
+ `day-window` (todayRange) and `zone-at` (fromCoordinates). Their names carry
87
+ no entity prefix, so they return their own shape instead of being filtered
88
+ against the entity's fields.
89
+
90
+ ## Tests
91
+
92
+ ```bash
93
+ bun kumiko test integration samples/timezones
94
+ ```
95
+
96
+ Integration tests under `src/__tests__/` prove the located round-trip with
97
+ DST-aware UTC, all four field types preserved, IANA rejection at the write
98
+ boundary, the `todayRange` day-window, and the `fromCoordinates` provider seam.
99
+
100
+ ## Related samples
101
+
102
+ - [basic-entity](/en/samples/recipes-basic-entity/) — the CRUD baseline these
103
+ time fields sit on top of.
104
+ - [custom-handlers](/en/samples/recipes-custom-handlers/) — more on
105
+ `r.writeHandler` / `r.queryHandler` with business logic.
106
+
107
+ ## Source files
108
+
109
+ ### src/__tests__/feature.integration.test.ts
110
+
111
+ ```ts
112
+ // Timezones Sample — Integration Test
113
+ // Proves: located round-trip with computed UTC across DST, all four time field
114
+ // types preserved, IANA validation at the write boundary, the todayRange
115
+ // day-window query, and the fromCoordinates GeoTzProvider seam.
116
+
117
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
118
+ import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
119
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
120
+ import {
121
+ setupTestStack,
122
+ type TestStack,
123
+ TestUsers,
124
+ unsafeCreateEntityTable,
125
+ } from "@cosmicdrift/kumiko-framework/stack";
126
+ import type { GeoTzProvider } from "@cosmicdrift/kumiko-framework/time";
127
+ import { deliveryEntity, timezonesFeature } from "../feature";
128
+
129
+ let stack: TestStack;
130
+
131
+ const admin = TestUsers.admin;
132
+
133
+ // A stand-in for an offline geo-tz library: positive latitude → northern
134
+ // example zone, negative → southern. Real providers resolve true lat/lng;
135
+ // this one only has to prove the injection seam threads through to ctx.tz.
136
+ const fakeGeoTz: GeoTzProvider = {
137
+ fromCoordinates: (coords) => (coords.latitude >= 0 ? "Europe/Berlin" : "America/Sao_Paulo"),
138
+ };
139
+
140
+ beforeAll(async () => {
141
+ stack = await setupTestStack({
142
+ features: [timezonesFeature],
143
+ extraContext: { geoTzProvider: fakeGeoTz },
144
+ });
145
+ await unsafeCreateEntityTable(stack.db, deliveryEntity);
146
+ await createEventsTable(stack.db);
147
+ });
148
+
149
+ afterAll(async () => {
150
+ await stack.cleanup();
151
+ });
152
+
153
+ beforeEach(() => {
154
+ stack.events.reset();
155
+ });
156
+
157
+ // --- locatedTimestamp round-trip ---
158
+
159
+ describe("located round-trip", () => {
160
+ test("write { at, tz } reads back { at, tz, utc } with UTC computed across DST", async () => {
161
+ // Lisbon is on WEST (UTC+1) on 2026-04-15, so 10:00 local is 09:00 UTC.
162
+ const created = await stack.http.writeOk<SaveContext>(
163
+ "timezones:write:delivery:create",
164
+ { label: "DST check", pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" } },
165
+ admin,
166
+ );
167
+
168
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
169
+ "timezones:query:delivery:detail",
170
+ { id: created.id },
171
+ admin,
172
+ );
173
+
174
+ const pickup = detail["pickup"] as { at: string; tz: string; utc: string };
175
+ expect(pickup.tz).toBe("Europe/Lisbon");
176
+ expect(pickup.at).toBe("2026-04-15T10:00:00");
177
+ expect(pickup.utc).toBe("2026-04-15T09:00:00Z");
178
+ });
179
+ });
180
+
181
+ // --- all four time field types ---
182
+
183
+ describe("field types", () => {
184
+ test("date, timestamp and tz fields each preserve their value", async () => {
185
+ const created = await stack.http.writeOk<SaveContext>(
186
+ "timezones:write:delivery:create",
187
+ {
188
+ label: "Full record",
189
+ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" },
190
+ dropoffOn: "2026-04-20",
191
+ bookedAt: "2026-04-15T08:30:00Z",
192
+ homeZone: "Europe/Berlin",
193
+ },
194
+ admin,
195
+ );
196
+
197
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
198
+ "timezones:query:delivery:detail",
199
+ { id: created.id },
200
+ admin,
201
+ );
202
+
203
+ // A `date` field carries a calendar day, no time-of-day/timezone
204
+ // component — it round-trips as a plain "yyyy-mm-dd" (kumiko-framework#1924).
205
+ expect(detail["dropoffOn"]).toBe("2026-04-20");
206
+ expect(detail["homeZone"]).toBe("Europe/Berlin");
207
+ expect(detail["bookedAt"]).toBe("2026-04-15T08:30:00Z");
208
+ });
209
+ });
210
+
211
+ // --- IANA validation at the write boundary ---
212
+
213
+ describe("IANA validation", () => {
214
+ test("invalid bare tz field is rejected with validation_error", async () => {
215
+ const error = await stack.http.writeErr(
216
+ "timezones:write:delivery:create",
217
+ {
218
+ label: "Bad zone",
219
+ pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" },
220
+ homeZone: "Mars/Olympus",
221
+ },
222
+ admin,
223
+ );
224
+ expect(error.code).toBe("validation_error");
225
+ });
226
+
227
+ test("invalid located-field tz is rejected with validation_error", async () => {
228
+ const error = await stack.http.writeErr(
229
+ "timezones:write:delivery:create",
230
+ { label: "Bad located zone", pickup: { at: "2026-04-15T10:00:00", tz: "Nowhere/Nope" } },
231
+ admin,
232
+ );
233
+ expect(error.code).toBe("validation_error");
234
+ });
235
+ });
236
+
237
+ // --- ctx.tz.todayRange — day-window query ---
238
+
239
+ describe("day-window query (todayRange)", () => {
240
+ test("returns the zone's calendar date and a 24h UTC window", async () => {
241
+ const window = await stack.http.queryOk<{
242
+ zone: string;
243
+ date: string;
244
+ start: string;
245
+ end: string;
246
+ }>("timezones:query:day-window", { zone: "Asia/Tokyo" }, admin);
247
+
248
+ expect(window.zone).toBe("Asia/Tokyo");
249
+ expect(window.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
250
+ expect(window.start.endsWith("Z")).toBe(true);
251
+ expect(window.end.endsWith("Z")).toBe(true);
252
+ // Tokyo has no DST, so today's window is exactly 24 hours.
253
+ expect(Date.parse(window.end) - Date.parse(window.start)).toBe(24 * 60 * 60 * 1000);
254
+ });
255
+
256
+ test("invalid zone is rejected with validation_error, not a raw RangeError", async () => {
257
+ const res = await stack.http.query(
258
+ "timezones:query:day-window",
259
+ { zone: "Nowhere/Nope" },
260
+ admin,
261
+ );
262
+ expect(res.status).toBe(400);
263
+ const body = (await res.json()) as { error: { code: string } };
264
+ expect(body.error.code).toBe("validation_error");
265
+ });
266
+ });
267
+
268
+ // --- ctx.tz.fromCoordinates — GeoTzProvider seam ---
269
+
270
+ describe("fromCoordinates (injected GeoTzProvider)", () => {
271
+ test("northern coordinates resolve to the provider's northern zone", async () => {
272
+ const result = await stack.http.queryOk<{ zone: string }>(
273
+ "timezones:query:zone-at",
274
+ { latitude: 52.52, longitude: 13.405 },
275
+ admin,
276
+ );
277
+ expect(result.zone).toBe("Europe/Berlin");
278
+ });
279
+
280
+ test("southern coordinates resolve to the provider's southern zone", async () => {
281
+ const result = await stack.http.queryOk<{ zone: string }>(
282
+ "timezones:query:zone-at",
283
+ { latitude: -23.55, longitude: -46.63 },
284
+ admin,
285
+ );
286
+ expect(result.zone).toBe("America/Sao_Paulo");
287
+ });
288
+ });
289
+ ```
290
+
291
+ ### src/feature.ts
292
+
293
+ ```ts
294
+ import {
295
+ createDateField,
296
+ createEntity,
297
+ createLocatedTimestampField,
298
+ createTextField,
299
+ createTimestampField,
300
+ createTzField,
301
+ defineEntityDeleteHandler,
302
+ defineFeature,
303
+ } from "@cosmicdrift/kumiko-framework/engine";
304
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
305
+ import { z } from "zod";
306
+
307
+ export const deliveryEntity = createEntity({
308
+ table: "read_sample_deliveries",
309
+ fields: {
310
+ label: createTextField({ personal: false, reason: "is_business_data", required: true }),
311
+ pickup: createLocatedTimestampField({ required: true }),
312
+ dropoffOn: createDateField(),
313
+ bookedAt: createTimestampField(),
314
+ homeZone: createTzField(),
315
+ },
316
+ softDelete: true,
317
+ });
318
+
319
+ const write = { access: { roles: ["Admin", "User"] } } as const;
320
+ const adminWrite = { access: { roles: ["Admin"] } } as const;
321
+ const openRead = {
322
+ access: {
323
+ openToAll: {
324
+ reason:
325
+ "any signed-in user may list deliveries and query the timezone helpers below; " +
326
+ "writes are still gated by the roles above",
327
+ },
328
+ },
329
+ } as const;
330
+
331
+ export const timezonesFeature = defineFeature("timezones", (r) => {
332
+ r.crud("delivery", deliveryEntity, {
333
+ write,
334
+ read: openRead,
335
+ verbs: { delete: false, restore: false },
336
+ });
337
+ r.writeHandler(defineEntityDeleteHandler("delivery", deliveryEntity, adminWrite));
338
+
339
+ r.queryHandler(
340
+ "day-window",
341
+ z.object({
342
+ zone: z.string().refine(isValidIanaTimeZone, { message: "Invalid IANA timezone" }),
343
+ }),
344
+ async (query, ctx) => {
345
+ const { start, end } = ctx.tz.todayRange(query.payload.zone);
346
+ return {
347
+ zone: query.payload.zone,
348
+ date: ctx.tz.today(query.payload.zone).toString(),
349
+ start: start.toString(),
350
+ end: end.toString(),
351
+ };
352
+ },
353
+ openRead,
354
+ );
355
+
356
+ r.queryHandler(
357
+ "zone-at",
358
+ z.object({ latitude: z.number(), longitude: z.number() }),
359
+ async (query, ctx) => {
360
+ const zone = await ctx.tz.fromCoordinates(query.payload);
361
+ return { zone };
362
+ },
363
+ openRead,
364
+ );
365
+ });
366
+ ```