@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,1815 @@
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
+ * The MySQL storage read path. Ported from
9
+ * `packages/db-postgres/src/modules/storage/storage-queries.ts`.
10
+ *
11
+ * Task 10A landed the UNION ALL reconstruction path (`getDocumentById`,
12
+ * `getDocumentByVersion`, `getDocumentHistory`) and a `findDocuments` scoped
13
+ * to what the `versioning` + `field-types` conformance suites needed.
14
+ *
15
+ * Task 10B (this port) completes the surface: the `DocumentFilter[]`
16
+ * predicate compiler (`$and`/`$or` combinators, relation hops,
17
+ * document-column filters), `findDocuments`' `pathFilter`/`query` (LIKE
18
+ * search)/`sort` (`LEFT JOIN LATERAL` field sort), `getDocumentByPath`,
19
+ * `getDocumentsByVersionIds`, `getDocumentsByDocumentIds`,
20
+ * `getPublishedVersion`, `getPublishedDocumentIds`,
21
+ * `getDocumentCountsByStatus`, order-key / tree reads, `getCurrentPath`, and
22
+ * `getDocumentSystemFieldsForUpdate`. `DocumentQueries` now declares
23
+ * `implements IDocumentQueries`.
24
+ */
25
+ import { ERR_DATABASE, ERR_NOT_FOUND, extractFlattenedFieldValue, getLogger, orderByContentLocale, resolveIdentityField, resolveStoreTypes, restoreFieldSetData, } from '@byline/core';
26
+ import { and, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
27
+ import { collections, currentDocumentsView, currentPublishedDocumentsView, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersionLocales, documentVersions, metaStore, } from '../../database/schema/index.js';
28
+ import { normalizeRow } from './normalize-row.js';
29
+ import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
30
+ import { toDate } from './storage-utils.js';
31
+ /** True when `a` contains every member of `b`. */
32
+ function isSuperset(a, b) {
33
+ for (const item of b) {
34
+ if (!a.has(item))
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ /**
40
+ * CollectionQueries
41
+ *
42
+ * Identical to pg's — a plain query-builder read against `byline_collections`,
43
+ * with no dialect-specific SQL anywhere.
44
+ */
45
+ export class CollectionQueries {
46
+ db;
47
+ constructor(db) {
48
+ this.db = db;
49
+ }
50
+ async getAllCollections() {
51
+ return await this.db.select().from(collections);
52
+ }
53
+ async getCollectionByPath(path) {
54
+ return this.db.query.collections.findFirst({ where: eq(collections.path, path) });
55
+ }
56
+ async getCollectionById(id) {
57
+ return this.db.query.collections.findFirst({ where: eq(collections.id, id) });
58
+ }
59
+ }
60
+ /**
61
+ * DocumentQueries
62
+ */
63
+ export class DocumentQueries {
64
+ db;
65
+ transactionDb;
66
+ collections;
67
+ defaultContentLocale;
68
+ collectionPathCache = new Map();
69
+ constructor(db, collections, defaultContentLocale, transactionDb) {
70
+ this.db = db;
71
+ this.transactionDb = transactionDb;
72
+ this.collections = collections;
73
+ this.defaultContentLocale = defaultContentLocale;
74
+ }
75
+ /**
76
+ * Lock the logical document row before reading its document-grain system
77
+ * fields. Every audited system-field writer takes this same parent-row lock,
78
+ * so path and advertised-locale snapshots serialize without locking a
79
+ * variable set of child rows.
80
+ *
81
+ * Runs on `this.transactionDb.get()` — the ambient `withTransaction`
82
+ * executor when one is open, else the pool — so `FOR UPDATE` (unchanged
83
+ * syntax from pg) actually takes its lock inside the caller's transaction.
84
+ * A lock taken on a bare pool connection would release at the end of its
85
+ * own implicit transaction and serialise nothing; see the §H ruling in the
86
+ * Task 10B report for why `transactionDb` is a required constructor
87
+ * parameter rather than a silently-defaulted one.
88
+ */
89
+ async getDocumentSystemFieldsForUpdate({ collection_id, document_id, }) {
90
+ const executor = this.transactionDb.get();
91
+ const [document] = await executor
92
+ .select({ source_locale: documents.source_locale })
93
+ .from(documents)
94
+ .where(and(eq(documents.collection_id, collection_id), eq(documents.id, document_id)))
95
+ .for('update');
96
+ if (document == null)
97
+ return null;
98
+ const [pathRow] = await executor
99
+ .select({ path: documentPaths.path })
100
+ .from(documentPaths)
101
+ .where(and(eq(documentPaths.collection_id, collection_id), eq(documentPaths.document_id, document_id), eq(documentPaths.locale, document.source_locale)))
102
+ .limit(1);
103
+ const localeRows = await executor
104
+ .select({ locale: documentAvailableLocales.locale })
105
+ .from(documentAvailableLocales)
106
+ .where(and(eq(documentAvailableLocales.collection_id, collection_id), eq(documentAvailableLocales.document_id, document_id)));
107
+ return {
108
+ source_locale: document.source_locale,
109
+ path: pathRow?.path ?? null,
110
+ availableLocales: localeRows.map((row) => row.locale).sort(),
111
+ };
112
+ }
113
+ /**
114
+ * Resolve a collection UUID to its CollectionDefinition by looking up the
115
+ * collection's path in the DB and matching it against the injected array.
116
+ */
117
+ async getDefinitionForCollection(collectionId) {
118
+ let path = this.collectionPathCache.get(collectionId);
119
+ if (!path) {
120
+ const row = await this.db.query.collections.findFirst({
121
+ where: eq(collections.id, collectionId),
122
+ });
123
+ if (!row) {
124
+ throw ERR_NOT_FOUND({
125
+ message: `collection not found in database: ${collectionId}`,
126
+ details: { collectionId },
127
+ }).log(getLogger());
128
+ }
129
+ path = row.path;
130
+ this.collectionPathCache.set(collectionId, path);
131
+ }
132
+ const definition = this.collections.find((c) => c.path === path);
133
+ if (!definition) {
134
+ throw ERR_NOT_FOUND({
135
+ message: `no CollectionDefinition found for path: ${path}`,
136
+ details: { collectionPath: path },
137
+ }).log(getLogger());
138
+ }
139
+ return definition;
140
+ }
141
+ /**
142
+ * Pick the Drizzle view reference to read from based on `readMode`.
143
+ *
144
+ * - `'any'` (default) → `current_documents` — the latest version of
145
+ * each logical document, regardless of status.
146
+ * - `'published'` → `current_published_documents` — the latest
147
+ * version whose status is `'published'`, falling back past newer
148
+ * drafts so public readers keep seeing previously-published
149
+ * content while editors work on an unpublished draft.
150
+ *
151
+ * Both views share the same row shape, so the returned reference is
152
+ * drop-in substitutable at every select/where site.
153
+ */
154
+ pickCurrentView(readMode) {
155
+ return readMode === 'published' ? currentPublishedDocumentsView : currentDocumentsView;
156
+ }
157
+ /**
158
+ * Build the locale priority chain for fallback resolution:
159
+ * `[requested, floor]`, deduplicated when both are the same. The floor is
160
+ * the document's own `source_locale` anchor when known (so a re-anchored
161
+ * document, or any document read after the global default is switched, falls
162
+ * back to the locale it was actually authored in) — otherwise the configured
163
+ * global default, which is correct for not-yet-anchored rows and for
164
+ * row-less lookups (findByPath). See docs/07-internationalization/index.md.
165
+ */
166
+ buildLocaleChain(requestedLocale, sourceLocale) {
167
+ const floor = sourceLocale ?? this.defaultContentLocale;
168
+ const requested = requestedLocale ?? floor;
169
+ return requested === floor ? [requested] : [requested, floor];
170
+ }
171
+ /**
172
+ * Build the `onMissingLocale: 'omit'` availability gate — an EXISTS against
173
+ * the version-locale ledger (`byline_document_version_locales`) that keeps
174
+ * only documents available in the requested locale. The `'all'` sentinel row
175
+ * covers locale-agnostic documents (no localized content). Returns `null`
176
+ * when the gate does not apply — a non-`'omit'` policy (`'empty'` /
177
+ * `'fallback'` / unset), or the admin sentinel `'all'` read — so callers can
178
+ * conditionally push it into a WHERE. No dialect-specific SQL — ports
179
+ * unchanged from pg.
180
+ */
181
+ localeAvailabilityExists(versionId, locale, onMissingLocale) {
182
+ if (onMissingLocale !== 'omit' || locale === 'all')
183
+ return null;
184
+ return sql `EXISTS (
185
+ SELECT 1 FROM byline_document_version_locales dvl
186
+ WHERE dvl.document_version_id = ${versionId}
187
+ AND (dvl.locale = ${locale} OR dvl.locale = 'all')
188
+ )`;
189
+ }
190
+ /**
191
+ * Batch-fetch the version-locale availability sets from the
192
+ * `byline_document_version_locales` ledger. For each version returns the
193
+ * concrete locales its content is complete in (`availableLocales`, in
194
+ * configured content-locale order), or `localeAgnostic: true` when the
195
+ * version carries only the `'all'` sentinel. Drives the
196
+ * `_availableVersionLocales` read metadata. One indexed query per call.
197
+ * Plain query-builder code — ports unchanged from pg.
198
+ */
199
+ async getAvailableLocalesByVersion(versionIds) {
200
+ const result = new Map();
201
+ if (versionIds.length === 0)
202
+ return result;
203
+ const rows = await this.db
204
+ .select({
205
+ vid: documentVersionLocales.document_version_id,
206
+ locale: documentVersionLocales.locale,
207
+ })
208
+ .from(documentVersionLocales)
209
+ .where(inArray(documentVersionLocales.document_version_id, versionIds));
210
+ for (const row of rows) {
211
+ let entry = result.get(row.vid);
212
+ if (entry == null) {
213
+ entry = { availableLocales: [], localeAgnostic: false };
214
+ result.set(row.vid, entry);
215
+ }
216
+ if (row.locale === 'all')
217
+ entry.localeAgnostic = true;
218
+ else
219
+ entry.availableLocales.push(row.locale);
220
+ }
221
+ for (const entry of result.values()) {
222
+ entry.availableLocales = orderByContentLocale(entry.availableLocales);
223
+ }
224
+ return result;
225
+ }
226
+ /**
227
+ * Batch-fetch the editorial advertised-locale sets from
228
+ * `byline_document_available_locales` (document-grain). For each logical
229
+ * document returns the set of locales the editor has elected to advertise,
230
+ * in configured content-locale order. Surfaced on reads as
231
+ * `availableLocales`. One indexed query per call. Plain query-builder
232
+ * code — ports unchanged from pg.
233
+ */
234
+ async getAdvertisedLocalesByDocument(documentIds) {
235
+ const result = new Map();
236
+ if (documentIds.length === 0)
237
+ return result;
238
+ const rows = await this.db
239
+ .select({
240
+ did: documentAvailableLocales.document_id,
241
+ locale: documentAvailableLocales.locale,
242
+ })
243
+ .from(documentAvailableLocales)
244
+ .where(inArray(documentAvailableLocales.document_id, documentIds));
245
+ for (const row of rows) {
246
+ let arr = result.get(row.did);
247
+ if (arr == null) {
248
+ arr = [];
249
+ result.set(row.did, arr);
250
+ }
251
+ arr.push(row.locale);
252
+ }
253
+ for (const [did, arr] of result)
254
+ result.set(did, orderByContentLocale(arr));
255
+ return result;
256
+ }
257
+ /**
258
+ * Emit a SQL fragment that resolves the path string for a document via
259
+ * the locale priority chain. Used as a projected column expression
260
+ * inside `SELECT` lists.
261
+ *
262
+ * Postgres:
263
+ * ```sql
264
+ * (SELECT path FROM byline_document_paths
265
+ * WHERE document_id = <docIdSql> AND locale = ANY(<chain>)
266
+ * ORDER BY array_position(<chain>, locale) LIMIT 1)
267
+ * ```
268
+ * MySQL has no `ANY(ARRAY[...])` or `array_position` — the locale-chain
269
+ * conversion (design spec §2): `IN (…)` for the membership test, and
270
+ * `ORDER BY FIELD(locale, …)` for the priority order. `FIELD()` returns
271
+ * the 1-based position of the first argument within the remaining
272
+ * argument list (0 if absent); since the `WHERE … IN (chain)` clause
273
+ * already restricts every candidate row to a locale that's a member of
274
+ * the chain, `FIELD()` can never actually return 0 here — every row that
275
+ * reaches the `ORDER BY` has a genuine position, and ascending order
276
+ * therefore picks the earliest (highest-priority, i.e. most-requested)
277
+ * chain entry first, exactly matching `array_position`'s semantics.
278
+ * Verified live against MySQL 9.7.1 (see the Task 10A report) and pinned
279
+ * by `storage-queries.test.ts`'s locale-chain-ordering test.
280
+ */
281
+ pathProjection(documentIdCol, requestedLocale, sourceLocaleCol) {
282
+ const floorSql = sourceLocaleCol
283
+ ? sql `COALESCE(${sourceLocaleCol}, ${this.defaultContentLocale})`
284
+ : sql `${this.defaultContentLocale}`;
285
+ const requestedSql = requestedLocale != null ? sql `${requestedLocale}` : floorSql;
286
+ const chainSql = sql.join([requestedSql, floorSql], sql `, `);
287
+ return sql `(
288
+ SELECT ${documentPaths.path} FROM ${documentPaths}
289
+ WHERE ${documentPaths.document_id} = ${documentIdCol}
290
+ AND ${documentPaths.locale} IN (${chainSql})
291
+ ORDER BY FIELD(${documentPaths.locale}, ${chainSql})
292
+ LIMIT 1
293
+ )`;
294
+ }
295
+ /**
296
+ * Emit a SQL fragment that resolves a `(collection_id, path)` tuple to a
297
+ * `document_id` via the locale priority chain. Used inside `WHERE` clauses
298
+ * for findByPath-style lookups. Same `IN (…)` / `FIELD()` locale-chain
299
+ * conversion as `pathProjection` — see that method's docblock. Returns
300
+ * NULL when no row matches in any locale, which makes the outer `=`
301
+ * predicate fail cleanly (no document found).
302
+ */
303
+ resolveDocumentIdByPath(collection_id, path, requestedLocale) {
304
+ const chain = this.buildLocaleChain(requestedLocale);
305
+ const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
306
+ return sql `(
307
+ SELECT ${documentPaths.document_id} FROM ${documentPaths}
308
+ WHERE ${documentPaths.collection_id} = ${collection_id}
309
+ AND ${documentPaths.path} = ${path}
310
+ AND ${documentPaths.locale} IN (${chainSql})
311
+ ORDER BY FIELD(${documentPaths.locale}, ${chainSql})
312
+ LIMIT 1
313
+ )`;
314
+ }
315
+ /**
316
+ * Project list for `current_documents` / `current_published_documents`
317
+ * reads, with `path` resolved through the locale priority chain. Used
318
+ * everywhere a read previously did `.select()` (which auto-pulls every
319
+ * view column) — `path` is no longer projected by the views, so call
320
+ * sites must list the projection explicitly.
321
+ */
322
+ viewProjection(view, requestedLocale) {
323
+ return {
324
+ id: view.id,
325
+ document_id: view.document_id,
326
+ collection_id: view.collection_id,
327
+ collection_version: view.collection_version,
328
+ event_type: view.event_type,
329
+ status: view.status,
330
+ is_deleted: view.is_deleted,
331
+ created_at: view.created_at,
332
+ updated_at: view.updated_at,
333
+ created_by: view.created_by,
334
+ change_summary: view.change_summary,
335
+ source_locale: view.source_locale,
336
+ path: this.pathProjection(sql `${view.document_id}`, requestedLocale, sql `${view.source_locale}`),
337
+ };
338
+ }
339
+ /**
340
+ * Project list for direct `byline_document_versions` reads (history,
341
+ * version-by-id lookups). Mirrors `viewProjection` but against the
342
+ * underlying table — `path` is sourced from `byline_document_paths` via
343
+ * the locale priority chain, since it no longer lives on the version row.
344
+ */
345
+ documentVersionsProjection(requestedLocale) {
346
+ const sourceLocaleSql = sql `(SELECT source_locale FROM byline_documents WHERE id = ${documentVersions.document_id})`;
347
+ return {
348
+ id: documentVersions.id,
349
+ document_id: documentVersions.document_id,
350
+ collection_id: documentVersions.collection_id,
351
+ collection_version: documentVersions.collection_version,
352
+ event_type: documentVersions.event_type,
353
+ status: documentVersions.status,
354
+ is_deleted: documentVersions.is_deleted,
355
+ created_at: documentVersions.created_at,
356
+ updated_at: documentVersions.updated_at,
357
+ created_by: documentVersions.created_by,
358
+ change_summary: documentVersions.change_summary,
359
+ source_locale: sourceLocaleSql,
360
+ path: this.pathProjection(sql `${documentVersions.document_id}`, requestedLocale, sourceLocaleSql),
361
+ };
362
+ }
363
+ /**
364
+ * Resolve the single effective content locale a version should be restored
365
+ * in, walking the fallback chain (`[requested, default]`) and returning the
366
+ * first locale the version is *available* in. Pure JS/data-structure code
367
+ * — no SQL — ports unchanged from pg. See pg's docblock for the full
368
+ * phase-1 availability rule.
369
+ */
370
+ resolveEffectiveLocale(flattenedData, chain) {
371
+ // biome-ignore lint/style/noNonNullAssertion: chain is non-empty by construction
372
+ const defaultLocale = chain[chain.length - 1];
373
+ const pathsByLocale = new Map();
374
+ for (const row of flattenedData) {
375
+ if (row.locale === 'all' || row.field_type === 'meta')
376
+ continue;
377
+ let set = pathsByLocale.get(row.locale);
378
+ if (set == null) {
379
+ set = new Set();
380
+ pathsByLocale.set(row.locale, set);
381
+ }
382
+ set.add(row.field_path.join('.'));
383
+ }
384
+ const canonical = pathsByLocale.get(defaultLocale) ?? new Set();
385
+ for (const candidate of chain) {
386
+ if (candidate === defaultLocale)
387
+ break;
388
+ if (canonical.size === 0)
389
+ return candidate;
390
+ const covered = pathsByLocale.get(candidate);
391
+ if (covered != null && isSuperset(covered, canonical))
392
+ return candidate;
393
+ }
394
+ return defaultLocale;
395
+ }
396
+ /**
397
+ * Reconstruct document fields from unified row values using schema-aware
398
+ * restoration. Meta rows (from store_meta) are converted to
399
+ * FlattenedFieldValue entries so that restoreFieldSetData can inject
400
+ * _id and _type for blocks and array items inline. Pure JS/core-delegate
401
+ * code — ports unchanged from pg.
402
+ */
403
+ reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient = false, onMissingLocale, sourceLocale) {
404
+ const flattenedData = unifiedFieldValues.map((row) => extractFlattenedFieldValue(row));
405
+ if (metaRows) {
406
+ for (const meta of metaRows) {
407
+ flattenedData.push({
408
+ locale: 'all',
409
+ field_path: meta.path.split('.'),
410
+ field_type: 'meta',
411
+ type: meta.type,
412
+ item_id: meta.item_id,
413
+ });
414
+ }
415
+ }
416
+ const resolveLocale = locale === 'all'
417
+ ? undefined
418
+ : onMissingLocale === 'fallback'
419
+ ? this.resolveEffectiveLocale(flattenedData, this.buildLocaleChain(locale, sourceLocale))
420
+ : locale;
421
+ const { data, warnings } = restoreFieldSetData(definition.fields, flattenedData, resolveLocale);
422
+ if (!lenient && warnings.length > 0) {
423
+ throw ERR_DATABASE({
424
+ message: `document reconstruction failed with ${warnings.length} warnings`,
425
+ details: { warnings },
426
+ }).log(getLogger());
427
+ }
428
+ return { fields: data, warnings };
429
+ }
430
+ /**
431
+ * getCurrentVersionMetadata — narrow metadata fetch for the current version.
432
+ *
433
+ * Hits `current_documents` only; no field reconstruction, no meta fetch,
434
+ * no path subquery. Used by lifecycle operations (status changes,
435
+ * restore, delete checks) that only need `document_version_id` /
436
+ * `status` / timestamps before mutating.
437
+ */
438
+ async getCurrentVersionMetadata({ collection_id, document_id, }) {
439
+ const [row] = await this.db
440
+ .select({
441
+ document_version_id: currentDocumentsView.id,
442
+ document_id: currentDocumentsView.document_id,
443
+ collection_id: currentDocumentsView.collection_id,
444
+ status: currentDocumentsView.status,
445
+ created_at: currentDocumentsView.created_at,
446
+ updated_at: currentDocumentsView.updated_at,
447
+ })
448
+ .from(currentDocumentsView)
449
+ .where(and(eq(currentDocumentsView.collection_id, collection_id), eq(currentDocumentsView.document_id, document_id)))
450
+ .limit(1);
451
+ if (!row)
452
+ return null;
453
+ return {
454
+ document_version_id: row.document_version_id,
455
+ document_id: row.document_id,
456
+ collection_id: row.collection_id ?? '',
457
+ status: row.status ?? 'draft',
458
+ created_at: row.created_at ?? new Date(),
459
+ updated_at: row.updated_at ?? new Date(),
460
+ };
461
+ }
462
+ /**
463
+ * getCurrentPath — resolve a document's canonical (source-locale) path.
464
+ *
465
+ * Reuses `pathProjection` against `current_documents`, passing
466
+ * `requestedLocale: undefined` so the projection's fallback floor — the
467
+ * document's own `source_locale` (COALESCE-guarded to the default content
468
+ * locale for not-yet-anchored rows) — supplies the canonical path. Used by
469
+ * the lifecycle to populate `path` on the status-change / unpublish hook
470
+ * contexts. Returns `null` when no path row (or document) exists.
471
+ */
472
+ async getCurrentPath({ collection_id, document_id, }) {
473
+ const [row] = await this.db
474
+ .select({
475
+ path: this.pathProjection(sql `${currentDocumentsView.document_id}`, undefined, sql `${currentDocumentsView.source_locale}`),
476
+ })
477
+ .from(currentDocumentsView)
478
+ .where(and(eq(currentDocumentsView.collection_id, collection_id), eq(currentDocumentsView.document_id, document_id)))
479
+ .limit(1);
480
+ return row?.path ?? null;
481
+ }
482
+ /**
483
+ * getDocumentById — gets the current version of a document by its logical document ID.
484
+ *
485
+ * When `lenient` is true, schema-mismatch warnings emitted during
486
+ * reconstruction are surfaced on the returned object as `restoreWarnings`
487
+ * rather than thrown. This is the admin edit path's "best-effort load"
488
+ * mode for documents written under a previous collection schema.
489
+ */
490
+ async getDocumentById({ collection_id, document_id, locale = 'en', reconstruct = true, readMode, filters, lenient = false, onMissingLocale, }) {
491
+ const view = this.pickCurrentView(readMode);
492
+ const baseConditions = [
493
+ eq(view.collection_id, collection_id),
494
+ eq(view.document_id, document_id),
495
+ ];
496
+ if (filters?.length) {
497
+ const outerScope = {
498
+ docVersionId: sql `${view.id}`,
499
+ documentId: sql `${view.document_id}`,
500
+ status: sql `${view.status}`,
501
+ path: this.pathProjection(sql `${view.document_id}`, locale, sql `${view.source_locale}`),
502
+ };
503
+ for (const f of filters) {
504
+ baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
505
+ }
506
+ }
507
+ const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale);
508
+ if (strictGate) {
509
+ baseConditions.push(strictGate);
510
+ }
511
+ const [document] = await this.db
512
+ .select(this.viewProjection(view, locale))
513
+ .from(view)
514
+ .where(and(...baseConditions));
515
+ if (document == null) {
516
+ return null;
517
+ }
518
+ const unifiedFieldValues = await this.getAllFieldValues(document.id, locale, document.source_locale);
519
+ if (reconstruct === true) {
520
+ const definition = await this.getDefinitionForCollection(collection_id);
521
+ const metaRows = await this.db
522
+ .select({
523
+ type: metaStore.type,
524
+ path: metaStore.path,
525
+ item_id: metaStore.item_id,
526
+ meta: metaStore.meta,
527
+ })
528
+ .from(metaStore)
529
+ .where(eq(metaStore.document_version_id, document.id));
530
+ const { fields, warnings } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, lenient, onMissingLocale, document.source_locale);
531
+ const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
532
+ const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
533
+ return {
534
+ document_version_id: document.id,
535
+ document_id: document.document_id,
536
+ path: document.path ?? '',
537
+ source_locale: document.source_locale ?? null,
538
+ status: document.status,
539
+ event_type: document.event_type,
540
+ created_at: document.created_at,
541
+ updated_at: document.updated_at,
542
+ created_by: document.created_by ?? null,
543
+ fields,
544
+ availableLocales: advertised ?? [],
545
+ _availableVersionLocales: availability?.availableLocales ?? [],
546
+ _localeAgnostic: availability?.localeAgnostic ?? false,
547
+ ...(lenient && warnings.length > 0 ? { restoreWarnings: warnings } : {}),
548
+ };
549
+ }
550
+ const fieldValues = this.convertUnionRowToFlattenedStores(unifiedFieldValues);
551
+ return {
552
+ document_version_id: document.id,
553
+ document_id: document.document_id,
554
+ path: document.path ?? '',
555
+ source_locale: document.source_locale ?? null,
556
+ status: document.status,
557
+ event_type: document.event_type,
558
+ created_at: document.created_at,
559
+ updated_at: document.updated_at,
560
+ created_by: document.created_by ?? null,
561
+ fields: fieldValues,
562
+ };
563
+ }
564
+ /**
565
+ * getDocumentByPath — resolves `(collection_id, path)` through the locale
566
+ * priority chain to a document_id, then reads and reconstructs its
567
+ * current version. See `resolveDocumentIdByPath` for the locale-chain
568
+ * SQL.
569
+ */
570
+ async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, onMissingLocale, }) {
571
+ const view = this.pickCurrentView(readMode);
572
+ const baseConditions = [
573
+ eq(view.collection_id, collection_id),
574
+ sql `${view.document_id} = ${this.resolveDocumentIdByPath(collection_id, path, locale)}`,
575
+ ];
576
+ if (filters?.length) {
577
+ const outerScope = {
578
+ docVersionId: sql `${view.id}`,
579
+ documentId: sql `${view.document_id}`,
580
+ status: sql `${view.status}`,
581
+ path: this.pathProjection(sql `${view.document_id}`, locale, sql `${view.source_locale}`),
582
+ };
583
+ for (const f of filters) {
584
+ baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
585
+ }
586
+ }
587
+ const strictGate = this.localeAvailabilityExists(sql `${view.id}`, locale, onMissingLocale);
588
+ if (strictGate) {
589
+ baseConditions.push(strictGate);
590
+ }
591
+ const [document] = await this.db
592
+ .select(this.viewProjection(view, locale))
593
+ .from(view)
594
+ .where(and(...baseConditions));
595
+ if (document == null) {
596
+ return null;
597
+ }
598
+ const unifiedFieldValues = await this.getAllFieldValues(document.id, locale, document.source_locale);
599
+ if (reconstruct === true) {
600
+ const definition = await this.getDefinitionForCollection(collection_id);
601
+ const metaRows = await this.db
602
+ .select({
603
+ type: metaStore.type,
604
+ path: metaStore.path,
605
+ item_id: metaStore.item_id,
606
+ meta: metaStore.meta,
607
+ })
608
+ .from(metaStore)
609
+ .where(eq(metaStore.document_version_id, document.id));
610
+ const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, onMissingLocale, document.source_locale);
611
+ const availability = (await this.getAvailableLocalesByVersion([document.id])).get(document.id);
612
+ const advertised = (await this.getAdvertisedLocalesByDocument([document.document_id])).get(document.document_id);
613
+ return {
614
+ document_version_id: document.id,
615
+ document_id: document.document_id,
616
+ path: document.path ?? '',
617
+ source_locale: document.source_locale ?? null,
618
+ status: document.status,
619
+ event_type: document.event_type,
620
+ created_at: document.created_at,
621
+ updated_at: document.updated_at,
622
+ created_by: document.created_by ?? null,
623
+ fields,
624
+ availableLocales: advertised ?? [],
625
+ _availableVersionLocales: availability?.availableLocales ?? [],
626
+ _localeAgnostic: availability?.localeAgnostic ?? false,
627
+ };
628
+ }
629
+ const fieldValues = this.convertUnionRowToFlattenedStores(unifiedFieldValues);
630
+ return {
631
+ document_version_id: document.id,
632
+ document_id: document.document_id,
633
+ path: document.path ?? '',
634
+ source_locale: document.source_locale ?? null,
635
+ status: document.status,
636
+ event_type: document.event_type,
637
+ created_at: document.created_at,
638
+ updated_at: document.updated_at,
639
+ created_by: document.created_by ?? null,
640
+ fields: fieldValues,
641
+ };
642
+ }
643
+ /**
644
+ * getDocumentByVersion — fetches a specific version and reconstructs its fields.
645
+ */
646
+ async getDocumentByVersion({ document_version_id, locale = 'all', collection_id, filters, }) {
647
+ const projectionLocale = locale === 'all' ? undefined : locale;
648
+ const filterLocale = projectionLocale ?? this.defaultContentLocale;
649
+ const conditions = [eq(documentVersions.id, document_version_id)];
650
+ if (collection_id)
651
+ conditions.push(eq(documentVersions.collection_id, collection_id));
652
+ if (filters?.length) {
653
+ const scope = {
654
+ docVersionId: sql `${documentVersions.id}`,
655
+ documentId: sql `${documentVersions.document_id}`,
656
+ status: sql `${documentVersions.status}`,
657
+ path: this.pathProjection(sql `${documentVersions.document_id}`, filterLocale),
658
+ };
659
+ for (const filter of filters) {
660
+ conditions.push(this.buildFilterExists(filter, filterLocale, scope, 'any', 0));
661
+ }
662
+ }
663
+ const [document] = await this.db
664
+ .select(this.documentVersionsProjection(projectionLocale))
665
+ .from(documentVersions)
666
+ .where(and(...conditions));
667
+ if (document == null)
668
+ return null;
669
+ const unifiedFieldValues = await this.getAllFieldValues(document.id, locale, document.source_locale);
670
+ const definition = await this.getDefinitionForCollection(document.collection_id);
671
+ const metaRows = await this.db
672
+ .select({
673
+ type: metaStore.type,
674
+ path: metaStore.path,
675
+ item_id: metaStore.item_id,
676
+ meta: metaStore.meta,
677
+ })
678
+ .from(metaStore)
679
+ .where(eq(metaStore.document_version_id, document.id));
680
+ const { fields } = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows, false, undefined, document.source_locale);
681
+ return {
682
+ document_version_id: document.id,
683
+ document_id: document.document_id,
684
+ path: document.path ?? '',
685
+ source_locale: document.source_locale ?? null,
686
+ status: document.status,
687
+ created_at: document.created_at,
688
+ updated_at: document.updated_at,
689
+ fields,
690
+ };
691
+ }
692
+ /**
693
+ * getDocumentsByVersionIds — fetches and reconstructs multiple documents by
694
+ * version ID. Used for batch loading a known set of versions (e.g.
695
+ * migration scripts, tests).
696
+ */
697
+ async getDocumentsByVersionIds({ document_version_ids, locale = 'all', }) {
698
+ if (document_version_ids.length === 0)
699
+ return [];
700
+ const docs = await this.db
701
+ .select(this.documentVersionsProjection(locale === 'all' ? undefined : locale))
702
+ .from(documentVersions)
703
+ .where(inArray(documentVersions.id, document_version_ids));
704
+ return this.reconstructDocuments({ documents: docs, locale });
705
+ }
706
+ /**
707
+ * getDocumentsByDocumentIds — batch-fetch current versions for a list of
708
+ * logical document IDs, with optional selective field loading.
709
+ *
710
+ * Resolves each document_id to its current version via the
711
+ * `current_documents` view (soft-deleted documents are excluded by the
712
+ * view definition), then delegates to `reconstructDocuments` for the
713
+ * shared field + meta reconstruction path.
714
+ *
715
+ * Primary consumer is the client API's relationship populate pass —
716
+ * `store_relation` rows carry `target_document_id` (not version ID), so
717
+ * populate collects those IDs and resolves them here in one round trip.
718
+ */
719
+ async getDocumentsByDocumentIds({ collection_id, document_ids, locale = 'all', fields, readMode, filters, }) {
720
+ if (document_ids.length === 0)
721
+ return [];
722
+ const view = this.pickCurrentView(readMode);
723
+ // The locale used to compile filter EXISTS subqueries should resolve
724
+ // values from a real locale, even when the surrounding read uses the
725
+ // sentinel `'all'` (populate batches that span every locale do this).
726
+ // Falling back to the installation default here matches the default
727
+ // used by the single-doc lookup methods.
728
+ const filterLocale = locale === 'all' ? this.defaultContentLocale : locale;
729
+ const baseConditions = [
730
+ eq(view.collection_id, collection_id),
731
+ inArray(view.document_id, document_ids),
732
+ ];
733
+ if (filters?.length) {
734
+ const outerScope = {
735
+ docVersionId: sql `${view.id}`,
736
+ documentId: sql `${view.document_id}`,
737
+ status: sql `${view.status}`,
738
+ path: this.pathProjection(sql `${view.document_id}`, filterLocale, sql `${view.source_locale}`),
739
+ };
740
+ for (const f of filters) {
741
+ baseConditions.push(this.buildFilterExists(f, filterLocale, outerScope, readMode, 0));
742
+ }
743
+ }
744
+ const docs = await this.db
745
+ .select(this.viewProjection(view, filterLocale))
746
+ .from(view)
747
+ .where(and(...baseConditions));
748
+ // Populated relation targets always fall back through the locale chain so
749
+ // a populated tree never has holes — independent of the outer read's
750
+ // `onMissingLocale`. (A no-op when `locale === 'all'`, which keeps the map.)
751
+ return this.reconstructDocuments({
752
+ documents: docs,
753
+ locale,
754
+ fields,
755
+ onMissingLocale: 'fallback',
756
+ });
757
+ }
758
+ /**
759
+ * getDocumentHistory — paginated version history for a document,
760
+ * including soft-deleted versions.
761
+ */
762
+ async getDocumentHistory({ collection_id, document_id, locale = 'all', page = 1, page_size = 20, order = 'updated_at', desc: descending = true, filters, }) {
763
+ const collection = await this.db.query.collections.findFirst({
764
+ where: eq(collections.id, collection_id),
765
+ });
766
+ if (collection == null || collection.config == null) {
767
+ throw ERR_NOT_FOUND({
768
+ message: `collection not found or missing config: ${collection_id}`,
769
+ details: { collectionId: collection_id },
770
+ }).log(getLogger());
771
+ }
772
+ const filterLocale = locale === 'all' ? this.defaultContentLocale : locale;
773
+ const conditions = [
774
+ eq(documentVersions.collection_id, collection_id),
775
+ eq(documentVersions.document_id, document_id),
776
+ ];
777
+ if (filters?.length) {
778
+ const scope = {
779
+ docVersionId: sql `${documentVersions.id}`,
780
+ documentId: sql `${documentVersions.document_id}`,
781
+ status: sql `${documentVersions.status}`,
782
+ path: this.pathProjection(sql `${documentVersions.document_id}`, filterLocale),
783
+ };
784
+ for (const filter of filters) {
785
+ conditions.push(this.buildFilterExists(filter, filterLocale, scope, 'any', 0));
786
+ }
787
+ }
788
+ // `count(*)::int` (pg) → `CAST(COUNT(*) AS SIGNED)` (design spec §2),
789
+ // with the `Number()` normalisation at the result edge kept as a belt.
790
+ const totalResult = await this.db
791
+ .select({ count: sql `CAST(COUNT(*) AS SIGNED)` })
792
+ .from(documentVersions)
793
+ .where(and(...conditions));
794
+ const total = Number(totalResult[0]?.count) || 0;
795
+ const total_pages = Math.ceil(total / page_size);
796
+ const offset = (page - 1) * page_size;
797
+ // History is per-document; path is sticky so every version row has the
798
+ // same value. `order === 'path'` is degenerate and was removed when
799
+ // path moved to byline_document_paths — fall back to created_at.
800
+ const orderColumn = documentVersions.created_at;
801
+ const orderFunc = descending === true ? sql `DESC` : sql `ASC`;
802
+ const projectionLocale = locale === 'all' ? undefined : locale;
803
+ const result = await this.db
804
+ .select(this.documentVersionsProjection(projectionLocale))
805
+ .from(documentVersions)
806
+ .where(and(...conditions))
807
+ .orderBy(sql `${orderColumn} ${orderFunc}`)
808
+ .limit(page_size)
809
+ .offset(offset);
810
+ const history = await this.reconstructDocuments({ documents: result, locale });
811
+ return {
812
+ documents: history,
813
+ meta: { total, page, page_size, total_pages, order, desc: descending },
814
+ };
815
+ }
816
+ /**
817
+ * getPublishedVersion
818
+ *
819
+ * Find the latest version of a document that has a specific status
820
+ * (defaults to 'published'). Queries `document_versions` directly so it
821
+ * can find a published version even when a newer draft exists.
822
+ *
823
+ * Returns minimal version metadata (not reconstructed content), or null
824
+ * if no version with the requested status exists.
825
+ */
826
+ async getPublishedVersion({ collection_id, document_id, status = 'published', }) {
827
+ const [row] = await this.db
828
+ .select({
829
+ document_version_id: documentVersions.id,
830
+ document_id: documentVersions.document_id,
831
+ status: documentVersions.status,
832
+ created_at: documentVersions.created_at,
833
+ updated_at: documentVersions.updated_at,
834
+ })
835
+ .from(documentVersions)
836
+ .where(and(eq(documentVersions.collection_id, collection_id), eq(documentVersions.document_id, document_id), eq(documentVersions.status, status), eq(documentVersions.is_deleted, false)))
837
+ .orderBy(sql `${documentVersions.id} DESC`)
838
+ .limit(1);
839
+ if (!row)
840
+ return null;
841
+ return {
842
+ document_version_id: row.document_version_id,
843
+ document_id: row.document_id,
844
+ status: row.status ?? 'draft',
845
+ created_at: row.created_at ?? new Date(),
846
+ updated_at: row.updated_at ?? new Date(),
847
+ };
848
+ }
849
+ /**
850
+ * getPublishedDocumentIds
851
+ *
852
+ * Given a list of document IDs, return the subset that have at least one
853
+ * version with the requested status (defaults to 'published'). Uses a
854
+ * single batch query instead of per-document lookups.
855
+ */
856
+ async getPublishedDocumentIds({ collection_id, document_ids, status = 'published', }) {
857
+ if (document_ids.length === 0)
858
+ return new Set();
859
+ const rows = await this.db
860
+ .select({ document_id: documentVersions.document_id })
861
+ .from(documentVersions)
862
+ .where(and(inArray(documentVersions.document_id, document_ids), eq(documentVersions.collection_id, collection_id), eq(documentVersions.status, status), eq(documentVersions.is_deleted, false)))
863
+ .groupBy(documentVersions.document_id);
864
+ return new Set(rows.map((r) => r.document_id));
865
+ }
866
+ /**
867
+ * getLastOrderKey
868
+ *
869
+ * Largest `order_key` currently in use for the given collection. Used
870
+ * at create-time on `orderable: true` collections to append the new
871
+ * row at the end. Returns `null` when no keyed rows exist yet.
872
+ */
873
+ async getLastOrderKey({ collection_id }) {
874
+ const rows = await this.db
875
+ .select({ order_key: documents.order_key })
876
+ .from(documents)
877
+ .where(and(eq(documents.collection_id, collection_id), isNotNull(documents.order_key)))
878
+ .orderBy(desc(documents.order_key))
879
+ .limit(1);
880
+ return rows[0]?.order_key ?? null;
881
+ }
882
+ /**
883
+ * getNeighborOrderKeys
884
+ *
885
+ * Resolve the `order_key` values bracketing a target gap in one query.
886
+ * `before_document_id` is the doc the moved row should land *after*;
887
+ * `after_document_id` is the doc it should land *before*. Either or
888
+ * both may be null (append / prepend / empty collection).
889
+ *
890
+ * Resolves both keys in a single round trip to keep the read consistent
891
+ * with the next-key computation that follows in the caller.
892
+ */
893
+ async getNeighborOrderKeys({ collection_id, before_document_id, after_document_id, }) {
894
+ const ids = [];
895
+ if (before_document_id)
896
+ ids.push(before_document_id);
897
+ if (after_document_id)
898
+ ids.push(after_document_id);
899
+ if (ids.length === 0) {
900
+ return { left: null, right: null };
901
+ }
902
+ const rows = await this.db
903
+ .select({ id: documents.id, order_key: documents.order_key })
904
+ .from(documents)
905
+ .where(and(eq(documents.collection_id, collection_id), inArray(documents.id, ids)));
906
+ const byId = new Map(rows.map((r) => [r.id, r.order_key]));
907
+ return {
908
+ left: before_document_id ? (byId.get(before_document_id) ?? null) : null,
909
+ right: after_document_id ? (byId.get(after_document_id) ?? null) : null,
910
+ };
911
+ }
912
+ /**
913
+ * getCanonicalDocumentOrder
914
+ *
915
+ * Returns every document in the collection in its canonical list-view
916
+ * order: `order_key ASC NULLS LAST, created_at DESC`. Used by the reorder
917
+ * server fn for backfill and recovery from key corruption.
918
+ *
919
+ * Divergence from pg: MySQL has no `NULLS LAST`. MySQL's own `ASC`
920
+ * default sorts NULL *first* (the opposite of pg's `NULLS LAST`), so the
921
+ * `(col IS NULL) ASC, col ASC` emulation idiom — the same one
922
+ * `buildDocumentOrderClause` uses — is required here too.
923
+ */
924
+ async getCanonicalDocumentOrder({ collection_id, }) {
925
+ const rows = await this.db
926
+ .select({ id: documents.id, order_key: documents.order_key })
927
+ .from(documents)
928
+ .where(eq(documents.collection_id, collection_id))
929
+ .orderBy(sql `(${documents.order_key} IS NULL) ASC, ${documents.order_key} ASC`, desc(documents.created_at));
930
+ return rows;
931
+ }
932
+ /**
933
+ * getTreeAncestors — see {@link IDocumentQueries.getTreeAncestors}.
934
+ *
935
+ * Recursive CTE walking `parent_document_id` upward from the given node.
936
+ * Returns ancestors root-first (`ORDER BY depth DESC`), each with a 1-based
937
+ * depth (1 = immediate parent). Empty for a root or unplaced node.
938
+ *
939
+ * Status-at-edge: in `published` mode each hop must resolve in
940
+ * `byline_current_published_documents`, so the walk stops at the first
941
+ * unpublished ancestor rather than skipping it (a truncated spine the splat
942
+ * handler turns into a 404). `any` mode walks the raw edges unchanged.
943
+ *
944
+ * `WITH RECURSIVE` syntax is unchanged from pg (the 8.0.14 engine floor
945
+ * guarantees support); the `::uuid` cast on `document_id` is dropped —
946
+ * MySQL has no cast syntax for a UUID-shaped `CHAR(36)` column, and the
947
+ * driver already binds the plain string correctly.
948
+ */
949
+ async getTreeAncestors({ document_id, maxDepth = 10_000, readMode = 'any', locale = this.defaultContentLocale, filters, }) {
950
+ const childGate = this.buildTreeVisibility(sql `child_document_id`, filters, locale, readMode, 'cv0');
951
+ const anchorParentGate = this.buildTreeVisibility(sql `parent_document_id`, filters, locale, readMode, 'pv0');
952
+ const recursiveParentGate = this.buildTreeVisibility(sql `r.parent_document_id`, filters, locale, readMode, 'pv1');
953
+ const query = sql `
954
+ WITH RECURSIVE ancestors AS (
955
+ SELECT parent_document_id AS ancestor_id, child_document_id AS node_id, 1 AS depth
956
+ FROM byline_document_relationships
957
+ WHERE child_document_id = ${document_id} AND parent_document_id IS NOT NULL
958
+ AND ${childGate}
959
+ AND ${anchorParentGate}
960
+ UNION ALL
961
+ SELECT r.parent_document_id, r.child_document_id, a.depth + 1
962
+ FROM byline_document_relationships r
963
+ JOIN ancestors a ON r.child_document_id = a.ancestor_id
964
+ WHERE r.parent_document_id IS NOT NULL AND a.depth < ${maxDepth}
965
+ AND ${recursiveParentGate}
966
+ )
967
+ SELECT ancestor_id AS document_id, depth FROM ancestors ORDER BY depth DESC
968
+ `;
969
+ const result = (await this.db.execute(query));
970
+ return result[0].map((r) => ({
971
+ document_id: r.document_id,
972
+ depth: Number(r.depth),
973
+ }));
974
+ }
975
+ /**
976
+ * getTreeChildren — see {@link IDocumentQueries.getTreeChildren}.
977
+ *
978
+ * Immediate children of a node ordered by the per-parent `order_key`.
979
+ * `parentDocumentId: null` returns the collection's root nodes; the join to
980
+ * `byline_documents` scopes roots to the collection (they have no parent to
981
+ * scope by). Plain query-builder code — ports unchanged from pg.
982
+ */
983
+ async getTreeChildren({ collectionId, parentDocumentId, }) {
984
+ const rows = await this.db
985
+ .select({
986
+ document_id: documentRelationships.child_document_id,
987
+ order_key: documentRelationships.order_key,
988
+ })
989
+ .from(documentRelationships)
990
+ .innerJoin(documents, eq(documents.id, documentRelationships.child_document_id))
991
+ .where(and(eq(documents.collection_id, collectionId), parentDocumentId == null
992
+ ? isNull(documentRelationships.parent_document_id)
993
+ : eq(documentRelationships.parent_document_id, parentDocumentId)))
994
+ .orderBy(documentRelationships.order_key);
995
+ return rows;
996
+ }
997
+ /**
998
+ * getTreeParent — see {@link IDocumentQueries.getTreeParent}.
999
+ *
1000
+ * Single indexed lookup on the edge table by `child_document_id` (unique).
1001
+ * No row → *unplaced*; a row with a null parent → *root*; a row with a parent
1002
+ * → *child*. Distinguishes the unplaced/root states that `getTreeAncestors`
1003
+ * (which returns `[]` for both) conflates. `::uuid` cast dropped, same as
1004
+ * `getTreeAncestors`.
1005
+ */
1006
+ async getTreeParent({ document_id, readMode = 'any', locale = this.defaultContentLocale, filters, }) {
1007
+ const childGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'cv0');
1008
+ const parentGate = this.buildTreeVisibility(sql `r.parent_document_id`, filters, locale, readMode, 'pv0');
1009
+ const query = sql `
1010
+ SELECT r.parent_document_id,
1011
+ CASE WHEN r.parent_document_id IS NULL THEN TRUE ELSE ${parentGate} END AS parent_visible
1012
+ FROM byline_document_relationships r
1013
+ WHERE r.child_document_id = ${document_id}
1014
+ AND ${childGate}
1015
+ LIMIT 1
1016
+ `;
1017
+ const result = (await this.db.execute(query));
1018
+ const row = result[0][0];
1019
+ if (row == null)
1020
+ return { placed: false, parentDocumentId: null };
1021
+ if (!row.parent_visible) {
1022
+ return { placed: true, parentDocumentId: null, parentRedacted: true };
1023
+ }
1024
+ return { placed: true, parentDocumentId: row.parent_document_id ?? null };
1025
+ }
1026
+ /**
1027
+ * getTreeSubtree — see {@link IDocumentQueries.getTreeSubtree}.
1028
+ *
1029
+ * Recursive CTE descending from the requested root (or the collection's
1030
+ * roots when `rootDocumentId` is null). Each row carries a `/`-joined path
1031
+ * of ancestor `order_key`s; ordering by that path yields a pre-order
1032
+ * depth-first walk (a parent's path is a prefix of its children's, and `/`
1033
+ * (0x2F) sorts below every key character in an ascii_bin comparison).
1034
+ * Status-at-edge: every node — anchor included — must exist in the chosen
1035
+ * current-documents view, so an unpublished node and its whole subtree
1036
+ * drop out in `published` mode.
1037
+ *
1038
+ * Three divergences from pg, all found (or, for the third, reproduced)
1039
+ * live — not assumed from docs:
1040
+ *
1041
+ * - `s.path || '/' || r.order_key` (pg's `||` string concat) is MySQL
1042
+ * boolean OR unless `PIPES_AS_CONCAT` is in `sql_mode` — confirmed
1043
+ * against this database's `@@sql_mode` (it isn't set), and confirmed
1044
+ * `SELECT 'a' || 'b'` returns `0`, not `'ab'`. `CONCAT(s.path, '/',
1045
+ * r.order_key)` is the portable form.
1046
+ * - `ORDER BY path COLLATE "C"` drops the explicit collation: `order_key`
1047
+ * (and therefore the CONCAT'd `path` column, once the width fix below
1048
+ * is in place) is already `ascii_bin` — byte-comparable — end to end
1049
+ * (`varcharByteSorted`, `database/schema/common.ts`), confirmed live
1050
+ * that `CONCAT()` over two `ascii_bin` operands plus a `'/'` literal
1051
+ * stays `ascii_bin` rather than falling back to the connection's
1052
+ * `utf8mb4_0900_ai_ci` default (which would reorder mixed-case keys
1053
+ * incorrectly, the same class of bug `varcharByteSorted` exists to
1054
+ * prevent on the base columns).
1055
+ * - pg's `r.order_key::text AS path` cast does **two** jobs, not one —
1056
+ * an earlier version of this docblock dropped the cast on collation
1057
+ * grounds alone and missed the second job. In Postgres, `::text`
1058
+ * also makes the anchor column **unbounded** (`text` has no length
1059
+ * cap), which an accumulating concatenation needs. MySQL infers a
1060
+ * recursive CTE's column types from the *non-recursive* (anchor) leg
1061
+ * only — a bare `r.order_key` reference in the anchor makes `path`
1062
+ * inherit `order_key`'s declared `varchar(128)` width
1063
+ * (`varcharByteSorted`, `database/schema/index.ts:299`), so every
1064
+ * recursive iteration's `CONCAT` is silently constrained to 128
1065
+ * bytes regardless of how the SELECT list is written. Reproduced
1066
+ * live against this server (`STRICT_TRANS_TABLES` is in `sql_mode`,
1067
+ * so it's a hard `ER_DATA_TOO_LONG`, not silent truncation):
1068
+ * `WITH RECURSIVE t AS (SELECT CAST('ab' AS CHAR(128) CHARACTER SET
1069
+ * ascii) AS p, 1 AS n UNION ALL SELECT CONCAT(p,'/','abc…xyz'), n+1
1070
+ * FROM t WHERE n<6) …` → `ERROR 1406 (22001): Data too long for
1071
+ * column 'p' at row 1`. The threshold — `Σ len(order_key) + depth − 1
1072
+ * > 128` — is far more reachable than the `cte_max_recursion_depth`
1073
+ * ceiling this method's own docblock elsewhere flags: roughly 11–40
1074
+ * tree levels for typical fractional-index keys, lower once keys
1075
+ * have grown through repeated same-position reordering. Fix: widen
1076
+ * the anchor's `path` column explicitly —
1077
+ * `CAST(r.order_key AS CHAR(4096) CHARACTER SET ascii) COLLATE
1078
+ * ascii_bin` — doing both jobs the pg cast did: width (4096 bytes,
1079
+ * generous headroom over the reachable-depth threshold above) *and*
1080
+ * collation (`CHARACTER SET ascii` alone resolves to
1081
+ * `ascii_general_ci`, not `ascii_bin` — confirmed live that a bare
1082
+ * `CAST(… AS CHAR(4096))` with no `CHARACTER SET`/`COLLATE` at all
1083
+ * reverts to the *connection's* default collation, not the source
1084
+ * column's, so both clauses are required, not just one).
1085
+ */
1086
+ async getTreeSubtree({ collectionId, rootDocumentId = null, maxDepth = 10_000, readMode = 'any', locale = this.defaultContentLocale, filters, }) {
1087
+ const rootCondition = rootDocumentId == null
1088
+ ? sql `r.parent_document_id IS NULL`
1089
+ : sql `r.child_document_id = ${rootDocumentId}`;
1090
+ const anchorGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'sv0');
1091
+ const childGate = this.buildTreeVisibility(sql `r.child_document_id`, filters, locale, readMode, 'sv1');
1092
+ const query = sql `
1093
+ WITH RECURSIVE subtree AS (
1094
+ SELECT r.child_document_id, r.parent_document_id, r.order_key,
1095
+ 0 AS depth,
1096
+ CAST(r.order_key AS CHAR(4096) CHARACTER SET ascii) COLLATE ascii_bin AS path
1097
+ FROM byline_document_relationships r
1098
+ JOIN byline_documents d ON d.id = r.child_document_id
1099
+ WHERE d.collection_id = ${collectionId}
1100
+ AND ${rootCondition}
1101
+ AND ${anchorGate}
1102
+ UNION ALL
1103
+ SELECT r.child_document_id, r.parent_document_id, r.order_key,
1104
+ s.depth + 1, CONCAT(s.path, '/', r.order_key)
1105
+ FROM byline_document_relationships r
1106
+ JOIN subtree s ON r.parent_document_id = s.child_document_id
1107
+ WHERE s.depth + 1 <= ${maxDepth}
1108
+ AND ${childGate}
1109
+ )
1110
+ SELECT child_document_id AS document_id,
1111
+ CASE WHEN depth = 0 THEN NULL ELSE parent_document_id END AS parent_document_id,
1112
+ depth, order_key
1113
+ FROM subtree
1114
+ ORDER BY path
1115
+ `;
1116
+ const result = (await this.db.execute(query));
1117
+ return result[0].map((r) => ({
1118
+ document_id: r.document_id,
1119
+ parent_document_id: r.parent_document_id ?? null,
1120
+ depth: Number(r.depth),
1121
+ order_key: r.order_key,
1122
+ }));
1123
+ }
1124
+ /**
1125
+ * getDocumentCountsByStatus
1126
+ *
1127
+ * Returns a count of current documents grouped by workflow status for a
1128
+ * given collection. Uses the `current_documents` view so each logical
1129
+ * document is counted once (at its latest/current version).
1130
+ */
1131
+ async getDocumentCountsByStatus({ collection_id, filters, }) {
1132
+ const conditions = [eq(currentDocumentsView.collection_id, collection_id)];
1133
+ if (filters?.length) {
1134
+ const outerScope = {
1135
+ docVersionId: sql `${currentDocumentsView.id}`,
1136
+ documentId: sql `${currentDocumentsView.document_id}`,
1137
+ status: sql `${currentDocumentsView.status}`,
1138
+ path: this.pathProjection(sql `${currentDocumentsView.document_id}`, this.defaultContentLocale),
1139
+ };
1140
+ for (const f of filters) {
1141
+ conditions.push(this.buildFilterExists(f, this.defaultContentLocale, outerScope, undefined, 0));
1142
+ }
1143
+ }
1144
+ const rows = await this.db
1145
+ .select({
1146
+ status: currentDocumentsView.status,
1147
+ count: sql `CAST(COUNT(*) AS SIGNED)`,
1148
+ })
1149
+ .from(currentDocumentsView)
1150
+ .where(and(...conditions))
1151
+ .groupBy(currentDocumentsView.status);
1152
+ return rows.map((r) => ({
1153
+ status: r.status ?? 'unknown',
1154
+ count: Number(r.count),
1155
+ }));
1156
+ }
1157
+ /**
1158
+ * reconstructDocuments — retrieve field values and reconstruct multiple documents.
1159
+ * Supports selective field loading via the `fields` parameter. Pure
1160
+ * JS/core-delegate orchestration — ports unchanged from pg (the
1161
+ * dialect-specific work is one level down, in
1162
+ * `getAllFieldValuesForMultipleVersions`).
1163
+ */
1164
+ async reconstructDocuments({ documents: docs, locale = 'all', fields: requestedFields, onMissingLocale, }) {
1165
+ if (docs.length === 0)
1166
+ return [];
1167
+ const versionIds = docs.map((v) => v.id);
1168
+ // Resolve definition once for the batch (safe — early return above guarantees length > 0)
1169
+ const firstDoc = docs[0];
1170
+ const definition = await this.getDefinitionForCollection(firstDoc.collection_id);
1171
+ // When specific fields are requested, resolve which store tables we need
1172
+ // and query only those — skipping irrelevant tables entirely.
1173
+ const storeTypes = requestedFields?.length
1174
+ ? resolveStoreTypes(definition.fields, requestedFields)
1175
+ : undefined;
1176
+ // The distinct fallback floors for the batch — each document's own
1177
+ // `source_locale` anchor — so the field fetch pulls every locale a row in
1178
+ // this page might fall back to, not just the global default.
1179
+ const floorLocales = [
1180
+ ...new Set(docs.map((d) => d.source_locale).filter((l) => l != null)),
1181
+ ];
1182
+ // Get field values for all versions in one query
1183
+ const allFieldValues = await this.getAllFieldValuesForMultipleVersions(versionIds, locale, storeTypes, floorLocales);
1184
+ // Group field values by document version
1185
+ const fieldValuesByVersion = new Map();
1186
+ for (const fieldValue of allFieldValues) {
1187
+ if (!fieldValuesByVersion.has(fieldValue.document_version_id)) {
1188
+ fieldValuesByVersion.set(fieldValue.document_version_id, []);
1189
+ }
1190
+ fieldValuesByVersion.get(fieldValue.document_version_id)?.push(fieldValue);
1191
+ }
1192
+ // Fetch meta rows for all versions in one query
1193
+ const allMetaRows = await this.db
1194
+ .select({
1195
+ document_version_id: metaStore.document_version_id,
1196
+ type: metaStore.type,
1197
+ path: metaStore.path,
1198
+ item_id: metaStore.item_id,
1199
+ meta: metaStore.meta,
1200
+ })
1201
+ .from(metaStore)
1202
+ .where(inArray(metaStore.document_version_id, versionIds));
1203
+ const metaByVersion = new Map();
1204
+ for (const row of allMetaRows) {
1205
+ const list = metaByVersion.get(row.document_version_id) ?? [];
1206
+ list.push({
1207
+ type: row.type,
1208
+ path: row.path,
1209
+ item_id: row.item_id,
1210
+ meta: row.meta,
1211
+ });
1212
+ if (!metaByVersion.has(row.document_version_id)) {
1213
+ metaByVersion.set(row.document_version_id, list);
1214
+ }
1215
+ }
1216
+ // Reconstruct each document with document data at root level
1217
+ const result = [];
1218
+ for (const doc of docs) {
1219
+ const versionFieldValues = fieldValuesByVersion.get(doc.id) || [];
1220
+ const docMetaRows = (metaByVersion.get(doc.id) ?? []);
1221
+ const { fields } = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows, false, onMissingLocale, doc.source_locale);
1222
+ // When specific fields were requested, trim the reconstructed object
1223
+ // to only those fields. Store-level filtering avoids querying unused
1224
+ // tables, but fields sharing a store (e.g. price + views in numeric)
1225
+ // still appear — this final pass removes them.
1226
+ const trimmedFields = requestedFields?.length
1227
+ ? Object.fromEntries(Object.entries(fields).filter(([k]) => requestedFields.includes(k)))
1228
+ : fields;
1229
+ const documentWithFields = {
1230
+ document_version_id: doc.id,
1231
+ document_id: doc.document_id,
1232
+ path: doc.path ?? '',
1233
+ source_locale: doc.source_locale ?? null,
1234
+ status: doc.status,
1235
+ event_type: doc.event_type,
1236
+ created_at: doc.created_at,
1237
+ updated_at: doc.updated_at,
1238
+ created_by: doc.created_by ?? null,
1239
+ fields: trimmedFields,
1240
+ };
1241
+ result.push(documentWithFields);
1242
+ }
1243
+ return result;
1244
+ }
1245
+ /**
1246
+ * Gets all field values for a single document version.
1247
+ * Delegates to the multi-version dynamic UNION ALL builder.
1248
+ */
1249
+ async getAllFieldValues(documentVersionId, locale = 'all', sourceLocale) {
1250
+ return this.getAllFieldValuesForMultipleVersions([documentVersionId], locale, undefined, sourceLocale ? [sourceLocale] : undefined);
1251
+ }
1252
+ /**
1253
+ * Gets field values for multiple versions in a single query — the UNION
1254
+ * ALL that is the whole point of this task.
1255
+ *
1256
+ * When `storeTypes` is provided, only those store tables are included in
1257
+ * the UNION ALL — this is the selective field loading optimisation for
1258
+ * list views that only need a subset of fields.
1259
+ *
1260
+ * Locale-chain conversion (design spec §2, the store-row locale
1261
+ * condition): pg's `locale = ANY(ARRAY[...]) ` → MySQL `locale IN (...)`.
1262
+ * No `ORDER BY`/`FIELD()` needed here — this is a membership filter, not a
1263
+ * priority pick (unlike `pathProjection`, every matching locale row is
1264
+ * kept; `resolveEffectiveLocale`/`restoreFieldSetData` pick the effective
1265
+ * one in JS afterwards).
1266
+ *
1267
+ * `db.execute()` on the mysql2 driver returns a `[rows, fields]` tuple
1268
+ * (unlike pg's `{ rows }` result object) — see `storage-commands.ts` for
1269
+ * the established pattern this mirrors.
1270
+ */
1271
+ async getAllFieldValuesForMultipleVersions(documentVersionIds, locale = 'all', storeTypes, floorLocales) {
1272
+ if (documentVersionIds.length === 0)
1273
+ return [];
1274
+ let localeCondition = sql ``;
1275
+ if (locale !== 'all') {
1276
+ const floors = floorLocales?.length ? floorLocales : [this.defaultContentLocale];
1277
+ const chain = [...new Set([locale, ...floors])];
1278
+ const chainSql = sql.join(chain.map((l) => sql `${l}`), sql `, `);
1279
+ localeCondition = sql `AND (locale IN (${chainSql}) OR locale = 'all')`;
1280
+ }
1281
+ const documentCondition = sql `document_version_id IN (${sql.join(documentVersionIds.map((id) => sql `${id}`), sql `, `)})`;
1282
+ const typesToQuery = storeTypes ?? new Set(allStoreTypes);
1283
+ const fragments = [];
1284
+ for (const st of allStoreTypes) {
1285
+ if (!typesToQuery.has(st))
1286
+ continue;
1287
+ fragments.push(sql `SELECT ${storeSelectList(st)} FROM ${sql.raw(storeTableNames[st])} WHERE ${documentCondition} ${localeCondition}`);
1288
+ }
1289
+ if (fragments.length === 0)
1290
+ return [];
1291
+ let unionQuery = fragments[0];
1292
+ for (let i = 1; i < fragments.length; i++) {
1293
+ unionQuery = sql `${unionQuery} UNION ALL ${fragments[i]}`;
1294
+ }
1295
+ const query = sql `${unionQuery} ORDER BY document_version_id, field_path, locale`;
1296
+ const result = (await this.db.execute(query));
1297
+ // Canonicalise the raw UNION ALL driver rows at the ingestion boundary —
1298
+ // see normalizeRow's docstring for what the mysql2 driver leaves in a
1299
+ // shape core's reconstruction can't consume as-is (TINYINT(1)-as-number,
1300
+ // DECIMAL-as-string with decimalNumbers: false, and — the opposite of
1301
+ // what an earlier version of this comment claimed — DATETIME/DATE-as-
1302
+ // string, coerced to Date by normalizeRow, not already a Date).
1303
+ return result[0].map(normalizeRow);
1304
+ }
1305
+ /**
1306
+ * findDocuments — field-level filtered, sorted, paginated query.
1307
+ *
1308
+ * Each `FieldFilter` becomes an EXISTS subquery against the appropriate EAV
1309
+ * store table. A `RelationFilter` becomes a nested EXISTS that joins
1310
+ * `store_relation` to the target collection's current-documents view
1311
+ * (selected by `readMode` so draft leaks can't happen through filter
1312
+ * predicates) and recurses into its own `nested` filters. A `FieldSort`
1313
+ * becomes a LEFT JOIN LATERAL to pull the sort value into the outer query
1314
+ * — unchanged syntax from pg (the 8.0.14 engine floor guarantees LATERAL
1315
+ * support), confirmed live against this container. Document-level
1316
+ * conditions (status, path) are applied directly on the current_documents
1317
+ * view.
1318
+ */
1319
+ async findDocuments({ collection_id, filters = [], status, pathFilter, query, sort, orderBy = 'created_at', orderDirection = 'desc', locale = 'en', page = 1, pageSize = 20, fields: requestedFields, readMode, onMissingLocale, }) {
1320
+ const offset = (page - 1) * pageSize;
1321
+ const sourceTable = readMode === 'published'
1322
+ ? sql.raw('byline_current_published_documents')
1323
+ : sql.raw('byline_current_documents');
1324
+ // -- Build WHERE conditions -----------------------------------------------
1325
+ const conditions = [sql `d.collection_id = ${collection_id}`];
1326
+ if (status) {
1327
+ conditions.push(sql `d.status = ${status}`);
1328
+ }
1329
+ // `onMissingLocale: 'omit'` — exclude documents not available in the
1330
+ // requested locale (filtered at the SQL layer so pagination stays correct).
1331
+ const strictGate = this.localeAvailabilityExists(sql `d.id`, locale, onMissingLocale);
1332
+ if (strictGate) {
1333
+ conditions.push(strictGate);
1334
+ }
1335
+ if (pathFilter) {
1336
+ conditions.push(this.buildFilterCondition(this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`), pathFilter.operator, pathFilter.value));
1337
+ }
1338
+ // Admin list-view quick search via EXISTS on store_text. MySQL has no
1339
+ // `ILIKE` — the elected divergence (design spec §2, not "fixed"): plain
1340
+ // `LIKE` against `byline_store_text.value`, which keeps the schema's
1341
+ // database-default `utf8mb4_0900_ai_ci` collation, so this search is
1342
+ // already case- AND accent-insensitive (pg's `ILIKE` is case-insensitive
1343
+ // only). See `buildFilterCondition`'s `$contains` branch for the same
1344
+ // divergence on the field-filter path.
1345
+ if (query) {
1346
+ const definition = await this.getDefinitionForCollection(collection_id);
1347
+ const searchFields = definition.listSearch != null && definition.listSearch.length > 0
1348
+ ? definition.listSearch
1349
+ : [resolveIdentityField(definition) ?? 'title'];
1350
+ const searchConditions = searchFields.map((fieldName) => sql `(field_name = ${fieldName} AND value LIKE ${`%${query}%`})`);
1351
+ conditions.push(sql `EXISTS (
1352
+ SELECT 1 FROM byline_store_text
1353
+ WHERE document_version_id = d.id
1354
+ AND (locale = ${locale} OR locale = 'all')
1355
+ AND (${sql.join(searchConditions, sql ` OR `)})
1356
+ )`);
1357
+ }
1358
+ // Field-level / relation-level EXISTS subqueries. Each relation hop
1359
+ // introduces its own alias scope (`r${depth}`, `td${depth}`) so nested
1360
+ // EXISTS clauses don't shadow their outer relation's aliases.
1361
+ for (const filter of filters) {
1362
+ conditions.push(this.buildFilterExists(filter, locale, {
1363
+ docVersionId: sql `d.id`,
1364
+ documentId: sql `d.document_id`,
1365
+ status: sql `d.status`,
1366
+ path: this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`),
1367
+ }, readMode, 0));
1368
+ }
1369
+ const whereClause = sql.join(conditions, sql ` AND `);
1370
+ // -- Build ORDER BY -------------------------------------------------------
1371
+ let orderClause;
1372
+ let sortJoin = sql ``;
1373
+ if (sort) {
1374
+ // Field-level sort via LEFT JOIN LATERAL
1375
+ const storeTable = storeTableNames[sort.storeType];
1376
+ if (storeTable) {
1377
+ sortJoin = sql `LEFT JOIN LATERAL (
1378
+ SELECT ${sql.raw(sort.valueColumn)} AS _sort_value
1379
+ FROM ${sql.raw(storeTable)}
1380
+ WHERE document_version_id = d.id
1381
+ AND field_name = ${sort.fieldName}
1382
+ AND (locale = ${locale} OR locale = 'all')
1383
+ LIMIT 1
1384
+ ) _sort ON true`;
1385
+ // MySQL has no `NULLS LAST`. `DESC` already sorts NULL last by
1386
+ // default (confirmed live), so `_sort_value DESC` needs no help;
1387
+ // `ASC` sorts NULL first by default (the opposite of pg's `NULLS
1388
+ // LAST`), so the `(col IS NULL) ASC, col ASC` emulation idiom is
1389
+ // required — same idiom as `buildDocumentOrderClause`.
1390
+ orderClause =
1391
+ sort.direction === 'desc'
1392
+ ? sql `_sort._sort_value DESC`
1393
+ : sql `(_sort._sort_value IS NULL) ASC, _sort._sort_value ASC`;
1394
+ }
1395
+ else {
1396
+ // Unrecognised store type — fall back to document-level sort
1397
+ orderClause = this.buildDocumentOrderClause(orderBy, orderDirection);
1398
+ }
1399
+ }
1400
+ else {
1401
+ orderClause = this.buildDocumentOrderClause(orderBy, orderDirection);
1402
+ }
1403
+ // -- Count query ----------------------------------------------------------
1404
+ // `count(*)::int` → `CAST(COUNT(*) AS SIGNED)` (design spec §2).
1405
+ const countQuery = sql `
1406
+ SELECT CAST(COUNT(*) AS SIGNED) AS total
1407
+ FROM ${sourceTable} d
1408
+ ${sortJoin}
1409
+ WHERE ${whereClause}
1410
+ `;
1411
+ const countResult = (await this.db.execute(countQuery));
1412
+ const total = Number(countResult[0][0]?.total) || 0;
1413
+ if (total === 0) {
1414
+ return { documents: [], total: 0 };
1415
+ }
1416
+ // -- Main query -----------------------------------------------------------
1417
+ //
1418
+ // `d.*` no longer includes `path` (it lives in byline_document_paths
1419
+ // keyed by document_id + locale). Project it via the locale-aware
1420
+ // subquery so the result rows still carry `path` for the in-memory
1421
+ // Document shape.
1422
+ const pathProjectionSql = this.pathProjection(sql `d.document_id`, locale, sql `d.source_locale`);
1423
+ const mainQuery = sql `
1424
+ SELECT d.*, ${pathProjectionSql} AS path
1425
+ FROM ${sourceTable} d
1426
+ ${sortJoin}
1427
+ WHERE ${whereClause}
1428
+ ORDER BY ${orderClause}
1429
+ LIMIT ${pageSize}
1430
+ OFFSET ${offset}
1431
+ `;
1432
+ const mainResult = (await this.db.execute(mainQuery));
1433
+ const currentDocuments = mainResult[0].map((row) => ({
1434
+ id: row.id,
1435
+ document_id: row.document_id,
1436
+ collection_id: row.collection_id,
1437
+ collection_version: row.collection_version,
1438
+ path: row.path ?? null,
1439
+ event_type: row.event_type,
1440
+ status: row.status,
1441
+ // Raw driver row (not the schema-typed query builder) — TINYINT(1)
1442
+ // arrives as a JS number here, same as normalizeRow's boolean_value
1443
+ // columns. `current_documents`'s own CTE already filters
1444
+ // `is_deleted = false`, so this is always falsy in practice; coerced
1445
+ // properly regardless.
1446
+ is_deleted: Boolean(row.is_deleted),
1447
+ // Raw driver row on this `db.execute(sql\`...\`)` path — drizzle's
1448
+ // mysql2 driver hands DATETIME columns back as strings here, not
1449
+ // `Date` (see `toDate`'s docstring, `storage-utils.ts`), confirmed
1450
+ // live: an un-coerced `as Date` cast was lying at the type level.
1451
+ created_at: toDate(row.created_at, 'created_at'),
1452
+ updated_at: toDate(row.updated_at, 'updated_at'),
1453
+ created_by: row.created_by,
1454
+ change_summary: row.change_summary,
1455
+ source_locale: row.source_locale ?? null,
1456
+ }));
1457
+ const documents = await this.reconstructDocuments({
1458
+ documents: currentDocuments,
1459
+ locale,
1460
+ fields: requestedFields,
1461
+ onMissingLocale,
1462
+ });
1463
+ // Attach the version-locale availability metadata per row (one batched
1464
+ // indexed query for the whole page) so list consumers can render
1465
+ // language affordances / hreflang without a follow-up fetch.
1466
+ const availability = await this.getAvailableLocalesByVersion(documents.map((d) => d.document_version_id));
1467
+ const advertised = await this.getAdvertisedLocalesByDocument(documents.map((d) => d.document_id));
1468
+ for (const doc of documents) {
1469
+ const a = availability.get(doc.document_version_id);
1470
+ doc.availableLocales = advertised.get(doc.document_id) ?? [];
1471
+ doc._availableVersionLocales = a?.availableLocales ?? [];
1472
+ doc._localeAgnostic = a?.localeAgnostic ?? false;
1473
+ }
1474
+ return { documents, total };
1475
+ }
1476
+ /**
1477
+ * Compile status and `beforeRead` visibility for one tree edge endpoint.
1478
+ * Ports unchanged from pg — the `EXISTS` shape has no dialect-specific
1479
+ * SQL of its own (its nested `buildFilterExists` calls carry the
1480
+ * dialect-specific bits).
1481
+ */
1482
+ buildTreeVisibility(documentId, filters, locale, readMode, aliasName) {
1483
+ const view = readMode === 'published'
1484
+ ? sql.raw('byline_current_published_documents')
1485
+ : sql.raw('byline_current_documents');
1486
+ const alias = sql.raw(aliasName);
1487
+ const scope = {
1488
+ docVersionId: sql `${alias}.id`,
1489
+ documentId: sql `${alias}.document_id`,
1490
+ status: sql `${alias}.status`,
1491
+ path: this.pathProjection(sql `${alias}.document_id`, locale, sql `${alias}.source_locale`),
1492
+ };
1493
+ const filterSql = (filters ?? []).map((filter) => this.buildFilterExists(filter, locale, scope, readMode, 0));
1494
+ const filterClause = filterSql.length > 0 ? sql ` AND ${sql.join(filterSql, sql ` AND `)}` : sql ``;
1495
+ return sql `EXISTS (
1496
+ SELECT 1 FROM ${view} ${alias}
1497
+ WHERE ${alias}.document_id = ${documentId}${filterClause}
1498
+ )`;
1499
+ }
1500
+ /**
1501
+ * Build an EXISTS subquery for a single DocumentFilter. Dispatches on
1502
+ * `kind` — field filters emit a direct EXISTS against the field's EAV
1503
+ * store; relation filters emit a nested EXISTS that joins through
1504
+ * `store_relation` to the target collection's current-documents view
1505
+ * and recurses against the target's own stores; combinator filters
1506
+ * emit a parenthesised AND/OR group; document-column filters emit a
1507
+ * direct comparison on the outer scope's status/path column. Ports
1508
+ * unchanged from pg — the dispatch itself has no dialect-specific SQL.
1509
+ */
1510
+ buildFilterExists(filter, locale, outerScope, readMode, depth) {
1511
+ switch (filter.kind) {
1512
+ case 'field':
1513
+ return this.buildFieldExists(filter, locale, outerScope.docVersionId);
1514
+ case 'relation':
1515
+ return this.buildRelationExists(filter, locale, outerScope, readMode, depth);
1516
+ case 'and':
1517
+ case 'or':
1518
+ return this.buildCombinatorGroup(filter, locale, outerScope, readMode, depth);
1519
+ case 'docColumn':
1520
+ return this.buildDocColumnFilter(filter, outerScope);
1521
+ }
1522
+ }
1523
+ /**
1524
+ * Build a parenthesised AND/OR group from a CombinatorFilter. Each child
1525
+ * compiles through `buildFilterExists` recursively, so combinators nest
1526
+ * freely and inherit the outer scope. Ports unchanged from pg.
1527
+ */
1528
+ buildCombinatorGroup(filter, locale, outerScope, readMode, depth) {
1529
+ const childSql = filter.children.map((child) => this.buildFilterExists(child, locale, outerScope, readMode, depth));
1530
+ const joiner = filter.kind === 'or' ? sql ` OR ` : sql ` AND `;
1531
+ return sql `(${sql.join(childSql, joiner)})`;
1532
+ }
1533
+ /**
1534
+ * Compile a `DocumentColumnFilter` against the outer scope's `status`,
1535
+ * `path`, or `id` column. Plain comparison — no EXISTS — because the
1536
+ * column lives directly on the outer relation (current-documents view),
1537
+ * not in the EAV stores. Ports unchanged from pg.
1538
+ */
1539
+ buildDocColumnFilter(filter, outerScope) {
1540
+ const column = filter.column === 'status'
1541
+ ? outerScope.status
1542
+ : filter.column === 'path'
1543
+ ? outerScope.path
1544
+ : outerScope.documentId;
1545
+ return this.buildFilterCondition(column, filter.operator, filter.value);
1546
+ }
1547
+ /**
1548
+ * Build an EXISTS subquery for a single field-level filter. Ports
1549
+ * unchanged from pg (aside from `buildFilterCondition`'s `ILIKE` → `LIKE`
1550
+ * one level down).
1551
+ */
1552
+ buildFieldExists(filter, locale, outerDocVersionId) {
1553
+ const storeTable = storeTableNames[filter.storeType];
1554
+ if (!storeTable) {
1555
+ throw ERR_DATABASE({
1556
+ message: `unknown store type: ${filter.storeType}`,
1557
+ details: { storeType: filter.storeType },
1558
+ }).log(getLogger());
1559
+ }
1560
+ const valueCol = sql.raw(filter.valueColumn);
1561
+ const condition = this.buildFilterCondition(valueCol, filter.operator, filter.value);
1562
+ return sql `EXISTS (
1563
+ SELECT 1 FROM ${sql.raw(storeTable)}
1564
+ WHERE document_version_id = ${outerDocVersionId}
1565
+ AND field_name = ${filter.fieldName}
1566
+ AND (locale = ${locale} OR locale = 'all')
1567
+ AND ${condition}
1568
+ )`;
1569
+ }
1570
+ /**
1571
+ * Build a nested EXISTS subquery for a cross-collection relation filter.
1572
+ *
1573
+ * Joins `store_relation` to the target collection's current-documents
1574
+ * view (`current_published_documents` under `readMode: 'published'`,
1575
+ * `current_documents` otherwise — so a draft target doesn't leak when
1576
+ * the outer read is in published mode), then recurses each nested
1577
+ * filter against the target version's own `td.id`.
1578
+ *
1579
+ * `hasMany` relations store one row per item at an indexed field name
1580
+ * (`<field>.0`, `<field>.1`, …), so the field match switches to a prefix
1581
+ * match; single relations match the exact name.
1582
+ *
1583
+ * The `quantifier` selects the set semantics over the relation's
1584
+ * (resolving) targets — see pg's docblock for the full 'some'/'none'/
1585
+ * 'every' semantics; this method ports the SQL shape unchanged.
1586
+ */
1587
+ buildRelationExists(filter, locale, outerScope, readMode, depth) {
1588
+ const targetView = readMode === 'published'
1589
+ ? sql.raw('byline_current_published_documents')
1590
+ : sql.raw('byline_current_documents');
1591
+ // Use depth-scoped aliases so nested relations don't shadow their
1592
+ // outer scope. e.g. outer relation gets `r0`/`td0`; a relation filter
1593
+ // nested inside that gets `r1`/`td1`.
1594
+ const rAlias = sql.raw(`r${depth}`);
1595
+ const tdAlias = sql.raw(`td${depth}`);
1596
+ const innerScope = {
1597
+ docVersionId: sql.raw(`td${depth}.id`),
1598
+ documentId: sql.raw(`td${depth}.document_id`),
1599
+ status: sql.raw(`td${depth}.status`),
1600
+ // `td${depth}.path` no longer exists on the view; resolve via the
1601
+ // locale priority chain against byline_document_paths instead, anchored
1602
+ // to the target document's own `source_locale`.
1603
+ path: this.pathProjection(sql.raw(`td${depth}.document_id`), locale, sql.raw(`td${depth}.source_locale`)),
1604
+ };
1605
+ const nestedConditions = filter.nested.map((nested) => this.buildFilterExists(nested, locale, innerScope, readMode, depth + 1));
1606
+ const quantifier = filter.quantifier ?? 'some';
1607
+ // `every` with nothing to fail is vacuously true for every document —
1608
+ // short-circuit rather than emitting `NOT (TRUE)` noise.
1609
+ if (quantifier === 'every' && nestedConditions.length === 0) {
1610
+ return sql `TRUE`;
1611
+ }
1612
+ // 'some'/'none' assert the nested conjunction on a matching row;
1613
+ // 'every' asserts the *negated* conjunction (a failing row) and wraps
1614
+ // the whole scan in NOT.
1615
+ const nestedAnd = nestedConditions.length === 0
1616
+ ? sql ``
1617
+ : quantifier === 'every'
1618
+ ? sql ` AND NOT (${sql.join(nestedConditions, sql ` AND `)})`
1619
+ : sql ` AND ${sql.join(nestedConditions, sql ` AND `)}`;
1620
+ // hasMany rows are stored at indexed paths (`gallery.0`, `gallery.1`, …)
1621
+ // where `field_name` is the *index segment* ('0', '1', …) and
1622
+ // `parent_path` is the field name — so multi-target rows match on
1623
+ // `parent_path` exactly, single-target rows on `field_name`. (A where
1624
+ // clause only addresses top-level fields, so `parent_path` needs no
1625
+ // prefix handling.)
1626
+ const fieldMatch = filter.hasMany
1627
+ ? sql `${rAlias}.parent_path = ${filter.fieldName}`
1628
+ : sql `${rAlias}.field_name = ${filter.fieldName}`;
1629
+ const existsSql = sql `EXISTS (
1630
+ SELECT 1 FROM byline_store_relation ${rAlias}
1631
+ JOIN ${targetView} ${tdAlias}
1632
+ ON ${tdAlias}.document_id = ${rAlias}.target_document_id
1633
+ AND ${tdAlias}.collection_id = ${rAlias}.target_collection_id
1634
+ WHERE ${rAlias}.document_version_id = ${outerScope.docVersionId}
1635
+ AND ${fieldMatch}
1636
+ AND ${rAlias}.target_collection_id = ${filter.targetCollectionId}
1637
+ AND (${rAlias}.locale = ${locale} OR ${rAlias}.locale = 'all')${nestedAnd}
1638
+ )`;
1639
+ return quantifier === 'some' ? existsSql : sql `NOT ${existsSql}`;
1640
+ }
1641
+ /**
1642
+ * Build a comparison condition for a filter operator.
1643
+ *
1644
+ * `$contains` divergence (design spec §2, elected — not "fixed"): pg's
1645
+ * `ILIKE` has no MySQL equivalent, so this compiles to plain `LIKE`. The
1646
+ * store `value` columns keep the schema's default `utf8mb4_0900_ai_ci`
1647
+ * collation, which is case- *and* accent-insensitive — a strictly wider
1648
+ * match than pg's case-insensitive-only `ILIKE`. `byline_document_paths
1649
+ * .path` is the one column this operator also reaches (via
1650
+ * `buildDocColumnFilter`'s `path` branch) that is NOT `ai_ci` — it's
1651
+ * `utf8mb4_bin` (`varcharCaseSensitive`, `database/schema/common.ts`), so
1652
+ * a `$contains` against `path` stays byte-exact and matches pg exactly.
1653
+ * Both behaviours fall out of the column's own collation; nothing here
1654
+ * special-cases `path` vs a store value.
1655
+ */
1656
+ buildFilterCondition(column, operator, value) {
1657
+ switch (operator) {
1658
+ case '$eq':
1659
+ return value === null ? sql `${column} IS NULL` : sql `${column} = ${value}`;
1660
+ case '$ne':
1661
+ return value === null ? sql `${column} IS NOT NULL` : sql `${column} != ${value}`;
1662
+ case '$gt':
1663
+ return sql `${column} > ${value}`;
1664
+ case '$gte':
1665
+ return sql `${column} >= ${value}`;
1666
+ case '$lt':
1667
+ return sql `${column} < ${value}`;
1668
+ case '$lte':
1669
+ return sql `${column} <= ${value}`;
1670
+ case '$contains':
1671
+ return sql `${column} LIKE ${`%${String(value)}%`}`;
1672
+ case '$in': {
1673
+ const arr = value;
1674
+ // Empty `$in` matches nothing — explicit FALSE avoids generating
1675
+ // an invalid empty `IN ()` clause.
1676
+ if (arr.length === 0)
1677
+ return sql `FALSE`;
1678
+ const items = sql.join(arr.map((v) => sql `${v}`), sql `, `);
1679
+ return sql `${column} IN (${items})`;
1680
+ }
1681
+ case '$nin': {
1682
+ const arr = value;
1683
+ if (arr.length === 0)
1684
+ return sql `TRUE`;
1685
+ const items = sql.join(arr.map((v) => sql `${v}`), sql `, `);
1686
+ return sql `${column} NOT IN (${items})`;
1687
+ }
1688
+ default:
1689
+ throw ERR_DATABASE({
1690
+ message: `unsupported filter operator: ${operator}`,
1691
+ details: { operator },
1692
+ }).log(getLogger());
1693
+ }
1694
+ }
1695
+ /**
1696
+ * Build an ORDER BY clause for a document-level column. `path` is
1697
+ * intentionally not sortable here — see pg's docblock.
1698
+ *
1699
+ * Divergence from pg (found by reasoning, confirmed live against this
1700
+ * container): MySQL has no `NULLS LAST`. For `DESC`, MySQL already sorts
1701
+ * NULL last by default, so `d.order_key DESC` needs no help. For `ASC`,
1702
+ * MySQL's default sorts NULL *first* — the opposite of pg's `NULLS LAST`
1703
+ * — so the emulation idiom `ORDER BY (col IS NULL), col ASC` is required
1704
+ * to match pg's behaviour (`(col IS NULL)` evaluates 0/1; ascending puts
1705
+ * real values, which evaluate to 0, before NULLs, which evaluate to 1).
1706
+ */
1707
+ buildDocumentOrderClause(orderBy, direction) {
1708
+ if (orderBy === 'order_key') {
1709
+ return direction === 'desc'
1710
+ ? sql `d.order_key DESC, d.created_at DESC`
1711
+ : sql `(d.order_key IS NULL) ASC, d.order_key ASC, d.created_at DESC`;
1712
+ }
1713
+ const columnMap = {
1714
+ created_at: 'd.created_at',
1715
+ updated_at: 'd.updated_at',
1716
+ };
1717
+ const col = columnMap[orderBy] ?? 'd.created_at';
1718
+ return direction === 'desc' ? sql `${sql.raw(col)} DESC` : sql `${sql.raw(col)} ASC`;
1719
+ }
1720
+ /**
1721
+ * Converts a union field row back into an array of FlattenedStore that
1722
+ * the reconstruction utilities expect. Pure JS mapping — ports unchanged
1723
+ * from pg.
1724
+ */
1725
+ convertUnionRowToFlattenedStores(unionRowValues) {
1726
+ return unionRowValues.map((row) => {
1727
+ const baseValue = {
1728
+ field_path: row.field_path,
1729
+ field_name: row.field_name,
1730
+ locale: row.locale,
1731
+ parent_path: row.parent_path ?? undefined,
1732
+ };
1733
+ switch (row.field_type) {
1734
+ case 'text':
1735
+ return {
1736
+ ...baseValue,
1737
+ field_type: 'text',
1738
+ value: row.text_value,
1739
+ };
1740
+ case 'richText':
1741
+ return {
1742
+ ...baseValue,
1743
+ field_type: 'richText',
1744
+ value: row.json_value,
1745
+ };
1746
+ case 'numeric':
1747
+ return {
1748
+ ...baseValue,
1749
+ field_type: row.number_type,
1750
+ number_type: row.number_type,
1751
+ value_integer: row.value_integer,
1752
+ value_decimal: row.value_decimal,
1753
+ value_float: row.value_float,
1754
+ };
1755
+ case 'boolean':
1756
+ return {
1757
+ ...baseValue,
1758
+ field_type: 'boolean',
1759
+ value: row.boolean_value,
1760
+ };
1761
+ case 'time':
1762
+ case 'date':
1763
+ case 'datetime':
1764
+ return {
1765
+ ...baseValue,
1766
+ field_type: row.date_type,
1767
+ date_type: row.date_type,
1768
+ value_time: row.value_time,
1769
+ value_date: row.value_date,
1770
+ value_timestamp_tz: row.value_timestamp_tz,
1771
+ };
1772
+ case 'image':
1773
+ case 'file':
1774
+ return {
1775
+ ...baseValue,
1776
+ field_type: row.field_type,
1777
+ file_id: row.file_id,
1778
+ filename: row.filename,
1779
+ original_filename: row.original_filename,
1780
+ mime_type: row.mime_type,
1781
+ file_size: row.file_size,
1782
+ storage_provider: row.storage_provider,
1783
+ storage_path: row.storage_path,
1784
+ storage_url: row.storage_url,
1785
+ file_hash: row.file_hash,
1786
+ image_width: row.image_width,
1787
+ image_height: row.image_height,
1788
+ image_format: row.image_format,
1789
+ processing_status: row.processing_status,
1790
+ thumbnail_generated: row.thumbnail_generated,
1791
+ };
1792
+ case 'relation':
1793
+ return {
1794
+ ...baseValue,
1795
+ field_type: 'relation',
1796
+ target_document_id: row.target_document_id,
1797
+ target_collection_id: row.target_collection_id,
1798
+ relationship_type: row.relationship_type,
1799
+ cascade_delete: row.cascade_delete,
1800
+ };
1801
+ default:
1802
+ throw ERR_DATABASE({
1803
+ message: `unknown field type: ${row.field_type}`,
1804
+ details: { fieldType: row.field_type },
1805
+ }).log(getLogger());
1806
+ }
1807
+ });
1808
+ }
1809
+ }
1810
+ export function createQueryBuilders(db, collections, defaultContentLocale, transactionDb) {
1811
+ return {
1812
+ collections: new CollectionQueries(db),
1813
+ documents: new DocumentQueries(db, collections, defaultContentLocale, transactionDb),
1814
+ };
1815
+ }