@fadhilp/stateql 0.9.0 → 0.10.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/README.md +149 -5
- package/dist/src/adapters.d.ts +7 -2
- package/dist/src/adapters.js +167 -0
- 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 +44 -0
- package/dist/src/mongodb.d.ts +4 -2
- package/dist/src/mongodb.js +53 -2
- package/dist/src/redis.d.ts +44 -0
- package/dist/src/redis.js +395 -0
- package/dist/src/sqlite-process.js +41 -0
- package/dist/src/stateql.d.ts +18 -4
- package/dist/src/stateql.js +650 -62
- package/dist/src/store.d.ts +25 -2
- package/dist/src/store.js +116 -12
- package/dist/src/table-editor.d.ts +5 -0
- package/dist/src/table-editor.js +22 -0
- package/dist/src/types.d.ts +80 -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,148 @@ 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
|
+
Connections likewise retain canonical `conn_*` IDs and receive persistent random
|
|
559
|
+
10-character lowercase base32 aliases, exposed as `alias` and `display_alias` by
|
|
560
|
+
`connect()` and `snapshot().connection` (optional in snapshot types for older
|
|
561
|
+
producers). Connection aliases are unique within the state store, allocated
|
|
562
|
+
atomically with the connection, and backfilled for existing records on startup.
|
|
563
|
+
They survive reopening; reconnecting creates a new ID and alias. They are display
|
|
564
|
+
identities only, separate from result aliases; internal references and lookups
|
|
565
|
+
continue to use canonical connection IDs.
|
|
566
|
+
|
|
567
|
+
### Safe profile updates
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
updateProfile(name, {
|
|
571
|
+
target?: string | null,
|
|
572
|
+
secretEnv?: string | null,
|
|
573
|
+
credentialRef?: string | null,
|
|
574
|
+
readOnly?: boolean,
|
|
575
|
+
})
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Omitting all source fields keeps the existing source. Supplying any source field
|
|
579
|
+
replaces the source atomically: exactly one non-null source is required and the
|
|
580
|
+
other source columns are cleared. Direct non-SQLite URLs containing credentials
|
|
581
|
+
or secret-like query parameters are rejected. `profile.list/show/update` return
|
|
582
|
+
only `{profile,target,secret_env,credential_ref,read_only}`. `target` is therefore
|
|
583
|
+
a normalized SQLite path or a secret-free URL; reference-backed profiles expose
|
|
584
|
+
only the environment-variable name or opaque credential reference, never a
|
|
585
|
+
resolved value. Profile changes affect subsequent `connect` calls and do not
|
|
586
|
+
silently mutate an already-open connection.
|
|
587
|
+
|
|
588
|
+
### Bounded catalog
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
listObjects(
|
|
592
|
+
{ kind?, schema?, search?, offset?, limit? },
|
|
593
|
+
{ timeoutMs?, signal? },
|
|
594
|
+
) -> { objects, next_offset, supported_kinds }
|
|
595
|
+
|
|
596
|
+
describeObject(
|
|
597
|
+
{ kind, schema?, name, identity? },
|
|
598
|
+
{ timeoutMs?, signal? },
|
|
599
|
+
) -> { object, definition? }
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
SQL/MongoDB offsets are non-negative numbers; limits default to 50 and are at
|
|
603
|
+
most 200. Redis `offset` and `next_offset` are opaque numeric SCAN cursor strings;
|
|
604
|
+
its limit is a SCAN `COUNT` hint with a hard 200-item response bound. Redis pages
|
|
605
|
+
are not snapshots and can be empty or contain duplicates while keys change.
|
|
606
|
+
Search is a case-insensitive name substring for SQL/MongoDB and escaped glob
|
|
607
|
+
substring matching for Redis. No exact counts are forced.
|
|
608
|
+
|
|
609
|
+
Supported kinds are returned on every page: SQLite `table,view,trigger`;
|
|
610
|
+
PostgreSQL `table,view,function,trigger,enum`; MySQL
|
|
611
|
+
`table,view,function,trigger`; MongoDB `collection,view`; Redis `key`.
|
|
612
|
+
PostgreSQL function identities include identity arguments, so overloads remain
|
|
613
|
+
distinct. `describeObject` is read-only and requires the structured identity;
|
|
614
|
+
legacy `inspect` behavior is unchanged (and intentionally unavailable for Redis).
|
|
615
|
+
|
|
616
|
+
### Reviewed multi-row table edits
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
planTableUpdates(
|
|
620
|
+
Array<{ row_token: string; changes: { set?: object; unset?: string[] } }>,
|
|
621
|
+
options?,
|
|
622
|
+
) -> PlanData
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Batches contain 1-100 distinct row identities and at most 256 KiB. All tokens,
|
|
626
|
+
connection/state versions, expiries, metadata, editable columns, and values are
|
|
627
|
+
validated before one plan is stored; expiry is the earliest token expiry.
|
|
628
|
+
`apply(plan_id)` executes all conditional row updates in one SQLite/PostgreSQL/
|
|
629
|
+
MySQL transaction and requires every row predicate to match, otherwise all are
|
|
630
|
+
rolled back. MongoDB uses one snapshot transaction and rejects deployments that
|
|
631
|
+
do not support transactions. Redis and active staged StateQL transactions are
|
|
632
|
+
rejected. The existing `planTableUpdate` and `apply` APIs remain supported.
|
|
633
|
+
Plans are actor-owned, claimed once, and retained as non-replayable when the
|
|
634
|
+
remote commit outcome is uncertain.
|
|
635
|
+
|
|
636
|
+
### Redis native commands
|
|
637
|
+
|
|
638
|
+
Redis/Rediss URLs support URL database selection, password or ACL username,
|
|
639
|
+
and TLS (`rediss`). Credential-bearing URLs must come from `secretEnv` or
|
|
640
|
+
`credentialRef`. Native methods accept `{command: string, args?: string[]}`:
|
|
641
|
+
|
|
642
|
+
- `redisQuery`: `GET`, `MGET`, `TYPE`, `EXISTS`, `TTL`, `PTTL`, `HGET`, `HMGET`,
|
|
643
|
+
bounded `LRANGE`, and bounded `SCAN`/`HSCAN`/`SSCAN`/`ZSCAN`.
|
|
644
|
+
- `redisExec` and `redisPlan`: one-key `SET`, `DEL`, `HSET`, `HDEL`, `LPUSH`,
|
|
645
|
+
`RPUSH`, `SADD`, `SREM`, `ZADD`, or `ZREM` mutation.
|
|
646
|
+
- `describeObject({kind:"key",name})`: bounded string/hash/list/set/zset value
|
|
647
|
+
inspection with TTL and continuation metadata where applicable.
|
|
648
|
+
|
|
649
|
+
Arguments are UTF-8 strings, at most 100 values/256 KiB; materialized replies are
|
|
650
|
+
at most 1 MiB. `KEYS`, scripts, modules, pub/sub, blocking commands, admin/flush,
|
|
651
|
+
and arbitrary commands are rejected. Key discovery always uses SCAN. A Redis
|
|
652
|
+
plan snapshots one bounded key and `apply` uses an isolated `WATCH` + one-command
|
|
653
|
+
`MULTI/EXEC`; a pre-apply content or expiry change returns `ROW_CONFLICT` and is
|
|
654
|
+
never retried automatically. Direct `redisExec` has Redis single-command
|
|
655
|
+
atomicity only. Redis has no SQL rollback or StateQL staged transaction support;
|
|
656
|
+
a lost write/EXEC reply is reported as `OUTCOME_UNKNOWN` and remains blocked.
|
|
657
|
+
|
|
658
|
+
### Lean history
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
history(limit?, {
|
|
662
|
+
origin?,
|
|
663
|
+
category?: "statement" | "introspection" | "management",
|
|
664
|
+
internal?: boolean,
|
|
665
|
+
offset?: number,
|
|
666
|
+
})
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
`category` and trusted-host `internal` filters are applied in SQLite before
|
|
670
|
+
`ORDER BY`, `LIMIT`, and `OFFSET`, so introspection cannot starve statement
|
|
671
|
+
history. `CommandExecutionContext.internal` is trusted host metadata and cannot
|
|
672
|
+
be supplied inside a batch command. Existing calls and origin filtering remain
|
|
673
|
+
compatible; old rows are classified from their command name and migrate as `internal: false`.
|
|
674
|
+
|
|
675
|
+
The synchronous, non-mutating snapshot bridge accepts the same classification
|
|
676
|
+
filters without entering the command queue or writing a history row:
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
stateql.snapshot({
|
|
680
|
+
historyLimit: 50,
|
|
681
|
+
historyCategory: "statement",
|
|
682
|
+
historyInternal: false,
|
|
683
|
+
});
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Both snapshot filters are applied by the store before `historyLimit`. Calling
|
|
687
|
+
`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;
|
|
@@ -30,9 +33,11 @@ export interface Adapter {
|
|
|
30
33
|
ping(): Promise<void>;
|
|
31
34
|
read(sql: string, params: SqlParameters): Promise<ReadResult>;
|
|
32
35
|
write(sql: string, params: SqlParameters, expectedRows?: 1): Promise<WriteResult>;
|
|
33
|
-
writeBatch(operations:
|
|
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 {
|
|
@@ -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;
|
|
@@ -306,6 +314,8 @@ class PostgresAdapter {
|
|
|
306
314
|
await this.setLocalDeadline();
|
|
307
315
|
const result = await this.query(operation.sql, postgresParams(parseJson(operation.parameters, `operation "${operation.id}" parameters`, isSqlParameters)), true);
|
|
308
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.");
|
|
309
319
|
}
|
|
310
320
|
}
|
|
311
321
|
catch (error) {
|
|
@@ -340,6 +350,67 @@ class PostgresAdapter {
|
|
|
340
350
|
throw error;
|
|
341
351
|
}
|
|
342
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
|
+
}
|
|
343
414
|
async close() {
|
|
344
415
|
if (!this.ending) {
|
|
345
416
|
this.ending = this.client.end().catch(() => undefined);
|
|
@@ -548,6 +619,8 @@ class MySqlAdapter {
|
|
|
548
619
|
for (const operation of operations) {
|
|
549
620
|
const [result] = await this.query(operation.sql, mysqlParams(parseJson(operation.parameters, `operation "${operation.id}" parameters`, isSqlParameters)), true, true);
|
|
550
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.");
|
|
551
624
|
}
|
|
552
625
|
}
|
|
553
626
|
catch (error) {
|
|
@@ -581,6 +654,62 @@ class MySqlAdapter {
|
|
|
581
654
|
throw error;
|
|
582
655
|
}
|
|
583
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
|
+
}
|
|
584
713
|
async close() {
|
|
585
714
|
if (this.closed)
|
|
586
715
|
return;
|
|
@@ -785,6 +914,44 @@ function postgresParams(params) {
|
|
|
785
914
|
return params;
|
|
786
915
|
throw new Error("PostgreSQL parameters must be a JSON array.");
|
|
787
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
|
+
}
|
|
788
955
|
function remainingMilliseconds(context) {
|
|
789
956
|
return Math.max(1, Math.ceil(context.deadline - Date.now()));
|
|
790
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;
|
package/dist/src/connection.js
CHANGED
|
@@ -16,8 +16,10 @@ export function detectDriver(target) {
|
|
|
16
16
|
return "mysql";
|
|
17
17
|
if (/^mongodb(?:\+srv)?:\/\//i.test(target))
|
|
18
18
|
return "mongodb";
|
|
19
|
+
if (/^rediss?:\/\//i.test(target))
|
|
20
|
+
return "redis";
|
|
19
21
|
if (/^[a-z][a-z\d+.-]*:\/\//i.test(target)) {
|
|
20
|
-
throw new StateQLError("UNSUPPORTED_DRIVER", "Only MongoDB, MySQL, PostgreSQL, and SQLite are supported.");
|
|
22
|
+
throw new StateQLError("UNSUPPORTED_DRIVER", "Only MongoDB, MySQL, PostgreSQL, Redis, and SQLite are supported.");
|
|
21
23
|
}
|
|
22
24
|
return "sqlite";
|
|
23
25
|
}
|
|
@@ -44,6 +46,20 @@ export function mongoDatabaseName(target) {
|
|
|
44
46
|
}
|
|
45
47
|
throw new StateQLError("INVALID_COMMAND", "MongoDB URL must include an explicit database name.");
|
|
46
48
|
}
|
|
49
|
+
export function redisDatabaseName(target) {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(target);
|
|
52
|
+
if (!url.hostname || !["redis:", "rediss:"].includes(url.protocol.toLowerCase()))
|
|
53
|
+
throw new Error();
|
|
54
|
+
const path = url.pathname.replace(/^\//, "");
|
|
55
|
+
if (path && !/^\d+$/.test(path))
|
|
56
|
+
throw new Error();
|
|
57
|
+
return `db${path || "0"}`;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new StateQLError("INVALID_COMMAND", "Invalid Redis URL or database number.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
47
63
|
export function credentialSource(value, expectedDriver, referenceSource = "secret_env") {
|
|
48
64
|
const sourceLabel = referenceSource === "credential_ref"
|
|
49
65
|
? "Credential reference"
|
|
@@ -51,7 +67,7 @@ export function credentialSource(value, expectedDriver, referenceSource = "secre
|
|
|
51
67
|
const explicitSqlite = /^sqlite:(?!\/\/)/i.test(value);
|
|
52
68
|
const driver = explicitSqlite ? "sqlite" : detectDriver(value);
|
|
53
69
|
if (driver === "sqlite" && (!explicitSqlite || value.length === 7)) {
|
|
54
|
-
throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a complete PostgreSQL/MySQL URL or an explicit sqlite: source; MongoDB URLs are also supported.`, {
|
|
70
|
+
throw new StateQLError("INVALID_COMMAND", `${sourceLabel} must contain a complete PostgreSQL/MySQL/Redis URL or an explicit sqlite: source; MongoDB URLs are also supported.`, {
|
|
55
71
|
suggestedAction: "Store the full database URL, or prefix an SQLite path with sqlite:.",
|
|
56
72
|
});
|
|
57
73
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { StateQL } from "./stateql.js";
|
|
2
2
|
export { CredentialResolutionError, StateQLError, exitCodeFor, } from "./errors.js";
|
|
3
3
|
export type { CredentialResolutionFailure } from "./errors.js";
|
|
4
|
-
export type {
|
|
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";
|
package/dist/src/migrations.js
CHANGED
|
@@ -85,6 +85,47 @@ const MIGRATIONS = [
|
|
|
85
85
|
apply(db) { addColumn(db, "history", "target", "TEXT"); },
|
|
86
86
|
validate(db) { requireColumns(db, "history", ["target"]); },
|
|
87
87
|
},
|
|
88
|
+
{
|
|
89
|
+
name: "generated_aliases_v1",
|
|
90
|
+
apply(db) {
|
|
91
|
+
addColumn(db, "aliases", "generated", "INTEGER NOT NULL DEFAULT 0");
|
|
92
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS aliases_generated_result ON aliases(result_id) WHERE generated = 1");
|
|
93
|
+
},
|
|
94
|
+
validate(db) {
|
|
95
|
+
requireColumns(db, "aliases", ["generated"]);
|
|
96
|
+
requireIndexes(db, ["aliases_generated_result"]);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "history_classification_v1",
|
|
101
|
+
apply(db) {
|
|
102
|
+
addColumn(db, "history", "category", "TEXT NOT NULL DEFAULT 'management'");
|
|
103
|
+
addColumn(db, "history", "internal", "INTEGER NOT NULL DEFAULT 0");
|
|
104
|
+
db.exec(`
|
|
105
|
+
UPDATE history SET category = 'statement'
|
|
106
|
+
WHERE category = 'management' AND command IN
|
|
107
|
+
('query','exec','plan','apply','filter','mongo.query','mongo.exec','mongo.plan','redis.query','redis.exec','redis.plan');
|
|
108
|
+
UPDATE history SET category = 'introspection'
|
|
109
|
+
WHERE category = 'management' AND (command LIKE 'inspect.%' OR command IN ('objects.list','object.describe','table.read'));
|
|
110
|
+
`);
|
|
111
|
+
db.exec("CREATE INDEX IF NOT EXISTS history_session_category ON history(session_id, category, internal)");
|
|
112
|
+
},
|
|
113
|
+
validate(db) {
|
|
114
|
+
requireColumns(db, "history", ["category", "internal"]);
|
|
115
|
+
requireIndexes(db, ["history_session_category"]);
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "connection_aliases_v1",
|
|
120
|
+
apply(db) {
|
|
121
|
+
addColumn(db, "connections", "alias", "TEXT");
|
|
122
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS connections_alias ON connections(alias)");
|
|
123
|
+
},
|
|
124
|
+
validate(db) {
|
|
125
|
+
requireColumns(db, "connections", ["alias"]);
|
|
126
|
+
requireIndexes(db, ["connections_alias"]);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
88
129
|
];
|
|
89
130
|
export function runMigrations(db, now) {
|
|
90
131
|
db.exec(`
|
|
@@ -186,6 +227,7 @@ function createInitialSchema(db) {
|
|
|
186
227
|
session_id TEXT NOT NULL,
|
|
187
228
|
name TEXT NOT NULL,
|
|
188
229
|
result_id TEXT NOT NULL,
|
|
230
|
+
generated INTEGER NOT NULL DEFAULT 0,
|
|
189
231
|
PRIMARY KEY(session_id, name),
|
|
190
232
|
FOREIGN KEY(result_id) REFERENCES results(id)
|
|
191
233
|
);
|
|
@@ -249,6 +291,8 @@ function createInitialSchema(db) {
|
|
|
249
291
|
actor_id TEXT NOT NULL,
|
|
250
292
|
command TEXT NOT NULL,
|
|
251
293
|
origin TEXT NOT NULL DEFAULT 'legacy',
|
|
294
|
+
category TEXT NOT NULL DEFAULT 'management',
|
|
295
|
+
internal INTEGER NOT NULL DEFAULT 0,
|
|
252
296
|
sql TEXT,
|
|
253
297
|
handle TEXT,
|
|
254
298
|
executed INTEGER NOT NULL,
|