@byline/core 3.1.1 → 3.2.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.
@@ -123,9 +123,14 @@ export interface UploadConfig {
123
123
  * have been written to the storage provider, before the document
124
124
  * version is created.
125
125
  *
126
+ * Accepts an inline object, or — because the schema is isomorphic — a
127
+ * **loader** (`hooks: () => import('./media.hooks.js')`) that defers the
128
+ * hooks module so server-only code (storage SDKs, `sharp`, AV scanners)
129
+ * never enters the client bundle. See {@link UploadHooksLoader}.
130
+ *
126
131
  * @see UploadHooks
127
132
  */
128
- hooks?: UploadHooks;
133
+ hooks?: UploadHooks | UploadHooksLoader;
129
134
  }
130
135
  /**
131
136
  * The three status names that every workflow must contain.
@@ -569,6 +574,52 @@ export interface UploadHooks {
569
574
  beforeStore?: BeforeStoreHookFn | BeforeStoreHookFn[];
570
575
  afterStore?: AfterStoreHookFn | AfterStoreHookFn[];
571
576
  }
577
+ /**
578
+ * A lazy loader for a field's upload hooks — the function form of
579
+ * `field.upload.hooks`. Returns the `UploadHooks` object, or a module
580
+ * namespace whose `default` export is the `UploadHooks` object (so
581
+ * `() => import('./media.hooks.js')` works directly against an
582
+ * `export default { … } satisfies UploadHooks`).
583
+ *
584
+ * Same rationale as {@link CollectionHooksLoader}: upload hooks are declared
585
+ * on a field *inside the collection schema*, which is **isomorphic** (bundled
586
+ * into the client admin). `beforeStore` / `afterStore` bodies typically reach
587
+ * for server-only code — storage SDKs, `sharp`, AV scanners, `node:crypto` —
588
+ * so declaring them inline drags that graph into the client bundle. The
589
+ * loader form defers the hooks module behind a dynamic `import()`, keeping it
590
+ * structurally absent from the client.
591
+ *
592
+ * @example
593
+ * // media.schema.ts — isomorphic, client-safe by construction
594
+ * {
595
+ * name: 'image',
596
+ * type: 'image',
597
+ * upload: {
598
+ * mimeTypes: ['image/*'],
599
+ * hooks: () => import('./media.hooks.js'),
600
+ * },
601
+ * }
602
+ *
603
+ * // media.hooks.ts — server-only; may import any server-only module freely
604
+ * export default { afterStore: (ctx) => { … } } satisfies UploadHooks
605
+ */
606
+ export type UploadHooksLoader = () => Promise<UploadHooks | {
607
+ default: UploadHooks;
608
+ }>;
609
+ /**
610
+ * Resolve a field's `upload.hooks` to a concrete `UploadHooks` object.
611
+ *
612
+ * - The inline-object form (`hooks: { … }`) is returned as-is.
613
+ * - The loader form (`hooks: () => import('./media.hooks.js')`) is invoked
614
+ * once and its result (unwrapping a module `default` export) memoized,
615
+ * keyed on the loader's function identity. The upload pipeline resolves
616
+ * through here, so a loader's dynamic `import()` runs at most once per
617
+ * process.
618
+ *
619
+ * Returns `undefined` when no upload hooks are declared. The counterpart to
620
+ * {@link resolveHooks} for the field-upload surface.
621
+ */
622
+ export declare function resolveUploadHooks(hooks: UploadHooks | UploadHooksLoader | undefined): Promise<UploadHooks | undefined>;
572
623
  /**
573
624
  * Context passed to `afterRead` hooks.
574
625
  *
@@ -716,6 +767,71 @@ export interface CollectionHooks {
716
767
  */
717
768
  afterRead?: CollectionHookSlot<AfterReadContext>;
718
769
  }
