@cosmicdrift/kumiko-framework 0.15.0 → 0.18.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 (58) hide show
  1. package/package.json +1 -1
  2. package/src/api/auth-routes.ts +2 -5
  3. package/src/api/routes.ts +9 -3
  4. package/src/bun-db/__tests__/query-guards.test.ts +53 -0
  5. package/src/bun-db/query.ts +164 -23
  6. package/src/compliance/profiles.ts +1 -4
  7. package/src/db/__tests__/cursor.test.ts +17 -0
  8. package/src/db/__tests__/migrate-generator.test.ts +71 -0
  9. package/src/db/__tests__/migrate-runner.test.ts +19 -0
  10. package/src/db/__tests__/pg-error.test.ts +43 -0
  11. package/src/db/assert-exists-in.ts +5 -1
  12. package/src/db/dialect.ts +11 -6
  13. package/src/db/entity-table-meta.ts +23 -8
  14. package/src/db/index.ts +25 -0
  15. package/src/db/migrate-runner.ts +35 -1
  16. package/src/db/money.ts +5 -0
  17. package/src/db/queries/test-stack.ts +3 -1
  18. package/src/db/table-builder.ts +6 -6
  19. package/src/engine/__tests__/duration-utils.test.ts +16 -0
  20. package/src/engine/__tests__/field-access.test.ts +38 -0
  21. package/src/engine/__tests__/no-return-guard.test.ts +17 -0
  22. package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
  23. package/src/engine/define-feature.ts +36 -0
  24. package/src/engine/feature-ast/extractors/shared.ts +2 -3
  25. package/src/engine/index.ts +1 -1
  26. package/src/engine/registry.ts +19 -0
  27. package/src/engine/schema-builder.ts +1 -1
  28. package/src/engine/types/feature.ts +40 -0
  29. package/src/engine/types/index.ts +2 -0
  30. package/src/errors/__tests__/error-helpers.test.ts +44 -0
  31. package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
  32. package/src/errors/classes.ts +5 -19
  33. package/src/errors/field-issue.ts +11 -0
  34. package/src/errors/index.ts +1 -0
  35. package/src/errors/zod-bridge.ts +3 -2
  36. package/src/es-ops/context.ts +2 -13
  37. package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +15 -12
  38. package/src/event-store/admin-api.ts +5 -4
  39. package/src/event-store/event-store.ts +3 -2
  40. package/src/event-store/snapshot.ts +2 -1
  41. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +161 -0
  42. package/src/migrations/index.ts +11 -1
  43. package/src/migrations/kumiko-drift.ts +122 -0
  44. package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
  45. package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
  46. package/src/pipeline/dispatcher-utils.ts +8 -7
  47. package/src/stack/request-helper.ts +39 -4
  48. package/src/stack/table-helpers.ts +1 -1
  49. package/src/stack/test-stack.ts +2 -2
  50. package/src/utils/__tests__/case.test.ts +16 -0
  51. package/src/utils/__tests__/is-plain-object.test.ts +16 -0
  52. package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
  53. package/src/utils/__tests__/safe-json.test.ts +22 -0
  54. package/src/utils/case.ts +6 -0
  55. package/src/utils/index.ts +4 -1
  56. package/src/utils/is-plain-object.ts +4 -0
  57. package/src/utils/parse-string-array-json.ts +14 -0
  58. package/src/utils/safe-json.ts +19 -0
@@ -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,44 @@ import type { SessionUser } from "../engine/types";
4
4
 
5
5
  export type BatchCommand = { type: string; payload: unknown };
6
6
 
