@recordtimelabel/core 0.3.1 → 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.
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/index.js +30 -14
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.
|
|
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.
|
|
@@ -84,10 +84,15 @@ The core also normalizes and preserves these compatibility metadata fields:
|
|
|
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
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
91
96
|
|
|
92
97
|
## Firestore v2 Gateway Contract
|
|
93
98
|
|
package/package.json
CHANGED
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.
|
|
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,30 +241,42 @@ const mergeTrashEntries = (remoteEntries = {}, localEntries = {}) => {
|
|
|
241
241
|
return merged;
|
|
242
242
|
};
|
|
243
243
|
|
|
244
|
-
const
|
|
244
|
+
const collectActiveLifecycleStates = (...states) => {
|
|
245
245
|
const records = new Map();
|
|
246
246
|
const folders = new Map();
|
|
247
|
-
const track = (target, id, entity) => {
|
|
247
|
+
const track = (target, id, entity, time) => {
|
|
248
248
|
if (!id) return;
|
|
249
249
|
const generation = Number(entity?.lifecycleGeneration || 0);
|
|
250
|
-
|
|
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 });
|
|
251
254
|
};
|
|
252
255
|
states.forEach((state) => {
|
|
253
256
|
Object.entries(state?.records || {}).forEach(([folderId, folderRecords]) => {
|
|
254
257
|
if (VIRTUAL_FOLDER_IDS.has(normalizeId(folderId))) return;
|
|
255
|
-
toArray(folderRecords).forEach((record) =>
|
|
258
|
+
toArray(folderRecords).forEach((record) => (
|
|
259
|
+
track(records, getRecordId(record), record, getRecordTime(record))
|
|
260
|
+
));
|
|
256
261
|
});
|
|
257
|
-
toArray(state?.folders).forEach((folder) =>
|
|
262
|
+
toArray(state?.folders).forEach((folder) => (
|
|
263
|
+
track(folders, normalizeId(folder?.id), folder, getFolderTime(folder))
|
|
264
|
+
));
|
|
258
265
|
});
|
|
259
266
|
return { records, folders };
|
|
260
267
|
};
|
|
261
268
|
|
|
262
269
|
/**
|
|
263
270
|
* A tombstone or trash entry only stays valid while it describes the newest lifecycle event for
|
|
264
|
-
* its entity. Once either side carries that entity as active
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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.
|
|
268
280
|
*/
|
|
269
281
|
const dropSupersededLifecycleDeletions = ({
|
|
270
282
|
deletedRecordTombstones = {},
|
|
@@ -272,9 +284,13 @@ const dropSupersededLifecycleDeletions = ({
|
|
|
272
284
|
trashEntries = {},
|
|
273
285
|
activeGenerations
|
|
274
286
|
}) => {
|
|
275
|
-
const isSuperseded = (target, id, deletion) =>
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
};
|
|
278
294
|
const nextRecordTombstones = { ...deletedRecordTombstones };
|
|
279
295
|
Object.entries(nextRecordTombstones).forEach(([recordId, tombstone]) => {
|
|
280
296
|
if (isSuperseded(activeGenerations.records, recordId, tombstone)) {
|
|
@@ -1134,7 +1150,7 @@ export const mergeLocalRemote = ({
|
|
|
1134
1150
|
local.deletedFolderTombstones
|
|
1135
1151
|
),
|
|
1136
1152
|
trashEntries: mergeTrashEntries(remote.trashEntries, local.trashEntries),
|
|
1137
|
-
activeGenerations:
|
|
1153
|
+
activeGenerations: collectActiveLifecycleStates(remote, local)
|
|
1138
1154
|
});
|
|
1139
1155
|
const folders = mergeFolders(remote.folders, local.folders, deletedFolderTombstones);
|
|
1140
1156
|
const folderIds = new Set(ensureFolders(folders).map((folder) => folder.id));
|