@interocitor/core 0.0.0-beta.3 → 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.
Files changed (98) hide show
  1. package/README.md +445 -91
  2. package/dist/adapters/cloudflare.d.ts +8 -9
  3. package/dist/adapters/cloudflare.d.ts.map +1 -1
  4. package/dist/adapters/cloudflare.js +38 -12
  5. package/dist/adapters/google-drive.d.ts +1 -1
  6. package/dist/adapters/google-drive.js +1 -2
  7. package/dist/adapters/memory.d.ts +4 -1
  8. package/dist/adapters/memory.d.ts.map +1 -1
  9. package/dist/adapters/memory.js +13 -2
  10. package/dist/adapters/webdav.d.ts +5 -0
  11. package/dist/adapters/webdav.d.ts.map +1 -1
  12. package/dist/adapters/webdav.js +18 -1
  13. package/dist/core/codec.d.ts +1 -1
  14. package/dist/core/codec.d.ts.map +1 -1
  15. package/dist/core/codec.js +39 -3
  16. package/dist/core/compaction.d.ts +8 -1
  17. package/dist/core/compaction.d.ts.map +1 -1
  18. package/dist/core/compaction.js +13 -5
  19. package/dist/core/crdt.d.ts +6 -3
  20. package/dist/core/crdt.d.ts.map +1 -1
  21. package/dist/core/crdt.js +38 -60
  22. package/dist/core/errors.d.ts +47 -0
  23. package/dist/core/errors.d.ts.map +1 -0
  24. package/dist/core/errors.js +61 -0
  25. package/dist/core/flush.d.ts +3 -3
  26. package/dist/core/flush.d.ts.map +1 -1
  27. package/dist/core/flush.js +64 -7
  28. package/dist/core/hlc.js +0 -1
  29. package/dist/core/ids.d.ts +49 -0
  30. package/dist/core/ids.d.ts.map +1 -0
  31. package/dist/core/ids.js +132 -0
  32. package/dist/core/internals.d.ts +10 -2
  33. package/dist/core/internals.d.ts.map +1 -1
  34. package/dist/core/internals.js +27 -9
  35. package/dist/core/manifest.d.ts +20 -5
  36. package/dist/core/manifest.d.ts.map +1 -1
  37. package/dist/core/manifest.js +65 -11
  38. package/dist/core/pull.d.ts +1 -1
  39. package/dist/core/pull.d.ts.map +1 -1
  40. package/dist/core/pull.js +21 -6
  41. package/dist/core/row-id.js +0 -1
  42. package/dist/core/schema-types.d.ts +22 -11
  43. package/dist/core/schema-types.d.ts.map +1 -1
  44. package/dist/core/schema-types.js +18 -9
  45. package/dist/core/schema-types.type-test.js +59 -5
  46. package/dist/core/sync-engine.d.ts +163 -12
  47. package/dist/core/sync-engine.d.ts.map +1 -1
  48. package/dist/core/sync-engine.js +1521 -219
  49. package/dist/core/table.d.ts +217 -17
  50. package/dist/core/table.d.ts.map +1 -1
  51. package/dist/core/table.js +376 -24
  52. package/dist/core/types.d.ts +382 -17
  53. package/dist/core/types.d.ts.map +1 -1
  54. package/dist/core/types.js +0 -1
  55. package/dist/crypto/encryption.d.ts.map +1 -1
  56. package/dist/crypto/encryption.js +6 -1
  57. package/dist/crypto/keys.js +0 -1
  58. package/dist/handshake/channel.js +0 -1
  59. package/dist/handshake/index.d.ts +5 -2
  60. package/dist/handshake/index.d.ts.map +1 -1
  61. package/dist/handshake/index.js +19 -2
  62. package/dist/handshake/qr.js +0 -1
  63. package/dist/index.d.ts +9 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +8 -6
  66. package/dist/storage/credential-store.d.ts +25 -2
  67. package/dist/storage/credential-store.d.ts.map +1 -1
  68. package/dist/storage/credential-store.js +55 -8
  69. package/dist/storage/local-store.d.ts +4 -1
  70. package/dist/storage/local-store.d.ts.map +1 -1
  71. package/dist/storage/local-store.js +37 -21
  72. package/package.json +3 -3
  73. package/dist/adapters/cloudflare.js.map +0 -1
  74. package/dist/adapters/google-drive.js.map +0 -1
  75. package/dist/adapters/memory.js.map +0 -1
  76. package/dist/adapters/webdav.js.map +0 -1
  77. package/dist/core/codec.js.map +0 -1
  78. package/dist/core/compaction.js.map +0 -1
  79. package/dist/core/crdt.js.map +0 -1
  80. package/dist/core/flush.js.map +0 -1
  81. package/dist/core/hlc.js.map +0 -1
  82. package/dist/core/internals.js.map +0 -1
  83. package/dist/core/manifest.js.map +0 -1
  84. package/dist/core/pull.js.map +0 -1
  85. package/dist/core/row-id.js.map +0 -1
  86. package/dist/core/schema-types.js.map +0 -1
  87. package/dist/core/schema-types.type-test.js.map +0 -1
  88. package/dist/core/sync-engine.js.map +0 -1
  89. package/dist/core/table.js.map +0 -1
  90. package/dist/core/types.js.map +0 -1
  91. package/dist/crypto/encryption.js.map +0 -1
  92. package/dist/crypto/keys.js.map +0 -1
  93. package/dist/handshake/channel.js.map +0 -1
  94. package/dist/handshake/index.js.map +0 -1
  95. package/dist/handshake/qr.js.map +0 -1
  96. package/dist/index.js.map +0 -1
  97. package/dist/storage/credential-store.js.map +0 -1
  98. package/dist/storage/local-store.js.map +0 -1
