@byline/core 3.17.1 → 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.
@@ -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.
@@ -141,9 +187,9 @@ export interface UploadConfig {
141
187
  *
142
188
  * `[draft, ...customStatuses, published, archived]`
143
189
  */
144
- export declare const WORKFLOW_STATUS_DRAFT: "draft";
145
- export declare const WORKFLOW_STATUS_PUBLISHED: "published";
146
- export declare const WORKFLOW_STATUS_ARCHIVED: "archived";
190
+ export declare const WORKFLOW_STATUS_DRAFT: 'draft';
191
+ export declare const WORKFLOW_STATUS_PUBLISHED: 'published';
192
+ export declare const WORKFLOW_STATUS_ARCHIVED: 'archived';
147
193
  export declare const REQUIRED_WORKFLOW_STATUSES: readonly ["draft", "published", "archived"];
148
194
  export type RequiredWorkflowStatusName = (typeof REQUIRED_WORKFLOW_STATUSES)[number];
149
195
  /**
@@ -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
  /**
@@ -144,6 +144,24 @@ export type TranslationBundleShape = Readonly<{
144
144
  export interface ClientConfig extends BaseConfig {
145
145
  /** Admin UI configuration for collections (client-side only). */
146
146
  admin?: CollectionAdminConfig[];
147
+ /**
148
+ * Installation-wide slugifier — the **client-side** copy, used by the
149
+ * admin path-widget to render the live `path` preview (create-mode
150
+ * placeholder and the "Regenerate" target) as the editor types.
151
+ *
152
+ * Must be the *same* pure, synchronous function registered on
153
+ * `ServerConfig.slugifier`: the server derivation is authoritative, and a
154
+ * divergent client copy would show a preview that disagrees with what is
155
+ * persisted (and could let "Regenerate" overwrite a correct path). Falls
156
+ * back to the default `slugify` from `@byline/core` when not set — so
157
+ * installations that keep the default slugifier need not set this at all.
158
+ *
159
+ * Lives on `ClientConfig` rather than `BaseConfig` because it is a function
160
+ * (not serialisable), and `BaseConfig` is contractually serialisable.
161
+ *
162
+ * @see ServerConfig.slugifier
163
+ */
164
+ slugifier?: SlugifierFn;
147
165
  /**
148
166
  * Site-wide field-level UI defaults. Currently surfaces the richtext
149
167
  * editor adapter slot — additional field-level defaults (custom
@@ -214,6 +232,11 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
214
232
  * Falls back to the default `slugify` from `@byline/core` when not set.
215
233
  * Must be pure and synchronous — it runs server-side at write time and
216
234
  * client-side for live form preview, and the two must agree on output.
235
+ *
236
+ * This is the **server-side** copy (the authoritative one — its output is
237
+ * what gets persisted). For the admin path-widget's live preview to match,
238
+ * register the *same* function on `ClientConfig.slugifier` via
239
+ * `defineClientConfig`. See {@link ClientConfig.slugifier}.
217
240
  */
218
241
  slugifier?: SlugifierFn;
219
242
  /**
@@ -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
  }
@@ -36,7 +36,7 @@ import type { CollectionDefinition } from '../@types/index.js';
36
36
  */
37
37
  export declare function registerCollectionAbilities(registry: AbilityRegistry, definition: CollectionDefinition): void;
38
38
  /** The ability suffixes that every collection contributes. Exposed for contract tests. */
39
- export declare const COLLECTION_ABILITY_VERBS: readonly ["read", "create", "update", "delete", "publish", "changeStatus", "reindex"];
39
+ export declare const COLLECTION_ABILITY_VERBS: readonly ['read', 'create', 'update', 'delete', 'publish', 'changeStatus', 'reindex'];
40
40
  export type CollectionAbilityVerb = (typeof COLLECTION_ABILITY_VERBS)[number];
41
41
  /** Compute the full ability key for a collection path and verb. */
42
42
  export declare function collectionAbilityKey(path: string, verb: CollectionAbilityVerb): string;
@@ -90,6 +90,10 @@ export function getClientConfig() {
90
90
  routes: serverConfig.routes,
91
91
  collections: serverConfig.collections,
92
92
  admin: [],
93
+ // Carry the slugifier through the SSR fallback so a form rendered
94
+ // server-side derives the same path preview as the hydrated client
95
+ // (both configs are meant to register the same function).
96
+ slugifier: serverConfig.slugifier,
93
97
  };
94
98
  }
