@cosmicdrift/kumiko-framework 0.132.0 → 0.134.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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +139 -0
  3. package/src/db/queries/shadow-swap.ts +62 -0
  4. package/src/engine/__tests__/boot-validator-dashboard.test.ts +136 -2
  5. package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
  6. package/src/engine/__tests__/deep-link.test.ts +35 -0
  7. package/src/engine/__tests__/factories-time.test.ts +2 -66
  8. package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +4 -84
  9. package/src/engine/boot-validator/entity-handler.ts +5 -4
  10. package/src/engine/boot-validator/screens-nav.ts +112 -16
  11. package/src/engine/deep-link.ts +23 -0
  12. package/src/engine/define-feature.ts +3 -17
  13. package/src/engine/factories.ts +2 -49
  14. package/src/engine/feature-ast/extractors/index.ts +1 -4
  15. package/src/engine/feature-ast/extractors/round6.ts +2 -36
  16. package/src/engine/feature-ast/parse.ts +1 -4
  17. package/src/engine/feature-ast/patch.ts +2 -5
  18. package/src/engine/feature-ast/patterns.ts +0 -14
  19. package/src/engine/feature-ast/render.ts +1 -9
  20. package/src/engine/index.ts +2 -1
  21. package/src/engine/pattern-library/__tests__/library.test.ts +0 -3
  22. package/src/engine/pattern-library/library.ts +0 -19
  23. package/src/engine/registry.ts +6 -16
  24. package/src/engine/types/feature.ts +3 -33
  25. package/src/engine/types/fields.ts +5 -4
  26. package/src/engine/types/index.ts +5 -0
  27. package/src/engine/types/screen.ts +64 -1
  28. package/src/engine/types/workspace.ts +0 -7
  29. package/src/errors/classes.ts +1 -1
  30. package/src/errors/field-issue.ts +0 -3
  31. package/src/errors/index.ts +0 -1
  32. package/src/i18n/required-surface-keys.ts +29 -10
  33. package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
  34. package/src/jobs/job-runner.ts +41 -1
  35. package/src/observability/__tests__/recording-tracer.test.ts +6 -4
  36. package/src/observability/index.ts +1 -0
  37. package/src/observability/noop-provider.ts +0 -9
  38. package/src/observability/recording-tracer.ts +0 -12
  39. package/src/observability/standard-metrics.ts +28 -0
  40. package/src/observability/types/span.ts +0 -6
  41. package/src/pipeline/projection-rebuild.ts +7 -0
  42. package/src/time/tz-context.ts +1 -1
  43. package/src/ui-types/index.ts +5 -0
  44. package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
  45. package/src/db/row-helpers.ts +0 -4
  46. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.132.0",
