@glance-apps/sync 2.0.0 → 2.1.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - **The snapshot-file sync cycle** (`runSnapshotFileCycle`), moved here from
15
+ dayGLANCE so every GLANCE app's iCloud and Direct Access tiers run one
16
+ cycle. A snapshot-file transport is a local file something else ferries
17
+ between devices (an iCloud container, a Nextcloud / Drive / Dropbox /
18
+ Syncthing folder): no server, no ETag. The cycle reads the whole file,
19
+ merges it with local state, applies what changed locally, and writes the
20
+ merged result back, with every data-safety guard the dayGLANCE loop grew
21
+ on devices: the seed guard (`evaluateMissingSnapshot`), the empty-state
22
+ guard, the first-run restore prompt (`shouldPromptFirstRun`), the content
23
+ gate (a write needs a real difference, not just a merge flag), the
24
+ made-here-or-relayed rule with relays staggered by writer rank
25
+ (`relayDecision`, `relayWaitMs`, `noteWriter`, `readWriters`), the write
26
+ throttle, and the envelope rules (the file decides, the switch decides the
27
+ first write; a wanted envelope with no key ready is held, never written
28
+ plaintext). Pure over an injected transport and `io`; the app's storage
29
+ keys and any strip of slices the file must not carry come in through `io`
30
+ (`localModifiedKey`, `lastLocalEditAt`, `localItemCount`, `hasData`,
31
+ `outgoing`). The read classification is `classifySnapshotText`.
32
+ - **The slice comparison** the cycle decides writes and applies with
33
+ (`sliceDiffs`, `describeSliceDiff`, `writeWorthy`, `canonicalJson`,
34
+ `explainSnapshotMerge`), so an app's diagnostics can run the identical
35
+ function the cycle runs.
36
+
37
+ No existing export changes. dayGLANCE's local copies become wrappers over
38
+ these; lastGLANCE's Direct Access tier (its Phase 7b) builds on them.
39
+
10
40
  ## [2.0.0] - 2026-08-30
11
41
 
12
42
  > ### ⚠️ ACTION REQUIRED for lastGLANCE and lifeGLANCE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -70,6 +70,31 @@ export {
70
70
  // from a rejected credential: re-enrollment that rotates the dead credential
71
71
  // away server-side.
72
72
  export { connectVaultSyncEngine, recoverVaultSyncEngine } from './vaultConnect.js';
