@cosmicdrift/kumiko-framework 0.220.0 → 0.221.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 (73) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/__tests__/request-locale.integration.test.ts +2 -2
  5. package/src/api/api-constants.ts +10 -0
  6. package/src/api/index.ts +1 -0
  7. package/src/api/server.ts +17 -1
  8. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  9. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  10. package/src/bun-db/index.ts +1 -0
  11. package/src/bun-db/query.ts +30 -14
  12. package/src/crypto/index.ts +1 -0
  13. package/src/crypto/is-self-pii-field.ts +8 -0
  14. package/src/crypto/subject-resolver.ts +4 -3
  15. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  16. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  17. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  18. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  19. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  20. package/src/db/blind-index-cleanup.ts +36 -19
  21. package/src/db/event-store-executor-context.ts +2 -2
  22. package/src/db/event-store-executor-read.ts +7 -6
  23. package/src/db/event-store-executor-write.ts +103 -49
  24. package/src/db/index.ts +2 -0
  25. package/src/db/migrate-generator.ts +14 -0
  26. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  27. package/src/db/queries/backfill-pii.ts +13 -10
  28. package/src/db/queries/raw-sql.ts +14 -2
  29. package/src/db/queries/seed-context.ts +8 -4
  30. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  31. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  32. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  33. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  34. package/src/engine/__tests__/schema-builder.test.ts +3 -3
  35. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  36. package/src/engine/boot-validator/entity-handler.ts +5 -0
  37. package/src/engine/boot-validator/pii-retention.ts +16 -4
  38. package/src/engine/boot-validator/screens.ts +9 -2
  39. package/src/engine/embedded-derived.ts +11 -10
  40. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  41. package/src/engine/feature-ast/patch.ts +9 -0
  42. package/src/engine/role-assignment.ts +36 -18
  43. package/src/errors/__tests__/classes.test.ts +5 -0
  44. package/src/errors/__tests__/write-failures.test.ts +3 -3
  45. package/src/errors/kumiko-error.ts +11 -11
  46. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  47. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  48. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  49. package/src/event-store/event-store.ts +7 -0
  50. package/src/event-store/index.ts +1 -0
  51. package/src/files/__tests__/files.integration.test.ts +181 -2
  52. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  53. package/src/files/file-routes.ts +53 -6
  54. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  55. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  56. package/src/i18n/index.ts +7 -1
  57. package/src/i18n/mail-registry.ts +10 -0
  58. package/src/i18n/request-locale.ts +11 -2
  59. package/src/i18n/required-surface-keys.ts +3 -1
  60. package/src/lifecycle/signal-handlers.ts +2 -0
  61. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  62. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +26 -42
  63. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +8 -6
  64. package/src/pipeline/distributed-lock.ts +3 -0
  65. package/src/schema-cli.ts +9 -4
  66. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  67. package/src/scripts/codemod/pii-personal-migration.ts +30 -14
  68. package/src/search/purge-subject.ts +4 -3
  69. package/src/search/reindex-entity.ts +2 -2
  70. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  71. package/src/stack/test-stack.ts +3 -0
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/upgrade-cli.ts +100 -12
@@ -90,6 +90,18 @@ describe("distributed lock", () => {
90
90
  expect(await lock.acquire("test-lock-7")).not.toBeNull();
91
91
  });
92
92
 
