@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 +1 -1
- package/src/__tests__/schema-cli-status.integration.test.ts +2 -1
- package/src/bun-db/__tests__/extract-table-info.test.ts +51 -0
- package/src/bun-db/query.ts +47 -92
- package/src/db/dialect.ts +6 -0
- package/src/db/queries/event-store.ts +16 -0
- package/src/engine/__tests__/projection-helpers.test.ts +12 -7
- package/src/engine/registry.ts +17 -17
- package/src/event-store/event-store.ts +15 -0
- package/src/event-store/index.ts +1 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +50 -1
- package/src/pipeline/dispatcher.ts +11 -1
- package/src/stack/table-helpers.ts +19 -9
- package/src/testing/db-cleanup.ts +7 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.24.
|
|
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
|
-
|
|
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
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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:
|
|
11
|
+
{ id: { name: "id" } },
|
|
12
12
|
{
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
package/src/engine/registry.ts
CHANGED
|
@@ -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
|
|
426
|
-
mergeHookListQualified(postSaveHooks, feature.hooks
|
|
427
|
-
mergeHookListQualified(preDeleteHooks, feature.hooks
|
|
428
|
-
mergeHookListQualified(postDeleteHooks, feature.hooks
|
|
429
|
-
mergeHookListQualified(preQueryHooks, feature.hooks
|
|
430
|
-
mergeHookListQualified(postQueryHooks, feature.hooks
|
|
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
|
|
434
|
-
mergeHookList(entityPreDeleteHooks, feature.entityHooks
|
|
435
|
-
mergeHookList(entityPostDeleteHooks, feature.entityHooks
|
|
436
|
-
mergeHookList(entityPostQueryHooks, feature.entityHooks
|
|
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
|
package/src/event-store/index.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|