@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 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 MongoDB databases while keeping results reusable
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 MongoDB URLs, or explicit
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.
@@ -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: OperationRecord[], isolation: string): 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;
@@ -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
- return stateql.history(numberOption(values.limit, 20));
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
 
@@ -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;
@@ -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
  }
@@ -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 { ActorData, ActorLinkData, ActorResolutionData, ActorsData, ActorUnlinkData, AliasData, ApplyData, BatchCommand, BatchCommandName, BatchOptions, 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, HistoryOptions, MongoAggregateOptions, MongoDocument, MongoFindOptions, MongoMutationOptions, MongoExecOptions, MongoPlanOptions, MongoQueryOptions, MongoReadCommand, MongoWriteCommand, MongoWriteOutcome, OperationData, PlanData, PlanOptions, ProfileData, ProfileOptions, ProfilesData, PurgeData, QueryOptions, RecentOperationData, RecentResultData, RemovedProfileData, Response, ResultData, RollbackTransactionData, Row, RowsData, RowsOptions, SqlParameters, SqlDriver, StateQLActorOptions, StateQLOptions, StateConfidence, StateQLSnapshot, StatusConnectionData, StatusData, Success, SessionData, SessionListItem, SessionsData, SessionSummaryData, TransactionData, TransactionReferenceData, Warning, } from "./types.js";
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";
@@ -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,