@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.
@@ -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
- : new URL(secret).pathname.replace(/^\//, "") || driver;
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 = await this.openAdapter(draft, context, adapterSource);
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.read("SELECT 1", []);
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 = "serializable") {
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 = await this.openAdapter(connection, context, adapterSource);
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 = await adapter.writeBatch(operations, transaction.isolation_level);
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 = await this.openAdapter(connection, context, adapterSource);
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
- historySql = plan.sql;
1048
- const planParameters = parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
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 = await this.openAdapter(connection, context, adapterSource);
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 = await this.performExec(session, connection, claimed.sql, {
1081
- params: planParameters,
1082
- allowUnbounded: Boolean(claimed.allow_unbounded),
1083
- allowDestructive: Boolean(claimed.allow_destructive),
1084
- }, context, { planId: claimed.id, claimToken }, adapterSource);
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
- if (!command || typeof command !== "object") {
1155
- return this.batchFailure("Batch command must be an object.");
1156
- }
1371
+ async executeCommand(command, context = {}) {
1372
+ let activeContext;
1157
1373
  try {
1158
- switch (command.command) {
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 ?? this.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",