@byline/core 3.15.1 → 3.16.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 +7 -7
- package/dist/@types/db-types.d.ts +46 -24
- package/dist/@types/query-predicate.d.ts +25 -3
- package/dist/@types/search-types.d.ts +8 -1
- package/dist/@types/site-config.d.ts +2 -2
- package/dist/auth/assert-actor-can-perform.d.ts +1 -1
- package/dist/auth/assert-actor-can-perform.js +1 -1
- package/dist/auth/register-collection-abilities.d.ts +1 -1
- package/dist/auth/register-collection-abilities.js +1 -1
- package/dist/config/config.d.ts +1 -1
- package/dist/config/config.js +1 -1
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/lib/errors.d.ts +1 -1
- package/dist/lib/errors.js +1 -1
- package/dist/query/parse-where.js +71 -20
- package/dist/query/parse-where.test.node.js +93 -0
- package/dist/schemas/zod/builder.js +2 -2
- package/dist/services/build-search-document.d.ts +39 -1
- package/dist/services/build-search-document.js +102 -16
- package/dist/services/build-search-document.test.node.js +111 -0
- package/dist/services/document-lifecycle/audit.d.ts +1 -1
- package/dist/services/document-lifecycle/audit.js +3 -3
- package/dist/services/document-lifecycle/context.d.ts +1 -1
- package/dist/services/document-lifecycle/create.d.ts +1 -1
- package/dist/services/document-lifecycle/create.js +1 -1
- package/dist/services/document-lifecycle/delete.js +3 -3
- package/dist/services/document-lifecycle/internals.d.ts +2 -2
- package/dist/services/document-lifecycle/internals.js +3 -3
- package/dist/services/document-lifecycle/status.js +1 -1
- package/dist/services/document-lifecycle/system-fields.d.ts +2 -2
- package/dist/services/document-lifecycle/system-fields.js +3 -3
- package/dist/services/document-lifecycle/tree.js +1 -1
- package/dist/services/document-lifecycle/update.d.ts +2 -2
- package/dist/services/document-lifecycle.test.node.js +3 -3
- package/dist/services/document-to-markdown.d.ts +1 -1
- package/dist/services/document-to-markdown.js +1 -1
- package/dist/services/document-to-markdown.test.node.js +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/populate.d.ts +1 -1
- package/package.json +3 -5
|
@@ -29,6 +29,14 @@ const testCollection = defineCollection({
|
|
|
29
29
|
targetCollection: 'test-categories',
|
|
30
30
|
optional: true,
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
name: 'tags',
|
|
34
|
+
type: 'relation',
|
|
35
|
+
label: 'Tags',
|
|
36
|
+
targetCollection: 'test-categories',
|
|
37
|
+
hasMany: true,
|
|
38
|
+
optional: true,
|
|
39
|
+
},
|
|
32
40
|
],
|
|
33
41
|
});
|
|
34
42
|
const categoriesCollection = defineCollection({
|
|
@@ -715,3 +723,88 @@ describe('mergePredicates', () => {
|
|
|
715
723
|
expect(parsed.filters[1]).toMatchObject({ kind: 'field', fieldName: 'title' });
|
|
716
724
|
});
|
|
717
725
|
});
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// parseWhere — relation quantifiers ($some / $every / $none)
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
describe('parseWhere — relation quantifiers', () => {
|
|
730
|
+
it('emits a $some RelationFilter with hasMany flag for a hasMany field', async () => {
|
|
731
|
+
const result = await parseWhere({ tags: { $some: { slug: 'news' } } }, testCollection, ctx);
|
|
732
|
+
expect(result.filters).toHaveLength(1);
|
|
733
|
+
expect(result.filters[0]).toEqual({
|
|
734
|
+
kind: 'relation',
|
|
735
|
+
fieldName: 'tags',
|
|
736
|
+
targetCollectionId: 'id-test-categories',
|
|
737
|
+
hasMany: true,
|
|
738
|
+
nested: [
|
|
739
|
+
{
|
|
740
|
+
kind: 'field',
|
|
741
|
+
fieldName: 'slug',
|
|
742
|
+
storeType: 'text',
|
|
743
|
+
valueColumn: 'value',
|
|
744
|
+
operator: '$eq',
|
|
745
|
+
value: 'news',
|
|
746
|
+
},
|
|
747
|
+
],
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
it('treats a plain sub-where on a hasMany field as implicit $some', async () => {
|
|
751
|
+
const result = await parseWhere({ tags: { slug: 'news' } }, testCollection, ctx);
|
|
752
|
+
expect(result.filters).toHaveLength(1);
|
|
753
|
+
expect(result.filters[0]).toMatchObject({
|
|
754
|
+
kind: 'relation',
|
|
755
|
+
fieldName: 'tags',
|
|
756
|
+
hasMany: true,
|
|
757
|
+
});
|
|
758
|
+
// Default quantifier is omitted from the wire shape.
|
|
759
|
+
expect(result.filters[0]).not.toHaveProperty('quantifier');
|
|
760
|
+
});
|
|
761
|
+
it('emits an $every RelationFilter', async () => {
|
|
762
|
+
const result = await parseWhere({ tags: { $every: { status: 'published' } } }, testCollection, ctx);
|
|
763
|
+
expect(result.filters).toHaveLength(1);
|
|
764
|
+
expect(result.filters[0]).toEqual({
|
|
765
|
+
kind: 'relation',
|
|
766
|
+
fieldName: 'tags',
|
|
767
|
+
targetCollectionId: 'id-test-categories',
|
|
768
|
+
hasMany: true,
|
|
769
|
+
quantifier: 'every',
|
|
770
|
+
nested: [{ kind: 'docColumn', column: 'status', operator: '$eq', value: 'published' }],
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
it('emits a $none RelationFilter with empty nested for `$none: {}`', async () => {
|
|
774
|
+
const result = await parseWhere({ tags: { $none: {} } }, testCollection, ctx);
|
|
775
|
+
expect(result.filters).toHaveLength(1);
|
|
776
|
+
expect(result.filters[0]).toEqual({
|
|
777
|
+
kind: 'relation',
|
|
778
|
+
fieldName: 'tags',
|
|
779
|
+
targetCollectionId: 'id-test-categories',
|
|
780
|
+
hasMany: true,
|
|
781
|
+
quantifier: 'none',
|
|
782
|
+
nested: [],
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
it('ANDs multiple quantifier keys on one field into separate filters', async () => {
|
|
786
|
+
const result = await parseWhere({ tags: { $some: { slug: 'a' }, $none: { slug: 'b' } } }, testCollection, ctx);
|
|
787
|
+
expect(result.filters).toHaveLength(2);
|
|
788
|
+
expect(result.filters[0]).toMatchObject({ kind: 'relation', fieldName: 'tags', hasMany: true });
|
|
789
|
+
expect(result.filters[0]).not.toHaveProperty('quantifier');
|
|
790
|
+
expect(result.filters[1]).toMatchObject({
|
|
791
|
+
kind: 'relation',
|
|
792
|
+
fieldName: 'tags',
|
|
793
|
+
quantifier: 'none',
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
it('supports quantifiers on single (non-hasMany) relation fields without the hasMany flag', async () => {
|
|
797
|
+
const result = await parseWhere({ category: { $none: { slug: 'hidden' } } }, testCollection, ctx);
|
|
798
|
+
expect(result.filters).toHaveLength(1);
|
|
799
|
+
expect(result.filters[0]).toMatchObject({
|
|
800
|
+
kind: 'relation',
|
|
801
|
+
fieldName: 'category',
|
|
802
|
+
quantifier: 'none',
|
|
803
|
+
});
|
|
804
|
+
expect(result.filters[0]).not.toHaveProperty('hasMany');
|
|
805
|
+
});
|
|
806
|
+
it('skips quantifier objects when ctx is not provided', async () => {
|
|
807
|
+
const result = await parseWhere({ tags: { $some: { slug: 'news' } } }, testCollection);
|
|
808
|
+
expect(result.filters).toHaveLength(0);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
@@ -208,7 +208,7 @@ export const createBaseSchema = (collection) => {
|
|
|
208
208
|
id: z.uuid(),
|
|
209
209
|
versionId: z.uuid().optional(),
|
|
210
210
|
path: z.string().optional(),
|
|
211
|
-
// The document's content source-locale anchor (see docs/
|
|
211
|
+
// The document's content source-locale anchor (see docs/07-internationalization/index.md).
|
|
212
212
|
// Carried through list/get responses so the admin can badge it; Zod would
|
|
213
213
|
// otherwise strip it as an undeclared key.
|
|
214
214
|
sourceLocale: z.string().optional(),
|
|
@@ -216,7 +216,7 @@ export const createBaseSchema = (collection) => {
|
|
|
216
216
|
hasPublishedVersion: z.boolean().optional(),
|
|
217
217
|
createdAt: z.iso.datetime(),
|
|
218
218
|
updatedAt: z.iso.datetime(),
|
|
219
|
-
// Version audit metadata — acting user + action (see docs/
|
|
219
|
+
// Version audit metadata — acting user + action (see docs/06-auth-and-security/02-auditability.md — Workstream 1).
|
|
220
220
|
// Declared so list/get/history responses carry them through the
|
|
221
221
|
// server-fn parse; Zod would otherwise strip them as undeclared keys.
|
|
222
222
|
createdBy: z.uuid().optional(),
|
|
@@ -5,7 +5,36 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `buildSearchDocument` — the document-grain assembler for the
|
|
10
|
+
* `SearchProvider` seam. Walks a collection's role-based `search` config
|
|
11
|
+
* against one locale-resolved document and emits a single, type-enriched
|
|
12
|
+
* `SearchDocument` for a driver to index. See
|
|
13
|
+
* `docs/05-reading-and-delivery/07-search.md`.
|
|
14
|
+
*
|
|
15
|
+
* Role-based and explicit: only the fields named in `search.{body,facets,
|
|
16
|
+
* filters}` are projected — nothing is auto-pulled, so unindexed content
|
|
17
|
+
* never leaks into the index. Core derives each field's `SearchFieldType`
|
|
18
|
+
* from the schema (the "type enrichment") so a driver can map it onto its
|
|
19
|
+
* own index without re-inspecting the collection definition.
|
|
20
|
+
*
|
|
21
|
+
* Pure and synchronous, like `documentToMarkdown`: the rich-text plain-text
|
|
22
|
+
* extractor is the editor-agnostic `toText` seam passed via options, and
|
|
23
|
+
* relation targets are resolved through a caller-supplied definition
|
|
24
|
+
* resolver — no globals, no DB reads. The caller is responsible for handing
|
|
25
|
+
* in a document whose `facets` relation fields are already populated (depth
|
|
26
|
+
* 1) with the target's identity + counter fields.
|
|
27
|
+
*
|
|
28
|
+
* `search.{facets,filters}` name **top-level** fields. `search.body` may name
|
|
29
|
+
* a top-level field of any kind: scalar / `richText` leaves index directly,
|
|
30
|
+
* and container fields (`blocks` / `array` / `group`) are walked recursively —
|
|
31
|
+
* every nested `richText` and text (`text` / `textArea`) leaf is flattened and
|
|
32
|
+
* concatenated into one searchable body string. Nested non-text leaves
|
|
33
|
+
* (`select`, `relation`, numbers, booleans, dates, files) are skipped so block
|
|
34
|
+
* configuration never pollutes the index — the same "content, not
|
|
35
|
+
* configuration" rule the markdown assembler follows.
|
|
36
|
+
*/
|
|
37
|
+
import { type CollectionDefinition, type RichTextToTextFn, type SearchDocument } from '../@types/index.js';
|
|
9
38
|
/** A locale-resolved document fed to the assembler — one locale's view. */
|
|
10
39
|
export interface SearchSourceDocument {
|
|
11
40
|
/** Stable document id (shared across versions and locales). */
|
|
@@ -43,4 +72,13 @@ export interface BuildSearchDocumentOptions {
|
|
|
43
72
|
* Assemble one type-enriched `SearchDocument` from a locale-resolved
|
|
44
73
|
* document and its collection's role-based `search` config.
|
|
45
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the zone set a collection indexes into, or `null` when the
|
|
77
|
+
* collection doesn't opt into search at all. A collection with a `search`
|
|
78
|
+
* config but no explicit `zones` belongs to a single implicit zone equal to
|
|
79
|
+
* its own path. Shared by the assembler (below) and the client's
|
|
80
|
+
* cross-collection `search({ zone })` membership check so the two can't
|
|
81
|
+
* drift.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveSearchZones(definition: CollectionDefinition): string[] | null;
|
|
46
84
|
export declare function buildSearchDocument(doc: SearchSourceDocument, definition: CollectionDefinition, options?: BuildSearchDocumentOptions): SearchDocument;
|
|
@@ -25,20 +25,41 @@
|
|
|
25
25
|
* in a document whose `facets` relation fields are already populated (depth
|
|
26
26
|
* 1) with the target's identity + counter fields.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* `search.{facets,filters}` name **top-level** fields. `search.body` may name
|
|
29
|
+
* a top-level field of any kind: scalar / `richText` leaves index directly,
|
|
30
|
+
* and container fields (`blocks` / `array` / `group`) are walked recursively —
|
|
31
|
+
* every nested `richText` and text (`text` / `textArea`) leaf is flattened and
|
|
32
|
+
* concatenated into one searchable body string. Nested non-text leaves
|
|
33
|
+
* (`select`, `relation`, numbers, booleans, dates, files) are skipped so block
|
|
34
|
+
* configuration never pollutes the index — the same "content, not
|
|
35
|
+
* configuration" rule the markdown assembler follows.
|
|
30
36
|
*/
|
|
37
|
+
import { isArrayField, isBlocksField, isGroupField, } from '../@types/index.js';
|
|
31
38
|
import { resolveIdentityField } from './populate.js';
|
|
32
39
|
/**
|
|
33
40
|
* Assemble one type-enriched `SearchDocument` from a locale-resolved
|
|
34
41
|
* document and its collection's role-based `search` config.
|
|
35
42
|
*/
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the zone set a collection indexes into, or `null` when the
|
|
45
|
+
* collection doesn't opt into search at all. A collection with a `search`
|
|
46
|
+
* config but no explicit `zones` belongs to a single implicit zone equal to
|
|
47
|
+
* its own path. Shared by the assembler (below) and the client's
|
|
48
|
+
* cross-collection `search({ zone })` membership check so the two can't
|
|
49
|
+
* drift.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveSearchZones(definition) {
|
|
52
|
+
const search = definition.search;
|
|
53
|
+
if (search == null)
|
|
54
|
+
return null;
|
|
55
|
+
return search.zones != null && search.zones.length > 0 ? search.zones : [definition.path];
|
|
56
|
+
}
|
|
36
57
|
export function buildSearchDocument(doc, definition, options = {}) {
|
|
37
58
|
const locale = options.locale ?? doc.locale;
|
|
38
59
|
const search = definition.search ?? {};
|
|
39
60
|
const fieldsData = doc.fields ?? {};
|
|
40
61
|
const title = stringValue(resolveLocalized(fieldsData[resolveIdentityField(definition) ?? ''], locale)) ?? '';
|
|
41
|
-
const zones =
|
|
62
|
+
const zones = resolveSearchZones(definition) ?? [definition.path];
|
|
42
63
|
const fields = [];
|
|
43
64
|
// --- body: searchable text -------------------------------------------------
|
|
44
65
|
for (const decl of search.body ?? []) {
|
|
@@ -46,19 +67,7 @@ export function buildSearchDocument(doc, definition, options = {}) {
|
|
|
46
67
|
const field = definition.fields.find((f) => f.name === name);
|
|
47
68
|
if (field == null)
|
|
48
69
|
continue;
|
|
49
|
-
|
|
50
|
-
if (field.type === 'richText') {
|
|
51
|
-
value = options.richTextToText
|
|
52
|
-
? nonEmpty(options.richTextToText({
|
|
53
|
-
value: resolveLocalized(fieldsData[name], locale),
|
|
54
|
-
fieldPath: name,
|
|
55
|
-
collectionPath: definition.path,
|
|
56
|
-
}))
|
|
57
|
-
: null;
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
value = stringValue(resolveLocalized(fieldsData[name], locale));
|
|
61
|
-
}
|
|
70
|
+
const value = collectBodyText(field, fieldsData[name], definition.path, locale, options);
|
|
62
71
|
if (value != null) {
|
|
63
72
|
fields.push(withBoost({ name, type: 'text', role: 'body', value }, decl));
|
|
64
73
|
}
|
|
@@ -113,6 +122,83 @@ function withBoost(field, decl) {
|
|
|
113
122
|
const boost = typeof decl === 'string' ? undefined : decl.boost;
|
|
114
123
|
return boost != null ? { ...field, boost } : field;
|
|
115
124
|
}
|
|
125
|
+
/** Text-bearing scalar leaves collected when walking into a container field. */
|
|
126
|
+
const TEXT_LEAF_TYPES = new Set(['text', 'textArea']);
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a single `search.body` field to its searchable text. A scalar /
|
|
129
|
+
* `richText` leaf indexes directly (preserving the original top-level
|
|
130
|
+
* behaviour); a container (`group` / `array` / `blocks`) is walked
|
|
131
|
+
* recursively, flattening every nested text leaf into one string.
|
|
132
|
+
*/
|
|
133
|
+
function collectBodyText(field, rawValue, collectionPath, locale, options) {
|
|
134
|
+
const value = resolveLocalized(rawValue, locale);
|
|
135
|
+
if (isGroupField(field) || isArrayField(field) || isBlocksField(field)) {
|
|
136
|
+
return collectContainerText(field, value, collectionPath, locale, options);
|
|
137
|
+
}
|
|
138
|
+
return collectLeafText(field, value, collectionPath, options, /* permissive */ true);
|
|
139
|
+
}
|
|
140
|
+
/** Walk a container field, concatenating the text of its nested leaves. */
|
|
141
|
+
function collectContainerText(field, value, collectionPath, locale, options) {
|
|
142
|
+
if (isGroupField(field)) {
|
|
143
|
+
return collectFieldSetText(field.fields, asRecord(value), collectionPath, locale, options);
|
|
144
|
+
}
|
|
145
|
+
if (isArrayField(field)) {
|
|
146
|
+
if (!Array.isArray(value))
|
|
147
|
+
return null;
|
|
148
|
+
return joinTexts(value.map((item) => collectFieldSetText(field.fields, asRecord(item), collectionPath, locale, options)));
|
|
149
|
+
}
|
|
150
|
+
// blocks: resolve each item's block definition by `_type`, then walk it.
|
|
151
|
+
if (!isBlocksField(field) || !Array.isArray(value))
|
|
152
|
+
return null;
|
|
153
|
+
const parts = [];
|
|
154
|
+
for (const item of value) {
|
|
155
|
+
const record = asRecord(item);
|
|
156
|
+
const block = field.blocks.find((b) => b.blockType === record._type);
|
|
157
|
+
if (block == null)
|
|
158
|
+
continue;
|
|
159
|
+
parts.push(collectFieldSetText(block.fields, record, collectionPath, locale, options));
|
|
160
|
+
}
|
|
161
|
+
return joinTexts(parts);
|
|
162
|
+
}
|
|
163
|
+
/** Collect text from every text-bearing leaf in a field set (one container level). */
|
|
164
|
+
function collectFieldSetText(fields, data, collectionPath, locale, options) {
|
|
165
|
+
const parts = [];
|
|
166
|
+
for (const field of fields) {
|
|
167
|
+
const value = resolveLocalized(data?.[field.name], locale);
|
|
168
|
+
if (isGroupField(field) || isArrayField(field) || isBlocksField(field)) {
|
|
169
|
+
parts.push(collectContainerText(field, value, collectionPath, locale, options));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Nested leaves: only text-bearing types contribute — skip configuration
|
|
173
|
+
// (select, relation, numbers, booleans, dates, files).
|
|
174
|
+
parts.push(collectLeafText(field, value, collectionPath, options, /* permissive */ false));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return joinTexts(parts);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Extract text from a single leaf field. `richText` flows through the `toText`
|
|
181
|
+
* seam. For other scalars, `permissive` distinguishes a field the implementor
|
|
182
|
+
* named directly in `search.body` (index any scalar value, as before) from a
|
|
183
|
+
* leaf reached by walking into a container (restricted to text/textArea so
|
|
184
|
+
* block configuration values never leak into the index).
|
|
185
|
+
*/
|
|
186
|
+
function collectLeafText(field, value, collectionPath, options, permissive) {
|
|
187
|
+
if (field.type === 'richText') {
|
|
188
|
+
return options.richTextToText
|
|
189
|
+
? nonEmpty(options.richTextToText({ value, fieldPath: field.name, collectionPath }))
|
|
190
|
+
: null;
|
|
191
|
+
}
|
|
192
|
+
if (permissive || TEXT_LEAF_TYPES.has(field.type)) {
|
|
193
|
+
return stringValue(value);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/** Join non-empty text parts with newlines, or null when there is nothing. */
|
|
198
|
+
function joinTexts(parts) {
|
|
199
|
+
const filtered = parts.filter((p) => p != null && p.length > 0);
|
|
200
|
+
return filtered.length > 0 ? filtered.join('\n') : null;
|
|
201
|
+
}
|
|
116
202
|
/** A populated single relation or array of relations → array of envelopes. */
|
|
117
203
|
function toEnvelopeArray(value) {
|
|
118
204
|
if (Array.isArray(value))
|
|
@@ -162,3 +162,114 @@ describe('buildSearchDocument', () => {
|
|
|
162
162
|
expect(out.title).toBe('Restauration');
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
|
+
// A collection whose prose lives inside a `blocks` field (the docs shape):
|
|
166
|
+
// RichTextBlock (richText + a checkbox toggle) and PhotoBlock (text alt +
|
|
167
|
+
// richText caption + a select + a relation). Mirrors the real-world case where
|
|
168
|
+
// the searchable body is nested, not top-level.
|
|
169
|
+
const blocksCollection = {
|
|
170
|
+
path: 'articles',
|
|
171
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
172
|
+
useAsTitle: 'title',
|
|
173
|
+
fields: [
|
|
174
|
+
{ name: 'title', label: 'Title', type: 'text', localized: true },
|
|
175
|
+
{
|
|
176
|
+
name: 'content',
|
|
177
|
+
label: 'Content',
|
|
178
|
+
type: 'blocks',
|
|
179
|
+
blocks: [
|
|
180
|
+
{
|
|
181
|
+
blockType: 'richTextBlock',
|
|
182
|
+
label: 'Richtext Block',
|
|
183
|
+
fields: [
|
|
184
|
+
{ name: 'richText', label: 'Richtext', type: 'richText', localized: true },
|
|
185
|
+
{ name: 'constrainedWidth', label: 'Constrained', type: 'checkbox' },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
blockType: 'photoBlock',
|
|
190
|
+
label: 'Photo Block',
|
|
191
|
+
fields: [
|
|
192
|
+
{
|
|
193
|
+
name: 'display',
|
|
194
|
+
label: 'Display',
|
|
195
|
+
type: 'select',
|
|
196
|
+
options: [
|
|
197
|
+
{ label: 'Default', value: 'default' },
|
|
198
|
+
{ label: 'Wide', value: 'wide' },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
{ name: 'photo', label: 'Photo', type: 'relation', targetCollection: 'media' },
|
|
202
|
+
{ name: 'alt', label: 'Alt', type: 'text' },
|
|
203
|
+
{ name: 'caption', label: 'Caption', type: 'richText', localized: true },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
search: { body: [{ field: 'title', boost: 2 }, 'content'] },
|
|
210
|
+
};
|
|
211
|
+
describe('buildSearchDocument — container (blocks) body', () => {
|
|
212
|
+
function blockSource() {
|
|
213
|
+
return {
|
|
214
|
+
documentId: 'art-1',
|
|
215
|
+
locale: 'en',
|
|
216
|
+
status: 'published',
|
|
217
|
+
path: 'forests',
|
|
218
|
+
fields: {
|
|
219
|
+
title: 'Forests',
|
|
220
|
+
content: [
|
|
221
|
+
{
|
|
222
|
+
_type: 'richTextBlock',
|
|
223
|
+
_id: 'b1',
|
|
224
|
+
richText: { text: 'None of this is certain. But it reflects what we observed.' },
|
|
225
|
+
constrainedWidth: true,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
_type: 'photoBlock',
|
|
229
|
+
_id: 'b2',
|
|
230
|
+
display: 'wide',
|
|
231
|
+
photo: { _resolved: true, document: { fields: { title: 'A tree' } } },
|
|
232
|
+
alt: 'A tall redwood',
|
|
233
|
+
caption: { text: 'Old growth in the valley.' },
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const doc = buildSearchDocument(blockSource(), blocksCollection, { richTextToText });
|
|
240
|
+
it('walks the blocks field and flattens nested richText + text leaves', () => {
|
|
241
|
+
const content = fieldByName(doc.fields, 'content');
|
|
242
|
+
expect(content).toMatchObject({ name: 'content', type: 'text', role: 'body' });
|
|
243
|
+
const value = content?.value;
|
|
244
|
+
expect(value).toContain('it reflects what we observed');
|
|
245
|
+
expect(value).toContain('A tall redwood');
|
|
246
|
+
expect(value).toContain('Old growth in the valley.');
|
|
247
|
+
});
|
|
248
|
+
it('skips nested non-text leaves (select, relation, checkbox) — no config noise', () => {
|
|
249
|
+
const value = fieldByName(doc.fields, 'content')?.value;
|
|
250
|
+
expect(value).not.toContain('wide'); // select
|
|
251
|
+
expect(value).not.toContain('A tree'); // relation target title
|
|
252
|
+
expect(value).not.toContain('true'); // checkbox
|
|
253
|
+
});
|
|
254
|
+
it('preserves top-level scalar body fields alongside the container', () => {
|
|
255
|
+
expect(fieldByName(doc.fields, 'title')).toMatchObject({ value: 'Forests', boost: 2 });
|
|
256
|
+
});
|
|
257
|
+
it('omits the container body field entirely when no toText seam is registered', () => {
|
|
258
|
+
// With no richText extractor, the only text-bearing leaves left are the
|
|
259
|
+
// PhotoBlock `alt` text.
|
|
260
|
+
const out = buildSearchDocument(blockSource(), blocksCollection);
|
|
261
|
+
const value = fieldByName(out.fields, 'content')?.value;
|
|
262
|
+
expect(value).toBe('A tall redwood');
|
|
263
|
+
});
|
|
264
|
+
it('produces no content field when a block field set has no text', () => {
|
|
265
|
+
const empty = {
|
|
266
|
+
...blockSource(),
|
|
267
|
+
fields: {
|
|
268
|
+
title: 'Forests',
|
|
269
|
+
content: [{ _type: 'photoBlock', _id: 'b3', display: 'wide' }],
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const out = buildSearchDocument(empty, blocksCollection, { richTextToText });
|
|
273
|
+
expect(fieldByName(out.fields, 'content')).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -38,7 +38,7 @@ export interface AuditCapability {
|
|
|
38
38
|
* **both** `withTransaction` and `commands.audit`. Returns a non-null
|
|
39
39
|
* capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
|
|
40
40
|
* rather than silently skipping the audit row or running it non-atomically.
|
|
41
|
-
* See docs/
|
|
41
|
+
* See docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
|
|
42
42
|
*/
|
|
43
43
|
export declare function requireAuditCapability(db: IDbAdapter): AuditCapability;
|
|
44
44
|
/** Order-insensitive equality for the advertised-locale set. */
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* Audit-log write helpers for the document-grain lifecycle write-points
|
|
10
|
-
* (docs/
|
|
10
|
+
* (docs/06-auth-and-security/02-auditability.md — Workstream 2). The audit log records the changes the
|
|
11
11
|
* immutable version stream does NOT capture an actor for: non-versioned
|
|
12
12
|
* system-field writes (path, available-locales), in-place status transitions,
|
|
13
13
|
* and deletions. Each such mutation and its audit row commit atomically inside
|
|
14
14
|
* `withTransaction` — a silently-unwritten audit row is the one unacceptable
|
|
15
|
-
* outcome (see docs/
|
|
15
|
+
* outcome (see docs/03-architecture/03-transactions.md).
|
|
16
16
|
*/
|
|
17
17
|
import { ERR_AUDIT_UNSUPPORTED } from '../../lib/errors.js';
|
|
18
18
|
import { actorId } from './internals.js';
|
|
@@ -42,7 +42,7 @@ export function auditActor(ctx) {
|
|
|
42
42
|
* **both** `withTransaction` and `commands.audit`. Returns a non-null
|
|
43
43
|
* capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
|
|
44
44
|
* rather than silently skipping the audit row or running it non-atomically.
|
|
45
|
-
* See docs/
|
|
45
|
+
* See docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
|
|
46
46
|
*/
|
|
47
47
|
export function requireAuditCapability(db) {
|
|
48
48
|
const withTransaction = db.withTransaction;
|
|
@@ -73,7 +73,7 @@ export interface DocumentLifecycleContext {
|
|
|
73
73
|
* — `assertActorCanPerform` runs at every lifecycle entry and rejects
|
|
74
74
|
* a missing context.
|
|
75
75
|
*
|
|
76
|
-
* See docs/
|
|
76
|
+
* See docs/06-auth-and-security/01-authn-authz.md.
|
|
77
77
|
*/
|
|
78
78
|
requestContext?: RequestContext;
|
|
79
79
|
}
|
|
@@ -39,7 +39,7 @@ export declare function createDocument(ctx: DocumentLifecycleContext, params: {
|
|
|
39
39
|
* sidebar widget). Document-grain and sticky like `path`: passed straight
|
|
40
40
|
* to the storage primitive, which replaces the document's rows wholesale.
|
|
41
41
|
* `undefined` writes nothing (a new document starts with an empty set —
|
|
42
|
-
* the safe opt-in default); `[]` clears it. See docs/
|
|
42
|
+
* the safe opt-in default); `[]` clears it. See docs/07-internationalization/index.md.
|
|
43
43
|
*/
|
|
44
44
|
availableLocales?: string[];
|
|
45
45
|
}): Promise<CreateDocumentResult>;
|
|
@@ -87,7 +87,7 @@ export async function createDocument(ctx, params) {
|
|
|
87
87
|
// command directly — no `update` re-assertion, no separate tree event
|
|
88
88
|
// (afterCreate covers invalidation). Post-version and best-effort: a
|
|
89
89
|
// failure leaves the document created-but-unplaced and is logged, not
|
|
90
|
-
// thrown. See docs/
|
|
90
|
+
// thrown. See docs/04-collections/03-document-trees.md.
|
|
91
91
|
if (definition.tree === true) {
|
|
92
92
|
try {
|
|
93
93
|
await appendTreeRoot(ctx, documentId);
|
|
@@ -96,9 +96,9 @@ export async function deleteDocument(ctx, params) {
|
|
|
96
96
|
// 3. Soft-delete all versions, atomically with the audit record. A
|
|
97
97
|
// whole-document delete mints no new version, so the version stream
|
|
98
98
|
// never records it — the audit log is the only place a deletion is
|
|
99
|
-
// accountable (docs/
|
|
99
|
+
// accountable (docs/06-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
|
|
100
100
|
// DB↔external side-effect and stays OUTSIDE the transaction — it is
|
|
101
|
-
// post-commit, best-effort compensation (docs/
|
|
101
|
+
// post-commit, best-effort compensation (docs/03-architecture/03-transactions.md).
|
|
102
102
|
const audit = requireAuditCapability(db);
|
|
103
103
|
const actor = auditActor(ctx);
|
|
104
104
|
let deletedVersionCount = 0;
|
|
@@ -132,7 +132,7 @@ export async function deleteDocument(ctx, params) {
|
|
|
132
132
|
// structural-change invalidation event. Post-commit and best-effort,
|
|
133
133
|
// like file cleanup: a failure here leaves the soft-delete intact
|
|
134
134
|
// (status-at-edge already hides the deleted node's subtree from
|
|
135
|
-
// reads) and is logged rather than thrown. See docs/
|
|
135
|
+
// reads) and is logged rather than thrown. See docs/04-collections/03-document-trees.md.
|
|
136
136
|
if (definition.tree === true) {
|
|
137
137
|
try {
|
|
138
138
|
await promoteChildrenAndRemove(ctx, { documentId: params.documentId });
|
|
@@ -28,7 +28,7 @@ import type { DocumentLifecycleContext } from './context.js';
|
|
|
28
28
|
* (the seeds/migrations escape hatch) — both yield `undefined` → NULL
|
|
29
29
|
* `created_by`, which the history strip renders as "unknown". Real
|
|
30
30
|
* `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
|
|
31
|
-
* is unaffected. See docs/
|
|
31
|
+
* is unaffected. See docs/06-auth-and-security/02-auditability.md — Workstream 1.
|
|
32
32
|
*/
|
|
33
33
|
export declare function actorId(ctx: DocumentLifecycleContext): string | undefined;
|
|
34
34
|
/**
|
|
@@ -76,7 +76,7 @@ export declare function appendTreeRoot(ctx: DocumentLifecycleContext, documentId
|
|
|
76
76
|
*
|
|
77
77
|
* No-op for non-tree collections. Best-effort and post-version: a failure leaves
|
|
78
78
|
* the document saved-but-unplaced and is logged, never thrown. See
|
|
79
|
-
* docs/
|
|
79
|
+
* docs/04-collections/03-document-trees.md.
|
|
80
80
|
*/
|
|
81
81
|
export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
|
|
82
82
|
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
@@ -35,7 +35,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
35
35
|
* (the seeds/migrations escape hatch) — both yield `undefined` → NULL
|
|
36
36
|
* `created_by`, which the history strip renders as "unknown". Real
|
|
37
37
|
* `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
|
|
38
|
-
* is unaffected. See docs/
|
|
38
|
+
* is unaffected. See docs/06-auth-and-security/02-auditability.md — Workstream 1.
|
|
39
39
|
*/
|
|
40
40
|
export function actorId(ctx) {
|
|
41
41
|
const id = ctx.requestContext?.actor?.id;
|
|
@@ -131,7 +131,7 @@ export async function appendTreeRoot(ctx, documentId) {
|
|
|
131
131
|
*
|
|
132
132
|
* No-op for non-tree collections. Best-effort and post-version: a failure leaves
|
|
133
133
|
* the document saved-but-unplaced and is logged, never thrown. See
|
|
134
|
-
* docs/
|
|
134
|
+
* docs/04-collections/03-document-trees.md.
|
|
135
135
|
*/
|
|
136
136
|
export async function selfHealTreePlacement(ctx, documentId) {
|
|
137
137
|
if (ctx.definition.tree !== true)
|
|
@@ -210,7 +210,7 @@ export function resolvePathForUpdate(args) {
|
|
|
210
210
|
// skip the write (existing path row stays as-is — sticky). The path row
|
|
211
211
|
// lives under the document's source_locale (its anchor), not the mutable
|
|
212
212
|
// global default — so this stays correct after the global default is
|
|
213
|
-
// switched. See docs/
|
|
213
|
+
// switched. See docs/07-internationalization/index.md.
|
|
214
214
|
return explicitPath ?? undefined;
|
|
215
215
|
}
|
|
216
216
|
// Non-source-locale (translation) write: reject any path change with a warn
|
|
@@ -89,7 +89,7 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
89
89
|
// 4–5. Mutate status in-place + auto-archive, atomically with the audit
|
|
90
90
|
// record. Status mutates the version row rather than minting a new
|
|
91
91
|
// version, so the version stream never captures *who* changed it —
|
|
92
|
-
// the audit log is its only accountability home (docs/
|
|
92
|
+
// the audit log is its only accountability home (docs/06-auth-and-security/02-auditability.md).
|
|
93
93
|
const audit = requireAuditCapability(db);
|
|
94
94
|
const actor = auditActor(ctx);
|
|
95
95
|
await audit.withTransaction(async () => {
|
|
@@ -25,7 +25,7 @@ export interface UpdateDocumentSystemFieldsResult {
|
|
|
25
25
|
* version. This service backs the admin path / available-locales widgets'
|
|
26
26
|
* direct-write Save (the `direct-write` and `both` dirty-reason cases). The
|
|
27
27
|
* public *advertised* set remains the intersection of `availableLocales` with
|
|
28
|
-
* the resolved version's completeness ledger. See docs/
|
|
28
|
+
* the resolved version's completeness ledger. See docs/07-internationalization/index.md.
|
|
29
29
|
*
|
|
30
30
|
* Flow:
|
|
31
31
|
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
@@ -40,7 +40,7 @@ export interface UpdateDocumentSystemFieldsResult {
|
|
|
40
40
|
* No content hooks fire — these are not content writes. Accountability for
|
|
41
41
|
* these mutations is the document-grain audit log: each field that actually
|
|
42
42
|
* changes records a `document.path.changed` / `document.locales.changed` row
|
|
43
|
-
* atomically with the write (docs/
|
|
43
|
+
* atomically with the write (docs/06-auth-and-security/02-auditability.md — Workstream 2).
|
|
44
44
|
*
|
|
45
45
|
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
46
46
|
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
@@ -22,7 +22,7 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
|
22
22
|
* version. This service backs the admin path / available-locales widgets'
|
|
23
23
|
* direct-write Save (the `direct-write` and `both` dirty-reason cases). The
|
|
24
24
|
* public *advertised* set remains the intersection of `availableLocales` with
|
|
25
|
-
* the resolved version's completeness ledger. See docs/
|
|
25
|
+
* the resolved version's completeness ledger. See docs/07-internationalization/index.md.
|
|
26
26
|
*
|
|
27
27
|
* Flow:
|
|
28
28
|
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
@@ -37,7 +37,7 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
|
37
37
|
* No content hooks fire — these are not content writes. Accountability for
|
|
38
38
|
* these mutations is the document-grain audit log: each field that actually
|
|
39
39
|
* changes records a `document.path.changed` / `document.locales.changed` row
|
|
40
|
-
* atomically with the write (docs/
|
|
40
|
+
* atomically with the write (docs/06-auth-and-security/02-auditability.md — Workstream 2).
|
|
41
41
|
*
|
|
42
42
|
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
43
43
|
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
@@ -78,7 +78,7 @@ export async function updateDocumentSystemFields(ctx, params) {
|
|
|
78
78
|
// Both document-grain writes and their audit rows commit atomically.
|
|
79
79
|
// These fields are non-versioned, so the version stream never records
|
|
80
80
|
// them — the audit log is their only accountability home. One audit row
|
|
81
|
-
// per field that actually changed (docs/
|
|
81
|
+
// per field that actually changed (docs/06-auth-and-security/02-auditability.md).
|
|
82
82
|
const currentPath = originalData.path;
|
|
83
83
|
const currentLocales = originalData.availableLocales ?? [];
|
|
84
84
|
const availableLocalesWritten = params.availableLocales !== undefined;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* Document-tree lifecycle service — the unversioned structural mutations for
|
|
10
|
-
* `tree: true` collections (docs/
|
|
10
|
+
* `tree: true` collections (docs/04-collections/03-document-trees.md). Wraps the storage adapter's
|
|
11
11
|
* tree commands so that, like the versioned lifecycle services, they enforce the
|
|
12
12
|
* actor ability and fire a collection hook. Tree writes mint no document version
|
|
13
13
|
* and touch no user fields, so the `afterTreeChange` hook is the *only*
|