@hasna/cloud 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -155,7 +155,7 @@ var require_arrayParser = __commonJS((exports, module) => {
155
155
  };
156
156
  });
157
157
 
158
- // node_modules/postgres-date/index.js
158
+ // node_modules/pg-types/node_modules/postgres-date/index.js
159
159
  var require_postgres_date = __commonJS((exports, module) => {
160
160
  var DATE_TIME = /(\d{1,})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d{1,})?.*?( BC)?$/;
161
161
  var DATE = /^(\d{1,})-(\d{2})-(\d{2})( BC)?$/;
@@ -257,7 +257,7 @@ var require_mutable = __commonJS((exports, module) => {
257
257
  }
258
258
  });
259
259
 
260
- // node_modules/postgres-interval/index.js
260
+ // node_modules/pg-types/node_modules/postgres-interval/index.js
261
261
  var require_postgres_interval = __commonJS((exports, module) => {
262
262
  var extend = require_mutable();
263
263
  module.exports = PostgresInterval;
@@ -349,7 +349,7 @@ var require_postgres_interval = __commonJS((exports, module) => {
349
349
  }
350
350
  });
351
351
 
352
- // node_modules/postgres-bytea/index.js
352
+ // node_modules/pg-types/node_modules/postgres-bytea/index.js
353
353
  var require_postgres_bytea = __commonJS((exports, module) => {
354
354
  var bufferFrom = Buffer.from || Buffer;
355
355
  module.exports = function parseBytea(input) {
@@ -5007,6 +5007,9 @@ class SqliteAdapter {
5007
5007
  exec(sql) {
5008
5008
  this.db.exec(sql);
5009
5009
  }
5010
+ query(sql) {
5011
+ return this.db.query(sql);
5012
+ }
5010
5013
  prepare(sql) {
5011
5014
  const stmt = this.db.prepare(sql);
5012
5015
  return {
@@ -9309,17 +9312,88 @@ function createDatabase(options) {
9309
9312
  return new SqliteAdapter(dbPath);
9310
9313
  }
9311
9314
  // src/sync.ts
9312
- function syncPush(local, cloud, options) {
9313
- return syncTransfer(local, cloud, options, "push");
9315
+ async function syncPush(local, remote, options) {
9316
+ const orderedTables = await getTableOrder(remote, options.tables);
9317
+ return syncTransfer(local, remote, { ...options, tables: orderedTables }, "push");
9318
+ }
9319
+ async function syncPull(remote, local, options) {
9320
+ const orderedTables = await getTableOrder(remote, options.tables);
9321
+ return syncTransfer(remote, local, { ...options, tables: orderedTables }, "pull");
9314
9322
  }
9315
- function syncPull(local, cloud, options) {
9316
- return syncTransfer(cloud, local, options, "pull");
9323
+ async function getTableOrder(remote, tables) {
9324
+ if (tables.length <= 1)
9325
+ return tables;
9326
+ try {
9327
+ const fks = await remote.all(`
9328
+ SELECT DISTINCT
9329
+ tc.table_name AS source_table,
9330
+ ccu.table_name AS referenced_table
9331
+ FROM information_schema.table_constraints tc
9332
+ JOIN information_schema.constraint_column_usage ccu
9333
+ ON tc.constraint_name = ccu.constraint_name
9334
+ AND tc.table_schema = ccu.table_schema
9335
+ WHERE tc.constraint_type = 'FOREIGN KEY'
9336
+ AND tc.table_schema = 'public'
9337
+ `);
9338
+ if (fks.length > 0) {
9339
+ return topoSort(tables, fks);
9340
+ }
9341
+ } catch {}
9342
+ return heuristicOrder(tables);
9317
9343
  }
9318
- function syncTransfer(source, target, options, _direction) {
9344
+ function topoSort(tables, fks) {
9345
+ const tableSet = new Set(tables);
9346
+ const deps = new Map;
9347
+ for (const t of tables) {
9348
+ deps.set(t, new Set);
9349
+ }
9350
+ for (const fk of fks) {
9351
+ if (tableSet.has(fk.source_table) && tableSet.has(fk.referenced_table)) {
9352
+ deps.get(fk.source_table).add(fk.referenced_table);
9353
+ }
9354
+ }
9355
+ const sorted = [];
9356
+ const visited = new Set;
9357
+ const visiting = new Set;
9358
+ function visit(table) {
9359
+ if (visited.has(table))
9360
+ return;
9361
+ if (visiting.has(table)) {
9362
+ sorted.push(table);
9363
+ visited.add(table);
9364
+ return;
9365
+ }
9366
+ visiting.add(table);
9367
+ const tableDeps = deps.get(table) ?? new Set;
9368
+ for (const dep of tableDeps) {
9369
+ visit(dep);
9370
+ }
9371
+ visiting.delete(table);
9372
+ visited.add(table);
9373
+ sorted.push(table);
9374
+ }
9375
+ for (const t of tables) {
9376
+ visit(t);
9377
+ }
9378
+ return sorted;
9379
+ }
9380
+ function heuristicOrder(tables) {
9381
+ const sorted = [...tables].sort((a, b) => {
9382
+ const aIsChild = a.includes("_") && tables.some((t) => a.startsWith(t + "_") || a.endsWith("_" + t));
9383
+ const bIsChild = b.includes("_") && tables.some((t) => b.startsWith(t + "_") || b.endsWith("_" + t));
9384
+ if (aIsChild && !bIsChild)
9385
+ return 1;
9386
+ if (!aIsChild && bIsChild)
9387
+ return -1;
9388
+ return a.localeCompare(b);
9389
+ });
9390
+ return sorted;
9391
+ }
9392
+ async function syncTransfer(source, target, options, _direction) {
9319
9393
  const {
9320
9394
  tables,
9321
9395
  onProgress,
9322
- batchSize = 500,
9396
+ batchSize = 100,
9323
9397
  conflictColumn = "updated_at",
9324
9398
  primaryKey = "id"
9325
9399
  } = options;
@@ -9342,7 +9416,7 @@ function syncTransfer(source, target, options, _direction) {
9342
9416
  totalTables: tables.length,
9343
9417
  currentTableIndex: i
9344
9418
  });
9345
- const rows = source.all(`SELECT * FROM "${table}"`);
9419
+ const rows = await readAll(source, `SELECT * FROM "${table}"`);
9346
9420
  result.rowsRead = rows.length;
9347
9421
  if (rows.length === 0) {
9348
9422
  onProgress?.({
@@ -9357,7 +9431,6 @@ function syncTransfer(source, target, options, _direction) {
9357
9431
  continue;
9358
9432
  }
9359
9433
  const columns = Object.keys(rows[0]);
9360
- const hasConflictCol = columns.includes(conflictColumn);
9361
9434
  const hasPrimaryKey = columns.includes(primaryKey);
9362
9435
  if (!hasPrimaryKey) {
9363
9436
  result.errors.push(`Table "${table}" has no "${primaryKey}" column — skipping`);
@@ -9372,34 +9445,18 @@ function syncTransfer(source, target, options, _direction) {
9372
9445
  totalTables: tables.length,
9373
9446
  currentTableIndex: i
9374
9447
  });
9448
+ const updateCols = columns.filter((c) => c !== primaryKey);
9375
9449
  for (let offset = 0;offset < rows.length; offset += batchSize) {
9376
9450
  const batch = rows.slice(offset, offset + batchSize);
9377
- for (const row of batch) {
9378
- try {
9379
- const existing = target.get(`SELECT "${primaryKey}"${hasConflictCol ? `, "${conflictColumn}"` : ""} FROM "${table}" WHERE "${primaryKey}" = ?`, row[primaryKey]);
9380
- if (existing) {
9381
- if (hasConflictCol && existing[conflictColumn] && row[conflictColumn]) {
9382
- const existingTime = new Date(existing[conflictColumn]).getTime();
9383
- const incomingTime = new Date(row[conflictColumn]).getTime();
9384
- if (existingTime >= incomingTime) {
9385
- result.rowsSkipped++;
9386
- continue;
9387
- }
9388
- }
9389
- const setClauses = columns.filter((c) => c !== primaryKey).map((c) => `"${c}" = ?`).join(", ");
9390
- const values = columns.filter((c) => c !== primaryKey).map((c) => row[c]);
9391
- values.push(row[primaryKey]);
9392
- target.run(`UPDATE "${table}" SET ${setClauses} WHERE "${primaryKey}" = ?`, ...values);
9393
- } else {
9394
- const placeholders = columns.map(() => "?").join(", ");
9395
- const colList = columns.map((c) => `"${c}"`).join(", ");
9396
- const values = columns.map((c) => row[c]);
9397
- target.run(`INSERT INTO "${table}" (${colList}) VALUES (${placeholders})`, ...values);
9398
- }
9399
- result.rowsWritten++;
9400
- } catch (err) {
9401
- result.errors.push(`Row ${row[primaryKey]}: ${err?.message ?? String(err)}`);
9451
+ try {
9452
+ if (isAsyncAdapter(target)) {
9453
+ await batchUpsertPg(target, table, columns, updateCols, primaryKey, batch);
9454
+ } else {
9455
+ batchUpsertSqlite(target, table, columns, updateCols, primaryKey, batch);
9402
9456
  }
9457
+ result.rowsWritten += batch.length;
9458
+ } catch (err) {
9459
+ result.errors.push(`Batch at offset ${offset}: ${err?.message ?? String(err)}`);
9403
9460
  }
9404
9461
  onProgress?.({
9405
9462
  table,
@@ -9425,12 +9482,44 @@ function syncTransfer(source, target, options, _direction) {
9425
9482
  }
9426
9483
  return results;
9427
9484
  }
9485
+ async function batchUpsertPg(target, table, columns, updateCols, primaryKey, batch) {
9486
+ if (batch.length === 0)
9487
+ return;
9488
+ const colList = columns.map((c) => `"${c}"`).join(", ");
9489
+ const valuePlaceholders = batch.map((_, rowIdx) => {
9490
+ const offset = rowIdx * columns.length;
9491
+ return `(${columns.map((_2, colIdx) => `$${offset + colIdx + 1}`).join(", ")})`;
9492
+ }).join(", ");
9493
+ const setClause = updateCols.length > 0 ? updateCols.map((c) => `"${c}" = EXCLUDED."${c}"`).join(", ") : `"${primaryKey}" = EXCLUDED."${primaryKey}"`;
9494
+ const sql = `INSERT INTO "${table}" (${colList}) VALUES ${valuePlaceholders}
9495
+ ON CONFLICT ("${primaryKey}") DO UPDATE SET ${setClause}`;
9496
+ const params = batch.flatMap((row) => columns.map((c) => row[c] ?? null));
9497
+ await target.run(sql, ...params);
9498
+ }
9499
+ function batchUpsertSqlite(target, table, columns, updateCols, primaryKey, batch) {
9500
+ if (batch.length === 0)
9501
+ return;
9502
+ const colList = columns.map((c) => `"${c}"`).join(", ");
9503
+ const valuePlaceholders = batch.map(() => `(${columns.map(() => "?").join(", ")})`).join(", ");
9504
+ const setClause = updateCols.length > 0 ? updateCols.map((c) => `"${c}" = EXCLUDED."${c}"`).join(", ") : `"${primaryKey}" = EXCLUDED."${primaryKey}"`;
9505
+ const sql = `INSERT INTO "${table}" (${colList}) VALUES ${valuePlaceholders}
9506
+ ON CONFLICT ("${primaryKey}") DO UPDATE SET ${setClause}`;
9507
+ const params = batch.flatMap((row) => columns.map((c) => row[c] ?? null));
9508
+ target.run(sql, ...params);
9509
+ }
9510
+ function isAsyncAdapter(adapter) {
9511
+ return adapter.constructor.name === "PgAdapterAsync" || typeof adapter.raw?.connect === "function";
9512
+ }
9513
+ async function readAll(adapter, sql) {
9514
+ const result = adapter.all(sql);
9515
+ return result instanceof Promise ? await result : result;
9516
+ }
9428
9517
  function listSqliteTables(db) {
9429
9518
  const rows = db.all(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`);
9430
9519
  return rows.map((r) => r.name);
9431
9520
  }
9432
- function listPgTables(db) {
9433
- const rows = db.all(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`);
9521
+ async function listPgTables(db) {
9522
+ const rows = await db.all(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`);
9434
9523
  return rows.map((r) => r.tablename);
9435
9524
  }
9436
9525
  // src/feedback.ts
@@ -9501,6 +9590,562 @@ function listFeedback(db) {
9501
9590
  ensureFeedbackTable(db);
9502
9591
  return db.all(`SELECT id, service, version, message, email, machine_id, created_at FROM feedback ORDER BY created_at DESC`);
9503
9592
  }
9593
+ // src/sync-progress.ts
9594
+ class SyncProgressTracker {
9595
+ db;
9596
+ progress = new Map;
9597
+ startTimes = new Map;
9598
+ callback;
9599
+ constructor(db, callback) {
9600
+ this.db = db;
9601
+ this.callback = callback;
9602
+ this.ensureResumeTable();
9603
+ }
9604
+ ensureResumeTable() {
9605
+ this.db.exec(`
9606
+ CREATE TABLE IF NOT EXISTS _sync_resume (
9607
+ table_name TEXT PRIMARY KEY,
9608
+ last_row_id TEXT,
9609
+ direction TEXT,
9610
+ started_at TEXT,
9611
+ status TEXT DEFAULT 'in_progress'
9612
+ )
9613
+ `);
9614
+ }
9615
+ start(table, total, direction) {
9616
+ const resumed = this.canResume(table);
9617
+ const now = Date.now();
9618
+ this.startTimes.set(table, now);
9619
+ const status = resumed ? "resumed" : "in_progress";
9620
+ const info = {
9621
+ table,
9622
+ total,
9623
+ done: 0,
9624
+ percent: 0,
9625
+ elapsed_ms: 0,
9626
+ eta_ms: 0,
9627
+ status
9628
+ };
9629
+ this.progress.set(table, info);
9630
+ this.db.run(`INSERT INTO _sync_resume (table_name, last_row_id, direction, started_at, status)
9631
+ VALUES (?, ?, ?, datetime('now'), ?)
9632
+ ON CONFLICT (table_name) DO UPDATE SET
9633
+ direction = excluded.direction,
9634
+ started_at = datetime('now'),
9635
+ status = excluded.status`, table, "", direction, status);
9636
+ this.notify(table);
9637
+ }
9638
+ update(table, done, lastRowId) {
9639
+ const info = this.progress.get(table);
9640
+ if (!info)
9641
+ return;
9642
+ const startTime = this.startTimes.get(table) ?? Date.now();
9643
+ const elapsed = Date.now() - startTime;
9644
+ const rate = done > 0 ? elapsed / done : 0;
9645
+ const remaining = info.total - done;
9646
+ const eta = remaining > 0 ? Math.round(rate * remaining) : 0;
9647
+ info.done = done;
9648
+ info.percent = info.total > 0 ? Math.round(done / info.total * 100) : 0;
9649
+ info.elapsed_ms = elapsed;
9650
+ info.eta_ms = eta;
9651
+ info.status = "in_progress";
9652
+ this.db.run(`UPDATE _sync_resume SET last_row_id = ?, status = 'in_progress' WHERE table_name = ?`, lastRowId, table);
9653
+ this.notify(table);
9654
+ }
9655
+ markComplete(table) {
9656
+ const info = this.progress.get(table);
9657
+ if (info) {
9658
+ const startTime = this.startTimes.get(table) ?? Date.now();
9659
+ info.elapsed_ms = Date.now() - startTime;
9660
+ info.done = info.total;
9661
+ info.percent = 100;
9662
+ info.eta_ms = 0;
9663
+ info.status = "completed";
9664
+ this.notify(table);
9665
+ }
9666
+ this.db.run(`UPDATE _sync_resume SET status = 'completed' WHERE table_name = ?`, table);
9667
+ }
9668
+ markFailed(table, _error) {
9669
+ const info = this.progress.get(table);
9670
+ if (info) {
9671
+ const startTime = this.startTimes.get(table) ?? Date.now();
9672
+ info.elapsed_ms = Date.now() - startTime;
9673
+ info.status = "failed";
9674
+ this.notify(table);
9675
+ }
9676
+ this.db.run(`UPDATE _sync_resume SET status = 'failed' WHERE table_name = ?`, table);
9677
+ }
9678
+ canResume(table) {
9679
+ const row = this.db.get(`SELECT status FROM _sync_resume WHERE table_name = ?`, table);
9680
+ if (!row)
9681
+ return false;
9682
+ return row.status === "in_progress" || row.status === "resumed";
9683
+ }
9684
+ getResumePoint(table) {
9685
+ const row = this.db.get(`SELECT table_name, last_row_id, direction, started_at, status FROM _sync_resume WHERE table_name = ?`, table);
9686
+ if (!row)
9687
+ return null;
9688
+ if (row.status !== "in_progress" && row.status !== "resumed")
9689
+ return null;
9690
+ return row;
9691
+ }
9692
+ clearResume(table) {
9693
+ this.db.run(`DELETE FROM _sync_resume WHERE table_name = ?`, table);
9694
+ this.progress.delete(table);
9695
+ this.startTimes.delete(table);
9696
+ }
9697
+ getProgress(table) {
9698
+ return this.progress.get(table) ?? null;
9699
+ }
9700
+ getAllProgress() {
9701
+ return Array.from(this.progress.values());
9702
+ }
9703
+ listResumeRecords() {
9704
+ return this.db.all(`SELECT table_name, last_row_id, direction, started_at, status FROM _sync_resume ORDER BY started_at DESC`);
9705
+ }
9706
+ notify(table) {
9707
+ const info = this.progress.get(table);
9708
+ if (info && this.callback) {
9709
+ this.callback({ ...info });
9710
+ }
9711
+ }
9712
+ }
9713
+ // src/sync-conflicts.ts
9714
+ function detectConflicts(local, remote, table, primaryKey = "id", conflictColumn = "updated_at") {
9715
+ const conflicts = [];
9716
+ const remoteMap = new Map;
9717
+ for (const row of remote) {
9718
+ const key = String(row[primaryKey]);
9719
+ remoteMap.set(key, row);
9720
+ }
9721
+ for (const localRow of local) {
9722
+ const key = String(localRow[primaryKey]);
9723
+ const remoteRow = remoteMap.get(key);
9724
+ if (!remoteRow)
9725
+ continue;
9726
+ const localTs = localRow[conflictColumn];
9727
+ const remoteTs = remoteRow[conflictColumn];
9728
+ if (localTs !== remoteTs) {
9729
+ conflicts.push({
9730
+ table,
9731
+ row_id: key,
9732
+ local_updated_at: String(localTs ?? ""),
9733
+ remote_updated_at: String(remoteTs ?? ""),
9734
+ local_data: { ...localRow },
9735
+ remote_data: { ...remoteRow },
9736
+ resolved: false
9737
+ });
9738
+ }
9739
+ }
9740
+ return conflicts;
9741
+ }
9742
+ function resolveConflicts(conflicts, strategy = "newest-wins") {
9743
+ return conflicts.map((conflict) => {
9744
+ const resolved = { ...conflict, resolved: true, resolution: strategy };
9745
+ switch (strategy) {
9746
+ case "local-wins":
9747
+ break;
9748
+ case "remote-wins":
9749
+ break;
9750
+ case "newest-wins": {
9751
+ const localTime = new Date(conflict.local_updated_at).getTime();
9752
+ const remoteTime = new Date(conflict.remote_updated_at).getTime();
9753
+ if (remoteTime > localTime) {
9754
+ resolved.resolution = "newest-wins";
9755
+ } else {
9756
+ resolved.resolution = "newest-wins";
9757
+ }
9758
+ break;
9759
+ }
9760
+ }
9761
+ return resolved;
9762
+ });
9763
+ }
9764
+ function getWinningData(conflict) {
9765
+ if (!conflict.resolved || !conflict.resolution) {
9766
+ throw new Error(`Conflict for row ${conflict.row_id} is not resolved`);
9767
+ }
9768
+ switch (conflict.resolution) {
9769
+ case "local-wins":
9770
+ return conflict.local_data;
9771
+ case "remote-wins":
9772
+ return conflict.remote_data;
9773
+ case "newest-wins": {
9774
+ const localTime = new Date(conflict.local_updated_at).getTime();
9775
+ const remoteTime = new Date(conflict.remote_updated_at).getTime();
9776
+ return remoteTime >= localTime ? conflict.remote_data : conflict.local_data;
9777
+ }
9778
+ case "manual":
9779
+ return conflict.local_data;
9780
+ default:
9781
+ return conflict.local_data;
9782
+ }
9783
+ }
9784
+ function ensureConflictsTable(db) {
9785
+ db.exec(`
9786
+ CREATE TABLE IF NOT EXISTS _sync_conflicts (
9787
+ id TEXT PRIMARY KEY,
9788
+ table_name TEXT,
9789
+ row_id TEXT,
9790
+ local_data TEXT,
9791
+ remote_data TEXT,
9792
+ local_updated_at TEXT,
9793
+ remote_updated_at TEXT,
9794
+ resolution TEXT,
9795
+ resolved_at TEXT,
9796
+ created_at TEXT DEFAULT (datetime('now'))
9797
+ )
9798
+ `);
9799
+ }
9800
+ function storeConflicts(db, conflicts) {
9801
+ ensureConflictsTable(db);
9802
+ for (const conflict of conflicts) {
9803
+ const id = `${conflict.table}:${conflict.row_id}:${Date.now()}`;
9804
+ db.run(`INSERT INTO _sync_conflicts (id, table_name, row_id, local_data, remote_data, local_updated_at, remote_updated_at, resolution, resolved_at)
9805
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, conflict.table, conflict.row_id, JSON.stringify(conflict.local_data), JSON.stringify(conflict.remote_data), conflict.local_updated_at, conflict.remote_updated_at, conflict.resolution ?? null, conflict.resolved ? new Date().toISOString() : null);
9806
+ }
9807
+ }
9808
+ function listConflicts(db, opts) {
9809
+ ensureConflictsTable(db);
9810
+ let sql = `SELECT * FROM _sync_conflicts WHERE 1=1`;
9811
+ const params = [];
9812
+ if (opts?.resolved !== undefined) {
9813
+ if (opts.resolved) {
9814
+ sql += ` AND resolution IS NOT NULL AND resolved_at IS NOT NULL`;
9815
+ } else {
9816
+ sql += ` AND (resolution IS NULL OR resolved_at IS NULL)`;
9817
+ }
9818
+ }
9819
+ if (opts?.table) {
9820
+ sql += ` AND table_name = ?`;
9821
+ params.push(opts.table);
9822
+ }
9823
+ sql += ` ORDER BY created_at DESC`;
9824
+ return db.all(sql, ...params);
9825
+ }
9826
+ function resolveConflict(db, conflictId, strategy) {
9827
+ ensureConflictsTable(db);
9828
+ const row = db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
9829
+ if (!row)
9830
+ return null;
9831
+ db.run(`UPDATE _sync_conflicts SET resolution = ?, resolved_at = datetime('now') WHERE id = ?`, strategy, conflictId);
9832
+ return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
9833
+ }
9834
+ function getConflict(db, conflictId) {
9835
+ ensureConflictsTable(db);
9836
+ return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
9837
+ }
9838
+ function purgeResolvedConflicts(db) {
9839
+ ensureConflictsTable(db);
9840
+ const result = db.run(`DELETE FROM _sync_conflicts WHERE resolution IS NOT NULL AND resolved_at IS NOT NULL`);
9841
+ return result.changes;
9842
+ }
9843
+ // src/sync-incremental.ts
9844
+ var SYNC_META_TABLE_SQL = `
9845
+ CREATE TABLE IF NOT EXISTS _sync_meta (
9846
+ table_name TEXT PRIMARY KEY,
9847
+ last_synced_at TEXT,
9848
+ last_synced_row_count INTEGER DEFAULT 0,
9849
+ direction TEXT DEFAULT 'push'
9850
+ )`;
9851
+ function ensureSyncMetaTable(db) {
9852
+ db.exec(SYNC_META_TABLE_SQL);
9853
+ }
9854
+ function getSyncMeta(db, table) {
9855
+ ensureSyncMetaTable(db);
9856
+ return db.get(`SELECT table_name, last_synced_at, last_synced_row_count, direction FROM _sync_meta WHERE table_name = ?`, table) ?? null;
9857
+ }
9858
+ function upsertSyncMeta(db, meta) {
9859
+ ensureSyncMetaTable(db);
9860
+ const existing = db.get(`SELECT table_name FROM _sync_meta WHERE table_name = ?`, meta.table_name);
9861
+ if (existing) {
9862
+ db.run(`UPDATE _sync_meta SET last_synced_at = ?, last_synced_row_count = ?, direction = ? WHERE table_name = ?`, meta.last_synced_at, meta.last_synced_row_count, meta.direction, meta.table_name);
9863
+ } else {
9864
+ db.run(`INSERT INTO _sync_meta (table_name, last_synced_at, last_synced_row_count, direction) VALUES (?, ?, ?, ?)`, meta.table_name, meta.last_synced_at, meta.last_synced_row_count, meta.direction);
9865
+ }
9866
+ }
9867
+ function transferRows(source, target, table, rows, options) {
9868
+ const { primaryKey = "id", conflictColumn = "updated_at" } = options;
9869
+ let written = 0;
9870
+ let skipped = 0;
9871
+ const errors2 = [];
9872
+ if (rows.length === 0)
9873
+ return { written, skipped, errors: errors2 };
9874
+ const columns = Object.keys(rows[0]);
9875
+ const hasConflictCol = columns.includes(conflictColumn);
9876
+ const hasPrimaryKey = columns.includes(primaryKey);
9877
+ if (!hasPrimaryKey) {
9878
+ errors2.push(`Table "${table}" has no "${primaryKey}" column -- skipping`);
9879
+ return { written, skipped, errors: errors2 };
9880
+ }
9881
+ for (const row of rows) {
9882
+ try {
9883
+ const existing = target.get(`SELECT "${primaryKey}"${hasConflictCol ? `, "${conflictColumn}"` : ""} FROM "${table}" WHERE "${primaryKey}" = ?`, row[primaryKey]);
9884
+ if (existing) {
9885
+ if (hasConflictCol && existing[conflictColumn] && row[conflictColumn]) {
9886
+ const existingTime = new Date(existing[conflictColumn]).getTime();
9887
+ const incomingTime = new Date(row[conflictColumn]).getTime();
9888
+ if (existingTime >= incomingTime) {
9889
+ skipped++;
9890
+ continue;
9891
+ }
9892
+ }
9893
+ const setClauses = columns.filter((c) => c !== primaryKey).map((c) => `"${c}" = ?`).join(", ");
9894
+ const values = columns.filter((c) => c !== primaryKey).map((c) => row[c]);
9895
+ values.push(row[primaryKey]);
9896
+ target.run(`UPDATE "${table}" SET ${setClauses} WHERE "${primaryKey}" = ?`, ...values);
9897
+ } else {
9898
+ const placeholders = columns.map(() => "?").join(", ");
9899
+ const colList = columns.map((c) => `"${c}"`).join(", ");
9900
+ const values = columns.map((c) => row[c]);
9901
+ target.run(`INSERT INTO "${table}" (${colList}) VALUES (${placeholders})`, ...values);
9902
+ }
9903
+ written++;
9904
+ } catch (err) {
9905
+ errors2.push(`Row ${row[primaryKey]}: ${err?.message ?? String(err)}`);
9906
+ }
9907
+ }
9908
+ return { written, skipped, errors: errors2 };
9909
+ }
9910
+ function incrementalSyncPush(local, remote, tables, options = {}) {
9911
+ const { conflictColumn = "updated_at", batchSize = 500 } = options;
9912
+ const results = [];
9913
+ ensureSyncMetaTable(local);
9914
+ for (const table of tables) {
9915
+ const stat = {
9916
+ table,
9917
+ total_rows: 0,
9918
+ synced_rows: 0,
9919
+ skipped_rows: 0,
9920
+ errors: [],
9921
+ first_sync: false
9922
+ };
9923
+ try {
9924
+ const countResult = local.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
9925
+ stat.total_rows = countResult?.cnt ?? 0;
9926
+ const meta = getSyncMeta(local, table);
9927
+ let rows;
9928
+ if (meta?.last_synced_at) {
9929
+ try {
9930
+ rows = local.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta.last_synced_at);
9931
+ } catch {
9932
+ rows = local.all(`SELECT * FROM "${table}"`);
9933
+ stat.first_sync = true;
9934
+ }
9935
+ } else {
9936
+ rows = local.all(`SELECT * FROM "${table}"`);
9937
+ stat.first_sync = true;
9938
+ }
9939
+ for (let offset = 0;offset < rows.length; offset += batchSize) {
9940
+ const batch = rows.slice(offset, offset + batchSize);
9941
+ const result = transferRows(local, remote, table, batch, options);
9942
+ stat.synced_rows += result.written;
9943
+ stat.skipped_rows += result.skipped;
9944
+ stat.errors.push(...result.errors);
9945
+ }
9946
+ if (rows.length === 0) {
9947
+ stat.skipped_rows = stat.total_rows;
9948
+ }
9949
+ const now = new Date().toISOString();
9950
+ upsertSyncMeta(local, {
9951
+ table_name: table,
9952
+ last_synced_at: now,
9953
+ last_synced_row_count: stat.synced_rows,
9954
+ direction: "push"
9955
+ });
9956
+ } catch (err) {
9957
+ stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
9958
+ }
9959
+ results.push(stat);
9960
+ }
9961
+ return results;
9962
+ }
9963
+ function incrementalSyncPull(remote, local, tables, options = {}) {
9964
+ const { conflictColumn = "updated_at", batchSize = 500 } = options;
9965
+ const results = [];
9966
+ ensureSyncMetaTable(local);
9967
+ for (const table of tables) {
9968
+ const stat = {
9969
+ table,
9970
+ total_rows: 0,
9971
+ synced_rows: 0,
9972
+ skipped_rows: 0,
9973
+ errors: [],
9974
+ first_sync: false
9975
+ };
9976
+ try {
9977
+ const countResult = remote.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
9978
+ stat.total_rows = countResult?.cnt ?? 0;
9979
+ const meta = getSyncMeta(local, table);
9980
+ let rows;
9981
+ if (meta?.last_synced_at) {
9982
+ try {
9983
+ rows = remote.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta.last_synced_at);
9984
+ } catch {
9985
+ rows = remote.all(`SELECT * FROM "${table}"`);
9986
+ stat.first_sync = true;
9987
+ }
9988
+ } else {
9989
+ rows = remote.all(`SELECT * FROM "${table}"`);
9990
+ stat.first_sync = true;
9991
+ }
9992
+ for (let offset = 0;offset < rows.length; offset += batchSize) {
9993
+ const batch = rows.slice(offset, offset + batchSize);
9994
+ const result = transferRows(remote, local, table, batch, options);
9995
+ stat.synced_rows += result.written;
9996
+ stat.skipped_rows += result.skipped;
9997
+ stat.errors.push(...result.errors);
9998
+ }
9999
+ if (rows.length === 0) {
10000
+ stat.skipped_rows = stat.total_rows;
10001
+ }
10002
+ const now = new Date().toISOString();
10003
+ upsertSyncMeta(local, {
10004
+ table_name: table,
10005
+ last_synced_at: now,
10006
+ last_synced_row_count: stat.synced_rows,
10007
+ direction: "pull"
10008
+ });
10009
+ } catch (err) {
10010
+ stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
10011
+ }
10012
+ results.push(stat);
10013
+ }
10014
+ return results;
10015
+ }
10016
+ function getSyncMetaAll(db) {
10017
+ ensureSyncMetaTable(db);
10018
+ return db.all(`SELECT table_name, last_synced_at, last_synced_row_count, direction FROM _sync_meta ORDER BY table_name`);
10019
+ }
10020
+ function getSyncMetaForTable(db, table) {
10021
+ return getSyncMeta(db, table);
10022
+ }
10023
+ function resetSyncMeta(db, table) {
10024
+ ensureSyncMetaTable(db);
10025
+ db.run(`DELETE FROM _sync_meta WHERE table_name = ?`, table);
10026
+ }
10027
+ function resetAllSyncMeta(db) {
10028
+ ensureSyncMetaTable(db);
10029
+ db.run(`DELETE FROM _sync_meta`);
10030
+ }
10031
+ // src/auto-sync.ts
10032
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
10033
+ import { homedir as homedir3 } from "os";
10034
+ import { join as join3 } from "path";
10035
+ var AUTO_SYNC_CONFIG_PATH = join3(homedir3(), ".hasna", "cloud", "config.json");
10036
+ var DEFAULT_AUTO_SYNC_CONFIG = {
10037
+ auto_sync_on_start: true,
10038
+ auto_sync_on_stop: true
10039
+ };
10040
+ function getAutoSyncConfig() {
10041
+ try {
10042
+ if (!existsSync3(AUTO_SYNC_CONFIG_PATH)) {
10043
+ return { ...DEFAULT_AUTO_SYNC_CONFIG };
10044
+ }
10045
+ const raw = JSON.parse(readFileSync2(AUTO_SYNC_CONFIG_PATH, "utf-8"));
10046
+ return {
10047
+ auto_sync_on_start: typeof raw.auto_sync_on_start === "boolean" ? raw.auto_sync_on_start : DEFAULT_AUTO_SYNC_CONFIG.auto_sync_on_start,
10048
+ auto_sync_on_stop: typeof raw.auto_sync_on_stop === "boolean" ? raw.auto_sync_on_stop : DEFAULT_AUTO_SYNC_CONFIG.auto_sync_on_stop
10049
+ };
10050
+ } catch {
10051
+ return { ...DEFAULT_AUTO_SYNC_CONFIG };
10052
+ }
10053
+ }
10054
+ function executeAutoSync(event, local, remote, tables) {
10055
+ const direction = event === "start" ? "pull" : "push";
10056
+ const result = {
10057
+ event,
10058
+ direction,
10059
+ success: false,
10060
+ tables_synced: 0,
10061
+ total_rows_synced: 0,
10062
+ errors: []
10063
+ };
10064
+ try {
10065
+ const stats = direction === "pull" ? incrementalSyncPull(remote, local, tables) : incrementalSyncPush(local, remote, tables);
10066
+ for (const s of stats) {
10067
+ if (s.errors.length === 0) {
10068
+ result.tables_synced++;
10069
+ }
10070
+ result.total_rows_synced += s.synced_rows;
10071
+ result.errors.push(...s.errors);
10072
+ }
10073
+ result.success = result.errors.length === 0;
10074
+ } catch (err) {
10075
+ result.errors.push(err?.message ?? String(err));
10076
+ }
10077
+ return result;
10078
+ }
10079
+ var cleanupHandlers = [];
10080
+ var signalHandlersInstalled = false;
10081
+ function installSignalHandlers() {
10082
+ if (signalHandlersInstalled)
10083
+ return;
10084
+ signalHandlersInstalled = true;
10085
+ const handleExit = () => {
10086
+ for (const fn of cleanupHandlers) {
10087
+ try {
10088
+ fn();
10089
+ } catch {}
10090
+ }
10091
+ };
10092
+ process.on("SIGTERM", () => {
10093
+ handleExit();
10094
+ process.exit(0);
10095
+ });
10096
+ process.on("SIGINT", () => {
10097
+ handleExit();
10098
+ process.exit(0);
10099
+ });
10100
+ process.on("beforeExit", () => {
10101
+ handleExit();
10102
+ });
10103
+ }
10104
+ function setupAutoSync(serviceName, server, local, remote, tables) {
10105
+ const config = getAutoSyncConfig();
10106
+ const cloudConfig = getCloudConfig();
10107
+ const isSyncEnabled = cloudConfig.mode === "hybrid" || cloudConfig.mode === "cloud";
10108
+ const syncOnStart = () => {
10109
+ if (!config.auto_sync_on_start || !isSyncEnabled)
10110
+ return null;
10111
+ return executeAutoSync("start", local, remote, tables);
10112
+ };
10113
+ const syncOnStop = () => {
10114
+ if (!config.auto_sync_on_stop || !isSyncEnabled)
10115
+ return null;
10116
+ return executeAutoSync("stop", local, remote, tables);
10117
+ };
10118
+ if (server && typeof server.onconnect === "function") {
10119
+ const origOnConnect = server.onconnect;
10120
+ server.onconnect = (...args) => {
10121
+ syncOnStart();
10122
+ return origOnConnect.apply(server, args);
10123
+ };
10124
+ } else if (server && typeof server.on === "function") {
10125
+ server.on("connect", () => {
10126
+ syncOnStart();
10127
+ });
10128
+ }
10129
+ if (server && typeof server.ondisconnect === "function") {
10130
+ const origOnDisconnect = server.ondisconnect;
10131
+ server.ondisconnect = (...args) => {
10132
+ syncOnStop();
10133
+ return origOnDisconnect.apply(server, args);
10134
+ };
10135
+ } else if (server && typeof server.on === "function") {
10136
+ server.on("disconnect", () => {
10137
+ syncOnStop();
10138
+ });
10139
+ }
10140
+ installSignalHandlers();
10141
+ cleanupHandlers.push(() => {
10142
+ syncOnStop();
10143
+ });
10144
+ return { syncOnStart, syncOnStop, config };
10145
+ }
10146
+ function enableAutoSync(serviceName, mcpServer, local, remote, tables) {
10147
+ setupAutoSync(serviceName, mcpServer, local, remote, tables);
10148
+ }
9504
10149
  // src/mcp-helpers.ts
9505
10150
  function registerCloudTools(server, serviceName) {
9506
10151
  server.tool(`${serviceName}_cloud_status`, "Show cloud configuration and connection health", {}, async () => {
@@ -9512,10 +10157,10 @@ function registerCloudTools(server, serviceName) {
9512
10157
  ];
9513
10158
  if (config.rds.host && config.rds.username) {
9514
10159
  try {
9515
- const pg2 = new PgAdapter(getConnectionString("postgres"));
9516
- pg2.get("SELECT 1 as ok");
10160
+ const pg2 = new PgAdapterAsync(getConnectionString("postgres"));
10161
+ await pg2.get("SELECT 1 as ok");
9517
10162
  lines.push("PostgreSQL: connected");
9518
- pg2.close();
10163
+ await pg2.close();
9519
10164
  } catch (err) {
9520
10165
  lines.push(`PostgreSQL: failed — ${err?.message}`);
9521
10166
  }
@@ -9536,11 +10181,11 @@ function registerCloudTools(server, serviceName) {
9536
10181
  };
9537
10182
  }
9538
10183
  const local = new SqliteAdapter(getDbPath(serviceName));
9539
- const cloud = new PgAdapter(getConnectionString(serviceName));
10184
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
9540
10185
  const tableList = tablesStr ? tablesStr.split(",").map((t) => t.trim()) : listSqliteTables(local);
9541
- const results = syncPush(local, cloud, { tables: tableList });
10186
+ const results = await syncPush(local, cloud, { tables: tableList });
9542
10187
  local.close();
9543
- cloud.close();
10188
+ await cloud.close();
9544
10189
  const total = results.reduce((s, r) => s + r.rowsWritten, 0);
9545
10190
  return {
9546
10191
  content: [{ type: "text", text: `Pushed ${total} rows across ${tableList.length} table(s).` }]
@@ -9559,17 +10204,16 @@ function registerCloudTools(server, serviceName) {
9559
10204
  };
9560
10205
  }
9561
10206
  const local = new SqliteAdapter(getDbPath(serviceName));
9562
- const cloud = new PgAdapter(getConnectionString(serviceName));
10207
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
9563
10208
  let tableList;
9564
10209
  if (tablesStr) {
9565
10210
  tableList = tablesStr.split(",").map((t) => t.trim());
9566
10211
  } else {
9567
10212
  try {
9568
- const rows = cloud.all(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`);
9569
- tableList = rows.map((r) => r.tablename);
10213
+ tableList = await listPgTables(cloud);
9570
10214
  } catch {
9571
10215
  local.close();
9572
- cloud.close();
10216
+ await cloud.close();
9573
10217
  return {
9574
10218
  content: [
9575
10219
  { type: "text", text: "Error: failed to list cloud tables." }
@@ -9578,9 +10222,9 @@ function registerCloudTools(server, serviceName) {
9578
10222
  };
9579
10223
  }
9580
10224
  }
9581
- const results = syncPull(local, cloud, { tables: tableList });
10225
+ const results = await syncPull(cloud, local, { tables: tableList });
9582
10226
  local.close();
9583
- cloud.close();
10227
+ await cloud.close();
9584
10228
  const total = results.reduce((s, r) => s + r.rowsWritten, 0);
9585
10229
  return {
9586
10230
  content: [{ type: "text", text: `Pulled ${total} rows across ${tableList.length} table(s).` }]
@@ -9614,25 +10258,25 @@ function registerCloudCommands(program, serviceName) {
9614
10258
  if (config.rds.host && config.rds.username) {
9615
10259
  try {
9616
10260
  const connStr = getConnectionString("postgres");
9617
- const pg2 = new PgAdapter(connStr);
9618
- pg2.get("SELECT 1 as ok");
10261
+ const pg2 = new PgAdapterAsync(connStr);
10262
+ await pg2.get("SELECT 1 as ok");
9619
10263
  console.log("PostgreSQL: connected");
9620
- pg2.close();
10264
+ await pg2.close();
9621
10265
  } catch (err) {
9622
10266
  console.log("PostgreSQL: connection failed —", err?.message);
9623
10267
  }
9624
10268
  }
9625
10269
  });
9626
- cloudCmd.command("push").description("Push local data to cloud").option("--tables <tables>", "Comma-separated table names").action((opts) => {
10270
+ cloudCmd.command("push").description("Push local data to cloud").option("--tables <tables>", "Comma-separated table names").action(async (opts) => {
9627
10271
  const config = getCloudConfig();
9628
10272
  if (config.mode === "local") {
9629
10273
  console.error("Error: mode is 'local'. Run `cloud setup` first.");
9630
10274
  process.exit(1);
9631
10275
  }
9632
10276
  const local = new SqliteAdapter(getDbPath(serviceName));
9633
- const cloud = new PgAdapter(getConnectionString(serviceName));
10277
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
9634
10278
  const tables = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : listSqliteTables(local);
9635
- const results = syncPush(local, cloud, {
10279
+ const results = await syncPush(local, cloud, {
9636
10280
  tables,
9637
10281
  onProgress: (p) => {
9638
10282
  if (p.phase === "done") {
@@ -9641,26 +10285,25 @@ function registerCloudCommands(program, serviceName) {
9641
10285
  }
9642
10286
  });
9643
10287
  local.close();
9644
- cloud.close();
10288
+ await cloud.close();
9645
10289
  const total = results.reduce((s, r) => s + r.rowsWritten, 0);
9646
10290
  console.log(`Done. ${total} rows pushed.`);
9647
10291
  });
9648
- cloudCmd.command("pull").description("Pull cloud data to local").option("--tables <tables>", "Comma-separated table names").action((opts) => {
10292
+ cloudCmd.command("pull").description("Pull cloud data to local").option("--tables <tables>", "Comma-separated table names").action(async (opts) => {
9649
10293
  const config = getCloudConfig();
9650
10294
  if (config.mode === "local") {
9651
10295
  console.error("Error: mode is 'local'. Run `cloud setup` first.");
9652
10296
  process.exit(1);
9653
10297
  }
9654
10298
  const local = new SqliteAdapter(getDbPath(serviceName));
9655
- const cloud = new PgAdapter(getConnectionString(serviceName));
10299
+ const cloud = new PgAdapterAsync(getConnectionString(serviceName));
9656
10300
  let tables;
9657
10301
  if (opts.tables) {
9658
10302
  tables = opts.tables.split(",").map((t) => t.trim());
9659
10303
  } else {
9660
- const rows = cloud.all(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`);
9661
- tables = rows.map((r) => r.tablename);
10304
+ tables = await listPgTables(cloud);
9662
10305
  }
9663
- const results = syncPull(local, cloud, {
10306
+ const results = await syncPull(cloud, local, {
9664
10307
  tables,
9665
10308
  onProgress: (p) => {
9666
10309
  if (p.phase === "done") {
@@ -9669,7 +10312,7 @@ function registerCloudCommands(program, serviceName) {
9669
10312
  }
9670
10313
  });
9671
10314
  local.close();
9672
- cloud.close();
10315
+ await cloud.close();
9673
10316
  const total = results.reduce((s, r) => s + r.rowsWritten, 0);
9674
10317
  console.log(`Done. ${total} rows pulled.`);
9675
10318
  });
@@ -9690,25 +10333,45 @@ export {
9690
10333
  translateDdl,
9691
10334
  syncPush,
9692
10335
  syncPull,
10336
+ storeConflicts,
10337
+ setupAutoSync,
9693
10338
  sendFeedback,
9694
10339
  saveFeedback,
9695
10340
  saveCloudConfig,
10341
+ resolveConflicts,
10342
+ resolveConflict,
10343
+ resetSyncMeta,
10344
+ resetAllSyncMeta,
9696
10345
  registerCloudTools,
9697
10346
  registerCloudCommands,
10347
+ purgeResolvedConflicts,
9698
10348
  migrateDotfile,
9699
10349
  listSqliteTables,
9700
10350
  listPgTables,
9701
10351
  listFeedback,
10352
+ listConflicts,
10353
+ incrementalSyncPush,
10354
+ incrementalSyncPull,
9702
10355
  hasLegacyDotfile,
10356
+ getWinningData,
10357
+ getSyncMetaForTable,
10358
+ getSyncMetaAll,
9703
10359
  getHasnaDir,
9704
10360
  getDbPath,
9705
10361
  getDataDir,
9706
10362
  getConnectionString,
10363
+ getConflict,
9707
10364
  getConfigPath,
9708
10365
  getConfigDir,
9709
10366
  getCloudConfig,
10367
+ getAutoSyncConfig,
10368
+ ensureSyncMetaTable,
9710
10369
  ensureFeedbackTable,
10370
+ ensureConflictsTable,
10371
+ enableAutoSync,
10372
+ detectConflicts,
9711
10373
  createDatabase,
10374
+ SyncProgressTracker,
9712
10375
  SqliteAdapter,
9713
10376
  PgAdapterAsync,
9714
10377
  PgAdapter,