@@ -10,36 +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 { applyOp } from "./crdt.js";
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, log, generateId, getDeviceId, ROW_META_KEYS } from "./internals.js";
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
- // ─── Sync Engine ─────────────────────────────────────────────────────
25
- /**
26
- * Typed sync engine. `S` is your database shape — inferred automatically
27
- * from `InferSchemaType<typeof schema>`. No default: either typed or `any`.
28
- *
29
- * @example
30
- * const schema = { version: 1, tables: { tasks: { fields: { title: types.string } } } }
31
- * satisfies DatabaseSchemaDefinition;
32
- *
33
- * type DB = InferSchemaType<typeof schema>;
34
- * const engine = new SyncEngine<DB>(adapter, { schema, remotePath: '/App', appName: 'App' });
35
- *
36
- * // Or declare a typed getter:
37
- * const getDb = async (): Promise<SyncEngine<DB>> => { ... }
38
- *
39
- * engine.table('tasks'); // Table<{ title: string }>
40
- * engine.table('other'); // TS error — 'other' is not keyof DB
41
- */
42
- export class SyncEngine {
25
+ const DEFAULT_COMPACT_WARNING_THRESHOLD = 50;
26
+ const DEFAULT_COMPACT_AUTO_THRESHOLD = 50;
27
+ const DEFAULT_COMPACT_AUTO_SAMPLE_NUMERATOR = 10;
28
+ const DEFAULT_COMPACT_AUTO_DEVICE_COUNT = 1;
29
+ const DEFAULT_FIRST_COMPACT_DELAY_MS = 10 * 60000;
30
+ const DEFAULT_FIRST_COMPACT_DELAY_JITTER_MS = 5 * 60000;
31
+ const DEFAULT_SECOND_COMPACT_DELAY_MS = 15 * 60000;
32
+ const DEFAULT_SECOND_COMPACT_DELAY_JITTER_MS = 5 * 60000;
33
+ const DEFAULT_COMPACT_REMOTE_CHANGE_THRESHOLD = 2;
34
+ const DEFAULT_BATCH_WINDOW_MS = 1000;
35
+ export class Interocitor {
43
36
  constructor(adapterOrConfig, maybeConfig) {
44
37
  this.encryptionKey = null;
45
38
  this.encrypted = false;
@@ -53,13 +46,41 @@ export class SyncEngine {
53
46
  // Flush management
54
47
  this.flushTimer = null;
55
48
  this.pendingCount = 0;
56
- // Poll management
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
57
57
  this.pollTimer = null;
58
+ this.unsubscribeRemoteInvalidations = null;
58
59
  // Lifecycle state
59
60
  this.initialized = false;
60
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;
61
69
  // Event listeners
62
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;
63
84
  const config = (maybeConfig ?? adapterOrConfig);
64
85
  const adapter = (maybeConfig ? adapterOrConfig : null);
65
86
  this.schema = config.schema;
@@ -74,12 +95,27 @@ export class SyncEngine {
74
95
  pollInterval: config.pollInterval ?? 30000,
75
96
  flushDebounce: config.flushDebounce ?? 2000,
76
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,
77
109
  dbName: config.dbName ?? 'interocitor',
78
110
  localStoreFactory: config.localStoreFactory ?? (() => new LocalStore(config.dbName, undefined, config.schema)),
79
111
  schema: config.schema,
80
112
  replicas: config.replicas ?? [],
113
+ onInit: config.onInit,
114
+ resolveInitialState: config.resolveInitialState,
81
115
  };
82
116
  this.serverId = this.config.serverId;
117
+ this.dbName = this.config.dbName;
118
+ this.logLevel = normalizeLogLevel(config.logLevel);
83
119
  this.local = this.config.localStoreFactory();
84
120
  this.deviceId = getDeviceId(config.deviceId);
85
121
  this.hlc = hlcInit(this.deviceId);
@@ -93,8 +129,333 @@ export class SyncEngine {
93
129
  }
94
130
  else {
95
131
  this.encrypted = true;
96
- // Will generate key in init() if no persisted key found
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 };
97
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);
433
+ }
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
+ };
98
459
  }
