@cosmicdrift/kumiko-framework 0.273.0 → 0.275.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.
Files changed (30) hide show
  1. package/package.json +3 -3
  2. package/src/changes.json +28 -7
  3. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  4. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  5. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  6. package/src/db/entity-field-encryption.ts +18 -0
  7. package/src/db/event-store-executor-context.ts +7 -3
  8. package/src/db/event-store-executor-read.ts +120 -34
  9. package/src/engine/__tests__/boot-validator.test.ts +20 -1
  10. package/src/engine/__tests__/dedupe-features.test.ts +85 -0
  11. package/src/engine/__tests__/engine.test.ts +86 -0
  12. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  13. package/src/engine/__tests__/soft-delete-cleanup-job-tenant-db.integration.test.ts +82 -0
  14. package/src/engine/boot-validator/index.ts +3 -1
  15. package/src/engine/boot-validator/projection-list-screens.ts +2 -2
  16. package/src/engine/create-app.ts +6 -4
  17. package/src/engine/dedupe-features.ts +46 -0
  18. package/src/engine/define-feature.ts +6 -0
  19. package/src/engine/entity-handlers.ts +3 -3
  20. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +2 -2
  21. package/src/engine/feature-config-events-jobs.ts +11 -2
  22. package/src/engine/index.ts +1 -0
  23. package/src/engine/registry-validate.ts +24 -2
  24. package/src/engine/registry.ts +3 -1
  25. package/src/engine/screen-filter-ops.ts +10 -2
  26. package/src/engine/soft-delete-cleanup.ts +4 -3
  27. package/src/jobs/__tests__/job-systemdb.integration.test.ts +1 -1
  28. package/src/jobs/__tests__/job-tenant-db.integration.test.ts +162 -0
  29. package/src/jobs/__tests__/jobs.integration.test.ts +3 -2
  30. package/src/jobs/job-runner.ts +18 -15
@@ -9,14 +9,19 @@ import {
9
9
  normalizeAccessEntry,
10
10
  shiftParams,
11
11
  } from "../engine/ownership";
12
- import type { EntityDefinition, EntityId } from "../engine/types";
12
+ import type { EntityDefinition, EntityId, SessionUser } from "../engine/types";
13
13
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
14
14
  import { UnprocessableError } from "../errors";
15
15
  import { getStreamVersion } from "../event-store";
16
+ import type { SearchAdapter } from "../search/types";
16
17
  import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta";
17
18
  import { rehydrateCompoundTypes } from "./compound-types";
18
19
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
19
- import { collectEncryptedFieldNames } from "./entity-field-encryption";
20
+ import {
21
+ collectEncryptedFieldNames,
22
+ hasSearchablePlaintext,
23
+ isSensitiveLabelField,
24
+ } from "./entity-field-encryption";
20
25
  import type { EventStoreExecutor } from "./event-store-executor";
21
26
  import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
22
27
  import { buildParentRefClause } from "./parent-ref-clause";
