@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.
- package/CHANGELOG.md +211 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/dbEngine.js +334 -52
- package/src/index.js +33 -1
- package/src/snapshotFileSync.js +480 -0
- package/src/snapshotMergeExplain.js +254 -0
- package/types/index.d.ts +207 -2
|
@@ -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
|
@@ -46,7 +46,14 @@ export type SyncErrorCode =
|
|
|
46
46
|
// `status: 429` and `retryInMs`, so any ladder that already treats a real
|
|
47
47
|
// 429 as transient handles it unchanged. Never a hard stop: the window is
|
|
48
48
|
// self-expiring and a success both releases it and halves the escalation.
|
|
49
|
-
| 'RATE_LIMITED'
|
|
49
|
+
| 'RATE_LIMITED'
|
|
50
|
+
// DB transport (1.12.0): this engine's OWN gate refused the call, because
|
|
51
|
+
// the direction's backoff window is open. No request was made. Carries
|
|
52
|
+
// `suppressed: true`, `direction`, `retryAt` and `retryInMs`. Never a hard
|
|
53
|
+
// stop and never a failure — a caller with its own retry ladder should
|
|
54
|
+
// treat it as a scheduled pause and align to `retryAt` rather than
|
|
55
|
+
// escalating a second cooldown on top of it (see isSuppressedError).
|
|
56
|
+
| 'SYNC_SUPPRESSED';
|
|
50
57
|
|
|
51
58
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
52
59
|
|
|
@@ -628,6 +635,18 @@ export interface DbSyncEngineConfig {
|
|
|
628
635
|
// and proceed without verification (unsafe — a wrong-key device could push
|
|
629
636
|
// poison rows). Off by default so the safe behavior is the default.
|
|
630
637
|
allowUnverified?: boolean;
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* WHEN the pull cursor is persisted. Must match what `applyRemoteEntity`
|
|
641
|
+
* promises: if it has NOT made the row durable by the time it resolves —
|
|
642
|
+
* it writes into a per-cycle mirror the caller may discard — then anything
|
|
643
|
+
* but `'end-of-pull'` or `'caller'` loses rows on a mid-pagination failure.
|
|
644
|
+
*
|
|
645
|
+
* An unrecognised value is refused at construction, not defaulted.
|
|
646
|
+
*
|
|
647
|
+
* @default 'end-of-pull'
|
|
648
|
+
*/
|
|
649
|
+
pullCursorCommit?: PullCursorCommit;
|
|
631
650
|
}
|
|
632
651
|
|
|
633
652
|
/** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */
|
|
@@ -715,9 +734,22 @@ export interface DbSyncEngine {
|
|
|
715
734
|
transportMode: 'database';
|
|
716
735
|
sync(): Promise<DbSyncResult>;
|
|
717
736
|
dbSyncCycle(): Promise<DbSyncResult>;
|
|
737
|
+
/**
|
|
738
|
+
* Pushes the dirty set. Enforces the push backoff window and the credential
|
|
739
|
+
* halt for EVERY caller (1.12.0), not only dbSyncCycle: rejects with
|
|
740
|
+
* `SYNC_SUPPRESSED` while the window is open and with `CREDENTIAL_INVALID`
|
|
741
|
+
* while the device is halted, in both cases without touching the network.
|
|
742
|
+
*/
|
|
718
743
|
pushDirtyRows(): Promise<{ written: number; deleted: number; maxSeq: number }>;
|
|
744
|
+
/** As pushDirtyRows, against the pull window. */
|
|
719
745
|
pullRemoteChanges(): Promise<DbSyncResult & { maxSeq: number; appliedRemote: boolean }>;
|
|
720
|
-
|
|
746
|
+
/**
|
|
747
|
+
* Best-effort cursor report. Never throws and never opens a window. Halted
|
|
748
|
+
* devices report nothing and get `{ updated: false, suppressed: true }`;
|
|
749
|
+
* deliberately NOT gated on the pull window, because whether a given cycle
|
|
750
|
+
* has anything to report is the caller's composition decision.
|
|
751
|
+
*/
|
|
752
|
+
updateDeviceCursor(): Promise<{ updated: boolean; suppressed?: boolean }>;
|
|
721
753
|
ensureRootKey(): Promise<void>;
|
|
722
754
|
|
|
723
755
|
markDirty(entityId: string): void;
|
|
@@ -773,8 +805,48 @@ export interface DbSyncEngine {
|
|
|
773
805
|
vault: VaultClient;
|
|
774
806
|
}
|
|
775
807
|
|
|
808
|
+
/**
|
|
809
|
+
* When the pull cursor is persisted. Chosen to match what the caller's
|
|
810
|
+
* `applyRemoteEntity` promises: an unrecognised value is refused at
|
|
811
|
+
* construction rather than defaulted.
|
|
812
|
+
*
|
|
813
|
+
* - `'end-of-pull'` (default) — once, after a fully successful pagination
|
|
814
|
+
* loop. Safe for every caller, including one that has not thought about it.
|
|
815
|
+
* - `'per-page'` — after each completed page. Requires an apply that is
|
|
816
|
+
* durable on return. Lets a large backlog resume from the last good page.
|
|
817
|
+
* - `'caller'` — the engine never persists; `pullRemoteChanges` returns
|
|
818
|
+
* `maxSeq` and the caller commits it with `setHighWaterMark`.
|
|
819
|
+
*/
|
|
820
|
+
export type PullCursorCommit = 'end-of-pull' | 'per-page' | 'caller';
|
|
821
|
+
|
|
776
822
|
export function createDbSyncEngine(config: DbSyncEngineConfig): DbSyncEngine;
|
|
777
823
|
|
|
824
|
+
/**
|
|
825
|
+
* True when an error is the engine's own gate refusing a call (`SYNC_SUPPRESSED`)
|
|
826
|
+
* rather than anything the server said.
|
|
827
|
+
*
|
|
828
|
+
* For callers that keep their own retry ladder or circuit breaker: treating a
|
|
829
|
+
* suppression as a failure is how two independent brakes compound, each
|
|
830
|
+
* looking correct in isolation while the device waits far longer than either
|
|
831
|
+
* intends. Branch on this (or on `err.suppressed`) and align to `err.retryAt`.
|
|
832
|
+
*/
|
|
833
|
+
export function isSuppressedError(err: unknown): boolean;
|
|
834
|
+
|
|
835
|
+
/** The shape of a `SYNC_SUPPRESSED` rejection. */
|
|
836
|
+
export interface SyncSuppressedError extends Error {
|
|
837
|
+
code: 'SYNC_SUPPRESSED';
|
|
838
|
+
suppressed: true;
|
|
839
|
+
/** Which half was refused. */
|
|
840
|
+
direction: 'push' | 'pull';
|
|
841
|
+
/** Why the window is open. */
|
|
842
|
+
reason: 'quota' | 'auth' | 'transport' | null;
|
|
843
|
+
/** The typed error that opened the window. */
|
|
844
|
+
causeCode: string | null;
|
|
845
|
+
/** Epoch ms the window closes. */
|
|
846
|
+
retryAt: number;
|
|
847
|
+
retryInMs: number;
|
|
848
|
+
}
|
|
849
|
+
|
|
778
850
|
/**
|
|
779
851
|
* Package-owned stable device identifier: generated once (crypto.randomUUID)
|
|
780
852
|
* and persisted under `{prefix}-device-id`. Used for both the device cursor
|
|
@@ -832,3 +904,136 @@ export function recoverVaultSyncEngine(
|
|
|
832
904
|
enrolled: true;
|
|
833
905
|
deviceId: string;
|
|
834
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
|
+
};
|