@cosmicdrift/kumiko-framework 0.57.2 → 0.59.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.57.2",
3
+ "version": "0.59.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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.55.1",
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 { createDbConnection, tableExists } from "../db";
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 { db, close } = createDbConnection(dbUrl);
201
- try {
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
- const body = (await res.json()) as { error: { code: string } };
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(((await res.json()) as { error: { code: string } }).error.code).toBe(
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
- const body = (await res.json()) as { error: { code: string } };
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
- return (client as { unsafe: UnsafeFn }).unsafe;
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
@@ -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
- expect(out.workspace?.definition.access).toEqual({
258
- roles: ["TenantAdmin", "Admin", "SystemAdmin"],
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
- // Row-Meta ist immer da, ohne Entity-Field zu sein: id (Aggregat-Id)
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 (visible !== undefined && typeof visible !== "boolean" && !fieldNames.has(visible.field)) {
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, masked) };
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 Schreib-Rollen aller Hub-Keys sonst sieht ein
172
- // unprivilegierter User einen leeren "Settings"-Switcher-Eintrag.
173
- access: unionEditAccess(masked.map((k) => k.def)),
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
- function unionEditAccess(defs: readonly ConfigKeyDefinition[]): AccessRule {
269
- return rolesToAccess(defs.flatMap((d) => [...d.access.write]));
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
@@ -30,7 +30,9 @@ import {
30
30
  unsafePushTables,
31
31
  } from "../../stack";
32
32
  import {
33
+ clearPendingRebuilds,
33
34
  enqueueProjectionRebuild,
35
+ listPendingRebuildRows,
34
36
  listPendingRebuilds,
35
37
  queueRebuildsFromMarkers,
36
38
  runPendingRebuilds,
@@ -161,6 +163,36 @@ describe("pending-rebuilds queue", () => {
161
163
  expect(await listPendingRebuilds(testDb.db)).toEqual([]);
162
164
  });
163
165
 
166
+ test("clear is scoped to the snapshot migration_id — a concurrent re-queue survives (#328)", async () => {
167
+ // Snapshot read: table queued for migration 0001.
168
+ writeRebuildMarker(markerDir, "0001_counts.sql", ["read_pending_counts"]);
169
+ await queueRebuildsFromMarkers(testDb.db, {
170
+ migrationsDir: markerDir,
171
+ appliedIds: ["0001_counts"],
172
+ });
173
+ const snapshot = await listPendingRebuildRows(testDb.db);
174
+ expect(snapshot).toEqual([{ tableName: "read_pending_counts", migrationId: "0001_counts" }]);
175
+
176
+ // A concurrent apply re-queues the SAME table for a NEWER migration between
177
+ // the snapshot read and the clear (upsert bumps migration_id, keeps the slot).
178
+ writeRebuildMarker(markerDir, "0002_counts.sql", ["read_pending_counts"]);
179
+ await queueRebuildsFromMarkers(testDb.db, {
180
+ migrationsDir: markerDir,
181
+ appliedIds: ["0002_counts"],
182
+ });
183
+
184
+ // Clearing against the OLD snapshot must NOT drop the freshly re-queued entry.
185
+ await clearPendingRebuilds(testDb.db, snapshot);
186
+ expect(await listPendingRebuilds(testDb.db)).toEqual(["read_pending_counts"]);
187
+ expect(await listPendingRebuildRows(testDb.db)).toEqual([
188
+ { tableName: "read_pending_counts", migrationId: "0002_counts" },
189
+ ]);
190
+
191
+ // Clearing against the CURRENT snapshot does drain it.
192
+ await clearPendingRebuilds(testDb.db, await listPendingRebuildRows(testDb.db));
193
+ expect(await listPendingRebuilds(testDb.db)).toEqual([]);
194
+ });
195
+
164
196
  test("no markers, no queue → noop", async () => {
165
197
  const run = await runPendingRebuilds(testDb.db, registry);
166
198
  expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [], unresolvedManaged: [] });
@@ -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 listPendingRebuilds(db: DbConnection): Promise<readonly string[]> {
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
- async function clearPendingRebuilds(db: DbConnection, tables: readonly string[]): Promise<void> {
73
- for (const tableName of tables) {
74
- await deleteMany(db, pendingRebuildsTable, { tableName });
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 pending = await listPendingRebuilds(db);
115
- if (pending.length === 0) {
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
- onBeforeFence: async () => {
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. Undefined in production.
145
- readonly onBeforeFence?: () => void | Promise<void>;
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.onBeforeFence?.();
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
- const warnedKeyCollisions = new Set<string>();
101
- function warnOncePerKeyCollision(entityName: string, key: string, isBaseField: boolean): void {
102
- const dedupKey = `${entityName}:${key}`;
103
- if (!warnedKeyCollisions.has(dedupKey)) {
104
- warnedKeyCollisions.add(dedupKey);
105
- const collidesWith = isBaseField ? `Stammfield "${key}"` : `earlier contributor key "${key}"`;
106
- console.warn(
107
- `[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
108
- `${collidesWith} — keeping the first value. Rename the contributor key.`,
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;