@byline/core 1.7.7 → 1.8.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.
@@ -207,7 +207,15 @@ export interface IDocumentCommands {
207
207
  collectionConfig: CollectionDefinition;
208
208
  action: string;
209
209
  documentData: any;
210
- path: string;
210
+ /**
211
+ * Optional. When provided, the adapter upserts a row into
212
+ * `byline_document_paths` keyed by `(document_id, defaultContentLocale)`.
213
+ * When omitted, no path write occurs — the lifecycle uses this to
214
+ * skip path writes during translation (non-default-locale) saves.
215
+ * The unique constraint on `(collection_id, locale, path)` may
216
+ * surface as `ERR_PATH_CONFLICT` from the lifecycle layer.
217
+ */
218
+ path?: string;
211
219
  locale?: string;
212
220
  status?: string;
213
221
  createdBy?: string;
@@ -85,6 +85,7 @@ export declare const ErrorCodes: {
85
85
  readonly DATABASE: "ERR_DATABASE";
86
86
  readonly STORAGE: "ERR_STORAGE";
87
87
  readonly READ_BUDGET_EXCEEDED: "ERR_READ_BUDGET_EXCEEDED";
88
+ readonly PATH_CONFLICT: "ERR_PATH_CONFLICT";
88
89
  };
89
90
  export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
90
91
  export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -95,3 +96,10 @@ export declare const ERR_PATCH_FAILED: (opts: BylineErrorOptions, errorConstruct
95
96
  export declare const ERR_DATABASE: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
96
97
  export declare const ERR_STORAGE: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
97
98
  export declare const ERR_READ_BUDGET_EXCEEDED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
99
+ /**
100
+ * Thrown when a write attempts to set `path` to a value already used by
101
+ * another document in the same `(collection, locale)` scope. Surfaces the
102
+ * underlying Postgres unique-constraint violation on
103
+ * `byline_document_paths(collection_id, locale, path)`.
104
+ */
105
+ export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
@@ -118,6 +118,7 @@ export const ErrorCodes = {
118
118
  DATABASE: 'ERR_DATABASE',
119
119
  STORAGE: 'ERR_STORAGE',
120
120
  READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED',
121
+ PATH_CONFLICT: 'ERR_PATH_CONFLICT',
121
122
  };
122
123
  // ---------------------------------------------------------------------------
123
124
  // Pre-instantiated factories
@@ -131,3 +132,10 @@ export const ERR_PATCH_FAILED = createErrorType(ErrorCodes.PATCH_FAILED);
131
132
  export const ERR_DATABASE = createErrorType(ErrorCodes.DATABASE);
132
133
  export const ERR_STORAGE = createErrorType(ErrorCodes.STORAGE);
133
134
  export const ERR_READ_BUDGET_EXCEEDED = createErrorType(ErrorCodes.READ_BUDGET_EXCEEDED);
135
+ /**
136
+ * Thrown when a write attempts to set `path` to a value already used by
137
+ * another document in the same `(collection, locale)` scope. Surfaces the
138
+ * underlying Postgres unique-constraint violation on
139
+ * `byline_document_paths(collection_id, locale, path)`.
140
+ */
141
+ export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn');
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { normalizeCollectionHook, } from '../@types/index.js';
9
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
10
- import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_VALIDATION, } from '../lib/errors.js';
10
+ import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, } from '../lib/errors.js';
11
11
  import { withLogContext } from '../lib/logger.js';
12
12
  import { applyPatches } from '../patches/index.js';
13
13
  import { normaliseDateFields } from '../utils/normalise-dates.js';
