@itrocks/mysql-maintainer 0.2.8 → 0.3.1

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
@@ -1,16 +1,20 @@
1
1
  import { ObjectOrType } from '@itrocks/class-type';
2
+ import { Type } from '@itrocks/class-type';
2
3
  import { Connection } from 'mariadb';
3
4
  import { QueryOptions } from 'mariadb';
4
5
  import { SqlError } from 'mariadb';
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
7
  export type Context = ObjectOrType | ObjectOrType[];
6
8
  export declare class Contextual implements Partial<Connection> {
7
9
  [x: number]: (() => Promise<void>) | undefined;
8
- contexts: Context[];
10
+ contextStorage: AsyncLocalStorage<Context>;
9
11
  errorCount: Record<string, Record<string, Array<number>>>;
10
12
  superQuery: <T = any>(sql: string | QueryOptions, values?: any) => Promise<T>;
13
+ uniqueIndexPromises: WeakMap<Type, Promise<void>>;
11
14
  applyTo(connection: ContextualConnection): Promise<ContextualConnection>;
12
15
  incrementErrorCount(sql: string, error: any): this is Connection;
13
16
  isSqlError(error: any): error is SqlError;
14
17
  query<T = any>(sql: string | QueryOptions, values?: any): Promise<T>;
18
+ run<T>(context: Context, operation: () => Promise<T>): Promise<T>;
15
19
  }
16
20
  export type ContextualConnection = Connection & Contextual;
@@ -1,20 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Contextual = void 0;
4
+ const node_async_hooks_1 = require("node:async_hooks");
4
5
  const mysql_maintainer_1 = require("./mysql-maintainer");
5
6
  const mysql_maintainer_2 = require("./mysql-maintainer");
7
+ const safe_error_code_1 = require("./safe-error-code");
6
8
  const MANAGED_ERROR_CODES = ['ER_BAD_FIELD_ERROR', 'ER_CANNOT_ADD_FOREIGN', 'ER_NO_SUCH_TABLE'];
7
9
  class Contextual {
8
- contexts = [];
10
+ contextStorage = new node_async_hooks_1.AsyncLocalStorage();
9
11
  errorCount = {};
10
12
  superQuery = () => new Promise(() => { });
13
+ uniqueIndexPromises = new WeakMap();
11
14
  async applyTo(connection) {
12
- connection.contexts = [];
15
+ connection.contextStorage = new node_async_hooks_1.AsyncLocalStorage();
13
16
  connection.errorCount = {};
14
17
  connection.incrementErrorCount = Contextual.prototype.incrementErrorCount;
15
18
  connection.isSqlError = Contextual.prototype.isSqlError;
19
+ connection.run = Contextual.prototype.run;
16
20
  connection.superQuery = connection.query;
17
21
  connection.query = Contextual.prototype.query;
22
+ connection.uniqueIndexPromises = new WeakMap();
18
23
  return connection;
19
24
  }
20
25
  incrementErrorCount(sql, error) {
@@ -24,8 +29,12 @@ class Contextual {
24
29
  if (!this.errorCount[sql][error.message]) {
25
30
  this.errorCount[sql][error.message] = [];
26
31
  }
27
- this.errorCount[sql][error.message].push(new Date().getTime());
28
- console.log('incremented', this.errorCount[sql][error.message].length, ':', sql, error.message);
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
+ });
29
38
  return true;
30
39
  }
31
40
  isSqlError(error) {
@@ -46,7 +55,7 @@ class Contextual {
46
55
  sql = (typeof sql === 'object') ? sql.sql : sql;
47
56
  if (this.incrementErrorCount(sql, error)
48
57
  && (this.errorCount[sql][error.message].length < 5)
49
- && await new mysql_maintainer_2.MysqlMaintainer(this).manageError(error, this.contexts[this.contexts.length - 1], sql, values)) {
58
+ && await new mysql_maintainer_2.MysqlMaintainer(this).manageError(error, this.contextStorage.getStore(), sql, values)) {
50
59
  return this.query(sql, values);
51
60
  }
52
61
  if (mysql_maintainer_1.DEBUG)
@@ -54,6 +63,9 @@ class Contextual {
54
63
  throw error;
55
64
  }
56
65
  }
66
+ run(context, operation) {
67
+ return this.contextStorage.run(context, operation);
68
+ }
57
69
  }
