@interocitor/core 0.0.0-beta.2

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/dist/adapters/cloudflare.d.ts +72 -0
  4. package/dist/adapters/cloudflare.d.ts.map +1 -0
  5. package/dist/adapters/cloudflare.js +227 -0
  6. package/dist/adapters/cloudflare.js.map +1 -0
  7. package/dist/adapters/google-drive.d.ts +64 -0
  8. package/dist/adapters/google-drive.d.ts.map +1 -0
  9. package/dist/adapters/google-drive.js +340 -0
  10. package/dist/adapters/google-drive.js.map +1 -0
  11. package/dist/adapters/memory.d.ts +45 -0
  12. package/dist/adapters/memory.d.ts.map +1 -0
  13. package/dist/adapters/memory.js +129 -0
  14. package/dist/adapters/memory.js.map +1 -0
  15. package/dist/adapters/webdav.d.ts +59 -0
  16. package/dist/adapters/webdav.d.ts.map +1 -0
  17. package/dist/adapters/webdav.js +247 -0
  18. package/dist/adapters/webdav.js.map +1 -0
  19. package/dist/core/codec.d.ts +20 -0
  20. package/dist/core/codec.d.ts.map +1 -0
  21. package/dist/core/codec.js +66 -0
  22. package/dist/core/codec.js.map +1 -0
  23. package/dist/core/compaction.d.ts +37 -0
  24. package/dist/core/compaction.d.ts.map +1 -0
  25. package/dist/core/compaction.js +134 -0
  26. package/dist/core/compaction.js.map +1 -0
  27. package/dist/core/crdt.d.ts +33 -0
  28. package/dist/core/crdt.d.ts.map +1 -0
  29. package/dist/core/crdt.js +188 -0
  30. package/dist/core/crdt.js.map +1 -0
  31. package/dist/core/flush.d.ts +9 -0
  32. package/dist/core/flush.d.ts.map +1 -0
  33. package/dist/core/flush.js +41 -0
  34. package/dist/core/flush.js.map +1 -0
  35. package/dist/core/hlc.d.ts +25 -0
  36. package/dist/core/hlc.d.ts.map +1 -0
  37. package/dist/core/hlc.js +76 -0
  38. package/dist/core/hlc.js.map +1 -0
  39. package/dist/core/internals.d.ts +25 -0
  40. package/dist/core/internals.d.ts.map +1 -0
  41. package/dist/core/internals.js +54 -0
  42. package/dist/core/internals.js.map +1 -0
  43. package/dist/core/manifest.d.ts +31 -0
  44. package/dist/core/manifest.d.ts.map +1 -0
  45. package/dist/core/manifest.js +111 -0
  46. package/dist/core/manifest.js.map +1 -0
  47. package/dist/core/pull.d.ts +26 -0
  48. package/dist/core/pull.d.ts.map +1 -0
  49. package/dist/core/pull.js +98 -0
  50. package/dist/core/pull.js.map +1 -0
  51. package/dist/core/row-id.d.ts +12 -0
  52. package/dist/core/row-id.d.ts.map +1 -0
  53. package/dist/core/row-id.js +12 -0
  54. package/dist/core/row-id.js.map +1 -0
  55. package/dist/core/schema-types.d.ts +13 -0
  56. package/dist/core/schema-types.d.ts.map +1 -0
  57. package/dist/core/schema-types.js +18 -0
  58. package/dist/core/schema-types.js.map +1 -0
  59. package/dist/core/schema-types.type-test.d.ts +2 -0
  60. package/dist/core/schema-types.type-test.d.ts.map +1 -0
  61. package/dist/core/schema-types.type-test.js +149 -0
  62. package/dist/core/schema-types.type-test.js.map +1 -0
  63. package/dist/core/sync-engine.d.ts +158 -0
  64. package/dist/core/sync-engine.d.ts.map +1 -0
  65. package/dist/core/sync-engine.js +714 -0
  66. package/dist/core/sync-engine.js.map +1 -0
  67. package/dist/core/table.d.ts +60 -0
  68. package/dist/core/table.d.ts.map +1 -0
  69. package/dist/core/table.js +106 -0
  70. package/dist/core/table.js.map +1 -0
  71. package/dist/core/types.d.ts +478 -0
  72. package/dist/core/types.d.ts.map +1 -0
  73. package/dist/core/types.js +7 -0
  74. package/dist/core/types.js.map +1 -0
  75. package/dist/crypto/encryption.d.ts +57 -0
  76. package/dist/crypto/encryption.d.ts.map +1 -0
  77. package/dist/crypto/encryption.js +195 -0
  78. package/dist/crypto/encryption.js.map +1 -0
  79. package/dist/crypto/keys.d.ts +48 -0
  80. package/dist/crypto/keys.d.ts.map +1 -0
  81. package/dist/crypto/keys.js +55 -0
  82. package/dist/crypto/keys.js.map +1 -0
  83. package/dist/handshake/channel.d.ts +117 -0
  84. package/dist/handshake/channel.d.ts.map +1 -0
  85. package/dist/handshake/channel.js +246 -0
  86. package/dist/handshake/channel.js.map +1 -0
  87. package/dist/handshake/index.d.ts +213 -0
  88. package/dist/handshake/index.d.ts.map +1 -0
  89. package/dist/handshake/index.js +182 -0
  90. package/dist/handshake/index.js.map +1 -0
  91. package/dist/handshake/qr.d.ts +100 -0
  92. package/dist/handshake/qr.d.ts.map +1 -0
  93. package/dist/handshake/qr.js +103 -0
  94. package/dist/handshake/qr.js.map +1 -0
  95. package/dist/index.d.ts +46 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +46 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/storage/credential-store.d.ts +99 -0
  100. package/dist/storage/credential-store.d.ts.map +1 -0
  101. package/dist/storage/credential-store.js +309 -0
  102. package/dist/storage/credential-store.js.map +1 -0
  103. package/dist/storage/local-store.d.ts +56 -0
  104. package/dist/storage/local-store.d.ts.map +1 -0
  105. package/dist/storage/local-store.js +411 -0
  106. package/dist/storage/local-store.js.map +1 -0
  107. package/package.json +68 -0
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Sync Engine
3
+ *
4
+ * Orchestrates:
5
+ * - Local writes → outbox → flush to cloud (primary + replicas)
6
+ * - Cloud poll → download → decrypt → CRDT merge → local DB
7
+ * - Rehydration from manifest-authoritative snapshot
8
+ *
9
+ * Network is never required for reads or local writes.
10
+ * All data operations hit IndexedDB first; cloud sync is async.
11
+ */
12
+ import { hlcInit, hlcNow, hlcSerialize, hlcParse, hlcCompareStr } from "./hlc.js";
13
+ import { applyOp } from "./crdt.js";
14
+ import { LocalStore } from "../storage/local-store.js";
15
+ import { Table } from "./table.js";
16
+ // Extracted modules
17
+ import { paths, log, generateId, getDeviceId, ROW_META_KEYS } from "./internals.js";
18
+ import { loadOrCreateManifest, upsertDeviceMetadata } from "./manifest.js";
19
+ import { generateKey, keyToPassphrase, passphraseToKey } from "../crypto/encryption.js";
20
+ import { createCredentialStore } from "../storage/credential-store.js";
21
+ import { flushToAdapter } from "./flush.js";
22
+ import { pull as doPull } from "./pull.js";
23
+ import { compact as doCompact, rehydrate as doRehydrate } from "./compaction.js";
24
+ // ─── Sync Engine ─────────────────────────────────────────────────────
25
+ /**
26
+ * Type parameter S maps table names to their plain record types.
27
+ *
28
+ * @example
29
+ * interface AppSchema {
30
+ * tasks: { title: string; status: 'open' | 'done' };
31
+ * notes: { content: string };
32
+ * }
33
+ * const engine = new SyncEngine<AppSchema>(adapter, { remotePath: '/App' });
34
+ * const tasks = engine.table('tasks'); // Table<{ title: string; status: 'open' | 'done' }>
35
+ *
36
+ * Schema is optional — omit it for untyped usage.
37
+ */
38
+ export class SyncEngine {
39
+ constructor(adapterOrConfig, maybeConfig) {
40
+ this.encryptionKey = null;
41
+ this.encrypted = false;
42
+ this.passphrase = null;
43
+ // In-memory CRDT merge cache — lazily populated on writes and pulls.
44
+ this.tables = {};
45
+ this.manifest = null;
46
+ this.remotePoisonError = null;
47
+ // Known table names (populated from IDB index on init, updated on writes)
48
+ this.knownTables = new Set();
49
+ // Flush management
50
+ this.flushTimer = null;
51
+ this.pendingCount = 0;
52
+ // Poll management
53
+ this.pollTimer = null;
54
+ // Lifecycle state
55
+ this.initialized = false;
56
+ this.connected = false;
57
+ // Event listeners
58
+ this.listeners = new Set();
59
+ const config = (maybeConfig ?? adapterOrConfig);
60
+ const adapter = (maybeConfig ? adapterOrConfig : null);
61
+ this.schema = config.schema;
62
+ this.credentialStore = config.credentialStore === null
63
+ ? null
64
+ : config.credentialStore ?? createCredentialStore(config.dbName ?? 'interocitor', config.appName);
65
+ this.adapter = adapter;
66
+ this.config = {
67
+ remotePath: config.remotePath,
68
+ serverManaged: config.serverManaged ?? false,
69
+ serverId: config.serverId ?? 'server_relay_1',
70
+ pollInterval: config.pollInterval ?? 30000,
71
+ flushDebounce: config.flushDebounce ?? 2000,
72
+ flushThreshold: config.flushThreshold ?? 50,
73
+ dbName: config.dbName ?? 'interocitor',
74
+ localStoreFactory: config.localStoreFactory ?? (() => new LocalStore(config.dbName, undefined, config.schema)),
75
+ schema: config.schema,
76
+ replicas: config.replicas ?? [],
77
+ };
78
+ this.serverId = this.config.serverId;
79
+ this.local = this.config.localStoreFactory();
80
+ this.deviceId = getDeviceId(config.deviceId);
81
+ this.hlc = hlcInit(this.deviceId);
82
+ // Encryption on by default. Opt out with encrypted: false.
83
+ if (config.encrypted === false) {
84
+ this.encrypted = false;
85
+ }
86
+ else if (config.passphrase) {
87
+ this.passphrase = config.passphrase;
88
+ this.encrypted = true;
89
+ }
90
+ else {
91
+ this.encrypted = true;
92
+ // Will generate key in init() if no persisted key found
93
+ }
94
+ }
95
+ // ── Internal accessors ─────────────────────────────────────────────
96
+ requireAdapter(operation) {
97
+ if (this.remotePoisonError)
98
+ throw this.remotePoisonError;
99
+ if (!this.adapter) {
100
+ throw new Error(`No remote storage adapter configured. Call setRemoteStorage() before ${operation}.`);
101
+ }
102
+ return this.adapter;
103
+ }
104
+ get codecState() {
105
+ return {
106
+ encryptionKey: this.encryptionKey,
107
+ encrypted: this.encrypted,
108
+ manifest: this.manifest,
109
+ };
110
+ }
111
+ get manifestContext() {
112
+ return {
113
+ adapter: this.requireAdapter('manifest'),
114
+ remotePath: this.config.remotePath,
115
+ serverId: this.serverId,
116
+ serverManaged: this.config.serverManaged,
117
+ deviceId: this.deviceId,
118
+ encrypted: this.encrypted,
119
+ schema: this.schema,
120
+ emit: (e) => this.emit(e),
121
+ };
122
+ }
123
+ // ── Flush / poll timers ────────────────────────────────────────────
124
+ clearScheduledFlush() {
125
+ if (this.flushTimer) {
126
+ clearTimeout(this.flushTimer);
127
+ this.flushTimer = null;
128
+ }
129
+ }
130
+ stopPolling() {
131
+ if (this.pollTimer) {
132
+ clearInterval(this.pollTimer);
133
+ this.pollTimer = null;
134
+ }
135
+ }
136
+ startPolling() {
137
+ this.stopPolling();
138
+ this.pollTimer = setInterval(() => {
139
+ this.pull().catch(() => { });
140
+ }, this.config.pollInterval);
141
+ }
142
+ // ── State helpers ──────────────────────────────────────────────────
143
+ async resetRemoteSyncState() {
144
+ this.manifest = null;
145
+ this.remotePoisonError = null;
146
+ this.connected = false;
147
+ await this.local.setMeta('cursor', '');
148
+ await this.local.setMeta('epoch', 0);
149
+ await this.local.setMeta('meshId', '');
150
+ }
151
+ getRowHlc(row) {
152
+ let latest = row._deletedHlc ?? '';
153
+ for (const [key, value] of Object.entries(row)) {
154
+ if (ROW_META_KEYS.has(key))
155
+ continue;
156
+ if (!value || typeof value !== 'object' || !('hlc' in value) || !('value' in value))
157
+ continue;
158
+ const entryHlc = value.hlc;
159
+ if (!latest || hlcCompareStr(entryHlc, latest) > 0)
160
+ latest = entryHlc;
161
+ }
162
+ return latest;
163
+ }
164
+ rowToSyncOp(row) {
165
+ if (row._deleted) {
166
+ const hlc = row._deletedHlc ?? this.getRowHlc(row);
167
+ if (!hlc)
168
+ return null;
169
+ return { type: 'delete', table: row._table, rowId: row._rowId, hlc };
170
+ }
171
+ const columns = {};
172
+ for (const [key, value] of Object.entries(row)) {
173
+ if (ROW_META_KEYS.has(key))
174
+ continue;
175
+ if (!value || typeof value !== 'object' || !('hlc' in value) || !('value' in value))
176
+ continue;
177
+ columns[key] = value;
178
+ }
179
+ if (Object.keys(columns).length === 0)
180
+ return null;
181
+ return { type: 'upsert', table: row._table, rowId: row._rowId, columns };
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;
206
+ }
207
+ async loadLocalState() {
208
+ this.tables = {};
209
+ this.knownTables = new Set();
210
+ const savedHlc = await this.local.getMeta('hlc');
211
+ if (savedHlc) {
212
+ this.hlc = hlcParse(savedHlc);
213
+ this.hlc.nodeId = this.deviceId;
214
+ }
215
+ for (const name of await this.local.getTableNames()) {
216
+ this.knownTables.add(name);
217
+ }
218
+ }
219
+ async ensureRowsCached(ops) {
220
+ for (const op of ops) {
221
+ if (this.tables[op.table]?.[op.rowId] !== undefined)
222
+ continue;
223
+ const existing = await this.local.getRow(op.table, op.rowId);
224
+ if (existing) {
225
+ if (!this.tables[op.table])
226
+ this.tables[op.table] = {};
227
+ this.tables[op.table][op.rowId] = existing;
228
+ }
229
+ }
230
+ }
231
+ async poisonRemote(error, path) {
232
+ const poisoned = error instanceof Error ? error : new Error(String(error));
233
+ if (!this.remotePoisonError) {
234
+ this.remotePoisonError = poisoned;
235
+ this.stopPolling();
236
+ this.clearScheduledFlush();
237
+ this.connected = false;
238
+ }
239
+ this.emit({ type: 'remote:poisoned', error: poisoned, path });
240
+ return poisoned;
241
+ }
242
+ // ── Events ─────────────────────────────────────────────────────────
243
+ on(listener) {
244
+ this.listeners.add(listener);
245
+ return () => this.listeners.delete(listener);
246
+ }
247
+ emit(event) {
248
+ for (const listener of this.listeners) {
249
+ try {
250
+ listener(event);
251
+ }
252
+ catch { /* don't let listener errors break sync */ }
253
+ }
254
+ }
255
+ // ── Encryption ─────────────────────────────────────────────────────
256
+ async persistCredentials() {
257
+ if (!this.credentialStore || !this.passphrase)
258
+ return;
259
+ await this.credentialStore.save({ passphrase: this.passphrase, deviceId: this.deviceId });
260
+ }
261
+ async loadPersistedCredentials() {
262
+ if (!this.credentialStore)
263
+ return null;
264
+ return this.credentialStore.load();
265
+ }
266
+ async clearPersistedCredentials() {
267
+ if (!this.credentialStore)
268
+ return;
269
+ await this.credentialStore.clear();
270
+ }
271
+ async resolveEncryption() {
272
+ if (!this.encrypted)
273
+ return;
274
+ // 1. Have passphrase (from config, setPassphrase(), or restoreCredentials())
275
+ if (this.passphrase && !this.encryptionKey) {
276
+ this.encryptionKey = await passphraseToKey(this.passphrase);
277
+ await this.persistCredentials();
278
+ return;
279
+ }
280
+ // 2. Already have a key (set via setPassphrase before init)
281
+ if (this.encryptionKey) {
282
+ await this.persistCredentials();
283
+ return;
284
+ }
285
+ // 3. Generate fresh key (first-time open)
286
+ const key = await generateKey();
287
+ this.encryptionKey = key;
288
+ this.passphrase = await keyToPassphrase(key);
289
+ await this.persistCredentials();
290
+ }
291
+ /**
292
+ * Set the mesh encryption passphrase.
293
+ * Call before init() or between disconnect() and init().
294
+ */
295
+ setPassphrase(passphrase) {
296
+ this.passphrase = passphrase;
297
+ this.encrypted = true;
298
+ this.encryptionKey = null; // re-derived in init()
299
+ }
300
+ /**
301
+ * Get the current mesh passphrase.
302
+ * Returns null if the mesh is unencrypted.
303
+ * Call after init() to ensure key derivation is complete.
304
+ */
305
+ getPassphrase() {
306
+ return this.passphrase;
307
+ }
308
+ /**
309
+ * Persist credentials to the OS keychain via biometrics.
310
+ * Call after pairing, or from a "Secure my keys" UI action.
311
+ * Returns true if saved, false if unavailable or user cancelled.
312
+ */
313
+ async secureWithBiometrics() {
314
+ return this.credentialStore?.secureWithBiometrics?.() ?? false;
315
+ }
316
+ /**
317
+ * Explicit restore path for wiped / returning users.
318
+ * Call only from intentional app UI: "try restore purchases",
319
+ * "restore access", etc.
320
+ *
321
+ * On success:
322
+ * - restores passphrase + device ID from OS keychain
323
+ * - re-populates silent local storage
324
+ * - derives encryption key
325
+ *
326
+ * Returns true if restore succeeded.
327
+ */
328
+ async restoreWithBiometrics() {
329
+ const restored = await (this.credentialStore?.restoreWithBiometrics?.() ?? Promise.resolve(null));
330
+ if (!restored)
331
+ return false;
332
+ this.deviceId = restored.deviceId;
333
+ this.hlc.nodeId = restored.deviceId;
334
+ this.passphrase = restored.passphrase;
335
+ if (this.encrypted) {
336
+ this.encryptionKey = await passphraseToKey(restored.passphrase);
337
+ }
338
+ return true;
339
+ }
340
+ /**
341
+ * Clear persisted credentials for this mesh.
342
+ * Warning: lost key = lost data. No recovery.
343
+ */
344
+ async clearCredentials() {
345
+ await this.clearPersistedCredentials();
346
+ this.encryptionKey = null;
347
+ this.passphrase = null;
348
+ this.encrypted = false;
349
+ }
350
+ /**
351
+ * @deprecated Use setPassphrase() instead. Will be removed.
352
+ */
353
+ setEncryptionKey(key) {
354
+ this.encryptionKey = key;
355
+ this.encrypted = true;
356
+ }
357
+ // ── Lifecycle ──────────────────────────────────────────────────────
358
+ async init() {
359
+ log('debug', 'init() — opening local store', { dbName: this.config.dbName, encrypted: this.encrypted });
360
+ try {
361
+ await this.local.open();
362
+ }
363
+ catch (err) {
364
+ log('error', 'init() — local store open failed', err);
365
+ throw err;
366
+ }
367
+ if (this.schema) {
368
+ await this.local.setMeta('schema:version', this.schema.version);
369
+ }
370
+ // Recover credentials from the silent primary store only.
371
+ // No biometric prompt during normal init.
372
+ await this.restoreCredentials();
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 {
377
+ await this.loadLocalState();
378
+ }
379
+ catch (err) {
380
+ log('error', 'init() — loadLocalState failed', err);
381
+ throw err;
382
+ }
383
+ log('debug', 'init() — complete', { knownTables: Array.from(this.knownTables) });
384
+ this.initialized = true;
385
+ }
386
+ /**
387
+ * Silent credential restore from the primary store only.
388
+ * Used during normal init(). No biometric prompt.
389
+ */
390
+ async restoreCredentials() {
391
+ const stored = await this.loadPersistedCredentials();
392
+ if (!stored)
393
+ return;
394
+ if (stored.deviceId && stored.deviceId !== this.deviceId) {
395
+ this.deviceId = stored.deviceId;
396
+ this.hlc.nodeId = stored.deviceId;
397
+ try {
398
+ localStorage.setItem('interocitor-device-id', stored.deviceId);
399
+ }
400
+ catch { /* ok */ }
401
+ }
402
+ if (this.encrypted && !this.passphrase && stored.passphrase) {
403
+ this.passphrase = stored.passphrase;
404
+ }
405
+ }
406
+ async connect() {
407
+ if (!this.initialized) {
408
+ throw new Error('Engine must be initialized via init() before connect()');
409
+ }
410
+ const adapter = this.requireAdapter('connect()');
411
+ log('debug', 'connect() — authenticating with adapter', { adapter: adapter.name });
412
+ if (!adapter.isAuthenticated()) {
413
+ this.emit({ type: 'auth:required' });
414
+ try {
415
+ await adapter.authenticate();
416
+ }
417
+ catch (err) {
418
+ log('error', 'connect() — authentication failed', err);
419
+ throw err;
420
+ }
421
+ this.emit({ type: 'auth:complete' });
422
+ }
423
+ const p = paths(this.config.remotePath);
424
+ log('debug', 'connect() — ensuring remote folders', { remotePath: this.config.remotePath, deviceId: this.deviceId });
425
+ for (const folder of [this.config.remotePath, p.devicesFolder, p.mainlineFolder, p.changesFolder]) {
426
+ try {
427
+ await adapter.ensureFolder(folder);
428
+ log('debug', 'connect() — ensureFolder ok', folder);
429
+ }
430
+ catch (err) {
431
+ log('error', 'connect() — ensureFolder failed', folder, err);
432
+ throw err;
433
+ }
434
+ }
435
+ log('debug', 'connect() — loading/creating manifest');
436
+ try {
437
+ await this.doLoadOrCreateManifest();
438
+ }
439
+ catch (err) {
440
+ log('error', 'connect() — loadOrCreateManifest failed', err);
441
+ throw err;
442
+ }
443
+ await upsertDeviceMetadata(adapter, this.config.remotePath, this.deviceId);
444
+ const localEpochRaw = await this.local.getMeta('epoch');
445
+ const localEpoch = typeof localEpochRaw === 'number' ? localEpochRaw : 0;
446
+ const remoteEpoch = this.manifest?.epoch ?? 0;
447
+ log('debug', 'connect() — epoch check', { localEpoch, remoteEpoch });
448
+ if (localEpoch < remoteEpoch) {
449
+ log('debug', 'connect() — epoch advanced, rehydrating from snapshot');
450
+ await this.rehydrate();
451
+ }
452
+ else {
453
+ log('debug', 'connect() — running initial pull');
454
+ await this.pull();
455
+ }
456
+ await this.flush();
457
+ this.startPolling();
458
+ this.connected = true;
459
+ log('info', 'connect() — connected', { remotePath: this.config.remotePath, deviceId: this.deviceId, pollInterval: this.config.pollInterval });
460
+ if (typeof window !== 'undefined') {
461
+ window.addEventListener('visibilitychange', () => {
462
+ if (document.visibilityState === 'hidden') {
463
+ this.flush().catch(() => { });
464
+ }
465
+ });
466
+ }
467
+ }
468
+ async disconnect() {
469
+ this.stopPolling();
470
+ this.clearScheduledFlush();
471
+ if (!this.remotePoisonError)
472
+ await this.flush();
473
+ this.local.close();
474
+ this.connected = false;
475
+ this.initialized = false;
476
+ this.remotePoisonError = null;
477
+ }
478
+ async setRemoteStorage(adapter) {
479
+ const wasConnected = this.connected;
480
+ const hadAdapter = this.adapter !== null;
481
+ if (wasConnected && hadAdapter && !this.remotePoisonError)
482
+ await this.pull();
483
+ this.stopPolling();
484
+ this.clearScheduledFlush();
485
+ if (this.initialized) {
486
+ await this.resetRemoteSyncState();
487
+ if (adapter)
488
+ await this.rebuildOutboxFromLocalState();
489
+ }
490
+ else {
491
+ this.manifest = null;
492
+ this.connected = false;
493
+ }
494
+ this.adapter = adapter;
495
+ this.remotePoisonError = null;
496
+ if (wasConnected && adapter)
497
+ await this.connect();
498
+ }
499
+ async setLocalStorage(local) {
500
+ const wasConnected = this.connected;
501
+ if (wasConnected)
502
+ await this.flush();
503
+ this.clearScheduledFlush();
504
+ this.pendingCount = 0;
505
+ this.local.close();
506
+ this.local = local;
507
+ await this.local.open();
508
+ await this.loadLocalState();
509
+ this.initialized = true;
510
+ if (!wasConnected)
511
+ return;
512
+ await this.pull();
513
+ await this.flush();
514
+ }
515
+ // ── Manifest (delegated) ───────────────────────────────────────────
516
+ async doLoadOrCreateManifest() {
517
+ const manifest = await loadOrCreateManifest(this.manifestContext, this.codecState, this.local, (err, path) => this.poisonRemote(err, path));
518
+ this.manifest = manifest;
519
+ this.encrypted = manifest.encrypted || this.encrypted;
520
+ }
521
+ // ── Local writes ───────────────────────────────────────────────────
522
+ async put(table, rowId, columns, userId) {
523
+ this.hlc = hlcNow(this.hlc);
524
+ const hlcStr = hlcSerialize(this.hlc);
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;
547
+ }
548
+ async delete(table, rowId, userId) {
549
+ this.hlc = hlcNow(this.hlc);
550
+ const hlcStr = hlcSerialize(this.hlc);
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;
576
+ }
577
+ async query(table) {
578
+ return this.local.getTable(table);
579
+ }
580
+ async queryWhere(table, clause) {
581
+ return this.local.queryWhere(table, clause);
582
+ }
583
+ async tableNames() {
584
+ return Array.from(this.knownTables);
585
+ }
586
+ table(name) {
587
+ return new Table(this, name);
588
+ }
589
+ // ── Flush (local → cloud) ──────────────────────────────────────────
590
+ scheduleFlush() {
591
+ this.pendingCount++;
592
+ if (this.pendingCount >= this.config.flushThreshold) {
593
+ this.flush().catch(err => this.emit({ type: 'flush:error', error: err }));
594
+ return;
595
+ }
596
+ if (this.flushTimer)
597
+ clearTimeout(this.flushTimer);
598
+ this.flushTimer = setTimeout(() => {
599
+ this.flush().catch(err => this.emit({ type: 'flush:error', error: err }));
600
+ }, this.config.flushDebounce);
601
+ }
602
+ async flush() {
603
+ if (!this.adapter) {
604
+ this.clearScheduledFlush();
605
+ return;
606
+ }
607
+ const entries = await this.local.drainOutbox();
608
+ if (entries.length === 0)
609
+ return;
610
+ log('debug', 'flush() — start', { entryCount: entries.length });
611
+ this.emit({ type: 'flush:start', entryCount: entries.length });
612
+ this.pendingCount = 0;
613
+ this.clearScheduledFlush();
614
+ try {
615
+ await this.doLoadOrCreateManifest();
616
+ await flushToAdapter(this.adapter, this.config.remotePath, entries, true, this.codecState, this.deviceId);
617
+ for (const replica of this.config.replicas) {
618
+ try {
619
+ const replicaRoot = replica.remotePath ?? this.config.remotePath;
620
+ if (!replica.adapter.isAuthenticated())
621
+ await replica.adapter.authenticate();
622
+ await flushToAdapter(replica.adapter, replicaRoot, entries, false, this.codecState, this.deviceId);
623
+ }
624
+ catch (err) {
625
+ log('warn', 'flush() — replica write failed', { adapter: replica.adapter.name }, err);
626
+ this.emit({ type: 'replica:error', adapter: replica.adapter.name, error: err });
627
+ }
628
+ }
629
+ log('debug', 'flush() — complete', { entryCount: entries.length });
630
+ this.emit({ type: 'flush:complete' });
631
+ }
632
+ catch (err) {
633
+ log('error', 'flush() — failed, re-queuing entries', err);
634
+ for (const entry of entries)
635
+ await this.local.pushOutbox(entry);
636
+ throw err;
637
+ }
638
+ }
639
+ // ── Pull (cloud → local) ──────────────────────────────────────────
640
+ async pull() {
641
+ const adapter = this.requireAdapter('pull()');
642
+ this.hlc = await doPull({
643
+ adapter,
644
+ local: this.local,
645
+ remotePath: this.config.remotePath,
646
+ codecState: this.codecState,
647
+ hlc: this.hlc,
648
+ deviceId: this.deviceId,
649
+ tables: this.tables,
650
+ knownTables: this.knownTables,
651
+ schema: this.schema,
652
+ emit: (e) => this.emit(e),
653
+ ensureRowsCached: (ops) => this.ensureRowsCached(ops),
654
+ poisonRemote: (err, path) => this.poisonRemote(err, path),
655
+ loadOrCreateManifest: () => this.doLoadOrCreateManifest(),
656
+ });
657
+ }
658
+ // ── Rehydrate / Compact ────────────────────────────────────────────
659
+ async rehydrate() {
660
+ const adapter = this.requireAdapter('rehydrate()');
661
+ this.hlc = await doRehydrate({
662
+ adapter,
663
+ local: this.local,
664
+ codecState: this.codecState,
665
+ manifest: this.manifest,
666
+ hlc: this.hlc,
667
+ deviceId: this.deviceId,
668
+ tables: this.tables,
669
+ knownTables: this.knownTables,
670
+ emit: (e) => this.emit(e),
671
+ poisonRemote: (err, path) => this.poisonRemote(err, path),
672
+ pull: () => this.pull(),
673
+ });
674
+ }
675
+ async compact() {
676
+ const adapter = this.requireAdapter('compact()');
677
+ if (!this.manifest)
678
+ throw new Error('Engine is not connected');
679
+ this.manifest = await doCompact({
680
+ adapter,
681
+ local: this.local,
682
+ remotePath: this.config.remotePath,
683
+ manifest: this.manifest,
684
+ codecState: this.codecState,
685
+ hlc: this.hlc,
686
+ deviceId: this.deviceId,
687
+ serverId: this.serverId,
688
+ emit: (e) => this.emit(e),
689
+ pull: () => this.pull(),
690
+ });
691
+ }
692
+ // ── Mesh management ────────────────────────────────────────────────
693
+ getManifest() {
694
+ return this.manifest ? { ...this.manifest } : null;
695
+ }
696
+ getDeviceId() {
697
+ return this.deviceId;
698
+ }
699
+ getMeshId() {
700
+ return this.manifest?.meshId;
701
+ }
702
+ isEncrypted() {
703
+ return this.encrypted;
704
+ }
705
+ /**
706
+ * Enable encryption on an existing unencrypted mesh.
707
+ * @deprecated Use setPassphrase() or pass encrypted/passphrase in config.
708
+ */
709
+ async enableEncryption(key) {
710
+ this.encryptionKey = key;
711
+ this.encrypted = true;
712
+ }
713
+ }
714
+ //# sourceMappingURL=sync-engine.js.map