93
+ test("renew with ttlSeconds 0 does not drop the lock", async () => {
94
+ const lock = createDistributedLock(testRedis.redis);
95
+ const token = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
96
+ if (!token) throw new Error("expected token");
97
+ const renewed = await lock.renew("test-lock-ttl0", token, 0);
98
+ expect(renewed).toBe(false);
99
+ // Original lock still held — a peer acquire must fail.
100
+ const peer = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
101
+ expect(peer).toBeNull();
102
+ await lock.release("test-lock-ttl0", token);
103
+ });
104
+
93
105
  test("renew on an expired/absent key fails", async () => {
94
106
  const lock = createDistributedLock(testRedis.redis);
95
107
  const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);
@@ -1,36 +1,18 @@
1
1
  // E.4 — PG LISTEN/NOTIFY wake-up. Without this, delivery latency is
2
2
  // bounded below by pollIntervalMs. With LISTEN, event-store.append fires
3
- // `pg_notify` on commit and any subscribed dispatcher wakes immediately
4
- // latency becomes TCP round-trip, typically sub-millisecond on localhost.
3
+ // `pg_notify` on commit and any subscribed dispatcher wakes immediately.
5
4
  //
6
- // The polling timer stays on as a safety net for dropped subscriptions
7
- // and crashes between commit and wake. These tests pin:
8
- //
9
- // 1. NOTIFY runOnce fires promptly, without waiting for the timer.
10
- // 2. The dispatcher starts cleanly when pgClient is wired and stops
11
- // without leaking the LISTEN connection, and still wakes on NOTIFY
12
- // after a restart cycle.
13
- //
14
- // #2042: these used to assert an absolute millisecond latency bound
15
- // (`< 40`, later `< 100`) against the test-stack's default 50ms polling
16
- // timer. On a shared CI runner a stalled event loop can push even a
17
- // working LISTEN's delivery past 100ms — measured up to 154ms — so no
18
- // millisecond bound both clears runner noise and stays under a 50ms
19
- // timer. Fix: push the polling timer out to 60s for this stack
20
- // (`eventDispatcherPollIntervalMs`) and assert delivery happens at all
21
- // inside a 5s window. If LISTEN is dead, nothing arrives before the 5s
22
- // deadline — a 12x margin under the 60s timer that no runner stall gets
23
- // anywhere near. If LISTEN works, delivery is near-instant regardless of
24
- // runner load. Verified by temporarily forcing pgClient to undefined in
25
- // test-stack.ts: both tests then fail at toHaveLength(1) with 0 received
26
- // after ~5s, confirming the assertion still discriminates.
5
+ // The polling timer stays as a safety net. These tests pin NOTIFY wake
6
+ // without waiting for the timer, and that start/stop still leaves LISTEN
7
+ // working. #2042: poll interval is 60s here; assert delivery inside 5s —
8
+ // if LISTEN is dead the timer cannot rescue the assertion.
27
9
 
28
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
29
11
  import { createEventStoreExecutor } from "../../db/event-store-executor";
30
12
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
31
13
  import { defineFeature } from "../../engine";
32
14
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
33
- import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
15
+ import { sharedWidgetEntity, sharedWidgetTable, waitFor } from "../../testing";
34
16
 
35
17
  // --- Fixture ---
36
18
 
@@ -38,7 +20,7 @@ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity,
38
20
  entityName: "widget",
39
21
  });
40
22
 
41
- const deliveryTimes: number[] = [];
23
+ let deliveryCount = 0;
42
24
 
