@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.
@@ -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
- : new URL(secret).pathname.replace(/^\//, "") || driver;
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 = await this.openAdapter(draft, context, adapterSource);
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.read("SELECT 1", []);
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 = "serializable") {
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 = await this.openAdapter(connection, context, adapterSource);
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 = await adapter.writeBatch(operations, transaction.isolation_level);
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 = await this.openAdapter(connection, context, adapterSource);
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
- historySql = plan.sql;
1048
- const planParameters = parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
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 = await this.openAdapter(connection, context, adapterSource);
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 = 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);
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",