@byline/db-postgres 0.9.3
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 +18 -0
- package/dist/database/schema/auth.d.ts +857 -0
- package/dist/database/schema/auth.d.ts.map +1 -0
- package/dist/database/schema/auth.js +176 -0
- package/dist/database/schema/auth.js.map +1 -0
- package/dist/database/schema/index.d.ts +2955 -0
- package/dist/database/schema/index.d.ts.map +1 -0
- package/dist/database/schema/index.js +500 -0
- package/dist/database/schema/index.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/test-helper.d.ts +17 -0
- package/dist/lib/test-helper.d.ts.map +1 -0
- package/dist/lib/test-helper.js +47 -0
- package/dist/lib/test-helper.js.map +1 -0
- package/dist/modules/admin/admin-permissions-repository.d.ts +17 -0
- package/dist/modules/admin/admin-permissions-repository.d.ts.map +1 -0
- package/dist/modules/admin/admin-permissions-repository.js +76 -0
- package/dist/modules/admin/admin-permissions-repository.js.map +1 -0
- package/dist/modules/admin/admin-roles-repository.d.ts +12 -0
- package/dist/modules/admin/admin-roles-repository.d.ts.map +1 -0
- package/dist/modules/admin/admin-roles-repository.js +168 -0
- package/dist/modules/admin/admin-roles-repository.js.map +1 -0
- package/dist/modules/admin/admin-store.d.ts +20 -0
- package/dist/modules/admin/admin-store.d.ts.map +1 -0
- package/dist/modules/admin/admin-store.js +28 -0
- package/dist/modules/admin/admin-store.js.map +1 -0
- package/dist/modules/admin/admin-users-repository.d.ts +12 -0
- package/dist/modules/admin/admin-users-repository.d.ts.map +1 -0
- package/dist/modules/admin/admin-users-repository.js +208 -0
- package/dist/modules/admin/admin-users-repository.js.map +1 -0
- package/dist/modules/admin/index.d.ts +27 -0
- package/dist/modules/admin/index.d.ts.map +1 -0
- package/dist/modules/admin/index.js +27 -0
- package/dist/modules/admin/index.js.map +1 -0
- package/dist/modules/admin/refresh-tokens-repository.d.ts +16 -0
- package/dist/modules/admin/refresh-tokens-repository.d.ts.map +1 -0
- package/dist/modules/admin/refresh-tokens-repository.js +132 -0
- package/dist/modules/admin/refresh-tokens-repository.js.map +1 -0
- package/dist/modules/admin/tests/auth-integration.test.d.ts +9 -0
- package/dist/modules/admin/tests/auth-integration.test.d.ts.map +1 -0
- package/dist/modules/admin/tests/auth-integration.test.js +392 -0
- package/dist/modules/admin/tests/auth-integration.test.js.map +1 -0
- package/dist/modules/admin/tests/session-provider.test.d.ts +9 -0
- package/dist/modules/admin/tests/session-provider.test.d.ts.map +1 -0
- package/dist/modules/admin/tests/session-provider.test.js +370 -0
- package/dist/modules/admin/tests/session-provider.test.js.map +1 -0
- package/dist/modules/storage/@types.d.ts +116 -0
- package/dist/modules/storage/@types.d.ts.map +1 -0
- package/dist/modules/storage/@types.js +9 -0
- package/dist/modules/storage/@types.js.map +1 -0
- package/dist/modules/storage/storage-commands.d.ts +136 -0
- package/dist/modules/storage/storage-commands.d.ts.map +1 -0
- package/dist/modules/storage/storage-commands.js +272 -0
- package/dist/modules/storage/storage-commands.js.map +1 -0
- package/dist/modules/storage/storage-flatten.d.ts +19 -0
- package/dist/modules/storage/storage-flatten.d.ts.map +1 -0
- package/dist/modules/storage/storage-flatten.js +261 -0
- package/dist/modules/storage/storage-flatten.js.map +1 -0
- package/dist/modules/storage/storage-insert.d.ts +22 -0
- package/dist/modules/storage/storage-insert.d.ts.map +1 -0
- package/dist/modules/storage/storage-insert.js +115 -0
- package/dist/modules/storage/storage-insert.js.map +1 -0
- package/dist/modules/storage/storage-queries.d.ts +377 -0
- package/dist/modules/storage/storage-queries.d.ts.map +1 -0
- package/dist/modules/storage/storage-queries.js +976 -0
- package/dist/modules/storage/storage-queries.js.map +1 -0
- package/dist/modules/storage/storage-restore.d.ts +19 -0
- package/dist/modules/storage/storage-restore.d.ts.map +1 -0
- package/dist/modules/storage/storage-restore.js +350 -0
- package/dist/modules/storage/storage-restore.js.map +1 -0
- package/dist/modules/storage/storage-store-manifest.d.ts +71 -0
- package/dist/modules/storage/storage-store-manifest.d.ts.map +1 -0
- package/dist/modules/storage/storage-store-manifest.js +294 -0
- package/dist/modules/storage/storage-store-manifest.js.map +1 -0
- package/dist/modules/storage/storage-utils.d.ts +23 -0
- package/dist/modules/storage/storage-utils.d.ts.map +1 -0
- package/dist/modules/storage/storage-utils.js +72 -0
- package/dist/modules/storage/storage-utils.js.map +1 -0
- package/dist/modules/storage/tests/storage-field-types.test.d.ts +9 -0
- package/dist/modules/storage/tests/storage-field-types.test.d.ts.map +1 -0
- package/dist/modules/storage/tests/storage-field-types.test.js +146 -0
- package/dist/modules/storage/tests/storage-field-types.test.js.map +1 -0
- package/dist/modules/storage/tests/storage-flatten-reconstruct.test.d.ts +9 -0
- package/dist/modules/storage/tests/storage-flatten-reconstruct.test.d.ts.map +1 -0
- package/dist/modules/storage/tests/storage-flatten-reconstruct.test.js +327 -0
- package/dist/modules/storage/tests/storage-flatten-reconstruct.test.js.map +1 -0
- package/dist/modules/storage/tests/storage-store-manifest.test.d.ts +9 -0
- package/dist/modules/storage/tests/storage-store-manifest.test.d.ts.map +1 -0
- package/dist/modules/storage/tests/storage-store-manifest.test.js +141 -0
- package/dist/modules/storage/tests/storage-store-manifest.test.js.map +1 -0
- package/dist/modules/storage/tests/storage-versioning.test.d.ts +9 -0
- package/dist/modules/storage/tests/storage-versioning.test.d.ts.map +1 -0
- package/dist/modules/storage/tests/storage-versioning.test.js +336 -0
- package/dist/modules/storage/tests/storage-versioning.test.js.map +1 -0
- package/package.json +81 -0
|
@@ -0,0 +1,976 @@
|
|
|
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
|
+
// TODO: getLogger() is used here as a global escape hatch because pgAdapter()
|
|
9
|
+
// constructs query/command classes before initBylineCore() wires up the Pino
|
|
10
|
+
// logger. A future refactor could inject the logger at construction time by
|
|
11
|
+
// either deferring adapter construction or accepting a lazy logger parameter.
|
|
12
|
+
import { ERR_DATABASE, ERR_NOT_FOUND, getLogger } from '@byline/core';
|
|
13
|
+
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
14
|
+
import { collections, currentDocumentsView, currentPublishedDocumentsView, documentVersions, metaStore, } from '../../database/schema/index.js';
|
|
15
|
+
import { extractFlattenedFieldValue, restoreFieldSetData } from './storage-restore.js';
|
|
16
|
+
import { allStoreTypes, storeSelectList, storeTableNames, } from './storage-store-manifest.js';
|
|
17
|
+
import { resolveStoreTypes } from './storage-utils.js';
|
|
18
|
+
/**
|
|
19
|
+
* CollectionQueries
|
|
20
|
+
*/
|
|
21
|
+
export class CollectionQueries {
|
|
22
|
+
db;
|
|
23
|
+
constructor(db) {
|
|
24
|
+
this.db = db;
|
|
25
|
+
}
|
|
26
|
+
async getAllCollections() {
|
|
27
|
+
return await this.db.select().from(collections);
|
|
28
|
+
}
|
|
29
|
+
async getCollectionByPath(path) {
|
|
30
|
+
return this.db.query.collections.findFirst({ where: eq(collections.path, path) });
|
|
31
|
+
}
|
|
32
|
+
async getCollectionById(id) {
|
|
33
|
+
return this.db.query.collections.findFirst({ where: eq(collections.id, id) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* DocumentQueries
|
|
38
|
+
*/
|
|
39
|
+
export class DocumentQueries {
|
|
40
|
+
db;
|
|
41
|
+
collections;
|
|
42
|
+
collectionPathCache = new Map();
|
|
43
|
+
constructor(db, collections) {
|
|
44
|
+
this.db = db;
|
|
45
|
+
this.collections = collections;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a collection UUID to its CollectionDefinition by looking up the
|
|
49
|
+
* collection's path in the DB and matching it against the injected array.
|
|
50
|
+
*/
|
|
51
|
+
async getDefinitionForCollection(collectionId) {
|
|
52
|
+
let path = this.collectionPathCache.get(collectionId);
|
|
53
|
+
if (!path) {
|
|
54
|
+
const row = await this.db.query.collections.findFirst({
|
|
55
|
+
where: eq(collections.id, collectionId),
|
|
56
|
+
});
|
|
57
|
+
if (!row) {
|
|
58
|
+
throw ERR_NOT_FOUND({
|
|
59
|
+
message: `collection not found in database: ${collectionId}`,
|
|
60
|
+
details: { collectionId },
|
|
61
|
+
}).log(getLogger());
|
|
62
|
+
}
|
|
63
|
+
path = row.path;
|
|
64
|
+
this.collectionPathCache.set(collectionId, path);
|
|
65
|
+
}
|
|
66
|
+
const definition = this.collections.find((c) => c.path === path);
|
|
67
|
+
if (!definition) {
|
|
68
|
+
throw ERR_NOT_FOUND({
|
|
69
|
+
message: `no CollectionDefinition found for path: ${path}`,
|
|
70
|
+
details: { collectionPath: path },
|
|
71
|
+
}).log(getLogger());
|
|
72
|
+
}
|
|
73
|
+
return definition;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pick the Drizzle view reference to read from based on `readMode`.
|
|
77
|
+
*
|
|
78
|
+
* - `'any'` (default) → `current_documents` — the latest version of
|
|
79
|
+
* each logical document, regardless of status.
|
|
80
|
+
* - `'published'` → `current_published_documents` — the latest
|
|
81
|
+
* version whose status is `'published'`, falling back past newer
|
|
82
|
+
* drafts so public readers keep seeing previously-published
|
|
83
|
+
* content while editors work on an unpublished draft.
|
|
84
|
+
*
|
|
85
|
+
* Both views share the same row shape, so the returned reference is
|
|
86
|
+
* drop-in substitutable at every select/where site.
|
|
87
|
+
*/
|
|
88
|
+
pickCurrentView(readMode) {
|
|
89
|
+
return readMode === 'published' ? currentPublishedDocumentsView : currentDocumentsView;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reconstruct document fields from unified row values using schema-aware
|
|
93
|
+
* restoration. Meta rows (from store_meta) are converted to
|
|
94
|
+
* FlattenedFieldValue entries so that restoreFieldSetData can inject
|
|
95
|
+
* _id and _type for blocks and array items inline.
|
|
96
|
+
*/
|
|
97
|
+
reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows) {
|
|
98
|
+
const flattenedData = unifiedFieldValues.map((row) => extractFlattenedFieldValue(row));
|
|
99
|
+
if (metaRows) {
|
|
100
|
+
for (const meta of metaRows) {
|
|
101
|
+
flattenedData.push({
|
|
102
|
+
locale: 'all',
|
|
103
|
+
field_path: meta.path.split('.'),
|
|
104
|
+
field_type: 'meta',
|
|
105
|
+
type: meta.type,
|
|
106
|
+
item_id: meta.item_id,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const resolveLocale = locale !== 'all' ? locale : undefined;
|
|
111
|
+
return restoreFieldSetData(definition.fields, flattenedData, resolveLocale);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* getCurrentVersionMetadata — narrow metadata fetch for the current version.
|
|
115
|
+
*
|
|
116
|
+
* Hits `current_documents` only; no field reconstruction, no meta fetch.
|
|
117
|
+
* Used by lifecycle operations (status changes, delete checks) that need
|
|
118
|
+
* `document_version_id` / `status` / `path` but not the document body.
|
|
119
|
+
*/
|
|
120
|
+
async getCurrentVersionMetadata({ collection_id, document_id, }) {
|
|
121
|
+
const [row] = await this.db
|
|
122
|
+
.select({
|
|
123
|
+
document_version_id: currentDocumentsView.id,
|
|
124
|
+
document_id: currentDocumentsView.document_id,
|
|
125
|
+
collection_id: currentDocumentsView.collection_id,
|
|
126
|
+
path: currentDocumentsView.path,
|
|
127
|
+
status: currentDocumentsView.status,
|
|
128
|
+
created_at: currentDocumentsView.created_at,
|
|
129
|
+
updated_at: currentDocumentsView.updated_at,
|
|
130
|
+
})
|
|
131
|
+
.from(currentDocumentsView)
|
|
132
|
+
.where(and(eq(currentDocumentsView.collection_id, collection_id), eq(currentDocumentsView.document_id, document_id)))
|
|
133
|
+
.limit(1);
|
|
134
|
+
if (!row)
|
|
135
|
+
return null;
|
|
136
|
+
return {
|
|
137
|
+
document_version_id: row.document_version_id,
|
|
138
|
+
document_id: row.document_id,
|
|
139
|
+
collection_id: row.collection_id ?? '',
|
|
140
|
+
path: row.path,
|
|
141
|
+
status: row.status ?? 'draft',
|
|
142
|
+
created_at: row.created_at ?? new Date(),
|
|
143
|
+
updated_at: row.updated_at ?? new Date(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* getDocumentById — gets the current version of a document by its logical document ID.
|
|
148
|
+
*/
|
|
149
|
+
async getDocumentById({ collection_id, document_id, locale = 'en', reconstruct = true, readMode, filters, }) {
|
|
150
|
+
const view = this.pickCurrentView(readMode);
|
|
151
|
+
// 1. Get current version (or current published version, per readMode)
|
|
152
|
+
const baseConditions = [
|
|
153
|
+
eq(view.collection_id, collection_id),
|
|
154
|
+
eq(view.document_id, document_id),
|
|
155
|
+
];
|
|
156
|
+
if (filters?.length) {
|
|
157
|
+
const outerScope = {
|
|
158
|
+
docVersionId: sql `${view.id}`,
|
|
159
|
+
status: sql `${view.status}`,
|
|
160
|
+
path: sql `${view.path}`,
|
|
161
|
+
};
|
|
162
|
+
for (const f of filters) {
|
|
163
|
+
baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const [document] = await this.db
|
|
167
|
+
.select()
|
|
168
|
+
.from(view)
|
|
169
|
+
.where(and(...baseConditions));
|
|
170
|
+
if (document == null) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
// 2. Get all field values for this document
|
|
174
|
+
const unifiedFieldValues = await this.getAllFieldValues(document.id, locale);
|
|
175
|
+
// 3. If reconstruct is true, reconstruct the fields and attach meta
|
|
176
|
+
if (reconstruct === true) {
|
|
177
|
+
const definition = await this.getDefinitionForCollection(collection_id);
|
|
178
|
+
const metaRows = await this.db
|
|
179
|
+
.select({
|
|
180
|
+
type: metaStore.type,
|
|
181
|
+
path: metaStore.path,
|
|
182
|
+
item_id: metaStore.item_id,
|
|
183
|
+
meta: metaStore.meta,
|
|
184
|
+
})
|
|
185
|
+
.from(metaStore)
|
|
186
|
+
.where(eq(metaStore.document_version_id, document.id));
|
|
187
|
+
const fields = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows);
|
|
188
|
+
return {
|
|
189
|
+
document_version_id: document.id,
|
|
190
|
+
document_id: document.document_id,
|
|
191
|
+
path: document.path,
|
|
192
|
+
status: document.status,
|
|
193
|
+
created_at: document.created_at,
|
|
194
|
+
updated_at: document.updated_at,
|
|
195
|
+
fields,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// Non-reconstructed: return raw flattened values
|
|
199
|
+
const fieldValues = this.convertUnionRowToFlattenedStores(unifiedFieldValues);
|
|
200
|
+
return {
|
|
201
|
+
document_version_id: document.id,
|
|
202
|
+
document_id: document.document_id,
|
|
203
|
+
path: document.path,
|
|
204
|
+
status: document.status,
|
|
205
|
+
created_at: document.created_at,
|
|
206
|
+
updated_at: document.updated_at,
|
|
207
|
+
fields: fieldValues,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async getDocumentByPath({ collection_id, path, locale = 'en', reconstruct = true, readMode, filters, }) {
|
|
211
|
+
const view = this.pickCurrentView(readMode);
|
|
212
|
+
// 1. Get current version (or current published version, per readMode)
|
|
213
|
+
const baseConditions = [eq(view.collection_id, collection_id), eq(view.path, path)];
|
|
214
|
+
if (filters?.length) {
|
|
215
|
+
const outerScope = {
|
|
216
|
+
docVersionId: sql `${view.id}`,
|
|
217
|
+
status: sql `${view.status}`,
|
|
218
|
+
path: sql `${view.path}`,
|
|
219
|
+
};
|
|
220
|
+
for (const f of filters) {
|
|
221
|
+
baseConditions.push(this.buildFilterExists(f, locale, outerScope, readMode, 0));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const [document] = await this.db
|
|
225
|
+
.select()
|
|
226
|
+
.from(view)
|
|
227
|
+
.where(and(...baseConditions));
|
|
228
|
+
if (document == null) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
// 2. Get all field values for this document
|
|
232
|
+
const unifiedFieldValues = await this.getAllFieldValues(document.id, locale);
|
|
233
|
+
// 3. If reconstruct is true, reconstruct the fields and attach meta
|
|
234
|
+
if (reconstruct === true) {
|
|
235
|
+
const definition = await this.getDefinitionForCollection(collection_id);
|
|
236
|
+
const metaRows = await this.db
|
|
237
|
+
.select({
|
|
238
|
+
type: metaStore.type,
|
|
239
|
+
path: metaStore.path,
|
|
240
|
+
item_id: metaStore.item_id,
|
|
241
|
+
meta: metaStore.meta,
|
|
242
|
+
})
|
|
243
|
+
.from(metaStore)
|
|
244
|
+
.where(eq(metaStore.document_version_id, document.id));
|
|
245
|
+
const fields = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows);
|
|
246
|
+
return {
|
|
247
|
+
document_version_id: document.id,
|
|
248
|
+
document_id: document.document_id,
|
|
249
|
+
path: document.path,
|
|
250
|
+
status: document.status,
|
|
251
|
+
created_at: document.created_at,
|
|
252
|
+
updated_at: document.updated_at,
|
|
253
|
+
fields,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// Non-reconstructed: return raw flattened values
|
|
257
|
+
const fieldValues = this.convertUnionRowToFlattenedStores(unifiedFieldValues);
|
|
258
|
+
return {
|
|
259
|
+
document_version_id: document.id,
|
|
260
|
+
document_id: document.document_id,
|
|
261
|
+
path: document.path,
|
|
262
|
+
status: document.status,
|
|
263
|
+
created_at: document.created_at,
|
|
264
|
+
updated_at: document.updated_at,
|
|
265
|
+
fields: fieldValues,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* getDocumentByVersion — fetches a specific version and reconstructs its fields.
|
|
270
|
+
*/
|
|
271
|
+
async getDocumentByVersion({ document_version_id, locale = 'all', }) {
|
|
272
|
+
const document = await this.db.query.documentVersions.findFirst({
|
|
273
|
+
where: eq(documentVersions.id, document_version_id),
|
|
274
|
+
});
|
|
275
|
+
if (document == null) {
|
|
276
|
+
throw ERR_NOT_FOUND({
|
|
277
|
+
message: `no current version found for document ${document_version_id}`,
|
|
278
|
+
details: { documentVersionId: document_version_id },
|
|
279
|
+
}).log(getLogger());
|
|
280
|
+
}
|
|
281
|
+
const unifiedFieldValues = await this.getAllFieldValues(document.id, locale);
|
|
282
|
+
const definition = await this.getDefinitionForCollection(document.collection_id);
|
|
283
|
+
const metaRows = await this.db
|
|
284
|
+
.select({
|
|
285
|
+
type: metaStore.type,
|
|
286
|
+
path: metaStore.path,
|
|
287
|
+
item_id: metaStore.item_id,
|
|
288
|
+
meta: metaStore.meta,
|
|
289
|
+
})
|
|
290
|
+
.from(metaStore)
|
|
291
|
+
.where(eq(metaStore.document_version_id, document.id));
|
|
292
|
+
const enrichedDocument = this.reconstructFromUnifiedRows(unifiedFieldValues, definition, locale, metaRows);
|
|
293
|
+
const documentWithFields = {
|
|
294
|
+
document_version_id: document.id,
|
|
295
|
+
document_id: document.document_id,
|
|
296
|
+
path: document.path,
|
|
297
|
+
status: document.status,
|
|
298
|
+
created_at: document.created_at,
|
|
299
|
+
updated_at: document.updated_at,
|
|
300
|
+
fields: enrichedDocument,
|
|
301
|
+
};
|
|
302
|
+
return documentWithFields;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* getDocumentsByVersionIds — fetches and reconstructs multiple documents by
|
|
306
|
+
* version ID. Used for batch loading a known set of versions (e.g.
|
|
307
|
+
* migration scripts, tests).
|
|
308
|
+
*/
|
|
309
|
+
async getDocumentsByVersionIds({ document_version_ids, locale = 'all', }) {
|
|
310
|
+
if (document_version_ids.length === 0)
|
|
311
|
+
return [];
|
|
312
|
+
const docs = await this.db
|
|
313
|
+
.select()
|
|
314
|
+
.from(documentVersions)
|
|
315
|
+
.where(inArray(documentVersions.id, document_version_ids));
|
|
316
|
+
return this.reconstructDocuments({ documents: docs, locale });
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* getDocumentsByDocumentIds — batch-fetch current versions for a list of
|
|
320
|
+
* logical document IDs, with optional selective field loading.
|
|
321
|
+
*
|
|
322
|
+
* Resolves each document_id to its current version via the
|
|
323
|
+
* `current_documents` view (soft-deleted documents are excluded by the
|
|
324
|
+
* view definition), then delegates to `reconstructDocuments` for the
|
|
325
|
+
* shared field + meta reconstruction path.
|
|
326
|
+
*
|
|
327
|
+
* Primary consumer is the client API's relationship populate pass —
|
|
328
|
+
* `store_relation` rows carry `target_document_id` (not version ID), so
|
|
329
|
+
* populate collects those IDs and resolves them here in one round trip.
|
|
330
|
+
*/
|
|
331
|
+
async getDocumentsByDocumentIds({ collection_id, document_ids, locale = 'all', fields, readMode, filters, }) {
|
|
332
|
+
if (document_ids.length === 0)
|
|
333
|
+
return [];
|
|
334
|
+
const view = this.pickCurrentView(readMode);
|
|
335
|
+
// The locale used to compile filter EXISTS subqueries should resolve
|
|
336
|
+
// values from a real locale, even when the surrounding read uses the
|
|
337
|
+
// sentinel `'all'` (populate batches that span every locale do this).
|
|
338
|
+
// Falling back to `'en'` here matches the default used by the
|
|
339
|
+
// single-doc lookup methods.
|
|
340
|
+
const filterLocale = locale === 'all' ? 'en' : locale;
|
|
341
|
+
const baseConditions = [
|
|
342
|
+
eq(view.collection_id, collection_id),
|
|
343
|
+
inArray(view.document_id, document_ids),
|
|
344
|
+
];
|
|
345
|
+
if (filters?.length) {
|
|
346
|
+
const outerScope = {
|
|
347
|
+
docVersionId: sql `${view.id}`,
|
|
348
|
+
status: sql `${view.status}`,
|
|
349
|
+
path: sql `${view.path}`,
|
|
350
|
+
};
|
|
351
|
+
for (const f of filters) {
|
|
352
|
+
baseConditions.push(this.buildFilterExists(f, filterLocale, outerScope, readMode, 0));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const docs = await this.db
|
|
356
|
+
.select()
|
|
357
|
+
.from(view)
|
|
358
|
+
.where(and(...baseConditions));
|
|
359
|
+
return this.reconstructDocuments({ documents: docs, locale, fields });
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* getDocumentHistory — paginated version history for a document,
|
|
363
|
+
* including soft-deleted versions.
|
|
364
|
+
*/
|
|
365
|
+
async getDocumentHistory({ collection_id, document_id, locale = 'all', page = 1, page_size = 20, order = 'updated_at', desc = true, }) {
|
|
366
|
+
const collection = await this.db.query.collections.findFirst({
|
|
367
|
+
where: eq(collections.id, collection_id),
|
|
368
|
+
});
|
|
369
|
+
if (collection == null || collection.config == null) {
|
|
370
|
+
throw ERR_NOT_FOUND({
|
|
371
|
+
message: `collection not found or missing config: ${collection_id}`,
|
|
372
|
+
details: { collectionId: collection_id },
|
|
373
|
+
}).log(getLogger());
|
|
374
|
+
}
|
|
375
|
+
const totalResult = await this.db
|
|
376
|
+
.select({
|
|
377
|
+
count: sql `count(*)`,
|
|
378
|
+
})
|
|
379
|
+
.from(documentVersions)
|
|
380
|
+
.where(and(eq(documentVersions.collection_id, collection_id), eq(documentVersions.document_id, document_id)));
|
|
381
|
+
const total = Number(totalResult[0]?.count) || 0;
|
|
382
|
+
const total_pages = Math.ceil(total / page_size);
|
|
383
|
+
const offset = (page - 1) * page_size;
|
|
384
|
+
const orderColumn = order === 'path' ? documentVersions.path : documentVersions.created_at;
|
|
385
|
+
const orderFunc = desc === true ? sql `DESC` : sql `ASC`;
|
|
386
|
+
const result = await this.db
|
|
387
|
+
.select()
|
|
388
|
+
.from(documentVersions)
|
|
389
|
+
.where(and(eq(documentVersions.collection_id, collection_id), eq(documentVersions.document_id, document_id)))
|
|
390
|
+
.orderBy(sql `${orderColumn} ${orderFunc}`)
|
|
391
|
+
.limit(page_size)
|
|
392
|
+
.offset(offset);
|
|
393
|
+
const history = await this.reconstructDocuments({ documents: result, locale });
|
|
394
|
+
return {
|
|
395
|
+
documents: history,
|
|
396
|
+
meta: { total, page, page_size, total_pages, order, desc },
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* getPublishedVersion
|
|
401
|
+
*
|
|
402
|
+
* Find the latest version of a document that has a specific status
|
|
403
|
+
* (defaults to 'published'). Queries `document_versions` directly so it
|
|
404
|
+
* can find a published version even when a newer draft exists.
|
|
405
|
+
*
|
|
406
|
+
* Returns minimal version metadata (not reconstructed content), or null
|
|
407
|
+
* if no version with the requested status exists.
|
|
408
|
+
*/
|
|
409
|
+
async getPublishedVersion({ collection_id, document_id, status = 'published', }) {
|
|
410
|
+
const [row] = await this.db
|
|
411
|
+
.select({
|
|
412
|
+
document_version_id: documentVersions.id,
|
|
413
|
+
document_id: documentVersions.document_id,
|
|
414
|
+
status: documentVersions.status,
|
|
415
|
+
created_at: documentVersions.created_at,
|
|
416
|
+
updated_at: documentVersions.updated_at,
|
|
417
|
+
})
|
|
418
|
+
.from(documentVersions)
|
|
419
|
+
.where(and(eq(documentVersions.collection_id, collection_id), eq(documentVersions.document_id, document_id), eq(documentVersions.status, status), eq(documentVersions.is_deleted, false)))
|
|
420
|
+
.orderBy(sql `${documentVersions.id} DESC`)
|
|
421
|
+
.limit(1);
|
|
422
|
+
if (!row)
|
|
423
|
+
return null;
|
|
424
|
+
return {
|
|
425
|
+
document_version_id: row.document_version_id,
|
|
426
|
+
document_id: row.document_id,
|
|
427
|
+
status: row.status ?? 'draft',
|
|
428
|
+
created_at: row.created_at ?? new Date(),
|
|
429
|
+
updated_at: row.updated_at ?? new Date(),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* getPublishedDocumentIds
|
|
434
|
+
*
|
|
435
|
+
* Given a list of document IDs, return the subset that have at least one
|
|
436
|
+
* version with the requested status (defaults to 'published'). Uses a
|
|
437
|
+
* single batch query instead of per-document lookups.
|
|
438
|
+
*/
|
|
439
|
+
async getPublishedDocumentIds({ collection_id, document_ids, status = 'published', }) {
|
|
440
|
+
if (document_ids.length === 0)
|
|
441
|
+
return new Set();
|
|
442
|
+
const rows = await this.db
|
|
443
|
+
.select({ document_id: documentVersions.document_id })
|
|
444
|
+
.from(documentVersions)
|
|
445
|
+
.where(and(inArray(documentVersions.document_id, document_ids), eq(documentVersions.collection_id, collection_id), eq(documentVersions.status, status), eq(documentVersions.is_deleted, false)))
|
|
446
|
+
.groupBy(documentVersions.document_id);
|
|
447
|
+
return new Set(rows.map((r) => r.document_id));
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* getDocumentCountsByStatus
|
|
451
|
+
*
|
|
452
|
+
* Returns a count of current documents grouped by workflow status for a
|
|
453
|
+
* given collection. Uses the `current_documents` view so each logical
|
|
454
|
+
* document is counted once (at its latest/current version).
|
|
455
|
+
*/
|
|
456
|
+
async getDocumentCountsByStatus({ collection_id, filters, }) {
|
|
457
|
+
const conditions = [eq(currentDocumentsView.collection_id, collection_id)];
|
|
458
|
+
if (filters?.length) {
|
|
459
|
+
const outerScope = {
|
|
460
|
+
docVersionId: sql `${currentDocumentsView.id}`,
|
|
461
|
+
status: sql `${currentDocumentsView.status}`,
|
|
462
|
+
path: sql `${currentDocumentsView.path}`,
|
|
463
|
+
};
|
|
464
|
+
for (const f of filters) {
|
|
465
|
+
conditions.push(this.buildFilterExists(f, 'en', outerScope, undefined, 0));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const rows = await this.db
|
|
469
|
+
.select({
|
|
470
|
+
status: currentDocumentsView.status,
|
|
471
|
+
count: sql `count(*)::int`,
|
|
472
|
+
})
|
|
473
|
+
.from(currentDocumentsView)
|
|
474
|
+
.where(and(...conditions))
|
|
475
|
+
.groupBy(currentDocumentsView.status);
|
|
476
|
+
return rows.map((r) => ({
|
|
477
|
+
status: r.status ?? 'unknown',
|
|
478
|
+
count: r.count,
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* reconstructDocuments — retrieve field values and reconstruct multiple documents.
|
|
483
|
+
* Supports selective field loading via the `fields` parameter.
|
|
484
|
+
*/
|
|
485
|
+
async reconstructDocuments({ documents, locale = 'all', fields: requestedFields, }) {
|
|
486
|
+
if (documents.length === 0)
|
|
487
|
+
return [];
|
|
488
|
+
const versionIds = documents.map((v) => v.id);
|
|
489
|
+
// Resolve definition once for the batch (safe — early return above guarantees length > 0)
|
|
490
|
+
const firstDoc = documents[0];
|
|
491
|
+
const definition = await this.getDefinitionForCollection(firstDoc.collection_id);
|
|
492
|
+
// When specific fields are requested, resolve which store tables we need
|
|
493
|
+
// and query only those — skipping irrelevant tables entirely.
|
|
494
|
+
const storeTypes = requestedFields?.length
|
|
495
|
+
? resolveStoreTypes(definition.fields, requestedFields)
|
|
496
|
+
: undefined;
|
|
497
|
+
// Get field values for all versions in one query
|
|
498
|
+
const allFieldValues = await this.getAllFieldValuesForMultipleVersions(versionIds, locale, storeTypes);
|
|
499
|
+
// Group field values by document version
|
|
500
|
+
const fieldValuesByVersion = new Map();
|
|
501
|
+
for (const fieldValue of allFieldValues) {
|
|
502
|
+
if (!fieldValuesByVersion.has(fieldValue.document_version_id)) {
|
|
503
|
+
fieldValuesByVersion.set(fieldValue.document_version_id, []);
|
|
504
|
+
}
|
|
505
|
+
fieldValuesByVersion.get(fieldValue.document_version_id)?.push(fieldValue);
|
|
506
|
+
}
|
|
507
|
+
// Fetch meta rows for all versions in one query
|
|
508
|
+
const allMetaRows = await this.db
|
|
509
|
+
.select({
|
|
510
|
+
document_version_id: metaStore.document_version_id,
|
|
511
|
+
type: metaStore.type,
|
|
512
|
+
path: metaStore.path,
|
|
513
|
+
item_id: metaStore.item_id,
|
|
514
|
+
meta: metaStore.meta,
|
|
515
|
+
})
|
|
516
|
+
.from(metaStore)
|
|
517
|
+
.where(inArray(metaStore.document_version_id, versionIds));
|
|
518
|
+
const metaByVersion = new Map();
|
|
519
|
+
for (const row of allMetaRows) {
|
|
520
|
+
const list = metaByVersion.get(row.document_version_id) ?? [];
|
|
521
|
+
list.push({
|
|
522
|
+
type: row.type,
|
|
523
|
+
path: row.path,
|
|
524
|
+
item_id: row.item_id,
|
|
525
|
+
meta: row.meta,
|
|
526
|
+
});
|
|
527
|
+
if (!metaByVersion.has(row.document_version_id)) {
|
|
528
|
+
metaByVersion.set(row.document_version_id, list);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// Reconstruct each document with document data at root level
|
|
532
|
+
const result = [];
|
|
533
|
+
for (const doc of documents) {
|
|
534
|
+
const versionFieldValues = fieldValuesByVersion.get(doc.id) || [];
|
|
535
|
+
const docMetaRows = (metaByVersion.get(doc.id) ?? []);
|
|
536
|
+
const fields = this.reconstructFromUnifiedRows(versionFieldValues, definition, locale, docMetaRows);
|
|
537
|
+
// When specific fields were requested, trim the reconstructed object
|
|
538
|
+
// to only those fields. Store-level filtering avoids querying unused
|
|
539
|
+
// tables, but fields sharing a store (e.g. price + views in numeric)
|
|
540
|
+
// still appear — this final pass removes them.
|
|
541
|
+
const trimmedFields = requestedFields?.length
|
|
542
|
+
? Object.fromEntries(Object.entries(fields).filter(([k]) => requestedFields.includes(k)))
|
|
543
|
+
: fields;
|
|
544
|
+
const documentWithFields = {
|
|
545
|
+
document_version_id: doc.id,
|
|
546
|
+
document_id: doc.document_id,
|
|
547
|
+
path: doc.path,
|
|
548
|
+
status: doc.status,
|
|
549
|
+
created_at: doc.created_at,
|
|
550
|
+
updated_at: doc.updated_at,
|
|
551
|
+
fields: trimmedFields,
|
|
552
|
+
};
|
|
553
|
+
result.push(documentWithFields);
|
|
554
|
+
}
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Gets all field values for a single document version.
|
|
559
|
+
* Delegates to the multi-version dynamic UNION ALL builder.
|
|
560
|
+
*/
|
|
561
|
+
async getAllFieldValues(documentVersionId, locale = 'all') {
|
|
562
|
+
return this.getAllFieldValuesForMultipleVersions([documentVersionId], locale);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Gets field values for multiple versions in a single query.
|
|
566
|
+
*
|
|
567
|
+
* When `storeTypes` is provided, only those store tables are included in
|
|
568
|
+
* the UNION ALL — this is the selective field loading optimisation for
|
|
569
|
+
* list views that only need a subset of fields.
|
|
570
|
+
*/
|
|
571
|
+
async getAllFieldValuesForMultipleVersions(documentVersionIds, locale = 'all', storeTypes) {
|
|
572
|
+
if (documentVersionIds.length === 0)
|
|
573
|
+
return [];
|
|
574
|
+
const localeCondition = locale === 'all' ? sql `` : sql `AND (locale = ${locale} OR locale = 'all')`;
|
|
575
|
+
const documentCondition = sql `document_version_id = ANY(ARRAY[${sql.join(documentVersionIds.map((id) => sql `${id}::uuid`), sql `, `)}])`;
|
|
576
|
+
const typesToQuery = storeTypes ?? new Set(allStoreTypes);
|
|
577
|
+
// Build UNION ALL from only the required store tables.
|
|
578
|
+
const fragments = [];
|
|
579
|
+
for (const st of allStoreTypes) {
|
|
580
|
+
if (!typesToQuery.has(st))
|
|
581
|
+
continue;
|
|
582
|
+
fragments.push(sql `SELECT ${storeSelectList(st)} FROM ${sql.raw(storeTableNames[st])} WHERE ${documentCondition} ${localeCondition}`);
|
|
583
|
+
}
|
|
584
|
+
if (fragments.length === 0)
|
|
585
|
+
return [];
|
|
586
|
+
// Join with UNION ALL
|
|
587
|
+
let unionQuery = fragments[0];
|
|
588
|
+
for (let i = 1; i < fragments.length; i++) {
|
|
589
|
+
unionQuery = sql `${unionQuery} UNION ALL ${fragments[i]}`;
|
|
590
|
+
}
|
|
591
|
+
const query = sql `${unionQuery} ORDER BY document_version_id, field_path, locale`;
|
|
592
|
+
const { rows } = await this.db.execute(query);
|
|
593
|
+
return rows;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* findDocuments — field-level filtered, sorted, paginated query.
|
|
597
|
+
*
|
|
598
|
+
* Each `FieldFilter` becomes an EXISTS subquery against the appropriate EAV
|
|
599
|
+
* store table. A `RelationFilter` becomes a nested EXISTS that joins
|
|
600
|
+
* `store_relation` to the target collection's current-documents view
|
|
601
|
+
* (selected by `readMode` so draft leaks can't happen through filter
|
|
602
|
+
* predicates) and recurses into its own `nested` filters. A `FieldSort`
|
|
603
|
+
* becomes a LEFT JOIN LATERAL to pull the sort value into the outer query.
|
|
604
|
+
* Document-level conditions (status, path) are applied directly on the
|
|
605
|
+
* current_documents view.
|
|
606
|
+
*/
|
|
607
|
+
async findDocuments({ collection_id, filters = [], status, pathFilter, query, sort, orderBy = 'created_at', orderDirection = 'desc', locale = 'en', page = 1, pageSize = 20, fields: requestedFields, readMode, }) {
|
|
608
|
+
const offset = (page - 1) * pageSize;
|
|
609
|
+
const sourceTable = readMode === 'published'
|
|
610
|
+
? sql.raw('byline_current_published_documents')
|
|
611
|
+
: sql.raw('byline_current_documents');
|
|
612
|
+
// -- Build WHERE conditions -----------------------------------------------
|
|
613
|
+
const conditions = [sql `d.collection_id = ${collection_id}`];
|
|
614
|
+
if (status) {
|
|
615
|
+
conditions.push(sql `d.status = ${status}`);
|
|
616
|
+
}
|
|
617
|
+
if (pathFilter) {
|
|
618
|
+
conditions.push(this.buildDocumentLevelCondition('d.path', pathFilter.operator, pathFilter.value));
|
|
619
|
+
}
|
|
620
|
+
// Text search across configured search fields via EXISTS on store_text.
|
|
621
|
+
if (query) {
|
|
622
|
+
const definition = await this.getDefinitionForCollection(collection_id);
|
|
623
|
+
const searchFields = definition.search?.fields ?? ['title'];
|
|
624
|
+
const searchConditions = searchFields.map((fieldName) => sql `(field_name = ${fieldName} AND value ILIKE ${`%${query}%`})`);
|
|
625
|
+
conditions.push(sql `EXISTS (
|
|
626
|
+
SELECT 1 FROM byline_store_text
|
|
627
|
+
WHERE document_version_id = d.id
|
|
628
|
+
AND (locale = ${locale} OR locale = 'all')
|
|
629
|
+
AND (${sql.join(searchConditions, sql ` OR `)})
|
|
630
|
+
)`);
|
|
631
|
+
}
|
|
632
|
+
// Field-level / relation-level EXISTS subqueries. Each relation hop
|
|
633
|
+
// introduces its own alias scope (`r${depth}`, `td${depth}`) so nested
|
|
634
|
+
// EXISTS clauses don't shadow their outer relation's aliases.
|
|
635
|
+
for (const filter of filters) {
|
|
636
|
+
conditions.push(this.buildFilterExists(filter, locale, { docVersionId: sql `d.id`, status: sql `d.status`, path: sql `d.path` }, readMode, 0));
|
|
637
|
+
}
|
|
638
|
+
const whereClause = sql.join(conditions, sql ` AND `);
|
|
639
|
+
// -- Build ORDER BY -------------------------------------------------------
|
|
640
|
+
let orderClause;
|
|
641
|
+
let sortJoin = sql ``;
|
|
642
|
+
if (sort) {
|
|
643
|
+
// Field-level sort via LEFT JOIN LATERAL
|
|
644
|
+
const storeTable = storeTableNames[sort.storeType];
|
|
645
|
+
if (storeTable) {
|
|
646
|
+
sortJoin = sql `LEFT JOIN LATERAL (
|
|
647
|
+
SELECT ${sql.raw(sort.valueColumn)} AS _sort_value
|
|
648
|
+
FROM ${sql.raw(storeTable)}
|
|
649
|
+
WHERE document_version_id = d.id
|
|
650
|
+
AND field_name = ${sort.fieldName}
|
|
651
|
+
AND (locale = ${locale} OR locale = 'all')
|
|
652
|
+
LIMIT 1
|
|
653
|
+
) _sort ON true`;
|
|
654
|
+
orderClause =
|
|
655
|
+
sort.direction === 'desc'
|
|
656
|
+
? sql `_sort._sort_value DESC NULLS LAST`
|
|
657
|
+
: sql `_sort._sort_value ASC NULLS LAST`;
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
// Unrecognised store type — fall back to document-level sort
|
|
661
|
+
orderClause = this.buildDocumentOrderClause(orderBy, orderDirection);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
orderClause = this.buildDocumentOrderClause(orderBy, orderDirection);
|
|
666
|
+
}
|
|
667
|
+
// -- Count query ----------------------------------------------------------
|
|
668
|
+
const countQuery = sql `
|
|
669
|
+
SELECT count(*)::int AS total
|
|
670
|
+
FROM ${sourceTable} d
|
|
671
|
+
${sortJoin}
|
|
672
|
+
WHERE ${whereClause}
|
|
673
|
+
`;
|
|
674
|
+
const countResult = await this.db.execute(countQuery);
|
|
675
|
+
const total = countResult.rows[0]?.total ?? 0;
|
|
676
|
+
if (total === 0) {
|
|
677
|
+
return { documents: [], total: 0 };
|
|
678
|
+
}
|
|
679
|
+
// -- Main query -----------------------------------------------------------
|
|
680
|
+
const mainQuery = sql `
|
|
681
|
+
SELECT d.*
|
|
682
|
+
FROM ${sourceTable} d
|
|
683
|
+
${sortJoin}
|
|
684
|
+
WHERE ${whereClause}
|
|
685
|
+
ORDER BY ${orderClause}
|
|
686
|
+
LIMIT ${pageSize}
|
|
687
|
+
OFFSET ${offset}
|
|
688
|
+
`;
|
|
689
|
+
const { rows } = await this.db.execute(mainQuery);
|
|
690
|
+
const currentDocuments = rows.map((row) => ({
|
|
691
|
+
id: row.id,
|
|
692
|
+
document_id: row.document_id,
|
|
693
|
+
collection_id: row.collection_id,
|
|
694
|
+
collection_version: row.collection_version,
|
|
695
|
+
path: row.path,
|
|
696
|
+
event_type: row.event_type,
|
|
697
|
+
status: row.status,
|
|
698
|
+
is_deleted: row.is_deleted,
|
|
699
|
+
created_at: new Date(row.created_at),
|
|
700
|
+
updated_at: new Date(row.updated_at),
|
|
701
|
+
created_by: row.created_by,
|
|
702
|
+
change_summary: row.change_summary,
|
|
703
|
+
}));
|
|
704
|
+
const documents = await this.reconstructDocuments({
|
|
705
|
+
documents: currentDocuments,
|
|
706
|
+
locale,
|
|
707
|
+
fields: requestedFields,
|
|
708
|
+
});
|
|
709
|
+
return { documents, total };
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Build an EXISTS subquery for a single DocumentFilter. Dispatches on
|
|
713
|
+
* `kind` — field filters emit a direct EXISTS against the field's EAV
|
|
714
|
+
* store; relation filters emit a nested EXISTS that joins through
|
|
715
|
+
* `store_relation` to the target collection's current-documents view
|
|
716
|
+
* and recurses against the target's own stores; combinator filters
|
|
717
|
+
* emit a parenthesised AND/OR group; document-column filters emit a
|
|
718
|
+
* direct comparison on the outer scope's status/path column.
|
|
719
|
+
*
|
|
720
|
+
* `outerScope` carries SQL references to the enclosing scope's
|
|
721
|
+
* `document_version_id`, `status`, and `path` — `d.id`/`d.status`/
|
|
722
|
+
* `d.path` at the top level, the equivalent column references on the
|
|
723
|
+
* Drizzle view for single-doc lookups, and `td${n}.…` inside relation
|
|
724
|
+
* hops. `depth` is the current relation-nesting level; each relation
|
|
725
|
+
* hop bumps it so aliases stay unique across nested EXISTS scopes
|
|
726
|
+
* (Postgres would otherwise resolve `td.id` to the innermost `td`,
|
|
727
|
+
* silently producing the wrong rows).
|
|
728
|
+
*/
|
|
729
|
+
buildFilterExists(filter, locale, outerScope, readMode, depth) {
|
|
730
|
+
switch (filter.kind) {
|
|
731
|
+
case 'field':
|
|
732
|
+
return this.buildFieldExists(filter, locale, outerScope.docVersionId);
|
|
733
|
+
case 'relation':
|
|
734
|
+
return this.buildRelationExists(filter, locale, outerScope, readMode, depth);
|
|
735
|
+
case 'and':
|
|
736
|
+
case 'or':
|
|
737
|
+
return this.buildCombinatorGroup(filter, locale, outerScope, readMode, depth);
|
|
738
|
+
case 'docColumn':
|
|
739
|
+
return this.buildDocColumnFilter(filter, outerScope);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Build a parenthesised AND/OR group from a CombinatorFilter. Each child
|
|
744
|
+
* compiles through `buildFilterExists` recursively, so combinators nest
|
|
745
|
+
* freely and inherit the outer scope.
|
|
746
|
+
*
|
|
747
|
+
* An empty `children` array would emit `()` and produce a syntax error,
|
|
748
|
+
* so callers (the parser) skip empty groups; this method assumes at
|
|
749
|
+
* least one child by construction.
|
|
750
|
+
*/
|
|
751
|
+
buildCombinatorGroup(filter, locale, outerScope, readMode, depth) {
|
|
752
|
+
const childSql = filter.children.map((child) => this.buildFilterExists(child, locale, outerScope, readMode, depth));
|
|
753
|
+
const joiner = filter.kind === 'or' ? sql ` OR ` : sql ` AND `;
|
|
754
|
+
return sql `(${sql.join(childSql, joiner)})`;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Compile a `DocumentColumnFilter` against the outer scope's status or
|
|
758
|
+
* path column. Plain comparison — no EXISTS — because the column lives
|
|
759
|
+
* directly on the outer relation (`document_versions` row), not in the
|
|
760
|
+
* EAV stores.
|
|
761
|
+
*/
|
|
762
|
+
buildDocColumnFilter(filter, outerScope) {
|
|
763
|
+
const column = filter.column === 'status' ? outerScope.status : outerScope.path;
|
|
764
|
+
return this.buildFilterCondition(column, filter.operator, filter.value);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Build an EXISTS subquery for a single field-level filter.
|
|
768
|
+
*/
|
|
769
|
+
buildFieldExists(filter, locale, outerDocVersionId) {
|
|
770
|
+
const storeTable = storeTableNames[filter.storeType];
|
|
771
|
+
if (!storeTable) {
|
|
772
|
+
throw ERR_DATABASE({
|
|
773
|
+
message: `unknown store type: ${filter.storeType}`,
|
|
774
|
+
details: { storeType: filter.storeType },
|
|
775
|
+
}).log(getLogger());
|
|
776
|
+
}
|
|
777
|
+
const valueCol = sql.raw(filter.valueColumn);
|
|
778
|
+
const condition = this.buildFilterCondition(valueCol, filter.operator, filter.value);
|
|
779
|
+
return sql `EXISTS (
|
|
780
|
+
SELECT 1 FROM ${sql.raw(storeTable)}
|
|
781
|
+
WHERE document_version_id = ${outerDocVersionId}
|
|
782
|
+
AND field_name = ${filter.fieldName}
|
|
783
|
+
AND (locale = ${locale} OR locale = 'all')
|
|
784
|
+
AND ${condition}
|
|
785
|
+
)`;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Build a nested EXISTS subquery for a cross-collection relation filter.
|
|
789
|
+
*
|
|
790
|
+
* Joins `store_relation` to the target collection's current-documents
|
|
791
|
+
* view (`current_published_documents` under `readMode: 'published'`,
|
|
792
|
+
* `current_documents` otherwise — so a draft target doesn't leak when
|
|
793
|
+
* the outer read is in published mode), then recurses each nested
|
|
794
|
+
* filter against the target version's own `td.id`.
|
|
795
|
+
*
|
|
796
|
+
* With no nested filters this reduces to "source has any relation row
|
|
797
|
+
* at all on this field pointing at a target that resolves in the
|
|
798
|
+
* selected view" — useful as a base case but more typically the
|
|
799
|
+
* nested list carries a predicate.
|
|
800
|
+
*/
|
|
801
|
+
buildRelationExists(filter, locale, outerScope, readMode, depth) {
|
|
802
|
+
const targetView = readMode === 'published'
|
|
803
|
+
? sql.raw('byline_current_published_documents')
|
|
804
|
+
: sql.raw('byline_current_documents');
|
|
805
|
+
// Use depth-scoped aliases so nested relations don't shadow their
|
|
806
|
+
// outer scope. e.g. outer relation gets `r0`/`td0`; a relation filter
|
|
807
|
+
// nested inside that gets `r1`/`td1`.
|
|
808
|
+
const rAlias = sql.raw(`r${depth}`);
|
|
809
|
+
const tdAlias = sql.raw(`td${depth}`);
|
|
810
|
+
const innerScope = {
|
|
811
|
+
docVersionId: sql.raw(`td${depth}.id`),
|
|
812
|
+
status: sql.raw(`td${depth}.status`),
|
|
813
|
+
path: sql.raw(`td${depth}.path`),
|
|
814
|
+
};
|
|
815
|
+
const nestedConditions = filter.nested.map((nested) => this.buildFilterExists(nested, locale, innerScope, readMode, depth + 1));
|
|
816
|
+
const nestedAnd = nestedConditions.length > 0 ? sql ` AND ${sql.join(nestedConditions, sql ` AND `)}` : sql ``;
|
|
817
|
+
return sql `EXISTS (
|
|
818
|
+
SELECT 1 FROM byline_store_relation ${rAlias}
|
|
819
|
+
JOIN ${targetView} ${tdAlias}
|
|
820
|
+
ON ${tdAlias}.document_id = ${rAlias}.target_document_id
|
|
821
|
+
AND ${tdAlias}.collection_id = ${rAlias}.target_collection_id
|
|
822
|
+
WHERE ${rAlias}.document_version_id = ${outerScope.docVersionId}
|
|
823
|
+
AND ${rAlias}.field_name = ${filter.fieldName}
|
|
824
|
+
AND ${rAlias}.target_collection_id = ${filter.targetCollectionId}
|
|
825
|
+
AND (${rAlias}.locale = ${locale} OR ${rAlias}.locale = 'all')${nestedAnd}
|
|
826
|
+
)`;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Build a comparison condition for a filter operator.
|
|
830
|
+
*/
|
|
831
|
+
buildFilterCondition(column, operator, value) {
|
|
832
|
+
switch (operator) {
|
|
833
|
+
case '$eq':
|
|
834
|
+
return value === null ? sql `${column} IS NULL` : sql `${column} = ${value}`;
|
|
835
|
+
case '$ne':
|
|
836
|
+
return value === null ? sql `${column} IS NOT NULL` : sql `${column} != ${value}`;
|
|
837
|
+
case '$gt':
|
|
838
|
+
return sql `${column} > ${value}`;
|
|
839
|
+
case '$gte':
|
|
840
|
+
return sql `${column} >= ${value}`;
|
|
841
|
+
case '$lt':
|
|
842
|
+
return sql `${column} < ${value}`;
|
|
843
|
+
case '$lte':
|
|
844
|
+
return sql `${column} <= ${value}`;
|
|
845
|
+
case '$contains':
|
|
846
|
+
return sql `${column} ILIKE ${`%${String(value)}%`}`;
|
|
847
|
+
case '$in': {
|
|
848
|
+
const arr = value;
|
|
849
|
+
return sql `${column} = ANY(${arr})`;
|
|
850
|
+
}
|
|
851
|
+
case '$nin': {
|
|
852
|
+
const arr = value;
|
|
853
|
+
return sql `${column} != ALL(${arr})`;
|
|
854
|
+
}
|
|
855
|
+
default:
|
|
856
|
+
throw ERR_DATABASE({
|
|
857
|
+
message: `unsupported filter operator: ${operator}`,
|
|
858
|
+
details: { operator },
|
|
859
|
+
}).log(getLogger());
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Build a condition for a document-level column (status, path).
|
|
864
|
+
*/
|
|
865
|
+
buildDocumentLevelCondition(column, operator, value) {
|
|
866
|
+
const col = sql.raw(column);
|
|
867
|
+
return this.buildFilterCondition(col, operator, value);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Build an ORDER BY clause for a document-level column.
|
|
871
|
+
*/
|
|
872
|
+
buildDocumentOrderClause(orderBy, direction) {
|
|
873
|
+
const columnMap = {
|
|
874
|
+
created_at: 'd.created_at',
|
|
875
|
+
updated_at: 'd.updated_at',
|
|
876
|
+
path: 'd.path',
|
|
877
|
+
};
|
|
878
|
+
const col = columnMap[orderBy] ?? 'd.created_at';
|
|
879
|
+
return direction === 'desc' ? sql `${sql.raw(col)} DESC` : sql `${sql.raw(col)} ASC`;
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Converts a union field row - back into an array of FlattenedStore
|
|
883
|
+
* that the reconstruction utilities expect
|
|
884
|
+
*/
|
|
885
|
+
convertUnionRowToFlattenedStores(unionRowValues) {
|
|
886
|
+
return unionRowValues.map((row) => {
|
|
887
|
+
const baseValue = {
|
|
888
|
+
field_path: row.field_path,
|
|
889
|
+
field_name: row.field_name,
|
|
890
|
+
locale: row.locale,
|
|
891
|
+
parent_path: row.parent_path ?? undefined,
|
|
892
|
+
};
|
|
893
|
+
switch (row.field_type) {
|
|
894
|
+
case 'text':
|
|
895
|
+
return {
|
|
896
|
+
...baseValue,
|
|
897
|
+
field_type: 'text',
|
|
898
|
+
value: row.text_value,
|
|
899
|
+
};
|
|
900
|
+
case 'richText':
|
|
901
|
+
return {
|
|
902
|
+
...baseValue,
|
|
903
|
+
field_type: 'richText',
|
|
904
|
+
value: row.json_value,
|
|
905
|
+
};
|
|
906
|
+
case 'numeric':
|
|
907
|
+
return {
|
|
908
|
+
...baseValue,
|
|
909
|
+
field_type: row.number_type,
|
|
910
|
+
number_type: row.number_type,
|
|
911
|
+
value_integer: row.value_integer,
|
|
912
|
+
value_decimal: row.value_decimal,
|
|
913
|
+
value_float: row.value_float,
|
|
914
|
+
};
|
|
915
|
+
case 'boolean':
|
|
916
|
+
return {
|
|
917
|
+
...baseValue,
|
|
918
|
+
field_type: 'boolean',
|
|
919
|
+
value: row.boolean_value,
|
|
920
|
+
};
|
|
921
|
+
case 'time':
|
|
922
|
+
case 'date':
|
|
923
|
+
case 'datetime':
|
|
924
|
+
return {
|
|
925
|
+
...baseValue,
|
|
926
|
+
field_type: row.date_type,
|
|
927
|
+
date_type: row.date_type,
|
|
928
|
+
value_time: row.value_time,
|
|
929
|
+
value_date: row.value_date,
|
|
930
|
+
value_timestamp_tz: row.value_timestamp_tz,
|
|
931
|
+
};
|
|
932
|
+
case 'image':
|
|
933
|
+
case 'file':
|
|
934
|
+
return {
|
|
935
|
+
...baseValue,
|
|
936
|
+
field_type: row.field_type,
|
|
937
|
+
file_id: row.file_id,
|
|
938
|
+
filename: row.filename,
|
|
939
|
+
original_filename: row.original_filename,
|
|
940
|
+
mime_type: row.mime_type,
|
|
941
|
+
file_size: row.file_size,
|
|
942
|
+
storage_provider: row.storage_provider,
|
|
943
|
+
storage_path: row.storage_path,
|
|
944
|
+
storage_url: row.storage_url,
|
|
945
|
+
file_hash: row.file_hash,
|
|
946
|
+
image_width: row.image_width,
|
|
947
|
+
image_height: row.image_height,
|
|
948
|
+
image_format: row.image_format,
|
|
949
|
+
processing_status: row.processing_status,
|
|
950
|
+
thumbnail_generated: row.thumbnail_generated,
|
|
951
|
+
};
|
|
952
|
+
case 'relation':
|
|
953
|
+
return {
|
|
954
|
+
...baseValue,
|
|
955
|
+
field_type: 'relation',
|
|
956
|
+
target_document_id: row.target_document_id,
|
|
957
|
+
target_collection_id: row.target_collection_id,
|
|
958
|
+
relationship_type: row.relationship_type,
|
|
959
|
+
cascade_delete: row.cascade_delete,
|
|
960
|
+
};
|
|
961
|
+
default:
|
|
962
|
+
throw ERR_DATABASE({
|
|
963
|
+
message: `unknown field type: ${row.field_type}`,
|
|
964
|
+
details: { fieldType: row.field_type },
|
|
965
|
+
}).log(getLogger());
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
export function createQueryBuilders(db, collections) {
|
|
971
|
+
return {
|
|
972
|
+
collections: new CollectionQueries(db),
|
|
973
|
+
documents: new DocumentQueries(db, collections),
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
//# sourceMappingURL=storage-queries.js.map
|