@cosmicdrift/kumiko-framework 0.187.0 → 0.189.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.
@@ -5,16 +5,18 @@
5
5
  // same-folder require cycle.
6
6
 
7
7
  import { rowMetaFieldNames } from "../../db/table-builder";
8
- import { qualifyEntityName } from "../qualified-name";
8
+ import { isValidQn, qualifyEntityName } from "../qualified-name";
9
9
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
10
10
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
11
- import type { FeatureDefinition } from "../types";
11
+ import type { EntityDefinition, FeatureDefinition } from "../types";
12
12
  import type {
13
13
  DashboardCustomPanel,
14
14
  DashboardFilterDefinition,
15
15
  DashboardPanelDefinition,
16
16
  DashboardScreenDefinition,
17
17
  DashboardStatGroupPanel,
18
+ EditFieldSpec,
19
+ EditLayout,
18
20
  FieldCondition,
19
21
  RowAction,
20
22
  RowFieldExtractor,
@@ -22,6 +24,48 @@ import type {
22
24
  ToolbarAction,
23
25
  } from "../types/screen";
24
26
 
27
+ // Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
28
+ // Can't import it directly — renderer depends on framework, not the reverse.
29
+ // Keep both lists in sync when a field type gains or loses an auto-wired widget.
30
+ const NO_WIDGET_FIELD_TYPES = new Set(["jsonb", "embedded", "files", "images"]);
31
+
32
+ // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
33
+ // entityEdit path (#1925) — a required field the user can never fill would
34
+ // block every save. Only the statically-resolvable case is caught here: a
35
+ // literal `required: true` (screen-spec override or entity-level default).
36
+ // A dynamic FieldCondition depends on runtime form values and can't be
37
+ // evaluated at boot; buildFormSchema() silently skips presence-checking it.
38
+ function validateNoWidgetRequiredField(
39
+ featureName: string,
40
+ screenId: string,
41
+ entityDef: EntityDefinition,
42
+ fieldSpec: Exclude<EditFieldSpec, string>,
43
+ ): void {
44
+ const fieldDef = entityDef.fields[fieldSpec.field];
45
+ // skip: field doesn't exist or its type already has a widget — nothing to validate.
46
+ if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.has(fieldDef.type)) return;
47
+ // Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
48
+ // grid widget (#1838) — only plain (non-list) embedded has no widget.
49
+ const isEmbeddedList =
50
+ fieldDef.type === "embedded" &&
51
+ (fieldDef as unknown as { multiple?: boolean }).multiple === true;
52
+ // skip: list variant has a widget — not the no-widget case this guard targets.
53
+ if (isEmbeddedList) return;
54
+ // skip: already read-only by spec — no fillable widget needed regardless of type.
55
+ if (fieldSpec.readOnly === true) return;
56
+ const entityRequired = "required" in fieldDef && fieldDef.required === true;
57
+ const isStaticallyRequired =
58
+ fieldSpec.required === undefined ? entityRequired : fieldSpec.required === true;
59
+ // skip: not required — a read-only widget-less field is fine to leave empty.
60
+ if (!isStaticallyRequired) return;
61
+ throw new Error(
62
+ `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}" is ` +
63
+ `type "${fieldDef.type}", which renders read-only on the auto-wired entityEdit path — a ` +
64
+ `required field the user could never fill would block every save. Set required: false, ` +
65
+ `move the field to a custom-component section, or drop the required constraint.`,
66
+ );
67
+ }
68
+
25
69
  // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
26
70
  // entityList and projectionList (framework#1708) — projectionList has no
27
71
  // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
@@ -71,6 +115,58 @@ function validateRowActionNavigateParams(
71
115
  }
72
116
  }
73
117
 
