@recordtimelabel/core 0.5.0 → 0.6.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/README.md CHANGED
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
17
17
  "@recordtimelabel/core": "file:../recordtimelabel-core"
18
18
  ```
19
19
 
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 current strict durable transport contract is prepared in package version `0.4.6` (publish it before updating consumers):
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 current published artifact is `0.6.0`:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.4.6"
23
+ "@recordtimelabel/core": "0.6.0"
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.
@@ -74,6 +74,11 @@ If this checkout's `package.json` is ahead of the published version, publish the
74
74
  - `OPERATION_TYPES`
75
75
  - `getActiveTrashEntries(entries, now)`
76
76
  - `RTL_TRASH_RETENTION_MS`
77
+ - `buildFirestoreV2SnapshotRoot({ currentRoot, incomingRoot, mode })`
78
+ - `applyRecordTimeLabelSnapshot(currentState, incomingState, mode)`
79
+ - `composeRecordTimeLabelHydratedState({ remote, pendingOps, importJobs, localNavigation })`
80
+ - `applyFirestoreV2ResolvedChangeBatch({ cache, changes, resolvedDocuments, targetRevision })`
81
+ - `FIRESTORE_V2_BOOTSTRAP_REASONS`
77
82
 
78
83
  The package exports five intentional entrypoints. The root (`@recordtimelabel/core`)
79
84
  keeps the complete backwards-compatible surface, `/protocol` contains only shared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,195 @@
1
+ export const FIRESTORE_V2_BOOTSTRAP_REASONS = Object.freeze({
2
+ REVISION_GAP: 'revision-gap',
3
+ REVISION_DUPLICATE: 'revision-duplicate',
4
+ REVISION_OVERSHOOT: 'revision-overshoot',
5
+ CACHE_AHEAD: 'cache-ahead',
6
+ CHANGED_DOCUMENT_MISSING: 'changed-document-missing',
7
+ ROOT_DOCUMENT_MISSING: 'root-document-missing',
8
+ INVALID_CHANGE_CONFLICT: 'invalid-change-conflict'
9
+ });
10
+
11
+ const toId = (value) => String(value || '').trim();
12
+ const toIdList = (value) => (Array.isArray(value) ? value : []).map(toId).filter(Boolean);
13
+
14
+ const cloneDocuments = (documents = {}) => ({
15
+ root: documents.root ? {...documents.root} : documents.root,
16
+ records: {...(documents.records || {})},
17
+ folders: {...(documents.folders || {})},
18
+ trash: {...(documents.trash || {})},
19
+ lifecycleTombstones: {...(documents.lifecycleTombstones || {})},
20
+ ops: {...(documents.ops || {})}
21
+ });
22
+
23
+ const fail = (reason, inputCache) => ({
24
+ ok: false,
25
+ bootstrapRequired: true,
26
+ reason,
27
+ cache: {
28
+ revision: Number(inputCache?.revision || 0),
29
+ documents: cloneDocuments(inputCache?.documents || {})
30
+ },
31
+ diagnostics: {reason, appliedCount: 0}
32
+ });
33
+
34
+ const hasConflict = (changedIds, deletedIds) => {
35
+ const deleted = new Set(deletedIds);
36
+ return changedIds.some((id) => deleted.has(id));
37
+ };
38
+
39
+ const resolvedDocument = (resolvedDocuments, kind, id) => {
40
+ const bucket = resolvedDocuments?.[kind];
41
+ if (!bucket || !Object.prototype.hasOwnProperty.call(bucket, id)) return undefined;
42
+ return bucket[id];
43
+ };
44
+
45
+ export const applyFirestoreV2ResolvedChangeBatch = ({
46
+ cache = {},
47
+ changes = [],
48
+ resolvedDocuments = {},
49
+ targetRevision = null
50
+ } = {}) => {
51
+ const inputRevision = Number(cache?.revision || 0);
52
+ const inputDocuments = cache?.documents || {};
53
+ const inputCache = {revision: inputRevision, documents: inputDocuments};
54
+ const expectedTarget = targetRevision == null ? null : Number(targetRevision);
55
+ if (!Number.isFinite(inputRevision) || inputRevision < 0) {
56
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
57
+ }
58
+
59
+ const batch = Array.isArray(changes) ? changes : [];
60
+ if (batch.length === 0) {
61
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && expectedTarget < inputRevision) {
62
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
63
+ }
64
+ return {
65
+ ok: true,
66
+ bootstrapRequired: false,
67
+ reason: null,
68
+ cache: {revision: inputRevision, documents: cloneDocuments(inputDocuments)},
69
+ diagnostics: {reason: null, appliedCount: 0}
70
+ };
71
+ }
72
+
73
+ const firstRevision = Number(batch[0]?.revision || 0);
74
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && inputRevision > expectedTarget) {
75
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
76
+ }
77
+ if (firstRevision <= inputRevision) {
78
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE, inputCache);
79
+ }
80
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && firstRevision > expectedTarget) {
81
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
82
+ }
83
+ if (firstRevision > inputRevision + 1) {
84
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
85
+ }
86
+
87
+ const candidate = {
88
+ revision: inputRevision,
89
+ documents: cloneDocuments(inputDocuments)
90
+ };
91
+ let rootChanged = false;
92
+
93
+ for (let index = 0; index < batch.length; index += 1) {
94
+ const change = batch[index] || {};
95
+ const revision = Number(change.revision || 0);
96
+ if (revision !== candidate.revision + 1) {
97
+ const reason = revision <= candidate.revision
98
+ ? FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE
99
+ : FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP;
100
+ return fail(reason, inputCache);
101
+ }
102
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && revision > expectedTarget) {
103
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
104
+ }
105
+
106
+ const changedRecords = toIdList(change.changedRecordIds);
107
+ const deletedRecords = toIdList(change.deletedRecordIds);
108
+ const changedFolders = toIdList(change.changedFolderIds);
109
+ const deletedFolders = toIdList(change.deletedFolderIds);
110
+ const changedTrash = toIdList(change.changedTrashEntryIds);
111
+ const deletedTrash = toIdList(change.deletedTrashEntryIds);
112
+ const lifecycleUpserts = Array.isArray(change.lifecycleTombstoneUpserts)
113
+ ? change.lifecycleTombstoneUpserts
114
+ : [];
115
+ const lifecycleDeletes = toIdList(change.deletedLifecycleTombstoneIds);
116
+ const lifecycleUpsertIds = lifecycleUpserts.map((entry) => toId(entry?.id)).filter(Boolean);
117
+
118
+ if (
119
+ hasConflict(changedRecords, deletedRecords) ||
120
+ hasConflict(changedFolders, deletedFolders) ||
121
+ hasConflict(changedTrash, deletedTrash) ||
122
+ hasConflict(lifecycleUpsertIds, lifecycleDeletes)
123
+ ) {
124
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.INVALID_CHANGE_CONFLICT, inputCache);
125
+ }
126
+
127
+ const missingChanged = [
128
+ ...changedRecords.map((id) => ['records', id]),
129
+ ...changedFolders.map((id) => ['folders', id]),
130
+ ...changedTrash.map((id) => ['trash', id])
131
+ ].some(([kind, id]) => {
132
+ const document = resolvedDocument(resolvedDocuments, kind, id);
133
+ return document == null;
134
+ });
135
+ if (missingChanged) {
136
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CHANGED_DOCUMENT_MISSING, inputCache);
137
+ }
138
+
139
+ if (change.rootChanged === true) {
140
+ const rootDocument = resolvedDocuments.root;
141
+ const rootRevision = Number(rootDocument?.revision || 0);
142
+ if (!rootDocument || !Number.isFinite(rootRevision) || rootRevision < revision) {
143
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
144
+ }
145
+ rootChanged = true;
146
+ }
147
+
148
+ changedRecords.forEach((id) => {
149
+ const document = resolvedDocument(resolvedDocuments, 'records', id);
150
+ candidate.documents.records[id] = {id, ...document};
151
+ });
152
+ changedFolders.forEach((id) => {
153
+ const document = resolvedDocument(resolvedDocuments, 'folders', id);
154
+ candidate.documents.folders[id] = {id, ...document};
155
+ });
156
+ changedTrash.forEach((id) => {
157
+ const document = resolvedDocument(resolvedDocuments, 'trash', id);
158
+ candidate.documents.trash[id] = {id, ...document};
159
+ });
160
+ deletedRecords.forEach((id) => delete candidate.documents.records[id]);
161
+ deletedFolders.forEach((id) => delete candidate.documents.folders[id]);
162
+ deletedTrash.forEach((id) => delete candidate.documents.trash[id]);
163
+ lifecycleUpserts.forEach((entry) => {
164
+ const id = toId(entry?.id);
165
+ if (id) candidate.documents.lifecycleTombstones[id] = {...entry, id};
166
+ });
167
+ lifecycleDeletes.forEach((id) => {
168
+ delete candidate.documents.lifecycleTombstones[id];
169
+ });
170
+ candidate.revision = revision;
171
+ }
172
+
173
+ if (rootChanged) {
174
+ const rootDocument = resolvedDocuments.root;
175
+ candidate.documents.root = {id: 'main', ...rootDocument};
176
+ } else if (candidate.documents.root) {
177
+ candidate.documents.root = {
178
+ ...candidate.documents.root,
179
+ id: 'main',
180
+ revision: candidate.revision
181
+ };
182
+ }
183
+
184
+ return {
185
+ ok: true,
186
+ bootstrapRequired: false,
187
+ reason: null,
188
+ cache: candidate,
189
+ diagnostics: {
190
+ reason: null,
191
+ appliedCount: batch.length,
192
+ revision: candidate.revision
193
+ }
194
+ };
195
+ };
@@ -21,6 +21,11 @@ export {
21
21
  buildRecordTimeLabelRequestId,
22
22
  buildOperationsFromSnapshotDiff,
23
23
  buildStateFromFirestoreV2Documents,
24
+ buildFirestoreV2SnapshotRoot,
25
+ applyRecordTimeLabelSnapshot,
26
+ composeRecordTimeLabelHydratedState,
27
+ applyFirestoreV2ResolvedChangeBatch,
28
+ FIRESTORE_V2_BOOTSTRAP_REASONS,
24
29
  estimateFirestoreV2WriteUnits,
25
30
  createRecordTimeLabelTransportFailureResults,
26
31
  extendFirestoreV2OperationReadPlanWithRecords,
package/src/index.js CHANGED
@@ -31,6 +31,28 @@ const REQUIRED_FOLDERS = [
31
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
32
32
  ];
33
33
 
34
+ import {
35
+ buildFirestoreV2SnapshotRoot,
36
+ isActiveLifecycleTombstone
37
+ } from './snapshot-root.js';
38
+ import {
39
+ applyFirestoreV2ResolvedChangeBatch,
40
+ FIRESTORE_V2_BOOTSTRAP_REASONS
41
+ } from './changefeed.js';
42
+
43
+ export {
44
+ buildFirestoreV2SnapshotRoot,
45
+ compareLifecycleTombstones,
46
+ isActiveLifecycleTombstone,
47
+ mergeRootLifecycleTombstone,
48
+ mergeUniqueSnapshotIds,
49
+ stripSnapshotBulkStatus
50
+ } from './snapshot-root.js';
51
+ export {
52
+ applyFirestoreV2ResolvedChangeBatch,
53
+ FIRESTORE_V2_BOOTSTRAP_REASONS
54
+ } from './changefeed.js';
55
+
34
56
  import {
35
57
  RECORD_GROUP_IDENTITY_VERSION,
36
58
  RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
@@ -87,7 +109,7 @@ export {
87
109
  selectBestMatchingTwitchVod
88
110
  };
89
111
 
90
- export const RECORD_TIMELABEL_CORE_VERSION = '0.5.0';
112
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.0';
91
113
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
92
114
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
93
115
  'fifo-retry-fence',
@@ -2084,6 +2106,184 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
2084
2106
  return { state, pendingOps };
2085
2107
  };
2086
2108
 
2109
+ const collectActiveTombstoneIds = (state = {}, kind) => {
2110
+ const rootField = kind === 'records' ? 'deletedRecordTombstones' : 'deletedFolderTombstones';
2111
+ const blocked = new Set();
2112
+ Object.entries(state?.[rootField] || {}).forEach(([id, tombstone]) => {
2113
+ if (isActiveLifecycleTombstone(tombstone)) blocked.add(id);
2114
+ });
2115
+ Object.entries(state?.lifecycleTombstones || {}).forEach(([id, tombstone]) => {
2116
+ if (!isActiveLifecycleTombstone(tombstone)) return;
2117
+ const entityId = String(tombstone.entityId || id.split(':').slice(1).join(':') || '').trim();
2118
+ const tombstoneKind = String(tombstone.kind || id.split(':')[0] || '').trim();
2119
+ if (kind === 'records' && tombstoneKind === 'record' && entityId) blocked.add(entityId);
2120
+ if (kind === 'folders' && tombstoneKind === 'folder' && entityId) blocked.add(entityId);
2121
+ });
2122
+ return blocked;
2123
+ };
2124
+
2125
+ const mergeLifecycleTombstoneDocuments = (current = {}, incoming = {}) => {
2126
+ const merged = {...(current || {})};
2127
+ Object.entries(incoming || {}).forEach(([id, tombstone]) => {
2128
+ const existing = merged[id];
2129
+ if (!existing) {
2130
+ merged[id] = tombstone;
2131
+ return;
2132
+ }
2133
+ const currentGeneration = Number(existing.lifecycleGeneration || 0);
2134
+ const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
2135
+ const currentDeletedAt = Number(existing.deletedAt || 0);
2136
+ const nextDeletedAt = Number(tombstone?.deletedAt || 0);
2137
+ if (nextGeneration > currentGeneration ||
2138
+ (nextGeneration === currentGeneration && nextDeletedAt >= currentDeletedAt)) {
2139
+ merged[id] = tombstone;
2140
+ }
2141
+ });
2142
+ return merged;
2143
+ };
2144
+
2145
+ const collectDocumentIdChanges = (currentDocs = {}, nextDocs = {}) => {
2146
+ const currentIds = new Set(Object.keys(currentDocs || {}));
2147
+ const nextIds = new Set(Object.keys(nextDocs || {}));
2148
+ return {
2149
+ upsertIds: [...nextIds].filter((id) => (
2150
+ JSON.stringify(currentDocs?.[id] || null) !== JSON.stringify(nextDocs?.[id] || null)
2151
+ )).sort(),
2152
+ deleteIds: [...currentIds].filter((id) => !nextIds.has(id)).sort()
2153
+ };
2154
+ };
2155
+
2156
+ export const applyRecordTimeLabelSnapshot = (
2157
+ currentState = {},
2158
+ incomingState = {},
2159
+ mode = 'snapshot.replace'
2160
+ ) => {
2161
+ const snapshotMode = mode === 'snapshot.merge' ? 'snapshot.merge' : 'snapshot.replace';
2162
+ const isMerge = snapshotMode === 'snapshot.merge';
2163
+ const current = normalizeState(currentState || {});
2164
+ const incoming = normalizeState(incomingState || {});
2165
+ const currentDocs = buildFirestoreV2DocumentsFromState(current);
2166
+ const incomingDocs = buildFirestoreV2DocumentsFromState(incoming);
2167
+ const blockedRecordIds = collectActiveTombstoneIds(current, 'records');
2168
+ const blockedFolderIds = collectActiveTombstoneIds(current, 'folders');
2169
+
2170
+ const mergedRecordDocs = {...(currentDocs.records || {})};
2171
+ Object.entries(incomingDocs.records || {}).forEach(([id, data]) => {
2172
+ if (blockedRecordIds.has(id)) return;
2173
+ mergedRecordDocs[id] = data;
2174
+ });
2175
+ const recordDocs = isMerge
2176
+ ? mergedRecordDocs
2177
+ : Object.fromEntries(Object.entries(mergedRecordDocs).filter(([id]) => (
2178
+ incomingDocs.records?.[id] && !blockedRecordIds.has(id)
2179
+ )));
2180
+
2181
+ const mergedFolderDocs = {...(currentDocs.folders || {})};
2182
+ Object.entries(incomingDocs.folders || {}).forEach(([id, data]) => {
2183
+ if (blockedFolderIds.has(id)) return;
2184
+ mergedFolderDocs[id] = data;
2185
+ });
2186
+ const folderDocs = isMerge
2187
+ ? mergedFolderDocs
2188
+ : Object.fromEntries(Object.entries(mergedFolderDocs).filter(([id]) => (
2189
+ incomingDocs.folders?.[id] && !blockedFolderIds.has(id)
2190
+ )));
2191
+
2192
+ const nextRoot = buildFirestoreV2SnapshotRoot({
2193
+ currentRoot: currentDocs.root || {},
2194
+ incomingRoot: incomingDocs.root || {},
2195
+ mode: snapshotMode
2196
+ });
2197
+ const {state} = buildStateFromFirestoreV2Documents({
2198
+ root: {id: 'main', ...nextRoot},
2199
+ records: recordDocs,
2200
+ folders: folderDocs,
2201
+ trash: {...(currentDocs.trash || {})},
2202
+ ops: {},
2203
+ lifecycleTombstones: mergeLifecycleTombstoneDocuments(
2204
+ currentDocs.lifecycleTombstones,
2205
+ incomingDocs.lifecycleTombstones
2206
+ )
2207
+ });
2208
+ const nextDocs = buildFirestoreV2DocumentsFromState(state);
2209
+ const recordChanges = collectDocumentIdChanges(currentDocs.records, nextDocs.records);
2210
+ const folderChanges = collectDocumentIdChanges(currentDocs.folders, nextDocs.folders);
2211
+ return {
2212
+ state: {...state, pendingOps: []},
2213
+ documentChanges: {
2214
+ records: recordChanges,
2215
+ folders: folderChanges,
2216
+ trash: {upsertIds: [], deleteIds: []},
2217
+ root: true
2218
+ },
2219
+ diagnostics: {
2220
+ mode: snapshotMode,
2221
+ blockedRecordCount: blockedRecordIds.size,
2222
+ blockedFolderCount: blockedFolderIds.size,
2223
+ recordUpsertCount: recordChanges.upsertIds.length,
2224
+ recordDeleteCount: recordChanges.deleteIds.length,
2225
+ folderUpsertCount: folderChanges.upsertIds.length,
2226
+ folderDeleteCount: folderChanges.deleteIds.length,
2227
+ trashPreserved: true
2228
+ }
2229
+ };
2230
+ };
2231
+
2232
+ export const composeRecordTimeLabelHydratedState = ({
2233
+ remote = {},
2234
+ pendingOps = [],
2235
+ importJobs = [],
2236
+ localNavigation = null
2237
+ } = {}) => {
2238
+ const jobs = (Array.isArray(importJobs) ? importJobs : [])
2239
+ .filter((job) => job && typeof job === 'object')
2240
+ .sort((left, right) => Number(left?.createdAt || 0) - Number(right?.createdAt || 0));
2241
+ const coveredOperationIds = new Set();
2242
+ let state = normalizeState(remote || {});
2243
+ jobs.forEach((job) => {
2244
+ state = applyRecordTimeLabelSnapshot(
2245
+ state,
2246
+ job.state || {},
2247
+ job.mode === 'snapshot.merge' ? 'snapshot.merge' : 'snapshot.replace'
2248
+ ).state;
2249
+ (Array.isArray(job.includedOperationIds) ? job.includedOperationIds : []).forEach((id) => {
2250
+ if (id) coveredOperationIds.add(id);
2251
+ });
2252
+ });
2253
+ (Array.isArray(pendingOps) ? pendingOps : [])
2254
+ .filter((operation) => operation?.id && !coveredOperationIds.has(operation.id))
2255
+ .forEach((operation) => {
2256
+ state = applyRecordTimeLabelOperation(state, operation);
2257
+ });
2258
+
2259
+ const navigation = localNavigation && typeof localNavigation === 'object' ? localNavigation : {};
2260
+ const nextSettings = {...(state.settings || {})};
2261
+ const lastActiveFolderId = navigation.lastActiveFolderId;
2262
+ if (lastActiveFolderId) {
2263
+ const folderExists = (Array.isArray(state.folders) ? state.folders : [])
2264
+ .some((folder) => folder?.id === lastActiveFolderId);
2265
+ if (folderExists) nextSettings.lastActiveFolderId = lastActiveFolderId;
2266
+ else delete nextSettings.lastActiveFolderId;
2267
+ } else {
2268
+ delete nextSettings.lastActiveFolderId;
2269
+ }
2270
+ const localExpandedGroups = Array.isArray(navigation.expandedGroups) ? navigation.expandedGroups : [];
2271
+ const expandedGroups = Array.from(new Set([
2272
+ ...(Array.isArray(state.expandedGroups) ? state.expandedGroups : []),
2273
+ ...localExpandedGroups
2274
+ ]));
2275
+ const remoteRevision = Number(remote?.revision);
2276
+ return {
2277
+ state: {
2278
+ ...state,
2279
+ settings: nextSettings,
2280
+ expandedGroups,
2281
+ ...(Number.isFinite(remoteRevision) ? {revision: remoteRevision} : {})
2282
+ },
2283
+ coveredOperationIds: Array.from(coveredOperationIds)
2284
+ };
2285
+ };
2286
+
2087
2287
  const mergeOrderArrays = (normalizer, ...orders) => {
2088
2288
  const seen = new Set();
2089
2289
  const result = [];
@@ -5388,6 +5588,11 @@ export default {
5388
5588
  buildFirestoreV2DocumentsFromState,
5389
5589
  buildFirestoreV2DocumentChangeSet,
5390
5590
  buildStateFromFirestoreV2Documents,
5591
+ buildFirestoreV2SnapshotRoot,
5592
+ applyRecordTimeLabelSnapshot,
5593
+ composeRecordTimeLabelHydratedState,
5594
+ applyFirestoreV2ResolvedChangeBatch,
5595
+ FIRESTORE_V2_BOOTSTRAP_REASONS,
5391
5596
  validateRecordTimeLabelOperationBatch,
5392
5597
  buildFirestoreV2OperationReadPlan,
5393
5598
  extendFirestoreV2OperationReadPlanWithRecords,
@@ -0,0 +1,92 @@
1
+ const toId = (value) => String(value || '').trim();
2
+
3
+ export const mergeUniqueSnapshotIds = (...lists) => {
4
+ const seen = new Set();
5
+ return lists.flatMap((list) => (Array.isArray(list) ? list : []))
6
+ .filter((id) => {
7
+ const value = toId(id);
8
+ if (!value || seen.has(value)) return false;
9
+ seen.add(value);
10
+ return true;
11
+ });
12
+ };
13
+
14
+ export const stripSnapshotBulkStatus = (root = {}) => {
15
+ const stableRoot = {...(root || {})};
16
+ delete stableRoot.bulkStatus;
17
+ return stableRoot;
18
+ };
19
+
20
+ const toFiniteTimestamp = (value) => {
21
+ const timestamp = Number(value);
22
+ return Number.isFinite(timestamp) ? timestamp : 0;
23
+ };
24
+
25
+ const tombstoneGeneration = (value) => {
26
+ const generation = Number(value?.lifecycleGeneration || 0);
27
+ return Number.isFinite(generation) ? generation : 0;
28
+ };
29
+
30
+ export const compareLifecycleTombstones = (left = {}, right = {}) => {
31
+ const generationDelta = tombstoneGeneration(left) - tombstoneGeneration(right);
32
+ if (generationDelta !== 0) return generationDelta;
33
+ return toFiniteTimestamp(left.deletedAt) - toFiniteTimestamp(right.deletedAt);
34
+ };
35
+
36
+ export const mergeRootLifecycleTombstone = (current, incoming) => {
37
+ if (current == null) return incoming;
38
+ if (incoming == null) return current;
39
+ return compareLifecycleTombstones(incoming, current) >= 0 ? incoming : current;
40
+ };
41
+
42
+ export const isActiveLifecycleTombstone = (value) => (
43
+ tombstoneGeneration(value) > 0 && toFiniteTimestamp(value?.deletedAt) > 0
44
+ );
45
+
46
+ const mergeTombstoneMap = (current = {}, incoming = {}) => {
47
+ const result = {};
48
+ Object.keys(current || {}).forEach((id) => {
49
+ result[id] = current[id];
50
+ });
51
+ Object.keys(incoming || {}).forEach((id) => {
52
+ result[id] = mergeRootLifecycleTombstone(current?.[id], incoming[id]);
53
+ });
54
+ return result;
55
+ };
56
+
57
+ export const buildFirestoreV2SnapshotRoot = ({
58
+ currentRoot = {},
59
+ incomingRoot = {},
60
+ mode = 'snapshot.replace'
61
+ } = {}) => {
62
+ const current = stripSnapshotBulkStatus(currentRoot);
63
+ const incoming = stripSnapshotBulkStatus(incomingRoot);
64
+ const isMerge = mode === 'snapshot.merge';
65
+ if (isMerge) {
66
+ return {
67
+ ...current,
68
+ ...incoming,
69
+ folderOrder: mergeUniqueSnapshotIds(incoming.folderOrder, current.folderOrder),
70
+ groupOrder: mergeUniqueSnapshotIds(incoming.groupOrder, current.groupOrder),
71
+ deletedRecordTombstones: mergeTombstoneMap(
72
+ current.deletedRecordTombstones,
73
+ incoming.deletedRecordTombstones
74
+ ),
75
+ deletedFolderTombstones: mergeTombstoneMap(
76
+ current.deletedFolderTombstones,
77
+ incoming.deletedFolderTombstones
78
+ )
79
+ };
80
+ }
81
+ return {
82
+ ...incoming,
83
+ deletedRecordTombstones: mergeTombstoneMap(
84
+ current.deletedRecordTombstones,
85
+ incoming.deletedRecordTombstones
86
+ ),
87
+ deletedFolderTombstones: mergeTombstoneMap(
88
+ current.deletedFolderTombstones,
89
+ incoming.deletedFolderTombstones
90
+ )
91
+ };
92
+ };