@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,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
- updateDeviceCursor(): Promise<{ updated: boolean }>;
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
+ };