@cosmicdrift/kumiko-framework 0.24.1 → 0.26.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 +1 -1
- package/src/bun-db/__tests__/extract-table-info.test.ts +22 -1
- package/src/bun-db/query.ts +5 -2
- package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
- package/src/db/__tests__/sql-inventory.test.ts +26 -1
- package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/queries/event-store.ts +2 -7
- package/src/db/sql-inventory.ts +9 -0
- package/src/db/tenant-db.ts +5 -2
- package/src/engine/__tests__/boot-validator.test.ts +79 -0
- package/src/engine/__tests__/post-query-hook.test.ts +6 -6
- package/src/engine/__tests__/registry.test.ts +58 -0
- package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
- package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
- package/src/engine/boot-validator/api-ext.ts +1 -5
- package/src/engine/boot-validator/screens-nav.ts +18 -2
- package/src/engine/registry.ts +43 -12
- package/src/engine/types/fields.ts +2 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +4 -1
- package/src/engine/validate-projection-allowlist.ts +13 -3
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/classes.ts +4 -2
- package/src/files/__tests__/file-ref-entity.test.ts +34 -0
- package/src/files/__tests__/in-memory-provider.test.ts +94 -0
- package/src/logging/__tests__/fallback-logger.test.ts +47 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
- package/src/pipeline/dispatcher.ts +57 -57
- package/src/pipeline/system-hooks.ts +17 -5
- package/src/random/__tests__/words.test.ts +44 -0
- package/src/random/generate.ts +3 -3
- package/src/random/words.ts +3 -3
- package/src/stack/table-helpers.ts +2 -2
- package/src/stack/test-stack.ts +3 -4
- package/src/errors/__tests__/field-issue-compat.test.ts +0 -16
|
@@ -62,7 +62,15 @@ export function validateScreens(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
for (const section of screen.layout.sections) {
|
|
65
|
-
if (isExtensionEditSection(section))
|
|
65
|
+
if (isExtensionEditSection(section)) {
|
|
66
|
+
if (section.component.react === undefined && section.component.native === undefined) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) extension section ` +
|
|
69
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
66
74
|
if (section.fields.length === 0) {
|
|
67
75
|
throw new Error(
|
|
68
76
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
|
|
@@ -161,7 +169,15 @@ export function validateScreens(
|
|
|
161
169
|
);
|
|
162
170
|
}
|
|
163
171
|
for (const section of screen.layout.sections) {
|
|
164
|
-
if (isExtensionEditSection(section))
|
|
172
|
+
if (isExtensionEditSection(section)) {
|
|
173
|
+
if (section.component.react === undefined && section.component.native === undefined) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) extension section ` +
|
|
176
|
+
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
165
181
|
if (section.fields.length === 0) {
|
|
166
182
|
throw new Error(
|
|
167
183
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
|
package/src/engine/registry.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
+
import { resolveTableName } from "../db/entity-table-meta";
|
|
2
3
|
import { buildEntityTable } from "../db/table-builder";
|
|
3
4
|
import { buildMetricName, validateMetricName } from "../observability";
|
|
4
5
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
@@ -174,6 +175,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
174
175
|
// Cousin of rawTables: same uniqueness-by-tableName invariant, different
|
|
175
176
|
// storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
|
|
176
177
|
const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
|
|
178
|
+
// Final physical table names (entity-derived + unmanaged) → owner. Catches
|
|
179
|
+
// a collision between an r.unmanagedTable() tableName and an r.entity()
|
|
180
|
+
// physical name at boot instead of as a duplicate CREATE TABLE in migrate.
|
|
181
|
+
const physicalTableOwners = new Map<
|
|
182
|
+
string,
|
|
183
|
+
{ kind: "entity" | "unmanaged"; owner: string; featureName: string }
|
|
184
|
+
>();
|
|
177
185
|
// Auth-claims hooks — tagged with featureName so the login resolver can
|
|
178
186
|
// auto-prefix each hook's returned keys with "<feature>:".
|
|
179
187
|
const authClaimsHooks: AuthClaimsHookDef[] = [];
|
|
@@ -321,6 +329,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
321
329
|
throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
|
|
322
330
|
}
|
|
323
331
|
entityMap.set(name, entity);
|
|
332
|
+
const physical = resolveTableName(name, entity, feature.name);
|
|
333
|
+
const clash = physicalTableOwners.get(physical);
|
|
334
|
+
if (clash?.kind === "unmanaged") {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
337
|
+
`collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
338
|
+
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
physicalTableOwners.set(physical, { kind: "entity", owner: name, featureName: feature.name });
|
|
324
342
|
}
|
|
325
343
|
|
|
326
344
|
// Relations: entityName (not prefixed)
|
|
@@ -559,6 +577,19 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
559
577
|
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
560
578
|
);
|
|
561
579
|
}
|
|
580
|
+
const physicalClash = physicalTableOwners.get(umName);
|
|
581
|
+
if (physicalClash?.kind === "entity") {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
|
|
584
|
+
`table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
|
|
585
|
+
`Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
physicalTableOwners.set(umName, {
|
|
589
|
+
kind: "unmanaged",
|
|
590
|
+
owner: umName,
|
|
591
|
+
featureName: feature.name,
|
|
592
|
+
});
|
|
562
593
|
unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
|
|
563
594
|
}
|
|
564
595
|
|
|
@@ -870,7 +901,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
870
901
|
if (!hasFieldAccessRules(feature)) continue;
|
|
871
902
|
|
|
872
903
|
// Write handlers: ALL must be entity-mapped (security-critical, writes need field-access checks)
|
|
873
|
-
for (const handlerName of Object.keys(feature.writeHandlers)) {
|
|
904
|
+
for (const handlerName of Object.keys(feature.writeHandlers ?? {})) {
|
|
874
905
|
const qualified = qualify(feature.name, "write", handlerName);
|
|
875
906
|
if (!handlerEntityMap.has(qualified)) {
|
|
876
907
|
throw new Error(
|
|
@@ -882,7 +913,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
882
913
|
|
|
883
914
|
// Query handlers: only those with a dash must resolve (typo protection).
|
|
884
915
|
// No dash = standalone query (dashboard, stats) — intentionally not entity-bound.
|
|
885
|
-
for (const handlerName of Object.keys(feature.queryHandlers)) {
|
|
916
|
+
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
886
917
|
if (!handlerName.includes(":")) continue;
|
|
887
918
|
const qualified = qualify(feature.name, "query", handlerName);
|
|
888
919
|
if (!handlerEntityMap.has(qualified)) {
|
|
@@ -1131,23 +1162,23 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1131
1162
|
// postQuery-special.
|
|
1132
1163
|
const allEntities = new Set<string>();
|
|
1133
1164
|
for (const feature of features) {
|
|
1134
|
-
for (const entityName of Object.keys(feature.entities)) {
|
|
1165
|
+
for (const entityName of Object.keys(feature.entities ?? {})) {
|
|
1135
1166
|
allEntities.add(entityName);
|
|
1136
1167
|
}
|
|
1137
1168
|
}
|
|
1138
1169
|
const entityHookMaps = [
|
|
1139
|
-
{ map: entityPostSaveHooks, phase: "postSave (entityHook)" },
|
|
1140
|
-
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)" },
|
|
1141
|
-
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)" },
|
|
1142
|
-
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)" },
|
|
1143
|
-
{ map: searchPayloadExtensions, phase: "searchPayloadExtension" },
|
|
1170
|
+
{ map: entityPostSaveHooks, phase: "postSave (entityHook)", kind: "hook" },
|
|
1171
|
+
{ map: entityPreDeleteHooks, phase: "preDelete (entityHook)", kind: "hook" },
|
|
1172
|
+
{ map: entityPostDeleteHooks, phase: "postDelete (entityHook)", kind: "hook" },
|
|
1173
|
+
{ map: entityPostQueryHooks, phase: "postQuery (entityHook)", kind: "hook" },
|
|
1174
|
+
{ map: searchPayloadExtensions, phase: "searchPayloadExtension", kind: "extension" },
|
|
1144
1175
|
] as const;
|
|
1145
|
-
for (const { map, phase } of entityHookMaps) {
|
|
1176
|
+
for (const { map, phase, kind } of entityHookMaps) {
|
|
1146
1177
|
for (const entityName of map.keys()) {
|
|
1147
1178
|
if (!allEntities.has(entityName)) {
|
|
1148
1179
|
throw new Error(
|
|
1149
|
-
`${phase}
|
|
1150
|
-
`Check for typos — the
|
|
1180
|
+
`${phase} ${kind} targets entity "${entityName}" but no entity with that name exists. ` +
|
|
1181
|
+
`Check for typos — the ${kind} will never fire.`,
|
|
1151
1182
|
);
|
|
1152
1183
|
}
|
|
1153
1184
|
}
|
|
@@ -1492,7 +1523,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1492
1523
|
|
|
1493
1524
|
/** Returns true if any entity in the feature has field-level access rules (read or write). */
|
|
1494
1525
|
function hasFieldAccessRules(feature: FeatureDefinition): boolean {
|
|
1495
|
-
for (const entity of Object.values(feature.entities)) {
|
|
1526
|
+
for (const entity of Object.values(feature.entities ?? {})) {
|
|
1496
1527
|
for (const field of Object.values(entity.fields)) {
|
|
1497
1528
|
if (field.access?.read?.length || field.access?.write?.length) {
|
|
1498
1529
|
return true;
|
|
@@ -484,7 +484,8 @@ export type TransitionMap = Readonly<Record<string, readonly string[]>>;
|
|
|
484
484
|
* vermeidet Migration-Churn beim Refactor.
|
|
485
485
|
*
|
|
486
486
|
* Single-column indices über `tenantId` sind redundant (buildEntityTable
|
|
487
|
-
* legt die immer automatisch an); die Boot-Validation warnt
|
|
487
|
+
* legt die immer automatisch an); die Boot-Validation warnt (außer
|
|
488
|
+
* `{ unique: true }` — semantische 1:1-Constraint, kein Performance-Hint). */
|
|
488
489
|
export type EntityIndexDef = {
|
|
489
490
|
readonly columns: readonly [string, ...string[]];
|
|
490
491
|
readonly unique?: boolean;
|
|
@@ -223,7 +223,7 @@ type SharedContextFields = {
|
|
|
223
223
|
// set handler needs to encrypt on write, the resolver needs to decrypt
|
|
224
224
|
// on read, and both reach for the same provider. Wired via extraContext.
|
|
225
225
|
readonly configEncryption?: import("../../db").EncryptionProvider;
|
|
226
|
-
// Rate-limit resolver. Wired by the framework when the `
|
|
226
|
+
// Rate-limit resolver. Wired by the framework when the `rate-limiting`
|
|
227
227
|
// feature is loaded — pipeline reads handler.rateLimit and calls
|
|
228
228
|
// .enforce() on this resolver before access-check. Absent when the
|
|
229
229
|
// app didn't load the feature: handlers with rateLimit set are
|
|
@@ -84,7 +84,10 @@ export type PreQueryHookFn = (
|
|
|
84
84
|
// on added fields (field-access-filter only knows entity's stammfields).
|
|
85
85
|
export type PostQueryHookFn = (
|
|
86
86
|
result: {
|
|
87
|
-
|
|
87
|
+
// undefined for standalone queries (no-colon handler names like
|
|
88
|
+
// "ns:dashboard") — those have no backing entity, but handler-keyed
|
|
89
|
+
// postQuery hooks still fire on them.
|
|
90
|
+
readonly entityName: string | undefined;
|
|
88
91
|
readonly rows: ReadonlyArray<Record<string, unknown>>;
|
|
89
92
|
},
|
|
90
93
|
context: AppContext,
|
|
@@ -55,13 +55,23 @@ function* walkAllSteps(steps: readonly StepInstance[]): Generator<StepInstance,
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// @cast-boundary drizzle-bridge — reads table name from
|
|
59
|
-
//
|
|
58
|
+
// @cast-boundary drizzle-bridge — reads table name from a Symbol without
|
|
59
|
+
// importing drizzle-orm (bun-db pattern, see bun-db/query.ts).
|
|
60
60
|
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
61
|
+
// table()/buildEntityTable spread column handles as enumerable props, so an
|
|
62
|
+
// entity field named `tableName`/`source` would shadow the matching meta key —
|
|
63
|
+
// the canonical meta under this symbol is the only collision-safe source.
|
|
64
|
+
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
61
65
|
|
|
62
66
|
function resolveTableNameFromStep(table: unknown): string {
|
|
63
67
|
if (typeof table === "object" && table !== null) {
|
|
64
|
-
//
|
|
68
|
+
// Canonical meta under the unshadowable symbol — preferred path.
|
|
69
|
+
const meta = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
|
|
70
|
+
if (meta !== null && typeof meta === "object") {
|
|
71
|
+
const metaName = (meta as Record<string, unknown>)["tableName"];
|
|
72
|
+
if (typeof metaName === "string") return metaName;
|
|
73
|
+
}
|
|
74
|
+
// Plain meta (buildEntityTableMeta / defineUnmanagedTable — no handle-spread).
|
|
65
75
|
if (
|
|
66
76
|
"source" in table &&
|
|
67
77
|
"tableName" in table &&
|
|
@@ -241,6 +241,11 @@ describe("NotFoundError", () => {
|
|
|
241
241
|
const err = new NotFoundError("billing-period", 7);
|
|
242
242
|
expect((err.details as { reason: string }).reason).toBe("billing_period_not_found");
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
test("PascalCase entity name does not leak a leading underscore into the reason", () => {
|
|
246
|
+
const err = new NotFoundError("Invoice", 7);
|
|
247
|
+
expect((err.details as { reason: string }).reason).toBe("invoice_not_found");
|
|
248
|
+
});
|
|
244
249
|
});
|
|
245
250
|
|
|
246
251
|
describe("ConflictError + VersionConflictError", () => {
|
package/src/errors/classes.ts
CHANGED
|
@@ -83,8 +83,10 @@ export class NotFoundError extends KumikoError {
|
|
|
83
83
|
const idStr = id !== undefined ? String(id) : undefined;
|
|
84
84
|
// The reason string follows `<snake_entity>_not_found` — keeps a stable,
|
|
85
85
|
// client-friendly tag that survives wire serialization even if the entity
|
|
86
|
-
// name is later renamed for display purposes.
|
|
87
|
-
|
|
86
|
+
// name is later renamed for display purposes. Strip the leading underscore
|
|
87
|
+
// toSnakeCase emits for a PascalCase name ("Invoice" → "_invoice") so the
|
|
88
|
+
// wire tag stays "invoice_not_found", not "_invoice_not_found".
|
|
89
|
+
const reason = `${toSnakeCase(entity).replace(/^_/, "")}_not_found`;
|
|
88
90
|
const details: NotFoundDetails & { reason: string } = { reason, entity, id: idStr };
|
|
89
91
|
super({
|
|
90
92
|
message: idStr !== undefined ? `${entity} ${idStr} not found` : `${entity} not found`,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ColumnMeta } from "../../db/entity-table-meta";
|
|
3
|
+
import { fileRefsTable } from "../file-ref-table";
|
|
4
|
+
|
|
5
|
+
// `fileRefsTable` is the live buildEntityTable() output the app boots with.
|
|
6
|
+
// Its runtime shape is a SchemaTable (EntityTableMeta & drizzle table), so the
|
|
7
|
+
// EntityTableMeta `columns` carry the resolved NOT NULL / DEFAULT per column —
|
|
8
|
+
// the drizzle-facing static type (EntityTable<E>) hides them, hence the cast.
|
|
9
|
+
// Importing fileRefEntity directly here would hit the engine↔file-ref-table
|
|
10
|
+
// init cycle (biome sorts it before file-ref-table); going through the built
|
|
11
|
+
// table sidesteps it and tests the exact object production uses.
|
|
12
|
+
const columns = (fileRefsTable as unknown as { readonly columns: readonly ColumnMeta[] }).columns;
|
|
13
|
+
const col = (name: string): readonly ColumnMeta[] => columns.filter((c) => c.name === name);
|
|
14
|
+
|
|
15
|
+
describe("fileRefEntity base-column drift", () => {
|
|
16
|
+
// Regression: an earlier revision declared `insertedAt`/`insertedById` as
|
|
17
|
+
// entity fields. The field-column then OVERRODE the framework base column in
|
|
18
|
+
// the {...base, ...field} last-wins merge (entity-table-meta.ts), dropping
|
|
19
|
+
// its NOT NULL DEFAULT now() and making inserted_at silently nullable — a
|
|
20
|
+
// production INSERT could then leave it null. Re-adding either field shadows
|
|
21
|
+
// the base column again and turns these red.
|
|
22
|
+
test("inserted_at stays NOT NULL DEFAULT now() (base column, not field-shadowed)", () => {
|
|
23
|
+
const insertedAt = col("inserted_at");
|
|
24
|
+
expect(insertedAt).toHaveLength(1); // exactly one — not duplicated by a redeclared field
|
|
25
|
+
expect(insertedAt[0]?.notNull).toBe(true);
|
|
26
|
+
expect(insertedAt[0]?.defaultSql).toBe("now()");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("inserted_by_id stays framework-managed nullable (not redeclared as a required field)", () => {
|
|
30
|
+
const insertedById = col("inserted_by_id");
|
|
31
|
+
expect(insertedById).toHaveLength(1);
|
|
32
|
+
expect(insertedById[0]?.notNull).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// createInMemoryFileProvider Unit-Tests (Phase 1, test-luecken-integration).
|
|
2
|
+
//
|
|
3
|
+
// Pinnt den FileStorageProvider-Contract der In-Memory-Impl — inkl. der
|
|
4
|
+
// non-obvious Eigenschaften: defensive Buffer-Copies (write UND read),
|
|
5
|
+
// lazy readStream-throw (erst beim ersten Chunk-Pull), und die bewusst
|
|
6
|
+
// erkennbare memory://-Fake-URL.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { createInMemoryFileProvider } from "../in-memory-provider";
|
|
10
|
+
|
|
11
|
+
const bytes = (s: string) => new TextEncoder().encode(s);
|
|
12
|
+
const decode = (u: Uint8Array) => new TextDecoder().decode(u);
|
|
13
|
+
|
|
14
|
+
describe("createInMemoryFileProvider — write/read roundtrip", () => {
|
|
15
|
+
test("read liefert die geschriebenen Bytes zurück", async () => {
|
|
16
|
+
const p = createInMemoryFileProvider();
|
|
17
|
+
await p.write("a.txt", bytes("hello"));
|
|
18
|
+
expect(decode(await p.read("a.txt"))).toBe("hello");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("write kopiert defensiv — Caller-Mutation nach write ändert Storage nicht", async () => {
|
|
22
|
+
const p = createInMemoryFileProvider();
|
|
23
|
+
const data = bytes("orig");
|
|
24
|
+
await p.write("k", data);
|
|
25
|
+
data[0] = 0;
|
|
26
|
+
expect(decode(await p.read("k"))).toBe("orig");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("read kopiert defensiv — Mutation des Ergebnisses ändert Storage nicht", async () => {
|
|
30
|
+
const p = createInMemoryFileProvider();
|
|
31
|
+
await p.write("k", bytes("orig"));
|
|
32
|
+
const first = await p.read("k");
|
|
33
|
+
first[0] = 0;
|
|
34
|
+
expect(decode(await p.read("k"))).toBe("orig");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("read auf fehlenden Key wirft", async () => {
|
|
38
|
+
const p = createInMemoryFileProvider();
|
|
39
|
+
await expect(p.read("missing")).rejects.toThrow("in-memory file not found: missing");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("createInMemoryFileProvider — writeStream/readStream", () => {
|
|
44
|
+
test("writeStream fügt Chunks zusammen, readStream liest zurück", async () => {
|
|
45
|
+
const p = createInMemoryFileProvider();
|
|
46
|
+
async function* src() {
|
|
47
|
+
yield bytes("foo");
|
|
48
|
+
yield bytes("bar");
|
|
49
|
+
}
|
|
50
|
+
await p.writeStream("s", src());
|
|
51
|
+
let out = "";
|
|
52
|
+
for await (const chunk of p.readStream("s")) out += decode(chunk);
|
|
53
|
+
expect(out).toBe("foobar");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("readStream auf fehlenden Key wirft erst beim ersten Chunk-Pull (lazy, wie S3)", async () => {
|
|
57
|
+
const p = createInMemoryFileProvider();
|
|
58
|
+
const it = p.readStream("missing")[Symbol.asyncIterator]();
|
|
59
|
+
await expect(it.next()).rejects.toThrow("in-memory file not found: missing");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("createInMemoryFileProvider — exists/delete", () => {
|
|
64
|
+
test("exists spiegelt write + delete", async () => {
|
|
65
|
+
const p = createInMemoryFileProvider();
|
|
66
|
+
expect(await p.exists("k")).toBe(false);
|
|
67
|
+
await p.write("k", bytes("x"));
|
|
68
|
+
expect(await p.exists("k")).toBe(true);
|
|
69
|
+
await p.delete("k");
|
|
70
|
+
expect(await p.exists("k")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("delete auf fehlenden Key ist no-op", async () => {
|
|
74
|
+
const p = createInMemoryFileProvider();
|
|
75
|
+
await expect(p.delete("nope")).resolves.toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("createInMemoryFileProvider — getSignedUrl/keys/clear", () => {
|
|
80
|
+
test("getSignedUrl liefert deterministische memory://-Fake-URL", async () => {
|
|
81
|
+
const p = createInMemoryFileProvider();
|
|
82
|
+
expect(p.getSignedUrl).toBeDefined();
|
|
83
|
+
expect(await p.getSignedUrl?.("path/to/f.jpg", 300)).toBe("memory://path/to/f.jpg?expires=300");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("keys listet geschriebene Keys, clear leert alles", async () => {
|
|
87
|
+
const p = createInMemoryFileProvider();
|
|
88
|
+
await p.write("a", bytes("1"));
|
|
89
|
+
await p.write("b", bytes("2"));
|
|
90
|
+
expect([...p.keys()].sort()).toEqual(["a", "b"]);
|
|
91
|
+
p.clear();
|
|
92
|
+
expect(p.keys()).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// createFallbackLogger Unit-Tests (Phase 1, test-luecken-integration).
|
|
2
|
+
//
|
|
3
|
+
// Pinnt beide Pfade des Fallback-Loggers — inkl. des non-obvious
|
|
4
|
+
// Format-Unterschieds: der wrapped-Pfad schreibt "[ns] msg", der
|
|
5
|
+
// console-Fallback "[ns] msg:" (trailing colon).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
8
|
+
import { createFallbackLogger } from "../utils";
|
|
9
|
+
|
|
10
|
+
describe("createFallbackLogger", () => {
|
|
11
|
+
describe("mit wrapped logger", () => {
|
|
12
|
+
test("delegiert an logger.error mit [namespace]-Prefix (kein colon)", () => {
|
|
13
|
+
const error = mock((_msg: string, _data?: Record<string, unknown>) => {});
|
|
14
|
+
const fallback = createFallbackLogger("redis", { error });
|
|
15
|
+
|
|
16
|
+
fallback.error("connection lost", { attempt: 3 });
|
|
17
|
+
|
|
18
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
19
|
+
expect(error).toHaveBeenCalledWith("[redis] connection lost", { attempt: 3 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("reicht fehlendes data-Argument als undefined durch", () => {
|
|
23
|
+
const error = mock((_msg: string, _data?: Record<string, unknown>) => {});
|
|
24
|
+
const fallback = createFallbackLogger("jobs", { error });
|
|
25
|
+
|
|
26
|
+
fallback.error("boom");
|
|
27
|
+
|
|
28
|
+
expect(error).toHaveBeenCalledWith("[jobs] boom", undefined);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("ohne logger (console-Fallback)", () => {
|
|
33
|
+
test("schreibt auf console.error mit [namespace]-Prefix UND trailing colon", () => {
|
|
34
|
+
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
35
|
+
try {
|
|
36
|
+
const fallback = createFallbackLogger("boot");
|
|
37
|
+
|
|
38
|
+
fallback.error("no logger wired", { phase: "init" });
|
|
39
|
+
|
|
40
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
41
|
+
expect(spy).toHaveBeenCalledWith("[boot] no logger wired:", { phase: "init" });
|
|
42
|
+
} finally {
|
|
43
|
+
spy.mockRestore();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -159,6 +159,59 @@ describe("dispatcher.query", () => {
|
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
// --- postQuery hooks on standalone (entity-less) queries ---
|
|
163
|
+
|
|
164
|
+
describe("dispatcher.query postQuery hooks", () => {
|
|
165
|
+
test("handler-keyed postQuery hook fires on a standalone (no-colon) query", async () => {
|
|
166
|
+
// Standalone queries (name without colon, e.g. "dashboard") map to no
|
|
167
|
+
// entity. Handler-keyed postQuery hooks must still fire — gating the hook
|
|
168
|
+
// pass on entity-existence makes such a hook register silently + never run.
|
|
169
|
+
const feature = defineFeature("dash", (r) => {
|
|
170
|
+
r.queryHandler("dashboard", z.object({}), async () => ({ count: 1 }), {
|
|
171
|
+
access: { openToAll: true },
|
|
172
|
+
});
|
|
173
|
+
r.hook("postQuery", "dashboard", async ({ entityName, rows }) => {
|
|
174
|
+
expect(entityName).toBeUndefined();
|
|
175
|
+
return { rows: rows.map((row) => ({ ...row, enriched: true })) };
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const dispatcher = createDispatcher(createRegistry([feature]), {});
|
|
180
|
+
const result = await dispatcher.query("dash:query:dashboard", {}, createTestUser());
|
|
181
|
+
expect(result).toEqual({ count: 1, enriched: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("postQuery hook does not run for a result with rows:null (not an array)", async () => {
|
|
185
|
+
// `{ rows: null }` is a legitimate 'nothing found' shape — it must take the
|
|
186
|
+
// single-object branch, not the rows-list branch (which would crash on
|
|
187
|
+
// [...null]). The hook here returns the row unchanged so the shape survives.
|
|
188
|
+
const feature = defineFeature("nullrows", (r) => {
|
|
189
|
+
r.queryHandler("dashboard", z.object({}), async () => ({ rows: null, nextCursor: null }), {
|
|
190
|
+
access: { openToAll: true },
|
|
191
|
+
});
|
|
192
|
+
r.hook("postQuery", "dashboard", async ({ rows }) => ({ rows }));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const dispatcher = createDispatcher(createRegistry([feature]), {});
|
|
196
|
+
const result = await dispatcher.query("nullrows:query:dashboard", {}, createTestUser());
|
|
197
|
+
expect(result).toEqual({ rows: null, nextCursor: null });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("single-object postQuery hook returning ≠1 row throws", async () => {
|
|
201
|
+
const feature = defineFeature("multi", (r) => {
|
|
202
|
+
r.queryHandler("dashboard", z.object({}), async () => ({ count: 1 }), {
|
|
203
|
+
access: { openToAll: true },
|
|
204
|
+
});
|
|
205
|
+
r.hook("postQuery", "dashboard", async ({ rows }) => ({ rows: [...rows, ...rows] }));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const dispatcher = createDispatcher(createRegistry([feature]), {});
|
|
209
|
+
await expect(dispatcher.query("multi:query:dashboard", {}, createTestUser())).rejects.toThrow(
|
|
210
|
+
/must return exactly one row, got 2/,
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
162
215
|
// --- Dispatch: Command (fire-and-forget) ---
|
|
163
216
|
|
|
164
217
|
describe("dispatcher.command", () => {
|
|
@@ -686,7 +686,7 @@ export function createDispatcher(
|
|
|
686
686
|
if (!rateLimit) return;
|
|
687
687
|
if (!context.rateLimit) {
|
|
688
688
|
throw new InternalError({
|
|
689
|
-
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the
|
|
689
|
+
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
690
690
|
});
|
|
691
691
|
}
|
|
692
692
|
const reqCtx = requestContext.get();
|
|
@@ -776,66 +776,66 @@ export function createDispatcher(
|
|
|
776
776
|
// 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
|
|
777
777
|
// — feuern für ALLE query-handlers des entity
|
|
778
778
|
const entityName = registry.getHandlerEntity(type);
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
for (const hook of postQueryHooks) {
|
|
803
|
-
const out = await hook({ entityName, rows }, handlerContext);
|
|
804
|
-
rows = [...out.rows];
|
|
805
|
-
}
|
|
806
|
-
// A single-object result carries exactly one row through the hook
|
|
807
|
-
// pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
|
|
808
|
-
// dropped) cannot be represented in the single-object response —
|
|
809
|
-
// surface it instead of silently falling back / truncating.
|
|
810
|
-
const [only, ...extra] = rows;
|
|
811
|
-
if (only === undefined || extra.length > 0) {
|
|
812
|
-
throw new Error(
|
|
813
|
-
`postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
|
|
814
|
-
);
|
|
815
|
-
}
|
|
816
|
-
result = only;
|
|
779
|
+
|
|
780
|
+
// Handler-keyed postQuery hooks fire for any query (incl. entity-less
|
|
781
|
+
// standalone queries like "ns:dashboard"). Entity-keyed hooks only apply
|
|
782
|
+
// when the handler maps to an entity — so this block must NOT be gated on
|
|
783
|
+
// entityName, or hooks on standalone queries register silently and never fire.
|
|
784
|
+
const handlerHooks = registry.getPostQueryHooks(type);
|
|
785
|
+
const entityHooks = entityName ? registry.getEntityPostQueryHooks(entityName) : [];
|
|
786
|
+
const postQueryHooks = [...handlerHooks, ...entityHooks];
|
|
787
|
+
if (postQueryHooks.length > 0 && result && typeof result === "object") {
|
|
788
|
+
if (Array.isArray(result)) {
|
|
789
|
+
let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
|
|
790
|
+
for (const hook of postQueryHooks) {
|
|
791
|
+
const out = await hook({ entityName, rows }, handlerContext);
|
|
792
|
+
rows = [...out.rows];
|
|
793
|
+
}
|
|
794
|
+
result = rows;
|
|
795
|
+
} else if (Array.isArray((result as { rows?: unknown }).rows)) {
|
|
796
|
+
// @cast-boundary engine-payload
|
|
797
|
+
const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
|
|
798
|
+
let rows = r.rows;
|
|
799
|
+
for (const hook of postQueryHooks) {
|
|
800
|
+
const out = await hook({ entityName, rows }, handlerContext);
|
|
801
|
+
rows = [...out.rows];
|
|
817
802
|
}
|
|
803
|
+
result = { ...r, rows };
|
|
804
|
+
} else {
|
|
805
|
+
let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
|
|
806
|
+
for (const hook of postQueryHooks) {
|
|
807
|
+
const out = await hook({ entityName, rows }, handlerContext);
|
|
808
|
+
rows = [...out.rows];
|
|
809
|
+
}
|
|
810
|
+
// A single-object result carries exactly one row through the hook
|
|
811
|
+
// pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
|
|
812
|
+
// dropped) cannot be represented in the single-object response —
|
|
813
|
+
// surface it instead of silently falling back / truncating.
|
|
814
|
+
if (rows.length !== 1) {
|
|
815
|
+
throw new Error(
|
|
816
|
+
`postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
result = rows[0];
|
|
818
820
|
}
|
|
821
|
+
}
|
|
819
822
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
823
|
+
// Field-level read filter — only applies to entity-bound results.
|
|
824
|
+
const entity = entityName ? registry.getEntity(entityName) : undefined;
|
|
825
|
+
if (entity && result && typeof result === "object") {
|
|
826
|
+
if (Array.isArray(result)) {
|
|
827
|
+
result = result.map((row: Record<string, unknown>) => filterReadFields(entity, row, user));
|
|
828
|
+
} else {
|
|
829
|
+
const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
|
|
830
|
+
if (Array.isArray((resultAsDbRow as { rows?: unknown }).rows)) {
|
|
831
|
+
// generic handler-result shape narrow
|
|
832
|
+
const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
|
|
833
|
+
result = {
|
|
834
|
+
...r,
|
|
835
|
+
rows: r.rows.map((row) => filterReadFields(entity, row, user)),
|
|
836
|
+
};
|
|
827
837
|
} else {
|
|
828
|
-
|
|
829
|
-
if ("rows" in resultAsDbRow) {
|
|
830
|
-
// generic handler-result shape narrow
|
|
831
|
-
const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
|
|
832
|
-
result = {
|
|
833
|
-
...r,
|
|
834
|
-
rows: r.rows.map((row) => filterReadFields(entity, row, user)),
|
|
835
|
-
};
|
|
836
|
-
} else {
|
|
837
|
-
result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
|
|
838
|
-
}
|
|
838
|
+
result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
|
|
839
839
|
}
|
|
840
840
|
}
|
|
841
841
|
}
|
|
@@ -98,7 +98,7 @@ function reconstructStateForSearch(
|
|
|
98
98
|
// Build a SearchDocument from raw field-state. Parallel to the old
|
|
99
99
|
// buildSearchDocument that took a SaveContext — same selector logic, just
|
|
100
100
|
// a different input shape.
|
|
101
|
-
async function buildSearchDocument(
|
|
101
|
+
export async function buildSearchDocument(
|
|
102
102
|
entityName: string,
|
|
103
103
|
entityId: EntityId,
|
|
104
104
|
state: Record<string, unknown>,
|
|
@@ -143,12 +143,24 @@ async function buildSearchDocument(
|
|
|
143
143
|
// F3 — Search-Payload-Extensions: contributors merge flat fields into the
|
|
144
144
|
// search-doc (customFields-bundle / tags / computed-counts / etc.).
|
|
145
145
|
// Sequential await — extensions are expected sync or sub-millisecond async;
|
|
146
|
-
// sequential keeps the path simple and deterministic.
|
|
147
|
-
//
|
|
148
|
-
//
|
|
146
|
+
// sequential keeps the path simple and deterministic.
|
|
147
|
+
//
|
|
148
|
+
// Precedence is base-fields-win: a contributor key that collides with a
|
|
149
|
+
// searchable Stammfield is dropped (not silently merged over the real value)
|
|
150
|
+
// and warned. A jsonb custom-field that happens to share a Stammfield name
|
|
151
|
+
// must not shadow the indexed Stammfield.
|
|
149
152
|
for (const contribute of extensions) {
|
|
150
153
|
const contributed = await contribute({ entityName, entityId, state });
|
|
151
|
-
Object.
|
|
154
|
+
for (const [key, value] of Object.entries(contributed)) {
|
|
155
|
+
if (Object.hasOwn(fields, key)) {
|
|
156
|
+
console.warn(
|
|
157
|
+
`[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
|
|
158
|
+
`Stammfield "${key}" — keeping the base field. Rename the contributor key.`,
|
|
159
|
+
);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
fields[key] = value;
|
|
163
|
+
}
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
return {
|