3
+ "version": "0.134.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>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.132.0",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.134.0",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -0,0 +1,139 @@
1
+ // Runtime guard #722: before the rebuild swaps the shadow over the live table,
2
+ // abort if a live row has NO event in the projection's source streams — a row
3
+ // no replay can reconstruct (#498 ghost, direct-inserted without a .created
4
+ // event), which the swap would silently drop.
5
+ //
6
+ // The guard is deliberately narrow (event EXISTENCE only, not a column diff):
7
+ // the framework legitimately makes live diverge from a fresh replay in shipped
8
+ // ways (blind-index erase→NULL, sensitive-strip, archived-stream wipe, the #494
9
+ // backfill flow). Those rows all HAVE an event, so they are not ghosts and the
10
+ // guard leaves them alone — proven by the "direct column-write" test below.
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
14
+ import { asRawClient, insertOne, selectMany } from "../../db/query";
15
+ import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
16
+ import { createRegistry } from "../../engine/registry";
17
+ import { createEventsTable } from "../../event-store";
18
+ import { rebuildProjection } from "../../pipeline";
19
+ import { createProjectionStateTable } from "../../pipeline/projection-state";
20
+ import { TestUsers, unsafeCreateEntityTable } from "../../stack";
21
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
22
+ import { createEventStoreExecutor } from "../event-store-executor";
23
+ import { buildEntityTable } from "../table-builder";
24
+ import { createTenantDb, type TenantDb } from "../tenant-db";
25
+
26
+ const userEntity = createEntity({
27
+ table: "read_unreachable_users",
28
+ fields: {
29
+ email: createTextField({ required: true }),
30
+ firstName: createTextField(),
31
+ isEnabled: createBooleanField({ default: true }),
32
+ },
33
+ softDelete: true,
34
+ });
35
+
36
+ const userFeature = defineFeature("unreachabletest", (r) => {
37
+ r.entity("user", userEntity);
38
+ });
39
+
40
+ const userTable = buildEntityTable("user", userEntity);
41
+ const projectionName = "unreachabletest:projection:user-entity";
42
+ const registry = createRegistry([userFeature]);
43
+
44
+ let testDb: BunTestDb;
45
+ let tdb: TenantDb;
46
+ const adminUser = TestUsers.admin;
47
+
48
+ beforeAll(async () => {
49
+ await ensureTemporalPolyfill();
50
+ testDb = await createTestDb();
51
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
52
+ await createEventsTable(testDb.db);
53
+ await createProjectionStateTable(testDb.db);
54
+ tdb = createTenantDb(testDb.db, adminUser.tenantId);
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await testDb.cleanup();
59
+ });
60
+
61
+ beforeEach(async () => {
62
+ await asRawClient(testDb.db).unsafe(
63
+ `TRUNCATE kumiko_events, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
64
+ );
65
+ });
66
+
67
+ async function snapshotTable(): Promise<readonly Record<string, unknown>[]> {
68
+ const rows = await selectMany(
69
+ testDb.db,
70
+ userTable,
71
+ {},
72
+ { orderBy: { col: "id", direction: "asc" } },
73
+ );
74
+ return rows as readonly Record<string, unknown>[];
75
+ }
76
+
77
+ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
78
+ test("clean executor-only projection rebuilds without firing the guard", async () => {
79
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
80
+ await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
81
+ await crud.create({ email: "b@test.de", firstName: "Bob" }, adminUser, tdb);
82
+ const before = await snapshotTable();
83
+
84
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
85
+
86
+ expect(result.eventsProcessed).toBe(2);
87
+ expect(await snapshotTable()).toEqual(before);
88
+ });
89
+
90
+ test("ghost row (no backing event) → swap aborts, live untouched", async () => {
91
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
92
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
93
+ if (!a.isSuccess) throw new Error("setup failed");
94
+
95
+ // Drift: clone the event-backed row into a second id WITHOUT appending an
96
+ // event — the #498 ghost. No replay can ever reconstruct it.
97
+ const [liveRow] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
98
+ if (!liveRow) throw new Error("live row missing");
99
+ const ghostId = crypto.randomUUID();
100
+ // Deliberately bypass the EXECUTOR_ONLY brand: the whole point is to write
101
+ // the entity table WITHOUT going through the executor — the production bug
102
+ // the guard defends against.
103
+ const writable = userTable as unknown as Parameters<typeof insertOne>[1];
104
+ await insertOne(tdb, writable, { ...liveRow, id: ghostId, email: "ghost@test.de" });
105
+ expect(await snapshotTable()).toHaveLength(2);
106
+
107
+ await expect(rebuildProjection(projectionName, { db: testDb.db, registry })).rejects.toThrow(
108
+ /have no\s+event in the projection's source streams/,
109
+ );
110
+
111
+ // Swap never ran: both rows — including the ghost — survive.
112
+ const after = await snapshotTable();
113
+ expect(after).toHaveLength(2);
114
+ expect(after.map((r) => r["id"])).toContain(ghostId);
115
+ });
116
+
117
+ test("direct column-write on an event-backed row does NOT trip the guard", async () => {
118
+ // The row has a .created event, so it is not a ghost. Its column state was
119
+ // direct-written without an event (the #494 / blind-index-erase class) — the
120
+ // guard is event-existence-only and leaves it alone; the replay overwrites
121
+ // the column from the event, which is the intended, shipped behavior.
122
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
123
+ const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
124
+ if (!a.isSuccess) throw new Error("setup failed");
125
+
126
+ await asRawClient(testDb.db).unsafe(
127
+ `UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
128
+ [a.data.id],
129
+ );
130
+
131
+ // No throw — the row is event-backed. Rebuild replays the create event.
132
+ await rebuildProjection(projectionName, { db: testDb.db, registry });
133
+
134
+ const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
135
+ expect(rebuilt?.["email"]).toBe("a@test.de");
136
+ // The direct write is overwritten by the replay — deliberately allowed.
137
+ expect(rebuilt?.["firstName"]).toBe("Alice");
138
+ });
139
+ });
@@ -149,6 +149,68 @@ export async function fenceLiveTable(
149
149
  await raw.unsafe(`LOCK TABLE public.${quoteTableIdent(tableName)} IN ACCESS EXCLUSIVE MODE`);
150
150
  }
