@byline/core 6.12.0 → 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 +6 -6
@@ -0,0 +1,93 @@
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
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
9
+ export function isPlainObject(value) {
10
+ if (typeof value !== 'object' || value === null)
11
+ return false;
12
+ const prototype = Object.getPrototypeOf(value);
13
+ return prototype === Object.prototype || prototype === null;
14
+ }
15
+ /** A string that both has the form of an ISO 8601 date-time and parses as one. */
16
+ export function isIsoDateTime(value) {
17
+ return typeof value === 'string' && ISO_DATE_TIME.test(value) && !Number.isNaN(Date.parse(value));
18
+ }
19
+ export const isCount = (value) => Number.isSafeInteger(value) && value >= 0;
20
+ /**
21
+ * Whether every slot of an array passes a check. It reads slots by index because
22
+ * `every` skips holes, and it stops at the first failure, so a very long sparse array
23
+ * cannot stall it.
24
+ */
25
+ export function everySlot(value, check) {
26
+ if (!Array.isArray(value))
27
+ return false;
28
+ for (let i = 0; i < value.length; i += 1) {
29
+ if (!check(value[i]))
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ /**
35
+ * Sorted keys and no insignificant whitespace, for hashing a configuration. `undefined`
36
+ * object members are dropped, so an unset option hashes like an absent one. Any other
37
+ * value JSON cannot represent exactly is rejected with a `TypeError` rather than coerced,
38
+ * because two different values must never canonicalise the same. That includes array
39
+ * holes and symbol keys.
40
+ */
41
+ export function canonicalJson(value) {
42
+ return canonicalise(value, [], true);
43
+ }
44
+ function canonicalise(value, ancestors, dropUndefined) {
45
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
46
+ return JSON.stringify(value);
47
+ }
48
+ if (typeof value === 'number') {
49
+ if (!Number.isFinite(value))
50
+ throw new TypeError(`${value} is not a JSON value`);
51
+ return JSON.stringify(value);
52
+ }
53
+ if (typeof value !== 'object') {
54
+ throw new TypeError(`A ${typeof value} is not a JSON value`);
55
+ }
56
+ if (ancestors.includes(value))
57
+ throw new TypeError('A cycle is not a JSON value');
58
+ const path = [...ancestors, value];
59
+ if (Array.isArray(value)) {
60
+ // Indexed, because `map` skips holes. A hole reads as `undefined`, which is rejected.
61
+ const items = [];
62
+ for (let i = 0; i < value.length; i += 1) {
63
+ items.push(canonicalise(value[i], path, dropUndefined));
64
+ }
65
+ return `[${items.join(',')}]`;
66
+ }
67
+ const prototype = Object.getPrototypeOf(value);
68
+ if (prototype !== Object.prototype && prototype !== null) {
69
+ throw new TypeError('Only plain objects are JSON values');
70
+ }
71
+ if (Object.getOwnPropertySymbols(value).length > 0) {
72
+ throw new TypeError('A symbol key is not a JSON value');
73
+ }
74
+ const members = Object.entries(value)
75
+ .filter(([, member]) => !dropUndefined || member !== undefined)
76
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
77
+ .map(([key, member]) => `${JSON.stringify(key)}:${canonicalise(member, path, dropUndefined)}`);
78
+ return `{${members.join(',')}}`;
79
+ }
80
+ /**
81
+ * Whether a value is a strict `JsonValue`. Unlike `canonicalJson`, it rejects an
82
+ * `undefined` object member, which would be lost on persistence.
83
+ */
84
+ export function isJsonValue(value) {
85
+ try {
86
+ canonicalise(value, [], false);
87
+ return true;
88
+ }
89
+ catch {
90
+ // Includes a RangeError from a structure nested too deeply to walk.
91
+ return false;
92
+ }
93
+ }
@@ -0,0 +1,20 @@
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 type { EligibilityUpdateOutcome, ExclusionState, ProjectionOutcome } from '../@types/extraction-types.js';
9
+ /**
10
+ * Derives whether ineligible text has left search. Reporting surfaces must not
11
+ * compute this any other way. `projection` is the outcome of the first write
12
+ * attempted after the eligibility update.
13
+ *
14
+ * When the eligibility update failed, the join still includes the old text, so a
15
+ * successful search write leaves it searchable. That case is reported as pending,
16
+ * never as excluded.
17
+ *
18
+ * @provisional
19
+ */
20
+ export declare function exclusionState(eligibility: EligibilityUpdateOutcome, projection: ProjectionOutcome | undefined): ExclusionState;
@@ -0,0 +1,30 @@
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
+ /**
9
+ * Derives whether ineligible text has left search. Reporting surfaces must not
10
+ * compute this any other way. `projection` is the outcome of the first write
11
+ * attempted after the eligibility update.
12
+ *
13
+ * When the eligibility update failed, the join still includes the old text, so a
14
+ * successful search write leaves it searchable. That case is reported as pending,
15
+ * never as excluded.
16
+ *
17
+ * @provisional
18
+ */
19
+ export function exclusionState(eligibility, projection) {
20
+ if (eligibility.kind === 'not-required')
21
+ return { state: 'not-required' };
22
+ if (eligibility.kind === 'failed') {
23
+ return { state: 'pending', reason: 'eligibility-update-failed' };
24
+ }
25
+ if (projection === undefined)
26
+ return { state: 'pending', reason: 'not-attempted' };
27
+ return projection.kind === 'written'
28
+ ? { state: 'excluded' }
29
+ : { state: 'pending', reason: projection.reason };
30
+ }
@@ -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,55 @@
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 { exclusionState } from './outcomes.js';
10
+ const written = { kind: 'written' };
11
+ const pending = (reason) => ({
12
+ kind: 'pending',
13
+ reason,
14
+ });
15
+ describe('exclusionState', () => {
16
+ it.each([
17
+ ['written', written],
18
+ ['pending', pending('write-failed')],
19
+ ['not attempted', undefined],
20
+ ])('is not-required when no update was required and projection is %s', (_label, projection) => {
21
+ expect(exclusionState({ kind: 'not-required' }, projection)).toEqual({ state: 'not-required' });
22
+ });
23
+ it('reports pending when the eligibility mark failed and the search write succeeded', () => {
24
+ // The regression this rule exists for: the join still includes the old text, so
25
+ // a successful write leaves it searchable.
26
+ expect(exclusionState({ kind: 'failed' }, written)).toEqual({
27
+ state: 'pending',
28
+ reason: 'eligibility-update-failed',
29
+ });
30
+ });
31
+ it.each([
32
+ ['pending', pending('write-failed')],
33
+ ['not attempted', undefined],
34
+ ])('reports pending when the mark failed and projection is %s', (_label, projection) => {
35
+ expect(exclusionState({ kind: 'failed' }, projection)).toEqual({
36
+ state: 'pending',
37
+ reason: 'eligibility-update-failed',
38
+ });
39
+ });
40
+ it('reports excluded when the mark was recorded and the write acknowledged', () => {
41
+ expect(exclusionState({ kind: 'recorded' }, written)).toEqual({ state: 'excluded' });
42
+ });
43
+ it.each(['write-failed', 'not-attempted', 'stale-write'])('carries the %s reason when the mark was recorded', (reason) => {
44
+ expect(exclusionState({ kind: 'recorded' }, pending(reason))).toEqual({
45
+ state: 'pending',
46
+ reason,
47
+ });
48
+ });
49
+ it('reports not-attempted when the mark was recorded and no write followed', () => {
50
+ expect(exclusionState({ kind: 'recorded' }, undefined)).toEqual({
51
+ state: 'pending',
52
+ reason: 'not-attempted',
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,40 @@
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
+ /**
9
+ * A code for one broken page map rule.
10
+ *
11
+ * @provisional
12
+ */
13
+ export type PageMapIssueCode = 'invalid-shape' | 'invalid-version' | 'invalid-separator' | 'invalid-trailing' | 'invalid-offset' | 'surrogate-split' | 'invalid-page-index' | 'page-count-mismatch' | 'page-overlap' | 'separator-mismatch' | 'text-outside-ranges' | 'unanchored-overlap' | 'unanchored-before-last-page' | 'unanchored-after-last-page' | 'block-unknown-page' | 'block-outside-page' | 'block-overlap';
14
+ /**
15
+ * One broken page map rule, and where it broke.
16
+ *
17
+ * @provisional
18
+ */
19
+ export interface PageMapIssue {
20
+ code: PageMapIssueCode;
21
+ /** For example `pages[1]`. */
22
+ path?: string;
23
+ message: string;
24
+ }
25
+ /**
26
+ * Checks the structure of a page map against the exact text it describes.
27
+ *
28
+ * Structural validity is not provenance. Coverage does not prove that page 3's text
29
+ * came from physical page 3; only sentinel verification against a fixture does.
30
+ *
31
+ * @provisional
32
+ */
33
+ export declare function validatePageMap(plainText: string, map: unknown, options?: {
34
+ pageCount?: number;
35
+ }): {
36
+ ok: true;
37
+ } | {
38
+ ok: false;
39
+ issues: PageMapIssue[];
40
+ };
@@ -0,0 +1,190 @@
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 { everySlot } from './internal.js';
9
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ const overlaps = (a, b) => a.charStart < b.charEnd && b.charStart < a.charEnd;
11
+ function splitsSurrogatePair(text, boundary) {
12
+ if (boundary <= 0 || boundary >= text.length)
13
+ return false;
14
+ const before = text.charCodeAt(boundary - 1);
15
+ const after = text.charCodeAt(boundary);
16
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
17
+ }
18
+ /**
19
+ * Checks the structure of a page map against the exact text it describes.
20
+ *
21
+ * Structural validity is not provenance. Coverage does not prove that page 3's text
22
+ * came from physical page 3; only sentinel verification against a fixture does.
23
+ *
24
+ * @provisional
25
+ */
26
+ export function validatePageMap(plainText, map, options) {
27
+ const issues = [];
28
+ const report = (code, message, path) => {
29
+ issues.push({ code, message, ...(path != null && { path }) });
30
+ };
31
+ const finish = () => issues.length === 0 ? { ok: true } : { ok: false, issues };
32
+ if (!isRecord(map)) {
33
+ report('invalid-shape', 'the page map is not an object');
34
+ return finish();
35
+ }
36
+ // Rule 1: version and separator.
37
+ if (map.version !== 1)
38
+ report('invalid-version', 'version must be 1', 'version');
39
+ const assembly = isRecord(map.assembly) ? map.assembly : {};
40
+ const separator = assembly.pageSeparator;
41
+ if (typeof separator !== 'string') {
42
+ report('invalid-separator', 'pageSeparator must be a string', 'assembly.pageSeparator');
43
+ }
44
+ const trailing = assembly.trailing;
45
+ if (trailing !== undefined && trailing !== 'embedded' && trailing !== 'none') {
46
+ report('invalid-trailing', "trailing must be 'embedded' or 'none'", 'assembly.trailing');
47
+ }
48
+ const listOf = (key, required) => {
49
+ const value = map[key];
50
+ if (value === undefined && !required)
51
+ return [];
52
+ if (!everySlot(value, isRecord)) {
53
+ report('invalid-shape', `${key} must be an array of objects, with no holes`, key);
54
+ return [];
55
+ }
56
+ return value;
57
+ };
58
+ const rawPages = listOf('pages', true);
59
+ const rawUnanchored = listOf('unanchored', false);
60
+ const rawBlocks = listOf('blocks', false);
61
+ rawPages.forEach((page, i) => {
62
+ if (page.label !== undefined && typeof page.label !== 'string') {
63
+ report('invalid-shape', 'a page label must be a string', `pages[${i}]`);
64
+ }
65
+ });
66
+ rawUnanchored.forEach((range, i) => {
67
+ if (typeof range.kind !== 'string') {
68
+ report('invalid-shape', 'an unanchored range needs a string kind', `unanchored[${i}]`);
69
+ }
70
+ });
71
+ rawBlocks.forEach((block, i) => {
72
+ if (typeof block.kind !== 'string') {
73
+ report('invalid-shape', 'a block needs a string kind', `blocks[${i}]`);
74
+ }
75
+ });
76
+ // Rules 2 and 3: every offset, in every list.
77
+ const offsetsAreValid = (entry, path) => {
78
+ const { charStart, charEnd } = entry;
79
+ if (!Number.isInteger(charStart) ||
80
+ !Number.isInteger(charEnd) ||
81
+ charStart < 0 ||
82
+ charStart > charEnd ||
83
+ charEnd > plainText.length) {
84
+ report('invalid-offset', 'offsets must be integers within the text, in order', path);
85
+ return false;
86
+ }
87
+ if (splitsSurrogatePair(plainText, charStart) ||
88
+ splitsSurrogatePair(plainText, charEnd)) {
89
+ report('surrogate-split', 'a boundary splits a UTF-16 surrogate pair', path);
90
+ }
91
+ return true;
92
+ };
93
+ const allOffsetsValid = [
94
+ ...rawPages.map((entry, i) => offsetsAreValid(entry, `pages[${i}]`)),
95
+ ...rawUnanchored.map((entry, i) => offsetsAreValid(entry, `unanchored[${i}]`)),
96
+ ...rawBlocks.map((entry, i) => offsetsAreValid(entry, `blocks[${i}]`)),
97
+ ].every(Boolean);
98
+ // Rule 4: page indexes.
99
+ let previousIndex = 0;
100
+ rawPages.forEach((page, i) => {
101
+ const { index } = page;
102
+ if (!Number.isInteger(index) || index <= previousIndex) {
103
+ report('invalid-page-index', 'indexes must be positive and strictly increase', `pages[${i}]`);
104
+ }
105
+ if (Number.isInteger(index))
106
+ previousIndex = Math.max(previousIndex, index);
107
+ });
108
+ if (options?.pageCount !== undefined) {
109
+ const exact = rawPages.length === options.pageCount && rawPages.every((page, i) => page.index === i + 1);
110
+ if (!exact) {
111
+ report('page-count-mismatch', `indexes must be exactly 1 to ${options.pageCount}; an empty page is a zero-length range`, 'pages');
112
+ }
113
+ }
114
+ // The remaining rules compare ranges and text, so they need sound offsets.
115
+ if (!allOffsetsValid || typeof separator !== 'string')
116
+ return finish();
117
+ const pages = rawPages;
118
+ const unanchored = rawUnanchored;
119
+ const blocks = rawBlocks;
120
+ // Rule 5: pages are ordered and do not overlap.
121
+ for (let i = 1; i < pages.length; i += 1) {
122
+ const previous = pages[i - 1];
123
+ const page = pages[i];
124
+ if (page.charStart < previous.charEnd) {
125
+ report('page-overlap', 'pages must be in text order and must not overlap', `pages[${i}]`);
126
+ }
127
+ }
128
+ // Rule 8: unanchored ranges.
129
+ unanchored.forEach((range, i) => {
130
+ if (pages.some((page) => overlaps(range, page))) {
131
+ report('unanchored-overlap', 'an unanchored range overlaps a page', `unanchored[${i}]`);
132
+ }
133
+ if (unanchored.slice(0, i).some((other) => overlaps(range, other))) {
134
+ report('unanchored-overlap', 'unanchored ranges overlap', `unanchored[${i}]`);
135
+ }
136
+ });
137
+ const lastPage = pages.at(-1);
138
+ if (lastPage !== undefined) {
139
+ unanchored.forEach((range, i) => {
140
+ if (trailing === 'embedded' && range.charStart < lastPage.charEnd) {
141
+ report('unanchored-before-last-page', "with trailing 'embedded', unanchored ranges follow the last page", `unanchored[${i}]`);
142
+ }
143
+ if (trailing !== 'embedded' && range.charStart >= lastPage.charEnd) {
144
+ report('unanchored-after-last-page', "without trailing 'embedded', no unanchored range follows the last page", `unanchored[${i}]`);
145
+ }
146
+ });
147
+ }
148
+ // Rules 6 and 7: complete coverage, in text order across both kinds of range.
149
+ const ranges = [...pages, ...unanchored].sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
150
+ const first = ranges[0];
151
+ const last = ranges.at(-1);
152
+ if (first === undefined || last === undefined) {
153
+ if (plainText.length > 0)
154
+ report('text-outside-ranges', 'the text has no ranges');
155
+ }
156
+ else {
157
+ if (first.charStart !== 0)
158
+ report('text-outside-ranges', 'text precedes the first range');
159
+ if (last.charEnd !== plainText.length) {
160
+ report('text-outside-ranges', 'text follows the last range');
161
+ }
162
+ }
163
+ for (let i = 1; i < ranges.length; i += 1) {
164
+ const previous = ranges[i - 1];
165
+ const range = ranges[i];
166
+ // An overlap was reported above; it has no gap to compare.
167
+ if (range.charStart < previous.charEnd)
168
+ continue;
169
+ if (plainText.slice(previous.charEnd, range.charStart) !== separator) {
170
+ report('separator-mismatch', `the text between offsets ${previous.charEnd} and ${range.charStart} is not the separator`);
171
+ }
172
+ }
173
+ // Rule 9: blocks.
174
+ blocks.forEach((block, i) => {
175
+ const page = pages.find((candidate) => candidate.index === block.page);
176
+ const path = `blocks[${i}]`;
177
+ if (page === undefined) {
178
+ report('block-unknown-page', `block names page ${String(block.page)}, which is absent`, path);
179
+ return;
180
+ }
181
+ if (block.charStart < page.charStart || block.charEnd > page.charEnd) {
182
+ report('block-outside-page', 'a block lies outside the page it names', path);
183
+ }
184
+ const earlier = blocks.slice(0, i).filter((other) => other.page === block.page);
185
+ if (earlier.some((other) => overlaps(block, other))) {
186
+ report('block-overlap', 'blocks of one page overlap', path);
187
+ }
188
+ });
189
+ return finish();
190
+ }
@@ -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 {};