@cosmicdrift/kumiko-framework 0.108.0 → 0.110.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.108.0",
3
+ "version": "0.110.0",
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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.108.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.110.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
3
3
  import { z } from "zod";
4
+ import type { TableColumns } from "../../db/dialect";
4
5
  import { createEventStoreExecutor } from "../../db/event-store-executor";
5
6
  import { asRawClient, selectMany } from "../../db/query";
6
7
  import { buildEntityTable } from "../../db/table-builder";
@@ -36,7 +37,9 @@ const auditEntity = createEntity({
36
37
  itemId: createTextField({ required: true }),
37
38
  },
38
39
  });
39
- const auditTable = buildEntityTable("audit", auditEntity);
40
+ // Brand (#742) is compile-time-only; the postSave hook writes this sink via method-form,
41
+ // so hold it at the unbranded TableColumns view (identical runtime shape).
42
+ const auditTable: TableColumns = buildEntityTable("audit", auditEntity);
40
43
 
41
44
  // Hook invocation logs — reset per test. Captures which phase each hook saw.
42
45
  const inTxHookLog: Array<{ id: EntityId; name: string }> = [];
@@ -8,6 +8,7 @@
8
8
  import { expect, test } from "bun:test";
9
9
  import { defineUnmanagedTable } from "../../db/entity-table-meta";
10
10
  import { buildEntityTable } from "../../db/table-builder";
11
+ import type { TenantDb } from "../../db/tenant-db";
11
12
  import { createEntity, createTextField } from "../../engine";
12
13
  import { type AnyDb, deleteMany, insertOne, selectMany, updateMany } from "../query";
13
14
 
@@ -41,8 +42,28 @@ async function _readAllowsManagedEntity(db: AnyDb): Promise<void> {
41
42
  await selectMany(db, brandedEntity, { id: "1" });
42
43
  }
43
44
 
