@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.
Files changed (42) hide show
  1. package/dist/@types/collection-types.d.ts +7 -7
  2. package/dist/@types/db-types.d.ts +46 -24
  3. package/dist/@types/query-predicate.d.ts +25 -3
  4. package/dist/@types/search-types.d.ts +8 -1
  5. package/dist/@types/site-config.d.ts +2 -2
  6. package/dist/auth/assert-actor-can-perform.d.ts +1 -1
  7. package/dist/auth/assert-actor-can-perform.js +1 -1
  8. package/dist/auth/register-collection-abilities.d.ts +1 -1
  9. package/dist/auth/register-collection-abilities.js +1 -1
  10. package/dist/config/config.d.ts +1 -1
  11. package/dist/config/config.js +1 -1
  12. package/dist/core.d.ts +1 -1
  13. package/dist/core.js +1 -1
  14. package/dist/lib/errors.d.ts +1 -1
  15. package/dist/lib/errors.js +1 -1
  16. package/dist/query/parse-where.js +71 -20
  17. package/dist/query/parse-where.test.node.js +93 -0
  18. package/dist/schemas/zod/builder.js +2 -2
  19. package/dist/services/build-search-document.d.ts +39 -1
  20. package/dist/services/build-search-document.js +102 -16
  21. package/dist/services/build-search-document.test.node.js +111 -0
  22. package/dist/services/document-lifecycle/audit.d.ts +1 -1
  23. package/dist/services/document-lifecycle/audit.js +3 -3
  24. package/dist/services/document-lifecycle/context.d.ts +1 -1
  25. package/dist/services/document-lifecycle/create.d.ts +1 -1
  26. package/dist/services/document-lifecycle/create.js +1 -1
  27. package/dist/services/document-lifecycle/delete.js +3 -3
  28. package/dist/services/document-lifecycle/internals.d.ts +2 -2
  29. package/dist/services/document-lifecycle/internals.js +3 -3
  30. package/dist/services/document-lifecycle/status.js +1 -1
  31. package/dist/services/document-lifecycle/system-fields.d.ts +2 -2
  32. package/dist/services/document-lifecycle/system-fields.js +3 -3
  33. package/dist/services/document-lifecycle/tree.js +1 -1
  34. package/dist/services/document-lifecycle/update.d.ts +2 -2
  35. package/dist/services/document-lifecycle.test.node.js +3 -3
  36. package/dist/services/document-to-markdown.d.ts +1 -1
  37. package/dist/services/document-to-markdown.js +1 -1
  38. package/dist/services/document-to-markdown.test.node.js +1 -1
  39. package/dist/services/index.d.ts +1 -1
  40. package/dist/services/index.js +1 -1
  41. package/dist/services/populate.d.ts +1 -1
  42. 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/I18N.md).
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/AUDIT.md — Workstream 1).
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
- import type { CollectionDefinition, RichTextToTextFn, SearchDocument } from '../@types/index.js';
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
- * v1 scope: `search.{body,facets,filters}` name **top-level** fields. Deep
29
- * paths into blocks / arrays are a follow-up.
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 = search.zones != null && search.zones.length > 0 ? search.zones : [definition.path];
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
- let value;
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/TRANSACTIONS.md and docs/AUDIT.md.
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/AUDIT.md — Workstream 2). The audit log records the changes the
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/TRANSACTIONS.md).
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/TRANSACTIONS.md and docs/AUDIT.md.
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/AUTHN-AUTHZ.md.
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/I18N.md.
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/DOCUMENT-TREE.md.
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/AUDIT.md). Storage-file cleanup (step 4) is a
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/TRANSACTIONS.md).
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/DOCUMENT-TREE.md.
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/AUDIT.md — Workstream 1.
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/DOCUMENT-TREE.md.
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/AUDIT.md — Workstream 1.
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/DOCUMENT-TREE.md.
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/I18N.md.
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/AUDIT.md).
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/I18N.md.
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/AUDIT.md — Workstream 2).
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/I18N.md.
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/AUDIT.md — Workstream 2).
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/AUDIT.md).
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/DOCUMENT-TREE.md). Wraps the storage adapter's
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*