@byline/core 6.6.0 → 6.7.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 (28) hide show
  1. package/dist/@types/db-types.d.ts +60 -0
  2. package/dist/auth/assert-locale-visibility.d.ts +27 -0
  3. package/dist/auth/assert-locale-visibility.js +43 -0
  4. package/dist/auth/assert-locale-visibility.test.node.js +46 -0
  5. package/dist/auth/index.d.ts +1 -0
  6. package/dist/auth/index.js +1 -0
  7. package/dist/config/resolve-item-view-columns.test.node.js +30 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/services/document-lifecycle/internals.js +3 -0
  11. package/dist/services/document-lifecycle.test.node.js +135 -0
  12. package/dist/services/populate.d.ts +9 -1
  13. package/dist/services/populate.js +4 -0
  14. package/dist/services/richtext-populate.d.ts +7 -1
  15. package/dist/services/richtext-populate.js +7 -4
  16. package/dist/storage/index.d.ts +1 -0
  17. package/dist/storage/index.js +1 -0
  18. package/dist/storage/locale-resolution.d.ts +76 -0
  19. package/dist/storage/locale-resolution.js +122 -0
  20. package/dist/storage/locale-resolution.test.node.d.ts +8 -0
  21. package/dist/storage/locale-resolution.test.node.js +245 -0
  22. package/package.json +2 -2
  23. package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +0 -1
  24. package/dist/schemas/zod/review-comparison-probe.test.node.js +0 -26
  25. package/dist/services/restore-review-probe.test.node.js +0 -3397
  26. package/dist/services/review-probes.test.node.js +0 -349
  27. /package/dist/{services/restore-review-probe.test.node.d.ts → auth/assert-locale-visibility.test.node.d.ts} +0 -0
  28. /package/dist/{services/review-probes.test.node.d.ts → config/resolve-item-view-columns.test.node.d.ts} +0 -0
@@ -44,6 +44,28 @@ export type ReadMode = 'any' | 'published';
44
44
  * in every locale. See `docs/08-internationalization/index.md`.
45
45
  */
46
46
  export type MissingLocalePolicy = 'empty' | 'fallback' | 'omit';
47
+ /**
48
+ * Whether a read's language selection honours the editor's advertised-locale
49
+ * checkboxes (the value of the `localeVisibility` read option).
50
+ *
51
+ * - `'public'` — in a collection with `advertiseLocales: true`, an
52
+ * additional translation is eligible only when it is both
53
+ * complete on the selected version and checked in the
54
+ * document's `availableLocales`. An unchecked translation
55
+ * behaves exactly like a missing one under every
56
+ * `onMissingLocale` policy. The source locale is always
57
+ * eligible. Collections without `advertiseLocales` are
58
+ * gated by completeness alone.
59
+ * - `'editorial'` — completeness alone decides; checkboxes are ignored, so an
60
+ * authorized reviewer can see a withheld translation. Also
61
+ * the visibility of exact editing reads.
62
+ *
63
+ * Independent of `ReadMode`: version selection chooses *which version*, locale
64
+ * visibility chooses *which of its languages* may be delivered. `@byline/client`
65
+ * defaults it from `status` — `'published'` (or omitted) → `'public'`,
66
+ * `'any'` → `'editorial'` — and requires an actor for `'editorial'`.
67
+ */
68
+ export type LocaleVisibility = 'public' | 'editorial';
47
69
  /**
48
70
  * Request-scoped context shared across every read and populate walk in one
49
71
  * logical request. Threaded through populate, `afterRead` hooks, and any
@@ -1058,6 +1080,15 @@ export interface IDocumentQueries {
1058
1080
  /** See `MissingLocalePolicy`. `'omit'` returns `null` when the document
1059
1081
  * is not available in the requested locale. Omitted ⇒ `'empty'`. */
1060
1082
  onMissingLocale?: MissingLocalePolicy;
1083
+ /**
1084
+ * See `LocaleVisibility`. Omitted ⇒ `'editorial'`: direct adapter reads are
1085
+ * trusted primitives and keep their exact editing behaviour. `@byline/client`
1086
+ * always passes the resolved value. Under `'public'`, `'fallback'` skips
1087
+ * ineligible locales, `'omit'` excludes documents whose requested locale is
1088
+ * ineligible, and an exact read of an ineligible locale withholds localized
1089
+ * values. A reconstructed result reports its decision as `resolved_locale`.
1090
+ */
1091
+ localeVisibility?: LocaleVisibility;
1061
1092
  }): Promise<any | null>;
