@recordtimelabel/core 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ import {fingerprintCanonicalJson} from './hash.js';
2
+ import {buildRecordTimeLabelGroupIndex} from './group-identity.js';
3
+ import {
4
+ DEFAULT_UNKNOWN_TITLE,
5
+ collectNonVirtualRecords,
6
+ extractTwitchVodIdFromUrl,
7
+ getFirstMatchingUrlValue,
8
+ getRecordUrls,
9
+ normalizeId,
10
+ normalizeRecordPlatform,
11
+ normalizeText,
12
+ parseElapsedSeconds
13
+ } from './shared.js';
14
+ import {parseUtcMillis} from './time.js';
15
+
16
+ const TITLE_BRACKET_PATTERN = /\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/g;
17
+ const NON_WORD_PATTERN = /[^\p{L}\p{N}\s]/gu;
18
+ const MINUTE_MS = 60 * 1000;
19
+ const HOUR_MS = 60 * MINUTE_MS;
20
+ const RECORD_AFTER_START_GRACE_MS = 10 * MINUTE_MS;
21
+ const STREAM_START_GRACE_MS = 45 * MINUTE_MS;
22
+ const ELAPSED_START_TOLERANCE_MS = 45 * MINUTE_MS;
23
+ const MAX_REASONABLE_STREAM_MS = 48 * HOUR_MS;
24
+
25
+ const getVodStartMs = (vod = {}) => parseUtcMillis(vod.publishedAt || vod.createdAt);
26
+
27
+ const getNextVodStartMs = (value) => (
28
+ typeof value === 'number' && Number.isFinite(value) && value > 0
29
+ ? value
30
+ : parseUtcMillis(value)
31
+ );
32
+
33
+ export const normalizeVodTitleForMatch = (value) => String(value || '')
34
+ .toLowerCase()
35
+ .normalize('NFKC')
36
+ .replace(TITLE_BRACKET_PATTERN, ' ')
37
+ .replace(NON_WORD_PATTERN, ' ')
38
+ .replace(/\s+/g, ' ')
39
+ .trim();
40
+
41
+ export const calculateVodTitleSimilarity = (expectedTitle, candidateTitle) => {
42
+ const expected = normalizeVodTitleForMatch(expectedTitle);
43
+ const candidate = normalizeVodTitleForMatch(candidateTitle);
44
+ if (!expected || !candidate) return 0;
45
+ if (expected === candidate) return 1;
46
+ if (expected.includes(candidate) || candidate.includes(expected)) return 0.9;
47
+
48
+ const expectedTokens = new Set(expected.split(' ').filter(Boolean));
49
+ const candidateTokens = new Set(candidate.split(' ').filter(Boolean));
50
+ if (expectedTokens.size === 0 || candidateTokens.size === 0) return 0;
51
+
52
+ let intersection = 0;
53
+ expectedTokens.forEach((token) => {
54
+ if (candidateTokens.has(token)) intersection += 1;
55
+ });
56
+ const union = new Set([...expectedTokens, ...candidateTokens]).size;
57
+ return union > 0 ? intersection / union : 0;
58
+ };
59
+
60
+ export const scoreTwitchVodCandidate = (vod = {}, context = {}) => {
61
+ const recordMs = parseUtcMillis(context.recordTimestamp);
62
+ const streamStartMs = parseUtcMillis(context.streamCreatedAt || context.streamStartedAt);
63
+ const elapsedSeconds = parseElapsedSeconds(context.recordLiveTimeSeconds);
64
+ const targetSeconds = parseElapsedSeconds(context.recordTargetTimeSeconds) ?? elapsedSeconds;
65
+ const vodStartMs = getVodStartMs(vod);
66
+ const nextVodStartMs = getNextVodStartMs(context.nextVodStartMs || context.nextVodStartAt);
67
+
68
+ if (!vod?.id || vodStartMs === null) return null;
69
+
70
+ const expectedStartMs = recordMs !== null && elapsedSeconds !== null
71
+ ? recordMs - (elapsedSeconds * 1000)
72
+ : null;
73
+ const lengthSeconds = parseElapsedSeconds(vod.lengthSeconds);
74
+ const hasStableAnchor = Boolean(streamStartMs !== null || expectedStartMs !== null);
75
+ const matchedBy = [];
76
+ let score = 0;
77
+ const streamAnchorMs = expectedStartMs !== null ? expectedStartMs : streamStartMs;
78
+ const targetAbsoluteMs = targetSeconds !== null && streamAnchorMs !== null
79
+ ? streamAnchorMs + (targetSeconds * 1000)
80
+ : recordMs;
81
+ const lengthEndMs = lengthSeconds !== null ? vodStartMs + (lengthSeconds * 1000) : null;
82
+ const inferredEndMs = nextVodStartMs !== null &&
83
+ nextVodStartMs > vodStartMs &&
84
+ nextVodStartMs - vodStartMs <= MAX_REASONABLE_STREAM_MS
85
+ ? nextVodStartMs
86
+ : null;
87
+ const vodEndMs = lengthEndMs || inferredEndMs;
88
+
89
+ if (targetAbsoluteMs !== null) {
90
+ if (targetAbsoluteMs < vodStartMs - RECORD_AFTER_START_GRACE_MS) return null;
91
+ if (vodEndMs !== null && targetAbsoluteMs >= vodEndMs + RECORD_AFTER_START_GRACE_MS) return null;
92
+ if (vodEndMs !== null) {
93
+ score += 80;
94
+ matchedBy.push('target-window');
95
+ }
96
+ }
97
+
98
+ if (expectedStartMs !== null) {
99
+ const startDelta = Math.abs(vodStartMs - expectedStartMs);
100
+ if (startDelta <= ELAPSED_START_TOLERANCE_MS) {
101
+ const closeness = 1 - (startDelta / ELAPSED_START_TOLERANCE_MS);
102
+ score += 85 + (closeness * 15);
103
+ matchedBy.push('elapsed-start');
104
+ }
105
+ }
106
+
107
+ if (streamStartMs !== null && recordMs !== null) {
108
+ const startsInsideStreamWindow =
109
+ vodStartMs >= streamStartMs - STREAM_START_GRACE_MS &&
110
+ vodStartMs <= recordMs + RECORD_AFTER_START_GRACE_MS;
111
+ if (startsInsideStreamWindow) {
112
+ const streamSpan = Math.max(recordMs - streamStartMs, 1);
113
+ const positionPenalty = Math.min(Math.max(vodStartMs - streamStartMs, 0) / streamSpan, 1) * 15;
114
+ score += 70 - positionPenalty;
115
+ matchedBy.push('stream-window');
116
+ }
117
+ }
118
+
119
+ if (recordMs !== null && vodEndMs !== null) {
120
+ const containsRecord =
121
+ recordMs >= vodStartMs - RECORD_AFTER_START_GRACE_MS &&
122
+ recordMs <= vodEndMs + RECORD_AFTER_START_GRACE_MS;
123
+ if (containsRecord) {
124
+ score += 60;
125
+ matchedBy.push('duration-window');
126
+ }
127
+ }
128
+
129
+ if (!hasStableAnchor && recordMs !== null) {
130
+ const recordOffsetMs = recordMs - vodStartMs;
131
+ if (recordOffsetMs >= -RECORD_AFTER_START_GRACE_MS && recordOffsetMs <= MAX_REASONABLE_STREAM_MS) {
132
+ const closeness = 1 - Math.min(Math.max(recordOffsetMs, 0) / MAX_REASONABLE_STREAM_MS, 1);
133
+ score += 30 + (closeness * 20);
134
+ matchedBy.push('legacy-time-window');
135
+ }
136
+ }
137
+
138
+ if (matchedBy.length === 0) return null;
139
+
140
+ const titleSimilarity = calculateVodTitleSimilarity(context.expectedTitle, vod.title || '');
141
+ score += titleSimilarity * 10;
142
+ if (score < (hasStableAnchor ? 55 : 25)) return null;
143
+
144
+ const vodTimeOffsetSeconds = targetSeconds !== null && matchedBy.includes('elapsed-start')
145
+ ? targetSeconds
146
+ : targetSeconds !== null && streamAnchorMs !== null
147
+ ? Math.max(0, Math.floor(targetSeconds - ((vodStartMs - streamAnchorMs) / 1000)))
148
+ : (recordMs !== null
149
+ ? Math.max(0, Math.floor((recordMs - vodStartMs) / 1000))
150
+ : targetSeconds);
151
+
152
+ return {
153
+ vod,
154
+ score,
155
+ matchedBy,
156
+ titleSimilarity,
157
+ vodStartAt: new Date(vodStartMs).toISOString(),
158
+ vodTimeOffsetSeconds
159
+ };
160
+ };
161
+
162
+ export const selectBestMatchingTwitchVod = (items = [], context = {}) => {
163
+ const normalizedItems = (Array.isArray(items) ? items : [])
164
+ .map((item) => item?.node || item)
165
+ .map((vod) => ({vod, startMs: getVodStartMs(vod)}))
166
+ .filter((entry) => entry.vod?.id && entry.startMs !== null)
167
+ .sort((a, b) => a.startMs - b.startMs);
168
+
169
+ return normalizedItems
170
+ .map((entry, index) => scoreTwitchVodCandidate(entry.vod, {
171
+ ...context,
172
+ nextVodStartMs: normalizedItems[index + 1]?.startMs ?? null
173
+ }))
174
+ .filter(Boolean)
175
+ .sort((a, b) => b.score - a.score)[0] || null;
176
+ };
177
+
178
+ const valuesEqual = (left, right) => {
179
+ if (Array.isArray(left) || Array.isArray(right)) {
180
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
181
+ return left.every((value, index) => valuesEqual(value, right[index]));
182
+ }
183
+ return left === right;
184
+ };
185
+
186
+ const recordHasConflictingIdentity = (record, source) => {
187
+ const recordStreamId = normalizeText(record?.streamId);
188
+ const sourceStreamId = normalizeText(source?.streamId);
189
+ if (recordStreamId && sourceStreamId && recordStreamId !== sourceStreamId) return true;
190
+ const recordVodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
191
+ const sourceVodId = normalizeText(source?.vodId) || getFirstMatchingUrlValue(source, extractTwitchVodIdFromUrl);
192
+ if (recordVodId && sourceVodId && recordVodId !== sourceVodId) return true;
193
+ return false;
194
+ };
195
+
196
+ export const buildRecordTimeLabelVodPatchOperationId = (recordId, patch = {}) => (
197
+ `record.update:${normalizeId(recordId)}:vod-patch:${fingerprintCanonicalJson(patch || {})}`
198
+ );
199
+
200
+ export const buildKnownTwitchVodRecordPatches = ({
201
+ records = [],
202
+ groupIndex = null,
203
+ fallbackPlatform,
204
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
205
+ } = {}) => {
206
+ const safeRecords = collectNonVirtualRecords(records);
207
+ const index = groupIndex || buildRecordTimeLabelGroupIndex({
208
+ records: safeRecords,
209
+ fallbackPlatform,
210
+ unknownTitle
211
+ });
212
+ const conflictGroupIds = new Set(index.diagnostics?.conflictGroupIds || []);
213
+ const sourceByGroupId = {};
214
+
215
+ safeRecords.forEach((record) => {
216
+ if (normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch' || record?.hasVod !== true) return;
217
+ const recordId = normalizeId(record?.id);
218
+ const groupId = index.groupIdByRecordId[recordId];
219
+ if (!groupId || conflictGroupIds.has(groupId)) return;
220
+ const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
221
+ if (!vodId) return;
222
+ const current = sourceByGroupId[groupId];
223
+ const currentScore = current
224
+ ? Number(Boolean(current.vodStartAt || current.vodPublishedAt)) +
225
+ Number(Number.isFinite(Number(current.vodTimeOffsetSeconds)))
226
+ : -1;
227
+ const nextScore = Number(Boolean(record.vodStartAt || record.vodPublishedAt)) +
228
+ Number(Number.isFinite(Number(record.vodTimeOffsetSeconds)));
229
+ if (!current || nextScore > currentScore) sourceByGroupId[groupId] = record;
230
+ });
231
+
232
+ const patches = [];
233
+ safeRecords.forEach((record) => {
234
+ const recordId = normalizeId(record?.id);
235
+ if (!recordId || normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch' || record.hasVod === true) {
236
+ return;
237
+ }
238
+ const groupId = index.groupIdByRecordId[recordId];
239
+ if (!groupId || conflictGroupIds.has(groupId)) return;
240
+ const source = sourceByGroupId[groupId];
241
+ if (!source || recordHasConflictingIdentity(record, source)) return;
242
+ const vodId = normalizeText(source.vodId) || getFirstMatchingUrlValue(source, extractTwitchVodIdFromUrl);
243
+ if (!vodId) return;
244
+
245
+ const vodStartAt = source.vodStartAt || source.vodPublishedAt || null;
246
+ const vodStartMs = parseUtcMillis(vodStartAt);
247
+ const recordCreatedAtMs = parseUtcMillis(record.createdAt);
248
+ const configuredOffsetSeconds = Number.isFinite(Number(record.timeOffsetSeconds))
249
+ ? Math.max(0, Number(record.timeOffsetSeconds))
250
+ : 0;
251
+ const timestampOffsetSeconds = vodStartMs !== null && recordCreatedAtMs !== null &&
252
+ recordCreatedAtMs >= vodStartMs
253
+ ? Math.max(0, Math.floor((recordCreatedAtMs - vodStartMs) / 1000) - configuredOffsetSeconds)
254
+ : null;
255
+ const fallbackOffsetSeconds = parseElapsedSeconds(record.liveTime || record.originalLiveTime);
256
+ const vodTimeOffsetSeconds = timestampOffsetSeconds ?? fallbackOffsetSeconds;
257
+ const sourceVideoUrl = getRecordUrls(source).find((url) => extractTwitchVodIdFromUrl(url) === vodId) ||
258
+ `https://www.twitch.tv/videos/${vodId}`;
259
+ const vodMatchedBy = Array.from(new Set([
260
+ ...(Array.isArray(source.vodMatchedBy) ? source.vodMatchedBy : []),
261
+ 'sibling-record'
262
+ ]));
263
+ const candidate = {
264
+ hasVod: true,
265
+ videoUrl: sourceVideoUrl,
266
+ vodId,
267
+ vodPublishedAt: source.vodPublishedAt || vodStartAt,
268
+ vodStartAt,
269
+ vodTitle: source.vodTitle || '',
270
+ vodLookupVersion: 2,
271
+ vodMatchedBy
272
+ };
273
+ if (Number.isFinite(vodTimeOffsetSeconds)) candidate.vodTimeOffsetSeconds = vodTimeOffsetSeconds;
274
+ if (Number.isFinite(Number(source.vodMatchConfidence))) {
275
+ candidate.vodMatchConfidence = Number(source.vodMatchConfidence);
276
+ }
277
+ if (source.downloadUrl) {
278
+ candidate.downloadUrl = source.downloadUrl;
279
+ candidate.isDirectM3u8 = source.isDirectM3u8 || false;
280
+ candidate.m3u8Type = source.m3u8Type || null;
281
+ }
282
+
283
+ const patch = {};
284
+ Object.entries(candidate).forEach(([field, value]) => {
285
+ if (!valuesEqual(record[field], value)) patch[field] = value;
286
+ });
287
+ if (Object.keys(patch).length === 0) return;
288
+ patches.push({
289
+ recordId,
290
+ patch,
291
+ operationId: buildRecordTimeLabelVodPatchOperationId(recordId, patch)
292
+ });
293
+ });
294
+
295
+ return {
296
+ patches,
297
+ diagnostics: {
298
+ sourceGroupCount: Object.keys(sourceByGroupId).length,
299
+ patchCount: patches.length,
300
+ skippedConflictGroupCount: conflictGroupIds.size
301
+ }
302
+ };
303
+ };
package/src/domain.js ADDED
@@ -0,0 +1,45 @@
1
+ export const RECORD_TIMELABEL_DOMAIN_CAPABILITIES = Object.freeze([
2
+ 'group-identity-v1',
3
+ 'twitch-vod-matching-v1',
4
+ 'legacy-import-v1',
5
+ 'channel-folder-planning-v1'
6
+ ]);
7
+
8
+ export {
9
+ RECORD_GROUP_IDENTITY_VERSION,
10
+ buildLegacyRecordTimeLabelGroupId,
11
+ buildRecordTimeLabelGroupIndex,
12
+ buildRecordTimeLabelGroupMetadata,
13
+ buildRecordTimeLabelGroupReorderOperationId,
14
+ buildRecordTimeLabelStandaloneGroupId,
15
+ canonicalizeRecordTimeLabelGroupView
16
+ } from './domain/group-identity.js';
17
+
18
+ export {
19
+ extractTwitchVodIdFromUrl,
20
+ extractYouTubeVideoIdFromUrl
21
+ } from './domain/shared.js';
22
+
23
+ export {
24
+ buildKnownTwitchVodRecordPatches,
25
+ buildRecordTimeLabelVodPatchOperationId,
26
+ calculateVodTitleSimilarity,
27
+ normalizeVodTitleForMatch,
28
+ scoreTwitchVodCandidate,
29
+ selectBestMatchingTwitchVod
30
+ } from './domain/twitch-vod.js';
31
+
32
+ export {
33
+ normalizeLegacyRecordTimeLabelImport,
34
+ remapLegacyRecordTimeLabelFolderAliases
35
+ } from './domain/legacy.js';
36
+
37
+ export {
38
+ buildRecordTimeLabelChannelFolderPlan,
39
+ collectRecordTimeLabelChannelCandidates,
40
+ extractChannelHandleFromUrl,
41
+ findBestRecordTimeLabelChannelFolder,
42
+ getChannelMatchKey,
43
+ isPendingChannelFolderId,
44
+ isValidChannelName
45
+ } from './domain/channel-folder.js';
package/src/index.js CHANGED
@@ -31,12 +31,69 @@ const REQUIRED_FOLDERS = [
31
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
32
32
  ];
33
33
 
34
- export const RECORD_TIMELABEL_CORE_VERSION = '0.4.6';
34
+ import {
35
+ RECORD_GROUP_IDENTITY_VERSION,
36
+ RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
37
+ buildKnownTwitchVodRecordPatches,
38
+ buildLegacyRecordTimeLabelGroupId,
39
+ buildRecordTimeLabelChannelFolderPlan,
40
+ buildRecordTimeLabelGroupIndex,
41
+ buildRecordTimeLabelGroupMetadata,
42
+ buildRecordTimeLabelGroupReorderOperationId,
43
+ buildRecordTimeLabelStandaloneGroupId,
44
+ buildRecordTimeLabelVodPatchOperationId,
45
+ calculateVodTitleSimilarity,
46
+ canonicalizeRecordTimeLabelGroupView,
47
+ collectRecordTimeLabelChannelCandidates,
48
+ extractChannelHandleFromUrl,
49
+ extractTwitchVodIdFromUrl,
50
+ extractYouTubeVideoIdFromUrl,
51
+ findBestRecordTimeLabelChannelFolder,
52
+ getChannelMatchKey,
53
+ isPendingChannelFolderId,
54
+ isValidChannelName,
55
+ normalizeLegacyRecordTimeLabelImport,
56
+ normalizeVodTitleForMatch,
57
+ remapLegacyRecordTimeLabelFolderAliases,
58
+ scoreTwitchVodCandidate,
59
+ selectBestMatchingTwitchVod
60
+ } from './domain.js';
61
+
62
+ export {
63
+ RECORD_GROUP_IDENTITY_VERSION,
64
+ RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
65
+ buildKnownTwitchVodRecordPatches,
66
+ buildLegacyRecordTimeLabelGroupId,
67
+ buildRecordTimeLabelChannelFolderPlan,
68
+ buildRecordTimeLabelGroupIndex,
69
+ buildRecordTimeLabelGroupMetadata,
70
+ buildRecordTimeLabelGroupReorderOperationId,
71
+ buildRecordTimeLabelStandaloneGroupId,
72
+ buildRecordTimeLabelVodPatchOperationId,
73
+ calculateVodTitleSimilarity,
74
+ canonicalizeRecordTimeLabelGroupView,
75
+ collectRecordTimeLabelChannelCandidates,
76
+ extractChannelHandleFromUrl,
77
+ extractTwitchVodIdFromUrl,
78
+ extractYouTubeVideoIdFromUrl,
79
+ findBestRecordTimeLabelChannelFolder,
80
+ getChannelMatchKey,
81
+ isPendingChannelFolderId,
82
+ isValidChannelName,
83
+ normalizeLegacyRecordTimeLabelImport,
84
+ normalizeVodTitleForMatch,
85
+ remapLegacyRecordTimeLabelFolderAliases,
86
+ scoreTwitchVodCandidate,
87
+ selectBestMatchingTwitchVod
88
+ };
89
+
90
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.5.0';
35
91
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
36
92
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
37
93
  'fifo-retry-fence',
38
94
  'baseline-refresh-after-rejection',
39
95
  'sync-batch-boundary',
96
+ 'remote-subscription-readiness',
40
97
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
41
98
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
42
99
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
@@ -3157,6 +3214,17 @@ export const createRecordTimeLabelSyncEngine = ({
3157
3214
  let unsubscribeCloud = null;
3158
3215
  let unsubscribeSession = null;
3159
3216
  let subscriptionContext = null;
3217
+ let remoteReadyGeneration = 0;
3218
+ let remoteReadyState = {
3219
+ generation: 0,
3220
+ settled: true,
3221
+ promise: Promise.resolve({
3222
+ success: true,
3223
+ skipped: true,
3224
+ reason: 'not_initialized',
3225
+ generation: 0
3226
+ })
3227
+ };
3160
3228
  let bootstrapAttemptSequence = 0;
3161
3229
  const listeners = new Set();
3162
3230
  let queue = Promise.resolve();
@@ -3501,7 +3569,9 @@ export const createRecordTimeLabelSyncEngine = ({
3501
3569
  return {success: true, ignored: true};
3502
3570
  }
3503
3571
  let baselineValue = remoteValue;
3504
- if (remoteRevision > candidate.remoteBaseline.revision + 1) {
3572
+ const remoteState = remote?.state ?? remote?.data;
3573
+ const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
3574
+ if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
3505
3575
  if (typeof cloud?.bootstrap !== 'function') {
3506
3576
  return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3507
3577
  }
@@ -3517,7 +3587,49 @@ export const createRecordTimeLabelSyncEngine = ({
3517
3587
  return getSnapshot();
3518
3588
  };
3519
3589
 
3520
- const stopCloudSubscription = () => {
3590
+ const createRemoteReadyState = () => {
3591
+ const generation = ++remoteReadyGeneration;
3592
+ let resolvePromise;
3593
+ let rejectPromise;
3594
+ const state = {
3595
+ generation,
3596
+ settled: false,
3597
+ promise: new Promise((resolve, reject) => {
3598
+ resolvePromise = resolve;
3599
+ rejectPromise = reject;
3600
+ }),
3601
+ resolve(value = {}) {
3602
+ if (state.settled) return;
3603
+ state.settled = true;
3604
+ resolvePromise({
3605
+ success: true,
3606
+ ...(value && typeof value === 'object' ? value : {}),
3607
+ generation
3608
+ });
3609
+ },
3610
+ reject(error) {
3611
+ if (state.settled) return;
3612
+ state.settled = true;
3613
+ rejectPromise(error);
3614
+ }
3615
+ };
3616
+ // Readiness is an opt-in host boundary. Keep a rejection observable to a
3617
+ // waiter without turning clients that have not adopted it yet into an
3618
+ // unhandled-rejection source.
3619
+ state.promise.catch(() => null);
3620
+ return state;
3621
+ };
3622
+
3623
+ const staleRemoteReady = (state, reason = 'stale_session') => {
3624
+ if (!state || state.settled) return;
3625
+ state.resolve({success: false, stale: true, reason});
3626
+ };
3627
+
3628
+ const isCurrentRemoteReady = (state) => (
3629
+ !destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
3630
+ );
3631
+
3632
+ const stopCloudSubscription = ({settleReady = true} = {}) => {
3521
3633
  if (typeof unsubscribeCloud === 'function') {
3522
3634
  try { unsubscribeCloud(); } catch (error) {
3523
3635
  logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
@@ -3525,20 +3637,87 @@ export const createRecordTimeLabelSyncEngine = ({
3525
3637
  }
3526
3638
  unsubscribeCloud = null;
3527
3639
  subscriptionContext = null;
3640
+ if (settleReady) staleRemoteReady(remoteReadyState);
3528
3641
  };
3529
3642
 
3530
3643
  const startCloudSubscription = (captured, candidate) => {
3531
3644
  stopCloudSubscription();
3532
- if (typeof cloud?.subscribe !== 'function' || !initialized || hydrationRequired) return;
3645
+ const readyState = createRemoteReadyState();
3646
+ remoteReadyState = readyState;
3647
+ if (!initialized || hydrationRequired) {
3648
+ readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
3649
+ return;
3650
+ }
3651
+ if (!captured?.uid) {
3652
+ readyState.resolve({skipped: true, reason: 'anonymous'});
3653
+ return;
3654
+ }
3655
+ if (typeof cloud?.subscribe !== 'function') {
3656
+ readyState.resolve({skipped: true, reason: 'no_subscription'});
3657
+ return;
3658
+ }
3533
3659
  const context = rtlSessionContext(captured, candidate, client);
3534
3660
  subscriptionContext = context;
3535
- unsubscribeCloud = cloud.subscribe((remoteValue) => {
3536
- if (destroyed) return;
3537
- return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3661
+ let subscriptionReturned = false;
3662
+ let adapterReadyExpected = false;
3663
+ let firstCallbackResult = null;
3664
+ const onRemote = (remoteValue) => {
3665
+ if (destroyed || !isCurrentRemoteReady(readyState)) {
3666
+ return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
3667
+ }
3668
+ const callbackResult = enqueue(() => processRemote(remoteValue, captured));
3669
+ if (!firstCallbackResult) firstCallbackResult = callbackResult;
3670
+ callbackResult.then((result) => {
3671
+ if (
3672
+ subscriptionReturned &&
3673
+ !adapterReadyExpected &&
3674
+ isCurrentRemoteReady(readyState)
3675
+ ) {
3676
+ readyState.resolve({
3677
+ revision: workspace.remoteBaseline.revision,
3678
+ result
3679
+ });
3680
+ }
3681
+ }).catch((error) => {
3682
+ if (isCurrentRemoteReady(readyState) && !readyState.settled) {
3683
+ readyState.reject(error);
3684
+ }
3538
3685
  logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3539
- return {success: false, error};
3540
3686
  });
3541
- }, context);
3687
+ return callbackResult;
3688
+ };
3689
+ let disposer;
3690
+ try {
3691
+ disposer = cloud.subscribe(onRemote, context);
3692
+ } catch (error) {
3693
+ readyState.reject(error);
3694
+ logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
3695
+ return;
3696
+ }
3697
+ unsubscribeCloud = typeof disposer === 'function' ? disposer : null;
3698
+ adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
3699
+ subscriptionReturned = true;
3700
+ if (adapterReadyExpected) {
3701
+ Promise.resolve(disposer.ready).then((result) => {
3702
+ if (!isCurrentRemoteReady(readyState)) return;
3703
+ readyState.resolve({
3704
+ revision: workspace.remoteBaseline.revision,
3705
+ result
3706
+ });
3707
+ }).catch((error) => {
3708
+ if (isCurrentRemoteReady(readyState) && !readyState.settled) {
3709
+ readyState.reject(error);
3710
+ }
3711
+ });
3712
+ } else if (firstCallbackResult) {
3713
+ firstCallbackResult.then((result) => {
3714
+ if (!isCurrentRemoteReady(readyState)) return;
3715
+ readyState.resolve({
3716
+ revision: workspace.remoteBaseline.revision,
3717
+ result
3718
+ });
3719
+ }).catch(() => null);
3720
+ }
3542
3721
  };
3543
3722
 
3544
3723
  const initialize = async () => {
@@ -4112,6 +4291,18 @@ export const createRecordTimeLabelSyncEngine = ({
4112
4291
  return queue.catch(() => null);
4113
4292
  },
4114
4293
 
4294
+ waitForRemoteReady() {
4295
+ if (destroyed) {
4296
+ return Promise.resolve({
4297
+ success: false,
4298
+ stale: true,
4299
+ reason: 'stale_session',
4300
+ generation: remoteReadyState.generation
4301
+ });
4302
+ }
4303
+ return remoteReadyState.promise;
4304
+ },
4305
+
4115
4306
  getSnapshot,
4116
4307
 
4117
4308
  subscribe(listener) {
@@ -5154,6 +5345,8 @@ export const buildOperationsFromSnapshotDiff = ({
5154
5345
 
5155
5346
  export default {
5156
5347
  RECORD_TIMELABEL_CORE_VERSION,
5348
+ RECORD_GROUP_IDENTITY_VERSION,
5349
+ RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
5157
5350
  RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
5158
5351
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
5159
5352
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,