@hasna/economy 0.3.4 → 0.3.5

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/dist/cli/index.js CHANGED
@@ -6394,11 +6394,16 @@ function resolveWorkerPath() {
6394
6394
  return candidate;
6395
6395
  return candidates[0];
6396
6396
  }
6397
+ function isTransientConnError(message) {
6398
+ const m = message.toLowerCase();
6399
+ return m.includes("econnreset") || m.includes("econnrefused") || m.includes("etimedout") || m.includes("connection terminated") || m.includes("connection ended") || m.includes("timeout exceeded when trying to connect") || m.includes("terminating connection") || m.includes("server closed the connection");
6400
+ }
6397
6401
 
6398
6402
  class SyncPgAdapter {
6399
6403
  worker;
6400
6404
  port;
6401
6405
  counter = 0;
6406
+ sleeper = new Int32Array(new SharedArrayBuffer(4));
6402
6407
  constructor(dsn) {
6403
6408
  const workerPath = resolveWorkerPath();
6404
6409
  this.worker = new Worker(workerPath, { workerData: { dsn } });
@@ -6407,25 +6412,45 @@ class SyncPgAdapter {
6407
6412
  this.port = channel.port1;
6408
6413
  this.worker.postMessage({ type: "init", port: channel.port2 }, [channel.port2]);
6409
6414
  }
6410
- query(sql, params) {
6411
- const pgSql = translateSqliteDates(translateSql(sql, "pg"));
6412
- const pgParams = translateParams(params);
6415
+ drainReply(expectedId) {
6416
+ const deadline = Date.now() + 30000;
6417
+ for (;; ) {
6418
+ const received = receiveMessageOnPort(this.port);
6419
+ if (received) {
6420
+ const reply = received.message;
6421
+ if (reply.id === expectedId)
6422
+ return reply;
6423
+ continue;
6424
+ }
6425
+ if (Date.now() > deadline)
6426
+ throw new Error("SyncPgAdapter: no reply from worker (timeout)");
6427
+ Atomics.wait(this.sleeper, 0, 0, 1);
6428
+ }
6429
+ }
6430
+ queryOnce(pgSql, pgParams) {
6413
6431
  const id = ++this.counter;
6414
6432
  const signal = new SharedArrayBuffer(4);
6415
6433
  const sig = new Int32Array(signal);
6416
6434
  Atomics.store(sig, 0, 0);
6417
6435
  this.worker.postMessage({ id, sql: pgSql, params: pgParams, signal });
6418
6436
  Atomics.wait(sig, 0, 0);
6419
- let received = receiveMessageOnPort(this.port);
6420
- for (let attempt = 0;!received && attempt < 1e6; attempt++)
6421
- received = receiveMessageOnPort(this.port);
6422
- if (!received)
6423
- throw new Error("SyncPgAdapter: no reply from worker");
6424
- const reply = received.message;
6437
+ const reply = this.drainReply(id);
6425
6438
  if (!reply.ok)
6426
6439
  throw new Error(reply.error);
6427
6440
  return { rows: reply.rows, rowCount: reply.rowCount };
6428
6441
  }
6442
+ query(sql, params) {
6443
+ const pgSql = translateSqliteDates(translateSql(sql, "pg"));
6444
+ const pgParams = translateParams(params);
6445
+ try {
6446
+ return this.queryOnce(pgSql, pgParams);
6447
+ } catch (err) {
6448
+ if (err instanceof Error && isTransientConnError(err.message)) {
6449
+ return this.queryOnce(pgSql, pgParams);
6450
+ }
6451
+ throw err;
6452
+ }
6453
+ }
6429
6454
  run(sql, ...params) {
6430
6455
  const result = this.query(sql, params);
6431
6456
  return { changes: result.rowCount, lastInsertRowid: result.rows?.[0]?.["id"] ?? 0 };
@@ -6,11 +6,31 @@ import { type DbAdapter, type PreparedStatement, type RunResult } from '@hasna/c
6
6
  * `DATE(col)` casts through `timestamptz`. Ordered most-specific first.
7
7
  */
8
8
  export declare function translateSqliteDates(sql: string): string;
9
+ /**
10
+ * Connection-level failures where the query provably never executed (the pool
11
+ * could not hand out or keep a live backend). These are safe to retry once —
12
+ * the pg pool discards the dead client and dials a fresh one on the next query,
13
+ * so a single retry transparently absorbs RDS failover / idle-drop / connect
14
+ * timeout blips instead of surfacing a 500 the caller has to retry by hand.
15
+ */
16
+ export declare function isTransientConnError(message: string): boolean;
9
17
  export declare class SyncPgAdapter implements DbAdapter {
10
18
  private readonly worker;
11
19
  private readonly port;
12
20
  private counter;
21
+ private readonly sleeper;
13
22
  constructor(dsn: string);
23
+ /**
24
+ * Drain the reply matching `expectedId`. The reply is enqueued on the port
25
+ * before the worker notifies, but cross-thread delivery can lag under CPU
26
+ * pressure — so poll on a wall-clock deadline (yielding via a 1ms Atomics.wait
27
+ * between empty reads) rather than a fixed iteration count, which could give
28
+ * up prematurely on a busy box and throw a spurious 500. Replies whose id does
29
+ * not match are stale results of a previously abandoned (timed-out) query;
30
+ * discard them so the port never cross-talks between requests.
31
+ */
32
+ private drainReply;
33
+ private queryOnce;
14
34
  private query;
15
35
  run(sql: string, ...params: unknown[]): RunResult;
16
36
  get(sql: string, ...params: unknown[]): unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"sync-pg.d.ts","sourceRoot":"","sources":["../../src/db/sync-pg.ts"],"names":[],"mappings":"AAeA,OAAO,EAAiC,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAIpH;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiCxD;AAaD,qBAAa,aAAc,YAAW,SAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,OAAO,CAAI;gBAEP,GAAG,EAAE,MAAM;IAYvB,OAAO,CAAC,KAAK;IAsBb,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS;IAIjD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO;IAG/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE;IAGjD,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAGvB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB;IASvC,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC;IAI9B,KAAK,IAAI,IAAI;CAGd"}
1
+ {"version":3,"file":"sync-pg.d.ts","sourceRoot":"","sources":["../../src/db/sync-pg.ts"],"names":[],"mappings":"AAeA,OAAO,EAAiC,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAIpH;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiCxD;AAaD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAY7D;AAED,qBAAa,aAAc,YAAW,SAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,OAAO,CAAI;IAInB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2C;gBAEvD,GAAG,EAAE,MAAM;IAYvB;;;;;;;;OAQG;IACH,OAAO,CAAC,UAAU;IAclB,OAAO,CAAC,SAAS;IAcjB,OAAO,CAAC,KAAK;IAcb,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,SAAS;IAIjD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO;IAG/C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE;IAGjD,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAGvB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB;IASvC,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC;IAI9B,KAAK,IAAI,IAAI;CAGd"}
@@ -1988,11 +1988,16 @@ function resolveWorkerPath() {
1988
1988
  return candidate;
1989
1989
  return candidates[0];
1990
1990
  }
1991
+ function isTransientConnError(message) {
1992
+ const m = message.toLowerCase();
1993
+ return m.includes("econnreset") || m.includes("econnrefused") || m.includes("etimedout") || m.includes("connection terminated") || m.includes("connection ended") || m.includes("timeout exceeded when trying to connect") || m.includes("terminating connection") || m.includes("server closed the connection");
1994
+ }
1991
1995
 
1992
1996
  class SyncPgAdapter {
1993
1997
  worker;
1994
1998
  port;
1995
1999
  counter = 0;
2000
+ sleeper = new Int32Array(new SharedArrayBuffer(4));
1996
2001
  constructor(dsn) {
1997
2002
  const workerPath = resolveWorkerPath();
1998
2003
  this.worker = new Worker(workerPath, { workerData: { dsn } });
@@ -2001,25 +2006,45 @@ class SyncPgAdapter {
2001
2006
  this.port = channel.port1;
2002
2007
  this.worker.postMessage({ type: "init", port: channel.port2 }, [channel.port2]);
2003
2008
  }
2004
- query(sql, params) {
2005
- const pgSql = translateSqliteDates(translateSql(sql, "pg"));
2006
- const pgParams = translateParams(params);
2009
+ drainReply(expectedId) {
2010
+ const deadline = Date.now() + 30000;
2011
+ for (;; ) {
2012
+ const received = receiveMessageOnPort(this.port);
2013
+ if (received) {
2014
+ const reply = received.message;
2015
+ if (reply.id === expectedId)
2016
+ return reply;
2017
+ continue;
2018
+ }
2019
+ if (Date.now() > deadline)
2020
+ throw new Error("SyncPgAdapter: no reply from worker (timeout)");
2021
+ Atomics.wait(this.sleeper, 0, 0, 1);
2022
+ }
2023
+ }
2024
+ queryOnce(pgSql, pgParams) {
2007
2025
  const id = ++this.counter;
2008
2026
  const signal = new SharedArrayBuffer(4);
2009
2027
  const sig = new Int32Array(signal);
2010
2028
  Atomics.store(sig, 0, 0);
2011
2029
  this.worker.postMessage({ id, sql: pgSql, params: pgParams, signal });
2012
2030
  Atomics.wait(sig, 0, 0);
2013
- let received = receiveMessageOnPort(this.port);
2014
- for (let attempt = 0;!received && attempt < 1e6; attempt++)
2015
- received = receiveMessageOnPort(this.port);
2016
- if (!received)
2017
- throw new Error("SyncPgAdapter: no reply from worker");
2018
- const reply = received.message;
2031
+ const reply = this.drainReply(id);
2019
2032
  if (!reply.ok)
2020
2033
  throw new Error(reply.error);
2021
2034
  return { rows: reply.rows, rowCount: reply.rowCount };
2022
2035
  }
2036
+ query(sql, params) {
2037
+ const pgSql = translateSqliteDates(translateSql(sql, "pg"));
2038
+ const pgParams = translateParams(params);
2039
+ try {
2040
+ return this.queryOnce(pgSql, pgParams);
2041
+ } catch (err) {
2042
+ if (err instanceof Error && isTransientConnError(err.message)) {
2043
+ return this.queryOnce(pgSql, pgParams);
2044
+ }
2045
+ throw err;
2046
+ }
2047
+ }
2023
2048
  run(sql, ...params) {
2024
2049
  const result = this.query(sql, params);
2025
2050
  return { changes: result.rowCount, lastInsertRowid: result.rows?.[0]?.["id"] ?? 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API + web dashboard for Claude Code, Codex, Gemini, OpenCode, Cursor, Pi, and Hermes",
5
5
  "repository": {
6
6
  "type": "git",