99
460
  // ── Internal accessors ─────────────────────────────────────────────
100
461
  requireAdapter(operation) {
@@ -105,6 +466,11 @@ export class SyncEngine {
105
466
  }
106
467
  return this.adapter;
107
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
+ }
108
474
  get codecState() {
109
475
  return {
110
476
  encryptionKey: this.encryptionKey,
@@ -115,7 +481,7 @@ export class SyncEngine {
115
481
  get manifestContext() {
116
482
  return {
117
483
  adapter: this.requireAdapter('manifest'),
118
- remotePath: this.config.remotePath,
484
+ remotePath: this.requireRemotePath('manifest'),
119
485
  serverId: this.serverId,
120
486
  serverManaged: this.config.serverManaged,
121
487
  deviceId: this.deviceId,
@@ -131,6 +497,22 @@ export class SyncEngine {
131
497
  this.flushTimer = null;
132
498
  }
133
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
+ }
134
516
  stopPolling() {
135
517
  if (this.pollTimer) {
136
518
  clearInterval(this.pollTimer);
@@ -143,70 +525,77 @@ export class SyncEngine {
143
525
  this.pull().catch(() => { });
144
526
  }, this.config.pollInterval);
145
527
  }
146
- // ── State helpers ──────────────────────────────────────────────────
147
- async resetRemoteSyncState() {
148
- this.manifest = null;
149
- this.remotePoisonError = null;
150
- this.connected = false;
151
- await this.local.setMeta('cursor', '');
152
- await this.local.setMeta('epoch', 0);
153
- await this.local.setMeta('meshId', '');
528
+ stopRemoteInvalidations() {
529
+ if (!this.unsubscribeRemoteInvalidations)
530
+ return;
531
+ try {
532
+ this.unsubscribeRemoteInvalidations();
533
+ }
534
+ catch { }
535
+ this.unsubscribeRemoteInvalidations = null;
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
+ });
154
571
  }
572
+ // ── State helpers ──────────────────────────────────────────────────
155
573
  getRowHlc(row) {
156
- let latest = row._deletedHlc ?? '';
157
- for (const [key, value] of Object.entries(row)) {
158
- if (ROW_META_KEYS.has(key))
159
- continue;
160
- if (!value || typeof value !== 'object' || !('hlc' in value) || !('value' in value))
574
+ let latest = row._meta.deletedHlc ?? '';
575
+ for (const entry of Object.values(row.payload)) {
576
+ if (!entry?.hlc)
161
577
  continue;
162
- const entryHlc = value.hlc;
163
- if (!latest || hlcCompareStr(entryHlc, latest) > 0)
164
- latest = entryHlc;
578
+ if (!latest || hlcCompareStr(entry.hlc, latest) > 0)
579
+ latest = entry.hlc;
165
580
  }
166
581
  return latest;
167
582
  }
168
583
  rowToSyncOp(row) {
169
- if (row._deleted) {
170
- const hlc = row._deletedHlc ?? this.getRowHlc(row);
584
+ if (row._meta.deleted) {
585
+ const hlc = row._meta.deletedHlc ?? this.getRowHlc(row);
171
586
  if (!hlc)
172
587
  return null;
173
- return { type: 'delete', table: row._table, rowId: row._rowId, hlc };
588
+ return { type: 'delete', table: row._meta.table, rowId: row._meta.rowId, hlc };
174
589
  }
175
590
  const columns = {};
176
- for (const [key, value] of Object.entries(row)) {
177
- if (ROW_META_KEYS.has(key))
178
- continue;
179
- 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)
180
593
  continue;
181
- columns[key] = value;
594
+ columns[key] = entry;
182
595
  }
183
596
  if (Object.keys(columns).length === 0)
184
597
  return null;
185
- return { type: 'upsert', table: row._table, rowId: row._rowId, columns };
186
- }
187
- buildChangeEntry(op, hlc) {
188
- return {
189
- id: generateId('chg'),
190
- ts: Date.now(),
191
- device: this.deviceId,
192
- hlc,
193
- ops: [op],
194
- };
195
- }
196
- async rebuildOutboxFromLocalState() {
197
- await this.local.drainOutbox();
198
- const rows = await this.local.getAllRows();
199
- let queued = 0;
200
- for (const row of rows) {
201
- const op = this.rowToSyncOp(row);
202
- const hlc = this.getRowHlc(row);
203
- if (!op || !hlc)
204
- continue;
205
- await this.local.pushOutbox(this.buildChangeEntry(op, hlc));
206
- queued++;
207
- }
208
- this.pendingCount = queued;
209
- return queued;
598
+ return { type: 'upsert', table: row._meta.table, rowId: row._meta.rowId, columns };
210
599
  }
211
600
  async loadLocalState() {
212
601
  this.tables = {};
@@ -239,8 +628,32 @@ export class SyncEngine {
239
628
  this.stopPolling();
240
629
  this.clearScheduledFlush();
241
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
+ });
242
644
  }
243
- this.emit({ type: 'remote:poisoned', error: poisoned, path });
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
+ });
244
657
  return poisoned;
245
658
  }
