@glance-apps/sync 1.2.0 → 1.3.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,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-06-14
11
+
12
+ ### Added
13
+
14
+ - **Multi-user roster sync in `mergeSyncData`.** A `users` array is now merged
15
+ across devices using last-write-wins per user, keyed by `syncId` (falling back
16
+ to `id`) and resolved by `updatedAt`. A roster signature (id, `updatedAt`,
17
+ `deleted` flag, and `name`) drives the `localChanged` / `remoteChanged` dirty
18
+ flags so roster edits propagate. The per-device `multiUserEnabled` toggle is
19
+ intentionally not merged.
20
+
21
+ ## [1.2.1] - 2026-06-14
22
+
23
+ ### Fixed
24
+
25
+ - **DB sync transport: row field renamed `ciphertext` to `envelope`** to match the
26
+ GLANCEvault batch and list endpoints. The encrypted rows exchanged with the
27
+ vault are now `{ entityId, envelope, createdAt }`; the field rename is applied
28
+ across the push row, the pull read, the vault client and engine types, the
29
+ tests, and the docs. The base64 encoding is unchanged: the value is still
30
+ `base64(IV || AES-GCM output)` produced by `encryptEntity`.
31
+
10
32
  ## [1.2.0] - 2026-06-13
11
33
 
12
34
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/dbCrypto.js CHANGED
@@ -257,7 +257,7 @@ export async function clearDbRootKey({ cryptoDBName, nativeGetSyncKey = null, na
257
257
 
258
258
  /**
259
259
  * Encrypts one entity into a base64 ciphertext string for the row's
260
- * `ciphertext` field. The output encodes [ 12-byte IV ][ AES-GCM ciphertext ].
260
+ * `envelope` field. The output encodes [ 12-byte IV ][ AES-GCM ciphertext ].
261
261
  *
262
262
  * @param {*} entity - plain JS value to encrypt (serialised with JSON)
263
263
  * @param {string} entityId - stable entity UUID, used as HKDF context
@@ -280,7 +280,7 @@ export async function encryptEntity(entity, entityId, rootKey = _rootKey) {
280
280
  /**
281
281
  * Decrypts a base64 ciphertext produced by encryptEntity back into the entity.
282
282
  *
283
- * @param {string} ciphertextB64 - the row's `ciphertext` field
283
+ * @param {string} ciphertextB64 - the row's `envelope` field
284
284
  * @param {string} entityId - stable entity UUID, used as HKDF context
285
285
  * @param {CryptoKey} [rootKey] - explicit root key (defaults to the session key)
286
286
  * @returns {Promise<*>} the decrypted entity
package/src/dbEngine.js CHANGED
@@ -202,8 +202,8 @@ export const createDbSyncEngine = (config) => {
202
202
  if (entity == null) {
203
203
  deletes.push(entityId);
204
204
  } else {
205
- const ciphertext = await encryptEntity(entity, entityId);
206
- upserts.push({ entityId, ciphertext, createdAt: Date.now() });
205
+ const envelope = await encryptEntity(entity, entityId);
206
+ upserts.push({ entityId, envelope, createdAt: Date.now() });
207
207
  }
208
208
  }
209
209
 
@@ -256,7 +256,7 @@ export const createDbSyncEngine = (config) => {
256
256
  }
257
257
 
258
258
  await ensureRootKey();
259
- const remoteEntity = await decryptEntity(R.ciphertext, R.entityId);
259
+ const remoteEntity = await decryptEntity(R.envelope, R.entityId);
260
260
  const local = await getLocalEntity(R.entityId);
261
261
 
262
262
  if (local == null) {
package/src/merge.js CHANGED
@@ -832,6 +832,25 @@ export const mergeSyncData = (localData, remoteData, retentionDays = 90) => {
832
832
  const prunedDeletedGoalIds = pruneTombstones(allDeletedGoalIds, tombstoneCutoff);
833
833
  const prunedDeletedProjectIds = pruneTombstones(allDeletedProjectIds, tombstoneCutoff);
834
834
 
835
+ // ── Multi-user roster: last-write-wins per user by updatedAt (keyed by syncId).
836
+ // NOTE: multiUserEnabled is intentionally NOT merged — it's a per-device toggle.
837
+ const localUsers = Array.isArray(localData.users) ? localData.users : [];
838
+ const remoteUsers = Array.isArray(remoteData.users) ? remoteData.users : [];
839
+ const usersById = new Map(localUsers.map(u => [u.syncId ?? u.id, u]));
840
+ for (const u of remoteUsers) {
841
+ const k = u.syncId ?? u.id;
842
+ const prev = usersById.get(k);
843
+ if (!prev || (u.updatedAt || '') > (prev.updatedAt || '')) usersById.set(k, u);
844
+ }
845
+ const mergedUsers = [...usersById.values()];
846
+
847
+ const rosterSig = (arr) => arr
848
+ .map(u => `${u.syncId ?? u.id}:${u.updatedAt || ''}:${u.deleted ? 1 : 0}:${u.name || ''}`)
849
+ .sort().join('|');
850
+ const mergedSig = rosterSig(mergedUsers);
851
+ if (mergedSig !== rosterSig(localUsers)) localChanged = true;
852
+ if (mergedSig !== rosterSig(remoteUsers)) remoteChanged = true;
853
+
835
854
  return {
836
855
  data: {
837
856
  tasks: finalTasks,
@@ -877,6 +896,7 @@ export const mergeSyncData = (localData, remoteData, retentionDays = 90) => {
877
896
  minimizedSections: localData.minimizedSections, // UI pref — keep local
878
897
  use24HourClock: localData.use24HourClock, // device pref — keep local
879
898
  tombstonePrunedBefore: mergedTombstonePrunedBefore,
899
+ users: mergedUsers,
880
900
  },
881
901
  localChanged,
882
902
  remoteChanged
@@ -75,7 +75,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
75
75
 
76
76
  return {
77
77
  /**
78
- * Upserts a batch of rows. Each row is { entityId, ciphertext, createdAt }.
78
+ * Upserts a batch of rows. Each row is { entityId, envelope, createdAt }.
79
79
  * @returns {Promise<{ written: number, maxSeq: number }>}
80
80
  */
81
81
  async batch(app, { accountId, rows }) {
package/types/index.d.ts CHANGED
@@ -366,10 +366,10 @@ export function hasDbRootKey(): boolean;
366
366
  export function encryptEntity(entity: unknown, entityId: string, rootKey?: CryptoKey): Promise<string>;
367
367
  export function decryptEntity<T = unknown>(ciphertext: string, entityId: string, rootKey?: CryptoKey): Promise<T>;
368
368
 
369
- // Row exchanged with GLANCEvault. ciphertext is base64(IV || AES-GCM output).
369
+ // Row exchanged with GLANCEvault. envelope is base64(IV || AES-GCM output).
370
370
  export interface VaultRow {
371
371
  entityId: string;
372
- ciphertext: string;
372
+ envelope: string;
373
373
  createdAt: number;
374
374
  }
375
375
 
@@ -377,7 +377,7 @@ export interface VaultPulledRow extends Partial<VaultRow> {
377
377
  entityId: string;
378
378
  seq: number;
379
379
  deleted?: boolean;
380
- ciphertext?: string;
380
+ envelope?: string;
381
381
  }
382
382
 
383
383
  export interface VaultClient {