@fadhilp/stateql 0.11.1 → 0.12.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,37 @@ 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
+ ### PostgreSQL diagnostics and maintenance
220
+
221
+ Run PostgreSQL plans through `query`:
222
+
223
+ ```bash
224
+ stql query "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM jobs WHERE id = 42"
225
+ ```
226
+
227
+ Plain `EXPLAIN` may plan a structurally validated `SELECT`, `INSERT`, `UPDATE`,
228
+ or `DELETE`. Because `EXPLAIN ANALYZE` executes its inner statement, StateQL
229
+ accepts only a validated read-only `SELECT`; `SELECT INTO`, writing CTEs, and
230
+ mutations are rejected. Diagnostics execute inside PostgreSQL `BEGIN READ ONLY`
231
+ and are never reused from cache. `--cache require` therefore returns
232
+ `CACHE_MISS` without executing the diagnostic.
233
+
234
+ `VACUUM`, `ANALYZE`, `REINDEX`, and `CLUSTER` are PostgreSQL maintenance writes:
235
+
236
+ ```bash
237
+ stql exec "VACUUM (ANALYZE) public.jobs" --allow-destructive
238
+ stql plan "REINDEX TABLE public.jobs" --allow-destructive
239
+ ```
240
+
241
+ They require a read-write connection and `--allow-destructive`, reject StateQL
242
+ parameters, and run as individually tracked autocommit operations. They cannot
243
+ be staged in a StateQL transaction. A timeout or cancellation after dispatch is
244
+ reported as `OUTCOME_UNKNOWN`; inspect database state before replaying it. Raw
245
+ `BEGIN`, `COMMIT`, `ROLLBACK`, savepoint, and other transaction-control SQL
246
+ remain unsupported—use `stql transaction` commands instead. See
247
+ [`SQL_COMMAND_ROADMAP.md`](SQL_COMMAND_ROADMAP.md) for the exact implemented
248
+ boundary and deferred command categories.
249
+
219
250
  ### Native MongoDB
220
251
 
221
252
  MongoDB commands use official Extended JSON (EJSON), so BSON values survive the
@@ -33,6 +33,7 @@ export interface Adapter {
33
33
  ping(): Promise<void>;
34
34
  read(sql: string, params: SqlParameters): Promise<ReadResult>;
35
35
  write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
36
+ writeAutocommit?(sql: string, params: SqlParameters): Promise<WriteResult>;
36
37
  writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
37
38
  signature(): Promise<string>;
38
39
  inspect(kind: string, table?: string): Promise<unknown>;
@@ -218,6 +218,12 @@ export function normalizePostgresConnectionString(source) {
218
218
  return source;
219
219
  }
220
220
  }
221
+ const POSTGRES_AUTOCOMMIT_STATEMENTS = new Set([
222
+ "vacuum",
223
+ "analyze",
224
+ "reindex",
225
+ "cluster",
226
+ ]);
221
227
  class PostgresAdapter {
222
228
  readOnly;
223
229
  context;
@@ -294,9 +300,36 @@ class PostgresAdapter {
294
300
  throw new AdapterWriteError(errorText(error), true);
295
301
  }
296
302
  }
