@byline/core 4.3.0 → 4.4.1
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 +9 -3
- package/dist/@types/field-types.d.ts +3 -2
- package/dist/config/attach-hooks.js +38 -28
- package/dist/config/attach-hooks.test.node.js +18 -4
- package/dist/config/validate-admin-configs.js +17 -51
- package/dist/config/validate-collections.js +63 -25
- package/dist/config/validate-collections.test.node.js +63 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/patches/apply-patches.js +20 -29
- package/dist/patches/patch.test.node.js +75 -1
- package/dist/paths/index.d.ts +16 -0
- package/dist/paths/index.js +15 -0
- package/dist/paths/parse-path.d.ts +44 -0
- package/dist/paths/parse-path.js +138 -0
- package/dist/paths/path-dialects.test.node.d.ts +8 -0
- package/dist/paths/path-dialects.test.node.js +267 -0
- package/dist/paths/path-types.d.ts +87 -0
- package/dist/paths/path-types.js +8 -0
- package/dist/paths/paths.test.node.d.ts +8 -0
- package/dist/paths/paths.test.node.js +339 -0
- package/dist/paths/resolve-path.d.ts +47 -0
- package/dist/paths/resolve-path.js +120 -0
- package/dist/services/assign-counter-values.js +9 -3
- package/package.json +2 -2
|
@@ -1152,9 +1152,15 @@ export interface CollectionDefinition {
|
|
|
1152
1152
|
*
|
|
1153
1153
|
* Receives the same minimal document shape as
|
|
1154
1154
|
* `CollectionAdminConfig.preview.url` (`PreviewDocument`-style envelope:
|
|
1155
|
-
* top-level columns plus `fields`) so the two hooks can share an
|
|
1156
|
-
* implementation
|
|
1157
|
-
* `
|
|
1155
|
+
* top-level columns plus `fields`), so the two hooks can share an
|
|
1156
|
+
* implementation. `preview.url` already defaults to this hook when
|
|
1157
|
+
* omitted — see `resolvePreviewUrl` in `@byline/host-tanstack-start` —
|
|
1158
|
+
* so the admin Preview button and the public path agree by construction.
|
|
1159
|
+
*
|
|
1160
|
+
* Note the `null` return is read differently by the two surfaces:
|
|
1161
|
+
* `resolvePreviewUrl` hides the Preview affordance, while the richtext
|
|
1162
|
+
* walker treats it as "no path could be built" and falls through to the
|
|
1163
|
+
* generic `/${collectionPath}/${path}` compose.
|
|
1158
1164
|
*/
|
|
1159
1165
|
buildDocumentPath?: (doc: {
|
|
1160
1166
|
id: string;
|
|
@@ -58,7 +58,8 @@ export interface FieldHookContext {
|
|
|
58
58
|
*/
|
|
59
59
|
data: Record<string, any>;
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
61
|
+
* Instance path of the field inside the document, e.g.
|
|
62
|
+
* `"content[id=block-id].richText"`.
|
|
62
63
|
*/
|
|
63
64
|
path: string;
|
|
64
65
|
/**
|
|
@@ -85,7 +86,7 @@ export interface FieldHookContext {
|
|
|
85
86
|
* items (a checkbox that unchecks its siblings) or clearing a dependent
|
|
86
87
|
* field when its driver changes. Paths use the same dot + bracket
|
|
87
88
|
* notation as `FieldHookContext.path`, e.g.
|
|
88
|
-
* `files[
|
|
89
|
+
* `files[id=file-id].filesGroup.generateThumbnail`.
|
|
89
90
|
*/
|
|
90
91
|
setFieldValue: (path: string, value: any) => void;
|
|
91
92
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
|
|
1
2
|
const registryCollectionHooks = new WeakMap();
|
|
2
3
|
const registryUploadHooks = new WeakMap();
|
|
3
4
|
/**
|
|
@@ -9,7 +10,7 @@ export function prepareHookAttachment(config) {
|
|
|
9
10
|
const collections = new Map();
|
|
10
11
|
const uploadFields = new Map();
|
|
11
12
|
for (const collection of config.collections) {
|
|
12
|
-
|
|
13
|
+
assertPathSafeSegment(collection.path, `collection path "${collection.path}"`);
|
|
13
14
|
collections.set(collection.path, collection);
|
|
14
15
|
indexUploadFields(collection, uploadFields);
|
|
15
16
|
}
|
|
@@ -97,34 +98,43 @@ function assertOwnershipIntact(label, current, owned) {
|
|
|
97
98
|
}
|
|
98
99
|
function indexUploadFields(collection, uploadFields) {
|
|
99
100
|
const leafPaths = new Map();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
|
|
109
|
-
}
|
|
110
|
-
leafPaths.set(field.name, canonical);
|
|
111
|
-
uploadFields.set(canonical, field);
|
|
112
|
-
}
|
|
113
|
-
if (field.type === 'group' || field.type === 'array') {
|
|
114
|
-
walk(field.fields, path);
|
|
115
|
-
}
|
|
116
|
-
else if (field.type === 'blocks') {
|
|
117
|
-
for (const block of field.blocks) {
|
|
118
|
-
assertDotFreeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
|
|
119
|
-
walk(block.fields, [...path, block.blockType]);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
101
|
+
walkFieldDeclarations(collection.fields, (field, segments) => {
|
|
102
|
+
assertPathSafeSegment(field.name, `field name "${field.name}" in collection "${collection.path}"`);
|
|
103
|
+
if ((field.type !== 'file' && field.type !== 'image') || field.upload === undefined)
|
|
104
|
+
return;
|
|
105
|
+
const canonical = `${collection.path}.${formatDeclarationPath(segments)}`;
|
|
106
|
+
const previous = leafPaths.get(field.name);
|
|
107
|
+
if (previous !== undefined) {
|
|
108
|
+
throw new Error(`Collection "${collection.path}" has duplicate upload-capable leaf name "${field.name}" at "${previous}" and "${canonical}". Upload leaf names must be unique within a collection.`);
|
|
122
109
|
}
|
|
123
|
-
|
|
124
|
-
|
|
110
|
+
leafPaths.set(field.name, canonical);
|
|
111
|
+
uploadFields.set(canonical, field);
|
|
112
|
+
}, {
|
|
113
|
+
// Every block is checked, including one declaring no fields — such a
|
|
114
|
+
// block never reaches the field visitor, so inferring block types from
|
|
115
|
+
// field segments alone would silently stop validating them.
|
|
116
|
+
onBlock: (block) => {
|
|
117
|
+
assertPathSafeSegment(block.blockType, `block type "${block.blockType}" in collection "${collection.path}"`);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
125
120
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
/**
|
|
122
|
+
* A name that appears as a path segment must not contain the grammar's own
|
|
123
|
+
* punctuation.
|
|
124
|
+
*
|
|
125
|
+
* `.` separates segments in every notation. `[` and `]` delimit item selectors
|
|
126
|
+
* in instance paths, which field names already reach — a field named `a[0]`
|
|
127
|
+
* would not survive the round trip through `parseInstancePath`. Both are
|
|
128
|
+
* rejected where the name is declared rather than left to corrupt a path
|
|
129
|
+
* later.
|
|
130
|
+
*/
|
|
131
|
+
function assertPathSafeSegment(segment, label) {
|
|
132
|
+
if (segment.length === 0) {
|
|
133
|
+
throw new Error(`${label} must be a non-empty path segment.`);
|
|
134
|
+
}
|
|
135
|
+
for (const character of ['.', '[', ']']) {
|
|
136
|
+
if (segment.includes(character)) {
|
|
137
|
+
throw new Error(`${label} must not contain "${character}" — it is field path grammar punctuation.`);
|
|
138
|
+
}
|
|
129
139
|
}
|
|
130
140
|
}
|
|
@@ -149,11 +149,11 @@ describe('server hook attachment', () => {
|
|
|
149
149
|
await expect(initBylineCore(invalid, {})).rejects.toThrow(/translations bundle/i);
|
|
150
150
|
expect(definition.hooks).toBe(stable);
|
|
151
151
|
});
|
|
152
|
-
it('rejects
|
|
153
|
-
expect(() => prepareHookAttachment({ collections: [collection('docs.archive')] })).toThrow(/
|
|
152
|
+
it('rejects path-punctuation in registry segments and duplicate upload leaf names at boot', () => {
|
|
153
|
+
expect(() => prepareHookAttachment({ collections: [collection('docs.archive')] })).toThrow(/grammar punctuation/);
|
|
154
154
|
const dottedField = collection();
|
|
155
155
|
dottedField.fields = [{ name: 'meta.asset', type: 'file', upload: {} }];
|
|
156
|
-
expect(() => prepareHookAttachment({ collections: [dottedField] })).toThrow(/
|
|
156
|
+
expect(() => prepareHookAttachment({ collections: [dottedField] })).toThrow(/grammar punctuation/);
|
|
157
157
|
const dottedBlock = collection();
|
|
158
158
|
dottedBlock.fields = [
|
|
159
159
|
{
|
|
@@ -162,7 +162,21 @@ describe('server hook attachment', () => {
|
|
|
162
162
|
blocks: [{ blockType: 'hero.large', fields: [] }],
|
|
163
163
|
},
|
|
164
164
|
];
|
|
165
|
-
expect(() => prepareHookAttachment({ collections: [dottedBlock] })).toThrow(/
|
|
165
|
+
expect(() => prepareHookAttachment({ collections: [dottedBlock] })).toThrow(/grammar punctuation/);
|
|
166
|
+
// Brackets delimit item selectors in instance paths, which field names
|
|
167
|
+
// reach — so they are rejected for the same reason dots are.
|
|
168
|
+
const bracketField = collection();
|
|
169
|
+
bracketField.fields = [{ name: 'asset[0]', type: 'file', upload: {} }];
|
|
170
|
+
expect(() => prepareHookAttachment({ collections: [bracketField] })).toThrow(/grammar punctuation/);
|
|
171
|
+
const bracketBlock = collection();
|
|
172
|
+
bracketBlock.fields = [
|
|
173
|
+
{
|
|
174
|
+
name: 'content',
|
|
175
|
+
type: 'blocks',
|
|
176
|
+
blocks: [{ blockType: 'hero[large]', fields: [] }],
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
expect(() => prepareHookAttachment({ collections: [bracketBlock] })).toThrow(/grammar punctuation/);
|
|
166
180
|
const duplicate = collection();
|
|
167
181
|
duplicate.fields = [
|
|
168
182
|
{ name: 'primary', type: 'group', fields: [{ name: 'asset', type: 'file', upload: {} }] },
|
|
@@ -5,40 +5,18 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { resolveDeclarationPath, walkFieldDeclarations, } from '../paths/index.js';
|
|
8
9
|
/**
|
|
9
|
-
* Resolve a
|
|
10
|
-
* against a field set. Schema paths address field *declarations*: every
|
|
11
|
-
* segment names a field, intermediate segments must be `group` / `array`
|
|
12
|
-
* structure fields, and no segment carries an item index. Returns:
|
|
10
|
+
* Resolve a `fields{}` override key against a field set.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* appears mid-path.
|
|
12
|
+
* Admin override keys are declaration paths with one deliberate narrowing:
|
|
13
|
+
* they may not traverse a `blocks` field at all, even correctly qualified by
|
|
14
|
+
* block type. Fields inside a block take their overrides from the
|
|
15
|
+
* blockType-keyed `blockAdmin` registry, so that one registration applies
|
|
16
|
+
* wherever the block renders.
|
|
20
17
|
*/
|
|
21
18
|
function resolveSchemaPath(fields, path) {
|
|
22
|
-
|
|
23
|
-
let current = fields;
|
|
24
|
-
for (let i = 0; i < segments.length; i++) {
|
|
25
|
-
const name = segments[i];
|
|
26
|
-
const field = current.find((f) => 'name' in f && f.name === name);
|
|
27
|
-
if (field == null)
|
|
28
|
-
return 'unresolved';
|
|
29
|
-
if (i === segments.length - 1)
|
|
30
|
-
return 'ok';
|
|
31
|
-
if (field.type === 'group' || field.type === 'array') {
|
|
32
|
-
current = field.fields;
|
|
33
|
-
}
|
|
34
|
-
else if (field.type === 'blocks') {
|
|
35
|
-
return 'blocks';
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
return 'unresolved';
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return 'unresolved';
|
|
19
|
+
return resolveDeclarationPath(fields, path, { blocks: 'forbidden' }).status;
|
|
42
20
|
}
|
|
43
21
|
/**
|
|
44
22
|
* Shared validation for a `fields{}` override map (collection- or block-level):
|
|
@@ -125,28 +103,16 @@ export function validateBlockAdminConfigs(blockAdmins, collections) {
|
|
|
125
103
|
// between same-blockType declarations is possible, so keys validate against
|
|
126
104
|
// the union of sites.
|
|
127
105
|
const blocksByType = new Map();
|
|
128
|
-
const walkBlock = (block) => {
|
|
129
|
-
let sites = blocksByType.get(block.blockType);
|
|
130
|
-
if (sites == null) {
|
|
131
|
-
sites = [];
|
|
132
|
-
blocksByType.set(block.blockType, sites);
|
|
133
|
-
}
|
|
134
|
-
sites.push(block);
|
|
135
|
-
walkFields(block.fields);
|
|
136
|
-
};
|
|
137
|
-
const walkFields = (fields) => {
|
|
138
|
-
for (const field of fields) {
|
|
139
|
-
if (field.type === 'blocks') {
|
|
140
|
-
for (const block of field.blocks)
|
|
141
|
-
walkBlock(block);
|
|
142
|
-
}
|
|
143
|
-
else if ('fields' in field && Array.isArray(field.fields)) {
|
|
144
|
-
walkFields(field.fields);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
106
|
for (const collection of collections) {
|
|
149
|
-
|
|
107
|
+
walkFieldDeclarations(collection.fields, () => { }, {
|
|
108
|
+
onBlock: (block) => {
|
|
109
|
+
const sites = blocksByType.get(block.blockType);
|
|
110
|
+
if (sites == null)
|
|
111
|
+
blocksByType.set(block.blockType, [block]);
|
|
112
|
+
else
|
|
113
|
+
sites.push(block);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
150
116
|
}
|
|
151
117
|
const seen = new Set();
|
|
152
118
|
for (const entry of blockAdmins) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
|
|
8
9
|
import { fieldTypeToStore } from '../storage/field-store-map.js';
|
|
9
10
|
/**
|
|
10
11
|
* Field names that cannot be declared in a collection schema because they
|
|
@@ -61,37 +62,27 @@ function hasLocalizedField(fields) {
|
|
|
61
62
|
});
|
|
62
63
|
return found;
|
|
63
64
|
}
|
|
65
|
+
/** Every field declaration, for checks that don't care where it sits. */
|
|
64
66
|
function walkFields(fields, visit) {
|
|
65
|
-
|
|
67
|
+
walkFieldDeclarations(fields, (field) => {
|
|
66
68
|
visit(field);
|
|
67
|
-
|
|
68
|
-
walkFields(field.fields, visit);
|
|
69
|
-
}
|
|
70
|
-
else if (field.type === 'blocks') {
|
|
71
|
-
for (const block of field.blocks) {
|
|
72
|
-
walkFields(block.fields, visit);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
69
|
+
});
|
|
76
70
|
}
|
|
77
71
|
/**
|
|
78
|
-
* Path-aware variant of `walkFields` — visits every field with its
|
|
79
|
-
*
|
|
72
|
+
* Path-aware variant of `walkFields` — visits every field with its
|
|
73
|
+
* declaration path (e.g. `files.filesGroup.generateThumbnail`) so validation
|
|
80
74
|
* errors can point at the exact declaration site.
|
|
75
|
+
*
|
|
76
|
+
* Delegates to the shared grammar's canonical walk. It previously descended
|
|
77
|
+
* into blocks without recording the block type, which made the emitted path
|
|
78
|
+
* ambiguous whenever two blocks in one field declared the same field name —
|
|
79
|
+
* both rendered as `content.alt`, and the message could not say which was at
|
|
80
|
+
* fault. `walkFieldDeclarations` carries the block type through.
|
|
81
81
|
*/
|
|
82
|
-
function walkFieldsWithPath(fields, visit
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (field.type === 'group' || field.type === 'array') {
|
|
87
|
-
walkFieldsWithPath(field.fields, visit, fieldPath);
|
|
88
|
-
}
|
|
89
|
-
else if (field.type === 'blocks') {
|
|
90
|
-
for (const block of field.blocks) {
|
|
91
|
-
walkFieldsWithPath(block.fields, visit, fieldPath);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
82
|
+
function walkFieldsWithPath(fields, visit) {
|
|
83
|
+
walkFieldDeclarations(fields, (field, segments) => {
|
|
84
|
+
visit(field, formatDeclarationPath(segments));
|
|
85
|
+
});
|
|
95
86
|
}
|
|
96
87
|
/**
|
|
97
88
|
* Top-level field names a collection's `search` config references (body +
|
|
@@ -114,6 +105,52 @@ function searchReferencedFieldNames(collection) {
|
|
|
114
105
|
}
|
|
115
106
|
return names;
|
|
116
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Enforce that every field named by a collection's `search` config resolves.
|
|
110
|
+
*
|
|
111
|
+
* `buildSearchDocument` looks each name up among the collection's **top-level**
|
|
112
|
+
* fields and silently skips anything it cannot resolve, so an unrecognised or
|
|
113
|
+
* dotted name produced a collection that indexed less than its author thought
|
|
114
|
+
* — with no error at boot and no signal at index time.
|
|
115
|
+
*
|
|
116
|
+
* Nested content is reachable, but by naming its top-level *container*: a
|
|
117
|
+
* `group` / `array` / `blocks` field named in `search.body` is walked
|
|
118
|
+
* recursively and every nested text leaf is flattened into the body. What is
|
|
119
|
+
* not supported is addressing one specific nested declaration, which is why a
|
|
120
|
+
* dotted path is rejected outright rather than accepted and ignored.
|
|
121
|
+
*/
|
|
122
|
+
function validateSearchFields(collection) {
|
|
123
|
+
const search = collection.search;
|
|
124
|
+
if (search == null)
|
|
125
|
+
return;
|
|
126
|
+
const topLevel = (name) => collection.fields.find((f) => 'name' in f && f.name === name);
|
|
127
|
+
const check = (name, option) => {
|
|
128
|
+
if (name.includes('.')) {
|
|
129
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.${option}\` but search config addresses top-level fields only. To index content nested inside a \`group\` / \`array\` / \`blocks\` field, name the top-level container — it is walked recursively and every nested text leaf is flattened into the body.`);
|
|
130
|
+
}
|
|
131
|
+
const field = topLevel(name);
|
|
132
|
+
if (field == null) {
|
|
133
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.${option}\` but no top-level field with that name exists.`);
|
|
134
|
+
}
|
|
135
|
+
return field;
|
|
136
|
+
};
|
|
137
|
+
for (const decl of search.body ?? []) {
|
|
138
|
+
check(typeof decl === 'string' ? decl : decl.field, 'body');
|
|
139
|
+
}
|
|
140
|
+
for (const decl of search.facets ?? []) {
|
|
141
|
+
const name = typeof decl === 'string' ? decl : decl.field;
|
|
142
|
+
const field = check(name, 'facets');
|
|
143
|
+
if (field.type !== 'relation') {
|
|
144
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.facets\` but field "${name}" has type "${field.type}". Facets are controlled-vocabulary references — name a \`relation\` field.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const name of search.filters ?? []) {
|
|
148
|
+
const field = check(name, 'filters');
|
|
149
|
+
if (field.type === 'group' || field.type === 'array' || field.type === 'blocks') {
|
|
150
|
+
throw new Error(`Collection "${collection.path}" names '${name}' in \`search.filters\` but field "${name}" has type "${field.type}". Filters project a single scalar value — name a value field.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
117
154
|
/**
|
|
118
155
|
* Enforce the `virtual` field constraints for one collection. See
|
|
119
156
|
* `BaseField.virtual` in field-types.ts for the contract these rules back:
|
|
@@ -236,6 +273,7 @@ export function validateCollections(collections) {
|
|
|
236
273
|
if (collection.tree === true && collection.orderable === true) {
|
|
237
274
|
throw new Error(`Collection "${collection.path}" sets both \`tree: true\` and \`orderable: true\`. A document-tree collection owns ordering on the tree edge (per-parent), so \`byline_documents.order_key\` is inert — set only \`tree: true\`.`);
|
|
238
275
|
}
|
|
276
|
+
validateSearchFields(collection);
|
|
239
277
|
validateVirtualFields(collection);
|
|
240
278
|
validateUploadLocations(collection);
|
|
241
279
|
}
|
|
@@ -510,6 +510,68 @@ describe('validateCollections', () => {
|
|
|
510
510
|
},
|
|
511
511
|
],
|
|
512
512
|
};
|
|
513
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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: {
|
|
@@ -156,6 +156,32 @@ describe('applyPatches', () => {
|
|
|
156
156
|
const moved = afterMove;
|
|
157
157
|
expect(moved.reviews.map((r) => r._id)).toEqual(['c', 'a', 'b']);
|
|
158
158
|
});
|
|
159
|
+
it('applies field patches against the array order at that point in the patch stream', () => {
|
|
160
|
+
const original = {
|
|
161
|
+
links: [
|
|
162
|
+
{ _id: 'a', link: 'A' },
|
|
163
|
+
{ _id: 'b', link: 'B' },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const positional = applyPatches(DocsDefinition, original, [
|
|
167
|
+
{ kind: 'array.move', path: 'links', itemId: 'b', toIndex: 0 },
|
|
168
|
+
{ kind: 'field.set', path: 'links[0].link', value: 'moved B' },
|
|
169
|
+
]);
|
|
170
|
+
expect(positional.errors).toHaveLength(0);
|
|
171
|
+
expect(positional.doc.links).toEqual([
|
|
172
|
+
{ _id: 'b', link: 'moved B' },
|
|
173
|
+
{ _id: 'a', link: 'A' },
|
|
174
|
+
]);
|
|
175
|
+
const stable = applyPatches(DocsDefinition, original, [
|
|
176
|
+
{ kind: 'array.move', path: 'links', itemId: 'b', toIndex: 0 },
|
|
177
|
+
{ kind: 'field.set', path: 'links[id=b].link', value: 'same B' },
|
|
178
|
+
]);
|
|
179
|
+
expect(stable.errors).toHaveLength(0);
|
|
180
|
+
expect(stable.doc.links).toEqual([
|
|
181
|
+
{ _id: 'b', link: 'same B' },
|
|
182
|
+
{ _id: 'a', link: 'A' },
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
159
185
|
it('array.remove removes an item by stable _id', () => {
|
|
160
186
|
const original = {
|
|
161
187
|
reviews: [
|
|
@@ -270,3 +296,51 @@ describe('applyPatches', () => {
|
|
|
270
296
|
expect(updated.content[0]?.constrainedWidth).toBe(true);
|
|
271
297
|
});
|
|
272
298
|
});
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Patch paths are instance paths
|
|
301
|
+
//
|
|
302
|
+
// `parsePatchPath` delegates to the shared grammar's `parseInstancePath`
|
|
303
|
+
// (`@byline/core` `paths/`). The two agree on every well-formed path; they
|
|
304
|
+
// differ only in that a malformed one is now rejected rather than partially
|
|
305
|
+
// parsed.
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
describe('parsePatchPath — shared instance-path grammar', () => {
|
|
308
|
+
it('parses positional and id selectors', () => {
|
|
309
|
+
expect(parsePatchPath('content[0].gallery[1].alt')).toEqual([
|
|
310
|
+
{ kind: 'field', key: 'content' },
|
|
311
|
+
{ kind: 'index', index: 0 },
|
|
312
|
+
{ kind: 'field', key: 'gallery' },
|
|
313
|
+
{ kind: 'index', index: 1 },
|
|
314
|
+
{ kind: 'field', key: 'alt' },
|
|
315
|
+
]);
|
|
316
|
+
expect(parsePatchPath('content[id=abc].alt')).toEqual([
|
|
317
|
+
{ kind: 'field', key: 'content' },
|
|
318
|
+
{ kind: 'id', id: 'abc' },
|
|
319
|
+
{ kind: 'field', key: 'alt' },
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
it('carries no block-type segment — the addressed item holds its own `_type`', () => {
|
|
323
|
+
expect(parsePatchPath('content[0].alt')).not.toContainEqual({
|
|
324
|
+
kind: 'field',
|
|
325
|
+
key: 'photoBlock',
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
it('rejects a malformed path instead of parsing it partially', () => {
|
|
329
|
+
// Previously `'a['` yielded `[{ kind: 'field', key: 'a' }]`, so a patch
|
|
330
|
+
// was applied at `a` — a path the client never addressed. Returning no
|
|
331
|
+
// segments makes callers reject the patch instead.
|
|
332
|
+
for (const malformed of ['a[', 'a[]', 'a[x]', 'a]b', 'a..b']) {
|
|
333
|
+
expect(parsePatchPath(malformed)).toEqual([]);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
it('surfaces a malformed path as a patch error rather than a silent write', () => {
|
|
337
|
+
const original = { title: 'original' };
|
|
338
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
339
|
+
{ kind: 'field.set', path: 'title[', value: 'injected' },
|
|
340
|
+
]);
|
|
341
|
+
expect(errors).toHaveLength(1);
|
|
342
|
+
expect(errors[0]?.message).toMatch(/unparseable path/i);
|
|
343
|
+
// The truncated path `title` is never written.
|
|
344
|
+
expect(doc.title).toBe('original');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -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';
|