@@ -104,26 +109,27 @@ function keysetBoundarySql(
104
109
 
105
110
  type ListFilter = {
106
111
  readonly field: string;
107
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
112
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
108
113
  readonly value: unknown;
109
114
  };
110
115
 
111
116
  // multiSelect stores its options as a jsonb array — a filter value is one
112
117
  // option, not the whole array, so eq/ne/in must check array containment
113
118
  // (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column (fw#2490).
114
- // lt/gt have no containment analogue; the boot-validator already blocks them
115
- // for screen-declared filters (screen-filter-ops.ts EQUALITY_ONLY), but a
116
- // client-supplied facet filter reaches here unvalidated, so treat it as
117
- // unsatisfiable rather than emitting SQL Postgres would reject.
119
+ // lt/gt/lte/gte have no containment analogue; the boot-validator already
120
+ // blocks them for screen-declared filters (screen-filter-ops.ts
121
+ // EQUALITY_ONLY), but a client-supplied facet filter reaches here
122
+ // unvalidated, so treat it as unsatisfiable rather than emitting SQL
123
+ // Postgres would reject.
118
124
  function applyMultiSelectFilter(
119
125
  colSql: (field: string) => string,
120
126
  whereSql: string[],
121
127
  params: unknown[],
122
128
  f: ListFilter,
123
129
  ): void {
124
- if (f.op === "lt" || f.op === "gt") {
130
+ if (f.op === "lt" || f.op === "gt" || f.op === "lte" || f.op === "gte") {
125
131
  whereSql.push("FALSE");
126
- // skip: lt/gt is unsatisfiable on a multiSelect column
132
+ // skip: lt/gt/lte/gte is unsatisfiable on a multiSelect column
127
133
  return;
128
134
  }
129
135
  if (f.op === "in") {
@@ -290,11 +296,86 @@ function collectReferenceSearchDescriptors(
290
296
  // unscoped SQL, a match in a foreign tenant must never surface a row here
291
297
  // (fw#2660's hard constraint). Mirrors the main table's own tenant filter
292
298
  // (see the `table["tenantId"]` check further down in list()).
299
+ async function resolveReferenceMatchesViaIlike(
300
+ descriptor: ReferenceSearchDescriptor,
301
+ searchTerm: string,
302
+ targetTable: Table,
303
+ targetTableName: string,
304
+ db: TenantDb,
305
+ ): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
306
+ const labelCol = physicalColumnName(targetTable, descriptor.labelField);
307
+ const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
308
+ let tenantClause = "";
309
+ if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
310
+ subParams.push(db.tenantId, SYSTEM_TENANT_ID);
311
+ tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
312
+ }
313
+ // ::text cast covers a non-text labelField (e.g. a number/select column
314
+ // used as label) — Postgres has no ILIKE for those types otherwise.
315
+ const sql =
316
+ `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
317
+ `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
318
+ const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
319
+ if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
320
+ // skip: no match, or over the cap — drop the clause rather than
321
+ // truncate to an arbitrary slice of matching targets
322
+ return undefined;
323
+ }
324
+ return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
325
+ }
326
+
327
+ // Same lookup, but the target's index is tenant-wide, not per-viewer, so
328
+ // candidate ids get re-checked against the target entity's own read access.
329
+ async function resolveReferenceMatchesViaSearchIndex(
330
+ descriptor: ReferenceSearchDescriptor,
331
+ searchTerm: string,
332
+ targetEntity: EntityDefinition,
333
+ targetTable: Table,
334
+ targetTableName: string,
335
+ user: SessionUser,
336
+ db: TenantDb,
337
+ searchAdapter: SearchAdapter,
338
+ ): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
339
+ const searchTenantId = targetEntity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
340
+ const results = await searchAdapter.search(searchTenantId, searchTerm, {
341
+ filterType: descriptor.targetEntityName,
342
+ limit: MAX_REFERENCE_SEARCH_IDS + 1,
343
+ });
344
+ if (results.length === 0 || results.length > MAX_REFERENCE_SEARCH_IDS) {
345
+ // skip: no match, or over the cap — same drop-the-clause policy as the ILIKE path
346
+ return undefined;
347
+ }
348
+
349
+ const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
350
+ if (ownership.kind === "empty") return undefined;
351
+
352
+ const candidateIds = results.map((r) => String(r.entityId));
353
+ if (ownership.kind === "pass") {
354
+ return { ownColumn: descriptor.ownColumn, ids: candidateIds };
355
+ }
356
+
357
+ const params: unknown[] = [...candidateIds];
358
+ const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
359
+ const shifted = shiftParams(
360
+ { sqlText: ownership.sqlText, params: ownership.params },
361
+ params.length,
362
+ );
363
+ const sql =
364
+ `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")}) ` +
365
+ `AND ${shifted.sqlText}`;
366
+ for (const p of shifted.params) params.push(p);
367
+ const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, params);
368
+ if (rows.length === 0) return undefined;
369
+ return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
370
+ }
371
+
293
372
  async function resolveReferenceMatches(
294
373
  descriptor: ReferenceSearchDescriptor,
295
374
  searchTerm: string,
296
375
  resolveEntity: (entityName: string) => EntityDefinition | undefined,
297
376
  db: TenantDb,
377
+ user: SessionUser,
378
+ searchAdapter: SearchAdapter,
298
379
  ): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
299
380
  const targetEntity = resolveEntity(descriptor.targetEntityName);