246
659
  // ── Events ─────────────────────────────────────────────────────────
@@ -249,6 +662,14 @@ export class SyncEngine {
249
662
  return () => this.listeners.delete(listener);
250
663
  }
251
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
+ }
252
673
  for (const listener of this.listeners) {
253
674
  try {
254
675
  listener(event);
@@ -260,7 +681,77 @@ export class SyncEngine {
260
681
  async persistCredentials() {
261
682
  if (!this.credentialStore || !this.passphrase)
262
683
  return;
263
- await this.credentialStore.save({ passphrase: this.passphrase, deviceId: this.deviceId });
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);
264
755
  }
265
756
  async loadPersistedCredentials() {
266
757
  if (!this.credentialStore)
@@ -273,17 +764,38 @@ export class SyncEngine {
273
764
  await this.credentialStore.clear();
274
765
  }
275
766
  async resolveEncryption() {
276
- if (!this.encrypted)
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');
277
776
  return;
777
+ }
278
778
  // 1. Have passphrase (from config, setPassphrase(), or restoreCredentials())
279
779
  if (this.passphrase && !this.encryptionKey) {
280
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 */ }
281
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 });
282
792
  return;
283
793
  }
284
794
  // 2. Already have a key (set via setPassphrase before init)
285
795
  if (this.encryptionKey) {
286
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 });
287
799
  return;
288
800
  }
289
801
  // 3. Generate fresh key (first-time open)
@@ -291,15 +803,54 @@ export class SyncEngine {
291
803
  this.encryptionKey = key;
292
804
  this.passphrase = await keyToPassphrase(key);
293
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
+ });
294
847
  }
295
848
  /**
296
849
  * Set the mesh encryption passphrase.
297
850
  * Call before init() or between disconnect() and init().
298
851
  */
299
852
  setPassphrase(passphrase) {
300
- this.passphrase = passphrase;
301
- this.encrypted = true;
302
- this.encryptionKey = null; // re-derived in init()
853
+ this.configureMesh({ passphrase, encrypted: true });
303
854
  }
304
855
  /**
305
856
  * Get the current mesh passphrase.
@@ -330,15 +881,30 @@ export class SyncEngine {
330
881
  * Returns true if restore succeeded.
331
882
  */
332
883
  async restoreWithBiometrics() {
333
- const restored = await (this.credentialStore?.restoreWithBiometrics?.() ?? Promise.resolve(null));
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
+ }
334
892
  if (!restored)
335
893
  return false;
894
+ const deviceIdChanged = restored.deviceId !== this.deviceId;
336
895
  this.deviceId = restored.deviceId;
337
896
  this.hlc.nodeId = restored.deviceId;
338
897
  this.passphrase = restored.passphrase;
339
898
  if (this.encrypted) {
340
899
  this.encryptionKey = await passphraseToKey(restored.passphrase);
341
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
+ });
342
908
  return true;