118
+ // Wizard layouts (mode: "wizard") render one section per step — a single
119
+ // step (or a step without a title, which would leave the progress
120
+ // indicator blank) defeats the point, so both fail at boot rather than
121
+ // as a broken step UI. Missing/blank titles are checked identically for
122
+ // both section kinds — EditExtensionSection.title is required by type,
123
+ // but that doesn't stop author code that circumvented the check from
124
+ // passing an empty string.
125
+ function validateWizardLayout(
126
+ featureName: string,
127
+ screenId: string,
128
+ screenType: "entityEdit" | "actionForm",
129
+ layout: EditLayout,
130
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
131
+ ): void {
132
+ // "form-draft" is hardcoded because the framework layer must not depend on
133
+ // @cosmicdrift/kumiko-bundled-features — same precedence as the
134
+ // "user-data-rights" check in gdpr-storage.ts.
135
+ if (layout.draft === true) {
136
+ if (layout.mode !== "wizard") {
137
+ throw new Error(
138
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but ` +
139
+ `mode is not "wizard" — draft persistence only applies to wizard layouts. Remove ` +
140
+ `draft: true or set mode: "wizard".`,
141
+ );
142
+ }
143
+ if (!featureMap.has("form-draft")) {
144
+ throw new Error(
145
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
146
+ `bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
147
+ `values. Add formDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
148
+ `feature list.`,
149
+ );
150
+ }
151
+ }
152
+ // skip: mode omitted/"single" — no wizard constraints apply.
153
+ if (layout.mode !== "wizard") return;
154
+ if (layout.sections.length < 2) {
155
+ throw new Error(
156
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but only ` +
157
+ `${layout.sections.length} section(s) — a wizard needs at least 2 sections (one per step).`,
158
+ );
159
+ }
160
+ layout.sections.forEach((section, index) => {
161
+ if (section.title === undefined || section.title.trim().length === 0) {
162
+ throw new Error(
163
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but ` +
164
+ `sections[${index}] has no title — every wizard step needs a title.`,
165
+ );
166
+ }
167
+ });
168
+ }
169
+
74
170
  // --- Screen validation ---
75
171
  //
76
172
  // For every r.screen() declaration check what's locally knowable at boot:
@@ -171,6 +267,15 @@ export function validateScreenShortIdCollisions(
171
267
  }
172
268
  }
173
269
 
270
+ // redirect/cancelTarget accept either a same-feature short id (unchanged
271
+ // behavior, qualified against the owning feature) or a fully-qualified
272
+ // cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
273
+ // short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
274
+ // the two forms don't collide.
275
+ function resolveScreenTargetQn(featureName: string, target: string): string {
276
+ return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
277
+ }
278
+
174
279
  export function validateScreens(
175
280
  feature: FeatureDefinition,
176
281
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -186,8 +291,9 @@ export function validateScreens(
186
291
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
187
292
  // Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
188
293
  // so zu den Custom-Editoren der Consumer-App). Der Validator spiegelt das:
189
- // same-feature ODER irgendein Feature. (redirect/cancelTarget bleiben bewusst
190
- // same-feature: deren Router baut die URL direkt aus der kurzen id.)
294
+ // same-feature ODER irgendein Feature. redirect/cancelTarget akzeptieren
295
+ // zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
296
+ // #1946) — kurze IDs bleiben same-feature wie zuvor.
191
297
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
192
298
  for (const [screenId, screen] of Object.entries(feature.screens)) {
193
299
  if (screen.type === "custom") {
@@ -441,32 +547,31 @@ export function validateScreens(
441
547
  }
442
548
  }
443
549
  }
550
+ validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
444
551
  if (screen.redirect !== undefined) {
445
- // redirect ist die kurze Screen-ID (z.B. "item-list"); der
446
- // nav-Router resolved sie beim Mount gegen die Schema-Map.
447
- // Cross-Feature-Redirect ist nicht supported der nav-Router
448
- // baut die URL aus screenId direkt, eine voll-QN würde als
449
- // `/shop:screen:foo/` landen und nirgendwo greifen.
450
- const candidateQn = qualifyEntityName(feature.name, "screen", screen.redirect);
552
+ // redirect ist entweder die kurze Screen-ID (same-feature, z.B.
553
+ // "item-list") oder eine voll-qualifizierte Cross-Feature-QN
554
+ // (`<feature>:screen:<id>`) der Renderer strippt letztere beim
555
+ // Navigieren auf die kurze ID (lastSegment), die der nav-Router
556
+ // app-weit auflöst (#1946).
557
+ const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
451
558
  if (!allScreenQns.has(candidateQn)) {
452
559
  throw new Error(
453
560
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect "${screen.redirect}" ` +
454
- `does not resolve to a registered screen in this feature. Known screens: ${
455
- [...Object.keys(feature.screens)].sort().join(", ") || "(none)"
456
- }.`,
561
+ `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
562
+ `in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
457
563
  );
458
564
  }
459
565
  }
460
566
  if (typeof screen.cancelTarget === "string") {
461
567
  // Gleiche Regel wie redirect — `false` (kein Cancel-Button)
462
568
  // braucht keine Validierung.
463
- const candidateQn = qualifyEntityName(feature.name, "screen", screen.cancelTarget);
569
+ const candidateQn = resolveScreenTargetQn(feature.name, screen.cancelTarget);
464
570
  if (!allScreenQns.has(candidateQn)) {
465
571
  throw new Error(
466
572
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) cancelTarget "${screen.cancelTarget}" ` +
467
- `does not resolve to a registered screen in this feature. Known screens: ${
468
- [...Object.keys(feature.screens)].sort().join(", ") || "(none)"
469
- }.`,
573
+ `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
574
+ `in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
470
575
  );
471
576
  }
472
577
  }
@@ -770,6 +875,20 @@ export function validateScreens(
770
875
  ),
771
876
  );
772
877
  }
878
+ validateNoWidgetRequiredField(feature.name, screenId, entityDef, normalized);
879
+ }
880
+ }
881
+ validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
882
+ if (screen.redirect !== undefined) {
883
+ // Same rule as actionForm's redirect: short screen-ID (same-feature)
884
+ // or a fully-qualified cross-feature QN (#1946).
885
+ const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
886
+ if (!allScreenQns.has(candidateQn)) {
887
+ throw new Error(
888
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) redirect "${screen.redirect}" ` +
889
+ `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
890
+ `in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
891
+ );
773
892
  }
774
893
  }
775
894
  }
@@ -106,6 +106,45 @@ describe("ValidationError", () => {
106
106
  expect(err.cause).toBe(result.error);
107
107
  });
108
108
 
109
+ test("custom issue with params.i18nKey overrides the mechanical errors.validation.custom key", () => {
110
+ const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
111
+ if (values.name === "") {
112
+ ctx.addIssue({
113
+ code: "custom",
114
+ path: ["name"],
115
+ message: '"name" is required.',
116
+ params: { i18nKey: "kumiko.validation.required" },
117
+ });
118
+ }
119
+ });
120
+ const result = schema.safeParse({ name: "" });
121
+ if (result.success) throw new Error("zod did not reject");
122
+
123
+ const err = validationErrorFromZod(result.error);
124
+ const fields = (err.details as { fields: Array<Record<string, unknown>> }).fields;
125
+ expect(fields[0]).toMatchObject({
126
+ code: "custom",
127
+ i18nKey: "kumiko.validation.required",
128
+ });
129
+ });
130
+
131
+ test("custom issue without params.i18nKey still falls back to errors.validation.custom", () => {
132
+ const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
133
+ if (values.name === "bad") {
134
+ ctx.addIssue({ code: "custom", path: ["name"], message: "not allowed" });
135
+ }
136
+ });
137
+ const result = schema.safeParse({ name: "bad" });
138
+ if (result.success) throw new Error("zod did not reject");
139
+
140
+ const err = validationErrorFromZod(result.error);
141
+ const fields = (err.details as { fields: Array<Record<string, unknown>> }).fields;
142
+ expect(fields[0]).toMatchObject({
143
+ code: "custom",
144
+ i18nKey: "errors.validation.custom",
145
+ });
146
+ });
147
+
109
148
  test('root-level zod issue maps to path "(root)"', () => {
110
149
  const schema = z.string();
111
150
  const result = schema.safeParse(123);
@@ -30,13 +30,30 @@ export function validationErrorFromZod(error: ZodError): ValidationError {
30
30
  return {
31
31
  path: issue.path.map(String).join(".") || "(root)",
32
32
  code: issue.code,
33
- i18nKey: `errors.validation.${issue.code}`,
33
+ i18nKey: resolveI18nKey(issue),
34
34
  ...(params && { params }),
35
35
  };
36
36
  });
37
37
  return new ValidationError({ fields }, { cause: error });
38
38
  }
