@recordtimelabel/core 0.6.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
package/src/domain.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export const RECORD_TIMELABEL_DOMAIN_CAPABILITIES = Object.freeze([
2
2
  'group-identity-v1',
3
3
  'twitch-vod-matching-v1',
4
+ 'twitch-vod-sibling-reconciliation-v1',
4
5
  'legacy-import-v1',
5
6
  'channel-folder-planning-v1'
6
7
  ]);
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.9';
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
- await persist();
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: nextOperation.createdAt,
3085
- lastLocalOperationType: nextOperation.type
3138
+ lastLocalOperationAt: lastOperation.createdAt,
3139
+ lastLocalOperationType: lastOperation.type
3086
3140
  };
3087
3141
  await persist();
3088
- notify({ type: 'local_applied', operation: nextOperation });
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 (durableOperations.length > 0) {
5410
- const lastOperation = durableOperations[durableOperations.length - 1];
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 (durableOperations.length > 0 || viewOperations.length > 0) {
5526
+ if (allDurableOperations.length > 0 || viewOperations.length > 0) {
5421
5527
  notify({
5422
5528
  type: 'local_applied',
5423
- operations: clone([...durableOperations, ...viewOperations]),
5424
- operation: clone(viewOperations.at(-1) || durableOperations.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,