343
909
  }
344
910
  /**
@@ -360,140 +926,523 @@ export class SyncEngine {
360
926
  }
361
927
  // ── Lifecycle ──────────────────────────────────────────────────────
362
928
  async init() {
363
- log('debug', 'init() — opening local store', { dbName: this.config.dbName, encrypted: this.encrypted });
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 });
364
933
  try {
365
934
  await this.local.open();
366
- }
367
- catch (err) {
368
- log('error', 'init() — local store open failed', err);
369
- throw err;
370
- }
371
- if (this.schema) {
372
- await this.local.setMeta('schema:version', this.schema.version);
373
- }
374
- // Recover credentials from the silent primary store only.
375
- // No biometric prompt during normal init.
376
- await this.restoreCredentials();
377
- // Resolve encryption: derive key from passphrase, load persisted, or generate.
378
- await this.resolveEncryption();
379
- log('debug', 'init() — loading local state (table names, HLC)');
380
- try {
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)');
381
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
+ }
382
952
  }
383
953
  catch (err) {
384
- log('error', 'init() — loadLocalState failed', err);
954
+ this.log('error', 'init() — failed', err);
955
+ this.initPromise = null;
385
956
  throw err;
386
957
  }
387
- log('debug', 'init() — complete', { knownTables: Array.from(this.knownTables) });
388
- this.initialized = true;
389
958
  }
390
959
  /**
391
960
  * Silent credential restore from the primary store only.
392
961
  * Used during normal init(). No biometric prompt.
393
962
  */
394
963
  async restoreCredentials() {
395
- const stored = await this.loadPersistedCredentials();
396
- if (!stored)
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 });
397
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;
398
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
+ });
399
1040
  this.deviceId = stored.deviceId;
400
1041
  this.hlc.nodeId = stored.deviceId;
401
1042
  try {
402
- localStorage.setItem('interocitor-device-id', stored.deviceId);
1043
+ if (typeof localStorage !== 'undefined')
1044
+ localStorage.setItem('interocitor-device-id', stored.deviceId);
403
1045
  }
404
1046
  catch { /* ok */ }
1047
+ deviceIdChanged = true;
405
1048
  }
406
- if (this.encrypted && !this.passphrase && stored.passphrase) {
407
- this.passphrase = stored.passphrase;
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
+ }
408
1085
  }
1086
+ this.emit({
1087
+ type: 'credentials:restored',
1088
+ source: 'silent-store',
1089
+ deviceIdChanged,
1090
+ hadPassphrase,
1091
+ });
409
1092
  }
410
1093
  async connect() {
411
- if (!this.initialized) {
412
- throw new Error('Engine must be initialized via init() before connect()');
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
+ }
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.
413
1200
  }
1201
+ return false;
1202
+ }
1203
+ async doConnect() {
414
1204
  const adapter = this.requireAdapter('connect()');
415
- log('debug', 'connect() — authenticating with adapter', { adapter: adapter.name });
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 });
416
1229
  if (!adapter.isAuthenticated()) {
417
1230
  this.emit({ type: 'auth:required' });
418
1231
  try {
419
1232
  await adapter.authenticate();
420
1233
  }
421
1234
  catch (err) {
422
- log('error', 'connect() — authentication failed', err);
423
- throw err;
1235
+ this.log('error', 'connect() — authentication failed', err);
1236
+ throw stage('authenticate', err);
424
1237
  }
425
1238
  this.emit({ type: 'auth:complete' });
426
1239
  }
427
- const p = paths(this.config.remotePath);
428
- log('debug', 'connect() ensuring remote folders', { remotePath: this.config.remotePath, deviceId: this.deviceId });
429
- for (const folder of [this.config.remotePath, p.devicesFolder, p.mainlineFolder, p.changesFolder]) {
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]) {
430
1252
  try {
431
1253
  await adapter.ensureFolder(folder);
432
- log('debug', 'connect() — ensureFolder ok', folder);
1254
+ this.log('debug', 'connect() — ensureFolder ok', folder);
433
1255
  }
434
1256
  catch (err) {
435
- log('error', 'connect() — ensureFolder failed', folder, err);
436
- throw err;
1257
+ this.log('error', 'connect() — ensureFolder failed', folder, err);
1258
+ throw stage('ensureFolder', err);
437
1259
  }
438
1260
  }
439
- log('debug', 'connect() — loading/creating manifest');
1261
+ this.log('debug', 'connect() — loading/creating manifest');
1262
+ let bootstrapped = false;
440
1263
  try {
441
- await this.doLoadOrCreateManifest();
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 });
442
1268
  }
