@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 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.1`:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.4.6"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.5.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",
@@ -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 {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,
@@ -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.5.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
+ };