@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,685 @@
|
|
|
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
|
+
const timestamp = Date.now();
|
|
11
|
+
function first(rows) {
|
|
12
|
+
const row = rows[0];
|
|
13
|
+
if (row == null)
|
|
14
|
+
throw new Error('expected at least one row, got none');
|
|
15
|
+
return row;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `created_at` is `DATETIME(6)` (microsecond precision — see `common.ts`'s
|
|
19
|
+
* `auditTimestamp` docblock), but back-to-back inserts in a tight loop can
|
|
20
|
+
* still in principle land close enough together to make a `created_at`-
|
|
21
|
+
* ordered (or `created_at`-tiebreaking) assertion flaky. A short delay
|
|
22
|
+
* between creates keeps `created_at` strictly increasing and the expected
|
|
23
|
+
* order deterministic, independent of how fine the column's own resolution is.
|
|
24
|
+
*/
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
const PagesCollectionConfig = {
|
|
29
|
+
path: `queries-test-pages-${timestamp}`,
|
|
30
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
31
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
32
|
+
};
|
|
33
|
+
const OrderKeyCollectionConfig = {
|
|
34
|
+
path: `queries-test-orderkey-${timestamp}`,
|
|
35
|
+
labels: { singular: 'Item', plural: 'Items' },
|
|
36
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
37
|
+
};
|
|
38
|
+
const OrderingPaginationCollectionConfig = {
|
|
39
|
+
path: `queries-test-ordering-${timestamp}`,
|
|
40
|
+
labels: { singular: 'Item', plural: 'Items' },
|
|
41
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
42
|
+
};
|
|
43
|
+
const AuthorsCollectionConfig = {
|
|
44
|
+
path: `queries-test-authors-${timestamp}`,
|
|
45
|
+
labels: { singular: 'Author', plural: 'Authors' },
|
|
46
|
+
fields: [{ name: 'name', type: 'text' }],
|
|
47
|
+
};
|
|
48
|
+
const PostsCollectionConfig = {
|
|
49
|
+
path: `queries-test-posts-${timestamp}`,
|
|
50
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
51
|
+
fields: [
|
|
52
|
+
{ name: 'title', type: 'text' },
|
|
53
|
+
{ name: 'views', type: 'integer', optional: true },
|
|
54
|
+
{
|
|
55
|
+
name: 'author',
|
|
56
|
+
type: 'relation',
|
|
57
|
+
targetCollection: `queries-test-authors-${timestamp}`,
|
|
58
|
+
optional: true,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const DeepTreeCollectionConfig = {
|
|
63
|
+
path: `queries-test-deep-tree-${timestamp}`,
|
|
64
|
+
labels: { singular: 'Node', plural: 'Nodes' },
|
|
65
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
66
|
+
tree: true,
|
|
67
|
+
};
|
|
68
|
+
describe('DocumentQueries.getDocumentById locale-chain path resolution (mysql, live database)', () => {
|
|
69
|
+
let testDb;
|
|
70
|
+
let collectionId;
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
// setupTestDB's defaultContentLocale is 'en' (see test-helper.ts).
|
|
73
|
+
testDb = setupTestDB([PagesCollectionConfig]);
|
|
74
|
+
const created = first(await testDb.commandBuilders.collections.create(PagesCollectionConfig.path, PagesCollectionConfig));
|
|
75
|
+
collectionId = created.id;
|
|
76
|
+
});
|
|
77
|
+
afterAll(async () => {
|
|
78
|
+
// `teardownTestDB()` is called once, at the very end of this file (see
|
|
79
|
+
// the last describe block below) — closing the shared pool here would
|
|
80
|
+
// pull it out from under the ordering/pagination describes that follow.
|
|
81
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
82
|
+
});
|
|
83
|
+
it('prioritises the requested locale over the source-locale floor when both have a path row (FIELD() ordering)', async () => {
|
|
84
|
+
const enPath = `en-path-${timestamp}`;
|
|
85
|
+
const frPath = `fr-path-${timestamp}`;
|
|
86
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
87
|
+
collectionId,
|
|
88
|
+
collectionVersion: 1,
|
|
89
|
+
collectionConfig: PagesCollectionConfig,
|
|
90
|
+
action: 'create',
|
|
91
|
+
documentData: { title: 'Locale chain doc' },
|
|
92
|
+
path: enPath,
|
|
93
|
+
locale: 'en',
|
|
94
|
+
status: 'draft',
|
|
95
|
+
});
|
|
96
|
+
const documentId = created.document.document_id;
|
|
97
|
+
// Add a second path row in a different locale for the same document —
|
|
98
|
+
// now `byline_document_paths` has two rows: source_locale ('en') and 'fr'.
|
|
99
|
+
await testDb.commandBuilders.documents.updateDocumentPath({
|
|
100
|
+
documentId,
|
|
101
|
+
collectionId,
|
|
102
|
+
locale: 'fr',
|
|
103
|
+
path: frPath,
|
|
104
|
+
});
|
|
105
|
+
// Requesting 'fr' directly: chain = ['fr', 'en'] (requested, then the
|
|
106
|
+
// floor). Both rows are candidates — FIELD() must rank 'fr' first,
|
|
107
|
+
// exactly like pg's array_position ranks the requested locale first.
|
|
108
|
+
const frDoc = await testDb.queryBuilders.documents.getDocumentById({
|
|
109
|
+
collection_id: collectionId,
|
|
110
|
+
document_id: documentId,
|
|
111
|
+
locale: 'fr',
|
|
112
|
+
});
|
|
113
|
+
expect(frDoc?.path).toBe(frPath);
|
|
114
|
+
// Requesting a third, unrelated locale ('es'): chain = ['es', 'en'].
|
|
115
|
+
// Neither row is 'es', so the `WHERE locale IN (chain)` filter alone
|
|
116
|
+
// (not FIELD()'s ordering) must exclude the irrelevant 'fr' row and land
|
|
117
|
+
// on the source-locale floor, 'en'.
|
|
118
|
+
const esDoc = await testDb.queryBuilders.documents.getDocumentById({
|
|
119
|
+
collection_id: collectionId,
|
|
120
|
+
document_id: documentId,
|
|
121
|
+
locale: 'es',
|
|
122
|
+
});
|
|
123
|
+
expect(esDoc?.path).toBe(enPath);
|
|
124
|
+
// Requesting 'en' directly: chain collapses to a single-element chain
|
|
125
|
+
// (`buildLocaleChain` dedupes requested === floor). Sanity check that
|
|
126
|
+
// this degenerate case still resolves correctly.
|
|
127
|
+
const enDoc = await testDb.queryBuilders.documents.getDocumentById({
|
|
128
|
+
collection_id: collectionId,
|
|
129
|
+
document_id: documentId,
|
|
130
|
+
locale: 'en',
|
|
131
|
+
});
|
|
132
|
+
expect(enDoc?.path).toBe(enPath);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
const DatesCollectionConfig = {
|
|
136
|
+
path: `queries-test-dates-${timestamp}`,
|
|
137
|
+
labels: { singular: 'DatedThing', plural: 'DatedThings' },
|
|
138
|
+
fields: [
|
|
139
|
+
{ name: 'title', type: 'text' },
|
|
140
|
+
{ name: 'onDate', type: 'date' },
|
|
141
|
+
{ name: 'atTime', type: 'datetime' },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* `date`/`datetime` field-type values round-trip as real `Date` objects
|
|
146
|
+
* through the ordinary `getDocumentById` read path — i.e. through
|
|
147
|
+
* `getAllFieldValuesForMultipleVersions`'s raw `db.execute(sql\`...\`)` UNION
|
|
148
|
+
* ALL and `normalizeRow`'s coercion. Task 11's audit-log fix
|
|
149
|
+
* (`audit-queries.ts`) found that drizzle-orm's mysql2 driver returns
|
|
150
|
+
* `DATE`/`DATETIME` columns as strings on that code path regardless of the
|
|
151
|
+
* pool's own `timezone` option; `normalizeRow` picked up the same defect for
|
|
152
|
+
* `value_date`/`value_timestamp_tz` — this is the live, end-to-end pin for
|
|
153
|
+
* that fix (`normalize-row.test.node.ts` pins the unit separately). No
|
|
154
|
+
* existing suite exercised a `date`/`datetime` field through this path, so
|
|
155
|
+
* nothing caught the un-coerced string before this test.
|
|
156
|
+
*/
|
|
157
|
+
describe('date/datetime field values are real Date objects on read (mysql, live database)', () => {
|
|
158
|
+
let testDb;
|
|
159
|
+
let collectionId;
|
|
160
|
+
beforeAll(async () => {
|
|
161
|
+
testDb = setupTestDB([DatesCollectionConfig]);
|
|
162
|
+
const created = first(await testDb.commandBuilders.collections.create(DatesCollectionConfig.path, DatesCollectionConfig));
|
|
163
|
+
collectionId = created.id;
|
|
164
|
+
});
|
|
165
|
+
afterAll(async () => {
|
|
166
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
167
|
+
});
|
|
168
|
+
it('returns value_date as a Date (UTC midnight for the stored calendar date)', async () => {
|
|
169
|
+
const onDate = new Date('2026-01-15T00:00:00.000Z');
|
|
170
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
171
|
+
collectionId,
|
|
172
|
+
collectionVersion: 1,
|
|
173
|
+
collectionConfig: DatesCollectionConfig,
|
|
174
|
+
action: 'create',
|
|
175
|
+
documentData: { title: 'Dated', onDate },
|
|
176
|
+
locale: 'all',
|
|
177
|
+
status: 'draft',
|
|
178
|
+
});
|
|
179
|
+
const doc = await testDb.queryBuilders.documents.getDocumentById({
|
|
180
|
+
collection_id: collectionId,
|
|
181
|
+
document_id: created.document.document_id,
|
|
182
|
+
locale: 'all',
|
|
183
|
+
});
|
|
184
|
+
const onDateValue = doc?.fields?.onDate;
|
|
185
|
+
expect(onDateValue).toBeInstanceOf(Date);
|
|
186
|
+
expect(onDateValue.toISOString()).toBe('2026-01-15T00:00:00.000Z');
|
|
187
|
+
});
|
|
188
|
+
it('returns value_timestamp_tz as a Date with full instant fidelity', async () => {
|
|
189
|
+
const atTime = new Date('2026-01-15T10:30:00.123Z');
|
|
190
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
191
|
+
collectionId,
|
|
192
|
+
collectionVersion: 1,
|
|
193
|
+
collectionConfig: DatesCollectionConfig,
|
|
194
|
+
action: 'create',
|
|
195
|
+
documentData: { title: 'Timed', atTime },
|
|
196
|
+
locale: 'all',
|
|
197
|
+
status: 'draft',
|
|
198
|
+
});
|
|
199
|
+
const doc = await testDb.queryBuilders.documents.getDocumentById({
|
|
200
|
+
collection_id: collectionId,
|
|
201
|
+
document_id: created.document.document_id,
|
|
202
|
+
locale: 'all',
|
|
203
|
+
});
|
|
204
|
+
const atTimeValue = doc?.fields?.atTime;
|
|
205
|
+
expect(atTimeValue).toBeInstanceOf(Date);
|
|
206
|
+
expect(atTimeValue.toISOString()).toBe('2026-01-15T10:30:00.123Z');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe('findDocuments ordering and pagination (mysql, live database)', () => {
|
|
210
|
+
let testDb;
|
|
211
|
+
beforeAll(() => {
|
|
212
|
+
// Both collections used by the nested describes below are declared
|
|
213
|
+
// upfront so `DocumentQueries.getDefinitionForCollection` can resolve
|
|
214
|
+
// either one's `CollectionDefinition` off this single `queryBuilders`
|
|
215
|
+
// instance — see `setupTestDB`'s doc comment.
|
|
216
|
+
testDb = setupTestDB([OrderKeyCollectionConfig, OrderingPaginationCollectionConfig]);
|
|
217
|
+
});
|
|
218
|
+
// `teardownTestDB()` is called once, at the very end of this file (see the
|
|
219
|
+
// last describe block below) — not here.
|
|
220
|
+
async function createDoc(collectionId, config, title, path) {
|
|
221
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
222
|
+
collectionId,
|
|
223
|
+
collectionVersion: 1,
|
|
224
|
+
collectionConfig: config,
|
|
225
|
+
action: 'create',
|
|
226
|
+
documentData: { title },
|
|
227
|
+
path,
|
|
228
|
+
locale: 'en',
|
|
229
|
+
status: 'draft',
|
|
230
|
+
});
|
|
231
|
+
return created.document.document_id;
|
|
232
|
+
}
|
|
233
|
+
describe('order_key ordering — NULLS-last emulation', () => {
|
|
234
|
+
let collectionId;
|
|
235
|
+
let doc1;
|
|
236
|
+
let doc2;
|
|
237
|
+
let doc3;
|
|
238
|
+
let doc4;
|
|
239
|
+
beforeAll(async () => {
|
|
240
|
+
const created = first(await testDb.commandBuilders.collections.create(OrderKeyCollectionConfig.path, OrderKeyCollectionConfig));
|
|
241
|
+
collectionId = created.id;
|
|
242
|
+
// doc1/doc2 get an order_key; doc3/doc4 stay unkeyed (NULL). Created
|
|
243
|
+
// sequentially with a short delay so the NULL rows' `created_at`
|
|
244
|
+
// tiebreak (newest-first) is deterministic.
|
|
245
|
+
doc1 = await createDoc(collectionId, OrderKeyCollectionConfig, 'keyed-1', `keyed-1-${timestamp}`);
|
|
246
|
+
await sleep(5);
|
|
247
|
+
doc2 = await createDoc(collectionId, OrderKeyCollectionConfig, 'keyed-2', `keyed-2-${timestamp}`);
|
|
248
|
+
await sleep(5);
|
|
249
|
+
doc3 = await createDoc(collectionId, OrderKeyCollectionConfig, 'unkeyed-3', `unkeyed-3-${timestamp}`);
|
|
250
|
+
await sleep(5);
|
|
251
|
+
doc4 = await createDoc(collectionId, OrderKeyCollectionConfig, 'unkeyed-4', `unkeyed-4-${timestamp}`);
|
|
252
|
+
await testDb.commandBuilders.documents.setOrderKey({ document_id: doc1, order_key: '1' });
|
|
253
|
+
await testDb.commandBuilders.documents.setOrderKey({ document_id: doc2, order_key: '2' });
|
|
254
|
+
});
|
|
255
|
+
afterAll(async () => {
|
|
256
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
257
|
+
});
|
|
258
|
+
it('ascending: keyed rows first in key order, unkeyed rows last (MySQL ASC sorts NULL first natively — the emulation this pins)', async () => {
|
|
259
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
260
|
+
collection_id: collectionId,
|
|
261
|
+
locale: 'en',
|
|
262
|
+
orderBy: 'order_key',
|
|
263
|
+
orderDirection: 'asc',
|
|
264
|
+
});
|
|
265
|
+
// doc1 ('1') < doc2 ('2') ascending; doc3/doc4 (NULL) sort last, tied
|
|
266
|
+
// on order_key so the `created_at DESC` secondary column decides —
|
|
267
|
+
// doc4 (created after doc3) comes first.
|
|
268
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([doc1, doc2, doc4, doc3]);
|
|
269
|
+
});
|
|
270
|
+
it('descending: keyed rows first in reverse key order, unkeyed rows still last (MySQL DESC already sorts NULL last natively — no emulation needed)', async () => {
|
|
271
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
272
|
+
collection_id: collectionId,
|
|
273
|
+
locale: 'en',
|
|
274
|
+
orderBy: 'order_key',
|
|
275
|
+
orderDirection: 'desc',
|
|
276
|
+
});
|
|
277
|
+
// doc2 ('2') > doc1 ('1') descending; doc3/doc4 (NULL) still sort
|
|
278
|
+
// last, same created_at DESC tiebreak as the ascending case.
|
|
279
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([doc2, doc1, doc4, doc3]);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
describe('document-column ordering and pagination', () => {
|
|
283
|
+
let collectionId;
|
|
284
|
+
let docIds = [];
|
|
285
|
+
beforeAll(async () => {
|
|
286
|
+
const created = first(await testDb.commandBuilders.collections.create(OrderingPaginationCollectionConfig.path, OrderingPaginationCollectionConfig));
|
|
287
|
+
collectionId = created.id;
|
|
288
|
+
docIds = [];
|
|
289
|
+
for (let i = 0; i < 5; i++) {
|
|
290
|
+
docIds.push(await createDoc(collectionId, OrderingPaginationCollectionConfig, `col-${i}`, `col-${i}-${timestamp}`));
|
|
291
|
+
await sleep(5);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
afterAll(async () => {
|
|
295
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
296
|
+
});
|
|
297
|
+
it('orders ascending by created_at (the non-order_key branch of buildDocumentOrderClause)', async () => {
|
|
298
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
299
|
+
collection_id: collectionId,
|
|
300
|
+
locale: 'en',
|
|
301
|
+
orderBy: 'created_at',
|
|
302
|
+
orderDirection: 'asc',
|
|
303
|
+
pageSize: docIds.length,
|
|
304
|
+
});
|
|
305
|
+
expect(result.documents.map((d) => d.document_id)).toEqual(docIds);
|
|
306
|
+
});
|
|
307
|
+
it('orders descending by created_at', async () => {
|
|
308
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
309
|
+
collection_id: collectionId,
|
|
310
|
+
locale: 'en',
|
|
311
|
+
orderBy: 'created_at',
|
|
312
|
+
orderDirection: 'desc',
|
|
313
|
+
pageSize: docIds.length,
|
|
314
|
+
});
|
|
315
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([...docIds].reverse());
|
|
316
|
+
});
|
|
317
|
+
it('paginates with page/pageSize, including a page beyond the last row', async () => {
|
|
318
|
+
const total = docIds.length; // 5
|
|
319
|
+
const page1 = await testDb.queryBuilders.documents.findDocuments({
|
|
320
|
+
collection_id: collectionId,
|
|
321
|
+
locale: 'en',
|
|
322
|
+
orderBy: 'created_at',
|
|
323
|
+
orderDirection: 'asc',
|
|
324
|
+
page: 1,
|
|
325
|
+
pageSize: 2,
|
|
326
|
+
});
|
|
327
|
+
expect(page1.total).toBe(total);
|
|
328
|
+
expect(page1.documents.map((d) => d.document_id)).toEqual(docIds.slice(0, 2));
|
|
329
|
+
const page2 = await testDb.queryBuilders.documents.findDocuments({
|
|
330
|
+
collection_id: collectionId,
|
|
331
|
+
locale: 'en',
|
|
332
|
+
orderBy: 'created_at',
|
|
333
|
+
orderDirection: 'asc',
|
|
334
|
+
page: 2,
|
|
335
|
+
pageSize: 2,
|
|
336
|
+
});
|
|
337
|
+
expect(page2.total).toBe(total);
|
|
338
|
+
expect(page2.documents.map((d) => d.document_id)).toEqual(docIds.slice(2, 4));
|
|
339
|
+
const page3 = await testDb.queryBuilders.documents.findDocuments({
|
|
340
|
+
collection_id: collectionId,
|
|
341
|
+
locale: 'en',
|
|
342
|
+
orderBy: 'created_at',
|
|
343
|
+
orderDirection: 'asc',
|
|
344
|
+
page: 3,
|
|
345
|
+
pageSize: 2,
|
|
346
|
+
});
|
|
347
|
+
expect(page3.total).toBe(total);
|
|
348
|
+
expect(page3.documents.map((d) => d.document_id)).toEqual(docIds.slice(4, 5));
|
|
349
|
+
// A page beyond the last row: `total` still reflects the full
|
|
350
|
+
// matching count (pagination doesn't change what's being counted),
|
|
351
|
+
// but this page's slice is empty.
|
|
352
|
+
const pageBeyond = await testDb.queryBuilders.documents.findDocuments({
|
|
353
|
+
collection_id: collectionId,
|
|
354
|
+
locale: 'en',
|
|
355
|
+
orderBy: 'created_at',
|
|
356
|
+
orderDirection: 'asc',
|
|
357
|
+
page: 4,
|
|
358
|
+
pageSize: 2,
|
|
359
|
+
});
|
|
360
|
+
expect(pageBeyond.total).toBe(total);
|
|
361
|
+
expect(pageBeyond.documents).toEqual([]);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
/**
|
|
366
|
+
* `findDocuments`' predicate compiler and the LATERAL field-sort join —
|
|
367
|
+
* none of the eleven registered `@byline/db-conformance` suites exercise
|
|
368
|
+
* the `$and`/`$or` combinators, a relation hop, `pathFilter`, `query`
|
|
369
|
+
* (LIKE search), or `sort` through `findDocuments` directly (the
|
|
370
|
+
* document-tree suite's `filters` only reach a plain field filter via
|
|
371
|
+
* `buildTreeVisibility`; `@byline/client`'s own combinator/relation-filter
|
|
372
|
+
* integration suite is pg-only — see `packages/client/tests/fixtures/
|
|
373
|
+
* setup.ts`). This describe block is this port's own live-database proof
|
|
374
|
+
* for that surface, mirroring the values-not-just-compiles standard the
|
|
375
|
+
* rest of this file already applies to `pathProjection`/`buildDocumentOrderClause`.
|
|
376
|
+
*/
|
|
377
|
+
describe('findDocuments filters, combinators, relation hops, search, and sort (mysql, live database)', () => {
|
|
378
|
+
let testDb;
|
|
379
|
+
let authorsCollectionId;
|
|
380
|
+
let postsCollectionId;
|
|
381
|
+
let adaId;
|
|
382
|
+
let graceId;
|
|
383
|
+
let post1; // by Ada, views 10, title "First post"
|
|
384
|
+
let post2; // by Grace, views 20, title "Second post"
|
|
385
|
+
let post3; // by Ada, no views (NULL), title "Ünïcödé Post" (accents, mixed case)
|
|
386
|
+
beforeAll(async () => {
|
|
387
|
+
testDb = setupTestDB([AuthorsCollectionConfig, PostsCollectionConfig]);
|
|
388
|
+
authorsCollectionId = first(await testDb.commandBuilders.collections.create(AuthorsCollectionConfig.path, AuthorsCollectionConfig)).id;
|
|
389
|
+
postsCollectionId = first(await testDb.commandBuilders.collections.create(PostsCollectionConfig.path, PostsCollectionConfig)).id;
|
|
390
|
+
async function createAuthor(name) {
|
|
391
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
392
|
+
collectionId: authorsCollectionId,
|
|
393
|
+
collectionVersion: 1,
|
|
394
|
+
collectionConfig: AuthorsCollectionConfig,
|
|
395
|
+
action: 'create',
|
|
396
|
+
documentData: { name },
|
|
397
|
+
path: `${name.toLowerCase()}-${timestamp}`,
|
|
398
|
+
locale: 'en',
|
|
399
|
+
status: 'published',
|
|
400
|
+
});
|
|
401
|
+
return created.document.document_id;
|
|
402
|
+
}
|
|
403
|
+
async function createPost(title, views, authorId, path) {
|
|
404
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
405
|
+
collectionId: postsCollectionId,
|
|
406
|
+
collectionVersion: 1,
|
|
407
|
+
collectionConfig: PostsCollectionConfig,
|
|
408
|
+
action: 'create',
|
|
409
|
+
documentData: {
|
|
410
|
+
title,
|
|
411
|
+
...(views !== undefined ? { views } : {}),
|
|
412
|
+
author: { targetDocumentId: authorId, targetCollectionId: authorsCollectionId },
|
|
413
|
+
},
|
|
414
|
+
path,
|
|
415
|
+
locale: 'en',
|
|
416
|
+
status: 'published',
|
|
417
|
+
});
|
|
418
|
+
return created.document.document_id;
|
|
419
|
+
}
|
|
420
|
+
adaId = await createAuthor('Ada');
|
|
421
|
+
graceId = await createAuthor('Grace');
|
|
422
|
+
post1 = await createPost('First post', 10, adaId, `first-post-${timestamp}`);
|
|
423
|
+
await sleep(5);
|
|
424
|
+
post2 = await createPost('Second post', 20, graceId, `second-post-${timestamp}`);
|
|
425
|
+
await sleep(5);
|
|
426
|
+
post3 = await createPost('Ünïcödé Post', undefined, adaId, `unicode-post-${timestamp}`);
|
|
427
|
+
});
|
|
428
|
+
// `teardownTestDB()` is called once, at the very end of this file (see the
|
|
429
|
+
// last describe block below) — not here.
|
|
430
|
+
it('$or combinator: matches either field-filter branch', async () => {
|
|
431
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
432
|
+
collection_id: postsCollectionId,
|
|
433
|
+
locale: 'en',
|
|
434
|
+
filters: [
|
|
435
|
+
{
|
|
436
|
+
kind: 'or',
|
|
437
|
+
children: [
|
|
438
|
+
{
|
|
439
|
+
kind: 'field',
|
|
440
|
+
fieldName: 'title',
|
|
441
|
+
storeType: 'text',
|
|
442
|
+
valueColumn: 'value',
|
|
443
|
+
operator: '$eq',
|
|
444
|
+
value: 'First post',
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
kind: 'field',
|
|
448
|
+
fieldName: 'title',
|
|
449
|
+
storeType: 'text',
|
|
450
|
+
valueColumn: 'value',
|
|
451
|
+
operator: '$eq',
|
|
452
|
+
value: 'Second post',
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
});
|
|
458
|
+
expect(new Set(result.documents.map((d) => d.document_id))).toEqual(new Set([post1, post2]));
|
|
459
|
+
expect(result.total).toBe(2);
|
|
460
|
+
});
|
|
461
|
+
it('$and combinator wrapping a docColumn(status) filter: intersects with a field filter', async () => {
|
|
462
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
463
|
+
collection_id: postsCollectionId,
|
|
464
|
+
locale: 'en',
|
|
465
|
+
filters: [
|
|
466
|
+
{
|
|
467
|
+
kind: 'and',
|
|
468
|
+
children: [
|
|
469
|
+
{ kind: 'docColumn', column: 'status', operator: '$eq', value: 'published' },
|
|
470
|
+
{
|
|
471
|
+
kind: 'field',
|
|
472
|
+
fieldName: 'title',
|
|
473
|
+
storeType: 'text',
|
|
474
|
+
valueColumn: 'value',
|
|
475
|
+
operator: '$eq',
|
|
476
|
+
value: 'First post',
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([post1]);
|
|
483
|
+
});
|
|
484
|
+
it('relation hop: finds posts whose author matches a nested field filter', async () => {
|
|
485
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
486
|
+
collection_id: postsCollectionId,
|
|
487
|
+
locale: 'en',
|
|
488
|
+
filters: [
|
|
489
|
+
{
|
|
490
|
+
kind: 'relation',
|
|
491
|
+
fieldName: 'author',
|
|
492
|
+
targetCollectionId: authorsCollectionId,
|
|
493
|
+
nested: [
|
|
494
|
+
{
|
|
495
|
+
kind: 'field',
|
|
496
|
+
fieldName: 'name',
|
|
497
|
+
storeType: 'text',
|
|
498
|
+
valueColumn: 'value',
|
|
499
|
+
operator: '$eq',
|
|
500
|
+
value: 'Ada',
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
expect(new Set(result.documents.map((d) => d.document_id))).toEqual(new Set([post1, post3]));
|
|
507
|
+
});
|
|
508
|
+
it('relation hop with quantifier "none": finds posts NOT authored by Ada', async () => {
|
|
509
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
510
|
+
collection_id: postsCollectionId,
|
|
511
|
+
locale: 'en',
|
|
512
|
+
filters: [
|
|
513
|
+
{
|
|
514
|
+
kind: 'relation',
|
|
515
|
+
fieldName: 'author',
|
|
516
|
+
targetCollectionId: authorsCollectionId,
|
|
517
|
+
quantifier: 'none',
|
|
518
|
+
nested: [
|
|
519
|
+
{
|
|
520
|
+
kind: 'field',
|
|
521
|
+
fieldName: 'name',
|
|
522
|
+
storeType: 'text',
|
|
523
|
+
valueColumn: 'value',
|
|
524
|
+
operator: '$eq',
|
|
525
|
+
value: 'Ada',
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
});
|
|
531
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([post2]);
|
|
532
|
+
});
|
|
533
|
+
it('pathFilter: matches an exact document path', async () => {
|
|
534
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
535
|
+
collection_id: postsCollectionId,
|
|
536
|
+
locale: 'en',
|
|
537
|
+
pathFilter: { operator: '$eq', value: `first-post-${timestamp}` },
|
|
538
|
+
});
|
|
539
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([post1]);
|
|
540
|
+
});
|
|
541
|
+
it('query (LIKE admin search): matches case- and accent-insensitively — the elected ILIKE→LIKE divergence', async () => {
|
|
542
|
+
// "unicode post" (lowercase, unaccented) must still match "Ünïcödé Post"
|
|
543
|
+
// — utf8mb4_0900_ai_ci (the store `value` column's collation) folds both
|
|
544
|
+
// case and diacritics, a strictly wider match than pg's case-only ILIKE.
|
|
545
|
+
// This is the divergence documented at `findDocuments`' query-search site
|
|
546
|
+
// and `buildFilterCondition`'s `$contains` branch — pinned here against a
|
|
547
|
+
// real accented row rather than just asserted in a comment.
|
|
548
|
+
const result = await testDb.queryBuilders.documents.findDocuments({
|
|
549
|
+
collection_id: postsCollectionId,
|
|
550
|
+
locale: 'en',
|
|
551
|
+
query: 'unicode post',
|
|
552
|
+
});
|
|
553
|
+
expect(result.documents.map((d) => d.document_id)).toEqual([post3]);
|
|
554
|
+
});
|
|
555
|
+
it('sort: LEFT JOIN LATERAL field sort on a numeric column, NULLS-last both directions', async () => {
|
|
556
|
+
// post1 views=10, post2 views=20, post3 views=NULL.
|
|
557
|
+
const asc = await testDb.queryBuilders.documents.findDocuments({
|
|
558
|
+
collection_id: postsCollectionId,
|
|
559
|
+
locale: 'en',
|
|
560
|
+
sort: {
|
|
561
|
+
fieldName: 'views',
|
|
562
|
+
storeType: 'numeric',
|
|
563
|
+
valueColumn: 'value_integer',
|
|
564
|
+
direction: 'asc',
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
// Ascending: real values first (10, 20), NULL last — the `(col IS NULL)
|
|
568
|
+
// ASC, col ASC` emulation this pins (MySQL's native ASC sorts NULL first).
|
|
569
|
+
expect(asc.documents.map((d) => d.document_id)).toEqual([post1, post2, post3]);
|
|
570
|
+
const desc = await testDb.queryBuilders.documents.findDocuments({
|
|
571
|
+
collection_id: postsCollectionId,
|
|
572
|
+
locale: 'en',
|
|
573
|
+
sort: {
|
|
574
|
+
fieldName: 'views',
|
|
575
|
+
storeType: 'numeric',
|
|
576
|
+
valueColumn: 'value_integer',
|
|
577
|
+
direction: 'desc',
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
// Descending: MySQL's native DESC already sorts NULL last — no emulation
|
|
581
|
+
// needed, confirmed here rather than just asserted.
|
|
582
|
+
expect(desc.documents.map((d) => d.document_id)).toEqual([post2, post1, post3]);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
/**
|
|
586
|
+
* `getTreeSubtree`'s recursive CTE `path` column — a live-database
|
|
587
|
+
* regression pin for the fix reviewed in the Task 10B fix round.
|
|
588
|
+
*
|
|
589
|
+
* MySQL infers a recursive CTE's column types from the non-recursive
|
|
590
|
+
* (anchor) leg only. The anchor's `path` column started life as a bare
|
|
591
|
+
* `r.order_key` reference, which made it inherit `order_key`'s declared
|
|
592
|
+
* `varchar(128)` width — every recursive iteration's `CONCAT` was then
|
|
593
|
+
* silently constrained to 128 bytes, and this database's `sql_mode`
|
|
594
|
+
* (`STRICT_TRANS_TABLES`) turns that into a hard `ER_DATA_TOO_LONG` rather
|
|
595
|
+
* than silent truncation. `documentTreeSuite`'s 20 tests all build shallow
|
|
596
|
+
* trees (a handful of levels), so none of them reach the threshold —
|
|
597
|
+
* `Σ len(order_key) + depth − 1 > 128`, roughly 11–40 levels for typical
|
|
598
|
+
* fractional-index keys. This describe block builds a straight-line chain
|
|
599
|
+
* deep enough to guarantee crossing that threshold and asserts the subtree
|
|
600
|
+
* comes back correctly ordered, not merely that it doesn't throw.
|
|
601
|
+
*
|
|
602
|
+
* Not added to `@byline/db-conformance`'s shared `document-tree` suite —
|
|
603
|
+
* this is a MySQL-only regression (Postgres's `::text` cast has no width
|
|
604
|
+
* cap), and per the fix-round instruction, the shared suite stays
|
|
605
|
+
* untouched.
|
|
606
|
+
*/
|
|
607
|
+
describe('getTreeSubtree deep path width (mysql, live database)', () => {
|
|
608
|
+
let testDb;
|
|
609
|
+
let collectionId;
|
|
610
|
+
// 60 single-child levels: each level's order_key is the fractional-index
|
|
611
|
+
// library's first-in-an-empty-group key ('a0', 2 bytes), so the
|
|
612
|
+
// accumulated path is `a0/a0/a0/…` — 60 * 3 - 1 = 179 bytes, comfortably
|
|
613
|
+
// past the 128-byte threshold that reproduced ER_DATA_TOO_LONG before the
|
|
614
|
+
// fix (and still comfortably under this fix's 4096-byte cast width).
|
|
615
|
+
const depth = 60;
|
|
616
|
+
let chain = [];
|
|
617
|
+
beforeAll(async () => {
|
|
618
|
+
testDb = setupTestDB([DeepTreeCollectionConfig]);
|
|
619
|
+
collectionId = first(await testDb.commandBuilders.collections.create(DeepTreeCollectionConfig.path, DeepTreeCollectionConfig)).id;
|
|
620
|
+
chain = [];
|
|
621
|
+
for (let i = 0; i < depth; i++) {
|
|
622
|
+
const created = await testDb.commandBuilders.documents.createDocumentVersion({
|
|
623
|
+
collectionId,
|
|
624
|
+
collectionVersion: 1,
|
|
625
|
+
collectionConfig: DeepTreeCollectionConfig,
|
|
626
|
+
action: 'create',
|
|
627
|
+
documentData: { title: `node-${i}` },
|
|
628
|
+
path: `deep-tree-node-${i}-${timestamp}`,
|
|
629
|
+
locale: 'en',
|
|
630
|
+
status: 'published',
|
|
631
|
+
});
|
|
632
|
+
chain.push(created.document.document_id);
|
|
633
|
+
}
|
|
634
|
+
// Place as a single-child chain: chain[0] is root, chain[1] is its only
|
|
635
|
+
// child, chain[2] is chain[1]'s only child, and so on. Each placement
|
|
636
|
+
// targets a brand-new (empty) sibling group, so every level mints the
|
|
637
|
+
// same short 'a0' key via the fractional-index library's empty-group
|
|
638
|
+
// default — the worst case for path growth (no width saved by longer
|
|
639
|
+
// keys pushing the depth threshold down, but also none saved by very
|
|
640
|
+
// short keys pushing it up beyond what's realistic in practice).
|
|
641
|
+
for (let i = 0; i < depth; i++) {
|
|
642
|
+
await testDb.commandBuilders.documents.placeTreeNode({
|
|
643
|
+
collectionId,
|
|
644
|
+
documentId: chain[i],
|
|
645
|
+
parentDocumentId: i === 0 ? null : chain[i - 1],
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
afterAll(async () => {
|
|
650
|
+
await testDb.commandBuilders.collections.delete(collectionId);
|
|
651
|
+
await teardownTestDB();
|
|
652
|
+
});
|
|
653
|
+
it('returns a 60-level single-child chain, correctly ordered, without ER_DATA_TOO_LONG', async () => {
|
|
654
|
+
const subtree = await testDb.queryBuilders.documents.getTreeSubtree({
|
|
655
|
+
collectionId,
|
|
656
|
+
rootDocumentId: null,
|
|
657
|
+
});
|
|
658
|
+
// Correctness, not just "didn't throw": exact count, exact document
|
|
659
|
+
// order (root-to-leaf, since it's a single-child chain), and exact
|
|
660
|
+
// depth per node.
|
|
661
|
+
expect(subtree.length).toBe(depth);
|
|
662
|
+
expect(subtree.map((n) => n.document_id)).toEqual(chain);
|
|
663
|
+
expect(subtree.map((n) => n.depth)).toEqual(Array.from({ length: depth }, (_, i) => i));
|
|
664
|
+
expect(subtree[0]?.parent_document_id).toBeNull();
|
|
665
|
+
for (let i = 1; i < depth; i++) {
|
|
666
|
+
expect(subtree[i]?.parent_document_id).toBe(chain[i - 1]);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
it('getTreeAncestors on the deepest leaf walks the full 59-ancestor chain root-first', async () => {
|
|
670
|
+
// The fixed-width uuidChar columns getTreeAncestors accumulates were
|
|
671
|
+
// never at risk (see the fix-round docblock), but this pins the deep
|
|
672
|
+
// case end to end alongside the subtree fix, using the same fixture.
|
|
673
|
+
const leaf = chain[depth - 1];
|
|
674
|
+
const ancestors = await testDb.queryBuilders.documents.getTreeAncestors({
|
|
675
|
+
document_id: leaf,
|
|
676
|
+
});
|
|
677
|
+
expect(ancestors.length).toBe(depth - 1);
|
|
678
|
+
// Root-first (`ORDER BY depth DESC`): chain[0] (the root) has the
|
|
679
|
+
// highest depth value (depth-1) and comes first; chain[depth-2] (the
|
|
680
|
+
// immediate parent) has depth 1 and comes last. `chain.slice(0,
|
|
681
|
+
// depth-1)` is already root-to-immediate-parent order, so no reverse.
|
|
682
|
+
expect(ancestors.map((a) => a.document_id)).toEqual(chain.slice(0, depth - 1));
|
|
683
|
+
expect(ancestors.map((a) => a.depth)).toEqual(Array.from({ length: depth - 1 }, (_, i) => depth - 1 - i));
|
|
684
|
+
});
|
|
685
|
+
});
|