@hasna/economy 0.2.29 → 0.2.31

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.
Files changed (66) hide show
  1. package/README.md +8 -8
  2. package/dist/cli/commands/tui.d.ts +1 -1
  3. package/dist/cli/commands/tui.d.ts.map +1 -1
  4. package/dist/cli/index.js +850 -187
  5. package/dist/db/database.d.ts +4 -2
  6. package/dist/db/database.d.ts.map +1 -1
  7. package/dist/db/storage-adapter.d.ts +34 -0
  8. package/dist/db/storage-adapter.d.ts.map +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1057 -54
  12. package/dist/ingest/billing.d.ts +1 -1
  13. package/dist/ingest/billing.d.ts.map +1 -1
  14. package/dist/ingest/claude-quota.d.ts +1 -1
  15. package/dist/ingest/claude-quota.d.ts.map +1 -1
  16. package/dist/ingest/claude.d.ts +1 -1
  17. package/dist/ingest/claude.d.ts.map +1 -1
  18. package/dist/ingest/codex-quota.d.ts +1 -1
  19. package/dist/ingest/codex-quota.d.ts.map +1 -1
  20. package/dist/ingest/codex.d.ts +1 -1
  21. package/dist/ingest/codex.d.ts.map +1 -1
  22. package/dist/ingest/cursor.d.ts +1 -1
  23. package/dist/ingest/cursor.d.ts.map +1 -1
  24. package/dist/ingest/gemini.d.ts +1 -1
  25. package/dist/ingest/gemini.d.ts.map +1 -1
  26. package/dist/ingest/hermes.d.ts +1 -1
  27. package/dist/ingest/hermes.d.ts.map +1 -1
  28. package/dist/ingest/opencode.d.ts +1 -1
  29. package/dist/ingest/opencode.d.ts.map +1 -1
  30. package/dist/ingest/otel.d.ts +1 -1
  31. package/dist/ingest/otel.d.ts.map +1 -1
  32. package/dist/ingest/pi.d.ts +1 -1
  33. package/dist/ingest/pi.d.ts.map +1 -1
  34. package/dist/ingest/plugin.d.ts +1 -1
  35. package/dist/ingest/plugin.d.ts.map +1 -1
  36. package/dist/lib/billing-diff.d.ts +1 -1
  37. package/dist/lib/billing-diff.d.ts.map +1 -1
  38. package/dist/lib/cloud-sync.d.ts +9 -2
  39. package/dist/lib/cloud-sync.d.ts.map +1 -1
  40. package/dist/lib/open-projects.d.ts +3 -2
  41. package/dist/lib/open-projects.d.ts.map +1 -1
  42. package/dist/lib/peer-sync.d.ts +1 -1
  43. package/dist/lib/peer-sync.d.ts.map +1 -1
  44. package/dist/lib/pricing.d.ts +1 -1
  45. package/dist/lib/pricing.d.ts.map +1 -1
  46. package/dist/lib/remote-storage.d.ts +15 -0
  47. package/dist/lib/remote-storage.d.ts.map +1 -0
  48. package/dist/lib/savings.d.ts +1 -1
  49. package/dist/lib/savings.d.ts.map +1 -1
  50. package/dist/lib/spikes.d.ts +1 -1
  51. package/dist/lib/spikes.d.ts.map +1 -1
  52. package/dist/lib/storage-sync.d.ts +27 -0
  53. package/dist/lib/storage-sync.d.ts.map +1 -0
  54. package/dist/lib/sync-all.d.ts +1 -1
  55. package/dist/lib/sync-all.d.ts.map +1 -1
  56. package/dist/lib/webhooks.d.ts +1 -1
  57. package/dist/lib/webhooks.d.ts.map +1 -1
  58. package/dist/mcp/http.d.ts +1 -0
  59. package/dist/mcp/http.d.ts.map +1 -1
  60. package/dist/mcp/index.js +518 -40
  61. package/dist/mcp/server.d.ts.map +1 -1
  62. package/dist/otel/index.js +442 -15
  63. package/dist/server/index.js +510 -51
  64. package/dist/server/serve.d.ts +1 -1
  65. package/dist/server/serve.d.ts.map +1 -1
  66. package/package.json +4 -4
