@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.
@@ -83,12 +83,29 @@ describe("documentIngestFoundationFeature — shape", () => {
83
83
  );
84
84
  });
85
85
 
86
- test("registers the fileRef.created MSP", () => {
86
+ test("registers the fileRef.created/restored MSP and the fileRef delete/forget + orphan-extract cleanup MSP", () => {
87
87
  expect(Object.keys(documentIngestFoundationFeature.multiStreamProjections)).toEqual([
88
88
  "request-ingest",
89
+ "forget-extract-with-file-ref",
89
90
  ]);
90
- const msp = documentIngestFoundationFeature.multiStreamProjections["request-ingest"];
91
- expect(Object.keys(msp?.apply ?? {})).toEqual(["fileRef.created"]);
91
+ const requestIngest = documentIngestFoundationFeature.multiStreamProjections["request-ingest"];
92
+ expect(Object.keys(requestIngest?.apply ?? {})).toEqual([
93
+ "fileRef.created",
94
+ "fileRef.restored",
95
+ ]);
96
+ const forgetExtract =
97
+ documentIngestFoundationFeature.multiStreamProjections["forget-extract-with-file-ref"];
98
+ expect(Object.keys(forgetExtract?.apply ?? {})).toEqual([
99
+ "fileRef.deleted",
100
+ "fileRef.forgotten",
101
+ "document-extract.created",
102
+ ]);
103
+ });
104
+
105
+ test("registers the documentIngestProvider extension point", () => {
106
+ expect(Object.keys(documentIngestFoundationFeature.registrarExtensions)).toContain(
107
+ "documentIngestProvider",
108
+ );
92
109
  });
93
110
  });
94
111
 
