@byline/db-mysql 4.8.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/LICENSE +373 -0
- package/README.md +283 -0
- package/dist/database/schema/auth.d.ts +951 -0
- package/dist/database/schema/auth.js +226 -0
- package/dist/database/schema/common.d.ts +168 -0
- package/dist/database/schema/common.js +130 -0
- package/dist/database/schema/index.d.ts +3419 -0
- package/dist/database/schema/index.js +895 -0
- package/dist/database/schema/schema-pins.test.node.d.ts +52 -0
- package/dist/database/schema/schema-pins.test.node.js +398 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +151 -0
- package/dist/lib/boot-check.d.ts +22 -0
- package/dist/lib/boot-check.js +42 -0
- package/dist/lib/boot-check.test.node.d.ts +8 -0
- package/dist/lib/boot-check.test.node.js +46 -0
- package/dist/lib/db-manager.d.ts +54 -0
- package/dist/lib/db-manager.js +49 -0
- package/dist/lib/test-db.d.ts +37 -0
- package/dist/lib/test-db.js +111 -0
- package/dist/lib/test-helper.d.ts +33 -0
- package/dist/lib/test-helper.js +68 -0
- package/dist/modules/admin/admin-permissions-repository.d.ts +35 -0
- package/dist/modules/admin/admin-permissions-repository.js +101 -0
- package/dist/modules/admin/admin-preferences-repository.d.ts +62 -0
- package/dist/modules/admin/admin-preferences-repository.js +156 -0
- package/dist/modules/admin/admin-roles-repository.d.ts +11 -0
- package/dist/modules/admin/admin-roles-repository.js +209 -0
- package/dist/modules/admin/admin-store.d.ts +20 -0
- package/dist/modules/admin/admin-store.js +30 -0
- package/dist/modules/admin/admin-users-repository.d.ts +11 -0
- package/dist/modules/admin/admin-users-repository.js +303 -0
- package/dist/modules/admin/index.d.ts +27 -0
- package/dist/modules/admin/index.js +27 -0
- package/dist/modules/admin/refresh-tokens-repository.d.ts +34 -0
- package/dist/modules/admin/refresh-tokens-repository.js +160 -0
- package/dist/modules/audit/audit-commands.d.ts +29 -0
- package/dist/modules/audit/audit-commands.js +40 -0
- package/dist/modules/audit/audit-queries.d.ts +41 -0
- package/dist/modules/audit/audit-queries.js +169 -0
- package/dist/modules/counters/counters-commands.d.ts +54 -0
- package/dist/modules/counters/counters-commands.js +121 -0
- package/dist/modules/counters/tests/counters-concurrency.test.d.ts +8 -0
- package/dist/modules/counters/tests/counters-concurrency.test.js +111 -0
- package/dist/modules/storage/classify-error.d.ts +34 -0
- package/dist/modules/storage/classify-error.js +50 -0
- package/dist/modules/storage/classify-error.test.node.d.ts +8 -0
- package/dist/modules/storage/classify-error.test.node.js +89 -0
- package/dist/modules/storage/normalize-row.d.ts +55 -0
- package/dist/modules/storage/normalize-row.js +135 -0
- package/dist/modules/storage/normalize-row.test.node.d.ts +8 -0
- package/dist/modules/storage/normalize-row.test.node.js +89 -0
- package/dist/modules/storage/storage-commands.d.ts +443 -0
- package/dist/modules/storage/storage-commands.js +1263 -0
- package/dist/modules/storage/storage-insert.d.ts +21 -0
- package/dist/modules/storage/storage-insert.js +152 -0
- package/dist/modules/storage/storage-queries.d.ts +805 -0
- package/dist/modules/storage/storage-queries.js +1815 -0
- package/dist/modules/storage/storage-store-manifest.d.ts +77 -0
- package/dist/modules/storage/storage-store-manifest.js +168 -0
- package/dist/modules/storage/storage-utils.d.ts +49 -0
- package/dist/modules/storage/storage-utils.js +76 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.d.ts +8 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.js +266 -0
- package/dist/modules/storage/tests/storage-commands.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-commands.test.js +324 -0
- package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-paths.test.js +214 -0
- package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-tree.test.js +361 -0
- package/dist/modules/storage/tests/storage-queries.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-queries.test.js +685 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.js +268 -0
- package/package.json +91 -0
|
@@ -0,0 +1,324 @@
|
|
|
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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
10
|
+
import { classifyError } from '../classify-error.js';
|
|
11
|
+
const timestamp = Date.now();
|
|
12
|
+
/** `noUncheckedIndexedAccess` guard: unwrap a `T[0]`, failing loudly if empty. */
|
|
13
|
+
function first(rows) {
|
|
14
|
+
const row = rows[0];
|
|
15
|
+
if (row == null) {
|
|
16
|
+
throw new Error('expected at least one row, got none');
|
|
17
|
+
}
|
|
18
|
+
return row;
|
|
19
|
+
}
|
|
20
|
+
/** Raw SQL through the live pool — bypasses the ORM read path entirely. */
|
|
21
|
+
async function queryRows(pool, sql, params) {
|
|
22
|
+
const [rows] = await pool.query(sql, params);
|
|
23
|
+
return rows;
|
|
24
|
+
}
|
|
25
|
+
async function queryOne(pool, sql, params) {
|
|
26
|
+
return first(await queryRows(pool, sql, params));
|
|
27
|
+
}
|
|
28
|
+
const CategoriesCollectionConfig = {
|
|
29
|
+
path: `cmd-test-categories-${timestamp}`,
|
|
30
|
+
labels: { singular: 'Category', plural: 'Categories' },
|
|
31
|
+
fields: [{ name: 'name', type: 'text' }],
|
|
32
|
+
};
|
|
33
|
+
const ArticlesCollectionConfig = {
|
|
34
|
+
path: `cmd-test-articles-${timestamp}`,
|
|
35
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
36
|
+
fields: [
|
|
37
|
+
{ name: 'title', type: 'text', localized: true },
|
|
38
|
+
{ name: 'views', type: 'integer' },
|
|
39
|
+
{ name: 'price', type: 'decimal' },
|
|
40
|
+
{ name: 'featured', type: 'boolean' },
|
|
41
|
+
{ name: 'publishedOn', type: 'datetime' },
|
|
42
|
+
{ name: 'category', type: 'relation', targetCollection: 'categories', optional: true },
|
|
43
|
+
{ name: 'body', type: 'richText', localized: true },
|
|
44
|
+
{
|
|
45
|
+
name: 'links',
|
|
46
|
+
type: 'array',
|
|
47
|
+
fields: [{ name: 'label', type: 'text' }],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
describe('storage-commands (mysql, live database)', () => {
|
|
52
|
+
let testDb;
|
|
53
|
+
let rawPool;
|
|
54
|
+
let categoriesId;
|
|
55
|
+
let articlesId;
|
|
56
|
+
let categoryDocumentId;
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
testDb = setupTestDB([CategoriesCollectionConfig, ArticlesCollectionConfig]);
|
|
59
|
+
rawPool = testDb.pool;
|
|
60
|
+
const categories = first(await testDb.commandBuilders.collections.create(CategoriesCollectionConfig.path, CategoriesCollectionConfig));
|
|
61
|
+
categoriesId = categories.id;
|
|
62
|
+
const articles = first(await testDb.commandBuilders.collections.create(ArticlesCollectionConfig.path, ArticlesCollectionConfig));
|
|
63
|
+
articlesId = articles.id;
|
|
64
|
+
const categoryVersion = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
65
|
+
collectionId: categoriesId,
|
|
66
|
+
collectionVersion: 1,
|
|
67
|
+
collectionConfig: CategoriesCollectionConfig,
|
|
68
|
+
action: 'create',
|
|
69
|
+
documentData: { name: 'News' },
|
|
70
|
+
path: `news-${timestamp}`,
|
|
71
|
+
locale: 'all',
|
|
72
|
+
status: 'draft',
|
|
73
|
+
});
|
|
74
|
+
categoryDocumentId = categoryVersion.document.document_id;
|
|
75
|
+
});
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await testDb.commandBuilders.collections.delete(articlesId);
|
|
78
|
+
await testDb.commandBuilders.collections.delete(categoriesId);
|
|
79
|
+
await teardownTestDB();
|
|
80
|
+
});
|
|
81
|
+
describe('CollectionCommands', () => {
|
|
82
|
+
it('create constructs the row in JS (id/path/config known up front, no RETURNING)', async () => {
|
|
83
|
+
const row = first(await testDb.commandBuilders.collections.create(`throwaway-${timestamp}`, {
|
|
84
|
+
path: `throwaway-${timestamp}`,
|
|
85
|
+
labels: { singular: 'Throwaway', plural: 'Throwaways' },
|
|
86
|
+
fields: [{ name: 'x', type: 'text' }],
|
|
87
|
+
}));
|
|
88
|
+
expect(row.id).toBeTruthy();
|
|
89
|
+
expect(row.path).toBe(`throwaway-${timestamp}`);
|
|
90
|
+
expect(row.version).toBe(1);
|
|
91
|
+
const dbRow = await queryOne(rawPool, 'SELECT id, path, version FROM byline_collections WHERE id = ?', [row.id]);
|
|
92
|
+
expect(dbRow.id).toBe(row.id);
|
|
93
|
+
expect(dbRow.path).toBe(`throwaway-${timestamp}`);
|
|
94
|
+
await testDb.commandBuilders.collections.delete(row.id);
|
|
95
|
+
const afterDelete = await queryRows(rawPool, 'SELECT id FROM byline_collections WHERE id = ?', [row.id]);
|
|
96
|
+
expect(afterDelete.length).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
it('update re-SELECTs the merged row (MySQL has no RETURNING)', async () => {
|
|
99
|
+
const row = first(await testDb.commandBuilders.collections.create(`updatable-${timestamp}`, {
|
|
100
|
+
path: `updatable-${timestamp}`,
|
|
101
|
+
labels: { singular: 'Updatable', plural: 'Updatables' },
|
|
102
|
+
fields: [{ name: 'x', type: 'text' }],
|
|
103
|
+
}));
|
|
104
|
+
const updated = first(await testDb.commandBuilders.collections.update(row.id, { version: 2 }));
|
|
105
|
+
expect(updated.version).toBe(2);
|
|
106
|
+
expect(updated.path).toBe(`updatable-${timestamp}`); // untouched fields survive the re-SELECT
|
|
107
|
+
await testDb.commandBuilders.collections.delete(row.id);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('DocumentCommands.createDocumentVersion', () => {
|
|
111
|
+
let firstVersionId;
|
|
112
|
+
let articleDocumentId;
|
|
113
|
+
const articlePath = `first-article-${timestamp}`;
|
|
114
|
+
it('creates a document + version and writes every store bucket correctly', async () => {
|
|
115
|
+
const result = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
116
|
+
collectionId: articlesId,
|
|
117
|
+
collectionVersion: 1,
|
|
118
|
+
collectionConfig: ArticlesCollectionConfig,
|
|
119
|
+
action: 'create',
|
|
120
|
+
documentData: {
|
|
121
|
+
title: { en: 'Hello world', es: 'Hola mundo' },
|
|
122
|
+
views: 42,
|
|
123
|
+
price: '19.99',
|
|
124
|
+
featured: true,
|
|
125
|
+
publishedOn: new Date('2024-01-15T10:30:00.123Z'),
|
|
126
|
+
category: { targetDocumentId: categoryDocumentId, targetCollectionId: categoriesId },
|
|
127
|
+
body: {
|
|
128
|
+
en: { root: { type: 'text', text: 'english body' } },
|
|
129
|
+
es: { root: { type: 'text', text: 'spanish body' } },
|
|
130
|
+
},
|
|
131
|
+
links: [{ label: 'one' }, { label: 'two' }],
|
|
132
|
+
},
|
|
133
|
+
path: articlePath,
|
|
134
|
+
availableLocales: ['en', 'es'],
|
|
135
|
+
locale: 'all',
|
|
136
|
+
status: 'draft',
|
|
137
|
+
});
|
|
138
|
+
expect(result.document.document_id).toBeTruthy();
|
|
139
|
+
expect(result.document.id).toBeTruthy();
|
|
140
|
+
expect(result.fieldCount).toBeGreaterThan(0);
|
|
141
|
+
firstVersionId = result.document.id;
|
|
142
|
+
articleDocumentId = result.document.document_id;
|
|
143
|
+
// byline_documents — source_locale anchored to the adapter's default ('en')
|
|
144
|
+
const docRow = await queryOne(rawPool, 'SELECT id, collection_id, source_locale FROM byline_documents WHERE id = ?', [articleDocumentId]);
|
|
145
|
+
expect(docRow.source_locale).toBe('en');
|
|
146
|
+
expect(docRow.collection_id).toBe(articlesId);
|
|
147
|
+
// byline_document_versions
|
|
148
|
+
const versionRow = await queryOne(rawPool, 'SELECT id, document_id, status, event_type, is_deleted FROM byline_document_versions WHERE id = ?', [firstVersionId]);
|
|
149
|
+
expect(versionRow.status).toBe('draft');
|
|
150
|
+
expect(versionRow.event_type).toBe('create');
|
|
151
|
+
expect(versionRow.is_deleted).toBe(0);
|
|
152
|
+
// byline_store_text — localized 'title', two locale rows
|
|
153
|
+
const textRows = await queryRows(rawPool, "SELECT locale, value FROM byline_store_text WHERE document_version_id = ? AND field_name = 'title' ORDER BY locale", [firstVersionId]);
|
|
154
|
+
expect(textRows.map((r) => [r.locale, r.value])).toEqual([
|
|
155
|
+
['en', 'Hello world'],
|
|
156
|
+
['es', 'Hola mundo'],
|
|
157
|
+
]);
|
|
158
|
+
// byline_store_numeric — integer 'views' and decimal 'price'
|
|
159
|
+
const viewsRow = await queryOne(rawPool, "SELECT number_type, value_integer FROM byline_store_numeric WHERE document_version_id = ? AND field_name = 'views'", [firstVersionId]);
|
|
160
|
+
expect(viewsRow.number_type).toBe('integer');
|
|
161
|
+
expect(viewsRow.value_integer).toBe(42);
|
|
162
|
+
const priceRow = await queryOne(rawPool, "SELECT number_type, value_decimal FROM byline_store_numeric WHERE document_version_id = ? AND field_name = 'price'", [firstVersionId]);
|
|
163
|
+
expect(priceRow.number_type).toBe('decimal');
|
|
164
|
+
// DECIMAL stays a string end to end (pool `decimalNumbers: false`).
|
|
165
|
+
expect(typeof priceRow.value_decimal).toBe('string');
|
|
166
|
+
expect(priceRow.value_decimal).toBe('19.99');
|
|
167
|
+
// byline_store_boolean — TINYINT(1) on the wire; the mysql2 driver
|
|
168
|
+
// returns a JS number here (0/1), not a boolean — confirmed live
|
|
169
|
+
// (see normalize-row.ts's docblock). This is the raw column value
|
|
170
|
+
// before any `normalizeRow` canonicalisation.
|
|
171
|
+
const featuredRow = await queryOne(rawPool, "SELECT value FROM byline_store_boolean WHERE document_version_id = ? AND field_name = 'featured'", [firstVersionId]);
|
|
172
|
+
expect(featuredRow.value).toBe(1);
|
|
173
|
+
// byline_store_datetime — DATETIME(6) round-trips as a Date with the
|
|
174
|
+
// pool's `timezone: 'Z'` option (this query goes through the raw pool,
|
|
175
|
+
// not drizzle's typeCast-overridden `db.execute()` — see
|
|
176
|
+
// `storage-utils.ts`'s `toDate` docblock for why that distinction
|
|
177
|
+
// matters), millisecond precision intact for this millisecond-only
|
|
178
|
+
// source value.
|
|
179
|
+
const publishedRow = await queryOne(rawPool, "SELECT value_timestamp_tz FROM byline_store_datetime WHERE document_version_id = ? AND field_name = 'publishedOn'", [firstVersionId]);
|
|
180
|
+
expect(publishedRow.value_timestamp_tz).toBeInstanceOf(Date);
|
|
181
|
+
expect(publishedRow.value_timestamp_tz.toISOString()).toBe('2024-01-15T10:30:00.123Z');
|
|
182
|
+
// byline_store_relation
|
|
183
|
+
const relationRow = await queryOne(rawPool, "SELECT target_document_id, target_collection_id, relationship_type, cascade_delete FROM byline_store_relation WHERE document_version_id = ? AND field_name = 'category'", [firstVersionId]);
|
|
184
|
+
expect(relationRow.target_document_id).toBe(categoryDocumentId);
|
|
185
|
+
expect(relationRow.target_collection_id).toBe(categoriesId);
|
|
186
|
+
expect(relationRow.relationship_type).toBe('reference');
|
|
187
|
+
expect(relationRow.cascade_delete).toBe(0);
|
|
188
|
+
// byline_store_json — richText 'body', already-parsed object on read
|
|
189
|
+
// (the driver parses JSON columns itself; `normalizeRow` must not
|
|
190
|
+
// double-`JSON.parse` them).
|
|
191
|
+
const bodyRow = await queryOne(rawPool, "SELECT value FROM byline_store_json WHERE document_version_id = ? AND field_name = 'body' AND locale = 'en'", [firstVersionId]);
|
|
192
|
+
expect(bodyRow.value).toEqual({ root: { type: 'text', text: 'english body' } });
|
|
193
|
+
// byline_store_meta — the array/array-item `_id` identity rows
|
|
194
|
+
const metaRows = await queryRows(rawPool, 'SELECT type, path FROM byline_store_meta WHERE document_version_id = ?', [firstVersionId]);
|
|
195
|
+
expect(metaRows.length).toBeGreaterThan(0);
|
|
196
|
+
// byline_document_paths — upserted under the document's source_locale
|
|
197
|
+
const pathRow = await queryOne(rawPool, 'SELECT path, locale, collection_id FROM byline_document_paths WHERE document_id = ?', [articleDocumentId]);
|
|
198
|
+
expect(pathRow.path).toBe(articlePath);
|
|
199
|
+
expect(pathRow.locale).toBe('en');
|
|
200
|
+
expect(pathRow.collection_id).toBe(articlesId);
|
|
201
|
+
// byline_document_available_locales — the editorial advertised set
|
|
202
|
+
const availableRows = await queryRows(rawPool, 'SELECT locale FROM byline_document_available_locales WHERE document_id = ? ORDER BY locale', [articleDocumentId]);
|
|
203
|
+
expect(availableRows.map((r) => r.locale)).toEqual(['en', 'es']);
|
|
204
|
+
// byline_document_version_locales — the completeness ledger
|
|
205
|
+
const localeLedgerRows = await queryRows(rawPool, 'SELECT locale FROM byline_document_version_locales WHERE document_version_id = ? ORDER BY locale', [firstVersionId]);
|
|
206
|
+
expect(localeLedgerRows.map((r) => r.locale)).toEqual(['en', 'es']);
|
|
207
|
+
});
|
|
208
|
+
it('creates a second version of the same document (multi-version, shared document_id)', async () => {
|
|
209
|
+
const second = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
210
|
+
documentId: articleDocumentId,
|
|
211
|
+
collectionId: articlesId,
|
|
212
|
+
collectionVersion: 1,
|
|
213
|
+
collectionConfig: ArticlesCollectionConfig,
|
|
214
|
+
action: 'update',
|
|
215
|
+
documentData: {
|
|
216
|
+
title: { en: 'Hello world v2', es: 'Hola mundo v2' },
|
|
217
|
+
views: 43,
|
|
218
|
+
price: '29.99',
|
|
219
|
+
featured: false,
|
|
220
|
+
publishedOn: new Date('2024-02-01T00:00:00.000Z'),
|
|
221
|
+
links: [],
|
|
222
|
+
},
|
|
223
|
+
locale: 'all',
|
|
224
|
+
status: 'draft',
|
|
225
|
+
});
|
|
226
|
+
expect(second.document.document_id).toBe(articleDocumentId);
|
|
227
|
+
expect(second.document.id).not.toBe(firstVersionId);
|
|
228
|
+
const versionRows = await queryRows(rawPool, 'SELECT id FROM byline_document_versions WHERE document_id = ?', [articleDocumentId]);
|
|
229
|
+
expect(versionRows.length).toBe(2);
|
|
230
|
+
// The first version's rows must be untouched — versioning is immutable.
|
|
231
|
+
const firstStillThere = await queryOne(rawPool, "SELECT value FROM byline_store_text WHERE document_version_id = ? AND field_name = 'title' AND locale = 'en'", [firstVersionId]);
|
|
232
|
+
expect(firstStillThere.value).toBe('Hello world');
|
|
233
|
+
});
|
|
234
|
+
it('copies non-active-locale rows forward when saving in a single locale', async () => {
|
|
235
|
+
const secondVersion = await queryOne(rawPool, 'SELECT id FROM byline_document_versions WHERE document_id = ? ORDER BY id DESC LIMIT 1', [articleDocumentId]);
|
|
236
|
+
const third = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
237
|
+
documentId: articleDocumentId,
|
|
238
|
+
collectionId: articlesId,
|
|
239
|
+
collectionVersion: 1,
|
|
240
|
+
collectionConfig: ArticlesCollectionConfig,
|
|
241
|
+
action: 'update',
|
|
242
|
+
documentData: {
|
|
243
|
+
title: 'Bonjour le monde', // single-locale shape: 'fr' is not 'all'
|
|
244
|
+
views: 43,
|
|
245
|
+
price: '29.99',
|
|
246
|
+
featured: false,
|
|
247
|
+
publishedOn: new Date('2024-02-01T00:00:00.000Z'),
|
|
248
|
+
links: [],
|
|
249
|
+
},
|
|
250
|
+
previousVersionId: secondVersion.id,
|
|
251
|
+
locale: 'fr',
|
|
252
|
+
status: 'draft',
|
|
253
|
+
});
|
|
254
|
+
const thirdVersionId = third.document.id;
|
|
255
|
+
// The freshly-written 'fr' row plus the carried-forward 'en'/'es' rows
|
|
256
|
+
// from the previous version — three distinct locales, one row each.
|
|
257
|
+
const titleRows = await queryRows(rawPool, "SELECT locale, value FROM byline_store_text WHERE document_version_id = ? AND field_name = 'title' ORDER BY locale", [thirdVersionId]);
|
|
258
|
+
expect(titleRows.map((r) => [r.locale, r.value])).toEqual([
|
|
259
|
+
['en', 'Hello world v2'],
|
|
260
|
+
['es', 'Hola mundo v2'],
|
|
261
|
+
['fr', 'Bonjour le monde'],
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
it('re-saving the same document + locale updates the path row in place (no duplicate-key error)', async () => {
|
|
265
|
+
const updatedPath = `${articlePath}-renamed`;
|
|
266
|
+
await testDb.commandBuilders.documents.createDocumentVersion({
|
|
267
|
+
documentId: articleDocumentId,
|
|
268
|
+
collectionId: articlesId,
|
|
269
|
+
collectionVersion: 1,
|
|
270
|
+
collectionConfig: ArticlesCollectionConfig,
|
|
271
|
+
action: 'update',
|
|
272
|
+
documentData: {
|
|
273
|
+
title: { en: 'Hello world v4' },
|
|
274
|
+
views: 44,
|
|
275
|
+
price: '39.99',
|
|
276
|
+
featured: false,
|
|
277
|
+
links: [],
|
|
278
|
+
},
|
|
279
|
+
path: updatedPath,
|
|
280
|
+
locale: 'all',
|
|
281
|
+
status: 'draft',
|
|
282
|
+
});
|
|
283
|
+
const pathRows = await queryRows(rawPool, 'SELECT path FROM byline_document_paths WHERE document_id = ?', [articleDocumentId]);
|
|
284
|
+
expect(pathRows.length).toBe(1);
|
|
285
|
+
expect(pathRows[0]?.path).toBe(updatedPath);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe('path conflicts surface as a real MySQL duplicate-key error that classifyError recognises', () => {
|
|
289
|
+
it('classifies a live ER_DUP_ENTRY from the (collection, locale, path) unique index', async () => {
|
|
290
|
+
const conflictingPath = `conflict-${timestamp}`;
|
|
291
|
+
await testDb.commandBuilders.documents.createDocumentVersion({
|
|
292
|
+
collectionId: articlesId,
|
|
293
|
+
collectionVersion: 1,
|
|
294
|
+
collectionConfig: ArticlesCollectionConfig,
|
|
295
|
+
action: 'create',
|
|
296
|
+
documentData: { title: 'A', views: 1, price: '1.00', featured: false, links: [] },
|
|
297
|
+
path: conflictingPath,
|
|
298
|
+
locale: 'all',
|
|
299
|
+
status: 'draft',
|
|
300
|
+
});
|
|
301
|
+
let caught;
|
|
302
|
+
try {
|
|
303
|
+
await testDb.commandBuilders.documents.createDocumentVersion({
|
|
304
|
+
collectionId: articlesId,
|
|
305
|
+
collectionVersion: 1,
|
|
306
|
+
collectionConfig: ArticlesCollectionConfig,
|
|
307
|
+
action: 'create',
|
|
308
|
+
documentData: { title: 'B', views: 1, price: '1.00', featured: false, links: [] },
|
|
309
|
+
path: conflictingPath,
|
|
310
|
+
locale: 'all',
|
|
311
|
+
status: 'draft',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
caught = err;
|
|
316
|
+
}
|
|
317
|
+
expect(caught).toBeDefined();
|
|
318
|
+
expect(classifyError(caught)).toEqual({
|
|
319
|
+
code: 'DB_UNIQUE_VIOLATION',
|
|
320
|
+
constraint: 'idx_document_paths_collection_locale_path',
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
10
|
+
import { classifyError } from '../classify-error.js';
|
|
11
|
+
const timestamp = Date.now();
|
|
12
|
+
function first(rows) {
|
|
13
|
+
const row = rows[0];
|
|
14
|
+
if (row == null)
|
|
15
|
+
throw new Error('expected at least one row, got none');
|
|
16
|
+
return row;
|
|
17
|
+
}
|
|
18
|
+
async function queryRows(pool, sql, params) {
|
|
19
|
+
const [rows] = await pool.query(sql, params);
|
|
20
|
+
return rows;
|
|
21
|
+
}
|
|
22
|
+
async function queryOne(pool, sql, params) {
|
|
23
|
+
return first(await queryRows(pool, sql, params));
|
|
24
|
+
}
|
|
25
|
+
const PagesCollectionConfig = {
|
|
26
|
+
path: `paths-test-pages-${timestamp}`,
|
|
27
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
28
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
29
|
+
};
|
|
30
|
+
describe('DocumentCommands.updateDocumentPath / setDocumentAvailableLocales (mysql, live database)', () => {
|
|
31
|
+
let testDb;
|
|
32
|
+
let rawPool;
|
|
33
|
+
let collectionId;
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
testDb = setupTestDB([PagesCollectionConfig]);
|
|
36
|
+
rawPool = testDb.pool;
|
|
37
|
+
const created = first(await testDb.commandBuilders.collections.create(PagesCollectionConfig.path, PagesCollectionConfig));
|
|
38
|
+
collectionId = created.id;
|
|
39
|
+
});
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
42
|
+
await teardownTestDB();
|
|
43
|
+
});
|
|
44
|
+
async function createDocWithoutPath(title) {
|
|
45
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
46
|
+
collectionId,
|
|
47
|
+
collectionVersion: 1,
|
|
48
|
+
collectionConfig: PagesCollectionConfig,
|
|
49
|
+
action: 'create',
|
|
50
|
+
documentData: { title },
|
|
51
|
+
locale: 'all',
|
|
52
|
+
status: 'draft',
|
|
53
|
+
});
|
|
54
|
+
return created.document.document_id;
|
|
55
|
+
}
|
|
56
|
+
describe('updateDocumentPath', () => {
|
|
57
|
+
it('inserts a fresh path row when the document has none yet', async () => {
|
|
58
|
+
const documentId = await createDocWithoutPath('Fresh Path Doc');
|
|
59
|
+
const path = `fresh-${timestamp}`;
|
|
60
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
61
|
+
documentId,
|
|
62
|
+
collectionId,
|
|
63
|
+
locale: 'en',
|
|
64
|
+
path,
|
|
65
|
+
});
|
|
66
|
+
const rows = await queryRows(rawPool, 'SELECT path, locale, collection_id FROM byline_document_paths WHERE document_id = ?', [documentId]);
|
|
67
|
+
expect(rows.length).toBe(1);
|
|
68
|
+
expect(rows[0]?.path).toBe(path);
|
|
69
|
+
expect(rows[0]?.locale).toBe('en');
|
|
70
|
+
});
|
|
71
|
+
it('updates the existing path row in place — no new document version', async () => {
|
|
72
|
+
const documentId = await createDocWithoutPath('Updatable Path Doc');
|
|
73
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
74
|
+
documentId,
|
|
75
|
+
collectionId,
|
|
76
|
+
locale: 'en',
|
|
77
|
+
path: `updatable-v1-${timestamp}`,
|
|
78
|
+
});
|
|
79
|
+
const versionCountBefore = (await queryRows(rawPool, 'SELECT id FROM byline_document_versions WHERE document_id = ?', [
|
|
80
|
+
documentId,
|
|
81
|
+
])).length;
|
|
82
|
+
const updatedPath = `updatable-v2-${timestamp}`;
|
|
83
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
84
|
+
documentId,
|
|
85
|
+
collectionId,
|
|
86
|
+
locale: 'en',
|
|
87
|
+
path: updatedPath,
|
|
88
|
+
});
|
|
89
|
+
const rows = await queryRows(rawPool, 'SELECT path FROM byline_document_paths WHERE document_id = ?', [documentId]);
|
|
90
|
+
expect(rows.length).toBe(1); // still one row — updated, not duplicated
|
|
91
|
+
expect(rows[0]?.path).toBe(updatedPath);
|
|
92
|
+
const versionCountAfter = (await queryRows(rawPool, 'SELECT id FROM byline_document_versions WHERE document_id = ?', [
|
|
93
|
+
documentId,
|
|
94
|
+
])).length;
|
|
95
|
+
expect(versionCountAfter).toBe(versionCountBefore); // no version minted
|
|
96
|
+
});
|
|
97
|
+
it('rejects a genuine cross-document path collision, surfaced via classifyError', async () => {
|
|
98
|
+
const path = `collide-${timestamp}`;
|
|
99
|
+
const docA = await createDocWithoutPath('Collide A');
|
|
100
|
+
const docB = await createDocWithoutPath('Collide B');
|
|
101
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
102
|
+
documentId: docA,
|
|
103
|
+
collectionId,
|
|
104
|
+
locale: 'en',
|
|
105
|
+
path,
|
|
106
|
+
});
|
|
107
|
+
let caught;
|
|
108
|
+
try {
|
|
109
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
110
|
+
documentId: docB,
|
|
111
|
+
collectionId,
|
|
112
|
+
locale: 'en',
|
|
113
|
+
path,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
caught = err;
|
|
118
|
+
}
|
|
119
|
+
expect(caught).toBeDefined();
|
|
120
|
+
expect(classifyError(caught)).toEqual({
|
|
121
|
+
code: 'DB_UNIQUE_VIOLATION',
|
|
122
|
+
constraint: 'idx_document_paths_collection_locale_path',
|
|
123
|
+
});
|
|
124
|
+
// docB's insert was rejected outright — no row for it, and docA's row untouched.
|
|
125
|
+
const bRows = await queryRows(rawPool, 'SELECT path FROM byline_document_paths WHERE document_id = ?', [docB]);
|
|
126
|
+
expect(bRows.length).toBe(0);
|
|
127
|
+
const aRow = await queryOne(rawPool, 'SELECT path FROM byline_document_paths WHERE document_id = ?', [docA]);
|
|
128
|
+
expect(aRow.path).toBe(path);
|
|
129
|
+
});
|
|
130
|
+
/**
|
|
131
|
+
* §C.2 (binding review finding): the path upsert had no concurrent-writer
|
|
132
|
+
* coverage. `writeDocumentPath` replaces Postgres's declarative
|
|
133
|
+
* `onConflictDoUpdate({ target })` with application-level control flow —
|
|
134
|
+
* insert, catch, classify, and only fall through to an `UPDATE` when the
|
|
135
|
+
* *own-document* unique index collided (see that method's docblock). This
|
|
136
|
+
* proves the classification still discriminates correctly under a true
|
|
137
|
+
* race, not just the sequential collision above: two never-before-pathed
|
|
138
|
+
* documents racing to claim the exact same (collection_id, locale, path)
|
|
139
|
+
* via `updateDocumentPath` — the second caller into `writeDocumentPath`
|
|
140
|
+
* this task wires in. Exactly one writer must win the unique index; the
|
|
141
|
+
* other must surface a `DB_UNIQUE_VIOLATION` on the *cross-document*
|
|
142
|
+
* index, not a duplicate row and not a silently swallowed error.
|
|
143
|
+
*/
|
|
144
|
+
it('races two writers onto the same (collection, locale, path): one wins, one is classified as a path conflict', async () => {
|
|
145
|
+
const path = `race-${timestamp}`;
|
|
146
|
+
const docA = await createDocWithoutPath('Race A');
|
|
147
|
+
const docB = await createDocWithoutPath('Race B');
|
|
148
|
+
const results = await Promise.allSettled([
|
|
149
|
+
testDb.commandBuilders.documents.updateDocumentPath({
|
|
150
|
+
documentId: docA,
|
|
151
|
+
collectionId,
|
|
152
|
+
locale: 'en',
|
|
153
|
+
path,
|
|
154
|
+
}),
|
|
155
|
+
testDb.commandBuilders.documents.updateDocumentPath({
|
|
156
|
+
documentId: docB,
|
|
157
|
+
collectionId,
|
|
158
|
+
locale: 'en',
|
|
159
|
+
path,
|
|
160
|
+
}),
|
|
161
|
+
]);
|
|
162
|
+
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
|
163
|
+
const rejected = results.filter((r) => r.status === 'rejected');
|
|
164
|
+
expect(fulfilled.length).toBe(1);
|
|
165
|
+
expect(rejected.length).toBe(1);
|
|
166
|
+
expect(classifyError(rejected[0]?.reason)).toEqual({
|
|
167
|
+
code: 'DB_UNIQUE_VIOLATION',
|
|
168
|
+
constraint: 'idx_document_paths_collection_locale_path',
|
|
169
|
+
});
|
|
170
|
+
// Exactly one row claims the contested path — no duplicate, no ghost row.
|
|
171
|
+
const claimants = await queryRows(rawPool, 'SELECT document_id FROM byline_document_paths WHERE collection_id = ? AND locale = ? AND path = ?', [collectionId, 'en', path]);
|
|
172
|
+
expect(claimants.length).toBe(1);
|
|
173
|
+
expect([docA, docB]).toContain(claimants[0]?.document_id);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe('setDocumentAvailableLocales', () => {
|
|
177
|
+
it('writes, replaces, and clears the advertised-locale set', async () => {
|
|
178
|
+
const documentId = await createDocWithoutPath('Locales Doc');
|
|
179
|
+
await testDb.commandBuilders.documents.setDocumentAvailableLocales({
|
|
180
|
+
documentId,
|
|
181
|
+
collectionId,
|
|
182
|
+
availableLocales: ['en', 'fr'],
|
|
183
|
+
});
|
|
184
|
+
let rows = await queryRows(rawPool, 'SELECT locale FROM byline_document_available_locales WHERE document_id = ? ORDER BY locale', [documentId]);
|
|
185
|
+
expect(rows.map((r) => r.locale)).toEqual(['en', 'fr']);
|
|
186
|
+
// Replace wholesale — 'es' only, 'en'/'fr' dropped.
|
|
187
|
+
await testDb.commandBuilders.documents.setDocumentAvailableLocales({
|
|
188
|
+
documentId,
|
|
189
|
+
collectionId,
|
|
190
|
+
availableLocales: ['es'],
|
|
191
|
+
});
|
|
192
|
+
rows = await queryRows(rawPool, 'SELECT locale FROM byline_document_available_locales WHERE document_id = ? ORDER BY locale', [documentId]);
|
|
193
|
+
expect(rows.map((r) => r.locale)).toEqual(['es']);
|
|
194
|
+
// Empty array clears the set entirely.
|
|
195
|
+
await testDb.commandBuilders.documents.setDocumentAvailableLocales({
|
|
196
|
+
documentId,
|
|
197
|
+
collectionId,
|
|
198
|
+
availableLocales: [],
|
|
199
|
+
});
|
|
200
|
+
rows = await queryRows(rawPool, 'SELECT locale FROM byline_document_available_locales WHERE document_id = ?', [documentId]);
|
|
201
|
+
expect(rows.length).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
it('deduplicates a caller-supplied duplicate locale (PK is document_id + locale)', async () => {
|
|
204
|
+
const documentId = await createDocWithoutPath('Dedup Locales Doc');
|
|
205
|
+
await testDb.commandBuilders.documents.setDocumentAvailableLocales({
|
|
206
|
+
documentId,
|
|
207
|
+
collectionId,
|
|
208
|
+
availableLocales: ['en', 'en', 'fr'],
|
|
209
|
+
});
|
|
210
|
+
const rows = await queryRows(rawPool, 'SELECT locale FROM byline_document_available_locales WHERE document_id = ? ORDER BY locale', [documentId]);
|
|
211
|
+
expect(rows.map((r) => r.locale)).toEqual(['en', 'fr']);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|