@glance-apps/sync 1.2.1 → 1.3.1

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,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.1] - 2026-06-14
11
+
12
+ ### Fixed
13
+
14
+ - **`mergeRoutineDefinitions` now applies last-write-wins on chip id collisions.**
15
+ Previously, when the same chip id existed on both sides, the local chip was
16
+ kept verbatim and the remote one skipped — `lastModified` was only consulted
17
+ for tombstone resurrection. As a result, any edit to an existing routine chip
18
+ (rename, reorder, time change, or stamping `ownerSyncId`) failed to propagate
19
+ across devices. Colliding chips are now resolved by the newer `lastModified`,
20
+ matching the behavior of `mergeArrayById` / `mergeHabits`. Ordering and
21
+ tombstone behavior are preserved.
22
+
23
+ ## [1.3.0] - 2026-06-14
24
+
25
+ ### Added
26
+
27
+ - **Multi-user roster sync in `mergeSyncData`.** A `users` array is now merged
28
+ across devices using last-write-wins per user, keyed by `syncId` (falling back
29
+ to `id`) and resolved by `updatedAt`. A roster signature (id, `updatedAt`,
30
+ `deleted` flag, and `name`) drives the `localChanged` / `remoteChanged` dirty
31
+ flags so roster edits propagate. The per-device `multiUserEnabled` toggle is
32
+ intentionally not merged.
33
+
10
34
  ## [1.2.1] - 2026-06-14
11
35
 
12
36
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/merge.js CHANGED
@@ -115,8 +115,11 @@ export const mergeArrayById = (localItems, remoteItems, deletedIds, syncHorizon
115
115
  *
116
116
  * Each side's bucket is an array of { id, name } chips. The merge preserves
117
117
  * local ordering and appends any remote-only chips at the end — mirroring the
118
- * mergeArrayById strategy. Chips present in the deletedChipIds tombstone map
119
- * are excluded from the merged result.
118
+ * mergeArrayById strategy. When the same chip id exists on both sides, the
119
+ * version with the newer `lastModified` wins (field-level last-write-wins), so
120
+ * edits like renames, time changes, or ownership stamps propagate across
121
+ * devices. Chips present in the deletedChipIds tombstone map are excluded from
122
+ * the merged result.
120
123
  *
121
124
  * @param {Object} localDefs - Local routine definitions (bucket → chip[])
122
125
  * @param {Object} remoteDefs - Remote routine definitions (bucket → chip[])
@@ -141,40 +144,33 @@ export const mergeRoutineDefinitions = (localDefs, remoteDefs, deletedChipIds =
141
144
  for (const bucket of allBuckets) {
142
145
  const localChips = localDefs[bucket] || [];
143
146
  const remoteChips = remoteDefs[bucket] || [];
144
- const localIds = new Set(localChips.map(c => String(c.id)));
145
147
  const remoteMap = new Map(remoteChips.map(c => [String(c.id), c]));
148
+ const localMap = new Map(localChips.map(c => [String(c.id), c]));
149
+ const lm = (c) => c?.lastModified ? new Date(c.lastModified).getTime() : 0;
146
150
 
147
- // Start with local chips in order, filtering out tombstoned chips
148
151
  const bucketMerged = [];
152
+ // Local chips in order; when a chip also exists remotely, take the newer one.
149
153
  for (const chip of localChips) {
150
- if (isTombstoned(chip)) {
151
- localChanged = true; // chip removed locally
152
- continue;
154
+ if (isTombstoned(chip)) { localChanged = true; continue; }
155
+ const r = remoteMap.get(String(chip.id));
156
+ if (r && !isTombstoned(r) && lm(r) > lm(chip)) {
157
+ bucketMerged.push(r); localChanged = true; // remote edit wins
158
+ } else {
159
+ bucketMerged.push(chip);
160
+ if (r && lm(chip) > lm(r)) remoteChanged = true; // local edit is newer
153
161
  }
154
- bucketMerged.push(chip);
155
162
  }
156
-
157
163
  // Append remote-only chips (skip tombstoned)
158
- for (const remoteChip of remoteChips) {
159
- const id = String(remoteChip.id);
160
- if (localIds.has(id)) continue;
161
- if (isTombstoned(remoteChip)) {
162
- remoteChanged = true; // tell remote this was deleted
163
- continue;
164
- }
165
- bucketMerged.push(remoteChip);
166
- localChanged = true;
164
+ for (const r of remoteChips) {
165
+ if (localMap.has(String(r.id))) continue;
166
+ if (isTombstoned(r)) { remoteChanged = true; continue; }
167
+ bucketMerged.push(r); localChanged = true;
167
168
  }
168
-
169
- // Check for local-only chips (remote needs them)
170
- for (const localChip of localChips) {
171
- const id = String(localChip.id);
172
- if (isTombstoned(localChip)) continue; // don't flag deleted chips as needing push
173
- if (!remoteMap.has(id)) {
174
- remoteChanged = true;
175
- }
169
+ // Local-only chips → remote needs them
170
+ for (const c of localChips) {
171
+ if (isTombstoned(c)) continue;
172
+ if (!remoteMap.has(String(c.id))) remoteChanged = true;
176
173
  }
177
-
178
174
  merged[bucket] = bucketMerged;
179
175
  }
180
176
 
@@ -832,6 +828,25 @@ export const mergeSyncData = (localData, remoteData, retentionDays = 90) => {
832
828
  const prunedDeletedGoalIds = pruneTombstones(allDeletedGoalIds, tombstoneCutoff);
833
829
  const prunedDeletedProjectIds = pruneTombstones(allDeletedProjectIds, tombstoneCutoff);
834
830
 
831
+ // ── Multi-user roster: last-write-wins per user by updatedAt (keyed by syncId).
832
+ // NOTE: multiUserEnabled is intentionally NOT merged — it's a per-device toggle.
833
+ const localUsers = Array.isArray(localData.users) ? localData.users : [];
834
+ const remoteUsers = Array.isArray(remoteData.users) ? remoteData.users : [];
835
+ const usersById = new Map(localUsers.map(u => [u.syncId ?? u.id, u]));
836
+ for (const u of remoteUsers) {
837
+ const k = u.syncId ?? u.id;
838
+ const prev = usersById.get(k);
839
+ if (!prev || (u.updatedAt || '') > (prev.updatedAt || '')) usersById.set(k, u);
840
+ }
841
+ const mergedUsers = [...usersById.values()];
842
+
843
+ const rosterSig = (arr) => arr
844
+ .map(u => `${u.syncId ?? u.id}:${u.updatedAt || ''}:${u.deleted ? 1 : 0}:${u.name || ''}`)
845
+ .sort().join('|');
846
+ const mergedSig = rosterSig(mergedUsers);
847
+ if (mergedSig !== rosterSig(localUsers)) localChanged = true;
848
+ if (mergedSig !== rosterSig(remoteUsers)) remoteChanged = true;
849
+
835
850
  return {
836
851
  data: {
837
852
  tasks: finalTasks,
@@ -877,6 +892,7 @@ export const mergeSyncData = (localData, remoteData, retentionDays = 90) => {
877
892
  minimizedSections: localData.minimizedSections, // UI pref — keep local
878
893
  use24HourClock: localData.use24HourClock, // device pref — keep local
879
894
  tombstonePrunedBefore: mergedTombstonePrunedBefore,
895
+ users: mergedUsers,
880
896
  },
881
897
  localChanged,
882
898
  remoteChanged