@byline/core 3.21.0 → 4.1.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.
Files changed (77) hide show
  1. package/dist/@types/collection-types.d.ts +66 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-types.d.ts +25 -1
  4. package/dist/@types/query-predicate.d.ts +3 -3
  5. package/dist/@types/search-types.d.ts +3 -4
  6. package/dist/@types/site-config.d.ts +51 -12
  7. package/dist/auth/apply-before-read.d.ts +23 -11
  8. package/dist/auth/apply-before-read.js +139 -33
  9. package/dist/auth/apply-before-read.test.node.js +241 -3
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.js +1 -1
  12. package/dist/auth/read-context-scope.d.ts +20 -0
  13. package/dist/auth/read-context-scope.js +48 -0
  14. package/dist/codegen/index.js +34 -3
  15. package/dist/codegen/index.test.node.js +20 -2
  16. package/dist/config/attach-hooks.d.ts +25 -0
  17. package/dist/config/attach-hooks.js +130 -0
  18. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  19. package/dist/config/attach-hooks.test.node.js +173 -0
  20. package/dist/config/config-hooks.test.node.d.ts +1 -0
  21. package/dist/config/config-hooks.test.node.js +56 -0
  22. package/dist/config/config.d.ts +9 -5
  23. package/dist/config/config.js +20 -2
  24. package/dist/config/routes.d.ts +5 -5
  25. package/dist/config/routes.js +42 -9
  26. package/dist/config/routes.test.node.d.ts +1 -0
  27. package/dist/config/routes.test.node.js +152 -0
  28. package/dist/core.d.ts +2 -2
  29. package/dist/core.js +20 -14
  30. package/dist/core.test.node.d.ts +1 -0
  31. package/dist/core.test.node.js +28 -0
  32. package/dist/host/host-request-bridge.d.ts +62 -0
  33. package/dist/host/host-request-bridge.js +38 -0
  34. package/dist/index.d.ts +5 -3
  35. package/dist/index.js +5 -3
  36. package/dist/lib/errors.d.ts +13 -0
  37. package/dist/lib/errors.js +14 -0
  38. package/dist/query/parse-where.d.ts +9 -0
  39. package/dist/query/parse-where.js +146 -2
  40. package/dist/query/parse-where.test.node.js +60 -1
  41. package/dist/services/collection-bootstrap.test.node.js +30 -59
  42. package/dist/services/discover-counter-groups.test.node.js +23 -0
  43. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  44. package/dist/services/document-lifecycle/audit.js +32 -1
  45. package/dist/services/document-lifecycle/create.js +13 -6
  46. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  47. package/dist/services/document-lifecycle/delete.js +91 -26
  48. package/dist/services/document-lifecycle/index.d.ts +1 -1
  49. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  50. package/dist/services/document-lifecycle/internals.js +22 -46
  51. package/dist/services/document-lifecycle/status.d.ts +1 -1
  52. package/dist/services/document-lifecycle/status.js +20 -3
  53. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  54. package/dist/services/document-lifecycle/system-fields.js +112 -74
  55. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  56. package/dist/services/document-lifecycle/tree.js +244 -123
  57. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  58. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  59. package/dist/services/document-lifecycle/update.js +2 -1
  60. package/dist/services/document-lifecycle.test.node.js +360 -16
  61. package/dist/services/document-read.d.ts +14 -6
  62. package/dist/services/document-read.js +47 -9
  63. package/dist/services/field-upload.test.node.js +70 -0
  64. package/dist/services/index.d.ts +2 -2
  65. package/dist/services/index.js +2 -2
  66. package/dist/services/populate.d.ts +7 -3
  67. package/dist/services/populate.js +180 -28
  68. package/dist/services/populate.test.node.js +59 -0
  69. package/dist/services/richtext-embed.d.ts +39 -2
  70. package/dist/services/richtext-embed.js +4 -34
  71. package/dist/services/richtext-embed.test.node.js +15 -0
  72. package/dist/services/richtext-populate.d.ts +20 -2
  73. package/dist/services/richtext-populate.js +160 -19
  74. package/dist/services/richtext-populate.test.node.js +523 -2
  75. package/dist/utils/root-relative-redirect.d.ts +6 -0
  76. package/dist/utils/root-relative-redirect.js +37 -0
  77. package/package.json +2 -2
