@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
|
@@ -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);
|
|
@@ -362,6 +362,190 @@ describe("r.screen() — registration", () => {
|
|
|
362
362
|
expect(() => validateBoot(features)).not.toThrow();
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
+
describe("relatedList search + facets (fw#2740)", () => {
|
|
366
|
+
test("rejects searchable: true when the bound query's schema has no search parameter", () => {
|
|
367
|
+
const features = [
|
|
368
|
+
defineFeature("app", (r) => {
|
|
369
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
370
|
+
access: { openToAll: true },
|
|
371
|
+
});
|
|
372
|
+
r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
373
|
+
access: { openToAll: true },
|
|
374
|
+
});
|
|
375
|
+
r.screen({
|
|
376
|
+
id: "x",
|
|
377
|
+
type: "projectionDetail",
|
|
378
|
+
query: "app:query:foo:detail",
|
|
379
|
+
layout: {
|
|
380
|
+
sections: [
|
|
381
|
+
{
|
|
382
|
+
kind: "relatedList",
|
|
383
|
+
title: "s",
|
|
384
|
+
query: "app:query:foo:list",
|
|
385
|
+
columns: ["name"],
|
|
386
|
+
searchable: true,
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}),
|
|
392
|
+
];
|
|
393
|
+
expect(() => validateBoot(features)).toThrow(/searchable: true.*"search"/);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("accepts searchable: true when the bound query's schema accepts search", () => {
|
|
397
|
+
const features = [
|
|
398
|
+
defineFeature("app", (r) => {
|
|
399
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
400
|
+
access: { openToAll: true },
|
|
401
|
+
});
|
|
402
|
+
r.queryHandler(
|
|
403
|
+
"foo:list",
|
|
404
|
+
z.object({ search: z.string().optional() }),
|
|
405
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
406
|
+
{ access: { openToAll: true } },
|
|
407
|
+
);
|
|
408
|
+
r.screen({
|
|
409
|
+
id: "x",
|
|
410
|
+
type: "projectionDetail",
|
|
411
|
+
query: "app:query:foo:detail",
|
|
412
|
+
layout: {
|
|
413
|
+
sections: [
|
|
414
|
+
{
|
|
415
|
+
kind: "relatedList",
|
|
416
|
+
title: "s",
|
|
417
|
+
query: "app:query:foo:list",
|
|
418
|
+
columns: ["name"],
|
|
419
|
+
searchable: true,
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}),
|
|
425
|
+
];
|
|
426
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("rejects facets when the bound query's schema has no filters parameter", () => {
|
|
430
|
+
const features = [
|
|
431
|
+
defineFeature("app", (r) => {
|
|
432
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
433
|
+
access: { openToAll: true },
|
|
434
|
+
});
|
|
435
|
+
r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
436
|
+
access: { openToAll: true },
|
|
437
|
+
});
|
|
438
|
+
r.screen({
|
|
439
|
+
id: "x",
|
|
440
|
+
type: "projectionDetail",
|
|
441
|
+
query: "app:query:foo:detail",
|
|
442
|
+
layout: {
|
|
443
|
+
sections: [
|
|
444
|
+
{
|
|
445
|
+
kind: "relatedList",
|
|
446
|
+
title: "s",
|
|
447
|
+
query: "app:query:foo:list",
|
|
448
|
+
columns: ["name", "status"],
|
|
449
|
+
facets: [
|
|
450
|
+
{
|
|
451
|
+
field: "status",
|
|
452
|
+
type: "select",
|
|
453
|
+
label: "Status",
|
|
454
|
+
options: [{ value: "active", label: "Active" }],
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}),
|
|
462
|
+
];
|
|
463
|
+
expect(() => validateBoot(features)).toThrow(/declares facets but query.*"filters"/);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("rejects a facet whose field is not a declared column", () => {
|
|
467
|
+
const features = [
|
|
468
|
+
defineFeature("app", (r) => {
|
|
469
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
470
|
+
access: { openToAll: true },
|
|
471
|
+
});
|
|
472
|
+
r.queryHandler(
|
|
473
|
+
"foo:list",
|
|
474
|
+
z.object({ filters: z.array(z.object({})).optional() }),
|
|
475
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
476
|
+
{ access: { openToAll: true } },
|
|
477
|
+
);
|
|
478
|
+
r.screen({
|
|
479
|
+
id: "x",
|
|
480
|
+
type: "projectionDetail",
|
|
481
|
+
query: "app:query:foo:detail",
|
|
482
|
+
layout: {
|
|
483
|
+
sections: [
|
|
484
|
+
{
|
|
485
|
+
kind: "relatedList",
|
|
486
|
+
title: "s",
|
|
487
|
+
query: "app:query:foo:list",
|
|
488
|
+
columns: ["name"],
|
|
489
|
+
facets: [
|
|
490
|
+
{
|
|
491
|
+
field: "status",
|
|
492
|
+
type: "select",
|
|
493
|
+
label: "Status",
|
|
494
|
+
options: [{ value: "active", label: "Active" }],
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}),
|
|
502
|
+
];
|
|
503
|
+
expect(() => validateBoot(features)).toThrow(
|
|
504
|
+
/relatedList section "s".*facet references field "status" which is not a declared column/,
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("accepts valid facets when the bound query's schema accepts filters", () => {
|
|
509
|
+
const features = [
|
|
510
|
+
defineFeature("app", (r) => {
|
|
511
|
+
r.queryHandler("foo:detail", z.object({}), async () => ({}), {
|
|
512
|
+
access: { openToAll: true },
|
|
513
|
+
});
|
|
514
|
+
r.queryHandler(
|
|
515
|
+
"foo:list",
|
|
516
|
+
z.object({ filters: z.array(z.object({})).optional() }),
|
|
517
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
518
|
+
{ access: { openToAll: true } },
|
|
519
|
+
);
|
|
520
|
+
r.screen({
|
|
521
|
+
id: "x",
|
|
522
|
+
type: "projectionDetail",
|
|
523
|
+
query: "app:query:foo:detail",
|
|
524
|
+
layout: {
|
|
525
|
+
sections: [
|
|
526
|
+
{
|
|
527
|
+
kind: "relatedList",
|
|
528
|
+
title: "s",
|
|
529
|
+
query: "app:query:foo:list",
|
|
530
|
+
columns: ["name", "status"],
|
|
531
|
+
facets: [
|
|
532
|
+
{
|
|
533
|
+
field: "status",
|
|
534
|
+
type: "select",
|
|
535
|
+
label: "Status",
|
|
536
|
+
options: [{ value: "active", label: "Active" }],
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
}),
|
|
544
|
+
];
|
|
545
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
365
549
|
test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
|
|
366
550
|
const features = [
|
|
367
551
|
defineFeature("shop", (r) => {
|
|
@@ -46,7 +46,10 @@ import {
|
|
|
46
46
|
} from "./nav";
|
|
47
47
|
import { collectClaimKeys, validateOwnershipRules } from "./ownership";
|
|
48
48
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
validateProjectionListScreens,
|
|
51
|
+
validateRelatedListSectionQueries,
|
|
52
|
+
} from "./projection-list-screens";
|
|
50
53
|
import { validateQueryOutputColumns } from "./query-output-columns";
|
|
51
54
|
import { validateQueryRefs } from "./query-refs";
|
|
52
55
|
import {
|
|
@@ -224,6 +227,7 @@ export function validateBoot(
|
|
|
224
227
|
// assume every `query` string already resolves to a registered handler.
|
|
225
228
|
validateQueryOutputColumns(features);
|
|
226
229
|
validateProjectionListScreens(features);
|
|
230
|
+
validateRelatedListSectionQueries(features);
|
|
227
231
|
validateExtensionPreSaveWiring(features);
|
|
228
232
|
validateGdprStoragePersistence(features);
|
|
229
233
|
validateFeatureBootChecks(features);
|