@byline/core 3.2.1 → 3.3.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.
@@ -340,6 +340,34 @@ export interface IDocumentCommands {
340
340
  document: any;
341
341
  fieldCount: number;
342
342
  }>;
343
+ /**
344
+ * Standalone, non-versioned write of a document's URL path
345
+ * (`byline_document_paths`). Edits the document-grain, sticky path row
346
+ * in-place **without** minting a new version or touching workflow status —
347
+ * the change is immediate and applies across every version. Backs the admin
348
+ * path widget's direct-write Save. The unique constraint on
349
+ * `(collection_id, locale, path)` may surface as `ERR_PATH_CONFLICT` from the
350
+ * lifecycle layer. See `docs/I18N.md`.
351
+ */
352
+ updateDocumentPath(params: {
353
+ documentId: string;
354
+ collectionId: string;
355
+ locale: string;
356
+ path: string;
357
+ }): Promise<void>;
358
+ /**
359
+ * Standalone, non-versioned write of a document's editorial advertised-locale
360
+ * set (`byline_document_available_locales`). Replaces the document-grain set
361
+ * wholesale **without** minting a new version or touching workflow status —
362
+ * the change is immediate and applies across every version. `[]` clears it.
363
+ * Backs the admin available-locales widget's direct-write Save. See
364
+ * `docs/I18N.md`.
365
+ */
366
+ setDocumentAvailableLocales(params: {
367
+ documentId: string;
368
+ collectionId: string;
369
+ availableLocales: string[];
370
+ }): Promise<void>;
343
371
  /**
344
372
  * Mutate the status on an existing document version row.
345
373
  *
@@ -39,6 +39,8 @@ function createMockDb(options) {
39
39
  collections: { create, update, delete: vi.fn(fail) },
40
40
  documents: {
41
41
  createDocumentVersion: vi.fn(fail),
42
+ updateDocumentPath: vi.fn(fail),
43
+ setDocumentAvailableLocales: vi.fn(fail),
42
44
  setDocumentStatus: vi.fn(fail),
43
45
  archivePublishedVersions: vi.fn(fail),
44
46
  softDeleteDocument: vi.fn(fail),
@@ -182,6 +184,8 @@ describe('ensureCollections', () => {
182
184
  collections: { create, update, delete: vi.fn() },
183
185
  documents: {
184
186
  createDocumentVersion: vi.fn(),
187
+ updateDocumentPath: vi.fn(),
188
+ setDocumentAvailableLocales: vi.fn(),
185
189
  setDocumentStatus: vi.fn(),
186
190
  archivePublishedVersions: vi.fn(),
187
191
  softDeleteDocument: vi.fn(),
@@ -24,6 +24,8 @@ function makeAdapter(options) {
24
24
  collections: { create: vi.fn(fail), update: vi.fn(fail), delete: vi.fn(fail) },
25
25
  documents: {
26
26
  createDocumentVersion: vi.fn(fail),
27
+ updateDocumentPath: vi.fn(fail),
28
+ setDocumentAvailableLocales: vi.fn(fail),
27
29
  setDocumentStatus: vi.fn(fail),
28
30
  archivePublishedVersions: vi.fn(fail),
29
31
  softDeleteDocument: vi.fn(fail),
@@ -104,6 +104,13 @@ export interface UpdateDocumentWithPatchesResult {
104
104
  documentId: string;
105
105
  documentVersionId: string;
106
106
  }
107
+ export interface UpdateDocumentSystemFieldsResult {
108
+ documentId: string;
109
+ /** The path actually written, or `undefined` when no path write occurred. */
110
+ path?: string;
111
+ /** Whether the advertised-locale set was rewritten this call. */
112
+ availableLocalesWritten: boolean;
113
+ }
107
114
  export interface ChangeStatusResult {
108
115
  previousStatus: string;
109
116
  newStatus: string;
@@ -263,6 +270,53 @@ export declare function updateDocumentWithPatches(ctx: DocumentLifecycleContext,
263
270
  */
264
271
  availableLocales?: string[];
265
272
  }): Promise<UpdateDocumentWithPatchesResult>;
