@context-vault/core 2.8.3

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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * sync.js — Bidirectional sync protocol
3
+ *
4
+ * v1 design:
5
+ * - Additive-only — no delete propagation (avoids data loss)
6
+ * - Last-write-wins by created_at for conflicts (both have same ID)
7
+ * - Push uses POST /api/vault/import/bulk
8
+ * - Pull uses GET /api/vault/export + local captureAndIndex()
9
+ */
10
+
11
+ import { captureAndIndex } from "../capture/index.js";
12
+
13
+ /**
14
+ * Build a manifest of local vault entries (id → { id, created_at, kind, title }).
15
+ *
16
+ * @param {import('../server/types.js').BaseCtx} ctx
17
+ * @returns {Map<string, { id: string, created_at: string, kind: string, title: string|null }>}
18
+ */
19
+ export function buildLocalManifest(ctx) {
20
+ const rows = ctx.db
21
+ .prepare(
22
+ "SELECT id, created_at, kind, title FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now'))",
23
+ )
24
+ .all();
25
+
26
+ const manifest = new Map();
27
+ for (const row of rows) {
28
+ manifest.set(row.id, {
29
+ id: row.id,
30
+ created_at: row.created_at,
31
+ kind: row.kind,
32
+ title: row.title || null,
33
+ });
34
+ }
35
+ return manifest;
36
+ }
37
+
38
+ /**
39
+ * Fetch the remote vault manifest from the hosted API.
40
+ *
41
+ * @param {string} hostedUrl - Base URL of hosted service
42
+ * @param {string} apiKey - Bearer token
43
+ * @returns {Promise<Map<string, { id: string, created_at: string, kind: string, title: string|null }>>}
44
+ */
45
+ export async function fetchRemoteManifest(hostedUrl, apiKey) {
46
+ const response = await fetch(`${hostedUrl}/api/vault/manifest`, {
47
+ headers: { Authorization: `Bearer ${apiKey}` },
48
+ });
49
+
50
+ if (!response.ok) {
51
+ throw new Error(`Failed to fetch remote manifest: HTTP ${response.status}`);
52
+ }
53
+
54
+ const data = await response.json();
55
+ const manifest = new Map();
56
+
57
+ for (const entry of data.entries || []) {
58
+ manifest.set(entry.id, {
59
+ id: entry.id,
60
+ created_at: entry.created_at,
61
+ kind: entry.kind,
62
+ title: entry.title || null,
63
+ });
64
+ }
65
+
66
+ return manifest;
67
+ }
68
+
69
+ /**
70
+ * @typedef {object} SyncPlan
71
+ * @property {string[]} toPush - Entry IDs that exist locally but not remotely
72
+ * @property {string[]} toPull - Entry IDs that exist remotely but not locally
73
+ * @property {string[]} upToDate - Entry IDs that exist in both
74
+ */
75
+
76
+ /**
77
+ * Compute what needs to be pushed/pulled by comparing manifests.
78
+ * Additive-only: entries in both are considered up-to-date.
79
+ *
80
+ * @param {Map<string, object>} local
81
+ * @param {Map<string, object>} remote
82
+ * @returns {SyncPlan}
83
+ */
84
+ export function computeSyncPlan(local, remote) {
85
+ const toPush = [];
86
+ const toPull = [];
87
+ const upToDate = [];
88
+
89
+ // Find local-only entries
90
+ for (const id of local.keys()) {
91
+ if (remote.has(id)) {
92
+ upToDate.push(id);
93
+ } else {
94
+ toPush.push(id);
95
+ }
96
+ }
97
+
98
+ // Find remote-only entries
99
+ for (const id of remote.keys()) {
100
+ if (!local.has(id)) {
101
+ toPull.push(id);
102
+ }
103
+ }
104
+
105
+ return { toPush, toPull, upToDate };
106
+ }
107
+
108
+ /**
109
+ * Execute a sync plan: push local entries to remote, pull remote entries to local.
110
+ *
111
+ * @param {import('../server/types.js').BaseCtx & Partial<import('../server/types.js').HostedCtxExtensions>} ctx
112
+ * @param {{ hostedUrl: string, apiKey: string, plan: SyncPlan, onProgress?: (phase: string, current: number, total: number) => void }} opts
113
+ * @returns {Promise<{ pushed: number, pulled: number, failed: number, errors: string[] }>}
114
+ */
115
+ export async function executeSync(
116
+ ctx,
117
+ { hostedUrl, apiKey, plan, onProgress },
118
+ ) {
119
+ let pushed = 0;
120
+ let pulled = 0;
121
+ let failed = 0;
122
+ const errors = [];
123
+
124
+ // ── Push: upload local-only entries to remote ──
125
+ if (plan.toPush.length > 0) {
126
+ const BATCH_SIZE = 50;
127
+ const entries = [];
128
+
129
+ // Collect full entry data for push
130
+ for (const id of plan.toPush) {
131
+ const row = ctx.stmts.getEntryById.get(id);
132
+ if (!row) continue;
133
+
134
+ entries.push({
135
+ kind: row.kind,
136
+ title: row.title || null,
137
+ body: row.body,
138
+ tags: row.tags ? JSON.parse(row.tags) : [],
139
+ meta: row.meta ? JSON.parse(row.meta) : undefined,
140
+ source: row.source || "sync-push",
141
+ identity_key: row.identity_key || undefined,
142
+ expires_at: row.expires_at || undefined,
143
+ });
144
+ }
145
+
146
+ // Push in batches
147
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
148
+ const batch = entries.slice(i, i + BATCH_SIZE);
149
+ if (onProgress) onProgress("push", i + batch.length, entries.length);
150
+
151
+ try {
152
+ const response = await fetch(`${hostedUrl}/api/vault/import/bulk`, {
153
+ method: "POST",
154
+ headers: {
155
+ "Content-Type": "application/json",
156
+ Authorization: `Bearer ${apiKey}`,
157
+ },
158
+ body: JSON.stringify({ entries: batch }),
159
+ });
160
+
161
+ if (!response.ok) {
162
+ const errData = await response.json().catch(() => ({}));
163
+ failed += batch.length;
164
+ errors.push(
165
+ `Push batch failed: HTTP ${response.status} — ${errData.error || "unknown"}`,
166
+ );
167
+ continue;
168
+ }
169
+
170
+ const result = await response.json();
171
+ pushed += result.imported || 0;
172
+ failed += result.failed || 0;
173
+ if (result.errors?.length) {
174
+ errors.push(...result.errors);
175
+ }
176
+ } catch (err) {
177
+ failed += batch.length;
178
+ errors.push(`Push batch failed: ${err.message}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // ── Pull: download remote-only entries to local ──
184
+ if (plan.toPull.length > 0) {
185
+ if (onProgress) onProgress("pull", 0, plan.toPull.length);
186
+
187
+ try {
188
+ const response = await fetch(`${hostedUrl}/api/vault/export`, {
189
+ headers: { Authorization: `Bearer ${apiKey}` },
190
+ });
191
+
192
+ if (!response.ok) {
193
+ throw new Error(`Export failed: HTTP ${response.status}`);
194
+ }
195
+
196
+ const data = await response.json();
197
+ const remoteEntries = data.entries || [];
198
+
199
+ // Filter to only pull entries we need
200
+ const pullIds = new Set(plan.toPull);
201
+ const entriesToPull = remoteEntries.filter((e) => pullIds.has(e.id));
202
+
203
+ for (let i = 0; i < entriesToPull.length; i++) {
204
+ const entry = entriesToPull[i];
205
+ if (onProgress) onProgress("pull", i + 1, entriesToPull.length);
206
+
207
+ try {
208
+ await captureAndIndex(ctx, {
209
+ kind: entry.kind,
210
+ title: entry.title,
211
+ body: entry.body,
212
+ meta:
213
+ entry.meta && typeof entry.meta === "object"
214
+ ? entry.meta
215
+ : undefined,
216
+ tags: Array.isArray(entry.tags) ? entry.tags : undefined,
217
+ source: entry.source || "sync-pull",
218
+ identity_key: entry.identity_key,
219
+ expires_at: entry.expires_at,
220
+ userId: ctx.userId || null,
221
+ });
222
+ pulled++;
223
+ } catch (err) {
224
+ failed++;
225
+ errors.push(`Pull "${entry.title || entry.id}": ${err.message}`);
226
+ }
227
+ }
228
+ } catch (err) {
229
+ failed += plan.toPull.length;
230
+ errors.push(`Pull failed: ${err.message}`);
231
+ }
232
+ }
233
+
234
+ return { pushed, pulled, failed, errors };
235
+ }