443
1269
  catch (err) {
444
- log('error', 'connect() — loadOrCreateManifest failed', err);
445
- 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);
446
1291
  }
447
- await upsertDeviceMetadata(adapter, this.config.remotePath, this.deviceId);
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
+ });
448
1305
  const localEpochRaw = await this.local.getMeta('epoch');
449
1306
  const localEpoch = typeof localEpochRaw === 'number' ? localEpochRaw : 0;
450
1307
  const remoteEpoch = this.manifest?.epoch ?? 0;
451
- 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
+ });
452
1319
  if (localEpoch < remoteEpoch) {
453
- log('debug', 'connect() — epoch advanced, rehydrating from snapshot');
1320
+ this.log('debug', 'connect() — epoch advanced, rehydrating from snapshot');
454
1321
  await this.rehydrate();
455
1322
  }
456
1323
  else {
457
- log('debug', 'connect() — running initial pull');
1324
+ this.log('debug', 'connect() — running initial pull');
458
1325
  await this.pull();
459
1326
  }
460
- await this.flush();
1327
+ await this.doFlush();
461
1328
  this.startPolling();
1329
+ this.startRemoteInvalidations(adapter);
462
1330
  this.connected = true;
463
- 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 });
464
1332
  if (typeof window !== 'undefined') {
465
1333
  window.addEventListener('visibilitychange', () => {
466
1334
  if (document.visibilityState === 'hidden') {
467
- this.flush().catch(() => { });
1335
+ this.doFlush().catch(() => { });
468
1336
  }
469
1337
  });
470
1338
  }
471
1339
  }
472
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.
473
1344
  this.stopPolling();
1345
+ this.stopRemoteInvalidations();
474
1346
  this.clearScheduledFlush();
475
- if (!this.remotePoisonError)
476
- await this.flush();
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
+ });
477
1365
  this.local.close();
478
1366
  this.connected = false;
479
1367
  this.initialized = false;
1368
+ this.initPromise = null;
1369
+ this.connectPromise = null;
480
1370
  this.remotePoisonError = null;
481
1371
  }
482
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 });
483
1385
  const wasConnected = this.connected;
484
1386
  const hadAdapter = this.adapter !== null;
485
- if (wasConnected && hadAdapter && !this.remotePoisonError)
486
- await this.pull();
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.
487
1411
  this.stopPolling();
1412
+ this.stopRemoteInvalidations();
488
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
+ }
489
1429
  if (this.initialized) {
490
- await this.resetRemoteSyncState();
491
- if (adapter)
492
- await this.rebuildOutboxFromLocalState();
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();
493
1443
  }
494
1444
  else {
495
1445
  this.manifest = null;
496
- this.connected = false;
497
1446
  }
498
1447
  this.adapter = adapter;
499
1448
  this.remotePoisonError = null;
@@ -501,9 +1450,10 @@ export class SyncEngine {
501
1450
  await this.connect();
502
1451
  }
503
1452
  async setLocalStorage(local) {
1453
+ await this.ensureReady();
504
1454
  const wasConnected = this.connected;
505
1455
  if (wasConnected)
506
- await this.flush();
1456
+ await this.doFlush();
507
1457
  this.clearScheduledFlush();
508
1458
  this.pendingCount = 0;
509
1459
  this.local.close();
@@ -514,139 +1464,481 @@ export class SyncEngine {
514
1464
  if (!wasConnected)
515
1465
  return;
516
1466
  await this.pull();
517
- await this.flush();
1467
+ await this.doFlush();
518
1468
  }
519
1469
  // ── Manifest (delegated) ───────────────────────────────────────────
520
- async doLoadOrCreateManifest() {
521
- const manifest = await loadOrCreateManifest(this.manifestContext, this.codecState, this.local, (err, path) => this.poisonRemote(err, path));
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);
522
1499
  this.manifest = manifest;
523
1500
  this.encrypted = manifest.encrypted || this.encrypted;
1501
+ return { bootstrapped };
524
1502
  }
525
1503
  // ── Local writes ───────────────────────────────────────────────────