95
99
  throw new Error('Byline has not been configured yet. Please call defineClientConfig in byline.config.ts first.');
@@ -23,7 +23,8 @@ export declare const RESERVED_FIELD_NAMES: ReadonlySet<string>;
23
23
  * into derived paths via `useAsPath`.
24
24
  * - When `useAsPath` is set, the referenced field must exist at the
25
25
  * top level of the collection and be of a type the slugifier can
26
- * sensibly consume (text-like or date-like).
26
+ * sensibly consume (text-like, date-like, or a numeric identity
27
+ * field — `integer` / `counter`).
27
28
  * - No field may be named `availableLocales`; collections opt into the
28
29
  * editorial available-locales control via `advertiseLocales: true`.
29
30
  * - When `advertiseLocales` is `true`, the collection must have at least
@@ -28,6 +28,14 @@ const USE_AS_PATH_SOURCE_TYPES = new Set([
28
28
  'date',
29
29
  'datetime',
30
30
  'time',
31
+ // Numeric identity fields. `derivePath` stringifies the value before
32
+ // slugifying, so an integer or an allocator-assigned `counter` becomes a
33
+ // clean numeric slug (e.g. `1`, `42`). A replacement slugifier can branch
34
+ // on `collectionPath` to reshape it further — e.g. zero-padding a serial
35
+ // number to a fixed width. `float` / `decimal` are deliberately excluded:
36
+ // their string form carries a `.` which does not belong in a path segment.
37
+ 'integer',
38
+ 'counter',
31
39
  ]);
32
40
  /**
33
41
  * True when any field in the tree (at any nesting depth) is `localized`.
@@ -65,7 +73,8 @@ function walkFields(fields, visit) {
65
73
  * into derived paths via `useAsPath`.
66
74
  * - When `useAsPath` is set, the referenced field must exist at the
67
75
  * top level of the collection and be of a type the slugifier can
68
- * sensibly consume (text-like or date-like).
76
+ * sensibly consume (text-like, date-like, or a numeric identity
77
+ * field — `integer` / `counter`).
69
78
  * - No field may be named `availableLocales`; collections opt into the
70
79
  * editorial available-locales control via `advertiseLocales: true`.
71
80
  * - When `advertiseLocales` is `true`, the collection must have at least
@@ -87,6 +87,28 @@ describe('validateCollections', () => {
87
87
  };
88
88
  expect(() => validateCollections([collection])).not.toThrow();
89
89
  });
90
+ it('accepts useAsPath pointing at a counter field', () => {
91
+ const collection = {
92
+ ...baseCollection,
93
+ fields: [
94
+ { name: 'title', label: 'Title', type: 'text' },
95
+ { name: 'serialNumber', label: 'Serial Number', type: 'counter', group: 'serials' },
96
+ ],
97
+ useAsPath: 'serialNumber',
98
+ };
99
+ expect(() => validateCollections([collection])).not.toThrow();
100
+ });
101
+ it('accepts useAsPath pointing at an integer field', () => {
102
+ const collection = {
103
+ ...baseCollection,
104
+ fields: [
105
+ { name: 'title', label: 'Title', type: 'text' },
106
+ { name: 'issue', label: 'Issue', type: 'integer' },
107
+ ],
108
+ useAsPath: 'issue',
109
+ };
110
+ expect(() => validateCollections([collection])).not.toThrow();
111
+ });
90
112
  // useAsPath deliberately resolves against top-level fields only. A
91
113
  // nested source (inside a group, array, or block) isn't addressable
92
114
  // in the derivation cascade — path is a singular identity anchor, not
@@ -76,17 +76,17 @@ export declare class BylineError extends Error {
76
76
  */