@@ -17,6 +17,60 @@ var __export = (target, all) => {
17
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  var __require = import.meta.require;
19
19
 
20
+ // src/db/storage-adapter.ts
21
+ import { Database as BunDatabase } from "bun:sqlite";
22
+
23
+ class SqliteAdapter {
24
+ db;
25
+ constructor(path) {
26
+ this.db = new BunDatabase(path, { create: true });
27
+ }
28
+ run(sql, ...params) {
29
+ const result = this.db.prepare(sql).run(...params);
30
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
31
+ }
32
+ get(sql, ...params) {
33
+ return this.db.prepare(sql).get(...params);
34
+ }
35
+ all(sql, ...params) {
36
+ return this.db.prepare(sql).all(...params);
37
+ }
38
+ exec(sql) {
39
+ this.db.exec(sql);
40
+ }
41
+ query(sql) {
42
+ return this.db.query(sql);
43
+ }
44
+ prepare(sql) {
45
+ const statement = this.db.prepare(sql);
46
+ return {
47
+ run(...params) {
48
+ const result = statement.run(...params);
49
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
50
+ },
51
+ get(...params) {
52
+ return statement.get(...params);
53
+ },
54
+ all(...params) {
55
+ return statement.all(...params);
56
+ },
57
+ finalize() {
58
+ statement.finalize();
59
+ }
60
+ };
61
+ }
62
+ close() {
63
+ this.db.close();
64
+ }
65
+ transaction(fn) {
66
+ return this.db.transaction(fn)();
67
+ }
68
+ get raw() {
69
+ return this.db;
70
+ }
71
+ }
72
+ var init_storage_adapter = () => {};
73
+
20
74
  // src/lib/pricing.ts
21
75
  var exports_pricing = {};
22
76
  __export(exports_pricing, {
@@ -513,7 +567,6 @@ var init_pricing = __esm(() => {
513
567
  });
514
568
 
515
569
  // src/db/database.ts
516
- import { SqliteAdapter as Database } from "@hasna/cloud";
517
570
  import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
518
571
  import { hostname } from "os";
519
572
  import { homedir } from "os";
@@ -556,7 +609,7 @@ function openDatabase(dbPath, skipSeed = false) {
556
609
  if (dir && !existsSync(dir))
557
610
  mkdirSync(dir, { recursive: true });
558
611
  }
559
- const db = new Database(path);
612
+ const db = new SqliteAdapter(path);
560
613
  db.exec("PRAGMA journal_mode = WAL");
561
614
  db.exec("PRAGMA busy_timeout = 5000");
562
615
  db.exec("PRAGMA foreign_keys = ON");
@@ -1308,13 +1361,17 @@ function queryDailyBreakdown(db, days = 30, machine) {
1308
1361
  ORDER BY date ASC
1309
1362
  `).all(...params);
1310
1363
  }
1311
- function queryHourlyBreakdown(db, machine) {
1312
- const machineClause = machine ? " AND machine_id = ?" : "";
1313
- const params = machine ? [machine] : [];
1364
+ function queryHourlyBreakdown(db, machine, hours) {
1365
+ const clauses = hours == null ? [`DATE(timestamp) = DATE('now')`] : [`DATETIME(timestamp) >= DATETIME('now', ?)`];
1366
+ const params = hours == null ? [] : [`-${hours} hours`];
1367
+ if (machine) {
1368
+ clauses.push("machine_id = ?");
1369
+ params.push(machine);
1370
+ }
1314
1371
  return db.prepare(`
1315
1372
  SELECT STRFTIME('%H', timestamp) as hour, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
1316
1373
  FROM requests
1317
- WHERE DATE(timestamp) = DATE('now')${machineClause}
1374
+ WHERE ${clauses.join(" AND ")}
1318
1375
  GROUP BY STRFTIME('%H', timestamp), agent
1319
1376
  ORDER BY hour ASC
1320
1377
  `).all(...params);
@@ -1579,7 +1636,10 @@ function dedupeRequests(db) {
1579
1636
  }
1580
1637
  return removed;
1581
1638
  }
1582
- var init_database = () => {};
1639
+ var init_database = __esm(() => {
1640
+ init_storage_adapter();
1641
+ init_storage_adapter();
1642
+ });
1583
1643
 
1584
1644
  // src/db/pg-migrations.ts
1585
1645
  var exports_pg_migrations = {};
@@ -1778,13 +1838,13 @@ __export(exports_billing, {
1778
1838
  });
1779
1839
  import { readFileSync as readFileSync9 } from "fs";
1780
1840
  function getAnthropicAdminKey() {
1781
- return process.env["HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY"] ?? process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
1841
+ return process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
1782
1842
  }
1783
1843
  function getOpenAIAdminKey() {
1784
- return process.env["HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY"] ?? process.env["OPENAI_ADMIN_API_KEY"] ?? null;
1844
+ return process.env["OPENAI_ADMIN_API_KEY"] ?? null;
1785
1845
  }
1786
1846
  function getGeminiBillingExportPath() {
1787
- return process.env["HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["HASNAXYZ_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["GEMINI_BILLING_EXPORT_PATH"] ?? null;
1847
+ return process.env["HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH"] ?? process.env["GEMINI_BILLING_EXPORT_PATH"] ?? null;
1788
1848
  }
1789
1849
  function toISODate(d) {
1790
1850
  return d.toISOString().substring(0, 10);
@@ -1860,7 +1920,7 @@ function parseBillingRows(content) {
1860
1920
  async function syncAnthropicBilling(db, opts = {}) {
1861
1921
  const key = getAnthropicAdminKey();
1862
1922
  if (!key)
1863
- throw new Error("Missing Anthropic admin key (HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY)");
1923
+ throw new Error("Missing Anthropic admin key (ANTHROPIC_ADMIN_API_KEY)");
1864
1924
  const now = new Date;
1865
1925
  const end = opts.toDate ? new Date(opts.toDate) : new Date(now.getTime() + 24 * 3600000);
1866
1926
  const days = opts.days ?? 31;
@@ -1909,7 +1969,7 @@ async function syncAnthropicBilling(db, opts = {}) {
1909
1969
  async function syncOpenAIBilling(db, opts = {}) {
1910
1970
  const key = getOpenAIAdminKey();
1911
1971
  if (!key)
1912
- throw new Error("Missing OpenAI admin key (HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY)");
1972
+ throw new Error("Missing OpenAI admin key (OPENAI_ADMIN_API_KEY)");
1913
1973
  const now = new Date;
1914
1974
  const end = opts.toDate ? new Date(opts.toDate) : now;
1915
1975
  const days = opts.days ?? 31;
@@ -1961,7 +2021,7 @@ async function syncGeminiBilling(db, opts = {}) {
1961
2021
  return {
1962
2022
  days: 0,
1963
2023
  totalUsd: 0,
1964
- skipped: "Missing Gemini billing export path (HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH, HASNAXYZ_ECONOMY_GEMINI_BILLING_EXPORT_PATH, or GEMINI_BILLING_EXPORT_PATH)"
2024
+ skipped: "Missing Gemini billing export path (HASNA_ECONOMY_GEMINI_BILLING_EXPORT_PATH or GEMINI_BILLING_EXPORT_PATH)"
1965
2025
  };
1966
2026
  }
1967
2027
  const now = new Date;
@@ -2002,22 +2062,26 @@ __export(exports_open_projects, {
2002
2062
  syncOpenProjectsRegistry: () => syncOpenProjectsRegistry
2003
2063
  });
2004
2064
  async function syncOpenProjectsRegistry(db, listActiveProjects) {
2005
- let listProjects2 = listActiveProjects;
2006
- if (!listProjects2) {
2065
+ let listOpenProjects = listActiveProjects;
2066
+ if (!listOpenProjects) {
2007
2067
  const projectsApi = await import("@hasna/projects");
2008
- listProjects2 = projectsApi.listProjects;
2068
+ listOpenProjects = projectsApi.listProjects ?? projectsApi.listWorkspaces;
2009
2069
  }
2010
- const projects = listProjects2({ status: "active", limit: 5000 });
2070
+ if (!listOpenProjects) {
2071
+ throw new Error("@hasna/projects does not expose listWorkspaces or listProjects");
2072
+ }
2073
+ const projects = listOpenProjects({ status: "active", limit: 5000 });
2011
2074
  let imported = 0;
2012
2075
  let skipped = 0;
2013
2076
  for (const project of projects) {
2014
- if (!project.path) {
2077
+ const path = project.path ?? project.primary_path ?? "";
2078
+ if (!path) {
2015
2079
  skipped++;
2016
2080
  continue;
2017
2081
  }
2018
2082
  upsertProject(db, {
2019
2083
  id: project.id,
2020
- path: project.path,
2084
+ path,
2021
2085
  name: project.name,
2022
2086
  description: project.description,
2023
2087
  tags: project.tags ?? [],
@@ -2033,9 +2097,9 @@ var init_open_projects = __esm(() => {
2033
2097
 
2034
2098
  // src/lib/config.ts
2035
2099
  import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
2036
- import { dirname, join as join9 } from "path";
2100
+ import { dirname as dirname2, join as join10 } from "path";
2037
2101
  function getConfigPath() {
2038
- return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join9(getDataDir(), "config.json");
2102
+ return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join10(getDataDir(), "config.json");
2039
2103
  }
2040
2104
  function loadConfig() {
2041
2105
  try {
@@ -2657,7 +2721,7 @@ init_pricing();
2657
2721
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
2658
2722
  import { homedir as homedir3 } from "os";
2659
2723
  import { join as join3, basename as basename2 } from "path";
2660
- import { Database as BunDatabase } from "bun:sqlite";
2724
+ import { Database as BunDatabase2 } from "bun:sqlite";
2661
2725
  var DEFAULT_CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
2662
2726
  var DEFAULT_CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
2663
2727
  var CODEX_INGEST_VERSION = "rollout-aggregate-v3";
@@ -2695,7 +2759,7 @@ function openCodexDb(dbPath, verbose) {
2695
2759
  for (const readonly of [true, false]) {
2696
2760
  let codexDb = null;
2697
2761
  try {
2698
- codexDb = readonly ? new BunDatabase(dbPath, { readonly: true }) : new BunDatabase(dbPath);
2762
+ codexDb = readonly ? new BunDatabase2(dbPath, { readonly: true }) : new BunDatabase2(dbPath);
2699
2763
  codexDb.prepare("PRAGMA schema_version").get();
2700
2764
  return codexDb;
2701
2765
  } catch (error) {
@@ -3761,6 +3825,8 @@ init_database();
3761
3825
 
3762
3826
  // src/lib/cloud-sync.ts
3763
3827
  init_database();
3828
+ import { homedir as homedir9, platform } from "os";
3829
+ import { dirname, join as join9 } from "path";
3764
3830
 
3765
3831
  // src/lib/package-metadata.ts
3766
3832
  import { readFileSync as readFileSync8 } from "fs";
@@ -3778,6 +3844,366 @@ function getPackageMetadata() {
3778
3844
  }
3779
3845
  var packageMetadata = getPackageMetadata();
3780
3846
 
3847
+ // src/lib/remote-storage.ts
3848
+ import pg from "pg";
3849
+ function translatePlaceholders(sql) {
3850
+ let index = 0;
3851
+ return sql.replace(/\?/g, () => `$${++index}`);
3852
+ }
3853
+ function normalizeParams(params) {
3854
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
3855
+ return flat.map((value) => value === undefined ? null : value);
3856
+ }
3857
+ function sslConfigFor(connectionString) {
3858
+ return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
3859
+ }
3860
+
3861
+ class PgAdapterAsync {
3862
+ pool;
3863
+ constructor(source) {
3864
+ this.pool = typeof source === "string" ? new pg.Pool({ connectionString: source, ssl: sslConfigFor(source) }) : source;
3865
+ }
3866
+ async run(sql, ...params) {
3867
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
3868
+ return { changes: result.rowCount ?? 0, lastInsertRowid: 0 };
3869
+ }
3870
+ async get(sql, ...params) {
3871
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
3872
+ return result.rows[0] ?? null;
3873
+ }
3874
+ async all(sql, ...params) {
3875
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
3876
+ return result.rows;
3877
+ }
3878
+ async exec(sql) {
3879
+ await this.pool.query(translatePlaceholders(sql));
3880
+ }
3881
+ async close() {
3882
+ await this.pool.end();
3883
+ }
3884
+ async transaction(fn) {
3885
+ const client = await this.pool.connect();
3886
+ try {
3887
+ await client.query("BEGIN");
3888
+ const result = await fn(client);
3889
+ await client.query("COMMIT");
3890
+ return result;
3891
+ } catch (error) {
3892
+ await client.query("ROLLBACK");
3893
+ throw error;
3894
+ } finally {
3895
+ client.release();
3896
+ }
3897
+ }
3898
+ get raw() {
3899
+ return this.pool;
3900
+ }
3901
+ }
3902
+
3903
+ // src/lib/storage-sync.ts
3904
+ async function syncPush(local, remote, options) {
3905
+ const tables = await getTableOrder(remote, options.tables);
3906
+ return syncTransfer(local, remote, { ...options, tables }, "push");
3907
+ }
3908
+ async function syncPull(remote, local, options) {
3909
+ const tables = await getTableOrder(remote, options.tables);
3910
+ return syncTransfer(remote, local, { ...options, tables }, "pull");
3911
+ }
3912
+ function quoteIdent(identifier) {
3913
+ return `"${identifier.replace(/"/g, '""')}"`;
3914
+ }
3915
+ async function getTableOrder(remote, tables) {
3916
+ if (tables.length <= 1)
3917
+ return tables;
3918
+ try {
3919
+ const rows = await remote.all(`
3920
+ SELECT DISTINCT
3921
+ tc.table_name AS source_table,
3922
+ ccu.table_name AS referenced_table
3923
+ FROM information_schema.table_constraints tc
3924
+ JOIN information_schema.constraint_column_usage ccu
3925
+ ON tc.constraint_name = ccu.constraint_name
3926
+ AND tc.table_schema = ccu.table_schema
3927
+ WHERE tc.constraint_type = 'FOREIGN KEY'
3928
+ AND tc.table_schema = 'public'
3929
+ `);
3930
+ if (rows.length > 0)
3931
+ return topoSort(tables, rows);
3932
+ } catch {}
3933
+ return tables;
3934
+ }
3935
+ function topoSort(tables, foreignKeys) {
3936
+ const allowed = new Set(tables);
3937
+ const deps = new Map;
3938
+ for (const table of tables)
3939
+ deps.set(table, new Set);
3940
+ for (const fk of foreignKeys) {
3941
+ if (allowed.has(fk.source_table) && allowed.has(fk.referenced_table)) {
3942
+ deps.get(fk.source_table)?.add(fk.referenced_table);
3943
+ }
3944
+ }
3945
+ const sorted = [];
3946
+ const visited = new Set;
3947
+ const visiting = new Set;
3948
+ function visit(table) {
3949
+ if (visited.has(table))
3950
+ return;
3951
+ if (visiting.has(table)) {
3952
+ visited.add(table);
3953
+ sorted.push(table);
3954
+ return;
3955
+ }
3956
+ visiting.add(table);
3957
+ for (const dep of deps.get(table) ?? [])
3958
+ visit(dep);
3959
+ visiting.delete(table);
3960
+ visited.add(table);
3961
+ sorted.push(table);
3962
+ }
3963
+ for (const table of tables)
3964
+ visit(table);
3965
+ return sorted;
3966
+ }
3967
+ async function resolvePrimaryKeys(source, target, table, option) {
3968
+ if (option)
3969
+ return Array.isArray(option) ? option : [option];
3970
+ const sourceKeys = await detectPrimaryKeys(source, table);
3971
+ if (sourceKeys.length > 0)
3972
+ return sourceKeys;
3973
+ return detectPrimaryKeys(target, table);
3974
+ }
3975
+ async function detectPrimaryKeys(adapter, table) {
3976
+ if (isAsyncAdapter(adapter)) {
3977
+ try {
3978
+ const rows = await adapter.all(`
3979
+ SELECT kcu.column_name, kcu.ordinal_position
3980
+ FROM information_schema.table_constraints tc
3981
+ JOIN information_schema.key_column_usage kcu
3982
+ ON tc.constraint_name = kcu.constraint_name
3983
+ AND tc.table_schema = kcu.table_schema
3984
+ WHERE tc.constraint_type = 'PRIMARY KEY'
3985
+ AND tc.table_schema = 'public'
3986
+ AND tc.table_name = ?
3987
+ ORDER BY kcu.ordinal_position
3988
+ `, table);
3989
+ return rows.map((row) => row.column_name);
3990
+ } catch {
3991
+ return [];
3992
+ }
3993
+ }
3994
+ try {
3995
+ const rows = adapter.all(`PRAGMA table_info(${quoteIdent(table)})`);
3996
+ return rows.filter((row) => row.pk > 0).sort((a, b) => a.pk - b.pk).map((row) => row.name);
3997
+ } catch {
3998
+ return [];
3999
+ }
4000
+ }
4001
+ async function ensureTablesExist(source, target, tables) {
4002
+ if (!isAsyncAdapter(source) || isAsyncAdapter(target))
4003
+ return;
4004
+ for (const table of tables)
4005
+ await ensureTableInSqliteFromPg(target, source, table);
4006
+ }
4007
+ async function ensureTableInSqliteFromPg(target, source, table) {
4008
+ const existing = target.all(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table);
4009
+ if (existing.length > 0)
4010
+ return;
4011
+ const columns = await source.all(`
4012
+ SELECT column_name, data_type, is_nullable
4013
+ FROM information_schema.columns
4014
+ WHERE table_schema = 'public' AND table_name = ?
4015
+ ORDER BY ordinal_position
4016
+ `, table);
4017
+ if (columns.length === 0)
4018
+ return;
4019
+ const primaryKeys = new Set(await detectPrimaryKeys(source, table));
4020
+ const definitions = columns.filter((column) => !["tsvector", "tsquery"].includes(column.data_type.toLowerCase())).map((column) => {
4021
+ const type = pgTypeToSqlite(column.data_type);
4022
+ const notNull = column.is_nullable === "NO" && !primaryKeys.has(column.column_name) ? " NOT NULL" : "";
4023
+ return `${quoteIdent(column.column_name)} ${type}${notNull}`;
4024
+ });
4025
+ if (primaryKeys.size > 0) {
4026
+ definitions.push(`PRIMARY KEY (${[...primaryKeys].map(quoteIdent).join(", ")})`);
4027
+ }
4028
+ target.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${definitions.join(", ")})`);
4029
+ }
4030
+ function pgTypeToSqlite(pgType) {
4031
+ const type = pgType.toLowerCase();
4032
+ if (type.includes("int") || ["bigint", "smallint", "serial", "bigserial"].includes(type))
4033
+ return "INTEGER";
4034
+ if (type.includes("bool"))
4035
+ return "INTEGER";
4036
+ if (type.includes("float") || type.includes("double") || ["real", "numeric", "decimal"].includes(type))
4037
+ return "REAL";
4038
+ if (type === "bytea")
4039
+ return "BLOB";
4040
+ return "TEXT";
4041
+ }
4042
+ async function filterColumnsForTarget(target, table, columns) {
4043
+ if (columns.includes("machine_id") && table !== "machines")
4044
+ await ensureMachineIdColumnInTarget(target, table);
4045
+ try {
4046
+ if (isAsyncAdapter(target)) {
4047
+ const rows2 = await target.all(`
4048
+ SELECT column_name
4049
+ FROM information_schema.columns
4050
+ WHERE table_schema = 'public' AND table_name = ?
4051
+ `, table);
4052
+ if (rows2.length === 0)
4053
+ return columns;
4054
+ const targetColumns2 = new Set(rows2.map((row) => row.column_name));
4055
+ return columns.filter((column) => targetColumns2.has(column));
4056
+ }
4057
+ const rows = target.all(`PRAGMA table_info(${quoteIdent(table)})`);
4058
+ if (rows.length === 0)
4059
+ return columns;
4060
+ const targetColumns = new Set(rows.map((row) => row.name));
4061
+ return columns.filter((column) => targetColumns.has(column));
4062
+ } catch {
4063
+ return columns;
4064
+ }
4065
+ }
4066
+ async function ensureMachineIdColumnInTarget(target, table) {
4067
+ if (isAsyncAdapter(target)) {
4068
+ const rows2 = await target.all(`
4069
+ SELECT column_name
4070
+ FROM information_schema.columns
4071
+ WHERE table_schema = 'public' AND table_name = ? AND column_name = 'machine_id'
4072
+ `, table);
4073
+ if (rows2.length === 0)
4074
+ await target.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN machine_id TEXT DEFAULT ''`);
4075
+ return;
4076
+ }
4077
+ const rows = target.all(`PRAGMA table_info(${quoteIdent(table)})`);
4078
+ if (!rows.some((row) => row.name === "machine_id")) {
4079
+ target.exec(`ALTER TABLE ${quoteIdent(table)} ADD COLUMN machine_id TEXT DEFAULT ''`);
4080
+ }
4081
+ }
4082
+ async function syncTransfer(source, target, options, _direction) {
4083
+ const { tables, onProgress, batchSize = 100, conflictColumn = "updated_at", primaryKey } = options;
4084
+ const results = [];
4085
+ const sqliteTarget = isAsyncAdapter(target) ? null : target;
4086
+ await ensureTablesExist(source, target, tables);
4087
+ if (sqliteTarget) {
4088
+ try {
4089
+ sqliteTarget.exec("PRAGMA foreign_keys = OFF");
4090
+ } catch {}
4091
+ }
4092
+ try {
4093
+ for (let i = 0;i < tables.length; i++) {
4094
+ const table = tables[i];
4095
+ const result = { table, rowsRead: 0, rowsWritten: 0, rowsSkipped: 0, errors: [] };
4096
+ try {
4097
+ onProgress?.({ table, phase: "reading", rowsRead: 0, rowsWritten: 0, totalTables: tables.length, currentTableIndex: i });
4098
+ const rows = await readAll(source, `SELECT * FROM ${quoteIdent(table)}`);
4099
+ result.rowsRead = rows.length;
4100
+ if (rows.length === 0) {
4101
+ onProgress?.({ table, phase: "done", rowsRead: 0, rowsWritten: 0, totalTables: tables.length, currentTableIndex: i });
4102
+ results.push(result);
4103
+ continue;
4104
+ }
4105
+ const sourceColumns = Object.keys(rows[0]);
4106
+ const columns = await filterColumnsForTarget(target, table, sourceColumns);
4107
+ const primaryKeys = await resolvePrimaryKeys(source, target, table, primaryKey);
4108
+ if (primaryKeys.length === 0) {
4109
+ result.errors.push(`Table "${table}" has no primary key; inserted without conflict handling`);
4110
+ for (const batch of batches(rows, batchSize)) {
4111
+ await insertBatch(target, table, columns, batch);
4112
+ result.rowsWritten += batch.length;
4113
+ }
4114
+ results.push(result);
4115
+ continue;
4116
+ }
4117
+ const missingKeys = primaryKeys.filter((key) => !columns.includes(key));
4118
+ if (missingKeys.length > 0) {
4119
+ result.errors.push(`Table "${table}" missing primary key column(s): ${missingKeys.join(", ")}`);
4120
+ results.push(result);
4121
+ continue;
4122
+ }
4123
+ onProgress?.({ table, phase: "writing", rowsRead: result.rowsRead, rowsWritten: 0, totalTables: tables.length, currentTableIndex: i });
4124
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
4125
+ const newestWinsColumn = columns.includes(conflictColumn) ? conflictColumn : undefined;
4126
+ for (const batch of batches(rows, batchSize)) {
4127
+ await upsertBatch(target, table, columns, updateColumns, primaryKeys, batch, newestWinsColumn);
4128
+ result.rowsWritten += batch.length;
4129
+ onProgress?.({ table, phase: "writing", rowsRead: result.rowsRead, rowsWritten: result.rowsWritten, totalTables: tables.length, currentTableIndex: i });
4130
+ }
4131
+ onProgress?.({ table, phase: "done", rowsRead: result.rowsRead, rowsWritten: result.rowsWritten, totalTables: tables.length, currentTableIndex: i });
4132
+ } catch (error) {
4133
+ result.errors.push(error instanceof Error ? error.message : String(error));
4134
+ }
4135
+ results.push(result);
4136
+ }
4137
+ } finally {
4138
+ if (sqliteTarget) {
4139
+ try {
4140
+ sqliteTarget.exec("PRAGMA foreign_keys = ON");
4141
+ } catch {}
4142
+ }
4143
+ }
4144
+ return results;
4145
+ }
4146
+ function batches(rows, size) {
4147
+ const result = [];
4148
+ for (let offset = 0;offset < rows.length; offset += size)
4149
+ result.push(rows.slice(offset, offset + size));
4150
+ return result;
4151
+ }
4152
+ async function upsertBatch(target, table, columns, updateColumns, primaryKeys, batch, conflictColumn) {
4153
+ if (batch.length === 0 || columns.length === 0)
4154
+ return;
4155
+ const fallbackKey = primaryKeys[0] ?? columns[0] ?? "id";
4156
+ const columnList = columns.map(quoteIdent).join(", ");
4157
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
4158
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
4159
+ const whereClause = conflictColumn && updateColumns.includes(conflictColumn) ? ` WHERE ${quoteIdent(table)}.${quoteIdent(conflictColumn)} IS NULL OR EXCLUDED.${quoteIdent(conflictColumn)} >= ${quoteIdent(table)}.${quoteIdent(conflictColumn)}` : "";
4160
+ if (isAsyncAdapter(target)) {
4161
+ const placeholders2 = batch.map((_, rowIndex) => `(${columns.map((__, columnIndex) => `$${rowIndex * columns.length + columnIndex + 1}`).join(", ")})`).join(", ");
4162
+ const params2 = batch.flatMap((row) => columns.map((column) => row[column] ?? null));
4163
+ await target.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES ${placeholders2}
4164
+ ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, ...params2);
4165
+ return;
4166
+ }
4167
+ const placeholders = batch.map(() => `(${columns.map(() => "?").join(", ")})`).join(", ");
4168
+ const params = batch.flatMap((row) => columns.map((column) => coerceForSqlite(row[column])));
4169
+ target.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES ${placeholders}
4170
+ ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, ...params);
4171
+ }
4172
+ async function insertBatch(target, table, columns, batch) {
4173
+ if (batch.length === 0 || columns.length === 0)
4174
+ return;
4175
+ const columnList = columns.map(quoteIdent).join(", ");
4176
+ if (isAsyncAdapter(target)) {
4177
+ const placeholders2 = batch.map((_, rowIndex) => `(${columns.map((__, columnIndex) => `$${rowIndex * columns.length + columnIndex + 1}`).join(", ")})`).join(", ");
4178
+ const params2 = batch.flatMap((row) => columns.map((column) => row[column] ?? null));
4179
+ await target.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES ${placeholders2}`, ...params2);
4180
+ return;
4181
+ }
4182
+ const placeholders = batch.map(() => `(${columns.map(() => "?").join(", ")})`).join(", ");
4183
+ const params = batch.flatMap((row) => columns.map((column) => coerceForSqlite(row[column])));
4184
+ target.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES ${placeholders}`, ...params);
4185
+ }
4186
+ function coerceForSqlite(value) {
4187
+ if (value === null || value === undefined)
4188
+ return null;
4189
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
4190
+ return value;
4191
+ if (value instanceof Date)
4192
+ return value.toISOString();
4193
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
4194
+ return value;
4195
+ if (typeof value === "object")
4196
+ return JSON.stringify(value);
4197
+ return String(value);
4198
+ }
4199
+ function isAsyncAdapter(adapter) {
4200
+ return adapter instanceof PgAdapterAsync;
4201
+ }
4202
+ async function readAll(adapter, sql) {
4203
+ const rows = adapter.all(sql);
4204
+ return rows instanceof Promise ? await rows : rows;
4205
+ }
4206
+
3781
4207
  // src/lib/cloud-sync.ts
