@fadhilp/stateql 0.1.2 → 0.2.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
1
  import { writeFileSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
- import { DatabaseSync } from "node:sqlite";
4
3
  import { env } from "node:process";
5
- import { BatchWriteError, createAdapter, } from "./adapters.js";
4
+ import { AdapterExecutionError, AdapterWriteError, BatchWriteError, createAdapter, createAdapterContext, } from "./adapters.js";
5
+ import { confidence, databaseIdentity, databaseUrlHasSecret, detectDriver, isEnvironmentName, normalizeSqliteSource, validateProfileName, version, } from "./connection.js";
6
6
  import { asStateQLError, StateQLError } from "./errors.js";
7
+ import { filterMaterializedRows, prepareFilterStatement, validateFilterParameters, } from "./filter.js";
8
+ import { operationData, paginationWarnings, profileData, rowsToCsv, sessionData, transactionData, } from "./response-data.js";
7
9
  import { analyzeSql } from "./sql.js";
8
10
  import { StateStore, } from "./store.js";
9
11
  import { compactRows, defaultHome, hash, parseJson, redact, } from "./util.js";
@@ -15,6 +17,9 @@ export class StateQL {
15
17
  resultTtlSeconds;
16
18
  maxCellCharacters;
17
19
  maxResultRows;
20
+ maxResultBytes;
21
+ timeoutMs;
22
+ signal;
18
23
  now;
19
24
  constructor(options = {}) {
20
25
  this.now = options.now ?? (() => new Date());
@@ -24,6 +29,9 @@ export class StateQL {
24
29
  this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
25
30
  this.maxCellCharacters = options.maxCellCharacters ?? 200;
26
31
  this.maxResultRows = positiveInteger(options.maxResultRows ?? 10_000, "maxResultRows");
32
+ this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
33
+ this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
34
+ this.signal = options.signal;
27
35
  if (this.maxResultRows >= Number.MAX_SAFE_INTEGER) {
28
36
  throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
29
37
  }
@@ -59,10 +67,10 @@ export class StateQL {
59
67
  : "Connection target is required.");
60
68
  }
61
69
  const driver = detectDriver(secret);
62
- if (driver === "postgres" &&
70
+ if (driver !== "sqlite" &&
63
71
  !secretEnv &&
64
- postgresUrlHasSecret(secret)) {
65
- throw new StateQLError("PERMISSION_DENIED", "Credential-bearing PostgreSQL URLs must use --env.", {
72
+ databaseUrlHasSecret(secret)) {
73
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`, {
66
74
  suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
67
75
  });
68
76
  }
@@ -73,7 +81,7 @@ export class StateQL {
73
81
  : secret;
74
82
  const databaseName = driver === "sqlite"
75
83
  ? basename(source)
76
- : new URL(secret).pathname.replace(/^\//, "") || "postgres";
84
+ : new URL(secret).pathname.replace(/^\//, "") || driver;
77
85
  const readOnly = options.readOnly ??
78
86
  (profile ? Boolean(profile.read_only) : true);
79
87
  const draft = {
@@ -88,11 +96,14 @@ export class StateQL {
88
96
  version: 0,
89
97
  created_at: this.now().toISOString(),
90
98
  };
91
- const adapter = await createAdapter(draft);
99
+ const adapter = await createAdapter(draft, this.executionContext(options));
92
100
  try {
93
101
  await adapter.read("SELECT 1", []);
94
102
  }
95
103
  catch (error) {
104
+ if (error instanceof AdapterExecutionError) {
105
+ throw stoppedStateQLError(error, false);
106
+ }
96
107
  throw new StateQLError("CONNECTION_FAILED", errorMessage(error), { retryable: true });
97
108
  }
98
109
  finally {
@@ -140,8 +151,8 @@ export class StateQL {
140
151
  let storedTarget = target;
141
152
  if (target) {
142
153
  const driver = detectDriver(target);
143
- if (driver === "postgres" && postgresUrlHasSecret(target)) {
144
- throw new StateQLError("PERMISSION_DENIED", "Credential-bearing PostgreSQL URLs must use --env.");
154
+ if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
155
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`);
145
156
  }
146
157
  if (driver === "sqlite")
147
158
  storedTarget = normalizeSqliteSource(target);
@@ -231,10 +242,10 @@ export class StateQL {
231
242
  if (!name.trim()) {
232
243
  throw new StateQLError("INVALID_COMMAND", "Session name is required.");
233
244
  }
234
- if (this.store.getSession(name)) {
245
+ if (this.store.getSessionByName(name)) {
235
246
  throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
236
247
  }
237
- const session = this.store.createSession(name);
248
+ const session = this.store.ensureSession(name);
238
249
  return {
239
250
  data: sessionData(session),
240
251
  handle: session.id,
@@ -310,12 +321,13 @@ export class StateQL {
310
321
  async query(sql, options = {}) {
311
322
  return this.run("query", async (session) => {
312
323
  const connection = this.requireConnection(session);
324
+ this.rejectDuringStagedTransaction(session, "Queries");
313
325
  const analysis = analyzeSql(sql, connection.driver);
314
326
  if (!analysis.read) {
315
327
  throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
316
328
  }
317
329
  const parameters = options.params ?? [];
318
- const adapter = await createAdapter(connection);
330
+ const adapter = await createAdapter(connection, this.executionContext(options));
319
331
  try {
320
332
  const stateVersion = version(connection);
321
333
  const stateSignature = await adapter.signature();
@@ -353,6 +365,12 @@ export class StateQL {
353
365
  if (result.rows.length > this.maxResultRows) {
354
366
  throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
355
367
  }
368
+ const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") +
369
+ Buffer.byteLength(JSON.stringify(result.rows), "utf8") +
370
+ Buffer.byteLength(JSON.stringify(result.columns), "utf8");
371
+ if (resultBytes > this.maxResultBytes) {
372
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultBytes}-byte materialization limit.`, { suggestedAction: "Select fewer rows or smaller columns." });
373
+ }
356
374
  const expiresAt = new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString();
357
375
  const saved = this.store.saveResult({
358
376
  sessionId: session.id,
@@ -379,6 +397,9 @@ export class StateQL {
379
397
  catch (error) {
380
398
  if (error instanceof StateQLError)
381
399
  throw error;
400
+ if (error instanceof AdapterExecutionError) {
401
+ throw stoppedStateQLError(error, true);
402
+ }
382
403
  throw new StateQLError("QUERY_FAILED", errorMessage(error), {
383
404
  retryable: true,
384
405
  executed: true,
@@ -529,7 +550,7 @@ export class StateQL {
529
550
  async exec(sql, options = {}) {
530
551
  return this.run("exec", async (session) => {
531
552
  const connection = this.requireConnection(session);
532
- return this.performExec(session, connection, sql, options);
553
+ return this.performExec(session, connection, sql, options, this.executionContext(options));
533
554
  });
534
555
  }
535
556
  async receipt(id) {
@@ -587,7 +608,7 @@ export class StateQL {
587
608
  };
588
609
  });
589
610
  }
590
- async commitTransaction(id) {
611
+ async commitTransaction(id, options = {}) {
591
612
  return this.run("transaction.commit", async (session) => {
592
613
  const transaction = this.requireActiveTransaction(session, id);
593
614
  const connection = this.store.getConnection(transaction.connection_id);
@@ -601,7 +622,7 @@ export class StateQL {
601
622
  if (operations.some((operation) => operation.connection_id !== connection.id)) {
602
623
  throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.", { suggestedAction: "Roll back the transaction." });
603
624
  }
604
- const adapter = await createAdapter(connection);
625
+ const adapter = await createAdapter(connection, this.executionContext(options));
605
626
  try {
606
627
  if (!this.store.markTransactionCommitting(transaction.id)) {
607
628
  throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
@@ -611,8 +632,12 @@ export class StateQL {
611
632
  results = await adapter.writeBatch(operations, transaction.isolation_level);
612
633
  }
613
634
  catch (error) {
614
- if (error instanceof BatchWriteError && !error.outcomeUnknown) {
635
+ if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
636
+ (error instanceof AdapterExecutionError && !error.outcomeUnknown)) {
615
637
  this.store.finishTransaction(transaction.id, session.id, "failed");
638
+ if (error instanceof AdapterExecutionError) {
639
+ throw stoppedStateQLError(error, false);
640
+ }
616
641
  throw new StateQLError("TRANSACTION_FAILED", error.message, {
617
642
  retryable: true,
618
643
  });
@@ -690,10 +715,11 @@ export class StateQL {
690
715
  };
691
716
  });
692
717
  }
693
- async inspect(kind, table) {
718
+ async inspect(kind, table, options = {}) {
694
719
  return this.run(`inspect.${kind}`, async (session) => {
695
720
  const connection = this.requireConnection(session);
696
- const adapter = await createAdapter(connection);
721
+ this.rejectDuringStagedTransaction(session, "Schema inspection");
722
+ const adapter = await createAdapter(connection, this.executionContext(options));
697
723
  try {
698
724
  const data = await adapter.inspect(kind, table);
699
725
  return {
@@ -704,6 +730,9 @@ export class StateQL {
704
730
  };
705
731
  }
706
732
  catch (error) {
733
+ if (error instanceof AdapterExecutionError) {
734
+ throw stoppedStateQLError(error, true);
735
+ }
707
736
  throw new StateQLError("QUERY_FAILED", errorMessage(error), {
708
737
  retryable: false,
709
738
  executed: true,
@@ -717,11 +746,12 @@ export class StateQL {
717
746
  async plan(sql, options = {}) {
718
747
  return this.run("plan", async (session) => {
719
748
  const connection = this.requireConnection(session);
749
+ this.rejectDuringStagedTransaction(session, "Plans");
720
750
  const analysis = analyzeSql(sql, connection.driver);
721
751
  if (analysis.read) {
722
752
  throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
723
753
  }
724
- const adapter = await createAdapter(connection);
754
+ const adapter = await createAdapter(connection, this.executionContext(options));
725
755
  try {
726
756
  const stateSignature = await adapter.signature();
727
757
  const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
@@ -762,13 +792,20 @@ export class StateQL {
762
792
  confidence: adapter.confidence,
763
793
  };
764
794
  }
795
+ catch (error) {
796
+ if (error instanceof AdapterExecutionError) {
797
+ throw stoppedStateQLError(error, true);
798
+ }
799
+ throw error;
800
+ }
765
801
  finally {
766
802
  await adapter.close();
767
803
  }
768
804
  });
769
805
  }
770
- async apply(planId) {
806
+ async apply(planId, options = {}) {
771
807
  return this.run("apply", async (session) => {
808
+ this.rejectDuringStagedTransaction(session, "Plans");
772
809
  const plan = this.store.getPlan(planId);
773
810
  if (!plan || plan.session_id !== session.id) {
774
811
  throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
@@ -786,12 +823,19 @@ export class StateQL {
786
823
  version(connection) !== plan.state_version) {
787
824
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
788
825
  }
789
- const adapter = await createAdapter(connection);
826
+ const context = this.executionContext(options);
827
+ const adapter = await createAdapter(connection, context);
790
828
  try {
791
829
  if ((await adapter.signature()) !== plan.state_signature) {
792
830
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
793
831
  }
794
832
  }
833
+ catch (error) {
834
+ if (error instanceof AdapterExecutionError) {
835
+ throw stoppedStateQLError(error, true);
836
+ }
837
+ throw error;
838
+ }
795
839
  finally {
796
840
  await adapter.close();
797
841
  }
@@ -799,7 +843,7 @@ export class StateQL {
799
843
  params: parseJson(plan.parameters, []),
800
844
  allowUnbounded: Boolean(plan.allow_unbounded),
801
845
  allowDestructive: Boolean(plan.allow_destructive),
802
- });
846
+ }, context);
803
847
  const operationId = String(result.data.operation_id);
804
848
  this.store.markPlanApplied(plan.id, operationId);
805
849
  return {
@@ -830,7 +874,7 @@ export class StateQL {
830
874
  async capabilities() {
831
875
  return this.run("capabilities", async () => ({
832
876
  data: {
833
- drivers: ["postgres", "sqlite"],
877
+ drivers: ["mysql", "postgres", "sqlite"],
834
878
  features: {
835
879
  result_handles: true,
836
880
  write_deduplication: true,
@@ -839,6 +883,8 @@ export class StateQL {
839
883
  persistent_sessions: true,
840
884
  result_filtering: true,
841
885
  schema_inspection: true,
886
+ deadlines: true,
887
+ cancellation: true,
842
888
  },
843
889
  },
844
890
  }));
@@ -855,6 +901,7 @@ export class StateQL {
855
901
  readOnly: command.read_only,
856
902
  secretEnv: command.secret_env,
857
903
  profile: command.profile,
904
+ timeoutMs: command.timeout_ms,
858
905
  });
859
906
  case "disconnect":
860
907
  return this.disconnect();
@@ -885,6 +932,7 @@ export class StateQL {
885
932
  const response = await this.query(batchString(command.sql, "sql"), {
886
933
  params: command.params ?? [],
887
934
  cache: command.cache ?? "auto",
935
+ timeoutMs: command.timeout_ms,
888
936
  });
889
937
  if (!response.ok || !command.as)
890
938
  return response;
@@ -917,6 +965,7 @@ export class StateQL {
917
965
  idempotencyKey: command.idempotency_key,
918
966
  allowUnbounded: command.allow_unbounded ?? false,
919
967
  allowDestructive: command.allow_destructive ?? false,
968
+ timeoutMs: command.timeout_ms,
920
969
  });
921
970
  case "show":
922
971
  return this.show(batchString(command.handle, "handle"));
@@ -932,13 +981,17 @@ export class StateQL {
932
981
  case "alias.set":
933
982
  return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
934
983
  case "inspect":
935
- return this.inspect(batchString(command.kind, "kind"), command.table);
984
+ return this.inspect(batchString(command.kind, "kind"), command.table, {
985
+ timeoutMs: command.timeout_ms,
986
+ });
936
987
  case "transaction.begin":
937
988
  return this.beginTransaction(command.isolation);
938
989
  case "transaction.status":
939
990
  return this.transactionStatus(command.handle);
940
991
  case "transaction.commit":
941
- return this.commitTransaction(command.handle);
992
+ return this.commitTransaction(command.handle, {
993
+ timeoutMs: command.timeout_ms,
994
+ });
942
995
  case "transaction.rollback":
943
996
  return this.rollbackTransaction(command.handle);
944
997
  case "plan":
@@ -946,9 +999,12 @@ export class StateQL {
946
999
  params: command.params ?? [],
947
1000
  allowUnbounded: command.allow_unbounded ?? false,
948
1001
  allowDestructive: command.allow_destructive,
1002
+ timeoutMs: command.timeout_ms,
949
1003
  });
950
1004
  case "apply":
951
- return this.apply(batchString(command.handle, "handle"));
1005
+ return this.apply(batchString(command.handle, "handle"), {
1006
+ timeoutMs: command.timeout_ms,
1007
+ });
952
1008
  case "history":
953
1009
  return this.history(command.limit ?? 20);
954
1010
  case "receipt":
@@ -982,7 +1038,7 @@ export class StateQL {
982
1038
  return;
983
1039
  }
984
1040
  }
985
- async performExec(session, connection, sql, options) {
1041
+ async performExec(session, connection, sql, options, context) {
986
1042
  if (connection.read_only) {
987
1043
  throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
988
1044
  }
@@ -996,6 +1052,10 @@ export class StateQL {
996
1052
  if (analysis.destructive && !options.allowDestructive) {
997
1053
  throw new StateQLError("DESTRUCTIVE_OPERATION_BLOCKED", "Destructive operation requires an explicit override.", { extra: { override_flag: "--allow-destructive" } });
998
1054
  }
1055
+ if (options.idempotencyKey !== undefined &&
1056
+ !options.idempotencyKey.trim()) {
1057
+ throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
1058
+ }
999
1059
  const parameters = options.params ?? [];
1000
1060
  const fingerprint = hash({
1001
1061
  sql: analysis.normalized,
@@ -1026,6 +1086,12 @@ export class StateQL {
1026
1086
  stateVersionBefore: version(connection),
1027
1087
  });
1028
1088
  const previous = reservation.previous;
1089
+ if (previous &&
1090
+ options.idempotencyKey &&
1091
+ !options.replay &&
1092
+ previous.fingerprint !== fingerprint) {
1093
+ throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.", { extra: { previous_operation_id: previous.id } });
1094
+ }
1029
1095
  if (previous && !reservation.operation) {
1030
1096
  if (previous.status === "executing" ||
1031
1097
  previous.status === "outcome_unknown") {
@@ -1066,7 +1132,7 @@ export class StateQL {
1066
1132
  }
1067
1133
  let adapter;
1068
1134
  try {
1069
- adapter = await createAdapter(connection);
1135
+ adapter = await createAdapter(connection, context);
1070
1136
  }
1071
1137
  catch (error) {
1072
1138
  this.store.failOperation(operation.id);
@@ -1102,6 +1168,16 @@ export class StateQL {
1102
1168
  catch (error) {
1103
1169
  if (error instanceof StateQLError)
1104
1170
  throw error;
1171
+ if (error instanceof AdapterExecutionError && !error.outcomeUnknown) {
1172
+ this.store.failOperation(operation.id);
1173
+ throw stoppedStateQLError(error, false);
1174
+ }
1175
+ if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1176
+ this.store.failOperation(operation.id);
1177
+ throw new StateQLError("QUERY_FAILED", error.message, {
1178
+ executed: true,
1179
+ });
1180
+ }
1105
1181
  this.store.markOperationOutcomeUnknown(operation.id);
1106
1182
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
1107
1183
  executed: true,
@@ -1134,6 +1210,11 @@ export class StateQL {
1134
1210
  return action(result, session);
1135
1211
  });
1136
1212
  }
1213
+ rejectDuringStagedTransaction(session, operation) {
1214
+ if (!session.active_transaction_id)
1215
+ return;
1216
+ throw new StateQLError("TRANSACTION_FAILED", `${operation} cannot run while a staged transaction is active.`, { suggestedAction: "Commit or roll back the transaction first." });
1217
+ }
1137
1218
  requireResult(idOrAlias, session) {
1138
1219
  const result = this.store.getResult(idOrAlias, session.id);
1139
1220
  if (!result) {
@@ -1162,6 +1243,9 @@ export class StateQL {
1162
1243
  }
1163
1244
  return transaction;
1164
1245
  }
1246
+ executionContext(options) {
1247
+ return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
1248
+ }
1165
1249
  resultData(result, cached) {
1166
1250
  const rows = this.store.resultRows(result);
1167
1251
  const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
@@ -1244,256 +1328,6 @@ export class StateQL {
1244
1328
  }
1245
1329
  }
1246
1330
  }
1247
- function prepareFilterStatement(columns, predicate) {
1248
- const text = predicate.trim();
1249
- if (!text) {
1250
- throw new StateQLError("INVALID_SQL", "Filter predicate is required.");
1251
- }
1252
- const columnNames = columns.map((column) => column.name);
1253
- if (columnNames.length === 0) {
1254
- throw new StateQLError("INVALID_SQL", "Filter requires at least one result column.");
1255
- }
1256
- const names = new Set();
1257
- for (const name of columnNames) {
1258
- const normalized = name.toLowerCase();
1259
- if (!name || name.includes("\0") || names.has(normalized)) {
1260
- throw new StateQLError("INVALID_SQL", "Filter requires unique, non-empty result column names.");
1261
- }
1262
- names.add(normalized);
1263
- }
1264
- const tableName = "__stateql_filter_source";
1265
- let indexColumn = "__stateql_row_index";
1266
- while (names.has(indexColumn.toLowerCase()))
1267
- indexColumn += "_";
1268
- const sql = `SELECT ${quoteIdentifier(indexColumn)} ` +
1269
- `FROM ${quoteIdentifier(tableName)} WHERE (${text})`;
1270
- const analysis = analyzeSql(sql, "sqlite");
1271
- const details = analysis.ast;
1272
- const from = details.from;
1273
- const source = Array.isArray(from)
1274
- ? from[0]
1275
- : undefined;
1276
- if (!analysis.read ||
1277
- !Array.isArray(from) ||
1278
- from.length !== 1 ||
1279
- source?.table !== tableName ||
1280
- details.with ||
1281
- details.groupby ||
1282
- details.having ||
1283
- details.orderby ||
1284
- details.limit ||
1285
- details.for_update ||
1286
- details._next ||
1287
- details.set_op ||
1288
- containsSelect(details.where)) {
1289
- throw new StateQLError("INVALID_SQL", "Filter accepts one scalar predicate only.");
1290
- }
1291
- validateFilterExpression(details.where, names, tableName);
1292
- const bindings = filterBindings(details.where);
1293
- return {
1294
- sql,
1295
- normalized: analysis.normalized,
1296
- tableName,
1297
- indexColumn,
1298
- columnNames,
1299
- positionalParameters: bindings.positional,
1300
- namedParameters: [...bindings.named],
1301
- };
1302
- }
1303
- function filterMaterializedRows(rows, filter, parameters) {
1304
- const db = new DatabaseSync(":memory:");
1305
- try {
1306
- const definitions = [
1307
- `${quoteIdentifier(filter.indexColumn)} INTEGER PRIMARY KEY`,
1308
- ...filter.columnNames.map(quoteIdentifier),
1309
- ];
1310
- db.exec(`CREATE TABLE ${quoteIdentifier(filter.tableName)} ` +
1311
- `(${definitions.join(", ")})`);
1312
- const placeholders = filter.columnNames.map(() => "?").join(", ");
1313
- const insert = db.prepare(`INSERT INTO ${quoteIdentifier(filter.tableName)} (` +
1314
- `${quoteIdentifier(filter.indexColumn)}, ` +
1315
- `${filter.columnNames.map(quoteIdentifier).join(", ")}) ` +
1316
- `VALUES (?, ${placeholders})`);
1317
- db.exec("BEGIN");
1318
- try {
1319
- rows.forEach((row, index) => {
1320
- const values = filter.columnNames.map((name) => sqliteFilterValue(row[name]));
1321
- insert.run(index, ...values);
1322
- });
1323
- db.exec("COMMIT");
1324
- }
1325
- catch (error) {
1326
- db.exec("ROLLBACK");
1327
- throw error;
1328
- }
1329
- const statement = db.prepare(`${filter.sql}\nORDER BY ${quoteIdentifier(filter.indexColumn)}`);
1330
- const selected = filterAll(statement, parameters);
1331
- return selected.map((row) => {
1332
- const index = Number(row[filter.indexColumn]);
1333
- if (!Number.isInteger(index) || !rows[index]) {
1334
- throw new StateQLError("INVALID_SQL", "Filter produced an invalid source row index.");
1335
- }
1336
- return rows[index];
1337
- });
1338
- }
1339
- catch (error) {
1340
- if (error instanceof StateQLError)
1341
- throw error;
1342
- throw new StateQLError("INVALID_SQL", errorMessage(error));
1343
- }
1344
- finally {
1345
- db.close();
1346
- }
1347
- }
1348
- function filterAll(statement, parameters) {
1349
- if (Array.isArray(parameters)) {
1350
- return statement.all(...parameters);
1351
- }
1352
- return statement.all(parameters);
1353
- }
1354
- function sqliteFilterValue(value) {
1355
- if (value === null || value === undefined)
1356
- return null;
1357
- if (typeof value === "string" ||
1358
- typeof value === "number" ||
1359
- typeof value === "bigint") {
1360
- return value;
1361
- }
1362
- if (typeof value === "boolean")
1363
- return value ? 1 : 0;
1364
- if (value instanceof Uint8Array)
1365
- return value;
1366
- try {
1367
- return JSON.stringify(value) ?? null;
1368
- }
1369
- catch {
1370
- return String(value);
1371
- }
1372
- }
1373
- function quoteIdentifier(value) {
1374
- return `"${value.replaceAll('"', '""')}"`;
1375
- }
1376
- function containsSelect(value) {
1377
- if (!value || typeof value !== "object")
1378
- return false;
1379
- const record = value;
1380
- if (record.type === "select")
1381
- return true;
1382
- return Object.values(record).some(containsSelect);
1383
- }
1384
- const FILTER_FUNCTIONS = new Set([
1385
- "abs",
1386
- "coalesce",
1387
- "ifnull",
1388
- "instr",
1389
- "json_extract",
1390
- "json_type",
1391
- "json_valid",
1392
- "length",
1393
- "lower",
1394
- "ltrim",
1395
- "nullif",
1396
- "round",
1397
- "rtrim",
1398
- "substr",
1399
- "substring",
1400
- "trim",
1401
- "typeof",
1402
- "upper",
1403
- ]);
1404
- function validateFilterExpression(value, columns, tableName) {
1405
- if (!value || typeof value !== "object")
1406
- return;
1407
- const record = value;
1408
- if (record.type === "column_ref") {
1409
- const column = record.column;
1410
- const table = record.table;
1411
- if (typeof column !== "string" ||
1412
- !columns.has(column.toLowerCase()) ||
1413
- (table !== null && table !== undefined && table !== tableName)) {
1414
- throw new StateQLError("INVALID_SQL", `Unknown filter column "${String(column)}".`);
1415
- }
1416
- }
1417
- else if (record.type === "double_quote_string") {
1418
- const column = String(record.value);
1419
- if (!columns.has(column.toLowerCase())) {
1420
- throw new StateQLError("INVALID_SQL", `Unknown filter column "${column}".`);
1421
- }
1422
- }
1423
- else if (record.type === "function") {
1424
- const name = filterFunctionName(record);
1425
- if (!name || !FILTER_FUNCTIONS.has(name)) {
1426
- throw new StateQLError("INVALID_SQL", `Filter function "${name ?? "unknown"}" is not allowed.`);
1427
- }
1428
- }
1429
- Object.values(record).forEach((item) => validateFilterExpression(item, columns, tableName));
1430
- }
1431
- function filterFunctionName(record) {
1432
- const name = record.name;
1433
- const parts = name?.name;
1434
- if (!Array.isArray(parts))
1435
- return undefined;
1436
- const last = parts.at(-1);
1437
- return typeof last?.value === "string" ? last.value.toLowerCase() : undefined;
1438
- }
1439
- function filterBindings(value) {
1440
- const named = new Set();
1441
- const prefixes = new Map();
1442
- let positional = 0;
1443
- const visit = (item) => {
1444
- if (!item || typeof item !== "object")
1445
- return;
1446
- const record = item;
1447
- if (record.type === "origin" && record.value === "?")
1448
- positional += 1;
1449
- if (record.type === "param" && typeof record.value === "string") {
1450
- addNamed(String(record.value), `:${String(record.value)}`);
1451
- }
1452
- if (record.type === "var" &&
1453
- typeof record.name === "string" &&
1454
- (record.prefix === "$" || record.prefix === "@")) {
1455
- addNamed(record.name, `${String(record.prefix)}${record.name}`);
1456
- }
1457
- Object.values(record).forEach(visit);
1458
- };
1459
- const addNamed = (name, token) => {
1460
- const previous = prefixes.get(name);
1461
- if (previous && previous !== token) {
1462
- throw new StateQLError("INVALID_SQL", `Filter parameter "${name}" uses conflicting prefixes.`);
1463
- }
1464
- prefixes.set(name, token);
1465
- named.add(name);
1466
- };
1467
- visit(value);
1468
- if (positional && named.size) {
1469
- throw new StateQLError("INVALID_SQL", "Filter cannot mix positional and named parameters.");
1470
- }
1471
- return { positional, named };
1472
- }
1473
- function validateFilterParameters(filter, parameters) {
1474
- if (filter.positionalParameters) {
1475
- if (!Array.isArray(parameters) ||
1476
- parameters.length !== filter.positionalParameters) {
1477
- throw new StateQLError("INVALID_SQL", `Filter requires exactly ${filter.positionalParameters} positional parameters.`);
1478
- }
1479
- return;
1480
- }
1481
- if (filter.namedParameters.length) {
1482
- if (Array.isArray(parameters)) {
1483
- throw new StateQLError("INVALID_SQL", "Filter requires named parameters.");
1484
- }
1485
- const supplied = Object.keys(parameters).sort();
1486
- const expected = [...filter.namedParameters].sort();
1487
- if (JSON.stringify(supplied) !== JSON.stringify(expected)) {
1488
- throw new StateQLError("INVALID_SQL", `Filter requires named parameters: ${expected.join(", ")}.`);
1489
- }
1490
- return;
1491
- }
1492
- if ((Array.isArray(parameters) && parameters.length) ||
1493
- (!Array.isArray(parameters) && Object.keys(parameters).length)) {
1494
- throw new StateQLError("INVALID_SQL", "Filter predicate has no parameters.");
1495
- }
1496
- }
1497
1331
  function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
1498
1332
  try {
1499
1333
  store.markTransactionOutcomeUnknown(transactionId, sessionId);
@@ -1506,6 +1340,9 @@ function boundedReadSql(sql, limit) {
1506
1340
  const statement = sql.trim().replace(/;\s*$/, "");
1507
1341
  return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
1508
1342
  }
1343
+ function databaseDisplayName(driver) {
1344
+ return driver === "postgres" ? "PostgreSQL" : "MySQL";
1345
+ }
1509
1346
  function normalizeIsolation(isolation, driver) {
1510
1347
  const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
1511
1348
  .replace(/\s+/g, " ");
@@ -1523,104 +1360,6 @@ function normalizeIsolation(isolation, driver) {
1523
1360
  }
1524
1361
  return normalized;
1525
1362
  }
1526
- function databaseIdentity(connection) {
1527
- return {
1528
- driver: connection.driver,
1529
- database: connection.database_name,
1530
- source: connection.source,
1531
- secretEnvironment: connection.secret_env,
1532
- };
1533
- }
1534
- function detectDriver(target) {
1535
- if (/^postgres(?:ql)?:\/\//i.test(target))
1536
- return "postgres";
1537
- if (/^[a-z][a-z\d+.-]*:\/\//i.test(target)) {
1538
- throw new StateQLError("UNSUPPORTED_DRIVER", "Only PostgreSQL and SQLite are supported.");
1539
- }
1540
- return "sqlite";
1541
- }
1542
- function normalizeSqliteSource(target) {
1543
- const source = target.startsWith("sqlite:") ? target.slice(7) : target;
1544
- if (source === ":memory:")
1545
- return source;
1546
- return resolve(source);
1547
- }
1548
- function postgresUrlHasSecret(target) {
1549
- try {
1550
- const url = new URL(target);
1551
- return (Boolean(url.password) ||
1552
- [...url.searchParams.keys()].some((key) => /pass|token|secret|private[_-]?key|api[_-]?key/i.test(key)));
1553
- }
1554
- catch {
1555
- throw new StateQLError("INVALID_COMMAND", "Invalid PostgreSQL URL.");
1556
- }
1557
- }
1558
- function version(connection) {
1559
- return `sv_${connection.version}`;
1560
- }
1561
- function confidence(connection) {
1562
- return connection.driver === "sqlite" ? "database_reported" : "ttl_based";
1563
- }
1564
- function sessionData(session) {
1565
- return {
1566
- session_id: session.id,
1567
- name: session.name,
1568
- state: session.status,
1569
- active_connection: session.active_connection_id,
1570
- active_transaction: session.active_transaction_id,
1571
- };
1572
- }
1573
- function profileData(profile) {
1574
- return {
1575
- profile: profile.name,
1576
- target: profile.target,
1577
- secret_env: profile.secret_env,
1578
- read_only: Boolean(profile.read_only),
1579
- };
1580
- }
1581
- function validateProfileName(name) {
1582
- if (/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name))
1583
- return;
1584
- throw new StateQLError("INVALID_COMMAND", "Profile name must be 1-64 letters, numbers, dots, underscores, or hyphens.");
1585
- }
1586
- function isEnvironmentName(name) {
1587
- return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
1588
- }
1589
- function operationData(operation) {
1590
- return {
1591
- operation_id: operation.id,
1592
- statement_type: operation.statement_type,
1593
- affected_rows: operation.affected_rows,
1594
- status: operation.status,
1595
- committed: operation.status === "committed",
1596
- transaction_id: operation.transaction_id,
1597
- state_version_before: operation.state_version_before,
1598
- state_version_after: operation.state_version_after,
1599
- ...(operation.replay_of ? { replay_of: operation.replay_of } : {}),
1600
- };
1601
- }
1602
- function transactionData(transaction, statements) {
1603
- return {
1604
- transaction_id: transaction.id,
1605
- state: transaction.state,
1606
- connection_id: transaction.connection_id,
1607
- statements,
1608
- pending_writes: transaction.state === "active" ? statements : 0,
1609
- start_state_version: transaction.start_version,
1610
- isolation_level: transaction.isolation_level,
1611
- age_ms: Date.now() - Date.parse(transaction.created_at),
1612
- };
1613
- }
1614
- function paginationWarnings(ordered) {
1615
- if (ordered)
1616
- return [];
1617
- return [
1618
- {
1619
- code: "NON_DETERMINISTIC_PAGINATION",
1620
- message: "Result has no explicit ORDER BY clause.",
1621
- },
1622
- ];
1623
- }
1624
1363
  function batchString(value, name) {
1625
1364
  if (value?.trim())
1626
1365
  return value;
@@ -1636,19 +1375,15 @@ function positiveInteger(value, name) {
1636
1375
  return value;
1637
1376
  throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
1638
1377
  }
1639
- function rowsToCsv(rows, columns) {
1640
- const encode = (value) => {
1641
- const text = value === null || value === undefined
1642
- ? ""
1643
- : typeof value === "object"
1644
- ? JSON.stringify(value)
1645
- : String(value);
1646
- return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
1647
- };
1648
- return [
1649
- columns.map(encode).join(","),
1650
- ...rows.map((row) => columns.map((column) => encode(row[column])).join(",")),
1651
- ].join("\n") + "\n";
1378
+ function executionTimeout(value) {
1379
+ const timeout = positiveInteger(value, "timeoutMs");
1380
+ if (timeout > 2_147_483_647) {
1381
+ throw new StateQLError("INVALID_COMMAND", "timeoutMs cannot exceed 2147483647 milliseconds.");
1382
+ }
1383
+ return timeout;
1384
+ }
1385
+ function stoppedStateQLError(error, executed) {
1386
+ return new StateQLError(error.reason === "timeout" ? "DEADLINE_EXCEEDED" : "OPERATION_CANCELLED", error.message, { retryable: true, executed });
1652
1387
  }
1653
1388
  function errorMessage(error) {
1654
1389
  return error instanceof Error ? error.message : String(error);