@hasna/contacts 0.6.22 → 0.6.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +24 -6
  2. package/dist/cli/commands/core.d.ts.map +1 -1
  3. package/dist/cli/index.js +1350 -269
  4. package/dist/cli/storage.d.ts.map +1 -1
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/remote-storage.d.ts +14 -0
  7. package/dist/db/remote-storage.d.ts.map +1 -0
  8. package/dist/db/remote-sync.d.ts +49 -0
  9. package/dist/db/remote-sync.d.ts.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1002 -7
  13. package/dist/mcp/index.js +1073 -60
  14. package/dist/mcp/storage-tools.d.ts.map +1 -1
  15. package/dist/server/index.js +1073 -60
  16. package/package.json +8 -4
  17. package/dist/cli/storage-contract.test.d.ts +0 -2
  18. package/dist/cli/storage-contract.test.d.ts.map +0 -1
  19. package/dist/db/activity.test.d.ts +0 -2
  20. package/dist/db/activity.test.d.ts.map +0 -1
  21. package/dist/db/applications.test.d.ts +0 -2
  22. package/dist/db/applications.test.d.ts.map +0 -1
  23. package/dist/db/companies.test.d.ts +0 -2
  24. package/dist/db/companies.test.d.ts.map +0 -1
  25. package/dist/db/contact-tasks.test.d.ts +0 -2
  26. package/dist/db/contact-tasks.test.d.ts.map +0 -1
  27. package/dist/db/contacts.test.d.ts +0 -2
  28. package/dist/db/contacts.test.d.ts.map +0 -1
  29. package/dist/db/coordination.test.d.ts +0 -2
  30. package/dist/db/coordination.test.d.ts.map +0 -1
  31. package/dist/db/database.test.d.ts +0 -2
  32. package/dist/db/database.test.d.ts.map +0 -1
  33. package/dist/db/deals.test.d.ts +0 -2
  34. package/dist/db/deals.test.d.ts.map +0 -1
  35. package/dist/db/documents.test.d.ts +0 -2
  36. package/dist/db/documents.test.d.ts.map +0 -1
  37. package/dist/db/events.test.d.ts +0 -2
  38. package/dist/db/events.test.d.ts.map +0 -1
  39. package/dist/db/field-history.test.d.ts +0 -2
  40. package/dist/db/field-history.test.d.ts.map +0 -1
  41. package/dist/db/freshness.test.d.ts +0 -2
  42. package/dist/db/freshness.test.d.ts.map +0 -1
  43. package/dist/db/graph.test.d.ts +0 -2
  44. package/dist/db/graph.test.d.ts.map +0 -1
  45. package/dist/db/groups.test.d.ts +0 -2
  46. package/dist/db/groups.test.d.ts.map +0 -1
  47. package/dist/db/health.test.d.ts +0 -2
  48. package/dist/db/health.test.d.ts.map +0 -1
  49. package/dist/db/identity.test.d.ts +0 -2
  50. package/dist/db/identity.test.d.ts.map +0 -1
  51. package/dist/db/job-history.test.d.ts +0 -2
  52. package/dist/db/job-history.test.d.ts.map +0 -1
  53. package/dist/db/learnings.test.d.ts +0 -2
  54. package/dist/db/learnings.test.d.ts.map +0 -1
  55. package/dist/db/notes.test.d.ts +0 -2
  56. package/dist/db/notes.test.d.ts.map +0 -1
  57. package/dist/db/org-chart.test.d.ts +0 -2
  58. package/dist/db/org-chart.test.d.ts.map +0 -1
  59. package/dist/db/org-members.test.d.ts +0 -2
  60. package/dist/db/org-members.test.d.ts.map +0 -1
  61. package/dist/db/relationships.test.d.ts +0 -2
  62. package/dist/db/relationships.test.d.ts.map +0 -1
  63. package/dist/db/signals.test.d.ts +0 -2
  64. package/dist/db/signals.test.d.ts.map +0 -1
  65. package/dist/db/sqlite-adapter.test.d.ts +0 -2
  66. package/dist/db/sqlite-adapter.test.d.ts.map +0 -1
  67. package/dist/db/storage.test.d.ts +0 -2
  68. package/dist/db/storage.test.d.ts.map +0 -1
  69. package/dist/db/tags.test.d.ts +0 -2
  70. package/dist/db/tags.test.d.ts.map +0 -1
  71. package/dist/db/vendor-comms.test.d.ts +0 -2
  72. package/dist/db/vendor-comms.test.d.ts.map +0 -1
  73. package/dist/lib/audit.test.d.ts +0 -2
  74. package/dist/lib/audit.test.d.ts.map +0 -1
  75. package/dist/lib/brief.test.d.ts +0 -2
  76. package/dist/lib/brief.test.d.ts.map +0 -1
  77. package/dist/lib/config.test.d.ts +0 -2
  78. package/dist/lib/config.test.d.ts.map +0 -1
  79. package/dist/lib/dedup.test.d.ts +0 -2
  80. package/dist/lib/dedup.test.d.ts.map +0 -1
  81. package/dist/lib/export.test.d.ts +0 -2
  82. package/dist/lib/export.test.d.ts.map +0 -1
  83. package/dist/lib/gmail-import.test.d.ts +0 -2
  84. package/dist/lib/gmail-import.test.d.ts.map +0 -1
  85. package/dist/lib/google-contacts.test.d.ts +0 -2
  86. package/dist/lib/google-contacts.test.d.ts.map +0 -1
  87. package/dist/lib/import.test.d.ts +0 -2
  88. package/dist/lib/import.test.d.ts.map +0 -1
  89. package/dist/lib/signature-parser.test.d.ts +0 -2
  90. package/dist/lib/signature-parser.test.d.ts.map +0 -1
  91. package/dist/lib/stats.test.d.ts +0 -2
  92. package/dist/lib/stats.test.d.ts.map +0 -1
  93. package/dist/lib/timeline.test.d.ts +0 -2
  94. package/dist/lib/timeline.test.d.ts.map +0 -1
  95. package/dist/lib/upcoming.test.d.ts +0 -2
  96. package/dist/lib/upcoming.test.d.ts.map +0 -1
  97. package/dist/mcp/http.test.d.ts +0 -2
  98. package/dist/mcp/http.test.d.ts.map +0 -1
  99. package/dist/mcp/mcp.test.d.ts +0 -2
  100. package/dist/mcp/mcp.test.d.ts.map +0 -1
  101. package/dist/mcp/storage-contract.test.d.ts +0 -2
  102. package/dist/mcp/storage-contract.test.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -57,22 +57,44 @@ class SqliteAdapter {
57
57
  var init_sqlite_adapter = () => {};
58
58
 
59
59
  // src/db/database.ts
60
- import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
60
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
61
61
  import { dirname, join, resolve } from "path";
62
+ function ensurePrivateDir(dir) {
63
+ if (!existsSync(dir))
64
+ mkdirSync(dir, { recursive: true, mode: 448 });
65
+ chmodSync(dir, 448);
66
+ }
67
+ function ensureCreatedPrivateDir(dir) {
68
+ if (!existsSync(dir))
69
+ mkdirSync(dir, { recursive: true, mode: 448 });
70
+ }
71
+ function protectDatabaseArtifacts(dbPath) {
72
+ if (dbPath === ":memory:")
73
+ return;
74
+ for (const artifact of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
75
+ if (existsSync(artifact))
76
+ chmodSync(artifact, 384);
77
+ }
78
+ }
62
79
  function getDataDir() {
63
80
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
81
+ const hasnaDir = join(home, ".hasna");
64
82
  const newDir = join(home, ".hasna", "contacts");
65
83
  const oldDir = join(home, ".contacts");
66
84
  if (existsSync(oldDir) && !existsSync(newDir)) {
67
- mkdirSync(newDir, { recursive: true });
85
+ ensurePrivateDir(hasnaDir);
86
+ ensurePrivateDir(newDir);
68
87
  for (const file of readdirSync(oldDir)) {
69
88
  const oldPath = join(oldDir, file);
70
89
  if (statSync(oldPath).isFile()) {
71
- copyFileSync(oldPath, join(newDir, file));
90
+ const newPath = join(newDir, file);
91
+ copyFileSync(oldPath, newPath);
92
+ chmodSync(newPath, 384);
72
93
  }
73
94
  }
74
95
  }
75
- mkdirSync(newDir, { recursive: true });
96
+ ensurePrivateDir(hasnaDir);
97
+ ensurePrivateDir(newDir);
76
98
  return newDir;
77
99
  }
78
100
  function getDbPath() {
@@ -86,8 +108,7 @@ function ensureDir(filePath) {
86
108
  if (filePath === ":memory:")
87
109
  return;
88
110
  const dir = dirname(resolve(filePath));
89
- if (!existsSync(dir))
90
- mkdirSync(dir, { recursive: true });
111
+ ensureCreatedPrivateDir(dir);
91
112
  }
92
113
  function getDatabase(path) {
93
114
  if (_db)
@@ -98,10 +119,12 @@ function getDatabase(path) {
98
119
  db.exec("PRAGMA journal_mode=WAL");
99
120
  db.exec("PRAGMA foreign_keys=ON");
100
121
  runMigrations(db);
122
+ protectDatabaseArtifacts(dbPath);
101
123
  _db = db;
102
124
  return db;
103
125
  }
104
126
  function resetDatabase() {
127
+ _db?.close();
105
128
  _db = null;
106
129
  }
107
130
  function uuid() {
@@ -1521,6 +1544,961 @@ function getStorageStatus(db = getDatabase()) {
1521
1544
  })
1522
1545
  };
1523
1546
  }
1547
+ // src/db/remote-sync.ts
1548
+ init_database();
1549
+
1550
+ // src/db/pg-migrations.ts
1551
+ var PG_MIGRATIONS = [
1552
+ `
1553
+ CREATE TABLE IF NOT EXISTS companies (
1554
+ id TEXT PRIMARY KEY,
1555
+ name TEXT NOT NULL,
1556
+ domain TEXT,
1557
+ logo_url TEXT,
1558
+ description TEXT,
1559
+ industry TEXT,
1560
+ size TEXT,
1561
+ founded_year INTEGER,
1562
+ notes TEXT,
1563
+ custom_fields TEXT NOT NULL DEFAULT '{}',
1564
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1565
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1566
+ );
1567
+
1568
+ CREATE TABLE IF NOT EXISTS contacts (
1569
+ id TEXT PRIMARY KEY,
1570
+ first_name TEXT NOT NULL DEFAULT '',
1571
+ last_name TEXT NOT NULL DEFAULT '',
1572
+ display_name TEXT NOT NULL,
1573
+ nickname TEXT,
1574
+ avatar_url TEXT,
1575
+ notes TEXT,
1576
+ birthday TEXT,
1577
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
1578
+ job_title TEXT,
1579
+ source TEXT NOT NULL DEFAULT 'manual',
1580
+ custom_fields TEXT NOT NULL DEFAULT '{}',
1581
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1582
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1583
+ );
1584
+
1585
+ CREATE TABLE IF NOT EXISTS tags (
1586
+ id TEXT PRIMARY KEY,
1587
+ name TEXT NOT NULL UNIQUE,
1588
+ color TEXT NOT NULL DEFAULT '#6366f1',
1589
+ description TEXT,
1590
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1591
+ );
1592
+
1593
+ CREATE TABLE IF NOT EXISTS contact_tags (
1594
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1595
+ tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
1596
+ PRIMARY KEY (contact_id, tag_id)
1597
+ );
1598
+
1599
+ CREATE TABLE IF NOT EXISTS company_tags (
1600
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
1601
+ tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
1602
+ PRIMARY KEY (company_id, tag_id)
1603
+ );
1604
+
1605
+ CREATE TABLE IF NOT EXISTS emails (
1606
+ id TEXT PRIMARY KEY,
1607
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
1608
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
1609
+ address TEXT NOT NULL,
1610
+ type TEXT NOT NULL DEFAULT 'work' CHECK(type IN ('work','personal','other')),
1611
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
1612
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1613
+ );
1614
+
1615
+ CREATE TABLE IF NOT EXISTS phones (
1616
+ id TEXT PRIMARY KEY,
1617
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
1618
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
1619
+ number TEXT NOT NULL,
1620
+ country_code TEXT,
1621
+ type TEXT NOT NULL DEFAULT 'mobile' CHECK(type IN ('mobile','work','home','fax','whatsapp','other')),
1622
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
1623
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1624
+ );
1625
+
1626
+ CREATE TABLE IF NOT EXISTS addresses (
1627
+ id TEXT PRIMARY KEY,
1628
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
1629
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
1630
+ type TEXT NOT NULL DEFAULT 'physical' CHECK(type IN ('physical','mailing','billing','virtual','other')),
1631
+ street TEXT,
1632
+ city TEXT,
1633
+ state TEXT,
1634
+ zip TEXT,
1635
+ country TEXT,
1636
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
1637
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1638
+ );
1639
+
1640
+ CREATE TABLE IF NOT EXISTS social_profiles (
1641
+ id TEXT PRIMARY KEY,
1642
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
1643
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
1644
+ platform TEXT NOT NULL CHECK(platform IN ('twitter','linkedin','github','instagram','telegram','discord','youtube','tiktok','bluesky','facebook','whatsapp','snapchat','reddit','other')),
1645
+ handle TEXT,
1646
+ url TEXT,
1647
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
1648
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1649
+ );
1650
+
1651
+ CREATE TABLE IF NOT EXISTS contact_relationships (
1652
+ id TEXT PRIMARY KEY,
1653
+ contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1654
+ contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1655
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN ('colleague','friend','family','reports_to','mentor','investor','partner','client','vendor','other')),
1656
+ notes TEXT,
1657
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1658
+ );
1659
+
1660
+ CREATE TABLE IF NOT EXISTS activity_log (
1661
+ id TEXT PRIMARY KEY,
1662
+ contact_id TEXT REFERENCES contacts(id) ON DELETE CASCADE,
1663
+ company_id TEXT REFERENCES companies(id) ON DELETE CASCADE,
1664
+ action TEXT NOT NULL,
1665
+ details TEXT,
1666
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1667
+ );
1668
+
1669
+ CREATE TABLE IF NOT EXISTS webhooks (
1670
+ id TEXT PRIMARY KEY,
1671
+ url TEXT NOT NULL,
1672
+ events TEXT NOT NULL DEFAULT '["*"]',
1673
+ secret TEXT,
1674
+ active BOOLEAN NOT NULL DEFAULT TRUE,
1675
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1676
+ );
1677
+
1678
+ -- Full-text search using PostgreSQL tsvector instead of FTS5
1679
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS search_vector tsvector;
1680
+ CREATE INDEX IF NOT EXISTS idx_contacts_search ON contacts USING GIN(search_vector);
1681
+
1682
+ CREATE OR REPLACE FUNCTION contacts_search_vector_update() RETURNS trigger AS $$
1683
+ BEGIN
1684
+ NEW.search_vector :=
1685
+ setweight(to_tsvector('simple', COALESCE(NEW.display_name, '')), 'A') ||
1686
+ setweight(to_tsvector('simple', COALESCE(NEW.first_name, '')), 'A') ||
1687
+ setweight(to_tsvector('simple', COALESCE(NEW.last_name, '')), 'A') ||
1688
+ setweight(to_tsvector('simple', COALESCE(NEW.nickname, '')), 'B') ||
1689
+ setweight(to_tsvector('english', COALESCE(NEW.notes, '')), 'C') ||
1690
+ setweight(to_tsvector('simple', COALESCE(NEW.job_title, '')), 'B');
1691
+ RETURN NEW;
1692
+ END;
1693
+ $$ LANGUAGE plpgsql;
1694
+
1695
+ DROP TRIGGER IF EXISTS contacts_search_vector_trigger ON contacts;
1696
+ CREATE TRIGGER contacts_search_vector_trigger
1697
+ BEFORE INSERT OR UPDATE OF display_name, first_name, last_name, nickname, notes, job_title ON contacts
1698
+ FOR EACH ROW EXECUTE FUNCTION contacts_search_vector_update();
1699
+
1700
+ -- Backfill
1701
+ UPDATE contacts SET search_vector =
1702
+ setweight(to_tsvector('simple', COALESCE(display_name, '')), 'A') ||
1703
+ setweight(to_tsvector('simple', COALESCE(first_name, '')), 'A') ||
1704
+ setweight(to_tsvector('simple', COALESCE(last_name, '')), 'A') ||
1705
+ setweight(to_tsvector('simple', COALESCE(nickname, '')), 'B') ||
1706
+ setweight(to_tsvector('english', COALESCE(notes, '')), 'C') ||
1707
+ setweight(to_tsvector('simple', COALESCE(job_title, '')), 'B')
1708
+ WHERE search_vector IS NULL;
1709
+
1710
+ CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
1711
+ INSERT INTO _migrations (version) VALUES (0) ON CONFLICT DO NOTHING;
1712
+ `,
1713
+ `
1714
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS last_contacted_at TEXT;
1715
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS website TEXT;
1716
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS preferred_contact_method TEXT;
1717
+
1718
+ CREATE TABLE IF NOT EXISTS groups (
1719
+ id TEXT PRIMARY KEY,
1720
+ name TEXT NOT NULL UNIQUE,
1721
+ description TEXT,
1722
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1723
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1724
+ );
1725
+
1726
+ CREATE TABLE IF NOT EXISTS contact_groups (
1727
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1728
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
1729
+ PRIMARY KEY (contact_id, group_id)
1730
+ );
1731
+
1732
+ INSERT INTO _migrations (version) VALUES (1) ON CONFLICT DO NOTHING;
1733
+ `,
1734
+ `
1735
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active';
1736
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS follow_up_at TEXT;
1737
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
1738
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS project_id TEXT;
1739
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
1740
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS project_id TEXT;
1741
+
1742
+ CREATE TABLE IF NOT EXISTS company_groups (
1743
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
1744
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
1745
+ PRIMARY KEY (company_id, group_id)
1746
+ );
1747
+
1748
+ INSERT INTO _migrations (version) VALUES (2) ON CONFLICT DO NOTHING;
1749
+ `,
1750
+ `
1751
+ CREATE TABLE IF NOT EXISTS company_relationships (
1752
+ id TEXT PRIMARY KEY,
1753
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1754
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
1755
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN ('client','vendor','partner','employee','contractor','investor','advisor','other')),
1756
+ notes TEXT,
1757
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1758
+ );
1759
+
1760
+ CREATE INDEX IF NOT EXISTS idx_company_relationships_contact ON company_relationships(contact_id);
1761
+ CREATE INDEX IF NOT EXISTS idx_company_relationships_company ON company_relationships(company_id);
1762
+
1763
+ INSERT INTO _migrations (version) VALUES (3) ON CONFLICT DO NOTHING;
1764
+ `,
1765
+ `
1766
+ ALTER TABLE groups ADD COLUMN IF NOT EXISTS project_id TEXT;
1767
+
1768
+ CREATE TABLE IF NOT EXISTS contact_projects (
1769
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1770
+ project_id TEXT NOT NULL,
1771
+ PRIMARY KEY (contact_id, project_id)
1772
+ );
1773
+
1774
+ CREATE INDEX IF NOT EXISTS idx_contact_projects_project ON contact_projects(project_id);
1775
+ CREATE INDEX IF NOT EXISTS idx_contact_projects_contact ON contact_projects(contact_id);
1776
+
1777
+ INSERT INTO contact_projects (contact_id, project_id)
1778
+ SELECT id, project_id FROM contacts WHERE project_id IS NOT NULL
1779
+ ON CONFLICT DO NOTHING;
1780
+
1781
+ INSERT INTO _migrations (version) VALUES (4) ON CONFLICT DO NOTHING;
1782
+ `,
1783
+ `
1784
+ CREATE TABLE IF NOT EXISTS contact_notes (
1785
+ id TEXT PRIMARY KEY,
1786
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1787
+ body TEXT NOT NULL,
1788
+ created_by TEXT,
1789
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1790
+ );
1791
+
1792
+ CREATE INDEX IF NOT EXISTS idx_contact_notes_contact ON contact_notes(contact_id);
1793
+
1794
+ INSERT INTO _migrations (version) VALUES (5) ON CONFLICT DO NOTHING;
1795
+ `,
1796
+ `
1797
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS is_owned_entity BOOLEAN NOT NULL DEFAULT FALSE;
1798
+ ALTER TABLE companies ADD COLUMN IF NOT EXISTS entity_type TEXT CHECK(entity_type IN ('operating','holding','dissolved','nonprofit','trust','branch','other'));
1799
+
1800
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS start_date TEXT;
1801
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS end_date TEXT;
1802
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS is_primary BOOLEAN NOT NULL DEFAULT FALSE;
1803
+ ALTER TABLE company_relationships ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','inactive','ended'));
1804
+
1805
+ CREATE TABLE IF NOT EXISTS org_members (
1806
+ id TEXT PRIMARY KEY,
1807
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
1808
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1809
+ title TEXT,
1810
+ specialization TEXT,
1811
+ office_phone TEXT,
1812
+ response_sla_hours INTEGER,
1813
+ notes TEXT,
1814
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1815
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1816
+ UNIQUE(company_id, contact_id)
1817
+ );
1818
+
1819
+ CREATE TABLE IF NOT EXISTS vendor_communications (
1820
+ id TEXT PRIMARY KEY,
1821
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
1822
+ contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
1823
+ comm_date TEXT NOT NULL,
1824
+ type TEXT NOT NULL DEFAULT 'email' CHECK(type IN ('email','call','meeting','invoice_request','invoice_received','payment','dispute','other')),
1825
+ direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound','outbound')),
1826
+ subject TEXT,
1827
+ body TEXT,
1828
+ status TEXT NOT NULL DEFAULT 'sent' CHECK(status IN ('sent','awaiting_response','responded','no_response','resolved')),
1829
+ invoice_amount DOUBLE PRECISION,
1830
+ invoice_currency TEXT,
1831
+ invoice_ref TEXT,
1832
+ follow_up_date TEXT,
1833
+ follow_up_done BOOLEAN NOT NULL DEFAULT FALSE,
1834
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1835
+ );
1836
+
1837
+ CREATE TABLE IF NOT EXISTS contact_tasks (
1838
+ id TEXT PRIMARY KEY,
1839
+ title TEXT NOT NULL,
1840
+ description TEXT,
1841
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1842
+ assigned_by TEXT,
1843
+ deadline TEXT,
1844
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','awaiting_response','in_progress','completed','cancelled','escalated')),
1845
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')),
1846
+ entity_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
1847
+ linked_todos_task_id TEXT,
1848
+ escalation_rules TEXT NOT NULL DEFAULT '[]',
1849
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1850
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1851
+ );
1852
+
1853
+ CREATE TABLE IF NOT EXISTS applications (
1854
+ id TEXT PRIMARY KEY,
1855
+ program_name TEXT NOT NULL,
1856
+ provider_company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
1857
+ type TEXT NOT NULL DEFAULT 'other' CHECK(type IN ('ai_credits','grant','startup_program','visa','trademark','tax_filing','loan','other')),
1858
+ value_usd DOUBLE PRECISION,
1859
+ applicant_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
1860
+ primary_contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
1861
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','submitted','pending','approved','rejected','follow_up_needed','expired','cancelled')),
1862
+ submitted_date TEXT,
1863
+ decision_date TEXT,
1864
+ follow_up_date TEXT,
1865
+ notes TEXT,
1866
+ method TEXT CHECK(method IN ('email','form','typeform','hubspot','manual','browser','feathery','other')),
1867
+ form_url TEXT,
1868
+ metadata TEXT NOT NULL DEFAULT '{}',
1869
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1870
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1871
+ );
1872
+
1873
+ ALTER TABLE contact_notes ADD COLUMN IF NOT EXISTS company_id TEXT REFERENCES companies(id) ON DELETE SET NULL;
1874
+
1875
+ CREATE TABLE IF NOT EXISTS contact_embeddings (
1876
+ contact_id TEXT PRIMARY KEY REFERENCES contacts(id) ON DELETE CASCADE,
1877
+ embedding TEXT NOT NULL,
1878
+ model TEXT NOT NULL DEFAULT 'tfidf',
1879
+ embedded_text TEXT,
1880
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1881
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1882
+ );
1883
+
1884
+ INSERT INTO _migrations (version) VALUES (6) ON CONFLICT DO NOTHING;
1885
+ `,
1886
+ `
1887
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS do_not_contact BOOLEAN NOT NULL DEFAULT FALSE;
1888
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5);
1889
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS timezone TEXT;
1890
+
1891
+ CREATE TABLE IF NOT EXISTS deals (
1892
+ id TEXT PRIMARY KEY,
1893
+ title TEXT NOT NULL,
1894
+ contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
1895
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
1896
+ stage TEXT NOT NULL DEFAULT 'lead' CHECK(stage IN ('lead','qualified','proposal','negotiation','won','lost','cancelled')),
1897
+ value_usd DOUBLE PRECISION,
1898
+ currency TEXT NOT NULL DEFAULT 'USD',
1899
+ close_date TEXT,
1900
+ notes TEXT,
1901
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1902
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1903
+ );
1904
+
1905
+ CREATE TABLE IF NOT EXISTS events (
1906
+ id TEXT PRIMARY KEY,
1907
+ title TEXT NOT NULL,
1908
+ type TEXT NOT NULL DEFAULT 'meeting' CHECK(type IN ('meeting','call','lunch','email','demo','conference','intro','other')),
1909
+ event_date TEXT NOT NULL,
1910
+ duration_min INTEGER,
1911
+ contact_ids TEXT NOT NULL DEFAULT '[]',
1912
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
1913
+ notes TEXT,
1914
+ outcome TEXT,
1915
+ deal_id TEXT REFERENCES deals(id) ON DELETE SET NULL,
1916
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1917
+ );
1918
+
1919
+ INSERT INTO _migrations (version) VALUES (7) ON CONFLICT DO NOTHING;
1920
+ `,
1921
+ `
1922
+ CREATE TABLE IF NOT EXISTS contact_field_history (
1923
+ id TEXT PRIMARY KEY,
1924
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1925
+ field_name TEXT NOT NULL,
1926
+ old_value TEXT,
1927
+ new_value TEXT,
1928
+ valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1929
+ source TEXT,
1930
+ confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
1931
+ created_by TEXT,
1932
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1933
+ );
1934
+
1935
+ CREATE TABLE IF NOT EXISTS job_history (
1936
+ id TEXT PRIMARY KEY,
1937
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1938
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
1939
+ company_name TEXT NOT NULL,
1940
+ title TEXT,
1941
+ start_date TEXT,
1942
+ end_date TEXT,
1943
+ is_current BOOLEAN NOT NULL DEFAULT FALSE,
1944
+ inferred BOOLEAN NOT NULL DEFAULT FALSE,
1945
+ source TEXT,
1946
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1947
+ );
1948
+
1949
+ CREATE TABLE IF NOT EXISTS contact_learnings (
1950
+ id TEXT PRIMARY KEY,
1951
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1952
+ content TEXT NOT NULL,
1953
+ type TEXT NOT NULL DEFAULT 'fact' CHECK(type IN ('preference','fact','inference','warning','signal')),
1954
+ confidence INTEGER NOT NULL DEFAULT 70 CHECK(confidence BETWEEN 0 AND 100),
1955
+ importance INTEGER NOT NULL DEFAULT 5 CHECK(importance BETWEEN 1 AND 10),
1956
+ learned_by TEXT,
1957
+ session_id TEXT,
1958
+ visibility TEXT NOT NULL DEFAULT 'shared' CHECK(visibility IN ('private','shared','human')),
1959
+ tags TEXT NOT NULL DEFAULT '[]',
1960
+ confirmed_count INTEGER NOT NULL DEFAULT 0,
1961
+ contradicts_id TEXT REFERENCES contact_learnings(id) ON DELETE SET NULL,
1962
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1963
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1964
+ );
1965
+
1966
+ CREATE TABLE IF NOT EXISTS contact_locks (
1967
+ id TEXT PRIMARY KEY,
1968
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
1969
+ agent_name TEXT NOT NULL,
1970
+ reason TEXT,
1971
+ acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1972
+ expires_at TIMESTAMPTZ NOT NULL,
1973
+ session_id TEXT
1974
+ );
1975
+
1976
+ CREATE TABLE IF NOT EXISTS contact_agent_activity (
1977
+ id TEXT PRIMARY KEY,
1978
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1979
+ agent_name TEXT NOT NULL,
1980
+ action TEXT NOT NULL,
1981
+ details TEXT,
1982
+ session_id TEXT,
1983
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1984
+ );
1985
+
1986
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS strength_score INTEGER NOT NULL DEFAULT 50;
1987
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS interaction_count INTEGER NOT NULL DEFAULT 0;
1988
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS last_interaction TEXT;
1989
+ ALTER TABLE contact_relationships ADD COLUMN IF NOT EXISTS relationship_status TEXT NOT NULL DEFAULT 'stable' CHECK(relationship_status IN ('warming','stable','cooling','ghost'));
1990
+
1991
+ CREATE TABLE IF NOT EXISTS contact_identities (
1992
+ id TEXT PRIMARY KEY,
1993
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
1994
+ system TEXT NOT NULL,
1995
+ external_id TEXT NOT NULL,
1996
+ external_url TEXT,
1997
+ confidence TEXT NOT NULL DEFAULT 'inferred' CHECK(confidence IN ('verified','inferred')),
1998
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1999
+ UNIQUE(system, external_id)
2000
+ );
2001
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS canonical_id TEXT;
2002
+
2003
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS relationship_health INTEGER NOT NULL DEFAULT 50;
2004
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS avg_response_hours DOUBLE PRECISION;
2005
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS preferred_channel TEXT;
2006
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS engagement_status TEXT NOT NULL DEFAULT 'new' CHECK(engagement_status IN ('warming','stable','cooling','ghost','new'));
2007
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS interaction_count_30d INTEGER NOT NULL DEFAULT 0;
2008
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS interaction_count_90d INTEGER NOT NULL DEFAULT 0;
2009
+
2010
+ CREATE TABLE IF NOT EXISTS contact_field_confidence (
2011
+ id TEXT PRIMARY KEY,
2012
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2013
+ field_name TEXT NOT NULL,
2014
+ confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
2015
+ source TEXT,
2016
+ last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2017
+ UNIQUE(contact_id, field_name)
2018
+ );
2019
+
2020
+ CREATE TABLE IF NOT EXISTS org_chart_edges (
2021
+ id TEXT PRIMARY KEY,
2022
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
2023
+ contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2024
+ contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2025
+ edge_type TEXT NOT NULL CHECK(edge_type IN ('reports_to','manages','collaborates_with','peer')),
2026
+ inferred BOOLEAN NOT NULL DEFAULT FALSE,
2027
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2028
+ UNIQUE(company_id, contact_a_id, contact_b_id, edge_type)
2029
+ );
2030
+
2031
+ CREATE TABLE IF NOT EXISTS deal_contact_roles (
2032
+ id TEXT PRIMARY KEY,
2033
+ deal_id TEXT NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
2034
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2035
+ account_role TEXT NOT NULL CHECK(account_role IN ('economic_buyer','technical_evaluator','champion','blocker','influencer','user','sponsor','other')),
2036
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2037
+ UNIQUE(deal_id, contact_id)
2038
+ );
2039
+
2040
+ INSERT INTO _migrations (version) VALUES (8) ON CONFLICT DO NOTHING;
2041
+ `,
2042
+ `
2043
+ ALTER TABLE contacts ADD COLUMN IF NOT EXISTS sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK(sensitivity IN ('normal','confidential','restricted'));
2044
+
2045
+ CREATE TABLE IF NOT EXISTS contact_documents (
2046
+ id TEXT PRIMARY KEY,
2047
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
2048
+ doc_type TEXT NOT NULL,
2049
+ label TEXT,
2050
+ encrypted_value TEXT NOT NULL,
2051
+ iv TEXT NOT NULL,
2052
+ encrypted_file_path TEXT,
2053
+ metadata TEXT NOT NULL DEFAULT '{}',
2054
+ expires_at TEXT,
2055
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2056
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2057
+ );
2058
+
2059
+ CREATE TABLE IF NOT EXISTS contact_health (
2060
+ id TEXT PRIMARY KEY,
2061
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
2062
+ blood_type TEXT,
2063
+ allergies TEXT NOT NULL DEFAULT '[]',
2064
+ medical_conditions TEXT NOT NULL DEFAULT '[]',
2065
+ medications TEXT NOT NULL DEFAULT '[]',
2066
+ emergency_contacts TEXT NOT NULL DEFAULT '[]',
2067
+ health_insurance_provider TEXT,
2068
+ health_insurance_id TEXT,
2069
+ primary_physician TEXT,
2070
+ primary_physician_phone TEXT,
2071
+ organ_donor BOOLEAN NOT NULL DEFAULT FALSE,
2072
+ notes TEXT,
2073
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2074
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2075
+ );
2076
+
2077
+ INSERT INTO _migrations (version) VALUES (9) ON CONFLICT DO NOTHING;
2078
+ `,
2079
+ `
2080
+ CREATE TABLE IF NOT EXISTS feedback (
2081
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
2082
+ message TEXT NOT NULL,
2083
+ email TEXT,
2084
+ category TEXT DEFAULT 'general',
2085
+ version TEXT,
2086
+ machine_id TEXT,
2087
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2088
+ );
2089
+
2090
+ INSERT INTO _migrations (version) VALUES (10) ON CONFLICT DO NOTHING;
2091
+ `
2092
+ ];
2093
+
2094
+ // src/db/remote-storage.ts
2095
+ import pg from "pg";
2096
+ var LOCAL_PG_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
2097
+ function parsePgUrl(connectionString) {
2098
+ try {
2099
+ return new URL(connectionString);
2100
+ } catch {
2101
+ return;
2102
+ }
2103
+ }
2104
+ function isLocalPgHost(hostname) {
2105
+ return LOCAL_PG_HOSTS.has(hostname.replace(/^\[|\]$/g, ""));
2106
+ }
2107
+ function normalizedParams(url, name) {
2108
+ return url.searchParams.getAll(name).map((value) => value.trim().toLowerCase());
2109
+ }
2110
+ function isFalseySslValue(value) {
2111
+ return value === "0" || value === "false" || value === "no" || value === "disable" || value === "disabled";
2112
+ }
2113
+ function isTruthyValue(value) {
2114
+ return value === "1" || value === "true" || value === "yes";
2115
+ }
2116
+ function sanitizedConnectionString(url) {
2117
+ for (const param of ["ssl", "sslmode", "uselibpqcompat"]) {
2118
+ url.searchParams.delete(param);
2119
+ }
2120
+ return url.toString();
2121
+ }
2122
+ function buildPgPoolConfig(connectionString) {
2123
+ const url = parsePgUrl(connectionString);
2124
+ if (!url) {
2125
+ return { connectionString, ssl: { rejectUnauthorized: true } };
2126
+ }
2127
+ const localHost = isLocalPgHost(url.hostname);
2128
+ const sslModes = normalizedParams(url, "sslmode");
2129
+ const sslParams = normalizedParams(url, "ssl");
2130
+ const libpqCompatParams = normalizedParams(url, "uselibpqcompat");
2131
+ const explicitTlsOff = sslModes.includes("disable") || sslParams.some(isFalseySslValue);
2132
+ const weakTlsMode = sslModes.some((mode) => mode === "allow" || mode === "prefer" || mode === "no-verify");
2133
+ const libpqCompatibility = libpqCompatParams.some(isTruthyValue);
2134
+ const sanitized = sanitizedConnectionString(url);
2135
+ if ((explicitTlsOff || weakTlsMode || libpqCompatibility) && !localHost) {
2136
+ throw new Error("Remote contacts PostgreSQL storage must use verified TLS.");
2137
+ }
2138
+ if (localHost && (explicitTlsOff || sslModes.length === 0 && sslParams.length === 0)) {
2139
+ return { connectionString: sanitized };
2140
+ }
2141
+ return { connectionString: sanitized, ssl: { rejectUnauthorized: true } };
2142
+ }
2143
+ function translatePlaceholders(sql) {
2144
+ let index = 0;
2145
+ return sql.replace(/\?/g, () => `$${++index}`);
2146
+ }
2147
+ function normalizeParams2(params) {
2148
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
2149
+ return flat.map((value) => value === undefined ? null : value);
2150
+ }
2151
+
2152
+ class PgAdapterAsync {
2153
+ pool;
2154
+ constructor(connectionString) {
2155
+ this.pool = new pg.Pool(buildPgPoolConfig(connectionString));
2156
+ }
2157
+ async run(sql, ...params) {
2158
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
2159
+ return { changes: result.rowCount ?? 0 };
2160
+ }
2161
+ async get(sql, ...params) {
2162
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
2163
+ return result.rows[0] ?? null;
2164
+ }
2165
+ async all(sql, ...params) {
2166
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams2(params));
2167
+ return result.rows;
2168
+ }
2169
+ async exec(sql) {
2170
+ await this.pool.query(sql);
2171
+ }
2172
+ async close() {
2173
+ await this.pool.end();
2174
+ }
2175
+ }
2176
+
2177
+ // src/db/remote-sync.ts
2178
+ var CONTACTS_REMOTE_TABLES = [
2179
+ "companies",
2180
+ "contacts",
2181
+ "tags",
2182
+ "contact_tags",
2183
+ "company_tags",
2184
+ "emails",
2185
+ "phones",
2186
+ "addresses",
2187
+ "social_profiles",
2188
+ "contact_relationships",
2189
+ "activity_log",
2190
+ "webhooks",
2191
+ "groups",
2192
+ "contact_groups",
2193
+ "company_groups",
2194
+ "company_relationships",
2195
+ "contact_projects",
2196
+ "contact_notes",
2197
+ "org_members",
2198
+ "vendor_communications",
2199
+ "contact_tasks",
2200
+ "applications",
2201
+ "deals",
2202
+ "events",
2203
+ "contact_field_history",
2204
+ "job_history",
2205
+ "contact_learnings",
2206
+ "contact_locks",
2207
+ "contact_agent_activity",
2208
+ "contact_identities",
2209
+ "contact_field_confidence",
2210
+ "org_chart_edges",
2211
+ "deal_contact_roles",
2212
+ "contact_embeddings",
2213
+ "contact_documents",
2214
+ "contact_health",
2215
+ "feedback"
2216
+ ];
2217
+ var CONTACTS_REMOTE_SENSITIVE_TABLES = [
2218
+ "webhooks",
2219
+ "contact_documents",
2220
+ "contact_health"
2221
+ ];
2222
+ var CONTACTS_REMOTE_DEFAULT_TABLES = CONTACTS_REMOTE_TABLES.filter((table) => !CONTACTS_REMOTE_SENSITIVE_TABLES.includes(table));
2223
+ var PRIMARY_KEYS = {
2224
+ companies: ["id"],
2225
+ contacts: ["id"],
2226
+ tags: ["id"],
2227
+ contact_tags: ["contact_id", "tag_id"],
2228
+ company_tags: ["company_id", "tag_id"],
2229
+ emails: ["id"],
2230
+ phones: ["id"],
2231
+ addresses: ["id"],
2232
+ social_profiles: ["id"],
2233
+ contact_relationships: ["id"],
2234
+ activity_log: ["id"],
2235
+ webhooks: ["id"],
2236
+ groups: ["id"],
2237
+ contact_groups: ["contact_id", "group_id"],
2238
+ company_groups: ["company_id", "group_id"],
2239
+ company_relationships: ["id"],
2240
+ contact_projects: ["contact_id", "project_id"],
2241
+ contact_notes: ["id"],
2242
+ org_members: ["id"],
2243
+ vendor_communications: ["id"],
2244
+ contact_tasks: ["id"],
2245
+ applications: ["id"],
2246
+ deals: ["id"],
2247
+ events: ["id"],
2248
+ contact_field_history: ["id"],
2249
+ job_history: ["id"],
2250
+ contact_learnings: ["id"],
2251
+ contact_locks: ["id"],
2252
+ contact_agent_activity: ["id"],
2253
+ contact_identities: ["id"],
2254
+ contact_field_confidence: ["id"],
2255
+ org_chart_edges: ["id"],
2256
+ deal_contact_roles: ["id"],
2257
+ contact_embeddings: ["contact_id"],
2258
+ contact_documents: ["id"],
2259
+ contact_health: ["id"],
2260
+ feedback: ["id"]
2261
+ };
2262
+ var CONTACTS_REMOTE_ENV = [
2263
+ "HASNA_CONTACTS_POSTGRES_URL",
2264
+ "OPEN_CONTACTS_POSTGRES_URL",
2265
+ "CONTACTS_POSTGRES_URL",
2266
+ "HASNA_CONTACTS_DATABASE_URL",
2267
+ "CONTACTS_DATABASE_URL"
2268
+ ];
2269
+
2270
+ class ContactsRemoteSyncError extends Error {
2271
+ results;
2272
+ constructor(message, results) {
2273
+ super(message);
2274
+ this.results = results;
2275
+ this.name = "ContactsRemoteSyncError";
2276
+ }
2277
+ }
2278
+ function getRemoteDatabaseUrl() {
2279
+ for (const name of CONTACTS_REMOTE_ENV) {
2280
+ const value = process.env[name];
2281
+ if (value)
2282
+ return value;
2283
+ }
2284
+ return null;
2285
+ }
2286
+ async function getRemotePg() {
2287
+ const url = getRemoteDatabaseUrl();
2288
+ if (!url)
2289
+ throw new Error(`Missing contacts remote database URL. Set one of: ${CONTACTS_REMOTE_ENV.join(", ")}`);
2290
+ return new PgAdapterAsync(url);
2291
+ }
2292
+ async function runRemoteMigrations(remote) {
2293
+ await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
2294
+ for (const sql of PG_MIGRATIONS)
2295
+ await remote.run(sql);
2296
+ }
2297
+ async function pushRemote(options) {
2298
+ const remote = await getRemotePg();
2299
+ const db = getDatabase();
2300
+ try {
2301
+ await runRemoteMigrations(remote);
2302
+ const results = [];
2303
+ for (const table of resolveRemoteTables(options?.tables))
2304
+ results.push(await pushTable(db, remote, table));
2305
+ recordSyncMeta(db, "push", results);
2306
+ throwIfSyncErrors("push", results);
2307
+ return results;
2308
+ } finally {
2309
+ await remote.close();
2310
+ }
2311
+ }
2312
+ async function pullRemote(options) {
2313
+ const remote = await getRemotePg();
2314
+ const db = getDatabase();
2315
+ try {
2316
+ await runRemoteMigrations(remote);
2317
+ const results = [];
2318
+ for (const table of resolveRemoteTables(options?.tables))
2319
+ results.push(await pullTable(remote, db, table));
2320
+ recordSyncMeta(db, "pull", results);
2321
+ throwIfSyncErrors("pull", results);
2322
+ return results;
2323
+ } finally {
2324
+ await remote.close();
2325
+ }
2326
+ }
2327
+ async function syncRemote(options) {
2328
+ const pull = await pullRemote(options);
2329
+ const push = await pushRemote(options);
2330
+ return { pull, push };
2331
+ }
2332
+ function getSyncMetaAll() {
2333
+ const db = getDatabase();
2334
+ ensureSyncMetaTable(db);
2335
+ return db.query("SELECT table_name, last_synced_at, direction FROM _contacts_sync_meta ORDER BY table_name, direction").all();
2336
+ }
2337
+ function getRemoteStatus() {
2338
+ return {
2339
+ configured: Boolean(getRemoteDatabaseUrl()),
2340
+ env: CONTACTS_REMOTE_ENV,
2341
+ default_tables: CONTACTS_REMOTE_DEFAULT_TABLES,
2342
+ sensitive_tables: CONTACTS_REMOTE_SENSITIVE_TABLES,
2343
+ tables: CONTACTS_REMOTE_TABLES,
2344
+ sync: getSyncMetaAll()
2345
+ };
2346
+ }
2347
+ function resolveRemoteTables(tables) {
2348
+ if (!tables || tables.length === 0)
2349
+ return [...CONTACTS_REMOTE_DEFAULT_TABLES];
2350
+ const allowed = new Set(CONTACTS_REMOTE_TABLES);
2351
+ const requested = tables.map((table) => table.trim()).filter(Boolean);
2352
+ const invalid = requested.filter((table) => !allowed.has(table));
2353
+ if (invalid.length > 0)
2354
+ throw new Error(`Unknown contacts sync table(s): ${invalid.join(", ")}`);
2355
+ return requested;
2356
+ }
2357
+ async function pushTable(db, remote, table) {
2358
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
2359
+ try {
2360
+ const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
2361
+ result.rowsRead = rows.length;
2362
+ if (rows.length === 0)
2363
+ return result;
2364
+ const remoteColumns = await getRemoteColumns(remote, table);
2365
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
2366
+ result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
2367
+ } catch (error) {
2368
+ result.errors.push(error instanceof Error ? error.message : String(error));
2369
+ }
2370
+ return result;
2371
+ }
2372
+ async function pullTable(remote, db, table) {
2373
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
2374
+ try {
2375
+ const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
2376
+ result.rowsRead = rows.length;
2377
+ if (rows.length === 0)
2378
+ return result;
2379
+ const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
2380
+ result.rowsWritten = upsertSqlite(db, table, columns, rows);
2381
+ } catch (error) {
2382
+ result.errors.push(error instanceof Error ? error.message : String(error));
2383
+ }
2384
+ return result;
2385
+ }
2386
+ async function getRemoteColumns(remote, table) {
2387
+ const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
2388
+ return new Map(rows.map((row) => [row.column_name, row.data_type]));
2389
+ }
2390
+ function filterRemoteColumns(remoteColumns, columns) {
2391
+ if (remoteColumns.size === 0)
2392
+ return columns;
2393
+ return columns.filter((column) => remoteColumns.has(column));
2394
+ }
2395
+ function filterLocalColumns(db, table, columns) {
2396
+ const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
2397
+ const allowed = new Set(rows.map((row) => row.name));
2398
+ return columns.filter((column) => allowed.has(column));
2399
+ }
2400
+ async function upsertPg(remote, table, columns, rows, remoteColumns) {
2401
+ if (columns.length === 0)
2402
+ return 0;
2403
+ const primaryKeys = PRIMARY_KEYS[table];
2404
+ const columnList = columns.map(quoteIdent).join(", ");
2405
+ const placeholders = columns.map(() => "?").join(", ");
2406
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
2407
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
2408
+ const fallbackKey = primaryKeys[0];
2409
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
2410
+ const whereClause = conflictWhereClause(table, columns, "EXCLUDED");
2411
+ for (const row of rows) {
2412
+ await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`, columns.map((column) => toPostgresValue(row[column], remoteColumns.get(column))));
2413
+ }
2414
+ return rows.length;
2415
+ }
2416
+ function upsertSqlite(db, table, columns, rows) {
2417
+ if (columns.length === 0)
2418
+ return 0;
2419
+ const primaryKeys = PRIMARY_KEYS[table];
2420
+ const columnList = columns.map(quoteIdent).join(", ");
2421
+ const placeholders = columns.map(() => "?").join(", ");
2422
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
2423
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
2424
+ const fallbackKey = primaryKeys[0];
2425
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
2426
+ const whereClause = conflictWhereClause(table, columns, "excluded");
2427
+ const statement = db.prepare(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}${whereClause}`);
2428
+ for (const row of rows) {
2429
+ const values = columns.map((column) => normalizeSqliteSyncValue(row[column]));
2430
+ statement.run(...values);
2431
+ }
2432
+ return rows.length;
2433
+ }
2434
+ function conflictWhereClause(table, columns, incomingAlias) {
2435
+ const timestampColumn = columns.includes("updated_at") ? "updated_at" : columns.includes("created_at") ? "created_at" : null;
2436
+ if (!timestampColumn)
2437
+ return "";
2438
+ const existing = `${quoteIdent(table)}.${quoteIdent(timestampColumn)}`;
2439
+ const incoming = `${incomingAlias}.${quoteIdent(timestampColumn)}`;
2440
+ return ` WHERE ${existing} IS NULL OR ${incoming} IS NULL OR ${incoming} >= ${existing}`;
2441
+ }
2442
+ function toPostgresValue(value, dataType) {
2443
+ if (value === undefined)
2444
+ return null;
2445
+ if (dataType === "boolean") {
2446
+ if (value === null)
2447
+ return null;
2448
+ if (typeof value === "number")
2449
+ return value !== 0;
2450
+ if (typeof value === "string")
2451
+ return !["0", "false", "no", ""].includes(value.toLowerCase());
2452
+ return Boolean(value);
2453
+ }
2454
+ return value;
2455
+ }
2456
+ function normalizeSqliteSyncValue(value) {
2457
+ if (value === undefined || value === null)
2458
+ return null;
2459
+ if (value instanceof Date)
2460
+ return value.toISOString();
2461
+ if (typeof value === "boolean")
2462
+ return value ? 1 : 0;
2463
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)
2464
+ return value;
2465
+ if (typeof value === "object")
2466
+ return JSON.stringify(value);
2467
+ return value;
2468
+ }
2469
+ function ensureSyncMetaTable(db) {
2470
+ db.exec(`
2471
+ CREATE TABLE IF NOT EXISTS _contacts_sync_meta (
2472
+ table_name TEXT NOT NULL,
2473
+ direction TEXT NOT NULL CHECK(direction IN ('push','pull')),
2474
+ last_synced_at TEXT NOT NULL,
2475
+ PRIMARY KEY (table_name, direction)
2476
+ )
2477
+ `);
2478
+ }
2479
+ function recordSyncMeta(db, direction, results) {
2480
+ ensureSyncMetaTable(db);
2481
+ const now2 = new Date().toISOString();
2482
+ const statement = db.prepare(`
2483
+ INSERT INTO _contacts_sync_meta (table_name, direction, last_synced_at)
2484
+ VALUES (?, ?, ?)
2485
+ ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
2486
+ `);
2487
+ for (const result of results) {
2488
+ if (result.errors.length === 0)
2489
+ statement.run(result.table, direction, now2);
2490
+ }
2491
+ }
2492
+ function throwIfSyncErrors(operation, results) {
2493
+ const failures = results.filter((result) => result.errors.length > 0);
2494
+ if (failures.length === 0)
2495
+ return;
2496
+ const details = failures.map((result) => `${result.table}: ${result.errors.join("; ")}`).join("; ");
2497
+ throw new ContactsRemoteSyncError(`Contacts remote ${operation} failed for ${failures.length} table(s): ${details}`, results);
2498
+ }
2499
+ function quoteIdent(identifier) {
2500
+ return `"${identifier.replaceAll('"', '""')}"`;
2501
+ }
1524
2502
  // src/db/feedback.ts
