@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 CHANGED
@@ -7,6 +7,217 @@ 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
+
40
+ ## [2.0.0] - 2026-08-30
41
+
42
+ > ### ⚠️ ACTION REQUIRED for lastGLANCE and lifeGLANCE
43
+ >
44
+ > **Add `pullCursorCommit: 'per-page'` to your `createDbSyncEngine` config when
45
+ > you take this release.** Without it you lose 1.10.0's mid-pagination resume
46
+ > and silently regain the convergence bug it fixed: a large backlog on a flaky
47
+ > connection re-downloads from the start on every failure instead of resuming
48
+ > from the last good page.
49
+ >
50
+ > It will not look like a config gap. It will look like a network problem,
51
+ > weeks after the bump, and nobody will connect the two. One line prevents it.
52
+
53
+ **Should you take this release?** Yes, and read this entry before you do — it is
54
+ the only release so far that changes what an existing caller gets **without that
55
+ caller changing any code**.
56
+
57
+ Since 1.10.0, `pullRemoteChanges` has persisted the pull cursor **per page**, on
58
+ the assumption that a row is durable the moment `applyRemoteEntity` returns.
59
+ That is true for a caller writing straight into its store. It is false for a
60
+ caller applying into a per-cycle mirror it discards on failure
61
+ (commit-only-on-success): those rows are consumed, the cursor advances past
62
+ them, they are never committed, an incremental pull never re-lists them, and a
63
+ glitch heal cannot reach them because they were never in a snapshot. That is
64
+ permanent row loss, and it was demonstrated as such.
65
+
66
+ The assumption was stated only in a source comment. The config contract
67
+ documenting `applyRemoteEntity` said nothing about durability at all. **A
68
+ caller cannot honour a contract it is never shown** — so it is now a declared,
69
+ validated mode, and the default is the safe one.
70
+
71
+ ### Changed — BREAKING
72
+
73
+ - **`pullCursorCommit` now controls when the pull cursor is persisted, and it
74
+ defaults to `'end-of-pull'`.** Previously the engine always behaved as
75
+ `'per-page'`, with no way to say otherwise.
76
+
77
+ | Mode | When the cursor advances | For |
78
+ |---|---|---|
79
+ | `'end-of-pull'` **(new default)** | Once, after a fully successful pagination loop | Any caller that has not thought about it |
80
+ | `'per-page'` | After each completed page — 1.10.0 behaviour | A caller whose apply is durable on return |
81
+ | `'caller'` | Never; the engine writes nothing | A commit-only-on-success composer |
82
+
83
+ Under `'end-of-pull'`, a mid-pagination failure re-pulls from where the run
84
+ started. Re-applying already-applied rows is idempotent under entity-grain
85
+ LWW, so the cost is bandwidth; the cost of the old default, for the wrong
86
+ caller, was rows.
87
+
88
+ In `'caller'` mode `pullRemoteChanges` returns `maxSeq` and the caller
89
+ commits it with `setHighWaterMark` once its own state is durable. No new API
90
+ was needed — `setHighWaterMark` was already public. `dbSyncCycle` commits for
91
+ itself on a fully successful cycle, so declaring `'caller'` and continuing to
92
+ use the cycle still converges.
93
+
94
+ - **An unrecognised `pullCursorCommit` is REFUSED at construction**, not
95
+ defaulted. `'perPage'`, `'per_page'` or `'PER-PAGE'` throw with a message
96
+ naming every valid mode and the value actually passed. A typo silently
97
+ falling back to a default is precisely the failure this contract exists to
98
+ remove: it would quietly restore the unsafe behaviour on a caller that was
99
+ trying to opt out of it.
100
+
101
+ ### Why this is 2.0.0
102
+
103
+ No API signature changed, and by the letter of this package's old versioning
104
+ note — "breaking changes to the public API or the sync envelope schema" — this
105
+ would have been a minor. That note was too narrow, and has been widened
106
+ (see the README): **a behavioural default that changes what an existing caller
107
+ gets without that caller changing code is breaking**, because the version
108
+ number is the only signal that cannot be skipped. Under-signalling here costs a
109
+ field bug that presents as a flaky connection; over-signalling costs a digit.
110
+
111
+ ### Why the default was flipped, and what argued against it
112
+
113
+ The default is what the next composed caller inherits by saying nothing, and
114
+ the next composed caller is an Obsidian plugin. Inheriting the unsafe path by
115
+ saying nothing is exactly what put a data-loss bug in front of dayGLANCE.
116
+
117
+ The alternative was real and was weighed: keep `'per-page'` as the default and
118
+ require an explicit opt-out from callers needing the safe path. It costs the
119
+ shipping consumers nothing and needs no major bump. It loses on the one point
120
+ that matters — the unsafe path stays the thing you get by default — and the
121
+ tiebreaker was sequencing rather than principle: all three consumers are
122
+ touching engine config for 1.12.0 anyway, so the safe default costs them one
123
+ extra line in a change they are already making. Had those landed far apart, the
124
+ decision would have gone the other way.
125
+
126
+ ### Notes
127
+
128
+ - **dayGLANCE's cursor rollback stays.** It covers two loss windows and
129
+ `'end-of-pull'` closes only the first. The second — the pull succeeded but a
130
+ later step (push, heal, commit) threw, so the mirror was discarded anyway —
131
+ is invisible to this package. Only `'caller'` mode closes both, and adopting
132
+ it is dayGLANCE's call on its own schedule.
133
+ - **Per-page convergence does not come back for dayGLANCE under any mode.** It
134
+ needs per-page *durable* commits, which an all-or-nothing merge-aware commit
135
+ does not provide.
136
+ - Not closed here: the cycle-only `syncing` re-entrancy guard (see 1.12.0).
137
+
138
+ ## [1.12.0] - 2026-08-30
139
+
140
+ **Should you take this release?** Yes if anything in your app calls
141
+ `pushDirtyRows`, `pullRemoteChanges` or `updateDeviceCursor` directly instead of
142
+ `dbSyncCycle`. Until now the two tiers had unequal safety: the cycle carried
143
+ backoff, over-quota suppression and the credential halt, and the primitives
144
+ beneath it carried none. A caller composing its own cycle silently opted out of
145
+ all three, with no signal that it had. This release moves enforcement down into
146
+ the primitives, so every caller inherits it by construction. **If you only ever
147
+ call `dbSyncCycle`, nothing about this release changes what your app does.**
148
+
149
+ ### Changed
150
+
151
+ - **The three primitives now enforce their own protections.**
152
+ `pushDirtyRows` and `pullRemoteChanges` refuse to run while their direction's
153
+ backoff window is open, rejecting with a typed `SYNC_SUPPRESSED` error
154
+ *before* any network call, and they open, escalate and clear those windows
155
+ themselves. Both also refuse while the device is under the credential halt,
156
+ and both now SET the halt when a rejection reaches them — a guard over a halt
157
+ that nothing sets never fires, so it took both halves.
158
+ - **`dbSyncCycle` delegates entirely downward.** It no longer checks a window
159
+ or records a failure; it distinguishes "my own gate refused this" from "the
160
+ server refused this" and reports as before. Enforcing in both places would
161
+ have **doubled the escalation** — `strikes` incrementing once in the
162
+ primitive and once in the cycle turns the ladder into 30s, 120s, 480s instead
163
+ of 30, 60, 120. The alternative shape (primitives no-op when called from
164
+ inside the cycle) was rejected: it makes correctness depend on a caller
165
+ passing a flag, which is the exact defect this release removes, relocated one
166
+ layer down.
167
+ - **`updateDeviceCursor` is deliberately different**: halt-gated, but *not*
168
+ window-gated, never recording and never throwing. It reports a refusal as
169
+ `{ updated: false, suppressed: true }`. It cannot throw because callers await
170
+ it mid-cycle inside their own try blocks, and it is not window-gated because
171
+ the pull now records its own failure — so the window is already open by the
172
+ time the cursor report runs on the very cycle the pull was attempted, and
173
+ gating there would silently undo 1.10.0's decoupling ("the cursor report runs
174
+ whenever the pull was ATTEMPTED, success or failure"). Whether a cycle has
175
+ anything to report is a composition decision that stays with the caller.
176
+
177
+ ### Added
178
+
179
+ - `SYNC_SUPPRESSED`: the typed rejection from the engine's own gate. It carries
180
+ `suppressed: true`, `direction` (`'push' | 'pull'`), `reason`, `causeCode`,
181
+ and `retryAt` / `retryInMs`.
182
+ - `isSuppressedError(err)` — exported so a caller can tell a pause from a
183
+ failure **without string matching and without hardcoding a code literal**.
184
+
185
+ ### Notes for callers with their own retry ladder or circuit breaker
186
+
187
+ - **Do not treat `SYNC_SUPPRESSED` as a failure.** This is the one integration
188
+ hazard in the release. An app that escalates its own cooldown on every thrown
189
+ error will escalate on a pause this engine already scheduled — two brakes
190
+ compounding, each correct in isolation, with the device waiting far longer
191
+ than either layer intends. Branch on `isSuppressedError(err)` (or
192
+ `err.suppressed`) and align to `err.retryAt` rather than adding a second
193
+ window on top of it.
194
+ - **This cannot be made structurally impossible from inside the package.** The
195
+ app owns its breaker and reacts to what it catches; the only way to remove
196
+ the caller's judgement entirely would be for the package to own the whole
197
+ retry, which means adopting `dbSyncCycle` — exactly what composed callers
198
+ compose to avoid. So the signal is made as unmissable as we can: a typed
199
+ code, a redundant boolean, an exported predicate, and a retry deadline to
200
+ align to.
201
+ - **Why a throw and not a skipped return value.** A return lets a caller that
202
+ ignores it treat a suppressed cycle as a successful one — committing state
203
+ built from a pull that never ran, and resetting its own breaker on a cycle
204
+ that did nothing. Throwing converts the worst failure mode from "silently
205
+ wrong" to "loudly refused". The argument the other way is real: a return
206
+ needs no `catch`, and a caller that forgets to special-case the code turns a
207
+ deliberate pause into what looks like an error. The `RATE_LIMITED` precedent
208
+ from 1.11.0 settled it — one shape for "the client refused this", not two.
209
+ - **A direct call that fails now opens a window**, so an immediate retry of a
210
+ failed primitive is refused rather than re-sent. Retrying after `retryAt` is
211
+ the supported pattern; this is what the cycle has always done.
212
+
213
+ ### Notes
214
+
215
+ - **Not closed by this release** (reported, deliberately unfixed): the
216
+ `syncing` re-entrancy guard is still cycle-only, so a composed caller can
217
+ overlap primitive calls. See the PR for what that does and why it is a
218
+ different shape of problem from the two this release addresses.
219
+ - The cursor durability contract is Phase 4b and lands separately.
220
+
10
221
  ## [1.11.0] - 2026-08-30
11
222
 
12
223
  **Should you take this release?** Yes if you use the GLANCEvault transport, and
package/README.md CHANGED
@@ -238,6 +238,8 @@ TypeScript declarations for all of the above are in `types/index.d.ts`.
238
238
 
239
239
  This package follows [Semantic Versioning](https://semver.org/). Breaking changes to the public API or the sync envelope schema will result in a major version bump.
240
240
 
241
+ A **change to a behavioural default** counts as breaking and gets a major bump too, even when no API signature changes. The test is whether a dependent that changes none of its own code can get different behaviour by taking the release: if it can, the version number has to say so, because the changelog is the thing people skip. 2.0.0 flipped the pull-cursor default from per-page to end-of-pull on exactly this basis.
242
+
241
243
  ## License
242
244
 
243
245
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.11.0",
3
+ "version": "2.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",