@hasna/economy 0.2.26 → 0.2.28

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
@@ -615,6 +615,26 @@ function openDatabase(dbPath, skipSeed = false) {
615
615
  }
616
616
  return db;
617
617
  }
618
+ function quoteSqlIdent(identifier) {
619
+ return `"${identifier.replace(/"/g, '""')}"`;
620
+ }
621
+ function hasColumn(db, table, column) {
622
+ const columns = db.prepare(`PRAGMA table_info(${quoteSqlIdent(table)})`).all();
623
+ return columns.some((c) => c.name === column);
624
+ }
625
+ function addColumnIfMissing(db, table, column, definition) {
626
+ if (hasColumn(db, table, column))
627
+ return false;
628
+ try {
629
+ db.exec(`ALTER TABLE ${quoteSqlIdent(table)} ADD COLUMN ${quoteSqlIdent(column)} ${definition}`);
630
+ return true;
631
+ } catch (error) {
632
+ const message = error instanceof Error ? error.message : String(error);
633
+ if (/duplicate column name/i.test(message))
634
+ return true;
635
+ throw error;
636
+ }
637
+ }
618
638
  function initSchema(db) {
619
639
  db.exec(`
620
640
  CREATE TABLE IF NOT EXISTS requests (
@@ -785,59 +805,31 @@ function initSchema(db) {
785
805
  CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date);
786
806
  CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date);
787
807
  `);
788
- const cols = db.prepare(`PRAGMA table_info(requests)`).all();
789
- if (!cols.some((c) => c.name === "machine_id")) {
790
- db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
791
- db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
792
- }
793
- if (!cols.some((c) => c.name === "cache_create_5m_tokens")) {
794
- db.exec(`ALTER TABLE requests ADD COLUMN cache_create_5m_tokens INTEGER DEFAULT 0`);
808
+ addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
809
+ addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
810
+ if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
795
811
  db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
796
812
  }
797
- if (!cols.some((c) => c.name === "cache_create_1h_tokens")) {
798
- db.exec(`ALTER TABLE requests ADD COLUMN cache_create_1h_tokens INTEGER DEFAULT 0`);
799
- }
800
- if (!cols.some((c) => c.name === "cost_basis")) {
801
- db.exec(`ALTER TABLE requests ADD COLUMN cost_basis TEXT DEFAULT 'estimated'`);
802
- }
803
- if (!cols.some((c) => c.name === "attribution_tag")) {
804
- db.exec(`ALTER TABLE requests ADD COLUMN attribution_tag TEXT DEFAULT ''`);
805
- }
806
- if (!cols.some((c) => c.name === "updated_at")) {
807
- db.exec(`ALTER TABLE requests ADD COLUMN updated_at TEXT DEFAULT ''`);
813
+ addColumnIfMissing(db, "requests", "cache_create_1h_tokens", "INTEGER DEFAULT 0");
814
+ addColumnIfMissing(db, "requests", "cost_basis", `TEXT DEFAULT 'estimated'`);
815
+ addColumnIfMissing(db, "requests", "attribution_tag", `TEXT DEFAULT ''`);
816
+ if (addColumnIfMissing(db, "requests", "updated_at", `TEXT DEFAULT ''`)) {
808
817
  db.exec(`UPDATE requests SET updated_at = timestamp WHERE updated_at = '' OR updated_at IS NULL`);
809
818
  }
810
- if (!cols.some((c) => c.name === "synced_at")) {
811
- db.exec(`ALTER TABLE requests ADD COLUMN synced_at TEXT DEFAULT ''`);
812
- }
819
+ addColumnIfMissing(db, "requests", "synced_at", `TEXT DEFAULT ''`);
813
820
  for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
814
- if (!cols.some((c) => c.name === column)) {
815
- db.exec(`ALTER TABLE requests ADD COLUMN ${column} TEXT DEFAULT ''`);
816
- }
817
- }
818
- const sessionCols = db.prepare(`PRAGMA table_info(sessions)`).all();
819
- if (!sessionCols.some((c) => c.name === "attribution_tag")) {
820
- db.exec(`ALTER TABLE sessions ADD COLUMN attribution_tag TEXT DEFAULT ''`);
821
+ addColumnIfMissing(db, "requests", column, `TEXT DEFAULT ''`);
821
822
  }
822
- if (!sessionCols.some((c) => c.name === "updated_at")) {
823
- db.exec(`ALTER TABLE sessions ADD COLUMN updated_at TEXT DEFAULT ''`);
823
+ addColumnIfMissing(db, "sessions", "attribution_tag", `TEXT DEFAULT ''`);
824
+ if (addColumnIfMissing(db, "sessions", "updated_at", `TEXT DEFAULT ''`)) {
824
825
  db.exec(`UPDATE sessions SET updated_at = started_at WHERE updated_at = '' OR updated_at IS NULL`);
825
826
  }
826
- if (!sessionCols.some((c) => c.name === "synced_at")) {
827
- db.exec(`ALTER TABLE sessions ADD COLUMN synced_at TEXT DEFAULT ''`);
828
- }
827
+ addColumnIfMissing(db, "sessions", "synced_at", `TEXT DEFAULT ''`);
829
828
  for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
830
- if (!sessionCols.some((c) => c.name === column)) {
831
- db.exec(`ALTER TABLE sessions ADD COLUMN ${column} TEXT DEFAULT ''`);
832
- }
833
- }
834
- const pricingCols = db.prepare(`PRAGMA table_info(model_pricing)`).all();
835
- if (!pricingCols.some((c) => c.name === "cache_write_1h_per_1m")) {
836
- db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`);
837
- }
838
- if (!pricingCols.some((c) => c.name === "cache_storage_per_1m_hour")) {
839
- db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`);
829
+ addColumnIfMissing(db, "sessions", column, `TEXT DEFAULT ''`);
840
830
  }
