@cosmicdrift/kumiko-framework 0.24.0 → 0.25.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/schema-cli-status.integration.test.ts +2 -1
  3. package/src/bun-db/__tests__/extract-table-info.test.ts +72 -0
  4. package/src/bun-db/query.ts +52 -94
  5. package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
  6. package/src/db/__tests__/sql-inventory.test.ts +26 -1
  7. package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
  8. package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
  9. package/src/db/dialect.ts +6 -0
  10. package/src/db/entity-table-meta.ts +1 -1
  11. package/src/db/queries/event-store.ts +18 -7
  12. package/src/db/sql-inventory.ts +9 -0
  13. package/src/db/tenant-db.ts +5 -2
  14. package/src/engine/__tests__/boot-validator.test.ts +79 -0
  15. package/src/engine/__tests__/post-query-hook.test.ts +6 -6
  16. package/src/engine/__tests__/projection-helpers.test.ts +12 -7
  17. package/src/engine/__tests__/registry.test.ts +58 -0
  18. package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
  19. package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
  20. package/src/engine/boot-validator/api-ext.ts +1 -5
  21. package/src/engine/boot-validator/screens-nav.ts +18 -2
  22. package/src/engine/registry.ts +60 -29
  23. package/src/engine/types/fields.ts +2 -1
  24. package/src/engine/types/handlers.ts +1 -1
  25. package/src/engine/types/hooks.ts +4 -1
  26. package/src/engine/validate-projection-allowlist.ts +13 -3
  27. package/src/errors/__tests__/classes.test.ts +5 -0
  28. package/src/errors/classes.ts +4 -2
  29. package/src/event-store/event-store.ts +15 -0
  30. package/src/event-store/index.ts +1 -0
  31. package/src/files/__tests__/file-ref-entity.test.ts +34 -0
  32. package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
  33. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +50 -1
  34. package/src/pipeline/dispatcher.ts +57 -47
  35. package/src/pipeline/system-hooks.ts +17 -5
  36. package/src/stack/table-helpers.ts +19 -9
  37. package/src/stack/test-stack.ts +3 -4
  38. package/src/testing/db-cleanup.ts +7 -9
  39. package/src/errors/__tests__/field-issue-compat.test.ts +0 -16
