@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.
Files changed (57) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/full-stack.integration.test.ts +3 -1
  3. package/src/crypto/__tests__/event-pii.test.ts +25 -3
  4. package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
  5. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
  6. package/src/crypto/event-pii.ts +7 -1
  7. package/src/crypto/index.ts +1 -0
  8. package/src/crypto/kms-wiring.ts +23 -10
  9. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
  10. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
  11. package/src/db/entity-table-meta.ts +9 -2
  12. package/src/db/event-store-executor-read.ts +124 -7
  13. package/src/db/table-builder.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +132 -2
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
  16. package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
  17. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  18. package/src/engine/__tests__/screen.test.ts +184 -0
  19. package/src/engine/boot-validator/index.ts +5 -1
  20. package/src/engine/boot-validator/projection-list-screens.ts +42 -0
  21. package/src/engine/boot-validator/screens.ts +45 -29
  22. package/src/engine/entity-handlers.ts +4 -0
  23. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
  25. package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
  26. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  27. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  28. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
  29. package/src/engine/feature-ast/extractors/events.ts +24 -0
  30. package/src/engine/feature-ast/patcher.ts +6 -1
  31. package/src/engine/feature-ast/patterns.ts +6 -0
  32. package/src/engine/feature-ast/render.ts +3 -4
  33. package/src/engine/feature-config-events-jobs.ts +33 -9
  34. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  35. package/src/engine/registry-facade.ts +10 -1
  36. package/src/engine/registry-state.ts +8 -0
  37. package/src/engine/registry-validate.ts +54 -3
  38. package/src/engine/types/index.ts +1 -0
  39. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
  40. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
  41. package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
  42. package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
  43. package/src/jobs/job-runner.ts +12 -4
  44. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  45. package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
  46. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
  47. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
  48. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
  49. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
  50. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
  51. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
  52. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
  53. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
  54. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
  55. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
  56. package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
  57. package/src/pipeline/system-hooks.ts +94 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.249.0",
