@recordtimelabel/core 0.6.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 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 published artifact is `0.6.0`:
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.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
package/src/changefeed.js CHANGED
@@ -11,6 +11,27 @@ export const FIRESTORE_V2_BOOTSTRAP_REASONS = Object.freeze({
11
11
  const toId = (value) => String(value || '').trim();
12
12
  const toIdList = (value) => (Array.isArray(value) ? value : []).map(toId).filter(Boolean);
13
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
+
14
35
  const cloneDocuments = (documents = {}) => ({
15
36
  root: documents.root ? {...documents.root} : documents.root,
16
37
  records: {...(documents.records || {})},
@@ -113,13 +134,18 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
113
134
  ? change.lifecycleTombstoneUpserts
114
135
  : [];
115
136
  const lifecycleDeletes = toIdList(change.deletedLifecycleTombstoneIds);
116
- const lifecycleUpsertIds = lifecycleUpserts.map((entry) => toId(entry?.id)).filter(Boolean);
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);
117
143
 
118
144
  if (
119
145
  hasConflict(changedRecords, deletedRecords) ||
120
146
  hasConflict(changedFolders, deletedFolders) ||
121
147
  hasConflict(changedTrash, deletedTrash) ||
122
- hasConflict(lifecycleUpsertIds, lifecycleDeletes)
148
+ hasConflict(lifecycleUpsertIds, lifecycleDeleteIds)
123
149
  ) {
124
150
  return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.INVALID_CHANGE_CONFLICT, inputCache);
125
151
  }
@@ -161,11 +187,21 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
161
187
  deletedFolders.forEach((id) => delete candidate.documents.folders[id]);
162
188
  deletedTrash.forEach((id) => delete candidate.documents.trash[id]);
163
189
  lifecycleUpserts.forEach((entry) => {
164
- const id = toId(entry?.id);
165
- if (id) candidate.documents.lifecycleTombstones[id] = {...entry, id};
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
+ };
166
200
  });
167
201
  lifecycleDeletes.forEach((id) => {
168
- delete candidate.documents.lifecycleTombstones[id];
202
+ lifecycleTombstoneKeyAliases(id).forEach((alias) => {
203
+ delete candidate.documents.lifecycleTombstones[alias];
204
+ });
169
205
  });
170
206
  candidate.revision = revision;
171
207
  }
@@ -1,4 +1,7 @@
1
- import {fingerprintCanonicalJson} from './hash.js';
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
+ };
@@ -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
+ };
@@ -1,4 +1,7 @@
1
- import {fingerprintCanonicalJson} from './hash.js';
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/index.js CHANGED
@@ -37,7 +37,9 @@ import {
37
37
  } from './snapshot-root.js';
38
38
  import {
39
39
  applyFirestoreV2ResolvedChangeBatch,
40
- FIRESTORE_V2_BOOTSTRAP_REASONS
40
+ FIRESTORE_V2_BOOTSTRAP_REASONS,
41
+ lifecycleTombstoneDocumentKey,
42
+ lifecycleTombstoneKeyAliases
41
43
  } from './changefeed.js';
42
44
 
