@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.
- package/package.json +2 -2
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +139 -0
- package/src/db/queries/shadow-swap.ts +62 -0
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +136 -2
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/deep-link.test.ts +35 -0
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +4 -84
- package/src/engine/boot-validator/entity-handler.ts +5 -4
- package/src/engine/boot-validator/screens-nav.ts +112 -16
- package/src/engine/deep-link.ts +23 -0
- package/src/engine/define-feature.ts +3 -17
- package/src/engine/factories.ts +2 -49
- package/src/engine/feature-ast/extractors/index.ts +1 -4
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/parse.ts +1 -4
- package/src/engine/feature-ast/patch.ts +2 -5
- package/src/engine/feature-ast/patterns.ts +0 -14
- package/src/engine/feature-ast/render.ts +1 -9
- package/src/engine/index.ts +2 -1
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -3
- package/src/engine/pattern-library/library.ts +0 -19
- package/src/engine/registry.ts +6 -16
- package/src/engine/types/feature.ts +3 -33
- package/src/engine/types/fields.ts +5 -4
- package/src/engine/types/index.ts +5 -0
- package/src/engine/types/screen.ts +64 -1
- package/src/engine/types/workspace.ts +0 -7
- package/src/errors/classes.ts +1 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +0 -1
- package/src/i18n/required-surface-keys.ts +29 -10
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/job-runner.ts +41 -1
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/index.ts +1 -0
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/standard-metrics.ts +28 -0
- package/src/observability/types/span.ts +0 -6
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/time/tz-context.ts +1 -1
- package/src/ui-types/index.ts +5 -0
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/row-helpers.ts +0 -4
- 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.
|
|
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.
|
|
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(
|
|
15
|
+
function dashboardFeature(
|
|
16
|
+
panels: DashboardScreenDefinition["panels"],
|
|
17
|
+
filter?: DashboardScreenDefinition["filter"],
|
|
18
|
+
) {
|
|
16
19
|
return defineFeature("demo", (r) => {
|
|
17
|
-
r.screen({
|
|
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
|
|
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
|
|
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(/
|
|
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,
|
|
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("
|
|
67
|
-
test("
|
|
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", (
|
|
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
|
|
92
|
+
// erst spät sichtbar — siehe advisor-Verdict + Memory `[EventDef-
|
|
173
93
|
// Exports-Pattern]`.
|
|
174
94
|
// =============================================================================
|
|
175
95
|
|