@byline/core 6.10.1 → 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.
@@ -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
- fileHash: data.file_hash,
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(39);
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>;