@recordtimelabel/core 0.6.9 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
17
17
  "@recordtimelabel/core": "file:../recordtimelabel-core"
18
18
  ```
19
19
 
20
- For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The release target is `0.6.9`; verify that its registry tarball and lockfile integrity are available before updating consumers:
20
+ For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The release target is `0.6.11`; verify that its registry tarball and lockfile integrity are available before updating consumers:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.6.9"
23
+ "@recordtimelabel/core": "0.6.11"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
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
@@ -7,6 +7,7 @@ import {
7
7
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
8
8
  RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
9
9
  RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
10
+ RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
10
11
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
11
12
  RECORD_TIMELABEL_CLOUD_RECOVERY_ACTIONS,
12
13
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
@@ -33,6 +34,7 @@ export {
33
34
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
34
35
  RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS,
35
36
  RECORD_TIMELABEL_CLOUD_FAILURE_SCHEMA_VERSION,
37
+ RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
36
38
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES,
37
39
  RECORD_TIMELABEL_CLOUD_RECOVERY_ACTIONS,
38
40
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
@@ -145,13 +147,14 @@ export {
145
147
  selectBestMatchingTwitchVod
146
148
  };
147
149
 
148
- export const RECORD_TIMELABEL_CORE_VERSION = '0.6.9';
150
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.11';
149
151
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
150
152
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
151
153
  'fifo-retry-fence',
152
154
  'baseline-refresh-after-rejection',
153
155
  'sync-batch-boundary',
154
156
  'remote-subscription-readiness',
157
+ 'twitch-vod-sibling-reconciliation-v1',
155
158
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
156
159
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
157
160
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
@@ -1484,6 +1487,27 @@ export const applyOperation = (state = {}, operation = {}) => {
1484
1487
 
1485
1488
  export const applyRecordTimeLabelOperation = (state = {}, operation = {}) => applyOperation(state, operation);
1486
1489
 
1490
+ const collectRecordTimeLabelDomainRecords = (records = {}) => Object.entries(records || {})
1491
+ .flatMap(([folderId, folderRecords]) => (
1492
+ folderId === 'all' || !Array.isArray(folderRecords) ? [] : folderRecords
1493
+ ));
1494
+
1495
+ export const buildRecordTimeLabelVodSiblingOperations = ({
1496
+ state = {},
1497
+ fallbackPlatform = 'unknown',
1498
+ unknownTitle
1499
+ } = {}) => {
1500
+ const normalized = normalizeState(state);
1501
+ const result = buildKnownTwitchVodRecordPatches({
1502
+ records: collectRecordTimeLabelDomainRecords(normalized.records),
1503
+ fallbackPlatform,
1504
+ unknownTitle
1505
+ });
1506
+ return result.patches.map(({recordId, patch}) => (
1507
+ buildRecordTimeLabelVodPatchOperation({recordId, patch})
1508
+ ));
1509
+ };
1510
+
1487
1511
  export const mergeLocalRemote = ({
1488
1512
  localState = {},
1489
1513
  remoteState = {},
@@ -3028,6 +3052,22 @@ export const createRecordTimeLabelController = ({
3028
3052
  });
3029
3053
  };
3030
3054
 
3055
+ const reconcileVodSiblings = () => {
3056
+ const pendingIds = new Set(pendingOps.map((operation) => operation?.id).filter(Boolean));
3057
+ const operations = buildRecordTimeLabelVodSiblingOperations({state})
3058
+ .filter((operation) => !pendingIds.has(operation.id))
3059
+ .map((operation) => ({
3060
+ ...operation,
3061
+ clientId: operation.clientId || clientId,
3062
+ createdAt: operation.createdAt || clock()
3063
+ }));
3064
+ operations.forEach((operation) => {
3065
+ state = applyRecordTimeLabelOperation(state, operation);
3066
+ });
3067
+ if (operations.length > 0) pendingOps = [...pendingOps, ...operations];
3068
+ return operations;
3069
+ };
3070
+
3031
3071
  const controller = {
3032
3072
  async init() {
3033
3073
  const loaded = await storageAdapter.load(storageKeys);
@@ -3035,6 +3075,8 @@ export const createRecordTimeLabelController = ({
3035
3075
  pendingOps = toArray(loaded?.pendingOps).map((operation) => ({ ...operation }));
3036
3076
  syncMeta = loaded?.syncMeta && typeof loaded.syncMeta === 'object' ? { ...loaded.syncMeta } : {};
3037
3077
 
3078
+ let shouldPersist = false;
3079
+
3038
3080
  if (cloudAdapter?.load) {
3039
3081
  const remoteState = await cloudAdapter.load();
3040
3082
  const merged = mergeRemoteStateIntoLocal({
@@ -3047,8 +3089,20 @@ export const createRecordTimeLabelController = ({
3047
3089
  now: clock()
3048
3090
  });
3049
3091
  state = merged.state;
3050
- await persist();
3092
+ shouldPersist = true;
3093
+ }
3094
+
3095
+ const reconciledOperations = reconcileVodSiblings();
3096
+ if (reconciledOperations.length > 0) {
3097
+ const lastOperation = reconciledOperations.at(-1);
3098
+ syncMeta = {
3099
+ ...syncMeta,
3100
+ lastLocalOperationAt: lastOperation.createdAt,
3101
+ lastLocalOperationType: lastOperation.type
3102
+ };
3103
+ shouldPersist = true;
3051
3104
  }
3105
+ if (shouldPersist) await persist();
3052
3106
 
3053
3107
  if (cloudAdapter?.subscribe) {
3054
3108
  unsubscribeCloud = cloudAdapter.subscribe(async (remoteState) => {
@@ -3079,13 +3133,19 @@ export const createRecordTimeLabelController = ({
3079
3133
  };
3080
3134
  state = applyRecordTimeLabelOperation(state, nextOperation);
3081
3135
  pendingOps = [...pendingOps, nextOperation];
3136
+ const reconciledOperations = reconcileVodSiblings();
3137
+ const lastOperation = reconciledOperations.at(-1) || nextOperation;
3082
3138
  syncMeta = {
3083
3139
  ...syncMeta,
3084
- lastLocalOperationAt: nextOperation.createdAt,
3085
- lastLocalOperationType: nextOperation.type
3140
+ lastLocalOperationAt: lastOperation.createdAt,
3141
+ lastLocalOperationType: lastOperation.type
3086
3142
  };
3087
3143
  await persist();
3088
- notify({ type: 'local_applied', operation: nextOperation });
3144
+ notify({
3145
+ type: 'local_applied',
3146
+ operation: nextOperation,
3147
+ operations: [nextOperation, ...reconciledOperations]
3148
+ });
3089
3149
  return getSnapshot();
3090
3150
  },
3091
3151
 
@@ -3100,8 +3160,17 @@ export const createRecordTimeLabelController = ({
3100
3160
  now: clock()
3101
3161
  });
3102
3162
  state = merged.state;
3163
+ const reconciledOperations = reconcileVodSiblings();
3164
+ if (reconciledOperations.length > 0) {
3165
+ const lastOperation = reconciledOperations.at(-1);
3166
+ syncMeta = {
3167
+ ...syncMeta,
3168
+ lastLocalOperationAt: lastOperation.createdAt,
3169
+ lastLocalOperationType: lastOperation.type
3170
+ };
3171
+ }
3103
3172
  await persist();
3104
- notify({ type: 'remote_merged' });
3173
+ notify({ type: 'remote_merged', operations: reconciledOperations });
3105
3174
  return getSnapshot();
3106
3175
  },
3107
3176
 
@@ -4169,6 +4238,32 @@ export const createRecordTimeLabelSyncEngine = ({
4169
4238
  return {changed, stale: false};
4170
4239
  };
4171
4240
 
4241
+ const appendVodSiblingReconciliation = (candidate, captured, {syncBatchId = null} = {}) => {
4242
+ const operations = buildRecordTimeLabelVodSiblingOperations({
4243
+ state: rtlDeriveDurableVisibleState(candidate)
4244
+ });
4245
+ if (operations.length === 0) return [];
4246
+
4247
+ const normalized = operations.map((operation) => rtlNormalizePendingOperation(operation, {
4248
+ client,
4249
+ clientId: typeof client === 'string' ? client : client?.id,
4250
+ now,
4251
+ ownerUid: captured?.uid ?? candidate.ownerUid,
4252
+ workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
4253
+ }));
4254
+ const reconciliationBatchId = syncBatchId || rtlStableSyncBatchId(normalized);
4255
+ normalized.forEach((operation) => {
4256
+ if (!operation.syncBatchId) operation.syncBatchId = reconciliationBatchId;
4257
+ });
4258
+ const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
4259
+ candidate.pendingOperations = [
4260
+ ...candidate.pendingOperations,
4261
+ ...identityChecked.accepted
4262
+ ];
4263
+ candidate.rejectedOperations = identityChecked.rejected;
4264
+ return identityChecked.accepted;
4265
+ };
4266
+
4172
4267
  const createBootstrapAttemptOptions = (mode) => ({
4173
4268
  mode,
4174
4269
  attemptId: `bootstrap:${mode}:${now()}:${++bootstrapAttemptSequence}:${
@@ -4952,9 +5047,10 @@ export const createRecordTimeLabelSyncEngine = ({
4952
5047
  }
4953
5048
  const applied = applyRemoteBaseline(candidate, baselineValue);
4954
5049
  if (applied.stale || !applied.changed) return {success: true, ignored: true};
5050
+ const reconciledOperations = appendVodSiblingReconciliation(candidate, captured);
4955
5051
  if (!(await persist(candidate, captured))) return {stale: true, reason: 'stale_session'};
4956
5052
  workspace = candidate;
4957
- notify({type: 'remote_merged'});
5053
+ notify({type: 'remote_merged', operations: clone(reconciledOperations)});
4958
5054
  return getSnapshot();
4959
5055
  };
4960
5056
 
@@ -5322,6 +5418,16 @@ export const createRecordTimeLabelSyncEngine = ({
5322
5418
  // Always persist the normalized durable shape before subscribing. This
5323
5419
  // also makes legacy migration atomic from the engine's point of view.
5324
5420
  clearPersistedHydrationBarrier(candidate);
5421
+ const reconciledOperations = appendVodSiblingReconciliation(candidate, captured);
5422
+ if (reconciledOperations.length > 0) {
5423
+ const lastOperation = reconciledOperations.at(-1);
5424
+ candidate.syncMeta = {
5425
+ ...candidate.syncMeta,
5426
+ lastLocalOperationAt: lastOperation.createdAt,
5427
+ lastLocalOperationId: lastOperation.id,
5428
+ lastLocalOperationType: lastOperation.type
5429
+ };
5430
+ }
5325
5431
  if (!(await persist(candidate, captured))) return getSnapshot();
5326
5432
  workspace = candidate;
5327
5433
  initialized = true;
@@ -5397,6 +5503,8 @@ export const createRecordTimeLabelSyncEngine = ({
5397
5503
  ...durableOperations
5398
5504
  ];
5399
5505
  candidate.rejectedOperations = identityChecked.rejected;
5506
+ const reconciledOperations = appendVodSiblingReconciliation(candidate, captured, {syncBatchId});
5507
+ const allDurableOperations = [...durableOperations, ...reconciledOperations];
5400
5508
  if (viewOperations.length > 0) {
5401
5509
  candidate.syncMeta = {
5402
5510
  ...candidate.syncMeta,
@@ -5406,8 +5514,8 @@ export const createRecordTimeLabelSyncEngine = ({
5406
5514
  lastLocalViewOperationId: viewOperations[viewOperations.length - 1].id
5407
5515
  };
5408
5516
  }
5409
- if (durableOperations.length > 0) {
5410
- const lastOperation = durableOperations[durableOperations.length - 1];
5517
+ if (allDurableOperations.length > 0) {
5518
+ const lastOperation = allDurableOperations[allDurableOperations.length - 1];
5411
5519
  candidate.syncMeta = {
5412
5520
  ...candidate.syncMeta,
5413
5521
  lastLocalOperationAt: lastOperation.createdAt,
@@ -5417,11 +5525,11 @@ export const createRecordTimeLabelSyncEngine = ({
5417
5525
  }
5418
5526
  if (!(await persist(candidate, captured))) return getSnapshot();
5419
5527
  workspace = candidate;
5420
- if (durableOperations.length > 0 || viewOperations.length > 0) {
5528
+ if (allDurableOperations.length > 0 || viewOperations.length > 0) {
5421
5529
  notify({
5422
5530
  type: 'local_applied',
5423
- operations: clone([...durableOperations, ...viewOperations]),
5424
- operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
5531
+ operations: clone([...allDurableOperations, ...viewOperations]),
5532
+ operation: clone(viewOperations.at(-1) || allDurableOperations.at(-1)),
5425
5533
  rejectedCount: identityChecked.rejectedCount,
5426
5534
  deduplicatedCount: identityChecked.deduplicatedCount
5427
5535
  });
@@ -7336,6 +7444,7 @@ export default {
7336
7444
  extractTwitchVodIdFromUrl,
7337
7445
  extractYouTubeVideoIdFromUrl,
7338
7446
  buildKnownTwitchVodRecordPatches,
7447
+ buildRecordTimeLabelVodSiblingOperations,
7339
7448
  buildRecordTimeLabelVodPatchOperation,
7340
7449
  buildRecordTimeLabelVodPatchOperationId,
7341
7450
  calculateVodTitleSimilarity,
package/src/protocol.js CHANGED
@@ -20,6 +20,11 @@ export const RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE =
20
20
  export const RECORD_TIMELABEL_CAPABILITY_CLOUD_FAILURE_STATE = 'cloud-failure-state-v1';
21
21
  export const RECORD_TIMELABEL_CAPABILITY_DETERMINISTIC_PLANNER = 'deterministic-planner-v1';
22
22
  export const RECORD_TIMELABEL_CAPABILITY_STRICT_READINESS = 'strict-readiness-v1';
23
+ // Platform adapters use this exact code when their local durable store cannot
24
+ // accept another snapshot. Keep provider-specific matching in the adapter;
25
+ // Core owns only the canonical wire code and recovery semantics.
26
+ export const RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED =
27
+ 'recordtimelabel_local_storage_quota_exceeded';
23
28
  // Keep the original capability spelling for rolling compatibility. New
24
29
  // clients may advertise the versioned alias while old clients continue to
25
30
  // advertise `lifecycle-generation-fence`.
@@ -133,6 +138,8 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
133
138
  ['too_many_requests', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
134
139
  ['network_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
135
140
  ['network_request_failed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
141
+ [RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
142
+ RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
136
143
  ['bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
137
144
  ['bootstraprequired', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
138
145
  ['recordtimelabel_bootstrap_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
@@ -856,6 +863,7 @@ export const createRecordTimeLabelTransportFailureResults = (
856
863
 
857
864
  export default {
858
865
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
866
+ RECORD_TIMELABEL_CLOUD_FAILURE_CODE_LOCAL_STORAGE_QUOTA_EXCEEDED,
859
867
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
860
868
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
861
869
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,