@cosmicdrift/kumiko-framework 0.326.1 → 0.328.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.
Files changed (55) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
  3. package/src/api/__tests__/client-ip.test.ts +20 -0
  4. package/src/api/__tests__/origin-middleware.test.ts +65 -0
  5. package/src/api/__tests__/websocket-route.test.ts +552 -0
  6. package/src/api/auth-middleware.ts +29 -7
  7. package/src/api/client-ip.ts +15 -2
  8. package/src/api/index.ts +24 -1
  9. package/src/api/jwt.ts +5 -0
  10. package/src/api/origin-middleware.ts +27 -1
  11. package/src/api/server.ts +161 -2
  12. package/src/api/websocket-route.ts +327 -0
  13. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +94 -1
  14. package/src/bun-db/query.ts +160 -23
  15. package/src/changes.json +54 -0
  16. package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
  17. package/src/crypto/event-pii.ts +20 -4
  18. package/src/crypto/system-event-pii.ts +57 -0
  19. package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
  20. package/src/db/queries/backfill-pii.ts +184 -85
  21. package/src/db/queries/pii-backfill-state.ts +33 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +26 -0
  23. package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
  24. package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
  25. package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
  26. package/src/engine/build-app-schema.ts +2 -0
  27. package/src/engine/define-feature.ts +1 -0
  28. package/src/engine/feature-builder-state.ts +3 -0
  29. package/src/engine/feature-ui-extensions.ts +52 -0
  30. package/src/engine/index.ts +5 -0
  31. package/src/engine/steps/describe-workflow-step-error.ts +6 -0
  32. package/src/engine/steps/mail-send.ts +5 -1
  33. package/src/engine/steps/retry.ts +13 -1
  34. package/src/engine/steps/webhook-send.ts +6 -16
  35. package/src/engine/types/index.ts +8 -0
  36. package/src/event-store/index.ts +4 -0
  37. package/src/event-store/provenance-append.ts +1 -1
  38. package/src/event-store/transfer.ts +2 -2
  39. package/src/i18n/required-surface-keys.ts +8 -0
  40. package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
  41. package/src/jobs/job-runner.ts +18 -0
  42. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +45 -3
  43. package/src/migrations/__tests__/pii-event-backfill.integration.test.ts +457 -0
  44. package/src/migrations/index.ts +11 -0
  45. package/src/migrations/pending-rebuilds.ts +29 -3
  46. package/src/migrations/pii-event-backfill.ts +422 -0
  47. package/src/pipeline/append-event-core.ts +1 -8
  48. package/src/pipeline/irreversible-operation-gate.ts +4 -3
  49. package/src/pipeline/msp-rebuild.ts +7 -1
  50. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +65 -0
  51. package/src/search/__tests__/search-adapter.test.ts +15 -0
  52. package/src/search/in-memory-adapter.ts +6 -0
  53. package/src/search/meilisearch-adapter.ts +24 -0
  54. package/src/stack/test-stack.ts +26 -2
  55. package/src/ui-types/index.ts +1 -0
@@ -1,3 +1,4 @@
1
+ import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
1
2
  import type * as z from "zod";
2
3
  import type { ZodType } from "zod";
3
4
  import { LifecycleHookTypes } from "./constants";
@@ -99,6 +100,7 @@ export type FeatureBuilderState = {
99
100
  contentCollections: Record<string, ContentCollectionDefinition>;
100
101
  workspaces: Record<string, WorkspaceDefinition>;
101
102
  httpRoutes: Record<string, HttpRouteDefinition>;
103
+ webSocketRoutes: Record<string, WebSocketRouteDefinition>;
102
104
  translations: TranslationKeys;
103
105
  isSystemScoped: boolean;
104
106
  toggleableDefault: boolean | undefined;
@@ -160,6 +162,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
160
162
  contentCollections: {},
161
163
  workspaces: {},
162
164
  httpRoutes: {},
165
+ webSocketRoutes: {},
163
166
  translations: {},
164
167
  isSystemScoped: false,
165
168
  toggleableDefault: undefined,
@@ -1,3 +1,9 @@
1
+ import {
2
+ WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT,
3
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
4
+ WEBSOCKET_ROUTE_PATH_PREFIX,
5
+ type WebSocketRouteDefinition,
6
+ } from "@cosmicdrift/kumiko-types/websocket-route";
1
7
  import type { EntityTableMeta } from "../db/entity-table-meta";
2
8
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
9
  import { LifecycleHookTypes } from "./constants";
@@ -514,6 +520,52 @@ export function buildUiExtensionsMethods<TName extends string>(
514
520
  }
515
521
  state.httpRoutes[key] = definition;
516
522
  },
