@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 +7 -2
- package/package.json +1 -1
- package/src/changefeed.js +195 -0
- package/src/firestore-v2.js +5 -0
- package/src/index.js +206 -1
- package/src/snapshot-root.js +92 -0
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
|
|
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.
|
|
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
|
@@ -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
|
+
};
|
package/src/firestore-v2.js
CHANGED
|
@@ -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.
|
|
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
|
+
};
|