@byline/core 3.15.2 → 3.16.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.
Files changed (41) 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 +9 -0
  20. package/dist/services/build-search-document.js +15 -1
  21. package/dist/services/document-lifecycle/audit.d.ts +1 -1
  22. package/dist/services/document-lifecycle/audit.js +3 -3
  23. package/dist/services/document-lifecycle/context.d.ts +1 -1
  24. package/dist/services/document-lifecycle/create.d.ts +1 -1
  25. package/dist/services/document-lifecycle/create.js +1 -1
  26. package/dist/services/document-lifecycle/delete.js +3 -3
  27. package/dist/services/document-lifecycle/internals.d.ts +2 -2
  28. package/dist/services/document-lifecycle/internals.js +3 -3
  29. package/dist/services/document-lifecycle/status.js +1 -1
  30. package/dist/services/document-lifecycle/system-fields.d.ts +2 -2
  31. package/dist/services/document-lifecycle/system-fields.js +3 -3
  32. package/dist/services/document-lifecycle/tree.js +1 -1
  33. package/dist/services/document-lifecycle/update.d.ts +2 -2
  34. package/dist/services/document-lifecycle.test.node.js +3 -3
  35. package/dist/services/document-to-markdown.d.ts +1 -1
  36. package/dist/services/document-to-markdown.js +1 -1
  37. package/dist/services/document-to-markdown.test.node.js +1 -1
  38. package/dist/services/index.d.ts +1 -1
  39. package/dist/services/index.js +1 -1
  40. package/dist/services/populate.d.ts +1 -1
  41. package/package.json +3 -5
@@ -474,7 +474,7 @@ export interface DeleteContext {
474
474
  }
475
475
  /**
476
476
  * Context passed to the `afterTreeChange` hook — the structural-change
477
- * invalidation event for `tree: true` collections (docs/DOCUMENT-TREE.md).
477
+ * invalidation event for `tree: true` collections (docs/04-collections/03-document-trees.md).
478
478
  *
479
479
  * Tree mutations are document-grain and **unversioned**, so the normal
480
480
  * version-write invalidation (`afterCreate` / `afterUpdate` / `afterStatusChange`)
@@ -676,7 +676,7 @@ export declare function resolveUploadHooks(hooks: UploadHooks | UploadHooksLoade
676
676
  * by populate. A hook that performs its own reads should thread this
677
677
  * context back in via `client.collection(...).findById(id, { _readContext:
678
678
  * readContext })` so the visited set and read budget are preserved —
679
- * essential to foreclose the A→B→A loop (see `docs/RELATIONSHIPS.md`).
679
+ * essential to foreclose the A→B→A loop (see `docs/04-collections/02-relationships.md`).
680
680
  */
