@byline/core 4.2.0 → 4.4.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.
@@ -510,6 +510,68 @@ describe('validateCollections', () => {
510
510
  },
511
511
  ],
512
512
  };
513
- expect(() => validateCollections([collection])).toThrow(/content\.file.*must not contain duplicate slashes/s);
513
+ // The path names the block type, so the message identifies which block's
514
+ // `file` declaration is at fault rather than just `content.file`.
515
+ expect(() => validateCollections([collection])).toThrow(/content\.attachmentsBlock\.file.*must not contain duplicate slashes/s);
516
+ });
517
+ });
518
+ // ---------------------------------------------------------------------------
519
+ // search config field resolution
520
+ //
521
+ // `buildSearchDocument` resolves each name among the collection's top-level
522
+ // fields and silently skips what it cannot resolve, so an unrecognised name
523
+ // used to produce a collection that indexed less than its author intended,
524
+ // with no signal at boot or at index time.
525
+ // ---------------------------------------------------------------------------
526
+ describe('validateCollections — search config fields', () => {
527
+ const searchable = (search) => ({
528
+ ...baseCollection,
529
+ search,
530
+ fields: [
531
+ { name: 'title', label: 'Title', type: 'text' },
532
+ { name: 'summary', label: 'Summary', type: 'text' },
533
+ { name: 'featured', label: 'Featured', type: 'checkbox' },
534
+ { name: 'topics', label: 'Topics', type: 'relation', targetCollection: 'topics' },
535
+ { name: 'format', label: 'Format', type: 'select', options: [] },
536
+ {
537
+ name: 'files',
538
+ label: 'Files',
539
+ type: 'array',
540
+ fields: [
541
+ {
542
+ name: 'filesGroup',
543
+ type: 'group',
544
+ fields: [{ name: 'caption', label: 'Caption', type: 'text' }],
545
+ },
546
+ ],
547
+ },
548
+ ],
549
+ });
550
+ it('accepts top-level names, including a container in body', () => {
551
+ expect(() => validateCollections([
552
+ searchable({
553
+ body: [{ field: 'title', boost: 2 }, 'summary', 'files'],
554
+ facets: ['topics'],
555
+ filters: ['featured'],
556
+ }),
557
+ ])).not.toThrow();
558
+ });
559
+ it('rejects a body name that does not exist', () => {
560
+ expect(() => validateCollections([searchable({ body: ['titel'] })])).toThrow(/no top-level field with that name exists/);
561
+ });
562
+ it('rejects a dotted path and explains what to name instead', () => {
563
+ // Accepting it would be worse than rejecting: the indexer would resolve
564
+ // nothing and silently index an empty body.
565
+ expect(() => validateCollections([searchable({ body: ['files.filesGroup.caption'] })])).toThrow(/top-level fields only.*name the top-level container/s);
566
+ });
567
+ it('rejects a facet that is not a relation', () => {
568
+ expect(() => validateCollections([searchable({ facets: ['format'] })])).toThrow(/Facets are controlled-vocabulary references/);
569
+ });
570
+ it('rejects a filter naming a container field', () => {
571
+ expect(() => validateCollections([searchable({ filters: ['files'] })])).toThrow(/Filters project a single scalar value/);
572
+ });
573
+ it('checks facets and filters too, not only body', () => {
574
+ expect(() => validateCollections([searchable({ facets: ['nope'] })])).toThrow(/no top-level field with that name exists/);
575
+ expect(() => validateCollections([searchable({ filters: ['nope'] })])).toThrow(/no top-level field with that name exists/);
514
576
  });
515
577
  });
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './l
12
12
  export { type BylineLogger, getLogger } from './lib/logger.js';
13
13
  export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
14
14
  export * from './patches/index.js';
