@fadhilp/stateql 0.11.0 → 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 +64 -9
- package/dist/src/adapters.d.ts +1 -0
- package/dist/src/adapters.js +35 -2
- package/dist/src/index.d.ts +1 -1
- package/dist/src/sql.d.ts +4 -1
- package/dist/src/sql.js +591 -94
- package/dist/src/stateql.d.ts +3 -1
- package/dist/src/stateql.js +53 -6
- package/dist/src/store.d.ts +1 -0
- package/dist/src/store.js +50 -0
- package/dist/src/types.d.ts +4 -0
- package/package.json +2 -1
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
|
|
@@ -479,16 +510,40 @@ context as `options.executionContext` for all commands in that batch.
|
|
|
479
510
|
|
|
480
511
|
### Actor workspaces
|
|
481
512
|
|
|
482
|
-
`StateQL.
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
513
|
+
`StateQL.forWorkspace(...)` is a trusted-host primitive that atomically creates
|
|
514
|
+
or reopens a durable workspace, attaches the requested actor, and returns a
|
|
515
|
+
client bound to that actor:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
const stateql = StateQL.forWorkspace({
|
|
519
|
+
home: "./.stql",
|
|
520
|
+
workspace: "pylon-global",
|
|
521
|
+
actor: "pylon-session:abc123",
|
|
522
|
+
credentialResolver,
|
|
523
|
+
signal,
|
|
524
|
+
});
|
|
525
|
+
```
|
|
486
526
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
`
|
|
490
|
-
|
|
491
|
-
|
|
527
|
+
Repeated opens of the same actor and workspace are idempotent. An actor already
|
|
528
|
+
attached elsewhere fails with a `StateQLError` whose code is
|
|
529
|
+
`PERMISSION_DENIED`; StateQL never moves or merges it. All actor options,
|
|
530
|
+
including limits, credential resolution, cancellation, `home`, and `now`, are
|
|
531
|
+
preserved. The workspace name also reserves a same-named actor identity for
|
|
532
|
+
legacy compatibility, so workspace and actor identifiers must be globally
|
|
533
|
+
collision-free. The returned client is still bound only to `actor`, preserving
|
|
534
|
+
plan, transaction, operation, and history ownership.
|
|
535
|
+
|
|
536
|
+
`StateQL.forActor(...)` retains its existing behavior: it resolves the actor's
|
|
537
|
+
attached session directly from StateQL storage and creates a legacy-compatible
|
|
538
|
+
session named after the actor on first use. Use `new StateQL({ session, actor })`
|
|
539
|
+
when the session and membership are already known.
|
|
540
|
+
|
|
541
|
+
Membership management and `forWorkspace` are library-only host capabilities,
|
|
542
|
+
not batch or CLI commands. Existing member-authorized management remains
|
|
543
|
+
available through `linkActor(session, actorId)`, `unlinkActor(session, actorId)`,
|
|
544
|
+
`listActors(session)`, and `resolveActor(actorId)`. Integrations should ask for
|
|
545
|
+
user confirmation before changing membership or the shared connection; a host
|
|
546
|
+
calling `forWorkspace` is responsible for authorizing that workspace access.
|
|
492
547
|
|
|
493
548
|
### Harness credential resolution
|
|
494
549
|
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/adapters.js
CHANGED
|
@@ -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,
|
|
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/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { StateQL } from "./stateql.js";
|
|
|
2
2
|
export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
|
|
3
3
|
export type { CredentialResolutionFailure } from "./errors.js";
|
|
4
4
|
export type { TableChange, TableIdentity, TableUpdate } from "./table-editor.js";
|
|
5
|
-
export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CatalogObject, CatalogObjectKind, DescribeObjectData, ListObjectsData, ListObjectsFilter, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialSource, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryCategory, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfileUpdateOptions, ProfilesData, PurgeData, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, RedisWriteOutcome, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StateQLSnapshotOptions, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
|
|
5
|
+
export type { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, CatalogObject, CatalogObjectKind, DescribeObjectData, ListObjectsData, ListObjectsFilter, CapabilitiesData, CommandExecutionContext, CommandOrigin, CloseSessionData, Column, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, CredentialAccess, CredentialOperation, CredentialRequest, CredentialSource, CredentialResolver, DisconnectData, DoctorData, Driver, ExecData, ExecOptions, ExecutionOptions, ExportData, Failure, FilterOptions, HistoryData, HistoryEntry, HistoryCategory, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfileUpdateOptions, ProfilesData, PurgeData, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, RedisWriteOutcome, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StateQLSnapshotOptions, StateQLWorkspaceOptions, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
|
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",
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "'"
|
|
595
|
+
if (character === "'") {
|
|
105
596
|
previousWord = undefined;
|
|
106
|
-
index =
|
|
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
|
-
|
|
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
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
if (!statement || typeof statement !== "object")
|
|
732
|
+
if (visited.has(value))
|
|
245
733
|
return false;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/stateql.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { type TableChange } from "./table-editor.js";
|
|
2
|
-
import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
|
|
2
|
+
import type { ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, CommandExecutionContext, CommandOrigin, BatchOptions, CapabilitiesData, CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, CloseSessionData, ColumnsData, CommitTransactionData, ConnectOptions, ConnectionData, CountData, DisconnectData, DoctorData, ExecData, ExecOptions, ExecutionOptions, ExportData, FilterOptions, HistoryData, HistoryOptions, OperationData, PlanData, PlanOptions, ProfileData, ProfilesData, ProfileOptions, ProfileUpdateOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, RedisCommand, RedisExecOptions, RedisPlanOptions, RedisQueryOptions, PurgeData, QueryOptions, RemovedProfileData, Response, ResultData, RollbackTransactionData, RowsData, RowsOptions, Column, StateQLActorOptions, StateQLOptions, StateQLWorkspaceOptions, StateQLSnapshot, StateQLSnapshotOptions, StatusData, SessionData, SessionsData, SessionSummaryData, TransactionData } from "./types.js";
|
|
3
3
|
export declare class StateQL {
|
|
4
4
|
/** Runtime contract marker for passwordRef/password_ref support. */
|
|
5
5
|
static readonly passwordReferenceVersion: 1;
|
|
6
6
|
static forActor(options: StateQLActorOptions): StateQL;
|
|
7
|
+
/** Opens one actor in a named shared workspace for a trusted library host. */
|
|
8
|
+
static forWorkspace(options: StateQLWorkspaceOptions): StateQL;
|
|
7
9
|
private readonly store;
|
|
8
10
|
private readonly sessionName;
|
|
9
11
|
private readonly actorId;
|
package/dist/src/stateql.js
CHANGED
|
@@ -17,6 +17,7 @@ import { compactRows, defaultHome, hash, isSqlParameters, parseJson, redact, } f
|
|
|
17
17
|
const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
|
|
18
18
|
const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
|
|
19
19
|
const DEFAULT_CREDENTIAL_RESOLUTION_TIMEOUT_MS = 120_000;
|
|
20
|
+
const WORKSPACE_BOOTSTRAP = Symbol("StateQL.workspaceBootstrap");
|
|
20
21
|
export class StateQL {
|
|
21
22
|
/** Runtime contract marker for passwordRef/password_ref support. */
|
|
22
23
|
static passwordReferenceVersion = 1;
|
|
@@ -37,6 +38,21 @@ export class StateQL {
|
|
|
37
38
|
store.close();
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
/** Opens one actor in a named shared workspace for a trusted library host. */
|
|
42
|
+
static forWorkspace(options) {
|
|
43
|
+
if (!options.workspace.trim()) {
|
|
44
|
+
throw new StateQLError("INVALID_COMMAND", "Workspace name is required.");
|
|
45
|
+
}
|
|
46
|
+
if (!options.actor.trim()) {
|
|
47
|
+
throw new StateQLError("INVALID_COMMAND", "Actor ID is required.");
|
|
48
|
+
}
|
|
49
|
+
const { workspace, ...actorOptions } = options;
|
|
50
|
+
return new StateQL({
|
|
51
|
+
...actorOptions,
|
|
52
|
+
session: workspace,
|
|
53
|
+
[WORKSPACE_BOOTSTRAP]: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
40
56
|
store;
|
|
41
57
|
sessionName;
|
|
42
58
|
actorId;
|
|
@@ -80,7 +96,12 @@ export class StateQL {
|
|
|
80
96
|
}
|
|
81
97
|
const store = new StateStore(options.home ?? defaultHome(), this.now, maxStateBytes);
|
|
82
98
|
try {
|
|
83
|
-
|
|
99
|
+
if (options[WORKSPACE_BOOTSTRAP]) {
|
|
100
|
+
store.bootstrapWorkspace(this.sessionName, this.actorId);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
store.bootstrapSession(this.sessionName, this.actorId, options.actor === undefined);
|
|
104
|
+
}
|
|
84
105
|
}
|
|
85
106
|
catch (error) {
|
|
86
107
|
store.close();
|
|
@@ -651,6 +672,13 @@ export class StateQL {
|
|
|
651
672
|
if (!analysis.read) {
|
|
652
673
|
throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
|
|
653
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
|
+
}
|
|
654
682
|
const parameters = options.params ?? [];
|
|
655
683
|
const context = this.executionContext(options);
|
|
656
684
|
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
@@ -668,8 +696,8 @@ export class StateQL {
|
|
|
668
696
|
stateVersion,
|
|
669
697
|
});
|
|
670
698
|
const cached = this.store.findResult(fingerprint);
|
|
671
|
-
|
|
672
|
-
|
|
699
|
+
if (analysis.cacheable &&
|
|
700
|
+
cacheMode !== "bypass" &&
|
|
673
701
|
cached &&
|
|
674
702
|
cached.row_count <= this.maxResultRows &&
|
|
675
703
|
this.cacheValid(cached, stateVersion, stateSignature)) {
|
|
@@ -688,7 +716,9 @@ export class StateQL {
|
|
|
688
716
|
suggestedAction: "Run with --cache auto or --cache bypass.",
|
|
689
717
|
});
|
|
690
718
|
}
|
|
691
|
-
const result = await adapter.read(
|
|
719
|
+
const result = await adapter.read(analysis.wrapForLimit
|
|
720
|
+
? boundedReadSql(sql, this.maxResultRows + 1)
|
|
721
|
+
: sql, parameters);
|
|
692
722
|
if (result.rows.length > this.maxResultRows) {
|
|
693
723
|
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
|
|
694
724
|
}
|
|
@@ -2179,12 +2209,19 @@ export class StateQL {
|
|
|
2179
2209
|
throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
|
|
2180
2210
|
}
|
|
2181
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
|
+
}
|
|
2182
2220
|
const fingerprint = hash({
|
|
2183
2221
|
sql: analysis.normalized,
|
|
2184
2222
|
parameters,
|
|
2185
2223
|
database: databaseIdentity(connection),
|
|
2186
2224
|
});
|
|
2187
|
-
const transactionId = session.active_transaction_id ?? undefined;
|
|
2188
2225
|
if (transactionId) {
|
|
2189
2226
|
const transaction = this.store.getTransaction(transactionId);
|
|
2190
2227
|
if (!transaction ||
|
|
@@ -2284,7 +2321,12 @@ export class StateQL {
|
|
|
2284
2321
|
});
|
|
2285
2322
|
}
|
|
2286
2323
|
try {
|
|
2287
|
-
|
|
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);
|
|
2288
2330
|
try {
|
|
2289
2331
|
const finalized = planClaim
|
|
2290
2332
|
? this.store.finishPlannedOperation({
|
|
@@ -3062,6 +3104,11 @@ function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId)
|
|
|
3062
3104
|
// A stale committing transaction is recovered as unknown after five minutes.
|
|
3063
3105
|
}
|
|
3064
3106
|
}
|
|
3107
|
+
function sqlParametersLength(parameters) {
|
|
3108
|
+
return Array.isArray(parameters)
|
|
3109
|
+
? parameters.length
|
|
3110
|
+
: Object.keys(parameters).length;
|
|
3111
|
+
}
|
|
3065
3112
|
function boundedReadSql(sql, limit) {
|
|
3066
3113
|
const statement = sql.trim().replace(/;\s*$/, "");
|
|
3067
3114
|
return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
|
package/dist/src/store.d.ts
CHANGED
|
@@ -132,6 +132,7 @@ export declare class StateStore {
|
|
|
132
132
|
private insertWithRandomId;
|
|
133
133
|
ensureSession(name?: string): SessionRecord;
|
|
134
134
|
bootstrapSession(name: string, actorId: string, ensureLegacyMembership: boolean): SessionRecord;
|
|
135
|
+
bootstrapWorkspace(name: string, actorId: string): SessionRecord;
|
|
135
136
|
createSession(name: string): SessionRecord;
|
|
136
137
|
isSessionMember(sessionId: string, actorId: string): boolean;
|
|
137
138
|
linkActor(sessionId: string, requestingActorId: string, actorId: string): "linked" | "already_linked" | "actor_conflict" | "denied";
|
package/dist/src/store.js
CHANGED
|
@@ -132,6 +132,56 @@ export class StateStore {
|
|
|
132
132
|
throw error;
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
bootstrapWorkspace(name, actorId) {
|
|
136
|
+
const timestamp = this.now().toISOString();
|
|
137
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
138
|
+
try {
|
|
139
|
+
let row = this.db
|
|
140
|
+
.prepare("SELECT id, status FROM sessions WHERE name = ? LIMIT 1")
|
|
141
|
+
.get(name);
|
|
142
|
+
const identities = actorId === name ? [actorId] : [name, actorId];
|
|
143
|
+
for (const identity of identities) {
|
|
144
|
+
const existing = this.resolveActor(identity);
|
|
145
|
+
if (existing && existing.id !== row?.id) {
|
|
146
|
+
throw new StateQLError("PERMISSION_DENIED", `Actor "${identity}" is already attached to workspace "${existing.name}" and cannot be attached to workspace "${name}".`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!row) {
|
|
150
|
+
const id = this.insertWithRandomId("s", "sessions", (candidate) => {
|
|
151
|
+
this.db
|
|
152
|
+
.prepare(`INSERT INTO sessions
|
|
153
|
+
(id, name, status, created_at, updated_at)
|
|
154
|
+
VALUES (?, ?, 'active', ?, ?)`)
|
|
155
|
+
.run(candidate, name, timestamp, timestamp);
|
|
156
|
+
});
|
|
157
|
+
row = { id, status: "active" };
|
|
158
|
+
}
|
|
159
|
+
else if (row.status !== "active") {
|
|
160
|
+
this.db
|
|
161
|
+
.prepare(`UPDATE sessions
|
|
162
|
+
SET status = 'active', updated_at = ?
|
|
163
|
+
WHERE id = ?`)
|
|
164
|
+
.run(timestamp, row.id);
|
|
165
|
+
}
|
|
166
|
+
for (const identity of identities) {
|
|
167
|
+
if (this.isSessionMember(row.id, identity))
|
|
168
|
+
continue;
|
|
169
|
+
this.db
|
|
170
|
+
.prepare(`INSERT INTO session_members(session_id, actor_id, attached_at)
|
|
171
|
+
VALUES (?, ?, ?)`)
|
|
172
|
+
.run(row.id, identity, timestamp);
|
|
173
|
+
}
|
|
174
|
+
const session = this.getSessionByName(name);
|
|
175
|
+
if (!session)
|
|
176
|
+
throw new Error(`Could not open workspace "${name}".`);
|
|
177
|
+
this.db.exec("COMMIT");
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
this.db.exec("ROLLBACK");
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
135
185
|
createSession(name) {
|
|
136
186
|
return this.bootstrapSession(name, name, true);
|
|
137
187
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -274,6 +274,10 @@ export interface StateQLOptions extends ExecutionOptions {
|
|
|
274
274
|
export type StateQLActorOptions = Omit<StateQLOptions, "session" | "actor"> & {
|
|
275
275
|
actor: string;
|
|
276
276
|
};
|
|
277
|
+
/** Trusted-host options for opening one actor in a named shared workspace. */
|
|
278
|
+
export type StateQLWorkspaceOptions = StateQLActorOptions & {
|
|
279
|
+
workspace: string;
|
|
280
|
+
};
|
|
277
281
|
export interface QueryOptions extends ExecutionOptions {
|
|
278
282
|
params?: SqlParameters;
|
|
279
283
|
cache?: "auto" | "bypass" | "require";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fadhilp/stateql",
|
|
3
|
-
"version": "0.
|
|
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": {
|