@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.
@@ -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 { documentExtractEntity, documentExtractsTable } from "./entity";
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 executor.forget({ id: row.id }, user, ctx.db);
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 — polls the
353
- // same bounded window rather than betting on a fixed sleep outlasting it.
354
- await waitFor(() => errors >= 2, 100);
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 { sleep } from "@cosmicdrift/kumiko-framework/testing";
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
- for (let i = 0; i < 40 && (await getCount()) !== 2; i++) await sleep(200);
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
- for (let i = 0; i < 40; i++) {
160
- runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
161
- jobName: PROJECTION_REBUILD_JOB,
162
- });
163
- if (runs.some((r) => r.status === "completed")) break;
164
- await sleep(200);
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);