@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
@@ -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
@@ -5,25 +5,12 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- /**
9
- * Richtext populate service — walks a reconstructed document, finds every
10
- * rich-text leaf (including those nested inside `group` / `array` /
11
- * `blocks` structures), gates each leaf by its `populateRelationsOnRead`
12
- * flag, and dispatches to the registered richtext populate adapter.
13
- *
14
- * Slots into the read pipeline alongside `populateDocuments`:
15
- *
16
- * findDocuments → reconstruct → populateDocuments → populateRichTextFields → afterRead
17
- *
18
- * The same `ReadContext` flows through both populate phases, so dedup /
19
- * cycle protection / read-budget enforcement covers rich-text fan-out
20
- * automatically and any nested reads the adapter performs.
21
- *
22
- * The adapter is invoked once per leaf rather than once per document so
23
- * each call has a precise `fieldPath` for error messages and so future
24
- * adapters can implement per-leaf caching if needed.
25
- */
26
8
  import { isArrayField, isBlocksField, isGroupField, } from '../@types/field-types.js';
9
+ import { bindReadContextAuthority, compileBeforeReadFilters } from '../auth/apply-before-read.js';
10
+ import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
11
+ import { resolveReadContextRoot } from '../auth/read-context-scope.js';
12
+ import { ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION } from '../lib/errors.js';
13
+ import { applyAfterRead } from './document-read.js';
27
14
  import { walkFieldTree } from './walk-field-tree.js';
28
15
  /**
29
16
  * Walk a field set and a matching reconstructed data tree in lockstep,
@@ -66,7 +53,7 @@ export function resolvePopulateOnRead(field) {
66
53
  * is `true`. Mutates document `fields` in place.
67
54
  */
68
55
  export async function populateRichTextFields(options) {
69
- const { fields, collectionPath, documents, populate, readContext } = options;
56
+ const { fields, collectionPath, documents, populate, readContext, requestContext, readMode, readDocuments, } = options;
70
57
  for (const doc of documents) {
71
58
  const docFields = (doc.fields ?? {});
72
59
  for (const leaf of collectRichTextLeaves(fields, docFields)) {
@@ -77,10 +64,164 @@ export async function populateRichTextFields(options) {
77
64
  fieldPath: leaf.fieldPath,
78
65
  collectionPath,
79
66
  readContext,
67
+ requestContext,
68
+ readMode,
69
+ readDocuments,
80
70
  });
81
71
  }
82
72
  }
83
73
  }
