@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.
- package/dist/@types/collection-types.d.ts +128 -3
- package/dist/@types/collection-types.js +72 -0
- package/dist/@types/resolve-hooks.test.node.d.ts +8 -0
- package/dist/@types/resolve-hooks.test.node.js +106 -0
- package/dist/auth/apply-before-read.d.ts +1 -1
- package/dist/auth/apply-before-read.js +3 -1
- package/dist/services/document-lifecycle.js +11 -11
- package/dist/services/document-read.js +3 -2
- package/dist/services/field-upload.js +7 -2
- package/package.json +2 -2
|
@@ -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
|
-
/**
|
|
729
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
82
|
+
"@byline/auth": "3.2.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|