@cosmicdrift/kumiko-framework 0.24.0 → 0.24.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
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>",
@@ -65,7 +65,8 @@ describe("runSchemaCli status", () => {
65
65
  writeMigration("0001_init.sql", `SELECT 1;`);
66
66
  const { out, lines } = captureOut();
67
67
  const code = await runSchemaCli(["status"], appDir, out);
68
- expect(code).toBe(0);
68
+ // Exit 1 = pending-drift erkannt (1 Migration lokal, 0 applied); status gated CI non-zero.
69
+ expect(code).toBe(1);
69
70
  const joined = lines.join("\n");
70
71
  expect(joined).toContain("0 applied");
71
72
  expect(joined).toContain("1 pending");
@@ -0,0 +1,51 @@
1
+ // extractTableInfo discriminator-shadow regression.
2
+ //
3
+ // EntityTableMeta carries a `source: "managed" | "unmanaged"` discriminator.
4
+ // table() (dialect) spreads the column handles as enumerable props over the
5
+ // meta object, so an entity field literally named `source` overwrote the
6
+ // discriminator → extractTableInfo failed the meta check, fell into the (dead)
7
+ // drizzle branch, and typed timestamptz columns via getSQLType() as
8
+ // "timestamp with time zone". prepareValue only serializes Temporal.Instant for
9
+ // "timestamptz" → a raw Temporal reached postgres-js → "Cannot use valueOf" on
10
+ // every create of such an entity (e.g. pattern-storage's pattern-file).
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { buildEntityTable } from "../../db/table-builder";
14
+ import { extractTableInfo } from "../query";
15
+
16
+ describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", () => {
17
+ test("an entity field named `source` does not shadow the discriminator", () => {
18
+ const table = buildEntityTable("patternFile", {
19
+ fields: {
20
+ path: { type: "text", required: true },
21
+ source: { type: "text", required: true },
22
+ },
23
+ });
24
+ const info = extractTableInfo(table);
25
+ // The framework-canonical pgType the bun-db serializer matches on — NOT the
26
+ // drizzle getSQLType() spelling "timestamp with time zone".
27
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
28
+ // The user-defined `source` column is still present + correctly typed.
29
+ expect(info.pgTypeOf("source")).toBe("text");
30
+ });
31
+
32
+ test.each([
33
+ "columns",
34
+ "tableName",
35
+ "indexes",
36
+ ])("an entity field named `%s` (another meta key) also does not shadow it", (fieldName) => {
37
+ const table = buildEntityTable("thing", {
38
+ fields: { [fieldName]: { type: "text", required: true } },
39
+ });
40
+ const info = extractTableInfo(table);
41
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
42
+ });
43
+
44
+ test("control entity without a colliding field is unaffected", () => {
45
+ const table = buildEntityTable("note", {
46
+ fields: { title: { type: "text", required: true } },
47
+ });
48
+ const info = extractTableInfo(table);
49
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
50
+ });
51
+ });
@@ -37,32 +37,33 @@ function snakeToCamel(key: string): string {
37
37
  import type { BunDbRunner } from "./connection";
38
38
 
39
39
  // Drizzle-pgTable-Inspection via raw Symbol-access (kein drizzle-orm import).
40
- // drizzle stores the table name unter `Symbol.for("kumiko:schema:Name")` und die
41
- // column-map unter `Symbol.for("kumiko:schema:Columns")`.
42
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
43
- const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
44
-
45
- function getTableName(table: unknown): string | null {
46
- if (typeof table !== "object" || table === null) return null;
47
- const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
48
- return typeof name === "string" ? name : null;
49
- }
50
-
51
- function extractDrizzleColumns(table: unknown): Map<string, { name: string; sqlType?: string }> {
52
- const out = new Map<string, { name: string; sqlType?: string }>();
53
- if (typeof table !== "object" || table === null) return out;
54
- const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
55
- if (typeof cols !== "object" || cols === null) return out;
56
- for (const [key, val] of Object.entries(cols as Record<string, unknown>)) {
57
- if (typeof val !== "object" || val === null) continue;
58
- const colObj = val as { name?: unknown; getSQLType?: () => string };
59
- const colName = colObj.name;
60
- if (typeof colName !== "string") continue;
61
- // Drizzle's getSQLType uses `this` call as method on colObj.
62
- const sqlType = typeof colObj.getSQLType === "function" ? colObj.getSQLType() : undefined;
63
- out.set(key, { name: colName, ...(sqlType !== undefined && { sqlType }) });
64
- }
65
- return out;
40
+ // table() (dialect) stores the canonical, shadow-proof EntityTableMeta under
41
+ // `Symbol.for("kumiko:schema:Meta")`. The column handles on the table object are
42
+ // enumerable props, so an entity field named `source`/`columns`/`tableName`/…
43
+ // would overwrite the matching meta key — reading the meta from the symbol is
44
+ // the only collision-safe path.
45
+ const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
46
+
47
+ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
48
+ return (
49
+ v !== null &&
50
+ typeof v === "object" &&
51
+ typeof (v as EntityTableMeta).tableName === "string" &&
52
+ Array.isArray((v as EntityTableMeta).columns) &&
53
+ ((v as EntityTableMeta).source === "managed" || (v as EntityTableMeta).source === "unmanaged")
54
+ );
55
+ }
56
+
57
+ // Resolve any framework table input to its canonical EntityTableMeta:
58
+ // - table()/buildEntityTable outputs carry it under KUMIKO_META_SYMBOL, immune
59
+ // to a column-handle shadowing a meta key.
60
+ // - buildEntityTableMeta / defineUnmanagedTable return a plain meta with no
61
+ // handle-spread, so its structural shape is itself unshadowable.
62
+ function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
63
+ if (table === null || typeof table !== "object") return undefined;
64
+ const fromSymbol = (table as Record<symbol, unknown>)[KUMIKO_META_SYMBOL];
65
+ if (isEntityTableMeta(fromSymbol)) return fromSymbol;
66
+ return isEntityTableMeta(table) ? table : undefined;
66
67
  }
