@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.
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
- import type { Column, CommandOrigin, Driver, MongoWriteOutcome, Row, SqlParameters, StateConfidence } from "./types.js";
2
+ import type { Column, CommandOrigin, Driver, HistoryCategory, MongoWriteOutcome, Row, SqlParameters, StateConfidence } from "./types.js";
3
3
  export interface SessionRecord {
4
4
  id: string;
5
5
  name: string;
@@ -46,6 +46,7 @@ export interface ResultRecord {
46
46
  state_confidence: StateConfidence;
47
47
  expires_at: string;
48
48
  created_at: string;
49
+ alias?: string;
49
50
  }
50
51
  export interface OperationRecord {
51
52
  id: string;
@@ -101,8 +102,11 @@ export interface HistoryRecord {
101
102
  session_id: string;
102
103
  actor_id: string;
103
104
  origin: CommandOrigin;
105
+ category: HistoryCategory;
106
+ internal: number;
104
107
  command: string;
105
108
  sql: string | null;
109
+ target: string | null;
106
110
  handle: string | null;
107
111
  executed: number;
108
112
  cached: number;
@@ -141,6 +145,13 @@ export declare class StateStore {
141
145
  credentialRef?: string;
142
146
  readOnly: boolean;
143
147
  }): ProfileRecord;
148
+ updateProfile(input: {
149
+ name: string;
150
+ target: string | null;
151
+ secretEnv: string | null;
152
+ credentialRef: string | null;
153
+ readOnly: boolean;
154
+ }): ProfileRecord | undefined;
144
155
  getProfile(name: string): ProfileRecord | undefined;
145
156
  listProfiles(): ProfileRecord[];
146
157
  removeProfile(name: string): boolean;
@@ -177,6 +188,9 @@ export declare class StateStore {
177
188
  resultRows(result: ResultRecord): Row[];
178
189
  resultColumns(result: ResultRecord): Column[];
179
190
  setAlias(sessionId: string, name: string, resultId: string): void;
191
+ generatedAlias(resultId: string): string;
192
+ private allocateGeneratedAlias;
193
+ private backfillGeneratedAliases;
180
194
  saveOperation(input: {
181
195
  sessionId: string;
182
196
  actorId: string;
@@ -276,8 +290,11 @@ export declare class StateStore {
276
290
  sessionId: string;
277
291
  actorId: string;
278
292
  origin?: CommandOrigin;
293
+ category?: HistoryCategory;
294
+ internal?: boolean;
279
295
  command: string;
280
296
  sql?: string;
297
+ target?: string;
281
298
  handle?: string;
282
299
  executed: boolean;
283
300
  cached: boolean;
@@ -285,7 +302,12 @@ export declare class StateStore {
285
302
  errorCode?: string;
286
303
  id?: string;
287
304
  }): HistoryRecord;
288
- history(sessionId: string, limit: number, origin?: CommandOrigin): HistoryRecord[];
305
+ history(sessionId: string, limit: number, input?: CommandOrigin | {
306
+ origin?: CommandOrigin;
307
+ category?: HistoryCategory;
308
+ internal?: boolean;
309
+ offset?: number;
310
+ }): HistoryRecord[];
289
311
  recentOperations(sessionId: string, limit: number): OperationRecord[];
290
312
  knownResults(sessionId: string, limit: number): Array<ResultRecord & {
291
313
  alias: string | null;
package/dist/src/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { chmodSync, existsSync, mkdirSync, statSync } from "node:fs";
2
3
  import { DatabaseSync } from "node:sqlite";
3
4
  import { join } from "node:path";
@@ -35,6 +36,7 @@ export class StateStore {
35
36
  this.db.exec("PRAGMA busy_timeout = 5000");
36
37
  this.db.exec("PRAGMA foreign_keys = ON");
37
38
  runMigrations(this.db, this.now);
39
+ this.backfillGeneratedAliases();
38
40
  this.recoverStaleCommittingTransactions();
39
41
  this.deleteExpiredData();
40
42
  }
@@ -240,6 +242,12 @@ export class StateStore {
240
242
  .run(input.name, input.target ?? null, input.secretEnv ?? null, input.credentialRef ?? null, input.readOnly ? 1 : 0, timestamp, timestamp);
241
243
  return this.getProfile(input.name);
242
244
  }
245
+ updateProfile(input) {
246
+ const result = this.db.prepare(`UPDATE profiles
247
+ SET target = ?, secret_env = ?, credential_ref = ?, read_only = ?, updated_at = ?
248
+ WHERE name = ?`).run(input.target, input.secretEnv, input.credentialRef, input.readOnly ? 1 : 0, this.now().toISOString(), input.name);
249
+ return Number(result.changes) === 1 ? this.getProfile(input.name) : undefined;
250
+ }
243
251
  getProfile(name) {
244
252
  return this.db
245
253
  .prepare("SELECT * FROM profiles WHERE name = ?")
@@ -341,6 +349,7 @@ export class StateStore {
341
349
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
342
350
  .run(id, input.sessionId, input.connectionId, input.fingerprint, input.sql, JSON.stringify(toJsonSafe(input.parameters)), JSON.stringify(toJsonSafe(input.rows)), JSON.stringify(input.columns), input.rows.length, input.stateVersion, input.stateSignature, input.stateConfidence, input.expiresAt, this.now().toISOString());
343
351
  this.enforceResultQuota(id);
352
+ this.allocateGeneratedAlias(input.sessionId, id);
344
353
  const result = this.getResult(id);
345
354
  this.db.exec("COMMIT");
346
355
  return result;
@@ -351,15 +360,18 @@ export class StateStore {
351
360
  }
352
361
  }
353
362
  findResult(fingerprint) {
354
- return this.db
363
+ const result = this.db
355
364
  .prepare(`SELECT * FROM results
356
365
  WHERE fingerprint = ?
357
366
  ORDER BY created_at DESC
358
367
  LIMIT 1`)
359
368
  .get(fingerprint);
369
+ if (result)
370
+ result.alias = this.generatedAlias(result.id);
371
+ return result;
360
372
  }
361
373
  getResult(idOrAlias, sessionId) {
362
- return this.db
374
+ const result = this.db
363
375
  .prepare(`SELECT results.*
364
376
  FROM results
365
377
  LEFT JOIN aliases
@@ -369,6 +381,9 @@ export class StateStore {
369
381
  AND (? IS NULL OR results.session_id = ?)
370
382
  LIMIT 1`)
371
383
  .get(idOrAlias, idOrAlias, sessionId ?? null, sessionId ?? null);
384
+ if (result)
385
+ result.alias = this.generatedAlias(result.id);
386
+ return result;
372
387
  }
373
388
  resultRows(result) {
374
389
  return parseJson(result.rows_json, `result "${result.id}" rows`, isRows);
@@ -377,12 +392,55 @@ export class StateStore {
377
392
  return parseJson(result.columns_json, `result "${result.id}" columns`, isColumns);
378
393
  }
379
394
  setAlias(sessionId, name, resultId) {
395
+ const existing = this.db.prepare("SELECT result_id, generated FROM aliases WHERE session_id = ? AND name = ?").get(sessionId, name);
396
+ if (existing?.generated) {
397
+ if (existing.result_id === resultId)
398
+ return;
399
+ throw new StateQLError("INVALID_COMMAND", "Generated result aliases cannot be reassigned.");
400
+ }
380
401
  this.db
381
- .prepare(`INSERT INTO aliases(session_id, name, result_id)
382
- VALUES (?, ?, ?)
402
+ .prepare(`INSERT INTO aliases(session_id, name, result_id, generated)
403
+ VALUES (?, ?, ?, 0)
383
404
  ON CONFLICT(session_id, name) DO UPDATE SET result_id = excluded.result_id`)
384
405
  .run(sessionId, name, resultId);
385
406
  }
407
+ generatedAlias(resultId) {
408
+ const row = this.db.prepare("SELECT name FROM aliases WHERE result_id = ? AND generated = 1").get(resultId);
409
+ if (!row)
410
+ throw new Error(`Result "${resultId}" has no generated alias.`);
411
+ return row.name;
412
+ }
413
+ allocateGeneratedAlias(sessionId, resultId) {
414
+ const existing = this.db.prepare("SELECT name FROM aliases WHERE result_id = ? AND generated = 1").get(resultId);
415
+ if (existing)
416
+ return existing.name;
417
+ for (let attempt = 0; attempt < 64; attempt++) {
418
+ const name = randomBase32Alias();
419
+ this.db.prepare("INSERT OR IGNORE INTO aliases(session_id, name, result_id, generated) VALUES (?, ?, ?, 1)").run(sessionId, name, resultId);
420
+ const allocated = this.db.prepare("SELECT name FROM aliases WHERE result_id = ? AND generated = 1").get(resultId);
421
+ if (allocated)
422
+ return allocated.name;
423
+ }
424
+ throw new Error("Could not allocate a unique result alias.");
425
+ }
426
+ backfillGeneratedAliases() {
427
+ const results = this.db.prepare(`SELECT results.id, results.session_id
428
+ FROM results
429
+ LEFT JOIN aliases ON aliases.result_id = results.id AND aliases.generated = 1
430
+ WHERE aliases.result_id IS NULL`).all();
431
+ if (!results.length)
432
+ return;
433
+ this.db.exec("BEGIN IMMEDIATE");
434
+ try {
435
+ for (const result of results)
436
+ this.allocateGeneratedAlias(result.session_id, result.id);
437
+ this.db.exec("COMMIT");
438
+ }
439
+ catch (error) {
440
+ this.db.exec("ROLLBACK");
441
+ throw error;
442
+ }
443
+ }
386
444
  saveOperation(input) {
387
445
  const id = this.nextId("op");
388
446
  this.db
@@ -797,10 +855,10 @@ export class StateStore {
797
855
  const id = input.id ?? this.nextId("cmd");
798
856
  this.db
799
857
  .prepare(`INSERT INTO history
800
- (id, timestamp, session_id, actor_id, origin, command, sql, handle,
858
+ (id, timestamp, session_id, actor_id, origin, category, internal, command, sql, target, handle,
801
859
  executed, cached, success, error_code)
802
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
803
- .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.origin ?? "legacy", input.command, boundedHistorySql(input.sql), input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
860
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
861
+ .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.origin ?? "legacy", input.category ?? "management", input.internal ? 1 : 0, input.command, boundedHistorySql(input.sql), input.target?.slice(0, 1024) ?? null, input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
804
862
  this.db
805
863
  .prepare(`DELETE FROM history
806
864
  WHERE rowid IN (
@@ -814,13 +872,29 @@ export class StateStore {
814
872
  .prepare("SELECT * FROM history WHERE id = ?")
815
873
  .get(id);
816
874
  }
817
- history(sessionId, limit, origin) {
875
+ history(sessionId, limit, input = {}) {
876
+ const options = typeof input === "string" ? { origin: input } : input;
877
+ const predicates = ["session_id = ?"];
878
+ const values = [sessionId];
879
+ if (options.origin !== undefined) {
880
+ predicates.push("origin = ?");
881
+ values.push(options.origin);
882
+ }
883
+ if (options.category !== undefined) {
884
+ predicates.push("category = ?");
885
+ values.push(options.category);
886
+ }
887
+ if (options.internal !== undefined) {
888
+ predicates.push("internal = ?");
889
+ values.push(options.internal ? 1 : 0);
890
+ }
891
+ values.push(limit, options.offset ?? 0);
818
892
  return this.db
819
893
  .prepare(`SELECT * FROM history
820
- WHERE session_id = ?${origin === undefined ? "" : " AND origin = ?"}
894
+ WHERE ${predicates.join(" AND ")}
821
895
  ORDER BY rowid DESC
822
- LIMIT ?`)
823
- .all(...(origin === undefined ? [sessionId, limit] : [sessionId, origin, limit]));
896
+ LIMIT ? OFFSET ?`)
897
+ .all(...values);
824
898
  }
825
899
  recentOperations(sessionId, limit) {
826
900
  return this.db
@@ -837,6 +911,7 @@ export class StateStore {
837
911
  LEFT JOIN aliases
838
912
  ON aliases.result_id = results.id
839
913
  AND aliases.session_id = results.session_id
914
+ AND aliases.generated = 1
840
915
  WHERE results.session_id = ?
841
916
  ORDER BY results.created_at DESC
842
917
  LIMIT ?`)
@@ -946,12 +1021,13 @@ export class StateStore {
946
1021
  while (this.resultBytes() > this.maxStateBytes) {
947
1022
  const candidate = this.db.prepare(`SELECT id FROM results
948
1023
  WHERE id <> ? AND NOT EXISTS (
949
- SELECT 1 FROM aliases WHERE aliases.result_id = results.id
1024
+ SELECT 1 FROM aliases WHERE aliases.result_id = results.id AND aliases.generated = 0
950
1025
  )
951
1026
  ORDER BY created_at, rowid LIMIT 1`).get(protectedId);
952
1027
  if (!candidate) {
953
1028
  throw new StateQLError("STATE_QUOTA_EXCEEDED", `Stored results exceed the ${this.maxStateBytes}-byte state quota.`, { suggestedAction: "Purge results or increase maxStateBytes." });
954
1029
  }
1030
+ this.db.prepare("DELETE FROM aliases WHERE result_id = ? AND generated = 1").run(candidate.id);
955
1031
  this.db.prepare("DELETE FROM results WHERE id = ?").run(candidate.id);
956
1032
  }
957
1033
  }
@@ -1019,6 +1095,10 @@ export class StateStore {
1019
1095
  function outcomeJson(outcome) {
1020
1096
  return outcome === undefined ? null : JSON.stringify(toJsonSafe(outcome));
1021
1097
  }
1098
+ function randomBase32Alias() {
1099
+ const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
1100
+ return [...randomBytes(10)].map((value) => alphabet[value & 31]).join("");
1101
+ }
1022
1102
  function boundedHistorySql(sql) {
1023
1103
  if (sql === undefined)
1024
1104
  return null;
@@ -0,0 +1,41 @@
1
+ import type { Driver, MongoWriteCommand, Row, SqlParameters } from "./types.js";
2
+ export interface TableIdentity {
3
+ schema?: string;
4
+ name: string;
5
+ }
6
+ export interface EditableColumn {
7
+ name: string;
8
+ type: string;
9
+ nullable: boolean;
10
+ generated: boolean;
11
+ key: number;
12
+ }
13
+ export interface EditableTable {
14
+ table: TableIdentity;
15
+ driver: Driver;
16
+ columns: EditableColumn[];
17
+ writable: boolean;
18
+ reason?: string;
19
+ }
20
+ export interface TableChange {
21
+ set?: Record<string, unknown>;
22
+ unset?: string[];
23
+ }
24
+ export interface TableUpdate {
25
+ metadata: EditableTable;
26
+ original: Row;
27
+ changes: TableChange;
28
+ }
29
+ export interface TableUpdateBatch {
30
+ version: 1;
31
+ updates: TableUpdate[];
32
+ }
33
+ export declare function quoteIdentifier(name: string, driver: Driver): string;
34
+ export declare function editableRow(metadata: EditableTable, row: Row): boolean;
35
+ export declare function compileTableUpdate(update: TableUpdate): {
36
+ sql: string;
37
+ params: SqlParameters;
38
+ mongo?: MongoWriteCommand;
39
+ };
40
+ export declare function parseTableUpdate(parameters: string): TableUpdate;
41
+ export declare function parseTableUpdates(parameters: string): TableUpdate[];
@@ -0,0 +1,137 @@
1
+ import { BSON } from "mongodb";
2
+ import { StateQLError } from "./errors.js";
3
+ export function quoteIdentifier(name, driver) {
4
+ if (!name || name.length > 500 || name.includes("\0"))
5
+ throw new StateQLError("INVALID_COMMAND", "Invalid database identifier.");
6
+ return driver === "mysql" ? "`" + name.replaceAll("`", "``") + "`" : '"' + name.replaceAll('"', '""') + '"';
7
+ }
8
+ function comparable(value, column) {
9
+ if (value === null)
10
+ return column.nullable;
11
+ const type = column.type.toLowerCase();
12
+ if (/^(?:tinyint|smallint|mediumint|int|integer|bigint|int2|int4|int8)(?:\b|$)/u.test(type))
13
+ return typeof value === "number" ? Number.isSafeInteger(value) : typeof value === "string" && /^-?\d+$/u.test(value);
14
+ if (/^(?:numeric|decimal)(?:\b|$)/u.test(type))
15
+ return typeof value === "string" && /^-?\d+(?:\.\d+)?$/u.test(value);
16
+ if (/^(?:boolean|bool)$/u.test(type))
17
+ return typeof value === "boolean";
18
+ if (/^(?:text|varchar|character varying|nvarchar|ntext|longtext|mediumtext|tinytext)(?:\b|$)/u.test(type))
19
+ return typeof value === "string" && value.length <= 16_384;
20
+ return false;
21
+ }
22
+ function mongoScalarType(value) {
23
+ if (value === null)
24
+ return "null";
25
+ if (typeof value === "string")
26
+ return "string";
27
+ if (typeof value === "boolean")
28
+ return "bool";
29
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== 1)
30
+ return undefined;
31
+ const types = { $oid: "objectId", $numberInt: "int", $numberLong: "long", $numberDecimal: "decimal", $date: "date" };
32
+ return types[Object.keys(value)[0]];
33
+ }
34
+ export function editableRow(metadata, row) {
35
+ if (!metadata.writable || Buffer.byteLength(JSON.stringify(row), "utf8") > 32 * 1024)
36
+ return false;
37
+ if (metadata.driver === "mongodb")
38
+ return Object.hasOwn(row, "_id") && Object.entries(row).every(([key, value]) => !key.startsWith("$") && !key.includes(".") && mongoScalarType(value) !== undefined);
39
+ const keys = metadata.columns.filter(column => column.key > 0);
40
+ return keys.length > 0 && keys.every(column => row[column.name] !== null && row[column.name] !== undefined) &&
41
+ metadata.columns.length === Object.keys(row).length && metadata.columns.every(column => Object.hasOwn(row, column.name) && comparable(row[column.name], column));
42
+ }
43
+ export function compileTableUpdate(update) {
44
+ const { metadata, original, changes } = update;
45
+ if (!editableRow(metadata, original))
46
+ throw new StateQLError("INVALID_COMMAND", "This row has no safe editable identity or comparison types.");
47
+ const entries = Object.entries(changes.set ?? {});
48
+ const unset = changes.unset ?? [];
49
+ if ((!entries.length && !unset.length) || entries.length + unset.length > 100 || Buffer.byteLength(JSON.stringify(changes), "utf8") > 32 * 1024)
50
+ throw new StateQLError("INVALID_COMMAND", "Provide bounded changed cells.");
51
+ if (metadata.driver === "mongodb") {
52
+ for (const name of [...entries.map(([name]) => name), ...unset]) {
53
+ if (name === "_id" || !Object.hasOwn(original, name) || name.startsWith("$") || name.includes("."))
54
+ throw new StateQLError("INVALID_COMMAND", "Only existing non-key fields can be changed.");
55
+ }
56
+ const document = BSON.EJSON.deserialize(original, { relaxed: false });
57
+ const mongo = {
58
+ operation: "updateOne", collection: metadata.table.name,
59
+ filter: { _id: document._id, $expr: { $and: [
60
+ { $eq: ["$$ROOT", { $literal: document }] },
61
+ ...Object.entries(original).map(([name, value]) => ({ $eq: [{ $type: "$" + name }, mongoScalarType(value)] })),
62
+ ] } },
63
+ update: { ...(entries.length ? { $set: BSON.EJSON.deserialize(Object.fromEntries(entries), { relaxed: false }) } : {}), ...(unset.length ? { $unset: Object.fromEntries(unset.map(name => [name, ""])) } : {}) },
64
+ options: { upsert: false, collation: { locale: "simple" } },
65
+ };
66
+ return { sql: "MongoDB conditional row update", params: [], mongo };
67
+ }
68
+ if (unset.length)
69
+ throw new StateQLError("INVALID_COMMAND", "SQL columns cannot be unset; use null where allowed.");
70
+ const driver = metadata.driver;
71
+ const quote = (name) => quoteIdentifier(name, driver);
72
+ const params = [];
73
+ const parameter = (value) => { params.push(value); return driver === "postgres" ? "$" + params.length : "?"; };
74
+ const assignments = entries.map(([name, value]) => {
75
+ const column = metadata.columns.find(column => column.name === name);
76
+ if (!column || column.key || column.generated || !comparable(value, column))
77
+ throw new StateQLError("INVALID_COMMAND", "A changed column is generated, a key, or has an unsupported value.");
78
+ return quote(name) + " = " + parameter(value);
79
+ });
80
+ const predicates = metadata.columns.map(column => {
81
+ const name = quote(column.name);
82
+ if (original[column.name] === null)
83
+ return name + " IS NULL";
84
+ const placeholder = parameter(original[column.name]);
85
+ const isText = /text|varchar|character varying/u.test(column.type.toLowerCase());
86
+ if (driver === "postgres") {
87
+ const left = isText ? "convert_to(" + name + ", 'UTF8')" : name;
88
+ const right = isText ? "convert_to(" + placeholder + ", 'UTF8')" : placeholder;
89
+ return left + " = " + right;
90
+ }
91
+ if (driver === "mysql")
92
+ return isText ? "CAST(" + name + " AS BINARY) = CAST(" + placeholder + " AS BINARY)" : name + " = " + placeholder;
93
+ return isText ? "CAST(" + name + " AS BLOB) IS CAST(" + placeholder + " AS BLOB)" : name + " IS " + placeholder;
94
+ });
95
+ const table = [metadata.table.schema, metadata.table.name].filter((name) => Boolean(name)).map(quote).join(".");
96
+ return { sql: "UPDATE " + table + " SET " + assignments.join(", ") + " WHERE " + predicates.join(" AND "), params };
97
+ }
98
+ export function parseTableUpdate(parameters) {
99
+ try {
100
+ const outer = JSON.parse(parameters);
101
+ if (!Array.isArray(outer) || outer.length !== 1 || typeof outer[0] !== "string" || outer[0].length > 128 * 1024)
102
+ throw new Error();
103
+ const update = JSON.parse(outer[0]);
104
+ if (!update || !update.metadata || !["sqlite", "postgres", "mysql", "mongodb"].includes(update.metadata.driver) ||
105
+ !Array.isArray(update.metadata.columns) || update.metadata.columns.length > 100 ||
106
+ !update.metadata.columns.every(column => typeof column.name === "string" && typeof column.type === "string" && typeof column.nullable === "boolean" && typeof column.generated === "boolean" && Number.isSafeInteger(column.key)) ||
107
+ !update.original || !update.changes || typeof update.changes !== "object")
108
+ throw new Error();
109
+ compileTableUpdate(update);
110
+ return update;
111
+ }
112
+ catch {
113
+ throw new StateQLError("STALE_PLAN", "The stored table update is invalid. Reload the row and plan again.");
114
+ }
115
+ }
116
+ export function parseTableUpdates(parameters) {
117
+ try {
118
+ const outer = JSON.parse(parameters);
119
+ if (!Array.isArray(outer) || outer.length !== 1 || typeof outer[0] !== "string" || outer[0].length > 512 * 1024)
120
+ throw new Error();
121
+ const batch = JSON.parse(outer[0]);
122
+ if (!batch || batch.version !== 1 || !Array.isArray(batch.updates) || batch.updates.length < 1 || batch.updates.length > 100)
123
+ throw new Error();
124
+ for (const update of batch.updates) {
125
+ if (!update?.metadata || !["sqlite", "postgres", "mysql", "mongodb"].includes(update.metadata.driver) ||
126
+ !Array.isArray(update.metadata.columns) || update.metadata.columns.length > 100 ||
127
+ !update.metadata.columns.every(column => typeof column.name === "string" && typeof column.type === "string" && typeof column.nullable === "boolean" && typeof column.generated === "boolean" && Number.isSafeInteger(column.key)) ||
128
+ !update.original || !update.changes || typeof update.changes !== "object")
129
+ throw new Error();
130
+ compileTableUpdate(update);
131
+ }
132
+ return batch.updates;
133
+ }
134
+ catch {
135
+ throw new StateQLError("STALE_PLAN", "The stored table update batch is invalid. Reload the rows and plan again.");
136
+ }
137
+ }
@@ -1,10 +1,13 @@
1
1
  export type SqlDriver = "sqlite" | "postgres" | "mysql";
2
- export type Driver = SqlDriver | "mongodb";
2
+ export type Driver = SqlDriver | "mongodb" | "redis";
3
3
  export type CommandOrigin = "legacy" | "user" | "model" | "system" | "api";
4
+ export type HistoryCategory = "statement" | "introspection" | "management";
4
5
  /** Trusted host metadata for one executeCommand call; never part of BatchCommand input. */
5
6
  export interface CommandExecutionContext {
6
7
  signal?: AbortSignal;
7
8
  origin?: CommandOrigin;
9
+ /** Marks host-generated setup or introspection separately from user statements. */
10
+ internal?: boolean;
8
11
  }
9
12
  export type CredentialAccess = "read" | "write";
10
13
  export type CredentialOperation = "connect" | "query" | "inspect" | "plan" | "exec" | "apply" | "transaction.commit";
@@ -78,14 +81,22 @@ export interface HistoryEntry {
78
81
  session_id: string;
79
82
  actor_id: string;
80
83
  origin: CommandOrigin;
84
+ category: HistoryCategory;
85
+ internal: boolean;
81
86
  command: string;
82
87
  sql: string | null;
88
+ target?: string | null;
83
89
  handle: string | null;
84
90
  executed: boolean;
85
91
  cached: boolean;
86
92
  success: boolean;
87
93
  error_code: string | null;
88
94
  }
95
+ export interface StateQLSnapshotOptions {
96
+ historyLimit?: number;
97
+ historyCategory?: HistoryCategory;
98
+ historyInternal?: boolean;
99
+ }
89
100
  export interface StateQLSnapshot {
90
101
  session: {
91
102
  session_id: string;
@@ -194,12 +205,49 @@ export interface MongoWriteOutcome {
194
205
  upserted_id?: unknown;
195
206
  deleted_count?: number;
196
207
  }
208
+ export interface RedisCommand {
209
+ command: string;
210
+ args?: string[];
211
+ }
212
+ export interface RedisWriteOutcome extends MongoWriteOutcome {
213
+ result: string | number | null;
214
+ }
215
+ export type CatalogObjectKind = "table" | "view" | "collection" | "function" | "trigger" | "enum" | "key";
216
+ export interface CatalogObject {
217
+ kind: CatalogObjectKind;
218
+ schema?: string;
219
+ name: string;
220
+ /** Stable database-native overload/object identity when name alone is ambiguous. */
221
+ identity?: string;
222
+ [key: string]: unknown;
223
+ }
224
+ export interface ListObjectsFilter {
225
+ kind?: CatalogObjectKind;
226
+ schema?: string;
227
+ search?: string;
228
+ /** Numeric for SQL/MongoDB; Redis uses its opaque SCAN cursor string. */
229
+ offset?: number | string;
230
+ limit?: number;
231
+ }
232
+ export interface ListObjectsData {
233
+ objects: CatalogObject[];
234
+ next_offset: number | string | null;
235
+ supported_kinds: CatalogObjectKind[];
236
+ }
237
+ export interface DescribeObjectData {
238
+ object: CatalogObject;
239
+ definition?: string | Record<string, unknown> | unknown[] | null;
240
+ [key: string]: unknown;
241
+ }
197
242
  export interface ExecutionOptions {
198
243
  timeoutMs?: number;
199
244
  signal?: AbortSignal;
200
245
  }
201
246
  export interface HistoryOptions {
202
247
  origin?: CommandOrigin;
248
+ category?: HistoryCategory;
249
+ internal?: boolean;
250
+ offset?: number;
203
251
  }
204
252
  export interface StateQLOptions extends ExecutionOptions {
205
253
  home?: string;
@@ -255,6 +303,21 @@ export interface ProfileOptions {
255
303
  secretEnv?: string;
256
304
  credentialRef?: string;
257
305
  }
306
+ export interface ProfileUpdateOptions {
307
+ target?: string | null;
308
+ secretEnv?: string | null;
309
+ credentialRef?: string | null;
310
+ readOnly?: boolean;
311
+ }
312
+ export interface RedisQueryOptions extends ExecutionOptions {
313
+ cache?: "auto" | "bypass" | "require";
314
+ }
315
+ export interface RedisExecOptions extends ExecutionOptions {
316
+ replay?: boolean;
317
+ idempotencyKey?: string;
318
+ }
319
+ export interface RedisPlanOptions extends ExecutionOptions {
320
+ }
258
321
  export interface RowsOptions {
259
322
  offset?: number;
260
323
  limit?: number;
@@ -268,7 +331,7 @@ export interface MongoPlanOptions extends ExecutionOptions {
268
331
  allowUnbounded?: boolean;
269
332
  allowDestructive?: boolean;
270
333
  }
271
- export type BatchCommandName = "connect" | "disconnect" | "status" | "profile.add" | "profile.list" | "profile.show" | "profile.remove" | "session.start" | "session.list" | "session.show" | "session.summary" | "session.close" | "query" | "filter" | "exec" | "show" | "rows" | "count" | "columns" | "alias.set" | "inspect" | "transaction.begin" | "transaction.status" | "transaction.commit" | "transaction.rollback" | "plan" | "mongo.query" | "mongo.exec" | "mongo.plan" | "apply" | "history" | "receipt" | "doctor" | "purge" | "capabilities";
334
+ export type BatchCommandName = "connect" | "disconnect" | "status" | "profile.add" | "profile.list" | "profile.show" | "profile.remove" | "profile.update" | "session.start" | "session.list" | "session.show" | "session.summary" | "session.close" | "query" | "filter" | "exec" | "show" | "rows" | "count" | "columns" | "alias.set" | "inspect" | "transaction.begin" | "transaction.status" | "transaction.commit" | "transaction.rollback" | "plan" | "mongo.query" | "mongo.exec" | "mongo.plan" | "redis.query" | "redis.exec" | "redis.plan" | "objects.list" | "object.describe" | "apply" | "history" | "receipt" | "doctor" | "purge" | "capabilities";
272
335
  export interface BatchCommand {
273
336
  command: BatchCommandName;
274
337
  target?: string;
@@ -281,6 +344,8 @@ export interface BatchCommand {
281
344
  table?: string;
282
345
  params?: SqlParameters;
283
346
  mongo?: MongoReadCommand | MongoWriteCommand;
347
+ redis?: RedisCommand;
348
+ object?: CatalogObject;
284
349
  cache?: "auto" | "bypass" | "require";
285
350
  read_only?: boolean;
286
351
  secret_env?: string;
@@ -291,11 +356,14 @@ export interface BatchCommand {
291
356
  allow_unbounded?: boolean;
292
357
  allow_destructive?: boolean;
293
358
  offset?: number;
359
+ cursor?: string;
294
360
  limit?: number;
295
361
  isolation?: string;
296
362
  timeout_ms?: number;
297
363
  /** Retrieval filter for the history command; does not attribute this command. */
298
364
  history_origin?: CommandOrigin;
365
+ history_category?: HistoryCategory;
366
+ history_internal?: boolean;
299
367
  scope?: "expired" | "results" | "history" | "all";
300
368
  }
301
369
  export interface BatchOptions {
@@ -415,6 +483,9 @@ export interface SessionSummaryData {
415
483
  }
416
484
  export interface ResultData {
417
485
  result_id: string;
486
+ alias: string;
487
+ /** Canonical generated alias; remains stable even when alias is an explicit caller alias. */
488
+ display_alias: string;
418
489
  rows: number;
419
490
  columns: Column[];
420
491
  preview: Row[];
@@ -427,6 +498,7 @@ export interface ResultData {
427
498
  mode: string;
428
499
  expires_at: string;
429
500
  };
501
+ next_cursor?: string | null;
430
502
  }
431
503
  export interface RowsData {
432
504
  result_id: string;
@@ -467,7 +539,7 @@ export interface OperationData {
467
539
  state_version_before: string;
468
540
  state_version_after: string | null;
469
541
  replay_of?: string;
470
- outcome?: MongoWriteOutcome;
542
+ outcome?: MongoWriteOutcome | RedisWriteOutcome;
471
543
  }
472
544
  export interface ExecData extends OperationData {
473
545
  duplicate?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
- "test": "npm run build && node --test dist/test/cli.test.js dist/test/credential.test.js dist/test/mongodb.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js",
28
+ "test": "npm run build && node --test dist/test/cli.test.js dist/test/credential.test.js dist/test/mongodb.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/redis.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js dist/test/panel.test.js",
29
29
  "release": "npm version",
30
30
  "version": "npm install",
31
31
  "prepack": "npm test"
@@ -37,6 +37,7 @@
37
37
  "postgresql",
38
38
  "cli",
39
39
  "agents",
40
+ "redis",
40
41
  "sql"
41
42
  ],
42
43
  "license": "MIT",
@@ -44,6 +45,7 @@
44
45
  "node": ">=22.16"
45
46
  },
46
47
  "dependencies": {
48
+ "@redis/client": "^5.12.1",
47
49
  "mongodb": "^6.21.0",
48
50
  "mysql2": "^3.23.1",
49
51
  "node-sql-parser": "^5.4.0",