526
1504
  async put(table, rowId, columns, userId) {
527
- this.hlc = hlcNow(this.hlc);
528
- const hlcStr = hlcSerialize(this.hlc);
529
- const columnEntries = {};
530
- for (const [key, value] of Object.entries(columns)) {
531
- columnEntries[key] = { value: value, hlc: hlcStr };
532
- }
533
- const op = { type: 'upsert', table, rowId, columns: columnEntries };
534
- await this.ensureRowsCached([op]);
535
- const row = applyOp(this.tables, op, this.manifest?.schema ?? 1, this.schema);
536
- this.knownTables.add(table);
537
- await this.local.putRow(row);
538
- await this.local.setMeta('hlc', hlcSerialize(this.hlc));
539
- const entry = {
540
- id: generateId('chg'),
541
- ts: Date.now(),
542
- device: this.deviceId,
543
- user: userId,
544
- hlc: hlcStr,
545
- ops: [op],
546
- };
547
- await this.local.pushOutbox(entry);
548
- this.emit({ type: 'change', table, rowId, row });
549
- this.scheduleFlush();
550
- return row;
1505
+ await this.ensureReady();
1506
+ return this.putNow(table, rowId, columns, userId);
551
1507
  }
552
1508
  async delete(table, rowId, userId) {
553
- this.hlc = hlcNow(this.hlc);
554
- const hlcStr = hlcSerialize(this.hlc);
555
- const op = { type: 'delete', table, rowId, hlc: hlcStr };
556
- await this.ensureRowsCached([op]);
557
- applyOp(this.tables, op, this.manifest?.schema ?? 1, this.schema);
558
- const row = this.tables[table]?.[rowId];
559
- if (row)
560
- await this.local.putRow(row);
561
- await this.local.setMeta('hlc', hlcSerialize(this.hlc));
562
- const entry = {
563
- id: generateId('chg'),
564
- ts: Date.now(),
565
- device: this.deviceId,
566
- user: userId,
567
- hlc: hlcStr,
568
- ops: [op],
569
- };
570
- await this.local.pushOutbox(entry);
571
- this.emit({ type: 'delete', table, rowId });
572
- this.scheduleFlush();
573
- }
574
- // ── Read ───────────────────────────────────────────────────────────
575
- async get(table, rowId) {
576
- const row = await this.local.getRow(table, rowId);
577
- if (!row || row._deleted)
578
- return undefined;
579
- return row;
1509
+ await this.ensureReady();
1510
+ return this.deleteNow(table, rowId, userId);
580
1511
  }
581
1512
  async query(table) {
582
- return this.local.getTable(table);
1513
+ await this.ensureReady();
1514
+ return this.queryNow(table);
583
1515
  }
584
1516
  async queryWhere(table, clause) {
585
- return this.local.queryWhere(table, clause);
1517
+ await this.ensureReady();
1518
+ return this.queryWhereNow(table, clause);
586
1519
  }
587
1520
  async tableNames() {
1521
+ await this.ensureReady();
588
1522
  return Array.from(this.knownTables);
589
1523
  }
590
1524
  table(name) {
591
1525
  return new Table(this, name);
592
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
+ }
593
1592
  // ── Flush (local → cloud) ──────────────────────────────────────────
594
1593
  scheduleFlush() {
595
1594
  this.pendingCount++;
1595
+ this.maybeEmitCompactWarning();
1596
+ this.armDelayedCompactAfterChange();
596
1597
  if (this.pendingCount >= this.config.flushThreshold) {
597
- this.flush().catch(err => this.emit({ type: 'flush:error', error: err }));
1598
+ this.doFlush().catch(err => this.emit({ type: 'flush:error', error: err }));
598
1599
  return;
599
1600
  }
600
1601
  if (this.flushTimer)
601
1602
  clearTimeout(this.flushTimer);
602
1603
  this.flushTimer = setTimeout(() => {
603
- this.flush().catch(err => this.emit({ type: 'flush:error', error: err }));
1604
+ this.doFlush().catch(err => this.emit({ type: 'flush:error', error: err }));
604
1605
  }, this.config.flushDebounce);
605
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. */
606
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() {
607
1864
  if (!this.adapter) {
608
1865
  this.clearScheduledFlush();
609
1866
  return;
610
1867
  }
1868
+ const triggerQueuedChangeCount = this.pendingCount;
611
1869
  const entries = await this.local.drainOutbox();
612
- if (entries.length === 0)
1870
+ if (entries.length === 0) {
1871
+ this.pendingCount = 0;
1872
+ this.resetCompactWarning();
613
1873
  return;
614
- log('debug', 'flush() — start', { entryCount: entries.length });
1874
+ }
1875
+ this.log('debug', 'flush() — start', { entryCount: entries.length });
615
1876
  this.emit({ type: 'flush:start', entryCount: entries.length });
616
1877
  this.pendingCount = 0;
1878
+ this.resetCompactWarning();
617
1879
  this.clearScheduledFlush();
618
1880
  try {
619
- await this.doLoadOrCreateManifest();
620
- await flushToAdapter(this.adapter, this.config.remotePath, entries, true, this.codecState, this.deviceId);
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));
621
1886
  for (const replica of this.config.replicas) {
622
1887
  try {
623
- const replicaRoot = replica.remotePath ?? this.config.remotePath;
1888
+ const replicaRoot = replica.remotePath ?? this.requireRemotePath('flush() replica');
624
1889
  if (!replica.adapter.isAuthenticated())
625
1890
  await replica.adapter.authenticate();
626
1891
  await flushToAdapter(replica.adapter, replicaRoot, entries, false, this.codecState, this.deviceId);
627
1892
  }
628
1893
  catch (err) {
629
- log('warn', 'flush() — replica write failed', { adapter: replica.adapter.name }, err);
1894
+ this.log('warn', 'flush() — replica write failed', { adapter: replica.adapter.name }, err);
630
1895
  this.emit({ type: 'replica:error', adapter: replica.adapter.name, error: err });
631
1896
  }
632
1897
  }
633
- log('debug', 'flush() complete', { entryCount: entries.length });
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 });
634
1922
  this.emit({ type: 'flush:complete' });