300
381
  if (targetEntity === undefined) {
@@ -308,38 +389,36 @@ async function resolveReferenceMatches(
308
389
  // `name` is a declared field on the tenant entity, not an id/row-meta column
309
390
  return undefined;
310
391
  }
311
- if (
312
- collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
313
- collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
314
- ) {
315
- // skip: encrypted/PII labelField — ILIKE can never match ciphertext,
316
- // emitting the query would just be a silent, permanent non-match
317
- return undefined;
318
- }
319
392
 
320
393
  const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
321
394
  const targetTableName = String(
322
395
  (targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
323
396
  );
324
- const labelCol = physicalColumnName(targetTable, descriptor.labelField);
325
- const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
326
- let tenantClause = "";
327
- if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
328
- subParams.push(db.tenantId, SYSTEM_TENANT_ID);
329
- tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
397
+
398
+ if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
399
+ return resolveReferenceMatchesViaIlike(
400
+ descriptor,
401
+ searchTerm,
402
+ targetTable,
403
+ targetTableName,
404
+ db,
405
+ );
330
406
  }
331
- // ::text cast covers a non-text labelField (e.g. a number/select column
332
- // used as label) — Postgres has no ILIKE for those types otherwise.
333
- const sql =
334
- `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
335
- `LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
336
- const rows = await executeRawQueryRead<{ id: string }>(tenantDbRunner(db), sql, subParams);
337
- if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
338
- // skip: no match, or over the cap — drop the clause rather than
339
- // truncate to an arbitrary slice of matching targets
407
+ if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
408
+ // skip: no plaintext anywhere to match — boot rejects this normally,
409
+ // this is defense-in-depth for a target entity resolved only at runtime.
340
410
  return undefined;
341
411
  }
342
- return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
412
+ return resolveReferenceMatchesViaSearchIndex(
413
+ descriptor,
414
+ searchTerm,
415
+ targetEntity,
416
+ targetTable,
417
+ targetTableName,
418
+ user,
419
+ db,
420
+ searchAdapter,
421
+ );
343
422
  }
344
423
 
345
424
  // Projected alongside the row when a reference sort is active, so the keyset
@@ -517,7 +596,14 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
517
596
  ? (
518
597
  await Promise.all(
519
598
  descriptors.map((d) =>
520
- resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db),
599
+ resolveReferenceMatches(
600
+ d,
601
+ searchTerm,
602
+ referenceSearch.resolveEntity,
603
+ db,
604
+ user,
605
+ effectiveSearchAdapter,
606
+ ),
521
607
  ),
522
608
  )
