@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,52 @@
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
+ * Schema pins — driven entirely off the Drizzle schema objects in
9
+ * `./index.ts` / `./auth.ts`, not a live database. These pin the
10
+ * dialect-critical properties spelled out in the Task 8 controller
11
+ * amendments (§F):
12
+ *
13
+ * 1. Every index's worst-case byte width — unique keys, primary keys,
14
+ * *and* plain secondary indexes — stays under InnoDB's 3072-byte
15
+ * `DYNAMIC` row-format index-key cap. Non-unique indexes are included
16
+ * deliberately: the binding constraint on some tables (e.g.
17
+ * `idx_text_path_value`) is a secondary index, not the table's
18
+ * tightest unique key, so checking unique keys alone would miss it.
19
+ * 2. Every id / FK column and every `order_key` column resolves to the
20
+ * `ascii_bin` collation (byte-wise, case-sensitive comparison —
21
+ * what makes id equality case-sensitive and `order_key`'s DB sort
22
+ * match JS string sort on the `generateKeyBetween` alphabet).
23
+ * 3. Every timestamp (instant) column is `datetime(6)` — microsecond
24
+ * precision, matching the Postgres adapter's `timestamp(..., {
25
+ * precision: 6, withTimezone: true })` exactly (see `common.ts`'s
26
+ * `auditTimestamp` docblock: this was `datetime(3)` originally, and a
27
+ * live `packages/db-conformance` run caught back-to-back statements on
28
+ * a fast local connection landing in the same millisecond tick and
29
+ * receiving an identical `CURRENT_TIMESTAMP(3)` value — a real
30
+ * correctness gap for anything ordering or windowing by these
31
+ * columns). Every bare time-of-day column is still `time(3)`,
32
+ * millisecond precision, so a fractional time value round-trips
33
+ * instead of silently truncating to whole seconds — `time` values are
34
+ * user-authored field data, not statement-ordering timestamps, so the
35
+ * race that motivated the instant-column bump upstream doesn't apply
36
+ * to them.
37
+ * 4. The `byline_document_paths` per-collection path-uniqueness index
38
+ * keeps the exact name `idx_document_paths_collection_locale_path`,
39
+ * because `packages/core/src/services/document-lifecycle/internals.ts`
40
+ * substring-matches this name against the adapter's `classifyError`
41
+ * constraint report to detect path collisions.
42
+ * 5. `byline_document_paths.path` carries the `utf8mb4_bin` collation
43
+ * (project-owner ruling) so path uniqueness is case- and
44
+ * accent-sensitive on MySQL exactly like it already is on Postgres —
45
+ * see `varcharCaseSensitive` in `./common.ts` for the Thai /
46
+ * Devanagari / Hebrew combining-mark evidence behind the ruling.
47
+ *
48
+ * Computed generically over the schema so a future column widening (or a
49
+ * new `_id` column that forgets `uuidChar`) trips this test rather than
50
+ * surfacing later as a mysterious production failure.
51
+ */
52
+ export {};
@@ -0,0 +1,398 @@
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
+ * Schema pins — driven entirely off the Drizzle schema objects in
9
+ * `./index.ts` / `./auth.ts`, not a live database. These pin the
10
+ * dialect-critical properties spelled out in the Task 8 controller
11
+ * amendments (§F):
12
+ *
13
+ * 1. Every index's worst-case byte width — unique keys, primary keys,
14
+ * *and* plain secondary indexes — stays under InnoDB's 3072-byte
15
+ * `DYNAMIC` row-format index-key cap. Non-unique indexes are included
16
+ * deliberately: the binding constraint on some tables (e.g.
17
+ * `idx_text_path_value`) is a secondary index, not the table's
18
+ * tightest unique key, so checking unique keys alone would miss it.
19
+ * 2. Every id / FK column and every `order_key` column resolves to the
20
+ * `ascii_bin` collation (byte-wise, case-sensitive comparison —
21
+ * what makes id equality case-sensitive and `order_key`'s DB sort
22
+ * match JS string sort on the `generateKeyBetween` alphabet).
23
+ * 3. Every timestamp (instant) column is `datetime(6)` — microsecond
24
+ * precision, matching the Postgres adapter's `timestamp(..., {
25
+ * precision: 6, withTimezone: true })` exactly (see `common.ts`'s
26
+ * `auditTimestamp` docblock: this was `datetime(3)` originally, and a
27
+ * live `packages/db-conformance` run caught back-to-back statements on
28
+ * a fast local connection landing in the same millisecond tick and
29
+ * receiving an identical `CURRENT_TIMESTAMP(3)` value — a real
30
+ * correctness gap for anything ordering or windowing by these
31
+ * columns). Every bare time-of-day column is still `time(3)`,
32
+ * millisecond precision, so a fractional time value round-trips
33
+ * instead of silently truncating to whole seconds — `time` values are
34
+ * user-authored field data, not statement-ordering timestamps, so the
35
+ * race that motivated the instant-column bump upstream doesn't apply
36
+ * to them.
37
+ * 4. The `byline_document_paths` per-collection path-uniqueness index
38
+ * keeps the exact name `idx_document_paths_collection_locale_path`,
39
+ * because `packages/core/src/services/document-lifecycle/internals.ts`
40
+ * substring-matches this name against the adapter's `classifyError`
41
+ * constraint report to detect path collisions.
42
+ * 5. `byline_document_paths.path` carries the `utf8mb4_bin` collation
43
+ * (project-owner ruling) so path uniqueness is case- and
44
+ * accent-sensitive on MySQL exactly like it already is on Postgres —
45
+ * see `varcharCaseSensitive` in `./common.ts` for the Thai /
46
+ * Devanagari / Hebrew combining-mark evidence behind the ruling.
47
+ *
48
+ * Computed generically over the schema so a future column widening (or a
49
+ * new `_id` column that forgets `uuidChar`) trips this test rather than
50
+ * surfacing later as a mysterious production failure.
51
+ */
52
+ import { is, SQL } from 'drizzle-orm';
53
+ import { getTableConfig, MySqlTable } from 'drizzle-orm/mysql-core';
54
+ import { describe, expect, it } from 'vitest';
55
+ import * as authSchema from './auth.js';
56
+ import * as coreSchema from './index.js';
57
+ // InnoDB's DYNAMIC row format (the default since MySQL 5.7 with
58
+ // innodb_large_prefix on, which is the default from 8.0 on) caps a single
59
+ // index key at 3072 bytes.
60
+ const INNODB_INDEX_KEY_BYTE_CAP = 3072;
61
+ function allTables() {
62
+ const tables = [];
63
+ for (const value of [...Object.values(coreSchema), ...Object.values(authSchema)]) {
64
+ if (is(value, MySqlTable)) {
65
+ tables.push(value);
66
+ }
67
+ }
68
+ return tables;
69
+ }
70
+ /**
71
+ * `ascii` counts 1 byte per character; anything else (the database's
72
+ * `utf8mb4` default charset) counts the InnoDB worst case of 4 bytes per
73
+ * character. Shared by both the whole-column and the prefixed-expression
74
+ * sizing below.
75
+ */
76
+ function bytesPerChar(sqlType) {
77
+ return /character set ascii/i.test(sqlType) ? 1 : 4;
78
+ }
79
+ /**
80
+ * Bytes of fractional-seconds storage MySQL adds to a `TIME`/`DATETIME`/
81
+ * `TIMESTAMP` base width for a given `fsp` (0-6): 0 for fsp 0, 1 byte for
82
+ * fsp 1-2, 2 bytes for fsp 3-4, 3 bytes for fsp 5-6. Every instant
83
+ * (`datetime`) column in this schema uses fsp 6 (see `common.ts`), which is
84
+ * 3 bytes; the one bare time-of-day column (`value_time`) stays at fsp 3
85
+ * (2 bytes) — see the timestamp-precision pin below for why the two are
86
+ * treated differently.
87
+ */
88
+ function fractionalSecondsBytes(fsp) {
89
+ if (fsp === 0)
90
+ return 0;
91
+ if (fsp <= 2)
92
+ return 1;
93
+ if (fsp <= 4)
94
+ return 2;
95
+ return 3;
96
+ }
97
+ /**
98
+ * InnoDB's packed-`DECIMAL` storage: 9 decimal digits pack into 4 bytes,
99
+ * with a partial-group table for the leftover 0-8 digits, applied
100
+ * separately to the integer and fractional digit counts.
101
+ */
102
+ function decimalStorageBytes(precision, scale) {
103
+ const partialGroupBytes = [0, 1, 1, 2, 2, 3, 3, 4, 4];
104
+ const digitBytes = (digits) => {
105
+ const fullGroups = Math.floor(digits / 9);
106
+ const leftover = digits % 9;
107
+ return fullGroups * 4 + (partialGroupBytes[leftover] ?? 0);
108
+ };
109
+ return digitBytes(precision - scale) + digitBytes(scale);
110
+ }
111
+ /**
112
+ * Worst-case byte width of a single column, given its rendered MySQL type
113
+ * string. `char`/`varchar` size by declared length × charset width (e.g.
114
+ * `varchar(500) CHARACTER SET ascii COLLATE ascii_bin`, or plain
115
+ * `varchar(10)` for the database's default `utf8mb4` charset — see
116
+ * `bytesPerChar`). Every other column type this schema's indexes actually
117
+ * use (fixed-width numerics, dates, and instants) is sized by its known
118
+ * InnoDB on-disk storage width, because the 3072-byte index-key cap
119
+ * applies to the whole key, not just its string-typed columns — a wide
120
+ * secondary index that mixes an id column with a handful of `int`/
121
+ * `datetime` columns still has to fit.
122
+ */
123
+ function columnByteWidth(columnName, sqlType) {
124
+ const stringMatch = sqlType.match(/^(?:char|varchar)\((\d+)\)/i);
125
+ if (stringMatch?.[1]) {
126
+ return Number(stringMatch[1]) * bytesPerChar(sqlType);
127
+ }
128
+ const typeMatch = sqlType.match(/^([a-z]+)(?:\(([^)]*)\))?/i);
129
+ const typeName = typeMatch?.[1]?.toLowerCase();
130
+ const args = typeMatch?.[2];
131
+ switch (typeName) {
132
+ case 'boolean':
133
+ case 'tinyint':
134
+ return 1;
135
+ case 'smallint':
136
+ return 2;
137
+ case 'mediumint':
138
+ return 3;
139
+ case 'int':
140
+ return 4;
141
+ case 'bigint':
142
+ return 8;
143
+ case 'float':
144
+ return 4;
145
+ case 'double':
146
+ return 8;
147
+ case 'decimal': {
148
+ const parts = (args ?? '').split(',').map(Number);
149
+ const precision = parts[0] ?? Number.NaN;
150
+ const scale = parts[1] ?? Number.NaN;
151
+ if (!Number.isFinite(precision) || !Number.isFinite(scale))
152
+ break;
153
+ return decimalStorageBytes(precision, scale);
154
+ }
155
+ case 'date':
156
+ return 3;
157
+ case 'time':
158
+ return 3 + fractionalSecondsBytes(args ? Number(args) : 0);
159
+ case 'datetime':
160
+ return 5 + fractionalSecondsBytes(args ? Number(args) : 0);
161
+ case 'timestamp':
162
+ return 4 + fractionalSecondsBytes(args ? Number(args) : 0);
163
+ default:
164
+ break;
165
+ }
166
+ throw new Error(`schema-pins: column '${columnName}' has SQL type '${sqlType}', which this byte-budget ` +
167
+ 'computation does not know how to size. Widen the pin to understand the new type — ' +
168
+ 'a column this schema-pin test cannot size is a column whose worst-case index-key ' +
169
+ 'contribution nothing is checking.');
170
+ }
171
+ /**
172
+ * Worst-case byte width of one index column, which is either a plain
173
+ * `MySqlColumn` or a prefixed expression built via `sql\`${table.col}(N)\``
174
+ * (the shape `idx_text_value` / `idx_text_path_value` / etc. use to index
175
+ * into the `TEXT` `value` column — MySQL requires an explicit key-length
176
+ * prefix on any index over a `TEXT`/`BLOB` column). For a prefixed
177
+ * expression, drizzle's `SQL` wraps the underlying column and a trailing
178
+ * `StringChunk` literal holding the `(N)` prefix syntax; the prefix
179
+ * length — not the column's own (possibly unbounded) declared length —
180
+ * is what actually goes into the index key, so it is sized directly
181
+ * rather than by widening the "whole column" case artificially.
182
+ */
183
+ function indexColumnByteWidth(tableName, keyName, col) {
184
+ if (!is(col, SQL)) {
185
+ return columnByteWidth(col.name, col.getSQLType());
186
+ }
187
+ let underlyingColumn;
188
+ let prefixLength;
189
+ for (const chunk of col.queryChunks) {
190
+ if (chunk && typeof chunk === 'object' && 'getSQLType' in chunk) {
191
+ underlyingColumn = chunk;
192
+ continue;
193
+ }
194
+ if (chunk && typeof chunk === 'object' && 'value' in chunk) {
195
+ const text = chunk.value.join('');
196
+ const m = text.match(/\((\d+)\)/);
197
+ if (m?.[1])
198
+ prefixLength = Number(m[1]);
199
+ }
200
+ }
201
+ if (!underlyingColumn || prefixLength === undefined) {
202
+ throw new Error(`schema-pins: index '${tableName}.${keyName}' contains a raw SQL expression this ` +
203
+ 'byte-budget computation does not know how to size (expected a `${column}(N)` ' +
204
+ 'key-length prefix). Widen the pin to understand the new expression shape.');
205
+ }
206
+ return prefixLength * bytesPerChar(underlyingColumn.getSQLType());
207
+ }
208
+ /**
209
+ * Every index-shaped structure declared on the schema — unique
210
+ * constraints, column-level `.unique()` markers, primary keys (composite
211
+ * and single-column), *and* plain secondary `index(...)` declarations.
212
+ * InnoDB's 3072-byte `DYNAMIC` key cap applies to every index on a table,
213
+ * not just the unique ones — a wide-but-non-unique index (e.g.
214
+ * `idx_text_path_value` on `(field_path, value(191))`) can be the
215
+ * tightest key on a table even when every *unique* key has headroom to
216
+ * spare, so checking only `uniqueConstraints` would let exactly the kind
217
+ * of widening this pin exists to catch slip through unpinned.
218
+ */
219
+ function allIndexLikeKeys() {
220
+ const keys = [];
221
+ for (const table of allTables()) {
222
+ const cfg = getTableConfig(table);
223
+ for (const uc of cfg.uniqueConstraints) {
224
+ keys.push({
225
+ tableName: cfg.name,
226
+ keyName: uc.name ?? '(unnamed unique)',
227
+ columns: uc.columns,
228
+ });
229
+ }
230
+ for (const col of cfg.columns) {
231
+ if (col.isUnique) {
232
+ keys.push({
233
+ tableName: cfg.name,
234
+ keyName: col.uniqueName ?? `${cfg.name}_${col.name}_unique`,
235
+ columns: [col],
236
+ });
237
+ }
238
+ }
239
+ for (const pk of cfg.primaryKeys) {
240
+ keys.push({
241
+ tableName: cfg.name,
242
+ keyName: pk.getName() || `${cfg.name}_pkey`,
243
+ columns: pk.columns,
244
+ });
245
+ }
246
+ const singleColPk = cfg.columns.filter((c) => c.primary);
247
+ if (singleColPk.length > 0 && cfg.primaryKeys.length === 0) {
248
+ keys.push({
249
+ tableName: cfg.name,
250
+ keyName: `${cfg.name}_pkey`,
251
+ columns: singleColPk,
252
+ });
253
+ }
254
+ for (const idx of cfg.indexes) {
255
+ keys.push({
256
+ tableName: cfg.name,
257
+ keyName: idx.config.name,
258
+ columns: idx.config.columns,
259
+ });
260
+ }
261
+ }
262
+ return keys;
263
+ }
264
+ describe('schema pins — index byte budget (spec §F.1)', () => {
265
+ const keys = allIndexLikeKeys();
266
+ it('finds indexes to check (schema is not empty)', () => {
267
+ expect(keys.length).toBeGreaterThan(0);
268
+ });
269
+ it.each(keys.map((k) => [`${k.tableName}.${k.keyName}`, k]))('%s stays within the 3072-byte InnoDB DYNAMIC index-key cap', (_label, key) => {
270
+ const width = key.columns.reduce((sum, col) => sum + indexColumnByteWidth(key.tableName, key.keyName, col), 0);
271
+ expect(width).toBeLessThanOrEqual(INNODB_INDEX_KEY_BYTE_CAP);
272
+ });
273
+ it('the tightest store-table unique key (document_version_id, field_path, locale) is exactly 2076 bytes', () => {
274
+ const textStoreKey = keys.find((k) => k.tableName === 'byline_store_text' && k.keyName === 'unique_text_field');
275
+ expect(textStoreKey).toBeDefined();
276
+ const width = textStoreKey.columns.reduce((sum, col) => sum + indexColumnByteWidth('byline_store_text', 'unique_text_field', col), 0);
277
+ expect(width).toBe(2076);
278
+ });
279
+ it('the tightest non-unique index on that table, idx_text_path_value (field_path + a value(191) prefix), is exactly 2764 bytes', () => {
280
+ const idx = keys.find((k) => k.tableName === 'byline_store_text' && k.keyName === 'idx_text_path_value');
281
+ expect(idx).toBeDefined();
282
+ const width = idx.columns.reduce((sum, col) => sum + indexColumnByteWidth('byline_store_text', 'idx_text_path_value', col), 0);
283
+ expect(width).toBe(2764);
284
+ });
285
+ });
286
+ describe('schema pins — ascii_bin collation (spec §F.2)', () => {
287
+ const char36Columns = [];
288
+ const orderKeyColumns = [];
289
+ for (const table of allTables()) {
290
+ const cfg = getTableConfig(table);
291
+ for (const col of cfg.columns) {
292
+ if (col.name === 'order_key') {
293
+ orderKeyColumns.push({ tableName: cfg.name, column: col });
294
+ continue;
295
+ }
296
+ // Asserted on the rendered *type* (any `char(36)` column) rather
297
+ // than the column *name* (`id` / `*_id`) — a name-pattern rule
298
+ // missed `documentVersions.created_by`, which is a `uuidChar`
299
+ // column that doesn't end in `_id`. Matching on type instead means
300
+ // every 36-char fixed-width column in the schema is held to this
301
+ // pin regardless of what it's called, and a column that renders as
302
+ // `char(36)` but isn't the `uuidChar` (ascii_bin) flavor fails the
303
+ // assertion below rather than escaping the check entirely.
304
+ if (/^char\(36\)/i.test(col.getSQLType())) {
305
+ char36Columns.push({ tableName: cfg.name, column: col });
306
+ }
307
+ }
308
+ }
309
+ it('finds char(36) columns to check (schema is not empty)', () => {
310
+ expect(char36Columns.length).toBeGreaterThan(0);
311
+ });
312
+ it('finds order_key columns to check (schema is not empty)', () => {
313
+ expect(orderKeyColumns.length).toBeGreaterThan(0);
314
+ });
315
+ it.each(char36Columns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s is char(36) CHARACTER SET ascii COLLATE ascii_bin (uuidChar)', (_label, column) => {
316
+ expect(column.getSQLType()).toBe('char(36) CHARACTER SET ascii COLLATE ascii_bin');
317
+ });
318
+ it.each(orderKeyColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s is varchar(128) CHARACTER SET ascii COLLATE ascii_bin (varcharByteSorted)', (_label, column) => {
319
+ expect(column.getSQLType()).toBe('varchar(128) CHARACTER SET ascii COLLATE ascii_bin');
320
+ });
321
+ });
322
+ describe('schema pins — timestamp precision (spec §F.3)', () => {
323
+ const instantColumns = [];
324
+ const timeColumns = [];
325
+ // Any temporal type that represents an instant (date + time-of-day) must
326
+ // be `datetime(6)`. `MySqlDateTime` is what `datetime()` produces —
327
+ // every timestamp column in this schema uses it — but a future column
328
+ // accidentally declared with mysql-core's `timestamp()` builder instead
329
+ // would produce `MySqlTimestamp` (rendered `timestamp(N)`, a distinct
330
+ // MySQL type this schema never intends to use, so it would fail the
331
+ // `datetime(6)` assertion below regardless of its own fsp). Filtering on
332
+ // `MySqlDateTime` alone would silently skip that column rather than
333
+ // failing it, so both instant-shaped types are checked here.
334
+ const INSTANT_COLUMN_TYPES = new Set(['MySqlDateTime', 'MySqlTimestamp']);
335
+ for (const table of allTables()) {
336
+ const cfg = getTableConfig(table);
337
+ for (const col of cfg.columns) {
338
+ if (INSTANT_COLUMN_TYPES.has(col.columnType)) {
339
+ instantColumns.push({ tableName: cfg.name, column: col });
340
+ }
341
+ if (col.columnType === 'MySqlTime') {
342
+ timeColumns.push({ tableName: cfg.name, column: col });
343
+ }
344
+ }
345
+ }
346
+ it('finds datetime columns to check (schema is not empty)', () => {
347
+ expect(instantColumns.length).toBeGreaterThan(0);
348
+ });
349
+ it.each(instantColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s is datetime(6) — microsecond precision (pg parity)', (_label, column) => {
350
+ expect(column.getSQLType()).toBe('datetime(6)');
351
+ });
352
+ // `MySqlDate` (the `date()` builder, used by `datetimeStore.value_date`)
353
+ // is the one temporal type genuinely outside this pin's scope — a
354
+ // calendar date has no time-of-day component to lose precision on, so
355
+ // there is nothing for an fsp pin to assert.
356
+ //
357
+ // `MySqlTime` used to get the same pass, on the theory that a bare
358
+ // time-of-day was a different concept from a timestamped instant. That
359
+ // reasoning was wrong: `time` is a real Byline field type
360
+ // (`packages/core/src/storage/field-store-map.ts`), and an unspecified
361
+ // `fsp` defaults to whole-second precision on MySQL — silently
362
+ // truncating a fractional time value that round-trips fine on Postgres,
363
+ // whose `time` column (also declared with no explicit precision on that
364
+ // side) defaults to microsecond precision. The exclusion is now a
365
+ // positive assertion instead.
366
+ it('finds time columns to check (schema is not empty)', () => {
367
+ expect(timeColumns.length).toBeGreaterThan(0);
368
+ });
369
+ it.each(timeColumns.map((c) => [`${c.tableName}.${c.column.name}`, c.column]))('%s is time(3) — millisecond precision', (_label, column) => {
370
+ expect(column.getSQLType()).toBe('time(3)');
371
+ });
372
+ });
373
+ describe('schema pins — document-paths unique index name (spec §E)', () => {
374
+ it('byline_document_paths carries a unique key literally named idx_document_paths_collection_locale_path', () => {
375
+ const cfg = getTableConfig(coreSchema.documentPaths);
376
+ const pathKey = cfg.uniqueConstraints.find((uc) => uc.name === 'idx_document_paths_collection_locale_path');
377
+ expect(pathKey).toBeDefined();
378
+ expect(pathKey?.columns.map((c) => c.name)).toEqual(['collection_id', 'locale', 'path']);
379
+ });
380
+ });
381
+ describe('schema pins — document-paths case-sensitive collation (project-owner ruling)', () => {
382
+ // `path` must resolve to `utf8mb4_bin` — not the database's default
383
+ // `utf8mb4_0900_ai_ci` — so path uniqueness is case- AND
384
+ // accent-sensitive on MySQL exactly like it already is on Postgres
385
+ // (whose default collation folds neither). Verified against a live
386
+ // server: MySQL's default collation also collapses non-Latin combining
387
+ // marks Byline's slugifier deliberately preserves (Thai tone marks,
388
+ // Devanagari anusvara, Hebrew niqqud — see
389
+ // `packages/core/src/utils/slugify.ts`), so two documents intended as
390
+ // distinct would silently collide as one path on MySQL only. See
391
+ // `varcharCaseSensitive` in `./common.ts` for the full ruling.
392
+ it('byline_document_paths.path is varchar(255) COLLATE utf8mb4_bin', () => {
393
+ const cfg = getTableConfig(coreSchema.documentPaths);
394
+ const pathColumn = cfg.columns.find((c) => c.name === 'path');
395
+ expect(pathColumn).toBeDefined();
396
+ expect(pathColumn?.getSQLType()).toBe('varchar(255) COLLATE utf8mb4_bin');
397
+ });
398
+ });
@@ -0,0 +1,79 @@
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 type { CollectionDefinition, IDbAdapter } from '@byline/core';
9
+ import { type MySql2Database } from 'drizzle-orm/mysql2';
10
+ import mysql from 'mysql2/promise';
11
+ import * as schema from './database/schema/index.js';
12
+ /**
13
+ * Public return type of `mysqlAdapter`. Extends `IDbAdapter` with concrete
14
+ * Drizzle + mysql2 handles so integrations that need the raw database (the
15
+ * session provider, housekeeping scripts, migration tooling) don't have
16
+ * to construct a second connection pool.
17
+ *
18
+ * Consumers that only need the adapter contract can still annotate as
19
+ * `IDbAdapter` and ignore the extra properties. Mirrors `PgAdapter`
20
+ * (`packages/db-postgres/src/index.ts`).
21
+ */
22
+ export interface MySqlAdapter extends IDbAdapter {
23
+ /** The underlying Drizzle instance, typed against the full schema. */
24
+ drizzle: MySql2Database<typeof schema>;
25
+ /** The mysql2 connection pool — exposed for housekeeping and teardown. */
26
+ pool: mysql.Pool;
27
+ }
28
+ export declare const mysqlAdapter: ({ connectionString, collections, defaultContentLocale, connectionLimit, idleTimeout, connectTimeout, maxIdle, }: {
29
+ connectionString: string;
30
+ collections: readonly CollectionDefinition[];
31
+ /**
32
+ * The installation's default content locale, sourced from
33
+ * `ServerConfig.i18n.content.defaultLocale`. Used by the storage layer as
34
+ * the **fallback** anchor only: new documents are stamped with it as their
35
+ * `source_locale`, and it is the floor for row-less lookups (findByPath) and
36
+ * for documents whose `source_locale` is not yet backfilled. Per-document
37
+ * reads and writes otherwise re-base onto each document's own `source_locale`
38
+ * (carried on the current-documents views), so changing this value does not
39
+ * re-interpret existing data. See docs/07-internationalization/index.md.
40
+ */
41
+ defaultContentLocale: string;
42
+ /**
43
+ * Maximum number of connections in the mysql2 pool. Defaults to 20. Tune
44
+ * via `BYLINE_DB_MYSQL_CONNECTION_LIMIT` in the host app. Mirrors
45
+ * `pgAdapter`'s `max`.
46
+ */
47
+ connectionLimit?: number;
48
+ /**
49
+ * Milliseconds an idle connection remains in the pool before being closed.
50
+ * Defaults to 2000, mirroring `pgAdapter`'s `idleTimeoutMillis`.
51
+ *
52
+ * mysql2's own default is 60000, which is why this is set explicitly rather
53
+ * than left to the driver: a long-lived idle sweep keeps a non-unref'd timer
54
+ * alive, so short-lived processes (seeds, migrations, one-shot scripts) appear
55
+ * to hang for a minute after their work commits instead of exiting. Matching
56
+ * the Postgres adapter's 2 seconds makes both adapters behave alike.
57
+ *
58
+ * Tune via `BYLINE_DB_MYSQL_IDLE_TIMEOUT_MILLIS`.
59
+ */
60
+ idleTimeout?: number;
61
+ /**
62
+ * Milliseconds to wait for a new connection before erroring. Defaults to
63
+ * 30000, mirroring `pgAdapter`'s `connectionTimeoutMillis` — long enough to
64
+ * absorb the cold starts of managed providers that pause idle databases
65
+ * (Neon on the Postgres side; PlanetScale and Aiven behave similarly here),
66
+ * where the first connect after a sleep can take a second or more.
67
+ *
68
+ * Tune via `BYLINE_DB_MYSQL_CONNECTION_TIMEOUT_MILLIS`.
69
+ */
70
+ connectTimeout?: number;
71
+ /**
72
+ * Maximum number of idle connections the pool retains. mysql2 defaults this
73
+ * to `connectionLimit`; left undefined here so that default stands. Exposed
74
+ * because it interacts with `idleTimeout` — mysql2 only sweeps connections
75
+ * beyond `maxIdle`, so raising the idle timeout without also lowering
76
+ * `maxIdle` keeps every connection warm. No `pgAdapter` equivalent.
77
+ */
78
+ maxIdle?: number;
79
+ }) => MySqlAdapter;