3782
4208
  var CLOUD_TABLES = [
3783
4209
  "requests",
@@ -3811,7 +4237,6 @@ async function getCloudPg() {
3811
4237
  if (!url) {
3812
4238
  throw new Error("Missing ECONOMY_CLOUD_DATABASE_URL (or HASNA_ECONOMY_CLOUD_DATABASE_URL)");
3813
4239
  }
3814
- const { PgAdapterAsync } = await import("@hasna/cloud");
3815
4240
  return new PgAdapterAsync(url);
3816
4241
  }
3817
4242
  async function runCloudMigrations(cloud) {
@@ -3821,40 +4246,52 @@ async function runCloudMigrations(cloud) {
3821
4246
  }
3822
4247
  }
3823
4248
  async function cloudPush(opts) {
3824
- const { syncPush, SqliteAdapter } = await import("@hasna/cloud");
3825
4249
  const cloud = await getCloudPg();
3826
- const local = new SqliteAdapter(getDbPath());
3827
- await runCloudMigrations(cloud);
3828
- const tables = opts?.tables ?? [...CLOUD_TABLES];
3829
- const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
3830
- const rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3831
- touchMachineRegistry(local, "push");
3832
- local.close();
3833
- await cloud.close();
3834
- return { rows, machine: getMachineId() };
4250
+ const local = openDatabase(getDbPath(), true);
4251
+ try {
4252
+ await runCloudMigrations(cloud);
4253
+ touchMachineRegistry(local, "push");
4254
+ const tables = resolveCloudTables(opts?.tables);
4255
+ const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
4256
+ const rows = results.reduce((sum, result) => sum + result.rowsWritten, 0);
4257
+ return { rows, machine: getMachineId() };
4258
+ } finally {
4259
+ local.close();
4260
+ await cloud.close();
4261
+ }
3835
4262
  }
3836
4263
  async function cloudPull(opts) {
3837
- const { syncPull, SqliteAdapter } = await import("@hasna/cloud");
3838
4264
  const cloud = await getCloudPg();
3839
- const local = new SqliteAdapter(getDbPath());
3840
- await runCloudMigrations(cloud);
3841
- const tables = opts?.tables ?? [...CLOUD_TABLES];
3842
- const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
3843
- const rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3844
- touchMachineRegistry(local, "pull");
3845
- local.close();
3846
- await cloud.close();
3847
- setLastCloudPull();
3848
- return { rows, machine: getMachineId() };
4265
+ const local = openDatabase(getDbPath(), true);
4266
+ try {
4267
+ await runCloudMigrations(cloud);
4268
+ const tables = resolveCloudTables(opts?.tables);
4269
+ const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
4270
+ const rows = results.reduce((sum, result) => sum + result.rowsWritten, 0);
4271
+ touchMachineRegistry(local, "pull");
4272
+ setLastCloudPull();
4273
+ return { rows, machine: getMachineId() };
4274
+ } finally {
4275
+ local.close();
4276
+ await cloud.close();
4277
+ }
3849
4278
  }
3850
4279
  function setLastCloudPull(at = new Date().toISOString()) {
3851
- const db = openDatabase();
3852
- db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES ('cloud', 'last_pull_at', ?)`).run(at);
4280
+ const db = openDatabase(undefined, true);
4281
+ try {
4282
+ db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES ('cloud', 'last_pull_at', ?)`).run(at);
4283
+ } finally {
4284
+ db.close();
4285
+ }
3853
4286
  }
