@byline/core 4.7.0 → 4.9.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 (40) hide show
  1. package/dist/@types/collection-types.d.ts +4 -4
  2. package/dist/@types/db-types.d.ts +22 -11
  3. package/dist/@types/search-types.d.ts +60 -11
  4. package/dist/@types/search-types.js +2 -1
  5. package/dist/@types/site-config.d.ts +7 -7
  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/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/errors.d.ts +27 -1
  17. package/dist/lib/errors.js +20 -1
  18. package/dist/schemas/zod/builder.js +2 -2
  19. package/dist/services/build-search-document.d.ts +1 -1
  20. package/dist/services/build-search-document.js +1 -1
  21. package/dist/services/document-lifecycle/audit.d.ts +1 -1
  22. package/dist/services/document-lifecycle/audit.js +2 -2
  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 +1 -1
  27. package/dist/services/document-lifecycle/duplicate.js +2 -2
  28. package/dist/services/document-lifecycle/internals.d.ts +10 -12
  29. package/dist/services/document-lifecycle/internals.js +18 -25
  30. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +8 -0
  31. package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +41 -0
  32. package/dist/services/document-lifecycle/status.js +1 -1
  33. package/dist/services/document-lifecycle/system-fields.d.ts +1 -1
  34. package/dist/services/document-lifecycle/system-fields.js +2 -2
  35. package/dist/services/document-lifecycle/update.d.ts +2 -2
  36. package/dist/services/document-lifecycle/update.js +2 -2
  37. package/dist/services/document-lifecycle.test.node.js +17 -4
  38. package/dist/services/validate-search-config.js +2 -2
  39. package/dist/storage/store-manifest.js +4 -0
  40. package/package.json +2 -2