273
+ /**
274
+ * Write a document's system-managed, document-grain fields — `path` and the
275
+ * editorial `availableLocales` set — **without** minting a new version or
276
+ * touching workflow status.
277
+ *
278
+ * These fields are document-grain (they live in `byline_document_paths` and
279
+ * `byline_document_available_locales`, keyed by logical document, sticky across
280
+ * versions), so a workflow status change would falsely imply the edit is gated
281
+ * behind publish. It is not: the write is immediate and applies across every
282
+ * version. This service backs the admin path / available-locales widgets'
283
+ * direct-write Save (the `direct-write` and `both` dirty-reason cases). The
284
+ * public *advertised* set remains the intersection of `availableLocales` with
285
+ * the resolved version's completeness ledger. See docs/I18N.md.
286
+ *
287
+ * Flow:
288
+ * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
289
+ * 2. Fetch the document to resolve its `source_locale` anchor + current path.
290
+ * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
291
+ * rule (translation-locale path edits are dropped with a warn); a real
292
+ * change is written via `updateDocumentPath`, mapping the unique-constraint
293
+ * violation to `ERR_PATH_CONFLICT`.
294
+ * 4. `availableLocales` (when supplied): rewritten wholesale via
295
+ * `setDocumentAvailableLocales`.
296
+ *
297
+ * No content hooks fire — these are not content writes. Accountability for
298
+ * these mutations is the job of the (planned) document-grain audit log.
299
+ *
300
+ * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
301
+ * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
302
+ */
303
+ export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext, params: {
304
+ documentId: string;
305
+ locale?: string;
306
+ /**
307
+ * Explicit path override from the path widget. `null` / empty / omitted
308
+ * means "no path write" (the existing row stays sticky). A non-empty
309
+ * string is written when the request locale is the document's source
310
+ * locale; on a translation locale it is dropped with a warn.
311
+ */
312
+ path?: string | null;
313
+ /**
314
+ * The editorial advertised-locale set from the available-locales widget.
315
+ * `undefined` means "no advertised-locale write"; an explicit array — `[]`
316
+ * included — replaces the set wholesale.
317
+ */
318
+ availableLocales?: string[];
319
+ }): Promise<UpdateDocumentSystemFieldsResult>;
266
320
  /**
267
321
  * Change a document's workflow status.
268
322
  *
@@ -460,6 +460,95 @@ export async function updateDocumentWithPatches(ctx, params) {
460
460
  return { documentId, documentVersionId };
461
461
  });
462
462
  }
463
+ /**
464
+ * Write a document's system-managed, document-grain fields — `path` and the
465
+ * editorial `availableLocales` set — **without** minting a new version or
466
+ * touching workflow status.
467
+ *
468
+ * These fields are document-grain (they live in `byline_document_paths` and
469
+ * `byline_document_available_locales`, keyed by logical document, sticky across
470
+ * versions), so a workflow status change would falsely imply the edit is gated
471
+ * behind publish. It is not: the write is immediate and applies across every
472
+ * version. This service backs the admin path / available-locales widgets'
473
+ * direct-write Save (the `direct-write` and `both` dirty-reason cases). The
474
+ * public *advertised* set remains the intersection of `availableLocales` with
475
+ * the resolved version's completeness ledger. See docs/I18N.md.
476
+ *
477
+ * Flow:
478
+ * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
479
+ * 2. Fetch the document to resolve its `source_locale` anchor + current path.
480
+ * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
481
+ * rule (translation-locale path edits are dropped with a warn); a real
482
+ * change is written via `updateDocumentPath`, mapping the unique-constraint
483
+ * violation to `ERR_PATH_CONFLICT`.
484
+ * 4. `availableLocales` (when supplied): rewritten wholesale via
485
+ * `setDocumentAvailableLocales`.
486
+ *
487
+ * No content hooks fire — these are not content writes. Accountability for
488
+ * these mutations is the job of the (planned) document-grain audit log.
489
+ *
490
+ * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
491
+ * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
492
+ */
493
+ export async function updateDocumentSystemFields(ctx, params) {
494
+ return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
495
+ const { db, collectionId, collectionPath, defaultLocale } = ctx;
496
+ assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
497
+ const requestLocale = params.locale ?? defaultLocale;
498
+ // Resolve the document's source-locale anchor + current path. Both feed
499
+ // the path source-locale guard below; the fetch also asserts existence.
500
+ const latest = await db.queries.documents.getDocumentById({
501
+ collection_id: collectionId,
502
+ document_id: params.documentId,
503
+ locale: requestLocale,
504
+ reconstruct: true,
505
+ });
506
+ if (latest == null) {
507
+ throw ERR_NOT_FOUND({
508
+ message: 'document not found',
509
+ details: { documentId: params.documentId },
510
+ }).log(ctx.logger);
511
+ }
512
+ const originalData = latest;
513
+ const sourceLocale = originalData.source_locale ?? defaultLocale;
514
+ // Path: honour the same source-locale-only rule the versioned write
515
+ // uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
516
+ // write" (null/empty override, or a translation-locale save).
517
+ const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
518
+ const pathForCommand = resolvePathForUpdate({
519
+ explicitPath,
520
+ currentPath: originalData.path,
521
+ requestLocale,
522
+ sourceLocale,
523
+ documentId: params.documentId,
524
+ logger: ctx.logger,
525
+ });
526
+ if (pathForCommand !== undefined) {
527
+ await db.commands.documents
528
+ .updateDocumentPath({
529
+ documentId: params.documentId,
530
+ collectionId,
531
+ locale: sourceLocale,
532
+ path: pathForCommand,
533
+ })
534
+ .catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
535
+ }
536
+ // Advertised locales: rewrite the document-grain set wholesale.
537
+ const availableLocalesWritten = params.availableLocales !== undefined;
538
+ if (params.availableLocales !== undefined) {
539
+ await db.commands.documents.setDocumentAvailableLocales({
540
+ documentId: params.documentId,
541
+ collectionId,
542
+ availableLocales: params.availableLocales,
543
+ });
544
+ }
545
+ return {
546
+ documentId: params.documentId,
547
+ path: pathForCommand,
548
+ availableLocalesWritten,
549
+ };
550
+ });
551
+ }
463
552
  /**
464
553
  * Change a document's workflow status.
465
554
  *
@@ -45,6 +45,8 @@ function createMockDb() {
45
45
  },
46
46
  documents: {
47
47
  createDocumentVersion,
48
+ updateDocumentPath: vi.fn(),
49
+ setDocumentAvailableLocales: vi.fn(),
48
50
  setDocumentStatus,
49
51
  archivePublishedVersions,
50
52
  softDeleteDocument,
@@ -53,6 +53,8 @@ function createMockDb() {
53
53
  },
54
54
  documents: {
55
55
  createDocumentVersion,
56
+ updateDocumentPath: vi.fn(),
57
+ setDocumentAvailableLocales: vi.fn(),
56
58
  setDocumentStatus: vi.fn(),
57
59
  archivePublishedVersions: vi.fn(),
58
60
  softDeleteDocument: vi.fn(),
@@ -141,6 +141,8 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
141
141
  collections: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
142
142
  documents: {
143
143
  createDocumentVersion: vi.fn(),
144
+ updateDocumentPath: vi.fn(),
145
+ setDocumentAvailableLocales: vi.fn(),
144
146
  setDocumentStatus: vi.fn(),
145
147
  archivePublishedVersions: vi.fn(),
146
148
  softDeleteDocument: vi.fn(),
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.2.1",
5
+ "version": "3.3.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.3",
82
- "@byline/auth": "3.2.1"
82
+ "@byline/auth": "3.3.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",