@hasna/cloud 0.1.2 → 0.1.4

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