@glance-apps/sync 1.10.0 → 2.0.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,306 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.0] - 2026-08-30
11
+
12
+ > ### ⚠️ ACTION REQUIRED for lastGLANCE and lifeGLANCE
13
+ >
14
+ > **Add `pullCursorCommit: 'per-page'` to your `createDbSyncEngine` config when
15
+ > you take this release.** Without it you lose 1.10.0's mid-pagination resume
16
+ > and silently regain the convergence bug it fixed: a large backlog on a flaky
17
+ > connection re-downloads from the start on every failure instead of resuming
18
+ > from the last good page.
19
+ >
20
+ > It will not look like a config gap. It will look like a network problem,
21
+ > weeks after the bump, and nobody will connect the two. One line prevents it.
22
+
23
+ **Should you take this release?** Yes, and read this entry before you do — it is
24
+ the only release so far that changes what an existing caller gets **without that
25
+ caller changing any code**.
26
+
27
+ Since 1.10.0, `pullRemoteChanges` has persisted the pull cursor **per page**, on
28
+ the assumption that a row is durable the moment `applyRemoteEntity` returns.
29
+ That is true for a caller writing straight into its store. It is false for a
30
+ caller applying into a per-cycle mirror it discards on failure
31
+ (commit-only-on-success): those rows are consumed, the cursor advances past
32
+ them, they are never committed, an incremental pull never re-lists them, and a
33
+ glitch heal cannot reach them because they were never in a snapshot. That is
34
+ permanent row loss, and it was demonstrated as such.
35
+
36
+ The assumption was stated only in a source comment. The config contract
37
+ documenting `applyRemoteEntity` said nothing about durability at all. **A
38
+ caller cannot honour a contract it is never shown** — so it is now a declared,
39
+ validated mode, and the default is the safe one.
40
+
41
+ ### Changed — BREAKING
42
+
43
+ - **`pullCursorCommit` now controls when the pull cursor is persisted, and it
44
+ defaults to `'end-of-pull'`.** Previously the engine always behaved as
45
+ `'per-page'`, with no way to say otherwise.
46
+
47
+ | Mode | When the cursor advances | For |
48
+ |---|---|---|
49
+ | `'end-of-pull'` **(new default)** | Once, after a fully successful pagination loop | Any caller that has not thought about it |
50
+ | `'per-page'` | After each completed page — 1.10.0 behaviour | A caller whose apply is durable on return |
51
+ | `'caller'` | Never; the engine writes nothing | A commit-only-on-success composer |
52
+
53
+ Under `'end-of-pull'`, a mid-pagination failure re-pulls from where the run
54
+ started. Re-applying already-applied rows is idempotent under entity-grain
55
+ LWW, so the cost is bandwidth; the cost of the old default, for the wrong
56
+ caller, was rows.
57
+
58
+ In `'caller'` mode `pullRemoteChanges` returns `maxSeq` and the caller
59
+ commits it with `setHighWaterMark` once its own state is durable. No new API
60
+ was needed — `setHighWaterMark` was already public. `dbSyncCycle` commits for
61
+ itself on a fully successful cycle, so declaring `'caller'` and continuing to
62
+ use the cycle still converges.
63
+
64
+ - **An unrecognised `pullCursorCommit` is REFUSED at construction**, not
65
+ defaulted. `'perPage'`, `'per_page'` or `'PER-PAGE'` throw with a message
66
+ naming every valid mode and the value actually passed. A typo silently
67
+ falling back to a default is precisely the failure this contract exists to
68
+ remove: it would quietly restore the unsafe behaviour on a caller that was
69
+ trying to opt out of it.
70
+
71
+ ### Why this is 2.0.0
72
+
73
+ No API signature changed, and by the letter of this package's old versioning
74
+ note — "breaking changes to the public API or the sync envelope schema" — this
75
+ would have been a minor. That note was too narrow, and has been widened
76
+ (see the README): **a behavioural default that changes what an existing caller
77
+ gets without that caller changing code is breaking**, because the version
78
+ number is the only signal that cannot be skipped. Under-signalling here costs a
79
+ field bug that presents as a flaky connection; over-signalling costs a digit.
80
+
81
+ ### Why the default was flipped, and what argued against it
82
+
83
+ The default is what the next composed caller inherits by saying nothing, and
84
+ the next composed caller is an Obsidian plugin. Inheriting the unsafe path by
85
+ saying nothing is exactly what put a data-loss bug in front of dayGLANCE.
86
+
87
+ The alternative was real and was weighed: keep `'per-page'` as the default and
88
+ require an explicit opt-out from callers needing the safe path. It costs the
89
+ shipping consumers nothing and needs no major bump. It loses on the one point
90
+ that matters — the unsafe path stays the thing you get by default — and the
91
+ tiebreaker was sequencing rather than principle: all three consumers are
92
+ touching engine config for 1.12.0 anyway, so the safe default costs them one
93
+ extra line in a change they are already making. Had those landed far apart, the
94
+ decision would have gone the other way.
95
+
96
+ ### Notes
97
+
98
+ - **dayGLANCE's cursor rollback stays.** It covers two loss windows and
99
+ `'end-of-pull'` closes only the first. The second — the pull succeeded but a
100
+ later step (push, heal, commit) threw, so the mirror was discarded anyway —
101
+ is invisible to this package. Only `'caller'` mode closes both, and adopting
102
+ it is dayGLANCE's call on its own schedule.
103
+ - **Per-page convergence does not come back for dayGLANCE under any mode.** It
104
+ needs per-page *durable* commits, which an all-or-nothing merge-aware commit
105
+ does not provide.
106
+ - Not closed here: the cycle-only `syncing` re-entrancy guard (see 1.12.0).
107
+
108
+ ## [1.12.0] - 2026-08-30
109
+
110
+ **Should you take this release?** Yes if anything in your app calls
111
+ `pushDirtyRows`, `pullRemoteChanges` or `updateDeviceCursor` directly instead of
112
+ `dbSyncCycle`. Until now the two tiers had unequal safety: the cycle carried
113
+ backoff, over-quota suppression and the credential halt, and the primitives
114
+ beneath it carried none. A caller composing its own cycle silently opted out of
115
+ all three, with no signal that it had. This release moves enforcement down into
116
+ the primitives, so every caller inherits it by construction. **If you only ever
117
+ call `dbSyncCycle`, nothing about this release changes what your app does.**
118
+
119
+ ### Changed
120
+
121
+ - **The three primitives now enforce their own protections.**
122
+ `pushDirtyRows` and `pullRemoteChanges` refuse to run while their direction's
123
+ backoff window is open, rejecting with a typed `SYNC_SUPPRESSED` error
124
+ *before* any network call, and they open, escalate and clear those windows
125
+ themselves. Both also refuse while the device is under the credential halt,
126
+ and both now SET the halt when a rejection reaches them — a guard over a halt
127
+ that nothing sets never fires, so it took both halves.
128
+ - **`dbSyncCycle` delegates entirely downward.** It no longer checks a window
129
+ or records a failure; it distinguishes "my own gate refused this" from "the
130
+ server refused this" and reports as before. Enforcing in both places would
131
+ have **doubled the escalation** — `strikes` incrementing once in the
132
+ primitive and once in the cycle turns the ladder into 30s, 120s, 480s instead
133
+ of 30, 60, 120. The alternative shape (primitives no-op when called from
134
+ inside the cycle) was rejected: it makes correctness depend on a caller
135
+ passing a flag, which is the exact defect this release removes, relocated one
136
+ layer down.
137
+ - **`updateDeviceCursor` is deliberately different**: halt-gated, but *not*
138
+ window-gated, never recording and never throwing. It reports a refusal as
139
+ `{ updated: false, suppressed: true }`. It cannot throw because callers await
140
+ it mid-cycle inside their own try blocks, and it is not window-gated because
141
+ the pull now records its own failure — so the window is already open by the
142
+ time the cursor report runs on the very cycle the pull was attempted, and
143
+ gating there would silently undo 1.10.0's decoupling ("the cursor report runs
144
+ whenever the pull was ATTEMPTED, success or failure"). Whether a cycle has
145
+ anything to report is a composition decision that stays with the caller.
146
+
147
+ ### Added
148
+
149
+ - `SYNC_SUPPRESSED`: the typed rejection from the engine's own gate. It carries
150
+ `suppressed: true`, `direction` (`'push' | 'pull'`), `reason`, `causeCode`,
151
+ and `retryAt` / `retryInMs`.
152
+ - `isSuppressedError(err)` — exported so a caller can tell a pause from a
153
+ failure **without string matching and without hardcoding a code literal**.
154
+
155
+ ### Notes for callers with their own retry ladder or circuit breaker
156
+
157
+ - **Do not treat `SYNC_SUPPRESSED` as a failure.** This is the one integration
158
+ hazard in the release. An app that escalates its own cooldown on every thrown
159
+ error will escalate on a pause this engine already scheduled — two brakes
160
+ compounding, each correct in isolation, with the device waiting far longer
161
+ than either layer intends. Branch on `isSuppressedError(err)` (or
162
+ `err.suppressed`) and align to `err.retryAt` rather than adding a second
163
+ window on top of it.
164
+ - **This cannot be made structurally impossible from inside the package.** The
165
+ app owns its breaker and reacts to what it catches; the only way to remove
166
+ the caller's judgement entirely would be for the package to own the whole
167
+ retry, which means adopting `dbSyncCycle` — exactly what composed callers
168
+ compose to avoid. So the signal is made as unmissable as we can: a typed
169
+ code, a redundant boolean, an exported predicate, and a retry deadline to
170
+ align to.
171
+ - **Why a throw and not a skipped return value.** A return lets a caller that
172
+ ignores it treat a suppressed cycle as a successful one — committing state
173
+ built from a pull that never ran, and resetting its own breaker on a cycle
174
+ that did nothing. Throwing converts the worst failure mode from "silently
175
+ wrong" to "loudly refused". The argument the other way is real: a return
176
+ needs no `catch`, and a caller that forgets to special-case the code turns a
177
+ deliberate pause into what looks like an error. The `RATE_LIMITED` precedent
178
+ from 1.11.0 settled it — one shape for "the client refused this", not two.
179
+ - **A direct call that fails now opens a window**, so an immediate retry of a
180
+ failed primitive is refused rather than re-sent. Retrying after `retryAt` is
181
+ the supported pattern; this is what the cycle has always done.
182
+
183
+ ### Notes
184
+
185
+ - **Not closed by this release** (reported, deliberately unfixed): the
186
+ `syncing` re-entrancy guard is still cycle-only, so a composed caller can
187
+ overlap primitive calls. See the PR for what that does and why it is a
188
+ different shape of problem from the two this release addresses.
189
+ - The cursor durability contract is Phase 4b and lands separately.
190
+
191
+ ## [1.11.0] - 2026-08-30
192
+
193
+ **Should you take this release?** Yes if you use the GLANCEvault transport, and
194
+ immediately if anything in your bundle writes to it on a timer. This release is
195
+ the client-side answer to a 2026-08-30 incident in which an intent-cleanup loop
196
+ in the dayGLANCE Obsidian plugin deleted rows that were already tombstones. The
197
+ server re-tombstoned on every call — fresh seq, fresh SSE nudge — which put the
198
+ row back above the caller's cursor and fed the loop its own output. It ran for
199
+ hours and saturated the server's per-IP rate budget, and it was **invisible**:
200
+ every delete was fire-and-forget behind a `.catch(() => {})`, so nothing failed
201
+ loudly enough to notice. Two patches landed elsewhere (the plugin no longer
202
+ deletes tombstones; the vault's `softDeleteRow` is now idempotent). This release
203
+ adds the protection and the visibility **by construction**, so no caller has to
204
+ remember them and no loop can be silent. No server changes: the wire contract is
205
+ untouched.
206
+
207
+ ### Added
208
+
209
+ - **The intents transport.** `intentsBatch(accountId, events)` and
210
+ `intentsList(accountId, { since, limit })` wrap the server's existing
211
+ `/intents/*` endpoints, so apps can drop their hand-rolled raw-fetch
212
+ transports and inherit this client's auth, error classification, brake and
213
+ meter. `intentsBatch` is insert-only server-side — a re-sent `eventId` is a
214
+ no-op — so retrying an ambiguous failure is safe by construction.
215
+ - **Pure transport**: the `envelope` is opaque here and is never decoded or
216
+ inspected. The codec, and the per-envelope key derivation it needs, stays
217
+ in `@glance-apps/intents`; this package carries the transport without
218
+ carrying the crypto.
219
+ - **A device-wide request brake.** Any real 429 now pauses *every* vault
220
+ request in the bundle realm: 30s, doubling per burst to a 10-minute ceiling,
221
+ one arming per burst (a concurrent fan-out that all meets the limiter costs
222
+ one escalation step, not ten). While paused, calls fail fast — **before
223
+ touching the network** — with a `VaultError` carrying `status: 429`,
224
+ `code: 'RATE_LIMITED'` and `retryInMs`.
225
+ - It is at **module scope, not per client instance**, deliberately: the thing
226
+ it protects is shared by every client in the process (the server's per-IP
227
+ budget), so the state that models it belongs to the realm. A separately
228
+ bundled copy — the Obsidian plugin's, say — is its own realm, which is
229
+ correct: separate process, separate traffic.
230
+ - **Decay, never amnesty.** A 2xx releases the pause but only *halves* the
231
+ escalation memory (below the 30s base it drains to zero). This is the fix
232
+ for the earlier app-side brake, which reset escalation to zero on *any*
233
+ success: on a saturated shared budget an occasional cheap request slips
234
+ into a fresh limiter window and returns 200, and each lucky 200 wiped the
235
+ whole 30s→480s escalation and re-licensed full cadence. Halving keeps the
236
+ storm's level in memory — the next 429 re-arms at 2× what is left — while a
237
+ genuine recovery still drains to nothing in a few quiet successes.
238
+ - Reads: `isVaultRateLimited()` and `vaultBrakeStatus()`, for callers that
239
+ prefer to sit a whole cycle out pre-flight. Escape hatch:
240
+ `createVaultClient({ brake: false })` opts a client out of both the gate
241
+ and the arming.
242
+ - **A budget meter.** Requests are counted per rolling minute and attributed by
243
+ method name. Crossing a soft threshold (default 300/min — half the server's
244
+ default per-IP budget, configurable) logs **one** line per window naming the
245
+ top contributors: `[vault] budget: 412 requests in the last minute (soft
246
+ limit 300) — top: intentsList 210, batch 130, deleteRow 72`. Visibility only;
247
+ it never throttles. Its whole argument is that it needs no theory about *why*
248
+ a loop is running, so the next storm is an attributed event on day one rather
249
+ than an afternoon of archaeology.
250
+ - **A write-loop detector — the success-side signal.** Failure visibility
251
+ already existed; what was missing on the day was the signal for everything
252
+ "succeeding" pathologically. The client now keeps a bounded per-`entityId`
253
+ history across `batch` upserts and `deleteRow`, and warns loudly (once per id
254
+ per window) when four **redundant** writes to one id land inside ten minutes.
255
+ Diagnostic only — never a brake.
256
+ - A write is redundant when it cannot have changed the row's state from what
257
+ the previous write to that id left it. Three shapes qualify: a **polarity
258
+ flip** (the delete-and-resupply loop), a **repeat delete** (re-deleting a
259
+ row that is already a tombstone — the incident's own shape: same polarity
260
+ every time, no content at all), and an **identical-content rewrite**.
261
+ - **Fails open by construction**: the rule needs repeated events on the
262
+ *same* id, so a first sync, a large import, or a bulk cleanup deleting many
263
+ different ids — many ids, one event each — can never trigger it, however
264
+ large. A bounded retry of one failed delete does not reach the threshold
265
+ either.
266
+ - Both thresholds are tunable via `configureVaultDiagnostics`. Four redundant
267
+ writes means five events (upsert/delete/upsert/delete/upsert, or five
268
+ deletes of one tombstone), which no human edit pattern produces and every
269
+ delete/resupply or re-tombstone loop produces within seconds; ten minutes
270
+ is long enough to catch a loop running on a 60s sync cycle and short enough
271
+ that a week of ordinary edits never accumulates into it.
272
+ - `getVaultStats()` returns all three in one read: brake status, the last
273
+ rolling minute of requests by method, and the current write-loop suspects.
274
+ `configureVaultDiagnostics(options)` tunes the visibility-only thresholds and
275
+ the log sink; `resetVaultDiagnostics()` clears everything (for tests).
276
+
277
+ ### Changed
278
+
279
+ - Every `createVaultClient` method now passes through the brake and the meter.
280
+ The only behaviour change a healthy caller can observe is the new
281
+ `RATE_LIMITED` rejection while a window is open — and only after the server
282
+ has already answered a real 429.
283
+ - A **429 arms the brake only when it is a rate-limiter hit**, never when it is
284
+ an over-quota rejection. The server uses 429 for both and they call for
285
+ opposite handling: a limiter hit means "stop asking for a while" (exactly
286
+ what the brake does), while a quota rejection means the account is full,
287
+ clears only when an operator acts, and already has its own self-resuming
288
+ window in the DB engine. Gating on it would hide `QUOTA_EXCEEDED` and its
289
+ descriptor behind `RATE_LIMITED` and leave apps unable to render "X of Y
290
+ used". The quota shape is recognised as narrowly as before, so anything that
291
+ is not unmistakably a quota body is treated as a limiter hit.
292
+
293
+ ### Notes
294
+
295
+ - **Not gated**: `fetchVaultHealth` and `enrollVaultDevice`. Both are
296
+ pre-credential, user-initiated, one-shot calls — `/healthz` is the
297
+ unauthenticated probe you reach for *while* diagnosing a stuck client, and
298
+ refusing it during a braked window would take away the diagnostic just when
299
+ it is wanted.
300
+ - **For lastGLANCE / lifeGLANCE**: safe to take at leisure (semver-minor).
301
+ During a braked window, client calls fail fast with `VaultError { status:
302
+ 429, code: 'RATE_LIMITED' }` instead of hitting the wire; any ladder that
303
+ already treats a real 429 as transient — they all do — handles it unchanged.
304
+ The meter and the write-loop warnings come along for free.
305
+ - An AES-GCM envelope carries a fresh random IV, so identical plaintext does
306
+ not produce an identical envelope: for encrypted payloads it is the polarity
307
+ flip that fires the detector, and the identical-content rule covers plaintext
308
+ and deterministic payloads.
309
+
10
310
  ## [1.10.0] - 2026-08-01
11
311
 
12
312
  **Should you take this release?** Yes, for any deployment using the database
package/README.md CHANGED
@@ -158,6 +158,71 @@ Pass `encryptionEnabled: true` in the sync config object stored via `engine.setC
158
158
  | `createProviders(config)` | Creates cloud sync provider objects (`nextcloud`, `koofr`, `webdav`). |
159
159
  | `normalizeEtag(raw)` | Normalizes a raw ETag header value for If-Match use: strips a weak-validator prefix (`W/"abc"` → `"abc"`) and the content-coding suffixes some servers append inside the quotes (`"abc-gzip"` / `"abc-br"` → `"abc"`). Quotes are preserved; `null`/`undefined` pass through unchanged. |
160
160
 
161
+ ### GLANCEvault client
162
+
163
+ | Export | Description |
164
+ |--------|-------------|
165
+ | `createVaultClient(config)` | HTTP client for a GLANCEvault server. `config` is `{ vaultUrl, vaultToken, fetchImpl?, brake? }`. Every method carries the Bearer token, classifies errors uniformly (`CREDENTIAL_INVALID`, `QUOTA_EXCEEDED`, `RATE_LIMITED`, else `VaultError` with `.status`), and passes through the module-scope brake and budget meter. |
166
+ | `fetchVaultHealth(config)` | Unauthenticated `GET /healthz`. Returns `{ status, version, schemaVersion, authMode }`, with `authMode` normalized to `'shared'` on servers that predate the field. |
167
+ | `enrollVaultDevice(config)` | Exchanges the admin-configured bootstrap secret for this device's own credential (`POST /enroll`). The credential is returned once and never again. |
168
+
169
+ **`VaultClient` methods:**
170
+
171
+ | Method | Description |
172
+ |--------|-------------|
173
+ | `batch(app, { accountId, rows })` | Upserts rows (`{ entityId, envelope, createdAt }`). Returns `{ written, maxSeq }`. |
174
+ | `list(app, { accountId, since })` | One page of rows with `seq > since`. Returns `{ rows, hasMore }`. |
175
+ | `getRow(app, entityId, accountId)` | Fetches one row; `null` on 404. |
176
+ | `deleteRow(app, entityId, accountId, opts?)` | Soft-deletes a row. `opts.deletedAt` (epoch ms) stamps the tombstone for delete-vs-edit LWW. |
177
+ | `device(app, { accountId, deviceId, lastSeenSeq })` | Updates this device's cursor. |
178
+ | `getSalt(accountId)` / `putSalt(accountId, salt)` | Reads/registers the account root-key salt as a `Uint8Array` (first-write-wins). |
179
+ | `intentsBatch(accountId, events)` | Appends intent events (`{ eventId, envelope, expiresAt }`) via `POST /intents/batch`. **Insert-only** — a re-sent `eventId` is a server no-op, so retrying an ambiguous failure is safe. Returns `{ written, maxSeq }`. |
180
+ | `intentsList(accountId, { since, limit })` | One ascending page of non-expired intent events with `seq > since` via `GET /intents/list`. Returns `{ rows, hasMore }`; the server pages at 500. |
181
+
182
+ The intents surface is **pure transport**: the `envelope` is opaque here and is
183
+ never decoded or inspected. The codec (and the per-envelope key derivation it
184
+ needs) stays in `@glance-apps/intents` — see `deriveKeyForSalt` above.
185
+
186
+ ### Request diagnostics
187
+
188
+ One brake, one budget meter and one write-loop history per **bundle realm** —
189
+ not per client instance. They model a resource every client in the process
190
+ shares: the server's per-IP request budget. A separately bundled copy of this
191
+ package (an Obsidian plugin, say) is its own realm, which is correct — it is a
192
+ separate process with its own traffic.
193
+
194
+ | Export | Description |
195
+ |--------|-------------|
196
+ | `isVaultRateLimited()` | `true` while the brake is engaged. For callers that would rather sit a whole cycle out pre-flight than fire a call and catch the rejection. |
197
+ | `vaultBrakeStatus()` | `{ braked, until, memoryMs, retryInMs }`. |
198
+ | `getVaultStats()` | The brake status, the last rolling minute of requests attributed by method, and the entityIds whose write history looks loop-shaped. |
199
+ | `configureVaultDiagnostics(options?)` | Tunes the visibility-only thresholds (`softLimitPerMinute`, `loopTransitions`, `loopWindowMs`) and the log sink. The brake's curve is deliberately not configurable. |
200
+ | `resetVaultDiagnostics()` | Clears all three and restores the defaults. For tests. |
201
+
202
+ **The brake.** Any real 429 pauses *every* vault request in the realm: 30s,
203
+ doubling per burst to a 10-minute ceiling, with one arming per burst. While
204
+ paused, calls fail fast — before touching the network — with a `VaultError`
205
+ carrying `status: 429`, `code: 'RATE_LIMITED'` and `retryInMs`, so retry
206
+ ladders that already treat a real 429 as transient need no new code. A 2xx
207
+ releases the pause but only **halves** the escalation memory, so the next 429
208
+ re-arms at the storm's level; a genuine recovery drains to zero in a few quiet
209
+ successes. An over-quota 429 (`QUOTA_EXCEEDED`) is not a limiter hit and does
210
+ not arm it. `createVaultClient({ brake: false })` opts a client out of both the
211
+ gate and the arming — an escape hatch for tests.
212
+
213
+ **The budget meter and the write-loop detector** are visibility only; neither
214
+ ever throttles. The meter logs one attributable line per minute when a realm
215
+ crosses a soft threshold (default 300/min, half the server's default per-IP
216
+ budget). The detector keeps a bounded per-`entityId` write history across
217
+ `batch` upserts and `deleteRow` and warns once per id per window when four
218
+ **redundant** writes to one id land inside ten minutes. A write is redundant
219
+ when it cannot have changed the row's state from what the previous write to
220
+ that id left it: a polarity flip, a **repeat delete** (re-deleting a row that
221
+ is already a tombstone — the shape of the incident this was built for), or a
222
+ rewrite with identical content. It fails open by construction: the rule needs
223
+ repeated events on the *same* id, so a first sync, a large import, or a bulk
224
+ cleanup deleting many different ids can never trigger it.
225
+
161
226
  ### Auto-backup
162
227
 
163
228
  | Export | Description |
@@ -173,6 +238,8 @@ TypeScript declarations for all of the above are in `types/index.d.ts`.
173
238
 
174
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.
175
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
+
176
243
  ## License
177
244
 
178
245
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.10.0",
3
+ "version": "2.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",