1062
1093
  /**
1063
1094
  * Fetch only the current version's metadata row (no field reconstruction).
@@ -1119,6 +1150,8 @@ export interface IDocumentQueries {
1119
1150
  /** See `MissingLocalePolicy`. `'omit'` returns `null` when the document
1120
1151
  * is not available in the requested locale. Omitted ⇒ `'empty'`. */
1121
1152
  onMissingLocale?: MissingLocalePolicy;
1153
+ /** See `getDocumentById.localeVisibility`. */
1154
+ localeVisibility?: LocaleVisibility;
1122
1155
  }): Promise<any | null>;
1123
1156
  getDocumentByVersion(params: {
1124
1157
  document_version_id: string;
@@ -1157,6 +1190,9 @@ export interface IDocumentQueries {
1157
1190
  filters?: DocumentFilter[];
1158
1191
  /** See `getDocumentById.requestContext`. */
1159
1192
  requestContext?: RequestContext;
1193
+ /** See `getDocumentById.localeVisibility`. Each document resolves under
1194
+ * its own source locale, completeness ledger and checkbox set. */
1195
+ localeVisibility?: LocaleVisibility;
1160
1196
  }): Promise<any[]>;
1161
1197
  getDocumentHistory(params: {
1162
1198
  collection_id: string;
@@ -1269,6 +1305,9 @@ export interface IDocumentQueries {
1269
1305
  * in the requested locale (filtered at the SQL layer so pagination stays
1270
1306
  * correct). Omitted ⇒ `'empty'`. */
1271
1307
  onMissingLocale?: MissingLocalePolicy;
1308
+ /** See `getDocumentById.localeVisibility`. Under `'public'` with `'omit'`,
1309
+ * the checkbox gate is compiled into SQL before count and pagination. */
1310
+ localeVisibility?: LocaleVisibility;
1272
1311
  }): Promise<{
1273
1312
  documents: any[];
1274
1313
  total: number;
@@ -1351,6 +1390,13 @@ export interface IDocumentQueries {
1351
1390
  readMode?: ReadMode;
1352
1391
  locale?: string;
1353
1392
  filters?: DocumentFilter[];
1393
+ /**
1394
+ * The read's resolved locale visibility. When supplied, localized
1395
+ * `filters` evaluate each node in the locale hydration shows (fallback
1396
+ * under this visibility). Omitted ⇒ exact requested-locale matching, as
1397
+ * editing reads expect.
1398
+ */
1399
+ localeVisibility?: LocaleVisibility;
1354
1400
  }): Promise<Array<{
1355
1401
  document_id: string;
1356
1402
  depth: number;
@@ -1388,6 +1434,13 @@ export interface IDocumentQueries {
1388
1434
  readMode?: ReadMode;
1389
1435
  locale?: string;
1390
1436
  filters?: DocumentFilter[];
1437
+ /**
1438
+ * The read's resolved locale visibility. When supplied, localized
1439
+ * `filters` evaluate each node in the locale hydration shows (fallback
1440
+ * under this visibility). Omitted ⇒ exact requested-locale matching, as
1441
+ * editing reads expect.
1442
+ */
1443
+ localeVisibility?: LocaleVisibility;
1391
1444
  }): Promise<{
1392
1445
  placed: boolean;
1393
1446
  parentDocumentId: string | null;
@@ -1422,6 +1475,13 @@ export interface IDocumentQueries {
1422
1475
  readMode?: ReadMode;
1423
1476
  locale?: string;
1424
1477
  filters?: DocumentFilter[];
1478
+ /**
1479
+ * The read's resolved locale visibility. When supplied, localized
1480
+ * `filters` evaluate each node in the locale hydration shows (fallback
1481
+ * under this visibility). Omitted ⇒ exact requested-locale matching, as
1482
+ * editing reads expect.
1483
+ */
1484
+ localeVisibility?: LocaleVisibility;
1425
1485
  }): Promise<Array<{
1426
1486
  document_id: string;
1427
1487
  parent_document_id: string | null;
@@ -0,0 +1,27 @@
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 { type RequestContext } from '@byline/auth';
9
+ import type { LocaleVisibility } from '../@types/index.js';
10
+ /**
11
+ * Authorize a read's locale visibility, alongside `assertActorCanPerform`.
12
+ * Called by `@byline/client` read methods after the collection read check and
13
+ * before any DB work.
14
+ *
15
+ * 1. **`'editorial'` requires an actor.** Editorial visibility can deliver a
16
+ * complete but unchecked translation, so an anonymous caller is rejected
17
+ * with `ERR_UNAUTHENTICATED` even when `readMode === 'published'`. The
18
+ * actor's collection read ability is asserted by `assertActorCanPerform`.
19
+ * 2. **`'public'` rejects `locale: 'all'`.** The multi-locale map would expose
20
+ * every stored translation, withheld or partial, so a public read must
21
+ * name one locale. This applies to every collection, including those
22
+ * without `advertiseLocales`.
23
+ *
24
+ * Locale visibility is read intent, not authority: nothing here elevates a
25
+ * context, and `_bypassBeforeRead` has no bearing on it.
26
+ */
27
+ export declare function assertLocaleVisibility(context: RequestContext | undefined, collectionPath: string, visibility: LocaleVisibility, locale: string | undefined): void;
@@ -0,0 +1,43 @@
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 { ERR_UNAUTHENTICATED } from '@byline/auth';
9
+ import { ERR_VALIDATION } from '../lib/errors.js';
10
+ /**
11
+ * Authorize a read's locale visibility, alongside `assertActorCanPerform`.
12
+ * Called by `@byline/client` read methods after the collection read check and
13
+ * before any DB work.
14
+ *
15
+ * 1. **`'editorial'` requires an actor.** Editorial visibility can deliver a
16
+ * complete but unchecked translation, so an anonymous caller is rejected
17
+ * with `ERR_UNAUTHENTICATED` even when `readMode === 'published'`. The
18
+ * actor's collection read ability is asserted by `assertActorCanPerform`.
19
+ * 2. **`'public'` rejects `locale: 'all'`.** The multi-locale map would expose
20
+ * every stored translation, withheld or partial, so a public read must
21
+ * name one locale. This applies to every collection, including those
22
+ * without `advertiseLocales`.
23
+ *
24
+ * Locale visibility is read intent, not authority: nothing here elevates a
25
+ * context, and `_bypassBeforeRead` has no bearing on it.
26
+ */
27
+ export function assertLocaleVisibility(context, collectionPath, visibility, locale) {
28
+ if (visibility === 'editorial') {
29
+ if (context?.actor == null) {
30
+ throw ERR_UNAUTHENTICATED({
31
+ message: `anonymous read of '${collectionPath}' cannot use localeVisibility: 'editorial'`,
32
+ });
33
+ }
34
+ return;
35
+ }
36
+ if (locale === 'all') {
37
+ throw ERR_VALIDATION({
38
+ message: `locale: 'all' is an editorial read on '${collectionPath}'; ` +
39
+ `a public read must request a single locale`,
40
+ details: { collectionPath },
41
+ });
42
+ }
43
+ }
@@ -0,0 +1,46 @@
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 { AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { ErrorCodes } from '../lib/errors.js';
11
+ import { assertLocaleVisibility } from './assert-locale-visibility.js';
12
+ const anonymous = createRequestContext({ actor: null, readMode: 'published' });
13
+ const admin = createSuperAdminContext({ id: 'test-admin' });
14
+ describe('assertLocaleVisibility', () => {
15
+ it('permits an anonymous public read of a single locale', () => {
16
+ expect(() => assertLocaleVisibility(anonymous, 'pages', 'public', 'es')).not.toThrow();
17
+ });
18
+ it('rejects an anonymous editorial read even with readMode published', () => {
19
+ try {
20
+ assertLocaleVisibility(anonymous, 'pages', 'editorial', 'es');
21
+ expect.fail('expected ERR_UNAUTHENTICATED');
22
+ }
23
+ catch (err) {
24
+ expect(err).toBeInstanceOf(AuthError);
25
+ expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
26
+ }
27
+ });
28
+ it('rejects an editorial read with no context', () => {
29
+ expect(() => assertLocaleVisibility(undefined, 'pages', 'editorial', 'es')).toThrow(AuthError);
30
+ });
31
+ it('permits an authenticated editorial read, including locale all', () => {
32
+ expect(() => assertLocaleVisibility(admin, 'pages', 'editorial', 'es')).not.toThrow();
33
+ expect(() => assertLocaleVisibility(admin, 'pages', 'editorial', 'all')).not.toThrow();
34
+ });
35
+ it('rejects a public locale all read, even for an authenticated actor', () => {
36
+ for (const ctx of [anonymous, admin]) {
37
+ try {
38
+ assertLocaleVisibility(ctx, 'pages', 'public', 'all');
39
+ expect.fail('expected ERR_VALIDATION');
40
+ }
41
+ catch (err) {
42
+ expect(err.code).toBe(ErrorCodes.VALIDATION);
43
+ }
44
+ }
45
+ });
46
+ });
@@ -7,5 +7,6 @@
7
7
  */
8
8
  export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
+ export { assertLocaleVisibility } from './assert-locale-visibility.js';
10
11
  export { type ActorAbilitySnapshot, filterReadableCollections, } from './filter-readable-collections.js';
11
12
  export { COLLECTION_ABILITY_VERBS, type CollectionAbilityResource, type CollectionAbilityResourceDescriptor, type CollectionAbilityVerb, collectionAbilityKey, type DocumentAbilityResource, type DocumentAbilityVerbFor, documentAbilityKey, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, type SingletonAbilityResource, type SingletonAbilityResourceDescriptor, type SingletonAbilityVerb, } from './register-collection-abilities.js';
@@ -7,5 +7,6 @@
7
7
  */
8
8
  export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
+ export { assertLocaleVisibility } from './assert-locale-visibility.js';
10
11
  export { filterReadableCollections, } from './filter-readable-collections.js';
11
12
  export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, documentAbilityKey, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, } from './register-collection-abilities.js';
@@ -0,0 +1,30 @@
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 { resolveItemViewColumns } from './config.js';
10
+ const itemViewCols = [{ fieldName: 'title', label: 'Title' }];
11
+ const pickerCols = [{ fieldName: 'name', label: 'Name' }];
12
+ const cfg = (over) => ({ slug: 'x', ...over });
13
+ describe('resolveItemViewColumns', () => {
14
+ it('returns itemView when present', () => {
15
+ expect(resolveItemViewColumns(cfg({ itemView: itemViewCols }))).toBe(itemViewCols);
16
+ });
17
+ it('falls back to the deprecated picker alias', () => {
18
+ expect(resolveItemViewColumns(cfg({ picker: pickerCols }))).toBe(pickerCols);
19
+ });
20
+ it('prefers itemView over picker when both are present', () => {
21
+ expect(resolveItemViewColumns(cfg({ itemView: itemViewCols, picker: pickerCols }))).toBe(itemViewCols);
22
+ });
23
+ it('returns undefined when neither is set', () => {
24
+ expect(resolveItemViewColumns(cfg({}))).toBeUndefined();
25
+ });
26
+ it('tolerates null / undefined config', () => {
27
+ expect(resolveItemViewColumns(null)).toBeUndefined();
28
+ expect(resolveItemViewColumns(undefined)).toBeUndefined();
29
+ });
30
+ });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './@types/index.js';
2
- export { type ActorAbilitySnapshot, applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityResource, type CollectionAbilityResourceDescriptor, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, type DocumentAbilityResource, type DocumentAbilityVerbFor, documentAbilityKey, filterReadableCollections, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, type SingletonAbilityResource, type SingletonAbilityResourceDescriptor, type SingletonAbilityVerb, } from './auth/index.js';
2
+ export { type ActorAbilitySnapshot, applyBeforeRead, assertActorCanPerform, assertLocaleVisibility, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityResource, type CollectionAbilityResourceDescriptor, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, type DocumentAbilityResource, type DocumentAbilityVerbFor, documentAbilityKey, filterReadableCollections, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, type SingletonAbilityResource, type SingletonAbilityResourceDescriptor, type SingletonAbilityVerb, } from './auth/index.js';
3
3
  export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, getSingletonAdminConfig, orderByContentLocale, } from './config/config.js';
4
4
  export { type CollectionGroupBucket, groupCollectionsForAdmin, } from './config/group-collections.js';
5
5
  export { resolveRoutes } from './config/routes.js';
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // breaking package-surface change.
12
12
  // ---------------------------------------------------------------------------
13
13
  export * from './@types/index.js';
14
- export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, documentAbilityKey, filterReadableCollections, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, } from './auth/index.js';
14
+ export { applyBeforeRead, assertActorCanPerform, assertLocaleVisibility, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, documentAbilityKey, filterReadableCollections, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, } from './auth/index.js';
15
15
  export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, getSingletonAdminConfig, orderByContentLocale, } from './config/config.js';