43
45
  export {
@@ -50,7 +52,9 @@ export {
50
52
  } from './snapshot-root.js';
51
53
  export {
52
54
  applyFirestoreV2ResolvedChangeBatch,
53
- FIRESTORE_V2_BOOTSTRAP_REASONS
55
+ FIRESTORE_V2_BOOTSTRAP_REASONS,
56
+ lifecycleTombstoneDocumentKey,
57
+ lifecycleTombstoneKeyAliases
54
58
  } from './changefeed.js';
55
59
 
56
60
  import {
@@ -61,11 +65,14 @@ import {
61
65
  buildRecordTimeLabelChannelFolderPlan,
62
66
  buildRecordTimeLabelGroupIndex,
63
67
  buildRecordTimeLabelGroupMetadata,
68
+ buildRecordTimeLabelGroupReorderOperation,
64
69
  buildRecordTimeLabelGroupReorderOperationId,
65
70
  buildRecordTimeLabelStandaloneGroupId,
71
+ buildRecordTimeLabelVodPatchOperation,
66
72
  buildRecordTimeLabelVodPatchOperationId,
67
73
  calculateVodTitleSimilarity,
68
74
  canonicalizeRecordTimeLabelGroupView,
75
+ createdAtForStableRecordTimeLabelOperationId,
69
76
  collectRecordTimeLabelChannelCandidates,
70
77
  extractChannelHandleFromUrl,
71
78
  extractTwitchVodIdFromUrl,
@@ -89,11 +96,14 @@ export {
89
96
  buildRecordTimeLabelChannelFolderPlan,
90
97
  buildRecordTimeLabelGroupIndex,
91
98
  buildRecordTimeLabelGroupMetadata,
99
+ buildRecordTimeLabelGroupReorderOperation,
92
100
  buildRecordTimeLabelGroupReorderOperationId,
93
101
  buildRecordTimeLabelStandaloneGroupId,
102
+ buildRecordTimeLabelVodPatchOperation,
94
103
  buildRecordTimeLabelVodPatchOperationId,
95
104
  calculateVodTitleSimilarity,
96
105
  canonicalizeRecordTimeLabelGroupView,
106
+ createdAtForStableRecordTimeLabelOperationId,
97
107
  collectRecordTimeLabelChannelCandidates,
98
108
  extractChannelHandleFromUrl,
99
109
  extractTwitchVodIdFromUrl,
@@ -109,7 +119,7 @@ export {
109
119
  selectBestMatchingTwitchVod
110
120
  };
111
121
 
112
- export const RECORD_TIMELABEL_CORE_VERSION = '0.6.0';
122
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.1';
113
123
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
114
124
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
115
125
  'fifo-retry-fence',
@@ -2123,25 +2133,58 @@ const collectActiveTombstoneIds = (state = {}, kind) => {
2123
2133
  };
2124
2134
 
2125
2135
  const mergeLifecycleTombstoneDocuments = (current = {}, incoming = {}) => {
2126
- const merged = {...(current || {})};
2127
- Object.entries(incoming || {}).forEach(([id, tombstone]) => {
2128
- const existing = merged[id];
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};
2129
2146
  if (!existing) {
2130
- merged[id] = tombstone;
2147
+ merged[documentKey] = nextTombstone;
2131
2148
  return;
2132
2149
  }
2133
2150
  const currentGeneration = Number(existing.lifecycleGeneration || 0);
2134
- const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
2151
+ const nextGeneration = Number(nextTombstone.lifecycleGeneration || 0);
2135
2152
  const currentDeletedAt = Number(existing.deletedAt || 0);
2136
- const nextDeletedAt = Number(tombstone?.deletedAt || 0);
2153
+ const nextDeletedAt = Number(nextTombstone.deletedAt || 0);
2137
2154
  if (nextGeneration > currentGeneration ||
2138
2155
  (nextGeneration === currentGeneration && nextDeletedAt >= currentDeletedAt)) {
2139
- merged[id] = tombstone;
2156
+ merged[documentKey] = nextTombstone;
2157
+ } else {
2158
+ merged[documentKey] = existing;
2140
2159
  }
2141
- });
2160
+ };
2161
+ Object.entries(current || {}).forEach(([id, tombstone]) => writeTombstone(id, tombstone));
2162
+ Object.entries(incoming || {}).forEach(([id, tombstone]) => writeTombstone(id, tombstone));
2142
2163
  return merged;
2143
2164
  };
2144
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
+
2145
2188
  const collectDocumentIdChanges = (currentDocs = {}, nextDocs = {}) => {
2146
2189
  const currentIds = new Set(Object.keys(currentDocs || {}));
2147
2190
  const nextIds = new Set(Object.keys(nextDocs || {}));
@@ -2164,45 +2207,92 @@ export const applyRecordTimeLabelSnapshot = (
2164
2207
  const incoming = normalizeState(incomingState || {});
2165
2208
  const currentDocs = buildFirestoreV2DocumentsFromState(current);
2166
2209
  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
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
+ )
2196
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
+ };
2197
2287
  const {state} = buildStateFromFirestoreV2Documents({
2198
2288
  root: {id: 'main', ...nextRoot},
2199
2289
  records: recordDocs,
2200
2290
  folders: folderDocs,
2201
2291
  trash: {...(currentDocs.trash || {})},
2202
2292
  ops: {},
2203
- lifecycleTombstones: mergeLifecycleTombstoneDocuments(
2204
- currentDocs.lifecycleTombstones,
2205
- incomingDocs.lifecycleTombstones
2293
+ lifecycleTombstones: buildV2LifecycleTombstoneDocuments(
2294
+ remainingTombstones.deletedRecordTombstones,
2295
+ remainingTombstones.deletedFolderTombstones
2206
2296
  )
2207
2297
  });
2208
2298
  const nextDocs = buildFirestoreV2DocumentsFromState(state);
@@ -5547,6 +5637,32 @@ export default {
5547
5637
  RECORD_TIMELABEL_CORE_VERSION,
5548
5638
  RECORD_GROUP_IDENTITY_VERSION,
5549
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,
5550
5666
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
5551
5667
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
5552
5668
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,