@byline/core 6.12.1 → 6.13.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.
Files changed (48) hide show
  1. package/dist/@types/extraction-types.d.ts +423 -0
  2. package/dist/@types/extraction-types.js +8 -0
  3. package/dist/@types/index.d.ts +1 -0
  4. package/dist/@types/index.js +1 -0
  5. package/dist/extraction/artifact.d.ts +46 -0
  6. package/dist/extraction/artifact.js +121 -0
  7. package/dist/extraction/artifact.test.node.d.ts +8 -0
  8. package/dist/extraction/artifact.test.node.js +194 -0
  9. package/dist/extraction/errors.d.ts +47 -0
  10. package/dist/extraction/errors.js +57 -0
  11. package/dist/extraction/errors.test.node.d.ts +8 -0
  12. package/dist/extraction/errors.test.node.js +68 -0
  13. package/dist/extraction/evidence.d.ts +22 -0
  14. package/dist/extraction/evidence.js +54 -0
  15. package/dist/extraction/evidence.test.node.d.ts +8 -0
  16. package/dist/extraction/evidence.test.node.js +138 -0
  17. package/dist/extraction/index.d.ts +14 -0
  18. package/dist/extraction/index.js +17 -0
  19. package/dist/extraction/internal.d.ts +30 -0
  20. package/dist/extraction/internal.js +93 -0
  21. package/dist/extraction/outcomes.d.ts +20 -0
  22. package/dist/extraction/outcomes.js +30 -0
  23. package/dist/extraction/outcomes.test.node.d.ts +8 -0
  24. package/dist/extraction/outcomes.test.node.js +55 -0
  25. package/dist/extraction/page-map.d.ts +40 -0
  26. package/dist/extraction/page-map.js +190 -0
  27. package/dist/extraction/page-map.test.node.d.ts +8 -0
  28. package/dist/extraction/page-map.test.node.js +371 -0
  29. package/dist/extraction/producer.d.ts +62 -0
  30. package/dist/extraction/producer.js +76 -0
  31. package/dist/extraction/producer.test.node.d.ts +8 -0
  32. package/dist/extraction/producer.test.node.js +230 -0
  33. package/dist/extraction/root-graph-guard.test.node.d.ts +8 -0
  34. package/dist/extraction/root-graph-guard.test.node.js +293 -0
  35. package/dist/extraction/work.d.ts +55 -0
  36. package/dist/extraction/work.js +102 -0
  37. package/dist/extraction/work.test.node.d.ts +8 -0
  38. package/dist/extraction/work.test.node.js +409 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +1 -0
  41. package/dist/storage/stored-files-server.d.ts +1 -0
  42. package/dist/storage/stored-files-server.js +9 -24
  43. package/dist/storage/stored-files-server.test.node.js +6 -0
  44. package/dist/storage/verified-read.d.ts +131 -0
  45. package/dist/storage/verified-read.js +328 -0
  46. package/dist/storage/verified-read.test.node.d.ts +8 -0
  47. package/dist/storage/verified-read.test.node.js +645 -0
  48. package/package.json +2 -2
