@fadhilp/stateql 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ StateQL is built around durable handles:
12
12
  rerunning the original SQL.
13
13
  3. Use operation, plan, and transaction handles to inspect and control writes.
14
14
 
15
- Requires Node.js 22.5 or newer.
15
+ Requires Node.js 22.16 or newer for the required `node:sqlite` APIs.
16
16
 
17
17
  ## Quick start
18
18
 
@@ -454,4 +454,4 @@ and keeping values out of their own logs and model-visible data.
454
454
  For writes, credential resolution happens after StateQL atomically reserves the
455
455
  operation for duplicate protection. A resolution failure keeps a non-executed
456
456
  `failed` audit record, does not consume the idempotency key, and permits a safe
457
- retry.
457
+ retry.
@@ -53,6 +53,7 @@ class SQLiteAdapter {
53
53
  pending = new Map();
54
54
  nextId = 1;
55
55
  closed = false;
56
+ closePromise;
56
57
  constructor(source, readOnly, context) {
57
58
  this.source = source;
58
59
  this.readOnly = readOnly;
@@ -100,12 +101,12 @@ class SQLiteAdapter {
100
101
  return this.call("inspect", [kind, table], false, false);
101
102
  }
102
103
  async close() {
103
- if (this.closed)
104
- return;
104
+ if (this.closePromise)
105
+ return this.closePromise;
105
106
  this.closed = true;
106
107
  if (this.child.exitCode !== null || this.child.signalCode !== null)
107
108
  return;
108
- await new Promise((resolve) => {
109
+ this.closePromise = new Promise((resolve) => {
109
110
  const done = () => {
110
111
  clearTimeout(timer);
111
112
  resolve();
@@ -117,7 +118,6 @@ class SQLiteAdapter {
117
118
  catch {
118
119
  // Process already exited.
119
120
  }
120
- resolve();
121
121
  }, 1_000);
122
122
  timer.unref();
123
123
  this.child.once("exit", done);
@@ -133,6 +133,7 @@ class SQLiteAdapter {
133
133
  }
134
134
  }
135
135
  });
136
+ return this.closePromise;
136
137
  }
137
138
  async call(operation, args, outcomeUnknown, batch) {
138
139
  throwIfStopped(this.context, false);
@@ -148,7 +149,9 @@ class SQLiteAdapter {
148
149
  readOnly: this.readOnly,
149
150
  operation,
150
151
  args,
151
- busyTimeoutMs: Math.min(5_000, remainingMilliseconds(this.context)),
152
+ // Let the client deadline terminate the worker before SQLite's
153
+ // lock timeout races it and reports a known, non-executed failure.
154
+ busyTimeoutMs: Math.min(5_000, remainingMilliseconds(this.context) + 250),
152
155
  }, (error) => {
153
156
  if (error)
154
157
  reject(error);
@@ -46,6 +46,15 @@ const MIGRATIONS = [
46
46
  apply: migrateSharedSessionActors,
47
47
  validate: validateSharedSessionActors,
48
48
  },
49
+ {
50
+ name: "history_sql_v1",
51
+ apply(db) {
52
+ addColumn(db, "history", "sql", "TEXT");
53
+ },
54
+ validate(db) {
55
+ requireColumns(db, "history", ["sql"]);
56
+ },
57
+ },
49
58
  ];
50
59
  export function runMigrations(db, now) {
51
60
  db.exec(`
@@ -202,6 +211,7 @@ function createInitialSchema(db) {
202
211
  session_id TEXT NOT NULL,
203
212
  actor_id TEXT NOT NULL,
204
213
  command TEXT NOT NULL,
214
+ sql TEXT,
205
215
  handle TEXT,
206
216
  executed INTEGER NOT NULL,
207
217
  cached INTEGER NOT NULL,
@@ -613,7 +613,7 @@ export class StateQL {
613
613
  finally {
614
614
  await closeAdapterQuietly(adapter);
615
615
  }
616
- });
616
+ }, sql);
617
617
  }
618
618
  async show(idOrAlias) {
619
619
  return this.withResult("show", idOrAlias, async (result) => ({
@@ -756,7 +756,7 @@ export class StateQL {
756
756
  return this.run("exec", async (session) => {
757
757
  const connection = this.requireConnection(session);
758
758
  return this.performExec(session, connection, sql, options, this.executionContext(options));
759
- });
759
+ }, sql);
760
760
  }
761
761
  async receipt(id) {
762
762
  return this.run("receipt", async (session) => {
@@ -1023,9 +1023,10 @@ export class StateQL {
1023
1023
  finally {
1024
1024
  await closeAdapterQuietly(adapter);
1025
1025
  }
1026
- });
1026
+ }, sql);
1027
1027
  }
1028
1028
  async apply(planId, options = {}) {
1029
+ let historySql;
1029
1030
  return this.run("apply", async (session) => {
1030
1031
  this.rejectDuringStagedTransaction(session, "Plans");
1031
1032
  const plan = this.store.getPlan(planId);
@@ -1043,6 +1044,7 @@ export class StateQL {
1043
1044
  if (Date.parse(plan.expires_at) <= this.now().getTime()) {
1044
1045
  throw new StateQLError("STALE_PLAN", "Plan has expired.");
1045
1046
  }
1047
+ historySql = plan.sql;
1046
1048
  const planParameters = parseJson(plan.parameters, `plan "${plan.id}" parameters`, isSqlParameters);
1047
1049
  const claimToken = this.store.nextId("claim");
1048
1050
  const claimed = this.store.claimPlan(plan.id, session.id, this.actorId, claimToken);
@@ -1096,7 +1098,7 @@ export class StateQL {
1096
1098
  if (!retainClaim)
1097
1099
  this.store.releasePlanClaim(plan.id, claimToken);
1098
1100
  }
1099
- });
1101
+ }, () => historySql);
1100
1102
  }
1101
1103
  async history(limit = 20) {
1102
1104
  return this.run("history", async (session) => ({
@@ -1642,7 +1644,7 @@ export class StateQL {
1642
1644
  result.state_version === stateVersion &&
1643
1645
  result.state_signature === stateSignature);
1644
1646
  }
1645
- async run(command, action) {
1647
+ async run(command, action, historySql) {
1646
1648
  const started = performance.now();
1647
1649
  let session = this.store.ensureSession(this.sessionName);
1648
1650
  const commandId = this.store.nextId("cmd");
@@ -1661,12 +1663,14 @@ export class StateQL {
1661
1663
  try {
1662
1664
  const result = await action(session);
1663
1665
  const responseSession = result.session ?? session;
1666
+ const sqlText = resolveHistorySql(historySql);
1664
1667
  this.store.addHistory({
1665
1668
  id: commandId,
1666
1669
  sessionId: session.id,
1667
1670
  actorId: this.actorId,
1668
1671
  command,
1669
1672
  ...(result.handle ? { handle: result.handle } : {}),
1673
+ ...(sqlText !== undefined ? { sql: sqlText } : {}),
1670
1674
  executed: result.executed ?? false,
1671
1675
  cached: result.cached ?? false,
1672
1676
  success: true,
@@ -1690,11 +1694,13 @@ export class StateQL {
1690
1694
  }
1691
1695
  catch (error) {
1692
1696
  const stateqlError = asStateQLError(error);
1697
+ const sqlText = resolveHistorySql(historySql);
1693
1698
  this.store.addHistory({
1694
1699
  id: commandId,
1695
1700
  sessionId: session.id,
1696
1701
  actorId: this.actorId,
1697
1702
  command,
1703
+ ...(sqlText !== undefined ? { sql: sqlText } : {}),
1698
1704
  executed: stateqlError.details.executed,
1699
1705
  cached: false,
1700
1706
  success: false,
@@ -1712,6 +1718,9 @@ export class StateQL {
1712
1718
  }
1713
1719
  }
1714
1720
  }
1721
+ function resolveHistorySql(sql) {
1722
+ return typeof sql === "function" ? sql() : sql;
1723
+ }
1715
1724
  function historyEntry(item) {
1716
1725
  return {
1717
1726
  command_id: item.id,
@@ -1719,6 +1728,7 @@ function historyEntry(item) {
1719
1728
  session_id: item.session_id,
1720
1729
  actor_id: item.actor_id,
1721
1730
  command: item.command,
1731
+ sql: item.sql,
1722
1732
  handle: item.handle,
1723
1733
  executed: Boolean(item.executed),
1724
1734
  cached: Boolean(item.cached),
@@ -1800,7 +1810,6 @@ async function resolveCredentialBeforeDeadline(resolver, request, context) {
1800
1810
  };
1801
1811
  const abort = () => finish(() => reject(new CredentialResolutionError("cancelled")));
1802
1812
  const timer = setTimeout(() => finish(() => reject(new CredentialResolutionError("timeout"))), remaining);
1803
- timer.unref?.();
1804
1813
  context.signal?.addEventListener("abort", abort, { once: true });
1805
1814
  Promise.resolve()
1806
1815
  .then(() => resolver(request))
@@ -98,6 +98,7 @@ export interface HistoryRecord {
98
98
  session_id: string;
99
99
  actor_id: string;
100
100
  command: string;
101
+ sql: string | null;
101
102
  handle: string | null;
102
103
  executed: number;
103
104
  cached: number;
@@ -267,6 +268,7 @@ export declare class StateStore {
267
268
  sessionId: string;
268
269
  actorId: string;
269
270
  command: string;
271
+ sql?: string;
270
272
  handle?: string;
271
273
  executed: boolean;
272
274
  cached: boolean;
package/dist/src/store.js CHANGED
@@ -6,6 +6,7 @@ import { runMigrations } from "./migrations.js";
6
6
  import { StateQLError } from "./errors.js";
7
7
  import { isColumns, isRows, isSqlParameters, parseJson, toJsonSafe, } from "./util.js";
8
8
  const HISTORY_LIMIT_PER_SESSION = 10_000;
9
+ const MAX_HISTORY_SQL_BYTES = 4_096;
9
10
  const DEFAULT_MAX_STATE_BYTES = 256 * 1024 * 1024;
10
11
  export class StateStore {
11
12
  now;
@@ -791,10 +792,10 @@ export class StateStore {
791
792
  const id = input.id ?? this.nextId("cmd");
792
793
  this.db
793
794
  .prepare(`INSERT INTO history
794
- (id, timestamp, session_id, actor_id, command, handle, executed,
795
+ (id, timestamp, session_id, actor_id, command, sql, handle, executed,
795
796
  cached, success, error_code)
796
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
797
- .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.command, input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
797
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
798
+ .run(id, this.now().toISOString(), input.sessionId, input.actorId, input.command, boundedHistorySql(input.sql), input.handle ?? null, input.executed ? 1 : 0, input.cached ? 1 : 0, input.success ? 1 : 0, input.errorCode ?? null);
798
799
  this.db
799
800
  .prepare(`DELETE FROM history
800
801
  WHERE rowid IN (
@@ -1010,6 +1011,22 @@ export class StateStore {
1010
1011
  }
1011
1012
  }
1012
1013
  }
1014
+ function boundedHistorySql(sql) {
1015
+ if (sql === undefined)
1016
+ return null;
1017
+ if (Buffer.byteLength(sql, "utf8") <= MAX_HISTORY_SQL_BYTES)
1018
+ return sql;
1019
+ const suffix = "…";
1020
+ let prefix = "";
1021
+ for (const character of sql) {
1022
+ if (Buffer.byteLength(`${prefix}${character}${suffix}`, "utf8") >
1023
+ MAX_HISTORY_SQL_BYTES) {
1024
+ break;
1025
+ }
1026
+ prefix += character;
1027
+ }
1028
+ return `${prefix}${suffix}`;
1029
+ }
1013
1030
  function restrictMode(path, allowed) {
1014
1031
  chmodSync(path, statSync(path).mode & allowed);
1015
1032
  }
@@ -64,6 +64,7 @@ export interface HistoryEntry {
64
64
  session_id: string;
65
65
  actor_id: string;
66
66
  command: string;
67
+ sql: string | null;
67
68
  handle: string | null;
68
69
  executed: boolean;
69
70
  cached: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fadhilp/stateql",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Stateful, agent-oriented database CLI for safe result reuse",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,8 @@
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
28
  "test": "npm run build && node --test dist/test/cli.test.js dist/test/credential.test.js dist/test/mysql.test.js dist/test/postgres.test.js dist/test/query.test.js dist/test/sqlite.test.js dist/test/store.test.js dist/test/terminal.test.js dist/test/transaction.test.js dist/test/write.test.js",
29
+ "release": "npm version",
30
+ "version": "npm install",
29
31
  "prepack": "npm test"
30
32
  },
31
33
  "keywords": [
@@ -39,7 +41,7 @@
39
41
  ],
40
42
  "license": "MIT",
41
43
  "engines": {
42
- "node": ">=22.5"
44
+ "node": ">=22.16"
43
45
  },
44
46
  "dependencies": {
45
47
  "mysql2": "^3.23.1",