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