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