7
+ type WireErrorBody = {
8
+ readonly code?: string;
9
+ readonly details?: {
10
+ readonly causeName?: string;
11
+ readonly causeMessage?: string;
12
+ readonly causeStack?: string;
13
+ };
14
+ };
15
+
16
+ function formatWriteFailure(type: string, body: unknown): string {
17
+ const parsed = body as {
18
+ isSuccess?: boolean;
19
+ error?: WireErrorBody | string;
20
+ };
21
+ const code =
22
+ (typeof parsed.error === "object" ? parsed.error?.code : undefined) ??
23
+ (typeof parsed.error === "string" ? parsed.error : "unknown");
24
+ const details =
25
+ typeof parsed.error === "object" && parsed.error?.details !== undefined
26
+ ? parsed.error.details
27
+ : undefined;
28
+ const causeMessage =
29
+ details && typeof details === "object" && "causeMessage" in details
30
+ ? String((details as { causeMessage?: unknown }).causeMessage ?? "")
31
+ : "";
32
+ const causeName =
33
+ details && typeof details === "object" && "causeName" in details
34
+ ? String((details as { causeName?: unknown }).causeName ?? "")
35
+ : "";
36
+ if (code === "internal_error" && (causeMessage || causeName)) {
37
+ return `Expected write "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`;
38
+ }
39
+ if (details !== undefined) {
40
+ return `Expected write "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`;
41
+ }
42
+ return `Expected write "${type}" to succeed but got error: ${code}`;
43
+ }
44
+
7
45
  export type RequestHelper = {
8
46
  write: (
9
47
  type: string,
@@ -123,10 +161,7 @@ export function createRequestHelper(app: Hono, jwt: JwtHelper): RequestHelper {
123
161
  // follow the error-contract shape { error: { code, i18nKey, ... } } with
124
162
  // a 4xx/5xx status — no isSuccess flag. Detect either.
125
163
  if (body.isSuccess !== true) {
126
- const code =
127
- (typeof body.error === "object" ? body.error?.code : undefined) ??
128
- (typeof body.error === "string" ? body.error : "unknown");
129
- throw new Error(`Expected write "${type}" to succeed but got error: ${code}`);
164
+ throw new Error(formatWriteFailure(type, body));
130
165
  }
131
166
  return body.data as T; // @cast-boundary engine-bridge
132
167
  },
@@ -107,7 +107,7 @@ export async function unsafePushTables(
107
107
  if (!prevIdxNames.has(idx.name)) {
108
108
  const kind = idx.unique ? "UNIQUE INDEX" : "INDEX";
109
109
  const colList = idx.columns.map((c) => `"${c}"`).join(", ");
110
- await createIndexIfNotExists(db, kind, idx.name, meta.tableName, colList);
110
+ await createIndexIfNotExists(db, kind, idx.name, meta.tableName, colList, idx.whereSql);
111
111
  }
112
112
  }
113
113
  } else {
@@ -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";
4
- export { parseJsonOrThrow, parseJsonSafe } from "./safe-json";
5
+ export { isPlainObject } from "./is-plain-object";
6
+ export { parseStringArrayJson } from "./parse-string-array-json";
7
+ export { parseJsonOrThrow, parseJsonSafe, stringifyJson } 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
+ }
@@ -28,3 +28,22 @@ export function parseJsonOrThrow<T>(raw: string, context: string): T {
28
28
  throw new Error(`Invalid JSON in ${context}: ${msg}`);
29
29
  }
30
30
  }
31
+
32
+ /** JSON.stringify that survives BigInt / Temporal values from DB rows. */
33
+ export function stringifyJson(value: unknown): string {
34
+ return JSON.stringify(value, (_key, v) => {
35
+ if (typeof v === "bigint") {
36
+ const asNumber = Number(v);
37
+ if (
38
+ asNumber <= Number.MAX_SAFE_INTEGER &&
39
+ asNumber >= Number.MIN_SAFE_INTEGER &&
40
+ BigInt(asNumber) === v
41
+ ) {
42
+ return asNumber;
43
+ }
44
+ return v.toString();
45
+ }
46
+ if (v instanceof Temporal.Instant) return v.toString();
47
+ return v;
48
+ });
49
+ }