@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.
- package/package.json +3 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/request-locale.integration.test.ts +2 -2
- package/src/api/api-constants.ts +10 -0
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +17 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +3 -3
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/patch.ts +9 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/__tests__/write-failures.test.ts +3 -3
- package/src/errors/kumiko-error.ts +11 -11
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +26 -42
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +8 -6
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/schema-cli.ts +9 -4
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +30 -14
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/test-stack.ts +3 -0
- package/src/ui-types/index.ts +1 -0
- 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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
100
|
-
//
|
|
101
|
-
// unlisten handle was mishandled, the subscription
|
|
102
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
const
|
|
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
|
|
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.
|
|
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) —
|
|
155
|
-
// allowlist blocks flag-like names
|
|
156
|
-
|
|
157
|
-
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 +=
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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: ${
|
|
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 (
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
75
|
+
const err = await stack!.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
|
|
76
76
|
expect(err.code).toBe("unprocessable");
|
|
77
|
-
expect(err.httpStatus).
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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),
|