@@ -7,22 +7,17 @@
7
7
  */
8
8
  import type { RequestContext } from '@byline/auth';
9
9
  import { type CollectionDefinition } from '../@types/collection-types.js';
10
- import type { ReadContext } from '../@types/db-types.js';
10
+ import { type ParseContext } from '../query/parse-where.js';
11
+ import type { DocumentFilter, ReadContext } from '../@types/db-types.js';
11
12
  import type { QueryPredicate } from '../@types/query-predicate.js';
12
13
  /**
13
14
  * Resolve the per-collection `beforeRead` hook predicate for the current
14
15
  * request, with caching across populate fanout.
15
16
  *
16
- * Behaviour:
17
- * - Cache hit on `readContext.beforeReadCache` (keyed by collection
18
- * path) returns the cached value immediately. The actor is invariant
19
- * for the lifetime of one `ReadContext`, so a single key per
20
- * collection is sufficient.
21
- * - Each configured hook function runs in declaration order. Predicates
22
- * returned by multiple hooks are combined with implicit AND. Hooks
23
- * that return `void` / `undefined` are skipped.
24
- * - The result is stored in the cache (including `null` for "ran with
25
- * no scoping") so subsequent batches in the same request reuse it.
17
+ * Each configured hook function runs in declaration order. Predicates from
18
+ * multiple hooks are combined with implicit AND. Results are cached in
19
+ * module-private state bound to one request authority; caller-owned
20
+ * `ReadContext` properties are never consulted for authorization.
26
21
  *
27
22
  * Returns `null` when no hook is configured, or every hook returned
28
23
  * void. Callers (`CollectionHandle`, `populateDocuments`) treat `null`
@@ -32,4 +27,21 @@ export declare function applyBeforeRead(params: {
32
27
  definition: CollectionDefinition;
33
28
  requestContext: RequestContext;
34
29
  readContext: ReadContext;
30
+ /** Stable adapter/client identity. Defaults to the definition for direct callers. */
31
+ securityDomain?: object;
35
32
  }): Promise<QueryPredicate | null>;
33
+ /**
34
+ * Resolve and strictly compile the security predicate once per logical read.
35
+ * Promise caching also prevents concurrent populate branches from compiling
36
+ * the same predicate or resolving its relation collection ids more than once.
37
+ */
38
+ export declare function compileBeforeReadFilters(params: {
39
+ definition: CollectionDefinition;
40
+ requestContext: RequestContext;
41
+ readContext: ReadContext;
42
+ parseContext: ParseContext;
43
+ /** Stable identity shared by every fanout path using this adapter/client. */
44
+ securityDomain: object;
45
+ }): Promise<DocumentFilter[] | undefined>;
46
+ /** Bind a logical read to one immutable request authority. */
47
+ export declare function bindReadContextAuthority(readContext: ReadContext, requestContext: RequestContext): void;
@@ -6,60 +6,166 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { resolveHooks, } from '../@types/collection-types.js';
9
+ import { ERR_READ_RECURSION, ERR_VALIDATION } from '../lib/errors.js';
10
+ import { parsePredicateFilters } from '../query/parse-where.js';
11
+ import { createHookReadContext, getReadContextScope } from './read-context-scope.js';
12
+ const readSecurityStates = new WeakMap();
9
13
  /**
10
14
  * Resolve the per-collection `beforeRead` hook predicate for the current
11
15
  * request, with caching across populate fanout.
12
16
  *
13
- * Behaviour:
14
- * - Cache hit on `readContext.beforeReadCache` (keyed by collection
15
- * path) returns the cached value immediately. The actor is invariant
16
- * for the lifetime of one `ReadContext`, so a single key per
17
- * collection is sufficient.
18
- * - Each configured hook function runs in declaration order. Predicates
19
- * returned by multiple hooks are combined with implicit AND. Hooks
20
- * that return `void` / `undefined` are skipped.
21
- * - The result is stored in the cache (including `null` for "ran with
22
- * no scoping") so subsequent batches in the same request reuse it.
17
+ * Each configured hook function runs in declaration order. Predicates from
18
+ * multiple hooks are combined with implicit AND. Results are cached in
19
+ * module-private state bound to one request authority; caller-owned
20
+ * `ReadContext` properties are never consulted for authorization.
23
21
  *
24
22
  * Returns `null` when no hook is configured, or every hook returned
25
23
  * void. Callers (`CollectionHandle`, `populateDocuments`) treat `null`
26
24
  * the same as "no scoping" — they pass nothing extra to the adapter.
27
25
  */
