@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,12 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { createRequestContext } from '@byline/auth';
9
- import { describe, expect, it } from 'vitest';
8
+ import { AdminAuth, createRequestContext } from '@byline/auth';
9
+ import { describe, expect, it, vi } from 'vitest';
10
10
  import { defineCollection, defineWorkflow, } from '../@types/collection-types.js';
11
11
  import { createReadContext } from '../services/populate.js';
12
- import { applyBeforeRead } from './apply-before-read.js';
12
+ import { applyBeforeRead, compileBeforeReadFilters } from './apply-before-read.js';
13
+ import { resolveReadContextRoot } from './read-context-scope.js';
13
14
  const baseCollection = (hook) => defineCollection({
14
15
  path: 'posts',
15
16
  labels: { singular: 'Post', plural: 'Posts' },
@@ -101,6 +102,55 @@ describe('applyBeforeRead', () => {
101
102
  expect(second).toBeNull();
102
103
  expect(callCount).toBe(1);
103
104
  });
105
+ it('ignores a caller-preseeded public cache entry', async () => {
106
+ const hook = vi.fn(() => ({ title: 'allowed' }));
107
+ const readContext = createReadContext({
108
+ beforeReadCache: new Map([['posts:any', null]]),
109
+ });
110
+ const result = await applyBeforeRead({
111
+ definition: baseCollection(hook),
112
+ requestContext: createRequestContext(),
113
+ readContext,
114
+ });
115
+ expect(hook).toHaveBeenCalledOnce();
116
+ expect(result).toEqual({ title: 'allowed' });
117
+ });
118
+ it('ignores a caller-preseeded public cache entry on the compiled-filter path', async () => {
119
+ const hook = vi.fn(() => ({ title: 'allowed' }));
120
+ const readContext = createReadContext({
121
+ beforeReadCache: new Map([['posts:any', null]]),
122
+ });
123
+ const filters = await compileBeforeReadFilters({
124
+ definition: baseCollection(hook),
125
+ requestContext: createRequestContext(),
126
+ readContext,
127
+ securityDomain: {},
128
+ parseContext: { collections: [], resolveCollectionId: vi.fn() },
129
+ });
130
+ expect(hook).toHaveBeenCalledOnce();
131
+ expect(filters).toEqual([
132
+ expect.objectContaining({ kind: 'field', fieldName: 'title', value: 'allowed' }),
133
+ ]);
134
+ });
135
+ it('rejects reuse under a different actor authority', async () => {
136
+ const hook = vi.fn(({ requestContext }) => ({
137
+ title: requestContext.actor?.id,
138
+ }));
139
+ const definition = baseCollection(hook);
140
+ const readContext = createReadContext();
141
+ const requestId = 'same-logical-request';
142
+ const alice = createRequestContext({
143
+ actor: new AdminAuth({ id: 'alice', abilities: ['collections.posts.read'] }),
144
+ requestId,
145
+ });
146
+ const bob = createRequestContext({
147
+ actor: new AdminAuth({ id: 'bob', abilities: ['collections.posts.read'] }),
148
+ requestId,
149
+ });
150
+ await applyBeforeRead({ definition, requestContext: alice, readContext });
151
+ await expect(applyBeforeRead({ definition, requestContext: bob, readContext })).rejects.toThrow('cannot be reused across request authorities');
152
+ expect(hook).toHaveBeenCalledOnce();
153
+ });
104
154
  it('runs the hook again on a fresh ReadContext', async () => {
105
155
  let callCount = 0;
106
156
  const hook = () => {
@@ -113,6 +163,32 @@ describe('applyBeforeRead', () => {
113
163
  await applyBeforeRead({ definition: def, requestContext, readContext: createReadContext() });
114
164
  expect(callCount).toBe(2);
115
165
  });
166
+ it('caches independently by effective read mode', async () => {
167
+ const modes = [];
168
+ const hook = ({ requestContext }) => {
169
+ modes.push(requestContext.readMode);
170
+ return { status: requestContext.readMode === 'published' ? 'published' : 'draft' };
171
+ };
172
+ const definition = baseCollection(hook);
173
+ const readContext = createReadContext();
174
+ const base = createRequestContext();
175
+ await applyBeforeRead({
176
+ definition,
177
+ requestContext: { ...base, readMode: 'published' },
178
+ readContext,
179
+ });
180
+ await applyBeforeRead({
181
+ definition,
182
+ requestContext: { ...base, readMode: 'any' },
183
+ readContext,
184
+ });
185
+ await applyBeforeRead({
186
+ definition,
187
+ requestContext: { ...base, readMode: 'published' },
188
+ readContext,
189
+ });
190
+ expect(modes).toEqual(['published', 'any']);
191
+ });
116
192
  it('passes collectionPath, requestContext, and readContext through to the hook', async () => {
117
193
  let received;
118
194
  const hook = (ctx) => {
@@ -125,8 +201,40 @@ describe('applyBeforeRead', () => {
125
201
  expect(received).toMatchObject({
126
202
  collectionPath: 'posts',
127
203
  requestContext,
204
+ });
205
+ expect(received.readContext.visited).toBe(readContext.visited);
206
+ });
207
+ it('delegates scoped read-budget state to the logical root context', async () => {
208
+ const readContext = createReadContext({ maxReads: 4 });
209
+ const hook = ({ readContext: scoped }) => {
210
+ scoped.visited.add('posts:nested');
211
+ scoped.readCount += 1;
212
+ scoped.maxReads = 3;
213
+ };
214
+ await applyBeforeRead({
215
+ definition: baseCollection(hook),
216
+ requestContext: createRequestContext(),
217
+ readContext,
218
+ });
219
+ expect(readContext.visited).toContain('posts:nested');
220
+ expect(readContext.readCount).toBe(1);
221
+ expect(readContext.maxReads).toBe(3);
222
+ });
223
+ it('resolves scoped hook contexts to their logical root', async () => {
224
+ const readContext = createReadContext();
225
+ let scopedContext;
226
+ const hook = ({ readContext: scoped }) => {
227
+ scopedContext = scoped;
228
+ };
229
+ await applyBeforeRead({
230
+ definition: baseCollection(hook),
231
+ requestContext: createRequestContext(),
128
232
  readContext,
129
233
  });
234
+ if (!scopedContext)
235
+ throw new Error('beforeRead did not receive a scoped ReadContext');
236
+ expect(resolveReadContextRoot(scopedContext)).toBe(readContext);
237
+ expect(resolveReadContextRoot(readContext)).toBe(readContext);
130
238
  });
131
239
  it('supports an async hook', async () => {
132
240
  const hook = async () => {
@@ -140,4 +248,134 @@ describe('applyBeforeRead', () => {
140
248
  });
141
249
  expect(result).toEqual({ tenantId: 't-1' });
142
250
  });
251
+ it('compiles security filters and resolves relation collection ids once per read context', async () => {
252
+ const categories = defineCollection({
253
+ path: 'categories',
254
+ labels: { singular: 'Category', plural: 'Categories' },
255
+ fields: [{ name: 'tenant', type: 'text', label: 'Tenant' }],
256
+ });
257
+ const definition = defineCollection({
258
+ path: 'posts',
259
+ labels: { singular: 'Post', plural: 'Posts' },
260
+ fields: [
261
+ {
262
+ name: 'category',
263
+ type: 'relation',
264
+ label: 'Category',
265
+ targetCollection: 'categories',
266
+ },
267
+ ],
268
+ hooks: { beforeRead: () => ({ category: { tenant: 'alice' } }) },
269
+ });
270
+ const readContext = createReadContext();
271
+ const requestContext = createRequestContext({ readMode: 'published' });
272
+ const resolveCollectionId = vi.fn(async () => 'categories-id');
273
+ const params = {
274
+ definition,
275
+ requestContext,
276
+ readContext,
277
+ securityDomain: {},
278
+ parseContext: { collections: [definition, categories], resolveCollectionId },
279
+ };
280
+ const [first, second] = await Promise.all([
281
+ compileBeforeReadFilters(params),
282
+ compileBeforeReadFilters(params),
283
+ ]);
284
+ expect(second).toBe(first);
285
+ expect(resolveCollectionId).toHaveBeenCalledOnce();
286
+ });
287
+ it('compiles the same definition independently for different security domains', async () => {
288
+ const categories = defineCollection({
289
+ path: 'categories',
290
+ labels: { singular: 'Category', plural: 'Categories' },
291
+ fields: [{ name: 'tenant', type: 'text', label: 'Tenant' }],
292
+ });
293
+ const hook = vi.fn(() => ({ category: { tenant: 'alice' } }));
294
+ const definition = defineCollection({
295
+ path: 'posts',
296
+ labels: { singular: 'Post', plural: 'Posts' },
297
+ fields: [
298
+ {
299
+ name: 'category',
300
+ type: 'relation',
301
+ label: 'Category',
302
+ targetCollection: 'categories',
303
+ },
304
+ ],
305
+ hooks: { beforeRead: hook },
306
+ });
307
+ const readContext = createReadContext();
308
+ const requestContext = createRequestContext({ readMode: 'published' });
309
+ const resolveA = vi.fn(async () => 'categories-a');
310
+ const resolveB = vi.fn(async () => 'categories-b');
311
+ await compileBeforeReadFilters({
312
+ definition,
313
+ requestContext,
314
+ readContext,
315
+ securityDomain: {},
316
+ parseContext: { collections: [definition, categories], resolveCollectionId: resolveA },
317
+ });
318
+ await compileBeforeReadFilters({
319
+ definition,
320
+ requestContext,
321
+ readContext,
322
+ securityDomain: {},
323
+ parseContext: { collections: [definition, categories], resolveCollectionId: resolveB },
324
+ });
325
+ expect(hook).toHaveBeenCalledTimes(2);
326
+ expect(resolveA).toHaveBeenCalledOnce();
327
+ expect(resolveB).toHaveBeenCalledOnce();
328
+ });
329
+ it('fails closed on a recursive beforeRead for the same collection', async () => {
330
+ const readContext = createReadContext();
331
+ const requestContext = createRequestContext();
332
+ let definition;
333
+ const hook = async (ctx) => {
334
+ await applyBeforeRead({
335
+ definition,
336
+ requestContext: ctx.requestContext,
337
+ readContext: ctx.readContext,
338
+ });
339
+ };
340
+ definition = baseCollection(hook);
341
+ await expect(applyBeforeRead({ definition, requestContext, readContext })).rejects.toMatchObject({ code: 'ERR_READ_RECURSION' });
342
+ });
343
+ it('fails closed on an A to B to A beforeRead cycle', async () => {
344
+ const readContext = createReadContext();
345
+ const requestContext = createRequestContext();
346
+ const securityDomain = {};
347
+ let a;
348
+ let b;
349
+ a = defineCollection({
350
+ path: 'a',
351
+ labels: { singular: 'A', plural: 'As' },
352
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
353
+ hooks: {
354
+ beforeRead: async (ctx) => {
355
+ await applyBeforeRead({
356
+ definition: b,
357
+ requestContext: ctx.requestContext,
358
+ readContext: ctx.readContext,
359
+ securityDomain,
360
+ });
361
+ },
362
+ },
363
+ });
364
+ b = defineCollection({
365
+ path: 'b',
366
+ labels: { singular: 'B', plural: 'Bs' },
367
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
368
+ hooks: {
369
+ beforeRead: async (ctx) => {
370
+ await applyBeforeRead({
371
+ definition: a,
372
+ requestContext: ctx.requestContext,
373
+ readContext: ctx.readContext,
374
+ securityDomain,
375
+ });
376
+ },
377
+ },
378
+ });
379
+ await expect(applyBeforeRead({ definition: a, requestContext, readContext, securityDomain })).rejects.toMatchObject({ code: 'ERR_READ_RECURSION' });
380
+ });
143
381
  });
@@ -5,6 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- export { applyBeforeRead } from './apply-before-read.js';
8
+ export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
10
  export { COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
@@ -5,6 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- export { applyBeforeRead } from './apply-before-read.js';
8
+ export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
10
  export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { ReadContext } from '../@types/db-types.js';
9
+ export interface ReadContextScope {
10
+ root: ReadContext;
11
+ ancestry: readonly object[];
12
+ }
13
+ export declare function getReadContextScope(readContext: ReadContext): ReadContextScope;
14
+ /** Resolve a scoped hook context to the ReadContext for its logical request. */
15
+ export declare function resolveReadContextRoot(readContext: ReadContext): ReadContext;
16
+ /**
17
+ * Carry hook ancestry by object identity, not by a forgeable public property.
18
+ * All mutable read-budget state still delegates to the logical root context.
19
+ */
20
+ export declare function createHookReadContext(parent: ReadContextScope, entry: object): ReadContext;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ const readContextScopes = new WeakMap();
9
+ export function getReadContextScope(readContext) {
10
+ return readContextScopes.get(readContext) ?? { root: readContext, ancestry: [] };
11
+ }
12
+ /** Resolve a scoped hook context to the ReadContext for its logical request. */
13
+ export function resolveReadContextRoot(readContext) {
14
+ return getReadContextScope(readContext).root;
15
+ }
16
+ /**
17
+ * Carry hook ancestry by object identity, not by a forgeable public property.
18
+ * All mutable read-budget state still delegates to the logical root context.
19
+ */
20
+ export function createHookReadContext(parent, entry) {
21
+ const root = parent.root;
22
+ const scoped = {};
23
+ Object.defineProperties(scoped, {
24
+ visited: { enumerable: true, get: () => root.visited, set: (value) => (root.visited = value) },
25
+ beforeReadCache: {
26
+ enumerable: true,
27
+ get: () => root.beforeReadCache,
28
+ set: (value) => (root.beforeReadCache = value),
29
+ },
30
+ readCount: {
31
+ enumerable: true,
32
+ get: () => root.readCount,
33
+ set: (value) => (root.readCount = value),
34
+ },
35
+ maxReads: {
36
+ enumerable: true,
37
+ get: () => root.maxReads,
38
+ set: (value) => (root.maxReads = value),
39
+ },
40
+ maxDepth: {
41
+ enumerable: true,
42
+ get: () => root.maxDepth,
43
+ set: (value) => (root.maxDepth = value),
44
+ },
45
+ });
46
+ readContextScopes.set(scoped, { root, ancestry: [...parent.ancestry, entry] });
47
+ return scoped;
48
+ }