@cosmicdrift/kumiko-bundled-features 0.310.0 → 0.312.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 +9 -9
- package/src/__tests__/extension-points-typed.test.ts +4 -0
- package/src/audit/__tests__/escape-hatch-audit.integration.test.ts +16 -12
- package/src/delivery/__tests__/delivery.integration.test.ts +21 -29
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
- package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
- package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
- package/src/document-ingest-foundation/boot-checks.ts +58 -0
- package/src/document-ingest-foundation/changes.json +18 -0
- package/src/document-ingest-foundation/events.ts +3 -0
- package/src/document-ingest-foundation/executor.ts +12 -0
- package/src/document-ingest-foundation/feature.ts +125 -72
- package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
- package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
- package/src/document-ingest-foundation/index.ts +17 -0
- package/src/document-ingest-foundation/providers.ts +78 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
- package/src/document-ingest-foundation/write-document-extract.ts +48 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +10 -12
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +11 -9
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// documentExtract is derived data — once its source fileRef is gone (soft
|
|
2
|
+
// delete or forget), the extract has no reason to survive it. A restored
|
|
3
|
+
// fileRef is re-ingested by feature.ts's request-ingest MSP
|
|
4
|
+
// (fileRef.restored), so a fresh extract replaces the forgotten one.
|
|
5
|
+
//
|
|
6
|
+
// forget(), not deleteMany: documentExtract is an ES-managed implicit
|
|
7
|
+
// projection, so forget() replays correctly on rebuild. This MSP itself has
|
|
8
|
+
// no `table`, so rebuildMultiStreamProjection never targets it directly.
|
|
9
|
+
//
|
|
10
|
+
// Also reacts to documentExtract.created: providers write extracts
|
|
11
|
+
// themselves (own executor call), racing this consumer's fileRef.deleted/
|
|
12
|
+
// forgotten handling. Each handler re-checks current fileRef liveness at
|
|
13
|
+
// processing time, so no extract survives regardless of write/delete order —
|
|
14
|
+
// this covers providers that bypass write-document-extract.ts's helper too.
|
|
15
|
+
|
|
16
|
+
import { createTenantDb, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import type { MultiStreamApplyFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import type { WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
|
|
20
|
+
import * as z from "zod";
|
|
21
|
+
import { documentExtractsTable } from "./entity";
|
|
22
|
+
import { documentExtractExecutor } from "./executor";
|
|
23
|
+
import { isFileRefLive } from "./file-ref-liveness";
|
|
24
|
+
|
|
25
|
+
// WriteResult.error is always a plain WriteErrorInfo object, never a
|
|
26
|
+
// KumikoError instance (JSON-serializable for the dispatcher's idempotency-key
|
|
27
|
+
// storage) — compare on .code, not instanceof.
|
|
28
|
+
function isNotFoundWriteError(error: WriteErrorInfo): boolean {
|
|
29
|
+
return error.code === "not_found";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function forgetExtractsForFileRef(
|
|
33
|
+
event: Parameters<MultiStreamApplyFn>[0],
|
|
34
|
+
tenantDb: TenantDb,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const rows = await tenantDb.selectMany<{ id: string }>(documentExtractsTable, {
|
|
37
|
+
fileRefId: event.aggregateId,
|
|
38
|
+
});
|
|
39
|
+
const user = createSystemUser(event.tenantId);
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
const result = await documentExtractExecutor.forget({ id: row.id }, user, tenantDb);
|
|
42
|
+
if (result.isSuccess) continue;
|
|
43
|
+
// Idempotent: a concurrent/redelivered apply that already forgot this
|
|
44
|
+
// row is not an error — anything else (version conflict, ownership
|
|
45
|
+
// denial) must surface so the dispatcher retries/dead-letters instead of
|
|
46
|
+
// silently leaving the extract behind.
|
|
47
|
+
if (isNotFoundWriteError(result.error)) continue;
|
|
48
|
+
throw new Error(
|
|
49
|
+
`document-ingest-foundation: failed to forget documentExtract ${row.id} for fileRef ${event.aggregateId}: ${result.error.message}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const documentExtractCreatedPayloadSchema = z.object({ fileRefId: z.string().min(1) });
|
|
55
|
+
|
|
56
|
+
export const forgetOrphanedDocumentExtractHook: MultiStreamApplyFn = async (event, tx) => {
|
|
57
|
+
const parsed = documentExtractCreatedPayloadSchema.safeParse(event.payload);
|
|
58
|
+
// skip: malformed payload — don't poison the consumer
|
|
59
|
+
if (!parsed.success) return;
|
|
60
|
+
const tenantDb = createTenantDb(tx, event.tenantId);
|
|
61
|
+
// skip: fileRef still live — the extract is legitimate
|
|
62
|
+
if (await isFileRefLive(tenantDb, parsed.data.fileRefId)) return;
|
|
63
|
+
const user = createSystemUser(event.tenantId);
|
|
64
|
+
const result = await documentExtractExecutor.forget({ id: event.aggregateId }, user, tenantDb);
|
|
65
|
+
// skip: forgotten now, or already gone via a concurrent/redelivered apply
|
|
66
|
+
if (result.isSuccess || isNotFoundWriteError(result.error)) return;
|
|
67
|
+
throw new Error(
|
|
68
|
+
`document-ingest-foundation: failed to forget orphaned documentExtract ${event.aggregateId}: ${result.error.message}`,
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const forgetExtractOnFileRefDeletedHook: MultiStreamApplyFn = async (event, tx) => {
|
|
73
|
+
const tenantDb = createTenantDb(tx, event.tenantId);
|
|
74
|
+
// skip: fileRef is live again — request-ingest and this consumer poll via
|
|
75
|
+
// separate cursors, so a delete→restore round-trip can finish before this
|
|
76
|
+
// consumer sees fileRef.deleted; forgetting would drop the live file's extract.
|
|
77
|
+
if (await isFileRefLive(tenantDb, event.aggregateId)) return;
|
|
78
|
+
await forgetExtractsForFileRef(event, tenantDb);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const forgetExtractOnFileRefForgottenHook: MultiStreamApplyFn = async (event, tx) => {
|
|
82
|
+
const tenantDb = createTenantDb(tx, event.tenantId);
|
|
83
|
+
await forgetExtractsForFileRef(event, tenantDb);
|
|
84
|
+
};
|
|
@@ -10,8 +10,25 @@ export {
|
|
|
10
10
|
DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
11
11
|
DOCUMENT_INGEST_REQUESTED_EVENT_QN,
|
|
12
12
|
DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
|
|
13
|
+
DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
14
|
+
DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
|
|
13
15
|
type DocumentIngestRequestedPayload,
|
|
16
|
+
type DocumentIngestSkippedPayload,
|
|
14
17
|
documentIngestRequestedPayloadSchema,
|
|
18
|
+
documentIngestSkippedPayloadSchema,
|
|
15
19
|
} from "./events";
|
|
16
20
|
export { documentIngestFoundationFeature } from "./feature";
|
|
17
21
|
export { readIngestPages, writeIngestPages } from "./pages";
|
|
22
|
+
export {
|
|
23
|
+
type DocumentIngestProviderOptions,
|
|
24
|
+
documentIngestProviderOptionsSchema,
|
|
25
|
+
documentIngestProviderTrigger,
|
|
26
|
+
EXT_DOCUMENT_INGEST_PROVIDER,
|
|
27
|
+
listIngestibleMimeTypes,
|
|
28
|
+
type ResolvedDocumentIngestProvider,
|
|
29
|
+
resolveDocumentIngestProviders,
|
|
30
|
+
} from "./providers";
|
|
31
|
+
export {
|
|
32
|
+
type DocumentExtractWriteResult,
|
|
33
|
+
writeDocumentExtractForLiveFileRef,
|
|
34
|
+
} from "./write-document-extract";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// document-ingest-foundation — provider extension-point. One shared
|
|
2
|
+
// documentIngest.requested QN fans out to every mounted provider's job,
|
|
3
|
+
// partitioned by documentIngestProviderTrigger's where-filter.
|
|
4
|
+
|
|
5
|
+
import type { FeatureDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "./events";
|
|
9
|
+
|
|
10
|
+
export const EXT_DOCUMENT_INGEST_PROVIDER = "documentIngestProvider" as const;
|
|
11
|
+
|
|
12
|
+
export const documentIngestProviderOptionsSchema = z.object({
|
|
13
|
+
mimeTypes: z.array(z.string().min(1)).min(1),
|
|
14
|
+
maxFileBytes: z.number().int().positive(),
|
|
15
|
+
});
|
|
16
|
+
export type DocumentIngestProviderOptions = z.infer<typeof documentIngestProviderOptionsSchema>;
|
|
17
|
+
|
|
18
|
+
// r.useExtension options-shape, co-located since the framework never imports upward.
|
|
19
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
20
|
+
interface KumikoExtensionOptionsMap {
|
|
21
|
+
[EXT_DOCUMENT_INGEST_PROVIDER]: DocumentIngestProviderOptions;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ResolvedDocumentIngestProvider = {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly maxFileBytes: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ExtensionUsages = FeatureDefinition["extensionUsages"];
|
|
31
|
+
|
|
32
|
+
// Throws on an invalid options shape or two providers claiming the same
|
|
33
|
+
// normalized mimeType — callers decide whether that's boot-fatal.
|
|
34
|
+
export function resolveDocumentIngestProviders(
|
|
35
|
+
usages: ExtensionUsages,
|
|
36
|
+
): ReadonlyMap<string, ResolvedDocumentIngestProvider> {
|
|
37
|
+
const byMime = new Map<string, ResolvedDocumentIngestProvider>();
|
|
38
|
+
for (const usage of usages) {
|
|
39
|
+
if (usage.extensionName !== EXT_DOCUMENT_INGEST_PROVIDER) continue;
|
|
40
|
+
const parsed = documentIngestProviderOptionsSchema.safeParse(usage.options);
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`document-ingest-foundation: provider "${usage.entityName}" registered invalid options via ` +
|
|
44
|
+
`r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, "${usage.entityName}", ...) — ${parsed.error.message}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
for (const mimeType of parsed.data.mimeTypes) {
|
|
48
|
+
const normalized = normalizeMimeType(mimeType);
|
|
49
|
+
const existing = byMime.get(normalized);
|
|
50
|
+
if (existing && existing.name !== usage.entityName) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`document-ingest-foundation: providers "${existing.name}" and "${usage.entityName}" both claim mimeType ` +
|
|
53
|
+
`"${normalized}" — each mimeType may have exactly one registered provider.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
byMime.set(normalized, { name: usage.entityName, maxFileBytes: parsed.data.maxFileBytes });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return byMime;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listIngestibleMimeTypes(registry: Registry): readonly string[] {
|
|
63
|
+
const usages = registry.getExtensionUsages(EXT_DOCUMENT_INGEST_PROVIDER);
|
|
64
|
+
return [...resolveDocumentIngestProviders(usages).keys()].sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type DocumentIngestProviderJobTrigger = {
|
|
68
|
+
readonly on: typeof DOCUMENT_INGEST_REQUESTED_EVENT_QN;
|
|
69
|
+
readonly where: { readonly provider: string };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// The trigger every provider's job must use — filters the shared
|
|
73
|
+
// documentIngest.requested QN down to just this provider's requests.
|
|
74
|
+
export function documentIngestProviderTrigger(
|
|
75
|
+
providerName: string,
|
|
76
|
+
): DocumentIngestProviderJobTrigger {
|
|
77
|
+
return { on: DOCUMENT_INGEST_REQUESTED_EVENT_QN, where: { provider: providerName } };
|
|
78
|
+
}
|
|
@@ -10,13 +10,9 @@
|
|
|
10
10
|
// (kumiko-framework#1495). The `pages` ciphertext dies separately when the
|
|
11
11
|
// pipeline's later `subject-keys` stage erases the tenant subject key.
|
|
12
12
|
|
|
13
|
-
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
14
13
|
import { createSystemUser, type TenantDataDestroyHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
const executor = createEventStoreExecutor(documentExtractsTable, documentExtractEntity, {
|
|
18
|
-
entityName: "document-extract",
|
|
19
|
-
});
|
|
14
|
+
import { documentExtractsTable } from "./entity";
|
|
15
|
+
import { documentExtractExecutor } from "./executor";
|
|
20
16
|
|
|
21
17
|
export const documentExtractTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
22
18
|
const rows = await ctx.db.selectMany<{ id: string }>(documentExtractsTable, {
|
|
@@ -24,7 +20,7 @@ export const documentExtractTenantDestroyHook: TenantDataDestroyHook = async (ct
|
|
|
24
20
|
});
|
|
25
21
|
const user = createSystemUser(ctx.tenantId);
|
|
26
22
|
for (const row of rows) {
|
|
27
|
-
const result = await
|
|
23
|
+
const result = await documentExtractExecutor.forget({ id: row.id }, user, ctx.db);
|
|
28
24
|
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
29
25
|
// discarded result would report this destroy stage "succeeded" while the
|
|
30
26
|
// extracted document text survives. Throw so the pipeline's retry/abandon
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Providers write documentExtract rows themselves (their own executor
|
|
2
|
+
// call on ctx.db) rather than through a request/response API, so this is
|
|
3
|
+
// the one place that write must go through to stay race-safe against a
|
|
4
|
+
// concurrent fileRef delete/forget — see forget-extract-with-file-ref.ts.
|
|
5
|
+
|
|
6
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import type { DocumentExtractMeta, IngestPage } from "./entity";
|
|
10
|
+
import { documentExtractExecutor } from "./executor";
|
|
11
|
+
import { isFileRefLive } from "./file-ref-liveness";
|
|
12
|
+
import { writeIngestPages } from "./pages";
|
|
13
|
+
|
|
14
|
+
export type DocumentExtractWriteResult =
|
|
15
|
+
| { readonly kind: "written"; readonly documentExtractId: string }
|
|
16
|
+
| { readonly kind: "skipped"; readonly reason: "file_ref_deleted" };
|
|
17
|
+
|
|
18
|
+
export async function writeDocumentExtractForLiveFileRef(input: {
|
|
19
|
+
readonly tenantDb: TenantDb;
|
|
20
|
+
readonly actor: SessionUser;
|
|
21
|
+
readonly fileRefId: string;
|
|
22
|
+
readonly storageKey: string;
|
|
23
|
+
readonly pages: readonly IngestPage[];
|
|
24
|
+
readonly meta: DocumentExtractMeta & Readonly<Record<string, unknown>>;
|
|
25
|
+
}): Promise<DocumentExtractWriteResult> {
|
|
26
|
+
// Early-skip optimization only — a fileRef deleted/forgotten right after
|
|
27
|
+
// this check still gets caught by forget-extract-with-file-ref.ts's
|
|
28
|
+
// documentExtract.created handler, which is the actual race-safety guarantee.
|
|
29
|
+
if (!(await isFileRefLive(input.tenantDb, input.fileRefId))) {
|
|
30
|
+
return { kind: "skipped", reason: "file_ref_deleted" };
|
|
31
|
+
}
|
|
32
|
+
const result = await documentExtractExecutor.create(
|
|
33
|
+
{
|
|
34
|
+
fileRefId: input.fileRefId,
|
|
35
|
+
storageKey: input.storageKey,
|
|
36
|
+
pages: writeIngestPages(input.pages),
|
|
37
|
+
meta: input.meta,
|
|
38
|
+
},
|
|
39
|
+
input.actor,
|
|
40
|
+
input.tenantDb,
|
|
41
|
+
);
|
|
42
|
+
if (!result.isSuccess) {
|
|
43
|
+
throw new InternalError({
|
|
44
|
+
message: `document-ingest-foundation: failed to write documentExtract for fileRef ${input.fileRefId}: ${result.error.message}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return { kind: "written", documentExtractId: String(result.data.id) };
|
|
48
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
6
|
import { EventEmitter } from "node:events";
|
|
7
7
|
import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
|
|
8
|
+
import { sleep, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
8
9
|
import {
|
|
9
10
|
type InboundMailContext,
|
|
10
11
|
isInboundAuthError,
|
|
@@ -152,13 +153,6 @@ const account: MailAccountRecord = {
|
|
|
152
153
|
watchState: "idle",
|
|
153
154
|
};
|
|
154
155
|
|
|
155
|
-
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
156
|
-
const t0 = Date.now();
|
|
157
|
-
while (!predicate() && Date.now() - t0 < timeoutMs) {
|
|
158
|
-
await Bun.sleep(5);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
156
|
function ctxWithDoc(doc: string | null): InboundMailContext {
|
|
163
157
|
return {
|
|
164
158
|
_userId: "imap-plugin-mocked",
|
|
@@ -332,7 +326,9 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
332
326
|
|
|
333
327
|
// drainNew() does MIME-parsing before onMessages fires — a fixed sleep
|
|
334
328
|
// flakes on a loaded CI runner; poll instead.
|
|
335
|
-
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed"))
|
|
329
|
+
await waitFor(() => received.some((batch) => batch.some((m) => m.subject === "pushed")), {
|
|
330
|
+
delays: Array(40).fill(5),
|
|
331
|
+
});
|
|
336
332
|
expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
|
|
337
333
|
|
|
338
334
|
await stop();
|
|
@@ -347,11 +343,13 @@ describe("imapInboundMailPlugin — mocked imapflow", () => {
|
|
|
347
343
|
},
|
|
348
344
|
});
|
|
349
345
|
lastIdleClient?.emit("error", new Error("socket hang up"));
|
|
350
|
-
await waitFor(() => errors >= 1);
|
|
346
|
+
await waitFor(() => errors >= 1, { delays: Array(40).fill(5) });
|
|
351
347
|
lastIdleClient?.emit("error", new Error("second"));
|
|
352
|
-
// Confirms onError stays unsubscribed after the first error
|
|
353
|
-
//
|
|
354
|
-
|
|
348
|
+
// Confirms onError stays unsubscribed after the first error. This is a
|
|
349
|
+
// grace period for a negative outcome, not a wait-for-true condition, so
|
|
350
|
+
// it's a single sleep (not a poll loop) rather than waitFor, which would
|
|
351
|
+
// throw once errors never reaches 2.
|
|
352
|
+
await sleep(100);
|
|
355
353
|
expect(errors).toBe(1);
|
|
356
354
|
await stop().catch(() => {});
|
|
357
355
|
});
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
unsafeCreateEntityTable,
|
|
41
41
|
unsafePushTables,
|
|
42
42
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
43
|
-
import {
|
|
43
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
44
44
|
import { createJobsFeature } from "../feature";
|
|
45
45
|
import { createJobRunLogger } from "../job-run-logger";
|
|
46
46
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
@@ -148,7 +148,7 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
// Poll until the worker drained the queue and the rebuild refilled.
|
|
151
|
-
|
|
151
|
+
await waitFor(async () => (await getCount()) === 2, { delays: Array(40).fill(200) });
|
|
152
152
|
expect(await getCount()).toBe(2);
|
|
153
153
|
|
|
154
154
|
// getCount()==2 only proves rebuildProjection's own writes landed — the
|
|
@@ -156,13 +156,15 @@ describe("projection-rebuild job (jobs feature composed)", () => {
|
|
|
156
156
|
// async append that starts only after the handler returns, so it can
|
|
157
157
|
// still be in flight here. Poll status too instead of racing it.
|
|
158
158
|
let runs: readonly { jobName: string; status: string }[] = [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
jobName:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
await waitFor(
|
|
160
|
+
async () => {
|
|
161
|
+
runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
|
|
162
|
+
jobName: PROJECTION_REBUILD_JOB,
|
|
163
|
+
});
|
|
164
|
+
return runs.some((r) => r.status === "completed");
|
|
165
|
+
},
|
|
166
|
+
{ delays: Array(40).fill(200) },
|
|
167
|
+
);
|
|
166
168
|
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
167
169
|
expect(runs.some((r) => r.status === "completed")).toBe(true);
|
|
168
170
|
}, 30000);
|