@fadhilp/stateql 0.9.0 → 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,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
  }
@@ -236,34 +241,19 @@ export class StateQL {
236
241
  async addProfile(name, target, options = {}) {
237
242
  return this.run("profile.add", async () => {
238
243
  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
244
  if (this.store.getProfile(name)) {
245
245
  throw new StateQLError("INVALID_COMMAND", `Profile "${name}" already exists.`);
246
246
  }
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,
247
+ const source = validatedProfileSource({
248
+ target,
265
249
  secretEnv: options.secretEnv,
266
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,
267
257
  readOnly: options.readOnly ?? true,
268
258
  });
269
259
  return {
@@ -273,6 +263,36 @@ export class StateQL {
273
263
  };
274
264
  });
275
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
+ }
276
296
  async listProfiles() {
277
297
  return this.run("profile.list", async () => ({
278
298
  data: { profiles: this.store.listProfiles().map(profileData) },
@@ -331,6 +351,13 @@ export class StateQL {
331
351
  if (historyLimit > MAX_SNAPSHOT_HISTORY_LIMIT) {
332
352
  throw new StateQLError("INVALID_COMMAND", `historyLimit cannot exceed ${MAX_SNAPSHOT_HISTORY_LIMIT}.`);
333
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
+ };
334
361
  return {
335
362
  session: {
336
363
  session_id: session.id,
@@ -371,7 +398,9 @@ export class StateQL {
371
398
  affected_rows: operation.affected_rows,
372
399
  status: operation.status,
373
400
  })),
374
- history: this.store.history(session.id, historyLimit).map(historyEntry),
401
+ history: this.store
402
+ .history(session.id, historyLimit, historyOptions)
403
+ .map(historyEntry),
375
404
  };
376
405
  }
377
406
  async status() {
@@ -748,6 +777,51 @@ export class StateQL {
748
777
  }
749
778
  });
750
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
+ }
751
825
  async show(idOrAlias) {
752
826
  return this.withResult("show", idOrAlias, async (result) => ({
753
827
  data: this.resultData(result, true),
@@ -954,7 +1028,7 @@ export class StateQL {
954
1028
  }));
955
1029
  }
956
1030
  async readTable(table, limit = 1000, options = {}) {
957
- return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api" }), async () => {
1031
+ return this.commandContexts.run(mergeCommandExecutionContext(this.commandContexts.getStore(), { signal: options.signal, origin: options.origin ?? "api", internal: true }), async () => {
958
1032
  if (!table || typeof table.name !== "string" || !table.name || table.name.length > 500 || table.name.includes("\0") ||
959
1033
  (table.schema !== undefined && (typeof table.schema !== "string" || !table.schema || table.schema.length > 500 || table.schema.includes("\0"))) ||
960
1034
  !Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
@@ -967,6 +1041,8 @@ export class StateQL {
967
1041
  throw new StateQLError("INVALID_COMMAND", "Only the main SQLite schema is supported.");
968
1042
  if (driver === "mongodb" && table.schema)
969
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.");
970
1046
  const quote = (name) => driver === "mysql" ? "\`" + name.replaceAll("\`", "\`\`") + "\`" : '"' + name.replaceAll('"', '""') + '"';
971
1047
  const qualified = [table.schema, table.name].filter((part) => Boolean(part)).map(quote).join(".");
972
1048
  const query = driver === "mongodb" ? JSON.stringify({ operation: "find", collection: table.name, options: { limit } }, null, 2)
@@ -1014,6 +1090,68 @@ export class StateQL {
1014
1090
  }
1015
1091
  }));
1016
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
+ }
1017
1155
  async editableMetadata(table, options) {
1018
1156
  const snapshot = this.snapshot({ historyLimit: 1 });
1019
1157
  const driver = snapshot.connection.driver;
@@ -1086,6 +1224,14 @@ export class StateQL {
1086
1224
  return this.performMongoExec(session, connection, value, options, this.executionContext(options));
1087
1225
  });
1088
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
+ }
1089
1235
  async receipt(id) {
1090
1236
  return this.run("receipt", async (session) => {
1091
1237
  const operation = this.store.getOperation(id);
@@ -1102,6 +1248,8 @@ export class StateQL {
1102
1248
  async beginTransaction(isolation) {
1103
1249
  return this.run("transaction.begin", async (session) => {
1104
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.");
1105
1253
  if (connection.read_only) {
1106
1254
  throw new StateQLError("READ_ONLY_CONNECTION", "Cannot begin a write transaction on a read-only connection.");
1107
1255
  }
@@ -1276,6 +1424,8 @@ export class StateQL {
1276
1424
  this.rejectDuringStagedTransaction(session, "Schema inspection");
1277
1425
  const context = this.executionContext(options);
1278
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.");
1279
1429
  const adapter = connection.driver === "mongodb"
1280
1430
  ? await this.openMongoAdapter(connection, context, adapterSource)
1281
1431
  : await this.openAdapter(connection, context, adapterSource);
@@ -1302,6 +1452,56 @@ export class StateQL {
1302
1452
  }
1303
1453
  }, undefined, table);
1304
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
+ }
1503
+ });
1504
+ }
1305
1505
  async plan(sql, options = {}) {
1306
1506
  return this.run("plan", async (session) => {
1307
1507
  const connection = this.requireConnection(session);
@@ -1434,6 +1634,43 @@ export class StateQL {
1434
1634
  }
1435
1635
  });
1436
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
+ }
1437
1674
  async apply(planId, options = {}) {
1438
1675
  let historySql;
1439
1676
  return this.run("apply", async (session) => {
@@ -1454,15 +1691,20 @@ export class StateQL {
1454
1691
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
1455
1692
  }
1456
1693
  const tableUpdate = plan.statement_type === "table.update" ? parseTableUpdate(plan.parameters) : undefined;
1694
+ const tableUpdates = plan.statement_type === "table.updates" ? parseTableUpdates(plan.parameters) : undefined;
1457
1695
  const compiled = tableUpdate ? compileTableUpdate(tableUpdate) : undefined;
1696
+ const compiledUpdates = tableUpdates?.map(compileTableUpdate);
1458
1697
  if (compiled && compiled.sql !== plan.sql)
1459
1698
  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
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.")
1463
1704
  ? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
1464
1705
  : undefined);
1465
- const planParameters = compiled?.params ?? (nativePlan
1706
+ const redisStored = redisPlan ? storedRedisPlan(plan.parameters, plan.statement_type, plan.id) : undefined;
1707
+ const planParameters = compiled?.params ?? (nativePlan || tableUpdates
1466
1708
  ? undefined
1467
1709
  : parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters));
1468
1710
  const claimToken = this.store.nextId("claim");
@@ -1477,19 +1719,30 @@ export class StateQL {
1477
1719
  version(connection) !== claimed.state_version) {
1478
1720
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
1479
1721
  }
1480
- if (nativePlan && connection.driver !== "mongodb") {
1722
+ if (mongoPlan && connection.driver !== "mongodb") {
1481
1723
  throw new StateQLError("STALE_PLAN", "MongoDB plan is not attached to a MongoDB connection.");
1482
1724
  }
1483
- 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")) {
1484
1729
  this.rejectMongoSql(connection, "mongoPlan");
1485
1730
  }
1486
1731
  if (tableUpdate && JSON.stringify(await this.editableMetadata(tableUpdate.metadata.table, options)) !== JSON.stringify(tableUpdate.metadata))
1487
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
+ }
1488
1739
  const context = this.executionContext(options);
1489
1740
  const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
1490
- const adapter = nativePlan
1741
+ const adapter = mongoPlan
1491
1742
  ? await this.openMongoAdapter(connection, context, adapterSource)
1492
- : await this.openAdapter(connection, context, adapterSource);
1743
+ : redisPlan
1744
+ ? await this.openRedisAdapter(connection, context, adapterSource)
1745
+ : await this.openAdapter(connection, context, adapterSource);
1493
1746
  try {
1494
1747
  if ((await adapter.signature()) !== claimed.state_signature) {
1495
1748
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
@@ -1506,18 +1759,22 @@ export class StateQL {
1506
1759
  finally {
1507
1760
  await closeAdapterQuietly(adapter);
1508
1761
  }
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);
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);
1521
1778
  return {
1522
1779
  ...result,
1523
1780
  data: { plan_id: claimed.id, ...result.data },
@@ -1537,15 +1794,22 @@ export class StateQL {
1537
1794
  }, () => historySql);
1538
1795
  }
1539
1796
  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
- }));
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
+ });
1549
1813
  }
1550
1814
  async doctor() {
1551
1815
  return this.run("doctor", async (session) => ({
@@ -1569,7 +1833,7 @@ export class StateQL {
1569
1833
  async capabilities() {
1570
1834
  return this.run("capabilities", async () => ({
1571
1835
  data: {
1572
- drivers: ["mongodb", "mysql", "postgres", "sqlite"],
1836
+ drivers: ["mongodb", "mysql", "postgres", "redis", "sqlite"],
1573
1837
  features: {
1574
1838
  result_handles: true,
1575
1839
  write_deduplication: true,
@@ -1584,6 +1848,10 @@ export class StateQL {
1584
1848
  state_diagnostics: true,
1585
1849
  state_purge: true,
1586
1850
  state_quota: true,
1851
+ bounded_catalog: true,
1852
+ generated_aliases: true,
1853
+ multi_row_table_plans: true,
1854
+ history_classification: true,
1587
1855
  },
1588
1856
  driver_features: {
1589
1857
  mongodb: {
@@ -1595,6 +1863,15 @@ export class StateQL {
1595
1863
  transactions_require_replica_set: true,
1596
1864
  inspection: true,
1597
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
+ },
1598
1875
  },
1599
1876
  },
1600
1877
  }));
@@ -1632,6 +1909,13 @@ export class StateQL {
1632
1909
  secretEnv: command.secret_env,
1633
1910
  credentialRef: command.credential_ref,
1634
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
+ });
1635
1919
  case "profile.list":
1636
1920
  return this.listProfiles();
1637
1921
  case "profile.show":
@@ -1681,6 +1965,13 @@ export class StateQL {
1681
1965
  data: { ...response.data, alias: command.as },
1682
1966
  };
1683
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
+ }
1684
1975
  case "filter": {
1685
1976
  const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
1686
1977
  if (!response.ok || !command.as)
@@ -1711,6 +2002,12 @@ export class StateQL {
1711
2002
  allowDestructive: command.allow_destructive ?? false,
1712
2003
  timeoutMs: command.timeout_ms,
1713
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
+ });
1714
2011
  case "show":
1715
2012
  return this.show(batchString(command.handle, "handle"));
1716
2013
  case "rows":
@@ -1728,6 +2025,16 @@ export class StateQL {
1728
2025
  return this.inspect(batchString(command.kind, "kind"), command.table, {
1729
2026
  timeoutMs: command.timeout_ms,
1730
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 });
1731
2038
  case "transaction.begin":
1732
2039
  return this.beginTransaction(command.isolation);
1733
2040
  case "transaction.status":
@@ -1751,6 +2058,8 @@ export class StateQL {
1751
2058
  allowDestructive: command.allow_destructive,
1752
2059
  timeoutMs: command.timeout_ms,
1753
2060
  });
2061
+ case "redis.plan":
2062
+ return this.redisPlan(command.redis, { timeoutMs: command.timeout_ms });
1754
2063
  case "apply":
1755
2064
  return this.apply(batchString(command.handle, "handle"), {
1756
2065
  timeoutMs: command.timeout_ms,
@@ -1758,6 +2067,9 @@ export class StateQL {
1758
2067
  case "history":
1759
2068
  return this.history(command.limit ?? 20, {
1760
2069
  origin: command.history_origin,
2070
+ category: command.history_category,
2071
+ internal: command.history_internal,
2072
+ offset: command.offset,
1761
2073
  });
1762
2074
  case "receipt":
1763
2075
  return this.receipt(batchString(command.handle, "handle"));
@@ -2180,6 +2492,139 @@ export class StateQL {
2180
2492
  }
2181
2493
  }
2182
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
+ }
2183
2628
  batchFailure(message) {
2184
2629
  return this.run("batch", async () => {
2185
2630
  throw new StateQLError("INVALID_COMMAND", message);
@@ -2223,10 +2668,20 @@ export class StateQL {
2223
2668
  }
2224
2669
  return connection;
2225
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
+ }
2226
2677
  rejectMongoSql(connection, nativeMethod) {
2227
- if (connection.driver !== "mongodb")
2678
+ if (connection.driver !== "mongodb" && connection.driver !== "redis")
2228
2679
  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.` });
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.` });
2230
2685
  }
2231
2686
  requireActiveTransaction(session, id) {
2232
2687
  const transactionId = id ?? session.active_transaction_id;
@@ -2334,6 +2789,18 @@ export class StateQL {
2334
2789
  throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
2335
2790
  }
2336
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
+ }
2337
2804
  executionContext(options) {
2338
2805
  return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), combineAbortSignals(options.signal, this.commandContexts.getStore()?.signal, this.signal));
2339
2806
  }
@@ -2342,6 +2809,8 @@ export class StateQL {
2342
2809
  const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
2343
2810
  return {
2344
2811
  result_id: result.id,
2812
+ alias: result.alias ?? this.store.generatedAlias(result.id),
2813
+ display_alias: result.alias ?? this.store.generatedAlias(result.id),
2345
2814
  rows: result.row_count,
2346
2815
  columns: this.store.resultColumns(result),
2347
2816
  preview,
@@ -2349,6 +2818,7 @@ export class StateQL {
2349
2818
  truncated: preview.length < result.row_count,
2350
2819
  cached,
2351
2820
  ...(cached ? { duplicate_of: result.id } : {}),
2821
+ ...(result.sql.startsWith("Redis native ") ? { next_cursor: redisResultCursor(result.parameters) } : {}),
2352
2822
  state_version: result.state_version,
2353
2823
  storage: {
2354
2824
  mode: "materialized",
@@ -2365,7 +2835,10 @@ export class StateQL {
2365
2835
  }
2366
2836
  async run(command, action, historySql, historyTarget) {
2367
2837
  const started = performance.now();
2368
- 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;
2369
2842
  let session = this.store.ensureSession(this.sessionName);
2370
2843
  const commandId = this.store.nextId("cmd");
2371
2844
  if (!this.store.isSessionMember(session.id, this.actorId)) {
@@ -2389,6 +2862,8 @@ export class StateQL {
2389
2862
  sessionId: session.id,
2390
2863
  actorId: this.actorId,
2391
2864
  origin,
2865
+ category,
2866
+ internal,
2392
2867
  command,
2393
2868
  target: historyTarget,
2394
2869
  ...(result.handle ? { handle: result.handle } : {}),
@@ -2422,6 +2897,8 @@ export class StateQL {
2422
2897
  sessionId: session.id,
2423
2898
  actorId: this.actorId,
2424
2899
  origin,
2900
+ category,
2901
+ internal,
2425
2902
  command,
2426
2903
  target: historyTarget,
2427
2904
  ...(sqlText !== undefined ? { sql: sqlText } : {}),
@@ -2452,6 +2929,8 @@ function historyEntry(item) {
2452
2929
  session_id: item.session_id,
2453
2930
  actor_id: item.actor_id,
2454
2931
  origin: item.origin,
2932
+ category: item.category,
2933
+ internal: Boolean(item.internal),
2455
2934
  command: item.command,
2456
2935
  sql: item.sql,
2457
2936
  ...(item.target ? { target: item.target } : {}),
@@ -2482,11 +2961,15 @@ function mergeCommandExecutionContext(inherited, supplied) {
2482
2961
  if (supplied.signal !== undefined && !(supplied.signal instanceof AbortSignal)) {
2483
2962
  throw new StateQLError("INVALID_COMMAND", "Command execution context signal must be an AbortSignal.");
2484
2963
  }
2964
+ if (supplied.internal !== undefined && typeof supplied.internal !== "boolean") {
2965
+ throw new StateQLError("INVALID_COMMAND", "Command execution context internal must be boolean.");
2966
+ }
2485
2967
  return {
2486
2968
  signal: combineAbortSignals(inherited?.signal, supplied.signal),
2487
2969
  origin: supplied.origin === undefined
2488
2970
  ? inherited?.origin
2489
2971
  : parseCommandOrigin(supplied.origin),
2972
+ internal: supplied.internal ?? inherited?.internal,
2490
2973
  };
2491
2974
  }
2492
2975
  function combineAbortSignals(...signals) {
@@ -2525,6 +3008,22 @@ function validatedMongoWrite(command) {
2525
3008
  throw new StateQLError("INVALID_COMMAND", errorMessage(error));
2526
3009
  }
2527
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
+ }
2528
3027
  function mongoDescriptor(operation) {
2529
3028
  return `MongoDB native ${operation}`;
2530
3029
  }
@@ -2566,14 +3065,68 @@ function storedMongoWrite(parameters, statementType, label, errorCode) {
2566
3065
  throw new StateQLError(errorCode, `Stored MongoDB ${label} payload is invalid.`);
2567
3066
  }
2568
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
+ }
2569
3118
  function databaseDisplayName(driver) {
2570
3119
  if (driver === "mongodb")
2571
3120
  return "MongoDB";
3121
+ if (driver === "redis")
3122
+ return "Redis";
2572
3123
  return driver === "postgres" ? "PostgreSQL" : "MySQL";
2573
3124
  }
2574
3125
  function normalizeIsolation(isolation, driver) {
2575
3126
  const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
2576
3127
  .replace(/\s+/g, " ");
3128
+ if (driver === "redis")
3129
+ throw new StateQLError("UNSUPPORTED_DRIVER", "Redis does not support staged SQL-style transactions.");
2577
3130
  if (driver === "mongodb") {
2578
3131
  if (normalized === "snapshot")
2579
3132
  return normalized;
@@ -2670,6 +3223,37 @@ function executionTimeout(value, name = "timeoutMs") {
2670
3223
  function stoppedStateQLError(error, executed) {
2671
3224
  return new StateQLError(error.reason === "timeout" ? "DEADLINE_EXCEEDED" : "OPERATION_CANCELLED", error.message, { retryable: true, executed });
2672
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
+ }
2673
3257
  function errorMessage(error) {
2674
3258
  return error instanceof Error ? error.message : String(error);
2675
3259
  }