@@ -32,6 +32,65 @@ async function invokeHook(hook, ctx) {
32
32
  function extractVersionId(document) {
33
33
  return document?.id ?? document?.document_version_id ?? '';
34
34
  }
35
+ /**
36
+ * Detect a Postgres unique-constraint violation on
37
+ * `byline_document_paths(collection_id, locale, path)` and translate it
38
+ * to `ERR_PATH_CONFLICT`. Any other error is rethrown unchanged.
39
+ *
40
+ * The Postgres SQLSTATE for unique violations is `23505`. Drivers carry
41
+ * the constraint name on the error object (`constraint`); matching by
42
+ * name keeps this targeted to the path constraint and avoids spuriously
43
+ * rebranding unrelated unique violations as path conflicts.
44
+ *
45
+ * Drizzle wraps the underlying pg error in `DrizzleQueryError` with the
46
+ * original attached as `cause`, so we walk a short cause chain to find
47
+ * the carried `code` / `constraint`.
48
+ */
49
+ function rethrowPathConflict(err, path, locale) {
50
+ let e = err;
51
+ // Walk at most a few `cause` hops — DrizzleQueryError → underlying pg error.
52
+ for (let i = 0; i < 3 && e; i++) {
53
+ if (e.code === '23505' &&
54
+ typeof e.constraint === 'string' &&
55
+ e.constraint.includes('document_paths_collection_locale_path')) {
56
+ throw ERR_PATH_CONFLICT({
57
+ message: `path "${path}" is already in use in this collection (locale: ${locale})`,
58
+ details: { path, locale, constraint: e.constraint },
59
+ });
60
+ }
61
+ e = e.cause;
62
+ }
63
+ throw err;
64
+ }
65
+ /**
66
+ * Resolve the path argument the storage primitive should receive on an
67
+ * update operation. Phase 1 only writes path rows under the default
68
+ * content locale; on translation saves a supplied path is dropped with
69
+ * a `logger.warn`, leaving the existing default-locale row untouched.
70
+ *
71
+ * Returns `undefined` to signal the storage primitive should skip the
72
+ * path write entirely (no upsert).
73
+ */
74
+ function resolvePathForUpdate(args) {
75
+ const { explicitPath, currentPath, requestLocale, defaultLocale, documentId, logger } = args;
76
+ if (requestLocale === defaultLocale) {
77
+ // Default-locale write: pass path through when supplied; otherwise
78
+ // skip the write (existing path row stays as-is — sticky).
79
+ return explicitPath ?? undefined;
80
+ }
81
+ // Non-default-locale write: reject any path change with a warn so the
82
+ // operation succeeds but the editor / API caller is informed.
83
+ if (explicitPath !== null && explicitPath !== currentPath) {
84
+ logger?.warn({
85
+ documentId,
86
+ requestedLocale: requestLocale,
87
+ defaultLocale,
88
+ suppliedPath: explicitPath,
89
+ currentPath,
90
+ }, 'path changes apply only on default-locale writes; ignored on translation save');
91
+ }
92
+ return undefined;
93
+ }
35
94
  /** Extract the logical document id from the document object returned by `createDocumentVersion`. */
36
95
  function extractDocumentId(document) {
37
96
  return document?.document_id ?? '';
@@ -97,7 +156,8 @@ export async function createDocument(ctx, params) {
97
156
  await invokeHook(hooks?.beforeCreate, { data, collectionPath });
98
157
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
99
158
  const resolvedPath = explicitPath ?? derivePath(definition, data, defaultLocale, slugifier);
100
- const result = await db.commands.documents.createDocumentVersion({
159
+ const result = await db.commands.documents
160
+ .createDocumentVersion({
101
161
  collectionId,
102
162
  collectionVersion: ctx.collectionVersion,
103
163
  collectionConfig: definition,
@@ -106,7 +166,8 @@ export async function createDocument(ctx, params) {
106
166
  path: resolvedPath,
107
167
  status: params.status ?? data.status,
108
168
  locale: params.locale ?? defaultLocale,
109
- });
169
+ })
170
+ .catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
110
171
  const documentId = extractDocumentId(result.document);
111
172
  const documentVersionId = extractVersionId(result.document);
112
173
  await invokeHook(hooks?.afterCreate, {
@@ -150,19 +211,29 @@ export async function updateDocument(ctx, params) {
150
211
  await invokeHook(hooks?.beforeUpdate, { data, originalData, collectionPath });
151
212
  const defaultStatus = getDefaultStatus(definition);
152
213
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
153
- const resolvedPath = explicitPath ?? originalData.path ?? crypto.randomUUID();
154
- const result = await db.commands.documents.createDocumentVersion({
214
+ const requestLocale = params.locale ?? defaultLocale;
215
+ const pathForCommand = resolvePathForUpdate({
216
+ explicitPath,
217
+ currentPath: originalData.path,
218
+ requestLocale,
219
+ defaultLocale,
220
+ documentId: params.documentId,
221
+ logger: ctx.logger,
222
+ });
223
+ const result = await db.commands.documents
224
+ .createDocumentVersion({
155
225
  documentId: params.documentId,
156
226
  collectionId,
157
227
  collectionVersion: ctx.collectionVersion,
158
228
  collectionConfig: definition,
159
229
  action: 'update',
160
230
  documentData: data,
161
- path: resolvedPath,
231
+ path: pathForCommand,
162
232
  status: defaultStatus,
163
- locale: params.locale ?? defaultLocale,
233
+ locale: requestLocale,
164
234
  previousVersionId: originalData.document_version_id,
165
- });
235
+ })
236
+ .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
166
237
  const documentId = extractDocumentId(result.document) || params.documentId;
167
238
  const documentVersionId = extractVersionId(result.document);
168
239
  await invokeHook(hooks?.afterUpdate, {
@@ -236,19 +307,29 @@ export async function updateDocumentWithPatches(ctx, params) {
236
307
  // 6. Persist.
237
308
  const defaultStatus = getDefaultStatus(definition);
238
309
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
239
- const resolvedPath = explicitPath ?? originalData.path ?? crypto.randomUUID();
240
- const result = await db.commands.documents.createDocumentVersion({
310
+ const requestLocale = params.locale ?? defaultLocale;
311
+ const pathForCommand = resolvePathForUpdate({
312
+ explicitPath,
313
+ currentPath: originalData.path,
314
+ requestLocale,
315
+ defaultLocale,
316
+ documentId: params.documentId,
317
+ logger: ctx.logger,
318
+ });
319
+ const result = await db.commands.documents
320
+ .createDocumentVersion({
241
321
  documentId: params.documentId,
242
322
  collectionId,
243
323
  collectionVersion: ctx.collectionVersion,
244
324
  collectionConfig: definition,
245
325
  action: 'update',
246
326
  documentData: nextData,
247
- path: resolvedPath,
327
+ path: pathForCommand,
248
328
  status: defaultStatus,
249
- locale: params.locale ?? defaultLocale,
329
+ locale: requestLocale,
250
330
  previousVersionId: originalData.document_version_id,
251
- });
331
+ })
332
+ .catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
252
333
  const documentId = extractDocumentId(result.document) || params.documentId;
253
334
  const documentVersionId = extractVersionId(result.document);
254
335
  // 7. afterUpdate hook.
@@ -493,6 +574,9 @@ export async function restoreDocumentVersion(ctx, params) {
493
574
  // the source tree forward in a single flatten pass — the
494
575
  // cross-locale carry-forward branch in createDocumentVersion does
495
576
  // not fire when locale === 'all'.
577
+ //
578
+ // No `path` is passed: restore does not change the document's path
579
+ // (the existing byline_document_paths row stays as-is — sticky).
496
580
  const result = await db.commands.documents.createDocumentVersion({
497
581
  documentId: params.documentId,
498
582
  collectionId,
@@ -500,7 +584,6 @@ export async function restoreDocumentVersion(ctx, params) {
500
584
  collectionConfig: definition,
501
585
  action: 'restore',
502
586
  documentData: sourceFields,
503
- path: currentMeta.path,
504
587
  status: getDefaultStatus(definition),
505
588
  locale: 'all',
506
589
  previousVersionId: currentMeta.document_version_id,
@@ -319,7 +319,7 @@ describe('Document lifecycle service', () => {
319
319
  documentVersionId: 'ver-1',
320
320
  }));
321
321
  });
322
- it('keeps path sticky from the previous version when no explicit path is supplied', async () => {
322
+ it('does not pass path to the storage primitive when no explicit path is supplied', async () => {
323
323
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
324
324
  getDocumentById.mockResolvedValue({
325
325
  document_version_id: 'prev-ver',
@@ -333,8 +333,9 @@ describe('Document lifecycle service', () => {
333
333
  documentId: 'doc-1',
334
334
  data: { title: 'Brand New Title' },
335
335
  });
336
- // Path is NOT re-derived from the now-changed title
337
- expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('original-path');
336
+ // Sticky path semantics: the storage layer is not asked to write the
337
+ // path row; the existing byline_document_paths row stays as-is.
338
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
338
339
  });
339
340
  it('uses an explicit params.path verbatim on update, overriding the sticky value', async () => {
340
341
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
@@ -365,6 +366,98 @@ describe('Document lifecycle service', () => {
365
366
  });
366
367
  expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
367
368
  });
369
+ it('drops path changes silently with a logger.warn on non-default-locale (translation) saves', async () => {
370
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
371
+ getDocumentById.mockResolvedValue({
372
+ document_version_id: 'prev-ver',
373
+ path: 'about',
374
+ status: 'draft',
375
+ fields: { title: 'About' },
376
+ });
377
+ const ctx = buildCtx(db);
378
+ const warn = ctx.logger?.warn;
379
+ warn.mockClear();
380
+ await updateDocument(ctx, {
381
+ documentId: 'doc-1',
382
+ data: { title: 'À propos' },
383
+ locale: 'fr',
384
+ path: 'a-propos',
385
+ });
386
+ // Save still proceeds — the version row is created — but the path
387
+ // row is left untouched (no `path` flows to the storage primitive).
388
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
389
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
390
+ expect(createDocumentVersion.mock.calls[0]?.[0].locale).toBe('fr');
391
+ // The caller is informed via a structured warn.
392
+ expect(warn).toHaveBeenCalledWith(expect.objectContaining({
393
+ documentId: 'doc-1',
394
+ requestedLocale: 'fr',
395
+ defaultLocale: 'en',
396
+ suppliedPath: 'a-propos',
397
+ currentPath: 'about',
398
+ }), expect.stringContaining('path changes apply only on default-locale writes'));
399
+ });
400
+ it('does not warn when a translation save supplies the same path as current', async () => {
401
+ const { db, getDocumentById } = createMockDb();
402
+ getDocumentById.mockResolvedValue({
403
+ document_version_id: 'prev-ver',
404
+ path: 'about',
405
+ status: 'draft',
406
+ fields: { title: 'About' },
407
+ });
408
+ const ctx = buildCtx(db);
409
+ const warn = ctx.logger?.warn;
410
+ warn.mockClear();
411
+ await updateDocument(ctx, {
412
+ documentId: 'doc-1',
413
+ data: { title: 'À propos' },
414
+ locale: 'fr',
415
+ path: 'about', // same as currentPath — idempotent, no warn
416
+ });
417
+ expect(warn).not.toHaveBeenCalled();
418
+ });
419
+ it('translates a Postgres unique-constraint violation on the path index to ERR_PATH_CONFLICT', async () => {
420
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
421
+ getDocumentById.mockResolvedValue({
422
+ document_version_id: 'prev-ver',
423
+ path: 'about',
424
+ status: 'draft',
425
+ fields: { title: 'About' },
426
+ });
427
+ // Simulate the pg driver throwing a unique-violation on the path
428
+ // constraint when the upsert tries to claim a slug owned by another
429
+ // document in the same (collection, locale).
430
+ createDocumentVersion.mockRejectedValueOnce(Object.assign(new Error('duplicate key value violates unique constraint'), {
431
+ code: '23505',
432
+ constraint: 'idx_document_paths_collection_locale_path',
433
+ }));
434
+ const ctx = buildCtx(db);
435
+ try {
436
+ await updateDocument(ctx, {
437
+ documentId: 'doc-1',
438
+ data: { title: 'About' },
439
+ path: 'home', // collides
440
+ });
441
+ throw new Error('expected ERR_PATH_CONFLICT');
442
+ }
443
+ catch (err) {
444
+ expect(err).toBeInstanceOf(BylineError);
445
+ expect(err.code).toBe(ErrorCodes.PATH_CONFLICT);
446
+ }
447
+ });
448
+ it('rethrows non-23505 errors unchanged', async () => {
449
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
450
+ getDocumentById.mockResolvedValue({
451
+ document_version_id: 'prev-ver',
452
+ path: 'about',
453
+ status: 'draft',
454
+ fields: { title: 'About' },
455
+ });
456
+ const original = new Error('connection refused');
457
+ createDocumentVersion.mockRejectedValueOnce(original);
458
+ const ctx = buildCtx(db);
459
+ await expect(updateDocument(ctx, { documentId: 'doc-1', data: { title: 'X' }, path: 'home' })).rejects.toBe(original);
460
+ });
368
461
  it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
369
462
  const callOrder = [];
370
463
  const hooks = {
@@ -719,13 +812,16 @@ describe('Document lifecycle service', () => {
719
812
  // Source version was 'archived'; default status for minimalCollection is 'draft'.
720
813
  expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
721
814
  });
722
- it('keeps path sticky from the current version, not the source', async () => {
815
+ it('does not pass path on restore the existing path row is sticky', async () => {
723
816
  const { db, createDocumentVersion, sourceVersionId } = setupRestore({
724
817
  currentPath: 'sticky-path',
725
818
  });
726
819
  const ctx = buildCtx(db);
727
820
  await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
728
- expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('sticky-path');
821
+ // Restore never changes a document's path: the existing
822
+ // byline_document_paths row carries forward unchanged. The storage
823
+ // primitive only writes to document_paths when `path` is supplied.
824
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
729
825
  });
730
826
  it('rejects when the source version belongs to a different document', async () => {
731
827
  const { db, sourceVersionId, createDocumentVersion } = setupRestore({
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": "1.7.7",
5
+ "version": "1.8.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.2",
82
- "@byline/auth": "1.7.7"
82
+ "@byline/auth": "1.8.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.14",