@byline/core 6.7.2 → 6.9.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/admin-types.d.ts +6 -6
- package/dist/@types/admin-types.js +3 -3
- package/dist/@types/form-admin-config.test.node.js +1 -1
- package/dist/@types/singleton-admin-config.test.node.js +8 -5
- package/dist/@types/site-config.d.ts +15 -1
- package/dist/config/config.d.ts +2 -2
- package/dist/config/config.js +14 -7
- package/dist/config/group-collections.js +1 -1
- package/dist/config/group-collections.test.node.js +6 -6
- package/dist/config/validate-admin-configs.d.ts +1 -1
- package/dist/config/validate-admin-configs.js +10 -10
- package/dist/config/validate-admin-configs.test.node.js +22 -22
- package/dist/config/validate-passkey-config.d.ts +4 -0
- package/dist/config/validate-passkey-config.js +89 -0
- package/dist/config/validate-passkey-config.test.node.d.ts +1 -0
- package/dist/config/validate-passkey-config.test.node.js +152 -0
- package/dist/core.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/paths/path-dialects.test.node.js +2 -2
- package/dist/paths/paths.test.node.js +1 -1
- package/dist/scheduler/validate-scheduler-config.d.ts +2 -1
- package/dist/scheduler/validate-scheduler-config.js +9 -4
- package/dist/scheduler/validate-scheduler-config.test.node.js +17 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +2 -1
- package/dist/services/document-lifecycle/delete-locale.js +27 -0
- package/dist/services/document-lifecycle/internals.d.ts +3 -2
- package/dist/services/document-lifecycle/internals.js +3 -3
- package/dist/services/document-lifecycle/merge-write-locale.d.ts +12 -0
- package/dist/services/document-lifecycle/merge-write-locale.js +53 -0
- package/dist/services/document-lifecycle/persistence.d.ts +4 -1
- package/dist/services/document-lifecycle/persistence.js +23 -1
- package/dist/services/document-lifecycle/update.js +2 -2
- package/dist/services/document-lifecycle.test.node.js +4 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.js +1 -1
- package/dist/services/singleton-lifecycle/update.js +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/storage-flatten.js +8 -2
- package/dist/storage/storage-restore.d.ts +15 -2
- package/dist/storage/storage-restore.js +66 -15
- package/dist/storage/unavailable-blocks.test.node.d.ts +8 -0
- package/dist/storage/unavailable-blocks.test.node.js +132 -0
- package/dist/validation/document-fields.js +7 -4
- package/package.json +3 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { testAdapter } from '../storage/db-adapter.test-helper.js';
|
|
3
|
+
import { resolveServerConfig } from './config.js';
|
|
4
|
+
import { resolvePasskeyConfig } from './validate-passkey-config.js';
|
|
5
|
+
const unused = async () => {
|
|
6
|
+
throw new Error('Configuration validation must not execute provider operations');
|
|
7
|
+
};
|
|
8
|
+
const passwordOnly = {
|
|
9
|
+
capabilities: { passwordChange: true, magicLink: false, sso: false },
|
|
10
|
+
signInWithPassword: unused,
|
|
11
|
+
verifyAccessToken: unused,
|
|
12
|
+
refreshSession: unused,
|
|
13
|
+
revokeSession: unused,
|
|
14
|
+
resolveActor: unused,
|
|
15
|
+
};
|
|
16
|
+
const ceremonies = {
|
|
17
|
+
signInOptions: unused,
|
|
18
|
+
verifySignIn: unused,
|
|
19
|
+
passwordProof: unused,
|
|
20
|
+
proofOptions: unused,
|
|
21
|
+
verifyProof: unused,
|
|
22
|
+
enrollmentOptions: unused,
|
|
23
|
+
verifyEnrollment: unused,
|
|
24
|
+
listCredentials: unused,
|
|
25
|
+
renameCredential: unused,
|
|
26
|
+
removeCredential: unused,
|
|
27
|
+
};
|
|
28
|
+
const enabled = {
|
|
29
|
+
...passwordOnly,
|
|
30
|
+
capabilities: { ...passwordOnly.capabilities, passkeys: true },
|
|
31
|
+
passkeys: ceremonies,
|
|
32
|
+
};
|
|
33
|
+
const config = {
|
|
34
|
+
rpId: 'example.com',
|
|
35
|
+
rpName: 'Byline',
|
|
36
|
+
origins: ['https://example.com', 'https://edit.example.com:8443'],
|
|
37
|
+
};
|
|
38
|
+
describe('optional passkey configuration', () => {
|
|
39
|
+
it('keeps existing password-only and absent providers working', () => {
|
|
40
|
+
expect(resolvePasskeyConfig(undefined, passwordOnly)).toBeUndefined();
|
|
41
|
+
expect(resolvePasskeyConfig(undefined, undefined)).toBeUndefined();
|
|
42
|
+
expect(resolvePasskeyConfig(undefined, {
|
|
43
|
+
...passwordOnly,
|
|
44
|
+
capabilities: { ...passwordOnly.capabilities, passkeys: false },
|
|
45
|
+
})).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
it('validates and snapshots explicitly configured trust without invoking a ceremony', () => {
|
|
48
|
+
const origins = [...config.origins];
|
|
49
|
+
const resolved = resolvePasskeyConfig({ ...config, origins }, enabled);
|
|
50
|
+
origins.push('https://evil.example.com');
|
|
51
|
+
expect(resolved.origins).toEqual(config.origins);
|
|
52
|
+
expect(Object.isFrozen(resolved)).toBe(true);
|
|
53
|
+
expect(Object.isFrozen(resolved.origins)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('runs validation through server configuration resolution', () => {
|
|
56
|
+
expect(() => resolveServerConfig({
|
|
57
|
+
collections: [],
|
|
58
|
+
db: testAdapter({}),
|
|
59
|
+
i18n: {
|
|
60
|
+
admin: { defaultLocale: 'en', locales: [] },
|
|
61
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
62
|
+
},
|
|
63
|
+
sessionProvider: passwordOnly,
|
|
64
|
+
passkeys: config,
|
|
65
|
+
})).toThrow('provider does not support passkeys');
|
|
66
|
+
});
|
|
67
|
+
it('requires configuration and capability to agree', () => {
|
|
68
|
+
expect(() => resolvePasskeyConfig(undefined, enabled)).toThrow('explicit relying-party');
|
|
69
|
+
expect(() => resolvePasskeyConfig(config, passwordOnly)).toThrow('does not support');
|
|
70
|
+
expect(() => resolvePasskeyConfig(config, undefined)).toThrow('does not support');
|
|
71
|
+
expect(() => resolvePasskeyConfig(config, { ...enabled, passkeys: undefined })).toThrow('does not support');
|
|
72
|
+
expect(() => resolvePasskeyConfig(undefined, { ...passwordOnly, passkeys: ceremonies })).toThrow('explicit relying-party');
|
|
73
|
+
});
|
|
74
|
+
it.each(Object.keys(ceremonies))('rejects a provider missing %s', (method) => {
|
|
75
|
+
const partial = { ...ceremonies, [method]: undefined };
|
|
76
|
+
expect(() => resolvePasskeyConfig(config, { ...enabled, passkeys: partial })).toThrow(`missing ${method}`);
|
|
77
|
+
});
|
|
78
|
+
it.each([
|
|
79
|
+
'',
|
|
80
|
+
'example.com:443',
|
|
81
|
+
'https://example.com',
|
|
82
|
+
'*.example.com',
|
|
83
|
+
'EXAMPLE.COM',
|
|
84
|
+
'example.com.',
|
|
85
|
+
'127.0.0.1',
|
|
86
|
+
'[::1]',
|
|
87
|
+
'com',
|
|
88
|
+
])('rejects invalid RP ID %s', (rpId) => {
|
|
89
|
+
expect(() => resolvePasskeyConfig({ ...config, rpId }, enabled)).toThrow('rpId');
|
|
90
|
+
});
|
|
91
|
+
it.each([
|
|
92
|
+
'http://example.com',
|
|
93
|
+
'https://example.com/',
|
|
94
|
+
'https://example.com/path',
|
|
95
|
+
'https://example.com?x=1',
|
|
96
|
+
'https://example.com#fragment',
|
|
97
|
+
'https://user@example.com',
|
|
98
|
+
'https://EXAMPLE.COM',
|
|
99
|
+
'https://example.com:443',
|
|
100
|
+
'https://evil-example.com',
|
|
101
|
+
'https://example.com.evil.com',
|
|
102
|
+
'https://other.com',
|
|
103
|
+
'null',
|
|
104
|
+
'*',
|
|
105
|
+
])('rejects an unsafe or noncanonical origin %s', (origin) => {
|
|
106
|
+
expect(() => resolvePasskeyConfig({ ...config, origins: [origin] }, enabled)).toThrow('Invalid passkey');
|
|
107
|
+
});
|
|
108
|
+
it('permits HTTP only for explicitly enabled localhost development', () => {
|
|
109
|
+
const local = { rpId: 'localhost', rpName: 'Local', origins: ['http://localhost:5173'] };
|
|
110
|
+
expect(() => resolvePasskeyConfig(local, enabled)).toThrow('HTTPS');
|
|
111
|
+
expect(resolvePasskeyConfig({ ...local, allowInsecureLocalhost: true }, enabled)?.origins).toEqual(local.origins);
|
|
112
|
+
expect(() => resolvePasskeyConfig({ ...config, origins: ['http://example.com'], allowInsecureLocalhost: true }, enabled)).toThrow('HTTPS');
|
|
113
|
+
expect(() => resolvePasskeyConfig({ ...local, origins: ['http://foo.localhost'], allowInsecureLocalhost: true }, enabled)).toThrow('HTTPS');
|
|
114
|
+
});
|
|
115
|
+
it.each([
|
|
116
|
+
{ rpName: '' },
|
|
117
|
+
{ rpName: ' ' },
|
|
118
|
+
{ rpName: 'x'.repeat(201) },
|
|
119
|
+
{ origins: [] },
|
|
120
|
+
{ origins: ['https://example.com', 'https://example.com'] },
|
|
121
|
+
{ origins: [42] },
|
|
122
|
+
{ allowInsecureLocalhost: 'true' },
|
|
123
|
+
{ relatedOrigins: true },
|
|
124
|
+
])('rejects malformed or unsupported configuration %j', (invalid) => {
|
|
125
|
+
expect(() => resolvePasskeyConfig({ ...config, ...invalid }, enabled)).toThrow('Invalid passkey');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('passkey host protection configuration', () => {
|
|
129
|
+
const server = {
|
|
130
|
+
collections: [],
|
|
131
|
+
db: testAdapter({}),
|
|
132
|
+
i18n: {
|
|
133
|
+
admin: { defaultLocale: 'en', locales: [] },
|
|
134
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
135
|
+
},
|
|
136
|
+
sessionProvider: enabled,
|
|
137
|
+
passkeys: config,
|
|
138
|
+
};
|
|
139
|
+
it('fails closed at boot without trusted IP and admission methods', () => {
|
|
140
|
+
expect(() => resolveServerConfig(server)).toThrow('passkeyRequests');
|
|
141
|
+
expect(() => resolveServerConfig({
|
|
142
|
+
...server,
|
|
143
|
+
passkeyRequests: { resolveClientIp: () => null, limiter: {} },
|
|
144
|
+
})).toThrow('passkeyRequests');
|
|
145
|
+
});
|
|
146
|
+
it('accepts explicit protection without invoking it', () => {
|
|
147
|
+
expect(resolveServerConfig({
|
|
148
|
+
...server,
|
|
149
|
+
passkeyRequests: { resolveClientIp: unused, limiter: { acquire: unused, consume: unused } },
|
|
150
|
+
}).passkeys).toEqual(config);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/dist/core.js
CHANGED
|
@@ -87,6 +87,7 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
87
87
|
tasks: configuredTasks,
|
|
88
88
|
adapter: composed.db,
|
|
89
89
|
passwordSignIn: resolvedConfig.passwordSignIn,
|
|
90
|
+
passkeyRequests: resolvedConfig.passkeyRequests,
|
|
90
91
|
});
|
|
91
92
|
// Freeze a snapshot of the validated task set: a new array of new, frozen
|
|
92
93
|
// objects, then freeze the array itself. Without this, `core.recurringTasks`
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { type CollectionGroupBucket, groupCollectionsForAdmin, } from './config/
|
|
|
5
5
|
export { resolveRoutes } from './config/routes.js';
|
|
6
6
|
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
|
7
7
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
8
|
+
export { resolvePasskeyConfig } from './config/validate-passkey-config.js';
|
|
8
9
|
export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
|
|
9
10
|
export * from './defaults/default-values.js';
|
|
10
11
|
export { getHostRequestBridge, type HostCookieSetOptions, type HostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { groupCollectionsForAdmin, } from './config/group-collections.js';
|
|
|
17
17
|
export { resolveRoutes } from './config/routes.js';
|
|
18
18
|
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
|
19
19
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
20
|
+
export { resolvePasskeyConfig } from './config/validate-passkey-config.js';
|
|
20
21
|
export { getBylineCore, initBylineCore } from './core.js';
|
|
21
22
|
export * from './defaults/default-values.js';
|
|
22
23
|
export { getHostRequestBridge, registerHostRequestBridge, tryGetHostRequestBridge, } from './host/host-request-bridge.js';
|
|
@@ -139,7 +139,7 @@ describe('path dialect — upload hook registry keys', () => {
|
|
|
139
139
|
// one registration applies wherever the block renders.
|
|
140
140
|
// ---------------------------------------------------------------------------
|
|
141
141
|
describe('path dialect — admin fields{} override keys', () => {
|
|
142
|
-
const admin = (key) => ({
|
|
142
|
+
const admin = (key) => ({ path: 'pages', fields: { [key]: {} } });
|
|
143
143
|
const validate = (key) => () => validateAdminConfigs([admin(key)], [fixture()]);
|
|
144
144
|
it('accepts a top-level field name', () => {
|
|
145
145
|
expect(validate('title')).not.toThrow();
|
|
@@ -262,6 +262,6 @@ describe('path dialects — cross-dialect relationships', () => {
|
|
|
262
262
|
collections: [fixture()],
|
|
263
263
|
hooks: { uploads: { [registryKey]: async () => ({}) } },
|
|
264
264
|
})).not.toThrow();
|
|
265
|
-
expect(() => validateAdminConfigs([{
|
|
265
|
+
expect(() => validateAdminConfigs([{ path: 'pages', fields: { [adminKey]: {} } }], [fixture()])).not.toThrow();
|
|
266
266
|
});
|
|
267
267
|
});
|
|
@@ -309,7 +309,7 @@ describe('resolveDeclarationPath — agreement with the admin fields{} validator
|
|
|
309
309
|
};
|
|
310
310
|
const validatorAccepts = (key) => {
|
|
311
311
|
try {
|
|
312
|
-
validateAdminConfigs([{
|
|
312
|
+
validateAdminConfigs([{ path: 'pages', fields: { [key]: {} } }], [collection]);
|
|
313
313
|
return true;
|
|
314
314
|
}
|
|
315
315
|
catch {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type { PasswordSignInProtection } from '@byline/auth';
|
|
8
|
+
import type { PasskeyRequestProtection, PasswordSignInProtection } from '@byline/auth';
|
|
9
9
|
import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
10
10
|
/**
|
|
11
11
|
* Boot-time gate. Recurring tasks registered against an adapter that does not
|
|
@@ -14,6 +14,7 @@ import type { ISchedulerStore, RecurringTaskDefinition } from './types.js';
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function validateSchedulerConfig(params: {
|
|
16
16
|
passwordSignIn?: PasswordSignInProtection;
|
|
17
|
+
passkeyRequests?: PasskeyRequestProtection;
|
|
17
18
|
tasks?: readonly RecurringTaskDefinition[];
|
|
18
19
|
adapter: {
|
|
19
20
|
scheduler?: ISchedulerStore;
|
|
@@ -13,10 +13,15 @@ import { validateRecurringTasks } from './validate-tasks.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export function validateSchedulerConfig(params) {
|
|
15
15
|
const tasks = params.tasks ?? [];
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
for (const [name, protection] of [
|
|
17
|
+
['Password sign-in', params.passwordSignIn],
|
|
18
|
+
['Passkeys', params.passkeyRequests],
|
|
19
|
+
]) {
|
|
20
|
+
const cleanup = protection?.limiter.requiredCleanupTask;
|
|
21
|
+
if (cleanup && !tasks.some((task) => task.name === cleanup)) {
|
|
22
|
+
throw new Error(`${name} requires recurring task "${cleanup}". Register the limiter's cleanupTask ` +
|
|
23
|
+
'in recurringTasks and start the host scheduler (or invoke runDueTasks externally).');
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
if (tasks.length === 0)
|
|
22
27
|
return;
|
|
@@ -52,3 +52,20 @@ describe('validateSchedulerConfig', () => {
|
|
|
52
52
|
})).toThrow(/interval/i);
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
it('requires passkey counter cleanup and accepts one shared task for both limiters', () => {
|
|
56
|
+
const protection = {
|
|
57
|
+
resolveClientIp: () => '192.0.2.1',
|
|
58
|
+
limiter: {
|
|
59
|
+
requiredCleanupTask: task.name,
|
|
60
|
+
acquire: async () => () => { },
|
|
61
|
+
consume: async () => ({ allowed: true, retryAfterSeconds: 0 }),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
expect(() => validateSchedulerConfig({ passkeyRequests: protection, adapter: { scheduler: store } })).toThrow('Passkeys requires recurring task');
|
|
65
|
+
expect(() => validateSchedulerConfig({
|
|
66
|
+
passwordSignIn: protection,
|
|
67
|
+
passkeyRequests: protection,
|
|
68
|
+
tasks: [task],
|
|
69
|
+
adapter: { scheduler: store },
|
|
70
|
+
})).not.toThrow();
|
|
71
|
+
});
|
|
@@ -104,6 +104,7 @@ export async function copyToLocale(ctx, params) {
|
|
|
104
104
|
const committed = await commitGuardedDocumentMutation(ctx, { ...params, previousVersionId, locale: params.targetLocale }, async () => {
|
|
105
105
|
if ((targetRecord._availableVersionLocales?.includes(params.targetLocale) ||
|
|
106
106
|
targetRecord._localeAgnostic === true) &&
|
|
107
|
+
!targetRecord.unavailableBlocks?.length &&
|
|
107
108
|
sameDocumentData(merged.data, baseline))
|
|
108
109
|
return { value: previousVersionId, changed: false };
|
|
109
110
|
const writeResult = await persistExistingDocumentVersion(ctx, {
|
|
@@ -113,7 +114,7 @@ export async function copyToLocale(ctx, params) {
|
|
|
113
114
|
status: getDefaultStatus(definition),
|
|
114
115
|
locale: params.targetLocale,
|
|
115
116
|
previousVersionId,
|
|
116
|
-
});
|
|
117
|
+
}, targetRecord);
|
|
117
118
|
return { value: extractVersionId(writeResult.document), changed: true };
|
|
118
119
|
});
|
|
119
120
|
const documentVersionId = committed.value;
|
|
@@ -9,6 +9,8 @@ import { resolveHooks } from '../../@types/index.js';
|
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
|
+
import { flattenFieldSetData } from '../../storage/storage-flatten.js';
|
|
13
|
+
import { restoreFieldSetData } from '../../storage/storage-restore.js';
|
|
12
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
15
|
import { runCommittedDocumentHook } from './committed-hook.js';
|
|
14
16
|
import { actorId, invokeHook } from './internals.js';
|
|
@@ -84,6 +86,31 @@ export async function deleteLocale(ctx, params) {
|
|
|
84
86
|
ctx,
|
|
85
87
|
documentId: params.documentId,
|
|
86
88
|
write: async () => {
|
|
89
|
+
// The ordinary delete-locale primitive copies raw stored rows.
|
|
90
|
+
// Reconstruct affected versions so unavailable blocks are omitted too.
|
|
91
|
+
if (targetRecord.unavailableBlocks?.length) {
|
|
92
|
+
const previous = await db.queries.documents.getDocumentByVersion({
|
|
93
|
+
document_version_id: previousVersionId,
|
|
94
|
+
document_id: params.documentId,
|
|
95
|
+
collection_id: collectionId,
|
|
96
|
+
locale: 'all',
|
|
97
|
+
});
|
|
98
|
+
const rows = flattenFieldSetData(definition.fields, previous.fields, 'all').filter((row) => row.locale !== params.locale);
|
|
99
|
+
const { data } = restoreFieldSetData(definition.fields, rows);
|
|
100
|
+
const saved = await db.commands.documents.createDocumentVersion({
|
|
101
|
+
documentId: params.documentId,
|
|
102
|
+
collectionId,
|
|
103
|
+
collectionVersion: ctx.collectionVersion,
|
|
104
|
+
collectionConfig: definition,
|
|
105
|
+
action: 'delete_locale',
|
|
106
|
+
documentData: data,
|
|
107
|
+
locale: 'all',
|
|
108
|
+
previousVersionId,
|
|
109
|
+
status: getDefaultStatus(definition),
|
|
110
|
+
createdBy: actorId(ctx),
|
|
111
|
+
});
|
|
112
|
+
return { newVersionId: saved.document.id, previousVersionId };
|
|
113
|
+
}
|
|
87
114
|
const deleted = await db.commands.documents.deleteDocumentLocale({
|
|
88
115
|
documentId: params.documentId,
|
|
89
116
|
locale: params.locale,
|
|
@@ -112,8 +112,9 @@ export declare function resolvePathForUpdate(args: {
|
|
|
112
112
|
*/
|
|
113
113
|
export declare function derivePath(definition: CollectionDefinition, data: Record<string, any>, defaultLocale: string, slugifier: SlugifierFn): string;
|
|
114
114
|
/**
|
|
115
|
-
* Strip the synthetic `_id`
|
|
116
|
-
* array-item node in a reconstructed document tree.
|
|
115
|
+
* Strip the synthetic `_id` identity from every block and
|
|
116
|
+
* array-item node in a reconstructed document tree. Preserve `_type`, which
|
|
117
|
+
* selects the schema used to flatten each surviving block.
|
|
117
118
|
*
|
|
118
119
|
* Reconstructed `locale: 'all'` trees carry stable `_id` values for
|
|
119
120
|
* blocks and array items (see CLAUDE.md → "Block/array items carry a
|
|
@@ -232,8 +232,9 @@ export function derivePath(definition, data, defaultLocale, slugifier) {
|
|
|
232
232
|
return crypto.randomUUID();
|
|
233
233
|
}
|
|
234
234
|
/**
|
|
235
|
-
* Strip the synthetic `_id`
|
|
236
|
-
* array-item node in a reconstructed document tree.
|
|
235
|
+
* Strip the synthetic `_id` identity from every block and
|
|
236
|
+
* array-item node in a reconstructed document tree. Preserve `_type`, which
|
|
237
|
+
* selects the schema used to flatten each surviving block.
|
|
237
238
|
*
|
|
238
239
|
* Reconstructed `locale: 'all'` trees carry stable `_id` values for
|
|
239
240
|
* blocks and array items (see CLAUDE.md → "Block/array items carry a
|
|
@@ -254,7 +255,6 @@ export function stripMetaIdsInPlace(value) {
|
|
|
254
255
|
if (value != null && typeof value === 'object' && !(value instanceof Date)) {
|
|
255
256
|
const obj = value;
|
|
256
257
|
delete obj._id;
|
|
257
|
-
delete obj._type;
|
|
258
258
|
for (const key of Object.keys(obj)) {
|
|
259
259
|
stripMetaIdsInPlace(obj[key]);
|
|
260
260
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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 { FieldSet } from '../../@types/index.js';
|
|
9
|
+
/** Preserve other locales by item identity after unavailable blocks were compacted.
|
|
10
|
+
* Incoming structure is authoritative; never carry old positional store rows forward.
|
|
11
|
+
*/
|
|
12
|
+
export declare function mergeWriteLocale(fields: FieldSet, previous: Record<string, any>, incoming: Record<string, any>, locale: string): Record<string, any>;
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
/** Preserve other locales by item identity after unavailable blocks were compacted.
|
|
9
|
+
* Incoming structure is authoritative; never carry old positional store rows forward.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeWriteLocale(fields, previous, incoming, locale) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const field of fields) {
|
|
14
|
+
const value = incoming[field.name];
|
|
15
|
+
const old = previous?.[field.name];
|
|
16
|
+
if (field.type === 'group') {
|
|
17
|
+
if (value != null)
|
|
18
|
+
result[field.name] = mergeWriteLocale(field.fields, old ?? {}, value, locale);
|
|
19
|
+
}
|
|
20
|
+
else if (field.type === 'array' || field.type === 'blocks') {
|
|
21
|
+
if (!Array.isArray(value))
|
|
22
|
+
continue;
|
|
23
|
+
result[field.name] = value.map((item, index) => {
|
|
24
|
+
// Hand-built payloads may omit identity metadata. Match their compacted
|
|
25
|
+
// position, as ordinary locale writes do; an explicit ID stays authoritative.
|
|
26
|
+
const prior = Array.isArray(old)
|
|
27
|
+
? item._id != null
|
|
28
|
+
? old.find((candidate) => candidate._id === item._id)
|
|
29
|
+
: old[index]
|
|
30
|
+
: undefined;
|
|
31
|
+
const childFields = field.type === 'array'
|
|
32
|
+
? field.fields
|
|
33
|
+
: field.blocks.find((block) => block.blockType === item._type)?.fields;
|
|
34
|
+
return {
|
|
35
|
+
...(item._id != null ? { _id: item._id } : {}),
|
|
36
|
+
...(field.type === 'blocks' ? { _type: item._type } : {}),
|
|
37
|
+
...mergeWriteLocale(childFields ?? [], prior ?? {}, item, locale),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else if (field.localized) {
|
|
42
|
+
const localized = { ...old };
|
|
43
|
+
delete localized[locale];
|
|
44
|
+
if (value !== undefined)
|
|
45
|
+
localized[locale] = value;
|
|
46
|
+
if (Object.keys(localized).length > 0)
|
|
47
|
+
result[field.name] = localized;
|
|
48
|
+
}
|
|
49
|
+
else if (value !== undefined)
|
|
50
|
+
result[field.name] = value;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import type { IDocumentCommands } from '../../@types/index.js';
|
|
9
|
+
import type { UnavailableBlock } from '../../storage/storage-restore.js';
|
|
9
10
|
import type { DocumentLifecycleContext } from './context.js';
|
|
10
11
|
type AdapterVersionWrite = Parameters<IDocumentCommands['createDocumentVersion']>[0];
|
|
11
12
|
type LifecycleOwnedWriteKeys = 'collectionId' | 'collectionVersion' | 'collectionConfig' | 'createdBy';
|
|
@@ -25,5 +26,7 @@ export declare function persistInitialDocumentVersion(ctx: DocumentLifecycleCont
|
|
|
25
26
|
* publication schedule atomically. All operation-specific preparation and
|
|
26
27
|
* hook sequencing remains in the caller.
|
|
27
28
|
*/
|
|
28
|
-
export declare function persistExistingDocumentVersion(ctx: DocumentLifecycleContext, write: ExistingDocumentVersionWrite
|
|
29
|
+
export declare function persistExistingDocumentVersion(ctx: DocumentLifecycleContext, write: ExistingDocumentVersionWrite, previousRead?: {
|
|
30
|
+
unavailableBlocks?: readonly UnavailableBlock[];
|
|
31
|
+
} | null): ReturnType<IDocumentCommands['createDocumentVersion']>;
|
|
29
32
|
export {};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { assertDocumentFields } from '../../validation/document-fields.js';
|
|
9
9
|
import { requireAuditCapability } from './audit.js';
|
|
10
10
|
import { actorId } from './internals.js';
|
|
11
|
+
import { mergeWriteLocale } from './merge-write-locale.js';
|
|
11
12
|
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
12
13
|
import { normalizeDocumentVersionParentError } from './stale-document.js';
|
|
13
14
|
import { appendTreeRoot } from './tree.js';
|
|
@@ -73,8 +74,29 @@ export async function persistInitialDocumentVersion(ctx, write) {
|
|
|
73
74
|
* publication schedule atomically. All operation-specific preparation and
|
|
74
75
|
* hook sequencing remains in the caller.
|
|
75
76
|
*/
|
|
76
|
-
export function persistExistingDocumentVersion(ctx, write) {
|
|
77
|
+
export async function persistExistingDocumentVersion(ctx, write, previousRead) {
|
|
77
78
|
assertWritableContent(ctx, write);
|
|
79
|
+
// Single-locale storage writes copy other locales' positional rows forward.
|
|
80
|
+
// After compaction those rows can resurrect unavailable blocks or attach a
|
|
81
|
+
// translation to the wrong survivor. Rebuild all locales by item identity.
|
|
82
|
+
if (previousRead?.unavailableBlocks?.length &&
|
|
83
|
+
write.previousVersionId &&
|
|
84
|
+
write.locale &&
|
|
85
|
+
write.locale !== 'all') {
|
|
86
|
+
const previous = await ctx.db.queries.documents.getDocumentByVersion({
|
|
87
|
+
document_version_id: write.previousVersionId,
|
|
88
|
+
document_id: write.documentId,
|
|
89
|
+
collection_id: ctx.collectionId,
|
|
90
|
+
locale: 'all',
|
|
91
|
+
});
|
|
92
|
+
if (previous?.unavailableBlocks?.length) {
|
|
93
|
+
write = {
|
|
94
|
+
...write,
|
|
95
|
+
documentData: mergeWriteLocale(ctx.definition.fields, previous.fields, write.documentData, write.locale),
|
|
96
|
+
locale: 'all',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
78
100
|
return commitContentVersionWithScheduleSuspension({
|
|
79
101
|
ctx,
|
|
80
102
|
documentId: write.documentId,
|
|
@@ -102,7 +102,7 @@ async function replaceFields(ctx, params, policy) {
|
|
|
102
102
|
status: policy === 'preserve' ? preservedStatus(ctx, locked.status) : defaultStatus,
|
|
103
103
|
locale: requestLocale,
|
|
104
104
|
previousVersionId,
|
|
105
|
-
});
|
|
105
|
+
}, latest);
|
|
106
106
|
if (policy === 'preserve' && locked.status === 'published') {
|
|
107
107
|
await db.commands.documents.archivePublishedVersions({
|
|
108
108
|
document_id: params.documentId,
|
|
@@ -212,7 +212,7 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
212
212
|
status: defaultStatus,
|
|
213
213
|
locale: requestLocale,
|
|
214
214
|
previousVersionId,
|
|
215
|
-
});
|
|
215
|
+
}, latest);
|
|
216
216
|
await selfHealTreePlacement(ctx, params.documentId);
|
|
217
217
|
return { value: { result, systemFields }, changed: true };
|
|
218
218
|
}).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
|
|
@@ -2857,7 +2857,7 @@ describe('Document lifecycle service', () => {
|
|
|
2857
2857
|
expect(call.path).toBe('hello-world-copy');
|
|
2858
2858
|
expect(result.newPath).toBe('hello-world-copy');
|
|
2859
2859
|
});
|
|
2860
|
-
it('strips _id
|
|
2860
|
+
it('strips _id identities but preserves block types when duplicating', async () => {
|
|
2861
2861
|
const mocks = createMockDb();
|
|
2862
2862
|
setupSource(mocks, {
|
|
2863
2863
|
sourceFields: {
|
|
@@ -2881,7 +2881,9 @@ describe('Document lifecycle service', () => {
|
|
|
2881
2881
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2882
2882
|
const sections = call.documentData.sections;
|
|
2883
2883
|
expect(sections[0]._id).toBeUndefined();
|
|
2884
|
-
expect(sections[0]._type).
|
|
2884
|
+
expect(sections[0]._type).toBe('section');
|
|
2885
|
+
expect(sections[0].blocks[0]._type).toBe('photoBlock');
|
|
2886
|
+
expect(sections[0].blocks[1]._type).toBe('textBlock');
|
|
2885
2887
|
expect(sections[0].blocks[0]._id).toBeUndefined();
|
|
2886
2888
|
expect(sections[0].blocks[1]._id).toBeUndefined();
|
|
2887
2889
|
// Content survives — only meta is stripped.
|
|
@@ -98,7 +98,7 @@ export async function copySingletonToLocale(ctx, params) {
|
|
|
98
98
|
status: getDefaultStatus(definition),
|
|
99
99
|
locale: params.targetLocale,
|
|
100
100
|
previousVersionId: slot.currentVersion?.document_version_id,
|
|
101
|
-
});
|
|
101
|
+
}, existingTarget ?? source);
|
|
102
102
|
},
|
|
103
103
|
};
|
|
104
104
|
},
|
|
@@ -139,7 +139,7 @@ export async function updateSingleton(ctx, params) {
|
|
|
139
139
|
action: 'update',
|
|
140
140
|
documentId: mappedDocumentId,
|
|
141
141
|
previousVersionId,
|
|
142
|
-
});
|
|
142
|
+
}, observed.current);
|
|
143
143
|
const documentId = extractDocumentId(result.document);
|
|
144
144
|
const documentVersionId = extractVersionId(result.document);
|
|
145
145
|
if (!documentId || !documentVersionId)
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export { assertDocumentVersionParent, type DocumentVersionParentAssertion, type
|
|
|
10
10
|
export { ALL_STORE_TYPES, type FieldStoreKind, type FieldStoreMapping, fieldTypeToStore, fieldTypeToStoreType, type StoreType, } from './field-store-map.js';
|
|
11
11
|
export { deriveVersionLocaleLedger, resolveLocaleVisibility, resolveReadLocale, type VersionLocaleLedger, } from './locale-resolution.js';
|
|
12
12
|
export { flattenFieldSetData } from './storage-flatten.js';
|
|
13
|
-
export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, } from './storage-restore.js';
|
|
13
|
+
export { extractFlattenedFieldValue, type RestoreResult, restoreFieldSetData, type UnavailableBlock, } from './storage-restore.js';
|
|
14
14
|
export { resolveStoreTypes } from './storage-utils.js';
|
|
15
15
|
export { type ColumnDef, storeColumnManifest, storeTableNames } from './store-manifest.js';
|
|
16
16
|
export type { FlattenedFieldValue, UnifiedFieldValue } from './storage-row-types.js';
|
|
@@ -101,8 +101,14 @@ function* flattenFieldDataGen(field, data, locale, field_path) {
|
|
|
101
101
|
else if (field.type === 'blocks') {
|
|
102
102
|
const items = data;
|
|
103
103
|
for (let i = 0; i < items.length; i++) {
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
const item = items[i];
|
|
105
|
+
if (item == null || typeof item !== 'object' || Array.isArray(item)) {
|
|
106
|
+
throw ERR_DATABASE({
|
|
107
|
+
message: `${field_path.join('.')}[${i}]: Block item is missing or must be an object`,
|
|
108
|
+
details: { fieldPath: field_path, index: i },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const { _id, _type, ...item_data } = item;
|
|
106
112
|
const item_path = [...field_path, `${i}`, _type];
|
|
107
113
|
const block = field.blocks.find((f) => f.blockType === _type);
|
|
108
114
|
if (!block) {
|
|
@@ -7,15 +7,24 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { FieldSet } from '../@types/index.js';
|
|
9
9
|
import type { FlattenedFieldValue, UnifiedFieldValue } from './storage-row-types.js';
|
|
10
|
+
export interface UnavailableBlock {
|
|
11
|
+
/** Instance path in the compacted document, e.g. sections[0].body. */
|
|
12
|
+
fieldPath: string;
|
|
13
|
+
/** Position in the stored blocks array before compaction. */
|
|
14
|
+
storedIndex: number;
|
|
15
|
+
blockType: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
}
|
|
10
18
|
export interface RestoreResult {
|
|
11
19
|
data: any;
|
|
12
20
|
warnings: string[];
|
|
21
|
+
unavailableBlocks: UnavailableBlock[];
|
|
13
22
|
}
|
|
14
23
|
/**
|
|
15
24
|
* Main entrypoint for restoring a document's field data from flattened form
|
|
16
25
|
* back into the original nested structure.
|
|
17
26
|
*
|
|
18
|
-
* Always returns `{ data, warnings }`. Warnings carry per-row reasons (e.g.
|
|
27
|
+
* Always returns `{ data, warnings, unavailableBlocks }`. Warnings carry per-row reasons (e.g.
|
|
19
28
|
* "Invalid block index 'undefined'") that are typically the result of a
|
|
20
29
|
* collection schema change leaving orphan rows behind. Callers decide what
|
|
21
30
|
* to do with them — strict reads reject any non-empty `warnings`, lenient
|
|
@@ -24,5 +33,9 @@ export interface RestoreResult {
|
|
|
24
33
|
* @param fields - The field definitions for the collection.
|
|
25
34
|
* @param flattenedData - The flattened field data to restore.
|
|
26
35
|
*/
|
|
27
|
-
export declare const restoreFieldSetData: (fields: FieldSet, flattenedData: FlattenedFieldValue[], resolveLocale?: string
|
|
36
|
+
export declare const restoreFieldSetData: (fields: FieldSet, flattenedData: FlattenedFieldValue[], resolveLocale?: string, identity?: {
|
|
37
|
+
collectionPath: string;
|
|
38
|
+
documentId: string;
|
|
39
|
+
versionId: string;
|
|
40
|
+
}) => RestoreResult;
|
|
28
41
|
export declare const extractFlattenedFieldValue: (unifiedValue: UnifiedFieldValue) => FlattenedFieldValue;
|