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