@cosmicdrift/kumiko-framework 0.176.1 → 0.177.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.176.1",
3
+ "version": "0.177.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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.176.1",
185
+ "@cosmicdrift/kumiko-types": "0.177.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.176.1",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.177.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -32,6 +32,10 @@ export function createSseBroker(): SseBroker {
32
32
  // Cross-replica fanout lives one level up: the SSE + access-invalidation
33
33
  // consumers (system-hooks.ts) run delivery: "per-instance" (#1718).
34
34
  const channels = new Map<string, Map<string, SseClient>>();
35
+ // Set, not Map<listenerId, fn> — dedup key is callback reference. Every
36
+ // subscriber must pass a distinct closure (dispatch-stream.ts does, one
37
+ // per stream). Two subscribes with the SAME reference for the same user
38
+ // collapse into one listener, and the first unsubscribe kills both.
35
39
  const accessInvalidationListeners = new Map<string, Set<() => void>>();
36
40
 
37
41
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.177.0",
4
+ "type": "breaking",
5
+ "title": "createMoneyField's amount now converts to/from minor-unit BIGINT storage (fw#1767).",
6
+ "detail": "flattenMoney/rehydrateMoney used to pass the API amount straight into the BIGINT column without the minor-unit (cents) conversion the column's own doc comment always claimed. A decimal amount (e.g. 56799.16) crashed the insert (float into bigint); a plain integer major-unit amount (e.g. 45000 meaning €450.00) was silently stored as 45000 minor units — 100× too small on read-back.",
7
+ "migration": "amount is now always major units (ordinary decimal, e.g. 56799.16) on both write and read — DB storage stays exact-integer cents automatically, no caller change needed for that direction. If you already wrote createMoneyField data under the old (unconverted) semantics, multiply stored amounts by 100 before upgrading, or reconcile after — no known production deployment currently persists money-typed data (verified solon and phronexsis are both pre-launch before this merged)."
8
+ },
2
9
  {
3
10
  "version": "0.167.0",
4
11
  "type": "breaking",
@@ -49,7 +49,8 @@ describe("flattenCompoundTypes — Pipeline", () => {
49
49
  expect(flat["label"]).toBe("ACME");
50
50
  expect((flat["pickupUtc"] as Temporal.Instant).toString()).toBe("2026-04-15T09:00:00Z");
51
51
  expect(flat["pickupTz"]).toBe("Europe/Lisbon");
52
- expect(flat["buyingPrice"]).toBe(45_000);
52
+ // flattenMoney converts major units (API) → minor units (DB, ×100).
53
+ expect(flat["buyingPrice"]).toBe(4_500_000);
53
54
  expect(flat["buyingPriceCurrency"]).toBe("EUR");
54
55
  });
55
56
 
@@ -75,7 +76,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
75
76
  label: "ACME",
76
77
  pickupUtc: "2026-04-15T09:00:00Z",
77
78
  pickupTz: "Europe/Lisbon",
78
- buyingPrice: 45_000,
79
+ buyingPrice: 4_500_000,
79
80
  buyingPriceCurrency: "EUR",
80
81
  },
81
82
  mixedEntity,
@@ -83,6 +84,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
83
84
  expect(out).toEqual({
84
85
  label: "ACME",
85
86
  pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
87
+ // rehydrateMoney converts minor units (DB) → major units (API, ÷100).
86
88
  buyingPrice: { amount: 45_000, currency: "EUR" },
87
89
  });
88
90
  });
@@ -1,4 +1,8 @@
1
1
  // Pure Unit-Tests für money flatten/rehydrate Helpers.
2
+ //
3
+ // Contract: API-form amount is MAJOR units (56799.16 EUR), DB-form is
4
+ // MINOR units (5679916 cents) — flattenMoney/rehydrateMoney convert at the
5
+ // boundary (×100 / ÷100). See money.ts's file header for why this exists.
2
6
 
3
7
  import { describe, expect, test } from "bun:test";