45
+ // Method-form (ctx.db.insertOne/updateMany/deleteMany) rejects the brand too — a
46
+ // projection written past its event stream is wiped on rebuild whether the write
47
+ // went through the free function or the TenantDb method.
48
+ async function _methodFormRejectsManagedEntity(db: TenantDb): Promise<void> {
49
+ // @ts-expect-error — managed EntityTable is executor-only; method-form insert is a compile error.
50
+ await db.insertOne(brandedEntity, { title: "x" });
51
+ // @ts-expect-error — method-form update on a managed EntityTable is rejected.
52
+ await db.updateMany(brandedEntity, { title: "y" }, { id: "1" });
53
+ // @ts-expect-error — method-form delete on a managed EntityTable is rejected.
54
+ await db.deleteMany(brandedEntity, { id: "1" });
55
+ }
56
+
57
+ async function _methodFormReadAllowsManagedEntity(db: TenantDb): Promise<void> {
58
+ // Method-form reads on a managed EntityTable stay fine (reads keep the plain param).
59
+ await db.selectMany(brandedEntity, { id: "1" });
60
+ await db.fetchOne(brandedEntity, { id: "1" });
61
+ }
62
+
44
63
  test("ES-write brand: compile-time contracts are wired", () => {
45
64
  expect(_writeRejectsManagedEntity).toBeDefined();
46
65
  expect(_writeAllowsUnmanagedTable).toBeDefined();
47
66
  expect(_readAllowsManagedEntity).toBeDefined();
67
+ expect(_methodFormRejectsManagedEntity).toBeDefined();
68
+ expect(_methodFormReadAllowsManagedEntity).toBeDefined();
48
69
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
+ import type { TableColumns } from "../dialect";
4
5
  import { buildEntityTable } from "../table-builder";
5
6
  import { createTenantDb } from "../tenant-db";
6
7
 
@@ -13,7 +14,9 @@ const entity = createEntity({
13
14
  table: "merge_items",
14
15
  fields: { name: createTextField({ required: true }) },
15
16
  });
16
- const table = buildEntityTable("mergeItem", entity);
17
+ // Brand (#742) is compile-time-only; hold the handle at the unbranded TableColumns
18
+ // view so the method-form scoping test still compiles (runtime shape is identical).
19
+ const table: TableColumns = buildEntityTable("mergeItem", entity);
17
20
 
18
21
  const own = testTenantId(1);
19
22
  const foreign = testTenantId(2);
@@ -10,7 +10,7 @@ import {
10
10
  unsafeCreateEntityTable,
11
11
  unsafePushTables,
12
12
  } from "../../stack";
13
- import { table as pgTable, serial, text, timestamp } from "../dialect";
13
+ import { table as pgTable, serial, type TableColumns, text, timestamp } from "../dialect";
14
14
  import { buildEntityTable } from "../table-builder";
15
15
  import { createTenantDb } from "../tenant-db";
16
16
 
@@ -26,7 +26,11 @@ const entity = createEntity({
26
26
  softDelete: true,
27
27
  });
28
28
 
29
- const table = buildEntityTable("tenantDbItem", entity);
29
+ // The ES-write brand (#742) is a compile-time phantom — no runtime property. This
30
+ // suite exercises TenantDb method-form scoping on a real entity table, so the handle
31
+ // is held at the unbranded TableColumns view: identical runtime shape, and both reads
32
+ // and method-form writes accept it (a branded EntityTable is now rejected).
33
+ const table: TableColumns = buildEntityTable("tenantDbItem", entity);
30
34
 
31
35
  // --- System table (no tenantId — like job_runs) ---
32
36
 
@@ -12,10 +12,21 @@ import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
12
12
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
13
13
  import type { DbRunner } from "./connection";
14
14
  import type { TableColumns } from "./dialect";
15
+ import type { EntityTableMeta } from "./entity-table-meta";
16
+ import type { NotExecutorOnly } from "./table-builder";
15
17
 
16
18
  // biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
17
19
  type Table = TableColumns<any>;
18
20
 
21
+ // Method-form writes reject the executor-only brand exactly like the free-function
22
+ // helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
23
+ // directly — free-function OR method-form — drifts the row past its event stream and
24
+ // a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
25
+ // metas are not projections → writable); `& NotExecutorOnly` strips only branded
26
+ // EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
27
+ // the plain `Table` param.
28
+ type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
29
+
19
30
  /**
20
31
  * TenantDb scope modes:
21
32
  *
@@ -45,21 +56,15 @@ export type TenantDb = {
45
56
  ): Promise<readonly T[]>;
46
57
  fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
47
58
  insertOne<T = Record<string, unknown>>(
48
- table: Table,
59
+ table: WritableTable,
49
60
  values: Record<string, unknown>,
50
61
  ): Promise<T | undefined>;
51
62
  updateMany<T = Record<string, unknown>>(
52
- table: Table,
63
+ table: WritableTable,
53
64
  set: Record<string, unknown>,
54
65
  where: WhereObject,
55
66
  ): Promise<readonly T[]>;
56
- // Method-form writes (ctx.db.insertOne/updateMany/deleteMany) keep the erased
57
- // Table param: the executor-only brand is enforced on the free-function
58
- // helpers (the reflexive path all production writes take), while method-form
59
- // on a branded table is covered by the guard-direct-entity-writes AST guard
60
- // (P5) — branding it here would force every push+write test into casts for
61
- // zero production benefit (no prod code writes a projection via method-form).
62
- deleteMany(table: Table, where: WhereObject): Promise<void>;
67
+ deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
63
68
  };
64
69
 
65
70
  // @cast-boundary tenant-db-row
@@ -1,5 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { z } from "zod";
3
+ import type { TableColumns } from "../../db/dialect";
3
4
  import { buildEntityTable } from "../../db/table-builder";
4
5
  import { createRegistry, defineFeature } from "../../engine";
5
6
  import type { AppContext, SaveContext } from "../../engine/types";
@@ -65,7 +66,9 @@ const todoEntity = {
65
66
 
66
67
  let postSaveInvocations = 0;
67
68
  const todoFeature = defineFeature("todo", (r) => {
68
- const todoTable = buildEntityTable("todo", todoEntity);
69
+ // Brand (#742) is compile-time-only; the unbranded TableColumns view keeps the
70
+ // method-form insert (and its observability span) working — runtime shape is identical.
71
+ const todoTable: TableColumns = buildEntityTable("todo", todoEntity);
69
72
  r.entity("todo", todoEntity);
70
73
 
71
74
  r.writeHandler(