58
70
  exports.Contextual = Contextual;
59
71
  //# sourceMappingURL=contextual-connection.js.map
@@ -4,7 +4,7 @@ import { Connection } from 'mariadb';
4
4
  import { QueryOptions } from 'mariadb';
5
5
  import { SqlError } from 'mariadb';
6
6
  import { Context } from './contextual-connection';
7
- export { Mysql } from './mysql';
7
+ export { Mysql, UniqueIndexMaintenanceError } from './mysql';
8
8
  export declare const DEBUG = false;
9
9
  export declare class MysqlMaintainer {
10
10
  connection: Connection;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MysqlMaintainer = exports.DEBUG = exports.Mysql = void 0;
3
+ exports.MysqlMaintainer = exports.DEBUG = exports.UniqueIndexMaintenanceError = exports.Mysql = void 0;
4
4
  const class_type_1 = require("@itrocks/class-type");
5
5
  const mysql_1 = require("@itrocks/mysql");
6
6
  const mysql_to_schema_1 = require("@itrocks/mysql-to-schema");
@@ -11,6 +11,7 @@ const schema_to_mysql_1 = require("@itrocks/schema-to-mysql");
11
11
  const store_1 = require("@itrocks/store");
12
12
  var mysql_2 = require("./mysql");
13
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; } });
14
15
  exports.DEBUG = false;
15
16
  const DELETION = false;
16
17
  class MysqlMaintainer {
package/cjs/mysql.d.ts CHANGED
@@ -3,24 +3,30 @@ import { Type } from '@itrocks/class-type';
3
3
  import { Mysql as M } from '@itrocks/mysql';
4
4
  import { Entity } from '@itrocks/storage';
5
5
  import { Identifier } from '@itrocks/storage';
6
- import { MayEntity } from '@itrocks/storage';
7
6
  import { Options } from '@itrocks/storage';
8
7
  import { SearchType } from '@itrocks/storage';
9
8
  import { ContextualConnection } from './contextual-connection';
9
+ export declare class UniqueIndexMaintenanceError extends Error {
10
+ readonly code = "SCHEMA_UNIQUE_INDEX_MAINTENANCE_FAILED";
11
+ constructor();
12
+ }
10
13
  export declare class Mysql extends M {
11
14
  connection?: ContextualConnection;
12
15
  connect(): Promise<ContextualConnection>;
13
16
  deleteId<T extends object>(type: ObjectOrType<T>, id: any, property?: keyof Entity<T>): Promise<void>;
14
17
  deleteRelatedId<T extends Entity>(object: T, property: keyof T, id: Identifier): Promise<void>;
18
+ ensureUniqueIndexes(type: Type): Promise<void>;
19
+ private hasUniqueIndexes;
15
20
  insert<T extends object>(object: T): Promise<Entity<T>>;
16
21
  insertRelatedId<T extends Entity>(object: T, property: keyof T, id: Identifier): Promise<void>;
22
+ protected maintainUniqueIndexes(type: Type, connection: ContextualConnection): Promise<void>;
17
23
  read<T extends object>(type: Type<T>, id: Identifier): Promise<Entity<T>>;
18
24
  readCollection<T extends object, PT extends object>(object: Entity<T>, property: keyof T, type?: Type<PT>): Promise<Awaited<PT & {
19
25
  id: Identifier;
20
26
  }>[]>;
21
27
  readCollectionIds<T extends object, PT extends object>(object: Entity<T>, property: keyof T, type?: Type<PT>): Promise<Identifier[]>;
22
28
  readMultiple<T extends object>(type: Type<T>, ids: Identifier[]): Promise<Entity<T>[]>;
23
- save<T extends object>(object: MayEntity<T>): Promise<Entity<T>>;
24
29
  search<T extends object>(type: Type<T>, search?: SearchType<T>, options?: Options): Promise<Entity<T>[]>;
25
30
  update<T extends object>(object: Entity<T>): Promise<Entity<T>>;
31
+ private withContext;
26
32
  }
package/cjs/mysql.js CHANGED
@@ -1,91 +1,100 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Mysql = void 0;
3
+ exports.Mysql = exports.UniqueIndexMaintenanceError = void 0;
4
+ const class_type_1 = require("@itrocks/class-type");
4
5
  const mysql_1 = require("@itrocks/mysql");
5
6
  const reflect_1 = require("@itrocks/reflect");
7
+ const reflect_2 = require("@itrocks/reflect");
8
+ const store_1 = require("@itrocks/store");
6
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;
7
21
  class Mysql extends mysql_1.Mysql {
8
22
  async connect() {
9
23
  return contextual_connection_1.Contextual.prototype.applyTo(await super.connect());
10
24
  }
11
25
  async deleteId(type, id, property = 'id') {
12
- const contexts = (this.connection ?? await this.connect()).contexts;
13
- contexts.push(type);
14
- const result = await super.deleteId(type, id, property);
15
- contexts.pop();
16
- return result;
26
+ return this.withContext(type, () => super.deleteId(type, id, property));
17
27
  }
18
28
  async deleteRelatedId(object, property, id) {
19
- const contexts = (this.connection ?? await this.connect()).contexts;
20
- contexts.push(object);
21
- const result = await super.deleteRelatedId(object, property, id);
22
- contexts.pop();
23
- return result;
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);
24
56
  }
25
57
  async insert(object) {
26
- const contexts = (this.connection ?? await this.connect()).contexts;
27
- contexts.push(object);
28
- const result = await super.insert(object);
29
- contexts.pop();
30
- return result;
58
+ return this.withContext(object, async () => {
59
+ await this.ensureUniqueIndexes((0, class_type_1.typeOf)(object));
60
+ return super.insert(object);
61
+ });
31
62
  }
32
63
  async insertRelatedId(object, property, id) {
33
- const contexts = (this.connection ?? await this.connect()).contexts;
34
- contexts.push(object);
35
- const result = await super.insertRelatedId(object, property, id);
36
- contexts.pop();
37
- return result;
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);
38
72
  }
