@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,895 @@
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 { eq, relations, sql } from 'drizzle-orm';
9
+ import { bigint, boolean, date, datetime, decimal, float, foreignKey, index, int, json, mysqlTable, mysqlView, primaryKey, text, time, unique, varchar, } from 'drizzle-orm/mysql-core';
10
+ import { createdAt, timestamps, uuidChar, varcharByteSorted, varcharCaseSensitive, } from './common.js';
11
+ // Every foreign key below is declared with the table-level `foreignKey()`
12
+ // builder and an explicit, short `fk_<table>_<column>` name, rather than
13
+ // the column-level `.references()` shorthand the Postgres schema uses.
14
+ // This is a real dialect difference, not a style choice: MySQL enforces a
15
+ // hard 64-character identifier cap (`ER_TOO_LONG_IDENT`) on every
16
+ // constraint name, including auto-generated ones, whereas Postgres's
17
+ // 63-byte `NAMEDATALEN` limit is enforced by silent truncation. Byline's
18
+ // table/column names are long and descriptive enough (e.g.
19
+ // `byline_document_version_locales` + `document_version_id` +
20
+ // `byline_document_versions` + `id`) that drizzle-kit's auto-generated
21
+ // `<table>_<column>_<foreignTable>_<foreignColumn>_fk` name exceeds 64
22
+ // characters for roughly half the foreign keys in this schema — silently
23
+ // on Postgres, but as a hard migration failure on MySQL. Explicit naming
24
+ // throughout (not just for the offenders) keeps the convention uniform and
25
+ // keeps a future column rename from reintroducing the failure.
26
+ // Collections table
27
+ export const collections = mysqlTable('byline_collections', {
28
+ id: uuidChar('id').primaryKey(),
29
+ path: varchar('path', { length: 255 }).unique().notNull(),
30
+ singular: text('singular').notNull(), // Singular label for the collection
31
+ plural: text('plural').notNull(), // Plural label for the collection
32
+ config: json('config').notNull(), // Store CollectionConfig
33
+ // Monotonically-increasing schema version. Incremented by the startup
34
+ // bootstrap whenever `schema_hash` changes (or to a value pinned
35
+ // explicitly via `CollectionDefinition.version`).
36
+ version: int('version').notNull().default(1),
37
+ // SHA-256 fingerprint of the data-shape-relevant portion of the
38
+ // collection's definition. Nullable in Phase 1 — populated on first
39
+ // `ensureCollections()` run post-migration, tightens to NOT NULL when
40
+ // the `collection_versions` history table lands.
41
+ schema_hash: varchar('schema_hash', { length: 64 }),
42
+ ...timestamps,
43
+ });
44
+ // Documents table
45
+ export const documents = mysqlTable('byline_documents', {
46
+ id: uuidChar('id').primaryKey(),
47
+ collection_id: uuidChar('collection_id').notNull(),
48
+ // Fractional-index sort key for collections with `orderable: true` in
49
+ // their admin config. Null on collections that haven't opted in, and on
50
+ // pre-existing rows in newly-`orderable` collections (sort NULLS LAST).
51
+ // Admin metadata — never per-version, never EAV; updated by the reorder
52
+ // server fn without bumping documentVersions.
53
+ //
54
+ // Uses `varcharByteSorted` (`ascii_bin`) so DB ordering matches JS string
55
+ // comparison — the fractional-index algorithm requires this. See
56
+ // `varcharByteSorted` in `./common.ts` and docs/04-collections/index.md (Orderable collections).
57
+ order_key: varcharByteSorted('order_key', { length: 128 }),
58
+ // The content locale this document was first authored in — its per-document
59
+ // data anchor. Set once at creation (= the global default content locale at
60
+ // that moment) and immutable in normal operation; changed only by the
61
+ // deliberate re-anchor operation. Re-bases the fallback floor, the path
62
+ // locale, and the completeness ledger off the mutable global config onto
63
+ // the document's own truth, so switching `i18n.content.defaultLocale` no
64
+ // longer silently re-interprets existing data. Backfilled by
65
+ // `backfillSourceLocales()` (boot-auto via initBylineCore).
66
+ //
67
+ source_locale: varchar('source_locale', { length: 10 }).notNull(),
68
+ ...timestamps,
69
+ }, (table) => [
70
+ foreignKey({
71
+ name: 'fk_documents_collection_id',
72
+ columns: [table.collection_id],
73
+ foreignColumns: [collections.id],
74
+ }).onDelete('cascade'),
75
+ index('idx_documents_collection').on(table.collection_id),
76
+ index('idx_documents_collection_order').on(table.collection_id, table.order_key),
77
+ ]);
78
+ // Document versions table
79
+ export const documentVersions = mysqlTable('byline_document_versions', {
80
+ id: uuidChar('id').primaryKey(), // UUIDv7 versioning by default
81
+ document_id: uuidChar('document_id').notNull(),
82
+ collection_id: uuidChar('collection_id').notNull(),
83
+ // Collection schema version this row was authored against. Used by
84
+ // future in-memory migration code to resolve historical document
85
+ // shapes. Phase 1 records the number; no composite FK yet — that
86
+ // anchors in Phase 2 alongside the history table.
87
+ collection_version: int('collection_version').notNull(),
88
+ doc: json('doc'), // optionally store the original document
89
+ event_type: varchar('event_type', { length: 20 }).notNull().default('create'), // 'create', 'update', 'delete'
90
+ status: varchar('status', { length: 50 }).default('draft'),
91
+ is_deleted: boolean('is_deleted').default(false), // Tombstone for soft deletes
92
+ ...timestamps,
93
+ created_by: uuidChar('created_by'),
94
+ change_summary: text('change_summary'),
95
+ }, (table) => [
96
+ foreignKey({
97
+ name: 'fk_document_versions_document_id',
98
+ columns: [table.document_id],
99
+ foreignColumns: [documents.id],
100
+ }).onDelete('cascade'),
101
+ foreignKey({
102
+ name: 'fk_document_versions_collection_id',
103
+ columns: [table.collection_id],
104
+ foreignColumns: [collections.id],
105
+ }).onDelete('cascade'),
106
+ // Index for finding all versions of a logical document
107
+ index('idx_documents_document_id').on(table.document_id),
108
+ // Index for current document lookup by logical document ID
109
+ index('idx_documents_collection_document_deleted').on(table.collection_id, table.document_id, table.is_deleted),
110
+ // Index to optimize the current documents view
111
+ index('idx_documents_current_view').on(table.collection_id, table.document_id, table.is_deleted, table.id),
112
+ // Event and audit indexes
113
+ index('idx_documents_event_type').on(table.event_type),
114
+ index('idx_documents_created_at').on(table.created_at),
115
+ // Ensure logical document belongs to only one collection
116
+ index('idx_documents_document_collection').on(table.document_id, table.collection_id),
117
+ ]);
118
+ // Document paths — one row per (logical document, content locale).
119
+ // Promotes `path` out of the version row so per-collection uniqueness can
120
+ // be enforced at the DB layer without colliding with the sticky
121
+ // carry-forward of path across versions. Phase 1 only ever writes the
122
+ // installation's default content locale; per-locale UI is a future phase
123
+ // that adds rows for additional locales without reshaping the schema.
124
+ // History is intentionally not preserved here — path rows are updated in
125
+ // place. See `docs/04-collections/05-document-paths.md` § "Path uniqueness".
126
+ //
127
+ // The `idx_document_paths_collection_locale_path` name below is
128
+ // load-bearing beyond this file: `classifyError` reports MySQL's
129
+ // duplicate-key error against the *index* name (unlike Postgres, which
130
+ // reports the constraint name), and
131
+ // `packages/core/src/services/document-lifecycle/internals.ts`
132
+ // substring-matches this exact string to detect a path collision. Do not
133
+ // rename it.
134
+ export const documentPaths = mysqlTable('byline_document_paths', {
135
+ document_id: uuidChar('document_id').notNull(),
136
+ locale: varchar('locale', { length: 10 }).notNull(),
137
+ collection_id: uuidChar('collection_id').notNull(),
138
+ // `utf8mb4_bin`, not the database default — see `varcharCaseSensitive`
139
+ // in `./common.ts` for why (case AND accent sensitivity, verified
140
+ // against live Thai/Devanagari/Hebrew slugs, not just Latin parity).
141
+ path: varcharCaseSensitive('path', { length: 255 }).notNull(),
142
+ ...timestamps,
143
+ }, (table) => [
144
+ foreignKey({
145
+ name: 'fk_document_paths_document_id',
146
+ columns: [table.document_id],
147
+ foreignColumns: [documents.id],
148
+ }).onDelete('cascade'),
149
+ foreignKey({
150
+ name: 'fk_document_paths_collection_id',
151
+ columns: [table.collection_id],
152
+ foreignColumns: [collections.id],
153
+ }).onDelete('cascade'),
154
+ // One path per (logical document, locale).
155
+ unique('unique_document_paths_document_locale').on(table.document_id, table.locale),
156
+ // Per-collection per-locale path uniqueness. Column order matches the
157
+ // resolution lookup pattern: WHERE collection_id = ? AND locale = ? AND path = ?.
158
+ unique('idx_document_paths_collection_locale_path').on(table.collection_id, table.locale, table.path),
159
+ // Reverse lookup by document.
160
+ index('idx_document_paths_document_id').on(table.document_id),
161
+ ]);
162
+ // Document → advertised content locales. One row per (logical document,
163
+ // advertised locale) — the editorial "advertise these locales" set an editor
164
+ // curates per document. The deliberate counterpart to the derived,
165
+ // version-grained `byline_document_version_locales` ledger: this is intent
166
+ // ("I want these advertised"), the ledger is fact ("this version is complete
167
+ // in these"). Document-grain and sticky across versions — editorial intent
168
+ // carries forward across edits and survives restore. Surfaced on reads as
169
+ // `availableLocales`; the public advertised set is the intersection with the
170
+ // ledger's `_availableVersionLocales`. Replaced wholesale on write (the lifecycle
171
+ // deletes then re-inserts the set), never appended. See docs/07-internationalization/index.md.
172
+ export const documentAvailableLocales = mysqlTable('byline_document_available_locales', {
173
+ document_id: uuidChar('document_id').notNull(),
174
+ locale: varchar('locale', { length: 10 }).notNull(),
175
+ collection_id: uuidChar('collection_id').notNull(),
176
+ ...timestamps,
177
+ }, (table) => [
178
+ foreignKey({
179
+ name: 'fk_document_available_locales_document_id',
180
+ columns: [table.document_id],
181
+ foreignColumns: [documents.id],
182
+ }).onDelete('cascade'),
183
+ foreignKey({
184
+ name: 'fk_document_available_locales_collection_id',
185
+ columns: [table.collection_id],
186
+ foreignColumns: [collections.id],
187
+ }).onDelete('cascade'),
188
+ // One row per (logical document, advertised locale).
189
+ primaryKey({ columns: [table.document_id, table.locale] }),
190
+ // Reverse lookup by document for the read projection.
191
+ index('idx_document_available_locales_document_id').on(table.document_id),
192
+ ]);
193
+ // Document version → available content locales. One row per (version, locale)
194
+ // for every locale the version's content is *complete* in — path-coverage
195
+ // against the default content locale: a locale is recorded only when it covers
196
+ // every localized field path the default locale has. A version with no
197
+ // localized content at all gets a single `'all'` sentinel row (it renders
198
+ // identically in any locale). Computed status-blind at write time and frozen
199
+ // on the immutable version, so restore / point-in-time reads stay consistent.
200
+ // Drives `localeFallback: 'strict'` reads via an indexed EXISTS gate without
201
+ // scanning the store_* tables. See docs/07-internationalization/index.md.
202
+ export const documentVersionLocales = mysqlTable('byline_document_version_locales', {
203
+ document_version_id: uuidChar('document_version_id').notNull(),
204
+ locale: varchar('locale', { length: 10 }).notNull(),
205
+ }, (table) => [
206
+ foreignKey({
207
+ name: 'fk_document_version_locales_document_version_id',
208
+ columns: [table.document_version_id],
209
+ foreignColumns: [documentVersions.id],
210
+ }).onDelete('cascade'),
211
+ primaryKey({ columns: [table.document_version_id, table.locale] }),
212
+ ]);
213
+ // Document Tree — single-parent ordered adjacency. See docs/04-collections/04-document-trees.md.
214
+ //
215
+ // A document-grain, unversioned hierarchy primitive for `tree: true`
216
+ // collections (self-referential, single collection). Rows reference the logical
217
+ // `document_id`, not the version `id`. Unlike most edge tables the FKs are
218
+ // load-bearing here:
219
+ // - child → cascade : when the document is deleted, its membership row
220
+ // disappears (the node leaves the tree).
221
+ // - parent → set null: when a parent document is deleted, its children's
222
+ // parent pointer clears — they promote to root.
223
+ export const documentRelationships = mysqlTable('byline_document_relationships', {
224
+ child_document_id: uuidChar('child_document_id').notNull(),
225
+ // Nullable = root node. `set null` promotes orphans to root on parent delete.
226
+ parent_document_id: uuidChar('parent_document_id'),
227
+ // Per-parent sibling order (each parent is its own keyspace). ascii_bin
228
+ // so DB ordering matches the JS fractional-index algorithm, exactly like
229
+ // `byline_documents.order_key`.
230
+ order_key: varcharByteSorted('order_key', { length: 128 }).notNull(),
231
+ ...timestamps,
232
+ }, (table) => [
233
+ foreignKey({
234
+ name: 'fk_document_relationships_child_document_id',
235
+ columns: [table.child_document_id],
236
+ foreignColumns: [documents.id],
237
+ }).onDelete('cascade'),
238
+ foreignKey({
239
+ name: 'fk_document_relationships_parent_document_id',
240
+ columns: [table.parent_document_id],
241
+ foreignColumns: [documents.id],
242
+ }).onDelete('set null'),
243
+ // Single-parent invariant: each document appears in at most one row.
244
+ unique('uq_document_relationships_child').on(table.child_document_id),
245
+ // Per-parent sibling read, in order — drives the authoring tree and the
246
+ // read-side flatten.
247
+ index('idx_document_relationships_parent_order').on(table.parent_document_id, table.order_key),
248
+ ]);
249
+ // Current Documents View — latest version of each logical document via
250
+ // `ROW_NUMBER() OVER (PARTITION BY document_id ORDER BY id DESC)`.
251
+ //
252
+ // MySQL forbade subqueries in a view's FROM clause until 8.0.14 — the same
253
+ // release that added LATERAL — which is the real reason this adapter's
254
+ // engine floor is 8.0.14 (see `src/lib/boot-check.ts`). Ported here with
255
+ // the same CTE-backed shape as the Postgres view.
256
+ //
257
+ // `path` is intentionally NOT projected here. Path resolution is locale-
258
+ // aware and lives in the storage adapter's read functions, which join
259
+ // `byline_document_paths` with the requested locale + default-locale
260
+ // fallback. See docs/04-collections/05-document-paths.md.
261
+ export const currentDocumentsView = mysqlView('byline_current_documents').as((qb) => {
262
+ const sq = qb.$with('sq').as(qb
263
+ .select({
264
+ id: documentVersions.id,
265
+ document_id: documentVersions.document_id,
266
+ collection_id: documentVersions.collection_id,
267
+ collection_version: documentVersions.collection_version,
268
+ event_type: documentVersions.event_type,
269
+ status: documentVersions.status,
270
+ is_deleted: documentVersions.is_deleted,
271
+ created_at: documentVersions.created_at,
272
+ updated_at: documentVersions.updated_at,
273
+ created_by: documentVersions.created_by,
274
+ change_summary: documentVersions.change_summary,
275
+ rn: sql `row_number() OVER (PARTITION BY ${documentVersions.document_id} ORDER BY ${documentVersions.id} DESC)`.as('rn'),
276
+ })
277
+ .from(documentVersions)
278
+ .where(eq(documentVersions.is_deleted, false)));
279
+ // `order_key` is sourced from `byline_documents` (the logical-document
280
+ // row, not the version row). Joining it through the view keeps
281
+ // `d.order_key` addressable in findDocuments' ORDER BY without an
282
+ // ad-hoc join per query. Always nullable; null sorts last for
283
+ // collections that haven't opted in to `orderable: true`.
284
+ return qb
285
+ .with(sq)
286
+ .select({
287
+ id: sq.id,
288
+ document_id: sq.document_id,
289
+ collection_id: sq.collection_id,
290
+ collection_version: sq.collection_version,
291
+ event_type: sq.event_type,
292
+ status: sq.status,
293
+ is_deleted: sq.is_deleted,
294
+ created_at: sq.created_at,
295
+ updated_at: sq.updated_at,
296
+ created_by: sq.created_by,
297
+ change_summary: sq.change_summary,
298
+ order_key: documents.order_key,
299
+ // The document's content-locale anchor, projected here so locale-aware
300
+ // read paths (`buildLocaleChain` / `pathProjection` / field-fallback)
301
+ // re-base onto the per-document source rather than the mutable global
302
+ // default — a primary-key join, already present for `order_key`.
303
+ // See docs/07-internationalization/index.md.
304
+ source_locale: documents.source_locale,
305
+ })
306
+ .from(sq)
307
+ .innerJoin(documents, eq(documents.id, sq.document_id))
308
+ .where(eq(sq.rn, 1));
309
+ });
310
+ // Current Published Documents View - gets the latest version of each logical
311
+ // document whose status is 'published', regardless of whether a newer draft
312
+ // version exists. Used by `readMode: 'published'` on reads so public
313
+ // consumers keep seeing the last published content while editors work on
314
+ // drafts. Row-wise shape is identical to `current_documents`.
315
+ export const currentPublishedDocumentsView = mysqlView('byline_current_published_documents').as((qb) => {
316
+ const sq = qb.$with('sq').as(qb
317
+ .select({
318
+ id: documentVersions.id,
319
+ document_id: documentVersions.document_id,
320
+ collection_id: documentVersions.collection_id,
321
+ collection_version: documentVersions.collection_version,
322
+ event_type: documentVersions.event_type,
323
+ status: documentVersions.status,
324
+ is_deleted: documentVersions.is_deleted,
325
+ created_at: documentVersions.created_at,
326
+ updated_at: documentVersions.updated_at,
327
+ created_by: documentVersions.created_by,
328
+ change_summary: documentVersions.change_summary,
329
+ rn: sql `row_number() OVER (PARTITION BY ${documentVersions.document_id} ORDER BY ${documentVersions.id} DESC)`.as('rn'),
330
+ })
331
+ .from(documentVersions)
332
+ .where(sql `${documentVersions.is_deleted} = false AND ${documentVersions.status} = 'published'`));
333
+ return qb
334
+ .with(sq)
335
+ .select({
336
+ id: sq.id,
337
+ document_id: sq.document_id,
338
+ collection_id: sq.collection_id,
339
+ collection_version: sq.collection_version,
340
+ event_type: sq.event_type,
341
+ status: sq.status,
342
+ is_deleted: sq.is_deleted,
343
+ created_at: sq.created_at,
344
+ updated_at: sq.updated_at,
345
+ created_by: sq.created_by,
346
+ change_summary: sq.change_summary,
347
+ order_key: documents.order_key,
348
+ // See `currentDocumentsView` — the per-document content-locale anchor,
349
+ // carried for locale-aware reads. PK join, already present.
350
+ source_locale: documents.source_locale,
351
+ })
352
+ .from(sq)
353
+ .innerJoin(documents, eq(documents.id, sq.document_id))
354
+ .where(eq(sq.rn, 1));
355
+ });
356
+ // Base field values structure
357
+ const baseStoreColumns = {
358
+ id: uuidChar('id').primaryKey(),
359
+ document_version_id: uuidChar('document_version_id').notNull(), // References the version ID
360
+ collection_id: uuidChar('collection_id').notNull(), // For cross-collection queries
361
+ // Kept at 500, matching the Postgres schema's `field_path` bound exactly
362
+ // (`packages/db-postgres/src/database/schema/index.ts`) — an earlier
363
+ // version of this file widened it to 512, but that was a mistake: it
364
+ // both broke parity with pg for no reason and left `parent_path` (below,
365
+ // a prefix of `field_path`) too narrow to hold a maximal `field_path`
366
+ // value, which would raise `ER_DATA_TOO_LONG` under strict mode on
367
+ // MySQL — a state unreachable on the pg adapter. The store tables'
368
+ // tightest unique key — (document_version_id, field_path, locale) — is
369
+ // 2076 bytes under InnoDB's 3072-byte DYNAMIC index-key cap: 36
370
+ // (char(36) ascii) + 2000 (varchar(500) utf8mb4) + 40 (varchar(10)
371
+ // utf8mb4). `idx_text_path_value` (field_path + a 191-char prefix of
372
+ // `value`) is the tightest *non-unique* index on this table at 2764
373
+ // bytes. See `schema-pins.test.node.ts`, which pins both.
374
+ field_path: varchar('field_path', { length: 500 }).notNull(),
375
+ field_name: varchar('field_name', { length: 255 }).notNull(),
376
+ locale: varchar('locale', { length: 10 }).notNull().default('default'),
377
+ parent_path: varchar('parent_path', { length: 500 }),
378
+ ...timestamps,
379
+ };
380
+ // 1. TEXT FIELDS TABLE
381
+ export const textStore = mysqlTable('byline_store_text', {
382
+ ...baseStoreColumns,
383
+ value: text('value').notNull(),
384
+ word_count: int('word_count'), // Pre-computed for analytics
385
+ }, (table) => [
386
+ foreignKey({
387
+ name: 'fk_store_text_document_version_id',
388
+ columns: [table.document_version_id],
389
+ foreignColumns: [documentVersions.id],
390
+ }).onDelete('cascade'),
391
+ foreignKey({
392
+ name: 'fk_store_text_collection_id',
393
+ columns: [table.collection_id],
394
+ foreignColumns: [collections.id],
395
+ }).onDelete('cascade'),
396
+ // Optimized indexes for text operations. MySQL requires an explicit
397
+ // key-length prefix to index a TEXT column (error 1170 otherwise) —
398
+ // 191 is the conventional MySQL prefix length (the largest that stays
399
+ // under the historical 767-byte REDUNDANT/COMPACT index-key limit at
400
+ // 4 bytes/char for utf8mb4: 767 / 4 ≈ 191), kept here even though this
401
+ // schema's DYNAMIC row format has more headroom, for portability.
402
+ index('idx_text_value').on(sql `${table.value}(191)`),
403
+ // Dropped: Postgres's GIN index over `to_tsvector('english', value)`
404
+ // has no MySQL equivalent through this schema layer (MySQL's own
405
+ // full-text index type is not exposed by drizzle-kit's mysql-core index
406
+ // builder, which only supports 'btree' | 'hash'). Not load-bearing —
407
+ // no query code references this index name — and Byline's actual
408
+ // full-text search is a separate, pluggable `SearchProvider` seam
409
+ // (`@byline/core`), not built on this table's indexes. See
410
+ // docs/05-reading-and-delivery/07-search.md.
411
+ index('idx_text_locale_value').on(table.locale, sql `${table.value}(191)`),
412
+ index('idx_text_path_value').on(table.field_path, sql `${table.value}(191)`),
413
+ // Unique constraints for unique fields
414
+ unique('unique_text_field').on(table.document_version_id, table.field_path, table.locale),
415
+ ]);
416
+ // 2. NUMERIC FIELDS TABLE
417
+ export const numericStore = mysqlTable('byline_store_numeric', {
418
+ ...baseStoreColumns,
419
+ // Store the original number type for reconstruction
420
+ number_type: varchar('number_type', { length: 20 }).notNull(), // 'integer', 'decimal', 'float'
421
+ value_integer: int('value_integer'),
422
+ value_decimal: decimal('value_decimal', { precision: 10, scale: 2 }),
423
+ // Postgres `real` (single-precision float) → MySQL `FLOAT` (also
424
+ // single-precision). MySQL's `REAL` is a configurable alias for
425
+ // `DOUBLE` unless the server runs with `REAL_AS_FLOAT`, so `float()` is
426
+ // the unambiguous match rather than the `real()` name.
427
+ value_float: float('value_float'),
428
+ }, (table) => [
429
+ foreignKey({
430
+ name: 'fk_store_numeric_document_version_id',
431
+ columns: [table.document_version_id],
432
+ foreignColumns: [documentVersions.id],
433
+ }).onDelete('cascade'),
434
+ foreignKey({
435
+ name: 'fk_store_numeric_collection_id',
436
+ columns: [table.collection_id],
437
+ foreignColumns: [collections.id],
438
+ }).onDelete('cascade'),
439
+ // Optimized indexes for numeric operations
440
+ index('idx_numeric_integer').on(table.value_integer),
441
+ index('idx_numeric_decimal').on(table.value_decimal),
442
+ index('idx_numeric_float').on(table.value_float),
443
+ // Range indexes for common queries
444
+ index('idx_numeric_integer_range').on(table.field_path, table.value_integer),
445
+ index('idx_numeric_decimal_range').on(table.field_path, table.value_decimal),
446
+ unique('unique_numeric_field').on(table.document_version_id, table.field_path, table.locale),
447
+ ]);
448
+ // 3. BOOLEAN FIELDS TABLE
449
+ export const booleanStore = mysqlTable('byline_store_boolean', {
450
+ ...baseStoreColumns,
451
+ value: boolean('value').notNull(),
452
+ }, (table) => [
453
+ foreignKey({
454
+ name: 'fk_store_boolean_document_version_id',
455
+ columns: [table.document_version_id],
456
+ foreignColumns: [documentVersions.id],
457
+ }).onDelete('cascade'),
458
+ foreignKey({
459
+ name: 'fk_store_boolean_collection_id',
460
+ columns: [table.collection_id],
461
+ foreignColumns: [collections.id],
462
+ }).onDelete('cascade'),
463
+ // Simple but effective indexes for boolean queries
464
+ index('idx_boolean_value').on(table.value),
465
+ index('idx_boolean_path_value').on(table.field_path, table.value),
466
+ index('idx_boolean_collection_value').on(table.collection_id, table.field_path, table.value),
467
+ unique('unique_boolean_field').on(table.document_version_id, table.field_path, table.locale),
468
+ ]);
469
+ // 4. DATE/TIME FIELDS TABLE
470
+ export const datetimeStore = mysqlTable('byline_store_datetime', {
471
+ ...baseStoreColumns,
472
+ // Store the original date type for reconstruction
473
+ date_type: varchar('date_type', { length: 20 }).notNull(), // 'date', 'time', 'timestamptz'
474
+ value_date: date('value_date'),
475
+ // fsp 3, matching the fsp discipline used everywhere else in this
476
+ // schema (spec §2). Without an explicit fsp, MySQL's `TIME` defaults
477
+ // to whole-second precision, silently truncating a fractional time
478
+ // value that round-trips fine on Postgres — whose `time` column (also
479
+ // declared with no explicit precision) defaults to microsecond
480
+ // precision instead. `time` is a real Byline field type
481
+ // (`packages/core/src/storage/field-store-map.ts`), so this isn't a
482
+ // hypothetical: a document with a fractional-second time value would
483
+ // read back truncated after a write, MySQL-only.
484
+ value_time: time('value_time', { fsp: 3 }),
485
+ // Postgres `timestamptz` → `datetime(6)`. pg declares this column with
486
+ // no explicit `precision`, which defaults to TIMESTAMPTZ's full
487
+ // microsecond resolution — fsp 6 matches that exactly, and keeps this
488
+ // column on the same precision as every other `datetime(...)` in this
489
+ // schema (see `common.ts`'s `auditTimestamp` doc comment for why a
490
+ // mixed-precision schema is worse than a uniform one). UTC by
491
+ // convention — see `common.ts`'s `auditTimestamp` doc comment.
492
+ value_timestamp_tz: datetime('value_timestamp_tz', { fsp: 6 }),
493
+ }, (table) => [
494
+ foreignKey({
495
+ name: 'fk_store_datetime_document_version_id',
496
+ columns: [table.document_version_id],
497
+ foreignColumns: [documentVersions.id],
498
+ }).onDelete('cascade'),
499
+ foreignKey({
500
+ name: 'fk_store_datetime_collection_id',
501
+ columns: [table.collection_id],
502
+ foreignColumns: [collections.id],
503
+ }).onDelete('cascade'),
504
+ // Optimized for date range queries
505
+ index('idx_datetime_date').on(table.value_date),
506
+ index('idx_datetime_timestamp_tz').on(table.value_timestamp_tz),
507
+ // Common date query patterns
508
+ index('idx_datetime_path_date').on(table.field_path, table.value_timestamp_tz),
509
+ index('idx_datetime_collection_date').on(table.collection_id, table.value_timestamp_tz),
510
+ unique('unique_datetime_field').on(table.document_version_id, table.field_path, table.locale),
511
+ ]);
512
+ // 5. RELATION FIELDS TABLE
513
+ export const relationStore = mysqlTable('byline_store_relation', {
514
+ ...baseStoreColumns,
515
+ target_document_id: uuidChar('target_document_id').notNull(),
516
+ target_collection_id: uuidChar('target_collection_id').notNull(),
517
+ // Relationship metadata
518
+ relationship_type: varchar('relationship_type', { length: 50 }).default('reference'), // 'reference', 'embed', 'weak'
519
+ cascade_delete: boolean('cascade_delete').default(false),
520
+ }, (table) => [
521
+ foreignKey({
522
+ name: 'fk_store_relation_document_version_id',
523
+ columns: [table.document_version_id],
524
+ foreignColumns: [documentVersions.id],
525
+ }).onDelete('cascade'),
526
+ foreignKey({
527
+ name: 'fk_store_relation_collection_id',
528
+ columns: [table.collection_id],
529
+ foreignColumns: [collections.id],
530
+ }).onDelete('cascade'),
531
+ // No onDelete — matches the Postgres schema's `.references(() =>
532
+ // documents.id)` / `.references(() => collections.id)` without an
533
+ // onDelete option, which defaults to NO ACTION on both dialects.
534
+ foreignKey({
535
+ name: 'fk_store_relation_target_document_id',
536
+ columns: [table.target_document_id],
537
+ foreignColumns: [documents.id],
538
+ }),
539
+ foreignKey({
540
+ name: 'fk_store_relation_target_collection_id',
541
+ columns: [table.target_collection_id],
542
+ foreignColumns: [collections.id],
543
+ }),
544
+ // Critical indexes for relationship queries
545
+ index('idx_relation_target_document').on(table.target_document_id),
546
+ index('idx_relation_target_collection').on(table.target_collection_id),
547
+ index('idx_relation_type').on(table.relationship_type),
548
+ // Reverse relationship lookup
549
+ index('idx_relation_reverse').on(table.target_document_id, table.field_path),
550
+ // Cross-collection relationship queries
551
+ index('idx_relation_collection_to_collection').on(table.collection_id, table.target_collection_id),
552
+ unique('unique_relation_field').on(table.document_version_id, table.field_path, table.locale),
553
+ ]);
554
+ // Generic meta store for document nodes (blocks, array items, fields, etc.)
555
+ // This allows attaching durable IDs and arbitrary metadata to any node
556
+ // in a document tree, keyed by document version and path.
557
+ export const metaStore = mysqlTable('byline_store_meta', {
558
+ id: uuidChar('id').primaryKey(),
559
+ document_version_id: uuidChar('document_version_id').notNull(),
560
+ collection_id: uuidChar('collection_id').notNull(),
561
+ // Node classification and linkage back into the reconstructed tree.
562
+ // Bounded (Postgres uses unbounded `text`) because MySQL cannot index
563
+ // an unbounded TEXT/BLOB column without an explicit key-length prefix,
564
+ // and both columns below participate in `unique_meta_node`. 50/512
565
+ // keep the tightest key on this table — 36 (id, ascii) + 200 (type,
566
+ // utf8mb4) + 2048 (path, utf8mb4) = 2284 bytes — comfortably under the
567
+ // 3072-byte InnoDB cap; see `schema-pins.test.node.ts`.
568
+ type: varchar('type', { length: 50 }).notNull(),
569
+ path: varchar('path', { length: 512 }).notNull(),
570
+ // Durable identifier for this item within a document version. This is the
571
+ // ID exposed to the dashboard/API for blocks, array items, etc.
572
+ item_id: varchar('item_id', { length: 255 }).notNull(),
573
+ // Optional opaque metadata payload for this node. Common attributes like
574
+ // label, icon, collapsed state, etc. can be stored here.
575
+ meta: json('meta'),
576
+ ...timestamps,
577
+ }, (table) => [
578
+ foreignKey({
579
+ name: 'fk_store_meta_document_version_id',
580
+ columns: [table.document_version_id],
581
+ foreignColumns: [documentVersions.id],
582
+ }).onDelete('cascade'),
583
+ foreignKey({
584
+ name: 'fk_store_meta_collection_id',
585
+ columns: [table.collection_id],
586
+ foreignColumns: [collections.id],
587
+ }).onDelete('cascade'),
588
+ // Fast lookup by document and node type/path when enriching reconstructed
589
+ // trees with meta information.
590
+ index('idx_meta_document_type_path').on(table.document_version_id, table.type, table.path),
591
+ // Resolve durable IDs (e.g. for array.move by item_id) back to a node path.
592
+ index('idx_meta_item_id').on(table.item_id),
593
+ // Support queries scoped by collection and type (e.g. all blocks in a collection).
594
+ index('idx_meta_collection_type').on(table.collection_id, table.type),
595
+ // Ensure only a single meta row exists for a given node in a document version.
596
+ unique('unique_meta_node').on(table.document_version_id, table.type, table.path),
597
+ ]);
598
+ // 6. FILE FIELDS TABLE (Your composite type example)
599
+ export const fileStore = mysqlTable('byline_store_file', {
600
+ ...baseStoreColumns,
601
+ // File identity
602
+ file_id: uuidChar('file_id').notNull(), // Reference to file storage system
603
+ filename: varchar('filename', { length: 255 }).notNull(),
604
+ original_filename: varchar('original_filename', { length: 255 }).notNull(),
605
+ // File metadata
606
+ mime_type: varchar('mime_type', { length: 100 }).notNull(),
607
+ file_size: bigint('file_size', { mode: 'number' }).notNull(), // Size in bytes
608
+ file_hash: varchar('file_hash', { length: 64 }), // SHA-256 hash for deduplication
609
+ // Storage information
610
+ storage_provider: varchar('storage_provider', { length: 50 }).notNull(), // 'local', 's3', 'gcs', etc.
611
+ storage_path: text('storage_path').notNull(),
612
+ storage_url: text('storage_url'), // CDN or direct URL
613
+ // Image-specific metadata (when applicable)
614
+ image_width: int('image_width'),
615
+ image_height: int('image_height'),
616
+ image_format: varchar('image_format', { length: 20 }),
617
+ // File processing status
618
+ processing_status: varchar('processing_status', { length: 20 }).default('pending'), // 'pending', 'processing', 'completed', 'failed'
619
+ thumbnail_generated: boolean('thumbnail_generated').default(false),
620
+ // Image variants (Sharp-generated derivatives). Persisted as JSON so
621
+ // the read path can return a `<picture>` / `srcset`-ready array
622
+ // without a sidecar table. Shape: FileStoreVariant[] —
623
+ // { name, storage_path, storage_url?, width?, height?, format? }.
624
+ variants: json('variants'),
625
+ }, (table) => [
626
+ foreignKey({
627
+ name: 'fk_store_file_document_version_id',
628
+ columns: [table.document_version_id],
629
+ foreignColumns: [documentVersions.id],
630
+ }).onDelete('cascade'),
631
+ foreignKey({
632
+ name: 'fk_store_file_collection_id',
633
+ columns: [table.collection_id],
634
+ foreignColumns: [collections.id],
635
+ }).onDelete('cascade'),
636
+ // File-specific indexes
637
+ index('idx_file_file_id').on(table.file_id),
638
+ index('idx_file_mime_type').on(table.mime_type),
639
+ index('idx_file_size').on(table.file_size),
640
+ index('idx_file_hash').on(table.file_hash),
641
+ // Image queries
642
+ index('idx_file_image_dimensions').on(table.image_width, table.image_height),
643
+ // Storage queries
644
+ index('idx_file_storage_provider').on(table.storage_provider),
645
+ index('idx_file_processing_status').on(table.processing_status),
646
+ unique('unique_file_field').on(table.document_version_id, table.field_path, table.locale),
647
+ ]);
648
+ // 7. JSON/STRUCTURED DATA FIELDS TABLE
649
+ export const jsonStore = mysqlTable('byline_store_json', {
650
+ ...baseStoreColumns,
651
+ value: json('value').notNull(),
652
+ // JSON metadata for optimization
653
+ json_schema: varchar('json_schema', { length: 100 }), // Schema identifier for validation
654
+ // Postgres `text[]` (array of top-level keys) → MySQL `json`, storing a
655
+ // JSON array of strings. MySQL has no native array column type.
656
+ object_keys: json('object_keys'),
657
+ }, (table) => [
658
+ foreignKey({
659
+ name: 'fk_store_json_document_version_id',
660
+ columns: [table.document_version_id],
661
+ foreignColumns: [documentVersions.id],
662
+ }).onDelete('cascade'),
663
+ foreignKey({
664
+ name: 'fk_store_json_collection_id',
665
+ columns: [table.collection_id],
666
+ foreignColumns: [collections.id],
667
+ }).onDelete('cascade'),
668
+ // Dropped: `idx_json_value_gin` (Postgres GIN over the whole `value`
669
+ // jsonb blob, for containment queries) and `idx_json_keys` (GIN over
670
+ // the `object_keys` array) have no MySQL equivalent through this schema
671
+ // layer — MySQL cannot index a JSON column directly; doing so requires
672
+ // a generated/virtual column over a specific JSON path expression,
673
+ // which is a different (narrower) indexing strategy than "index the
674
+ // whole document," not a straight port. Neither index name is
675
+ // referenced by any query code today.
676
+ index('idx_json_schema').on(table.json_schema),
677
+ unique('unique_json_field').on(table.document_version_id, table.field_path, table.locale),
678
+ ]);
679
+ // ---------------------------------------------------------------------------
680
+ // Counter groups registry
681
+ // ---------------------------------------------------------------------------
682
+ //
683
+ // One row per counter `group` discovered in collection field definitions,
684
+ // plus one row per runtime-scoped counter self-registered by
685
+ // `nextScopedCounterValue`. On Postgres the actual ID allocator is a
686
+ // SEQUENCE object (named in `sequence_name`), reconciled at boot by
687
+ // `IDbAdapter.ensureCounterGroup`. MySQL has no native user-defined SEQUENCE
688
+ // object (unlike Postgres, or MariaDB's own extension), so this table IS the
689
+ // allocator (Task 11 decision): `current_value` is the counter state itself,
690
+ // advanced in place via `UPDATE ... SET current_value = LAST_INSERT_ID(current_value + 1)`
691
+ // (`nextCounterValue`) or, for first-use self-registration, a single
692
+ // `INSERT ... ON DUPLICATE KEY UPDATE current_value = LAST_INSERT_ID(current_value + 1)`
693
+ // (`nextScopedCounterValue`) — both read the freshly-assigned value back via
694
+ // `SELECT LAST_INSERT_ID()` on the same checked-out connection. Static and
695
+ // scoped counters deliberately share this one table/row-per-name rather than
696
+ // splitting scoped counters into a second table: `ICounterCommands` documents
697
+ // that `nextCounterValue` on a name `nextScopedCounterValue` has already
698
+ // registered must continue the very same count — a second table could not
699
+ // honour that without every scoped call double-writing to keep both in sync.
700
+ // See `packages/db-mysql/src/modules/counters/counters-commands.ts`.
701
+ //
702
+ // Why a separate table rather than reading allocator objects from
703
+ // `information_schema`: the mapping from `group_name` → allocator identity
704
+ // belongs in the application's schema, not in MySQL metadata, so backups
705
+ // and adapter logic have a stable name to anchor against.
706
+ //
707
+ // `group_name` is bounded (Postgres uses unbounded `text`) because it is
708
+ // this table's primary key, and MySQL cannot index — including as a
709
+ // PRIMARY KEY — an unbounded TEXT/BLOB column without an explicit
710
+ // key-length prefix. 255 matches `byline_collections.path`'s bound, the
711
+ // closest sibling "short identifier" column in this schema.
712
+ export const counterGroups = mysqlTable('byline_counter_groups', {
713
+ group_name: varchar('group_name', { length: 255 }).primaryKey(),
714
+ sequence_name: text('sequence_name').notNull(),
715
+ // The counter's live state on this dialect (see comment block above).
716
+ // Postgres has no equivalent column — its allocator is a SEQUENCE object.
717
+ current_value: bigint('current_value', { mode: 'number' }).notNull().default(0),
718
+ ...createdAt,
719
+ });
720
+ // RELATIONS
721
+ // =========
722
+ export const collectionsRelations = relations(collections, ({ many }) => ({
723
+ documents: many(documentVersions),
724
+ text_values: many(textStore),
725
+ numeric_values: many(numericStore),
726
+ boolean_values: many(booleanStore),
727
+ datetime_values: many(datetimeStore),
728
+ relation_values: many(relationStore, { relationName: 'source_collection' }),
729
+ file_values: many(fileStore),
730
+ json_values: many(jsonStore),
731
+ }));
732
+ export const documentsRelations = relations(documentVersions, ({ one, many }) => ({
733
+ collection: one(collections, {
734
+ fields: [documentVersions.collection_id],
735
+ references: [collections.id],
736
+ }),
737
+ // Relations for field values
738
+ text_values: many(textStore),
739
+ numeric_values: many(numericStore),
740
+ boolean_values: many(booleanStore),
741
+ datetime_values: many(datetimeStore),
742
+ relation_values: many(relationStore),
743
+ file_values: many(fileStore),
744
+ json_values: many(jsonStore),
745
+ }));
746
+ export const documentRelationshipsRelations = relations(documentRelationships, ({ one }) => ({
747
+ parent: one(documents, {
748
+ fields: [documentRelationships.parent_document_id],
749
+ references: [documents.id],
750
+ relationName: 'tree_parent',
751
+ }),
752
+ child: one(documents, {
753
+ fields: [documentRelationships.child_document_id],
754
+ references: [documents.id],
755
+ relationName: 'tree_child',
756
+ }),
757
+ }));
758
+ // Document-tree edges on the logical document. The tree read path itself is a
759
+ // recursive CTE (see docs/04-collections/04-document-trees.md), not the Drizzle query builder —
760
+ // these relations exist for completeness / ad-hoc joins.
761
+ export const documentTreeRelations = relations(documents, ({ many }) => ({
762
+ // The membership edge where this document is the child — its placement in the
763
+ // tree. 0..1 by the unique(child_document_id) constraint; modelled as `many`
764
+ // per Drizzle's inverse-side convention (the FK lives on the edge table).
765
+ tree_parent_edge: many(documentRelationships, { relationName: 'tree_child' }),
766
+ // Edges where this document is the parent — its ordered children.
767
+ tree_child_edges: many(documentRelationships, { relationName: 'tree_parent' }),
768
+ }));
769
+ // Field value relations
770
+ export const textStoreRelations = relations(textStore, ({ one }) => ({
771
+ document: one(documentVersions, {
772
+ fields: [textStore.document_version_id],
773
+ references: [documentVersions.id],
774
+ }),
775
+ collection: one(collections, {
776
+ fields: [textStore.collection_id],
777
+ references: [collections.id],
778
+ }),
779
+ }));
780
+ export const numericStoreRelations = relations(numericStore, ({ one }) => ({
781
+ document: one(documentVersions, {
782
+ fields: [numericStore.document_version_id],
783
+ references: [documentVersions.id],
784
+ }),
785
+ collection: one(collections, {
786
+ fields: [numericStore.collection_id],
787
+ references: [collections.id],
788
+ }),
789
+ }));
790
+ export const booleanStoreRelations = relations(booleanStore, ({ one }) => ({
791
+ document: one(documentVersions, {
792
+ fields: [booleanStore.document_version_id],
793
+ references: [documentVersions.id],
794
+ }),
795
+ collection: one(collections, {
796
+ fields: [booleanStore.collection_id],
797
+ references: [collections.id],
798
+ }),
799
+ }));
800
+ export const datetimeStoreRelations = relations(datetimeStore, ({ one }) => ({
801
+ document: one(documentVersions, {
802
+ fields: [datetimeStore.document_version_id],
803
+ references: [documentVersions.id],
804
+ }),
805
+ collection: one(collections, {
806
+ fields: [datetimeStore.collection_id],
807
+ references: [collections.id],
808
+ }),
809
+ }));
810
+ export const relationStoreRelations = relations(relationStore, ({ one }) => ({
811
+ document: one(documentVersions, {
812
+ fields: [relationStore.document_version_id],
813
+ references: [documentVersions.id],
814
+ }),
815
+ collection: one(collections, {
816
+ fields: [relationStore.collection_id],
817
+ references: [collections.id],
818
+ relationName: 'source_collection',
819
+ }),
820
+ // This relation is now based on the logical document_id.
821
+ // Note: This will relate to *all* versions of the document.
822
+ // You will typically query against the `currentDocumentsView` to get the latest version.
823
+ target_document: one(documentVersions, {
824
+ fields: [relationStore.target_document_id],
825
+ references: [documentVersions.document_id],
826
+ }),
827
+ target_collection: one(collections, {
828
+ fields: [relationStore.target_collection_id],
829
+ references: [collections.id],
830
+ }),
831
+ }));
832
+ export const fileStoreRelations = relations(fileStore, ({ one }) => ({
833
+ document: one(documentVersions, {
834
+ fields: [fileStore.document_version_id],
835
+ references: [documentVersions.id],
836
+ }),
837
+ collection: one(collections, {
838
+ fields: [fileStore.collection_id],
839
+ references: [collections.id],
840
+ }),
841
+ }));
842
+ export const jsonStoreRelations = relations(jsonStore, ({ one }) => ({
843
+ document: one(documentVersions, {
844
+ fields: [jsonStore.document_version_id],
845
+ references: [documentVersions.id],
846
+ }),
847
+ collection: one(collections, {
848
+ fields: [jsonStore.collection_id],
849
+ references: [collections.id],
850
+ }),
851
+ }));
852
+ // ---------------------------------------------------------------------------
853
+ // Audit log — byline_audit_log
854
+ // ---------------------------------------------------------------------------
855
+ // Document-grain audit log. Records the changes the immutable version stream
856
+ // does NOT capture an actor for: non-versioned system-field writes (path,
857
+ // availableLocales), in-place status transitions, and deletions — plus, later,
858
+ // admin-module events. One generic table (nullable `document_id`, namespaced
859
+ // `action`) so the system-wide activity report and future admin-realm auditing
860
+ // fit without a second migration. Append-only and deliberately **FK-free**: an
861
+ // audit row is an immutable historical fact that must outlive the document,
862
+ // collection, or actor it references — a `document.deleted` row cannot be
863
+ // allowed to cascade-delete itself. See docs/06-auth-and-security/02-auditability.md — Workstream 2.
864
+ export const auditLog = mysqlTable('byline_audit_log', {
865
+ id: uuidChar('id').primaryKey(), // UUIDv7 — time-ordered, so id ordering ≈ time ordering
866
+ document_id: uuidChar('document_id'), // NULL for admin-realm (non-document) events; no FK
867
+ collection_id: uuidChar('collection_id'), // no FK — outlives the collection
868
+ actor_id: uuidChar('actor_id'), // NULL = system / internal tooling / non-UUID synthetic actor
869
+ actor_realm: varchar('actor_realm', { length: 16 }).notNull(), // 'admin' | 'user' | 'system'
870
+ action: varchar('action', { length: 64 }).notNull(), // namespaced, e.g. 'document.path.changed'
871
+ field: varchar('field', { length: 128 }), // the changed field where meaningful (e.g. 'path'), else NULL
872
+ before: json('before'),
873
+ after: json('after'),
874
+ // fsp 6, matching pg's `timestamp('occurred_at', { precision: 6, withTimezone: true })`
875
+ // — see `common.ts`'s `auditTimestamp` doc comment for why every
876
+ // temporal column in this schema moved from fsp 3 to fsp 6. This is
877
+ // the column that surfaced the issue: at fsp 3, two audit rows (or an
878
+ // audit row and a version-stream row) appended in quick succession on a
879
+ // fast local connection could receive an identical or ambiguously
880
+ // ordered `occurred_at`, confirmed live via `packages/db-conformance`'s
881
+ // audit activity-feed fixture — see the Task 11 report.
882
+ occurred_at: datetime('occurred_at', { fsp: 6 }).notNull().default(sql `CURRENT_TIMESTAMP(6)`),
883
+ }, (table) => [
884
+ // Per-document history, time-ordered (id is UUIDv7).
885
+ index('idx_audit_log_document_id').on(table.document_id, table.id),
886
+ // Per-actor activity — the system-wide report's actor filter.
887
+ index('idx_audit_log_actor_id').on(table.actor_id, table.id),
888
+ // Action-type filter for the activity report.
889
+ index('idx_audit_log_action').on(table.action, table.id),
890
+ ]);
891
+ // ---------------------------------------------------------------------------
892
+ // Auth schema — byline_admin_users, byline_admin_roles, etc.
893
+ // See ./auth.ts for definitions and rationale.
894
+ // ---------------------------------------------------------------------------
895
+ export { adminPermissions, adminPermissionsRelations, adminRefreshTokens, adminRefreshTokensRelations, adminRoleAdminUser, adminRoleAdminUserRelations, adminRoles, adminRolesRelations, adminUserPreferences, adminUsers, adminUsersRelations, } from './auth.js';