@cosmicdrift/kumiko-framework 0.165.1 → 0.165.3

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 (45) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +7 -3
  4. package/src/api/__tests__/sse-broker.test.ts +27 -18
  5. package/src/api/routes.ts +3 -3
  6. package/src/api/sse-broker.ts +12 -13
  7. package/src/bun-db/query.ts +2 -2
  8. package/src/crypto/index.ts +1 -0
  9. package/src/crypto/subject-resolver.ts +15 -0
  10. package/src/db/__tests__/decimal-field.test.ts +3 -3
  11. package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
  12. package/src/db/__tests__/migrate-runner.test.ts +19 -1
  13. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -4
  15. package/src/db/collect-table-metas.ts +3 -3
  16. package/src/db/entity-table-meta.ts +49 -32
  17. package/src/db/index.ts +5 -1
  18. package/src/db/migrate-runner.ts +18 -11
  19. package/src/db/table-builder.ts +2 -2
  20. package/src/db/tenant-db.ts +1 -1
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +24 -6
  22. package/src/engine/__tests__/store-table.test.ts +8 -11
  23. package/src/engine/boot-validator/pii-retention.ts +18 -8
  24. package/src/engine/constants.ts +0 -4
  25. package/src/engine/feature-ast/extractors/round5.ts +1 -1
  26. package/src/engine/feature-changelog.ts +93 -0
  27. package/src/engine/feature-manifest.ts +4 -0
  28. package/src/engine/feature-ui-extensions.ts +1 -1
  29. package/src/engine/index.ts +10 -0
  30. package/src/engine/registry-state.ts +2 -2
  31. package/src/engine/validate-projection-allowlist.ts +1 -1
  32. package/src/jobs/__tests__/scheduler-id.test.ts +18 -0
  33. package/src/jobs/index.ts +1 -1
  34. package/src/jobs/job-runner.ts +31 -1
  35. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
  36. package/src/migrations/projection-table-index.ts +1 -1
  37. package/src/pipeline/__tests__/dispatcher.test.ts +61 -0
  38. package/src/pipeline/dispatch-stream.ts +22 -12
  39. package/src/pipeline/system-hooks.ts +54 -7
  40. package/src/schema-cli.ts +2 -3
  41. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +164 -0
  42. package/src/search/index.ts +1 -0
  43. package/src/search/purge-subject.ts +135 -0
  44. package/src/testing/__tests__/wait-for.test.ts +8 -4
  45. package/src/testing/wait-for.ts +10 -5
