@cosmicdrift/kumiko-framework 0.57.2 → 0.60.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/__tests__/schema-cli.integration.test.ts +25 -14
- package/src/api/__tests__/origin-middleware.test.ts +18 -7
- package/src/db/__tests__/schema-inspection.test.ts +20 -0
- package/src/db/event-store-executor.ts +6 -0
- package/src/db/schema-inspection.ts +8 -1
- package/src/db/table-builder.ts +11 -0
- package/src/engine/__tests__/boot-validator.test.ts +76 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +25 -2
- package/src/engine/__tests__/search-payload-extension.test.ts +11 -0
- package/src/engine/boot-validator/screens-nav.ts +12 -4
- package/src/engine/build-config-feature-schema.ts +18 -10
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +73 -0
- package/src/migrations/pending-rebuilds.ts +34 -10
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +53 -1
- package/src/pipeline/projection-rebuild.ts +9 -3
- package/src/pipeline/system-hooks.ts +25 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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.57.2",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
|
|
6
|
-
import {
|
|
6
|
+
import { tableExists } from "../db";
|
|
7
|
+
import { asRawClient } from "../db/query";
|
|
7
8
|
import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
8
9
|
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
9
10
|
|
|
@@ -192,24 +193,34 @@ describe("runSchemaCli — DB-backed paths", () => {
|
|
|
192
193
|
// entity-read tables after `apply`, so runProdApp's first event-store access
|
|
193
194
|
// hit "relation kumiko_events does not exist". `apply` now ensures the
|
|
194
195
|
// framework-infra tables (idempotent) so a greenfield deploy boots.
|
|
196
|
+
//
|
|
197
|
+
// createTestDb's beforeAll already materialized kumiko_events/_snapshots/
|
|
198
|
+
// _archived_streams via createEventsTable — without dropping all five infra
|
|
199
|
+
// tables first, 3/5 assertions would be tautological (green even if `apply`
|
|
200
|
+
// never created them). Drop → assert-absent reproduces greenfield in the
|
|
201
|
+
// shared DB; apply recreates them, so the end-state matches the other tests.
|
|
202
|
+
const infraTables = [
|
|
203
|
+
"public.kumiko_events",
|
|
204
|
+
"public.kumiko_snapshots",
|
|
205
|
+
"public.kumiko_archived_streams",
|
|
206
|
+
"public.kumiko_event_consumers",
|
|
207
|
+
"public.kumiko_projections",
|
|
208
|
+
];
|
|
209
|
+
await asRawClient(testDb.db).unsafe(
|
|
210
|
+
`DROP TABLE IF EXISTS "kumiko_events", "kumiko_snapshots", "kumiko_archived_streams", ` +
|
|
211
|
+
`"kumiko_event_consumers", "kumiko_projections" CASCADE`,
|
|
212
|
+
);
|
|
213
|
+
for (const table of infraTables) {
|
|
214
|
+
expect(await tableExists(testDb.db, table)).toBe(false);
|
|
215
|
+
}
|
|
216
|
+
|
|
195
217
|
const appCwd = freshAppCwd();
|
|
196
218
|
writeSchemaFile(appCwd, "tbl_infra");
|
|
197
219
|
await runSchemaCli(["generate", "infra_test"], appCwd, captureOut().out);
|
|
198
220
|
await runSchemaCli(["apply"], appCwd, captureOut().out);
|
|
199
221
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
for (const table of [
|
|
203
|
-
"public.kumiko_events",
|
|
204
|
-
"public.kumiko_snapshots",
|
|
205
|
-
"public.kumiko_archived_streams",
|
|
206
|
-
"public.kumiko_event_consumers",
|
|
207
|
-
"public.kumiko_projections",
|
|
208
|
-
]) {
|
|
209
|
-
expect(await tableExists(db, table)).toBe(true);
|
|
210
|
-
}
|
|
211
|
-
} finally {
|
|
212
|
-
await close();
|
|
222
|
+
for (const table of infraTables) {
|
|
223
|
+
expect(await tableExists(testDb.db, table)).toBe(true);
|
|
213
224
|
}
|
|
214
225
|
rmSync(appCwd, { recursive: true, force: true });
|
|
215
226
|
});
|
|
@@ -16,6 +16,21 @@ import {
|
|
|
16
16
|
originMiddleware,
|
|
17
17
|
} from "../origin-middleware";
|
|
18
18
|
|
|
19
|
+
function isErrorBody(v: unknown): v is { error: { code: string } } {
|
|
20
|
+
if (typeof v !== "object" || v === null || !("error" in v)) return false;
|
|
21
|
+
const err = (v as { error: unknown }).error;
|
|
22
|
+
return (
|
|
23
|
+
typeof err === "object" && err !== null && typeof (err as { code: unknown }).code === "string"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readErrorCode(res: Response): Promise<string> {
|
|
28
|
+
const body: unknown = await res.json();
|
|
29
|
+
if (!isErrorBody(body))
|
|
30
|
+
throw new Error(`expected { error: { code } }, got ${JSON.stringify(body)}`);
|
|
31
|
+
return body.error.code;
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
const JWT_SECRET = "origin-middleware-test-secret-min-32-characters-long";
|
|
20
35
|
const ALLOWED = "https://admin.example.eu";
|
|
21
36
|
const DISALLOWED = "https://tenant.example.eu";
|
|
@@ -123,8 +138,7 @@ describe("originMiddleware", () => {
|
|
|
123
138
|
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
|
|
124
139
|
});
|
|
125
140
|
expect(res.status).toBe(403);
|
|
126
|
-
|
|
127
|
-
expect(body.error.code).toBe("origin_not_allowed");
|
|
141
|
+
expect(await readErrorCode(res)).toBe("origin_not_allowed");
|
|
128
142
|
});
|
|
129
143
|
|
|
130
144
|
// The guard runs as /api/* middleware before routing, so a disallowed-origin
|
|
@@ -140,9 +154,7 @@ describe("originMiddleware", () => {
|
|
|
140
154
|
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
|
|
141
155
|
});
|
|
142
156
|
expect(res.status).toBe(403);
|
|
143
|
-
expect(
|
|
144
|
-
"origin_not_allowed",
|
|
145
|
-
);
|
|
157
|
+
expect(await readErrorCode(res)).toBe("origin_not_allowed");
|
|
146
158
|
});
|
|
147
159
|
|
|
148
160
|
test("disallowed origin is blocked even as a simple text/plain request", async () => {
|
|
@@ -198,7 +210,6 @@ describe("originMiddleware", () => {
|
|
|
198
210
|
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, "Sec-Fetch-Site": "cross-site" },
|
|
199
211
|
});
|
|
200
212
|
expect(res.status).toBe(403);
|
|
201
|
-
|
|
202
|
-
expect(body.error.code).toBe("origin_not_allowed");
|
|
213
|
+
expect(await readErrorCode(res)).toBe("origin_not_allowed");
|
|
203
214
|
});
|
|
204
215
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DbConnection } from "../connection";
|
|
3
|
+
import { columnNamesOf, tableExists } from "../schema-inspection";
|
|
4
|
+
|
|
5
|
+
describe("schema-inspection — resolveUnsafeClient guard", () => {
|
|
6
|
+
// A db handle without `.unsafe` on $client / session.client / itself used to
|
|
7
|
+
// reach the query call as `undefined` and crash callers with an opaque
|
|
8
|
+
// "unsafe is not a function". The guard must surface a named, actionable error.
|
|
9
|
+
const noUnsafe = {} as unknown as DbConnection;
|
|
10
|
+
|
|
11
|
+
test("tableExists throws a named error when no .unsafe fn resolves", async () => {
|
|
12
|
+
await expect(tableExists(noUnsafe, "public.x")).rejects.toThrow(
|
|
13
|
+
/resolveUnsafeClient: no `\.unsafe/,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("columnNamesOf throws the same named error", async () => {
|
|
18
|
+
await expect(columnNamesOf(noUnsafe, "x")).rejects.toThrow(/resolveUnsafeClient: no `\.unsafe/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -930,6 +930,12 @@ export function createEventStoreExecutor(
|
|
|
930
930
|
const tableInfo = extractTableInfo(table);
|
|
931
931
|
const rows = rawRows.map((r) => coerceRow(rehydrateCompoundTypes(r, entity), tableInfo));
|
|
932
932
|
|
|
933
|
+
// list rows (and their cache entries) carry the READ-ROW version, not the
|
|
934
|
+
// authoritative stream version detail() applies. That is intentional —
|
|
935
|
+
// it's display-only and must never be used as an optimistic-lock base;
|
|
936
|
+
// edit flows reload via detail(), which reconciles the stream version.
|
|
937
|
+
// Sourcing a lock base from a list row would reintroduce the #336
|
|
938
|
+
// version_conflict. (#336)
|
|
933
939
|
if (entityCache && entityName && rows.length > 0) {
|
|
934
940
|
await entityCache.mset(
|
|
935
941
|
user.tenantId,
|
|
@@ -12,7 +12,14 @@ function resolveUnsafeClient(db: DbConnection | DbTx): UnsafeFn {
|
|
|
12
12
|
unsafe?: UnsafeFn;
|
|
13
13
|
};
|
|
14
14
|
const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
|
|
15
|
-
|
|
15
|
+
const fn = (client as { unsafe?: UnsafeFn }).unsafe;
|
|
16
|
+
if (fn === undefined) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
"resolveUnsafeClient: no `.unsafe(sql, params)` fn on the db connection " +
|
|
19
|
+
"(checked $client, session.client, db itself) — schema-inspection needs the raw postgres escape hatch.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return fn;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
// True when `<fullyQualifiedName>` refers to an existing relation in the
|
package/src/db/table-builder.ts
CHANGED
|
@@ -409,6 +409,17 @@ export function buildBaseColumns(softDelete: boolean, idType: "serial" | "uuid"
|
|
|
409
409
|
return base;
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
const ROW_META_BASE: ReadonlySet<string> = new Set(Object.keys(buildBaseColumns(false)));
|
|
413
|
+
const ROW_META_WITH_SOFT_DELETE: ReadonlySet<string> = new Set(Object.keys(buildBaseColumns(true)));
|
|
414
|
+
|
|
415
|
+
// Row-meta columns exist on every row without being declared entity fields, so the
|
|
416
|
+
// boot-validator must treat them as known when an action picks/maps/gates on them.
|
|
417
|
+
// softDelete-only columns stay unknown for non-softDelete entities — gating on them
|
|
418
|
+
// there is a real misconfiguration, not a row-meta reference.
|
|
419
|
+
export function rowMetaFieldNames(softDelete: boolean): ReadonlySet<string> {
|
|
420
|
+
return softDelete ? ROW_META_WITH_SOFT_DELETE : ROW_META_BASE;
|
|
421
|
+
}
|
|
422
|
+
|
|
412
423
|
export type BuildEntityTableOptions = {
|
|
413
424
|
readonly featureName?: string;
|
|
414
425
|
// Relations declared for this entity. When present, every belongsTo
|
|
@@ -2008,6 +2008,82 @@ describe("boot-validator", () => {
|
|
|
2008
2008
|
});
|
|
2009
2009
|
});
|
|
2010
2010
|
|
|
2011
|
+
// --- row-meta allowlist: ALLE Base-Columns, nicht nur id/version (#331, #323) ---
|
|
2012
|
+
// buildBaseColumns legt tenantId/insertedAt/modifiedAt/...-Spalten auf jede Row;
|
|
2013
|
+
// pick/map/visible darauf ist legitim und darf den Boot nicht killen. softDelete-
|
|
2014
|
+
// Spalten (isDeleted/...) existieren NUR auf softDelete-Entities — darauf zu picken
|
|
2015
|
+
// ist sonst eine echte Fehlkonfiguration und muss weiter werfen.
|
|
2016
|
+
describe("entityList rowAction row-meta allowlist (#331, #323)", () => {
|
|
2017
|
+
function makeFeature(opts: {
|
|
2018
|
+
pick?: readonly string[];
|
|
2019
|
+
visibleField?: string;
|
|
2020
|
+
softDelete?: boolean;
|
|
2021
|
+
}) {
|
|
2022
|
+
return defineFeature("shop", (r) => {
|
|
2023
|
+
r.entity(
|
|
2024
|
+
"product",
|
|
2025
|
+
createEntity({
|
|
2026
|
+
fields: { name: createTextField() },
|
|
2027
|
+
softDelete: opts.softDelete ?? false,
|
|
2028
|
+
}),
|
|
2029
|
+
);
|
|
2030
|
+
r.screen({
|
|
2031
|
+
id: "product-list",
|
|
2032
|
+
type: "entityList",
|
|
2033
|
+
entity: "product",
|
|
2034
|
+
columns: ["name"],
|
|
2035
|
+
rowActions: [
|
|
2036
|
+
{
|
|
2037
|
+
id: "archive",
|
|
2038
|
+
label: "actions.archive",
|
|
2039
|
+
handler: "shop:write:archive",
|
|
2040
|
+
...(opts.pick ? { payload: { pick: [...opts.pick] } } : {}),
|
|
2041
|
+
...(opts.visibleField ? { visible: { field: opts.visibleField, eq: true } } : {}),
|
|
2042
|
+
},
|
|
2043
|
+
],
|
|
2044
|
+
});
|
|
2045
|
+
r.writeHandler(
|
|
2046
|
+
"archive",
|
|
2047
|
+
z.object({}),
|
|
2048
|
+
async () => ({ isSuccess: true as const, data: null }),
|
|
2049
|
+
{
|
|
2050
|
+
access: { roles: ["Admin"] },
|
|
2051
|
+
},
|
|
2052
|
+
);
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
test("pick mit Base-Column tenantId → kein Throw", () => {
|
|
2057
|
+
expect(() => validateBoot([makeFeature({ pick: ["id", "tenantId"] })])).not.toThrow();
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
test("visible.field auf Row-Meta (version) → kein Throw", () => {
|
|
2061
|
+
expect(() => validateBoot([makeFeature({ visibleField: "version" })])).not.toThrow();
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
test("visible.field auf id → kein Throw (Aggregat-id immer vorhanden)", () => {
|
|
2065
|
+
expect(() => validateBoot([makeFeature({ visibleField: "id" })])).not.toThrow();
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
test("visible.field auf unknown Field → Throw", () => {
|
|
2069
|
+
expect(() => validateBoot([makeFeature({ visibleField: "ghost" })])).toThrow(
|
|
2070
|
+
/visible\.field references unknown field "ghost"/,
|
|
2071
|
+
);
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
test("softDelete-Entity: pick isDeleted → kein Throw", () => {
|
|
2075
|
+
expect(() =>
|
|
2076
|
+
validateBoot([makeFeature({ pick: ["id", "isDeleted"], softDelete: true })]),
|
|
2077
|
+
).not.toThrow();
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
test("Nicht-softDelete-Entity: pick isDeleted → Throw (Spalte existiert nicht)", () => {
|
|
2081
|
+
expect(() =>
|
|
2082
|
+
validateBoot([makeFeature({ pick: ["id", "isDeleted"], softDelete: false })]),
|
|
2083
|
+
).toThrow(/references unknown field "isDeleted"/);
|
|
2084
|
+
});
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2011
2087
|
// --- toolbarAction navigate + writeHandler Validierung (Tier 2.7e-2) ---
|
|
2012
2088
|
describe("entityList toolbarAction navigate (Tier 2.7e-2)", () => {
|
|
2013
2089
|
function makeFeature(targetScreen: string, withTarget: boolean) {
|
|
@@ -254,8 +254,31 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
|
254
254
|
r.config({ keys: { apiKey: createTenantConfig("text", { mask: { title: "a.key" } }) } });
|
|
255
255
|
});
|
|
256
256
|
const out = buildConfigFeatureSchema(createRegistry([adminOnly]));
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
const acc = out.workspace?.definition.access;
|
|
258
|
+
expect(acc && "roles" in acc ? [...acc.roles].sort() : acc).toEqual([
|
|
259
|
+
"Admin",
|
|
260
|
+
"SystemAdmin",
|
|
261
|
+
"TenantAdmin",
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("workspace access excludes the machine-only 'system' role when a machine key is masked", () => {
|
|
266
|
+
// A masked machine-only key (write defaults to ["system"]) is excluded from
|
|
267
|
+
// every human nav — its "system" role must not leak into the switcher gate
|
|
268
|
+
// just because it sits in the hub next to a human-writable key (#406/1).
|
|
269
|
+
const mixed = defineFeature("mixedhub", (r) => {
|
|
270
|
+
r.config({
|
|
271
|
+
keys: {
|
|
272
|
+
label: createTenantConfig("text", { mask: { title: "m.label" } }),
|
|
273
|
+
internalFlag: createSystemConfig("boolean", { mask: { title: "m.flag" } }),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
259
276
|
});
|
|
277
|
+
const out = buildConfigFeatureSchema(createRegistry([mixed]));
|
|
278
|
+
const acc = out.workspace?.definition.access;
|
|
279
|
+
expect(acc).toBeDefined();
|
|
280
|
+
const roles = acc && "roles" in acc ? acc.roles : [];
|
|
281
|
+
expect(roles).not.toContain("system");
|
|
282
|
+
expect([...roles].sort()).toEqual(["Admin", "SystemAdmin", "TenantAdmin"]);
|
|
260
283
|
});
|
|
261
284
|
});
|
|
@@ -162,6 +162,17 @@ describe("buildSearchDocument — contributor precedence (base fields win)", ()
|
|
|
162
162
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
test("collision dedup is scoped per registry — separate registries each warn", async () => {
|
|
166
|
+
// Identical contributor-vs-base collision in two independent registries. A
|
|
167
|
+
// module-global dedup Set would warn for the first and silence the second
|
|
168
|
+
// (and leak across tests); per-registry scoping must warn once for EACH.
|
|
169
|
+
const r1 = registryWith(() => ({ title: "from-contributor" }));
|
|
170
|
+
const r2 = registryWith(() => ({ title: "from-contributor" }));
|
|
171
|
+
await buildSearchDocument("thing", "t1", { title: "real-value" }, r1);
|
|
172
|
+
await buildSearchDocument("thing", "t1", { title: "real-value" }, r2);
|
|
173
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
174
|
+
});
|
|
175
|
+
|
|
165
176
|
test("contributor-vs-contributor collision warns with the right message", async () => {
|
|
166
177
|
const feature = defineFeature("test", (r) => {
|
|
167
178
|
const thing = r.entity(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
1
2
|
import { SETTINGS_HUB_AUDIENCE_NAV_QNS } from "../build-config-feature-schema";
|
|
2
3
|
import { qualifyEntityName } from "../qualified-name";
|
|
3
4
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
@@ -31,6 +32,7 @@ function validateActionFieldRefs(
|
|
|
31
32
|
actionId: string,
|
|
32
33
|
action: RowAction | ToolbarAction,
|
|
33
34
|
fieldNames: ReadonlySet<string>,
|
|
35
|
+
rowMeta: ReadonlySet<string>,
|
|
34
36
|
): void {
|
|
35
37
|
// ToolbarAction.payload ist ein STATISCHER Record (kein Row-Context) —
|
|
36
38
|
// nur echte pick/map-Extractoren werden gegen die Feldnamen geprüft.
|
|
@@ -49,9 +51,7 @@ function validateActionFieldRefs(
|
|
|
49
51
|
}
|
|
50
52
|
const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
|
|
51
53
|
for (const source of sources) {
|
|
52
|
-
|
|
53
|
-
// und version (optimistic lock — Standard-pick für Lifecycle-Writes).
|
|
54
|
-
if (source === "id" || source === "version") continue;
|
|
54
|
+
if (rowMeta.has(source)) continue;
|
|
55
55
|
if (!fieldNames.has(source)) {
|
|
56
56
|
throw new Error(
|
|
57
57
|
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
@@ -62,7 +62,12 @@ function validateActionFieldRefs(
|
|
|
62
62
|
};
|
|
63
63
|
checkExtractor("payload", payload);
|
|
64
64
|
checkExtractor("params", params);
|
|
65
|
-
if (
|
|
65
|
+
if (
|
|
66
|
+
visible !== undefined &&
|
|
67
|
+
typeof visible !== "boolean" &&
|
|
68
|
+
!rowMeta.has(visible.field) &&
|
|
69
|
+
!fieldNames.has(visible.field)
|
|
70
|
+
) {
|
|
66
71
|
throw new Error(
|
|
67
72
|
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
68
73
|
`visible.field references unknown field "${visible.field}". Known fields: ${known()}.`,
|
|
@@ -304,6 +309,7 @@ export function validateScreens(
|
|
|
304
309
|
}
|
|
305
310
|
|
|
306
311
|
const fieldNames = new Set(Object.keys(entityDef.fields));
|
|
312
|
+
const rowMeta = rowMetaFieldNames(entityDef.softDelete ?? false);
|
|
307
313
|
if (screen.type === "entityList") {
|
|
308
314
|
// Empty column list would render as a blank table — almost always the
|
|
309
315
|
// sign of an in-progress screen the author forgot to fill in. Fail
|
|
@@ -437,6 +443,7 @@ export function validateScreens(
|
|
|
437
443
|
action.id,
|
|
438
444
|
action,
|
|
439
445
|
fieldNames,
|
|
446
|
+
rowMeta,
|
|
440
447
|
);
|
|
441
448
|
}
|
|
442
449
|
}
|
|
@@ -469,6 +476,7 @@ export function validateScreens(
|
|
|
469
476
|
action.id,
|
|
470
477
|
action,
|
|
471
478
|
fieldNames,
|
|
479
|
+
rowMeta,
|
|
472
480
|
);
|
|
473
481
|
}
|
|
474
482
|
}
|
|
@@ -132,7 +132,7 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
|
|
|
132
132
|
// Alle masked Keys maschinen-only → kein menschlicher Hub, kein (leerer)
|
|
133
133
|
// Settings-Switcher.
|
|
134
134
|
if (navs.length === 0) return { screens, navs };
|
|
135
|
-
return { screens, navs, workspace: buildSettingsWorkspace(navs
|
|
135
|
+
return { screens, navs, workspace: buildSettingsWorkspace(navs) };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
// Keys, die an `scope` sichtbar sind, gepaart mit ihren effektiven Schreib-
|
|
@@ -157,10 +157,7 @@ function effectiveWriteRoles(def: ConfigKeyDefinition, scope: ConfigScope): stri
|
|
|
157
157
|
// admin.navMembers === ["orders:nav:list", ...]). Die generierten Navs leben
|
|
158
158
|
// unter SETTINGS_HUB_FEATURE, also `config:nav:<shortId>`. Sortiert = stabile
|
|
159
159
|
// Landing-Screen-Wahl (firstNavScreenId iteriert navMembers der Reihe nach).
|
|
160
|
-
function buildSettingsWorkspace(
|
|
161
|
-
navs: readonly NavDefinition[],
|
|
162
|
-
masked: readonly MaskedKey[],
|
|
163
|
-
): WorkspaceSchema {
|
|
160
|
+
function buildSettingsWorkspace(navs: readonly NavDefinition[]): WorkspaceSchema {
|
|
164
161
|
const navMembers = navs.map((n) => `${SETTINGS_HUB_FEATURE}:nav:${n.id}`).sort();
|
|
165
162
|
return {
|
|
166
163
|
definition: {
|
|
@@ -168,9 +165,12 @@ function buildSettingsWorkspace(
|
|
|
168
165
|
label: "config.settings.title",
|
|
169
166
|
icon: "settings",
|
|
170
167
|
order: 1000,
|
|
171
|
-
// Union der
|
|
172
|
-
// unprivilegierter User einen leeren
|
|
173
|
-
|
|
168
|
+
// Union der Zugriffs-Regeln der bereits generierten (machine-gefilterten)
|
|
169
|
+
// Hub-Navs — sonst sieht ein unprivilegierter User einen leeren
|
|
170
|
+
// "Settings"-Switcher. Aus den Navs statt aus `masked`, damit die
|
|
171
|
+
// machine-only "system"-Rolle (die in keinem Nav steht) nicht ins
|
|
172
|
+
// Switcher-Gate leakt.
|
|
173
|
+
access: unionAccessRules(navs.map((n) => n.access)),
|
|
174
174
|
},
|
|
175
175
|
navMembers,
|
|
176
176
|
};
|
|
@@ -265,8 +265,16 @@ function minMaskOrder(keys: readonly MaskedKey[]): number {
|
|
|
265
265
|
// opt-int, und zeigt user-scope (write `all`) jedem. `all` lässt sich in
|
|
266
266
|
// AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
|
|
267
267
|
// per Key gegated.
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
// Union der Navs-Access-Regeln: ein openToAll-Nav öffnet das ganze Gate, sonst
|
|
269
|
+
// die Vereinigung der Rollen. undefined-access-Navs tragen nichts bei.
|
|
270
|
+
function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRule {
|
|
271
|
+
const roles: string[] = [];
|
|
272
|
+
for (const rule of rules) {
|
|
273
|
+
if (rule === undefined) continue;
|
|
274
|
+
if ("openToAll" in rule) return { openToAll: true };
|
|
275
|
+
roles.push(...rule.roles);
|
|
276
|
+
}
|
|
277
|
+
return rolesToAccess(roles);
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
// `all` lässt sich in AccessRule nur als openToAll ausdrücken; der Write bleibt
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type ProjectionDefinition,
|
|
22
22
|
} from "../../engine";
|
|
23
23
|
import { createEventsTable } from "../../event-store";
|
|
24
|
+
import type { JobRunner } from "../../jobs/job-runner";
|
|
24
25
|
import { createProjectionStateTable } from "../../pipeline";
|
|
25
26
|
import {
|
|
26
27
|
createTestDb,
|
|
@@ -30,8 +31,11 @@ import {
|
|
|
30
31
|
unsafePushTables,
|
|
31
32
|
} from "../../stack";
|
|
32
33
|
import {
|
|
34
|
+
clearPendingRebuilds,
|
|
33
35
|
enqueueProjectionRebuild,
|
|
36
|
+
listPendingRebuildRows,
|
|
34
37
|
listPendingRebuilds,
|
|
38
|
+
PROJECTION_REBUILD_JOB,
|
|
35
39
|
queueRebuildsFromMarkers,
|
|
36
40
|
runPendingRebuilds,
|
|
37
41
|
} from "../pending-rebuilds";
|
|
@@ -161,6 +165,36 @@ describe("pending-rebuilds queue", () => {
|
|
|
161
165
|
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
162
166
|
});
|
|
163
167
|
|
|
168
|
+
test("clear is scoped to the snapshot migration_id — a concurrent re-queue survives (#328)", async () => {
|
|
169
|
+
// Snapshot read: table queued for migration 0001.
|
|
170
|
+
writeRebuildMarker(markerDir, "0001_counts.sql", ["read_pending_counts"]);
|
|
171
|
+
await queueRebuildsFromMarkers(testDb.db, {
|
|
172
|
+
migrationsDir: markerDir,
|
|
173
|
+
appliedIds: ["0001_counts"],
|
|
174
|
+
});
|
|
175
|
+
const snapshot = await listPendingRebuildRows(testDb.db);
|
|
176
|
+
expect(snapshot).toEqual([{ tableName: "read_pending_counts", migrationId: "0001_counts" }]);
|
|
177
|
+
|
|
178
|
+
// A concurrent apply re-queues the SAME table for a NEWER migration between
|
|
179
|
+
// the snapshot read and the clear (upsert bumps migration_id, keeps the slot).
|
|
180
|
+
writeRebuildMarker(markerDir, "0002_counts.sql", ["read_pending_counts"]);
|
|
181
|
+
await queueRebuildsFromMarkers(testDb.db, {
|
|
182
|
+
migrationsDir: markerDir,
|
|
183
|
+
appliedIds: ["0002_counts"],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Clearing against the OLD snapshot must NOT drop the freshly re-queued entry.
|
|
187
|
+
await clearPendingRebuilds(testDb.db, snapshot);
|
|
188
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual(["read_pending_counts"]);
|
|
189
|
+
expect(await listPendingRebuildRows(testDb.db)).toEqual([
|
|
190
|
+
{ tableName: "read_pending_counts", migrationId: "0002_counts" },
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
// Clearing against the CURRENT snapshot does drain it.
|
|
194
|
+
await clearPendingRebuilds(testDb.db, await listPendingRebuildRows(testDb.db));
|
|
195
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
164
198
|
test("no markers, no queue → noop", async () => {
|
|
165
199
|
const run = await runPendingRebuilds(testDb.db, registry);
|
|
166
200
|
expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] });
|
|
@@ -248,3 +282,42 @@ describe("enqueueProjectionRebuild — inline fallback (no jobs feature)", () =>
|
|
|
248
282
|
expect(await getCount()).toBe(2);
|
|
249
283
|
});
|
|
250
284
|
});
|
|
285
|
+
|
|
286
|
+
// #391/2: jobRunner present but the projection-rebuild job not registered (a
|
|
287
|
+
// caller that wired a jobRunner yet forgot to compose createJobsFeature()). The
|
|
288
|
+
// getJob-capability guard must still fall to the inline rebuild — NOT dispatch
|
|
289
|
+
// onto a runner whose queue has no handler for the job (silent no-op forever).
|
|
290
|
+
describe("enqueueProjectionRebuild — inline fallback (jobRunner without the job)", () => {
|
|
291
|
+
test("jobRunner present but job unregistered → inline, dispatch never called", async () => {
|
|
292
|
+
await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
|
|
293
|
+
await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
|
|
294
|
+
await executor.create({ groupId: GROUP, name: "c" }, admin, tdb);
|
|
295
|
+
|
|
296
|
+
// Sanity: this registry has no projection-rebuild job (no jobs feature).
|
|
297
|
+
expect(registry.getJob(PROJECTION_REBUILD_JOB)).toBeUndefined();
|
|
298
|
+
|
|
299
|
+
let dispatchCalls = 0;
|
|
300
|
+
const stubJobRunner: JobRunner = {
|
|
301
|
+
start: async () => {},
|
|
302
|
+
stop: async () => {},
|
|
303
|
+
handleEvent: async () => {},
|
|
304
|
+
dispatch: async () => {
|
|
305
|
+
dispatchCalls++;
|
|
306
|
+
return "should-not-happen";
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const outcome = await enqueueProjectionRebuild("pendingtest:projection:pending-counts", {
|
|
311
|
+
db: testDb.db,
|
|
312
|
+
registry,
|
|
313
|
+
jobRunner: stubJobRunner,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(outcome.mode).toBe("inline");
|
|
317
|
+
if (outcome.mode === "inline") {
|
|
318
|
+
expect(outcome.result.eventsProcessed).toBe(3);
|
|
319
|
+
}
|
|
320
|
+
expect(dispatchCalls).toBe(0); // routed to inline, not to a handler-less queue
|
|
321
|
+
expect(await getCount()).toBe(3);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -59,19 +59,33 @@ export async function queueRebuildsFromMarkers(
|
|
|
59
59
|
return [...queued];
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
type PendingRebuildRow = { readonly tableName: string };
|
|
62
|
+
type PendingRebuildRow = { readonly tableName: string; readonly migrationId: string };
|
|
63
63
|
|
|
64
|
-
export async function
|
|
64
|
+
export async function listPendingRebuildRows(
|
|
65
|
+
db: DbConnection,
|
|
66
|
+
): Promise<readonly PendingRebuildRow[]> {
|
|
65
67
|
await createPendingRebuildsTable(db);
|
|
66
68
|
const rows = await selectMany<PendingRebuildRow>(db, pendingRebuildsTable, undefined, {
|
|
67
69
|
orderBy: [{ col: "queuedAt" }, { col: "tableName" }],
|
|
68
70
|
});
|
|
69
|
-
return rows.map((row) => row.tableName);
|
|
71
|
+
return rows.map((row) => ({ tableName: row.tableName, migrationId: row.migrationId }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function listPendingRebuilds(db: DbConnection): Promise<readonly string[]> {
|
|
75
|
+
return (await listPendingRebuildRows(db)).map((row) => row.tableName);
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
// Clears against the (table_name, migration_id) snapshot the caller read, not
|
|
79
|
+
// table_name alone: a concurrent queueRebuildsFromMarkers can re-queue the same
|
|
80
|
+
// table for a NEWER migration (upsert bumps migration_id, keeps the slot)
|
|
81
|
+
// between the read and the clear. Scoping the delete to the snapshot's
|
|
82
|
+
// migration_id leaves that fresh re-queue intact instead of dropping it. (#328)
|
|
83
|
+
export async function clearPendingRebuilds(
|
|
84
|
+
db: DbConnection,
|
|
85
|
+
rows: readonly PendingRebuildRow[],
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
for (const { tableName, migrationId } of rows) {
|
|
88
|
+
await deleteMany(db, pendingRebuildsTable, { tableName, migrationId });
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
91
|
|
|
@@ -111,10 +125,20 @@ export async function runPendingRebuilds(
|
|
|
111
125
|
registry: Registry,
|
|
112
126
|
options: RunPendingRebuildsOptions = {},
|
|
113
127
|
): Promise<PendingRebuildRun> {
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
128
|
+
const pendingRows = await listPendingRebuildRows(db);
|
|
129
|
+
if (pendingRows.length === 0) {
|
|
116
130
|
return { rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] };
|
|
117
131
|
}
|
|
132
|
+
// (table → migration) snapshot read together with the table list; the clear
|
|
133
|
+
// below scopes its DELETE to this migration_id so a concurrent re-queue for a
|
|
134
|
+
// newer migration survives instead of being dropped (#328).
|
|
135
|
+
const migrationByTable = new Map(pendingRows.map((r) => [r.tableName, r.migrationId]));
|
|
136
|
+
const snapshotRows = (tables: readonly string[]): readonly PendingRebuildRow[] =>
|
|
137
|
+
tables.flatMap((tableName) => {
|
|
138
|
+
const migrationId = migrationByTable.get(tableName);
|
|
139
|
+
return migrationId === undefined ? [] : [{ tableName, migrationId }];
|
|
140
|
+
});
|
|
141
|
+
const pending = pendingRows.map((r) => r.tableName);
|
|
118
142
|
|
|
119
143
|
const tableToProjection = buildProjectionTableIndex(registry);
|
|
120
144
|
const thisRun = new Set(options.thisRunTables ?? []);
|
|
@@ -141,7 +165,7 @@ export async function runPendingRebuilds(
|
|
|
141
165
|
// nicht im Liegenlassen.
|
|
142
166
|
const drained = [...unmapped, ...unresolvedManaged];
|
|
143
167
|
if (drained.length > 0) {
|
|
144
|
-
await clearPendingRebuilds(db, drained);
|
|
168
|
+
await clearPendingRebuilds(db, snapshotRows(drained));
|
|
145
169
|
}
|
|
146
170
|
|
|
147
171
|
if (unresolvedManaged.length > 0) {
|
|
@@ -156,7 +180,7 @@ export async function runPendingRebuilds(
|
|
|
156
180
|
for (const [projection, tables] of byProjection) {
|
|
157
181
|
try {
|
|
158
182
|
const result = await rebuildProjection(projection, { db, registry });
|
|
159
|
-
await clearPendingRebuilds(db, tables);
|
|
183
|
+
await clearPendingRebuilds(db, snapshotRows(tables));
|
|
160
184
|
rebuilt.push({ projection, eventsProcessed: result.eventsProcessed });
|
|
161
185
|
} catch (e) {
|
|
162
186
|
failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
|
|
@@ -687,7 +687,7 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
|
|
|
687
687
|
// Fires after the unlocked bulk drain (events 1+2 applied) and before the
|
|
688
688
|
// fence. A 3rd event committed here is exactly the write Phase 1's single
|
|
689
689
|
// up-front SELECT missed — the fenced final drain must pick it up.
|
|
690
|
-
|
|
690
|
+
__test_onBeforeFence: async () => {
|
|
691
691
|
injected++;
|
|
692
692
|
await appendCreatedEvent(group, "c"); // event id 3, separate connection
|
|
693
693
|
},
|
|
@@ -698,6 +698,58 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
|
|
|
698
698
|
expect(await getCount(group)).toBe(3);
|
|
699
699
|
});
|
|
700
700
|
|
|
701
|
+
test("KNOWN LIMITATION (#443): a lower-id write committed late during replay is currently LOST", async () => {
|
|
702
|
+
// bigserial assigns ids at INSERT (pre-commit); a cross-aggregate write can
|
|
703
|
+
// commit an id BELOW the cursor the unlocked drain already advanced past, and
|
|
704
|
+
// the fenced final drain (`WHERE id > cursor`) never revisits it. This
|
|
705
|
+
// characterization test pins that data-loss deterministically. No clean fix
|
|
706
|
+
// preserves the online (short-fence) property — see #443. When the fix lands,
|
|
707
|
+
// flip groupX's assertion to toBe(1).
|
|
708
|
+
const db = testDb.db as DbConnection; // @cast-boundary test-harness (TestDb.db is intentionally unknown)
|
|
709
|
+
const groupX = "00000000-0000-4000-8000-000000000201";
|
|
710
|
+
const groupY = "00000000-0000-4000-8000-000000000202";
|
|
711
|
+
const aggX = "00000000-0000-4000-8000-0000000002a1";
|
|
712
|
+
|
|
713
|
+
// Connection A inserts aggregate X's event (grabs the LOW id) but holds its
|
|
714
|
+
// tx open — uncommitted, so the rebuild's READ COMMITTED scan can't see it.
|
|
715
|
+
let releaseX!: () => void;
|
|
716
|
+
const xGate = new Promise<void>((resolve) => {
|
|
717
|
+
releaseX = resolve;
|
|
718
|
+
});
|
|
719
|
+
let markXInserted!: () => void;
|
|
720
|
+
const xInserted = new Promise<void>((resolve) => {
|
|
721
|
+
markXInserted = resolve;
|
|
722
|
+
});
|
|
723
|
+
const xDone = db.begin(async (xtx: DbTx) => {
|
|
724
|
+
await asRawClient(xtx).unsafe(
|
|
725
|
+
`INSERT INTO "kumiko_events"
|
|
726
|
+
(aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
727
|
+
VALUES ($1::uuid, 'rebuild-item', $2::uuid, 0, 'rebuild-item.created', $3::jsonb, '{}'::jsonb, 'test')`,
|
|
728
|
+
[aggX, admin.tenantId, JSON.stringify({ groupId: groupX })],
|
|
729
|
+
);
|
|
730
|
+
markXInserted();
|
|
731
|
+
await xGate;
|
|
732
|
+
});
|
|
733
|
+
await xInserted;
|
|
734
|
+
|
|
735
|
+
// Aggregate Y commits AFTER X grabbed its id → Y carries the HIGHER id.
|
|
736
|
+
await appendCreatedEvent(groupY, "y");
|
|
737
|
+
|
|
738
|
+
await rebuildProjection(qualifiedProjectionName, {
|
|
739
|
+
db: testDb.db,
|
|
740
|
+
registry,
|
|
741
|
+
__test_onBeforeFence: async () => {
|
|
742
|
+
// X commits now: its low id becomes visible, but below the cursor that
|
|
743
|
+
// already advanced past Y's higher id.
|
|
744
|
+
releaseX();
|
|
745
|
+
await xDone;
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
expect(await getCount(groupY)).toBe(1);
|
|
750
|
+
expect(await getCount(groupX)).toBeUndefined(); // BUG (#443): should be 1
|
|
751
|
+
});
|
|
752
|
+
|
|
701
753
|
test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
|
|
702
754
|
// The catch-up loop only works if each fresh SELECT in the rebuild tx sees
|
|
703
755
|
// rows other connections committed since the previous batch. Under
|
|
@@ -110,6 +110,10 @@ function rowToStoredEvent(row: StoredEventRow): StoredEvent {
|
|
|
110
110
|
// - The shadow is rebuilt from EntityTableMeta, so an index hand-added in a
|
|
111
111
|
// migration but absent from meta is not reconstructed.
|
|
112
112
|
// - Requires CREATE privilege to provision the shared rebuild schema.
|
|
113
|
+
// - A cross-aggregate write that COMMITS with an event id below the cursor
|
|
114
|
+
// after the cursor already passed it is lost from the rebuild (id-order
|
|
115
|
+
// != commit-order; bigserial assigns ids pre-commit). Only reachable
|
|
116
|
+
// during a concurrent rebuild; recovered by the next rebuild. #443.
|
|
113
117
|
|
|
114
118
|
export type RebuildResult = {
|
|
115
119
|
readonly projection: string;
|
|
@@ -141,8 +145,10 @@ type RebuildDeps = {
|
|
|
141
145
|
readonly fenceLockTimeoutMs?: number;
|
|
142
146
|
// Test-only seam: fires once after the unlocked bulk drain and before the
|
|
143
147
|
// cutover fence. Lets a concurrency test inject a committed write into the
|
|
144
|
-
// replay window deterministically.
|
|
145
|
-
|
|
148
|
+
// replay window deterministically. The `__test_` prefix marks it test-only:
|
|
149
|
+
// a production caller passing a slow callback here would hold the
|
|
150
|
+
// ACCESS-EXCLUSIVE fence for the callback's duration, blocking every writer.
|
|
151
|
+
readonly __test_onBeforeFence?: () => void | Promise<void>;
|
|
146
152
|
};
|
|
147
153
|
|
|
148
154
|
export async function rebuildProjection(
|
|
@@ -224,7 +230,7 @@ export async function rebuildProjection(
|
|
|
224
230
|
|
|
225
231
|
// Test seam: inject a mid-replay committed write here to prove the
|
|
226
232
|
// fenced final drain catches it instead of losing it at swap.
|
|
227
|
-
await deps.
|
|
233
|
+
await deps.__test_onBeforeFence?.();
|
|
228
234
|
|
|
229
235
|
// Fence the live table, then drain the final delta. Once ACCESS
|
|
230
236
|
// EXCLUSIVE is held no concurrent apply can commit a new event, so this
|
|
@@ -96,18 +96,31 @@ function reconstructStateForSearch(
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// buildSearchDocument runs per save — without dedup a colliding contributor
|
|
99
|
-
// key would spam one warn line per write on the hotpath.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
99
|
+
// key would spam one warn line per write on the hotpath. Scoped per registry
|
|
100
|
+
// (not module-global) so each app/test instance dedups independently: a
|
|
101
|
+
// process-wide Set would silence the warning for every later registry once any
|
|
102
|
+
// registry hit a given collision, and would leak dedup-state across tests.
|
|
103
|
+
const warnedKeyCollisionsByRegistry = new WeakMap<Registry, Set<string>>();
|
|
104
|
+
function warnOncePerKeyCollision(
|
|
105
|
+
registry: Registry,
|
|
106
|
+
entityName: string,
|
|
107
|
+
key: string,
|
|
108
|
+
isBaseField: boolean,
|
|
109
|
+
): void {
|
|
110
|
+
let warned = warnedKeyCollisionsByRegistry.get(registry);
|
|
111
|
+
if (!warned) {
|
|
112
|
+
warned = new Set<string>();
|
|
113
|
+
warnedKeyCollisionsByRegistry.set(registry, warned);
|
|
110
114
|
}
|
|
115
|
+
const dedupKey = `${entityName}:${key}`;
|
|
116
|
+
// skip: already warned for this entity:key collision — dedup the hotpath
|
|
117
|
+
if (warned.has(dedupKey)) return;
|
|
118
|
+
warned.add(dedupKey);
|
|
119
|
+
const collidesWith = isBaseField ? `base field "${key}"` : `earlier contributor key "${key}"`;
|
|
120
|
+
console.warn(
|
|
121
|
+
`[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
|
|
122
|
+
`${collidesWith} — keeping the first value. Rename the contributor key.`,
|
|
123
|
+
);
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
// Build a SearchDocument from raw field-state. Parallel to the old
|
|
@@ -169,7 +182,7 @@ export async function buildSearchDocument(
|
|
|
169
182
|
const contributed = await contribute({ entityName, entityId, state });
|
|
170
183
|
for (const [key, value] of Object.entries(contributed)) {
|
|
171
184
|
if (Object.hasOwn(fields, key)) {
|
|
172
|
-
warnOncePerKeyCollision(entityName, key, baseFieldKeys.has(key));
|
|
185
|
+
warnOncePerKeyCollision(registry, entityName, key, baseFieldKeys.has(key));
|
|
173
186
|
continue;
|
|
174
187
|
}
|
|
175
188
|
fields[key] = value;
|