@byline/core 3.18.0 → 3.19.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/collection-types.d.ts +88 -3
- package/dist/@types/db-types.d.ts +26 -0
- package/dist/@types/storage-types.d.ts +21 -0
- package/dist/services/assign-counter-values.test.node.js +1 -0
- package/dist/services/collection-bootstrap.test.node.js +2 -0
- package/dist/services/discover-counter-groups.test.node.js +1 -0
- package/dist/services/document-lifecycle.test.node.js +1 -0
- package/dist/services/field-upload.js +40 -8
- package/dist/services/field-upload.test.node.js +79 -0
- package/dist/services/populate.test.node.js +1 -0
- package/package.json +2 -2
|
@@ -132,6 +132,52 @@ export interface UploadConfig {
|
|
|
132
132
|
* @see UploadHooks
|
|
133
133
|
*/
|
|
134
134
|
hooks?: UploadHooks | UploadHooksLoader;
|
|
135
|
+
/**
|
|
136
|
+
* Form-value paths the admin upload executor resolves at submit time and
|
|
137
|
+
* posts alongside the file, so server-side `beforeStore` / `afterStore`
|
|
138
|
+
* hooks receive document context in `ctx.fields` (always strings —
|
|
139
|
+
* multipart form values arrive untyped).
|
|
140
|
+
*
|
|
141
|
+
* Paths use filesystem-style resolution relative to the upload field's
|
|
142
|
+
* containing scope. For a field at `files[2].filesGroup.publicationFile`:
|
|
143
|
+
*
|
|
144
|
+
* - `'language'` (bare / `./`) → sibling: `files[2].filesGroup.language`
|
|
145
|
+
* - `'../label'` → one level up: `files[2].label`
|
|
146
|
+
* - `'/serialNumber'` → document root: `serialNumber`
|
|
147
|
+
*
|
|
148
|
+
* Each resolved value is posted under the path's **leaf name** (`language`,
|
|
149
|
+
* `serialNumber`); when two context paths share a leaf name the later
|
|
150
|
+
* declaration wins. Serialisation: strings pass through; numbers/booleans
|
|
151
|
+
* are stringified; relation envelopes post their `targetDocumentId`
|
|
152
|
+
* (hasMany: comma-joined); `null`/`undefined` values are omitted; anything
|
|
153
|
+
* else is JSON-stringified.
|
|
154
|
+
*
|
|
155
|
+
* Independent of `context`, the executor always posts `documentId` (edit
|
|
156
|
+
* mode only — absent while the document is unsaved) and `fieldPath` (the
|
|
157
|
+
* full form path of the upload field, e.g.
|
|
158
|
+
* `files[2].filesGroup.publicationFile`).
|
|
159
|
+
*
|
|
160
|
+
* Hooks must treat all of these as **client-supplied claims** and re-verify
|
|
161
|
+
* anything security- or integrity-relevant server-side (e.g. fetch the
|
|
162
|
+
* document by `documentId` rather than trusting a posted serial).
|
|
163
|
+
*/
|
|
164
|
+
context?: string[];
|
|
165
|
+
/**
|
|
166
|
+
* When `true`, the admin renders a "save this document first" notice in
|
|
167
|
+
* place of the upload widget until the document has been persisted (i.e.
|
|
168
|
+
* the form is in edit mode with a document id). Existing stored values
|
|
169
|
+
* still render normally — only the *upload* affordance is gated.
|
|
170
|
+
*
|
|
171
|
+
* Use this when server-side upload hooks depend on state that exists only
|
|
172
|
+
* after the first save — allocator-assigned `counter` fields, the document
|
|
173
|
+
* id itself — e.g. a `beforeStore` hook that derives the storage key from
|
|
174
|
+
* the document's serial number.
|
|
175
|
+
*
|
|
176
|
+
* Admin-side UX only: API callers can still upload without a document.
|
|
177
|
+
* Enforce the invariant server-side in a `beforeStore` hook (reject with
|
|
178
|
+
* `{ error }` when the required context is missing).
|
|
179
|
+
*/
|
|
180
|
+
requireSavedDocument?: boolean;
|
|
135
181
|
}
|
|
136
182
|
/**
|
|
137
183
|
* The three status names that every workflow must contain.
|
|
@@ -520,7 +566,19 @@ export interface TreeChangeContext {
|
|
|
520
566
|
*
|
|
521
567
|
* - Rename the file by returning a string or `{ filename }`. The
|
|
522
568
|
* override is threaded into `storage.upload(...)`, so generated
|
|
523
|
-
* image variants automatically inherit the new prefix.
|
|
569
|
+
* image variants automatically inherit the new prefix. The storage
|
|
570
|
+
* provider still derives the final key (e.g. local storage prefixes
|
|
571
|
+
* `<collection>/<uuid>-` for collision avoidance).
|
|
572
|
+
* - Take **full control of the storage key** by returning
|
|
573
|
+
* `{ storagePath }` — a fully-qualified, POSIX-style path (no
|
|
574
|
+
* leading slash) that is threaded into `storage.upload(...)` as
|
|
575
|
+
* `targetStoragePath` and written **verbatim**: no UUID prefix, no
|
|
576
|
+
* collection namespace, no provider rewriting. The hook assumes
|
|
577
|
+
* responsibility for sanitisation and collision avoidance (see
|
|
578
|
+
* `UploadFileOptions.targetStoragePath`). Generated image variants
|
|
579
|
+
* derive their sibling paths from the custom original. When
|
|
580
|
+
* `storagePath` is returned without `filename`, the stored
|
|
581
|
+
* `filename` defaults to the path's basename.
|
|
524
582
|
* - Reject the upload by returning `{ error }`. Surfaces as
|
|
525
583
|
* `ERR_VALIDATION` with the supplied message; no file is written,
|
|
526
584
|
* no variants are generated, no document is created, no later
|
|
@@ -529,7 +587,8 @@ export interface TreeChangeContext {
|
|
|
529
587
|
*
|
|
530
588
|
* When configured as an array, hooks fold: each function receives the
|
|
531
589
|
* filename returned by the previous function (or the original sanitised
|
|
532
|
-
* filename if the previous returned `void`).
|
|
590
|
+
* filename if the previous returned `void`), and `ctx.storagePath`
|
|
591
|
+
* carries the most recent explicit storage-path override (if any).
|
|
533
592
|
*/
|
|
534
593
|
export interface BeforeStoreContext {
|
|
535
594
|
/** Name of the image/file field receiving this upload. */
|
|
@@ -538,6 +597,12 @@ export interface BeforeStoreContext {
|
|
|
538
597
|
field: ImageField | FileField;
|
|
539
598
|
/** Sanitised default filename. Hooks may override. */
|
|
540
599
|
filename: string;
|
|
600
|
+
/**
|
|
601
|
+
* Explicit storage-path override set by an earlier hook in the chain
|
|
602
|
+
* (via `{ storagePath }`), if any. `undefined` means the storage
|
|
603
|
+
* provider will derive the key itself from `filename` + `collection`.
|
|
604
|
+
*/
|
|
605
|
+
storagePath?: string;
|
|
541
606
|
mimeType: string;
|
|
542
607
|
fileSize: number;
|
|
543
608
|
/**
|
|
@@ -550,22 +615,37 @@ export interface BeforeStoreContext {
|
|
|
550
615
|
collectionPath: string;
|
|
551
616
|
/** Authenticated request context. `actor.id`, `actor.tenantId`, etc. for prefixing. */
|
|
552
617
|
requestContext: RequestContext;
|
|
618
|
+
/**
|
|
619
|
+
* The storage provider this upload will be written to (the field's
|
|
620
|
+
* `upload.storage` or the site-wide default). Lets hooks feature-detect
|
|
621
|
+
* and use the optional provider capabilities — e.g. `storage.exists?.(key)`
|
|
622
|
+
* to collision-check an explicit `{ storagePath }` before claiming it.
|
|
623
|
+
*/
|
|
624
|
+
storage: IStorageProvider;
|
|
553
625
|
}
|
|
554
626
|
/**
|
|
555
627
|
* Result returned by a `beforeStore` hook.
|
|
556
628
|
*
|
|
557
629
|
* - `string` → override filename (shorthand).
|
|
558
|
-
* - `{ filename }` → override filename (object form).
|
|
630
|
+
* - `{ filename }` → override filename (object form). The storage
|
|
631
|
+
* provider still derives the final key.
|
|
632
|
+
* - `{ storagePath }` → take full control of the storage key: written
|
|
633
|
+
* verbatim via `UploadFileOptions.targetStoragePath`
|
|
634
|
+
* (no UUID prefix / provider rewriting). May be
|
|
635
|
+
* combined with `filename`; without it, the stored
|
|
636
|
+
* filename defaults to the path's basename.
|
|
559
637
|
* - `{ error }` → reject the upload; surfaces as
|
|
560
638
|
* `ERR_VALIDATION`. Short-circuits the chain.
|
|
561
639
|
* - `void` / undefined → keep current defaults.
|
|
562
640
|
*/
|
|
563
641
|
export type BeforeStoreResult = string | {
|
|
564
642
|
filename?: string;
|
|
643
|
+
storagePath?: string;
|
|
565
644
|
error?: undefined;
|
|
566
645
|
} | {
|
|
567
646
|
error: string;
|
|
568
647
|
filename?: undefined;
|
|
648
|
+
storagePath?: undefined;
|
|
569
649
|
} | void;
|
|
570
650
|
/**
|
|
571
651
|
* A `beforeStore` hook function. Async-capable.
|
|
@@ -595,6 +675,11 @@ export interface AfterStoreContext {
|
|
|
595
675
|
fields: Record<string, string>;
|
|
596
676
|
collectionPath: string;
|
|
597
677
|
requestContext: RequestContext;
|
|
678
|
+
/**
|
|
679
|
+
* The storage provider the file was written to. See
|
|
680
|
+
* {@link BeforeStoreContext.storage}.
|
|
681
|
+
*/
|
|
682
|
+
storage: IStorageProvider;
|
|
598
683
|
}
|
|
599
684
|
/** An `afterStore` hook function. Async-capable. */
|
|
600
685
|
export type AfterStoreHookFn = (ctx: AfterStoreContext) => void | Promise<void>;
|
|
@@ -424,6 +424,32 @@ export interface ICounterCommands {
|
|
|
424
424
|
* and deletes. The facet-URL use case does not require gapless IDs.
|
|
425
425
|
*/
|
|
426
426
|
nextCounterValue(groupName: string): Promise<number>;
|
|
427
|
+
/**
|
|
428
|
+
* Atomically allocate the next value from a **runtime-scoped** counter
|
|
429
|
+
* group, self-registering the group on first use.
|
|
430
|
+
*
|
|
431
|
+
* The schema-declared counter mechanism above is deliberately static:
|
|
432
|
+
* groups come from `CounterField.group` strings, are discovered and
|
|
433
|
+
* registered once at boot, and `nextCounterValue` throws for anything
|
|
434
|
+
* unregistered — that honesty catches configuration bugs. But some
|
|
435
|
+
* sequences are keyed on *data* that only exists at runtime — e.g. a
|
|
436
|
+
* per-document sub-sequence like "files attached to publication X",
|
|
437
|
+
* where each value must be unique and never reused within that scope
|
|
438
|
+
* (`publications:<documentId>:files` → 1, 2, 3, …). Those scopes cannot
|
|
439
|
+
* be known at boot, so this method performs an idempotent
|
|
440
|
+
* ensure-then-allocate instead of throwing on the first use.
|
|
441
|
+
*
|
|
442
|
+
* Semantics are otherwise identical to `nextCounterValue`: values are
|
|
443
|
+
* monotonic per scope and never reused (a Postgres SEQUENCE per scope
|
|
444
|
+
* in the Postgres adapter); gaps are expected on rolled-back or
|
|
445
|
+
* abandoned work.
|
|
446
|
+
*
|
|
447
|
+
* Callers own the scope-name namespace. Use a stable, collision-proof
|
|
448
|
+
* convention — `'<collectionPath>:<documentId>:<purpose>'` is
|
|
449
|
+
* recommended — and never derive scope names from mutable data
|
|
450
|
+
* (renaming a scope starts a fresh sequence at 1).
|
|
451
|
+
*/
|
|
452
|
+
nextScopedCounterValue(scopeName: string): Promise<number>;
|
|
427
453
|
}
|
|
428
454
|
export interface ICollectionCommands {
|
|
429
455
|
/**
|
|
@@ -97,4 +97,25 @@ export interface IStorageProvider {
|
|
|
97
97
|
* signing logic here.
|
|
98
98
|
*/
|
|
99
99
|
getUrl(storagePath: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* Move (re-key) a previously stored file from one `storage_path` to
|
|
102
|
+
* another, returning the new location metadata. The destination path is
|
|
103
|
+
* written verbatim — same contract as `UploadFileOptions.targetStoragePath`
|
|
104
|
+
* (POSIX-style, no leading slash; the caller owns sanitisation and
|
|
105
|
+
* collision avoidance). Throws if the source does not exist.
|
|
106
|
+
*
|
|
107
|
+
* **Optional capability** — both first-party providers (`storage-local`,
|
|
108
|
+
* `storage-s3`) implement it, but custom providers may not. Callers must
|
|
109
|
+
* feature-detect (`if (storage.move) …`) before relying on it.
|
|
110
|
+
*/
|
|
111
|
+
move?(fromPath: string, toPath: string): Promise<StoredFileLocation>;
|
|
112
|
+
/**
|
|
113
|
+
* Report whether a file exists at the given `storage_path`. Useful for
|
|
114
|
+
* collision checks when callers (e.g. `beforeStore` hooks assigning
|
|
115
|
+
* explicit storage keys via `{ storagePath }`) need to guarantee a key
|
|
116
|
+
* is free before claiming it.
|
|
117
|
+
*
|
|
118
|
+
* **Optional capability** — see `move`.
|
|
119
|
+
*/
|
|
120
|
+
exists?(storagePath: string): Promise<boolean>;
|
|
100
121
|
}
|
|
@@ -52,6 +52,7 @@ function createMockDb(options) {
|
|
|
52
52
|
counters: {
|
|
53
53
|
ensureCounterGroup: vi.fn(fail),
|
|
54
54
|
nextCounterValue: vi.fn(fail),
|
|
55
|
+
nextScopedCounterValue: vi.fn(fail),
|
|
55
56
|
},
|
|
56
57
|
},
|
|
57
58
|
queries: {
|
|
@@ -203,6 +204,7 @@ describe('ensureCollections', () => {
|
|
|
203
204
|
counters: {
|
|
204
205
|
ensureCounterGroup: vi.fn(),
|
|
205
206
|
nextCounterValue: vi.fn(),
|
|
207
|
+
nextScopedCounterValue: vi.fn(),
|
|
206
208
|
},
|
|
207
209
|
},
|
|
208
210
|
queries: {
|
|
@@ -69,18 +69,22 @@ function normalizeUploadHook(hook) {
|
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* Run the `beforeStore` chain. Each function receives the previous
|
|
72
|
-
* function's filename
|
|
72
|
+
* function's filename / storage-path overrides (fold). A function may:
|
|
73
73
|
*
|
|
74
74
|
* - return a string or `{ filename }` to substitute a new filename;
|
|
75
|
+
* - return `{ storagePath }` to take full control of the storage key
|
|
76
|
+
* (bypasses provider key derivation — no UUID prefix). When set
|
|
77
|
+
* without `filename`, the filename defaults to the path's basename;
|
|
75
78
|
* - return `{ error }` to short-circuit with `ERR_VALIDATION`;
|
|
76
|
-
* - return `void` / `undefined` to leave
|
|
79
|
+
* - return `void` / `undefined` to leave current values unchanged.
|
|
77
80
|
*
|
|
78
|
-
* Returns the resolved filename.
|
|
81
|
+
* Returns the resolved filename and optional storage path.
|
|
79
82
|
*/
|
|
80
83
|
async function runBeforeStoreChain(hooks, ctx, logger) {
|
|
81
84
|
let effective = ctx.filename;
|
|
85
|
+
let effectiveStoragePath;
|
|
82
86
|
for (const fn of hooks) {
|
|
83
|
-
const result = await fn({ ...ctx, filename: effective });
|
|
87
|
+
const result = await fn({ ...ctx, filename: effective, storagePath: effectiveStoragePath });
|
|
84
88
|
if (result == null)
|
|
85
89
|
continue;
|
|
86
90
|
if (typeof result === 'string') {
|
|
@@ -96,14 +100,35 @@ async function runBeforeStoreChain(hooks, ctx, logger) {
|
|
|
96
100
|
details: { collectionPath: ctx.collectionPath, fieldName: ctx.fieldName },
|
|
97
101
|
}, runBeforeStoreChain).log(logger);
|
|
98
102
|
}
|
|
103
|
+
let filenameOverridden = false;
|
|
99
104
|
if ('filename' in result && typeof result.filename === 'string') {
|
|
100
105
|
const trimmed = result.filename.trim();
|
|
101
|
-
if (trimmed)
|
|
106
|
+
if (trimmed) {
|
|
102
107
|
effective = trimmed;
|
|
108
|
+
filenameOverridden = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if ('storagePath' in result && typeof result.storagePath === 'string') {
|
|
112
|
+
// Normalise: POSIX-style, no leading slash (matches the
|
|
113
|
+
// UploadFileOptions.targetStoragePath contract).
|
|
114
|
+
const trimmed = result.storagePath.trim().replace(/^\/+/, '');
|
|
115
|
+
if (trimmed) {
|
|
116
|
+
effectiveStoragePath = trimmed;
|
|
117
|
+
// Keep the stored filename in sync with the explicit key
|
|
118
|
+
// unless the hook also set filename itself.
|
|
119
|
+
if (!filenameOverridden) {
|
|
120
|
+
effective = posixBasename(trimmed);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
103
123
|
}
|
|
104
124
|
}
|
|
105
125
|
}
|
|
106
|
-
return effective;
|
|
126
|
+
return { filename: effective, storagePath: effectiveStoragePath };
|
|
127
|
+
}
|
|
128
|
+
/** Last path segment of a POSIX-style storage path. */
|
|
129
|
+
function posixBasename(storagePath) {
|
|
130
|
+
const idx = storagePath.lastIndexOf('/');
|
|
131
|
+
return idx === -1 ? storagePath : storagePath.slice(idx + 1);
|
|
107
132
|
}
|
|
108
133
|
export async function uploadField(ctx, params) {
|
|
109
134
|
return withLogContext({ domain: 'services', module: 'upload', function: 'uploadField' }, async () => {
|
|
@@ -163,7 +188,7 @@ export async function uploadField(ctx, params) {
|
|
|
163
188
|
// graphs out of the client bundle; the inline form returns as-is.
|
|
164
189
|
const uploadHooks = await resolveUploadHooks(upload.hooks);
|
|
165
190
|
const beforeStoreHooks = normalizeUploadHook(uploadHooks?.beforeStore);
|
|
166
|
-
const effectiveFilename = await runBeforeStoreChain(beforeStoreHooks, {
|
|
191
|
+
const { filename: effectiveFilename, storagePath: effectiveStoragePath } = await runBeforeStoreChain(beforeStoreHooks, {
|
|
167
192
|
fieldName,
|
|
168
193
|
field,
|
|
169
194
|
filename: sanitised,
|
|
@@ -176,9 +201,14 @@ export async function uploadField(ctx, params) {
|
|
|
176
201
|
requestId: '',
|
|
177
202
|
readMode: 'any',
|
|
178
203
|
},
|
|
204
|
+
storage,
|
|
179
205
|
}, logger);
|
|
180
206
|
// -- Storage write. Filename is the post-hook value, so generated
|
|
181
|
-
// variants automatically inherit the new prefix.
|
|
207
|
+
// variants automatically inherit the new prefix. An explicit
|
|
208
|
+
// `storagePath` from the chain is threaded through as
|
|
209
|
+
// `targetStoragePath`, which providers write verbatim (no UUID
|
|
210
|
+
// prefix / key derivation) — variants still derive sibling paths
|
|
211
|
+
// from the resulting `storedFile.storagePath`.
|
|
182
212
|
let storedFile;
|
|
183
213
|
try {
|
|
184
214
|
storedFile = await storage.upload(buffer, {
|
|
@@ -186,6 +216,7 @@ export async function uploadField(ctx, params) {
|
|
|
186
216
|
mimeType,
|
|
187
217
|
size: fileSize,
|
|
188
218
|
collection: collectionPath,
|
|
219
|
+
...(effectiveStoragePath !== undefined && { targetStoragePath: effectiveStoragePath }),
|
|
189
220
|
});
|
|
190
221
|
}
|
|
191
222
|
catch (err) {
|
|
@@ -261,6 +292,7 @@ export async function uploadField(ctx, params) {
|
|
|
261
292
|
requestId: '',
|
|
262
293
|
readMode: 'any',
|
|
263
294
|
},
|
|
295
|
+
storage,
|
|
264
296
|
};
|
|
265
297
|
await fn(afterCtx);
|
|
266
298
|
}
|
|
@@ -66,6 +66,7 @@ function createMockDb() {
|
|
|
66
66
|
counters: {
|
|
67
67
|
ensureCounterGroup: vi.fn(),
|
|
68
68
|
nextCounterValue: vi.fn(),
|
|
69
|
+
nextScopedCounterValue: vi.fn(),
|
|
69
70
|
},
|
|
70
71
|
},
|
|
71
72
|
queries: {
|
|
@@ -248,6 +249,84 @@ describe('uploadField service', () => {
|
|
|
248
249
|
});
|
|
249
250
|
expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({ filename: 'PUB-42-tenant-hero.png' }));
|
|
250
251
|
});
|
|
252
|
+
it('beforeStore { storagePath } threads targetStoragePath verbatim and derives the filename from its basename', async () => {
|
|
253
|
+
const definition = withFieldUpload(uploadCollection, 'image', (f) => {
|
|
254
|
+
f.upload.hooks = {
|
|
255
|
+
beforeStore: () => ({ storagePath: 'publications/forru-0000447-0001-en.png' }),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
const { ctx, upload } = buildCtx({ definition });
|
|
259
|
+
const result = await uploadField(ctx, {
|
|
260
|
+
buffer: Buffer.from('png'),
|
|
261
|
+
originalFilename: 'hero.png',
|
|
262
|
+
mimeType: 'image/png',
|
|
263
|
+
fileSize: 3,
|
|
264
|
+
shouldCreateDocument: false,
|
|
265
|
+
});
|
|
266
|
+
// Explicit key is handed to the provider verbatim — no UUID prefix,
|
|
267
|
+
// no collection-derived key.
|
|
268
|
+
expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
|
|
269
|
+
targetStoragePath: 'publications/forru-0000447-0001-en.png',
|
|
270
|
+
filename: 'forru-0000447-0001-en.png',
|
|
271
|
+
}));
|
|
272
|
+
// Stored filename follows the explicit key's basename.
|
|
273
|
+
expect(result.storedFile.filename).toBe('forru-0000447-0001-en.png');
|
|
274
|
+
});
|
|
275
|
+
it('beforeStore { storagePath, filename } honours both; a leading slash is stripped from the key', async () => {
|
|
276
|
+
const definition = withFieldUpload(uploadCollection, 'image', (f) => {
|
|
277
|
+
f.upload.hooks = {
|
|
278
|
+
beforeStore: () => ({
|
|
279
|
+
storagePath: '/publications/forru-0000447-0002-en.png',
|
|
280
|
+
filename: 'display-name.png',
|
|
281
|
+
}),
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
const { ctx, upload } = buildCtx({ definition });
|
|
285
|
+
const result = await uploadField(ctx, {
|
|
286
|
+
buffer: Buffer.from('png'),
|
|
287
|
+
originalFilename: 'hero.png',
|
|
288
|
+
mimeType: 'image/png',
|
|
289
|
+
fileSize: 3,
|
|
290
|
+
shouldCreateDocument: false,
|
|
291
|
+
});
|
|
292
|
+
expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
|
|
293
|
+
targetStoragePath: 'publications/forru-0000447-0002-en.png',
|
|
294
|
+
filename: 'display-name.png',
|
|
295
|
+
}));
|
|
296
|
+
expect(result.storedFile.filename).toBe('display-name.png');
|
|
297
|
+
});
|
|
298
|
+
it('folds storagePath through a chain — later hooks see ctx.storagePath and may override it', async () => {
|
|
299
|
+
const secondHook = vi.fn(({ storagePath }) => ({
|
|
300
|
+
storagePath: storagePath.replace('-draft', ''),
|
|
301
|
+
}));
|
|
302
|
+
const definition = withFieldUpload(uploadCollection, 'image', (f) => {
|
|
303
|
+
f.upload.hooks = {
|
|
304
|
+
beforeStore: [() => ({ storagePath: 'publications/report-draft.png' }), secondHook],
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
const { ctx, upload } = buildCtx({ definition });
|
|
308
|
+
await uploadField(ctx, {
|
|
309
|
+
buffer: Buffer.from('png'),
|
|
310
|
+
originalFilename: 'hero.png',
|
|
311
|
+
mimeType: 'image/png',
|
|
312
|
+
fileSize: 3,
|
|
313
|
+
shouldCreateDocument: false,
|
|
314
|
+
});
|
|
315
|
+
expect(secondHook).toHaveBeenCalledWith(expect.objectContaining({ storagePath: 'publications/report-draft.png' }));
|
|
316
|
+
expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({ targetStoragePath: 'publications/report.png' }));
|
|
317
|
+
});
|
|
318
|
+
it('no storagePath override → storage.upload receives no targetStoragePath', async () => {
|
|
319
|
+
const { ctx, upload } = buildCtx();
|
|
320
|
+
await uploadField(ctx, {
|
|
321
|
+
buffer: Buffer.from('png'),
|
|
322
|
+
originalFilename: 'hero.png',
|
|
323
|
+
mimeType: 'image/png',
|
|
324
|
+
fileSize: 3,
|
|
325
|
+
shouldCreateDocument: false,
|
|
326
|
+
});
|
|
327
|
+
const options = upload.mock.calls[0]?.[1];
|
|
328
|
+
expect(options).not.toHaveProperty('targetStoragePath');
|
|
329
|
+
});
|
|
251
330
|
it('beforeStore { error } short-circuits — no storage write, no variants, no afterStore', async () => {
|
|
252
331
|
const afterStore = vi.fn();
|
|
253
332
|
const definition = withFieldUpload(uploadCollection, 'image', (f) => {
|
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": "3.
|
|
5
|
+
"version": "3.19.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"pino": "^10.3.1",
|
|
77
77
|
"sharp": "^0.35.3",
|
|
78
78
|
"zod": "^4.4.3",
|
|
79
|
-
"@byline/auth": "3.
|
|
79
|
+
"@byline/auth": "3.19.0"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@biomejs/biome": "2.5.2",
|