@byline/db-mysql 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +373 -0
- package/README.md +283 -0
- package/dist/database/schema/auth.d.ts +951 -0
- package/dist/database/schema/auth.js +226 -0
- package/dist/database/schema/common.d.ts +168 -0
- package/dist/database/schema/common.js +130 -0
- package/dist/database/schema/index.d.ts +3419 -0
- package/dist/database/schema/index.js +895 -0
- package/dist/database/schema/schema-pins.test.node.d.ts +52 -0
- package/dist/database/schema/schema-pins.test.node.js +398 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +151 -0
- package/dist/lib/boot-check.d.ts +22 -0
- package/dist/lib/boot-check.js +42 -0
- package/dist/lib/boot-check.test.node.d.ts +8 -0
- package/dist/lib/boot-check.test.node.js +46 -0
- package/dist/lib/db-manager.d.ts +54 -0
- package/dist/lib/db-manager.js +49 -0
- package/dist/lib/test-db.d.ts +37 -0
- package/dist/lib/test-db.js +111 -0
- package/dist/lib/test-helper.d.ts +33 -0
- package/dist/lib/test-helper.js +68 -0
- package/dist/modules/admin/admin-permissions-repository.d.ts +35 -0
- package/dist/modules/admin/admin-permissions-repository.js +101 -0
- package/dist/modules/admin/admin-preferences-repository.d.ts +62 -0
- package/dist/modules/admin/admin-preferences-repository.js +156 -0
- package/dist/modules/admin/admin-roles-repository.d.ts +11 -0
- package/dist/modules/admin/admin-roles-repository.js +209 -0
- package/dist/modules/admin/admin-store.d.ts +20 -0
- package/dist/modules/admin/admin-store.js +30 -0
- package/dist/modules/admin/admin-users-repository.d.ts +11 -0
- package/dist/modules/admin/admin-users-repository.js +303 -0
- package/dist/modules/admin/index.d.ts +27 -0
- package/dist/modules/admin/index.js +27 -0
- package/dist/modules/admin/refresh-tokens-repository.d.ts +34 -0
- package/dist/modules/admin/refresh-tokens-repository.js +160 -0
- package/dist/modules/audit/audit-commands.d.ts +29 -0
- package/dist/modules/audit/audit-commands.js +40 -0
- package/dist/modules/audit/audit-queries.d.ts +41 -0
- package/dist/modules/audit/audit-queries.js +169 -0
- package/dist/modules/counters/counters-commands.d.ts +54 -0
- package/dist/modules/counters/counters-commands.js +121 -0
- package/dist/modules/counters/tests/counters-concurrency.test.d.ts +8 -0
- package/dist/modules/counters/tests/counters-concurrency.test.js +111 -0
- package/dist/modules/storage/classify-error.d.ts +34 -0
- package/dist/modules/storage/classify-error.js +50 -0
- package/dist/modules/storage/classify-error.test.node.d.ts +8 -0
- package/dist/modules/storage/classify-error.test.node.js +89 -0
- package/dist/modules/storage/normalize-row.d.ts +55 -0
- package/dist/modules/storage/normalize-row.js +135 -0
- package/dist/modules/storage/normalize-row.test.node.d.ts +8 -0
- package/dist/modules/storage/normalize-row.test.node.js +89 -0
- package/dist/modules/storage/storage-commands.d.ts +443 -0
- package/dist/modules/storage/storage-commands.js +1263 -0
- package/dist/modules/storage/storage-insert.d.ts +21 -0
- package/dist/modules/storage/storage-insert.js +152 -0
- package/dist/modules/storage/storage-queries.d.ts +805 -0
- package/dist/modules/storage/storage-queries.js +1815 -0
- package/dist/modules/storage/storage-store-manifest.d.ts +77 -0
- package/dist/modules/storage/storage-store-manifest.js +168 -0
- package/dist/modules/storage/storage-utils.d.ts +49 -0
- package/dist/modules/storage/storage-utils.js +76 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.d.ts +8 -0
- package/dist/modules/storage/tests/dialect-pins.integration.test.js +266 -0
- package/dist/modules/storage/tests/storage-commands.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-commands.test.js +324 -0
- package/dist/modules/storage/tests/storage-document-paths.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-paths.test.js +214 -0
- package/dist/modules/storage/tests/storage-document-tree.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-document-tree.test.js +361 -0
- package/dist/modules/storage/tests/storage-queries.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-queries.test.js +685 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.d.ts +8 -0
- package/dist/modules/storage/tests/storage-status-and-lifecycle.test.js +268 -0
- package/package.json +91 -0
|
@@ -0,0 +1,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
|
+
}
|