@glance-apps/sync 1.5.2 → 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 CHANGED
@@ -7,6 +7,85 @@ 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
+
69
+ ## [1.5.3] - 2026-07-04
70
+
71
+ ### Fixed
72
+
73
+ - **Generic WebDAV auto-backups now nest under the app folder instead of the
74
+ server root.** The `webdav` backup provider built its upload directory as the
75
+ bare WebDAV root (`<webdavUrl>/`), so backups landed at
76
+ `<webdavUrl>/<prefix><ts>.json`. On NAS targets whose base URL is the server
77
+ root (Synology/fnOS), a `PUT` to `/` is rejected with `405 Method Not Allowed`
78
+ because writes are only permitted inside a shared folder, so auto-backups
79
+ silently failed (lifeGLANCE issue #206). The provider now writes to
80
+ `<webdavUrl>/<appFolderName>/backups/` — matching how the sync file path
81
+ (`<webdavUrl>/<appFolderName>/<syncFilename>`) and the `nextcloud` backup
82
+ provider (`<appFolder>/backups/`) are constructed — and issues an `MKCOL` for
83
+ the app folder and the `backups/` subdir on a `404`/`409` before retrying the
84
+ `PUT`, so the first backup to a fresh target succeeds. `listBackups`,
85
+ `downloadBackup`, `deleteBackup`, and retention all target the same nested
86
+ directory. The folder segment comes from `appFolderName` (the sync folder),
87
+ not the dead `config.folder` field.
88
+
10
89
  ## [1.5.2] - 2026-06-19
11
90
 
12
91
  ### Fixed
@@ -295,7 +374,10 @@ entire sync infrastructure can be shared across the GLANCE app family.
295
374
  - TypeScript declarations in `types/index.d.ts`.
296
375
  - WebDAV CORS proxy handler in `api/webdav-proxy.js`.
297
376
 
298
- [Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.1.2...HEAD
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
379
+ [1.5.3]: https://github.com/glance-apps/glance-sync/compare/v1.5.2...v1.5.3
380
+ [1.5.2]: https://github.com/glance-apps/glance-sync/compare/v1.5.1...v1.5.2
299
381
  [1.1.2]: https://github.com/glance-apps/glance-sync/compare/v1.1.1...v1.1.2
300
382
  [1.1.1]: https://github.com/glance-apps/glance-sync/compare/v1.1.0...v1.1.1
301
383
  [1.1.0]: https://github.com/glance-apps/glance-sync/compare/v1.0.3...v1.1.0
package/README.md CHANGED
@@ -114,7 +114,7 @@ Pass `encryptionEnabled: true` in the sync config object stored via `engine.setC
114
114
  |--------|-------------|
115
115
  | `sync()` / `download()` | Full cycle: download → validate → merge → apply → upload if changed. |
116
116
  | `upload(opts?)` | Upload local state to the remote provider. |
117
- | `runBackup(frequency)` | Save a snapshot to IDB and upload it to the remote backup folder. |
117
+ | `runBackup(frequency)` | Save a snapshot to IDB and upload it to the remote backup folder (`<appFolderName>/backups/`, a sibling of the sync file inside the app folder). |
118
118
  | `test(config)` | Test the connection to the configured provider. |
119
119
  | `getConfig()` / `setConfig(cfg)` | Read/write the persisted sync config (credentials, provider, flags). |
120
120
  | `getLastSynced()` | ISO 8601 timestamp of the last successful sync. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/autoBackup.js CHANGED
@@ -195,7 +195,12 @@ export function createAutoBackupProviders(config) {
195
195
  { key: 'appPassword', label: 'Password / App Password', type: 'password', placeholder: 'your-password' }
196
196
  ],
197
197
  _getBackupDirUrl(providerConfig) {
198
- return `${providerConfig.webdavUrl.replace(/\/+$/, '')}/`;
198
+ // Nest backups under the app folder (mirroring the sync file path
199
+ // `${webdavUrl}/${appFolderName}/${syncFilename}` and the nextcloud
200
+ // provider's `${appFolder}/backups/`). Writing to the bare WebDAV root
201
+ // fails with 405 on NAS targets (Synology/fnOS) that only permit PUT
202
+ // inside a shared folder.
203
+ return `${providerConfig.webdavUrl.replace(/\/+$/, '')}/${appFolder}/backups/`;
199
204
  },
200
205
  _getAuthHeaders(providerConfig) {
201
206
  return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
@@ -213,6 +218,11 @@ export function createAutoBackupProviders(config) {
213
218
  wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
214
219
  let res = await doUpload();
215
220
  if (res.status === 404 || res.status === 409) {
221
+ // Create the app folder before the backups/ subdir so the first
222
+ // backup to a fresh target doesn't 404/409 (mirrors the nextcloud
223
+ // provider). MKCOL on an existing collection is a harmless no-op.
224
+ const parentDir = `${providerConfig.webdavUrl.replace(/\/+$/, '')}/${appFolder}/`;
225
+ await wdFetch('MKCOL', parentDir, authHeaders);
216
226
  await wdFetch('MKCOL', dirUrl, authHeaders);
217
227
  res = await doUpload();
218
228
  }
package/src/dbEngine.js CHANGED
@@ -373,18 +373,34 @@ export const createDbSyncEngine = (config) => {
373
373
  }
374
374
  }
375
375
 
376
- // Soft-delete first, then batch the upserts. If any call throws, we fall out
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;
@@ -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= soft-delete a row
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: { accountId },
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>;