@@ -0,0 +1,102 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { EXTRACTION_ARTIFACT_SCHEMA_VERSION } from './artifact.js';
9
+ import { isSourceVerificationEvidence } from './evidence.js';
10
+ import { assertDeploymentIdentifier } from './producer.js';
11
+ /**
12
+ * Decides whether a reference needs extraction, before any bytes are read.
13
+ *
14
+ * - A reference with no `fileHash` is an identity problem. `force` does not bypass it.
15
+ * - `force` always yields `work`. It bypasses reuse, never the verified read.
16
+ * - `reuse` needs a complete, verified artifact for the reference's hash, valid
17
+ * evidence for that hash, the same provider and configuration, a version that
18
+ * passes the version rules, and the current schema. Follow it with
19
+ * `probeVerifiedSource`.
20
+ * - A `null` current engine version never yields a version reason, so an unknown
21
+ * version cannot cause repeated work.
22
+ * - An artifact with a `null` `producerConfigHash` is historical output whose
23
+ * configuration was never recorded. Only that hash and a `null` recorded version go
24
+ * uncompared, and `reuse` then reports `producerUnchecked: true`. A provider
25
+ * mismatch, two known versions that differ, and every other reason apply unchanged.
26
+ *
27
+ * The decision is synchronous and cannot recompute the hash, so it trusts that
28
+ * `producer.deploymentIdentifier` is the value hashed into `producerConfigHash`.
29
+ * `buildExtractionProducer` guarantees that. It checks the identifier's form and
30
+ * throws a `TypeError` when malformed.
31
+ *
32
+ * Hosts may add their own reasons and treat the union as the decision.
33
+ *
34
+ * @provisional
35
+ */
36
+ export function decideExtractionWork(input) {
37
+ const { reference, producer, existing } = input;
38
+ if (producer.deploymentIdentifier !== null) {
39
+ assertDeploymentIdentifier(producer.deploymentIdentifier);
40
+ }
41
+ const fileHash = reference.fileHash;
42
+ if (fileHash === undefined || fileHash === null || fileHash === '') {
43
+ return { kind: 'identity-problem', problem: 'missing-hash' };
44
+ }
45
+ const versionUnchecked = producer.providerVersion === null && producer.deploymentIdentifier === null;
46
+ const reasons = new Set();
47
+ if (input.force === true)
48
+ reasons.add('force');
49
+ const artifact = existing?.artifact;
50
+ if (existing?.state === 'failed' || existing?.state === 'identity-problem') {
51
+ reasons.add('previous-attempt-unsuccessful');
52
+ }
53
+ else if (artifact === undefined) {
54
+ reasons.add('no-artifact');
55
+ }
56
+ let evidence;
57
+ if (artifact !== undefined) {
58
+ if (artifact.schemaVersion !== EXTRACTION_ARTIFACT_SCHEMA_VERSION)
59
+ reasons.add('schema-changed');
60
+ if (artifact.contentHash === null || !artifact.sourceVerified) {
61
+ reasons.add('unverified');
62
+ }
63
+ else if (artifact.contentHash !== fileHash) {
64
+ reasons.add('source-changed');
65
+ }
66
+ else if (isSourceVerificationEvidence(existing?.evidence, { expectedHash: fileHash })) {
67
+ evidence = existing?.evidence;
68
+ }
69
+ else {
70
+ reasons.add('unverified');
71
+ }
72
+ if (producerChanged(artifact, producer))
73
+ reasons.add('producer-changed');
74
+ }
75
+ if (reasons.size > 0 || evidence === undefined || artifact === undefined) {
76
+ return { kind: 'work', reasons: [...reasons], versionUnchecked };
77
+ }
78
+ return {
79
+ kind: 'reuse',
80
+ evidence,
81
+ versionUnchecked,
82
+ producerUnchecked: artifact.producerConfigHash === null,
83
+ };
84
+ }
85
+ function producerChanged(artifact, producer) {
86
+ if (artifact.provider !== producer.provider)
87
+ return true;
88
+ const recordedConfiguration = artifact.producerConfigHash !== null;
89
+ if (recordedConfiguration && artifact.producerConfigHash !== producer.producerConfigHash) {
90
+ return true;
91
+ }
92
+ // A null current version never yields a reason, so it cannot cause repeated work.
93
+ if (producer.providerVersion === null)
94
+ return false;
95
+ if (artifact.providerVersion === null) {
96
+ // Recorded before versions were known: work once, and the new artifact then
97
+ // records the version. Historical output with no recorded configuration is
98
+ // exempt, because adopting these contracts must not cause re-extraction.
99
+ return recordedConfiguration;
100
+ }
101
+ return artifact.providerVersion !== producer.providerVersion;
102
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,409 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { buildExtractionProducer } from './producer.js';
10
+ import { decideExtractionWork } from './work.js';
11
+ const HASH = 'a'.repeat(64);
12
+ const OTHER_HASH = 'c'.repeat(64);
13
+ const CONFIG_HASH = 'b'.repeat(64);
14
+ const OTHER_CONFIG_HASH = 'd'.repeat(64);
15
+ const EVIDENCE = {
16
+ expectedHash: HASH,
17
+ contentHash: HASH,
18
+ validator: { kind: 's3-etag', value: '"abc"' },
19
+ verifiedAt: '2026-10-31T06:00:00.000Z',
20
+ };
21
+ function producer(change) {
22
+ return {
23
+ provider: 'tika',
24
+ providerVersion: '3.0.0',
25
+ producerConfigHash: CONFIG_HASH,
26
+ deploymentIdentifier: null,
27
+ ...change,
28
+ };
29
+ }
30
+ function existing(artifact) {
31
+ return {
32
+ state: 'complete',
33
+ evidence: EVIDENCE,
34
+ artifact: {
35
+ contentHash: HASH,
36
+ sourceVerified: true,
37
+ provider: 'tika',
38
+ providerVersion: '3.0.0',
39
+ producerConfigHash: CONFIG_HASH,
40
+ schemaVersion: 1,
41
+ ...artifact,
42
+ },
43
+ };
44
+ }
45
+ const reference = { fileHash: HASH };
46
+ describe('decideExtractionWork', () => {
47
+ describe('source identity', () => {
48
+ it.each([
49
+ ['undefined', undefined],
50
+ ['null', null],
51
+ ['empty', ''],
52
+ ])('reports a %s fileHash as an identity problem, not a work reason', (_label, fileHash) => {
53
+ expect(decideExtractionWork({
54
+ reference: { fileHash },
55
+ producer: producer(),
56
+ existing: existing(),
57
+ })).toEqual({ kind: 'identity-problem', problem: 'missing-hash' });
58
+ });
59
+ it('does not let force bypass a missing hash', () => {
60
+ expect(decideExtractionWork({ force: true, reference: {}, producer: producer(), existing: null })).toEqual({ kind: 'identity-problem', problem: 'missing-hash' });
61
+ });
62
+ });
63
+ describe('reuse', () => {
64
+ it('reuses a complete, verified artifact for the same producer', () => {
65
+ expect(decideExtractionWork({ reference, producer: producer(), existing: existing() })).toEqual({
66
+ kind: 'reuse',
67
+ evidence: EVIDENCE,
68
+ versionUnchecked: false,
69
+ producerUnchecked: false,
70
+ });
71
+ });
72
+ });
73
+ describe('reasons', () => {
74
+ it.each([
75
+ ['no existing record', null, ['no-artifact']],
76
+ ['an undefined record', undefined, ['no-artifact']],
77
+ ['a complete record with no artifact', { state: 'complete' }, ['no-artifact']],
78
+ ['a failed attempt', { state: 'failed' }, ['previous-attempt-unsuccessful']],
79
+ [
80
+ 'an identity problem',
81
+ { state: 'identity-problem' },
82
+ ['previous-attempt-unsuccessful'],
83
+ ],
84
+ [
85
+ 'a failed attempt over a usable artifact',
86
+ { ...existing(), state: 'failed' },
87
+ ['previous-attempt-unsuccessful'],
88
+ ],
89
+ ['a different source', existing({ contentHash: OTHER_HASH }), ['source-changed']],
90
+ ['an unverified artifact', existing({ sourceVerified: false }), ['unverified']],
91
+ ['a legacy artifact', existing({ contentHash: null, sourceVerified: false }), ['unverified']],
92
+ ['missing evidence', { ...existing(), evidence: undefined }, ['unverified']],
93
+ ['copied-only evidence', { ...existing(), evidence: { expectedHash: HASH } }, ['unverified']],
94
+ [
95
+ 'evidence for another hash',
96
+ {
97
+ ...existing(),
98
+ evidence: { ...EVIDENCE, expectedHash: OTHER_HASH, contentHash: OTHER_HASH },
99
+ },
100
+ ['unverified'],
101
+ ],
102
+ ['a different provider', existing({ provider: 'docling' }), ['producer-changed']],
103
+ [
104
+ 'a different configuration',
105
+ existing({ producerConfigHash: OTHER_CONFIG_HASH }),
106
+ ['producer-changed'],
107
+ ],
108
+ ['an unknown schema version', existing({ schemaVersion: 2 }), ['schema-changed']],
109
+ ])('reports %s', (_label, record, reasons) => {
110
+ expect(decideExtractionWork({ reference, producer: producer(), existing: record })).toEqual({
111
+ kind: 'work',
112
+ reasons,
113
+ versionUnchecked: false,
114
+ });
115
+ });
116
+ it('reports several reasons once each', () => {
117
+ const decision = decideExtractionWork({
118
+ reference,
119
+ producer: producer(),
120
+ existing: existing({
121
+ provider: 'docling',
122
+ producerConfigHash: OTHER_CONFIG_HASH,
123
+ schemaVersion: 2,
124
+ }),
125
+ });
126
+ expect(decision).toEqual({
127
+ kind: 'work',
128
+ reasons: ['schema-changed', 'producer-changed'],
129
+ versionUnchecked: false,
130
+ });
131
+ });
132
+ it('does not add unverified for evidence of a source that changed anyway', () => {
133
+ const decision = decideExtractionWork({
134
+ reference: { fileHash: OTHER_HASH },
135
+ producer: producer(),
136
+ existing: existing(),
137
+ });
138
+ expect(decision).toEqual({
139
+ kind: 'work',
140
+ reasons: ['source-changed'],
141
+ versionUnchecked: false,
142
+ });
143
+ });
144
+ });
145
+ describe('force', () => {
146
+ it('yields work on reuse-eligible input', () => {
147
+ expect(decideExtractionWork({ force: true, reference, producer: producer(), existing: existing() })).toEqual({ kind: 'work', reasons: ['force'], versionUnchecked: false });
148
+ });
149
+ it('is reported with any other reasons', () => {
150
+ expect(decideExtractionWork({ force: true, reference, producer: producer(), existing: null })).toEqual({ kind: 'work', reasons: ['force', 'no-artifact'], versionUnchecked: false });
151
+ });
152
+ });
153
+ describe('engine version', () => {
154
+ it('reuses when the known versions are equal', () => {
155
+ expect(decideExtractionWork({ reference, producer: producer(), existing: existing() }).kind).toBe('reuse');
156
+ });
157
+ it('reports producer-changed when the known versions differ', () => {
158
+ expect(decideExtractionWork({
159
+ reference,
160
+ producer: producer({ providerVersion: '4.0.0' }),
161
+ existing: existing(),
162
+ })).toEqual({ kind: 'work', reasons: ['producer-changed'], versionUnchecked: false });
163
+ });
164
+ it('reports producer-changed when a recorded hash has a null version and the current is known', () => {
165
+ expect(decideExtractionWork({
166
+ reference,
167
+ producer: producer(),
168
+ existing: existing({ providerVersion: null }),
169
+ })).toEqual({ kind: 'work', reasons: ['producer-changed'], versionUnchecked: false });
170
+ });
171
+ it.each([
172
+ ['a recorded version', '3.0.0'],
173
+ ['no recorded version', null],
174
+ ])('gives no version reason with an unknown current version and %s', (_label, version) => {
175
+ expect(decideExtractionWork({
176
+ reference,
177
+ producer: producer({ providerVersion: null }),
178
+ existing: existing({ providerVersion: version }),
179
+ })).toEqual({
180
+ kind: 'reuse',
181
+ evidence: EVIDENCE,
182
+ versionUnchecked: true,
183
+ producerUnchecked: false,
184
+ });
185
+ });
186
+ it('reports versionUnchecked false when a deployment identifier is configured', () => {
187
+ expect(decideExtractionWork({
188
+ reference,
189
+ producer: producer({ providerVersion: null, deploymentIdentifier: 'image-a' }),
190
+ existing: existing({ providerVersion: null }),
191
+ })).toEqual({
192
+ kind: 'reuse',
193
+ evidence: EVIDENCE,
194
+ versionUnchecked: false,
195
+ producerUnchecked: false,
196
+ });
197
+ });
198
+ it('reports versionUnchecked on work too', () => {
199
+ expect(decideExtractionWork({
200
+ reference,
201
+ producer: producer({ providerVersion: null }),
202
+ existing: null,
203
+ })).toEqual({ kind: 'work', reasons: ['no-artifact'], versionUnchecked: true });
204
+ });
205
+ });
206
+ describe('deployment identifier form', () => {
207
+ it.each([
208
+ ['empty', ''],
209
+ ['untrimmed', ' image'],
210
+ ['not a string', 7],
211
+ ])('rejects an identifier that is %s', (_label, deploymentIdentifier) => {
212
+ expect(() => decideExtractionWork({
213
+ reference,
214
+ producer: producer({ deploymentIdentifier: deploymentIdentifier }),
215
+ })).toThrow(TypeError);
216
+ });
217
+ });
218
+ describe('historical output with no recorded producer', () => {
219
+ const historical = () => existing({ providerVersion: null, producerConfigHash: null });
220
+ it.each([
221
+ ['a known current version', '3.0.0', false],
222
+ ['an unknown current version', null, true],
223
+ ])('reuses without work, with %s', (_label, providerVersion, versionUnchecked) => {
224
+ expect(decideExtractionWork({
225
+ reference,
226
+ producer: producer({ providerVersion }),
227
+ existing: historical(),
228
+ })).toEqual({
229
+ kind: 'reuse',
230
+ evidence: EVIDENCE,
231
+ versionUnchecked,
232
+ producerUnchecked: true,
233
+ });
234
+ });
235
+ it('does not detect a changed deployment identifier', () => {
236
+ expect(decideExtractionWork({
237
+ reference,
238
+ producer: producer({ deploymentIdentifier: 'image-b' }),
239
+ existing: historical(),
240
+ })).toMatchObject({ kind: 'reuse', producerUnchecked: true });
241
+ });
242
+ it('still compares a recorded version with a known current one', () => {
243
+ expect(decideExtractionWork({
244
+ reference,
245
+ producer: producer({ providerVersion: '4.0.0' }),
246
+ existing: existing({ providerVersion: '3.0.0', producerConfigHash: null }),
247
+ })).toEqual({ kind: 'work', reasons: ['producer-changed'], versionUnchecked: false });
248
+ });
249
+ it('reuses when a recorded version equals the known current one', () => {
250
+ expect(decideExtractionWork({
251
+ reference,
252
+ producer: producer(),
253
+ existing: existing({ providerVersion: '3.0.0', producerConfigHash: null }),
254
+ })).toMatchObject({ kind: 'reuse', producerUnchecked: true });
255
+ });
256
+ it.each([
257
+ ['a provider mismatch', { provider: 'docling' }, 'producer-changed'],
258
+ ['a changed source hash', { contentHash: OTHER_HASH }, 'source-changed'],
259
+ ['an unverified artifact', { sourceVerified: false }, 'unverified'],
260
+ ['a legacy artifact', { contentHash: null, sourceVerified: false }, 'unverified'],
261
+ ['a different schema', { schemaVersion: 2 }, 'schema-changed'],
262
+ ])('still reports %s', (_label, change, reason) => {
263
+ expect(decideExtractionWork({
264
+ reference,
265
+ producer: producer(),
266
+ existing: existing({ providerVersion: null, producerConfigHash: null, ...change }),
267
+ })).toEqual({ kind: 'work', reasons: [reason], versionUnchecked: false });
268
+ });
269
+ it('still requires evidence', () => {
270
+ expect(decideExtractionWork({
271
+ reference,
272
+ producer: producer(),
273
+ existing: { ...historical(), evidence: undefined },
274
+ })).toEqual({ kind: 'work', reasons: ['unverified'], versionUnchecked: false });
275
+ });
276
+ it.each(['failed', 'identity-problem'])('still retries a %s state', (state) => {
277
+ expect(decideExtractionWork({
278
+ reference,
279
+ producer: producer(),
280
+ existing: { ...historical(), state },
281
+ })).toEqual({
282
+ kind: 'work',
283
+ reasons: ['previous-attempt-unsuccessful'],
284
+ versionUnchecked: false,
285
+ });
286
+ });
287
+ it('still honours force', () => {
288
+ expect(decideExtractionWork({
289
+ force: true,
290
+ reference,
291
+ producer: producer(),
292
+ existing: historical(),
293
+ })).toEqual({ kind: 'work', reasons: ['force'], versionUnchecked: false });
294
+ });
295
+ it('never reports producerUnchecked for a recorded configuration', () => {
296
+ const decision = decideExtractionWork({
297
+ reference,
298
+ producer: producer({ providerVersion: null }),
299
+ existing: existing({ providerVersion: null }),
300
+ });
301
+ expect(decision).toMatchObject({ kind: 'reuse', producerUnchecked: false });
302
+ });
303
+ });
304
+ describe('successive runs', () => {
305
+ // A small orchestration loop over the real helpers: each run decides, and work
306
+ // records an artifact the way the specification requires.
307
+ function createHarness(options) {
308
+ const provider = {
309
+ name: 'fake',
310
+ capabilities: {
311
+ ocr: false,
312
+ markdown: false,
313
+ tables: false,
314
+ formulas: false,
315
+ charts: false,
316
+ coordinates: false,
317
+ pageMap: false,
318
+ structure: false,
319
+ formats: ['*/*'],
320
+ },
321
+ ...(options.resolveVersion != null && { resolveVersion: options.resolveVersion }),
322
+ extract: async () => {
323
+ const version = options.engineVersion();
324
+ return {
325
+ provider: 'fake',
326
+ ...(version != null && { providerVersion: version }),
327
+ plainText: 'text',
328
+ metadata: {},
329
+ signals: { totalChars: 4 },
330
+ };
331
+ },
332
+ };
333
+ let record = null;
334
+ let extractions = 0;
335
+ return {
336
+ get extractions() {
337
+ return extractions;
338
+ },
339
+ async run(deploymentIdentifier) {
340
+ const resolved = (await provider.resolveVersion?.()) ?? null;
341
+ const current = await buildExtractionProducer({
342
+ provider,
343
+ providerVersion: resolved,
344
+ deploymentIdentifier,
345
+ });
346
+ const decision = decideExtractionWork({ reference, producer: current, existing: record });
347
+ if (decision.kind === 'work') {
348
+ extractions += 1;
349
+ const result = await provider.extract({ data: new Uint8Array() });
350
+ record = {
351
+ state: 'complete',
352
+ evidence: EVIDENCE,
353
+ artifact: {
354
+ contentHash: HASH,
355
+ sourceVerified: true,
356
+ provider: result.provider,
357
+ // The result's own version wins, then the version resolved for the run.
358
+ providerVersion: result.providerVersion ?? resolved,
359
+ producerConfigHash: current.producerConfigHash,
360
+ schemaVersion: 1,
361
+ },
362
+ };
363
+ }
364
+ return decision;
365
+ },
366
+ };
367
+ }
368
+ it('re-extracts once when a configured deployment identifier changes', async () => {
369
+ const harness = createHarness({ engineVersion: () => undefined });
370
+ const run1 = await harness.run('A');
371
+ const run2 = await harness.run('A');
372
+ const run3 = await harness.run('B');
373
+ const run4 = await harness.run('B');
374
+ expect(run1).toMatchObject({ kind: 'work', reasons: ['no-artifact'] });
375
+ expect(run2).toMatchObject({ kind: 'reuse', versionUnchecked: false });
376
+ expect(run3).toMatchObject({ kind: 'work', reasons: ['producer-changed'] });
377
+ expect(run4).toMatchObject({ kind: 'reuse', versionUnchecked: false });
378
+ expect(harness.extractions).toBe(2);
379
+ });
380
+ it('records the resolved version when the result reports none, then reuses', async () => {
381
+ const harness = createHarness({
382
+ engineVersion: () => undefined,
383
+ resolveVersion: async () => '3.0.0',
384
+ });
385
+ expect(await harness.run(null)).toMatchObject({ kind: 'work' });
386
+ expect(await harness.run(null)).toMatchObject({ kind: 'reuse', versionUnchecked: false });
387
+ expect(harness.extractions).toBe(1);
388
+ });
389
+ it('records the result version after a mid-run upgrade, then reuses', async () => {
390
+ let resolved = '3.0.0';
391
+ const harness = createHarness({
392
+ // The engine was upgraded while the first run was in progress.
393
+ engineVersion: () => '4.0.0',
394
+ resolveVersion: async () => resolved,
395
+ });
396
+ expect(await harness.run(null)).toMatchObject({ kind: 'work' });
397
+ resolved = '4.0.0';
398
+ expect(await harness.run(null)).toMatchObject({ kind: 'reuse' });
399
+ expect(harness.extractions).toBe(1);
400
+ });
401
+ it('never repeats work for a driver with no version and no identifier', async () => {
402
+ const harness = createHarness({ engineVersion: () => undefined });
403
+ await harness.run(null);
404
+ expect(await harness.run(null)).toMatchObject({ kind: 'reuse', versionUnchecked: true });
405
+ expect(await harness.run(null)).toMatchObject({ kind: 'reuse', versionUnchecked: true });
406
+ expect(harness.extractions).toBe(1);
407
+ });
408
+ });
409
+ });
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
8
8
  export { resolvePasskeyConfig } from './config/validate-passkey-config.js';
9
9
  export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
10
10
  export * from './defaults/default-values.js';
11
+ export * from './extraction/index.js';
11
12
  export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
12
13
  export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_DOCUMENT_STALE, ERR_LOCK_CONFLICT, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_STORAGE_PRECONDITION, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, getLockConflictDetails, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
13
14
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
20
20
  export { resolvePasskeyConfig } from './config/validate-passkey-config.js';
21
21
  export { getBylineCore, initBylineCore } from './core.js';
22
22
  export * from './defaults/default-values.js';
23
+ export * from './extraction/index.js';
23
24
  export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
24
25
  export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_DOCUMENT_HOOK_COMMITTED, ERR_DOCUMENT_STALE, ERR_LOCK_CONFLICT, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_STORAGE_PRECONDITION, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, getLockConflictDetails, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
25
26
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
@@ -8,6 +8,7 @@
8
8
  import type { IDbAdapter } from '../@types/db-types.js';
9
9
  import type { IStorageProvider, StoredFileLocation, UploadFileOptions } from '../@types/storage-types.js';
10
10
  import type { BylineLogger } from '../lib/logger.js';
11
+ export { probeVerifiedSource, readVerifiedSource, type SourceProbe, VERIFIED_SOURCE_SPOOL_PREFIX, type VerifiedSourceBody, type VerifiedSourceRead, } from './verified-read.js';
11
12
  /** SHA-256 (lower-case hex) and byte count of a stream, read to its end. */
12
13
  export declare function hashStream(stream: NodeJS.ReadableStream, options?: {
13
14
  signal?: AbortSignal;
@@ -11,30 +11,19 @@
11
11
  import { createHash } from 'node:crypto';
12
12
  import { Transform } from 'node:stream';
13
13
  import { ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
14
+ import { consumeStream, destroyStream } from './verified-read.js';
15
+ export { probeVerifiedSource, readVerifiedSource, VERIFIED_SOURCE_SPOOL_PREFIX, } from './verified-read.js';
14
16
  /** SHA-256 (lower-case hex) and byte count of a stream, read to its end. */
15
17
  export async function hashStream(stream, options) {
16
18
  const hash = createHash('sha256');
17
19
  let size = 0;
18
- const signal = options?.signal;
19
- if (signal?.aborted) {
20
- // An already-aborted signal never fires 'abort'; release the stream here.
21
- // Destroy without an error: nothing listens to this stream yet, so an
22
- // error argument would surface as an unhandled 'error' event.
23
- destroyStream(stream);
24
- signal.throwIfAborted();
25
- }
26
- const onAbort = () => destroyStream(stream, signal?.reason);
27
- signal?.addEventListener('abort', onAbort, { once: true });
28
- try {
29
- for await (const chunk of stream) {
30
- const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
31
- hash.update(bytes);
32
- size += bytes.length;
33
- }
34
- }
35
- finally {
36
- signal?.removeEventListener('abort', onAbort);
37
- }
20
+ await consumeStream(stream, {
21
+ ...(options?.signal !== undefined && { signal: options.signal }),
22
+ onChunk: (chunk) => {
23
+ hash.update(chunk);
24
+ size += chunk.length;
25
+ },
26
+ });
38
27
  return { fileHash: hash.digest('hex'), size };
39
28
  }
40
29
  /**
@@ -135,10 +124,6 @@ export async function uploadWithContentHash(storage, input, options) {
135
124
  input.removeListener('error', onInputError);
136
125
  return { location, fileHash: hash.digest('hex') };
137
126
  }
138
- function destroyStream(stream, reason) {
139
- const destroyable = stream;
140
- destroyable.destroy?.(reason instanceof Error ? reason : undefined);
141
- }
142
127
  /** Maximum entries kept in each summary path list. Counts are never capped. */
143
128
  const BACKFILL_REPORT_LIMIT = 1000;
144
129
  /**
@@ -61,6 +61,12 @@ const opts = (size, extra) => ({
61
61
  ...extra,
62
62
  });
63
63
  describe('hashStream', () => {
64
+ it('rejects when the signal fires as the stream ends', async () => {
65
+ const controller = new AbortController();
66
+ const stream = Readable.from([Buffer.from('abc')]);
67
+ stream.on('end', () => controller.abort(new Error('stop')));
68
+ await expect(hashStream(stream, { signal: controller.signal })).rejects.toThrow('stop');
69
+ });
64
70
  it('hashes a stream to its end and counts bytes', async () => {
65
71
  const bytes = Buffer.from('hello world');
66
72
  await expect(hashStream(Readable.from([bytes.subarray(0, 5), bytes.subarray(5)]))).resolves.toEqual({