@@ -83,8 +83,10 @@ export class NotFoundError extends KumikoError {
83
83
  const idStr = id !== undefined ? String(id) : undefined;
84
84
  // The reason string follows `<snake_entity>_not_found` — keeps a stable,
85
85
  // client-friendly tag that survives wire serialization even if the entity
86
- // name is later renamed for display purposes.
87
- const reason = `${toSnakeCase(entity)}_not_found`;
86
+ // name is later renamed for display purposes. Strip the leading underscore
87
+ // toSnakeCase emits for a PascalCase name ("Invoice" → "_invoice") so the
88
+ // wire tag stays "invoice_not_found", not "_invoice_not_found".
89
+ const reason = `${toSnakeCase(entity).replace(/^_/, "")}_not_found`;
88
90
  const details: NotFoundDetails & { reason: string } = { reason, entity, id: idStr };
89
91
  super({
90
92
  message: idStr !== undefined ? `${entity} ${idStr} not found` : `${entity} not found`,
@@ -4,6 +4,7 @@ import {
4
4
  insertSubsequentEventRow,
5
5
  notifyPgChannel,
6
6
  selectAggregateMaxVersion,
7
+ selectAggregateStreamTenant,
7
8
  selectEventsHighWaterMark,
8
9
  selectStreamMaxVersion,
9
10
  } from "../db/queries/event-store";
@@ -282,6 +283,20 @@ export async function getAggregateStreamMaxVersion(
282
283
  return selectAggregateMaxVersion(db, aggregateId);
283
284
  }
284
285
 
286
+ /** Stream tenant of an aggregate (the tenant_id its events live under), with no
287
+ * membership/tenant filter. Recovers the write target for a systemScope
288
+ * aggregate whose stream tenant isn't one of the subject's memberships.
289
+ * Returns null for unknown streams. */
290
+ export async function getAggregateStreamTenant(
291
+ db: DbRunner,
292
+ aggregateId: string,
293
+ aggregateType: string,
294
+ ): Promise<TenantId | null> {
295
+ const tenantId = await selectAggregateStreamTenant(db, aggregateId, aggregateType);
296
+ // DB-boundary: kumiko_events.tenant_id is a TenantId-shaped uuid column.
297
+ return tenantId as TenantId | null;
298
+ }
299
+
285
300
  // Global high-water-mark = MAX(events.id). Marten/Wolverine standard for
286
301
  // projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
287
302
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
@@ -13,6 +13,7 @@ export {
13
13
  type EventMetadata,
14
14
  type EventToAppend,
15
15
  getAggregateStreamMaxVersion,
16
+ getAggregateStreamTenant,
16
17
  getEventsHighWaterMark,
17
18
  getStreamVersion,
18
19
  loadAggregate,
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ColumnMeta } from "../../db/entity-table-meta";
3
+ import { fileRefsTable } from "../file-ref-table";
4
+
5
+ // `fileRefsTable` is the live buildEntityTable() output the app boots with.
6
+ // Its runtime shape is a SchemaTable (EntityTableMeta & drizzle table), so the
7
+ // EntityTableMeta `columns` carry the resolved NOT NULL / DEFAULT per column —
8
+ // the drizzle-facing static type (EntityTable<E>) hides them, hence the cast.
9
+ // Importing fileRefEntity directly here would hit the engine↔file-ref-table
10
+ // init cycle (biome sorts it before file-ref-table); going through the built
11
+ // table sidesteps it and tests the exact object production uses.
12
+ const columns = (fileRefsTable as unknown as { readonly columns: readonly ColumnMeta[] }).columns;
13
+ const col = (name: string): readonly ColumnMeta[] => columns.filter((c) => c.name === name);
14
+
15
+ describe("fileRefEntity base-column drift", () => {
16
+ // Regression: an earlier revision declared `insertedAt`/`insertedById` as
17
+ // entity fields. The field-column then OVERRODE the framework base column in
18
+ // the {...base, ...field} last-wins merge (entity-table-meta.ts), dropping
19
+ // its NOT NULL DEFAULT now() and making inserted_at silently nullable — a
20
+ // production INSERT could then leave it null. Re-adding either field shadows
21
+ // the base column again and turns these red.
22
+ test("inserted_at stays NOT NULL DEFAULT now() (base column, not field-shadowed)", () => {
23
+ const insertedAt = col("inserted_at");
24
+ expect(insertedAt).toHaveLength(1); // exactly one — not duplicated by a redeclared field
25
+ expect(insertedAt[0]?.notNull).toBe(true);
26
+ expect(insertedAt[0]?.defaultSql).toBe("now()");
27
+ });
28
+
29
+ test("inserted_by_id stays framework-managed nullable (not redeclared as a required field)", () => {
30
+ const insertedById = col("inserted_by_id");
31
+ expect(insertedById).toHaveLength(1);
32
+ expect(insertedById[0]?.notNull).toBe(false);
33
+ });
34
+ });
@@ -159,6 +159,59 @@ describe("dispatcher.query", () => {
159
159
  });
160
160
  });
161
161
 
162
+ // --- postQuery hooks on standalone (entity-less) queries ---
163
+
164
+ describe("dispatcher.query postQuery hooks", () => {
165
+ test("handler-keyed postQuery hook fires on a standalone (no-colon) query", async () => {
166
+ // Standalone queries (name without colon, e.g. "dashboard") map to no
167
+ // entity. Handler-keyed postQuery hooks must still fire — gating the hook
168
+ // pass on entity-existence makes such a hook register silently + never run.
169
+ const feature = defineFeature("dash", (r) => {
170
+ r.queryHandler("dashboard", z.object({}), async () => ({ count: 1 }), {
171
+ access: { openToAll: true },
172
+ });
173
+ r.hook("postQuery", "dashboard", async ({ entityName, rows }) => {
174
+ expect(entityName).toBeUndefined();
175
+ return { rows: rows.map((row) => ({ ...row, enriched: true })) };
176
+ });
177
+ });
178
+
179
+ const dispatcher = createDispatcher(createRegistry([feature]), {});
180
+ const result = await dispatcher.query("dash:query:dashboard", {}, createTestUser());
181
+ expect(result).toEqual({ count: 1, enriched: true });
182
+ });
183
+
184
+ test("postQuery hook does not run for a result with rows:null (not an array)", async () => {
185
+ // `{ rows: null }` is a legitimate 'nothing found' shape — it must take the
186
+ // single-object branch, not the rows-list branch (which would crash on
187
+ // [...null]). The hook here returns the row unchanged so the shape survives.
188
+ const feature = defineFeature("nullrows", (r) => {
189
+ r.queryHandler("dashboard", z.object({}), async () => ({ rows: null, nextCursor: null }), {
190
+ access: { openToAll: true },
191
+ });
192
+ r.hook("postQuery", "dashboard", async ({ rows }) => ({ rows }));
193
+ });
194
+
195
+ const dispatcher = createDispatcher(createRegistry([feature]), {});
196
+ const result = await dispatcher.query("nullrows:query:dashboard", {}, createTestUser());
197
+ expect(result).toEqual({ rows: null, nextCursor: null });
198
+ });
199
+
200
+ test("single-object postQuery hook returning ≠1 row throws", async () => {
201
+ const feature = defineFeature("multi", (r) => {
202
+ r.queryHandler("dashboard", z.object({}), async () => ({ count: 1 }), {
203
+ access: { openToAll: true },
204
+ });
205
+ r.hook("postQuery", "dashboard", async ({ rows }) => ({ rows: [...rows, ...rows] }));
206
+ });
207
+
208
+ const dispatcher = createDispatcher(createRegistry([feature]), {});
209
+ await expect(dispatcher.query("multi:query:dashboard", {}, createTestUser())).rejects.toThrow(
210
+ /must return exactly one row, got 2/,
211
+ );
212
+ });
213
+ });
214
+
162
215
  // --- Dispatch: Command (fire-and-forget) ---
163
216
 
164
217
  describe("dispatcher.command", () => {
@@ -68,13 +68,50 @@ const postQueryFeature = defineFeature("postquerytest", (r) => {
68
68
  r.entityHook("postQuery", widget, entityKeyedHook);
69
69
  });
70
70
 
71
+ // --- Single-object-result invariant fixtures ---
72
+ //
73
+ // A query handler that returns a plain object (not array, not {rows}) carries
74
+ // exactly one row through the hook pipeline. A hook that returns 0 or ≥2 rows
75
+ // for such a result used to be swallowed (`rows[0] ?? result`): 0 rows fell
76
+ // back to the unhooked original, ≥2 silently dropped the extras. Both now
77
+ // surface as a dispatcher error instead.
78
+
79
+ const gadgetEntity = createEntity({
80
+ table: "read_post_query_gadgets",
81
+ fields: { name: createTextField({ required: true }) },
82
+ });
83
+ const gizmoEntity = createEntity({
84
+ table: "read_post_query_gizmos",
85
+ fields: { name: createTextField({ required: true }) },
86
+ });
87
+
88
+ const dropRowHook: PostQueryHookFn = async () => ({ rows: [] });
89
+ const duplicateRowHook: PostQueryHookFn = async ({ rows }) => ({ rows: [...rows, ...rows] });
90
+
91
+ const singleObjectFeature = defineFeature("singleobjtest", (r) => {
92
+ const gadget = r.entity("gadget", gadgetEntity);
93
+ r.queryHandler("gadget:get", z.object({}), async () => ({ id: "g1", name: "Gadget" }), {
94
+ access: { openToAll: true },
95
+ });
96
+ r.entityHook("postQuery", gadget, dropRowHook);
97
+
98
+ const gizmo = r.entity("gizmo", gizmoEntity);
99
+ r.queryHandler("gizmo:get", z.object({}), async () => ({ id: "z1", name: "Gizmo" }), {
100
+ access: { openToAll: true },
101
+ });
102
+ r.entityHook("postQuery", gizmo, duplicateRowHook);
103
+ });
104
+
71
105
  // --- Test stack ---
72
106
 
73
107
  let stack: TestStack;
74
108
  const admin = TestUsers.admin;
75
109
 
76
110
  beforeAll(async () => {
77
- stack = await setupTestStack({ features: [postQueryFeature], systemHooks: [] });
111
+ stack = await setupTestStack({
112
+ features: [postQueryFeature, singleObjectFeature],
113
+ systemHooks: [],
114
+ });
78
115
  });
79
116
 
80
117
  afterAll(async () => {
@@ -114,3 +151,15 @@ describe("postQuery-Hook integration through dispatcher", () => {
114
151
  expect(result.nextCursor).toBeNull();
115
152
  });
116
153
  });
154
+
155
+ describe("single-object-result postQuery invariant: exactly one row", () => {
156
+ test("hook returning 0 rows surfaces as 500 (not a silent fallback to the unhooked result)", async () => {
157
+ const res = await stack.http.query("singleobjtest:query:gadget:get", {}, admin);
158
+ expect(res.status).toBe(500);
159
+ });
160
+
161
+ test("hook returning ≥2 rows surfaces as 500 (not a silent truncation to the first)", async () => {
162
+ const res = await stack.http.query("singleobjtest:query:gizmo:get", {}, admin);
163
+ expect(res.status).toBe(500);
164
+ });
165
+ });
@@ -686,7 +686,7 @@ export function createDispatcher(
686
686
  if (!rateLimit) return;
687
687
  if (!context.rateLimit) {
688
688
  throw new InternalError({
689
- message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rateLimiting feature or remove the option.`,
689
+ message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
690
690
  });
691
691
  }
692
692
  const reqCtx = requestContext.get();
@@ -776,56 +776,66 @@ export function createDispatcher(
776
776
  // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
777
777
  // — feuern für ALLE query-handlers des entity
778
778
  const entityName = registry.getHandlerEntity(type);
779
- if (entityName) {
780
- const handlerHooks = registry.getPostQueryHooks(type);
781
- const entityHooks = registry.getEntityPostQueryHooks(entityName);
782
- const postQueryHooks = [...handlerHooks, ...entityHooks];
783
- if (postQueryHooks.length > 0 && result && typeof result === "object") {
784
- if (Array.isArray(result)) {
785
- let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
786
- for (const hook of postQueryHooks) {
787
- const out = await hook({ entityName, rows }, handlerContext);
788
- rows = [...out.rows];
789
- }
790
- result = rows;
791
- } else if ("rows" in result) {
792
- // @cast-boundary engine-payload
793
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
794
- let rows = r.rows;
795
- for (const hook of postQueryHooks) {
796
- const out = await hook({ entityName, rows }, handlerContext);
797
- rows = [...out.rows];
798
- }
799
- result = { ...r, rows };
800
- } else {
801
- let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
802
- for (const hook of postQueryHooks) {
803
- const out = await hook({ entityName, rows }, handlerContext);
804
- rows = [...out.rows];
805
- }
806
- result = rows[0] ?? result;
779
+
780
+ // Handler-keyed postQuery hooks fire for any query (incl. entity-less
781
+ // standalone queries like "ns:dashboard"). Entity-keyed hooks only apply
782
+ // when the handler maps to an entity — so this block must NOT be gated on
783
+ // entityName, or hooks on standalone queries register silently and never fire.
784
+ const handlerHooks = registry.getPostQueryHooks(type);
785
+ const entityHooks = entityName ? registry.getEntityPostQueryHooks(entityName) : [];
786
+ const postQueryHooks = [...handlerHooks, ...entityHooks];
787
+ if (postQueryHooks.length > 0 && result && typeof result === "object") {
788
+ if (Array.isArray(result)) {
789
+ let rows = result as Record<string, unknown>[]; // @cast-boundary engine-payload
790
+ for (const hook of postQueryHooks) {
791
+ const out = await hook({ entityName, rows }, handlerContext);
792
+ rows = [...out.rows];
793
+ }
794
+ result = rows;
795
+ } else if (Array.isArray((result as { rows?: unknown }).rows)) {
796
+ // @cast-boundary engine-payload
797
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
798
+ let rows = r.rows;
799
+ for (const hook of postQueryHooks) {
800
+ const out = await hook({ entityName, rows }, handlerContext);
801
+ rows = [...out.rows];
802
+ }
803
+ result = { ...r, rows };
804
+ } else {
805
+ let rows: Record<string, unknown>[] = [result as Record<string, unknown>]; // @cast-boundary engine-payload
806
+ for (const hook of postQueryHooks) {
807
+ const out = await hook({ entityName, rows }, handlerContext);
808
+ rows = [...out.rows];
807
809
  }
810
+ // A single-object result carries exactly one row through the hook
811
+ // pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
812
+ // dropped) cannot be represented in the single-object response —
813
+ // surface it instead of silently falling back / truncating.
814
+ if (rows.length !== 1) {
815
+ throw new Error(
816
+ `postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
817
+ );
818
+ }
819
+ result = rows[0];
808
820
  }
821
+ }
809
822
 
810
- // Field-level read filter
811
- const entity = registry.getEntity(entityName);
812
- if (entity && result && typeof result === "object") {
813
- if (Array.isArray(result)) {
814
- result = result.map((row: Record<string, unknown>) =>
815
- filterReadFields(entity, row, user),
816
- );
823
+ // Field-level read filter — only applies to entity-bound results.
824
+ const entity = entityName ? registry.getEntity(entityName) : undefined;
825
+ if (entity && result && typeof result === "object") {
826
+ if (Array.isArray(result)) {
827
+ result = result.map((row: Record<string, unknown>) => filterReadFields(entity, row, user));
828
+ } else {
829
+ const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
830
+ if (Array.isArray((resultAsDbRow as { rows?: unknown }).rows)) {
831
+ // generic handler-result shape narrow
832
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
833
+ result = {
834
+ ...r,
835
+ rows: r.rows.map((row) => filterReadFields(entity, row, user)),
836
+ };
817
837
  } else {
818
- const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
819
- if ("rows" in resultAsDbRow) {
820
- // generic handler-result shape narrow
821
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
822
- result = {
823
- ...r,
824
- rows: r.rows.map((row) => filterReadFields(entity, row, user)),
825
- };
826
- } else {
827
- result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
828
- }
838
+ result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
829
839
  }
830
840
  }
831
841
  }
@@ -98,7 +98,7 @@ function reconstructStateForSearch(
98
98
  // Build a SearchDocument from raw field-state. Parallel to the old
99
99
  // buildSearchDocument that took a SaveContext — same selector logic, just
100
100
  // a different input shape.
101
- async function buildSearchDocument(
101
+ export async function buildSearchDocument(
102
102
  entityName: string,
103
103
  entityId: EntityId,
104
104
  state: Record<string, unknown>,
@@ -143,12 +143,24 @@ async function buildSearchDocument(
143
143
  // F3 — Search-Payload-Extensions: contributors merge flat fields into the
144
144
  // search-doc (customFields-bundle / tags / computed-counts / etc.).
145
145
  // Sequential await — extensions are expected sync or sub-millisecond async;
146
- // sequential keeps the path simple and deterministic. If parallel ever
147
- // matters, switch to Promise.all but bind contributor-output-precedence
148
- // (last-wins vs. merge-conflict) explicitly.
146
+ // sequential keeps the path simple and deterministic.
147
+ //
148
+ // Precedence is base-fields-win: a contributor key that collides with a
149
+ // searchable Stammfield is dropped (not silently merged over the real value)
150
+ // and warned. A jsonb custom-field that happens to share a Stammfield name
151
+ // must not shadow the indexed Stammfield.
149
152
  for (const contribute of extensions) {
150
153
  const contributed = await contribute({ entityName, entityId, state });
151
- Object.assign(fields, contributed);
154
+ for (const [key, value] of Object.entries(contributed)) {
155
+ if (Object.hasOwn(fields, key)) {
156
+ console.warn(
157
+ `[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
158
+ `Stammfield "${key}" — keeping the base field. Rename the contributor key.`,
159
+ );
160
+ continue;
161
+ }
162
+ fields[key] = value;
163
+ }
152
164
  }
153
165
 
154
166
  return {
@@ -13,6 +13,7 @@ import { buildEntityTable, toTableName } from "../db/table-builder";
13
13
  import type { EventDispatcher } from "../pipeline";
14
14
 
15
15
  const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
16
+ const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
16
17
  function tableNameOf(table: unknown): string {
17
18
  if (typeof table !== "object" || table === null) {
18
19
  throw new Error("table-helpers: table is not a SchemaTable object");
@@ -53,17 +54,26 @@ export async function unsafeEnsureEntityTable(
53
54
  // Tables produced by the native dialect already carry EntityTableMeta-shape
54
55
  // (source/columns/indexes). renderTableDdl converts that to CREATE TABLE +
55
56
  // CREATE INDEX statements executed via db/queries/test-stack.
57
+ function isMetaShape(v: unknown): v is EntityTableMeta {
58
+ return (
59
+ typeof v === "object" &&
60
+ v !== null &&
61
+ typeof (v as EntityTableMeta).tableName === "string" &&
62
+ Array.isArray((v as EntityTableMeta).columns) &&
63
+ Array.isArray((v as EntityTableMeta).indexes) &&
64
+ ((v as EntityTableMeta).source === "managed" || (v as EntityTableMeta).source === "unmanaged")
65
+ );
66
+ }
67
+
56
68
  function tableToMeta(table: unknown): EntityTableMeta {
57
- if (
58
- typeof table === "object" &&
59
- table !== null &&
60
- "tableName" in table &&
61
- "columns" in table &&
62
- "indexes" in table &&
63
- "source" in table
64
- ) {
65
- return table as EntityTableMeta;
69
+ // table() spreads column handles as enumerable props, so a field named
70
+ // `columns`/`tableName`/`source`/… shadows the matching meta key — read the
71
+ // canonical meta from the unshadowable symbol when present.
72
+ if (table !== null && typeof table === "object") {
73
+ const fromSymbol = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
74
+ if (isMetaShape(fromSymbol)) return fromSymbol;
66
75
  }
76
+ if (isMetaShape(table)) return table;
67
77
  throw new Error("unsafePushTables: argument is not a SchemaTable / EntityTableMeta");
68
78
  }
69
79
 
@@ -171,8 +171,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
171
171
  // everything registered via r.projection() — keeps tests from having to
172
172
  // know which projections a feature happens to declare. Two projections
173
173
  // backed by the same physical table (e.g. an alternative apply-shape for
174
- // the same read-model in a test feature) are deduped by Drizzle-table
175
- // reference so drizzle-kit doesn't emit duplicate CREATE TABLE statements.
174
+ // the same read-model in a test feature) are deduped by table reference so
175
+ // we emit only one CREATE TABLE per physical table.
176
176
  const projectionTables: Record<string, unknown> = {};
177
177
  const seenTables = new Set<unknown>();
178
178
  for (const feature of options.features) {
@@ -205,8 +205,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
205
205
  // unsafePushTables emits raw CREATE TABLE — fine for ephemeral test DBs but
206
206
  // collides on re-boot against a persistent DB whose projection tables
207
207
  // were created during a previous run. Filter out the ones that already
208
- // exist; drizzle-kit's diff machinery would otherwise emit CREATE for
209
- // them again.
208
+ // exist so the re-boot doesn't fail on duplicate CREATE TABLE.
210
209
  const { tableExists } = await import("../db/schema-inspection");
211
210
  const missing: Record<string, unknown> = {};
212
211
  for (const [key, tbl] of Object.entries(projectionTables)) {
@@ -3,19 +3,17 @@
3
3
  * beforeEach hooks. All table clears go through typed `deleteMany` (empty
4
4
  * where = full table wipe). Raw SQL stays out of test files.
5
5
  */
6
+ import type { EntityTableMeta } from "../db/entity-table-meta";
6
7
  import { type AnyDb, deleteMany } from "../db/query";
7
8
 
8
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
9
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
10
-
11
- /** EntityTableMeta, drizzle pgTable, or plain table name string. */
9
+ /** EntityTableMeta, a built table, or a plain table name string. */
12
10
  export type ClearableTable = string | { readonly tableName?: string } | unknown;
13
11
 
14
- function tableFromName(name: string): unknown {
15
- return {
16
- [KUMIKO_NAME_SYMBOL]: name,
17
- [KUMIKO_COLUMNS_SYMBOL]: {},
18
- };
12
+ // A full-table wipe (empty where) only needs the table name — give deleteMany
13
+ // a minimal-but-canonical EntityTableMeta so extractTableInfo accepts it
14
+ // without inferring columns.
15
+ function tableFromName(name: string): EntityTableMeta {
16
+ return { tableName: name, columns: [], indexes: [], source: "unmanaged" };
19
17
  }
20
18
 
21
19
  function resolveClearableTable(table: ClearableTable): unknown {
@@ -1,16 +0,0 @@
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
- });