@glance-apps/sync 1.3.2 → 1.4.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,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-06-18
11
+
12
+ ### Fixed
13
+
14
+ - **DB sync push no longer skips unread remote rows.** The DB engine split the
15
+ single high-water mark into two cursors: a pull cursor (`getHighWaterMark`,
16
+ the highest seq actually listed and applied) that only the pull step
17
+ advances, and a separate push-ack marker (`getPushAck`) for push
18
+ idempotency. Previously `pushDirtyRows` advanced the shared high-water mark to
19
+ the max seq the server assigned its pushed rows; because pushed rows get the
20
+ highest seqs, the next pull resumed from `since` above any remote row whose
21
+ seq sat below them and silently skipped it — unrecoverable for insert-only
22
+ rows. A push consumes nothing and now never moves the pull cursor, so
23
+ push-then-pull and pull-then-push are both safe. An existing stored cursor is
24
+ read as pull-progress (the conservative interpretation), so upgrades are safe.
25
+
26
+ ### Added
27
+
28
+ - `getPushAck()` / `setPushAck()` on the DB sync engine expose the push-ack
29
+ high-water mark.
30
+
10
31
  ## [1.3.2] - 2026-06-14
11
32
 
12
33
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/dbEngine.js CHANGED
@@ -105,7 +105,16 @@ export const createDbSyncEngine = (config) => {
105
105
 
106
106
  // localStorage keys owned by this engine.
107
107
  const KEY_CONFIG = `${storageKeyPrefix}-db-sync-config`;
108
+ // KEY_HWM is the PULL cursor: the highest seq this device has actually
109
+ // consumed (listed + applied). It is the `since` we resume pulling from, so
110
+ // only the pull step may advance it. A push consumes nothing and must never
111
+ // touch it — see KEY_PUSH_ACK below.
108
112
  const KEY_HWM = `${storageKeyPrefix}-db-sync-hwm`;
113
+ // KEY_PUSH_ACK is the PUSH high-water mark: the highest seq the server has
114
+ // assigned to rows this device has pushed and fully acknowledged. It exists
115
+ // purely for push idempotency/observability and is never read by the pull
116
+ // cursor, so it cannot cause unread remote rows to be skipped.
117
+ const KEY_PUSH_ACK = `${storageKeyPrefix}-db-sync-push-ack`;
109
118
  const KEY_DIRTY = `${storageKeyPrefix}-db-sync-dirty`;
110
119
  const KEY_LAST_SYNCED = `${storageKeyPrefix}-db-sync-last-synced`;
111
120
 
@@ -123,6 +132,10 @@ export const createDbSyncEngine = (config) => {
123
132
  };
124
133
  const getLastSynced = () => localStorage.getItem(KEY_LAST_SYNCED) || null;
125
134
 
135
+ // PULL cursor. Advanced only by pullRemoteChanges from rows actually applied.
136
+ // An existing stored value from before the pull/push split is read as
137
+ // pull-progress, which is the conservative interpretation (it never lets us
138
+ // resume ahead of what we have consumed).
126
139
  const getHighWaterMark = () => {
127
140
  const raw = localStorage.getItem(KEY_HWM);
128
141
  const n = raw == null ? 0 : Number(raw);
@@ -132,6 +145,17 @@ export const createDbSyncEngine = (config) => {
132
145
  localStorage.setItem(KEY_HWM, String(seq));
133
146
  };
134
147
 
148
+ // PUSH high-water mark. Tracked separately so a push can never advance the
149
+ // pull cursor (KEY_HWM) and skip lower-seq remote rows.
150
+ const getPushAck = () => {
151
+ const raw = localStorage.getItem(KEY_PUSH_ACK);
152
+ const n = raw == null ? 0 : Number(raw);
153
+ return Number.isFinite(n) ? n : 0;
154
+ };
155
+ const setPushAck = (seq) => {
156
+ localStorage.setItem(KEY_PUSH_ACK, String(seq));
157
+ };
158
+
135
159
  // Dirty set persisted as a JSON array; deduped via Set on read/write.
136
160
  const getDirtySet = () => {
137
161
  const raw = localStorage.getItem(KEY_DIRTY);
@@ -187,11 +211,14 @@ export const createDbSyncEngine = (config) => {
187
211
  // ── Step 1: push dirty rows ───────────────────────────────────────────────
188
212
  // Encrypts every dirty entity and upserts it as a batch. Entities that no
189
213
  // longer exist locally are pushed as soft-deletes. The dirty set is cleared
190
- // and the high water mark advanced only after the server fully acknowledges,
191
- // so a network failure leaves un-acked rows dirty for an idempotent re-send.
214
+ // and the push-ack high water mark advanced only after the server fully
215
+ // acknowledges, so a network failure leaves un-acked rows dirty for an
216
+ // idempotent re-send. Push deliberately does NOT touch the pull cursor
217
+ // (getHighWaterMark): the server assigns pushed rows the highest seqs, so
218
+ // advancing `since` here would skip any remote row whose seq sits below them.
192
219
  const pushDirtyRows = async () => {
193
220
  const dirty = getDirtySet();
194
- if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getHighWaterMark() };
221
+ if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getPushAck() };
195
222
 
196
223
  await ensureRootKey();
197
224
 
@@ -208,8 +235,8 @@ export const createDbSyncEngine = (config) => {
208
235
  }
209
236
 
210
237
  // Soft-delete first, then batch the upserts. If any call throws, we fall out
211
- // before clearing the dirty set or advancing the cursor.
212
- let maxSeq = getHighWaterMark();
238
+ // before clearing the dirty set or advancing the push-ack marker.
239
+ let maxSeq = getPushAck();
213
240
  for (const entityId of deletes) {
214
241
  const r = await vault.deleteRow(app, entityId, accountId);
215
242
  if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
@@ -220,9 +247,11 @@ export const createDbSyncEngine = (config) => {
220
247
  if (result && typeof result.maxSeq === 'number') maxSeq = Math.max(maxSeq, result.maxSeq);
221
248
  }
222
249
 
223
- // Full acknowledgment: safe to mark clean.
250
+ // Full acknowledgment: safe to mark clean. Advance the push-ack marker only;
251
+ // the pull cursor stays put so the next pull still lists from the highest
252
+ // seq we have actually consumed.
224
253
  clearDirty();
225
- setHighWaterMark(maxSeq);
254
+ setPushAck(maxSeq);
226
255
  return { written: upserts.length, deleted: deletes.length, maxSeq };
227
256
  };
228
257
 
@@ -285,13 +314,18 @@ export const createDbSyncEngine = (config) => {
285
314
  hasMore = !!more;
286
315
  }
287
316
 
317
+ // Pull is the sole writer of the pull cursor. Advance it (monotonically)
318
+ // from the highest seq we actually listed this run.
288
319
  setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
289
320
  return { maxSeq, appliedRemote };
290
321
  };
291
322
 
292
323
  // ── Step 3: update device cursor ──────────────────────────────────────────
293
324
  // Best-effort: a failure here only affects tombstone GC timing, never sync
294
- // correctness, so it is swallowed.
325
+ // correctness, so it is swallowed. lastSeenSeq reports the pull cursor (what
326
+ // we have truly consumed), not the push-ack marker: reporting consumed
327
+ // progress is the conservative value for server-side tombstone GC, since it
328
+ // never lets the server reclaim a tombstone this device has not yet seen.
295
329
  const updateDeviceCursor = async () => {
296
330
  if (!deviceId) return { updated: false };
297
331
  try {
@@ -352,9 +386,11 @@ export const createDbSyncEngine = (config) => {
352
386
  getDirtySet,
353
387
  clearDirty,
354
388
 
355
- // Cursor
389
+ // Cursor (pull-progress) and push-ack marker
356
390
  getHighWaterMark,
357
391
  setHighWaterMark,
392
+ getPushAck,
393
+ setPushAck,
358
394
 
359
395
  // Config
360
396
  getConfig,
package/types/index.d.ts CHANGED
@@ -441,8 +441,12 @@ export interface DbSyncEngine {
441
441
  getDirtySet(): string[];
442
442
  clearDirty(): void;
443
443
 
444
+ /** Pull cursor: highest seq actually listed + applied. Only pull advances it. */
444
445
  getHighWaterMark(): number;
445
446
  setHighWaterMark(seq: number): void;
447
+ /** Push-ack marker: highest seq the server assigned to pushed rows. Never feeds the pull cursor. */
448
+ getPushAck(): number;
449
+ setPushAck(seq: number): void;
446
450
 
447
451
  getConfig(): Record<string, unknown> | null;
448
452
  setConfig(config: Record<string, unknown> | null): void;