@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,11 +1,14 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { compileTableUpdate, editableRow, parseTableUpdate, parseTableUpdates } from "./table-editor.js";
2
4
  import { writeFileSync } from "node:fs";
3
5
  import { basename, resolve } from "node:path";
4
6
  import { env } from "node:process";
5
7
  import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
6
- import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateCredentialRef, validateProfileName, version, } from "./connection.js";
8
+ import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, redisDatabaseName, normalizeSqliteSource, validateCredentialRef, validateProfileName, version, } from "./connection.js";
7
9
  import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
8
10
  import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
11
+ import { deserializeRedisCommand, RedisAdapter, serializeRedisCommand, validateRedisReadCommand, validateRedisWriteCommand, } from "./redis.js";
9
12
  import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
10
13
  import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
11
14
  import { analyzeSql } from "./sql.js";
@@ -48,6 +51,10 @@ export class StateQL {
48
51
  credentialResolver;
49
52
  now;
50
53
  closed = false;
54
+ tableResults = new Map();
55
+ editTokens = new Map();
56
+ // ponytail: cache one bounded immutable result; use indexed result storage if larger results are needed.
57
+ panelRows;
51
58
  constructor(options = {}) {
52
59
  this.now = options.now ?? (() => new Date());
53
60
  this.sessionName = options.session ?? env.STQL_SESSION ?? "default";
@@ -83,6 +90,9 @@ export class StateQL {
83
90
  if (this.closed)
84
91
  return;
85
92
  this.closed = true;
93
+ this.panelRows = undefined;
94
+ this.tableResults.clear();
95
+ this.editTokens.clear();
86
96
  this.store.close();
87
97
  }
88
98
  [Symbol.dispose]() {
@@ -163,7 +173,9 @@ export class StateQL {
163
173
  ? basename(adapterSource)
164
174
  : driver === "mongodb"
165
175
  ? mongoDatabaseName(adapterSource)
166
- : new URL(secret).pathname.replace(/^\//, "") || driver;
176
+ : driver === "redis"
177
+ ? redisDatabaseName(adapterSource)
178
+ : new URL(secret).pathname.replace(/^\//, "") || driver;
167
179
  const draft = {
168
180
  id: "pending",
169
181
  session_id: session.id,
@@ -179,7 +191,9 @@ export class StateQL {
179
191
  };
180
192
  const adapter = driver === "mongodb"
181
193
  ? await this.openMongoAdapter(draft, context, adapterSource)
182
- : await this.openAdapter(draft, context, adapterSource);
194
+ : driver === "redis"
195
+ ? await this.openRedisAdapter(draft, context, adapterSource)
196
+ : await this.openAdapter(draft, context, adapterSource);
183
197
  try {
184
198
  await adapter.ping();
185
199
  }
@@ -227,34 +241,19 @@ export class StateQL {
227
241
  async addProfile(name, target, options = {}) {
228
242
  return this.run("profile.add", async () => {
229
243
  validateProfileName(name);
230
- const sourceCount = [target, options.secretEnv, options.credentialRef]
231
- .filter((value) => value !== undefined).length;
232
- if (sourceCount !== 1 || target === "") {
233
- throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
234
- }
235
244
  if (this.store.getProfile(name)) {
236
245
  throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
237
246
  }
238
- if (options.secretEnv !== undefined && !isEnvironmentName(options.secretEnv)) {
239
- throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
240
- }
241
- if (options.credentialRef !== undefined) {
242
- validateCredentialRef(options.credentialRef);
243
- }
244
- let storedTarget = target;
245
- if (target) {
246
- const driver = detectDriver(target);
247
- if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
248
- throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
249
- }
250
- if (driver === "sqlite")
251
- storedTarget = normalizeSqliteSource(target);
252
- }
253
- const profile = this.store.addProfile({
254
- name,
255
- target: storedTarget,
247
+ const source = validatedProfileSource({
248
+ target,
256
249
  secretEnv: options.secretEnv,
257
250
  credentialRef: options.credentialRef,
251
+ });
252
+ const profile = this.store.addProfile({
253
+ name,
254
+ target: source.target ?? undefined,
255
+ secretEnv: source.secretEnv ?? undefined,
256
+ credentialRef: source.credentialRef ?? undefined,
258
257
  readOnly: options.readOnly ?? true,
259
258
  });
260
259
  return {
@@ -264,6 +263,36 @@ export class StateQL {
264
263
  };
265
264
  });
266
265
  }
266
+ async updateProfile(name, changes) {
267
+ return this.run("profile.update", async () => {
268
+ validateProfileName(name);
269
+ const existing = this.store.getProfile(name);
270
+ if (!existing)
271
+ throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
272
+ if (!changes || typeof changes !== "object" || Array.isArray(changes) ||
273
+ Object.keys(changes).some((key) => !["target", "secretEnv", "credentialRef", "readOnly"].includes(key))) {
274
+ throw new StateQLError("INVALID_COMMAND", "Profile update contains unknown fields.");
275
+ }
276
+ if (changes.readOnly !== undefined && typeof changes.readOnly !== "boolean")
277
+ throw new StateQLError("INVALID_COMMAND", "Profile readOnly must be boolean.");
278
+ const changesSource = Object.hasOwn(changes, "target") || Object.hasOwn(changes, "secretEnv") || Object.hasOwn(changes, "credentialRef");
279
+ if (!changesSource && changes.readOnly === undefined)
280
+ throw new StateQLError("INVALID_COMMAND", "Profile update has no changes.");
281
+ const source = changesSource
282
+ ? validatedProfileSource({ target: changes.target ?? undefined, secretEnv: changes.secretEnv ?? undefined, credentialRef: changes.credentialRef ?? undefined })
283
+ : { target: existing.target, secretEnv: existing.secret_env, credentialRef: existing.credential_ref };
284
+ const profile = this.store.updateProfile({
285
+ name,
286
+ target: source.target,
287
+ secretEnv: source.secretEnv,
288
+ credentialRef: source.credentialRef,
289
+ readOnly: changes.readOnly ?? Boolean(existing.read_only),
290
+ });
291
+ if (!profile)
292
+ throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
293
+ return { data: profileData(profile), handle: `profile:${name}`, executed: true };
294
+ });
295
+ }
267
296
  async listProfiles() {
268
297
  return this.run("profile.list", async () => ({
269
298
  data: { profiles: this.store.listProfiles().map(profileData) },
@@ -322,6 +351,13 @@ export class StateQL {
322
351
  if (historyLimit > MAX_SNAPSHOT_HISTORY_LIMIT) {
323
352
  throw new StateQLError("INVALID_COMMAND", `historyLimit cannot exceed ${MAX_SNAPSHOT_HISTORY_LIMIT}.`);
324
353
  }
354
+ if (options.historyInternal !== undefined && typeof options.historyInternal !== "boolean") {
355
+ throw new StateQLError("INVALID_COMMAND", "Snapshot historyInternal filter must be boolean.");
356
+ }
357
+ const historyOptions = {
358
+ ...(options.historyCategory === undefined ? {} : { category: parseHistoryCategory(options.historyCategory) }),
359
+ ...(options.historyInternal === undefined ? {} : { internal: options.historyInternal }),
360
+ };
325
361
  return {
326
362
  session: {
327
363
  session_id: session.id,
@@ -362,7 +398,9 @@ export class StateQL {
362
398
  affected_rows: operation.affected_rows,
363
399
  status: operation.status,
364
400
  })),
365
- history: this.store.history(session.id, historyLimit).map(historyEntry),
401
+ history: this.store
402
+ .history(session.id, historyLimit, historyOptions)
403
+ .map(historyEntry),
366
404
  };
367
405
  }
368
406
  async status() {
@@ -739,6 +777,51 @@ export class StateQL {
739
777
  }
740
778
  });
741
779
  }
780
+ async redisQuery(command, options = {}) {
781
+ return this.run("redis.query", async (session) => {
782
+ const value = validatedRedisRead(command);
783
+ const serialized = serializeRedisCommand(value);
784
+ const connection = this.requireRedisConnection(session, "redisQuery");
785
+ this.rejectDuringStagedTransaction(session, "Redis queries");
786
+ const context = this.executionContext(options);
787
+ const source = await this.resolveConnectionSource(connection, session, "query", "read", context);
788
+ const adapter = await this.openRedisAdapter(connection, context, source);
789
+ try {
790
+ const stateVersion = version(connection);
791
+ const stateSignature = await adapter.signature();
792
+ const fingerprint = hash({ command: serialized, connection: connection.id, database: connection.database_name, stateVersion });
793
+ const cached = this.store.findResult(fingerprint);
794
+ const cacheMode = options.cache ?? "auto";
795
+ if (cacheMode !== "bypass" && cached && cached.row_count <= this.maxResultRows && this.cacheValid(cached, stateVersion, stateSignature)) {
796
+ return { data: this.resultData(cached, true), handle: cached.id, cached: true, stateVersion, confidence: cached.state_confidence };
797
+ }
798
+ if (cacheMode === "require")
799
+ throw new StateQLError("CACHE_MISS", "No valid cached Redis result exists.", { retryable: true });
800
+ const result = await adapter.read(value);
801
+ const parameters = [serialized, result.nextCursor ?? null];
802
+ const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") + Buffer.byteLength(JSON.stringify(result.rows), "utf8") + Buffer.byteLength(JSON.stringify(result.columns), "utf8");
803
+ if (result.rows.length > this.maxResultRows || resultBytes > this.maxResultBytes)
804
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Redis response exceeds materialization limits.");
805
+ const saved = this.store.saveResult({
806
+ sessionId: session.id, connectionId: connection.id, fingerprint,
807
+ sql: `Redis native ${value.command}`, parameters, rows: result.rows, columns: result.columns,
808
+ stateVersion, stateSignature, stateConfidence: adapter.confidence,
809
+ expiresAt: new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString(),
810
+ });
811
+ return { data: this.resultData(saved, false), handle: saved.id, executed: true, stateVersion, confidence: adapter.confidence };
812
+ }
813
+ catch (error) {
814
+ if (error instanceof StateQLError)
815
+ throw error;
816
+ if (error instanceof AdapterExecutionError)
817
+ throw stoppedStateQLError(error, true);
818
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { retryable: true, executed: true });
819
+ }
820
+ finally {
821
+ await closeAdapterQuietly(adapter);
822
+ }
823
+ });
824
+ }
742
825
  async show(idOrAlias) {
743
826
  return this.withResult("show", idOrAlias, async (result) => ({
744
827
  data: this.resultData(result, true),
@@ -855,6 +938,257 @@ export class StateQL {
855
938
  };
856
939
  });
857
940
  }
941
+ /** Owned, full-value pages for host UIs. Reading a page does not add a command to history. */
942
+ readMaterialized(id, options = {}) {
943
+ options.signal?.throwIfAborted();
944
+ const result = this.panelResult(id);
945
+ const offset = nonNegativeInteger(options.offset ?? 0, "offset");
946
+ const limit = positiveInteger(options.limit ?? 100, "limit");
947
+ if (limit > 100 || offset > 10_000)
948
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Page bounds exceeded.");
949
+ const all = this.fullResultRows(result);
950
+ const rows = [];
951
+ let bytes = 0;
952
+ for (const row of all.slice(offset, offset + limit)) {
953
+ const size = Buffer.byteLength(JSON.stringify(row), "utf8");
954
+ if (bytes + size > 200 * 1024) {
955
+ if (!rows.length)
956
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "A row exceeds the browser page limit. Export this result instead.");
957
+ break;
958
+ }
959
+ rows.push(row);
960
+ bytes += size;
961
+ }
962
+ const next = offset + rows.length;
963
+ const metadata = this.tableResults.get(id);
964
+ const connection = this.store.activeConnection(this.store.ensureSession(this.sessionName));
965
+ const eligible = metadata && connection?.id === result.connection_id && !connection.read_only && version(connection) === result.state_version;
966
+ const rowTokens = rows.map(row => {
967
+ if (!eligible || !editableRow(metadata, row))
968
+ return null;
969
+ const token = randomUUID();
970
+ for (const [key, entry] of this.editTokens)
971
+ if (entry.expires <= this.now().getTime())
972
+ this.editTokens.delete(key);
973
+ if (this.editTokens.size >= 1000)
974
+ this.editTokens.delete(this.editTokens.keys().next().value);
975
+ this.editTokens.set(token, { metadata, original: structuredClone(row), sessionId: result.session_id, connectionId: result.connection_id, stateVersion: result.state_version, expires: this.now().getTime() + 10 * 60_000 });
976
+ return token;
977
+ });
978
+ const writable = metadata?.driver === "mongodb" ? [...new Set(rows.flatMap(row => Object.keys(row)))].filter(name => name !== "_id")
979
+ : metadata?.columns.filter(column => !column.key && !column.generated).map(column => column.name) ?? [];
980
+ return { row_tokens: rowTokens, writable_columns: writable, ...(!metadata?.writable ? { editing_reason: metadata?.reason ?? "Query results are read-only." } : {}),
981
+ result_id: result.id, offset, limit, rows: structuredClone(rows), columns: this.store.resultColumns(result),
982
+ returned: rows.length, total: all.length, truncated: next < all.length, next_offset: next < all.length ? next : null };
983
+ }
984
+ /** No caller-selected filesystem paths. The host delivers these bounded attachment bytes. */
985
+ async serializeResult(id, format, signal, origin = "api") {
986
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal, origin }), () => this.run("export", async () => {
987
+ if (!["json", "jsonl", "csv"].includes(format))
988
+ throw new StateQLError("INVALID_COMMAND", "Unsupported export format.");
989
+ const result = this.panelResult(id);
990
+ const rows = this.fullResultRows(result);
991
+ const columns = this.store.resultColumns(result).map(column => column.name);
992
+ const chunks = [];
993
+ let bytes = 0;
994
+ const append = (chunk) => {
995
+ bytes += Buffer.byteLength(chunk, "utf8");
996
+ if (bytes > 32 * 1024 * 1024)
997
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Export exceeds 32 MiB.");
998
+ chunks.push(chunk);
999
+ };
1000
+ const csvCell = (value) => {
1001
+ let valueText = value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
1002
+ if (typeof value !== "number" && /^[\s\u0000-\u001f]*[=+@-]|^[\t\r\n]/u.test(valueText))
1003
+ valueText = "'" + valueText;
1004
+ return /[",\r\n]/u.test(valueText) ? '"' + valueText.replaceAll('"', '""') + '"' : valueText;
1005
+ };
1006
+ if (format === "json")
1007
+ append("[");
1008
+ if (format === "csv")
1009
+ append(columns.map(csvCell).join(",") + "\n");
1010
+ const deadline = Date.now() + 30_000;
1011
+ for (let index = 0; index < rows.length; index++) {
1012
+ if (index % 100 === 0) {
1013
+ await new Promise(resolve => setImmediate(resolve));
1014
+ signal?.throwIfAborted();
1015
+ this.signal?.throwIfAborted();
1016
+ if (Date.now() > deadline)
1017
+ throw new StateQLError("DEADLINE_EXCEEDED", "Export preparation timed out.");
1018
+ }
1019
+ const row = rows[index];
1020
+ append(format === "csv" ? columns.map(column => csvCell(row[column])).join(",") + "\n"
1021
+ : (format === "json" && index ? "," : "") + JSON.stringify(row) + (format === "jsonl" ? "\n" : ""));
1022
+ }
1023
+ signal?.throwIfAborted();
1024
+ if (format === "json")
1025
+ append("]\n");
1026
+ this.panelResult(id);
1027
+ return { data: { content: chunks.join(""), format, rows: rows.length }, handle: id };
1028
+ }));
1029
+ }
1030
+ async readTable(table, limit = 1000, options = {}) {
1031
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api", internal: true }), async () => {
1032
+ if (!table || typeof table.name !== "string" || !table.name || table.name.length > 500 || table.name.includes("\0") ||
1033
+ (table.schema !== undefined && (typeof table.schema !== "string" || !table.schema || table.schema.length > 500 || table.schema.includes("\0"))) ||
1034
+ !Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
1035
+ throw new StateQLError("INVALID_COMMAND", "Invalid table or sample limit.");
1036
+ const snapshot = this.snapshot({ historyLimit: 1 });
1037
+ const driver = snapshot.connection?.driver;
1038
+ if (!driver)
1039
+ throw new StateQLError("CONNECTION_NOT_FOUND", "Connect to a database first.");
1040
+ if (driver === "sqlite" && table.schema && table.schema !== "main")
1041
+ throw new StateQLError("INVALID_COMMAND", "Only the main SQLite schema is supported.");
1042
+ if (driver === "mongodb" && table.schema)
1043
+ throw new StateQLError("INVALID_COMMAND", "MongoDB collections do not accept a schema.");
1044
+ if (driver === "redis")
1045
+ throw new StateQLError("INVALID_COMMAND", "Redis keys are not SQL tables; use redisQuery and describeObject.");
1046
+ const quote = (name) => driver === "mysql" ? "\`" + name.replaceAll("\`", "\`\`") + "\`" : '"' + name.replaceAll('"', '""') + '"';
1047
+ const qualified = [table.schema, table.name].filter((part) => Boolean(part)).map(quote).join(".");
1048
+ const query = driver === "mongodb" ? JSON.stringify({ operation: "find", collection: table.name, options: { limit } }, null, 2)
1049
+ : "SELECT * FROM " + qualified + " LIMIT " + limit;
1050
+ const metadata = await this.editableMetadata(table, options);
1051
+ const response = driver === "mongodb"
1052
+ ? await this.mongoQuery({ operation: "find", collection: table.name, options: { limit } }, options)
1053
+ : await this.query(query, options);
1054
+ if (!response.ok)
1055
+ return response;
1056
+ if (this.tableResults.size >= 20)
1057
+ this.tableResults.delete(this.tableResults.keys().next().value);
1058
+ this.tableResults.set(response.data.result_id, metadata);
1059
+ return { ...response, data: { ...response.data, table, sample_limit: limit, query } };
1060
+ });
1061
+ }
1062
+ async planTableUpdate(token, changes, options = {}) {
1063
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), () => this.run("table.plan", async (session) => {
1064
+ this.rejectDuringStagedTransaction(session, "Table edits");
1065
+ const connection = this.requireConnection(session);
1066
+ const original = this.editTokens.get(token);
1067
+ if (!original || original.sessionId !== session.id || original.connectionId !== connection.id ||
1068
+ original.stateVersion !== version(connection) || original.expires <= this.now().getTime())
1069
+ throw new StateQLError("STALE_PLAN", "Row identity expired or the connection changed. Reload the row.");
1070
+ if (connection.read_only)
1071
+ throw new StateQLError("READ_ONLY_CONNECTION", "This connection is read-only.");
1072
+ const metadata = await this.editableMetadata(original.metadata.table, options);
1073
+ if (JSON.stringify(metadata) !== JSON.stringify(original.metadata))
1074
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload the table.");
1075
+ const update = { metadata, original: original.original, changes };
1076
+ const compiled = compileTableUpdate(update);
1077
+ const context = this.executionContext(options);
1078
+ const source = await this.resolveConnectionSource(connection, session, "plan", "read", context);
1079
+ const adapter = connection.driver === "mongodb" ? await this.openMongoAdapter(connection, context, source) : await this.openAdapter(connection, context, source);
1080
+ try {
1081
+ const plan = this.store.savePlan({ sessionId: session.id, ownerActorId: this.actorId, connectionId: connection.id,
1082
+ sql: compiled.sql, parameters: [JSON.stringify(update)], statementType: "table.update", stateVersion: version(connection),
1083
+ stateSignature: await adapter.signature(), destructive: false, allowUnbounded: false, allowDestructive: false,
1084
+ expiresAt: new Date(original.expires).toISOString() });
1085
+ return { data: { plan_id: plan.id, statement_type: plan.statement_type, destructive: false, requires_confirmation: true, required_overrides: [],
1086
+ state_version: plan.state_version, owner_actor_id: this.actorId, expires_at: plan.expires_at }, handle: plan.id };
1087
+ }
1088
+ finally {
1089
+ await closeAdapterQuietly(adapter);
1090
+ }
1091
+ }));
1092
+ }
1093
+ async planTableUpdates(changes, options = {}) {
1094
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), () => this.run("table.plan", async (session) => {
1095
+ this.rejectDuringStagedTransaction(session, "Table edits");
1096
+ if (!Array.isArray(changes) || changes.length < 1 || changes.length > 100 || Buffer.byteLength(JSON.stringify(changes), "utf8") > 256 * 1024) {
1097
+ throw new StateQLError("INVALID_COMMAND", "Table edit batch must contain 1-100 bounded rows.");
1098
+ }
1099
+ const connection = this.requireConnection(session);
1100
+ if (connection.read_only)
1101
+ throw new StateQLError("READ_ONLY_CONNECTION", "This connection is read-only.");
1102
+ if (connection.driver === "redis")
1103
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support table edits.");
1104
+ const now = this.now().getTime();
1105
+ const tokens = new Set();
1106
+ const identities = new Set();
1107
+ const updates = [];
1108
+ let expires = Number.MAX_SAFE_INTEGER;
1109
+ const metadataByTable = new Map();
1110
+ for (const item of changes) {
1111
+ if (!item || typeof item.row_token !== "string" || tokens.has(item.row_token))
1112
+ throw new StateQLError("INVALID_COMMAND", "Table edit row tokens must be unique.");
1113
+ tokens.add(item.row_token);
1114
+ const original = this.editTokens.get(item.row_token);
1115
+ if (!original || original.sessionId !== session.id || original.connectionId !== connection.id || original.stateVersion !== version(connection) || original.expires <= now) {
1116
+ throw new StateQLError("STALE_PLAN", "A row identity expired or the connection changed. Reload the rows.");
1117
+ }
1118
+ const tableKey = JSON.stringify(original.metadata.table);
1119
+ let metadata = metadataByTable.get(tableKey);
1120
+ if (!metadata) {
1121
+ metadata = await this.editableMetadata(original.metadata.table, options);
1122
+ metadataByTable.set(tableKey, metadata);
1123
+ }
1124
+ if (JSON.stringify(metadata) !== JSON.stringify(original.metadata))
1125
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload the table.");
1126
+ const identity = tableUpdateIdentity(metadata, original.original);
1127
+ if (identities.has(identity))
1128
+ throw new StateQLError("INVALID_COMMAND", "The same row cannot appear twice in one edit batch.");
1129
+ identities.add(identity);
1130
+ const update = { metadata, original: original.original, changes: item.changes };
1131
+ compileTableUpdate(update);
1132
+ updates.push(update);
1133
+ expires = Math.min(expires, original.expires);
1134
+ }
1135
+ const payload = JSON.stringify({ version: 1, updates });
1136
+ const context = this.executionContext(options);
1137
+ const source = await this.resolveConnectionSource(connection, session, "plan", "read", context);
1138
+ const adapter = connection.driver === "mongodb" ? await this.openMongoAdapter(connection, context, source) : await this.openAdapter(connection, context, source);
1139
+ try {
1140
+ const plan = this.store.savePlan({
1141
+ sessionId: session.id, ownerActorId: this.actorId, connectionId: connection.id,
1142
+ sql: `Conditional table update batch (${updates.length} rows)`, parameters: [payload], statementType: "table.updates",
1143
+ stateVersion: version(connection), stateSignature: await adapter.signature(), destructive: false,
1144
+ allowUnbounded: false, allowDestructive: false, expiresAt: new Date(expires).toISOString(),
1145
+ });
1146
+ return { data: { plan_id: plan.id, statement_type: plan.statement_type, destructive: false, requires_confirmation: true,
1147
+ required_overrides: [], state_version: plan.state_version, owner_actor_id: plan.owner_actor_id, expires_at: plan.expires_at },
1148
+ handle: plan.id, executed: true, stateVersion: plan.state_version, confidence: adapter.confidence };
1149
+ }
1150
+ finally {
1151
+ await closeAdapterQuietly(adapter);
1152
+ }
1153
+ }));
1154
+ }
1155
+ async editableMetadata(table, options) {
1156
+ const snapshot = this.snapshot({ historyLimit: 1 });
1157
+ const driver = snapshot.connection.driver;
1158
+ const unavailable = { table, driver, columns: [], writable: false, reason: "This table or its values cannot be edited safely." };
1159
+ // The legacy inspection API splits qualified names. Fail closed for ambiguous names.
1160
+ if (table.name.includes(".") || table.schema?.includes("."))
1161
+ return { ...unavailable, reason: "Editing identifiers containing dots is not supported." };
1162
+ const name = driver === "sqlite" || driver === "mongodb" ? table.name : [table.schema, table.name].filter(Boolean).join(".");
1163
+ const response = await this.inspect("editable", name, options);
1164
+ if (!response.ok)
1165
+ return { ...unavailable, reason: response.error.code };
1166
+ const value = response.data;
1167
+ if (!value || !Array.isArray(value.columns) || value.columns.length > 100)
1168
+ return unavailable;
1169
+ return { table, driver, columns: value.columns, writable: value.writable === true && !snapshot.connection.read_only,
1170
+ ...(value.writable ? {} : { reason: "Only ordinary tables with transactional writes and a primary key can be edited." }) };
1171
+ }
1172
+ panelResult(id) {
1173
+ if (typeof id !== "string" || !id || id.length > 200)
1174
+ throw new StateQLError("INVALID_COMMAND", "A result ID is required.");
1175
+ const session = this.store.ensureSession(this.sessionName);
1176
+ if (!this.store.isSessionMember(session.id, this.actorId))
1177
+ this.throwMembershipDenied(session);
1178
+ const result = this.requireResult(id, session);
1179
+ if (result.id !== id)
1180
+ throw new StateQLError("INVALID_COMMAND", "Use an immutable result ID, not an alias.");
1181
+ if (Date.parse(result.expires_at) <= this.now().getTime())
1182
+ throw new StateQLError("RESULT_EXPIRED", "Result expired. Run the query again.");
1183
+ if (result.row_count > 10_000 || Buffer.byteLength(result.rows_json, "utf8") > 16 * 1024 * 1024)
1184
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Stored result exceeds browser limits.");
1185
+ return result;
1186
+ }
1187
+ fullResultRows(result) {
1188
+ if (this.panelRows?.id !== result.id || this.panelRows.json !== result.rows_json)
1189
+ this.panelRows = { id: result.id, json: result.rows_json, rows: this.store.resultRows(result) };
1190
+ return this.panelRows.rows;
1191
+ }
858
1192
  async exportResult(idOrAlias, output, format = "csv") {
859
1193
  return this.withResult("export", idOrAlias, async (result) => {
860
1194
  const rows = this.store.resultRows(result);
@@ -890,6 +1224,14 @@ export class StateQL {
890
1224
  return this.performMongoExec(session, connection, value, options, this.executionContext(options));
891
1225
  });
892
1226
  }
1227
+ async redisExec(command, options = {}) {
1228
+ return this.run("redis.exec", async (session) => {
1229
+ const value = validatedRedisWrite(command);
1230
+ const connection = this.requireRedisConnection(session, "redisExec");
1231
+ this.rejectDuringStagedTransaction(session, "Redis writes");
1232
+ return this.performRedisExec(session, connection, value, options, this.executionContext(options));
1233
+ });
1234
+ }
893
1235
  async receipt(id) {
894
1236
  return this.run("receipt", async (session) => {
895
1237
  const operation = this.store.getOperation(id);
@@ -906,6 +1248,8 @@ export class StateQL {
906
1248
  async beginTransaction(isolation) {
907
1249
  return this.run("transaction.begin", async (session) => {
908
1250
  const connection = this.requireConnection(session);
1251
+ if (connection.driver === "redis")
1252
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support staged StateQL transactions; use redisPlan/apply for one guarded mutation.");
909
1253
  if (connection.read_only) {
910
1254
  throw new StateQLError("READ_ONLY_CONNECTION", "Cannot begin a write transaction on a read-only connection.");
911
1255
  }
@@ -1080,6 +1424,8 @@ export class StateQL {
1080
1424
  this.rejectDuringStagedTransaction(session, "Schema inspection");
1081
1425
  const context = this.executionContext(options);
1082
1426
  const adapterSource = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
1427
+ if (connection.driver === "redis")
1428
+ throw new StateQLError("INVALID_COMMAND", "Legacy inspect is not supported for Redis; use listObjects or describeObject.");
1083
1429
  const adapter = connection.driver === "mongodb"
1084
1430
  ? await this.openMongoAdapter(connection, context, adapterSource)
1085
1431
  : await this.openAdapter(connection, context, adapterSource);
@@ -1104,6 +1450,56 @@ export class StateQL {
1104
1450
  finally {
1105
1451
  await closeAdapterQuietly(adapter);
1106
1452
  }
1453
+ }, undefined, table);
1454
+ }
1455
+ async listObjects(filter = {}, options = {}) {
1456
+ return this.run("objects.list", async (session) => {
1457
+ validateCatalogFilter(filter);
1458
+ const connection = this.requireConnection(session);
1459
+ this.rejectDuringStagedTransaction(session, "Catalog discovery");
1460
+ const context = this.executionContext(options);
1461
+ const source = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
1462
+ const adapter = connection.driver === "mongodb"
1463
+ ? await this.openMongoAdapter(connection, context, source)
1464
+ : connection.driver === "redis"
1465
+ ? await this.openRedisAdapter(connection, context, source)
1466
+ : await this.openAdapter(connection, context, source);
1467
+ try {
1468
+ return { data: await adapter.listObjects(filter), executed: true, stateVersion: version(connection), confidence: adapter.confidence };
1469
+ }
1470
+ catch (error) {
1471
+ if (error instanceof AdapterExecutionError)
1472
+ throw stoppedStateQLError(error, true);
1473
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
1474
+ }
1475
+ finally {
1476
+ await closeAdapterQuietly(adapter);
1477
+ }
1478
+ });
1479
+ }
1480
+ async describeObject(object, options = {}) {
1481
+ return this.run("object.describe", async (session) => {
1482
+ validateCatalogObject(object);
1483
+ const connection = this.requireConnection(session);
1484
+ this.rejectDuringStagedTransaction(session, "Catalog description");
1485
+ const context = this.executionContext(options);
1486
+ const source = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
1487
+ const adapter = connection.driver === "mongodb"
1488
+ ? await this.openMongoAdapter(connection, context, source)
1489
+ : connection.driver === "redis"
1490
+ ? await this.openRedisAdapter(connection, context, source)
1491
+ : await this.openAdapter(connection, context, source);
1492
+ try {
1493
+ return { data: await adapter.describeObject(object), executed: true, stateVersion: version(connection), confidence: adapter.confidence };
1494
+ }
1495
+ catch (error) {
1496
+ if (error instanceof AdapterExecutionError)
1497
+ throw stoppedStateQLError(error, true);
1498
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
1499
+ }
1500
+ finally {
1501
+ await closeAdapterQuietly(adapter);
1502
+ }
1107
1503
  });
1108
1504
  }
1109
1505
  async plan(sql, options = {}) {
@@ -1238,6 +1634,43 @@ export class StateQL {
1238
1634
  }
1239
1635
  });
1240
1636
  }
1637
+ async redisPlan(command, options = {}) {
1638
+ return this.run("redis.plan", async (session) => {
1639
+ const value = validatedRedisWrite(command);
1640
+ const connection = this.requireRedisConnection(session, "redisPlan");
1641
+ this.rejectDuringStagedTransaction(session, "Plans");
1642
+ if (connection.read_only)
1643
+ throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.");
1644
+ const context = this.executionContext(options);
1645
+ const source = await this.resolveConnectionSource(connection, session, "plan", "read", context);
1646
+ const adapter = await this.openRedisAdapter(connection, context, source);
1647
+ try {
1648
+ const precondition = await adapter.precondition(value);
1649
+ const payload = JSON.stringify({ command: serializeRedisCommand(value), precondition });
1650
+ const plan = this.store.savePlan({
1651
+ sessionId: session.id, ownerActorId: this.actorId, connectionId: connection.id,
1652
+ sql: `Redis native ${value.command}`, parameters: [payload], statementType: `redis.${value.command.toLowerCase()}`,
1653
+ stateVersion: version(connection), stateSignature: await adapter.signature(), destructive: value.command === "DEL",
1654
+ allowUnbounded: false, allowDestructive: true,
1655
+ expiresAt: new Date(this.now().getTime() + 10 * 60_000).toISOString(),
1656
+ });
1657
+ return { data: { plan_id: plan.id, statement_type: plan.statement_type, destructive: Boolean(plan.destructive),
1658
+ requires_confirmation: true, required_overrides: [], state_version: plan.state_version,
1659
+ owner_actor_id: plan.owner_actor_id, expires_at: plan.expires_at }, handle: plan.id, executed: true,
1660
+ stateVersion: plan.state_version, confidence: adapter.confidence };
1661
+ }
1662
+ catch (error) {
1663
+ if (error instanceof StateQLError)
1664
+ throw error;
1665
+ if (error instanceof AdapterExecutionError)
1666
+ throw stoppedStateQLError(error, true);
1667
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
1668
+ }
1669
+ finally {
1670
+ await closeAdapterQuietly(adapter);
1671
+ }
1672
+ });
1673
+ }
1241
1674
  async apply(planId, options = {}) {
1242
1675
  let historySql;
1243
1676
  return this.run("apply", async (session) => {
@@ -1257,14 +1690,23 @@ export class StateQL {
1257
1690
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
1258
1691
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
1259
1692
  }
1260
- const nativePlan = plan.statement_type.startsWith("mongo.");
1261
- historySql = nativePlan ? undefined : plan.sql;
1262
- const mongoCommand = nativePlan
1693
+ const tableUpdate = plan.statement_type === "table.update" ? parseTableUpdate(plan.parameters) : undefined;
1694
+ const tableUpdates = plan.statement_type === "table.updates" ? parseTableUpdates(plan.parameters) : undefined;
1695
+ const compiled = tableUpdate ? compileTableUpdate(tableUpdate) : undefined;
1696
+ const compiledUpdates = tableUpdates?.map(compileTableUpdate);
1697
+ if (compiled && compiled.sql !== plan.sql)
1698
+ throw new StateQLError("STALE_PLAN", "Stored update does not match its plan.");
1699
+ const mongoPlan = tableUpdate?.metadata.driver === "mongodb" || tableUpdates?.[0]?.metadata.driver === "mongodb" || plan.statement_type.startsWith("mongo.");
1700
+ const redisPlan = plan.statement_type.startsWith("redis.");
1701
+ const nativePlan = Boolean(mongoPlan || redisPlan);
1702
+ historySql = nativePlan || tableUpdates ? undefined : plan.sql;
1703
+ const mongoCommand = compiled?.mongo ?? (plan.statement_type.startsWith("mongo.")
1263
1704
  ? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
1264
- : undefined;
1265
- const planParameters = nativePlan
1705
+ : undefined);
1706
+ const redisStored = redisPlan ? storedRedisPlan(plan.parameters, plan.statement_type, plan.id) : undefined;
1707
+ const planParameters = compiled?.params ?? (nativePlan || tableUpdates
1266
1708
  ? undefined
1267
- : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
1709
+ : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters));
1268
1710
  const claimToken = this.store.nextId("claim");
1269
1711
  const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
1270
1712
  if (!claimed) {
@@ -1277,17 +1719,30 @@ export class StateQL {
1277
1719
  version(connection) !== claimed.state_version) {
1278
1720
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
1279
1721
  }
1280
- if (nativePlan && connection.driver !== "mongodb") {
1722
+ if (mongoPlan && connection.driver !== "mongodb") {
1281
1723
  throw new StateQLError("STALE_PLAN", "MongoDB plan is not attached to a MongoDB connection.");
1282
1724
  }
1283
- if (!nativePlan && connection.driver === "mongodb") {
1725
+ if (redisPlan && connection.driver !== "redis") {
1726
+ throw new StateQLError("STALE_PLAN", "Redis plan is not attached to a Redis connection.");
1727
+ }
1728
+ if (!nativePlan && (connection.driver === "mongodb" || connection.driver === "redis")) {
1284
1729
  this.rejectMongoSql(connection, "mongoPlan");
1285
1730
  }
1731
+ if (tableUpdate && JSON.stringify(await this.editableMetadata(tableUpdate.metadata.table, options)) !== JSON.stringify(tableUpdate.metadata))
1732
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload and plan again.");
1733
+ if (tableUpdates) {
1734
+ for (const update of tableUpdates) {
1735
+ if (JSON.stringify(await this.editableMetadata(update.metadata.table, options)) !== JSON.stringify(update.metadata))
1736
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload and plan again.");
1737
+ }
1738
+ }
1286
1739
  const context = this.executionContext(options);
1287
1740
  const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
1288
- const adapter = nativePlan
1741
+ const adapter = mongoPlan
1289
1742
  ? await this.openMongoAdapter(connection, context, adapterSource)
1290
- : await this.openAdapter(connection, context, adapterSource);
1743
+ : redisPlan
1744
+ ? await this.openRedisAdapter(connection, context, adapterSource)
1745
+ : await this.openAdapter(connection, context, adapterSource);
1291
1746
  try {
1292
1747
  if ((await adapter.signature()) !== claimed.state_signature) {
1293
1748
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
@@ -1304,16 +1759,22 @@ export class StateQL {
1304
1759
  finally {
1305
1760
  await closeAdapterQuietly(adapter);
1306
1761
  }
1307
- const result = mongoCommand
1308
- ? await this.performMongoExec(session, connection, mongoCommand, {
1309
- allowUnbounded: Boolean(claimed.allow_unbounded),
1310
- allowDestructive: Boolean(claimed.allow_destructive),
1311
- }, context, { planId: claimed.id, claimToken }, adapterSource)
1312
- : await this.performExec(session, connection, claimed.sql, {
1313
- params: planParameters,
1314
- allowUnbounded: Boolean(claimed.allow_unbounded),
1315
- allowDestructive: Boolean(claimed.allow_destructive),
1316
- }, context, { planId: claimed.id, claimToken }, adapterSource);
1762
+ const result = tableUpdates && compiledUpdates
1763
+ ? await this.performTableBatch(session, connection, tableUpdates, compiledUpdates, context, { planId: claimed.id, claimToken }, adapterSource)
1764
+ : redisStored
1765
+ ? await this.performRedisExec(session, connection, redisStored.command, {}, context, { planId: claimed.id, claimToken }, adapterSource, redisStored.precondition)
1766
+ : mongoCommand
1767
+ ? await this.performMongoExec(session, connection, mongoCommand, {
1768
+ allowUnbounded: Boolean(claimed.allow_unbounded),
1769
+ allowDestructive: Boolean(claimed.allow_destructive),
1770
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1771
+ }, context, { planId: claimed.id, claimToken }, adapterSource)
1772
+ : await this.performExec(session, connection, claimed.sql, {
1773
+ params: planParameters,
1774
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1775
+ allowUnbounded: Boolean(claimed.allow_unbounded),
1776
+ allowDestructive: Boolean(claimed.allow_destructive),
1777
+ }, context, { planId: claimed.id, claimToken }, adapterSource);
1317
1778
  return {
1318
1779
  ...result,
1319
1780
  data: { plan_id: claimed.id, ...result.data },
@@ -1333,15 +1794,22 @@ export class StateQL {
1333
1794
  }, () => historySql);
1334
1795
  }
1335
1796
  async history(limit = 20, options = {}) {
1336
- return this.run("history", async (session) => ({
1337
- data: {
1338
- history: this.store
1339
- .history(session.id, positiveInteger(limit, "limit"), options.origin === undefined
1340
- ? undefined
1341
- : parseCommandOrigin(options.origin))
1342
- .map(historyEntry),
1343
- },
1344
- }));
1797
+ return this.run("history", async (session) => {
1798
+ if (options.internal !== undefined && typeof options.internal !== "boolean")
1799
+ throw new StateQLError("INVALID_COMMAND", "History internal filter must be boolean.");
1800
+ return {
1801
+ data: {
1802
+ history: this.store
1803
+ .history(session.id, positiveInteger(limit, "limit"), {
1804
+ ...(options.origin === undefined ? {} : { origin: parseCommandOrigin(options.origin) }),
1805
+ ...(options.category === undefined ? {} : { category: parseHistoryCategory(options.category) }),
1806
+ ...(options.internal === undefined ? {} : { internal: options.internal }),
1807
+ offset: nonNegativeInteger(options.offset ?? 0, "offset"),
1808
+ })
1809
+ .map(historyEntry),
1810
+ },
1811
+ };
1812
+ });
1345
1813
  }
1346
1814
  async doctor() {
1347
1815
  return this.run("doctor", async (session) => ({
@@ -1365,7 +1833,7 @@ export class StateQL {
1365
1833
  async capabilities() {
1366
1834
  return this.run("capabilities", async () => ({
1367
1835
  data: {
1368
- drivers: ["mongodb", "mysql", "postgres", "sqlite"],
1836
+ drivers: ["mongodb", "mysql", "postgres", "redis", "sqlite"],
1369
1837
  features: {
1370
1838
  result_handles: true,
1371
1839
  write_deduplication: true,
@@ -1380,6 +1848,10 @@ export class StateQL {
1380
1848
  state_diagnostics: true,
1381
1849
  state_purge: true,
1382
1850
  state_quota: true,
1851
+ bounded_catalog: true,
1852
+ generated_aliases: true,
1853
+ multi_row_table_plans: true,
1854
+ history_classification: true,
1383
1855
  },
1384
1856
  driver_features: {
1385
1857
  mongodb: {
@@ -1391,6 +1863,15 @@ export class StateQL {
1391
1863
  transactions_require_replica_set: true,
1392
1864
  inspection: true,
1393
1865
  },
1866
+ redis: {
1867
+ sql: false,
1868
+ native_read: true,
1869
+ native_write: true,
1870
+ plans: true,
1871
+ transactions: false,
1872
+ guarded_single_key_writes: true,
1873
+ inspection: true,
1874
+ },
1394
1875
  },
1395
1876
  },
1396
1877
  }));
@@ -1428,6 +1909,13 @@ export class StateQL {
1428
1909
  secretEnv: command.secret_env,
1429
1910
  credentialRef: command.credential_ref,
1430
1911
  });
1912
+ case "profile.update":
1913
+ return this.updateProfile(batchString(command.name, "name"), {
1914
+ ...(command.target !== undefined ? { target: command.target } : {}),
1915
+ ...(command.secret_env !== undefined ? { secretEnv: command.secret_env } : {}),
1916
+ ...(command.credential_ref !== undefined ? { credentialRef: command.credential_ref } : {}),
1917
+ ...(command.read_only !== undefined ? { readOnly: command.read_only } : {}),
1918
+ });
1431
1919
  case "profile.list":
1432
1920
  return this.listProfiles();
1433
1921
  case "profile.show":
@@ -1477,6 +1965,13 @@ export class StateQL {
1477
1965
  data: { ...response.data, alias: command.as },
1478
1966
  };
1479
1967
  }
1968
+ case "redis.query": {
1969
+ const response = await this.redisQuery(command.redis, { cache: command.cache ?? "auto", timeoutMs: command.timeout_ms });
1970
+ if (!response.ok || !command.as)
1971
+ return response;
1972
+ this.store.setAlias(response.session_id, command.as, response.data.result_id);
1973
+ return { ...response, data: { ...response.data, alias: command.as } };
1974
+ }
1480
1975
  case "filter": {
1481
1976
  const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
1482
1977
  if (!response.ok || !command.as)
@@ -1507,6 +2002,12 @@ export class StateQL {
1507
2002
  allowDestructive: command.allow_destructive ?? false,
1508
2003
  timeoutMs: command.timeout_ms,
1509
2004
  });
2005
+ case "redis.exec":
2006
+ return this.redisExec(command.redis, {
2007
+ replay: command.replay ?? false,
2008
+ idempotencyKey: command.idempotency_key,
2009
+ timeoutMs: command.timeout_ms,
2010
+ });
1510
2011
  case "show":
1511
2012
  return this.show(batchString(command.handle, "handle"));
1512
2013
  case "rows":
@@ -1524,6 +2025,16 @@ export class StateQL {
1524
2025
  return this.inspect(batchString(command.kind, "kind"), command.table, {
1525
2026
  timeoutMs: command.timeout_ms,
1526
2027
  });
2028
+ case "objects.list":
2029
+ return this.listObjects({
2030
+ ...(command.kind ? { kind: command.kind } : {}),
2031
+ ...(command.table ? { schema: command.table } : {}),
2032
+ ...(command.where ? { search: command.where } : {}),
2033
+ offset: command.cursor ?? command.offset ?? 0,
2034
+ limit: command.limit ?? 50,
2035
+ }, { timeoutMs: command.timeout_ms });
2036
+ case "object.describe":
2037
+ return this.describeObject(command.object, { timeoutMs: command.timeout_ms });
1527
2038
  case "transaction.begin":
1528
2039
  return this.beginTransaction(command.isolation);
1529
2040
  case "transaction.status":
@@ -1547,6 +2058,8 @@ export class StateQL {
1547
2058
  allowDestructive: command.allow_destructive,
1548
2059
  timeoutMs: command.timeout_ms,
1549
2060
  });
2061
+ case "redis.plan":
2062
+ return this.redisPlan(command.redis, { timeoutMs: command.timeout_ms });
1550
2063
  case "apply":
1551
2064
  return this.apply(batchString(command.handle, "handle"), {
1552
2065
  timeoutMs: command.timeout_ms,
@@ -1554,6 +2067,9 @@ export class StateQL {
1554
2067
  case "history":
1555
2068
  return this.history(command.limit ?? 20, {
1556
2069
  origin: command.history_origin,
2070
+ category: command.history_category,
2071
+ internal: command.history_internal,
2072
+ offset: command.offset,
1557
2073
  });
1558
2074
  case "receipt":
1559
2075
  return this.receipt(batchString(command.handle, "handle"));
@@ -1716,7 +2232,7 @@ export class StateQL {
1716
2232
  });
1717
2233
  }
1718
2234
  try {
1719
- const write = await adapter.write(sql, parameters);
2235
+ const write = await adapter.write(sql, parameters, options.expectedRows);
1720
2236
  try {
1721
2237
  const finalized = planClaim
1722
2238
  ? this.store.finishPlannedOperation({
@@ -1764,6 +2280,8 @@ export class StateQL {
1764
2280
  }
1765
2281
  if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1766
2282
  this.store.failOperation(operation.id);
2283
+ if (error.message.startsWith("ROW_CONFLICT:"))
2284
+ throw new StateQLError("ROW_CONFLICT", "The row changed or no longer matches. Reload before editing.");
1767
2285
  throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
1768
2286
  }
1769
2287
  this.store.markOperationOutcomeUnknown(operation.id);
@@ -1906,7 +2424,7 @@ export class StateQL {
1906
2424
  });
1907
2425
  }
1908
2426
  try {
1909
- const write = await adapter.write(value);
2427
+ const write = await adapter.write(value, options.expectedRows);
1910
2428
  try {
1911
2429
  const finalized = planClaim
1912
2430
  ? this.store.finishPlannedOperation({
@@ -1955,6 +2473,8 @@ export class StateQL {
1955
2473
  }
1956
2474
  if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1957
2475
  this.store.failOperation(operation.id);
2476
+ if (error.message.startsWith("ROW_CONFLICT:"))
2477
+ throw new StateQLError("ROW_CONFLICT", "The document changed or was removed. Reload before editing.");
1958
2478
  throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
1959
2479
  }
1960
2480
  this.store.markOperationOutcomeUnknown(operation.id);
@@ -1972,6 +2492,139 @@ export class StateQL {
1972
2492
  }
1973
2493
  }
1974
2494
  }
2495
+ async performRedisExec(session, connection, command, options, context, planClaim, resolvedSource, precondition) {
2496
+ const value = validatedRedisWrite(command);
2497
+ if (connection.driver !== "redis")
2498
+ throw new StateQLError("INVALID_COMMAND", "redisExec requires an active Redis connection.");
2499
+ if (connection.read_only)
2500
+ throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.");
2501
+ if (options.idempotencyKey !== undefined && !options.idempotencyKey.trim())
2502
+ throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
2503
+ const serialized = serializeRedisCommand(value);
2504
+ const fingerprint = hash({ command: serialized, database: databaseIdentity(connection) });
2505
+ const reservation = this.store.reserveOperation({
2506
+ sessionId: session.id, actorId: this.actorId, connectionId: connection.id, fingerprint,
2507
+ sql: `Redis native ${value.command}`, parameters: [serialized], statementType: `redis.${value.command.toLowerCase()}`,
2508
+ status: "executing", replay: options.replay ?? false, idempotencyKey: options.idempotencyKey,
2509
+ stateVersionBefore: version(connection),
2510
+ });
2511
+ if (reservation.denied)
2512
+ throw new StateQLError(reservation.denied === "membership" ? "PERMISSION_DENIED" : "TRANSACTION_FAILED", "Redis write reservation was denied.");
2513
+ const previous = reservation.previous;
2514
+ if (previous && options.idempotencyKey && !options.replay && previous.fingerprint !== fingerprint) {
2515
+ throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.");
2516
+ }
2517
+ if (previous && !reservation.operation) {
2518
+ if (previous.status === "executing" || previous.status === "outcome_unknown")
2519
+ throw new StateQLError("OUTCOME_UNKNOWN", "A matching Redis write has an unknown outcome.", { executed: true });
2520
+ if (options.idempotencyKey)
2521
+ return { data: { ...operationData(previous), duplicate: true, duplicate_of: previous.id, idempotency_key: options.idempotencyKey }, handle: previous.id, cached: true, stateVersion: previous.state_version_after ?? previous.state_version_before };
2522
+ throw new StateQLError("POTENTIAL_DUPLICATE_WRITE", "An equivalent Redis operation was previously applied.", { extra: { previous_operation_id: previous.id, replay_required: true } });
2523
+ }
2524
+ const operation = reservation.operation;
2525
+ let adapter;
2526
+ let source;
2527
+ try {
2528
+ source = resolvedSource ?? await this.resolveConnectionSource(connection, session, "exec", "write", context);
2529
+ adapter = await this.openRedisAdapter(connection, context, source);
2530
+ }
2531
+ catch (error) {
2532
+ this.store.failOperation(operation.id);
2533
+ if (error instanceof StateQLError)
2534
+ throw error;
2535
+ throw new StateQLError("CONNECTION_FAILED", "Redis connection failed.", { retryable: true });
2536
+ }
2537
+ try {
2538
+ const write = await adapter.write(value, precondition);
2539
+ try {
2540
+ const finalized = planClaim
2541
+ ? this.store.finishPlannedOperation({ planId: planClaim.planId, claimToken: planClaim.claimToken, operationId: operation.id,
2542
+ connectionId: connection.id, affectedRows: write.affectedRows, outcome: write.outcome })
2543
+ : (() => { const stateVersion = this.store.bumpVersion(connection.id); return { operation: this.store.finishOperation(operation.id, write.affectedRows, stateVersion, write.outcome), stateVersion }; })();
2544
+ return { data: { ...operationData(finalized.operation), duplicate: Boolean(previous), duplicate_override: Boolean(previous) },
2545
+ handle: finalized.operation.id, executed: true, stateVersion: finalized.stateVersion, confidence: adapter.confidence };
2546
+ }
2547
+ catch (error) {
2548
+ this.store.markOperationOutcomeUnknown(operation.id);
2549
+ throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), { executed: true, suggestedAction: "Inspect Redis state before issuing a replacement write." });
2550
+ }
2551
+ }
2552
+ catch (error) {
2553
+ if (error instanceof StateQLError)
2554
+ throw error;
2555
+ if ((error instanceof AdapterExecutionError || error instanceof AdapterWriteError) && !error.outcomeUnknown) {
2556
+ this.store.failOperation(operation.id);
2557
+ if (error.message.startsWith("ROW_CONFLICT:"))
2558
+ throw new StateQLError("ROW_CONFLICT", "The Redis key changed. Reload and plan again.");
2559
+ throw error instanceof AdapterExecutionError ? stoppedStateQLError(error, false) : new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
2560
+ }
2561
+ this.store.markOperationOutcomeUnknown(operation.id);
2562
+ throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, source), { executed: true, suggestedAction: "Inspect Redis state before issuing a replacement write." });
2563
+ }
2564
+ finally {
2565
+ await closeAdapterQuietly(adapter);
2566
+ }
2567
+ }
2568
+ async performTableBatch(session, connection, updates, compiled, context, planClaim, source) {
2569
+ if (connection.driver === "redis")
2570
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support table edit batches.");
2571
+ const reservation = this.store.reserveOperation({
2572
+ sessionId: session.id, actorId: this.actorId, connectionId: connection.id,
2573
+ fingerprint: hash({ plan: planClaim.planId, updates }), sql: `Conditional table update batch (${updates.length} rows)`,
2574
+ parameters: [JSON.stringify({ version: 1, updates })], statementType: "table.updates", status: "executing",
2575
+ replay: true, stateVersionBefore: version(connection),
2576
+ });
2577
+ if (!reservation.operation)
2578
+ throw new StateQLError("STALE_PLAN", "Table edit batch could not be reserved.");
2579
+ const operation = reservation.operation;
2580
+ const adapter = connection.driver === "mongodb"
2581
+ ? await this.openMongoAdapter(connection, context, source)
2582
+ : await this.openAdapter(connection, context, source);
2583
+ try {
2584
+ let affectedRows;
2585
+ if (connection.driver === "mongodb") {
2586
+ const commands = compiled.map((item) => {
2587
+ if (!item.mongo)
2588
+ throw new StateQLError("STALE_PLAN", "Table edit batch contains mixed drivers.");
2589
+ return item.mongo;
2590
+ });
2591
+ const results = await adapter.writeBatch(commands, "snapshot", true);
2592
+ affectedRows = results.reduce((sum, item) => sum + item.affectedRows, 0);
2593
+ }
2594
+ else {
2595
+ const operations = compiled.map((item, index) => ({
2596
+ ...operation, id: `${operation.id}:${index}`, sql: item.sql, parameters: JSON.stringify(item.params), statement_type: "update", expectedRows: 1,
2597
+ }));
2598
+ const results = await adapter.writeBatch(operations, "serializable");
2599
+ affectedRows = results.reduce((sum, item) => sum + item.affectedRows, 0);
2600
+ }
2601
+ try {
2602
+ const finalized = this.store.finishPlannedOperation({ planId: planClaim.planId, claimToken: planClaim.claimToken,
2603
+ operationId: operation.id, connectionId: connection.id, affectedRows });
2604
+ return { data: operationData(finalized.operation), handle: finalized.operation.id, executed: true,
2605
+ stateVersion: finalized.stateVersion, confidence: adapter.confidence };
2606
+ }
2607
+ catch (error) {
2608
+ this.store.markOperationOutcomeUnknown(operation.id);
2609
+ throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), { executed: true, suggestedAction: "Inspect every edited row before retrying." });
2610
+ }
2611
+ }
2612
+ catch (error) {
2613
+ if (error instanceof StateQLError)
2614
+ throw error;
2615
+ if ((error instanceof BatchWriteError || error instanceof AdapterExecutionError) && !error.outcomeUnknown) {
2616
+ this.store.failOperation(operation.id);
2617
+ if (error.message.startsWith("ROW_CONFLICT:"))
2618
+ throw new StateQLError("ROW_CONFLICT", "At least one row changed; the entire edit batch was rolled back.");
2619
+ throw error instanceof AdapterExecutionError ? stoppedStateQLError(error, false) : new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
2620
+ }
2621
+ this.store.markOperationOutcomeUnknown(operation.id);
2622
+ throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, source), { executed: true, suggestedAction: "Inspect every edited row before retrying." });
2623
+ }
2624
+ finally {
2625
+ await closeAdapterQuietly(adapter);
2626
+ }
2627
+ }
1975
2628
  batchFailure(message) {
1976
2629
  return this.run("batch", async () => {
1977
2630
  throw new StateQLError("INVALID_COMMAND", message);
@@ -2015,10 +2668,20 @@ export class StateQL {
2015
2668
  }
2016
2669
  return connection;
2017
2670
  }
2671
+ requireRedisConnection(session, method) {
2672
+ const connection = this.requireConnection(session);
2673
+ if (connection.driver !== "redis")
2674
+ throw new StateQLError("INVALID_COMMAND", `${method} requires an active Redis connection.`);
2675
+ return connection;
2676
+ }
2018
2677
  rejectMongoSql(connection, nativeMethod) {
2019
- if (connection.driver !== "mongodb")
2678
+ if (connection.driver !== "mongodb" && connection.driver !== "redis")
2020
2679
  return;
2021
- throw new StateQLError("INVALID_COMMAND", `SQL is not supported for MongoDB connections; use ${nativeMethod} instead.`, { suggestedAction: `Use ${nativeMethod} with a native MongoDB command.` });
2680
+ const method = connection.driver === "redis"
2681
+ ? nativeMethod === "mongoQuery" ? "redisQuery" : nativeMethod === "mongoExec" ? "redisExec" : "redisPlan"
2682
+ : nativeMethod;
2683
+ const name = connection.driver === "redis" ? "Redis" : "MongoDB";
2684
+ throw new StateQLError("INVALID_COMMAND", `SQL is not supported for ${name} connections; use ${method} instead.`, { suggestedAction: `Use ${method} with a native ${name} command.` });
2022
2685
  }
2023
2686
  requireActiveTransaction(session, id) {
2024
2687
  const transactionId = id ?? session.active_transaction_id;
@@ -2126,6 +2789,18 @@ export class StateQL {
2126
2789
  throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
2127
2790
  }
2128
2791
  }
2792
+ async openRedisAdapter(connection, context, source) {
2793
+ try {
2794
+ if (connection.driver !== "redis")
2795
+ throw new Error("Redis adapter requires a Redis connection.");
2796
+ return new RedisAdapter(source, Boolean(connection.read_only), context);
2797
+ }
2798
+ catch (error) {
2799
+ if (error instanceof StateQLError)
2800
+ throw error;
2801
+ throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
2802
+ }
2803
+ }
2129
2804
  executionContext(options) {
2130
2805
  return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), combineAbortSignals(options.signal, this.commandContexts.getStore()?.signal, this.signal));
2131
2806
  }
@@ -2134,6 +2809,8 @@ export class StateQL {
2134
2809
  const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
2135
2810
  return {
2136
2811
  result_id: result.id,
2812
+ alias: result.alias ?? this.store.generatedAlias(result.id),
2813
+ display_alias: result.alias ?? this.store.generatedAlias(result.id),
2137
2814
  rows: result.row_count,
2138
2815
  columns: this.store.resultColumns(result),
2139
2816
  preview,
@@ -2141,6 +2818,7 @@ export class StateQL {
2141
2818
  truncated: preview.length < result.row_count,
2142
2819
  cached,
2143
2820
  ...(cached ? { duplicate_of: result.id } : {}),
2821
+ ...(result.sql.startsWith("Redis native ") ? { next_cursor: redisResultCursor(result.parameters) } : {}),
2144
2822
  state_version: result.state_version,
2145
2823
  storage: {
2146
2824
  mode: "materialized",
@@ -2155,9 +2833,12 @@ export class StateQL {
2155
2833
  result.state_version === stateVersion &&
2156
2834
  result.state_signature === stateSignature);
2157
2835
  }
2158
- async run(command, action, historySql) {
2836
+ async run(command, action, historySql, historyTarget) {
2159
2837
  const started = performance.now();
2160
- const origin = this.commandContexts.getStore()?.origin ?? "legacy";
2838
+ const commandContext = this.commandContexts.getStore();
2839
+ const origin = commandContext?.origin ?? "legacy";
2840
+ const category = historyCategory(command);
2841
+ const internal = commandContext?.internal ?? false;
2161
2842
  let session = this.store.ensureSession(this.sessionName);
2162
2843
  const commandId = this.store.nextId("cmd");
2163
2844
  if (!this.store.isSessionMember(session.id, this.actorId)) {
@@ -2181,7 +2862,10 @@ export class StateQL {
2181
2862
  sessionId: session.id,
2182
2863
  actorId: this.actorId,
2183
2864
  origin,
2865
+ category,
2866
+ internal,
2184
2867
  command,
2868
+ target: historyTarget,
2185
2869
  ...(result.handle ? { handle: result.handle } : {}),
2186
2870
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2187
2871
  executed: result.executed ?? false,
@@ -2213,7 +2897,10 @@ export class StateQL {
2213
2897
  sessionId: session.id,
2214
2898
  actorId: this.actorId,
2215
2899
  origin,
2900
+ category,
2901
+ internal,
2216
2902
  command,
2903
+ target: historyTarget,
2217
2904
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
2218
2905
  executed: stateqlError.details.executed,
2219
2906
  cached: false,
@@ -2242,8 +2929,11 @@ function historyEntry(item) {
2242
2929
  session_id: item.session_id,
2243
2930
  actor_id: item.actor_id,
2244
2931
  origin: item.origin,
2932
+ category: item.category,
2933
+ internal: Boolean(item.internal),
2245
2934
  command: item.command,
2246
2935
  sql: item.sql,
2936
+ ...(item.target ? { target: item.target } : {}),
2247
2937
  handle: item.handle,
2248
2938
  executed: Boolean(item.executed),
2249
2939
  cached: Boolean(item.cached),
@@ -2271,11 +2961,15 @@ function mergeCommandExecutionContext(inherited, supplied) {
2271
2961
  if (supplied.signal !== undefined && !(supplied.signal instanceof AbortSignal)) {
2272
2962
  throw new StateQLError("INVALID_COMMAND", "Command execution context signal must be an AbortSignal.");
2273
2963
  }
2964
+ if (supplied.internal !== undefined && typeof supplied.internal !== "boolean") {
2965
+ throw new StateQLError("INVALID_COMMAND", "Command execution context internal must be boolean.");
2966
+ }
2274
2967
  return {
2275
2968
  signal: combineAbortSignals(inherited?.signal, supplied.signal),
2276
2969
  origin: supplied.origin === undefined
2277
2970
  ? inherited?.origin
2278
2971
  : parseCommandOrigin(supplied.origin),
2972
+ internal: supplied.internal ?? inherited?.internal,
2279
2973
  };
2280
2974
  }
2281
2975
  function combineAbortSignals(...signals) {
@@ -2314,6 +3008,22 @@ function validatedMongoWrite(command) {
2314
3008
  throw new StateQLError("INVALID_COMMAND", errorMessage(error));
2315
3009
  }
2316
3010
  }
3011
+ function validatedRedisRead(command) {
3012
+ try {
3013
+ return validateRedisReadCommand(command);
3014
+ }
3015
+ catch (error) {
3016
+ throw new StateQLError("INVALID_COMMAND", errorMessage(error));
3017
+ }
3018
+ }
3019
+ function validatedRedisWrite(command) {
3020
+ try {
3021
+ return validateRedisWriteCommand(command);
3022
+ }
3023
+ catch (error) {
3024
+ throw new StateQLError("INVALID_COMMAND", errorMessage(error));
3025
+ }
3026
+ }
2317
3027
  function mongoDescriptor(operation) {
2318
3028
  return `MongoDB native ${operation}`;
2319
3029
  }
@@ -2355,14 +3065,68 @@ function storedMongoWrite(parameters, statementType, label, errorCode) {
2355
3065
  throw new StateQLError(errorCode, `Stored MongoDB ${label} payload is invalid.`);
2356
3066
  }
2357
3067
  }
3068
+ function storedRedisPlan(parameters, statementType, planId) {
3069
+ try {
3070
+ const outer = JSON.parse(parameters);
3071
+ if (!Array.isArray(outer) || outer.length !== 1 || typeof outer[0] !== "string")
3072
+ throw new Error();
3073
+ const payload = JSON.parse(outer[0]);
3074
+ if (typeof payload.command !== "string" || !payload.precondition || typeof payload.precondition !== "object")
3075
+ throw new Error();
3076
+ const command = deserializeRedisCommand(payload.command);
3077
+ const precondition = payload.precondition;
3078
+ if (typeof precondition.key !== "string" || typeof precondition.fingerprint !== "string" || typeof precondition.expiresAt !== "number" || !Number.isFinite(precondition.expiresAt) || statementType !== `redis.${command.command.toLowerCase()}`)
3079
+ throw new Error();
3080
+ return { command, precondition };
3081
+ }
3082
+ catch {
3083
+ throw new StateQLError("STALE_PLAN", `Stored Redis plan "${planId}" payload is invalid.`);
3084
+ }
3085
+ }
3086
+ function redisResultCursor(parameters) {
3087
+ try {
3088
+ const value = JSON.parse(parameters);
3089
+ return Array.isArray(value) && (typeof value[1] === "string" || value[1] === null) ? value[1] : null;
3090
+ }
3091
+ catch {
3092
+ return null;
3093
+ }
3094
+ }
3095
+ function tableUpdateIdentity(metadata, row) {
3096
+ const identity = metadata.driver === "mongodb"
3097
+ ? { table: metadata.table, id: row._id }
3098
+ : { table: metadata.table, keys: metadata.columns.filter((column) => column.key > 0).sort((a, b) => a.key - b.key).map((column) => [column.name, row[column.name]]) };
3099
+ return hash(identity);
3100
+ }
3101
+ function validateCatalogFilter(filter) {
3102
+ if (!filter || typeof filter !== "object" || Array.isArray(filter) || Object.keys(filter).some((key) => !["kind", "schema", "search", "offset", "limit"].includes(key)))
3103
+ throw new StateQLError("INVALID_COMMAND", "Catalog filter contains unknown fields.");
3104
+ if (filter.kind !== undefined && !["table", "view", "collection", "function", "trigger", "enum", "key"].includes(filter.kind))
3105
+ throw new StateQLError("INVALID_COMMAND", "Unknown catalog object kind.");
3106
+ for (const [name, value] of [["schema", filter.schema], ["search", filter.search]])
3107
+ if (value !== undefined && (typeof value !== "string" || !value || value.length > 200 || value.includes("\0")))
3108
+ throw new StateQLError("INVALID_COMMAND", `Catalog ${name} is invalid.`);
3109
+ if (filter.offset !== undefined && !((typeof filter.offset === "number" && Number.isSafeInteger(filter.offset) && filter.offset >= 0) || (typeof filter.offset === "string" && /^\d+$/.test(filter.offset))))
3110
+ throw new StateQLError("INVALID_COMMAND", "Catalog offset is invalid.");
3111
+ if (filter.limit !== undefined && (!Number.isSafeInteger(filter.limit) || filter.limit < 1 || filter.limit > 200))
3112
+ throw new StateQLError("INVALID_COMMAND", "Catalog limit must be 1-200.");
3113
+ }
3114
+ function validateCatalogObject(object) {
3115
+ if (!object || typeof object !== "object" || Array.isArray(object) || !["table", "view", "collection", "function", "trigger", "enum", "key"].includes(object.kind) || typeof object.name !== "string" || !object.name || object.name.length > 500 || object.name.includes("\0") || (object.schema !== undefined && (typeof object.schema !== "string" || !object.schema || object.schema.length > 500 || object.schema.includes("\0"))) || (object.identity !== undefined && (typeof object.identity !== "string" || !object.identity || object.identity.length > 1000 || object.identity.includes("\0"))))
3116
+ throw new StateQLError("INVALID_COMMAND", "Catalog object identity is invalid.");
3117
+ }
2358
3118
  function databaseDisplayName(driver) {
2359
3119
  if (driver === "mongodb")
2360
3120
  return "MongoDB";
3121
+ if (driver === "redis")
3122
+ return "Redis";
2361
3123
  return driver === "postgres" ? "PostgreSQL" : "MySQL";
2362
3124
  }
2363
3125
  function normalizeIsolation(isolation, driver) {
2364
3126
  const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
2365
3127
  .replace(/\s+/g, " ");
3128
+ if (driver === "redis")
3129
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support staged SQL-style transactions.");
2366
3130
  if (driver === "mongodb") {
2367
3131
  if (normalized === "snapshot")
2368
3132
  return normalized;
@@ -2459,6 +3223,37 @@ function executionTimeout(value, name = "timeoutMs") {
2459
3223
  function stoppedStateQLError(error, executed) {
2460
3224
  return new StateQLError(error.reason === "timeout" ? "DEADLINE_EXCEEDED" : "OPERATION_CANCELLED", error.message, { retryable: true, executed });
2461
3225
  }
3226
+ function validatedProfileSource(input) {
3227
+ const sourceCount = [input.target, input.secretEnv, input.credentialRef].filter((value) => value !== undefined).length;
3228
+ if (sourceCount !== 1 || input.target === "" || input.secretEnv === "" || input.credentialRef === "") {
3229
+ throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
3230
+ }
3231
+ if (input.secretEnv !== undefined && !isEnvironmentName(input.secretEnv))
3232
+ throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
3233
+ if (input.credentialRef !== undefined)
3234
+ validateCredentialRef(input.credentialRef);
3235
+ let target = input.target ?? null;
3236
+ if (target) {
3237
+ const driver = detectDriver(target);
3238
+ if (driver !== "sqlite" && databaseUrlHasSecret(target))
3239
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
3240
+ if (driver === "sqlite")
3241
+ target = normalizeSqliteSource(target);
3242
+ }
3243
+ return { target, secretEnv: input.secretEnv ?? null, credentialRef: input.credentialRef ?? null };
3244
+ }
3245
+ function historyCategory(command) {
3246
+ if (["query", "exec", "plan", "apply", "mongo.query", "mongo.exec", "mongo.plan", "redis.query", "redis.exec", "redis.plan", "filter"].includes(command))
3247
+ return "statement";
3248
+ if (command.startsWith("inspect.") || ["objects.list", "object.describe", "table.read"].includes(command))
3249
+ return "introspection";
3250
+ return "management";
3251
+ }
3252
+ function parseHistoryCategory(value) {
3253
+ if (value === "statement" || value === "introspection" || value === "management")
3254
+ return value;
3255
+ throw new StateQLError("INVALID_COMMAND", `Unknown history category "${String(value)}".`);
3256
+ }
2462
3257
  function errorMessage(error) {
2463
3258
  return error instanceof Error ? error.message : String(error);
2464
3259
  }