@fadhilp/stateql 0.5.4 → 0.6.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 +64 -21
- 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 +10 -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 +8 -1
- package/dist/src/stateql.js +544 -22
- package/dist/src/store.d.ts +5 -2
- package/dist/src/store.js +13 -7
- package/dist/src/types.d.ts +90 -2
- package/package.json +3 -2
package/dist/src/stateql.js
CHANGED
|
@@ -2,8 +2,9 @@ import { writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
import { env } from "node:process";
|
|
4
4
|
import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
|
|
5
|
-
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
5
|
+
import { confidence, credentialSource, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, mongoDatabaseName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
|
|
6
6
|
import { asStateQLError, CredentialResolutionError, StateQLError, } from "./errors.js";
|
|
7
|
+
import { analyzeMongoWriteSafety, deserializeMongoWriteCommand, serializeMongoCommand, validateMongoReadCommand, validateMongoWriteCommand, MongoAdapter, } from "./mongodb.js";
|
|
7
8
|
import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
|
|
8
9
|
import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
|
|
9
10
|
import { analyzeSql } from "./sql.js";
|
|
@@ -139,7 +140,9 @@ export class StateQL {
|
|
|
139
140
|
: adapterSource;
|
|
140
141
|
const databaseName = driver === "sqlite"
|
|
141
142
|
? basename(adapterSource)
|
|
142
|
-
:
|
|
143
|
+
: driver === "mongodb"
|
|
144
|
+
? mongoDatabaseName(adapterSource)
|
|
145
|
+
: new URL(secret).pathname.replace(/^\//, "") || driver;
|
|
143
146
|
const draft = {
|
|
144
147
|
id: "pending",
|
|
145
148
|
session_id: session.id,
|
|
@@ -152,9 +155,11 @@ export class StateQL {
|
|
|
152
155
|
version: 0,
|
|
153
156
|
created_at: this.now().toISOString(),
|
|
154
157
|
};
|
|
155
|
-
const adapter =
|
|
158
|
+
const adapter = driver === "mongodb"
|
|
159
|
+
? await this.openMongoAdapter(draft, context, adapterSource)
|
|
160
|
+
: await this.openAdapter(draft, context, adapterSource);
|
|
156
161
|
try {
|
|
157
|
-
await adapter.
|
|
162
|
+
await adapter.ping();
|
|
158
163
|
}
|
|
159
164
|
catch (error) {
|
|
160
165
|
if (error instanceof AdapterExecutionError) {
|
|
@@ -524,6 +529,7 @@ export class StateQL {
|
|
|
524
529
|
async query(sql, options = {}) {
|
|
525
530
|
return this.run("query", async (session) => {
|
|
526
531
|
const connection = this.requireConnection(session);
|
|
532
|
+
this.rejectMongoSql(connection, "mongoQuery");
|
|
527
533
|
this.rejectDuringStagedTransaction(session, "Queries");
|
|
528
534
|
const analysis = analyzeSql(sql, connection.driver);
|
|
529
535
|
if (!analysis.read) {
|
|
@@ -615,6 +621,95 @@ export class StateQL {
|
|
|
615
621
|
}
|
|
616
622
|
}, sql);
|
|
617
623
|
}
|
|
624
|
+
async mongoQuery(command, options = {}) {
|
|
625
|
+
return this.run("mongo.query", async (session) => {
|
|
626
|
+
const value = validatedMongoRead(command);
|
|
627
|
+
const serializedCommand = serializeMongoCommand(value);
|
|
628
|
+
const connection = this.requireMongoConnection(session, "mongoQuery");
|
|
629
|
+
this.rejectDuringStagedTransaction(session, "MongoDB queries");
|
|
630
|
+
const context = this.executionContext(options);
|
|
631
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "query", "read", context);
|
|
632
|
+
const adapter = await this.openMongoAdapter(connection, context, adapterSource);
|
|
633
|
+
try {
|
|
634
|
+
const stateVersion = version(connection);
|
|
635
|
+
const stateSignature = await adapter.signature();
|
|
636
|
+
const fingerprint = hash({
|
|
637
|
+
command: serializedCommand,
|
|
638
|
+
driver: connection.driver,
|
|
639
|
+
connection: connection.id,
|
|
640
|
+
database: connection.database_name,
|
|
641
|
+
transaction: session.active_transaction_id,
|
|
642
|
+
stateVersion,
|
|
643
|
+
});
|
|
644
|
+
const cached = this.store.findResult(fingerprint);
|
|
645
|
+
const cacheMode = options.cache ?? "auto";
|
|
646
|
+
const warnings = mongoPaginationWarnings(value);
|
|
647
|
+
if (cacheMode !== "bypass" &&
|
|
648
|
+
cached &&
|
|
649
|
+
cached.row_count <= this.maxResultRows &&
|
|
650
|
+
this.cacheValid(cached, stateVersion, stateSignature)) {
|
|
651
|
+
return {
|
|
652
|
+
data: this.resultData(cached, true),
|
|
653
|
+
handle: cached.id,
|
|
654
|
+
cached: true,
|
|
655
|
+
warnings,
|
|
656
|
+
stateVersion,
|
|
657
|
+
confidence: cached.state_confidence,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
if (cacheMode === "require") {
|
|
661
|
+
throw new StateQLError("CACHE_MISS", "No valid cached result exists.", {
|
|
662
|
+
retryable: true,
|
|
663
|
+
suggestedAction: "Run with cache auto or cache bypass.",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const result = await adapter.read(value, this.maxResultRows + 1);
|
|
667
|
+
if (result.rows.length > this.maxResultRows) {
|
|
668
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Use a narrower filter or limit." });
|
|
669
|
+
}
|
|
670
|
+
const parameters = [serializedCommand];
|
|
671
|
+
const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") +
|
|
672
|
+
Buffer.byteLength(JSON.stringify(result.rows), "utf8") +
|
|
673
|
+
Buffer.byteLength(JSON.stringify(result.columns), "utf8");
|
|
674
|
+
if (resultBytes > this.maxResultBytes) {
|
|
675
|
+
throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultBytes}-byte materialization limit.`, { suggestedAction: "Return fewer or smaller documents." });
|
|
676
|
+
}
|
|
677
|
+
const expiresAt = new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString();
|
|
678
|
+
const saved = this.store.saveResult({
|
|
679
|
+
sessionId: session.id,
|
|
680
|
+
connectionId: connection.id,
|
|
681
|
+
fingerprint,
|
|
682
|
+
sql: mongoDescriptor(value.operation),
|
|
683
|
+
parameters,
|
|
684
|
+
rows: result.rows,
|
|
685
|
+
columns: result.columns,
|
|
686
|
+
stateVersion,
|
|
687
|
+
stateSignature,
|
|
688
|
+
stateConfidence: adapter.confidence,
|
|
689
|
+
expiresAt,
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
data: this.resultData(saved, false),
|
|
693
|
+
handle: saved.id,
|
|
694
|
+
executed: true,
|
|
695
|
+
warnings,
|
|
696
|
+
stateVersion,
|
|
697
|
+
confidence: adapter.confidence,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
if (error instanceof StateQLError)
|
|
702
|
+
throw error;
|
|
703
|
+
if (error instanceof AdapterExecutionError) {
|
|
704
|
+
throw stoppedStateQLError(error, true);
|
|
705
|
+
}
|
|
706
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
await closeAdapterQuietly(adapter);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
618
713
|
async show(idOrAlias) {
|
|
619
714
|
return this.withResult("show", idOrAlias, async (result) => ({
|
|
620
715
|
data: this.resultData(result, true),
|
|
@@ -755,9 +850,17 @@ export class StateQL {
|
|
|
755
850
|
async exec(sql, options = {}) {
|
|
756
851
|
return this.run("exec", async (session) => {
|
|
757
852
|
const connection = this.requireConnection(session);
|
|
853
|
+
this.rejectMongoSql(connection, "mongoExec");
|
|
758
854
|
return this.performExec(session, connection, sql, options, this.executionContext(options));
|
|
759
855
|
}, sql);
|
|
760
856
|
}
|
|
857
|
+
async mongoExec(command, options = {}) {
|
|
858
|
+
return this.run("mongo.exec", async (session) => {
|
|
859
|
+
const value = validatedMongoWrite(command);
|
|
860
|
+
const connection = this.requireMongoConnection(session, "mongoExec");
|
|
861
|
+
return this.performMongoExec(session, connection, value, options, this.executionContext(options));
|
|
862
|
+
});
|
|
863
|
+
}
|
|
761
864
|
async receipt(id) {
|
|
762
865
|
return this.run("receipt", async (session) => {
|
|
763
866
|
const operation = this.store.getOperation(id);
|
|
@@ -771,7 +874,7 @@ export class StateQL {
|
|
|
771
874
|
};
|
|
772
875
|
});
|
|
773
876
|
}
|
|
774
|
-
async beginTransaction(isolation
|
|
877
|
+
async beginTransaction(isolation) {
|
|
775
878
|
return this.run("transaction.begin", async (session) => {
|
|
776
879
|
const connection = this.requireConnection(session);
|
|
777
880
|
if (connection.read_only) {
|
|
@@ -780,7 +883,7 @@ export class StateQL {
|
|
|
780
883
|
if (session.active_transaction_id) {
|
|
781
884
|
throw new StateQLError("TRANSACTION_FAILED", `Transaction "${session.active_transaction_id}" is already active.`);
|
|
782
885
|
}
|
|
783
|
-
const normalizedIsolation = normalizeIsolation(isolation, connection.driver);
|
|
886
|
+
const normalizedIsolation = normalizeIsolation(isolation ?? (connection.driver === "mongodb" ? "snapshot" : "serializable"), connection.driver);
|
|
784
887
|
const transaction = this.store.createTransaction({
|
|
785
888
|
sessionId: session.id,
|
|
786
889
|
actorId: this.actorId,
|
|
@@ -829,20 +932,30 @@ export class StateQL {
|
|
|
829
932
|
// Validate durable payloads before opening a database adapter or changing
|
|
830
933
|
// the transaction state.
|
|
831
934
|
const operations = this.store.validatedTransactionOperations(transaction.id);
|
|
935
|
+
if (operations.some((operation) => operation.connection_id !== connection.id)) {
|
|
936
|
+
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.");
|
|
937
|
+
}
|
|
938
|
+
const mongoCommands = connection.driver === "mongodb"
|
|
939
|
+
? operations.map(storedMongoOperation)
|
|
940
|
+
: undefined;
|
|
941
|
+
if (connection.driver !== "mongodb" &&
|
|
942
|
+
operations.some((operation) => operation.statement_type.startsWith("mongo."))) {
|
|
943
|
+
throw new StateQLError("TRANSACTION_FAILED", "Transaction contains native MongoDB writes for a SQL connection.");
|
|
944
|
+
}
|
|
832
945
|
const context = this.executionContext(options);
|
|
833
946
|
const adapterSource = await this.resolveConnectionSource(connection, session, "transaction.commit", "write", context);
|
|
834
|
-
const adapter =
|
|
947
|
+
const adapter = connection.driver === "mongodb"
|
|
948
|
+
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
949
|
+
: await this.openAdapter(connection, context, adapterSource);
|
|
835
950
|
try {
|
|
836
951
|
if (!this.store.claimTransactionForCommit(transaction.id, session.id, this.actorId, operations)) {
|
|
837
952
|
throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
|
|
838
953
|
}
|
|
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
954
|
let results;
|
|
844
955
|
try {
|
|
845
|
-
results =
|
|
956
|
+
results = mongoCommands
|
|
957
|
+
? await adapter.writeBatch(mongoCommands, transaction.isolation_level)
|
|
958
|
+
: await adapter.writeBatch(operations, transaction.isolation_level);
|
|
846
959
|
}
|
|
847
960
|
catch (error) {
|
|
848
961
|
if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
|
|
@@ -876,6 +989,9 @@ export class StateQL {
|
|
|
876
989
|
operations: operations.map((operation, index) => ({
|
|
877
990
|
id: operation.id,
|
|
878
991
|
affectedRows: results[index].affectedRows,
|
|
992
|
+
...(results[index].outcome
|
|
993
|
+
? { outcome: results[index].outcome }
|
|
994
|
+
: {}),
|
|
879
995
|
})),
|
|
880
996
|
});
|
|
881
997
|
}
|
|
@@ -935,7 +1051,9 @@ export class StateQL {
|
|
|
935
1051
|
this.rejectDuringStagedTransaction(session, "Schema inspection");
|
|
936
1052
|
const context = this.executionContext(options);
|
|
937
1053
|
const adapterSource = await this.resolveConnectionSource(connection, session, "inspect", "read", context);
|
|
938
|
-
const adapter =
|
|
1054
|
+
const adapter = connection.driver === "mongodb"
|
|
1055
|
+
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
1056
|
+
: await this.openAdapter(connection, context, adapterSource);
|
|
939
1057
|
try {
|
|
940
1058
|
const data = await adapter.inspect(kind, table);
|
|
941
1059
|
return {
|
|
@@ -962,6 +1080,7 @@ export class StateQL {
|
|
|
962
1080
|
async plan(sql, options = {}) {
|
|
963
1081
|
return this.run("plan", async (session) => {
|
|
964
1082
|
const connection = this.requireConnection(session);
|
|
1083
|
+
this.rejectMongoSql(connection, "mongoPlan");
|
|
965
1084
|
this.rejectDuringStagedTransaction(session, "Plans");
|
|
966
1085
|
const analysis = analyzeSql(sql, connection.driver);
|
|
967
1086
|
if (analysis.read) {
|
|
@@ -1025,6 +1144,71 @@ export class StateQL {
|
|
|
1025
1144
|
}
|
|
1026
1145
|
}, sql);
|
|
1027
1146
|
}
|
|
1147
|
+
async mongoPlan(command, options = {}) {
|
|
1148
|
+
return this.run("mongo.plan", async (session) => {
|
|
1149
|
+
const value = validatedMongoWrite(command);
|
|
1150
|
+
const serializedCommand = serializeMongoCommand(value);
|
|
1151
|
+
const connection = this.requireMongoConnection(session, "mongoPlan");
|
|
1152
|
+
this.rejectDuringStagedTransaction(session, "Plans");
|
|
1153
|
+
const safety = analyzeMongoWriteSafety(value);
|
|
1154
|
+
const context = this.executionContext(options);
|
|
1155
|
+
const adapterSource = await this.resolveConnectionSource(connection, session, "plan", "read", context);
|
|
1156
|
+
const adapter = await this.openMongoAdapter(connection, context, adapterSource);
|
|
1157
|
+
try {
|
|
1158
|
+
const stateSignature = await adapter.signature();
|
|
1159
|
+
const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
|
|
1160
|
+
const plan = this.store.savePlan({
|
|
1161
|
+
sessionId: session.id,
|
|
1162
|
+
ownerActorId: this.actorId,
|
|
1163
|
+
connectionId: connection.id,
|
|
1164
|
+
sql: mongoDescriptor(value.operation),
|
|
1165
|
+
parameters: [serializedCommand],
|
|
1166
|
+
statementType: `mongo.${value.operation}`,
|
|
1167
|
+
stateVersion: version(connection),
|
|
1168
|
+
stateSignature,
|
|
1169
|
+
destructive: safety.destructive || safety.unbounded,
|
|
1170
|
+
allowUnbounded: options.allowUnbounded ?? false,
|
|
1171
|
+
allowDestructive: options.allowDestructive ?? false,
|
|
1172
|
+
expiresAt,
|
|
1173
|
+
});
|
|
1174
|
+
return {
|
|
1175
|
+
data: {
|
|
1176
|
+
plan_id: plan.id,
|
|
1177
|
+
statement_type: plan.statement_type,
|
|
1178
|
+
destructive: Boolean(plan.destructive),
|
|
1179
|
+
requires_confirmation: (safety.unbounded && !Boolean(plan.allow_unbounded)) ||
|
|
1180
|
+
(safety.destructive && !Boolean(plan.allow_destructive)),
|
|
1181
|
+
required_overrides: [
|
|
1182
|
+
...(safety.unbounded && !Boolean(plan.allow_unbounded)
|
|
1183
|
+
? ["--allow-unbounded"]
|
|
1184
|
+
: []),
|
|
1185
|
+
...(safety.destructive && !Boolean(plan.allow_destructive)
|
|
1186
|
+
? ["--allow-destructive"]
|
|
1187
|
+
: []),
|
|
1188
|
+
],
|
|
1189
|
+
state_version: plan.state_version,
|
|
1190
|
+
owner_actor_id: plan.owner_actor_id,
|
|
1191
|
+
expires_at: plan.expires_at,
|
|
1192
|
+
},
|
|
1193
|
+
handle: plan.id,
|
|
1194
|
+
executed: true,
|
|
1195
|
+
stateVersion: plan.state_version,
|
|
1196
|
+
confidence: adapter.confidence,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
if (error instanceof StateQLError)
|
|
1201
|
+
throw error;
|
|
1202
|
+
if (error instanceof AdapterExecutionError) {
|
|
1203
|
+
throw stoppedStateQLError(error, true);
|
|
1204
|
+
}
|
|
1205
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { retryable: true, executed: true });
|
|
1206
|
+
}
|
|
1207
|
+
finally {
|
|
1208
|
+
await closeAdapterQuietly(adapter);
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1028
1212
|
async apply(planId, options = {}) {
|
|
1029
1213
|
let historySql;
|
|
1030
1214
|
return this.run("apply", async (session) => {
|
|
@@ -1044,8 +1228,14 @@ export class StateQL {
|
|
|
1044
1228
|
if (Date.parse(plan.expires_at) <= this.now().getTime()) {
|
|
1045
1229
|
throw new StateQLError("STALE_PLAN", "Plan has expired.");
|
|
1046
1230
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1231
|
+
const nativePlan = plan.statement_type.startsWith("mongo.");
|
|
1232
|
+
historySql = nativePlan ? undefined : plan.sql;
|
|
1233
|
+
const mongoCommand = nativePlan
|
|
1234
|
+
? storedMongoPlan(plan.parameters, plan.statement_type, plan.id)
|
|
1235
|
+
: undefined;
|
|
1236
|
+
const planParameters = nativePlan
|
|
1237
|
+
? undefined
|
|
1238
|
+
: parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
|
|
1049
1239
|
const claimToken = this.store.nextId("claim");
|
|
1050
1240
|
const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
|
|
1051
1241
|
if (!claimed) {
|
|
@@ -1058,9 +1248,17 @@ export class StateQL {
|
|
|
1058
1248
|
version(connection) !== claimed.state_version) {
|
|
1059
1249
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
1060
1250
|
}
|
|
1251
|
+
if (nativePlan && connection.driver !== "mongodb") {
|
|
1252
|
+
throw new StateQLError("STALE_PLAN", "MongoDB plan is not attached to a MongoDB connection.");
|
|
1253
|
+
}
|
|
1254
|
+
if (!nativePlan && connection.driver === "mongodb") {
|
|
1255
|
+
this.rejectMongoSql(connection, "mongoPlan");
|
|
1256
|
+
}
|
|
1061
1257
|
const context = this.executionContext(options);
|
|
1062
1258
|
const adapterSource = await this.resolveConnectionSource(connection, session, "apply", "write", context);
|
|
1063
|
-
const adapter =
|
|
1259
|
+
const adapter = nativePlan
|
|
1260
|
+
? await this.openMongoAdapter(connection, context, adapterSource)
|
|
1261
|
+
: await this.openAdapter(connection, context, adapterSource);
|
|
1064
1262
|
try {
|
|
1065
1263
|
if ((await adapter.signature()) !== claimed.state_signature) {
|
|
1066
1264
|
throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
|
|
@@ -1077,11 +1275,16 @@ export class StateQL {
|
|
|
1077
1275
|
finally {
|
|
1078
1276
|
await closeAdapterQuietly(adapter);
|
|
1079
1277
|
}
|
|
1080
|
-
const result =
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1278
|
+
const result = mongoCommand
|
|
1279
|
+
? await this.performMongoExec(session, connection, mongoCommand, {
|
|
1280
|
+
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1281
|
+
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1282
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource)
|
|
1283
|
+
: await this.performExec(session, connection, claimed.sql, {
|
|
1284
|
+
params: planParameters,
|
|
1285
|
+
allowUnbounded: Boolean(claimed.allow_unbounded),
|
|
1286
|
+
allowDestructive: Boolean(claimed.allow_destructive),
|
|
1287
|
+
}, context, { planId: claimed.id, claimToken }, adapterSource);
|
|
1085
1288
|
return {
|
|
1086
1289
|
...result,
|
|
1087
1290
|
data: { plan_id: claimed.id, ...result.data },
|
|
@@ -1131,7 +1334,7 @@ export class StateQL {
|
|
|
1131
1334
|
async capabilities() {
|
|
1132
1335
|
return this.run("capabilities", async () => ({
|
|
1133
1336
|
data: {
|
|
1134
|
-
drivers: ["mysql", "postgres", "sqlite"],
|
|
1337
|
+
drivers: ["mongodb", "mysql", "postgres", "sqlite"],
|
|
1135
1338
|
features: {
|
|
1136
1339
|
result_handles: true,
|
|
1137
1340
|
write_deduplication: true,
|
|
@@ -1147,6 +1350,17 @@ export class StateQL {
|
|
|
1147
1350
|
state_purge: true,
|
|
1148
1351
|
state_quota: true,
|
|
1149
1352
|
},
|
|
1353
|
+
driver_features: {
|
|
1354
|
+
mongodb: {
|
|
1355
|
+
sql: false,
|
|
1356
|
+
native_read: true,
|
|
1357
|
+
native_write: true,
|
|
1358
|
+
plans: true,
|
|
1359
|
+
transactions: true,
|
|
1360
|
+
transactions_require_replica_set: true,
|
|
1361
|
+
inspection: true,
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1150
1364
|
},
|
|
1151
1365
|
}));
|
|
1152
1366
|
}
|
|
@@ -1206,6 +1420,22 @@ export class StateQL {
|
|
|
1206
1420
|
data: { ...response.data, alias: command.as },
|
|
1207
1421
|
};
|
|
1208
1422
|
}
|
|
1423
|
+
case "mongo.query": {
|
|
1424
|
+
const response = await this.mongoQuery(command.mongo, {
|
|
1425
|
+
cache: command.cache ?? "auto",
|
|
1426
|
+
timeoutMs: command.timeout_ms,
|
|
1427
|
+
});
|
|
1428
|
+
if (!response.ok || !command.as)
|
|
1429
|
+
return response;
|
|
1430
|
+
const resultId = response.data.result_id;
|
|
1431
|
+
if (typeof resultId !== "string")
|
|
1432
|
+
return response;
|
|
1433
|
+
this.store.setAlias(response.session_id, command.as, resultId);
|
|
1434
|
+
return {
|
|
1435
|
+
...response,
|
|
1436
|
+
data: { ...response.data, alias: command.as },
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1209
1439
|
case "filter": {
|
|
1210
1440
|
const response = await this.filter(batchString(command.handle, "handle"), batchString(command.where, "where"), { params: command.params ?? [] });
|
|
1211
1441
|
if (!response.ok || !command.as)
|
|
@@ -1228,6 +1458,14 @@ export class StateQL {
|
|
|
1228
1458
|
allowDestructive: command.allow_destructive ?? false,
|
|
1229
1459
|
timeoutMs: command.timeout_ms,
|
|
1230
1460
|
});
|
|
1461
|
+
case "mongo.exec":
|
|
1462
|
+
return this.mongoExec(command.mongo, {
|
|
1463
|
+
replay: command.replay ?? false,
|
|
1464
|
+
idempotencyKey: command.idempotency_key,
|
|
1465
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
1466
|
+
allowDestructive: command.allow_destructive ?? false,
|
|
1467
|
+
timeoutMs: command.timeout_ms,
|
|
1468
|
+
});
|
|
1231
1469
|
case "show":
|
|
1232
1470
|
return this.show(batchString(command.handle, "handle"));
|
|
1233
1471
|
case "rows":
|
|
@@ -1262,6 +1500,12 @@ export class StateQL {
|
|
|
1262
1500
|
allowDestructive: command.allow_destructive,
|
|
1263
1501
|
timeoutMs: command.timeout_ms,
|
|
1264
1502
|
});
|
|
1503
|
+
case "mongo.plan":
|
|
1504
|
+
return this.mongoPlan(command.mongo, {
|
|
1505
|
+
allowUnbounded: command.allow_unbounded ?? false,
|
|
1506
|
+
allowDestructive: command.allow_destructive,
|
|
1507
|
+
timeoutMs: command.timeout_ms,
|
|
1508
|
+
});
|
|
1265
1509
|
case "apply":
|
|
1266
1510
|
return this.apply(batchString(command.handle, "handle"), {
|
|
1267
1511
|
timeoutMs: command.timeout_ms,
|
|
@@ -1304,6 +1548,7 @@ export class StateQL {
|
|
|
1304
1548
|
}
|
|
1305
1549
|
}
|
|
1306
1550
|
async performExec(session, connection, sql, options, context, planClaim, resolvedSource) {
|
|
1551
|
+
this.rejectMongoSql(connection, "mongoExec");
|
|
1307
1552
|
if (connection.read_only) {
|
|
1308
1553
|
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1309
1554
|
}
|
|
@@ -1492,6 +1737,197 @@ export class StateQL {
|
|
|
1492
1737
|
}
|
|
1493
1738
|
}
|
|
1494
1739
|
}
|
|
1740
|
+
async performMongoExec(session, connection, command, options, context, planClaim, resolvedSource) {
|
|
1741
|
+
const value = validatedMongoWrite(command);
|
|
1742
|
+
if (connection.driver !== "mongodb") {
|
|
1743
|
+
throw new StateQLError("INVALID_COMMAND", "mongoExec requires an active MongoDB connection.");
|
|
1744
|
+
}
|
|
1745
|
+
if (connection.read_only) {
|
|
1746
|
+
throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
|
|
1747
|
+
}
|
|
1748
|
+
const safety = analyzeMongoWriteSafety(value);
|
|
1749
|
+
if (safety.unbounded && !options.allowUnbounded) {
|
|
1750
|
+
throw new StateQLError("UNBOUNDED_MUTATION", "MongoDB mutation has an empty filter.", { extra: { override_flag: "--allow-unbounded" } });
|
|
1751
|
+
}
|
|
1752
|
+
if (safety.destructive && !options.allowDestructive) {
|
|
1753
|
+
throw new StateQLError("DESTRUCTIVE_OPERATION_BLOCKED", "Destructive MongoDB operation requires an explicit override.", { extra: { override_flag: "--allow-destructive" } });
|
|
1754
|
+
}
|
|
1755
|
+
if (options.idempotencyKey !== undefined &&
|
|
1756
|
+
!options.idempotencyKey.trim()) {
|
|
1757
|
+
throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
|
|
1758
|
+
}
|
|
1759
|
+
const serializedCommand = serializeMongoCommand(value);
|
|
1760
|
+
const parameters = [serializedCommand];
|
|
1761
|
+
const fingerprint = hash({
|
|
1762
|
+
command: serializedCommand,
|
|
1763
|
+
database: databaseIdentity(connection),
|
|
1764
|
+
});
|
|
1765
|
+
const transactionId = session.active_transaction_id ?? undefined;
|
|
1766
|
+
if (transactionId) {
|
|
1767
|
+
const transaction = this.store.getTransaction(transactionId);
|
|
1768
|
+
if (!transaction ||
|
|
1769
|
+
transaction.session_id !== session.id ||
|
|
1770
|
+
transaction.state !== "active" ||
|
|
1771
|
+
transaction.connection_id !== connection.id) {
|
|
1772
|
+
throw new StateQLError("TRANSACTION_FAILED", "Active transaction does not match the active connection.");
|
|
1773
|
+
}
|
|
1774
|
+
if (transaction.owner_actor_id !== this.actorId) {
|
|
1775
|
+
throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
const reservation = this.store.reserveOperation({
|
|
1779
|
+
sessionId: session.id,
|
|
1780
|
+
actorId: this.actorId,
|
|
1781
|
+
connectionId: connection.id,
|
|
1782
|
+
fingerprint,
|
|
1783
|
+
sql: mongoDescriptor(value.operation),
|
|
1784
|
+
parameters,
|
|
1785
|
+
statementType: `mongo.${value.operation}`,
|
|
1786
|
+
status: transactionId ? "pending" : "executing",
|
|
1787
|
+
transactionId,
|
|
1788
|
+
replay: options.replay ?? false,
|
|
1789
|
+
idempotencyKey: options.idempotencyKey,
|
|
1790
|
+
stateVersionBefore: version(connection),
|
|
1791
|
+
});
|
|
1792
|
+
if (reservation.denied === "membership") {
|
|
1793
|
+
throw new StateQLError("PERMISSION_DENIED", "Actor membership changed before the write was reserved.");
|
|
1794
|
+
}
|
|
1795
|
+
if (reservation.denied === "transaction") {
|
|
1796
|
+
const active = this.store.getSession(session.id)?.active_transaction_id;
|
|
1797
|
+
const transaction = active ? this.store.getTransaction(active) : undefined;
|
|
1798
|
+
if (transaction && transaction.owner_actor_id !== this.actorId) {
|
|
1799
|
+
throw new StateQLError("PERMISSION_DENIED", "Only the transaction owner may stage writes.");
|
|
1800
|
+
}
|
|
1801
|
+
throw new StateQLError("TRANSACTION_FAILED", "The active transaction changed before the write was reserved.");
|
|
1802
|
+
}
|
|
1803
|
+
const previous = reservation.previous;
|
|
1804
|
+
if (previous &&
|
|
1805
|
+
options.idempotencyKey &&
|
|
1806
|
+
!options.replay &&
|
|
1807
|
+
previous.fingerprint !== fingerprint) {
|
|
1808
|
+
throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.", { extra: { previous_operation_id: previous.id } });
|
|
1809
|
+
}
|
|
1810
|
+
if (previous && !reservation.operation) {
|
|
1811
|
+
if (previous.status === "executing" ||
|
|
1812
|
+
previous.status === "outcome_unknown") {
|
|
1813
|
+
throw new StateQLError("OUTCOME_UNKNOWN", "A matching write has an unknown outcome.", {
|
|
1814
|
+
executed: true,
|
|
1815
|
+
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1816
|
+
extra: { previous_operation_id: previous.id },
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
if (options.idempotencyKey) {
|
|
1820
|
+
return {
|
|
1821
|
+
data: {
|
|
1822
|
+
...operationData(previous),
|
|
1823
|
+
duplicate: true,
|
|
1824
|
+
duplicate_of: previous.id,
|
|
1825
|
+
idempotency_key: options.idempotencyKey,
|
|
1826
|
+
},
|
|
1827
|
+
handle: previous.id,
|
|
1828
|
+
cached: true,
|
|
1829
|
+
stateVersion: previous.state_version_after ?? previous.state_version_before,
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
throw new StateQLError("POTENTIAL_DUPLICATE_WRITE", "An equivalent operation was previously applied.", {
|
|
1833
|
+
extra: {
|
|
1834
|
+
previous_operation_id: previous.id,
|
|
1835
|
+
replay_required: true,
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
const operation = reservation.operation;
|
|
1840
|
+
if (transactionId) {
|
|
1841
|
+
return {
|
|
1842
|
+
data: operationData(operation),
|
|
1843
|
+
handle: operation.id,
|
|
1844
|
+
executed: false,
|
|
1845
|
+
stateVersion: version(connection),
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
let adapter;
|
|
1849
|
+
let adapterSource;
|
|
1850
|
+
try {
|
|
1851
|
+
adapterSource =
|
|
1852
|
+
resolvedSource ??
|
|
1853
|
+
(await this.resolveConnectionSource(connection, session, "exec", "write", context));
|
|
1854
|
+
adapter = await this.openMongoAdapter(connection, context, adapterSource);
|
|
1855
|
+
}
|
|
1856
|
+
catch (error) {
|
|
1857
|
+
this.store.failOperation(operation.id);
|
|
1858
|
+
if (error instanceof StateQLError)
|
|
1859
|
+
throw error;
|
|
1860
|
+
throw new StateQLError("CONNECTION_FAILED", "Database connection failed.", {
|
|
1861
|
+
retryable: true,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
try {
|
|
1865
|
+
const write = await adapter.write(value);
|
|
1866
|
+
try {
|
|
1867
|
+
const finalized = planClaim
|
|
1868
|
+
? this.store.finishPlannedOperation({
|
|
1869
|
+
planId: planClaim.planId,
|
|
1870
|
+
claimToken: planClaim.claimToken,
|
|
1871
|
+
operationId: operation.id,
|
|
1872
|
+
connectionId: connection.id,
|
|
1873
|
+
affectedRows: write.affectedRows,
|
|
1874
|
+
outcome: write.outcome,
|
|
1875
|
+
})
|
|
1876
|
+
: (() => {
|
|
1877
|
+
const stateVersion = this.store.bumpVersion(connection.id);
|
|
1878
|
+
return {
|
|
1879
|
+
operation: this.store.finishOperation(operation.id, write.affectedRows, stateVersion, write.outcome),
|
|
1880
|
+
stateVersion,
|
|
1881
|
+
};
|
|
1882
|
+
})();
|
|
1883
|
+
const committed = finalized.operation;
|
|
1884
|
+
const after = finalized.stateVersion;
|
|
1885
|
+
return {
|
|
1886
|
+
data: {
|
|
1887
|
+
...operationData(committed),
|
|
1888
|
+
duplicate: Boolean(previous),
|
|
1889
|
+
duplicate_override: Boolean(previous),
|
|
1890
|
+
},
|
|
1891
|
+
handle: committed.id,
|
|
1892
|
+
executed: true,
|
|
1893
|
+
stateVersion: after,
|
|
1894
|
+
confidence: adapter.confidence,
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1899
|
+
throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
|
|
1900
|
+
executed: true,
|
|
1901
|
+
suggestedAction: "Inspect database state before issuing any replacement write.",
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
catch (error) {
|
|
1906
|
+
if (error instanceof StateQLError)
|
|
1907
|
+
throw error;
|
|
1908
|
+
if (error instanceof AdapterExecutionError && !error.outcomeUnknown) {
|
|
1909
|
+
this.store.failOperation(operation.id);
|
|
1910
|
+
throw stoppedStateQLError(error, false);
|
|
1911
|
+
}
|
|
1912
|
+
if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
|
|
1913
|
+
this.store.failOperation(operation.id);
|
|
1914
|
+
throw new StateQLError("QUERY_FAILED", safeCredentialErrorMessage(error, adapterSource), { executed: true });
|
|
1915
|
+
}
|
|
1916
|
+
this.store.markOperationOutcomeUnknown(operation.id);
|
|
1917
|
+
throw new StateQLError("OUTCOME_UNKNOWN", safeCredentialErrorMessage(error, adapterSource), {
|
|
1918
|
+
executed: true,
|
|
1919
|
+
suggestedAction: "Inspect database state, then use --replay only if another execution is safe.",
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
finally {
|
|
1923
|
+
try {
|
|
1924
|
+
await adapter.close();
|
|
1925
|
+
}
|
|
1926
|
+
catch {
|
|
1927
|
+
// Write outcome and metadata are already recorded.
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1495
1931
|
batchFailure(message) {
|
|
1496
1932
|
return this.run("batch", async () => {
|
|
1497
1933
|
throw new StateQLError("INVALID_COMMAND", message);
|
|
@@ -1528,6 +1964,18 @@ export class StateQL {
|
|
|
1528
1964
|
}
|
|
1529
1965
|
return connection;
|
|
1530
1966
|
}
|
|
1967
|
+
requireMongoConnection(session, method) {
|
|
1968
|
+
const connection = this.requireConnection(session);
|
|
1969
|
+
if (connection.driver !== "mongodb") {
|
|
1970
|
+
throw new StateQLError("INVALID_COMMAND", `${method} requires an active MongoDB connection.`);
|
|
1971
|
+
}
|
|
1972
|
+
return connection;
|
|
1973
|
+
}
|
|
1974
|
+
rejectMongoSql(connection, nativeMethod) {
|
|
1975
|
+
if (connection.driver !== "mongodb")
|
|
1976
|
+
return;
|
|
1977
|
+
throw new StateQLError("INVALID_COMMAND", `SQL is not supported for MongoDB connections; use ${nativeMethod} instead.`, { suggestedAction: `Use ${nativeMethod} with a native MongoDB command.` });
|
|
1978
|
+
}
|
|
1531
1979
|
requireActiveTransaction(session, id) {
|
|
1532
1980
|
const transactionId = id ?? session.active_transaction_id;
|
|
1533
1981
|
if (!transactionId) {
|
|
@@ -1615,6 +2063,16 @@ export class StateQL {
|
|
|
1615
2063
|
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
|
|
1616
2064
|
}
|
|
1617
2065
|
}
|
|
2066
|
+
async openMongoAdapter(connection, context, source) {
|
|
2067
|
+
try {
|
|
2068
|
+
return new MongoAdapter(connection, context, { source });
|
|
2069
|
+
}
|
|
2070
|
+
catch (error) {
|
|
2071
|
+
if (error instanceof StateQLError)
|
|
2072
|
+
throw error;
|
|
2073
|
+
throw new StateQLError("CONNECTION_FAILED", safeCredentialErrorMessage(error, source), { retryable: true });
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
1618
2076
|
executionContext(options) {
|
|
1619
2077
|
return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
|
|
1620
2078
|
}
|
|
@@ -1748,12 +2206,76 @@ function boundedReadSql(sql, limit) {
|
|
|
1748
2206
|
const statement = sql.trim().replace(/;\s*$/, "");
|
|
1749
2207
|
return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
|
|
1750
2208
|
}
|
|
2209
|
+
function validatedMongoRead(command) {
|
|
2210
|
+
try {
|
|
2211
|
+
return validateMongoReadCommand(command);
|
|
2212
|
+
}
|
|
2213
|
+
catch (error) {
|
|
2214
|
+
throw new StateQLError("INVALID_COMMAND", errorMessage(error));
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
function validatedMongoWrite(command) {
|
|
2218
|
+
try {
|
|
2219
|
+
return validateMongoWriteCommand(command);
|
|
2220
|
+
}
|
|
2221
|
+
catch (error) {
|
|
2222
|
+
throw new StateQLError("INVALID_COMMAND", errorMessage(error));
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
function mongoDescriptor(operation) {
|
|
2226
|
+
return `MongoDB native ${operation}`;
|
|
2227
|
+
}
|
|
2228
|
+
function mongoPaginationWarnings(command) {
|
|
2229
|
+
const ordered = command.operation === "find"
|
|
2230
|
+
? command.options?.sort !== undefined
|
|
2231
|
+
: command.pipeline.some((stage) => Object.prototype.hasOwnProperty.call(stage, "$sort"));
|
|
2232
|
+
return ordered
|
|
2233
|
+
? []
|
|
2234
|
+
: [{
|
|
2235
|
+
code: "NON_DETERMINISTIC_PAGINATION",
|
|
2236
|
+
message: "MongoDB result has no explicit sort.",
|
|
2237
|
+
}];
|
|
2238
|
+
}
|
|
2239
|
+
function storedMongoOperation(operation) {
|
|
2240
|
+
if (!operation.statement_type.startsWith("mongo.")) {
|
|
2241
|
+
throw new StateQLError("TRANSACTION_FAILED", "MongoDB transaction contains a mixed or corrupt native payload.");
|
|
2242
|
+
}
|
|
2243
|
+
return storedMongoWrite(operation.parameters, operation.statement_type, `operation "${operation.id}"`, "TRANSACTION_FAILED");
|
|
2244
|
+
}
|
|
2245
|
+
function storedMongoPlan(parameters, statementType, planId) {
|
|
2246
|
+
return storedMongoWrite(parameters, statementType, `plan "${planId}"`, "STALE_PLAN");
|
|
2247
|
+
}
|
|
2248
|
+
function storedMongoWrite(parameters, statementType, label, errorCode) {
|
|
2249
|
+
try {
|
|
2250
|
+
const payload = JSON.parse(parameters);
|
|
2251
|
+
if (!Array.isArray(payload) ||
|
|
2252
|
+
payload.length !== 1 ||
|
|
2253
|
+
typeof payload[0] !== "string") {
|
|
2254
|
+
throw new Error("payload must contain one EJSON command string");
|
|
2255
|
+
}
|
|
2256
|
+
const command = deserializeMongoWriteCommand(payload[0]);
|
|
2257
|
+
if (statementType !== `mongo.${command.operation}`) {
|
|
2258
|
+
throw new Error("operation does not match its statement type");
|
|
2259
|
+
}
|
|
2260
|
+
return command;
|
|
2261
|
+
}
|
|
2262
|
+
catch {
|
|
2263
|
+
throw new StateQLError(errorCode, `Stored MongoDB ${label} payload is invalid.`);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
1751
2266
|
function databaseDisplayName(driver) {
|
|
2267
|
+
if (driver === "mongodb")
|
|
2268
|
+
return "MongoDB";
|
|
1752
2269
|
return driver === "postgres" ? "PostgreSQL" : "MySQL";
|
|
1753
2270
|
}
|
|
1754
2271
|
function normalizeIsolation(isolation, driver) {
|
|
1755
2272
|
const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
|
|
1756
2273
|
.replace(/\s+/g, " ");
|
|
2274
|
+
if (driver === "mongodb") {
|
|
2275
|
+
if (normalized === "snapshot")
|
|
2276
|
+
return normalized;
|
|
2277
|
+
throw new StateQLError("INVALID_COMMAND", `MongoDB does not support isolation level "${normalized}".`);
|
|
2278
|
+
}
|
|
1757
2279
|
const supported = new Set([
|
|
1758
2280
|
"serializable",
|
|
1759
2281
|
"repeatable read",
|