@recordtimelabel/core 0.6.8 → 0.6.10
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 +2 -2
- package/package.json +1 -1
- package/src/changefeed.js +31 -1
- package/src/domain.js +1 -0
- package/src/index.js +119 -12
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 release target 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 release target is `0.6.9`; verify that its registry tarball and lockfile integrity are available before updating consumers:
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.6.
|
|
23
|
+
"@recordtimelabel/core": "0.6.9"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
|
package/package.json
CHANGED
package/src/changefeed.js
CHANGED
|
@@ -145,6 +145,33 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
|
|
|
145
145
|
};
|
|
146
146
|
let rootChanged = false;
|
|
147
147
|
|
|
148
|
+
// Resolved documents represent the final target revision, not every
|
|
149
|
+
// intermediate receipt. If an entity is changed and then deleted within
|
|
150
|
+
// this contiguous batch, its final document correctly does not exist.
|
|
151
|
+
// Record the final mutation so that specific case can be reduced without
|
|
152
|
+
// weakening the fail-closed rule for genuinely missing changed documents.
|
|
153
|
+
const finalMutations = {
|
|
154
|
+
records: new Map(),
|
|
155
|
+
folders: new Map(),
|
|
156
|
+
trash: new Map()
|
|
157
|
+
};
|
|
158
|
+
batch.forEach((change, index) => {
|
|
159
|
+
[
|
|
160
|
+
['records', change?.changedRecordIds, 'changed'],
|
|
161
|
+
['records', change?.deletedRecordIds, 'deleted'],
|
|
162
|
+
['folders', change?.changedFolderIds, 'changed'],
|
|
163
|
+
['folders', change?.deletedFolderIds, 'deleted'],
|
|
164
|
+
['trash', change?.changedTrashEntryIds, 'changed'],
|
|
165
|
+
['trash', change?.deletedTrashEntryIds, 'deleted']
|
|
166
|
+
].forEach(([kind, ids, mutation]) => {
|
|
167
|
+
toIdList(ids).forEach((id) => finalMutations[kind].set(id, {index, mutation}));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
const isShadowedByLaterDelete = (kind, id, index) => {
|
|
171
|
+
const finalMutation = finalMutations[kind].get(id);
|
|
172
|
+
return finalMutation?.mutation === 'deleted' && finalMutation.index > index;
|
|
173
|
+
};
|
|
174
|
+
|
|
148
175
|
for (let index = 0; index < batch.length; index += 1) {
|
|
149
176
|
const change = batch[index] || {};
|
|
150
177
|
const revision = Number(change.revision || 0);
|
|
@@ -193,7 +220,7 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
|
|
|
193
220
|
...changedTrash.map((id) => ['trash', id])
|
|
194
221
|
].some(([kind, id]) => {
|
|
195
222
|
const document = resolvedDocument(resolvedDocuments, kind, id);
|
|
196
|
-
return document == null;
|
|
223
|
+
return document == null && !isShadowedByLaterDelete(kind, id, index);
|
|
197
224
|
});
|
|
198
225
|
if (missingChanged) {
|
|
199
226
|
return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CHANGED_DOCUMENT_MISSING, inputCache);
|
|
@@ -211,14 +238,17 @@ export const applyFirestoreV2ResolvedChangeBatch = ({
|
|
|
211
238
|
|
|
212
239
|
changedRecords.forEach((id) => {
|
|
213
240
|
const document = resolvedDocument(resolvedDocuments, 'records', id);
|
|
241
|
+
if (document == null && isShadowedByLaterDelete('records', id, index)) return;
|
|
214
242
|
candidate.documents.records[id] = {id, ...document};
|
|
215
243
|
});
|
|
216
244
|
changedFolders.forEach((id) => {
|
|
217
245
|
const document = resolvedDocument(resolvedDocuments, 'folders', id);
|
|
246
|
+
if (document == null && isShadowedByLaterDelete('folders', id, index)) return;
|
|
218
247
|
candidate.documents.folders[id] = {id, ...document};
|
|
219
248
|
});
|
|
220
249
|
changedTrash.forEach((id) => {
|
|
221
250
|
const document = resolvedDocument(resolvedDocuments, 'trash', id);
|
|
251
|
+
if (document == null && isShadowedByLaterDelete('trash', id, index)) return;
|
|
222
252
|
candidate.documents.trash[id] = {id, ...document};
|
|
223
253
|
});
|
|
224
254
|
deletedRecords.forEach((id) => delete candidate.documents.records[id]);
|
package/src/domain.js
CHANGED
package/src/index.js
CHANGED
|
@@ -145,13 +145,14 @@ export {
|
|
|
145
145
|
selectBestMatchingTwitchVod
|
|
146
146
|
};
|
|
147
147
|
|
|
148
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.
|
|
148
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.10';
|
|
149
149
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
150
150
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
151
151
|
'fifo-retry-fence',
|
|
152
152
|
'baseline-refresh-after-rejection',
|
|
153
153
|
'sync-batch-boundary',
|
|
154
154
|
'remote-subscription-readiness',
|
|
155
|
+
'twitch-vod-sibling-reconciliation-v1',
|
|
155
156
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
156
157
|
RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
|
|
157
158
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
@@ -1484,6 +1485,27 @@ export const applyOperation = (state = {}, operation = {}) => {
|
|
|
1484
1485
|
|
|
1485
1486
|
export const applyRecordTimeLabelOperation = (state = {}, operation = {}) => applyOperation(state, operation);
|
|
1486
1487
|
|
|
1488
|
+
const collectRecordTimeLabelDomainRecords = (records = {}) => Object.entries(records || {})
|
|
1489
|
+
.flatMap(([folderId, folderRecords]) => (
|
|
1490
|
+
folderId === 'all' || !Array.isArray(folderRecords) ? [] : folderRecords
|
|
1491
|
+
));
|
|
1492
|
+
|
|
1493
|
+
export const buildRecordTimeLabelVodSiblingOperations = ({
|
|
1494
|
+
state = {},
|
|
1495
|
+
fallbackPlatform = 'unknown',
|
|
1496
|
+
unknownTitle
|
|
1497
|
+
} = {}) => {
|
|
1498
|
+
const normalized = normalizeState(state);
|
|
1499
|
+
const result = buildKnownTwitchVodRecordPatches({
|
|
1500
|
+
records: collectRecordTimeLabelDomainRecords(normalized.records),
|
|
1501
|
+
fallbackPlatform,
|
|
1502
|
+
unknownTitle
|
|
1503
|
+
});
|
|
1504
|
+
return result.patches.map(({recordId, patch}) => (
|
|
1505
|
+
buildRecordTimeLabelVodPatchOperation({recordId, patch})
|
|
1506
|
+
));
|
|
1507
|
+
};
|
|
1508
|
+
|
|
1487
1509
|
export const mergeLocalRemote = ({
|
|
1488
1510
|
localState = {},
|
|
1489
1511
|
remoteState = {},
|
|
@@ -3028,6 +3050,22 @@ export const createRecordTimeLabelController = ({
|
|
|
3028
3050
|
});
|
|
3029
3051
|
};
|
|
3030
3052
|
|
|
3053
|
+
const reconcileVodSiblings = () => {
|
|
3054
|
+
const pendingIds = new Set(pendingOps.map((operation) => operation?.id).filter(Boolean));
|
|
3055
|
+
const operations = buildRecordTimeLabelVodSiblingOperations({state})
|
|
3056
|
+
.filter((operation) => !pendingIds.has(operation.id))
|
|
3057
|
+
.map((operation) => ({
|
|
3058
|
+
...operation,
|
|
3059
|
+
clientId: operation.clientId || clientId,
|
|
3060
|
+
createdAt: operation.createdAt || clock()
|
|
3061
|
+
}));
|
|
3062
|
+
operations.forEach((operation) => {
|
|
3063
|
+
state = applyRecordTimeLabelOperation(state, operation);
|
|
3064
|
+
});
|
|
3065
|
+
if (operations.length > 0) pendingOps = [...pendingOps, ...operations];
|
|
3066
|
+
return operations;
|
|
3067
|
+
};
|
|
3068
|
+
|
|
3031
3069
|
const controller = {
|
|
3032
3070
|
async init() {
|
|
3033
3071
|
const loaded = await storageAdapter.load(storageKeys);
|
|
@@ -3035,6 +3073,8 @@ export const createRecordTimeLabelController = ({
|
|
|
3035
3073
|
pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
|
|
3036
3074
|
syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
|
|
3037
3075
|
|
|
3076
|
+
let shouldPersist = false;
|
|
3077
|
+
|
|
3038
3078
|
if (cloudAdapter?.load) {
|
|
3039
3079
|
const remoteState = await cloudAdapter.load();
|
|
3040
3080
|
const merged = mergeRemoteStateIntoLocal({
|
|
@@ -3047,8 +3087,20 @@ export const createRecordTimeLabelController = ({
|
|
|
3047
3087
|
now: clock()
|
|
3048
3088
|
});
|
|
3049
3089
|
state = merged.state;
|
|
3050
|
-
|
|
3090
|
+
shouldPersist = true;
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
const reconciledOperations = reconcileVodSiblings();
|
|
3094
|
+
if (reconciledOperations.length > 0) {
|
|
3095
|
+
const lastOperation = reconciledOperations.at(-1);
|
|
3096
|
+
syncMeta = {
|
|
3097
|
+
...syncMeta,
|
|
3098
|
+
lastLocalOperationAt: lastOperation.createdAt,
|
|
3099
|
+
lastLocalOperationType: lastOperation.type
|
|
3100
|
+
};
|
|
3101
|
+
shouldPersist = true;
|
|
3051
3102
|
}
|
|
3103
|
+
if (shouldPersist) await persist();
|
|
3052
3104
|
|
|
3053
3105
|
if (cloudAdapter?.subscribe) {
|
|
3054
3106
|
unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
|
|
@@ -3079,13 +3131,19 @@ export const createRecordTimeLabelController = ({
|
|
|
3079
3131
|
};
|
|
3080
3132
|
state = applyRecordTimeLabelOperation(state, nextOperation);
|
|
3081
3133
|
pendingOps = [...pendingOps, nextOperation];
|
|
3134
|
+
const reconciledOperations = reconcileVodSiblings();
|
|
3135
|
+
const lastOperation = reconciledOperations.at(-1) || nextOperation;
|
|
3082
3136
|
syncMeta = {
|
|
3083
3137
|
...syncMeta,
|
|
3084
|
-
lastLocalOperationAt:
|
|
3085
|
-
lastLocalOperationType:
|
|
3138
|
+
lastLocalOperationAt: lastOperation.createdAt,
|
|
3139
|
+
lastLocalOperationType: lastOperation.type
|
|
3086
3140
|
};
|
|
3087
3141
|
await persist();
|
|
3088
|
-
notify({
|
|
3142
|
+
notify({
|
|
3143
|
+
type: 'local_applied',
|
|
3144
|
+
operation: nextOperation,
|
|
3145
|
+
operations: [nextOperation, ...reconciledOperations]
|
|
3146
|
+
});
|
|
3089
3147
|
return getSnapshot();
|
|
3090
3148
|
},
|
|
3091
3149
|
|
|
@@ -3100,8 +3158,17 @@ export const createRecordTimeLabelController = ({
|
|
|
3100
3158
|
now: clock()
|
|
3101
3159
|
});
|
|
3102
3160
|
state = merged.state;
|
|
3161
|
+
const reconciledOperations = reconcileVodSiblings();
|
|
3162
|
+
if (reconciledOperations.length > 0) {
|
|
3163
|
+
const lastOperation = reconciledOperations.at(-1);
|
|
3164
|
+
syncMeta = {
|
|
3165
|
+
...syncMeta,
|
|
3166
|
+
lastLocalOperationAt: lastOperation.createdAt,
|
|
3167
|
+
lastLocalOperationType: lastOperation.type
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3103
3170
|
await persist();
|
|
3104
|
-
notify({ type: 'remote_merged' });
|
|
3171
|
+
notify({ type: 'remote_merged', operations: reconciledOperations });
|
|
3105
3172
|
return getSnapshot();
|
|
3106
3173
|
},
|
|
3107
3174
|
|
|
@@ -4169,6 +4236,32 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4169
4236
|
return {changed, stale: false};
|
|
4170
4237
|
};
|
|
4171
4238
|
|
|
4239
|
+
const appendVodSiblingReconciliation = (candidate, captured, {syncBatchId = null} = {}) => {
|
|
4240
|
+
const operations = buildRecordTimeLabelVodSiblingOperations({
|
|
4241
|
+
state: rtlDeriveDurableVisibleState(candidate)
|
|
4242
|
+
});
|
|
4243
|
+
if (operations.length === 0) return [];
|
|
4244
|
+
|
|
4245
|
+
const normalized = operations.map((operation) => rtlNormalizePendingOperation(operation, {
|
|
4246
|
+
client,
|
|
4247
|
+
clientId: typeof client === 'string' ? client : client?.id,
|
|
4248
|
+
now,
|
|
4249
|
+
ownerUid: captured?.uid ?? candidate.ownerUid,
|
|
4250
|
+
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
|
|
4251
|
+
}));
|
|
4252
|
+
const reconciliationBatchId = syncBatchId || rtlStableSyncBatchId(normalized);
|
|
4253
|
+
normalized.forEach((operation) => {
|
|
4254
|
+
if (!operation.syncBatchId) operation.syncBatchId = reconciliationBatchId;
|
|
4255
|
+
});
|
|
4256
|
+
const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
|
|
4257
|
+
candidate.pendingOperations = [
|
|
4258
|
+
...candidate.pendingOperations,
|
|
4259
|
+
...identityChecked.accepted
|
|
4260
|
+
];
|
|
4261
|
+
candidate.rejectedOperations = identityChecked.rejected;
|
|
4262
|
+
return identityChecked.accepted;
|
|
4263
|
+
};
|
|
4264
|
+
|
|
4172
4265
|
const createBootstrapAttemptOptions = (mode) => ({
|
|
4173
4266
|
mode,
|
|
4174
4267
|
attemptId: `bootstrap:${mode}:${now()}:${++bootstrapAttemptSequence}:${
|
|
@@ -4952,9 +5045,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
4952
5045
|
}
|
|
4953
5046
|
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
4954
5047
|
if (applied.stale || !applied.changed) return {success: true, ignored: true};
|
|
5048
|
+
const reconciledOperations = appendVodSiblingReconciliation(candidate, captured);
|
|
4955
5049
|
if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
|
|
4956
5050
|
workspace = candidate;
|
|
4957
|
-
notify({type: 'remote_merged'});
|
|
5051
|
+
notify({type: 'remote_merged', operations: clone(reconciledOperations)});
|
|
4958
5052
|
return getSnapshot();
|
|
4959
5053
|
};
|
|
4960
5054
|
|
|
@@ -5322,6 +5416,16 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5322
5416
|
// Always persist the normalized durable shape before subscribing. This
|
|
5323
5417
|
// also makes legacy migration atomic from the engine's point of view.
|
|
5324
5418
|
clearPersistedHydrationBarrier(candidate);
|
|
5419
|
+
const reconciledOperations = appendVodSiblingReconciliation(candidate, captured);
|
|
5420
|
+
if (reconciledOperations.length > 0) {
|
|
5421
|
+
const lastOperation = reconciledOperations.at(-1);
|
|
5422
|
+
candidate.syncMeta = {
|
|
5423
|
+
...candidate.syncMeta,
|
|
5424
|
+
lastLocalOperationAt: lastOperation.createdAt,
|
|
5425
|
+
lastLocalOperationId: lastOperation.id,
|
|
5426
|
+
lastLocalOperationType: lastOperation.type
|
|
5427
|
+
};
|
|
5428
|
+
}
|
|
5325
5429
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5326
5430
|
workspace = candidate;
|
|
5327
5431
|
initialized = true;
|
|
@@ -5397,6 +5501,8 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5397
5501
|
...durableOperations
|
|
5398
5502
|
];
|
|
5399
5503
|
candidate.rejectedOperations = identityChecked.rejected;
|
|
5504
|
+
const reconciledOperations = appendVodSiblingReconciliation(candidate, captured, {syncBatchId});
|
|
5505
|
+
const allDurableOperations = [...durableOperations, ...reconciledOperations];
|
|
5400
5506
|
if (viewOperations.length > 0) {
|
|
5401
5507
|
candidate.syncMeta = {
|
|
5402
5508
|
...candidate.syncMeta,
|
|
@@ -5406,8 +5512,8 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5406
5512
|
lastLocalViewOperationId: viewOperations[viewOperations.length - 1].id
|
|
5407
5513
|
};
|
|
5408
5514
|
}
|
|
5409
|
-
if (
|
|
5410
|
-
const lastOperation =
|
|
5515
|
+
if (allDurableOperations.length > 0) {
|
|
5516
|
+
const lastOperation = allDurableOperations[allDurableOperations.length - 1];
|
|
5411
5517
|
candidate.syncMeta = {
|
|
5412
5518
|
...candidate.syncMeta,
|
|
5413
5519
|
lastLocalOperationAt: lastOperation.createdAt,
|
|
@@ -5417,11 +5523,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
5417
5523
|
}
|
|
5418
5524
|
if (!(await persist(candidate, captured))) return getSnapshot();
|
|
5419
5525
|
workspace = candidate;
|
|
5420
|
-
if (
|
|
5526
|
+
if (allDurableOperations.length > 0 || viewOperations.length > 0) {
|
|
5421
5527
|
notify({
|
|
5422
5528
|
type: 'local_applied',
|
|
5423
|
-
operations: clone([...
|
|
5424
|
-
operation: clone(viewOperations.at(-1) ||
|
|
5529
|
+
operations: clone([...allDurableOperations, ...viewOperations]),
|
|
5530
|
+
operation: clone(viewOperations.at(-1) || allDurableOperations.at(-1)),
|
|
5425
5531
|
rejectedCount: identityChecked.rejectedCount,
|
|
5426
5532
|
deduplicatedCount: identityChecked.deduplicatedCount
|
|
5427
5533
|
});
|
|
@@ -7336,6 +7442,7 @@ export default {
|
|
|
7336
7442
|
extractTwitchVodIdFromUrl,
|
|
7337
7443
|
extractYouTubeVideoIdFromUrl,
|
|
7338
7444
|
buildKnownTwitchVodRecordPatches,
|
|
7445
|
+
buildRecordTimeLabelVodSiblingOperations,
|
|
7339
7446
|
buildRecordTimeLabelVodPatchOperation,
|
|
7340
7447
|
buildRecordTimeLabelVodPatchOperationId,
|
|
7341
7448
|
calculateVodTitleSimilarity,
|