@cosmicdrift/kumiko-framework 0.174.0 → 0.176.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/api/__tests__/api.test.ts +11 -2
- package/src/crypto/ciphertext-pattern.ts +19 -0
- package/src/db/__tests__/entity-table-from-registry.test.ts +73 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +18 -0
- package/src/db/__tests__/migrate-runner.test.ts +16 -0
- package/src/db/blind-index-cleanup.ts +15 -24
- package/src/db/eagerload.ts +21 -5
- package/src/db/entity-table-from-registry.ts +31 -0
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-write.ts +6 -1
- package/src/db/index.ts +1 -0
- package/src/db/migrate-runner.ts +5 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -1
- package/src/engine/__tests__/ownership.test.ts +23 -0
- package/src/engine/__tests__/schema-builder.test.ts +64 -6
- package/src/engine/boot-validator/access-roles.ts +63 -21
- package/src/engine/boot-validator/entity-handler.ts +4 -0
- package/src/engine/boot-validator/index.ts +17 -2
- package/src/engine/boot-validator/pii-retention.ts +17 -10
- package/src/engine/boot-validator/screens.ts +10 -2
- package/src/engine/boot-validator.ts +1 -0
- package/src/engine/create-app.ts +4 -2
- package/src/engine/index.ts +2 -0
- package/src/engine/ownership.ts +19 -0
- package/src/engine/schema-builder.ts +42 -21
- package/src/entrypoint/index.ts +2 -5
- package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
- package/src/jobs/job-runner.ts +7 -1
- package/src/pipeline/system-hooks.ts +16 -3
- package/src/schema-cli.ts +7 -5
- package/src/search/purge-subject.ts +2 -9
- package/src/secrets/derive-purpose-secret.ts +6 -16
- package/src/testing/__tests__/e2e-generator.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +2 -2
- package/src/testing/e2e-generator.ts +5 -0
- package/src/testing/shared-entities.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.176.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.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.176.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.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.176.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -564,9 +564,18 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
564
564
|
// in-flight .next(), so cleanup only runs once the pending pull settles.
|
|
565
565
|
// Abort before heartbeatMs so the route hits the 499 branch; sleep then
|
|
566
566
|
// completes and the queued return drains the generator's finally.
|
|
567
|
+
//
|
|
568
|
+
// Abort is triggered off an entry signal, not a fixed sleep margin against
|
|
569
|
+
// the heartbeat timer — a stalled event loop could otherwise let the
|
|
570
|
+
// heartbeat fire first and flip the route onto the 200-SSE branch.
|
|
567
571
|
let cleanedUp = false;
|
|
572
|
+
let entered: () => void;
|
|
573
|
+
const atEntry = new Promise<void>((resolve) => {
|
|
574
|
+
entered = resolve;
|
|
575
|
+
});
|
|
568
576
|
const dispatcher = stubDispatcher(async function* () {
|
|
569
577
|
try {
|
|
578
|
+
entered();
|
|
570
579
|
await Bun.sleep(80);
|
|
571
580
|
yield { i: 0 };
|
|
572
581
|
} finally {
|
|
@@ -583,8 +592,8 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
583
592
|
signal: ac.signal,
|
|
584
593
|
}),
|
|
585
594
|
);
|
|
586
|
-
// Abort
|
|
587
|
-
await
|
|
595
|
+
// Abort as soon as the generator has been entered — no timing window left.
|
|
596
|
+
await atEntry;
|
|
588
597
|
ac.abort();
|
|
589
598
|
const res = await pending;
|
|
590
599
|
expect(res.status).toBe(499);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared SQL helpers for locating a subject's PII ciphertext by its inline
|
|
2
|
+
// subject-key prefix (kumiko-pii:v<version>:<subjectKey>:...). Used by both
|
|
3
|
+
// the blind-index sweep (db/blind-index-cleanup.ts) and the search-index
|
|
4
|
+
// purge (search/purge-subject.ts) — the two sweeps must stay in lockstep
|
|
5
|
+
// across ciphertext format versions.
|
|
6
|
+
|
|
7
|
+
export function quoteIdent(name: string): string {
|
|
8
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function escapeLikePattern(value: string): string {
|
|
12
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
|
|
16
|
+
// subject key placement is stable across versions.
|
|
17
|
+
export function subjectCiphertextLikePattern(subjectKey: string): string {
|
|
18
|
+
return `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../../engine/define-feature";
|
|
3
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
4
|
+
import { createRegistry } from "../../engine/registry";
|
|
5
|
+
import { extractTableName, type SchemaTable } from "../dialect";
|
|
6
|
+
import { entityTableFromRegistry } from "../entity-table-from-registry";
|
|
7
|
+
import { buildEntityTable } from "../table-builder";
|
|
8
|
+
|
|
9
|
+
function unitEntity() {
|
|
10
|
+
return createEntity({ table: "read_units", fields: { name: createTextField() } });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("entityTableFromRegistry", () => {
|
|
14
|
+
test("returns the table the registry booted, not one derived from the definition", () => {
|
|
15
|
+
const registry = createRegistry([
|
|
16
|
+
defineFeature("housing", (r) => {
|
|
17
|
+
r.entity("unit", unitEntity());
|
|
18
|
+
}),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const fromRegistry = entityTableFromRegistry(registry, "unit", unitEntity());
|
|
22
|
+
const projection = [...registry.getAllProjections().values()].find(
|
|
23
|
+
(p) => p.isImplicit && p.source === "unit",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(projection).toBeDefined();
|
|
27
|
+
expect(fromRegistry).toBe(projection?.table as SchemaTable);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// An entity nobody registered has no projection to read — the caller still
|
|
31
|
+
// gets a usable table instead of undefined, which is what made the helper
|
|
32
|
+
// worth having over a raw lookup.
|
|
33
|
+
test("falls back to deriving the table when no implicit projection exists", () => {
|
|
34
|
+
const registry = createRegistry([]);
|
|
35
|
+
|
|
36
|
+
const derived = entityTableFromRegistry(registry, "unit", unitEntity());
|
|
37
|
+
|
|
38
|
+
expect(extractTableName(derived)).toBe(
|
|
39
|
+
extractTableName(buildEntityTable("unit", unitEntity())),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// A projection for a different entity must not be handed back: both are
|
|
44
|
+
// implicit, and picking the first one would silently write into the wrong
|
|
45
|
+
// table.
|
|
46
|
+
test("ignores implicit projections of other entities", () => {
|
|
47
|
+
const registry = createRegistry([
|
|
48
|
+
defineFeature("housing", (r) => {
|
|
49
|
+
r.entity("unit", unitEntity());
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const other = createEntity({ table: "read_tenants", fields: { name: createTextField() } });
|
|
54
|
+
const derived = entityTableFromRegistry(registry, "tenant", other);
|
|
55
|
+
|
|
56
|
+
expect(extractTableName(derived)).toBe("read_tenants");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Guards the cast in entity-table-from-registry.ts: the helper hands the
|
|
61
|
+
// projection's table straight to callers that pass it to selectMany/executors,
|
|
62
|
+
// so it has to carry the physical name those consumers read off it.
|
|
63
|
+
test("the registry table carries a physical name", () => {
|
|
64
|
+
const registry = createRegistry([
|
|
65
|
+
defineFeature("housing", (r) => {
|
|
66
|
+
r.entity("unit", unitEntity());
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
expect(extractTableName(entityTableFromRegistry(registry, "unit", unitEntity()))).toBe(
|
|
71
|
+
"read_units",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
@@ -272,6 +272,24 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
|
|
|
272
272
|
);
|
|
273
273
|
expect(result.isSuccess).toBe(true);
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
// Review-fix (kumiko-framework#1685): a preSave hook that echoes `id`/
|
|
277
|
+
// `version` back in its return value must not have those leak into the
|
|
278
|
+
// persisted row — the framework-minted aggregateId stays authoritative.
|
|
279
|
+
test("create: preSave hook returning `id`/`version` does not override the minted aggregateId", async () => {
|
|
280
|
+
const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb, {
|
|
281
|
+
preSave: async (changes) => ({ ...changes, id: "hook-injected-id", version: 999 }),
|
|
282
|
+
});
|
|
283
|
+
expect(result.isSuccess).toBe(true);
|
|
284
|
+
if (!result.isSuccess) return;
|
|
285
|
+
expect(result.data.id).not.toBe("hook-injected-id");
|
|
286
|
+
|
|
287
|
+
const row = await asRawClient(testDb.db).unsafe(
|
|
288
|
+
`SELECT id FROM read_es_write_owned_field WHERE id = $1`,
|
|
289
|
+
[result.data.id],
|
|
290
|
+
);
|
|
291
|
+
expect(row.length).toBe(1);
|
|
292
|
+
});
|
|
275
293
|
});
|
|
276
294
|
|
|
277
295
|
// =============================================================================
|
|
@@ -99,4 +99,20 @@ describe("splitSqlStatements", () => {
|
|
|
99
99
|
'CREATE TABLE "a" ("id" uuid);',
|
|
100
100
|
]);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("throws fail-loud on a dollar-quoted body instead of splitting it in half", () => {
|
|
104
|
+
expect(() => splitSqlStatements("DO $$ BEGIN PERFORM 1; END $$;")).toThrow(
|
|
105
|
+
/unsupported dollar-quoted body/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("throws fail-loud on a tagged dollar-quoted body ($tag$...$tag$)", () => {
|
|
110
|
+
expect(() => splitSqlStatements("DO $tag$ BEGIN PERFORM 1; END $tag$;")).toThrow(
|
|
111
|
+
/unsupported dollar-quoted body/,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a bare $ not opening a dollar-tag does not false-positive (digit after $ is not a tag)", () => {
|
|
116
|
+
expect(splitSqlStatements("SELECT $1;")).toEqual(["SELECT $1;"]);
|
|
117
|
+
});
|
|
102
118
|
});
|
|
@@ -1,46 +1,37 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Immediate blind-index nulling after a subject erase (#818).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// bidx
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// (kumiko-pii:v1:<subjectKey>:...),
|
|
8
|
-
//
|
|
3
|
+
// After kms.eraseKey the ciphertext is unreadable, but the deterministic
|
|
4
|
+
// bidx column would stay matchable until the next write/rebuild — a
|
|
5
|
+
// linkage window ("does any row hold value X"). This sweep closes it right
|
|
6
|
+
// away: the ciphertext names its subject inline
|
|
7
|
+
// (kumiko-pii:v1:<subjectKey>:...), so a LIKE-prefix match finds exactly
|
|
8
|
+
// the erased subject's rows — one UPDATE per lookupable field.
|
|
9
9
|
//
|
|
10
|
-
// Rows
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Rows the forget run deletes/anonymizes via the executor anyway get their
|
|
11
|
+
// bidx recomputed automatically there; this sweep covers the rows left
|
|
12
|
+
// behind (foreign entities with userOwned fields).
|
|
13
13
|
|
|
14
14
|
import { collectLookupableFields } from "../crypto/blind-index";
|
|
15
|
+
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
15
16
|
import type { FeatureDefinition } from "../engine/types";
|
|
16
17
|
import { toSnakeCase } from "../utils/case";
|
|
17
18
|
import type { DbRunner } from "./connection";
|
|
18
19
|
import { resolveTableName } from "./entity-table-meta";
|
|
19
20
|
import { executeRawQuery } from "./queries/raw-sql";
|
|
20
21
|
|
|
21
|
-
function quoteIdent(name: string): string {
|
|
22
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function escapeLikePattern(value: string): string {
|
|
26
|
-
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
22
|
export async function nullBlindIndexesForSubject(
|
|
30
23
|
db: DbRunner,
|
|
31
24
|
features: ReadonlyMap<string, FeatureDefinition>,
|
|
32
25
|
subjectKey: string,
|
|
33
26
|
): Promise<void> {
|
|
34
|
-
|
|
35
|
-
// subject key placement is stable across versions.
|
|
36
|
-
const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
27
|
+
const likePattern = subjectCiphertextLikePattern(subjectKey);
|
|
37
28
|
for (const feature of features.values()) {
|
|
38
29
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
39
30
|
const lookupable = collectLookupableFields(entity);
|
|
40
31
|
if (lookupable.length === 0) continue;
|
|
41
|
-
//
|
|
42
|
-
// (buildEntityTable
|
|
43
|
-
//
|
|
32
|
+
// No featureName prefix — the dispatcher builds entity tables without
|
|
33
|
+
// one (buildEntityTable with no featureName option), the sweep has to
|
|
34
|
+
// hit the same names.
|
|
44
35
|
const tableName = resolveTableName(entityName, entity, undefined);
|
|
45
36
|
for (const fieldName of lookupable) {
|
|
46
37
|
const snake = toSnakeCase(fieldName);
|
package/src/db/eagerload.ts
CHANGED
|
@@ -118,9 +118,10 @@ function hasOwnershipScopedRead(refEntity: EntityDefinition): boolean {
|
|
|
118
118
|
async function decryptReferencedRow(
|
|
119
119
|
row: Record<string, unknown>,
|
|
120
120
|
refEntity: EntityDefinition,
|
|
121
|
+
piiFields: readonly string[],
|
|
122
|
+
encryptedFields: ReadonlySet<string>,
|
|
123
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
121
124
|
): Promise<Record<string, unknown>> {
|
|
122
|
-
const piiFields = collectPiiSubjectFields(refEntity);
|
|
123
|
-
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
124
125
|
if (hasOwnershipScopedRead(refEntity)) {
|
|
125
126
|
if (piiFields.length === 0 && encryptedFields.size === 0) return row;
|
|
126
127
|
const out = { ...row };
|
|
@@ -130,7 +131,6 @@ async function decryptReferencedRow(
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
let out = row;
|
|
133
|
-
const kms = configuredPiiSubjectKms();
|
|
134
134
|
if (piiFields.length > 0 && kms) {
|
|
135
135
|
out = await decryptPiiFieldValues(out, piiFields, kms, {
|
|
136
136
|
requestId: requestContext.get()?.requestId ?? "eagerload",
|
|
@@ -147,16 +147,22 @@ async function decryptReferencedRow(
|
|
|
147
147
|
// must not 500 the whole list request — the main rows the caller asked for
|
|
148
148
|
// are unrelated to this one broken reference. Drop just that row from the
|
|
149
149
|
// map; the renderer falls back to the raw UUID.
|
|
150
|
+
//
|
|
151
|
+
// piiFields/encryptedFields/kms are constant per refEntity (fw#1671) — the
|
|
152
|
+
// caller computes them once and passes them in instead of recomputing per row.
|
|
150
153
|
async function buildRefLookupMap(
|
|
151
154
|
rawRefRows: ReadonlyArray<Record<string, unknown>>,
|
|
152
155
|
refEntity: EntityDefinition,
|
|
153
156
|
refEntityName: string,
|
|
157
|
+
piiFields: readonly string[],
|
|
158
|
+
encryptedFields: ReadonlySet<string>,
|
|
159
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
154
160
|
): Promise<Map<string, Record<string, unknown>>> {
|
|
155
161
|
const map = new Map<string, Record<string, unknown>>();
|
|
156
162
|
for (const r of rawRefRows) {
|
|
157
163
|
let decrypted: Record<string, unknown>;
|
|
158
164
|
try {
|
|
159
|
-
decrypted = await decryptReferencedRow(r, refEntity);
|
|
165
|
+
decrypted = await decryptReferencedRow(r, refEntity, piiFields, encryptedFields, kms);
|
|
160
166
|
} catch (e) {
|
|
161
167
|
console.warn(
|
|
162
168
|
`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -214,7 +220,17 @@ export async function enrichWithReferences(
|
|
|
214
220
|
const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
|
|
215
221
|
Record<string, unknown>
|
|
216
222
|
>;
|
|
217
|
-
const
|
|
223
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
224
|
+
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
225
|
+
const kms = configuredPiiSubjectKms();
|
|
226
|
+
const map = await buildRefLookupMap(
|
|
227
|
+
rawRefRows,
|
|
228
|
+
refEntity,
|
|
229
|
+
rf.refEntityName,
|
|
230
|
+
piiFields,
|
|
231
|
+
encryptedFields,
|
|
232
|
+
kms,
|
|
233
|
+
);
|
|
218
234
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map };
|
|
219
235
|
}),
|
|
220
236
|
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The table an entity actually lives in, as the booted registry sees it.
|
|
2
|
+
//
|
|
3
|
+
// `buildEntityTable` derives a table from the entity definition alone, which is
|
|
4
|
+
// right until something changed the projection after registration — an
|
|
5
|
+
// extendEntityProjection, a featureName prefix, a table override. The registry
|
|
6
|
+
// carries the result of all of that, so a caller that needs to read or write
|
|
7
|
+
// the same rows the pipeline writes has to ask the registry first and may only
|
|
8
|
+
// fall back to deriving.
|
|
9
|
+
//
|
|
10
|
+
// Callers are the ones holding a registry and an entity definition but no
|
|
11
|
+
// executor: seeds, jobs and consumers that talk to a feature's read model
|
|
12
|
+
// directly.
|
|
13
|
+
|
|
14
|
+
import type { Registry } from "../engine";
|
|
15
|
+
import type { SchemaTable } from "./dialect";
|
|
16
|
+
import { buildEntityTable } from "./table-builder";
|
|
17
|
+
|
|
18
|
+
export function entityTableFromRegistry(
|
|
19
|
+
registry: Registry,
|
|
20
|
+
entityName: string,
|
|
21
|
+
entity: Parameters<typeof buildEntityTable>[1],
|
|
22
|
+
): SchemaTable {
|
|
23
|
+
for (const projection of registry.getAllProjections().values()) {
|
|
24
|
+
if (!projection.isImplicit) continue;
|
|
25
|
+
if (projection.source !== entityName) continue;
|
|
26
|
+
// @cast-boundary registry-projection — ProjectionDefinition.table is the
|
|
27
|
+
// untyped table object every projection registrar accepts.
|
|
28
|
+
return projection.table as SchemaTable;
|
|
29
|
+
}
|
|
30
|
+
return buildEntityTable(entityName, entity);
|
|
31
|
+
}
|
|
@@ -247,7 +247,7 @@ export function deriveEntityTableMeta(
|
|
|
247
247
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
248
248
|
const source = options?.source ?? "managed";
|
|
249
249
|
if (source === "unmanaged") {
|
|
250
|
-
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
250
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
|
|
251
251
|
}
|
|
252
252
|
const idType = entity.idType ?? "uuid";
|
|
253
253
|
|
|
@@ -424,6 +424,11 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
|
424
424
|
/**
|
|
425
425
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
426
426
|
* Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
|
|
427
|
+
*
|
|
428
|
+
* Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
|
|
429
|
+
* no softDelete — the app author owns tenant-scoping and retention for this
|
|
430
|
+
* table. Justify WHY in the call site; reviewers should scrutinize every new
|
|
431
|
+
* unmanaged table.
|
|
427
432
|
*/
|
|
428
433
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
429
434
|
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
@@ -69,7 +69,12 @@ async function runPreSave(
|
|
|
69
69
|
): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
|
|
70
70
|
if (!preSave) return { data: changes as DbRow };
|
|
71
71
|
try {
|
|
72
|
-
|
|
72
|
+
const hookResult = await preSave(changes, previous, isNew);
|
|
73
|
+
// A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
|
|
74
|
+
// must not leak them into the persisted row — aggregateId already comes
|
|
75
|
+
// from generateId()/the loaded row, not from hook output (fw#1685).
|
|
76
|
+
const { id: _hookId, version: _hookVersion, ...safe } = hookResult as Record<string, unknown>;
|
|
77
|
+
return { data: safe as DbRow };
|
|
73
78
|
} catch (e) {
|
|
74
79
|
return {
|
|
75
80
|
failure: writeFailure(
|
package/src/db/index.ts
CHANGED
package/src/db/migrate-runner.ts
CHANGED
|
@@ -160,6 +160,11 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
160
160
|
current += ch;
|
|
161
161
|
continue;
|
|
162
162
|
}
|
|
163
|
+
if (ch === "$" && /^\$([A-Za-z_]\w*)?\$/.test(sqlText.slice(i))) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"splitSqlStatements: unsupported dollar-quoted body — migration SQL is malformed, refusing to split",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
163
168
|
if (ch === ";") {
|
|
164
169
|
statements.push(current);
|
|
165
170
|
current = "";
|
|
@@ -626,6 +626,25 @@ describe("validateBoot — retention", () => {
|
|
|
626
626
|
expect(matchingWarn).toBeUndefined();
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
+
test("blockDelete with only a subjectRef-only field and no anonymize warns (#1645)", () => {
|
|
630
|
+
const feature = defineFeature("test", (r) => {
|
|
631
|
+
r.entity(
|
|
632
|
+
"lease",
|
|
633
|
+
createEntity({
|
|
634
|
+
fields: {
|
|
635
|
+
authorId: createTextField({ subjectRef: true }),
|
|
636
|
+
},
|
|
637
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
validateBoot([feature]);
|
|
642
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
643
|
+
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
644
|
+
);
|
|
645
|
+
expect(matchingWarn).toBeDefined();
|
|
646
|
+
});
|
|
647
|
+
|
|
629
648
|
test('retention.keepFor with invalid format "30days" warns', () => {
|
|
630
649
|
const feature = defineFeature("test", (r) => {
|
|
631
650
|
r.entity(
|
|
@@ -600,7 +600,7 @@ describe("boot-validator", () => {
|
|
|
600
600
|
});
|
|
601
601
|
}),
|
|
602
602
|
];
|
|
603
|
-
|
|
603
|
+
validateBootRaw(withBootValidatorFixture(features), { warnOnUniqueAccessRoles: true });
|
|
604
604
|
// Not toHaveBeenCalledTimes(1): this file's tests share the process-global
|
|
605
605
|
// console.warn and run with the default concurrency (bunfig.toml) — other
|
|
606
606
|
// concurrently-running tests' own "role used by one handler" warnings can
|
|
@@ -615,6 +615,27 @@ describe("boot-validator", () => {
|
|
|
615
615
|
}
|
|
616
616
|
});
|
|
617
617
|
|
|
618
|
+
test("does NOT warn on unique access roles by default — opt-in only (#1711)", () => {
|
|
619
|
+
const warnSpy = spyOn(console, "warn");
|
|
620
|
+
try {
|
|
621
|
+
const features = [
|
|
622
|
+
defineFeature("b", (r) => {
|
|
623
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
624
|
+
access: { roles: ["OnlyThereRole"] },
|
|
625
|
+
});
|
|
626
|
+
}),
|
|
627
|
+
];
|
|
628
|
+
validateBoot(features);
|
|
629
|
+
expect(
|
|
630
|
+
warnSpy.mock.calls.some((call) =>
|
|
631
|
+
(call[0] as string | undefined)?.includes("OnlyThereRole"),
|
|
632
|
+
),
|
|
633
|
+
).toBe(false);
|
|
634
|
+
} finally {
|
|
635
|
+
warnSpy.mockRestore();
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
618
639
|
test("throws when a stream handler has no access rule", () => {
|
|
619
640
|
const features = [
|
|
620
641
|
defineFeature("a", (r) => {
|
|
@@ -2886,6 +2907,35 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
2886
2907
|
);
|
|
2887
2908
|
});
|
|
2888
2909
|
|
|
2910
|
+
test("navigate with params targeting a cross-entity entityList screen → no throw (list screens read URL search params for filter-prefill, fw#1708)", () => {
|
|
2911
|
+
const feature = defineFeature("housing", (r) => {
|
|
2912
|
+
r.entity("unit", createEntity({ fields: { id: createTextField() } }));
|
|
2913
|
+
r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
|
|
2914
|
+
r.screen({
|
|
2915
|
+
id: "unit-list",
|
|
2916
|
+
type: "entityList",
|
|
2917
|
+
entity: "unit",
|
|
2918
|
+
columns: ["id"],
|
|
2919
|
+
rowActions: [
|
|
2920
|
+
{
|
|
2921
|
+
kind: "navigate",
|
|
2922
|
+
id: "view-contracts",
|
|
2923
|
+
label: "actions.viewContracts",
|
|
2924
|
+
screen: "contract-list",
|
|
2925
|
+
params: { map: { "housing:contract-list.f.unitId": "id" } },
|
|
2926
|
+
},
|
|
2927
|
+
],
|
|
2928
|
+
});
|
|
2929
|
+
r.screen({
|
|
2930
|
+
id: "contract-list",
|
|
2931
|
+
type: "entityList",
|
|
2932
|
+
entity: "contract",
|
|
2933
|
+
columns: ["unitId"],
|
|
2934
|
+
});
|
|
2935
|
+
});
|
|
2936
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
2937
|
+
});
|
|
2938
|
+
|
|
2889
2939
|
test("navigate with params targeting a custom screen → no throw (author owns the component, may read searchParams itself)", () => {
|
|
2890
2940
|
const feature = defineFeature("shop", (r) => {
|
|
2891
2941
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
@@ -195,6 +195,29 @@ describe("userCanReadFieldRow() — multi-role OR", () => {
|
|
|
195
195
|
// row with mismatched teamId — TeamMember would fail, Admin passes
|
|
196
196
|
expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
// fw#1700: matchesRule() throws on a where-rule (SQL-layer only, can't
|
|
200
|
+
// evaluate in-memory). Field-level access is boot-validator-rejected for
|
|
201
|
+
// where-rules, but this function is also reachable from hand-rolled
|
|
202
|
+
// entity-level reads — a role backed by a where-rule must fail closed
|
|
203
|
+
// (deny, no throw) instead of crashing the caller with an uncaught 500.
|
|
204
|
+
test("role backed by a where-rule → fails closed (deny), does not throw", () => {
|
|
205
|
+
const whereMap: OwnershipMap = {
|
|
206
|
+
Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
207
|
+
};
|
|
208
|
+
const user = mkUser({ roles: ["Support"] });
|
|
209
|
+
expect(() => userCanReadFieldRow(user, whereMap, { teamId: "ops" })).not.toThrow();
|
|
210
|
+
expect(userCanReadFieldRow(user, whereMap, { teamId: "ops" })).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("where-rule role does not block a later 'all' role in the same access map", () => {
|
|
214
|
+
const mixedMap: OwnershipMap = {
|
|
215
|
+
Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
216
|
+
Admin: "all",
|
|
217
|
+
};
|
|
218
|
+
const user = mkUser({ roles: ["Support", "Admin"] });
|
|
219
|
+
expect(userCanReadFieldRow(user, mixedMap, { teamId: "ops" })).toBe(true);
|
|
220
|
+
});
|
|
198
221
|
});
|
|
199
222
|
|
|
200
223
|
// --- userCanWriteFieldRow() — STRADDLE PREVENTION ---
|
|
@@ -450,6 +450,26 @@ describe("buildInsertSchema", () => {
|
|
|
450
450
|
}
|
|
451
451
|
});
|
|
452
452
|
|
|
453
|
+
// Review-fix (kumiko-framework#1712): an optional select WITHOUT a default
|
|
454
|
+
// normalizes an untouched <select> to null (see the "unset (null)" test
|
|
455
|
+
// above). A client that reuses that null against a since-defaulted field
|
|
456
|
+
// must fall back to the default too, not get rejected as an invalid enum
|
|
457
|
+
// value the way a bare `null` previously was.
|
|
458
|
+
test("optional select with default accepts null and falls back to the default", () => {
|
|
459
|
+
const entity = createEntity({
|
|
460
|
+
table: "Test",
|
|
461
|
+
fields: {
|
|
462
|
+
locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
const schema = buildInsertSchema(entity);
|
|
466
|
+
const result = schema.safeParse({ locale: null });
|
|
467
|
+
expect(result.success).toBe(true);
|
|
468
|
+
if (result.success) {
|
|
469
|
+
expect(result.data["locale"]).toBe("de");
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
453
473
|
test("optional select with default still validates a real value", () => {
|
|
454
474
|
const entity = createEntity({
|
|
455
475
|
table: "Test",
|
|
@@ -549,11 +569,13 @@ describe("buildUpdateSchema", () => {
|
|
|
549
569
|
}
|
|
550
570
|
});
|
|
551
571
|
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
572
|
+
// fw#1703: buildUpdateSchema never applies defaults for an OMITTED field —
|
|
573
|
+
// omitting a field must leave it untouched. But an explicit `""` from an
|
|
574
|
+
// untouched <select> is a submission, not an omission, and "a field with a
|
|
575
|
+
// default is never unset" (same invariant the insert path documents at
|
|
576
|
+
// #1702) — so "" must map to the field's default on update too, not clobber
|
|
577
|
+
// an existing value to null.
|
|
578
|
+
test("optional select with default on update: empty string falls back to the default", () => {
|
|
557
579
|
const entity = createEntity({
|
|
558
580
|
table: "Test",
|
|
559
581
|
fields: {
|
|
@@ -565,7 +587,43 @@ describe("buildUpdateSchema", () => {
|
|
|
565
587
|
const result = schema.safeParse({ locale: "" });
|
|
566
588
|
expect(result.success).toBe(true);
|
|
567
589
|
if (result.success) {
|
|
568
|
-
expect(result.data["locale"]).
|
|
590
|
+
expect(result.data["locale"]).toBe("de");
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("optional select with default on update: omitting the field leaves it untouched", () => {
|
|
595
|
+
const entity = createEntity({
|
|
596
|
+
table: "Test",
|
|
597
|
+
fields: {
|
|
598
|
+
locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const schema = buildUpdateSchema(entity);
|
|
603
|
+
const result = schema.safeParse({});
|
|
604
|
+
expect(result.success).toBe(true);
|
|
605
|
+
if (result.success) {
|
|
606
|
+
expect(Object.hasOwn(result.data, "locale")).toBe(false);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("required select with default on update: empty string falls back to the default instead of a required-error", () => {
|
|
611
|
+
const entity = createEntity({
|
|
612
|
+
table: "Test",
|
|
613
|
+
fields: {
|
|
614
|
+
locale: createSelectField({
|
|
615
|
+
options: ["de", "en"] as const,
|
|
616
|
+
default: "de",
|
|
617
|
+
required: true,
|
|
618
|
+
}),
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const schema = buildUpdateSchema(entity);
|
|
623
|
+
const result = schema.safeParse({ locale: "" });
|
|
624
|
+
expect(result.success).toBe(true);
|
|
625
|
+
if (result.success) {
|
|
626
|
+
expect(result.data["locale"]).toBe("de");
|
|
569
627
|
}
|
|
570
628
|
});
|
|
571
629
|
});
|