@byline/core 6.11.0 → 6.12.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/dist/@types/db-types.d.ts +68 -0
- package/dist/@types/field-data-types.d.ts +17 -0
- package/dist/@types/storage-types.d.ts +109 -1
- package/dist/@types/store-types.d.ts +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/errors.d.ts +8 -0
- package/dist/lib/errors.js +8 -0
- package/dist/schemas/zod/builder.js +10 -1
- package/dist/services/field-upload.js +19 -1
- package/dist/services/field-upload.test.node.js +50 -0
- package/dist/storage/content-hash.d.ts +23 -0
- package/dist/storage/content-hash.js +26 -0
- package/dist/storage/content-hash.test.node.d.ts +8 -0
- package/dist/storage/content-hash.test.node.js +67 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/storage-flatten-reconstruct.test.node.js +59 -0
- package/dist/storage/storage-flatten.js +2 -0
- package/dist/storage/storage-restore.js +13 -1
- package/dist/storage/storage-row-types.d.ts +4 -0
- package/dist/storage/store-manifest.js +10 -0
- package/dist/storage/store-manifest.test.node.js +5 -1
- package/dist/storage/stored-files-backfill.test.node.d.ts +8 -0
- package/dist/storage/stored-files-backfill.test.node.js +251 -0
- package/dist/storage/stored-files-server.d.ts +104 -0
- package/dist/storage/stored-files-server.js +292 -0
- package/dist/storage/stored-files-server.test.node.d.ts +8 -0
- package/dist/storage/stored-files-server.test.node.js +216 -0
- package/package.json +7 -2
|
@@ -406,3 +406,62 @@ describe('virtual fields', () => {
|
|
|
406
406
|
expect(items[0]?.filesGroup.thumbnailPage).toBeUndefined();
|
|
407
407
|
});
|
|
408
408
|
});
|
|
409
|
+
describe('stored-file content identity round trip', () => {
|
|
410
|
+
const FilesCollection = defineCollection({
|
|
411
|
+
path: 'identity-files',
|
|
412
|
+
labels: { singular: 'File', plural: 'Files' },
|
|
413
|
+
fields: [{ name: 'attachment', label: 'Attachment', type: 'file' }],
|
|
414
|
+
});
|
|
415
|
+
const baseFile = {
|
|
416
|
+
fileId: 'file-1',
|
|
417
|
+
filename: 'report.pdf',
|
|
418
|
+
originalFilename: 'Report.pdf',
|
|
419
|
+
mimeType: 'application/pdf',
|
|
420
|
+
fileSize: 3,
|
|
421
|
+
storageProvider: 'local',
|
|
422
|
+
storagePath: 'media/report.pdf',
|
|
423
|
+
processingStatus: 'complete',
|
|
424
|
+
};
|
|
425
|
+
const HASH = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad';
|
|
426
|
+
it('round-trips fileHash and storageValidator through the flattened row', () => {
|
|
427
|
+
const flattened = flattenFieldSetData(FilesCollection.fields, {
|
|
428
|
+
attachment: {
|
|
429
|
+
...baseFile,
|
|
430
|
+
fileHash: HASH,
|
|
431
|
+
storageValidator: { kind: 'local-stat', value: '1:2:3:3' },
|
|
432
|
+
},
|
|
433
|
+
}, 'all');
|
|
434
|
+
const row = flattened[0];
|
|
435
|
+
expect(row.file_hash).toBe(HASH);
|
|
436
|
+
expect(row.storage_validator_kind).toBe('local-stat');
|
|
437
|
+
expect(row.storage_validator_value).toBe('1:2:3:3');
|
|
438
|
+
const { data } = restoreFieldSetData(FilesCollection.fields, flattened);
|
|
439
|
+
expect(data.attachment).toMatchObject({
|
|
440
|
+
fileHash: HASH,
|
|
441
|
+
storageValidator: { kind: 'local-stat', value: '1:2:3:3' },
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
it('omits unknown identity instead of restoring null', () => {
|
|
445
|
+
const flattened = flattenFieldSetData(FilesCollection.fields, { attachment: baseFile }, 'all').map((row) => ({
|
|
446
|
+
...row,
|
|
447
|
+
file_hash: null,
|
|
448
|
+
storage_validator_kind: null,
|
|
449
|
+
storage_validator_value: null,
|
|
450
|
+
}));
|
|
451
|
+
const { data } = restoreFieldSetData(FilesCollection.fields, flattened);
|
|
452
|
+
const attachment = data.attachment;
|
|
453
|
+
expect('fileHash' in attachment).toBe(false);
|
|
454
|
+
expect('storageValidator' in attachment).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
it('restores a validator only as a complete pair', () => {
|
|
457
|
+
const flattened = flattenFieldSetData(FilesCollection.fields, { attachment: { ...baseFile, fileHash: HASH } }, 'all').map((row) => ({
|
|
458
|
+
...row,
|
|
459
|
+
storage_validator_kind: 'local-stat',
|
|
460
|
+
storage_validator_value: null,
|
|
461
|
+
}));
|
|
462
|
+
const { data } = restoreFieldSetData(FilesCollection.fields, flattened);
|
|
463
|
+
const attachment = data.attachment;
|
|
464
|
+
expect(attachment.fileHash).toBe(HASH);
|
|
465
|
+
expect('storageValidator' in attachment).toBe(false);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -269,6 +269,8 @@ const flattenValueFieldData = (field, field_path, value, locale) => {
|
|
|
269
269
|
storage_path: v.storagePath,
|
|
270
270
|
storage_url: v.storageUrl,
|
|
271
271
|
file_hash: v.fileHash,
|
|
272
|
+
storage_validator_kind: v.storageValidator?.kind,
|
|
273
|
+
storage_validator_value: v.storageValidator?.value,
|
|
272
274
|
image_width: v.imageWidth,
|
|
273
275
|
image_height: v.imageHeight,
|
|
274
276
|
image_format: v.imageFormat,
|
|
@@ -300,7 +300,17 @@ const extractValueFieldData = (data) => {
|
|
|
300
300
|
storageProvider: data.storage_provider,
|
|
301
301
|
storagePath: data.storage_path,
|
|
302
302
|
storageUrl: data.storage_url,
|
|
303
|
-
|
|
303
|
+
// Content identity is omitted, never null or '', when unknown. The
|
|
304
|
+
// validator is restored only as a complete pair.
|
|
305
|
+
...(data.file_hash ? { fileHash: data.file_hash } : {}),
|
|
306
|
+
...(data.storage_validator_kind && data.storage_validator_value
|
|
307
|
+
? {
|
|
308
|
+
storageValidator: {
|
|
309
|
+
kind: data.storage_validator_kind,
|
|
310
|
+
value: data.storage_validator_value,
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
: {}),
|
|
304
314
|
imageWidth: data.image_width,
|
|
305
315
|
imageHeight: data.image_height,
|
|
306
316
|
imageFormat: data.image_format,
|
|
@@ -379,6 +389,8 @@ export const extractFlattenedFieldValue = (unifiedValue) => {
|
|
|
379
389
|
storage_path: unifiedValue.storage_path,
|
|
380
390
|
storage_url: orUndefined(unifiedValue.storage_url),
|
|
381
391
|
file_hash: orUndefined(unifiedValue.file_hash),
|
|
392
|
+
storage_validator_kind: orUndefined(unifiedValue.storage_validator_kind),
|
|
393
|
+
storage_validator_value: orUndefined(unifiedValue.storage_validator_value),
|
|
382
394
|
image_width: orUndefined(unifiedValue.image_width),
|
|
383
395
|
image_height: orUndefined(unifiedValue.image_height),
|
|
384
396
|
image_format: orUndefined(unifiedValue.image_format),
|
|
@@ -43,6 +43,8 @@ interface FlattenedFileFieldValue extends BaseFlattenedFieldData {
|
|
|
43
43
|
storage_path: string;
|
|
44
44
|
storage_url?: string;
|
|
45
45
|
file_hash?: string;
|
|
46
|
+
storage_validator_kind?: string;
|
|
47
|
+
storage_validator_value?: string;
|
|
46
48
|
image_width?: number;
|
|
47
49
|
image_height?: number;
|
|
48
50
|
image_format?: string;
|
|
@@ -93,6 +95,8 @@ export interface UnifiedFieldValue {
|
|
|
93
95
|
storage_path: string | null;
|
|
94
96
|
storage_url: string | null;
|
|
95
97
|
file_hash: string | null;
|
|
98
|
+
storage_validator_kind: string | null;
|
|
99
|
+
storage_validator_value: string | null;
|
|
96
100
|
image_width: number | null;
|
|
97
101
|
image_height: number | null;
|
|
98
102
|
image_format: string | null;
|
|
@@ -129,6 +129,16 @@ export const storeColumnManifest = [
|
|
|
129
129
|
nullCast: 'varchar',
|
|
130
130
|
sources: { file: 'file_hash' },
|
|
131
131
|
},
|
|
132
|
+
{
|
|
133
|
+
name: 'storage_validator_kind',
|
|
134
|
+
nullCast: 'varchar',
|
|
135
|
+
sources: { file: 'storage_validator_kind' },
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'storage_validator_value',
|
|
139
|
+
nullCast: 'varchar',
|
|
140
|
+
sources: { file: 'storage_validator_value' },
|
|
141
|
+
},
|
|
132
142
|
{
|
|
133
143
|
name: 'image_width',
|
|
134
144
|
nullCast: 'integer',
|
|
@@ -16,7 +16,7 @@ import { storeColumnManifest, storeTableNames } from './store-manifest.js';
|
|
|
16
16
|
*/
|
|
17
17
|
describe('store-manifest', () => {
|
|
18
18
|
it('pins the manifest column count', () => {
|
|
19
|
-
expect(storeColumnManifest.length).toBe(
|
|
19
|
+
expect(storeColumnManifest.length).toBe(41);
|
|
20
20
|
});
|
|
21
21
|
it('pins the manifest column order', () => {
|
|
22
22
|
expect(storeColumnManifest.map((col) => col.name)).toEqual([
|
|
@@ -43,6 +43,8 @@ describe('store-manifest', () => {
|
|
|
43
43
|
'storage_path',
|
|
44
44
|
'storage_url',
|
|
45
45
|
'file_hash',
|
|
46
|
+
'storage_validator_kind',
|
|
47
|
+
'storage_validator_value',
|
|
46
48
|
'image_width',
|
|
47
49
|
'image_height',
|
|
48
50
|
'image_format',
|
|
@@ -105,6 +107,8 @@ describe('store-manifest', () => {
|
|
|
105
107
|
'storage_path',
|
|
106
108
|
'storage_url',
|
|
107
109
|
'file_hash',
|
|
110
|
+
'storage_validator_kind',
|
|
111
|
+
'storage_validator_value',
|
|
108
112
|
'image_width',
|
|
109
113
|
'image_height',
|
|
110
114
|
'image_format',
|
|
@@ -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,251 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
9
|
+
import { Readable } from 'node:stream';
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { ErrorCodes } from '../lib/errors.js';
|
|
12
|
+
import { backfillStoredFileIdentity } from './stored-files-server.js';
|
|
13
|
+
const sha = (text) => createHash('sha256').update(Buffer.from(text)).digest('hex');
|
|
14
|
+
/** In-memory adapter with the documented paging and update semantics. */
|
|
15
|
+
function fakeDb(rows, collections = { media: 'col-media' }) {
|
|
16
|
+
const matches = (row, p) => row.storageProvider === p.storageProvider &&
|
|
17
|
+
(p.collectionId === undefined || row.collectionId === p.collectionId);
|
|
18
|
+
const queries = {
|
|
19
|
+
async listStoredFilePaths(p) {
|
|
20
|
+
const selected = rows.filter((row) => matches(row, p) &&
|
|
21
|
+
(p.identity === 'missing' ? row.fileHash === null : row.fileHash !== null) &&
|
|
22
|
+
(p.afterPath === undefined || row.storagePath > p.afterPath));
|
|
23
|
+
const paths = [...new Set(selected.map((row) => row.storagePath))].sort().slice(0, p.limit);
|
|
24
|
+
return paths.map((storagePath) => {
|
|
25
|
+
if (p.identity === 'missing')
|
|
26
|
+
return { storagePath, hashes: [] };
|
|
27
|
+
const counts = new Map();
|
|
28
|
+
for (const row of selected) {
|
|
29
|
+
if (row.storagePath === storagePath && row.fileHash) {
|
|
30
|
+
counts.set(row.fileHash, (counts.get(row.fileHash) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
storagePath,
|
|
35
|
+
hashes: [...counts].sort().map(([fileHash, rowCount]) => ({ fileHash, rowCount })),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const setIdentityForPath = vi.fn(async (p) => {
|
|
41
|
+
let updated = 0;
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (matches(row, p) && row.storagePath === p.storagePath && row.fileHash === null) {
|
|
44
|
+
row.fileHash = p.fileHash;
|
|
45
|
+
row.validator = p.storageValidator;
|
|
46
|
+
updated += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return updated;
|
|
50
|
+
});
|
|
51
|
+
const db = {
|
|
52
|
+
commands: { storedFiles: { setIdentityForPath } },
|
|
53
|
+
queries: {
|
|
54
|
+
storedFiles: queries,
|
|
55
|
+
collections: {
|
|
56
|
+
getCollectionByPath: async (path) => collections[path] ? { id: collections[path], path } : null,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { db, setIdentityForPath };
|
|
61
|
+
}
|
|
62
|
+
/** A provider whose objects are in-memory strings; `download` is a bound read. */
|
|
63
|
+
function fakeStorage(objects, options) {
|
|
64
|
+
const download = vi.fn(async (storagePath) => {
|
|
65
|
+
if (options?.failOn?.includes(storagePath))
|
|
66
|
+
throw new Error(`read failed: ${storagePath}`);
|
|
67
|
+
const content = objects[storagePath];
|
|
68
|
+
if (content === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
body: Readable.from([Buffer.from(content)]),
|
|
72
|
+
validator: { kind: 'test', value: `v:${storagePath}:${content.length}` },
|
|
73
|
+
size: content.length,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const storage = {
|
|
77
|
+
providerName: 'local',
|
|
78
|
+
upload: vi.fn(),
|
|
79
|
+
delete: vi.fn(),
|
|
80
|
+
getUrl: (p) => p,
|
|
81
|
+
download,
|
|
82
|
+
};
|
|
83
|
+
return { storage, download };
|
|
84
|
+
}
|
|
85
|
+
const row = (storagePath, extra) => ({
|
|
86
|
+
storageProvider: 'local',
|
|
87
|
+
storagePath,
|
|
88
|
+
collectionId: 'col-media',
|
|
89
|
+
fileHash: null,
|
|
90
|
+
validator: null,
|
|
91
|
+
...extra,
|
|
92
|
+
});
|
|
93
|
+
describe('backfillStoredFileIdentity — fill', () => {
|
|
94
|
+
it('reads each path once and records hash and validator on every null row', async () => {
|
|
95
|
+
const rows = [row('media/a.pdf'), row('media/a.pdf'), row('media/b.pdf')];
|
|
96
|
+
const { db } = fakeDb(rows);
|
|
97
|
+
const { storage, download } = fakeStorage({ 'media/a.pdf': 'AAA', 'media/b.pdf': 'BB' });
|
|
98
|
+
const summary = await backfillStoredFileIdentity({ db, storage });
|
|
99
|
+
expect(summary).toMatchObject({ mode: 'fill', scanned: 2, hashed: 2, rowsUpdated: 3 });
|
|
100
|
+
expect(download).toHaveBeenCalledTimes(2);
|
|
101
|
+
expect(rows.map((r) => r.fileHash)).toEqual([sha('AAA'), sha('AAA'), sha('BB')]);
|
|
102
|
+
expect(rows[0]?.validator).toEqual({ kind: 'test', value: 'v:media/a.pdf:3' });
|
|
103
|
+
});
|
|
104
|
+
it('makes no changes on a second run', async () => {
|
|
105
|
+
const rows = [row('media/a.pdf')];
|
|
106
|
+
const { db, setIdentityForPath } = fakeDb(rows);
|
|
107
|
+
const { storage } = fakeStorage({ 'media/a.pdf': 'AAA' });
|
|
108
|
+
await backfillStoredFileIdentity({ db, storage });
|
|
109
|
+
setIdentityForPath.mockClear();
|
|
110
|
+
const again = await backfillStoredFileIdentity({ db, storage });
|
|
111
|
+
expect(again).toMatchObject({ scanned: 0, rowsUpdated: 0 });
|
|
112
|
+
expect(setIdentityForPath).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
it('never overwrites a recorded hash', async () => {
|
|
115
|
+
const rows = [row('media/a.pdf', { fileHash: sha('OLD') }), row('media/a.pdf')];
|
|
116
|
+
const { db } = fakeDb(rows);
|
|
117
|
+
const { storage } = fakeStorage({ 'media/a.pdf': 'NEW' });
|
|
118
|
+
await backfillStoredFileIdentity({ db, storage });
|
|
119
|
+
expect(rows.map((r) => r.fileHash)).toEqual([sha('OLD'), sha('NEW')]);
|
|
120
|
+
});
|
|
121
|
+
it('resumes cleanly after a run stopped by its limit', async () => {
|
|
122
|
+
const rows = [row('media/a.pdf'), row('media/b.pdf'), row('media/c.pdf')];
|
|
123
|
+
const { db } = fakeDb(rows);
|
|
124
|
+
const { storage } = fakeStorage({ 'media/a.pdf': 'A', 'media/b.pdf': 'B', 'media/c.pdf': 'C' });
|
|
125
|
+
const first = await backfillStoredFileIdentity({ db, storage, limit: 1, batchSize: 1 });
|
|
126
|
+
const rest = await backfillStoredFileIdentity({ db, storage, batchSize: 1 });
|
|
127
|
+
expect(first.scanned).toBe(1);
|
|
128
|
+
expect(rest.scanned).toBe(2);
|
|
129
|
+
expect(rows.every((r) => r.fileHash !== null)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
it('leaves missing and unreadable objects without identity and reports them', async () => {
|
|
132
|
+
const rows = [row('media/gone.pdf'), row('media/bad.pdf'), row('media/ok.pdf')];
|
|
133
|
+
const { db } = fakeDb(rows);
|
|
134
|
+
const { storage } = fakeStorage({ 'media/bad.pdf': 'x', 'media/ok.pdf': 'ok' }, { failOn: ['media/bad.pdf'] });
|
|
135
|
+
const summary = await backfillStoredFileIdentity({ db, storage });
|
|
136
|
+
expect(summary).toMatchObject({ scanned: 3, hashed: 1, missing: 1, failed: 1, rowsUpdated: 1 });
|
|
137
|
+
expect(summary.missingPaths).toEqual(['media/gone.pdf']);
|
|
138
|
+
expect(summary.failures).toEqual([
|
|
139
|
+
{ storagePath: 'media/bad.pdf', error: 'read failed: media/bad.pdf' },
|
|
140
|
+
]);
|
|
141
|
+
expect(rows.map((r) => r.fileHash === null)).toEqual([true, true, false]);
|
|
142
|
+
});
|
|
143
|
+
it('writes nothing in a dry run', async () => {
|
|
144
|
+
const rows = [row('media/a.pdf')];
|
|
145
|
+
const { db, setIdentityForPath } = fakeDb(rows);
|
|
146
|
+
const { storage } = fakeStorage({ 'media/a.pdf': 'AAA' });
|
|
147
|
+
const summary = await backfillStoredFileIdentity({ db, storage, dryRun: true });
|
|
148
|
+
expect(summary).toMatchObject({ dryRun: true, scanned: 1, hashed: 1, rowsUpdated: 0 });
|
|
149
|
+
expect(setIdentityForPath).not.toHaveBeenCalled();
|
|
150
|
+
expect(rows[0]?.fileHash).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
it('selects only rows written by the configured provider', async () => {
|
|
153
|
+
const rows = [row('media/a.pdf'), row('media/a.pdf', { storageProvider: 's3' })];
|
|
154
|
+
const { db } = fakeDb(rows);
|
|
155
|
+
const { storage } = fakeStorage({ 'media/a.pdf': 'AAA' });
|
|
156
|
+
await backfillStoredFileIdentity({ db, storage });
|
|
157
|
+
expect(rows.map((r) => r.fileHash)).toEqual([sha('AAA'), null]);
|
|
158
|
+
});
|
|
159
|
+
it('confines a collection-scoped run to that collection', async () => {
|
|
160
|
+
const rows = [row('shared/a.pdf'), row('shared/a.pdf', { collectionId: 'col-docs' })];
|
|
161
|
+
const { db } = fakeDb(rows, { media: 'col-media', docs: 'col-docs' });
|
|
162
|
+
const { storage } = fakeStorage({ 'shared/a.pdf': 'AAA' });
|
|
163
|
+
const summary = await backfillStoredFileIdentity({ db, storage, collectionPath: 'media' });
|
|
164
|
+
expect(summary.rowsUpdated).toBe(1);
|
|
165
|
+
expect(rows.map((r) => r.fileHash)).toEqual([sha('AAA'), null]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('backfillStoredFileIdentity — refusals', () => {
|
|
169
|
+
it('refuses a provider without download', async () => {
|
|
170
|
+
const { db } = fakeDb([row('media/a.pdf')]);
|
|
171
|
+
const { storage } = fakeStorage({});
|
|
172
|
+
delete storage.download;
|
|
173
|
+
await expect(backfillStoredFileIdentity({ db, storage })).rejects.toMatchObject({
|
|
174
|
+
code: ErrorCodes.STORAGE,
|
|
175
|
+
details: expect.objectContaining({ reason: 'download-unavailable' }),
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
it('refuses an adapter without stored-file maintenance', async () => {
|
|
179
|
+
const { storage } = fakeStorage({});
|
|
180
|
+
const db = { commands: {}, queries: {} };
|
|
181
|
+
await expect(backfillStoredFileIdentity({ db, storage })).rejects.toMatchObject({
|
|
182
|
+
code: ErrorCodes.VALIDATION,
|
|
183
|
+
details: expect.objectContaining({ reason: 'adapter-unsupported' }),
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
it('rejects an unknown collection path', async () => {
|
|
187
|
+
const { db } = fakeDb([row('media/a.pdf')]);
|
|
188
|
+
const { storage } = fakeStorage({});
|
|
189
|
+
await expect(backfillStoredFileIdentity({ db, storage, collectionPath: 'nope' })).rejects.toMatchObject({
|
|
190
|
+
code: ErrorCodes.VALIDATION,
|
|
191
|
+
details: expect.objectContaining({ reason: 'unknown-collection' }),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('backfillStoredFileIdentity — verify', () => {
|
|
196
|
+
it('reports matched and mismatched hash groups and writes nothing', async () => {
|
|
197
|
+
const rows = [
|
|
198
|
+
row('media/a.pdf', { fileHash: sha('AAA') }),
|
|
199
|
+
row('media/b.pdf', { fileHash: sha('STALE') }),
|
|
200
|
+
row('media/c.pdf'),
|
|
201
|
+
];
|
|
202
|
+
const { db, setIdentityForPath } = fakeDb(rows);
|
|
203
|
+
const { storage } = fakeStorage({ 'media/a.pdf': 'AAA', 'media/b.pdf': 'CURRENT' });
|
|
204
|
+
const summary = await backfillStoredFileIdentity({ db, storage, mode: 'verify' });
|
|
205
|
+
expect(summary).toMatchObject({ mode: 'verify', scanned: 2, matched: 1, mismatched: 1 });
|
|
206
|
+
expect(summary.mismatches).toEqual([
|
|
207
|
+
{
|
|
208
|
+
storagePath: 'media/b.pdf',
|
|
209
|
+
recordedHash: sha('STALE'),
|
|
210
|
+
currentHash: sha('CURRENT'),
|
|
211
|
+
rowCount: 1,
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
expect(setIdentityForPath).not.toHaveBeenCalled();
|
|
215
|
+
expect(rows[2]?.fileHash).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
it('pages by whole paths: batchSize 1 reports every hash group on a multi-hash path', async () => {
|
|
218
|
+
// The first path carries two different recorded hashes (an older version
|
|
219
|
+
// recorded bytes later replaced at the same path); another path follows.
|
|
220
|
+
// A row of the same object in another collection must not be touched.
|
|
221
|
+
const rows = [
|
|
222
|
+
row('media/a.pdf', { fileHash: sha('OLD') }),
|
|
223
|
+
row('media/a.pdf', { fileHash: sha('NEW') }),
|
|
224
|
+
row('media/a.pdf', { fileHash: sha('NEW') }),
|
|
225
|
+
row('media/b.pdf', { fileHash: sha('B') }),
|
|
226
|
+
row('media/a.pdf', { collectionId: 'col-docs' }),
|
|
227
|
+
];
|
|
228
|
+
const before = structuredClone(rows);
|
|
229
|
+
const { db, setIdentityForPath } = fakeDb(rows, { media: 'col-media', docs: 'col-docs' });
|
|
230
|
+
const { storage, download } = fakeStorage({ 'media/a.pdf': 'NEW', 'media/b.pdf': 'B' });
|
|
231
|
+
const summary = await backfillStoredFileIdentity({
|
|
232
|
+
db,
|
|
233
|
+
storage,
|
|
234
|
+
mode: 'verify',
|
|
235
|
+
batchSize: 1,
|
|
236
|
+
collectionPath: 'media',
|
|
237
|
+
});
|
|
238
|
+
expect(summary).toMatchObject({ scanned: 2, hashed: 2, matched: 2, mismatched: 1 });
|
|
239
|
+
expect(summary.mismatches).toEqual([
|
|
240
|
+
{
|
|
241
|
+
storagePath: 'media/a.pdf',
|
|
242
|
+
recordedHash: sha('OLD'),
|
|
243
|
+
currentHash: sha('NEW'),
|
|
244
|
+
rowCount: 1,
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
expect(download.mock.calls.map((call) => call[0])).toEqual(['media/a.pdf', 'media/b.pdf']);
|
|
248
|
+
expect(setIdentityForPath).not.toHaveBeenCalled();
|
|
249
|
+
expect(rows).toEqual(before);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
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 { IDbAdapter } from '../@types/db-types.js';
|
|
9
|
+
import type { IStorageProvider, StoredFileLocation, UploadFileOptions } from '../@types/storage-types.js';
|
|
10
|
+
import type { BylineLogger } from '../lib/logger.js';
|
|
11
|
+
/** SHA-256 (lower-case hex) and byte count of a stream, read to its end. */
|
|
12
|
+
export declare function hashStream(stream: NodeJS.ReadableStream, options?: {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
fileHash: string;
|
|
16
|
+
size: number;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Upload an original object and compute its content hash in the same pass.
|
|
20
|
+
*
|
|
21
|
+
* - A `Buffer` is hashed in memory; a length that differs from `options.size`
|
|
22
|
+
* fails before the provider is called.
|
|
23
|
+
* - A stream is piped through a hashing pass-through as the provider consumes
|
|
24
|
+
* it. If it ends short, runs long, or is not fully consumed when the provider
|
|
25
|
+
* resolves, the upload fails, the input is destroyed, and no hash is
|
|
26
|
+
* returned. The object is deleted when the provider generated its path; a
|
|
27
|
+
* `targetStoragePath` write may have replaced an existing object, which
|
|
28
|
+
* cannot be undone, and the error says so.
|
|
29
|
+
*/
|
|
30
|
+
export declare function uploadWithContentHash(storage: IStorageProvider, input: NodeJS.ReadableStream | Buffer, options: UploadFileOptions): Promise<{
|
|
31
|
+
location: StoredFileLocation;
|
|
32
|
+
fileHash: string;
|
|
33
|
+
}>;
|
|
34
|
+
/** Options for {@link backfillStoredFileIdentity}. */
|
|
35
|
+
export interface BackfillStoredFileIdentityOptions {
|
|
36
|
+
db: IDbAdapter;
|
|
37
|
+
storage: IStorageProvider;
|
|
38
|
+
/**
|
|
39
|
+
* `'fill'` (default): record `fileHash` and `storageValidator` on rows
|
|
40
|
+
* without a hash. `'verify'`: re-hash paths that already have a recorded
|
|
41
|
+
* hash and report matches and mismatches; writes nothing.
|
|
42
|
+
*/
|
|
43
|
+
mode?: 'fill' | 'verify';
|
|
44
|
+
/** Restrict selection and updates to one collection's rows. */
|
|
45
|
+
collectionPath?: string;
|
|
46
|
+
/** Distinct paths per page. Default 100. */
|
|
47
|
+
batchSize?: number;
|
|
48
|
+
/** Maximum distinct paths to process in this run. Default: no limit. */
|
|
49
|
+
limit?: number;
|
|
50
|
+
/** Fill mode only: read and hash, but write nothing. */
|
|
51
|
+
dryRun?: boolean;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
logger?: Pick<BylineLogger, 'info' | 'warn'>;
|
|
54
|
+
}
|
|
55
|
+
/** Per-run summary. Each path list keeps at most 1000 entries; counts are never capped. */
|
|
56
|
+
export interface BackfillStoredFileIdentitySummary {
|
|
57
|
+
mode: 'fill' | 'verify';
|
|
58
|
+
dryRun: boolean;
|
|
59
|
+
/** Distinct paths selected. */
|
|
60
|
+
scanned: number;
|
|
61
|
+
/** Paths read and hashed. */
|
|
62
|
+
hashed: number;
|
|
63
|
+
/** Fill mode: rows updated (0 in a dry run). */
|
|
64
|
+
rowsUpdated: number;
|
|
65
|
+
/** Paths whose object no longer exists. */
|
|
66
|
+
missing: number;
|
|
67
|
+
/** Paths that could not be read or hashed. */
|
|
68
|
+
failed: number;
|
|
69
|
+
/** Verify mode: recorded-hash groups equal to the current bytes. */
|
|
70
|
+
matched: number;
|
|
71
|
+
/** Verify mode: recorded-hash groups that differ from the current bytes. */
|
|
72
|
+
mismatched: number;
|
|
73
|
+
missingPaths: string[];
|
|
74
|
+
failures: Array<{
|
|
75
|
+
storagePath: string;
|
|
76
|
+
error: string;
|
|
77
|
+
}>;
|
|
78
|
+
mismatches: Array<{
|
|
79
|
+
storagePath: string;
|
|
80
|
+
recordedHash: string;
|
|
81
|
+
currentHash: string;
|
|
82
|
+
rowCount: number;
|
|
83
|
+
}>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Fill or verify stored-file content identity for existing `byline_store_file`
|
|
87
|
+
* rows (spec 2026-10-31 §4.8).
|
|
88
|
+
*
|
|
89
|
+
* - Selects only rows written by `storage.providerName`, optionally one
|
|
90
|
+
* collection's rows, paging by distinct storage path. Each path is read once
|
|
91
|
+
* per run with a bound `download`, so the recorded validator always belongs
|
|
92
|
+
* to the bytes that were hashed.
|
|
93
|
+
* - Fill never overwrites a recorded hash, so re-running is idempotent and an
|
|
94
|
+
* interrupted run resumes cleanly. It hashes the bytes present now: where an
|
|
95
|
+
* older version's object was later replaced at the same path, the recorded
|
|
96
|
+
* hash describes the current bytes, which the validator identifies.
|
|
97
|
+
* - Legacy-null policy: a missing object, or a read or hash failure, leaves the
|
|
98
|
+
* rows without identity and is reported. A provider without `download`, or
|
|
99
|
+
* an adapter without stored-file maintenance, refuses to start.
|
|
100
|
+
*
|
|
101
|
+
* This is a maintenance primitive: it uses direct `db.queries` / `db.commands`
|
|
102
|
+
* calls and bypasses collection auth and hooks.
|
|
103
|
+
*/
|
|
104
|
+
export declare function backfillStoredFileIdentity(options: BackfillStoredFileIdentityOptions): Promise<BackfillStoredFileIdentitySummary>;
|