770
+ /**
771
+ * A lazy loader for a collection's hooks — the function form of
772
+ * `CollectionDefinition.hooks`. Returns the `CollectionHooks` object, or a
773
+ * module namespace whose `default` export is the `CollectionHooks` object
774
+ * (so `() => import('./docs.hooks.js')` works directly against an
775
+ * `export default { … } satisfies CollectionHooks`).
776
+ *
777
+ * Why this exists: a `CollectionDefinition` is **isomorphic** — the same
778
+ * schema module is bundled into the *client* admin as well as the server.
779
+ * Any module the schema *statically imports* is dragged into the client
780
+ * bundle, so a hook body that imports server-only code (cache invalidation,
781
+ * queue clients, Node built-ins) leaks that entire graph into the browser.
782
+ * The loader form defers the hooks module behind a dynamic `import()`, so
783
+ * the hooks module and its server-only graph are *structurally absent* from
784
+ * the client — no per-call-site SSR guards required.
785
+ *
786
+ * @example
787
+ * // docs.schema.ts — isomorphic, client-safe by construction
788
+ * export const Docs = defineCollection({
789
+ * // …declarative field config…
790
+ * hooks: () => import('./docs.hooks.js'),
791
+ * })
792
+ *
793
+ * // docs.hooks.ts — server-only; may import any server-only module freely
794
+ * import { invalidateDocument } from '@/lib/cache/with-cache'
795
+ * export default {
796
+ * afterCreate: ({ collectionPath, path }) => invalidateDocument(collectionPath, path),
797
+ * } satisfies CollectionHooks
798
+ */
799
+ export type CollectionHooksLoader = () => Promise<CollectionHooks | {
800
+ default: CollectionHooks;
801
+ }>;
802
+ /**
803
+ * Resolve a collection's `hooks` to a concrete `CollectionHooks` object.
804
+ *
805
+ * - The inline-object form (`hooks: { … }`) is returned as-is.
806
+ * - The loader form (`hooks: () => import('./docs.hooks.js')`) is invoked
807
+ * once and its result (unwrapping a module `default` export) memoized,
808
+ * keyed on the loader's function identity. Every read/write path resolves
809
+ * through here, so a loader's dynamic `import()` runs at most once per
810
+ * process regardless of how many documents flow through it.
811
+ *
812
+ * Returns `undefined` when no hooks are declared.
813
+ */
814
+ export declare function resolveHooks(definition: CollectionDefinition): Promise<CollectionHooks | undefined>;
815
+ /**
816
+ * Type-safe factory for authoring a collection's hooks in a separate
817
+ * module (the loader form: `hooks: () => import('./docs.hooks.js')`).
818
+ * Returns the object as-is — the counterpart to `defineCollection` /
819
+ * `defineBlock` for the sibling hooks file.
820
+ *
821
+ * Note: hook contexts currently type `data` as `Record<string, any>`, so
822
+ * this provides the same checking as `satisfies CollectionHooks` (a named
823
+ * factory + a stable place to hang docs), not per-collection field-data
824
+ * narrowing. Threading `CollectionFieldData<C>` into hook contexts is a
825
+ * separate future enhancement; when it lands it can be added here without
826
+ * authors changing call sites.
827
+ *
828
+ * @example
829
+ * // docs.hooks.ts
830
+ * export default defineHooks({
831
+ * afterCreate: ({ collectionPath, path }) => invalidateDocument(collectionPath, path),
832
+ * })
833
+ */
834
+ export declare function defineHooks(hooks: CollectionHooks): CollectionHooks;
719
835
  export interface CollectionDefinition {
720
836
  labels: {
721
837
  singular: string;
@@ -725,8 +841,17 @@ export interface CollectionDefinition {
725
841
  fields: Field[];
726
842
  /** Sequential workflow configuration. Falls back to DEFAULT_WORKFLOW if omitted. */
727
843
  workflow?: WorkflowConfig;
728
- /** Lifecycle hooks for server-side document operations. */
729
- hooks?: CollectionHooks;
844
+ /**
845
+ * Lifecycle hooks for server-side document operations.
846
+ *
847
+ * Two forms:
848
+ * - **Inline** (`hooks: { afterCreate, … }`) — valid for hooks whose
849
+ * bodies only touch isomorphic / declarative code.
850
+ * - **Loader** (`hooks: () => import('./docs.hooks.js')`) — defers the
851
+ * hooks module behind a dynamic `import()` so server-only code never
852
+ * enters the client bundle. See {@link CollectionHooksLoader}.
853
+ */
854
+ hooks?: CollectionHooks | CollectionHooksLoader;
730
855
  /**
731
856
  * Configures which text fields are searched when the admin list view's
732
857
  * search box is used. Only `store_text` fields are supported for now.
@@ -143,12 +143,84 @@ export function defineWorkflow(input = {}) {
143
143
  ...(input.defaultStatus ? { defaultStatus: input.defaultStatus } : {}),
144
144
  };
145
145
  }
146
+ const resolvedUploadHooksCache = new WeakMap();
147
+ /**
148
+ * Resolve a field's `upload.hooks` to a concrete `UploadHooks` object.
149
+ *
150
+ * - The inline-object form (`hooks: { … }`) is returned as-is.
151
+ * - The loader form (`hooks: () => import('./media.hooks.js')`) is invoked
152
+ * once and its result (unwrapping a module `default` export) memoized,
153
+ * keyed on the loader's function identity. The upload pipeline resolves
154
+ * through here, so a loader's dynamic `import()` runs at most once per
155
+ * process.
156
+ *
157
+ * Returns `undefined` when no upload hooks are declared. The counterpart to
158
+ * {@link resolveHooks} for the field-upload surface.
159
+ */
160
+ export async function resolveUploadHooks(hooks) {
161
+ if (typeof hooks !== 'function')
162
+ return hooks;
163
+ const cached = resolvedUploadHooksCache.get(hooks);
164
+ if (cached)
165
+ return cached;
166
+ const loaded = await hooks();
167
+ const resolved = 'default' in loaded ? loaded.default : loaded;
168
+ resolvedUploadHooksCache.set(hooks, resolved);
169
+ return resolved;
170
+ }
146
171
  /** Normalise a collection-hook slot (single function or array) into a flat array. */
147
172
  export function normalizeCollectionHook(hook) {
148
173
  if (!hook)
149
174
  return [];
150
175
  return Array.isArray(hook) ? hook : [hook];
151
176
  }
177
+ const resolvedHooksCache = new WeakMap();
178
+ /**
179
+ * Resolve a collection's `hooks` to a concrete `CollectionHooks` object.
180
+ *
181
+ * - The inline-object form (`hooks: { … }`) is returned as-is.
182
+ * - The loader form (`hooks: () => import('./docs.hooks.js')`) is invoked
183
+ * once and its result (unwrapping a module `default` export) memoized,
184
+ * keyed on the loader's function identity. Every read/write path resolves
185
+ * through here, so a loader's dynamic `import()` runs at most once per
186
+ * process regardless of how many documents flow through it.
187
+ *
188
+ * Returns `undefined` when no hooks are declared.
189
+ */
190
+ export async function resolveHooks(definition) {
191
+ const hooks = definition.hooks;
192
+ if (typeof hooks !== 'function')
193
+ return hooks;
194
+ const cached = resolvedHooksCache.get(hooks);
195
+ if (cached)
196
+ return cached;
197
+ const loaded = await hooks();
198
+ const resolved = 'default' in loaded ? loaded.default : loaded;
199
+ resolvedHooksCache.set(hooks, resolved);
200
+ return resolved;
201
+ }
202
+ /**
203
+ * Type-safe factory for authoring a collection's hooks in a separate
204
+ * module (the loader form: `hooks: () => import('./docs.hooks.js')`).
205
+ * Returns the object as-is — the counterpart to `defineCollection` /
206
+ * `defineBlock` for the sibling hooks file.
207
+ *
208
+ * Note: hook contexts currently type `data` as `Record<string, any>`, so
209
+ * this provides the same checking as `satisfies CollectionHooks` (a named
210
+ * factory + a stable place to hang docs), not per-collection field-data
211
+ * narrowing. Threading `CollectionFieldData<C>` into hook contexts is a
212
+ * separate future enhancement; when it lands it can be added here without
213
+ * authors changing call sites.
214
+ *
215
+ * @example
216
+ * // docs.hooks.ts
217
+ * export default defineHooks({
218
+ * afterCreate: ({ collectionPath, path }) => invalidateDocument(collectionPath, path),
219
+ * })
220
+ */
221
+ export function defineHooks(hooks) {
222
+ return hooks;
223
+ }
152
224
  /**
153
225
  * Type-safe factory for creating a CollectionDefinition.
154
226
  * Returns the definition as-is but provides type checking.
@@ -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,106 @@
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 { describe, expect, it, vi } from 'vitest';
9
+ import { defineHooks, resolveHooks, resolveUploadHooks } from './collection-types.js';
10
+ function baseCollection() {
11
+ return {
12
+ path: 'docs',
13
+ labels: { singular: 'Doc', plural: 'Docs' },
14
+ fields: [{ name: 'title', type: 'text' }],
15
+ };
16
+ }
17
+ describe('resolveHooks', () => {
18
+ it('returns undefined when no hooks are declared', async () => {
19
+ const def = baseCollection();
20
+ expect(await resolveHooks(def)).toBeUndefined();
21
+ });
22
+ it('returns the inline-object form as-is', async () => {
23
+ const hooks = { afterCreate: () => { } };
24
+ const def = { ...baseCollection(), hooks };
25
+ expect(await resolveHooks(def)).toBe(hooks);
26
+ });
27
+ it('invokes a loader returning a bare CollectionHooks object', async () => {
28
+ const hooks = { afterCreate: () => { } };
29
+ const def = {
30
+ ...baseCollection(),
31
+ hooks: () => Promise.resolve(hooks),
32
+ };
33
+ expect(await resolveHooks(def)).toBe(hooks);
34
+ });
35
+ it('unwraps a loader returning a module namespace with a default export', async () => {
36
+ const hooks = { afterCreate: () => { } };
37
+ const def = {
38
+ ...baseCollection(),
39
+ // Mirrors `() => import('./docs.hooks.js')` against `export default …`.
40
+ hooks: () => Promise.resolve({ default: hooks }),
41
+ };
42
+ expect(await resolveHooks(def)).toBe(hooks);
43
+ });
44
+ it('invokes the loader at most once and memoizes the result', async () => {
45
+ const hooks = { afterCreate: () => { } };
46
+ const loader = vi.fn(() => Promise.resolve({ default: hooks }));
47
+ const def = { ...baseCollection(), hooks: loader };
48
+ const first = await resolveHooks(def);
49
+ const second = await resolveHooks(def);
50
+ expect(first).toBe(hooks);
51
+ expect(second).toBe(hooks);
52
+ expect(loader).toHaveBeenCalledTimes(1);
53
+ });
54
+ it('caches per loader identity — distinct loaders each run once', async () => {
55
+ const a = { afterCreate: () => { } };
56
+ const b = { afterUpdate: () => { } };
57
+ const loaderA = vi.fn(() => Promise.resolve(a));
58
+ const loaderB = vi.fn(() => Promise.resolve(b));
59
+ expect(await resolveHooks({ ...baseCollection(), hooks: loaderA })).toBe(a);
60
+ expect(await resolveHooks({ ...baseCollection(), hooks: loaderB })).toBe(b);
61
+ expect(await resolveHooks({ ...baseCollection(), hooks: loaderA })).toBe(a);
62
+ expect(loaderA).toHaveBeenCalledTimes(1);
63
+ expect(loaderB).toHaveBeenCalledTimes(1);
64
+ });
65
+ });
66
+ describe('resolveUploadHooks', () => {
67
+ it('returns undefined when no upload hooks are declared', async () => {
68
+ expect(await resolveUploadHooks(undefined)).toBeUndefined();
69
+ });
70
+ it('returns the inline-object form as-is', async () => {
71
+ const hooks = { afterStore: () => { } };
72
+ expect(await resolveUploadHooks(hooks)).toBe(hooks);
73
+ });
74
+ it('invokes a loader returning a bare UploadHooks object', async () => {
75
+ const hooks = { beforeStore: () => { } };
76
+ expect(await resolveUploadHooks(() => Promise.resolve(hooks))).toBe(hooks);
77
+ });
78
+ it('unwraps a loader returning a module namespace with a default export', async () => {
79
+ const hooks = { afterStore: () => { } };
80
+ // Mirrors `() => import('./media.hooks.js')` against `export default …`.
81
+ expect(await resolveUploadHooks(() => Promise.resolve({ default: hooks }))).toBe(hooks);
82
+ });
83
+ it('invokes the loader at most once and memoizes the result', async () => {
84
+ const hooks = { afterStore: () => { } };
85
+ const loader = vi.fn(() => Promise.resolve({ default: hooks }));
86
+ const first = await resolveUploadHooks(loader);
87
+ const second = await resolveUploadHooks(loader);
88
+ expect(first).toBe(hooks);
89
+ expect(second).toBe(hooks);
90
+ expect(loader).toHaveBeenCalledTimes(1);
91
+ });
92
+ });
93
+ describe('defineHooks', () => {
94
+ it('returns the hooks object unchanged (identity factory)', () => {
95
+ const hooks = { afterCreate: () => { } };
96
+ expect(defineHooks(hooks)).toBe(hooks);
97
+ });
98
+ it('produces a value a loader can resolve through', async () => {
99
+ const hooks = defineHooks({ beforeCreate: () => { } });
100
+ const def = {
101
+ ...baseCollection(),
102
+ hooks: () => Promise.resolve({ default: hooks }),
103
+ };
104
+ expect(await resolveHooks(def)).toBe(hooks);
105
+ });
106
+ });
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { RequestContext } from '@byline/auth';
9
- import type { CollectionDefinition } from '../@types/collection-types.js';
9
+ import { type CollectionDefinition } from '../@types/collection-types.js';
10
10
  import type { ReadContext } from '../@types/db-types.js';
11
11
  import type { QueryPredicate } from '../@types/query-predicate.js';
12
12
  /**
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { resolveHooks, } from '../@types/collection-types.js';
8
9
  /**
9
10
  * Resolve the per-collection `beforeRead` hook predicate for the current
10
11
  * request, with caching across populate fanout.
@@ -30,7 +31,8 @@ export async function applyBeforeRead(params) {
30
31
  if (readContext.beforeReadCache.has(collectionPath)) {
31
32
  return readContext.beforeReadCache.get(collectionPath) ?? null;
32
33
  }
33
- const hooks = normalizeBeforeReadHook(definition.hooks?.beforeRead);
34
+ const resolved = await resolveHooks(definition);
35
+ const hooks = normalizeBeforeReadHook(resolved?.beforeRead);
34
36
  if (hooks.length === 0) {
35
37
  readContext.beforeReadCache.set(collectionPath, null);
36
38
  return null;
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { isArrayField, isBlocksField, isGroupField, normalizeCollectionHook, } from '../@types/index.js';
8
+ import { isArrayField, isBlocksField, isGroupField, normalizeCollectionHook, resolveHooks, } from '../@types/index.js';
9
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
10
10
  import { getCollectionDefinition, getServerConfig } from '../config/config.js';
11
11
  import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, ErrorCodes, } from '../lib/errors.js';
@@ -204,7 +204,7 @@ export async function createDocument(ctx, params) {
204
204
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
205
205
  assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
206
206
  const slugifier = ctx.slugifier ?? slugify;
207
- const hooks = definition.hooks;
207
+ const hooks = await resolveHooks(definition);
208
208
  const data = params.data;
209
209
  if (params.locale != null && params.locale !== defaultLocale) {
210
210
  throw ERR_VALIDATION({
@@ -277,7 +277,7 @@ export async function updateDocument(ctx, params) {
277
277
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocument' }, async () => {
278
278
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
279
279
  assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
280
- const hooks = definition.hooks;
280
+ const hooks = await resolveHooks(definition);
281
281
  const data = params.data;
282
282
  // Fetch the real original so hooks get accurate originalData (fixes the
283
283
  // PUT handler bug where originalData === data).
@@ -365,7 +365,7 @@ export async function updateDocumentWithPatches(ctx, params) {
365
365
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentWithPatches' }, async () => {
366
366
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
367
367
  assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
368
- const hooks = definition.hooks;
368
+ const hooks = await resolveHooks(definition);
369
369
  // 1. Fetch current document.
370
370
  const latest = await db.queries.documents.getDocumentById({
371
371
  collection_id: collectionId,
@@ -493,7 +493,7 @@ export async function changeDocumentStatus(ctx, params) {
493
493
  details: { collectionPath, nextStatus: params.nextStatus },
494
494
  }).log(ctx.logger);
495
495
  }
496
- const hooks = definition.hooks;
496
+ const hooks = await resolveHooks(definition);
497
497
  // 1. Fetch current version metadata. No field reconstruction needed —
498
498
  // status transitions only touch the document_versions.status column.
499
499
  const latest = await db.queries.documents.getCurrentVersionMetadata({
@@ -573,7 +573,7 @@ export async function unpublishDocument(ctx, params) {
573
573
  details: { collectionPath },
574
574
  }).log(ctx.logger);
575
575
  }
576
- const hooks = definition.hooks;
576
+ const hooks = await resolveHooks(definition);
577
577
  // Resolve the document's canonical path so the hooks can target the
578
578
  // specific document/URL (CDN purge, cache-key drop).
579
579
  const path = (await db.queries.documents.getCurrentPath({
@@ -641,7 +641,7 @@ export async function restoreDocumentVersion(ctx, params) {
641
641
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
642
642
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
643
643
  assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
644
- const hooks = definition.hooks;
644
+ const hooks = await resolveHooks(definition);
645
645
  // 1. Read source version (full multi-locale tree).
646
646
  const source = await db.queries.documents.getDocumentByVersion({
647
647
  document_version_id: params.sourceVersionId,
@@ -773,7 +773,7 @@ export async function deleteDocument(ctx, params) {
773
773
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteDocument' }, async () => {
774
774
  const { db, collectionPath, definition, logger } = ctx;
775
775
  assertActorCanPerform(ctx.requestContext, collectionPath, 'delete');
776
- const hooks = definition.hooks;
776
+ const hooks = await resolveHooks(definition);
777
777
  // 1. Verify the document exists.
778
778
  // For collections that have any upload-capable image/file field
779
779
  // AND a storage provider, fetch with reconstruct: true so we
@@ -964,7 +964,7 @@ export async function duplicateDocument(ctx, params) {
964
964
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
965
965
  assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
966
966
  const slugifier = ctx.slugifier ?? slugify;
967
- const hooks = definition.hooks;
967
+ const hooks = await resolveHooks(definition);
968
968
  // 1. Read source with locale='all' — single read, full multi-locale tree.
969
969
  const source = await db.queries.documents.getDocumentById({
970
970
  collection_id: collectionId,
@@ -1322,7 +1322,7 @@ export async function copyToLocale(ctx, params) {
1322
1322
  // 4. Hooks see the target-locale view as originalData (consistent
1323
1323
  // with how updateDocument scopes originalData to the active
1324
1324
  // locale) and the merged payload as the next `data`.
1325
- const hooks = definition.hooks;
1325
+ const hooks = await resolveHooks(definition);
1326
1326
  const copyToLocaleMarker = {
1327
1327
  sourceLocale: params.sourceLocale,
1328
1328
  targetLocale: params.targetLocale,
@@ -1440,7 +1440,7 @@ export async function deleteLocale(ctx, params) {
1440
1440
  details: { documentId: params.documentId, locale: params.locale, collectionPath },
1441
1441
  }).log(ctx.logger);
1442
1442
  }
1443
- const hooks = definition.hooks;
1443
+ const hooks = await resolveHooks(definition);
1444
1444
  const deleteLocaleMarker = { locale: params.locale };
1445
1445
  const originalData = targetRecord.fields ?? {};
1446
1446
  await invokeHook(hooks?.beforeUpdate, {
@@ -20,7 +20,7 @@
20
20
  * using `db.commands.documents.createDocumentVersion` directly skips the
21
21
  * `beforeCreate` / `afterCreate` hooks.
22
22
  */
23
- import { normalizeCollectionHook } from '../@types/index.js';
23
+ import { normalizeCollectionHook, resolveHooks } from '../@types/index.js';
24
24
  async function invokeHook(hook, ctx) {
25
25
  const fns = normalizeCollectionHook(hook);
26
26
  for (const fn of fns) {
@@ -40,7 +40,8 @@ async function invokeHook(hook, ctx) {
40
40
  * nested `client.collection(...).findById(id, { _readContext })` calls.
41
41
  */
42
42
  export async function applyAfterRead(params) {
43
- const hook = params.definition.hooks?.afterRead;
43
+ const resolved = await resolveHooks(params.definition);
44
+ const hook = resolved?.afterRead;
44
45
  if (!hook)
45
46
  return;
46
47
  const docId = params.doc?.document_id;
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { resolveUploadHooks } from '../@types/index.js';
8
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
9
10
  import { ERR_DATABASE, ERR_STORAGE, ERR_VALIDATION } from '../lib/errors.js';
10
11
  import { withLogContext } from '../lib/logger.js';
@@ -157,7 +158,11 @@ export async function uploadField(ctx, params) {
157
158
  // rather than throw — `assertActorCanPerform` is the auth gate
158
159
  // for whether the upload should run at all, not the hook layer.
159
160
  const sanitised = sanitiseFilename(originalFilename || 'upload');
160
- const beforeStoreHooks = normalizeUploadHook(upload.hooks?.beforeStore);
161
+ // Resolve the field's upload hooks once. The loader form
162
+ // (`hooks: () => import('./media.hooks.js')`) keeps server-only hook
163
+ // graphs out of the client bundle; the inline form returns as-is.
164
+ const uploadHooks = await resolveUploadHooks(upload.hooks);
165
+ const beforeStoreHooks = normalizeUploadHook(uploadHooks?.beforeStore);
161
166
  const effectiveFilename = await runBeforeStoreChain(beforeStoreHooks, {
162
167
  fieldName,
163
168
  field,
@@ -242,7 +247,7 @@ export async function uploadField(ctx, params) {
242
247
  };
243
248
  // -- afterStore chain. Failures are logged but do not roll back
244
249
  // the storage write (consistent with `afterCreate` etc.).
245
- const afterStoreHooks = normalizeUploadHook(upload.hooks?.afterStore);
250
+ const afterStoreHooks = normalizeUploadHook(uploadHooks?.afterStore);
246
251
  for (const fn of afterStoreHooks) {
247
252
  try {
248
253
  const afterCtx = {
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.1.1",
5
+ "version": "3.2.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "3.1.1"
82
+ "@byline/auth": "3.2.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",