@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,1263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { DbErrorCodes, ERR_CONFLICT, ERR_NOT_FOUND, ERR_VALIDATION, flattenFieldSetData, generateKeyBetween, TREE_PLACEMENT_STALE_MARKER, } from '@byline/core';
|
|
9
|
+
import { and, desc, eq, inArray, ne, notInArray, sql } from 'drizzle-orm';
|
|
10
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
11
|
+
import { booleanStore, collections, currentDocumentsView, datetimeStore, documentAvailableLocales, documentPaths, documentRelationships, documents, documentVersions, fileStore, jsonStore, metaStore, numericStore, relationStore, textStore, } from '../../database/schema/index.js';
|
|
12
|
+
import { classifyError } from './classify-error.js';
|
|
13
|
+
import { prepareFieldInsertBuckets } from './storage-insert.js';
|
|
14
|
+
import { affectedRowCount, getFirstOrThrow } from './storage-utils.js';
|
|
15
|
+
/**
|
|
16
|
+
* Depth backstop for the document-tree recursive walks (cycle guard, ancestor
|
|
17
|
+
* walk). The write-path cycle guard prevents true cycles, so this only bounds
|
|
18
|
+
* recursion against pre-existing pathological state — far deeper than any real
|
|
19
|
+
* documentation hierarchy. Mirrors `packages/db-postgres/src/modules/storage/
|
|
20
|
+
* storage-commands.ts`. See docs/04-collections/04-document-trees.md.
|
|
21
|
+
*/
|
|
22
|
+
const TREE_MAX_DEPTH = 10_000;
|
|
23
|
+
const staleTreePlacementMessage = (message) => `${TREE_PLACEMENT_STALE_MARKER} tree placement is stale: ${message}`;
|
|
24
|
+
/**
|
|
25
|
+
* CollectionCommands
|
|
26
|
+
*
|
|
27
|
+
* Mirrors `packages/db-postgres/src/modules/storage/storage-commands.ts`'s
|
|
28
|
+
* `CollectionCommands`. MySQL has no `RETURNING` clause, so every write
|
|
29
|
+
* constructs its return value in JS instead — collection ids are
|
|
30
|
+
* app-generated UUIDv7, so `create` already knows every value it needs
|
|
31
|
+
* without a round trip. `update` re-`SELECT`s because `patch` is partial
|
|
32
|
+
* and the caller expects the merged row back.
|
|
33
|
+
*/
|
|
34
|
+
export class CollectionCommands {
|
|
35
|
+
dbManager;
|
|
36
|
+
constructor(dbManager) {
|
|
37
|
+
this.dbManager = dbManager;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The executor for this call — the ambient transaction when a
|
|
41
|
+
* `withTransaction` boundary is open, otherwise the pool. Resolved per
|
|
42
|
+
* access so every `this.db.*` below transparently joins an enclosing
|
|
43
|
+
* transaction with no call-site change. See docs/03-architecture/03-transactions.md.
|
|
44
|
+
*/
|
|
45
|
+
get db() {
|
|
46
|
+
return this.dbManager.get();
|
|
47
|
+
}
|
|
48
|
+
async create(path, config, opts) {
|
|
49
|
+
const id = uuidv7();
|
|
50
|
+
const version = opts?.version ?? 1;
|
|
51
|
+
const singular = config.labels.singular || path;
|
|
52
|
+
const plural = config.labels.plural || `${path}s`;
|
|
53
|
+
// `created_at`/`updated_at` are written explicitly rather than left to
|
|
54
|
+
// the column's `DEFAULT CURRENT_TIMESTAMP(6)` — an earlier version of
|
|
55
|
+
// this method approximated them with a request-time `Date` instead
|
|
56
|
+
// (reasoning: "no caller in this adapter's current scope depends on
|
|
57
|
+
// the authoritative value"), which is the exact reasoning that held for
|
|
58
|
+
// `DocumentCommands.createDocumentVersion` right up until a
|
|
59
|
+
// `db-conformance` fixture started windowing on `created_at` and an
|
|
60
|
+
// intermittent few-millisecond gap between the app-side capture and the
|
|
61
|
+
// DB-side `CURRENT_TIMESTAMP(6)` evaluation turned into a real flake —
|
|
62
|
+
// see that method's `created_at`/`updated_at` comment below for the
|
|
63
|
+
// live-server evidence. Writing the same `Date` instance to the column
|
|
64
|
+
// that gets returned makes the two identical by construction instead of
|
|
65
|
+
// two independent clock reads that can drift under load.
|
|
66
|
+
const now = new Date();
|
|
67
|
+
await this.db.insert(collections).values({
|
|
68
|
+
id,
|
|
69
|
+
path,
|
|
70
|
+
singular,
|
|
71
|
+
plural,
|
|
72
|
+
config,
|
|
73
|
+
version,
|
|
74
|
+
created_at: now,
|
|
75
|
+
updated_at: now,
|
|
76
|
+
...(opts?.schemaHash !== undefined ? { schema_hash: opts.schemaHash } : {}),
|
|
77
|
+
});
|
|
78
|
+
// `.returning()` has no MySQL equivalent — every value here is already
|
|
79
|
+
// known (app-generated id, caller-supplied config, and the `created_at`
|
|
80
|
+
// / `updated_at` just written above), so the row is constructed in JS
|
|
81
|
+
// rather than re-`SELECT`ed.
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
id,
|
|
85
|
+
path,
|
|
86
|
+
singular,
|
|
87
|
+
plural,
|
|
88
|
+
config,
|
|
89
|
+
version,
|
|
90
|
+
schema_hash: opts?.schemaHash ?? null,
|
|
91
|
+
created_at: now,
|
|
92
|
+
updated_at: now,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
async update(id, patch) {
|
|
97
|
+
const set = { updated_at: new Date() };
|
|
98
|
+
if (patch.config !== undefined)
|
|
99
|
+
set.config = patch.config;
|
|
100
|
+
if (patch.version !== undefined)
|
|
101
|
+
set.version = patch.version;
|
|
102
|
+
if (patch.schemaHash !== undefined)
|
|
103
|
+
set.schema_hash = patch.schemaHash;
|
|
104
|
+
await this.db.update(collections).set(set).where(eq(collections.id, id));
|
|
105
|
+
// MySQL has no `RETURNING` — re-`SELECT` the merged row since `patch` is
|
|
106
|
+
// partial and the caller expects the full row back.
|
|
107
|
+
const row = await this.db
|
|
108
|
+
.select()
|
|
109
|
+
.from(collections)
|
|
110
|
+
.where(eq(collections.id, id))
|
|
111
|
+
.then(getFirstOrThrow('Failed to load collection after update'));
|
|
112
|
+
return [row];
|
|
113
|
+
}
|
|
114
|
+
async delete(id) {
|
|
115
|
+
return await this.db.delete(collections).where(eq(collections.id, id));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* DocumentCommands
|
|
120
|
+
*
|
|
121
|
+
* Ported from `packages/db-postgres/src/modules/storage/storage-commands.ts`'s
|
|
122
|
+
* `DocumentCommands`. Task 9A landed `createDocumentVersion` and its private
|
|
123
|
+
* helpers; Task 9B (this task) ports the rest of the write surface —
|
|
124
|
+
* `updateDocumentPath`, `setDocumentAvailableLocales`, `setDocumentStatus`,
|
|
125
|
+
* `archivePublishedVersions`, `softDeleteDocument`, `deleteDocumentLocale`,
|
|
126
|
+
* `setOrderKey`, `placeTreeNode`, `removeFromTree`, and
|
|
127
|
+
* `promoteChildrenAndRemoveFromTree` — completing `IDocumentCommands`. The
|
|
128
|
+
* pg source's `reAnchorDocument` / `reAnchorDocuments` / `backfillVersionLocales`
|
|
129
|
+
* are deliberately NOT ported here: `@byline/db-conformance`'s own suites
|
|
130
|
+
* (`document-paths.ts`, `locale-fallback.ts`) document them as "Postgres-only
|
|
131
|
+
* maintenance operations documented as off the core `IDbAdapter` contract" —
|
|
132
|
+
* not something a conforming adapter is required to implement, and neither is
|
|
133
|
+
* a member of `IDocumentCommands`.
|
|
134
|
+
*/
|
|
135
|
+
export class DocumentCommands {
|
|
136
|
+
dbManager;
|
|
137
|
+
defaultContentLocale;
|
|
138
|
+
constructor(dbManager, defaultContentLocale) {
|
|
139
|
+
this.dbManager = dbManager;
|
|
140
|
+
this.defaultContentLocale = defaultContentLocale;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The executor for this call — the ambient transaction when a
|
|
144
|
+
* `withTransaction` boundary is open, otherwise the pool. Resolved per
|
|
145
|
+
* access so every `this.db.*` below transparently joins an enclosing
|
|
146
|
+
* transaction with no call-site change. See docs/03-architecture/03-transactions.md.
|
|
147
|
+
*/
|
|
148
|
+
get db() {
|
|
149
|
+
return this.dbManager.get();
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* createDocumentVersion
|
|
153
|
+
*
|
|
154
|
+
* Creates a new document or a new version of an existing document. Ported
|
|
155
|
+
* from `packages/db-postgres/src/modules/storage/storage-commands.ts`'s
|
|
156
|
+
* `DocumentCommands.createDocumentVersion` — see spec §2's normative
|
|
157
|
+
* conversion table, applied at each site below:
|
|
158
|
+
*
|
|
159
|
+
* - `.returning()` → construct-in-JS. Every id here is app-generated
|
|
160
|
+
* UUIDv7 (minted before the insert), so the value is already known —
|
|
161
|
+
* no re-`SELECT` is needed anywhere in this method.
|
|
162
|
+
* - `onConflictDoUpdate` (path upsert) → NOT `.onDuplicateKeyUpdate()`
|
|
163
|
+
* (found live: MySQL's `ON DUPLICATE KEY UPDATE` has no per-constraint
|
|
164
|
+
* targeting the way pg's `onConflictDoUpdate({ target })` does, so it
|
|
165
|
+
* would silently absorb a genuine cross-document path conflict instead
|
|
166
|
+
* of erroring). `writeDocumentPath` below does the targeting itself —
|
|
167
|
+
* see its docblock.
|
|
168
|
+
* - `ON CONFLICT (document_version_id, field_path, locale) DO NOTHING`
|
|
169
|
+
* (7 sites, the per-locale carry-forward) → MySQL has no per-row
|
|
170
|
+
* `gen_random_uuid()` to call from an `INSERT … SELECT`, and ids must
|
|
171
|
+
* be app-generated UUIDv7 — never `INSERT IGNORE`, which would swallow
|
|
172
|
+
* unrelated errors, and never a DB-generated id. So each site becomes
|
|
173
|
+
* a typed `SELECT` of the previous version's carry-forward rows,
|
|
174
|
+
* followed by a JS-side UUIDv7-per-row bulk `INSERT … ON DUPLICATE KEY
|
|
175
|
+
* UPDATE id = id` (drizzle's own documented no-op idiom for "do
|
|
176
|
+
* nothing" on MySQL) — see `copyForwardStoreRows` below, which the 7
|
|
177
|
+
* call sites share.
|
|
178
|
+
* - `::uuid` casts → dropped (MySQL ids are plain `CHAR(36)`).
|
|
179
|
+
*
|
|
180
|
+
* @param params - Options for creating the document
|
|
181
|
+
* @returns The created document and the number of field values inserted
|
|
182
|
+
*/
|
|
183
|
+
async createDocumentVersion(params) {
|
|
184
|
+
return await this.db.transaction(async (tx) => {
|
|
185
|
+
let documentId = params.documentId;
|
|
186
|
+
// 1. Create the main document if needed, and resolve the document's
|
|
187
|
+
// `source_locale` — its per-document data anchor. A brand-new document
|
|
188
|
+
// is anchored to the configured default content locale (the locale it is
|
|
189
|
+
// authored in; `createDocument` enforces create-in-default). An existing
|
|
190
|
+
// document carries its own anchor on `byline_documents`; read it so the
|
|
191
|
+
// path row and the completeness ledger below key off *this document's*
|
|
192
|
+
// source locale rather than the mutable global default. NULL (a row not
|
|
193
|
+
// yet touched by `backfillSourceLocales`) falls back to the configured
|
|
194
|
+
// default — the value it was implicitly authored against.
|
|
195
|
+
// See docs/07-internationalization/index.md.
|
|
196
|
+
let sourceLocale;
|
|
197
|
+
if (documentId == null) {
|
|
198
|
+
documentId = uuidv7();
|
|
199
|
+
sourceLocale = this.defaultContentLocale;
|
|
200
|
+
await tx.insert(documents).values({
|
|
201
|
+
id: documentId,
|
|
202
|
+
collection_id: params.collectionId,
|
|
203
|
+
order_key: params.orderKey ?? null,
|
|
204
|
+
source_locale: sourceLocale,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const existing = await tx
|
|
209
|
+
.select({ source_locale: documents.source_locale })
|
|
210
|
+
.from(documents)
|
|
211
|
+
.where(eq(documents.id, documentId))
|
|
212
|
+
.then(getFirstOrThrow('Failed to load document for new version'));
|
|
213
|
+
sourceLocale = existing.source_locale ?? this.defaultContentLocale;
|
|
214
|
+
}
|
|
215
|
+
// 2. Create the document version. The id is minted here (app-side
|
|
216
|
+
// UUIDv7), so the row is constructed in JS below rather than
|
|
217
|
+
// re-`SELECT`ed — MySQL has no `RETURNING`.
|
|
218
|
+
//
|
|
219
|
+
// `created_at`/`updated_at` are passed explicitly rather than left to
|
|
220
|
+
// the column's `DEFAULT CURRENT_TIMESTAMP(6)` — the same explicit-write
|
|
221
|
+
// fix `CollectionCommands.create` above now also carries, for the same
|
|
222
|
+
// underlying reason it was found here first: `occurred_at` on this row
|
|
223
|
+
// is a value the shared `db-conformance` audit activity-feed fixture
|
|
224
|
+
// (and any other caller windowing on `created_at`) treats as
|
|
225
|
+
// authoritative to derive query boundaries from the very `Date` this
|
|
226
|
+
// method returns. Leaving the column to the DB default means the
|
|
227
|
+
// *returned* timestamp (captured here, before the INSERT is even sent)
|
|
228
|
+
// and the *persisted* timestamp (evaluated by the MySQL server when the
|
|
229
|
+
// statement executes) are two independent clock reads that can diverge
|
|
230
|
+
// under load — confirmed live: under CPU contention this measured a
|
|
231
|
+
// 1–6ms gap between the two, enough to push a boundary row outside an
|
|
232
|
+
// inclusive `<=` window derived from the approximated value and
|
|
233
|
+
// intermittently fail `filters by date range on occurred_at`
|
|
234
|
+
// (`packages/db-conformance/src/suites/audit.ts`). Writing the same
|
|
235
|
+
// `Date` instance to the column that gets returned makes the two
|
|
236
|
+
// identical by construction — there is no longer a second clock to
|
|
237
|
+
// drift against.
|
|
238
|
+
const documentVersionId = uuidv7();
|
|
239
|
+
const createdAt = new Date();
|
|
240
|
+
await tx.insert(documentVersions).values({
|
|
241
|
+
id: documentVersionId,
|
|
242
|
+
document_id: documentId,
|
|
243
|
+
collection_id: params.collectionId,
|
|
244
|
+
collection_version: params.collectionVersion,
|
|
245
|
+
event_type: params.action ?? 'create',
|
|
246
|
+
status: params.status ?? 'draft',
|
|
247
|
+
created_by: params.createdBy ?? null,
|
|
248
|
+
created_at: createdAt,
|
|
249
|
+
updated_at: createdAt,
|
|
250
|
+
});
|
|
251
|
+
const documentVersion = {
|
|
252
|
+
id: documentVersionId,
|
|
253
|
+
document_id: documentId,
|
|
254
|
+
collection_id: params.collectionId,
|
|
255
|
+
collection_version: params.collectionVersion,
|
|
256
|
+
doc: null,
|
|
257
|
+
event_type: params.action ?? 'create',
|
|
258
|
+
status: params.status ?? 'draft',
|
|
259
|
+
is_deleted: false,
|
|
260
|
+
created_by: params.createdBy ?? null,
|
|
261
|
+
change_summary: null,
|
|
262
|
+
created_at: createdAt,
|
|
263
|
+
updated_at: createdAt,
|
|
264
|
+
};
|
|
265
|
+
// 2a. Upsert the document_paths row when a path is supplied. The path
|
|
266
|
+
// row lives under the document's `source_locale` (its data anchor),
|
|
267
|
+
// not the mutable global default — so a re-anchored document, or any
|
|
268
|
+
// document read after the global default is switched, still resolves by
|
|
269
|
+
// path. The lifecycle layer skips this param for non-source-locale
|
|
270
|
+
// (translation) saves. Unique-constraint violations on
|
|
271
|
+
// (collection_id, locale, path) bubble up as a MySQL ER_DUP_ENTRY error
|
|
272
|
+
// which the lifecycle wraps as ERR_PATH_CONFLICT (via `classifyError`).
|
|
273
|
+
if (params.path !== undefined) {
|
|
274
|
+
await this.writeDocumentPath(tx, {
|
|
275
|
+
documentId,
|
|
276
|
+
locale: sourceLocale,
|
|
277
|
+
collectionId: params.collectionId,
|
|
278
|
+
path: params.path,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// 2b. Replace the document_available_locales rows when an editorial set
|
|
282
|
+
// is supplied. Document-grain and sticky across versions: `undefined`
|
|
283
|
+
// leaves the existing set untouched (the lifecycle omits the param on
|
|
284
|
+
// saves that don't touch advertising), while an explicit array — empty
|
|
285
|
+
// included — replaces it wholesale. Deduplicated so a caller-supplied
|
|
286
|
+
// duplicate doesn't collide on the (document_id, locale) primary key.
|
|
287
|
+
if (params.availableLocales !== undefined) {
|
|
288
|
+
await this.writeDocumentAvailableLocales(tx, {
|
|
289
|
+
documentId,
|
|
290
|
+
collectionId: params.collectionId,
|
|
291
|
+
availableLocales: params.availableLocales,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// 3. Flatten the document data to field values
|
|
295
|
+
const flattenedFields = flattenFieldSetData(params.collectionConfig.fields, params.documentData, params.locale ?? 'all');
|
|
296
|
+
// 4. Batch-insert all field values, grouped by store type
|
|
297
|
+
const storeBuckets = prepareFieldInsertBuckets(flattenedFields, documentVersion.id, params.collectionId);
|
|
298
|
+
if (storeBuckets.text.length > 0) {
|
|
299
|
+
await tx.insert(textStore).values(storeBuckets.text);
|
|
300
|
+
}
|
|
301
|
+
if (storeBuckets.numeric.length > 0) {
|
|
302
|
+
await tx.insert(numericStore).values(storeBuckets.numeric);
|
|
303
|
+
}
|
|
304
|
+
if (storeBuckets.boolean.length > 0) {
|
|
305
|
+
await tx.insert(booleanStore).values(storeBuckets.boolean);
|
|
306
|
+
}
|
|
307
|
+
if (storeBuckets.datetime.length > 0) {
|
|
308
|
+
await tx.insert(datetimeStore).values(storeBuckets.datetime);
|
|
309
|
+
}
|
|
310
|
+
if (storeBuckets.file.length > 0) {
|
|
311
|
+
await tx.insert(fileStore).values(storeBuckets.file);
|
|
312
|
+
}
|
|
313
|
+
if (storeBuckets.relation.length > 0) {
|
|
314
|
+
await tx.insert(relationStore).values(storeBuckets.relation);
|
|
315
|
+
}
|
|
316
|
+
if (storeBuckets.json.length > 0) {
|
|
317
|
+
await tx.insert(jsonStore).values(storeBuckets.json);
|
|
318
|
+
}
|
|
319
|
+
if (storeBuckets.meta.length > 0) {
|
|
320
|
+
await tx.insert(metaStore).values(storeBuckets.meta);
|
|
321
|
+
}
|
|
322
|
+
// 5. Copy field-value rows for other locales from the previous version.
|
|
323
|
+
// When saving in a specific locale (e.g. 'fr'), only rows for that locale
|
|
324
|
+
// and locale='all' are written above. Any existing rows for other locales
|
|
325
|
+
// (e.g. 'en', 'es') from the previous version must be carried forward so
|
|
326
|
+
// per-locale content is not lost under immutable versioning. Seven store
|
|
327
|
+
// tables, one `copyForwardStoreRows` call each — see the method doc for
|
|
328
|
+
// why this isn't a single `INSERT … SELECT` the way pg does it.
|
|
329
|
+
if (params.previousVersionId && params.locale && params.locale !== 'all') {
|
|
330
|
+
const prevId = params.previousVersionId;
|
|
331
|
+
const newId = documentVersion.id;
|
|
332
|
+
const activeLoc = params.locale;
|
|
333
|
+
await this.copyForwardStoreRows(tx, textStore, prevId, newId, activeLoc);
|
|
334
|
+
await this.copyForwardStoreRows(tx, numericStore, prevId, newId, activeLoc);
|
|
335
|
+
await this.copyForwardStoreRows(tx, booleanStore, prevId, newId, activeLoc);
|
|
336
|
+
await this.copyForwardStoreRows(tx, datetimeStore, prevId, newId, activeLoc);
|
|
337
|
+
await this.copyForwardStoreRows(tx, jsonStore, prevId, newId, activeLoc);
|
|
338
|
+
await this.copyForwardStoreRows(tx, relationStore, prevId, newId, activeLoc);
|
|
339
|
+
await this.copyForwardStoreRows(tx, fileStore, prevId, newId, activeLoc);
|
|
340
|
+
}
|
|
341
|
+
// 6. Record the version's available content locales for
|
|
342
|
+
// `localeFallback: 'strict'` reads. A locale is "available" when it
|
|
343
|
+
// covers every localized field path the default content locale has
|
|
344
|
+
// (path-coverage). Derived from the *persisted* localized rows, so it
|
|
345
|
+
// accounts for the per-locale carry-forward in step 5 — not just the
|
|
346
|
+
// freshly-flattened locale. A version with no localized content at all
|
|
347
|
+
// records a single `'all'` sentinel (it renders identically in any
|
|
348
|
+
// locale). Status-blind by design — see docs/07-internationalization/index.md.
|
|
349
|
+
await this.writeVersionLocaleLedger(tx, documentVersion.id, sourceLocale);
|
|
350
|
+
return {
|
|
351
|
+
document: documentVersion,
|
|
352
|
+
fieldCount: flattenedFields.length,
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* writeDocumentPath
|
|
358
|
+
*
|
|
359
|
+
* Upsert the `byline_document_paths` row for a (document, locale) pair. The
|
|
360
|
+
* path row is document-grain and sticky across versions — it lives under the
|
|
361
|
+
* document's `source_locale` (its data anchor), not the mutable global
|
|
362
|
+
* default.
|
|
363
|
+
*
|
|
364
|
+
* Real dialect divergence from pg, found live (a test that should have
|
|
365
|
+
* raised a conflict silently "succeeded" instead): pg's `onConflictDoUpdate`
|
|
366
|
+
* takes an explicit `target` — the (`document_id`, `locale`) unique index —
|
|
367
|
+
* so a collision on the *other* unique index
|
|
368
|
+
* (`idx_document_paths_collection_locale_path`, a genuine path conflict
|
|
369
|
+
* from a different document) is deliberately left untargeted and bubbles up
|
|
370
|
+
* as `23505`. MySQL's `ON DUPLICATE KEY UPDATE` has no per-constraint
|
|
371
|
+
* targeting — it fires for a collision on *any* unique/primary key on the
|
|
372
|
+
* table, so a naive `.onDuplicateKeyUpdate()` would silently rewrite
|
|
373
|
+
* whichever existing row the *other* document's path collided with instead
|
|
374
|
+
* of raising `ER_DUP_ENTRY`. So this does the targeting itself: try the
|
|
375
|
+
* `INSERT` first; if it fails on the *own-document* unique index
|
|
376
|
+
* (`unique_document_paths_document_locale`, the ordinary "this document is
|
|
377
|
+
* resaving its already-anchored path" case — detected via `classifyError`,
|
|
378
|
+
* the same cause-chain-walking classifier the lifecycle layer uses, since
|
|
379
|
+
* mysql2/Drizzle wraps the duplicate-key error rather than throwing it
|
|
380
|
+
* bare), fall through to an explicit `UPDATE` instead. Any other
|
|
381
|
+
* duplicate-key error — in particular the collection-scoped
|
|
382
|
+
* path-uniqueness index — is rethrown unchanged, so it still surfaces as
|
|
383
|
+
* `ER_DUP_ENTRY` for the lifecycle layer's own `classifyError`-based
|
|
384
|
+
* `ERR_PATH_CONFLICT` mapping.
|
|
385
|
+
*/
|
|
386
|
+
async writeDocumentPath(tx, args) {
|
|
387
|
+
try {
|
|
388
|
+
await tx.insert(documentPaths).values({
|
|
389
|
+
document_id: args.documentId,
|
|
390
|
+
locale: args.locale,
|
|
391
|
+
collection_id: args.collectionId,
|
|
392
|
+
path: args.path,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
const classification = classifyError(err);
|
|
397
|
+
const isOwnDocumentLocaleConflict = classification.code === DbErrorCodes.UNIQUE_VIOLATION &&
|
|
398
|
+
classification.constraint === 'unique_document_paths_document_locale';
|
|
399
|
+
if (!isOwnDocumentLocaleConflict) {
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
await tx
|
|
403
|
+
.update(documentPaths)
|
|
404
|
+
.set({
|
|
405
|
+
path: args.path,
|
|
406
|
+
collection_id: args.collectionId,
|
|
407
|
+
updated_at: new Date(),
|
|
408
|
+
})
|
|
409
|
+
.where(and(eq(documentPaths.document_id, args.documentId), eq(documentPaths.locale, args.locale)));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* writeDocumentAvailableLocales
|
|
414
|
+
*
|
|
415
|
+
* Replace a document's `byline_document_available_locales` rows wholesale —
|
|
416
|
+
* the editorial advertised-locale set. Document-grain and sticky across
|
|
417
|
+
* versions: `delete`-then-`insert`, deduplicated so a caller-supplied
|
|
418
|
+
* duplicate doesn't collide on the `(document_id, locale)` primary key. An
|
|
419
|
+
* empty array clears the set (advertise nothing).
|
|
420
|
+
*/
|
|
421
|
+
async writeDocumentAvailableLocales(tx, args) {
|
|
422
|
+
await tx
|
|
423
|
+
.delete(documentAvailableLocales)
|
|
424
|
+
.where(eq(documentAvailableLocales.document_id, args.documentId));
|
|
425
|
+
const locales = [...new Set(args.availableLocales)];
|
|
426
|
+
if (locales.length > 0) {
|
|
427
|
+
await tx.insert(documentAvailableLocales).values(locales.map((locale) => ({
|
|
428
|
+
document_id: args.documentId,
|
|
429
|
+
locale,
|
|
430
|
+
collection_id: args.collectionId,
|
|
431
|
+
})));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* writeVersionLocaleLedger
|
|
436
|
+
*
|
|
437
|
+
* Compute and insert a version's `byline_document_version_locales` rows: a
|
|
438
|
+
* locale is recorded when it covers every localized field path the version's
|
|
439
|
+
* `sourceLocale` has (path-coverage), and a version with no localized content
|
|
440
|
+
* records a single `'all'` sentinel. Reads the version's persisted store rows,
|
|
441
|
+
* so callers must have written them first. `::uuid` casts are dropped
|
|
442
|
+
* (MySQL ids are plain `CHAR(36)`); the WITH/UNION/HAVING NOT EXISTS logic is
|
|
443
|
+
* otherwise unchanged MySQL 8+ syntax — but the WITH clause's *position* is a
|
|
444
|
+
* real dialect difference, found live (a syntax error, not assumed): Postgres
|
|
445
|
+
* accepts `WITH … INSERT INTO … SELECT …`, whereas MySQL requires the WITH
|
|
446
|
+
* clause *after* `INSERT INTO <table>` and immediately before the `SELECT`
|
|
447
|
+
* it modifies (confirmed against a live MySQL 9.7.1 server) — so the two
|
|
448
|
+
* clauses are transposed relative to the pg original. See
|
|
449
|
+
* docs/07-internationalization/index.md.
|
|
450
|
+
*/
|
|
451
|
+
async writeVersionLocaleLedger(tx, versionId, sourceLocale) {
|
|
452
|
+
await tx.execute(sql `
|
|
453
|
+
INSERT INTO byline_document_version_locales (document_version_id, locale)
|
|
454
|
+
WITH loc AS (
|
|
455
|
+
SELECT field_path, locale FROM byline_store_text WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
456
|
+
UNION SELECT field_path, locale FROM byline_store_numeric WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
457
|
+
UNION SELECT field_path, locale FROM byline_store_boolean WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
458
|
+
UNION SELECT field_path, locale FROM byline_store_datetime WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
459
|
+
UNION SELECT field_path, locale FROM byline_store_file WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
460
|
+
UNION SELECT field_path, locale FROM byline_store_relation WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
461
|
+
UNION SELECT field_path, locale FROM byline_store_json WHERE document_version_id = ${versionId} AND locale <> 'all'
|
|
462
|
+
),
|
|
463
|
+
canonical AS (
|
|
464
|
+
SELECT field_path FROM loc WHERE locale = ${sourceLocale}
|
|
465
|
+
),
|
|
466
|
+
covering AS (
|
|
467
|
+
SELECT l.locale
|
|
468
|
+
FROM loc l
|
|
469
|
+
GROUP BY l.locale
|
|
470
|
+
HAVING NOT EXISTS (
|
|
471
|
+
SELECT 1 FROM canonical c
|
|
472
|
+
WHERE NOT EXISTS (
|
|
473
|
+
SELECT 1 FROM loc l2 WHERE l2.locale = l.locale AND l2.field_path = c.field_path
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
SELECT ${versionId}, locale FROM covering
|
|
478
|
+
UNION ALL
|
|
479
|
+
SELECT ${versionId}, 'all' WHERE NOT EXISTS (SELECT 1 FROM loc)
|
|
480
|
+
`);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* copyForwardStoreRows
|
|
484
|
+
*
|
|
485
|
+
* Carry forward one store table's rows for every locale except `'all'` and
|
|
486
|
+
* the version's active write locale, from `prevVersionId` to `newVersionId`.
|
|
487
|
+
* The MySQL counterpart of pg's `INSERT … SELECT gen_random_uuid(), … ON
|
|
488
|
+
* CONFLICT (document_version_id, field_path, locale) DO NOTHING`: MySQL has
|
|
489
|
+
* no per-row id generator usable from an `INSERT … SELECT`, and ids must be
|
|
490
|
+
* app-generated UUIDv7 (never DB-generated, never `INSERT IGNORE` — see the
|
|
491
|
+
* method doc on `createDocumentVersion`) — so this reads the candidate rows
|
|
492
|
+
* with a typed `SELECT`, mints a fresh UUIDv7 per row in JS, and bulk-inserts
|
|
493
|
+
* with `.onDuplicateKeyUpdate({ set: { id: sql\`id\` } })`, drizzle's own
|
|
494
|
+
* documented no-op idiom for "do nothing on conflict" on MySQL. One generic
|
|
495
|
+
* helper shared by all 7 call sites (pg keeps 7 near-identical raw-SQL
|
|
496
|
+
* blocks because it doesn't need this per-row JS step; MySQL does, so
|
|
497
|
+
* factoring it once here avoids seven copies of the same shape).
|
|
498
|
+
*
|
|
499
|
+
* `table` is threaded through as `AnyMySqlTable` and cast internally —
|
|
500
|
+
* the 7 store tables share the same `(id, document_version_id, …, locale,
|
|
501
|
+
* created_at, updated_at)` column shape (`baseStoreColumns` in the schema),
|
|
502
|
+
* but Drizzle's query builder generics don't unify cleanly across a union
|
|
503
|
+
* of distinct table types for `.from()` / `.insert()`, so the cast is
|
|
504
|
+
* confined to this one private helper rather than leaking into the 7 call
|
|
505
|
+
* sites, which stay fully typed.
|
|
506
|
+
*/
|
|
507
|
+
async copyForwardStoreRows(tx, table, prevVersionId, newVersionId, activeLocale) {
|
|
508
|
+
const t = table;
|
|
509
|
+
const rows = (await tx
|
|
510
|
+
.select()
|
|
511
|
+
.from(table)
|
|
512
|
+
.where(and(eq(t.document_version_id, prevVersionId), notInArray(t.locale, ['all', activeLocale]))));
|
|
513
|
+
if (rows.length === 0)
|
|
514
|
+
return;
|
|
515
|
+
const values = rows.map((row) => {
|
|
516
|
+
const { id: _id, created_at: _createdAt, updated_at: _updatedAt, ...rest } = row;
|
|
517
|
+
return { ...rest, id: uuidv7(), document_version_id: newVersionId };
|
|
518
|
+
});
|
|
519
|
+
await tx.insert(table).values(values).onDuplicateKeyUpdate({
|
|
520
|
+
set: { id: sql `id` },
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* updateDocumentPath
|
|
525
|
+
*
|
|
526
|
+
* Standalone, non-versioned write of a document's URL path. Backs the admin
|
|
527
|
+
* path widget's direct-write Save path: it edits `byline_document_paths`
|
|
528
|
+
* in-place (document-grain, sticky) **without** minting a new document
|
|
529
|
+
* version or touching workflow status. The path's document-grain nature means
|
|
530
|
+
* the change is immediate and applies across every version of the document.
|
|
531
|
+
*
|
|
532
|
+
* Second caller into `writeDocumentPath` (the first is `createDocumentVersion`
|
|
533
|
+
* step 2a) — see that method's docblock for the insert-then-catch-and-
|
|
534
|
+
* conditionally-update targeting it does in place of pg's
|
|
535
|
+
* `onConflictDoUpdate({ target })`.
|
|
536
|
+
*
|
|
537
|
+
* Source-locale enforcement and `ERR_PATH_CONFLICT` mapping live in the
|
|
538
|
+
* lifecycle service that calls this; the command itself only performs the
|
|
539
|
+
* upsert (and surfaces the raw `ER_DUP_ENTRY` for the service to translate
|
|
540
|
+
* via `classifyError`).
|
|
541
|
+
*/
|
|
542
|
+
async updateDocumentPath(params) {
|
|
543
|
+
await this.db.transaction(async (tx) => {
|
|
544
|
+
await this.writeDocumentPath(tx, {
|
|
545
|
+
documentId: params.documentId,
|
|
546
|
+
locale: params.locale,
|
|
547
|
+
collectionId: params.collectionId,
|
|
548
|
+
path: params.path,
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* setDocumentAvailableLocales
|
|
554
|
+
*
|
|
555
|
+
* Standalone, non-versioned write of a document's editorial advertised-locale
|
|
556
|
+
* set. Backs the admin available-locales widget's direct-write Save path: it
|
|
557
|
+
* replaces `byline_document_available_locales` wholesale (document-grain)
|
|
558
|
+
* **without** minting a new document version or touching workflow status. The
|
|
559
|
+
* change is immediate and applies across every version of the document; the
|
|
560
|
+
* public advertised set remains the intersection with the resolved version's
|
|
561
|
+
* completeness ledger. See docs/07-internationalization/index.md.
|
|
562
|
+
*/
|
|
563
|
+
async setDocumentAvailableLocales(params) {
|
|
564
|
+
await this.db.transaction(async (tx) => {
|
|
565
|
+
await this.writeDocumentAvailableLocales(tx, {
|
|
566
|
+
documentId: params.documentId,
|
|
567
|
+
collectionId: params.collectionId,
|
|
568
|
+
availableLocales: params.availableLocales,
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* copyAllVersionStoreRows
|
|
574
|
+
*
|
|
575
|
+
* Copy every store row — all seven value-store tables (optionally excluding
|
|
576
|
+
* one locale) plus the locale-agnostic `byline_store_meta` identity rows
|
|
577
|
+
* (always copied wholesale, unfiltered — a block's identity is shared
|
|
578
|
+
* across locales) — from one document version to another, verbatim. New
|
|
579
|
+
* `id`s are minted per row (MySQL has no per-row `gen_random_uuid()`
|
|
580
|
+
* usable from an `INSERT … SELECT`, and ids must be app-generated UUIDv7 —
|
|
581
|
+
* see `copyForwardStoreRows` above); `created_at`/`updated_at` are left off
|
|
582
|
+
* the inserted row so the column default (`CURRENT_TIMESTAMP(6)`) supplies
|
|
583
|
+
* a fresh timestamp, matching pg's explicit `NOW(), NOW()`.
|
|
584
|
+
*
|
|
585
|
+
* The target version is assumed fresh (no existing rows), so — unlike
|
|
586
|
+
* `copyForwardStoreRows`, which may collide with rows `createDocumentVersion`
|
|
587
|
+
* already wrote in the same transaction — this performs a plain `INSERT`
|
|
588
|
+
* with no conflict handling. A collision here would mean the caller reused
|
|
589
|
+
* a non-fresh version id, which should fail loudly rather than being
|
|
590
|
+
* silently absorbed. Used by `deleteDocumentLocale` to snapshot the current
|
|
591
|
+
* version into the new one with the target locale's rows dropped.
|
|
592
|
+
*/
|
|
593
|
+
async copyAllVersionStoreRows(tx, fromVersionId, toVersionId, excludeLocale) {
|
|
594
|
+
await this.copyVersionStoreRows(tx, textStore, fromVersionId, toVersionId, excludeLocale);
|
|
595
|
+
await this.copyVersionStoreRows(tx, numericStore, fromVersionId, toVersionId, excludeLocale);
|
|
596
|
+
await this.copyVersionStoreRows(tx, booleanStore, fromVersionId, toVersionId, excludeLocale);
|
|
597
|
+
await this.copyVersionStoreRows(tx, datetimeStore, fromVersionId, toVersionId, excludeLocale);
|
|
598
|
+
await this.copyVersionStoreRows(tx, jsonStore, fromVersionId, toVersionId, excludeLocale);
|
|
599
|
+
await this.copyVersionStoreRows(tx, relationStore, fromVersionId, toVersionId, excludeLocale);
|
|
600
|
+
await this.copyVersionStoreRows(tx, fileStore, fromVersionId, toVersionId, excludeLocale);
|
|
601
|
+
const metaRows = (await tx
|
|
602
|
+
.select()
|
|
603
|
+
.from(metaStore)
|
|
604
|
+
.where(eq(metaStore.document_version_id, fromVersionId)));
|
|
605
|
+
if (metaRows.length > 0) {
|
|
606
|
+
const values = metaRows.map((row) => {
|
|
607
|
+
const { id: _id, created_at: _createdAt, updated_at: _updatedAt, ...rest } = row;
|
|
608
|
+
return { ...rest, id: uuidv7(), document_version_id: toVersionId };
|
|
609
|
+
});
|
|
610
|
+
await tx.insert(metaStore).values(values);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** One value-store table's share of `copyAllVersionStoreRows`. */
|
|
614
|
+
async copyVersionStoreRows(tx, table, fromVersionId, toVersionId, excludeLocale) {
|
|
615
|
+
const t = table;
|
|
616
|
+
const conditions = [eq(t.document_version_id, fromVersionId)];
|
|
617
|
+
if (excludeLocale)
|
|
618
|
+
conditions.push(ne(t.locale, excludeLocale));
|
|
619
|
+
const rows = (await tx
|
|
620
|
+
.select()
|
|
621
|
+
.from(table)
|
|
622
|
+
.where(and(...conditions)));
|
|
623
|
+
if (rows.length === 0)
|
|
624
|
+
return;
|
|
625
|
+
const values = rows.map((row) => {
|
|
626
|
+
const { id: _id, created_at: _createdAt, updated_at: _updatedAt, ...rest } = row;
|
|
627
|
+
return { ...rest, id: uuidv7(), document_version_id: toVersionId };
|
|
628
|
+
});
|
|
629
|
+
await tx.insert(table).values(values);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* deleteDocumentLocale
|
|
633
|
+
*
|
|
634
|
+
* Remove one content locale's data from a document by writing a **new
|
|
635
|
+
* immutable version** that carries forward every store row except the
|
|
636
|
+
* target locale's (the `'all'` rows and all other locales are kept). The
|
|
637
|
+
* prior version still holds the deleted locale, so the operation is
|
|
638
|
+
* recoverable via version restore, and a previously-published version keeps
|
|
639
|
+
* serving until the new version is published.
|
|
640
|
+
*
|
|
641
|
+
* The new version's status is supplied by the caller (the lifecycle service
|
|
642
|
+
* passes the workflow's default — a fresh draft, matching `copyToLocale`).
|
|
643
|
+
* The derived availability ledger is recomputed from the carried-forward
|
|
644
|
+
* rows, so the deleted locale drops out automatically. The default content
|
|
645
|
+
* locale (the document's anchor) must never be passed here — the lifecycle
|
|
646
|
+
* service enforces that.
|
|
647
|
+
*
|
|
648
|
+
* Defensively returns `null` when the document has no current version (the
|
|
649
|
+
* service validates existence first, so this is a guard).
|
|
650
|
+
*/
|
|
651
|
+
async deleteDocumentLocale(params) {
|
|
652
|
+
const { documentId, locale, status, createdBy } = params;
|
|
653
|
+
return this.db.transaction(async (tx) => {
|
|
654
|
+
// 1. Current (latest, non-deleted) version + the document's anchor.
|
|
655
|
+
const current = await tx
|
|
656
|
+
.select({
|
|
657
|
+
versionId: documentVersions.id,
|
|
658
|
+
collectionId: documentVersions.collection_id,
|
|
659
|
+
collectionVersion: documentVersions.collection_version,
|
|
660
|
+
sourceLocale: documents.source_locale,
|
|
661
|
+
})
|
|
662
|
+
.from(documentVersions)
|
|
663
|
+
.innerJoin(documents, eq(documents.id, documentVersions.document_id))
|
|
664
|
+
.where(and(eq(documentVersions.document_id, documentId), eq(documentVersions.is_deleted, false)))
|
|
665
|
+
.orderBy(desc(documentVersions.id))
|
|
666
|
+
.limit(1)
|
|
667
|
+
.then((rows) => rows[0]);
|
|
668
|
+
if (current == null)
|
|
669
|
+
return null;
|
|
670
|
+
const sourceLocale = current.sourceLocale ?? this.defaultContentLocale;
|
|
671
|
+
// 2. New immutable version: a snapshot of the current version with the
|
|
672
|
+
// target locale's value rows dropped (meta + 'all' + other locales
|
|
673
|
+
// carried forward).
|
|
674
|
+
const newVersionId = uuidv7();
|
|
675
|
+
await tx.insert(documentVersions).values({
|
|
676
|
+
id: newVersionId,
|
|
677
|
+
document_id: documentId,
|
|
678
|
+
collection_id: current.collectionId,
|
|
679
|
+
collection_version: current.collectionVersion,
|
|
680
|
+
event_type: 'delete_locale',
|
|
681
|
+
status: status ?? 'draft',
|
|
682
|
+
change_summary: `deleted content locale ${locale}`,
|
|
683
|
+
created_by: createdBy ?? null,
|
|
684
|
+
});
|
|
685
|
+
await this.copyAllVersionStoreRows(tx, current.versionId, newVersionId, locale);
|
|
686
|
+
// 3. Recompute the new version's availability ledger against the source
|
|
687
|
+
// locale — the dropped locale no longer covers it, so it falls out.
|
|
688
|
+
await this.writeVersionLocaleLedger(tx, newVersionId, sourceLocale);
|
|
689
|
+
return { newVersionId, previousVersionId: current.versionId };
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* setDocumentStatus
|
|
694
|
+
*
|
|
695
|
+
* Mutate the status field on an existing document version row.
|
|
696
|
+
* This is the one case where we UPDATE a version in-place — status is
|
|
697
|
+
* lifecycle metadata, not content.
|
|
698
|
+
*/
|
|
699
|
+
async setDocumentStatus(params) {
|
|
700
|
+
await this.db
|
|
701
|
+
.update(documentVersions)
|
|
702
|
+
.set({
|
|
703
|
+
status: params.status,
|
|
704
|
+
updated_at: new Date(),
|
|
705
|
+
})
|
|
706
|
+
.where(eq(documentVersions.id, params.document_version_id));
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* archivePublishedVersions
|
|
710
|
+
*
|
|
711
|
+
* Set ALL versions of a document that currently have `currentStatus`
|
|
712
|
+
* (defaults to 'published') to 'archived'. Optionally exclude a specific
|
|
713
|
+
* version so the caller can protect the version it is about to publish.
|
|
714
|
+
*
|
|
715
|
+
* Returns the number of rows updated. MySQL has no `RETURNING` and drizzle's
|
|
716
|
+
* mysql2 `update()` resolves to a `[ResultSetHeader, FieldPacket[]]` tuple
|
|
717
|
+
* rather than pg's driver result object — `affectedRows` lives on the first
|
|
718
|
+
* element (confirmed live against the test database), not `.rowCount`. See
|
|
719
|
+
* `affectedRowCount()` in `storage-utils.ts`, shared by every guarded write
|
|
720
|
+
* in this adapter (this method, `softDeleteDocument` below, and the admin
|
|
721
|
+
* repositories) for that cast.
|
|
722
|
+
*/
|
|
723
|
+
async archivePublishedVersions(params) {
|
|
724
|
+
const targetStatus = params.currentStatus ?? 'published';
|
|
725
|
+
const conditions = [
|
|
726
|
+
eq(documentVersions.document_id, params.document_id),
|
|
727
|
+
eq(documentVersions.status, targetStatus),
|
|
728
|
+
];
|
|
729
|
+
if (params.excludeVersionId) {
|
|
730
|
+
conditions.push(ne(documentVersions.id, params.excludeVersionId));
|
|
731
|
+
}
|
|
732
|
+
const result = await this.db
|
|
733
|
+
.update(documentVersions)
|
|
734
|
+
.set({ status: 'archived', updated_at: new Date() })
|
|
735
|
+
.where(and(...conditions));
|
|
736
|
+
return affectedRowCount(result);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* softDeleteDocument
|
|
740
|
+
*
|
|
741
|
+
* Mark ALL versions of a document as deleted by setting `is_deleted = true`.
|
|
742
|
+
* The `current_documents` view filters these out, so the document disappears
|
|
743
|
+
* from listings without physically removing data.
|
|
744
|
+
*
|
|
745
|
+
* Returns the number of version rows marked as deleted.
|
|
746
|
+
*/
|
|
747
|
+
async softDeleteDocument(params) {
|
|
748
|
+
return this.db.transaction(async (tx) => {
|
|
749
|
+
// Tree placement takes this same collection lock before inspecting any
|
|
750
|
+
// endpoint state. Taking it before document/version locks makes direct
|
|
751
|
+
// soft deletion serialize with placement without reversing the normal
|
|
752
|
+
// lifecycle delete's lock order.
|
|
753
|
+
const collectionId = await this.lockDocumentCollection(tx, params.document_id);
|
|
754
|
+
if (collectionId == null)
|
|
755
|
+
return 0;
|
|
756
|
+
const [document] = await tx
|
|
757
|
+
.select({ id: documents.id })
|
|
758
|
+
.from(documents)
|
|
759
|
+
.where(eq(documents.id, params.document_id))
|
|
760
|
+
.for('update');
|
|
761
|
+
if (document == null)
|
|
762
|
+
return 0;
|
|
763
|
+
const result = await tx
|
|
764
|
+
.update(documentVersions)
|
|
765
|
+
.set({
|
|
766
|
+
is_deleted: true,
|
|
767
|
+
updated_at: new Date(),
|
|
768
|
+
})
|
|
769
|
+
.where(eq(documentVersions.document_id, params.document_id));
|
|
770
|
+
return affectedRowCount(result);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Write `order_key` on a single `byline_documents` row. Single-column
|
|
775
|
+
* metadata update — no new version row, no `documentVersions` touch.
|
|
776
|
+
* `updated_at` on the document row is bumped so list caches invalidate.
|
|
777
|
+
*/
|
|
778
|
+
async setOrderKey(params) {
|
|
779
|
+
await this.db
|
|
780
|
+
.update(documents)
|
|
781
|
+
.set({
|
|
782
|
+
order_key: params.order_key,
|
|
783
|
+
updated_at: new Date(),
|
|
784
|
+
})
|
|
785
|
+
.where(eq(documents.id, params.document_id));
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Serialize structural changes per collection and verify the collection
|
|
789
|
+
* exists. `FOR UPDATE` — no `FOR UPDATE OF` qualifier needed since this
|
|
790
|
+
* query selects from a single table (confirmed against a live MySQL 9.7.1
|
|
791
|
+
* server; MySQL 8.0.1+ supports `FOR UPDATE OF tbl_name` for the
|
|
792
|
+
* multi-table case used below in `lockDocumentCollection`).
|
|
793
|
+
*/
|
|
794
|
+
async lockTreeCollection(tx, collectionId) {
|
|
795
|
+
const locked = await tx.execute(sql `
|
|
796
|
+
SELECT id FROM byline_collections
|
|
797
|
+
WHERE id = ${collectionId}
|
|
798
|
+
FOR UPDATE
|
|
799
|
+
`);
|
|
800
|
+
const rows = locked[0];
|
|
801
|
+
if (rows.length === 0) {
|
|
802
|
+
throw ERR_NOT_FOUND({ message: 'collection not found', details: { collectionId } });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Resolve a document's collection while locking only the collection row.
|
|
807
|
+
* `FOR UPDATE OF c` — confirmed live against a MySQL 9.7.1 server (MySQL
|
|
808
|
+
* 8.0.1+ supports the per-table lock qualifier; `::uuid` casts dropped).
|
|
809
|
+
*/
|
|
810
|
+
async lockDocumentCollection(tx, documentId) {
|
|
811
|
+
const locked = await tx.execute(sql `
|
|
812
|
+
SELECT c.id AS collection_id
|
|
813
|
+
FROM byline_collections c
|
|
814
|
+
JOIN byline_documents d ON d.collection_id = c.id
|
|
815
|
+
WHERE d.id = ${documentId}
|
|
816
|
+
FOR UPDATE OF c
|
|
817
|
+
`);
|
|
818
|
+
const rows = locked[0];
|
|
819
|
+
return rows[0]?.collection_id ?? null;
|
|
820
|
+
}
|
|
821
|
+
/** Read one ordered sibling group on the already collection-locked transaction. */
|
|
822
|
+
async treeGroup(tx, collectionId, parentDocumentId) {
|
|
823
|
+
const rows = await tx
|
|
824
|
+
.select({
|
|
825
|
+
documentId: documentRelationships.child_document_id,
|
|
826
|
+
orderKey: documentRelationships.order_key,
|
|
827
|
+
})
|
|
828
|
+
.from(documentRelationships)
|
|
829
|
+
.innerJoin(documents, eq(documents.id, documentRelationships.child_document_id))
|
|
830
|
+
.where(and(eq(documents.collection_id, collectionId), parentDocumentId == null
|
|
831
|
+
? sql `${documentRelationships.parent_document_id} IS NULL`
|
|
832
|
+
: eq(documentRelationships.parent_document_id, parentDocumentId)))
|
|
833
|
+
.orderBy(documentRelationships.order_key);
|
|
834
|
+
return rows;
|
|
835
|
+
}
|
|
836
|
+
/** Read a node's placement from the already collection-locked transaction. */
|
|
837
|
+
async treePlacement(tx, collectionId, documentId) {
|
|
838
|
+
const [edge] = await tx
|
|
839
|
+
.select({
|
|
840
|
+
parentDocumentId: documentRelationships.parent_document_id,
|
|
841
|
+
orderKey: documentRelationships.order_key,
|
|
842
|
+
})
|
|
843
|
+
.from(documentRelationships)
|
|
844
|
+
.where(eq(documentRelationships.child_document_id, documentId))
|
|
845
|
+
.limit(1);
|
|
846
|
+
if (edge == null) {
|
|
847
|
+
return {
|
|
848
|
+
state: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
849
|
+
siblings: [],
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const siblings = await this.treeGroup(tx, collectionId, edge.parentDocumentId);
|
|
853
|
+
const index = siblings.findIndex((row) => row.documentId === documentId);
|
|
854
|
+
return {
|
|
855
|
+
state: {
|
|
856
|
+
placed: true,
|
|
857
|
+
parentDocumentId: edge.parentDocumentId,
|
|
858
|
+
orderKey: edge.orderKey,
|
|
859
|
+
index: index >= 0 ? index : null,
|
|
860
|
+
},
|
|
861
|
+
siblings,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/** Read a raw node-and-descendants set while the collection tree lock is held. */
|
|
865
|
+
async treeSubtreeIds(tx, collectionId, documentId) {
|
|
866
|
+
const result = await tx.execute(sql `
|
|
867
|
+
WITH RECURSIVE subtree AS (
|
|
868
|
+
SELECT d.id AS document_id, 0 AS depth
|
|
869
|
+
FROM byline_documents d
|
|
870
|
+
WHERE d.id = ${documentId}
|
|
871
|
+
AND d.collection_id = ${collectionId}
|
|
872
|
+
UNION ALL
|
|
873
|
+
SELECT r.child_document_id, s.depth + 1
|
|
874
|
+
FROM byline_document_relationships r
|
|
875
|
+
JOIN subtree s ON r.parent_document_id = s.document_id
|
|
876
|
+
JOIN byline_documents d ON d.id = r.child_document_id
|
|
877
|
+
WHERE d.collection_id = ${collectionId}
|
|
878
|
+
AND s.depth < ${TREE_MAX_DEPTH}
|
|
879
|
+
)
|
|
880
|
+
SELECT document_id FROM subtree ORDER BY depth
|
|
881
|
+
`);
|
|
882
|
+
const rows = result[0];
|
|
883
|
+
return rows.map((row) => row.document_id);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* placeTreeNode — see {@link IDocumentCommands.placeTreeNode}.
|
|
887
|
+
*
|
|
888
|
+
* Single transaction: same-collection guard → cycle guard → resolve the
|
|
889
|
+
* target sibling group's neighbour keys → mint a fractional key → upsert the
|
|
890
|
+
* edge row. Unversioned; touches only `byline_document_relationships`.
|
|
891
|
+
*
|
|
892
|
+
* The edge upsert uses a plain `.onDuplicateKeyUpdate()` — unlike
|
|
893
|
+
* `writeDocumentPath`, `byline_document_relationships` carries exactly one
|
|
894
|
+
* unique constraint (`uq_document_relationships_child`, on
|
|
895
|
+
* `child_document_id`), so MySQL's lack of per-constraint targeting is not
|
|
896
|
+
* an issue here: any duplicate-key collision on this table can only be that
|
|
897
|
+
* one index, so there is no ambiguity to guard against.
|
|
898
|
+
*/
|
|
899
|
+
async placeTreeNode(params) {
|
|
900
|
+
const { collectionId, documentId, parentDocumentId } = params;
|
|
901
|
+
const beforeDocumentId = params.beforeDocumentId ?? null;
|
|
902
|
+
const afterDocumentId = params.afterDocumentId ?? null;
|
|
903
|
+
if (parentDocumentId === documentId) {
|
|
904
|
+
throw ERR_VALIDATION({
|
|
905
|
+
message: 'a document cannot be its own parent in the document tree',
|
|
906
|
+
details: { documentId },
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (beforeDocumentId === documentId || afterDocumentId === documentId) {
|
|
910
|
+
throw ERR_VALIDATION({
|
|
911
|
+
message: 'a document cannot be its own tree neighbour',
|
|
912
|
+
details: { documentId },
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
if (beforeDocumentId != null && beforeDocumentId === afterDocumentId) {
|
|
916
|
+
throw ERR_VALIDATION({
|
|
917
|
+
message: 'beforeDocumentId and afterDocumentId must identify different tree neighbours',
|
|
918
|
+
details: { documentId, beforeDocumentId, afterDocumentId },
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return await this.db.transaction(async (tx) => {
|
|
922
|
+
// One collection-row lock serializes all edge and sibling-key changes in
|
|
923
|
+
// the tree, so the returned before state and no-op decision cannot race.
|
|
924
|
+
await this.lockTreeCollection(tx, collectionId);
|
|
925
|
+
// Same-collection guard — every supplied endpoint must live in `collectionId`.
|
|
926
|
+
const ids = [documentId, parentDocumentId, beforeDocumentId, afterDocumentId].filter((id) => id != null);
|
|
927
|
+
const docRows = await tx
|
|
928
|
+
.select({ id: documents.id, collection_id: documents.collection_id })
|
|
929
|
+
.from(documents)
|
|
930
|
+
.where(inArray(documents.id, ids));
|
|
931
|
+
const collectionById = new Map(docRows.map((r) => [r.id, r.collection_id]));
|
|
932
|
+
if (collectionById.get(documentId) == null) {
|
|
933
|
+
throw ERR_NOT_FOUND({ message: 'document not found', details: { documentId } });
|
|
934
|
+
}
|
|
935
|
+
if (collectionById.get(documentId) !== collectionId) {
|
|
936
|
+
throw ERR_VALIDATION({
|
|
937
|
+
message: 'document does not belong to the collection',
|
|
938
|
+
details: { documentId, collectionId },
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (parentDocumentId != null) {
|
|
942
|
+
if (collectionById.get(parentDocumentId) == null) {
|
|
943
|
+
throw ERR_NOT_FOUND({
|
|
944
|
+
message: 'parent document not found',
|
|
945
|
+
details: { parentDocumentId },
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
if (collectionById.get(parentDocumentId) !== collectionId) {
|
|
949
|
+
throw ERR_VALIDATION({
|
|
950
|
+
message: 'parent document is in a different collection',
|
|
951
|
+
details: { parentDocumentId, collectionId },
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const neighbours = [
|
|
956
|
+
{ role: 'beforeDocumentId', id: beforeDocumentId },
|
|
957
|
+
{ role: 'afterDocumentId', id: afterDocumentId },
|
|
958
|
+
];
|
|
959
|
+
for (const neighbour of neighbours) {
|
|
960
|
+
if (neighbour.id == null)
|
|
961
|
+
continue;
|
|
962
|
+
if (collectionById.get(neighbour.id) == null) {
|
|
963
|
+
throw ERR_NOT_FOUND({
|
|
964
|
+
message: 'tree neighbour document not found',
|
|
965
|
+
details: { documentId, [neighbour.role]: neighbour.id },
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
if (collectionById.get(neighbour.id) !== collectionId) {
|
|
969
|
+
throw ERR_VALIDATION({
|
|
970
|
+
message: 'tree neighbour is in a different collection',
|
|
971
|
+
details: { documentId, collectionId, [neighbour.role]: neighbour.id },
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const liveRows = await tx
|
|
976
|
+
.select({ id: currentDocumentsView.document_id })
|
|
977
|
+
.from(currentDocumentsView)
|
|
978
|
+
.where(and(eq(currentDocumentsView.collection_id, collectionId), inArray(currentDocumentsView.document_id, ids)));
|
|
979
|
+
const liveIds = new Set(liveRows.map((row) => row.id));
|
|
980
|
+
if (!liveIds.has(documentId)) {
|
|
981
|
+
throw ERR_CONFLICT({
|
|
982
|
+
message: staleTreePlacementMessage('document no longer has a current version'),
|
|
983
|
+
details: { documentId, collectionId },
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
if (parentDocumentId != null && !liveIds.has(parentDocumentId)) {
|
|
987
|
+
throw ERR_CONFLICT({
|
|
988
|
+
message: staleTreePlacementMessage('parent no longer has a current version'),
|
|
989
|
+
details: { documentId, parentDocumentId, collectionId },
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
for (const neighbour of neighbours) {
|
|
993
|
+
if (neighbour.id != null && !liveIds.has(neighbour.id)) {
|
|
994
|
+
throw ERR_CONFLICT({
|
|
995
|
+
message: staleTreePlacementMessage(`${neighbour.role} no longer has a current version`),
|
|
996
|
+
details: { documentId, collectionId, [neighbour.role]: neighbour.id },
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (parentDocumentId != null) {
|
|
1001
|
+
// Cycle guard — reject when `documentId` is the new parent itself or
|
|
1002
|
+
// any of its ancestors (which would put the node below its own
|
|
1003
|
+
// subtree). Walk upward from `parentDocumentId`; depth-bounded.
|
|
1004
|
+
const cycle = await tx.execute(sql `
|
|
1005
|
+
WITH RECURSIVE chain AS (
|
|
1006
|
+
SELECT ${parentDocumentId} AS node_id, 0 AS depth
|
|
1007
|
+
UNION ALL
|
|
1008
|
+
SELECT r.parent_document_id, c.depth + 1
|
|
1009
|
+
FROM byline_document_relationships r
|
|
1010
|
+
JOIN chain c ON r.child_document_id = c.node_id
|
|
1011
|
+
WHERE r.parent_document_id IS NOT NULL AND c.depth < ${TREE_MAX_DEPTH}
|
|
1012
|
+
)
|
|
1013
|
+
SELECT 1 FROM chain WHERE node_id = ${documentId} LIMIT 1
|
|
1014
|
+
`);
|
|
1015
|
+
const cycleRows = cycle[0];
|
|
1016
|
+
if (cycleRows.length > 0) {
|
|
1017
|
+
throw ERR_VALIDATION({
|
|
1018
|
+
message: 'move would create a cycle in the document tree',
|
|
1019
|
+
details: { documentId, parentDocumentId },
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const before = await this.treePlacement(tx, collectionId, documentId);
|
|
1024
|
+
if (params.ifUnplaced === true && before.state.placed) {
|
|
1025
|
+
return {
|
|
1026
|
+
changed: false,
|
|
1027
|
+
before: before.state,
|
|
1028
|
+
after: before.state,
|
|
1029
|
+
beforeSiblingDocumentIds: before.siblings.map((row) => row.documentId),
|
|
1030
|
+
beforeSubtreeDocumentIds: [],
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const targetGroup = (before.state.placed && before.state.parentDocumentId === parentDocumentId
|
|
1034
|
+
? before.siblings
|
|
1035
|
+
: await this.treeGroup(tx, collectionId, parentDocumentId)).filter((row) => row.documentId !== documentId);
|
|
1036
|
+
const leftIndex = beforeDocumentId
|
|
1037
|
+
? targetGroup.findIndex((row) => row.documentId === beforeDocumentId)
|
|
1038
|
+
: -1;
|
|
1039
|
+
const rightIndex = afterDocumentId
|
|
1040
|
+
? targetGroup.findIndex((row) => row.documentId === afterDocumentId)
|
|
1041
|
+
: -1;
|
|
1042
|
+
if (beforeDocumentId && leftIndex < 0) {
|
|
1043
|
+
throw ERR_CONFLICT({
|
|
1044
|
+
message: staleTreePlacementMessage('beforeDocumentId is no longer in the target sibling group'),
|
|
1045
|
+
details: { documentId, parentDocumentId, beforeDocumentId },
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
if (afterDocumentId && rightIndex < 0) {
|
|
1049
|
+
throw ERR_CONFLICT({
|
|
1050
|
+
message: staleTreePlacementMessage('afterDocumentId is no longer in the target sibling group'),
|
|
1051
|
+
details: { documentId, parentDocumentId, afterDocumentId },
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
if (afterDocumentId && beforeDocumentId == null && rightIndex !== 0) {
|
|
1055
|
+
throw ERR_CONFLICT({
|
|
1056
|
+
message: staleTreePlacementMessage('right neighbour is no longer first'),
|
|
1057
|
+
details: { documentId, parentDocumentId, afterDocumentId },
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
if (beforeDocumentId && afterDocumentId == null && leftIndex !== targetGroup.length - 1) {
|
|
1061
|
+
throw ERR_CONFLICT({
|
|
1062
|
+
message: staleTreePlacementMessage('left neighbour is no longer last'),
|
|
1063
|
+
details: { documentId, parentDocumentId, beforeDocumentId },
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
let insertionIndex;
|
|
1067
|
+
if (beforeDocumentId && afterDocumentId) {
|
|
1068
|
+
if (leftIndex + 1 !== rightIndex) {
|
|
1069
|
+
throw ERR_CONFLICT({
|
|
1070
|
+
message: staleTreePlacementMessage('target neighbours are no longer adjacent'),
|
|
1071
|
+
details: { documentId, parentDocumentId, beforeDocumentId, afterDocumentId },
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
insertionIndex = rightIndex;
|
|
1075
|
+
}
|
|
1076
|
+
else if (beforeDocumentId) {
|
|
1077
|
+
insertionIndex = leftIndex + 1;
|
|
1078
|
+
}
|
|
1079
|
+
else if (afterDocumentId) {
|
|
1080
|
+
insertionIndex = rightIndex;
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
insertionIndex = targetGroup.length;
|
|
1084
|
+
}
|
|
1085
|
+
if (before.state.placed &&
|
|
1086
|
+
before.state.parentDocumentId === parentDocumentId &&
|
|
1087
|
+
before.state.index === insertionIndex) {
|
|
1088
|
+
return {
|
|
1089
|
+
changed: false,
|
|
1090
|
+
before: before.state,
|
|
1091
|
+
after: before.state,
|
|
1092
|
+
beforeSiblingDocumentIds: before.siblings.map((row) => row.documentId),
|
|
1093
|
+
beforeSubtreeDocumentIds: [],
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
const left = targetGroup[insertionIndex - 1]?.orderKey ?? null;
|
|
1097
|
+
const right = targetGroup[insertionIndex]?.orderKey ?? null;
|
|
1098
|
+
let orderKey;
|
|
1099
|
+
try {
|
|
1100
|
+
orderKey = generateKeyBetween(left, right);
|
|
1101
|
+
}
|
|
1102
|
+
catch (err) {
|
|
1103
|
+
throw ERR_VALIDATION({
|
|
1104
|
+
message: 'cannot generate order_key between the supplied tree neighbours',
|
|
1105
|
+
details: {
|
|
1106
|
+
documentId,
|
|
1107
|
+
parentDocumentId,
|
|
1108
|
+
left,
|
|
1109
|
+
right,
|
|
1110
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
await tx
|
|
1115
|
+
.insert(documentRelationships)
|
|
1116
|
+
.values({
|
|
1117
|
+
child_document_id: documentId,
|
|
1118
|
+
parent_document_id: parentDocumentId,
|
|
1119
|
+
order_key: orderKey,
|
|
1120
|
+
})
|
|
1121
|
+
.onDuplicateKeyUpdate({
|
|
1122
|
+
set: {
|
|
1123
|
+
parent_document_id: parentDocumentId,
|
|
1124
|
+
order_key: orderKey,
|
|
1125
|
+
updated_at: new Date(),
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
return {
|
|
1129
|
+
changed: true,
|
|
1130
|
+
before: before.state,
|
|
1131
|
+
after: {
|
|
1132
|
+
placed: true,
|
|
1133
|
+
parentDocumentId,
|
|
1134
|
+
orderKey,
|
|
1135
|
+
index: insertionIndex,
|
|
1136
|
+
},
|
|
1137
|
+
beforeSiblingDocumentIds: before.siblings.map((row) => row.documentId),
|
|
1138
|
+
beforeSubtreeDocumentIds: [],
|
|
1139
|
+
};
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* removeFromTree — see {@link IDocumentCommands.removeFromTree}.
|
|
1144
|
+
* Single-row delete; no-op when the node is already unplaced.
|
|
1145
|
+
*/
|
|
1146
|
+
async removeFromTree(params) {
|
|
1147
|
+
return this.db.transaction(async (tx) => {
|
|
1148
|
+
await this.lockTreeCollection(tx, params.collectionId);
|
|
1149
|
+
const [document] = await tx
|
|
1150
|
+
.select({ collectionId: documents.collection_id })
|
|
1151
|
+
.from(documents)
|
|
1152
|
+
.where(eq(documents.id, params.documentId))
|
|
1153
|
+
.limit(1);
|
|
1154
|
+
if (document == null) {
|
|
1155
|
+
throw ERR_NOT_FOUND({
|
|
1156
|
+
message: 'document not found',
|
|
1157
|
+
details: { documentId: params.documentId },
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
if (document.collectionId !== params.collectionId) {
|
|
1161
|
+
throw ERR_VALIDATION({
|
|
1162
|
+
message: 'document does not belong to the collection',
|
|
1163
|
+
details: params,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
const before = await this.treePlacement(tx, params.collectionId, params.documentId);
|
|
1167
|
+
const beforeSubtreeDocumentIds = params.includeSubtree
|
|
1168
|
+
? await this.treeSubtreeIds(tx, params.collectionId, params.documentId)
|
|
1169
|
+
: [];
|
|
1170
|
+
if (!before.state.placed) {
|
|
1171
|
+
return {
|
|
1172
|
+
changed: false,
|
|
1173
|
+
before: before.state,
|
|
1174
|
+
after: before.state,
|
|
1175
|
+
beforeSiblingDocumentIds: [],
|
|
1176
|
+
beforeSubtreeDocumentIds,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
await tx
|
|
1180
|
+
.delete(documentRelationships)
|
|
1181
|
+
.where(eq(documentRelationships.child_document_id, params.documentId));
|
|
1182
|
+
return {
|
|
1183
|
+
changed: true,
|
|
1184
|
+
before: before.state,
|
|
1185
|
+
after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
1186
|
+
beforeSiblingDocumentIds: before.siblings.map((row) => row.documentId),
|
|
1187
|
+
beforeSubtreeDocumentIds,
|
|
1188
|
+
};
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
/** Promote direct children to roots and remove the parent edge under one lock. */
|
|
1192
|
+
async promoteChildrenAndRemoveFromTree(params) {
|
|
1193
|
+
return this.db.transaction(async (tx) => {
|
|
1194
|
+
await this.lockTreeCollection(tx, params.collectionId);
|
|
1195
|
+
const [document] = await tx
|
|
1196
|
+
.select({ id: documents.id, collectionId: documents.collection_id })
|
|
1197
|
+
.from(documents)
|
|
1198
|
+
.where(eq(documents.id, params.documentId))
|
|
1199
|
+
.limit(1);
|
|
1200
|
+
if (document == null) {
|
|
1201
|
+
throw ERR_NOT_FOUND({
|
|
1202
|
+
message: 'document not found',
|
|
1203
|
+
details: { documentId: params.documentId },
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
if (document.collectionId !== params.collectionId) {
|
|
1207
|
+
throw ERR_VALIDATION({
|
|
1208
|
+
message: 'document does not belong to the collection',
|
|
1209
|
+
details: params,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
const parent = await this.treePlacement(tx, params.collectionId, params.documentId);
|
|
1213
|
+
const children = await this.treeGroup(tx, params.collectionId, params.documentId);
|
|
1214
|
+
const roots = (await this.treeGroup(tx, params.collectionId, null)).filter((row) => row.documentId !== params.documentId);
|
|
1215
|
+
const promoted = [];
|
|
1216
|
+
for (const [index, child] of children.entries()) {
|
|
1217
|
+
const orderKey = generateKeyBetween(roots.at(-1)?.orderKey ?? null, null);
|
|
1218
|
+
const after = {
|
|
1219
|
+
placed: true,
|
|
1220
|
+
parentDocumentId: null,
|
|
1221
|
+
orderKey,
|
|
1222
|
+
index: roots.length,
|
|
1223
|
+
};
|
|
1224
|
+
await tx
|
|
1225
|
+
.update(documentRelationships)
|
|
1226
|
+
.set({ parent_document_id: null, order_key: orderKey, updated_at: new Date() })
|
|
1227
|
+
.where(eq(documentRelationships.child_document_id, child.documentId));
|
|
1228
|
+
promoted.push({
|
|
1229
|
+
documentId: child.documentId,
|
|
1230
|
+
before: {
|
|
1231
|
+
placed: true,
|
|
1232
|
+
parentDocumentId: params.documentId,
|
|
1233
|
+
orderKey: child.orderKey,
|
|
1234
|
+
index,
|
|
1235
|
+
},
|
|
1236
|
+
after,
|
|
1237
|
+
});
|
|
1238
|
+
roots.push({ documentId: child.documentId, orderKey });
|
|
1239
|
+
}
|
|
1240
|
+
if (parent.state.placed) {
|
|
1241
|
+
await tx
|
|
1242
|
+
.delete(documentRelationships)
|
|
1243
|
+
.where(eq(documentRelationships.child_document_id, params.documentId));
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
removed: {
|
|
1247
|
+
changed: parent.state.placed,
|
|
1248
|
+
before: parent.state,
|
|
1249
|
+
after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
|
|
1250
|
+
beforeSiblingDocumentIds: parent.siblings.map((row) => row.documentId),
|
|
1251
|
+
beforeSubtreeDocumentIds: [],
|
|
1252
|
+
},
|
|
1253
|
+
promoted,
|
|
1254
|
+
};
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
export function createCommandBuilders(dbManager, defaultContentLocale) {
|
|
1259
|
+
return {
|
|
1260
|
+
collections: new CollectionCommands(dbManager),
|
|
1261
|
+
documents: new DocumentCommands(dbManager, defaultContentLocale),
|
|
1262
|
+
};
|
|
1263
|
+
}
|