@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.
- package/dist/@types/collection-types.d.ts +72 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-data-types.d.ts +8 -10
- package/dist/@types/field-data-types.js +1 -1
- package/dist/@types/field-data-types.test.node.d.ts +1 -0
- package/dist/@types/field-data-types.test.node.js +70 -0
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/relation-types.d.ts +38 -0
- package/dist/@types/relation-types.js +8 -0
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +52 -13
- package/dist/auth/apply-before-read.d.ts +23 -11
- package/dist/auth/apply-before-read.js +139 -33
- package/dist/auth/apply-before-read.test.node.js +241 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/read-context-scope.d.ts +20 -0
- package/dist/auth/read-context-scope.js +48 -0
- package/dist/codegen/fixtures/all-fields.d.ts +342 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
- package/dist/codegen/fixtures/all-fields.expected.js +1 -0
- package/dist/codegen/fixtures/all-fields.js +94 -0
- package/dist/codegen/index.d.ts +16 -0
- package/dist/codegen/index.js +431 -0
- package/dist/codegen/index.test.node.d.ts +1 -0
- package/dist/codegen/index.test.node.js +230 -0
- package/dist/config/attach-hooks.d.ts +25 -0
- package/dist/config/attach-hooks.js +130 -0
- package/dist/config/attach-hooks.test.node.d.ts +1 -0
- package/dist/config/attach-hooks.test.node.js +173 -0
- package/dist/config/config-hooks.test.node.d.ts +1 -0
- package/dist/config/config-hooks.test.node.js +56 -0
- package/dist/config/config.d.ts +9 -5
- package/dist/config/config.js +20 -2
- package/dist/config/routes.d.ts +5 -5
- package/dist/config/routes.js +42 -9
- package/dist/config/routes.test.node.d.ts +1 -0
- package/dist/config/routes.test.node.js +152 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +20 -14
- package/dist/core.test.node.d.ts +1 -0
- package/dist/core.test.node.js +28 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +10 -1
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/schemas/zod/builder.js +7 -3
- package/dist/schemas/zod/builder.test.node.d.ts +1 -0
- package/dist/schemas/zod/builder.test.node.js +49 -0
- package/dist/services/collection-bootstrap.d.ts +1 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- package/dist/services/discover-counter-groups.d.ts +1 -1
- package/dist/services/discover-counter-groups.test.node.js +23 -0
- package/dist/services/document-lifecycle/audit.d.ts +22 -1
- package/dist/services/document-lifecycle/audit.js +32 -1
- package/dist/services/document-lifecycle/create.d.ts +2 -2
- package/dist/services/document-lifecycle/create.js +18 -8
- package/dist/services/document-lifecycle/delete.d.ts +17 -1
- package/dist/services/document-lifecycle/delete.js +91 -26
- package/dist/services/document-lifecycle/index.d.ts +1 -1
- package/dist/services/document-lifecycle/internals.d.ts +0 -20
- package/dist/services/document-lifecycle/internals.js +22 -46
- package/dist/services/document-lifecycle/status.d.ts +1 -1
- package/dist/services/document-lifecycle/status.js +20 -3
- package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
- package/dist/services/document-lifecycle/system-fields.js +112 -74
- package/dist/services/document-lifecycle/tree.d.ts +22 -24
- package/dist/services/document-lifecycle/tree.js +244 -123
- package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/tree.test.node.js +663 -0
- package/dist/services/document-lifecycle/update.d.ts +4 -4
- package/dist/services/document-lifecycle/update.js +11 -5
- package/dist/services/document-lifecycle.test.node.js +426 -16
- package/dist/services/document-read.d.ts +14 -6
- package/dist/services/document-read.js +47 -9
- package/dist/services/field-upload.test.node.js +70 -0
- package/dist/services/index.d.ts +3 -2
- package/dist/services/index.js +3 -2
- package/dist/services/normalize-numeric-fields.d.ts +23 -0
- package/dist/services/normalize-numeric-fields.js +89 -0
- package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
- package/dist/services/normalize-numeric-fields.test.node.js +99 -0
- package/dist/services/populate.d.ts +9 -23
- package/dist/services/populate.js +180 -28
- package/dist/services/populate.test.node.js +59 -0
- package/dist/services/richtext-embed.d.ts +39 -2
- package/dist/services/richtext-embed.js +4 -34
- package/dist/services/richtext-embed.test.node.js +15 -0
- package/dist/services/richtext-populate.d.ts +21 -3
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/services/validate-search-config.d.ts +1 -1
- package/dist/storage/collection-fingerprint.js +6 -0
- package/dist/storage/collection-fingerprint.test.node.js +21 -0
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- package/package.json +7 -2
|
@@ -5,11 +5,135 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
|
|
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
|
-
//
|
|
99
|
-
//
|
|
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({
|
|
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
|
-
|
|
434
|
+
return compileBeforeReadFilters({
|
|
281
435
|
definition: targetDef,
|
|
282
436
|
requestContext,
|
|
283
437
|
readContext,
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return
|
|
297
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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;
|