@glance-apps/sync 1.11.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.
@@ -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
+ }