@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,6 +7,7 @@
7
7
  */
8
8
  import { createSuperAdminContext } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
+ import { commitHookAttachment, prepareHookAttachment } from '../config/attach-hooks.js';
10
11
  import { BylineError, ErrorCodes } from '../lib/errors.js';
11
12
  import { uploadField } from './field-upload.js';
12
13
  const uploadCollection = {
@@ -62,12 +63,23 @@ function createMockDb() {
62
63
  setOrderKey: vi.fn(),
63
64
  placeTreeNode: vi.fn(),
64
65
  removeFromTree: vi.fn(),
66
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
67
+ removed: {
68
+ changed: false,
69
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
70
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
71
+ beforeSiblingDocumentIds: [],
72
+ beforeSubtreeDocumentIds: [],
73
+ },
74
+ promoted: [],
75
+ })),
65
76
  },
66
77
  counters: {
67
78
  ensureCounterGroup: vi.fn(),
68
79
  nextCounterValue: vi.fn(),
69
80
  nextScopedCounterValue: vi.fn(),
70
81
  },
82
+ audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
71
83
  },
72
84
  queries: {
73
85
  collections: {
@@ -76,6 +88,7 @@ function createMockDb() {
76
88
  getCollectionById: vi.fn(),
77
89
  },
78
90
  documents: {
91
+ getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
79
92
  getDocumentById: vi.fn(),
80
93
  getCurrentVersionMetadata: vi.fn(),
81
94
  getCurrentPath: vi.fn(),
@@ -96,7 +109,18 @@ function createMockDb() {
96
109
  getTreeParent: vi.fn(),
97
110
  getTreeSubtree: vi.fn(),
98
111
  },
112
+ audit: {
113
+ getDocumentAuditLog: vi.fn(async () => ({
114
+ entries: [],
115
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
116
+ })),
117
+ findAuditLog: vi.fn(async () => ({
118
+ entries: [],
119
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
120
+ })),
121
+ },
99
122
  },
123
+ withTransaction: async (fn) => fn(),
100
124
  };
101
125
  return { db, createDocumentVersion };
102
126
  }
@@ -162,6 +186,52 @@ function buildCtx(overrides) {
162
186
  return { ctx, createDocumentVersion, upload, del, imageProcessor };
163
187
  }
