@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 +30 -0
- package/package.json +1 -1
- package/src/index.js +25 -0
- package/src/snapshotFileSync.js +480 -0
- package/src/snapshotMergeExplain.js +254 -0
- package/types/index.d.ts +133 -0
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
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
|
+
};
|