@cosmicdrift/kumiko-framework 0.109.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 +2 -2
- package/src/api/__tests__/batch.integration.test.ts +4 -1
- package/src/bun-db/__tests__/write-brand.test.ts +21 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -1
- package/src/db/__tests__/tenant-db.integration.test.ts +6 -2
- package/src/db/tenant-db.ts +14 -9
- package/src/observability/__tests__/observability.integration.test.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -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:
|
|
59
|
+
table: WritableTable,
|
|
49
60
|
values: Record<string, unknown>,
|
|
50
61
|
): Promise<T | undefined>;
|
|
51
62
|
updateMany<T = Record<string, unknown>>(
|
|
52
|
-
table:
|
|
63
|
+
table: WritableTable,
|
|
53
64
|
set: Record<string, unknown>,
|
|
54
65
|
where: WhereObject,
|
|
55
66
|
): Promise<readonly T[]>;
|
|
56
|
-
|
|
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
|
-
|
|
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(
|