@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.
- 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
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
// Server-only stored-file identity helpers, published as
|
|
9
|
+
// `@byline/core/stored-files`. They hash streams incrementally with
|
|
10
|
+
// `node:crypto`, so they must stay out of the browser-safe main entry.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { Transform } from 'node:stream';
|
|
13
|
+
import { ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
|
|
14
|
+
/** SHA-256 (lower-case hex) and byte count of a stream, read to its end. */
|
|
15
|
+
export async function hashStream(stream, options) {
|
|
16
|
+
const hash = createHash('sha256');
|
|
17
|
+
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
|
+
}
|
|
38
|
+
return { fileHash: hash.digest('hex'), size };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Upload an original object and compute its content hash in the same pass.
|
|
42
|
+
*
|
|
43
|
+
* - A `Buffer` is hashed in memory; a length that differs from `options.size`
|
|
44
|
+
* fails before the provider is called.
|
|
45
|
+
* - A stream is piped through a hashing pass-through as the provider consumes
|
|
46
|
+
* it. If it ends short, runs long, or is not fully consumed when the provider
|
|
47
|
+
* resolves, the upload fails, the input is destroyed, and no hash is
|
|
48
|
+
* returned. The object is deleted when the provider generated its path; a
|
|
49
|
+
* `targetStoragePath` write may have replaced an existing object, which
|
|
50
|
+
* cannot be undone, and the error says so.
|
|
51
|
+
*/
|
|
52
|
+
export async function uploadWithContentHash(storage, input, options) {
|
|
53
|
+
if (Buffer.isBuffer(input)) {
|
|
54
|
+
if (input.length !== options.size) {
|
|
55
|
+
throw ERR_VALIDATION({
|
|
56
|
+
message: `Upload is ${input.length} bytes but declared ${options.size} bytes.`,
|
|
57
|
+
details: { reason: 'size-mismatch', actualSize: input.length, declaredSize: options.size },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const fileHash = createHash('sha256').update(input).digest('hex');
|
|
61
|
+
const location = await storage.upload(input, options);
|
|
62
|
+
return { location, fileHash };
|
|
63
|
+
}
|
|
64
|
+
const hash = createHash('sha256');
|
|
65
|
+
let size = 0;
|
|
66
|
+
let ended = false;
|
|
67
|
+
// The provider reads this transform. Its readable side emits 'end' only
|
|
68
|
+
// after the provider has consumed every byte, so `ended` is "fully read by
|
|
69
|
+
// the provider", not merely "fully written by the source".
|
|
70
|
+
const hashing = new Transform({
|
|
71
|
+
transform(chunk, _encoding, callback) {
|
|
72
|
+
hash.update(chunk);
|
|
73
|
+
size += chunk.length;
|
|
74
|
+
callback(null, chunk);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
hashing.on('end', () => {
|
|
78
|
+
ended = true;
|
|
79
|
+
});
|
|
80
|
+
// A provider that rejects may never listen to the transform; keep a late
|
|
81
|
+
// error from becoming an unhandled 'error' event. Errors still reach any
|
|
82
|
+
// listener the provider attached.
|
|
83
|
+
const swallow = () => { };
|
|
84
|
+
hashing.on('error', swallow);
|
|
85
|
+
// Forward a source failure to the provider through the transform.
|
|
86
|
+
const onInputError = (err) => hashing.destroy(err);
|
|
87
|
+
input.on('error', onInputError);
|
|
88
|
+
input.pipe(hashing);
|
|
89
|
+
/** Detach and destroy both sides. The input's own later errors are absorbed. */
|
|
90
|
+
const release = () => {
|
|
91
|
+
input.unpipe(hashing);
|
|
92
|
+
input.removeListener('error', onInputError);
|
|
93
|
+
input.on('error', swallow);
|
|
94
|
+
destroyStream(input);
|
|
95
|
+
hashing.destroy();
|
|
96
|
+
};
|
|
97
|
+
let location;
|
|
98
|
+
try {
|
|
99
|
+
location = await storage.upload(hashing, options);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// The provider's error is the primary failure; cleanup must not mask it.
|
|
103
|
+
release();
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
if (!ended || size !== options.size) {
|
|
107
|
+
release();
|
|
108
|
+
const providerGeneratedPath = options.targetStoragePath === undefined;
|
|
109
|
+
let objectDeleted = false;
|
|
110
|
+
if (providerGeneratedPath) {
|
|
111
|
+
try {
|
|
112
|
+
await storage.delete(location.storagePath);
|
|
113
|
+
objectDeleted = true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Reported through `objectDeleted: false`; the size failure is primary.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw ERR_STORAGE({
|
|
120
|
+
message: ended
|
|
121
|
+
? `Upload stream was ${size} bytes but declared ${options.size} bytes.`
|
|
122
|
+
: 'The storage provider resolved before consuming the whole upload stream.',
|
|
123
|
+
details: {
|
|
124
|
+
reason: ended ? 'size-mismatch' : 'stream-not-consumed',
|
|
125
|
+
actualSize: size,
|
|
126
|
+
declaredSize: options.size,
|
|
127
|
+
storagePath: location.storagePath,
|
|
128
|
+
objectDeleted,
|
|
129
|
+
// A verbatim `targetStoragePath` write may have replaced an existing
|
|
130
|
+
// object; Byline cannot restore it.
|
|
131
|
+
replacementUndone: false,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
input.removeListener('error', onInputError);
|
|
136
|
+
return { location, fileHash: hash.digest('hex') };
|
|
137
|
+
}
|
|
138
|
+
function destroyStream(stream, reason) {
|
|
139
|
+
const destroyable = stream;
|
|
140
|
+
destroyable.destroy?.(reason instanceof Error ? reason : undefined);
|
|
141
|
+
}
|
|
142
|
+
/** Maximum entries kept in each summary path list. Counts are never capped. */
|
|
143
|
+
const BACKFILL_REPORT_LIMIT = 1000;
|
|
144
|
+
/**
|
|
145
|
+
* Fill or verify stored-file content identity for existing `byline_store_file`
|
|
146
|
+
* rows (spec 2026-10-31 §4.8).
|
|
147
|
+
*
|
|
148
|
+
* - Selects only rows written by `storage.providerName`, optionally one
|
|
149
|
+
* collection's rows, paging by distinct storage path. Each path is read once
|
|
150
|
+
* per run with a bound `download`, so the recorded validator always belongs
|
|
151
|
+
* to the bytes that were hashed.
|
|
152
|
+
* - Fill never overwrites a recorded hash, so re-running is idempotent and an
|
|
153
|
+
* interrupted run resumes cleanly. It hashes the bytes present now: where an
|
|
154
|
+
* older version's object was later replaced at the same path, the recorded
|
|
155
|
+
* hash describes the current bytes, which the validator identifies.
|
|
156
|
+
* - Legacy-null policy: a missing object, or a read or hash failure, leaves the
|
|
157
|
+
* rows without identity and is reported. A provider without `download`, or
|
|
158
|
+
* an adapter without stored-file maintenance, refuses to start.
|
|
159
|
+
*
|
|
160
|
+
* This is a maintenance primitive: it uses direct `db.queries` / `db.commands`
|
|
161
|
+
* calls and bypasses collection auth and hooks.
|
|
162
|
+
*/
|
|
163
|
+
export async function backfillStoredFileIdentity(options) {
|
|
164
|
+
const { db, storage, signal, logger } = options;
|
|
165
|
+
const mode = options.mode ?? 'fill';
|
|
166
|
+
const dryRun = mode === 'fill' && options.dryRun === true;
|
|
167
|
+
const batchSize = options.batchSize ?? 100;
|
|
168
|
+
const limit = options.limit ?? Number.POSITIVE_INFINITY;
|
|
169
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
170
|
+
throw ERR_VALIDATION({
|
|
171
|
+
message: 'batchSize must be a positive integer.',
|
|
172
|
+
details: { batchSize },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const queries = db.queries.storedFiles;
|
|
176
|
+
const commands = db.commands.storedFiles;
|
|
177
|
+
if (!queries || (mode === 'fill' && !commands)) {
|
|
178
|
+
throw ERR_VALIDATION({
|
|
179
|
+
message: 'The database adapter does not support stored-file identity maintenance.',
|
|
180
|
+
details: { reason: 'adapter-unsupported' },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (!storage.download) {
|
|
184
|
+
throw ERR_STORAGE({
|
|
185
|
+
message: `Storage provider '${storage.providerName}' has no download(); stored-file ` +
|
|
186
|
+
'identity cannot be backfilled or verified. Rows stay without identity.',
|
|
187
|
+
details: { reason: 'download-unavailable', storageProvider: storage.providerName },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
let collectionId;
|
|
191
|
+
if (options.collectionPath !== undefined) {
|
|
192
|
+
const collection = await db.queries.collections.getCollectionByPath(options.collectionPath);
|
|
193
|
+
if (!collection?.id) {
|
|
194
|
+
throw ERR_VALIDATION({
|
|
195
|
+
message: `Unknown collection '${options.collectionPath}'.`,
|
|
196
|
+
details: { reason: 'unknown-collection', collectionPath: options.collectionPath },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
collectionId = collection.id;
|
|
200
|
+
}
|
|
201
|
+
const summary = {
|
|
202
|
+
mode,
|
|
203
|
+
dryRun,
|
|
204
|
+
scanned: 0,
|
|
205
|
+
hashed: 0,
|
|
206
|
+
rowsUpdated: 0,
|
|
207
|
+
missing: 0,
|
|
208
|
+
failed: 0,
|
|
209
|
+
matched: 0,
|
|
210
|
+
mismatched: 0,
|
|
211
|
+
missingPaths: [],
|
|
212
|
+
failures: [],
|
|
213
|
+
mismatches: [],
|
|
214
|
+
};
|
|
215
|
+
const keep = (list, entry) => {
|
|
216
|
+
if (list.length < BACKFILL_REPORT_LIMIT)
|
|
217
|
+
list.push(entry);
|
|
218
|
+
};
|
|
219
|
+
let afterPath;
|
|
220
|
+
while (summary.scanned < limit) {
|
|
221
|
+
signal?.throwIfAborted();
|
|
222
|
+
const pageLimit = Math.min(batchSize, limit - summary.scanned);
|
|
223
|
+
const page = await queries.listStoredFilePaths({
|
|
224
|
+
storageProvider: storage.providerName,
|
|
225
|
+
...(collectionId !== undefined && { collectionId }),
|
|
226
|
+
identity: mode === 'fill' ? 'missing' : 'present',
|
|
227
|
+
...(afterPath !== undefined && { afterPath }),
|
|
228
|
+
limit: pageLimit,
|
|
229
|
+
});
|
|
230
|
+
for (const group of page) {
|
|
231
|
+
signal?.throwIfAborted();
|
|
232
|
+
summary.scanned += 1;
|
|
233
|
+
afterPath = group.storagePath;
|
|
234
|
+
let current;
|
|
235
|
+
try {
|
|
236
|
+
const read = await storage.download(group.storagePath, signal ? { signal } : undefined);
|
|
237
|
+
if (!read) {
|
|
238
|
+
current = null;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const { fileHash } = await hashStream(read.body, signal ? { signal } : undefined);
|
|
242
|
+
current = { fileHash, validator: read.validator };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
if (signal?.aborted)
|
|
247
|
+
throw err;
|
|
248
|
+
summary.failed += 1;
|
|
249
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
250
|
+
keep(summary.failures, { storagePath: group.storagePath, error: message });
|
|
251
|
+
logger?.warn({ storagePath: group.storagePath, err }, 'stored-file identity read failed');
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!current) {
|
|
255
|
+
summary.missing += 1;
|
|
256
|
+
keep(summary.missingPaths, group.storagePath);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
summary.hashed += 1;
|
|
260
|
+
if (mode === 'verify') {
|
|
261
|
+
for (const recorded of group.hashes) {
|
|
262
|
+
if (recorded.fileHash === current.fileHash) {
|
|
263
|
+
summary.matched += 1;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
summary.mismatched += 1;
|
|
267
|
+
keep(summary.mismatches, {
|
|
268
|
+
storagePath: group.storagePath,
|
|
269
|
+
recordedHash: recorded.fileHash,
|
|
270
|
+
currentHash: current.fileHash,
|
|
271
|
+
rowCount: recorded.rowCount,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!dryRun && commands) {
|
|
278
|
+
summary.rowsUpdated += await commands.setIdentityForPath({
|
|
279
|
+
storageProvider: storage.providerName,
|
|
280
|
+
storagePath: group.storagePath,
|
|
281
|
+
...(collectionId !== undefined && { collectionId }),
|
|
282
|
+
fileHash: current.fileHash,
|
|
283
|
+
storageValidator: current.validator,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (page.length < pageLimit)
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
logger?.info({ ...summary, missingPaths: undefined, failures: undefined, mismatches: undefined }, 'stored-file identity backfill finished');
|
|
291
|
+
return summary;
|
|
292
|
+
}
|
|
@@ -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,216 @@
|
|
|
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 { hashStream, uploadWithContentHash } from './stored-files-server.js';
|
|
13
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
/** A source that never ends on its own. */
|
|
15
|
+
function endless() {
|
|
16
|
+
return new Readable({
|
|
17
|
+
read() {
|
|
18
|
+
this.push(Buffer.alloc(1024, 1));
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** A provider that drains the stream (or takes the buffer) and records what it stored. */
|
|
23
|
+
function drainingProvider(options) {
|
|
24
|
+
const stored = new Map();
|
|
25
|
+
const upload = vi.fn(async (input, opts) => {
|
|
26
|
+
const storagePath = opts.targetStoragePath ?? 'media/generated-abc123.bin';
|
|
27
|
+
if (Buffer.isBuffer(input)) {
|
|
28
|
+
stored.set(storagePath, input);
|
|
29
|
+
}
|
|
30
|
+
else if (options?.consume !== 'none') {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for await (const chunk of input)
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
stored.set(storagePath, Buffer.concat(chunks));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
stored.set(storagePath, Buffer.alloc(0));
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
storageProvider: 'test',
|
|
41
|
+
storagePath,
|
|
42
|
+
storageUrl: null,
|
|
43
|
+
storageValidator: { kind: 'test', value: '1' },
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
const del = vi.fn(async (storagePath) => {
|
|
47
|
+
stored.delete(storagePath);
|
|
48
|
+
});
|
|
49
|
+
const storage = {
|
|
50
|
+
providerName: 'test',
|
|
51
|
+
upload,
|
|
52
|
+
delete: del,
|
|
53
|
+
getUrl: (p) => `/files/${p}`,
|
|
54
|
+
};
|
|
55
|
+
return { storage, upload, del, stored };
|
|
56
|
+
}
|
|
57
|
+
const opts = (size, extra) => ({
|
|
58
|
+
filename: 'a.bin',
|
|
59
|
+
mimeType: 'application/octet-stream',
|
|
60
|
+
size,
|
|
61
|
+
...extra,
|
|
62
|
+
});
|
|
63
|
+
describe('hashStream', () => {
|
|
64
|
+
it('hashes a stream to its end and counts bytes', async () => {
|
|
65
|
+
const bytes = Buffer.from('hello world');
|
|
66
|
+
await expect(hashStream(Readable.from([bytes.subarray(0, 5), bytes.subarray(5)]))).resolves.toEqual({
|
|
67
|
+
fileHash: sha(bytes),
|
|
68
|
+
size: bytes.length,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
it('rejects an already-aborted signal and destroys the stream', async () => {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
controller.abort(new Error('stop'));
|
|
74
|
+
const stream = Readable.from([Buffer.from('x')]);
|
|
75
|
+
await expect(hashStream(stream, { signal: controller.signal })).rejects.toThrow('stop');
|
|
76
|
+
expect(stream.destroyed).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('destroys the stream when aborted mid-read', async () => {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
let chunks = 0;
|
|
81
|
+
// Aborts from inside the source after a few chunks, so the test does not
|
|
82
|
+
// depend on a timer firing between synchronous pushes.
|
|
83
|
+
const stream = new Readable({
|
|
84
|
+
read() {
|
|
85
|
+
chunks += 1;
|
|
86
|
+
if (chunks === 4)
|
|
87
|
+
controller.abort(new Error('later'));
|
|
88
|
+
this.push(Buffer.alloc(1024, 1));
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
await expect(hashStream(stream, { signal: controller.signal })).rejects.toThrow();
|
|
92
|
+
expect(stream.destroyed).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('uploadWithContentHash', () => {
|
|
96
|
+
it('hashes a buffer and returns the provider location', async () => {
|
|
97
|
+
const { storage, stored } = drainingProvider();
|
|
98
|
+
const bytes = Buffer.from('buffered bytes');
|
|
99
|
+
const result = await uploadWithContentHash(storage, bytes, opts(bytes.length));
|
|
100
|
+
expect(result.fileHash).toBe(sha(bytes));
|
|
101
|
+
expect(result.location.storageValidator).toEqual({ kind: 'test', value: '1' });
|
|
102
|
+
expect(stored.get(result.location.storagePath)).toEqual(bytes);
|
|
103
|
+
});
|
|
104
|
+
it('rejects a buffer of the wrong length before calling the provider', async () => {
|
|
105
|
+
const { storage, upload } = drainingProvider();
|
|
106
|
+
await expect(uploadWithContentHash(storage, Buffer.from('abc'), opts(4))).rejects.toMatchObject({
|
|
107
|
+
code: ErrorCodes.VALIDATION,
|
|
108
|
+
details: expect.objectContaining({ reason: 'size-mismatch' }),
|
|
109
|
+
});
|
|
110
|
+
expect(upload).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
it('hashes a stream in the same pass the provider consumes it', async () => {
|
|
113
|
+
const { storage, stored } = drainingProvider();
|
|
114
|
+
const parts = [Buffer.from('chunk-one;'), Buffer.from('chunk-two;'), Buffer.from('three')];
|
|
115
|
+
const whole = Buffer.concat(parts);
|
|
116
|
+
const result = await uploadWithContentHash(storage, Readable.from(parts), opts(whole.length));
|
|
117
|
+
expect(result.fileHash).toBe(sha(whole));
|
|
118
|
+
expect(stored.get(result.location.storagePath)).toEqual(whole);
|
|
119
|
+
});
|
|
120
|
+
it('fails a short stream and deletes the provider-generated object', async () => {
|
|
121
|
+
const { storage, del, stored } = drainingProvider();
|
|
122
|
+
await expect(uploadWithContentHash(storage, Readable.from([Buffer.from('abc')]), opts(10))).rejects.toMatchObject({
|
|
123
|
+
code: ErrorCodes.STORAGE,
|
|
124
|
+
details: expect.objectContaining({
|
|
125
|
+
reason: 'size-mismatch',
|
|
126
|
+
actualSize: 3,
|
|
127
|
+
declaredSize: 10,
|
|
128
|
+
objectDeleted: true,
|
|
129
|
+
replacementUndone: false,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
expect(del).toHaveBeenCalledWith('media/generated-abc123.bin');
|
|
133
|
+
expect(stored.size).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
it('fails a long stream', async () => {
|
|
136
|
+
const { storage } = drainingProvider();
|
|
137
|
+
await expect(uploadWithContentHash(storage, Readable.from([Buffer.from('abcdef')]), opts(3))).rejects.toMatchObject({ details: expect.objectContaining({ reason: 'size-mismatch' }) });
|
|
138
|
+
});
|
|
139
|
+
it('does not delete a verbatim targetStoragePath write it cannot undo', async () => {
|
|
140
|
+
const { storage, del } = drainingProvider();
|
|
141
|
+
await expect(uploadWithContentHash(storage, Readable.from([Buffer.from('abc')]), opts(10, { targetStoragePath: 'media/fixed.bin' }))).rejects.toMatchObject({
|
|
142
|
+
details: expect.objectContaining({ objectDeleted: false, replacementUndone: false }),
|
|
143
|
+
});
|
|
144
|
+
expect(del).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
it('fails when the provider resolves without consuming the stream', async () => {
|
|
147
|
+
const { storage, del } = drainingProvider({ consume: 'none' });
|
|
148
|
+
const input = Readable.from([Buffer.from('abc')]);
|
|
149
|
+
await expect(uploadWithContentHash(storage, input, opts(3))).rejects.toMatchObject({
|
|
150
|
+
code: ErrorCodes.STORAGE,
|
|
151
|
+
details: expect.objectContaining({ reason: 'stream-not-consumed' }),
|
|
152
|
+
});
|
|
153
|
+
expect(del).toHaveBeenCalledOnce();
|
|
154
|
+
expect(input.destroyed).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
describe('cleanup when the provider or source fails', () => {
|
|
157
|
+
function rejectingProvider(readBeforeFailing) {
|
|
158
|
+
let received;
|
|
159
|
+
const storage = {
|
|
160
|
+
providerName: 'test',
|
|
161
|
+
upload: async (input) => {
|
|
162
|
+
received = input;
|
|
163
|
+
let read = 0;
|
|
164
|
+
if (readBeforeFailing > 0) {
|
|
165
|
+
for await (const chunk of received) {
|
|
166
|
+
read += chunk.length;
|
|
167
|
+
if (read >= readBeforeFailing)
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error('provider refused');
|
|
172
|
+
},
|
|
173
|
+
delete: vi.fn(async () => { }),
|
|
174
|
+
getUrl: (p) => p,
|
|
175
|
+
};
|
|
176
|
+
return { storage, received: () => received };
|
|
177
|
+
}
|
|
178
|
+
it('destroys both sides when the provider rejects before reading', async () => {
|
|
179
|
+
const { storage, received } = rejectingProvider(0);
|
|
180
|
+
const input = endless();
|
|
181
|
+
await expect(uploadWithContentHash(storage, input, opts(10))).rejects.toThrow('provider refused');
|
|
182
|
+
expect(input.destroyed).toBe(true);
|
|
183
|
+
expect(received()?.destroyed).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
it('destroys both sides when the provider rejects part-way', async () => {
|
|
186
|
+
const { storage, received } = rejectingProvider(4096);
|
|
187
|
+
const input = endless();
|
|
188
|
+
await expect(uploadWithContentHash(storage, input, opts(1_000_000))).rejects.toThrow('provider refused');
|
|
189
|
+
expect(input.destroyed).toBe(true);
|
|
190
|
+
expect(received()?.destroyed).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
it('surfaces a source failure through the provider and releases the input', async () => {
|
|
193
|
+
const { storage } = drainingProvider();
|
|
194
|
+
const input = new Readable({ read() { } });
|
|
195
|
+
const pending = uploadWithContentHash(storage, input, opts(100));
|
|
196
|
+
input.push(Buffer.from('partial'));
|
|
197
|
+
setTimeout(() => input.destroy(new Error('source broke')), 5);
|
|
198
|
+
await expect(pending).rejects.toThrow('source broke');
|
|
199
|
+
expect(input.destroyed).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
it('absorbs a source error raised after the provider has rejected', async () => {
|
|
202
|
+
const { storage } = rejectingProvider(0);
|
|
203
|
+
const input = new Readable({ read() { } });
|
|
204
|
+
await expect(uploadWithContentHash(storage, input, opts(10))).rejects.toThrow('provider refused');
|
|
205
|
+
// Would be an unhandled 'error' event (and fail the run) without cleanup.
|
|
206
|
+
input.emit('error', new Error('late'));
|
|
207
|
+
});
|
|
208
|
+
it('reports objectDeleted: false when deleting the partial object fails', async () => {
|
|
209
|
+
const { storage, del } = drainingProvider();
|
|
210
|
+
del.mockRejectedValueOnce(new Error('delete failed'));
|
|
211
|
+
await expect(uploadWithContentHash(storage, Readable.from([Buffer.from('abc')]), opts(10))).rejects.toMatchObject({
|
|
212
|
+
details: expect.objectContaining({ reason: 'size-mismatch', objectDeleted: false }),
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.
|
|
5
|
+
"version": "6.12.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.22.2"
|
|
8
8
|
},
|
|
@@ -51,6 +51,11 @@
|
|
|
51
51
|
"import": "./dist/workflow/index.js",
|
|
52
52
|
"require": "./dist/workflow/index.js"
|
|
53
53
|
},
|
|
54
|
+
"./stored-files": {
|
|
55
|
+
"types": "./dist/storage/stored-files-server.d.ts",
|
|
56
|
+
"import": "./dist/storage/stored-files-server.js",
|
|
57
|
+
"require": "./dist/storage/stored-files-server.js"
|
|
58
|
+
},
|
|
54
59
|
"./scheduler": {
|
|
55
60
|
"types": "./dist/scheduler/index.d.ts",
|
|
56
61
|
"import": "./dist/scheduler/index.js",
|
|
@@ -82,7 +87,7 @@
|
|
|
82
87
|
"dist"
|
|
83
88
|
],
|
|
84
89
|
"dependencies": {
|
|
85
|
-
"@byline/auth": "6.
|
|
90
|
+
"@byline/auth": "6.12.0",
|
|
86
91
|
"dotenv": "^18.0.8",
|
|
87
92
|
"pino": "^10.4.0",
|
|
88
93
|
"sharp": "^0.35.5",
|