523
609
  ).filter(
@@ -2306,7 +2306,7 @@ describe("boot-validator", () => {
2306
2306
  function makeFeature(
2307
2307
  filter: {
2308
2308
  readonly field: string;
2309
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
2309
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
2310
2310
  readonly value: unknown;
2311
2311
  },
2312
2312
  fields: Record<string, unknown> = {
@@ -2365,6 +2365,25 @@ describe("boot-validator", () => {
2365
2365
  ).not.toThrow();
2366
2366
  });
2367
2367
 
2368
+ test("filter.op=lte/gte auf number-Feld → kein Throw (vergleichbar)", () => {
2369
+ const fields = {
2370
+ name: { type: "text", filterable: true },
2371
+ rank: { type: "number", filterable: true },
2372
+ };
2373
+ expect(() =>
2374
+ validateBoot([makeFeature({ field: "rank", op: "lte", value: 5 }, fields)]),
2375
+ ).not.toThrow();
2376
+ expect(() =>
2377
+ validateBoot([makeFeature({ field: "rank", op: "gte", value: 5 }, fields)]),
2378
+ ).not.toThrow();
2379
+ });
2380
+
2381
+ test("filter.op=lte auf text-Feld → Throw (op-vs-Type-Compat)", () => {
2382
+ expect(() => validateBoot([makeFeature({ field: "status", op: "lte", value: "x" })])).toThrow(
2383
+ /filter\.op "lte" is not allowed on field "status" \(type "text"\)/,
2384
+ );
2385
+ });
2386
+
2368
2387
  test('filter.op="in" mit non-array value → Throw', () => {
2369
2388
  expect(() =>
2370
2389
  validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createApp, createRegistry, dedupeFeatures, defineFeature } from "../index";
3
+
4
+ describe("dedupeFeatures", () => {
5
+ test("same reference mounted twice collapses to one entry, order preserved", () => {
6
+ const shared = defineFeature("shared", () => {});
7
+ const other = defineFeature("other", () => {});
8
+
9
+ const result = dedupeFeatures([shared, other, shared]);
10
+
11
+ expect(result).toEqual([shared, other]);
12
+ expect(result[0]).toBe(shared);
13
+ });
14
+
15
+ test("registry builds when the same reference is mounted twice", () => {
16
+ const shared = defineFeature("shared", () => {});
17
+
18
+ expect(() => createRegistry([shared, shared])).not.toThrow();
19
+ });
20
+
21
+ test("two instances with shallow-equal dedupeOptions collapse — first instance kept", () => {
22
+ const a = defineFeature("x", () => {}, { dedupeOptions: { limit: 3 } });
23
+ const b = defineFeature("x", () => {}, { dedupeOptions: { limit: 3 } });
24
+ const other = defineFeature("y", () => {});
25
+
26
+ const result = dedupeFeatures([a, other, b]);
27
+
28
+ expect(result).toEqual([a, other]);
29
+ expect(result[0]).toBe(a);
30
+ });
31
+
32
+ test("differing primitive dedupeOptions throws with a 'different options' message", () => {
33
+ const a = defineFeature("x", () => {}, { dedupeOptions: { limit: 3 } });
34
+ const b = defineFeature("x", () => {}, { dedupeOptions: { limit: 5 } });
35
+
36
+ expect(() => dedupeFeatures([a, b])).toThrow(/Duplicate feature: "x".*different options/);
37
+ });
38
+
39
+ test("same function reference in dedupeOptions collapses", () => {
40
+ const onTrigger = () => {};
41
+ const a = defineFeature("x", () => {}, { dedupeOptions: { onTrigger } });
42
+ const b = defineFeature("x", () => {}, { dedupeOptions: { onTrigger } });
43
+
44
+ const result = dedupeFeatures([a, b]);
45
+
46
+ expect(result).toEqual([a]);
47
+ });
48
+
49
+ test("different function reference in dedupeOptions throws", () => {
50
+ const a = defineFeature("x", () => {}, { dedupeOptions: { onTrigger: () => {} } });
51
+ const b = defineFeature("x", () => {}, { dedupeOptions: { onTrigger: () => {} } });
52
+
53
+ expect(() => dedupeFeatures([a, b])).toThrow(/Duplicate feature: "x".*different options/);
54
+ });
55
+
56
+ test("{ a: undefined } and {} are treated as shallow-equal (missing key == undefined)", () => {
57
+ const a = defineFeature("x", () => {}, { dedupeOptions: { a: undefined } });
58
+ const b = defineFeature("x", () => {}, { dedupeOptions: {} });
59
+
60
+ const result = dedupeFeatures([a, b]);
61
+
62
+ expect(result).toEqual([a]);
63
+ });
64
+
65
+ test("two distinct instances with no dedupeOptions throw with a 'distinct instances' message", () => {
66
+ const a = defineFeature("x", () => {});
67
+ const b = defineFeature("x", () => {});
68
+
69
+ expect(() => dedupeFeatures([a, b])).toThrow(
70
+ /Duplicate feature: "x" mounted twice as distinct instances/,
71
+ );
72
+ });
73
+
74
+ test("createApp boots end-to-end with a feature mounted twice via dedupeOptions", () => {
75
+ const a = defineFeature("x", () => {}, { dedupeOptions: {} });
76
+ const b = defineFeature("x", () => {}, { dedupeOptions: {} });
77
+
78
+ expect(() =>
79
+ createApp({
80
+ roles: ["Admin"],
81
+ features: [a, b],
82
+ }),
83
+ ).not.toThrow();
84
+ });
85
+ });
@@ -754,6 +754,92 @@ describe("createRegistry", () => {
754
754
  expect(() => createRegistry([feature])).toThrow(/multiple/);
755
755
  });
756
756
 
757
+ test("throws at boot when a searchable reference targets an encrypted labelField", () => {
758
+ const feature = defineFeature("crm", (r) => {
759
+ r.entity(
760
+ "customer",
761
+ createEntity({
762
+ table: "Customers",
763
+ fields: {
764
+ name: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
765
+ },
766
+ }),
767
+ );
768
+ r.entity(
769
+ "order",
770
+ createEntity({
771
+ table: "Orders",
772
+ fields: {
773
+ customerId: {
774
+ type: "reference",
775
+ entity: "customer",
776
+ labelField: "name",
777
+ searchable: true,
778
+ },
779
+ },
780
+ }),
781
+ );
782
+ });
783
+
784
+ expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
785
+ });
786
+
787
+ test('throws at boot when a searchable reference targets a PII labelField without find: "fuzzy" (no plaintext index)', () => {
788
+ const feature = defineFeature("crm", (r) => {
789
+ r.entity(
790
+ "contact",
791
+ createEntity({
792
+ table: "Contacts",
793
+ fields: { displayName: createTextField({ personal: "self", find: "exact" }) },
794
+ }),
795
+ );
796
+ r.entity(
797
+ "order",
798
+ createEntity({
799
+ table: "Orders",
800
+ fields: {
801
+ contactId: {
802
+ type: "reference",
803
+ entity: "contact",
804
+ labelField: "displayName",
805
+ searchable: true,
806
+ },
807
+ },
808
+ }),
809
+ );
810
+ });
811
+
812
+ expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
813
+ });
814
+
815
+ test('does not throw when a searchable reference targets a PII labelField with find: "fuzzy" (derived search index)', () => {
816
+ const feature = defineFeature("crm", (r) => {
817
+ r.entity(
818
+ "contact",
819
+ createEntity({
820
+ table: "Contacts",
821
+ fields: { displayName: createTextField({ personal: "self", find: "fuzzy" }) },
822
+ }),
823
+ );
824
+ r.entity(
825
+ "order",
826
+ createEntity({
827
+ table: "Orders",
828
+ fields: {
829
+ contactId: {
830
+ type: "reference",
831
+ entity: "contact",
832
+ labelField: "displayName",
833
+ searchable: true,
834
+ },
835
+ },
836
+ }),
837
+ );
838
+ });
839
+
840
+ expect(() => createRegistry([feature])).not.toThrow();
841
+ });
842
+
757
843
  test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
758
844
  const feature = defineFeature("crm", (r) => {
759
845
  r.entity(
@@ -3,7 +3,7 @@ import { createDecimalField, createMoneyField, createNumberField } from "../fact
3
3
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
4
4
  import type { ScreenFilterOp } from "../types";
5
5
 
6
- const COMPARABLE: ScreenFilterOp[] = ["eq", "ne", "lt", "gt", "in"];
6
+ const COMPARABLE: ScreenFilterOp[] = ["eq", "ne", "lt", "gt", "lte", "gte", "in"];
7
7
 
8
8
  describe("getAllowedFilterOps — decimal is comparable (#343/1)", () => {
9
9
  test("decimal yields the full comparable op-set, not the empty default", () => {
@@ -0,0 +1,82 @@
1
+ // fw#2914 — soft-delete cleanup runs through the real job runner with ctx.db as a
2
+ // tenant-filtered TenantDb: a run purges only its own tenant's expired rows.
3
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4
+ import { asRawClient } from "../../bun-db/query";
5
+ import { selectMany } from "../../db/query";
6
+ import { buildEntityTable } from "../../db/table-builder";
7
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
8
+ import { waitFor } from "../../testing";
9
+ import {
10
+ createEntity,
11
+ createTextField,
12
+ defineEntityCreateHandler,
13
+ defineEntityDeleteHandler,
14
+ defineFeature,
15
+ } from "../index";
16
+ import { SOFT_DELETE_CLEANUP_JOB } from "../soft-delete-cleanup";
17
+
18
+ const itemEntity = createEntity({
19
+ table: "fw2914_sdc_items",
20
+ softDelete: true,
21
+ fields: {
22
+ label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
23
+ },
24
+ });
25
+ const itemTable = buildEntityTable("item", itemEntity);
26
+
27
+ const feature = defineFeature("sdcjob", (r) => {
28
+ r.entity("item", itemEntity);
29
+ r.writeHandler(defineEntityCreateHandler("item", itemEntity, { access: { roles: ["Admin"] } }));
30
+ r.writeHandler(defineEntityDeleteHandler("item", itemEntity, { access: { roles: ["Admin"] } }));
31
+ });
32
+
33
+ let stack: TestStack;
34
+
35
+ beforeAll(async () => {
36
+ stack = await setupTestStack({
37
+ features: [feature],
38
+ jobs: {
39
+ consumerLane: "worker",
40
+ getActiveTenantIds: async () => [TestUsers.admin.tenantId],
41
+ },
42
+ });
43
+ await unsafeCreateEntityTable(stack.db, itemEntity, "item");
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await stack.cleanup();
48
+ });
49
+
50
+ async function createExpiredDeletedItem(user: typeof TestUsers.admin): Promise<string> {
51
+ const created = await stack.http.writeOk<{ id: string }>(
52
+ "sdcjob:write:item:create",
53
+ { label: `expired-${user.tenantId}` },
54
+ user,
55
+ );
56
+ await stack.http.writeOk("sdcjob:write:item:delete", { id: created.id }, user);
57
+ // kumiko-lint-ignore raw-sql test fixture ages deletedAt past the default grace period
58
+ await asRawClient(stack.db).unsafe(
59
+ `UPDATE fw2914_sdc_items SET deleted_at = now() - interval '400 days' WHERE id = $1`,
60
+ [created.id],
61
+ );
62
+ return created.id;
63
+ }
64
+
65
+ describe("soft-delete cleanup job smoke run on a tenant-filtered JobContext.db (fw#2914)", () => {
66
+ test("a per-tenant fan-out run purges only the active tenant's expired soft-deleted rows", async () => {
67
+ const ownId = await createExpiredDeletedItem(TestUsers.admin);
68
+ const foreignId = await createExpiredDeletedItem(TestUsers.otherTenant);
69
+ const jobRunner = stack.jobRunner;
70
+ if (!jobRunner) throw new Error("setupTestStack({ jobs }) did not wire a jobRunner");
71
+
72
+ await jobRunner.dispatch(SOFT_DELETE_CLEANUP_JOB, {});
73
+
74
+ await waitFor(async () => {
75
+ const own = await selectMany(stack.db, itemTable, { id: ownId });
76
+ expect(own).toHaveLength(0);
77
+ });
78
+ const foreign = await selectMany(stack.db, itemTable, { id: foreignId });
79
+ expect(foreign).toHaveLength(1);
80
+ expect(foreign[0]?.["isDeleted"]).toBe(true);
81
+ });
82
+ });
@@ -1,4 +1,5 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
+ import { dedupeFeatures } from "../dedupe-features";
2
3
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
4
  import type { FeatureDefinition } from "../types";
4
5
  import { validateAccessDeclarations } from "./access-declarations";
@@ -91,9 +92,10 @@ export type ValidateBootOptions = {
91
92
  * Throws on the first error found — fail fast.
92
93
  */
93
94
  export function validateBoot(
94
- features: readonly FeatureDefinition[],
95
+ rawFeatures: readonly FeatureDefinition[],
95
96
  options?: ValidateBootOptions,
96
97
  ): void {
98
+ const features = dedupeFeatures(rawFeatures);
97
99
  const featureMap = new Map<string, FeatureDefinition>();
98
100
  for (const f of features) {
99
101
  featureMap.set(f.name, f);
@@ -96,7 +96,7 @@ function validateProjectionListFilterSchemaAcceptance(
96
96
  if (screen.filter === undefined || schemaAccepts(schema, "filter")) return;
97
97
  throw new Error(
98
98
  `${prefix}: declares filter but query "${screen.query}" has no "filter" parameter in its Zod ` +
99
- `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
99
+ `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
100
100
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
101
101
  );
102
102
  }
@@ -203,7 +203,7 @@ function validateRelatedListParentFilter(
203
203
  if (!schemaAccepts(schema, "filter")) {
204
204
  throw new Error(
205
205
  `${prefix}: declares parentFilter but query "${section.query}" has no "filter" parameter in its ` +
206
- `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
206
+ `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
207
207
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
208
208
  );
209
209
  }
@@ -1,4 +1,5 @@
1
1
  import { type ValidateBootOptions, validateBoot } from "./boot-validator";
2
+ import { dedupeFeatures } from "./dedupe-features";
2
3
  import { createRegistry } from "./registry";
3
4
  import type { FeatureDefinition, Registry } from "./types";
4
5
  import { DEFAULT_CURRENCIES } from "./types";
@@ -20,6 +21,7 @@ export type App = {
20
21
  };
21
22
 
22
23
  export function createApp(config: AppConfig): App {
24
+ const features = dedupeFeatures(config.features);
23
25
  const validRoles = new Set(config.roles);
24
26
 
25
27
  // "system" is reserved for SYSTEM_USER — cannot be used as an app role
@@ -32,7 +34,7 @@ export function createApp(config: AppConfig): App {
32
34
 
33
35
  // Validate all roles referenced by features exist in app-defined roles.
34
36
  // openToAll access has no role list — nothing to validate there.
35
- for (const feature of config.features) {
37
+ for (const feature of features) {
36
38
  for (const handler of Object.values(feature.writeHandlers)) {
37
39
  if (handler.access && "roles" in handler.access) {
38
40
  for (const role of handler.access.roles) {
@@ -83,7 +85,7 @@ export function createApp(config: AppConfig): App {
83
85
  const currencies = [...new Set([...DEFAULT_CURRENCIES, ...(config.currencies ?? [])])];
84
86
 
85
87
  // Validate defaultCurrency on entities that have money fields
86
- for (const feature of config.features) {
88
+ for (const feature of features) {
87
89
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
88
90
  // A top-level money field isn't the only way an entity can hold money —
89
91
  // an embedded-list's sub-schema (e.g. invoice lines) can carry a money
@@ -110,10 +112,10 @@ export function createApp(config: AppConfig): App {
110
112
  }
111
113
 
112
114
  // Run boot-time validation before creating registry
113
- validateBoot(config.features, config.validateBootOptions);
115
+ validateBoot(features, config.validateBootOptions);
114
116
 
115
117
  return {
116
- registry: createRegistry(config.features),
118
+ registry: createRegistry(features),
117
119
  roles: config.roles,
118
120
  softDeleteDefault,
119
121
  currencies,
@@ -0,0 +1,46 @@
1
+ import type { FeatureDefinition } from "./types";
2
+
3
+ function dedupeOptionsShallowEqual(
4
+ a: Readonly<Record<string, unknown>>,
5
+ b: Readonly<Record<string, unknown>>,
6
+ ): boolean {
7
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
8
+ for (const key of keys) {
9
+ if (!Object.is(a[key], b[key])) return false;
10
+ }
11
+ return true;
12
+ }
13
+
14
+ function isInterchangeable(existing: FeatureDefinition, next: FeatureDefinition): boolean {
15
+ if (existing === next) return true;
16
+ if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
17
+ return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions);
18
+ }
19
+ return false;
20
+ }
21
+
22
+ // First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
23
+ export function dedupeFeatures(features: readonly FeatureDefinition[]): FeatureDefinition[] {
24
+ const byName = new Map<string, FeatureDefinition>();
25
+ const result: FeatureDefinition[] = [];
26
+
27
+ for (const feature of features) {
28
+ const existing = byName.get(feature.name);
29
+ if (existing === undefined) {
30
+ byName.set(feature.name, feature);
31
+ result.push(feature);
32
+ continue;
33
+ }
34
+ if (isInterchangeable(existing, feature)) continue;
35
+ if (existing.dedupeOptions !== undefined && feature.dedupeOptions !== undefined) {
36
+ throw new Error(
37
+ `Duplicate feature: "${feature.name}" mounted twice with different options — mount it once or pass identical options`,
38
+ );
39
+ }
40
+ throw new Error(
41
+ `Duplicate feature: "${feature.name}" mounted twice as distinct instances — mount it once`,
42
+ );
43
+ }
44
+
45
+ return result;
46
+ }