@fadhilp/stateql 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
- import { compileTableUpdate, editableRow, parseTableUpdate } from "./table-editor.js";
3
+ import { compileTableUpdate, editableRow, parseTableUpdate, parseTableUpdates } from "./table-editor.js";
4
4
  import { writeFileSync } from "node:fs";
5
5
  import { basename, resolve } from "node:path";
6
6
  import { env } from "node:process";
7
7
  import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
8
- 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";
9
9
  import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
10
10
  import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
11
+ import { deserializeRedisCommand, RedisAdapter, serializeRedisCommand, validateRedisReadCommand, validateRedisWriteCommand, } from "./redis.js";
11
12
  import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
12
13
  import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
13
14
  import { analyzeSql } from "./sql.js";
@@ -172,7 +173,9 @@ export class StateQL {
172
173
  ? basename(adapterSource)
173
174
  : driver === "mongodb"
174
175
  ? mongoDatabaseName(adapterSource)
175
- : new URL(secret).pathname.replace(/^\//, "") || driver;
176
+ : driver === "redis"
177
+ ? redisDatabaseName(adapterSource)
178
+ : new URL(secret).pathname.replace(/^\//, "") || driver;
176
179
  const draft = {
177
180
  id: "pending",
178
181
  session_id: session.id,
@@ -188,7 +191,9 @@ export class StateQL {
188
191
  };
189
192
  const adapter = driver === "mongodb"
190
193
  ? await this.openMongoAdapter(draft, context, adapterSource)
191
- : await this.openAdapter(draft, context, adapterSource);
194
+ : driver === "redis"
195
+ ? await this.openRedisAdapter(draft, context, adapterSource)
196
+ : await this.openAdapter(draft, context, adapterSource);
192
197
  try {
193
198
  await adapter.ping();
194
199
  }
@@ -218,6 +223,8 @@ export class StateQL {
218
223
  return {
219
224
  data: {
220
225
  connection_id: connection.id,
226
+ alias: connection.alias,
227
+ display_alias: connection.alias,
221
228
  driver,
222
229
  database: databaseName,
223
230
  name: connection.name,
@@ -236,34 +243,19 @@ export class StateQL {
236
243
  async addProfile(name, target, options = {}) {
237
244
  return this.run("profile.add", async () => {
238
245
  validateProfileName(name);
239
- const sourceCount = [target, options.secretEnv, options.credentialRef]
240
- .filter((value) => value !== undefined).length;
241
- if (sourceCount !== 1 || target === "") {
242
- throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
243
- }
244
246
  if (this.store.getProfile(name)) {
245
247
  throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
246
248
  }
247
- if (options.secretEnv !== undefined && !isEnvironmentName(options.secretEnv)) {
248
- throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
249
- }
250
- if (options.credentialRef !== undefined) {
251
- validateCredentialRef(options.credentialRef);
252
- }
253
- let storedTarget = target;
254
- if (target) {
255
- const driver = detectDriver(target);
256
- if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
257
- throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
258
- }
259
- if (driver === "sqlite")
260
- storedTarget = normalizeSqliteSource(target);
261
- }
262
- const profile = this.store.addProfile({
263
- name,
264
- target: storedTarget,
249
+ const source = validatedProfileSource({
250
+ target,
265
251
  secretEnv: options.secretEnv,
266
252
  credentialRef: options.credentialRef,
253
+ });
254
+ const profile = this.store.addProfile({
255
+ name,
256
+ target: source.target ?? undefined,
257
+ secretEnv: source.secretEnv ?? undefined,
258
+ credentialRef: source.credentialRef ?? undefined,
267
259
  readOnly: options.readOnly ?? true,
268
260
  });
269
261
  return {
@@ -273,6 +265,36 @@ export class StateQL {
273
265
  };
274
266
  });
275
267
  }
268
+ async updateProfile(name, changes) {
269
+ return this.run("profile.update", async () => {
270
+ validateProfileName(name);
271
+ const existing = this.store.getProfile(name);
272
+ if (!existing)
273
+ throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
274
+ if (!changes || typeof changes !== "object" || Array.isArray(changes) ||
275
+ Object.keys(changes).some((key) => !["target", "secretEnv", "credentialRef", "readOnly"].includes(key))) {
276
+ throw new StateQLError("INVALID_COMMAND", "Profile update contains unknown fields.");
277
+ }
278
+ if (changes.readOnly !== undefined && typeof changes.readOnly !== "boolean")
279
+ throw new StateQLError("INVALID_COMMAND", "Profile readOnly must be boolean.");
280
+ const changesSource = Object.hasOwn(changes, "target") || Object.hasOwn(changes, "secretEnv") || Object.hasOwn(changes, "credentialRef");
281
+ if (!changesSource && changes.readOnly === undefined)
282
+ throw new StateQLError("INVALID_COMMAND", "Profile update has no changes.");
283
+ const source = changesSource
284
+ ? validatedProfileSource({ target: changes.target ?? undefined, secretEnv: changes.secretEnv ?? undefined, credentialRef: changes.credentialRef ?? undefined })
285
+ : { target: existing.target, secretEnv: existing.secret_env, credentialRef: existing.credential_ref };
286
+ const profile = this.store.updateProfile({
287
+ name,
288
+ target: source.target,
289
+ secretEnv: source.secretEnv,
290
+ credentialRef: source.credentialRef,
291
+ readOnly: changes.readOnly ?? Boolean(existing.read_only),
292
+ });
293
+ if (!profile)
294
+ throw new StateQLError("CONNECTION_NOT_FOUND", `Profile "${name}" was not found.`);
295
+ return { data: profileData(profile), handle: `profile:${name}`, executed: true };
296
+ });
297
+ }
276
298
  async listProfiles() {
277
299
  return this.run("profile.list", async () => ({
278
300
  data: { profiles: this.store.listProfiles().map(profileData) },
@@ -331,6 +353,13 @@ export class StateQL {
331
353
  if (historyLimit > MAX_SNAPSHOT_HISTORY_LIMIT) {
332
354
  throw new StateQLError("INVALID_COMMAND", `historyLimit cannot exceed ${MAX_SNAPSHOT_HISTORY_LIMIT}.`);
333
355
  }
356
+ if (options.historyInternal !== undefined && typeof options.historyInternal !== "boolean") {
357
+ throw new StateQLError("INVALID_COMMAND", "Snapshot historyInternal filter must be boolean.");
358
+ }
359
+ const historyOptions = {
360
+ ...(options.historyCategory === undefined ? {} : { category: parseHistoryCategory(options.historyCategory) }),
361
+ ...(options.historyInternal === undefined ? {} : { internal: options.historyInternal }),
362
+ };
334
363
  return {
335
364
  session: {
336
365
  session_id: session.id,
@@ -341,6 +370,8 @@ export class StateQL {
341
370
  connection: connection
342
371
  ? {
343
372
  connection_id: connection.id,
373
+ alias: connection.alias,
374
+ display_alias: connection.alias,
344
375
  name: connection.name,
345
376
  status: "connected",
346
377
  driver: connection.driver,
@@ -371,7 +402,9 @@ export class StateQL {
371
402
  affected_rows: operation.affected_rows,
372
403
  status: operation.status,
373
404
  })),
374
- history: this.store.history(session.id, historyLimit).map(historyEntry),
405
+ history: this.store
406
+ .history(session.id, historyLimit, historyOptions)
407
+ .map(historyEntry),
375
408
  };
376
409
  }
377
410
  async status() {
@@ -748,6 +781,51 @@ export class StateQL {
748
781
  }
749
782
  });
750
783
  }
784
+ async redisQuery(command, options = {}) {
785
+ return this.run("redis.query", async (session) => {
786
+ const value = validatedRedisRead(command);
787
+ const serialized = serializeRedisCommand(value);
788
+ const connection = this.requireRedisConnection(session, "redisQuery");
789
+ this.rejectDuringStagedTransaction(session, "Redis queries");
790
+ const context = this.executionContext(options);
791
+ const source = await this.resolveConnectionSource(connection, session, "query", "read", context);
792
+ const adapter = await this.openRedisAdapter(connection, context, source);
793
+ try {
794
+ const stateVersion = version(connection);
795
+ const stateSignature = await adapter.signature();
796
+ const fingerprint = hash({ command: serialized, connection: connection.id, database: connection.database_name, stateVersion });
797
+ const cached = this.store.findResult(fingerprint);
798
+ const cacheMode = options.cache ?? "auto";
799
+ if (cacheMode !== "bypass" && cached && cached.row_count <= this.maxResultRows && this.cacheValid(cached, stateVersion, stateSignature)) {
800
+ return { data: this.resultData(cached, true), handle: cached.id, cached: true, stateVersion, confidence: cached.state_confidence };
801
+ }
802
+ if (cacheMode === "require")
803
+ throw new StateQLError("CACHE_MISS", "No valid cached Redis result exists.", { retryable: true });
804
+ const result = await adapter.read(value);
805
+ const parameters = [serialized, result.nextCursor ?? null];
806
+ const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") + Buffer.byteLength(JSON.stringify(result.rows), "utf8") + Buffer.byteLength(JSON.stringify(result.columns), "utf8");
807
+ if (result.rows.length > this.maxResultRows || resultBytes > this.maxResultBytes)
808
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", "Redis response exceeds materialization limits.");
809
+ const saved = this.store.saveResult({
810
+ sessionId: session.id, connectionId: connection.id, fingerprint,
811
+ sql: `Redis native ${value.command}`, parameters, rows: result.rows, columns: result.columns,
812
+ stateVersion, stateSignature, stateConfidence: adapter.confidence,
813
+ expiresAt: new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString(),
814
+ });
815
+ return { data: this.resultData(saved, false), handle: saved.id, executed: true, stateVersion, confidence: adapter.confidence };
816
+ }
817
+ catch (error) {
818
+ if (error instanceof StateQLError)
819
+ throw error;
820
+ if (error instanceof AdapterExecutionError)
821
+ throw stoppedStateQLError(error, true);
822
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { retryable: true, executed: true });
823
+ }
824
+ finally {
825
+ await closeAdapterQuietly(adapter);
826
+ }
827
+ });
828
+ }
751
829
  async show(idOrAlias) {
752
830
  return this.withResult("show", idOrAlias, async (result) => ({
753
831
  data: this.resultData(result, true),
@@ -954,7 +1032,7 @@ export class StateQL {
954
1032
  }));
955
1033
  }
956
1034
  async readTable(table, limit = 1000, options = {}) {
957
- return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), async () => {
1035
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api", internal: true }), async () => {
958
1036
  if (!table || typeof table.name !== "string" || !table.name || table.name.length > 500 || table.name.includes("\0") ||
959
1037
  (table.schema !== undefined && (typeof table.schema !== "string" || !table.schema || table.schema.length > 500 || table.schema.includes("\0"))) ||
960
1038
  !Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
@@ -967,6 +1045,8 @@ export class StateQL {
967
1045
  throw new StateQLError("INVALID_COMMAND", "Only the main SQLite schema is supported.");
968
1046
  if (driver === "mongodb" && table.schema)
969
1047
  throw new StateQLError("INVALID_COMMAND", "MongoDB collections do not accept a schema.");
1048
+ if (driver === "redis")
1049
+ throw new StateQLError("INVALID_COMMAND", "Redis keys are not SQL tables; use redisQuery and describeObject.");
970
1050
  const quote = (name) => driver === "mysql" ? "\`" + name.replaceAll("\`", "\`\`") + "\`" : '"' + name.replaceAll('"', '""') + '"';
971
1051
  const qualified = [table.schema, table.name].filter((part) => Boolean(part)).map(quote).join(".");
972
1052
  const query = driver === "mongodb" ? JSON.stringify({ operation: "find", collection: table.name, options: { limit } }, null, 2)
@@ -1014,6 +1094,68 @@ export class StateQL {
1014
1094
  }
1015
1095
  }));
1016
1096
  }
1097
+ async planTableUpdates(changes, options = {}) {
1098
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), () => this.run("table.plan", async (session) => {
1099
+ this.rejectDuringStagedTransaction(session, "Table edits");
1100
+ if (!Array.isArray(changes) || changes.length < 1 || changes.length > 100 || Buffer.byteLength(JSON.stringify(changes), "utf8") > 256 * 1024) {
1101
+ throw new StateQLError("INVALID_COMMAND", "Table edit batch must contain 1-100 bounded rows.");
1102
+ }
1103
+ const connection = this.requireConnection(session);
1104
+ if (connection.read_only)
1105
+ throw new StateQLError("READ_ONLY_CONNECTION", "This connection is read-only.");
1106
+ if (connection.driver === "redis")
1107
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support table edits.");
1108
+ const now = this.now().getTime();
1109
+ const tokens = new Set();
1110
+ const identities = new Set();
1111
+ const updates = [];
1112
+ let expires = Number.MAX_SAFE_INTEGER;
1113
+ const metadataByTable = new Map();
1114
+ for (const item of changes) {
1115
+ if (!item || typeof item.row_token !== "string" || tokens.has(item.row_token))
1116
+ throw new StateQLError("INVALID_COMMAND", "Table edit row tokens must be unique.");
1117
+ tokens.add(item.row_token);
1118
+ const original = this.editTokens.get(item.row_token);
1119
+ if (!original || original.sessionId !== session.id || original.connectionId !== connection.id || original.stateVersion !== version(connection) || original.expires <= now) {
1120
+ throw new StateQLError("STALE_PLAN", "A row identity expired or the connection changed. Reload the rows.");
1121
+ }
1122
+ const tableKey = JSON.stringify(original.metadata.table);
1123
+ let metadata = metadataByTable.get(tableKey);
1124
+ if (!metadata) {
1125
+ metadata = await this.editableMetadata(original.metadata.table, options);
1126
+ metadataByTable.set(tableKey, metadata);
1127
+ }
1128
+ if (JSON.stringify(metadata) !== JSON.stringify(original.metadata))
1129
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload the table.");
1130
+ const identity = tableUpdateIdentity(metadata, original.original);
1131
+ if (identities.has(identity))
1132
+ throw new StateQLError("INVALID_COMMAND", "The same row cannot appear twice in one edit batch.");
1133
+ identities.add(identity);
1134
+ const update = { metadata, original: original.original, changes: item.changes };
1135
+ compileTableUpdate(update);
1136
+ updates.push(update);
1137
+ expires = Math.min(expires, original.expires);
1138
+ }
1139
+ const payload = JSON.stringify({ version: 1, updates });
1140
+ const context = this.executionContext(options);
1141
+ const source = await this.resolveConnectionSource(connection, session, "plan", "read", context);
1142
+ const adapter = connection.driver === "mongodb" ? await this.openMongoAdapter(connection, context, source) : await this.openAdapter(connection, context, source);
1143
+ try {
1144
+ const plan = this.store.savePlan({
1145
+ sessionId: session.id, ownerActorId: this.actorId, connectionId: connection.id,
1146
+ sql: `Conditional table update batch (${updates.length} rows)`, parameters: [payload], statementType: "table.updates",
1147
+ stateVersion: version(connection), stateSignature: await adapter.signature(), destructive: false,
1148
+ allowUnbounded: false, allowDestructive: false, expiresAt: new Date(expires).toISOString(),
1149
+ });
1150
+ return { data: { plan_id: plan.id, statement_type: plan.statement_type, destructive: false, requires_confirmation: true,
1151
+ required_overrides: [], state_version: plan.state_version, owner_actor_id: plan.owner_actor_id, expires_at: plan.expires_at },
1152
+ handle: plan.id, executed: true, stateVersion: plan.state_version, confidence: adapter.confidence };
1153
+ }
1154
+ finally {
1155
+ await closeAdapterQuietly(adapter);
1156
+ }
1157
+ }));
1158
+ }
1017
1159
  async editableMetadata(table, options) {
1018
1160
  const snapshot = this.snapshot({ historyLimit: 1 });
1019
1161
  const driver = snapshot.connection.driver;
@@ -1086,6 +1228,14 @@ export class StateQL {
1086
1228
  return this.performMongoExec(session, connection, value, options, this.executionContext(options));
1087
1229
  });
1088
1230
  }
1231
+ async redisExec(command, options = {}) {
1232
+ return this.run("redis.exec", async (session) => {
1233
+ const value = validatedRedisWrite(command);
1234
+ const connection = this.requireRedisConnection(session, "redisExec");
1235
+ this.rejectDuringStagedTransaction(session, "Redis writes");
1236
+ return this.performRedisExec(session, connection, value, options, this.executionContext(options));
1237
+ });
1238
+ }
1089
1239
  async receipt(id) {
1090
1240
  return this.run("receipt", async (session) => {
1091
1241
  const operation = this.store.getOperation(id);
@@ -1102,6 +1252,8 @@ export class StateQL {
1102
1252
  async beginTransaction(isolation) {
1103
1253
  return this.run("transaction.begin", async (session) => {
1104
1254
  const connection = this.requireConnection(session);
1255
+ if (connection.driver === "redis")
1256
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support staged StateQL transactions; use redisPlan/apply for one guarded mutation.");
1105
1257
  if (connection.read_only) {
1106
1258
  throw new StateQLError("READ_ONLY_CONNECTION", "Cannot begin a write transaction on a read-only connection.");
1107
1259
  }
@@ -1276,6 +1428,8 @@ export class StateQL {
1276
1428
  this.rejectDuringStagedTransaction(session, "Schema inspection");
1277
1429
  const context = this.executionContext(options);
1278
1430
  const adapterSource = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
1431
+ if (connection.driver === "redis")
1432
+ throw new StateQLError("INVALID_COMMAND", "Legacy inspect is not supported for Redis; use listObjects or describeObject.");
1279
1433
  const adapter = connection.driver === "mongodb"
1280
1434
  ? await this.openMongoAdapter(connection, context, adapterSource)
1281
1435
  : await this.openAdapter(connection, context, adapterSource);
@@ -1302,6 +1456,56 @@ export class StateQL {
1302
1456
  }
1303
1457
  }, undefined, table);
1304
1458
  }
1459
+ async listObjects(filter = {}, options = {}) {
1460
+ return this.run("objects.list", async (session) => {
1461
+ validateCatalogFilter(filter);
1462
+ const connection = this.requireConnection(session);
1463
+ this.rejectDuringStagedTransaction(session, "Catalog discovery");
1464
+ const context = this.executionContext(options);
1465
+ const source = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
1466
+ const adapter = connection.driver === "mongodb"
1467
+ ? await this.openMongoAdapter(connection, context, source)
1468
+ : connection.driver === "redis"
1469
+ ? await this.openRedisAdapter(connection, context, source)
1470
+ : await this.openAdapter(connection, context, source);
1471
+ try {
1472
+ return { data: await adapter.listObjects(filter), executed: true, stateVersion: version(connection), confidence: adapter.confidence };
1473
+ }
1474
+ catch (error) {
1475
+ if (error instanceof AdapterExecutionError)
1476
+ throw stoppedStateQLError(error, true);
1477
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
1478
+ }
1479
+ finally {
1480
+ await closeAdapterQuietly(adapter);
1481
+ }
1482
+ });
1483
+ }
1484
+ async describeObject(object, options = {}) {
1485
+ return this.run("object.describe", async (session) => {
1486
+ validateCatalogObject(object);
1487
+ const connection = this.requireConnection(session);
1488
+ this.rejectDuringStagedTransaction(session, "Catalog description");
1489
+ const context = this.executionContext(options);
1490
+ const source = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
1491
+ const adapter = connection.driver === "mongodb"
1492
+ ? await this.openMongoAdapter(connection, context, source)
1493
+ : connection.driver === "redis"
1494
+ ? await this.openRedisAdapter(connection, context, source)
1495
+ : await this.openAdapter(connection, context, source);
1496
+ try {
1497
+ return { data: await adapter.describeObject(object), executed: true, stateVersion: version(connection), confidence: adapter.confidence };
1498
+ }
1499
+ catch (error) {
1500
+ if (error instanceof AdapterExecutionError)
1501
+ throw stoppedStateQLError(error, true);
1502
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
1503
+ }
1504
+ finally {
1505
+ await closeAdapterQuietly(adapter);
1506
+ }
1507
+ });
1508
+ }
1305
1509
  async plan(sql, options = {}) {
1306
1510
  return this.run("plan", async (session) => {
1307
1511
  const connection = this.requireConnection(session);
@@ -1434,6 +1638,43 @@ export class StateQL {
1434
1638
  }
1435
1639
  });
1436
1640
  }
1641
+ async redisPlan(command, options = {}) {
1642
+ return this.run("redis.plan", async (session) => {
1643
+ const value = validatedRedisWrite(command);
1644
+ const connection = this.requireRedisConnection(session, "redisPlan");
1645
+ this.rejectDuringStagedTransaction(session, "Plans");
1646
+ if (connection.read_only)
1647
+ throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.");
1648
+ const context = this.executionContext(options);
1649
+ const source = await this.resolveConnectionSource(connection, session, "plan", "read", context);
1650
+ const adapter = await this.openRedisAdapter(connection, context, source);
1651
+ try {
1652
+ const precondition = await adapter.precondition(value);
1653
+ const payload = JSON.stringify({ command: serializeRedisCommand(value), precondition });
1654
+ const plan = this.store.savePlan({
1655
+ sessionId: session.id, ownerActorId: this.actorId, connectionId: connection.id,
1656
+ sql: `Redis native ${value.command}`, parameters: [payload], statementType: `redis.${value.command.toLowerCase()}`,
1657
+ stateVersion: version(connection), stateSignature: await adapter.signature(), destructive: value.command === "DEL",
1658
+ allowUnbounded: false, allowDestructive: true,
1659
+ expiresAt: new Date(this.now().getTime() + 10 * 60_000).toISOString(),
1660
+ });
1661
+ return { data: { plan_id: plan.id, statement_type: plan.statement_type, destructive: Boolean(plan.destructive),
1662
+ requires_confirmation: true, required_overrides: [], state_version: plan.state_version,
1663
+ owner_actor_id: plan.owner_actor_id, expires_at: plan.expires_at }, handle: plan.id, executed: true,
1664
+ stateVersion: plan.state_version, confidence: adapter.confidence };
1665
+ }
1666
+ catch (error) {
1667
+ if (error instanceof StateQLError)
1668
+ throw error;
1669
+ if (error instanceof AdapterExecutionError)
1670
+ throw stoppedStateQLError(error, true);
1671
+ throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
1672
+ }
1673
+ finally {
1674
+ await closeAdapterQuietly(adapter);
1675
+ }
1676
+ });
1677
+ }
1437
1678
  async apply(planId, options = {}) {
1438
1679
  let historySql;
1439
1680
  return this.run("apply", async (session) => {
@@ -1454,15 +1695,20 @@ export class StateQL {
1454
1695
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
1455
1696
  }
1456
1697
  const tableUpdate = plan.statement_type === "table.update" ? parseTableUpdate(plan.parameters) : undefined;
1698
+ const tableUpdates = plan.statement_type === "table.updates" ? parseTableUpdates(plan.parameters) : undefined;
1457
1699
  const compiled = tableUpdate ? compileTableUpdate(tableUpdate) : undefined;
1700
+ const compiledUpdates = tableUpdates?.map(compileTableUpdate);
1458
1701
  if (compiled && compiled.sql !== plan.sql)
1459
1702
  throw new StateQLError("STALE_PLAN", "Stored update does not match its plan.");
1460
- const nativePlan = tableUpdate?.metadata.driver === "mongodb" || plan.statement_type.startsWith("mongo.");
1461
- historySql = nativePlan ? undefined : plan.sql;
1462
- const mongoCommand = compiled?.mongo ?? (nativePlan
1703
+ const mongoPlan = tableUpdate?.metadata.driver === "mongodb" || tableUpdates?.[0]?.metadata.driver === "mongodb" || plan.statement_type.startsWith("mongo.");
1704
+ const redisPlan = plan.statement_type.startsWith("redis.");
1705
+ const nativePlan = Boolean(mongoPlan || redisPlan);
1706
+ historySql = nativePlan || tableUpdates ? undefined : plan.sql;
1707
+ const mongoCommand = compiled?.mongo ?? (plan.statement_type.startsWith("mongo.")
1463
1708
  ? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
1464
1709
  : undefined);
1465
- const planParameters = compiled?.params ?? (nativePlan
1710
+ const redisStored = redisPlan ? storedRedisPlan(plan.parameters, plan.statement_type, plan.id) : undefined;
1711
+ const planParameters = compiled?.params ?? (nativePlan || tableUpdates
1466
1712
  ? undefined
1467
1713
  : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters));
1468
1714
  const claimToken = this.store.nextId("claim");
@@ -1477,19 +1723,30 @@ export class StateQL {
1477
1723
  version(connection) !== claimed.state_version) {
1478
1724
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
1479
1725
  }
1480
- if (nativePlan && connection.driver !== "mongodb") {
1726
+ if (mongoPlan && connection.driver !== "mongodb") {
1481
1727
  throw new StateQLError("STALE_PLAN", "MongoDB plan is not attached to a MongoDB connection.");
1482
1728
  }
1483
- if (!nativePlan && connection.driver === "mongodb") {
1729
+ if (redisPlan && connection.driver !== "redis") {
1730
+ throw new StateQLError("STALE_PLAN", "Redis plan is not attached to a Redis connection.");
1731
+ }
1732
+ if (!nativePlan && (connection.driver === "mongodb" || connection.driver === "redis")) {
1484
1733
  this.rejectMongoSql(connection, "mongoPlan");
1485
1734
  }
1486
1735
  if (tableUpdate && JSON.stringify(await this.editableMetadata(tableUpdate.metadata.table, options)) !== JSON.stringify(tableUpdate.metadata))
1487
1736
  throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload and plan again.");
1737
+ if (tableUpdates) {
1738
+ for (const update of tableUpdates) {
1739
+ if (JSON.stringify(await this.editableMetadata(update.metadata.table, options)) !== JSON.stringify(update.metadata))
1740
+ throw new StateQLError("STALE_PLAN", "Table metadata changed. Reload and plan again.");
1741
+ }
1742
+ }
1488
1743
  const context = this.executionContext(options);
1489
1744
  const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
1490
- const adapter = nativePlan
1745
+ const adapter = mongoPlan
1491
1746
  ? await this.openMongoAdapter(connection, context, adapterSource)
1492
- : await this.openAdapter(connection, context, adapterSource);
1747
+ : redisPlan
1748
+ ? await this.openRedisAdapter(connection, context, adapterSource)
1749
+ : await this.openAdapter(connection, context, adapterSource);
1493
1750
  try {
1494
1751
  if ((await adapter.signature()) !== claimed.state_signature) {
1495
1752
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
@@ -1506,18 +1763,22 @@ export class StateQL {
1506
1763
  finally {
1507
1764
  await closeAdapterQuietly(adapter);
1508
1765
  }
1509
- const result = mongoCommand
1510
- ? await this.performMongoExec(session, connection, mongoCommand, {
1511
- allowUnbounded: Boolean(claimed.allow_unbounded),
1512
- allowDestructive: Boolean(claimed.allow_destructive),
1513
- ...(tableUpdate ? { expectedRows: 1 } : {}),
1514
- }, context, { planId: claimed.id, claimToken }, adapterSource)
1515
- : await this.performExec(session, connection, claimed.sql, {
1516
- params: planParameters,
1517
- ...(tableUpdate ? { expectedRows: 1 } : {}),
1518
- allowUnbounded: Boolean(claimed.allow_unbounded),
1519
- allowDestructive: Boolean(claimed.allow_destructive),
1520
- }, context, { planId: claimed.id, claimToken }, adapterSource);
1766
+ const result = tableUpdates && compiledUpdates
1767
+ ? await this.performTableBatch(session, connection, tableUpdates, compiledUpdates, context, { planId: claimed.id, claimToken }, adapterSource)
1768
+ : redisStored
1769
+ ? await this.performRedisExec(session, connection, redisStored.command, {}, context, { planId: claimed.id, claimToken }, adapterSource, redisStored.precondition)
1770
+ : mongoCommand
1771
+ ? await this.performMongoExec(session, connection, mongoCommand, {
1772
+ allowUnbounded: Boolean(claimed.allow_unbounded),
1773
+ allowDestructive: Boolean(claimed.allow_destructive),
1774
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1775
+ }, context, { planId: claimed.id, claimToken }, adapterSource)
1776
+ : await this.performExec(session, connection, claimed.sql, {
1777
+ params: planParameters,
1778
+ ...(tableUpdate ? { expectedRows: 1 } : {}),
1779
+ allowUnbounded: Boolean(claimed.allow_unbounded),
1780
+ allowDestructive: Boolean(claimed.allow_destructive),
1781
+ }, context, { planId: claimed.id, claimToken }, adapterSource);
1521
1782
  return {
1522
1783
  ...result,
1523
1784
  data: { plan_id: claimed.id, ...result.data },
@@ -1537,15 +1798,22 @@ export class StateQL {
1537
1798
  }, () => historySql);
1538
1799
  }
1539
1800
  async history(limit = 20, options = {}) {
1540
- return this.run("history", async (session) => ({
1541
- data: {
1542
- history: this.store
1543
- .history(session.id, positiveInteger(limit, "limit"), options.origin === undefined
1544
- ? undefined
1545
- : parseCommandOrigin(options.origin))
1546
- .map(historyEntry),
1547
- },
1548
- }));
1801
+ return this.run("history", async (session) => {
1802
+ if (options.internal !== undefined && typeof options.internal !== "boolean")
1803
+ throw new StateQLError("INVALID_COMMAND", "History internal filter must be boolean.");
1804
+ return {
1805
+ data: {
1806
+ history: this.store
1807
+ .history(session.id, positiveInteger(limit, "limit"), {
1808
+ ...(options.origin === undefined ? {} : { origin: parseCommandOrigin(options.origin) }),
1809
+ ...(options.category === undefined ? {} : { category: parseHistoryCategory(options.category) }),
1810
+ ...(options.internal === undefined ? {} : { internal: options.internal }),
1811
+ offset: nonNegativeInteger(options.offset ?? 0, "offset"),
1812
+ })
1813
+ .map(historyEntry),
1814
+ },
1815
+ };
1816
+ });
1549
1817
  }
1550
1818
  async doctor() {
1551
1819
  return this.run("doctor", async (session) => ({
@@ -1569,7 +1837,7 @@ export class StateQL {
1569
1837
  async capabilities() {
1570
1838
  return this.run("capabilities", async () => ({
1571
1839
  data: {
1572
- drivers: ["mongodb", "mysql", "postgres", "sqlite"],
1840
+ drivers: ["mongodb", "mysql", "postgres", "redis", "sqlite"],
1573
1841
  features: {
1574
1842
  result_handles: true,
1575
1843
  write_deduplication: true,
@@ -1584,6 +1852,10 @@ export class StateQL {
1584
1852
  state_diagnostics: true,
1585
1853
  state_purge: true,
1586
1854
  state_quota: true,
1855
+ bounded_catalog: true,
1856
+ generated_aliases: true,
1857
+ multi_row_table_plans: true,
1858
+ history_classification: true,
1587
1859
  },
1588
1860
  driver_features: {
1589
1861
  mongodb: {
@@ -1595,6 +1867,15 @@ export class StateQL {
1595
1867
  transactions_require_replica_set: true,
1596
1868
  inspection: true,
1597
1869
  },
1870
+ redis: {
1871
+ sql: false,
1872
+ native_read: true,
1873
+ native_write: true,
1874
+ plans: true,
1875
+ transactions: false,
1876
+ guarded_single_key_writes: true,
1877
+ inspection: true,
1878
+ },
1598
1879
  },
1599
1880
  },
1600
1881
  }));
@@ -1632,6 +1913,13 @@ export class StateQL {
1632
1913
  secretEnv: command.secret_env,
1633
1914
  credentialRef: command.credential_ref,
1634
1915
  });
1916
+ case "profile.update":
1917
+ return this.updateProfile(batchString(command.name, "name"), {
1918
+ ...(command.target !== undefined ? { target: command.target } : {}),
1919
+ ...(command.secret_env !== undefined ? { secretEnv: command.secret_env } : {}),
1920
+ ...(command.credential_ref !== undefined ? { credentialRef: command.credential_ref } : {}),
1921
+ ...(command.read_only !== undefined ? { readOnly: command.read_only } : {}),
1922
+ });
1635
1923
  case "profile.list":
1636
1924
  return this.listProfiles();
1637
1925
  case "profile.show":
@@ -1681,6 +1969,13 @@ export class StateQL {
1681
1969
  data: { ...response.data, alias: command.as },
1682
1970
  };
1683
1971
  }
1972
+ case "redis.query": {
1973
+ const response = await this.redisQuery(command.redis, { cache: command.cache ?? "auto", timeoutMs: command.timeout_ms });
1974
+ if (!response.ok || !command.as)
1975
+ return response;
1976
+ this.store.setAlias(response.session_id, command.as, response.data.result_id);
1977
+ return { ...response, data: { ...response.data, alias: command.as } };
1978
+ }
1684
1979
  case "filter": {
1685
1980
  const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
1686
1981
  if (!response.ok || !command.as)
@@ -1711,6 +2006,12 @@ export class StateQL {
1711
2006
  allowDestructive: command.allow_destructive ?? false,
1712
2007
  timeoutMs: command.timeout_ms,
1713
2008
  });
2009
+ case "redis.exec":
2010
+ return this.redisExec(command.redis, {
2011
+ replay: command.replay ?? false,
2012
+ idempotencyKey: command.idempotency_key,
2013
+ timeoutMs: command.timeout_ms,
2014
+ });
1714
2015
  case "show":
1715
2016
  return this.show(batchString(command.handle, "handle"));
1716
2017
  case "rows":
@@ -1728,6 +2029,16 @@ export class StateQL {
1728
2029
  return this.inspect(batchString(command.kind, "kind"), command.table, {
1729
2030
  timeoutMs: command.timeout_ms,
1730
2031
  });
2032
+ case "objects.list":
2033
+ return this.listObjects({
2034
+ ...(command.kind ? { kind: command.kind } : {}),
2035
+ ...(command.table ? { schema: command.table } : {}),
2036
+ ...(command.where ? { search: command.where } : {}),
2037
+ offset: command.cursor ?? command.offset ?? 0,
2038
+ limit: command.limit ?? 50,
2039
+ }, { timeoutMs: command.timeout_ms });
2040
+ case "object.describe":
2041
+ return this.describeObject(command.object, { timeoutMs: command.timeout_ms });
1731
2042
  case "transaction.begin":
1732
2043
  return this.beginTransaction(command.isolation);
1733
2044
  case "transaction.status":
@@ -1751,6 +2062,8 @@ export class StateQL {
1751
2062
  allowDestructive: command.allow_destructive,
1752
2063
  timeoutMs: command.timeout_ms,
1753
2064
  });
2065
+ case "redis.plan":
2066
+ return this.redisPlan(command.redis, { timeoutMs: command.timeout_ms });
1754
2067
  case "apply":
1755
2068
  return this.apply(batchString(command.handle, "handle"), {
1756
2069
  timeoutMs: command.timeout_ms,
@@ -1758,6 +2071,9 @@ export class StateQL {
1758
2071
  case "history":
1759
2072
  return this.history(command.limit ?? 20, {
1760
2073
  origin: command.history_origin,
2074
+ category: command.history_category,
2075
+ internal: command.history_internal,
2076
+ offset: command.offset,
1761
2077
  });
1762
2078
  case "receipt":
1763
2079
  return this.receipt(batchString(command.handle, "handle"));
@@ -2180,6 +2496,139 @@ export class StateQL {
2180
2496
  }
2181
2497
  }
2182
2498
  }
2499
+ async performRedisExec(session, connection, command, options, context, planClaim, resolvedSource, precondition) {
2500
+ const value = validatedRedisWrite(command);
2501
+ if (connection.driver !== "redis")
2502
+ throw new StateQLError("INVALID_COMMAND", "redisExec requires an active Redis connection.");
2503
+ if (connection.read_only)
2504
+ throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.");
2505
+ if (options.idempotencyKey !== undefined && !options.idempotencyKey.trim())
2506
+ throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
2507
+ const serialized = serializeRedisCommand(value);
2508
+ const fingerprint = hash({ command: serialized, database: databaseIdentity(connection) });
2509
+ const reservation = this.store.reserveOperation({
2510
+ sessionId: session.id, actorId: this.actorId, connectionId: connection.id, fingerprint,
2511
+ sql: `Redis native ${value.command}`, parameters: [serialized], statementType: `redis.${value.command.toLowerCase()}`,
2512
+ status: "executing", replay: options.replay ?? false, idempotencyKey: options.idempotencyKey,
2513
+ stateVersionBefore: version(connection),
2514
+ });
2515
+ if (reservation.denied)
2516
+ throw new StateQLError(reservation.denied === "membership" ? "PERMISSION_DENIED" : "TRANSACTION_FAILED", "Redis write reservation was denied.");
2517
+ const previous = reservation.previous;
2518
+ if (previous && options.idempotencyKey && !options.replay && previous.fingerprint !== fingerprint) {
2519
+ throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.");
2520
+ }
2521
+ if (previous && !reservation.operation) {
2522
+ if (previous.status === "executing" || previous.status === "outcome_unknown")
2523
+ throw new StateQLError("OUTCOME_UNKNOWN", "A matching Redis write has an unknown outcome.", { executed: true });
2524
+ if (options.idempotencyKey)
2525
+ 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 };
2526
+ throw new StateQLError("POTENTIAL_DUPLICATE_WRITE", "An equivalent Redis operation was previously applied.", { extra: { previous_operation_id: previous.id, replay_required: true } });
2527
+ }
2528
+ const operation = reservation.operation;
2529
+ let adapter;
2530
+ let source;
2531
+ try {
2532
+ source = resolvedSource ?? await this.resolveConnectionSource(connection, session, "exec", "write", context);
2533
+ adapter = await this.openRedisAdapter(connection, context, source);
2534
+ }
2535
+ catch (error) {
2536
+ this.store.failOperation(operation.id);
2537
+ if (error instanceof StateQLError)
2538
+ throw error;
2539
+ throw new StateQLError("CONNECTION_FAILED", "Redis connection failed.", { retryable: true });
2540
+ }
2541
+ try {
2542
+ const write = await adapter.write(value, precondition);
2543
+ try {
2544
+ const finalized = planClaim
2545
+ ? this.store.finishPlannedOperation({ planId: planClaim.planId, claimToken: planClaim.claimToken, operationId: operation.id,
2546
+ connectionId: connection.id, affectedRows: write.affectedRows, outcome: write.outcome })
2547
+ : (() => { const stateVersion = this.store.bumpVersion(connection.id); return { operation: this.store.finishOperation(operation.id, write.affectedRows, stateVersion, write.outcome), stateVersion }; })();
2548
+ return { data: { ...operationData(finalized.operation), duplicate: Boolean(previous), duplicate_override: Boolean(previous) },
2549
+ handle: finalized.operation.id, executed: true, stateVersion: finalized.stateVersion, confidence: adapter.confidence };
2550
+ }
2551
+ catch (error) {
2552
+ this.store.markOperationOutcomeUnknown(operation.id);
2553
+ throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), { executed: true, suggestedAction: "Inspect Redis state before issuing a replacement write." });
2554
+ }
2555
+ }
2556
+ catch (error) {
2557
+ if (error instanceof StateQLError)
2558
+ throw error;
2559
+ if ((error instanceof AdapterExecutionError || error instanceof AdapterWriteError) && !error.outcomeUnknown) {
2560
+ this.store.failOperation(operation.id);
2561
+ if (error.message.startsWith("ROW_CONFLICT:"))
2562
+ throw new StateQLError("ROW_CONFLICT", "The Redis key changed. Reload and plan again.");
2563
+ throw error instanceof AdapterExecutionError ? stoppedStateQLError(error, false) : new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
2564
+ }
2565
+ this.store.markOperationOutcomeUnknown(operation.id);
2566
+ throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, source), { executed: true, suggestedAction: "Inspect Redis state before issuing a replacement write." });
2567
+ }
2568
+ finally {
2569
+ await closeAdapterQuietly(adapter);
2570
+ }
2571
+ }
2572
+ async performTableBatch(session, connection, updates, compiled, context, planClaim, source) {
2573
+ if (connection.driver === "redis")
2574
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support table edit batches.");
2575
+ const reservation = this.store.reserveOperation({
2576
+ sessionId: session.id, actorId: this.actorId, connectionId: connection.id,
2577
+ fingerprint: hash({ plan: planClaim.planId, updates }), sql: `Conditional table update batch (${updates.length} rows)`,
2578
+ parameters: [JSON.stringify({ version: 1, updates })], statementType: "table.updates", status: "executing",
2579
+ replay: true, stateVersionBefore: version(connection),
2580
+ });
2581
+ if (!reservation.operation)
2582
+ throw new StateQLError("STALE_PLAN", "Table edit batch could not be reserved.");
2583
+ const operation = reservation.operation;
2584
+ const adapter = connection.driver === "mongodb"
2585
+ ? await this.openMongoAdapter(connection, context, source)
2586
+ : await this.openAdapter(connection, context, source);
2587
+ try {
2588
+ let affectedRows;
2589
+ if (connection.driver === "mongodb") {
2590
+ const commands = compiled.map((item) => {
2591
+ if (!item.mongo)
2592
+ throw new StateQLError("STALE_PLAN", "Table edit batch contains mixed drivers.");
2593
+ return item.mongo;
2594
+ });
2595
+ const results = await adapter.writeBatch(commands, "snapshot", true);
2596
+ affectedRows = results.reduce((sum, item) => sum + item.affectedRows, 0);
2597
+ }
2598
+ else {
2599
+ const operations = compiled.map((item, index) => ({
2600
+ ...operation, id: `${operation.id}:${index}`, sql: item.sql, parameters: JSON.stringify(item.params), statement_type: "update", expectedRows: 1,
2601
+ }));
2602
+ const results = await adapter.writeBatch(operations, "serializable");
2603
+ affectedRows = results.reduce((sum, item) => sum + item.affectedRows, 0);
2604
+ }
2605
+ try {
2606
+ const finalized = this.store.finishPlannedOperation({ planId: planClaim.planId, claimToken: planClaim.claimToken,
2607
+ operationId: operation.id, connectionId: connection.id, affectedRows });
2608
+ return { data: operationData(finalized.operation), handle: finalized.operation.id, executed: true,
2609
+ stateVersion: finalized.stateVersion, confidence: adapter.confidence };
2610
+ }
2611
+ catch (error) {
2612
+ this.store.markOperationOutcomeUnknown(operation.id);
2613
+ throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), { executed: true, suggestedAction: "Inspect every edited row before retrying." });
2614
+ }
2615
+ }
2616
+ catch (error) {
2617
+ if (error instanceof StateQLError)
2618
+ throw error;
2619
+ if ((error instanceof BatchWriteError || error instanceof AdapterExecutionError) && !error.outcomeUnknown) {
2620
+ this.store.failOperation(operation.id);
2621
+ if (error.message.startsWith("ROW_CONFLICT:"))
2622
+ throw new StateQLError("ROW_CONFLICT", "At least one row changed; the entire edit batch was rolled back.");
2623
+ throw error instanceof AdapterExecutionError ? stoppedStateQLError(error, false) : new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, source), { executed: true });
2624
+ }
2625
+ this.store.markOperationOutcomeUnknown(operation.id);
2626
+ throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, source), { executed: true, suggestedAction: "Inspect every edited row before retrying." });
2627
+ }
2628
+ finally {
2629
+ await closeAdapterQuietly(adapter);
2630
+ }
2631
+ }
2183
2632
  batchFailure(message) {
2184
2633
  return this.run("batch", async () => {
2185
2634
  throw new StateQLError("INVALID_COMMAND", message);
@@ -2223,10 +2672,20 @@ export class StateQL {
2223
2672
  }
2224
2673
  return connection;
2225
2674
  }
2675
+ requireRedisConnection(session, method) {
2676
+ const connection = this.requireConnection(session);
2677
+ if (connection.driver !== "redis")
2678
+ throw new StateQLError("INVALID_COMMAND", `${method} requires an active Redis connection.`);
2679
+ return connection;
2680
+ }
2226
2681
  rejectMongoSql(connection, nativeMethod) {
2227
- if (connection.driver !== "mongodb")
2682
+ if (connection.driver !== "mongodb" && connection.driver !== "redis")
2228
2683
  return;
2229
- throw new StateQLError("INVALID_COMMAND", `SQL is not supported for MongoDB connections; use ${nativeMethod} instead.`, { suggestedAction: `Use ${nativeMethod} with a native MongoDB command.` });
2684
+ const method = connection.driver === "redis"
2685
+ ? nativeMethod === "mongoQuery" ? "redisQuery" : nativeMethod === "mongoExec" ? "redisExec" : "redisPlan"
2686
+ : nativeMethod;
2687
+ const name = connection.driver === "redis" ? "Redis" : "MongoDB";
2688
+ throw new StateQLError("INVALID_COMMAND", `SQL is not supported for ${name} connections; use ${method} instead.`, { suggestedAction: `Use ${method} with a native ${name} command.` });
2230
2689
  }
2231
2690
  requireActiveTransaction(session, id) {
2232
2691
  const transactionId = id ?? session.active_transaction_id;
@@ -2334,6 +2793,18 @@ export class StateQL {
2334
2793
  throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
2335
2794
  }
2336
2795
  }
2796
+ async openRedisAdapter(connection, context, source) {
2797
+ try {
2798
+ if (connection.driver !== "redis")
2799
+ throw new Error("Redis adapter requires a Redis connection.");
2800
+ return new RedisAdapter(source, Boolean(connection.read_only), context);
2801
+ }
2802
+ catch (error) {
2803
+ if (error instanceof StateQLError)
2804
+ throw error;
2805
+ throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
2806
+ }
2807
+ }
2337
2808
  executionContext(options) {
2338
2809
  return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), combineAbortSignals(options.signal, this.commandContexts.getStore()?.signal, this.signal));
2339
2810
  }
@@ -2342,6 +2813,8 @@ export class StateQL {
2342
2813
  const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
2343
2814
  return {
2344
2815
  result_id: result.id,
2816
+ alias: result.alias ?? this.store.generatedAlias(result.id),
2817
+ display_alias: result.alias ?? this.store.generatedAlias(result.id),
2345
2818
  rows: result.row_count,
2346
2819
  columns: this.store.resultColumns(result),
2347
2820
  preview,
@@ -2349,6 +2822,7 @@ export class StateQL {
2349
2822
  truncated: preview.length < result.row_count,
2350
2823
  cached,
2351
2824
  ...(cached ? { duplicate_of: result.id } : {}),
2825
+ ...(result.sql.startsWith("Redis native ") ? { next_cursor: redisResultCursor(result.parameters) } : {}),
2352
2826
  state_version: result.state_version,
2353
2827
  storage: {
2354
2828
  mode: "materialized",
@@ -2365,7 +2839,10 @@ export class StateQL {
2365
2839
  }
2366
2840
  async run(command, action, historySql, historyTarget) {
2367
2841
  const started = performance.now();
2368
- const origin = this.commandContexts.getStore()?.origin ?? "legacy";
2842
+ const commandContext = this.commandContexts.getStore();
2843
+ const origin = commandContext?.origin ?? "legacy";
2844
+ const category = historyCategory(command);
2845
+ const internal = commandContext?.internal ?? false;
2369
2846
  let session = this.store.ensureSession(this.sessionName);
2370
2847
  const commandId = this.store.nextId("cmd");
2371
2848
  if (!this.store.isSessionMember(session.id, this.actorId)) {
@@ -2389,6 +2866,8 @@ export class StateQL {
2389
2866
  sessionId: session.id,
2390
2867
  actorId: this.actorId,
2391
2868
  origin,
2869
+ category,
2870
+ internal,
2392
2871
  command,
2393
2872
  target: historyTarget,
2394
2873
  ...(result.handle ? { handle: result.handle } : {}),
@@ -2422,6 +2901,8 @@ export class StateQL {
2422
2901
  sessionId: session.id,
2423
2902
  actorId: this.actorId,
2424
2903
  origin,
2904
+ category,
2905
+ internal,
2425
2906
  command,
2426
2907
  target: historyTarget,
2427
2908
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
@@ -2452,6 +2933,8 @@ function historyEntry(item) {
2452
2933
  session_id: item.session_id,
2453
2934
  actor_id: item.actor_id,
2454
2935
  origin: item.origin,
2936
+ category: item.category,
2937
+ internal: Boolean(item.internal),
2455
2938
  command: item.command,
2456
2939
  sql: item.sql,
2457
2940
  ...(item.target ? { target: item.target } : {}),
@@ -2482,11 +2965,15 @@ function mergeCommandExecutionContext(inherited, supplied) {
2482
2965
  if (supplied.signal !== undefined && !(supplied.signal instanceof AbortSignal)) {
2483
2966
  throw new StateQLError("INVALID_COMMAND", "Command execution context signal must be an AbortSignal.");
2484
2967
  }
2968
+ if (supplied.internal !== undefined && typeof supplied.internal !== "boolean") {
2969
+ throw new StateQLError("INVALID_COMMAND", "Command execution context internal must be boolean.");
2970
+ }
2485
2971
  return {
2486
2972
  signal: combineAbortSignals(inherited?.signal, supplied.signal),
2487
2973
  origin: supplied.origin === undefined
2488
2974
  ? inherited?.origin
2489
2975
  : parseCommandOrigin(supplied.origin),
2976
+ internal: supplied.internal ?? inherited?.internal,
2490
2977
  };
2491
2978
  }
2492
2979
  function combineAbortSignals(...signals) {
@@ -2525,6 +3012,22 @@ function validatedMongoWrite(command) {
2525
3012
  throw new StateQLError("INVALID_COMMAND", errorMessage(error));
2526
3013
  }
2527
3014
  }
3015
+ function validatedRedisRead(command) {
3016
+ try {
3017
+ return validateRedisReadCommand(command);
3018
+ }
3019
+ catch (error) {
3020
+ throw new StateQLError("INVALID_COMMAND", errorMessage(error));
3021
+ }
3022
+ }
3023
+ function validatedRedisWrite(command) {
3024
+ try {
3025
+ return validateRedisWriteCommand(command);
3026
+ }
3027
+ catch (error) {
3028
+ throw new StateQLError("INVALID_COMMAND", errorMessage(error));
3029
+ }
3030
+ }
2528
3031
  function mongoDescriptor(operation) {
2529
3032
  return `MongoDB native ${operation}`;
2530
3033
  }
@@ -2566,14 +3069,68 @@ function storedMongoWrite(parameters, statementType, label, errorCode) {
2566
3069
  throw new StateQLError(errorCode, `Stored MongoDB ${label} payload is invalid.`);
2567
3070
  }
2568
3071
  }
3072
+ function storedRedisPlan(parameters, statementType, planId) {
3073
+ try {
3074
+ const outer = JSON.parse(parameters);
3075
+ if (!Array.isArray(outer) || outer.length !== 1 || typeof outer[0] !== "string")
3076
+ throw new Error();
3077
+ const payload = JSON.parse(outer[0]);
3078
+ if (typeof payload.command !== "string" || !payload.precondition || typeof payload.precondition !== "object")
3079
+ throw new Error();
3080
+ const command = deserializeRedisCommand(payload.command);
3081
+ const precondition = payload.precondition;
3082
+ if (typeof precondition.key !== "string" || typeof precondition.fingerprint !== "string" || typeof precondition.expiresAt !== "number" || !Number.isFinite(precondition.expiresAt) || statementType !== `redis.${command.command.toLowerCase()}`)
3083
+ throw new Error();
3084
+ return { command, precondition };
3085
+ }
3086
+ catch {
3087
+ throw new StateQLError("STALE_PLAN", `Stored Redis plan "${planId}" payload is invalid.`);
3088
+ }
3089
+ }
3090
+ function redisResultCursor(parameters) {
3091
+ try {
3092
+ const value = JSON.parse(parameters);
3093
+ return Array.isArray(value) && (typeof value[1] === "string" || value[1] === null) ? value[1] : null;
3094
+ }
3095
+ catch {
3096
+ return null;
3097
+ }
3098
+ }
3099
+ function tableUpdateIdentity(metadata, row) {
3100
+ const identity = metadata.driver === "mongodb"
3101
+ ? { table: metadata.table, id: row._id }
3102
+ : { 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]]) };
3103
+ return hash(identity);
3104
+ }
3105
+ function validateCatalogFilter(filter) {
3106
+ if (!filter || typeof filter !== "object" || Array.isArray(filter) || Object.keys(filter).some((key) => !["kind", "schema", "search", "offset", "limit"].includes(key)))
3107
+ throw new StateQLError("INVALID_COMMAND", "Catalog filter contains unknown fields.");
3108
+ if (filter.kind !== undefined && !["table", "view", "collection", "function", "trigger", "enum", "key"].includes(filter.kind))
3109
+ throw new StateQLError("INVALID_COMMAND", "Unknown catalog object kind.");
3110
+ for (const [name, value] of [["schema", filter.schema], ["search", filter.search]])
3111
+ if (value !== undefined && (typeof value !== "string" || !value || value.length > 200 || value.includes("\0")))
3112
+ throw new StateQLError("INVALID_COMMAND", `Catalog ${name} is invalid.`);
3113
+ if (filter.offset !== undefined && !((typeof filter.offset === "number" && Number.isSafeInteger(filter.offset) && filter.offset >= 0) || (typeof filter.offset === "string" && /^\d+$/.test(filter.offset))))
3114
+ throw new StateQLError("INVALID_COMMAND", "Catalog offset is invalid.");
3115
+ if (filter.limit !== undefined && (!Number.isSafeInteger(filter.limit) || filter.limit < 1 || filter.limit > 200))
3116
+ throw new StateQLError("INVALID_COMMAND", "Catalog limit must be 1-200.");
3117
+ }
3118
+ function validateCatalogObject(object) {
3119
+ 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"))))
3120
+ throw new StateQLError("INVALID_COMMAND", "Catalog object identity is invalid.");
3121
+ }
2569
3122
  function databaseDisplayName(driver) {
2570
3123
  if (driver === "mongodb")
2571
3124
  return "MongoDB";
3125
+ if (driver === "redis")
3126
+ return "Redis";
2572
3127
  return driver === "postgres" ? "PostgreSQL" : "MySQL";
2573
3128
  }
2574
3129
  function normalizeIsolation(isolation, driver) {
2575
3130
  const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
2576
3131
  .replace(/\s+/g, " ");
3132
+ if (driver === "redis")
3133
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support staged SQL-style transactions.");
2577
3134
  if (driver === "mongodb") {
2578
3135
  if (normalized === "snapshot")
2579
3136
  return normalized;
@@ -2670,6 +3227,37 @@ function executionTimeout(value, name = "timeoutMs") {
2670
3227
  function stoppedStateQLError(error, executed) {
2671
3228
  return new StateQLError(error.reason === "timeout" ? "DEADLINE_EXCEEDED" : "OPERATION_CANCELLED", error.message, { retryable: true, executed });
2672
3229
  }
3230
+ function validatedProfileSource(input) {
3231
+ const sourceCount = [input.target, input.secretEnv, input.credentialRef].filter((value) => value !== undefined).length;
3232
+ if (sourceCount !== 1 || input.target === "" || input.secretEnv === "" || input.credentialRef === "") {
3233
+ throw new StateQLError("INVALID_COMMAND", "Profile requires exactly one target, secret environment variable, or credential reference.");
3234
+ }
3235
+ if (input.secretEnv !== undefined && !isEnvironmentName(input.secretEnv))
3236
+ throw new StateQLError("INVALID_COMMAND", "Secret environment variable name is invalid.");
3237
+ if (input.credentialRef !== undefined)
3238
+ validateCredentialRef(input.credentialRef);
3239
+ let target = input.target ?? null;
3240
+ if (target) {
3241
+ const driver = detectDriver(target);
3242
+ if (driver !== "sqlite" && databaseUrlHasSecret(target))
3243
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env or --credential-ref.`);
3244
+ if (driver === "sqlite")
3245
+ target = normalizeSqliteSource(target);
3246
+ }
3247
+ return { target, secretEnv: input.secretEnv ?? null, credentialRef: input.credentialRef ?? null };
3248
+ }
3249
+ function historyCategory(command) {
3250
+ if (["query", "exec", "plan", "apply", "mongo.query", "mongo.exec", "mongo.plan", "redis.query", "redis.exec", "redis.plan", "filter"].includes(command))
3251
+ return "statement";
3252
+ if (command.startsWith("inspect.") || ["objects.list", "object.describe", "table.read"].includes(command))
3253
+ return "introspection";
3254
+ return "management";
3255
+ }
3256
+ function parseHistoryCategory(value) {
3257
+ if (value === "statement" || value === "introspection" || value === "management")
3258
+ return value;
3259
+ throw new StateQLError("INVALID_COMMAND", `Unknown history category "${String(value)}".`);
3260
+ }
2673
3261
  function errorMessage(error) {
2674
3262
  return error instanceof Error ? error.message : String(error);
2675
3263
  }