@byline/db-mysql 4.8.0

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