831
+ addColumnIfMissing(db, "model_pricing", "cache_write_1h_per_1m", "REAL NOT NULL DEFAULT 0");
832
+ addColumnIfMissing(db, "model_pricing", "cache_storage_per_1m_hour", "REAL NOT NULL DEFAULT 0");
841
833
  db.exec(`
842
834
  CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
843
835
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
@@ -998,17 +990,22 @@ function querySummary(db, period, machine, allMachines = false) {
998
990
  const codexTotals = db.prepare(`
999
991
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1000
992
  COALESCE(SUM(total_tokens), 0) as tokens,
993
+ COALESCE(SUM(request_count), 0) as requests,
1001
994
  COUNT(*) as sessions
1002
995
  FROM sessions
1003
996
  WHERE ${sWhere}${machineClause}
1004
997
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1005
998
  `).get();
1006
- const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
999
+ const requestSessionCount = db.prepare(`
1000
+ SELECT COUNT(DISTINCT session_id) as sessions
1001
+ FROM requests
1002
+ WHERE ${rWhere}${machineClause}
1003
+ `).get();
1007
1004
  return {
1008
1005
  total_usd: r.total_usd + codexTotals.cost_usd,
1009
- requests: r.requests,
1006
+ requests: r.requests + codexTotals.requests,
1010
1007
  tokens: r.tokens + codexTotals.tokens,
1011
- sessions: sessionCount.sessions,
1008
+ sessions: requestSessionCount.sessions + codexTotals.sessions,
1012
1009
  period
1013
1010
  };
1014
1011
  }
@@ -1087,9 +1084,7 @@ function queryAgentBreakdown(db, period = "all") {
1087
1084
  }
1088
1085
  return [...groups.values()].sort((a, b) => b.api_equivalent_usd - a.api_equivalent_usd);
1089
1086
  }
1090
- function labelForPath(projectPath, projectName) {
1091
- if (projectName && projectName.trim() !== "")
1092
- return projectName;
1087
+ function pathProjectLabel(projectPath) {
1093
1088
  if (!projectPath)
1094
1089
  return "";
1095
1090
  const segments = projectPath.split("/").filter(Boolean);
@@ -1098,12 +1093,45 @@ function labelForPath(projectPath, projectName) {
1098
1093
  if (projectPrefix.test(seg))
1099
1094
  return seg;
1100
1095
  }
1101
- const generic = new Set(["web", "app", "apps", "packages", "src", "lib", "server", "client", "api", "frontend", "backend"]);
1096
+ const generic = new Set([
1097
+ "web",
1098
+ "app",
1099
+ "apps",
1100
+ "packages",
1101
+ "src",
1102
+ "lib",
1103
+ "server",
1104
+ "client",
1105
+ "api",
1106
+ "frontend",
1107
+ "backend",
1108
+ "home",
1109
+ "users",
1110
+ "workspace",
1111
+ "workspaces",
1112
+ "hasna"
1113
+ ]);
1102
1114
  for (let i = segments.length - 1;i >= 0; i--) {
1103
1115
  if (!generic.has(segments[i].toLowerCase()))
1104
1116
  return segments[i];
1105
1117
  }
1106
- return segments[segments.length - 1] ?? projectPath;
1118
+ return null;
1119
+ }
1120
+ function isRepoLikeLabel(label) {
1121
+ return /^(open|skill|hook|service|connect|platform|agent|tool|iapp|project|scaffold|capp)-/.test(label) || label.includes("-");
1122
+ }
1123
+ function labelForPath(projectPath, projectName) {
1124
+ const pathLabel = pathProjectLabel(projectPath);
1125
+ if (pathLabel && (!projectName || projectName.trim() === "" || isRepoLikeLabel(pathLabel)))
1126
+ return pathLabel;
1127
+ if (projectName && projectName.trim() !== "")
1128
+ return projectName;
1129
+ if (pathLabel)
1130
+ return pathLabel;
1131
+ return projectPath;
1132
+ }
1133
+ function groupKeyForPath(projectPath, projectName) {
1134
+ return labelForPath(projectPath, projectName).trim().toLowerCase();
1107
1135
  }
1108
1136
  function queryProjectBreakdown(db, period = "all") {
1109
1137
  const requestWhere = requestPeriodWhere(period);
@@ -1118,14 +1146,15 @@ function queryProjectBreakdown(db, period = "all") {
1118
1146
  const label = labelForPath(s.project_path, s.project_name);
1119
1147
  if (!label)
1120
1148
  continue;
1121
- const g = groups.get(label) ?? { sessionIds: [], samplePath: s.project_path };
1149
+ const key = groupKeyForPath(s.project_path, s.project_name);
1150
+ const g = groups.get(key) ?? { label, sessionIds: [], samplePath: s.project_path };
1122
1151
  g.sessionIds.push(s.id);
1123
1152
  if (!g.samplePath)
1124
1153
  g.samplePath = s.project_path;
1125
- groups.set(label, g);
1154
+ groups.set(key, g);
1126
1155
  }
1127
1156
  const result = [];
1128
- for (const [label, g] of groups.entries()) {
1157
+ for (const g of groups.values()) {
1129
1158
  const placeholders = g.sessionIds.map(() => "?").join(",");
1130
1159
  const reqStats = placeholders.length ? db.prepare(`
1131
1160
  SELECT
@@ -1156,7 +1185,7 @@ function queryProjectBreakdown(db, period = "all") {
1156
1185
  const lastActive = [reqStats.last_active, sessionOnlyStats.last_active].filter(Boolean).sort().at(-1) ?? "";
1157
1186
  result.push({
1158
1187
  project_path: g.samplePath,
1159
- project_name: label,
1188
+ project_name: g.label,
1160
1189
  sessions: totalSessions,
1161
1190
  requests: reqStats.requests + sessionOnlyStats.requests,
1162
1191
  total_tokens: reqStats.total_tokens + sessionOnlyStats.total_tokens,
@@ -1396,17 +1425,48 @@ function queryBillingSummary(db, period) {
1396
1425
  }
1397
1426
  return { total_usd: total, by_provider };
1398
1427
  }
1399
- function listMachines(db) {
1428
+ function listMachines(db, period = "all") {
1429
+ const rWhere = requestPeriodWhere(period);
1430
+ const sWhere = sessionPeriodWhere(period);
1400
1431
  return db.prepare(`
1432
+ WITH request_stats AS (
1433
+ SELECT
1434
+ machine_id,
1435
+ COUNT(DISTINCT session_id) as sessions,
1436
+ COUNT(*) as requests,
1437
+ COALESCE(SUM(cost_usd), 0) as total_cost_usd,
1438
+ MAX(timestamp) as last_active
1439
+ FROM requests
1440
+ WHERE machine_id != ''
1441
+ AND ${rWhere}
1442
+ GROUP BY machine_id
1443
+ ),
1444
+ session_only_stats AS (
1445
+ SELECT
1446
+ machine_id,
1447
+ COUNT(*) as sessions,
1448
+ COALESCE(SUM(request_count), 0) as requests,
1449
+ COALESCE(SUM(total_cost_usd), 0) as total_cost_usd,
1450
+ MAX(started_at) as last_active
1451
+ FROM sessions
1452
+ WHERE machine_id != ''
1453
+ AND ${sWhere}
1454
+ AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1455
+ GROUP BY machine_id
1456
+ ),
1457
+ combined AS (
1458
+ SELECT * FROM request_stats
1459
+ UNION ALL
1460
+ SELECT * FROM session_only_stats
1461
+ )
1401
1462
  SELECT
1402
- s.machine_id,
1403
- COUNT(DISTINCT s.id) as sessions,
1404
- COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
1405
- COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
1406
- MAX(s.started_at) as last_active
1407
- FROM sessions s
1408
- WHERE s.machine_id != ''
1409
- GROUP BY s.machine_id
1463
+ machine_id,
1464
+ COALESCE(SUM(sessions), 0) as sessions,
1465
+ COALESCE(SUM(requests), 0) as requests,
1466
+ COALESCE(SUM(total_cost_usd), 0) as total_cost_usd,
1467
+ MAX(last_active) as last_active
1468
+ FROM combined
1469
+ GROUP BY machine_id
1410
1470
  ORDER BY total_cost_usd DESC
1411
1471
  `).all();
1412
1472
  }
@@ -1488,17 +1548,21 @@ function listMachineRegistry(db) {
1488
1548
  }
1489
1549
  function dedupeRequests(db) {
1490
1550
  const dupes = db.prepare(`
1491
- SELECT source_request_id, agent, MIN(id) as keep_id, COUNT(*) as cnt
1551
+ SELECT source_request_id, agent, COALESCE(machine_id, '') as machine_id, MIN(id) as keep_id, COUNT(*) as cnt
1492
1552
  FROM requests
1493
1553
  WHERE source_request_id != '' AND source_request_id IS NOT NULL
1494
- GROUP BY source_request_id, agent
1554
+ GROUP BY source_request_id, agent, COALESCE(machine_id, '')
1495
1555
  HAVING cnt > 1
1496
1556
  `).all();
1497
1557
  let removed = 0;
1498
1558
  for (const row of dupes) {
1499
1559
  const result = db.prepare(`
1500
- DELETE FROM requests WHERE source_request_id = ? AND agent = ? AND id != ?
1501
- `).run(row.source_request_id, row.agent, row.keep_id);
1560
+ DELETE FROM requests
1561
+ WHERE source_request_id = ?
1562
+ AND agent = ?
1563
+ AND COALESCE(machine_id, '') = ?
1564
+ AND id != ?
1565
+ `).run(row.source_request_id, row.agent, row.machine_id, row.keep_id);
1502
1566
  removed += result.changes;
1503
1567
  }
1504
1568
  return removed;
@@ -4028,7 +4092,7 @@ __export(exports_config, {
4028
4092
  loadConfig: () => loadConfig2,
4029
4093
  getConfigValue: () => getConfigValue
4030
4094
  });
4031
- import { existsSync as existsSync12, readFileSync as readFileSync11, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
4095
+ import { existsSync as existsSync13, readFileSync as readFileSync11, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
4032
4096
  import { dirname as dirname2, join as join12 } from "path";
4033
4097
  function getConfigPath() {
4034
4098
  return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join12(getDataDir(), "config.json");
@@ -4036,7 +4100,7 @@ function getConfigPath() {
4036
4100
  function loadConfig2() {
4037
4101
  try {
4038
4102
  const configPath = getConfigPath();
4039
- if (existsSync12(configPath)) {
4103
+ if (existsSync13(configPath)) {
4040
4104
  const raw = readFileSync11(configPath, "utf-8");
4041
4105
  return { ...DEFAULTS, ...JSON.parse(raw) };
4042
4106
  }
@@ -4046,7 +4110,7 @@ function loadConfig2() {
4046
4110
  function saveConfig2(config) {
4047
4111
  const configPath = getConfigPath();
4048
4112
  const dir = dirname2(configPath);
4049
- if (!existsSync12(dir))
4113
+ if (!existsSync13(dir))
4050
4114
  mkdirSync3(dir, { recursive: true });
4051
4115
  writeFileSync2(configPath, JSON.stringify(config, null, 2) + `
4052
4116
  `);
@@ -4190,7 +4254,7 @@ var init_webhooks = __esm(() => {
4190
4254
  });
4191
4255
 
4192
4256
  // src/lib/watch-paths.ts
4193
- import { existsSync as existsSync13 } from "fs";
4257
+ import { existsSync as existsSync14 } from "fs";
4194
4258
  function getWatchPaths() {
4195
4259
  const p = agentPaths();
4196
4260
  const candidates = [
@@ -4203,7 +4267,7 @@ function getWatchPaths() {
4203
4267
  p.piSessions,
4204
4268
  p.hermesDir
4205
4269
  ];
4206
- return candidates.filter((path) => existsSync13(path));
4270
+ return candidates.filter((path) => existsSync14(path));
4207
4271
  }
4208
4272
  var init_watch_paths = __esm(() => {
4209
4273
  init_paths();
@@ -4373,7 +4437,7 @@ __export(exports_serve, {
4373
4437
  createHandler: () => createHandler
4374
4438
  });
4375
4439
  import { randomUUID as randomUUID2 } from "crypto";
4376
- import { existsSync as existsSync14 } from "fs";
4440
+ import { existsSync as existsSync15 } from "fs";
4377
4441
  import { resolve, sep } from "path";
4378
4442
  function json(data, status = 200) {
4379
4443
  return new Response(JSON.stringify(data), {
@@ -4438,13 +4502,13 @@ function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
4438
4502
  if (url.pathname.startsWith("/api") || url.pathname === "/health") {
4439
4503
  return apiHandler(req);
4440
4504
  }
4441
- if (existsSync14(dashboardDir)) {
4505
+ if (existsSync15(dashboardDir)) {
4442
4506
  const filePath = dashboardPath(dashboardDir, url.pathname);
4443
- if (filePath && existsSync14(filePath)) {
4507
+ if (filePath && existsSync15(filePath)) {
4444
4508
  return new Response(Bun.file(filePath));
4445
4509
  }
4446
4510
  const indexPath = dashboardPath(dashboardDir, "/");
4447
- if (indexPath && existsSync14(indexPath)) {
4511
+ if (indexPath && existsSync15(indexPath)) {
4448
4512
  return new Response(Bun.file(indexPath));
4449
4513
  }
4450
4514
  }
@@ -4479,7 +4543,7 @@ function createHandler(db) {
4479
4543
  const period = url.searchParams.get("period") ?? "month";
4480
4544
  return ok({
4481
4545
  summary: querySummary(db, period, undefined, true),
4482
- machines: listMachines(db),
4546
+ machines: listMachines(db, period),
4483
4547
  registry: listMachineRegistry(db),
4484
4548
  current_machine: getMachineId()
4485
4549
  });
@@ -4841,14 +4905,14 @@ __export(exports_menubar, {
4841
4905
  });
4842
4906
  import chalk6 from "chalk";
4843
4907
  import { execFileSync as execFileSync2 } from "child_process";
4844
- import { cpSync, existsSync as existsSync15, mkdirSync as mkdirSync4, rmSync, writeFileSync as writeFileSync3 } from "fs";
4908
+ import { cpSync, existsSync as existsSync16, mkdirSync as mkdirSync4, rmSync, writeFileSync as writeFileSync3 } from "fs";
4845
4909
  import { tmpdir, arch } from "os";
4846
4910
  import { join as join13 } from "path";
4847
4911
  function getArch() {
4848
4912
  return arch() === "arm64" ? "arm64" : "x86_64";
4849
4913
  }
4850
4914
  function isInstalled() {
4851
- return existsSync15(APP_PATH);
4915
+ return existsSync16(APP_PATH);
4852
4916
  }
4853
4917
  function isRunning() {
4854
4918
  try {
@@ -6182,6 +6246,7 @@ var TOP_LEVEL = [
6182
6246
  "init",
6183
6247
  "estimate",
6184
6248
  "fleet",
6249
+ "merge-db",
6185
6250
  "todos",
6186
6251
  "serve",
6187
6252
  "mcp",
@@ -6511,7 +6576,7 @@ function registerFleetCommands(program) {
6511
6576
  const db = openDatabase();
6512
6577
  const period = parsePeriod(opts.period, "today");
6513
6578
  const summary = querySummary(db, period, undefined, true);
6514
- const machines = listMachines(db);
6579
+ const machines = listMachines(db, period);
6515
6580
  const registry = listMachineRegistry(db);
6516
6581
  if (opts.json) {
6517
6582
  console.log(JSON.stringify({ period, summary, machines, registry }, null, 2));
@@ -6535,6 +6600,285 @@ function registerFleetCommands(program) {
6535
6600
  init_agents();
6536
6601
  init_sync_all();
6537
6602
  init_cloud_sync();
6603
+
6604
+ // src/lib/peer-sync.ts
6605
+ init_database();
6606
+ init_package_metadata();
6607
+ import { Database as BunDatabase2 } from "bun:sqlite";
6608
+ import { existsSync as existsSync12 } from "fs";
6609
+ var GENERIC_PEER_TABLES = [
6610
+ "usage_snapshots",
6611
+ "subscriptions",
6612
+ "billing_daily",
6613
+ "savings_daily",
6614
+ "budgets",
6615
+ "goals",
6616
+ "model_pricing",
6617
+ "machines"
6618
+ ];
6619
+ function quoteIdent(identifier) {
6620
+ return `"${identifier.replace(/"/g, '""')}"`;
6621
+ }
6622
+ function tableExists(db, table) {
6623
+ const row = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table);
6624
+ return Boolean(row);
6625
+ }
6626
+ function tableColumns(db, table) {
6627
+ if (!tableExists(db, table))
6628
+ return [];
6629
+ return db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all();
6630
+ }
6631
+ function commonColumns(source, target, table) {
6632
+ const sourceCols = new Set(tableColumns(source, table).map((c) => c.name));
6633
+ return tableColumns(target, table).map((c) => c.name).filter((c) => sourceCols.has(c));
6634
+ }
6635
+ function primaryKeyColumns(db, table) {
6636
+ return tableColumns(db, table).filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
6637
+ }
6638
+ function selectRows(source, table, columns) {
6639
+ if (columns.length === 0)
6640
+ return [];
6641
+ const select = columns.map(quoteIdent).join(", ");
6642
+ return source.prepare(`SELECT ${select} FROM ${quoteIdent(table)}`).all();
6643
+ }
6644
+ function rowByKey(target, table, keyColumns, row) {
6645
+ if (keyColumns.length === 0)
6646
+ return null;
6647
+ if (keyColumns.some((c) => row[c] == null))
6648
+ return null;
6649
+ const where = keyColumns.map((c) => `${quoteIdent(c)} = ?`).join(" AND ");
6650
+ return target.prepare(`SELECT * FROM ${quoteIdent(table)} WHERE ${where}`).get(...keyColumns.map((c) => row[c]));
6651
+ }
6652
+ function hasId(target, table, id) {
6653
+ return target.prepare(`SELECT id, machine_id FROM ${quoteIdent(table)} WHERE id = ?`).get(id);
6654
+ }
6655
+ function shouldReplace(source, existing) {
6656
+ if (!existing)
6657
+ return true;
6658
+ const sourceUpdated = source["updated_at"];
6659
+ const existingUpdated = existing["updated_at"];
6660
+ if (typeof sourceUpdated === "string" && typeof existingUpdated === "string" && existingUpdated !== "") {
6661
+ return sourceUpdated >= existingUpdated;
6662
+ }
6663
+ return true;
6664
+ }
6665
+ function normalizeRow(row, columns, sourceMachine, now) {
6666
+ const next = { ...row };
6667
+ if (columns.includes("machine_id") && (!next["machine_id"] || next["machine_id"] === "")) {
6668
+ next["machine_id"] = sourceMachine;
6669
+ }
6670
+ if (columns.includes("updated_at") && (!next["updated_at"] || next["updated_at"] === "")) {
6671
+ next["updated_at"] = next["timestamp"] ?? next["started_at"] ?? next["created_at"] ?? now;
6672
+ }
6673
+ if (columns.includes("synced_at") && next["synced_at"] == null)
6674
+ next["synced_at"] = "";
6675
+ if (columns.includes("attribution_tag") && next["attribution_tag"] == null)
6676
+ next["attribution_tag"] = "";
6677
+ return next;
6678
+ }
6679
+ function insertOrReplace(target, table, columns, row) {
6680
+ const colSql = columns.map(quoteIdent).join(", ");
6681
+ const placeholders = columns.map(() => "?").join(", ");
6682
+ target.prepare(`
6683
+ INSERT OR REPLACE INTO ${quoteIdent(table)} (${colSql})
6684
+ VALUES (${placeholders})
6685
+ `).run(...columns.map((c) => row[c] ?? null));
6686
+ }
6687
+ function collisionId(target, table, machine, originalId) {
6688
+ const base = `${machine || "peer"}:${originalId}`;
6689
+ const baseRow = hasId(target, table, base);
6690
+ if (!baseRow || String(baseRow["machine_id"] ?? "") === machine)
6691
+ return base;
6692
+ for (let i = 2;; i++) {
6693
+ const candidate = `${base}:${i}`;
6694
+ const row = hasId(target, table, candidate);
6695
+ if (!row || String(row["machine_id"] ?? "") === machine)
6696
+ return candidate;
6697
+ }
6698
+ }
6699
+ function mergeIdentityTable(target, source, table, sourceMachine, now, sessionIdMap) {
6700
+ const stats = { table, inserted: 0, updated: 0, skipped: 0, collisions: 0 };
6701
+ const columns = commonColumns(source, target, table);
6702
+ const rows = selectRows(source, table, columns);
6703
+ const idMap = new Map;
6704
+ for (const raw of rows) {
6705
+ const row = normalizeRow(raw, columns, sourceMachine, now);
6706
+ const originalId = String(row["id"] ?? "");
6707
+ if (!originalId) {
6708
+ stats.skipped++;
6709
+ continue;
6710
+ }
6711
+ const machine = String(row["machine_id"] ?? "");
6712
+ const directExisting = hasId(target, table, originalId);
6713
+ if (directExisting && String(directExisting["machine_id"] ?? "") !== machine) {
6714
+ row["id"] = collisionId(target, table, machine, originalId);
6715
+ stats.collisions++;
6716
+ }
6717
+ if (table === "requests" && sessionIdMap) {
6718
+ const originalSessionId = String(row["session_id"] ?? "");
6719
+ row["session_id"] = sessionIdMap.get(originalSessionId) ?? originalSessionId;
6720
+ }
6721
+ const existing = hasId(target, table, String(row["id"]));
6722
+ idMap.set(originalId, String(row["id"]));
6723
+ if (existing && !shouldReplace(row, existing)) {
6724
+ stats.skipped++;
6725
+ continue;
6726
+ }
6727
+ insertOrReplace(target, table, columns, row);
6728
+ if (existing)
6729
+ stats.updated++;
6730
+ else
6731
+ stats.inserted++;
6732
+ }
6733
+ return { stats, idMap };
6734
+ }
6735
+ function mergeProjects(target, source) {
6736
+ const table = "projects";
6737
+ const stats = { table, inserted: 0, updated: 0, skipped: 0, collisions: 0 };
6738
+ const columns = commonColumns(source, target, table);
6739
+ const rows = selectRows(source, table, columns);
6740
+ for (const raw of rows) {
6741
+ const row = { ...raw };
6742
+ const path = String(row["path"] ?? "");
6743
+ const id = String(row["id"] ?? "");
6744
+ if (!path || !id) {
6745
+ stats.skipped++;
6746
+ continue;
6747
+ }
6748
+ const existingByPath = target.prepare(`SELECT * FROM projects WHERE path = ?`).get(path);
6749
+ if (existingByPath) {
6750
+ row["id"] = existingByPath["id"] ?? id;
6751
+ insertOrReplace(target, table, columns, row);
6752
+ stats.updated++;
6753
+ continue;
6754
+ }
6755
+ const existingById = target.prepare(`SELECT * FROM projects WHERE id = ?`).get(id);
6756
+ if (existingById && String(existingById["path"] ?? "") !== path) {
6757
+ row["id"] = `peer:${id}`;
6758
+ stats.collisions++;
6759
+ while (target.prepare(`SELECT id FROM projects WHERE id = ?`).get(row["id"])) {
6760
+ row["id"] = `peer:${String(row["id"])}`;
6761
+ }
6762
+ }
6763
+ insertOrReplace(target, table, columns, row);
6764
+ stats.inserted++;
6765
+ }
6766
+ return stats;
6767
+ }
6768
+ function mergeGenericTable(target, source, table, sourceMachine, now) {
6769
+ const stats = { table, inserted: 0, updated: 0, skipped: 0, collisions: 0 };
6770
+ const columns = commonColumns(source, target, table);
6771
+ const keyColumns = primaryKeyColumns(target, table).filter((c) => columns.includes(c));
6772
+ const rows = selectRows(source, table, columns);
6773
+ for (const raw of rows) {
6774
+ const row = normalizeRow(raw, columns, sourceMachine, now);
6775
+ const existing = rowByKey(target, table, keyColumns, row);
6776
+ if (existing && !shouldReplace(row, existing)) {
6777
+ stats.skipped++;
6778
+ continue;
6779
+ }
6780
+ insertOrReplace(target, table, columns, row);
6781
+ if (existing)
6782
+ stats.updated++;
6783
+ else
6784
+ stats.inserted++;
6785
+ }
6786
+ return stats;
6787
+ }
6788
+ function detectSourceMachine(source, fallback) {
6789
+ if (fallback && fallback.trim())
6790
+ return fallback.trim();
6791
+ const counts = new Map;
6792
+ for (const table of ["sessions", "requests", "usage_snapshots"]) {
6793
+ if (!tableExists(source, table))
6794
+ continue;
6795
+ const rows = source.prepare(`
6796
+ SELECT machine_id, COUNT(*) as cnt
6797
+ FROM ${quoteIdent(table)}
6798
+ WHERE machine_id != '' AND machine_id IS NOT NULL
6799
+ GROUP BY machine_id
6800
+ `).all();
6801
+ for (const row of rows) {
6802
+ counts.set(row.machine_id, (counts.get(row.machine_id) ?? 0) + row.cnt);
6803
+ }
6804
+ }
6805
+ let best = "";
6806
+ let bestCount = -1;
6807
+ for (const [machine, count] of counts.entries()) {
6808
+ if (count > bestCount) {
6809
+ best = machine;
6810
+ bestCount = count;
6811
+ }
6812
+ }
6813
+ return best || "peer";
6814
+ }
6815
+ function ensureMachineRegistry(target, machine, now) {
6816
+ if (!machine)
6817
+ return;
6818
+ target.prepare(`
6819
+ INSERT INTO machines (machine_id, hostname, last_seen_at, last_push_at, last_pull_at, economy_version, updated_at)
6820
+ VALUES (?, ?, ?, NULL, ?, ?, ?)
6821
+ ON CONFLICT(machine_id) DO UPDATE SET
6822
+ hostname = COALESCE(NULLIF(machines.hostname, ''), excluded.hostname),
6823
+ last_seen_at = CASE
6824
+ WHEN machines.last_seen_at IS NULL OR machines.last_seen_at < excluded.last_seen_at THEN excluded.last_seen_at
6825
+ ELSE machines.last_seen_at
6826
+ END,
6827
+ last_pull_at = excluded.last_pull_at,
6828
+ economy_version = excluded.economy_version,
6829
+ updated_at = excluded.updated_at
6830
+ `).run(machine, machine, now, now, packageMetadata.version, now);
6831
+ }
6832
+ function openSourceDatabase(path) {
6833
+ try {
6834
+ return new BunDatabase2(path, { readonly: true });
6835
+ } catch {
6836
+ return new BunDatabase2(path);
6837
+ }
6838
+ }
6839
+ function mergePeerDatabase(target, sourcePath, opts = {}) {
6840
+ if (!existsSync12(sourcePath))
6841
+ throw new Error(`source database does not exist: ${sourcePath}`);
6842
+ const source = openSourceDatabase(sourcePath);
6843
+ const now = opts.now ?? new Date().toISOString();
6844
+ const sourceMachine = detectSourceMachine(source, opts.sourceMachine);
6845
+ const tables = [];
6846
+ try {
6847
+ target.exec("PRAGMA foreign_keys = OFF");
6848
+ target.exec("BEGIN IMMEDIATE");
6849
+ try {
6850
+ tables.push(mergeProjects(target, source));
6851
+ const sessionMerge = mergeIdentityTable(target, source, "sessions", sourceMachine, now);
6852
+ tables.push(sessionMerge.stats);
6853
+ tables.push(mergeIdentityTable(target, source, "requests", sourceMachine, now, sessionMerge.idMap).stats);
6854
+ for (const table of GENERIC_PEER_TABLES) {
6855
+ tables.push(mergeGenericTable(target, source, table, sourceMachine, now));
6856
+ }
6857
+ ensureMachineRegistry(target, sourceMachine, now);
6858
+ target.exec("COMMIT");
6859
+ } catch (err) {
6860
+ target.exec("ROLLBACK");
6861
+ throw err;
6862
+ } finally {
6863
+ target.exec("PRAGMA foreign_keys = ON");
6864
+ }
6865
+ } finally {
6866
+ source.close();
6867
+ }
6868
+ const deduped = dedupeRequests(target);
6869
+ const rowsWritten = tables.reduce((sum, table) => sum + table.inserted + table.updated, 0);
6870
+ const collisions = tables.reduce((sum, table) => sum + table.collisions, 0);
6871
+ return {
6872
+ source_path: sourcePath,
6873
+ source_machine: sourceMachine,
6874
+ rows_written: rowsWritten,
6875
+ collisions,
6876
+ deduped,
6877
+ tables: tables.filter((t) => t.inserted || t.updated || t.skipped || t.collisions)
6878
+ };
6879
+ }
6880
+
6881
+ // src/cli/index.ts
6538
6882
  init_database();
6539
6883
  init_database();
6540
6884
  init_billing();
@@ -7361,6 +7705,21 @@ program.command("machines").description("List all machines that have synced data
7361
7705
  ${chalk7.dim("Current machine:")} ${chalk7.bold(current)}`);
7362
7706
  console.log();
7363
7707
  });
7708
+ program.command("merge-db <source-db>").description("Merge another Economy SQLite database into this machine").option("--source-machine <id>", "Machine id to use for source rows that do not have one").option("--json", "Output JSON").action((sourceDb, opts) => {
7709
+ const db = openDatabase();
7710
+ const result = mergePeerDatabase(db, sourceDb, { sourceMachine: opts.sourceMachine });
7711
+ if (opts.json) {
7712
+ console.log(JSON.stringify(result, null, 2));
7713
+ return;
7714
+ }
7715
+ console.log();
7716
+ console.log(chalk7.bold.cyan(` Merged Economy DB \u2014 ${result.source_machine}`));
7717
+ console.log(` Rows written: ${fmtCount(result.rows_written)} \xB7 collisions remapped: ${fmtCount(result.collisions)} \xB7 deduped: ${fmtCount(result.deduped)}`);
7718
+ for (const table of result.tables) {
7719
+ console.log(` ${chalk7.white(table.table.padEnd(16))}` + ` inserted ${fmtCount(table.inserted).padStart(6)}` + ` updated ${fmtCount(table.updated).padStart(6)}` + ` skipped ${fmtCount(table.skipped).padStart(6)}` + ` collisions ${fmtCount(table.collisions).padStart(3)}`);
7720
+ }
7721
+ console.log();
7722
+ });
7364
7723
  program.command("export").description("Export data as CSV").option("--type <type>", "Data type: sessions or requests", "sessions").option("--period <period>", "Period: today|week|month|all", "month").option("--output <file>", "Output file path (default: stdout)").action(async (opts) => {
7365
7724
  await autoSync();
7366
7725
  const db = openDatabase();