@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,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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|