@cosmicdrift/kumiko-bundled-features 0.311.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
+ }