73
+ // 2.1.0: the snapshot-file cycle (iCloud, Direct Access) and the slice
74
+ // comparison it decides writes and applies with. Pure over an injected
75
+ // transport; see src/snapshotFileSync.js.
76
+ export {
77
+ runSnapshotFileCycle,
78
+ classifySnapshotText,
79
+ evaluateMissingSnapshot,
80
+ shouldPromptFirstRun,
81
+ payloadHasData,
82
+ relayDecision,
83
+ relayWaitMs,
84
+ noteWriter,
85
+ readWriters,
86
+ RELAY_CONFIRM_MS,
87
+ RELAY_STAGGER_MS,
88
+ MISSING_GRACE_MS,
89
+ } from './snapshotFileSync.js';
90
+ export {
91
+ canonicalJson,
92
+ describeSliceDiff,
93
+ sliceDiffs,
94
+ writeWorthy,
95
+ explainSnapshotMerge,
96
+ PER_MERGE_STAMPS,
97
+ } from './snapshotMergeExplain.js';
73
98
  export {
74
99
  setupDbRootKey,
75
100
  initDbRootKey,
@@ -0,0 +1,480 @@
1
+ /**
2
+ * @glance-apps/sync 2.1.0: the snapshot-file sync cycle, moved here from
3
+ * dayGLANCE's src/sync/snapshotFileSync.js so every GLANCE app's iCloud and
4
+ * Direct Access tiers run one cycle (dayGLANCE's docs/direct-access-sync.md).
5
+ *
6
+ * A snapshot-file transport is a local file that something else ferries
7
+ * between devices: an iCloud ubiquity container, a Nextcloud / Google Drive /
8
+ * Dropbox / Syncthing folder. There is no server and no ETag, so the cycle
9
+ * is: read the whole file, merge it with local state by the same three-way
10
+ * merge the WebDAV tier uses, apply what changed locally, and write the
11
+ * merged result back. Pure: the transport, the merge, the apply, the crypto
12
+ * and the app's storage keys all come in through the arguments. Every guard
13
+ * has a test that fails when it is removed:
14
+ *
15
+ * • an ABSENT file is seeded from local data, but only once the seed guard
16
+ * (evaluateMissingSnapshot) says absence is not a transient eviction, and
17
+ * only when the payload is not an empty copy of a populated store;
18
+ * • a 'downloading' placeholder and an unparseable file skip the cycle;
19
+ * • an 'error' object is surfaced, never merged;
20
+ * • an encrypted envelope is decrypted when the key is cached; when it cannot
21
+ * be, the transport decides whether local data may be written over it as
22
+ * plaintext (iCloud: yes, it is a legacy cleanup inside Apple's container;
23
+ * Direct Access: never, the folder is someone else's cloud);
24
+ * • a real snapshot stamps the transport's own last-synced key BEFORE the
25
+ * first-run prompt, because reading it is what proves the transport works;
26
+ * • a fresh device facing a populated snapshot is asked, not restored;
27
+ * • writes are throttled per transport so a daemon's round-trip is not raced;
28
+ * • health-store counts are stripped from every copy written out, on the
29
+ * transports that ask for it (`io.outgoing`): dayGLANCE's iCloud file does,
30
+ * for Apple's guideline 5.1.3; Direct Access is someone else's folder and
31
+ * carries them, exactly as GLANCEvault and WebDAV do;
32
+ * • a write happens only when what would be written differs from the file,
33
+ * and an apply only when the merged data differs from local state. The
34
+ * merge's own flags are necessary, not sufficient: with the strip above
35
+ * they fired on every cycle of every Mac holding health counts, and the
36
+ * resulting identical rewrites starved every phone (2026-10-05);
37
+ * • a change made on this device is written at once; a change that reached
38
+ * this device by another road is only relayed once the file has sat
39
+ * unchanged, still lacking it, for RELAY_CONFIRM_MS. A change reaches a
40
+ * device by two roads at different speeds, GLANCEvault in seconds and the
41
+ * folder's syncing tool in tens of seconds, so the second Mac learned of
42
+ * an iPhone's edit from the vault, found its folder copy stale, wrote the
43
+ * same data with a fresh stamp, and Nextcloud reported a conflict on
44
+ * every change (2026-10-08). Deferring every write by one poll did not
45
+ * help: it held the originating device's write back too, which only
46
+ * lengthened the window in which the others relayed it. "Made here" is
47
+ * the device's own last-edit stamp (`io.lastLocalEditAt`, set by the persist
48
+ * pass for an edit and not for an apply) being newer than this device's
49
+ * last write to, or before that last read of, this file.
50
+ *
51
+ * Nothing here touches React. An app's hook owns the poll, the mutex and the
52
+ * prompt state (dayGLANCE: hooks/useSnapshotFileSync.js); the app owns i18n
53
+ * and status UI.
54
+ */
55
+
56
+ import { sliceDiffs, writeWorthy } from './snapshotMergeExplain.js';
57
+
58
+ // ─── The seed guard ────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * How long an absent snapshot is assumed to be a temporary eviction. iCloud
62
+ * and the folder tools restore an evicted file within seconds; ten minutes is
63
+ * far past that while still being a delay a user would sit through.
64
+ */
65
+ export const MISSING_GRACE_MS = 10 * 60 * 1000;
66
+
67
+ /**
68
+ * Whether an absent file may be seeded, or is an eviction to wait out.
69
+ *
70
+ * @param {object} s
71
+ * @param {boolean} s.hasSyncedBefore this device has previously read a real snapshot
72
+ * @param {number} s.missingSince epoch ms of the first consecutive sighting of
73
+ * the file as absent, or 0 if it was present last cycle
74
+ * @param {number} s.now epoch ms
75
+ * @param {number} [s.graceMs]
76
+ * @returns {{skip: boolean, missingSince: number}}
77
+ * skip — true to leave the file alone this cycle
78
+ * missingSince — the value to carry into the next cycle (0 clears the streak)
79
+ */
80
+ export function evaluateMissingSnapshot({ hasSyncedBefore, missingSince, now, graceMs = MISSING_GRACE_MS } = {}) {
81
+ // Never synced: nothing can have been evicted, so this is a real first run
82
+ // and seeding is the correct, necessary behaviour.
83
+ if (!hasSyncedBefore) return { skip: false, missingSince: 0 };
84
+ // First cycle seeing it absent: start the clock and wait.
85
+ if (!missingSince) return { skip: true, missingSince: now };
86
+ // Still inside the window: keep waiting, preserving the original sighting so
87
+ // the window measures the whole streak rather than restarting every cycle.
88
+ if (now - missingSince < graceMs) return { skip: true, missingSince };
89
+ // Gone long enough that eviction no longer explains it. Seed, and clear the
90
+ // streak so the next absence starts its own window.
91
+ return { skip: false, missingSince: 0 };
92
+ }
93
+
94
+ // ─── The first-run prompt ──────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * A fresh install facing populated remote data is asked whether to restore
98
+ * rather than restored silently: only when no decision was recorded, the
99
+ * remote copy has data, this device has none, and the transport is reachable.
100
+ */
101
+ export function shouldPromptFirstRun({ decided, remoteHasData, localHasData, available = true } = {}) {
102
+ return !!(!decided && remoteHasData && !localHasData && available);
103
+ }
104
+
105
+ /** The default `io.hasData`: any non-empty array in the data. Apps that know their content slices pass their own. */
106
+ export function payloadHasData(data) {
107
+ if (!data || typeof data !== 'object') return false;
108
+ return Object.values(data).some((v) => Array.isArray(v) && v.length > 0);
109
+ }
110
+
111
+ // The two stamps an app keeps around the cycle are the app's own storage keys,
112
+ // handed in through `io`: `io.localModifiedKey` (stamped on every apply, so the
113
+ // app's other engines see the device changed) and `io.lastLocalEditAt` (when
114
+ // this device itself last changed its data, set by the app's persist pass for
115
+ // an edit made here and not for an apply from a transport).
116
+
117
+ /**
118
+ * How long a change that reached this device by another road must stay
119
+ * missing from an unchanged file before this device relays it. Longer than
120
+ * the folder's syncing tool takes to deliver the originating device's own
121
+ * write (Nextcloud's desktop client polls every 30 s without push), so the
122
+ * relay only happens when nobody else carried it.
123
+ */
124
+ export const RELAY_CONFIRM_MS = 90 * 1000;
125
+
126
+ /**
127
+ * The relay rule, shared with the event-set cycle (intents/folderIntents.js):
128
+ * a change this device did not make goes out only once the same difference
129
+ * (`fingerprint`, against the file version it was seen on) has been seen
130
+ * again RELAY_CONFIRM_MS later. Returns whether to write now and the pending
131
+ * look to carry: a different fingerprint starts over, the same one is kept.
132
+ *
133
+ * @param {{fingerprint: string, at: number}|null} pending
134
+ * @param {string} fingerprint
135
+ * @param {number} now
136
+ * @returns {{write: boolean, pending: {fingerprint: string, at: number}}}
137
+ */
138
+ export function relayDecision(pending, fingerprint, now, waitMs = RELAY_CONFIRM_MS) {
139
+ if (pending && pending.fingerprint === fingerprint && now - pending.at >= waitMs) {
140
+ return { write: true, pending };
141
+ }
142
+ return { write: false, pending: (!pending || pending.fingerprint !== fingerprint) ? { fingerprint, at: now } : pending };
143
+ }
144
+
145
+ /**
146
+ * Relays are staggered across devices, a minute per rank. A change that
147
+ * arrives by another road (the vault's push nudge, an edit from a phone whose
148
+ * folder tool round-trips slowly) reaches every device on the folder within
149
+ * the same second, so with one relay clock they all wrote the same file
150
+ * together, 90 s later, and the folder kept a conflict copy per pair (two
151
+ * Macs, 2026-10-10). Each writer stamps its device id in the file header;
152
+ * each device ranks itself among the writers it has seen there, and waits an
153
+ * extra RELAY_STAGGER_MS per rank. The first-ranked device writes at 90 s;
154
+ * the next sees the file catch up before its own clock runs out and drops
155
+ * the relay. A device that has not seen the others yet ranks first, collides
156
+ * at most once, then learns them from the file.
157
+ */
158
+ export const RELAY_STAGGER_MS = 60 * 1000;
159
+ const WRITERS_LIMIT = 32;
160
+
161
+ /** The writers seen in a transport's file, under `${transport.lastSyncedKey}:writers`. */
162
+ export function readWriters(storage, key) {
163
+ try {
164
+ const v = JSON.parse(storage?.getItem(key) ?? '[]');
165
+ return Array.isArray(v) ? v.filter((w) => typeof w === 'string') : [];
166
+ } catch { return []; }
167
+ }
168
+
169
+ /** Records a writer id read from the file. Returns true when it was new. */
170
+ export function noteWriter(storage, key, writer) {
171
+ if (typeof writer !== 'string' || !writer) return false;
172
+ const seen = readWriters(storage, key);
173
+ if (seen.includes(writer)) return false;
174
+ seen.push(writer);
175
+ try { storage?.setItem(key, JSON.stringify(seen.slice(-WRITERS_LIMIT))); } catch { /* ignore */ }
176
+ return true;
177
+ }
178
+
179
+ /** This device's relay wait: RELAY_CONFIRM_MS plus a minute per rank among the writers seen (its own id included). */
180
+ export function relayWaitMs(storage, key, self) {
181
+ if (typeof self !== 'string' || !self) return RELAY_CONFIRM_MS;
182
+ const ids = [...new Set([...readWriters(storage, key), self])].sort();
183
+ return RELAY_CONFIRM_MS + ids.indexOf(self) * RELAY_STAGGER_MS;
184
+ }
185
+
186
+ /**
187
+ * Classifies the raw text a transport read.
188
+ *
189
+ * @param {string|null|undefined} str
190
+ * @returns {{kind: 'absent'}
191
+ * | {kind: 'unparseable'}
192
+ * | {kind: 'downloading'}
193
+ * | {kind: 'error', error: string}
194
+ * | {kind: 'snapshot', remote: object}}
195
+ */
196
+ export function classifySnapshotText(str) {
197
+ if (!str || str === 'null') return { kind: 'absent' };
198
+ let remote;
199
+ try { remote = JSON.parse(str); } catch { return { kind: 'unparseable' }; }
200
+ if (remote?.downloading) return { kind: 'downloading' };
201
+ if (remote?.error) return { kind: 'error', error: String(remote.error) };
202
+ return { kind: 'snapshot', remote };
203
+ }
204
+
205
+ /**
206
+ * Runs one cycle. Never throws for a transport outcome; a thrown error from
207
+ * `io` (a bug) propagates so the caller's `finally` still releases its lock.
208
+ *
209
+ * @param {object} args
210
+ * @param {object} args.transport
211
+ * @param {string} args.transport.id for log lines
212
+ * @param {() => Promise<string|null>} args.transport.read
213
+ * @param {(text: string) => Promise<boolean>} args.transport.write
214
+ * @param {string} args.transport.lastSyncedKey storage key this transport stamps
215
+ * @param {() => boolean} args.transport.firstRunDecided has the user answered the restore prompt
216
+ * @param {number} args.transport.writeThrottleMs
217
+ * @param {boolean} args.transport.allowsPlaintextReseed may an undecryptable envelope be overwritten
218
+ * @param {() => boolean} [args.transport.encryptsWrites] the per-device switch: seed and upgrade as an envelope
219
+ * @param {object} args.io
220
+ * @param {() => object} args.io.buildSyncPayload `{ version, lastModified?, data }`
221
+ * @param {(data: object, opts: {allowEmpty: boolean}) => void} args.io.applyEngineData
222
+ * @param {(local: object, remote: object, retentionDays: number) => {data: object, localChanged: boolean, remoteChanged: boolean, deviceLocalKeys?: string[]}} args.io.mergeSyncData
223
+ * @param {(payload: object) => object} [args.io.outgoing] what the transport actually writes, from the plaintext payload (a strip of slices the file must not carry); identity by default
224
+ * @param {number} args.io.syncRetentionDays
225
+ * @param {(envelope: object) => boolean} args.io.isEncryptedEnvelope
226
+ * @param {(envelope: object) => Promise<object>} args.io.decryptData
227
+ * @param {(payload: object) => Promise<object>} [args.io.encryptData] plaintext payload → envelope
228
+ * @param {() => boolean} [args.io.encryptionReady] can encryptData succeed now (a key or the passphrase is in memory)
229
+ * @param {Storage} args.io.storage localStorage-like
230
+ * @param {() => string|null} [args.io.lastLocalEditAt] when this device itself last changed its data (absent: never, every change is relayed)
231
+ * @param {string} [args.io.localModifiedKey] stamped with the time of every apply, for the app's other engines
232
+ * @param {() => number} [args.io.localItemCount] how many items the app's own storage holds (the empty-state guard: a payload of nothing over a store of something is not seeded)
233
+ * @param {(data: object) => boolean} [args.io.hasData] does a payload carry anything worth restoring (default payloadHasData)
234
+ * @param {string|(() => string)} [args.io.deviceId] stamped as `writtenBy` in the file header; ranks this device's relay (relayWaitMs)
235
+ * @param {() => number} [args.io.now]
236
+ * @param {Console} [args.io.log]
237
+ * @param {{missingSince: number, lastWriteAt: number, lastWrittenAt?: number, pendingWrite?: {fingerprint: string, at: number}|null}} args.state carried between cycles
238
+ * @returns {Promise<{state: {missingSince: number, lastWriteAt: number, lastWrittenAt: number, pendingWrite: object|null}, outcome: object}>}
239
+ * outcome.kind is one of:
240
+ * 'skipped' (reason: 'missing-grace' | 'empty-state-guard' | 'downloading' |
241
+ * 'unparseable' | 'no-data' | 'encrypted-unreadable' | 'key-needed')
242
+ * encrypted-unreadable carries needsKey: true when no key and no
243
+ * passphrase are in memory (the prompt collects one), false when
244
+ * the key in memory is the wrong one
245
+ * 'seeded' the absent file was written from local data (wrote: boolean)
246
+ * 'reseeded' an undecryptable envelope was replaced with local plaintext
247
+ * 'error' (reason: 'unavailable', error) the transport reported an error
248
+ * 'prompted' (info) a first-run restore choice is needed; nothing was written
249
+ * 'merged' (localChanged, remoteChanged, applied, wrote, deferred, ownEdits, keyNeeded)
250
+ * deferred: a relay is wanted and waits for the file to catch up
251
+ * ownEdits: this device has a change of its own not yet written
252
+ * keyNeeded: a write is wanted as an envelope and no key is ready
253
+ *
254
+ * Encryption (docs/direct-access-sync.md, Phase 6): the file decides, the
255
+ * switch decides the first write. A file read as an envelope is written back
256
+ * as an envelope whatever the switch says (never downgrade someone else's
257
+ * cloud folder); a plaintext or absent file is written as an envelope when
258
+ * `transport.encryptsWrites()` is on. Every guard, the content gate and the
259
+ * made-here rule look at plaintext, before the envelope. A wanted envelope
260
+ * with no key ready is held, never written plaintext.
261
+ */
262
+ export async function runSnapshotFileCycle({ transport, io, state }) {
263
+ const log = io.log ?? console;
264
+ const now = io.now ?? Date.now;
265
+ const next = {
266
+ missingSince: state?.missingSince ?? 0,
267
+ lastWriteAt: state?.lastWriteAt ?? 0,
268
+ // The last SUCCESSFUL write to this file (lastWriteAt counts attempts, for
269
+ // the throttle), or before any, the read before this cycle: the baseline
270
+ // an edit made here has to be newer than to count as not yet written.
271
+ lastWrittenAt: state?.lastWrittenAt ?? 0,
272
+ pendingWrite: state?.pendingWrite ?? null,
273
+ };
274
+ const lastLocalEdit = (() => {
275
+ try {
276
+ const v = io.lastLocalEditAt ? io.lastLocalEditAt() : null;
277
+ const t = v ? new Date(v).getTime() : NaN;
278
+ return Number.isFinite(t) ? t : 0;
279
+ } catch { return 0; }
280
+ })();
281
+ const deviceId = (() => {
282
+ try { const v = typeof io.deviceId === 'function' ? io.deviceId() : io.deviceId; return typeof v === 'string' && v ? v : null; }
283
+ catch { return null; }
284
+ })();
285
+ const writersKey = `${transport.lastSyncedKey}:writers`;
286
+ // The header every write carries: who wrote it (relayWaitMs).
287
+ const header = () => (deviceId ? { writtenBy: deviceId } : {});
288
+
289
+ // Writing faster than the daemon's round-trip piles conflict versions onto
290
+ // the remote; skip writes inside the window. The next poll re-runs the merge
291
+ // and writes any genuinely deferred change. The stamp is taken before the
292
+ // write so a failing write is throttled like a succeeding one.
293
+ const throttledWrite = async (text) => {
294
+ if (now() - next.lastWriteAt < transport.writeThrottleMs) return false;
295
+ next.lastWriteAt = now();
296
+ const ok = await transport.write(text);
297
+ if (!ok) log.error(`[${transport.id}] snapshot write failed`);
298
+ return ok;
299
+ };
300
+ // What the transport actually writes, from the plaintext payload: an app
301
+ // may strip slices a file must not carry (dayGLANCE's iCloud file leaves out
302
+ // HealthKit-derived counts for Apple's guideline 5.1.3). The strip reads
303
+ // plaintext, before any envelope, and never touches what is applied locally.
304
+ const strip = typeof io.outgoing === 'function' ? (payload) => io.outgoing(payload) ?? payload : (payload) => payload;
305
+
306
+ const read = classifySnapshotText(await transport.read());
307
+
308
+ // The file decides; the switch decides the first write.
309
+ const envelopeRead = read.kind === 'snapshot' && !!io.isEncryptedEnvelope(read.remote);
310
+ const envelopeOut = envelopeRead || !!transport.encryptsWrites?.();
311
+ const canEncrypt = typeof io.encryptData === 'function' && (io.encryptionReady ? !!io.encryptionReady() : true);
312
+ const keyNeeded = envelopeOut && !canEncrypt;
313
+ // Serialised after the health strip (the strip reads plaintext).
314
+ const encode = async (payload) => JSON.stringify(envelopeOut ? await io.encryptData(payload) : payload);
315
+ const outgoing = (payload) => encode(strip(payload));
316
+
317
+ if (read.kind === 'absent') {
318
+ // No remote file yet — seed it with local data, but only if the absence is
319
+ // real (not a transient eviction) and state is hydrated: if React state is
320
+ // empty while localStorage has tasks, seeding would wipe the remote copy.
321
+ const missing = evaluateMissingSnapshot({
322
+ hasSyncedBefore: !!io.storage.getItem(transport.lastSyncedKey),
323
+ missingSince: next.missingSince,
324
+ now: now(),
325
+ });
326
+ next.missingSince = missing.missingSince;
327
+ if (missing.skip) return { state: next, outcome: { kind: 'skipped', reason: 'missing-grace' } };
328
+ const localCount = typeof io.localItemCount === 'function' ? (io.localItemCount() || 0) : 0;
329
+ const payload = io.buildSyncPayload();
330
+ const hasData = io.hasData ?? payloadHasData;
331
+ if (localCount > 0 && !hasData(payload.data)) {
332
+ return { state: next, outcome: { kind: 'skipped', reason: 'empty-state-guard' } };
333
+ }
334
+ if (keyNeeded) return { state: next, outcome: { kind: 'skipped', reason: 'key-needed' } };
335
+ const wrote = await throttledWrite(await outgoing({ ...payload, ...header() }));
336
+ return { state: next, outcome: { kind: 'seeded', wrote } };
337
+ }
338
+
339
+ // Exists but is still coming down from the cloud, or is mid-write by the
340
+ // daemon: let the next poll retry.
341
+ if (read.kind === 'downloading') return { state: next, outcome: { kind: 'skipped', reason: 'downloading' } };
342
+ if (read.kind === 'unparseable') return { state: next, outcome: { kind: 'skipped', reason: 'unparseable' } };
343
+ if (read.kind === 'error') {
344
+ return { state: next, outcome: { kind: 'error', reason: 'unavailable', error: read.error } };
345
+ }
346
+
347
+ let remote = read.remote;
348
+ if (envelopeRead) {
349
+ let needsKey = false;
350
+ try { remote = await io.decryptData(remote); }
351
+ catch (decErr) {
352
+ log.warn(`[${transport.id}] encrypted snapshot could not be decrypted:`, decErr?.message ?? decErr);
353
+ needsKey = decErr?.code === 'PASSPHRASE_REQUIRED';
354
+ remote = null;
355
+ }
356
+ if (!remote?.data) {
357
+ if (!transport.allowsPlaintextReseed) {
358
+ // Someone else's cloud folder: never downgrade a file we cannot read.
359
+ // Nothing is applied and nothing is written; with no key in memory
360
+ // at all the caller prompts for the passphrase.
361
+ return { state: next, outcome: { kind: 'skipped', reason: 'encrypted-unreadable', needsKey } };
362
+ }
363
+ // iCloud's legacy-envelope cleanup: the file could not be followed, so
364
+ // this is the one write that is plaintext by design.
365
+ const wrote = await throttledWrite(JSON.stringify(strip(io.buildSyncPayload())));
366
+ return { state: next, outcome: { kind: 'reseeded', wrote } };
367
+ }
368
+ }
369
+ if (!remote?.data) return { state: next, outcome: { kind: 'skipped', reason: 'no-data' } };
370
+
371
+ // A real snapshot came back, so the transport demonstrably works from this
372
+ // device. Record that — it is what a later absence is measured against — and
373
+ // record it BEFORE the first-run prompt: reading the snapshot is what proves
374
+ // the transport works, whatever the user then chooses to do with it.
375
+ const previousRead = (() => {
376
+ try { const v = io.storage.getItem(transport.lastSyncedKey); const t = v ? new Date(v).getTime() : NaN; return Number.isFinite(t) ? t : 0; }
377
+ catch { return 0; }
378
+ })();
379
+ io.storage.setItem(transport.lastSyncedKey, new Date(now()).toISOString());
380
+ next.missingSince = 0;
381
+ if (!next.lastWrittenAt) next.lastWrittenAt = previousRead;
382
+ if (remote.writtenBy !== deviceId) noteWriter(io.storage, writersKey, remote.writtenBy);
383
+
384
+ // First launch on a device with no data of its own, facing a populated
385
+ // remote copy. Ask instead of restoring silently (shouldPromptFirstRun).
386
+ const localData = io.buildSyncPayload().data;
387
+ const hasData = io.hasData ?? payloadHasData;
388
+ if (shouldPromptFirstRun({
389
+ decided: transport.firstRunDecided(),
390
+ remoteHasData: hasData(remote.data),
391
+ localHasData: hasData(localData),
392
+ available: true,
393
+ })) {
394
+ return {
395
+ state: next,
396
+ outcome: {
397
+ kind: 'prompted',
398
+ info: {
399
+ // The app names its own slices; these two are dayGLANCE's and stay
400
+ // for its prompt. An app with other slices reads `data` itself.
401
+ taskCount: remote.data.tasks?.length ?? 0,
402
+ inboxCount: remote.data.unscheduledTasks?.length ?? 0,
403
+ lastModified: remote.lastModified ?? null,
404
+ data: remote.data,
405
+ },
406
+ },
407
+ };
408
+ }
409
+
410
+ const { data: mergedData, localChanged, remoteChanged, deviceLocalKeys = [] } =
411
+ io.mergeSyncData(localData, remote.data, io.syncRetentionDays);
412
+
413
+ // The merge's flags say whether it picked anything from either side. They do
414
+ // NOT say whether the file or the device would actually change, and the two
415
+ // questions differ: the iCloud write strips HealthKit-derived habit counts
416
+ // (the app's strip), so a device that holds those counts (any Mac
417
+ // receiving them through GLANCEvault) sees them "missing" from the file on
418
+ // every cycle, and the flag alone rewrote an identical stripped file every
419
+ // 15 s on every Mac in a fleet — which kept every phone's copy non-current
420
+ // and its edits stuck for minutes (2026-10-05). So a write needs a flag AND
421
+ // a real difference between what would be written and the file; an apply
422
+ // needs the flag AND a real difference between the merged data and local
423
+ // state. snapshotMergeExplain.js is that comparison, and an app's
424
+ // diagnostics panel can run the identical function. An order-only difference
425
+ // does not count for the write: the merge keeps each device's own order,
426
+ // so writing it changes nothing on the other device (writeWorthy).
427
+ const outPayload = strip({
428
+ version: 2,
429
+ lastModified: new Date(now()).toISOString(),
430
+ ...header(),
431
+ data: mergedData,
432
+ });
433
+ const applyNeeded = localChanged && sliceDiffs(mergedData, localData, { ignoreDropped: true }).length > 0;
434
+ // Device-local keys (the merge names them) are left out of the file
435
+ // question: every device keeps its own value, so the file's is nobody's.
436
+ const writable = (remoteChanged || localChanged)
437
+ ? writeWorthy(sliceDiffs(outPayload.data, remote.data, { ignoreKeys: deviceLocalKeys }))
438
+ : [];
439
+ const writeNeeded = writable.length > 0;
440
+
441
+ if (applyNeeded) {
442
+ // Apply the FULL merged data locally (health-sourced counts stay on-device).
443
+ io.applyEngineData(mergedData, { allowEmpty: !!remote.lastModified });
444
+ if (io.localModifiedKey) io.storage.setItem(io.localModifiedKey, new Date(now()).toISOString());
445
+ }
446
+
447
+ // Made here, or relayed (header). A change of this device's own goes out
448
+ // now. Anything else is relayed only after the file has sat unchanged,
449
+ // still lacking it, for RELAY_CONFIRM_MS: what was seen (the differences a
450
+ // write would resolve, against the file version they were seen on) has to
451
+ // be seen again that much later. A different file version starts over,
452
+ // since the slower road may just have delivered it.
453
+ const ownEdits = lastLocalEdit > next.lastWrittenAt;
454
+ let wrote = false;
455
+ let deferred = false;
456
+ if (writeNeeded && keyNeeded) {
457
+ // An envelope is wanted and nothing can seal it: hold the write where it
458
+ // is (the relay clock, if any, keeps running) and say so.
459
+ } else if (writeNeeded && ownEdits) {
460
+ wrote = await throttledWrite(await encode(outPayload));
461
+ if (wrote) next.lastWrittenAt = now();
462
+ next.pendingWrite = null;
463
+ } else if (writeNeeded) {
464
+ const fingerprint = `${remote.lastModified ?? ''}\u0000${writable.map((d) => d.summary).join('\n')}`;
465
+ const decision = relayDecision(next.pendingWrite, fingerprint, now(), relayWaitMs(io.storage, writersKey, deviceId));
466
+ next.pendingWrite = decision.pending;
467
+ if (decision.write) {
468
+ wrote = await throttledWrite(await encode(outPayload));
469
+ if (wrote) next.lastWrittenAt = now();
470
+ } else {
471
+ deferred = true;
472
+ }
473
+ } else {
474
+ next.pendingWrite = null;
475
+ }
476
+ return {
477
+ state: next,
478
+ outcome: { kind: 'merged', localChanged, remoteChanged, applied: applyNeeded, wrote, deferred, ownEdits, keyNeeded: writeNeeded && keyNeeded },
479
+ };
480
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Compares what a snapshot-file sync cycle would write and apply against what
3
+ * is already there, slice by slice.
4
+ *
5
+ * ── Why this exists ────────────────────────────────────────────────────────
6
+ * On 2026-10-05 every Mac in a fleet was seen rewriting dayglance-sync.json on
7
+ * every cycle: each diagnostics report showed the file modified ~30 ms after
8
+ * the cycle's read, with nothing changed on any device. Every such write made
9
+ * the phone's copy non-current, the phone then waited minutes for the download,
10
+ * and edits made on the phone in that window reached nobody.
11
+ *
12
+ * The cause was a collision of two correct rules. The iCloud write strips
13
+ * HealthKit-derived habit counts from the file (Apple guideline 5.1.3,
14
+ * the app's strip), while the Macs hold those counts locally because
15
+ * they arrive through GLANCEvault. So every merge saw 214 days of counts the
16
+ * file lacked, raised `remoteChanged`, wrote a stripped file identical to the
17
+ * one already there, and did it again next cycle. The merge's change flags
18
+ * answer "did the merge pick anything from local?", not "would the file
19
+ * change?", and only the second question should start a write.
20
+ *
21
+ * So the cycle now asks both: a write needs a flag AND a real difference
22
+ * between the outgoing (stripped) data and the file; an apply needs a flag AND
23
+ * a real difference between the merged data and local state. This module is
24
+ * that comparison, and the diagnostics panel runs the identical function so
25
+ * what it reports is what the cycle decides.
26
+ *
27
+ * Comparison rules: arrays of ids are matched by id with order noted
28
+ * separately, scalar lists compare as sets, maps by entry, everything else by
29
+ * canonical JSON. Absent and empty slices are equal, and so are absent and
30
+ * empty entries inside a map. Slices the merge
31
+ * re-stamps on every run (the tombstone fence) are ignored. For the apply
32
+ * question, slices the merge output simply does not carry are ignored too:
33
+ * applyEngineData leaves absent keys alone.
34
+ *
35
+ * Pure: no React, no storage, no bridge.
36
+ */
37
+
38
+ /** JSON with object keys sorted at every level, so equal content compares equal. */
39
+ export function canonicalJson(value) {
40
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
41
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
42
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
43
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
44
+ }
45
+
46
+ const isIdList = (v) =>
47
+ Array.isArray(v) && v.length > 0 && v.every((x) => x && typeof x === 'object' && x.id !== undefined);
48
+ const isPrimitiveList = (v) =>
49
+ Array.isArray(v) && v.every((x) => x === null || typeof x !== 'object');
50
+
51
+ /**
52
+ * Slices the merge re-stamps on every run by design, so they differ from the
53
+ * file on every cycle without meaning anything. `tombstonePrunedBefore` is the
54
+ * fixed 60-day tombstone fence, recomputed from the clock each merge
55
+ * (the app's merge wrapper). The write decision never reads them; neither does this report.
56
+ */
57
+ export const PER_MERGE_STAMPS = ['tombstonePrunedBefore'];
58
+
59
+ /** An absent slice and an empty one are the same thing to every consumer. */
60
+ const isEmptyish = (v) =>
61
+ v === undefined || v === null
62
+ || (Array.isArray(v) && v.length === 0)
63
+ || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);
64
+
65
+ /**
66
+ * Describes how `merged` differs from `other` for one slice, or null when it
67
+ * does not. `other` is the file for the write question, local state for the
68
+ * apply question.
69
+ *
70
+ * @returns {null | {key: string, kind: string, summary: string}}
71
+ */
72
+ export function describeSliceDiff(key, merged, other) {
73
+ if (PER_MERGE_STAMPS.includes(key)) return null;
74
+ if (isEmptyish(merged) && isEmptyish(other)) return null;
75
+ const mergedMissing = merged === undefined;
76
+ const otherMissing = other === undefined;
77
+ if (mergedMissing && otherMissing) return null;
78
+ if (otherMissing) return { key, kind: 'added', summary: `${key}: added` };
79
+ if (mergedMissing) return { key, kind: 'dropped', summary: `${key}: dropped` };
80
+
81
+ // Collections of ids: match by id so an order change is reported as what it
82
+ // is rather than as every item differing.
83
+ if ((isIdList(merged) || isIdList(other)) && Array.isArray(merged) && Array.isArray(other)) {
84
+ const byId = (list) => new Map(list.filter((x) => x && x.id !== undefined).map((x) => [String(x.id), x]));
85
+ const m = byId(merged);
86
+ const o = byId(other);
87
+ // Name up to five ids per group: a report that says "-1 only on other
88
+ // side" without the id cannot be acted on (an Android inbox task the merge
89
+ // kept dropping, 2026-10-06).
90
+ const sample = (ids) => (ids.length ? ` (${ids.slice(0, 5).join(', ')}${ids.length > 5 ? ', …' : ''})` : '');
91
+ // A changed id names the fields that differ (up to four), so "habits: 1
92
+ // changed" says WHAT about the row differs (2026-10-09).
93
+ const fields = (a, b) => {
94
+ const keys = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].sort()
95
+ .filter((k) => canonicalJson(a?.[k]) !== canonicalJson(b?.[k]));
96
+ return keys.length ? `: ${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}` : '';
97
+ };
98
+ const changedIds = [];
99
+ const onlyMergedIds = [];
100
+ const onlyOtherIds = [];
101
+ for (const [id, item] of m) {
102
+ if (!o.has(id)) { onlyMergedIds.push(id); continue; }
103
+ if (canonicalJson(item) !== canonicalJson(o.get(id))) changedIds.push(`${id}${fields(item, o.get(id))}`);
104
+ }
105
+ for (const id of o.keys()) if (!m.has(id)) onlyOtherIds.push(id);
106
+ const changed = changedIds.length;
107
+ const onlyMerged = onlyMergedIds.length;
108
+ const onlyOther = onlyOtherIds.length;
109
+ const orderDiffers = changed === 0 && onlyMerged === 0 && onlyOther === 0
110
+ && merged.map((x) => String(x?.id)).join('\u0000') !== other.map((x) => String(x?.id)).join('\u0000');
111
+ if (!changed && !onlyMerged && !onlyOther && !orderDiffers) return null;
112
+ const parts = [];
113
+ if (changed) parts.push(`${changed} changed${sample(changedIds)}`);
114
+ if (onlyMerged) parts.push(`+${onlyMerged} only in result${sample(onlyMergedIds)}`);
115
+ if (onlyOther) parts.push(`-${onlyOther} only on other side${sample(onlyOtherIds)}`);
116
+ if (orderDiffers) parts.push('order differs');
117
+ return { key, kind: orderDiffers && parts.length === 1 ? 'order' : 'items', summary: `${key}: ${parts.join(', ')}` };
118
+ }
119
+
120
+ // Lists of scalars (completedTaskUids and the like): set arithmetic.
121
+ if (isPrimitiveList(merged) && isPrimitiveList(other)) {
122
+ const m = new Set(merged.map(canonicalJson));
123
+ const o = new Set(other.map(canonicalJson));
124
+ let plus = 0;
125
+ let minus = 0;
126
+ for (const v of m) if (!o.has(v)) plus += 1;
127
+ for (const v of o) if (!m.has(v)) minus += 1;
128
+ if (!plus && !minus) {
129
+ if (merged.length !== other.length) return { key, kind: 'duplicates', summary: `${key}: same set, ${merged.length} vs ${other.length} entries` };
130
+ return null;
131
+ }
132
+ return { key, kind: 'set', summary: `${key}: +${plus} / -${minus}` };
133
+ }
134
+
135
+ if (canonicalJson(merged) === canonicalJson(other)) return null;
136
+
137
+ // Maps keyed by something (habitLogs, dailyNotes, dayWindows…): say how many.
138
+ // An empty entry and an absent one are the same thing here too: the HealthKit
139
+ // strip leaves a day whose only count it removed as `{}`, and the file may
140
+ // hold that day or not depending on which device last wrote it.
141
+ if (merged && other && typeof merged === 'object' && typeof other === 'object' && !Array.isArray(merged) && !Array.isArray(other)) {
142
+ const keys = new Set([...Object.keys(merged), ...Object.keys(other)]);
143
+ let n = 0;
144
+ for (const k of keys) {
145
+ if (isEmptyish(merged[k]) && isEmptyish(other[k])) continue;
146
+ if (canonicalJson(merged[k]) !== canonicalJson(other[k])) n += 1;
147
+ }
148
+ if (n === 0) return null;
149
+ return { key, kind: 'map', summary: `${key}: ${n} of ${keys.size} entries differ` };
150
+ }
151
+ return { key, kind: 'value', summary: `${key}: differs` };
152
+ }
153
+
154
+ /**
155
+ * Every slice in which `a` differs from `b`.
156
+ *
157
+ * @param {object} a the data about to be written or applied
158
+ * @param {object} b the data already there (file, or local state)
159
+ * @param {{ignoreDropped?: boolean, ignoreKeys?: Iterable<string>}} [opts]
160
+ * ignoreDropped: skip slices `a` does not carry at all (for the apply
161
+ * question: absent keys are left alone); ignoreKeys: slices to leave
162
+ * out altogether (the merge's device-local keys, for the write question:
163
+ * every device keeps its own value, so the file's is nobody's business)
164
+ * @returns {Array<{key: string, kind: string, summary: string}>}
165
+ */
166
+ export function sliceDiffs(a, b, { ignoreDropped = false, ignoreKeys = [] } = {}) {
167
+ const skip = new Set(ignoreKeys);
168
+ const keys = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].sort();
169
+ const out = [];
170
+ for (const k of keys) {
171
+ if (skip.has(k)) continue;
172
+ if (ignoreDropped && a?.[k] === undefined) continue;
173
+ const d = describeSliceDiff(k, a?.[k], b?.[k]);
174
+ if (d) out.push(d);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * The differences a write can actually resolve. An order-only difference in
181
+ * an id-keyed list is not one: the merge keeps each device's own order
182
+ * (mergeArrayById preserves local order and appends remote-only items), so
183
+ * writing it never changes the other device's order, and two devices that
184
+ * order a list differently would rewrite the file in turn forever. Two Macs
185
+ * did, over todayRoutines (2026-10-05). The difference is still reported;
186
+ * it just does not start a write.
187
+ */
188
+ export const writeWorthy = (diffs) => diffs.filter((d) => d.kind !== 'order');
189
+
190
+ /**
191
+ * Runs the merge and explains its outcome, with the write and apply decisions
192
+ * the cycle makes from it.
193
+ *
194
+ * @param {object} args
195
+ * @param {object} args.local this device's payload `data` (buildSyncPayload().data)
196
+ * @param {object} args.remote the file's `data`
197
+ * @param {number} args.retentionDays
198
+ * @param {(local, remote, retentionDays) => {data: object, localChanged: boolean, remoteChanged: boolean, deviceLocalKeys?: string[]}} args.merge
199
+ * @param {(data: object) => object} [args.outgoing] what the transport would
200
+ * actually write (the iCloud HealthKit strip); identity by default
201
+ * @returns {{
202
+ * localChanged: boolean, remoteChanged: boolean, the merge's own flags
203
+ * fileDiffs: Array<{key, kind, summary}>, outgoing vs file
204
+ * deviceDiffs: Array<{key, kind, summary}>, merged vs local (dropped slices ignored)
205
+ * wouldWrite: boolean, a flag AND a file difference a write can resolve
206
+ * wouldApply: boolean, localChanged AND a device difference
207
+ * writeFlagWithoutDiff: boolean, a write flag with no file difference a write could resolve
208
+ * applyFlagWithoutDiff: boolean, an apply flag with no device difference
209
+ * flagWithoutDiff: boolean, either of the two
210
+ * error: string|null,
211
+ * }}
212
+ */
213
+ export function explainSnapshotMerge({ local, remote, retentionDays, merge, outgoing = (d) => d }) {
214
+ const empty = {
215
+ localChanged: false, remoteChanged: false, fileDiffs: [], deviceDiffs: [],
216
+ wouldWrite: false, wouldApply: false,
217
+ writeFlagWithoutDiff: false, applyFlagWithoutDiff: false, flagWithoutDiff: false, error: null,
218
+ };
219
+ let result;
220
+ try {
221
+ result = merge(local, remote, retentionDays);
222
+ } catch (err) {
223
+ return { ...empty, error: err?.message ?? String(err) };
224
+ }
225
+ const merged = result?.data ?? {};
226
+ const localChanged = !!result?.localChanged;
227
+ const remoteChanged = !!result?.remoteChanged;
228
+ let out;
229
+ try { out = outgoing(merged) ?? merged; } catch (err) { return { ...empty, localChanged, remoteChanged, error: err?.message ?? String(err) }; }
230
+ // Device-local keys (the app's merge wrapper, result.deviceLocalKeys) are left out of
231
+ // the file question: the file holds whichever device wrote last, and a
232
+ // write would change what no device reads.
233
+ const fileDiffs = sliceDiffs(out, remote, { ignoreKeys: result?.deviceLocalKeys ?? [] });
234
+ const deviceDiffs = sliceDiffs(merged, local, { ignoreDropped: true });
235
+ const flagged = remoteChanged || localChanged;
236
+ const writable = writeWorthy(fileDiffs);
237
+ // Each flag is judged against its own question. A Mac over a Direct Access
238
+ // folder reported "apply YES" with no device difference under a note about
239
+ // a skipped WRITE (2026-10-08): the note has to name the flag that fired.
240
+ const writeFlagWithoutDiff = remoteChanged && writable.length === 0;
241
+ const applyFlagWithoutDiff = localChanged && deviceDiffs.length === 0;
242
+ return {
243
+ localChanged,
244
+ remoteChanged,
245
+ fileDiffs,
246
+ deviceDiffs,
247
+ wouldWrite: flagged && writable.length > 0,
248
+ wouldApply: localChanged && deviceDiffs.length > 0,
249
+ writeFlagWithoutDiff,
250
+ applyFlagWithoutDiff,
251
+ flagWithoutDiff: writeFlagWithoutDiff || applyFlagWithoutDiff,
252
+ error: null,
253
+ };
254
+ }
package/types/index.d.ts CHANGED
@@ -904,3 +904,136 @@ export function recoverVaultSyncEngine(
904
904
  enrolled: true;
905
905
  deviceId: string;
906
906
  }>;
907
+
908
+ // ---------------------------------------------------------------------------
909
+ // Snapshot-file cycle (2.1.0): iCloud, Direct Access
910
+ // ---------------------------------------------------------------------------
911
+
912
+ /** How a transport's read is classified (classifySnapshotText). */
913
+ export type SnapshotRead =
914
+ | { kind: 'absent' }
915
+ | { kind: 'downloading' }
916
+ | { kind: 'unparseable' }
917
+ | { kind: 'error'; error: string }
918
+ | { kind: 'snapshot'; remote: Record<string, unknown> };
919
+
920
+ export function classifySnapshotText(text: string | null | undefined): SnapshotRead;
921
+
922
+ export interface SnapshotFileTransport {
923
+ /** For log lines. */
924
+ id?: string;
925
+ read(): Promise<string | null>;
926
+ write(text: string): Promise<boolean>;
927
+ /** The storage key this transport stamps on every real read (the seed guard's "has synced before"). */
928
+ lastSyncedKey: string;
929
+ /** Has the user answered the first-run restore prompt. */
930
+ firstRunDecided(): boolean;
931
+ writeThrottleMs: number;
932
+ /** May an undecryptable envelope be overwritten with plaintext (iCloud: yes, a legacy cleanup; a folder: never). */
933
+ allowsPlaintextReseed: boolean;
934
+ /** The per-device switch that seeds and upgrades the file as an envelope. */
935
+ encryptsWrites?(): boolean;
936
+ }
937
+
938
+ export interface SnapshotFileIo {
939
+ buildSyncPayload(): { version: number; lastModified?: string; data: Record<string, unknown> };
940
+ applyEngineData(data: Record<string, unknown>, opts: { allowEmpty: boolean }): void;
941
+ mergeSyncData(local: Record<string, unknown>, remote: Record<string, unknown>, retentionDays: number): {
942
+ data: Record<string, unknown>;
943
+ localChanged: boolean;
944
+ remoteChanged: boolean;
945
+ /** Slices every device keeps its own value of; left out of the write question. */
946
+ deviceLocalKeys?: string[];
947
+ };
948
+ syncRetentionDays: number;
949
+ isEncryptedEnvelope(value: unknown): boolean;
950
+ decryptData(envelope: unknown): Promise<Record<string, unknown>>;
951
+ /** Plaintext payload to envelope; required when a transport encrypts or an envelope is read. */
952
+ encryptData?(payload: Record<string, unknown>): Promise<Record<string, unknown>>;
953
+ /** Can encryptData succeed now (a key or the passphrase is in memory). */
954
+ encryptionReady?(): boolean;
955
+ /** What the transport actually writes, from the plaintext payload (a strip of slices the file must not carry). */
956
+ outgoing?(payload: Record<string, unknown>): Record<string, unknown>;
957
+ storage: Pick<Storage, 'getItem' | 'setItem'>;
958
+ /** When this device itself last changed its data (ISO); absent: every change is relayed. */
959
+ lastLocalEditAt?(): string | null;
960
+ /** Stamped with the time of every apply, for the app's other engines. */
961
+ localModifiedKey?: string;
962
+ /** How many items the app's own store holds (the empty-state guard). */
963
+ localItemCount?(): number;
964
+ /** Does a payload carry anything worth restoring (default payloadHasData). */
965
+ hasData?(data: Record<string, unknown>): boolean;
966
+ /** Stamped as `writtenBy` in the file header; ranks this device's relay. */
967
+ deviceId?: string | (() => string);
968
+ now?(): number;
969
+ log?: Pick<Console, 'warn' | 'error'>;
970
+ }
971
+
972
+ export interface SnapshotFileState {
973
+ missingSince: number;
974
+ lastWriteAt: number;
975
+ lastWrittenAt?: number;
976
+ pendingWrite?: { fingerprint: string; at: number } | null;
977
+ }
978
+
979
+ export type SnapshotFileOutcome =
980
+ | { kind: 'skipped'; reason: 'missing-grace' | 'empty-state-guard' | 'downloading' | 'unparseable' | 'no-data' | 'key-needed' }
981
+ | { kind: 'skipped'; reason: 'encrypted-unreadable'; needsKey: boolean }
982
+ | { kind: 'seeded'; wrote: boolean }
983
+ | { kind: 'reseeded'; wrote: boolean }
984
+ | { kind: 'error'; reason: 'unavailable'; error: string }
985
+ | { kind: 'prompted'; info: { taskCount: number; inboxCount: number; lastModified: string | null; data: Record<string, unknown> } }
986
+ | { kind: 'merged'; localChanged: boolean; remoteChanged: boolean; applied: boolean; wrote: boolean; deferred: boolean; ownEdits: boolean; keyNeeded: boolean };
987
+
988
+ /**
989
+ * One cycle of snapshot-file sync over an injected transport: the seed guard,
990
+ * the first-run prompt, the content gate, the made-here-or-relayed rule with
991
+ * relays staggered by writer rank, and the envelope rules (the file decides,
992
+ * the switch decides the first write). See src/snapshotFileSync.js.
993
+ */
994
+ export function runSnapshotFileCycle(args: {
995
+ transport: SnapshotFileTransport;
996
+ io: SnapshotFileIo;
997
+ state: SnapshotFileState | null | undefined;
998
+ }): Promise<{ state: Required<SnapshotFileState>; outcome: SnapshotFileOutcome }>;
999
+
1000
+ export const RELAY_CONFIRM_MS: number;
1001
+ export const RELAY_STAGGER_MS: number;
1002
+ export const MISSING_GRACE_MS: number;
1003
+
1004
+ export function evaluateMissingSnapshot(s?: { hasSyncedBefore?: boolean; missingSince?: number; now?: number; graceMs?: number }): { skip: boolean; missingSince: number };
1005
+ export function shouldPromptFirstRun(s?: { decided?: boolean; remoteHasData?: boolean; localHasData?: boolean; available?: boolean }): boolean;
1006
+ export function payloadHasData(data: unknown): boolean;
1007
+
1008
+ /** The relay rule: write once the same fingerprint has been seen for `waitMs`; a different one starts over. */
1009
+ export function relayDecision(
1010
+ pending: { fingerprint: string; at: number } | null,
1011
+ fingerprint: string,
1012
+ now: number,
1013
+ waitMs?: number,
1014
+ ): { write: boolean; pending: { fingerprint: string; at: number } };
1015
+ /** RELAY_CONFIRM_MS plus RELAY_STAGGER_MS per writer that sorts before this device among those seen. */
1016
+ export function relayWaitMs(storage: Pick<Storage, 'getItem'>, key: string, self: string | null): number;
1017
+ export function noteWriter(storage: Pick<Storage, 'getItem' | 'setItem'>, key: string, writer: unknown): boolean;
1018
+ export function readWriters(storage: Pick<Storage, 'getItem'>, key: string): string[];
1019
+
1020
+ /** A slice-level difference between two snapshot data objects. */
1021
+ export interface SliceDiff { key: string; kind: string; summary: string }
1022
+ export function canonicalJson(value: unknown): string;
1023
+ export function describeSliceDiff(key: string, merged: unknown, other: unknown): SliceDiff | null;
1024
+ export function sliceDiffs(a: Record<string, unknown> | null | undefined, b: Record<string, unknown> | null | undefined, opts?: { ignoreDropped?: boolean; ignoreKeys?: Iterable<string> }): SliceDiff[];
1025
+ export function writeWorthy(diffs: SliceDiff[]): SliceDiff[];
1026
+ export const PER_MERGE_STAMPS: string[];
1027
+ export function explainSnapshotMerge(args: {
1028
+ local: Record<string, unknown>;
1029
+ remote: Record<string, unknown>;
1030
+ retentionDays: number;
1031
+ merge: SnapshotFileIo['mergeSyncData'];
1032
+ outgoing?: (data: Record<string, unknown>) => Record<string, unknown>;
1033
+ }): {
1034
+ localChanged: boolean; remoteChanged: boolean;
1035
+ fileDiffs: SliceDiff[]; deviceDiffs: SliceDiff[];
1036
+ wouldWrite: boolean; wouldApply: boolean;
1037
+ writeFlagWithoutDiff: boolean; applyFlagWithoutDiff: boolean; flagWithoutDiff: boolean;
1038
+ error: string | null;
1039
+ };