164
188
  describe('uploadField service', () => {
189
+ it('fires registry hooks for a nested array instance selected by its unique upload leaf', async () => {
190
+ const beforeStore = vi.fn();
191
+ const publicationFile = {
192
+ name: 'publicationFile',
193
+ type: 'file',
194
+ upload: { mimeTypes: ['application/pdf'] },
195
+ };
196
+ const definition = {
197
+ path: 'documents',
198
+ labels: { singular: 'Document', plural: 'Documents' },
199
+ fields: [
200
+ {
201
+ name: 'files',
202
+ type: 'array',
203
+ fields: [{ name: 'filesGroup', type: 'group', fields: [publicationFile] }],
204
+ },
205
+ ],
206
+ };
207
+ commitHookAttachment(prepareHookAttachment({
208
+ collections: [definition],
209
+ hooks: {
210
+ uploads: {
211
+ 'documents.files.filesGroup.publicationFile': { beforeStore },
212
+ },
213
+ },
214
+ }));
215
+ const { ctx } = buildCtx({
216
+ definition,
217
+ collectionPath: 'documents',
218
+ fieldName: 'publicationFile',
219
+ });
220
+ await uploadField(ctx, {
221
+ buffer: Buffer.from('pdf'),
222
+ originalFilename: 'publication.pdf',
223
+ mimeType: 'application/pdf',
224
+ fileSize: 3,
225
+ fields: { runtimeFormPath: 'files[2].filesGroup.publicationFile' },
226
+ shouldCreateDocument: false,
227
+ });
228
+ expect(beforeStore).toHaveBeenCalledOnce();
229
+ expect(beforeStore).toHaveBeenCalledWith(expect.objectContaining({
230
+ fieldName: 'publicationFile',
231
+ field: publicationFile,
232
+ fields: { runtimeFormPath: 'files[2].filesGroup.publicationFile' },
233
+ }));
234
+ });
165
235
  it('uploads a file without creating a document when requested', async () => {
166
236
  const { ctx, createDocumentVersion, upload } = buildCtx();
167
237
  const result = await uploadField(ctx, {
@@ -1,4 +1,4 @@
1
- export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
1
+ export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_VALIDATION, } from '../lib/errors.js';
2
2
  export { normaliseDateFields } from '../utils/normalise-dates.js';
3
3
  export { type AssignCounterValuesInput, assignCounterValues } from './assign-counter-values.js';
4
4
  export { type BuildSearchDocumentOptions, buildSearchDocument, resolveSearchZones, type SearchSourceDocument, } from './build-search-document.js';
@@ -13,6 +13,6 @@ export { type CanonicalNumericFieldType, type CanonicalNumericValue, isCanonical
13
13
  export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, resolveIdentityField, type UnresolvedRelationValue, } from './populate.js';
14
14
  export { buildRelationSummaryPopulateMap, type RelationTargetResolver, resolveRelationProjection, } from './relation-projection.js';
15
15
  export { type EmbedRichTextFieldsOptions, embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
16
- export { collectRichTextLeaves, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
16
+ export { collectRichTextLeaves, createRichTextDocumentReader, type PopulateRichTextFieldsOptions, populateRichTextFields, type RichTextAdapterPresence, type RichTextLeaf, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
17
17
  export { type SearchProviderPresence, validateSearchConfig, } from './validate-search-config.js';
18
18
  export { type FieldLeaf, walkFieldTree } from './walk-field-tree.js';
@@ -1,5 +1,5 @@
1
1
  // packages/core/src/services/index.ts
2
- export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
2
+ export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_VALIDATION, } from '../lib/errors.js';
3
3
  export { normaliseDateFields } from '../utils/normalise-dates.js';
4
4
  export { assignCounterValues } from './assign-counter-values.js';
5
5
  export { buildSearchDocument, resolveSearchZones, } from './build-search-document.js';
@@ -14,6 +14,6 @@ export { isCanonicalNumericValue, normalizeNumericFields, normalizeNumericValue,
14
14
  export { createReadContext, populateDocuments, resolveIdentityField, } from './populate.js';
15
15
  export { buildRelationSummaryPopulateMap, resolveRelationProjection, } from './relation-projection.js';
16
16
  export { embedRichTextFields, resolveEmbedOnSave, } from './richtext-embed.js';
17
- export { collectRichTextLeaves, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
17
+ export { collectRichTextLeaves, createRichTextDocumentReader, populateRichTextFields, resolvePopulateOnRead, validateRichTextFieldFlags, } from './richtext-populate.js';
18
18
  export { validateSearchConfig, } from './validate-search-config.js';
19
19
  export { walkFieldTree } from './walk-field-tree.js';
@@ -128,7 +128,7 @@
128
128
  * wins; otherwise selects union together, identity field is always
129
129
  * added).
130
130
  */
131
- import type { RequestContext } from '@byline/auth';
131
+ import { type RequestContext } from '@byline/auth';
132
132
  import type { CollectionDefinition, FieldSet, IDbAdapter, PopulateFieldSpec, PopulateSpec, ReadContext, ReadMode, RelatedDocumentValue, RelationField } from '../@types/index.js';
133
133
  export type { ReadContext } from '../@types/index.js';
134
134
  /** Build a fresh ReadContext. */
@@ -179,13 +179,17 @@ export interface PopulateOptions {
179
179
  /**
180
180
  * Request-scoped auth context. Required when any target collection in the
181
181
  * walk has a `beforeRead` hook configured. Each target's hook is invoked
182
- * (and cached on `readContext.beforeReadCache`) before its batch fetch,
182
+ * (and cached in private authority-bound state) before its batch fetch,
183
183
  * and the resulting predicate is ANDed onto the fetch's WHERE. When
184
184
  * omitted — most synthetic / test call paths — `beforeRead` hooks are
185
185
  * skipped entirely; the production read paths all forward this through
186
- * from `CollectionHandle`.
186
+ * from `CollectionHandle`. Low-level synthetic calls still receive an
187
+ * anonymous operation context in `afterRead`, so the hook contract never
188
+ * lacks identity state.
187
189
  */
188
190
  requestContext?: RequestContext;
191
+ /** Private cache domain explicitly shared by every read path in one client instance. */
192
+ securityDomain?: object;
189
193
  /**
190
194
  * Skip `beforeRead` hook resolution on every target collection. The
191
195
  * top-level read's `_bypassBeforeRead` flag rides through to populate
@@ -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) {