@@ -0,0 +1,204 @@
1
+ // r.bootCheck does NOT run inside setupTestStack, only createApp()'s
2
+ // validateBoot() — most boot-error assertions below call
3
+ // validateDocumentIngestProviderWiring directly; one drives real validateBoot().
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ defineFeature,
8
+ type JobTrigger,
9
+ type Registry,
10
+ validateBoot,
11
+ } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
13
+ import { createConfigFeature } from "../../config";
14
+ import { createTenantFeature } from "../../tenant/feature";
15
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
16
+ import { validateDocumentIngestProviderWiring } from "../boot-checks";
17
+ import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "../events";
18
+ import { documentIngestFoundationFeature } from "../feature";
19
+ import {
20
+ type DocumentIngestProviderOptions,
21
+ documentIngestProviderTrigger,
22
+ EXT_DOCUMENT_INGEST_PROVIDER,
23
+ listIngestibleMimeTypes,
24
+ resolveDocumentIngestProviders,
25
+ } from "../providers";
26
+
27
+ function makeProviderFeature(name: string, options: DocumentIngestProviderOptions) {
28
+ return defineFeature(`test-provider-${name}`, (r) => {
29
+ // requires() only matters to validateBoot's cross-feature checks — the
30
+ // direct resolveDocumentIngestProviders/validateDocumentIngestProviderWiring
31
+ // tests below never look at it.
32
+ r.requires("document-ingest-foundation");
33
+ r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, name, options);
34
+ });
35
+ }
36
+
37
+ function makeConsumerFeature(jobName: string, trigger: JobTrigger) {
38
+ return defineFeature(`test-consumer-${jobName}`, (r) => {
39
+ r.job(jobName, { trigger, runIn: "worker" }, async () => {});
40
+ });
41
+ }
42
+
43
+ describe("resolveDocumentIngestProviders", () => {
44
+ test("resolves a single provider's mimeTypes to itself", () => {
45
+ const feature = makeProviderFeature("alpha", {
46
+ mimeTypes: ["application/pdf", "image/png"],
47
+ maxFileBytes: 1000,
48
+ });
49
+
50
+ const resolved = resolveDocumentIngestProviders(feature.extensionUsages);
51
+
52
+ expect(resolved.size).toBe(2);
53
+ expect(resolved.get("application/pdf")).toEqual({ name: "alpha", maxFileBytes: 1000 });
54
+ expect(resolved.get("image/png")).toEqual({ name: "alpha", maxFileBytes: 1000 });
55
+ });
56
+
57
+ test("two providers claiming the same mimeType throws", () => {
58
+ const alpha = makeProviderFeature("alpha", {
59
+ mimeTypes: ["application/pdf"],
60
+ maxFileBytes: 1000,
61
+ });
62
+ const beta = makeProviderFeature("beta", {
63
+ mimeTypes: ["application/pdf"],
64
+ maxFileBytes: 2000,
65
+ });
66
+
67
+ expect(() =>
68
+ resolveDocumentIngestProviders([...alpha.extensionUsages, ...beta.extensionUsages]),
69
+ ).toThrow(/both claim mimeType "application\/pdf"/);
70
+ });
71
+
72
+ test("invalid options shape (bypassing the type system, e.g. a dynamic config) throws", () => {
73
+ const feature = makeProviderFeature(
74
+ "bad",
75
+ // Missing maxFileBytes — only reachable by a caller that bypasses the
76
+ // typed r.useExtension overload (e.g. hand-built dynamic config).
77
+ { mimeTypes: ["application/pdf"] } as DocumentIngestProviderOptions,
78
+ );
79
+
80
+ expect(() => resolveDocumentIngestProviders(feature.extensionUsages)).toThrow(
81
+ /registered invalid options/,
82
+ );
83
+ });
84
+ });
85
+
86
+ describe("listIngestibleMimeTypes", () => {
87
+ test("returns every registered provider's mimeTypes, sorted", () => {
88
+ const alpha = makeProviderFeature("alpha", {
89
+ mimeTypes: ["image/png", "application/pdf"],
90
+ maxFileBytes: 1000,
91
+ });
92
+ const beta = makeProviderFeature("beta", { mimeTypes: ["text/csv"], maxFileBytes: 500 });
93
+ // Minimal test double — the function only calls getExtensionUsages.
94
+ const registry = {
95
+ getExtensionUsages: () => [...alpha.extensionUsages, ...beta.extensionUsages],
96
+ } as unknown as Registry;
97
+
98
+ expect(listIngestibleMimeTypes(registry)).toEqual(["application/pdf", "image/png", "text/csv"]);
99
+ });
100
+ });
101
+
102
+ describe("documentIngestProviderTrigger", () => {
103
+ test("builds the {on, where: {provider}} shape every provider job must use", () => {
104
+ expect(documentIngestProviderTrigger("alpha")).toEqual({
105
+ on: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
106
+ where: { provider: "alpha" },
107
+ });
108
+ });
109
+ });
110
+
111
+ describe("validateDocumentIngestProviderWiring", () => {
112
+ test("zero providers, zero jobs — passes (uploads simply skip as unsupported-mime-type)", () => {
113
+ expect(() => validateDocumentIngestProviderWiring([])).not.toThrow();
114
+ });
115
+
116
+ test("one provider with a matching wired job — passes", () => {
117
+ const provider = makeProviderFeature("alpha", {
118
+ mimeTypes: ["application/pdf"],
119
+ maxFileBytes: 1000,
120
+ });
121
+ const consumer = makeConsumerFeature("process-alpha", documentIngestProviderTrigger("alpha"));
122
+
123
+ expect(() => validateDocumentIngestProviderWiring([provider, consumer])).not.toThrow();
124
+ });
125
+
126
+ test("a job triggers on documentIngest.requested without a provider filter — throws", () => {
127
+ const provider = makeProviderFeature("alpha", {
128
+ mimeTypes: ["application/pdf"],
129
+ maxFileBytes: 1000,
130
+ });
131
+ const consumer = makeConsumerFeature("process-anything", {
132
+ on: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
133
+ });
134
+
135
+ expect(() => validateDocumentIngestProviderWiring([provider, consumer])).toThrow(
136
+ /without a provider filter/,
137
+ );
138
+ });
139
+
140
+ test("a job's where.provider doesn't match any registered provider — throws", () => {
141
+ const provider = makeProviderFeature("alpha", {
142
+ mimeTypes: ["application/pdf"],
143
+ maxFileBytes: 1000,
144
+ });
145
+ const consumer = makeConsumerFeature(
146
+ "process-ghost",
147
+ documentIngestProviderTrigger("ghost-provider"),
148
+ );
149
+
150
+ expect(() => validateDocumentIngestProviderWiring([provider, consumer])).toThrow(
151
+ /filters documentIngest\.requested to provider "ghost-provider"/,
152
+ );
153
+ });
154
+
155
+ test("a registered provider with no job wired to it — throws", () => {
156
+ const provider = makeProviderFeature("alpha", {
157
+ mimeTypes: ["application/pdf"],
158
+ maxFileBytes: 1000,
159
+ });
160
+
161
+ expect(() => validateDocumentIngestProviderWiring([provider])).toThrow(
162
+ /Provider "alpha" is registered.*but no job triggers/,
163
+ );
164
+ });
165
+ });
166
+
167
+ describe("validateBoot — proves the feature actually registers the bootCheck", () => {
168
+ test("two providers colliding on a case-insensitive mimeType fail real boot, not just a direct validateDocumentIngestProviderWiring call", () => {
169
+ const providerA = makeProviderFeature("provider-a", {
170
+ mimeTypes: ["application/pdf"],
171
+ maxFileBytes: 1000,
172
+ });
173
+ const providerAConsumer = makeConsumerFeature(
174
+ "process-provider-a",
175
+ documentIngestProviderTrigger("provider-a"),
176
+ );
177
+ const providerB = makeProviderFeature("provider-b", {
178
+ // Same mimeType as provider-a modulo case — normalizeMimeType
179
+ // lowercases both, so this must collide exactly like an exact-match would.
180
+ mimeTypes: ["Application/PDF"],
181
+ maxFileBytes: 1000,
182
+ });
183
+ const providerBConsumer = makeConsumerFeature(
184
+ "process-provider-b",
185
+ documentIngestProviderTrigger("provider-b"),
186
+ );
187
+
188
+ expect(() =>
189
+ validateBoot([
190
+ createConfigFeature(),
191
+ createTenantFeature(),
192
+ createComplianceProfilesFeature(),
193
+ createTenantLifecycleFeature(),
194
+ documentIngestFoundationFeature,
195
+ providerA,
196
+ providerAConsumer,
197
+ providerB,
198
+ providerBConsumer,
199
+ ]),
200
+ ).toThrow(
201
+ /\[Feature document-ingest-foundation\] r\.bootCheck failed: .*both claim mimeType "application\/pdf"/,
202
+ );
203
+ });
204
+ });
@@ -0,0 +1,58 @@
1
+ // Every job triggered on documentIngest.requested must filter to exactly one
2
+ // registered provider, and every registered provider must have a job wired
3
+ // to it — otherwise requested ingests for that provider never get picked up.
4
+
5
+ import type { FeatureDefinition, JobDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "./events";
7
+ import { resolveDocumentIngestProviders } from "./providers";
8
+
9
+ // undefined = job isn't triggered on documentIngest.requested at all (not our
10
+ // concern here). "" = triggered without a usable where.provider filter.
11
+ function providerFilterOf(trigger: JobDefinition["trigger"]): string | undefined {
12
+ if (!("on" in trigger)) return undefined;
13
+ const on = Array.isArray(trigger.on) ? trigger.on : [trigger.on];
14
+ if (!on.includes(DOCUMENT_INGEST_REQUESTED_EVENT_QN)) return undefined;
15
+ const provider = trigger.where?.["provider"];
16
+ return typeof provider === "string" && provider.length > 0 ? provider : "";
17
+ }
18
+
19
+ export function validateDocumentIngestProviderWiring(features: readonly FeatureDefinition[]): void {
20
+ const allUsages = features.flatMap((f) => f.extensionUsages);
21
+ // Throws on invalid options / mimeType collisions.
22
+ const byMime = resolveDocumentIngestProviders(allUsages);
23
+ const registeredProviderNames = new Set([...byMime.values()].map((p) => p.name));
24
+
25
+ const wiredProviderNames = new Set<string>();
26
+ for (const feature of features) {
27
+ for (const jobDef of Object.values(feature.jobs)) {
28
+ const provider = providerFilterOf(jobDef.trigger);
29
+ if (provider === undefined) continue;
30
+ if (provider === "") {
31
+ throw new Error(
32
+ `[kumiko:boot] Job "${jobDef.name}" (feature "${feature.name}") triggers on ` +
33
+ `document-ingest-foundation's documentIngest.requested without a provider filter — every job on ` +
34
+ `this event must route through documentIngestProviderTrigger("<name>"), or it fires for every ` +
35
+ `provider's uploads. Use: trigger: documentIngestProviderTrigger("<name>").`,
36
+ );
37
+ }
38
+ if (!registeredProviderNames.has(provider)) {
39
+ throw new Error(
40
+ `[kumiko:boot] Job "${jobDef.name}" (feature "${feature.name}") filters documentIngest.requested to ` +
41
+ `provider "${provider}", but no feature registers that provider via ` +
42
+ `r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, "${provider}", ...). Known: ` +
43
+ `${[...registeredProviderNames].join(", ") || "<none>"}.`,
44
+ );
45
+ }
46
+ wiredProviderNames.add(provider);
47
+ }
48
+ }
49
+
50
+ for (const name of registeredProviderNames) {
51
+ if (wiredProviderNames.has(name)) continue;
52
+ throw new Error(
53
+ `[kumiko:boot] Provider "${name}" is registered via EXT_DOCUMENT_INGEST_PROVIDER but no job triggers on ` +
54
+ `documentIngestProviderTrigger("${name}") — files matching its mimeTypes would be requested for ingest ` +
55
+ `and never processed. Add an r.job with that trigger, or unmount the provider.`,
56
+ );
57
+ }
58
+ }
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.312.0",
4
+ "type": "breaking",
5
+ "title": "document-ingest-foundation is provider-driven; ALLOWED_MIME_TYPES/MAX_FILE_BYTES are gone",
6
+ "migration": "The hardcoded MIME allowlist and fixed size cap are removed. A provider feature (e.g. kumiko-enterprise's LiteParse) now registers via r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, \"<name>\", { mimeTypes, maxFileBytes }) and routes its r.job's trigger through documentIngestProviderTrigger(\"<name>\") instead of a bare { on: DOCUMENT_INGEST_REQUESTED_EVENT_QN }. A provider feature must also declare r.requires(\"document-ingest-foundation\") — validateBoot rejects r.useExtension(EXT_DOCUMENT_INGEST_PROVIDER, ...) without it as a missing feature dependency. Boot now throws if: two providers claim the same mimeType, a job triggers on documentIngest.requested without a provider filter, a job filters to an unregistered provider name, or a registered provider has no job wired to it. documentIngest.requested's payload gained a required provider field (event schema version 2; a migration upcasts existing v1 events to provider: \"unknown\", a sentinel that cannot match any real provider filter). A file whose mimeType has no registered provider now appends documentIngest.skipped with reason unsupported-mime-type instead of file-too-large. A fileRef.deleted or fileRef.forgotten event now forgets that fileRef's documentExtract row (new forget-extract-with-file-ref table-less MSP) — previously the extract silently outlived a deleted/forgotten source file. Mounting documentIngestFoundationFeature with zero providers is valid: every upload is simply skipped as unsupported-mime-type. Boot only fails for a misconfigured wiring (unfiltered job on documentIngest.requested, a job filtered to an unregistered provider name, two providers claiming the same mimeType, or a registered provider with no job wired to it)."
7
+ },
8
+ {
9
+ "version": "0.312.0",
10
+ "type": "fix",
11
+ "title": "document-ingest-foundation: no documentExtract survives a deleted/forgotten fileRef, even when a provider is still writing one concurrently",
12
+ "migration": "New export writeDocumentExtractForLiveFileRef(input) — providers that write documentExtract rows themselves (own executor call on ctx.db, outside the request/response flow) should write through this helper instead of calling the executor directly, and only emit their ready/failed follow-up event when the result's `kind` is \"written\" (a \"skipped\" result means the fileRef was already deleted/forgotten and nothing was written). The forget-extract-with-file-ref MSP now also reacts to documentExtract's own created event: if the extract's fileRef is not live at processing time, the consumer forgets that extract immediately. This closes the race where a provider's write and the fileRef's delete/forget interleave in either order — regardless of whether the provider uses the new helper or writes directly, no extract can now outlive its fileRef. The NotFound-tolerance check in the forget path is now a plain `error.code === \"not_found\"` comparison (WriteResult.error is always a plain WriteErrorInfo object, never a KumikoError instance, so `instanceof` never matched here). Test seeds of documentExtract rows with made-up fileRefIds, in any test stack that mounts document-ingest-foundation with a running dispatcher (calls runOnce), are now actively forgotten by this guard once the dispatcher processes the seed's created event — existing such seed patterns need a live (unsoftdeleted) fileRef backing the seeded fileRefId, or must avoid running the dispatcher over that event."
13
+ },
14
+ {
15
+ "version": "0.312.0",
16
+ "type": "fix",
17
+ "title": "document-ingest-foundation: a restored fileRef is re-ingested, and fileRef.deleted no longer forgets an extract that is live again",
18
+ "migration": "The request-ingest MSP now also reacts to fileRef.restored, parsing the entity fields from the event's `previous` snapshot and routing through the same provider-resolution/skip logic as fileRef.created — a delete→restore round-trip re-requests documentIngest.requested instead of leaving the file without an extract. forget-extract-with-file-ref's fileRef.deleted handler now checks the fileRef's current liveness (new isFileRefLive helper) before forgetting: since request-ingest and forget-extract-with-file-ref poll via separate consumer cursors, a fileRef.deleted event processed after a later restore already made the fileRef live again would otherwise forget the extract belonging to the now-live file. fileRef.forgotten still forgets unconditionally — forget stays a final Art. 17 erasure, restore on a forgotten fileRef fails NotFound as before, and no re-ingest happens for it. No consumer/action changes required."
19
+ },
2
20
  {
3
21
  "version": "0.306.0",
4
22
  "type": "fix",
@@ -21,6 +21,9 @@ export const documentIngestRequestedPayloadSchema = z.object({
21
21
  fileName: z.string().min(1),
22
22
  mimeType: z.string().min(1),
23
23
  size: z.number().int().min(0),
24
+ // Which registered provider claimed this mimeType — the job-trigger
25
+ // where-filter every provider's job routes through (documentIngestProviderTrigger).
26
+ provider: z.string().min(1),
24
27
  });
25
28
  export type DocumentIngestRequestedPayload = z.infer<typeof documentIngestRequestedPayloadSchema>;
26
29
 
@@ -0,0 +1,12 @@
1
+ // Single shared instance — one createEventStoreExecutor per entity per
2
+ // process is the framework convention, reused by every write path outside
3
+ // the entity's own inline projection (tenant-destroy-hook, forget-extract-with-file-ref).
4
+
5
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
6
+ import { documentExtractEntity, documentExtractsTable } from "./entity";
7
+
8
+ export const documentExtractExecutor = createEventStoreExecutor(
9
+ documentExtractsTable,
10
+ documentExtractEntity,
11
+ { entityName: "document-extract" },
12
+ );
@@ -1,15 +1,8 @@
1
1
  // kumiko-feature-version: 1
2
2
  //
3
- // document-ingest-foundation — Phase-1 (MVP) skeleton for the shared
4
- // PDF/Scan/Image → normalized-text ingest primitive. Owns the
5
- // `documentExtract` entity + tenant-config, and (Theme A, kumiko-framework
6
- // #1497) the fileRef.created trigger: a multiStreamProjection that validates
7
- // mime/size and — on OK — appends `documentIngest.requested`, the anchor
8
- // event the Phase-2 worker job (kumiko-enterprise LiteParse provider) reacts
9
- // to (event-trigger, not job-trigger — `files` exports no write-handler ref
10
- // for r.job's trigger.on to hang off, files-post-processing recipe pattern).
11
- // LiteParse itself lands in kumiko-enterprise#273-275. See
12
- // CosmicDriftGameStudio/kumiko-framework#1495 for the full phase breakdown.
3
+ // Provider-driven PDF/Scan/Image → normalized-text ingest primitive. No
4
+ // hardcoded mime allowlist or size cap — providers register via
5
+ // EXT_DOCUMENT_INGEST_PROVIDER (see providers.ts).
13
6
 
14
7
  import { entityEventName } from "@cosmicdrift/kumiko-framework/db";
15
8
  import {
@@ -17,8 +10,11 @@ import {
17
10
  createTenantConfig,
18
11
  defineFeature,
19
12
  EXT_TENANT_DATA,
13
+ type MultiStreamApplyFn,
20
14
  } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { normalizeMimeType } from "@cosmicdrift/kumiko-framework/files";
21
16
  import * as z from "zod";
17
+ import { validateDocumentIngestProviderWiring } from "./boot-checks";
22
18
  import { documentExtractEntity } from "./entity";
23
19
  import {
24
20
  DOCUMENT_INGEST_AGGREGATE_TYPE,
@@ -29,39 +25,99 @@ import {
29
25
  documentIngestRequestedPayloadSchema,
30
26
  documentIngestSkippedPayloadSchema,
31
27
  } from "./events";
28
+ import {
29
+ forgetExtractOnFileRefDeletedHook,
30
+ forgetExtractOnFileRefForgottenHook,
31
+ forgetOrphanedDocumentExtractHook,
32
+ } from "./forget-extract-with-file-ref";
33
+ import { EXT_DOCUMENT_INGEST_PROVIDER, resolveDocumentIngestProviders } from "./providers";
32
34
  import { documentExtractTenantDestroyHook } from "./tenant-destroy-hook";
33
35
 
34
36
  const FEATURE_NAME = "document-ingest-foundation";
35
37
 
36
38
  const FILE_REF_CREATED = entityEventName("fileRef", "created");
39
+ const FILE_REF_RESTORED = entityEventName("fileRef", "restored");
40
+ const FILE_REF_DELETED = entityEventName("fileRef", "deleted");
41
+ const FILE_REF_FORGOTTEN = entityEventName("fileRef", "forgotten");
42
+ // Executors write under entityName "document-extract" (executor.ts), not the
43
+ // camelCase r.entity() name — the event type follows the executor.
44
+ const DOCUMENT_EXTRACT_CREATED = entityEventName("document-extract", "created");
45
+
46
+ type MspApplyContext = Parameters<MultiStreamApplyFn>[2];
37
47
 
38
- // Phase-1 scope (plan doc): PDF + raster images only. xlsx/docx/vision are
39
- // Phase 2, routed through separate providers, not this MSP.
40
- const ALLOWED_MIME_TYPES = new Set(["application/pdf", "image/png", "image/jpeg", "image/tiff"]);
41
-
42
- // ponytail: fixed cap, not tenant-config — the plan only calls out
43
- // maxPagesPerFile as tenant-configurable. Runs BEFORE the mime check for
44
- // the same reason it must run before isComplex() downstream: rejecting on
45
- // size is O(1), the checks after it are not (Spike: 699-page PDF). Chosen
46
- // on domain grounds — scanned invoices and official letters routinely land
47
- // in the 10-20mb range, well above file-routes.ts' unconstrained-upload
48
- // default (10mb); tests raise maxUploadSize instead of shrinking this.
49
- const MAX_FILE_BYTES = 25 * 1024 * 1024;
50
-
51
- // Narrow fileRef.created's generic entity payload at the MSP boundary.
52
- // Missing/invalid fields → skip (return), never cast — a bad size would
53
- // otherwise pass `undefined > MAX_FILE_BYTES` and a later appendDomainEvent
54
- // schema failure would dead the request-ingest consumer for all tenants.
55
- const fileRefCreatedPayloadSchema = z.object({
48
+ // Missing/invalid fields → skip, never cast — a bad size would otherwise
49
+ // pass a provider's maxFileBytes comparison and dead-letter the consumer.
50
+ // Shared by fileRef.created (the entity fields directly) and fileRef.restored
51
+ // (the same fields nested under `previous`, the soft-deleted snapshot).
52
+ const fileRefIngestFieldsSchema = z.object({
56
53
  storageKey: z.string().min(1),
57
54
  fileName: z.string().min(1),
58
55
  mimeType: z.string().min(1),
59
56
  size: z.number().int().min(0),
60
57
  });
58
+ type FileRefIngestFields = z.infer<typeof fileRefIngestFieldsSchema>;
59
+
60
+ // Shared by fileRef.created and fileRef.restored — a restored fileRef goes
61
+ // through the same provider routing/skip logic as a fresh upload, so a
62
+ // delete→restore round-trip re-requests ingest instead of leaving the file
63
+ // without an extract.
64
+ async function requestIngestForFileRef(
65
+ event: { readonly aggregateId: string },
66
+ fileRef: FileRefIngestFields,
67
+ ctx: MspApplyContext,
68
+ ): Promise<void> {
69
+ const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
70
+ ctx.unsafeAppendEvent({
71
+ aggregateId: event.aggregateId,
72
+ aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
73
+ type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
74
+ payload: {
75
+ fileRefId: event.aggregateId,
76
+ storageKey: fileRef.storageKey,
77
+ fileName: fileRef.fileName,
78
+ mimeType: fileRef.mimeType,
79
+ size: fileRef.size,
80
+ reason,
81
+ },
82
+ });
83
+
84
+ // Boot already refused an invalid/conflicting provider config — a
85
+ // throw here can only mean the registry changed since then, handled
86
+ // like any other apply throw (retry/dead-letter).
87
+ const providers = resolveDocumentIngestProviders(
88
+ ctx.registry.getExtensionUsages(EXT_DOCUMENT_INGEST_PROVIDER),
89
+ );
90
+ const provider = providers.get(normalizeMimeType(fileRef.mimeType));
91
+ // skip: no provider claims this mimeType — no ingest requested
92
+ if (!provider) {
93
+ await skip("unsupported-mime-type");
94
+ return;
95
+ }
96
+ // skip: over the winning provider's own cap — no ingest requested,
97
+ // upload itself already succeeded
98
+ if (fileRef.size > provider.maxFileBytes) {
99
+ await skip("file-too-large");
100
+ return;
101
+ }
102
+
103
+ await ctx.unsafeAppendEvent({
104
+ aggregateId: event.aggregateId,
105
+ aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
106
+ type: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
107
+ payload: {
108
+ fileRefId: event.aggregateId,
109
+ storageKey: fileRef.storageKey,
110
+ fileName: fileRef.fileName,
111
+ mimeType: fileRef.mimeType,
112
+ size: fileRef.size,
113
+ provider: provider.name,
114
+ },
115
+ });
116
+ }
61
117
 
62
118
  export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
63
119
  r.describe(
64
- "Shared PDF/Scan/Image → normalized-text ingest primitive. Owns the `documentExtract` entity (fileRefId, storageKey, per-page text + metadata) as an implicit entity-projection, the per-tenant `ocrLanguage`/`maxPagesPerFile` config keys, and a fileRef.created trigger that validates mime/size and requests ingest via `documentIngest.requested`. LiteParse provider wiring lands in follow-up features.",
120
+ "Shared PDF/Scan/Image → normalized-text ingest primitive. Owns the `documentExtract` entity (fileRefId, storageKey, per-page text + metadata) as an implicit entity-projection, the per-tenant `ocrLanguage`/`maxPagesPerFile` config keys, the `documentIngestProvider` extension-point providers register accepted mimeTypes/size caps under, and a fileRef.created/fileRef.restored trigger that resolves the provider for a file's mimeType and requests ingest via `documentIngest.requested` tagged with the winning provider — a delete→restore round-trip re-requests ingest the same way a fresh upload does. An orphan-extract guard forgets any `documentExtract` whose fileRef is no longer live at processing time, regardless of how a provider's own write races the fileRef's delete/forget — providers should write through `writeDocumentExtractForLiveFileRef`.",
65
121
  );
66
122
  r.uiHints({
67
123
  displayLabel: "Document Ingest Foundation",
@@ -69,7 +125,7 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
69
125
  recommended: false,
70
126
  });
71
127
  // tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
72
- // tenant-subject ciphertext and needs the destroy hook below (#1621).
128
+ // tenant-subject ciphertext and needs the destroy hook below.
73
129
  r.requires("config", "tenant-lifecycle");
74
130
 
75
131
  r.entity("documentExtract", documentExtractEntity);
@@ -77,6 +133,10 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
77
133
  destroy: documentExtractTenantDestroyHook,
78
134
  });
79
135
 
136
+ // No onRegister: resolution happens at boot and per-apply, not on register.
137
+ r.extendsRegistrar(EXT_DOCUMENT_INGEST_PROVIDER, {});
138
+ r.bootCheck(({ features }) => validateDocumentIngestProviderWiring(features));
139
+
80
140
  const ocrLanguageConfigKey = r.config(
81
141
  "ocrLanguage",
82
142
  createTenantConfig("text", {
@@ -103,9 +163,20 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
103
163
  );
104
164
 
105
165
  // "fileName" can carry a real person's name; the payload has no user-subject
106
- // field to encrypt it under, so piiFields stays "none" pending #2776.
166
+ // field to encrypt it under, so piiFields stays "none" for now.
107
167
  r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
108
168
  piiFields: "none",
169
+ version: 2,
170
+ migrations: [
171
+ {
172
+ fromVersion: 1,
173
+ toVersion: 2,
174
+ // "unknown" can never match a real where.provider filter — an
175
+ // upcast v1 row reads as unclaimed instead of silently misrouting to
176
+ // whichever provider happens to be mounted today.
177
+ transform: { default: { provider: "unknown" } },
178
+ },
179
+ ],
109
180
  });
110
181
  r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema, {
111
182
  piiFields: "none",
@@ -117,50 +188,32 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
117
188
  [FILE_REF_CREATED]: async (event, _tx, ctx) => {
118
189
  // entity-event payloads are generic Record<string, unknown> — parse
119
190
  // at the MSP boundary (same pattern as storage-tracking's readNumber).
120
- const parsed = fileRefCreatedPayloadSchema.safeParse(event.payload);
191
+ const parsed = fileRefIngestFieldsSchema.safeParse(event.payload);
121
192
  // skip: malformed / incomplete payload — don't poison the consumer
122
193
  if (!parsed.success) return;
123
- const payload = parsed.data;
124
-
125
- const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
126
- ctx.unsafeAppendEvent({
127
- aggregateId: event.aggregateId,
128
- aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
129
- type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
130
- payload: {
131
- fileRefId: event.aggregateId,
132
- storageKey: payload.storageKey,
133
- fileName: payload.fileName,
134
- mimeType: payload.mimeType,
135
- size: payload.size,
136
- reason,
137
- },
138
- });
139
-
140
- // skip: over the fixed cap — no ingest requested, upload itself already succeeded
141
- if (payload.size > MAX_FILE_BYTES) {
142
- await skip("file-too-large");
143
- return;
144
- }
145
- // skip: outside the Phase-1 mime allowlist — no ingest requested
146
- if (!ALLOWED_MIME_TYPES.has(payload.mimeType)) {
147
- await skip("unsupported-mime-type");
148
- return;
149
- }
150
-
151
- await ctx.unsafeAppendEvent({
152
- aggregateId: event.aggregateId,
153
- aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
154
- type: DOCUMENT_INGEST_REQUESTED_EVENT_QN,
155
- payload: {
156
- fileRefId: event.aggregateId,
157
- storageKey: payload.storageKey,
158
- fileName: payload.fileName,
159
- mimeType: payload.mimeType,
160
- size: payload.size,
161
- },
162
- });
194
+ await requestIngestForFileRef(event, parsed.data, ctx);
163
195
  },
196
+ [FILE_REF_RESTORED]: async (event, _tx, ctx) => {
197
+ // fileRef.restored carries { previous }, the pre-restore soft-deleted
198
+ // row (event-store-executor-write.ts restore()) — same field shape
199
+ // as fileRef.created's payload.
200
+ const parsed = fileRefIngestFieldsSchema.safeParse(event.payload["previous"]);
201
+ // skip: malformed / incomplete previous snapshot — don't poison the consumer
202
+ if (!parsed.success) return;
203
+ await requestIngestForFileRef(event, parsed.data, ctx);
204
+ },
205
+ },
206
+ });
207
+
208
+ // fileRef delete/forget cleanup: documentExtract rows are derived data —
209
+ // once the source file is gone (soft-deleted or Art.17-forgotten), the
210
+ // extracted text has no reason to survive it either.
211
+ r.multiStreamProjection({
212
+ name: "forget-extract-with-file-ref",
213
+ apply: {
214
+ [FILE_REF_DELETED]: forgetExtractOnFileRefDeletedHook,
215
+ [FILE_REF_FORGOTTEN]: forgetExtractOnFileRefForgottenHook,
216
+ [DOCUMENT_EXTRACT_CREATED]: forgetOrphanedDocumentExtractHook,
164
217
  },
165
218
  });
166
219
 
@@ -0,0 +1,10 @@
1
+ // db.fetchOne applies TenantDb's tenant predicate but no isDeleted filter
2
+ // (see event-store-executor-write.ts's restore()/forget() comments) — a
3
+ // soft-deleted fileRef still round-trips as a row here.
4
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
5
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
6
+
7
+ export async function isFileRefLive(tenantDb: TenantDb, fileRefId: string): Promise<boolean> {
8
+ const row = await tenantDb.fetchOne<{ isDeleted: boolean }>(fileRefsTable, { id: fileRefId });
9
+ return row !== undefined && row.isDeleted !== true;
10
+ }