@spine-event-engine/storage-mysql 2.0.0-snapshot.13

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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +243 -0
  3. package/REFERENCE.md +140 -0
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +15 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/mysql/column-mapping.d.ts +27 -0
  9. package/dist/mysql/column-mapping.d.ts.map +1 -0
  10. package/dist/mysql/column-mapping.js +94 -0
  11. package/dist/mysql/column-mapping.js.map +1 -0
  12. package/dist/mysql/delivery-cleanup.d.ts +37 -0
  13. package/dist/mysql/delivery-cleanup.d.ts.map +1 -0
  14. package/dist/mysql/delivery-cleanup.js +108 -0
  15. package/dist/mysql/delivery-cleanup.js.map +1 -0
  16. package/dist/mysql/entity-commit.d.ts +79 -0
  17. package/dist/mysql/entity-commit.d.ts.map +1 -0
  18. package/dist/mysql/entity-commit.js +123 -0
  19. package/dist/mysql/entity-commit.js.map +1 -0
  20. package/dist/mysql/entity-history.d.ts +123 -0
  21. package/dist/mysql/entity-history.d.ts.map +1 -0
  22. package/dist/mysql/entity-history.js +347 -0
  23. package/dist/mysql/entity-history.js.map +1 -0
  24. package/dist/mysql/errors.d.ts +25 -0
  25. package/dist/mysql/errors.d.ts.map +1 -0
  26. package/dist/mysql/errors.js +44 -0
  27. package/dist/mysql/errors.js.map +1 -0
  28. package/dist/mysql/id-column.d.ts +36 -0
  29. package/dist/mysql/id-column.d.ts.map +1 -0
  30. package/dist/mysql/id-column.js +113 -0
  31. package/dist/mysql/id-column.js.map +1 -0
  32. package/dist/mysql/record-storage.d.ts +379 -0
  33. package/dist/mysql/record-storage.d.ts.map +1 -0
  34. package/dist/mysql/record-storage.js +941 -0
  35. package/dist/mysql/record-storage.js.map +1 -0
  36. package/dist/mysql/storage-factory.d.ts +218 -0
  37. package/dist/mysql/storage-factory.d.ts.map +1 -0
  38. package/dist/mysql/storage-factory.js +477 -0
  39. package/dist/mysql/storage-factory.js.map +1 -0
  40. package/dist/mysql/table-resolver.d.ts +34 -0
  41. package/dist/mysql/table-resolver.d.ts.map +1 -0
  42. package/dist/mysql/table-resolver.js +87 -0
  43. package/dist/mysql/table-resolver.js.map +1 -0
  44. package/dist/mysql/table-spec.d.ts +88 -0
  45. package/dist/mysql/table-spec.d.ts.map +1 -0
  46. package/dist/mysql/table-spec.js +105 -0
  47. package/dist/mysql/table-spec.js.map +1 -0
  48. package/dist/mysql/testing.d.ts +47 -0
  49. package/dist/mysql/testing.d.ts.map +1 -0
  50. package/dist/mysql/testing.js +111 -0
  51. package/dist/mysql/testing.js.map +1 -0
  52. package/dist/tsconfig.tsbuildinfo +1 -0
  53. package/package.json +38 -0
