@hasna/economy 0.2.30 → 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.
- package/README.md +6 -6
- package/dist/cli/commands/tui.d.ts +1 -1
- package/dist/cli/commands/tui.d.ts.map +1 -1
- package/dist/cli/index.js +850 -187
- package/dist/db/database.d.ts +4 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/storage-adapter.d.ts +34 -0
- package/dist/db/storage-adapter.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1057 -54
- package/dist/ingest/billing.d.ts +1 -1
- package/dist/ingest/billing.d.ts.map +1 -1
- package/dist/ingest/claude-quota.d.ts +1 -1
- package/dist/ingest/claude-quota.d.ts.map +1 -1
- package/dist/ingest/claude.d.ts +1 -1
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex-quota.d.ts +1 -1
- package/dist/ingest/codex-quota.d.ts.map +1 -1
- package/dist/ingest/codex.d.ts +1 -1
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/ingest/cursor.d.ts +1 -1
- package/dist/ingest/cursor.d.ts.map +1 -1
- package/dist/ingest/gemini.d.ts +1 -1
- package/dist/ingest/gemini.d.ts.map +1 -1
- package/dist/ingest/hermes.d.ts +1 -1
- package/dist/ingest/hermes.d.ts.map +1 -1
- package/dist/ingest/opencode.d.ts +1 -1
- package/dist/ingest/opencode.d.ts.map +1 -1
- package/dist/ingest/otel.d.ts +1 -1
- package/dist/ingest/otel.d.ts.map +1 -1
- package/dist/ingest/pi.d.ts +1 -1
- package/dist/ingest/pi.d.ts.map +1 -1
- package/dist/ingest/plugin.d.ts +1 -1
- package/dist/ingest/plugin.d.ts.map +1 -1
- package/dist/lib/billing-diff.d.ts +1 -1
- package/dist/lib/billing-diff.d.ts.map +1 -1
- package/dist/lib/cloud-sync.d.ts +9 -2
- package/dist/lib/cloud-sync.d.ts.map +1 -1
- package/dist/lib/open-projects.d.ts +3 -2
- package/dist/lib/open-projects.d.ts.map +1 -1
- package/dist/lib/peer-sync.d.ts +1 -1
- package/dist/lib/peer-sync.d.ts.map +1 -1
- package/dist/lib/pricing.d.ts +1 -1
- package/dist/lib/pricing.d.ts.map +1 -1
- package/dist/lib/remote-storage.d.ts +15 -0
- package/dist/lib/remote-storage.d.ts.map +1 -0
- package/dist/lib/savings.d.ts +1 -1
- package/dist/lib/savings.d.ts.map +1 -1
- package/dist/lib/spikes.d.ts +1 -1
- package/dist/lib/spikes.d.ts.map +1 -1
- package/dist/lib/storage-sync.d.ts +27 -0
- package/dist/lib/storage-sync.d.ts.map +1 -0
- package/dist/lib/sync-all.d.ts +1 -1
- package/dist/lib/sync-all.d.ts.map +1 -1
- package/dist/lib/webhooks.d.ts +1 -1
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/mcp/index.js +514 -38
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/otel/index.js +442 -15
- package/dist/server/index.js +510 -51
- package/dist/server/serve.d.ts +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
1313
|
-
const params =
|
|
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
|
|
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["
|
|
1841
|
+
return process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
|
|
1782
1842
|
}
|
|
1783
1843
|
function getOpenAIAdminKey() {
|
|
1784
|
-
return process.env["
|
|
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["
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
2006
|
-
if (!
|
|
2065
|
+
let listOpenProjects = listActiveProjects;
|
|
2066
|
+
if (!listOpenProjects) {
|
|
2007
2067
|
const projectsApi = await import("@hasna/projects");
|
|
2008
|
-
|
|
2068
|
+
listOpenProjects = projectsApi.listProjects ?? projectsApi.listWorkspaces;
|
|
2009
2069
|
}
|
|
2010
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2100
|
+
import { dirname as dirname2, join as join10 } from "path";
|
|
2037
2101
|
function getConfigPath() {
|
|
2038
|
-
return process.env["HASNA_ECONOMY_CONFIG_PATH"] ??
|
|
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
|
|
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
|
|
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 =
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
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 =
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3857
|
-
|
|
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
|
-
|
|
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");
|