@cosmicdrift/kumiko-framework 0.111.0 → 0.113.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.111.0",
3
+ "version": "0.113.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.111.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.113.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
package/src/api/routes.ts CHANGED
@@ -90,7 +90,10 @@ export function createApiRoutes(dispatcher: Dispatcher) {
90
90
  }
91
91
  return c.json(result);
92
92
  } catch (e) {
93
- return writeErrorResponse(c, toKumiko(e));
93
+ // 615/1: pass the batch's command types, not undefined — a single
94
+ // "type" doesn't apply to a batch, but logging none loses the fault
95
+ // context entirely.
96
+ return writeErrorResponse(c, toKumiko(e), body.commands?.map((cmd) => cmd.type).join(","));
94
97
  }
95
98
  });
96
99
 
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
2
3
  import { createTenantConfig } from "../config-helpers";
3
4
  import { defineFeature } from "../define-feature";
5
+ import { createEntity, createTextField } from "../factories";
4
6
  import { createRegistry } from "../registry";
5
7
  import type { FeatureDefinition } from "../types/feature";
6
8
 
@@ -130,4 +132,30 @@ describe("extensionSelector boot-validation", () => {
130
132
  }),
131
133
  ).toThrow(/declared twice/);
132
134
  });
135
+
136
+ // 437/2: createRegistry now delegates this check to
137
+ // validateExtensionPreSaveWiring (shared with validateBoot's standalone
138
+ // callers) instead of a duplicate inline computation — pins that
139
+ // createRegistry itself still enforces it, not just validateBoot.
140
+ test("throws when extension preSave targets entity with no mapped write handlers", () => {
141
+ const ext = defineFeature("cap-ext", (r) => {
142
+ r.extendsRegistrar("credit-cap", {
143
+ hooks: { preSave: async (changes) => changes },
144
+ });
145
+ });
146
+ const consumer = defineFeature("money-horse", (r) => {
147
+ r.requires("cap-ext");
148
+ r.entity("credit", createEntity({ table: "Credits", fields: { name: createTextField() } }));
149
+ r.writeHandler(
150
+ "doSomething",
151
+ z.object({}),
152
+ async () => ({ isSuccess: true as const, data: {} }),
153
+ { access: { openToAll: true } },
154
+ );
155
+ r.useExtension("credit-cap", "credit");
156
+ });
157
+ expect(() => createRegistry([ext, consumer])).toThrow(
158
+ /no write handler is entity-mapped to "credit"/i,
159
+ );
160
+ });
133
161
  });
@@ -45,6 +45,46 @@ describe("r.screen() — registration", () => {
45
45
  expect(feature.screens["product-list"]?.type).toBe("entityList");
46
46
  });
47
47
 