@@ -844,7 +844,7 @@ export interface AfterReadContext {
844
844
  * - `collectionPath` — the collection being queried (useful when the
845
845
  * same hook function is reused across collections).
846
846
  *
847
- * See `docs/06-auth-and-security/01-authn-authz.md` for the strategic rationale; the Quick
847
+ * See `docs/07-auth-and-security/01-authn-authz.md` for the strategic rationale; the Quick
848
848
  * Reference there carries six worked recipes.
849
849
  */
850
850
  export interface BeforeReadContext {
@@ -937,7 +937,7 @@ export interface CollectionHooks {
937
937
  * that the query layer ANDs onto the caller's `where` to enforce
938
938
  * read-side row scoping (multi-tenant, owner-only-drafts, soft-delete
939
939
  * hide, etc). Returning `void` applies no scoping. Multiple functions
940
- * combine with implicit AND. See `docs/06-auth-and-security/01-authn-authz.md` (Read-side
940
+ * combine with implicit AND. See `docs/07-auth-and-security/01-authn-authz.md` (Read-side
941
941
  * scoping + Quick Reference recipes).
942
942
  */
943
943
  beforeRead?: BeforeReadHookSlot;
@@ -1042,7 +1042,7 @@ export interface CollectionDefinition {
1042
1042
  * of what to index. The implementor names fields by the role they play;
1043
1043
  * core derives each field's type from the schema and assembles the
1044
1044
  * type-enriched `SearchDocument` (see the `SearchProvider` seam in
1045
- * `docs/05-reading-and-delivery/07-search.md`). Nothing is auto-pulled, so
1045
+ * `docs/06-search/01-configuration.md`). Nothing is auto-pulled, so
1046
1046
  * unindexed content (editorial notes, internal fields) never leaks into
1047
1047
  * the index.
1048
1048
  *
@@ -1131,7 +1131,7 @@ export interface CollectionDefinition {
1131
1131
  * collection has at least one `localized` field, so the validator rejects
1132
1132
  * `advertiseLocales: true` on a collection with none.
1133
1133
  *
1134
- * See `docs/07-internationalization/index.md`.
1134
+ * See `docs/08-internationalization/index.md`.
1135
1135
  */
1136
1136
  advertiseLocales?: boolean;
1137
1137
  /**
@@ -1,5 +1,6 @@
1
1
  import type { RequestContext } from '@byline/auth';
2
2
  import type { CollectionDefinition } from '@byline/core';
3
+ import type { DbErrorClassification } from '../lib/errors.js';
3
4
  import type { QueryPredicate } from './query-predicate.js';
4
5
  /**
5
6
  * Read mode for document queries.
@@ -39,7 +40,7 @@ export type ReadMode = 'any' | 'published';
39
40
  * safe default for internal/direct reads); `@byline/client` defaults it to
40
41
  * `'fallback'` for application reads. Availability follows path-coverage against
41
42
  * the default content locale; a document with no localized content is available
42
- * in every locale. See `docs/07-internationalization/index.md`.
43
+ * in every locale. See `docs/08-internationalization/index.md`.
43
44
  */
44
45
  export type MissingLocalePolicy = 'empty' | 'fallback' | 'omit';
45
46
  /**
@@ -240,16 +241,26 @@ export interface IDbAdapter {
240
241
  * boot by `initBylineCore` so in-place upgrades self-heal without a manual
241
242
  * step or a migrate-ordering constraint — a no-op (zero rows) once every
242
243
  * document is stamped. Optional so adapters that don't model `source_locale`
243
- * need not implement it. See docs/07-internationalization/index.md.
244
+ * need not implement it. See docs/08-internationalization/index.md.
244
245
  */
245
246
  backfillSourceLocales?: () => Promise<{
246
247
  rowsUpdated: number;
247
248
  }>;
249
+ /**
250
+ * Classify a raw driver error into an adapter-agnostic, code-based shape so
251
+ * core can map database failures to domain errors without knowing driver
252
+ * anatomy. The error-side analogue of the storage `normalizeRow` seam.
253
+ *
254
+ * Optional: when absent, core treats every error as `DB_UNKNOWN` and rethrows
255
+ * it unchanged — the current behaviour for adapters that do not implement it.
256
+ * Canonical adapters (db-postgres, db-mysql) implement it.
257
+ */
258
+ classifyError?(err: unknown): DbErrorClassification;
248
259
  }
249
260
  /**
250
261
  * The realm of the actor that performed an audited change. `'admin'` for
251
262
  * admin-user actions, `'user'` reserved for the end-user realm, `'system'`
252
- * for deliberate internal-tooling writes. See docs/06-auth-and-security/02-auditability.md.
263
+ * for deliberate internal-tooling writes. See docs/07-auth-and-security/02-auditability.md.
253
264
  */
254
265
  export type AuditActorRealm = 'admin' | 'user' | 'system';
255
266
  /** Input to `IAuditCommands.append` — one audit-log row. */
@@ -294,7 +305,7 @@ export interface AuditLogPage {
294
305
  }
295
306
  /**
296
307
  * Append-only audit-log writes. The companion read interface is
297
- * `IAuditQueries`. See docs/06-auth-and-security/02-auditability.md — Workstream 2.
308
+ * `IAuditQueries`. See docs/07-auth-and-security/02-auditability.md — Workstream 2.
298
309
  */
299
310
  export interface IAuditCommands {
300
311
  /**
@@ -307,7 +318,7 @@ export interface IAuditCommands {
307
318
  id: string;
308
319
  }>;
309
320
  }
310
- /** Audit-log reads. See docs/06-auth-and-security/02-auditability.md — Workstreams 3 & 4. */
321
+ /** Audit-log reads. See docs/07-auth-and-security/02-auditability.md — Workstreams 3 & 4. */
311
322
  export interface IAuditQueries {
312
323
  /**
313
324
  * The audit history for one document, newest first, paged. Backs the
@@ -321,7 +332,7 @@ export interface IAuditQueries {
321
332
  page_size?: number;
322
333
  }): Promise<AuditLogPage>;
323
334
  /**
324
- * The system-wide activity feed (docs/06-auth-and-security/02-auditability.md — Workstream 4), newest
335
+ * The system-wide activity feed (docs/07-auth-and-security/02-auditability.md — Workstream 4), newest
325
336
  * first, paged and filterable. A read-time **union** of two disjoint event
326
337
  * sources, normalised onto the `AuditLogEntry` shape:
327
338
  *
@@ -506,7 +517,7 @@ export interface IDocumentCommands {
506
517
  * editorial advertised-locale set. `undefined` leaves the existing set
507
518
  * untouched (sticky across versions, like `path`); `[]` clears it. The
508
519
  * locale values are the advertised content locales themselves, not the
509
- * write locale. See `docs/07-internationalization/index.md`.
520
+ * write locale. See `docs/08-internationalization/index.md`.
510
521
  */
511
522
  availableLocales?: string[];
512
523
  locale?: string;
@@ -538,7 +549,7 @@ export interface IDocumentCommands {
538
549
  * the change is immediate and applies across every version. Backs the admin
539
550
  * path widget's direct-write Save. The unique constraint on
540
551
  * `(collection_id, locale, path)` may surface as `ERR_PATH_CONFLICT` from the
541
- * lifecycle layer. See `docs/07-internationalization/index.md`.
552
+ * lifecycle layer. See `docs/08-internationalization/index.md`.
542
553
  */
543
554
  updateDocumentPath(params: {
544
555
  documentId: string;
@@ -552,7 +563,7 @@ export interface IDocumentCommands {
552
563
  * wholesale **without** minting a new version or touching workflow status —
553
564
  * the change is immediate and applies across every version. `[]` clears it.
554
565
  * Backs the admin available-locales widget's direct-write Save. See
555
- * `docs/07-internationalization/index.md`.
566
+ * `docs/08-internationalization/index.md`.
556
567
  */
557
568
  setDocumentAvailableLocales(params: {
558
569
  documentId: string;
@@ -606,7 +617,7 @@ export interface IDocumentCommands {
606
617
  documentId: string;
607
618
  locale: string;
608
619
  status?: string;
609
- /** Acting user id for the version audit trail (`created_by`). See docs/06-auth-and-security/02-auditability.md. */
620
+ /** Acting user id for the version audit trail (`created_by`). See docs/07-auth-and-security/02-auditability.md. */
610
621
  createdBy?: string;
611
622
  }): Promise<{
612
623
  newVersionId: string;
@@ -720,7 +731,7 @@ export interface IDocumentQueries {
720
731
  /**
721
732
  * Request-scoped auth context. Adapters thread it through to
722
733
  * `assertActorCanPerform` for ability assertion and to `beforeRead`
723
- * hooks for query scoping. See docs/06-auth-and-security/01-authn-authz.md.
734
+ * hooks for query scoping. See docs/07-auth-and-security/01-authn-authz.md.
724
735
  */
725
736
  requestContext?: RequestContext;
726
737
  /**
@@ -16,18 +16,19 @@
16
16
  * the provider never sees EAV store rows. The document carries a typed,
17
17
  * role-tagged field projection ({@link SearchField}) so a driver can map
18
18
  * each field onto its own index schema (Postgres store columns + weighted
19
- * tsvector, Solr dynamic fields, a vector store's payload, …) without
20
- * re-deriving types. A built-in Postgres full-text driver ships as
21
- * `@byline/search-postgres`; external drivers implement the same interface
22
- * rather than forking the read path.
19
+ * tsvector, MySQL FULLTEXT columns, Solr dynamic fields, a vector store's
20
+ * payload, …) without re-deriving types. Built-in SQL full-text drivers ship as
21
+ * `@byline/search-postgres` and `@byline/search-mysql`; external drivers
22
+ * implement the same interface rather than forking the read path.
23
23
  *
24
- * The provider factory (e.g. `postgresSearch({ getClient })`) lives in the
24
+ * The provider factory (e.g. `postgresSearch({ pool })` or
25
+ * `mysqlSearch({ pool })`) lives in the
25
26
  * driver package, not here — core declares only the interface and its data
26
27
  * types, the same way `RichTextPopulateFn` is declared in core while
27
28
  * `lexicalEditorPopulateServer()` lives in `@byline/richtext-lexical`. This
28
29
  * keeps `@byline/core` from depending on `@byline/client`.
29
30
  *
30
- * See `docs/05-reading-and-delivery/07-search.md` for the full design.
31
+ * See `docs/06-search/04-provider-contract.md` for the full contract.
31
32
  */
32
33
  import type { QueryPredicate } from './query-predicate.js';
33
34
  /**
@@ -138,14 +139,47 @@ export interface SearchDocument {
138
139
  /** ISO-8601 timestamp of the indexed version. */
139
140
  updatedAt: string;
140
141
  }
142
+ /** How independently analyzed query concepts combine for full-text matching. */
143
+ export type SearchTermOperator = 'all' | 'any';
144
+ /**
145
+ * Phrase behavior for a lexical query:
146
+ *
147
+ * - `auto` — quoted spans are phrases; unquoted text follows `operator`.
148
+ * - `required` — the complete non-empty query is also required as a phrase.
149
+ * - `off` — do not create phrase constraints, including for quoted spans.
150
+ */
151
+ export type SearchPhraseMode = 'auto' | 'required' | 'off';
152
+ /**
153
+ * Provider-neutral full-text matching intent. Providers translate this into
154
+ * their native query syntax; it is product behavior, not a ranking hint.
155
+ */
156
+ export interface SearchMatching {
157
+ /** Whether every concept or any concept must match. Defaults to `all`. */
158
+ operator?: SearchTermOperator;
159
+ /**
160
+ * Require at least this many concepts when `operator` is `any`.
161
+ * Providers that cannot express this advertise that limitation through
162
+ * `capabilities.fullText.minimumShouldMatch`.
163
+ */
164
+ minimumShouldMatch?: number;
165
+ /** Phrase handling. Defaults to `auto`. */
166
+ phrase?: SearchPhraseMode;
167
+ }
168
+ /** Maximum UTF-16 length accepted for one full-text query. */
169
+ export declare const MAX_SEARCH_QUERY_LENGTH = 1024;
141
170
  /**
142
171
  * A search request. Either collection-scoped (`collectionPath`) for
143
172
  * homogeneous results, or zone-scoped (`zone`) for heterogeneous
144
173
  * cross-collection results — see the two `client.search()` entry points.
145
174
  */
146
175
  export interface SearchQuery {
147
- /** The free-text query string. */
176
+ /** The free-text query string, limited to {@link MAX_SEARCH_QUERY_LENGTH}. */
148
177
  query: string;
178
+ /**
179
+ * Explicit full-text matching semantics. Defaults to all concepts required
180
+ * with quoted spans treated as phrases.
181
+ */
182
+ matching?: SearchMatching;
149
183
  /**
150
184
  * Cross-collection scope — every collection indexed into this zone.
151
185
  * Mutually exclusive with `collectionPath` in practice.
@@ -239,6 +273,21 @@ export interface SearchCapabilities {
239
273
  weighting: boolean;
240
274
  /** Matched-snippet highlighting in results. */
241
275
  highlights: boolean;
276
+ /** Detailed full-text analysis and matching capabilities. */
277
+ fullText: {
278
+ /** The backend applies its own language analyzer to original text. */
279
+ nativeAnalysis: boolean;
280
+ /** The backend can index application-produced portable logical tokens. */
281
+ portableAnalysis: boolean;
282
+ /** Supports `matching.operator: 'all'`. */
283
+ allTerms: boolean;
284
+ /** Supports `matching.operator: 'any'`. */
285
+ anyTerms: boolean;
286
+ /** Supports `matching.minimumShouldMatch`. */
287
+ minimumShouldMatch: boolean;
288
+ /** Supports ordered phrase constraints. */
289
+ phrase: boolean;
290
+ };
242
291
  }
243
292
  /**
244
293
  * The provider-agnostic search seam. A driver implements indexing
@@ -271,11 +320,11 @@ export interface SearchProvider {
271
320
  /** Execute a query and return ranked hits. */
272
321
  search(query: SearchQuery): Promise<SearchResults>;
273
322
  /**
274
- * Drop and rebuild a collection's slice (or the whole index). Used for
275
- * first-time backfill, driver swaps, and after a `search` config change.
276
- * Optional not every driver supports bulk rebuild.
323
+ * Clear a collection's slice (or the whole derived index) before the caller
324
+ * rebuilds it. Required because search projections are disposable and must
325
+ * be reproducible after analyzer, schema, or provider changes.
277
326
  */
278
- reindex?(opts: {
327
+ reindex(opts: {
279
328
  collectionPath?: string;
280
329
  }): Promise<void>;
281
330
  }
@@ -5,4 +5,5 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- export {};
8
+ /** Maximum UTF-16 length accepted for one full-text query. */
9
+ export const MAX_SEARCH_QUERY_LENGTH = 1_024;
@@ -70,7 +70,7 @@ export interface BaseConfig {
70
70
  * its path locale, and the completeness ledger — so changing this value
71
71
  * is safe for existing data: they keep reading against the locale they
72
72
  * were authored in. New documents created after the change anchor to the
73
- * new value. See docs/07-internationalization/index.md.
73
+ * new value. See docs/08-internationalization/index.md.
74
74
  *
75
75
  * (Switching this on a live system still needs the one-time
76
76
  * `backfillSourceLocales()` maintenance step to have stamped any rows
@@ -109,7 +109,7 @@ export interface BaseConfig {
109
109
  *
110
110
  * Optional at the type level so `BaseConfig` stays loose for tests
111
111
  * and seed scripts; required at runtime via `validateTranslations`
112
- * whenever `interface.locales` is non-empty. See `docs/07-internationalization/index.md` for
112
+ * whenever `interface.locales` is non-empty. See `docs/08-internationalization/index.md` for
113
113
  * the design.
114
114
  *
115
115
  * The shape is declared inline (rather than imported from
@@ -415,10 +415,10 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
415
415
  * beside `db` and `storage`, composed by `initBylineCore()`.
416
416
  *
417
417
  * Drivers ship as separate packages and are constructed via a factory,
418
- * mirroring the richText server adapters. The built-in Postgres
419
- * full-text driver is `@byline/search-postgres`. When any collection
420
- * opts into search (`CollectionDefinition.search`) but no provider is
421
- * registered, `initBylineCore()` fails fast.
418
+ * mirroring the richText server adapters. The built-in SQL full-text drivers
419
+ * are `@byline/search-postgres` and `@byline/search-mysql`. When any
420
+ * collection opts into search (`CollectionDefinition.search`) but no
421
+ * provider is registered, `initBylineCore()` fails fast.
422
422
  *
423
423
  * @example
424
424
  * ```ts
@@ -426,7 +426,7 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
426
426
  *
427
427
  * defineServerConfig({
428
428
  * // ...
429
- * search: postgresSearch({ getClient: getAdminBylineClient }),
429
+ * search: postgresSearch({ pool: db.pool }),
430
430
  * })
431
431
  * ```
432
432
  */
@@ -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/06-auth-and-security/01-authn-authz.md.
36
+ * See docs/07-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/06-auth-and-security/01-authn-authz.md.
36
+ * See docs/07-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/06-auth-and-security/01-authn-authz.md.
35
+ * docs/07-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/06-auth-and-security/01-authn-authz.md.
33
+ * docs/07-auth-and-security/01-authn-authz.md.
34
34
  */
35
35
  export function registerCollectionAbilities(registry, definition) {
36
36
  const path = definition.path;
@@ -51,7 +51,7 @@ export declare function getServerConfig(): ResolvedServerConfig;
51
51
  * config-driven at the read source. The payoff is canonical downstream
52
52
  * ordering (display switcher, hreflang `alternates`, sitemap) regardless of
53
53
  * the order a document declared its locales in. Read-time projection only;
54
- * nothing persisted changes. See docs/07-internationalization/index.md.
54
+ * nothing persisted changes. See docs/08-internationalization/index.md.
55
55
  */
56
56
  export declare function orderByContentLocale(codes: string[]): string[];
57
57
  export declare function defineBylineCore(core: unknown): void;
@@ -147,7 +147,7 @@ export function getServerConfig() {
147
147
  * config-driven at the read source. The payoff is canonical downstream
148
148
  * ordering (display switcher, hreflang `alternates`, sitemap) regardless of
149
149
  * the order a document declared its locales in. Read-time projection only;
150
- * nothing persisted changes. See docs/07-internationalization/index.md.
150
+ * nothing persisted changes. See docs/08-internationalization/index.md.
151
151
  */
152
152
  export function orderByContentLocale(codes) {
153
153
  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/06-auth-and-security/01-authn-authz.md.
41
+ * time by the admin role-editor UI. See docs/07-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
@@ -105,7 +105,7 @@ export const initBylineCore = async (config, pinoLogger) => {
105
105
  // and is a no-op (zero rows) once every document is stamped. Self-heals
106
106
  // in-place upgrades without a manual maintenance step. The write path stamps
107
107
  // new documents directly, so steady-state boots touch nothing. See
108
- // docs/07-internationalization/index.md.
108
+ // docs/08-internationalization/index.md.
109
109
  if (typeof composed.db.backfillSourceLocales === 'function') {
110
110
  const { rowsUpdated } = await composed.db.backfillSourceLocales();
111
111
  if (rowsUpdated > 0) {
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
7
7
  export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
8
8
  export * from './defaults/default-values.js';
9
9
  export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
10
- export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
10
+ export { BylineError, type DbErrorClassification, type DbErrorCode, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
11
11
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
12
12
  export { type BylineLogger, getLogger } from './lib/logger.js';
13
13
  export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
23
23
  export { getBylineCore, initBylineCore } from './core.js';
24
24
  export * from './defaults/default-values.js';
25
25
  export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
26
- export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
26
+ export { BylineError, DbErrorCodes, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
27
27
  export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
28
28
  export { getLogger } from './lib/logger.js';
29
29
  export { AsyncRegistry, Registry } from './lib/registry.js';
@@ -118,7 +118,7 @@ export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstruc
118
118
  * `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
119
119
  * user error: an auditability guarantee cannot be honoured non-atomically, so
120
120
  * the write is refused loudly rather than recorded with a gap. See
121
- * docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
121
+ * docs/03-architecture/03-transactions.md and docs/07-auth-and-security/02-auditability.md.
122
122
  */
123
123
  export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
124
124
  /**
@@ -127,3 +127,29 @@ export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorCons
127
127
  * reconcile, while transports can distinguish it from a rolled-back mutation.
128
128
  */
129
129
  export declare const ERR_TREE_HOOK_COMMITTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
130
+ /**
131
+ * Code-based classification of a raw database driver error, produced by
132
+ * `IDbAdapter.classifyError`. The error-side analogue of the storage
133
+ * `normalizeRow` seam: the adapter canonicalises driver anatomy into these
134
+ * codes so `@byline/core` can map DB failures to domain errors (e.g.
135
+ * `ERR_PATH_CONFLICT`) without knowing any driver's error shape.
136
+ *
137
+ * Distinct from `ErrorCodes` above: those are thrown `BylineError` codes;
138
+ * these are returned classification values. Extend with new codes (e.g. a
139
+ * future `STALE_RECORD` for optimistic-concurrency failures) as consumers
140
+ * need them.
141
+ */
142
+ export declare const DbErrorCodes: {
143
+ readonly UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION';
144
+ readonly UNKNOWN: 'DB_UNKNOWN';
145
+ };
146
+ export type DbErrorCode = (typeof DbErrorCodes)[keyof typeof DbErrorCodes];
147
+ export interface DbErrorClassification {
148
+ code: DbErrorCode;
149
+ /**
150
+ * For `DB_UNIQUE_VIOLATION`: the violated constraint / index name when the
151
+ * driver exposes it (Postgres carries it structurally; MySQL parses it from
152
+ * the error message). Absent when the driver surfaces no name.
153
+ */
154
+ constraint?: string;
155
+ }
@@ -155,7 +155,7 @@ export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn
155
155
  * `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
156
156
  * user error: an auditability guarantee cannot be honoured non-atomically, so
157
157
  * the write is refused loudly rather than recorded with a gap. See
158
- * docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
158
+ * docs/03-architecture/03-transactions.md and docs/07-auth-and-security/02-auditability.md.
159
159
  */
160
160
  export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTED);
161
161
  /**
@@ -164,3 +164,22 @@ export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTE
164
164
  * reconcile, while transports can distinguish it from a rolled-back mutation.
165
165
  */
166
166
  export const ERR_TREE_HOOK_COMMITTED = createErrorType(ErrorCodes.TREE_HOOK_COMMITTED, 'warn');
167
+ // ---------------------------------------------------------------------------
168
+ // Database error classification (adapter seam)
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Code-based classification of a raw database driver error, produced by
172
+ * `IDbAdapter.classifyError`. The error-side analogue of the storage
173
+ * `normalizeRow` seam: the adapter canonicalises driver anatomy into these
174
+ * codes so `@byline/core` can map DB failures to domain errors (e.g.
175
+ * `ERR_PATH_CONFLICT`) without knowing any driver's error shape.
176
+ *
177
+ * Distinct from `ErrorCodes` above: those are thrown `BylineError` codes;
178
+ * these are returned classification values. Extend with new codes (e.g. a
179
+ * future `STALE_RECORD` for optimistic-concurrency failures) as consumers
180
+ * need them.
181
+ */
182
+ export const DbErrorCodes = {
183
+ UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION',
184
+ UNKNOWN: 'DB_UNKNOWN',
185
+ };
@@ -247,7 +247,7 @@ export const createBaseSchema = (collection) => {
247
247
  id: z.uuid(),
248
248
  versionId: z.uuid().optional(),
249
249
  path: z.string().optional(),
250
- // The document's content source-locale anchor (see docs/07-internationalization/index.md).
250
+ // The document's content source-locale anchor (see docs/08-internationalization/index.md).
251
251
  // Carried through list/get responses so the admin can badge it; Zod would
252
252
  // otherwise strip it as an undeclared key.
253
253
  sourceLocale: z.string().optional(),
@@ -255,7 +255,7 @@ export const createBaseSchema = (collection) => {
255
255
  hasPublishedVersion: z.boolean().optional(),
256
256
  createdAt: z.iso.datetime(),
257
257
  updatedAt: z.iso.datetime(),
258
- // Version audit metadata — acting user + action (see docs/06-auth-and-security/02-auditability.md — Workstream 1).
258
+ // Version audit metadata — acting user + action (see docs/07-auth-and-security/02-auditability.md — Workstream 1).
259
259
  // Declared so list/get/history responses carry them through the
260
260
  // server-fn parse; Zod would otherwise strip them as undeclared keys.
261
261
  createdBy: z.uuid().optional(),
@@ -10,7 +10,7 @@
10
10
  * `SearchProvider` seam. Walks a collection's role-based `search` config
11
11
  * against one locale-resolved document and emits a single, type-enriched
12
12
  * `SearchDocument` for a driver to index. See
13
- * `docs/05-reading-and-delivery/07-search.md`.
13
+ * `docs/06-search/04-provider-contract.md`.
14
14
  *
15
15
  * Role-based and explicit: only the fields named in `search.{body,facets,
16
16
  * filters}` are projected — nothing is auto-pulled, so unindexed content
@@ -10,7 +10,7 @@
10
10
  * `SearchProvider` seam. Walks a collection's role-based `search` config
11
11
  * against one locale-resolved document and emits a single, type-enriched
12
12
  * `SearchDocument` for a driver to index. See
13
- * `docs/05-reading-and-delivery/07-search.md`.
13
+ * `docs/06-search/04-provider-contract.md`.
14
14
  *
15
15
  * Role-based and explicit: only the fields named in `search.{body,facets,
16
16
  * filters}` are projected — nothing is auto-pulled, so unindexed content
@@ -51,7 +51,7 @@ export interface TreeAuditCapability extends AuditCapability {
51
51
  * **both** `withTransaction` and `commands.audit`. Returns a non-null
52
52
  * capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
53
53
  * rather than silently skipping the audit row or running it non-atomically.
54
- * See docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
54
+ * See docs/03-architecture/03-transactions.md and docs/07-auth-and-security/02-auditability.md.
55
55
  */
56
56
  export declare function requireAuditCapability(db: IDbAdapter): AuditCapability;
57
57
  /**
@@ -7,7 +7,7 @@
7
7
  */
8
8
  /**
9
9
  * Audit-log write helpers for the document-grain lifecycle write-points
10
- * (docs/06-auth-and-security/02-auditability.md — Workstream 2). The audit log records the changes the
10
+ * (docs/07-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
@@ -46,7 +46,7 @@ export function auditActor(ctx) {
46
46
  * **both** `withTransaction` and `commands.audit`. Returns a non-null
47
47
  * capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
48
48
  * rather than silently skipping the audit row or running it non-atomically.
49
- * See docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
49
+ * See docs/03-architecture/03-transactions.md and docs/07-auth-and-security/02-auditability.md.
50
50
  */
51
51
  export function requireAuditCapability(db) {
52
52
  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/06-auth-and-security/01-authn-authz.md.
76
+ * See docs/07-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/07-internationalization/index.md.
42
+ * the safe opt-in default); `[]` clears it. See docs/08-internationalization/index.md.
43
43
  */
44
44
  availableLocales?: string[];
45
45
  }): Promise<CreateDocumentResult>;
@@ -86,7 +86,7 @@ export async function createDocument(ctx, params) {
86
86
  orderKey,
87
87
  createdBy: actorId(ctx),
88
88
  })
89
- .catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
89
+ .catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale));
90
90
  const documentId = extractDocumentId(result.document);
91
91
  const documentVersionId = extractVersionId(result.document);
92
92
  // `tree: true` collections place every document in the tree by default:
@@ -124,7 +124,7 @@ export async function deleteDocument(ctx, params) {
124
124
  // back, so soft-deleted documents cannot leak live edges.
125
125
  // whole-document delete mints no new version, so the version stream
126
126
  // never records it — the audit log is the only place a deletion is
127
- // accountable (docs/06-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
127
+ // accountable (docs/07-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
128
128
  // DB↔external side-effect and stays OUTSIDE the transaction — it is
129
129
  // post-commit, best-effort compensation (docs/03-architecture/03-transactions.md).
130
130
  const treeAudit = definition.tree === true ? requireTreeAuditCapability(db) : undefined;
@@ -172,7 +172,7 @@ export async function duplicateDocument(ctx, params) {
172
172
  orderKey,
173
173
  createdBy: actorId(ctx),
174
174
  })
175
- .catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
175
+ .catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale));
176
176
  }
177
177
  catch (err) {
178
178
  if (!isPathConflictError(err)) {
@@ -197,7 +197,7 @@ export async function duplicateDocument(ctx, params) {
197
197
  orderKey,
198
198
  createdBy: actorId(ctx),
199
199
  })
200
- .catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
200
+ .catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale));
201
201
  }
202
202
  const newDocumentId = extractDocumentId(result.document);
203
203
  const newDocumentVersionId = extractVersionId(result.document);
@@ -11,7 +11,7 @@
11
11
  * the barrel (`index.ts`), so the package's public surface is unchanged
12
12
  * by the per-operation split.
13
13
  */
14
- import { type CollectionDefinition, type CollectionHookSlot } from '../../@types/index.js';
14
+ import { type CollectionDefinition, type CollectionHookSlot, type IDbAdapter } from '../../@types/index.js';
15
15
  import type { BylineLogger } from '../../lib/logger.js';
16
16
  import type { SlugifierFn } from '../../utils/slugify.js';
17
17
  import type { DocumentLifecycleContext } from './context.js';
@@ -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/06-auth-and-security/02-auditability.md — Workstream 1.
31
+ * is unaffected. See docs/07-auth-and-security/02-auditability.md — Workstream 1.
32
32
  */
33
33
  export declare function actorId(ctx: DocumentLifecycleContext): string | undefined;
34
34
  /**
@@ -64,20 +64,18 @@ export declare function extractVersionId(document: any): string;
64
64
  /** Extract the logical document id from the document object returned by `createDocumentVersion`. */
65
65
  export declare function extractDocumentId(document: any): string;
66
66
  /**
67
- * Detect a Postgres unique-constraint violation on
67
+ * Detect a unique-constraint violation on
68
68
  * `byline_document_paths(collection_id, locale, path)` and translate it
69
69
  * to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
70
70
  *
71
- * The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
72
- * the constraint name on the error object (`constraint`); matching by
73
- * name keeps this targeted to the path constraint and avoids spuriously
74
- * rebranding unrelated unique violations as path conflicts.
75
- *
76
- * Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
77
- * original attached as `cause`, so we walk a short cause chain to find
78
- * the carried `code` / `constraint`.
71
+ * Driver anatomy (SQLSTATE codes, `cause`-chain walking, constraint-name
72
+ * carriage) is delegated to `db.classifyError` the adapter seam that
73
+ * canonicalises a raw driver error into a `DbErrorClassification`. This
74
+ * function only knows the classification codes and the path constraint's
75
+ * name substring; it stays targeted to the path constraint so unrelated
76
+ * unique violations aren't spuriously rebranded as path conflicts.
79
77
  */
80
- export declare function rethrowPathConflict(err: unknown, path: string, locale: string): never;
78
+ export declare function rethrowPathConflict(db: IDbAdapter, err: unknown, path: string, locale: string): never;
81
79
  /**
82
80
  * Detect whether an error is the `ERR_PATH_CONFLICT` raised by
83
81
  * `rethrowPathConflict`. Used by `duplicateDocument`'s retry logic to
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { normalizeCollectionHook, } from '../../@types/index.js';
15
15
  import { getCollectionDefinition, getServerConfig } from '../../config/config.js';
16
- import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
16
+ import { DbErrorCodes, ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
17
  import { generateKeyBetween } from '../../lib/fractional-index.js';
18
18
  import { createReadContext } from '../populate.js';
19
19
  import { embedRichTextFields } from '../richtext-embed.js';
@@ -36,7 +36,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
36
36
  * (the seeds/migrations escape hatch) — both yield `undefined` → NULL
37
37
  * `created_by`, which the history strip renders as "unknown". Real
38
38
  * `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
39
- * is unaffected. See docs/06-auth-and-security/02-auditability.md — Workstream 1.
39
+ * is unaffected. See docs/07-auth-and-security/02-auditability.md — Workstream 1.
40
40
  */
41
41
  export function actorId(ctx) {
42
42
  const id = ctx.requestContext?.actor?.id;
@@ -131,32 +131,25 @@ export function extractDocumentId(document) {
131
131
  return document?.document_id ?? '';
132
132
  }
133
133
  /**
134
- * Detect a Postgres unique-constraint violation on
134
+ * Detect a unique-constraint violation on
135
135
  * `byline_document_paths(collection_id, locale, path)` and translate it
136
136
  * to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
137
137
  *
138
- * The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
139
- * the constraint name on the error object (`constraint`); matching by
140
- * name keeps this targeted to the path constraint and avoids spuriously
141
- * rebranding unrelated unique violations as path conflicts.
142
- *
143
- * Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
144
- * original attached as `cause`, so we walk a short cause chain to find
145
- * the carried `code` / `constraint`.
138
+ * Driver anatomy (SQLSTATE codes, `cause`-chain walking, constraint-name
139
+ * carriage) is delegated to `db.classifyError` the adapter seam that
140
+ * canonicalises a raw driver error into a `DbErrorClassification`. This
141
+ * function only knows the classification codes and the path constraint's
142
+ * name substring; it stays targeted to the path constraint so unrelated
143
+ * unique violations aren't spuriously rebranded as path conflicts.
146
144
  */
147
- export function rethrowPathConflict(err, path, locale) {
148
- let e = err;
149
- // Walk at most a few `cause` hops — DrizzleQueryError → underlying pg error.
150
- for (let i = 0; i < 3 && e; i++) {
151
- if (e.code === '23505' &&
152
- typeof e.constraint === 'string' &&
153
- e.constraint.includes('document_paths_collection_locale_path')) {
154
- throw ERR_PATH_CONFLICT({
155
- message: `path "${path}" is already in use in this collection (locale: ${locale})`,
156
- details: { path, locale, constraint: e.constraint },
157
- });
158
- }
159
- e = e.cause;
145
+ export function rethrowPathConflict(db, err, path, locale) {
146
+ const classification = db.classifyError?.(err);
147
+ if (classification?.code === DbErrorCodes.UNIQUE_VIOLATION &&
148
+ classification.constraint?.includes('document_paths_collection_locale_path')) {
149
+ throw ERR_PATH_CONFLICT({
150
+ message: `path "${path}" is already in use in this collection (locale: ${locale})`,
151
+ details: { path, locale, constraint: classification.constraint },
152
+ });
160
153
  }
161
154
  throw err;
162
155
  }
@@ -186,7 +179,7 @@ export function resolvePathForUpdate(args) {
186
179
  // skip the write (existing path row stays as-is — sticky). The path row
187
180
  // lives under the document's source_locale (its anchor), not the mutable
188
181
  // global default — so this stays correct after the global default is
189
- // switched. See docs/07-internationalization/index.md.
182
+ // switched. See docs/08-internationalization/index.md.
190
183
  return explicitPath ?? undefined;
191
184
  }
192
185
  // Non-source-locale (translation) write: reject any path change with a warn
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,41 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { ErrorCodes } from '../../lib/errors.js';
10
+ import { rethrowPathConflict } from './internals.js';
11
+ const adapterWith = (c) => ({ classifyError: c === undefined ? undefined : () => c });
12
+ describe('rethrowPathConflict', () => {
13
+ it('maps a unique violation on the path constraint to ERR_PATH_CONFLICT', () => {
14
+ const db = adapterWith({
15
+ code: 'DB_UNIQUE_VIOLATION',
16
+ constraint: 'byline_document_paths_document_paths_collection_locale_path',
17
+ });
18
+ try {
19
+ rethrowPathConflict(db, new Error('raw'), 'news/hello', 'en');
20
+ throw new Error('should have thrown');
21
+ }
22
+ catch (e) {
23
+ expect(e.code).toBe(ErrorCodes.PATH_CONFLICT);
24
+ }
25
+ });
26
+ it('rethrows raw when the unique violation is on a different constraint', () => {
27
+ const raw = new Error('raw');
28
+ const db = adapterWith({ code: 'DB_UNIQUE_VIOLATION', constraint: 'some_other_unique' });
29
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
30
+ });
31
+ it('rethrows raw for DB_UNKNOWN', () => {
32
+ const raw = new Error('raw');
33
+ const db = adapterWith({ code: 'DB_UNKNOWN' });
34
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
35
+ });
36
+ it('rethrows raw when the adapter has no classifyError', () => {
37
+ const raw = new Error('raw');
38
+ const db = adapterWith(undefined);
39
+ expect(() => rethrowPathConflict(db, raw, 'p', 'en')).toThrow(raw);
40
+ });
41
+ });
@@ -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/06-auth-and-security/02-auditability.md).
92
+ // the audit log is its only accountability home (docs/07-auth-and-security/02-auditability.md).
93
93
  const audit = requireAuditCapability(db);
94
94
  const actor = auditActor(ctx);
95
95
  await audit.withTransaction(async () => {
@@ -31,7 +31,7 @@ export interface UpdateDocumentSystemFieldsResult {
31
31
  * version. This service backs the admin path / available-locales widgets'
32
32
  * direct-write Save (the `direct-write` and `both` dirty-reason cases). The
33
33
  * public *advertised* set remains the intersection of `availableLocales` with
34
- * the resolved version's completeness ledger. See docs/07-internationalization/index.md.
34
+ * the resolved version's completeness ledger. See docs/08-internationalization/index.md.
35
35
  *
36
36
  * Flow:
37
37
  * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
@@ -23,7 +23,7 @@ import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './interna
23
23
  * version. This service backs the admin path / available-locales widgets'
24
24
  * direct-write Save (the `direct-write` and `both` dirty-reason cases). The
25
25
  * public *advertised* set remains the intersection of `availableLocales` with
26
- * the resolved version's completeness ledger. See docs/07-internationalization/index.md.
26
+ * the resolved version's completeness ledger. See docs/08-internationalization/index.md.
27
27
  *
28
28
  * Flow:
29
29
  * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
@@ -104,7 +104,7 @@ export async function updateDocumentSystemFields(ctx, params) {
104
104
  locale: sourceLocale,
105
105
  path: pathForCommand,
106
106
  })
107
- .catch((err) => rethrowPathConflict(err, pathForCommand, sourceLocale));
107
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand, sourceLocale));
108
108
  await audit.append({
109
109
  documentId: params.documentId,
110
110
  collectionId,
@@ -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/07-internationalization/index.md.
46
+ * available-locales sidebar widget. See docs/08-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/07-internationalization/index.md.
81
+ * array replaces it wholesale. See docs/08-internationalization/index.md.
82
82
  */
83
83
  availableLocales?: string[];
84
84
  }): Promise<UpdateDocumentWithPatchesResult>;
@@ -91,7 +91,7 @@ export async function updateDocument(ctx, params) {
91
91
  previousVersionId: originalData.document_version_id,
92
92
  createdBy: actorId(ctx),
93
93
  })
94
- .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
94
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', defaultLocale));
95
95
  const documentId = extractDocumentId(result.document) || params.documentId;
96
96
  const documentVersionId = extractVersionId(result.document);
97
97
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -210,7 +210,7 @@ export async function updateDocumentWithPatches(ctx, params) {
210
210
  previousVersionId: originalData.document_version_id,
211
211
  createdBy: actorId(ctx),
212
212
  })
213
- .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
213
+ .catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', defaultLocale));
214
214
  const documentId = extractDocumentId(result.document) || params.documentId;
215
215
  const documentVersionId = extractVersionId(result.document);
216
216
  // Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
9
  import { describe, expect, it, vi } from 'vitest';
10
- import { BylineError, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
10
+ import { BylineError, DbErrorCodes, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
11
11
  import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Fixtures / Helpers
@@ -45,12 +45,25 @@ function createMockDb() {
45
45
  const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
46
46
  const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
47
47
  const getCurrentPath = vi.fn().mockResolvedValue('current-path');
48
- // Audit capability (docs/06-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
48
+ // Audit capability (docs/07-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
49
49
  // in unit tests (runs the unit of work immediately, no real tx); `append`
50
50
  // records the calls so write-point tests can assert the audit rows emitted.
51
51
  const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
52
52
  const withTransaction = vi.fn(async (fn) => fn());
53
+ // Fake classifier mirroring db-postgres's real one closely enough for
54
+ // rethrowPathConflict's consumption of the seam: a pg-shaped 23505 with a
55
+ // constraint name classifies as a unique violation; anything else is
56
+ // unknown. Individual tests inject pg-shaped errors on `createDocumentVersion`
57
+ // (see `translates a Postgres unique-constraint violation...` below).
58
+ const classifyError = vi.fn((err) => {
59
+ const e = err;
60
+ if (e?.code === '23505') {
61
+ return { code: DbErrorCodes.UNIQUE_VIOLATION, constraint: e.constraint };
62
+ }
63
+ return { code: DbErrorCodes.UNKNOWN };
64
+ });
53
65
  const db = {
66
+ classifyError,
54
67
  commands: {
55
68
  collections: {
56
69
  create: vi.fn(),
@@ -197,7 +210,7 @@ describe('Document lifecycle service', () => {
197
210
  data: { title: 'Hello' },
198
211
  locale: 'en',
199
212
  });
200
- // Audit contract (docs/06-auth-and-security/02-auditability.md — W1): every version row
213
+ // Audit contract (docs/07-auth-and-security/02-auditability.md — W1): every version row
201
214
  // records the actor that created it.
202
215
  expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
203
216
  });
@@ -793,7 +806,7 @@ describe('Document lifecycle service', () => {
793
806
  getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
794
807
  const ctx = buildCtx(db);
795
808
  await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
796
- // The mutation + audit row run inside one withTransaction (docs/06-auth-and-security/02-auditability.md).
809
+ // The mutation + audit row run inside one withTransaction (docs/07-auth-and-security/02-auditability.md).
797
810
  expect(withTransaction).toHaveBeenCalledOnce();
798
811
  expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
799
812
  documentId: 'doc-1',
@@ -27,6 +27,6 @@ export function validateSearchConfig(collections, adapters) {
27
27
  return;
28
28
  throw new Error(`initBylineCore: ${optedIn.length} collection(s) opt into search ` +
29
29
  `(${optedIn.join(', ')}) but no search provider is registered. ` +
30
- `Wire one via ServerConfig.search — see \`@byline/search-postgres\` ` +
31
- `\`postgresSearch()\` for the built-in Postgres full-text driver.`);
30
+ `Wire one via ServerConfig.search — use \`postgresSearch()\` from ` +
31
+ `\`@byline/search-postgres\` or \`mysqlSearch()\` from \`@byline/search-mysql\`.`);
32
32
  }
@@ -186,6 +186,10 @@ export const storeColumnManifest = [
186
186
  nullCast: 'varchar',
187
187
  sources: { json: 'json_schema' },
188
188
  },
189
+ // nullCast 'text[]' is the one abstract null-cast type with no MySQL array
190
+ // equivalent; the MySQL adapter maps it to CAST(NULL AS JSON) in its UNION
191
+ // null casts (Postgres uses NULL::text[]). See
192
+ // specs/2026-07-24-db-error-classification-seam-design.md §6.
189
193
  {
190
194
  name: 'object_keys',
191
195
  nullCast: 'text[]',
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": "4.7.0",
5
+ "version": "4.9.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -82,7 +82,7 @@
82
82
  "sharp": "^0.35.3",
83
83
  "uuid": "^14.0.1",
84
84
  "zod": "^4.4.3",
85
- "@byline/auth": "4.7.0"
85
+ "@byline/auth": "4.9.0"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@biomejs/biome": "2.5.4",