package/README.md CHANGED
@@ -79,7 +79,7 @@ export const taskFeature = defineFeature("tasks", (r) => {
79
79
  | Entry | What's in it |
80
80
  |---|---|
81
81
  | `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field helpers, access rules, registry |
82
- | `@cosmicdrift/kumiko-framework/db` | `buildEntityTableMeta`, `createEventStoreExecutor`, migrations, tenant-db |
82
+ | `@cosmicdrift/kumiko-framework/db` | `deriveEntityTableMeta`, `createEventStoreExecutor`, migrations, tenant-db |
83
83
  | `@cosmicdrift/kumiko-framework/event-store` | `events` table, `append`, `loadAggregate`, `loadAggregateAsOf` |
84
84
  | `@cosmicdrift/kumiko-framework/pipeline` | Dispatcher, event-dispatcher (AsyncDaemon), projection-rebuild, SSE + search consumers |
85
85
  | `@cosmicdrift/kumiko-framework/api` | `buildServer`, auth middleware, SSE route, error contract |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.165.1",
3
+ "version": "0.165.3",
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>",
@@ -197,10 +197,10 @@
197
197
  "zod": "^4.4.3"
198
198
  },
199
199
  "peerDependencies": {
200
- "@cosmicdrift/kumiko-types": "^0.165.1"
200
+ "@cosmicdrift/kumiko-types": "^0.165.3"
201
201
  },
202
202
  "devDependencies": {
203
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.1",
203
+ "@cosmicdrift/kumiko-dispatcher-live": "0.165.3",
204
204
  "bun-types": "^1.3.13",
205
205
  "pino-pretty": "^13.1.3"
206
206
  },
@@ -128,10 +128,14 @@ describe("AuthRoutesConfig.trustedProxyHops", () => {
128
128
  expect(third.status).toBe(429);
129
129
  });
130
130
 
131
- test("hops=1 with no XFF header at all falls back to unknown, not a throw", async () => {
131
+ test("hops=1 with no XFF and no X-Real-IP collapses distinct clients into unknown", async () => {
132
132
  const app = await buildApp({ trustedProxyHops: 1 });
133
- const res = await app.request(verifyRequest(undefined));
134
- expect(res.status).toBe(422);
133
+ // Three requests without either header must share the "unknown" bucket —
134
+ // a single 422 only proves "no throw", not the collapse (fw#1555#2).
135
+ const attempt = () => app.request(verifyRequest(undefined));
136
+ expect((await attempt()).status).toBe(422);
137
+ expect((await attempt()).status).toBe(422);
138
+ expect((await attempt()).status).toBe(429);
135
139
  });
136
140
 
137
141
  test("hops=1 with short/missing XFF uses x-real-ip so clients stay independent", async () => {
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { createSseBroker, type SseEvent } from "../sse-broker";
2
+ import { createSseBroker, type SseBroker, type SseEvent } from "../sse-broker";
3
+
4
+ function requireAccessInvalidation(broker: SseBroker) {
5
+ const subscribe = broker.subscribeAccessInvalidation;
6
+ const publish = broker.publishAccessInvalidation;
7
+ if (!subscribe || !publish) {
8
+ throw new Error("createSseBroker must implement access-invalidation hooks");
9
+ }
10
+ return { subscribe, publish };
11
+ }
3
12
 
4
13
  describe("SSE broker", () => {
5
14
  test("adds client and tracks count", () => {
@@ -57,59 +66,59 @@ describe("SSE broker", () => {
57
66
  });
58
67
 
59
68
  test("subscribeAccessInvalidation fires only listeners on the same user's channel", () => {
60
- const broker = createSseBroker();
69
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
61
70
  const onInvalidateA = mock();
62
71
  const onInvalidateB = mock();
63
72
 
64
- broker.subscribeAccessInvalidation("user-a", onInvalidateA);
65
- broker.subscribeAccessInvalidation("user-b", onInvalidateB);
73
+ subscribe("user-a", onInvalidateA);
74
+ subscribe("user-b", onInvalidateB);
66
75
 
67
- broker.publishAccessInvalidation("user-a");
76
+ publish("user-a");
68
77
 
69
78
  expect(onInvalidateA).toHaveBeenCalledTimes(1);
70
79
  expect(onInvalidateB).not.toHaveBeenCalled();
71
80
  });
72
81
 
73
82
  test("subscribeAccessInvalidation supports multiple listeners on the same user", () => {
74
- const broker = createSseBroker();
83
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
75
84
  const first = mock();
76
85
  const second = mock();
77
86
 
78
- broker.subscribeAccessInvalidation("user-a", first);
79
- broker.subscribeAccessInvalidation("user-a", second);
80
- broker.publishAccessInvalidation("user-a");
87
+ subscribe("user-a", first);
88
+ subscribe("user-a", second);
89
+ publish("user-a");
81
90
 
82
91
  expect(first).toHaveBeenCalledTimes(1);
83
92
  expect(second).toHaveBeenCalledTimes(1);
84
93
  });
85
94
 
86
95
  test("unsubscribe (returned closure) stops further delivery", () => {
87
- const broker = createSseBroker();
96
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
88
97
  const onInvalidate = mock();
89
98
 
90
- const unsubscribe = broker.subscribeAccessInvalidation("user-a", onInvalidate);
99
+ const unsubscribe = subscribe("user-a", onInvalidate);
91
100
  unsubscribe();
92
- broker.publishAccessInvalidation("user-a");
101
+ publish("user-a");
93
102
 
94
103
  expect(onInvalidate).not.toHaveBeenCalled();
95
104
  });
96
105
 
97
106
  test("a fired listener can unsubscribe itself without skipping other listeners", () => {
98
- const broker = createSseBroker();
107
+ const { subscribe, publish } = requireAccessInvalidation(createSseBroker());
99
108
  let unsubscribeSelf: () => void = () => {};
100
109
  const self = mock(() => unsubscribeSelf());
101
110
  const other = mock();
102
111
 
103
- unsubscribeSelf = broker.subscribeAccessInvalidation("user-a", self);
104
- broker.subscribeAccessInvalidation("user-a", other);
105
- broker.publishAccessInvalidation("user-a");
112
+ unsubscribeSelf = subscribe("user-a", self);
113
+ subscribe("user-a", other);
114
+ publish("user-a");
106
115
 
107
116
  expect(self).toHaveBeenCalledTimes(1);
108
117
  expect(other).toHaveBeenCalledTimes(1);
109
118
  });
110
119
 
111
120
  test("publishAccessInvalidation to a user with no listeners does nothing", () => {
112
- const broker = createSseBroker();
113
- expect(() => broker.publishAccessInvalidation("nobody-listening")).not.toThrow();
121
+ const { publish } = requireAccessInvalidation(createSseBroker());
122
+ expect(() => publish("nobody-listening")).not.toThrow();
114
123
  });
115
124
  });
package/src/api/routes.ts CHANGED
@@ -19,9 +19,9 @@ import { patAllows } from "./pat-scope";
19
19
  import { requestContext } from "./request-context";
20
20
  import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
21
21
 
22
- // SSE frame event names for POST /api/stream (framework-owned; dispatcher-live
23
- // has no dependency on this package and keeps its own copy in sse-stream.ts —
24
- // a drift between the two fails the real-HTTP frame assertions in api.test.ts).
22
+ // SSE frame event names for POST /api/stream. Parallel definition lives in
23
+ // @cosmicdrift/kumiko-headless (dispatcher-live imports that one) framework
24
+ // cannot depend on headless for four string literals.
25
25
  export const StreamFrame = {
26
26
  chunk: "chunk",
27
27
  ping: "ping",
@@ -18,18 +18,18 @@ export type SseBroker = {
18
18
  pushToChannel(channel: string, event: SseEvent): void;
19
19
  getClientCount(channel: string): number;
20
20
  getTotalClientCount(): number;
21
- // Internal (non-SSE-client) subscription, e.g. dispatch-stream watching
22
- // for mid-stream access revocation. Kept separate from addClient/
23
- // pushToChannel: those count towards getClientCount/getTotalClientCount
24
- // (real SSE connections) and their send/close shape doesn't fit a plain
25
- // callback listener. Returns an unsubscribe function.
26
- subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
27
- publishAccessInvalidation(userId: string): void;
21
+ // Separate from addClient so it doesn't count towards getClientCount.
22
+ // Optional: additive for external/test SseBroker impls (call sites use?.).
23
+ subscribeAccessInvalidation?(userId: string, onInvalidate: () => void): () => void;
24
+ publishAccessInvalidation?(userId: string): void;
28
25
  };
29
26
 
30
27
  export function createSseBroker(): SseBroker {
28
+ // ponytail: in-process only — publishAccessInvalidation does not fan out via
29
+ // Redis. Multi-replica deployments will not revoke SSE streams on other pods
30
+ // (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
31
31
  const channels = new Map<string, Map<string, SseClient>>();
32
- const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
32
+ const accessInvalidationListeners = new Map<string, Set<() => void>>();
33
33
 
34
34
  function getOrCreateChannel(channel: string): Map<string, SseClient> {
35
35
  let clients = channels.get(channel);
@@ -79,18 +79,17 @@ export function createSseBroker(): SseBroker {
79
79
 
80
80
  subscribeAccessInvalidation(userId, onInvalidate) {
81
81
  const channel = userAccessChannel(userId);
82
- const listenerId = generateId();
83
82
  let listeners = accessInvalidationListeners.get(channel);
84
83
  if (!listeners) {
85
- listeners = new Map();
84
+ listeners = new Set();
86
85
  accessInvalidationListeners.set(channel, listeners);
87
86
  }
88
- listeners.set(listenerId, onInvalidate);
87
+ listeners.add(onInvalidate);
89
88
  return () => {
90
89
  const current = accessInvalidationListeners.get(channel);
91
90
  // skip: already unsubscribed (e.g. stream ended after a publish already fired)
92
91
  if (!current) return;
93
- current.delete(listenerId);
92
+ current.delete(onInvalidate);
94
93
  if (current.size === 0) accessInvalidationListeners.delete(channel);
95
94
  };
96
95
  },
@@ -102,7 +101,7 @@ export function createSseBroker(): SseBroker {
102
101
  if (!listeners) return;
103
102
  // Snapshot before iterating — a fired listener unsubscribes itself,
104
103
  // which would mutate `listeners` mid-iteration otherwise.
105
- for (const onInvalidate of [...listeners.values()]) {
104
+ for (const onInvalidate of [...listeners]) {
106
105
  onInvalidate();
107
106
  }
108
107
  },
@@ -67,7 +67,7 @@ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
67
67
  // Resolve any framework table input to its canonical EntityTableMeta:
68
68
  // - table()/buildEntityTable outputs carry it under KUMIKO_META_SYMBOL, immune
69
69
  // to a column-handle shadowing a meta key.
70
- // - buildEntityTableMeta / defineUnmanagedTable return a plain meta with no
70
+ // - deriveEntityTableMeta / defineUnmanagedTable return a plain meta with no
71
71
  // handle-spread, so its structural shape is itself unshadowable.
72
72
  export function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
73
73
  if (table === null || typeof table !== "object") return undefined;
@@ -252,7 +252,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
252
252
  if (!meta) {
253
253
  throw new Error(
254
254
  "bun-db.extractTableInfo: table is not a kumiko EntityTableMeta — " +
255
- "build it via buildEntityTable / buildEntityTableMeta / table().",
255
+ "build it via buildEntityTable / deriveEntityTableMeta / table().",
256
256
  );
257
257
  }
258
258
  const colByField = new Map<string, string>();
@@ -61,6 +61,7 @@ export {
61
61
  } from "./request-kms-cache";
62
62
  export {
63
63
  collectPiiSubjectFields,
64
+ collectSearchableSubjectFields,
64
65
  type ResolveSubjectOptions,
65
66
  resolveSubjectForField,
66
67
  SubjectResolutionError,
@@ -89,3 +89,18 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
89
89
  )
90
90
  .map(([name]) => name);
91
91
  }
92
+
93
+ /** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
94
+ export function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[] {
95
+ return Object.entries(entity.fields)
96
+ .filter(([, field]) => {
97
+ const subject =
98
+ ("userOwned" in field && field.userOwned !== undefined) ||
99
+ ("tenantOwned" in field && field.tenantOwned === true) ||
100
+ ("pii" in field && field.pii === true);
101
+ if (!subject) return false;
102
+ if ("sensitive" in field && field.sensitive === true) return false;
103
+ return "searchable" in field && field.searchable === true;
104
+ })
105
+ .map(([name]) => name);
106
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDecimalField, createEntity } from "../../engine/factories";
3
3
  import { fieldToZod } from "../../engine/schema-builder";
4
- import { buildEntityTableMeta } from "../entity-table-meta";
4
+ import { deriveEntityTableMeta } from "../entity-table-meta";
5
5
  import { coerceRow, extractTableInfo } from "../query";
6
6
  import { renderTableDdl } from "../render-ddl";
7
7
  import { buildEntityTable } from "../table-builder";
@@ -25,7 +25,7 @@ const entity = createEntity({
25
25
  describe("decimal field — column + DDL", () => {
26
26
  test("maps to numeric(precision,scale) with required→NOT NULL", () => {
27
27
  const cols = new Map(
28
- buildEntityTableMeta("decimalProbe", entity).columns.map((c) => [c.name, c]),
28
+ deriveEntityTableMeta("decimalProbe", entity).columns.map((c) => [c.name, c]),
29
29
  );
30
30
  expect(cols.get("sum")?.pgType).toBe("numeric(14,2)");
31
31
  expect(cols.get("interest")?.pgType).toBe("numeric(6,4)");
@@ -35,7 +35,7 @@ describe("decimal field — column + DDL", () => {
35
35
  });
36
36
 
37
37
  test("renders real numeric(p,s) DDL", () => {
38
- const ddl = renderTableDdl(buildEntityTableMeta("decimalProbe", entity)).join("\n");
38
+ const ddl = renderTableDdl(deriveEntityTableMeta("decimalProbe", entity)).join("\n");
39
39
  expect(ddl).toContain('"interest" numeric(6,4) NOT NULL');
40
40
  expect(ddl).toContain('"rate" numeric(12,2)');
41
41
  });
@@ -1,4 +1,4 @@
1
- // #1210: buildEntityTableMeta() hardcoded source: "managed", so unmanaged
1
+ // #1210: deriveEntityTableMeta() hardcoded source: "managed", so unmanaged
2
2
  // direct-write stores (store_user_sessions, store_api_tokens, mail sync/seen
3
3
  // cursors) were misclassified as rebuildable event-sourced projections — a
4
4
  // destructive column change would DROP+rebuild-from-events tables that have
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
10
10
  import { createEntity, createTextField } from "../../engine";
11
- import { buildEntityTableMeta } from "../entity-table-meta";
11
+ import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
12
12
  import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
13
13
 
14
14
  const entity = createEntity({
@@ -19,14 +19,14 @@ const entity = createEntity({
19
19
  },
20
20
  });
21
21
 
22
- describe("buildEntityTableMeta — options.source (#1210)", () => {
22
+ describe("deriveEntityTableMeta — options.source (#1210)", () => {
23
23
  test("defaults to managed when omitted", () => {
24
- expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
24
+ expect(deriveEntityTableMeta("source-probe", entity).source).toBe("managed");
25
25
  });
26
26
 
27
27
  test("options.source: 'unmanaged' changes only source, columns + piiSubjectFields stay identical", () => {
28
- const managed = buildEntityTableMeta("source-probe", entity);
29
- const unmanaged = buildEntityTableMeta("source-probe", entity, { source: "unmanaged" });
28
+ const managed = deriveEntityTableMeta("source-probe", entity);
29
+ const unmanaged = deriveEntityTableMeta("source-probe", entity, { source: "unmanaged" });
30
30
 
31
31
  expect(managed.source).toBe("managed");
32
32
  expect(unmanaged.source).toBe("unmanaged");
@@ -37,9 +37,9 @@ describe("buildEntityTableMeta — options.source (#1210)", () => {
37
37
  });
38
38
 
39
39
  test("flipping an existing table's meta to unmanaged produces an empty migration diff", () => {
40
- const prevSnapshot = snapshotFromMetas([buildEntityTableMeta("source-probe", entity)]);
40
+ const prevSnapshot = snapshotFromMetas([deriveEntityTableMeta("source-probe", entity)]);
41
41
  const nextSnapshot = snapshotFromMetas([
42
- buildEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
42
+ deriveEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
43
43
  ]);
44
44
 
45
45
  const diff = diffSnapshots(prevSnapshot, nextSnapshot);
@@ -47,4 +47,39 @@ describe("buildEntityTableMeta — options.source (#1210)", () => {
47
47
  expect(diff.droppedTables).toEqual([]);
48
48
  expect(diff.changedTables).toEqual([]);
49
49
  });
50
+
51
+ test("deprecated buildEntityTableMeta alias still works", async () => {
52
+ const { buildEntityTableMeta } = await import("../entity-table-meta");
53
+ expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
54
+ });
55
+ });
56
+
57
+ describe("unmanaged builders reject read_ prefix (#1208)", () => {
58
+ test("deriveEntityTableMeta(..., { source: unmanaged }) with read_ table throws", () => {
59
+ const readEntity = createEntity({
60
+ table: "read_source_probe",
61
+ fields: { userId: createTextField({ required: true }) },
62
+ });
63
+ expect(() =>
64
+ deriveEntityTableMeta("source-probe", readEntity, { source: "unmanaged" }),
65
+ ).toThrow(/the "read_" prefix is reserved/);
66
+ });
67
+
68
+ test("default toTableName (read_*) + unmanaged throws", () => {
69
+ const noTable = createEntity({
70
+ fields: { userId: createTextField({ required: true }) },
71
+ });
72
+ expect(() => deriveEntityTableMeta("source-probe", noTable, { source: "unmanaged" })).toThrow(
73
+ /the "read_" prefix is reserved/,
74
+ );
75
+ });
76
+
77
+ test("defineUnmanagedTable with read_ tableName throws", () => {
78
+ expect(() =>
79
+ defineUnmanagedTable({
80
+ tableName: "read_oops",
81
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
82
+ }),
83
+ ).toThrow(/the "read_" prefix is reserved/);
84
+ });
50
85
  });
@@ -34,6 +34,14 @@ describe("splitSqlStatements", () => {
34
34
  expect(splitSqlStatements(sql)).toEqual(['CREATE TABLE "a" ("id" uuid);']);
35
35
  });
36
36
 
37
+ test("block comment leaves a space so adjacent tokens do not fuse", () => {
38
+ expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
39
+ });
40
+
41
+ test("nested block comments close only at matching depth (Postgres)", () => {
42
+ expect(splitSqlStatements("/* a /* b */ c */ SELECT 1;")).toEqual(["SELECT 1;"]);
43
+ });
44
+
37
45
  test("a block-comment opener inside a line comment does not swallow the next statement", () => {
38
46
  const sql = `
39
47
  -- note: see /* details below
@@ -69,7 +77,17 @@ describe("splitSqlStatements", () => {
69
77
 
70
78
  test("throws fail-loud on an unterminated block comment instead of silently dropping statements", () => {
71
79
  const sql = `/* oops\nCREATE TABLE "a" ("id" uuid);`;
72
- expect(() => splitSqlStatements(sql)).toThrow();
80
+ expect(() => splitSqlStatements(sql)).toThrow(/unterminated blockComment/);
81
+ });
82
+
83
+ test("throws fail-loud on an unterminated single-quoted string", () => {
84
+ const sql = `INSERT INTO "a" ("v") VALUES ('oops;`;
85
+ expect(() => splitSqlStatements(sql)).toThrow(/unterminated singleQuote/);
86
+ });
87
+
88
+ test("throws fail-loud on an unterminated double-quoted identifier", () => {
89
+ const sql = `CREATE TABLE "weird;`;
90
+ expect(() => splitSqlStatements(sql)).toThrow(/unterminated doubleQuote/);
73
91
  });
74
92
 
75
93
  test("a trailing line comment without a newline terminates cleanly", () => {
@@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createEntity } from "../../engine/factories";
3
3
  import { sql } from "../dialect";
4
4
  import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
5
- import { buildEntityTableMeta } from "../entity-table-meta";
5
+ import { deriveEntityTableMeta } from "../entity-table-meta";
6
6
  import { asEntityTableMeta } from "../query";
7
7
  import { buildEntityTable } from "../table-builder";
8
8
 
9
9
  // Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
10
- // KUMIKO_META_SYMBOL) und buildEntityTableMeta (Migrations-Pfad) müssen
10
+ // KUMIKO_META_SYMBOL) und deriveEntityTableMeta (Migrations-Pfad) müssen
11
11
  // für dieselbe EntityDefinition identische Spalten + Indexes produzieren.
12
12
  // Drift hier = Migration und Prod-Tabelle (bzw. collectTableMetas-Output)
13
13
  // gehen auseinander — gefunden als #255-Follow-up: select/number/bigInt
@@ -33,9 +33,9 @@ function byName<T extends { name: string }>(items: readonly T[]): readonly T[] {
33
33
  return [...items].sort((a, b) => a.name.localeCompare(b.name));
34
34
  }
35
35
 
36
- describe("buildEntityTable ↔ buildEntityTableMeta lock-step", () => {
36
+ describe("buildEntityTable ↔ deriveEntityTableMeta lock-step", () => {
37
37
  const fromBuilder = asEntityTableMeta(buildEntityTable("lockstepProbe", entityWithDefaults));
38
- const fromMeta = buildEntityTableMeta("lockstepProbe", entityWithDefaults);
38
+ const fromMeta = deriveEntityTableMeta("lockstepProbe", entityWithDefaults);
39
39
 
40
40
  test("builder table carries an EntityTableMeta", () => {
41
41
  expect(fromBuilder).toBeDefined();
@@ -92,7 +92,7 @@ describe("lock-step — softDelete + explizite Indexes", () => {
92
92
  const fromBuilder = asEntityTableMeta(
93
93
  buildEntityTable("lockstepProbeSd", entityWithSoftDeleteAndIndexes),
94
94
  );
95
- const fromMeta = buildEntityTableMeta("lockstepProbeSd", entityWithSoftDeleteAndIndexes);
95
+ const fromMeta = deriveEntityTableMeta("lockstepProbeSd", entityWithSoftDeleteAndIndexes);
96
96
 
97
97
  test("identical columns inkl. softDelete-Spalten", () => {
98
98
  expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
@@ -125,7 +125,7 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
125
125
  const fromBuilder = asEntityTableMeta(
126
126
  buildEntityTable("lockstepProbeBidx", entityWithLookupable),
127
127
  );
128
- const fromMeta = buildEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
128
+ const fromMeta = deriveEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
129
129
 
130
130
  test("identical columns inkl. nullable bidx-Spalte", () => {
131
131
  expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
@@ -3,7 +3,7 @@ import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
4
  import type { DbRunner } from "../connection";
5
5
  import type { TableColumns } from "../dialect";
6
- import { buildEntityTableMeta } from "../entity-table-meta";
6
+ import { deriveEntityTableMeta } from "../entity-table-meta";
7
7
  import { buildEntityTable } from "../table-builder";
8
8
  import { createTenantDb } from "../tenant-db";
9
9
 
@@ -123,16 +123,16 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
123
123
  });
124
124
 
125
125
  // Root-cause regression for the cross-tenant leak fixed in hasTenantColumn:
126
- // unmanaged direct-write stores (buildEntityTableMeta, e.g. userSessionTable,
126
+ // unmanaged direct-write stores (deriveEntityTableMeta, e.g. userSessionTable,
127
127
  // apiTokenTable) store tenantId as a snake_case column-meta entry, not a
128
128
  // direct `table.tenantId` property — a naive property check silently treated
129
129
  // them as tenant-less and skipped the WHERE-scope entirely.
130
- describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (buildEntityTableMeta)", () => {
130
+ describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (deriveEntityTableMeta)", () => {
131
131
  const unmanagedEntity = createEntity({
132
132
  table: "merge_meta_items",
133
133
  fields: { tenantId: createTextField({ required: true }), name: createTextField() },
134
134
  });
135
- const unmanagedTable = buildEntityTableMeta("merge-meta-item", unmanagedEntity);
135
+ const unmanagedTable = deriveEntityTableMeta("merge-meta-item", unmanagedEntity);
136
136
 
137
137
  test("selectMany still applies the tenant scope (pre-fix: no WHERE at all)", async () => {
138
138
  const captured: Captured[] = [];
@@ -9,7 +9,7 @@ import type { FeatureDefinition } from "../engine/types";
9
9
  import { compareByCodepoint } from "../utils";
10
10
  import {
11
11
  assertBackingTableSuperset,
12
- buildEntityTableMeta,
12
+ deriveEntityTableMeta,
13
13
  type EntityTableMeta,
14
14
  } from "./entity-table-meta";
15
15
  import { enumerateFeatureTableSources } from "./feature-table-sources";
@@ -36,10 +36,10 @@ export function collectTableMetas(
36
36
  const byName = new Map<string, { meta: EntityTableMeta; origin: string }>();
37
37
 
38
38
  // Pass 1: kanonische Schema-Quellen, identisch zum bisherigen Template-
39
- // Verhalten (gleiche Reihenfolge, gleiche buildEntityTableMeta-Optionen).
39
+ // Verhalten (gleiche Reihenfolge, gleiche deriveEntityTableMeta-Optionen).
40
40
  for (const feature of features) {
41
41
  for (const [name, ent] of Object.entries(feature.entities ?? {})) {
42
- const fieldMeta = buildEntityTableMeta(name, ent, { relations: feature.relations[name] });
42
+ const fieldMeta = deriveEntityTableMeta(name, ent, { relations: feature.relations[name] });
43
43
  // Backing table wins: it's the physical DDL truth for ride-along columns/
44
44
  // indexes the field-DSL can't express (secrets' envelope). Validated as a
45
45
  // superset of the field-derived meta so a field/table disagreement throws.
@@ -1,22 +1,20 @@
1
- // EntityTableMeta — plain-data Schema-Meta für eine Read-Model-Tabelle.
2
- // Single source of truth statt verheirateter drizzle-pgTable-Builder.
1
+ // EntityTableMeta — plain-data schema meta for a read-model table.
2
+ // Single source of truth instead of a married drizzle pgTable builder.
3
3
  //
4
- // Phase 3a (Drizzle-Replacement Plan): Type + Generator existieren parallel
5
- // zu buildEntityTable. Konsumenten bleiben auf EntityTable (via Adapter
6
- // `entityTableMetaToEntityTable`), bis Phase 4 die Query-API auf Bun.sql
7
- // umstellt.
4
+ // Phase 3a (Drizzle-Replacement Plan): type + generator exist in parallel
5
+ // with buildEntityTable. Consumers stay on EntityTable (via adapter
6
+ // `entityTableMetaToEntityTable`) until Phase 4 moves the query API to Bun.sql.
8
7
  //
9
- // Designed für zwei Quellen:
10
- // 1. **Managed** — EntityDefinition via buildEntityTableMeta(name, entity).
11
- // Standard-Pfad mit base-columns (id, tenant_id, version, inserted_at,
12
- // modified_at, inserted_by_id, modified_by_id, ggf. softDelete-Cols),
13
- // automatischer tenant_id-Index, audit-fähig.
14
- // 2. **Unmanaged** — defineUnmanagedTable(input). Escape-Hatch für Tabellen
15
- // die NICHT durch das Entity-System gemanagt werden keine erzwungenen
16
- // base-columns, kein Standard-Audit-Trail. App-Author trägt Verantwortung
17
- // für Tenant-Scoping, Version-Tracking, audit-by-Spalten. Verwendung
18
- // auf Sondercases beschränken (child-projection-tables ohne tenant,
19
- // append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
8
+ // Two sources:
9
+ // 1. **Managed** — EntityDefinition via deriveEntityTableMeta(name, entity).
10
+ // Standard path with base columns (id, tenant_id, version, inserted_at,
11
+ // modified_at, inserted_by_id, modified_by_id, optional softDelete cols),
12
+ // automatic tenant_id index, audit-capable. Defaults to source: "managed".
13
+ // 2. **Unmanaged** — defineUnmanagedTable(input), or
14
+ // deriveEntityTableMeta(..., { source: "unmanaged" }) when you still want
15
+ // entity-shaped base columns. Escape hatch: no forced audit trail for the
16
+ // hand-built path; app author owns tenant scoping / versioning. Prefer a
17
+ // `store_` table name `read_` is reserved for managed projections (#1208/#1220).
20
18
 
21
19
  import { collectPiiSubjectFields } from "../crypto";
22
20
  import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
@@ -232,12 +230,25 @@ export function resolveTableName(
232
230
  return `${featureName}_${baseName}`;
233
231
  }
234
232
 
235
- export function buildEntityTableMeta(
233
+ /**
234
+ * Derive EntityTableMeta from an EntityDefinition (base columns + field DDL).
235
+ * Defaults to `source: "managed"` (rebuildable projection). For direct-write
236
+ * stores pass `{ source: "unmanaged" }` and use a non-`read_` table name
237
+ * (convention: `store_*`) — or build columns by hand with `defineUnmanagedTable`.
238
+ *
239
+ * Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
240
+ * escape hatch (#1208).
241
+ */
242
+ export function deriveEntityTableMeta(
236
243
  entityName: string,
237
244
  entity: EntityDefinition,
238
245
  options?: BuildEntityTableMetaOptions,
239
246
  ): EntityTableMeta {
240
247
  const tableName = resolveTableName(entityName, entity, options?.featureName);
248
+ const source = options?.source ?? "managed";
249
+ if (source === "unmanaged") {
250
+ assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
251
+ }
241
252
  const idType = entity.idType ?? "uuid";
242
253
 
243
254
  // Base-columns first, then user-fields. User-fields with the same
@@ -349,24 +360,14 @@ export function buildEntityTableMeta(
349
360
  tableName,
350
361
  columns,
351
362
  indexes,
352
- source: options?.source ?? "managed",
363
+ source,
353
364
  ...(piiSubjectFields.length > 0 && { piiSubjectFields }),
354
365
  };
355
366
  }
356
367
 
357
- // Escape-Hatch für Tabellen die NICHT durch das Entity-System gemanagt
358
- // werden. Kein Audit-Trail (keine version, inserted_at, modified_by etc.),
359
- // kein automatischer tenant_id-Index, kein softDelete-Support.
360
- //
361
- // **Vorsicht-vor-Use:** wenn du das hier benutzt, gibst du das Standard-
362
- // Audit-Pattern auf. Begründe im Code WARUM (child-projection ohne tenant-
363
- // scope, aggregate-id-PK ohne DEFAULT, append-only-log mit serial PK,
364
- // performance-critical hot-path ohne version-check, …). Reviewer sollten
365
- // jede neue defineUnmanagedTable-Stelle prüfen.
366
- //
367
- // Heutige use-cases im framework:
368
- // - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
369
- // - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
368
+ /** @deprecated Use {@link deriveEntityTableMeta} the old name read as an unmanaged escape hatch (#1208). */
369
+ export const buildEntityTableMeta = deriveEntityTableMeta;
370
+
370
371
  function sqlExpressionText(where: unknown): string | undefined {
371
372
  if (
372
373
  typeof where === "object" &&
@@ -420,7 +421,12 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
420
421
  return m;
421
422
  }
422
423
 
424
+ /**
425
+ * Hand-built EntityTableMeta for direct-write stores (no entity base columns).
426
+ * Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
427
+ */
423
428
  export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
429
+ assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
424
430
  return {
425
431
  tableName: input.tableName,
426
432
  columns: input.columns,
@@ -431,3 +437,14 @@ export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMet
431
437
  source: "unmanaged",
432
438
  };
433
439
  }
440
+
441
+ function assertUnmanagedTableName(tableName: string, via: string): void {
442
+ if (tableName.startsWith("read_")) {
443
+ throw new Error(
444
+ `${via}("${tableName}"): the "read_" prefix is reserved for managed ` +
445
+ `r.entity()/r.projection() tables. Unmanaged direct-write stores need a ` +
446
+ `distinct name (convention: "store_${tableName.slice("read_".length)}"). ` +
447
+ `See #1208/#1220.`,
448
+ );
449
+ }
450
+ }