@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 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,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.
@@ -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: OperationRecord[], isolation: string): Promise<WriteResult[]>;
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;
@@ -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
- 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;