16
16
  export { groupCollectionsForAdmin, } from './config/group-collections.js';
17
17
  export { resolveRoutes } from './config/routes.js';
@@ -100,6 +100,9 @@ export async function applyRichTextEmbed(ctx, data) {
100
100
  requestContext,
101
101
  readContext,
102
102
  readMode: 'published',
103
+ // Snapshots copied into a saved parent are public delivery: an editor's
104
+ // authenticated session must not capture a withheld translation.
105
+ localeVisibility: 'public',
103
106
  locale: ctx.defaultLocale,
104
107
  richTextPopulate,
105
108
  }),
@@ -1086,6 +1086,83 @@ describe('Document lifecycle service', () => {
1086
1086
  patches: [],
1087
1087
  })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
1088
1088
  });
1089
+ it('writes a combined locale and content save in one transaction, locale first', async () => {
1090
+ const afterSystemFieldsChange = vi.fn();
1091
+ const afterUpdate = vi.fn();
1092
+ const { db, getDocumentById, getDocumentSystemFieldsForUpdate, createDocumentVersion, withTransaction, } = createMockDb();
1093
+ getDocumentById.mockResolvedValue({
1094
+ document_version_id: 'previous',
1095
+ fields: { title: 'Old' },
1096
+ });
1097
+ getDocumentSystemFieldsForUpdate.mockResolvedValue({
1098
+ path: 'doc',
1099
+ source_locale: 'en',
1100
+ availableLocales: ['en'],
1101
+ });
1102
+ const order = [];
1103
+ let outerSettled = false;
1104
+ vi.mocked(db.commands.documents.setDocumentAvailableLocales).mockImplementation(async () => {
1105
+ // Watch the outer guarded unit that is running this write.
1106
+ const outer = withTransaction.mock.results[0]?.value;
1107
+ if (outer == null)
1108
+ throw new Error('locale write ran outside a guarded unit');
1109
+ void outer.finally(() => {
1110
+ outerSettled = true;
1111
+ });
1112
+ order.push(`locales:${db.revisions.isInTransaction()}`);
1113
+ });
1114
+ createDocumentVersion.mockImplementation(async () => {
1115
+ // Version persistence may open a nested unit; it must still be inside
1116
+ // the outer guarded unit that wrote the locales.
1117
+ order.push(`version:${db.revisions.isInTransaction()}:outerOpen=${!outerSettled}`);
1118
+ return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
1119
+ });
1120
+ const definition = { ...minimalCollection, hooks: { afterSystemFieldsChange, afterUpdate } };
1121
+ await updateDocumentWithPatches(buildCtx(db, definition), {
1122
+ expectedRevision: 1,
1123
+ documentId: 'doc-1',
1124
+ patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
1125
+ availableLocales: ['en', 'fr'],
1126
+ });
1127
+ // Both writes run inside the same guarded unit of work, so the locale
1128
+ // change and the new content version commit or roll back together.
1129
+ expect(order).toEqual(['locales:true', 'version:true:outerOpen=true']);
1130
+ expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
1131
+ expect(afterUpdate).toHaveBeenCalledOnce();
1132
+ });
1133
+ it('fails a combined save as a whole when the content version write fails', async () => {
1134
+ const afterSystemFieldsChange = vi.fn();
1135
+ const afterUpdate = vi.fn();
1136
+ const { db, getDocumentById, getDocumentSystemFieldsForUpdate, createDocumentVersion, auditAppend, } = createMockDb();
1137
+ getDocumentById.mockResolvedValue({
1138
+ document_version_id: 'previous',
1139
+ fields: { title: 'Old' },
1140
+ });
1141
+ getDocumentSystemFieldsForUpdate.mockResolvedValue({
1142
+ path: 'doc',
1143
+ source_locale: 'en',
1144
+ availableLocales: ['en'],
1145
+ });
1146
+ createDocumentVersion.mockRejectedValue(new Error('version write failed'));
1147
+ const auditInTransaction = [];
1148
+ auditAppend.mockImplementation(async () => {
1149
+ auditInTransaction.push(db.revisions.isInTransaction());
1150
+ return { id: 'audit-1' };
1151
+ });
1152
+ const definition = { ...minimalCollection, hooks: { afterSystemFieldsChange, afterUpdate } };
1153
+ await expect(updateDocumentWithPatches(buildCtx(db, definition), {
1154
+ expectedRevision: 1,
1155
+ documentId: 'doc-1',
1156
+ patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
1157
+ availableLocales: ['en', 'fr'],
1158
+ })).rejects.toThrow('version write failed');
1159
+ // The locale write and its audit row ran inside the failed unit of work,
1160
+ // which the adapter rolls back; no committed-change hook reports it.
1161
+ expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
1162
+ expect(auditInTransaction.every(Boolean)).toBe(true);
1163
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
1164
+ expect(afterUpdate).not.toHaveBeenCalled();
1165
+ });
1089
1166
  it('classifies a post-patch afterUpdate failure without replaying the version write', async () => {
1090
1167
  const afterUpdate = vi
1091
1168
  .fn()
@@ -2189,6 +2266,64 @@ describe('Document lifecycle service', () => {
2189
2266
  currentPath: 'new-slug',
2190
2267
  }));
2191
2268
  });