523
+ webSocketRoute(definition: WebSocketRouteDefinition): void {
524
+ if (!definition.path.startsWith(WEBSOCKET_ROUTE_PATH_PREFIX)) {
525
+ throw new Error(
526
+ `[Feature ${name}] webSocketRoute path "${definition.path}" must start with ` +
527
+ `"${WEBSOCKET_ROUTE_PATH_PREFIX}" — only that namespace rides the /api/* auth chain ` +
528
+ "the upgrade relies on.",
529
+ );
530
+ }
531
+ if (definition.path.includes("*")) {
532
+ throw new Error(
533
+ `[Feature ${name}] webSocketRoute path "${definition.path}" must not contain "*" — ` +
534
+ "wildcards would let one route swallow unrelated /api/ws/* paths.",
535
+ );
536
+ }
537
+ const { maxMessageBytes } = definition;
538
+ if (
539
+ maxMessageBytes !== undefined &&
540
+ (!Number.isInteger(maxMessageBytes) ||
541
+ maxMessageBytes < 1 ||
542
+ maxMessageBytes > WEBSOCKET_MAX_PAYLOAD_BYTES)
543
+ ) {
544
+ throw new Error(
545
+ `[Feature ${name}] webSocketRoute "${definition.path}" maxMessageBytes must be an ` +
546
+ `integer between 1 and ${WEBSOCKET_MAX_PAYLOAD_BYTES}, got ${maxMessageBytes}.`,
547
+ );
548
+ }
549
+ const { maxConnectionsPerUser } = definition;
550
+ if (
551
+ maxConnectionsPerUser !== undefined &&
552
+ (!Number.isInteger(maxConnectionsPerUser) ||
553
+ maxConnectionsPerUser < 1 ||
554
+ maxConnectionsPerUser > WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT)
555
+ ) {
556
+ throw new Error(
557
+ `[Feature ${name}] webSocketRoute "${definition.path}" maxConnectionsPerUser must be an ` +
558
+ `integer between 1 and ${WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT}, got ${maxConnectionsPerUser}.`,
559
+ );
560
+ }
561
+ if (state.webSocketRoutes[definition.path]) {
562
+ throw new Error(
563
+ `[Feature ${name}] WebSocket route "${definition.path}" already registered. ` +
564
+ "path must be unique per feature.",
565
+ );
566
+ }
567
+ state.webSocketRoutes[definition.path] = definition;
568
+ },
517
569
  storeTable(meta: EntityTableMeta, options: StoreTableOptions): void {
518
570
  // Name comes from the meta itself — apps already give the table a
519
571
  // name when calling defineUnmanagedTable, no need to repeat it.
@@ -298,6 +298,10 @@ export type { TransitionGraph } from "./state-machine";
298
298
  export { defineTransitions, guardTransition } from "./state-machine";
299
299
  export { evaluateEventMatch } from "./steps/_event-match";
300
300
  export {
301
+ STEP_DISPATCH_AGGREGATE_TYPE,
302
+ STEP_DISPATCH_FAILED_TYPE,
303
+ STEP_DISPATCH_REQUESTED_TYPE,
304
+ STEP_DISPATCHED_TYPE,
301
305
  SUSPEND_SENTINEL,
302
306
  WORKFLOW_AGGREGATE_TYPE,
303
307
  WORKFLOW_RESUMED_TYPE,
@@ -308,6 +312,7 @@ export {
308
312
  WORKFLOW_WAITING_FOR_EVENT_TYPE,
309
313
  WORKFLOW_WAITING_TYPE,
310
314
  } from "./steps/_step-dispatch-constants";
315
+ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error";
311
316
  export {
312
317
  ANONYMOUS_ROLE,
313
318
  ANONYMOUS_USER_ID,
@@ -0,0 +1,6 @@
1
+ // Workflow events (run-failed, retry.scheduled) outlive an Art. 17 erase, and a
2
+ // raw error message can echo recipients or payload fragments. Events carry only
3
+ // this generic text; the raw error goes to the log.
4
+ export function describeWorkflowStepError(error: unknown): string {
5
+ return `workflow step failed (${error instanceof Error ? error.name : "unknown error"})`;
6
+ }
@@ -33,7 +33,11 @@ defineStep<MailSendArgs, void>({
33
33
  type: STEP_DISPATCH_REQUESTED_TYPE,
34
34
  payload: {
35
35
  stepKind: "mail.send",
36
- spec: { to, subject, body, ...(from && { from }) },
36
+ // Flat string fields: event PII encryption only handles top-level strings.
37
+ to: JSON.stringify(to),
38
+ subject,
39
+ body,
40
+ ...(from && { from }),
37
41
  },
38
42
  });
39
43
  },
@@ -7,6 +7,7 @@
7
7
  // after the backoff duration and re-enters the step at the same index.
8
8
  // After all attempts exhausted, the original error propagates.
9
9
 
10
+ import { createFallbackLogger } from "../../logging";
10
11
  import { defineStep } from "../define-step";
11
12
  import { runStepList } from "../run-pipeline";
12
13
  import type { PipelineCtx, StepInstance } from "../types/step";
@@ -15,6 +16,9 @@ import {
15
16
  WORKFLOW_AGGREGATE_TYPE,
16
17
  WORKFLOW_RETRY_SCHEDULED_TYPE,
17
18
  } from "./_step-dispatch-constants";
19
+ import { describeWorkflowStepError } from "./describe-workflow-step-error";
20
+
21
+ const log = createFallbackLogger("workflow-retry");
18
22
 
19
23
  type RetryStepArgs = {
20
24
  readonly times: number;
@@ -47,6 +51,14 @@ defineStep<RetryStepArgs, undefined | typeof SUSPEND_SENTINEL>({
47
51
  throw error;
48
52
  }
49
53
 
54
+ log.warn("workflow step failed, retry scheduled", {
55
+ runId: ctx.workflow.runId,
56
+ workflowName: ctx.workflow.workflowName,
57
+ stepIndex: ctx.workflow.stepIndex,
58
+ attempt,
59
+ error: String(error),
60
+ });
61
+
50
62
  const backoffMs = calculateBackoff(attempt, args.backoff);
51
63
  const wakeAt = Temporal.Now.instant().add({ milliseconds: backoffMs }).toString();
52
64
 
@@ -60,7 +72,7 @@ defineStep<RetryStepArgs, undefined | typeof SUSPEND_SENTINEL>({
60
72
  maxAttempts,
61
73
  wakeAt,
62
74
  workflowName: ctx.workflow.workflowName,
63
- error: String(error),
75
+ error: describeWorkflowStepError(error),
64
76
  triggerEventType: ctx.event.type,
65
77
  triggerPayload: ctx.event.payload,
66
78
  ...(ctx.workflow.definitionFingerprint && {
@@ -14,15 +14,6 @@ import {
14
14
  STEP_DISPATCH_REQUESTED_TYPE,
15
15
  } from "./_step-dispatch-constants";
16
16
 
17
- // Re-export for back-compat callers (bundled step-dispatcher imports
18
- // these). The canonical home is _step-dispatch-constants.ts.
19
- export {
20
- STEP_DISPATCH_AGGREGATE_TYPE,
21
- STEP_DISPATCH_FAILED_TYPE,
22
- STEP_DISPATCH_REQUESTED_TYPE,
23
- STEP_DISPATCHED_TYPE,
24
- } from "./_step-dispatch-constants";
25
-
26
17
  type WebhookHttpMethod = "POST" | "PUT" | "PATCH";
27
18
 
28
19
  // Name inside the tenant-owned secrets namespace
@@ -55,13 +46,12 @@ defineStep<WebhookSendArgs, void>({
55
46
  type: STEP_DISPATCH_REQUESTED_TYPE,
56
47
  payload: {
57
48
  stepKind: "webhook.send",
58
- spec: {
59
- url,
60
- method: args.method ?? "POST",
61
- headers,
62
- body,
63
- ...(args.auth && { auth: args.auth }),
64
- },
49
+ // Flat string fields: event PII encryption only handles top-level strings.
50
+ url,
51
+ method: args.method ?? "POST",
52
+ headersJson: JSON.stringify(headers),
53
+ ...(body !== undefined && { bodyJson: JSON.stringify(body) }),
54
+ ...(args.auth && { auth: args.auth }),
65
55
  retry: args.retry ?? { times: 3, backoff: "exponential" },
66
56
  },
67
57
  });
@@ -143,6 +143,7 @@ export type {
143
143
  ResolvedPiiFlags,
144
144
  RetentionDef,
145
145
  SelectFieldDef,
146
+ SelectOptionTone,
146
147
  TextFieldDef,
147
148
  TimestampFieldDef,
148
149
  TransitionMap,
@@ -367,6 +368,13 @@ export type {
367
368
  TreeNode,
368
369
  TreeNodeState,
369
370
  } from "@cosmicdrift/kumiko-types/tree-node";
371
+ export type {
372
+ WebSocketConnection,
373
+ WebSocketMessageData,
374
+ WebSocketRouteConnectDeps,
375
+ WebSocketRouteDefinition,
376
+ WebSocketSessionHandlers,
377
+ } from "@cosmicdrift/kumiko-types/websocket-route";
370
378
  export type { WorkspaceDefinition } from "@cosmicdrift/kumiko-types/workspace";
371
379
  // Re-export types that were duplicated in types.ts but are canonical in constants.ts
372
380
  export type {
@@ -1,8 +1,12 @@
1
1
  export {
2
2
  backfillEventPiiEncryption,
3
+ backfillEventPiiEncryptionBatch,
4
+ type PiiBackfillBatchOptions,
5
+ type PiiBackfillBatchResult,
3
6
  type PiiBackfillFailure,
4
7
  type PiiBackfillOptions,
5
8
  type PiiBackfillResult,
9
+ type PiiBackfillScanCache,
6
10
  } from "../db/queries/backfill-pii";
7
11
  export {
8
12
  type ArchiveStreamArgs,
@@ -1,8 +1,8 @@
1
+ import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii";
1
2
  import { runInSavepointIfSupported } from "../db/query";
2
3
  import { type TenantDb, unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db";
3
4
  import type { TenantId } from "../engine/types";
4
5
  import { AccessDeniedError, InternalError } from "../errors";
5
- import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
6
6
  import { append, type EventMetadata, getStreamVersion } from "./event-store";
7
7
 
8
8
  // Fixed by the framework, not the caller — the point of this entry point is
@@ -8,6 +8,7 @@
8
8
  // commits it together with the root row's ownership write — see
9
9
  // move-entity-graph.ts's file header for why that matters).
10
10
 
11
+ import { AGGREGATE_TRANSFERRED_EVENT_TYPE } from "../crypto/system-event-pii";
11
12
  import type { DbRunner } from "../db";
12
13
  import {
13
14
  deleteSnapshotsForAggregates,
@@ -15,12 +16,11 @@ import {
15
16
  transferEventRows,
16
17
  } from "../db/queries/event-store-transfer";
17
18
  import type { TenantId } from "../engine/types";
18
- import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
19
19
  import { generateId } from "../utils/ids";
20
20
  import { append } from "./event-store";
21
21
 
22
22
  export const AGGREGATE_TRANSFER_STREAM_TYPE = "aggregate-transfer";
23
- export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
23
+ export { AGGREGATE_TRANSFERRED_EVENT_TYPE };
24
24
 
25
25
  export type TransferAggregateStreamsArgs = {
26
26
  readonly sourceTenantId: TenantId;
@@ -166,6 +166,8 @@ export function requiredKeysFromScreen(
166
166
  out.add(fieldLabelKey(featureName, list.entity, normalized.field));
167
167
  }
168
168
  }
169
+ pushKey(out, list.createLabel);
170
+ pushKey(out, list.searchPlaceholder);
169
171
  for (const action of list.rowActions ?? []) pushRowActionKeys(out, action);
170
172
  for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
171
173
  break;
@@ -177,6 +179,8 @@ export function requiredKeysFromScreen(
177
179
  const normalized = normalizeListColumn(col);
178
180
  if (normalized.label !== undefined) pushKey(out, normalized.label);
179
181
  }
182
+ pushKey(out, list.createLabel);
183
+ pushKey(out, list.searchPlaceholder);
180
184
  for (const action of list.rowActions ?? []) pushRowActionKeys(out, action);
181
185
  for (const action of list.toolbarActions ?? []) pushToolbarActionKeys(out, action);
182
186
  break;
@@ -207,6 +211,8 @@ export function requiredKeysFromScreen(
207
211
  case "actionForm": {
208
212
  const form = screen as ActionFormScreenDefinition;
209
213
  pushKey(out, form.submitLabel);
214
+ pushKey(out, form.summary?.title);
215
+ pushKey(out, form.summary?.subtitle);
210
216
  for (const fieldName of Object.keys(form.fields)) {
211
217
  const override = form.fieldLabels?.[fieldName];
212
218
  if (override !== undefined) pushKey(out, override);
@@ -318,6 +324,8 @@ export function requiredKeysFromScreen(
318
324
  }
319
325
  if (section.kind === "relatedList") {
320
326
  pushKey(out, section.title);
327
+ pushKey(out, section.description);
328
+ pushKey(out, section.itemNoun);
321
329
  for (const col of section.columns) {
322
330
  const normalized = normalizeListColumn(col);
323
331
  if (normalized.label !== undefined) pushKey(out, normalized.label);
@@ -1530,3 +1530,34 @@ describe("boot gates", () => {
1530
1530
  expect(() => createRegistry([validStringForm])).not.toThrow();
1531
1531
  });
1532
1532
  });
1533
+
1534
+ describe("runner lifecycle: immediate stop (fw#1805)", () => {
1535
+ test("stop() right after construction leaves no unhandled rejection", async () => {
1536
+ const unhandled: unknown[] = [];
1537
+ const onUnhandled = (reason: unknown) => {
1538
+ unhandled.push(reason);
1539
+ };
1540
+ process.on("unhandledRejection", onUnhandled);
1541
+ try {
1542
+ // @timeout-exception: #1805 the 0-4ms delay is the variable under test (stop during connect), nothing to poll for
1543
+ for (let i = 0; i < 20; i++) {
1544
+ const runner = createJobRunner({
1545
+ registry: createRegistry([testFeature]),
1546
+ context: {},
1547
+ redisUrl,
1548
+ consumerLane: "worker",
1549
+ queueNamePrefix: `kumiko-test-stop-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 8)}`,
1550
+ });
1551
+ // Deterministic 0-4ms delays hit the window where the Queue
1552
+ // connection is still 'initializing'.
1553
+ await sleep(i % 5);
1554
+ await runner.stop();
1555
+ }
1556
+ // Rejections surface on a later tick.
1557
+ await sleep(200);
1558
+ } finally {
1559
+ process.off("unhandledRejection", onUnhandled);
1560
+ }
1561
+ expect(unhandled).toEqual([]);
1562
+ });
1563
+ });
@@ -1181,6 +1181,24 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1181
1181
  await worker.close();
1182
1182
  worker = null;
1183
1183
  }
1184
+ // BullMQ close() while a Queue's connection is still 'initializing'
1185
+ // rejects the in-flight INFO with "Connection is closed." on an emitter
1186
+ // without listeners -> unhandled rejection (fw#1805). Wait for readiness
1187
+ // first; a failed/timed-out wait must not block the close itself.
1188
+ const readinessTimeout = timeoutReject(
1189
+ bootRedisTimeoutMs,
1190
+ `job-runner: Queue not ready within ${bootRedisTimeoutMs}ms during stop()`,
1191
+ );
1192
+ try {
1193
+ await Promise.race([
1194
+ Promise.all([queues.api.waitUntilReady(), queues.worker.waitUntilReady()]),
1195
+ readinessTimeout.promise,
1196
+ ]);
1197
+ } catch {
1198
+ // close() below still has to run
1199
+ } finally {
1200
+ readinessTimeout.cancel();
1201
+ }
1184
1202
  await Promise.all([queues.api.close(), queues.worker.close()]);
1185
1203
  if (lockRedis) {
1186
1204
  // quit() drains in-flight commands; disconnect() would cancel them
@@ -6,7 +6,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
6
6
  import { mkdtempSync, rmSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
- import { integer, table as pgTable, uuid } from "../../db/dialect";
9
+ import { integer, table as pgTable, text, uuid } from "../../db/dialect";
10
10
  import { createEventStoreExecutor } from "../../db/event-store-executor";
11
11
  import { asRawClient, selectMany } from "../../db/query";
12
12
  import { writeRebuildMarker } from "../../db/rebuild-marker";
@@ -21,7 +21,7 @@ import {
21
21
  type ProjectionDefinition,
22
22
  } from "../../engine";
23
23
  import type { JobRunner } from "../../jobs/job-runner";
24
- import { createProjectionStateTable } from "../../pipeline";
24
+ import { createEventConsumerStateTable, createProjectionStateTable } from "../../pipeline";
25
25
  import {
26
26
  createTestDb,
27
27
  type TestDb,
@@ -54,6 +54,12 @@ const countsTable = pgTable("read_pending_counts", {
54
54
  itemCount: integer("item_count").notNull().default(0),
55
55
  });
56
56
 
57
+ const mspItemNamesTable = pgTable("read_pending_msp_names", {
58
+ id: uuid("id").primaryKey(),
59
+ tenantId: uuid("tenant_id").notNull(),
60
+ name: text("name"),
61
+ });
62
+
57
63
  // Exists in the DB but has no registered projection — models an owning
58
64
  // feature missing from the composition (distinct from a deliberately DROPped
59
65
  // table, which is never pushed at all).
@@ -83,6 +89,20 @@ const countsProjection: ProjectionDefinition = {
83
89
  const feature = defineFeature("pendingtest", (r) => {
84
90
  r.entity("pending-item", itemEntity);
85
91
  r.projection(countsProjection);
92
+ r.multiStreamProjection({
93
+ name: "pending-item-names",
94
+ table: mspItemNamesTable,
95
+ apply: {
96
+ "pending-item.created": async (event, tx) => {
97
+ const payload = event.payload as { name: string };
98
+ await asRawClient(tx).unsafe(
99
+ `INSERT INTO "read_pending_msp_names" (id, tenant_id, name) VALUES ($1::uuid, $2::uuid, $3)
100
+ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`,
101
+ [event.aggregateId, event.tenantId, payload.name],
102
+ );
103
+ },
104
+ },
105
+ });
86
106
  });
87
107
 
88
108
  const admin = TestUsers.admin;
@@ -97,9 +117,11 @@ beforeAll(async () => {
97
117
  testDb = await createTestDb();
98
118
  await unsafeCreateEntityTable(testDb.db, itemEntity, "pending-item");
99
119
  await createProjectionStateTable(testDb.db);
120
+ await createEventConsumerStateTable(testDb.db);
100
121
  await unsafePushTables(testDb.db, {
101
122
  readPendingCounts: countsTable,
102
123
  readOrphanProjection: orphanProjectionTable,
124
+ readPendingMspNames: mspItemNamesTable,
103
125
  });
104
126
  tdb = createTenantDb(testDb.db, admin.tenantId);
105
127
  markerDir = mkdtempSync(join(tmpdir(), "pending-rebuilds-"));
@@ -113,7 +135,7 @@ afterAll(async () => {
113
135
  beforeEach(async () => {
114
136
  failApply = false;
115
137
  await asRawClient(testDb.db).unsafe(
116
- `TRUNCATE kumiko_events, read_pending_items, read_pending_counts, read_orphan_projection, kumiko_projections RESTART IDENTITY CASCADE`,
138
+ `TRUNCATE kumiko_events, read_pending_items, read_pending_counts, read_orphan_projection, read_pending_msp_names, kumiko_projections RESTART IDENTITY CASCADE`,
117
139
  );
118
140
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS kumiko_pending_rebuilds`);
119
141
  });
@@ -160,6 +182,26 @@ describe("pending-rebuilds queue", () => {
160
182
  expect(await getCount()).toBe(2);
161
183
  });
162
184
 
185
+ test("a queued multi-stream projection table is rebuilt through the multi-stream path", async () => {
186
+ await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
187
+ await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
188
+ writeRebuildMarker(markerDir, "0003_msp.sql", ["read_pending_msp_names"]);
189
+ await queueRebuildsFromMarkers(testDb.db, {
190
+ migrationsDir: markerDir,
191
+ appliedIds: ["0003_msp"],
192
+ });
193
+
194
+ const run = await runPendingRebuilds(testDb.db, registry);
195
+
196
+ expect(run.failed).toEqual([]);
197
+ expect(run.rebuilt).toEqual([
198
+ { projection: "pendingtest:projection:pending-item-names", eventsProcessed: 2 },
199
+ ]);
200
+ expect(await listPendingRebuilds(testDb.db)).toEqual([]);
201
+ const rows = await selectMany(testDb.db, mspItemNamesTable);
202
+ expect(rows.map((r) => r.name).sort()).toEqual(["a", "b"]);
203
+ });
204
+
163
205
  test("tables without a registered projection are drained, not stuck forever", async () => {
164
206
  writeRebuildMarker(markerDir, "0002_unmapped.sql", ["read_some_plain_table"]);
165
207
  await queueRebuildsFromMarkers(testDb.db, {