@glance-apps/sync 1.5.1 → 1.5.3
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 +50 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/autoBackup.js +11 -1
- package/src/dbEngine.js +14 -1
- package/src/vaultClient.js +19 -0
- package/types/index.d.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.3] - 2026-07-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Generic WebDAV auto-backups now nest under the app folder instead of the
|
|
15
|
+
server root.** The `webdav` backup provider built its upload directory as the
|
|
16
|
+
bare WebDAV root (`<webdavUrl>/`), so backups landed at
|
|
17
|
+
`<webdavUrl>/<prefix><ts>.json`. On NAS targets whose base URL is the server
|
|
18
|
+
root (Synology/fnOS), a `PUT` to `/` is rejected with `405 Method Not Allowed`
|
|
19
|
+
because writes are only permitted inside a shared folder, so auto-backups
|
|
20
|
+
silently failed (lifeGLANCE issue #206). The provider now writes to
|
|
21
|
+
`<webdavUrl>/<appFolderName>/backups/` — matching how the sync file path
|
|
22
|
+
(`<webdavUrl>/<appFolderName>/<syncFilename>`) and the `nextcloud` backup
|
|
23
|
+
provider (`<appFolder>/backups/`) are constructed — and issues an `MKCOL` for
|
|
24
|
+
the app folder and the `backups/` subdir on a `404`/`409` before retrying the
|
|
25
|
+
`PUT`, so the first backup to a fresh target succeeds. `listBackups`,
|
|
26
|
+
`downloadBackup`, `deleteBackup`, and retention all target the same nested
|
|
27
|
+
directory. The folder segment comes from `appFolderName` (the sync folder),
|
|
28
|
+
not the dead `config.folder` field.
|
|
29
|
+
|
|
30
|
+
## [1.5.2] - 2026-06-19
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **Key verifier no longer fails with a cryptic `get row failed: 400`.** The
|
|
35
|
+
verifier's single-row GET (`GET /sync/{app}/__glance_keycheck`) is scoped by
|
|
36
|
+
`accountId`, which GLANCEvault requires as a query param on every row-scoped
|
|
37
|
+
endpoint. When the verifier ran with an `accountId` that was empty or
|
|
38
|
+
whitespace — e.g. fired during reconstruction before the account id was
|
|
39
|
+
populated — the request went out as `?accountId=` (or `?accountId=+++`), which
|
|
40
|
+
the server correctly rejects with `400 {"error":"accountId is required"}`,
|
|
41
|
+
aborting enable. The default `createVaultClient` now validates `accountId` on
|
|
42
|
+
every row-scoped call (`batch`, `list`, single-row GET, `DELETE`, `device`) and
|
|
43
|
+
throws a clear, typed, retryable `ACCOUNT_ID_REQUIRED` instead of putting a
|
|
44
|
+
malformed `accountId` on the wire — so the next cycle (once the id is loaded)
|
|
45
|
+
succeeds. `createDbSyncEngine` also rejects an empty/whitespace `accountId` at
|
|
46
|
+
construction (a whitespace-only id previously passed the plain falsy check).
|
|
47
|
+
The verifier path surfaces `ACCOUNT_ID_REQUIRED` (and `PASSPHRASE_REQUIRED`)
|
|
48
|
+
with their own code rather than mislabeling them `VERIFIER_UNSUPPORTED`. The
|
|
49
|
+
verifier GET correctly carries `?accountId=...` (like the working `list` call)
|
|
50
|
+
and, on a fresh account, 404 → writes the verifier via `batch` with
|
|
51
|
+
`accountId` + `insertOnly: true`.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `ACCOUNT_ID_REQUIRED` standardized on the `SyncErrorCode` type.
|
|
56
|
+
|
|
10
57
|
## [1.5.1] - 2026-06-19
|
|
11
58
|
|
|
12
59
|
### Fixed
|
|
@@ -268,7 +315,9 @@ entire sync infrastructure can be shared across the GLANCE app family.
|
|
|
268
315
|
- TypeScript declarations in `types/index.d.ts`.
|
|
269
316
|
- WebDAV CORS proxy handler in `api/webdav-proxy.js`.
|
|
270
317
|
|
|
271
|
-
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.
|
|
318
|
+
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.5.3...HEAD
|
|
319
|
+
[1.5.3]: https://github.com/glance-apps/glance-sync/compare/v1.5.2...v1.5.3
|
|
320
|
+
[1.5.2]: https://github.com/glance-apps/glance-sync/compare/v1.5.1...v1.5.2
|
|
272
321
|
[1.1.2]: https://github.com/glance-apps/glance-sync/compare/v1.1.1...v1.1.2
|
|
273
322
|
[1.1.1]: https://github.com/glance-apps/glance-sync/compare/v1.1.0...v1.1.1
|
|
274
323
|
[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
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
|
-
|
|
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
|
@@ -92,7 +92,13 @@ export const createDbSyncEngine = (config) => {
|
|
|
92
92
|
if (!storageKeyPrefix) throw new Error('createDbSyncEngine: storageKeyPrefix is required');
|
|
93
93
|
if (!config.vaultClient && !vaultUrl) throw new Error('createDbSyncEngine: vaultUrl is required');
|
|
94
94
|
if (!config.vaultClient && !vaultToken) throw new Error('createDbSyncEngine: vaultToken is required');
|
|
95
|
-
if (
|
|
95
|
+
if (typeof accountId !== 'string' || accountId.trim() === '') {
|
|
96
|
+
// Reject empty/whitespace too: a whitespace-only id passes a plain falsy
|
|
97
|
+
// check but serialises to `?accountId=+++`, which the server trims to empty
|
|
98
|
+
// and rejects with a cryptic 400 on every row-scoped call (incl. the key
|
|
99
|
+
// verifier's single-row GET).
|
|
100
|
+
throw new Error('createDbSyncEngine: accountId is required');
|
|
101
|
+
}
|
|
96
102
|
if (typeof getLocalEntity !== 'function') throw new Error('createDbSyncEngine: getLocalEntity is required');
|
|
97
103
|
if (typeof applyRemoteEntity !== 'function') throw new Error('createDbSyncEngine: applyRemoteEntity is required');
|
|
98
104
|
if (typeof applyRemoteDelete !== 'function') throw new Error('createDbSyncEngine: applyRemoteDelete is required');
|
|
@@ -242,7 +248,14 @@ export const createDbSyncEngine = (config) => {
|
|
|
242
248
|
// skipped would still push poison rows. Pausing sync with a clear reason is
|
|
243
249
|
// the correct degradation. (config.allowUnverified is an explicit operator
|
|
244
250
|
// escape hatch, off by default, that downgrades this to a logged warning.)
|
|
251
|
+
// Client-readiness failures (no passphrase yet, or accountId not populated)
|
|
252
|
+
// are NOT a server problem: re-throw them as-is so they surface with their own
|
|
253
|
+
// typed, retryable code instead of being mislabeled "update your server".
|
|
254
|
+
const isClientNotReady = (err) =>
|
|
255
|
+
err && (err.code === 'ACCOUNT_ID_REQUIRED' || err.code === 'PASSPHRASE_REQUIRED');
|
|
256
|
+
|
|
245
257
|
const verifierUnsupported = (cause) => {
|
|
258
|
+
if (isClientNotReady(cause)) throw cause;
|
|
246
259
|
if (allowUnverified) {
|
|
247
260
|
// eslint-disable-next-line no-console
|
|
248
261
|
console.warn(`[${appId}] db sync proceeding UNVERIFIED: the server cannot host the key verifier (${KEYCHECK_ENTITY_ID}).`, cause);
|
package/src/vaultClient.js
CHANGED
|
@@ -73,12 +73,27 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
73
73
|
return res.json();
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
// accountId scopes every row-scoped endpoint (batch, list, single-row GET,
|
|
77
|
+
// DELETE, device). The server has no default and rejects a missing/empty value
|
|
78
|
+
// with a cryptic 400 {"error":"accountId is required"}. Validate it here so a
|
|
79
|
+
// caller that fires before the account id is populated (e.g. the key verifier
|
|
80
|
+
// running during reconstruction) fails with a clear, typed, retryable error
|
|
81
|
+
// and we never put a malformed `?accountId=` on the wire.
|
|
82
|
+
const requireAccountId = (accountId, context) => {
|
|
83
|
+
if (typeof accountId !== 'string' || accountId.trim() === '') {
|
|
84
|
+
const err = new Error(`${context}: accountId is required but was missing or empty.`);
|
|
85
|
+
err.code = 'ACCOUNT_ID_REQUIRED';
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
76
90
|
return {
|
|
77
91
|
/**
|
|
78
92
|
* Upserts a batch of rows. Each row is { entityId, envelope, createdAt }.
|
|
79
93
|
* @returns {Promise<{ written: number, maxSeq: number }>}
|
|
80
94
|
*/
|
|
81
95
|
async batch(app, { accountId, rows }) {
|
|
96
|
+
requireAccountId(accountId, 'batch upsert');
|
|
82
97
|
const res = await request('POST', `/sync/${encodeURIComponent(app)}/batch`, {
|
|
83
98
|
body: { accountId, rows },
|
|
84
99
|
});
|
|
@@ -90,6 +105,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
90
105
|
* @returns {Promise<{ rows: Array, hasMore: boolean }>}
|
|
91
106
|
*/
|
|
92
107
|
async list(app, { accountId, since }) {
|
|
108
|
+
requireAccountId(accountId, 'list');
|
|
93
109
|
const res = await request('GET', `/sync/${encodeURIComponent(app)}/list`, {
|
|
94
110
|
query: { accountId, since: String(since) },
|
|
95
111
|
});
|
|
@@ -100,6 +116,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
100
116
|
* Fetches a single row by entityId. Returns null on 404.
|
|
101
117
|
*/
|
|
102
118
|
async getRow(app, entityId, accountId) {
|
|
119
|
+
requireAccountId(accountId, 'get row');
|
|
103
120
|
const res = await request('GET', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
|
|
104
121
|
query: { accountId },
|
|
105
122
|
});
|
|
@@ -112,6 +129,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
112
129
|
* the new seq for the tombstone).
|
|
113
130
|
*/
|
|
114
131
|
async deleteRow(app, entityId, accountId) {
|
|
132
|
+
requireAccountId(accountId, 'delete row');
|
|
115
133
|
const res = await request('DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
|
|
116
134
|
query: { accountId },
|
|
117
135
|
});
|
|
@@ -125,6 +143,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
125
143
|
* @returns {Promise<{ updated: boolean }>}
|
|
126
144
|
*/
|
|
127
145
|
async device(app, { accountId, deviceId, lastSeenSeq }) {
|
|
146
|
+
requireAccountId(accountId, 'device cursor');
|
|
128
147
|
const res = await request('POST', `/sync/${encodeURIComponent(app)}/device`, {
|
|
129
148
|
body: { accountId, deviceId, lastSeenSeq },
|
|
130
149
|
});
|
package/types/index.d.ts
CHANGED
|
@@ -24,7 +24,10 @@ export type SyncErrorCode =
|
|
|
24
24
|
| 'ROW_DECRYPT_FAILED'
|
|
25
25
|
// DB transport: the server can't host the key verifier (doesn't support the
|
|
26
26
|
// reserved __glance_keycheck id or the single-row endpoint).
|
|
27
|
-
| 'VERIFIER_UNSUPPORTED'
|
|
27
|
+
| 'VERIFIER_UNSUPPORTED'
|
|
28
|
+
// DB transport: a row-scoped call (incl. the key verifier) was made before the
|
|
29
|
+
// accountId was populated — retryable once the account id is available.
|
|
30
|
+
| 'ACCOUNT_ID_REQUIRED';
|
|
28
31
|
|
|
29
32
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
30
33
|
|