@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.
- package/README.md +140 -5
- package/dist/src/adapters.d.ts +7 -2
- package/dist/src/adapters.js +167 -0
- package/dist/src/cli.js +66 -3
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +18 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/migrations.js +33 -0
- package/dist/src/mongodb.d.ts +4 -2
- package/dist/src/mongodb.js +53 -2
- package/dist/src/redis.d.ts +44 -0
- package/dist/src/redis.js +395 -0
- package/dist/src/sqlite-process.js +41 -0
- package/dist/src/stateql.d.ts +18 -4
- package/dist/src/stateql.js +646 -62
- package/dist/src/store.d.ts +22 -2
- package/dist/src/store.js +92 -12
- package/dist/src/table-editor.d.ts +5 -0
- package/dist/src/table-editor.js +22 -0
- package/dist/src/types.d.ts +74 -3
- package/package.json +4 -2
package/dist/src/stateql.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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
|
|
1461
|
-
|
|
1462
|
-
const
|
|
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
|
|
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 (
|
|
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 (
|
|
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 =
|
|
1741
|
+
const adapter = mongoPlan
|
|
1491
1742
|
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
1492
|
-
:
|
|
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 =
|
|
1510
|
-
? await this.
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
:
|
|
1546
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|