@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.
- package/package.json +4 -4
- package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
- package/src/api/__tests__/client-ip.test.ts +20 -0
- package/src/api/__tests__/origin-middleware.test.ts +65 -0
- package/src/api/__tests__/websocket-route.test.ts +552 -0
- package/src/api/auth-middleware.ts +29 -7
- package/src/api/client-ip.ts +15 -2
- package/src/api/index.ts +24 -1
- package/src/api/jwt.ts +5 -0
- package/src/api/origin-middleware.ts +27 -1
- package/src/api/server.ts +161 -2
- package/src/api/websocket-route.ts +327 -0
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +94 -1
- package/src/bun-db/query.ts +160 -23
- package/src/changes.json +54 -0
- package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
- package/src/crypto/event-pii.ts +20 -4
- package/src/crypto/system-event-pii.ts +57 -0
- package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
- package/src/db/queries/backfill-pii.ts +184 -85
- package/src/db/queries/pii-backfill-state.ts +33 -0
- package/src/engine/__tests__/build-app-schema.test.ts +26 -0
- package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
- package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
- package/src/engine/build-app-schema.ts +2 -0
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +52 -0
- package/src/engine/index.ts +5 -0
- package/src/engine/steps/describe-workflow-step-error.ts +6 -0
- package/src/engine/steps/mail-send.ts +5 -1
- package/src/engine/steps/retry.ts +13 -1
- package/src/engine/steps/webhook-send.ts +6 -16
- package/src/engine/types/index.ts +8 -0
- package/src/event-store/index.ts +4 -0
- package/src/event-store/provenance-append.ts +1 -1
- package/src/event-store/transfer.ts +2 -2
- package/src/i18n/required-surface-keys.ts +8 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
- package/src/jobs/job-runner.ts +18 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +45 -3
- package/src/migrations/__tests__/pii-event-backfill.integration.test.ts +457 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/pending-rebuilds.ts +29 -3
- package/src/migrations/pii-event-backfill.ts +422 -0
- package/src/pipeline/append-event-core.ts +1 -8
- package/src/pipeline/irreversible-operation-gate.ts +4 -3
- package/src/pipeline/msp-rebuild.ts +7 -1
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +65 -0
- package/src/search/__tests__/search-adapter.test.ts +15 -0
- package/src/search/in-memory-adapter.ts +6 -0
- package/src/search/meilisearch-adapter.ts +24 -0
- package/src/stack/test-stack.ts +26 -2
- 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.
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 {
|
package/src/event-store/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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, {
|