@cosmicdrift/kumiko-framework 0.15.0 → 0.16.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/api/auth-routes.ts +2 -5
- package/src/bun-db/query.ts +2 -5
- package/src/compliance/profiles.ts +1 -4
- package/src/db/__tests__/cursor.test.ts +17 -0
- package/src/db/__tests__/migrate-generator.test.ts +71 -0
- package/src/db/__tests__/migrate-runner.test.ts +19 -0
- package/src/db/__tests__/pg-error.test.ts +43 -0
- package/src/db/table-builder.ts +6 -6
- package/src/engine/__tests__/duration-utils.test.ts +16 -0
- package/src/engine/__tests__/field-access.test.ts +38 -0
- package/src/engine/__tests__/no-return-guard.test.ts +17 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
- package/src/engine/define-feature.ts +36 -0
- package/src/engine/feature-ast/extractors/shared.ts +2 -3
- package/src/engine/registry.ts +19 -0
- package/src/engine/types/feature.ts +40 -0
- package/src/engine/types/index.ts +2 -0
- package/src/errors/__tests__/error-helpers.test.ts +44 -0
- package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
- package/src/errors/classes.ts +5 -19
- package/src/errors/field-issue.ts +11 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/zod-bridge.ts +3 -2
- package/src/es-ops/context.ts +2 -13
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
- package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
- package/src/pipeline/dispatcher-utils.ts +8 -7
- package/src/stack/test-stack.ts +2 -2
- package/src/utils/__tests__/case.test.ts +16 -0
- package/src/utils/__tests__/is-plain-object.test.ts +16 -0
- package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
- package/src/utils/__tests__/safe-json.test.ts +22 -0
- package/src/utils/case.ts +6 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/is-plain-object.ts +4 -0
- package/src/utils/parse-string-array-json.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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>",
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createSystemUser } from "../engine/system-user";
|
|
|
6
6
|
import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
|
|
7
7
|
import { NotFoundError } from "../errors";
|
|
8
8
|
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
9
|
+
import { parseStringArrayJson } from "../utils/parse-string-array-json";
|
|
9
10
|
import { Routes } from "./api-constants";
|
|
10
11
|
import {
|
|
11
12
|
AUTH_COOKIE_NAME,
|
|
@@ -819,11 +820,7 @@ export function createAuthRoutes(
|
|
|
819
820
|
)) as { roles?: string | null } | null;
|
|
820
821
|
const raw = userRow?.roles;
|
|
821
822
|
if (typeof raw === "string" && raw.length > 0) {
|
|
822
|
-
|
|
823
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
824
|
-
if (Array.isArray(parsed) && parsed.every((r) => typeof r === "string")) {
|
|
825
|
-
globalRoles = parsed;
|
|
826
|
-
}
|
|
823
|
+
globalRoles = parseStringArrayJson(raw);
|
|
827
824
|
}
|
|
828
825
|
} catch (e) {
|
|
829
826
|
// Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
|
package/src/bun-db/query.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
23
23
|
import { toSnakeCase } from "../db/table-builder";
|
|
24
24
|
import { camelCase as envCamelCase } from "../env";
|
|
25
|
+
import { parseJsonSafe } from "../utils/safe-json";
|
|
25
26
|
|
|
26
27
|
// Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
|
|
27
28
|
// (designed for SHOUT_CASE input) — for already-camelCase keys (mock rows
|
|
@@ -276,11 +277,7 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
|
|
|
276
277
|
const t = instantFromDriver(value);
|
|
277
278
|
if (t !== null) coerced = t;
|
|
278
279
|
} else if (pgType === "jsonb" && typeof value === "string") {
|
|
279
|
-
|
|
280
|
-
coerced = JSON.parse(value);
|
|
281
|
-
} catch {
|
|
282
|
-
// leave as string on parse error — caller decides
|
|
283
|
-
}
|
|
280
|
+
coerced = parseJsonSafe(value, value);
|
|
284
281
|
} else if ((pgType === "bigint" || pgType === "bigserial") && typeof value === "string") {
|
|
285
282
|
// postgres-js returns BIGINT as string to avoid JS-Number precision
|
|
286
283
|
// loss past 2^53. Framework contract: bigint columns surface as
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
//
|
|
34
34
|
// Siehe docs/plans/datenschutz/compliance-profiles.md.
|
|
35
35
|
|
|
36
|
+
import { isPlainObject } from "../utils/is-plain-object";
|
|
36
37
|
import type { BundleTier } from "./sub-processors";
|
|
37
38
|
|
|
38
39
|
// --- Profile-Schema ---
|
|
@@ -302,10 +303,6 @@ export type ComplianceProfileOverride = DeepReadonly<DeepPartial<ComplianceProfi
|
|
|
302
303
|
|
|
303
304
|
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
|
304
305
|
|
|
305
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
306
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
306
|
// Pfade die als ganzes Atom ersetzt werden (NICHT rekursiv gemergt).
|
|
310
307
|
// Notwendig fuer Diskriminierte-Union-Types wo das Patch ein Schwester-
|
|
311
308
|
// Property statt einer Override sein kann — z.B. retention von
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { decodeCursor, encodeCursor } from "../cursor";
|
|
3
|
+
|
|
4
|
+
describe("encodeCursor / decodeCursor", () => {
|
|
5
|
+
test("round-trips string ids", () => {
|
|
6
|
+
const id = "0194a1b2-c3d4-7890-abcd-ef1234567890";
|
|
7
|
+
expect(decodeCursor(encodeCursor(id))).toBe(id);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("round-trips numeric ids", () => {
|
|
11
|
+
expect(decodeCursor(encodeCursor(42))).toBe("42");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("decodeCursor throws on empty payload", () => {
|
|
15
|
+
expect(() => decodeCursor(encodeCursor(""))).toThrow(/Invalid cursor/);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EntityTableMeta } from "../entity-table-meta";
|
|
3
|
+
import {
|
|
4
|
+
diffSnapshots,
|
|
5
|
+
generateMigration,
|
|
6
|
+
renderMigrationSql,
|
|
7
|
+
snapshotFromMetas,
|
|
8
|
+
} from "../migrate-generator";
|
|
9
|
+
|
|
10
|
+
function meta(
|
|
11
|
+
tableName: string,
|
|
12
|
+
extraColumn?: EntityTableMeta["columns"][number],
|
|
13
|
+
): EntityTableMeta {
|
|
14
|
+
return {
|
|
15
|
+
tableName,
|
|
16
|
+
source: "unmanaged",
|
|
17
|
+
indexes: [],
|
|
18
|
+
columns: [
|
|
19
|
+
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
20
|
+
...(extraColumn ? [extraColumn] : []),
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("snapshotFromMetas", () => {
|
|
26
|
+
test("sorts tables by name for stable snapshots", () => {
|
|
27
|
+
const snap = snapshotFromMetas([meta("zebras"), meta("apples")]);
|
|
28
|
+
expect(snap.tables.map((t) => t.tableName)).toEqual(["apples", "zebras"]);
|
|
29
|
+
expect(snap.version).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("diffSnapshots", () => {
|
|
34
|
+
test("null prev → all tables are new", () => {
|
|
35
|
+
const next = snapshotFromMetas([meta("tasks")]);
|
|
36
|
+
const diff = diffSnapshots(null, next);
|
|
37
|
+
expect(diff.newTables.map((t) => t.tableName)).toEqual(["tasks"]);
|
|
38
|
+
expect(diff.droppedTables).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("detects dropped table and new column", () => {
|
|
42
|
+
const prev = snapshotFromMetas([meta("tasks"), meta("legacy")]);
|
|
43
|
+
const next = snapshotFromMetas([
|
|
44
|
+
meta("tasks", { name: "title", pgType: "text", notNull: true }),
|
|
45
|
+
]);
|
|
46
|
+
const diff = diffSnapshots(prev, next);
|
|
47
|
+
expect(diff.droppedTables).toEqual(["legacy"]);
|
|
48
|
+
expect(diff.changedTables[0]?.newColumns.map((c) => c.name)).toEqual(["title"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("renderMigrationSql / generateMigration", () => {
|
|
53
|
+
test("emits CREATE TABLE for new tables", () => {
|
|
54
|
+
const diff = diffSnapshots(null, snapshotFromMetas([meta("tasks")]));
|
|
55
|
+
const sql = renderMigrationSql(diff, { name: "init", sequenceNumber: 1 });
|
|
56
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "tasks"');
|
|
57
|
+
expect(sql).toContain("Migration 0001_init");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("generateMigration bundles snapshot + sql", () => {
|
|
61
|
+
const out = generateMigration({
|
|
62
|
+
metas: [meta("tasks")],
|
|
63
|
+
prevSnapshot: null,
|
|
64
|
+
name: "init",
|
|
65
|
+
sequenceNumber: 1,
|
|
66
|
+
});
|
|
67
|
+
expect(out.snapshot.tables).toHaveLength(1);
|
|
68
|
+
expect(out.sqlContent).toContain("0001_init");
|
|
69
|
+
expect(out.filename).toBe("0001_init.sql");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { splitSqlStatements } from "../migrate-runner";
|
|
3
|
+
|
|
4
|
+
describe("splitSqlStatements", () => {
|
|
5
|
+
test("splits on semicolons and strips line comments", () => {
|
|
6
|
+
const sql = `
|
|
7
|
+
CREATE TABLE "a" (id uuid); -- inline comment
|
|
8
|
+
CREATE TABLE "b" (id uuid);
|
|
9
|
+
`;
|
|
10
|
+
expect(splitSqlStatements(sql)).toEqual([
|
|
11
|
+
'CREATE TABLE "a" (id uuid);',
|
|
12
|
+
'CREATE TABLE "b" (id uuid);',
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("filters empty segments", () => {
|
|
17
|
+
expect(splitSqlStatements("-- only comments\n; ;")).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation } from "../pg-error";
|
|
3
|
+
|
|
4
|
+
describe("extractPgError", () => {
|
|
5
|
+
test("reads code from top-level postgres-js error", () => {
|
|
6
|
+
const info = extractPgError({ code: "23505", constraint_name: "users_email_uq" });
|
|
7
|
+
expect(info).toEqual({ code: "23505", constraint_name: "users_email_uq" });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("unwraps DrizzleQueryError.cause", () => {
|
|
11
|
+
const info = extractPgError({
|
|
12
|
+
message: "wrapper",
|
|
13
|
+
cause: { code: "23505", constraint_name: "uq" },
|
|
14
|
+
});
|
|
15
|
+
expect(info?.code).toBe("23505");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("returns null for non-objects", () => {
|
|
19
|
+
expect(extractPgError("nope")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("isUniqueViolation", () => {
|
|
24
|
+
test("true for SQLSTATE 23505", () => {
|
|
25
|
+
expect(isUniqueViolation({ code: "23505" })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("false otherwise", () => {
|
|
29
|
+
expect(isUniqueViolation({ code: "23503" })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isTableAlreadyExists", () => {
|
|
34
|
+
test("true for SQLSTATE 42P07", () => {
|
|
35
|
+
expect(isTableAlreadyExists({ code: "42P07" })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("constraintOf", () => {
|
|
40
|
+
test("returns constraint_name when present", () => {
|
|
41
|
+
expect(constraintOf({ constraint_name: "users_email_uq" })).toBe("users_email_uq");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/db/table-builder.ts
CHANGED
|
@@ -5,6 +5,10 @@ import type {
|
|
|
5
5
|
FieldsMap,
|
|
6
6
|
} from "../engine/types";
|
|
7
7
|
import { assertUnreachable } from "../utils";
|
|
8
|
+
import { toSnakeCase } from "../utils/case";
|
|
9
|
+
|
|
10
|
+
export { toSnakeCase } from "../utils/case";
|
|
11
|
+
|
|
8
12
|
import {
|
|
9
13
|
bigint,
|
|
10
14
|
boolean,
|
|
@@ -192,12 +196,8 @@ function fieldToColumns(
|
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
// Accepts both camelCase (`tenantMembership`) and kebab-case (`tenant-membership`)
|
|
195
|
-
// entity / field names.
|
|
196
|
-
//
|
|
197
|
-
// kept working for already-shipped code.
|
|
198
|
-
export function toSnakeCase(str: string): string {
|
|
199
|
-
return str.replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
200
|
-
}
|
|
199
|
+
// entity / field names. Implementation lives in utils/case — re-exported here
|
|
200
|
+
// for backwards-compatible imports from db/table-builder.
|
|
201
201
|
|
|
202
202
|
/**
|
|
203
203
|
* Derives a table name from an entity name:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { addDuration } from "../steps/_duration-utils";
|
|
3
|
+
|
|
4
|
+
describe("addDuration", () => {
|
|
5
|
+
test("adds ISO duration to base instant", () => {
|
|
6
|
+
const base = "2024-01-01T00:00:00Z";
|
|
7
|
+
const result = addDuration(base, "PT1H");
|
|
8
|
+
expect(result).toBe(Temporal.Instant.from(base).add({ hours: 1 }).toString());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws on invalid duration", () => {
|
|
12
|
+
expect(() => addDuration("2024-01-01T00:00:00Z", "not-a-duration")).toThrow(
|
|
13
|
+
/Invalid ISO-8601 duration/,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { checkWriteFieldRoles, filterReadFields } from "../field-access";
|
|
3
|
+
import type { EntityDefinition } from "../types";
|
|
4
|
+
|
|
5
|
+
const entity: EntityDefinition = {
|
|
6
|
+
fields: {
|
|
7
|
+
title: { type: "text", access: { read: { editor: "all" }, write: { editor: "all" } } },
|
|
8
|
+
secret: { type: "text", access: { read: { admin: "all" }, write: { admin: "all" } } },
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const editor = { id: "u1", tenantId: "t1", roles: ["editor"] as const };
|
|
13
|
+
const admin = { id: "u2", tenantId: "t1", roles: ["admin"] as const };
|
|
14
|
+
|
|
15
|
+
describe("filterReadFields", () => {
|
|
16
|
+
test("strips fields the user cannot read", () => {
|
|
17
|
+
const row = { id: 1, title: "Hello", secret: "hidden" };
|
|
18
|
+
const filtered = filterReadFields(entity, row, editor);
|
|
19
|
+
expect(filtered["title"]).toBe("Hello");
|
|
20
|
+
expect(filtered["secret"]).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("keeps restricted fields for allowed roles", () => {
|
|
24
|
+
const row = { id: 1, title: "Hello", secret: "visible" };
|
|
25
|
+
const filtered = filterReadFields(entity, row, admin);
|
|
26
|
+
expect(filtered["secret"]).toBe("visible");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("checkWriteFieldRoles", () => {
|
|
31
|
+
test("returns denied field name when role missing", () => {
|
|
32
|
+
expect(checkWriteFieldRoles(entity, { secret: "x" }, editor)).toBe("secret");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returns null when all changed fields are allowed", () => {
|
|
36
|
+
expect(checkWriteFieldRoles(entity, { title: "x" }, editor)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateNoReturnSteps } from "../steps/_no-return-guard";
|
|
3
|
+
import type { StepInstance } from "../types/step";
|
|
4
|
+
|
|
5
|
+
describe("validateNoReturnSteps", () => {
|
|
6
|
+
test("passes when no return steps present", () => {
|
|
7
|
+
const steps = [{ kind: "noop" }] as unknown as readonly StepInstance[];
|
|
8
|
+
expect(() => validateNoReturnSteps(steps, "r.step.branch.onTrue")).not.toThrow();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws when return step is nested", () => {
|
|
12
|
+
const steps = [{ kind: "return" }] as unknown as readonly StepInstance[];
|
|
13
|
+
expect(() => validateNoReturnSteps(steps, "r.step.forEach.do")).toThrow(
|
|
14
|
+
/not allowed inside r\.step\.forEach\.do/,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Unit tests for r.unmanagedTable() — the EntityTableMeta cousin of
|
|
2
|
+
// r.rawTable. Same audit-trail contract, different storage shape (post-
|
|
3
|
+
// drizzle migrate-runner). See define-feature.ts / DX-4.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
7
|
+
import { defineFeature } from "../define-feature";
|
|
8
|
+
import { createRegistry } from "../registry";
|
|
9
|
+
|
|
10
|
+
const probeMeta = defineUnmanagedTable({
|
|
11
|
+
tableName: "ut_probe",
|
|
12
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
13
|
+
});
|
|
14
|
+
const probeMetaTwo = defineUnmanagedTable({
|
|
15
|
+
tableName: "ut_probe_two",
|
|
16
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("r.unmanagedTable — declaration", () => {
|
|
20
|
+
test("rejects duplicate registrations within one feature", () => {
|
|
21
|
+
expect(() =>
|
|
22
|
+
defineFeature("probe", (r) => {
|
|
23
|
+
r.unmanagedTable(probeMeta, { reason: "test" });
|
|
24
|
+
r.unmanagedTable(probeMeta, { reason: "test" });
|
|
25
|
+
}),
|
|
26
|
+
).toThrow(/already registered/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects empty reason", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
defineFeature("probe", (r) => {
|
|
32
|
+
r.unmanagedTable(probeMeta, { reason: "" });
|
|
33
|
+
}),
|
|
34
|
+
).toThrow(/options\.reason must be a non-empty string/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rejects whitespace-only reason", () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
defineFeature("probe", (r) => {
|
|
40
|
+
r.unmanagedTable(probeMeta, { reason: " " });
|
|
41
|
+
}),
|
|
42
|
+
).toThrow(/options\.reason must be a non-empty string/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("accepts valid registration and stores meta + reason", () => {
|
|
46
|
+
const feature = defineFeature("probe", (r) => {
|
|
47
|
+
r.unmanagedTable(probeMeta, {
|
|
48
|
+
reason: "read-side projection of an event-stream",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
expect(feature.unmanagedTables).toHaveProperty("ut_probe");
|
|
52
|
+
expect(feature.unmanagedTables["ut_probe"]?.reason).toBe(
|
|
53
|
+
"read-side projection of an event-stream",
|
|
54
|
+
);
|
|
55
|
+
expect(feature.unmanagedTables["ut_probe"]?.meta).toBe(probeMeta);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("two unmanaged tables on one feature register under their tableName", () => {
|
|
59
|
+
const feature = defineFeature("dual", (r) => {
|
|
60
|
+
r.unmanagedTable(probeMeta, { reason: "one" });
|
|
61
|
+
r.unmanagedTable(probeMetaTwo, { reason: "two" });
|
|
62
|
+
});
|
|
63
|
+
expect(Object.keys(feature.unmanagedTables).sort()).toEqual(["ut_probe", "ut_probe_two"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("absent unmanagedTables on a feature is ok", () => {
|
|
67
|
+
const feat = defineFeature("plain", () => {
|
|
68
|
+
// no r.unmanagedTable calls
|
|
69
|
+
});
|
|
70
|
+
expect(feat.unmanagedTables).toEqual({});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("createRegistry — unmanagedTable aggregation", () => {
|
|
75
|
+
test("rejects cross-feature tableName collisions at boot", () => {
|
|
76
|
+
// Two features can't share the same physical tableName — migrate-runner
|
|
77
|
+
// would race two CREATE TABLE statements. Boot-validator catches it.
|
|
78
|
+
const featA = defineFeature("a", (r) => {
|
|
79
|
+
r.unmanagedTable(probeMeta, { reason: "first" });
|
|
80
|
+
});
|
|
81
|
+
const featB = defineFeature("b", (r) => {
|
|
82
|
+
r.unmanagedTable(probeMeta, { reason: "second" });
|
|
83
|
+
});
|
|
84
|
+
expect(() => createRegistry([featA, featB])).toThrow(
|
|
85
|
+
/Unmanaged-table "ut_probe" registered by both feature "a" and "b"/,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("two features with distinct tableNames register cleanly", () => {
|
|
90
|
+
const featA = defineFeature("a", (r) => {
|
|
91
|
+
r.unmanagedTable(probeMeta, { reason: "first" });
|
|
92
|
+
});
|
|
93
|
+
const featB = defineFeature("b", (r) => {
|
|
94
|
+
r.unmanagedTable(probeMetaTwo, { reason: "second" });
|
|
95
|
+
});
|
|
96
|
+
expect(() => createRegistry([featA, featB])).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
|
+
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
2
3
|
import { toTableName } from "../db/table-builder";
|
|
3
4
|
import { LifecycleHookTypes } from "./constants";
|
|
4
5
|
import type { QueryHandlerDefinition, WriteHandlerDefinition } from "./define-handler";
|
|
@@ -61,6 +62,7 @@ import type {
|
|
|
61
62
|
TreeActionDef,
|
|
62
63
|
TreeActionsHandle,
|
|
63
64
|
TreeChildrenSubscribe,
|
|
65
|
+
UnmanagedTableEntry,
|
|
64
66
|
ValidationHookFn,
|
|
65
67
|
WriteHandlerDef,
|
|
66
68
|
WriteHandlerFn,
|
|
@@ -136,6 +138,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
136
138
|
const projections: Record<string, ProjectionDefinition> = {};
|
|
137
139
|
const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
|
|
138
140
|
const rawTables: Record<string, RawTableEntry> = {};
|
|
141
|
+
const unmanagedTables: Record<string, UnmanagedTableEntry> = {};
|
|
139
142
|
const authClaimsHooks: AuthClaimsFn[] = [];
|
|
140
143
|
const claimKeys: Record<string, ClaimKeyDefinition> = {};
|
|
141
144
|
const screens: Record<string, ScreenDefinition> = {};
|
|
@@ -791,6 +794,38 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
791
794
|
};
|
|
792
795
|
},
|
|
793
796
|
|
|
797
|
+
unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void {
|
|
798
|
+
// Name comes from the meta itself — apps already give the table a
|
|
799
|
+
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
800
|
+
const tableName = meta.tableName;
|
|
801
|
+
if (!isKebabSegment(tableName.replace(/_/g, "-"))) {
|
|
802
|
+
// EntityTableMeta uses snake_case for tableName (matches Postgres
|
|
803
|
+
// convention); we just guard against truly broken input.
|
|
804
|
+
throw new Error(
|
|
805
|
+
`[Feature ${name}] Unmanaged-table name "${tableName}" must be a ` +
|
|
806
|
+
`valid identifier (lowercase letters, digits, underscores; start with a letter).`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
if (unmanagedTables[tableName]) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`[Feature ${name}] r.unmanagedTable("${tableName}") already registered. ` +
|
|
812
|
+
`Unmanaged-table names must be unique per feature.`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`[Feature ${name}] r.unmanagedTable("${tableName}"): options.reason must be a ` +
|
|
818
|
+
`non-empty string. The reason justifies the audit-trail bypass — ` +
|
|
819
|
+
`if you can't write one, declare data via r.entity() instead.`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
unmanagedTables[tableName] = {
|
|
823
|
+
name: tableName,
|
|
824
|
+
meta,
|
|
825
|
+
reason: options.reason,
|
|
826
|
+
};
|
|
827
|
+
},
|
|
828
|
+
|
|
794
829
|
claimKey<T extends ClaimKeyType>(
|
|
795
830
|
shortName: string,
|
|
796
831
|
options: { readonly type: T },
|
|
@@ -905,6 +940,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
905
940
|
workspaces,
|
|
906
941
|
httpRoutes,
|
|
907
942
|
rawTables,
|
|
943
|
+
unmanagedTables,
|
|
908
944
|
...(treeActions !== undefined && { treeActions }),
|
|
909
945
|
...(treeProvider !== undefined && { treeProvider }),
|
|
910
946
|
...(envSchema !== undefined && { envSchema }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CallExpression, Node } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
|
+
import { isPlainObject } from "../../../utils/is-plain-object";
|
|
3
4
|
import type { ParseError } from "../parse";
|
|
4
5
|
|
|
5
6
|
export type ExtractOutput<TPattern> =
|
|
@@ -107,9 +108,7 @@ export function readDataLiteralNode(node: Node): unknown {
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
export
|
|
111
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
-
}
|
|
111
|
+
export { isPlainObject } from "../../../utils/is-plain-object";
|
|
113
112
|
|
|
114
113
|
export function readPropertyKey(propAssign: import("ts-morph").PropertyAssignment): string {
|
|
115
114
|
const nameNode = propAssign.getNameNode();
|
package/src/engine/registry.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
TranslationKeys,
|
|
41
41
|
TreeActionDef,
|
|
42
42
|
TreeChildrenSubscribe,
|
|
43
|
+
UnmanagedTableDef,
|
|
43
44
|
WorkspaceDefinition,
|
|
44
45
|
WriteHandlerDef,
|
|
45
46
|
} from "./types";
|
|
@@ -169,6 +170,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
169
170
|
// enforced at ingest below (collisions would race two CREATE TABLE
|
|
170
171
|
// statements at the same physical name and break boot).
|
|
171
172
|
const rawTableMap = new Map<string, RawTableDef>();
|
|
173
|
+
// Unmanaged tables — declared via r.unmanagedTable() (EntityTableMeta).
|
|
174
|
+
// Cousin of rawTables: same uniqueness-by-tableName invariant, different
|
|
175
|
+
// storage shape (post-drizzle migrate-runner consumes EntityTableMeta).
|
|
176
|
+
const unmanagedTableMap = new Map<string, UnmanagedTableDef>();
|
|
172
177
|
// Auth-claims hooks — tagged with featureName so the login resolver can
|
|
173
178
|
// auto-prefix each hook's returned keys with "<feature>:".
|
|
174
179
|
const authClaimsHooks: AuthClaimsHookDef[] = [];
|
|
@@ -543,6 +548,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
543
548
|
rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
544
549
|
}
|
|
545
550
|
|
|
551
|
+
// Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
|
|
552
|
+
// Two features registering the same physical tableName would race two
|
|
553
|
+
// CREATE TABLE statements via migrate-runner.
|
|
554
|
+
for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
|
|
555
|
+
const existing = unmanagedTableMap.get(umName);
|
|
556
|
+
if (existing) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
|
|
559
|
+
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
|
|
563
|
+
}
|
|
564
|
+
|
|
546
565
|
// Claim keys: aggregated by qualified name. Two features cannot collide
|
|
547
566
|
// here (qualified by feature name), but we still guard for explicit
|
|
548
567
|
// correctness — the only way to hit this is a hand-built FeatureDefinition
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
|
+
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
2
3
|
|
|
3
4
|
// PgTable historically came from drizzle-orm/pg-core; the native dialect
|
|
4
5
|
// no longer carries drizzle internal class types. Every caller really
|
|
@@ -148,6 +149,23 @@ export type RawTableDef = RawTableEntry & {
|
|
|
148
149
|
readonly featureName: string;
|
|
149
150
|
};
|
|
150
151
|
|
|
152
|
+
// --- Unmanaged tables (declared by features via r.unmanagedTable()) ---
|
|
153
|
+
|
|
154
|
+
/** Per-feature unmanaged-table registration. `meta` is the
|
|
155
|
+
* `EntityTableMeta` (framework-native shape used by `migrate-runner`).
|
|
156
|
+
* The `reason` justifies the bypass at the registration site — same
|
|
157
|
+
* contract as `r.rawTable`. */
|
|
158
|
+
export type UnmanagedTableEntry = {
|
|
159
|
+
readonly name: string;
|
|
160
|
+
readonly meta: EntityTableMeta;
|
|
161
|
+
readonly reason: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Registry-aggregated unmanaged-table — adds the owning feature name. */
|
|
165
|
+
export type UnmanagedTableDef = UnmanagedTableEntry & {
|
|
166
|
+
readonly featureName: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
151
169
|
// --- Feature Definition (output of defineFeature) ---
|
|
152
170
|
|
|
153
171
|
export type FeatureDefinition = {
|
|
@@ -274,6 +292,12 @@ export type FeatureDefinition = {
|
|
|
274
292
|
// system. Keyed by feature-local short name. The registry attaches
|
|
275
293
|
// featureName on aggregation, lifting RawTableEntry → RawTableDef.
|
|
276
294
|
readonly rawTables: Readonly<Record<string, RawTableEntry>>;
|
|
295
|
+
// Unmanaged tables declared via r.unmanagedTable() — `EntityTableMeta`
|
|
296
|
+
// shape (post-drizzle), keyed by feature-local table-name. Cousin of
|
|
297
|
+
// rawTables: same bypass-justification contract, different storage
|
|
298
|
+
// shape. `kumiko schema generate` aggregates these alongside
|
|
299
|
+
// r.entity()-derived metas to build the full schema.
|
|
300
|
+
readonly unmanagedTables: Readonly<Record<string, UnmanagedTableEntry>>;
|
|
277
301
|
// Optional Zod-schema for env-vars this feature reads at runtime.
|
|
278
302
|
// Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
|
|
279
303
|
// this to build one app-wide schema for boot-validation + dry-run
|
|
@@ -582,6 +606,22 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
582
606
|
// declare data via `r.entity()` instead.
|
|
583
607
|
rawTable(name: string, table: PgTable, options: RawTableOptions): void;
|
|
584
608
|
|
|
609
|
+
// Declare an "unmanaged" framework-native table (post-drizzle).
|
|
610
|
+
// EntityTableMeta carries the same column-shape that r.entity() builds,
|
|
611
|
+
// minus the audit-trail + base-columns scaffolding — used for read-side
|
|
612
|
+
// projections of event-streams (delivery-attempts, job-run-logs) where
|
|
613
|
+
// r.entity()'s aggregate-lifecycle assumptions don't fit.
|
|
614
|
+
//
|
|
615
|
+
// The `meta` argument is the result of `defineUnmanagedTable(...)` from
|
|
616
|
+
// `@cosmicdrift/kumiko-framework/db`. Reason-justification + audit-trail
|
|
617
|
+
// contract identical to `r.rawTable`.
|
|
618
|
+
//
|
|
619
|
+
// Why this exists separate from `r.rawTable`: rawTable carries a Drizzle
|
|
620
|
+
// `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
|
|
621
|
+
// shape that `migrate-runner` consumes. After the full drizzle-cut they
|
|
622
|
+
// will likely merge; for now they coexist.
|
|
623
|
+
unmanagedTable(meta: EntityTableMeta, options: RawTableOptions): void;
|
|
624
|
+
|
|
585
625
|
// Register the tree-actions schema for this feature — a map of
|
|
586
626
|
// action-name → action-definition (with optional typed args). At-most-
|
|
587
627
|
// one call per feature.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { NotFoundError } from "../classes";
|
|
3
|
+
import { FrameworkReasons } from "../reasons";
|
|
4
|
+
import { buildInvalidTransitionDetails } from "../transition-details";
|
|
5
|
+
import { reraiseAsKumikoError, toWriteErrorInfo, writeFailure } from "../write-error-info";
|
|
6
|
+
|
|
7
|
+
describe("writeFailure", () => {
|
|
8
|
+
test("wraps KumikoError into WriteFailure envelope", () => {
|
|
9
|
+
const failure = writeFailure(new NotFoundError("invoice", "inv-1"));
|
|
10
|
+
expect(failure.isSuccess).toBe(false);
|
|
11
|
+
expect(failure.error.code).toBe("not_found");
|
|
12
|
+
expect(failure.error.httpStatus).toBe(404);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("reraiseAsKumikoError", () => {
|
|
17
|
+
test("round-trips WriteErrorInfo through KumikoError", () => {
|
|
18
|
+
const info = toWriteErrorInfo(new NotFoundError("task", 7));
|
|
19
|
+
const err = reraiseAsKumikoError(info);
|
|
20
|
+
expect(err.code).toBe("not_found");
|
|
21
|
+
expect(err.httpStatus).toBe(404);
|
|
22
|
+
expect(err.message).toContain("task");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("buildInvalidTransitionDetails", () => {
|
|
27
|
+
test("builds structured from/to/allowed + message", () => {
|
|
28
|
+
const details = buildInvalidTransitionDetails("draft", "paid", ["sent"]);
|
|
29
|
+
expect(details).toMatchObject({
|
|
30
|
+
from: "draft",
|
|
31
|
+
to: "paid",
|
|
32
|
+
allowed: ["sent"],
|
|
33
|
+
});
|
|
34
|
+
expect(details.message).toContain("draft");
|
|
35
|
+
expect(details.message).toContain("sent");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("FrameworkReasons", () => {
|
|
40
|
+
test("exposes stable snake_case reason codes", () => {
|
|
41
|
+
expect(FrameworkReasons.invalidTransition).toBe("invalid_transition");
|
|
42
|
+
expect(FrameworkReasons.staleState).toBe("stale_state");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { FieldIssue as FrameworkFieldIssue } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import type { FieldIssue as HeadlessFieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
|
|
5
|
+
describe("FieldIssue cross-package contract", () => {
|
|
6
|
+
test("framework and headless FieldIssue shapes are assignable", () => {
|
|
7
|
+
const frameworkIssue: FrameworkFieldIssue = {
|
|
8
|
+
path: "title",
|
|
9
|
+
code: "too_small",
|
|
10
|
+
i18nKey: "errors.validation.too_small",
|
|
11
|
+
params: { minimum: 1 },
|
|
12
|
+
};
|
|
13
|
+
const headlessIssue: HeadlessFieldIssue = frameworkIssue;
|
|
14
|
+
expect(headlessIssue.path).toBe("title");
|
|
15
|
+
});
|
|
16
|
+
});
|
package/src/errors/classes.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
+
import { toSnakeCase } from "../utils/case";
|
|
2
|
+
import type { FieldIssue } from "./field-issue";
|
|
1
3
|
import { type ErrorOpts, KumikoError } from "./kumiko-error";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
// hook-derived validation errors so the client sees one list.
|
|
5
|
-
export type ValidationFieldIssue = {
|
|
6
|
-
readonly path: string;
|
|
7
|
-
readonly code: string;
|
|
8
|
-
readonly i18nKey: string;
|
|
9
|
-
readonly params?: Readonly<Record<string, unknown>>;
|
|
10
|
-
};
|
|
5
|
+
export type { FieldIssue, ValidationFieldIssue } from "./field-issue";
|
|
11
6
|
|
|
12
7
|
export type ValidationDetails = {
|
|
13
|
-
readonly fields: readonly
|
|
8
|
+
readonly fields: readonly FieldIssue[];
|
|
14
9
|
};
|
|
15
10
|
|
|
16
11
|
export class ValidationError extends KumikoError {
|
|
@@ -89,7 +84,7 @@ export class NotFoundError extends KumikoError {
|
|
|
89
84
|
// The reason string follows `<snake_entity>_not_found` — keeps a stable,
|
|
90
85
|
// client-friendly tag that survives wire serialization even if the entity
|
|
91
86
|
// name is later renamed for display purposes.
|
|
92
|
-
const reason = `${
|
|
87
|
+
const reason = `${toSnakeCase(entity)}_not_found`;
|
|
93
88
|
const details: NotFoundDetails & { reason: string } = { reason, entity, id: idStr };
|
|
94
89
|
super({
|
|
95
90
|
message: idStr !== undefined ? `${entity} ${idStr} not found` : `${entity} not found`,
|
|
@@ -101,15 +96,6 @@ export class NotFoundError extends KumikoError {
|
|
|
101
96
|
}
|
|
102
97
|
}
|
|
103
98
|
|
|
104
|
-
// Accepts camelCase OR kebab-case entity names and produces snake_case for
|
|
105
|
-
// the reason tag. New code uses kebab; legacy camelCase still flows through.
|
|
106
|
-
function toSnake(s: string): string {
|
|
107
|
-
return s
|
|
108
|
-
.replace(/-/g, "_")
|
|
109
|
-
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
110
|
-
.toLowerCase();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
99
|
// Generic 409. Features that need a narrower shape should subclass (see
|
|
114
100
|
// VersionConflictError) — this way the HTTP layer stays uniform while callers
|
|
115
101
|
// can still instanceof on the concrete subtype in handlers.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Canonical per-field validation issue shape — shared between server-side
|
|
2
|
+
// ValidationError, Zod-bridge, and client-side DispatcherError.details.fields.
|
|
3
|
+
export type FieldIssue = {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly i18nKey: string;
|
|
7
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** @deprecated Use `FieldIssue` — kept for existing imports. */
|
|
11
|
+
export type ValidationFieldIssue = FieldIssue;
|
package/src/errors/index.ts
CHANGED
package/src/errors/zod-bridge.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ZodError, ZodIssue } from "zod";
|
|
2
|
-
import { ValidationError
|
|
2
|
+
import { ValidationError } from "./classes";
|
|
3
|
+
import type { FieldIssue } from "./field-issue";
|
|
3
4
|
|
|
4
5
|
// Zod issues carry a .code and sometimes issue-specific params (min, max, etc).
|
|
5
6
|
// We surface those under `params` so the client can render "must be at least N"
|
|
@@ -24,7 +25,7 @@ const ISSUE_PARAM_KEYS = [
|
|
|
24
25
|
] as const;
|
|
25
26
|
|
|
26
27
|
export function validationErrorFromZod(error: ZodError): ValidationError {
|
|
27
|
-
const fields = error.issues.map<
|
|
28
|
+
const fields = error.issues.map<FieldIssue>((issue) => {
|
|
28
29
|
const params = extractIssueParams(issue);
|
|
29
30
|
return {
|
|
30
31
|
path: issue.path.map(String).join(".") || "(root)",
|
package/src/es-ops/context.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../db/queries/seed-context";
|
|
16
16
|
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine";
|
|
17
17
|
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
18
|
+
import { parseStringArrayJson } from "../utils/parse-string-array-json";
|
|
18
19
|
import type { SeedMembershipRow, SeedMigrationContext, SeedTenantRow } from "./types";
|
|
19
20
|
|
|
20
21
|
export type CreateSeedMigrationContextArgs = {
|
|
@@ -75,7 +76,7 @@ export function createSeedMigrationContext(
|
|
|
75
76
|
userId: r.user_id,
|
|
76
77
|
tenantId: r.tenant_id,
|
|
77
78
|
streamTenantId: r.stream_tenant_id,
|
|
78
|
-
roles:
|
|
79
|
+
roles: parseStringArrayJson(r.roles),
|
|
79
80
|
}),
|
|
80
81
|
);
|
|
81
82
|
},
|
|
@@ -89,17 +90,5 @@ export function createSeedMigrationContext(
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
function safeParseRolesJson(raw: string): readonly string[] {
|
|
93
|
-
try {
|
|
94
|
-
const parsed: unknown = JSON.parse(raw);
|
|
95
|
-
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
|
|
96
|
-
return parsed;
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Fallthrough — return empty rather than throwing in a seed context.
|
|
100
|
-
}
|
|
101
|
-
return [];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
93
|
// Re-export für Caller-Convenience.
|
|
105
94
|
export type { SeedMigrationContext } from "./types";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createSystemUser } from "../../engine/system-user";
|
|
3
|
+
import { InternalError } from "../../errors";
|
|
4
|
+
import {
|
|
5
|
+
describeShape,
|
|
6
|
+
dispatcherSpanAttributes,
|
|
7
|
+
extractNestedSpecs,
|
|
8
|
+
isFailedWriteResult,
|
|
9
|
+
isLifecycleResult,
|
|
10
|
+
isWriteResultShape,
|
|
11
|
+
prefixValidationPath,
|
|
12
|
+
resolveType,
|
|
13
|
+
wrapToKumiko,
|
|
14
|
+
} from "../dispatcher-utils";
|
|
15
|
+
|
|
16
|
+
describe("isFailedWriteResult", () => {
|
|
17
|
+
test("narrows failed write results", () => {
|
|
18
|
+
const result = { isSuccess: false as const, error: { code: "validation_error" } };
|
|
19
|
+
expect(isFailedWriteResult(result)).toBe(true);
|
|
20
|
+
expect(isFailedWriteResult({ isSuccess: true, data: {} })).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("isWriteResultShape / isLifecycleResult", () => {
|
|
25
|
+
test("detects write-result envelope", () => {
|
|
26
|
+
expect(isWriteResultShape({ isSuccess: true, data: 1 })).toBe(true);
|
|
27
|
+
expect(isWriteResultShape({ kind: "created" })).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("detects lifecycle results", () => {
|
|
31
|
+
expect(isLifecycleResult({ kind: "deleted" })).toBe(true);
|
|
32
|
+
expect(isLifecycleResult(null)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("describeShape", () => {
|
|
37
|
+
test("summarizes unknown values", () => {
|
|
38
|
+
expect(describeShape(null)).toBe("null");
|
|
39
|
+
expect(describeShape("x")).toBe("string");
|
|
40
|
+
expect(describeShape({ a: 1, b: 2 })).toContain("object with keys");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("dispatcherSpanAttributes", () => {
|
|
45
|
+
test("includes handler, operation, user, tenant, optional feature", () => {
|
|
46
|
+
const user = createSystemUser("tenant-1");
|
|
47
|
+
const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
|
|
48
|
+
expect(attrs).toMatchObject({
|
|
49
|
+
"kumiko.handler": "feat:query:task:list",
|
|
50
|
+
"kumiko.operation": "query",
|
|
51
|
+
"kumiko.feature": "feat",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("prefixValidationPath", () => {
|
|
57
|
+
test("prefixes validation field paths", () => {
|
|
58
|
+
const info = {
|
|
59
|
+
code: "validation_error",
|
|
60
|
+
httpStatus: 400,
|
|
61
|
+
i18nKey: "errors.validation.failed",
|
|
62
|
+
message: "Validation failed",
|
|
63
|
+
details: {
|
|
64
|
+
fields: [{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" }],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const prefixed = prefixValidationPath(info, "tasks.0");
|
|
68
|
+
const fields = (prefixed.details as { fields: { path: string }[] }).fields;
|
|
69
|
+
expect(fields[0]?.path).toBe("tasks.0.title");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("leaves non-validation errors unchanged", () => {
|
|
73
|
+
const info = {
|
|
74
|
+
code: "not_found",
|
|
75
|
+
httpStatus: 404,
|
|
76
|
+
i18nKey: "errors.notFound",
|
|
77
|
+
message: "missing",
|
|
78
|
+
};
|
|
79
|
+
expect(prefixValidationPath(info, "x")).toBe(info);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resolveType", () => {
|
|
84
|
+
test("unwraps HandlerRef objects", () => {
|
|
85
|
+
expect(resolveType({ name: "feat:write:task:create" })).toBe("feat:write:task:create");
|
|
86
|
+
expect(resolveType("feat:query:task:list")).toBe("feat:query:task:list");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("wrapToKumiko", () => {
|
|
91
|
+
test("passes through KumikoError instances", () => {
|
|
92
|
+
const err = new InternalError();
|
|
93
|
+
expect(wrapToKumiko(err)).toBe(err);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("wraps generic Error as InternalError", () => {
|
|
97
|
+
const wrapped = wrapToKumiko(new TypeError("boom"));
|
|
98
|
+
expect(wrapped.code).toBe("internal_error");
|
|
99
|
+
expect(wrapped.cause).toBeInstanceOf(TypeError);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("extractNestedSpecs", () => {
|
|
104
|
+
test("returns null for non-create handlers", () => {
|
|
105
|
+
expect(extractNestedSpecs("feat:write:task:update", { tasks: [] }, {} as never)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { RedisKeys } from "../redis-keys";
|
|
3
|
+
|
|
4
|
+
describe("RedisKeys", () => {
|
|
5
|
+
test("uses unique kumiko-prefixed namespaces", () => {
|
|
6
|
+
const values = Object.values(RedisKeys);
|
|
7
|
+
expect(new Set(values).size).toBe(values.length);
|
|
8
|
+
for (const key of values) {
|
|
9
|
+
expect(key.startsWith("kumiko:")).toBe(true);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -6,7 +6,13 @@ import type {
|
|
|
6
6
|
SessionUser,
|
|
7
7
|
WriteResult,
|
|
8
8
|
} from "../engine/types";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type FieldIssue,
|
|
11
|
+
InternalError,
|
|
12
|
+
isKumikoError,
|
|
13
|
+
type KumikoError,
|
|
14
|
+
type WriteErrorInfo,
|
|
15
|
+
} from "../errors";
|
|
10
16
|
|
|
11
17
|
export type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
|
|
12
18
|
|
|
@@ -146,12 +152,7 @@ export function prefixValidationPath(info: WriteErrorInfo, prefix: string): Writ
|
|
|
146
152
|
if (info.code !== "validation_error") return info;
|
|
147
153
|
const details = info.details as // @cast-boundary error-details
|
|
148
154
|
| {
|
|
149
|
-
fields?: readonly
|
|
150
|
-
path: string;
|
|
151
|
-
code: string;
|
|
152
|
-
i18nKey: string;
|
|
153
|
-
params?: Readonly<Record<string, unknown>>;
|
|
154
|
-
}[];
|
|
155
|
+
fields?: readonly FieldIssue[];
|
|
155
156
|
}
|
|
156
157
|
| undefined;
|
|
157
158
|
const fields = details?.fields;
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { JwtHelper } from "../api/jwt";
|
|
|
4
4
|
import { buildServer } from "../api/server";
|
|
5
5
|
import { createSseBroker } from "../api/sse-broker";
|
|
6
6
|
import type { PgClient } from "../db/connection";
|
|
7
|
+
import { extractTableInfo } from "../db/query";
|
|
7
8
|
import { createRegistry } from "../engine/registry";
|
|
8
9
|
import type { FeatureDefinition, Registry, TenantId } from "../engine/types";
|
|
9
10
|
import { createArchivedStreamsTable, createEventsTable } from "../event-store";
|
|
@@ -207,10 +208,9 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
207
208
|
// exist; drizzle-kit's diff machinery would otherwise emit CREATE for
|
|
208
209
|
// them again.
|
|
209
210
|
const { tableExists } = await import("../db/schema-inspection");
|
|
210
|
-
const { getTableName } = await import("drizzle-orm");
|
|
211
211
|
const missing: Record<string, unknown> = {};
|
|
212
212
|
for (const [key, tbl] of Object.entries(projectionTables)) {
|
|
213
|
-
const physical =
|
|
213
|
+
const physical = extractTableInfo(tbl).name;
|
|
214
214
|
if (await tableExists(testDb.db, `public.${physical}`)) continue;
|
|
215
215
|
missing[key] = tbl;
|
|
216
216
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { toSnakeCase } from "../case";
|
|
3
|
+
|
|
4
|
+
describe("toSnakeCase", () => {
|
|
5
|
+
test("camelCase → snake_case", () => {
|
|
6
|
+
expect(toSnakeCase("tenantMembership")).toBe("tenant_membership");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("kebab-case → snake_case", () => {
|
|
10
|
+
expect(toSnakeCase("billing-period")).toBe("billing_period");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("single segment unchanged", () => {
|
|
14
|
+
expect(toSnakeCase("users")).toBe("users");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isPlainObject } from "../is-plain-object";
|
|
3
|
+
|
|
4
|
+
describe("isPlainObject", () => {
|
|
5
|
+
test("accepts plain objects", () => {
|
|
6
|
+
expect(isPlainObject({})).toBe(true);
|
|
7
|
+
expect(isPlainObject({ a: 1 })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("rejects null, arrays, and primitives", () => {
|
|
11
|
+
expect(isPlainObject(null)).toBe(false);
|
|
12
|
+
expect(isPlainObject([])).toBe(false);
|
|
13
|
+
expect(isPlainObject("x")).toBe(false);
|
|
14
|
+
expect(isPlainObject(42)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseStringArrayJson } from "../parse-string-array-json";
|
|
3
|
+
|
|
4
|
+
describe("parseStringArrayJson", () => {
|
|
5
|
+
test("parses string array", () => {
|
|
6
|
+
expect(parseStringArrayJson('["admin","editor"]')).toEqual(["admin", "editor"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns fallback on invalid JSON", () => {
|
|
10
|
+
expect(parseStringArrayJson("{bad", ["guest"])).toEqual(["guest"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns fallback when JSON is not a string array", () => {
|
|
14
|
+
expect(parseStringArrayJson("[1,2]", [])).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseJsonOrThrow, parseJsonSafe } from "../safe-json";
|
|
3
|
+
|
|
4
|
+
describe("parseJsonSafe", () => {
|
|
5
|
+
test("parses valid JSON", () => {
|
|
6
|
+
expect(parseJsonSafe<{ a: number } | null>('{"a":1}', null)).toEqual({ a: 1 });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns fallback on invalid JSON", () => {
|
|
10
|
+
expect(parseJsonSafe("{bad", { ok: false })).toEqual({ ok: false });
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("parseJsonOrThrow", () => {
|
|
15
|
+
test("parses valid JSON", () => {
|
|
16
|
+
expect(parseJsonOrThrow<number[]>("[1,2]", "test")).toEqual([1, 2]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("throws with context on invalid JSON", () => {
|
|
20
|
+
expect(() => parseJsonOrThrow("{", "roles column")).toThrow(/Invalid JSON in roles column/);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Accepts both camelCase (`tenantMembership`) and kebab-case (`tenant-membership`)
|
|
2
|
+
// names. Kebab is canonical for new multi-word identifiers; camelCase remains
|
|
3
|
+
// supported for shipped code.
|
|
4
|
+
export function toSnakeCase(str: string): string {
|
|
5
|
+
return str.replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
6
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { assertUnreachable } from "./assert";
|
|
2
|
+
export { toSnakeCase } from "./case";
|
|
2
3
|
export { readPositiveIntEnv } from "./env-parse";
|
|
3
4
|
export { generateId } from "./ids";
|
|
5
|
+
export { isPlainObject } from "./is-plain-object";
|
|
6
|
+
export { parseStringArrayJson } from "./parse-string-array-json";
|
|
4
7
|
export { parseJsonOrThrow, parseJsonSafe } from "./safe-json";
|
|
5
8
|
export { parseRoles } from "./serialization";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { parseJsonSafe } from "./safe-json";
|
|
2
|
+
|
|
3
|
+
/** Parses a JSON-encoded string array from DB/cache columns; returns fallback on invalid input. */
|
|
4
|
+
export function parseStringArrayJson(
|
|
5
|
+
raw: string,
|
|
6
|
+
fallback: readonly string[] = [],
|
|
7
|
+
): readonly string[] {
|
|
8
|
+
const parsed = parseJsonSafe<unknown>(raw, null);
|
|
9
|
+
if (parsed === null) return fallback;
|
|
10
|
+
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|