43
25
  const listenFeature = defineFeature("listen", (r) => {
44
26
  r.entity("widget", sharedWidgetEntity);
@@ -47,7 +29,7 @@ const listenFeature = defineFeature("listen", (r) => {
47
29
  name: "latency-probe",
48
30
  apply: {
49
31
  "widget.created": async () => {
50
- deliveryTimes.push(Date.now());
32
+ deliveryCount += 1;
51
33
  },
52
34
  },
53
35
  });
@@ -79,42 +61,44 @@ afterAll(async () => {
79
61
 
80
62
  describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
81
63
  test("NOTIFY on commit triggers runOnce without waiting for the poll timer", async () => {
82
- deliveryTimes.length = 0;
64
+ deliveryCount = 0;
83
65
 
84
66
  await stack.eventDispatcher?.start();
85
67
  try {
86
68
  await executor.create({ name: "latency-test" }, admin, tdb);
87
69
 
88
- const deadline = Date.now() + 5000;
89
- while (Date.now() < deadline && deliveryTimes.length === 0) {
90
- await new Promise((r) => setTimeout(r, 5));
91
- }
92
- expect(deliveryTimes).toHaveLength(1);
70
+ await waitFor(
71
+ () => {
72
+ expect(deliveryCount).toBe(1);
73
+ },
74
+ { delays: [100, 500, 1000, 3500] },
75
+ );
93
76
  } finally {
94
77
  await stack.eventDispatcher?.stop();
95
78
  }
96
79
  });
97
80
 
98
81
  test("dispatcher start/stop cycle with LISTEN attached still delivers after restart", async () => {
99
- // Repeated start/stop must not leak connections or break LISTEN. After
100
- // 3 cycles the last .start() should still wake on NOTIFY — if the
101
- // unlisten handle was mishandled, the subscription would either be
102
- // stale (LISTEN on a closed connection) or double-registered.
82
+ // Repeated start/stop must not leak connections or break LISTEN.
83
+ // Two start/stop cycles, then a third start() that must still wake on
84
+ // NOTIFY — if the unlisten handle was mishandled, the subscription
85
+ // would be stale or double-registered.
103
86
  for (let i = 0; i < 2; i++) {
104
87
  await stack.eventDispatcher?.start();
105
88
  await stack.eventDispatcher?.stop();
106
89
  }
107
90
 
108
- deliveryTimes.length = 0;
91
+ deliveryCount = 0;
109
92
  await stack.eventDispatcher?.start();
110
93
  try {
111
94
  await executor.create({ name: "restart-probe" }, admin, tdb);
112
95
 
113
- const deadline = Date.now() + 5000;
114
- while (Date.now() < deadline && deliveryTimes.length === 0) {
115
- await new Promise((r) => setTimeout(r, 5));
116
- }
117
- expect(deliveryTimes).toHaveLength(1);
96
+ await waitFor(
97
+ () => {
98
+ expect(deliveryCount).toBe(1);
99
+ },
100
+ { delays: [100, 500, 1000, 3500] },
101
+ );
118
102
  } finally {
119
103
  await stack.eventDispatcher?.stop();
120
104
  }
@@ -190,10 +190,12 @@ describe("ctx.loadAggregate via queryHandler — Marten AggregateStreamAsync equ
190
190
  { customer: "TimeTraveler", status: "draft" },
191
191
  admin,
192
192
  );
193
- // Capture the event timestamp from the events-table for a precise cutoff.
194
- // A too-small offset risks clock granularity flakes we rely on the
195
- // millisecond-precision timestamp column.
196
- const preApprove = new Date();
193
+ // Cutoff must come from the event row's created_at (Postgres now()), not
194
+ // host wall-clock Docker clock skew made Date()-based cutoffs exclude
195
+ // the create event (fw#2425).
196
+ const beforeApprove = await loadAggregateRaw(stack.db, created.id, admin.tenantId);
197
+ expect(beforeApprove).toHaveLength(1);
198
+ const preApprove = beforeApprove[0]!.createdAt;
197
199
  // Make sure the next event's createdAt is strictly after the cutoff.
198
200
  await new Promise((r) => setTimeout(r, 10));
199
201
  await stack.http.writeOk(
@@ -210,10 +212,10 @@ describe("ctx.loadAggregate via queryHandler — Marten AggregateStreamAsync equ
210
212
  );
211
213
  expect(now.approved).toBe(true);
212
214
 
213
- // asOf preApprove: the approval is in the future, not yet visible.
215
+ // asOf create.createdAt: the approval is in the future, not yet visible.
214
216
  const past = await stack.http.queryOk<{ approved: boolean; status: string }>(
215
217
  "asoftest:query:invoice:state",
216
- { id: created.id, asOf: preApprove.toISOString() },
218
+ { id: created.id, asOf: preApprove.toString() },
217
219
  admin,
218
220
  );
219
221
  expect(past.approved).toBe(false);
@@ -50,6 +50,9 @@ export function createDistributedLock(
50
50
  },
51
51
 
52
52
  async renew(key, token, ttlSeconds) {
53
+ // EXPIRE with 0/negative deletes the key but still returns 1 — reject
54
+ // before we claim ownership while releasing the lock.
55
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) return false;
53
56
  const result = (await redis.eval(
54
57
  renewScript,
55
58
  1,
package/src/schema-cli.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
12
12
  import { join, resolve as resolvePath } from "node:path";
13
13
  import {
14
+ assertValidMigrationName,
14
15
  baselineMigrations,
15
16
  createDbConnection,
16
17
  type DbConnection,
@@ -151,10 +152,14 @@ export async function runSchemaCli(
151
152
  out.err(" Usage: schema generate <name>");
152
153
  return 1;
153
154
  }
154
- // name lands unescaped in `${seq}_${name}.sql` (generateMigration) — this
155
- // allowlist blocks flag-like names and path traversal (`../../x`).
156
- if (name.startsWith("-") || !/^[A-Za-z0-9_-]+$/.test(name)) {
157
- out.err(` Invalid migration name "${name}" — use letters, digits, "-", "_" only.`);
155
+ // name lands unescaped in `${seq}_${name}.sql` (generateMigration) —
156
+ // shared allowlist blocks flag-like names, path traversal, and
157
+ // ENAMETOOLONG-length names.
158
+ try {
159
+ assertValidMigrationName(name);
160
+ } catch (e) {
161
+ const msg = e instanceof Error ? e.message : String(e);
162
+ out.err(` ${msg}`);
158
163
  out.err(" Usage: schema generate <name>");
159
164
  return 1;
160
165
  }
@@ -8,47 +8,55 @@
8
8
  //
9
9
  // Usage: bun scripts/codemod/crypto-shredding-testing-move.ts <targetDir> [--dry-run]
10
10
 
11
- import { resolve } from "node:path";
12
- import { Glob } from "bun";
13
- import { Project } from "ts-morph";
11
+ import { type Dirent, readdirSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { Project, type SourceFile } from "ts-morph";
14
14
 
15
15
  const OLD_SPECIFIER = "@cosmicdrift/kumiko-framework/crypto";
16
16
  const NEW_SPECIFIER = "@cosmicdrift/kumiko-framework/testing";
17
17
  const MOVED_NAMES = new Set(["resetPiiSubjectKmsForTests", "resetBlindIndexKeyForTests"]);
18
18
 
19
+ const EXCLUDE_DIRS = new Set(["node_modules", "dist", "build"]);
20
+
19
21
  function findTargetFiles(rootDir: string): string[] {
20
- const glob = new Glob("**/*.{ts,tsx}");
21
- const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
22
+ // Walk the tree and skip excluded dirs while descending — filtering the
23
+ // absolute path after a full Glob scan silently no-ops when the repo root
24
+ // itself contains "/build/" or "/node_modules/" (fw#2289).
22
25
  const files: string[] = [];
23
- for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
24
- const abs = resolve(rootDir, file);
25
- if (EXCLUDE.some((p) => abs.includes(p))) continue;
26
- files.push(abs);
27
- }
26
+ const walk = (dir: string): void => {
27
+ let entries: Dirent[];
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true });
30
+ } catch {
31
+ // skip: unreadable directory during walk — treat as empty.
32
+ return;
33
+ }
34
+ for (const ent of entries) {
35
+ if (ent.isDirectory()) {
36
+ if (EXCLUDE_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
37
+ walk(join(dir, ent.name));
38
+ continue;
39
+ }
40
+ if (ent.isFile() && /\.(ts|tsx)$/.test(ent.name)) {
41
+ files.push(join(dir, ent.name));
42
+ }
43
+ }
44
+ };
45
+ walk(rootDir);
28
46
  return files.sort();
29
47
  }
30
48
 
31
- async function main(): Promise<void> {
32
- const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
33
- const dryRun = process.argv.includes("--dry-run");
34
- const rootDir = resolve(positional[0] ?? process.cwd());
35
-
36
- const files = findTargetFiles(rootDir);
37
- const project = new Project({
38
- skipAddingFilesFromTsConfig: true,
39
- skipFileDependencyResolution: true,
40
- });
41
-
42
- let touchedFiles = 0;
43
- let movedNames = 0;
44
-
45
- for (const file of files) {
46
- const sourceFile = project.addSourceFileAtPath(file);
47
- const oldImport = sourceFile
48
- .getImportDeclarations()
49
- .find((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER);
50
- if (!oldImport) continue;
49
+ /** Move matching value imports from crypto → testing. Returns count of names moved. */
50
+ function migrateFile(sourceFile: SourceFile): number {
51
+ // Value imports only — a pre-existing `import type { … } from testing`
52
+ // must not receive runtime helpers (they would be erased).
53
+ const oldImports = sourceFile
54
+ .getImportDeclarations()
55
+ .filter((d) => d.getModuleSpecifierValue() === OLD_SPECIFIER && !d.isTypeOnly());
56
+ if (oldImports.length === 0) return 0;
51
57
 
58
+ let fileMoved = 0;
59
+ for (const oldImport of oldImports) {
52
60
  const movedHere = oldImport.getNamedImports().filter((spec) => MOVED_NAMES.has(spec.getName()));
53
61
  if (movedHere.length === 0) continue;
54
62
 
@@ -56,7 +64,7 @@ async function main(): Promise<void> {
56
64
 
57
65
  const existingNewImport = sourceFile
58
66
  .getImportDeclarations()
59
- .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER);
67
+ .find((d) => d.getModuleSpecifierValue() === NEW_SPECIFIER && !d.isTypeOnly());
60
68
  if (existingNewImport) {
61
69
  const already = new Set(existingNewImport.getNamedImports().map((s) => s.getName()));
62
70
  for (const name of names) {
@@ -73,8 +81,32 @@ async function main(): Promise<void> {
73
81
  !!oldImport.getNamespaceImport();
74
82
  if (!remaining) oldImport.remove();
75
83
 
84
+ fileMoved += names.length;
85
+ }
86
+ return fileMoved;
87
+ }
88
+
89
+ async function main(): Promise<void> {
90
+ const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
91
+ const dryRun = process.argv.includes("--dry-run");
92
+ const rootDir = resolve(positional[0] ?? process.cwd());
93
+
94
+ const files = findTargetFiles(rootDir);
95
+ const project = new Project({
96
+ skipAddingFilesFromTsConfig: true,
97
+ skipFileDependencyResolution: true,
98
+ });
99
+
100
+ let touchedFiles = 0;
101
+ let movedNames = 0;
102
+
103
+ for (const file of files) {
104
+ const sourceFile = project.addSourceFileAtPath(file);
105
+ const fileMoved = migrateFile(sourceFile);
106
+ if (fileMoved === 0) continue;
107
+
76
108
  touchedFiles++;
77
- movedNames += names.length;
109
+ movedNames += fileMoved;
78
110
  if (!dryRun) sourceFile.saveSync();
79
111
  }
80
112
 
@@ -204,15 +204,22 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
204
204
  }
205
205
 
206
206
  if (subjectProps.length > 1) {
207
- report(
208
- subjectProps[0]!.prop,
209
- `multiple subject annotations on one field (${subjectProps.map((s) => s.name).join(", ")}) — needs a human decision on which subject is correct`,
210
- );
207
+ const first = subjectProps[0];
208
+ if (first) {
209
+ report(
210
+ first.prop,
211
+ `multiple subject annotations on one field (${subjectProps.map((s) => s.name).join(", ")}) — needs a human decision on which subject is correct`,
212
+ );
213
+ }
211
214
  // skip: reported above — multiple subject annotations need a human call on which one wins
212
215
  return;
213
216
  }
214
217
 
215
- const subject = subjectProps[0]!;
218
+ const subject = subjectProps[0];
219
+ if (!subject) {
220
+ // skip: length===0 and length>1 already returned — empty [0] is unreachable
221
+ return;
222
+ }
216
223
 
217
224
  if (NO_PERSONAL_FACTORIES.has(factoryName)) {
218
225
  report(
@@ -276,12 +283,13 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
276
283
  return;
277
284
  }
278
285
  const ownerFieldProp = findProp(init, "ownerField");
279
- if (!ownerFieldProp?.getInitializer()) {
286
+ const ownerFieldInit = ownerFieldProp?.getInitializer();
287
+ if (!ownerFieldInit) {
280
288
  report(subject.prop, 'userOwned is missing an "ownerField" property');
281
289
  // skip: reported above — userOwned is missing its ownerField property
282
290
  return;
283
291
  }
284
- personalInit = `{ of: ${ownerFieldProp.getInitializer()!.getText()} }`;
292
+ personalInit = `{ of: ${ownerFieldInit.getText()} }`;
285
293
  }
286
294
 
287
295
  const subjectIsRefOrPlaintext =
@@ -331,9 +339,9 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
331
339
  const hasSearchable = !!searchableProp;
332
340
  const hasSensitive = !!sensitiveProp;
333
341
 
334
- if (hasSensitive && (hasLookupable || hasSearchable)) {
342
+ if (sensitiveProp && (hasLookupable || hasSearchable)) {
335
343
  report(
336
- sensitiveProp!,
344
+ sensitiveProp,
337
345
  'sensitive combined with lookupable/searchable — two find values ("secret" vs "exact"/"fuzzy"), needs a human decision',
338
346
  );
339
347
  // skip: reported above — sensitive plus lookupable/searchable is an ambiguous find value
@@ -348,10 +356,13 @@ function processObjectLiteral(obj: ObjectLiteralExpression, factoryName: string)
348
356
  else find = "none";
349
357
 
350
358
  if (isLongTextFind && (find === "exact" || find === "fuzzy")) {
351
- report(
352
- (lookupableProp ?? searchableProp)!,
353
- 'lookupable/searchable on createLongTextField — only "none"/"secret" are valid find values on longText',
354
- );
359
+ const findSite = lookupableProp ?? searchableProp;
360
+ if (findSite) {
361
+ report(
362
+ findSite,
363
+ 'lookupable/searchable on createLongTextField — only "none"/"secret" are valid find values on longText',
364
+ );
365
+ }
355
366
  // skip: reported above — exact/fuzzy find isn't valid on createLongTextField
356
367
  return;
357
368
  }
@@ -381,7 +392,12 @@ function applyTransform(
381
392
  next: { personal: string; reason: string | undefined; find: string | undefined },
382
393
  ): void {
383
394
  const properties = obj.getProperties();
384
- const anchor = removedProps[0]!;
395
+ // Call sites always pass at least subject.prop; without an anchor there is nowhere to insert.
396
+ const anchor = removedProps[0];
397
+ if (!anchor) {
398
+ // skip: no removed prop to anchor inserts on — nothing to transform
399
+ return;
400
+ }
385
401
  const anchorIndex = properties.indexOf(anchor);
386
402
  const removedSet = new Set<PropertyAssignment>(removedProps);
387
403
  let insertIndex = 0;
@@ -9,11 +9,12 @@
9
9
  // that still carry the subject key in encrypted columns.
10
10
 
11
11
  import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
12
+ import { isSelfPiiField } from "../crypto/is-self-pii-field";
12
13
  import type { SubjectId } from "../crypto/kms-adapter";
13
14
  import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
14
15
  import type { DbRunner } from "../db/connection";
15
16
  import { resolveTableName } from "../db/entity-table-meta";
16
- import { executeRawQuery } from "../db/queries/raw-sql";
17
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
17
18
  import type { FeatureDefinition } from "../engine/types";
18
19
  import type { EntityDefinition } from "../engine/types/fields";
19
20
  import type { EntityId, TenantId } from "../engine/types/identifiers";
@@ -46,7 +47,7 @@ function ownershipPredicates(
46
47
  params.push(subject.userId);
47
48
  parts.push(`${quoteIdent(col)} = $${n}`);
48
49
  }
49
- } else if ("pii" in field && field.pii === true && selfIdN === undefined) {
50
+ } else if (isSelfPiiField(field) && selfIdN === undefined) {
50
51
  selfIdN = nextParam();
51
52
  params.push(subject.userId);
52
53
  parts.push(`${quoteIdent("id")} = $${selfIdN}`);
@@ -78,7 +79,7 @@ async function collectMatchingRowsForEntity(
78
79
  let offset = 0;
79
80
  for (;;) {
80
81
  const offsetN = params.length + 1;
81
- const page = await executeRawQuery<MatchedRow>(
82
+ const page = await executeRawQueryRead<MatchedRow>(
82
83
  db,
83
84
  `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
84
85
  ORDER BY ${quoteIdent("id")} ASC
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { DbRunner } from "../db/connection";
9
9
  import { resolveTableName } from "../db/entity-table-meta";
10
- import { executeRawQuery } from "../db/queries/raw-sql";
10
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
11
11
  import type { Registry, TenantId } from "../engine/types";
12
12
  import {
13
13
  buildSearchDocument,
@@ -112,7 +112,7 @@ export async function reindexEntity(
112
112
  // across both types needs a text cast that breaks integer ordering.
113
113
  // This is a one-time backfill over existing rows, not a live hot path;
114
114
  // switch to keyset if it ever needs to run against a churning table.
115
- const rows = await executeRawQuery<Record<string, unknown>>(
115
+ const rows = await executeRawQueryRead<Record<string, unknown>>(
116
116
  db,
117
117
  `SELECT * FROM ${quoteIdent(tableName)}
118
118
  WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter}
@@ -10,7 +10,7 @@ import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
10
10
  import { setupTestStack, type TestStack } from "../test-stack";
11
11
  import { TestUsers } from "../test-users";
12
12
 
13
- let stack: TestStack;
13
+ let stack: TestStack | undefined;
14
14
 
15
15
  const pingFeature = defineFeature("reqhelp", (r) => {
16
16
  r.writeHandler(
@@ -49,12 +49,12 @@ beforeAll(async () => {
49
49
  });
50
50
 
51
51
  afterAll(async () => {
52
- await stack.cleanup();
52
+ await stack?.cleanup();
53
53
  });
54
54
 
55
55
  describe("createRequestHelper via setupTestStack.http", () => {
56
56
  test("writeOk posts to /api/write and returns handler data", async () => {
57
- const data = await stack.http.writeOk<{ note: string; userId: string }>(
57
+ const data = await stack!.http.writeOk<{ note: string; userId: string }>(
58
58
  "reqhelp:write:echo",
59
59
  { note: "hello" },
60
60
  TestUsers.admin,
@@ -63,7 +63,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
63
63
  });
64
64
 
65
65
  test("queryOk posts to /api/query and returns handler data", async () => {
66
- const data = await stack.http.queryOk<{ id: string; ok: boolean }>(
66
+ const data = await stack!.http.queryOk<{ id: string; ok: boolean }>(
67
67
  "reqhelp:query:lookup",
68
68
  { id: "abc" },
69
69
  TestUsers.admin,
@@ -72,14 +72,14 @@ describe("createRequestHelper via setupTestStack.http", () => {
72
72
  });
73
73
 
74
74
  test("writeErr returns structured WriteErrorInfo with httpStatus", async () => {
75
- const err = await stack.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
75
+ const err = await stack!.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
76
76
  expect(err.code).toBe("unprocessable");
77
- expect(err.httpStatus).toBeGreaterThanOrEqual(400);
77
+ expect(err.httpStatus).toBe(422);
78
78
  expect(err.i18nKey).toBe("errors.unprocessable");
79
79
  });
80
80
 
81
81
  test("queryErr returns structured WriteErrorInfo for not-found", async () => {
82
- const err = await stack.http.queryErr(
82
+ const err = await stack!.http.queryErr(
83
83
  "reqhelp:query:lookup",
84
84
  { id: "missing" },
85
85
  TestUsers.admin,
@@ -89,13 +89,13 @@ describe("createRequestHelper via setupTestStack.http", () => {
89
89
  });
90
90
 
91
91
  test("writeOk throws when the write fails (so suites cannot ignore failures)", async () => {
92
- await expect(stack.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
92
+ await expect(stack!.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
93
93
  /reqhelp:write:boom/,
94
94
  );
95
95
  });
96
96
 
97
97
  test("batch posts commands and returns per-command results", async () => {
98
- const res = await stack.http.batch(
98
+ const res = await stack!.http.batch(
99
99
  [
100
100
  { type: "reqhelp:write:echo", payload: { note: "a" } },
101
101
  { type: "reqhelp:write:echo", payload: { note: "b" } },
@@ -112,7 +112,7 @@ describe("createRequestHelper via setupTestStack.http", () => {
112
112
  });
113
113
 
114
114
  test("writeWithHeaders forwards extra headers alongside auth", async () => {
115
- const res = await stack.http.writeWithHeaders(
115
+ const res = await stack!.http.writeWithHeaders(
116
116
  "reqhelp:write:echo",
117
117
  { note: "hdr" },
118
118
  TestUsers.admin,
@@ -122,5 +122,19 @@ describe("createRequestHelper via setupTestStack.http", () => {
122
122
  const body = (await res.json()) as { isSuccess?: boolean; data?: { note?: string } };
123
123
  expect(body.isSuccess).toBe(true);
124
124
  expect(body.data?.note).toBe("hdr");
125
+ expect(res.headers.get("X-Correlation-ID")).toBe("corr-42");
126
+ });
127
+
128
+ test("queryWithHeaders forwards extra headers alongside auth", async () => {
129
+ const res = await stack!.http.queryWithHeaders(
130
+ "reqhelp:query:lookup",
131
+ { id: "abc" },
132
+ TestUsers.admin,
133
+ { "X-Correlation-ID": "corr-query" },
134
+ );
135
+ expect(res.ok).toBe(true);
136
+ expect(res.headers.get("X-Correlation-ID")).toBe("corr-query");
137
+ const body = (await res.json()) as { data?: { id?: string; ok?: boolean } };
138
+ expect(body.data).toEqual({ id: "abc", ok: true });
125
139
  });
126
140
  });
@@ -72,6 +72,8 @@ export type TestStackOptions = {
72
72
  jwtSecret?: string;
73
73
  /** Extra fields merged into the AppContext (e.g. _notifyFactory, configResolver).
74
74
  * Can be a function receiving (registry, db, sseBroker) for late binding. */
75
+ /** Boot default locale (BCP-47) merged into AppContext before extraContext. */
76
+ defaultLocale?: string;
75
77
  extraContext?:
76
78
  | Record<string, unknown>
77
79
  | ((deps: {
@@ -294,6 +296,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
294
296
  registry,
295
297
  ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
296
298
  ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
299
+ ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
297
300
  ...(typeof options.extraContext === "function"
298
301
  ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
299
302
  : options.extraContext),
@@ -77,6 +77,7 @@ export type {
77
77
  EntityEditScreenDefinition,
78
78
  EntityListScreenDefinition,
79
79
  FieldCondition,
80
+ FieldIconKey,
80
81
  FieldRenderer,
81
82
  FormWidth,
82
83
  ListColumnSpec,