@recordtimelabel/core 0.6.0 → 0.6.2
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 +20 -2
- package/package.json +1 -1
- package/src/changefeed.js +41 -5
- 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/index.js +287 -73
- package/src/protocol.js +236 -1
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.
|
|
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.2`:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.6.
|
|
23
|
+
"@recordtimelabel/core": "0.6.2"
|
|
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.
|
|
@@ -36,6 +36,10 @@ If this checkout's `package.json` is ahead of the published version, publish the
|
|
|
36
36
|
- `RTL_MAX_SYNC_DRAIN_ROUNDS`
|
|
37
37
|
- `RTL_SYNC_DRAIN_RETRY_DELAY_MS`
|
|
38
38
|
- `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
|
|
39
|
+
- `RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE`
|
|
40
|
+
- `RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES`
|
|
41
|
+
- `normalizeRecordTimeLabelCloudFailure(input)`
|
|
42
|
+
- `toRecordTimeLabelCloudFailureError(input)`
|
|
39
43
|
- `RECORD_TIMELABEL_PROTOCOL_CAPABILITIES`
|
|
40
44
|
- `toRecordTimeLabelWireOperation(operation)`
|
|
41
45
|
- `buildRecordTimeLabelRequestId(namespace, operations)`
|
|
@@ -97,6 +101,20 @@ has `load()` and `save(workspace)` methods (adapters may consume the optional se
|
|
|
97
101
|
`save(workspace, fenceContext)` argument to enforce the session/epoch atomically), and its cloud port has `bootstrap(context, attemptOptions)`,
|
|
98
102
|
`applyOperations(envelope, context)`, and `subscribe(listener, context)` methods. The session port
|
|
99
103
|
provides `current()`, `subscribe(listener)`, and `isCurrent(sessionToken, uid, workspaceEpoch)`.
|
|
104
|
+
`sessionToken` is an opaque fence for equality only. Core never aliases `current.id` or
|
|
105
|
+
the whole session object as an authorization token, and `bootstrap`/`catchUp`/`apply`
|
|
106
|
+
context no longer carries `token`. Platform adapters obtain Firebase credentials from
|
|
107
|
+
their own credential provider.
|
|
108
|
+
|
|
109
|
+
`normalizeRecordTimeLabelCloudFailure` preserves `status`, `reason`, `retryable`, and
|
|
110
|
+
`retryAfterMs`, and assigns exactly one class: `transient`, `bootstrap-required`,
|
|
111
|
+
`auth-transition-required`, `terminal`, or `stale-session`. Capability
|
|
112
|
+
`cloud-failure-state-v1` advertises this contract. An `auth-transition-required`
|
|
113
|
+
bootstrap or catch-up failure latches that session identity so repeated `init()`
|
|
114
|
+
calls do not invoke `cloud.bootstrap` again. A changed SessionPort capture, UID, or
|
|
115
|
+
workspace epoch clears the latch. `recoverBaseline` falls back from catch-up only for
|
|
116
|
+
explicit `bootstrap-required` / cursor-gap outcomes; auth and transient failures keep
|
|
117
|
+
their class and must not start a second full walk.
|
|
100
118
|
|
|
101
119
|
The persisted workspace is versioned and contains only durable data:
|
|
102
120
|
|
package/package.json
CHANGED
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
|
|
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,
|
|
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
|
|
165
|
-
|
|
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
|
-
|
|
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 {
|
|
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/index.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
2
3
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
3
4
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
4
5
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
6
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
5
7
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
6
8
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
7
9
|
buildRecordTimeLabelRequestId,
|
|
8
10
|
createRecordTimeLabelTransportFailureResults,
|
|
11
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
9
12
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
10
13
|
normalizeRecordTimeLabelOperationResults,
|
|
14
|
+
toRecordTimeLabelCloudFailureError,
|
|
11
15
|
toRecordTimeLabelWireOperation
|
|
12
16
|
} from './protocol.js';
|
|
13
17
|
|
|
14
18
|
export {
|
|
19
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
15
20
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
16
21
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
17
22
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
23
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
18
24
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
19
25
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
20
26
|
buildRecordTimeLabelRequestId,
|
|
21
27
|
createRecordTimeLabelTransportFailureResults,
|
|
28
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
22
29
|
normalizeRecordTimeLabelEnvelopeResponse,
|
|
23
30
|
normalizeRecordTimeLabelOperationResults,
|
|
31
|
+
toRecordTimeLabelCloudFailureError,
|
|
24
32
|
toRecordTimeLabelWireOperation
|
|
25
33
|
} from './protocol.js';
|
|
26
34
|
|
|
@@ -37,7 +45,9 @@ import {
|
|
|
37
45
|
} from './snapshot-root.js';
|
|
38
46
|
import {
|
|
39
47
|
applyFirestoreV2ResolvedChangeBatch,
|
|
40
|
-
FIRESTORE_V2_BOOTSTRAP_REASONS
|
|
48
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS,
|
|
49
|
+
lifecycleTombstoneDocumentKey,
|
|
50
|
+
lifecycleTombstoneKeyAliases
|
|
41
51
|
} from './changefeed.js';
|
|
42
52
|
|
|
43
53
|
export {
|
|
@@ -50,7 +60,9 @@ export {
|
|
|
50
60
|
} from './snapshot-root.js';
|
|
51
61
|
export {
|
|
52
62
|
applyFirestoreV2ResolvedChangeBatch,
|
|
53
|
-
FIRESTORE_V2_BOOTSTRAP_REASONS
|
|
63
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS,
|
|
64
|
+
lifecycleTombstoneDocumentKey,
|
|
65
|
+
lifecycleTombstoneKeyAliases
|
|
54
66
|
} from './changefeed.js';
|
|
55
67
|
|
|
56
68
|
import {
|
|
@@ -61,11 +73,14 @@ import {
|
|
|
61
73
|
buildRecordTimeLabelChannelFolderPlan,
|
|
62
74
|
buildRecordTimeLabelGroupIndex,
|
|
63
75
|
buildRecordTimeLabelGroupMetadata,
|
|
76
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
64
77
|
buildRecordTimeLabelGroupReorderOperationId,
|
|
65
78
|
buildRecordTimeLabelStandaloneGroupId,
|
|
79
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
66
80
|
buildRecordTimeLabelVodPatchOperationId,
|
|
67
81
|
calculateVodTitleSimilarity,
|
|
68
82
|
canonicalizeRecordTimeLabelGroupView,
|
|
83
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
69
84
|
collectRecordTimeLabelChannelCandidates,
|
|
70
85
|
extractChannelHandleFromUrl,
|
|
71
86
|
extractTwitchVodIdFromUrl,
|
|
@@ -89,11 +104,14 @@ export {
|
|
|
89
104
|
buildRecordTimeLabelChannelFolderPlan,
|
|
90
105
|
buildRecordTimeLabelGroupIndex,
|
|
91
106
|
buildRecordTimeLabelGroupMetadata,
|
|
107
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
92
108
|
buildRecordTimeLabelGroupReorderOperationId,
|
|
93
109
|
buildRecordTimeLabelStandaloneGroupId,
|
|
110
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
94
111
|
buildRecordTimeLabelVodPatchOperationId,
|
|
95
112
|
calculateVodTitleSimilarity,
|
|
96
113
|
canonicalizeRecordTimeLabelGroupView,
|
|
114
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
97
115
|
collectRecordTimeLabelChannelCandidates,
|
|
98
116
|
extractChannelHandleFromUrl,
|
|
99
117
|
extractTwitchVodIdFromUrl,
|
|
@@ -109,7 +127,7 @@ export {
|
|
|
109
127
|
selectBestMatchingTwitchVod
|
|
110
128
|
};
|
|
111
129
|
|
|
112
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
130
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.2';
|
|
113
131
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
114
132
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
115
133
|
'fifo-retry-fence',
|
|
@@ -118,7 +136,8 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
118
136
|
'remote-subscription-readiness',
|
|
119
137
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
120
138
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
121
|
-
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
139
|
+
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
140
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE
|
|
122
141
|
]);
|
|
123
142
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
124
143
|
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
@@ -2123,25 +2142,58 @@ const collectActiveTombstoneIds = (state = {}, kind) => {
|
|
|
2123
2142
|
};
|
|
2124
2143
|
|
|
2125
2144
|
const mergeLifecycleTombstoneDocuments = (current = {}, incoming = {}) => {
|
|
2126
|
-
const merged = {
|
|
2127
|
-
|
|
2128
|
-
const
|
|
2145
|
+
const merged = {};
|
|
2146
|
+
const writeTombstone = (rawId, tombstone) => {
|
|
2147
|
+
const documentKey = lifecycleTombstoneDocumentKey(rawId || tombstone?.id);
|
|
2148
|
+
if (!documentKey || !tombstone) return;
|
|
2149
|
+
const logicalId = decodeURIComponent(documentKey);
|
|
2150
|
+
lifecycleTombstoneKeyAliases(logicalId).forEach((alias) => {
|
|
2151
|
+
delete merged[alias];
|
|
2152
|
+
});
|
|
2153
|
+
const existing = merged[documentKey];
|
|
2154
|
+
const nextTombstone = {...tombstone, id: logicalId};
|
|
2129
2155
|
if (!existing) {
|
|
2130
|
-
merged[
|
|
2156
|
+
merged[documentKey] = nextTombstone;
|
|
2131
2157
|
return;
|
|
2132
2158
|
}
|
|
2133
2159
|
const currentGeneration = Number(existing.lifecycleGeneration || 0);
|
|
2134
|
-
const nextGeneration = Number(
|
|
2160
|
+
const nextGeneration = Number(nextTombstone.lifecycleGeneration || 0);
|
|
2135
2161
|
const currentDeletedAt = Number(existing.deletedAt || 0);
|
|
2136
|
-
const nextDeletedAt = Number(
|
|
2162
|
+
const nextDeletedAt = Number(nextTombstone.deletedAt || 0);
|
|
2137
2163
|
if (nextGeneration > currentGeneration ||
|
|
2138
2164
|
(nextGeneration === currentGeneration && nextDeletedAt >= currentDeletedAt)) {
|
|
2139
|
-
merged[
|
|
2165
|
+
merged[documentKey] = nextTombstone;
|
|
2166
|
+
} else {
|
|
2167
|
+
merged[documentKey] = existing;
|
|
2140
2168
|
}
|
|
2141
|
-
}
|
|
2169
|
+
};
|
|
2170
|
+
Object.entries(current || {}).forEach(([id, tombstone]) => writeTombstone(id, tombstone));
|
|
2171
|
+
Object.entries(incoming || {}).forEach(([id, tombstone]) => writeTombstone(id, tombstone));
|
|
2142
2172
|
return merged;
|
|
2143
2173
|
};
|
|
2144
2174
|
|
|
2175
|
+
const pickNewerLifecycleEntity = (left, right, getTime) => {
|
|
2176
|
+
if (!left) return right;
|
|
2177
|
+
if (!right) return left;
|
|
2178
|
+
const leftGeneration = Number(left.lifecycleGeneration || 0);
|
|
2179
|
+
const rightGeneration = Number(right.lifecycleGeneration || 0);
|
|
2180
|
+
if (leftGeneration !== rightGeneration) {
|
|
2181
|
+
return rightGeneration > leftGeneration ? right : left;
|
|
2182
|
+
}
|
|
2183
|
+
return getTime(right) >= getTime(left) ? right : left;
|
|
2184
|
+
};
|
|
2185
|
+
|
|
2186
|
+
const tombstoneBlocksEntity = (tombstone, entity, getTime) => {
|
|
2187
|
+
if (!isActiveLifecycleTombstone(tombstone)) return false;
|
|
2188
|
+
if (!entity) return true;
|
|
2189
|
+
const tombstoneGeneration = Number(tombstone.lifecycleGeneration || 0);
|
|
2190
|
+
const entityGeneration = Number(entity.lifecycleGeneration || 0);
|
|
2191
|
+
if (entityGeneration !== tombstoneGeneration) {
|
|
2192
|
+
return tombstoneGeneration > entityGeneration;
|
|
2193
|
+
}
|
|
2194
|
+
return toFiniteTimestamp(tombstone.deletedAt) >= getTime(entity);
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2145
2197
|
const collectDocumentIdChanges = (currentDocs = {}, nextDocs = {}) => {
|
|
2146
2198
|
const currentIds = new Set(Object.keys(currentDocs || {}));
|
|
2147
2199
|
const nextIds = new Set(Object.keys(nextDocs || {}));
|
|
@@ -2164,45 +2216,92 @@ export const applyRecordTimeLabelSnapshot = (
|
|
|
2164
2216
|
const incoming = normalizeState(incomingState || {});
|
|
2165
2217
|
const currentDocs = buildFirestoreV2DocumentsFromState(current);
|
|
2166
2218
|
const incomingDocs = buildFirestoreV2DocumentsFromState(incoming);
|
|
2167
|
-
const
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
|
2219
|
+
const mergedTombstoneDocs = mergeLifecycleTombstoneDocuments(
|
|
2220
|
+
currentDocs.lifecycleTombstones,
|
|
2221
|
+
incomingDocs.lifecycleTombstones
|
|
2222
|
+
);
|
|
2223
|
+
const splitTombstones = splitLifecycleTombstoneDocuments(mergedTombstoneDocs);
|
|
2224
|
+
const mergedRecordTombstones = mergeLifecycleTombstoneSources(
|
|
2225
|
+
current.deletedRecordTombstones,
|
|
2226
|
+
mergeLifecycleTombstoneSources(incoming.deletedRecordTombstones, splitTombstones.records)
|
|
2227
|
+
);
|
|
2228
|
+
const mergedFolderTombstones = mergeLifecycleTombstoneSources(
|
|
2229
|
+
current.deletedFolderTombstones,
|
|
2230
|
+
mergeLifecycleTombstoneSources(incoming.deletedFolderTombstones, splitTombstones.folders)
|
|
2231
|
+
);
|
|
2232
|
+
const remainingTombstones = dropSupersededLifecycleDeletions({
|
|
2233
|
+
deletedRecordTombstones: mergedRecordTombstones,
|
|
2234
|
+
deletedFolderTombstones: mergedFolderTombstones,
|
|
2235
|
+
trashEntries: {},
|
|
2236
|
+
activeGenerations: collectActiveLifecycleStates(
|
|
2237
|
+
...(isMerge ? [current, incoming] : [incoming])
|
|
2238
|
+
)
|
|
2196
2239
|
});
|
|
2240
|
+
|
|
2241
|
+
const selectRecordDocs = (currentRecordDocs, incomingRecordDocs) => {
|
|
2242
|
+
const sourceIds = isMerge
|
|
2243
|
+
? new Set([...Object.keys(currentRecordDocs || {}), ...Object.keys(incomingRecordDocs || {})])
|
|
2244
|
+
: new Set(Object.keys(incomingRecordDocs || {}));
|
|
2245
|
+
const nextDocs = {};
|
|
2246
|
+
sourceIds.forEach((id) => {
|
|
2247
|
+
const entity = isMerge
|
|
2248
|
+
? pickNewerLifecycleEntity(currentRecordDocs?.[id], incomingRecordDocs?.[id], getRecordTime)
|
|
2249
|
+
: incomingRecordDocs?.[id];
|
|
2250
|
+
if (!entity || tombstoneBlocksEntity(remainingTombstones.deletedRecordTombstones[id], entity, getRecordTime)) {
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
nextDocs[id] = entity;
|
|
2254
|
+
});
|
|
2255
|
+
return nextDocs;
|
|
2256
|
+
};
|
|
2257
|
+
const selectFolderDocs = (currentFolderDocs, incomingFolderDocs) => {
|
|
2258
|
+
const sourceIds = isMerge
|
|
2259
|
+
? new Set([...Object.keys(currentFolderDocs || {}), ...Object.keys(incomingFolderDocs || {})])
|
|
2260
|
+
: new Set(Object.keys(incomingFolderDocs || {}));
|
|
2261
|
+
const nextDocs = {};
|
|
2262
|
+
sourceIds.forEach((id) => {
|
|
2263
|
+
const entity = isMerge
|
|
2264
|
+
? pickNewerLifecycleEntity(currentFolderDocs?.[id], incomingFolderDocs?.[id], getFolderTime)
|
|
2265
|
+
: incomingFolderDocs?.[id];
|
|
2266
|
+
if (!entity || tombstoneBlocksEntity(remainingTombstones.deletedFolderTombstones[id], entity, getFolderTime)) {
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
nextDocs[id] = entity;
|
|
2270
|
+
});
|
|
2271
|
+
return nextDocs;
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
const recordDocs = selectRecordDocs(currentDocs.records, incomingDocs.records);
|
|
2275
|
+
const folderDocs = selectFolderDocs(currentDocs.folders, incomingDocs.folders);
|
|
2276
|
+
const blockedRecordIds = new Set(
|
|
2277
|
+
Object.keys(remainingTombstones.deletedRecordTombstones || {}).filter((id) => (
|
|
2278
|
+
isActiveLifecycleTombstone(remainingTombstones.deletedRecordTombstones[id]) && !recordDocs[id]
|
|
2279
|
+
))
|
|
2280
|
+
);
|
|
2281
|
+
const blockedFolderIds = new Set(
|
|
2282
|
+
Object.keys(remainingTombstones.deletedFolderTombstones || {}).filter((id) => (
|
|
2283
|
+
isActiveLifecycleTombstone(remainingTombstones.deletedFolderTombstones[id]) && !folderDocs[id]
|
|
2284
|
+
))
|
|
2285
|
+
);
|
|
2286
|
+
|
|
2287
|
+
const nextRoot = {
|
|
2288
|
+
...buildFirestoreV2SnapshotRoot({
|
|
2289
|
+
currentRoot: currentDocs.root || {},
|
|
2290
|
+
incomingRoot: incomingDocs.root || {},
|
|
2291
|
+
mode: snapshotMode
|
|
2292
|
+
}),
|
|
2293
|
+
deletedRecordTombstones: remainingTombstones.deletedRecordTombstones,
|
|
2294
|
+
deletedFolderTombstones: remainingTombstones.deletedFolderTombstones
|
|
2295
|
+
};
|
|
2197
2296
|
const {state} = buildStateFromFirestoreV2Documents({
|
|
2198
2297
|
root: {id: 'main', ...nextRoot},
|
|
2199
2298
|
records: recordDocs,
|
|
2200
2299
|
folders: folderDocs,
|
|
2201
2300
|
trash: {...(currentDocs.trash || {})},
|
|
2202
2301
|
ops: {},
|
|
2203
|
-
lifecycleTombstones:
|
|
2204
|
-
|
|
2205
|
-
|
|
2302
|
+
lifecycleTombstones: buildV2LifecycleTombstoneDocuments(
|
|
2303
|
+
remainingTombstones.deletedRecordTombstones,
|
|
2304
|
+
remainingTombstones.deletedFolderTombstones
|
|
2206
2305
|
)
|
|
2207
2306
|
});
|
|
2208
2307
|
const nextDocs = buildFirestoreV2DocumentsFromState(state);
|
|
@@ -3264,12 +3363,12 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3264
3363
|
} catch {
|
|
3265
3364
|
current = null;
|
|
3266
3365
|
}
|
|
3267
|
-
//
|
|
3268
|
-
//
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
?
|
|
3272
|
-
:
|
|
3366
|
+
// SessionPort.sessionToken is an opaque fence for equality only. Never treat
|
|
3367
|
+
// current.id or the whole session object as an authorization credential.
|
|
3368
|
+
const sessionToken = current && typeof current === 'object' &&
|
|
3369
|
+
Object.prototype.hasOwnProperty.call(current, 'sessionToken')
|
|
3370
|
+
? current.sessionToken
|
|
3371
|
+
: null;
|
|
3273
3372
|
const uid = rtlNormalizeUid(current && typeof current === 'object'
|
|
3274
3373
|
? (current.uid ?? current.ownerUid)
|
|
3275
3374
|
: null);
|
|
@@ -3280,8 +3379,7 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3280
3379
|
return {
|
|
3281
3380
|
current,
|
|
3282
3381
|
hasSession: typeof session?.current === 'function',
|
|
3283
|
-
|
|
3284
|
-
sessionToken: token,
|
|
3382
|
+
sessionToken,
|
|
3285
3383
|
uid,
|
|
3286
3384
|
workspaceEpoch: hasEpoch
|
|
3287
3385
|
? rtlNormalizeWorkspaceEpoch(rawEpoch, fallbackEpoch)
|
|
@@ -3290,9 +3388,16 @@ const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
|
3290
3388
|
};
|
|
3291
3389
|
};
|
|
3292
3390
|
|
|
3391
|
+
const rtlSameSessionIdentity = (left, right) => (
|
|
3392
|
+
Boolean(left) &&
|
|
3393
|
+
Boolean(right) &&
|
|
3394
|
+
left.uid === right.uid &&
|
|
3395
|
+
Number(left.workspaceEpoch) === Number(right.workspaceEpoch) &&
|
|
3396
|
+
Object.is(left.sessionToken, right.sessionToken)
|
|
3397
|
+
);
|
|
3398
|
+
|
|
3293
3399
|
const rtlSessionContext = (captured, workspace, client) => ({
|
|
3294
3400
|
sessionToken: captured?.sessionToken ?? null,
|
|
3295
|
-
token: captured?.token ?? null,
|
|
3296
3401
|
uid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
3297
3402
|
ownerUid: captured?.uid ?? (captured?.hasSession ? null : workspace?.ownerUid ?? null),
|
|
3298
3403
|
workspaceEpoch: workspace?.workspaceEpoch ?? captured?.workspaceEpoch ?? 0,
|
|
@@ -3426,6 +3531,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3426
3531
|
})
|
|
3427
3532
|
};
|
|
3428
3533
|
let bootstrapAttemptSequence = 0;
|
|
3534
|
+
let authTransitionLatch = null;
|
|
3429
3535
|
const listeners = new Set();
|
|
3430
3536
|
let queue = Promise.resolve();
|
|
3431
3537
|
|
|
@@ -3478,7 +3584,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3478
3584
|
}
|
|
3479
3585
|
if (typeof session?.current !== 'function') return true;
|
|
3480
3586
|
const current = rtlExtractSession(session, captured.workspaceEpoch);
|
|
3481
|
-
if (
|
|
3587
|
+
if (
|
|
3588
|
+
captured.sessionToken !== undefined &&
|
|
3589
|
+
captured.sessionToken !== null &&
|
|
3590
|
+
!Object.is(current.sessionToken, captured.sessionToken)
|
|
3591
|
+
) return false;
|
|
3482
3592
|
if (captured.uid !== current.uid) return false;
|
|
3483
3593
|
return !captured.hasEpoch || current.workspaceEpoch === captured.workspaceEpoch;
|
|
3484
3594
|
};
|
|
@@ -3528,21 +3638,75 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3528
3638
|
|
|
3529
3639
|
const normalizeBootstrapResponse = (response) => {
|
|
3530
3640
|
if (!rtlEnvelopeSuccess(response)) {
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3641
|
+
throw toRecordTimeLabelCloudFailureError({
|
|
3642
|
+
...((response && typeof response === 'object') ? response : {}),
|
|
3643
|
+
message: response?.error?.message || response?.message || 'recordtimelabel_bootstrap_failed',
|
|
3644
|
+
code: response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed',
|
|
3645
|
+
reason: response?.error?.reason || response?.reason ||
|
|
3646
|
+
response?.error?.code || response?.code || 'recordtimelabel_bootstrap_failed'
|
|
3647
|
+
});
|
|
3534
3648
|
}
|
|
3535
3649
|
const remote = rtlEnvelopePayload(response);
|
|
3536
3650
|
const revision = Number(remote?.revision);
|
|
3537
3651
|
const state = remote?.state ?? remote?.data;
|
|
3538
3652
|
if (!remote || !rtlDurableIsObject(state) || !Number.isFinite(revision) || revision < 0) {
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3653
|
+
throw toRecordTimeLabelCloudFailureError({
|
|
3654
|
+
code: 'recordtimelabel_invalid_bootstrap_response',
|
|
3655
|
+
reason: 'recordtimelabel_invalid_bootstrap_response',
|
|
3656
|
+
message: 'recordtimelabel_invalid_bootstrap_response',
|
|
3657
|
+
retryable: false
|
|
3658
|
+
});
|
|
3542
3659
|
}
|
|
3543
3660
|
return {state, revision, changeCursor: remote.changeCursor ?? null};
|
|
3544
3661
|
};
|
|
3545
3662
|
|
|
3663
|
+
const clearAuthTransitionLatchIfSessionChanged = (captured) => {
|
|
3664
|
+
if (!authTransitionLatch) return;
|
|
3665
|
+
if (!rtlSameSessionIdentity(authTransitionLatch, captured)) {
|
|
3666
|
+
authTransitionLatch = null;
|
|
3667
|
+
}
|
|
3668
|
+
};
|
|
3669
|
+
|
|
3670
|
+
const throwIfAuthTransitionLatched = (captured) => {
|
|
3671
|
+
clearAuthTransitionLatchIfSessionChanged(captured);
|
|
3672
|
+
if (!authTransitionLatch) return;
|
|
3673
|
+
throw toRecordTimeLabelCloudFailureError(authTransitionLatch.failure);
|
|
3674
|
+
};
|
|
3675
|
+
|
|
3676
|
+
const rememberAuthTransitionFailure = (captured, failure) => {
|
|
3677
|
+
if (failure?.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED) {
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
authTransitionLatch = {
|
|
3681
|
+
uid: captured?.uid ?? null,
|
|
3682
|
+
sessionToken: captured?.sessionToken ?? null,
|
|
3683
|
+
workspaceEpoch: captured?.workspaceEpoch ?? 0,
|
|
3684
|
+
failure: {
|
|
3685
|
+
class: failure.class,
|
|
3686
|
+
status: failure.status ?? null,
|
|
3687
|
+
reason: failure.reason ?? failure.code ?? 'auth-transition-required',
|
|
3688
|
+
code: failure.code ?? failure.reason ?? 'auth-transition-required',
|
|
3689
|
+
message: failure.message || failure.reason || failure.code || 'auth-transition-required',
|
|
3690
|
+
retryable: false,
|
|
3691
|
+
retryAfterMs: null,
|
|
3692
|
+
bootstrapRequired: false
|
|
3693
|
+
}
|
|
3694
|
+
};
|
|
3695
|
+
};
|
|
3696
|
+
|
|
3697
|
+
const capturedFromContext = (context) => ({
|
|
3698
|
+
uid: context?.uid ?? null,
|
|
3699
|
+
sessionToken: context?.sessionToken ?? null,
|
|
3700
|
+
workspaceEpoch: context?.workspaceEpoch ?? 0
|
|
3701
|
+
});
|
|
3702
|
+
|
|
3703
|
+
const rethrowClassifiedCloudFailure = (captured, error) => {
|
|
3704
|
+
const failure = normalizeRecordTimeLabelCloudFailure(error);
|
|
3705
|
+
rememberAuthTransitionFailure(captured, failure);
|
|
3706
|
+
if (error?.class === failure.class) throw error;
|
|
3707
|
+
throw toRecordTimeLabelCloudFailureError(failure);
|
|
3708
|
+
};
|
|
3709
|
+
|
|
3546
3710
|
const requireBootstrapRevision = (baseline, minimumRevision) => {
|
|
3547
3711
|
const minimum = Number(minimumRevision);
|
|
3548
3712
|
if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
|
|
@@ -3561,10 +3725,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3561
3725
|
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3562
3726
|
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3563
3727
|
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3564
|
-
// bootstrap walk
|
|
3565
|
-
//
|
|
3566
|
-
//
|
|
3728
|
+
// bootstrap walk only for explicit bootstrap-required / cursor-gap
|
|
3729
|
+
// outcomes. Auth and transient failures keep their class and must not
|
|
3730
|
+
// start a second full walk. A successful but stale catch-up response is
|
|
3731
|
+
// fail-closed: falling back would hide a revision contract violation.
|
|
3567
3732
|
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
3733
|
+
const captured = capturedFromContext(context);
|
|
3734
|
+
throwIfAuthTransitionLatched(captured);
|
|
3568
3735
|
if (typeof cloud?.catchUp === 'function') {
|
|
3569
3736
|
try {
|
|
3570
3737
|
const caught = await cloud.catchUp(context, {
|
|
@@ -3575,20 +3742,35 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3575
3742
|
if (rtlEnvelopeSuccess(caught)) {
|
|
3576
3743
|
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3577
3744
|
}
|
|
3745
|
+
const failure = normalizeRecordTimeLabelCloudFailure(caught);
|
|
3746
|
+
if (failure.class !== RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
3747
|
+
rethrowClassifiedCloudFailure(captured, failure);
|
|
3748
|
+
}
|
|
3578
3749
|
} catch (error) {
|
|
3579
3750
|
if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
|
|
3580
3751
|
throw error;
|
|
3581
3752
|
}
|
|
3582
|
-
|
|
3753
|
+
if (error?.class === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED ||
|
|
3754
|
+
normalizeRecordTimeLabelCloudFailure(error).class ===
|
|
3755
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED) {
|
|
3756
|
+
// Explicit gap recovery only.
|
|
3757
|
+
} else {
|
|
3758
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
3759
|
+
}
|
|
3583
3760
|
}
|
|
3584
3761
|
}
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3762
|
+
throwIfAuthTransitionLatched(captured);
|
|
3763
|
+
try {
|
|
3764
|
+
return requireBootstrapRevision(
|
|
3765
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3766
|
+
context,
|
|
3767
|
+
bootstrapAttemptOptions(mode)
|
|
3768
|
+
)),
|
|
3769
|
+
targetRevision
|
|
3770
|
+
);
|
|
3771
|
+
} catch (error) {
|
|
3772
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
3773
|
+
}
|
|
3592
3774
|
};
|
|
3593
3775
|
|
|
3594
3776
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
@@ -3922,6 +4104,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3922
4104
|
|
|
3923
4105
|
const initialize = async () => {
|
|
3924
4106
|
const captured = capture();
|
|
4107
|
+
throwIfAuthTransitionLatched(captured);
|
|
3925
4108
|
const loaded = await storage.load();
|
|
3926
4109
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3927
4110
|
// Read the raw owner and baseline before normalizeLoadedWorkspace adopts
|
|
@@ -3960,7 +4143,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3960
4143
|
));
|
|
3961
4144
|
} catch (error) {
|
|
3962
4145
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3963
|
-
|
|
4146
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
3964
4147
|
}
|
|
3965
4148
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3966
4149
|
applyRemoteBaseline(candidate, bootstrap);
|
|
@@ -3978,6 +4161,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3978
4161
|
if (destroyed) return;
|
|
3979
4162
|
enqueue(async () => {
|
|
3980
4163
|
const current = capture();
|
|
4164
|
+
clearAuthTransitionLatchIfSessionChanged(current);
|
|
3981
4165
|
const sameIdentity = current.uid === workspace.ownerUid &&
|
|
3982
4166
|
Number(current.workspaceEpoch) === Number(workspace.workspaceEpoch);
|
|
3983
4167
|
if (sameIdentity) {
|
|
@@ -4330,7 +4514,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4330
4514
|
);
|
|
4331
4515
|
} catch (error) {
|
|
4332
4516
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4333
|
-
|
|
4517
|
+
rethrowClassifiedCloudFailure(captured, error);
|
|
4334
4518
|
}
|
|
4335
4519
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
4336
4520
|
applyRemoteBaseline(candidate, freshBaseline);
|
|
@@ -5547,10 +5731,40 @@ export default {
|
|
|
5547
5731
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
5548
5732
|
RECORD_GROUP_IDENTITY_VERSION,
|
|
5549
5733
|
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
5734
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
5735
|
+
buildRecordTimeLabelGroupIndex,
|
|
5736
|
+
buildRecordTimeLabelGroupMetadata,
|
|
5737
|
+
buildRecordTimeLabelGroupReorderOperation,
|
|
5738
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
5739
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
5740
|
+
canonicalizeRecordTimeLabelGroupView,
|
|
5741
|
+
extractTwitchVodIdFromUrl,
|
|
5742
|
+
extractYouTubeVideoIdFromUrl,
|
|
5743
|
+
buildKnownTwitchVodRecordPatches,
|
|
5744
|
+
buildRecordTimeLabelVodPatchOperation,
|
|
5745
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
5746
|
+
calculateVodTitleSimilarity,
|
|
5747
|
+
normalizeVodTitleForMatch,
|
|
5748
|
+
scoreTwitchVodCandidate,
|
|
5749
|
+
selectBestMatchingTwitchVod,
|
|
5750
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
5751
|
+
remapLegacyRecordTimeLabelFolderAliases,
|
|
5752
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
5753
|
+
collectRecordTimeLabelChannelCandidates,
|
|
5754
|
+
createdAtForStableRecordTimeLabelOperationId,
|
|
5755
|
+
extractChannelHandleFromUrl,
|
|
5756
|
+
findBestRecordTimeLabelChannelFolder,
|
|
5757
|
+
getChannelMatchKey,
|
|
5758
|
+
isPendingChannelFolderId,
|
|
5759
|
+
isValidChannelName,
|
|
5550
5760
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5761
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
5551
5762
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5552
5763
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
5553
5764
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
5765
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
5766
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
5767
|
+
toRecordTimeLabelCloudFailureError,
|
|
5554
5768
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
5555
5769
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
5556
5770
|
RTL_SYNC_PROTOCOL_VERSION,
|
package/src/protocol.js
CHANGED
|
@@ -23,6 +23,237 @@ export const RECORD_TIMELABEL_PROTOCOL_CAPABILITIES = Object.freeze([
|
|
|
23
23
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
24
24
|
]);
|
|
25
25
|
|
|
26
|
+
export const RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES = Object.freeze({
|
|
27
|
+
TRANSIENT: 'transient',
|
|
28
|
+
BOOTSTRAP_REQUIRED: 'bootstrap-required',
|
|
29
|
+
AUTH_TRANSITION_REQUIRED: 'auth-transition-required',
|
|
30
|
+
TERMINAL: 'terminal',
|
|
31
|
+
STALE_SESSION: 'stale-session'
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE = 'cloud-failure-state-v1';
|
|
35
|
+
|
|
36
|
+
const CLOUD_FAILURE_CLASS_VALUES = new Set(Object.values(RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES));
|
|
37
|
+
|
|
38
|
+
const AUTH_TRANSITION_CODES = new Set([
|
|
39
|
+
'auth-transition-required',
|
|
40
|
+
'auth_transition_required',
|
|
41
|
+
'malformed_id_token',
|
|
42
|
+
'invalid_id_token',
|
|
43
|
+
'unauthenticated',
|
|
44
|
+
'expired_id_token',
|
|
45
|
+
'id_token_expired',
|
|
46
|
+
'token_expired',
|
|
47
|
+
'token_project_mismatch',
|
|
48
|
+
'token_user_mismatch',
|
|
49
|
+
'token_not_yet_valid',
|
|
50
|
+
'missing_token',
|
|
51
|
+
'missing_uid',
|
|
52
|
+
'token_refresh_failed',
|
|
53
|
+
'token_refresh_required'
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const BOOTSTRAP_REQUIRED_CODES = new Set([
|
|
57
|
+
'bootstrap-required',
|
|
58
|
+
'bootstrap_required',
|
|
59
|
+
'bootstraprequired',
|
|
60
|
+
'expired_bootstrap_cursor',
|
|
61
|
+
'invalid_bootstrap_cursor',
|
|
62
|
+
'revision_gap',
|
|
63
|
+
'recordtimelabel_bootstrap_required'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const STALE_SESSION_CODES = new Set([
|
|
67
|
+
'stale-session',
|
|
68
|
+
'stale_session',
|
|
69
|
+
'recordtimelabel_stale_session',
|
|
70
|
+
'auth_context_changed'
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const TERMINAL_CONFLICT_CODES = new Set([
|
|
74
|
+
'operation_id_conflict',
|
|
75
|
+
'operation_request_invalid_ids',
|
|
76
|
+
'operation_result_count_mismatch',
|
|
77
|
+
'operation_result_incomplete',
|
|
78
|
+
'operation_result_duplicate_id',
|
|
79
|
+
'operation_result_unknown_id',
|
|
80
|
+
'operation_result_missing_id',
|
|
81
|
+
'operation_result_ambiguous_id'
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const pickFailureText = (...values) => {
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const unwrapFailureSource = (input) => {
|
|
92
|
+
if (input == null) return {};
|
|
93
|
+
if (typeof input === 'string') return {message: input, reason: input, code: input};
|
|
94
|
+
if (typeof input !== 'object') return {message: String(input)};
|
|
95
|
+
const nested = input.error && typeof input.error === 'object' ? input.error : null;
|
|
96
|
+
return nested ? {...nested, ...input, error: nested} : input;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const normalizeFailureClass = (value) => (
|
|
100
|
+
CLOUD_FAILURE_CLASS_VALUES.has(value) ? value : null
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const normalizeFailureRetryAfterMs = (value) => {
|
|
104
|
+
if (value === null || value === undefined || value === '') return null;
|
|
105
|
+
const retryAfterMs = Number(value);
|
|
106
|
+
return Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const collectFailureTokens = (source) => [
|
|
110
|
+
source.class,
|
|
111
|
+
source.failureClass,
|
|
112
|
+
source.cloudFailureClass,
|
|
113
|
+
source.code,
|
|
114
|
+
source.reason,
|
|
115
|
+
source.name,
|
|
116
|
+
source.message,
|
|
117
|
+
source.error,
|
|
118
|
+
source.error?.code,
|
|
119
|
+
source.error?.reason,
|
|
120
|
+
source.error?.message,
|
|
121
|
+
source.error?.name
|
|
122
|
+
].flatMap((value) => {
|
|
123
|
+
if (typeof value !== 'string') return [];
|
|
124
|
+
return value.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const looksLikeNetworkFailure = (source, tokens) => {
|
|
128
|
+
const blob = [
|
|
129
|
+
source.name,
|
|
130
|
+
source.code,
|
|
131
|
+
source.reason,
|
|
132
|
+
source.message,
|
|
133
|
+
source.error?.name,
|
|
134
|
+
source.error?.code,
|
|
135
|
+
source.error?.reason,
|
|
136
|
+
source.error?.message
|
|
137
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
138
|
+
return Boolean(
|
|
139
|
+
tokens.includes('retryable') ||
|
|
140
|
+
tokens.includes('unavailable') ||
|
|
141
|
+
tokens.includes('temporarily_unavailable') ||
|
|
142
|
+
tokens.includes('bulk_job_in_progress') ||
|
|
143
|
+
tokens.includes('deadline_exceeded') ||
|
|
144
|
+
tokens.includes('network_error') ||
|
|
145
|
+
tokens.includes('err_network') ||
|
|
146
|
+
tokens.includes('econnreset') ||
|
|
147
|
+
tokens.includes('etimedout') ||
|
|
148
|
+
blob.includes('failed to fetch') ||
|
|
149
|
+
blob.includes('fetch failed') ||
|
|
150
|
+
blob.includes('network-request-failed') ||
|
|
151
|
+
blob.includes('network request failed')
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const classifyRecordTimeLabelCloudFailure = (source, status, tokens) => {
|
|
156
|
+
const explicitClass = normalizeFailureClass(
|
|
157
|
+
source.class ?? source.failureClass ?? source.cloudFailureClass
|
|
158
|
+
);
|
|
159
|
+
if (explicitClass) return explicitClass;
|
|
160
|
+
if (
|
|
161
|
+
source.stale === true ||
|
|
162
|
+
tokens.some((token) => STALE_SESSION_CODES.has(token) || token === 'stale-session')
|
|
163
|
+
) {
|
|
164
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
status === 401 ||
|
|
168
|
+
tokens.some((token) => AUTH_TRANSITION_CODES.has(token))
|
|
169
|
+
) {
|
|
170
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.AUTH_TRANSITION_REQUIRED;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
source.bootstrapRequired === true ||
|
|
174
|
+
source.bootstrap_required === true ||
|
|
175
|
+
tokens.some((token) => BOOTSTRAP_REQUIRED_CODES.has(token))
|
|
176
|
+
) {
|
|
177
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED;
|
|
178
|
+
}
|
|
179
|
+
if (tokens.some((token) => TERMINAL_CONFLICT_CODES.has(token))) {
|
|
180
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
|
|
181
|
+
}
|
|
182
|
+
const retryable = source.retryable === true || source.error?.retryable === true;
|
|
183
|
+
if (
|
|
184
|
+
status === 408 ||
|
|
185
|
+
status === 425 ||
|
|
186
|
+
status === 429 ||
|
|
187
|
+
status === 409 ||
|
|
188
|
+
(Number.isFinite(status) && status >= 500) ||
|
|
189
|
+
retryable ||
|
|
190
|
+
looksLikeNetworkFailure(source, tokens)
|
|
191
|
+
) {
|
|
192
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT;
|
|
193
|
+
}
|
|
194
|
+
return RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classify a cloud/bootstrap/catch-up failure without inspecting JWT structure
|
|
199
|
+
* or Firebase SDKs. Adapters may pass Firebase codes as opaque reason/code
|
|
200
|
+
* strings; this helper only preserves metadata and assigns one class.
|
|
201
|
+
*/
|
|
202
|
+
export const normalizeRecordTimeLabelCloudFailure = (input = {}) => {
|
|
203
|
+
const source = unwrapFailureSource(input);
|
|
204
|
+
const statusValue = Number(source.status ?? source.statusCode ?? source.error?.status);
|
|
205
|
+
const status = Number.isFinite(statusValue) ? statusValue : null;
|
|
206
|
+
const tokens = collectFailureTokens(source);
|
|
207
|
+
const failureClass = classifyRecordTimeLabelCloudFailure(source, status, tokens);
|
|
208
|
+
const reason = pickFailureText(
|
|
209
|
+
source.reason,
|
|
210
|
+
source.error?.reason,
|
|
211
|
+
source.code,
|
|
212
|
+
source.error?.code,
|
|
213
|
+
source.message,
|
|
214
|
+
source.error?.message
|
|
215
|
+
);
|
|
216
|
+
const code = pickFailureText(
|
|
217
|
+
source.code,
|
|
218
|
+
source.error?.code,
|
|
219
|
+
reason,
|
|
220
|
+
'recordtimelabel_cloud_failure'
|
|
221
|
+
);
|
|
222
|
+
const message = pickFailureText(
|
|
223
|
+
source.message,
|
|
224
|
+
source.error?.message,
|
|
225
|
+
reason,
|
|
226
|
+
code,
|
|
227
|
+
'recordtimelabel_cloud_failure'
|
|
228
|
+
);
|
|
229
|
+
const retryAfterMs = failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT
|
|
230
|
+
? normalizeFailureRetryAfterMs(source.retryAfterMs ?? source.error?.retryAfterMs)
|
|
231
|
+
: null;
|
|
232
|
+
return {
|
|
233
|
+
class: failureClass,
|
|
234
|
+
status,
|
|
235
|
+
reason,
|
|
236
|
+
code,
|
|
237
|
+
message,
|
|
238
|
+
retryable: failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT,
|
|
239
|
+
retryAfterMs,
|
|
240
|
+
bootstrapRequired: failureClass === RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const toRecordTimeLabelCloudFailureError = (input = {}) => {
|
|
245
|
+
const failure = normalizeRecordTimeLabelCloudFailure(input);
|
|
246
|
+
const error = new Error(failure.message);
|
|
247
|
+
error.class = failure.class;
|
|
248
|
+
error.code = failure.code;
|
|
249
|
+
error.reason = failure.reason;
|
|
250
|
+
error.status = failure.status;
|
|
251
|
+
error.retryable = failure.retryable;
|
|
252
|
+
error.retryAfterMs = failure.retryAfterMs;
|
|
253
|
+
error.bootstrapRequired = failure.bootstrapRequired;
|
|
254
|
+
return error;
|
|
255
|
+
};
|
|
256
|
+
|
|
26
257
|
const ALLOWED_OPERATION_RESULT_STATUSES = new Set(
|
|
27
258
|
Object.values(RECORD_TIMELABEL_OPERATION_RESULT_STATUSES)
|
|
28
259
|
);
|
|
@@ -269,10 +500,14 @@ export default {
|
|
|
269
500
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
270
501
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
271
502
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
503
|
+
RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE,
|
|
504
|
+
RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
|
|
272
505
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
273
506
|
toRecordTimeLabelWireOperation,
|
|
274
507
|
buildRecordTimeLabelRequestId,
|
|
275
508
|
createRecordTimeLabelTransportFailureResults,
|
|
276
509
|
normalizeRecordTimeLabelOperationResults,
|
|
277
|
-
normalizeRecordTimeLabelEnvelopeResponse
|
|
510
|
+
normalizeRecordTimeLabelEnvelopeResponse,
|
|
511
|
+
normalizeRecordTimeLabelCloudFailure,
|
|
512
|
+
toRecordTimeLabelCloudFailureError
|
|
278
513
|
};
|