1525
2503
  init_database();
1526
2504
  import { hostname } from "os";
@@ -5109,6 +6087,7 @@ export {
5109
6087
  unlockVault,
5110
6088
  unarchiveContact,
5111
6089
  unarchiveCompany,
6090
+ syncRemote,
5112
6091
  storeFile,
5113
6092
  setHealthData,
5114
6093
  setDealContactRole,
@@ -5121,7 +6100,9 @@ export {
5121
6100
  saveLocalFeedback,
5122
6101
  saveLearning,
5123
6102
  saveImage,
6103
+ runRemoteMigrations,
5124
6104
  runConnector,
6105
+ resolveRemoteTables,
5125
6106
  resolveIdentity,
5126
6107
  resolveByPartial,
5127
6108
  resetDatabase,
@@ -5135,12 +6116,15 @@ export {
5135
6116
  recordFieldChange,
5136
6117
  recomputeAllSignals,
5137
6118
  readConnectorTokens,
6119
+ pushRemote,
5138
6120
  pushContactToGoogle,
6121
+ pullRemote,
5139
6122
  pullGoogleContactsAsInputs,
5140
6123
  parseName,
5141
6124
  parseLinkedIn,
5142
6125
  parseEmailSignature,
5143
6126
  parseAddressHeader,
6127
+ normalizeSqliteSyncValue,
5144
6128
  mergeContacts,
5145
6129
  markFollowUpDone,
5146
6130
  markFieldVerified,
@@ -5196,8 +6180,12 @@ export {
5196
6180
  getUpcomingItems,
5197
6181
  getTagByName,
5198
6182
  getTag,
6183
+ getSyncMetaAll,
5199
6184
  getStorageStatus,
5200
6185
  getStaleContacts,
6186
+ getRemoteStatus,
6187
+ getRemotePg,
6188
+ getRemoteDatabaseUrl,
5201
6189
  getRelationshipSignals,
5202
6190
  getRelationship,
5203
6191
  getPreviousEmployers,
@@ -5285,6 +6273,7 @@ export {
5285
6273
  cleanExpiredLocks,
5286
6274
  checkLock,
5287
6275
  checkEscalations,
6276
+ buildPgPoolConfig,
5288
6277
  buildContactEmbeddingText,
5289
6278
  autoLinkContactToCompany,
5290
6279
  auditContact,
@@ -5306,11 +6295,17 @@ export {
5306
6295
  acquireLock,
5307
6296
  TagNotFoundError,
5308
6297
  SqliteAdapter,
6298
+ PgAdapterAsync,
5309
6299
  DuplicateTagNameError,
5310
6300
  DOCUMENT_TYPES,
6301
+ ContactsRemoteSyncError,
5311
6302
  ContactNotFoundError,
5312
6303
  ConnectorNotInstalledError,
5313
6304
  ConnectorAuthError,
5314
6305
  CompanyNotFoundError,
5315
- CONTACTS_STORAGE_TABLES
6306
+ CONTACTS_STORAGE_TABLES,
6307
+ CONTACTS_REMOTE_TABLES,
6308
+ CONTACTS_REMOTE_SENSITIVE_TABLES,
6309
+ CONTACTS_REMOTE_ENV,
6310
+ CONTACTS_REMOTE_DEFAULT_TABLES
5316
6311
  };