303
+ async writeAutocommit(sql, params) {
304
+ if (this.readOnly)
305
+ throw new Error("Connection is read-only.");
306
+ let values;
307
+ try {
308
+ values = postgresParams(params);
309
+ await this.connect();
310
+ }
311
+ catch (error) {
312
+ if (error instanceof AdapterExecutionError)
313
+ throw error;
314
+ throw new AdapterWriteError(errorText(error), false);
315
+ }
316
+ try {
317
+ const result = await this.query(sql, values, true, false);
318
+ return { affectedRows: result.rowCount ?? 0 };
319
+ }
320
+ catch (error) {
321
+ if (error instanceof AdapterExecutionError)
322
+ throw error;
323
+ throw new AdapterWriteError(errorText(error), true);
324
+ }
325
+ }
297
326
  async writeBatch(operations, isolation) {
298
327
  if (this.readOnly)
299
328
  throw new Error("Connection is read-only.");
329
+ const unsupported = operations.find((operation) => POSTGRES_AUTOCOMMIT_STATEMENTS.has(operation.statement_type));
330
+ if (unsupported) {
331
+ throw new BatchWriteError(`PostgreSQL transactions cannot include ${unsupported.statement_type.toUpperCase()} maintenance statements.`, false);
332
+ }
300
333
  await this.connect();
301
334
  const level = isolation.toUpperCase();
302
335
  if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
@@ -491,8 +524,8 @@ class PostgresAdapter {
491
524
  async setLocalDeadline() {
492
525
  await this.query(`SET LOCAL statement_timeout = ${remainingMilliseconds(this.context)}`, [], false);
493
526
  }
494
- async query(sql, params, outcomeUnknown) {
495
- throwIfStopped(this.context, outcomeUnknown);
527
+ async query(sql, params, outcomeUnknown, preDispatchOutcomeUnknown = outcomeUnknown) {
528
+ throwIfStopped(this.context, preDispatchOutcomeUnknown);
496
529
  try {
497
530
  return await withContext(this.client.query(sql, params), this.context, () => this.stop(), outcomeUnknown);
498
531
  }
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" | "replace" | "update" | "delete" | "create" | "alter" | "drop" | "truncate" | "explain" | "vacuum" | "analyze" | "reindex" | "cluster";
4
4
  export interface SqlAnalysis {
5
5
  ast: AST;
6
6
  normalized: string;
@@ -9,6 +9,9 @@ export interface SqlAnalysis {
9
9
  unboundedMutation: boolean;
10
10
  destructive: boolean;
11
11
  ordered: boolean;
12
+ wrapForLimit: boolean;
13
+ cacheable: boolean;
14
+ requiresAutocommit: boolean;
12
15
  }
13
16
  export declare function analyzeSql(sql: string, driver: SqlDriver): SqlAnalysis;
14
17
  /** @internal Compatibility for existing connection records during Mongo rollout. */
package/dist/src/sql.js CHANGED
@@ -14,68 +14,19 @@ const SUPPORTED_STATEMENTS = new Set([
14
14
  "truncate",
15
15
  ]);
16
16
  export function analyzeSql(sql, driver) {
17
- if (driver === "mongodb") {
18
- throw new StateQLError("INVALID_SQL", "SQL is not supported for MongoDB connections.");
17
+ if (driver === "mongodb" || driver === "redis") {
18
+ throw new StateQLError("INVALID_SQL", `SQL is not supported for ${driver} connections.`);
19
19
  }
20
20
  const trimmed = sql.trim();
21
21
  if (!trimmed)
22
22
  throw new StateQLError("INVALID_SQL", "SQL is empty.");
23
23
  try {
24
- const database = driver === "postgres"
25
- ? "Postgresql"
26
- : driver === "mysql"
27
- ? "MySQL"
28
- : "Sqlite";
29
- const parserSql = driver === "postgres"
30
- ? postgresParserSql(trimmed)
31
- : trimmed;
32
- const parsed = parser.astify(parserSql, { database });
33
- if (Array.isArray(parsed)) {
34
- if (parsed.length !== 1) {
35
- throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
36
- }
24
+ if (driver === "postgres") {
25
+ const postgresCommand = analyzePostgresCommand(trimmed);
26
+ if (postgresCommand)
27
+ return postgresCommand;
37
28
  }
38
- const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
39
- if (!ast)
40
- throw new StateQLError("INVALID_SQL", "SQL is empty.");
41
- const rawType = String(ast.type);
42
- if (!SUPPORTED_STATEMENTS.has(rawType)) {
43
- throw new StateQLError("INVALID_SQL", `Unsupported SQL statement type "${rawType}".`);
44
- }
45
- const statementType = rawType;
46
- if (statementType === "select" && selectContainsWrite(ast)) {
47
- throw new StateQLError("INVALID_SQL", "Read statements cannot contain writes or SELECT INTO.");
48
- }
49
- const normalized = parserSql === trimmed
50
- ? parser
51
- .sqlify(ast, { database })
52
- .replace(/;\s*$/, "")
53
- .replace(/\s+/g, " ")
54
- .trim()
55
- // Keep the exact ordering modifiers in cache and idempotency fingerprints.
56
- // The parser copy is analysis-only; adapters execute the original SQL.
57
- : trimmed.replace(/;\s*$/, "");
58
- const details = ast;
59
- const read = statementType === "select";
60
- const mutation = statementType === "update" ||
61
- statementType === "delete" ||
62
- statementType === "truncate";
63
- const destructive = statementType === "drop" ||
64
- statementType === "alter" ||
65
- statementType === "delete" ||
66
- statementType === "replace" ||
67
- statementType === "truncate" ||
68
- (driver === "sqlite" &&
69
- /^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
70
- return {
71
- ast,
72
- normalized,
73
- statementType,
74
- read,
75
- unboundedMutation: statementType === "truncate" || (mutation && !details.where),
76
- destructive,
77
- ordered: read && Boolean(details.orderby),
78
- };
29
+ return analyzeParsedSql(trimmed, driver);
79
30
  }
80
31
  catch (error) {
81
32
  if (error instanceof StateQLError)
@@ -84,6 +35,542 @@ export function analyzeSql(sql, driver) {
84
35
  throw new StateQLError("INVALID_SQL", message);
85
36
  }
86
37
  }
38
+ function analyzeParsedSql(sql, driver) {
39
+ const database = driver === "postgres"
40
+ ? "Postgresql"
41
+ : driver === "mysql"
42
+ ? "MySQL"
43
+ : "Sqlite";
44
+ const parserSql = driver === "postgres" ? postgresParserSql(sql) : sql;
45
+ const parsed = parser.astify(parserSql, { database });
46
+ if (Array.isArray(parsed) && parsed.length !== 1) {
47
+ throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
48
+ }
49
+ const ast = (Array.isArray(parsed) ? parsed[0] : parsed);
50
+ if (!ast)
51
+ 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
+ const normalized = parserSql === sql
61
+ ? parser
62
+ .sqlify(ast, { database })
63
+ .replace(/;\s*$/, "")
64
+ .replace(/\s+/g, " ")
65
+ .trim()
66
+ // Keep the exact ordering modifiers in cache and idempotency fingerprints.
67
+ // The parser copy is analysis-only; adapters execute the original SQL.
68
+ : sql.replace(/;\s*$/, "");
69
+ const details = ast;
70
+ const read = statementType === "select";
71
+ const mutation = statementType === "update" ||
72
+ statementType === "delete" ||
73
+ statementType === "truncate";
74
+ const destructive = statementType === "drop" ||
75
+ statementType === "alter" ||
76
+ statementType === "delete" ||
77
+ statementType === "replace" ||
78
+ statementType === "truncate" ||
79
+ (driver === "sqlite" &&
80
+ /^(?:INSERT|UPDATE) OR REPLACE\b/i.test(normalized));
81
+ return {
82
+ ast,
83
+ normalized,
84
+ statementType,
85
+ read,
86
+ unboundedMutation: statementType === "truncate" || (mutation && !details.where),
87
+ destructive,
88
+ ordered: read && Boolean(details.orderby),
89
+ wrapForLimit: read,
90
+ cacheable: true,
91
+ requiresAutocommit: false,
92
+ };
93
+ }
94
+ const EXPLAIN_INNER_STATEMENTS = new Set([
95
+ "select",
96
+ "insert",
97
+ "update",
98
+ "delete",
99
+ ]);
100
+ const EXPLAIN_BOOLEAN_OPTIONS = new Set([
101
+ "ANALYZE",
102
+ "VERBOSE",
103
+ "COSTS",
104
+ "SETTINGS",
105
+ "GENERIC_PLAN",
106
+ "BUFFERS",
107
+ "WAL",
108
+ "TIMING",
109
+ "SUMMARY",
110
+ "MEMORY",
111
+ ]);
112
+ const EXPLAIN_FORMATS = new Set(["TEXT", "XML", "JSON", "YAML"]);
113
+ const EXPLAIN_SERIALIZE = new Set(["NONE", "TEXT", "BINARY"]);
114
+ const BOOLEAN_VALUES = new Set(["TRUE", "FALSE", "ON", "OFF"]);
115
+ function analyzePostgresCommand(sql) {
116
+ const scanner = new PostgresPrefixScanner(sql);
117
+ const command = scanner.readWord();
118
+ if (!command)
119
+ return undefined;
120
+ switch (command.value) {
121
+ case "EXPLAIN":
122
+ return analyzePostgresExplain(sql, scanner);
123
+ case "VACUUM":
124
+ case "ANALYZE":
125
+ case "REINDEX":
126
+ case "CLUSTER":
127
+ return analyzePostgresMaintenance(sql, command.value);
128
+ default:
129
+ return undefined;
130
+ }
131
+ }
132
+ function analyzePostgresExplain(sql, scanner) {
133
+ let analyze = false;
134
+ const seen = new Set();
135
+ if (scanner.consume("(")) {
136
+ while (true) {
137
+ const option = scanner.readWord();
138
+ if (!option || seen.has(option.value))
139
+ invalidPostgresSyntax("EXPLAIN");
140
+ seen.add(option.value);
141
+ const next = scanner.peek();
142
+ let value;
143
+ if (next !== "," && next !== ")") {
144
+ value = scanner.readWord()?.value;
145
+ if (!value)
146
+ invalidPostgresSyntax("EXPLAIN");
147
+ }
148
+ validateExplainOption(option.value, value);
149
+ if (option.value === "ANALYZE") {
150
+ analyze = value === undefined || value === "TRUE" || value === "ON";
151
+ }
152
+ if (scanner.consume(")"))
153
+ break;
154
+ if (!scanner.consume(","))
155
+ invalidPostgresSyntax("EXPLAIN");
156
+ }
157
+ }
158
+ else {
159
+ while (true) {
160
+ const option = scanner.peekWord();
161
+ if (option !== "ANALYZE" && option !== "VERBOSE")
162
+ break;
163
+ scanner.readWord();
164
+ if (seen.has(option))
165
+ invalidPostgresSyntax("EXPLAIN");
166
+ seen.add(option);
167
+ if (option === "ANALYZE")
168
+ analyze = true;
169
+ }
170
+ }
171
+ const innerSql = sql.slice(scanner.triviaEnd());
172
+ if (!innerSql)
173
+ invalidPostgresSyntax("EXPLAIN");
174
+ const inner = analyzeParsedSql(innerSql, "postgres");
175
+ if (!EXPLAIN_INNER_STATEMENTS.has(inner.statementType)) {
176
+ throw new StateQLError("INVALID_SQL", `EXPLAIN does not support ${inner.statementType.toUpperCase()} statements.`);
177
+ }
178
+ if (analyze && inner.statementType !== "select") {
179
+ throw new StateQLError("INVALID_SQL", "EXPLAIN ANALYZE accepts read-only SELECT statements only.");
180
+ }
181
+ return {
182
+ ast: inner.ast,
183
+ normalized: sql.replace(/;\s*$/, ""),
184
+ statementType: "explain",
185
+ read: true,
186
+ unboundedMutation: false,
187
+ destructive: false,
188
+ ordered: false,
189
+ wrapForLimit: false,
190
+ cacheable: false,
191
+ requiresAutocommit: false,
192
+ };
193
+ }
194
+ function validateExplainOption(option, value) {
195
+ if (EXPLAIN_BOOLEAN_OPTIONS.has(option)) {
196
+ if (value !== undefined && !BOOLEAN_VALUES.has(value))
197
+ invalidPostgresSyntax("EXPLAIN");
198
+ return;
199
+ }
200
+ if (option === "FORMAT" && value && EXPLAIN_FORMATS.has(value))
201
+ return;
202
+ if (option === "SERIALIZE" && value && EXPLAIN_SERIALIZE.has(value))
203
+ return;
204
+ invalidPostgresSyntax("EXPLAIN");
205
+ }
206
+ const VACUUM_OPTIONS = new Map([
207
+ ["FULL", "boolean"],
208
+ ["FREEZE", "boolean"],
209
+ ["VERBOSE", "boolean"],
210
+ ["ANALYZE", "boolean"],
211
+ ["DISABLE_PAGE_SKIPPING", "boolean"],
212
+ ["SKIP_LOCKED", "boolean"],
213
+ ["INDEX_CLEANUP", new Set(["AUTO", "ON", "OFF"])],
214
+ ["PROCESS_MAIN", "boolean"],
215
+ ["PROCESS_TOAST", "boolean"],
216
+ ["TRUNCATE", "boolean"],
217
+ ["PARALLEL", "number"],
218
+ ["SKIP_DATABASE_STATS", "boolean"],
219
+ ["ONLY_DATABASE_STATS", "boolean"],
220
+ ]);
221
+ const ANALYZE_OPTIONS = new Map([
222
+ ["VERBOSE", "boolean"],
223
+ ["SKIP_LOCKED", "boolean"],
224
+ ]);
225
+ const REINDEX_OPTIONS = new Map([
226
+ ["VERBOSE", "boolean"],
227
+ ["TABLESPACE", "identifier"],
228
+ ]);
229
+ const CLUSTER_OPTIONS = new Map([
230
+ ["VERBOSE", "boolean"],
231
+ ]);
232
+ function analyzePostgresMaintenance(sql, command) {
233
+ const parser = new UtilityParser(tokenizePostgresMaintenance(sql), command);
234
+ parser.expectWord(command);
235
+ switch (command) {
236
+ case "VACUUM":
237
+ parser.options(VACUUM_OPTIONS, ["FULL", "FREEZE", "VERBOSE", "ANALYZE"]);
238
+ parser.optionalTargets();
239
+ break;
240
+ case "ANALYZE":
241
+ parser.options(ANALYZE_OPTIONS, ["VERBOSE"]);
242
+ parser.optionalTargets();
243
+ break;
244
+ case "REINDEX": {
245
+ parser.options(REINDEX_OPTIONS);
246
+ const target = parser.expectOneOf([
247
+ "INDEX",
248
+ "TABLE",
249
+ "SCHEMA",
250
+ "DATABASE",
251
+ "SYSTEM",
252
+ ]);
253
+ if (target !== "SYSTEM")
254
+ parser.consumeWord("CONCURRENTLY");
255
+ if (target === "INDEX" || target === "TABLE") {
256
+ parser.qualifiedIdentifier();
257
+ }
258
+ else {
259
+ parser.identifier();
260
+ }
261
+ break;
262
+ }
263
+ case "CLUSTER":
264
+ parser.options(CLUSTER_OPTIONS, ["VERBOSE"]);
265
+ if (!parser.done()) {
266
+ parser.qualifiedIdentifier();
267
+ if (parser.consumeWord("USING"))
268
+ parser.identifier();
269
+ }
270
+ break;
271
+ }
272
+ parser.expectDone();
273
+ return {
274
+ ast: { type: command.toLowerCase() },
275
+ normalized: sql.replace(/;\s*$/, ""),
276
+ statementType: command.toLowerCase(),
277
+ read: false,
278
+ unboundedMutation: false,
279
+ destructive: true,
280
+ ordered: false,
281
+ wrapForLimit: false,
282
+ cacheable: false,
283
+ requiresAutocommit: true,
284
+ };
285
+ }
286
+ function tokenizePostgresMaintenance(sql) {
287
+ const scanner = new PostgresPrefixScanner(sql);
288
+ const tokens = [];
289
+ while (scanner.triviaEnd() < sql.length) {
290
+ const character = sql[scanner.position];
291
+ if (character === ";") {
292
+ scanner.position += 1;
293
+ if (scanner.triviaEnd() !== sql.length) {
294
+ throw new StateQLError("INVALID_SQL", "Exactly one SQL statement is required.");
295
+ }
296
+ break;
297
+ }
298
+ const word = scanner.readWord(false);
299
+ if (word) {
300
+ tokens.push({ kind: "word", value: word.value });
301
+ continue;
302
+ }
303
+ if (character === '"') {
304
+ const end = postgresQuotedIdentifierEnd(sql, scanner.position);
305
+ if (end === undefined)
306
+ invalidPostgresSyntax("maintenance");
307
+ tokens.push({ kind: "identifier", value: sql.slice(scanner.position, end) });
308
+ scanner.position = end;
309
+ continue;
310
+ }
311
+ if (/[0-9]/u.test(character)) {
312
+ const start = scanner.position;
313
+ scanner.position += 1;
314
+ while (/[0-9]/u.test(sql[scanner.position] ?? ""))
315
+ scanner.position += 1;
316
+ tokens.push({ kind: "number", value: sql.slice(start, scanner.position) });
317
+ continue;
318
+ }
319
+ if (["(", ")", ",", "."].includes(character)) {
320
+ tokens.push({ kind: "punctuation", value: character });
321
+ scanner.position += 1;
322
+ continue;
323
+ }
324
+ invalidPostgresSyntax("maintenance");
325
+ }
326
+ return tokens;
327
+ }
328
+ class UtilityParser {
329
+ tokens;
330
+ command;
331
+ index = 0;
332
+ constructor(tokens, command) {
333
+ this.tokens = tokens;
334
+ this.command = command;
335
+ }
336
+ done() {
337
+ return this.index >= this.tokens.length;
338
+ }
339
+ expectDone() {
340
+ if (!this.done())
341
+ invalidPostgresSyntax(this.command);
342
+ }
343
+ expectWord(word) {
344
+ if (!this.consumeWord(word))
345
+ invalidPostgresSyntax(this.command);
346
+ }
347
+ consumeWord(word) {
348
+ const token = this.tokens[this.index];
349
+ if (token?.kind !== "word" || token.value !== word)
350
+ return false;
351
+ this.index += 1;
352
+ return true;
353
+ }
354
+ expectOneOf(words) {
355
+ const token = this.tokens[this.index];
356
+ if (token?.kind !== "word" || !words.includes(token.value)) {
357
+ invalidPostgresSyntax(this.command);
358
+ }
359
+ this.index += 1;
360
+ return token.value;
361
+ }
362
+ options(options, legacy = []) {
363
+ if (this.consumePunctuation("(")) {
364
+ const seen = new Set();
365
+ while (true) {
366
+ const option = this.tokens[this.index];
367
+ if (option?.kind !== "word" || seen.has(option.value)) {
368
+ invalidPostgresSyntax(this.command);
369
+ }
370
+ const kind = options.get(option.value);
371
+ if (!kind)
372
+ invalidPostgresSyntax(this.command);
373
+ seen.add(option.value);
374
+ this.index += 1;
375
+ const next = this.tokens[this.index];
376
+ if (next?.value !== "," && next?.value !== ")") {
377
+ this.optionValue(kind);
378
+ }
379
+ else if (kind !== "boolean") {
380
+ invalidPostgresSyntax(this.command);
381
+ }
382
+ if (this.consumePunctuation(")"))
383
+ return seen;
384
+ if (!this.consumePunctuation(","))
385
+ invalidPostgresSyntax(this.command);
386
+ }
387
+ }
388
+ const seen = new Set();
389
+ while (true) {
390
+ const option = this.tokens[this.index];
391
+ if (option?.kind !== "word" || !legacy.includes(option.value))
392
+ return seen;
393
+ if (seen.has(option.value))
394
+ invalidPostgresSyntax(this.command);
395
+ seen.add(option.value);
396
+ this.index += 1;
397
+ }
398
+ }
399
+ optionalTargets() {
400
+ if (this.done())
401
+ return;
402
+ while (true) {
403
+ this.qualifiedIdentifier();
404
+ if (this.consumePunctuation("(")) {
405
+ this.identifier();
406
+ while (this.consumePunctuation(","))
407
+ this.identifier();
408
+ if (!this.consumePunctuation(")"))
409
+ invalidPostgresSyntax(this.command);
410
+ }
411
+ if (!this.consumePunctuation(","))
412
+ return;
413
+ }
414
+ }
415
+ qualifiedIdentifier() {
416
+ this.identifier();
417
+ while (this.consumePunctuation("."))
418
+ this.identifier();
419
+ }
420
+ identifier() {
421
+ const token = this.tokens[this.index];
422
+ if (token?.kind !== "word" && token?.kind !== "identifier") {
423
+ invalidPostgresSyntax(this.command);
424
+ }
425
+ this.index += 1;
426
+ }
427
+ optionValue(kind) {
428
+ const token = this.tokens[this.index];
429
+ if (!token)
430
+ invalidPostgresSyntax(this.command);
431
+ if (kind === "boolean") {
432
+ if (token.kind !== "word" || !BOOLEAN_VALUES.has(token.value)) {
433
+ invalidPostgresSyntax(this.command);
434
+ }
435
+ }
436
+ else if (kind === "number") {
437
+ if (token.kind !== "number")
438
+ invalidPostgresSyntax(this.command);
439
+ }
440
+ else if (kind === "identifier") {
441
+ if (token.kind !== "word" && token.kind !== "identifier") {
442
+ invalidPostgresSyntax(this.command);
443
+ }
444
+ }
445
+ else if (token.kind !== "word" || !kind.has(token.value)) {
446
+ invalidPostgresSyntax(this.command);
447
+ }
448
+ this.index += 1;
449
+ }
450
+ consumePunctuation(value) {
451
+ const token = this.tokens[this.index];
452
+ if (token?.kind !== "punctuation" || token.value !== value)
453
+ return false;
454
+ this.index += 1;
455
+ return true;
456
+ }
457
+ }
458
+ class PostgresPrefixScanner {
459
+ sql;
460
+ position = 0;
461
+ constructor(sql) {
462
+ this.sql = sql;
463
+ }
464
+ triviaEnd() {
465
+ while (this.position < this.sql.length) {
466
+ if (/\s/u.test(this.sql[this.position])) {
467
+ this.position += 1;
468
+ }
469
+ else if (this.sql.startsWith("--", this.position)) {
470
+ this.position = lineCommentEnd(this.sql, this.position + 2);
471
+ }
472
+ else if (this.sql.startsWith("/*", this.position)) {
473
+ const end = postgresBlockCommentEnd(this.sql, this.position + 2);
474
+ if (end === undefined) {
475
+ throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
476
+ }
477
+ this.position = end;
478
+ }
479
+ else {
480
+ break;
481
+ }
482
+ }
483
+ return this.position;
484
+ }
485
+ readWord(skipTrivia = true) {
486
+ if (skipTrivia)
487
+ this.triviaEnd();
488
+ const start = this.position;
489
+ if (!identifierStart(this.sql[start]))
490
+ return undefined;
491
+ this.position += 1;
492
+ while (identifierPart(this.sql[this.position]))
493
+ this.position += 1;
494
+ return {
495
+ value: this.sql.slice(start, this.position).toUpperCase(),
496
+ start,
497
+ end: this.position,
498
+ };
499
+ }
500
+ peekWord() {
501
+ const position = this.position;
502
+ const value = this.readWord()?.value;
503
+ this.position = position;
504
+ return value;
505
+ }
506
+ peek() {
507
+ this.triviaEnd();
508
+ return this.sql[this.position];
509
+ }
510
+ consume(value) {
511
+ this.triviaEnd();
512
+ if (!this.sql.startsWith(value, this.position))
513
+ return false;
514
+ this.position += value.length;
515
+ return true;
516
+ }
517
+ }
518
+ function invalidPostgresSyntax(command) {
519
+ throw new StateQLError("INVALID_SQL", `Unsupported or invalid PostgreSQL ${command} syntax.`);
520
+ }
521
+ function postgresQuotedIdentifierEnd(sql, start) {
522
+ let index = start + 1;
523
+ while (index < sql.length) {
524
+ if (sql[index] !== '"') {
525
+ index += 1;
526
+ }
527
+ else if (sql[index + 1] === '"') {
528
+ index += 2;
529
+ }
530
+ else {
531
+ return index + 1;
532
+ }
533
+ }
534
+ return undefined;
535
+ }
536
+ function postgresQuotedStringScanEnd(sql, start) {
537
+ let index = start + 1;
538
+ while (index < sql.length) {
539
+ if (sql[index] === "\\") {
540
+ index += 2;
541
+ }
542
+ else if (sql[index] !== "'") {
543
+ index += 1;
544
+ }
545
+ else if (sql[index + 1] === "'") {
546
+ index += 2;
547
+ }
548
+ else {
549
+ return index + 1;
550
+ }
551
+ }
552
+ return index;
553
+ }
554
+ function postgresBlockCommentEnd(sql, start) {
555
+ let depth = 1;
556
+ let index = start;
557
+ while (index < sql.length) {
558
+ if (sql.startsWith("/*", index)) {
559
+ depth += 1;
560
+ index += 2;
561
+ }
562
+ else if (sql.startsWith("*/", index)) {
563
+ depth -= 1;
564
+ index += 2;
565
+ if (depth === 0)
566
+ return index;
567
+ }
568
+ else {
569
+ index += 1;
570
+ }
571
+ }
572
+ return undefined;
573
+ }
87
574
  function postgresParserSql(sql) {
88
575
  const output = sql.split("");
89
576
  const orderDepths = new Set();
@@ -97,13 +584,26 @@ function postgresParserSql(sql) {
97
584
  continue;
98
585
  }
99
586
  if (sql.startsWith("/*", index)) {
100
- index = blockCommentEnd(sql, index + 2);
587
+ const end = postgresBlockCommentEnd(sql, index + 2);
588
+ if (end === undefined) {
589
+ throw new StateQLError("INVALID_SQL", "Unterminated SQL comment.");
590
+ }
591
+ index = end;
101
592
  continue;
102
593
  }
103
594
  const character = sql[index];
104
- if (character === "'" || character === '"') {
595
+ if (character === "'") {
105
596
  previousWord = undefined;
106
- index = quotedEnd(sql, index + 1, character);
597
+ index = postgresQuotedStringScanEnd(sql, index);
598
+ continue;
599
+ }
600
+ if (character === '"') {
601
+ previousWord = undefined;
602
+ const end = postgresQuotedIdentifierEnd(sql, index);
603
+ if (end === undefined) {
604
+ throw new StateQLError("INVALID_SQL", "Unterminated quoted SQL identifier.");
605
+ }
606
+ index = end;
107
607
  continue;
108
608
  }
109
609
  if (character === "$") {
@@ -111,7 +611,10 @@ function postgresParserSql(sql) {
111
611
  if (delimiter) {
112
612
  previousWord = undefined;
113
613
  const end = sql.indexOf(delimiter, index + delimiter.length);
114
- index = end < 0 ? sql.length : end + delimiter.length;
614
+ if (end < 0) {
615
+ throw new StateQLError("INVALID_SQL", "Unterminated dollar-quoted SQL value.");
616
+ }
617
+ index = end + delimiter.length;
115
618
  continue;
116
619
  }
117
620
  }
@@ -205,24 +708,6 @@ function blockCommentEnd(sql, start) {
205
708
  }
206
709
  return index;
207
710
  }
208
- function quotedEnd(sql, start, quote) {
209
- let index = start;
210
- while (index < sql.length) {
211
- if (sql[index] === "\\" && quote === "'") {
212
- index += 2;
213
- }
214
- else if (sql[index] !== quote) {
215
- index += 1;
216
- }
217
- else if (sql[index + 1] === quote) {
218
- index += 2;
219
- }
220
- else {
221
- return index + 1;
222
- }
223
- }
224
- return index;
225
- }
226
711
  function identifierStart(value) {
227
712
  return value !== undefined && /[A-Za-z_\u0080-\uFFFF]/u.test(value);
228
713
  }
@@ -230,23 +715,35 @@ function identifierPart(value) {
230
715
  return value !== undefined && /[A-Za-z0-9_$\u0080-\uFFFF]/u.test(value);
231
716
  }
232
717
  function selectContainsWrite(ast) {
233
- const details = ast;
234
- const into = details.into;
235
- if (into?.type === "into" || into?.expr)
236
- return true;
237
- const withStatements = details.with;
238
- if (!Array.isArray(withStatements))
239
- return false;
240
- return withStatements.some((entry) => {
241
- if (!entry || typeof entry !== "object")
718
+ const visited = new Set();
719
+ const writeTypes = new Set([
720
+ "insert",
721
+ "replace",
722
+ "update",
723
+ "delete",
724
+ "create",
725
+ "alter",
726
+ "drop",
727
+ "truncate",
728
+ ]);
729
+ const visit = (value) => {
730
+ if (!value || typeof value !== "object")
242
731
  return false;
243
- const statement = entry.stmt;
244
- if (!statement || typeof statement !== "object")
732
+ if (visited.has(value))
245
733
  return false;
246
- const wrapper = statement;
247
- const child = (wrapper.ast ?? statement);
248
- if (child.type !== "select")
734
+ visited.add(value);
735
+ if (Array.isArray(value))
736
+ return value.some(visit);
737
+ const details = value;
738
+ const type = typeof details.type === "string" ? details.type : undefined;
739
+ if (type && writeTypes.has(type))
249
740
  return true;
250
- return selectContainsWrite(child);
251
- });
741
+ if (type === "select") {
742
+ const into = details.into;
743
+ if (into?.type === "into" || into?.expr)
744
+ return true;
745
+ }
746
+ return Object.values(details).some(visit);
747
+ };
748
+ return visit(ast);
252
749
  }
@@ -672,6 +672,13 @@ export class StateQL {
672
672
  if (!analysis.read) {
673
673
  throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
674
674
  }
675
+ const cacheMode = options.cache ?? "auto";
676
+ if (!analysis.cacheable && cacheMode === "require") {
677
+ throw new StateQLError("CACHE_MISS", "This statement is not cacheable.", {
678
+ retryable: true,
679
+ suggestedAction: "Run with --cache auto or --cache bypass.",
680
+ });
681
+ }
675
682
  const parameters = options.params ?? [];
676
683
  const context = this.executionContext(options);
677
684
  const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
@@ -689,8 +696,8 @@ export class StateQL {
689
696
  stateVersion,
690
697
  });
691
698
  const cached = this.store.findResult(fingerprint);
692
- const cacheMode = options.cache ?? "auto";
693
- if (cacheMode !== "bypass" &&
699
+ if (analysis.cacheable &&
700
+ cacheMode !== "bypass" &&
694
701
  cached &&
695
702
  cached.row_count <= this.maxResultRows &&
696
703
  this.cacheValid(cached, stateVersion, stateSignature)) {
@@ -709,7 +716,9 @@ export class StateQL {
709
716
  suggestedAction: "Run with --cache auto or --cache bypass.",
710
717
  });
711
718
  }
712
- const result = await adapter.read(boundedReadSql(sql, this.maxResultRows + 1), parameters);
719
+ const result = await adapter.read(analysis.wrapForLimit
720
+ ? boundedReadSql(sql, this.maxResultRows + 1)
721
+ : sql, parameters);
713
722
  if (result.rows.length > this.maxResultRows) {
714
723
  throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
715
724
  }
@@ -2200,12 +2209,19 @@ export class StateQL {
2200
2209
  throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
2201
2210
  }
2202
2211
  const parameters = options.params ?? [];
2212
+ if (analysis.requiresAutocommit &&
2213
+ (options.expectedRows !== undefined || sqlParametersLength(parameters) > 0)) {
2214
+ throw new StateQLError("INVALID_SQL", "PostgreSQL maintenance statements do not accept StateQL parameters or row-count preconditions.");
2215
+ }
2216
+ const transactionId = session.active_transaction_id ?? undefined;
2217
+ if (analysis.requiresAutocommit && transactionId) {
2218
+ throw new StateQLError("TRANSACTION_FAILED", `${analysis.statementType.toUpperCase()} cannot be staged in a transaction.`, { suggestedAction: "Rollback or commit the staged transaction, then run the maintenance statement separately." });
2219
+ }
2203
2220
  const fingerprint = hash({
2204
2221
  sql: analysis.normalized,
2205
2222
  parameters,
2206
2223
  database: databaseIdentity(connection),
2207
2224
  });
2208
- const transactionId = session.active_transaction_id ?? undefined;
2209
2225
  if (transactionId) {
2210
2226
  const transaction = this.store.getTransaction(transactionId);
2211
2227
  if (!transaction ||
@@ -2305,7 +2321,12 @@ export class StateQL {
2305
2321
  });
2306
2322
  }
2307
2323
  try {
2308
- const write = await adapter.write(sql, parameters, options.expectedRows);
2324
+ if (analysis.requiresAutocommit && !adapter.writeAutocommit) {
2325
+ throw new AdapterWriteError(`${analysis.statementType.toUpperCase()} requires PostgreSQL autocommit execution.`, false);
2326
+ }
2327
+ const write = analysis.requiresAutocommit
2328
+ ? await adapter.writeAutocommit(sql, parameters)
2329
+ : await adapter.write(sql, parameters, options.expectedRows);
2309
2330
  try {
2310
2331
  const finalized = planClaim
2311
2332
  ? this.store.finishPlannedOperation({
@@ -3083,6 +3104,11 @@ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId)
3083
3104
  // A stale committing transaction is recovered as unknown after five minutes.
3084
3105
  }
3085
3106
  }
3107
+ function sqlParametersLength(parameters) {
3108
+ return Array.isArray(parameters)
3109
+ ? parameters.length
3110
+ : Object.keys(parameters).length;
3111
+ }
3086
3112
  function boundedReadSql(sql, limit) {
3087
3113
  const statement = sql.trim().replace(/;\s*$/, "");
3088
3114
  return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "dist/src",
23
23
  "README.md",
24
+ "SQL_COMMAND_ROADMAP.md",
24
25
  "LICENSE"
25
26
  ],
26
27
  "scripts": {