@fadhilp/stateql 0.1.2 → 0.2.2

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,12 +1,16 @@
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";
12
+ const DEFAULT_SNAPSHOT_HISTORY_LIMIT = 50;
13
+ const MAX_SNAPSHOT_HISTORY_LIMIT = 100;
10
14
  export class StateQL {
11
15
  store;
12
16
  sessionName;
@@ -15,6 +19,9 @@ export class StateQL {
15
19
  resultTtlSeconds;
16
20
  maxCellCharacters;
17
21
  maxResultRows;
22
+ maxResultBytes;
23
+ timeoutMs;
24
+ signal;
18
25
  now;
19
26
  constructor(options = {}) {
20
27
  this.now = options.now ?? (() => new Date());
@@ -24,6 +31,9 @@ export class StateQL {
24
31
  this.resultTtlSeconds = options.resultTtlSeconds ?? 86_400;
25
32
  this.maxCellCharacters = options.maxCellCharacters ?? 200;
26
33
  this.maxResultRows = positiveInteger(options.maxResultRows ?? 10_000, "maxResultRows");
34
+ this.maxResultBytes = positiveInteger(options.maxResultBytes ?? 16 * 1024 * 1024, "maxResultBytes");
35
+ this.timeoutMs = executionTimeout(options.timeoutMs ?? 30_000);
36
+ this.signal = options.signal;
27
37
  if (this.maxResultRows >= Number.MAX_SAFE_INTEGER) {
28
38
  throw new StateQLError("INVALID_COMMAND", "maxResultRows is too large.");
29
39
  }
@@ -59,10 +69,10 @@ export class StateQL {
59
69
  : "Connection target is required.");
60
70
  }
61
71
  const driver = detectDriver(secret);
62
- if (driver === "postgres" &&
72
+ if (driver !== "sqlite" &&
63
73
  !secretEnv &&
64
- postgresUrlHasSecret(secret)) {
65
- throw new StateQLError("PERMISSION_DENIED", "Credential-bearing PostgreSQL URLs must use --env.", {
74
+ databaseUrlHasSecret(secret)) {
75
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`, {
66
76
  suggestedAction: "Set the URL in an environment variable and reconnect with --env NAME.",
67
77
  });
68
78
  }
@@ -73,7 +83,7 @@ export class StateQL {
73
83
  : secret;
74
84
  const databaseName = driver === "sqlite"
75
85
  ? basename(source)
76
- : new URL(secret).pathname.replace(/^\//, "") || "postgres";
86
+ : new URL(secret).pathname.replace(/^\//, "") || driver;
77
87
  const readOnly = options.readOnly ??
78
88
  (profile ? Boolean(profile.read_only) : true);
79
89
  const draft = {
@@ -88,11 +98,14 @@ export class StateQL {
88
98
  version: 0,
89
99
  created_at: this.now().toISOString(),
90
100
  };
91
- const adapter = await createAdapter(draft);
101
+ const adapter = await createAdapter(draft, this.executionContext(options));
92
102
  try {
93
103
  await adapter.read("SELECT 1", []);
94
104
  }
95
105
  catch (error) {
106
+ if (error instanceof AdapterExecutionError) {
107
+ throw stoppedStateQLError(error, false);
108
+ }
96
109
  throw new StateQLError("CONNECTION_FAILED", errorMessage(error), { retryable: true });
97
110
  }
98
111
  finally {
@@ -140,8 +153,8 @@ export class StateQL {
140
153
  let storedTarget = target;
141
154
  if (target) {
142
155
  const driver = detectDriver(target);
143
- if (driver === "postgres" && postgresUrlHasSecret(target)) {
144
- throw new StateQLError("PERMISSION_DENIED", "Credential-bearing PostgreSQL URLs must use --env.");
156
+ if (driver !== "sqlite" && databaseUrlHasSecret(target)) {
157
+ throw new StateQLError("PERMISSION_DENIED", `Credential-bearing ${databaseDisplayName(driver)} URLs must use --env.`);
145
158
  }
146
159
  if (driver === "sqlite")
147
160
  storedTarget = normalizeSqliteSource(target);
@@ -197,6 +210,58 @@ export class StateQL {
197
210
  return { data: { disconnected: true }, executed: true };
198
211
  });
199
212
  }
213
+ snapshot(options = {}) {
214
+ const session = this.store
215
+ .listSessions()
216
+ .find((candidate) => candidate.name === this.sessionName);
217
+ if (!session) {
218
+ throw new StateQLError("INVALID_COMMAND", "The active session was not found.");
219
+ }
220
+ const connection = this.store.activeConnection(session);
221
+ const transaction = session.active_transaction_id
222
+ ? this.store.getTransaction(session.active_transaction_id)
223
+ : undefined;
224
+ const historyLimit = positiveInteger(options.historyLimit ?? DEFAULT_SNAPSHOT_HISTORY_LIMIT, "historyLimit");
225
+ if (historyLimit > MAX_SNAPSHOT_HISTORY_LIMIT) {
226
+ throw new StateQLError("INVALID_COMMAND", `historyLimit cannot exceed ${MAX_SNAPSHOT_HISTORY_LIMIT}.`);
227
+ }
228
+ return {
229
+ session: {
230
+ session_id: session.id,
231
+ name: session.name,
232
+ status: session.status,
233
+ },
234
+ connection: connection
235
+ ? {
236
+ connection_id: connection.id,
237
+ name: connection.name,
238
+ status: "connected",
239
+ driver: connection.driver,
240
+ database: connection.database_name,
241
+ read_only: Boolean(connection.read_only),
242
+ }
243
+ : null,
244
+ transaction: transaction
245
+ ? { transaction_id: transaction.id, state: transaction.state }
246
+ : null,
247
+ state_version: connection ? version(connection) : null,
248
+ state_confidence: connection ? confidence(connection) : null,
249
+ recent_results: this.store.knownResults(session.id, 10).map((result) => ({
250
+ alias: result.alias,
251
+ handle: result.id,
252
+ rows: result.row_count,
253
+ })),
254
+ recent_operations: this.store
255
+ .recentOperations(session.id, 10)
256
+ .map((operation) => ({
257
+ handle: operation.id,
258
+ type: operation.statement_type,
259
+ affected_rows: operation.affected_rows,
260
+ status: operation.status,
261
+ })),
262
+ history: this.store.history(session.id, historyLimit).map(historyEntry),
263
+ };
264
+ }
200
265
  async status() {
201
266
  return this.run("status", async (session) => {
202
267
  const connection = this.store.activeConnection(session);
@@ -231,10 +296,10 @@ export class StateQL {
231
296
  if (!name.trim()) {
232
297
  throw new StateQLError("INVALID_COMMAND", "Session name is required.");
233
298
  }
234
- if (this.store.getSession(name)) {
299
+ if (this.store.getSessionByName(name)) {
235
300
  throw new StateQLError("INVALID_COMMAND", `Active session "${name}" already exists.`);
236
301
  }
237
- const session = this.store.createSession(name);
302
+ const session = this.store.ensureSession(name);
238
303
  return {
239
304
  data: sessionData(session),
240
305
  handle: session.id,
@@ -310,12 +375,13 @@ export class StateQL {
310
375
  async query(sql, options = {}) {
311
376
  return this.run("query", async (session) => {
312
377
  const connection = this.requireConnection(session);
378
+ this.rejectDuringStagedTransaction(session, "Queries");
313
379
  const analysis = analyzeSql(sql, connection.driver);
314
380
  if (!analysis.read) {
315
381
  throw new StateQLError("INVALID_SQL", "query accepts read statements only; use exec for writes.");
316
382
  }
317
383
  const parameters = options.params ?? [];
318
- const adapter = await createAdapter(connection);
384
+ const adapter = await createAdapter(connection, this.executionContext(options));
319
385
  try {
320
386
  const stateVersion = version(connection);
321
387
  const stateSignature = await adapter.signature();
@@ -353,6 +419,12 @@ export class StateQL {
353
419
  if (result.rows.length > this.maxResultRows) {
354
420
  throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultRows}-row materialization limit.`, { suggestedAction: "Add a narrower WHERE clause or LIMIT." });
355
421
  }
422
+ const resultBytes = Buffer.byteLength(JSON.stringify(parameters), "utf8") +
423
+ Buffer.byteLength(JSON.stringify(result.rows), "utf8") +
424
+ Buffer.byteLength(JSON.stringify(result.columns), "utf8");
425
+ if (resultBytes > this.maxResultBytes) {
426
+ throw new StateQLError("OUTPUT_LIMIT_EXCEEDED", `Query exceeds the ${this.maxResultBytes}-byte materialization limit.`, { suggestedAction: "Select fewer rows or smaller columns." });
427
+ }
356
428
  const expiresAt = new Date(this.now().getTime() + this.resultTtlSeconds * 1000).toISOString();
357
429
  const saved = this.store.saveResult({
358
430
  sessionId: session.id,
@@ -379,6 +451,9 @@ export class StateQL {
379
451
  catch (error) {
380
452
  if (error instanceof StateQLError)
381
453
  throw error;
454
+ if (error instanceof AdapterExecutionError) {
455
+ throw stoppedStateQLError(error, true);
456
+ }
382
457
  throw new StateQLError("QUERY_FAILED", errorMessage(error), {
383
458
  retryable: true,
384
459
  executed: true,
@@ -529,7 +604,7 @@ export class StateQL {
529
604
  async exec(sql, options = {}) {
530
605
  return this.run("exec", async (session) => {
531
606
  const connection = this.requireConnection(session);
532
- return this.performExec(session, connection, sql, options);
607
+ return this.performExec(session, connection, sql, options, this.executionContext(options));
533
608
  });
534
609
  }
535
610
  async receipt(id) {
@@ -587,7 +662,7 @@ export class StateQL {
587
662
  };
588
663
  });
589
664
  }
590
- async commitTransaction(id) {
665
+ async commitTransaction(id, options = {}) {
591
666
  return this.run("transaction.commit", async (session) => {
592
667
  const transaction = this.requireActiveTransaction(session, id);
593
668
  const connection = this.store.getConnection(transaction.connection_id);
@@ -601,7 +676,7 @@ export class StateQL {
601
676
  if (operations.some((operation) => operation.connection_id !== connection.id)) {
602
677
  throw new StateQLError("TRANSACTION_FAILED", "Transaction contains writes staged for another connection.", { suggestedAction: "Roll back the transaction." });
603
678
  }
604
- const adapter = await createAdapter(connection);
679
+ const adapter = await createAdapter(connection, this.executionContext(options));
605
680
  try {
606
681
  if (!this.store.markTransactionCommitting(transaction.id)) {
607
682
  throw new StateQLError("TRANSACTION_FAILED", "Transaction is no longer active.");
@@ -611,8 +686,12 @@ export class StateQL {
611
686
  results = await adapter.writeBatch(operations, transaction.isolation_level);
612
687
  }
613
688
  catch (error) {
614
- if (error instanceof BatchWriteError && !error.outcomeUnknown) {
689
+ if ((error instanceof BatchWriteError && !error.outcomeUnknown) ||
690
+ (error instanceof AdapterExecutionError && !error.outcomeUnknown)) {
615
691
  this.store.finishTransaction(transaction.id, session.id, "failed");
692
+ if (error instanceof AdapterExecutionError) {
693
+ throw stoppedStateQLError(error, false);
694
+ }
616
695
  throw new StateQLError("TRANSACTION_FAILED", error.message, {
617
696
  retryable: true,
618
697
  });
@@ -690,10 +769,11 @@ export class StateQL {
690
769
  };
691
770
  });
692
771
  }
693
- async inspect(kind, table) {
772
+ async inspect(kind, table, options = {}) {
694
773
  return this.run(`inspect.${kind}`, async (session) => {
695
774
  const connection = this.requireConnection(session);
696
- const adapter = await createAdapter(connection);
775
+ this.rejectDuringStagedTransaction(session, "Schema inspection");
776
+ const adapter = await createAdapter(connection, this.executionContext(options));
697
777
  try {
698
778
  const data = await adapter.inspect(kind, table);
699
779
  return {
@@ -704,6 +784,9 @@ export class StateQL {
704
784
  };
705
785
  }
706
786
  catch (error) {
787
+ if (error instanceof AdapterExecutionError) {
788
+ throw stoppedStateQLError(error, true);
789
+ }
707
790
  throw new StateQLError("QUERY_FAILED", errorMessage(error), {
708
791
  retryable: false,
709
792
  executed: true,
@@ -717,11 +800,12 @@ export class StateQL {
717
800
  async plan(sql, options = {}) {
718
801
  return this.run("plan", async (session) => {
719
802
  const connection = this.requireConnection(session);
803
+ this.rejectDuringStagedTransaction(session, "Plans");
720
804
  const analysis = analyzeSql(sql, connection.driver);
721
805
  if (analysis.read) {
722
806
  throw new StateQLError("INVALID_SQL", "plan accepts write statements only.");
723
807
  }
724
- const adapter = await createAdapter(connection);
808
+ const adapter = await createAdapter(connection, this.executionContext(options));
725
809
  try {
726
810
  const stateSignature = await adapter.signature();
727
811
  const expiresAt = new Date(this.now().getTime() + 10 * 60_000).toISOString();
@@ -762,13 +846,20 @@ export class StateQL {
762
846
  confidence: adapter.confidence,
763
847
  };
764
848
  }
849
+ catch (error) {
850
+ if (error instanceof AdapterExecutionError) {
851
+ throw stoppedStateQLError(error, true);
852
+ }
853
+ throw error;
854
+ }
765
855
  finally {
766
856
  await adapter.close();
767
857
  }
768
858
  });
769
859
  }
770
- async apply(planId) {
860
+ async apply(planId, options = {}) {
771
861
  return this.run("apply", async (session) => {
862
+ this.rejectDuringStagedTransaction(session, "Plans");
772
863
  const plan = this.store.getPlan(planId);
773
864
  if (!plan || plan.session_id !== session.id) {
774
865
  throw new StateQLError("STALE_PLAN", `Plan "${planId}" was not found.`);
@@ -786,12 +877,19 @@ export class StateQL {
786
877
  version(connection) !== plan.state_version) {
787
878
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
788
879
  }
789
- const adapter = await createAdapter(connection);
880
+ const context = this.executionContext(options);
881
+ const adapter = await createAdapter(connection, context);
790
882
  try {
791
883
  if ((await adapter.signature()) !== plan.state_signature) {
792
884
  throw new StateQLError("STALE_PLAN", "Database state changed after this plan was created.");
793
885
  }
794
886
  }
887
+ catch (error) {
888
+ if (error instanceof AdapterExecutionError) {
889
+ throw stoppedStateQLError(error, true);
890
+ }
891
+ throw error;
892
+ }
795
893
  finally {
796
894
  await adapter.close();
797
895
  }
@@ -799,7 +897,7 @@ export class StateQL {
799
897
  params: parseJson(plan.parameters, []),
800
898
  allowUnbounded: Boolean(plan.allow_unbounded),
801
899
  allowDestructive: Boolean(plan.allow_destructive),
802
- });
900
+ }, context);
803
901
  const operationId = String(result.data.operation_id);
804
902
  this.store.markPlanApplied(plan.id, operationId);
805
903
  return {
@@ -813,24 +911,14 @@ export class StateQL {
813
911
  data: {
814
912
  history: this.store
815
913
  .history(session.id, positiveInteger(limit, "limit"))
816
- .map((item) => ({
817
- command_id: item.id,
818
- timestamp: item.timestamp,
819
- session_id: item.session_id,
820
- command: item.command,
821
- handle: item.handle,
822
- executed: Boolean(item.executed),
823
- cached: Boolean(item.cached),
824
- success: Boolean(item.success),
825
- error_code: item.error_code,
826
- })),
914
+ .map(historyEntry),
827
915
  },
828
916
  }));
829
917
  }
830
918
  async capabilities() {
831
919
  return this.run("capabilities", async () => ({
832
920
  data: {
833
- drivers: ["postgres", "sqlite"],
921
+ drivers: ["mysql", "postgres", "sqlite"],
834
922
  features: {
835
923
  result_handles: true,
836
924
  write_deduplication: true,
@@ -839,6 +927,8 @@ export class StateQL {
839
927
  persistent_sessions: true,
840
928
  result_filtering: true,
841
929
  schema_inspection: true,
930
+ deadlines: true,
931
+ cancellation: true,
842
932
  },
843
933
  },
844
934
  }));
@@ -855,6 +945,7 @@ export class StateQL {
855
945
  readOnly: command.read_only,
856
946
  secretEnv: command.secret_env,
857
947
  profile: command.profile,
948
+ timeoutMs: command.timeout_ms,
858
949
  });
859
950
  case "disconnect":
860
951
  return this.disconnect();
@@ -885,6 +976,7 @@ export class StateQL {
885
976
  const response = await this.query(batchString(command.sql, "sql"), {
886
977
  params: command.params ?? [],
887
978
  cache: command.cache ?? "auto",
979
+ timeoutMs: command.timeout_ms,
888
980
  });
889
981
  if (!response.ok || !command.as)
890
982
  return response;
@@ -917,6 +1009,7 @@ export class StateQL {
917
1009
  idempotencyKey: command.idempotency_key,
918
1010
  allowUnbounded: command.allow_unbounded ?? false,
919
1011
  allowDestructive: command.allow_destructive ?? false,
1012
+ timeoutMs: command.timeout_ms,
920
1013
  });
921
1014
  case "show":
922
1015
  return this.show(batchString(command.handle, "handle"));
@@ -932,13 +1025,17 @@ export class StateQL {
932
1025
  case "alias.set":
933
1026
  return this.setAlias(batchString(command.name, "name"), batchString(command.handle, "handle"));
934
1027
  case "inspect":
935
- return this.inspect(batchString(command.kind, "kind"), command.table);
1028
+ return this.inspect(batchString(command.kind, "kind"), command.table, {
1029
+ timeoutMs: command.timeout_ms,
1030
+ });
936
1031
  case "transaction.begin":
937
1032
  return this.beginTransaction(command.isolation);
938
1033
  case "transaction.status":
939
1034
  return this.transactionStatus(command.handle);
940
1035
  case "transaction.commit":
941
- return this.commitTransaction(command.handle);
1036
+ return this.commitTransaction(command.handle, {
1037
+ timeoutMs: command.timeout_ms,
1038
+ });
942
1039
  case "transaction.rollback":
943
1040
  return this.rollbackTransaction(command.handle);
944
1041
  case "plan":
@@ -946,9 +1043,12 @@ export class StateQL {
946
1043
  params: command.params ?? [],
947
1044
  allowUnbounded: command.allow_unbounded ?? false,
948
1045
  allowDestructive: command.allow_destructive,
1046
+ timeoutMs: command.timeout_ms,
949
1047
  });
950
1048
  case "apply":
951
- return this.apply(batchString(command.handle, "handle"));
1049
+ return this.apply(batchString(command.handle, "handle"), {
1050
+ timeoutMs: command.timeout_ms,
1051
+ });
952
1052
  case "history":
953
1053
  return this.history(command.limit ?? 20);
954
1054
  case "receipt":
@@ -982,7 +1082,7 @@ export class StateQL {
982
1082
  return;
983
1083
  }
984
1084
  }
985
- async performExec(session, connection, sql, options) {
1085
+ async performExec(session, connection, sql, options, context) {
986
1086
  if (connection.read_only) {
987
1087
  throw new StateQLError("READ_ONLY_CONNECTION", "Connection is read-only.", { suggestedAction: "Reconnect with --read-write." });
988
1088
  }
@@ -996,6 +1096,10 @@ export class StateQL {
996
1096
  if (analysis.destructive && !options.allowDestructive) {
997
1097
  throw new StateQLError("DESTRUCTIVE_OPERATION_BLOCKED", "Destructive operation requires an explicit override.", { extra: { override_flag: "--allow-destructive" } });
998
1098
  }
1099
+ if (options.idempotencyKey !== undefined &&
1100
+ !options.idempotencyKey.trim()) {
1101
+ throw new StateQLError("INVALID_COMMAND", "Idempotency key cannot be empty.");
1102
+ }
999
1103
  const parameters = options.params ?? [];
1000
1104
  const fingerprint = hash({
1001
1105
  sql: analysis.normalized,
@@ -1026,6 +1130,12 @@ export class StateQL {
1026
1130
  stateVersionBefore: version(connection),
1027
1131
  });
1028
1132
  const previous = reservation.previous;
1133
+ if (previous &&
1134
+ options.idempotencyKey &&
1135
+ !options.replay &&
1136
+ previous.fingerprint !== fingerprint) {
1137
+ throw new StateQLError("IDEMPOTENCY_CONFLICT", "Idempotency key was already used for a different write.", { extra: { previous_operation_id: previous.id } });
1138
+ }
1029
1139
  if (previous && !reservation.operation) {
1030
1140
  if (previous.status === "executing" ||
1031
1141
  previous.status === "outcome_unknown") {
@@ -1066,7 +1176,7 @@ export class StateQL {
1066
1176
  }
1067
1177
  let adapter;
1068
1178
  try {
1069
- adapter = await createAdapter(connection);
1179
+ adapter = await createAdapter(connection, context);
1070
1180
  }
1071
1181
  catch (error) {
1072
1182
  this.store.failOperation(operation.id);
@@ -1102,6 +1212,16 @@ export class StateQL {
1102
1212
  catch (error) {
1103
1213
  if (error instanceof StateQLError)
1104
1214
  throw error;
1215
+ if (error instanceof AdapterExecutionError && !error.outcomeUnknown) {
1216
+ this.store.failOperation(operation.id);
1217
+ throw stoppedStateQLError(error, false);
1218
+ }
1219
+ if (error instanceof AdapterWriteError && !error.outcomeUnknown) {
1220
+ this.store.failOperation(operation.id);
1221
+ throw new StateQLError("QUERY_FAILED", error.message, {
1222
+ executed: true,
1223
+ });
1224
+ }
1105
1225
  this.store.markOperationOutcomeUnknown(operation.id);
1106
1226
  throw new StateQLError("OUTCOME_UNKNOWN", errorMessage(error), {
1107
1227
  executed: true,
@@ -1134,6 +1254,11 @@ export class StateQL {
1134
1254
  return action(result, session);
1135
1255
  });
1136
1256
  }
1257
+ rejectDuringStagedTransaction(session, operation) {
1258
+ if (!session.active_transaction_id)
1259
+ return;
1260
+ throw new StateQLError("TRANSACTION_FAILED", `${operation} cannot run while a staged transaction is active.`, { suggestedAction: "Commit or roll back the transaction first." });
1261
+ }
1137
1262
  requireResult(idOrAlias, session) {
1138
1263
  const result = this.store.getResult(idOrAlias, session.id);
1139
1264
  if (!result) {
@@ -1162,6 +1287,9 @@ export class StateQL {
1162
1287
  }
1163
1288
  return transaction;
1164
1289
  }
1290
+ executionContext(options) {
1291
+ return createAdapterContext(executionTimeout(options.timeoutMs ?? this.timeoutMs), options.signal ?? this.signal);
1292
+ }
1165
1293
  resultData(result, cached) {
1166
1294
  const rows = this.store.resultRows(result);
1167
1295
  const preview = compactRows(rows.slice(0, this.previewRows), this.maxCellCharacters);
@@ -1244,255 +1372,18 @@ export class StateQL {
1244
1372
  }
1245
1373
  }
1246
1374
  }
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);
1375
+ function historyEntry(item) {
1293
1376
  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);
1377
+ command_id: item.id,
1378
+ timestamp: item.timestamp,
1379
+ session_id: item.session_id,
1380
+ command: item.command,
1381
+ handle: item.handle,
1382
+ executed: Boolean(item.executed),
1383
+ cached: Boolean(item.cached),
1384
+ success: Boolean(item.success),
1385
+ error_code: item.error_code,
1466
1386
  };
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
1387
  }
1497
1388
  function markTransactionOutcomeUnknown(store, transactionId, sessionId) {
1498
1389
  try {
@@ -1506,6 +1397,9 @@ function boundedReadSql(sql, limit) {
1506
1397
  const statement = sql.trim().replace(/;\s*$/, "");
1507
1398
  return `SELECT * FROM (${statement}) AS _stateql_bounded LIMIT ${limit}`;
1508
1399
  }
1400
+ function databaseDisplayName(driver) {
1401
+ return driver === "postgres" ? "PostgreSQL" : "MySQL";
1402
+ }
1509
1403
  function normalizeIsolation(isolation, driver) {
1510
1404
  const normalized = isolation.trim().toLowerCase().replace(/[_-]+/g, " ")
1511
1405
  .replace(/\s+/g, " ");
@@ -1523,104 +1417,6 @@ function normalizeIsolation(isolation, driver) {
1523
1417
  }
1524
1418
  return normalized;
1525
1419
  }
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
1420
  function batchString(value, name) {
1625
1421
  if (value?.trim())
1626
1422
  return value;
@@ -1636,19 +1432,15 @@ function positiveInteger(value, name) {
1636
1432
  return value;
1637
1433
  throw new StateQLError("INVALID_COMMAND", `${name} must be a positive integer.`);
1638
1434
  }
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";
1435
+ function executionTimeout(value) {
1436
+ const timeout = positiveInteger(value, "timeoutMs");
1437
+ if (timeout > 2_147_483_647) {
1438
+ throw new StateQLError("INVALID_COMMAND", "timeoutMs cannot exceed 2147483647 milliseconds.");
1439
+ }
1440
+ return timeout;
1441
+ }
1442
+ function stoppedStateQLError(error, executed) {
1443
+ return new StateQLError(error.reason === "timeout" ? "DEADLINE_EXCEEDED" : "OPERATION_CANCELLED", error.message, { retryable: true, executed });
1652
1444
  }
1653
1445
  function errorMessage(error) {
1654
1446
  return error instanceof Error ? error.message : String(error);