@fadhilp/stateql 0.11.1 → 0.13.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
@@ -216,6 +216,116 @@ Use `--params JSON` for a JSON array or named parameters. Use
216
216
  `--params-file FILE` when JSON is awkward to quote; `--params-file -` reads
217
217
  JSON from standard input.
218
218
 
219
+ ### Dialect upserts
220
+
221
+ PostgreSQL `INSERT ... ON CONFLICT DO NOTHING|UPDATE` and MySQL `INSERT ... ON
222
+ DUPLICATE KEY UPDATE` are structurally validated and recorded with statement
223
+ type `upsert`. Finite `VALUES` and MySQL `INSERT ... SET` sources use normal
224
+ write policy. An update-upsert fed by `SELECT` requires `--allow-unbounded`
225
+ because its candidate row count is not statically bounded. Upserts support direct
226
+ `exec`, `plan`/`apply`, and staged transactions; hidden additional writes are rejected.
227
+
228
+ MySQL `INSERT IGNORE` remains a non-overwriting `insert`. SQLite `INSERT OR
229
+ REPLACE` retains destructive-operation approval, while SQLite modern `ON
230
+ CONFLICT ... DO UPDATE` and every `MERGE` form remain blocked until the parser
231
+ can expose their complete mutation structure.
232
+
233
+ ### PostgreSQL diagnostics and maintenance
234
+
235
+ Run PostgreSQL plans through `query`:
236
+
237
+ ```bash
238
+ stql query "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM jobs WHERE id = 42"
239
+ ```
240
+
241
+ Plain `EXPLAIN` may plan a structurally validated `SELECT`, `INSERT`, `UPDATE`,
242
+ or `DELETE`. Because `EXPLAIN ANALYZE` executes its inner statement, StateQL
243
+ accepts only a validated read-only `SELECT`; `SELECT INTO`, writing CTEs, and
244
+ mutations are rejected. Diagnostics execute inside PostgreSQL `BEGIN READ ONLY`
245
+ and are never reused from cache. `--cache require` therefore returns
246
+ `CACHE_MISS` without executing the diagnostic.
247
+
248
+ StateQL supports PostgreSQL 14–18. Top-level `VALUES` is a bounded read and
249
+ accepts normal PostgreSQL positional parameters. It is conservatively
250
+ non-cacheable because expressions may be volatile. The following narrow `SHOW`
251
+ allowlist is also available as non-cacheable diagnostics:
252
+ `server_version`, `server_version_num`, `transaction_read_only`,
253
+ `transaction_isolation`, and `default_transaction_isolation`. `SHOW ALL` and
254
+ other settings remain blocked. Syntax accepted by StateQL but introduced by a
255
+ newer PostgreSQL release may be rejected safely by an older server.
256
+
257
+ `VACUUM`, `ANALYZE`, `REINDEX`, and `CLUSTER` are PostgreSQL maintenance writes:
258
+
259
+ ```bash
260
+ stql exec "VACUUM (ANALYZE) public.jobs" --allow-destructive
261
+ stql plan "REINDEX TABLE public.jobs" --allow-destructive
262
+ ```
263
+
264
+ The PostgreSQL 14–18 grammar includes parenthesized `REINDEX CONCURRENTLY`,
265
+ PostgreSQL 16 `BUFFER_USAGE_LIMIT` for `VACUUM`/`ANALYZE`, optional
266
+ `DATABASE`/`SYSTEM` reindex names, and PostgreSQL 18 `ONLY table *` maintenance
267
+ targets. Memory sizes accept an integer number of kilobytes or a quoted
268
+ `B|kB|MB|GB|TB` value. Older servers may reject newer forms after dispatch, so
269
+ StateQL retains conservative unknown-outcome handling.
270
+
271
+ They require a read-write connection and `--allow-destructive`, reject StateQL
272
+ parameters, and run as individually tracked autocommit operations. They cannot
273
+ be staged in a StateQL transaction. A timeout or cancellation after dispatch is
274
+ reported as `OUTCOME_UNKNOWN`; inspect database state before replaying it. Raw
275
+ `BEGIN`, `COMMIT`, `ROLLBACK`, savepoint, and other transaction-control SQL
276
+ remain unsupported—use `stql transaction` commands instead. See
277
+ [`SQL_COMMAND_ROADMAP.md`](SQL_COMMAND_ROADMAP.md) for the exact implemented
278
+ boundary and deferred command categories.
279
+
280
+ ### SQLite and MySQL diagnostics and maintenance
281
+
282
+ SQLite supports `EXPLAIN QUERY PLAN` for structurally read-only `SELECT`
283
+ statements. MySQL supports `EXPLAIN SELECT` plus bare `SHOW TABLES`,
284
+ `SHOW COLUMNS FROM table`, and `SHOW INDEX|INDEXES FROM table`. Broader
285
+ `EXPLAIN`, `SHOW`, and write-bearing forms remain blocked.
286
+
287
+ MySQL executable comments (`/*! ... */`) are rejected throughout SQL. Because
288
+ StateQL does not assume a server `sql_mode`, quoting that could expose these
289
+ comments under `ANSI_QUOTES` or `NO_BACKSLASH_ESCAPES` is also rejected.
290
+
291
+ These diagnostics use `query`, work with read-only connections, preserve the
292
+ original statement instead of applying StateQL's limiting SQL wrapper, and are
293
+ never reused from cache. Materialized results still receive StateQL's row and
294
+ byte checks.
295
+
296
+ SQLite also supports bare `VACUUM`, plus `ANALYZE [target]` and
297
+ `REINDEX [target]` with at most one unqualified or double-quoted target:
298
+
299
+ ```bash
300
+ stql exec "ANALYZE jobs" --allow-destructive
301
+ stql exec "REINDEX jobs_created_at_idx" --allow-destructive
302
+ stql exec "VACUUM" --allow-destructive
303
+ ```
304
+
305
+ These commands require a read-write connection, reject parameters, run as
306
+ individually tracked autocommit operations, and cannot be staged. A timeout,
307
+ cancellation, or error after dispatch is reported as `OUTCOME_UNKNOWN`.
308
+ `VACUUM INTO`, schema-qualified targets, paths, `ATTACH`, and arbitrary `PRAGMA`
309
+ remain blocked.
310
+
311
+ MySQL supports one optionally qualified bare or backtick-quoted target for
312
+ `ANALYZE TABLE`, `OPTIMIZE TABLE`, and `CHECK TABLE`:
313
+
314
+ ```bash
315
+ stql exec "ANALYZE TABLE jobs" --allow-destructive
316
+ stql plan "OPTIMIZE TABLE jobs" --allow-destructive
317
+ stql query "CHECK TABLE jobs"
318
+ ```
319
+
320
+ `ANALYZE` and `OPTIMIZE` are durable autocommit writes requiring a read-write
321
+ connection and destructive approval; server-reported error rows become known
322
+ failed operations, while timeout or cancellation after dispatch remains
323
+ `OUTCOME_UNKNOWN`. `CHECK TABLE` is an unwrapped, non-cacheable autocommit read
324
+ that works on read-only connections and retains normal result limits. All three
325
+ reject StateQL parameters, options, multiple targets, and additional
326
+ statements, and none can run during a staged transaction.
327
+
328
+
219
329
  ### Native MongoDB
