@cosmicdrift/kumiko-framework 0.249.0 → 0.251.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 +3 -3
- package/src/__tests__/full-stack.integration.test.ts +3 -1
- package/src/crypto/__tests__/event-pii.test.ts +25 -3
- package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
- package/src/crypto/event-pii.ts +7 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/kms-wiring.ts +23 -10
- package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
- package/src/db/entity-table-meta.ts +9 -2
- package/src/db/event-store-executor-read.ts +124 -7
- package/src/db/table-builder.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +132 -2
- package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +184 -0
- package/src/engine/boot-validator/index.ts +5 -1
- package/src/engine/boot-validator/projection-list-screens.ts +42 -0
- package/src/engine/boot-validator/screens.ts +45 -29
- package/src/engine/entity-handlers.ts +4 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
- package/src/engine/feature-ast/extractors/events.ts +24 -0
- package/src/engine/feature-ast/patcher.ts +6 -1
- package/src/engine/feature-ast/patterns.ts +6 -0
- package/src/engine/feature-ast/render.ts +3 -4
- package/src/engine/feature-config-events-jobs.ts +33 -9
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/registry-facade.ts +10 -1
- package/src/engine/registry-state.ts +8 -0
- package/src/engine/registry-validate.ts +54 -3
- package/src/engine/types/index.ts +1 -0
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
- package/src/jobs/job-runner.ts +12 -4
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
- package/src/pipeline/system-hooks.ts +94 -25
|
@@ -121,3 +121,45 @@ export function validateProjectionListScreens(features: readonly FeatureDefiniti
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
// relatedList sections declare search/facets the same way a projectionList
|
|
126
|
+
// screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
|
|
127
|
+
// auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
|
|
128
|
+
// undeclared param would fail every query instead of failing at boot.
|
|
129
|
+
// Reachable on projectionDetail only (screens.ts rejects relatedList
|
|
130
|
+
// elsewhere), but the layout walk stays uniform so lifting that restriction
|
|
131
|
+
// doesn't quietly skip these checks.
|
|
132
|
+
export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
|
|
133
|
+
const queryHandlers = buildQueryHandlerMap(features);
|
|
134
|
+
for (const feature of features) {
|
|
135
|
+
for (const screen of Object.values(feature.screens)) {
|
|
136
|
+
if (
|
|
137
|
+
screen.type !== "projectionDetail" &&
|
|
138
|
+
screen.type !== "entityEdit" &&
|
|
139
|
+
screen.type !== "actionForm" &&
|
|
140
|
+
screen.type !== "configEdit"
|
|
141
|
+
) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
for (const section of screen.layout.sections) {
|
|
145
|
+
if (section.kind !== "relatedList") continue;
|
|
146
|
+
const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
|
|
147
|
+
const schema = queryHandlers.get(section.query)?.schema;
|
|
148
|
+
if (section.searchable === true && !schemaAccepts(schema, "search")) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
section.facets !== undefined &&
|
|
155
|
+
section.facets.length > 0 &&
|
|
156
|
+
!schemaAccepts(schema, "filters")
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
EditFieldSpec,
|
|
26
26
|
EditLayout,
|
|
27
27
|
FieldCondition,
|
|
28
|
+
ListColumnSpec,
|
|
29
|
+
ListFacetSpec,
|
|
28
30
|
RowAction,
|
|
29
31
|
RowActionNavigateBase,
|
|
30
32
|
RowFieldExtractor,
|
|
@@ -559,37 +561,12 @@ export function validateScreens(
|
|
|
559
561
|
`filter.value to be a readonly array.`,
|
|
560
562
|
);
|
|
561
563
|
}
|
|
562
|
-
// Facets (fw#2224) — unlike filter, a field inventory IS available
|
|
563
|
-
// here: the declared columns. A facet on a field with no column is
|
|
564
|
-
// almost always a typo (the user never sees the field anywhere), so
|
|
565
|
-
// this is hard-checked rather than just documented.
|
|
566
564
|
if (screen.facets !== undefined) {
|
|
567
|
-
|
|
568
|
-
|
|
565
|
+
validateListFacets(
|
|
566
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
|
|
567
|
+
screen.facets,
|
|
568
|
+
screen.columns,
|
|
569
569
|
);
|
|
570
|
-
const seenFacetFields = new Set<string>();
|
|
571
|
-
for (const facet of screen.facets) {
|
|
572
|
-
if (seenFacetFields.has(facet.field)) {
|
|
573
|
-
throw new Error(
|
|
574
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) declares facet ` +
|
|
575
|
-
`"${facet.field}" more than once.`,
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
seenFacetFields.add(facet.field);
|
|
579
|
-
if (!columnFieldNames.has(facet.field)) {
|
|
580
|
-
throw new Error(
|
|
581
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet references field ` +
|
|
582
|
-
`"${facet.field}" which is not a declared column. Known columns: ` +
|
|
583
|
-
`${[...columnFieldNames].sort().join(", ")}`,
|
|
584
|
-
);
|
|
585
|
-
}
|
|
586
|
-
if (facet.type === "select" && facet.options.length === 0) {
|
|
587
|
-
throw new Error(
|
|
588
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet "${facet.field}" ` +
|
|
589
|
-
`(type "select") has an empty options list — declare at least one option.`,
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
570
|
}
|
|
594
571
|
if (screen.rowActions !== undefined) {
|
|
595
572
|
for (const action of screen.rowActions) {
|
|
@@ -863,6 +840,13 @@ export function validateScreens(
|
|
|
863
840
|
);
|
|
864
841
|
}
|
|
865
842
|
}
|
|
843
|
+
if (section.facets !== undefined) {
|
|
844
|
+
validateListFacets(
|
|
845
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
|
|
846
|
+
section.facets,
|
|
847
|
+
section.columns,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
866
850
|
continue;
|
|
867
851
|
}
|
|
868
852
|
if (isWriteFormEditSection(section)) {
|
|
@@ -1805,6 +1789,38 @@ export function validateColumnRendererForm(
|
|
|
1805
1789
|
}
|
|
1806
1790
|
}
|
|
1807
1791
|
|
|
1792
|
+
// Facets (fw#2224) — unlike filter, a field inventory IS available here: the
|
|
1793
|
+
// declared columns. A facet on a field with no column is almost always a
|
|
1794
|
+
// typo (the user never sees the field anywhere), so this is hard-checked
|
|
1795
|
+
// rather than just documented. Shared by projectionList and relatedList
|
|
1796
|
+
// (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
|
|
1797
|
+
function validateListFacets(
|
|
1798
|
+
prefix: string,
|
|
1799
|
+
facets: readonly ListFacetSpec[],
|
|
1800
|
+
columns: readonly ListColumnSpec[],
|
|
1801
|
+
): void {
|
|
1802
|
+
const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
|
|
1803
|
+
const seenFacetFields = new Set<string>();
|
|
1804
|
+
for (const facet of facets) {
|
|
1805
|
+
if (seenFacetFields.has(facet.field)) {
|
|
1806
|
+
throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
|
|
1807
|
+
}
|
|
1808
|
+
seenFacetFields.add(facet.field);
|
|
1809
|
+
if (!columnFieldNames.has(facet.field)) {
|
|
1810
|
+
throw new Error(
|
|
1811
|
+
`${prefix} facet references field "${facet.field}" which is not a declared column. ` +
|
|
1812
|
+
`Known columns: ${[...columnFieldNames].sort().join(", ")}`,
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
if (facet.type === "select" && facet.options.length === 0) {
|
|
1816
|
+
throw new Error(
|
|
1817
|
+
`${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
|
|
1818
|
+
`declare at least one option.`,
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1808
1824
|
export function findEntityFeature(
|
|
1809
1825
|
entityName: string,
|
|
1810
1826
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -384,6 +384,10 @@ export function defineEntityQueryHandler(
|
|
|
384
384
|
fields: ctx.registry.getSearchableReferences(entityName),
|
|
385
385
|
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
386
386
|
},
|
|
387
|
+
referenceSort: {
|
|
388
|
+
fields: ctx.registry.getSortableReferences(entityName),
|
|
389
|
+
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
390
|
+
},
|
|
387
391
|
});
|
|
388
392
|
const enrichedRows = hasRefFields
|
|
389
393
|
? await enrichWithReferences(
|
|
@@ -42,7 +42,7 @@ defineFeature("todoList", (r) => {
|
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
r.defineEvent("taskCompleted", z.object({ id: z.string() }), { version: 1 });
|
|
45
|
+
r.defineEvent("taskCompleted", z.object({ id: z.string() }), { piiFields: "none", version: 1 });
|
|
46
46
|
|
|
47
47
|
r.writeHandler(
|
|
48
48
|
"task:create",
|
|
@@ -2224,7 +2224,7 @@ describe("extractDefineEvent", () => {
|
|
|
2224
2224
|
test("captures eventName + version", () => {
|
|
2225
2225
|
const result = parseInline(`
|
|
2226
2226
|
defineFeature("f", (r) => {
|
|
2227
|
-
r.defineEvent("incidentOpened", z.object({ id: z.string() }), { version: 2 });
|
|
2227
|
+
r.defineEvent("incidentOpened", z.object({ id: z.string() }), { piiFields: "none", version: 2 });
|
|
2228
2228
|
});
|
|
2229
2229
|
`);
|
|
2230
2230
|
|
|
@@ -2241,6 +2241,7 @@ defineFeature("f", (r) => {
|
|
|
2241
2241
|
r.defineEvent({
|
|
2242
2242
|
name: "incidentOpened",
|
|
2243
2243
|
schema: z.object({ id: z.string() }),
|
|
2244
|
+
piiFields: "none",
|
|
2244
2245
|
version: 2,
|
|
2245
2246
|
migrations: { "1": (payload) => payload },
|
|
2246
2247
|
});
|
|
@@ -2285,6 +2286,17 @@ defineFeature("f", (r) => {
|
|
|
2285
2286
|
expect(result.errors[0]?.methodName).toBe("defineEvent");
|
|
2286
2287
|
});
|
|
2287
2288
|
|
|
2289
|
+
test("emits ParseError when object form is missing the piiFields property", () => {
|
|
2290
|
+
const result = parseInline(`
|
|
2291
|
+
defineFeature("f", (r) => {
|
|
2292
|
+
r.defineEvent({ name: "incidentOpened", schema: z.object({ id: z.string() }) });
|
|
2293
|
+
});
|
|
2294
|
+
`);
|
|
2295
|
+
|
|
2296
|
+
expect(result.errors[0]?.methodName).toBe("defineEvent");
|
|
2297
|
+
expect(result.errors[0]?.reason).toContain("piiFields");
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2288
2300
|
test("emits ParseError when the event name is not a string literal", () => {
|
|
2289
2301
|
const result = parseInline(`
|
|
2290
2302
|
defineFeature("f", (r) => {
|
|
@@ -2304,6 +2316,17 @@ defineFeature("f", (r) => {
|
|
|
2304
2316
|
|
|
2305
2317
|
expect(result.errors[0]?.methodName).toBe("defineEvent");
|
|
2306
2318
|
});
|
|
2319
|
+
|
|
2320
|
+
test("emits ParseError when the positional form is missing the piiFields option", () => {
|
|
2321
|
+
const result = parseInline(`
|
|
2322
|
+
defineFeature("f", (r) => {
|
|
2323
|
+
r.defineEvent("incidentOpened", z.object({ id: z.string() }), { version: 2 });
|
|
2324
|
+
});
|
|
2325
|
+
`);
|
|
2326
|
+
|
|
2327
|
+
expect(result.errors[0]?.methodName).toBe("defineEvent");
|
|
2328
|
+
expect(result.errors[0]?.reason).toContain("piiFields");
|
|
2329
|
+
});
|
|
2307
2330
|
});
|
|
2308
2331
|
|
|
2309
2332
|
describe("extractDefineEvent — migrations (formerly extractEventMigration)", () => {
|
|
@@ -2311,6 +2334,7 @@ describe("extractDefineEvent — migrations (formerly extractEventMigration)", (
|
|
|
2311
2334
|
const result = parseInline(`
|
|
2312
2335
|
defineFeature("f", (r) => {
|
|
2313
2336
|
r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
|
|
2337
|
+
piiFields: "none",
|
|
2314
2338
|
version: 2,
|
|
2315
2339
|
migrations: [
|
|
2316
2340
|
{ fromVersion: 1, toVersion: 2, transform: (payload) => ({ ...payload, severity: "low" }) },
|
|
@@ -2333,6 +2357,7 @@ defineFeature("f", (r) => {
|
|
|
2333
2357
|
r.defineEvent({
|
|
2334
2358
|
name: "incidentOpened",
|
|
2335
2359
|
schema: z.object({ id: z.string() }),
|
|
2360
|
+
piiFields: "none",
|
|
2336
2361
|
version: 2,
|
|
2337
2362
|
migrations: {
|
|
2338
2363
|
"1": (payload) => ({ ...payload, severity: "low" }),
|
|
@@ -2353,6 +2378,7 @@ defineFeature("f", (r) => {
|
|
|
2353
2378
|
const result = parseInline(`
|
|
2354
2379
|
defineFeature("f", (r) => {
|
|
2355
2380
|
r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
|
|
2381
|
+
piiFields: "none",
|
|
2356
2382
|
migrations: [
|
|
2357
2383
|
"skip-me",
|
|
2358
2384
|
{ fromVersion: "bad", transform: (payload) => payload },
|
|
@@ -307,6 +307,7 @@ describe("patch coverage for the remaining pattern-kinds", () => {
|
|
|
307
307
|
createFeaturePatcher(sf).addDefineEvent({
|
|
308
308
|
name: "itemCreated",
|
|
309
309
|
schemaSource: "z.object({ id: z.string() })",
|
|
310
|
+
piiFieldsSource: '"none"',
|
|
310
311
|
version: 2,
|
|
311
312
|
migrations: { "1": "(old) => old" },
|
|
312
313
|
});
|
|
@@ -178,6 +178,7 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
|
|
|
178
178
|
p.addDefineEvent({
|
|
179
179
|
name: "stepCompleted",
|
|
180
180
|
schemaSource: "z.object({ id: z.string() })",
|
|
181
|
+
piiFieldsSource: '"none"',
|
|
181
182
|
version: 2,
|
|
182
183
|
migrations: { "1": '(old) => ({ id: old.id ?? "" })' },
|
|
183
184
|
});
|
|
@@ -86,6 +86,7 @@ function parse(source: string): {
|
|
|
86
86
|
// to match the new context.
|
|
87
87
|
const BODY_LOC_KEYS = new Set([
|
|
88
88
|
"schemaSource",
|
|
89
|
+
"piiFields",
|
|
89
90
|
"handlerBody",
|
|
90
91
|
"fnBody",
|
|
91
92
|
"transformBody",
|
|
@@ -228,7 +229,7 @@ defineFeature("workflow", (r) => {
|
|
|
228
229
|
console.log("step deleted");
|
|
229
230
|
});
|
|
230
231
|
|
|
231
|
-
r.defineEvent("stepCompleted", z.object({ id: z.string() }), { version: 1 });
|
|
232
|
+
r.defineEvent("stepCompleted", z.object({ id: z.string() }), { piiFields: "none", version: 1 });
|
|
232
233
|
|
|
233
234
|
r.nav({ id: "steps", label: "Steps", screen: "workflow:screen:step-list" });
|
|
234
235
|
});
|
|
@@ -458,8 +459,18 @@ describe("renderPattern — single-pattern shape", () => {
|
|
|
458
459
|
end: { line: 1, column: 1 },
|
|
459
460
|
raw: "z.object({})",
|
|
460
461
|
},
|
|
462
|
+
piiFields: {
|
|
463
|
+
file: "x",
|
|
464
|
+
start: { line: 1, column: 1 },
|
|
465
|
+
end: { line: 1, column: 1 },
|
|
466
|
+
raw: '"none"',
|
|
467
|
+
},
|
|
461
468
|
});
|
|
462
|
-
expect(out).toBe(
|
|
469
|
+
expect(out).toBe(
|
|
470
|
+
["r.defineEvent(DOC_INGESTED_EVENT, z.object({}), {", ' piiFields: "none",', "});"].join(
|
|
471
|
+
"\n",
|
|
472
|
+
),
|
|
473
|
+
);
|
|
463
474
|
});
|
|
464
475
|
});
|
|
465
476
|
|
|
@@ -479,7 +490,7 @@ const DOC_INGESTED_EVENT = "docIngested" as const;
|
|
|
479
490
|
defineFeature("user-data-rights", (r) => {
|
|
480
491
|
r.extendsRegistrar(EXT_TENANT_DATA, { onRegister: () => {} });
|
|
481
492
|
r.useExtension(EXT_TENANT_DATA, "document", { description: "org-scoped" });
|
|
482
|
-
r.defineEvent(DOC_INGESTED_EVENT, z.object({ id: z.string() }));
|
|
493
|
+
r.defineEvent(DOC_INGESTED_EVENT, z.object({ id: z.string() }), { piiFields: "none" });
|
|
483
494
|
});
|
|
484
495
|
`;
|
|
485
496
|
|
|
@@ -625,6 +636,7 @@ import { z } from "zod";
|
|
|
625
636
|
|
|
626
637
|
defineFeature("billing", (r) => {
|
|
627
638
|
r.defineEvent("invoicePaid", z.object({ totalCents: z.number() }), {
|
|
639
|
+
piiFields: "none",
|
|
628
640
|
version: 2,
|
|
629
641
|
migrations: [
|
|
630
642
|
{
|
|
@@ -656,6 +668,47 @@ describe("render → parse roundtrip — r.defineEvent({ migrations }) fold", ()
|
|
|
656
668
|
});
|
|
657
669
|
});
|
|
658
670
|
|
|
671
|
+
const DEFINE_EVENT_WITH_PII_FIELDS_FEATURE = `
|
|
672
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
673
|
+
import { z } from "zod";
|
|
674
|
+
|
|
675
|
+
defineFeature("mailer", (r) => {
|
|
676
|
+
r.defineEvent("attempt", z.object({ recipientId: z.string(), recipientAddress: z.string() }), {
|
|
677
|
+
piiFields: { recipientAddress: { subjectField: "recipientId" } },
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
`;
|
|
681
|
+
|
|
682
|
+
describe("render → parse roundtrip — r.defineEvent piiFields stance (fw#2558)", () => {
|
|
683
|
+
test('piiFields: "none" survives parse → render → parse unchanged', () => {
|
|
684
|
+
const initial = parse(DEFINE_EVENT_WITH_MIGRATIONS_FEATURE);
|
|
685
|
+
const rendered = renderFeatureFile({
|
|
686
|
+
featureName: initial.featureName ?? "",
|
|
687
|
+
patterns: initial.patterns,
|
|
688
|
+
});
|
|
689
|
+
expect(rendered).toContain('piiFields: "none",');
|
|
690
|
+
const reparsed = parse(rendered);
|
|
691
|
+
expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("an object-literal piiFields stance survives parse → render → parse unchanged", () => {
|
|
695
|
+
const initial = parse(DEFINE_EVENT_WITH_PII_FIELDS_FEATURE);
|
|
696
|
+
const rendered = renderFeatureFile({
|
|
697
|
+
featureName: initial.featureName ?? "",
|
|
698
|
+
patterns: initial.patterns,
|
|
699
|
+
});
|
|
700
|
+
expect(rendered).toContain('piiFields: { recipientAddress: { subjectField: "recipientId" } }');
|
|
701
|
+
const reparsed = parse(rendered);
|
|
702
|
+
expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
|
|
703
|
+
|
|
704
|
+
const eventPattern = reparsed.patterns.find((p) => p.kind === "defineEvent");
|
|
705
|
+
expect(eventPattern?.kind).toBe("defineEvent");
|
|
706
|
+
if (eventPattern?.kind === "defineEvent") {
|
|
707
|
+
expect(eventPattern.piiFields.raw).toContain("recipientAddress");
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
659
712
|
// --- Real-compile check ---------------------------------------------------
|
|
660
713
|
//
|
|
661
714
|
// Everything above proves parse(render(patterns)) is structurally faithful.
|
|
@@ -131,6 +131,17 @@ export function extractDefineEvent(
|
|
|
131
131
|
const v = readDataLiteralNode(versionInit);
|
|
132
132
|
if (typeof v === "number") version = v;
|
|
133
133
|
}
|
|
134
|
+
const piiFieldsInit = obj
|
|
135
|
+
.getProperty("piiFields")
|
|
136
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
137
|
+
?.getInitializer();
|
|
138
|
+
if (!piiFieldsInit) {
|
|
139
|
+
return fail(
|
|
140
|
+
"defineEvent",
|
|
141
|
+
sourceLocationFromNode(call, sourceFile),
|
|
142
|
+
'requires an explicit `piiFields` stance (an object literal or "none")',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
134
145
|
const migrationsInit = obj
|
|
135
146
|
.getProperty("migrations")
|
|
136
147
|
?.asKind(SyntaxKind.PropertyAssignment)
|
|
@@ -143,6 +154,7 @@ export function extractDefineEvent(
|
|
|
143
154
|
source: sourceLocationFromNode(call, sourceFile),
|
|
144
155
|
eventName: nameInit.getLiteralValue(),
|
|
145
156
|
schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
|
|
157
|
+
piiFields: sourceLocationFromNode(piiFieldsInit, sourceFile),
|
|
146
158
|
...(version !== undefined && { version }),
|
|
147
159
|
...(migrations !== undefined && { migrations }),
|
|
148
160
|
});
|
|
@@ -168,6 +180,17 @@ export function extractDefineEvent(
|
|
|
168
180
|
let migrations: Readonly<Record<string, SourceLocation>> | undefined;
|
|
169
181
|
const optionsArg = args[2];
|
|
170
182
|
const optionsObj = optionsArg?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
183
|
+
const piiFieldsInit = optionsObj
|
|
184
|
+
?.getProperty("piiFields")
|
|
185
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
186
|
+
?.getInitializer();
|
|
187
|
+
if (!piiFieldsInit) {
|
|
188
|
+
return fail(
|
|
189
|
+
"defineEvent",
|
|
190
|
+
sourceLocationFromNode(call, sourceFile),
|
|
191
|
+
'requires an explicit `piiFields` stance (an object literal or "none")',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
171
194
|
if (optionsObj) {
|
|
172
195
|
const versionInit = optionsObj
|
|
173
196
|
.getProperty("version")
|
|
@@ -191,6 +214,7 @@ export function extractDefineEvent(
|
|
|
191
214
|
eventName: eventNameRef.value,
|
|
192
215
|
...(eventNameRef.raw !== undefined && { eventNameRaw: eventNameRef.raw }),
|
|
193
216
|
schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
|
|
217
|
+
piiFields: sourceLocationFromNode(piiFieldsInit, sourceFile),
|
|
194
218
|
...(version !== undefined && { version }),
|
|
195
219
|
...(migrations !== undefined && { migrations }),
|
|
196
220
|
});
|
|
@@ -147,6 +147,10 @@ export type AddHttpRouteArgs = {
|
|
|
147
147
|
export type AddDefineEventArgs = {
|
|
148
148
|
readonly name: string;
|
|
149
149
|
readonly schemaSource: string;
|
|
150
|
+
// Raw source of the `piiFields` option value (e.g. `'"none"'` or an object
|
|
151
|
+
// literal) — mandatory, mirroring r.defineEvent's own required stance
|
|
152
|
+
// (fw#2558).
|
|
153
|
+
readonly piiFieldsSource: string;
|
|
150
154
|
readonly version?: number;
|
|
151
155
|
// Keyed by fromVersion (as a string) → the transform source text for the
|
|
152
156
|
// fromVersion -> fromVersion+1 step. Folded in from the former
|
|
@@ -496,7 +500,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
496
500
|
});
|
|
497
501
|
},
|
|
498
502
|
|
|
499
|
-
addDefineEvent({ name, schemaSource, version, migrations }) {
|
|
503
|
+
addDefineEvent({ name, schemaSource, piiFieldsSource, version, migrations }) {
|
|
500
504
|
const migrationLocs = migrations
|
|
501
505
|
? Object.fromEntries(
|
|
502
506
|
Object.entries(migrations).map(([fromVersion, source]) => [
|
|
@@ -510,6 +514,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
|
|
|
510
514
|
source: SYNTHETIC_LOC,
|
|
511
515
|
eventName: name,
|
|
512
516
|
schemaSource: rawLoc(schemaSource),
|
|
517
|
+
piiFields: rawLoc(piiFieldsSource),
|
|
513
518
|
...(version !== undefined && { version }),
|
|
514
519
|
...(migrationLocs !== undefined && { migrations: migrationLocs }),
|
|
515
520
|
});
|
|
@@ -561,6 +561,12 @@ export type DefineEventPattern = {
|
|
|
561
561
|
readonly eventNameRaw?: string;
|
|
562
562
|
readonly schemaSource: SourceLocation;
|
|
563
563
|
readonly version?: number;
|
|
564
|
+
// Raw source of the `piiFields` option value ("none", an object literal,
|
|
565
|
+
// or a constant reference) — mandatory because `r.defineEvent` itself
|
|
566
|
+
// requires an explicit PII stance (fw#2558); round-tripping it verbatim
|
|
567
|
+
// avoids the extractor ever having to invent a stance the author didn't
|
|
568
|
+
// author.
|
|
569
|
+
readonly piiFields: SourceLocation;
|
|
564
570
|
// Map fromVersion (as string, e.g. "1") → SourceLocation of the transform
|
|
565
571
|
// closure for the fromVersion → fromVersion+1 step.
|
|
566
572
|
readonly migrations?: Readonly<Record<string, SourceLocation>>;
|
|
@@ -596,11 +596,10 @@ function renderMultiStreamProjection(p: MultiStreamProjectionPattern): string {
|
|
|
596
596
|
function renderDefineEvent(p: DefineEventPattern): string {
|
|
597
597
|
const nameLiteral = p.eventNameRaw ?? JSON.stringify(p.eventName);
|
|
598
598
|
const migrationEntries = p.migrations !== undefined ? Object.entries(p.migrations) : [];
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return `r.defineEvent(${nameLiteral}, ${p.schemaSource.raw});`;
|
|
602
|
-
}
|
|
599
|
+
// piiFields is a mandatory option (fw#2558) — the options object is
|
|
600
|
+
// always rendered, unlike the old version/migrations-only shortcut.
|
|
603
601
|
const lines: string[] = [`r.defineEvent(${nameLiteral}, ${p.schemaSource.raw}, {`];
|
|
602
|
+
lines.push(` piiFields: ${p.piiFields.raw},`);
|
|
604
603
|
if (p.version !== undefined) lines.push(` version: ${p.version},`);
|
|
605
604
|
if (migrationEntries.length > 0) {
|
|
606
605
|
lines.push(" migrations: [");
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
DeclarativeEventMigration,
|
|
15
15
|
EventDef,
|
|
16
16
|
EventPiiFields,
|
|
17
|
+
EventPiiStance,
|
|
17
18
|
EventUpcastFn,
|
|
18
19
|
JobDefinition,
|
|
19
20
|
JobHandlerFn,
|
|
@@ -217,9 +218,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
217
218
|
defineEvent: <const TInner extends string, TPayload>(
|
|
218
219
|
eventName: TInner,
|
|
219
220
|
schema: ZodType<TPayload>,
|
|
220
|
-
options
|
|
221
|
+
options: {
|
|
221
222
|
readonly version?: number;
|
|
222
|
-
readonly piiFields
|
|
223
|
+
readonly piiFields: EventPiiStance;
|
|
223
224
|
// Step-wise upcast chain for this event, folded in from the former
|
|
224
225
|
// standalone r.eventMigration() call (#1082 step 8) — an event and
|
|
225
226
|
// its schema evolution are one lifecycle, not two registrar
|
|
@@ -232,6 +233,33 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
232
233
|
}[];
|
|
233
234
|
},
|
|
234
235
|
): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
|
|
236
|
+
// Fail-closed PII stance gate (fw#2558) — runs before anything else so
|
|
237
|
+
// an untyped JS consumer (no compiler to stop it) still can't register
|
|
238
|
+
// an event without declaring whether its payload carries personal data.
|
|
239
|
+
const missingStanceError = () =>
|
|
240
|
+
new Error(
|
|
241
|
+
`[Feature ${name}] defineEvent("${eventName}") must declare an explicit PII stance. Pass { piiFields: { <payloadField>: { subjectField: "<userIdField>" } } } for payload fields holding personal data, or { piiFields: "none" } when the payload holds none.`,
|
|
242
|
+
);
|
|
243
|
+
if (options === undefined || options.piiFields === undefined) {
|
|
244
|
+
throw missingStanceError();
|
|
245
|
+
}
|
|
246
|
+
const piiFields = options.piiFields;
|
|
247
|
+
// @cast-boundary runtime-guard — an untyped JS caller can hand
|
|
248
|
+
// anything through `piiFields` (including null), so re-check the
|
|
249
|
+
// shape before trusting it as EventPiiStance: `typeof null === "object"`
|
|
250
|
+
// would otherwise pass through to Object.keys(null) below and throw a
|
|
251
|
+
// raw TypeError instead of this guidance message.
|
|
252
|
+
if (piiFields !== "none" && (typeof piiFields !== "object" || piiFields === null)) {
|
|
253
|
+
throw missingStanceError();
|
|
254
|
+
}
|
|
255
|
+
if (piiFields !== "none" && Object.keys(piiFields).length === 0) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields: {} is not a stance — use piiFields: "none" to declare the payload holds no personal data.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (piiFields !== "none") {
|
|
261
|
+
validateEventPiiFields(eventName, schema, piiFields);
|
|
262
|
+
}
|
|
235
263
|
// Return the fully-qualified event name so callers can pass it
|
|
236
264
|
// straight to ctx.appendEvent without hand-building the
|
|
237
265
|
// "<feature>:event:<name>" shape. Registry keeps events keyed by
|
|
@@ -243,16 +271,12 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
243
271
|
// returned `name` carries the literal qualified shape that the
|
|
244
272
|
// augmented `KumikoEventTypeMap` keys against.
|
|
245
273
|
const qualified = qn(toKebab(name), "event", toKebab(eventName));
|
|
246
|
-
const version = options
|
|
274
|
+
const version = options.version ?? 1;
|
|
247
275
|
if (!Number.isInteger(version) || version < 1) {
|
|
248
276
|
throw new Error(
|
|
249
277
|
`[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
|
|
250
278
|
);
|
|
251
279
|
}
|
|
252
|
-
const piiFields = options?.piiFields;
|
|
253
|
-
if (piiFields) {
|
|
254
|
-
validateEventPiiFields(eventName, schema, piiFields);
|
|
255
|
-
}
|
|
256
280
|
// @cast-boundary engine-bridge — runtime-string mirrors the
|
|
257
281
|
// template-literal-type via QualifiedEventName + toKebab. Both
|
|
258
282
|
// sides are tested (CamelToKebab type-tests + scan-events kebab
|
|
@@ -261,10 +285,10 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
261
285
|
name: qualified as QualifiedEventName<TName, TInner>,
|
|
262
286
|
schema,
|
|
263
287
|
version,
|
|
264
|
-
|
|
288
|
+
piiFields,
|
|
265
289
|
};
|
|
266
290
|
state.events[eventName] = def;
|
|
267
|
-
for (const m of options
|
|
291
|
+
for (const m of options.migrations ?? []) {
|
|
268
292
|
registerEventMigration(eventName, m.fromVersion, m.toVersion, m.transform);
|
|
269
293
|
}
|
|
270
294
|
return def;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
IncomingRelation,
|
|
3
|
+
RegistryState,
|
|
4
|
+
SearchableReferenceField,
|
|
5
|
+
SortableReferenceField,
|
|
6
|
+
} from "./registry-state";
|
|
2
7
|
import { filterByPhase, filterOwned } from "./registry-state";
|
|
3
8
|
import type {
|
|
4
9
|
AuthClaimsHookDef,
|
|
@@ -101,6 +106,10 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
101
106
|
return state.searchableReferencesCache.get(entityName) ?? [];
|
|
102
107
|
},
|
|
103
108
|
|
|
109
|
+
getSortableReferences(entityName: string): readonly SortableReferenceField[] {
|
|
110
|
+
return state.sortableReferencesCache.get(entityName) ?? [];
|
|
111
|
+
},
|
|
112
|
+
|
|
104
113
|
getIncomingRelations(entityName: string): readonly IncomingRelation[] {
|
|
105
114
|
return state.incomingRelationsCache.get(entityName) ?? [];
|
|
106
115
|
},
|
|
@@ -60,6 +60,12 @@ export type SearchableReferenceField = {
|
|
|
60
60
|
readonly labelField: string;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// One entry per `ReferenceFieldDef` with `sortable: true` (fw#2741). Same
|
|
64
|
+
// resolved shape as the searchable variant and the same boot guarantee on
|
|
65
|
+
// `labelField` — ORDER BY on the UUID column would look meaningful and be
|
|
66
|
+
// arbitrary.
|
|
67
|
+
export type SortableReferenceField = SearchableReferenceField;
|
|
68
|
+
|
|
63
69
|
const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
|
|
64
70
|
|
|
65
71
|
// Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
|
|
@@ -235,6 +241,7 @@ export type RegistryState = {
|
|
|
235
241
|
searchableFieldsCache: Map<string, readonly string[]>;
|
|
236
242
|
sortableFieldsCache: Map<string, readonly string[]>;
|
|
237
243
|
searchableReferencesCache: Map<string, readonly SearchableReferenceField[]>;
|
|
244
|
+
sortableReferencesCache: Map<string, readonly SortableReferenceField[]>;
|
|
238
245
|
incomingRelationsCache: Map<string, IncomingRelation[]>;
|
|
239
246
|
hasRateLimitedHandlerCached: boolean;
|
|
240
247
|
};
|
|
@@ -298,6 +305,7 @@ export function createInitialState(): RegistryState {
|
|
|
298
305
|
searchableFieldsCache: new Map(),
|
|
299
306
|
sortableFieldsCache: new Map(),
|
|
300
307
|
searchableReferencesCache: new Map(),
|
|
308
|
+
sortableReferencesCache: new Map(),
|
|
301
309
|
incomingRelationsCache: new Map(),
|
|
302
310
|
hasRateLimitedHandlerCached: false,
|
|
303
311
|
};
|