@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/api/auth-routes.ts +2 -5
  3. package/src/bun-db/query.ts +2 -5
  4. package/src/compliance/profiles.ts +1 -4
  5. package/src/db/__tests__/cursor.test.ts +17 -0
  6. package/src/db/__tests__/migrate-generator.test.ts +71 -0
  7. package/src/db/__tests__/migrate-runner.test.ts +19 -0
  8. package/src/db/__tests__/pg-error.test.ts +43 -0
  9. package/src/db/table-builder.ts +6 -6
  10. package/src/engine/__tests__/duration-utils.test.ts +16 -0
  11. package/src/engine/__tests__/field-access.test.ts +38 -0
  12. package/src/engine/__tests__/no-return-guard.test.ts +17 -0
  13. package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
  14. package/src/engine/define-feature.ts +36 -0
  15. package/src/engine/feature-ast/extractors/shared.ts +2 -3
  16. package/src/engine/registry.ts +19 -0
  17. package/src/engine/types/feature.ts +40 -0
  18. package/src/engine/types/index.ts +2 -0
  19. package/src/errors/__tests__/error-helpers.test.ts +44 -0
  20. package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
  21. package/src/errors/classes.ts +5 -19
  22. package/src/errors/field-issue.ts +11 -0
  23. package/src/errors/index.ts +1 -0
  24. package/src/errors/zod-bridge.ts +3 -2
  25. package/src/es-ops/context.ts +2 -13
  26. package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
  27. package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
  28. package/src/pipeline/dispatcher-utils.ts +8 -7
  29. package/src/stack/test-stack.ts +2 -2
  30. package/src/utils/__tests__/case.test.ts +16 -0
  31. package/src/utils/__tests__/is-plain-object.test.ts +16 -0
  32. package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
  33. package/src/utils/__tests__/safe-json.test.ts +22 -0
  34. package/src/utils/case.ts +6 -0
  35. package/src/utils/index.ts +3 -0
  36. package/src/utils/is-plain-object.ts +4 -0
  37. 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.15.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>",
@@ -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
- // @cast-boundary db-row — userTable.roles is JSON-encoded string[] per AuthUserRow contract
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
@@ -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
- try {
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
+ });
@@ -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. Kebab is the canonical form for new multi-word entity
196
- // types (consistent across r.entity, event-types, table names) — camelCase is
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 function isPlainObject(value: unknown): value is Record<string, unknown> {
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();
@@ -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.
@@ -70,6 +70,8 @@ export type {
70
70
  SecretKeyDefinition,
71
71
  SecretKeyHandle,
72
72
  SecretOptions,
73
+ UnmanagedTableDef,
74
+ UnmanagedTableEntry,
73
75
  } from "./feature";
74
76
  export type {
75
77
  AnyFileFieldDef,
@@ -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
+ });
@@ -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
- // Per-field validation issue. Shared shape between Zod-derived and
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 ValidationFieldIssue[];
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 = `${toSnake(entity)}_not_found`;
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;
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  FeatureDisabledDetails,
3
+ FieldIssue,
3
4
  NotFoundDetails,
4
5
  RateLimitDetails,
5
6
  UniqueViolationDetails,
@@ -1,5 +1,6 @@
1
1
  import type { ZodError, ZodIssue } from "zod";
2
- import { ValidationError, type ValidationFieldIssue } from "./classes";
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<ValidationFieldIssue>((issue) => {
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)",
@@ -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: safeParseRolesJson(r.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 { InternalError, isKumikoError, type KumikoError, type WriteErrorInfo } from "../errors";
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;
@@ -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 = getTableName(tbl as Parameters<typeof getTableName>[0]); // @cast-boundary drizzle-bridge
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
+ }
@@ -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,4 @@
1
+ /** Non-null object that is not an array — shared guard for deep-merge and AST extractors. */
2
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
@@ -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
+ }