@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.
- 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 +72 -0
- package/src/bun-db/query.ts +52 -94
- package/src/db/__tests__/source-shadow-create.integration.test.ts +54 -0
- package/src/db/__tests__/sql-inventory.test.ts +26 -1
- package/src/db/__tests__/table-builder-indexes.test.ts +15 -0
- package/src/db/__tests__/tenant-db-where-merge.test.ts +81 -0
- package/src/db/dialect.ts +6 -0
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/queries/event-store.ts +18 -7
- package/src/db/sql-inventory.ts +9 -0
- package/src/db/tenant-db.ts +5 -2
- package/src/engine/__tests__/boot-validator.test.ts +79 -0
- package/src/engine/__tests__/post-query-hook.test.ts +6 -6
- package/src/engine/__tests__/projection-helpers.test.ts +12 -7
- package/src/engine/__tests__/registry.test.ts +58 -0
- package/src/engine/__tests__/search-payload-extension.test.ts +49 -3
- package/src/engine/__tests__/unmanaged-table.test.ts +30 -1
- package/src/engine/boot-validator/api-ext.ts +1 -5
- package/src/engine/boot-validator/screens-nav.ts +18 -2
- package/src/engine/registry.ts +60 -29
- package/src/engine/types/fields.ts +2 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +4 -1
- package/src/engine/validate-projection-allowlist.ts +13 -3
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/classes.ts +4 -2
- package/src/event-store/event-store.ts +15 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/file-ref-entity.test.ts +34 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +53 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +50 -1
- package/src/pipeline/dispatcher.ts +57 -47
- package/src/pipeline/system-hooks.ts +17 -5
- package/src/stack/table-helpers.ts +19 -9
- package/src/stack/test-stack.ts +3 -4
- package/src/testing/db-cleanup.ts +7 -9
- package/src/errors/__tests__/field-issue-compat.test.ts +0 -16
package/src/errors/classes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/event-store/index.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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.
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
|
175
|
-
//
|
|
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
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
-
});
|