77
77
  export declare const createErrorType: (code: string, logLevel?: LogLevel) => (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
78
78
  export declare const ErrorCodes: {
79
- readonly UNHANDLED: "ERR_UNHANDLED";
80
- readonly NOT_FOUND: "ERR_NOT_FOUND";
81
- readonly CONFLICT: "ERR_CONFLICT";
82
- readonly VALIDATION: "ERR_VALIDATION";
83
- readonly INVALID_TRANSITION: "ERR_INVALID_TRANSITION";
84
- readonly PATCH_FAILED: "ERR_PATCH_FAILED";
85
- readonly DATABASE: "ERR_DATABASE";
86
- readonly STORAGE: "ERR_STORAGE";
87
- readonly READ_BUDGET_EXCEEDED: "ERR_READ_BUDGET_EXCEEDED";
88
- readonly PATH_CONFLICT: "ERR_PATH_CONFLICT";
89
- readonly AUDIT_UNSUPPORTED: "ERR_AUDIT_UNSUPPORTED";
79
+ readonly UNHANDLED: 'ERR_UNHANDLED';
80
+ readonly NOT_FOUND: 'ERR_NOT_FOUND';
81
+ readonly CONFLICT: 'ERR_CONFLICT';
82
+ readonly VALIDATION: 'ERR_VALIDATION';
83
+ readonly INVALID_TRANSITION: 'ERR_INVALID_TRANSITION';
84
+ readonly PATCH_FAILED: 'ERR_PATCH_FAILED';
85
+ readonly DATABASE: 'ERR_DATABASE';
86
+ readonly STORAGE: 'ERR_STORAGE';
87
+ readonly READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED';
88
+ readonly PATH_CONFLICT: 'ERR_PATH_CONFLICT';
89
+ readonly AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED';
90
90
  };
91
91
  export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
92
92
  export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -46,7 +46,7 @@ export function resolveFieldForPath(definition, path) {
46
46
  if (segments.length === 0)
47
47
  return null;
48
48
  const [first, ...rest] = segments;
49
- if (!first || first.kind !== 'field')
49
+ if (first?.kind !== 'field')
50
50
  return null;
51
51
  let current = definition.fields.find((f) => f.name === first.key);
52
52
  if (!current)
@@ -53,9 +53,6 @@ export declare const createCollectionSchemasForPath: (path: string) => {
53
53
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
54
54
  }, z.core.$strip>;
55
55
  full: z.ZodObject<{
56
- fields: z.ZodObject<{
57
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
58
- }, z.core.$strip>;
59
56
  id: z.ZodUUID;
60
57
  versionId: z.ZodOptional<z.ZodUUID>;
61
58
  path: z.ZodOptional<z.ZodString>;
@@ -68,12 +65,12 @@ export declare const createCollectionSchemasForPath: (path: string) => {
68
65
  updatedAt: z.ZodISODateTime;
69
66
  createdBy: z.ZodOptional<z.ZodUUID>;
70
67
  eventType: z.ZodOptional<z.ZodString>;
68
+ fields: z.ZodObject<{
69
+ [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
70
+ }, z.core.$strip>;
71
71
  }, z.core.$strip>;
72
72
  list: z.ZodObject<{
73
73
  docs: z.ZodArray<z.ZodObject<{
74
- fields: z.ZodObject<{
75
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
76
- }, z.core.$strip>;
77
74
  id: z.ZodUUID;
78
75
  versionId: z.ZodOptional<z.ZodUUID>;
79
76
  path: z.ZodOptional<z.ZodString>;
@@ -86,6 +83,9 @@ export declare const createCollectionSchemasForPath: (path: string) => {
86
83
  updatedAt: z.ZodISODateTime;
87
84
  createdBy: z.ZodOptional<z.ZodUUID>;
88
85
  eventType: z.ZodOptional<z.ZodString>;
86
+ fields: z.ZodObject<{
87
+ [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
88
+ }, z.core.$strip>;
89
89
  }, z.core.$strip>>;
90
90
  meta: z.ZodObject<{
91
91
  page: z.ZodNumber;
@@ -108,9 +108,6 @@ export declare const createCollectionSchemasForPath: (path: string) => {
108
108
  }, z.core.$strip>;
109
109
  history: z.ZodObject<{
110
110
  docs: z.ZodArray<z.ZodObject<{
111
- fields: z.ZodObject<{
112
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
113
- }, z.core.$strip>;
114
111
  id: z.ZodUUID;
115
112
  versionId: z.ZodOptional<z.ZodUUID>;
116
113
  path: z.ZodOptional<z.ZodString>;
@@ -123,6 +120,9 @@ export declare const createCollectionSchemasForPath: (path: string) => {
123
120
  updatedAt: z.ZodISODateTime;
124
121
  createdBy: z.ZodOptional<z.ZodUUID>;
125
122
  eventType: z.ZodOptional<z.ZodString>;
123
+ fields: z.ZodObject<{
124
+ [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
125
+ }, z.core.$strip>;
126
126
  }, z.core.$strip>>;
127
127
  meta: z.ZodObject<{
128
128
  page: z.ZodNumber;
@@ -137,9 +137,6 @@ export declare const createCollectionSchemasForPath: (path: string) => {
137
137
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
138
138
  }, z.core.$strip>;
139
139
  get: z.ZodObject<{
140
- fields: z.ZodObject<{
141
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
142
- }, z.core.$strip>;
143
140
  id: z.ZodUUID;
144
141
  versionId: z.ZodOptional<z.ZodUUID>;
145
142
  path: z.ZodOptional<z.ZodString>;
@@ -152,6 +149,9 @@ export declare const createCollectionSchemasForPath: (path: string) => {
152
149
  updatedAt: z.ZodISODateTime;
153
150
  createdBy: z.ZodOptional<z.ZodUUID>;
154
151
  eventType: z.ZodOptional<z.ZodString>;
152
+ fields: z.ZodObject<{
153
+ [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
154
+ }, z.core.$strip>;
155
155
  }, z.core.$strip>;
156
156
  update: z.ZodObject<{
157
157
  [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
@@ -176,93 +176,6 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
176
176
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
177
177
  }, z.core.$strip>;
178
178
  full: z.ZodObject<{
179
- fields: z.ZodObject<{
180
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
181
- }, z.core.$strip>;
182
- id: z.ZodUUID;
183
- versionId: z.ZodOptional<z.ZodUUID>;
184
- path: z.ZodOptional<z.ZodString>;
185
- sourceLocale: z.ZodOptional<z.ZodString>;
186
- status: z.ZodEnum<{
187
- [x: string]: string;
188
- }>;
189
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
190
- createdAt: z.ZodISODateTime;
191
- updatedAt: z.ZodISODateTime;
192
- createdBy: z.ZodOptional<z.ZodUUID>;
193
- eventType: z.ZodOptional<z.ZodString>;
194
- }, z.core.$strip>;
195
- list: z.ZodObject<{
196
- docs: z.ZodArray<z.ZodObject<{
197
- fields: z.ZodObject<{
198
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
199
- }, z.core.$strip>;
200
- id: z.ZodUUID;
201
- versionId: z.ZodOptional<z.ZodUUID>;
202
- path: z.ZodOptional<z.ZodString>;
203
- sourceLocale: z.ZodOptional<z.ZodString>;
204
- status: z.ZodEnum<{
205
- [x: string]: string;
206
- }>;
207
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
208
- createdAt: z.ZodISODateTime;
209
- updatedAt: z.ZodISODateTime;
210
- createdBy: z.ZodOptional<z.ZodUUID>;
211
- eventType: z.ZodOptional<z.ZodString>;
212
- }, z.core.$strip>>;
213
- meta: z.ZodObject<{
214
- page: z.ZodNumber;
215
- pageSize: z.ZodNumber;
216
- total: z.ZodNumber;
217
- totalPages: z.ZodNumber;
218
- order: z.ZodOptional<z.ZodString>;
219
- desc: z.ZodOptional<z.ZodBoolean>;
220
- }, z.core.$strip>;
221
- included: z.ZodObject<{
222
- collection: z.ZodObject<{
223
- id: z.ZodString;
224
- labels: z.ZodObject<{
225
- singular: z.ZodString;
226
- plural: z.ZodString;
227
- }, z.core.$strip>;
228
- path: z.ZodLiteral<string>;
229
- }, z.core.$strip>;
230
- }, z.core.$strip>;
231
- }, z.core.$strip>;
232
- history: z.ZodObject<{
233
- docs: z.ZodArray<z.ZodObject<{
234
- fields: z.ZodObject<{
235
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
236
- }, z.core.$strip>;
237
- id: z.ZodUUID;
238
- versionId: z.ZodOptional<z.ZodUUID>;
239
- path: z.ZodOptional<z.ZodString>;
240
- sourceLocale: z.ZodOptional<z.ZodString>;
241
- status: z.ZodEnum<{
242
- [x: string]: string;
243
- }>;
244
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
245
- createdAt: z.ZodISODateTime;
246
- updatedAt: z.ZodISODateTime;
247
- createdBy: z.ZodOptional<z.ZodUUID>;
248
- eventType: z.ZodOptional<z.ZodString>;
249
- }, z.core.$strip>>;
250
- meta: z.ZodObject<{
251
- page: z.ZodNumber;
252
- pageSize: z.ZodNumber;
253
- total: z.ZodNumber;
254
- totalPages: z.ZodNumber;
255
- order: z.ZodOptional<z.ZodString>;
256
- desc: z.ZodOptional<z.ZodBoolean>;
257
- }, z.core.$strip>;
258
- }, z.core.$strip>;
259
- create: z.ZodObject<{
260
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
261
- }, z.core.$strip>;
262
- get: z.ZodObject<{
263
- fields: z.ZodObject<{
264
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
265
- }, z.core.$strip>;
266
179
  id: z.ZodUUID;
267
180
  versionId: z.ZodOptional<z.ZodUUID>;
268
181
  path: z.ZodOptional<z.ZodString>;
@@ -275,88 +188,12 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
275
188
  updatedAt: z.ZodISODateTime;
276
189
  createdBy: z.ZodOptional<z.ZodUUID>;
277
190
  eventType: z.ZodOptional<z.ZodString>;
278
- }, z.core.$strip>;
279
- update: z.ZodObject<{
280
- [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
281
- }, z.core.$strip>;
282
- };
283
- export declare const createTypedCollectionSchemas: (collection: CollectionDefinition) => {
284
- base: z.ZodObject<{
285
- id: z.ZodUUID;
286
- versionId: z.ZodOptional<z.ZodUUID>;
287
- path: z.ZodOptional<z.ZodString>;
288
- sourceLocale: z.ZodOptional<z.ZodString>;
289
- status: z.ZodEnum<{
290
- [x: string]: string;
291
- }>;
292
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
293
- createdAt: z.ZodISODateTime;
294
- updatedAt: z.ZodISODateTime;
295
- createdBy: z.ZodOptional<z.ZodUUID>;
296
- eventType: z.ZodOptional<z.ZodString>;
297
- }, z.core.$strip>;
298
- fields: z.ZodObject<{
299
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
300
- }, z.core.$strip>;
301
- full: z.ZodObject<{
302
191
  fields: z.ZodObject<{
303
192
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
304
193
  }, z.core.$strip>;
305
- id: z.ZodUUID;
306
- versionId: z.ZodOptional<z.ZodUUID>;
307
- path: z.ZodOptional<z.ZodString>;
308
- sourceLocale: z.ZodOptional<z.ZodString>;
309
- status: z.ZodEnum<{
310
- [x: string]: string;
311
- }>;
312
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
313
- createdAt: z.ZodISODateTime;
314
- updatedAt: z.ZodISODateTime;
315
- createdBy: z.ZodOptional<z.ZodUUID>;
316
- eventType: z.ZodOptional<z.ZodString>;
317
194
  }, z.core.$strip>;
318
195
  list: z.ZodObject<{
319
196
  docs: z.ZodArray<z.ZodObject<{
320
- fields: z.ZodObject<{
321
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
322
- }, z.core.$strip>;
323
- id: z.ZodUUID;
324
- versionId: z.ZodOptional<z.ZodUUID>;
325
- path: z.ZodOptional<z.ZodString>;
326
- sourceLocale: z.ZodOptional<z.ZodString>;
327
- status: z.ZodEnum<{
328
- [x: string]: string;
329
- }>;
330
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
331
- createdAt: z.ZodISODateTime;
332
- updatedAt: z.ZodISODateTime;
333
- createdBy: z.ZodOptional<z.ZodUUID>;
334
- eventType: z.ZodOptional<z.ZodString>;
335
- }, z.core.$strip>>;
336
- meta: z.ZodObject<{
337
- page: z.ZodNumber;
338
- pageSize: z.ZodNumber;
339
- total: z.ZodNumber;
340
- totalPages: z.ZodNumber;
341
- order: z.ZodOptional<z.ZodString>;
342
- desc: z.ZodOptional<z.ZodBoolean>;
343
- }, z.core.$strip>;
344
- included: z.ZodObject<{
345
- collection: z.ZodObject<{
346
- id: z.ZodString;
347
- labels: z.ZodObject<{
348
- singular: z.ZodString;
349
- plural: z.ZodString;
350
- }, z.core.$strip>;
351
- path: z.ZodLiteral<string>;
352
- }, z.core.$strip>;
353
- }, z.core.$strip>;
354
- }, z.core.$strip>;
355
- history: z.ZodObject<{
356
- docs: z.ZodArray<z.ZodObject<{
357
- fields: z.ZodObject<{
358
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
359
- }, z.core.$strip>;
360
197
  id: z.ZodUUID;
361
198
  versionId: z.ZodOptional<z.ZodUUID>;
362
199
  path: z.ZodOptional<z.ZodString>;
@@ -369,92 +206,9 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
369
206
  updatedAt: z.ZodISODateTime;
370
207
  createdBy: z.ZodOptional<z.ZodUUID>;
371
208
  eventType: z.ZodOptional<z.ZodString>;
372
- }, z.core.$strip>>;
373
- meta: z.ZodObject<{
374
- page: z.ZodNumber;
375
- pageSize: z.ZodNumber;
376
- total: z.ZodNumber;
377
- totalPages: z.ZodNumber;
378
- order: z.ZodOptional<z.ZodString>;
379
- desc: z.ZodOptional<z.ZodBoolean>;
380
- }, z.core.$strip>;
381
- }, z.core.$strip>;
382
- create: z.ZodObject<{
383
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
384
- }, z.core.$strip>;
385
- get: z.ZodObject<{
386
- fields: z.ZodObject<{
387
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
388
- }, z.core.$strip>;
389
- id: z.ZodUUID;
390
- versionId: z.ZodOptional<z.ZodUUID>;
391
- path: z.ZodOptional<z.ZodString>;
392
- sourceLocale: z.ZodOptional<z.ZodString>;
393
- status: z.ZodEnum<{
394
- [x: string]: string;
395
- }>;
396
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
397
- createdAt: z.ZodISODateTime;
398
- updatedAt: z.ZodISODateTime;
399
- createdBy: z.ZodOptional<z.ZodUUID>;
400
- eventType: z.ZodOptional<z.ZodString>;
401
- }, z.core.$strip>;
402
- update: z.ZodObject<{
403
- [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
404
- }, z.core.$strip>;
405
- };
406
- export declare const createTypedCollectionSchemasForPath: (path: string) => {
407
- base: z.ZodObject<{
408
- id: z.ZodUUID;
409
- versionId: z.ZodOptional<z.ZodUUID>;
410
- path: z.ZodOptional<z.ZodString>;
411
- sourceLocale: z.ZodOptional<z.ZodString>;
412
- status: z.ZodEnum<{
413
- [x: string]: string;
414
- }>;
415
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
416
- createdAt: z.ZodISODateTime;
417
- updatedAt: z.ZodISODateTime;
418
- createdBy: z.ZodOptional<z.ZodUUID>;
419
- eventType: z.ZodOptional<z.ZodString>;
420
- }, z.core.$strip>;
421
- fields: z.ZodObject<{
422
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
423
- }, z.core.$strip>;
424
- full: z.ZodObject<{
425
- fields: z.ZodObject<{
426
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
427
- }, z.core.$strip>;
428
- id: z.ZodUUID;
429
- versionId: z.ZodOptional<z.ZodUUID>;
430
- path: z.ZodOptional<z.ZodString>;
431
- sourceLocale: z.ZodOptional<z.ZodString>;
432
- status: z.ZodEnum<{
433
- [x: string]: string;
434
- }>;
435
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
436
- createdAt: z.ZodISODateTime;
437
- updatedAt: z.ZodISODateTime;
438
- createdBy: z.ZodOptional<z.ZodUUID>;
439
- eventType: z.ZodOptional<z.ZodString>;
440
- }, z.core.$strip>;
441
- list: z.ZodObject<{
442
- docs: z.ZodArray<z.ZodObject<{
443
209
  fields: z.ZodObject<{
444
210
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
445
211
  }, z.core.$strip>;
446
- id: z.ZodUUID;
447
- versionId: z.ZodOptional<z.ZodUUID>;
448
- path: z.ZodOptional<z.ZodString>;
449
- sourceLocale: z.ZodOptional<z.ZodString>;
450
- status: z.ZodEnum<{
451
- [x: string]: string;
452
- }>;
453
- hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
454
- createdAt: z.ZodISODateTime;
455
- updatedAt: z.ZodISODateTime;
456
- createdBy: z.ZodOptional<z.ZodUUID>;
457
- eventType: z.ZodOptional<z.ZodString>;
458
212
  }, z.core.$strip>>;
459
213
  meta: z.ZodObject<{
460
214
  page: z.ZodNumber;
@@ -477,9 +231,6 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
477
231
  }, z.core.$strip>;
478
232
  history: z.ZodObject<{
479
233
  docs: z.ZodArray<z.ZodObject<{
480
- fields: z.ZodObject<{
481
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
482
- }, z.core.$strip>;
483
234
  id: z.ZodUUID;
484
235
  versionId: z.ZodOptional<z.ZodUUID>;
485
236
  path: z.ZodOptional<z.ZodString>;
@@ -492,6 +243,9 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
492
243
  updatedAt: z.ZodISODateTime;
493
244
  createdBy: z.ZodOptional<z.ZodUUID>;
494
245
  eventType: z.ZodOptional<z.ZodString>;
246
+ fields: z.ZodObject<{
247
+ [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
248
+ }, z.core.$strip>;
495
249
  }, z.core.$strip>>;
496
250
  meta: z.ZodObject<{
497
251
  page: z.ZodNumber;
@@ -506,9 +260,6 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
506
260
  [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
507
261
  }, z.core.$strip>;
508
262
  get: z.ZodObject<{
509
- fields: z.ZodObject<{
510
- [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
511
- }, z.core.$strip>;
512
263
  id: z.ZodUUID;
513
264
  versionId: z.ZodOptional<z.ZodUUID>;
514
265
  path: z.ZodOptional<z.ZodString>;
@@ -521,8 +272,13 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
521
272
  updatedAt: z.ZodISODateTime;
522
273
  createdBy: z.ZodOptional<z.ZodUUID>;
523
274
  eventType: z.ZodOptional<z.ZodString>;
275
+ fields: z.ZodObject<{
276
+ [x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
277
+ }, z.core.$strip>;
524
278
  }, z.core.$strip>;
525
279
  update: z.ZodObject<{
526
280
  [x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
527
281
  }, z.core.$strip>;
528
282
  };
283
+ export declare const createTypedCollectionSchemas: typeof createCollectionSchemas;
284
+ export declare const createTypedCollectionSchemasForPath: typeof createCollectionSchemasForPath;
@@ -3,8 +3,8 @@ import type { CollectionDefinition } from '../../@types/index.js';
3
3
  type TypedSchemaSet = ReturnType<typeof createTypedCollectionSchemas>;
4
4
  export declare const getCollectionSchemasForPath: (path: string) => TypedSchemaSet;
5
5
  export declare const getCollectionSchemas: (collection: CollectionDefinition) => TypedSchemaSet;
6
- export declare const getTypedCollectionSchemasForPath: (path: string) => TypedSchemaSet;
7
- export declare const getTypedCollectionSchemas: (collection: CollectionDefinition) => TypedSchemaSet;
6
+ export declare const getTypedCollectionSchemasForPath: typeof getCollectionSchemasForPath;
7
+ export declare const getTypedCollectionSchemas: typeof getCollectionSchemas;
8
8
  export declare const clearSchemaCache: (collectionPath?: string) => void;
9
9
  export declare const getCacheStats: () => {
10
10
  size: number;
@@ -20,6 +20,7 @@ function makeCounters(start = 1) {
20
20
  calls.push(group);
21
21
  return next++;
22
22
  }),
23
+ nextScopedCounterValue: vi.fn(),
23
24
  },
24
25
  calls,
25
26
  };
@@ -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: {
@@ -37,6 +37,7 @@ function makeAdapter(options) {
37
37
  counters: {
38
38
  ensureCounterGroup,
39
39
  nextCounterValue: vi.fn(fail),
40
+ nextScopedCounterValue: vi.fn(fail),
40
41
  },
41
42
  },
42
43
  queries: {
@@ -9,10 +9,10 @@ import type { AuditActorRealm, AuditLogAppendInput, IDbAdapter } from '../../@ty
9
9
  import type { DocumentLifecycleContext } from './context.js';
10
10
  /** Namespaced audit actions for document-grain changes. */
11
11
  export declare const AUDIT_ACTIONS: {
12
- readonly pathChanged: "document.path.changed";
13
- readonly localesChanged: "document.locales.changed";
14
- readonly statusChanged: "document.status.changed";
15
- readonly deleted: "document.deleted";
12
+ readonly pathChanged: 'document.path.changed';
13
+ readonly localesChanged: 'document.locales.changed';
14
+ readonly statusChanged: 'document.status.changed';
15
+ readonly deleted: 'document.deleted';
16
16
  };
17
17
  /**
18
18
  * The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
@@ -63,6 +63,7 @@ function createMockDb() {
63
63
  counters: {
64
64
  ensureCounterGroup: vi.fn(),
65
65
  nextCounterValue: vi.fn(),
66
+ nextScopedCounterValue: vi.fn(),
66
67
  },
67
68
  audit: { append: auditAppend },
68
69
  },
@@ -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 override (fold). A function may:
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 the filename unchanged.
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) => {
@@ -154,6 +154,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
154
154
  counters: {
155
155
  ensureCounterGroup: vi.fn(),
156
156
  nextCounterValue: vi.fn(),
157
+ nextScopedCounterValue: vi.fn(),
157
158
  },
158
159
  },
159
160
  queries: {
@@ -33,9 +33,9 @@ import { z } from 'zod';
33
33
  * this file — emit codes here, translate in `@byline/admin`.
34
34
  */
35
35
  export declare const PASSWORD_ERROR_CODES: {
36
- readonly TOO_SHORT: "password.tooShort";
37
- readonly TOO_LONG: "password.tooLong";
38
- readonly COMPLEXITY: "password.complexity";
36
+ readonly TOO_SHORT: 'password.tooShort';
37
+ readonly TOO_LONG: 'password.tooLong';
38
+ readonly COMPLEXITY: 'password.complexity';
39
39
  };
40
40
  /**
41
41
  * Standard password policy — 8 to 128 characters, must contain at least
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.17.1",
5
+ "version": "3.19.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -74,20 +74,20 @@
74
74
  "dependencies": {
75
75
  "dotenv": "^17.4.2",
76
76
  "pino": "^10.3.1",
77
- "sharp": "^0.34.5",
77
+ "sharp": "^0.35.3",
78
78
  "zod": "^4.4.3",
79
- "@byline/auth": "3.17.1"
79
+ "@byline/auth": "3.19.0"
80
80
  },
81
81
  "devDependencies": {
82
- "@biomejs/biome": "2.4.15",
83
- "@types/node": "^25.9.1",
82
+ "@biomejs/biome": "2.5.2",
83
+ "@types/node": "^26.1.0",
84
84
  "chokidar": "^5.0.0",
85
85
  "chokidar-cli": "^3.0.0",
86
86
  "npm-run-all": "^4.1.5",
87
- "tsc-alias": "^1.8.17",
88
- "tsx": "^4.22.3",
89
- "typescript": "6.0.3",
90
- "vitest": "^4.1.7"
87
+ "tsc-alias": "^1.9.0",
88
+ "tsx": "^4.23.0",
89
+ "typescript": "^7.0.2",
90
+ "vitest": "^4.1.10"
91
91
  },
92
92
  "publishConfig": {
93
93
  "access": "public",