@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.
- 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.js +46 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/config/resolve-item-view-columns.test.node.js +30 -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
- package/dist/schemas/zod/review-comparison-probe.test.node.d.ts +0 -1
- package/dist/schemas/zod/review-comparison-probe.test.node.js +0 -26
- package/dist/services/restore-review-probe.test.node.js +0 -3397
- package/dist/services/review-probes.test.node.js +0 -349
- /package/dist/{services/restore-review-probe.test.node.d.ts → auth/assert-locale-visibility.test.node.d.ts} +0 -0
- /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
|
+
});
|
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';
|
|
@@ -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
|
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';
|