@fadhilp/stateql 0.12.0 → 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,20 @@ 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
+
219
233
  ### PostgreSQL diagnostics and maintenance
220
234
 
221
235
  Run PostgreSQL plans through `query`:
@@ -231,6 +245,15 @@ mutations are rejected. Diagnostics execute inside PostgreSQL `BEGIN READ ONLY`
231
245
  and are never reused from cache. `--cache require` therefore returns
232
246
  `CACHE_MISS` without executing the diagnostic.
233
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
+
234
257
  `VACUUM`, `ANALYZE`, `REINDEX`, and `CLUSTER` are PostgreSQL maintenance writes:
235
258
 
236
259
  ```bash
@@ -238,6 +261,13 @@ stql exec "VACUUM (ANALYZE) public.jobs" --allow-destructive
238
261
  stql plan "REINDEX TABLE public.jobs" --allow-destructive
239
262
  ```
240
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
+
241
271
  They require a read-write connection and `--allow-destructive`, reject StateQL
242
272
  parameters, and run as individually tracked autocommit operations. They cannot
243
273
  be staged in a StateQL transaction. A timeout or cancellation after dispatch is
@@ -247,6 +277,55 @@ remain unsupported—use `stql transaction` commands instead. See
247
277
  [`SQL_COMMAND_ROADMAP.md`](SQL_COMMAND_ROADMAP.md) for the exact implemented
248
278
  boundary and deferred command categories.
249
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
+
250
329
  ### Native MongoDB
251
330
 
252
331
  MongoDB commands use official Extended JSON (EJSON), so BSON values survive the
@@ -32,6 +32,7 @@ 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>;
36
37
  writeAutocommit?(sql: string, params: SqlParameters): Promise<WriteResult>;
37
38
  writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
