@itrocks/mysql-maintainer 0.2.7 → 0.3.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/README.md CHANGED
@@ -68,6 +68,8 @@ composition system's loading rules.
68
68
  ## How it works
69
69
 
70
70
  The package enriches the usual `Mysql` operations with the context required to identify the persistent classes involved.
71
+ Each connection stores that context with `AsyncLocalStorage`, so concurrent operations on different classes cannot
72
+ overwrite one another's maintenance target.
71
73
  It intercepts MySQL errors that indicate a missing or outdated table, column, or foreign key, derives the expected
72
74
  schema from the object model, applies the required structural changes, and retries the original operation.
73
75
 
@@ -82,6 +84,16 @@ For example, a read follows this simplified sequence:
82
84
  The exported classes that implement context tracking, error handling, schema updates, and retries are internal
83
85
  mechanics of this integration. They are not a separate API that an application needs to call.
84
86
 
87
+ Unique indexes declared with `@Unique` are checked before the first insert or update of each persistent class on a
88
+ database connection. This lazy preflight also detects an index added to a class after its table already exists. Writes
89
+ of the same class on the same connection share one preflight, and a new connection performs the check again.
90
+
91
+ If existing duplicate rows prevent a unique index from being created, the maintainer does not alter or delete those
92
+ rows. It rejects the write with a generic `UniqueIndexMaintenanceError`. Operational logs contain only a fixed event
93
+ name and a driver error code from a closed operational allowlist; they do not contain SQL, parameters, driver messages,
94
+ arbitrary code strings, or stored values. Once an
95
+ operator resolves the duplicates, a later write retries the preflight.
96
+
85
97
  ## Persistent model requirements
86
98
 
87
99
  Automatic maintenance derives database structure from business classes and their reflection metadata. Those classes