67
68
 
68
69
  // `db` Input akzeptiert drei Shapes:
@@ -218,69 +219,30 @@ export type TableInfo = {
218
219
  };
219
220
 
220
221
  export function extractTableInfo(table: TableLike): TableInfo {
221
- // EntityTableMeta discriminator: hat source-property "managed" | "unmanaged"
222
- if (
223
- table !== null &&
224
- typeof table === "object" &&
225
- "source" in table &&
226
- (table.source === "managed" || table.source === "unmanaged")
227
- ) {
228
- const meta = table as EntityTableMeta;
229
- const colByField = new Map<string, string>();
230
- const fieldByCol = new Map<string, string>();
231
- const typeByCol = new Map<string, string>();
232
- const bigintModeByCol = new Map<string, "number" | "bigint">();
233
- for (const c of meta.columns) {
234
- typeByCol.set(c.name, c.pgType);
235
- if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
236
- // EntityTableMeta column names are snake_case. Map snake → snake AND
237
- // derive a camelCase JS field-name so result rows can be renamed back
238
- // to the API shape (`aggregate_id` → `aggregateId`).
239
- colByField.set(c.name, c.name);
240
- const camel = snakeToCamel(c.name);
241
- if (camel !== c.name) colByField.set(camel, c.name);
242
- fieldByCol.set(c.name, camel === c.name ? c.name : camel);
243
- }
244
- return {
245
- name: meta.tableName,
246
- columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
247
- pgTypeOf: (col) => typeByCol.get(col),
248
- bigintJsModeOf: (col) => bigintModeByCol.get(col),
249
- fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
250
- hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
251
- };
252
- }
253
- // drizzle pgTable: tableName via Symbol.for("kumiko:schema:Name"), columns via
254
- // enumerable properties (jeder col-object hat .name + .getSQLType()).
255
- // Wir lesen Beide via raw Symbol/Property-access — kein drizzle-orm import.
256
- const name = getTableName(table);
257
- if (!name) {
222
+ const meta = asEntityTableMeta(table);
223
+ if (!meta) {
258
224
  throw new Error(
259
- "bun-db.extractTableInfo: table-Argument ist weder EntityTableMeta noch drizzle pgTable",
225
+ "bun-db.extractTableInfo: table is not a kumiko EntityTableMeta " +
226
+ "build it via buildEntityTable / buildEntityTableMeta / table().",
260
227
  );
261
228
  }
262
- const cols = extractDrizzleColumns(table);
263
229
  const colByField = new Map<string, string>();
264
230
  const fieldByCol = new Map<string, string>();
265
231
  const typeByCol = new Map<string, string>();
266
232
  const bigintModeByCol = new Map<string, "number" | "bigint">();
267
- if (
268
- table !== null &&
269
- typeof table === "object" &&
270
- "columns" in table &&
271
- Array.isArray((table as EntityTableMeta).columns)
272
- ) {
273
- for (const c of (table as EntityTableMeta).columns) {
274
- if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
275
- }
276
- }
277
- for (const [field, { name: colName, sqlType }] of cols) {
278
- colByField.set(field, colName);
279
- fieldByCol.set(colName, field);
280
- if (sqlType) typeByCol.set(colName, sqlType);
233
+ for (const c of meta.columns) {
234
+ typeByCol.set(c.name, c.pgType);
235
+ if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
236
+ // EntityTableMeta column names are snake_case. Map snake → snake AND
237
+ // derive a camelCase JS field-name so result rows can be renamed back
238
+ // to the API shape (`aggregate_id` → `aggregateId`).
239
+ colByField.set(c.name, c.name);
240
+ const camel = snakeToCamel(c.name);
241
+ if (camel !== c.name) colByField.set(camel, c.name);
242
+ fieldByCol.set(c.name, camel === c.name ? c.name : camel);
281
243
  }
282
244
  return {
283
- name,
245
+ name: meta.tableName,
284
246
  columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
285
247
  pgTypeOf: (col) => typeByCol.get(col),
286
248
  bigintJsModeOf: (col) => bigintModeByCol.get(col),
@@ -731,16 +693,6 @@ function insertEntries(info: TableInfo, values: Record<string, unknown>): Insert
731
693
  });
732
694
  }
733
695
 
734
- function isEntityTableMeta(table: unknown): table is EntityTableMeta {
735
- return (
736
- typeof table === "object" &&
737
- table !== null &&
738
- "source" in table &&
739
- (table.source === "managed" || table.source === "unmanaged") &&
740
- "columns" in table
741
- );
742
- }
743
-
744
696
  function resolveConflictColumns(
745
697
  table: TableLike,
746
698
  info: TableInfo,
@@ -749,8 +701,11 @@ function resolveConflictColumns(
749
701
  if (conflictKeys !== undefined && conflictKeys.length > 0) {
750
702
  return conflictKeys.map((field) => info.columnOf(field));
751
703
  }
752
- if (isEntityTableMeta(table)) {
753
- const pks = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
704
+ // Read the shadow-proof meta — `table.columns` directly would be the handle
705
+ // object when an entity has a field named `columns`.
706
+ const meta = asEntityTableMeta(table);
707
+ if (meta) {
708
+ const pks = meta.columns.filter((c) => c.primaryKey).map((c) => c.name);
754
709
  if (pks.length > 0) return pks;
755
710
  }
756
711
  if (info.hasColumn("id")) return [info.columnOf("id")];
package/src/db/dialect.ts CHANGED
@@ -40,6 +40,11 @@ export type ColumnHandle = {
40
40
 
41
41
  const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
42
42
  const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
43
+ // Shadow-proof handle on the EntityTableMeta. The column handles below are
44
+ // spread as enumerable props, so an entity field named `source`/`columns`/
45
+ // `tableName`/… would overwrite the matching meta key. extractTableInfo reads
46
+ // the canonical meta from this symbol instead of the (shadowable) props.
47
+ const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
43
48
 
44
49
  // SchemaTable — opaque shape with both EntityTableMeta + Symbol-based
45
50
  // introspection. Returned by `table(...)`.
@@ -469,6 +474,7 @@ export function table<TCols extends ColumnMap>(
469
474
  const out = Object.assign({}, base, handles, {
470
475
  [KUMIKO_NAME_SYMBOL]: tableName,
471
476
  [KUMIKO_COLUMNS_SYMBOL]: handles,
477
+ [KUMIKO_META_SYMBOL]: base,
472
478
  }) as SchemaTable;
473
479
  return out;
474
480
  }
@@ -81,6 +81,22 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
81
81
  return rows[0]?.v ?? 0;
82
82
  }
83
83
 
84
+ /** tenant_id the aggregate's events were written under — no membership/tenant
85
+ * filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
86
+ * its creating executor used, which need not be a tenant the subject holds a
87
+ * membership in. Returns null for unknown streams. */
88
+ export async function selectAggregateStreamTenant(
89
+ db: AnyDb,
90
+ aggregateId: string,
91
+ aggregateType: string,
92
+ ): Promise<string | null> {
93
+ const rows = (await asRawClient(db).unsafe(
94
+ `SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
95
+ [aggregateId, aggregateType],
96
+ )) as ReadonlyArray<{ t: string | null }>;
97
+ return rows[0]?.t ?? null;
98
+ }
99
+
84
100
  export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
85
101
  const rows = (await asRawClient(db).unsafe(
86
102
  `SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
@@ -3,15 +3,20 @@ import type { StoredEvent } from "../../event-store/event-store";
3
3
  import { setFields } from "../projection-helpers";
4
4
  import type { ProjectionTable } from "../types/projection";
5
5
 
6
- // Minimal fake table: only the `id` column is needed for setFields, plus
7
- // the kumiko:schema:Name + kumiko:schema:Columns symbols that bun-db introspects for
8
- // table-name + column-mapping. We don't run real SQL — unsafe() is mocked.
9
- const fakeIdCol = { name: "id" };
6
+ // Minimal fake table: an EntityTableMeta (what bun-db introspects for
7
+ // table-name + column-mapping) plus a top-level `id` handle, which setFields
8
+ // existence-checks before building its apply fn. We don't run real SQL —
9
+ // unsafe() is mocked.
10
10
  const fakeTable = Object.assign(
11
- { id: fakeIdCol },
11
+ { id: { name: "id" } },
12
12
  {
13
- [Symbol.for("kumiko:schema:Name")]: "fake_table",
14
- [Symbol.for("kumiko:schema:Columns")]: { id: fakeIdCol },
13
+ tableName: "fake_table",
14
+ source: "unmanaged",
15
+ indexes: [],
16
+ columns: [
17
+ { name: "id", pgType: "uuid", notNull: true },
18
+ { name: "status", pgType: "text", notNull: false },
19
+ ],
15
20
  },
16
21
  ) as unknown as ProjectionTable;
17
22
 
@@ -422,18 +422,18 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
422
422
  // Lifecycle hooks: keyed by handler QN. featureName rides along on each
423
423
  // hook entry — defineFeature sets it, the registry just appends.
424
424
  // Save/delete hooks target write handlers, query hooks target query handlers.
425
- mergeHookListQualified(preSaveHooks, feature.hooks.preSave, feature.name, "write");
426
- mergeHookListQualified(postSaveHooks, feature.hooks.postSave, feature.name, "write");
427
- mergeHookListQualified(preDeleteHooks, feature.hooks.preDelete, feature.name, "write");
428
- mergeHookListQualified(postDeleteHooks, feature.hooks.postDelete, feature.name, "write");
429
- mergeHookListQualified(preQueryHooks, feature.hooks.preQuery, feature.name, "query");
430
- mergeHookListQualified(postQueryHooks, feature.hooks.postQuery, feature.name, "query");
425
+ mergeHookListQualified(preSaveHooks, feature.hooks?.preSave, feature.name, "write");
426
+ mergeHookListQualified(postSaveHooks, feature.hooks?.postSave, feature.name, "write");
427
+ mergeHookListQualified(preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
428
+ mergeHookListQualified(postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
429
+ mergeHookListQualified(preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
430
+ mergeHookListQualified(postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
431
431
 
432
432
  // Entity hooks: NOT prefixed, keyed by entity name
433
- mergeHookList(entityPostSaveHooks, feature.entityHooks.postSave);
434
- mergeHookList(entityPreDeleteHooks, feature.entityHooks.preDelete);
435
- mergeHookList(entityPostDeleteHooks, feature.entityHooks.postDelete);
436
- mergeHookList(entityPostQueryHooks, feature.entityHooks.postQuery);
433
+ mergeHookList(entityPostSaveHooks, feature.entityHooks?.postSave);
434
+ mergeHookList(entityPreDeleteHooks, feature.entityHooks?.preDelete);
435
+ mergeHookList(entityPostDeleteHooks, feature.entityHooks?.postDelete);
436
+ mergeHookList(entityPostQueryHooks, feature.entityHooks?.postQuery);
437
437
 
438
438
  // F3 search-payload-extensions: per-entity contributors merged additively
439
439
  for (const [entityName, contributors] of Object.entries(
@@ -453,9 +453,9 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
453
453
  }
454
454
  extensionMap.set(extName, extDef);
455
455
  }
456
- extensionUsages.push(...feature.extensionUsages);
457
- allReferenceData.push(...feature.referenceData);
458
- allConfigSeeds.push(...feature.configSeeds);
456
+ extensionUsages.push(...(feature.extensionUsages ?? []));
457
+ allReferenceData.push(...(feature.referenceData ?? []));
458
+ allConfigSeeds.push(...(feature.configSeeds ?? []));
459
459
 
460
460
  // Metrics: validate + qualify per feature. Collisions across features are
461
461
  // rejected here — two features can't both register "created_total" under
@@ -477,7 +477,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
477
477
  // Secret keys: already qualified during defineFeature (same "<feature>:<short>"
478
478
  // convention used elsewhere). Reject cross-feature duplicates — extensions
479
479
  // could theoretically register on another feature's namespace.
480
- for (const def of Object.values(feature.secretKeys)) {
480
+ for (const def of Object.values(feature.secretKeys ?? {})) {
481
481
  if (secretKeyMap.has(def.qualifiedName)) {
482
482
  throw new Error(
483
483
  `[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
@@ -567,7 +567,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
567
567
  // correctness — the only way to hit this is a hand-built FeatureDefinition
568
568
  // bypassing defineFeature's per-feature duplicate check.
569
569
  const declaredShortNames = new Set<string>();
570
- for (const def of Object.values(feature.claimKeys)) {
570
+ for (const def of Object.values(feature.claimKeys ?? {})) {
571
571
  if (claimKeyMap.has(def.qualifiedName)) {
572
572
  throw new Error(
573
573
  `[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
@@ -670,7 +670,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
670
670
  // on undeclared inner-keys (typo / rename drift). Features that don't
671
671
  // declare claimKeys skip the check entirely — it's opt-in.
672
672
  const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
673
- for (const fn of feature.authClaimsHooks) {
673
+ for (const fn of feature.authClaimsHooks ?? []) {
674
674
  authClaimsHooks.push({
675
675
  featureName: feature.name,
676
676
  fn,
@@ -1035,7 +1035,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1035
1035
 
1036
1036
  // Validate: all required features must be registered
1037
1037
  for (const feature of features) {
1038
- for (const required of feature.requires) {
1038
+ for (const required of feature.requires ?? []) {
1039
1039
  if (!featureMap.has(required)) {
1040
1040
  throw new Error(
1041
1041
  `Feature "${feature.name}" requires feature "${required}" which is not registered`,
@@ -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,
@@ -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
+ });
@@ -803,7 +803,17 @@ export function createDispatcher(
803
803
  const out = await hook({ entityName, rows }, handlerContext);
804
804
  rows = [...out.rows];
805
805
  }
806
- result = rows[0] ?? result;
806
+ // A single-object result carries exactly one row through the hook
807
+ // pipeline. Returning 0 rows (effect lost) or ≥2 rows (extras
808
+ // dropped) cannot be represented in the single-object response —
809
+ // surface it instead of silently falling back / truncating.
810
+ const [only, ...extra] = rows;
811
+ if (only === undefined || extra.length > 0) {
812
+ throw new Error(
813
+ `postQuery hook on single-object result for "${type}" must return exactly one row, got ${rows.length}`,
814
+ );
815
+ }
816
+ result = only;
807
817
  }
808
818
  }
809
819
 
@@ -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
+ "indexes" in v &&
64
+ "source" in v
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
 
@@ -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 {