1923
+ await this.maybeAutoCompact(triggerQueuedChangeCount);
635
1924
  }
636
1925
  catch (err) {
637
- log('error', 'flush() — failed, re-queuing entries', err);
1926
+ this.log('error', 'flush() — failed, re-queuing entries', err);
638
1927
  for (const entry of entries)
639
1928
  await this.local.pushOutbox(entry);
1929
+ this.pendingCount = entries.length;
1930
+ this.maybeEmitCompactWarning();
640
1931
  throw err;
641
1932
  }
642
1933
  }
643
1934
  // ── Pull (cloud → local) ──────────────────────────────────────────
644
1935
  async pull() {
1936
+ await this.ensureReady();
645
1937
  const adapter = this.requireAdapter('pull()');
646
1938
  this.hlc = await doPull({
647
1939
  adapter,
648
1940
  local: this.local,
649
- remotePath: this.config.remotePath,
1941
+ remotePath: this.requireRemotePath('pull()'),
650
1942
  codecState: this.codecState,
651
1943
  hlc: this.hlc,
652
1944
  deviceId: this.deviceId,
@@ -656,11 +1948,12 @@ export class SyncEngine {
656
1948
  emit: (e) => this.emit(e),
657
1949
  ensureRowsCached: (ops) => this.ensureRowsCached(ops),
658
1950
  poisonRemote: (err, path) => this.poisonRemote(err, path),
659
- loadOrCreateManifest: () => this.doLoadOrCreateManifest(),
1951
+ loadOrCreateManifest: async () => { await this.doLoadOrCreateManifest('pull'); },
660
1952
  });
661
1953
  }
662
1954
  // ── Rehydrate / Compact ────────────────────────────────────────────
663
1955
  async rehydrate() {
1956
+ await this.ensureReady();
664
1957
  const adapter = this.requireAdapter('rehydrate()');
665
1958
  this.hlc = await doRehydrate({
666
1959
  adapter,
@@ -677,21 +1970,31 @@ export class SyncEngine {
677
1970
  });
678
1971
  }
679
1972
  async compact() {
680
- const adapter = this.requireAdapter('compact()');
681
- if (!this.manifest)
682
- throw new Error('Engine is not connected');
683
- this.manifest = await doCompact({
684
- adapter,
685
- local: this.local,
686
- remotePath: this.config.remotePath,
687
- manifest: this.manifest,
688
- codecState: this.codecState,
689
- hlc: this.hlc,
690
- deviceId: this.deviceId,
691
- serverId: this.serverId,
692
- emit: (e) => this.emit(e),
693
- pull: () => this.pull(),
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;
694
1995
  });
1996
+ this.compactInFlight = run;
1997
+ return run;
695
1998
  }
696
1999
  // ── Mesh management ────────────────────────────────────────────────
697
2000
  getManifest() {
@@ -715,4 +2018,3 @@ export class SyncEngine {
715
2018
  this.encrypted = true;
716
2019
  }
717
2020
  }
718
- //# sourceMappingURL=sync-engine.js.map