4
8
  import { createEntity, createMoneyField, createTextField } from "../../engine";
@@ -21,24 +25,29 @@ const usdEntity: EntityDefinition = createEntity({
21
25
  },
22
26
  });
23
27
 
24
- describe("flattenMoney — Insert/Update Convert", () => {
25
- test("{ amount, currency } → { <name>: amount, <name>Currency: currency }", () => {
26
- const flat = flattenMoney({ buyingPrice: { amount: 45000, currency: "EUR" } }, orderEntity);
28
+ describe("flattenMoney — Insert/Update Convert (major units → minor units)", () => {
29
+ test("{ amount, currency } → { <name>: amount*100, <name>Currency: currency }", () => {
30
+ const flat = flattenMoney({ buyingPrice: { amount: 450, currency: "EUR" } }, orderEntity);
27
31
  expect(flat).toEqual({ buyingPrice: 45000, buyingPriceCurrency: "EUR" });
28
32
  });
29
33
 
34
+ test("decimal amount rounds to the nearest cent", () => {
35
+ const flat = flattenMoney({ buyingPrice: { amount: 56799.16, currency: "EUR" } }, orderEntity);
36
+ expect(flat).toEqual({ buyingPrice: 5679916, buyingPriceCurrency: "EUR" });
37
+ });
38
+
30
39
  test("primitive number (legacy) wird akzeptiert + entity.defaultCurrency angehängt", () => {
31
- const flat = flattenMoney({ buyingPrice: 45000 }, orderEntity);
40
+ const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
32
41
  expect(flat).toEqual({ buyingPrice: 45000, buyingPriceCurrency: "EUR" });
33
42
  });
34
43
 
35
44
  test("primitive number nutzt USD wenn entity.defaultCurrency = USD", () => {
36
- const flat = flattenMoney({ fee: 199 }, usdEntity);
45
+ const flat = flattenMoney({ fee: 1.99 }, usdEntity);
37
46
  expect(flat).toEqual({ fee: 199, feeCurrency: "USD" });
38
47
  });
39
48
 
40
49
  test("expliziter <name>Currency im Payload überschreibt nicht", () => {
41
- const flat = flattenMoney({ buyingPrice: 45000, buyingPriceCurrency: "USD" }, orderEntity);
50
+ const flat = flattenMoney({ buyingPrice: 450, buyingPriceCurrency: "USD" }, orderEntity);
42
51
  // Wenn bereits gesetzt, nicht überschreiben — caller-explicit gewinnt
43
52
  expect(flat["buyingPriceCurrency"]).toBe("USD");
44
53
  });
@@ -46,8 +55,8 @@ describe("flattenMoney — Insert/Update Convert", () => {
46
55
  test("mehrere money-Felder am gleichen Object", () => {
47
56
  const flat = flattenMoney(
48
57
  {
49
- buyingPrice: { amount: 45000, currency: "EUR" },
50
- sellingPrice: { amount: 60000, currency: "USD" },
58
+ buyingPrice: { amount: 450, currency: "EUR" },
59
+ sellingPrice: { amount: 600, currency: "USD" },
51
60
  },
52
61
  orderEntity,
53
62
  );
@@ -61,7 +70,7 @@ describe("flattenMoney — Insert/Update Convert", () => {
61
70
 
62
71
  test("andere Felder bleiben unverändert", () => {
63
72
  const flat = flattenMoney(
64
- { label: "Premium", buyingPrice: { amount: 100, currency: "EUR" } },
73
+ { label: "Premium", buyingPrice: { amount: 1, currency: "EUR" } },
65
74
  orderEntity,
66
75
  );
67
76
  expect(flat["label"]).toBe("Premium");
@@ -77,33 +86,33 @@ describe("flattenMoney — Insert/Update Convert", () => {
77
86
  const noCurrencyEntity: EntityDefinition = createEntity({
78
87
  fields: { fee: createMoneyField() },
79
88
  });
80
- const flat = flattenMoney({ fee: 50 }, noCurrencyEntity);
89
+ const flat = flattenMoney({ fee: 0.5 }, noCurrencyEntity);
81
90
  expect(flat["feeCurrency"]).toBe("EUR");
82
91
  });
83
92
 
84
93
  test("ist pure — input wird nicht mutiert", () => {
85
- const input = { buyingPrice: { amount: 45000, currency: "EUR" } };
94
+ const input = { buyingPrice: { amount: 450, currency: "EUR" } };
86
95
  const before = JSON.stringify(input);
87
96
  flattenMoney(input, orderEntity);
88
97
  expect(JSON.stringify(input)).toBe(before);
89
98
  });
90
99
  });
91
100
 
92
- describe("rehydrateMoney — Read Convert", () => {
93
- test("{ <name>: number, <name>Currency: string } → { <name>: { amount, currency } }", () => {
101
+ describe("rehydrateMoney — Read Convert (minor units → major units)", () => {
102
+ test("{ <name>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency } }", () => {
94
103
  const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity);
95
- expect(out).toEqual({ buyingPrice: { amount: 45000, currency: "EUR" } });
104
+ expect(out).toEqual({ buyingPrice: { amount: 450, currency: "EUR" } });
96
105
  });
97
106
 
98
107
  test("PG-BIGINT als String wird zu number gecastet", () => {
99
108
  // Postgres-driver liefert BIGINT manchmal als String (>2^53 sicher).
100
109
  const out = rehydrateMoney({ buyingPrice: "45000", buyingPriceCurrency: "EUR" }, orderEntity);
101
- expect(out["buyingPrice"]).toEqual({ amount: 45000, currency: "EUR" });
110
+ expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
102
111
  });
103
112
 
104
113
  test("fehlende Currency-Spalte fällt auf entity.defaultCurrency", () => {
105
114
  const out = rehydrateMoney({ buyingPrice: 45000 }, orderEntity);
106
- expect(out["buyingPrice"]).toEqual({ amount: 45000, currency: "EUR" });
115
+ expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
107
116
  });
108
117
 
109
118
  test("null/undefined amount → Field wird aus Output entfernt", () => {
@@ -122,25 +131,25 @@ describe("rehydrateMoney — Read Convert", () => {
122
131
  orderEntity,
123
132
  );
124
133
  expect(out).toEqual({
125
- buyingPrice: { amount: 45000, currency: "EUR" },
126
- sellingPrice: { amount: 60000, currency: "USD" },
134
+ buyingPrice: { amount: 450, currency: "EUR" },
135
+ sellingPrice: { amount: 600, currency: "USD" },
127
136
  });
128
137
  });
129
138
 
130
- test("Round-Trip: flatten dann rehydrate ergibt dasselbe", () => {
139
+ test("Round-Trip: flatten dann rehydrate ergibt dasselbe, inkl. Cents", () => {
131
140
  const original = {
132
- buyingPrice: { amount: 45000, currency: "EUR" },
133
- sellingPrice: { amount: 60000, currency: "USD" },
141
+ buyingPrice: { amount: 450.5, currency: "EUR" },
142
+ sellingPrice: { amount: 56799.16, currency: "USD" },
134
143
  };
135
144
  const flat = flattenMoney(original, orderEntity);
136
145
  const rehydrated = rehydrateMoney(flat, orderEntity);
137
146
  expect(rehydrated).toEqual(original);
138
147
  });
139
148
 
140
- test("Round-Trip primitive-Insert: flatten(45000) → rehydrate → { amount:45000, currency:EUR }", () => {
141
- const flat = flattenMoney({ buyingPrice: 45000 }, orderEntity);
149
+ test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR }", () => {
150
+ const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
142
151
  const out = rehydrateMoney(flat, orderEntity);
143
- expect(out["buyingPrice"]).toEqual({ amount: 45000, currency: "EUR" });
152
+ expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
144
153
  });
145
154
 
146
155
  test("ist pure — input wird nicht mutiert", () => {
@@ -166,13 +175,13 @@ describe("rehydrateMoney — Read Convert", () => {
166
175
  describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () => {
167
176
  test("Update-Changes-Payload mit money geht durch flatten + zurück durch rehydrate", () => {
168
177
  // Simuliert was der Executor macht: changes → flatten → DB → rehydrate
169
- const changes = { buyingPrice: { amount: 99_000, currency: "USD" } };
178
+ const changes = { buyingPrice: { amount: 990, currency: "USD" } };
170
179
  const flat = flattenMoney(changes, orderEntity);
171
180
  expect(flat).toEqual({ buyingPrice: 99_000, buyingPriceCurrency: "USD" });
172
181
 
173
182
  // DB liefert dieselben Spalten zurück
174
183
  const out = rehydrateMoney(flat, orderEntity);
175
- expect(out).toEqual({ buyingPrice: { amount: 99_000, currency: "USD" } });
184
+ expect(out).toEqual({ buyingPrice: { amount: 990, currency: "USD" } });
176
185
  });
177
186
 
178
187
  test("List-Pfad: mehrere Rows hintereinander rehydraten", () => {
@@ -183,9 +192,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
183
192
  ];
184
193
  const apiRows = dbRows.map((r) => rehydrateMoney(r, orderEntity));
185
194
  expect(apiRows).toEqual([
186
- { buyingPrice: { amount: 100, currency: "EUR" } },
187
- { buyingPrice: { amount: 200, currency: "USD" } },
188
- { buyingPrice: { amount: 300, currency: "GBP" } },
195
+ { buyingPrice: { amount: 1, currency: "EUR" } },
196
+ { buyingPrice: { amount: 2, currency: "USD" } },
197
+ { buyingPrice: { amount: 3, currency: "GBP" } },
189
198
  ]);
190
199
  });
191
200
  });
package/src/db/money.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  // Auto-Convert für money-Felder im DB-Layer.
2
2
  //
3
3
  // Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
4
- // API-Form: { amount, currency } | number (permissiv für Legacy)
5
- // DB-Form: <name> BIGINT + <name>Currency TEXT
6
- // Read-Form: { amount, currency }
4
+ // API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
5
+ // DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
6
+ // Read-Form: { amount, currency } — amount in MAJOR units again
7
+ //
8
+ // table-builder.ts's moneyAmount column has always documented BIGINT as
9
+ // "the integer minor unit" — this file used to just pass the API amount
10
+ // through unconverted, silently violating that contract: a caller doing
11
+ // the ergonomic thing (passing 56799.16) got a float into a bigint column
12
+ // (driver error) or, worse, an integer major-unit amount (56799) got
13
+ // stored as if it were already minor units — 100× too small on read back.
14
+ // MINOR_UNIT_SCALE fixes that at the boundary so every caller can just
15
+ // pass/receive ordinary decimal amounts; DB storage stays exact-integer
16
+ // cents (no float drift in SUM()/aggregate queries).
7
17
  //
8
18
  // Permissiv-Insert: primitive number wird als amount akzeptiert (Legacy aus
9
19
  // pre-Stufe-3-Samples). Currency fällt dann auf entity.defaultCurrency
@@ -18,6 +28,19 @@ import { DEFAULT_CURRENCIES } from "../engine/types";
18
28
 
19
29
  const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
20
30
 
31
+ // 2 decimal places (cents) — covers every currently-supported currency
32
+ // (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
33
+ // upgrade path once a currency needing a different scale actually lands.
34
+ const MINOR_UNIT_SCALE = 100;
35
+
36
+ function toMinorUnits(amount: number): number {
37
+ return Math.round(amount * MINOR_UNIT_SCALE);
38
+ }
39
+
40
+ function toMajorUnits(amountMinor: number): number {
41
+ return amountMinor / MINOR_UNIT_SCALE;
42
+ }
43
+
21
44
  /**
22
45
  * API → DB: money-Felder zu zwei flachen Spalten flatten.
23
46
  *
@@ -68,7 +91,7 @@ export function flattenMoney(
68
91
  }
69
92
 
70
93
  delete result[name];
71
- result[name] = amount;
94
+ result[name] = toMinorUnits(amount);
72
95
  result[`${name}Currency`] = currency;
73
96
  }
74
97
 
@@ -103,18 +126,18 @@ export function rehydrateMoney(
103
126
  continue;
104
127
  }
105
128
 
106
- let amount: number;
129
+ let amountMinor: number;
107
130
  if (typeof amountRaw === "number") {
108
- amount = amountRaw;
131
+ amountMinor = amountRaw;
109
132
  } else if (typeof amountRaw === "bigint") {
110
- amount = Number(amountRaw);
111
- if (Number.isNaN(amount)) {
133
+ amountMinor = Number(amountRaw);
134
+ if (Number.isNaN(amountMinor)) {
112
135
  throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
113
136
  }
114
137
  } else if (typeof amountRaw === "string" && amountRaw !== "") {
115
138
  // PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
116
- amount = Number(amountRaw);
117
- if (Number.isNaN(amount)) {
139
+ amountMinor = Number(amountRaw);
140
+ if (Number.isNaN(amountMinor)) {
118
141
  throw new Error(
119
142
  `rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a number — DB corruption?`,
120
143
  );
@@ -128,7 +151,7 @@ export function rehydrateMoney(
128
151
  const currency =
129
152
  typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
130
153
 
131
- result[name] = { amount, currency };
154
+ result[name] = { amount: toMajorUnits(amountMinor), currency };
132
155
  }
133
156
 
134
157
  return result;
@@ -591,7 +591,7 @@ describe("boot-validator", () => {
591
591
  });
592
592
 
593
593
  test("warns when a role is used by exactly one handler, reached through the real validateBoot wiring", () => {
594
- const warnSpy = spyOn(console, "warn");
594
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
595
595
  try {
596
596
  const features = [
597
597
  defineFeature("a", (r) => {
@@ -616,7 +616,7 @@ describe("boot-validator", () => {
616
616
  });
617
617
 
618
618
  test("does NOT warn on unique access roles by default — opt-in only (#1711)", () => {
619
- const warnSpy = spyOn(console, "warn");
619
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
620
620
  try {
621
621
  const features = [
622
622
  defineFeature("b", (r) => {
@@ -15,7 +15,7 @@ describe("warnOnUniqueAccessRoles", () => {
15
15
  let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
16
16
 
17
17
  beforeEach(() => {
18
- warnSpy = spyOn(console, "warn");
18
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19
19
  });
20
20
 
21
21
  afterEach(() => {
@@ -73,6 +73,13 @@ import {
73
73
  import type { IdempotencyGuard } from "./idempotency";
74
74
  import type { LifecycleHooks } from "./lifecycle-pipeline";
75
75
 
76
+ // Framework/pipeline stays bundled-features-free, so this can't import the
77
+ // `tenant` feature — the literal below IS the coupling to its `timezone`
78
+ // config key. Renaming that key (or the "tenant" feature name) must update
79
+ // this constant too; tenant-timezone-boot.integration.test.ts boots the real
80
+ // createTenantFeature() and would catch a drift.
81
+ const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
82
+
76
83
  export type BatchCommand = {
77
84
  readonly type: string;
78
85
  readonly payload: unknown;
@@ -522,7 +529,7 @@ export async function buildHandlerContext(
522
529
  // comes from SessionUser.timezone (set at login), else falls back to
523
530
  // tenant (createTzContext's own default). An app-injected GeoTzProvider
524
531
  // (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
525
- const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
532
+ const tenantTz = config !== undefined ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
526
533
  // Guarded against garbage: an unvalidated string here (free-form config
527
534
  // key, legacy JWT claim predating validation) blows up every ctx.tz call
528
535
  // for the whole tenant with a RangeError. Fall back to UTC/tenant instead
@@ -61,6 +61,66 @@ function ownershipPredicates(
61
61
  return { sql: parts.join(" OR "), params };
62
62
  }
63
63
 
64
+ type MatchedRow = { id: string; tenant_id: string };
65
+
66
+ // ponytail: LIMIT/OFFSET, not a keyset cursor — mirrors reindexEntity's same
67
+ // tradeoff (id type varies uuid/serial across entities). A tenant-destroy
68
+ // purge is a one-time sweep, not a hot path.
69
+ const PURGE_BATCH_SIZE = 500;
70
+
71
+ async function collectMatchingRowsForEntity(
72
+ db: DbRunner,
73
+ tableName: string,
74
+ whereSql: string,
75
+ params: readonly unknown[],
76
+ ): Promise<readonly MatchedRow[]> {
77
+ const rows: MatchedRow[] = [];
78
+ let offset = 0;
79
+ for (;;) {
80
+ const offsetN = params.length + 1;
81
+ const page = await executeRawQuery<MatchedRow>(
82
+ db,
83
+ `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
84
+ ORDER BY ${quoteIdent("id")} ASC
85
+ LIMIT ${PURGE_BATCH_SIZE} OFFSET $${offsetN}`,
86
+ [...params, offset],
87
+ );
88
+ if (page.length === 0) break;
89
+ rows.push(...page);
90
+ offset += page.length;
91
+ if (page.length < PURGE_BATCH_SIZE) break;
92
+ }
93
+ return rows;
94
+ }
95
+
96
+ function buildSubjectPredicate(
97
+ entity: EntityDefinition,
98
+ fields: readonly string[],
99
+ likePattern: string,
100
+ subject: SubjectId | undefined,
101
+ ): { sql: string; params: unknown[] } {
102
+ let paramIdx = 0;
103
+ const nextParam = () => ++paramIdx;
104
+ const params: unknown[] = [];
105
+ const orParts: string[] = [];
106
+
107
+ const likeN = nextParam();
108
+ params.push(likePattern);
109
+ orParts.push(
110
+ `(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
111
+ );
112
+
113
+ if (subject) {
114
+ const owned = ownershipPredicates(entity, fields, subject, nextParam);
115
+ if (owned) {
116
+ params.push(...owned.params);
117
+ orParts.push(`(${owned.sql})`);
118
+ }
119
+ }
120
+
121
+ return { sql: orParts.join(" OR "), params };
122
+ }
123
+
64
124
  export async function purgeSearchDocumentsForSubject(
65
125
  db: DbRunner,
66
126
  features: ReadonlyMap<string, FeatureDefinition>,
@@ -78,30 +138,12 @@ export async function purgeSearchDocumentsForSubject(
78
138
  const fields = collectSearchableSubjectFields(entity);
79
139
  if (fields.length === 0) continue;
80
140
  const tableName = resolveTableName(entityName, entity, undefined);
81
-
82
- let paramIdx = 0;
83
- const nextParam = () => ++paramIdx;
84
- const params: unknown[] = [];
85
- const orParts: string[] = [];
86
-
87
- const likeN = nextParam();
88
- params.push(likePattern);
89
- orParts.push(
90
- `(${fields.map((f) => `${quoteIdent(toSnakeCase(f))} LIKE $${likeN}`).join(" OR ")})`,
91
- );
92
-
93
- if (subject) {
94
- const owned = ownershipPredicates(entity, fields, subject, nextParam);
95
- if (owned) {
96
- params.push(...owned.params);
97
- orParts.push(`(${owned.sql})`);
98
- }
99
- }
100
-
101
- const rows = await executeRawQuery<{ id: string; tenant_id: string }>(
141
+ const predicate = buildSubjectPredicate(entity, fields, likePattern, subject);
142
+ const rows = await collectMatchingRowsForEntity(
102
143
  db,
103
- `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${orParts.join(" OR ")}`,
104
- params,
144
+ tableName,
145
+ predicate.sql,
146
+ predicate.params,
105
147
  );
106
148
  for (const row of rows) {
107
149
  const key = `${row.tenant_id}:${entityName}:${row.id}`;