39
73
  async read(type, id) {
40
- const contexts = (this.connection ?? await this.connect()).contexts;
41
- contexts.push(type);
42
- const result = await super.read(type, id);
43
- contexts.pop();
44
- return result;
74
+ return this.withContext(type, () => super.read(type, id));
45
75
  }
46
76
  async readCollection(object, property, type) {
47
- type ??= new reflect_1.ReflectProperty(object, property).collectionType.elementType.type;
48
- const contexts = (this.connection ?? await this.connect()).contexts;
49
- contexts.push([object, type]);
50
- const result = await super.readCollection(object, property, type);
51
- contexts.pop();
52
- return result;
77
+ type ??= new reflect_2.ReflectProperty(object, property).collectionType.elementType.type;
78
+ return this.withContext([object, type], () => super.readCollection(object, property, type));
53
79
  }
54
80
  async readCollectionIds(object, property, type) {
55
- type ??= new reflect_1.ReflectProperty(object, property).collectionType.elementType.type;
56
- const contexts = (this.connection ?? await this.connect()).contexts;
57
- contexts.push([object, type]);
58
- const result = await super.readCollectionIds(object, property, type);
59
- contexts.pop();
60
- return result;
81
+ type ??= new reflect_2.ReflectProperty(object, property).collectionType.elementType.type;
82
+ return this.withContext([object, type], () => super.readCollectionIds(object, property, type));
61
83
  }
62
84
  async readMultiple(type, ids) {
63
- const contexts = (this.connection ?? await this.connect()).contexts;
64
- contexts.push(type);
65
- const result = await super.readMultiple(type, ids);
66
- contexts.pop();
67
- return result;
68
- }
69
- async save(object) {
70
- const contexts = (this.connection ?? await this.connect()).contexts;
71
- contexts.push(object);
72
- const result = await super.save(object);
73
- contexts.pop();
74
- return result;
85
+ return this.withContext(type, () => super.readMultiple(type, ids));
75
86
  }
76
87
  async search(type, search = {}, options) {
77
- const contexts = (this.connection ?? await this.connect()).contexts;
78
- contexts.push(type);
79
- const result = await super.search(type, search, options);
80
- contexts.pop();
81
- return result;
88
+ return this.withContext(type, () => super.search(type, search, options));
82
89
  }
83
90
  async update(object) {
84
- const contexts = (this.connection ?? await this.connect()).contexts;
85
- contexts.push(object);
86
- const result = await super.update(object);
87
- contexts.pop();
88
- return result;
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);
89
98
  }
90
99
  }
91
100
  exports.Mysql = Mysql;
@@ -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.8"
59
+ "version": "0.3.1"
55
60
  }