@byline/core 6.5.0 → 6.7.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.
- package/dist/@types/db-types.d.ts +60 -0
- package/dist/auth/assert-locale-visibility.d.ts +27 -0
- package/dist/auth/assert-locale-visibility.js +43 -0
- package/dist/auth/assert-locale-visibility.test.node.d.ts +8 -0
- package/dist/auth/assert-locale-visibility.test.node.js +46 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/services/document-lifecycle/internals.js +3 -0
- package/dist/services/document-lifecycle.test.node.js +135 -0
- package/dist/services/populate.d.ts +9 -1
- package/dist/services/populate.js +4 -0
- package/dist/services/richtext-populate.d.ts +7 -1
- package/dist/services/richtext-populate.js +7 -4
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/locale-resolution.d.ts +76 -0
- package/dist/storage/locale-resolution.js +122 -0
- package/dist/storage/locale-resolution.test.node.d.ts +8 -0
- package/dist/storage/locale-resolution.test.node.js +245 -0
- package/package.json +2 -2
|
@@ -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,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,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
|
+
});
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/auth/index.js
CHANGED
|
@@ -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';
|
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
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/storage/index.js
CHANGED
|
@@ -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';
|
|
@@ -0,0 +1,76 @@
|
|
|
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 { LocaleVisibility, MissingLocalePolicy, ReadMode } from '../@types/index.js';
|
|
9
|
+
import type { FlattenedFieldValue } from './storage-row-types.js';
|
|
10
|
+
/**
|
|
11
|
+
* One version's entry in the `byline_document_version_locales` ledger: the
|
|
12
|
+
* concrete locales its content is complete in, or `localeAgnostic` when the
|
|
13
|
+
* version carries only the `'all'` sentinel (no localized content).
|
|
14
|
+
*/
|
|
15
|
+
export interface VersionLocaleLedger {
|
|
16
|
+
availableLocales: readonly string[];
|
|
17
|
+
localeAgnostic: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Derive a completeness ledger from loaded store rows, using the same rule the
|
|
21
|
+
* write path records: a locale is complete when it covers every localized field
|
|
22
|
+
* path the source locale has; a version with no localized rows is
|
|
23
|
+
* locale-agnostic. Used only for versions written before the ledger existed and
|
|
24
|
+
* not yet backfilled. It can only see the rows a read loaded, so a projected
|
|
25
|
+
* read of such a version may still misjudge completeness.
|
|
26
|
+
*/
|
|
27
|
+
export declare function deriveVersionLocaleLedger(rows: readonly FlattenedFieldValue[], sourceLocale: string): VersionLocaleLedger;
|
|
28
|
+
/**
|
|
29
|
+
* The per-document facts a locale-eligibility decision needs. `ledger` is the
|
|
30
|
+
* selected version's completeness ledger; `availableLocales` is the document's
|
|
31
|
+
* saved editorial checkbox set; `advertiseLocales` is the collection's opt-in
|
|
32
|
+
* to that control.
|
|
33
|
+
*/
|
|
34
|
+
export interface LocaleEligibility {
|
|
35
|
+
visibility: LocaleVisibility;
|
|
36
|
+
sourceLocale: string;
|
|
37
|
+
ledger: VersionLocaleLedger;
|
|
38
|
+
advertiseLocales: boolean;
|
|
39
|
+
availableLocales: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The locale visibility a read uses: an explicit `localeVisibility` wins;
|
|
43
|
+
* otherwise `status: 'any'` implies `'editorial'` and an omitted or
|
|
44
|
+
* `'published'` status implies `'public'`. Resolving visibility never grants
|
|
45
|
+
* access; callers still authorize `'editorial'` separately.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveLocaleVisibility(status: ReadMode | undefined, explicit?: LocaleVisibility): LocaleVisibility;
|
|
48
|
+
/** The outcome of one read's language decision for one document version. */
|
|
49
|
+
export interface ReadLocaleDecision {
|
|
50
|
+
/** The locale to restore fields in; `undefined` for a multi-locale (`'all'`) read. */
|
|
51
|
+
restoreLocale: string | undefined;
|
|
52
|
+
/** The `resolvedLocale` read metadata: the locale the fields were selected in, or `null`. */
|
|
53
|
+
resolvedLocale: string | null;
|
|
54
|
+
/** `true` when localized values must be withheld and only non-localized values returned. */
|
|
55
|
+
withholdLocalized: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Decide, for one document version, which locale a read restores, what it
|
|
59
|
+
* reports as `resolvedLocale`, and whether localized values must be withheld.
|
|
60
|
+
* Shared by every storage adapter so the language decision is identical across
|
|
61
|
+
* engines and is made from the whole version, before any field projection.
|
|
62
|
+
*
|
|
63
|
+
* - `locale: 'all'` restores locale maps and reports `null`.
|
|
64
|
+
* - A locale-agnostic version restores the requested locale and reports `null`.
|
|
65
|
+
* - `'fallback'` restores and reports the first eligible chain entry.
|
|
66
|
+
* - `'omit'`, `'empty'` and an omitted policy are exact: an eligible locale is
|
|
67
|
+
* restored and reported. Under `'public'` visibility an ineligible locale
|
|
68
|
+
* withholds localized values and reports `null`; under `'editorial'` the
|
|
69
|
+
* exact locale is restored even when partial.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveReadLocale(params: {
|
|
72
|
+
locale: string;
|
|
73
|
+
chain: readonly string[];
|
|
74
|
+
onMissingLocale: MissingLocalePolicy | undefined;
|
|
75
|
+
eligibility: LocaleEligibility;
|
|
76
|
+
}): ReadLocaleDecision;
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Derive a completeness ledger from loaded store rows, using the same rule the
|
|
10
|
+
* write path records: a locale is complete when it covers every localized field
|
|
11
|
+
* path the source locale has; a version with no localized rows is
|
|
12
|
+
* locale-agnostic. Used only for versions written before the ledger existed and
|
|
13
|
+
* not yet backfilled. It can only see the rows a read loaded, so a projected
|
|
14
|
+
* read of such a version may still misjudge completeness.
|
|
15
|
+
*/
|
|
16
|
+
export function deriveVersionLocaleLedger(rows, sourceLocale) {
|
|
17
|
+
const pathsByLocale = new Map();
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
if (row.locale === 'all' || row.field_type === 'meta')
|
|
20
|
+
continue;
|
|
21
|
+
let paths = pathsByLocale.get(row.locale);
|
|
22
|
+
if (paths == null) {
|
|
23
|
+
paths = new Set();
|
|
24
|
+
pathsByLocale.set(row.locale, paths);
|
|
25
|
+
}
|
|
26
|
+
paths.add(row.field_path.join('.'));
|
|
27
|
+
}
|
|
28
|
+
if (pathsByLocale.size === 0)
|
|
29
|
+
return { availableLocales: [], localeAgnostic: true };
|
|
30
|
+
const canonical = pathsByLocale.get(sourceLocale) ?? new Set();
|
|
31
|
+
const availableLocales = [];
|
|
32
|
+
for (const [locale, paths] of pathsByLocale) {
|
|
33
|
+
let covers = true;
|
|
34
|
+
for (const path of canonical) {
|
|
35
|
+
if (!paths.has(path)) {
|
|
36
|
+
covers = false;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (covers)
|
|
41
|
+
availableLocales.push(locale);
|
|
42
|
+
}
|
|
43
|
+
return { availableLocales, localeAgnostic: false };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether `locale` may be delivered for one document version.
|
|
47
|
+
*
|
|
48
|
+
* - A locale-agnostic version renders identically everywhere: always eligible.
|
|
49
|
+
* - The source locale is always eligible, whatever its checkbox says.
|
|
50
|
+
* - Any other locale must be complete on the selected version.
|
|
51
|
+
* - Under `'public'` visibility in a collection with `advertiseLocales`, it
|
|
52
|
+
* must also be checked in the document's editorial set. An empty set
|
|
53
|
+
* authorizes no additional translation.
|
|
54
|
+
*/
|
|
55
|
+
function isLocaleEligible(locale, eligibility) {
|
|
56
|
+
const { visibility, sourceLocale, ledger, advertiseLocales, availableLocales } = eligibility;
|
|
57
|
+
if (ledger.localeAgnostic)
|
|
58
|
+
return true;
|
|
59
|
+
if (locale === sourceLocale)
|
|
60
|
+
return true;
|
|
61
|
+
if (!ledger.availableLocales.includes(locale))
|
|
62
|
+
return false;
|
|
63
|
+
if (visibility === 'editorial' || !advertiseLocales)
|
|
64
|
+
return true;
|
|
65
|
+
return availableLocales.includes(locale);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Walk `chain` (`[requested, …, floor]`) and return the first locale eligible
|
|
69
|
+
* under `eligibility`. Every candidate must pass, so a withheld intermediate
|
|
70
|
+
* locale can never be selected. The floor is terminal and returned when no
|
|
71
|
+
* earlier candidate qualifies.
|
|
72
|
+
*/
|
|
73
|
+
function resolveEligibleLocale(chain, eligibility) {
|
|
74
|
+
for (const candidate of chain) {
|
|
75
|
+
if (isLocaleEligible(candidate, eligibility))
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
// biome-ignore lint/style/noNonNullAssertion: a locale chain always has a floor
|
|
79
|
+
return chain[chain.length - 1];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The locale visibility a read uses: an explicit `localeVisibility` wins;
|
|
83
|
+
* otherwise `status: 'any'` implies `'editorial'` and an omitted or
|
|
84
|
+
* `'published'` status implies `'public'`. Resolving visibility never grants
|
|
85
|
+
* access; callers still authorize `'editorial'` separately.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveLocaleVisibility(status, explicit) {
|
|
88
|
+
if (explicit != null)
|
|
89
|
+
return explicit;
|
|
90
|
+
return status === 'any' ? 'editorial' : 'public';
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Decide, for one document version, which locale a read restores, what it
|
|
94
|
+
* reports as `resolvedLocale`, and whether localized values must be withheld.
|
|
95
|
+
* Shared by every storage adapter so the language decision is identical across
|
|
96
|
+
* engines and is made from the whole version, before any field projection.
|
|
97
|
+
*
|
|
98
|
+
* - `locale: 'all'` restores locale maps and reports `null`.
|
|
99
|
+
* - A locale-agnostic version restores the requested locale and reports `null`.
|
|
100
|
+
* - `'fallback'` restores and reports the first eligible chain entry.
|
|
101
|
+
* - `'omit'`, `'empty'` and an omitted policy are exact: an eligible locale is
|
|
102
|
+
* restored and reported. Under `'public'` visibility an ineligible locale
|
|
103
|
+
* withholds localized values and reports `null`; under `'editorial'` the
|
|
104
|
+
* exact locale is restored even when partial.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveReadLocale(params) {
|
|
107
|
+
const { locale, chain, onMissingLocale, eligibility } = params;
|
|
108
|
+
if (locale === 'all') {
|
|
109
|
+
return { restoreLocale: undefined, resolvedLocale: null, withholdLocalized: false };
|
|
110
|
+
}
|
|
111
|
+
if (eligibility.ledger.localeAgnostic) {
|
|
112
|
+
return { restoreLocale: locale, resolvedLocale: null, withholdLocalized: false };
|
|
113
|
+
}
|
|
114
|
+
if (onMissingLocale === 'fallback') {
|
|
115
|
+
const selected = resolveEligibleLocale(chain, eligibility);
|
|
116
|
+
return { restoreLocale: selected, resolvedLocale: selected, withholdLocalized: false };
|
|
117
|
+
}
|
|
118
|
+
if (eligibility.visibility === 'public' && !isLocaleEligible(locale, eligibility)) {
|
|
119
|
+
return { restoreLocale: locale, resolvedLocale: null, withholdLocalized: true };
|
|
120
|
+
}
|
|
121
|
+
return { restoreLocale: locale, resolvedLocale: locale, withholdLocalized: false };
|
|
122
|
+
}
|
|
@@ -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,245 @@
|
|
|
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 { deriveVersionLocaleLedger, resolveLocaleVisibility, resolveReadLocale, } from './locale-resolution.js';
|
|
10
|
+
// The eligibility rule and the fallback walk are private to the module; these
|
|
11
|
+
// helpers observe them through `resolveReadLocale`, the only adapter-facing
|
|
12
|
+
// entry point. A locale is eligible exactly when a fallback read toward the
|
|
13
|
+
// source selects it, and the walk's result is the locale that read restores.
|
|
14
|
+
function isLocaleEligible(locale, eligibility) {
|
|
15
|
+
const chain = locale === eligibility.sourceLocale ? [locale] : [locale, eligibility.sourceLocale];
|
|
16
|
+
return (resolveReadLocale({ locale, chain, onMissingLocale: 'fallback', eligibility }).restoreLocale ===
|
|
17
|
+
locale);
|
|
18
|
+
}
|
|
19
|
+
function resolveEligibleLocale(chain, eligibility) {
|
|
20
|
+
const decision = resolveReadLocale({
|
|
21
|
+
locale: chain[0],
|
|
22
|
+
chain,
|
|
23
|
+
onMissingLocale: 'fallback',
|
|
24
|
+
eligibility,
|
|
25
|
+
});
|
|
26
|
+
return decision.restoreLocale;
|
|
27
|
+
}
|
|
28
|
+
describe('deriveVersionLocaleLedger', () => {
|
|
29
|
+
const row = (locale, path, field_type = 'text') => ({ locale, field_path: path.split('.'), field_type });
|
|
30
|
+
it('records the locales that cover every source-locale localized path', () => {
|
|
31
|
+
const ledger = deriveVersionLocaleLedger([
|
|
32
|
+
row('en', 'title'),
|
|
33
|
+
row('en', 'body'),
|
|
34
|
+
row('de', 'title'),
|
|
35
|
+
row('de', 'body'),
|
|
36
|
+
row('fr', 'title'),
|
|
37
|
+
row('all', 'sku'),
|
|
38
|
+
row('all', 'content.0', 'meta'),
|
|
39
|
+
], 'en');
|
|
40
|
+
expect(ledger).toEqual({ availableLocales: ['en', 'de'], localeAgnostic: false });
|
|
41
|
+
});
|
|
42
|
+
it('marks a version with no localized rows as locale-agnostic', () => {
|
|
43
|
+
expect(deriveVersionLocaleLedger([row('all', 'sku')], 'en')).toEqual({
|
|
44
|
+
availableLocales: [],
|
|
45
|
+
localeAgnostic: true,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('isLocaleEligible', () => {
|
|
50
|
+
const base = {
|
|
51
|
+
visibility: 'public',
|
|
52
|
+
sourceLocale: 'en',
|
|
53
|
+
ledger: { availableLocales: ['en', 'es', 'de'], localeAgnostic: false },
|
|
54
|
+
advertiseLocales: true,
|
|
55
|
+
availableLocales: ['de'],
|
|
56
|
+
};
|
|
57
|
+
it('public: a complete and checked translation is eligible', () => {
|
|
58
|
+
expect(isLocaleEligible('de', base)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it('public: a complete but unchecked translation is not eligible', () => {
|
|
61
|
+
expect(isLocaleEligible('es', base)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('public: a checked but incomplete translation is not eligible', () => {
|
|
64
|
+
expect(isLocaleEligible('fr', { ...base, availableLocales: ['fr'] })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it('public: the source is eligible even when its checkbox is off', () => {
|
|
67
|
+
expect(isLocaleEligible('en', { ...base, availableLocales: [] })).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('public: an empty advertised set authorizes no additional translation', () => {
|
|
70
|
+
expect(isLocaleEligible('de', { ...base, availableLocales: [] })).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it('public: a collection without advertiseLocales is gated by completeness alone', () => {
|
|
73
|
+
const optOut = { ...base, advertiseLocales: false, availableLocales: [] };
|
|
74
|
+
expect(isLocaleEligible('es', optOut)).toBe(true);
|
|
75
|
+
expect(isLocaleEligible('fr', optOut)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
it('a locale-agnostic version is eligible in every locale', () => {
|
|
78
|
+
const agnostic = { ...base, ledger: { availableLocales: [], localeAgnostic: true } };
|
|
79
|
+
expect(isLocaleEligible('fr', agnostic)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('editorial: a complete but unchecked translation is eligible', () => {
|
|
82
|
+
expect(isLocaleEligible('es', { ...base, visibility: 'editorial' })).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it('editorial: completeness is still required', () => {
|
|
85
|
+
expect(isLocaleEligible('fr', { ...base, visibility: 'editorial' })).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('resolveEligibleLocale', () => {
|
|
89
|
+
const base = {
|
|
90
|
+
visibility: 'public',
|
|
91
|
+
sourceLocale: 'en',
|
|
92
|
+
ledger: { availableLocales: ['en', 'es', 'es-MX'], localeAgnostic: false },
|
|
93
|
+
advertiseLocales: true,
|
|
94
|
+
availableLocales: [],
|
|
95
|
+
};
|
|
96
|
+
it('public: an unchecked translation falls back to the source', () => {
|
|
97
|
+
expect(resolveEligibleLocale(['es', 'en'], base)).toBe('en');
|
|
98
|
+
});
|
|
99
|
+
it('editorial: the same unchecked translation is selected', () => {
|
|
100
|
+
expect(resolveEligibleLocale(['es', 'en'], { ...base, visibility: 'editorial' })).toBe('es');
|
|
101
|
+
});
|
|
102
|
+
it('public: a withheld intermediate candidate is skipped', () => {
|
|
103
|
+
// Named fallback chains are not configurable yet; this pins that every
|
|
104
|
+
// candidate must satisfy the policy if they are added later.
|
|
105
|
+
const eligibility = { ...base, availableLocales: ['es'] };
|
|
106
|
+
expect(resolveEligibleLocale(['es-MX', 'es', 'en'], eligibility)).toBe('es');
|
|
107
|
+
expect(resolveEligibleLocale(['es-MX', 'es', 'en'], base)).toBe('en');
|
|
108
|
+
});
|
|
109
|
+
it('returns the floor when nothing earlier qualifies', () => {
|
|
110
|
+
expect(resolveEligibleLocale(['fr', 'en'], base)).toBe('en');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('resolveLocaleVisibility', () => {
|
|
114
|
+
it('defaults to public for an omitted or published status', () => {
|
|
115
|
+
expect(resolveLocaleVisibility(undefined)).toBe('public');
|
|
116
|
+
expect(resolveLocaleVisibility('published')).toBe('public');
|
|
117
|
+
});
|
|
118
|
+
it('defaults to editorial for status any', () => {
|
|
119
|
+
expect(resolveLocaleVisibility('any')).toBe('editorial');
|
|
120
|
+
});
|
|
121
|
+
it('an explicit value wins over the status default', () => {
|
|
122
|
+
expect(resolveLocaleVisibility('published', 'editorial')).toBe('editorial');
|
|
123
|
+
expect(resolveLocaleVisibility('any', 'public')).toBe('public');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('resolveReadLocale — the seven resolvedLocale result types', () => {
|
|
127
|
+
const eligibility = (overrides = {}) => ({
|
|
128
|
+
visibility: 'public',
|
|
129
|
+
sourceLocale: 'en',
|
|
130
|
+
// Spanish complete but unchecked; German partial (not in the ledger).
|
|
131
|
+
ledger: { availableLocales: ['en', 'es'], localeAgnostic: false },
|
|
132
|
+
advertiseLocales: true,
|
|
133
|
+
availableLocales: [],
|
|
134
|
+
...overrides,
|
|
135
|
+
});
|
|
136
|
+
const chain = (locale) => (locale === 'en' ? ['en'] : [locale, 'en']);
|
|
137
|
+
it('R1: fallback reports the selected permitted locale, including the source', () => {
|
|
138
|
+
expect(resolveReadLocale({
|
|
139
|
+
locale: 'es',
|
|
140
|
+
chain: chain('es'),
|
|
141
|
+
onMissingLocale: 'fallback',
|
|
142
|
+
eligibility: eligibility({ availableLocales: ['es'] }),
|
|
143
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: 'es', withholdLocalized: false });
|
|
144
|
+
expect(resolveReadLocale({
|
|
145
|
+
locale: 'es',
|
|
146
|
+
chain: chain('es'),
|
|
147
|
+
onMissingLocale: 'fallback',
|
|
148
|
+
eligibility: eligibility(),
|
|
149
|
+
})).toEqual({ restoreLocale: 'en', resolvedLocale: 'en', withholdLocalized: false });
|
|
150
|
+
expect(resolveReadLocale({
|
|
151
|
+
locale: 'es',
|
|
152
|
+
chain: chain('es'),
|
|
153
|
+
onMissingLocale: 'fallback',
|
|
154
|
+
eligibility: eligibility({ visibility: 'editorial' }),
|
|
155
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: 'es', withholdLocalized: false });
|
|
156
|
+
});
|
|
157
|
+
it('R2: omit reports the requested locale', () => {
|
|
158
|
+
expect(resolveReadLocale({
|
|
159
|
+
locale: 'es',
|
|
160
|
+
chain: chain('es'),
|
|
161
|
+
onMissingLocale: 'omit',
|
|
162
|
+
eligibility: eligibility({ availableLocales: ['es'] }),
|
|
163
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: 'es', withholdLocalized: false });
|
|
164
|
+
});
|
|
165
|
+
it('R3: public empty with an eligible locale reports it, with no fallback', () => {
|
|
166
|
+
expect(resolveReadLocale({
|
|
167
|
+
locale: 'es',
|
|
168
|
+
chain: chain('es'),
|
|
169
|
+
onMissingLocale: 'empty',
|
|
170
|
+
eligibility: eligibility({ availableLocales: ['es'] }),
|
|
171
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: 'es', withholdLocalized: false });
|
|
172
|
+
});
|
|
173
|
+
it('R4: public empty with an unavailable translation withholds values and reports null', () => {
|
|
174
|
+
// Complete but unchecked.
|
|
175
|
+
expect(resolveReadLocale({
|
|
176
|
+
locale: 'es',
|
|
177
|
+
chain: chain('es'),
|
|
178
|
+
onMissingLocale: 'empty',
|
|
179
|
+
eligibility: eligibility(),
|
|
180
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: null, withholdLocalized: true });
|
|
181
|
+
// Checked but incomplete.
|
|
182
|
+
expect(resolveReadLocale({
|
|
183
|
+
locale: 'de',
|
|
184
|
+
chain: chain('de'),
|
|
185
|
+
onMissingLocale: 'empty',
|
|
186
|
+
eligibility: eligibility({ availableLocales: ['de'] }),
|
|
187
|
+
})).toEqual({ restoreLocale: 'de', resolvedLocale: null, withholdLocalized: true });
|
|
188
|
+
// An omitted policy is exact, like empty.
|
|
189
|
+
expect(resolveReadLocale({
|
|
190
|
+
locale: 'es',
|
|
191
|
+
chain: chain('es'),
|
|
192
|
+
onMissingLocale: undefined,
|
|
193
|
+
eligibility: eligibility(),
|
|
194
|
+
}).withholdLocalized).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
it('R4: public empty still serves the source even when its checkbox is off', () => {
|
|
197
|
+
expect(resolveReadLocale({
|
|
198
|
+
locale: 'en',
|
|
199
|
+
chain: chain('en'),
|
|
200
|
+
onMissingLocale: 'empty',
|
|
201
|
+
eligibility: eligibility(),
|
|
202
|
+
})).toEqual({ restoreLocale: 'en', resolvedLocale: 'en', withholdLocalized: false });
|
|
203
|
+
});
|
|
204
|
+
it('R5: editorial empty reports the requested locale even when partial', () => {
|
|
205
|
+
expect(resolveReadLocale({
|
|
206
|
+
locale: 'de',
|
|
207
|
+
chain: chain('de'),
|
|
208
|
+
onMissingLocale: 'empty',
|
|
209
|
+
eligibility: eligibility({ visibility: 'editorial' }),
|
|
210
|
+
})).toEqual({ restoreLocale: 'de', resolvedLocale: 'de', withholdLocalized: false });
|
|
211
|
+
});
|
|
212
|
+
it('R6: a multi-locale read reports null and restores locale maps', () => {
|
|
213
|
+
expect(resolveReadLocale({
|
|
214
|
+
locale: 'all',
|
|
215
|
+
chain: ['all'],
|
|
216
|
+
onMissingLocale: 'empty',
|
|
217
|
+
eligibility: eligibility({ visibility: 'editorial' }),
|
|
218
|
+
})).toEqual({ restoreLocale: undefined, resolvedLocale: null, withholdLocalized: false });
|
|
219
|
+
});
|
|
220
|
+
it('R7: a locale-agnostic version reports null under every policy', () => {
|
|
221
|
+
const agnostic = eligibility({ ledger: { availableLocales: [], localeAgnostic: true } });
|
|
222
|
+
for (const onMissingLocale of ['fallback', 'omit', 'empty', undefined]) {
|
|
223
|
+
expect(resolveReadLocale({
|
|
224
|
+
locale: 'es',
|
|
225
|
+
chain: chain('es'),
|
|
226
|
+
onMissingLocale,
|
|
227
|
+
eligibility: agnostic,
|
|
228
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: null, withholdLocalized: false });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
it('a non-advertised collection withholds an incomplete translation publicly', () => {
|
|
232
|
+
expect(resolveReadLocale({
|
|
233
|
+
locale: 'de',
|
|
234
|
+
chain: chain('de'),
|
|
235
|
+
onMissingLocale: 'empty',
|
|
236
|
+
eligibility: eligibility({ advertiseLocales: false }),
|
|
237
|
+
}).withholdLocalized).toBe(true);
|
|
238
|
+
expect(resolveReadLocale({
|
|
239
|
+
locale: 'es',
|
|
240
|
+
chain: chain('es'),
|
|
241
|
+
onMissingLocale: 'empty',
|
|
242
|
+
eligibility: eligibility({ advertiseLocales: false }),
|
|
243
|
+
})).toEqual({ restoreLocale: 'es', resolvedLocale: 'es', withholdLocalized: false });
|
|
244
|
+
});
|
|
245
|
+
});
|
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": "6.
|
|
5
|
+
"version": "6.7.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"sharp": "^0.35.4",
|
|
88
88
|
"uuid": "^14.0.2",
|
|
89
89
|
"zod": "^4.6.5",
|
|
90
|
-
"@byline/auth": "6.
|
|
90
|
+
"@byline/auth": "6.7.0"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
93
|
"@biomejs/biome": "2.5.14",
|