220
330
 
221
331
  MongoDB commands use official Extended JSON (EJSON), so BSON values survive the
@@ -32,7 +32,9 @@ export interface Adapter {
32
32
  readonly confidence: StateConfidence;
33
33
  ping(): Promise<void>;
34
34
  read(sql: string, params: SqlParameters): Promise<ReadResult>;
35
+ readAutocommit?(sql: string, params: SqlParameters): Promise<ReadResult>;
35
36
  write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
37
+ writeAutocommit?(sql: string, params: SqlParameters): Promise<WriteResult>;
36
38
  writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
37
39
  signature(): Promise<string>;
38
40
  inspect(kind: string, table?: string): Promise<unknown>;
@@ -51,6 +51,7 @@ export async function createAdapter(connection, context, input) {
51
51
  throw new StateQLError("UNSUPPORTED_DRIVER", "Redis uses the native Redis adapter.");
52
52
  }
53
53
  }
54
+ const SQLITE_AUTOCOMMIT_STATEMENTS = new Set(["vacuum", "analyze", "reindex"]);
54
55
  class SQLiteAdapter {
55
56
  source;
56
57
  readOnly;
@@ -101,7 +102,14 @@ class SQLiteAdapter {
101
102
  async write(sql, params, expectedRows) {
102
103
  return this.call("write", [sql, params, expectedRows], true, false);
103
104
  }
105
+ async writeAutocommit(sql, params) {
106
+ return this.call("writeAutocommit", [sql, params], true, false);
107
+ }
104
108
  async writeBatch(operations, isolation) {
109
+ const unsupported = operations.find((operation) => SQLITE_AUTOCOMMIT_STATEMENTS.has(operation.statement_type));
110
+ if (unsupported) {
111
+ throw new BatchWriteError(`SQLite transactions cannot include ${unsupported.statement_type.toUpperCase()} maintenance statements.`, false);
112
+ }
105
113
  return this.call("writeBatch", [operations, isolation], true, true);
106
114
  }
107
115
  async signature() {
@@ -218,6 +226,12 @@ export function normalizePostgresConnectionString(source) {
218
226
  return source;
219
227
  }
220
228
  }
229
+ const POSTGRES_AUTOCOMMIT_STATEMENTS = new Set([
230
+ "vacuum",
231
+ "analyze",
232
+ "reindex",
233
+ "cluster",
234
+ ]);
221
235
  class PostgresAdapter {
222
236
  readOnly;
223
237
  context;
@@ -294,9 +308,36 @@ class PostgresAdapter {
294
308
  throw new AdapterWriteError(errorText(error), true);
295
309
  }
296
310
  }
311
+ async writeAutocommit(sql, params) {
312
+ if (this.readOnly)
313
+ throw new Error("Connection is read-only.");
314
+ let values;
315
+ try {
316
+ values = postgresParams(params);
317
+ await this.connect();
318
+ }
319
+ catch (error) {
320
+ if (error instanceof AdapterExecutionError)
321
+ throw error;
322
+ throw new AdapterWriteError(errorText(error), false);
323
+ }
324
+ try {
325
+ const result = await this.query(sql, values, true, false);
326
+ return { affectedRows: result.rowCount ?? 0 };
327
+ }
328
+ catch (error) {
329
+ if (error instanceof AdapterExecutionError)
330
+ throw error;
331
+ throw new AdapterWriteError(errorText(error), true);
332
+ }
333
+ }
297
334
  async writeBatch(operations, isolation) {
298
335
  if (this.readOnly)
299
336
  throw new Error("Connection is read-only.");
337
+ const unsupported = operations.find((operation) => POSTGRES_AUTOCOMMIT_STATEMENTS.has(operation.statement_type));
338
+ if (unsupported) {
339
+ throw new BatchWriteError(`PostgreSQL transactions cannot include ${unsupported.statement_type.toUpperCase()} maintenance statements.`, false);
340
+ }
300
341
  await this.connect();
301
342
  const level = isolation.toUpperCase();
302
343
  if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
@@ -491,8 +532,8 @@ class PostgresAdapter {
491
532
  async setLocalDeadline() {
492
533
  await this.query(`SET LOCAL statement_timeout = ${remainingMilliseconds(this.context)}`, [], false);
493
534
  }
494
- async query(sql, params, outcomeUnknown) {
495
- throwIfStopped(this.context, outcomeUnknown);
535
+ async query(sql, params, outcomeUnknown, preDispatchOutcomeUnknown = outcomeUnknown) {
536
+ throwIfStopped(this.context, preDispatchOutcomeUnknown);
496
537
  try {
497
538
  return await withContext(this.client.query(sql, params), this.context, () => this.stop(), outcomeUnknown);
498
539
  }
@@ -557,6 +598,16 @@ class MySqlAdapter {
557
598
  throw error;
558
599
  }
559
600
  }
601
+ async readAutocommit(sql, params) {
602
+ const [result, fields] = await this.query(sql, mysqlParams(params), false, false);
603
+ return {
604
+ rows: toJsonSafe(mysqlRows(result)),
605
+ columns: fields.map((field) => ({
606
+ name: field.name,
607
+ type: mysqlFieldType(field),
608
+ })),
609
+ };
610
+ }
560
611
  async write(sql, params, expectedRows) {
561
612
  if (this.readOnly)
562
613
  throw new Error("Connection is read-only.");
@@ -596,6 +647,35 @@ class MySqlAdapter {
596
647
  throw new AdapterWriteError(errorText(error), true);
597
648
  }
598
649
  }
650
+ async writeAutocommit(sql, params) {
651
+ if (this.readOnly)
652
+ throw new AdapterWriteError("Connection is read-only.", false);
653
+ let values;
654
+ try {
655
+ values = mysqlParams(params);
656
+ await this.connect();
657
+ }
658
+ catch (error) {
659
+ if (error instanceof AdapterExecutionError)
660
+ throw error;
661
+ throw new AdapterWriteError(errorText(error), false);
662
+ }
663
+ try {
664
+ const [result] = await this.runQuery(sql, values, true, false);
665
+ const maintenanceError = mysqlMaintenanceError(result);
666
+ if (maintenanceError)
667
+ throw new AdapterWriteError(maintenanceError, false);
668
+ return {
669
+ affectedRows: Array.isArray(result) ? 0 : mysqlAffectedRows(result),
670
+ };
671
+ }
672
+ catch (error) {
673
+ if (error instanceof AdapterExecutionError || error instanceof AdapterWriteError) {
674
+ throw error;
675
+ }
676
+ throw new AdapterWriteError(errorText(error), true);
677
+ }
678
+ }
599
679
  async writeBatch(operations, isolation) {
600
680
  if (this.readOnly)
601
681
  throw new Error("Connection is read-only.");
@@ -868,6 +948,7 @@ const MYSQL_ISOLATION_LEVELS = new Set([
868
948
  const MYSQL_TRANSACTIONAL_STATEMENTS = new Set([
869
949
  "delete",
870
950
  "insert",
951
+ "upsert",
871
952
  "replace",
872
953
  "update",
873
954
  ]);
@@ -894,6 +975,18 @@ function mysqlRows(result) {
894
975
  }
895
976
  return result;
896
977
  }
978
+ function mysqlMaintenanceError(result) {
979
+ if (!Array.isArray(result))
980
+ return undefined;
981
+ for (const row of result) {
982
+ const typeEntry = Object.entries(row).find(([key]) => key.toLowerCase() === "msg_type");
983
+ if (String(typeEntry?.[1] ?? "").toLowerCase() !== "error")
984
+ continue;
985
+ const textEntry = Object.entries(row).find(([key]) => key.toLowerCase() === "msg_text");
986
+ return String(textEntry?.[1] ?? "MySQL maintenance failed.");
987
+ }
988
+ return undefined;
989
+ }
897
990
  function mysqlAffectedRows(result) {
898
991
  if (Array.isArray(result) || !("affectedRows" in result)) {
899
992
  throw new Error("MySQL statement did not return a write result.");
package/dist/src/sql.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AST } from "node-sql-parser";
2
2
  import type { Driver, SqlDriver } from "./types.js";
3
- export type StatementType = "select" | "insert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate";
3
+ export type StatementType = "select" | "insert" | "upsert" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate" | "values" | "show" | "explain" | "vacuum" | "analyze" | "reindex" | "optimize" | "check" | "cluster";
4
4
  export interface SqlAnalysis {
5
5
  ast: AST;
6
6
  normalized: string;
@@ -9,6 +9,11 @@ export interface SqlAnalysis {
9
9
  unboundedMutation: boolean;
10
10
  destructive: boolean;
11
11
  ordered: boolean;
12
+ wrapForLimit: boolean;
13
+ cacheable: boolean;
14
+ requiresAutocommit: boolean;
15
+ /** Analysis-only SQL with trailing trivia removed when limit wrapping needs it. */
16
+ limitSql?: string;
12
17
  }
13
18
  export declare function analyzeSql(sql: string, driver: SqlDriver): SqlAnalysis;
14
19
  /** @internal Compatibility for existing connection records during Mongo rollout. */