@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 +110 -0
- package/dist/src/adapters.d.ts +2 -0
- package/dist/src/adapters.js +95 -2
- package/dist/src/sql.d.ts +6 -1
- package/dist/src/sql.js +1086 -95
- package/dist/src/sqlite-process.js +16 -0
- package/dist/src/stateql.js +40 -5
- package/package.json +2 -1
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
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/adapters.js
CHANGED
|
@@ -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,
|
|
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. */
|