39
39
 
40
+ // Every zod code maps mechanically to `errors.validation.<code>` — except
41
+ // `code: "custom"`, which is zod's one-size-fits-all bucket for every
42
+ // `superRefine`/`refine` check in the codebase (e.g. schema-builder.ts's
43
+ // totalsMatch check). Left mechanical, ALL of them would collapse onto the
44
+ // same `errors.validation.custom` ("Invalid value.") key. A `superRefine`
45
+ // that needs its own key sets `params.i18nKey` on the issue; this is the one
46
+ // place that honors it. Keep in sync with the client-side mirror
47
+ // (packages/headless/src/form/zod-bridge.ts) — a superRefine can run on
48
+ // either side.
49
+ function resolveI18nKey(issue: ZodIssue): string {
50
+ if (issue.code === "custom") {
51
+ const override = issue.params?.["i18nKey"];
52
+ if (typeof override === "string") return override;
53
+ }
54
+ return `errors.validation.${issue.code}`;
55
+ }
56
+
40
57
  function extractIssueParams(issue: ZodIssue): Readonly<Record<string, unknown>> | undefined {
41
58
  // ZodIssue is a discriminated union with variant-specific params (minimum,
42
59
  // maximum, expected, …); reading them generically requires widening since
@@ -3,16 +3,26 @@
3
3
  // the raw-SQL spike used as proof before the ES pivot.
4
4
  //
5
5
  // Targets (from docs/plans/architecture/event-sourcing-spike-1.md):
6
- // - Write-Latency p99 < 30ms (append a single event)
7
- // - Read-Latency p99 < 25ms (loadAggregate for a single aggregate)
8
- // - Update-Latency p99 < 30ms (append with predecessor-check WHERE EXISTS)
6
+ // - Write-Latency p95 < 30ms (append a single event)
7
+ // - Read-Latency p95 < 25ms (loadAggregate for a single aggregate)
8
+ // - Update-Latency p95 < 30ms (append with predecessor-check WHERE EXISTS)
9
9
  // - Snapshot-Load < 50ms (1000-event aggregate, snapshot @ 900)
10
10
  //
11
11
  // Workload is sequential against local Docker Postgres — no network
12
12
  // latency, single-node PG. Production deploys are slower; these numbers
13
- // are the ceiling. Red test = framework regression, no slack tolerated.
13
+ // are the ceiling.
14
14
  //
15
- // Isolated from bulk integration via `bun run test:integration:perf`.
15
+ // Isolated from bulk integration via `bun run test:integration:perf`. Used
16
+ // to run inside the `integration` CI job, right after the ~213-test bulk
17
+ // suite, and flaked up to 3.4x under that (30-102ms vs the 25-30ms budgets
18
+ // above, #1940). Moved to its own `event-store-perf` CI job
19
+ // (test:integration:perf:eventstore) — but re-measuring against a fresh
20
+ // container per run (mirroring that job) showed the real cause wasn't job
21
+ // contention: p50 sits at 1-3ms in every run, and single-sample p99 spikes
22
+ // to 47-73ms even fully isolated on an idle machine, from cold-Postgres
23
+ // connection/cache warm-up. Gate switched from p99 (the single worst-of-200
24
+ // sample) to p95 (drops the top 10), which absorbs that cold-start outlier
25
+ // while still catching a real order-of-magnitude regression.
16
26
 
17
27
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
28
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
@@ -60,7 +70,7 @@ async function measure<T>(op: () => Promise<T>): Promise<number> {
60
70
  }
61
71
 
62
72
  describe("event-store performance — Gate A", () => {
63
- test("write-latency p99 < 30ms over 200 sequential appends", async () => {
73
+ test("write-latency p95 < 30ms over 200 sequential appends", async () => {
64
74
  const samples: number[] = [];
65
75
 
66
76
  // Warm-up — Connection-Pool + Drizzle-Prepare-Overhead
@@ -95,13 +105,16 @@ describe("event-store performance — Gate A", () => {
95
105
 
96
106
  samples.sort((a, b) => a - b);
97
107
  const p50 = percentile(samples, 0.5);
108
+ const p95 = percentile(samples, 0.95);
98
109
  const p99 = percentile(samples, 0.99);
99
- console.log(` Write-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`);
110
+ console.log(
111
+ ` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
112
+ );
100
113
 
101
- expect(p99).toBeLessThan(30);
114
+ expect(p95).toBeLessThan(30);
102
115
  });
103
116
 
104
- test("read-latency p99 < 25ms for loadAggregate detail reads", async () => {
117
+ test("read-latency p95 < 25ms for loadAggregate detail reads", async () => {
105
118
  // Seed 200 single-event aggregates
106
119
  const ids: string[] = [];
107
120
  for (let i = 0; i < 200; i++) {
@@ -130,18 +143,18 @@ describe("event-store performance — Gate A", () => {
130
143
 
131
144
  samples.sort((a, b) => a - b);
132
145
  const p50 = percentile(samples, 0.5);
146
+ const p95 = percentile(samples, 0.95);
133
147
  const p99 = percentile(samples, 0.99);
134
148
  console.log(
135
- ` Read-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=${ids.length})`,
149
+ ` Read-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=${ids.length})`,
136
150
  );
137
151
 
138
- // 25ms statt der 10ms aus dem Spike-Doc: der shared cdgs-runner failt
139
- // lastabhängig (real gemessen 13.7ms p99) als CI-Gate zählt die
140
- // Größenordnung, nicht der Idle-Bestwert. Tracking: #325.
141
- expect(p99).toBeLessThan(25);
152
+ // 25ms budget kept from the original spike doc's 10ms an
153
+ // order-of-magnitude gate, not an idle-best-case one. Tracking: #325.
154
+ expect(p95).toBeLessThan(25);
142
155
  });
143
156
 
144
- test("update-latency p99 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
157
+ test("update-latency p95 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
145
158
  // Single aggregate, repeated updates — the INSERT … SELECT … WHERE EXISTS
146
159
  // path is heavier than a simple create and adds an index lookup.
147
160
  const aggregateId = uuid();
@@ -191,10 +204,13 @@ describe("event-store performance — Gate A", () => {
191
204
 
192
205
  samples.sort((a, b) => a - b);
193
206
  const p50 = percentile(samples, 0.5);
207
+ const p95 = percentile(samples, 0.95);
194
208
  const p99 = percentile(samples, 0.99);
195
- console.log(` Update-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`);
209
+ console.log(
210
+ ` Update-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
211
+ );
196
212
 
197
- expect(p99).toBeLessThan(30);
213
+ expect(p95).toBeLessThan(30);
198
214
  });
199
215
 
200
216
  test("snapshot-load < 50ms for 1000-event aggregate (Gate A)", async () => {
@@ -139,7 +139,7 @@ describe("upcaster error-policy: quarantine", () => {
139
139
  expect(result).toHaveLength(1);
140
140
  expect(result[0]?.id).toBe("10");
141
141
  expect(result[0]?.eventVersion).toBe(2);
142
- expect((result[0]?.payload as { migrated?: boolean }).migrated).toBe(true);
142
+ expect((result[0]!.payload as { migrated?: boolean }).migrated).toBe(true);
143
143
 
144
144
  const dl = await listDeadLetters(testDb.db);
145
145
  expect(dl).toHaveLength(1);
@@ -729,33 +729,36 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
729
729
  (pipeline: ReturnType<typeof createLifecycleHooks>) =>
730
730
  pipeline.runPostDeleteBatch([deletectx], {}),
731
731
  ],
732
- ])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
733
- const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
734
- try {
735
- const calls: string[] = [];
736
- const systemHooks = buildHooks([
737
- {
738
- name: "failing",
739
- priority: 1000,
740
- fn: async () => {
741
- throw new Error("batch-hook-boom");
732
+ ])(
733
+ "one %s hook throwing doesn't stop the others (Promise.allSettled) logged, never thrown",
734
+ async (_name, buildHooks, run) => {
735
+ const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
736
+ try {
737
+ const calls: string[] = [];
738
+ const systemHooks = buildHooks([
739
+ {
740
+ name: "failing",
741
+ priority: 1000,
742
+ fn: async () => {
743
+ throw new Error("batch-hook-boom");
744
+ },
742
745
  },
743
- },
744
- {
745
- name: "ok",
746
- priority: 1001,
747
- fn: async () => {
748
- calls.push("ok-ran");
746
+ {
747
+ name: "ok",
748
+ priority: 1001,
749
+ fn: async () => {
750
+ calls.push("ok-ran");
751
+ },
749
752
  },
750
- },
751
- ]);
752
- const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
753
- // Must not throw.
754
- await run(pipeline);
755
- expect(calls).toEqual(["ok-ran"]);
756
- expect(consoleSpy).toHaveBeenCalled();
757
- } finally {
758
- consoleSpy.mockRestore();
759
- }
760
- });
753
+ ]);
754
+ const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
755
+ // Must not throw.
756
+ await run(pipeline);
757
+ expect(calls).toEqual(["ok-ran"]);
758
+ expect(consoleSpy).toHaveBeenCalled();
759
+ } finally {
760
+ consoleSpy.mockRestore();
761
+ }
762
+ },
763
+ );
761
764
  });
@@ -297,7 +297,7 @@ describe("entity cache", () => {
297
297
 
298
298
  const single = await cache.get("00000000-0000-4000-8000-000000000001", "event", 42);
299
299
  expect(single?.["insertedAt"]).toBeInstanceOf(Date);
300
- expect((single?.["insertedAt"] as Date).getTime()).toBe(insertedAt.getTime());
300
+ expect((single!["insertedAt"] as Date).getTime()).toBe(insertedAt.getTime());
301
301
  // Non-ISO strings must not be coerced
302
302
  expect(typeof single?.["title"]).toBe("string");
303
303
  expect(single?.["note"]).toBe("not a date: 2026-04");
@@ -4,26 +4,19 @@ import { isValidIanaTimeZone } from "../iana";
4
4
  describe("isValidIanaTimeZone", () => {
5
5
  // Die 5 Zonen der geplanten CI-TZ-Matrix (timezones.md) müssen alle gültig
6
6
  // sein — sonst kann die Matrix sie nicht setzen.
7
- test.each([
8
- "UTC",
9
- "Europe/Berlin",
10
- "America/Los_Angeles",
11
- "Asia/Tokyo",
12
- "Pacific/Apia",
13
- ])("akzeptiert kanonische Zone %s", (zone) => {
14
- expect(isValidIanaTimeZone(zone)).toBe(true);
15
- });
7
+ test.each(["UTC", "Europe/Berlin", "America/Los_Angeles", "Asia/Tokyo", "Pacific/Apia"])(
8
+ "akzeptiert kanonische Zone %s",
9
+ (zone) => {
10
+ expect(isValidIanaTimeZone(zone)).toBe(true);
11
+ },
12
+ );
16
13
 
17
- test.each([
18
- "",
19
- "Mars/Phobos",
20
- "europe/berlin",
21
- "Europe/Berlin ",
22
- "GMT+2",
23
- "not-a-zone",
24
- ])("lehnt ungültigen / nicht-kanonischen String %p ab", (value) => {
25
- expect(isValidIanaTimeZone(value)).toBe(false);
26
- });
14
+ test.each(["", "Mars/Phobos", "europe/berlin", "Europe/Berlin ", "GMT+2", "not-a-zone"])(
15
+ "lehnt ungültigen / nicht-kanonischen String %p ab",
16
+ (value) => {
17
+ expect(isValidIanaTimeZone(value)).toBe(false);
18
+ },
19
+ );
27
20
 
28
21
  // Intl.supportedValuesOf("timeZone") listet nur kanonische Namen — gültige
29
22
  // IANA-Aliase fehlen darin, obwohl Intl.DateTimeFormat/Temporal/ctx.tz.parse
@@ -12,3 +12,17 @@ describe("stringifyJson — Temporal.Instant without ambient Temporal", () => {
12
12
  });
13
13
  });
14
14
  });
15
+
16
+ describe("stringifyJson — Temporal.PlainDate (kumiko-framework#1924)", () => {
17
+ test("serializes to yyyy-mm-dd via PlainDate's own toJSON(), no special-casing needed", () => {
18
+ const day = PolyfillTemporal.PlainDate.from("2026-03-15");
19
+ expect(stringifyJson({ publishedAt: day })).toBe('{"publishedAt":"2026-03-15"}');
20
+ });
21
+
22
+ test("serializes polyfill PlainDate when globalThis.Temporal is missing", async () => {
23
+ const day = PolyfillTemporal.PlainDate.from("2026-03-15");
24
+ await withoutAmbientTemporal(() => {
25
+ expect(stringifyJson({ publishedAt: day })).toBe('{"publishedAt":"2026-03-15"}');
26
+ });
27
+ });
28
+ });