@interocitor/core 0.0.0-beta.3 → 0.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +446 -91
- package/dist/adapters/cloudflare.d.ts +8 -9
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +38 -12
- package/dist/adapters/google-drive.d.ts +1 -1
- package/dist/adapters/google-drive.js +1 -2
- package/dist/adapters/memory.d.ts +4 -1
- package/dist/adapters/memory.d.ts.map +1 -1
- package/dist/adapters/memory.js +13 -2
- package/dist/adapters/webdav.d.ts +5 -0
- package/dist/adapters/webdav.d.ts.map +1 -1
- package/dist/adapters/webdav.js +18 -1
- package/dist/core/codec.d.ts +1 -1
- package/dist/core/codec.d.ts.map +1 -1
- package/dist/core/codec.js +39 -3
- package/dist/core/compaction.d.ts +9 -1
- package/dist/core/compaction.d.ts.map +1 -1
- package/dist/core/compaction.js +63 -7
- package/dist/core/crdt.d.ts +6 -3
- package/dist/core/crdt.d.ts.map +1 -1
- package/dist/core/crdt.js +53 -67
- package/dist/core/errors.d.ts +47 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +61 -0
- package/dist/core/flush.d.ts +3 -3
- package/dist/core/flush.d.ts.map +1 -1
- package/dist/core/flush.js +64 -7
- package/dist/core/hlc.js +0 -1
- package/dist/core/ids.d.ts +49 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +132 -0
- package/dist/core/internals.d.ts +10 -2
- package/dist/core/internals.d.ts.map +1 -1
- package/dist/core/internals.js +27 -9
- package/dist/core/manifest.d.ts +24 -5
- package/dist/core/manifest.d.ts.map +1 -1
- package/dist/core/manifest.js +80 -13
- package/dist/core/pull.d.ts +1 -1
- package/dist/core/pull.d.ts.map +1 -1
- package/dist/core/pull.js +21 -6
- package/dist/core/row-id.js +0 -1
- package/dist/core/schema-types.d.ts +22 -11
- package/dist/core/schema-types.d.ts.map +1 -1
- package/dist/core/schema-types.js +18 -9
- package/dist/core/schema-types.type-test.js +59 -5
- package/dist/core/sync-engine.d.ts +166 -12
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +1615 -219
- package/dist/core/table.d.ts +217 -17
- package/dist/core/table.d.ts.map +1 -1
- package/dist/core/table.js +376 -24
- package/dist/core/types.d.ts +413 -17
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +0 -1
- package/dist/crypto/encryption.d.ts.map +1 -1
- package/dist/crypto/encryption.js +6 -1
- package/dist/crypto/keys.js +0 -1
- package/dist/handshake/channel.js +0 -1
- package/dist/handshake/index.d.ts +5 -2
- package/dist/handshake/index.d.ts.map +1 -1
- package/dist/handshake/index.js +19 -2
- package/dist/handshake/qr.js +0 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/storage/credential-store.d.ts +25 -2
- package/dist/storage/credential-store.d.ts.map +1 -1
- package/dist/storage/credential-store.js +55 -8
- package/dist/storage/local-store.d.ts +4 -1
- package/dist/storage/local-store.d.ts.map +1 -1
- package/dist/storage/local-store.js +37 -21
- package/package.json +3 -3
- package/dist/adapters/cloudflare.js.map +0 -1
- package/dist/adapters/google-drive.js.map +0 -1
- package/dist/adapters/memory.js.map +0 -1
- package/dist/adapters/webdav.js.map +0 -1
- package/dist/core/codec.js.map +0 -1
- package/dist/core/compaction.js.map +0 -1
- package/dist/core/crdt.js.map +0 -1
- package/dist/core/flush.js.map +0 -1
- package/dist/core/hlc.js.map +0 -1
- package/dist/core/internals.js.map +0 -1
- package/dist/core/manifest.js.map +0 -1
- package/dist/core/pull.js.map +0 -1
- package/dist/core/row-id.js.map +0 -1
- package/dist/core/schema-types.js.map +0 -1
- package/dist/core/schema-types.type-test.js.map +0 -1
- package/dist/core/sync-engine.js.map +0 -1
- package/dist/core/table.js.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/crypto/encryption.js.map +0 -1
- package/dist/crypto/keys.js.map +0 -1
- package/dist/handshake/channel.js.map +0 -1
- package/dist/handshake/index.js.map +0 -1
- package/dist/handshake/qr.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/storage/credential-store.js.map +0 -1
- package/dist/storage/local-store.js.map +0 -1
package/dist/core/sync-engine.js
CHANGED
|
@@ -10,36 +10,30 @@
|
|
|
10
10
|
* All data operations hit IndexedDB first; cloud sync is async.
|
|
11
11
|
*/
|
|
12
12
|
import { hlcInit, hlcNow, hlcSerialize, hlcParse, hlcCompareStr } from "./hlc.js";
|
|
13
|
-
import {
|
|
13
|
+
import { Table, computeCacheKey } from "./table.js";
|
|
14
|
+
import { readColumn } from "./crdt.js";
|
|
14
15
|
import { LocalStore } from "../storage/local-store.js";
|
|
15
|
-
import { Table } from "./table.js";
|
|
16
16
|
// Extracted modules
|
|
17
|
-
import { paths,
|
|
17
|
+
import { paths, logAtLevel, normalizeLogLevel, generateId, getDeviceId } from "./internals.js";
|
|
18
18
|
import { loadOrCreateManifest, upsertDeviceMetadata } from "./manifest.js";
|
|
19
19
|
import { generateKey, keyToPassphrase, passphraseToKey } from "../crypto/encryption.js";
|
|
20
|
+
import { MeshCredentialMismatchError } from "./errors.js";
|
|
20
21
|
import { createCredentialStore } from "../storage/credential-store.js";
|
|
21
22
|
import { flushToAdapter } from "./flush.js";
|
|
22
23
|
import { pull as doPull } from "./pull.js";
|
|
23
24
|
import { compact as doCompact, rehydrate as doRehydrate } from "./compaction.js";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* // Or declare a typed getter:
|
|
37
|
-
* const getDb = async (): Promise<SyncEngine<DB>> => { ... }
|
|
38
|
-
*
|
|
39
|
-
* engine.table('tasks'); // Table<{ title: string }>
|
|
40
|
-
* engine.table('other'); // TS error — 'other' is not keyof DB
|
|
41
|
-
*/
|
|
42
|
-
export class SyncEngine {
|
|
25
|
+
const DEFAULT_COMPACT_WARNING_THRESHOLD = 50;
|
|
26
|
+
const DEFAULT_COMPACT_AUTO_THRESHOLD = 50;
|
|
27
|
+
const DEFAULT_COMPACT_AUTO_SAMPLE_NUMERATOR = 10;
|
|
28
|
+
const DEFAULT_COMPACT_AUTO_DEVICE_COUNT = 1;
|
|
29
|
+
const DEFAULT_FIRST_COMPACT_DELAY_MS = 10 * 60000;
|
|
30
|
+
const DEFAULT_FIRST_COMPACT_DELAY_JITTER_MS = 5 * 60000;
|
|
31
|
+
const DEFAULT_SECOND_COMPACT_DELAY_MS = 15 * 60000;
|
|
32
|
+
const DEFAULT_SECOND_COMPACT_DELAY_JITTER_MS = 5 * 60000;
|
|
33
|
+
const DEFAULT_COMPACT_REMOTE_CHANGE_THRESHOLD = 2;
|
|
34
|
+
const DEFAULT_OFFLINE_GRACE_MS = 7 * 24 * 60 * 60000;
|
|
35
|
+
const DEFAULT_BATCH_WINDOW_MS = 1000;
|
|
36
|
+
export class Interocitor {
|
|
43
37
|
constructor(adapterOrConfig, maybeConfig) {
|
|
44
38
|
this.encryptionKey = null;
|
|
45
39
|
this.encrypted = false;
|
|
@@ -53,13 +47,41 @@ export class SyncEngine {
|
|
|
53
47
|
// Flush management
|
|
54
48
|
this.flushTimer = null;
|
|
55
49
|
this.pendingCount = 0;
|
|
56
|
-
|
|
50
|
+
this.compactWarningEmitted = false;
|
|
51
|
+
this.compactInFlight = null;
|
|
52
|
+
this.compactScheduleVersion = 0;
|
|
53
|
+
this.compactCheckTimer = null;
|
|
54
|
+
this.compactRunTimer = null;
|
|
55
|
+
this.batchTimer = null;
|
|
56
|
+
this.pendingBatch = null;
|
|
57
|
+
// Poll / push invalidation management
|
|
57
58
|
this.pollTimer = null;
|
|
59
|
+
this.unsubscribeRemoteInvalidations = null;
|
|
58
60
|
// Lifecycle state
|
|
59
61
|
this.initialized = false;
|
|
60
62
|
this.connected = false;
|
|
63
|
+
this.initPromise = null;
|
|
64
|
+
// In-flight connect dedupe. Concurrent callers (React StrictMode double-
|
|
65
|
+
// mount, dual auto-reconnect resolves) share the same execution instead of
|
|
66
|
+
// both running the full connect() pipeline (manifest create, pull, flush,
|
|
67
|
+
// startPolling) twice in parallel — which doubles every flushed change file
|
|
68
|
+
// and stacks polling timers.
|
|
69
|
+
this.connectPromise = null;
|
|
61
70
|
// Event listeners
|
|
62
71
|
this.listeners = new Set();
|
|
72
|
+
// Async query cache. cacheKey -> entry. See QueryCacheEntry doc above.
|
|
73
|
+
this.queryCache = new Map();
|
|
74
|
+
// table name -> set of cache keys whose descriptors target that table.
|
|
75
|
+
this.queryCacheByTable = new Map();
|
|
76
|
+
// Single-row cache. Mirrors queryCache 1:1 in shape and lifecycle.
|
|
77
|
+
// key = `r=table|id=rowId`. Same emit() chokepoint invalidates.
|
|
78
|
+
this.rowCache = new Map();
|
|
79
|
+
this.rowCacheByTable = new Map();
|
|
80
|
+
// ── Batched writes ─────────────────────────────────────────────────
|
|
81
|
+
// All writes performed inside `fn` are merged into ONE ChangeEntry.
|
|
82
|
+
// Implicit batching also happens automatically: writes within the
|
|
83
|
+
// configured batchWindowMs window are flushed into a single ChangeEntry.
|
|
84
|
+
this.batchDepth = 0;
|
|
63
85
|
const config = (maybeConfig ?? adapterOrConfig);
|
|
64
86
|
const adapter = (maybeConfig ? adapterOrConfig : null);
|
|
65
87
|
this.schema = config.schema;
|
|
@@ -74,12 +96,28 @@ export class SyncEngine {
|
|
|
74
96
|
pollInterval: config.pollInterval ?? 30000,
|
|
75
97
|
flushDebounce: config.flushDebounce ?? 2000,
|
|
76
98
|
flushThreshold: config.flushThreshold ?? 50,
|
|
99
|
+
compactWarnThreshold: config.compactWarnThreshold ?? DEFAULT_COMPACT_WARNING_THRESHOLD,
|
|
100
|
+
compactAutoThreshold: config.compactAutoThreshold ?? DEFAULT_COMPACT_AUTO_THRESHOLD,
|
|
101
|
+
compactAutoSampleNumerator: config.compactAutoSampleNumerator ?? DEFAULT_COMPACT_AUTO_SAMPLE_NUMERATOR,
|
|
102
|
+
compactAutoDeviceCount: Math.max(1, Math.floor(config.compactAutoDeviceCount ?? DEFAULT_COMPACT_AUTO_DEVICE_COUNT)),
|
|
103
|
+
autoCompact: config.autoCompact ?? true,
|
|
104
|
+
firstCompactDelayMs: config.firstCompactDelayMs ?? DEFAULT_FIRST_COMPACT_DELAY_MS,
|
|
105
|
+
firstCompactDelayJitterMs: config.firstCompactDelayJitterMs ?? DEFAULT_FIRST_COMPACT_DELAY_JITTER_MS,
|
|
106
|
+
secondCompactDelayMs: config.secondCompactDelayMs ?? DEFAULT_SECOND_COMPACT_DELAY_MS,
|
|
107
|
+
secondCompactDelayJitterMs: config.secondCompactDelayJitterMs ?? DEFAULT_SECOND_COMPACT_DELAY_JITTER_MS,
|
|
108
|
+
compactRemoteChangeThreshold: config.compactRemoteChangeThreshold ?? DEFAULT_COMPACT_REMOTE_CHANGE_THRESHOLD,
|
|
109
|
+
offlineGraceMs: config.offlineGraceMs ?? DEFAULT_OFFLINE_GRACE_MS,
|
|
110
|
+
batchWindowMs: config.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS,
|
|
77
111
|
dbName: config.dbName ?? 'interocitor',
|
|
78
112
|
localStoreFactory: config.localStoreFactory ?? (() => new LocalStore(config.dbName, undefined, config.schema)),
|
|
79
113
|
schema: config.schema,
|
|
80
114
|
replicas: config.replicas ?? [],
|
|
115
|
+
onInit: config.onInit,
|
|
116
|
+
resolveInitialState: config.resolveInitialState,
|
|
81
117
|
};
|
|
82
118
|
this.serverId = this.config.serverId;
|
|
119
|
+
this.dbName = this.config.dbName;
|
|
120
|
+
this.logLevel = normalizeLogLevel(config.logLevel);
|
|
83
121
|
this.local = this.config.localStoreFactory();
|
|
84
122
|
this.deviceId = getDeviceId(config.deviceId);
|
|
85
123
|
this.hlc = hlcInit(this.deviceId);
|
|
@@ -93,9 +131,340 @@ export class SyncEngine {
|
|
|
93
131
|
}
|
|
94
132
|
else {
|
|
95
133
|
this.encrypted = true;
|
|
96
|
-
// Will generate key in
|
|
134
|
+
// Will generate key in doInit() if no persisted key found
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
log(level, ...args) {
|
|
138
|
+
logAtLevel(this.logLevel, level, ...args);
|
|
139
|
+
}
|
|
140
|
+
supportsRemoteInvalidations(adapter) {
|
|
141
|
+
return typeof adapter.subscribeToInvalidations === 'function';
|
|
142
|
+
}
|
|
143
|
+
/** Await this before any storage operation. Returns the shared init promise. */
|
|
144
|
+
ensureReady() {
|
|
145
|
+
if (this.initialized)
|
|
146
|
+
return Promise.resolve();
|
|
147
|
+
if (!this.initPromise)
|
|
148
|
+
this.initPromise = this.doInit();
|
|
149
|
+
return this.initPromise;
|
|
150
|
+
}
|
|
151
|
+
async putNow(table, rowId, columns, _userId) {
|
|
152
|
+
const tableName = table;
|
|
153
|
+
const current = await this.local.getRow(tableName, rowId);
|
|
154
|
+
const isResurrection = current?._meta.deleted === true;
|
|
155
|
+
// Clone row with namespaced shape. New rows and resurrected tombstones start
|
|
156
|
+
// with empty payload so a partial insert after delete cannot republish
|
|
157
|
+
// pre-delete columns.
|
|
158
|
+
const row = current
|
|
159
|
+
? { _meta: { ...current._meta }, payload: isResurrection ? {} : { ...current.payload } }
|
|
160
|
+
: {
|
|
161
|
+
_meta: { table: tableName, rowId, deleted: false, schemaVersion: this.schema?.version ?? 0 },
|
|
162
|
+
payload: {},
|
|
163
|
+
};
|
|
164
|
+
const nextHlc = hlcNow(this.hlc);
|
|
165
|
+
this.hlc = nextHlc;
|
|
166
|
+
const stamp = hlcSerialize(nextHlc);
|
|
167
|
+
// User payload is fully isolated. Any key — including names like
|
|
168
|
+
// `_table`, `_rowId`, `_meta`, `payload` — is safe; meta is a separate
|
|
169
|
+
// namespace and cannot be reached by user input.
|
|
170
|
+
for (const [key, value] of Object.entries(columns)) {
|
|
171
|
+
row.payload[key] = { value: value === undefined ? null : value, hlc: stamp };
|
|
172
|
+
}
|
|
173
|
+
row._meta.deleted = false;
|
|
174
|
+
row._meta.deletedHlc = undefined;
|
|
175
|
+
row._meta.owner = this.deviceId;
|
|
176
|
+
await this.local.putRow(row);
|
|
177
|
+
const op = this.rowToSyncOp(row);
|
|
178
|
+
const hlc = this.getRowHlc(row);
|
|
179
|
+
if (op && hlc)
|
|
180
|
+
await this.queueOpForBatchedFlush(op, hlc);
|
|
181
|
+
this.knownTables.add(tableName);
|
|
182
|
+
this.emit({ type: 'change', table: tableName, rowId, row });
|
|
183
|
+
return row;
|
|
184
|
+
}
|
|
185
|
+
async deleteNow(table, rowId, _userId) {
|
|
186
|
+
const tableName = table;
|
|
187
|
+
const current = await this.local.getRow(tableName, rowId);
|
|
188
|
+
if (!current || current._meta.deleted)
|
|
189
|
+
return;
|
|
190
|
+
const nextHlc = hlcNow(this.hlc);
|
|
191
|
+
this.hlc = nextHlc;
|
|
192
|
+
current._meta.deleted = true;
|
|
193
|
+
current._meta.deletedHlc = hlcSerialize(nextHlc);
|
|
194
|
+
current._meta.owner = this.deviceId;
|
|
195
|
+
// Tombstones carry only deletion metadata. Payload is no longer needed for
|
|
196
|
+
// CRDT conflict checks and should not retain deleted user data.
|
|
197
|
+
current.payload = {};
|
|
198
|
+
await this.local.putRow(current);
|
|
199
|
+
const op = this.rowToSyncOp(current);
|
|
200
|
+
const hlc = this.getRowHlc(current);
|
|
201
|
+
if (op && hlc)
|
|
202
|
+
await this.queueOpForBatchedFlush(op, hlc);
|
|
203
|
+
this.emit({ type: 'delete', table: tableName, rowId });
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Append the op to the in-flight batch. If we are inside a `batch()` block,
|
|
207
|
+
* the op stays buffered until the block ends. Otherwise it joins an
|
|
208
|
+
* implicit window of `batchWindowMs`. Either way the result is one
|
|
209
|
+
* ChangeEntry per batch instead of one per write.
|
|
210
|
+
*/
|
|
211
|
+
async queueOpForBatchedFlush(op, hlc) {
|
|
212
|
+
this.appendOpToPendingBatch(op, hlc);
|
|
213
|
+
if (this.isBatching())
|
|
214
|
+
return;
|
|
215
|
+
if (this.config.batchWindowMs <= 0) {
|
|
216
|
+
await this.flushPendingBatch();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.armImplicitBatchTimer();
|
|
220
|
+
}
|
|
221
|
+
async queryNow(table) {
|
|
222
|
+
return this.local.getTable(table);
|
|
223
|
+
}
|
|
224
|
+
async queryWhereNow(table, clause) {
|
|
225
|
+
return this.local.queryWhere(table, clause);
|
|
226
|
+
}
|
|
227
|
+
// ── Query cache (async-only, descriptor-keyed) ─────────────────────
|
|
228
|
+
/** Public readiness signal. Used by render-time consumers to decide
|
|
229
|
+
* between sync cache reads and awaiting a load. */
|
|
230
|
+
isReady() {
|
|
231
|
+
return this.initialized;
|
|
232
|
+
}
|
|
233
|
+
/** Stable cache key for a descriptor. Owned by core. */
|
|
234
|
+
getQueryCacheKey(descriptor) {
|
|
235
|
+
return computeCacheKey(descriptor);
|
|
236
|
+
}
|
|
237
|
+
/** Sync cache snapshot. Never starts a load. Empty/pending/ready/error. */
|
|
238
|
+
readQueryCache(descriptor) {
|
|
239
|
+
const key = this.getQueryCacheKey(descriptor);
|
|
240
|
+
const entry = this.queryCache.get(key);
|
|
241
|
+
if (!entry)
|
|
242
|
+
return { status: 'empty', promise: null };
|
|
243
|
+
return {
|
|
244
|
+
status: entry.status,
|
|
245
|
+
promise: entry.promise ?? null,
|
|
246
|
+
rows: entry.rows,
|
|
247
|
+
error: entry.error,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Load rows through the cache.
|
|
252
|
+
*
|
|
253
|
+
* - If a snapshot exists and `bypassCache` is not set, dedupes to the
|
|
254
|
+
* in-flight promise (when pending) or starts a refresh that keeps the
|
|
255
|
+
* stale rows visible until it resolves.
|
|
256
|
+
* - When the engine is not yet ready, falls through `ensureReady()`; if
|
|
257
|
+
* `bypassCache` is set, never returns the cached rows.
|
|
258
|
+
*/
|
|
259
|
+
loadQueryRows(descriptor, options) {
|
|
260
|
+
const key = this.getQueryCacheKey(descriptor);
|
|
261
|
+
const existing = this.queryCache.get(key);
|
|
262
|
+
if (!options?.bypassCache && existing?.status === 'pending' && existing.promise) {
|
|
263
|
+
return existing.promise;
|
|
264
|
+
}
|
|
265
|
+
if (!options?.bypassCache && existing?.status === 'ready' && existing.rows) {
|
|
266
|
+
// Fast-path: hand back resolved rows without re-fetch.
|
|
267
|
+
// Callers that want freshness pass `bypassCache: true`.
|
|
268
|
+
return Promise.resolve(existing.rows);
|
|
269
|
+
}
|
|
270
|
+
return this.runQuery(descriptor, key);
|
|
271
|
+
}
|
|
272
|
+
runQuery(descriptor, key) {
|
|
273
|
+
const promise = (async () => {
|
|
274
|
+
await this.ensureReady();
|
|
275
|
+
const rows = descriptor.clause
|
|
276
|
+
? await this.local.queryWhere(descriptor.table, descriptor.clause)
|
|
277
|
+
: await this.local.getTable(descriptor.table);
|
|
278
|
+
// Apply deterministic orderBy from descriptor, so cache stores already-
|
|
279
|
+
// ordered rows. Sync-only `.sort(compareFn)` derivations stay outside
|
|
280
|
+
// and run after the cache hand-off.
|
|
281
|
+
if (!descriptor.orderBy)
|
|
282
|
+
return rows;
|
|
283
|
+
const { field, dir } = descriptor.orderBy;
|
|
284
|
+
const sorted = [...rows].sort((a, b) => {
|
|
285
|
+
const av = readColumn(a, field);
|
|
286
|
+
const bv = readColumn(b, field);
|
|
287
|
+
if (av === bv)
|
|
288
|
+
return 0;
|
|
289
|
+
const lt = av < bv ? -1 : 1;
|
|
290
|
+
return dir === 'asc' ? lt : -lt;
|
|
291
|
+
});
|
|
292
|
+
return sorted;
|
|
293
|
+
})();
|
|
294
|
+
const previous = this.queryCache.get(key);
|
|
295
|
+
const entry = {
|
|
296
|
+
descriptor,
|
|
297
|
+
status: 'pending',
|
|
298
|
+
rows: previous?.rows, // keep stale rows visible while refreshing
|
|
299
|
+
promise,
|
|
300
|
+
};
|
|
301
|
+
this.queryCache.set(key, entry);
|
|
302
|
+
this.indexCacheByTable(descriptor.table, key);
|
|
303
|
+
promise.then(rows => {
|
|
304
|
+
const current = this.queryCache.get(key);
|
|
305
|
+
if (current?.promise !== promise)
|
|
306
|
+
return; // superseded
|
|
307
|
+
this.queryCache.set(key, { descriptor, status: 'ready', rows });
|
|
308
|
+
}).catch(err => {
|
|
309
|
+
const current = this.queryCache.get(key);
|
|
310
|
+
if (current?.promise !== promise)
|
|
311
|
+
return;
|
|
312
|
+
this.queryCache.set(key, {
|
|
313
|
+
descriptor,
|
|
314
|
+
status: 'error',
|
|
315
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
316
|
+
rows: current.rows,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
return promise;
|
|
320
|
+
}
|
|
321
|
+
indexCacheByTable(table, key) {
|
|
322
|
+
let set = this.queryCacheByTable.get(table);
|
|
323
|
+
if (!set) {
|
|
324
|
+
set = new Set();
|
|
325
|
+
this.queryCacheByTable.set(table, set);
|
|
326
|
+
}
|
|
327
|
+
set.add(key);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Mark all cached queries against `table` as stale and refresh them in the
|
|
331
|
+
* background. Stale rows stay visible. Called from local mutations.
|
|
332
|
+
*/
|
|
333
|
+
invalidateQueryCacheForTable(table) {
|
|
334
|
+
const keys = this.queryCacheByTable.get(table);
|
|
335
|
+
if (!keys || keys.size === 0)
|
|
336
|
+
return;
|
|
337
|
+
for (const key of keys) {
|
|
338
|
+
const entry = this.queryCache.get(key);
|
|
339
|
+
if (!entry)
|
|
340
|
+
continue;
|
|
341
|
+
this.runQuery(entry.descriptor, key);
|
|
97
342
|
}
|
|
98
343
|
}
|
|
344
|
+
// ── Row cache (async-only, descriptor-keyed) ───────────────────────
|
|
345
|
+
// Same shape and semantics as the query cache. Kept as a separate map so
|
|
346
|
+
// single-row reads don't compete with table scans.
|
|
347
|
+
/** Stable cache key for a row descriptor. Owned by core. */
|
|
348
|
+
getRowCacheKey(descriptor) {
|
|
349
|
+
return `r=${descriptor.table}|id=${descriptor.rowId}`;
|
|
350
|
+
}
|
|
351
|
+
/** Sync row cache snapshot. Never starts a load. */
|
|
352
|
+
readRowCache(descriptor) {
|
|
353
|
+
const key = this.getRowCacheKey(descriptor);
|
|
354
|
+
const entry = this.rowCache.get(key);
|
|
355
|
+
if (!entry)
|
|
356
|
+
return { status: 'empty', promise: null };
|
|
357
|
+
return {
|
|
358
|
+
status: entry.status,
|
|
359
|
+
promise: entry.promise ?? null,
|
|
360
|
+
row: entry.row,
|
|
361
|
+
error: entry.error,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Load a row through the cache. Same dedupe + stale-while-revalidate
|
|
366
|
+
* semantics as `loadQueryRows`.
|
|
367
|
+
*/
|
|
368
|
+
loadRow(descriptor, options) {
|
|
369
|
+
const key = this.getRowCacheKey(descriptor);
|
|
370
|
+
const existing = this.rowCache.get(key);
|
|
371
|
+
if (!options?.bypassCache && existing?.status === 'pending' && existing.promise) {
|
|
372
|
+
return existing.promise;
|
|
373
|
+
}
|
|
374
|
+
if (!options?.bypassCache && existing?.status === 'ready') {
|
|
375
|
+
return Promise.resolve(existing.row ?? undefined);
|
|
376
|
+
}
|
|
377
|
+
return this.runRow(descriptor, key);
|
|
378
|
+
}
|
|
379
|
+
runRow(descriptor, key) {
|
|
380
|
+
const promise = (async () => {
|
|
381
|
+
await this.ensureReady();
|
|
382
|
+
const row = await this.local.getRow(descriptor.table, descriptor.rowId);
|
|
383
|
+
if (!row || row._meta.deleted)
|
|
384
|
+
return undefined;
|
|
385
|
+
return row;
|
|
386
|
+
})();
|
|
387
|
+
const previous = this.rowCache.get(key);
|
|
388
|
+
const entry = {
|
|
389
|
+
descriptor,
|
|
390
|
+
status: 'pending',
|
|
391
|
+
row: previous?.row, // keep stale row visible while refreshing
|
|
392
|
+
promise,
|
|
393
|
+
};
|
|
394
|
+
this.rowCache.set(key, entry);
|
|
395
|
+
this.indexRowCacheByTable(descriptor.table, key);
|
|
396
|
+
promise.then(row => {
|
|
397
|
+
const current = this.rowCache.get(key);
|
|
398
|
+
if (current?.promise !== promise)
|
|
399
|
+
return;
|
|
400
|
+
this.rowCache.set(key, { descriptor, status: 'ready', row: row ?? null });
|
|
401
|
+
}).catch(err => {
|
|
402
|
+
const current = this.rowCache.get(key);
|
|
403
|
+
if (current?.promise !== promise)
|
|
404
|
+
return;
|
|
405
|
+
this.rowCache.set(key, {
|
|
406
|
+
descriptor,
|
|
407
|
+
status: 'error',
|
|
408
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
409
|
+
row: current.row,
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
return promise;
|
|
413
|
+
}
|
|
414
|
+
indexRowCacheByTable(table, key) {
|
|
415
|
+
let set = this.rowCacheByTable.get(table);
|
|
416
|
+
if (!set) {
|
|
417
|
+
set = new Set();
|
|
418
|
+
this.rowCacheByTable.set(table, set);
|
|
419
|
+
}
|
|
420
|
+
set.add(key);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Refresh all cached rows for a table. Called from emit() alongside the
|
|
424
|
+
* query cache invalidation. Keeps prior row visible while in-flight.
|
|
425
|
+
*
|
|
426
|
+
* Table-wide invalidation is intentional: Interocitor targets rare-update
|
|
427
|
+
* workloads, not realtime state streams. The over-fetch on a write is a
|
|
428
|
+
* fixed-cost reload of cached rows for that one table — cheap, simple,
|
|
429
|
+
* and matches the query cache strategy. Finer-grained per-rowId
|
|
430
|
+
* invalidation can be added later without changing this contract.
|
|
431
|
+
*/
|
|
432
|
+
invalidateRowCacheForTable(table) {
|
|
433
|
+
const keys = this.rowCacheByTable.get(table);
|
|
434
|
+
if (!keys || keys.size === 0)
|
|
435
|
+
return;
|
|
436
|
+
for (const key of keys) {
|
|
437
|
+
const entry = this.rowCache.get(key);
|
|
438
|
+
if (!entry)
|
|
439
|
+
continue;
|
|
440
|
+
this.runRow(entry.descriptor, key);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
get initContext() {
|
|
444
|
+
return {
|
|
445
|
+
put: this.putNow.bind(this),
|
|
446
|
+
delete: this.deleteNow.bind(this),
|
|
447
|
+
// Cache APIs (ReadinessAwareQueryExecutor). Init-time tables need them
|
|
448
|
+
// because Table constructors and Table.row()/Table.query() resolve
|
|
449
|
+
// through engine.getQueryCacheKey / readQueryCache / loadQueryRows
|
|
450
|
+
// (and row equivalents). Without these, onInit handlers calling
|
|
451
|
+
// ctx.table(x).row(id) crash at construction time.
|
|
452
|
+
isReady: this.isReady.bind(this),
|
|
453
|
+
getQueryCacheKey: this.getQueryCacheKey.bind(this),
|
|
454
|
+
readQueryCache: this.readQueryCache.bind(this),
|
|
455
|
+
loadQueryRows: this.loadQueryRows.bind(this),
|
|
456
|
+
getRowCacheKey: this.getRowCacheKey.bind(this),
|
|
457
|
+
readRowCache: this.readRowCache.bind(this),
|
|
458
|
+
loadRow: this.loadRow.bind(this),
|
|
459
|
+
query: this.queryNow.bind(this),
|
|
460
|
+
queryWhere: this.queryWhereNow.bind(this),
|
|
461
|
+
table: (name) => new Table(this.initContext, name),
|
|
462
|
+
on: this.on.bind(this),
|
|
463
|
+
getDeviceId: this.getDeviceId.bind(this),
|
|
464
|
+
getMeshId: this.getMeshId.bind(this),
|
|
465
|
+
isEncrypted: this.isEncrypted.bind(this),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
99
468
|
// ── Internal accessors ─────────────────────────────────────────────
|
|
100
469
|
requireAdapter(operation) {
|
|
101
470
|
if (this.remotePoisonError)
|
|
@@ -105,6 +474,32 @@ export class SyncEngine {
|
|
|
105
474
|
}
|
|
106
475
|
return this.adapter;
|
|
107
476
|
}
|
|
477
|
+
requireRemotePath(operation) {
|
|
478
|
+
if (!this.config.remotePath)
|
|
479
|
+
throw new Error(`${operation} requires remotePath; configure mesh before connecting`);
|
|
480
|
+
return this.config.remotePath;
|
|
481
|
+
}
|
|
482
|
+
async rebuildOutboxFromLocalState() {
|
|
483
|
+
const rows = await this.local.getAllRows();
|
|
484
|
+
const entries = [];
|
|
485
|
+
for (const row of rows) {
|
|
486
|
+
const op = this.rowToSyncOp(row);
|
|
487
|
+
const hlc = this.getRowHlc(row);
|
|
488
|
+
if (!op || !hlc)
|
|
489
|
+
continue;
|
|
490
|
+
entries.push({
|
|
491
|
+
id: generateId('chg'),
|
|
492
|
+
ts: Date.now(),
|
|
493
|
+
device: this.deviceId,
|
|
494
|
+
hlc,
|
|
495
|
+
ops: [op],
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
if (entries.length === 0)
|
|
499
|
+
return;
|
|
500
|
+
await this.local.pushOutboxEntries(entries);
|
|
501
|
+
this.pendingCount = await this.local.outboxSize();
|
|
502
|
+
}
|
|
108
503
|
get codecState() {
|
|
109
504
|
return {
|
|
110
505
|
encryptionKey: this.encryptionKey,
|
|
@@ -115,7 +510,7 @@ export class SyncEngine {
|
|
|
115
510
|
get manifestContext() {
|
|
116
511
|
return {
|
|
117
512
|
adapter: this.requireAdapter('manifest'),
|
|
118
|
-
remotePath: this.
|
|
513
|
+
remotePath: this.requireRemotePath('manifest'),
|
|
119
514
|
serverId: this.serverId,
|
|
120
515
|
serverManaged: this.config.serverManaged,
|
|
121
516
|
deviceId: this.deviceId,
|
|
@@ -124,6 +519,25 @@ export class SyncEngine {
|
|
|
124
519
|
emit: (e) => this.emit(e),
|
|
125
520
|
};
|
|
126
521
|
}
|
|
522
|
+
async acknowledgeManifest() {
|
|
523
|
+
if (!this.adapter || !this.config.remotePath || !this.manifest)
|
|
524
|
+
return;
|
|
525
|
+
// Before the first compaction there is no canonical watermark/floor to
|
|
526
|
+
// acknowledge. Initial connect already writes device presence metadata;
|
|
527
|
+
// avoid an extra no-op device write/read on every bootstrap/reconnect.
|
|
528
|
+
if (!this.manifest.watermarkHlc && !this.manifest.gcFloorHlc && this.manifest.epoch === 0)
|
|
529
|
+
return;
|
|
530
|
+
await upsertDeviceMetadata(this.adapter, this.config.remotePath, this.deviceId, {
|
|
531
|
+
displayName: this.config.deviceName,
|
|
532
|
+
deviceType: this.config.deviceType,
|
|
533
|
+
observedManifestGeneration: this.manifest.generation,
|
|
534
|
+
observedEpoch: this.manifest.epoch,
|
|
535
|
+
observedWatermarkHlc: this.manifest.watermarkHlc,
|
|
536
|
+
observedGcFloorHlc: this.manifest.gcFloorHlc,
|
|
537
|
+
});
|
|
538
|
+
await this.local.setMeta('gcFloorHlc', this.manifest.gcFloorHlc ?? '');
|
|
539
|
+
await this.local.setMeta('gcEpoch', this.manifest.gcEpoch ?? 0);
|
|
540
|
+
}
|
|
127
541
|
// ── Flush / poll timers ────────────────────────────────────────────
|
|
128
542
|
clearScheduledFlush() {
|
|
129
543
|
if (this.flushTimer) {
|
|
@@ -131,6 +545,22 @@ export class SyncEngine {
|
|
|
131
545
|
this.flushTimer = null;
|
|
132
546
|
}
|
|
133
547
|
}
|
|
548
|
+
clearCompactTimers() {
|
|
549
|
+
if (this.compactCheckTimer)
|
|
550
|
+
clearTimeout(this.compactCheckTimer);
|
|
551
|
+
if (this.compactRunTimer)
|
|
552
|
+
clearTimeout(this.compactRunTimer);
|
|
553
|
+
this.compactCheckTimer = null;
|
|
554
|
+
this.compactRunTimer = null;
|
|
555
|
+
this.compactScheduleVersion += 1;
|
|
556
|
+
}
|
|
557
|
+
jitterDelay(baseMs, jitterMs) {
|
|
558
|
+
if (jitterMs <= 0)
|
|
559
|
+
return Math.max(0, baseMs);
|
|
560
|
+
const min = Math.max(0, baseMs - jitterMs);
|
|
561
|
+
const max = baseMs + jitterMs;
|
|
562
|
+
return Math.floor(min + Math.random() * (max - min + 1));
|
|
563
|
+
}
|
|
134
564
|
stopPolling() {
|
|
135
565
|
if (this.pollTimer) {
|
|
136
566
|
clearInterval(this.pollTimer);
|
|
@@ -143,70 +573,77 @@ export class SyncEngine {
|
|
|
143
573
|
this.pull().catch(() => { });
|
|
144
574
|
}, this.config.pollInterval);
|
|
145
575
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
576
|
+
stopRemoteInvalidations() {
|
|
577
|
+
if (!this.unsubscribeRemoteInvalidations)
|
|
578
|
+
return;
|
|
579
|
+
try {
|
|
580
|
+
this.unsubscribeRemoteInvalidations();
|
|
581
|
+
}
|
|
582
|
+
catch { }
|
|
583
|
+
this.unsubscribeRemoteInvalidations = null;
|
|
154
584
|
}
|
|
585
|
+
startRemoteInvalidations(adapter) {
|
|
586
|
+
this.stopRemoteInvalidations();
|
|
587
|
+
if (!this.supportsRemoteInvalidations(adapter)) {
|
|
588
|
+
this.log('debug', '[interocitor:relay] adapter has no invalidation subscription', { adapter: adapter.name });
|
|
589
|
+
this.emit({ type: 'relay:unavailable', adapter: adapter.name, reason: 'adapter-unsupported' });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.log('info', '[interocitor:relay] subscribing', { adapter: adapter.name, remotePath: this.config.remotePath, deviceId: this.deviceId });
|
|
593
|
+
this.emit({ type: 'relay:subscribe', adapter: adapter.name, remotePath: this.config.remotePath, deviceId: this.deviceId });
|
|
594
|
+
this.unsubscribeRemoteInvalidations = adapter.subscribeToInvalidations((payload) => {
|
|
595
|
+
this.log('info', '[interocitor:relay] invalidation received', payload);
|
|
596
|
+
this.emit({ type: 'relay:message', adapter: adapter.name, payload });
|
|
597
|
+
if (!this.connected)
|
|
598
|
+
return;
|
|
599
|
+
this.pull().catch((error) => {
|
|
600
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
601
|
+
this.log('warn', '[interocitor:relay] pull after invalidation failed', err);
|
|
602
|
+
this.emit({ type: 'relay:error', adapter: adapter.name, error: err });
|
|
603
|
+
});
|
|
604
|
+
}, {
|
|
605
|
+
onReady: () => {
|
|
606
|
+
this.log('info', '[interocitor:relay] ready', { adapter: adapter.name });
|
|
607
|
+
this.emit({ type: 'relay:ready', adapter: adapter.name });
|
|
608
|
+
},
|
|
609
|
+
onError: (error) => {
|
|
610
|
+
const err = error instanceof Error ? error : new Error(error ? String(error) : 'Remote invalidation subscription error');
|
|
611
|
+
this.log('warn', '[interocitor:relay] subscription error', err);
|
|
612
|
+
this.emit({ type: 'relay:error', adapter: adapter.name, error: err });
|
|
613
|
+
},
|
|
614
|
+
onClose: () => {
|
|
615
|
+
this.log('warn', '[interocitor:relay] closed', { adapter: adapter.name });
|
|
616
|
+
this.emit({ type: 'relay:closed', adapter: adapter.name });
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
// ── State helpers ──────────────────────────────────────────────────
|
|
155
621
|
getRowHlc(row) {
|
|
156
|
-
let latest = row.
|
|
157
|
-
for (const
|
|
158
|
-
if (
|
|
159
|
-
continue;
|
|
160
|
-
if (!value || typeof value !== 'object' || !('hlc' in value) || !('value' in value))
|
|
622
|
+
let latest = row._meta.deletedHlc ?? '';
|
|
623
|
+
for (const entry of Object.values(row.payload)) {
|
|
624
|
+
if (!entry?.hlc)
|
|
161
625
|
continue;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
latest = entryHlc;
|
|
626
|
+
if (!latest || hlcCompareStr(entry.hlc, latest) > 0)
|
|
627
|
+
latest = entry.hlc;
|
|
165
628
|
}
|
|
166
629
|
return latest;
|
|
167
630
|
}
|
|
168
631
|
rowToSyncOp(row) {
|
|
169
|
-
if (row.
|
|
170
|
-
const hlc = row.
|
|
632
|
+
if (row._meta.deleted) {
|
|
633
|
+
const hlc = row._meta.deletedHlc ?? this.getRowHlc(row);
|
|
171
634
|
if (!hlc)
|
|
172
635
|
return null;
|
|
173
|
-
return { type: 'delete', table: row.
|
|
636
|
+
return { type: 'delete', table: row._meta.table, rowId: row._meta.rowId, hlc };
|
|
174
637
|
}
|
|
175
638
|
const columns = {};
|
|
176
|
-
for (const [key,
|
|
177
|
-
if (
|
|
639
|
+
for (const [key, entry] of Object.entries(row.payload)) {
|
|
640
|
+
if (!entry?.hlc)
|
|
178
641
|
continue;
|
|
179
|
-
|
|
180
|
-
continue;
|
|
181
|
-
columns[key] = value;
|
|
642
|
+
columns[key] = entry;
|
|
182
643
|
}
|
|
183
644
|
if (Object.keys(columns).length === 0)
|
|
184
645
|
return null;
|
|
185
|
-
return { type: 'upsert', table: row.
|
|
186
|
-
}
|
|
187
|
-
buildChangeEntry(op, hlc) {
|
|
188
|
-
return {
|
|
189
|
-
id: generateId('chg'),
|
|
190
|
-
ts: Date.now(),
|
|
191
|
-
device: this.deviceId,
|
|
192
|
-
hlc,
|
|
193
|
-
ops: [op],
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
async rebuildOutboxFromLocalState() {
|
|
197
|
-
await this.local.drainOutbox();
|
|
198
|
-
const rows = await this.local.getAllRows();
|
|
199
|
-
let queued = 0;
|
|
200
|
-
for (const row of rows) {
|
|
201
|
-
const op = this.rowToSyncOp(row);
|
|
202
|
-
const hlc = this.getRowHlc(row);
|
|
203
|
-
if (!op || !hlc)
|
|
204
|
-
continue;
|
|
205
|
-
await this.local.pushOutbox(this.buildChangeEntry(op, hlc));
|
|
206
|
-
queued++;
|
|
207
|
-
}
|
|
208
|
-
this.pendingCount = queued;
|
|
209
|
-
return queued;
|
|
646
|
+
return { type: 'upsert', table: row._meta.table, rowId: row._meta.rowId, columns };
|
|
210
647
|
}
|
|
211
648
|
async loadLocalState() {
|
|
212
649
|
this.tables = {};
|
|
@@ -239,8 +676,32 @@ export class SyncEngine {
|
|
|
239
676
|
this.stopPolling();
|
|
240
677
|
this.clearScheduledFlush();
|
|
241
678
|
this.connected = false;
|
|
679
|
+
this.connectPromise = null;
|
|
680
|
+
// Drop the adapter's "ensured folders" cache. After poison we don't
|
|
681
|
+
// know whether the structure on disk is intact (corrupt manifest may
|
|
682
|
+
// have been minted while folders were partially created), so the
|
|
683
|
+
// next connect must re-validate every folder.
|
|
684
|
+
this.adapter?.resetFolderCache?.();
|
|
685
|
+
this.log('error', 'remote:poisoned — sync halted', {
|
|
686
|
+
dbName: this.dbName,
|
|
687
|
+
remotePath: this.config.remotePath,
|
|
688
|
+
deviceId: this.deviceId,
|
|
689
|
+
path,
|
|
690
|
+
message: poisoned.message,
|
|
691
|
+
});
|
|
242
692
|
}
|
|
243
|
-
this.emit({
|
|
693
|
+
this.emit({
|
|
694
|
+
type: 'remote:poisoned',
|
|
695
|
+
error: poisoned,
|
|
696
|
+
path,
|
|
697
|
+
context: {
|
|
698
|
+
dbName: this.dbName,
|
|
699
|
+
remotePath: this.config.remotePath,
|
|
700
|
+
deviceId: this.deviceId,
|
|
701
|
+
meshId: this.manifest?.meshId,
|
|
702
|
+
encrypted: this.encrypted,
|
|
703
|
+
},
|
|
704
|
+
});
|
|
244
705
|
return poisoned;
|
|
245
706
|
}
|
|
246
707
|
// ── Events ─────────────────────────────────────────────────────────
|
|
@@ -249,6 +710,14 @@ export class SyncEngine {
|
|
|
249
710
|
return () => this.listeners.delete(listener);
|
|
250
711
|
}
|
|
251
712
|
emit(event) {
|
|
713
|
+
// Single chokepoint for cache invalidation. Local mutations and remote
|
|
714
|
+
// pull both flow through emit(); both invalidate query cache for the
|
|
715
|
+
// affected table the same way. Local mutations also pre-invalidate so
|
|
716
|
+
// synchronous reads after `put`/`delete` see fresh data.
|
|
717
|
+
if (event.type === 'change' || event.type === 'delete') {
|
|
718
|
+
this.invalidateQueryCacheForTable(event.table);
|
|
719
|
+
this.invalidateRowCacheForTable(event.table);
|
|
720
|
+
}
|
|
252
721
|
for (const listener of this.listeners) {
|
|
253
722
|
try {
|
|
254
723
|
listener(event);
|
|
@@ -260,7 +729,77 @@ export class SyncEngine {
|
|
|
260
729
|
async persistCredentials() {
|
|
261
730
|
if (!this.credentialStore || !this.passphrase)
|
|
262
731
|
return;
|
|
263
|
-
|
|
732
|
+
try {
|
|
733
|
+
// Bind the persisted record to the active meshId when known.
|
|
734
|
+
// The store keeps ONE record per dbName; meshId lets the engine
|
|
735
|
+
// detect "wrong mesh" on the next load instead of silently reusing
|
|
736
|
+
// the previous mesh's key.
|
|
737
|
+
//
|
|
738
|
+
// Crucial: do NOT clobber an existing stored meshId when the
|
|
739
|
+
// engine's manifest has not been loaded yet (e.g. persist runs
|
|
740
|
+
// during init before connect()). Read-modify-write preserves the
|
|
741
|
+
// marker so `assertCredentialMeshParity` can still detect a stale
|
|
742
|
+
// record on the upcoming connect.
|
|
743
|
+
let meshId = this.manifest?.meshId;
|
|
744
|
+
if (!meshId) {
|
|
745
|
+
try {
|
|
746
|
+
const existing = await this.credentialStore.load();
|
|
747
|
+
if (existing?.meshId)
|
|
748
|
+
meshId = existing.meshId;
|
|
749
|
+
}
|
|
750
|
+
catch { /* best-effort merge */ }
|
|
751
|
+
}
|
|
752
|
+
await this.credentialStore.save({
|
|
753
|
+
passphrase: this.passphrase,
|
|
754
|
+
deviceId: this.deviceId,
|
|
755
|
+
...(meshId ? { meshId } : {}),
|
|
756
|
+
});
|
|
757
|
+
this.log('debug', 'persistCredentials() — saved', { dbName: this.dbName, deviceId: this.deviceId, meshId });
|
|
758
|
+
this.emit({
|
|
759
|
+
type: 'credentials:persisted',
|
|
760
|
+
dbName: this.dbName,
|
|
761
|
+
remotePath: this.config.remotePath,
|
|
762
|
+
deviceId: this.deviceId,
|
|
763
|
+
encrypted: this.encrypted,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
this.log('error', 'persistCredentials() — failed', err);
|
|
768
|
+
// Persistence failure must not break local writes; surface via log only.
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Compare the persisted credential record against the live meshId.
|
|
773
|
+
*
|
|
774
|
+
* Throws `MeshCredentialMismatchError` (and emits `credentials:meshMismatch`)
|
|
775
|
+
* when the credential store has a record under this `dbName` whose meshId
|
|
776
|
+
* disagrees with `manifest.meshId`. The remote is NOT poisoned — the local
|
|
777
|
+
* credential store has stale data from a previous mesh that shared the
|
|
778
|
+
* same dbName.
|
|
779
|
+
*/
|
|
780
|
+
async assertCredentialMeshParity() {
|
|
781
|
+
const activeMeshId = this.manifest?.meshId;
|
|
782
|
+
if (!activeMeshId || !this.credentialStore)
|
|
783
|
+
return;
|
|
784
|
+
let stored = null;
|
|
785
|
+
try {
|
|
786
|
+
stored = await this.credentialStore.load();
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return; // load failures already surface elsewhere; do not block connect
|
|
790
|
+
}
|
|
791
|
+
if (!stored?.meshId)
|
|
792
|
+
return; // legacy/no-meshId record: nothing to assert
|
|
793
|
+
if (stored.meshId === activeMeshId)
|
|
794
|
+
return;
|
|
795
|
+
this.emit({
|
|
796
|
+
type: 'credentials:meshMismatch',
|
|
797
|
+
dbName: this.dbName,
|
|
798
|
+
remotePath: this.config.remotePath,
|
|
799
|
+
storedMeshId: stored.meshId,
|
|
800
|
+
activeMeshId,
|
|
801
|
+
});
|
|
802
|
+
throw new MeshCredentialMismatchError(this.dbName, stored.meshId, activeMeshId);
|
|
264
803
|
}
|
|
265
804
|
async loadPersistedCredentials() {
|
|
266
805
|
if (!this.credentialStore)
|
|
@@ -273,17 +812,38 @@ export class SyncEngine {
|
|
|
273
812
|
await this.credentialStore.clear();
|
|
274
813
|
}
|
|
275
814
|
async resolveEncryption() {
|
|
276
|
-
|
|
815
|
+
console.log('[interocitor:cred] resolveEncryption() — entry', {
|
|
816
|
+
dbName: this.dbName,
|
|
817
|
+
encrypted: this.encrypted,
|
|
818
|
+
hasPassphrase: !!this.passphrase,
|
|
819
|
+
hasKey: !!this.encryptionKey,
|
|
820
|
+
passphraseFingerprint: this.passphrase ? `len=${this.passphrase.length} head=${this.passphrase.slice(0, 8)} tail=${this.passphrase.slice(-4)}` : null,
|
|
821
|
+
});
|
|
822
|
+
if (!this.encrypted) {
|
|
823
|
+
this.log('debug', 'resolveEncryption() — encryption disabled');
|
|
277
824
|
return;
|
|
825
|
+
}
|
|
278
826
|
// 1. Have passphrase (from config, setPassphrase(), or restoreCredentials())
|
|
279
827
|
if (this.passphrase && !this.encryptionKey) {
|
|
280
828
|
this.encryptionKey = await passphraseToKey(this.passphrase);
|
|
829
|
+
try {
|
|
830
|
+
const raw = await crypto.subtle.exportKey('raw', this.encryptionKey);
|
|
831
|
+
const hash = await crypto.subtle.digest('SHA-256', raw);
|
|
832
|
+
const bytes = new Uint8Array(hash);
|
|
833
|
+
const hex = Array.from(bytes.slice(0, 6)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
834
|
+
console.log('[interocitor:cred] resolveEncryption() — derived key', { dbName: this.dbName, keyFingerprint: `sha256-${hex}` });
|
|
835
|
+
}
|
|
836
|
+
catch { /* ignore */ }
|
|
281
837
|
await this.persistCredentials();
|
|
838
|
+
this.log('info', 'resolveEncryption() — derived key from passphrase', { dbName: this.dbName });
|
|
839
|
+
this.emit({ type: 'encryption:resolved', strategy: 'passphrase', dbName: this.dbName, remotePath: this.config.remotePath, encrypted: true });
|
|
282
840
|
return;
|
|
283
841
|
}
|
|
284
842
|
// 2. Already have a key (set via setPassphrase before init)
|
|
285
843
|
if (this.encryptionKey) {
|
|
286
844
|
await this.persistCredentials();
|
|
845
|
+
this.log('info', 'resolveEncryption() — using preset key', { dbName: this.dbName });
|
|
846
|
+
this.emit({ type: 'encryption:resolved', strategy: 'existing-key', dbName: this.dbName, remotePath: this.config.remotePath, encrypted: true });
|
|
287
847
|
return;
|
|
288
848
|
}
|
|
289
849
|
// 3. Generate fresh key (first-time open)
|
|
@@ -291,15 +851,54 @@ export class SyncEngine {
|
|
|
291
851
|
this.encryptionKey = key;
|
|
292
852
|
this.passphrase = await keyToPassphrase(key);
|
|
293
853
|
await this.persistCredentials();
|
|
854
|
+
this.log('warn', 'resolveEncryption() — generated fresh key (first-time open). Lose credentials => lose data.', { dbName: this.dbName });
|
|
855
|
+
this.emit({ type: 'encryption:resolved', strategy: 'generated', dbName: this.dbName, remotePath: this.config.remotePath, encrypted: true });
|
|
856
|
+
}
|
|
857
|
+
applyInitialState(state) {
|
|
858
|
+
if (!state)
|
|
859
|
+
return;
|
|
860
|
+
if (state.deviceId) {
|
|
861
|
+
this.deviceId = state.deviceId;
|
|
862
|
+
this.hlc.nodeId = state.deviceId;
|
|
863
|
+
}
|
|
864
|
+
if (state.remotePath !== undefined) {
|
|
865
|
+
this.config.remotePath = state.remotePath;
|
|
866
|
+
}
|
|
867
|
+
if (state.encrypted !== undefined) {
|
|
868
|
+
this.encrypted = state.encrypted;
|
|
869
|
+
if (!state.encrypted) {
|
|
870
|
+
this.passphrase = null;
|
|
871
|
+
this.encryptionKey = null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (state.passphrase !== undefined) {
|
|
875
|
+
this.passphrase = state.passphrase;
|
|
876
|
+
this.encryptionKey = null;
|
|
877
|
+
if (state.passphrase !== null)
|
|
878
|
+
this.encrypted = true;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
configureMesh(state) {
|
|
882
|
+
if (this.connected)
|
|
883
|
+
throw new Error('Cannot configure mesh while connected');
|
|
884
|
+
if (this.initialized)
|
|
885
|
+
throw new Error('Cannot configure mesh after init(); create a new engine or configure before connect');
|
|
886
|
+
this.applyInitialState(state);
|
|
887
|
+
this.emit({
|
|
888
|
+
type: 'mesh:configured',
|
|
889
|
+
dbName: this.dbName,
|
|
890
|
+
remotePath: this.config.remotePath,
|
|
891
|
+
deviceId: this.deviceId,
|
|
892
|
+
encrypted: this.encrypted,
|
|
893
|
+
hadPassphrase: this.passphrase !== null,
|
|
894
|
+
});
|
|
294
895
|
}
|
|
295
896
|
/**
|
|
296
897
|
* Set the mesh encryption passphrase.
|
|
297
898
|
* Call before init() or between disconnect() and init().
|
|
298
899
|
*/
|
|
299
900
|
setPassphrase(passphrase) {
|
|
300
|
-
this.passphrase
|
|
301
|
-
this.encrypted = true;
|
|
302
|
-
this.encryptionKey = null; // re-derived in init()
|
|
901
|
+
this.configureMesh({ passphrase, encrypted: true });
|
|
303
902
|
}
|
|
304
903
|
/**
|
|
305
904
|
* Get the current mesh passphrase.
|
|
@@ -330,15 +929,30 @@ export class SyncEngine {
|
|
|
330
929
|
* Returns true if restore succeeded.
|
|
331
930
|
*/
|
|
332
931
|
async restoreWithBiometrics() {
|
|
333
|
-
|
|
932
|
+
let restored = null;
|
|
933
|
+
try {
|
|
934
|
+
restored = await (this.credentialStore?.restoreWithBiometrics?.() ?? Promise.resolve(null));
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
this.log('warn', 'restoreWithBiometrics() — failed', err);
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
334
940
|
if (!restored)
|
|
335
941
|
return false;
|
|
942
|
+
const deviceIdChanged = restored.deviceId !== this.deviceId;
|
|
336
943
|
this.deviceId = restored.deviceId;
|
|
337
944
|
this.hlc.nodeId = restored.deviceId;
|
|
338
945
|
this.passphrase = restored.passphrase;
|
|
339
946
|
if (this.encrypted) {
|
|
340
947
|
this.encryptionKey = await passphraseToKey(restored.passphrase);
|
|
341
948
|
}
|
|
949
|
+
this.log('info', 'restoreWithBiometrics() — restored', { dbName: this.dbName, deviceIdChanged });
|
|
950
|
+
this.emit({
|
|
951
|
+
type: 'credentials:restored',
|
|
952
|
+
source: 'biometric',
|
|
953
|
+
deviceIdChanged,
|
|
954
|
+
hadPassphrase: true,
|
|
955
|
+
});
|
|
342
956
|
return true;
|
|
343
957
|
}
|
|
344
958
|
/**
|
|
@@ -360,140 +974,535 @@ export class SyncEngine {
|
|
|
360
974
|
}
|
|
361
975
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
362
976
|
async init() {
|
|
363
|
-
|
|
977
|
+
await this.ensureReady();
|
|
978
|
+
}
|
|
979
|
+
async doInit() {
|
|
980
|
+
this.log('debug', 'init() — opening local store', { dbName: this.config.dbName, encrypted: this.encrypted, remotePath: this.config.remotePath });
|
|
364
981
|
try {
|
|
365
982
|
await this.local.open();
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
// Resolve encryption: derive key from passphrase, load persisted, or generate.
|
|
378
|
-
await this.resolveEncryption();
|
|
379
|
-
log('debug', 'init() — loading local state (table names, HLC)');
|
|
380
|
-
try {
|
|
983
|
+
if (this.schema) {
|
|
984
|
+
await this.local.setMeta('schema:version', this.schema.version);
|
|
985
|
+
}
|
|
986
|
+
const initialState = await this.config.resolveInitialState?.();
|
|
987
|
+
this.applyInitialState(initialState ?? null);
|
|
988
|
+
// Recover credentials from the silent primary store only.
|
|
989
|
+
// No biometric prompt during normal init.
|
|
990
|
+
await this.restoreCredentials();
|
|
991
|
+
// Resolve encryption: derive key from passphrase, load persisted, or generate.
|
|
992
|
+
await this.resolveEncryption();
|
|
993
|
+
this.log('debug', 'init() — loading local state (table names, HLC)');
|
|
381
994
|
await this.loadLocalState();
|
|
995
|
+
this.log('debug', 'init() — complete', { knownTables: Array.from(this.knownTables) });
|
|
996
|
+
this.initialized = true;
|
|
997
|
+
if (this.config.onInit) {
|
|
998
|
+
await this.config.onInit(this.initContext);
|
|
999
|
+
}
|
|
382
1000
|
}
|
|
383
1001
|
catch (err) {
|
|
384
|
-
log('error', 'init() —
|
|
1002
|
+
this.log('error', 'init() — failed', err);
|
|
1003
|
+
this.initPromise = null;
|
|
385
1004
|
throw err;
|
|
386
1005
|
}
|
|
387
|
-
log('debug', 'init() — complete', { knownTables: Array.from(this.knownTables) });
|
|
388
|
-
this.initialized = true;
|
|
389
1006
|
}
|
|
390
1007
|
/**
|
|
391
1008
|
* Silent credential restore from the primary store only.
|
|
392
1009
|
* Used during normal init(). No biometric prompt.
|
|
393
1010
|
*/
|
|
394
1011
|
async restoreCredentials() {
|
|
395
|
-
|
|
396
|
-
|
|
1012
|
+
console.log('[interocitor:cred] restoreCredentials() — entry', {
|
|
1013
|
+
dbName: this.dbName,
|
|
1014
|
+
activeDeviceId: this.deviceId,
|
|
1015
|
+
activePassphraseFingerprint: this.passphrase ? `len=${this.passphrase.length} head=${this.passphrase.slice(0, 8)} tail=${this.passphrase.slice(-4)}` : null,
|
|
1016
|
+
hasKey: !!this.encryptionKey,
|
|
1017
|
+
encrypted: this.encrypted,
|
|
1018
|
+
});
|
|
1019
|
+
let stored = null;
|
|
1020
|
+
try {
|
|
1021
|
+
stored = await this.loadPersistedCredentials();
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
console.log('[interocitor:cred] restoreCredentials() — store load failed', { dbName: this.dbName, err: err instanceof Error ? err.message : String(err) });
|
|
1025
|
+
this.log('warn', 'restoreCredentials() — silent load failed', err);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
console.log('[interocitor:cred] restoreCredentials() — store loaded', {
|
|
1029
|
+
dbName: this.dbName,
|
|
1030
|
+
hasStored: !!stored,
|
|
1031
|
+
storedDeviceId: stored?.deviceId,
|
|
1032
|
+
storedMeshId: stored?.meshId,
|
|
1033
|
+
storedPassphraseFingerprint: stored?.passphrase ? `len=${stored.passphrase.length} head=${stored.passphrase.slice(0, 8)} tail=${stored.passphrase.slice(-4)}` : null,
|
|
1034
|
+
});
|
|
1035
|
+
if (!stored) {
|
|
1036
|
+
this.log('debug', 'restoreCredentials() — no persisted credentials', { dbName: this.dbName });
|
|
397
1037
|
return;
|
|
1038
|
+
}
|
|
1039
|
+
// Mesh-id parity check.
|
|
1040
|
+
//
|
|
1041
|
+
// The credential store keeps ONE record per dbName. If the stored
|
|
1042
|
+
// record names a meshId and the local store already knows a different
|
|
1043
|
+
// meshId for this dbName (typical: user clicked "create new mesh"
|
|
1044
|
+
// under the same dbName, then reloaded), the persisted passphrase is
|
|
1045
|
+
// for the OLD mesh and silently adopting it would either fail
|
|
1046
|
+
// decryption or poison the new remote.
|
|
1047
|
+
//
|
|
1048
|
+
// The connect-time post-manifest check below covers the case where
|
|
1049
|
+
// the local-store meshId is empty (fresh install + stale cred record).
|
|
1050
|
+
if (stored.meshId) {
|
|
1051
|
+
const localMeshIdRaw = await this.local.getMeta('meshId');
|
|
1052
|
+
const localMeshId = typeof localMeshIdRaw === 'string' ? localMeshIdRaw : '';
|
|
1053
|
+
if (localMeshId && localMeshId !== stored.meshId) {
|
|
1054
|
+
this.log('error', 'restoreCredentials() — meshId mismatch, refusing to adopt stored credentials', {
|
|
1055
|
+
dbName: this.dbName,
|
|
1056
|
+
storedMeshId: stored.meshId,
|
|
1057
|
+
activeMeshId: localMeshId,
|
|
1058
|
+
});
|
|
1059
|
+
this.emit({
|
|
1060
|
+
type: 'credentials:meshMismatch',
|
|
1061
|
+
dbName: this.dbName,
|
|
1062
|
+
remotePath: this.config.remotePath,
|
|
1063
|
+
storedMeshId: stored.meshId,
|
|
1064
|
+
activeMeshId: localMeshId,
|
|
1065
|
+
});
|
|
1066
|
+
throw new MeshCredentialMismatchError(this.dbName, stored.meshId, localMeshId);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
let deviceIdChanged = false;
|
|
398
1070
|
if (stored.deviceId && stored.deviceId !== this.deviceId) {
|
|
1071
|
+
// The local device-id global is shared across meshes on this origin.
|
|
1072
|
+
// If the persisted dbName-scoped store has a different device-id than
|
|
1073
|
+
// the global, the user is straddling two meshes and we are about to
|
|
1074
|
+
// self-poison their HLC. Surface it so the UI can intervene before
|
|
1075
|
+
// any writes happen.
|
|
1076
|
+
this.log('warn', 'restoreCredentials() — device-id mismatch, restoring stored id', {
|
|
1077
|
+
dbName: this.dbName,
|
|
1078
|
+
storedDeviceId: stored.deviceId,
|
|
1079
|
+
activeDeviceId: this.deviceId,
|
|
1080
|
+
});
|
|
1081
|
+
this.emit({
|
|
1082
|
+
type: 'credentials:conflict',
|
|
1083
|
+
storedDeviceId: stored.deviceId,
|
|
1084
|
+
activeDeviceId: this.deviceId,
|
|
1085
|
+
dbName: this.dbName,
|
|
1086
|
+
remotePath: this.config.remotePath,
|
|
1087
|
+
});
|
|
399
1088
|
this.deviceId = stored.deviceId;
|
|
400
1089
|
this.hlc.nodeId = stored.deviceId;
|
|
401
1090
|
try {
|
|
402
|
-
localStorage
|
|
1091
|
+
if (typeof localStorage !== 'undefined')
|
|
1092
|
+
localStorage.setItem('interocitor-device-id', stored.deviceId);
|
|
403
1093
|
}
|
|
404
1094
|
catch { /* ok */ }
|
|
1095
|
+
deviceIdChanged = true;
|
|
405
1096
|
}
|
|
406
|
-
|
|
407
|
-
|
|
1097
|
+
let hadPassphrase = false;
|
|
1098
|
+
if (this.encrypted && stored.passphrase) {
|
|
1099
|
+
if (!this.passphrase) {
|
|
1100
|
+
this.passphrase = stored.passphrase;
|
|
1101
|
+
hadPassphrase = true;
|
|
1102
|
+
}
|
|
1103
|
+
else if (this.passphrase !== stored.passphrase) {
|
|
1104
|
+
// Caller passed a different passphrase than the one persisted under
|
|
1105
|
+
// dbName. This is the classic "self-sabotage": same dbName, two keys.
|
|
1106
|
+
// Local rows were written with one key; new flushes will use another;
|
|
1107
|
+
// every reload after this will start poisoning remote files.
|
|
1108
|
+
this.log('error', 'restoreCredentials() — passphrase conflict! Caller-provided passphrase differs from persisted. Refusing to silently swap.', {
|
|
1109
|
+
dbName: this.dbName,
|
|
1110
|
+
remotePath: this.config.remotePath,
|
|
1111
|
+
});
|
|
1112
|
+
// Keep caller-provided passphrase; the conflict event lets UI prompt
|
|
1113
|
+
// the user to either clearCredentials() or correct the passphrase.
|
|
1114
|
+
this.emit({
|
|
1115
|
+
type: 'credentials:conflict',
|
|
1116
|
+
storedDeviceId: stored.deviceId,
|
|
1117
|
+
activeDeviceId: this.deviceId,
|
|
1118
|
+
dbName: this.dbName,
|
|
1119
|
+
remotePath: this.config.remotePath,
|
|
1120
|
+
});
|
|
1121
|
+
// Reset the local cursor so the upcoming pull cannot use the
|
|
1122
|
+
// skip-listing fast-path. Without this, doFlush()'s post-write
|
|
1123
|
+
// cursor advance under the OLD key hides remote change files
|
|
1124
|
+
// from the new (mismatched) key — the engine would never decode
|
|
1125
|
+
// them and never surface the decode failure that proves the
|
|
1126
|
+
// passphrase is wrong. Conflict surfaces via decode:error +
|
|
1127
|
+
// remote:poisoned on the next pull, instead of silently going.
|
|
1128
|
+
try {
|
|
1129
|
+
await this.local.setMeta('cursor', '');
|
|
1130
|
+
}
|
|
1131
|
+
catch { /* best-effort */ }
|
|
1132
|
+
}
|
|
408
1133
|
}
|
|
1134
|
+
this.emit({
|
|
1135
|
+
type: 'credentials:restored',
|
|
1136
|
+
source: 'silent-store',
|
|
1137
|
+
deviceIdChanged,
|
|
1138
|
+
hadPassphrase,
|
|
1139
|
+
});
|
|
409
1140
|
}
|
|
410
1141
|
async connect() {
|
|
411
|
-
|
|
412
|
-
|
|
1142
|
+
console.log('[interocitor:connect] connect() — entry', {
|
|
1143
|
+
dbName: this.dbName,
|
|
1144
|
+
remotePath: this.config.remotePath,
|
|
1145
|
+
deviceId: this.deviceId,
|
|
1146
|
+
adapter: this.adapter?.name ?? null,
|
|
1147
|
+
encrypted: this.encrypted,
|
|
1148
|
+
hasPassphrase: !!this.passphrase,
|
|
1149
|
+
hasKey: !!this.encryptionKey,
|
|
1150
|
+
meshId: this.manifest?.meshId,
|
|
1151
|
+
connected: this.connected,
|
|
1152
|
+
hasInFlight: !!this.connectPromise,
|
|
1153
|
+
});
|
|
1154
|
+
await this.ensureReady();
|
|
1155
|
+
if (!this.config.remotePath)
|
|
1156
|
+
throw new Error('connect() requires remotePath; configure mesh before connecting');
|
|
1157
|
+
// Idempotent. If we are already connected to a live mesh on this
|
|
1158
|
+
// adapter+remotePath, don't restart the session — restart was the root
|
|
1159
|
+
// cause of the "observer" reload bug, where polling/flush timers and
|
|
1160
|
+
// adapter sessions stacked across UI reloads and started corrupting
|
|
1161
|
+
// the local cursor + remote files.
|
|
1162
|
+
if (this.connected && !this.remotePoisonError) {
|
|
1163
|
+
this.log('debug', 'connect() — already connected, no-op', {
|
|
1164
|
+
dbName: this.dbName,
|
|
1165
|
+
remotePath: this.config.remotePath,
|
|
1166
|
+
deviceId: this.deviceId,
|
|
1167
|
+
});
|
|
1168
|
+
this.emit({
|
|
1169
|
+
type: 'connect:noop',
|
|
1170
|
+
dbName: this.dbName,
|
|
1171
|
+
remotePath: this.config.remotePath,
|
|
1172
|
+
deviceId: this.deviceId,
|
|
1173
|
+
reason: 'already-connected',
|
|
1174
|
+
});
|
|
1175
|
+
return;
|
|
413
1176
|
}
|
|
1177
|
+
// Concurrent-callers dedupe. The `connected` flag flips true only after
|
|
1178
|
+
// the full pipeline (manifest, pull, doFlush, startPolling) resolves;
|
|
1179
|
+
// a second caller arriving before that would re-enter and double every
|
|
1180
|
+
// flushed change file. Share the in-flight promise instead.
|
|
1181
|
+
if (this.connectPromise) {
|
|
1182
|
+
this.log('debug', 'connect() — join in-flight connect', {
|
|
1183
|
+
dbName: this.dbName,
|
|
1184
|
+
remotePath: this.config.remotePath,
|
|
1185
|
+
deviceId: this.deviceId,
|
|
1186
|
+
});
|
|
1187
|
+
this.emit({
|
|
1188
|
+
type: 'connect:noop',
|
|
1189
|
+
dbName: this.dbName,
|
|
1190
|
+
remotePath: this.config.remotePath,
|
|
1191
|
+
deviceId: this.deviceId,
|
|
1192
|
+
reason: 'already-connected',
|
|
1193
|
+
});
|
|
1194
|
+
return this.connectPromise;
|
|
1195
|
+
}
|
|
1196
|
+
this.connectPromise = this.doConnect().finally(() => {
|
|
1197
|
+
this.connectPromise = null;
|
|
1198
|
+
});
|
|
1199
|
+
return this.connectPromise;
|
|
1200
|
+
}
|
|
1201
|
+
async tryConnectFastPath(adapter) {
|
|
1202
|
+
// Fast-path is only safe when we have a locally cached manifest whose
|
|
1203
|
+
// encryption mode matches the current engine config. If it does not match,
|
|
1204
|
+
// fall through to full connect so MeshEncryptionMismatchError is raised.
|
|
1205
|
+
if (!this.manifest) {
|
|
1206
|
+
const cached = await this.local.getMeta('manifestCache');
|
|
1207
|
+
if (!cached || cached.encrypted !== this.encrypted)
|
|
1208
|
+
return false;
|
|
1209
|
+
this.manifest = cached;
|
|
1210
|
+
}
|
|
1211
|
+
const cursorRaw = await this.local.getMeta('cursor');
|
|
1212
|
+
const cursor = typeof cursorRaw === 'string' ? cursorRaw : '';
|
|
1213
|
+
if (!cursor)
|
|
1214
|
+
return false;
|
|
1215
|
+
if (await this.local.outboxSize() > 0)
|
|
1216
|
+
return false;
|
|
1217
|
+
const remotePath = this.requireRemotePath('connect() fast-path');
|
|
1218
|
+
const p = paths(remotePath);
|
|
1219
|
+
try {
|
|
1220
|
+
const headRaw = await adapter.readFile(p.changesHead);
|
|
1221
|
+
const head = JSON.parse(new TextDecoder().decode(headRaw));
|
|
1222
|
+
this.emit({
|
|
1223
|
+
type: 'trace:head',
|
|
1224
|
+
op: 'read',
|
|
1225
|
+
reason: 'connect-fast-path',
|
|
1226
|
+
path: p.changesHead,
|
|
1227
|
+
priorHlc: head.latestHlc ?? null,
|
|
1228
|
+
});
|
|
1229
|
+
if (head.latestHlc && hlcCompareStr(head.latestHlc, cursor) <= 0) {
|
|
1230
|
+
this.emit({
|
|
1231
|
+
type: 'trace:head',
|
|
1232
|
+
op: 'skip-no-change',
|
|
1233
|
+
reason: 'connect-fast-path',
|
|
1234
|
+
path: p.changesHead,
|
|
1235
|
+
priorHlc: head.latestHlc,
|
|
1236
|
+
nextHlc: cursor,
|
|
1237
|
+
});
|
|
1238
|
+
this.emit({
|
|
1239
|
+
type: 'connect:state',
|
|
1240
|
+
dbName: this.dbName,
|
|
1241
|
+
remotePath: this.config.remotePath,
|
|
1242
|
+
deviceId: this.deviceId,
|
|
1243
|
+
meshId: this.manifest?.meshId,
|
|
1244
|
+
encrypted: this.encrypted,
|
|
1245
|
+
});
|
|
1246
|
+
this.emit({ type: 'sync:complete', entriesMerged: 0 });
|
|
1247
|
+
this.startPolling();
|
|
1248
|
+
this.startRemoteInvalidations(adapter);
|
|
1249
|
+
this.connected = true;
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
catch {
|
|
1254
|
+
// Missing or malformed head means this is not a safe fast path.
|
|
1255
|
+
// Fall back to the full connect pipeline, which validates manifest,
|
|
1256
|
+
// folders, credentials, epoch, and then pulls.
|
|
1257
|
+
}
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
async doConnect() {
|
|
414
1261
|
const adapter = this.requireAdapter('connect()');
|
|
415
|
-
log('
|
|
1262
|
+
console.log('[interocitor:connect] doConnect() — start', {
|
|
1263
|
+
dbName: this.dbName,
|
|
1264
|
+
remotePath: this.config.remotePath,
|
|
1265
|
+
deviceId: this.deviceId,
|
|
1266
|
+
adapter: adapter.name,
|
|
1267
|
+
meshIdBefore: this.manifest?.meshId,
|
|
1268
|
+
});
|
|
1269
|
+
const stage = (s, err) => {
|
|
1270
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
1271
|
+
console.log('[interocitor:connect] doConnect() — STAGE FAIL', { stage: s, dbName: this.dbName, deviceId: this.deviceId, err: e.message });
|
|
1272
|
+
this.emit({
|
|
1273
|
+
type: 'connect:error',
|
|
1274
|
+
error: e,
|
|
1275
|
+
stage: s,
|
|
1276
|
+
dbName: this.dbName,
|
|
1277
|
+
remotePath: this.config.remotePath,
|
|
1278
|
+
deviceId: this.deviceId,
|
|
1279
|
+
});
|
|
1280
|
+
return e;
|
|
1281
|
+
};
|
|
1282
|
+
const stageOk = (s, extra) => {
|
|
1283
|
+
console.log('[interocitor:connect] doConnect() — stage ok', { stage: s, dbName: this.dbName, deviceId: this.deviceId, ...(extra ?? {}) });
|
|
1284
|
+
};
|
|
1285
|
+
this.log('debug', 'connect() — authenticating with adapter', { adapter: adapter.name });
|
|
416
1286
|
if (!adapter.isAuthenticated()) {
|
|
417
1287
|
this.emit({ type: 'auth:required' });
|
|
418
1288
|
try {
|
|
419
1289
|
await adapter.authenticate();
|
|
420
1290
|
}
|
|
421
1291
|
catch (err) {
|
|
422
|
-
log('error', 'connect() — authentication failed', err);
|
|
423
|
-
throw err;
|
|
1292
|
+
this.log('error', 'connect() — authentication failed', err);
|
|
1293
|
+
throw stage('authenticate', err);
|
|
424
1294
|
}
|
|
425
1295
|
this.emit({ type: 'auth:complete' });
|
|
426
1296
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1297
|
+
// Reload steady-state fast path: local IDB has a cursor, there is no
|
|
1298
|
+
// pending outbox, and remote head has not advanced. In that case the
|
|
1299
|
+
// client has nothing to publish or merge. After the minimal auth check,
|
|
1300
|
+
// probe head and stop — no folder creation, manifest reads, device
|
|
1301
|
+
// metadata writes, listFiles, or change-file reads. This covers clients
|
|
1302
|
+
// that recreate the adapter on reload before calling setRemoteStorage().
|
|
1303
|
+
if (await this.tryConnectFastPath(adapter))
|
|
1304
|
+
return;
|
|
1305
|
+
const remotePath = this.requireRemotePath('connect()');
|
|
1306
|
+
const p = paths(remotePath);
|
|
1307
|
+
this.log('debug', 'connect() — ensuring remote folders', { remotePath, deviceId: this.deviceId });
|
|
1308
|
+
for (const folder of [remotePath, p.devicesFolder, p.mainlineFolder, p.changesFolder]) {
|
|
430
1309
|
try {
|
|
431
1310
|
await adapter.ensureFolder(folder);
|
|
432
|
-
log('debug', 'connect() — ensureFolder ok', folder);
|
|
1311
|
+
this.log('debug', 'connect() — ensureFolder ok', folder);
|
|
433
1312
|
}
|
|
434
1313
|
catch (err) {
|
|
435
|
-
log('error', 'connect() — ensureFolder failed', folder, err);
|
|
436
|
-
throw err;
|
|
1314
|
+
this.log('error', 'connect() — ensureFolder failed', folder, err);
|
|
1315
|
+
throw stage('ensureFolder', err);
|
|
437
1316
|
}
|
|
438
1317
|
}
|
|
439
|
-
log('debug', 'connect() — loading/creating manifest');
|
|
1318
|
+
this.log('debug', 'connect() — loading/creating manifest');
|
|
1319
|
+
let bootstrapped = false;
|
|
440
1320
|
try {
|
|
441
|
-
|
|
1321
|
+
// Force on connect: any cached manifest predates the new transport
|
|
1322
|
+
// session and may be stale (compaction by another writer, mesh swap).
|
|
1323
|
+
({ bootstrapped } = await this.doLoadOrCreateManifest('connect', true));
|
|
1324
|
+
stageOk('loadOrCreateManifest', { bootstrapped, meshId: this.manifest?.meshId, generation: this.manifest?.generation });
|
|
442
1325
|
}
|
|
443
1326
|
catch (err) {
|
|
444
|
-
log('error', 'connect() — loadOrCreateManifest failed', err);
|
|
445
|
-
throw err;
|
|
1327
|
+
this.log('error', 'connect() — loadOrCreateManifest failed', err);
|
|
1328
|
+
throw stage('loadOrCreateManifest', err);
|
|
1329
|
+
}
|
|
1330
|
+
// Post-manifest credential check.
|
|
1331
|
+
//
|
|
1332
|
+
// We now know the live meshId. Compare it to the meshId attached to
|
|
1333
|
+
// the persisted credential record. If they disagree, the persisted
|
|
1334
|
+
// record is for a different mesh that happened to share the same
|
|
1335
|
+
// dbName (e.g. user clicked "create new mesh" twice). Refuse to
|
|
1336
|
+
// proceed — silently using the wrong key would poison the remote.
|
|
1337
|
+
//
|
|
1338
|
+
// The restoreCredentials() check covers reloads where the local
|
|
1339
|
+
// store already remembers the active meshId; this branch covers the
|
|
1340
|
+
// first connect after a fresh install.
|
|
1341
|
+
try {
|
|
1342
|
+
await this.assertCredentialMeshParity();
|
|
1343
|
+
stageOk('credentialMeshParity', { meshId: this.manifest?.meshId });
|
|
446
1344
|
}
|
|
447
|
-
|
|
1345
|
+
catch (err) {
|
|
1346
|
+
this.log('error', 'connect() — credential mesh parity failed', err);
|
|
1347
|
+
throw stage('credentialMeshParity', err);
|
|
1348
|
+
}
|
|
1349
|
+
// Anchor credentials to the live meshId now that parity is known
|
|
1350
|
+
// good. First-connect of a fresh mesh has no meshId in the
|
|
1351
|
+
// credential record yet; on reconnect this is a no-op write that
|
|
1352
|
+
// keeps the record fresh.
|
|
1353
|
+
await this.persistCredentials();
|
|
1354
|
+
stageOk('persistCredentialsPostManifest');
|
|
1355
|
+
await upsertDeviceMetadata(adapter, remotePath, this.deviceId, {
|
|
1356
|
+
displayName: this.config.deviceName,
|
|
1357
|
+
deviceType: this.config.deviceType,
|
|
1358
|
+
// Skip the read-merge GET when we just minted the manifest in this
|
|
1359
|
+
// same connect cycle — no prior device record can possibly exist.
|
|
1360
|
+
bootstrap: bootstrapped,
|
|
1361
|
+
});
|
|
448
1362
|
const localEpochRaw = await this.local.getMeta('epoch');
|
|
449
1363
|
const localEpoch = typeof localEpochRaw === 'number' ? localEpochRaw : 0;
|
|
450
1364
|
const remoteEpoch = this.manifest?.epoch ?? 0;
|
|
451
|
-
log('debug', 'connect() — epoch check', { localEpoch, remoteEpoch });
|
|
1365
|
+
this.log('debug', 'connect() — epoch check', { localEpoch, remoteEpoch });
|
|
1366
|
+
this.emit({
|
|
1367
|
+
type: 'connect:state',
|
|
1368
|
+
dbName: this.dbName,
|
|
1369
|
+
remotePath: this.config.remotePath,
|
|
1370
|
+
deviceId: this.deviceId,
|
|
1371
|
+
localEpoch,
|
|
1372
|
+
remoteEpoch,
|
|
1373
|
+
meshId: this.manifest?.meshId,
|
|
1374
|
+
encrypted: this.encrypted,
|
|
1375
|
+
});
|
|
452
1376
|
if (localEpoch < remoteEpoch) {
|
|
453
|
-
log('debug', 'connect() — epoch advanced, rehydrating from snapshot');
|
|
1377
|
+
this.log('debug', 'connect() — epoch advanced, rehydrating from snapshot');
|
|
454
1378
|
await this.rehydrate();
|
|
455
1379
|
}
|
|
456
1380
|
else {
|
|
457
|
-
log('debug', 'connect() — running initial pull');
|
|
1381
|
+
this.log('debug', 'connect() — running initial pull');
|
|
458
1382
|
await this.pull();
|
|
459
1383
|
}
|
|
460
|
-
|
|
1384
|
+
if (bootstrapped) {
|
|
1385
|
+
await this.rebuildOutboxFromLocalState();
|
|
1386
|
+
}
|
|
1387
|
+
await this.doFlush();
|
|
461
1388
|
this.startPolling();
|
|
1389
|
+
this.startRemoteInvalidations(adapter);
|
|
462
1390
|
this.connected = true;
|
|
463
|
-
log('info', 'connect() — connected', { remotePath: this.config.remotePath, deviceId: this.deviceId, pollInterval: this.config.pollInterval });
|
|
1391
|
+
this.log('info', 'connect() — connected', { remotePath: this.config.remotePath, deviceId: this.deviceId, pollInterval: this.config.pollInterval });
|
|
464
1392
|
if (typeof window !== 'undefined') {
|
|
465
1393
|
window.addEventListener('visibilitychange', () => {
|
|
466
1394
|
if (document.visibilityState === 'hidden') {
|
|
467
|
-
this.
|
|
1395
|
+
this.doFlush().catch(() => { });
|
|
468
1396
|
}
|
|
469
1397
|
});
|
|
470
1398
|
}
|
|
471
1399
|
}
|
|
472
1400
|
async disconnect() {
|
|
1401
|
+
await this.ensureReady();
|
|
1402
|
+
// Hard tear-down. Order matters: stop timers first so no in-flight
|
|
1403
|
+
// poll/push/flush touches the adapter while we are killing the session.
|
|
473
1404
|
this.stopPolling();
|
|
1405
|
+
this.stopRemoteInvalidations();
|
|
474
1406
|
this.clearScheduledFlush();
|
|
475
|
-
|
|
476
|
-
|
|
1407
|
+
this.clearCompactTimers();
|
|
1408
|
+
this.clearBatchTimer();
|
|
1409
|
+
await this.flushPendingBatch();
|
|
1410
|
+
if (!this.remotePoisonError) {
|
|
1411
|
+
try {
|
|
1412
|
+
await this.doFlush();
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
this.log('warn', 'disconnect() — flush before close failed (continuing)', err);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
this.emit({
|
|
1419
|
+
type: 'transport:teardown',
|
|
1420
|
+
dbName: this.dbName,
|
|
1421
|
+
remotePath: this.config.remotePath,
|
|
1422
|
+
deviceId: this.deviceId,
|
|
1423
|
+
reason: 'disconnect',
|
|
1424
|
+
});
|
|
477
1425
|
this.local.close();
|
|
478
1426
|
this.connected = false;
|
|
479
1427
|
this.initialized = false;
|
|
1428
|
+
this.initPromise = null;
|
|
1429
|
+
this.connectPromise = null;
|
|
480
1430
|
this.remotePoisonError = null;
|
|
481
1431
|
}
|
|
482
1432
|
async setRemoteStorage(adapter) {
|
|
1433
|
+
console.log('[interocitor:share] setRemoteStorage() — entry', {
|
|
1434
|
+
dbName: this.dbName,
|
|
1435
|
+
newAdapter: adapter?.name ?? null,
|
|
1436
|
+
currentAdapter: this.adapter?.name ?? null,
|
|
1437
|
+
sameByRef: adapter === this.adapter,
|
|
1438
|
+
remotePath: this.config.remotePath,
|
|
1439
|
+
deviceId: this.deviceId,
|
|
1440
|
+
connected: this.connected,
|
|
1441
|
+
meshId: this.manifest?.meshId,
|
|
1442
|
+
});
|
|
1443
|
+
await this.ensureReady();
|
|
1444
|
+
this.log('debug', 'setRemoteStorage()', { adapter: adapter?.name ?? null, remotePath: this.config.remotePath });
|
|
483
1445
|
const wasConnected = this.connected;
|
|
484
1446
|
const hadAdapter = this.adapter !== null;
|
|
485
|
-
|
|
486
|
-
|
|
1447
|
+
const switching = adapter !== this.adapter;
|
|
1448
|
+
console.log('[interocitor:share] setRemoteStorage() — decision', { wasConnected, hadAdapter, switching });
|
|
1449
|
+
// Same-adapter no-op. Callers (auto-reconnect, React StrictMode, etc.)
|
|
1450
|
+
// commonly re-attach the same adapter on every reload. Without this
|
|
1451
|
+
// guard we would tear down the live transport, reset cursor/epoch/meshId,
|
|
1452
|
+
// re-queue every IDB row into the outbox via rebuildOutboxFromLocalState,
|
|
1453
|
+
// then reconnect — which re-flushes the entire dataset as a fresh batch
|
|
1454
|
+
// of change files on every reload.
|
|
1455
|
+
if (!switching) {
|
|
1456
|
+
this.log('debug', 'setRemoteStorage() — same adapter, no-op');
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (wasConnected && hadAdapter && !this.remotePoisonError) {
|
|
1460
|
+
try {
|
|
1461
|
+
await this.pull();
|
|
1462
|
+
}
|
|
1463
|
+
catch (err) {
|
|
1464
|
+
this.log('warn', 'setRemoteStorage() — final pull before swap failed (continuing)', err);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
// Hard tear-down of the previous transport before swapping.
|
|
1468
|
+
// Without this, polling timers + outbox flush can race against the
|
|
1469
|
+
// newly-attached adapter and re-write the *new* mesh with files signed
|
|
1470
|
+
// for the old mesh — i.e. self-poison the remote on adapter switch.
|
|
487
1471
|
this.stopPolling();
|
|
1472
|
+
this.stopRemoteInvalidations();
|
|
488
1473
|
this.clearScheduledFlush();
|
|
1474
|
+
this.connected = false;
|
|
1475
|
+
if (switching && hadAdapter) {
|
|
1476
|
+
// Drop the OLD adapter's folder cache before we let go of it.
|
|
1477
|
+
// Belt-and-braces: if the old adapter is reattached later, we cannot
|
|
1478
|
+
// trust prior "this folder exists" observations against a potentially
|
|
1479
|
+
// different mesh layout.
|
|
1480
|
+
this.adapter?.resetFolderCache?.();
|
|
1481
|
+
this.emit({
|
|
1482
|
+
type: 'transport:teardown',
|
|
1483
|
+
dbName: this.dbName,
|
|
1484
|
+
remotePath: this.config.remotePath,
|
|
1485
|
+
deviceId: this.deviceId,
|
|
1486
|
+
reason: adapter ? 'switch-adapter' : 'detach',
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
489
1489
|
if (this.initialized) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1490
|
+
// Transport swaps are not semantic local-state resets. Preserve cursor,
|
|
1491
|
+
// epoch, meshId, rows, and any genuine unsent outbox entries. The next
|
|
1492
|
+
// connect will re-read/validate the manifest for the newly attached
|
|
1493
|
+
// adapter and pull only if its head is ahead of the preserved cursor.
|
|
1494
|
+
//
|
|
1495
|
+
// The previous code called resetRemoteSyncState() and then
|
|
1496
|
+
// rebuildOutboxFromLocalState(), which converted every canonical local
|
|
1497
|
+
// row back into a pending outbound write. That made reload/adapter attach
|
|
1498
|
+
// self-feed: already-synced rows became fresh change files for no reason.
|
|
1499
|
+
this.manifest = null;
|
|
1500
|
+
this.remotePoisonError = null;
|
|
1501
|
+
this.connected = false;
|
|
1502
|
+
this.pendingCount = await this.local.outboxSize();
|
|
493
1503
|
}
|
|
494
1504
|
else {
|
|
495
1505
|
this.manifest = null;
|
|
496
|
-
this.connected = false;
|
|
497
1506
|
}
|
|
498
1507
|
this.adapter = adapter;
|
|
499
1508
|
this.remotePoisonError = null;
|
|
@@ -501,9 +1510,10 @@ export class SyncEngine {
|
|
|
501
1510
|
await this.connect();
|
|
502
1511
|
}
|
|
503
1512
|
async setLocalStorage(local) {
|
|
1513
|
+
await this.ensureReady();
|
|
504
1514
|
const wasConnected = this.connected;
|
|
505
1515
|
if (wasConnected)
|
|
506
|
-
await this.
|
|
1516
|
+
await this.doFlush();
|
|
507
1517
|
this.clearScheduledFlush();
|
|
508
1518
|
this.pendingCount = 0;
|
|
509
1519
|
this.local.close();
|
|
@@ -514,139 +1524,509 @@ export class SyncEngine {
|
|
|
514
1524
|
if (!wasConnected)
|
|
515
1525
|
return;
|
|
516
1526
|
await this.pull();
|
|
517
|
-
await this.
|
|
1527
|
+
await this.doFlush();
|
|
518
1528
|
}
|
|
519
1529
|
// ── Manifest (delegated) ───────────────────────────────────────────
|
|
520
|
-
|
|
521
|
-
|
|
1530
|
+
/**
|
|
1531
|
+
* Load the mesh manifest, optionally short-circuiting via the in-memory
|
|
1532
|
+
* cache.
|
|
1533
|
+
*
|
|
1534
|
+
* `reason` is a free-form caller tag used by `trace:manifest` events so
|
|
1535
|
+
* developers can answer "why is my manifest being re-read every flush?".
|
|
1536
|
+
*
|
|
1537
|
+
* `force=true` bypasses the cache. Required after poison, after compaction
|
|
1538
|
+
* advances generation, or whenever the caller explicitly needs disk state.
|
|
1539
|
+
* Cached path emits `trace:manifest { op: 'cache-hit' }` so test/devtools
|
|
1540
|
+
* can assert that the steady-state pipeline does ZERO GETs on flush/pull.
|
|
1541
|
+
*/
|
|
1542
|
+
/**
|
|
1543
|
+
* Returns whether the manifest was bootstrapped (freshly minted) on this
|
|
1544
|
+
* call. Callers (connect()) use this to skip the device-metadata GET when
|
|
1545
|
+
* we know no prior device record can exist.
|
|
1546
|
+
*/
|
|
1547
|
+
async doLoadOrCreateManifest(reason = 'unknown', force = false) {
|
|
1548
|
+
if (!force && this.manifest && !this.remotePoisonError) {
|
|
1549
|
+
this.emit({
|
|
1550
|
+
type: 'trace:manifest',
|
|
1551
|
+
op: 'cache-hit',
|
|
1552
|
+
reason,
|
|
1553
|
+
generation: this.manifest.generation,
|
|
1554
|
+
cached: true,
|
|
1555
|
+
});
|
|
1556
|
+
return { bootstrapped: false };
|
|
1557
|
+
}
|
|
1558
|
+
const { manifest, bootstrapped } = await loadOrCreateManifest(this.manifestContext, this.codecState, this.local, (err, path) => this.poisonRemote(err, path), reason);
|
|
522
1559
|
this.manifest = manifest;
|
|
523
1560
|
this.encrypted = manifest.encrypted || this.encrypted;
|
|
1561
|
+
await this.local.setMeta('manifestCache', manifest);
|
|
1562
|
+
await this.local.setMeta('remoteGcFloorHlc', manifest.gcFloorHlc ?? '');
|
|
1563
|
+
await this.local.setMeta('remoteGcEpoch', manifest.gcEpoch ?? 0);
|
|
1564
|
+
return { bootstrapped };
|
|
524
1565
|
}
|
|
525
1566
|
// ── Local writes ───────────────────────────────────────────────────
|
|
526
1567
|
async put(table, rowId, columns, userId) {
|
|
527
|
-
this.
|
|
528
|
-
|
|
529
|
-
const columnEntries = {};
|
|
530
|
-
for (const [key, value] of Object.entries(columns)) {
|
|
531
|
-
columnEntries[key] = { value: value, hlc: hlcStr };
|
|
532
|
-
}
|
|
533
|
-
const op = { type: 'upsert', table, rowId, columns: columnEntries };
|
|
534
|
-
await this.ensureRowsCached([op]);
|
|
535
|
-
const row = applyOp(this.tables, op, this.manifest?.schema ?? 1, this.schema);
|
|
536
|
-
this.knownTables.add(table);
|
|
537
|
-
await this.local.putRow(row);
|
|
538
|
-
await this.local.setMeta('hlc', hlcSerialize(this.hlc));
|
|
539
|
-
const entry = {
|
|
540
|
-
id: generateId('chg'),
|
|
541
|
-
ts: Date.now(),
|
|
542
|
-
device: this.deviceId,
|
|
543
|
-
user: userId,
|
|
544
|
-
hlc: hlcStr,
|
|
545
|
-
ops: [op],
|
|
546
|
-
};
|
|
547
|
-
await this.local.pushOutbox(entry);
|
|
548
|
-
this.emit({ type: 'change', table, rowId, row });
|
|
549
|
-
this.scheduleFlush();
|
|
550
|
-
return row;
|
|
1568
|
+
await this.ensureReady();
|
|
1569
|
+
return this.putNow(table, rowId, columns, userId);
|
|
551
1570
|
}
|
|
552
1571
|
async delete(table, rowId, userId) {
|
|
553
|
-
this.
|
|
554
|
-
|
|
555
|
-
const op = { type: 'delete', table, rowId, hlc: hlcStr };
|
|
556
|
-
await this.ensureRowsCached([op]);
|
|
557
|
-
applyOp(this.tables, op, this.manifest?.schema ?? 1, this.schema);
|
|
558
|
-
const row = this.tables[table]?.[rowId];
|
|
559
|
-
if (row)
|
|
560
|
-
await this.local.putRow(row);
|
|
561
|
-
await this.local.setMeta('hlc', hlcSerialize(this.hlc));
|
|
562
|
-
const entry = {
|
|
563
|
-
id: generateId('chg'),
|
|
564
|
-
ts: Date.now(),
|
|
565
|
-
device: this.deviceId,
|
|
566
|
-
user: userId,
|
|
567
|
-
hlc: hlcStr,
|
|
568
|
-
ops: [op],
|
|
569
|
-
};
|
|
570
|
-
await this.local.pushOutbox(entry);
|
|
571
|
-
this.emit({ type: 'delete', table, rowId });
|
|
572
|
-
this.scheduleFlush();
|
|
573
|
-
}
|
|
574
|
-
// ── Read ───────────────────────────────────────────────────────────
|
|
575
|
-
async get(table, rowId) {
|
|
576
|
-
const row = await this.local.getRow(table, rowId);
|
|
577
|
-
if (!row || row._deleted)
|
|
578
|
-
return undefined;
|
|
579
|
-
return row;
|
|
1572
|
+
await this.ensureReady();
|
|
1573
|
+
return this.deleteNow(table, rowId, userId);
|
|
580
1574
|
}
|
|
581
1575
|
async query(table) {
|
|
582
|
-
|
|
1576
|
+
await this.ensureReady();
|
|
1577
|
+
return this.queryNow(table);
|
|
583
1578
|
}
|
|
584
1579
|
async queryWhere(table, clause) {
|
|
585
|
-
|
|
1580
|
+
await this.ensureReady();
|
|
1581
|
+
return this.queryWhereNow(table, clause);
|
|
586
1582
|
}
|
|
587
1583
|
async tableNames() {
|
|
1584
|
+
await this.ensureReady();
|
|
588
1585
|
return Array.from(this.knownTables);
|
|
589
1586
|
}
|
|
590
1587
|
table(name) {
|
|
591
1588
|
return new Table(this, name);
|
|
592
1589
|
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Group a sequence of writes into a single ChangeEntry. The entry
|
|
1592
|
+
* carries every op as one atomic unit, producing one remote file
|
|
1593
|
+
* instead of one per write.
|
|
1594
|
+
*
|
|
1595
|
+
* Nested batch() calls join the outer batch.
|
|
1596
|
+
*/
|
|
1597
|
+
async batch(fn) {
|
|
1598
|
+
await this.ensureReady();
|
|
1599
|
+
this.batchDepth += 1;
|
|
1600
|
+
try {
|
|
1601
|
+
const result = await fn();
|
|
1602
|
+
return result;
|
|
1603
|
+
}
|
|
1604
|
+
finally {
|
|
1605
|
+
this.batchDepth -= 1;
|
|
1606
|
+
if (this.batchDepth === 0)
|
|
1607
|
+
await this.flushPendingBatch();
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
isBatching() {
|
|
1611
|
+
return this.batchDepth > 0;
|
|
1612
|
+
}
|
|
1613
|
+
clearBatchTimer() {
|
|
1614
|
+
if (!this.batchTimer)
|
|
1615
|
+
return;
|
|
1616
|
+
clearTimeout(this.batchTimer);
|
|
1617
|
+
this.batchTimer = null;
|
|
1618
|
+
}
|
|
1619
|
+
appendOpToPendingBatch(op, hlc) {
|
|
1620
|
+
if (!this.pendingBatch) {
|
|
1621
|
+
this.pendingBatch = {
|
|
1622
|
+
id: generateId('chg'),
|
|
1623
|
+
ts: Date.now(),
|
|
1624
|
+
device: this.deviceId,
|
|
1625
|
+
hlc,
|
|
1626
|
+
ops: [op],
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
this.pendingBatch.ops.push(op);
|
|
1631
|
+
// Carry the highest HLC seen in this batch
|
|
1632
|
+
if (hlcCompareStr(hlc, this.pendingBatch.hlc) > 0)
|
|
1633
|
+
this.pendingBatch.hlc = hlc;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
armImplicitBatchTimer() {
|
|
1637
|
+
if (this.isBatching())
|
|
1638
|
+
return;
|
|
1639
|
+
if (this.batchTimer)
|
|
1640
|
+
return;
|
|
1641
|
+
this.batchTimer = setTimeout(() => {
|
|
1642
|
+
this.batchTimer = null;
|
|
1643
|
+
void this.flushPendingBatch();
|
|
1644
|
+
}, this.config.batchWindowMs);
|
|
1645
|
+
}
|
|
1646
|
+
async flushPendingBatch() {
|
|
1647
|
+
const pending = this.pendingBatch;
|
|
1648
|
+
this.pendingBatch = null;
|
|
1649
|
+
this.clearBatchTimer();
|
|
1650
|
+
if (!pending)
|
|
1651
|
+
return;
|
|
1652
|
+
await this.local.pushOutbox(pending);
|
|
1653
|
+
this.scheduleFlush();
|
|
1654
|
+
}
|
|
593
1655
|
// ── Flush (local → cloud) ──────────────────────────────────────────
|
|
594
1656
|
scheduleFlush() {
|
|
595
1657
|
this.pendingCount++;
|
|
1658
|
+
this.maybeEmitCompactWarning();
|
|
1659
|
+
this.armDelayedCompactAfterChange();
|
|
596
1660
|
if (this.pendingCount >= this.config.flushThreshold) {
|
|
597
|
-
this.
|
|
1661
|
+
this.doFlush().catch(err => this.emit({ type: 'flush:error', error: err }));
|
|
598
1662
|
return;
|
|
599
1663
|
}
|
|
600
1664
|
if (this.flushTimer)
|
|
601
1665
|
clearTimeout(this.flushTimer);
|
|
602
1666
|
this.flushTimer = setTimeout(() => {
|
|
603
|
-
this.
|
|
1667
|
+
this.doFlush().catch(err => this.emit({ type: 'flush:error', error: err }));
|
|
604
1668
|
}, this.config.flushDebounce);
|
|
605
1669
|
}
|
|
1670
|
+
maybeEmitCompactWarning() {
|
|
1671
|
+
if (this.compactWarningEmitted)
|
|
1672
|
+
return;
|
|
1673
|
+
if (this.pendingCount < this.config.compactWarnThreshold)
|
|
1674
|
+
return;
|
|
1675
|
+
this.compactWarningEmitted = true;
|
|
1676
|
+
this.emit({
|
|
1677
|
+
type: 'compact:warning',
|
|
1678
|
+
queuedChangeCount: this.pendingCount,
|
|
1679
|
+
threshold: this.config.compactWarnThreshold,
|
|
1680
|
+
autoCompactThreshold: this.config.compactAutoThreshold,
|
|
1681
|
+
remotePath: this.config.remotePath,
|
|
1682
|
+
deviceId: this.deviceId,
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
resetCompactWarning() {
|
|
1686
|
+
if (this.pendingCount !== 0)
|
|
1687
|
+
return;
|
|
1688
|
+
this.compactWarningEmitted = false;
|
|
1689
|
+
}
|
|
1690
|
+
async maybeAutoCompact(triggerQueuedChangeCount) {
|
|
1691
|
+
if (triggerQueuedChangeCount < this.config.compactAutoThreshold)
|
|
1692
|
+
return;
|
|
1693
|
+
const sampleWindow = Math.max(1, Math.floor(this.config.compactAutoDeviceCount / Math.max(1, this.config.compactAutoSampleNumerator)));
|
|
1694
|
+
const sampleRoll = Math.floor(Math.random() * sampleWindow);
|
|
1695
|
+
const baseEvent = {
|
|
1696
|
+
queuedChangeCount: triggerQueuedChangeCount,
|
|
1697
|
+
threshold: this.config.compactAutoThreshold,
|
|
1698
|
+
sampleRoll,
|
|
1699
|
+
sampleWindow,
|
|
1700
|
+
trigger: 'immediate',
|
|
1701
|
+
remotePath: this.config.remotePath,
|
|
1702
|
+
deviceId: this.deviceId,
|
|
1703
|
+
};
|
|
1704
|
+
if (!this.config.autoCompact) {
|
|
1705
|
+
this.emit({ type: 'compact:auto:skip', ...baseEvent, reason: 'disabled' });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (!this.adapter || !this.config.remotePath) {
|
|
1709
|
+
this.emit({ type: 'compact:auto:skip', ...baseEvent, reason: 'missing-remote' });
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (!this.connected) {
|
|
1713
|
+
this.emit({ type: 'compact:auto:skip', ...baseEvent, reason: 'not-connected' });
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (this.remotePoisonError) {
|
|
1717
|
+
this.emit({ type: 'compact:auto:skip', ...baseEvent, reason: 'poisoned' });
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
if (this.compactInFlight) {
|
|
1721
|
+
this.emit({ type: 'compact:auto:skip', ...baseEvent, reason: 'already-running' });
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
if (sampleRoll !== 0) {
|
|
1725
|
+
this.emit({ type: 'compact:auto:skip', ...baseEvent, reason: 'sampling' });
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
this.emit({ type: 'compact:auto:start', ...baseEvent });
|
|
1729
|
+
const run = this.compact().then(() => {
|
|
1730
|
+
this.emit({
|
|
1731
|
+
type: 'compact:auto:complete',
|
|
1732
|
+
queuedChangeCount: triggerQueuedChangeCount,
|
|
1733
|
+
threshold: this.config.compactAutoThreshold,
|
|
1734
|
+
trigger: 'immediate',
|
|
1735
|
+
remotePath: this.config.remotePath,
|
|
1736
|
+
deviceId: this.deviceId,
|
|
1737
|
+
});
|
|
1738
|
+
}).catch((error) => {
|
|
1739
|
+
this.emit({
|
|
1740
|
+
type: 'compact:auto:error',
|
|
1741
|
+
queuedChangeCount: triggerQueuedChangeCount,
|
|
1742
|
+
threshold: this.config.compactAutoThreshold,
|
|
1743
|
+
trigger: 'immediate',
|
|
1744
|
+
remotePath: this.config.remotePath,
|
|
1745
|
+
deviceId: this.deviceId,
|
|
1746
|
+
error,
|
|
1747
|
+
});
|
|
1748
|
+
}).finally(() => {
|
|
1749
|
+
if (this.compactInFlight === run)
|
|
1750
|
+
this.compactInFlight = null;
|
|
1751
|
+
});
|
|
1752
|
+
this.compactInFlight = run;
|
|
1753
|
+
await run;
|
|
1754
|
+
}
|
|
1755
|
+
// ── Delayed compact support (secondary path) ────────────────────────
|
|
1756
|
+
// Independent from the immediate sampled auto-compact above. Both paths
|
|
1757
|
+
// can co-exist: any single compact() call is deduped via compactInFlight.
|
|
1758
|
+
// Helps lazy clients eventually compact even when sampling never fires.
|
|
1759
|
+
armDelayedCompactAfterChange() {
|
|
1760
|
+
if (!this.config.autoCompact)
|
|
1761
|
+
return;
|
|
1762
|
+
if (!this.config.remotePath)
|
|
1763
|
+
return;
|
|
1764
|
+
const delayMs = this.jitterDelay(this.config.firstCompactDelayMs, this.config.firstCompactDelayJitterMs);
|
|
1765
|
+
this.compactScheduleVersion += 1;
|
|
1766
|
+
const version = this.compactScheduleVersion;
|
|
1767
|
+
if (this.compactCheckTimer)
|
|
1768
|
+
clearTimeout(this.compactCheckTimer);
|
|
1769
|
+
this.emit({
|
|
1770
|
+
type: 'compact:delayed:scheduled',
|
|
1771
|
+
queuedChangeCount: this.pendingCount,
|
|
1772
|
+
delayMs,
|
|
1773
|
+
phase: 'check',
|
|
1774
|
+
remotePath: this.config.remotePath,
|
|
1775
|
+
deviceId: this.deviceId,
|
|
1776
|
+
});
|
|
1777
|
+
this.compactCheckTimer = setTimeout(() => {
|
|
1778
|
+
this.compactCheckTimer = null;
|
|
1779
|
+
this.runDelayedCompactCheck(version).catch(() => { });
|
|
1780
|
+
}, delayMs);
|
|
1781
|
+
}
|
|
1782
|
+
async runDelayedCompactCheck(version) {
|
|
1783
|
+
if (version !== this.compactScheduleVersion)
|
|
1784
|
+
return;
|
|
1785
|
+
if (!this.config.autoCompact || !this.connected || !this.adapter || !this.config.remotePath)
|
|
1786
|
+
return;
|
|
1787
|
+
if (this.remotePoisonError)
|
|
1788
|
+
return;
|
|
1789
|
+
let remoteChangeFileCount = 0;
|
|
1790
|
+
try {
|
|
1791
|
+
const adapter = this.adapter;
|
|
1792
|
+
const remoteRoot = this.config.remotePath;
|
|
1793
|
+
const list = await adapter.listFiles(`${remoteRoot}/changes`).catch(() => []);
|
|
1794
|
+
remoteChangeFileCount = list.filter(f => /\/changes\/[^/]+-chg_[^/]+\.json$/.test(f.path)).length;
|
|
1795
|
+
}
|
|
1796
|
+
catch { /* best-effort */ }
|
|
1797
|
+
this.emit({
|
|
1798
|
+
type: 'compact:delayed:check',
|
|
1799
|
+
queuedChangeCount: this.pendingCount,
|
|
1800
|
+
remoteChangeFileCount,
|
|
1801
|
+
threshold: this.config.compactRemoteChangeThreshold,
|
|
1802
|
+
remotePath: this.config.remotePath,
|
|
1803
|
+
deviceId: this.deviceId,
|
|
1804
|
+
});
|
|
1805
|
+
if (remoteChangeFileCount <= this.config.compactRemoteChangeThreshold) {
|
|
1806
|
+
this.emit({
|
|
1807
|
+
type: 'compact:auto:skip',
|
|
1808
|
+
queuedChangeCount: this.pendingCount,
|
|
1809
|
+
threshold: this.config.compactAutoThreshold,
|
|
1810
|
+
trigger: 'delayed',
|
|
1811
|
+
remotePath: this.config.remotePath,
|
|
1812
|
+
deviceId: this.deviceId,
|
|
1813
|
+
reason: 'below-remote-threshold',
|
|
1814
|
+
});
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const delayMs = this.jitterDelay(this.config.secondCompactDelayMs, this.config.secondCompactDelayJitterMs);
|
|
1818
|
+
if (this.compactRunTimer)
|
|
1819
|
+
clearTimeout(this.compactRunTimer);
|
|
1820
|
+
this.emit({
|
|
1821
|
+
type: 'compact:delayed:scheduled',
|
|
1822
|
+
queuedChangeCount: this.pendingCount,
|
|
1823
|
+
delayMs,
|
|
1824
|
+
phase: 'compact',
|
|
1825
|
+
remotePath: this.config.remotePath,
|
|
1826
|
+
deviceId: this.deviceId,
|
|
1827
|
+
});
|
|
1828
|
+
const triggerQueuedChangeCount = this.pendingCount;
|
|
1829
|
+
this.compactRunTimer = setTimeout(() => {
|
|
1830
|
+
this.compactRunTimer = null;
|
|
1831
|
+
this.runDelayedCompact(version, triggerQueuedChangeCount, remoteChangeFileCount).catch(() => { });
|
|
1832
|
+
}, delayMs);
|
|
1833
|
+
}
|
|
1834
|
+
async runDelayedCompact(version, queuedChangeCount, remoteChangeFileCount) {
|
|
1835
|
+
if (version !== this.compactScheduleVersion) {
|
|
1836
|
+
this.emit({
|
|
1837
|
+
type: 'compact:auto:skip',
|
|
1838
|
+
queuedChangeCount,
|
|
1839
|
+
threshold: this.config.compactAutoThreshold,
|
|
1840
|
+
trigger: 'delayed',
|
|
1841
|
+
remotePath: this.config.remotePath,
|
|
1842
|
+
deviceId: this.deviceId,
|
|
1843
|
+
reason: 'superseded',
|
|
1844
|
+
});
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (!this.config.autoCompact || !this.connected || !this.adapter || !this.config.remotePath) {
|
|
1848
|
+
this.emit({
|
|
1849
|
+
type: 'compact:auto:skip',
|
|
1850
|
+
queuedChangeCount,
|
|
1851
|
+
threshold: this.config.compactAutoThreshold,
|
|
1852
|
+
trigger: 'delayed',
|
|
1853
|
+
remotePath: this.config.remotePath,
|
|
1854
|
+
deviceId: this.deviceId,
|
|
1855
|
+
reason: !this.connected ? 'not-connected' : !this.config.autoCompact ? 'disabled' : 'missing-remote',
|
|
1856
|
+
});
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (this.remotePoisonError) {
|
|
1860
|
+
this.emit({
|
|
1861
|
+
type: 'compact:auto:skip',
|
|
1862
|
+
queuedChangeCount,
|
|
1863
|
+
threshold: this.config.compactAutoThreshold,
|
|
1864
|
+
trigger: 'delayed',
|
|
1865
|
+
remotePath: this.config.remotePath,
|
|
1866
|
+
deviceId: this.deviceId,
|
|
1867
|
+
reason: 'poisoned',
|
|
1868
|
+
});
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
if (this.compactInFlight) {
|
|
1872
|
+
this.emit({
|
|
1873
|
+
type: 'compact:auto:skip',
|
|
1874
|
+
queuedChangeCount,
|
|
1875
|
+
threshold: this.config.compactAutoThreshold,
|
|
1876
|
+
trigger: 'delayed',
|
|
1877
|
+
remotePath: this.config.remotePath,
|
|
1878
|
+
deviceId: this.deviceId,
|
|
1879
|
+
reason: 'already-running',
|
|
1880
|
+
});
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
this.emit({
|
|
1884
|
+
type: 'compact:auto:start',
|
|
1885
|
+
queuedChangeCount,
|
|
1886
|
+
threshold: this.config.compactAutoThreshold,
|
|
1887
|
+
trigger: 'delayed',
|
|
1888
|
+
remoteChangeFileCount,
|
|
1889
|
+
remotePath: this.config.remotePath,
|
|
1890
|
+
deviceId: this.deviceId,
|
|
1891
|
+
});
|
|
1892
|
+
const run = this.compact().then(() => {
|
|
1893
|
+
this.emit({
|
|
1894
|
+
type: 'compact:auto:complete',
|
|
1895
|
+
queuedChangeCount,
|
|
1896
|
+
threshold: this.config.compactAutoThreshold,
|
|
1897
|
+
trigger: 'delayed',
|
|
1898
|
+
remoteChangeFileCount,
|
|
1899
|
+
remotePath: this.config.remotePath,
|
|
1900
|
+
deviceId: this.deviceId,
|
|
1901
|
+
});
|
|
1902
|
+
}).catch((error) => {
|
|
1903
|
+
this.emit({
|
|
1904
|
+
type: 'compact:auto:error',
|
|
1905
|
+
queuedChangeCount,
|
|
1906
|
+
threshold: this.config.compactAutoThreshold,
|
|
1907
|
+
trigger: 'delayed',
|
|
1908
|
+
remoteChangeFileCount,
|
|
1909
|
+
remotePath: this.config.remotePath,
|
|
1910
|
+
deviceId: this.deviceId,
|
|
1911
|
+
error,
|
|
1912
|
+
});
|
|
1913
|
+
});
|
|
1914
|
+
await run;
|
|
1915
|
+
}
|
|
1916
|
+
/** Public flush — waits for init. Safe to call from user code. */
|
|
606
1917
|
async flush() {
|
|
1918
|
+
await this.ensureReady();
|
|
1919
|
+
// Drain any pending implicit batch first so its ops reach the outbox
|
|
1920
|
+
// before we read it. Without this, flush() called from user code right
|
|
1921
|
+
// after a write inside the batch window would skip those writes.
|
|
1922
|
+
await this.flushPendingBatch();
|
|
1923
|
+
return this.doFlush();
|
|
1924
|
+
}
|
|
1925
|
+
/** Internal flush — no ensureReady guard (called from connect, pull, doInit). */
|
|
1926
|
+
hasPreFloorEntries(entries) {
|
|
1927
|
+
const floor = this.manifest?.gcFloorHlc;
|
|
1928
|
+
if (!floor)
|
|
1929
|
+
return false;
|
|
1930
|
+
return entries.some(entry => entry.hlc && hlcCompareStr(entry.hlc, floor) <= 0);
|
|
1931
|
+
}
|
|
1932
|
+
async doFlush() {
|
|
607
1933
|
if (!this.adapter) {
|
|
608
1934
|
this.clearScheduledFlush();
|
|
609
1935
|
return;
|
|
610
1936
|
}
|
|
1937
|
+
const triggerQueuedChangeCount = this.pendingCount;
|
|
611
1938
|
const entries = await this.local.drainOutbox();
|
|
612
|
-
if (entries.length === 0)
|
|
1939
|
+
if (entries.length === 0) {
|
|
1940
|
+
this.pendingCount = 0;
|
|
1941
|
+
this.resetCompactWarning();
|
|
613
1942
|
return;
|
|
614
|
-
|
|
1943
|
+
}
|
|
1944
|
+
// Reload manifest before deciding whether non-empty outbox entries
|
|
1945
|
+
// predate the current point-of-no-return. A long-sleeping client may have
|
|
1946
|
+
// a cached manifest from before another device compacted. Keep this after
|
|
1947
|
+
// the empty-outbox return so idle flushes and transport teardown do not
|
|
1948
|
+
// perform surprising remote reads or poison adapter switches.
|
|
1949
|
+
await this.doLoadOrCreateManifest('flush');
|
|
1950
|
+
// The manifest GC floor is a point of no return. If this local outbox
|
|
1951
|
+
// contains entries at/before the floor, the device missed the retention
|
|
1952
|
+
// window. Do not publish them; align from the canonical snapshot instead.
|
|
1953
|
+
if (this.hasPreFloorEntries(entries)) {
|
|
1954
|
+
this.clearScheduledFlush();
|
|
1955
|
+
if (this.manifest?.snapshotPath) {
|
|
1956
|
+
await this.rehydrate();
|
|
1957
|
+
this.pendingCount = 0;
|
|
1958
|
+
this.resetCompactWarning();
|
|
1959
|
+
}
|
|
1960
|
+
else {
|
|
1961
|
+
for (const entry of entries)
|
|
1962
|
+
await this.local.pushOutbox(entry);
|
|
1963
|
+
this.pendingCount = entries.length;
|
|
1964
|
+
}
|
|
1965
|
+
throw new Error(`Refusing to flush changes at or before gcFloorHlc ${this.manifest?.gcFloorHlc}; rehydrate required`);
|
|
1966
|
+
}
|
|
1967
|
+
this.log('debug', 'flush() — start', { entryCount: entries.length });
|
|
615
1968
|
this.emit({ type: 'flush:start', entryCount: entries.length });
|
|
616
1969
|
this.pendingCount = 0;
|
|
1970
|
+
this.resetCompactWarning();
|
|
617
1971
|
this.clearScheduledFlush();
|
|
618
1972
|
try {
|
|
619
|
-
await this.
|
|
620
|
-
await flushToAdapter(this.adapter, this.config.remotePath, entries, true, this.codecState, this.deviceId);
|
|
1973
|
+
await flushToAdapter(this.adapter, this.requireRemotePath('flush()'), entries, true, this.codecState, this.deviceId, (e) => this.emit(e));
|
|
621
1974
|
for (const replica of this.config.replicas) {
|
|
622
1975
|
try {
|
|
623
|
-
const replicaRoot = replica.remotePath ?? this.
|
|
1976
|
+
const replicaRoot = replica.remotePath ?? this.requireRemotePath('flush() replica');
|
|
624
1977
|
if (!replica.adapter.isAuthenticated())
|
|
625
1978
|
await replica.adapter.authenticate();
|
|
626
1979
|
await flushToAdapter(replica.adapter, replicaRoot, entries, false, this.codecState, this.deviceId);
|
|
627
1980
|
}
|
|
628
1981
|
catch (err) {
|
|
629
|
-
log('warn', 'flush() — replica write failed', { adapter: replica.adapter.name }, err);
|
|
1982
|
+
this.log('warn', 'flush() — replica write failed', { adapter: replica.adapter.name }, err);
|
|
630
1983
|
this.emit({ type: 'replica:error', adapter: replica.adapter.name, error: err });
|
|
631
1984
|
}
|
|
632
1985
|
}
|
|
633
|
-
|
|
1986
|
+
// Advance the local cursor past our own just-flushed entries.
|
|
1987
|
+
// The cursor is the "we have already merged everything <= X"
|
|
1988
|
+
// marker that pull()'s fast-path uses to short-circuit listing
|
|
1989
|
+
// and reading change files. Without this, a page reload after a
|
|
1990
|
+
// local-only write storm re-lists the changes folder and re-GETs
|
|
1991
|
+
// every file we authored ourselves — re-decoding our own writes
|
|
1992
|
+
// through the CRDT path despite local IDB already being canonical.
|
|
1993
|
+
// Monotonic-forward only: never let cursor go backwards on disk.
|
|
1994
|
+
let highestFlushedHlc = '';
|
|
1995
|
+
for (const entry of entries) {
|
|
1996
|
+
if (!entry.hlc)
|
|
1997
|
+
continue;
|
|
1998
|
+
if (!highestFlushedHlc || hlcCompareStr(entry.hlc, highestFlushedHlc) > 0) {
|
|
1999
|
+
highestFlushedHlc = entry.hlc;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
if (highestFlushedHlc) {
|
|
2003
|
+
const cursorRaw = await this.local.getMeta('cursor');
|
|
2004
|
+
const cursor = typeof cursorRaw === 'string' ? cursorRaw : '';
|
|
2005
|
+
if (!cursor || hlcCompareStr(highestFlushedHlc, cursor) > 0) {
|
|
2006
|
+
await this.local.setMeta('cursor', highestFlushedHlc);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
this.log('debug', 'flush() — complete', { entryCount: entries.length });
|
|
634
2010
|
this.emit({ type: 'flush:complete' });
|
|
2011
|
+
await this.maybeAutoCompact(triggerQueuedChangeCount);
|
|
635
2012
|
}
|
|
636
2013
|
catch (err) {
|
|
637
|
-
log('error', 'flush() — failed, re-queuing entries', err);
|
|
2014
|
+
this.log('error', 'flush() — failed, re-queuing entries', err);
|
|
638
2015
|
for (const entry of entries)
|
|
639
2016
|
await this.local.pushOutbox(entry);
|
|
2017
|
+
this.pendingCount = entries.length;
|
|
2018
|
+
this.maybeEmitCompactWarning();
|
|
640
2019
|
throw err;
|
|
641
2020
|
}
|
|
642
2021
|
}
|
|
643
2022
|
// ── Pull (cloud → local) ──────────────────────────────────────────
|
|
644
2023
|
async pull() {
|
|
2024
|
+
await this.ensureReady();
|
|
645
2025
|
const adapter = this.requireAdapter('pull()');
|
|
646
2026
|
this.hlc = await doPull({
|
|
647
2027
|
adapter,
|
|
648
2028
|
local: this.local,
|
|
649
|
-
remotePath: this.
|
|
2029
|
+
remotePath: this.requireRemotePath('pull()'),
|
|
650
2030
|
codecState: this.codecState,
|
|
651
2031
|
hlc: this.hlc,
|
|
652
2032
|
deviceId: this.deviceId,
|
|
@@ -656,11 +2036,13 @@ export class SyncEngine {
|
|
|
656
2036
|
emit: (e) => this.emit(e),
|
|
657
2037
|
ensureRowsCached: (ops) => this.ensureRowsCached(ops),
|
|
658
2038
|
poisonRemote: (err, path) => this.poisonRemote(err, path),
|
|
659
|
-
loadOrCreateManifest: () => this.doLoadOrCreateManifest(),
|
|
2039
|
+
loadOrCreateManifest: async () => { await this.doLoadOrCreateManifest('pull'); },
|
|
660
2040
|
});
|
|
2041
|
+
await this.acknowledgeManifest();
|
|
661
2042
|
}
|
|
662
2043
|
// ── Rehydrate / Compact ────────────────────────────────────────────
|
|
663
2044
|
async rehydrate() {
|
|
2045
|
+
await this.ensureReady();
|
|
664
2046
|
const adapter = this.requireAdapter('rehydrate()');
|
|
665
2047
|
this.hlc = await doRehydrate({
|
|
666
2048
|
adapter,
|
|
@@ -675,23 +2057,38 @@ export class SyncEngine {
|
|
|
675
2057
|
poisonRemote: (err, path) => this.poisonRemote(err, path),
|
|
676
2058
|
pull: () => this.pull(),
|
|
677
2059
|
});
|
|
2060
|
+
await this.acknowledgeManifest();
|
|
678
2061
|
}
|
|
679
2062
|
async compact() {
|
|
680
|
-
|
|
681
|
-
if (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
adapter
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
2063
|
+
await this.ensureReady();
|
|
2064
|
+
if (this.compactInFlight)
|
|
2065
|
+
return this.compactInFlight;
|
|
2066
|
+
const run = (async () => {
|
|
2067
|
+
const adapter = this.requireAdapter('compact()');
|
|
2068
|
+
if (!this.manifest)
|
|
2069
|
+
throw new Error('Engine is not connected');
|
|
2070
|
+
this.manifest = await doCompact({
|
|
2071
|
+
adapter,
|
|
2072
|
+
local: this.local,
|
|
2073
|
+
remotePath: this.requireRemotePath('compact()'),
|
|
2074
|
+
manifest: this.manifest,
|
|
2075
|
+
codecState: this.codecState,
|
|
2076
|
+
hlc: this.hlc,
|
|
2077
|
+
deviceId: this.deviceId,
|
|
2078
|
+
serverId: this.serverId,
|
|
2079
|
+
emit: (e) => this.emit(e),
|
|
2080
|
+
pull: () => this.pull(),
|
|
2081
|
+
offlineGraceMs: this.config.offlineGraceMs,
|
|
2082
|
+
});
|
|
2083
|
+
await this.local.setMeta('remoteGcFloorHlc', this.manifest.gcFloorHlc ?? '');
|
|
2084
|
+
await this.local.setMeta('remoteGcEpoch', this.manifest.gcEpoch ?? 0);
|
|
2085
|
+
await this.acknowledgeManifest();
|
|
2086
|
+
})().finally(() => {
|
|
2087
|
+
if (this.compactInFlight === run)
|
|
2088
|
+
this.compactInFlight = null;
|
|
694
2089
|
});
|
|
2090
|
+
this.compactInFlight = run;
|
|
2091
|
+
return run;
|
|
695
2092
|
}
|
|
696
2093
|
// ── Mesh management ────────────────────────────────────────────────
|
|
697
2094
|
getManifest() {
|
|
@@ -715,4 +2112,3 @@ export class SyncEngine {
|
|
|
715
2112
|
this.encrypted = true;
|
|
716
2113
|
}
|
|
717
2114
|
}
|
|
718
|
-
//# sourceMappingURL=sync-engine.js.map
|