@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
package/src/db/dialect.ts CHANGED
@@ -94,6 +94,7 @@ type ColumnFinal = {
94
94
  readonly unique: boolean;
95
95
  readonly identity: boolean;
96
96
  readonly defaultSql?: string;
97
+ readonly bigintJsMode?: "number" | "bigint";
97
98
  };
98
99
 
99
100
  export type ColumnBuilder<TValue = unknown> = {
@@ -112,7 +113,11 @@ export type ColumnBuilder<TValue = unknown> = {
112
113
  $onUpdate(fn: () => unknown): ColumnBuilder<TValue>;
113
114
  };
114
115
 
115
- function buildColumn(sqlName: string, pgType: PgType): ColumnBuilder<unknown> {
116
+ function buildColumn(
117
+ sqlName: string,
118
+ pgType: PgType,
119
+ opts?: { bigintJsMode?: "number" | "bigint" },
120
+ ): ColumnBuilder<unknown> {
116
121
  let notNull = false;
117
122
  let primaryKey = false;
118
123
  let unique = false;
@@ -152,6 +157,7 @@ function buildColumn(sqlName: string, pgType: PgType): ColumnBuilder<unknown> {
152
157
  unique,
153
158
  identity,
154
159
  ...(defaultSql !== undefined && { defaultSql }),
160
+ ...(opts?.bigintJsMode !== undefined && { bigintJsMode: opts.bigintJsMode }),
155
161
  };
156
162
  },
157
163
  notNull() {
@@ -219,11 +225,9 @@ export function serial(name: string): ColumnBuilder<number> {
219
225
  return buildColumn(name, "serial") as ColumnBuilder<number>;
220
226
  }
221
227
 
222
- export function bigint(
223
- name: string,
224
- _opts?: { mode?: "bigint" | "number" },
225
- ): ColumnBuilder<bigint> {
226
- return buildColumn(name, "bigint") as ColumnBuilder<bigint>;
228
+ export function bigint(name: string, opts?: { mode?: "bigint" | "number" }): ColumnBuilder<bigint> {
229
+ const jsMode = opts?.mode === "number" ? "number" : "bigint";
230
+ return buildColumn(name, "bigint", { bigintJsMode: jsMode }) as ColumnBuilder<bigint>;
227
231
  }
228
232
 
229
233
  export function bigserial(
@@ -407,6 +411,7 @@ export function table<TCols extends ColumnMap>(
407
411
  ...(final.primaryKey && { primaryKey: true }),
408
412
  ...(final.identity && { identity: true }),
409
413
  ...(final.defaultSql !== undefined && { defaultSql: final.defaultSql }),
414
+ ...(final.bigintJsMode !== undefined && { bigintJsMode: final.bigintJsMode }),
410
415
  };
411
416
  columnMetas.push(meta);
412
417
 
@@ -51,6 +51,9 @@ export type ColumnMeta = {
51
51
  readonly defaultSql?: string;
52
52
  readonly primaryKey?: boolean;
53
53
  readonly identity?: boolean;
54
+ // bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
55
+ // drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
56
+ readonly bigintJsMode?: "number" | "bigint";
54
57
  };
55
58
 
56
59
  export type IndexMeta = {
@@ -199,6 +202,7 @@ function fieldToColumnMeta(
199
202
  name: snake,
200
203
  pgType: "bigint",
201
204
  notNull: field.required === true,
205
+ bigintJsMode: "number",
202
206
  ...(def !== undefined && { defaultSql: def }),
203
207
  },
204
208
  ];
@@ -211,7 +215,7 @@ function fieldToColumnMeta(
211
215
  case "money": {
212
216
  const cur = entity.defaultCurrency ?? "EUR";
213
217
  return [
214
- { name: snake, pgType: "bigint", notNull: field.required === true },
218
+ { name: snake, pgType: "bigint", notNull: field.required === true, bigintJsMode: "bigint" },
215
219
  {
216
220
  name: `${snake}_currency`,
217
221
  pgType: "text",
@@ -329,23 +333,22 @@ export function buildEntityTableMeta(
329
333
  indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
330
334
  }
331
335
 
332
- // Explizit deklarierte indexes (EntityIndexDef). `def.where` ist heute
333
- // ein drizzle SQL-AST wir können daraus keinen zuverlässigen Raw-SQL-
334
- // String rendern (queryChunks sind internal). Wenn ein where gesetzt
335
- // ist, markieren wir den IndexMeta mit needsManualWhere=true; der DDL-
336
- // Renderer emittiert das Statement dann als AUSKOMMENTIERT mit Warn-
337
- // Hinweis. App-Author muss das im generierten SQL-File hand-editieren.
336
+ // Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
337
+ // SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db)
338
+ // renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
338
339
  for (const def of (entity.indexes ?? []) as readonly EntityIndexDef[]) {
339
340
  const cols = def.columns.map(
340
341
  (fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName),
341
342
  );
342
343
  const suffix = def.unique === true ? "unique" : "idx";
343
344
  const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
345
+ const whereSql = sqlExpressionText(def.where);
344
346
  indexes.push({
345
347
  name: indexName,
346
348
  columns: cols,
347
349
  ...(def.unique === true && { unique: true }),
348
- ...(def.where !== undefined && { needsManualWhere: true }),
350
+ ...(whereSql !== undefined && { whereSql }),
351
+ ...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
349
352
  });
350
353
  }
351
354
 
@@ -377,6 +380,18 @@ export type UnmanagedTableInput = {
377
380
  readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
378
381
  };
379
382
 
383
+ function sqlExpressionText(where: unknown): string | undefined {
384
+ if (
385
+ typeof where === "object" &&
386
+ where !== null &&
387
+ (where as { kind?: unknown }).kind === "sql-expr" &&
388
+ typeof (where as { text?: unknown }).text === "string"
389
+ ) {
390
+ return (where as { text: string }).text;
391
+ }
392
+ return undefined;
393
+ }
394
+
380
395
  export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
381
396
  return {
382
397
  tableName: input.tableName,
package/src/db/index.ts CHANGED
@@ -51,6 +51,31 @@ export type {
51
51
  } from "./event-store-executor";
52
52
  export { createEventStoreExecutor, entityEventName } from "./event-store-executor";
53
53
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
54
+ export {
55
+ diffSnapshots,
56
+ type GenerateMigrationInput,
57
+ type GenerateMigrationOutput,
58
+ generateMigration,
59
+ loadSnapshotJson,
60
+ renderMigrationSql,
61
+ type SchemaDiff,
62
+ type Snapshot,
63
+ snapshotFromMetas,
64
+ writeSnapshotJson,
65
+ } from "./migrate-generator";
66
+ export {
67
+ type AppliedMigration,
68
+ type ApplyResult,
69
+ type BaselineResult,
70
+ baselineMigrations,
71
+ fetchAppliedMigrations,
72
+ loadMigrationsFromDir,
73
+ type Migration,
74
+ MigrationChecksumMismatchError,
75
+ runMigrations,
76
+ runMigrationsFromDir,
77
+ splitSqlStatements,
78
+ } from "./migrate-runner";
54
79
  export { flattenMoney, rehydrateMoney } from "./money";
55
80
  export {
56
81
  constraintOf,
@@ -111,7 +111,9 @@ async function executeRaw(db: DbRunner, sqlText: string): Promise<void> {
111
111
  await rawClient(db).unsafe(sqlText);
112
112
  }
113
113
 
114
- async function fetchAppliedMigrations(db: DbConnection): Promise<readonly AppliedMigration[]> {
114
+ export async function fetchAppliedMigrations(
115
+ db: DbConnection,
116
+ ): Promise<readonly AppliedMigration[]> {
115
117
  const result = await rawClient(db).unsafe(
116
118
  `SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`,
117
119
  );
@@ -204,3 +206,35 @@ export async function runMigrationsFromDir(db: DbConnection, dir: string): Promi
204
206
  const migrations = loadMigrationsFromDir(dir);
205
207
  return runMigrations(db, migrations);
206
208
  }
209
+
210
+ export type BaselineResult = {
211
+ readonly marked: readonly string[];
212
+ readonly alreadyTracked: readonly string[];
213
+ };
214
+
215
+ // Marks migrations as applied in `_kumiko_migrations` WITHOUT executing their
216
+ // SQL. For adopting an existing DB whose tables already exist — e.g. the
217
+ // cutover from the legacy drizzle-kit system, where re-running 0001_init would
218
+ // hit CREATE-TABLE conflicts. Idempotent: already-tracked ids are left as-is.
219
+ export async function baselineMigrations(
220
+ db: DbConnection,
221
+ migrations: readonly Migration[],
222
+ ): Promise<BaselineResult> {
223
+ await executeRaw(db, MIGRATIONS_TABLE_DDL);
224
+ const applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
225
+ const marked: string[] = [];
226
+ const alreadyTracked: string[] = [];
227
+ const client = rawClient(db);
228
+ for (const m of migrations) {
229
+ if (applied.has(m.id)) {
230
+ alreadyTracked.push(m.id);
231
+ continue;
232
+ }
233
+ await client.unsafe(
234
+ `INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2) ON CONFLICT ("id") DO NOTHING`,
235
+ [m.id, m.checksum],
236
+ );
237
+ marked.push(m.id);
238
+ }
239
+ return { marked, alreadyTracked };
240
+ }
package/src/db/money.ts CHANGED
@@ -106,6 +106,11 @@ export function rehydrateMoney(
106
106
  let amount: number;
107
107
  if (typeof amountRaw === "number") {
108
108
  amount = amountRaw;
109
+ } else if (typeof amountRaw === "bigint") {
110
+ amount = Number(amountRaw);
111
+ if (Number.isNaN(amount)) {
112
+ throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
113
+ }
109
114
  } else if (typeof amountRaw === "string" && amountRaw !== "") {
110
115
  // PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
111
116
  amount = Number(amountRaw);
@@ -25,9 +25,11 @@ export async function createIndexIfNotExists(
25
25
  indexName: string,
26
26
  tableName: string,
27
27
  columnList: string,
28
+ whereSql?: string,
28
29
  ): Promise<void> {
30
+ const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
29
31
  await asRawClient(db).unsafe(
30
- `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})`,
32
+ `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
31
33
  );
32
34
  }
33
35
 
@@ -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();
@@ -154,7 +154,7 @@ export { resolveConfigOrParam } from "./resolve-config-or-param";
154
154
  export { runsInLane } from "./run-in";
155
155
  export type { StepListOutcome } from "./run-pipeline";
156
156
  export { runPipeline, runStepList } from "./run-pipeline";
157
- export { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
157
+ export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-builder";
158
158
  export type { TransitionGraph } from "./state-machine";
159
159
  export { defineTransitions, guardTransition } from "./state-machine";
160
160
  export {
@@ -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
@@ -18,7 +18,7 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
18
18
  }
19
19
  }
20
20
 
21
- function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
21
+ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
22
22
  switch (field.type) {
23
23
  case "text": {
24
24
  let schema = z.string();
@@ -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,