@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 +21 -0
- package/package.json +1 -1
- package/src/dbEngine.js +45 -9
- package/types/index.d.ts +4 -0
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
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
|
|
191
|
-
// so a network failure leaves un-acked rows dirty for an
|
|
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:
|
|
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
|
|
212
|
-
let maxSeq =
|
|
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
|
-
|
|
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;
|