@@ -0,0 +1,20 @@
1
+ import { ObjectOrType } from '@itrocks/class-type';
2
+ import { Type } from '@itrocks/class-type';
3
+ import { Connection } from 'mariadb';
4
+ import { QueryOptions } from 'mariadb';
5
+ import { SqlError } from 'mariadb';
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
7
+ export type Context = ObjectOrType | ObjectOrType[];
8
+ export declare class Contextual implements Partial<Connection> {
9
+ [x: number]: (() => Promise<void>) | undefined;
10
+ contextStorage: AsyncLocalStorage<Context>;
11
+ errorCount: Record<string, Record<string, Array<number>>>;
12
+ superQuery: <T = any>(sql: string | QueryOptions, values?: any) => Promise<T>;
13
+ uniqueIndexPromises: WeakMap<Type, Promise<void>>;
14
+ applyTo(connection: ContextualConnection): Promise<ContextualConnection>;
15
+ incrementErrorCount(sql: string, error: any): this is Connection;
16
+ isSqlError(error: any): error is SqlError;
17
+ query<T = any>(sql: string | QueryOptions, values?: any): Promise<T>;
18
+ run<T>(context: Context, operation: () => Promise<T>): Promise<T>;
19
+ }
20
+ export type ContextualConnection = Connection & Contextual;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Contextual = void 0;
4
+ const node_async_hooks_1 = require("node:async_hooks");
5
+ const mysql_maintainer_1 = require("./mysql-maintainer");
6
+ const mysql_maintainer_2 = require("./mysql-maintainer");
7
+ const safe_error_code_1 = require("./safe-error-code");
8
+ const MANAGED_ERROR_CODES = ['ER_BAD_FIELD_ERROR', 'ER_CANNOT_ADD_FOREIGN', 'ER_NO_SUCH_TABLE'];
9
+ class Contextual {
10
+ contextStorage = new node_async_hooks_1.AsyncLocalStorage();
11
+ errorCount = {};
12
+ superQuery = () => new Promise(() => { });
13
+ uniqueIndexPromises = new WeakMap();
14
+ async applyTo(connection) {
15
+ connection.contextStorage = new node_async_hooks_1.AsyncLocalStorage();
16
+ connection.errorCount = {};
17
+ connection.incrementErrorCount = Contextual.prototype.incrementErrorCount;
18
+ connection.isSqlError = Contextual.prototype.isSqlError;
19
+ connection.run = Contextual.prototype.run;
20
+ connection.superQuery = connection.query;
21
+ connection.query = Contextual.prototype.query;
22
+ connection.uniqueIndexPromises = new WeakMap();
23
+ return connection;
24
+ }
25
+ incrementErrorCount(sql, error) {
26
+ if (!this.errorCount[sql]) {
27
+ this.errorCount[sql] = {};
28
+ }
29
+ if (!this.errorCount[sql][error.message]) {
30
+ this.errorCount[sql][error.message] = [];
31
+ }
32
+ const errors = this.errorCount[sql][error.message];
33
+ errors.push(new Date().getTime());
34
+ console.warn('[@itrocks/mysql-maintainer] Schema maintenance attempt', {
35
+ attempt: errors.length,
36
+ code: (0, safe_error_code_1.safeDriverErrorCode)(error)
37
+ });
38
+ return true;
39
+ }
40
+ isSqlError(error) {
41
+ return (typeof error === 'object') && !!error.code;
42
+ }
43
+ async query(sql, values) {
44
+ try {
45
+ if (mysql_maintainer_1.DEBUG)
46
+ console.log(sql, values);
47
+ return await this.superQuery(sql, values);
48
+ }
49
+ catch (error) {
50
+ if (!this.isSqlError(error) || !MANAGED_ERROR_CODES.includes(error.code)) {
51
+ if (mysql_maintainer_1.DEBUG)
52
+ console.log('MAINTAINER: throw', error);
53
+ throw error;
54
+ }
55
+ sql = (typeof sql === 'object') ? sql.sql : sql;
56
+ if (this.incrementErrorCount(sql, error)
57
+ && (this.errorCount[sql][error.message].length < 5)
58
+ && await new mysql_maintainer_2.MysqlMaintainer(this).manageError(error, this.contextStorage.getStore(), sql, values)) {
59
+ return this.query(sql, values);
60
+ }
61
+ if (mysql_maintainer_1.DEBUG)
62
+ console.log('MAINTAINER: throw (2)', error);
63
+ throw error;
64
+ }
65
+ }
66
+ run(context, operation) {
67
+ return this.contextStorage.run(context, operation);
68
+ }
69
+ }
70
+ exports.Contextual = Contextual;
71
+ //# sourceMappingURL=contextual-connection.js.map
@@ -0,0 +1,18 @@
1
+ import { ObjectOrType } from '@itrocks/class-type';
2
+ import { Type } from '@itrocks/class-type';
3
+ import { Connection } from 'mariadb';
4
+ import { QueryOptions } from 'mariadb';
5
+ import { SqlError } from 'mariadb';
6
+ import { Context } from './contextual-connection';
7
+ export { Mysql, UniqueIndexMaintenanceError } from './mysql';
8
+ export declare const DEBUG = false;
9
+ export declare class MysqlMaintainer {
10
+ connection: Connection;
11
+ constructor(connection: Connection);
12
+ createImplicitTable(type1: ObjectOrType, type2: ObjectOrType): Promise<boolean>;
13
+ createTable(type: Type): Promise<boolean>;
14
+ implicitTableName(type1: ObjectOrType, type2: ObjectOrType): string;
15
+ manageError(error: SqlError, context: Context, _sql: string | QueryOptions, _values: any[]): Promise<boolean>;
16
+ updateContextTables(context: Context, tableName?: string): Promise<boolean>;
17
+ updateTable(type: Type): Promise<boolean>;
18
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MysqlMaintainer = exports.DEBUG = exports.UniqueIndexMaintenanceError = exports.Mysql = void 0;
4
+ const class_type_1 = require("@itrocks/class-type");
5
+ const mysql_1 = require("@itrocks/mysql");
6
+ const mysql_to_schema_1 = require("@itrocks/mysql-to-schema");
7
+ const reflect_to_schema_1 = require("@itrocks/reflect-to-schema");
8
+ const schema_diff_1 = require("@itrocks/schema-diff");
9
+ const schema_diff_mysql_1 = require("@itrocks/schema-diff-mysql");
10
+ const schema_to_mysql_1 = require("@itrocks/schema-to-mysql");
11
+ const store_1 = require("@itrocks/store");
12
+ var mysql_2 = require("./mysql");
13
+ Object.defineProperty(exports, "Mysql", { enumerable: true, get: function () { return mysql_2.Mysql; } });
14
+ Object.defineProperty(exports, "UniqueIndexMaintenanceError", { enumerable: true, get: function () { return mysql_2.UniqueIndexMaintenanceError; } });
15
+ exports.DEBUG = false;
16
+ const DELETION = false;
17
+ class MysqlMaintainer {
18
+ connection;
19
+ constructor(connection) {
20
+ this.connection = connection;
21
+ }
22
+ async createImplicitTable(type1, type2) {
23
+ const joinTable = this.implicitTableName(type1, type2);
24
+ const table1 = (0, store_1.storeOf)(type1);
25
+ const table2 = (0, store_1.storeOf)(type2);
26
+ const query = `CREATE TABLE \`${joinTable}\` (
27
+ ${table1}_id int unsigned NOT NULL,
28
+ ${table2}_id int unsigned NOT NULL,
29
+ PRIMARY KEY (${table1}_id, ${table2}_id),
30
+ CONSTRAINT \`${joinTable}.${table1}_id\` FOREIGN KEY (${table1}_id) REFERENCES \`${table1}\` (id) ON DELETE CASCADE ON UPDATE CASCADE,
31
+ CONSTRAINT \`${joinTable}.${table2}_id\` FOREIGN KEY (${table2}_id) REFERENCES \`${table2}\` (id) ON DELETE CASCADE ON UPDATE CASCADE
32
+ )`;
33
+ await this.connection.query(query);
34
+ return true;
35
+ }
36
+ async createTable(type) {
37
+ const tableName = (0, store_1.storeOf)(type);
38
+ if (!tableName) {
39
+ throw 'No table name for type';
40
+ }
41
+ const tableSchema = new reflect_to_schema_1.ReflectToTable().convert(type);
42
+ const schemaToMysql = new schema_to_mysql_1.SchemaToMysql();
43
+ const sql = schemaToMysql.sql(tableSchema);
44
+ await this.connection.query(sql);
45
+ return true;
46
+ }
47
+ implicitTableName(type1, type2) {
48
+ const table1 = (0, store_1.storeOf)(type1);
49
+ const table2 = (0, store_1.storeOf)(type2);
50
+ if (!table1 || !table2) {
51
+ throw 'Collection objects are not stored';
52
+ }
53
+ return (0, mysql_1.joinTableName)(table1, table2);
54
+ }
55
+ async manageError(error, context, _sql, _values) {
56
+ if (exports.DEBUG)
57
+ console.log('MAINTAINER: manageError', error);
58
+ switch (error.code) {
59
+ case 'ER_BAD_FIELD_ERROR':
60
+ case 'ER_CANNOT_ADD_FOREIGN':
61
+ return this.updateContextTables(context);
62
+ case 'ER_NO_SUCH_TABLE':
63
+ const tableName = /Table '.*?\.(.*?)'/.exec(error.message)?.[1];
64
+ return this.updateContextTables(context, tableName);
65
+ }
66
+ return false;
67
+ }
68
+ async updateContextTables(context, tableName) {
69
+ let result = false;
70
+ const contexts = Array.isArray(context) ? context : [context];
71
+ for (const context of contexts) {
72
+ const type = (0, class_type_1.typeOf)(context);
73
+ const tableName = (0, store_1.storeOf)(type);
74
+ if (!tableName) {
75
+ throw 'No table name for type';
76
+ }
77
+ const exists = (await this.connection.query('SHOW TABLES LIKE ?', tableName));
78
+ result ||= await (exists.length ? this.updateTable(type) : this.createTable(type));
79
+ }
80
+ if ((contexts.length === 2) && (this.implicitTableName(contexts[0], contexts[1]) === tableName)) {
81
+ result ||= await this.createImplicitTable(contexts[0], contexts[1]);
82
+ }
83
+ return result;
84
+ }
85
+ async updateTable(type) {
86
+ const tableName = (0, store_1.storeOf)(type);
87
+ if (!tableName) {
88
+ throw 'No table name for type';
89
+ }
90
+ const classTable = new reflect_to_schema_1.ReflectToTable().convert(type);
91
+ new mysql_to_schema_1.MysqlToTable(this.connection).normalize(classTable);
92
+ const mysqlTable = await ((new mysql_to_schema_1.MysqlToTable(this.connection)).convert(tableName));
93
+ const schemaDiff = new schema_diff_1.TableDiff(mysqlTable, classTable);
94
+ if (!schemaDiff.additions.length
95
+ && !schemaDiff.changes.length
96
+ && (!schemaDiff.deletions.length || !DELETION)
97
+ && !schemaDiff.tableChanges()) {
98
+ return false;
99
+ }
100
+ const schemaDiffMysql = new schema_diff_mysql_1.SchemaDiffMysql();
101
+ const sql = schemaDiffMysql.sql(schemaDiff, DELETION);
102
+ await this.connection.query(sql);
103
+ return true;
104
+ }
105
+ }
106
+ exports.MysqlMaintainer = MysqlMaintainer;
107
+ //# sourceMappingURL=mysql-maintainer.js.map
package/cjs/mysql.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { ObjectOrType } from '@itrocks/class-type';
2
+ import { Type } from '@itrocks/class-type';
3
+ import { Mysql as M } from '@itrocks/mysql';
4
+ import { Entity } from '@itrocks/storage';
5
+ import { Identifier } from '@itrocks/storage';
6
+ import { Options } from '@itrocks/storage';
7
+ import { SearchType } from '@itrocks/storage';
8
+ import { ContextualConnection } from './contextual-connection';
9
+ export declare class UniqueIndexMaintenanceError extends Error {
10
+ readonly code = "SCHEMA_UNIQUE_INDEX_MAINTENANCE_FAILED";
11
+ constructor();
12
+ }
13
+ export declare class Mysql extends M {
14
+ connection?: ContextualConnection;
15
+ connect(): Promise<ContextualConnection>;
16
+ deleteId<T extends object>(type: ObjectOrType<T>, id: any, property?: keyof Entity<T>): Promise<void>;
17
+ deleteRelatedId<T extends Entity>(object: T, property: keyof T, id: Identifier): Promise<void>;
18
+ ensureUniqueIndexes(type: Type): Promise<void>;
19
+ private hasUniqueIndexes;
20
+ insert<T extends object>(object: T): Promise<Entity<T>>;
21
+ insertRelatedId<T extends Entity>(object: T, property: keyof T, id: Identifier): Promise<void>;
22
+ protected maintainUniqueIndexes(type: Type, connection: ContextualConnection): Promise<void>;
23
+ read<T extends object>(type: Type<T>, id: Identifier): Promise<Entity<T>>;
24
+ readCollection<T extends object, PT extends object>(object: Entity<T>, property: keyof T, type?: Type<PT>): Promise<Awaited<PT & {
25
+ id: Identifier;
26
+ }>[]>;
27
+ readCollectionIds<T extends object, PT extends object>(object: Entity<T>, property: keyof T, type?: Type<PT>): Promise<Identifier[]>;
28
+ readMultiple<T extends object>(type: Type<T>, ids: Identifier[]): Promise<Entity<T>[]>;
29
+ search<T extends object>(type: Type<T>, search?: SearchType<T>, options?: Options): Promise<Entity<T>[]>;
30
+ update<T extends object>(object: Entity<T>): Promise<Entity<T>>;
31
+ private withContext;
32
+ }
package/cjs/mysql.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Mysql = exports.UniqueIndexMaintenanceError = void 0;
4
+ const class_type_1 = require("@itrocks/class-type");
5
+ const mysql_1 = require("@itrocks/mysql");
6
+ const reflect_1 = require("@itrocks/reflect");
7
+ const reflect_2 = require("@itrocks/reflect");
8
+ const store_1 = require("@itrocks/store");
9
+ const contextual_connection_1 = require("./contextual-connection");
10
+ const mysql_maintainer_1 = require("./mysql-maintainer");
11
+ const safe_error_code_1 = require("./safe-error-code");
12
+ const UNIQUE_INDEX_ERROR_CODE = 'SCHEMA_UNIQUE_INDEX_MAINTENANCE_FAILED';
13
+ class UniqueIndexMaintenanceError extends Error {
14
+ code = UNIQUE_INDEX_ERROR_CODE;
15
+ constructor() {
16
+ super('Unique index maintenance failed');
17
+ this.name = 'UniqueIndexMaintenanceError';
18
+ }
19
+ }
20
+ exports.UniqueIndexMaintenanceError = UniqueIndexMaintenanceError;
21
+ class Mysql extends mysql_1.Mysql {
22
+ async connect() {
23
+ return contextual_connection_1.Contextual.prototype.applyTo(await super.connect());
24
+ }
25
+ async deleteId(type, id, property = 'id') {
26
+ return this.withContext(type, () => super.deleteId(type, id, property));
27
+ }
28
+ async deleteRelatedId(object, property, id) {
29
+ return this.withContext(object, () => super.deleteRelatedId(object, property, id));
30
+ }
31
+ async ensureUniqueIndexes(type) {
32
+ if (!this.hasUniqueIndexes(type))
33
+ return;
34
+ const connection = this.connection ?? await this.connect();
35
+ const promises = connection.uniqueIndexPromises;
36
+ const current = promises.get(type);
37
+ if (current)
38
+ return current;
39
+ let maintenance;
40
+ maintenance = Promise.resolve()
41
+ .then(() => this.maintainUniqueIndexes(type, connection))
42
+ .catch(error => {
43
+ if (promises.get(type) === maintenance)
44
+ promises.delete(type);
45
+ console.warn('[@itrocks/mysql-maintainer] Unique index maintenance failed', {
46
+ code: (0, safe_error_code_1.safeDriverErrorCode)(error),
47
+ event: 'unique-index-preflight'
48
+ });
49
+ throw new UniqueIndexMaintenanceError();
50
+ });
51
+ promises.set(type, maintenance);
52
+ return maintenance;
53
+ }
54
+ hasUniqueIndexes(type) {
55
+ return new reflect_1.ReflectClass(type).properties.some(property => (0, store_1.uniqueOf)(type, property.name) !== false);
56
+ }
57
+ async insert(object) {
58
+ return this.withContext(object, async () => {
59
+ await this.ensureUniqueIndexes((0, class_type_1.typeOf)(object));
60
+ return super.insert(object);
61
+ });
62
+ }
63
+ async insertRelatedId(object, property, id) {
64
+ return this.withContext(object, () => super.insertRelatedId(object, property, id));
65
+ }
66
+ async maintainUniqueIndexes(type, connection) {
67
+ const originalConnection = Object.create(connection);
68
+ originalConnection.query = ((sql, values) => {
69
+ return connection.superQuery(sql, values);
70
+ });
71
+ await new mysql_maintainer_1.MysqlMaintainer(originalConnection).updateContextTables(type);
72
+ }
73
+ async read(type, id) {
74
+ return this.withContext(type, () => super.read(type, id));
75
+ }
76
+ async readCollection(object, property, type) {
77
+ type ??= new reflect_2.ReflectProperty(object, property).collectionType.elementType.type;
78
+ return this.withContext([object, type], () => super.readCollection(object, property, type));
79
+ }
80
+ async readCollectionIds(object, property, type) {
81
+ type ??= new reflect_2.ReflectProperty(object, property).collectionType.elementType.type;
82
+ return this.withContext([object, type], () => super.readCollectionIds(object, property, type));
83
+ }
84
+ async readMultiple(type, ids) {
85
+ return this.withContext(type, () => super.readMultiple(type, ids));
86
+ }
87
+ async search(type, search = {}, options) {
88
+ return this.withContext(type, () => super.search(type, search, options));
89
+ }
90
+ async update(object) {
91
+ return this.withContext(object, async () => {
92
+ await this.ensureUniqueIndexes((0, class_type_1.typeOf)(object));
93
+ return super.update(object);
94
+ });
95
+ }
96
+ async withContext(context, operation) {
97
+ return (this.connection ?? await this.connect()).run(context, operation);
98
+ }
99
+ }
100
+ exports.Mysql = Mysql;
101
+ //# sourceMappingURL=mysql.js.map
@@ -0,0 +1 @@
1
+ export declare function safeDriverErrorCode(error: unknown): string;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.safeDriverErrorCode = safeDriverErrorCode;
4
+ const SAFE_DRIVER_ERROR_CODES = new Set([
5
+ 'ER_BAD_FIELD_ERROR',
6
+ 'ER_CANNOT_ADD_FOREIGN',
7
+ 'ER_DUP_ENTRY',
8
+ 'ER_NO_SUCH_TABLE'
9
+ ]);
10
+ function safeDriverErrorCode(error) {
11
+ const code = (typeof error === 'object') && error && ('code' in error)
12
+ ? error.code
13
+ : undefined;
14
+ return (typeof code === 'string') && SAFE_DRIVER_ERROR_CODES.has(code) ? code : 'UNKNOWN';
15
+ }
16
+ //# sourceMappingURL=safe-error-code.js.map
package/package.json CHANGED
@@ -17,6 +17,9 @@
17
17
  },
18
18
  "description": "Reactively maintains database structure by updating schema and retrying on MySQL errors",
19
19
  "devDependencies": {
20
+ "@itrocks/class-file": "latest",
21
+ "@itrocks/rename": "latest",
22
+ "@types/node": "^24.10",
20
23
  "typescript": "^7.0"
21
24
  },
22
25
  "engines": {
@@ -49,7 +52,9 @@
49
52
  "url": "git+https://github.com/itrocks-ts/mysql-maintainer.git"
50
53
  },
51
54
  "scripts": {
52
- "build": "tsc"
55
+ "build": "tsc",
56
+ "test": "npm run build && node --test \"test/**/*.test.mjs\"",
57
+ "test:integration": "npm run build && node --test \"test/**/*.integration.mjs\""
53
58
  },
54
- "version": "0.2.7"
59
+ "version": "0.3.0"
55
60
  }