@glance-apps/sync 1.5.3 → 1.6.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 +61 -1
- package/package.json +1 -1
- package/src/dbEngine.js +46 -7
- package/src/vaultClient.js +10 -3
- package/types/index.d.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.6.0] - 2026-07-10
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **DB engine: a pulled delete no longer unconditionally beats a newer local
|
|
15
|
+
edit.** `applyRemoteRow` applied a pulled `deleted: true` row without any
|
|
16
|
+
timestamp comparison AND pruned the entity from the dirty set, so a device
|
|
17
|
+
that edited an entity offline (with a newer `lastModified`) and then pulled a
|
|
18
|
+
peer's delete before pushing silently discarded its own newer edit — the
|
|
19
|
+
opposite of the engine's upsert conflict rule and of the file tier's
|
|
20
|
+
newest-write-wins over tombstones. Deletes now participate in the same
|
|
21
|
+
entity-grain LWW: the engine stamps each pushed soft-delete with a
|
|
22
|
+
`deletedAt` (epoch ms, taken at push time) and applies a pulled delete only
|
|
23
|
+
when the local copy's `lastModified` does not exceed that stamp. When the
|
|
24
|
+
local edit is strictly newer, the delete is discarded and the entity is
|
|
25
|
+
marked (or kept) dirty so the next push re-upserts it over the tombstone and
|
|
26
|
+
restores the row fleet-wide.
|
|
27
|
+
- **Tie-break:** on an exact timestamp tie the DELETE wins (unlike the upsert
|
|
28
|
+
rule, where local wins ties), because `deletedAt` is stamped at push time
|
|
29
|
+
and therefore already post-dates the deleting device's own last edit.
|
|
30
|
+
- **Backward compatibility:** a tombstone with no `deletedAt` — from a
|
|
31
|
+
GLANCEvault server that predates the stamp, or a row deleted by an older
|
|
32
|
+
client — keeps the previous unconditional delete-wins behavior. `deletedAt`
|
|
33
|
+
rides the DELETE request as an extra query param, which old servers ignore.
|
|
34
|
+
- **DB engine: push sends upserts before deletes, closing the transient
|
|
35
|
+
fleet-wide delete window.** `pushDirtyRows` used to issue the per-entity
|
|
36
|
+
`deleteRow` calls before the batched upserts. A cross-list move (delete
|
|
37
|
+
`unscheduledTasks:X` + upsert `tasks:X` in the same dirty set) that failed
|
|
38
|
+
between the two steps (5xx / network drop) left the delete on the server with
|
|
39
|
+
no replacement row, so every peer pulled a bare delete and the entity
|
|
40
|
+
vanished fleet-wide until the origin device successfully retried. The batch
|
|
41
|
+
upsert now runs first; a failure between the steps leaves the benign inverse
|
|
42
|
+
(both rows briefly exist) and the retained dirty set retries both
|
|
43
|
+
idempotently. No invariant depended on the old order: upserts and deletes in
|
|
44
|
+
one push can never share an `entityId`, and `maxSeq`/ack accounting is
|
|
45
|
+
order-independent.
|
|
46
|
+
|
|
47
|
+
### Changed — behavior notes for consumers
|
|
48
|
+
|
|
49
|
+
- Delete/edit races now resolve by newest-write-wins instead of delete-always-
|
|
50
|
+
wins. An entity edited on one device *after* (per wall clock) another
|
|
51
|
+
device's deletion will be **restored on all devices** rather than deleted.
|
|
52
|
+
Apps relying on "delete always sticks" semantics should treat this as a
|
|
53
|
+
breaking behavior change. Clock skew caveat: `deletedAt` comes from the
|
|
54
|
+
deleting device's clock at push time, so a device with a fast clock can win
|
|
55
|
+
conflicts it shouldn't — same trust model as the existing `lastModified` LWW.
|
|
56
|
+
- During a partial push failure, peers may transiently observe a moved entity
|
|
57
|
+
in both its old and new lists (previously: in neither) until the retry lands.
|
|
58
|
+
- Unchanged (regression-guarded): upsert LWW keeps local on ties and prunes a
|
|
59
|
+
remotely-superseded entity from the dirty set; the split pull/push cursors
|
|
60
|
+
and their persistence keys; the exported `getRow` vault surface and
|
|
61
|
+
`decryptEntity`; push idempotency for re-sent upserts and soft-deletes.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- `createVaultClient().deleteRow(app, entityId, accountId, { deletedAt })` —
|
|
66
|
+
optional fourth argument; the stamp is sent as a `deletedAt` query param.
|
|
67
|
+
`VaultPulledRow` gains an optional `deletedAt` field.
|
|
68
|
+
|
|
10
69
|
## [1.5.3] - 2026-07-04
|
|
11
70
|
|
|
12
71
|
### Fixed
|
|
@@ -315,7 +374,8 @@ entire sync infrastructure can be shared across the GLANCE app family.
|
|
|
315
374
|
- TypeScript declarations in `types/index.d.ts`.
|
|
316
375
|
- WebDAV CORS proxy handler in `api/webdav-proxy.js`.
|
|
317
376
|
|
|
318
|
-
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.
|
|
377
|
+
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.6.0...HEAD
|
|
378
|
+
[1.6.0]: https://github.com/glance-apps/glance-sync/compare/v1.5.3...v1.6.0
|
|
319
379
|
[1.5.3]: https://github.com/glance-apps/glance-sync/compare/v1.5.2...v1.5.3
|
|
320
380
|
[1.5.2]: https://github.com/glance-apps/glance-sync/compare/v1.5.1...v1.5.2
|
|
321
381
|
[1.1.2]: https://github.com/glance-apps/glance-sync/compare/v1.1.1...v1.1.2
|
package/package.json
CHANGED
package/src/dbEngine.js
CHANGED
|
@@ -373,18 +373,34 @@ export const createDbSyncEngine = (config) => {
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
-
//
|
|
377
|
-
// before clearing the dirty set or advancing the push-ack marker.
|
|
376
|
+
// Batch the upserts FIRST, then soft-delete. If any call throws, we fall
|
|
377
|
+
// out before clearing the dirty set or advancing the push-ack marker.
|
|
378
|
+
//
|
|
379
|
+
// The order matters for partial-failure safety: a cross-list move puts a
|
|
380
|
+
// delete ("old-list:X") and its replacement upsert ("new-list:X") in the
|
|
381
|
+
// same dirty set. Deleting first opened a window where the delete landed,
|
|
382
|
+
// the batch failed, and every peer pulled a delete with no replacement —
|
|
383
|
+
// the entity vanished fleet-wide until this device successfully retried.
|
|
384
|
+
// Upserting first inverts the partial state into a benign one (both rows
|
|
385
|
+
// briefly exist) that the retried delete cleans up. Nothing depends on
|
|
386
|
+
// deletes landing first: upserts and deletes in one push never share an
|
|
387
|
+
// entityId (an entity is either present locally or not), and maxSeq is
|
|
388
|
+
// order-independent.
|
|
389
|
+
//
|
|
390
|
+
// Each delete carries a deletedAt stamp so pulling devices can run LWW
|
|
391
|
+
// against it (see applyRemoteRow). Servers that predate the stamp ignore
|
|
392
|
+
// the extra field, in which case pulled tombstones simply lack deletedAt
|
|
393
|
+
// and fall back to the old delete-wins behavior.
|
|
378
394
|
let maxSeq = getPushAck();
|
|
379
|
-
for (const entityId of deletes) {
|
|
380
|
-
const r = await vault.deleteRow(app, entityId, accountId);
|
|
381
|
-
if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
|
|
382
|
-
if (r && typeof r.maxSeq === 'number') maxSeq = Math.max(maxSeq, r.maxSeq);
|
|
383
|
-
}
|
|
384
395
|
if (upserts.length > 0) {
|
|
385
396
|
const result = await vault.batch(app, { accountId, rows: upserts });
|
|
386
397
|
if (result && typeof result.maxSeq === 'number') maxSeq = Math.max(maxSeq, result.maxSeq);
|
|
387
398
|
}
|
|
399
|
+
for (const entityId of deletes) {
|
|
400
|
+
const r = await vault.deleteRow(app, entityId, accountId, { deletedAt: Date.now() });
|
|
401
|
+
if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
|
|
402
|
+
if (r && typeof r.maxSeq === 'number') maxSeq = Math.max(maxSeq, r.maxSeq);
|
|
403
|
+
}
|
|
388
404
|
|
|
389
405
|
// Full acknowledgment: safe to mark clean. Advance the push-ack marker only;
|
|
390
406
|
// the pull cursor stays put so the next pull still lists from the highest
|
|
@@ -401,6 +417,29 @@ export const createDbSyncEngine = (config) => {
|
|
|
401
417
|
// failure — the caller decides whether to quarantine.
|
|
402
418
|
const applyRemoteRow = async (R, dirty) => {
|
|
403
419
|
if (R.deleted) {
|
|
420
|
+
// A tombstone participates in the same entity-grain LWW as an upsert,
|
|
421
|
+
// using the deletedAt stamp the pushing device attached (see
|
|
422
|
+
// pushDirtyRows). A local copy KEEPS only when its lastModified strictly
|
|
423
|
+
// exceeds deletedAt: the delete wins ties, because deletedAt is stamped
|
|
424
|
+
// at push time and therefore already post-dates the edit that produced
|
|
425
|
+
// it on the deleting device. Tombstones from servers/devices that
|
|
426
|
+
// predate the stamp carry no deletedAt (ts -> 0); those keep the old
|
|
427
|
+
// unconditional delete-wins behavior.
|
|
428
|
+
const deletedAt = ts(R.deletedAt);
|
|
429
|
+
if (deletedAt > 0) {
|
|
430
|
+
const local = await getLocalEntity(R.entityId);
|
|
431
|
+
if (local != null && ts(lastModifiedOf(local)) > deletedAt) {
|
|
432
|
+
// The local edit is newer than the delete: keep it, and make sure it
|
|
433
|
+
// is dirty so the next push re-upserts it over the tombstone and
|
|
434
|
+
// restores the row fleet-wide (matching the file tier's
|
|
435
|
+
// newest-write-wins over tombstones).
|
|
436
|
+
if (!dirty.has(R.entityId)) {
|
|
437
|
+
dirty.add(R.entityId);
|
|
438
|
+
writeDirtySet([...dirty]);
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
404
443
|
await applyRemoteDelete(R.entityId);
|
|
405
444
|
if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
|
|
406
445
|
return true;
|
package/src/vaultClient.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// POST /sync/:app/batch upsert rows, returns { written, maxSeq }
|
|
11
11
|
// GET /sync/:app/list?accountId=&since= incremental fetch, { rows, hasMore }
|
|
12
12
|
// GET /sync/:app/:entityId?accountId= fetch one row
|
|
13
|
-
// DELETE /sync/:app/:entityId?accountId=
|
|
13
|
+
// DELETE /sync/:app/:entityId?accountId=&deletedAt= soft-delete a row (deletedAt optional)
|
|
14
14
|
// POST /sync/:app/device update device cursor, { updated }
|
|
15
15
|
// GET /salt/:accountId fetch the account root-key salt
|
|
16
16
|
// PUT /salt/:accountId register a salt (first-write-wins)
|
|
@@ -127,11 +127,18 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
127
127
|
/**
|
|
128
128
|
* Soft-deletes a row by entityId. Returns the server response (may include
|
|
129
129
|
* the new seq for the tombstone).
|
|
130
|
+
*
|
|
131
|
+
* opts.deletedAt (epoch ms) stamps the tombstone so pulling devices can
|
|
132
|
+
* resolve delete-vs-edit conflicts with LWW. It rides as a query param, so
|
|
133
|
+
* servers that predate the stamp simply ignore it (their tombstones come
|
|
134
|
+
* back without deletedAt and the engine falls back to delete-wins).
|
|
130
135
|
*/
|
|
131
|
-
async deleteRow(app, entityId, accountId) {
|
|
136
|
+
async deleteRow(app, entityId, accountId, opts = {}) {
|
|
132
137
|
requireAccountId(accountId, 'delete row');
|
|
138
|
+
const query = { accountId };
|
|
139
|
+
if (opts && opts.deletedAt != null) query.deletedAt = String(opts.deletedAt);
|
|
133
140
|
const res = await request('DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
|
|
134
|
-
query
|
|
141
|
+
query,
|
|
135
142
|
});
|
|
136
143
|
if (res.status === 404) return null;
|
|
137
144
|
return jsonOrThrow(res, 'delete row');
|
package/types/index.d.ts
CHANGED
|
@@ -396,6 +396,8 @@ export interface VaultPulledRow extends Partial<VaultRow> {
|
|
|
396
396
|
entityId: string;
|
|
397
397
|
seq: number;
|
|
398
398
|
deleted?: boolean;
|
|
399
|
+
/** Tombstone LWW stamp (epoch ms or date string). Absent on rows from servers that predate it: the engine then treats the delete as unconditionally winning. */
|
|
400
|
+
deletedAt?: number | string;
|
|
399
401
|
envelope?: string;
|
|
400
402
|
}
|
|
401
403
|
|
|
@@ -403,7 +405,7 @@ export interface VaultClient {
|
|
|
403
405
|
batch(app: string, args: { accountId: string; rows: VaultRow[] }): Promise<{ written: number; maxSeq: number }>;
|
|
404
406
|
list(app: string, args: { accountId: string; since: number }): Promise<{ rows: VaultPulledRow[]; hasMore: boolean }>;
|
|
405
407
|
getRow(app: string, entityId: string, accountId: string): Promise<VaultPulledRow | null>;
|
|
406
|
-
deleteRow(app: string, entityId: string, accountId: string): Promise<{ seq?: number } | null>;
|
|
408
|
+
deleteRow(app: string, entityId: string, accountId: string, opts?: { deletedAt?: number }): Promise<{ seq?: number } | null>;
|
|
407
409
|
device(app: string, args: { accountId: string; deviceId: string; lastSeenSeq: number }): Promise<{ updated: boolean }>;
|
|
408
410
|
getSalt(accountId: string): Promise<Uint8Array | null>;
|
|
409
411
|
putSalt(accountId: string, salt: Uint8Array): Promise<Uint8Array>;
|