681
681
  export interface AfterReadContext {
682
682
  /** The raw reconstructed document. Mutate in place — changes persist. */
@@ -709,7 +709,7 @@ export interface AfterReadContext {
709
709
  * - `collectionPath` — the collection being queried (useful when the
710
710
  * same hook function is reused across collections).
711
711
  *
712
- * See `docs/AUTHN-AUTHZ.md` for the strategic rationale; the Quick
712
+ * See `docs/06-auth-and-security/01-authn-authz.md` for the strategic rationale; the Quick
713
713
  * Reference there carries six worked recipes.
714
714
  */
715
715
  export interface BeforeReadContext {
@@ -786,7 +786,7 @@ export interface CollectionHooks {
786
786
  * (`removeFromTree`), or the promote-children-to-root that accompanies a
787
787
  * delete. Tree writes mint no document version, so this is the only
788
788
  * invalidation signal for them. Fires once per write with the full affected
789
- * set ({@link TreeChangeContext}). See docs/DOCUMENT-TREE.md.
789
+ * set ({@link TreeChangeContext}). See docs/04-collections/03-document-trees.md.
790
790
  */
791
791
  afterTreeChange?: CollectionHookSlot<TreeChangeContext>;
792
792
  /**
@@ -795,7 +795,7 @@ export interface CollectionHooks {
795
795
  * that the query layer ANDs onto the caller's `where` to enforce
796
796
  * read-side row scoping (multi-tenant, owner-only-drafts, soft-delete
797
797
  * hide, etc). Returning `void` applies no scoping. Multiple functions
798
- * combine with implicit AND. See `docs/AUTHN-AUTHZ.md` (Read-side
798
+ * combine with implicit AND. See `docs/06-auth-and-security/01-authn-authz.md` (Read-side
799
799
  * scoping + Quick Reference recipes).
800
800
  */
801
801
  beforeRead?: BeforeReadHookSlot;
@@ -970,7 +970,7 @@ export interface CollectionDefinition {
970
970
  * collection has at least one `localized` field, so the validator rejects
971
971
  * `advertiseLocales: true` on a collection with none.
972
972
  *
973
- * See `docs/I18N.md`.
973
+ * See `docs/07-internationalization/index.md`.
974
974
  */
975
975
  advertiseLocales?: boolean;
976
976
  /**
@@ -1061,7 +1061,7 @@ export interface CollectionDefinition {
1061
1061
  * touch no user fields. Do **not** also declare a `parent` relation field; the
1062
1062
  * tree owns structure (a topic that genuinely belongs in two places is a
1063
1063
  * cross-link relation field, never a second tree edge). See
1064
- * docs/DOCUMENT-TREE.md.
1064
+ * docs/04-collections/03-document-trees.md.
1065
1065
  */
1066
1066
  tree?: boolean;
1067
1067
  /**
@@ -39,7 +39,7 @@ export type ReadMode = 'any' | 'published';
39
39
  * safe default for internal/direct reads); `@byline/client` defaults it to
40
40
  * `'fallback'` for application reads. Availability follows path-coverage against
41
41
  * the default content locale; a document with no localized content is available
42
- * in every locale. See `docs/I18N.md`.
42
+ * in every locale. See `docs/07-internationalization/index.md`.
43
43
  */
44
44
  export type MissingLocalePolicy = 'empty' | 'fallback' | 'omit';
45
45
  /**
@@ -128,6 +128,28 @@ export interface RelationFilter {
128
128
  targetCollectionId: string;
129
129
  /** Filters applied to the target document. Recursive. */
130
130
  nested: DocumentFilter[];
131
+ /**
132
+ * Whether the source field is a multi-target (`hasMany`) relation. hasMany
133
+ * items are stored as indexed `store_relation` rows (`<field>.0`,
134
+ * `<field>.1`, …), so the adapter matches `field_name` by prefix instead
135
+ * of exact equality. Set at parse time from the field definition.
136
+ */
137
+ hasMany?: boolean;
138
+ /**
139
+ * Quantifier over the relation's target set, for `hasMany` relations
140
+ * (single relations are a set of ≤ 1 and compose the same way):
141
+ *
142
+ * - `'some'` (default) — at least one target satisfies `nested`.
143
+ * - `'every'` — no target fails `nested`. Vacuously true when the
144
+ * document has no (resolving) targets on the field.
145
+ * - `'none'` — no target satisfies `nested`. With empty `nested`,
146
+ * matches documents that have no resolving targets at all.
147
+ *
148
+ * Targets that do not resolve in the selected read view (deleted, or
149
+ * unpublished under `readMode: 'published'`) are ignored by all three —
150
+ * the same visibility rule populate applies.
151
+ */
152
+ quantifier?: 'some' | 'every' | 'none';
131
153
  }
132
154
  /**
133
155
  * A boolean combinator group. Wraps a list of child filters with explicit
@@ -204,7 +226,7 @@ export interface IDbAdapter {
204
226
  documents: IDocumentCommands;
205
227
  counters: ICounterCommands;
206
228
  /**
207
- * Append-only audit-log writes (docs/AUDIT.md — Workstream 2). Optional
229
+ * Append-only audit-log writes (docs/06-auth-and-security/02-auditability.md — Workstream 2). Optional
208
230
  * capability, paired with `withTransaction`: a consumer that records audit
209
231
  * entries asserts both are present and throws otherwise (it must never
210
232
  * silently skip the audit row). Adapters that model the audit log
@@ -215,14 +237,14 @@ export interface IDbAdapter {
215
237
  queries: {
216
238
  collections: ICollectionQueries;
217
239
  documents: IDocumentQueries;
218
- /** Audit-log reads — per-document history, system-wide report. See docs/AUDIT.md. */
240
+ /** Audit-log reads — per-document history, system-wide report. See docs/06-auth-and-security/02-auditability.md. */
219
241
  audit?: IAuditQueries;
220
242
  };
221
243
  /**
222
244
  * Optional capability: run `fn` inside a single database transaction so the
223
245
  * writes it performs commit or roll back atomically. The adapter propagates
224
246
  * the transaction to every `commands.*` call made within `fn` (see
225
- * docs/TRANSACTIONS.md — AsyncLocalStorage propagation), so a service can
247
+ * docs/03-architecture/03-transactions.md — AsyncLocalStorage propagation), so a service can
226
248
  * compose multiple commands into one unit of work without threading a
227
249
  * transaction handle through their signatures.
228
250
  *
@@ -232,7 +254,7 @@ export interface IDbAdapter {
232
254
  * method** (or implement it to throw); a consumer that requires atomicity
233
255
  * (e.g. the audit log) MUST assert its presence and throw — never silently
234
256
  * run non-atomically, which would defeat the very guarantee it provides. See
235
- * docs/TRANSACTIONS.md ("Serverless / HTTP-gateway databases — the contract
257
+ * docs/03-architecture/03-transactions.md ("Serverless / HTTP-gateway databases — the contract
236
258
  * seam").
237
259
  */
238
260
  withTransaction?: <T>(fn: () => Promise<T>) => Promise<T>;
@@ -243,7 +265,7 @@ export interface IDbAdapter {
243
265
  * boot by `initBylineCore` so in-place upgrades self-heal without a manual
244
266
  * step or a migrate-ordering constraint — a no-op (zero rows) once every
245
267
  * document is stamped. Optional so adapters that don't model `source_locale`
246
- * need not implement it. See docs/I18N.md.
268
+ * need not implement it. See docs/07-internationalization/index.md.
247
269
  */
248
270
  backfillSourceLocales?: () => Promise<{
249
271
  rowsUpdated: number;
@@ -252,7 +274,7 @@ export interface IDbAdapter {
252
274
  /**
253
275
  * The realm of the actor that performed an audited change. `'admin'` for
254
276
  * admin-user actions, `'user'` reserved for the end-user realm, `'system'`
255
- * for deliberate internal-tooling writes. See docs/AUDIT.md.
277
+ * for deliberate internal-tooling writes. See docs/06-auth-and-security/02-auditability.md.
256
278
  */
257
279
  export type AuditActorRealm = 'admin' | 'user' | 'system';
258
280
  /** Input to `IAuditCommands.append` — one audit-log row. */
@@ -297,7 +319,7 @@ export interface AuditLogPage {
297
319
  }
298
320
  /**
299
321
  * Append-only audit-log writes. The companion read interface is
300
- * `IAuditQueries`. See docs/AUDIT.md — Workstream 2.
322
+ * `IAuditQueries`. See docs/06-auth-and-security/02-auditability.md — Workstream 2.
301
323
  */
302
324
  export interface IAuditCommands {
303
325
  /**
@@ -310,7 +332,7 @@ export interface IAuditCommands {
310
332
  id: string;
311
333
  }>;
312
334
  }
313
- /** Audit-log reads. See docs/AUDIT.md — Workstreams 3 & 4. */
335
+ /** Audit-log reads. See docs/06-auth-and-security/02-auditability.md — Workstreams 3 & 4. */
314
336
  export interface IAuditQueries {
315
337
  /**
316
338
  * The audit history for one document, newest first, paged. Backs the
@@ -324,7 +346,7 @@ export interface IAuditQueries {
324
346
  page_size?: number;
325
347
  }): Promise<AuditLogPage>;
326
348
  /**
327
- * The system-wide activity feed (docs/AUDIT.md — Workstream 4), newest
349
+ * The system-wide activity feed (docs/06-auth-and-security/02-auditability.md — Workstream 4), newest
328
350
  * first, paged and filterable. A read-time **union** of two disjoint event
329
351
  * sources, normalised onto the `AuditLogEntry` shape:
330
352
  *
@@ -364,7 +386,7 @@ export interface IAuditQueries {
364
386
  /**
365
387
  * Adapter capability for the shared-pool counter mechanism backing the
366
388
  * `counter` field type. See `packages/core/src/@types/field-types.ts`
367
- * (CounterField) for the field-level contract and `docs/COLLECTIONS.md`
389
+ * (CounterField) for the field-level contract and `docs/04-collections/index.md`
368
390
  * (Counter fields) for the conceptual overview.
369
391
  *
370
392
  * Both methods are keyed by the developer-facing `groupName` (the value
@@ -455,7 +477,7 @@ export interface IDocumentCommands {
455
477
  * editorial advertised-locale set. `undefined` leaves the existing set
456
478
  * untouched (sticky across versions, like `path`); `[]` clears it. The
457
479
  * locale values are the advertised content locales themselves, not the
458
- * write locale. See `docs/I18N.md`.
480
+ * write locale. See `docs/07-internationalization/index.md`.
459
481
  */
460
482
  availableLocales?: string[];
461
483
  locale?: string;
@@ -473,7 +495,7 @@ export interface IDocumentCommands {
473
495
  * Only set on the initial create (when `documentId` is undefined) for
474
496
  * collections with `orderable: true`. Ignored on subsequent versions of
475
497
  * an existing document — order is admin metadata on the logical document,
476
- * not per-version content. See docs/COLLECTIONS.md (Orderable collections).
498
+ * not per-version content. See docs/04-collections/index.md (Orderable collections).
477
499
  */
478
500
  orderKey?: string;
479
501
  }): Promise<{
@@ -487,7 +509,7 @@ export interface IDocumentCommands {
487
509
  * the change is immediate and applies across every version. Backs the admin
488
510
  * path widget's direct-write Save. The unique constraint on
489
511
  * `(collection_id, locale, path)` may surface as `ERR_PATH_CONFLICT` from the
490
- * lifecycle layer. See `docs/I18N.md`.
512
+ * lifecycle layer. See `docs/07-internationalization/index.md`.
491
513
  */
492
514
  updateDocumentPath(params: {
493
515
  documentId: string;
@@ -501,7 +523,7 @@ export interface IDocumentCommands {
501
523
  * wholesale **without** minting a new version or touching workflow status —
502
524
  * the change is immediate and applies across every version. `[]` clears it.
503
525
  * Backs the admin available-locales widget's direct-write Save. See
504
- * `docs/I18N.md`.
526
+ * `docs/07-internationalization/index.md`.
505
527
  */
506
528
  setDocumentAvailableLocales(params: {
507
529
  documentId: string;
@@ -555,7 +577,7 @@ export interface IDocumentCommands {
555
577
  documentId: string;
556
578
  locale: string;
557
579
  status?: string;
558
- /** Acting user id for the version audit trail (`created_by`). See docs/AUDIT.md. */
580
+ /** Acting user id for the version audit trail (`created_by`). See docs/06-auth-and-security/02-auditability.md. */
559
581
  createdBy?: string;
560
582
  }): Promise<{
561
583
  newVersionId: string;
@@ -598,7 +620,7 @@ export interface IDocumentCommands {
598
620
  * **before** (its right neighbour). Either may be null — `{}` appends as the
599
621
  * only/last child, `beforeDocumentId` alone appends after it, `afterDocumentId`
600
622
  * alone prepends before it. Both are resolved within the *target* parent
601
- * group. See docs/DOCUMENT-TREE.md.
623
+ * group. See docs/04-collections/03-document-trees.md.
602
624
  */
603
625
  placeTreeNode(params: {
604
626
  collectionId: string;
@@ -613,7 +635,7 @@ export interface IDocumentCommands {
613
635
  * Remove a document's edge row, returning it to the *unplaced* state (in the
614
636
  * collection, but not in the tree). Distinct from document deletion — the
615
637
  * document and its content are untouched. No-op when the node is already
616
- * unplaced. See docs/DOCUMENT-TREE.md.
638
+ * unplaced. See docs/04-collections/03-document-trees.md.
617
639
  */
618
640
  removeFromTree(params: {
619
641
  documentId: string;
@@ -642,7 +664,7 @@ export interface IDocumentQueries {
642
664
  /**
643
665
  * Request-scoped auth context. Adapters thread it through to
644
666
  * `assertActorCanPerform` for ability assertion and to `beforeRead`
645
- * hooks for query scoping. See docs/AUTHN-AUTHZ.md.
667
+ * hooks for query scoping. See docs/06-auth-and-security/01-authn-authz.md.
646
668
  */
647
669
  requestContext?: RequestContext;
648
670
  /**
@@ -696,7 +718,7 @@ export interface IDocumentQueries {
696
718
  *
697
719
  * Source-locale only: this resolves the single canonical slug, which is the
698
720
  * only path row a document has today. When per-locale paths land (see
699
- * docs/DOCUMENT-PATHS.md → "Phase — per-locale paths"), the write-side hook
721
+ * docs/04-collections/04-document-paths.md → "Phase — per-locale paths"), the write-side hook
700
722
  * contexts that consume this must be enriched to carry the locale each path
701
723
  * was derived under (or the full `locale → path` set) — a single canonical
702
724
  * `path` is no longer sufficient for per-localised-URL cache/CDN purges.
@@ -925,7 +947,7 @@ export interface IDocumentQueries {
925
947
  * parent, increasing toward the root). Empty for a root or unplaced node.
926
948
  *
927
949
  * Backs breadcrumbs and the read-time hierarchical-URL canonicalization
928
- * (docs/DOCUMENT-TREE.md). Depth-bounded as a backstop against pathological
950
+ * (docs/04-collections/03-document-trees.md). Depth-bounded as a backstop against pathological
929
951
  * key state even though the write-path cycle guard prevents true cycles.
930
952
  *
931
953
  * **Status-at-edge.** `readMode: 'published'` joins
@@ -950,7 +972,7 @@ export interface IDocumentQueries {
950
972
  * `order_key`. `parentDocumentId: null` returns the collection's root nodes.
951
973
  * Scoped to `collectionId` so root reads (which have no parent to scope by)
952
974
  * stay within the collection. One level only — the recursive subtree read is
953
- * a separate query. See docs/DOCUMENT-TREE.md.
975
+ * a separate query. See docs/04-collections/03-document-trees.md.
954
976
  */
955
977
  getTreeChildren(params: {
956
978
  collectionId: string;
@@ -961,7 +983,7 @@ export interface IDocumentQueries {
961
983
  }>>;
962
984
  /**
963
985
  * Resolve a single node's placement state in the tree, distinguishing the
964
- * three tri-states (docs/DOCUMENT-TREE.md → "Node placement"):
986
+ * three tri-states (docs/04-collections/03-document-trees.md → "Node placement"):
965
987
  *
966
988
  * - **Unplaced** (no edge row) → `{ placed: false, parentDocumentId: null }`
967
989
  * - **Root** (edge row, null parent) → `{ placed: true, parentDocumentId: null }`
@@ -993,7 +1015,7 @@ export interface IDocumentQueries {
993
1015
  * `byline_current_published_documents` and drops any node without a current
994
1016
  * published version; because the walk only recurses through *included*
995
1017
  * nodes, an unpublished node's entire subtree is omitted — the spine is
996
- * broken and descendants are not promoted (see docs/DOCUMENT-TREE.md).
1018
+ * broken and descendants are not promoted (see docs/04-collections/03-document-trees.md).
997
1019
  * `readMode: 'any'` (the default) includes every current, non-deleted node.
998
1020
  * Depth-bounded by `maxDepth` as a backstop.
999
1021
  *
@@ -21,13 +21,35 @@ export interface FilterOperators {
21
21
  $in?: Array<string | number>;
22
22
  $nin?: Array<string | number>;
23
23
  }
24
+ /**
25
+ * Quantifiers usable on a relation field's predicate value — the multi-target
26
+ * (`hasMany`) analogue of a nested sub-where. Each key carries a nested
27
+ * `QueryPredicate` evaluated against the relation's *targets*:
28
+ *
29
+ * - `$some` — at least one target matches (a bare nested sub-where on a
30
+ * relation field is shorthand for this).
31
+ * - `$every` — no target fails the predicate. Vacuously true when the
32
+ * document has no targets on the field (Prisma-style semantics).
33
+ * - `$none` — no target matches. `$none: {}` matches documents with no
34
+ * (resolving) targets at all.
35
+ *
36
+ * Multiple quantifier keys on one field AND together. Also valid on single
37
+ * relations (a set of ≤ 1). Targets that don't resolve in the selected read
38
+ * view (deleted, or unpublished under published-mode reads) are ignored.
39
+ */
40
+ export interface RelationQuantifiers {
41
+ $some?: QueryPredicate;
42
+ $every?: QueryPredicate;
43
+ $none?: QueryPredicate;
44
+ }
24
45
  /**
25
46
  * The value side of a predicate entry. Either a bare value (interpreted as
26
47
  * `$eq`), an operator object, a nested `QueryPredicate` (for cross-collection
27
- * relation filters or combinator children), or an array of `QueryPredicate`
28
- * (the value side of `$and` / `$or`).
48
+ * relation filters or combinator children), a `RelationQuantifiers` object
49
+ * (relation fields only), or an array of `QueryPredicate` (the value side of
50
+ * `$and` / `$or`).
29
51
  */
30
- export type PredicateValue = string | number | boolean | null | FilterOperators | QueryPredicate | QueryPredicate[];
52
+ export type PredicateValue = string | number | boolean | null | FilterOperators | RelationQuantifiers | QueryPredicate | QueryPredicate[];
31
53
  /**
32
54
  * Structured predicate language used by:
33
55
  * - the client API's `where` clause (re-exported as `WhereClause`),
@@ -200,7 +200,14 @@ export interface SearchFacetBucket {
200
200
  /** The result envelope returned by `SearchProvider.search`. */
201
201
  export interface SearchResults {
202
202
  hits: SearchHit[];
203
- /** Total matches across all pages (not just the returned `hits`). */
203
+ /**
204
+ * Total matches across all pages (not just the returned `hits`).
205
+ *
206
+ * This is the *provider's* count. When the querying collection applies
207
+ * `beforeRead` row scoping, unauthorized hits are dropped **after**
208
+ * ranking (see `CollectionHandle.search`), so `total` — like facet
209
+ * counts — is approximate under scoping and exact without it.
210
+ */
204
211
  total: number;
205
212
  /**
206
213
  * Facet buckets keyed by field name, when `facets` was requested and the
@@ -64,7 +64,7 @@ export interface BaseConfig {
64
64
  * its path locale, and the completeness ledger — so changing this value
65
65
  * is safe for existing data: they keep reading against the locale they
66
66
  * were authored in. New documents created after the change anchor to the
67
- * new value. See docs/I18N.md.
67
+ * new value. See docs/07-internationalization/index.md.
68
68
  *
69
69
  * (Switching this on a live system still needs the one-time
70
70
  * `backfillSourceLocales()` maintenance step to have stamped any rows
@@ -103,7 +103,7 @@ export interface BaseConfig {
103
103
  *
104
104
  * Optional at the type level so `BaseConfig` stays loose for tests
105
105
  * and seed scripts; required at runtime via `validateTranslations`
106
- * whenever `interface.locales` is non-empty. See `docs/I18N.md` for
106
+ * whenever `interface.locales` is non-empty. See `docs/07-internationalization/index.md` for
107
107
  * the design.
108
108
  *
109
109
  * The shape is declared inline (rather than imported from
@@ -33,6 +33,6 @@ import { type CollectionAbilityVerb } from './register-collection-abilities.js';
33
33
  * bypass this helper — the same escape hatch that skips collection
34
34
  * hooks. Seeds, migrations, and internal tooling live there.
35
35
  *
36
- * See docs/AUTHN-AUTHZ.md.
36
+ * See docs/06-auth-and-security/01-authn-authz.md.
37
37
  */
38
38
  export declare function assertActorCanPerform(context: RequestContext | undefined, collectionPath: string, verb: CollectionAbilityVerb): void;
@@ -33,7 +33,7 @@ import { collectionAbilityKey, } from './register-collection-abilities.js';
33
33
  * bypass this helper — the same escape hatch that skips collection
34
34
  * hooks. Seeds, migrations, and internal tooling live there.
35
35
  *
36
- * See docs/AUTHN-AUTHZ.md.
36
+ * See docs/06-auth-and-security/01-authn-authz.md.
37
37
  */
38
38
  export function assertActorCanPerform(context, collectionPath, verb) {
39
39
  if (!context) {
@@ -32,7 +32,7 @@ import type { CollectionDefinition } from '../@types/index.js';
32
32
  * predictable and avoids hidden conditional logic downstream.
33
33
  *
34
34
  * Called from `initBylineCore()` for each declared collection. See
35
- * docs/AUTHN-AUTHZ.md.
35
+ * docs/06-auth-and-security/01-authn-authz.md.
36
36
  */
37
37
  export declare function registerCollectionAbilities(registry: AbilityRegistry, definition: CollectionDefinition): void;
38
38
  /** The ability suffixes that every collection contributes. Exposed for contract tests. */
@@ -30,7 +30,7 @@
30
30
  * predictable and avoids hidden conditional logic downstream.
31
31
  *
32
32
  * Called from `initBylineCore()` for each declared collection. See
33
- * docs/AUTHN-AUTHZ.md.
33
+ * docs/06-auth-and-security/01-authn-authz.md.
34
34
  */
35
35
  export function registerCollectionAbilities(registry, definition) {
36
36
  const path = definition.path;
@@ -47,7 +47,7 @@ export declare function getServerConfig(): ServerConfig;
47
47
  * config-driven at the read source. The payoff is canonical downstream
48
48
  * ordering (display switcher, hreflang `alternates`, sitemap) regardless of
49
49
  * the order a document declared its locales in. Read-time projection only;
50
- * nothing persisted changes. See docs/I18N.md.
50
+ * nothing persisted changes. See docs/07-internationalization/index.md.
51
51
  */
52
52
  export declare function orderByContentLocale(codes: string[]): string[];
53
53
  export declare function defineBylineCore(core: unknown): void;
@@ -124,7 +124,7 @@ export function getServerConfig() {
124
124
  * config-driven at the read source. The payoff is canonical downstream
125
125
  * ordering (display switcher, hreflang `alternates`, sitemap) regardless of
126
126
  * the order a document declared its locales in. Read-time projection only;
127
- * nothing persisted changes. See docs/I18N.md.
127
+ * nothing persisted changes. See docs/07-internationalization/index.md.
128
128
  */
129
129
  export function orderByContentLocale(codes) {
130
130
  const content = getServerConfigInstance()?.i18n?.content;
package/dist/core.d.ts CHANGED
@@ -38,7 +38,7 @@ export interface BylineCore<TAdminStore = unknown> {
38
38
  * during server bootstrap and before any admin UI renders.
39
39
  *
40
40
  * Consumed at runtime by `AdminAuth.assertAbility()` and at design
41
- * time by the admin role-editor UI. See docs/AUTHN-AUTHZ.md.
41
+ * time by the admin role-editor UI. See docs/06-auth-and-security/01-authn-authz.md.
42
42
  */
43
43
  abilities: AbilityRegistry;
44
44
  /** Convenience wrapper around `abilities.register()`. */
package/dist/core.js CHANGED
@@ -103,7 +103,7 @@ export const initBylineCore = async (config, pinoLogger) => {
103
103
  // and is a no-op (zero rows) once every document is stamped. Self-heals
104
104
  // in-place upgrades without a manual maintenance step. The write path stamps
105
105
  // new documents directly, so steady-state boots touch nothing. See
106
- // docs/I18N.md.
106
+ // docs/07-internationalization/index.md.
107
107
  if (typeof composed.db.backfillSourceLocales === 'function') {
108
108
  const { rowsUpdated } = await composed.db.backfillSourceLocales();
109
109
  if (rowsUpdated > 0) {
@@ -111,6 +111,6 @@ export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstruc
111
111
  * `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
112
112
  * user error: an auditability guarantee cannot be honoured non-atomically, so
113
113
  * the write is refused loudly rather than recorded with a gap. See
114
- * docs/TRANSACTIONS.md and docs/AUDIT.md.
114
+ * docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
115
115
  */
116
116
  export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -147,6 +147,6 @@ export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn
147
147
  * `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
148
148
  * user error: an auditability guarantee cannot be honoured non-atomically, so
149
149
  * the write is refused loudly rather than recorded with a gap. See
150
- * docs/TRANSACTIONS.md and docs/AUDIT.md.
150
+ * docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
151
151
  */
152
152
  export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTED);
@@ -249,12 +249,19 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
249
249
  const field = definition.fields.find((f) => f.name === key);
250
250
  if (!field)
251
251
  continue; // Unknown field — skip silently
252
- // Relation field with a plain-object sub-clause cross-collection filter.
253
- // A "plain object with no $-prefixed top-level keys" is unambiguously a
254
- // nested where; anything else (bare value, operator object) stays in the
255
- // ordinary field-filter path below and matches the relation's
252
+ // Relation field with a quantifier object (`$some` / `$every` / `$none`)
253
+ // or a plain-object sub-clause cross-collection filter(s).
254
+ //
255
+ // - `{ gallery: { $some: { } } }` — explicit quantifier(s) over the
256
+ // relation's target set; multiple quantifier keys AND together.
257
+ // - `{ category: { path: 'news' } }` — a plain object with no
258
+ // $-prefixed top-level keys is unambiguously a nested where,
259
+ // shorthand for `$some`.
260
+ //
261
+ // Anything else (bare value, operator object) stays in the ordinary
262
+ // field-filter path below and matches the relation's
256
263
  // target_document_id column directly.
257
- if (field.type === 'relation' && isPlainSubWhere(rawValue)) {
264
+ if (field.type === 'relation' && (isQuantifierObject(rawValue) || isPlainSubWhere(rawValue))) {
258
265
  if (!ctx) {
259
266
  // No way to resolve the target without a ParseContext. Direct
260
267
  // callers of `parseWhere` (tests, tooling) can legitimately hit
@@ -273,21 +280,35 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
273
280
  continue;
274
281
  }
275
282
  const targetCollectionId = await ctx.resolveCollectionId(targetPath);
276
- const nested = await parseWhereInternal(rawValue, targetDef, ctx, {
277
- isNested: true,
278
- inCombinator: false,
279
- });
280
- // Splice nested filters straight in. The nested parse runs with
281
- // `isNested: true`, which promotes `status` / `path` into
282
- // `DocumentColumnFilter` entries the adapter resolves against the
283
- // target version's columns (`td${depth}.status` / `td${depth}.path`)
284
- // via the inner relation scope. `query` is dropped one level up.
285
- result.filters.push({
286
- kind: 'relation',
287
- fieldName: key,
288
- targetCollectionId,
289
- nested: nested.filters,
290
- });
283
+ // Normalise both forms to a list of (quantifier, sub-where) pairs
284
+ // the plain sub-where is shorthand for a single `$some`.
285
+ const quantified = isQuantifierObject(rawValue)
286
+ ? Object.entries(rawValue).map(([qKey, sub]) => ({
287
+ quantifier: qKey.slice(1),
288
+ sub: sub,
289
+ }))
290
+ : [{ quantifier: 'some', sub: rawValue }];
291
+ for (const { quantifier, sub } of quantified) {
292
+ const nested = await parseWhereInternal(sub, targetDef, ctx, {
293
+ isNested: true,
294
+ inCombinator: false,
295
+ });
296
+ // Splice nested filters straight in. The nested parse runs with
297
+ // `isNested: true`, which promotes `status` / `path` into
298
+ // `DocumentColumnFilter` entries the adapter resolves against the
299
+ // target version's columns (`td${depth}.status` / `td${depth}.path`)
300
+ // via the inner relation scope. `query` is dropped one level up.
301
+ // `hasMany: false` / `quantifier: 'some'` are the adapter defaults —
302
+ // omit them so the plain single-relation filter shape is unchanged.
303
+ result.filters.push({
304
+ kind: 'relation',
305
+ fieldName: key,
306
+ targetCollectionId,
307
+ nested: nested.filters,
308
+ ...(relation.hasMany === true ? { hasMany: true } : {}),
309
+ ...(quantifier === 'some' ? {} : { quantifier }),
310
+ });
311
+ }
291
312
  continue;
292
313
  }
293
314
  const storeInfo = fieldTypeToStore[field.type];
@@ -350,6 +371,36 @@ export function parseSort(sort, definition) {
350
371
  * than a comparison value.
351
372
  */
352
373
  const COMBINATOR_KEYS = new Set(['$and', '$or']);
374
+ /**
375
+ * Relation quantifier keys (`{ gallery: { $some: { … } } }`). Only
376
+ * meaningful on relation fields; each key's value is a nested sub-where
377
+ * evaluated against the relation's targets.
378
+ */
379
+ const QUANTIFIER_KEYS = new Set(['$some', '$every', '$none']);
380
+ /**
381
+ * A quantifier object is a non-null, non-array object whose keys are *all*
382
+ * quantifier keys (`$some` / `$every` / `$none`), each carrying a nested
383
+ * sub-where object (empty allowed — `$none: {}` means "no targets at all").
384
+ * Mixing quantifier keys with field names or operators is not a recognised
385
+ * shape and falls through to the ordinary operator path (which rejects it).
386
+ */
387
+ function isQuantifierObject(raw) {
388
+ if (raw === null || typeof raw !== 'object')
389
+ return false;
390
+ if (Array.isArray(raw))
391
+ return false;
392
+ const keys = Object.keys(raw);
393
+ if (keys.length === 0)
394
+ return false;
395
+ for (const k of keys) {
396
+ if (!QUANTIFIER_KEYS.has(k))
397
+ return false;
398
+ const v = raw[k];
399
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
400
+ return false;
401
+ }
402
+ return true;
403
+ }
353
404
  /**
354
405
  * A "plain sub-where" is a non-null, non-array object whose top-level keys
355
406
  * are field names or predicate-level combinators (`$and` / `$or`) — but
@@ -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(),
@@ -72,4 +72,13 @@ export interface BuildSearchDocumentOptions {
72
72
  * Assemble one type-enriched `SearchDocument` from a locale-resolved
73
73
  * document and its collection's role-based `search` config.
74
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;
75
84
  export declare function buildSearchDocument(doc: SearchSourceDocument, definition: CollectionDefinition, options?: BuildSearchDocumentOptions): SearchDocument;
@@ -40,12 +40,26 @@ import { resolveIdentityField } from './populate.js';
40
40
  * Assemble one type-enriched `SearchDocument` from a locale-resolved
41
41
  * document and its collection's role-based `search` config.
42
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
+ }
43
57
  export function buildSearchDocument(doc, definition, options = {}) {
44
58
  const locale = options.locale ?? doc.locale;
45
59
  const search = definition.search ?? {};
46
60
  const fieldsData = doc.fields ?? {};
47
61
  const title = stringValue(resolveLocalized(fieldsData[resolveIdentityField(definition) ?? ''], locale)) ?? '';
48
- const zones = search.zones != null && search.zones.length > 0 ? search.zones : [definition.path];
62
+ const zones = resolveSearchZones(definition) ?? [definition.path];
49
63
  const fields = [];
50
64
  // --- body: searchable text -------------------------------------------------
51
65
  for (const decl of search.body ?? []) {
@@ -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*
@@ -43,7 +43,7 @@ export declare function updateDocument(ctx: DocumentLifecycleContext, params: {
43
43
  * The editorial advertised-locale set. `undefined` leaves the existing
44
44
  * set untouched (sticky — document-grain, like `path`); an explicit array
45
45
  * (empty included) replaces it wholesale. Driven by the admin
46
- * available-locales sidebar widget. See docs/I18N.md.
46
+ * available-locales sidebar widget. See docs/07-internationalization/index.md.
47
47
  */
48
48
  availableLocales?: string[];
49
49
  }): Promise<UpdateDocumentResult>;
@@ -78,7 +78,7 @@ export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext,
78
78
  * The editorial advertised-locale set (typically supplied alongside
79
79
  * patches when the admin available-locales widget has been edited).
80
80
  * `undefined` leaves the existing set untouched (sticky); an explicit
81
- * array replaces it wholesale. See docs/I18N.md.
81
+ * array replaces it wholesale. See docs/07-internationalization/index.md.
82
82
  */
83
83
  availableLocales?: string[];
84
84
  }): Promise<UpdateDocumentWithPatchesResult>;
@@ -36,7 +36,7 @@ function createMockDb() {
36
36
  const getDocumentById = vi.fn().mockResolvedValue(null);
37
37
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
38
38
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
39
- // Audit capability (docs/AUDIT.md — W2). `withTransaction` is a passthrough
39
+ // Audit capability (docs/06-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
40
40
  // in unit tests (runs the unit of work immediately, no real tx); `append`
41
41
  // records the calls so write-point tests can assert the audit rows emitted.
42
42
  const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
@@ -165,7 +165,7 @@ describe('Document lifecycle service', () => {
165
165
  data: { title: 'Hello' },
166
166
  locale: 'en',
167
167
  });
168
- // Audit contract (docs/AUDIT.md — W1): every version row
168
+ // Audit contract (docs/06-auth-and-security/02-auditability.md — W1): every version row
169
169
  // records the actor that created it.
170
170
  expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
171
171
  });
@@ -703,7 +703,7 @@ describe('Document lifecycle service', () => {
703
703
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
704
704
  const ctx = buildCtx(db);
705
705
  await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
706
- // The mutation + audit row run inside one withTransaction (docs/AUDIT.md).
706
+ // The mutation + audit row run inside one withTransaction (docs/06-auth-and-security/02-auditability.md).
707
707
  expect(withTransaction).toHaveBeenCalledOnce();
708
708
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
709
709
  documentId: 'doc-1',
@@ -8,7 +8,7 @@
8
8
  /**
9
9
  * `documentToMarkdown` — the document-grain markdown assembler for the
10
10
  * agent-readable export surface (`.md` routes, `llms.txt`). See
11
- * docs/TODO.md → "Markdown export".
11
+ * TODO-INTERNAL.md → "Markdown export".
12
12
  *
13
13
  * A page is a composite (text, richtext, blocks, arrays, relations); this
14
14
  * walks the collection's schema and the locale-resolved field data in
@@ -8,7 +8,7 @@
8
8
  /**
9
9
  * `documentToMarkdown` — the document-grain markdown assembler for the
10
10
  * agent-readable export surface (`.md` routes, `llms.txt`). See
11
- * docs/TODO.md → "Markdown export".
11
+ * TODO-INTERNAL.md → "Markdown export".
12
12
  *
13
13
  * A page is a composite (text, richtext, blocks, arrays, relations); this
14
14
  * walks the collection's schema and the locale-resolved field data in
@@ -9,7 +9,7 @@
9
9
  * Contract tests for the document-grain markdown assembler. The expected
10
10
  * strings ARE the format contract — agents build on this shape, so a
11
11
  * change here is a consumer-visible format change and should be
12
- * deliberate (docs/TODO.md → "The output is a contract surface").
12
+ * deliberate (TODO-INTERNAL.md → "The output is a contract surface").
13
13
  */
14
14
  import { describe, expect, it } from 'vitest';
15
15
  import { documentToMarkdown } from './document-to-markdown.js';
@@ -1,7 +1,7 @@
1
1
  export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
2
2
  export { normaliseDateFields } from '../utils/normalise-dates.js';
3
3
  export { type AssignCounterValuesInput, assignCounterValues } from './assign-counter-values.js';
4
- export { type BuildSearchDocumentOptions, buildSearchDocument, type SearchSourceDocument, } from './build-search-document.js';
4
+ export { type BuildSearchDocumentOptions, buildSearchDocument, resolveSearchZones, type SearchSourceDocument, } from './build-search-document.js';
5
5
  export { type CollectionRecord, type EnsureCollectionsInput, ensureCollections, } from './collection-bootstrap.js';
6
6
  export { type DiscoverCounterGroupsInput, discoverCounterGroups, } from './discover-counter-groups.js';
7
7
  export * from './document-lifecycle/index.js';
@@ -2,7 +2,7 @@
2
2
  export { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION, } from '../lib/errors.js';
3
3
  export { normaliseDateFields } from '../utils/normalise-dates.js';
4
4
  export { assignCounterValues } from './assign-counter-values.js';
5
- export { buildSearchDocument, } from './build-search-document.js';
5
+ export { buildSearchDocument, resolveSearchZones, } from './build-search-document.js';
6
6
  export { ensureCollections, } from './collection-bootstrap.js';
7
7
  export { discoverCounterGroups, } from './discover-counter-groups.js';
8
8
  export * from './document-lifecycle/index.js';
@@ -25,7 +25,7 @@
25
25
  * documents. The guard is in place from day one so that the hook work
26
26
  * in Phase 4+ cannot reintroduce the problem.
27
27
  *
28
- * See docs/RELATIONSHIPS.md for the full design rationale.
28
+ * See docs/04-collections/02-relationships.md for the full design rationale.
29
29
  *
30
30
  * ---------------------------------------------------------------------
31
31
  * DSL summary
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.15.2",
5
+ "version": "3.16.1",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -73,19 +73,17 @@
73
73
  ],
74
74
  "dependencies": {
75
75
  "dotenv": "^17.4.2",
76
- "jose": "^6.2.3",
77
- "npm-run-all": "^4.1.5",
78
76
  "pino": "^10.3.1",
79
77
  "sharp": "^0.34.5",
80
- "uuid": "^14.0.0",
81
78
  "zod": "^4.4.3",
82
- "@byline/auth": "3.15.2"
79
+ "@byline/auth": "3.16.1"
83
80
  },
84
81
  "devDependencies": {
85
82
  "@biomejs/biome": "2.4.15",
86
83
  "@types/node": "^25.9.1",
87
84
  "chokidar": "^5.0.0",
88
85
  "chokidar-cli": "^3.0.0",
86
+ "npm-run-all": "^4.1.5",
89
87
  "tsc-alias": "^1.8.17",
90
88
  "tsx": "^4.22.3",
91
89
  "typescript": "6.0.3",