@fadhilp/stateql 0.1.2 → 0.2.2

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.
@@ -1,7 +1,17 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { DatabaseSync } from "node:sqlite";
1
+ import { fork } from "node:child_process";
2
+ import { createConnection as createMySqlConnection, } from "mysql2";
3
3
  import { Client, types as pgTypes } from "pg";
4
- import { hash, parseJson, toJsonSafe } from "./util.js";
4
+ import { parseJson, toJsonSafe } from "./util.js";
5
+ export class AdapterExecutionError extends Error {
6
+ reason;
7
+ outcomeUnknown;
8
+ constructor(message, reason, outcomeUnknown) {
9
+ super(message);
10
+ this.reason = reason;
11
+ this.outcomeUnknown = outcomeUnknown;
12
+ this.name = "AdapterExecutionError";
13
+ }
14
+ }
5
15
  export class BatchWriteError extends Error {
6
16
  outcomeUnknown;
7
17
  constructor(message, outcomeUnknown) {
@@ -10,7 +20,21 @@ export class BatchWriteError extends Error {
10
20
  this.name = "BatchWriteError";
11
21
  }
12
22
  }
13
- export async function createAdapter(connection) {
23
+ export class AdapterWriteError extends Error {
24
+ outcomeUnknown;
25
+ constructor(message, outcomeUnknown) {
26
+ super(message);
27
+ this.outcomeUnknown = outcomeUnknown;
28
+ this.name = "AdapterWriteError";
29
+ }
30
+ }
31
+ export function createAdapterContext(timeoutMs, signal) {
32
+ return {
33
+ deadline: Date.now() + timeoutMs,
34
+ ...(signal ? { signal } : {}),
35
+ };
36
+ }
37
+ export async function createAdapter(connection, context) {
14
38
  const source = connection.secret_env
15
39
  ? process.env[connection.secret_env]
16
40
  : connection.source;
@@ -18,201 +42,451 @@ export async function createAdapter(connection) {
18
42
  throw new Error(`Environment variable ${connection.secret_env ?? "(missing)"} is not set.`);
19
43
  }
20
44
  if (connection.driver === "sqlite") {
21
- return new SQLiteAdapter(source, Boolean(connection.read_only));
45
+ return new SQLiteAdapter(source, Boolean(connection.read_only), context);
22
46
  }
23
- return new PostgresAdapter(source, Boolean(connection.read_only));
47
+ if (connection.driver === "postgres") {
48
+ return new PostgresAdapter(source, Boolean(connection.read_only), context);
49
+ }
50
+ return new MySqlAdapter(source, Boolean(connection.read_only), context);
24
51
  }
25
52
  class SQLiteAdapter {
26
53
  source;
27
54
  readOnly;
55
+ context;
28
56
  confidence = "database_reported";
29
- db;
30
- constructor(source, readOnly) {
57
+ child;
58
+ pending = new Map();
59
+ nextId = 1;
60
+ closed = false;
61
+ constructor(source, readOnly, context) {
31
62
  this.source = source;
32
63
  this.readOnly = readOnly;
33
- this.db = new DatabaseSync(source, {
34
- readOnly,
35
- enableForeignKeyConstraints: true,
64
+ this.context = context;
65
+ this.child = fork(new URL("./sqlite-process.js", import.meta.url), [], {
66
+ execArgv: [],
67
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
68
+ serialization: "advanced",
69
+ });
70
+ this.child.on("message", (message) => {
71
+ const call = this.pending.get(message.id);
72
+ if (!call)
73
+ return;
74
+ if (message.error) {
75
+ call.reject(call.batch
76
+ ? new BatchWriteError(message.error.message, message.error.outcomeUnknown ?? true)
77
+ : message.error.outcomeUnknown === undefined
78
+ ? new Error(message.error.message)
79
+ : new AdapterWriteError(message.error.message, message.error.outcomeUnknown));
80
+ }
81
+ else {
82
+ call.resolve(message.result);
83
+ }
84
+ });
85
+ this.child.on("error", (error) => this.failPending(error));
86
+ this.child.on("exit", (code, signal) => {
87
+ if (!this.closed) {
88
+ this.failPending(new Error(`SQLite execution process exited unexpectedly (${signal ?? code ?? "unknown"}).`));
89
+ }
36
90
  });
37
- this.db.exec("PRAGMA busy_timeout = 5000");
38
91
  }
39
92
  async read(sql, params) {
40
- const statement = this.db.prepare(sql);
41
- const rows = bindAll(statement, params);
42
- return {
43
- rows: toJsonSafe(rows),
44
- columns: statement.columns().map((column) => ({
45
- name: column.name,
46
- type: column.type?.toLowerCase() ?? inferType(rows, column.name),
47
- })),
48
- };
93
+ return this.call("read", [sql, params], false, false);
94
+ }
95
+ async write(sql, params) {
96
+ return this.call("write", [sql, params], true, false);
97
+ }
98
+ async writeBatch(operations, isolation) {
99
+ return this.call("writeBatch", [operations, isolation], true, true);
100
+ }
101
+ async signature() {
102
+ return this.call("signature", [], false, false);
103
+ }
104
+ async inspect(kind, table) {
105
+ return this.call("inspect", [kind, table], false, false);
106
+ }
107
+ async close() {
108
+ if (this.closed)
109
+ return;
110
+ this.closed = true;
111
+ if (this.child.exitCode !== null || this.child.signalCode !== null)
112
+ return;
113
+ await new Promise((resolve) => {
114
+ const done = () => {
115
+ clearTimeout(timer);
116
+ resolve();
117
+ };
118
+ const timer = setTimeout(() => {
119
+ try {
120
+ this.child.kill("SIGKILL");
121
+ }
122
+ catch {
123
+ // Process already exited.
124
+ }
125
+ resolve();
126
+ }, 1_000);
127
+ timer.unref();
128
+ this.child.once("exit", done);
129
+ try {
130
+ this.child.disconnect();
131
+ }
132
+ catch {
133
+ try {
134
+ this.child.kill("SIGKILL");
135
+ }
136
+ catch {
137
+ // Process already exited.
138
+ }
139
+ }
140
+ });
141
+ }
142
+ async call(operation, args, outcomeUnknown, batch) {
143
+ throwIfStopped(this.context, false);
144
+ if (this.closed)
145
+ throw new Error("SQLite adapter is closed.");
146
+ const id = this.nextId++;
147
+ const result = new Promise((resolve, reject) => {
148
+ this.pending.set(id, { batch, resolve, reject });
149
+ try {
150
+ this.child.send({
151
+ id,
152
+ source: this.source,
153
+ readOnly: this.readOnly,
154
+ operation,
155
+ args,
156
+ busyTimeoutMs: Math.min(5_000, remainingMilliseconds(this.context)),
157
+ }, (error) => {
158
+ if (error)
159
+ reject(error);
160
+ });
161
+ }
162
+ catch (error) {
163
+ reject(error);
164
+ }
165
+ });
166
+ try {
167
+ return (await withContext(result, this.context, () => this.terminate(), outcomeUnknown));
168
+ }
169
+ finally {
170
+ this.pending.delete(id);
171
+ }
172
+ }
173
+ terminate() {
174
+ if (this.closed)
175
+ return;
176
+ this.closed = true;
177
+ try {
178
+ this.child.kill("SIGKILL");
179
+ }
180
+ catch {
181
+ // Process already exited.
182
+ }
183
+ }
184
+ failPending(error) {
185
+ for (const call of this.pending.values())
186
+ call.reject(error);
187
+ this.pending.clear();
188
+ }
189
+ }
190
+ class PostgresAdapter {
191
+ readOnly;
192
+ context;
193
+ confidence = "ttl_based";
194
+ client;
195
+ connected = false;
196
+ ending;
197
+ constructor(source, readOnly, context) {
198
+ this.readOnly = readOnly;
199
+ this.context = context;
200
+ const timeout = Math.min(2_147_483_647, remainingMilliseconds(context));
201
+ this.client = new Client({
202
+ connectionString: source,
203
+ connectionTimeoutMillis: timeout,
204
+ statement_timeout: timeout,
205
+ });
206
+ }
207
+ async read(sql, params) {
208
+ await this.connect();
209
+ await this.query("BEGIN READ ONLY", [], false);
210
+ try {
211
+ await this.setLocalDeadline();
212
+ const result = await this.query(sql, postgresParams(params), false);
213
+ await this.query("COMMIT", [], false);
214
+ return {
215
+ rows: toJsonSafe(result.rows),
216
+ columns: result.fields.map((field) => ({
217
+ name: field.name,
218
+ type: pgTypes.getTypeParser(field.dataTypeID).name ||
219
+ `oid_${field.dataTypeID}`,
220
+ })),
221
+ };
222
+ }
223
+ catch (error) {
224
+ await this.rollbackQuietly();
225
+ throw error;
226
+ }
49
227
  }
50
228
  async write(sql, params) {
51
229
  if (this.readOnly)
52
230
  throw new Error("Connection is read-only.");
53
- const result = bindRun(this.db.prepare(sql), params);
54
- return { affectedRows: Number(result.changes) };
231
+ try {
232
+ await this.connect();
233
+ await this.query("BEGIN", [], false);
234
+ }
235
+ catch (error) {
236
+ if (error instanceof AdapterExecutionError)
237
+ throw error;
238
+ throw new AdapterWriteError(errorText(error), false);
239
+ }
240
+ let committing = false;
241
+ try {
242
+ await this.setLocalDeadline();
243
+ const result = await this.query(sql, postgresParams(params), true);
244
+ committing = true;
245
+ await this.query("COMMIT", [], true);
246
+ return { affectedRows: result.rowCount ?? 0 };
247
+ }
248
+ catch (error) {
249
+ const rolledBack = await this.rollbackQuietly();
250
+ if (!committing && rolledBack) {
251
+ if (error instanceof AdapterExecutionError) {
252
+ throw new AdapterExecutionError(error.message, error.reason, false);
253
+ }
254
+ throw new AdapterWriteError(errorText(error), false);
255
+ }
256
+ if (error instanceof AdapterExecutionError)
257
+ throw error;
258
+ throw new AdapterWriteError(errorText(error), true);
259
+ }
55
260
  }
56
261
  async writeBatch(operations, isolation) {
57
262
  if (this.readOnly)
58
263
  throw new Error("Connection is read-only.");
59
- if (isolation !== "serializable") {
60
- throw new Error(`SQLite does not support isolation level "${isolation}".`);
264
+ await this.connect();
265
+ const level = isolation.toUpperCase();
266
+ if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
267
+ throw new Error(`Unsupported PostgreSQL isolation level "${isolation}".`);
61
268
  }
62
- const results = [];
63
269
  try {
64
- this.db.exec("BEGIN");
270
+ await this.query(`BEGIN ISOLATION LEVEL ${level}`, [], false);
65
271
  }
66
272
  catch (error) {
67
273
  throw new BatchWriteError(errorText(error), false);
68
274
  }
275
+ const results = [];
69
276
  try {
70
277
  for (const operation of operations) {
71
- results.push(await this.write(operation.sql, parseJson(operation.parameters, [])));
278
+ await this.setLocalDeadline();
279
+ const result = await this.query(operation.sql, postgresParams(parseJson(operation.parameters, [])), true);
280
+ results.push({ affectedRows: result.rowCount ?? 0 });
72
281
  }
73
282
  }
74
283
  catch (error) {
75
- try {
76
- this.db.exec("ROLLBACK");
77
- }
78
- catch {
79
- throw new BatchWriteError(errorText(error), true);
284
+ if (await this.rollbackQuietly()) {
285
+ throw new BatchWriteError(errorText(error), false);
80
286
  }
81
- throw new BatchWriteError(errorText(error), false);
287
+ throw new BatchWriteError(errorText(error), true);
82
288
  }
83
289
  try {
84
- this.db.exec("COMMIT");
290
+ await this.query("COMMIT", [], true);
85
291
  return results;
86
292
  }
87
293
  catch (error) {
88
- try {
89
- this.db.exec("ROLLBACK");
90
- throw new BatchWriteError(errorText(error), false);
91
- }
92
- catch (rollbackError) {
93
- if (rollbackError instanceof BatchWriteError)
94
- throw rollbackError;
95
- throw new BatchWriteError(errorText(error), true);
96
- }
294
+ await this.rollbackQuietly();
295
+ throw new BatchWriteError(errorText(error), true);
97
296
  }
98
297
  }
99
298
  async signature() {
100
- if (this.source === ":memory:")
101
- return "memory";
102
- const stats = statSync(this.source, { bigint: true });
103
- const walPath = `${this.source}-wal`;
104
- const wal = existsSync(walPath)
105
- ? statSync(walPath, { bigint: true })
106
- : undefined;
107
- return hash({
108
- size: stats.size.toString(),
109
- modified: stats.mtimeNs.toString(),
110
- walSize: wal?.size.toString() ?? "0",
111
- walModified: wal?.mtimeNs.toString() ?? "0",
112
- });
299
+ throwIfStopped(this.context, false);
300
+ return "ttl";
113
301
  }
114
302
  async inspect(kind, table) {
303
+ await this.connect();
304
+ await this.query("BEGIN READ ONLY", [], false);
305
+ try {
306
+ const result = await this.inspectTransaction(kind, table);
307
+ await this.query("COMMIT", [], false);
308
+ return result;
309
+ }
310
+ catch (error) {
311
+ await this.rollbackQuietly();
312
+ throw error;
313
+ }
314
+ }
315
+ async close() {
316
+ if (!this.ending) {
317
+ this.ending = this.client.end().catch(() => undefined);
318
+ }
319
+ await this.ending;
320
+ }
321
+ async inspectTransaction(kind, table) {
115
322
  if (kind === "schema") {
116
- const tables = this.db
117
- .prepare(`SELECT name, type
118
- FROM sqlite_master
119
- WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
120
- ORDER BY name`)
121
- .all();
122
- return { schema: "main", tables };
323
+ await this.setLocalDeadline();
324
+ const result = await this.query(`SELECT table_schema AS schema, table_name AS name, table_type AS type
325
+ FROM information_schema.tables
326
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
327
+ ORDER BY table_schema, table_name`, [], false);
328
+ return { tables: result.rows };
123
329
  }
124
330
  if (!table)
125
331
  throw new Error(`Table is required for inspect ${kind}.`);
126
- const quoted = quoteSqliteLiteral(table);
127
- const columns = this.db
128
- .prepare(`PRAGMA table_info(${quoted})`)
129
- .all();
130
- if (columns.length === 0)
332
+ const [schema, name] = table.includes(".")
333
+ ? table.split(".", 2)
334
+ : ["public", table];
335
+ await this.setLocalDeadline();
336
+ const columns = await this.query(`SELECT column_name AS name, data_type AS type,
337
+ is_nullable = 'YES' AS nullable
338
+ FROM information_schema.columns
339
+ WHERE table_schema = $1 AND table_name = $2
340
+ ORDER BY ordinal_position`, [schema, name], false);
341
+ if (columns.rows.length === 0) {
131
342
  throw new Error(`Table "${table}" was not found.`);
132
- const indexes = this.db
133
- .prepare(`PRAGMA index_list(${quoted})`)
134
- .all();
135
- const foreignKeys = this.db
136
- .prepare(`PRAGMA foreign_key_list(${quoted})`)
137
- .all();
343
+ }
138
344
  if (kind === "columns")
139
- return { table, columns };
345
+ return { table: name, schema, columns: columns.rows };
346
+ await this.setLocalDeadline();
347
+ const indexes = await this.query(`SELECT indexname AS name, indexdef AS definition
348
+ FROM pg_indexes WHERE schemaname = $1 AND tablename = $2
349
+ ORDER BY indexname`, [schema, name], false);
350
+ await this.setLocalDeadline();
351
+ const constraints = await this.query(`SELECT constraint_name AS name, constraint_type AS type
352
+ FROM information_schema.table_constraints
353
+ WHERE table_schema = $1 AND table_name = $2
354
+ ORDER BY constraint_name`, [schema, name], false);
140
355
  if (kind === "indexes")
141
- return { table, indexes };
356
+ return { table: name, schema, indexes: indexes.rows };
142
357
  if (kind === "constraints") {
143
- return {
144
- table,
145
- primary_key: columns
146
- .filter((column) => Number(column.pk) > 0)
147
- .map((column) => column.name),
148
- foreign_keys: foreignKeys,
149
- };
358
+ return { table: name, schema, constraints: constraints.rows };
150
359
  }
151
360
  if (kind !== "table")
152
361
  throw new Error(`Unknown inspection kind "${kind}".`);
153
362
  return {
154
- table,
155
- schema: "main",
156
- columns: columns.map((column) => ({
157
- name: column.name,
158
- type: String(column.type).toLowerCase(),
159
- nullable: column.notnull === 0 && Number(column.pk) === 0,
160
- primary_key: Number(column.pk) > 0,
161
- })),
162
- indexes: indexes.length,
163
- foreign_keys: foreignKeys.length,
363
+ table: name,
364
+ schema,
365
+ columns: columns.rows,
366
+ indexes: indexes.rows.length,
367
+ constraints: constraints.rows.length,
164
368
  };
165
369
  }
166
- async close() {
167
- this.db.close();
370
+ async connect() {
371
+ if (this.connected)
372
+ return;
373
+ throwIfStopped(this.context, false);
374
+ await withContext(this.client.connect(), this.context, () => this.stop(), false);
375
+ this.connected = true;
376
+ if (this.readOnly) {
377
+ await this.query("SET default_transaction_read_only = on", [], false);
378
+ }
379
+ }
380
+ async setLocalDeadline() {
381
+ await this.query(`SET LOCAL statement_timeout = ${remainingMilliseconds(this.context)}`, [], false);
382
+ }
383
+ async query(sql, params, outcomeUnknown) {
384
+ throwIfStopped(this.context, outcomeUnknown);
385
+ try {
386
+ return await withContext(this.client.query(sql, params), this.context, () => this.stop(), outcomeUnknown);
387
+ }
388
+ catch (error) {
389
+ if (error instanceof AdapterExecutionError)
390
+ throw error;
391
+ if (postgresErrorCode(error) === "57014") {
392
+ throw stoppedError(this.context, outcomeUnknown);
393
+ }
394
+ throw error;
395
+ }
396
+ }
397
+ async rollbackQuietly() {
398
+ if (this.ending)
399
+ return false;
400
+ try {
401
+ await this.query("ROLLBACK", [], false);
402
+ return true;
403
+ }
404
+ catch {
405
+ return false;
406
+ }
407
+ }
408
+ stop() {
409
+ if (!this.ending) {
410
+ this.ending = this.client.end().catch(() => undefined);
411
+ }
168
412
  }
169
413
  }
170
- class PostgresAdapter {
414
+ class MySqlAdapter {
415
+ source;
171
416
  readOnly;
417
+ context;
172
418
  confidence = "ttl_based";
419
+ rawClient;
173
420
  client;
174
421
  connected = false;
175
- constructor(source, readOnly) {
422
+ closed = false;
423
+ constructor(source, readOnly, context) {
424
+ this.source = source;
176
425
  this.readOnly = readOnly;
177
- this.client = new Client({ connectionString: source });
426
+ this.context = context;
178
427
  }
179
428
  async read(sql, params) {
180
- await this.connect();
181
- await this.client.query("BEGIN READ ONLY");
429
+ await this.query("START TRANSACTION READ ONLY", [], false, false);
182
430
  try {
183
- const result = await this.client.query(sql, postgresParams(params));
184
- await this.client.query("COMMIT");
431
+ const [result, fields] = await this.query(sql, mysqlParams(params), false, true);
432
+ await this.query("COMMIT", [], false, false);
185
433
  return {
186
- rows: toJsonSafe(result.rows),
187
- columns: result.fields.map((field) => ({
434
+ rows: toJsonSafe(mysqlRows(result)),
435
+ columns: fields.map((field) => ({
188
436
  name: field.name,
189
- type: pgTypes.getTypeParser(field.dataTypeID).name ||
190
- `oid_${field.dataTypeID}`,
437
+ type: mysqlFieldType(field),
191
438
  })),
192
439
  };
193
440
  }
194
441
  catch (error) {
195
- await this.client.query("ROLLBACK");
442
+ await this.rollbackQuietly();
196
443
  throw error;
197
444
  }
198
445
  }
199
446
  async write(sql, params) {
200
447
  if (this.readOnly)
201
448
  throw new Error("Connection is read-only.");
202
- await this.connect();
203
- const result = await this.client.query(sql, postgresParams(params));
204
- return { affectedRows: result.rowCount ?? 0 };
449
+ let values;
450
+ try {
451
+ values = mysqlParams(params);
452
+ }
453
+ catch (error) {
454
+ throw new AdapterWriteError(errorText(error), false);
455
+ }
456
+ try {
457
+ await this.query("START TRANSACTION", [], false, false);
458
+ }
459
+ catch (error) {
460
+ if (error instanceof AdapterExecutionError)
461
+ throw error;
462
+ throw new AdapterWriteError(errorText(error), false);
463
+ }
464
+ try {
465
+ const [result] = await this.query(sql, values, true, true);
466
+ await this.query("COMMIT", [], true, false);
467
+ return { affectedRows: mysqlAffectedRows(result) };
468
+ }
469
+ catch (error) {
470
+ await this.rollbackQuietly();
471
+ if (error instanceof AdapterExecutionError)
472
+ throw error;
473
+ throw new AdapterWriteError(errorText(error), true);
474
+ }
205
475
  }
206
476
  async writeBatch(operations, isolation) {
207
477
  if (this.readOnly)
208
478
  throw new Error("Connection is read-only.");
209
- await this.connect();
479
+ const unsupported = operations.find((operation) => !MYSQL_TRANSACTIONAL_STATEMENTS.has(operation.statement_type));
480
+ if (unsupported) {
481
+ throw new BatchWriteError(`MySQL transactions cannot atomically include ${unsupported.statement_type.toUpperCase()} statements.`, false);
482
+ }
210
483
  const level = isolation.toUpperCase();
211
- if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
212
- throw new Error(`Unsupported PostgreSQL isolation level "${isolation}".`);
484
+ if (!MYSQL_ISOLATION_LEVELS.has(level)) {
485
+ throw new Error(`Unsupported MySQL isolation level "${isolation}".`);
213
486
  }
214
487
  try {
215
- await this.client.query(`BEGIN ISOLATION LEVEL ${level}`);
488
+ await this.query(`SET TRANSACTION ISOLATION LEVEL ${level}`, [], false, false);
489
+ await this.query("START TRANSACTION", [], false, false);
216
490
  }
217
491
  catch (error) {
218
492
  throw new BatchWriteError(errorText(error), false);
@@ -220,127 +494,303 @@ class PostgresAdapter {
220
494
  const results = [];
221
495
  try {
222
496
  for (const operation of operations) {
223
- results.push(await this.write(operation.sql, parseJson(operation.parameters, [])));
497
+ const [result] = await this.query(operation.sql, mysqlParams(parseJson(operation.parameters, [])), true, true);
498
+ results.push({ affectedRows: mysqlAffectedRows(result) });
224
499
  }
225
500
  }
226
501
  catch (error) {
227
- try {
228
- await this.client.query("ROLLBACK");
229
- }
230
- catch {
231
- throw new BatchWriteError(errorText(error), true);
502
+ if (await this.rollbackQuietly()) {
503
+ throw new BatchWriteError(errorText(error), false);
232
504
  }
233
- throw new BatchWriteError(errorText(error), false);
505
+ throw new BatchWriteError(errorText(error), true);
234
506
  }
235
507
  try {
236
- await this.client.query("COMMIT");
508
+ await this.query("COMMIT", [], true, false);
237
509
  return results;
238
510
  }
239
511
  catch (error) {
240
- try {
241
- await this.client.query("ROLLBACK");
242
- }
243
- catch {
244
- // COMMIT response was lost; rollback cannot establish outcome.
245
- }
512
+ await this.rollbackQuietly();
246
513
  throw new BatchWriteError(errorText(error), true);
247
514
  }
248
515
  }
249
516
  async signature() {
517
+ throwIfStopped(this.context, false);
250
518
  return "ttl";
251
519
  }
252
520
  async inspect(kind, table) {
253
- await this.connect();
521
+ await this.query("START TRANSACTION READ ONLY", [], false, false);
522
+ try {
523
+ const result = await this.inspectTransaction(kind, table);
524
+ await this.query("COMMIT", [], false, false);
525
+ return result;
526
+ }
527
+ catch (error) {
528
+ await this.rollbackQuietly();
529
+ throw error;
530
+ }
531
+ }
532
+ async close() {
533
+ if (this.closed)
534
+ return;
535
+ this.closed = true;
536
+ this.connected = false;
537
+ if (!this.client)
538
+ return;
539
+ await this.client.end().catch(() => undefined);
540
+ }
541
+ async inspectTransaction(kind, table) {
254
542
  if (kind === "schema") {
255
- const result = await this.client.query(`SELECT table_schema AS schema, table_name AS name, table_type AS type
543
+ const [result] = await this.query(`SELECT TABLE_SCHEMA AS schema, TABLE_NAME AS name, TABLE_TYPE AS type
256
544
  FROM information_schema.tables
257
- WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
258
- ORDER BY table_schema, table_name`);
259
- return { tables: result.rows };
545
+ WHERE TABLE_SCHEMA = DATABASE()
546
+ ORDER BY TABLE_NAME`, [], false, false);
547
+ return { tables: mysqlRows(result) };
260
548
  }
261
549
  if (!table)
262
550
  throw new Error(`Table is required for inspect ${kind}.`);
263
- const [schema, name] = table.includes(".")
264
- ? table.split(".", 2)
265
- : ["public", table];
266
- const columns = await this.client.query(`SELECT column_name AS name, data_type AS type,
267
- is_nullable = 'YES' AS nullable
551
+ const separator = table.indexOf(".");
552
+ const schema = separator === -1
553
+ ? await this.databaseName()
554
+ : table.slice(0, separator);
555
+ const name = separator === -1 ? table : table.slice(separator + 1);
556
+ if (!schema || !name)
557
+ throw new Error(`Invalid table name "${table}".`);
558
+ const [columnResult] = await this.query(`SELECT COLUMN_NAME AS name, DATA_TYPE AS type,
559
+ IS_NULLABLE = 'YES' AS nullable
268
560
  FROM information_schema.columns
269
- WHERE table_schema = $1 AND table_name = $2
270
- ORDER BY ordinal_position`, [schema, name]);
271
- if (columns.rows.length === 0) {
561
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
562
+ ORDER BY ORDINAL_POSITION`, [schema, name], false, true);
563
+ const columns = mysqlRows(columnResult).map((column) => ({
564
+ ...column,
565
+ nullable: Boolean(column.nullable),
566
+ }));
567
+ if (columns.length === 0) {
272
568
  throw new Error(`Table "${table}" was not found.`);
273
569
  }
274
570
  if (kind === "columns")
275
- return { table: name, schema, columns: columns.rows };
276
- const indexes = await this.client.query(`SELECT indexname AS name, indexdef AS definition
277
- FROM pg_indexes WHERE schemaname = $1 AND tablename = $2
278
- ORDER BY indexname`, [schema, name]);
279
- const constraints = await this.client.query(`SELECT constraint_name AS name, constraint_type AS type
571
+ return { table: name, schema, columns };
572
+ const [indexResult] = await this.query(`SELECT INDEX_NAME AS name, NON_UNIQUE AS non_unique,
573
+ INDEX_TYPE AS index_type,
574
+ GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS columns
575
+ FROM information_schema.statistics
576
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
577
+ GROUP BY INDEX_NAME, NON_UNIQUE, INDEX_TYPE
578
+ ORDER BY INDEX_NAME`, [schema, name], false, true);
579
+ const indexes = mysqlRows(indexResult).map((index) => ({
580
+ name: String(index.name),
581
+ definition: `${Number(index.non_unique) === 0 ? "UNIQUE " : ""}${String(index.index_type)} (${String(index.columns)})`,
582
+ }));
583
+ const [constraintResult] = await this.query(`SELECT CONSTRAINT_NAME AS name, CONSTRAINT_TYPE AS type
280
584
  FROM information_schema.table_constraints
281
- WHERE table_schema = $1 AND table_name = $2
282
- ORDER BY constraint_name`, [schema, name]);
585
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
586
+ ORDER BY CONSTRAINT_NAME`, [schema, name], false, true);
587
+ const constraints = mysqlRows(constraintResult);
283
588
  if (kind === "indexes")
284
- return { table: name, schema, indexes: indexes.rows };
589
+ return { table: name, schema, indexes };
285
590
  if (kind === "constraints") {
286
- return { table: name, schema, constraints: constraints.rows };
591
+ return { table: name, schema, constraints };
287
592
  }
288
593
  if (kind !== "table")
289
594
  throw new Error(`Unknown inspection kind "${kind}".`);
290
595
  return {
291
596
  table: name,
292
597
  schema,
293
- columns: columns.rows,
294
- indexes: indexes.rows.length,
295
- constraints: constraints.rows.length,
598
+ columns,
599
+ indexes: indexes.length,
600
+ constraints: constraints.length,
296
601
  };
297
602
  }
298
- async close() {
299
- if (this.connected)
300
- await this.client.end();
603
+ async databaseName() {
604
+ const [result] = await this.query("SELECT DATABASE() AS database_name", [], false, false);
605
+ const row = mysqlRows(result)[0];
606
+ if (!row?.database_name)
607
+ throw new Error("MySQL connection has no database selected.");
608
+ return String(row.database_name);
301
609
  }
302
610
  async connect() {
303
611
  if (this.connected)
304
612
  return;
305
- await this.client.connect();
306
- this.connected = true;
307
- if (this.readOnly) {
308
- await this.client.query("SET default_transaction_read_only = on");
613
+ throwIfStopped(this.context, false);
614
+ if (this.closed)
615
+ throw new Error("MySQL adapter is closed.");
616
+ const rawClient = createMySqlConnection({
617
+ uri: this.source,
618
+ connectTimeout: remainingMilliseconds(this.context),
619
+ multipleStatements: false,
620
+ namedPlaceholders: false,
621
+ supportBigNumbers: true,
622
+ bigNumberStrings: true,
623
+ dateStrings: true,
624
+ });
625
+ this.rawClient = rawClient;
626
+ this.client = rawClient.promise();
627
+ try {
628
+ await withContext(this.client.connect(), this.context, () => this.stop(), false);
629
+ this.connected = true;
630
+ if (this.readOnly) {
631
+ await this.runQuery("SET SESSION TRANSACTION READ ONLY", [], false, false);
632
+ }
633
+ }
634
+ catch (error) {
635
+ this.stop();
636
+ throw error;
637
+ }
638
+ }
639
+ async query(sql, params, outcomeUnknown, prepared) {
640
+ await this.connect();
641
+ return this.runQuery(sql, params, outcomeUnknown, prepared);
642
+ }
643
+ async runQuery(sql, params, outcomeUnknown, prepared) {
644
+ throwIfStopped(this.context, outcomeUnknown);
645
+ if (!this.client || this.closed)
646
+ throw new Error("MySQL adapter is closed.");
647
+ const result = prepared
648
+ ? this.client.execute(sql, params)
649
+ : this.client.query(sql, params);
650
+ return withContext(result, this.context, () => this.stop(), outcomeUnknown);
651
+ }
652
+ async rollbackQuietly() {
653
+ if (this.closed || !this.connected)
654
+ return false;
655
+ try {
656
+ await this.query("ROLLBACK", [], false, false);
657
+ return true;
658
+ }
659
+ catch {
660
+ return false;
309
661
  }
310
662
  }
663
+ stop() {
664
+ if (this.closed)
665
+ return;
666
+ this.closed = true;
667
+ this.connected = false;
668
+ this.rawClient?.destroy();
669
+ }
311
670
  }
312
- const POSTGRES_ISOLATION_LEVELS = new Set([
671
+ const MYSQL_ISOLATION_LEVELS = new Set([
313
672
  "SERIALIZABLE",
314
673
  "REPEATABLE READ",
315
674
  "READ COMMITTED",
316
675
  "READ UNCOMMITTED",
317
676
  ]);
318
- function bindAll(statement, params) {
319
- if (Array.isArray(params))
320
- return statement.all(...params);
321
- return statement.all(params);
677
+ const MYSQL_TRANSACTIONAL_STATEMENTS = new Set([
678
+ "delete",
679
+ "insert",
680
+ "replace",
681
+ "update",
682
+ ]);
683
+ function mysqlParams(params) {
684
+ if (!Array.isArray(params)) {
685
+ throw new Error("MySQL parameters must be a JSON array.");
686
+ }
687
+ return params.map((value) => {
688
+ if (value === undefined ||
689
+ Array.isArray(value) ||
690
+ (typeof value === "object" &&
691
+ value !== null &&
692
+ !(value instanceof Date) &&
693
+ !Buffer.isBuffer(value) &&
694
+ !(value instanceof Uint8Array))) {
695
+ throw new Error("MySQL parameter values must be scalar.");
696
+ }
697
+ return value;
698
+ });
322
699
  }
323
- function bindRun(statement, params) {
324
- if (Array.isArray(params))
325
- return statement.run(...params);
326
- return statement.run(params);
700
+ function mysqlRows(result) {
701
+ if (!Array.isArray(result)) {
702
+ throw new Error("MySQL statement did not return rows.");
703
+ }
704
+ return result;
327
705
  }
706
+ function mysqlAffectedRows(result) {
707
+ if (Array.isArray(result) || !("affectedRows" in result)) {
708
+ throw new Error("MySQL statement did not return a write result.");
709
+ }
710
+ return Number(result.affectedRows);
711
+ }
712
+ function mysqlFieldType(field) {
713
+ return String(field.typeName ?? field.columnType ?? field.type ?? "unknown").toLowerCase();
714
+ }
715
+ const POSTGRES_ISOLATION_LEVELS = new Set([
716
+ "SERIALIZABLE",
717
+ "REPEATABLE READ",
718
+ "READ COMMITTED",
719
+ "READ UNCOMMITTED",
720
+ ]);
328
721
  function postgresParams(params) {
329
722
  if (Array.isArray(params))
330
723
  return params;
331
724
  throw new Error("PostgreSQL parameters must be a JSON array.");
332
725
  }
333
- function errorText(error) {
334
- return error instanceof Error ? error.message : String(error);
726
+ function remainingMilliseconds(context) {
727
+ return Math.max(1, Math.ceil(context.deadline - Date.now()));
728
+ }
729
+ function throwIfStopped(context, outcomeUnknown) {
730
+ if (context.signal?.aborted || context.deadline <= Date.now()) {
731
+ throw stoppedError(context, outcomeUnknown);
732
+ }
335
733
  }
336
- function inferType(rows, name) {
337
- const value = rows.find((row) => row[name] !== null)?.[name];
338
- if (value === undefined)
339
- return "unknown";
340
- if (Buffer.isBuffer(value))
341
- return "binary";
342
- return typeof value;
734
+ function stoppedError(context, outcomeUnknown) {
735
+ const aborted = context.signal?.aborted ?? false;
736
+ return new AdapterExecutionError(aborted ? "Database operation cancelled." : "Database operation timed out.", aborted ? "aborted" : "timeout", outcomeUnknown);
343
737
  }
344
- function quoteSqliteLiteral(value) {
345
- return `'${value.replaceAll("'", "''")}'`;
738
+ function withContext(promise, context, onStop, outcomeUnknown) {
739
+ try {
740
+ throwIfStopped(context, outcomeUnknown);
741
+ }
742
+ catch (error) {
743
+ try {
744
+ onStop();
745
+ }
746
+ catch {
747
+ // Preserve deadline/cancellation error.
748
+ }
749
+ return Promise.reject(error);
750
+ }
751
+ return new Promise((resolve, reject) => {
752
+ let settled = false;
753
+ const finish = (action) => {
754
+ if (settled)
755
+ return;
756
+ settled = true;
757
+ clearTimeout(timer);
758
+ context.signal?.removeEventListener("abort", stop);
759
+ action();
760
+ };
761
+ const stop = () => {
762
+ finish(() => {
763
+ try {
764
+ onStop();
765
+ }
766
+ catch {
767
+ // Preserve deadline/cancellation error.
768
+ }
769
+ reject(stoppedError(context, outcomeUnknown));
770
+ });
771
+ };
772
+ const stopped = () => Boolean(context.signal?.aborted) || context.deadline <= Date.now();
773
+ const timer = setTimeout(stop, remainingMilliseconds(context));
774
+ context.signal?.addEventListener("abort", stop, { once: true });
775
+ promise.then((value) => {
776
+ if (stopped())
777
+ stop();
778
+ else
779
+ finish(() => resolve(value));
780
+ }, (error) => {
781
+ if (stopped())
782
+ stop();
783
+ else
784
+ finish(() => reject(error));
785
+ });
786
+ });
787
+ }
788
+ function postgresErrorCode(error) {
789
+ if (!error || typeof error !== "object")
790
+ return undefined;
791
+ const code = error.code;
792
+ return typeof code === "string" ? code : undefined;
793
+ }
794
+ function errorText(error) {
795
+ return error instanceof Error ? error.message : String(error);
346
796
  }