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