@fadhilp/stateql 0.5.4 → 0.7.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 +91 -23
- package/dist/src/adapters.d.ts +1 -0
- package/dist/src/adapters.js +19 -6
- package/dist/src/cli.js +48 -2
- package/dist/src/connection.d.ts +1 -0
- package/dist/src/connection.js +29 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/migrations.js +22 -0
- package/dist/src/mongodb.d.ts +48 -0
- package/dist/src/mongodb.js +868 -0
- package/dist/src/response-data.js +12 -0
- package/dist/src/sql.d.ts +3 -1
- package/dist/src/sql.js +3 -0
- package/dist/src/stateql.d.ts +11 -3
- package/dist/src/stateql.js +730 -154
- package/dist/src/store.d.ts +8 -3
- package/dist/src/store.js +20 -14
- package/dist/src/types.d.ts +103 -2
- package/package.json +3 -2
package/dist/src/stateql.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { writeFileSync } from "node:fs";
|
|
2
3
|
import { basename, resolve } from "node:path";
|
|
3
4
|
import { env } from "node:process";
|
|
4
5
|
import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
|
|
5
|
-
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
6
|
+
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
6
7
|
import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
|
|
8
|
+
import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
|
|
7
9
|
import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
|
|
8
10
|
import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
|
|
9
11
|
import { analyzeSql } from "./sql.js";
|
|
@@ -40,6 +42,7 @@ export class StateQL {
|
|
|
40
42
|
maxResultBytes;
|
|
41
43
|
timeoutMs;
|
|
42
44
|
signal;
|
|
45
|
+
commandContexts = new AsyncLocalStorage();
|
|
43
46
|
credentialResolver;
|
|
44
47
|
now;
|
|
45
48
|
closed = false;
|
|
@@ -139,7 +142,9 @@ export class StateQL {
|
|
|
139
142
|
: adapterSource;
|
|
140
143
|
const databaseName = driver === "sqlite"
|
|
141
144
|
? basename(adapterSource)
|
|
142
|
-
:
|
|
145
|
+
: driver === "mongodb"
|
|
146
|
+
? mongoDatabaseName(adapterSource)
|
|
147
|
+
: new URL(secret).pathname.replace(/^\//, "") || driver;
|
|
143
148
|
const draft = {
|
|
144
149
|
id: "pending",
|
|
145
150
|
session_id: session.id,
|
|
@@ -152,9 +157,11 @@ export class StateQL {
|
|
|
152
157
|
version: 0,
|
|
153
158
|
created_at: this.now().toISOString(),
|
|
154
159
|
};
|
|
155
|
-
const adapter =
|
|
160
|
+
const adapter = driver === "mongodb"
|
|
161
|
+
? await this.openMongoAdapter(draft, context, adapterSource)
|
|
162
|
+
: await this.openAdapter(draft, context, adapterSource);
|
|
156
163
|
try {
|
|
157
|
-
await adapter.
|
|
164
|
+
await adapter.ping();
|
|
158
165
|
}
|
|
159
166
|
catch (error) {
|
|
160
167
|
if (error instanceof AdapterExecutionError) {
|
|
@@ -524,6 +531,7 @@ export class StateQL {
|
|
|
524
531
|
async query(sql, options = {}) {
|
|
525
532
|
return this.run("query", async (session) => {
|
|
526
533
|
const connection = this.requireConnection(session);
|
|
534
|
+
this.rejectMongoSql(connection, "mongoQuery");
|
|
527
535
|
this.rejectDuringStagedTransaction(session, "Queries");
|
|
528
536
|
const analysis = analyzeSql(sql, connection.driver);
|
|
529
537
|
if (!analysis.read) {
|
|
@@ -615,6 +623,95 @@ export class StateQL {
|
|
|
615
623
|
}
|
|
616
624
|
}, sql);
|
|
617
625
|
}
|
|
626
|
+
async mongoQuery(command, options = {}) {
|
|
627
|
+
return this.run("mongo.query", async (session) => {
|
|
628
|
+
const value = validatedMongoRead(command);
|
|
629
|
+
const serializedCommand = serializeMongoCommand(value);
|
|
630
|
+
const connection = this.requireMongoConnection(session, "mongoQuery");
|
|
631
|
+
this.rejectDuringStagedTransaction(session, "MongoDB queries");
|
|
632
|
+
const context = this.executionContext(options);
|
|
633
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
634
|
+
const adapter = await this.openMongoAdapter(connection, context, adapterSource);
|
|
635
|
+
try {
|
|
636
|
+
const stateVersion = version(connection);
|
|
637
|
+
const stateSignature = await adapter.signature();
|
|
638
|
+
const fingerprint = hash({
|
|
639
|
+
command: serializedCommand,
|
|
640
|
+
driver: connection.driver,
|
|
641
|
+
connection: connection.id,
|
|
642
|
+
database: connection.database_name,
|
|
643
|
+
transaction: session.active_transaction_id,
|
|
644
|
+
stateVersion,
|
|
645
|
+
});
|
|
646
|
+
const cached = this.store.findResult(fingerprint);
|
|
647
|
+
const cacheMode = options.cache ?? "auto";
|
|
648
|
+
const warnings = mongoPaginationWarnings(value);
|
|
649
|
+
if (cacheMode !== "bypass" &&
|
|
650
|
+
cached &&
|
|
651
|
+
cached.row_count <= this.maxResultRows &&
|
|
652
|
+
this.cacheValid(cached, stateVersion, stateSignature)) {
|
|
653
|
+
return {
|
|
654
|
+
data: this.resultData(cached, true),
|
|
655
|
+
handle: cached.id,
|
|
656
|
+
cached: true,
|
|
657
|
+
warnings,
|
|
658
|
+
stateVersion,
|
|
659
|
+
confidence: cached.state_confidence,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (cacheMode === "require") {
|
|
663
|
+
throw new StateQLError("CACHE_MISS", "No valid cached result exists.", {
|
|
664
|
+
retryable: true,
|
|
665
|
+
suggestedAction: "Run with cache auto or cache bypass.",
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
const result = await adapter.read(value, this.maxResultRows + 1);
|
|
669
|
+
if (result.rows.length > this.maxResultRows) {
|
|
670
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Use a narrower filter or limit." });
|
|
671
|
+
}
|
|
672
|
+
const parameters = [serializedCommand];
|
|
673
|
+
const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") +
|
|
674
|
+
Buffer.byteLength(JSON.stringify(result.rows), "utf8") +
|
|
675
|
+
Buffer.byteLength(JSON.stringify(result.columns), "utf8");
|
|
676
|
+
if (resultBytes > this.maxResultBytes) {
|
|
677
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultBytes}-byte materialization limit.`, { suggestedAction: "Return fewer or smaller documents." });
|
|
678
|
+
}
|
|
679
|
+
const expiresAt = new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString();
|
|
680
|
+
const saved = this.store.saveResult({
|
|
681
|
+
sessionId: session.id,
|
|
682
|
+
connectionId: connection.id,
|
|
683
|
+
fingerprint,
|
|
684
|
+
sql: mongoDescriptor(value.operation),
|
|
685
|
+
parameters,
|
|
686
|
+
rows: result.rows,
|
|
687
|
+
columns: result.columns,
|
|
688
|
+
stateVersion,
|
|
689
|
+
stateSignature,
|
|
690
|
+
stateConfidence: adapter.confidence,
|
|
691
|
+
expiresAt,
|
|
692
|
+
});
|
|
693
|
+
return {
|
|
694
|
+
data: this.resultData(saved, false),
|
|
695
|
+
handle: saved.id,
|
|
696
|
+
executed: true,
|
|
697
|
+
warnings,
|
|
698
|
+
stateVersion,
|
|
699
|
+
confidence: adapter.confidence,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (error instanceof StateQLError)
|
|
704
|
+
throw error;
|
|
705
|
+
if (error instanceof AdapterExecutionError) {
|
|
706
|
+
throw stoppedStateQLError(error, true);
|
|
707
|
+
}
|
|
708
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
709
|
+
}
|
|
710
|
+
finally {
|
|
711
|
+
await closeAdapterQuietly(adapter);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}
|
|
618
715
|
async show(idOrAlias) {
|
|
619
716
|
return this.withResult("show", idOrAlias, async (result) => ({
|
|
620
717
|
data: this.resultData(result, true),
|
|
@@ -755,9 +852,17 @@ export class StateQL {
|
|
|
755
852
|
async exec(sql, options = {}) {
|
|
756
853
|
return this.run("exec", async (session) => {
|
|
757
854
|
const connection = this.requireConnection(session);
|
|
855
|
+
this.rejectMongoSql(connection, "mongoExec");
|
|
758
856
|
return this.performExec(session, connection, sql, options, this.executionContext(options));
|
|
759
857
|
}, sql);
|
|
760
858
|
}
|
|
859
|
+
async mongoExec(command, options = {}) {
|
|
860
|
+
return this.run("mongo.exec", async (session) => {
|
|
861
|
+
const value = validatedMongoWrite(command);
|
|
862
|
+
const connection = this.requireMongoConnection(session, "mongoExec");
|
|
863
|
+
return this.performMongoExec(session, connection, value, options, this.executionContext(options));
|
|
864
|
+
});
|
|
865
|
+
}
|
|
761
866
|
async receipt(id) {
|
|
762
867
|
return this.run("receipt", async (session) => {
|
|
763
868
|
const operation = this.store.getOperation(id);
|
|
@@ -771,7 +876,7 @@ export class StateQL {
|
|
|
771
876
|
};
|
|
772
877
|
});
|
|
773
878
|
}
|
|
774
|
-
async beginTransaction(isolation
|
|
879
|
+
async beginTransaction(isolation) {
|
|
775
880
|
return this.run("transaction.begin", async (session) => {
|
|
776
881
|
const connection = this.requireConnection(session);
|
|
777
882
|
if (connection.read_only) {
|
|
@@ -780,7 +885,7 @@ export class StateQL {
|
|
|
780
885
|
if (session.active_transaction_id) {
|
|
781
886
|
throw new StateQLError("TRANSACTION_FAILED", `Transaction "${session.active_transaction_id}" is already active.`);
|
|
782
887
|
}
|
|
783
|
-
const normalizedIsolation = normalizeIsolation(isolation, connection.driver);
|
|
888
|
+
const normalizedIsolation = normalizeIsolation(isolation ?? (connection.driver === "mongodb" ? "snapshot" : "serializable"), connection.driver);
|
|
784
889
|
const transaction = this.store.createTransaction({
|
|
785
890
|
sessionId: session.id,
|
|
786
891
|
actorId: this.actorId,
|
|
@@ -829,20 +934,30 @@ export class StateQL {
|
|
|
829
934
|
// Validate durable payloads before opening a database adapter or changing
|
|
830
935
|
// the transaction state.
|
|
831
936
|
const operations = this.store.validatedTransactionOperations(transaction.id);
|
|
937
|
+
if (operations.some((operation) => operation.connection_id !== connection.id)) {
|
|
938
|
+
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.");
|
|
939
|
+
}
|
|
940
|
+
const mongoCommands = connection.driver === "mongodb"
|
|
941
|
+
? operations.map(storedMongoOperation)
|
|
942
|
+
: undefined;
|
|
943
|
+
if (connection.driver !== "mongodb" &&
|
|
944
|
+
operations.some((operation) => operation.statement_type.startsWith("mongo."))) {
|
|
945
|
+
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains native MongoDB writes for a SQL connection.");
|
|
946
|
+
}
|
|
832
947
|
const context = this.executionContext(options);
|
|
833
948
|
const adapterSource = await this.resolveConnectionSource(connection, session, "transaction.commit", "write", context);
|
|
834
|
-
const adapter =
|
|
949
|
+
const adapter = connection.driver === "mongodb"
|
|
950
|
+
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
951
|
+
: await this.openAdapter(connection, context, adapterSource);
|
|
835
952
|
try {
|
|
836
953
|
if (!this.store.claimTransactionForCommit(transaction.id, session.id, this.actorId, operations)) {
|
|
837
954
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
838
955
|
}
|
|
839
|
-
if (operations.some((operation) => operation.connection_id !== connection.id)) {
|
|
840
|
-
this.store.finishTransaction(transaction.id, session.id, this.actorId, "failed");
|
|
841
|
-
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.");
|
|
842
|
-
}
|
|
843
956
|
let results;
|
|
844
957
|
try {
|
|
845
|
-
results =
|
|
958
|
+
results = mongoCommands
|
|
959
|
+
? await adapter.writeBatch(mongoCommands, transaction.isolation_level)
|
|
960
|
+
: await adapter.writeBatch(operations, transaction.isolation_level);
|
|
846
961
|
}
|
|
847
962
|
catch (error) {
|
|
848
963
|
if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
|
|
@@ -876,6 +991,9 @@ export class StateQL {
|
|
|
876
991
|
operations: operations.map((operation, index) => ({
|
|
877
992
|
id: operation.id,
|
|
878
993
|
affectedRows: results[index].affectedRows,
|
|
994
|
+
...(results[index].outcome
|
|
995
|
+
? { outcome: results[index].outcome }
|
|
996
|
+
: {}),
|
|
879
997
|
})),
|
|
880
998
|
});
|
|
881
999
|
}
|
|
@@ -935,7 +1053,9 @@ export class StateQL {
|
|
|
935
1053
|
this.rejectDuringStagedTransaction(session, "Schema inspection");
|
|
936
1054
|
const context = this.executionContext(options);
|
|
937
1055
|
const adapterSource = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
|
|
938
|
-
const adapter =
|
|
1056
|
+
const adapter = connection.driver === "mongodb"
|
|
1057
|
+
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
1058
|
+
: await this.openAdapter(connection, context, adapterSource);
|
|
939
1059
|
try {
|
|
940
1060
|
const data = await adapter.inspect(kind, table);
|
|
941
1061
|
return {
|
|
@@ -962,6 +1082,7 @@ export class StateQL {
|
|
|
962
1082
|
async plan(sql, options = {}) {
|
|
963
1083
|
return this.run("plan", async (session) => {
|
|
964
1084
|
const connection = this.requireConnection(session);
|
|
1085
|
+
this.rejectMongoSql(connection, "mongoPlan");
|
|
965
1086
|
this.rejectDuringStagedTransaction(session, "Plans");
|
|
966
1087
|
const analysis = analyzeSql(sql, connection.driver);
|
|
967
1088
|
if (analysis.read) {
|
|
@@ -1025,6 +1146,71 @@ export class StateQL {
|
|
|
1025
1146
|
}
|
|
1026
1147
|
}, sql);
|
|
1027
1148
|
}
|
|
1149
|
+
async mongoPlan(command, options = {}) {
|
|
1150
|
+
return this.run("mongo.plan", async (session) => {
|
|
1151
|
+
const value = validatedMongoWrite(command);
|
|
1152
|
+
const serializedCommand = serializeMongoCommand(value);
|
|
1153
|
+
const connection = this.requireMongoConnection(session, "mongoPlan");
|
|
1154
|
+
this.rejectDuringStagedTransaction(session, "Plans");
|
|
1155
|
+
const safety = analyzeMongoWriteSafety(value);
|
|
1156
|
+
const context = this.executionContext(options);
|
|
1157
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "plan", "read", context);
|
|
1158
|
+
const adapter = await this.openMongoAdapter(connection, context, adapterSource);
|
|
1159
|
+
try {
|
|
1160
|
+
const stateSignature = await adapter.signature();
|
|
1161
|
+
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
1162
|
+
const plan = this.store.savePlan({
|
|
1163
|
+
sessionId: session.id,
|
|
1164
|
+
ownerActorId: this.actorId,
|
|
1165
|
+
connectionId: connection.id,
|
|
1166
|
+
sql: mongoDescriptor(value.operation),
|
|
1167
|
+
parameters: [serializedCommand],
|
|
1168
|
+
statementType: `mongo.${value.operation}`,
|
|
1169
|
+
stateVersion: version(connection),
|
|
1170
|
+
stateSignature,
|
|
1171
|
+
destructive: safety.destructive || safety.unbounded,
|
|
1172
|
+
allowUnbounded: options.allowUnbounded ?? false,
|
|
1173
|
+
allowDestructive: options.allowDestructive ?? false,
|
|
1174
|
+
expiresAt,
|
|
1175
|
+
});
|
|
1176
|
+
return {
|
|
1177
|
+
data: {
|
|
1178
|
+
plan_id: plan.id,
|
|
1179
|
+
statement_type: plan.statement_type,
|
|
1180
|
+
destructive: Boolean(plan.destructive),
|
|
1181
|
+
requires_confirmation: (safety.unbounded && !Boolean(plan.allow_unbounded)) ||
|
|
1182
|
+
(safety.destructive && !Boolean(plan.allow_destructive)),
|
|
1183
|
+
required_overrides: [
|
|
1184
|
+
...(safety.unbounded && !Boolean(plan.allow_unbounded)
|
|
1185
|
+
? ["--allow-unbounded"]
|
|
1186
|
+
: []),
|
|
1187
|
+
...(safety.destructive && !Boolean(plan.allow_destructive)
|
|
1188
|
+
? ["--allow-destructive"]
|
|
1189
|
+
: []),
|
|
1190
|
+
],
|
|
1191
|
+
state_version: plan.state_version,
|
|
1192
|
+
owner_actor_id: plan.owner_actor_id,
|
|
1193
|
+
expires_at: plan.expires_at,
|
|
1194
|
+
},
|
|
1195
|
+
handle: plan.id,
|
|
1196
|
+
executed: true,
|
|
1197
|
+
stateVersion: plan.state_version,
|
|
1198
|
+
confidence: adapter.confidence,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
if (error instanceof StateQLError)
|
|
1203
|
+
throw error;
|
|
1204
|
+
if (error instanceof AdapterExecutionError) {
|
|
1205
|
+
throw stoppedStateQLError(error, true);
|
|
1206
|
+
}
|
|
1207
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
1208
|
+
}
|
|
1209
|
+
finally {
|
|
1210
|
+
await closeAdapterQuietly(adapter);
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1028
1214
|
async apply(planId, options = {}) {
|
|
1029
1215
|
let historySql;
|
|
1030
1216
|
return this.run("apply", async (session) => {
|
|
@@ -1044,8 +1230,14 @@ export class StateQL {
|
|
|
1044
1230
|
if (Date.parse(plan.expires_at) <= this.now().getTime()) {
|
|
1045
1231
|
throw new StateQLError("STALE_PLAN", "Plan has expired.");
|
|
1046
1232
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1233
|
+
const nativePlan = plan.statement_type.startsWith("mongo.");
|
|
1234
|
+
historySql = nativePlan ? undefined : plan.sql;
|
|
1235
|
+
const mongoCommand = nativePlan
|
|
1236
|
+
? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
|
|
1237
|
+
: undefined;
|
|
1238
|
+
const planParameters = nativePlan
|
|
1239
|
+
? undefined
|
|
1240
|
+
: parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
|
|
1049
1241
|
const claimToken = this.store.nextId("claim");
|
|
1050
1242
|
const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
|
|
1051
1243
|
if (!claimed) {
|
|
@@ -1058,9 +1250,17 @@ export class StateQL {
|
|
|
1058
1250
|
version(connection) !== claimed.state_version) {
|
|
1059
1251
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1060
1252
|
}
|
|
1253
|
+
if (nativePlan && connection.driver !== "mongodb") {
|
|
1254
|
+
throw new StateQLError("STALE_PLAN", "MongoDB plan is not attached to a MongoDB connection.");
|
|
1255
|
+
}
|
|
1256
|
+
if (!nativePlan && connection.driver === "mongodb") {
|
|
1257
|
+
this.rejectMongoSql(connection, "mongoPlan");
|
|
1258
|
+
}
|
|
1061
1259
|
const context = this.executionContext(options);
|
|
1062
1260
|
const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
|
|
1063
|
-
const adapter =
|
|
1261
|
+
const adapter = nativePlan
|
|
1262
|
+
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
1263
|
+
: await this.openAdapter(connection, context, adapterSource);
|
|
1064
1264
|
try {
|
|
1065
1265
|
if ((await adapter.signature()) !== claimed.state_signature) {
|
|
1066
1266
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
@@ -1077,11 +1277,16 @@ export class StateQL {
|
|
|
1077
1277
|
finally {
|
|
1078
1278
|
await closeAdapterQuietly(adapter);
|
|
1079
1279
|
}
|
|
1080
|
-
const result =
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1280
|
+
const result = mongoCommand
|
|
1281
|
+
? await this.performMongoExec(session, connection, mongoCommand, {
|
|
1282
|
+
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1283
|
+
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1284
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource)
|
|
1285
|
+
: await this.performExec(session, connection, claimed.sql, {
|
|
1286
|
+
params: planParameters,
|
|
1287
|
+
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1288
|
+
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1289
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource);
|
|
1085
1290
|
return {
|
|
1086
1291
|
...result,
|
|
1087
1292
|
data: { plan_id: claimed.id, ...result.data },
|
|
@@ -1100,11 +1305,13 @@ export class StateQL {
|
|
|
1100
1305
|
}
|
|
1101
1306
|
}, () => historySql);
|
|
1102
1307
|
}
|
|
1103
|
-
async history(limit = 20) {
|
|
1308
|
+
async history(limit = 20, options = {}) {
|
|
1104
1309
|
return this.run("history", async (session) => ({
|
|
1105
1310
|
data: {
|
|
1106
1311
|
history: this.store
|
|
1107
|
-
.history(session.id, positiveInteger(limit, "limit")
|
|
1312
|
+
.history(session.id, positiveInteger(limit, "limit"), options.origin === undefined
|
|
1313
|
+
? undefined
|
|
1314
|
+
: parseCommandOrigin(options.origin))
|
|
1108
1315
|
.map(historyEntry),
|
|
1109
1316
|
},
|
|
1110
1317
|
}));
|
|
@@ -1131,7 +1338,7 @@ export class StateQL {
|
|
|
1131
1338
|
async capabilities() {
|
|
1132
1339
|
return this.run("capabilities", async () => ({
|
|
1133
1340
|
data: {
|
|
1134
|
-
drivers: ["mysql", "postgres", "sqlite"],
|
|
1341
|
+
drivers: ["mongodb", "mysql", "postgres", "sqlite"],
|
|
1135
1342
|
features: {
|
|
1136
1343
|
result_handles: true,
|
|
1137
1344
|
write_deduplication: true,
|
|
@@ -1147,142 +1354,194 @@ export class StateQL {
|
|
|
1147
1354
|
state_purge: true,
|
|
1148
1355
|
state_quota: true,
|
|
1149
1356
|
},
|
|
1357
|
+
driver_features: {
|
|
1358
|
+
mongodb: {
|
|
1359
|
+
sql: false,
|
|
1360
|
+
native_read: true,
|
|
1361
|
+
native_write: true,
|
|
1362
|
+
plans: true,
|
|
1363
|
+
transactions: true,
|
|
1364
|
+
transactions_require_replica_set: true,
|
|
1365
|
+
inspection: true,
|
|
1366
|
+
},
|
|
1367
|
+
},
|
|
1150
1368
|
},
|
|
1151
1369
|
}));
|
|
1152
1370
|
}
|
|
1153
|
-
async executeCommand(command) {
|
|
1154
|
-
|
|
1155
|
-
return this.batchFailure("Batch command must be an object.");
|
|
1156
|
-
}
|
|
1371
|
+
async executeCommand(command, context = {}) {
|
|
1372
|
+
let activeContext;
|
|
1157
1373
|
try {
|
|
1158
|
-
|
|
1159
|
-
case "connect":
|
|
1160
|
-
return this.connect(command.target, {
|
|
1161
|
-
name: command.name,
|
|
1162
|
-
readOnly: command.read_only,
|
|
1163
|
-
secretEnv: command.secret_env,
|
|
1164
|
-
profile: command.profile,
|
|
1165
|
-
timeoutMs: command.timeout_ms,
|
|
1166
|
-
});
|
|
1167
|
-
case "disconnect":
|
|
1168
|
-
return this.disconnect();
|
|
1169
|
-
case "status":
|
|
1170
|
-
return this.status();
|
|
1171
|
-
case "profile.add":
|
|
1172
|
-
return this.addProfile(batchString(command.name, "name"), command.target, {
|
|
1173
|
-
readOnly: command.read_only ?? true,
|
|
1174
|
-
secretEnv: command.secret_env,
|
|
1175
|
-
});
|
|
1176
|
-
case "profile.list":
|
|
1177
|
-
return this.listProfiles();
|
|
1178
|
-
case "profile.show":
|
|
1179
|
-
return this.showProfile(batchString(command.name, "name"));
|
|
1180
|
-
case "profile.remove":
|
|
1181
|
-
return this.removeProfile(batchString(command.name, "name"));
|
|
1182
|
-
case "session.start":
|
|
1183
|
-
return this.startSession(batchString(command.name, "name"));
|
|
1184
|
-
case "session.list":
|
|
1185
|
-
return this.listSessions();
|
|
1186
|
-
case "session.show":
|
|
1187
|
-
return this.showSession(command.name);
|
|
1188
|
-
case "session.summary":
|
|
1189
|
-
return this.sessionSummary();
|
|
1190
|
-
case "session.close":
|
|
1191
|
-
return this.closeSession();
|
|
1192
|
-
case "query": {
|
|
1193
|
-
const response = await this.query(batchString(command.sql, "sql"), {
|
|
1194
|
-
params: command.params ?? [],
|
|
1195
|
-
cache: command.cache ?? "auto",
|
|
1196
|
-
timeoutMs: command.timeout_ms,
|
|
1197
|
-
});
|
|
1198
|
-
if (!response.ok || !command.as)
|
|
1199
|
-
return response;
|
|
1200
|
-
const resultId = response.data.result_id;
|
|
1201
|
-
if (typeof resultId !== "string")
|
|
1202
|
-
return response;
|
|
1203
|
-
this.store.setAlias(response.session_id, command.as, resultId);
|
|
1204
|
-
return {
|
|
1205
|
-
...response,
|
|
1206
|
-
data: { ...response.data, alias: command.as },
|
|
1207
|
-
};
|
|
1208
|
-
}
|
|
1209
|
-
case "filter": {
|
|
1210
|
-
const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
|
|
1211
|
-
if (!response.ok || !command.as)
|
|
1212
|
-
return response;
|
|
1213
|
-
const resultId = response.data.result_id;
|
|
1214
|
-
if (typeof resultId !== "string")
|
|
1215
|
-
return response;
|
|
1216
|
-
this.store.setAlias(response.session_id, command.as, resultId);
|
|
1217
|
-
return {
|
|
1218
|
-
...response,
|
|
1219
|
-
data: { ...response.data, alias: command.as },
|
|
1220
|
-
};
|
|
1221
|
-
}
|
|
1222
|
-
case "exec":
|
|
1223
|
-
return this.exec(batchString(command.sql, "sql"), {
|
|
1224
|
-
params: command.params ?? [],
|
|
1225
|
-
replay: command.replay ?? false,
|
|
1226
|
-
idempotencyKey: command.idempotency_key,
|
|
1227
|
-
allowUnbounded: command.allow_unbounded ?? false,
|
|
1228
|
-
allowDestructive: command.allow_destructive ?? false,
|
|
1229
|
-
timeoutMs: command.timeout_ms,
|
|
1230
|
-
});
|
|
1231
|
-
case "show":
|
|
1232
|
-
return this.show(batchString(command.handle, "handle"));
|
|
1233
|
-
case "rows":
|
|
1234
|
-
return this.rows(batchString(command.handle, "handle"), {
|
|
1235
|
-
offset: command.offset ?? 0,
|
|
1236
|
-
limit: command.limit ?? 20,
|
|
1237
|
-
});
|
|
1238
|
-
case "count":
|
|
1239
|
-
return this.count(batchString(command.handle, "handle"));
|
|
1240
|
-
case "columns":
|
|
1241
|
-
return this.columns(batchString(command.handle, "handle"));
|
|
1242
|
-
case "alias.set":
|
|
1243
|
-
return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
|
|
1244
|
-
case "inspect":
|
|
1245
|
-
return this.inspect(batchString(command.kind, "kind"), command.table, {
|
|
1246
|
-
timeoutMs: command.timeout_ms,
|
|
1247
|
-
});
|
|
1248
|
-
case "transaction.begin":
|
|
1249
|
-
return this.beginTransaction(command.isolation);
|
|
1250
|
-
case "transaction.status":
|
|
1251
|
-
return this.transactionStatus(command.handle);
|
|
1252
|
-
case "transaction.commit":
|
|
1253
|
-
return this.commitTransaction(command.handle, {
|
|
1254
|
-
timeoutMs: command.timeout_ms,
|
|
1255
|
-
});
|
|
1256
|
-
case "transaction.rollback":
|
|
1257
|
-
return this.rollbackTransaction(command.handle);
|
|
1258
|
-
case "plan":
|
|
1259
|
-
return this.plan(batchString(command.sql, "sql"), {
|
|
1260
|
-
params: command.params ?? [],
|
|
1261
|
-
allowUnbounded: command.allow_unbounded ?? false,
|
|
1262
|
-
allowDestructive: command.allow_destructive,
|
|
1263
|
-
timeoutMs: command.timeout_ms,
|
|
1264
|
-
});
|
|
1265
|
-
case "apply":
|
|
1266
|
-
return this.apply(batchString(command.handle, "handle"), {
|
|
1267
|
-
timeoutMs: command.timeout_ms,
|
|
1268
|
-
});
|
|
1269
|
-
case "history":
|
|
1270
|
-
return this.history(command.limit ?? 20);
|
|
1271
|
-
case "receipt":
|
|
1272
|
-
return this.receipt(batchString(command.handle, "handle"));
|
|
1273
|
-
case "doctor":
|
|
1274
|
-
return this.doctor();
|
|
1275
|
-
case "purge":
|
|
1276
|
-
return this.purge(command.scope ?? "expired");
|
|
1277
|
-
case "capabilities":
|
|
1278
|
-
return this.capabilities();
|
|
1279
|
-
default:
|
|
1280
|
-
return this.batchFailure(`Unknown batch command "${String(command.command)}".`);
|
|
1281
|
-
}
|
|
1374
|
+
activeContext = mergeCommandExecutionContext(this.commandContexts.getStore(), context);
|
|
1282
1375
|
}
|
|
1283
1376
|
catch (error) {
|
|
1284
1377
|
return this.batchFailure(errorMessage(error));
|
|
1285
1378
|
}
|
|
1379
|
+
return this.commandContexts.run(activeContext, async () => {
|
|
1380
|
+
if (!command || typeof command !== "object") {
|
|
1381
|
+
return this.batchFailure("Batch command must be an object.");
|
|
1382
|
+
}
|
|
1383
|
+
try {
|
|
1384
|
+
switch (command.command) {
|
|
1385
|
+
case "connect":
|
|
1386
|
+
return this.connect(command.target, {
|
|
1387
|
+
name: command.name,
|
|
1388
|
+
readOnly: command.read_only,
|
|
1389
|
+
secretEnv: command.secret_env,
|
|
1390
|
+
profile: command.profile,
|
|
1391
|
+
timeoutMs: command.timeout_ms,
|
|
1392
|
+
});
|
|
1393
|
+
case "disconnect":
|
|
1394
|
+
return this.disconnect();
|
|
1395
|
+
case "status":
|
|
1396
|
+
return this.status();
|
|
1397
|
+
case "profile.add":
|
|
1398
|
+
return this.addProfile(batchString(command.name, "name"), command.target, {
|
|
1399
|
+
readOnly: command.read_only ?? true,
|
|
1400
|
+
secretEnv: command.secret_env,
|
|
1401
|
+
});
|
|
1402
|
+
case "profile.list":
|
|
1403
|
+
return this.listProfiles();
|
|
1404
|
+
case "profile.show":
|
|
1405
|
+
return this.showProfile(batchString(command.name, "name"));
|
|
1406
|
+
case "profile.remove":
|
|
1407
|
+
return this.removeProfile(batchString(command.name, "name"));
|
|
1408
|
+
case "session.start":
|
|
1409
|
+
return this.startSession(batchString(command.name, "name"));
|
|
1410
|
+
case "session.list":
|
|
1411
|
+
return this.listSessions();
|
|
1412
|
+
case "session.show":
|
|
1413
|
+
return this.showSession(command.name);
|
|
1414
|
+
case "session.summary":
|
|
1415
|
+
return this.sessionSummary();
|
|
1416
|
+
case "session.close":
|
|
1417
|
+
return this.closeSession();
|
|
1418
|
+
case "query": {
|
|
1419
|
+
const response = await this.query(batchString(command.sql, "sql"), {
|
|
1420
|
+
params: command.params ?? [],
|
|
1421
|
+
cache: command.cache ?? "auto",
|
|
1422
|
+
timeoutMs: command.timeout_ms,
|
|
1423
|
+
});
|
|
1424
|
+
if (!response.ok || !command.as)
|
|
1425
|
+
return response;
|
|
1426
|
+
const resultId = response.data.result_id;
|
|
1427
|
+
if (typeof resultId !== "string")
|
|
1428
|
+
return response;
|
|
1429
|
+
this.store.setAlias(response.session_id, command.as, resultId);
|
|
1430
|
+
return {
|
|
1431
|
+
...response,
|
|
1432
|
+
data: { ...response.data, alias: command.as },
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
case "mongo.query": {
|
|
1436
|
+
const response = await this.mongoQuery(command.mongo, {
|
|
1437
|
+
cache: command.cache ?? "auto",
|
|
1438
|
+
timeoutMs: command.timeout_ms,
|
|
1439
|
+
});
|
|
1440
|
+
if (!response.ok || !command.as)
|
|
1441
|
+
return response;
|
|
1442
|
+
const resultId = response.data.result_id;
|
|
1443
|
+
if (typeof resultId !== "string")
|
|
1444
|
+
return response;
|
|
1445
|
+
this.store.setAlias(response.session_id, command.as, resultId);
|
|
1446
|
+
return {
|
|
1447
|
+
...response,
|
|
1448
|
+
data: { ...response.data, alias: command.as },
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
case "filter": {
|
|
1452
|
+
const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
|
|
1453
|
+
if (!response.ok || !command.as)
|
|
1454
|
+
return response;
|
|
1455
|
+
const resultId = response.data.result_id;
|
|
1456
|
+
if (typeof resultId !== "string")
|
|
1457
|
+
return response;
|
|
1458
|
+
this.store.setAlias(response.session_id, command.as, resultId);
|
|
1459
|
+
return {
|
|
1460
|
+
...response,
|
|
1461
|
+
data: { ...response.data, alias: command.as },
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
case "exec":
|
|
1465
|
+
return this.exec(batchString(command.sql, "sql"), {
|
|
1466
|
+
params: command.params ?? [],
|
|
1467
|
+
replay: command.replay ?? false,
|
|
1468
|
+
idempotencyKey: command.idempotency_key,
|
|
1469
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
1470
|
+
allowDestructive: command.allow_destructive ?? false,
|
|
1471
|
+
timeoutMs: command.timeout_ms,
|
|
1472
|
+
});
|
|
1473
|
+
case "mongo.exec":
|
|
1474
|
+
return this.mongoExec(command.mongo, {
|
|
1475
|
+
replay: command.replay ?? false,
|
|
1476
|
+
idempotencyKey: command.idempotency_key,
|
|
1477
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
1478
|
+
allowDestructive: command.allow_destructive ?? false,
|
|
1479
|
+
timeoutMs: command.timeout_ms,
|
|
1480
|
+
});
|
|
1481
|
+
case "show":
|
|
1482
|
+
return this.show(batchString(command.handle, "handle"));
|
|
1483
|
+
case "rows":
|
|
1484
|
+
return this.rows(batchString(command.handle, "handle"), {
|
|
1485
|
+
offset: command.offset ?? 0,
|
|
1486
|
+
limit: command.limit ?? 20,
|
|
1487
|
+
});
|
|
1488
|
+
case "count":
|
|
1489
|
+
return this.count(batchString(command.handle, "handle"));
|
|
1490
|
+
case "columns":
|
|
1491
|
+
return this.columns(batchString(command.handle, "handle"));
|
|
1492
|
+
case "alias.set":
|
|
1493
|
+
return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
|
|
1494
|
+
case "inspect":
|
|
1495
|
+
return this.inspect(batchString(command.kind, "kind"), command.table, {
|
|
1496
|
+
timeoutMs: command.timeout_ms,
|
|
1497
|
+
});
|
|
1498
|
+
case "transaction.begin":
|
|
1499
|
+
return this.beginTransaction(command.isolation);
|
|
1500
|
+
case "transaction.status":
|
|
1501
|
+
return this.transactionStatus(command.handle);
|
|
1502
|
+
case "transaction.commit":
|
|
1503
|
+
return this.commitTransaction(command.handle, {
|
|
1504
|
+
timeoutMs: command.timeout_ms,
|
|
1505
|
+
});
|
|
1506
|
+
case "transaction.rollback":
|
|
1507
|
+
return this.rollbackTransaction(command.handle);
|
|
1508
|
+
case "plan":
|
|
1509
|
+
return this.plan(batchString(command.sql, "sql"), {
|
|
1510
|
+
params: command.params ?? [],
|
|
1511
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
1512
|
+
allowDestructive: command.allow_destructive,
|
|
1513
|
+
timeoutMs: command.timeout_ms,
|
|
1514
|
+
});
|
|
1515
|
+
case "mongo.plan":
|
|
1516
|
+
return this.mongoPlan(command.mongo, {
|
|
1517
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
1518
|
+
allowDestructive: command.allow_destructive,
|
|
1519
|
+
timeoutMs: command.timeout_ms,
|
|
1520
|
+
});
|
|
1521
|
+
case "apply":
|
|
1522
|
+
return this.apply(batchString(command.handle, "handle"), {
|
|
1523
|
+
timeoutMs: command.timeout_ms,
|
|
1524
|
+
});
|
|
1525
|
+
case "history":
|
|
1526
|
+
return this.history(command.limit ?? 20, {
|
|
1527
|
+
origin: command.history_origin,
|
|
1528
|
+
});
|
|
1529
|
+
case "receipt":
|
|
1530
|
+
return this.receipt(batchString(command.handle, "handle"));
|
|
1531
|
+
case "doctor":
|
|
1532
|
+
return this.doctor();
|
|
1533
|
+
case "purge":
|
|
1534
|
+
return this.purge(command.scope ?? "expired");
|
|
1535
|
+
case "capabilities":
|
|
1536
|
+
return this.capabilities();
|
|
1537
|
+
default:
|
|
1538
|
+
return this.batchFailure(`Unknown batch command "${String(command.command)}".`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
catch (error) {
|
|
1542
|
+
return this.batchFailure(errorMessage(error));
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1286
1545
|
}
|
|
1287
1546
|
async *batch(commands, options = {}) {
|
|
1288
1547
|
const maxCommands = options.maxCommands ?? 1_000;
|
|
@@ -1297,13 +1556,14 @@ export class StateQL {
|
|
|
1297
1556
|
yield await this.batchFailure(`Batch cannot exceed ${maxCommands} commands.`);
|
|
1298
1557
|
return;
|
|
1299
1558
|
}
|
|
1300
|
-
const response = await this.executeCommand(command);
|
|
1559
|
+
const response = await this.executeCommand(command, options.executionContext);
|
|
1301
1560
|
yield response;
|
|
1302
1561
|
if (!response.ok && !options.continueOnError)
|
|
1303
1562
|
return;
|
|
1304
1563
|
}
|
|
1305
1564
|
}
|
|
1306
1565
|
async performExec(session, connection, sql, options, context, planClaim, resolvedSource) {
|
|
1566
|
+
this.rejectMongoSql(connection, "mongoExec");
|
|
1307
1567
|
if (connection.read_only) {
|
|
1308
1568
|
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1309
1569
|
}
|
|
@@ -1492,6 +1752,197 @@ export class StateQL {
|
|
|
1492
1752
|
}
|
|
1493
1753
|
}
|
|
1494
1754
|
}
|
|
1755
|
+
async performMongoExec(session, connection, command, options, context, planClaim, resolvedSource) {
|
|
1756
|
+
const value = validatedMongoWrite(command);
|
|
1757
|
+
if (connection.driver !== "mongodb") {
|
|
1758
|
+
throw new StateQLError("INVALID_COMMAND", "mongoExec requires an active MongoDB connection.");
|
|
1759
|
+
}
|
|
1760
|
+
if (connection.read_only) {
|
|
1761
|
+
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1762
|
+
}
|
|
1763
|
+
const safety = analyzeMongoWriteSafety(value);
|
|
1764
|
+
if (safety.unbounded && !options.allowUnbounded) {
|
|
1765
|
+
throw new StateQLError("UNBOUNDED_MUTATION", "MongoDB mutation has an empty filter.", { extra: { override_flag: "--allow-unbounded" } });
|
|
1766
|
+
}
|
|
1767
|
+
if (safety.destructive && !options.allowDestructive) {
|
|
1768
|
+
throw new StateQLError("DESTRUCTIVE_OPERATION_BLOCKED", "Destructive MongoDB operation requires an explicit override.", { extra: { override_flag: "--allow-destructive" } });
|
|
1769
|
+
}
|
|
1770
|
+
if (options.idempotencyKey !== undefined &&
|
|
1771
|
+
!options.idempotencyKey.trim()) {
|
|
1772
|
+
throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
|
|
1773
|
+
}
|
|
1774
|
+
const serializedCommand = serializeMongoCommand(value);
|
|
1775
|
+
const parameters = [serializedCommand];
|
|
1776
|
+
const fingerprint = hash({
|
|
1777
|
+
command: serializedCommand,
|
|
1778
|
+
database: databaseIdentity(connection),
|
|
1779
|
+
});
|
|
1780
|
+
const transactionId = session.active_transaction_id ?? undefined;
|
|
1781
|
+
if (transactionId) {
|
|
1782
|
+
const transaction = this.store.getTransaction(transactionId);
|
|
1783
|
+
if (!transaction ||
|
|
1784
|
+
transaction.session_id !== session.id ||
|
|
1785
|
+
transaction.state !== "active" ||
|
|
1786
|
+
transaction.connection_id !== connection.id) {
|
|
1787
|
+
throw new StateQLError("TRANSACTION_FAILED", "Active transaction does not match the active connection.");
|
|
1788
|
+
}
|
|
1789
|
+
if (transaction.owner_actor_id !== this.actorId) {
|
|
1790
|
+
throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const reservation = this.store.reserveOperation({
|
|
1794
|
+
sessionId: session.id,
|
|
1795
|
+
actorId: this.actorId,
|
|
1796
|
+
connectionId: connection.id,
|
|
1797
|
+
fingerprint,
|
|
1798
|
+
sql: mongoDescriptor(value.operation),
|
|
1799
|
+
parameters,
|
|
1800
|
+
statementType: `mongo.${value.operation}`,
|
|
1801
|
+
status: transactionId ? "pending" : "executing",
|
|
1802
|
+
transactionId,
|
|
1803
|
+
replay: options.replay ?? false,
|
|
1804
|
+
idempotencyKey: options.idempotencyKey,
|
|
1805
|
+
stateVersionBefore: version(connection),
|
|
1806
|
+
});
|
|
1807
|
+
if (reservation.denied === "membership") {
|
|
1808
|
+
throw new StateQLError("PERMISSION_DENIED", "Actor membership changed before the write was reserved.");
|
|
1809
|
+
}
|
|
1810
|
+
if (reservation.denied === "transaction") {
|
|
1811
|
+
const active = this.store.getSession(session.id)?.active_transaction_id;
|
|
1812
|
+
const transaction = active ? this.store.getTransaction(active) : undefined;
|
|
1813
|
+
if (transaction && transaction.owner_actor_id !== this.actorId) {
|
|
1814
|
+
throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
|
|
1815
|
+
}
|
|
1816
|
+
throw new StateQLError("TRANSACTION_FAILED", "The active transaction changed before the write was reserved.");
|
|
1817
|
+
}
|
|
1818
|
+
const previous = reservation.previous;
|
|
1819
|
+
if (previous &&
|
|
1820
|
+
options.idempotencyKey &&
|
|
1821
|
+
!options.replay &&
|
|
1822
|
+
previous.fingerprint !== fingerprint) {
|
|
1823
|
+
throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.", { extra: { previous_operation_id: previous.id } });
|
|
1824
|
+
}
|
|
1825
|
+
if (previous && !reservation.operation) {
|
|
1826
|
+
if (previous.status === "executing" ||
|
|
1827
|
+
previous.status === "outcome_unknown") {
|
|
1828
|
+
throw new StateQLError("OUTCOME_UNKNOWN", "A matching write has an unknown outcome.", {
|
|
1829
|
+
executed: true,
|
|
1830
|
+
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1831
|
+
extra: { previous_operation_id: previous.id },
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
if (options.idempotencyKey) {
|
|
1835
|
+
return {
|
|
1836
|
+
data: {
|
|
1837
|
+
...operationData(previous),
|
|
1838
|
+
duplicate: true,
|
|
1839
|
+
duplicate_of: previous.id,
|
|
1840
|
+
idempotency_key: options.idempotencyKey,
|
|
1841
|
+
},
|
|
1842
|
+
handle: previous.id,
|
|
1843
|
+
cached: true,
|
|
1844
|
+
stateVersion: previous.state_version_after ?? previous.state_version_before,
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
throw new StateQLError("POTENTIAL_DUPLICATE_WRITE", "An equivalent operation was previously applied.", {
|
|
1848
|
+
extra: {
|
|
1849
|
+
previous_operation_id: previous.id,
|
|
1850
|
+
replay_required: true,
|
|
1851
|
+
},
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
const operation = reservation.operation;
|
|
1855
|
+
if (transactionId) {
|
|
1856
|
+
return {
|
|
1857
|
+
data: operationData(operation),
|
|
1858
|
+
handle: operation.id,
|
|
1859
|
+
executed: false,
|
|
1860
|
+
stateVersion: version(connection),
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
let adapter;
|
|
1864
|
+
let adapterSource;
|
|
1865
|
+
try {
|
|
1866
|
+
adapterSource =
|
|
1867
|
+
resolvedSource ??
|
|
1868
|
+
(await this.resolveConnectionSource(connection, session, "exec", "write", context));
|
|
1869
|
+
adapter = await this.openMongoAdapter(connection, context, adapterSource);
|
|
1870
|
+
}
|
|
1871
|
+
catch (error) {
|
|
1872
|
+
this.store.failOperation(operation.id);
|
|
1873
|
+
if (error instanceof StateQLError)
|
|
1874
|
+
throw error;
|
|
1875
|
+
throw new StateQLError("CONNECTION_FAILED", "Database connection failed.", {
|
|
1876
|
+
retryable: true,
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
try {
|
|
1880
|
+
const write = await adapter.write(value);
|
|
1881
|
+
try {
|
|
1882
|
+
const finalized = planClaim
|
|
1883
|
+
? this.store.finishPlannedOperation({
|
|
1884
|
+
planId: planClaim.planId,
|
|
1885
|
+
claimToken: planClaim.claimToken,
|
|
1886
|
+
operationId: operation.id,
|
|
1887
|
+
connectionId: connection.id,
|
|
1888
|
+
affectedRows: write.affectedRows,
|
|
1889
|
+
outcome: write.outcome,
|
|
1890
|
+
})
|
|
1891
|
+
: (() => {
|
|
1892
|
+
const stateVersion = this.store.bumpVersion(connection.id);
|
|
1893
|
+
return {
|
|
1894
|
+
operation: this.store.finishOperation(operation.id, write.affectedRows, stateVersion, write.outcome),
|
|
1895
|
+
stateVersion,
|
|
1896
|
+
};
|
|
1897
|
+
})();
|
|
1898
|
+
const committed = finalized.operation;
|
|
1899
|
+
const after = finalized.stateVersion;
|
|
1900
|
+
return {
|
|
1901
|
+
data: {
|
|
1902
|
+
...operationData(committed),
|
|
1903
|
+
duplicate: Boolean(previous),
|
|
1904
|
+
duplicate_override: Boolean(previous),
|
|
1905
|
+
},
|
|
1906
|
+
handle: committed.id,
|
|
1907
|
+
executed: true,
|
|
1908
|
+
stateVersion: after,
|
|
1909
|
+
confidence: adapter.confidence,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
catch (error) {
|
|
1913
|
+
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1914
|
+
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
1915
|
+
executed: true,
|
|
1916
|
+
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
catch (error) {
|
|
1921
|
+
if (error instanceof StateQLError)
|
|
1922
|
+
throw error;
|
|
1923
|
+
if (error instanceof AdapterExecutionError && !error.outcomeUnknown) {
|
|
1924
|
+
this.store.failOperation(operation.id);
|
|
1925
|
+
throw stoppedStateQLError(error, false);
|
|
1926
|
+
}
|
|
1927
|
+
if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
|
|
1928
|
+
this.store.failOperation(operation.id);
|
|
1929
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
|
|
1930
|
+
}
|
|
1931
|
+
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1932
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
1933
|
+
executed: true,
|
|
1934
|
+
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
finally {
|
|
1938
|
+
try {
|
|
1939
|
+
await adapter.close();
|
|
1940
|
+
}
|
|
1941
|
+
catch {
|
|
1942
|
+
// Write outcome and metadata are already recorded.
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1495
1946
|
batchFailure(message) {
|
|
1496
1947
|
return this.run("batch", async () => {
|
|
1497
1948
|
throw new StateQLError("INVALID_COMMAND", message);
|
|
@@ -1528,6 +1979,18 @@ export class StateQL {
|
|
|
1528
1979
|
}
|
|
1529
1980
|
return connection;
|
|
1530
1981
|
}
|
|
1982
|
+
requireMongoConnection(session, method) {
|
|
1983
|
+
const connection = this.requireConnection(session);
|
|
1984
|
+
if (connection.driver !== "mongodb") {
|
|
1985
|
+
throw new StateQLError("INVALID_COMMAND", `${method} requires an active MongoDB connection.`);
|
|
1986
|
+
}
|
|
1987
|
+
return connection;
|
|
1988
|
+
}
|
|
1989
|
+
rejectMongoSql(connection, nativeMethod) {
|
|
1990
|
+
if (connection.driver !== "mongodb")
|
|
1991
|
+
return;
|
|
1992
|
+
throw new StateQLError("INVALID_COMMAND", `SQL is not supported for MongoDB connections; use ${nativeMethod} instead.`, { suggestedAction: `Use ${nativeMethod} with a native MongoDB command.` });
|
|
1993
|
+
}
|
|
1531
1994
|
requireActiveTransaction(session, id) {
|
|
1532
1995
|
const transactionId = id ?? session.active_transaction_id;
|
|
1533
1996
|
if (!transactionId) {
|
|
@@ -1615,8 +2078,18 @@ export class StateQL {
|
|
|
1615
2078
|
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
|
|
1616
2079
|
}
|
|
1617
2080
|
}
|
|
2081
|
+
async openMongoAdapter(connection, context, source) {
|
|
2082
|
+
try {
|
|
2083
|
+
return new MongoAdapter(connection, context, { source });
|
|
2084
|
+
}
|
|
2085
|
+
catch (error) {
|
|
2086
|
+
if (error instanceof StateQLError)
|
|
2087
|
+
throw error;
|
|
2088
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
1618
2091
|
executionContext(options) {
|
|
1619
|
-
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal
|
|
2092
|
+
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), combineAbortSignals(options.signal, this.commandContexts.getStore()?.signal, this.signal));
|
|
1620
2093
|
}
|
|
1621
2094
|
resultData(result, cached) {
|
|
1622
2095
|
const rows = this.store.resultRows(result);
|
|
@@ -1646,6 +2119,7 @@ export class StateQL {
|
|
|
1646
2119
|
}
|
|
1647
2120
|
async run(command, action, historySql) {
|
|
1648
2121
|
const started = performance.now();
|
|
2122
|
+
const origin = this.commandContexts.getStore()?.origin ?? "legacy";
|
|
1649
2123
|
let session = this.store.ensureSession(this.sessionName);
|
|
1650
2124
|
const commandId = this.store.nextId("cmd");
|
|
1651
2125
|
if (!this.store.isSessionMember(session.id, this.actorId)) {
|
|
@@ -1668,6 +2142,7 @@ export class StateQL {
|
|
|
1668
2142
|
id: commandId,
|
|
1669
2143
|
sessionId: session.id,
|
|
1670
2144
|
actorId: this.actorId,
|
|
2145
|
+
origin,
|
|
1671
2146
|
command,
|
|
1672
2147
|
...(result.handle ? { handle: result.handle } : {}),
|
|
1673
2148
|
...(sqlText !== undefined ? { sql: sqlText } : {}),
|
|
@@ -1699,6 +2174,7 @@ export class StateQL {
|
|
|
1699
2174
|
id: commandId,
|
|
1700
2175
|
sessionId: session.id,
|
|
1701
2176
|
actorId: this.actorId,
|
|
2177
|
+
origin,
|
|
1702
2178
|
command,
|
|
1703
2179
|
...(sqlText !== undefined ? { sql: sqlText } : {}),
|
|
1704
2180
|
executed: stateqlError.details.executed,
|
|
@@ -1727,6 +2203,7 @@ function historyEntry(item) {
|
|
|
1727
2203
|
timestamp: item.timestamp,
|
|
1728
2204
|
session_id: item.session_id,
|
|
1729
2205
|
actor_id: item.actor_id,
|
|
2206
|
+
origin: item.origin,
|
|
1730
2207
|
command: item.command,
|
|
1731
2208
|
sql: item.sql,
|
|
1732
2209
|
handle: item.handle,
|
|
@@ -1736,6 +2213,41 @@ function historyEntry(item) {
|
|
|
1736
2213
|
error_code: item.error_code,
|
|
1737
2214
|
};
|
|
1738
2215
|
}
|
|
2216
|
+
const COMMAND_ORIGINS = new Set([
|
|
2217
|
+
"legacy",
|
|
2218
|
+
"user",
|
|
2219
|
+
"model",
|
|
2220
|
+
"system",
|
|
2221
|
+
"api",
|
|
2222
|
+
]);
|
|
2223
|
+
function parseCommandOrigin(value) {
|
|
2224
|
+
if (typeof value === "string" && COMMAND_ORIGINS.has(value)) {
|
|
2225
|
+
return value;
|
|
2226
|
+
}
|
|
2227
|
+
throw new StateQLError("INVALID_COMMAND", `Unknown command origin "${String(value)}".`);
|
|
2228
|
+
}
|
|
2229
|
+
function mergeCommandExecutionContext(inherited, supplied) {
|
|
2230
|
+
if (!supplied || typeof supplied !== "object") {
|
|
2231
|
+
throw new StateQLError("INVALID_COMMAND", "Command execution context must be an object.");
|
|
2232
|
+
}
|
|
2233
|
+
if (supplied.signal !== undefined && !(supplied.signal instanceof AbortSignal)) {
|
|
2234
|
+
throw new StateQLError("INVALID_COMMAND", "Command execution context signal must be an AbortSignal.");
|
|
2235
|
+
}
|
|
2236
|
+
return {
|
|
2237
|
+
signal: combineAbortSignals(inherited?.signal, supplied.signal),
|
|
2238
|
+
origin: supplied.origin === undefined
|
|
2239
|
+
? inherited?.origin
|
|
2240
|
+
: parseCommandOrigin(supplied.origin),
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
function combineAbortSignals(...signals) {
|
|
2244
|
+
const present = signals.filter((signal) => signal !== undefined);
|
|
2245
|
+
if (present.length === 0)
|
|
2246
|
+
return undefined;
|
|
2247
|
+
if (present.length === 1)
|
|
2248
|
+
return present[0];
|
|
2249
|
+
return AbortSignal.any(present);
|
|
2250
|
+
}
|
|
1739
2251
|
function markTransactionOutcomeUnknown(store, transactionId, sessionId, actorId) {
|
|
1740
2252
|
try {
|
|
1741
2253
|
store.markTransactionOutcomeUnknown(transactionId, sessionId, actorId);
|
|
@@ -1748,12 +2260,76 @@ function boundedReadSql(sql, limit) {
|
|
|
1748
2260
|
const statement = sql.trim().replace(/;\s*$/, "");
|
|
1749
2261
|
return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
|
|
1750
2262
|
}
|
|
2263
|
+
function validatedMongoRead(command) {
|
|
2264
|
+
try {
|
|
2265
|
+
return validateMongoReadCommand(command);
|
|
2266
|
+
}
|
|
2267
|
+
catch (error) {
|
|
2268
|
+
throw new StateQLError("INVALID_COMMAND", errorMessage(error));
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
function validatedMongoWrite(command) {
|
|
2272
|
+
try {
|
|
2273
|
+
return validateMongoWriteCommand(command);
|
|
2274
|
+
}
|
|
2275
|
+
catch (error) {
|
|
2276
|
+
throw new StateQLError("INVALID_COMMAND", errorMessage(error));
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
function mongoDescriptor(operation) {
|
|
2280
|
+
return `MongoDB native ${operation}`;
|
|
2281
|
+
}
|
|
2282
|
+
function mongoPaginationWarnings(command) {
|
|
2283
|
+
const ordered = command.operation === "find"
|
|
2284
|
+
? command.options?.sort !== undefined
|
|
2285
|
+
: command.pipeline.some((stage) => Object.prototype.hasOwnProperty.call(stage, "$sort"));
|
|
2286
|
+
return ordered
|
|
2287
|
+
? []
|
|
2288
|
+
: [{
|
|
2289
|
+
code: "NON_DETERMINISTIC_PAGINATION",
|
|
2290
|
+
message: "MongoDB result has no explicit sort.",
|
|
2291
|
+
}];
|
|
2292
|
+
}
|
|
2293
|
+
function storedMongoOperation(operation) {
|
|
2294
|
+
if (!operation.statement_type.startsWith("mongo.")) {
|
|
2295
|
+
throw new StateQLError("TRANSACTION_FAILED", "MongoDB transaction contains a mixed or corrupt native payload.");
|
|
2296
|
+
}
|
|
2297
|
+
return storedMongoWrite(operation.parameters, operation.statement_type, `operation "${operation.id}"`, "TRANSACTION_FAILED");
|
|
2298
|
+
}
|
|
2299
|
+
function storedMongoPlan(parameters, statementType, planId) {
|
|
2300
|
+
return storedMongoWrite(parameters, statementType, `plan "${planId}"`, "STALE_PLAN");
|
|
2301
|
+
}
|
|
2302
|
+
function storedMongoWrite(parameters, statementType, label, errorCode) {
|
|
2303
|
+
try {
|
|
2304
|
+
const payload = JSON.parse(parameters);
|
|
2305
|
+
if (!Array.isArray(payload) ||
|
|
2306
|
+
payload.length !== 1 ||
|
|
2307
|
+
typeof payload[0] !== "string") {
|
|
2308
|
+
throw new Error("payload must contain one EJSON command string");
|
|
2309
|
+
}
|
|
2310
|
+
const command = deserializeMongoWriteCommand(payload[0]);
|
|
2311
|
+
if (statementType !== `mongo.${command.operation}`) {
|
|
2312
|
+
throw new Error("operation does not match its statement type");
|
|
2313
|
+
}
|
|
2314
|
+
return command;
|
|
2315
|
+
}
|
|
2316
|
+
catch {
|
|
2317
|
+
throw new StateQLError(errorCode, `Stored MongoDB ${label} payload is invalid.`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
1751
2320
|
function databaseDisplayName(driver) {
|
|
2321
|
+
if (driver === "mongodb")
|
|
2322
|
+
return "MongoDB";
|
|
1752
2323
|
return driver === "postgres" ? "PostgreSQL" : "MySQL";
|
|
1753
2324
|
}
|
|
1754
2325
|
function normalizeIsolation(isolation, driver) {
|
|
1755
2326
|
const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
|
|
1756
2327
|
.replace(/\s+/g, " ");
|
|
2328
|
+
if (driver === "mongodb") {
|
|
2329
|
+
if (normalized === "snapshot")
|
|
2330
|
+
return normalized;
|
|
2331
|
+
throw new StateQLError("INVALID_COMMAND", `MongoDB does not support isolation level "${normalized}".`);
|
|
2332
|
+
}
|
|
1757
2333
|
const supported = new Set([
|
|
1758
2334
|
"serializable",
|
|
1759
2335
|
"repeatable read",
|