2269
+ it('re-runs reconciliation on a no-op locale-only retry after a committed hook failure', async () => {
2270
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2271
+ setupDoc(getDocumentSystemFieldsForUpdate);
2272
+ const afterSystemFieldsChange = vi
2273
+ .fn()
2274
+ .mockRejectedValueOnce(new Error('search unavailable'))
2275
+ .mockResolvedValue(undefined);
2276
+ const ctx = buildCtx(db, {
2277
+ ...minimalCollection,
2278
+ hooks: { afterSystemFieldsChange },
2279
+ });
2280
+ await expect(updateDocumentSystemFields(ctx, {
2281
+ expectedRevision: 1,
2282
+ documentId: 'doc-1',
2283
+ availableLocales: ['en', 'fr'],
2284
+ })).rejects.toMatchObject({
2285
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2286
+ details: { revision: 2 },
2287
+ });
2288
+ // The committed locale set stays; the retry writes nothing new.
2289
+ setupDoc(getDocumentSystemFieldsForUpdate, { availableLocales: ['en', 'fr'] });
2290
+ const retry = await updateDocumentSystemFields(ctx, {
2291
+ expectedRevision: 2,
2292
+ documentId: 'doc-1',
2293
+ availableLocales: ['en', 'fr'],
2294
+ reconcile: true,
2295
+ });
2296
+ expect(retry).toMatchObject({ changed: false, reconciliation: true });
2297
+ expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
2298
+ expect(db.commands.documents.createDocumentVersion).not.toHaveBeenCalled();
2299
+ expect(auditAppend).toHaveBeenCalledOnce();
2300
+ expect(afterSystemFieldsChange).toHaveBeenCalledTimes(2);
2301
+ expect(afterSystemFieldsChange).toHaveBeenLastCalledWith(expect.objectContaining({
2302
+ requested: { path: false, availableLocales: true },
2303
+ changed: { path: false, availableLocales: false },
2304
+ reconciliation: true,
2305
+ previousAvailableLocales: ['en', 'fr'],
2306
+ currentAvailableLocales: ['en', 'fr'],
2307
+ }));
2308
+ });
2309
+ it('does not invoke the hook for a plain no-op locale write', async () => {
2310
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2311
+ setupDoc(getDocumentSystemFieldsForUpdate, { availableLocales: ['en', 'fr'] });
2312
+ const afterSystemFieldsChange = vi.fn();
2313
+ const ctx = buildCtx(db, {
2314
+ ...minimalCollection,
2315
+ hooks: { afterSystemFieldsChange },
2316
+ });
2317
+ const result = await updateDocumentSystemFields(ctx, {
2318
+ expectedRevision: 1,
2319
+ documentId: 'doc-1',
2320
+ availableLocales: ['fr', 'en', 'fr'],
2321
+ });
2322
+ expect(result).toMatchObject({ changed: false });
2323
+ expect(db.commands.documents.setDocumentAvailableLocales).not.toHaveBeenCalled();
2324
+ expect(auditAppend).not.toHaveBeenCalled();
2325
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
2326
+ });
2192
2327
  it('uses the locked transaction snapshot for audit and invalidation payloads', async () => {
2193
2328
  const { db, getDocumentById, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2194
2329
  getDocumentById.mockResolvedValue({
@@ -129,7 +129,7 @@
129
129
  * added).
130
130
  */
131
131
  import { type RequestContext } from '@byline/auth';
132
- import type { CollectionDefinition, FieldSet, IDbAdapter, PopulateFieldSpec, PopulateSpec, ReadContext, ReadMode, RelatedDocumentValue, RelationField } from '../@types/index.js';
132
+ import type { CollectionDefinition, FieldSet, IDbAdapter, LocaleVisibility, PopulateFieldSpec, PopulateSpec, ReadContext, ReadMode, RelatedDocumentValue, RelationField } from '../@types/index.js';
133
133
  export type { ReadContext } from '../@types/index.js';
134
134
  /** Build a fresh ReadContext. */
135
135
  export declare function createReadContext(overrides?: Partial<ReadContext>): ReadContext;
@@ -170,6 +170,14 @@ export interface PopulateOptions {
170
170
  * its last published version rather than leaking a draft.
171
171
  */
172
172
  readMode?: ReadMode;
173
+ /**
174
+ * Locale visibility forwarded to every target batch fetch and rich-text
175
+ * read. Each target resolves eligibility under its own collection opt-in,
176
+ * source locale, completeness ledger and checkbox set, so a parent's
177
+ * checked locale never authorizes a target's. Omitted ⇒ derived from
178
+ * `readMode` (`'published'` → `'public'`, otherwise `'editorial'`).
179
+ */
180
+ localeVisibility?: LocaleVisibility;
173
181
  /**
174
182
  * Request-scoped recursion guard. Omit to create a fresh context for
175
183
  * this top-level call. Threaded through by future read-side hooks to
@@ -132,6 +132,7 @@ import { createRequestContext } from '@byline/auth';
132
132
  import { compileBeforeReadFilters } from '../auth/apply-before-read.js';
133
133
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
134
134
  import { ERR_READ_BUDGET_EXCEEDED } from '../lib/errors.js';
135
+ import { resolveLocaleVisibility } from '../storage/locale-resolution.js';
135
136
  import { applyAfterRead } from './document-read.js';
136
137
  import { createRichTextDocumentReader, populateRichTextFields } from './richtext-populate.js';
137
138
  import { walkFieldTree } from './walk-field-tree.js';
@@ -161,6 +162,7 @@ export async function populateDocuments(opts) {
161
162
  const securityDomain = opts.securityDomain ?? {};
162
163
  const operationRequestContext = opts.requestContext ??
163
164
  createRequestContext({ readMode: opts.readMode ?? 'any', locale: opts.locale });
165
+ const localeVisibility = opts.localeVisibility ?? resolveLocaleVisibility(opts.readMode ?? 'any');
164
166
  const populate = opts.populate;
165
167
  const requestedDepth = opts.depth ?? (populate !== undefined ? 1 : 0);
166
168
  const maxDepth = Math.max(0, Math.min(requestedDepth, ctx.maxDepth));
@@ -248,6 +250,7 @@ export async function populateDocuments(opts) {
248
250
  fields: selectList,
249
251
  readMode: opts.readMode,
250
252
  filters: targetFilters,
253
+ localeVisibility,
251
254
  });
252
255
  }
253
256
  const byId = new Map();
@@ -333,6 +336,7 @@ export async function populateDocuments(opts) {
333
336
  requestContext: opts.requestContext,
334
337
  readContext: ctx,
335
338
  readMode: opts.readMode ?? 'any',
339
+ localeVisibility,
336
340
  locale: opts.locale,
337
341
  bypassBeforeRead: opts.bypassBeforeRead,
338
342
  richTextPopulate: opts.richTextPopulate,
@@ -26,7 +26,7 @@
26
26
  import type { RequestContext } from '@byline/auth';
27
27
  import { type FieldSet, type RichTextField, type RichTextPopulateFn, type RichTextReadDocumentsFn } from '../@types/field-types.js';
28
28
  import type { CollectionDefinition } from '../@types/collection-types.js';
29
- import type { IDbAdapter, ReadContext, ReadMode } from '../@types/index.js';
29
+ import type { IDbAdapter, LocaleVisibility, ReadContext, ReadMode } from '../@types/index.js';
30
30
  /**
31
31
  * One rich-text leaf yielded by `collectRichTextLeaves`. The walker hands
32
32
  * back a reference to the *parent container* (`parent[key]`) rather than
@@ -87,6 +87,12 @@ export declare function createRichTextDocumentReader(options: {
87
87
  requestContext: RequestContext;
88
88
  readContext: ReadContext;
89
89
  readMode: ReadMode;
90
+ /**
91
+ * Locale visibility for the batch fetch. Omitted ⇒ derived from `readMode`.
92
+ * Part of the materialization cache key, so a result read under one
93
+ * visibility is never reused for a read under another.
94
+ */
95
+ localeVisibility?: LocaleVisibility;
90
96
  locale?: string;
91
97
  bypassBeforeRead?: true;
92
98
  /** Private cache domain explicitly shared with the originating client read. */
@@ -10,6 +10,7 @@ import { bindReadContextAuthority, compileBeforeReadFilters } from '../auth/appl
10
10
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
11
11
  import { resolveReadContextRoot } from '../auth/read-context-scope.js';
12
12
  import { ERR_READ_BUDGET_EXCEEDED, ERR_VALIDATION } from '../lib/errors.js';
13
+ import { resolveLocaleVisibility } from '../storage/locale-resolution.js';
13
14
  import { applyAfterRead } from './document-read.js';
14
15
  import { walkFieldTree } from './walk-field-tree.js';
15
16
  /**
@@ -74,6 +75,7 @@ export async function populateRichTextFields(options) {
74
75
  /** Build the secure batch reader exposed to editor-agnostic adapters. */
75
76
  export function createRichTextDocumentReader(options) {
76
77
  const { db, collections, requestContext, readContext, readMode, locale, bypassBeforeRead, richTextPopulate, } = options;
78
+ const localeVisibility = options.localeVisibility ?? resolveLocaleVisibility(readMode);
77
79
  const securityDomain = options.securityDomain ?? {};
78
80
  const state = getRichTextReaderState(readContext);
79
81
  const read = async ({ collectionPath, documentIds, fields }) => {
@@ -116,7 +118,7 @@ export function createRichTextDocumentReader(options) {
116
118
  const resultById = new Map();
117
119
  const idsToFetch = [];
118
120
  for (const documentId of new Set(documentIds)) {
119
- const key = richTextMaterializationKey(collectionId, documentId, requestContext.requestId, locale, readMode, projection);
121
+ const key = richTextMaterializationKey(collectionId, documentId, requestContext.requestId, locale, readMode, localeVisibility, projection);
120
122
  if (state.cache.has(key)) {
121
123
  const cached = state.cache.get(key);
122
124
  if (cached != null)
@@ -132,6 +134,7 @@ export function createRichTextDocumentReader(options) {
132
134
  document_ids: idsToFetch,
133
135
  fields,
134
136
  readMode,
137
+ localeVisibility,
135
138
  locale,
136
139
  filters: filters && filters.length > 0 ? filters : undefined,
137
140
  }));
@@ -139,7 +142,7 @@ export function createRichTextDocumentReader(options) {
139
142
  .filter((doc) => typeof doc.document_id === 'string')
140
143
  .map((doc) => [doc.document_id, doc]));
141
144
  for (const documentId of idsToFetch) {
142
- const materializationKey = richTextMaterializationKey(collectionId, documentId, requestContext.requestId, locale, readMode, projection);
145
+ const materializationKey = richTextMaterializationKey(collectionId, documentId, requestContext.requestId, locale, readMode, localeVisibility, projection);
143
146
  // Earlier items in this batch may recursively populate a later target.
144
147
  // Honour that completed/active state rather than reprocessing the stale
145
148
  // raw row captured by the outer batch and overwriting its redacted cache.
@@ -219,8 +222,8 @@ function getRichTextReaderState(readContext) {
219
222
  richTextReaderStates.set(root, state);
220
223
  return state;
221
224
  }
222
- function richTextMaterializationKey(collectionId, documentId, requestId, locale, readMode, projection) {
223
- return `${collectionId}:${documentId}:${requestId}:${locale ?? 'all'}:${readMode}:${projection}`;
225
+ function richTextMaterializationKey(collectionId, documentId, requestId, locale, readMode, localeVisibility, projection) {
226
+ return `${collectionId}:${documentId}:${requestId}:${locale ?? 'all'}:${readMode}:${localeVisibility}:${projection}`;
224
227
  }
225
228
  // ---------------------------------------------------------------------------
226
229
  // Boot-time validation
@@ -8,6 +8,7 @@
8
8
  export { fingerprintCollection } from './collection-fingerprint.js';
9
9
  export { assertDocumentVersionParent, type DocumentVersionParentAssertion, type DocumentVersionParentConflictReason, } from './document-version-parent.js';
10
10
  export { ALL_STORE_TYPES, type FieldStoreKind, type FieldStoreMapping, fieldTypeToStore, fieldTypeToStoreType, type StoreType, } from './field-store-map.js';
11
+ export { deriveVersionLocaleLedger, resolveLocaleVisibility, resolveReadLocale, type VersionLocaleLedger, } from './locale-resolution.js';
11
12
  export { flattenFieldSetData } from './storage-flatten.js';
12
13
  export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, } from './storage-restore.js';
13
14
  export { resolveStoreTypes } from './storage-utils.js';
@@ -8,6 +8,7 @@
8
8
  export { fingerprintCollection } from './collection-fingerprint.js';
9
9
  export { assertDocumentVersionParent, } from './document-version-parent.js';
10
10
  export { ALL_STORE_TYPES, fieldTypeToStore, fieldTypeToStoreType, } from './field-store-map.js';
11
+ export { deriveVersionLocaleLedger, resolveLocaleVisibility, resolveReadLocale, } from './locale-resolution.js';
11
12
  export { flattenFieldSetData } from './storage-flatten.js';
12
13
  export { extractFlattenedFieldValue, restoreFieldSetData, } from './storage-restore.js';
13
14
  export { resolveStoreTypes } from './storage-utils.js';