parallelclaw 1.0.0
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/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
package/lib/sync/push.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /sync/push — receive rows from a peer and apply them locally.
|
|
3
|
+
*
|
|
4
|
+
* Wire contract (see SYNC.md §Wire protocol):
|
|
5
|
+
*
|
|
6
|
+
* Body: { "rows": [Row, Row, ...] } 1..1000 rows
|
|
7
|
+
*
|
|
8
|
+
* Row: {
|
|
9
|
+
* source, conversation_id, msg_id, uuid, role, sender, text,
|
|
10
|
+
* ts, edited_at, channel, metadata,
|
|
11
|
+
* conversation: { title, first_ts, last_ts, parent_conversation_id, project_path }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Response: {
|
|
15
|
+
* accepted: N, // newly inserted (we didn't have this msg_id before)
|
|
16
|
+
* deduplicated: M, // already had — ON CONFLICT updated text from caller
|
|
17
|
+
* last_id: <int>
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Behaviour mirrors lib/ingest-file.js so a synced row ends up identical to
|
|
21
|
+
* a row written by the daemon's own ingest path. UNIQUE(source, conv_id, msg_id)
|
|
22
|
+
* guarantees idempotency — a stuck push can be retried indefinitely without
|
|
23
|
+
* duplicating rows.
|
|
24
|
+
*
|
|
25
|
+
* The "deduplicated" count is computed via a pre-check (single indexed lookup
|
|
26
|
+
* against the UNIQUE index) — cheap, and the only way to distinguish INSERT
|
|
27
|
+
* from ON-CONFLICT-UPDATE since better-sqlite3 reports changes=1 for both.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomUUID } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB
|
|
33
|
+
const MAX_ROWS_PER_PUSH = 1000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the push handler. Pass a read-write db handle. Returns a (req, res)
|
|
37
|
+
* function suitable for direct mounting under the sync server's router.
|
|
38
|
+
*
|
|
39
|
+
* Prepared statements are created once at handler-build time and reused
|
|
40
|
+
* per request — fast path with no statement compilation overhead.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Pure row-applier — the shared core that both the HTTP push handler and the
|
|
44
|
+
* local replicate-pull path use to insert rows. NO body-size cap here: that
|
|
45
|
+
* limit is a NETWORK concern (bounding a single HTTP request), not a reason
|
|
46
|
+
* to refuse applying rows we already hold in memory locally.
|
|
47
|
+
*
|
|
48
|
+
* Returns { apply(rows) → {accepted, deduplicated, lastId, firstError} }.
|
|
49
|
+
* Prepared statements are compiled once at build time.
|
|
50
|
+
*/
|
|
51
|
+
export function makeRowApplier({ db }) {
|
|
52
|
+
if (!db) throw new Error('makeRowApplier: db is required');
|
|
53
|
+
|
|
54
|
+
const insertMessage = db.prepare(`
|
|
55
|
+
INSERT INTO messages (source, conversation_id, msg_id, role, sender, text,
|
|
56
|
+
ts, metadata, edited_at, uuid, channel, origin)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
58
|
+
ON CONFLICT(source, conversation_id, msg_id) DO UPDATE SET
|
|
59
|
+
text = excluded.text,
|
|
60
|
+
uuid = COALESCE(messages.uuid, excluded.uuid),
|
|
61
|
+
origin = COALESCE(messages.origin, excluded.origin)
|
|
62
|
+
`);
|
|
63
|
+
|
|
64
|
+
const upsertConversation = db.prepare(`
|
|
65
|
+
INSERT INTO conversations (conversation_id, source, title, first_ts,
|
|
66
|
+
last_ts, message_count,
|
|
67
|
+
parent_conversation_id, project_path)
|
|
68
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
69
|
+
ON CONFLICT(conversation_id) DO UPDATE SET
|
|
70
|
+
title = COALESCE(excluded.title, title),
|
|
71
|
+
first_ts = COALESCE(MIN(first_ts, excluded.first_ts), excluded.first_ts, first_ts),
|
|
72
|
+
last_ts = COALESCE(MAX(last_ts, excluded.last_ts), excluded.last_ts, last_ts),
|
|
73
|
+
project_path = COALESCE(excluded.project_path, project_path),
|
|
74
|
+
message_count = (
|
|
75
|
+
SELECT COUNT(*) FROM messages WHERE messages.conversation_id = conversations.conversation_id
|
|
76
|
+
)
|
|
77
|
+
`);
|
|
78
|
+
|
|
79
|
+
const existsCheck = db.prepare(`
|
|
80
|
+
SELECT 1 FROM messages
|
|
81
|
+
WHERE source = ? AND conversation_id = ? AND msg_id = ?
|
|
82
|
+
LIMIT 1
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
const maxIdQuery = db.prepare(`SELECT MAX(id) AS id FROM messages`);
|
|
86
|
+
|
|
87
|
+
function apply(rows) {
|
|
88
|
+
let accepted = 0;
|
|
89
|
+
let deduplicated = 0;
|
|
90
|
+
let skipped = 0;
|
|
91
|
+
let firstError = null;
|
|
92
|
+
|
|
93
|
+
const tx = db.transaction(() => {
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const ok = applyRow(row, {
|
|
96
|
+
insertMessage, upsertConversation, existsCheck,
|
|
97
|
+
countAccepted: () => accepted++,
|
|
98
|
+
countDedup: () => deduplicated++,
|
|
99
|
+
recordError: (err) => { if (!firstError) firstError = err; },
|
|
100
|
+
});
|
|
101
|
+
// A row that neither inserted nor dedup'd (validation reject or a
|
|
102
|
+
// caught per-row error) is a SKIP. Surfacing the count makes silent
|
|
103
|
+
// drops visible — verbatim memory must never lose rows quietly.
|
|
104
|
+
if (!ok) skipped++;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
tx();
|
|
108
|
+
|
|
109
|
+
const lastRow = maxIdQuery.get();
|
|
110
|
+
return { accepted, deduplicated, skipped, lastId: lastRow?.id ?? 0, firstError };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { apply };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function makePushHandler({ db }) {
|
|
117
|
+
if (!db) throw new Error('makePushHandler: db is required');
|
|
118
|
+
|
|
119
|
+
const applier = makeRowApplier({ db });
|
|
120
|
+
|
|
121
|
+
return function pushHandler(req, res) {
|
|
122
|
+
readBody(req, res, MAX_BODY_BYTES)
|
|
123
|
+
.then((body) => {
|
|
124
|
+
if (body == null) return; // already responded with 413
|
|
125
|
+
let payload;
|
|
126
|
+
try {
|
|
127
|
+
payload = JSON.parse(body);
|
|
128
|
+
} catch (_) {
|
|
129
|
+
return respondJson(res, 400, { error: 'bad_request', detail: 'invalid JSON' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!payload || !Array.isArray(payload.rows)) {
|
|
133
|
+
return respondJson(res, 400, { error: 'bad_request', detail: 'rows[] required' });
|
|
134
|
+
}
|
|
135
|
+
if (payload.rows.length === 0) {
|
|
136
|
+
return respondJson(res, 200, { accepted: 0, deduplicated: 0, last_id: 0 });
|
|
137
|
+
}
|
|
138
|
+
if (payload.rows.length > MAX_ROWS_PER_PUSH) {
|
|
139
|
+
return respondJson(res, 400, {
|
|
140
|
+
error: 'bad_request',
|
|
141
|
+
detail: `rows[] max ${MAX_ROWS_PER_PUSH}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let result;
|
|
146
|
+
try {
|
|
147
|
+
result = applier.apply(payload.rows);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return respondJson(res, 500, {
|
|
150
|
+
error: 'internal',
|
|
151
|
+
detail: `transaction failed: ${err.message}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const body200 = {
|
|
156
|
+
accepted: result.accepted,
|
|
157
|
+
deduplicated: result.deduplicated,
|
|
158
|
+
last_id: result.lastId,
|
|
159
|
+
};
|
|
160
|
+
if (result.firstError) body200.warning = result.firstError;
|
|
161
|
+
respondJson(res, 200, body200);
|
|
162
|
+
})
|
|
163
|
+
.catch((err) => {
|
|
164
|
+
respondJson(res, 500, { error: 'internal', detail: err.message });
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply one Row inside the transaction. Returns true if the row was processed
|
|
171
|
+
* (regardless of accepted vs deduplicated), false if validation rejected it.
|
|
172
|
+
*
|
|
173
|
+
* Validation is intentionally lenient — we want sync to be forward-compatible
|
|
174
|
+
* with future schema additions, so unknown fields are ignored rather than
|
|
175
|
+
* rejected. Only the bare minimum is enforced.
|
|
176
|
+
*/
|
|
177
|
+
function applyRow(row, ctx) {
|
|
178
|
+
// Required fields. msg_id may be null per spec, but if it IS null we still
|
|
179
|
+
// try to insert — the UNIQUE constraint will then dedup on (source, conv_id, NULL),
|
|
180
|
+
// which behaves correctly for our purposes (multiple null msg_ids in same
|
|
181
|
+
// conversation are allowed; that mirrors the existing ingest behavior).
|
|
182
|
+
if (!row || typeof row !== 'object') return false;
|
|
183
|
+
if (typeof row.source !== 'string' || !row.source) return false;
|
|
184
|
+
if (typeof row.conversation_id !== 'string' || !row.conversation_id) return false;
|
|
185
|
+
if (typeof row.role !== 'string' || !row.role) return false;
|
|
186
|
+
if (typeof row.text !== 'string') return false;
|
|
187
|
+
// ts may be null in unusual cases (e.g. boundary rows) — accept anything coercible to int or null
|
|
188
|
+
const ts = (row.ts == null) ? null : Number(row.ts);
|
|
189
|
+
if (ts != null && !Number.isFinite(ts)) return false;
|
|
190
|
+
|
|
191
|
+
// Conversation upsert — we always do this so messages don't orphan if
|
|
192
|
+
// they arrive before their conversation row exists locally.
|
|
193
|
+
const conv = row.conversation || {};
|
|
194
|
+
try {
|
|
195
|
+
ctx.upsertConversation.run(
|
|
196
|
+
row.conversation_id,
|
|
197
|
+
row.source,
|
|
198
|
+
coalesceString(conv.title),
|
|
199
|
+
coalesceInt(conv.first_ts),
|
|
200
|
+
coalesceInt(conv.last_ts),
|
|
201
|
+
0,
|
|
202
|
+
coalesceString(conv.parent_conversation_id),
|
|
203
|
+
coalesceString(conv.project_path),
|
|
204
|
+
);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
ctx.recordError(`conversation_upsert: ${err.message}`);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Detect whether we already have this message (so accepted/dedup counts
|
|
211
|
+
// are correct even though ON CONFLICT swallows the case from SQLite's POV).
|
|
212
|
+
const existed = ctx.existsCheck.get(
|
|
213
|
+
row.source,
|
|
214
|
+
row.conversation_id,
|
|
215
|
+
row.msg_id ?? null,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Generate-on-insert UUID per SYNC.md §UUID generation policy.
|
|
219
|
+
const uuid = coalesceString(row.uuid) || randomUUID();
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
ctx.insertMessage.run(
|
|
223
|
+
row.source,
|
|
224
|
+
row.conversation_id,
|
|
225
|
+
row.msg_id ?? null,
|
|
226
|
+
row.role,
|
|
227
|
+
coalesceString(row.sender),
|
|
228
|
+
row.text,
|
|
229
|
+
ts,
|
|
230
|
+
coalesceMetadata(row.metadata),
|
|
231
|
+
coalesceInt(row.edited_at),
|
|
232
|
+
uuid,
|
|
233
|
+
coalesceString(row.channel),
|
|
234
|
+
// Provenance travels WITH the row — never re-stamped locally. A synced
|
|
235
|
+
// row keeps the origin of the node that captured it; pre-provenance
|
|
236
|
+
// rows stay NULL.
|
|
237
|
+
coalesceString(row.origin),
|
|
238
|
+
);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
ctx.recordError(`message_insert: ${err.message}`);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (existed) ctx.countDedup();
|
|
245
|
+
else ctx.countAccepted();
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Read req body up to maxBytes; respond 413 if exceeded. Resolves to body or null. */
|
|
250
|
+
function readBody(req, res, maxBytes) {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const chunks = [];
|
|
253
|
+
let total = 0;
|
|
254
|
+
let exceeded = false;
|
|
255
|
+
req.on('data', (chunk) => {
|
|
256
|
+
if (exceeded) return;
|
|
257
|
+
total += chunk.length;
|
|
258
|
+
if (total > maxBytes) {
|
|
259
|
+
exceeded = true;
|
|
260
|
+
respondJson(res, 413, { error: 'payload_too_large' });
|
|
261
|
+
req.destroy();
|
|
262
|
+
resolve(null);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
chunks.push(chunk);
|
|
266
|
+
});
|
|
267
|
+
req.on('end', () => {
|
|
268
|
+
if (exceeded) return;
|
|
269
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
270
|
+
});
|
|
271
|
+
req.on('error', reject);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function respondJson(res, status, obj) {
|
|
276
|
+
if (res.headersSent || res.writableEnded) return;
|
|
277
|
+
res.statusCode = status;
|
|
278
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
279
|
+
res.end(JSON.stringify(obj));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function coalesceString(v) {
|
|
283
|
+
if (v == null) return null;
|
|
284
|
+
return typeof v === 'string' ? v : String(v);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function coalesceInt(v) {
|
|
288
|
+
if (v == null || v === '') return null;
|
|
289
|
+
const n = Number(v);
|
|
290
|
+
return Number.isFinite(n) ? Math.trunc(n) : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Metadata is stored as TEXT in messages.metadata. We accept either a
|
|
295
|
+
* JSON string (passthrough) or an object (stringify). Anything else
|
|
296
|
+
* becomes null.
|
|
297
|
+
*/
|
|
298
|
+
function coalesceMetadata(v) {
|
|
299
|
+
if (v == null) return null;
|
|
300
|
+
if (typeof v === 'string') return v;
|
|
301
|
+
if (typeof v === 'object') {
|
|
302
|
+
try { return JSON.stringify(v); } catch (_) { return null; }
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bidirectional replication loop for one remote.
|
|
3
|
+
*
|
|
4
|
+
* `replicateOnce(remoteAlias)`:
|
|
5
|
+
* 1. PULL phase — keep pulling rows id > pulled_to from the remote until
|
|
6
|
+
* has_more is false; insert each batch locally via the same INSERT OR
|
|
7
|
+
* UPDATE logic the server uses (we just call lib/sync/push internally).
|
|
8
|
+
* 2. PUSH phase — read local rows with id > pushed_to in id-asc batches
|
|
9
|
+
* and POST them to the remote until empty.
|
|
10
|
+
* 3. Persist updated cursors + last_sync_at into ~/.memex/config.json.
|
|
11
|
+
*
|
|
12
|
+
* The PULL-then-PUSH ordering is deliberate: if both sides have new rows,
|
|
13
|
+
* we want the local DB to see the remote's new state BEFORE we re-push
|
|
14
|
+
* the union back, so the second cycle stabilises quickly.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent throughout — re-running on the same data is safe (everything
|
|
17
|
+
* deduplicates via UNIQUE(source, conv_id, msg_id)).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import Database from 'better-sqlite3';
|
|
23
|
+
|
|
24
|
+
import { createSyncClient } from './client.js';
|
|
25
|
+
import { makeRowApplier } from './push.js';
|
|
26
|
+
import { getSyncRemote, upsertSyncRemote } from './config.js';
|
|
27
|
+
|
|
28
|
+
const HOME = homedir();
|
|
29
|
+
const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
|
|
30
|
+
const DEFAULT_DB_PATH = join(MEMEX_DIR, 'data', 'memex.db');
|
|
31
|
+
|
|
32
|
+
const PULL_BATCH = 500;
|
|
33
|
+
// Push batching is ADAPTIVE (Phase 1). We start optimistic and halve on a
|
|
34
|
+
// 413 payload_too_large, because the server caps the BODY at 2MB and row
|
|
35
|
+
// size varies wildly (a telegram line is ~100 bytes; a claude-code turn with
|
|
36
|
+
// embedded tool-call artefacts can be multiple KB). Count is just a proxy
|
|
37
|
+
// for bytes, so we react to the real signal (413) rather than hardcoding low.
|
|
38
|
+
const PUSH_BATCH_START = 250;
|
|
39
|
+
const PUSH_BATCH_MIN = 1;
|
|
40
|
+
// Server caps the request body at 2MB. We pre-flight the serialized payload
|
|
41
|
+
// client-side and shrink BEFORE sending if it would exceed this safe ceiling
|
|
42
|
+
// (1.8MB leaves headroom for the tiny framing difference between our estimate
|
|
43
|
+
// and the server's measured byte count). This avoids the mid-stream 413+
|
|
44
|
+
// connection-reset entirely — much cleaner than uploading 3MB just to be
|
|
45
|
+
// rejected with an EPIPE. The 413 catch below remains as a belt-and-suspenders
|
|
46
|
+
// backstop in case a peer runs a lower cap than we assume.
|
|
47
|
+
const SAFE_PUSH_BYTES = 1.8 * 1024 * 1024;
|
|
48
|
+
// Retries for the pull-apply path when concurrent writes (capture daemon)
|
|
49
|
+
// cause transient SQLite/FTS5 errors. Each retry re-applies the whole page
|
|
50
|
+
// (idempotent). Exhausting retries aborts WITHOUT advancing the cursor.
|
|
51
|
+
const PULL_APPLY_RETRIES = 4;
|
|
52
|
+
|
|
53
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
54
|
+
|
|
55
|
+
// Network errors worth retrying in place: connection resets mid-transfer
|
|
56
|
+
// (flaky VPN/SSH-tunnel path — seen live during the first canonical join),
|
|
57
|
+
// timeouts, and refused connections (a self-healing tunnel respawns in ~15s,
|
|
58
|
+
// so a 20s backoff usually lands on the fresh tunnel).
|
|
59
|
+
const TRANSIENT_NET_RE = /ECONNRESET|EPIPE|ETIMEDOUT|ECONNREFUSED|socket hang up|request timeout/i;
|
|
60
|
+
const PULL_NET_RETRY_DELAYS = [2000, 8000, 20000];
|
|
61
|
+
|
|
62
|
+
async function pullWithRetry(client, opts, log) {
|
|
63
|
+
for (let i = 0; ; i++) {
|
|
64
|
+
try { return await client.pull(opts); }
|
|
65
|
+
catch (err) {
|
|
66
|
+
const msg = String(err.message || err);
|
|
67
|
+
if (i >= PULL_NET_RETRY_DELAYS.length || !TRANSIENT_NET_RE.test(msg)) throw err;
|
|
68
|
+
log(` pull hiccup (${msg.slice(0, 60)}) — retry ${i + 1}/${PULL_NET_RETRY_DELAYS.length} in ${PULL_NET_RETRY_DELAYS[i] / 1000}s`);
|
|
69
|
+
await sleep(PULL_NET_RETRY_DELAYS[i]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run one full bidirectional sync against a configured remote.
|
|
76
|
+
*
|
|
77
|
+
* opts.dbPath — local memex.db; defaults to ~/.memex/data/memex.db
|
|
78
|
+
* opts.alias — remote alias from sync.remotes config; required
|
|
79
|
+
* opts.log — optional log function (default console.log); pass () => {} for silence
|
|
80
|
+
*/
|
|
81
|
+
export async function replicateOnce({ alias, dbPath = DEFAULT_DB_PATH, log = console.log } = {}) {
|
|
82
|
+
if (!alias) throw new Error('replicateOnce: alias required');
|
|
83
|
+
|
|
84
|
+
const remote = getSyncRemote(alias);
|
|
85
|
+
if (!remote) throw new Error(`replicateOnce: remote "${alias}" not configured`);
|
|
86
|
+
if (!remote.url || !remote.bearer) {
|
|
87
|
+
throw new Error(`replicateOnce: remote "${alias}" missing url or bearer`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const client = createSyncClient({
|
|
91
|
+
url: remote.url,
|
|
92
|
+
bearer: remote.bearer,
|
|
93
|
+
insecure: remote.insecure === true, // tracer-bullet flag
|
|
94
|
+
cert_fp: remote.cert_fp || null,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Quick liveness probe — bail early with a clear message if peer is down.
|
|
98
|
+
let peerHealth;
|
|
99
|
+
try {
|
|
100
|
+
peerHealth = await client.health();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new Error(`peer ${alias} unreachable: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Open local DB read-write. WAL mode is already set by db-init at install time.
|
|
106
|
+
// busy_timeout: when the capture daemon holds the write lock, wait up to 10s
|
|
107
|
+
// for it instead of failing immediately — cuts down on transient lock errors
|
|
108
|
+
// during concurrent ingest.
|
|
109
|
+
const db = new Database(dbPath);
|
|
110
|
+
db.pragma('journal_mode = WAL');
|
|
111
|
+
db.pragma('synchronous = NORMAL');
|
|
112
|
+
db.pragma('busy_timeout = 10000');
|
|
113
|
+
|
|
114
|
+
const stats = {
|
|
115
|
+
alias,
|
|
116
|
+
peer_version: peerHealth.version,
|
|
117
|
+
pulled: { batches: 0, rows: 0, accepted: 0, deduplicated: 0, skipped: 0 },
|
|
118
|
+
pushed: { batches: 0, rows: 0, accepted: 0, deduplicated: 0 },
|
|
119
|
+
cursors_before: { pulled_to: remote.pulled_to || 0, pushed_to: remote.pushed_to || 0 },
|
|
120
|
+
cursors_after: { pulled_to: remote.pulled_to || 0, pushed_to: remote.pushed_to || 0 },
|
|
121
|
+
elapsed_ms: 0,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const t0 = Date.now();
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Snapshot our max local id BEFORE pull starts. Rows inserted by the
|
|
128
|
+
// pull phase get ids > localMaxBeforePull, and we exclude them from
|
|
129
|
+
// the push phase so we don't echo back what we just received.
|
|
130
|
+
//
|
|
131
|
+
// Without this guard, after a pull of N rows our push phase would
|
|
132
|
+
// happily ship those same N rows back to the peer, who'd dedup them
|
|
133
|
+
// — correct outcome but 2× bandwidth and noise in the stats.
|
|
134
|
+
const localMaxBeforePull = (db.prepare(`SELECT COALESCE(MAX(id), 0) AS m FROM messages`).get().m) | 0;
|
|
135
|
+
|
|
136
|
+
// 1. PULL phase
|
|
137
|
+
// Apply pulled rows via the shared row-applier — NOT through the HTTP push
|
|
138
|
+
// handler. The handler enforces a 2MB body cap (a network concern); a pull
|
|
139
|
+
// page of fat rows can exceed that, but locally we're applying in-memory
|
|
140
|
+
// rows directly, so no size limit applies.
|
|
141
|
+
const localApplier = makeRowApplier({ db });
|
|
142
|
+
let pulled_to = remote.pulled_to || 0;
|
|
143
|
+
let ftsRebuiltThisRun = false; // self-heal the FTS5 index at most once per run
|
|
144
|
+
while (true) {
|
|
145
|
+
const page = await pullWithRetry(client, { since: pulled_to, limit: PULL_BATCH }, log);
|
|
146
|
+
if (!page.rows || page.rows.length === 0) break;
|
|
147
|
+
|
|
148
|
+
// Apply with retry + FTS self-heal. A skipped row whose page-cursor then
|
|
149
|
+
// advances would be LOST FOREVER, so we never advance past a page that
|
|
150
|
+
// didn't fully apply. Failure modes we recover from:
|
|
151
|
+
// • "database disk image is malformed" → FTS5 index corruption (seen
|
|
152
|
+
// after concurrent-write episodes). Self-heal: rebuild the index once,
|
|
153
|
+
// then retry. Rebuild regenerates FTS from the messages table — if it
|
|
154
|
+
// fixes the error, great; if the corruption is in the main table, the
|
|
155
|
+
// rebuild won't help and we'll abort loud (below).
|
|
156
|
+
// • transient lock/busy → just retry with backoff.
|
|
157
|
+
// Exhausting retries ABORTS without advancing the cursor — loud failure
|
|
158
|
+
// beats silent data loss.
|
|
159
|
+
let inserted;
|
|
160
|
+
let attempt = 0;
|
|
161
|
+
while (true) {
|
|
162
|
+
inserted = localApplier.apply(page.rows);
|
|
163
|
+
if (inserted.skipped === 0) break;
|
|
164
|
+
|
|
165
|
+
const malformed = /malformed/i.test(inserted.firstError || '');
|
|
166
|
+
if (malformed && !ftsRebuiltThisRun) {
|
|
167
|
+
log(` ⚠ FTS5 corruption detected (${inserted.firstError}) — rebuilding index (one-time self-heal)…`);
|
|
168
|
+
try {
|
|
169
|
+
db.prepare(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`).run();
|
|
170
|
+
ftsRebuiltThisRun = true;
|
|
171
|
+
log(` ✓ FTS5 index rebuilt — retrying batch`);
|
|
172
|
+
continue; // retry immediately; don't burn a backoff attempt
|
|
173
|
+
} catch (e) {
|
|
174
|
+
log(` ✗ FTS5 rebuild failed: ${e.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
attempt++;
|
|
179
|
+
if (attempt > PULL_APPLY_RETRIES) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`pull apply: ${inserted.skipped} row(s) still failing after ${PULL_APPLY_RETRIES} ` +
|
|
182
|
+
`retries — first error: "${inserted.firstError}". Cursor NOT advanced, no data lost. ` +
|
|
183
|
+
`If this is "malformed", the FTS5 rebuild didn't clear it — run a deeper repair: ` +
|
|
184
|
+
`sqlite3 ~/.memex/data/memex.db "INSERT INTO messages_fts(messages_fts) VALUES('rebuild');" ` +
|
|
185
|
+
`Otherwise it's usually a transient lock from the capture daemon — re-run sync.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
log(` ⚠ ${inserted.skipped} row(s) failed (${inserted.firstError}) — retry ${attempt}/${PULL_APPLY_RETRIES}`);
|
|
189
|
+
await sleep(250 * attempt); // linear backoff
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
stats.pulled.batches += 1;
|
|
193
|
+
stats.pulled.rows += page.rows.length;
|
|
194
|
+
stats.pulled.accepted += inserted.accepted;
|
|
195
|
+
stats.pulled.deduplicated += inserted.deduplicated;
|
|
196
|
+
// skipped is 0 here by construction (we either succeeded or threw above)
|
|
197
|
+
|
|
198
|
+
pulled_to = page.next_cursor; // only advance after a clean apply
|
|
199
|
+
// Persist after EVERY clean page, not just at the end — an aborted run
|
|
200
|
+
// (network reset, sleep, Ctrl-C) then RESUMES from the last good page.
|
|
201
|
+
// Without this, a multi-minute first sync over a flaky link restarts
|
|
202
|
+
// from zero on every hiccup and may never complete (seen live 2026-06-11).
|
|
203
|
+
upsertSyncRemote(alias, { pulled_to });
|
|
204
|
+
if (!page.has_more) break;
|
|
205
|
+
}
|
|
206
|
+
stats.cursors_after.pulled_to = pulled_to;
|
|
207
|
+
|
|
208
|
+
// 2. PUSH phase — only rows in (pushed_to, localMaxBeforePull].
|
|
209
|
+
// Anything newer than localMaxBeforePull either came from the peer
|
|
210
|
+
// via the pull we just did, or is a concurrent write we'll catch
|
|
211
|
+
// on the next cycle.
|
|
212
|
+
const fetchOurs = db.prepare(`
|
|
213
|
+
SELECT
|
|
214
|
+
m.id, m.source, m.conversation_id, m.msg_id, m.role, m.sender, m.text,
|
|
215
|
+
m.ts, m.edited_at, m.uuid, m.channel, m.origin, m.metadata,
|
|
216
|
+
c.title AS conv_title,
|
|
217
|
+
c.first_ts AS conv_first_ts,
|
|
218
|
+
c.last_ts AS conv_last_ts,
|
|
219
|
+
c.parent_conversation_id AS conv_parent,
|
|
220
|
+
c.project_path AS conv_project_path
|
|
221
|
+
FROM messages m
|
|
222
|
+
LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
|
|
223
|
+
WHERE m.id > ? AND m.id <= ?
|
|
224
|
+
ORDER BY m.id ASC
|
|
225
|
+
LIMIT ?
|
|
226
|
+
`);
|
|
227
|
+
|
|
228
|
+
let pushed_to = remote.pushed_to || 0;
|
|
229
|
+
let batchSize = PUSH_BATCH_START;
|
|
230
|
+
let pushNetRetries = 0;
|
|
231
|
+
while (true) {
|
|
232
|
+
const localRows = fetchOurs.all(pushed_to, localMaxBeforePull, batchSize);
|
|
233
|
+
if (localRows.length === 0) break;
|
|
234
|
+
|
|
235
|
+
const wire = localRows.map((r) => ({
|
|
236
|
+
source: r.source,
|
|
237
|
+
conversation_id: r.conversation_id,
|
|
238
|
+
msg_id: r.msg_id,
|
|
239
|
+
uuid: r.uuid,
|
|
240
|
+
role: r.role,
|
|
241
|
+
sender: r.sender,
|
|
242
|
+
text: r.text,
|
|
243
|
+
ts: r.ts,
|
|
244
|
+
edited_at: r.edited_at,
|
|
245
|
+
channel: r.channel,
|
|
246
|
+
origin: r.origin,
|
|
247
|
+
metadata: r.metadata,
|
|
248
|
+
conversation: {
|
|
249
|
+
title: r.conv_title,
|
|
250
|
+
first_ts: r.conv_first_ts,
|
|
251
|
+
last_ts: r.conv_last_ts,
|
|
252
|
+
parent_conversation_id: r.conv_parent,
|
|
253
|
+
project_path: r.conv_project_path,
|
|
254
|
+
},
|
|
255
|
+
}));
|
|
256
|
+
|
|
257
|
+
// Pre-flight size check — shrink BEFORE sending if this batch would
|
|
258
|
+
// exceed the server's body cap. Avoids the mid-stream 413/EPIPE entirely.
|
|
259
|
+
const payloadBytes = Buffer.byteLength(JSON.stringify({ rows: wire }), 'utf-8');
|
|
260
|
+
if (payloadBytes > SAFE_PUSH_BYTES && localRows.length > PUSH_BATCH_MIN) {
|
|
261
|
+
batchSize = Math.max(PUSH_BATCH_MIN, Math.floor(localRows.length / 2));
|
|
262
|
+
stats.pushed.shrinks = (stats.pushed.shrinks || 0) + 1;
|
|
263
|
+
log(` push batch ~${(payloadBytes / 1048576).toFixed(2)}MB > cap — shrinking to ${batchSize} rows and re-fetching`);
|
|
264
|
+
continue; // re-fetch the same segment with fewer rows
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let result;
|
|
268
|
+
try {
|
|
269
|
+
result = await client.push({ rows: wire });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// Backstop: if a peer enforces a lower cap than SAFE_PUSH_BYTES, we
|
|
272
|
+
// might still get a 413 — or a mid-stream connection reset (EPIPE/
|
|
273
|
+
// ECONNRESET) when the server destroys the request before we finish
|
|
274
|
+
// uploading. Treat all three as "too big, halve and retry".
|
|
275
|
+
const tooBig =
|
|
276
|
+
err.status === 413 ||
|
|
277
|
+
err.code === 'EPIPE' ||
|
|
278
|
+
err.code === 'ECONNRESET' ||
|
|
279
|
+
/EPIPE|ECONNRESET|socket hang up/i.test(String(err.message));
|
|
280
|
+
if (tooBig && localRows.length > PUSH_BATCH_MIN) {
|
|
281
|
+
batchSize = Math.max(PUSH_BATCH_MIN, Math.floor(localRows.length / 2));
|
|
282
|
+
stats.pushed.shrinks = (stats.pushed.shrinks || 0) + 1;
|
|
283
|
+
log(` push rejected (${err.status || err.code || 'reset'}) — halving to ${batchSize} and retrying`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// At min batch a reset can't be "too big" — it's a flaky link. Retry
|
|
287
|
+
// in place with backoff (the self-healing tunnel respawns in ~15s).
|
|
288
|
+
if (err.status !== 413 && TRANSIENT_NET_RE.test(String(err.message || err)) &&
|
|
289
|
+
pushNetRetries < PULL_NET_RETRY_DELAYS.length) {
|
|
290
|
+
const delay = PULL_NET_RETRY_DELAYS[pushNetRetries++];
|
|
291
|
+
log(` push hiccup (${String(err.message || err).slice(0, 60)}) — retry in ${delay / 1000}s`);
|
|
292
|
+
await sleep(delay);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
throw err; // genuine error — propagate
|
|
296
|
+
}
|
|
297
|
+
pushNetRetries = 0; // a clean send resets the transient budget
|
|
298
|
+
|
|
299
|
+
stats.pushed.batches += 1;
|
|
300
|
+
stats.pushed.rows += localRows.length;
|
|
301
|
+
stats.pushed.accepted += result.accepted;
|
|
302
|
+
stats.pushed.deduplicated += result.deduplicated;
|
|
303
|
+
|
|
304
|
+
pushed_to = localRows[localRows.length - 1].id;
|
|
305
|
+
upsertSyncRemote(alias, { pushed_to }); // resume-safe, mirrors the pull side
|
|
306
|
+
|
|
307
|
+
// Recover throughput: after a successful send, grow the batch back
|
|
308
|
+
// toward the optimistic start size (doubling), so one fat row doesn't
|
|
309
|
+
// pin us at a tiny batch for the rest of the run.
|
|
310
|
+
if (batchSize < PUSH_BATCH_START) {
|
|
311
|
+
batchSize = Math.min(PUSH_BATCH_START, batchSize * 2);
|
|
312
|
+
}
|
|
313
|
+
// Termination is handled by the empty-fetch check at loop top once
|
|
314
|
+
// pushed_to reaches localMaxBeforePull.
|
|
315
|
+
}
|
|
316
|
+
stats.cursors_after.pushed_to = pushed_to;
|
|
317
|
+
|
|
318
|
+
// 3. Persist cursors + clear any prior error
|
|
319
|
+
upsertSyncRemote(alias, {
|
|
320
|
+
pulled_to: stats.cursors_after.pulled_to,
|
|
321
|
+
pushed_to: stats.cursors_after.pushed_to,
|
|
322
|
+
last_sync_at: Date.now(),
|
|
323
|
+
last_error: null,
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
upsertSyncRemote(alias, { last_error: String(err.message || err) });
|
|
327
|
+
db.close();
|
|
328
|
+
throw err;
|
|
329
|
+
} finally {
|
|
330
|
+
stats.elapsed_ms = Date.now() - t0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
db.close();
|
|
334
|
+
return stats;
|
|
335
|
+
}
|