@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.
Files changed (75) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +283 -0
  3. package/dist/database/schema/auth.d.ts +951 -0
  4. package/dist/database/schema/auth.js +226 -0
  5. package/dist/database/schema/common.d.ts +168 -0
  6. package/dist/database/schema/common.js +130 -0
  7. package/dist/database/schema/index.d.ts +3419 -0
  8. package/dist/database/schema/index.js +895 -0
  9. package/dist/database/schema/schema-pins.test.node.d.ts +52 -0
  10. package/dist/database/schema/schema-pins.test.node.js +398 -0
  11. package/dist/index.d.ts +79 -0
  12. package/dist/index.js +151 -0
  13. package/dist/lib/boot-check.d.ts +22 -0
  14. package/dist/lib/boot-check.js +42 -0
  15. package/dist/lib/boot-check.test.node.d.ts +8 -0
  16. package/dist/lib/boot-check.test.node.js +46 -0
  17. package/dist/lib/db-manager.d.ts +54 -0
  18. package/dist/lib/db-manager.js +49 -0
  19. package/dist/lib/test-db.d.ts +37 -0
  20. package/dist/lib/test-db.js +111 -0
  21. package/dist/lib/test-helper.d.ts +33 -0
  22. package/dist/lib/test-helper.js +68 -0
  23. package/dist/modules/admin/admin-permissions-repository.d.ts +35 -0
  24. package/dist/modules/admin/admin-permissions-repository.js +101 -0
  25. package/dist/modules/admin/admin-preferences-repository.d.ts +62 -0
  26. package/dist/modules/admin/admin-preferences-repository.js +156 -0
  27. package/dist/modules/admin/admin-roles-repository.d.ts +11 -0
  28. package/dist/modules/admin/admin-roles-repository.js +209 -0
  29. package/dist/modules/admin/admin-store.d.ts +20 -0
  30. package/dist/modules/admin/admin-store.js +30 -0
  31. package/dist/modules/admin/admin-users-repository.d.ts +11 -0
  32. package/dist/modules/admin/admin-users-repository.js +303 -0
  33. package/dist/modules/admin/index.d.ts +27 -0
  34. package/dist/modules/admin/index.js +27 -0
  35. package/dist/modules/admin/refresh-tokens-repository.d.ts +34 -0
  36. package/dist/modules/admin/refresh-tokens-repository.js +160 -0
  37. package/dist/modules/audit/audit-commands.d.ts +29 -0
  38. package/dist/modules/audit/audit-commands.js +40 -0
  39. package/dist/modules/audit/audit-queries.d.ts +41 -0
  40. package/dist/modules/audit/audit-queries.js +169 -0
  41. package/dist/modules/counters/counters-commands.d.ts +54 -0
  42. package/dist/modules/counters/counters-commands.js +121 -0
  43. package/dist/modules/counters/tests/counters-concurrency.test.d.ts +8 -0
  44. package/dist/modules/counters/tests/counters-concurrency.test.js +111 -0
  45. package/dist/modules/storage/classify-error.d.ts +34 -0
  46. package/dist/modules/storage/classify-error.js +50 -0
  47. package/dist/modules/storage/classify-error.test.node.d.ts +8 -0
  48. package/dist/modules/storage/classify-error.test.node.js +89 -0
  49. package/dist/modules/storage/normalize-row.d.ts +55 -0
  50. package/dist/modules/storage/normalize-row.js +135 -0
  51. package/dist/modules/storage/normalize-row.test.node.d.ts +8 -0
  52. package/dist/modules/storage/normalize-row.test.node.js +89 -0
  53. package/dist/modules/storage/storage-commands.d.ts +443 -0
  54. package/dist/modules/storage/storage-commands.js +1263 -0
  55. package/dist/modules/storage/storage-insert.d.ts +21 -0
  56. package/dist/modules/storage/storage-insert.js +152 -0
  57. package/dist/modules/storage/storage-queries.d.ts +805 -0
  58. package/dist/modules/storage/storage-queries.js +1815 -0
  59. package/dist/modules/storage/storage-store-manifest.d.ts +77 -0
  60. package/dist/modules/storage/storage-store-manifest.js +168 -0
  61. package/dist/modules/storage/storage-utils.d.ts +49 -0
  62. package/dist/modules/storage/storage-utils.js +76 -0
  63. package/dist/modules/storage/tests/dialect-pins.integration.test.d.ts +8 -0
  64. package/dist/modules/storage/tests/dialect-pins.integration.test.js +266 -0
  65. package/dist/modules/storage/tests/storage-commands.test.d.ts +8 -0
  66. package/dist/modules/storage/tests/storage-commands.test.js +324 -0
  67. package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
  68. package/dist/modules/storage/tests/storage-document-paths.test.js +214 -0
  69. package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
  70. package/dist/modules/storage/tests/storage-document-tree.test.js +361 -0
  71. package/dist/modules/storage/tests/storage-queries.test.d.ts +8 -0
  72. package/dist/modules/storage/tests/storage-queries.test.js +685 -0
  73. package/dist/modules/storage/tests/storage-status-and-lifecycle.test.d.ts +8 -0
  74. package/dist/modules/storage/tests/storage-status-and-lifecycle.test.js +268 -0
  75. 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
+ });