@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
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
|