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