@recordtimelabel/core 0.5.0 → 0.6.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/README.md +7 -2
- package/package.json +1 -1
- package/src/changefeed.js +231 -0
- package/src/domain/group-identity.js +38 -1
- package/src/domain/hash.js +6 -0
- package/src/domain/twitch-vod.js +20 -1
- package/src/domain.js +6 -0
- package/src/firestore-v2.js +5 -0
- package/src/index.js +322 -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.1`:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.
|
|
23
|
+
"@recordtimelabel/core": "0.6.1"
|
|
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,231 @@
|
|
|
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 decodeLifecycleTombstoneId = (value) => {
|
|
15
|
+
const id = toId(value);
|
|
16
|
+
if (!id) return '';
|
|
17
|
+
try {
|
|
18
|
+
return decodeURIComponent(id);
|
|
19
|
+
} catch {
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const lifecycleTombstoneDocumentKey = (value) => {
|
|
25
|
+
const logicalId = decodeLifecycleTombstoneId(value);
|
|
26
|
+
return logicalId ? encodeURIComponent(logicalId) : '';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const lifecycleTombstoneKeyAliases = (value) => {
|
|
30
|
+
const logicalId = decodeLifecycleTombstoneId(value);
|
|
31
|
+
if (!logicalId) return [];
|
|
32
|
+
return [...new Set([toId(value), logicalId, encodeURIComponent(logicalId)].filter(Boolean))];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const cloneDocuments = (documents = {}) => ({
|
|
36
|
+
root: documents.root ? {...documents.root} : documents.root,
|
|
37
|
+
records: {...(documents.records || {})},
|
|
38
|
+
folders: {...(documents.folders || {})},
|
|
39
|
+
trash: {...(documents.trash || {})},
|
|
40
|
+
lifecycleTombstones: {...(documents.lifecycleTombstones || {})},
|
|
41
|
+
ops: {...(documents.ops || {})}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const fail = (reason, inputCache) => ({
|
|
45
|
+
ok: false,
|
|
46
|
+
bootstrapRequired: true,
|
|
47
|
+
reason,
|
|
48
|
+
cache: {
|
|
49
|
+
revision: Number(inputCache?.revision || 0),
|
|
50
|
+
documents: cloneDocuments(inputCache?.documents || {})
|
|
51
|
+
},
|
|
52
|
+
diagnostics: {reason, appliedCount: 0}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const hasConflict = (changedIds, deletedIds) => {
|
|
56
|
+
const deleted = new Set(deletedIds);
|
|
57
|
+
return changedIds.some((id) => deleted.has(id));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const resolvedDocument = (resolvedDocuments, kind, id) => {
|
|
61
|
+
const bucket = resolvedDocuments?.[kind];
|
|
62
|
+
if (!bucket || !Object.prototype.hasOwnProperty.call(bucket, id)) return undefined;
|
|
63
|
+
return bucket[id];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const applyFirestoreV2ResolvedChangeBatch = ({
|
|
67
|
+
cache = {},
|
|
68
|
+
changes = [],
|
|
69
|
+
resolvedDocuments = {},
|
|
70
|
+
targetRevision = null
|
|
71
|
+
} = {}) => {
|
|
72
|
+
const inputRevision = Number(cache?.revision || 0);
|
|
73
|
+
const inputDocuments = cache?.documents || {};
|
|
74
|
+
const inputCache = {revision: inputRevision, documents: inputDocuments};
|
|
75
|
+
const expectedTarget = targetRevision == null ? null : Number(targetRevision);
|
|
76
|
+
if (!Number.isFinite(inputRevision) || inputRevision < 0) {
|
|
77
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const batch = Array.isArray(changes) ? changes : [];
|
|
81
|
+
if (batch.length === 0) {
|
|
82
|
+
if (expectedTarget != null && Number.isFinite(expectedTarget) && expectedTarget < inputRevision) {
|
|
83
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
bootstrapRequired: false,
|
|
88
|
+
reason: null,
|
|
89
|
+
cache: {revision: inputRevision, documents: cloneDocuments(inputDocuments)},
|
|
90
|
+
diagnostics: {reason: null, appliedCount: 0}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const firstRevision = Number(batch[0]?.revision || 0);
|
|
95
|
+
if (expectedTarget != null && Number.isFinite(expectedTarget) && inputRevision > expectedTarget) {
|
|
96
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
|
|
97
|
+
}
|
|
98
|
+
if (firstRevision <= inputRevision) {
|
|
99
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE, inputCache);
|
|
100
|
+
}
|
|
101
|
+
if (expectedTarget != null && Number.isFinite(expectedTarget) && firstRevision > expectedTarget) {
|
|
102
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
|
|
103
|
+
}
|
|
104
|
+
if (firstRevision > inputRevision + 1) {
|
|
105
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const candidate = {
|
|
109
|
+
revision: inputRevision,
|
|
110
|
+
documents: cloneDocuments(inputDocuments)
|
|
111
|
+
};
|
|
112
|
+
let rootChanged = false;
|
|
113
|
+
|
|
114
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
115
|
+
const change = batch[index] || {};
|
|
116
|
+
const revision = Number(change.revision || 0);
|
|
117
|
+
if (revision !== candidate.revision + 1) {
|
|
118
|
+
const reason = revision <= candidate.revision
|
|
119
|
+
? FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE
|
|
120
|
+
: FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP;
|
|
121
|
+
return fail(reason, inputCache);
|
|
122
|
+
}
|
|
123
|
+
if (expectedTarget != null && Number.isFinite(expectedTarget) && revision > expectedTarget) {
|
|
124
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const changedRecords = toIdList(change.changedRecordIds);
|
|
128
|
+
const deletedRecords = toIdList(change.deletedRecordIds);
|
|
129
|
+
const changedFolders = toIdList(change.changedFolderIds);
|
|
130
|
+
const deletedFolders = toIdList(change.deletedFolderIds);
|
|
131
|
+
const changedTrash = toIdList(change.changedTrashEntryIds);
|
|
132
|
+
const deletedTrash = toIdList(change.deletedTrashEntryIds);
|
|
133
|
+
const lifecycleUpserts = Array.isArray(change.lifecycleTombstoneUpserts)
|
|
134
|
+
? change.lifecycleTombstoneUpserts
|
|
135
|
+
: [];
|
|
136
|
+
const lifecycleDeletes = toIdList(change.deletedLifecycleTombstoneIds);
|
|
137
|
+
const lifecycleUpsertIds = lifecycleUpserts
|
|
138
|
+
.map((entry) => lifecycleTombstoneDocumentKey(entry?.id))
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
const lifecycleDeleteIds = lifecycleDeletes
|
|
141
|
+
.map((id) => lifecycleTombstoneDocumentKey(id))
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
hasConflict(changedRecords, deletedRecords) ||
|
|
146
|
+
hasConflict(changedFolders, deletedFolders) ||
|
|
147
|
+
hasConflict(changedTrash, deletedTrash) ||
|
|
148
|
+
hasConflict(lifecycleUpsertIds, lifecycleDeleteIds)
|
|
149
|
+
) {
|
|
150
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.INVALID_CHANGE_CONFLICT, inputCache);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const missingChanged = [
|
|
154
|
+
...changedRecords.map((id) => ['records', id]),
|
|
155
|
+
...changedFolders.map((id) => ['folders', id]),
|
|
156
|
+
...changedTrash.map((id) => ['trash', id])
|
|
157
|
+
].some(([kind, id]) => {
|
|
158
|
+
const document = resolvedDocument(resolvedDocuments, kind, id);
|
|
159
|
+
return document == null;
|
|
160
|
+
});
|
|
161
|
+
if (missingChanged) {
|
|
162
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CHANGED_DOCUMENT_MISSING, inputCache);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (change.rootChanged === true) {
|
|
166
|
+
const rootDocument = resolvedDocuments.root;
|
|
167
|
+
const rootRevision = Number(rootDocument?.revision || 0);
|
|
168
|
+
if (!rootDocument || !Number.isFinite(rootRevision) || rootRevision < revision) {
|
|
169
|
+
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
|
|
170
|
+
}
|
|
171
|
+
rootChanged = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
changedRecords.forEach((id) => {
|
|
175
|
+
const document = resolvedDocument(resolvedDocuments, 'records', id);
|
|
176
|
+
candidate.documents.records[id] = {id, ...document};
|
|
177
|
+
});
|
|
178
|
+
changedFolders.forEach((id) => {
|
|
179
|
+
const document = resolvedDocument(resolvedDocuments, 'folders', id);
|
|
180
|
+
candidate.documents.folders[id] = {id, ...document};
|
|
181
|
+
});
|
|
182
|
+
changedTrash.forEach((id) => {
|
|
183
|
+
const document = resolvedDocument(resolvedDocuments, 'trash', id);
|
|
184
|
+
candidate.documents.trash[id] = {id, ...document};
|
|
185
|
+
});
|
|
186
|
+
deletedRecords.forEach((id) => delete candidate.documents.records[id]);
|
|
187
|
+
deletedFolders.forEach((id) => delete candidate.documents.folders[id]);
|
|
188
|
+
deletedTrash.forEach((id) => delete candidate.documents.trash[id]);
|
|
189
|
+
lifecycleUpserts.forEach((entry) => {
|
|
190
|
+
const logicalId = decodeLifecycleTombstoneId(entry?.id);
|
|
191
|
+
const documentKey = lifecycleTombstoneDocumentKey(logicalId);
|
|
192
|
+
if (!documentKey) return;
|
|
193
|
+
lifecycleTombstoneKeyAliases(logicalId).forEach((alias) => {
|
|
194
|
+
delete candidate.documents.lifecycleTombstones[alias];
|
|
195
|
+
});
|
|
196
|
+
candidate.documents.lifecycleTombstones[documentKey] = {
|
|
197
|
+
...entry,
|
|
198
|
+
id: logicalId
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
lifecycleDeletes.forEach((id) => {
|
|
202
|
+
lifecycleTombstoneKeyAliases(id).forEach((alias) => {
|
|
203
|
+
delete candidate.documents.lifecycleTombstones[alias];
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
candidate.revision = revision;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (rootChanged) {
|
|
210
|
+
const rootDocument = resolvedDocuments.root;
|
|
211
|
+
candidate.documents.root = {id: 'main', ...rootDocument};
|
|
212
|
+
} else if (candidate.documents.root) {
|
|
213
|
+
candidate.documents.root = {
|
|
214
|
+
...candidate.documents.root,
|
|
215
|
+
id: 'main',
|
|
216
|
+
revision: candidate.revision
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
bootstrapRequired: false,
|
|
223
|
+
reason: null,
|
|
224
|
+
cache: candidate,
|
|
225
|
+
diagnostics: {
|
|
226
|
+
reason: null,
|
|
227
|
+
appliedCount: batch.length,
|
|
228
|
+
revision: candidate.revision
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
3
|
+
fingerprintCanonicalJson
|
|
4
|
+
} from './hash.js';
|
|
2
5
|
import {
|
|
3
6
|
DEFAULT_UNKNOWN_CHANNEL,
|
|
4
7
|
DEFAULT_UNKNOWN_TITLE,
|
|
@@ -309,6 +312,26 @@ export const buildRecordTimeLabelGroupIndex = ({
|
|
|
309
312
|
);
|
|
310
313
|
addAliasTarget(aliasTargetsByGroupId, buildLegacyRecordTimeLabelGroupId(record, platform, unknownTitle), groupId);
|
|
311
314
|
addAliasTarget(aliasTargetsByGroupId, buildFallbackRecordGroupId(record, platform, unknownTitle), groupId);
|
|
315
|
+
const declaredPlatform = normalizeText(record?.platform).toLowerCase();
|
|
316
|
+
if (declaredPlatform !== 'twitch' && declaredPlatform !== 'youtube') {
|
|
317
|
+
['twitch', 'youtube'].forEach((themePlatform) => {
|
|
318
|
+
addAliasTarget(
|
|
319
|
+
aliasTargetsByGroupId,
|
|
320
|
+
buildLegacyRecordTimeLabelGroupId(record, themePlatform, unknownTitle),
|
|
321
|
+
groupId
|
|
322
|
+
);
|
|
323
|
+
addAliasTarget(
|
|
324
|
+
aliasTargetsByGroupId,
|
|
325
|
+
buildFallbackRecordGroupId(record, themePlatform, unknownTitle),
|
|
326
|
+
groupId
|
|
327
|
+
);
|
|
328
|
+
addAliasTarget(
|
|
329
|
+
aliasTargetsByGroupId,
|
|
330
|
+
buildRecordTimeLabelStandaloneGroupId(record, themePlatform, unknownTitle),
|
|
331
|
+
groupId
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
312
335
|
});
|
|
313
336
|
|
|
314
337
|
const groups = Object.values(groupsById).sort((left, right) => {
|
|
@@ -454,3 +477,17 @@ export const buildRecordTimeLabelGroupMetadata = ({
|
|
|
454
477
|
export const buildRecordTimeLabelGroupReorderOperationId = (previousOrder = [], nextOrder = []) => (
|
|
455
478
|
`group.reorder:canonical:${fingerprintCanonicalJson(normalizeIdList(previousOrder))}:${fingerprintCanonicalJson(normalizeIdList(nextOrder))}`
|
|
456
479
|
);
|
|
480
|
+
|
|
481
|
+
export const buildRecordTimeLabelGroupReorderOperation = ({
|
|
482
|
+
previousOrder = [],
|
|
483
|
+
nextOrder = []
|
|
484
|
+
} = {}) => {
|
|
485
|
+
const groupOrder = normalizeIdList(nextOrder);
|
|
486
|
+
const id = buildRecordTimeLabelGroupReorderOperationId(previousOrder, groupOrder);
|
|
487
|
+
return {
|
|
488
|
+
id,
|
|
489
|
+
type: 'group.reorder',
|
|
490
|
+
createdAt: createdAtForStableRecordTimeLabelOperationId(id),
|
|
491
|
+
payload: {groupOrder}
|
|
492
|
+
};
|
|
493
|
+
};
|
package/src/domain/hash.js
CHANGED
|
@@ -145,3 +145,9 @@ export const digestCanonicalJson = (value) => sha256Hex(canonicalizeJson(value))
|
|
|
145
145
|
export const fingerprintCanonicalJson = (value, length = 16) => (
|
|
146
146
|
digestCanonicalJson(value).slice(0, length)
|
|
147
147
|
);
|
|
148
|
+
|
|
149
|
+
export const createdAtForStableRecordTimeLabelOperationId = (operationId) => {
|
|
150
|
+
const digest = digestCanonicalJson(String(operationId || ''));
|
|
151
|
+
const value = Number.parseInt(digest.slice(0, 12), 16);
|
|
152
|
+
return Number.isSafeInteger(value) && value > 0 ? value : 1;
|
|
153
|
+
};
|
package/src/domain/twitch-vod.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
3
|
+
fingerprintCanonicalJson
|
|
4
|
+
} from './hash.js';
|
|
2
5
|
import {buildRecordTimeLabelGroupIndex} from './group-identity.js';
|
|
3
6
|
import {
|
|
4
7
|
DEFAULT_UNKNOWN_TITLE,
|
|
@@ -197,6 +200,22 @@ export const buildRecordTimeLabelVodPatchOperationId = (recordId, patch = {}) =>
|
|
|
197
200
|
`record.update:${normalizeId(recordId)}:vod-patch:${fingerprintCanonicalJson(patch || {})}`
|
|
198
201
|
);
|
|
199
202
|
|
|
203
|
+
export const buildRecordTimeLabelVodPatchOperation = ({recordId, patch = {}} = {}) => {
|
|
204
|
+
const safePatch = {...(patch || {})};
|
|
205
|
+
delete safePatch.updatedAt;
|
|
206
|
+
const id = buildRecordTimeLabelVodPatchOperationId(recordId, safePatch);
|
|
207
|
+
const createdAt = createdAtForStableRecordTimeLabelOperationId(id);
|
|
208
|
+
return {
|
|
209
|
+
id,
|
|
210
|
+
type: 'record.update',
|
|
211
|
+
createdAt,
|
|
212
|
+
payload: {
|
|
213
|
+
recordId: normalizeId(recordId),
|
|
214
|
+
patch: {...safePatch, updatedAt: createdAt}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
|
|
200
219
|
export const buildKnownTwitchVodRecordPatches = ({
|
|
201
220
|
records = [],
|
|
202
221
|
groupIndex = null,
|
package/src/domain.js
CHANGED
|
@@ -10,11 +10,16 @@ export {
|
|
|
10
10
|
buildLegacyRecordTimeLabelGroupId,
|
|
11
11
|
buildRecordTimeLabelGroupIndex,
|
|
12
12
|
buildRecordTimeLabelGroupMetadata,
|
|
13
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
13
14
|
buildRecordTimeLabelGroupReorderOperationId,
|
|
14
15
|
buildRecordTimeLabelStandaloneGroupId,
|
|
15
16
|
canonicalizeRecordTimeLabelGroupView
|
|
16
17
|
} from './domain/group-identity.js';
|
|
17
18
|
|
|
19
|
+
export {
|
|
20
|
+
createdAtForStableRecordTimeLabelOperationId
|
|
21
|
+
} from './domain/hash.js';
|
|
22
|
+
|
|
18
23
|
export {
|
|
19
24
|
extractTwitchVodIdFromUrl,
|
|
20
25
|
extractYouTubeVideoIdFromUrl
|
|
@@ -22,6 +27,7 @@ export {
|
|
|
22
27
|
|
|
23
28
|
export {
|
|
24
29
|
buildKnownTwitchVodRecordPatches,
|
|
30
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
25
31
|
buildRecordTimeLabelVodPatchOperationId,
|
|
26
32
|
calculateVodTitleSimilarity,
|
|
27
33
|
normalizeVodTitleForMatch,
|
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,32 @@ 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
|
+
lifecycleTombstoneDocumentKey,
|
|
42
|
+
lifecycleTombstoneKeyAliases
|
|
43
|
+
} from './changefeed.js';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
buildFirestoreV2SnapshotRoot,
|
|
47
|
+
compareLifecycleTombstones,
|
|
48
|
+
isActiveLifecycleTombstone,
|
|
49
|
+
mergeRootLifecycleTombstone,
|
|
50
|
+
mergeUniqueSnapshotIds,
|
|
51
|
+
stripSnapshotBulkStatus
|
|
52
|
+
} from './snapshot-root.js';
|
|
53
|
+
export {
|
|
54
|
+
applyFirestoreV2ResolvedChangeBatch,
|
|
55
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS,
|
|
56
|
+
lifecycleTombstoneDocumentKey,
|
|
57
|
+
lifecycleTombstoneKeyAliases
|
|
58
|
+
} from './changefeed.js';
|
|
59
|
+
|
|
34
60
|
import {
|
|
35
61
|
RECORD_GROUP_IDENTITY_VERSION,
|
|
36
62
|
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
@@ -39,11 +65,14 @@ import {
|
|
|
39
65
|
buildRecordTimeLabelChannelFolderPlan,
|
|
40
66
|
buildRecordTimeLabelGroupIndex,
|
|
41
67
|
buildRecordTimeLabelGroupMetadata,
|
|
68
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
42
69
|
buildRecordTimeLabelGroupReorderOperationId,
|
|
43
70
|
buildRecordTimeLabelStandaloneGroupId,
|
|
71
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
44
72
|
buildRecordTimeLabelVodPatchOperationId,
|
|
45
73
|
calculateVodTitleSimilarity,
|
|
46
74
|
canonicalizeRecordTimeLabelGroupView,
|
|
75
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
47
76
|
collectRecordTimeLabelChannelCandidates,
|
|
48
77
|
extractChannelHandleFromUrl,
|
|
49
78
|
extractTwitchVodIdFromUrl,
|
|
@@ -67,11 +96,14 @@ export {
|
|
|
67
96
|
buildRecordTimeLabelChannelFolderPlan,
|
|
68
97
|
buildRecordTimeLabelGroupIndex,
|
|
69
98
|
buildRecordTimeLabelGroupMetadata,
|
|
99
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
70
100
|
buildRecordTimeLabelGroupReorderOperationId,
|
|
71
101
|
buildRecordTimeLabelStandaloneGroupId,
|
|
102
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
72
103
|
buildRecordTimeLabelVodPatchOperationId,
|
|
73
104
|
calculateVodTitleSimilarity,
|
|
74
105
|
canonicalizeRecordTimeLabelGroupView,
|
|
106
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
75
107
|
collectRecordTimeLabelChannelCandidates,
|
|
76
108
|
extractChannelHandleFromUrl,
|
|
77
109
|
extractTwitchVodIdFromUrl,
|
|
@@ -87,7 +119,7 @@ export {
|
|
|
87
119
|
selectBestMatchingTwitchVod
|
|
88
120
|
};
|
|
89
121
|
|
|
90
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.
|
|
122
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.1';
|
|
91
123
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
92
124
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
93
125
|
'fifo-retry-fence',
|
|
@@ -2084,6 +2116,264 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
|
|
|
2084
2116
|
return { state, pendingOps };
|
|
2085
2117
|
};
|
|
2086
2118
|
|
|
2119
|
+
const collectActiveTombstoneIds = (state = {}, kind) => {
|
|
2120
|
+
const rootField = kind === 'records' ? 'deletedRecordTombstones' : 'deletedFolderTombstones';
|
|
2121
|
+
const blocked = new Set();
|
|
2122
|
+
Object.entries(state?.[rootField] || {}).forEach(([id, tombstone]) => {
|
|
2123
|
+
if (isActiveLifecycleTombstone(tombstone)) blocked.add(id);
|
|
2124
|
+
});
|
|
2125
|
+
Object.entries(state?.lifecycleTombstones || {}).forEach(([id, tombstone]) => {
|
|
2126
|
+
if (!isActiveLifecycleTombstone(tombstone)) return;
|
|
2127
|
+
const entityId = String(tombstone.entityId || id.split(':').slice(1).join(':') || '').trim();
|
|
2128
|
+
const tombstoneKind = String(tombstone.kind || id.split(':')[0] || '').trim();
|
|
2129
|
+
if (kind === 'records' && tombstoneKind === 'record' && entityId) blocked.add(entityId);
|
|
2130
|
+
if (kind === 'folders' && tombstoneKind === 'folder' && entityId) blocked.add(entityId);
|
|
2131
|
+
});
|
|
2132
|
+
return blocked;
|
|
2133
|
+
};
|
|
2134
|
+
|
|
2135
|
+
const mergeLifecycleTombstoneDocuments = (current = {}, incoming = {}) => {
|
|
2136
|
+
const merged = {};
|
|
2137
|
+
const writeTombstone = (rawId, tombstone) => {
|
|
2138
|
+
const documentKey = lifecycleTombstoneDocumentKey(rawId || tombstone?.id);
|
|
2139
|
+
if (!documentKey || !tombstone) return;
|
|
2140
|
+
const logicalId = decodeURIComponent(documentKey);
|
|
2141
|
+
lifecycleTombstoneKeyAliases(logicalId).forEach((alias) => {
|
|
2142
|
+
delete merged[alias];
|
|
2143
|
+
});
|
|
2144
|
+
const existing = merged[documentKey];
|
|
2145
|
+
const nextTombstone = {...tombstone, id: logicalId};
|
|
2146
|
+
if (!existing) {
|
|
2147
|
+
merged[documentKey] = nextTombstone;
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
const currentGeneration = Number(existing.lifecycleGeneration || 0);
|
|
2151
|
+
const nextGeneration = Number(nextTombstone.lifecycleGeneration || 0);
|
|
2152
|
+
const currentDeletedAt = Number(existing.deletedAt || 0);
|
|
2153
|
+
const nextDeletedAt = Number(nextTombstone.deletedAt || 0);
|
|
2154
|
+
if (nextGeneration > currentGeneration ||
|
|
2155
|
+
(nextGeneration === currentGeneration && nextDeletedAt >= currentDeletedAt)) {
|
|
2156
|
+
merged[documentKey] = nextTombstone;
|
|
2157
|
+
} else {
|
|
2158
|
+
merged[documentKey] = existing;
|
|
2159
|
+
}
|
|
2160
|
+
};
|
|
2161
|
+
Object.entries(current || {}).forEach(([id, tombstone]) => writeTombstone(id, tombstone));
|
|
2162
|
+
Object.entries(incoming || {}).forEach(([id, tombstone]) => writeTombstone(id, tombstone));
|
|
2163
|
+
return merged;
|
|
2164
|
+
};
|
|
2165
|
+
|
|
2166
|
+
const pickNewerLifecycleEntity = (left, right, getTime) => {
|
|
2167
|
+
if (!left) return right;
|
|
2168
|
+
if (!right) return left;
|
|
2169
|
+
const leftGeneration = Number(left.lifecycleGeneration || 0);
|
|
2170
|
+
const rightGeneration = Number(right.lifecycleGeneration || 0);
|
|
2171
|
+
if (leftGeneration !== rightGeneration) {
|
|
2172
|
+
return rightGeneration > leftGeneration ? right : left;
|
|
2173
|
+
}
|
|
2174
|
+
return getTime(right) >= getTime(left) ? right : left;
|
|
2175
|
+
};
|
|
2176
|
+
|
|
2177
|
+
const tombstoneBlocksEntity = (tombstone, entity, getTime) => {
|
|
2178
|
+
if (!isActiveLifecycleTombstone(tombstone)) return false;
|
|
2179
|
+
if (!entity) return true;
|
|
2180
|
+
const tombstoneGeneration = Number(tombstone.lifecycleGeneration || 0);
|
|
2181
|
+
const entityGeneration = Number(entity.lifecycleGeneration || 0);
|
|
2182
|
+
if (entityGeneration !== tombstoneGeneration) {
|
|
2183
|
+
return tombstoneGeneration > entityGeneration;
|
|
2184
|
+
}
|
|
2185
|
+
return toFiniteTimestamp(tombstone.deletedAt) >= getTime(entity);
|
|
2186
|
+
};
|
|
2187
|
+
|
|
2188
|
+
const collectDocumentIdChanges = (currentDocs = {}, nextDocs = {}) => {
|
|
2189
|
+
const currentIds = new Set(Object.keys(currentDocs || {}));
|
|
2190
|
+
const nextIds = new Set(Object.keys(nextDocs || {}));
|
|
2191
|
+
return {
|
|
2192
|
+
upsertIds: [...nextIds].filter((id) => (
|
|
2193
|
+
JSON.stringify(currentDocs?.[id] || null) !== JSON.stringify(nextDocs?.[id] || null)
|
|
2194
|
+
)).sort(),
|
|
2195
|
+
deleteIds: [...currentIds].filter((id) => !nextIds.has(id)).sort()
|
|
2196
|
+
};
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
export const applyRecordTimeLabelSnapshot = (
|
|
2200
|
+
currentState = {},
|
|
2201
|
+
incomingState = {},
|
|
2202
|
+
mode = 'snapshot.replace'
|
|
2203
|
+
) => {
|
|
2204
|
+
const snapshotMode = mode === 'snapshot.merge' ? 'snapshot.merge' : 'snapshot.replace';
|
|
2205
|
+
const isMerge = snapshotMode === 'snapshot.merge';
|
|
2206
|
+
const current = normalizeState(currentState || {});
|
|
2207
|
+
const incoming = normalizeState(incomingState || {});
|
|
2208
|
+
const currentDocs = buildFirestoreV2DocumentsFromState(current);
|
|
2209
|
+
const incomingDocs = buildFirestoreV2DocumentsFromState(incoming);
|
|
2210
|
+
const mergedTombstoneDocs = mergeLifecycleTombstoneDocuments(
|
|
2211
|
+
currentDocs.lifecycleTombstones,
|
|
2212
|
+
incomingDocs.lifecycleTombstones
|
|
2213
|
+
);
|
|
2214
|
+
const splitTombstones = splitLifecycleTombstoneDocuments(mergedTombstoneDocs);
|
|
2215
|
+
const mergedRecordTombstones = mergeLifecycleTombstoneSources(
|
|
2216
|
+
current.deletedRecordTombstones,
|
|
2217
|
+
mergeLifecycleTombstoneSources(incoming.deletedRecordTombstones, splitTombstones.records)
|
|
2218
|
+
);
|
|
2219
|
+
const mergedFolderTombstones = mergeLifecycleTombstoneSources(
|
|
2220
|
+
current.deletedFolderTombstones,
|
|
2221
|
+
mergeLifecycleTombstoneSources(incoming.deletedFolderTombstones, splitTombstones.folders)
|
|
2222
|
+
);
|
|
2223
|
+
const remainingTombstones = dropSupersededLifecycleDeletions({
|
|
2224
|
+
deletedRecordTombstones: mergedRecordTombstones,
|
|
2225
|
+
deletedFolderTombstones: mergedFolderTombstones,
|
|
2226
|
+
trashEntries: {},
|
|
2227
|
+
activeGenerations: collectActiveLifecycleStates(
|
|
2228
|
+
...(isMerge ? [current, incoming] : [incoming])
|
|
2229
|
+
)
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
const selectRecordDocs = (currentRecordDocs, incomingRecordDocs) => {
|
|
2233
|
+
const sourceIds = isMerge
|
|
2234
|
+
? new Set([...Object.keys(currentRecordDocs || {}), ...Object.keys(incomingRecordDocs || {})])
|
|
2235
|
+
: new Set(Object.keys(incomingRecordDocs || {}));
|
|
2236
|
+
const nextDocs = {};
|
|
2237
|
+
sourceIds.forEach((id) => {
|
|
2238
|
+
const entity = isMerge
|
|
2239
|
+
? pickNewerLifecycleEntity(currentRecordDocs?.[id], incomingRecordDocs?.[id], getRecordTime)
|
|
2240
|
+
: incomingRecordDocs?.[id];
|
|
2241
|
+
if (!entity || tombstoneBlocksEntity(remainingTombstones.deletedRecordTombstones[id], entity, getRecordTime)) {
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
nextDocs[id] = entity;
|
|
2245
|
+
});
|
|
2246
|
+
return nextDocs;
|
|
2247
|
+
};
|
|
2248
|
+
const selectFolderDocs = (currentFolderDocs, incomingFolderDocs) => {
|
|
2249
|
+
const sourceIds = isMerge
|
|
2250
|
+
? new Set([...Object.keys(currentFolderDocs || {}), ...Object.keys(incomingFolderDocs || {})])
|
|
2251
|
+
: new Set(Object.keys(incomingFolderDocs || {}));
|
|
2252
|
+
const nextDocs = {};
|
|
2253
|
+
sourceIds.forEach((id) => {
|
|
2254
|
+
const entity = isMerge
|
|
2255
|
+
? pickNewerLifecycleEntity(currentFolderDocs?.[id], incomingFolderDocs?.[id], getFolderTime)
|
|
2256
|
+
: incomingFolderDocs?.[id];
|
|
2257
|
+
if (!entity || tombstoneBlocksEntity(remainingTombstones.deletedFolderTombstones[id], entity, getFolderTime)) {
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
nextDocs[id] = entity;
|
|
2261
|
+
});
|
|
2262
|
+
return nextDocs;
|
|
2263
|
+
};
|
|
2264
|
+
|
|
2265
|
+
const recordDocs = selectRecordDocs(currentDocs.records, incomingDocs.records);
|
|
2266
|
+
const folderDocs = selectFolderDocs(currentDocs.folders, incomingDocs.folders);
|
|
2267
|
+
const blockedRecordIds = new Set(
|
|
2268
|
+
Object.keys(remainingTombstones.deletedRecordTombstones || {}).filter((id) => (
|
|
2269
|
+
isActiveLifecycleTombstone(remainingTombstones.deletedRecordTombstones[id]) && !recordDocs[id]
|
|
2270
|
+
))
|
|
2271
|
+
);
|
|
2272
|
+
const blockedFolderIds = new Set(
|
|
2273
|
+
Object.keys(remainingTombstones.deletedFolderTombstones || {}).filter((id) => (
|
|
2274
|
+
isActiveLifecycleTombstone(remainingTombstones.deletedFolderTombstones[id]) && !folderDocs[id]
|
|
2275
|
+
))
|
|
2276
|
+
);
|
|
2277
|
+
|
|
2278
|
+
const nextRoot = {
|
|
2279
|
+
...buildFirestoreV2SnapshotRoot({
|
|
2280
|
+
currentRoot: currentDocs.root || {},
|
|
2281
|
+
incomingRoot: incomingDocs.root || {},
|
|
2282
|
+
mode: snapshotMode
|
|
2283
|
+
}),
|
|
2284
|
+
deletedRecordTombstones: remainingTombstones.deletedRecordTombstones,
|
|
2285
|
+
deletedFolderTombstones: remainingTombstones.deletedFolderTombstones
|
|
2286
|
+
};
|
|
2287
|
+
const {state} = buildStateFromFirestoreV2Documents({
|
|
2288
|
+
root: {id: 'main', ...nextRoot},
|
|
2289
|
+
records: recordDocs,
|
|
2290
|
+
folders: folderDocs,
|
|
2291
|
+
trash: {...(currentDocs.trash || {})},
|
|
2292
|
+
ops: {},
|
|
2293
|
+
lifecycleTombstones: buildV2LifecycleTombstoneDocuments(
|
|
2294
|
+
remainingTombstones.deletedRecordTombstones,
|
|
2295
|
+
remainingTombstones.deletedFolderTombstones
|
|
2296
|
+
)
|
|
2297
|
+
});
|
|
2298
|
+
const nextDocs = buildFirestoreV2DocumentsFromState(state);
|
|
2299
|
+
const recordChanges = collectDocumentIdChanges(currentDocs.records, nextDocs.records);
|
|
2300
|
+
const folderChanges = collectDocumentIdChanges(currentDocs.folders, nextDocs.folders);
|
|
2301
|
+
return {
|
|
2302
|
+
state: {...state, pendingOps: []},
|
|
2303
|
+
documentChanges: {
|
|
2304
|
+
records: recordChanges,
|
|
2305
|
+
folders: folderChanges,
|
|
2306
|
+
trash: {upsertIds: [], deleteIds: []},
|
|
2307
|
+
root: true
|
|
2308
|
+
},
|
|
2309
|
+
diagnostics: {
|
|
2310
|
+
mode: snapshotMode,
|
|
2311
|
+
blockedRecordCount: blockedRecordIds.size,
|
|
2312
|
+
blockedFolderCount: blockedFolderIds.size,
|
|
2313
|
+
recordUpsertCount: recordChanges.upsertIds.length,
|
|
2314
|
+
recordDeleteCount: recordChanges.deleteIds.length,
|
|
2315
|
+
folderUpsertCount: folderChanges.upsertIds.length,
|
|
2316
|
+
folderDeleteCount: folderChanges.deleteIds.length,
|
|
2317
|
+
trashPreserved: true
|
|
2318
|
+
}
|
|
2319
|
+
};
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
export const composeRecordTimeLabelHydratedState = ({
|
|
2323
|
+
remote = {},
|
|
2324
|
+
pendingOps = [],
|
|
2325
|
+
importJobs = [],
|
|
2326
|
+
localNavigation = null
|
|
2327
|
+
} = {}) => {
|
|
2328
|
+
const jobs = (Array.isArray(importJobs) ? importJobs : [])
|
|
2329
|
+
.filter((job) => job && typeof job === 'object')
|
|
2330
|
+
.sort((left, right) => Number(left?.createdAt || 0) - Number(right?.createdAt || 0));
|
|
2331
|
+
const coveredOperationIds = new Set();
|
|
2332
|
+
let state = normalizeState(remote || {});
|
|
2333
|
+
jobs.forEach((job) => {
|
|
2334
|
+
state = applyRecordTimeLabelSnapshot(
|
|
2335
|
+
state,
|
|
2336
|
+
job.state || {},
|
|
2337
|
+
job.mode === 'snapshot.merge' ? 'snapshot.merge' : 'snapshot.replace'
|
|
2338
|
+
).state;
|
|
2339
|
+
(Array.isArray(job.includedOperationIds) ? job.includedOperationIds : []).forEach((id) => {
|
|
2340
|
+
if (id) coveredOperationIds.add(id);
|
|
2341
|
+
});
|
|
2342
|
+
});
|
|
2343
|
+
(Array.isArray(pendingOps) ? pendingOps : [])
|
|
2344
|
+
.filter((operation) => operation?.id && !coveredOperationIds.has(operation.id))
|
|
2345
|
+
.forEach((operation) => {
|
|
2346
|
+
state = applyRecordTimeLabelOperation(state, operation);
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
const navigation = localNavigation && typeof localNavigation === 'object' ? localNavigation : {};
|
|
2350
|
+
const nextSettings = {...(state.settings || {})};
|
|
2351
|
+
const lastActiveFolderId = navigation.lastActiveFolderId;
|
|
2352
|
+
if (lastActiveFolderId) {
|
|
2353
|
+
const folderExists = (Array.isArray(state.folders) ? state.folders : [])
|
|
2354
|
+
.some((folder) => folder?.id === lastActiveFolderId);
|
|
2355
|
+
if (folderExists) nextSettings.lastActiveFolderId = lastActiveFolderId;
|
|
2356
|
+
else delete nextSettings.lastActiveFolderId;
|
|
2357
|
+
} else {
|
|
2358
|
+
delete nextSettings.lastActiveFolderId;
|
|
2359
|
+
}
|
|
2360
|
+
const localExpandedGroups = Array.isArray(navigation.expandedGroups) ? navigation.expandedGroups : [];
|
|
2361
|
+
const expandedGroups = Array.from(new Set([
|
|
2362
|
+
...(Array.isArray(state.expandedGroups) ? state.expandedGroups : []),
|
|
2363
|
+
...localExpandedGroups
|
|
2364
|
+
]));
|
|
2365
|
+
const remoteRevision = Number(remote?.revision);
|
|
2366
|
+
return {
|
|
2367
|
+
state: {
|
|
2368
|
+
...state,
|
|
2369
|
+
settings: nextSettings,
|
|
2370
|
+
expandedGroups,
|
|
2371
|
+
...(Number.isFinite(remoteRevision) ? {revision: remoteRevision} : {})
|
|
2372
|
+
},
|
|
2373
|
+
coveredOperationIds: Array.from(coveredOperationIds)
|
|
2374
|
+
};
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2087
2377
|
const mergeOrderArrays = (normalizer, ...orders) => {
|
|
2088
2378
|
const seen = new Set();
|
|
2089
2379
|
const result = [];
|
|
@@ -5347,6 +5637,32 @@ export default {
|
|
|
5347
5637
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
5348
5638
|
RECORD_GROUP_IDENTITY_VERSION,
|
|
5349
5639
|
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
5640
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
5641
|
+
buildRecordTimeLabelGroupIndex,
|
|
5642
|
+
buildRecordTimeLabelGroupMetadata,
|
|
5643
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
5644
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
5645
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
5646
|
+
canonicalizeRecordTimeLabelGroupView,
|
|
5647
|
+
extractTwitchVodIdFromUrl,
|
|
5648
|
+
extractYouTubeVideoIdFromUrl,
|
|
5649
|
+
buildKnownTwitchVodRecordPatches,
|
|
5650
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
5651
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
5652
|
+
calculateVodTitleSimilarity,
|
|
5653
|
+
normalizeVodTitleForMatch,
|
|
5654
|
+
scoreTwitchVodCandidate,
|
|
5655
|
+
selectBestMatchingTwitchVod,
|
|
5656
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
5657
|
+
remapLegacyRecordTimeLabelFolderAliases,
|
|
5658
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
5659
|
+
collectRecordTimeLabelChannelCandidates,
|
|
5660
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
5661
|
+
extractChannelHandleFromUrl,
|
|
5662
|
+
findBestRecordTimeLabelChannelFolder,
|
|
5663
|
+
getChannelMatchKey,
|
|
5664
|
+
isPendingChannelFolderId,
|
|
5665
|
+
isValidChannelName,
|
|
5350
5666
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5351
5667
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5352
5668
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
@@ -5388,6 +5704,11 @@ export default {
|
|
|
5388
5704
|
buildFirestoreV2DocumentsFromState,
|
|
5389
5705
|
buildFirestoreV2DocumentChangeSet,
|
|
5390
5706
|
buildStateFromFirestoreV2Documents,
|
|
5707
|
+
buildFirestoreV2SnapshotRoot,
|
|
5708
|
+
applyRecordTimeLabelSnapshot,
|
|
5709
|
+
composeRecordTimeLabelHydratedState,
|
|
5710
|
+
applyFirestoreV2ResolvedChangeBatch,
|
|
5711
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS,
|
|
5391
5712
|
validateRecordTimeLabelOperationBatch,
|
|
5392
5713
|
buildFirestoreV2OperationReadPlan,
|
|
5393
5714
|
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
|
+
};
|