@byline/core 4.16.0 → 4.18.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 +86 -31
- package/dist/@types/admin-types.js +12 -0
- package/dist/@types/collection-types.d.ts +211 -29
- package/dist/@types/collection-types.js +68 -8
- package/dist/@types/db-types.d.ts +50 -0
- package/dist/@types/form-admin-config.test.node.d.ts +6 -0
- package/dist/@types/form-admin-config.test.node.js +26 -0
- package/dist/@types/search-types.d.ts +12 -0
- package/dist/@types/singleton-admin-config.test.node.js +66 -0
- package/dist/@types/singleton-definition.test.node.d.ts +8 -0
- package/dist/@types/singleton-definition.test.node.js +75 -0
- package/dist/@types/site-config.d.ts +9 -8
- package/dist/auth/apply-before-read.d.ts +2 -1
- package/dist/auth/apply-before-read.js +13 -3
- package/dist/auth/apply-before-read.test.node.js +44 -0
- package/dist/auth/assert-actor-can-perform.d.ts +4 -3
- package/dist/auth/assert-actor-can-perform.js +9 -7
- package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
- package/dist/auth/filter-readable-collections.d.ts +6 -6
- package/dist/auth/filter-readable-collections.js +8 -8
- package/dist/auth/filter-readable-collections.test.node.js +26 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/register-collection-abilities.d.ts +34 -30
- package/dist/auth/register-collection-abilities.js +81 -77
- package/dist/auth/register-collection-abilities.test.node.js +86 -8
- package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.js +32 -16
- package/dist/codegen/index.test.node.js +35 -2
- package/dist/config/attach-hooks.d.ts +3 -3
- package/dist/config/attach-hooks.js +8 -0
- package/dist/config/attach-hooks.test.node.js +59 -0
- package/dist/config/config-hooks.test.node.js +34 -1
- package/dist/config/config.d.ts +2 -1
- package/dist/config/config.js +9 -1
- package/dist/config/group-collections.d.ts +10 -9
- package/dist/config/group-collections.js +4 -4
- package/dist/config/group-collections.test.node.js +14 -0
- package/dist/config/validate-admin-configs.d.ts +4 -4
- package/dist/config/validate-admin-configs.js +40 -7
- package/dist/config/validate-admin-configs.test.node.js +44 -0
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +75 -0
- package/dist/core.d.ts +2 -3
- package/dist/core.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/lib/errors.d.ts +5 -3
- package/dist/lib/errors.js +1 -0
- package/dist/services/collection-bootstrap.js +12 -0
- package/dist/services/collection-bootstrap.test.node.js +63 -6
- package/dist/services/discover-counter-groups.test.node.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
- package/dist/services/document-lifecycle/create.js +6 -11
- package/dist/services/document-lifecycle/delete-locale.js +1 -1
- package/dist/services/document-lifecycle/delete.js +1 -1
- package/dist/services/document-lifecycle/duplicate.js +7 -18
- package/dist/services/document-lifecycle/persistence.d.ts +29 -0
- package/dist/services/document-lifecycle/persistence.js +42 -0
- package/dist/services/document-lifecycle/restore.js +9 -17
- package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
- package/dist/services/document-lifecycle/status.js +3 -3
- package/dist/services/document-lifecycle/system-fields.js +1 -1
- package/dist/services/document-lifecycle/tree.js +2 -2
- package/dist/services/document-lifecycle/tree.test.node.js +6 -1
- package/dist/services/document-lifecycle/update.js +20 -36
- package/dist/services/document-lifecycle.test.node.js +35 -0
- package/dist/services/field-upload.d.ts +1 -1
- package/dist/services/field-upload.js +20 -6
- package/dist/services/field-upload.test.node.js +125 -9
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/populate.js +1 -1
- package/dist/services/populate.test.node.js +9 -0
- package/dist/services/richtext-populate.js +1 -1
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
- package/dist/services/singleton-lifecycle/index.d.ts +14 -0
- package/dist/services/singleton-lifecycle/index.js +11 -0
- package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
- package/dist/services/singleton-lifecycle/internals.js +118 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
- package/dist/services/singleton-lifecycle/restore.js +93 -0
- package/dist/services/singleton-lifecycle/update.d.ts +16 -0
- package/dist/services/singleton-lifecycle/update.js +107 -0
- package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
- package/dist/services/singleton-lifecycle.test.node.js +396 -0
- package/dist/storage/collection-fingerprint.js +18 -6
- package/dist/storage/collection-fingerprint.test.node.js +15 -0
- package/package.json +5 -5
- package/dist/config/resolve-item-view-columns.test.node.js +0 -30
- /package/dist/{config/resolve-item-view-columns.test.node.d.ts → @types/singleton-admin-config.test.node.d.ts} +0 -0
|
@@ -0,0 +1,396 @@
|
|
|
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 { AdminAuth, createRequestContext } from '@byline/auth';
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { DbErrorCodes } from '../lib/errors.js';
|
|
11
|
+
import { copySingletonToLocale, resolveSingletonDocumentId, restoreSingletonVersion, updateSingleton, } from './singleton-lifecycle/index.js';
|
|
12
|
+
const noopLogger = {
|
|
13
|
+
log: vi.fn(),
|
|
14
|
+
fatal: vi.fn(),
|
|
15
|
+
error: vi.fn(),
|
|
16
|
+
warn: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
debug: vi.fn(),
|
|
19
|
+
trace: vi.fn(),
|
|
20
|
+
silent: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
function createHarness(options = {}) {
|
|
23
|
+
const definition = {
|
|
24
|
+
path: 'site-settings',
|
|
25
|
+
label: 'Site settings',
|
|
26
|
+
singleton: true,
|
|
27
|
+
fields: [
|
|
28
|
+
{ name: 'title', type: 'text', localized: true },
|
|
29
|
+
{ name: 'count', type: 'integer', optional: true },
|
|
30
|
+
],
|
|
31
|
+
hooks: options.hooks,
|
|
32
|
+
};
|
|
33
|
+
let mappedDocumentId = options.mapped ? 'doc-1' : null;
|
|
34
|
+
let currentVersion = options.mapped && !options.softDeleted
|
|
35
|
+
? {
|
|
36
|
+
document_version_id: 'ver-current',
|
|
37
|
+
document_id: 'doc-1',
|
|
38
|
+
collection_id: 'col-1',
|
|
39
|
+
status: 'draft',
|
|
40
|
+
created_at: new Date('2026-08-25T00:00:00.000Z'),
|
|
41
|
+
updated_at: new Date('2026-08-25T00:00:00.000Z'),
|
|
42
|
+
}
|
|
43
|
+
: null;
|
|
44
|
+
const views = new Map();
|
|
45
|
+
const versions = new Map();
|
|
46
|
+
if (options.mapped && !options.softDeleted) {
|
|
47
|
+
views.set('en', { fields: { title: 'Before', count: 1 } });
|
|
48
|
+
views.set('all', { fields: { title: { en: 'Before' }, count: 1 } });
|
|
49
|
+
}
|
|
50
|
+
let transactionDepth = 0;
|
|
51
|
+
let committedTransactions = 0;
|
|
52
|
+
let versionCounter = 0;
|
|
53
|
+
const withTransaction = vi.fn(async (operation) => {
|
|
54
|
+
const isOuter = transactionDepth === 0;
|
|
55
|
+
const snapshot = isOuter
|
|
56
|
+
? {
|
|
57
|
+
mappedDocumentId,
|
|
58
|
+
currentVersion,
|
|
59
|
+
views: new Map(views),
|
|
60
|
+
versionCounter,
|
|
61
|
+
}
|
|
62
|
+
: null;
|
|
63
|
+
transactionDepth++;
|
|
64
|
+
try {
|
|
65
|
+
const result = await operation();
|
|
66
|
+
transactionDepth--;
|
|
67
|
+
if (isOuter)
|
|
68
|
+
committedTransactions++;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
transactionDepth--;
|
|
73
|
+
if (snapshot != null) {
|
|
74
|
+
mappedDocumentId = snapshot.mappedDocumentId;
|
|
75
|
+
currentVersion = snapshot.currentVersion;
|
|
76
|
+
views.clear();
|
|
77
|
+
for (const [locale, view] of snapshot.views)
|
|
78
|
+
views.set(locale, view);
|
|
79
|
+
versionCounter = snapshot.versionCounter;
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const lockSlot = vi.fn(async () => {
|
|
85
|
+
expect(transactionDepth).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
const setMapping = vi.fn(async (_collectionId, documentId) => {
|
|
88
|
+
mappedDocumentId = documentId;
|
|
89
|
+
});
|
|
90
|
+
const createDocumentVersion = vi.fn(async (write) => {
|
|
91
|
+
const documentId = write.documentId ?? 'doc-1';
|
|
92
|
+
const documentVersionId = `ver-${++versionCounter}`;
|
|
93
|
+
currentVersion = {
|
|
94
|
+
document_version_id: documentVersionId,
|
|
95
|
+
document_id: documentId,
|
|
96
|
+
collection_id: 'col-1',
|
|
97
|
+
status: write.status,
|
|
98
|
+
created_at: new Date('2026-08-25T00:00:00.000Z'),
|
|
99
|
+
updated_at: new Date('2026-08-26T00:00:00.000Z'),
|
|
100
|
+
};
|
|
101
|
+
views.set(write.locale, { fields: write.documentData });
|
|
102
|
+
return {
|
|
103
|
+
document: { id: documentVersionId, document_id: documentId },
|
|
104
|
+
fieldCount: Object.keys(write.documentData).length,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
const getMappedDocumentId = vi.fn(async () => mappedDocumentId);
|
|
108
|
+
const getCurrentVersionMetadata = vi.fn(async () => currentVersion);
|
|
109
|
+
const getDocumentById = vi.fn(async (params) => {
|
|
110
|
+
if (mappedDocumentId == null || currentVersion == null)
|
|
111
|
+
return null;
|
|
112
|
+
const locale = params.locale ?? 'en';
|
|
113
|
+
const view = views.get(locale);
|
|
114
|
+
if (view == null && params.onMissingLocale === 'omit')
|
|
115
|
+
return null;
|
|
116
|
+
return {
|
|
117
|
+
document_id: mappedDocumentId,
|
|
118
|
+
document_version_id: currentVersion.document_version_id,
|
|
119
|
+
status: currentVersion.status,
|
|
120
|
+
fields: view?.fields ?? {},
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
const getDocumentByVersion = vi.fn(async (params) => versions.get(params.document_version_id));
|
|
124
|
+
const classifyError = vi.fn((_error) => ({ code: DbErrorCodes.UNKNOWN }));
|
|
125
|
+
const db = {
|
|
126
|
+
classifyError,
|
|
127
|
+
commands: {
|
|
128
|
+
documents: {
|
|
129
|
+
createDocumentVersion,
|
|
130
|
+
publishSchedules: {
|
|
131
|
+
suspendForContentEdit: vi.fn(async () => ({ status: 'schedule_not_found' })),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
counters: {
|
|
135
|
+
nextCounterValue: vi.fn(async () => 1),
|
|
136
|
+
},
|
|
137
|
+
audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
|
|
138
|
+
singletons: {
|
|
139
|
+
lockSlot,
|
|
140
|
+
setMapping,
|
|
141
|
+
clearMapping: vi.fn(async () => {
|
|
142
|
+
mappedDocumentId = null;
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
queries: {
|
|
147
|
+
documents: {
|
|
148
|
+
getCurrentVersionMetadata,
|
|
149
|
+
getDocumentById,
|
|
150
|
+
getDocumentByVersion,
|
|
151
|
+
},
|
|
152
|
+
singletons: { getMappedDocumentId },
|
|
153
|
+
},
|
|
154
|
+
withTransaction,
|
|
155
|
+
};
|
|
156
|
+
const actor = new AdminAuth({
|
|
157
|
+
id: 'editor',
|
|
158
|
+
abilities: ['singletons.site-settings.update'],
|
|
159
|
+
});
|
|
160
|
+
const ctx = {
|
|
161
|
+
db,
|
|
162
|
+
definition,
|
|
163
|
+
collectionId: 'col-1',
|
|
164
|
+
collectionVersion: 1,
|
|
165
|
+
collectionPath: definition.path,
|
|
166
|
+
defaultLocale: 'en',
|
|
167
|
+
logger: noopLogger,
|
|
168
|
+
requestContext: createRequestContext({ actor }),
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
ctx,
|
|
172
|
+
definition,
|
|
173
|
+
views,
|
|
174
|
+
versions,
|
|
175
|
+
createDocumentVersion,
|
|
176
|
+
getMappedDocumentId,
|
|
177
|
+
getCurrentVersionMetadata,
|
|
178
|
+
getDocumentById,
|
|
179
|
+
lockSlot,
|
|
180
|
+
setMapping,
|
|
181
|
+
classifyError,
|
|
182
|
+
withTransaction,
|
|
183
|
+
isInTransaction: () => transactionDepth > 0,
|
|
184
|
+
committedTransactions: () => committedTransactions,
|
|
185
|
+
mappedDocumentId: () => mappedDocumentId,
|
|
186
|
+
currentVersion: () => currentVersion,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
describe('singleton lifecycle service', () => {
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
vi.clearAllMocks();
|
|
192
|
+
});
|
|
193
|
+
it('resolves null from the mapping before initial materialisation', async () => {
|
|
194
|
+
const harness = createHarness();
|
|
195
|
+
await expect(resolveSingletonDocumentId(harness.ctx)).resolves.toBeNull();
|
|
196
|
+
});
|
|
197
|
+
it('materialises once and fires save hooks around the committed transaction', async () => {
|
|
198
|
+
const contexts = [];
|
|
199
|
+
let harness;
|
|
200
|
+
harness = createHarness({
|
|
201
|
+
hooks: {
|
|
202
|
+
beforeSave: (context) => {
|
|
203
|
+
expect(harness.isInTransaction()).toBe(true);
|
|
204
|
+
contexts.push({ phase: 'before', ...context });
|
|
205
|
+
context.data.title = 'Mutated by hook';
|
|
206
|
+
},
|
|
207
|
+
afterSave: (context) => {
|
|
208
|
+
expect(harness.isInTransaction()).toBe(false);
|
|
209
|
+
expect(harness.committedTransactions()).toBe(1);
|
|
210
|
+
contexts.push({ phase: 'after', ...context });
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const result = await updateSingleton(harness.ctx, { data: { title: 'Incoming' } });
|
|
215
|
+
expect(result).toEqual({ documentId: 'doc-1', documentVersionId: 'ver-1' });
|
|
216
|
+
expect(harness.lockSlot).toHaveBeenCalledOnce();
|
|
217
|
+
expect(harness.setMapping).toHaveBeenCalledWith('col-1', 'doc-1');
|
|
218
|
+
expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({
|
|
219
|
+
action: 'create',
|
|
220
|
+
documentData: { title: 'Mutated by hook' },
|
|
221
|
+
locale: 'en',
|
|
222
|
+
}));
|
|
223
|
+
expect(harness.createDocumentVersion.mock.calls[0]?.[0]).not.toHaveProperty('documentId');
|
|
224
|
+
expect(contexts).toEqual([
|
|
225
|
+
expect.objectContaining({
|
|
226
|
+
phase: 'before',
|
|
227
|
+
isInitialSave: true,
|
|
228
|
+
originalData: null,
|
|
229
|
+
documentId: null,
|
|
230
|
+
operation: { type: 'save' },
|
|
231
|
+
}),
|
|
232
|
+
expect.objectContaining({
|
|
233
|
+
phase: 'after',
|
|
234
|
+
isInitialSave: true,
|
|
235
|
+
documentId: 'doc-1',
|
|
236
|
+
documentVersionId: 'ver-1',
|
|
237
|
+
operation: { type: 'save' },
|
|
238
|
+
}),
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
it.each([
|
|
242
|
+
['Postgres', 'byline_singleton_documents_pkey'],
|
|
243
|
+
['MySQL', 'PRIMARY'],
|
|
244
|
+
])('classifies a %s first-save mapping race by code and rolls back the version', async (_adapterName, constraint) => {
|
|
245
|
+
const harness = createHarness();
|
|
246
|
+
const uniqueViolation = new Error('engine-specific duplicate error');
|
|
247
|
+
harness.setMapping.mockRejectedValueOnce(uniqueViolation);
|
|
248
|
+
harness.classifyError.mockReturnValueOnce({
|
|
249
|
+
code: DbErrorCodes.UNIQUE_VIOLATION,
|
|
250
|
+
constraint,
|
|
251
|
+
});
|
|
252
|
+
await expect(updateSingleton(harness.ctx, { data: { title: 'Losing write' } })).rejects.toMatchObject({
|
|
253
|
+
code: 'ERR_CONFLICT',
|
|
254
|
+
message: "singleton 'site-settings' was materialised concurrently",
|
|
255
|
+
});
|
|
256
|
+
expect(harness.classifyError).toHaveBeenCalledWith(uniqueViolation);
|
|
257
|
+
expect(harness.committedTransactions()).toBe(0);
|
|
258
|
+
expect(harness.mappedDocumentId()).toBeNull();
|
|
259
|
+
expect(harness.currentVersion()).toBeNull();
|
|
260
|
+
expect(harness.views.size).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
it('updates the mapped document and supplies current field data to both hooks', async () => {
|
|
263
|
+
const beforeSave = vi.fn((context) => {
|
|
264
|
+
expect(context.data.count).toBe(2);
|
|
265
|
+
context.data.count = '3';
|
|
266
|
+
});
|
|
267
|
+
const afterSave = vi.fn();
|
|
268
|
+
const harness = createHarness({ mapped: true, hooks: { beforeSave, afterSave } });
|
|
269
|
+
await updateSingleton(harness.ctx, {
|
|
270
|
+
data: { title: 'After', count: '2' },
|
|
271
|
+
expectedVersionId: 'ver-current',
|
|
272
|
+
});
|
|
273
|
+
expect(harness.setMapping).not.toHaveBeenCalled();
|
|
274
|
+
expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({
|
|
275
|
+
documentId: 'doc-1',
|
|
276
|
+
action: 'update',
|
|
277
|
+
documentData: { title: 'After', count: 3 },
|
|
278
|
+
previousVersionId: 'ver-current',
|
|
279
|
+
}));
|
|
280
|
+
expect(beforeSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
281
|
+
isInitialSave: false,
|
|
282
|
+
documentId: 'doc-1',
|
|
283
|
+
originalData: { title: 'Before', count: 1 },
|
|
284
|
+
}));
|
|
285
|
+
expect(afterSave).toHaveBeenCalledWith(expect.objectContaining({ isInitialSave: false, documentVersionId: 'ver-1' }));
|
|
286
|
+
});
|
|
287
|
+
it('rejects a missing update ability before any database work', async () => {
|
|
288
|
+
const harness = createHarness();
|
|
289
|
+
harness.ctx.requestContext = createRequestContext({
|
|
290
|
+
actor: new AdminAuth({ id: 'reader', abilities: ['singletons.site-settings.read'] }),
|
|
291
|
+
});
|
|
292
|
+
await expect(updateSingleton(harness.ctx, { data: { title: 'No' } })).rejects.toMatchObject({
|
|
293
|
+
code: 'ERR_FORBIDDEN',
|
|
294
|
+
});
|
|
295
|
+
expect(harness.lockSlot).not.toHaveBeenCalled();
|
|
296
|
+
expect(harness.getMappedDocumentId).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
it('enforces expectedVersionId under the locked slot state', async () => {
|
|
299
|
+
const current = createHarness({ mapped: true });
|
|
300
|
+
await expect(updateSingleton(current.ctx, {
|
|
301
|
+
data: { title: 'Yes' },
|
|
302
|
+
expectedVersionId: 'ver-current',
|
|
303
|
+
})).resolves.toMatchObject({ documentId: 'doc-1' });
|
|
304
|
+
const stale = createHarness({ mapped: true });
|
|
305
|
+
await expect(updateSingleton(stale.ctx, { data: { title: 'No' }, expectedVersionId: 'ver-stale' })).rejects.toMatchObject({ code: 'ERR_CONFLICT' });
|
|
306
|
+
expect(stale.createDocumentVersion).not.toHaveBeenCalled();
|
|
307
|
+
const unmaterialised = createHarness();
|
|
308
|
+
await expect(updateSingleton(unmaterialised.ctx, {
|
|
309
|
+
data: { title: 'No' },
|
|
310
|
+
expectedVersionId: 'ver-believed-current',
|
|
311
|
+
})).rejects.toMatchObject({ code: 'ERR_CONFLICT' });
|
|
312
|
+
expect(unmaterialised.createDocumentVersion).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
it('rejects a non-default first locale before document or mapping writes', async () => {
|
|
315
|
+
const beforeSave = vi.fn();
|
|
316
|
+
const harness = createHarness({ hooks: { beforeSave } });
|
|
317
|
+
await expect(updateSingleton(harness.ctx, { data: { title: 'ไทย' }, locale: 'th' })).rejects.toMatchObject({ code: 'ERR_VALIDATION' });
|
|
318
|
+
expect(beforeSave).not.toHaveBeenCalled();
|
|
319
|
+
expect(harness.createDocumentVersion).not.toHaveBeenCalled();
|
|
320
|
+
expect(harness.setMapping).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
it('keeps a soft-deleted document mapped and rejects a replacement save', async () => {
|
|
323
|
+
const harness = createHarness({ mapped: true, softDeleted: true });
|
|
324
|
+
await expect(updateSingleton(harness.ctx, { data: { title: 'Replacement' } })).rejects.toMatchObject({ code: 'ERR_CONFLICT' });
|
|
325
|
+
expect(harness.mappedDocumentId()).toBe('doc-1');
|
|
326
|
+
expect(harness.createDocumentVersion).not.toHaveBeenCalled();
|
|
327
|
+
expect(harness.setMapping).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
it('leaves a committed version in place when afterSave rejects', async () => {
|
|
330
|
+
const harness = createHarness({
|
|
331
|
+
hooks: {
|
|
332
|
+
afterSave: () => {
|
|
333
|
+
throw new Error('notification failed');
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
await expect(updateSingleton(harness.ctx, { data: { title: 'Committed' } })).rejects.toThrow('notification failed');
|
|
338
|
+
expect(harness.committedTransactions()).toBe(1);
|
|
339
|
+
expect(harness.mappedDocumentId()).toBe('doc-1');
|
|
340
|
+
expect(harness.currentVersion()?.document_version_id).toBe('ver-1');
|
|
341
|
+
});
|
|
342
|
+
it('restores complete all-locale data through the singleton save discriminator', async () => {
|
|
343
|
+
const beforeSave = vi.fn();
|
|
344
|
+
const afterSave = vi.fn();
|
|
345
|
+
const harness = createHarness({ mapped: true, hooks: { beforeSave, afterSave } });
|
|
346
|
+
harness.views.set('all', { fields: { title: { en: 'Current', th: 'ปัจจุบัน' }, count: 2 } });
|
|
347
|
+
harness.versions.set('ver-source', {
|
|
348
|
+
document_id: 'doc-1',
|
|
349
|
+
fields: { title: { en: 'Historic', th: 'อดีต' }, count: 1 },
|
|
350
|
+
});
|
|
351
|
+
await restoreSingletonVersion(harness.ctx, { sourceVersionId: 'ver-source' });
|
|
352
|
+
const hookShape = expect.objectContaining({
|
|
353
|
+
data: { title: { en: 'Historic', th: 'อดีต' }, count: 1 },
|
|
354
|
+
originalData: { title: { en: 'Current', th: 'ปัจจุบัน' }, count: 2 },
|
|
355
|
+
locale: 'all',
|
|
356
|
+
isInitialSave: false,
|
|
357
|
+
operation: { type: 'restore', sourceVersionId: 'ver-source' },
|
|
358
|
+
});
|
|
359
|
+
expect(beforeSave).toHaveBeenCalledWith(hookShape);
|
|
360
|
+
expect(afterSave).toHaveBeenCalledWith(hookShape);
|
|
361
|
+
expect(harness.createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({ action: 'restore', locale: 'all' }));
|
|
362
|
+
});
|
|
363
|
+
it('copies the merged target payload and distinguishes overwrite modes', async () => {
|
|
364
|
+
const run = async (overwrite) => {
|
|
365
|
+
const beforeSave = vi.fn();
|
|
366
|
+
const harness = createHarness({ mapped: true, hooks: { beforeSave } });
|
|
367
|
+
harness.views.set('en', { fields: { title: 'Source', count: 1 } });
|
|
368
|
+
harness.views.set('th', { fields: { title: 'Target', count: 1 } });
|
|
369
|
+
await copySingletonToLocale(harness.ctx, {
|
|
370
|
+
sourceLocale: 'en',
|
|
371
|
+
targetLocale: 'th',
|
|
372
|
+
overwrite,
|
|
373
|
+
});
|
|
374
|
+
return { context: beforeSave.mock.calls[0]?.[0], harness };
|
|
375
|
+
};
|
|
376
|
+
const preserving = await run(false);
|
|
377
|
+
const overwriting = await run(true);
|
|
378
|
+
expect(preserving.context).toEqual(expect.objectContaining({
|
|
379
|
+
data: { title: 'Target', count: 1 },
|
|
380
|
+
originalData: { title: 'Target', count: 1 },
|
|
381
|
+
locale: 'th',
|
|
382
|
+
operation: {
|
|
383
|
+
type: 'copyToLocale',
|
|
384
|
+
sourceLocale: 'en',
|
|
385
|
+
targetLocale: 'th',
|
|
386
|
+
overwrite: false,
|
|
387
|
+
},
|
|
388
|
+
}));
|
|
389
|
+
expect(overwriting.context).toEqual(expect.objectContaining({
|
|
390
|
+
data: { title: 'Source', count: 1 },
|
|
391
|
+
operation: expect.objectContaining({ type: 'copyToLocale', overwrite: true }),
|
|
392
|
+
}));
|
|
393
|
+
expect(preserving.harness.setMapping).not.toHaveBeenCalled();
|
|
394
|
+
expect(overwriting.harness.setMapping).not.toHaveBeenCalled();
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
// Web Crypto (`globalThis.crypto.subtle`) works identically in Node 20+
|
|
9
|
+
// and every modern browser. Using it here — instead of `node:crypto` —
|
|
10
|
+
// keeps this module free of Node built-ins so it can sit in the static
|
|
11
|
+
// module graph walked by client bundlers without being externalised.
|
|
12
|
+
import { isSingleton } from '../@types/collection-types.js';
|
|
8
13
|
function canonicalField(field) {
|
|
9
14
|
const base = {
|
|
10
15
|
name: field.name,
|
|
@@ -124,14 +129,21 @@ function canonicalCollection(def) {
|
|
|
124
129
|
path: def.path,
|
|
125
130
|
fields: def.fields.map(canonicalField),
|
|
126
131
|
};
|
|
132
|
+
// Emitted only for singletons: adding an unconditional `kind` would change
|
|
133
|
+
// every existing collection's hash and bump every stored schema version on
|
|
134
|
+
// upgrade, even though its data shape did not change.
|
|
135
|
+
if (isSingleton(def))
|
|
136
|
+
out.kind = 'singleton';
|
|
127
137
|
if (def.workflow)
|
|
128
138
|
out.workflow = canonicalWorkflow(def.workflow);
|
|
129
|
-
if (def
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
if (!isSingleton(def)) {
|
|
140
|
+
if (def.useAsPath !== undefined)
|
|
141
|
+
out.useAsPath = def.useAsPath;
|
|
142
|
+
if (def.useAsTitle !== undefined)
|
|
143
|
+
out.useAsTitle = def.useAsTitle;
|
|
144
|
+
if (def.advertiseLocales !== undefined)
|
|
145
|
+
out.advertiseLocales = def.advertiseLocales;
|
|
146
|
+
}
|
|
135
147
|
return out;
|
|
136
148
|
}
|
|
137
149
|
// ---------------------------------------------------------------------------
|
|
@@ -270,4 +270,19 @@ describe('fingerprintCollection', () => {
|
|
|
270
270
|
});
|
|
271
271
|
expect(await fingerprintCollection(b)).toBe(hashA);
|
|
272
272
|
});
|
|
273
|
+
it('distinguishes a singleton from a collection with the same shape', async () => {
|
|
274
|
+
const fields = [{ name: 'name', label: 'Site name', type: 'text' }];
|
|
275
|
+
const asCollection = await fingerprintCollection({
|
|
276
|
+
path: 'site-settings',
|
|
277
|
+
labels: { singular: 'Setting', plural: 'Settings' },
|
|
278
|
+
fields,
|
|
279
|
+
});
|
|
280
|
+
const asSingleton = await fingerprintCollection({
|
|
281
|
+
path: 'site-settings',
|
|
282
|
+
label: 'Site settings',
|
|
283
|
+
singleton: true,
|
|
284
|
+
fields,
|
|
285
|
+
});
|
|
286
|
+
expect(asSingleton).not.toBe(asCollection);
|
|
287
|
+
});
|
|
273
288
|
});
|
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": "4.
|
|
5
|
+
"version": "4.18.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -84,14 +84,14 @@
|
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"dotenv": "^17.4.2",
|
|
86
86
|
"pino": "^10.3.1",
|
|
87
|
-
"sharp": "^0.35.
|
|
87
|
+
"sharp": "^0.35.4",
|
|
88
88
|
"uuid": "^14.0.2",
|
|
89
89
|
"zod": "^4.4.3",
|
|
90
|
-
"@byline/auth": "4.
|
|
90
|
+
"@byline/auth": "4.18.0"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
|
-
"@biomejs/biome": "2.5.
|
|
94
|
-
"@types/node": "^26.
|
|
93
|
+
"@biomejs/biome": "2.5.10",
|
|
94
|
+
"@types/node": "^26.4.0",
|
|
95
95
|
"chokidar": "^5.0.0",
|
|
96
96
|
"chokidar-cli": "^3.0.0",
|
|
97
97
|
"npm-run-all": "^4.1.5",
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
});
|
|
File without changes
|