@recordtimelabel/core 0.3.0 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/src/index.js +93 -10
package/README.md CHANGED
@@ -20,7 +20,7 @@ During local development an app can consume a sibling checkout with:
20
20
  For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The v2 gateway contract release is:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.3.0"
23
+ "@recordtimelabel/core": "0.3.2"
24
24
  ```
25
25
 
26
26
  If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
@@ -83,6 +83,17 @@ The core also normalizes and preserves these compatibility metadata fields:
83
83
 
84
84
  Deletion must be represented by tombstones or operations. Do not reintroduce the old heuristic that treats "local exists but cloud missing for more than five minutes" as deletion.
85
85
 
86
+ `mergeLocalRemote` resolves deletions by `lifecycleGeneration`, in both directions: a tombstone or
87
+ trash entry is dropped when either side carries that entity as active and newer, and restore
88
+ operations are what bump the generation. When generations tie — an active copy written outside the
89
+ delete/restore path — the active copy's own timestamp breaks the tie against `deletedAt`. A stale
90
+ snapshot on either side therefore cannot re-delete an entity that was already restored.
91
+
92
+ Consumers must not reimplement this reconciliation in an app-level wrapper. In particular, never
93
+ clear a local tombstone just because the remote snapshot still carries the entity as active: that
94
+ is presence-based inference, the same anti-pattern as the five-minute heuristic above, and it is not
95
+ order-independent, so two clients using different rules will not converge on shared cloud data.
96
+
86
97
  ## Firestore v2 Gateway Contract
87
98
 
88
99
  The package exposes platform-neutral v2 document helpers so app adapters can share the same record/folder/settings/ops shape before wiring Firebase SDK or REST calls:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ const REQUIRED_FOLDERS = [
5
5
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
6
6
  ];
7
7
 
8
- export const RECORD_TIMELABEL_CORE_VERSION = '0.3.0';
8
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.3.2';
9
9
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
10
10
  export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
11
11
  export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
@@ -241,6 +241,82 @@ const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
241
241
  return merged;
242
242
  };
243
243
 
244
+ const collectActiveLifecycleStates = (...states) => {
245
+ const records = new Map();
246
+ const folders = new Map();
247
+ const track = (target, id, entity, time) => {
248
+ if (!id) return;
249
+ const generation = Number(entity?.lifecycleGeneration || 0);
250
+ const current = target.get(id);
251
+ if (current && (current.generation > generation ||
252
+ (current.generation === generation && current.time >= time))) return;
253
+ target.set(id, { generation, time });
254
+ };
255
+ states.forEach((state) => {
256
+ Object.entries(state?.records || {}).forEach(([folderId, folderRecords]) => {
257
+ if (VIRTUAL_FOLDER_IDS.has(normalizeId(folderId))) return;
258
+ toArray(folderRecords).forEach((record) => (
259
+ track(records, getRecordId(record), record, getRecordTime(record))
260
+ ));
261
+ });
262
+ toArray(state?.folders).forEach((folder) => (
263
+ track(folders, normalizeId(folder?.id), folder, getFolderTime(folder))
264
+ ));
265
+ });
266
+ return { records, folders };
267
+ };
268
+
269
+ /**
270
+ * A tombstone or trash entry only stays valid while it describes the newest lifecycle event for
271
+ * its entity. Once either side carries that entity as active and newer, the deletion is stale and
272
+ * must be dropped, otherwise mergeTombstones' union lets a stale snapshot re-delete a record that
273
+ * was already restored.
274
+ *
275
+ * "Newer" is decided by lifecycleGeneration first — restore operations bump it, and generations
276
+ * default to 0 so legacy data never outranks a real tombstone. Generations only tie when an active
277
+ * copy was written outside the delete/restore path, and there the active copy's own timestamp
278
+ * breaks the tie. That tie-break also makes this consistent with mergeLocalRemote's local-record
279
+ * loop, which already intends a record newer than `deletedAt` to survive its tombstone.
280
+ */
281
+ const dropSupersededLifecycleDeletions = ({
282
+ deletedRecordTombstones = {},
283
+ deletedFolderTombstones = {},
284
+ trashEntries = {},
285
+ activeGenerations
286
+ }) => {
287
+ const isSuperseded = (target, id, deletion) => {
288
+ const active = target.get(normalizeId(id));
289
+ if (!active) return false;
290
+ const deletionGeneration = Number(deletion?.lifecycleGeneration || 0);
291
+ if (active.generation !== deletionGeneration) return active.generation > deletionGeneration;
292
+ return active.time > toFiniteTimestamp(deletion?.deletedAt);
293
+ };
294
+ const nextRecordTombstones = { ...deletedRecordTombstones };
295
+ Object.entries(nextRecordTombstones).forEach(([recordId, tombstone]) => {
296
+ if (isSuperseded(activeGenerations.records, recordId, tombstone)) {
297
+ delete nextRecordTombstones[recordId];
298
+ }
299
+ });
300
+ const nextFolderTombstones = { ...deletedFolderTombstones };
301
+ Object.entries(nextFolderTombstones).forEach(([folderId, tombstone]) => {
302
+ if (isSuperseded(activeGenerations.folders, folderId, tombstone)) {
303
+ delete nextFolderTombstones[folderId];
304
+ }
305
+ });
306
+ const nextTrashEntries = { ...trashEntries };
307
+ Object.values(nextTrashEntries).forEach((entry) => {
308
+ const target = entry?.kind === 'folder' ? activeGenerations.folders : activeGenerations.records;
309
+ if (isSuperseded(target, entry?.entityId, entry)) {
310
+ delete nextTrashEntries[entry.id];
311
+ }
312
+ });
313
+ return {
314
+ deletedRecordTombstones: nextRecordTombstones,
315
+ deletedFolderTombstones: nextFolderTombstones,
316
+ trashEntries: nextTrashEntries
317
+ };
318
+ };
319
+
244
320
  const resolveRecordFolderId = (folderId, record = {}) => {
245
321
  const candidates = [
246
322
  record.folderId,
@@ -1060,15 +1136,22 @@ export const mergeLocalRemote = ({
1060
1136
  } = {}) => {
1061
1137
  const local = normalizeState(localState);
1062
1138
  const remote = normalizeState(remoteState);
1063
- const deletedRecordTombstones = mergeTombstones(
1064
- remote.deletedRecordTombstones,
1065
- local.deletedRecordTombstones
1066
- );
1067
- const deletedFolderTombstones = mergeTombstones(
1068
- remote.deletedFolderTombstones,
1069
- local.deletedFolderTombstones
1070
- );
1071
- const trashEntries = mergeTrashEntries(remote.trashEntries, local.trashEntries);
1139
+ const {
1140
+ deletedRecordTombstones,
1141
+ deletedFolderTombstones,
1142
+ trashEntries
1143
+ } = dropSupersededLifecycleDeletions({
1144
+ deletedRecordTombstones: mergeTombstones(
1145
+ remote.deletedRecordTombstones,
1146
+ local.deletedRecordTombstones
1147
+ ),
1148
+ deletedFolderTombstones: mergeTombstones(
1149
+ remote.deletedFolderTombstones,
1150
+ local.deletedFolderTombstones
1151
+ ),
1152
+ trashEntries: mergeTrashEntries(remote.trashEntries, local.trashEntries),
1153
+ activeGenerations: collectActiveLifecycleStates(remote, local)
1154
+ });
1072
1155
  const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
1073
1156
  const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
1074
1157