151
151
 
152
+ // Ids reported when the swap is aborted — enough to locate the ghost rows
153
+ // without dumping an unbounded set into the log.
154
+ const UNREACHABLE_SAMPLE_LIMIT = 20;
155
+
156
+ // Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
157
+ // row whose aggregate id has NO event in the projection's source streams is
158
+ // UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
159
+ // drop it. That is the #498 ghost — a row direct-inserted without ever emitting
160
+ // a .created event. The static CI guard cannot see it in data that already
161
+ // exists in production, or on table identifiers it couldn't resolve; this
162
+ // catches it at cutover and aborts (tx rolls back, live untouched).
163
+ //
164
+ // Deliberately NARROW — event EXISTENCE only, not a column or row-vs-shadow
165
+ // diff. The framework legitimately makes live diverge from a fresh replay in
166
+ // several SHIPPED ways, none of which is drift:
167
+ // - a blind-index column recomputed to NULL after the subject's key is
168
+ // shredded (GDPR erase) — the NULL is the intended end state;
169
+ // - a `sensitive` column stripped from the event log by design;
170
+ // - an archived stream that stops replaying (fw#832) — the row's wipe is the
171
+ // intended tombstone behavior, reported via backfill's `failed` list;
172
+ // - a legacy column direct-written before its handler emitted events, healed
173
+ // by the #494 backfill-then-rebuild flow.
174
+ // Checking event existence INCLUDING archived streams leaves every one of them
175
+ // alone: those rows all have a real event, so they are not ghosts. Column-level
176
+ // drift detection (fail-hard vs. graceful repair) is the open question deferred
177
+ // from #722.
178
+ //
179
+ // Implicit projections only (caller-gated). aggregate_id and the entity id are
180
+ // both uuid, so the anti-join probes the events index without a cast.
181
+ export async function assertNoUnreachableLiveRows(
182
+ tx: AnyDb,
183
+ projectionName: string,
184
+ tableName: string,
185
+ aggregateTypes: readonly string[],
186
+ ): Promise<void> {
187
+ // skip: no source streams → no events could back any row anyway; a rebuild
188
+ // of a subscription-less projection swaps an empty shadow (handled upstream).
189
+ if (aggregateTypes.length === 0) return;
190
+ const raw = asRawClient(tx);
191
+ const t = quoteTableIdent(tableName);
192
+ const ghosts = await raw.unsafe<{ id: unknown }>(
193
+ `SELECT l."id" FROM public.${t} l
194
+ WHERE NOT EXISTS (
195
+ SELECT 1 FROM "kumiko_events" e
196
+ WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
197
+ )
198
+ LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`,
199
+ [aggregateTypes],
200
+ );
201
+ // skip: every live row has a backing event — nothing unreachable, swap is safe
202
+ if (ghosts.length === 0) return;
203
+ const ids = ghosts.map((r) => String(r.id));
204
+ throw new Error(
205
+ `projection-rebuild "${projectionName}": ${ids.length}+ live rows in "${tableName}" have no ` +
206
+ `event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
207
+ `would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
208
+ `without emitting a .created event. Fix: register the table with r.unmanagedTable(meta, ` +
209
+ `{ reason }) to opt out of rebuild, or emit the missing events. See ` +
210
+ `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
211
+ );
212
+ }
213
+
152
214
  // Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
153
215
  // active shadow search_path can't redirect them. DROP without CASCADE: if any
154
216
  // object depends on the live table the swap fails loud and the whole rebuild
@@ -12,15 +12,25 @@ const STAT_PANEL = {
12
12
  valueField: "count",
13
13
  } as const;
14
14
 
15
- function dashboardFeature(panels: DashboardScreenDefinition["panels"]) {
15
+ function dashboardFeature(
16
+ panels: DashboardScreenDefinition["panels"],
17
+ filter?: DashboardScreenDefinition["filter"],
18
+ ) {
16
19
  return defineFeature("demo", (r) => {
17
- r.screen({ id: "overview", type: "dashboard", panels });
20
+ r.screen({
21
+ id: "overview",
22
+ type: "dashboard",
23
+ panels,
24
+ ...(filter !== undefined && { filter }),
25
+ });
18
26
  r.translations({
19
27
  keys: {
20
28
  "screen:overview.title": { de: "Übersicht", en: "Overview" },
21
29
  "demo:dashboard:panel:open-incidents": { de: "Offene Vorfälle", en: "Open incidents" },
22
30
  "demo:dashboard:panel:latest": { de: "Neueste", en: "Latest" },
23
31
  "demo:dashboard:col:name": { de: "Name", en: "Name" },
32
+ "demo:dashboard:group:net-worth": { de: "Net Worth", en: "Net Worth" },
33
+ "demo:dashboard:filter:region": { de: "Region", en: "Region" },
24
34
  },
25
35
  });
26
36
  });
@@ -90,4 +100,128 @@ describe("validateBoot — dashboard screens", () => {
90
100
  expect(keys).toContain("demo:dashboard:panel:latest");
91
101
  expect(keys).toContain("demo:dashboard:col:name");
92
102
  });
103
+
104
+ test("accepts a stat-group, feed, progress-list and custom panel", () => {
105
+ const feature = dashboardFeature(
106
+ [
107
+ {
108
+ kind: "stat-group",
109
+ id: "net-worth",
110
+ label: "demo:dashboard:group:net-worth",
111
+ stats: [STAT_PANEL],
112
+ },
113
+ {
114
+ kind: "feed",
115
+ id: "upcoming",
116
+ label: "demo:dashboard:panel:latest",
117
+ query: "demo:query:incident:latest",
118
+ },
119
+ {
120
+ kind: "progress-list",
121
+ id: "progress",
122
+ label: "demo:dashboard:panel:latest",
123
+ query: "demo:query:incident:latest",
124
+ },
125
+ {
126
+ kind: "custom",
127
+ id: "custom-panel",
128
+ component: { react: { __component: "demo-custom" } },
129
+ },
130
+ ],
131
+ {
132
+ id: "region",
133
+ label: "demo:dashboard:filter:region",
134
+ kind: "select",
135
+ options: [{ value: "eu", label: "demo:dashboard:filter:region" }],
136
+ },
137
+ );
138
+ expect(() => validateBoot([feature])).not.toThrow();
139
+ });
140
+
141
+ test("rejects a stat-group with an empty stats list", () => {
142
+ const feature = dashboardFeature([
143
+ { kind: "stat-group", id: "net-worth", label: "demo:dashboard:group:net-worth", stats: [] },
144
+ ]);
145
+ expect(() => validateBoot([feature])).toThrow(/empty stats list/);
146
+ });
147
+
148
+ test("rejects a duplicate id nested inside a stat-group", () => {
149
+ const feature = dashboardFeature([
150
+ STAT_PANEL,
151
+ {
152
+ kind: "stat-group",
153
+ id: "net-worth",
154
+ label: "demo:dashboard:group:net-worth",
155
+ stats: [STAT_PANEL],
156
+ },
157
+ ]);
158
+ expect(() => validateBoot([feature])).toThrow(/duplicate panel id/);
159
+ });
160
+
161
+ test("rejects a custom panel without a react/native component", () => {
162
+ const feature = dashboardFeature([{ kind: "custom", id: "custom-panel", component: {} }]);
163
+ expect(() => validateBoot([feature])).toThrow(/has no component/);
164
+ });
165
+
166
+ test("rejects a filter that sets neither options nor optionsQuery", () => {
167
+ const feature = dashboardFeature([STAT_PANEL], {
168
+ id: "region",
169
+ label: "demo:dashboard:filter:region",
170
+ kind: "select",
171
+ });
172
+ expect(() => validateBoot([feature])).toThrow(/exactly one of/);
173
+ });
174
+
175
+ test("rejects a filter that sets both options and optionsQuery", () => {
176
+ const feature = dashboardFeature([STAT_PANEL], {
177
+ id: "region",
178
+ label: "demo:dashboard:filter:region",
179
+ kind: "select",
180
+ options: [{ value: "eu", label: "demo:dashboard:filter:region" }],
181
+ optionsQuery: "demo:query:folder:list",
182
+ });
183
+ expect(() => validateBoot([feature])).toThrow(/exactly one of/);
184
+ });
185
+
186
+ test("rejects a filter with an empty options list", () => {
187
+ const feature = dashboardFeature([STAT_PANEL], {
188
+ id: "region",
189
+ label: "demo:dashboard:filter:region",
190
+ kind: "select",
191
+ options: [],
192
+ });
193
+ expect(() => validateBoot([feature])).toThrow(/filter.options is empty/);
194
+ });
195
+
196
+ test("requiredKeysFromScreen sammelt stat-group-Kinder- und Filter-Labels, aber keine custom-Panel-Keys", () => {
197
+ const screen: DashboardScreenDefinition = {
198
+ id: "overview",
199
+ type: "dashboard",
200
+ filter: {
201
+ id: "region",
202
+ label: "demo:dashboard:filter:region",
203
+ kind: "select",
204
+ options: [{ value: "eu", label: "demo:dashboard:col:name" }],
205
+ },
206
+ panels: [
207
+ {
208
+ kind: "stat-group",
209
+ id: "net-worth",
210
+ label: "demo:dashboard:group:net-worth",
211
+ stats: [STAT_PANEL],
212
+ },
213
+ {
214
+ kind: "custom",
215
+ id: "custom-panel",
216
+ component: { react: { __component: "demo-custom" } },
217
+ },
218
+ ],
219
+ };
220
+ const keys = requiredKeysFromScreen("demo", screen);
221
+ expect(keys).toContain("demo:dashboard:group:net-worth");
222
+ expect(keys).toContain("demo:dashboard:panel:open-incidents");
223
+ expect(keys).toContain("demo:dashboard:filter:region");
224
+ expect(keys).toContain("demo:dashboard:col:name");
225
+ expect(keys).not.toContain("custom-panel");
226
+ });
93
227
  });
@@ -7,25 +7,9 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { validateBoot } from "../boot-validator";
9
9
  import { defineFeature } from "../define-feature";
10
- import { createEntity, createTimestampField, createTzField, locatedTimestamp } from "../factories";
10
+ import { createEntity, createTimestampField, createTzField } from "../factories";
11
11
 
12
12
  describe("validateBoot — locatedBy markers", () => {
13
- test("locatedTimestamp(name) Helper-Pair passiert validiert (positive case)", () => {
14
- const feature = defineFeature("test", (r) => {
15
- r.entity(
16
- "order",
17
- createEntity({
18
- fields: {
19
- ...locatedTimestamp("pickup"),
20
- ...locatedTimestamp("delivery"),
21
- },
22
- }),
23
- );
24
- });
25
-
26
- expect(() => validateBoot([feature])).not.toThrow();
27
- });
28
-
29
13
  test("manuelle Konstruktion mit korrektem Pair passiert (positive case)", () => {
30
14
  const feature = defineFeature("test", (r) => {
31
15
  r.entity(
@@ -75,7 +59,7 @@ describe("validateBoot — locatedBy markers", () => {
75
59
  expect(() => validateBoot([feature])).toThrow(/expected "tz"/);
76
60
  });
77
61
 
78
- test("Fehlermeldung verweist auf locatedTimestamp-Helper als Fix", () => {
62
+ test("Fehlermeldung verweist auf createLocatedTimestampField als Fix", () => {
79
63
  const feature = defineFeature("test", (r) => {
80
64
  r.entity(
81
65
  "order",
@@ -87,7 +71,7 @@ describe("validateBoot — locatedBy markers", () => {
87
71
  );
88
72
  });
89
73
 
90
- expect(() => validateBoot([feature])).toThrow(/locatedTimestamp\("pickup"\)/);
74
+ expect(() => validateBoot([feature])).toThrow(/createLocatedTimestampField/);
91
75
  });
92
76
 
93
77
  test("Timestamp ohne locatedBy ist OK (reiner UTC-Instant)", () => {
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildDeepLinkUrl } from "../deep-link";
3
+
4
+ describe("buildDeepLinkUrl()", () => {
5
+ test("screenId only", () => {
6
+ expect(buildDeepLinkUrl("https://app.example.com", { screenId: "jobs" })).toBe(
7
+ "https://app.example.com/jobs",
8
+ );
9
+ });
10
+
11
+ test("screenId + entityId", () => {
12
+ expect(
13
+ buildDeepLinkUrl("https://app.example.com", { screenId: "jobs", entityId: "job-1" }),
14
+ ).toBe("https://app.example.com/jobs/job-1");
15
+ });
16
+
17
+ test("workspaceId + screenId + entityId, in path order", () => {
18
+ expect(
19
+ buildDeepLinkUrl("https://app.example.com", {
20
+ workspaceId: "admin",
21
+ screenId: "jobs",
22
+ entityId: "job-1",
23
+ }),
24
+ ).toBe("https://app.example.com/admin/jobs/job-1");
25
+ });
26
+
27
+ test("strips trailing slash(es) from baseUrl", () => {
28
+ expect(buildDeepLinkUrl("https://app.example.com/", { screenId: "jobs" })).toBe(
29
+ "https://app.example.com/jobs",
30
+ );
31
+ expect(buildDeepLinkUrl("https://app.example.com//", { screenId: "jobs" })).toBe(
32
+ "https://app.example.com/jobs",
33
+ );
34
+ });
35
+ });
@@ -1,17 +1,12 @@
1
1
  // Unit-Tests für die neuen Time-Field-Factories
2
- // (createTimestampField, createTzField, locatedTimestamp).
2
+ // (createTimestampField, createTzField, createLocatedTimestampField).
3
3
  //
4
4
  // Test-Fokus: korrektes Field-Shape + locatedBy-Marker-Verdrahtung. Die
5
5
  // echte TZ-Konvertierung (Wall-Clock ↔ UTC) testen wir später beim
6
6
  // DB-Wrapper-Schritt.
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import {
10
- createLocatedTimestampField,
11
- createTimestampField,
12
- createTzField,
13
- locatedTimestamp,
14
- } from "../factories";
9
+ import { createLocatedTimestampField, createTimestampField, createTzField } from "../factories";
15
10
 
16
11
  describe("createTimestampField", () => {
17
12
  test("default-Form ist nicht-required UTC-Instant ohne locatedBy", () => {
@@ -57,65 +52,6 @@ describe("createTzField", () => {
57
52
  });
58
53
  });
59
54
 
60
- describe("locatedTimestamp(name) Helper", () => {
61
- test("erzeugt korrektes Pair aus <name>At + <name>Tz mit locatedBy-Verdrahtung", () => {
62
- const fields = locatedTimestamp("pickup");
63
- expect(fields).toEqual({
64
- pickupAt: { type: "timestamp", locatedBy: "pickupTz" },
65
- pickupTz: { type: "tz" },
66
- });
67
- });
68
-
69
- test("required-Override propagiert auf BEIDE Felder", () => {
70
- const fields = locatedTimestamp("delivery", { required: true });
71
- expect(fields).toEqual({
72
- deliveryAt: { type: "timestamp", locatedBy: "deliveryTz", required: true },
73
- deliveryTz: { type: "tz", required: true },
74
- });
75
- });
76
-
77
- test("access-Override propagiert auf BEIDE Felder (Field-Level Read-Access)", () => {
78
- const fields = locatedTimestamp("internal", {
79
- access: { read: ["Dispatcher"] },
80
- });
81
- expect(fields).toEqual({
82
- internalAt: {
83
- type: "timestamp",
84
- locatedBy: "internalTz",
85
- access: { read: ["Dispatcher"] },
86
- },
87
- internalTz: { type: "tz", access: { read: ["Dispatcher"] } },
88
- });
89
- });
90
-
91
- test("locatedBy-Marker zeigt immer auf das EIGENE Tz-Feld (nicht auf einen anderen Namen)", () => {
92
- // Das ist der Kern des Patterns — wenn die zwei Felder nicht
93
- // konsistent verdrahtet sind, fliegt der Boot-Validator (kommt in
94
- // späterer Iteration). Hier prüfen wir die Helper-Garantie.
95
- for (const name of ["a", "x_y", "long_field_name"]) {
96
- const fields = locatedTimestamp(name);
97
- const at = fields[`${name}At`];
98
- if (at?.type !== "timestamp") throw new Error("at field missing");
99
- expect(at.locatedBy).toBe(`${name}Tz`);
100
- }
101
- });
102
-
103
- test("Spread in createEntity-fields Kompositions-tauglich", () => {
104
- // Realer Use-Case: pickup + delivery in einer Entity, plus normale Felder.
105
- const entityFields = {
106
- ...locatedTimestamp("pickup"),
107
- ...locatedTimestamp("delivery"),
108
- // Kein Konflikt zwischen den beiden Pairs.
109
- };
110
- expect(Object.keys(entityFields).sort()).toEqual([
111
- "deliveryAt",
112
- "deliveryTz",
113
- "pickupAt",
114
- "pickupTz",
115
- ]);
116
- });
117
- });
118
-
119
55
  describe("createLocatedTimestampField (Phase A — atomarer Field-Type)", () => {
120
56
  test("default-Form ist nicht-required mit type 'locatedTimestamp'", () => {
121
57
  expect(createLocatedTimestampField()).toEqual({
@@ -1,15 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { buildTarget, createRegistry, defineFeature } from "../index";
3
- import type { TreeChildrenSubscribe, TreeNode } from "../types/tree-node";
4
-
5
- // Stub-Provider für Tests. Form: (ctx) => (emit) => unsubscribe.
6
- // Emittet einmal initial, kein Cleanup nötig (no-op unsubscribe).
7
- function makeStubProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
8
- return () => (emit) => {
9
- emit(nodes);
10
- return () => {};
11
- };
12
- }
13
3
 
14
4
  describe("r.treeActions — registrar slot", () => {
15
5
  test("feature without r.treeActions leaves the slot undefined", () => {
@@ -63,80 +53,12 @@ describe("r.treeActions — registrar slot", () => {
63
53
  });
64
54
  });
65
55
 
66
- describe("r.tree — registrar slot", () => {
67
- test("feature without r.tree leaves the slot undefined", () => {
68
- const feature = defineFeature("empty", () => {});
69
- expect(feature.treeProvider).toBeUndefined();
70
- });
71
-
72
- test("single r.tree call stores the provider function on the FeatureDefinition", () => {
73
- const provider = makeStubProvider([{ label: "Marketing" }]);
74
- const feature = defineFeature("text-content", (r) => {
75
- r.tree(provider);
76
- });
77
- expect(feature.treeProvider).toBe(provider);
78
- });
79
-
80
- test("second r.tree call throws — only-once-guard", () => {
81
- expect(() =>
82
- defineFeature("dupe", (r) => {
83
- r.tree(makeStubProvider([]));
84
- r.tree(makeStubProvider([]));
85
- }),
86
- ).toThrow(/r\.tree\(\) already called/);
87
- });
88
-
89
- test("treeActions and tree are independent slots — can declare one without the other", () => {
90
- const onlyActions = defineFeature("a", (r) => {
91
- r.treeActions({ list: {} });
92
- });
93
- expect(onlyActions.treeActions).toBeDefined();
94
- expect(onlyActions.treeProvider).toBeUndefined();
95
-
96
- const onlyProvider = defineFeature("b", (r) => {
97
- r.tree(makeStubProvider([]));
98
- });
99
- expect(onlyProvider.treeActions).toBeUndefined();
100
- expect(onlyProvider.treeProvider).toBeDefined();
101
- });
102
- });
103
-
104
- describe("Registry.getTreeProviders + getTreeActions", () => {
105
- test("empty registry returns empty providers map and undefined actions", () => {
56
+ describe("Registry.getTreeActions", () => {
57
+ test("empty registry returns undefined actions", () => {
106
58
  const reg = createRegistry([]);
107
- expect(reg.getTreeProviders().size).toBe(0);
108
59
  expect(reg.getTreeActions("nonexistent")).toBeUndefined();
109
60
  });
110
61
 
111
- test("aggregates providers from multiple features keyed by feature name", () => {
112
- const providerA = makeStubProvider([{ label: "A-root" }]);
113
- const providerB = makeStubProvider([{ label: "B-root" }]);
114
- const featureA = defineFeature("text-content", (r) => {
115
- r.tree(providerA);
116
- });
117
- const featureB = defineFeature("legal-pages", (r) => {
118
- r.tree(providerB);
119
- });
120
- const reg = createRegistry([featureA, featureB]);
121
-
122
- const providers = reg.getTreeProviders();
123
- expect(providers.size).toBe(2);
124
- expect(providers.get("text-content")).toBe(providerA);
125
- expect(providers.get("legal-pages")).toBe(providerB);
126
- });
127
-
128
- test("features without r.tree are absent from the providers map (Zero-Whitelist-Filter)", () => {
129
- const featureWithProvider = defineFeature("text-content", (r) => {
130
- r.tree(makeStubProvider([]));
131
- });
132
- const featureWithoutProvider = defineFeature("schema-editor", () => {});
133
- const reg = createRegistry([featureWithProvider, featureWithoutProvider]);
134
-
135
- const providers = reg.getTreeProviders();
136
- expect(providers.has("text-content")).toBe(true);
137
- expect(providers.has("schema-editor")).toBe(false);
138
- });
139
-
140
62
  test("getTreeActions returns the erased map for a feature that declared r.treeActions", () => {
141
63
  const feature = defineFeature("text-content", (r) => {
142
64
  r.treeActions({
@@ -153,9 +75,7 @@ describe("Registry.getTreeProviders + getTreeActions", () => {
153
75
  });
154
76
 
155
77
  test("getTreeActions returns undefined for a feature without r.treeActions", () => {
156
- const feature = defineFeature("no-actions", (r) => {
157
- r.tree(makeStubProvider([]));
158
- });
78
+ const feature = defineFeature("no-actions", () => {});
159
79
  const reg = createRegistry([feature]);
160
80
 
161
81
  expect(reg.getTreeActions("no-actions")).toBeUndefined();
@@ -169,7 +89,7 @@ describe("Registry.getTreeProviders + getTreeActions", () => {
169
89
  // returnt einen typed Handle, der via setup-export durch FeatureDefinition
170
90
  // fließt und compile-time-typisiert vom Schicht-1-buildTarget konsumiert
171
91
  // wird. Ohne diese Tests würde ein Type-Drift in einer der zwei Schichten
172
- // erst in V.1.1 sichtbar — siehe advisor-Verdict + Memory `[EventDef-
92
+ // erst spät sichtbar — siehe advisor-Verdict + Memory `[EventDef-
173
93
  // Exports-Pattern]`.
174
94
  // =============================================================================
175
95