15
+ export * from './paths/index.js';
15
16
  export { mergePredicates, type ParseContext, type ParsedSort, type ParsedWhere, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
16
17
  export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
17
18
  export * from './services/index.js';
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './l
28
28
  export { getLogger } from './lib/logger.js';
29
29
  export { AsyncRegistry, Registry } from './lib/registry.js';
30
30
  export * from './patches/index.js';
31
+ export * from './paths/index.js';
31
32
  export { mergePredicates, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
32
33
  export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
33
34
  export * from './services/index.js';
@@ -1,6 +1,10 @@
1
1
  // Core implementation of the patch engine
2
2
  import { isBlocksField, isStructureField } from '../@types/field-types.js';
3
- // Very small path grammar:
3
+ import { parseInstancePath } from '../paths/parse-path.js';
4
+ // Instance-path grammar — see `@byline/core` `paths/`. Patch paths address
5
+ // items, so they carry selectors and no block type (the item holds `_type`).
6
+ //
7
+ // Historical shapes, unchanged:
4
8
  // - "foo.bar" -> [{field: 'foo'}, {field: 'bar'}]
5
9
  // - "reviews[0].rating" -> [{field: 'reviews'}, {index: 0}, {field: 'rating'}]
6
10
  // - "reviews[id=abc].rating" -> [{field: 'reviews'}, {id: 'abc'}, {field: 'rating'}]
@@ -8,33 +12,19 @@ import { isBlocksField, isStructureField } from '../@types/field-types.js';
8
12
  export function parsePatchPath(path) {
9
13
  if (!path)
10
14
  return [];
11
- const segments = [];
12
- const parts = String(path).split('.');
13
- for (const part of parts) {
14
- const fieldMatch = part.match(/^([^[]+)/);
15
- if (!fieldMatch) {
16
- continue;
17
- }
18
- const field = fieldMatch[1];
19
- segments.push({ kind: 'field', key: field });
20
- const bracketRegex = /\[([^\]]+)\]/g;
21
- let match;
22
- // Biome: avoid assignment in the while condition
23
- // eslint-disable-next-line no-constant-condition
24
- while (true) {
25
- match = bracketRegex.exec(part);
26
- if (match === null)
27
- break;
28
- const token = match[1];
29
- if (/^\d+$/.test(token)) {
30
- segments.push({ kind: 'index', index: Number.parseInt(token, 10) });
31
- }
32
- else if (token.startsWith('id=')) {
33
- segments.push({ kind: 'id', id: token.slice(3) });
34
- }
35
- }
36
- }
37
- return segments;
15
+ const parsed = parseInstancePath(String(path));
16
+ // An unparseable path yields no segments, so callers reject the patch
17
+ // rather than applying it somewhere else. The previous hand-rolled parser
18
+ // was lenient — `"a["` produced `[field: 'a']`, and the patch was then
19
+ // applied at `a`, a path the client never addressed. Silent misapplication
20
+ // is a worse failure than a rejected patch.
21
+ if (!parsed.ok)
22
+ return [];
23
+ return parsed.segments.map((segment) => segment.kind === 'field'
24
+ ? // The grammar names this `name`; the patch resolver has always called
25
+ // it `key`. Renamed at the boundary rather than churning consumers.
26
+ { kind: 'field', key: segment.name }
27
+ : segment);
38
28
  }
39
29
  // Very small, best-effort resolver that walks the CollectionDefinition to find
40
30
  // the field targeted by a patch path. This is intentionally conservative and
@@ -187,7 +177,8 @@ function ensurePath(root, segments) {
187
177
  function applyFieldPatch(doc, patch) {
188
178
  const segments = parsePatchPath(patch.path);
189
179
  if (segments.length === 0) {
190
- throw new Error('Empty path for field patch');
180
+ // Covers both an empty path and one that did not parse.
181
+ throw new Error(`Empty or unparseable path for field patch: ${JSON.stringify(patch.path ?? '')}`);
191
182
  }
192
183
  if (patch.kind === 'field.set') {
193
184
  const { parent, key } = ensurePath(doc, segments);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { applyPatches } from './apply-patches.js';
2
+ import { applyPatches, parsePatchPath } from './apply-patches.js';
3
3
  const DocsDefinition = {
4
4
  path: 'docs',
5
5
  labels: {
@@ -270,3 +270,51 @@ describe('applyPatches', () => {
270
270
  expect(updated.content[0]?.constrainedWidth).toBe(true);
271
271
  });
272
272
  });
273
+ // ---------------------------------------------------------------------------
274
+ // Patch paths are instance paths
275
+ //
276
+ // `parsePatchPath` delegates to the shared grammar's `parseInstancePath`
277
+ // (`@byline/core` `paths/`). The two agree on every well-formed path; they
278
+ // differ only in that a malformed one is now rejected rather than partially
279
+ // parsed.
280
+ // ---------------------------------------------------------------------------
281
+ describe('parsePatchPath — shared instance-path grammar', () => {
282
+ it('parses positional and id selectors', () => {
283
+ expect(parsePatchPath('content[0].gallery[1].alt')).toEqual([
284
+ { kind: 'field', key: 'content' },
285
+ { kind: 'index', index: 0 },
286
+ { kind: 'field', key: 'gallery' },
287
+ { kind: 'index', index: 1 },
288
+ { kind: 'field', key: 'alt' },
289
+ ]);
290
+ expect(parsePatchPath('content[id=abc].alt')).toEqual([
291
+ { kind: 'field', key: 'content' },
292
+ { kind: 'id', id: 'abc' },
293
+ { kind: 'field', key: 'alt' },
294
+ ]);
295
+ });
296
+ it('carries no block-type segment — the addressed item holds its own `_type`', () => {
297
+ expect(parsePatchPath('content[0].alt')).not.toContainEqual({
298
+ kind: 'field',
299
+ key: 'photoBlock',
300
+ });
301
+ });
302
+ it('rejects a malformed path instead of parsing it partially', () => {
303
+ // Previously `'a['` yielded `[{ kind: 'field', key: 'a' }]`, so a patch
304
+ // was applied at `a` — a path the client never addressed. Returning no
305
+ // segments makes callers reject the patch instead.
306
+ for (const malformed of ['a[', 'a[]', 'a[x]', 'a]b', 'a..b']) {
307
+ expect(parsePatchPath(malformed)).toEqual([]);
308
+ }
309
+ });
310
+ it('surfaces a malformed path as a patch error rather than a silent write', () => {
311
+ const original = { title: 'original' };
312
+ const { doc, errors } = applyPatches(DocsDefinition, original, [
313
+ { kind: 'field.set', path: 'title[', value: 'injected' },
314
+ ]);
315
+ expect(errors).toHaveLength(1);
316
+ expect(errors[0]?.message).toMatch(/unparseable path/i);
317
+ // The truncated path `title` is never written.
318
+ expect(doc.title).toBe('original');
319
+ });
320
+ });
@@ -0,0 +1,16 @@
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
+ /**
9
+ * Field path grammar — one AST, two serialisations.
10
+ *
11
+ * See `path-types.ts` for the instance/declaration model, and
12
+ * `docs/04-collections/01-fields.md` for the author-facing account.
13
+ */
14
+ export { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, toDeclarationSegments, } from './parse-path.js';
15
+ export { resolveDeclarationPath, walkFieldDeclarations } from './resolve-path.js';
16
+ export type { PathParseFailure, PathParseResult, PathResolution, PathSegment, ResolveOptions, } from './path-types.js';
@@ -0,0 +1,15 @@
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
+ /**
9
+ * Field path grammar — one AST, two serialisations.
10
+ *
11
+ * See `path-types.ts` for the instance/declaration model, and
12
+ * `docs/04-collections/01-fields.md` for the author-facing account.
13
+ */
14
+ export { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, toDeclarationSegments, } from './parse-path.js';
15
+ export { resolveDeclarationPath, walkFieldDeclarations } from './resolve-path.js';
@@ -0,0 +1,44 @@
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 { PathParseResult, PathSegment } from './path-types.js';
9
+ /**
10
+ * Parse a declaration path: dotted, index-free, block types written as plain
11
+ * segments (`content.photoBlock.gallery.alt`).
12
+ *
13
+ * Every segment comes back as `kind: 'field'`. Item indices are a parse
14
+ * error rather than something silently dropped — a caller writing
15
+ * `files[0].caption` where a declaration is expected has made a category
16
+ * mistake, and saying so is more useful than quietly addressing every item.
17
+ */
18
+ export declare function parseDeclarationPath(path: string): PathParseResult;
19
+ /**
20
+ * Parse an instance path: dotted field names with bracket item selectors,
21
+ * either positional (`gallery[1]`) or by stable id (`gallery[id=abc]`).
22
+ *
23
+ * Blocks carry no discriminator here — the addressed item resolves its own
24
+ * `_type` — so a block hop is just the blocks field name followed by an
25
+ * item selector.
26
+ */
27
+ export declare function parseInstancePath(path: string): PathParseResult;
28
+ /**
29
+ * Serialise segments as a declaration path. Index and id segments are
30
+ * dropped, so this doubles as the instance → declaration projection when
31
+ * applied to instance segments.
32
+ */
33
+ export declare function formatDeclarationPath(segments: readonly PathSegment[]): string;
34
+ /** Serialise segments as an instance path, rendering selectors in brackets. */
35
+ export declare function formatInstancePath(segments: readonly PathSegment[]): string;
36
+ /**
37
+ * Drop the item selectors from a path, leaving the declaration it addresses.
38
+ *
39
+ * This is the projection several call sites hand-roll with a regex today.
40
+ * Doing it over segments rather than raw text is what makes it safe: a field
41
+ * legitimately named `0` is a `field` segment and survives, where a
42
+ * string-level `/^\d+$/` filter would silently delete it.
43
+ */
44
+ export declare function toDeclarationSegments(segments: readonly PathSegment[]): readonly PathSegment[];
@@ -0,0 +1,133 @@
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
+ // ---------------------------------------------------------------------------
9
+ // Parsing and serialising field paths.
10
+ //
11
+ // Parsing is schema-unaware: it recovers structure from text and nothing
12
+ // more. In particular it cannot tell a block type from a field name, since
13
+ // both are bare identifiers — `resolveDeclarationPath` settles that against
14
+ // a field set. Callers that already hold the schema (path *producers*) should
15
+ // build segments directly and use the formatters here.
16
+ // ---------------------------------------------------------------------------
17
+ const INDEX_TOKEN = /^\[(\d+)\]$/;
18
+ const ID_TOKEN = /^\[id=(.+)\]$/;
19
+ /**
20
+ * Parse a declaration path: dotted, index-free, block types written as plain
21
+ * segments (`content.photoBlock.gallery.alt`).
22
+ *
23
+ * Every segment comes back as `kind: 'field'`. Item indices are a parse
24
+ * error rather than something silently dropped — a caller writing
25
+ * `files[0].caption` where a declaration is expected has made a category
26
+ * mistake, and saying so is more useful than quietly addressing every item.
27
+ */
28
+ export function parseDeclarationPath(path) {
29
+ if (path.trim() === '')
30
+ return { ok: false, reason: 'empty' };
31
+ if (path.includes('[') || path.includes(']'))
32
+ return { ok: false, reason: 'index' };
33
+ const segments = [];
34
+ for (const part of path.split('.')) {
35
+ if (part === '')
36
+ return { ok: false, reason: 'emptySegment' };
37
+ segments.push({ kind: 'field', name: part });
38
+ }
39
+ return { ok: true, segments };
40
+ }
41
+ /**
42
+ * Parse an instance path: dotted field names with bracket item selectors,
43
+ * either positional (`gallery[1]`) or by stable id (`gallery[id=abc]`).
44
+ *
45
+ * Blocks carry no discriminator here — the addressed item resolves its own
46
+ * `_type` — so a block hop is just the blocks field name followed by an
47
+ * item selector.
48
+ */
49
+ export function parseInstancePath(path) {
50
+ if (path.trim() === '')
51
+ return { ok: false, reason: 'empty' };
52
+ const segments = [];
53
+ for (const part of path.split('.')) {
54
+ if (part === '')
55
+ return { ok: false, reason: 'emptySegment' };
56
+ const bracket = part.indexOf('[');
57
+ if (bracket === -1) {
58
+ if (part.includes(']'))
59
+ return { ok: false, reason: 'malformed' };
60
+ segments.push({ kind: 'field', name: part });
61
+ continue;
62
+ }
63
+ const name = part.slice(0, bracket);
64
+ if (name === '')
65
+ return { ok: false, reason: 'emptySegment' };
66
+ segments.push({ kind: 'field', name });
67
+ // One segment may carry several selectors (`items[0][1]` is not produced
68
+ // today, but parsing it costs nothing and beats silently truncating).
69
+ let rest = part.slice(bracket);
70
+ while (rest !== '') {
71
+ const close = rest.indexOf(']');
72
+ if (close === -1)
73
+ return { ok: false, reason: 'malformed' };
74
+ const token = rest.slice(0, close + 1);
75
+ rest = rest.slice(close + 1);
76
+ const indexMatch = INDEX_TOKEN.exec(token);
77
+ if (indexMatch?.[1] != null) {
78
+ segments.push({ kind: 'index', index: Number.parseInt(indexMatch[1], 10) });
79
+ continue;
80
+ }
81
+ const idMatch = ID_TOKEN.exec(token);
82
+ if (idMatch?.[1] != null) {
83
+ segments.push({ kind: 'id', id: idMatch[1] });
84
+ continue;
85
+ }
86
+ return { ok: false, reason: 'malformed' };
87
+ }
88
+ }
89
+ return { ok: true, segments };
90
+ }
91
+ /**
92
+ * Serialise segments as a declaration path. Index and id segments are
93
+ * dropped, so this doubles as the instance → declaration projection when
94
+ * applied to instance segments.
95
+ */
96
+ export function formatDeclarationPath(segments) {
97
+ return segments
98
+ .filter((segment) => segment.kind === 'field' || segment.kind === 'blockType')
99
+ .map((segment) => (segment.kind === 'blockType' ? segment.blockType : segment.name))
100
+ .join('.');
101
+ }
102
+ /** Serialise segments as an instance path, rendering selectors in brackets. */
103
+ export function formatInstancePath(segments) {
104
+ let out = '';
105
+ for (const segment of segments) {
106
+ switch (segment.kind) {
107
+ case 'field':
108
+ out += out === '' ? segment.name : `.${segment.name}`;
109
+ break;
110
+ case 'blockType':
111
+ // Instance paths carry no discriminator; the item knows its own type.
112
+ break;
113
+ case 'index':
114
+ out += `[${segment.index}]`;
115
+ break;
116
+ case 'id':
117
+ out += `[id=${segment.id}]`;
118
+ break;
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ /**
124
+ * Drop the item selectors from a path, leaving the declaration it addresses.
125
+ *
126
+ * This is the projection several call sites hand-roll with a regex today.
127
+ * Doing it over segments rather than raw text is what makes it safe: a field
128
+ * legitimately named `0` is a `field` segment and survives, where a
129
+ * string-level `/^\d+$/` filter would silently delete it.
130
+ */
131
+ export function toDeclarationSegments(segments) {
132
+ return segments.filter((segment) => segment.kind === 'field' || segment.kind === 'blockType');
133
+ }
@@ -0,0 +1,8 @@
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
+ export {};