3
+ "version": "0.251.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.249.0",
201
+ "@cosmicdrift/kumiko-types": "0.251.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.249.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.251.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -63,7 +63,9 @@ async function emitUserCreated(
63
63
  const userFeature = defineFeature("users", (r) => {
64
64
  const user = r.entity("user", userEntity);
65
65
 
66
- const userCreated = r.defineEvent("user.created", z.object({ id: z.any(), email: z.string() }));
66
+ const userCreated = r.defineEvent("user.created", z.object({ id: z.any(), email: z.string() }), {
67
+ piiFields: "none",
68
+ });
67
69
  USER_CREATED_EVENT = userCreated.name;
68
70
 
69
71
  // r.multiStreamProjection: capture USER_CREATED_EVENT asynchronously via
@@ -83,12 +83,34 @@ describe("defineEvent piiFields validation", () => {
83
83
  ).toThrow(/cannot use itself as subjectField/);
84
84
  });
85
85
 
86
- test("events without piiFields do not enter the catalog", () => {
86
+ test("omitting the options argument throws an explicit PII stance error (fw#2558)", () => {
87
+ expect(() =>
88
+ defineFeature("mailer", (r) => {
89
+ // @cast-boundary test-only — the type signature makes `options`
90
+ // mandatory, but an untyped JS consumer can still omit it at
91
+ // runtime; the registrar must fail closed for that caller too.
92
+ (r.defineEvent as (name: string, schema: typeof attemptSchema) => unknown)(
93
+ "attempt",
94
+ attemptSchema,
95
+ );
96
+ }),
97
+ ).toThrow(/explicit PII stance/);
98
+ });
99
+
100
+ test('piiFields: {} throws, recommending piiFields: "none"', () => {
101
+ expect(() =>
102
+ defineFeature("mailer", (r) => {
103
+ r.defineEvent("attempt", attemptSchema, { piiFields: {} });
104
+ }),
105
+ ).toThrow(/use piiFields: "none"/);
106
+ });
107
+
108
+ test('piiFields: "none" registers successfully and stays out of the catalog', () => {
87
109
  const feature = defineFeature("mailer", (r) => {
88
- r.defineEvent("attempt", attemptSchema);
110
+ r.defineEvent("attempt", attemptSchema, { piiFields: "none" });
89
111
  });
90
112
  createRegistry([feature]);
91
- expect(configuredEventPiiCatalog().size).toBe(0);
113
+ expect(configuredEventPiiCatalog().has(EVENT_TYPE)).toBe(false);
92
114
  });
93
115
  });
94
116
 
@@ -95,7 +95,9 @@ describe("resolveKmsWiring", () => {
95
95
  test("falls back to plaintext PII when the trio is entirely absent", () => {
96
96
  const wiring = resolveKmsWiring({});
97
97
 
98
- expect(wiring).toEqual({ allowPlaintextPii: "local dev without subject-keys KMS (fw#818)" });
98
+ expect("kms" in wiring).toBe(false);
99
+ if ("kms" in wiring) throw new Error("unreachable");
100
+ expect(wiring.allowPlaintextPii).toBe("local dev without subject-keys KMS (fw#818)");
99
101
  });
100
102
 
101
103
  // Regression: every member being optional made TypeScript's weak-type
@@ -111,7 +113,18 @@ describe("resolveKmsWiring", () => {
111
113
  test("carries an app-supplied fallback reason into the boot log", () => {
112
114
  const wiring = resolveKmsWiring({}, { plaintextReason: "solon pre-UI gate" });
113
115
 
114
- expect(wiring).toEqual({ allowPlaintextPii: "solon pre-UI gate" });
116
+ expect("kms" in wiring).toBe(false);
117
+ if ("kms" in wiring) throw new Error("unreachable");
118
+ expect(wiring.allowPlaintextPii).toBe("solon pre-UI gate");
119
+ });
120
+
121
+ // fw#2551: an ops script must be able to release the subject-keys pool in a
122
+ // `finally` without knowing which branch it got — this compiles only while
123
+ // `close` sits on BOTH union members, and the plaintext branch must resolve
124
+ // rather than blow up on a wiring that opened nothing.
125
+ test("exposes close on both branches, callable without narrowing", async () => {
126
+ await expect(resolveKmsWiring({}).close()).resolves.toBeUndefined();
127
+ await expect(resolveKmsWiring(fullTrio).close()).resolves.toBeUndefined();
115
128
  });
116
129
 
117
130
  // All-or-none is the core of this module: a partial trio means someone
@@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from "node:crypto";
3
3
  import postgres from "postgres";
4
4
  import { createTestDb } from "../../stack/db";
5
5
  import { type KmsContext, type SubjectId, subjectIdToKey } from "../kms-adapter";
6
+ import { resolveKmsWiring } from "../kms-wiring";
6
7
  import { PgKmsAdapter } from "../pg-kms-adapter";
7
8
  import { describeKmsAdapterContract } from "./kms-adapter-contract";
8
9
 
@@ -133,4 +134,21 @@ describe("PgKmsAdapter — pg specifics", () => {
133
134
  expect(rows[0]?.["erased_by"]).toBe("operator-1");
134
135
  expect(rows[0]?.["erase_reason"]).toBe("user-forget");
135
136
  });
137
+
138
+ // fw#2551: the pool resolveKmsWiring opens is what kept ops scripts from
139
+ // exiting. Proving release needs a pool that was genuinely open, so the
140
+ // health() before close is half the assertion.
141
+ test("resolveKmsWiring's close releases the pool it opened", async () => {
142
+ const wiring = resolveKmsWiring({
143
+ PLATFORM_KEK: platformKek,
144
+ SUBJECT_KEYS_DATABASE_URL: databaseUrl,
145
+ KUMIKO_BLIND_INDEX_KEY: "blind-index-key",
146
+ });
147
+ if (!("kms" in wiring)) throw new Error("unreachable");
148
+
149
+ expect((await wiring.kms.health()).ok).toBe(true);
150
+ await wiring.close();
151
+
152
+ await expect(wiring.kms.health()).rejects.toThrow();
153
+ });
136
154
  });
@@ -4,7 +4,13 @@
4
4
  // path at all. `r.defineEvent(name, schema, { piiFields })` declares which
5
5
  // payload fields are PII and which payload field names the owning user;
6
6
  // createRegistry publishes the catalog and append() — the single write funnel
7
- // into kumiko_events — encrypts every catalogued field. No caller can forget.
7
+ // into kumiko_events — encrypts every catalogued field.
8
+ //
9
+ // `piiFields` is a mandatory, explicit stance (fw#2558): a payload can only
10
+ // go uncatalogued through a declared `piiFields: "none"`, never by omission —
11
+ // defineEvent refuses to register an event without one. What used to be a
12
+ // silent no-op (forgetting the option) is now either an explicit "none" or a
13
+ // missing subject KMS at encrypt-time, both visible states, not a gap.
8
14
 
9
15
  import { requestContext } from "../api/request-context";
10
16
  import type { EventPiiFields } from "../engine/types/handlers";
@@ -39,6 +39,7 @@ export {
39
39
  type KmsWiring,
40
40
  type KmsWiringEnv,
41
41
  type KmsWiringOptions,
42
+ type KmsWiringRelease,
42
43
  type PgKmsRotationEnv,
43
44
  type PlaintextPiiWiring,
44
45
  requireKmsWiring,
@@ -33,14 +33,22 @@ export type PgKmsRotationEnv = KmsWiringEnv & {
33
33
  readonly SUBJECT_KEYS_DATABASE_URL: string;
34
34
  };
35
35
 
36
- export type ActiveKmsWiring = {
36
+ /** Releases what the wiring opened. Present on BOTH branches (a no-op on the
37
+ * plaintext fallback) so a short-lived process can `finally { await
38
+ * wiring.close() }` without narrowing first. Skipping it leaves the adapter's
39
+ * connection pool holding the event loop open and the process never exits —
40
+ * a long-running server is supposed to hold it, every ops script is not
41
+ * (fw#2551). */
42
+ export type KmsWiringRelease = { readonly close: () => Promise<void> };
43
+
44
+ export type ActiveKmsWiring = KmsWiringRelease & {
37
45
  readonly kms: PgKmsAdapter;
38
46
  readonly blindIndexKey: string;
39
47
  };
40
48
 
41
49
  /** Plaintext fallback carries its reason so the boot log says WHY PII is
42
50
  * unencrypted — an app running like this by accident is a reportable breach. */
43
- export type PlaintextPiiWiring = { readonly allowPlaintextPii: string };
51
+ export type PlaintextPiiWiring = KmsWiringRelease & { readonly allowPlaintextPii: string };
44
52
 
45
53
  export type KmsWiring = ActiveKmsWiring | PlaintextPiiWiring;
46
54
 
@@ -141,18 +149,23 @@ function assertTrioConsistent(env: KmsWiringEnv, logPrefix: string | undefined):
141
149
  export function resolveKmsWiring(env: KmsWiringEnv, options: KmsWiringOptions = {}): KmsWiring {
142
150
  const complete = assertTrioConsistent(env, options.logPrefix);
143
151
  if (complete && env.PLATFORM_KEK && env.SUBJECT_KEYS_DATABASE_URL && env.KUMIKO_BLIND_INDEX_KEY) {
152
+ const kms = createPgKmsAdapter(
153
+ buildPgKmsOptions({
154
+ ...env,
155
+ PLATFORM_KEK: env.PLATFORM_KEK,
156
+ SUBJECT_KEYS_DATABASE_URL: env.SUBJECT_KEYS_DATABASE_URL,
157
+ }),
158
+ );
144
159
  return {
145
- kms: createPgKmsAdapter(
146
- buildPgKmsOptions({
147
- ...env,
148
- PLATFORM_KEK: env.PLATFORM_KEK,
149
- SUBJECT_KEYS_DATABASE_URL: env.SUBJECT_KEYS_DATABASE_URL,
150
- }),
151
- ),
160
+ kms,
152
161
  blindIndexKey: env.KUMIKO_BLIND_INDEX_KEY,
162
+ close: () => kms.close(),
153
163
  };
154
164
  }
155
- return { allowPlaintextPii: options.plaintextReason ?? DEFAULT_PLAINTEXT_REASON };
165
+ return {
166
+ allowPlaintextPii: options.plaintextReason ?? DEFAULT_PLAINTEXT_REASON,
167
+ close: () => Promise.resolve(),
168
+ };
156
169
  }
157
170
 
158
171
  /** Same as `resolveKmsWiring` but the trio is mandatory — an absent trio throws
@@ -0,0 +1,258 @@
1
+ // fw#2741 — a list sorted by a `sortable` reference field must order by the
2
+ // target row's labelField, not by the FK column's UUID. The fixture pins three
3
+ // mutually distinct orderings (label / own id / referenced UUID) so a passing
4
+ // assertion can only come from the label expression, never from the column the
5
+ // read path used to fall back to.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
9
+ import { asRawClient } from "../../db/query";
10
+ import { createEntity, createTextField } from "../../engine";
11
+ import type { EntityDefinition } from "../../engine/types";
12
+ import { createEventsTable } from "../../event-store";
13
+ import { TestUsers, unsafeCreateEntityTable } from "../../stack";
14
+ import { seedRows } from "../../testing";
15
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
16
+ import { createEventStoreExecutor } from "../event-store-executor";
17
+ import { buildEntityTable } from "../table-builder";
18
+ import { createTenantDb, type TenantDb } from "../tenant-db";
19
+
20
+ const customerEntity = createEntity({
21
+ table: "read_ref_sort_customers",
22
+ fields: { name: createTextField({ required: true }) },
23
+ });
24
+ const customerTable = buildEntityTable("refSortCustomer", customerEntity);
25
+
26
+ const orderEntity = createEntity({
27
+ table: "read_ref_sort_orders",
28
+ fields: {
29
+ note: createTextField(),
30
+ customerId: {
31
+ type: "reference",
32
+ entity: "refSortCustomer",
33
+ labelField: "name",
34
+ sortable: true,
35
+ },
36
+ },
37
+ });
38
+ const orderTable = buildEntityTable("refSortOrder", orderEntity);
39
+
40
+ // Same shape, but the reference column is Admin-only: a caller who cannot read
41
+ // the column must not be able to probe its target's label through the ordering
42
+ // (fw#2629 parity — the read path gates sorting like projection).
43
+ const restrictedOrderEntity = createEntity({
44
+ table: "read_ref_sort_restricted_orders",
45
+ fields: {
46
+ note: createTextField(),
47
+ customerId: {
48
+ type: "reference",
49
+ entity: "refSortCustomer",
50
+ labelField: "name",
51
+ sortable: true,
52
+ access: { read: { Admin: "all" } },
53
+ },
54
+ },
55
+ });
56
+ const restrictedOrderTable = buildEntityTable("refSortRestrictedOrder", restrictedOrderEntity);
57
+
58
+ const CUSTOMER_MIKE = "11111111-1111-4111-8111-111111111111";
59
+ const CUSTOMER_ALPHA = "22222222-2222-4222-8222-222222222222";
60
+ const CUSTOMER_ZULU = "33333333-3333-4333-8333-333333333333";
61
+
62
+ // Own ids ascend a < b < c while the labels they point at do not, so the three
63
+ // candidate orderings are pairwise different:
64
+ // by label: [orderC, orderB, orderA] (Alpha, Mike, Zulu)
65
+ // by own id: [orderA, orderB, orderC]
66
+ // by customerId UUID: [orderB, orderC, orderA] (1111…, 2222…, 3333…)
67
+ const ORDER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
68
+ const ORDER_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
69
+ const ORDER_C = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
70
+
71
+ function resolveEntity(name: string): EntityDefinition | undefined {
72
+ return name === "refSortCustomer" ? customerEntity : undefined;
73
+ }
74
+
75
+ const referenceSort = {
76
+ fields: [{ fieldName: "customerId", targetEntityName: "refSortCustomer", labelField: "name" }],
77
+ resolveEntity,
78
+ };
79
+
80
+ let testDb: BunTestDb;
81
+ let tdbA: TenantDb;
82
+ const admin = TestUsers.admin;
83
+ const otherTenantAdmin = TestUsers.otherTenant;
84
+ const orderExec = createEventStoreExecutor(orderTable, orderEntity, { entityName: "refSortOrder" });
85
+ const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
86
+ entityName: "refSortRestrictedOrder",
87
+ });
88
+
89
+ async function seedFixture(): Promise<void> {
90
+ await seedRows(testDb.db, customerTable, [
91
+ { id: CUSTOMER_MIKE, tenantId: admin.tenantId, name: "Mike" },
92
+ { id: CUSTOMER_ALPHA, tenantId: admin.tenantId, name: "Alpha" },
93
+ { id: CUSTOMER_ZULU, tenantId: admin.tenantId, name: "Zulu" },
94
+ ]);
95
+ await seedRows(testDb.db, orderTable, [
96
+ { id: ORDER_A, tenantId: admin.tenantId, note: "a", customerId: CUSTOMER_ZULU },
97
+ { id: ORDER_B, tenantId: admin.tenantId, note: "b", customerId: CUSTOMER_MIKE },
98
+ { id: ORDER_C, tenantId: admin.tenantId, note: "c", customerId: CUSTOMER_ALPHA },
99
+ ]);
100
+ }
101
+
102
+ const idsOf = (rows: readonly Record<string, unknown>[]): unknown[] => rows.map((r) => r["id"]);
103
+
104
+ beforeAll(async () => {
105
+ await ensureTemporalPolyfill();
106
+ testDb = await createTestDb();
107
+ await unsafeCreateEntityTable(testDb.db, customerEntity, "refSortCustomer");
108
+ await unsafeCreateEntityTable(testDb.db, orderEntity, "refSortOrder");
109
+ await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSortRestrictedOrder");
110
+ await createEventsTable(testDb.db);
111
+ tdbA = createTenantDb(testDb.db, admin.tenantId);
112
+ });
113
+
114
+ afterAll(async () => {
115
+ await testDb.cleanup();
116
+ });
117
+
118
+ beforeEach(async () => {
119
+ await asRawClient(testDb.db).unsafe(
120
+ "TRUNCATE kumiko_events, read_ref_sort_customers, read_ref_sort_orders, " +
121
+ "read_ref_sort_restricted_orders RESTART IDENTITY CASCADE",
122
+ );
123
+ });
124
+
125
+ describe("event-store-executor.list — sortable reference fields (fw#2741)", () => {
126
+ test("orders ascending by the target row's label, not by the FK UUID", async () => {
127
+ await seedFixture();
128
+
129
+ const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
130
+ referenceSort,
131
+ });
132
+
133
+ expect(idsOf(res.rows)).toEqual([ORDER_C, ORDER_B, ORDER_A]);
134
+ });
135
+
136
+ test("orders descending by the target row's label", async () => {
137
+ await seedFixture();
138
+
139
+ const res = await orderExec.list({ sort: "customerId", sortDirection: "desc" }, admin, tdbA, {
140
+ referenceSort,
141
+ });
142
+
143
+ expect(idsOf(res.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
144
+ });
145
+
146
+ test("does not leak the sort-label projection into the returned rows", async () => {
147
+ await seedFixture();
148
+
149
+ const res = await orderExec.list({ sort: "customerId" }, admin, tdbA, { referenceSort });
150
+
151
+ for (const row of res.rows) {
152
+ expect(Object.keys(row)).not.toContain("__kumiko_sort_label");
153
+ }
154
+ });
155
+
156
+ test("pages through the label order with a keyset cursor, no gaps or repeats", async () => {
157
+ await seedFixture();
158
+
159
+ const page1 = await orderExec.list(
160
+ { sort: "customerId", sortDirection: "asc", limit: 2, totalCount: true },
161
+ admin,
162
+ tdbA,
163
+ { referenceSort },
164
+ );
165
+ expect(idsOf(page1.rows)).toEqual([ORDER_C, ORDER_B]);
166
+ expect(page1.total).toBe(3);
167
+ expect(page1.nextCursor).not.toBeNull();
168
+
169
+ const page2 = await orderExec.list(
170
+ {
171
+ sort: "customerId",
172
+ sortDirection: "asc",
173
+ limit: 2,
174
+ cursor: page1.nextCursor as string,
175
+ totalCount: true,
176
+ },
177
+ admin,
178
+ tdbA,
179
+ { referenceSort },
180
+ );
181
+ expect(idsOf(page2.rows)).toEqual([ORDER_A]);
182
+ // totalCount counts the same WHERE the page used, so a cursored page counts
183
+ // what is left. The assertion that matters here is that it answers at all:
184
+ // the count binds only the WHERE params while ORDER BY now adds its own,
185
+ // and Postgres rejects a statement handed more parameters than it uses.
186
+ expect(page2.total).toBe(1);
187
+ });
188
+
189
+ test("a target row in a foreign tenant contributes no label and sorts last", async () => {
190
+ await seedFixture();
191
+ const foreignCustomer = "44444444-4444-4444-8444-444444444444";
192
+ await seedRows(testDb.db, customerTable, [
193
+ // Reference UUIDs carry no FK constraint, so a tenant-A row can point at
194
+ // a tenant-B target. The tenant-scoped lookup must not resolve it —
195
+ // otherwise "AAA Foreign" would sort first and leak its existence.
196
+ { id: foreignCustomer, tenantId: otherTenantAdmin.tenantId, name: "AAA Foreign" },
197
+ ]);
198
+ const orderD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
199
+ await seedRows(testDb.db, orderTable, [
200
+ { id: orderD, tenantId: admin.tenantId, note: "d", customerId: foreignCustomer },
201
+ ]);
202
+
203
+ const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
204
+ referenceSort,
205
+ });
206
+
207
+ expect(idsOf(res.rows)).toEqual([ORDER_C, ORDER_B, ORDER_A, orderD]);
208
+ });
209
+
210
+ test("a reference field that did not opt into sortable falls back to id order, never UUID order", async () => {
211
+ await seedFixture();
212
+
213
+ const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
214
+ referenceSort: { fields: [], resolveEntity },
215
+ });
216
+
217
+ expect(idsOf(res.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
218
+ });
219
+
220
+ test("an unresolvable target entity falls back to id order, never UUID order", async () => {
221
+ await seedFixture();
222
+
223
+ const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
224
+ referenceSort: { fields: referenceSort.fields, resolveEntity: () => undefined },
225
+ });
226
+
227
+ expect(idsOf(res.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
228
+ });
229
+
230
+ test("a caller without read access to the reference column gets id order, an Admin gets label order", async () => {
231
+ await seedRows(testDb.db, customerTable, [
232
+ { id: CUSTOMER_MIKE, tenantId: admin.tenantId, name: "Mike" },
233
+ { id: CUSTOMER_ALPHA, tenantId: admin.tenantId, name: "Alpha" },
234
+ { id: CUSTOMER_ZULU, tenantId: admin.tenantId, name: "Zulu" },
235
+ ]);
236
+ await seedRows(testDb.db, restrictedOrderTable, [
237
+ { id: ORDER_A, tenantId: admin.tenantId, note: "a", customerId: CUSTOMER_ZULU },
238
+ { id: ORDER_B, tenantId: admin.tenantId, note: "b", customerId: CUSTOMER_MIKE },
239
+ { id: ORDER_C, tenantId: admin.tenantId, note: "c", customerId: CUSTOMER_ALPHA },
240
+ ]);
241
+
242
+ const asUser = await restrictedOrderExec.list(
243
+ { sort: "customerId", sortDirection: "asc" },
244
+ TestUsers.user,
245
+ tdbA,
246
+ { referenceSort },
247
+ );
248
+ expect(idsOf(asUser.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
249
+
250
+ const asAdmin = await restrictedOrderExec.list(
251
+ { sort: "customerId", sortDirection: "asc" },
252
+ admin,
253
+ tdbA,
254
+ { referenceSort },
255
+ );
256
+ expect(idsOf(asAdmin.rows)).toEqual([ORDER_C, ORDER_B, ORDER_A]);
257
+ });
258
+ });
@@ -120,6 +120,23 @@ describe("lock-step — softDelete + explizite Indexes", () => {
120
120
  );
121
121
  expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
122
122
  });
123
+
124
+ test("explicit unique index without author where gets the soft-delete predicate (#2593)", () => {
125
+ const idx = fromMeta.indexes.find((i) => i.name.endsWith("_owner_id_status_unique"));
126
+ expect(idx?.unique).toBe(true);
127
+ expect(idx?.whereSql).toBe('"is_deleted" = false');
128
+ });
129
+
130
+ test("author-provided where is left unchanged, no is_deleted appended (#2593)", () => {
131
+ const idx = fromMeta.indexes.find((i) => i.name === "open_title_unique");
132
+ expect(idx?.whereSql).toBe("status = 'open'");
133
+ });
134
+
135
+ test("non-unique explicit index gets no predicate at all (#2593)", () => {
136
+ const idx = fromMeta.indexes.find((i) => i.name.endsWith("_owner_id_idx"));
137
+ expect(idx?.unique).toBeUndefined();
138
+ expect(idx?.whereSql).toBeUndefined();
139
+ });
123
140
  });
124
141
 
125
142
  // Dritte Probe: lookupable-Feld (#818) — bidx-Spalte, bidx-Index und das
@@ -159,6 +176,14 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
159
176
  expect(partial?.columns).toEqual(["tenant_slug", "email_bidx"]);
160
177
  expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
161
178
  });
179
+
180
+ test("without softDelete, the plaintext unique index gets no predicate (#2593)", () => {
181
+ const plaintext = fromMeta.indexes.find(
182
+ (i) => i.name.endsWith("_tenant_slug_email_unique") && !i.name.endsWith("_bidx"),
183
+ );
184
+ expect(plaintext?.unique).toBe(true);
185
+ expect(plaintext?.whereSql).toBeUndefined();
186
+ });
162
187
  });
163
188
 
164
189
  // Fourth probe: softDelete + lookupable (#2464) — the partial bidx unique
@@ -188,4 +213,12 @@ describe("lock-step — softDelete + lookupable/blind-index (#2464)", () => {
188
213
  expect(partial).toBeDefined();
189
214
  expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL AND "is_deleted" = false');
190
215
  });
216
+
217
+ test("plaintext unique index also gets the soft-delete predicate (#2593)", () => {
218
+ const plaintext = fromMeta.indexes.find(
219
+ (i) => i.name.endsWith("_tenant_slug_email_unique") && !i.name.endsWith("_bidx"),
220
+ );
221
+ expect(plaintext?.unique).toBe(true);
222
+ expect(plaintext?.whereSql).toBe('"is_deleted" = false');
223
+ });
191
224
  });
@@ -338,13 +338,20 @@ export function deriveEntityTableMeta(
338
338
  );
339
339
  const suffix = def.unique === true ? "unique" : "idx";
340
340
  const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
341
- const whereSql = sqlExpressionText(def.where);
341
+ const explicitWhereSql = sqlExpressionText(def.where);
342
+ // Soft-deleted rows keep their value, so a full unique index would
343
+ // permanently block reuse; kept in lock-step with table-builder.ts (framework#2593).
344
+ const whereSql =
345
+ explicitWhereSql ??
346
+ (def.where === undefined && def.unique === true && entity.softDelete === true
347
+ ? `"is_deleted" = false`
348
+ : undefined);
342
349
  indexes.push({
343
350
  name: indexName,
344
351
  columns: cols,
345
352
  ...(def.unique === true && { unique: true }),
346
353
  ...(whereSql !== undefined && { whereSql }),
347
- ...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
354
+ ...(def.where !== undefined && explicitWhereSql === undefined && { needsManualWhere: true }),
348
355
  });
349
356
  // Unique index over lookupable columns: partial bidx counterpart, so
350
357
  // uniqueness also holds for encrypted rows. The original stays