@byline/core 3.20.4 → 4.0.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 (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -5,11 +5,135 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { applyBeforeRead } from '../auth/apply-before-read.js';
8
+ /**
9
+ * Relationship population service.
10
+ *
11
+ * `populateDocuments` walks a set of reconstructed documents, finds every
12
+ * relation leaf that matches a caller-supplied populate spec, batches
13
+ * fetches against each target collection (one DB round-trip per depth
14
+ * level per target collection), and replaces each leaf in place with the
15
+ * populated document. Missing targets become a `{ _resolved: false }`
16
+ * stub; already-visited targets become a `{ _cycle: true }` stub.
17
+ *
18
+ * Consumed by both `@byline/client` (external read API with `populate`
19
+ * and `depth` options) and the admin webapp's API-preview server fn.
20
+ *
21
+ * A request-scoped `ReadContext` is threaded through the walk. Its
22
+ * `visited` set and `readCount` budget guard against recursive reads —
23
+ * particularly the A→B→A failure mode that appears when future
24
+ * `afterRead` hooks invoke their own reads from within populated
25
+ * documents. The guard is in place from day one so that the hook work
26
+ * in Phase 4+ cannot reintroduce the problem.
27
+ *
28
+ * See docs/04-collections/02-relationships.md for the full design rationale.
29
+ *
30
+ * ---------------------------------------------------------------------
31
+ * DSL summary
32
+ * ---------------------------------------------------------------------
33
+ *
34
+ * The populate DSL has two independent axes:
35
+ *
36
+ * 1. **Scope** — which relations in the *source* document to walk.
37
+ * 2. **Projection** — which fields of each *target* document to load.
38
+ *
39
+ * The top-level `populate` value selects scope + (optionally) a uniform
40
+ * projection across the whole tree:
41
+ *
42
+ * - `populate: true` → walk every relation leaf,
43
+ * default projection at every
44
+ * depth. See below for exactly
45
+ * what the default projection
46
+ * returns.
47
+ * - `populate: '*'` → walk every relation leaf, full
48
+ * document projection at every
49
+ * depth. Symmetric with the
50
+ * sub-spec shorthand and intended
51
+ * for tools like the admin API
52
+ * preview where the whole tree
53
+ * should be visible.
54
+ * - `populate: { name: … }` → walk only the named relations.
55
+ * - `populate: undefined` → skip populate entirely (no-op).
56
+ *
57
+ * Default projection — exactly what comes back for `true` (and for any
58
+ * sub-spec whose `select` is omitted):
59
+ *
60
+ * - **Document row metadata, always present** (lives on the
61
+ * `document_versions` row, not in the `store_*` tables, so it is
62
+ * returned regardless of the `fields` projection):
63
+ * `document_version_id`, `document_id`, `collection_id`, `path`,
64
+ * `status`, `created_at`, `updated_at`.
65
+ * - **The `useAsTitle` field** (schema-declared identity field;
66
+ * falls back to the first declared text field when `useAsTitle`
67
+ * is not set on the `CollectionDefinition`). This is the one
68
+ * entry added to the `fields` object.
69
+ *
70
+ * In effect "default projection" is "enough to identify and label the
71
+ * target" — document metadata for wiring, plus one user-defined field
72
+ * (typically `title`) for a human-readable label. Callers wanting more
73
+ * use `'*'` (full doc) or `{ select: [...] }` (explicit fields).
74
+ *
75
+ * Each matched leaf carries a `PopulateFieldSpec` that selects projection:
76
+ *
77
+ * - `true` → default projection: the target's
78
+ * identity field (`useAsTitle`,
79
+ * falling back to the first text
80
+ * field). Document metadata
81
+ * (`document_id`, `collection_id`,
82
+ * `path`, `status`, timestamps) is
83
+ * always included for free — it
84
+ * lives on the row, not in the
85
+ * store_* tables.
86
+ * - `'*'` → full document: every field of
87
+ * the target is loaded.
88
+ * - `{ select: [...] }` → explicit field list, merged with
89
+ * the identity field so downstream
90
+ * UI always has a label to render.
91
+ * - `{ populate: {...} }` → nested populate for the next
92
+ * depth level. Combinable with
93
+ * `select`.
94
+ *
95
+ * Examples:
96
+ *
97
+ * populate: true
98
+ * → every relation, default projection at every depth level.
99
+ *
100
+ * populate: '*'
101
+ * → every relation, full projection at every depth level
102
+ * (use for API previews / debug views that want the whole tree).
103
+ *
104
+ * populate: { heroImage: true }
105
+ * → only heroImage, default projection. If heroImage's own
106
+ * relations exist, they populate at the next depth with `true`.
107
+ *
108
+ * populate: { heroImage: '*' }
109
+ * → only heroImage, full document. If heroImage's own relations
110
+ * exist, they populate at the next depth with `'*'` (consistent
111
+ * with how `true` propagates).
112
+ *
113
+ * populate: { author: { select: ['name'] } }
114
+ * → only author; fetch `name` + identity field.
115
+ *
116
+ * populate: { author: { select: ['name'], populate: { employer: '*' } } }
117
+ * → author with `name` at depth 1; employer fully populated at depth 2.
118
+ *
119
+ * Notes:
120
+ *
121
+ * - `'*'` belongs on the sub-spec (or as the whole top-level spec),
122
+ * not inside `select`. `select` is always an explicit field list.
123
+ * - Projection defaults are transitive at every depth: `true` propagates
124
+ * `true` into nested levels; `'*'` propagates `'*'`. Explicit
125
+ * `{ populate: {...} }` maps take precedence when declared.
126
+ * - Multiple leaves pointing at the same target document are batched
127
+ * into a single fetch; their projection specs are merged (any `'*'`
128
+ * wins; otherwise selects union together, identity field is always
129
+ * added).
130
+ */
131
+ import { createRequestContext } from '@byline/auth';
132
+ import { compileBeforeReadFilters } from '../auth/apply-before-read.js';
133
+ import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
9
134
  import { ERR_READ_BUDGET_EXCEEDED } from '../lib/errors.js';
10
- import { parseWhere } from '../query/parse-where.js';
11
135
  import { applyAfterRead } from './document-read.js';
12
- import { populateRichTextFields } from './richtext-populate.js';
136
+ import { createRichTextDocumentReader, populateRichTextFields } from './richtext-populate.js';
13
137
  import { walkFieldTree } from './walk-field-tree.js';
14
138
  // ---------------------------------------------------------------------------
15
139
  // ReadContext — recursion guard
@@ -20,8 +144,6 @@ const DEFAULT_MAX_DEPTH = 8;
20
144
  export function createReadContext(overrides) {
21
145
  return {
22
146
  visited: overrides?.visited ?? new Set(),
23
- afterReadFired: overrides?.afterReadFired ?? new Set(),
24
- beforeReadCache: overrides?.beforeReadCache ?? new Map(),
25
147
  readCount: overrides?.readCount ?? 0,
26
148
  maxReads: overrides?.maxReads ?? DEFAULT_MAX_READS,
27
149
  maxDepth: overrides?.maxDepth ?? DEFAULT_MAX_DEPTH,
@@ -36,6 +158,9 @@ export function createReadContext(overrides) {
36
158
  */
37
159
  export async function populateDocuments(opts) {
38
160
  const ctx = opts.readContext ?? createReadContext();
161
+ const securityDomain = opts.securityDomain ?? {};
162
+ const operationRequestContext = opts.requestContext ??
163
+ createRequestContext({ readMode: opts.readMode ?? 'any', locale: opts.locale });
39
164
  const populate = opts.populate;
40
165
  const requestedDepth = opts.depth ?? (populate !== undefined ? 1 : 0);
41
166
  const maxDepth = Math.max(0, Math.min(requestedDepth, ctx.maxDepth));
@@ -90,13 +215,19 @@ export async function populateDocuments(opts) {
90
215
  for (const [targetCollectionId, leaves] of byTarget) {
91
216
  const targetDef = await resolveCollectionDef(opts.db, opts.collections, targetCollectionId, defCache);
92
217
  const selectList = buildBatchSelect(leaves, targetDef);
218
+ // Production reads always carry an operation-scoped context. Assert the
219
+ // target collection before any adapter access; unresolved target schemas
220
+ // remain unresolved rather than bypassing the collection gate.
221
+ if (opts.requestContext && targetDef) {
222
+ assertActorCanPerform(opts.requestContext, targetDef.path, 'read');
223
+ }
93
224
  // Only fetch IDs we haven't materialised earlier in this request.
94
225
  const idsToFetch = Array.from(new Set(leaves
95
226
  .filter((l) => !ctx.visited.has(visitedKey(targetCollectionId, l.value.targetDocumentId)))
96
227
  .map((l) => l.value.targetDocumentId)));
97
228
  // Resolve the target collection's `beforeRead` predicate, if any.
98
- // The cache on `ctx.beforeReadCache` ensures each (collectionPath,
99
- // actor) tuple only runs through the hook once per request, even
229
+ // Private authority-bound state ensures each collection/mode tuple only
230
+ // runs through the hook once per request, even
100
231
  // across populate fanout where the same target collection may be
101
232
  // visited from multiple sources.
102
233
  const targetFilters = await resolveBeforeReadFiltersForTarget({
@@ -106,9 +237,10 @@ export async function populateDocuments(opts) {
106
237
  readContext: ctx,
107
238
  bypassBeforeRead: opts.bypassBeforeRead,
108
239
  db: opts.db,
240
+ securityDomain,
109
241
  });
110
242
  let fetched = [];
111
- if (idsToFetch.length > 0) {
243
+ if (idsToFetch.length > 0 && targetDef) {
112
244
  fetched = await opts.db.queries.documents.getDocumentsByDocumentIds({
113
245
  collection_id: targetCollectionId,
114
246
  document_ids: idsToFetch,
@@ -186,16 +318,38 @@ export async function populateDocuments(opts) {
186
318
  // so hook authors observe fully-populated rich-text content. The
187
319
  // gate (per-field `populateRelationsOnRead`) lives inside the
188
320
  // service; passing the function unconditionally is correct.
189
- if (opts.richTextPopulate) {
321
+ if (opts.richTextPopulate && opts.requestContext) {
190
322
  await populateRichTextFields({
191
323
  fields: targetDef.fields,
192
324
  collectionPath: targetDef.path,
193
325
  documents: [d],
194
326
  populate: opts.richTextPopulate,
195
327
  readContext: ctx,
328
+ requestContext: opts.requestContext,
329
+ readMode: opts.readMode ?? 'any',
330
+ readDocuments: createRichTextDocumentReader({
331
+ db: opts.db,
332
+ collections: opts.collections,
333
+ requestContext: opts.requestContext,
334
+ readContext: ctx,
335
+ readMode: opts.readMode ?? 'any',
336
+ locale: opts.locale,
337
+ bypassBeforeRead: opts.bypassBeforeRead,
338
+ richTextPopulate: opts.richTextPopulate,
339
+ securityDomain,
340
+ }),
196
341
  });
197
342
  }
198
- await applyAfterRead({ doc: d, definition: targetDef, readContext: ctx });
343
+ await applyAfterRead({
344
+ doc: d,
345
+ definition: targetDef,
346
+ readContext: ctx,
347
+ requestContext: operationRequestContext,
348
+ locale: opts.locale,
349
+ readMode: opts.readMode,
350
+ projection: selectList,
351
+ materialization: 'relation-target',
352
+ });
199
353
  }
200
354
  if (!targetDef || queuedForNext.has(key))
201
355
  continue;
@@ -274,31 +428,29 @@ async function resolveCollectionDef(db, collections, id, cache) {
274
428
  * the EXISTS-loop entirely.
275
429
  */
276
430
  async function resolveBeforeReadFiltersForTarget(params) {
277
- const { targetDef, collections, requestContext, readContext, bypassBeforeRead, db } = params;
431
+ const { targetDef, collections, requestContext, readContext, bypassBeforeRead, db, securityDomain, } = params;
278
432
  if (!targetDef || !requestContext || bypassBeforeRead)
279
433
  return undefined;
280
- const predicate = await applyBeforeRead({
434
+ return compileBeforeReadFilters({
281
435
  definition: targetDef,
282
436
  requestContext,
283
437
  readContext,
284
- });
285
- if (predicate == null)
286
- return undefined;
287
- const parsed = await parseWhere(predicate, targetDef, {
288
- collections,
289
- resolveCollectionId: async (path) => {
290
- // Match the target collection by path against the loaded collection
291
- // list first (cheap, in-memory); fall back to a DB lookup for cases
292
- // where a hook references a collection not loaded into the same
293
- // populate context. This mirrors `CollectionHandle`'s parser ctx.
294
- const local = collections.find((c) => c.path === path);
295
- if (local?.id)
296
- return local.id;
297
- const row = await db.queries.collections.getCollectionByPath(path);
298
- return row?.id ?? '';
438
+ securityDomain,
439
+ parseContext: {
440
+ collections,
441
+ resolveCollectionId: async (path) => {
442
+ // Match the target collection by path against the loaded collection
443
+ // list first (cheap, in-memory); fall back to a DB lookup for cases
444
+ // where a hook references a collection not loaded into the same
445
+ // populate context. This mirrors `CollectionHandle`'s parser ctx.
446
+ const local = collections.find((c) => c.path === path);
447
+ if (local?.id)
448
+ return local.id;
449
+ const row = await db.queries.collections.getCollectionByPath(path);
450
+ return row?.id ?? '';
451
+ },
299
452
  },
300
453
  });
301
- return parsed.filters.length > 0 ? parsed.filters : undefined;
302
454
  }
303
455
  /**
304
456
  * Collect every relation leaf whose name matches `populate`. Structure
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { AdminAuth, createRequestContext } from '@byline/auth';
8
9
  import { describe, expect, it, vi } from 'vitest';
9
10
  import { BylineError, ErrorCodes } from '../lib/errors.js';
10
11
  import { __internal, createReadContext, populateDocuments } from './populate.js';
@@ -150,12 +151,23 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
150
151
  setOrderKey: vi.fn(),
151
152
  placeTreeNode: vi.fn(),
152
153
  removeFromTree: vi.fn(),
154
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
155
+ removed: {
156
+ changed: false,
157
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
158
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
159
+ beforeSiblingDocumentIds: [],
160
+ beforeSubtreeDocumentIds: [],
161
+ },
162
+ promoted: [],
163
+ })),
153
164
  },
154
165
  counters: {
155
166
  ensureCounterGroup: vi.fn(),
156
167
  nextCounterValue: vi.fn(),
157
168
  nextScopedCounterValue: vi.fn(),
158
169
  },
170
+ audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
159
171
  },
160
172
  queries: {
161
173
  collections: {
@@ -164,6 +176,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
164
176
  getCollectionById,
165
177
  },
166
178
  documents: {
179
+ getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
167
180
  getDocumentById: vi.fn(),
168
181
  getCurrentVersionMetadata: vi.fn(),
169
182
  getCurrentPath: vi.fn(),
@@ -184,7 +197,18 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
184
197
  getTreeParent: vi.fn(),
185
198
  getTreeSubtree: vi.fn(),
186
199
  },
200
+ audit: {
201
+ getDocumentAuditLog: vi.fn(async () => ({
202
+ entries: [],
203
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
204
+ })),
205
+ findAuditLog: vi.fn(async () => ({
206
+ entries: [],
207
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
208
+ })),
209
+ },
187
210
  },
211
+ withTransaction: async (fn) => fn(),
188
212
  };
189
213
  return { db, getDocumentsByDocumentIds, getCollectionById };
190
214
  }
@@ -353,6 +377,41 @@ describe('buildBatchSelect', () => {
353
377
  // populateDocuments — behaviour
354
378
  // ---------------------------------------------------------------------------
355
379
  describe('populateDocuments', () => {
380
+ it('isolates direct callers by default and shares compiled filters only with an explicit domain', async () => {
381
+ const beforeRead = vi.fn(() => ({ name: 'allowed' }));
382
+ const scopedAuthors = {
383
+ ...authorsCollection,
384
+ hooks: { beforeRead },
385
+ };
386
+ const { db, getDocumentsByDocumentIds } = makeMockAdapter();
387
+ const readContext = createReadContext();
388
+ const requestContext = createRequestContext({
389
+ actor: new AdminAuth({ id: 'reader', abilities: ['collections.authors.read'] }),
390
+ });
391
+ const populate = (suffix, securityDomain) => populateDocuments({
392
+ db,
393
+ collections: [postsCollection, scopedAuthors],
394
+ collectionId: 'posts',
395
+ documents: [
396
+ shapedDoc('posts', `p-${suffix}`, {
397
+ author: relationRef('authors', `a-${suffix}`),
398
+ }),
399
+ ],
400
+ populate: true,
401
+ readContext,
402
+ requestContext,
403
+ securityDomain,
404
+ });
405
+ await populate('implicit-a');
406
+ await populate('implicit-b');
407
+ const sharedDomain = {};
408
+ await populate('explicit-a', sharedDomain);
409
+ await populate('explicit-b', sharedDomain);
410
+ const calls = getDocumentsByDocumentIds.mock.calls;
411
+ expect(calls[1]?.[0].filters).not.toBe(calls[0]?.[0].filters);
412
+ expect(calls[3]?.[0].filters).toBe(calls[2]?.[0].filters);
413
+ expect(beforeRead).toHaveBeenCalledTimes(3);
414
+ });
356
415
  it('is a no-op when populate is omitted', async () => {
357
416
  const { db, getDocumentsByDocumentIds } = makeMockAdapter();
358
417
  const doc = shapedDoc('posts', 'p1', { author: relationRef('authors', 'a1') });
@@ -5,8 +5,42 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { FieldSet, RichTextEmbedFn, RichTextField } from '../@types/field-types.js';
9
- import type { ReadContext } from '../@types/index.js';
8
+ /**
9
+ * Richtext embed service walks an outgoing document, finds every
10
+ * rich-text leaf (including those nested inside `group` / `array` /
11
+ * `blocks` structures), gates each leaf by its `embedRelationsOnSave`
12
+ * flag, and dispatches to the registered richtext embed adapter so
13
+ * link envelopes (and any future write-time embeds) can be refreshed
14
+ * before the value is flattened and persisted.
15
+ *
16
+ * Mirror of `richtext-populate.ts`. Slots into the document-lifecycle
17
+ * write path:
18
+ *
19
+ * beforeCreate / beforeUpdate
20
+ * → assignCounterValues
21
+ * → embedRichTextFields (this module)
22
+ * → createDocumentVersion
23
+ * → afterCreate / afterUpdate
24
+ *
25
+ * Per-leaf errors are logged and swallowed — leaving the persisted state
26
+ * for that leaf as the editor submitted it. Aligns with the strategy's
27
+ * "branch C — hard error" non-destructive fail mode (see
28
+ * docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
29
+ *
30
+ * Multi-locale (`locale: 'all'`) writes: when a richText leaf's value is
31
+ * a `{ <locale>: lexicalJson }` map (the shape used by
32
+ * `restoreDocumentVersion` and `duplicateDocument`), the adapter's
33
+ * `getLexicalRoot` parses the object as a single tree, finds no `root`
34
+ * key and no `children` array, and yields nothing. So embed is a no-op
35
+ * on multi-locale writes — the persisted state carries forward exactly
36
+ * what the source had. Per-locale walking is a deliberate future
37
+ * refinement; today's behaviour matches the populate side (which only
38
+ * fires on locale-scoped reads) and the renderer's fallback chain
39
+ * handles stale embedded paths.
40
+ */
41
+ import type { RequestContext } from '@byline/auth';
42
+ import type { FieldSet, RichTextEmbedFn, RichTextField, RichTextReadDocumentsFn } from '../@types/field-types.js';
43
+ import type { ReadContext, ReadMode } from '../@types/index.js';
10
44
  import type { BylineLogger } from '../lib/logger.js';
11
45
  /**
12
46
  * Resolve the effective `embedRelationsOnSave` for a richText field.
@@ -32,6 +66,9 @@ export interface EmbedRichTextFieldsOptions {
32
66
  * visited-set / read-budget machinery as the rest of the framework.
33
67
  */
34
68
  readContext: ReadContext;
69
+ requestContext: RequestContext;
70
+ readMode: ReadMode;
71
+ readDocuments: RichTextReadDocumentsFn;
35
72
  /** Structured logger — used for branch-C per-leaf error reporting. */
36
73
  logger: BylineLogger;
37
74
  }
@@ -5,39 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- /**
9
- * Richtext embed service — walks an outgoing document, finds every
10
- * rich-text leaf (including those nested inside `group` / `array` /
11
- * `blocks` structures), gates each leaf by its `embedRelationsOnSave`
12
- * flag, and dispatches to the registered richtext embed adapter so
13
- * link envelopes (and any future write-time embeds) can be refreshed
14
- * before the value is flattened and persisted.
15
- *
16
- * Mirror of `richtext-populate.ts`. Slots into the document-lifecycle
17
- * write path:
18
- *
19
- * beforeCreate / beforeUpdate
20
- * → assignCounterValues
21
- * → embedRichTextFields (this module)
22
- * → createDocumentVersion
23
- * → afterCreate / afterUpdate
24
- *
25
- * Per-leaf errors are logged and swallowed — leaving the persisted state
26
- * for that leaf as the editor submitted it. Aligns with the strategy's
27
- * "branch C — hard error" non-destructive fail mode (see
28
- * docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
29
- *
30
- * Multi-locale (`locale: 'all'`) writes: when a richText leaf's value is
31
- * a `{ <locale>: lexicalJson }` map (the shape used by
32
- * `restoreDocumentVersion` and `duplicateDocument`), the adapter's
33
- * `getLexicalRoot` parses the object as a single tree, finds no `root`
34
- * key and no `children` array, and yields nothing. So embed is a no-op
35
- * on multi-locale writes — the persisted state carries forward exactly
36
- * what the source had. Per-locale walking is a deliberate future
37
- * refinement; today's behaviour matches the populate side (which only
38
- * fires on locale-scoped reads) and the renderer's fallback chain
39
- * handles stale embedded paths.
40
- */
41
8
  import { collectRichTextLeaves } from './richtext-populate.js';
42
9
  /**
43
10
  * Resolve the effective `embedRelationsOnSave` for a richText field.
@@ -58,7 +25,7 @@ export function resolveEmbedOnSave(field) {
58
25
  * downstream proceeds. Document-level errors propagate.
59
26
  */
60
27
  export async function embedRichTextFields(options) {
61
- const { fields, collectionPath, data, embed, readContext, logger } = options;
28
+ const { fields, collectionPath, data, embed, readContext, requestContext, readMode, readDocuments, logger, } = options;
62
29
  for (const leaf of collectRichTextLeaves(fields, data)) {
63
30
  if (!resolveEmbedOnSave(leaf.field))
64
31
  continue;
@@ -68,6 +35,9 @@ export async function embedRichTextFields(options) {
68
35
  fieldPath: leaf.fieldPath,
69
36
  collectionPath,
70
37
  readContext,
38
+ requestContext,
39
+ readMode,
40
+ readDocuments,
71
41
  });
72
42
  }
73
43
  catch (err) {
@@ -11,6 +11,7 @@
11
11
  * branch C (per-leaf error swallow). The visitor-side branches (A / B /
12
12
  * found) live in `@byline/richtext-lexical` and have their own suite.
13
13
  */
14
+ import { createSuperAdminContext } from '@byline/auth';
14
15
  import { describe, expect, it, vi } from 'vitest';
15
16
  import { embedRichTextFields } from './richtext-embed.js';
16
17
  const noopLogger = {
@@ -24,6 +25,11 @@ const noopLogger = {
24
25
  silent: vi.fn(),
25
26
  };
26
27
  const fakeReadContext = {};
28
+ const authOptions = {
29
+ requestContext: createSuperAdminContext(),
30
+ readMode: 'published',
31
+ readDocuments: vi.fn(),
32
+ };
27
33
  const richTextValue = (label) => ({
28
34
  root: { type: 'root', children: [], _label: label },
29
35
  });
@@ -56,9 +62,15 @@ describe('embedRichTextFields', () => {
56
62
  data,
57
63
  embed,
58
64
  readContext: fakeReadContext,
65
+ ...authOptions,
59
66
  logger: noopLogger,
60
67
  });
61
68
  expect(embed).toHaveBeenCalledTimes(3);
69
+ expect(embed).toHaveBeenCalledWith(expect.objectContaining({
70
+ requestContext: authOptions.requestContext,
71
+ readMode: 'published',
72
+ readDocuments: authOptions.readDocuments,
73
+ }));
62
74
  const paths = embed.mock.calls.map((call) => call[0].fieldPath);
63
75
  expect(paths.sort()).toEqual(['body', 'faq.0.answer', 'meta.summary']);
64
76
  });
@@ -83,6 +95,7 @@ describe('embedRichTextFields', () => {
83
95
  data,
84
96
  embed,
85
97
  readContext: fakeReadContext,
98
+ ...authOptions,
86
99
  logger: noopLogger,
87
100
  });
88
101
  expect(embed).toHaveBeenCalledTimes(1);
@@ -116,6 +129,7 @@ describe('embedRichTextFields', () => {
116
129
  data,
117
130
  embed,
118
131
  readContext: fakeReadContext,
132
+ ...authOptions,
119
133
  logger,
120
134
  })).resolves.toBeUndefined();
121
135
  // All three leaves were attempted — the failure in 'second' did not
@@ -142,6 +156,7 @@ describe('embedRichTextFields', () => {
142
156
  data,
143
157
  embed,
144
158
  readContext: fakeReadContext,
159
+ ...authOptions,
145
160
  logger: noopLogger,
146
161
  });
147
162
  expect(embed).not.toHaveBeenCalled();
@@ -23,9 +23,10 @@
23
23
  * each call has a precise `fieldPath` for error messages and so future
24
24
  * adapters can implement per-leaf caching if needed.
25
25
  */
26
- import { type FieldSet, type RichTextField, type RichTextPopulateFn } from '../@types/field-types.js';
26
+ import type { RequestContext } from '@byline/auth';
27
+ import { type FieldSet, type RichTextField, type RichTextPopulateFn, type RichTextReadDocumentsFn } from '../@types/field-types.js';
27
28
  import type { CollectionDefinition } from '../@types/collection-types.js';
28
- import type { ReadContext } from '../@types/index.js';
29
+ import type { IDbAdapter, ReadContext, ReadMode } from '../@types/index.js';
29
30
  /**
30
31
  * One rich-text leaf yielded by `collectRichTextLeaves`. The walker hands
31
32
  * back a reference to the *parent container* (`parent[key]`) rather than
@@ -61,6 +62,9 @@ export interface PopulateRichTextFieldsOptions {
61
62
  /** Registered server-side populate function from `ServerConfig`. */
62
63
  populate: RichTextPopulateFn;
63
64
  readContext: ReadContext;
65
+ requestContext: RequestContext;
66
+ readMode: ReadMode;
67
+ readDocuments: RichTextReadDocumentsFn;
64
68
  }
65
69
  /**
66
70
  * Resolve the effective `populateRelationsOnRead` for a richText field.
@@ -76,6 +80,20 @@ export declare function resolvePopulateOnRead(field: RichTextField): boolean;
76
80
  * is `true`. Mutates document `fields` in place.
77
81
  */
78
82
  export declare function populateRichTextFields(options: PopulateRichTextFieldsOptions): Promise<void>;
83
+ /** Build the secure batch reader exposed to editor-agnostic adapters. */
84
+ export declare function createRichTextDocumentReader(options: {
85
+ db: IDbAdapter;
86
+ collections: readonly CollectionDefinition[];
87
+ requestContext: RequestContext;
88
+ readContext: ReadContext;
89
+ readMode: ReadMode;
90
+ locale?: string;
91
+ bypassBeforeRead?: true;
92
+ /** Private cache domain explicitly shared with the originating client read. */
93
+ securityDomain?: object;
94
+ /** Adapter reused recursively for rich-text fields on target documents. */
95
+ richTextPopulate?: RichTextPopulateFn;
96
+ }): RichTextReadDocumentsFn;
79
97
  /**
80
98
  * Which richtext server adapters the host has registered. Pass both
81
99
  * flags so the validator can fail-fast on each missing-adapter case
@@ -102,4 +120,4 @@ export interface RichTextAdapterPresence {
102
120
  * Called once at `initBylineCore()` time. Fail-fast at boot is the right
103
121
  * posture; the alternative is a silent broken renderer at request time.
104
122
  */
105
- export declare function validateRichTextFieldFlags(collections: CollectionDefinition[], adapters: RichTextAdapterPresence): void;
123
+ export declare function validateRichTextFieldFlags(collections: readonly CollectionDefinition[], adapters: RichTextAdapterPresence): void;