@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
|
@@ -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
|
|
91
|
-
expect(Object.keys(
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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"
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|