@cosmicdrift/kumiko-framework 0.249.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/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
|
@@ -300,6 +300,69 @@ async function resolveReferenceMatches(
|
|
|
300
300
|
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
// Projected alongside the row when a reference sort is active, so the keyset
|
|
304
|
+
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
305
|
+
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
306
|
+
|
|
307
|
+
// fw#2741 — resolve a `sortable: true` reference field's label inline for
|
|
308
|
+
// ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
|
|
309
|
+
// row per entity row, the read model stays untouched, and the lookup rides the
|
|
310
|
+
// target's primary key. Tenant-scoped exactly like the search path — a foreign
|
|
311
|
+
// tenant's label must never influence this tenant's ordering.
|
|
312
|
+
// undefined means "cannot order by this label", which drops the sort to plain
|
|
313
|
+
// id order; falling back to the UUID column would look deliberate to the user
|
|
314
|
+
// while being arbitrary, which the issue calls worse than no sorting.
|
|
315
|
+
function buildReferenceSortExpr(
|
|
316
|
+
outerTableName: string,
|
|
317
|
+
ownColumnSql: string,
|
|
318
|
+
descriptor: { readonly targetEntityName: string; readonly labelField: string },
|
|
319
|
+
resolveEntity: (entityName: string) => EntityDefinition | undefined,
|
|
320
|
+
db: TenantDb,
|
|
321
|
+
): ((params: unknown[]) => string) | undefined {
|
|
322
|
+
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
323
|
+
if (targetEntity === undefined) {
|
|
324
|
+
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
325
|
+
// should have caught; drop the sort rather than 500 the request
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
if (targetEntity.fields[descriptor.labelField] === undefined) {
|
|
329
|
+
// skip: labelField isn't a real (declared) column on the target entity
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
|
|
334
|
+
collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
|
|
335
|
+
) {
|
|
336
|
+
// skip: encrypted/PII labelField — ciphertext orders by its bytes, which is
|
|
337
|
+
// arbitrary to the reader and would also rank rows by their stored secret
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
342
|
+
const targetTableName = String(
|
|
343
|
+
(targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
|
|
344
|
+
);
|
|
345
|
+
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
346
|
+
const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
|
|
347
|
+
const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
|
|
348
|
+
const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
|
|
349
|
+
|
|
350
|
+
// No ::text cast (unlike the ILIKE search path): a non-text label must keep
|
|
351
|
+
// its native collation/numeric order, and the keyset param infers its type
|
|
352
|
+
// from the comparison anyway.
|
|
353
|
+
return (params: unknown[]): string => {
|
|
354
|
+
let tenantClause = "";
|
|
355
|
+
if (tenantScoped) {
|
|
356
|
+
params.push(tenantId, SYSTEM_TENANT_ID);
|
|
357
|
+
tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
|
|
358
|
+
}
|
|
359
|
+
return (
|
|
360
|
+
`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
|
|
361
|
+
`WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause})`
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
303
366
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
304
367
|
const {
|
|
305
368
|
table,
|
|
@@ -431,12 +494,35 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
431
494
|
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
432
495
|
}
|
|
433
496
|
|
|
434
|
-
const
|
|
497
|
+
const sortCandidate =
|
|
435
498
|
payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
|
|
436
499
|
? payload.sort
|
|
437
500
|
: undefined;
|
|
438
501
|
const sortDescending = payload.sortDirection === "desc";
|
|
439
502
|
|
|
503
|
+
// fw#2741 — a reference column stores a UUID, so it is never sorted by
|
|
504
|
+
// directly: either the field opted into `sortable` and we order by the
|
|
505
|
+
// target row's label, or the sort is dropped to plain id order.
|
|
506
|
+
let sortField: string | undefined;
|
|
507
|
+
let referenceSortExpr: ((params: unknown[]) => string) | undefined;
|
|
508
|
+
if (sortCandidate !== undefined && entity.fields[sortCandidate]?.type === "reference") {
|
|
509
|
+
const referenceSort = runtimeOptions?.referenceSort;
|
|
510
|
+
const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
|
|
511
|
+
referenceSortExpr =
|
|
512
|
+
referenceSort && descriptor
|
|
513
|
+
? buildReferenceSortExpr(
|
|
514
|
+
tableName,
|
|
515
|
+
colSql(sortCandidate),
|
|
516
|
+
descriptor,
|
|
517
|
+
referenceSort.resolveEntity,
|
|
518
|
+
db,
|
|
519
|
+
)
|
|
520
|
+
: undefined;
|
|
521
|
+
if (referenceSortExpr !== undefined) sortField = sortCandidate;
|
|
522
|
+
} else {
|
|
523
|
+
sortField = sortCandidate;
|
|
524
|
+
}
|
|
525
|
+
|
|
440
526
|
// Tenant-Filter (replicates TenantDb's readWhere semantics).
|
|
441
527
|
if (table["tenantId"] !== undefined && db.mode === "tenant") {
|
|
442
528
|
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
@@ -453,7 +539,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
453
539
|
} else {
|
|
454
540
|
whereSql.push(
|
|
455
541
|
keysetBoundarySql(
|
|
456
|
-
colSql(sortField),
|
|
542
|
+
referenceSortExpr !== undefined ? referenceSortExpr(params) : colSql(sortField),
|
|
457
543
|
colSql("id"),
|
|
458
544
|
{ id: cursor.id, sortValue: cursor.sortValue },
|
|
459
545
|
sortDescending,
|
|
@@ -501,17 +587,40 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
501
587
|
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
502
588
|
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
503
589
|
|
|
590
|
+
// The COUNT below reuses the WHERE text only, so it must bind exactly the
|
|
591
|
+
// params that text references — Postgres rejects a statement supplied
|
|
592
|
+
// with more parameters than it uses, and ORDER BY may add its own.
|
|
593
|
+
const whereParams = params.slice();
|
|
594
|
+
|
|
595
|
+
const sortSql =
|
|
596
|
+
sortField === undefined
|
|
597
|
+
? undefined
|
|
598
|
+
: referenceSortExpr !== undefined
|
|
599
|
+
? referenceSortExpr(params)
|
|
600
|
+
: colSql(sortField);
|
|
504
601
|
const orderByClause =
|
|
505
|
-
|
|
506
|
-
? ` ORDER BY ${
|
|
602
|
+
sortSql !== undefined
|
|
603
|
+
? ` ORDER BY ${sortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
|
|
507
604
|
: ` ORDER BY ${colSql("id")} ASC`;
|
|
508
605
|
const useOffset = !payload.cursor && offset > 0;
|
|
509
606
|
const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
|
|
510
607
|
|
|
511
608
|
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
512
|
-
|
|
609
|
+
// Project the resolved label too, so the keyset cursor below carries the
|
|
610
|
+
// value that was ordered by rather than the FK column's UUID.
|
|
611
|
+
const selectList =
|
|
612
|
+
referenceSortExpr !== undefined && sortSql !== undefined
|
|
613
|
+
? `*, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
|
|
614
|
+
: "*";
|
|
615
|
+
const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
513
616
|
|
|
514
617
|
const rawRows = await executeRawQueryRead<Record<string, unknown>>(db.raw, listSql, params);
|
|
618
|
+
const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
|
|
619
|
+
if (referenceSortExpr !== undefined) {
|
|
620
|
+
// The alias is a sort artifact, not a column of this entity — strip it
|
|
621
|
+
// before coercion so it never reaches the caller or the entity cache.
|
|
622
|
+
for (const row of rawRows) delete row[SORT_LABEL_ALIAS];
|
|
623
|
+
}
|
|
515
624
|
// Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
|
|
516
625
|
// Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
|
|
517
626
|
// column names, while compound-type lookups (rehydrateMoney et al.) and the
|
|
@@ -542,7 +651,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
542
651
|
if (rows.length === limit && lastRow && lastRaw) {
|
|
543
652
|
const cursorId = lastRow["id"] as string; // @cast-boundary engine-payload
|
|
544
653
|
const sortText =
|
|
545
|
-
sortField === undefined
|
|
654
|
+
sortField === undefined
|
|
655
|
+
? undefined
|
|
656
|
+
: toCursorSortText(
|
|
657
|
+
referenceSortExpr !== undefined ? lastSortLabel : lastRaw[physicalCol(sortField)],
|
|
658
|
+
);
|
|
546
659
|
nextCursor =
|
|
547
660
|
sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
548
661
|
}
|
|
@@ -559,7 +672,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
559
672
|
total = filterIds.length;
|
|
560
673
|
} else {
|
|
561
674
|
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
562
|
-
const countRows = await executeRawQueryRead<{ count: number }>(
|
|
675
|
+
const countRows = await executeRawQueryRead<{ count: number }>(
|
|
676
|
+
db.raw,
|
|
677
|
+
countSql,
|
|
678
|
+
whereParams,
|
|
679
|
+
);
|
|
563
680
|
total = countRows[0]?.count ?? 0;
|
|
564
681
|
}
|
|
565
682
|
}
|
package/src/db/table-builder.ts
CHANGED
|
@@ -572,6 +572,16 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
572
572
|
// Pass through to the IndexBuilderWithCols.where()-API.
|
|
573
573
|
if (def.where !== undefined) {
|
|
574
574
|
chain = chain.where(def.where as SqlExpression);
|
|
575
|
+
} else if (def.unique === true && entity.softDelete === true) {
|
|
576
|
+
// Soft-deleted rows keep their value, so a full unique index would
|
|
577
|
+
// permanently block reuse; kept in lock-step with entity-table-meta.ts (framework#2593).
|
|
578
|
+
const softDeleteWhere: SqlExpression = {
|
|
579
|
+
kind: "sql-expr",
|
|
580
|
+
text: `"is_deleted" = false`,
|
|
581
|
+
params: [],
|
|
582
|
+
[SQL_EXPR_BRAND]: true,
|
|
583
|
+
};
|
|
584
|
+
chain = chain.where(softDeleteWhere);
|
|
575
585
|
}
|
|
576
586
|
indexes[indexName] = chain;
|
|
577
587
|
// Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
|
|
@@ -150,7 +150,9 @@ describe("defineFeature", () => {
|
|
|
150
150
|
test("r.defineEvent returns typed EventDef and registers on feature", () => {
|
|
151
151
|
let eventRef: { name: string } | undefined;
|
|
152
152
|
const feature = defineFeature("orders", (r) => {
|
|
153
|
-
eventRef = r.defineEvent("order:created", z.object({ orderId: z.number() })
|
|
153
|
+
eventRef = r.defineEvent("order:created", z.object({ orderId: z.number() }), {
|
|
154
|
+
piiFields: "none",
|
|
155
|
+
});
|
|
154
156
|
});
|
|
155
157
|
|
|
156
158
|
// E.3: defineEvent returns the fully-qualified name so callers can
|
|
@@ -185,7 +187,7 @@ describe("defineFeature", () => {
|
|
|
185
187
|
|
|
186
188
|
test("registry prefixes event names with feature name", () => {
|
|
187
189
|
const feature = defineFeature("orders", (r) => {
|
|
188
|
-
r.defineEvent("order:created", z.object({ orderId: z.number() }));
|
|
190
|
+
r.defineEvent("order:created", z.object({ orderId: z.number() }), { piiFields: "none" });
|
|
189
191
|
});
|
|
190
192
|
const registry = createRegistry([feature]);
|
|
191
193
|
expect(registry.getEvent("orders:event:order:created")).toBeDefined();
|
|
@@ -698,6 +700,134 @@ describe("createRegistry", () => {
|
|
|
698
700
|
|
|
699
701
|
expect(() => createRegistry([feature])).toThrow(/multiple/);
|
|
700
702
|
});
|
|
703
|
+
|
|
704
|
+
test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
|
|
705
|
+
const feature = defineFeature("crm", (r) => {
|
|
706
|
+
r.entity(
|
|
707
|
+
"customer",
|
|
708
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
709
|
+
);
|
|
710
|
+
r.entity(
|
|
711
|
+
"order",
|
|
712
|
+
createEntity({
|
|
713
|
+
table: "Orders",
|
|
714
|
+
fields: {
|
|
715
|
+
note: createTextField({ sortable: true }),
|
|
716
|
+
customerId: {
|
|
717
|
+
type: "reference",
|
|
718
|
+
entity: "customer",
|
|
719
|
+
labelField: "name",
|
|
720
|
+
sortable: true,
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const registry = createRegistry([feature]);
|
|
728
|
+
// getSortableFields lists plain columns the read path can ORDER BY
|
|
729
|
+
// directly — a reference sorts through its target's label instead.
|
|
730
|
+
expect(registry.getSortableFields("order")).toEqual(["note"]);
|
|
731
|
+
expect(registry.getSortableReferences("order")).toEqual([
|
|
732
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
733
|
+
]);
|
|
734
|
+
expect(registry.getSortableReferences("nonexistent")).toEqual([]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("resolves a cross-feature sortable reference to its bare entity name (fw#2741)", () => {
|
|
738
|
+
const users = defineFeature("users", (r) => {
|
|
739
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
740
|
+
});
|
|
741
|
+
const crm = defineFeature("crm", (r) => {
|
|
742
|
+
r.entity(
|
|
743
|
+
"ticket",
|
|
744
|
+
createEntity({
|
|
745
|
+
table: "Tickets",
|
|
746
|
+
fields: {
|
|
747
|
+
ownerId: {
|
|
748
|
+
type: "reference",
|
|
749
|
+
entity: "users:user",
|
|
750
|
+
labelField: "email",
|
|
751
|
+
sortable: true,
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
}),
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
const registry = createRegistry([users, crm]);
|
|
759
|
+
expect(registry.getSortableReferences("ticket")).toEqual([
|
|
760
|
+
{ fieldName: "ownerId", targetEntityName: "user", labelField: "email" },
|
|
761
|
+
]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
|
|
765
|
+
const feature = defineFeature("crm", (r) => {
|
|
766
|
+
r.entity(
|
|
767
|
+
"customer",
|
|
768
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
769
|
+
);
|
|
770
|
+
r.entity(
|
|
771
|
+
"order",
|
|
772
|
+
createEntity({
|
|
773
|
+
table: "Orders",
|
|
774
|
+
fields: {
|
|
775
|
+
customerId: { type: "reference", entity: "customer", sortable: true },
|
|
776
|
+
},
|
|
777
|
+
}),
|
|
778
|
+
);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
expect(() => createRegistry([feature])).toThrow(/labelField/);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("throws at boot when a sortable reference field uses labelField: 'id' (fw#2741)", () => {
|
|
785
|
+
const feature = defineFeature("crm", (r) => {
|
|
786
|
+
r.entity(
|
|
787
|
+
"customer",
|
|
788
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
789
|
+
);
|
|
790
|
+
r.entity(
|
|
791
|
+
"order",
|
|
792
|
+
createEntity({
|
|
793
|
+
table: "Orders",
|
|
794
|
+
fields: {
|
|
795
|
+
customerId: {
|
|
796
|
+
type: "reference",
|
|
797
|
+
entity: "customer",
|
|
798
|
+
labelField: "id",
|
|
799
|
+
sortable: true,
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
}),
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
expect(() => createRegistry([feature])).toThrow(/labelField/);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("throws at boot when sortable is combined with multiple on a reference field (fw#2741)", () => {
|
|
810
|
+
const feature = defineFeature("crm", (r) => {
|
|
811
|
+
r.entity("tag", createEntity({ table: "Tags", fields: { name: createTextField() } }));
|
|
812
|
+
r.entity(
|
|
813
|
+
"post",
|
|
814
|
+
createEntity({
|
|
815
|
+
table: "Posts",
|
|
816
|
+
fields: {
|
|
817
|
+
tagIds: {
|
|
818
|
+
type: "reference",
|
|
819
|
+
entity: "tag",
|
|
820
|
+
labelField: "name",
|
|
821
|
+
sortable: true,
|
|
822
|
+
multiple: true,
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
expect(() => createRegistry([feature])).toThrow(/multiple/);
|
|
830
|
+
});
|
|
701
831
|
});
|
|
702
832
|
|
|
703
833
|
// --- Access ---
|
|
@@ -9,6 +9,7 @@ const upcastCtx = {} as EventUpcastCtx;
|
|
|
9
9
|
function compile(spec: DeclarativeEventMigration) {
|
|
10
10
|
const feature = defineFeature("billing", (r) => {
|
|
11
11
|
r.defineEvent("invoicePaid", z.unknown(), {
|
|
12
|
+
piiFields: "none",
|
|
12
13
|
version: 2,
|
|
13
14
|
migrations: [{ fromVersion: 1, toVersion: 2, transform: spec }],
|
|
14
15
|
});
|
|
@@ -56,6 +57,7 @@ describe("declarative eventMigration", () => {
|
|
|
56
57
|
const fn = (payload: unknown) => payload;
|
|
57
58
|
const feature = defineFeature("billing", (r) => {
|
|
58
59
|
r.defineEvent("invoicePaid", z.unknown(), {
|
|
60
|
+
piiFields: "none",
|
|
59
61
|
version: 2,
|
|
60
62
|
migrations: [{ fromVersion: 1, toVersion: 2, transform: fn }],
|
|
61
63
|
});
|
|
@@ -21,7 +21,9 @@ describe("r.extendEntityProjection — registration", () => {
|
|
|
21
21
|
test("merges apply keys + extraSources into the implicit projection", () => {
|
|
22
22
|
const feature = defineFeature("test", (r) => {
|
|
23
23
|
r.entity("unit", exampleEntity());
|
|
24
|
-
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() })
|
|
24
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
|
|
25
|
+
piiFields: "none",
|
|
26
|
+
});
|
|
25
27
|
r.extendEntityProjection("unit", {
|
|
26
28
|
sources: ["field-definition"],
|
|
27
29
|
apply: { [setEvent.name]: noopApply },
|
|
@@ -39,7 +41,9 @@ describe("r.extendEntityProjection — registration", () => {
|
|
|
39
41
|
test("extension source equal to the entity name is not duplicated into extraSources", () => {
|
|
40
42
|
const feature = defineFeature("test", (r) => {
|
|
41
43
|
r.entity("unit", exampleEntity());
|
|
42
|
-
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() })
|
|
44
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
|
|
45
|
+
piiFields: "none",
|
|
46
|
+
});
|
|
43
47
|
r.extendEntityProjection("unit", {
|
|
44
48
|
sources: ["unit"],
|
|
45
49
|
apply: { [setEvent.name]: noopApply },
|
|
@@ -66,7 +70,9 @@ describe("r.extendEntityProjection — registration", () => {
|
|
|
66
70
|
|
|
67
71
|
test("registration order is free: extension before r.entity in the same feature", () => {
|
|
68
72
|
const feature = defineFeature("test", (r) => {
|
|
69
|
-
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() })
|
|
73
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
|
|
74
|
+
piiFields: "none",
|
|
75
|
+
});
|
|
70
76
|
r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
|
|
71
77
|
r.entity("unit", exampleEntity());
|
|
72
78
|
});
|
|
@@ -89,7 +95,9 @@ describe("r.extendEntityProjection — registry-build validation", () => {
|
|
|
89
95
|
test("unknown entity fails at registry build", () => {
|
|
90
96
|
const feature = defineFeature("test", (r) => {
|
|
91
97
|
r.entity("unit", exampleEntity());
|
|
92
|
-
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() })
|
|
98
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
|
|
99
|
+
piiFields: "none",
|
|
100
|
+
});
|
|
93
101
|
r.extendEntityProjection("typo-entity", { apply: { [setEvent.name]: noopApply } });
|
|
94
102
|
});
|
|
95
103
|
expect(() => createRegistry([feature])).toThrow(/no r\.entity/);
|
|
@@ -106,7 +114,9 @@ describe("r.extendEntityProjection — registry-build validation", () => {
|
|
|
106
114
|
test("apply-key collision between two extensions fails at registry build", () => {
|
|
107
115
|
const feature = defineFeature("test", (r) => {
|
|
108
116
|
r.entity("unit", exampleEntity());
|
|
109
|
-
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() })
|
|
117
|
+
const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
|
|
118
|
+
piiFields: "none",
|
|
119
|
+
});
|
|
110
120
|
r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
|
|
111
121
|
r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
|
|
112
122
|
});
|
|
@@ -454,7 +454,7 @@ const bulkLogHandler = defineWriteHandler({
|
|
|
454
454
|
const demoPipelineFeature = defineFeature("demoPipeline", (r) => {
|
|
455
455
|
r.requires.projection("pipeline_demo_log");
|
|
456
456
|
r.entity("widget", widgetEntity);
|
|
457
|
-
r.defineEvent("annotated", z.object({ note: z.string() }));
|
|
457
|
+
r.defineEvent("annotated", z.object({ note: z.string() }), { piiFields: "none" });
|
|
458
458
|
r.writeHandler(echoHandler);
|
|
459
459
|
r.writeHandler(explodeHandler);
|
|
460
460
|
r.writeHandler(compoundHandler);
|
|
@@ -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.
|