@@ -0,0 +1,941 @@
1
+ /*
2
+ * Copyright 2026, CodeMatters. All rights reserved.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5
+ * in compliance with the License. You may obtain a copy of the License at
6
+ *
7
+ * https://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
10
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11
+ * or implied. See the License for the specific language governing permissions and limitations under
12
+ * the License.
13
+ */
14
+ import { fromBinary, toBinary } from "@bufbuild/protobuf";
15
+ import { createHash } from "node:crypto";
16
+ import { StringifierRegistry } from "@spine-event-engine/core";
17
+ import { ColumnMappings, defaultQueryCandidateLimit, RecordStorage, } from "@spine-event-engine/storage";
18
+ import { resolvedMysqlTableSpec } from "./table-spec.js";
19
+ import { MysqlStorageDataError, MysqlStorageOperationError, MysqlStorageSchemaError, mysqlError, } from "./errors.js";
20
+ import { MysqlColumnMapping } from "./column-mapping.js";
21
+ import { MysqlIdColumn } from "./id-column.js";
22
+ const maximumNormalizedPlanBinds = 1_000;
23
+ /**
24
+ * Stores one record family in one physical MySQL table.
25
+ *
26
+ * @typeParam I The record identifier type.
27
+ * @typeParam R The stored Protobuf record type.
28
+ */
29
+ export class MysqlRecordStorage extends RecordStorage {
30
+ table;
31
+ lifecycle;
32
+ onClose;
33
+ createOperation;
34
+ // prettier-ignore
35
+ /**
36
+ * Declares support for atomic compare-and-set operations.
37
+ */
38
+ atomicCompareAndSet = true;
39
+ #ready;
40
+ #bound;
41
+ #idColumn;
42
+ #columnMapping;
43
+ /**
44
+ * Returns the physical table name.
45
+ *
46
+ * @returns Returns the physical table name.
47
+ */
48
+ get tableName() {
49
+ return this.table.tableName;
50
+ }
51
+ /**
52
+ * Prepares the table before a coordinator transaction begins.
53
+ *
54
+ * @returns Resolves after table creation and inspection complete.
55
+ */
56
+ async prepare() {
57
+ await this.using(() => Promise.resolve());
58
+ }
59
+ /**
60
+ * Creates a MySQL record-family storage handle.
61
+ *
62
+ * @param context Provides the storage context.
63
+ * @param recordSpec Describes stored records.
64
+ * @param table Describes the resolved physical table.
65
+ * @param lifecycle Acquires and releases MySQL connections.
66
+ * @param onClose Removes this handle from its provider.
67
+ * @param createOperation Creates optional table-creation SQL.
68
+ * @param tableSpec Supplies the canonical table layout.
69
+ * @param stringifiers The schema-bound message stringifiers.
70
+ */
71
+ constructor(context, recordSpec, table, lifecycle, onClose, createOperation, tableSpec, stringifiers = new StringifierRegistry()) {
72
+ super(context, recordSpec);
73
+ this.table = table;
74
+ this.lifecycle = lifecycle;
75
+ this.onClose = onClose;
76
+ this.createOperation = createOperation;
77
+ this.#idColumn = new MysqlIdColumn(recordSpec.idType, stringifiers);
78
+ this.#columnMapping = new MysqlColumnMapping(stringifiers);
79
+ this.tableSpec =
80
+ tableSpec ??
81
+ resolvedMysqlTableSpec({
82
+ tableName: table.tableName,
83
+ sourceType: recordSpec.sourceType,
84
+ recordType: recordSpec.recordType,
85
+ idType: recordSpec.idType,
86
+ declaredColumns: recordSpec.columns,
87
+ });
88
+ }
89
+ tableSpec;
90
+ /**
91
+ * Closes this record-family storage handle.
92
+ */
93
+ close() {
94
+ if (!this.isOpen())
95
+ return;
96
+ super.close();
97
+ this.onClose();
98
+ }
99
+ /**
100
+ * Deletes the record identified by an ID.
101
+ *
102
+ * @param id Identifies the record to delete.
103
+ * @returns Resolves whether a record was deleted.
104
+ */
105
+ async delete(id) {
106
+ this.validateId(id);
107
+ return super.delete(id);
108
+ }
109
+ /**
110
+ * Reads the record identified by an ID.
111
+ *
112
+ * @param id Identifies the record to read.
113
+ * @param options Configures record reading.
114
+ * @returns Resolves to the stored record when present.
115
+ */
116
+ async read(id, options) {
117
+ this.validateId(id);
118
+ return super.read(id, options);
119
+ }
120
+ /**
121
+ * Writes one record.
122
+ *
123
+ * @param record Provides the record to write.
124
+ * @returns Resolves after the record is written.
125
+ */
126
+ async write(record) {
127
+ this.validateId(this.recordSpec.idValueIn(record));
128
+ await super.write(record);
129
+ }
130
+ /**
131
+ * Writes a sequence of records.
132
+ *
133
+ * @param records Provides the records to write.
134
+ * @returns Resolves after the records are written.
135
+ */
136
+ async writeAll(records) {
137
+ const stored = [...records];
138
+ for (const record of stored)
139
+ this.validateId(this.recordSpec.idValueIn(record));
140
+ await super.writeAll(stored);
141
+ }
142
+ /**
143
+ * Rejects an oversized storage slot before beginning compare-and-set work.
144
+ *
145
+ * @param id Identifies the storage slot.
146
+ * @param expected Provides the expected stored record.
147
+ * @param next Provides the replacement record.
148
+ * @returns Resolves whether the conditional mutation was applied.
149
+ */
150
+ async compareAndSet(id, expected, next) {
151
+ this.validateId(id);
152
+ return super.compareAndSet(id, expected, next);
153
+ }
154
+ /**
155
+ * Writes an immutable record when an identical record is absent.
156
+ *
157
+ * @param record Provides the immutable record.
158
+ * @returns Resolves after the record is written or confirmed identical.
159
+ */
160
+ async writeImmutable(record) {
161
+ const id = this.recordSpec.idValueIn(record);
162
+ this.idKey(id);
163
+ await this.using(async (connection) => {
164
+ if (!(await this.immutableAbsent(connection, id, record)))
165
+ return;
166
+ if ((await this.insertImmutableOn(connection, record)).affectedRows === 1)
167
+ return;
168
+ await this.immutableAbsent(connection, id, record);
169
+ });
170
+ }
171
+ /**
172
+ * Checks an immutable record without creating it.
173
+ *
174
+ * @param record Provides the immutable record to check.
175
+ * @returns Resolves when the record is absent or identical.
176
+ */
177
+ async assertImmutable(record) {
178
+ const id = this.recordSpec.idValueIn(record);
179
+ this.idKey(id);
180
+ await this.using(async (connection) => {
181
+ await this.immutableAbsent(connection, id, record);
182
+ });
183
+ }
184
+ /**
185
+ * Binds nested storage calls to a coordinator-owned connection.
186
+ *
187
+ * @param connection Provides the coordinator-owned connection.
188
+ * @param work Performs the bound storage work.
189
+ * @typeParam T The bound work's result type.
190
+ * @returns Returns the work result.
191
+ */
192
+ async withConnection(connection, work) {
193
+ if (this.#bound !== undefined) {
194
+ return work();
195
+ }
196
+ this.#bound = connection;
197
+ try {
198
+ await this.ready(connection);
199
+ return await work();
200
+ }
201
+ finally {
202
+ this.#bound = undefined;
203
+ }
204
+ }
205
+ /**
206
+ * Reads one record with the coordinator transaction's row/gap lock.
207
+ *
208
+ * @param id Identifies the record.
209
+ * @returns Resolves to the stored record when present.
210
+ */
211
+ async readLocked(id) {
212
+ return this.using((connection) => this.readOn(connection, id, true));
213
+ }
214
+ /**
215
+ * Deletes one stored record.
216
+ *
217
+ * @param id Identifies the record to delete.
218
+ * @returns Resolves whether a record was deleted.
219
+ */
220
+ async deleteRecord(id) {
221
+ return this.using(async (connection) => {
222
+ const [result] = await connection.execute(`DELETE FROM \`${this.table.tableName}\` WHERE ID=?`, [this.idKey(id)]);
223
+ return result.affectedRows === 1;
224
+ });
225
+ }
226
+ /**
227
+ * Reads one stored record.
228
+ *
229
+ * @param id Identifies the record to read.
230
+ * @returns Resolves to the stored record when present.
231
+ */
232
+ async readRecord(id) {
233
+ return this.using((connection) => this.readOn(connection, id));
234
+ }
235
+ /**
236
+ * Reads stored record entries matching a query.
237
+ *
238
+ * @param query Specifies the record query.
239
+ * @returns Resolves to matching record entries.
240
+ */
241
+ async queryRecordEntries(query) {
242
+ if (query.ids?.length === 0)
243
+ return [];
244
+ this.validateQuery(query);
245
+ return this.using(async (connection) => {
246
+ const sql = this.querySql(query);
247
+ const [rows] = await connection.query(sql.sql, sql.values);
248
+ try {
249
+ return rows.map((row) => ({
250
+ id: this.decodeId(row.ID),
251
+ record: fromBinary(this.recordSpec.recordType, row.bytes),
252
+ }));
253
+ }
254
+ catch (error) {
255
+ throw mysqlError(MysqlStorageDataError, "Stored MySQL record data is invalid.", error);
256
+ }
257
+ });
258
+ }
259
+ /**
260
+ * Returns the normalized query features genuinely executed by MySQL.
261
+ * @returns The normalized features executed by this provider.
262
+ */
263
+ queryCapabilities() {
264
+ return {
265
+ comparisons: ["equal", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual"],
266
+ features: ["either", "nested", "order", "mask", "limit"],
267
+ };
268
+ }
269
+ /**
270
+ * Executes the complete candidate selection in contained, bound MySQL SQL.
271
+ * @param plan The validated normalized query plan.
272
+ * @returns The selected record entries.
273
+ */
274
+ async queryPlanRecordEntries(plan) {
275
+ const compiled = this.planSql(plan);
276
+ return this.using(async (connection) => {
277
+ const [rows] = await connection.query(compiled.sql, compiled.values);
278
+ try {
279
+ return rows.map((row) => ({
280
+ id: this.decodeId(row.ID),
281
+ record: fromBinary(this.recordSpec.recordType, row.bytes),
282
+ }));
283
+ }
284
+ catch (error) {
285
+ throw mysqlError(MysqlStorageDataError, "Stored MySQL record data is invalid.", error);
286
+ }
287
+ });
288
+ }
289
+ /**
290
+ * Compares and conditionally replaces one stored record.
291
+ *
292
+ * @param id Identifies the record to replace.
293
+ * @param expected Provides the expected current record.
294
+ * @param next Provides the replacement record.
295
+ * @returns Resolves whether the replacement was applied.
296
+ */
297
+ async compareAndSetRecord(id, expected, next) {
298
+ for (let attempt = 0; attempt < 2; attempt++) {
299
+ try {
300
+ return await this.compareAndSetOnce(id, expected, next);
301
+ }
302
+ catch (error) {
303
+ if (attempt === 0 && mysqlDeadlock(error))
304
+ continue;
305
+ throw mysqlError(MysqlStorageOperationError, "MySQL record operation failed.", error);
306
+ }
307
+ }
308
+ throw new MysqlStorageOperationError("MySQL record operation failed.");
309
+ }
310
+ /**
311
+ * Performs one compare-and-set attempt on a single connection.
312
+ *
313
+ * @param id Identifies the record slot.
314
+ * @param expected Provides the expected materialized record.
315
+ * @param next Provides the replacement materialized record.
316
+ * @returns Resolves whether the replacement was applied.
317
+ */
318
+ async compareAndSetOnce(id, expected, next) {
319
+ const connection = await this.lifecycle.acquire();
320
+ let locked = false;
321
+ try {
322
+ await this.ready(connection);
323
+ const transactional = await this.isTransactional(connection);
324
+ if (transactional)
325
+ await connection.beginTransaction();
326
+ else {
327
+ await this.acquireCasLock(connection, id);
328
+ locked = true;
329
+ }
330
+ return await this.applyCompareAndSet(connection, transactional, id, expected, next);
331
+ }
332
+ catch (error) {
333
+ await connection.rollback().catch(() => undefined);
334
+ throw error;
335
+ }
336
+ finally {
337
+ if (locked)
338
+ await connection
339
+ .execute("SELECT RELEASE_LOCK(?)", [this.casLockKey(id)])
340
+ .catch(() => undefined);
341
+ this.lifecycle.release(connection);
342
+ }
343
+ }
344
+ /**
345
+ * Acquires the advisory lock for a non-transactional record slot.
346
+ *
347
+ * @param connection Provides the lock connection.
348
+ * @param id Identifies the record slot.
349
+ * @returns Resolves after the lock is acquired.
350
+ */
351
+ async acquireCasLock(connection, id) {
352
+ const [rows] = await connection.execute("SELECT GET_LOCK(?, ?) AS acquired", [this.casLockKey(id), 30]);
353
+ if (rows[0]?.acquired !== 1 && rows[0]?.acquired !== "1")
354
+ throw new MysqlStorageOperationError("Unable to acquire MySQL record lock.");
355
+ }
356
+ /**
357
+ * Applies a compare-and-set while the record slot is protected.
358
+ *
359
+ * @param connection Provides the protected connection.
360
+ * @param transactional Tells whether the connection has an active transaction.
361
+ * @param id Identifies the record slot.
362
+ * @param expected Provides the expected materialized record.
363
+ * @param next Provides the replacement materialized record.
364
+ * @returns Resolves whether the replacement was applied.
365
+ */
366
+ async applyCompareAndSet(connection, transactional, id, expected, next) {
367
+ const current = await this.readOn(connection, id, true);
368
+ if (!same(this.recordSpec.recordType, current, expected?.record)) {
369
+ if (transactional)
370
+ await connection.rollback();
371
+ return false;
372
+ }
373
+ if (next === undefined)
374
+ await connection.execute(`DELETE FROM \`${this.table.tableName}\` WHERE ID=?`, [
375
+ this.idKey(id),
376
+ ]);
377
+ else
378
+ await this.writeOn(connection, next.record);
379
+ if (transactional)
380
+ await connection.commit();
381
+ return true;
382
+ }
383
+ /**
384
+ * Writes materialized records in one transaction.
385
+ *
386
+ * @param records Provides the materialized records.
387
+ * @returns Resolves after the records are written.
388
+ */
389
+ async writeAllRecords(records) {
390
+ const connection = await this.lifecycle.acquire();
391
+ try {
392
+ await this.ready(connection);
393
+ await connection.beginTransaction();
394
+ for (const record of records)
395
+ await this.writeOn(connection, record.record);
396
+ await connection.commit();
397
+ }
398
+ catch (error) {
399
+ await connection.rollback().catch(() => undefined);
400
+ throw mysqlError(MysqlStorageOperationError, "MySQL record operation failed.", error);
401
+ }
402
+ finally {
403
+ this.lifecycle.release(connection);
404
+ }
405
+ }
406
+ /**
407
+ * Writes one materialized record.
408
+ *
409
+ * @param record Provides the materialized record.
410
+ * @returns Resolves after the record is written.
411
+ */
412
+ async writeRecord(record) {
413
+ await this.using((connection) => this.writeOn(connection, record.record));
414
+ }
415
+ /**
416
+ * Executes work with the coordinator-bound connection or a temporary connection.
417
+ *
418
+ * @param work Performs the database operation.
419
+ * @typeParam T The database operation result type.
420
+ * @returns Resolves to the operation result.
421
+ */
422
+ async using(work) {
423
+ const bound = this.#bound;
424
+ if (bound !== undefined) {
425
+ return work(bound);
426
+ }
427
+ const connection = await this.lifecycle.acquire();
428
+ try {
429
+ await this.ready(connection);
430
+ return await work(connection);
431
+ }
432
+ catch (error) {
433
+ throw mysqlError(MysqlStorageOperationError, "MySQL record operation failed.", error);
434
+ }
435
+ finally {
436
+ this.lifecycle.release(connection);
437
+ }
438
+ }
439
+ /**
440
+ * Creates and validates the physical table once for this handle.
441
+ *
442
+ * @param connection Provides the connection used for preparation.
443
+ * @returns Resolves when the table is ready.
444
+ */
445
+ async ready(connection) {
446
+ this.#ready ??= this.createAndInspect(connection).catch((error) => {
447
+ this.#ready = undefined;
448
+ throw mysqlError(MysqlStorageSchemaError, "MySQL record-family schema is incompatible.", error);
449
+ });
450
+ await this.#ready;
451
+ }
452
+ /**
453
+ * Creates the table when absent and verifies its stored schema.
454
+ *
455
+ * @param connection Provides the connection used for schema operations.
456
+ * @returns Resolves after schema verification.
457
+ */
458
+ async createAndInspect(connection) {
459
+ const columnsSql = this.tableSpec.columns.map(mysqlColumnDefinition).join(", ");
460
+ const primarySql = this.tableSpec.primaryKey.map((name) => `\`${name}\``).join(", ");
461
+ await connection.query(this.createOperation?.() ??
462
+ `CREATE TABLE IF NOT EXISTS \`${this.table.tableName}\` ` +
463
+ `(${columnsSql}, PRIMARY KEY (${primarySql})) ENGINE=InnoDB`);
464
+ const [columns] = await connection.query("SELECT column_name AS column_name, column_type AS column_type, " +
465
+ "is_nullable AS is_nullable, column_default AS column_default, " +
466
+ "collation_name AS collation_name, extra AS extra " +
467
+ "FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=?", [this.table.tableName]);
468
+ const expected = new Map(this.tableSpec.columns.map((column) => [column.name.toLowerCase(), column]));
469
+ for (const column of this.tableSpec.columns) {
470
+ const actual = columns.find((candidate) => candidate.column_name.toLowerCase() === column.name.toLowerCase());
471
+ if (actual === undefined)
472
+ throw new Error(`MySQL table ${this.table.tableName} lacks ${column.name}.`);
473
+ this.assertCompatibleColumn(column, actual);
474
+ }
475
+ for (const column of columns)
476
+ if (!expected.has(column.column_name.toLowerCase()))
477
+ throw new Error(`MySQL table ${this.table.tableName} has incompatible extra column ${column.column_name}.`);
478
+ const [primary] = await connection.query("SELECT column_name AS column_name, seq_in_index AS seq_in_index " +
479
+ "FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? " +
480
+ "AND index_name='PRIMARY' ORDER BY seq_in_index", [this.table.tableName]);
481
+ if (columns.some((column) => column.column_type !== undefined) &&
482
+ (primary.length !== this.tableSpec.primaryKey.length ||
483
+ primary.some((column, index) => column.column_name.toLowerCase() !== this.tableSpec.primaryKey[index]?.toLowerCase())))
484
+ throw new Error(`MySQL table ${this.table.tableName} has an incompatible primary key.`);
485
+ const [indexes] = await connection.query("SELECT index_name AS index_name, non_unique AS non_unique, " +
486
+ "column_name AS column_name, seq_in_index AS seq_in_index " +
487
+ "FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? " +
488
+ "ORDER BY index_name, seq_in_index", [this.table.tableName]);
489
+ for (const index of groupedIndexes(indexes))
490
+ if (index.nonUnique === 0 &&
491
+ index.name.toLowerCase() !== "primary" &&
492
+ !this.tableSpec.primaryKey.every((name) => index.columns.some((column) => column.toLowerCase() === name.toLowerCase())))
493
+ throw new Error(`MySQL table ${this.table.tableName} has incompatible unique index ${index.name}.`);
494
+ const [engines] = await connection.query("SELECT engine AS engine FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?", [this.table.tableName]);
495
+ const engine = engines[0]?.engine;
496
+ if (engine !== undefined && !["innodb", "myisam", "aria"].includes(engine.toLowerCase()))
497
+ throw new Error(`MySQL table ${this.table.tableName} has unsupported engine ${engine}.`);
498
+ }
499
+ /**
500
+ * Verifies one physical column against the canonical table specification.
501
+ *
502
+ * @param expected Describes the required column.
503
+ * @param actual Describes the stored column.
504
+ */
505
+ assertCompatibleColumn(expected, actual) {
506
+ if (!compatibleMysqlType(expected.mysqlType, actual.column_type))
507
+ throw new Error(`MySQL table ${this.table.tableName} has an incompatible ${expected.name} type.`);
508
+ if (actual.is_nullable !== undefined && (actual.is_nullable === "YES") !== expected.nullable)
509
+ throw new Error(`MySQL table ${this.table.tableName} has incompatible nullable ${expected.name}.`);
510
+ if (!sameDefault(expected.defaultSql, actual.column_default))
511
+ throw new Error(`MySQL table ${this.table.tableName} has incompatible ${expected.name} default.`);
512
+ if (isBinaryType(expected.mysqlType) &&
513
+ actual.collation_name !== undefined &&
514
+ actual.collation_name !== null)
515
+ throw new Error(`MySQL table ${this.table.tableName} has incompatible ${expected.name} collation.`);
516
+ }
517
+ /**
518
+ * Reads one record on the supplied connection.
519
+ *
520
+ * @param connection Provides the connection used for reading.
521
+ * @param id Identifies the record.
522
+ * @param lock Requests a row or gap lock when supported.
523
+ * @returns Resolves to the stored record when present.
524
+ */
525
+ async readOn(connection, id, lock = false) {
526
+ const [rows] = await connection.execute(`SELECT bytes FROM \`${this.table.tableName}\` WHERE ID=?${lock ? " FOR UPDATE" : ""}`, [this.idKey(id)]);
527
+ if (rows[0] === undefined)
528
+ return undefined;
529
+ try {
530
+ return fromBinary(this.recordSpec.recordType, rows[0].bytes);
531
+ }
532
+ catch (error) {
533
+ throw mysqlError(MysqlStorageDataError, "Stored MySQL record data is invalid.", error);
534
+ }
535
+ }
536
+ /**
537
+ * Determines whether the physical table supports transactions.
538
+ *
539
+ * @param connection Provides the connection used for metadata inspection.
540
+ * @returns Resolves whether the table uses InnoDB.
541
+ */
542
+ async isTransactional(connection) {
543
+ const [rows] = await connection.query("SELECT engine AS engine FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=?", [this.table.tableName]);
544
+ return rows[0]?.engine?.toLowerCase() === "innodb";
545
+ }
546
+ /**
547
+ * Calculates the fixed-width advisory-lock key for one record slot.
548
+ *
549
+ * @param id Identifies the record slot.
550
+ * @returns The advisory-lock key.
551
+ */
552
+ casLockKey(id) {
553
+ return createHash("sha256")
554
+ .update(this.lifecycle.databaseName)
555
+ .update("\u0000")
556
+ .update(this.table.tableName)
557
+ .update("\u0000")
558
+ .update(String(this.idKey(id)))
559
+ .digest("hex");
560
+ }
561
+ /**
562
+ * Checks that an immutable slot is absent or stores identical bytes.
563
+ *
564
+ * @param connection Provides the connection used for reading.
565
+ * @param id Identifies the immutable slot.
566
+ * @param record Provides the proposed immutable record.
567
+ * @returns Resolves whether the slot is absent.
568
+ */
569
+ async immutableAbsent(connection, id, record) {
570
+ const current = await this.readOn(connection, id);
571
+ if (current === undefined)
572
+ return true;
573
+ if (same(this.recordSpec.recordType, current, record))
574
+ return false;
575
+ throw new MysqlStorageOperationError("MySQL immutable record collides.");
576
+ }
577
+ /**
578
+ * Writes one record on the supplied connection.
579
+ *
580
+ * @param connection Provides the connection used for writing.
581
+ * @param record Provides the record to store.
582
+ * @returns Resolves after the write.
583
+ */
584
+ async writeOn(connection, record) {
585
+ const materialized = this.recordSpec.materialize(record);
586
+ const columns = this.recordSpec.columns;
587
+ const names = ["ID", "bytes", ...columns.map((column) => column.name)];
588
+ const values = [
589
+ this.idKey(materialized.id),
590
+ toBinary(this.recordSpec.recordType, record),
591
+ ...columns.map((column) => ColumnMappings.value(this.#columnMapping, column.type, materialized.columns.get(column.name))),
592
+ ];
593
+ const update = [
594
+ "bytes=VALUES(bytes)",
595
+ ...columns.map((column) => `\`${column.name}\`=VALUES(\`${column.name}\`)`),
596
+ ];
597
+ const fields = names.map((name) => `\`${name}\``).join(", ");
598
+ const placeholders = names.map(() => "?").join(", ");
599
+ await connection.execute(`INSERT INTO \`${this.table.tableName}\` (${fields}) VALUES (${placeholders}) ` +
600
+ `ON DUPLICATE KEY UPDATE ${update.join(", ")}`, values);
601
+ }
602
+ /**
603
+ * Tries to insert one immutable record without replacing an existing row.
604
+ *
605
+ * @param connection Provides the connection used for writing.
606
+ * @param record Provides the immutable record.
607
+ * @returns Resolves to the insertion result.
608
+ */
609
+ async insertImmutableOn(connection, record) {
610
+ const materialized = this.recordSpec.materialize(record);
611
+ const columns = this.recordSpec.columns;
612
+ const names = ["ID", "bytes", ...columns.map((column) => column.name)];
613
+ const values = [
614
+ this.idKey(materialized.id),
615
+ toBinary(this.recordSpec.recordType, record),
616
+ ...columns.map((column) => ColumnMappings.value(this.#columnMapping, column.type, materialized.columns.get(column.name))),
617
+ ];
618
+ const fields = names.map((name) => `\`${name}\``).join(", ");
619
+ const placeholders = names.map(() => "?").join(", ");
620
+ const [result] = await connection.execute(`INSERT IGNORE INTO \`${this.table.tableName}\` (${fields}) VALUES (${placeholders})`, values);
621
+ return result;
622
+ }
623
+ /**
624
+ * Builds parameterized SQL for a legacy record query.
625
+ *
626
+ * @param query Specifies the legacy query.
627
+ * @returns The SQL statement and bound values.
628
+ */
629
+ querySql(query) {
630
+ const values = [];
631
+ const clauses = [];
632
+ if (query.ids !== undefined) {
633
+ clauses.push(`ID IN (${query.ids.map(() => "?").join(", ")})`);
634
+ values.push(...query.ids.map((id) => this.idKey(id)));
635
+ }
636
+ for (const filter of query.filters ?? []) {
637
+ const name = filter.column === "id" ? "ID" : filter.column;
638
+ this.assertColumn(name);
639
+ clauses.push(`\`${name}\` <=> ?`);
640
+ values.push(this.queryValue(name, filter.value));
641
+ }
642
+ const order = query.sort ?? [];
643
+ for (const item of order)
644
+ this.assertColumn(item.field === "id" ? "ID" : item.field);
645
+ if (query.after !== undefined) {
646
+ const terms = [];
647
+ for (let index = 0; index < order.length; index += 1) {
648
+ const item = order[index];
649
+ if (item === undefined)
650
+ continue;
651
+ const field = item.field === "id" ? "ID" : item.field;
652
+ const prefix = order
653
+ .slice(0, index)
654
+ .map((previous) => `\`${previous.field === "id" ? "ID" : previous.field}\` <=> ?`);
655
+ for (const previous of order.slice(0, index))
656
+ values.push(this.queryValue(previous.field === "id" ? "ID" : previous.field, previous.field === "id"
657
+ ? query.after.id
658
+ : query.after.values[order.indexOf(previous)]?.value));
659
+ const previous = prefix.length === 0 ? "" : `${prefix.join(" AND ")} AND `;
660
+ const operator = item.direction === "desc" ? "<" : ">";
661
+ terms.push(`${previous}\`${field}\` ${operator} ?`);
662
+ values.push(this.queryValue(field, field === "ID" ? query.after.id : query.after.values[index]?.value));
663
+ }
664
+ const prefixes = order.map((item) => `\`${item.field === "id" ? "ID" : item.field}\` <=> ?`);
665
+ for (let index = 0; index < order.length; index += 1)
666
+ values.push(this.queryValue(order[index]?.field === "id" ? "ID" : (order[index]?.field ?? "ID"), order[index]?.field === "id" ? query.after.id : query.after.values[index]?.value));
667
+ terms.push(`${prefixes.length === 0 ? "" : `${prefixes.join(" AND ")} AND `}ID > ?`);
668
+ values.push(this.idKey(query.after.id));
669
+ clauses.push(`(${terms.join(" OR ")})`);
670
+ }
671
+ const orders = [
672
+ ...order.map((item) => `\`${item.field === "id" ? "ID" : item.field}\` ${item.direction === "desc" ? "DESC" : "ASC"}`),
673
+ "ID ASC",
674
+ ];
675
+ let sql = `SELECT ID, bytes FROM \`${this.table.tableName}\` ` +
676
+ (clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")} `) +
677
+ `ORDER BY ${orders.join(", ")}`;
678
+ if (query.limit !== undefined) {
679
+ sql += " LIMIT ?";
680
+ values.push(query.limit);
681
+ if (query.offset !== undefined) {
682
+ sql += " OFFSET ?";
683
+ values.push(query.offset);
684
+ }
685
+ }
686
+ else if (query.offset !== undefined) {
687
+ sql += " LIMIT 18446744073709551615 OFFSET ?";
688
+ values.push(query.offset);
689
+ }
690
+ return { sql, values };
691
+ }
692
+ /**
693
+ * Builds parameterized SQL for a normalized query plan.
694
+ *
695
+ * @param plan Specifies the normalized query plan.
696
+ * @returns The SQL statement and bound values.
697
+ */
698
+ planSql(plan) {
699
+ const values = [];
700
+ const predicate = plan.predicate === undefined ? undefined : this.planPredicate(plan.predicate, values);
701
+ if (values.length >= maximumNormalizedPlanBinds) {
702
+ throw new MysqlStorageOperationError(`MySQL normalized query exceeds the ${String(maximumNormalizedPlanBinds)}-parameter bind budget.`);
703
+ }
704
+ const order = [
705
+ ...(plan.order ?? []).map((item) => `${this.planColumn(item.column)} ${item.direction === "desc" ? "DESC" : "ASC"}`),
706
+ "ID ASC",
707
+ ];
708
+ const candidateLimit = plan.candidateLimit ?? defaultQueryCandidateLimit;
709
+ const limit = Math.min(plan.limit ?? Number.MAX_SAFE_INTEGER, candidateLimit + 1);
710
+ values.push(limit);
711
+ return {
712
+ sql: `SELECT ID, bytes FROM \`${this.table.tableName}\` ` +
713
+ (predicate === undefined ? "" : `WHERE ${predicate} `) +
714
+ `ORDER BY ${order.join(", ")} LIMIT ?`,
715
+ values,
716
+ };
717
+ }
718
+ /**
719
+ * Builds SQL for one normalized predicate and appends its bound values.
720
+ *
721
+ * @param predicate Specifies the predicate to compile.
722
+ * @param values Receives the predicate's bound values.
723
+ * @returns The predicate SQL.
724
+ */
725
+ planPredicate(predicate, values) {
726
+ if (predicate.kind === "ids") {
727
+ values.push(...predicate.ids.map((id) => this.idKey(id)));
728
+ return `ID IN (${predicate.ids.map(() => "?").join(", ")})`;
729
+ }
730
+ if (predicate.kind === "comparison") {
731
+ const column = this.planColumn(predicate.column);
732
+ const operator = predicate.operator === "equal"
733
+ ? "="
734
+ : predicate.operator === "greaterThan"
735
+ ? ">"
736
+ : predicate.operator === "lessThan"
737
+ ? "<"
738
+ : predicate.operator === "greaterOrEqual"
739
+ ? ">="
740
+ : "<=";
741
+ values.push(this.planValue(predicate.column, predicate.value));
742
+ return `${column} ${operator} ?`;
743
+ }
744
+ const joiner = predicate.kind === "all" ? " AND " : " OR ";
745
+ return `(${predicate.predicates.map((child) => this.planPredicate(child, values)).join(joiner)})`;
746
+ }
747
+ /**
748
+ * Returns the SQL spelling of a declared query column.
749
+ *
750
+ * @param column Specifies the column name.
751
+ * @returns The column's SQL spelling.
752
+ */
753
+ planColumn(column) {
754
+ if (column === "ID")
755
+ return "ID";
756
+ this.assertColumn(column);
757
+ return `\`${column}\``;
758
+ }
759
+ /**
760
+ * Converts a normalized query value for its declared column type.
761
+ *
762
+ * @param column Specifies the declared column.
763
+ * @param value Provides the query value.
764
+ * @returns The value accepted by the MySQL driver.
765
+ */
766
+ planValue(column, value) {
767
+ const declared = this.recordSpec.columns.find((candidate) => candidate.name === column);
768
+ if (declared === undefined)
769
+ throw new MysqlStorageOperationError(`MySQL query column is not declared: ${column}`);
770
+ return ColumnMappings.value(this.#columnMapping, declared.type, value);
771
+ }
772
+ /**
773
+ * Validates all identifiers and columns in a legacy query.
774
+ *
775
+ * @param query Specifies the query to validate.
776
+ */
777
+ validateQuery(query) {
778
+ try {
779
+ for (const id of query.ids ?? [])
780
+ this.idKey(id);
781
+ for (const filter of query.filters ?? []) {
782
+ const name = filter.column === "id" ? "ID" : filter.column;
783
+ this.assertColumn(name);
784
+ this.queryValue(name, filter.value);
785
+ }
786
+ for (const item of query.sort ?? [])
787
+ this.assertColumn(item.field === "id" ? "ID" : item.field);
788
+ if (query.after !== undefined) {
789
+ this.idKey(query.after.id);
790
+ for (let index = 0; index < (query.sort?.length ?? 0); index += 1) {
791
+ const field = query.sort?.[index]?.field;
792
+ if (field !== undefined && field !== "id")
793
+ this.queryValue(field, query.after.values[index]?.value);
794
+ }
795
+ }
796
+ }
797
+ catch (error) {
798
+ if (error instanceof Error && /too large/i.test(error.message))
799
+ throw new MysqlStorageOperationError("MySQL storage identifier is too large.");
800
+ throw mysqlError(MysqlStorageOperationError, "MySQL storage identifier is invalid.", error);
801
+ }
802
+ }
803
+ /**
804
+ * Validates that a query column is declared by this storage.
805
+ *
806
+ * @param name Specifies the physical column name.
807
+ */
808
+ assertColumn(name) {
809
+ if (name !== "ID" && !this.recordSpec.columns.some((column) => column.name === name))
810
+ throw new MysqlStorageOperationError(`MySQL query column is not declared: ${name}`);
811
+ }
812
+ /**
813
+ * Converts a record identifier for use by the MySQL driver.
814
+ *
815
+ * @param id Provides the record identifier.
816
+ * @returns The corresponding database value.
817
+ */
818
+ idKey(id) {
819
+ return this.#idColumn.value(id);
820
+ }
821
+ /**
822
+ * Validates that a record identifier can be stored.
823
+ *
824
+ * @param id Provides the record identifier.
825
+ */
826
+ validateId(id) {
827
+ try {
828
+ this.idKey(id);
829
+ }
830
+ catch (error) {
831
+ if (error instanceof Error && /too large/i.test(error.message))
832
+ throw new MysqlStorageOperationError("MySQL storage identifier is too large.");
833
+ throw mysqlError(MysqlStorageOperationError, "MySQL storage identifier is invalid.", error);
834
+ }
835
+ }
836
+ /**
837
+ * Decodes a record identifier from a database value.
838
+ *
839
+ * @param key Provides the stored database value.
840
+ * @returns The reconstructed record identifier.
841
+ */
842
+ decodeId(key) {
843
+ return this.#idColumn.read(key);
844
+ }
845
+ /**
846
+ * Converts a legacy query value for its declared column type.
847
+ *
848
+ * @param name Specifies the physical column name.
849
+ * @param value Provides the query value.
850
+ * @returns The value accepted by the MySQL driver.
851
+ */
852
+ queryValue(name, value) {
853
+ if (name === "ID")
854
+ return this.idKey(value);
855
+ const column = this.recordSpec.columns.find((candidate) => candidate.name === name);
856
+ if (column === undefined)
857
+ throw new MysqlStorageOperationError(`MySQL query column is not declared: ${name}`);
858
+ return ColumnMappings.value(this.#columnMapping, column.type, value);
859
+ }
860
+ }
861
+ function mysqlColumnDefinition(column) {
862
+ const nullable = column.nullable ? " NULL" : " NOT NULL";
863
+ const defaultSql = column.defaultSql === undefined ? "" : ` DEFAULT ${column.defaultSql}`;
864
+ return `\`${column.name}\` ${column.mysqlType}${nullable}${defaultSql}`;
865
+ }
866
+ function compatibleMysqlType(expected, actual) {
867
+ if (actual === undefined)
868
+ return true;
869
+ const normalize = (value) => value
870
+ .toLowerCase()
871
+ .replaceAll(/\s+/g, " ")
872
+ // MariaDB reports legacy integer display widths while MySQL 8 does not.
873
+ .replace(/\b(tinyint|smallint|mediumint|int|bigint)\(\d+\)/g, "$1");
874
+ const normalizedExpected = normalize(expected);
875
+ const actualSpelling = actual.toLowerCase().replaceAll(/\s+/g, " ");
876
+ if (normalizedExpected === "boolean")
877
+ return ["boolean", "tinyint", "tinyint(1)"].includes(actualSpelling);
878
+ const normalizedActual = normalize(actual);
879
+ const expectedCapacity = /^(varbinary|varchar)\((\d+)\)$/.exec(normalizedExpected);
880
+ const actualCapacity = /^(varbinary|varchar)\((\d+)\)$/.exec(normalizedActual);
881
+ if (expectedCapacity !== null)
882
+ return (actualCapacity !== null &&
883
+ actualCapacity[1] === expectedCapacity[1] &&
884
+ Number(actualCapacity[2]) >= Number(expectedCapacity[2]));
885
+ if (normalizedExpected === "blob")
886
+ return ["blob", "mediumblob", "longblob"].includes(normalizedActual);
887
+ return normalizedExpected === normalizedActual;
888
+ }
889
+ function sameDefault(expected, actual) {
890
+ if (expected === undefined)
891
+ return actual === undefined || actual === null || actual.toUpperCase() === "NULL";
892
+ if (actual === undefined)
893
+ return true;
894
+ if (actual === null)
895
+ return false;
896
+ return normalizedDefault(actual) === normalizedDefault(expected);
897
+ }
898
+ function normalizedDefault(value) {
899
+ const normalized = value.replaceAll(/[()'\s]/g, "").toLowerCase();
900
+ if (normalized === "false")
901
+ return "0";
902
+ if (normalized === "true")
903
+ return "1";
904
+ return normalized;
905
+ }
906
+ function isBinaryType(type) {
907
+ return /binary|blob/i.test(type);
908
+ }
909
+ function groupedIndexes(indexes) {
910
+ const grouped = new Map();
911
+ for (const index of indexes) {
912
+ const existing = grouped.get(index.index_name) ?? {
913
+ name: index.index_name,
914
+ nonUnique: index.non_unique,
915
+ columns: [],
916
+ };
917
+ existing.columns[index.seq_in_index - 1] = index.column_name;
918
+ grouped.set(index.index_name, existing);
919
+ }
920
+ return [...grouped.values()];
921
+ }
922
+ /**
923
+ * Compares two Protobuf messages by their canonical binary form.
924
+ *
925
+ * @typeParam R The compared message type.
926
+ * @param schema Specifies the message schema.
927
+ * @param left Provides the first message.
928
+ * @param right Provides the second message.
929
+ * @returns Whether both values are absent or encode to identical bytes.
930
+ */
931
+ function same(schema, left, right) {
932
+ return left === undefined || right === undefined
933
+ ? left === right
934
+ : Buffer.compare(toBinary(schema, left), toBinary(schema, right)) === 0;
935
+ }
936
+ function mysqlDeadlock(error) {
937
+ return (typeof error === "object" &&
938
+ error !== null &&
939
+ error.code === "ER_LOCK_DEADLOCK");
940
+ }
941
+ //# sourceMappingURL=record-storage.js.map