@fadhilp/stateql 0.1.1
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/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/src/adapters.d.ts +23 -0
- package/dist/src/adapters.js +346 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +516 -0
- package/dist/src/errors.d.ts +12 -0
- package/dist/src/errors.js +45 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +2 -0
- package/dist/src/sql.d.ts +13 -0
- package/dist/src/sql.js +91 -0
- package/dist/src/stateql.d.ts +55 -0
- package/dist/src/stateql.js +1655 -0
- package/dist/src/store.d.ts +245 -0
- package/dist/src/store.js +622 -0
- package/dist/src/types.d.ts +112 -0
- package/dist/src/types.js +1 -0
- package/dist/src/util.d.ts +8 -0
- package/dist/src/util.js +75 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StateQL contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# StateQL
|
|
2
|
+
|
|
3
|
+
StateQL is a stateful database CLI for AI agents and automation. It provides a
|
|
4
|
+
safe interface for querying, changing, and inspecting databases while keeping
|
|
5
|
+
results reusable and operations traceable across commands.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22.5 or newer.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
Connect to an existing SQLite database, then run a filtered, parameterized
|
|
12
|
+
query. Parameters keep values separate from SQL; `ORDER BY` makes paging
|
|
13
|
+
stable, while `LIMIT` bounds work at the database.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install --global stateql
|
|
17
|
+
export STQL_SESSION=audit
|
|
18
|
+
stql profile add local ./app.sqlite
|
|
19
|
+
stql connect local
|
|
20
|
+
|
|
21
|
+
stql query \
|
|
22
|
+
"SELECT id, name, email FROM users WHERE status = ? AND created_at >= ? ORDER BY id LIMIT 50" \
|
|
23
|
+
--param active \
|
|
24
|
+
--param 2026-01-01
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Example output:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"ok":true,"handle":"q_1","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"},{"id":18,"name":"Linus","email":"linus@kernel.org"}],"truncated":false,"cached":false,"total":3,"next_offset":null}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`q_1` is a durable result handle. Filter its stored snapshot without querying
|
|
34
|
+
the original database; the result becomes another durable handle:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
stql filter q_1 "email LIKE ?" --param "%@example.com"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"ok":true,"handle":"q_2","rows":[{"id":7,"name":"Ada","email":"ada@example.com"},{"id":12,"name":"Grace","email":"grace@example.com"}],"truncated":false,"cached":false,"total":2,"next_offset":null}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Give the derived result a readable alias, page through it, inspect its count,
|
|
45
|
+
or export it—all without rerunning SQL:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
stql alias set example-users q_2
|
|
49
|
+
stql rows example-users --offset 0 --limit 1
|
|
50
|
+
stql rows example-users --offset 1 --limit 1
|
|
51
|
+
stql count example-users
|
|
52
|
+
stql export example-users --output example-users.csv --format csv
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Example first page:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{"ok":true,"handle":"q_2","rows":[{"id":7,"name":"Ada","email":"ada@example.com"}],"total":2,"truncated":true,"next_offset":1}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Running the same normalized query with the same parameters reuses `q_1` while
|
|
62
|
+
its cache is valid. Use `--cache bypass` when a fresh read is required.
|
|
63
|
+
|
|
64
|
+
PostgreSQL credentials should come from an environment variable:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
export APP_DATABASE_URL='postgres://user:password@host/app'
|
|
68
|
+
stql connect --env APP_DATABASE_URL --name app --read-only
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
stql connect <sqlite-path|postgres-url> [--name NAME] [--env ENV] [--read-write]
|
|
75
|
+
stql connect --profile NAME
|
|
76
|
+
stql status
|
|
77
|
+
stql profile add|list|show|remove
|
|
78
|
+
stql session start|list|show|summary|close
|
|
79
|
+
stql query <sql> [--params JSON | --param VALUE...] [--cache auto|bypass|require]
|
|
80
|
+
stql filter <result-handle> <predicate> [--params JSON | --param VALUE...]
|
|
81
|
+
stql exec <sql> [--params JSON | --param VALUE...] [--idempotency-key KEY] [--replay]
|
|
82
|
+
[--allow-unbounded] [--allow-destructive]
|
|
83
|
+
stql show|count|columns <result-handle>
|
|
84
|
+
stql rows <result-handle> [--offset N] [--limit N]
|
|
85
|
+
stql export <result-handle> --output FILE [--format json|jsonl|csv]
|
|
86
|
+
stql inspect schema|table|columns|indexes|constraints [table]
|
|
87
|
+
stql transaction begin|status|commit|rollback [--isolation LEVEL]
|
|
88
|
+
stql plan <sql> [--allow-unbounded] [--allow-destructive]
|
|
89
|
+
stql apply <plan-handle>
|
|
90
|
+
stql history [--limit N]
|
|
91
|
+
stql receipt <operation-handle>
|
|
92
|
+
stql capabilities
|
|
93
|
+
stql batch [commands.json|commands.jsonl|-] [--continue-on-error]
|
|
94
|
+
stql pipe [--continue-on-error]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Output modes
|
|
98
|
+
|
|
99
|
+
CLI output defaults to compact, one-line `agent` JSON. Successes flatten useful
|
|
100
|
+
data and expose the primary durable ID as `handle`; errors retain their complete
|
|
101
|
+
error object. Empty warnings and tracing metadata are omitted.
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"ok":false,"error":{"code":"UNBOUNDED_MUTATION","message":"Mutation has no WHERE clause.","retryable":false,"executed":false,"override_flag":"--allow-unbounded"}}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use `--output json` for the original pretty, verbose envelope, or
|
|
108
|
+
`--output jsonl` for that envelope on one line. `--output text` prints a short
|
|
109
|
+
human status; `--output silent` prints only a successful handle. Set
|
|
110
|
+
`STQL_OUTPUT` to choose a mode globally. For `export`, `--output` names the
|
|
111
|
+
file, so use `STQL_OUTPUT` to choose its response mode. Library responses keep
|
|
112
|
+
the full envelope regardless of CLI mode.
|
|
113
|
+
|
|
114
|
+
For shell-safe positional parameters, repeat `--param`. JSON scalars become
|
|
115
|
+
their native types; other values remain strings.
|
|
116
|
+
|
|
117
|
+
```powershell
|
|
118
|
+
stql exec "INSERT INTO users (name, status) VALUES (?, ?)" `
|
|
119
|
+
--param Ada --param trial
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Use `--params-file FILE` for arrays or named parameters that are awkward to
|
|
123
|
+
quote. `--params-file -` reads JSON from standard input.
|
|
124
|
+
|
|
125
|
+
## Local profiles
|
|
126
|
+
|
|
127
|
+
Profiles persist under `STQL_HOME` with other StateQL metadata.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
stql profile add local ./app.sqlite --read-write
|
|
131
|
+
stql profile add production --env PROD_DATABASE_URL --read-only
|
|
132
|
+
stql profile list
|
|
133
|
+
stql connect local
|
|
134
|
+
stql connect --profile production
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A bare connection target matching a profile name resolves to that profile;
|
|
138
|
+
otherwise it remains a path or database URL. Profiles store targets, read-only
|
|
139
|
+
policy, and environment-variable names. Credential values are never stored.
|
|
140
|
+
|
|
141
|
+
## Batch and pipes
|
|
142
|
+
|
|
143
|
+
`batch` reads a JSON array from a `.json` file or JSONL from a `.jsonl` file.
|
|
144
|
+
`pipe` reads JSONL from standard input. Commands run sequentially and stop on
|
|
145
|
+
the first error unless `--continue-on-error` is set. Output defaults to one
|
|
146
|
+
compact `agent` JSON object per line.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
printf '%s\n' \
|
|
150
|
+
'{"command":"query","sql":"SELECT id, email FROM users ORDER BY id","as":"users"}' \
|
|
151
|
+
'{"command":"filter","handle":"users","where":"email LIKE ?","params":["%@example.com"],"as":"example_users"}' \
|
|
152
|
+
'{"command":"rows","handle":"example_users","limit":10}' |
|
|
153
|
+
stql pipe
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
[
|
|
158
|
+
{
|
|
159
|
+
"command": "exec",
|
|
160
|
+
"sql": "UPDATE jobs SET claimed = 1 WHERE id = ?",
|
|
161
|
+
"params": [42],
|
|
162
|
+
"idempotency_key": "claim-job-42"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"command": "query",
|
|
166
|
+
"sql": "SELECT * FROM jobs WHERE id = ?",
|
|
167
|
+
"params": [42]
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Run the array with `stql batch commands.json`. Batch fields use snake case;
|
|
173
|
+
supported command names match CLI paths, such as `filter`,
|
|
174
|
+
`transaction.begin`, `session.summary`, `alias.set`, `plan`, and `apply`.
|
|
175
|
+
Batch filters use `where` for the predicate and may assign the derived result
|
|
176
|
+
with `as`.
|
|
177
|
+
|
|
178
|
+
State metadata lives under `STQL_HOME`, or the platform data directory when
|
|
179
|
+
unset. Set `STQL_SESSION` to select a named session.
|
|
180
|
+
|
|
181
|
+
Read cache entries expire after five minutes; materialized handles expire after
|
|
182
|
+
24 hours. Queries exceeding 10,000 rows fail before materialization; add a
|
|
183
|
+
narrower `WHERE` clause or `LIMIT`. Command history keeps the latest 10,000
|
|
184
|
+
entries per session. SQLite cache reuse also checks
|
|
185
|
+
the database file signature; PostgreSQL reuse is labeled `ttl_based`, never
|
|
186
|
+
authoritative. Transactions are staged in local state so they survive CLI
|
|
187
|
+
invocations, then executed atomically on commit. Connections cannot be changed
|
|
188
|
+
or disconnected while a transaction is active. SQLite supports `serializable`;
|
|
189
|
+
PostgreSQL also supports `repeatable read`, `read committed`, and
|
|
190
|
+
`read uncommitted`. PostgreSQL reads run inside database-enforced read-only
|
|
191
|
+
transactions.
|
|
192
|
+
|
|
193
|
+
StateQL stores no PostgreSQL password. Credential-bearing URLs must be supplied
|
|
194
|
+
through `--env`. SQLite result rows are materialized locally for durable access.
|
|
195
|
+
`filter` evaluates one scalar SQLite predicate against those stored rows, keeps
|
|
196
|
+
source order, state metadata, and expiry, and never accesses the original
|
|
197
|
+
database. Use parameters for values. Subqueries, query-shaping clauses, and
|
|
198
|
+
non-allowlisted functions are rejected; common deterministic functions such as
|
|
199
|
+
`lower`, `upper`, `length`, and `coalesce` are supported.
|
|
200
|
+
|
|
201
|
+
Destructive and unbounded operations require their respective flags
|
|
202
|
+
independently. Plans persist only flags explicitly supplied when the plan is
|
|
203
|
+
created; `apply` never adds authorization. If a database write starts but its
|
|
204
|
+
final outcome cannot be recorded safely, StateQL returns `OUTCOME_UNKNOWN` and
|
|
205
|
+
blocks automatic replay. Inspect database state before using `--replay`.
|
|
206
|
+
Interrupted commits remain fail-closed; stale `committing` records become
|
|
207
|
+
`outcome_unknown` after five minutes.
|
|
208
|
+
|
|
209
|
+
## Library
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { StateQL } from "stateql";
|
|
213
|
+
|
|
214
|
+
const stateql = new StateQL({ home: "./.stql" });
|
|
215
|
+
const response = await stateql.query("SELECT * FROM users");
|
|
216
|
+
if (response.ok) {
|
|
217
|
+
const handle = (response.data as { result_id: string }).result_id;
|
|
218
|
+
await stateql.filter(handle, "email LIKE ?", {
|
|
219
|
+
params: ["%@example.com"],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Column, Row, SqlParameters, StateConfidence } from "./types.js";
|
|
2
|
+
import type { ConnectionRecord, OperationRecord } from "./store.js";
|
|
3
|
+
export interface ReadResult {
|
|
4
|
+
rows: Row[];
|
|
5
|
+
columns: Column[];
|
|
6
|
+
}
|
|
7
|
+
export interface WriteResult {
|
|
8
|
+
affectedRows: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class BatchWriteError extends Error {
|
|
11
|
+
readonly outcomeUnknown: boolean;
|
|
12
|
+
constructor(message: string, outcomeUnknown: boolean);
|
|
13
|
+
}
|
|
14
|
+
export interface Adapter {
|
|
15
|
+
readonly confidence: StateConfidence;
|
|
16
|
+
read(sql: string, params: SqlParameters): Promise<ReadResult>;
|
|
17
|
+
write(sql: string, params: SqlParameters): Promise<WriteResult>;
|
|
18
|
+
writeBatch(operations: OperationRecord[], isolation: string): Promise<WriteResult[]>;
|
|
19
|
+
signature(): Promise<string>;
|
|
20
|
+
inspect(kind: string, table?: string): Promise<unknown>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function createAdapter(connection: ConnectionRecord): Promise<Adapter>;
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { Client, types as pgTypes } from "pg";
|
|
4
|
+
import { hash, parseJson, toJsonSafe } from "./util.js";
|
|
5
|
+
export class BatchWriteError extends Error {
|
|
6
|
+
outcomeUnknown;
|
|
7
|
+
constructor(message, outcomeUnknown) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
10
|
+
this.name = "BatchWriteError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function createAdapter(connection) {
|
|
14
|
+
const source = connection.secret_env
|
|
15
|
+
? process.env[connection.secret_env]
|
|
16
|
+
: connection.source;
|
|
17
|
+
if (!source) {
|
|
18
|
+
throw new Error(`Environment variable ${connection.secret_env ?? "(missing)"} is not set.`);
|
|
19
|
+
}
|
|
20
|
+
if (connection.driver === "sqlite") {
|
|
21
|
+
return new SQLiteAdapter(source, Boolean(connection.read_only));
|
|
22
|
+
}
|
|
23
|
+
return new PostgresAdapter(source, Boolean(connection.read_only));
|
|
24
|
+
}
|
|
25
|
+
class SQLiteAdapter {
|
|
26
|
+
source;
|
|
27
|
+
readOnly;
|
|
28
|
+
confidence = "database_reported";
|
|
29
|
+
db;
|
|
30
|
+
constructor(source, readOnly) {
|
|
31
|
+
this.source = source;
|
|
32
|
+
this.readOnly = readOnly;
|
|
33
|
+
this.db = new DatabaseSync(source, {
|
|
34
|
+
readOnly,
|
|
35
|
+
enableForeignKeyConstraints: true,
|
|
36
|
+
});
|
|
37
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
38
|
+
}
|
|
39
|
+
async read(sql, params) {
|
|
40
|
+
const statement = this.db.prepare(sql);
|
|
41
|
+
const rows = bindAll(statement, params);
|
|
42
|
+
return {
|
|
43
|
+
rows: toJsonSafe(rows),
|
|
44
|
+
columns: statement.columns().map((column) => ({
|
|
45
|
+
name: column.name,
|
|
46
|
+
type: column.type?.toLowerCase() ?? inferType(rows, column.name),
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async write(sql, params) {
|
|
51
|
+
if (this.readOnly)
|
|
52
|
+
throw new Error("Connection is read-only.");
|
|
53
|
+
const result = bindRun(this.db.prepare(sql), params);
|
|
54
|
+
return { affectedRows: Number(result.changes) };
|
|
55
|
+
}
|
|
56
|
+
async writeBatch(operations, isolation) {
|
|
57
|
+
if (this.readOnly)
|
|
58
|
+
throw new Error("Connection is read-only.");
|
|
59
|
+
if (isolation !== "serializable") {
|
|
60
|
+
throw new Error(`SQLite does not support isolation level "${isolation}".`);
|
|
61
|
+
}
|
|
62
|
+
const results = [];
|
|
63
|
+
try {
|
|
64
|
+
this.db.exec("BEGIN");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
throw new BatchWriteError(errorText(error), false);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
for (const operation of operations) {
|
|
71
|
+
results.push(await this.write(operation.sql, parseJson(operation.parameters, [])));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
try {
|
|
76
|
+
this.db.exec("ROLLBACK");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new BatchWriteError(errorText(error), true);
|
|
80
|
+
}
|
|
81
|
+
throw new BatchWriteError(errorText(error), false);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
this.db.exec("COMMIT");
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
try {
|
|
89
|
+
this.db.exec("ROLLBACK");
|
|
90
|
+
throw new BatchWriteError(errorText(error), false);
|
|
91
|
+
}
|
|
92
|
+
catch (rollbackError) {
|
|
93
|
+
if (rollbackError instanceof BatchWriteError)
|
|
94
|
+
throw rollbackError;
|
|
95
|
+
throw new BatchWriteError(errorText(error), true);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async signature() {
|
|
100
|
+
if (this.source === ":memory:")
|
|
101
|
+
return "memory";
|
|
102
|
+
const stats = statSync(this.source, { bigint: true });
|
|
103
|
+
const walPath = `${this.source}-wal`;
|
|
104
|
+
const wal = existsSync(walPath)
|
|
105
|
+
? statSync(walPath, { bigint: true })
|
|
106
|
+
: undefined;
|
|
107
|
+
return hash({
|
|
108
|
+
size: stats.size.toString(),
|
|
109
|
+
modified: stats.mtimeNs.toString(),
|
|
110
|
+
walSize: wal?.size.toString() ?? "0",
|
|
111
|
+
walModified: wal?.mtimeNs.toString() ?? "0",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async inspect(kind, table) {
|
|
115
|
+
if (kind === "schema") {
|
|
116
|
+
const tables = this.db
|
|
117
|
+
.prepare(`SELECT name, type
|
|
118
|
+
FROM sqlite_master
|
|
119
|
+
WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
|
|
120
|
+
ORDER BY name`)
|
|
121
|
+
.all();
|
|
122
|
+
return { schema: "main", tables };
|
|
123
|
+
}
|
|
124
|
+
if (!table)
|
|
125
|
+
throw new Error(`Table is required for inspect ${kind}.`);
|
|
126
|
+
const quoted = quoteSqliteLiteral(table);
|
|
127
|
+
const columns = this.db
|
|
128
|
+
.prepare(`PRAGMA table_info(${quoted})`)
|
|
129
|
+
.all();
|
|
130
|
+
if (columns.length === 0)
|
|
131
|
+
throw new Error(`Table "${table}" was not found.`);
|
|
132
|
+
const indexes = this.db
|
|
133
|
+
.prepare(`PRAGMA index_list(${quoted})`)
|
|
134
|
+
.all();
|
|
135
|
+
const foreignKeys = this.db
|
|
136
|
+
.prepare(`PRAGMA foreign_key_list(${quoted})`)
|
|
137
|
+
.all();
|
|
138
|
+
if (kind === "columns")
|
|
139
|
+
return { table, columns };
|
|
140
|
+
if (kind === "indexes")
|
|
141
|
+
return { table, indexes };
|
|
142
|
+
if (kind === "constraints") {
|
|
143
|
+
return {
|
|
144
|
+
table,
|
|
145
|
+
primary_key: columns
|
|
146
|
+
.filter((column) => Number(column.pk) > 0)
|
|
147
|
+
.map((column) => column.name),
|
|
148
|
+
foreign_keys: foreignKeys,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (kind !== "table")
|
|
152
|
+
throw new Error(`Unknown inspection kind "${kind}".`);
|
|
153
|
+
return {
|
|
154
|
+
table,
|
|
155
|
+
schema: "main",
|
|
156
|
+
columns: columns.map((column) => ({
|
|
157
|
+
name: column.name,
|
|
158
|
+
type: String(column.type).toLowerCase(),
|
|
159
|
+
nullable: column.notnull === 0 && Number(column.pk) === 0,
|
|
160
|
+
primary_key: Number(column.pk) > 0,
|
|
161
|
+
})),
|
|
162
|
+
indexes: indexes.length,
|
|
163
|
+
foreign_keys: foreignKeys.length,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async close() {
|
|
167
|
+
this.db.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
class PostgresAdapter {
|
|
171
|
+
readOnly;
|
|
172
|
+
confidence = "ttl_based";
|
|
173
|
+
client;
|
|
174
|
+
connected = false;
|
|
175
|
+
constructor(source, readOnly) {
|
|
176
|
+
this.readOnly = readOnly;
|
|
177
|
+
this.client = new Client({ connectionString: source });
|
|
178
|
+
}
|
|
179
|
+
async read(sql, params) {
|
|
180
|
+
await this.connect();
|
|
181
|
+
await this.client.query("BEGIN READ ONLY");
|
|
182
|
+
try {
|
|
183
|
+
const result = await this.client.query(sql, postgresParams(params));
|
|
184
|
+
await this.client.query("COMMIT");
|
|
185
|
+
return {
|
|
186
|
+
rows: toJsonSafe(result.rows),
|
|
187
|
+
columns: result.fields.map((field) => ({
|
|
188
|
+
name: field.name,
|
|
189
|
+
type: pgTypes.getTypeParser(field.dataTypeID).name ||
|
|
190
|
+
`oid_${field.dataTypeID}`,
|
|
191
|
+
})),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
await this.client.query("ROLLBACK");
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async write(sql, params) {
|
|
200
|
+
if (this.readOnly)
|
|
201
|
+
throw new Error("Connection is read-only.");
|
|
202
|
+
await this.connect();
|
|
203
|
+
const result = await this.client.query(sql, postgresParams(params));
|
|
204
|
+
return { affectedRows: result.rowCount ?? 0 };
|
|
205
|
+
}
|
|
206
|
+
async writeBatch(operations, isolation) {
|
|
207
|
+
if (this.readOnly)
|
|
208
|
+
throw new Error("Connection is read-only.");
|
|
209
|
+
await this.connect();
|
|
210
|
+
const level = isolation.toUpperCase();
|
|
211
|
+
if (!POSTGRES_ISOLATION_LEVELS.has(level)) {
|
|
212
|
+
throw new Error(`Unsupported PostgreSQL isolation level "${isolation}".`);
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
await this.client.query(`BEGIN ISOLATION LEVEL ${level}`);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
throw new BatchWriteError(errorText(error), false);
|
|
219
|
+
}
|
|
220
|
+
const results = [];
|
|
221
|
+
try {
|
|
222
|
+
for (const operation of operations) {
|
|
223
|
+
results.push(await this.write(operation.sql, parseJson(operation.parameters, [])));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
try {
|
|
228
|
+
await this.client.query("ROLLBACK");
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
throw new BatchWriteError(errorText(error), true);
|
|
232
|
+
}
|
|
233
|
+
throw new BatchWriteError(errorText(error), false);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await this.client.query("COMMIT");
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
try {
|
|
241
|
+
await this.client.query("ROLLBACK");
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// COMMIT response was lost; rollback cannot establish outcome.
|
|
245
|
+
}
|
|
246
|
+
throw new BatchWriteError(errorText(error), true);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async signature() {
|
|
250
|
+
return "ttl";
|
|
251
|
+
}
|
|
252
|
+
async inspect(kind, table) {
|
|
253
|
+
await this.connect();
|
|
254
|
+
if (kind === "schema") {
|
|
255
|
+
const result = await this.client.query(`SELECT table_schema AS schema, table_name AS name, table_type AS type
|
|
256
|
+
FROM information_schema.tables
|
|
257
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
258
|
+
ORDER BY table_schema, table_name`);
|
|
259
|
+
return { tables: result.rows };
|
|
260
|
+
}
|
|
261
|
+
if (!table)
|
|
262
|
+
throw new Error(`Table is required for inspect ${kind}.`);
|
|
263
|
+
const [schema, name] = table.includes(".")
|
|
264
|
+
? table.split(".", 2)
|
|
265
|
+
: ["public", table];
|
|
266
|
+
const columns = await this.client.query(`SELECT column_name AS name, data_type AS type,
|
|
267
|
+
is_nullable = 'YES' AS nullable
|
|
268
|
+
FROM information_schema.columns
|
|
269
|
+
WHERE table_schema = $1 AND table_name = $2
|
|
270
|
+
ORDER BY ordinal_position`, [schema, name]);
|
|
271
|
+
if (columns.rows.length === 0) {
|
|
272
|
+
throw new Error(`Table "${table}" was not found.`);
|
|
273
|
+
}
|
|
274
|
+
if (kind === "columns")
|
|
275
|
+
return { table: name, schema, columns: columns.rows };
|
|
276
|
+
const indexes = await this.client.query(`SELECT indexname AS name, indexdef AS definition
|
|
277
|
+
FROM pg_indexes WHERE schemaname = $1 AND tablename = $2
|
|
278
|
+
ORDER BY indexname`, [schema, name]);
|
|
279
|
+
const constraints = await this.client.query(`SELECT constraint_name AS name, constraint_type AS type
|
|
280
|
+
FROM information_schema.table_constraints
|
|
281
|
+
WHERE table_schema = $1 AND table_name = $2
|
|
282
|
+
ORDER BY constraint_name`, [schema, name]);
|
|
283
|
+
if (kind === "indexes")
|
|
284
|
+
return { table: name, schema, indexes: indexes.rows };
|
|
285
|
+
if (kind === "constraints") {
|
|
286
|
+
return { table: name, schema, constraints: constraints.rows };
|
|
287
|
+
}
|
|
288
|
+
if (kind !== "table")
|
|
289
|
+
throw new Error(`Unknown inspection kind "${kind}".`);
|
|
290
|
+
return {
|
|
291
|
+
table: name,
|
|
292
|
+
schema,
|
|
293
|
+
columns: columns.rows,
|
|
294
|
+
indexes: indexes.rows.length,
|
|
295
|
+
constraints: constraints.rows.length,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async close() {
|
|
299
|
+
if (this.connected)
|
|
300
|
+
await this.client.end();
|
|
301
|
+
}
|
|
302
|
+
async connect() {
|
|
303
|
+
if (this.connected)
|
|
304
|
+
return;
|
|
305
|
+
await this.client.connect();
|
|
306
|
+
this.connected = true;
|
|
307
|
+
if (this.readOnly) {
|
|
308
|
+
await this.client.query("SET default_transaction_read_only = on");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const POSTGRES_ISOLATION_LEVELS = new Set([
|
|
313
|
+
"SERIALIZABLE",
|
|
314
|
+
"REPEATABLE READ",
|
|
315
|
+
"READ COMMITTED",
|
|
316
|
+
"READ UNCOMMITTED",
|
|
317
|
+
]);
|
|
318
|
+
function bindAll(statement, params) {
|
|
319
|
+
if (Array.isArray(params))
|
|
320
|
+
return statement.all(...params);
|
|
321
|
+
return statement.all(params);
|
|
322
|
+
}
|
|
323
|
+
function bindRun(statement, params) {
|
|
324
|
+
if (Array.isArray(params))
|
|
325
|
+
return statement.run(...params);
|
|
326
|
+
return statement.run(params);
|
|
327
|
+
}
|
|
328
|
+
function postgresParams(params) {
|
|
329
|
+
if (Array.isArray(params))
|
|
330
|
+
return params;
|
|
331
|
+
throw new Error("PostgreSQL parameters must be a JSON array.");
|
|
332
|
+
}
|
|
333
|
+
function errorText(error) {
|
|
334
|
+
return error instanceof Error ? error.message : String(error);
|
|
335
|
+
}
|
|
336
|
+
function inferType(rows, name) {
|
|
337
|
+
const value = rows.find((row) => row[name] !== null)?.[name];
|
|
338
|
+
if (value === undefined)
|
|
339
|
+
return "unknown";
|
|
340
|
+
if (Buffer.isBuffer(value))
|
|
341
|
+
return "binary";
|
|
342
|
+
return typeof value;
|
|
343
|
+
}
|
|
344
|
+
function quoteSqliteLiteral(value) {
|
|
345
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
346
|
+
}
|