28
26
  export async function applyBeforeRead(params) {
29
- const { definition, requestContext, readContext } = params;
27
+ const { definition, requestContext, readContext, securityDomain = definition } = params;
30
28
  const collectionPath = definition.path;
31
- if (readContext.beforeReadCache.has(collectionPath)) {
32
- return readContext.beforeReadCache.get(collectionPath) ?? null;
29
+ const scope = getReadContextScope(readContext);
30
+ const entry = getBeforeReadCacheEntry(scope.root, requestContext, securityDomain, definition);
31
+ if (scope.ancestry.includes(entry)) {
32
+ throw ERR_READ_RECURSION({
33
+ message: `beforeRead recursion blocked for collection '${collectionPath}'`,
34
+ details: { collectionPath, readMode: requestContext.readMode ?? 'any' },
35
+ });
33
36
  }
34
- const resolved = await resolveHooks(definition);
35
- const hooks = normalizeBeforeReadHook(resolved?.beforeRead);
36
- if (hooks.length === 0) {
37
- readContext.beforeReadCache.set(collectionPath, null);
38
- return null;
37
+ if (entry.predicate)
38
+ return entry.predicate;
39
+ const pending = (async () => {
40
+ const resolved = await resolveHooks(definition);
41
+ const hooks = normalizeBeforeReadHook(resolved?.beforeRead);
42
+ if (hooks.length === 0)
43
+ return null;
44
+ const predicates = [];
45
+ for (const hook of hooks) {
46
+ const result = await hook({
47
+ collectionPath,
48
+ requestContext,
49
+ readContext: createHookReadContext(scope, entry),
50
+ });
51
+ if (result != null)
52
+ predicates.push(result);
53
+ }
54
+ if (predicates.length === 0)
55
+ return null;
56
+ if (predicates.length === 1)
57
+ return predicates[0] ?? null;
58
+ return { $and: predicates };
59
+ })();
60
+ entry.predicate = pending;
61
+ return pending;
62
+ }
63
+ /**
64
+ * Resolve and strictly compile the security predicate once per logical read.
65
+ * Promise caching also prevents concurrent populate branches from compiling
66
+ * the same predicate or resolving its relation collection ids more than once.
67
+ */
68
+ export async function compileBeforeReadFilters(params) {
69
+ const { definition, requestContext, readContext, parseContext, securityDomain } = params;
70
+ const scope = getReadContextScope(readContext);
71
+ const entry = getBeforeReadCacheEntry(scope.root, requestContext, securityDomain, definition);
72
+ if (scope.ancestry.includes(entry)) {
73
+ throw ERR_READ_RECURSION({
74
+ message: `beforeRead recursion blocked for collection '${definition.path}'`,
75
+ details: {
76
+ collectionPath: definition.path,
77
+ readMode: requestContext.readMode ?? 'any',
78
+ },
79
+ });
39
80
  }
40
- const predicates = [];
41
- for (const hook of hooks) {
42
- const result = await hook({
43
- collectionPath,
81
+ if (entry.compiledFilters)
82
+ return entry.compiledFilters;
83
+ const compiled = (async () => {
84
+ const predicate = await applyBeforeRead({
85
+ definition,
44
86
  requestContext,
45
87
  readContext,
88
+ securityDomain,
89
+ });
90
+ if (predicate == null)
91
+ return undefined;
92
+ const filters = await parsePredicateFilters(predicate, definition, parseContext, {
93
+ strict: true,
46
94
  });
47
- if (result != null) {
48
- predicates.push(result);
95
+ return filters.length > 0 ? filters : undefined;
96
+ })();
97
+ entry.compiledFilters = compiled;
98
+ return compiled;
99
+ }
100
+ /** Bind a logical read to one immutable request authority. */
101
+ export function bindReadContextAuthority(readContext, requestContext) {
102
+ getReadSecurityState(readContext, requestContext);
103
+ }
104
+ function getReadSecurityState(readContext, requestContext) {
105
+ const root = getReadContextScope(readContext).root;
106
+ const authorityToken = requestAuthorityToken(requestContext);
107
+ const existing = readSecurityStates.get(root);
108
+ if (existing) {
109
+ if (existing.authorityToken !== authorityToken) {
110
+ throw ERR_VALIDATION({
111
+ message: 'ReadContext cannot be reused across request authorities',
112
+ });
49
113
  }
114
+ return existing;
115
+ }
116
+ const state = {
117
+ authorityToken,
118
+ domains: new WeakMap(),
119
+ };
120
+ readSecurityStates.set(root, state);
121
+ return state;
122
+ }
123
+ function getBeforeReadCacheEntry(readContext, requestContext, securityDomain, definition) {
124
+ const state = getReadSecurityState(readContext, requestContext);
125
+ let domain = state.domains.get(securityDomain);
126
+ if (!domain) {
127
+ domain = { definitions: new WeakMap() };
128
+ state.domains.set(securityDomain, domain);
50
129
  }
51
- let combined;
52
- if (predicates.length === 0) {
53
- combined = null;
130
+ let definitionState = domain.definitions.get(definition);
131
+ if (!definitionState) {
132
+ definitionState = { modes: new Map() };
133
+ domain.definitions.set(definition, definitionState);
54
134
  }
55
- else if (predicates.length === 1) {
56
- combined = predicates[0];
135
+ const mode = requestContext.readMode ?? 'any';
136
+ let entry = definitionState.modes.get(mode);
137
+ if (!entry) {
138
+ entry = {};
139
+ definitionState.modes.set(mode, entry);
57
140
  }
58
- else {
59
- combined = { $and: predicates };
141
+ return entry;
142
+ }
143
+ /**
144
+ * `requestId` stays in the token deliberately. A `ReadContext` is a
145
+ * per-logical-request object, and predicates cached on it may embed
146
+ * request-time state (embargo cutoffs, preview windows), so reusing one
147
+ * across requests must fail loudly even for the same actor. The flip side
148
+ * is a contract on host adapters: a `RequestContext` factory must return
149
+ * the same instance for every call within one logical request (see
150
+ * `oncePerRequest` in `@byline/host-tanstack-start`) — a factory that
151
+ * mints a fresh `requestId` per call makes any two reads sharing a
152
+ * `ReadContext` throw the cross-authority error above.
153
+ */
154
+ function requestAuthorityToken(requestContext) {
155
+ const actor = requestContext.actor;
156
+ if (actor == null) {
157
+ return JSON.stringify([requestContext.requestId, requestContext.locale ?? null, 'anonymous']);
60
158
  }
61
- readContext.beforeReadCache.set(collectionPath, combined);
62
- return combined;
159
+ const realm = 'isSuperAdmin' in actor ? 'admin' : 'user';
160
+ const isSuperAdmin = 'isSuperAdmin' in actor ? actor.isSuperAdmin : false;
161
+ return JSON.stringify([
162
+ requestContext.requestId,
163
+ requestContext.locale ?? null,
164
+ realm,
165
+ actor.id,
166
+ isSuperAdmin,
167
+ Array.from(actor.abilities).sort(),
168
+ ]);
63
169
  }
64
170
  /** Normalise a `beforeRead` slot (single function or array) into a flat array. */
65
171
  function normalizeBeforeReadHook(slot) {
@@ -5,11 +5,12 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { createRequestContext } from '@byline/auth';
9
- import { describe, expect, it } from 'vitest';
8
+ import { AdminAuth, createRequestContext } from '@byline/auth';
9
+ import { describe, expect, it, vi } from 'vitest';
10
10
  import { defineCollection, defineWorkflow, } from '../@types/collection-types.js';
11
11
  import { createReadContext } from '../services/populate.js';
12
- import { applyBeforeRead } from './apply-before-read.js';
12
+ import { applyBeforeRead, compileBeforeReadFilters } from './apply-before-read.js';
13
+ import { resolveReadContextRoot } from './read-context-scope.js';
13
14
  const baseCollection = (hook) => defineCollection({
14
15
  path: 'posts',
15
16
  labels: { singular: 'Post', plural: 'Posts' },
@@ -101,6 +102,55 @@ describe('applyBeforeRead', () => {
101
102
  expect(second).toBeNull();
102
103
  expect(callCount).toBe(1);
103
104
  });
105
+ it('ignores a caller-preseeded public cache entry', async () => {
106
+ const hook = vi.fn(() => ({ title: 'allowed' }));
107
+ const readContext = createReadContext({
108
+ beforeReadCache: new Map([['posts:any', null]]),
109
+ });
110
+ const result = await applyBeforeRead({
111
+ definition: baseCollection(hook),
112
+ requestContext: createRequestContext(),
113
+ readContext,
114
+ });
115
+ expect(hook).toHaveBeenCalledOnce();
116
+ expect(result).toEqual({ title: 'allowed' });
117
+ });
118
+ it('ignores a caller-preseeded public cache entry on the compiled-filter path', async () => {
119
+ const hook = vi.fn(() => ({ title: 'allowed' }));
120
+ const readContext = createReadContext({
121
+ beforeReadCache: new Map([['posts:any', null]]),
122
+ });
123
+ const filters = await compileBeforeReadFilters({
124
+ definition: baseCollection(hook),
125
+ requestContext: createRequestContext(),
126
+ readContext,
127
+ securityDomain: {},
128
+ parseContext: { collections: [], resolveCollectionId: vi.fn() },
129
+ });
130
+ expect(hook).toHaveBeenCalledOnce();
131
+ expect(filters).toEqual([
132
+ expect.objectContaining({ kind: 'field', fieldName: 'title', value: 'allowed' }),
133
+ ]);
134
+ });
135
+ it('rejects reuse under a different actor authority', async () => {
136
+ const hook = vi.fn(({ requestContext }) => ({
137
+ title: requestContext.actor?.id,
138
+ }));
139
+ const definition = baseCollection(hook);
140
+ const readContext = createReadContext();
141
+ const requestId = 'same-logical-request';
142
+ const alice = createRequestContext({
143
+ actor: new AdminAuth({ id: 'alice', abilities: ['collections.posts.read'] }),
144
+ requestId,
145
+ });
146
+ const bob = createRequestContext({
147
+ actor: new AdminAuth({ id: 'bob', abilities: ['collections.posts.read'] }),
148
+ requestId,
149
+ });
150
+ await applyBeforeRead({ definition, requestContext: alice, readContext });
151
+ await expect(applyBeforeRead({ definition, requestContext: bob, readContext })).rejects.toThrow('cannot be reused across request authorities');
152
+ expect(hook).toHaveBeenCalledOnce();
153
+ });
104
154
  it('runs the hook again on a fresh ReadContext', async () => {
105
155
  let callCount = 0;
106
156
  const hook = () => {
@@ -113,6 +163,32 @@ describe('applyBeforeRead', () => {
113
163
  await applyBeforeRead({ definition: def, requestContext, readContext: createReadContext() });
114
164
  expect(callCount).toBe(2);
115
165
  });
166
+ it('caches independently by effective read mode', async () => {
167
+ const modes = [];
168
+ const hook = ({ requestContext }) => {
169
+ modes.push(requestContext.readMode);
170
+ return { status: requestContext.readMode === 'published' ? 'published' : 'draft' };
171
+ };
172
+ const definition = baseCollection(hook);
173
+ const readContext = createReadContext();
174
+ const base = createRequestContext();
175
+ await applyBeforeRead({
176
+ definition,
177
+ requestContext: { ...base, readMode: 'published' },
178
+ readContext,
179
+ });
180
+ await applyBeforeRead({
181
+ definition,
182
+ requestContext: { ...base, readMode: 'any' },
183
+ readContext,
184
+ });
185
+ await applyBeforeRead({
186
+ definition,
187
+ requestContext: { ...base, readMode: 'published' },
188
+ readContext,
189
+ });
190
+ expect(modes).toEqual(['published', 'any']);
191
+ });
116
192
  it('passes collectionPath, requestContext, and readContext through to the hook', async () => {
117
193
  let received;
118
194
  const hook = (ctx) => {
@@ -125,8 +201,40 @@ describe('applyBeforeRead', () => {
125
201
  expect(received).toMatchObject({
126
202
  collectionPath: 'posts',
127
203
  requestContext,
204
+ });
205
+ expect(received.readContext.visited).toBe(readContext.visited);
206
+ });
207
+ it('delegates scoped read-budget state to the logical root context', async () => {
208
+ const readContext = createReadContext({ maxReads: 4 });
209
+ const hook = ({ readContext: scoped }) => {
210
+ scoped.visited.add('posts:nested');
211
+ scoped.readCount += 1;
212
+ scoped.maxReads = 3;
213
+ };
214
+ await applyBeforeRead({
215
+ definition: baseCollection(hook),
216
+ requestContext: createRequestContext(),
217
+ readContext,
218
+ });
219
+ expect(readContext.visited).toContain('posts:nested');
220
+ expect(readContext.readCount).toBe(1);
221
+ expect(readContext.maxReads).toBe(3);
222
+ });
223
+ it('resolves scoped hook contexts to their logical root', async () => {
224
+ const readContext = createReadContext();
225
+ let scopedContext;
226
+ const hook = ({ readContext: scoped }) => {
227
+ scopedContext = scoped;
228
+ };
229
+ await applyBeforeRead({
230
+ definition: baseCollection(hook),
231
+ requestContext: createRequestContext(),
128
232
  readContext,
129
233
  });
234
+ if (!scopedContext)
235
+ throw new Error('beforeRead did not receive a scoped ReadContext');
236
+ expect(resolveReadContextRoot(scopedContext)).toBe(readContext);
237
+ expect(resolveReadContextRoot(readContext)).toBe(readContext);
130
238
  });
131
239
  it('supports an async hook', async () => {
132
240
  const hook = async () => {
@@ -140,4 +248,134 @@ describe('applyBeforeRead', () => {
140
248
  });
141
249
  expect(result).toEqual({ tenantId: 't-1' });
142
250
  });
251
+ it('compiles security filters and resolves relation collection ids once per read context', async () => {
252
+ const categories = defineCollection({
253
+ path: 'categories',
254
+ labels: { singular: 'Category', plural: 'Categories' },
255
+ fields: [{ name: 'tenant', type: 'text', label: 'Tenant' }],
256
+ });
257
+ const definition = defineCollection({
258
+ path: 'posts',
259
+ labels: { singular: 'Post', plural: 'Posts' },
260
+ fields: [
261
+ {
262
+ name: 'category',
263
+ type: 'relation',
264
+ label: 'Category',
265
+ targetCollection: 'categories',
266
+ },
267
+ ],
268
+ hooks: { beforeRead: () => ({ category: { tenant: 'alice' } }) },
269
+ });
270
+ const readContext = createReadContext();
271
+ const requestContext = createRequestContext({ readMode: 'published' });
272
+ const resolveCollectionId = vi.fn(async () => 'categories-id');
273
+ const params = {
274
+ definition,
275
+ requestContext,
276
+ readContext,
277
+ securityDomain: {},
278
+ parseContext: { collections: [definition, categories], resolveCollectionId },
279
+ };
280
+ const [first, second] = await Promise.all([
281
+ compileBeforeReadFilters(params),
282
+ compileBeforeReadFilters(params),
283
+ ]);
284
+ expect(second).toBe(first);
285
+ expect(resolveCollectionId).toHaveBeenCalledOnce();
286
+ });
287
+ it('compiles the same definition independently for different security domains', async () => {
288
+ const categories = defineCollection({
289
+ path: 'categories',
290
+ labels: { singular: 'Category', plural: 'Categories' },
291
+ fields: [{ name: 'tenant', type: 'text', label: 'Tenant' }],
292
+ });
293
+ const hook = vi.fn(() => ({ category: { tenant: 'alice' } }));
294
+ const definition = defineCollection({
295
+ path: 'posts',
296
+ labels: { singular: 'Post', plural: 'Posts' },
297
+ fields: [
298
+ {
299
+ name: 'category',
300
+ type: 'relation',
301
+ label: 'Category',
302
+ targetCollection: 'categories',
303
+ },
304
+ ],
305
+ hooks: { beforeRead: hook },
306
+ });
307
+ const readContext = createReadContext();
308
+ const requestContext = createRequestContext({ readMode: 'published' });
309
+ const resolveA = vi.fn(async () => 'categories-a');
310
+ const resolveB = vi.fn(async () => 'categories-b');
311
+ await compileBeforeReadFilters({
312
+ definition,
313
+ requestContext,
314
+ readContext,
315
+ securityDomain: {},
316
+ parseContext: { collections: [definition, categories], resolveCollectionId: resolveA },
317
+ });
318
+ await compileBeforeReadFilters({
319
+ definition,
320
+ requestContext,
321
+ readContext,
322
+ securityDomain: {},
323
+ parseContext: { collections: [definition, categories], resolveCollectionId: resolveB },
324
+ });
325
+ expect(hook).toHaveBeenCalledTimes(2);
326
+ expect(resolveA).toHaveBeenCalledOnce();
327
+ expect(resolveB).toHaveBeenCalledOnce();
328
+ });
329
+ it('fails closed on a recursive beforeRead for the same collection', async () => {
330
+ const readContext = createReadContext();
331
+ const requestContext = createRequestContext();
332
+ let definition;
333
+ const hook = async (ctx) => {
334
+ await applyBeforeRead({
335
+ definition,
336
+ requestContext: ctx.requestContext,
337
+ readContext: ctx.readContext,
338
+ });
339
+ };
340
+ definition = baseCollection(hook);
341
+ await expect(applyBeforeRead({ definition, requestContext, readContext })).rejects.toMatchObject({ code: 'ERR_READ_RECURSION' });
342
+ });
343
+ it('fails closed on an A to B to A beforeRead cycle', async () => {
344
+ const readContext = createReadContext();
345
+ const requestContext = createRequestContext();
346
+ const securityDomain = {};
347
+ let a;
348
+ let b;
349
+ a = defineCollection({
350
+ path: 'a',
351
+ labels: { singular: 'A', plural: 'As' },
352
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
353
+ hooks: {
354
+ beforeRead: async (ctx) => {
355
+ await applyBeforeRead({
356
+ definition: b,
357
+ requestContext: ctx.requestContext,
358
+ readContext: ctx.readContext,
359
+ securityDomain,
360
+ });
361
+ },
362
+ },
363
+ });
364
+ b = defineCollection({
365
+ path: 'b',
366
+ labels: { singular: 'B', plural: 'Bs' },
367
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
368
+ hooks: {
369
+ beforeRead: async (ctx) => {
370
+ await applyBeforeRead({
371
+ definition: a,
372
+ requestContext: ctx.requestContext,
373
+ readContext: ctx.readContext,
374
+ securityDomain,
375
+ });
376
+ },
377
+ },
378
+ });
379
+ await expect(applyBeforeRead({ definition: a, requestContext, readContext, securityDomain })).rejects.toMatchObject({ code: 'ERR_READ_RECURSION' });
380
+ });
143
381
  });
@@ -5,6 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- export { applyBeforeRead } from './apply-before-read.js';
8
+ export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
10
  export { COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
@@ -5,6 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- export { applyBeforeRead } from './apply-before-read.js';
8
+ export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
10
  export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
@@ -0,0 +1,20 @@
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 type { ReadContext } from '../@types/db-types.js';
9
+ export interface ReadContextScope {
10
+ root: ReadContext;
11
+ ancestry: readonly object[];
12
+ }
13
+ export declare function getReadContextScope(readContext: ReadContext): ReadContextScope;
14
+ /** Resolve a scoped hook context to the ReadContext for its logical request. */
15
+ export declare function resolveReadContextRoot(readContext: ReadContext): ReadContext;
16
+ /**
17
+ * Carry hook ancestry by object identity, not by a forgeable public property.
18
+ * All mutable read-budget state still delegates to the logical root context.
19
+ */
20
+ export declare function createHookReadContext(parent: ReadContextScope, entry: object): ReadContext;
@@ -0,0 +1,48 @@
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
+ const readContextScopes = new WeakMap();
9
+ export function getReadContextScope(readContext) {
10
+ return readContextScopes.get(readContext) ?? { root: readContext, ancestry: [] };
11
+ }
12
+ /** Resolve a scoped hook context to the ReadContext for its logical request. */
13
+ export function resolveReadContextRoot(readContext) {
14
+ return getReadContextScope(readContext).root;
15
+ }
16
+ /**
17
+ * Carry hook ancestry by object identity, not by a forgeable public property.
18
+ * All mutable read-budget state still delegates to the logical root context.
19
+ */
20
+ export function createHookReadContext(parent, entry) {
21
+ const root = parent.root;
22
+ const scoped = {};
23
+ Object.defineProperties(scoped, {
24
+ visited: { enumerable: true, get: () => root.visited, set: (value) => (root.visited = value) },
25
+ beforeReadCache: {
26
+ enumerable: true,
27
+ get: () => root.beforeReadCache,
28
+ set: (value) => (root.beforeReadCache = value),
29
+ },
30
+ readCount: {
31
+ enumerable: true,
32
+ get: () => root.readCount,
33
+ set: (value) => (root.readCount = value),
34
+ },
35
+ maxReads: {
36
+ enumerable: true,
37
+ get: () => root.maxReads,
38
+ set: (value) => (root.maxReads = value),
39
+ },
40
+ maxDepth: {
41
+ enumerable: true,
42
+ get: () => root.maxDepth,
43
+ set: (value) => (root.maxDepth = value),
44
+ },
45
+ });
46
+ readContextScopes.set(scoped, { root, ancestry: [...parent.ancestry, entry] });
47
+ return scoped;
48
+ }
@@ -6,8 +6,8 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
- const FORMAT_VERSION = 1;
10
- const HASH_DOMAIN = '@byline/core/codegen:collection-types:v1\n';
9
+ const FORMAT_VERSION = 2;
10
+ const HASH_DOMAIN = '@byline/core/codegen:collection-types:v2\n';
11
11
  const IMPORT_ORDER = ['JsonObject', 'JsonValue', 'RelatedDocumentValue', 'StoredFileValue'];
12
12
  function compareStrings(a, b) {
13
13
  return a < b ? -1 : a > b ? 1 : 0;
@@ -411,7 +411,38 @@ function emitBody(analysis) {
411
411
  const importLines = usedImports.length === 0
412
412
  ? []
413
413
  : ['import type {', ...usedImports.map((name) => ` ${name},`), "} from '@byline/core'", ''];
414
- return `${[...importLines, ...declarations].join('\n').replace(/\n+$/, '')}\n`;
414
+ // Every type declaration lives inside the `@byline/generated-types`
415
+ // declaration-merge block, so the published stub package is the one
416
+ // canonical import path for app collection types
417
+ // (`import type { NewsFields } from '@byline/generated-types'`). The
418
+ // file's top-level `@byline/core` imports remain visible inside the
419
+ // block (module augmentation is lexically scoped).
420
+ const declarationLines = declarations
421
+ .join('\n')
422
+ .replace(/\n+$/, '')
423
+ .split('\n')
424
+ .map((line) => (line.length === 0 ? line : ` ${line}`));
425
+ // Register the app's collection registry with @byline/client (a second
426
+ // declaration merge). Every bare `BylineClient` in the app's program —
427
+ // including the `@byline/client/server` getters — then resolves to
428
+ // `BylineClient<CollectionFieldsByPath>`. Both `@byline/generated-types`
429
+ // and `@byline/client` must be resolvable in the consuming program; apps
430
+ // that generate types depend on both.
431
+ const registerLines = [
432
+ "declare module '@byline/client' {",
433
+ ' interface Register {',
434
+ " collections: import('@byline/generated-types').CollectionFieldsByPath",
435
+ ' }',
436
+ '}',
437
+ ];
438
+ return `${[
439
+ ...importLines,
440
+ "declare module '@byline/generated-types' {",
441
+ ...declarationLines,
442
+ '}',
443
+ '',
444
+ ...registerLines,
445
+ ].join('\n')}\n`;
415
446
  }
416
447
  /**
417
448
  * Emit deterministic application collection types from evaluated runtime definitions.