3854
4287
  function getLastCloudPull() {
3855
- const db = openDatabase();
3856
- const row = db.prepare(`SELECT value FROM ingest_state WHERE source = 'cloud' AND key = 'last_pull_at'`).get();
3857
- return row?.value ?? null;
4288
+ const db = openDatabase(undefined, true);
4289
+ try {
4290
+ const row = db.prepare(`SELECT value FROM ingest_state WHERE source = 'cloud' AND key = 'last_pull_at'`).get();
4291
+ return row?.value ?? null;
4292
+ } finally {
4293
+ db.close();
4294
+ }
3858
4295
  }
3859
4296
  function shouldPullFromCloud() {
3860
4297
  if (!getCloudDatabaseUrl())
@@ -3900,6 +4337,19 @@ function touchMachineRegistry(db, direction) {
3900
4337
  updated_at = excluded.updated_at
3901
4338
  `).run(machine, machine, now, direction === "push" ? now : null, direction === "pull" ? now : null, packageMetadata.version, now, direction, direction);
3902
4339
  }
4340
+ function resolveCloudTables(tables) {
4341
+ if (!tables || tables.length === 0)
4342
+ return [...CLOUD_TABLES];
4343
+ const allowed = new Set(CLOUD_TABLES);
4344
+ const requested = tables.map((table) => table.trim()).filter(Boolean);
4345
+ const invalid = requested.filter((table) => !allowed.has(table));
4346
+ if (invalid.length > 0) {
4347
+ throw new Error(`Unknown economy sync table(s): ${invalid.join(", ")}`);
4348
+ }
4349
+ return requested;
4350
+ }
4351
+ var SCHEDULE_CONFIG_DIR = join9(homedir9(), ".hasna", "economy");
4352
+ var SCHEDULE_CONFIG_PATH = join9(SCHEDULE_CONFIG_DIR, "cloud-sync-schedule.json");
3903
4353
 
3904
4354
  // src/lib/sync-all.ts
3905
4355
  async function syncAll(db, opts = {}) {
@@ -4177,7 +4627,16 @@ function createHandler(db) {
4177
4627
  }
4178
4628
  if (path === "/api/hourly" && method === "GET") {
4179
4629
  const machine = url.searchParams.get("machine") ?? undefined;
4180
- return ok(queryHourlyBreakdown(db, machine));
4630
+ const rawHours = url.searchParams.get("hours");
4631
+ let hours;
4632
+ if (rawHours != null) {
4633
+ const parsedHours = Number(rawHours);
4634
+ if (!Number.isInteger(parsedHours) || parsedHours < 1 || parsedHours > 48) {
4635
+ return err("hours must be between 1 and 48");
4636
+ }
4637
+ hours = parsedHours;
4638
+ }
4639
+ return ok(queryHourlyBreakdown(db, machine, hours));
4181
4640
  }
4182
4641
  if (path === "/api/sessions" && method === "GET") {
4183
4642
  const agent = url.searchParams.get("agent");