@fadhilp/stateql 0.8.1 → 0.10.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 +140 -5
- package/dist/src/adapters.d.ts +8 -3
- package/dist/src/adapters.js +205 -5
- package/dist/src/cli.js +66 -3
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +18 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/migrations.js +38 -0
- package/dist/src/mongodb.d.ts +5 -3
- package/dist/src/mongodb.js +66 -4
- package/dist/src/redis.d.ts +44 -0
- package/dist/src/redis.js +395 -0
- package/dist/src/sqlite-process.js +49 -1
- package/dist/src/stateql.d.ts +56 -4
- package/dist/src/stateql.js +858 -63
- package/dist/src/store.d.ts +24 -2
- package/dist/src/store.js +92 -12
- package/dist/src/table-editor.d.ts +41 -0
- package/dist/src/table-editor.js +137 -0
- package/dist/src/types.d.ts +75 -3
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
StateQL is a stateful database CLI and TypeScript library for AI agents and
|
|
4
4
|
automation. It provides a safe interface for querying, changing, and inspecting
|
|
5
|
-
SQLite, PostgreSQL, MySQL, and
|
|
6
|
-
and operations traceable across commands.
|
|
5
|
+
SQLite, PostgreSQL, MySQL, MongoDB, and Redis databases while keeping results
|
|
6
|
+
reusable and operations traceable across commands.
|
|
7
7
|
|
|
8
8
|
StateQL is built around durable handles:
|
|
9
9
|
|
|
@@ -527,8 +527,8 @@ safety and duplicate checks. Requests contain actor and session identity, the
|
|
|
527
527
|
operation's effective read/write access, an abort signal, and sanitized
|
|
528
528
|
connection metadata.
|
|
529
529
|
|
|
530
|
-
Returned values must be complete PostgreSQL, MySQL, or
|
|
531
|
-
`sqlite:` sources. StateQL validates the source and its stored driver before
|
|
530
|
+
Returned values must be complete PostgreSQL, MySQL, MongoDB, or Redis URLs, or
|
|
531
|
+
explicit `sqlite:` sources. StateQL validates the source and its stored driver before
|
|
532
532
|
adapter construction and normalizes SQLite paths. Credential-bearing database
|
|
533
533
|
URLs are redacted before connection metadata is persisted and never enter
|
|
534
534
|
history, snapshots, cache keys, or responses. SQLite paths remain persisted
|
|
@@ -540,4 +540,139 @@ and keeping values out of their own logs and model-visible data.
|
|
|
540
540
|
For writes, credential resolution happens after StateQL atomically reserves the
|
|
541
541
|
operation for duplicate protection. A resolution failure keeps a non-executed
|
|
542
542
|
`failed` audit record, does not consume the idempotency key, and permits a safe
|
|
543
|
-
retry.
|
|
543
|
+
retry.
|
|
544
|
+
|
|
545
|
+
## Pylon database integration API (0.9.0)
|
|
546
|
+
|
|
547
|
+
### Result identities and aliases
|
|
548
|
+
|
|
549
|
+
Every materialized SQL, MongoDB, Redis, table, or derived result keeps its
|
|
550
|
+
immutable `q_*` `result_id` and receives a cryptographically random 10-character
|
|
551
|
+
lowercase base32 `display_alias`. `ResultData.alias` normally equals that alias.
|
|
552
|
+
When a batch command supplies `as`, `alias` remains the caller alias for backward
|
|
553
|
+
compatibility while `display_alias` remains canonical. Generated aliases are
|
|
554
|
+
session-scoped, allocated atomically with the result, stable on cache reuse, and
|
|
555
|
+
cannot be reassigned by `setAlias`; explicit aliases and all old handles continue
|
|
556
|
+
to resolve.
|
|
557
|
+
|
|
558
|
+
### Safe profile updates
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
updateProfile(name, {
|
|
562
|
+
target?: string | null,
|
|
563
|
+
secretEnv?: string | null,
|
|
564
|
+
credentialRef?: string | null,
|
|
565
|
+
readOnly?: boolean,
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Omitting all source fields keeps the existing source. Supplying any source field
|
|
570
|
+
replaces the source atomically: exactly one non-null source is required and the
|
|
571
|
+
other source columns are cleared. Direct non-SQLite URLs containing credentials
|
|
572
|
+
or secret-like query parameters are rejected. `profile.list/show/update` return
|
|
573
|
+
only `{profile,target,secret_env,credential_ref,read_only}`. `target` is therefore
|
|
574
|
+
a normalized SQLite path or a secret-free URL; reference-backed profiles expose
|
|
575
|
+
only the environment-variable name or opaque credential reference, never a
|
|
576
|
+
resolved value. Profile changes affect subsequent `connect` calls and do not
|
|
577
|
+
silently mutate an already-open connection.
|
|
578
|
+
|
|
579
|
+
### Bounded catalog
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
listObjects(
|
|
583
|
+
{ kind?, schema?, search?, offset?, limit? },
|
|
584
|
+
{ timeoutMs?, signal? },
|
|
585
|
+
) -> { objects, next_offset, supported_kinds }
|
|
586
|
+
|
|
587
|
+
describeObject(
|
|
588
|
+
{ kind, schema?, name, identity? },
|
|
589
|
+
{ timeoutMs?, signal? },
|
|
590
|
+
) -> { object, definition? }
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
SQL/MongoDB offsets are non-negative numbers; limits default to 50 and are at
|
|
594
|
+
most 200. Redis `offset` and `next_offset` are opaque numeric SCAN cursor strings;
|
|
595
|
+
its limit is a SCAN `COUNT` hint with a hard 200-item response bound. Redis pages
|
|
596
|
+
are not snapshots and can be empty or contain duplicates while keys change.
|
|
597
|
+
Search is a case-insensitive name substring for SQL/MongoDB and escaped glob
|
|
598
|
+
substring matching for Redis. No exact counts are forced.
|
|
599
|
+
|
|
600
|
+
Supported kinds are returned on every page: SQLite `table,view,trigger`;
|
|
601
|
+
PostgreSQL `table,view,function,trigger,enum`; MySQL
|
|
602
|
+
`table,view,function,trigger`; MongoDB `collection,view`; Redis `key`.
|
|
603
|
+
PostgreSQL function identities include identity arguments, so overloads remain
|
|
604
|
+
distinct. `describeObject` is read-only and requires the structured identity;
|
|
605
|
+
legacy `inspect` behavior is unchanged (and intentionally unavailable for Redis).
|
|
606
|
+
|
|
607
|
+
### Reviewed multi-row table edits
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
planTableUpdates(
|
|
611
|
+
Array<{ row_token: string; changes: { set?: object; unset?: string[] } }>,
|
|
612
|
+
options?,
|
|
613
|
+
) -> PlanData
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Batches contain 1-100 distinct row identities and at most 256 KiB. All tokens,
|
|
617
|
+
connection/state versions, expiries, metadata, editable columns, and values are
|
|
618
|
+
validated before one plan is stored; expiry is the earliest token expiry.
|
|
619
|
+
`apply(plan_id)` executes all conditional row updates in one SQLite/PostgreSQL/
|
|
620
|
+
MySQL transaction and requires every row predicate to match, otherwise all are
|
|
621
|
+
rolled back. MongoDB uses one snapshot transaction and rejects deployments that
|
|
622
|
+
do not support transactions. Redis and active staged StateQL transactions are
|
|
623
|
+
rejected. The existing `planTableUpdate` and `apply` APIs remain supported.
|
|
624
|
+
Plans are actor-owned, claimed once, and retained as non-replayable when the
|
|
625
|
+
remote commit outcome is uncertain.
|
|
626
|
+
|
|
627
|
+
### Redis native commands
|
|
628
|
+
|
|
629
|
+
Redis/Rediss URLs support URL database selection, password or ACL username,
|
|
630
|
+
and TLS (`rediss`). Credential-bearing URLs must come from `secretEnv` or
|
|
631
|
+
`credentialRef`. Native methods accept `{command: string, args?: string[]}`:
|
|
632
|
+
|
|
633
|
+
- `redisQuery`: `GET`, `MGET`, `TYPE`, `EXISTS`, `TTL`, `PTTL`, `HGET`, `HMGET`,
|
|
634
|
+
bounded `LRANGE`, and bounded `SCAN`/`HSCAN`/`SSCAN`/`ZSCAN`.
|
|
635
|
+
- `redisExec` and `redisPlan`: one-key `SET`, `DEL`, `HSET`, `HDEL`, `LPUSH`,
|
|
636
|
+
`RPUSH`, `SADD`, `SREM`, `ZADD`, or `ZREM` mutation.
|
|
637
|
+
- `describeObject({kind:"key",name})`: bounded string/hash/list/set/zset value
|
|
638
|
+
inspection with TTL and continuation metadata where applicable.
|
|
639
|
+
|
|
640
|
+
Arguments are UTF-8 strings, at most 100 values/256 KiB; materialized replies are
|
|
641
|
+
at most 1 MiB. `KEYS`, scripts, modules, pub/sub, blocking commands, admin/flush,
|
|
642
|
+
and arbitrary commands are rejected. Key discovery always uses SCAN. A Redis
|
|
643
|
+
plan snapshots one bounded key and `apply` uses an isolated `WATCH` + one-command
|
|
644
|
+
`MULTI/EXEC`; a pre-apply content or expiry change returns `ROW_CONFLICT` and is
|
|
645
|
+
never retried automatically. Direct `redisExec` has Redis single-command
|
|
646
|
+
atomicity only. Redis has no SQL rollback or StateQL staged transaction support;
|
|
647
|
+
a lost write/EXEC reply is reported as `OUTCOME_UNKNOWN` and remains blocked.
|
|
648
|
+
|
|
649
|
+
### Lean history
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
history(limit?, {
|
|
653
|
+
origin?,
|
|
654
|
+
category?: "statement" | "introspection" | "management",
|
|
655
|
+
internal?: boolean,
|
|
656
|
+
offset?: number,
|
|
657
|
+
})
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`category` and trusted-host `internal` filters are applied in SQLite before
|
|
661
|
+
`ORDER BY`, `LIMIT`, and `OFFSET`, so introspection cannot starve statement
|
|
662
|
+
history. `CommandExecutionContext.internal` is trusted host metadata and cannot
|
|
663
|
+
be supplied inside a batch command. Existing calls and origin filtering remain
|
|
664
|
+
compatible; old rows are classified from their command name and migrate as `internal: false`.
|
|
665
|
+
|
|
666
|
+
The synchronous, non-mutating snapshot bridge accepts the same classification
|
|
667
|
+
filters without entering the command queue or writing a history row:
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
stateql.snapshot({
|
|
671
|
+
historyLimit: 50,
|
|
672
|
+
historyCategory: "statement",
|
|
673
|
+
historyInternal: false,
|
|
674
|
+
});
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Both snapshot filters are applied by the store before `historyLimit`. Calling
|
|
678
|
+
`snapshot()` with no options preserves the legacy 50-entry CLI snapshot.
|
package/dist/src/adapters.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Column, Row, SqlParameters, StateConfidence } from "./types.js";
|
|
1
|
+
import type { CatalogObject, DescribeObjectData, ListObjectsData, ListObjectsFilter, Column, Row, SqlParameters, StateConfidence } from "./types.js";
|
|
2
2
|
import type { ConnectionRecord, OperationRecord } from "./store.js";
|
|
3
3
|
export interface ReadResult {
|
|
4
4
|
rows: Row[];
|
|
@@ -7,6 +7,9 @@ export interface ReadResult {
|
|
|
7
7
|
export interface WriteResult {
|
|
8
8
|
affectedRows: number;
|
|
9
9
|
}
|
|
10
|
+
export type BatchWriteOperation = OperationRecord & {
|
|
11
|
+
expectedRows?: 1;
|
|
12
|
+
};
|
|
10
13
|
export interface AdapterContext {
|
|
11
14
|
deadline: number;
|
|
12
15
|
timeoutMs?: number;
|
|
@@ -29,10 +32,12 @@ export interface Adapter {
|
|
|
29
32
|
readonly confidence: StateConfidence;
|
|
30
33
|
ping(): Promise<void>;
|
|
31
34
|
read(sql: string, params: SqlParameters): Promise<ReadResult>;
|
|
32
|
-
write(sql: string, params: SqlParameters): Promise<WriteResult>;
|
|
33
|
-
writeBatch(operations:
|
|
35
|
+
write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
|
|
36
|
+
writeBatch(operations: BatchWriteOperation[], isolation: string): Promise<WriteResult[]>;
|
|
34
37
|
signature(): Promise<string>;
|
|
35
38
|
inspect(kind: string, table?: string): Promise<unknown>;
|
|
39
|
+
listObjects(filter: ListObjectsFilter): Promise<ListObjectsData>;
|
|
40
|
+
describeObject(object: CatalogObject): Promise<DescribeObjectData>;
|
|
36
41
|
close(): Promise<void>;
|
|
37
42
|
}
|
|
38
43
|
export declare function createAdapterContext(timeoutMs: number, signal?: AbortSignal): AdapterContext;
|
package/dist/src/adapters.js
CHANGED
|
@@ -47,6 +47,8 @@ export async function createAdapter(connection, context, input) {
|
|
|
47
47
|
return new MySqlAdapter(source, Boolean(connection.read_only), context);
|
|
48
48
|
case "mongodb":
|
|
49
49
|
throw new StateQLError("UNSUPPORTED_DRIVER", "MongoDB uses the native MongoDB adapter.");
|
|
50
|
+
case "redis":
|
|
51
|
+
throw new StateQLError("UNSUPPORTED_DRIVER", "Redis uses the native Redis adapter.");
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
class SQLiteAdapter {
|
|
@@ -96,8 +98,8 @@ class SQLiteAdapter {
|
|
|
96
98
|
async read(sql, params) {
|
|
97
99
|
return this.call("read", [sql, params], false, false);
|
|
98
100
|
}
|
|
99
|
-
async write(sql, params) {
|
|
100
|
-
return this.call("write", [sql, params], true, false);
|
|
101
|
+
async write(sql, params, expectedRows) {
|
|
102
|
+
return this.call("write", [sql, params, expectedRows], true, false);
|
|
101
103
|
}
|
|
102
104
|
async writeBatch(operations, isolation) {
|
|
103
105
|
return this.call("writeBatch", [operations, isolation], true, true);
|
|
@@ -108,6 +110,12 @@ class SQLiteAdapter {
|
|
|
108
110
|
async inspect(kind, table) {
|
|
109
111
|
return this.call("inspect", [kind, table], false, false);
|
|
110
112
|
}
|
|
113
|
+
async listObjects(filter) {
|
|
114
|
+
return this.call("listObjects", [filter], false, false);
|
|
115
|
+
}
|
|
116
|
+
async describeObject(object) {
|
|
117
|
+
return this.call("describeObject", [object], false, false);
|
|
118
|
+
}
|
|
111
119
|
async close() {
|
|
112
120
|
if (this.closePromise)
|
|
113
121
|
return this.closePromise;
|
|
@@ -251,7 +259,7 @@ class PostgresAdapter {
|
|
|
251
259
|
throw error;
|
|
252
260
|
}
|
|
253
261
|
}
|
|
254
|
-
async write(sql, params) {
|
|
262
|
+
async write(sql, params, expectedRows) {
|
|
255
263
|
if (this.readOnly)
|
|
256
264
|
throw new Error("Connection is read-only.");
|
|
257
265
|
try {
|
|
@@ -267,6 +275,8 @@ class PostgresAdapter {
|
|
|
267
275
|
try {
|
|
268
276
|
await this.setLocalDeadline();
|
|
269
277
|
const result = await this.query(sql, postgresParams(params), true);
|
|
278
|
+
if (expectedRows === 1 && result.rowCount !== 1)
|
|
279
|
+
throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
|
|
270
280
|
committing = true;
|
|
271
281
|
await this.query("COMMIT", [], true);
|
|
272
282
|
return { affectedRows: result.rowCount ?? 0 };
|
|
@@ -304,6 +314,8 @@ class PostgresAdapter {
|
|
|
304
314
|
await this.setLocalDeadline();
|
|
305
315
|
const result = await this.query(operation.sql, postgresParams(parseJson(operation.parameters, `operation "${operation.id}" parameters`, isSqlParameters)), true);
|
|
306
316
|
results.push({ affectedRows: result.rowCount ?? 0 });
|
|
317
|
+
if (operation.expectedRows === 1 && result.rowCount !== 1)
|
|
318
|
+
throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
|
|
307
319
|
}
|
|
308
320
|
}
|
|
309
321
|
catch (error) {
|
|
@@ -338,6 +350,67 @@ class PostgresAdapter {
|
|
|
338
350
|
throw error;
|
|
339
351
|
}
|
|
340
352
|
}
|
|
353
|
+
async listObjects(filter) {
|
|
354
|
+
if (filter.kind && !["table", "view", "function", "trigger", "enum"].includes(filter.kind))
|
|
355
|
+
throw new Error(`PostgreSQL does not support catalog kind "${filter.kind}".`);
|
|
356
|
+
const { where, params } = postgresCatalogFilter(filter);
|
|
357
|
+
const limit = catalogLimit(filter.limit);
|
|
358
|
+
const offset = catalogOffset(filter.offset);
|
|
359
|
+
const result = await this.read(`SELECT * FROM (
|
|
360
|
+
SELECT CASE WHEN table_type = 'VIEW' THEN 'view' ELSE 'table' END AS kind,
|
|
361
|
+
table_schema AS schema, table_name AS name,
|
|
362
|
+
table_schema || '.' || table_name AS identity
|
|
363
|
+
FROM information_schema.tables
|
|
364
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
365
|
+
UNION ALL
|
|
366
|
+
SELECT 'function', n.nspname, p.proname,
|
|
367
|
+
n.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')'
|
|
368
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
369
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND p.prokind = 'f'
|
|
370
|
+
UNION ALL
|
|
371
|
+
SELECT DISTINCT 'trigger', event_object_schema, trigger_name,
|
|
372
|
+
event_object_schema || '.' || event_object_table || '.' || trigger_name
|
|
373
|
+
FROM information_schema.triggers
|
|
374
|
+
UNION ALL
|
|
375
|
+
SELECT 'enum', n.nspname, t.typname, n.nspname || '.' || t.typname
|
|
376
|
+
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e'
|
|
377
|
+
) objects ${where} ORDER BY schema, kind, name, identity LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, limit + 1, offset]);
|
|
378
|
+
return catalogPage(result.rows, limit, offset, ["table", "view", "function", "trigger", "enum"]);
|
|
379
|
+
}
|
|
380
|
+
async describeObject(object) {
|
|
381
|
+
const schema = object.schema ?? "public";
|
|
382
|
+
if (object.kind === "function") {
|
|
383
|
+
const result = await this.read(`SELECT pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
384
|
+
WHERE n.nspname = $1 AND p.proname = $2
|
|
385
|
+
AND n.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' = $3`, [schema, object.name, object.identity ?? ""]);
|
|
386
|
+
if (!result.rows[0])
|
|
387
|
+
throw new Error("Catalog object was not found.");
|
|
388
|
+
return { object, definition: String(result.rows[0].definition) };
|
|
389
|
+
}
|
|
390
|
+
if (object.kind === "trigger") {
|
|
391
|
+
const result = await this.read(`SELECT pg_get_triggerdef(t.oid, true) AS definition FROM pg_trigger t
|
|
392
|
+
JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
393
|
+
WHERE NOT t.tgisinternal AND n.nspname=$1 AND t.tgname=$2
|
|
394
|
+
AND n.nspname || '.' || c.relname || '.' || t.tgname=$3`, [schema, object.name, object.identity ?? ""]);
|
|
395
|
+
if (!result.rows[0])
|
|
396
|
+
throw new Error("Catalog object was not found.");
|
|
397
|
+
return { object, definition: String(result.rows[0].definition) };
|
|
398
|
+
}
|
|
399
|
+
if (object.kind === "enum") {
|
|
400
|
+
const result = await this.read(`SELECT e.enumlabel AS value FROM pg_enum e JOIN pg_type t ON t.oid=e.enumtypid JOIN pg_namespace n ON n.oid=t.typnamespace
|
|
401
|
+
WHERE n.nspname=$1 AND t.typname=$2 ORDER BY e.enumsortorder`, [schema, object.name]);
|
|
402
|
+
return { object, definition: result.rows };
|
|
403
|
+
}
|
|
404
|
+
if (object.kind === "view") {
|
|
405
|
+
const result = await this.read("SELECT view_definition AS definition FROM information_schema.views WHERE table_schema=$1 AND table_name=$2", [schema, object.name]);
|
|
406
|
+
if (!result.rows[0])
|
|
407
|
+
throw new Error("Catalog object was not found.");
|
|
408
|
+
return { object, definition: String(result.rows[0].definition) };
|
|
409
|
+
}
|
|
410
|
+
if (object.kind === "table")
|
|
411
|
+
return { object, definition: await this.inspect("table", `${schema}.${object.name}`) };
|
|
412
|
+
throw new Error(`PostgreSQL does not support catalog kind "${object.kind}".`);
|
|
413
|
+
}
|
|
341
414
|
async close() {
|
|
342
415
|
if (!this.ending) {
|
|
343
416
|
this.ending = this.client.end().catch(() => undefined);
|
|
@@ -358,6 +431,18 @@ class PostgresAdapter {
|
|
|
358
431
|
const [schema, name] = table.includes(".")
|
|
359
432
|
? table.split(".", 2)
|
|
360
433
|
: ["public", table];
|
|
434
|
+
if (kind === "editable") {
|
|
435
|
+
await this.setLocalDeadline();
|
|
436
|
+
const result = await this.query(`SELECT c.column_name AS name, c.data_type AS type, c.is_nullable = 'YES' AS nullable,
|
|
437
|
+
(c.is_generated = 'ALWAYS' OR c.is_identity = 'YES') AS generated,
|
|
438
|
+
COALESCE(k.ordinal_position, 0) AS key
|
|
439
|
+
FROM information_schema.columns c
|
|
440
|
+
LEFT JOIN information_schema.key_column_usage k ON k.table_schema = c.table_schema AND k.table_name = c.table_name AND k.column_name = c.column_name
|
|
441
|
+
AND EXISTS (SELECT 1 FROM information_schema.table_constraints t WHERE t.constraint_schema = k.constraint_schema AND t.constraint_name = k.constraint_name AND t.table_name = k.table_name AND t.constraint_type = 'PRIMARY KEY')
|
|
442
|
+
WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`, [schema, name], false);
|
|
443
|
+
const objects = await this.query("SELECT table_type FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2", [schema, name], false);
|
|
444
|
+
return { writable: objects.rows[0]?.table_type === "BASE TABLE", columns: result.rows.map(column => ({ ...column, key: Number(column.key) })) };
|
|
445
|
+
}
|
|
361
446
|
await this.setLocalDeadline();
|
|
362
447
|
const columns = await this.query(`SELECT column_name AS name, data_type AS type,
|
|
363
448
|
is_nullable = 'YES' AS nullable
|
|
@@ -472,7 +557,7 @@ class MySqlAdapter {
|
|
|
472
557
|
throw error;
|
|
473
558
|
}
|
|
474
559
|
}
|
|
475
|
-
async write(sql, params) {
|
|
560
|
+
async write(sql, params, expectedRows) {
|
|
476
561
|
if (this.readOnly)
|
|
477
562
|
throw new Error("Connection is read-only.");
|
|
478
563
|
let values;
|
|
@@ -490,13 +575,22 @@ class MySqlAdapter {
|
|
|
490
575
|
throw error;
|
|
491
576
|
throw new AdapterWriteError(errorText(error), false);
|
|
492
577
|
}
|
|
578
|
+
let committing = false;
|
|
493
579
|
try {
|
|
494
580
|
const [result] = await this.query(sql, values, true, true);
|
|
581
|
+
if (expectedRows === 1 && mysqlAffectedRows(result) !== 1)
|
|
582
|
+
throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
|
|
583
|
+
committing = true;
|
|
495
584
|
await this.query("COMMIT", [], true, false);
|
|
496
585
|
return { affectedRows: mysqlAffectedRows(result) };
|
|
497
586
|
}
|
|
498
587
|
catch (error) {
|
|
499
|
-
await this.rollbackQuietly();
|
|
588
|
+
const rolledBack = await this.rollbackQuietly();
|
|
589
|
+
if (!committing && rolledBack) {
|
|
590
|
+
if (error instanceof AdapterExecutionError)
|
|
591
|
+
throw new AdapterExecutionError(error.message, error.reason, false);
|
|
592
|
+
throw new AdapterWriteError(errorText(error), false);
|
|
593
|
+
}
|
|
500
594
|
if (error instanceof AdapterExecutionError)
|
|
501
595
|
throw error;
|
|
502
596
|
throw new AdapterWriteError(errorText(error), true);
|
|
@@ -525,6 +619,8 @@ class MySqlAdapter {
|
|
|
525
619
|
for (const operation of operations) {
|
|
526
620
|
const [result] = await this.query(operation.sql, mysqlParams(parseJson(operation.parameters, `operation "${operation.id}" parameters`, isSqlParameters)), true, true);
|
|
527
621
|
results.push({ affectedRows: mysqlAffectedRows(result) });
|
|
622
|
+
if (operation.expectedRows === 1 && mysqlAffectedRows(result) !== 1)
|
|
623
|
+
throw new Error("ROW_CONFLICT: The row changed or no longer has a unique identity.");
|
|
528
624
|
}
|
|
529
625
|
}
|
|
530
626
|
catch (error) {
|
|
@@ -558,6 +654,62 @@ class MySqlAdapter {
|
|
|
558
654
|
throw error;
|
|
559
655
|
}
|
|
560
656
|
}
|
|
657
|
+
async listObjects(filter) {
|
|
658
|
+
if (filter.kind && !["table", "view", "function", "trigger"].includes(filter.kind))
|
|
659
|
+
throw new Error(`MySQL does not support catalog kind "${filter.kind}".`);
|
|
660
|
+
const limit = catalogLimit(filter.limit);
|
|
661
|
+
const offset = catalogOffset(filter.offset);
|
|
662
|
+
const predicates = ["1=1"];
|
|
663
|
+
const params = [];
|
|
664
|
+
if (filter.kind) {
|
|
665
|
+
predicates.push("kind = ?");
|
|
666
|
+
params.push(filter.kind);
|
|
667
|
+
}
|
|
668
|
+
if (filter.schema) {
|
|
669
|
+
predicates.push("schema_name = ?");
|
|
670
|
+
params.push(filter.schema);
|
|
671
|
+
}
|
|
672
|
+
if (filter.search) {
|
|
673
|
+
predicates.push("name LIKE ? ESCAPE '\\\\'");
|
|
674
|
+
params.push(`%${escapeLike(filter.search)}%`);
|
|
675
|
+
}
|
|
676
|
+
const result = await this.read(`SELECT kind, schema_name AS schema, name, identity FROM (
|
|
677
|
+
SELECT CASE WHEN TABLE_TYPE='VIEW' THEN 'view' ELSE 'table' END AS kind, TABLE_SCHEMA AS schema_name,
|
|
678
|
+
TABLE_NAME AS name, CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS identity
|
|
679
|
+
FROM information_schema.tables WHERE TABLE_SCHEMA=DATABASE()
|
|
680
|
+
UNION ALL
|
|
681
|
+
SELECT 'function', ROUTINE_SCHEMA, ROUTINE_NAME, CONCAT(ROUTINE_SCHEMA, '.', ROUTINE_NAME)
|
|
682
|
+
FROM information_schema.routines WHERE ROUTINE_SCHEMA=DATABASE() AND ROUTINE_TYPE='FUNCTION'
|
|
683
|
+
UNION ALL
|
|
684
|
+
SELECT 'trigger', TRIGGER_SCHEMA, TRIGGER_NAME, CONCAT(TRIGGER_SCHEMA, '.', EVENT_OBJECT_TABLE, '.', TRIGGER_NAME)
|
|
685
|
+
FROM information_schema.triggers WHERE TRIGGER_SCHEMA=DATABASE()
|
|
686
|
+
) objects WHERE ${predicates.join(" AND ")} ORDER BY schema_name, kind, name, identity LIMIT ? OFFSET ?`, [...params, limit + 1, offset]);
|
|
687
|
+
return catalogPage(result.rows, limit, offset, ["table", "view", "function", "trigger"]);
|
|
688
|
+
}
|
|
689
|
+
async describeObject(object) {
|
|
690
|
+
const schema = object.schema ?? await this.databaseName();
|
|
691
|
+
if (object.kind === "function") {
|
|
692
|
+
const result = await this.read("SELECT ROUTINE_DEFINITION AS definition FROM information_schema.routines WHERE ROUTINE_SCHEMA=? AND ROUTINE_NAME=? AND ROUTINE_TYPE='FUNCTION'", [schema, object.name]);
|
|
693
|
+
if (!result.rows[0])
|
|
694
|
+
throw new Error("Catalog object was not found.");
|
|
695
|
+
return { object, definition: String(result.rows[0].definition ?? "") };
|
|
696
|
+
}
|
|
697
|
+
if (object.kind === "trigger") {
|
|
698
|
+
const result = await this.read("SELECT ACTION_STATEMENT AS definition FROM information_schema.triggers WHERE TRIGGER_SCHEMA=? AND TRIGGER_NAME=?", [schema, object.name]);
|
|
699
|
+
if (!result.rows[0])
|
|
700
|
+
throw new Error("Catalog object was not found.");
|
|
701
|
+
return { object, definition: String(result.rows[0].definition ?? "") };
|
|
702
|
+
}
|
|
703
|
+
if (object.kind === "view") {
|
|
704
|
+
const result = await this.read("SELECT VIEW_DEFINITION AS definition FROM information_schema.views WHERE TABLE_SCHEMA=? AND TABLE_NAME=?", [schema, object.name]);
|
|
705
|
+
if (!result.rows[0])
|
|
706
|
+
throw new Error("Catalog object was not found.");
|
|
707
|
+
return { object, definition: String(result.rows[0].definition ?? "") };
|
|
708
|
+
}
|
|
709
|
+
if (object.kind === "table")
|
|
710
|
+
return { object, definition: await this.inspect("table", `${schema}.${object.name}`) };
|
|
711
|
+
throw new Error(`MySQL does not support catalog kind "${object.kind}".`);
|
|
712
|
+
}
|
|
561
713
|
async close() {
|
|
562
714
|
if (this.closed)
|
|
563
715
|
return;
|
|
@@ -584,6 +736,16 @@ class MySqlAdapter {
|
|
|
584
736
|
const name = separator === -1 ? table : table.slice(separator + 1);
|
|
585
737
|
if (!schema || !name)
|
|
586
738
|
throw new Error(`Invalid table name "${table}".`);
|
|
739
|
+
if (kind === "editable") {
|
|
740
|
+
const [result] = await this.query(`SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS type, c.IS_NULLABLE = 'YES' AS nullable,
|
|
741
|
+
c.EXTRA LIKE '%GENERATED%' AS \`generated\`, COALESCE(k.ORDINAL_POSITION, 0) AS \`key\`
|
|
742
|
+
FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage k
|
|
743
|
+
ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME AND k.CONSTRAINT_NAME = 'PRIMARY'
|
|
744
|
+
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`, [schema, name], false, true);
|
|
745
|
+
const [objects] = await this.query("SELECT ENGINE AS engine FROM information_schema.tables WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", [schema, name], false, true);
|
|
746
|
+
return { writable: String(mysqlRows(objects)[0]?.engine).toLowerCase() === "innodb",
|
|
747
|
+
columns: mysqlRows(result).map(column => ({ ...column, nullable: Boolean(column.nullable), generated: Boolean(column.generated), key: Number(column.key) })) };
|
|
748
|
+
}
|
|
587
749
|
const [columnResult] = await this.query(`SELECT COLUMN_NAME AS name, DATA_TYPE AS type,
|
|
588
750
|
IS_NULLABLE = 'YES' AS nullable
|
|
589
751
|
FROM information_schema.columns
|
|
@@ -752,6 +914,44 @@ function postgresParams(params) {
|
|
|
752
914
|
return params;
|
|
753
915
|
throw new Error("PostgreSQL parameters must be a JSON array.");
|
|
754
916
|
}
|
|
917
|
+
function catalogLimit(value) {
|
|
918
|
+
const limit = value ?? 50;
|
|
919
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)
|
|
920
|
+
throw new Error("Catalog limit must be 1-200.");
|
|
921
|
+
return limit;
|
|
922
|
+
}
|
|
923
|
+
function catalogOffset(value) {
|
|
924
|
+
const offset = value ?? 0;
|
|
925
|
+
if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000)
|
|
926
|
+
throw new Error("SQL catalog offset must be a non-negative integer at most 1000000.");
|
|
927
|
+
return offset;
|
|
928
|
+
}
|
|
929
|
+
function catalogPage(objects, limit, offset, supportedKinds) {
|
|
930
|
+
const more = objects.length > limit;
|
|
931
|
+
return { objects: objects.slice(0, limit), next_offset: more ? offset + limit : null, supported_kinds: supportedKinds };
|
|
932
|
+
}
|
|
933
|
+
function escapeLike(value) {
|
|
934
|
+
if (!value || value.length > 200 || value.includes("\0"))
|
|
935
|
+
throw new Error("Catalog search must be 1-200 characters.");
|
|
936
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
937
|
+
}
|
|
938
|
+
function postgresCatalogFilter(filter) {
|
|
939
|
+
const predicates = [];
|
|
940
|
+
const params = [];
|
|
941
|
+
if (filter.kind) {
|
|
942
|
+
params.push(filter.kind);
|
|
943
|
+
predicates.push(`kind = $${params.length}`);
|
|
944
|
+
}
|
|
945
|
+
if (filter.schema) {
|
|
946
|
+
params.push(filter.schema);
|
|
947
|
+
predicates.push(`schema = $${params.length}`);
|
|
948
|
+
}
|
|
949
|
+
if (filter.search) {
|
|
950
|
+
params.push(`%${escapeLike(filter.search)}%`);
|
|
951
|
+
predicates.push(`name ILIKE $${params.length} ESCAPE '\\'`);
|
|
952
|
+
}
|
|
953
|
+
return { where: predicates.length ? `WHERE ${predicates.join(" AND ")}` : "", params };
|
|
954
|
+
}
|
|
755
955
|
function remainingMilliseconds(context) {
|
|
756
956
|
return Math.max(1, Math.ceil(context.deadline - Date.now()));
|
|
757
957
|
}
|
package/dist/src/cli.js
CHANGED
|
@@ -26,6 +26,12 @@ const parsed = parseArgs({
|
|
|
26
26
|
"allow-destructive": { type: "boolean" },
|
|
27
27
|
offset: { type: "string" },
|
|
28
28
|
limit: { type: "string" },
|
|
29
|
+
cursor: { type: "string" },
|
|
30
|
+
schema: { type: "string" },
|
|
31
|
+
search: { type: "string" },
|
|
32
|
+
category: { type: "string" },
|
|
33
|
+
internal: { type: "boolean" },
|
|
34
|
+
external: { type: "boolean" },
|
|
29
35
|
"timeout-ms": { type: "string" },
|
|
30
36
|
"max-state-bytes": { type: "string" },
|
|
31
37
|
"cache-ttl-seconds": { type: "string" },
|
|
@@ -163,6 +169,8 @@ async function dispatch() {
|
|
|
163
169
|
return dispatchSession(subcommand, rest);
|
|
164
170
|
case "mongo":
|
|
165
171
|
return dispatchMongo(subcommand, rest);
|
|
172
|
+
case "redis":
|
|
173
|
+
return dispatchRedis(subcommand, rest);
|
|
166
174
|
case "query":
|
|
167
175
|
return stateql.query(sql, {
|
|
168
176
|
params,
|
|
@@ -199,6 +207,23 @@ async function dispatch() {
|
|
|
199
207
|
return stateql.setAlias(requireValue(rest[0], "alias"), requireValue(rest[1], "result handle"));
|
|
200
208
|
case "inspect":
|
|
201
209
|
return stateql.inspect(normalizeInspectKind(requireValue(subcommand, "inspection kind")), rest[0]);
|
|
210
|
+
case "objects":
|
|
211
|
+
return stateql.listObjects({
|
|
212
|
+
...(subcommand ? { kind: subcommand } : {}),
|
|
213
|
+
...(values.schema ? { schema: values.schema } : {}),
|
|
214
|
+
...(values.search ? { search: values.search } : {}),
|
|
215
|
+
offset: values.cursor ?? numberOption(values.offset, 0),
|
|
216
|
+
limit: numberOption(values.limit, 50),
|
|
217
|
+
});
|
|
218
|
+
case "object": {
|
|
219
|
+
const object = {
|
|
220
|
+
kind: requireValue(subcommand, "object kind"),
|
|
221
|
+
name: requireValue(rest[0], "object name"),
|
|
222
|
+
...(values.schema ? { schema: values.schema } : {}),
|
|
223
|
+
...(rest[1] ? { identity: rest[1] } : {}),
|
|
224
|
+
};
|
|
225
|
+
return stateql.describeObject(object);
|
|
226
|
+
}
|
|
202
227
|
case "transaction":
|
|
203
228
|
return dispatchTransaction(subcommand, rest[0]);
|
|
204
229
|
case "plan":
|
|
@@ -212,7 +237,13 @@ async function dispatch() {
|
|
|
212
237
|
case "apply":
|
|
213
238
|
return stateql.apply(requireValue(subcommand, "plan handle"));
|
|
214
239
|
case "history":
|
|
215
|
-
|
|
240
|
+
if (values.internal && values.external)
|
|
241
|
+
throw new Error("--internal and --external are mutually exclusive.");
|
|
242
|
+
return stateql.history(numberOption(values.limit, 20), {
|
|
243
|
+
...(values.category ? { category: values.category } : {}),
|
|
244
|
+
...(values.internal ? { internal: true } : values.external ? { internal: false } : {}),
|
|
245
|
+
offset: numberOption(values.offset, 0),
|
|
246
|
+
});
|
|
216
247
|
case "receipt":
|
|
217
248
|
return stateql.receipt(requireValue(subcommand, "operation handle"));
|
|
218
249
|
case "doctor":
|
|
@@ -252,6 +283,26 @@ async function dispatchMongo(action, args) {
|
|
|
252
283
|
throw new Error(`Unknown MongoDB command "${action ?? ""}".`);
|
|
253
284
|
}
|
|
254
285
|
}
|
|
286
|
+
async function dispatchRedis(action, args) {
|
|
287
|
+
const payload = parseRedisCommand(requireValue(args.join(" ").trim(), "Redis JSON command"));
|
|
288
|
+
switch (action) {
|
|
289
|
+
case "query": return stateql.redisQuery(payload, { cache: cacheMode(values.cache) });
|
|
290
|
+
case "exec": return stateql.redisExec(payload, { replay: values.replay ?? false, ...(values["idempotency-key"] ? { idempotencyKey: values["idempotency-key"] } : {}) });
|
|
291
|
+
case "plan": return stateql.redisPlan(payload);
|
|
292
|
+
default: throw new Error(`Unknown Redis command "${action ?? ""}".`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function parseRedisCommand(value) {
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(value);
|
|
298
|
+
if (!parsed || typeof parsed !== "object")
|
|
299
|
+
throw new Error();
|
|
300
|
+
return parsed;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
throw new Error("Invalid Redis JSON command.");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
255
306
|
function parseMongoCommand(value) {
|
|
256
307
|
try {
|
|
257
308
|
return BSON.EJSON.parse(value, { relaxed: false });
|
|
@@ -287,6 +338,15 @@ async function dispatchProfile(action, args) {
|
|
|
287
338
|
...(values["credential-ref"] ? { credentialRef: values["credential-ref"] } : {}),
|
|
288
339
|
readOnly: !values["read-write"],
|
|
289
340
|
});
|
|
341
|
+
case "update":
|
|
342
|
+
if (values["read-only"] && values["read-write"])
|
|
343
|
+
throw new Error("--read-only and --read-write are mutually exclusive.");
|
|
344
|
+
return stateql.updateProfile(requireValue(args[0], "profile name"), {
|
|
345
|
+
...(args[1] !== undefined ? { target: args[1] } : {}),
|
|
346
|
+
...(values.env ? { secretEnv: values.env } : {}),
|
|
347
|
+
...(values["credential-ref"] ? { credentialRef: values["credential-ref"] } : {}),
|
|
348
|
+
...(values["read-only"] ? { readOnly: true } : values["read-write"] ? { readOnly: false } : {}),
|
|
349
|
+
});
|
|
290
350
|
case "list":
|
|
291
351
|
return stateql.listProfiles();
|
|
292
352
|
case "show":
|
|
@@ -581,14 +641,17 @@ Usage: stql <command> [arguments] [options]
|
|
|
581
641
|
Commands:
|
|
582
642
|
connect TARGET | --env ENV | --credential-ref REF | --profile NAME
|
|
583
643
|
disconnect, status
|
|
584
|
-
profile add NAME [TARGET | --env ENV | --credential-ref REF]
|
|
644
|
+
profile add|update NAME [TARGET | --env ENV | --credential-ref REF]
|
|
585
645
|
profile list|show|remove
|
|
586
646
|
query, filter, exec, show, rows, count, columns, export
|
|
587
647
|
mongo query|exec|plan '<EJSON command>'
|
|
648
|
+
redis query|exec|plan '<JSON command>'
|
|
588
649
|
alias set
|
|
589
650
|
inspect schema|table|collection|collections|columns|indexes|constraints
|
|
651
|
+
objects [KIND] [--schema NAME] [--search TEXT] [--offset N|--cursor CURSOR]
|
|
652
|
+
object KIND NAME [IDENTITY] [--schema NAME]
|
|
590
653
|
transaction begin|status|commit|rollback
|
|
591
|
-
plan, apply, history, receipt, doctor, purge, capabilities
|
|
654
|
+
plan, apply, history [--category CATEGORY] [--internal|--external], receipt, doctor, purge, capabilities
|
|
592
655
|
batch [file.json|file.jsonl|-]
|
|
593
656
|
pipe
|
|
594
657
|
|
package/dist/src/connection.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CredentialSource, Driver, StateConfidence } from "./types.js";
|
|
|
3
3
|
export declare function databaseIdentity(connection: ConnectionRecord): unknown;
|
|
4
4
|
export declare function detectDriver(target: string): Driver;
|
|
5
5
|
export declare function mongoDatabaseName(target: string): string;
|
|
6
|
+
export declare function redisDatabaseName(target: string): string;
|
|
6
7
|
export declare function credentialSource(value: string, expectedDriver?: Driver, referenceSource?: CredentialSource): {
|
|
7
8
|
driver: Driver;
|
|
8
9
|
source: string;
|