@cosmicdrift/kumiko-framework 0.248.0 → 0.250.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-helpers.test.ts +52 -0
- package/src/engine/boot-validator/action-wiring.ts +6 -5
- 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/screen-helpers.ts +2 -0
- 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
|
@@ -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
|
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
2
|
import { resolveName } from "./handler-helpers";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
RegistryState,
|
|
5
|
+
SearchableReferenceField,
|
|
6
|
+
SortableReferenceField,
|
|
7
|
+
} from "./registry-state";
|
|
4
8
|
import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
|
|
5
9
|
import {
|
|
6
10
|
buildSoftDeleteCleanupJob,
|
|
@@ -192,12 +196,43 @@ function buildSearchableReferenceField(
|
|
|
192
196
|
};
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
//
|
|
199
|
+
// fw#2741: ORDER BY runs against the referenced row's label column, so the
|
|
200
|
+
// same explicit-labelField requirement as searchable applies — ordering by the
|
|
201
|
+
// "id" default would be a UUID order that looks deliberate to the user.
|
|
202
|
+
function buildSortableReferenceField(
|
|
203
|
+
entityName: string,
|
|
204
|
+
fieldName: string,
|
|
205
|
+
field: ReferenceFieldDef,
|
|
206
|
+
): SortableReferenceField {
|
|
207
|
+
if (field.labelField === undefined || field.labelField === "id") {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`[Entity ${entityName}] field "${fieldName}": sortable reference fields require an ` +
|
|
210
|
+
`explicit, non-"id" labelField. Ordering by "id" is a UUID order — arbitrary to the ` +
|
|
211
|
+
`user while looking deliberate. Set labelField to a human-readable field on the ` +
|
|
212
|
+
`referenced entity.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (field.multiple === true) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`[Entity ${entityName}] field "${fieldName}": sortable is not supported on multiple ` +
|
|
218
|
+
`(array) reference fields — there is no single label to order by; remove "multiple" ` +
|
|
219
|
+
`or "sortable".`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
fieldName,
|
|
224
|
+
targetEntityName: parseReferenceTargetEntityName(field.entity),
|
|
225
|
+
labelField: field.labelField,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Precompute: searchable/sortable fields, searchable + sortable reference fields.
|
|
196
230
|
export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
197
231
|
for (const [name, entity] of state.entityMap) {
|
|
198
232
|
const searchable: string[] = [];
|
|
199
233
|
const sortable: string[] = [];
|
|
200
234
|
const searchableReferences: SearchableReferenceField[] = [];
|
|
235
|
+
const sortableReferences: SortableReferenceField[] = [];
|
|
201
236
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
202
237
|
if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
|
|
203
238
|
if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
|
|
@@ -209,10 +244,14 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
|
209
244
|
if (field.type === "reference" && field.searchable === true) {
|
|
210
245
|
searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
|
|
211
246
|
}
|
|
247
|
+
if (field.type === "reference" && field.sortable === true) {
|
|
248
|
+
sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
|
|
249
|
+
}
|
|
212
250
|
}
|
|
213
251
|
state.searchableFieldsCache.set(name, searchable);
|
|
214
252
|
state.sortableFieldsCache.set(name, sortable);
|
|
215
253
|
state.searchableReferencesCache.set(name, searchableReferences);
|
|
254
|
+
state.sortableReferencesCache.set(name, sortableReferences);
|
|
216
255
|
}
|
|
217
256
|
}
|
|
218
257
|
|
|
@@ -664,9 +703,21 @@ export function publishEventPiiCatalog(state: RegistryState): void {
|
|
|
664
703
|
// into kumiko_events — encrypts catalogued payload fields regardless of
|
|
665
704
|
// which path produced the event (ctx.appendEvent, MSP-apply, low-level
|
|
666
705
|
// append in delivery/jobs loggers).
|
|
706
|
+
//
|
|
707
|
+
// Defense-in-depth (fw#2558): defineEvent is the only producer of
|
|
708
|
+
// EventDef.piiFields and already fails closed without a stance, but this
|
|
709
|
+
// boot gate re-checks every registered event — a future producer that
|
|
710
|
+
// forgets to run through defineEvent would otherwise leave a hole where
|
|
711
|
+
// append() silently ships plaintext instead of refusing to boot.
|
|
667
712
|
const eventPiiCatalog = new Map<string, EventPiiFields>();
|
|
668
713
|
for (const [qualified, def] of state.eventMap) {
|
|
669
|
-
if (def.piiFields
|
|
714
|
+
if (def.piiFields === undefined) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`Registered event "${qualified}" has no PII stance. Every r.defineEvent(...) call must pass { piiFields: { ... } } or { piiFields: "none" }.`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (def.piiFields === "none") continue;
|
|
720
|
+
eventPiiCatalog.set(qualified, def.piiFields);
|
|
670
721
|
}
|
|
671
722
|
configureEventPiiCatalog(eventPiiCatalog);
|
|
672
723
|
}
|
|
@@ -56,6 +56,8 @@ export function evalFieldCondition(cond: FieldCondition, values: Record<string,
|
|
|
56
56
|
if (typeof cond === "boolean") return cond;
|
|
57
57
|
const val = values[cond.field];
|
|
58
58
|
if ("eq" in cond) return val === cond.eq;
|
|
59
|
+
if ("in" in cond) return cond.in.includes(val);
|
|
60
|
+
if ("notIn" in cond) return !cond.notIn.includes(val);
|
|
59
61
|
return val !== cond.ne;
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -25,7 +25,10 @@ const writeProbeResults: Array<{ isSuccess: boolean }> = [];
|
|
|
25
25
|
const writeProbeFailures: string[] = [];
|
|
26
26
|
|
|
27
27
|
const writeProbeFeature = defineFeature("writeProbe", (r) => {
|
|
28
|
-
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
28
|
+
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
29
|
+
piiFields: "none",
|
|
30
|
+
version: 1,
|
|
31
|
+
});
|
|
29
32
|
r.writeHandler(
|
|
30
33
|
"note",
|
|
31
34
|
z.object({ note: z.string() }),
|
|
@@ -23,7 +23,10 @@ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
|
23
23
|
import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
|
|
24
24
|
|
|
25
25
|
const splitFeature = defineFeature("split", (r) => {
|
|
26
|
-
const tick = r.defineEvent("tick", z.object({ note: z.string() }), {
|
|
26
|
+
const tick = r.defineEvent("tick", z.object({ note: z.string() }), {
|
|
27
|
+
piiFields: "none",
|
|
28
|
+
version: 1,
|
|
29
|
+
});
|
|
27
30
|
r.multiStreamProjection({
|
|
28
31
|
name: "spy",
|
|
29
32
|
apply: {
|
|
@@ -38,7 +41,10 @@ const splitFeature = defineFeature("split", (r) => {
|
|
|
38
41
|
const consumedNotes: string[] = [];
|
|
39
42
|
|
|
40
43
|
const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
41
|
-
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
44
|
+
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
45
|
+
piiFields: "none",
|
|
46
|
+
version: 1,
|
|
47
|
+
});
|
|
42
48
|
r.writeHandler(
|
|
43
49
|
"note",
|
|
44
50
|
z.object({ note: z.string() }),
|
|
@@ -76,6 +82,7 @@ const jobSawFilesRef: string[] = [];
|
|
|
76
82
|
|
|
77
83
|
const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
78
84
|
const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
|
|
85
|
+
piiFields: "none",
|
|
79
86
|
version: 1,
|
|
80
87
|
});
|
|
81
88
|
// Stands in for file-foundation, which this package cannot import.
|
|
@@ -310,7 +317,7 @@ describe("entrypoint factories", () => {
|
|
|
310
317
|
// process's lane are skipped during buildServer()".
|
|
311
318
|
|
|
312
319
|
const laneFeature = defineFeature("lane", (r) => {
|
|
313
|
-
const ping = r.defineEvent("ping", z.object({}), { version: 1 });
|
|
320
|
+
const ping = r.defineEvent("ping", z.object({}), { piiFields: "none", version: 1 });
|
|
314
321
|
// Three MSPs: one pinned to api, one to worker (explicit), one to both.
|
|
315
322
|
// A fourth would be a default-undefined-runIn MSP which resolves to
|
|
316
323
|
// "worker" — covered implicitly by the worker test below.
|
|
@@ -57,6 +57,7 @@ const orderFeature = defineFeature("upcastshop", (r) => {
|
|
|
57
57
|
"priced",
|
|
58
58
|
z.object({ totalCents: z.number().int(), currency: z.string() }),
|
|
59
59
|
{
|
|
60
|
+
piiFields: "none",
|
|
60
61
|
version: 3,
|
|
61
62
|
migrations: [
|
|
62
63
|
// v1 → v2: renamed totalEuros → total (kept as string for this step)
|
|
@@ -321,6 +322,7 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
|
|
|
321
322
|
"placed",
|
|
322
323
|
z.object({ customerId: z.string(), segment: z.string() }),
|
|
323
324
|
{
|
|
325
|
+
piiFields: "none",
|
|
324
326
|
version: 2,
|
|
325
327
|
migrations: [
|
|
326
328
|
{
|
|
@@ -407,6 +409,7 @@ describe("upcaster: boot-time validation", () => {
|
|
|
407
409
|
r.entity("hole-order", orderEntity);
|
|
408
410
|
// Only 1→2 registered — the 2→3 gap must be rejected.
|
|
409
411
|
r.defineEvent("bad", z.object({ v3: z.string() }), {
|
|
412
|
+
piiFields: "none",
|
|
410
413
|
version: 3,
|
|
411
414
|
migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
|
|
412
415
|
});
|
|
@@ -424,6 +427,7 @@ describe("upcaster: boot-time validation", () => {
|
|
|
424
427
|
const future = defineFeature("future", (r) => {
|
|
425
428
|
r.entity("future-order", orderEntity);
|
|
426
429
|
r.defineEvent("early", z.object({ x: z.number() }), {
|
|
430
|
+
piiFields: "none",
|
|
427
431
|
version: 1,
|
|
428
432
|
migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
|
|
429
433
|
});
|
|
@@ -435,6 +439,7 @@ describe("upcaster: boot-time validation", () => {
|
|
|
435
439
|
const gaps = defineFeature("gaps", (r) => {
|
|
436
440
|
r.entity("gap-order", orderEntity);
|
|
437
441
|
r.defineEvent("jumpy", z.object({ v: z.number() }), {
|
|
442
|
+
piiFields: "none",
|
|
438
443
|
version: 4,
|
|
439
444
|
migrations: [
|
|
440
445
|
{ fromVersion: 1, toVersion: 2, transform: (p) => p },
|
|
@@ -452,6 +457,7 @@ describe("upcaster: registrar input validation", () => {
|
|
|
452
457
|
defineFeature("bigstep", (r) => {
|
|
453
458
|
r.entity("bigstep-order", orderEntity);
|
|
454
459
|
r.defineEvent("biz", z.object({ x: z.number() }), {
|
|
460
|
+
piiFields: "none",
|
|
455
461
|
version: 3,
|
|
456
462
|
migrations: [{ fromVersion: 1, toVersion: 3, transform: (p) => p }],
|
|
457
463
|
});
|
|
@@ -464,6 +470,7 @@ describe("upcaster: registrar input validation", () => {
|
|
|
464
470
|
defineFeature("dupestep", (r) => {
|
|
465
471
|
r.entity("dup-order", orderEntity);
|
|
466
472
|
r.defineEvent("dup", z.object({ x: z.number() }), {
|
|
473
|
+
piiFields: "none",
|
|
467
474
|
version: 2,
|
|
468
475
|
migrations: [
|
|
469
476
|
{ fromVersion: 1, toVersion: 2, transform: (p) => p },
|
|
@@ -478,7 +485,7 @@ describe("upcaster: registrar input validation", () => {
|
|
|
478
485
|
expect(() =>
|
|
479
486
|
defineFeature("badver", (r) => {
|
|
480
487
|
r.entity("badver-order", orderEntity);
|
|
481
|
-
r.defineEvent("neg", z.object({ x: z.number() }), { version: 0 });
|
|
488
|
+
r.defineEvent("neg", z.object({ x: z.number() }), { piiFields: "none", version: 0 });
|
|
482
489
|
}),
|
|
483
490
|
).toThrow(/positive integer/);
|
|
484
491
|
});
|