@cosmicdrift/kumiko-framework 0.105.2 → 0.109.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/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- package/src/utils/serialization.ts +10 -3
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// r.extendEntityProjection — registrar/registry contracts (#759). The
|
|
2
|
+
// runtime rebuild-replay behaviour is proven in
|
|
3
|
+
// custom-fields/__tests__/custom-fields.integration.test.ts.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { defineFeature } from "../define-feature";
|
|
8
|
+
import { createEntity, createTextField } from "../factories";
|
|
9
|
+
import { createRegistry } from "../registry";
|
|
10
|
+
|
|
11
|
+
function exampleEntity(name = "unit") {
|
|
12
|
+
return createEntity({
|
|
13
|
+
table: name,
|
|
14
|
+
fields: { name: createTextField() },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const noopApply = async (): Promise<void> => {};
|
|
19
|
+
|
|
20
|
+
describe("r.extendEntityProjection — registration", () => {
|
|
21
|
+
test("merges apply keys + extraSources into the implicit projection", () => {
|
|
22
|
+
const feature = defineFeature("test", (r) => {
|
|
23
|
+
r.entity("unit", exampleEntity());
|
|
24
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
|
|
25
|
+
r.extendEntityProjection("unit", {
|
|
26
|
+
sources: ["field-definition"],
|
|
27
|
+
apply: { [setEvent.name]: noopApply },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
const registry = createRegistry([feature]);
|
|
31
|
+
const projection = registry.getAllProjections().get("test:projection:unit-entity");
|
|
32
|
+
expect(projection).toBeDefined();
|
|
33
|
+
expect(projection?.apply["test:event:custom-field-set"]).toBe(noopApply);
|
|
34
|
+
expect(projection?.apply["unit.created"]).toBeDefined();
|
|
35
|
+
expect(projection?.extraSources).toEqual(["field-definition"]);
|
|
36
|
+
expect(projection?.source).toBe("unit");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("extension source equal to the entity name is not duplicated into extraSources", () => {
|
|
40
|
+
const feature = defineFeature("test", (r) => {
|
|
41
|
+
r.entity("unit", exampleEntity());
|
|
42
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
|
|
43
|
+
r.extendEntityProjection("unit", {
|
|
44
|
+
sources: ["unit"],
|
|
45
|
+
apply: { [setEvent.name]: noopApply },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
const registry = createRegistry([feature]);
|
|
49
|
+
const projection = registry.getAllProjections().get("test:projection:unit-entity");
|
|
50
|
+
expect(projection?.extraSources).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("auto-verb of a registered extraSources entity is a valid apply-key", () => {
|
|
54
|
+
const feature = defineFeature("test", (r) => {
|
|
55
|
+
r.entity("unit", exampleEntity());
|
|
56
|
+
r.entity("field-definition", exampleEntity("field_definitions"));
|
|
57
|
+
r.extendEntityProjection("unit", {
|
|
58
|
+
sources: ["field-definition"],
|
|
59
|
+
apply: { "field-definition.deleted": noopApply },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
const registry = createRegistry([feature]);
|
|
63
|
+
const projection = registry.getAllProjections().get("test:projection:unit-entity");
|
|
64
|
+
expect(projection?.apply["field-definition.deleted"]).toBe(noopApply);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("registration order is free: extension before r.entity in the same feature", () => {
|
|
68
|
+
const feature = defineFeature("test", (r) => {
|
|
69
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
|
|
70
|
+
r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
|
|
71
|
+
r.entity("unit", exampleEntity());
|
|
72
|
+
});
|
|
73
|
+
const registry = createRegistry([feature]);
|
|
74
|
+
const projection = registry.getAllProjections().get("test:projection:unit-entity");
|
|
75
|
+
expect(projection?.apply["test:event:custom-field-set"]).toBe(noopApply);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("empty apply throws at registration time", () => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
defineFeature("test", (r) => {
|
|
81
|
+
r.entity("unit", exampleEntity());
|
|
82
|
+
r.extendEntityProjection("unit", { apply: {} });
|
|
83
|
+
}),
|
|
84
|
+
).toThrow(/no apply handlers/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("r.extendEntityProjection — registry-build validation", () => {
|
|
89
|
+
test("unknown entity fails at registry build", () => {
|
|
90
|
+
const feature = defineFeature("test", (r) => {
|
|
91
|
+
r.entity("unit", exampleEntity());
|
|
92
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
|
|
93
|
+
r.extendEntityProjection("typo-entity", { apply: { [setEvent.name]: noopApply } });
|
|
94
|
+
});
|
|
95
|
+
expect(() => createRegistry([feature])).toThrow(/no r\.entity/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("apply-key collision with a lifecycle apply fails at registry build", () => {
|
|
99
|
+
const feature = defineFeature("test", (r) => {
|
|
100
|
+
r.entity("unit", exampleEntity());
|
|
101
|
+
r.extendEntityProjection("unit", { apply: { "unit.created": noopApply } });
|
|
102
|
+
});
|
|
103
|
+
expect(() => createRegistry([feature])).toThrow(/collides/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("apply-key collision between two extensions fails at registry build", () => {
|
|
107
|
+
const feature = defineFeature("test", (r) => {
|
|
108
|
+
r.entity("unit", exampleEntity());
|
|
109
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
|
|
110
|
+
r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
|
|
111
|
+
r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
|
|
112
|
+
});
|
|
113
|
+
expect(() => createRegistry([feature])).toThrow(/collides/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("unknown apply-key (neither auto-verb nor domain event) fails at registry build", () => {
|
|
117
|
+
const feature = defineFeature("test", (r) => {
|
|
118
|
+
r.entity("unit", exampleEntity());
|
|
119
|
+
r.extendEntityProjection("unit", { apply: { "customField.set": noopApply } });
|
|
120
|
+
});
|
|
121
|
+
expect(() => createRegistry([feature])).toThrow(/no such event/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -50,7 +50,7 @@ describe("setFields", () => {
|
|
|
50
50
|
test("returns an apply fn that UPDATEs the passed fields WHERE id = aggregateId", async () => {
|
|
51
51
|
const apply = setFields(fakeTable, { status: "sent" });
|
|
52
52
|
const { fakeTx, unsafe } = makeFakeTx();
|
|
53
|
-
await apply(makeFakeEvent(), fakeTx);
|
|
53
|
+
await apply(makeFakeEvent(), fakeTx, fakeTable);
|
|
54
54
|
expect(unsafe).toHaveBeenCalledTimes(1);
|
|
55
55
|
const [sqlText, params] = unsafe.mock.calls[0]!;
|
|
56
56
|
expect(sqlText).toMatch(/UPDATE "fake_table" SET "status" = \$1.*WHERE "id" = \$2/);
|
|
@@ -62,7 +62,7 @@ describe("setFields", () => {
|
|
|
62
62
|
status: (event.payload as { newStatus: string }).newStatus,
|
|
63
63
|
}));
|
|
64
64
|
const { fakeTx, unsafe } = makeFakeTx();
|
|
65
|
-
await apply(makeFakeEvent({ payload: { newStatus: "cancelled" } }), fakeTx);
|
|
65
|
+
await apply(makeFakeEvent({ payload: { newStatus: "cancelled" } }), fakeTx, fakeTable);
|
|
66
66
|
expect(unsafe).toHaveBeenCalledTimes(1);
|
|
67
67
|
const [, params] = unsafe.mock.calls[0]!;
|
|
68
68
|
expect(params).toEqual(["cancelled", "agg-42"]);
|
|
@@ -4,8 +4,10 @@ import { createRegistry } from "../registry";
|
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_GRACE_DAYS,
|
|
6
6
|
SOFT_DELETE_CLEANUP_JOB,
|
|
7
|
+
SOFT_DELETE_CLEANUP_SYSTEM_JOB,
|
|
7
8
|
SOFT_DELETE_GRACE_DAYS_KEY,
|
|
8
9
|
softDeleteCleanupJob,
|
|
10
|
+
softDeleteCleanupSystemJob,
|
|
9
11
|
} from "../soft-delete-cleanup";
|
|
10
12
|
import type { AppContext } from "../types/handlers";
|
|
11
13
|
|
|
@@ -31,9 +33,17 @@ describe("registry soft-delete auto-wiring", () => {
|
|
|
31
33
|
expect(key?.default).toBe(DEFAULT_GRACE_DAYS);
|
|
32
34
|
});
|
|
33
35
|
|
|
36
|
+
test("also injects the system-scope cleanup job (not perTenant)", () => {
|
|
37
|
+
const registry = createRegistry([featureWith(true)]);
|
|
38
|
+
expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)).toBe(true);
|
|
39
|
+
const job = registry.getJob(SOFT_DELETE_CLEANUP_SYSTEM_JOB);
|
|
40
|
+
expect(job?.perTenant).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
34
43
|
test("does NOT inject when no entity uses softDelete", () => {
|
|
35
44
|
const registry = createRegistry([featureWith(false)]);
|
|
36
45
|
expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_JOB)).toBe(false);
|
|
46
|
+
expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)).toBe(false);
|
|
37
47
|
expect(registry.getAllConfigKeys().has(SOFT_DELETE_GRACE_DAYS_KEY)).toBe(false);
|
|
38
48
|
});
|
|
39
49
|
});
|
|
@@ -87,22 +97,21 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext
|
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
describe("softDeleteCleanupJob handler", () => {
|
|
90
|
-
test("hard-deletes only softDelete implicit projections
|
|
100
|
+
test("hard-deletes only tenant-scoped softDelete implicit projections; system-global entities are skipped", async () => {
|
|
101
|
+
// Regression (565/1): sysThing (no tenantId column) must NOT be touched
|
|
102
|
+
// here — this handler is perTenant-fanned-out, so sweeping a system-
|
|
103
|
+
// global entity with THIS tenant's grace value would purge it using
|
|
104
|
+
// whichever tenant has the shortest grace period, for every tenant.
|
|
105
|
+
// softDeleteCleanupSystemJob (below) owns sysThing instead.
|
|
91
106
|
const calls: DeleteCall[] = [];
|
|
92
107
|
await softDeleteCleanupJob({}, makeCtx({ calls }));
|
|
93
108
|
|
|
94
|
-
// thing
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(
|
|
100
|
-
expect(systemScoped).toHaveLength(1);
|
|
101
|
-
|
|
102
|
-
for (const c of calls) {
|
|
103
|
-
expect(c.where["isDeleted"]).toBe(true);
|
|
104
|
-
expect(c.where["deletedAt"]).toBeDefined();
|
|
105
|
-
}
|
|
109
|
+
// thing deleted; sysThing (no tenantId), auditEntry (softDelete:false)
|
|
110
|
+
// and custom (explicit) all skipped.
|
|
111
|
+
expect(calls).toHaveLength(1);
|
|
112
|
+
expect(calls[0]?.where["tenantId"]).toBe("t1");
|
|
113
|
+
expect(calls[0]?.where["isDeleted"]).toBe(true);
|
|
114
|
+
expect(calls[0]?.where["deletedAt"]).toBeDefined();
|
|
106
115
|
});
|
|
107
116
|
|
|
108
117
|
test("cutoff defaults to DEFAULT_GRACE_DAYS when no config resolver", async () => {
|
|
@@ -127,3 +136,30 @@ describe("softDeleteCleanupJob handler", () => {
|
|
|
127
136
|
);
|
|
128
137
|
});
|
|
129
138
|
});
|
|
139
|
+
|
|
140
|
+
describe("softDeleteCleanupSystemJob handler", () => {
|
|
141
|
+
test("hard-deletes only system-global (no tenantId) softDelete implicit projections", async () => {
|
|
142
|
+
const calls: DeleteCall[] = [];
|
|
143
|
+
await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
|
|
144
|
+
|
|
145
|
+
// sysThing deleted; thing (has tenantId), auditEntry (softDelete:false)
|
|
146
|
+
// and custom (explicit) all skipped.
|
|
147
|
+
expect(calls).toHaveLength(1);
|
|
148
|
+
expect(calls[0]?.where["tenantId"]).toBeUndefined();
|
|
149
|
+
expect(calls[0]?.where["isDeleted"]).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("cutoff is DEFAULT_GRACE_DAYS — no per-tenant config to read", async () => {
|
|
153
|
+
const calls: DeleteCall[] = [];
|
|
154
|
+
await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
|
|
155
|
+
const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
|
|
156
|
+
const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
|
|
157
|
+
expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("throws when the job context is missing db/registry", async () => {
|
|
161
|
+
await expect(softDeleteCleanupSystemJob({}, {} as AppContext)).rejects.toThrow(
|
|
162
|
+
/ctx.db \+ ctx.registry/,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -270,6 +270,20 @@ export function validateEntityIndexes(feature: FeatureDefinition): void {
|
|
|
270
270
|
`buildEntityTable always creates one automatically. Remove this entry.`,
|
|
271
271
|
);
|
|
272
272
|
}
|
|
273
|
+
// AES-GCM's random-IV ciphertext is never equal to itself across two
|
|
274
|
+
// writes of the same plaintext, so a unique index on an encrypted
|
|
275
|
+
// column can never detect a real duplicate.
|
|
276
|
+
if (def.unique) {
|
|
277
|
+
for (const col of def.columns) {
|
|
278
|
+
const field = entity.fields[col];
|
|
279
|
+
if (field && (field.type === "text" || field.type === "longText") && field.encrypted) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`${where}: column "${col}" is encrypted — a unique index on it can never detect ` +
|
|
282
|
+
`duplicates (non-deterministic ciphertext). Remove \`unique\` or \`encrypted\`.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
273
287
|
}
|
|
274
288
|
}
|
|
275
289
|
}
|
|
@@ -298,6 +312,15 @@ export function validateEncryptedFields(feature: FeatureDefinition): boolean {
|
|
|
298
312
|
`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable`,
|
|
299
313
|
);
|
|
300
314
|
}
|
|
315
|
+
// AES-GCM draws a fresh random IV per call, so the ciphertext is
|
|
316
|
+
// non-deterministic — an equality filter on it never matches, even
|
|
317
|
+
// against the identical plaintext.
|
|
318
|
+
if (field.filterable) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and filterable ` +
|
|
321
|
+
`— non-deterministic ciphertext never matches an equality filter.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
301
324
|
}
|
|
302
325
|
}
|
|
303
326
|
}
|
|
@@ -507,3 +530,25 @@ export function validateExtendSchemaCollisions(feature: FeatureDefinition): void
|
|
|
507
530
|
}
|
|
508
531
|
}
|
|
509
532
|
}
|
|
533
|
+
|
|
534
|
+
// --- derivedFields / fields collision detection ---
|
|
535
|
+
//
|
|
536
|
+
// augmentDerivedFields (entity-handlers.ts) does `{...row}` then
|
|
537
|
+
// `out[fieldName] = def.derive(row, ctx)` — a derivedFields key that matches a
|
|
538
|
+
// stored field name silently overwrites the real, persisted value with the
|
|
539
|
+
// derived one on every read, without a boot or runtime error. Reject it here,
|
|
540
|
+
// analogous to the extendSchema collision check above.
|
|
541
|
+
export function validateDerivedFieldCollisions(feature: FeatureDefinition): void {
|
|
542
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
543
|
+
if (!entity.derivedFields) continue;
|
|
544
|
+
const fieldNames = new Set(Object.keys(entity.fields));
|
|
545
|
+
for (const derivedName of Object.keys(entity.derivedFields)) {
|
|
546
|
+
if (fieldNames.has(derivedName)) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`Entity "${entityName}": derivedFields key "${derivedName}" conflicts with a stored ` +
|
|
549
|
+
`field of the same name — the derived value would silently overwrite the real one on every read.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EXT_USER_DATA } from "../extension-names";
|
|
1
2
|
import type { FeatureDefinition } from "../types";
|
|
2
3
|
|
|
3
4
|
// Providers whose bytes do not survive a process restart. Only "inmemory"
|
|
@@ -65,7 +66,7 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
|
|
|
65
66
|
export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
|
|
66
67
|
for (const feature of features) {
|
|
67
68
|
for (const usage of feature.extensionUsages) {
|
|
68
|
-
if (usage.extensionName !==
|
|
69
|
+
if (usage.extensionName !== EXT_USER_DATA) continue;
|
|
69
70
|
const hasExport = typeof usage.options?.["export"] === "function";
|
|
70
71
|
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
71
72
|
if (hasExport && !hasDelete) {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
warnOnToggleableDependencies,
|
|
13
13
|
} from "./config-deps";
|
|
14
14
|
import {
|
|
15
|
+
validateDerivedFieldCollisions,
|
|
15
16
|
validateEmbeddedFields,
|
|
16
17
|
validateEncryptedFields,
|
|
17
18
|
validateEntityIndexes,
|
|
@@ -33,11 +34,13 @@ import {
|
|
|
33
34
|
collectKnownRoles,
|
|
34
35
|
collectNavQns,
|
|
35
36
|
collectScreenQns,
|
|
37
|
+
collectScreensByShortId,
|
|
36
38
|
collectWorkspaceQns,
|
|
37
39
|
collectWriteHandlerQns,
|
|
38
40
|
validateDefaultWorkspaceUniqueness,
|
|
39
41
|
validateNavCycles,
|
|
40
42
|
validateNavs,
|
|
43
|
+
validateScreenShortIdCollisions,
|
|
41
44
|
validateScreens,
|
|
42
45
|
validateWorkspaces,
|
|
43
46
|
} from "./screens-nav";
|
|
@@ -110,6 +113,8 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
110
113
|
const allNavQns = collectNavQns(features);
|
|
111
114
|
const allWorkspaceQns = collectWorkspaceQns(features);
|
|
112
115
|
const allWriteHandlerQns = collectWriteHandlerQns(features);
|
|
116
|
+
const screensByShortId = collectScreensByShortId(features);
|
|
117
|
+
validateScreenShortIdCollisions(screensByShortId);
|
|
113
118
|
|
|
114
119
|
// Cross-feature API exposure-map — jedes Feature deklariert Marker via
|
|
115
120
|
// r.exposesApi(name). Per-feature validateApiExposureMatching walkt
|
|
@@ -144,6 +149,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
144
149
|
validateTransitions(feature);
|
|
145
150
|
validateExtensionUsages(feature, extensionProviders);
|
|
146
151
|
validateExtendSchemaCollisions(feature);
|
|
152
|
+
validateDerivedFieldCollisions(feature);
|
|
147
153
|
validateHandlerAccess(feature);
|
|
148
154
|
validateLocatedTimestamps(feature);
|
|
149
155
|
validateEntityIndexes(feature);
|
|
@@ -154,7 +160,14 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
154
160
|
validateConfigKeyBacking(feature);
|
|
155
161
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
156
162
|
validateMultiStreamProjections(feature);
|
|
157
|
-
validateScreens(
|
|
163
|
+
validateScreens(
|
|
164
|
+
feature,
|
|
165
|
+
featureMap,
|
|
166
|
+
allWriteHandlerQns,
|
|
167
|
+
allScreenQns,
|
|
168
|
+
allConfigKeyQns,
|
|
169
|
+
screensByShortId,
|
|
170
|
+
);
|
|
158
171
|
validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
|
|
159
172
|
validateWorkspaces(feature, allNavQns);
|
|
160
173
|
}
|
|
@@ -3,7 +3,13 @@ import { SETTINGS_HUB_AUDIENCE_NAV_QNS } from "../build-config-feature-schema";
|
|
|
3
3
|
import { qualifyEntityName } from "../qualified-name";
|
|
4
4
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
5
5
|
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
FieldCondition,
|
|
8
|
+
RowAction,
|
|
9
|
+
RowFieldExtractor,
|
|
10
|
+
ScreenDefinition,
|
|
11
|
+
ToolbarAction,
|
|
12
|
+
} from "../types/screen";
|
|
7
13
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
8
14
|
|
|
9
15
|
// --- Screen validation ---
|
|
@@ -81,12 +87,41 @@ function validateActionFieldRefs(
|
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
// Two features registering the same short screen-id is a silent routing
|
|
91
|
+
// footgun: create-app.tsx's runtime router resolves a bare navigate-target
|
|
92
|
+
// id by scanning features[] and taking the first match — the collision
|
|
93
|
+
// never surfaces as an error, the second feature's screen is just
|
|
94
|
+
// unreachable by that id (and always the same one that loses, in whatever
|
|
95
|
+
// order the app composed its features).
|
|
96
|
+
export function validateScreenShortIdCollisions(
|
|
97
|
+
screensByShortId: ReadonlyMap<
|
|
98
|
+
string,
|
|
99
|
+
ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
|
|
100
|
+
>,
|
|
101
|
+
): void {
|
|
102
|
+
for (const [shortId, entries] of screensByShortId) {
|
|
103
|
+
const featureNames = new Set(entries.map((e) => e.featureName));
|
|
104
|
+
if (featureNames.size > 1) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Screen short-id "${shortId}" is registered by ${featureNames.size} features ` +
|
|
107
|
+
`(${[...featureNames].join(", ")}) — the runtime router resolves a bare navigate-target ` +
|
|
108
|
+
`id by taking the first match, so all but one of these screens would be unreachable by ` +
|
|
109
|
+
`that id. Give each screen a distinct id.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
export function validateScreens(
|
|
85
116
|
feature: FeatureDefinition,
|
|
86
117
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
87
118
|
allWriteHandlerQns: ReadonlySet<string>,
|
|
88
119
|
allScreenQns: ReadonlySet<string>,
|
|
89
120
|
allConfigKeyQns: ReadonlySet<string>,
|
|
121
|
+
screensByShortId: ReadonlyMap<
|
|
122
|
+
string,
|
|
123
|
+
ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
|
|
124
|
+
>,
|
|
90
125
|
): void {
|
|
91
126
|
// navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
|
|
92
127
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
@@ -338,11 +373,16 @@ export function validateScreens(
|
|
|
338
373
|
}
|
|
339
374
|
for (const col of screen.columns) {
|
|
340
375
|
const normalized = normalizeListColumn(col);
|
|
341
|
-
// A
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
376
|
+
// A virtual presentational column (drawn by a columnRenderer component
|
|
377
|
+
// from the row, e.g. tag chips) needs BOTH a label AND a renderer —
|
|
378
|
+
// renderer is what actually makes it "virtual" (label alone still
|
|
379
|
+
// needs list.ts's virtual-branch to have something to draw; without
|
|
380
|
+
// a renderer the column would render nothing). A column with neither
|
|
381
|
+
// matching a real field NOR a renderer is a typo worth failing the boot.
|
|
382
|
+
if (
|
|
383
|
+
!columnFieldNames.has(normalized.field) &&
|
|
384
|
+
!(normalized.label !== undefined && normalized.renderer !== undefined)
|
|
385
|
+
) {
|
|
346
386
|
throw new Error(
|
|
347
387
|
buildUnknownFieldMessage(
|
|
348
388
|
feature.name,
|
|
@@ -446,6 +486,26 @@ export function validateScreens(
|
|
|
446
486
|
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
447
487
|
);
|
|
448
488
|
}
|
|
489
|
+
// The renderer's default-entityId fallback (row["id"]) only fires
|
|
490
|
+
// for a same-feature entityEdit target — it can't safely guess
|
|
491
|
+
// the id for a screen owned by a different feature. Cross-feature
|
|
492
|
+
// + entityEdit therefore MUST set an explicit entityId, or the
|
|
493
|
+
// edit screen silently opens with no entity context at runtime.
|
|
494
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
495
|
+
if (
|
|
496
|
+
target !== undefined &&
|
|
497
|
+
target.featureName !== feature.name &&
|
|
498
|
+
target.screen.type === "entityEdit" &&
|
|
499
|
+
action.entityId === undefined
|
|
500
|
+
) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
503
|
+
`navigates cross-feature to entityEdit screen "${action.screen}" (feature ` +
|
|
504
|
+
`"${target.featureName}") without an explicit entityId field — the renderer's ` +
|
|
505
|
+
`same-feature row["id"] fallback does not apply across features. Set entityId to ` +
|
|
506
|
+
`the row field that names the target entity's id.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
449
509
|
} else {
|
|
450
510
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
451
511
|
throw new Error(
|
|
@@ -648,6 +708,30 @@ export function screenShortIdsFrom(allScreenQns: ReadonlySet<string>): Set<strin
|
|
|
648
708
|
return set;
|
|
649
709
|
}
|
|
650
710
|
|
|
711
|
+
// Short screen-id → every {featureName, screen} that registers it. The
|
|
712
|
+
// runtime router (create-app.tsx) resolves a bare navigate-target short-id by
|
|
713
|
+
// scanning ALL features and taking the first match — so two features
|
|
714
|
+
// registering the same short-id is a silent routing footgun (whichever
|
|
715
|
+
// feature comes first in the app's features[] array always wins, the other
|
|
716
|
+
// is unreachable by that id) and a prerequisite for the entityId-check below
|
|
717
|
+
// (which target screen it resolves to must be unambiguous).
|
|
718
|
+
export function collectScreensByShortId(
|
|
719
|
+
features: readonly FeatureDefinition[],
|
|
720
|
+
): Map<string, ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>> {
|
|
721
|
+
const map = new Map<
|
|
722
|
+
string,
|
|
723
|
+
Array<{ readonly featureName: string; readonly screen: ScreenDefinition }>
|
|
724
|
+
>();
|
|
725
|
+
for (const f of features) {
|
|
726
|
+
for (const [screenId, screen] of Object.entries(f.screens)) {
|
|
727
|
+
const entries = map.get(screenId) ?? [];
|
|
728
|
+
entries.push({ featureName: f.name, screen });
|
|
729
|
+
map.set(screenId, entries);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return map;
|
|
733
|
+
}
|
|
734
|
+
|
|
651
735
|
// Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
|
|
652
736
|
// Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
|
|
653
737
|
// im Schema deklarierte handler tatsächlich registriert ist —
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
SETTINGS_HUB_FEATURE,
|
|
33
33
|
} from "./build-config-feature-schema";
|
|
34
34
|
import type { Registry } from "./types/feature";
|
|
35
|
-
import type { DerivedFieldDef, FieldDefinition } from "./types/fields";
|
|
35
|
+
import type { ClientDerivedFieldDef, DerivedFieldDef, FieldDefinition } from "./types/fields";
|
|
36
36
|
|
|
37
37
|
export type BuildAppSchemaOptions = {
|
|
38
38
|
/** Dev-server authoring hints (Settings-Hub placement). Default off — only
|
|
@@ -290,7 +290,7 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
|
|
|
290
290
|
// `entity.derivedFields[field].valueType` auf — fehlt der Eintrag, wirft es
|
|
291
291
|
// "references unknown field". Der executor hat den Wert server-seitig schon
|
|
292
292
|
// an die Row gehängt; der Client braucht nur den valueType für den Renderer.
|
|
293
|
-
const derivedOut: Record<string,
|
|
293
|
+
const derivedOut: Record<string, ClientDerivedFieldDef> = {};
|
|
294
294
|
for (const [name, derivedDef] of Object.entries(entity.derivedFields ?? {})) {
|
|
295
295
|
derivedOut[name] = projectDerivedField(derivedDef);
|
|
296
296
|
}
|
|
@@ -300,16 +300,24 @@ function projectEntity(entity: EntityDefinition): EntityDefinition {
|
|
|
300
300
|
// Kein Cast nötig: alle weggelassenen Felder sind `?`-optional.
|
|
301
301
|
return {
|
|
302
302
|
fields: fieldsOut,
|
|
303
|
-
|
|
303
|
+
// @cast-boundary schema-walk: EntityDefinition.derivedFields is typed for
|
|
304
|
+
// the SERVER (derive required); this is the one place a client schema
|
|
305
|
+
// narrows it to ClientDerivedFieldDef (valueType only) — the cast lives
|
|
306
|
+
// at the actual server/client type boundary instead of inside
|
|
307
|
+
// projectDerivedField, which now honestly returns the narrower type.
|
|
308
|
+
...(Object.keys(derivedOut).length > 0 && {
|
|
309
|
+
derivedFields: derivedOut as unknown as Record<string, DerivedFieldDef>,
|
|
310
|
+
}),
|
|
304
311
|
...(typeof entity.table === "string" && { table: entity.table }),
|
|
305
312
|
};
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
// Nur valueType durch — die derive-fn ist Server-only und NICHT JSON-safe
|
|
309
|
-
// (würde sonst die Output-Walk-Guard triggern).
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
316
|
+
// (würde sonst die Output-Walk-Guard triggern). Gibt ehrlich
|
|
317
|
+
// ClientDerivedFieldDef zurück statt eine DerivedFieldDef vorzutäuschen,
|
|
318
|
+
// die keine derive-fn hat.
|
|
319
|
+
function projectDerivedField(derivedDef: DerivedFieldDef): ClientDerivedFieldDef {
|
|
320
|
+
return { valueType: derivedDef.valueType };
|
|
313
321
|
}
|
|
314
322
|
|
|
315
323
|
// Whitelist pro Field. `default` darf nur durch wenn Literal (string/
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ConfigKeyType,
|
|
16
16
|
ConfigSeedDef,
|
|
17
17
|
EntityDefinition,
|
|
18
|
+
EntityProjectionExtension,
|
|
18
19
|
EntityRef,
|
|
19
20
|
EventDef,
|
|
20
21
|
EventMigrationDef,
|
|
@@ -141,6 +142,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
141
142
|
const secretKeys: Record<string, SecretKeyDefinition> = {};
|
|
142
143
|
const projections: Record<string, ProjectionDefinition> = {};
|
|
143
144
|
const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
|
|
145
|
+
const entityProjectionExtensions: Record<string, EntityProjectionExtension[]> = {};
|
|
144
146
|
const rawTables: Record<string, RawTableEntry> = {};
|
|
145
147
|
const unmanagedTables: Record<string, UnmanagedTableEntry> = {};
|
|
146
148
|
const authClaimsHooks: AuthClaimsFn[] = [];
|
|
@@ -727,6 +729,20 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
727
729
|
multiStreamProjections[definition.name] = definition;
|
|
728
730
|
},
|
|
729
731
|
|
|
732
|
+
extendEntityProjection(entityName: string, extension: EntityProjectionExtension): void {
|
|
733
|
+
if (Object.keys(extension.apply).length === 0) {
|
|
734
|
+
throw new Error(
|
|
735
|
+
`[Feature ${name}] extendEntityProjection("${entityName}") has no apply handlers. ` +
|
|
736
|
+
`Declare at least one event type, otherwise the rebuild replay has nothing to do.`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
// Entity existence + apply-key collisions are validated at registry
|
|
740
|
+
// build — r.entity may legally be called after this in the same feature.
|
|
741
|
+
const list = entityProjectionExtensions[entityName] ?? [];
|
|
742
|
+
list.push(extension);
|
|
743
|
+
entityProjectionExtensions[entityName] = list;
|
|
744
|
+
},
|
|
745
|
+
|
|
730
746
|
authClaims(fn: AuthClaimsFn): void {
|
|
731
747
|
authClaimsHooks.push(fn);
|
|
732
748
|
},
|
|
@@ -997,6 +1013,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
997
1013
|
metrics,
|
|
998
1014
|
secretKeys,
|
|
999
1015
|
projections,
|
|
1016
|
+
entityProjectionExtensions,
|
|
1000
1017
|
multiStreamProjections,
|
|
1001
1018
|
authClaimsHooks,
|
|
1002
1019
|
claimKeys,
|
|
@@ -90,7 +90,20 @@ export function defineWriteHandler<
|
|
|
90
90
|
// responses get `[]`, so existing call-sites are unaffected. Checking it in a
|
|
91
91
|
// parameter post-inference (not as a `TData extends …` constraint, which TS
|
|
92
92
|
// rejects as circular, TS2313) is what makes inference survive.
|
|
93
|
-
|
|
93
|
+
//
|
|
94
|
+
// Membership form `true extends ContainsSecret<TData>` (556/1), not
|
|
95
|
+
// `ContainsSecret<TData> extends true`: when TData is a union like
|
|
96
|
+
// `{ok:true} | {s:Secret<string>}`, the naked-type-parameter conditional in
|
|
97
|
+
// ContainsSecret DISTRIBUTES over the union, so the result is
|
|
98
|
+
// `false | true` = `boolean`, not the literal `true` — `ContainsSecret<
|
|
99
|
+
// TData> extends true` is then false (boolean isn't assignable to the
|
|
100
|
+
// literal true) and the old check silently fell through to `[]` even with
|
|
101
|
+
// a real leak in one branch. Putting the naked `true` on the LEFT instead
|
|
102
|
+
// keeps the check fail-closed for the union case (`true extends boolean`
|
|
103
|
+
// is true) without eagerly normalizing ContainsSecret<TData> against a
|
|
104
|
+
// literal — which is what blew up TS's instantiation depth on generic
|
|
105
|
+
// call-sites (createTokenRequestHandler's still-unresolved TSuccessKind).
|
|
106
|
+
..._noSecretInResponse: true extends ContainsSecret<TData>
|
|
94
107
|
? [
|
|
95
108
|
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
|
|
96
109
|
]
|
|
@@ -174,7 +187,8 @@ export function defineQueryHandler<
|
|
|
174
187
|
def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>,
|
|
175
188
|
// R6: phantom rest-param — see defineWriteHandler. Forbids a Secret<> in the
|
|
176
189
|
// inferred query response `TResult` at compile time; `[]` for clean responses.
|
|
177
|
-
|
|
190
|
+
// Membership form (556/1) — see defineWriteHandler's comment for why.
|
|
191
|
+
..._noSecretInResponse: true extends ContainsSecret<TResult>
|
|
178
192
|
? [
|
|
179
193
|
secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field.",
|
|
180
194
|
]
|