48
+ test("stores a projectionList screen bound to an explicit (cross-feature) query", () => {
49
+ const feature = defineFeature("app", (r) => {
50
+ r.screen({
51
+ id: "rent-list",
52
+ type: "projectionList",
53
+ query: "ledger:query:schedule:list",
54
+ columns: [
55
+ { field: "description", label: "app:col.desc" },
56
+ {
57
+ field: "amount",
58
+ label: "app:col.amount",
59
+ renderer: { react: { __component: "EuroCell" } },
60
+ },
61
+ ],
62
+ });
63
+ });
64
+ const screen = feature.screens["rent-list"];
65
+ expect(screen?.type).toBe("projectionList");
66
+ if (screen?.type !== "projectionList") throw new Error("type-narrow failed");
67
+ expect(screen.query).toBe("ledger:query:schedule:list");
68
+ });
69
+
70
+ test("validateBoot rejects a projectionList with empty columns", () => {
71
+ const features = [
72
+ defineFeature("app", (r) => {
73
+ r.screen({ id: "x", type: "projectionList", query: "app:query:foo:list", columns: [] });
74
+ }),
75
+ ];
76
+ expect(() => validateBoot(features)).toThrow(/empty columns list/i);
77
+ });
78
+
79
+ test("validateBoot rejects a projectionList with an empty query", () => {
80
+ const features = [
81
+ defineFeature("app", (r) => {
82
+ r.screen({ id: "x", type: "projectionList", query: "", columns: ["name"] });
83
+ }),
84
+ ];
85
+ expect(() => validateBoot(features)).toThrow(/empty or non-string query/i);
86
+ });
87
+
48
88
  test("stores an entityEdit screen with sections + conditional fields", () => {
49
89
  const feature = defineFeature("shop", (r) => {
50
90
  r.entity("product", productEntity());
@@ -31,6 +31,9 @@ describe("registry soft-delete auto-wiring", () => {
31
31
  const key = registry.getConfigKey(SOFT_DELETE_GRACE_DAYS_KEY);
32
32
  expect(key?.type).toBe("number");
33
33
  expect(key?.default).toBe(DEFAULT_GRACE_DAYS);
34
+ // 565/2: min must be 1, not 0 — graceDays: 0 means "hard-delete every
35
+ // currently soft-deleted row on the next cron run", not "no cleanup".
36
+ expect(key?.bounds?.min).toBe(1);
34
37
  });
35
38
 
36
39
  test("also injects the system-scope cleanup job (not perTenant)", () => {
@@ -141,6 +141,27 @@ export function validateScreens(
141
141
  continue;
142
142
  }
143
143
 
144
+ if (screen.type === "projectionList") {
145
+ // Query-getrieben, keine Entity → nur query + columns prüfen (die
146
+ // Column-Felder können nicht gegen eine Entity gecheckt werden; sie
147
+ // werden zur Render-Zeit gegen die Projection-Rows aufgelöst).
148
+ if (!screen.query || typeof screen.query !== "string") {
149
+ throw new Error(
150
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query.`,
151
+ );
152
+ }
153
+ if (screen.columns.length === 0) {
154
+ throw new Error(
155
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) has an empty columns list — ` +
156
+ `declare at least one column.`,
157
+ );
158
+ }
159
+ for (const col of screen.columns) {
160
+ validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
161
+ }
162
+ continue;
163
+ }
164
+
144
165
  if (screen.type === "configEdit") {
145
166
  // configEdit: layout/fields wie actionForm validieren, plus
146
167
  // Cross-Check dass jeder qualifizierte Config-Key registriert
@@ -14,7 +14,7 @@
14
14
  // Siehe docs/plans/datenschutz/user-data-rights.md.
15
15
 
16
16
  import type { DbRunner } from "../../db/connection";
17
- import type { TenantId } from "../types";
17
+ import type { Registry, TenantId } from "../types";
18
18
 
19
19
  // SessionUser.id ist plattformweit `string` (kein Brand-Type). Wenn
20
20
  // jemals ein UserId-Brand eingefuehrt wird, ersetzt man hier den
@@ -70,6 +70,17 @@ export interface UserDataStorageProvider {
70
70
 
71
71
  export interface UserDataHookCtx {
72
72
  readonly db: DbRunner;
73
+ /**
74
+ * The app registry. A forget hook that must erase CHILD read-model rows past
75
+ * the entity's own row — m:n join projections, per-parent detail projections —
76
+ * uses it to run those custom projections for the executor's
77
+ * `<entity>.forgotten` event: `runProjectionsForEvent(result.data.event,
78
+ * ctx.registry, ctx.db)`. `executor.forget` purges only its OWN projection,
79
+ * and the dispatcher's post-command projection pass does not fire in the forget
80
+ * pipeline (a job, not a dispatched command), so the hook must trigger the
81
+ * cascade itself. Live-erase + rebuild `<parent>.forgotten`-apply converge.
82
+ */
83
+ readonly registry: Registry;
73
84
  readonly tenantId: TenantId;
74
85
  readonly userId: UserId;
75
86
  /**
@@ -6,7 +6,6 @@ export {
6
6
  validateAppCustomScreenWriteQns,
7
7
  validateBoot,
8
8
  } from "./boot-validator";
9
- export { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
10
9
  export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema";
11
10
  export type { ConfigFeatureSchema } from "./build-config-feature-schema";
12
11
  export {
@@ -7,6 +7,7 @@ import {
7
7
  import { asEntityTableMeta } from "../db/query";
8
8
  import { buildEntityTable } from "../db/table-builder";
9
9
  import { buildMetricName, validateMetricName } from "../observability";
10
+ import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
10
11
  import { type QnType, qualifyEntityName } from "./qualified-name";
11
12
  import {
12
13
  buildSoftDeleteCleanupJob,
@@ -1051,26 +1052,11 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1051
1052
  }
1052
1053
  }
1053
1054
 
1054
- // Extension preSave: useExtension on an entity must have at least one mapped write handler.
1055
- const extensionsWithPreSave = new Set<string>();
1056
- for (const f of features) {
1057
- for (const [extName, def] of Object.entries(f.registrarExtensions ?? {})) {
1058
- if (def.hooks?.preSave) extensionsWithPreSave.add(extName);
1059
- }
1060
- }
1061
- for (const usage of extensionUsages) {
1062
- if (!extensionsWithPreSave.has(usage.extensionName)) continue;
1063
- const hasMapped = [...writeHandlerMap.keys()].some(
1064
- (qn) => handlerEntityMap.get(qn) === usage.entityName,
1065
- );
1066
- if (!hasMapped) {
1067
- throw new Error(
1068
- `Feature "${usage.featureName}" uses extension "${usage.extensionName}" with preSave on entity "${usage.entityName}" ` +
1069
- `but no write handler is entity-mapped to "${usage.entityName}". ` +
1070
- `Use create/update/delete on a matching entity or name the handler "entity:verb".`,
1071
- );
1072
- }
1073
- }
1055
+ // Extension preSave: useExtension on an entity must have at least one mapped
1056
+ // write handler. Shared with validateBoot's standalone callers (437/2)
1057
+ // schema-cli's `validate` runs validateBoot on raw FEATURES without ever
1058
+ // building a Registry, so this can't live only here.
1059
+ validateExtensionPreSaveWiring(features);
1074
1060
 
1075
1061
  // Validate: all relation targets must reference existing entities
1076
1062
  for (const [entityName, rels] of relationMap) {
@@ -29,7 +29,11 @@ export const softDeleteGraceDaysConfig: ConfigKeyDefinition = {
29
29
  default: DEFAULT_GRACE_DAYS,
30
30
  scope: "tenant",
31
31
  access: { read: ["TenantAdmin", "SystemAdmin"], write: ["SystemAdmin"] },
32
- bounds: { min: 0 },
32
+ // min: 1, not 0 (565/2) — graceDays: 0 doesn't mean "no cleanup", it means
33
+ // "hard-delete every currently soft-deleted row on the next cron run".
34
+ // Clamping (not rejecting) keeps the resolve-config-or-param clamp path's
35
+ // existing silent-below-bound / audited-crossing behavior.
36
+ bounds: { min: 1 },
33
37
  };
34
38
 
35
39
  export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
@@ -56,7 +60,7 @@ export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
56
60
  db,
57
61
  )
58
62
  : undefined;
59
- const graceDays = typeof resolved === "number" && resolved >= 0 ? resolved : DEFAULT_GRACE_DAYS;
63
+ const graceDays = typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
60
64
  const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
61
65
 
62
66
  for (const proj of registry.getAllProjections().values()) {
@@ -524,7 +524,9 @@ export function isFileField(field: FieldDefinition | undefined): field is AnyFil
524
524
  // --- Derived (computed) fields ---
525
525
  //
526
526
  // A derived field is read-time only: its value is computed from the stored row
527
- // (and the clock) when a list/detail query runs, never persisted. It lives in
527
+ // (and the clock) when an entityList query runs (676/2 NOT detail; only the
528
+ // "list" case in entity-handlers.ts calls augmentDerivedFields), never
529
+ // persisted. It lives in
528
530
  // `EntityDefinition.derivedFields` — deliberately NOT in `fields`, so it
529
531
  // produces no DB column, never enters a write schema, and can't be the target
530
532
  // of an entityEdit. A declarative `entityList` can name it like any column and
@@ -230,6 +230,7 @@ export type {
230
230
  FormatSpec,
231
231
  ListColumnSpec,
232
232
  PlatformComponent,
233
+ ProjectionListScreenDefinition,
233
234
  RowAction,
234
235
  RowActionNavigate,
235
236
  RowActionWriteHandler,
@@ -296,6 +296,34 @@ export type EntityListScreenDefinition = {
296
296
  readonly access?: AccessRule;
297
297
  };
298
298
 
299
+ // --- projectionList ---
300
+
301
+ // Like entityList, but bound to an EXPLICIT query instead of an entity. The
302
+ // list-query is taken verbatim from `query` (a fully qualified QN like
303
+ // "ledger:query:schedule:list") — NOT derived from the screen's own feature —
304
+ // so a screen can render any read-projection, including one owned by a
305
+ // different feature (the entityList feature-local resolution can't). Columns
306
+ // carry their own labels (no entity to derive field-labels from); there is no
307
+ // auto create-navigation (a projection isn't an editable entity list). Row
308
+ // interaction is explicit via `rowActions`. The query must return the same
309
+ // paged envelope as an entity list-query: `{ rows, nextCursor, total? }`.
310
+ export type ProjectionListScreenDefinition = {
311
+ readonly id: string;
312
+ readonly type: "projectionList";
313
+ readonly query: string;
314
+ readonly columns: readonly ListColumnSpec[];
315
+ readonly rowRenderer?: PlatformComponent;
316
+ readonly cardRenderer?: PlatformComponent;
317
+ readonly rowActions?: readonly RowAction[];
318
+ readonly toolbarActions?: readonly ToolbarAction[];
319
+ readonly pagination?: ListPaginationMode;
320
+ readonly pageSize?: number;
321
+ readonly defaultSort?: ListSortSpec;
322
+ readonly searchable?: boolean;
323
+ readonly slots?: ScreenSlots;
324
+ readonly access?: AccessRule;
325
+ };
326
+
299
327
  // --- entityEdit ---
300
328
 
301
329
  // camelCase `readOnly` instead of the spec's lowercase `readonly`: TS's
@@ -526,6 +554,7 @@ export type ScreenSlots = {
526
554
 
527
555
  export type ScreenDefinition =
528
556
  | EntityListScreenDefinition
557
+ | ProjectionListScreenDefinition
529
558
  | EntityEditScreenDefinition
530
559
  | ActionFormScreenDefinition
531
560
  | ConfigEditScreenDefinition
@@ -48,8 +48,8 @@ describe("toStoredEvent", () => {
48
48
  });
49
49
 
50
50
  test("Feld-Vollständigkeit: das Mapping deckt genau die StoredEvent-Keys ab", () => {
51
- // Pin gegen versehentliches Weglassen eines Feldes im Mapping. Muss mit
52
- // der StoredEvent-Definition (event-store.ts) synchron bleiben.
51
+ // Guards required fields staying in sync with StoredEvent (event-store.ts);
52
+ // optional fields consistently omitted on both sides must be added manually.
53
53
  const expectedKeys: ReadonlyArray<keyof StoredEvent> = [
54
54
  "id",
55
55
  "aggregateId",
@@ -0,0 +1,70 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { Registry } from "../../engine/types";
3
+ import { makeFileProviderResolver } from "../provider-resolver";
4
+ import type { FileStorageProvider } from "../types";
5
+
6
+ const fakeProvider = { name: "fake" } as unknown as FileStorageProvider;
7
+
8
+ function fakeRegistry(): Registry {
9
+ return {
10
+ getExtensionUsages: () => [
11
+ {
12
+ entityName: "fake",
13
+ options: { build: async () => fakeProvider },
14
+ },
15
+ ],
16
+ } as unknown as Registry;
17
+ }
18
+
19
+ // 698/2: resolveProvider must not re-read config + secrets on every call —
20
+ // the provider is effectively static per tenant for the process lifetime.
21
+ describe("makeFileProviderResolver — per-tenant cache", () => {
22
+ test("a second resolve for the same tenant reuses the cached build, doesn't call configAccessorFactory again", async () => {
23
+ const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
24
+ const resolver = makeFileProviderResolver({
25
+ registry: fakeRegistry(),
26
+ _configAccessorFactory: configAccessorFactory,
27
+ db: {} as never,
28
+ });
29
+
30
+ const first = await resolver("tenant-a" as never);
31
+ const second = await resolver("tenant-a" as never);
32
+
33
+ expect(first).toBe(fakeProvider);
34
+ expect(second).toBe(fakeProvider);
35
+ expect(configAccessorFactory).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ test("different tenants get independent cache entries", async () => {
39
+ const configAccessorFactory = mock(() => async (_key: unknown) => "fake");
40
+ const resolver = makeFileProviderResolver({
41
+ registry: fakeRegistry(),
42
+ _configAccessorFactory: configAccessorFactory,
43
+ db: {} as never,
44
+ });
45
+
46
+ await resolver("tenant-a" as never);
47
+ await resolver("tenant-b" as never);
48
+
49
+ expect(configAccessorFactory).toHaveBeenCalledTimes(2);
50
+ });
51
+
52
+ test("a rejected build is evicted — the next call retries instead of staying poisoned", async () => {
53
+ let calls = 0;
54
+ const resolver = makeFileProviderResolver({
55
+ registry: {
56
+ getExtensionUsages: () => {
57
+ calls++;
58
+ if (calls === 1) throw new Error("transient failure");
59
+ return [{ entityName: "fake", options: { build: async () => fakeProvider } }];
60
+ },
61
+ } as unknown as Registry,
62
+ _configAccessorFactory: () => async (_key: unknown) => "fake",
63
+ db: {} as never,
64
+ });
65
+
66
+ await expect(resolver("tenant-a" as never)).rejects.toThrow("transient failure");
67
+ const second = await resolver("tenant-a" as never);
68
+ expect(second).toBe(fakeProvider);
69
+ });
70
+ });
@@ -138,21 +138,34 @@ export type FileProviderResolverDeps = {
138
138
  // SYSTEM identity: provider construction is an infra read, distinct from the
139
139
  // request user's authorization which stays at the route accessGuard.
140
140
  export function makeFileProviderResolver(deps: FileProviderResolverDeps): FileProviderResolver {
141
- return async (tenantId) => {
142
- if (!deps.registry || !deps._configAccessorFactory || !deps.db) {
143
- throw new Error(
144
- "makeFileProviderResolver: registry/_configAccessorFactory/db missing " +
145
- "mount the config + file-foundation features and wire context.db",
141
+ // Per-tenant cache (698/2) without it, every resolveProvider(tenantId)
142
+ // call re-reads config + secrets from the DB, even though the provider is
143
+ // effectively static per tenant for the process lifetime. A rejected
144
+ // promise is evicted so a transient failure (or a secret genuinely not
145
+ // configured yet) doesn't permanently poison this tenant's entry.
146
+ const cache = new Map<string, Promise<FileStorageProvider>>();
147
+ return (tenantId) => {
148
+ const cached = cache.get(tenantId);
149
+ if (cached) return cached;
150
+ const built = (async () => {
151
+ if (!deps.registry || !deps._configAccessorFactory || !deps.db) {
152
+ throw new Error(
153
+ "makeFileProviderResolver: registry/_configAccessorFactory/db missing — " +
154
+ "mount the config + file-foundation features and wire context.db",
155
+ );
156
+ }
157
+ const config = deps._configAccessorFactory({
158
+ user: { id: SYSTEM_USER_ID, tenantId },
159
+ db: deps.db,
160
+ secrets: deps.secrets,
161
+ });
162
+ return createFileProviderForTenant(
163
+ { config, registry: deps.registry, secrets: deps.secrets, _userId: SYSTEM_USER_ID },
164
+ tenantId,
146
165
  );
147
- }
148
- const config = deps._configAccessorFactory({
149
- user: { id: SYSTEM_USER_ID, tenantId },
150
- db: deps.db,
151
- secrets: deps.secrets,
152
- });
153
- return createFileProviderForTenant(
154
- { config, registry: deps.registry, secrets: deps.secrets, _userId: SYSTEM_USER_ID },
155
- tenantId,
156
- );
166
+ })();
167
+ built.catch(() => cache.delete(tenantId));
168
+ cache.set(tenantId, built);
169
+ return built;
157
170
  };
158
171
  }
@@ -5,6 +5,7 @@ import type {
5
5
  EntityListScreenDefinition,
6
6
  FeatureDefinition,
7
7
  NavDefinition,
8
+ ProjectionListScreenDefinition,
8
9
  RowAction,
9
10
  ScreenDefinition,
10
11
  ToolbarAction,
@@ -76,6 +77,17 @@ export function requiredKeysFromScreen(
76
77
  for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
77
78
  break;
78
79
  }
80
+ case "projectionList": {
81
+ const list = screen as ProjectionListScreenDefinition;
82
+ // Keine Entity → keine field-label-Fallbacks; nur explizite Column-Labels.
83
+ for (const col of list.columns) {
84
+ const normalized = normalizeListColumn(col);
85
+ if (normalized.label !== undefined) pushKey(out, normalized.label);
86
+ }
87
+ for (const action of list.rowActions ?? []) pushRowActionKeys(out, action);
88
+ for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
89
+ break;
90
+ }
79
91
  case "entityEdit": {
80
92
  const edit = screen as EntityEditScreenDefinition;
81
93
  pushKey(out, edit.submitLabel);
@@ -306,6 +306,18 @@ export async function rebuildProjection(
306
306
  while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
307
307
  // re-replay the full log into the fresh shadow
308
308
  }
309
+ // 656/2: drainBatch increments eventsProcessed for every drained row
310
+ // regardless of quarantine (skipApplyErrors), so this must hold
311
+ // exactly under the fence — a mismatch means drainBatch silently
312
+ // dropped a row instead of counting it, which the rest of this
313
+ // function has no other way to detect.
314
+ if (eventsProcessed < Number(expectedEvents)) {
315
+ throw new Error(
316
+ `projection-rebuild "${projectionName}": fenced re-replay processed ` +
317
+ `${eventsProcessed} events but ${expectedEvents} were expected — drainBatch ` +
318
+ "silently dropped rows instead of counting them.",
319
+ );
320
+ }
309
321
  }
310
322
  }
311
323
 
@@ -112,6 +112,9 @@ export function generateE2ESpec(
112
112
  // pro Field geschrieben, ohne CRUD-Zustand zu generieren wäre der
113
113
  // Spec wertlos. Branding/SMTP/etc. sind Author-spezifisch.
114
114
  if (screen.type === "configEdit") continue;
115
+ // projectionList: query-getrieben, Author-spezifische Projection —
116
+ // kein generischer CRUD-Spec ableitbar (wie actionForm/configEdit).
117
+ if (screen.type === "projectionList") continue;
115
118
  const { scope: feature, name: short } = parseQn(screenQn);
116
119
  const urlPath = `/t/${tenant}/${feature}/${short}`;
117
120
  const title = `${feature}/${short}`;
@@ -1,9 +1,7 @@
1
- // Pacific/Apia (Samoa) liegt weit östlich der Datumsgrenze bei UTC+13/+14 —
2
- // eine Vormittags-Wall-Clock dort fällt in UTC auf den VORHERIGEN Kalendertag.
3
- // Das Plan-Doc (timezones.md) nennt Apia explizit als Datumsgrenzen-Edge-Case
4
- // der TZ-Matrix. Als Datums-Ordnungs-Eigenschaft formuliert, damit der Test
5
- // unabhängig von der DST-Sicht der tzdata grün bleibt (UTC+13 wie +14 schieben
6
- // 10:00 auf den Vortag).
1
+ // Pacific/Apia sits far east of the dateline at UTC+13/+14 — a morning wall
2
+ // clock there falls on the PREVIOUS UTC calendar day. Phrased as a date-
3
+ // ordering property (not a fixed offset) so the test stays green regardless
4
+ // of the tzdata DST view (UTC+13 and +14 both push 10:00 to the prior day).
7
5
 
8
6
  import { beforeAll, describe, expect, test } from "bun:test";
9
7
  import { ensureTemporalPolyfill } from "../polyfill";
@@ -13,8 +11,8 @@ beforeAll(async () => {
13
11
  await ensureTemporalPolyfill();
14
12
  });
15
13
 
16
- describe("ctx.tz — Pacific/Apia Datumsgrenze", () => {
17
- test("Vormittags-Wall-Clock in Apia mappt auf den vorherigen UTC-Kalendertag", () => {
14
+ describe("ctx.tz — Pacific/Apia dateline", () => {
15
+ test("a morning wall clock in Apia maps to the previous UTC calendar day", () => {
18
16
  const tz = createTzContext();
19
17
  const zdt = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
20
18
  expect(zdt.timeZoneId).toBe("Pacific/Apia");
@@ -22,11 +20,11 @@ describe("ctx.tz — Pacific/Apia Datumsgrenze", () => {
22
20
  const utcDate = zdt.toInstant().toZonedDateTimeISO("UTC").toPlainDate().toString();
23
21
  expect(utcDate).toBe("2026-01-14");
24
22
 
25
- // Offset ist ein großer positiver Wert (UTC+13 oder +14).
23
+ // Offset is a large positive value (UTC+13 or +14).
26
24
  expect(zdt.offsetNanoseconds).toBeGreaterThan(12 * 3600 * 1e9);
27
25
  });
28
26
 
29
- test("Round-Trip bewahrt Wall-Clock + Instant über die Datumsgrenze", () => {
27
+ test("round-trip preserves wall clock + instant across the dateline", () => {
30
28
  const tz = createTzContext();
31
29
  const original = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
32
30
  const restored = tz.fromLocatedJson(tz.toLocatedJson(original));
@@ -57,6 +57,7 @@ export type {
57
57
  FieldRenderer,
58
58
  ListColumnSpec,
59
59
  PlatformComponent,
60
+ ProjectionListScreenDefinition,
60
61
  RowAction,
61
62
  RowActionNavigate,
62
63
  RowActionWriteHandler,