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