@@ -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() {
@@ -590,6 +598,16 @@ class MySqlAdapter {
590
598
  throw error;
591
599
  }
592
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
+ }
593
611
  async write(sql, params, expectedRows) {
594
612
  if (this.readOnly)
595
613
  throw new Error("Connection is read-only.");
@@ -629,6 +647,35 @@ class MySqlAdapter {
629
647
  throw new AdapterWriteError(errorText(error), true);
630
648
  }
631
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
+ }
632
679
  async writeBatch(operations, isolation) {
633
680
  if (this.readOnly)
634
681
  throw new Error("Connection is read-only.");
@@ -901,6 +948,7 @@ const MYSQL_ISOLATION_LEVELS = new Set([
901
948
  const MYSQL_TRANSACTIONAL_STATEMENTS = new Set([
902
949
  "delete",
903
950
  "insert",
951
+ "upsert",
904
952
  "replace",
905
953
  "update",
906
954
  ]);
@@ -927,6 +975,18 @@ function mysqlRows(result) {
927
975
  }
928
976
  return result;
929
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
+ }
930
990
  function mysqlAffectedRows(result) {
931
991
  if (Array.isArray(result) || !("affectedRows" in result)) {
932
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" | "explain" | "vacuum" | "analyze" | "reindex" | "cluster";
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;
@@ -12,6 +12,8 @@ export interface SqlAnalysis {
12
12
  wrapForLimit: boolean;
13
13
  cacheable: boolean;
14
14
  requiresAutocommit: boolean;
15
+ /** Analysis-only SQL with trailing trivia removed when limit wrapping needs it. */
16
+ limitSql?: string;
15
17
  }
16
18
  export declare function analyzeSql(sql: string, driver: SqlDriver): SqlAnalysis;
17
19
  /** @internal Compatibility for existing connection records during Mongo rollout. */
package/dist/src/sql.js CHANGED
@@ -26,6 +26,14 @@ export function analyzeSql(sql, driver) {
26
26
  if (postgresCommand)
27
27
  return postgresCommand;
28
28
  }
29
+ else if (driver === "sqlite") {
30
+ const sqliteCommand = analyzeSqliteCommand(trimmed);
31
+ if (sqliteCommand)
32
+ return sqliteCommand;
33
+ }
34
+ else if (driver === "mysql") {
35
+ return analyzeMySqlSql(trimmed);
36
+ }
29
37
  return analyzeParsedSql(trimmed, driver);
30
38
  }
31
39
  catch (error) {
@@ -35,7 +43,7 @@ export function analyzeSql(sql, driver) {
35
43
  throw new StateQLError("INVALID_SQL", message);
36
44
  }
37
45
  }
38
- function analyzeParsedSql(sql, driver) {
46
+ function parseSqlStatement(sql, driver) {
39
47
  const database = driver === "postgres"
40
48
  ? "Postgresql"
41
49
  : driver === "mysql"
@@ -49,14 +57,6 @@ function analyzeParsedSql(sql, driver) {
49
57
  const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
50
58
  if (!ast)
51
59
  throw new StateQLError("INVALID_SQL", "SQL is empty.");
52
- const rawType = String(ast.type);
53
- if (!SUPPORTED_STATEMENTS.has(rawType)) {
54
- throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
55
- }
56
- const statementType = rawType;
57
- if (statementType === "select" && selectContainsWrite(ast)) {
58
- throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
59
- }
60
60
  const normalized = parserSql === sql
61
61
  ? parser
62
62
  .sqlify(ast, { database })
@@ -66,6 +66,24 @@ function analyzeParsedSql(sql, driver) {
66
66
  // Keep the exact ordering modifiers in cache and idempotency fingerprints.
67
67
  // The parser copy is analysis-only; adapters execute the original SQL.
68
68
  : sql.replace(/;\s*$/, "");
69
+ return { ast, normalized };
70
+ }
71
+ function analyzeParsedSql(sql, driver) {
72
+ return analyzeParsedStatement(parseSqlStatement(sql, driver), driver);
73
+ }
74
+ function analyzeParsedStatement({ ast, normalized }, driver) {
75
+ const rawType = String(ast.type);
76
+ if (!SUPPORTED_STATEMENTS.has(rawType)) {
77
+ throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
78
+ }
79
+ const rawStatementType = rawType;
80
+ const insert = rawStatementType === "insert"
81
+ ? analyzeInsert(ast, driver)
82
+ : undefined;
83
+ const statementType = insert?.statementType ?? rawStatementType;
84
+ if (statementType === "select" && containsUnexpectedWrite(ast)) {
85
+ throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
86
+ }
69
87
  const details = ast;
70
88
  const read = statementType === "select";
71
89
  const mutation = statementType === "update" ||
@@ -83,7 +101,8 @@ function analyzeParsedSql(sql, driver) {
83
101
  normalized,
84
102
  statementType,
85
103
  read,
86
- unboundedMutation: statementType === "truncate" || (mutation && !details.where),
104
+ unboundedMutation: insert?.unboundedMutation ??
105
+ (statementType === "truncate" || (mutation && !details.where)),
87
106
  destructive,
88
107
  ordered: read && Boolean(details.orderby),
89
108
  wrapForLimit: read,
@@ -91,11 +110,76 @@ function analyzeParsedSql(sql, driver) {
91
110
  requiresAutocommit: false,
92
111
  };
93
112
  }
113
+ function analyzeInsert(ast, driver) {
114
+ const details = ast;
115
+ const conflict = record(details.conflict);
116
+ const duplicate = record(details.on_duplicate_update);
117
+ const allowedWrites = new Set([ast]);
118
+ let upsert = false;
119
+ let updatesOnConflict = false;
120
+ if (conflict) {
121
+ if (driver !== "postgres")
122
+ invalidUpsertSyntax(driver);
123
+ const action = record(conflict.action);
124
+ const expression = record(action?.expr);
125
+ if (conflict.type !== "conflict" ||
126
+ conflict.keyword !== "on" ||
127
+ action?.keyword !== "do" ||
128
+ !expression) {
129
+ invalidUpsertSyntax(driver);
130
+ }
131
+ upsert = true;
132
+ if (expression.type === "update") {
133
+ if (!Array.isArray(expression.set) || expression.set.length === 0) {
134
+ invalidUpsertSyntax(driver);
135
+ }
136
+ allowedWrites.add(expression);
137
+ updatesOnConflict = true;
138
+ }
139
+ else if (expression.type !== "origin" ||
140
+ String(expression.value).toLowerCase() !== "nothing") {
141
+ invalidUpsertSyntax(driver);
142
+ }
143
+ }
144
+ if (duplicate) {
145
+ if (driver !== "mysql" ||
146
+ duplicate.keyword !== "on duplicate key update" ||
147
+ !Array.isArray(duplicate.set) ||
148
+ duplicate.set.length === 0) {
149
+ invalidUpsertSyntax(driver);
150
+ }
151
+ upsert = true;
152
+ updatesOnConflict = true;
153
+ }
154
+ if (containsUnexpectedWrite(ast, allowedWrites)) {
155
+ throw new StateQLError("INVALID_SQL", "INSERT statements cannot contain additional write statements or SELECT INTO.");
156
+ }
157
+ const source = record(details.values);
158
+ const assignmentSource = driver === "mysql" && !source &&
159
+ Array.isArray(details.set) && details.set.length > 0;
160
+ if (upsert && !assignmentSource &&
161
+ (!source || !["select", "values"].includes(String(source.type)))) {
162
+ invalidUpsertSyntax(driver);
163
+ }
164
+ return {
165
+ statementType: upsert ? "upsert" : "insert",
166
+ unboundedMutation: updatesOnConflict && source?.type === "select",
167
+ };
168
+ }
169
+ function invalidUpsertSyntax(driver) {
170
+ throw new StateQLError("INVALID_SQL", `Unsupported or invalid ${driver} upsert syntax.`);
171
+ }
172
+ function record(value) {
173
+ return value && typeof value === "object" && !Array.isArray(value)
174
+ ? value
175
+ : undefined;
176
+ }
94
177
  const EXPLAIN_INNER_STATEMENTS = new Set([
95
178
  "select",
96
179
  "insert",
97
180
  "update",
98
181
  "delete",
182
+ "upsert",
99
183
  ]);
100
184
  const EXPLAIN_BOOLEAN_OPTIONS = new Set([
101
185
  "ANALYZE",
@@ -111,15 +195,26 @@ const EXPLAIN_BOOLEAN_OPTIONS = new Set([
111
195
  ]);
112
196
  const EXPLAIN_FORMATS = new Set(["TEXT", "XML", "JSON", "YAML"]);
113
197
  const EXPLAIN_SERIALIZE = new Set(["NONE", "TEXT", "BINARY"]);
114
- const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF"]);
198
+ const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF", "1", "0"]);
199
+ const POSTGRES_SHOW_SETTINGS = new Set([
200
+ "default_transaction_isolation",
201
+ "server_version",
202
+ "server_version_num",
203
+ "transaction_isolation",
204
+ "transaction_read_only",
205
+ ]);
115
206
  function analyzePostgresCommand(sql) {
116
- const scanner = new PostgresPrefixScanner(sql);
207
+ const scanner = new SqlPrefixScanner(sql, "postgres");
117
208
  const command = scanner.readWord();
118
209
  if (!command)
119
210
  return undefined;
120
211
  switch (command.value) {
121
212
  case "EXPLAIN":
122
213
  return analyzePostgresExplain(sql, scanner);
214
+ case "SHOW":
215
+ return analyzePostgresShow(sql);
216
+ case "VALUES":
217
+ return analyzePostgresValues(sql);
123
218
  case "VACUUM":
124
219
  case "ANALYZE":
125
220
  case "REINDEX":
@@ -129,6 +224,179 @@ function analyzePostgresCommand(sql) {
129
224
  return undefined;
130
225
  }
131
226
  }
227
+ function analyzePostgresShow(sql) {
228
+ const parsed = parseSqlStatement(sql, "postgres");
229
+ const details = parsed.ast;
230
+ const variable = details.var;
231
+ if (details.type !== "show" ||
232
+ details.keyword !== "var" ||
233
+ variable?.type !== "var" ||
234
+ typeof variable.name !== "string" ||
235
+ (Array.isArray(variable.members) && variable.members.length > 0) ||
236
+ !POSTGRES_SHOW_SETTINGS.has(variable.name.toLowerCase())) {
237
+ invalidPostgresSyntax("SHOW");
238
+ }
239
+ return readDiagnostic(parsed, "show");
240
+ }
241
+ function analyzePostgresValues(sql) {
242
+ const statement = postgresStatementBody(sql);
243
+ const wrapped = analyzeParsedSql(`SELECT * FROM (${statement}) AS _stateql_values`, "postgres");
244
+ return {
245
+ ...wrapped,
246
+ normalized: statement,
247
+ statementType: "values",
248
+ ordered: false,
249
+ cacheable: false,
250
+ limitSql: statement,
251
+ };
252
+ }
253
+ const MYSQL_SHOW_KEYWORDS = new Set(["columns", "index", "indexes", "tables"]);
254
+ function analyzeMySqlSql(sql) {
255
+ const scanner = new MySqlPrefixScanner(sql);
256
+ scanner.validateComments();
257
+ const command = scanner.readWord();
258
+ if (command?.value === "ANALYZE" ||
259
+ command?.value === "OPTIMIZE" ||
260
+ command?.value === "CHECK") {
261
+ return analyzeMySqlMaintenance(sql, scanner, command.value);
262
+ }
263
+ const parsed = parseSqlStatement(sql, "mysql");
264
+ const details = parsed.ast;
265
+ if (details.type === "explain") {
266
+ const inner = details.expr;
267
+ if (!inner || String(inner.type) !== "select" || containsUnexpectedWrite(inner)) {
268
+ throw new StateQLError("INVALID_SQL", "MySQL EXPLAIN accepts read-only SELECT statements only.");
269
+ }
270
+ return readDiagnostic(parsed, "explain");
271
+ }
272
+ if (details.type === "show") {
273
+ const keyword = typeof details.keyword === "string"
274
+ ? details.keyword.toLowerCase()
275
+ : "";
276
+ const allowedKeys = keyword === "tables"
277
+ ? new Set(["type", "keyword"])
278
+ : new Set(["type", "keyword", "from"]);
279
+ const hasUnexpectedShape = Object.entries(details).some(([key, value]) => value !== undefined && value !== null && !allowedKeys.has(key));
280
+ const from = details.from;
281
+ if (!MYSQL_SHOW_KEYWORDS.has(keyword) ||
282
+ hasUnexpectedShape ||
283
+ (keyword !== "tables" && (!Array.isArray(from) || from.length !== 1))) {
284
+ throw new StateQLError("INVALID_SQL", `Unsupported MySQL SHOW form "${keyword || "unknown"}".`);
285
+ }
286
+ return readDiagnostic(parsed, "show");
287
+ }
288
+ return analyzeParsedStatement(parsed, "mysql");
289
+ }
290
+ function analyzeMySqlMaintenance(sql, scanner, command) {
291
+ if (scanner.readWord()?.value !== "TABLE")
292
+ invalidMySqlSyntax(command);
293
+ if (!scanner.readQualifiedIdentifier())
294
+ invalidMySqlSyntax(command);
295
+ if (scanner.consume(";")) {
296
+ if (scanner.triviaEnd() !== sql.length)
297
+ invalidMySqlSyntax(command);
298
+ }
299
+ else if (scanner.triviaEnd() !== sql.length) {
300
+ invalidMySqlSyntax(command);
301
+ }
302
+ const read = command === "CHECK";
303
+ return {
304
+ ast: { type: command.toLowerCase() },
305
+ normalized: sql.replace(/;\s*$/, ""),
306
+ statementType: command.toLowerCase(),
307
+ read,
308
+ unboundedMutation: false,
309
+ destructive: !read,
310
+ ordered: false,
311
+ wrapForLimit: false,
312
+ cacheable: false,
313
+ requiresAutocommit: true,
314
+ };
315
+ }
316
+ function invalidMySqlSyntax(command) {
317
+ throw new StateQLError("INVALID_SQL", `Unsupported or invalid MySQL ${command} TABLE syntax.`);
318
+ }
319
+ function analyzeSqliteCommand(sql) {
320
+ const scanner = new SqlPrefixScanner(sql, "sqlite");
321
+ const command = scanner.readWord();
322
+ switch (command?.value) {
323
+ case "EXPLAIN":
324
+ return analyzeSqliteExplain(sql, scanner);
325
+ case "VACUUM":
326
+ case "ANALYZE":
327
+ case "REINDEX":
328
+ return analyzeSqliteMaintenance(sql, scanner, command.value);
329
+ default:
330
+ return undefined;
331
+ }
332
+ }
333
+ function analyzeSqliteExplain(sql, scanner) {
334
+ if (scanner.readWord()?.value !== "QUERY" || scanner.readWord()?.value !== "PLAN") {
335
+ throw new StateQLError("INVALID_SQL", "Only SQLite EXPLAIN QUERY PLAN is supported.");
336
+ }
337
+ const innerSql = sql.slice(scanner.triviaEnd());
338
+ if (!innerSql) {
339
+ throw new StateQLError("INVALID_SQL", "Invalid SQLite EXPLAIN QUERY PLAN syntax.");
340
+ }
341
+ const inner = analyzeParsedSql(innerSql, "sqlite");
342
+ if (inner.statementType !== "select") {
343
+ throw new StateQLError("INVALID_SQL", "SQLite EXPLAIN QUERY PLAN accepts read-only SELECT statements only.");
344
+ }
345
+ return readDiagnostic({ ast: inner.ast, normalized: sql.replace(/;\s*$/, "") }, "explain");
346
+ }
347
+ function analyzeSqliteMaintenance(sql, scanner, command) {
348
+ if (command !== "VACUUM" && scanner.peek() !== ";" && scanner.peek() !== undefined) {
349
+ if (!readSqliteIdentifier(sql, scanner))
350
+ invalidSqliteSyntax(command);
351
+ }
352
+ if (scanner.consume(";")) {
353
+ if (scanner.triviaEnd() !== sql.length)
354
+ invalidSqliteSyntax(command);
355
+ }
356
+ else if (scanner.triviaEnd() !== sql.length) {
357
+ invalidSqliteSyntax(command);
358
+ }
359
+ return {
360
+ ast: { type: command.toLowerCase() },
361
+ normalized: sql.replace(/;\s*$/, ""),
362
+ statementType: command.toLowerCase(),
363
+ read: false,
364
+ unboundedMutation: false,
365
+ destructive: true,
366
+ ordered: false,
367
+ wrapForLimit: false,
368
+ cacheable: false,
369
+ requiresAutocommit: true,
370
+ };
371
+ }
372
+ function readSqliteIdentifier(sql, scanner) {
373
+ scanner.triviaEnd();
374
+ if (sql[scanner.position] === '"') {
375
+ const end = postgresQuotedIdentifierEnd(sql, scanner.position);
376
+ if (end === undefined)
377
+ return false;
378
+ scanner.position = end;
379
+ return true;
380
+ }
381
+ return Boolean(scanner.readWord(false));
382
+ }
383
+ function invalidSqliteSyntax(command) {
384
+ throw new StateQLError("INVALID_SQL", `Unsupported or invalid SQLite ${command} syntax.`);
385
+ }
386
+ function readDiagnostic({ ast, normalized }, statementType) {
387
+ return {
388
+ ast,
389
+ normalized,
390
+ statementType,
391
+ read: true,
392
+ unboundedMutation: false,
393
+ destructive: false,
394
+ ordered: false,
395
+ wrapForLimit: false,
396
+ cacheable: false,
397
+ requiresAutocommit: false,
398
+ };
399
+ }
132
400
  function analyzePostgresExplain(sql, scanner) {
133
401
  let analyze = false;
134
402
  const seen = new Set();
@@ -217,12 +485,15 @@ const VACUUM_OPTIONS = new Map([
217
485
  ["PARALLEL", "number"],
218
486
  ["SKIP_DATABASE_STATS", "boolean"],
219
487
  ["ONLY_DATABASE_STATS", "boolean"],
488
+ ["BUFFER_USAGE_LIMIT", "size"],
220
489
  ]);
221
490
  const ANALYZE_OPTIONS = new Map([
222
491
  ["VERBOSE", "boolean"],
223
492
  ["SKIP_LOCKED", "boolean"],
493
+ ["BUFFER_USAGE_LIMIT", "size"],
224
494
  ]);
225
495
  const REINDEX_OPTIONS = new Map([
496
+ ["CONCURRENTLY", "boolean"],
226
497
  ["VERBOSE", "boolean"],
227
498
  ["TABLESPACE", "identifier"],
228
499
  ]);
@@ -235,14 +506,14 @@ function analyzePostgresMaintenance(sql, command) {
235
506
  switch (command) {
236
507
  case "VACUUM":
237
508
  parser.options(VACUUM_OPTIONS, ["FULL", "FREEZE", "VERBOSE", "ANALYZE"]);
238
- parser.optionalTargets();
509
+ parser.optionalTargets(true);
239
510
  break;
240
511
  case "ANALYZE":
241
512
  parser.options(ANALYZE_OPTIONS, ["VERBOSE"]);
242
- parser.optionalTargets();
513
+ parser.optionalTargets(true);
243
514
  break;
244
515
  case "REINDEX": {
245
- parser.options(REINDEX_OPTIONS);
516
+ const options = parser.options(REINDEX_OPTIONS);
246
517
  const target = parser.expectOneOf([
247
518
  "INDEX",
248
519
  "TABLE",
@@ -250,12 +521,17 @@ function analyzePostgresMaintenance(sql, command) {
250
521
  "DATABASE",
251
522
  "SYSTEM",
252
523
  ]);
253
- if (target !== "SYSTEM")
254
- parser.consumeWord("CONCURRENTLY");
524
+ const postTargetConcurrent = parser.consumeWord("CONCURRENTLY");
525
+ if (postTargetConcurrent && options.has("CONCURRENTLY")) {
526
+ invalidPostgresSyntax(command);
527
+ }
255
528
  if (target === "INDEX" || target === "TABLE") {
256
529
  parser.qualifiedIdentifier();
257
530
  }
258
- else {
531
+ else if (target === "SCHEMA") {
532
+ parser.identifier();
533
+ }
534
+ else if (!parser.done()) {
259
535
  parser.identifier();
260
536
  }
261
537
  break;
@@ -284,7 +560,7 @@ function analyzePostgresMaintenance(sql, command) {
284
560
  };
285
561
  }
286
562
  function tokenizePostgresMaintenance(sql) {
287
- const scanner = new PostgresPrefixScanner(sql);
563
+ const scanner = new SqlPrefixScanner(sql, "postgres");
288
564
  const tokens = [];
289
565
  while (scanner.triviaEnd() < sql.length) {
290
566
  const character = sql[scanner.position];
@@ -308,6 +584,15 @@ function tokenizePostgresMaintenance(sql) {
308
584
  scanner.position = end;
309
585
  continue;
310
586
  }
587
+ if (character === "'") {
588
+ const end = postgresQuotedStringScanEnd(sql, scanner.position);
589
+ if (end <= scanner.position + 1 || sql[end - 1] !== "'") {
590
+ invalidPostgresSyntax("maintenance");
591
+ }
592
+ tokens.push({ kind: "string", value: sql.slice(scanner.position, end) });
593
+ scanner.position = end;
594
+ continue;
595
+ }
311
596
  if (/[0-9]/u.test(character)) {
312
597
  const start = scanner.position;
313
598
  scanner.position += 1;
@@ -316,7 +601,7 @@ function tokenizePostgresMaintenance(sql) {
316
601
  tokens.push({ kind: "number", value: sql.slice(start, scanner.position) });
317
602
  continue;
318
603
  }
319
- if (["(", ")", ",", "."].includes(character)) {
604
+ if (["(", ")", ",", ".", "*"].includes(character)) {
320
605
  tokens.push({ kind: "punctuation", value: character });
321
606
  scanner.position += 1;
322
607
  continue;
@@ -396,11 +681,15 @@ class UtilityParser {
396
681
  this.index += 1;
397
682
  }
398
683
  }
399
- optionalTargets() {
684
+ optionalTargets(allowOnlyAndStar = false) {
400
685
  if (this.done())
401
686
  return;
402
687
  while (true) {
688
+ if (allowOnlyAndStar)
689
+ this.consumeWord("ONLY");
403
690
  this.qualifiedIdentifier();
691
+ if (allowOnlyAndStar)
692
+ this.consumePunctuation("*");
404
693
  if (this.consumePunctuation("(")) {
405
694
  this.identifier();
406
695
  while (this.consumePunctuation(","))
@@ -429,7 +718,8 @@ class UtilityParser {
429
718
  if (!token)
430
719
  invalidPostgresSyntax(this.command);
431
720
  if (kind === "boolean") {
432
- if (token.kind !== "word" || !BOOLEAN_VALUES.has(token.value)) {
721
+ if ((token.kind !== "word" && token.kind !== "number") ||
722
+ !BOOLEAN_VALUES.has(token.value)) {
433
723
  invalidPostgresSyntax(this.command);
434
724
  }
435
725
  }
@@ -442,6 +732,10 @@ class UtilityParser {
442
732
  invalidPostgresSyntax(this.command);
443
733
  }
444
734
  }
735
+ else if (kind === "size") {
736
+ if (!validMaintenanceSize(token))
737
+ invalidPostgresSyntax(this.command);
738
+ }
445
739
  else if (token.kind !== "word" || !kind.has(token.value)) {
446
740
  invalidPostgresSyntax(this.command);
447
741
  }
@@ -455,12 +749,151 @@ class UtilityParser {
455
749
  return true;
456
750
  }
457
751
  }
458
- class PostgresPrefixScanner {
752
+ function validMaintenanceSize(token) {
753
+ let amountText;
754
+ let unit;
755
+ if (token.kind === "number") {
756
+ amountText = token.value;
757
+ unit = "KB";
758
+ }
759
+ else if (token.kind === "string") {
760
+ const match = token.value.match(/^'([0-9]+)(?:\s*(B|KB|MB|GB|TB))?'$/iu);
761
+ if (!match)
762
+ return false;
763
+ amountText = match[1];
764
+ unit = (match[2]?.toUpperCase() ?? "KB");
765
+ }
766
+ else {
767
+ return false;
768
+ }
769
+ const factors = {
770
+ B: 1n,
771
+ KB: 1024n,
772
+ MB: 1024n ** 2n,
773
+ GB: 1024n ** 3n,
774
+ TB: 1024n ** 4n,
775
+ };
776
+ const bytes = BigInt(amountText) * factors[unit];
777
+ return bytes === 0n ||
778
+ (bytes >= 128n * factors.KB && bytes <= 16n * factors.GB);
779
+ }
780
+ class MySqlPrefixScanner {
459
781
  sql;
460
782
  position = 0;
461
783
  constructor(sql) {
462
784
  this.sql = sql;
463
785
  }
786
+ validateComments() {
787
+ if (!this.sql.includes("/*!"))
788
+ return;
789
+ // sql_mode is unknown: cover string escapes, ANSI_QUOTES, and
790
+ // NO_BACKSLASH_ESCAPES. Reject mode-dependent executable comments.
791
+ for (const [singleEscapes, doubleEscapes] of [
792
+ [true, true], [true, false], [false, false],
793
+ ]) {
794
+ this.position = 0;
795
+ while (this.triviaEnd() < this.sql.length) {
796
+ const quote = this.sql[this.position];
797
+ if (quote === "'" || quote === '"' || quote === "`") {
798
+ const escaped = quote === "'" ? singleEscapes : quote === '"' && doubleEscapes;
799
+ if (!this.readQuoted(quote, escaped)) {
800
+ throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL value.");
801
+ }
802
+ }
803
+ else {
804
+ this.position += 1;
805
+ }
806
+ }
807
+ }
808
+ this.position = 0;
809
+ }
810
+ triviaEnd() {
811
+ while (this.position < this.sql.length) {
812
+ if (/\s/u.test(this.sql[this.position])) {
813
+ this.position += 1;
814
+ }
815
+ else if (this.sql[this.position] === "#") {
816
+ this.position = lineCommentEnd(this.sql, this.position + 1);
817
+ }
818
+ else if (this.sql.startsWith("--", this.position) &&
819
+ (this.sql[this.position + 2] === undefined || /[\x00-\x20]/u.test(this.sql[this.position + 2]))) {
820
+ this.position = lineCommentEnd(this.sql, this.position + 2);
821
+ }
822
+ else if (this.sql.startsWith("/*", this.position)) {
823
+ if (this.sql[this.position + 2] === "!") {
824
+ throw new StateQLError("INVALID_SQL", "MySQL executable comments are not supported.");
825
+ }
826
+ const end = nonNestedBlockCommentEnd(this.sql, this.position + 2);
827
+ if (end === undefined) {
828
+ throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
829
+ }
830
+ this.position = end;
831
+ }
832
+ else {
833
+ break;
834
+ }
835
+ }
836
+ return this.position;
837
+ }
838
+ readWord(skipTrivia = true) {
839
+ if (skipTrivia)
840
+ this.triviaEnd();
841
+ const start = this.position;
842
+ if (!identifierStart(this.sql[start]))
843
+ return undefined;
844
+ this.position += 1;
845
+ while (identifierPart(this.sql[this.position]))
846
+ this.position += 1;
847
+ return { value: this.sql.slice(start, this.position).toUpperCase() };
848
+ }
849
+ readQualifiedIdentifier() {
850
+ if (!this.readIdentifier())
851
+ return false;
852
+ if (!this.consume("."))
853
+ return true;
854
+ return this.readIdentifier();
855
+ }
856
+ consume(value) {
857
+ this.triviaEnd();
858
+ if (!this.sql.startsWith(value, this.position))
859
+ return false;
860
+ this.position += value.length;
861
+ return true;
862
+ }
863
+ readIdentifier() {
864
+ this.triviaEnd();
865
+ if (this.sql[this.position] !== "`")
866
+ return Boolean(this.readWord(false));
867
+ return this.readQuoted("`", false);
868
+ }
869
+ readQuoted(quote, backslashEscapes) {
870
+ let index = this.position + 1;
871
+ while (index < this.sql.length) {
872
+ if (backslashEscapes && this.sql[index] === "\\") {
873
+ index += 2;
874
+ }
875
+ else if (this.sql[index] !== quote) {
876
+ index += 1;
877
+ }
878
+ else if (this.sql[index + 1] === quote) {
879
+ index += 2;
880
+ }
881
+ else {
882
+ this.position = index + 1;
883
+ return true;
884
+ }
885
+ }
886
+ return false;
887
+ }
888
+ }
889
+ class SqlPrefixScanner {
890
+ sql;
891
+ dialect;
892
+ position = 0;
893
+ constructor(sql, dialect) {
894
+ this.sql = sql;
895
+ this.dialect = dialect;
896
+ }
464
897
  triviaEnd() {
465
898
  while (this.position < this.sql.length) {
466
899
  if (/\s/u.test(this.sql[this.position])) {
@@ -470,7 +903,9 @@ class PostgresPrefixScanner {
470
903
  this.position = lineCommentEnd(this.sql, this.position + 2);
471
904
  }
472
905
  else if (this.sql.startsWith("/*", this.position)) {
473
- const end = postgresBlockCommentEnd(this.sql, this.position + 2);
906
+ const end = this.dialect === "postgres"
907
+ ? postgresBlockCommentEnd(this.sql, this.position + 2)
908
+ : nonNestedBlockCommentEnd(this.sql, this.position + 2);
474
909
  if (end === undefined) {
475
910
  throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
476
911
  }
@@ -515,6 +950,61 @@ class PostgresPrefixScanner {
515
950
  return true;
516
951
  }
517
952
  }
953
+ function postgresStatementBody(sql) {
954
+ let index = 0;
955
+ let lastTokenStart = 0;
956
+ let lastTokenEnd = 0;
957
+ while (index < sql.length) {
958
+ if (/\s/u.test(sql[index])) {
959
+ index += 1;
960
+ continue;
961
+ }
962
+ if (sql.startsWith("--", index)) {
963
+ index = lineCommentEnd(sql, index + 2);
964
+ continue;
965
+ }
966
+ if (sql.startsWith("/*", index)) {
967
+ const end = postgresBlockCommentEnd(sql, index + 2);
968
+ if (end === undefined) {
969
+ throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
970
+ }
971
+ index = end;
972
+ continue;
973
+ }
974
+ const start = index;
975
+ const character = sql[index];
976
+ if (character === "'") {
977
+ index = postgresQuotedStringScanEnd(sql, index);
978
+ }
979
+ else if (character === '"') {
980
+ const end = postgresQuotedIdentifierEnd(sql, index);
981
+ if (end === undefined) {
982
+ throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL identifier.");
983
+ }
984
+ index = end;
985
+ }
986
+ else if (character === "$") {
987
+ const delimiter = sql.slice(index).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)?.[0];
988
+ if (delimiter) {
989
+ const end = sql.indexOf(delimiter, index + delimiter.length);
990
+ if (end < 0) {
991
+ throw new StateQLError("INVALID_SQL", "Unterminated dollar-quoted SQL value.");
992
+ }
993
+ index = end + delimiter.length;
994
+ }
995
+ else {
996
+ index += 1;
997
+ }
998
+ }
999
+ else {
1000
+ index += 1;
1001
+ }
1002
+ lastTokenStart = start;
1003
+ lastTokenEnd = index;
1004
+ }
1005
+ const end = sql[lastTokenStart] === ";" ? lastTokenStart : lastTokenEnd;
1006
+ return sql.slice(0, end).trim();
1007
+ }
518
1008
  function invalidPostgresSyntax(command) {
519
1009
  throw new StateQLError("INVALID_SQL", `Unsupported or invalid PostgreSQL ${command} syntax.`);
520
1010
  }
@@ -551,6 +1041,10 @@ function postgresQuotedStringScanEnd(sql, start) {
551
1041
  }
552
1042
  return index;
553
1043
  }
1044
+ function nonNestedBlockCommentEnd(sql, start) {
1045
+ const end = sql.indexOf("*/", start);
1046
+ return end < 0 ? undefined : end + 2;
1047
+ }
554
1048
  function postgresBlockCommentEnd(sql, start) {
555
1049
  let depth = 1;
556
1050
  let index = start;
@@ -714,7 +1208,7 @@ function identifierStart(value) {
714
1208
  function identifierPart(value) {
715
1209
  return value !== undefined && /[A-Za-z0-9_$\u0080-\uFFFF]/u.test(value);
716
1210
  }
717
- function selectContainsWrite(ast) {
1211
+ function containsUnexpectedWrite(ast, allowedWrites = new Set()) {
718
1212
  const visited = new Set();
719
1213
  const writeTypes = new Set([
720
1214
  "insert",
@@ -736,7 +1230,7 @@ function selectContainsWrite(ast) {
736
1230
  return value.some(visit);
737
1231
  const details = value;
738
1232
  const type = typeof details.type === "string" ? details.type : undefined;
739
- if (type && writeTypes.has(type))
1233
+ if (type && writeTypes.has(type) && !allowedWrites.has(value))
740
1234
  return true;
741
1235
  if (type === "select") {
742
1236
  const into = details.into;
@@ -83,6 +83,22 @@ function execute(request) {
83
83
  }
84
84
  }
85
85
  }
86
+ case "writeAutocommit": {
87
+ if (readOnly)
88
+ throw new SQLiteBatchError("Connection is read-only.", false);
89
+ const [sql, params] = request.args;
90
+ if ((Array.isArray(params) && params.length > 0) ||
91
+ (!Array.isArray(params) && Object.keys(params).length > 0)) {
92
+ throw new SQLiteBatchError("SQLite maintenance statements do not accept parameters.", false);
93
+ }
94
+ try {
95
+ database.exec(sql);
96
+ return { affectedRows: 0 };
97
+ }
98
+ catch (error) {
99
+ throw new SQLiteBatchError(errorText(error), true);
100
+ }
101
+ }
86
102
  case "writeBatch": {
87
103
  if (readOnly)
88
104
  throw new Error("Connection is read-only.");
@@ -680,6 +680,9 @@ export class StateQL {
680
680
  });
681
681
  }
682
682
  const parameters = options.params ?? [];
683
+ if (analysis.requiresAutocommit && sqlParametersLength(parameters) > 0) {
684
+ throw new StateQLError("INVALID_SQL", "Autocommit diagnostic statements do not accept StateQL parameters.");
685
+ }
683
686
  const context = this.executionContext(options);
684
687
  const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
685
688
  const adapter = await this.openAdapter(connection, context, adapterSource);
@@ -716,9 +719,15 @@ export class StateQL {
716
719
  suggestedAction: "Run with --cache auto or --cache bypass.",
717
720
  });
718
721
  }
719
- const result = await adapter.read(analysis.wrapForLimit
720
- ? boundedReadSql(sql, this.maxResultRows + 1)
721
- : sql, parameters);
722
+ const executionSql = analysis.wrapForLimit
723
+ ? boundedReadSql(analysis.limitSql ?? sql, this.maxResultRows + 1)
724
+ : sql;
725
+ if (analysis.requiresAutocommit && !adapter.readAutocommit) {
726
+ throw new StateQLError("UNSUPPORTED_DRIVER", `${analysis.statementType.toUpperCase()} requires adapter autocommit reads.`);
727
+ }
728
+ const result = analysis.requiresAutocommit
729
+ ? await adapter.readAutocommit(executionSql, parameters)
730
+ : await adapter.read(executionSql, parameters);
722
731
  if (result.rows.length > this.maxResultRows) {
723
732
  throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
724
733
  }
@@ -2211,7 +2220,7 @@ export class StateQL {
2211
2220
  const parameters = options.params ?? [];
2212
2221
  if (analysis.requiresAutocommit &&
2213
2222
  (options.expectedRows !== undefined || sqlParametersLength(parameters) > 0)) {
2214
- throw new StateQLError("INVALID_SQL", "PostgreSQL maintenance statements do not accept StateQL parameters or row-count preconditions.");
2223
+ throw new StateQLError("INVALID_SQL", "Autocommit maintenance statements do not accept StateQL parameters or row-count preconditions.");
2215
2224
  }
2216
2225
  const transactionId = session.active_transaction_id ?? undefined;
2217
2226
  if (analysis.requiresAutocommit && transactionId) {
@@ -2322,7 +2331,7 @@ export class StateQL {
2322
2331
  }
2323
2332
  try {
2324
2333
  if (analysis.requiresAutocommit && !adapter.writeAutocommit) {
2325
- throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires PostgreSQL autocommit execution.`, false);
2334
+ throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires adapter autocommit execution.`, false);
2326
2335
  }
2327
2336
  const write = analysis.requiresAutocommit
2328
2337
  ? await adapter.writeAutocommit(sql, parameters)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",