74
+ /** Build the secure batch reader exposed to editor-agnostic adapters. */
75
+ export function createRichTextDocumentReader(options) {
76
+ const { db, collections, requestContext, readContext, readMode, locale, bypassBeforeRead, richTextPopulate, } = options;
77
+ const securityDomain = options.securityDomain ?? {};
78
+ const state = getRichTextReaderState(readContext);
79
+ const read = async ({ collectionPath, documentIds, fields }) => {
80
+ bindReadContextAuthority(readContext, requestContext);
81
+ if (documentIds.length === 0)
82
+ return [];
83
+ const definition = collections.find((candidate) => candidate.path === collectionPath);
84
+ if (definition == null) {
85
+ throw ERR_VALIDATION({
86
+ message: `richtext target collection '${collectionPath}' is not registered`,
87
+ details: { collectionPath },
88
+ });
89
+ }
90
+ assertActorCanPerform(requestContext, collectionPath, 'read');
91
+ let filters;
92
+ if (!bypassBeforeRead) {
93
+ filters = await compileBeforeReadFilters({
94
+ definition,
95
+ requestContext,
96
+ readContext,
97
+ securityDomain,
98
+ parseContext: {
99
+ collections,
100
+ resolveCollectionId: async (path) => {
101
+ const row = await db.queries.collections.getCollectionByPath(path);
102
+ return row?.id ?? '';
103
+ },
104
+ },
105
+ });
106
+ }
107
+ const collection = await db.queries.collections.getCollectionByPath(collectionPath);
108
+ if (collection?.id == null) {
109
+ throw ERR_VALIDATION({
110
+ message: `richtext target collection '${collectionPath}' is not available`,
111
+ details: { collectionPath },
112
+ });
113
+ }
114
+ const collectionId = collection.id;
115
+ const projection = fields == null ? '*' : [...fields].sort().join(',');
116
+ const resultById = new Map();
117
+ const idsToFetch = [];
118
+ for (const documentId of new Set(documentIds)) {
119
+ const key = richTextMaterializationKey(collectionId, documentId, requestContext.requestId, locale, readMode, projection);
120
+ if (state.cache.has(key)) {
121
+ const cached = state.cache.get(key);
122
+ if (cached != null)
123
+ resultById.set(documentId, cached);
124
+ }
125
+ else if (!state.active.has(`${collectionId}:${documentId}`)) {
126
+ idsToFetch.push(documentId);
127
+ }
128
+ }
129
+ if (idsToFetch.length > 0) {
130
+ const fetched = (await db.queries.documents.getDocumentsByDocumentIds({
131
+ collection_id: collectionId,
132
+ document_ids: idsToFetch,
133
+ fields,
134
+ readMode,
135
+ locale,
136
+ filters: filters && filters.length > 0 ? filters : undefined,
137
+ }));
138
+ const fetchedById = new Map(fetched
139
+ .filter((doc) => typeof doc.document_id === 'string')
140
+ .map((doc) => [doc.document_id, doc]));
141
+ for (const documentId of idsToFetch) {
142
+ const materializationKey = richTextMaterializationKey(collectionId, documentId, requestContext.requestId, locale, readMode, projection);
143
+ // Earlier items in this batch may recursively populate a later target.
144
+ // Honour that completed/active state rather than reprocessing the stale
145
+ // raw row captured by the outer batch and overwriting its redacted cache.
146
+ if (state.cache.has(materializationKey)) {
147
+ const cached = state.cache.get(materializationKey);
148
+ if (cached != null)
149
+ resultById.set(documentId, cached);
150
+ continue;
151
+ }
152
+ const documentKey = `${collectionId}:${documentId}`;
153
+ if (state.active.has(documentKey))
154
+ continue;
155
+ const doc = fetchedById.get(documentId);
156
+ if (doc == null) {
157
+ state.cache.set(materializationKey, null);
158
+ continue;
159
+ }
160
+ state.active.add(documentKey);
161
+ readContext.visited.add(documentKey);
162
+ try {
163
+ readContext.readCount += 1;
164
+ if (readContext.readCount > readContext.maxReads) {
165
+ throw ERR_READ_BUDGET_EXCEEDED({
166
+ message: `richtext populate exceeded read budget (maxReads=${readContext.maxReads})`,
167
+ details: {
168
+ readCount: readContext.readCount,
169
+ maxReads: readContext.maxReads,
170
+ targetCollectionId: collectionId,
171
+ targetDocumentId: documentId,
172
+ },
173
+ });
174
+ }
175
+ if (richTextPopulate) {
176
+ await populateRichTextFields({
177
+ fields: definition.fields,
178
+ collectionPath,
179
+ documents: [doc],
180
+ populate: richTextPopulate,
181
+ readContext,
182
+ requestContext,
183
+ readMode,
184
+ readDocuments: read,
185
+ });
186
+ }
187
+ await applyAfterRead({
188
+ doc,
189
+ definition,
190
+ readContext,
191
+ requestContext,
192
+ locale,
193
+ readMode,
194
+ projection: fields,
195
+ materialization: 'richtext-target',
196
+ });
197
+ state.cache.set(materializationKey, doc);
198
+ resultById.set(documentId, doc);
199
+ }
200
+ finally {
201
+ state.active.delete(documentKey);
202
+ }
203
+ }
204
+ }
205
+ return documentIds.flatMap((documentId) => {
206
+ const doc = resultById.get(documentId);
207
+ return doc == null ? [] : [doc];
208
+ });
209
+ };
210
+ return read;
211
+ }
212
+ const richTextReaderStates = new WeakMap();
213
+ function getRichTextReaderState(readContext) {
214
+ const root = resolveReadContextRoot(readContext);
215
+ const existing = richTextReaderStates.get(root);
216
+ if (existing)
217
+ return existing;
218
+ const state = { active: new Set(), cache: new Map() };
219
+ richTextReaderStates.set(root, state);
220
+ return state;
221
+ }
222
+ function richTextMaterializationKey(collectionId, documentId, requestId, locale, readMode, projection) {
223
+ return `${collectionId}:${documentId}:${requestId}:${locale ?? 'all'}:${readMode}:${projection}`;
224
+ }
84
225
  // ---------------------------------------------------------------------------
85
226
  // Boot-time validation
86
227
  // ---------------------------------------------------------------------------