@recordtimelabel/core 0.6.4 → 0.6.6

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.4`; 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.6`; verify that its registry tarball and lockfile integrity are available before updating consumers:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.6.4"
23
+ "@recordtimelabel/core": "0.6.6"
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.
@@ -134,10 +134,22 @@ The persisted workspace is versioned and contains only durable data:
134
134
  remoteBaseline: { state, revision: 12, changeCursor: 'cursor-12' },
135
135
  pendingOperations: [],
136
136
  rejectedOperations: {},
137
- syncMeta: {}
137
+ syncMeta: {
138
+ // Core-owned resume fence; the cursor itself remains opaque adapter data.
139
+ changeCursorBinding: {
140
+ ownerUid: 'user-id', uid: 'user-id', workspaceEpoch: 3,
141
+ authSessionBinding: 'auth-session-3'
142
+ }
143
+ }
138
144
  }
139
145
  ```
140
146
 
147
+ `remoteBaseline.changeCursor` is an opaque string owned by the cloud adapter and
148
+ must remain byte-for-byte stable across persistence. Core stores the owner,
149
+ workspace-epoch, and non-credential auth binding separately in
150
+ `syncMeta.changeCursorBinding` so a committed workspace can be resumed without
151
+ embedding session credentials in the cursor.
152
+
141
153
  Every normalized pending operation also carries `ownerUid` and a non-negative
142
154
  `workspaceEpoch`. Dispatch stamps these fields from the captured session; legacy
143
155
  operations missing them inherit the migrated workspace identity. An operation whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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/index.js CHANGED
@@ -141,7 +141,7 @@ export {
141
141
  selectBestMatchingTwitchVod
142
142
  };
143
143
 
144
- export const RECORD_TIMELABEL_CORE_VERSION = '0.6.4';
144
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.6.6';
145
145
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
146
146
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
147
147
  'fifo-retry-fence',
@@ -1041,9 +1041,9 @@ export const applyOperation = (state = {}, operation = {}) => {
1041
1041
 
1042
1042
  case OPERATION_TYPES.RECORD_MOVE: {
1043
1043
  const recordId = normalizeId(payload.recordId || payload.id || payload.record?.id);
1044
- const rawTargetFolderId = normalizeId(payload.targetFolderId || payload.folderId);
1044
+ const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
1045
1045
  if (!recordId || !rtlIsSafeDocumentId(recordId) ||
1046
- (rawTargetFolderId && !rtlIsSafeDocumentId(rawTargetFolderId))) return normalized;
1046
+ !rtlIsSafeDocumentId(rawTargetFolderId)) return normalized;
1047
1047
  const targetFolderId = safeFolderId(rawTargetFolderId);
1048
1048
  const entry = findRecordEntry(nextState.records, recordId);
1049
1049
  if (!entry) return normalized;
@@ -1145,6 +1145,10 @@ export const applyOperation = (state = {}, operation = {}) => {
1145
1145
  originalRecordIndex: Number.isInteger(payload.originalRecordIndex)
1146
1146
  ? payload.originalRecordIndex
1147
1147
  : Math.max(0, Number(existingEntry?.index || 0)),
1148
+ originalGroupId: String(payload.originalGroupId || '').trim(),
1149
+ originalGroupOrderIndex: Number.isInteger(payload.originalGroupOrderIndex)
1150
+ ? payload.originalGroupOrderIndex
1151
+ : -1,
1148
1152
  lifecycleGeneration,
1149
1153
  deletedAt,
1150
1154
  purgeAt: toFiniteTimestamp(payload.purgeAt) || deletedAt + RTL_TRASH_RETENTION_MS,
@@ -1206,6 +1210,16 @@ export const applyOperation = (state = {}, operation = {}) => {
1206
1210
  );
1207
1211
  targetRecords.splice(restoreIndex, 0, restoredRecord);
1208
1212
  nextState.records[targetFolderId] = targetRecords;
1213
+ const originalGroupId = String(trashEntry.originalGroupId || '').trim();
1214
+ const originalGroupOrderIndex = Number.isInteger(trashEntry.originalGroupOrderIndex)
1215
+ ? trashEntry.originalGroupOrderIndex
1216
+ : -1;
1217
+ if (originalGroupId && originalGroupOrderIndex >= 0 &&
1218
+ !nextState.groupOrder.includes(originalGroupId)) {
1219
+ const groupOrder = [...nextState.groupOrder];
1220
+ groupOrder.splice(Math.min(originalGroupOrderIndex, groupOrder.length), 0, originalGroupId);
1221
+ nextState.groupOrder = groupOrder;
1222
+ }
1209
1223
  delete nextState.trashEntries[trashEntryId];
1210
1224
  delete nextState.deletedRecordTombstones[recordId];
1211
1225
  break;
@@ -3288,20 +3302,20 @@ const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
3288
3302
  !Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
3289
3303
  !Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
3290
3304
  !rtlHasCompleteBaselineState(baseline.state) ||
3291
- !rtlRawNonNegativeSafeInteger(baseline.revision) ||
3292
- baseline.revision <= 0) {
3293
- return false;
3294
- }
3295
- if (!rtlResumeIdentityMatches(
3296
- baseline.changeCursor,
3297
- captured,
3298
- ownerUid,
3299
- loaded.workspaceEpoch
3300
- )) {
3305
+ !rtlRawNonNegativeSafeInteger(baseline.revision)) {
3301
3306
  return false;
3302
3307
  }
3303
3308
  if (loaded.syncMeta !== undefined &&
3304
3309
  !rtlDurableIsObject(loaded.syncMeta)) return false;
3310
+ if (typeof baseline.changeCursor !== 'string' ||
3311
+ !rtlResumeIdentityMatches(
3312
+ loaded.syncMeta?.changeCursorBinding,
3313
+ captured,
3314
+ ownerUid,
3315
+ loaded.workspaceEpoch
3316
+ )) {
3317
+ return false;
3318
+ }
3305
3319
  const resumeMarker = loaded.syncMeta?.resumeMarker ??
3306
3320
  loaded.syncMeta?.resume ??
3307
3321
  loaded.syncMeta?.bootstrapMarker;
@@ -3358,11 +3372,54 @@ const rtlStripSessionTokens = (value) => {
3358
3372
  const result = {};
3359
3373
  Object.entries(value).forEach(([key, entry]) => {
3360
3374
  if (rtlIsCredentialKey(key)) return;
3361
- result[key] = rtlStripSessionTokens(entry);
3375
+ Object.defineProperty(result, key, {
3376
+ value: rtlStripSessionTokens(entry),
3377
+ enumerable: true,
3378
+ configurable: true,
3379
+ writable: true
3380
+ });
3362
3381
  });
3363
3382
  return result;
3364
3383
  };
3365
3384
 
3385
+ // Domain state and operation payloads are opaque to Core. They may legitimately
3386
+ // contain fields such as `designToken` or `session` that are part of the user's
3387
+ // data, so the session-secret scrubber is limited to Core-owned envelopes.
3388
+ const rtlSanitizeDurableOperation = (operation) => {
3389
+ const sanitized = rtlStripSessionTokens(operation);
3390
+ if (rtlDurableIsObject(operation) &&
3391
+ Object.prototype.hasOwnProperty.call(operation, 'payload')) {
3392
+ sanitized.payload = clone(operation.payload);
3393
+ }
3394
+ return sanitized;
3395
+ };
3396
+
3397
+ const rtlSanitizeDurableWorkspace = (workspace) => {
3398
+ const sanitized = rtlStripSessionTokens(workspace);
3399
+ if (!rtlDurableIsObject(workspace)) return sanitized;
3400
+ if (rtlDurableIsObject(workspace.remoteBaseline) &&
3401
+ Object.prototype.hasOwnProperty.call(workspace.remoteBaseline, 'state')) {
3402
+ sanitized.remoteBaseline = {
3403
+ ...sanitized.remoteBaseline,
3404
+ state: clone(workspace.remoteBaseline.state)
3405
+ };
3406
+ }
3407
+ if (Array.isArray(workspace.pendingOperations)) {
3408
+ sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
3409
+ }
3410
+ if (rtlDurableIsObject(workspace.rejectedOperations)) {
3411
+ sanitized.rejectedOperations = {...sanitized.rejectedOperations};
3412
+ Object.entries(workspace.rejectedOperations).forEach(([id, entry]) => {
3413
+ if (!rtlDurableIsObject(entry) || !rtlDurableIsObject(entry.operation)) return;
3414
+ sanitized.rejectedOperations[id] = {
3415
+ ...sanitized.rejectedOperations[id],
3416
+ operation: rtlSanitizeDurableOperation(entry.operation)
3417
+ };
3418
+ });
3419
+ }
3420
+ return sanitized;
3421
+ };
3422
+
3366
3423
  const rtlNormalizeOperation = (operation = {}, {
3367
3424
  client,
3368
3425
  clientId,
@@ -3389,7 +3446,7 @@ const rtlNormalizeOperation = (operation = {}, {
3389
3446
  ...rtlStripSessionTokens(input),
3390
3447
  // Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
3391
3448
  // invalid whitespace or hostile ID instead of receiving a silently rewritten one.
3392
- id: Object.prototype.hasOwnProperty.call(input, 'id') ? input.id : generatedId,
3449
+ id: input.id === null || input.id === undefined ? generatedId : input.id,
3393
3450
  type: typeof input.type === 'string' ? input.type.trim() : input.type,
3394
3451
  payload: normalizedPayload,
3395
3452
  clientId: resolvedClientId,
@@ -3441,6 +3498,72 @@ const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) =>
3441
3498
  syncMeta: {}
3442
3499
  });
3443
3500
 
3501
+ const RTL_BOOTSTRAP_RESUME_META_KEYS = [
3502
+ 'resumeMarker',
3503
+ 'resume',
3504
+ 'bootstrapMarker',
3505
+ 'authSessionBinding',
3506
+ 'changeCursorBinding'
3507
+ ];
3508
+
3509
+ const rtlForceOperationIdentity = (operation, ownerUid, workspaceEpoch, options = {}) => ({
3510
+ ...rtlNormalizePendingOperation(operation, {
3511
+ ...options,
3512
+ ownerUid,
3513
+ workspaceEpoch
3514
+ }),
3515
+ ownerUid: rtlNormalizeUid(ownerUid),
3516
+ workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0)
3517
+ });
3518
+
3519
+ const rtlClearBootstrapResumeMeta = (syncMeta) => {
3520
+ const next = rtlDurableIsObject(syncMeta) ? {...syncMeta} : {};
3521
+ RTL_BOOTSTRAP_RESUME_META_KEYS.forEach((key) => {
3522
+ delete next[key];
3523
+ });
3524
+ return next;
3525
+ };
3526
+
3527
+ const rtlRebindSameOwnerWorkspaceEpoch = (workspace, captured, now) => {
3528
+ const ownerUid = rtlNormalizeUid(captured?.uid);
3529
+ const workspaceEpoch = rtlNormalizeWorkspaceEpoch(captured?.workspaceEpoch, 0);
3530
+ const next = clone(workspace);
3531
+ next.ownerUid = ownerUid;
3532
+ next.workspaceEpoch = workspaceEpoch;
3533
+ next.pendingOperations = toArray(next.pendingOperations).map((operation) => (
3534
+ rtlForceOperationIdentity(operation, ownerUid, workspaceEpoch, {now})
3535
+ ));
3536
+ if (rtlDurableIsObject(next.rejectedOperations)) {
3537
+ const rejected = {};
3538
+ Object.entries(next.rejectedOperations).forEach(([id, entry]) => {
3539
+ const value = rtlDurableIsObject(entry) ? {...entry} : entry;
3540
+ if (rtlDurableIsObject(value) && value.operation) {
3541
+ value.operation = rtlForceOperationIdentity(
3542
+ value.operation,
3543
+ ownerUid,
3544
+ workspaceEpoch,
3545
+ {now}
3546
+ );
3547
+ }
3548
+ Object.defineProperty(rejected, id, {
3549
+ value,
3550
+ enumerable: true,
3551
+ configurable: true,
3552
+ writable: true
3553
+ });
3554
+ });
3555
+ next.rejectedOperations = rejected;
3556
+ }
3557
+ next.remoteBaseline = {
3558
+ ...(rtlDurableIsObject(next.remoteBaseline)
3559
+ ? next.remoteBaseline
3560
+ : rtlNormalizeRemoteBaseline({})),
3561
+ changeCursor: null
3562
+ };
3563
+ next.syncMeta = rtlClearBootstrapResumeMeta(next.syncMeta);
3564
+ return next;
3565
+ };
3566
+
3444
3567
  const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3445
3568
  const source = rtlDurableIsObject(input) ? input : {};
3446
3569
  const legacy = !source.remoteBaseline && (
@@ -3479,7 +3602,12 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3479
3602
  Object.entries(rejectedSource).forEach(([rawId, value]) => {
3480
3603
  const id = normalizeId(rawId || value?.id || value?.operationId);
3481
3604
  if (!id) return;
3482
- rejectedOperations[id] = clone(value && typeof value === 'object' ? value : {reason: value});
3605
+ Object.defineProperty(rejectedOperations, id, {
3606
+ value: clone(value && typeof value === 'object' ? value : {reason: value}),
3607
+ enumerable: true,
3608
+ configurable: true,
3609
+ writable: true
3610
+ });
3483
3611
  if (rejectedOperations[id].operation) {
3484
3612
  rejectedOperations[id].operation = rtlNormalizePendingOperation(
3485
3613
  rejectedOperations[id].operation,
@@ -3506,11 +3634,13 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3506
3634
  const rtlOperationIdentityReason = (operation, workspace) => {
3507
3635
  const candidateIds = [
3508
3636
  operation?.id,
3637
+ operation?.payload?.id,
3509
3638
  operation?.payload?.recordId,
3510
3639
  operation?.payload?.folderId,
3511
3640
  operation?.payload?.targetFolderId,
3512
3641
  operation?.payload?.trashEntryId,
3513
3642
  operation?.payload?.record?.id,
3643
+ operation?.payload?.record?.folderId,
3514
3644
  operation?.payload?.folder?.id,
3515
3645
  ...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
3516
3646
  ...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
@@ -3651,7 +3781,8 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
3651
3781
  }
3652
3782
  rejectedCount += 1;
3653
3783
  const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
3654
- rejected[rejectedId] = {
3784
+ Object.defineProperty(rejected, rejectedId, {
3785
+ value: {
3655
3786
  id: rejectedId,
3656
3787
  operation: clone(operation),
3657
3788
  status: 'rejected',
@@ -3666,7 +3797,11 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
3666
3797
  ),
3667
3798
  workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
3668
3799
  }
3669
- };
3800
+ },
3801
+ enumerable: true,
3802
+ configurable: true,
3803
+ writable: true
3804
+ });
3670
3805
  });
3671
3806
  return {accepted, rejected, rejectedCount, deduplicatedCount};
3672
3807
  };
@@ -3741,8 +3876,6 @@ const rtlEnvelopePayload = (value) => {
3741
3876
  return candidate;
3742
3877
  };
3743
3878
  if (value && typeof value === 'object') {
3744
- if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
3745
- if (value.baseline) return normalizeRevisionAlias(value.baseline);
3746
3879
  // A canonical failure may carry an authoritative baseline for an
3747
3880
  // operation-id conflict. It must win over compatibility state fields
3748
3881
  // left beside `failure`; otherwise a stale outer state can be applied
@@ -3751,6 +3884,8 @@ const rtlEnvelopePayload = (value) => {
3751
3884
  const nested = rtlEnvelopePayload(value.failure);
3752
3885
  if (nested) return nested;
3753
3886
  }
3887
+ if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
3888
+ if (value.baseline) return normalizeRevisionAlias(value.baseline);
3754
3889
  if (
3755
3890
  value.state !== undefined ||
3756
3891
  value.data !== undefined ||
@@ -3980,17 +4115,20 @@ export const createRecordTimeLabelSyncEngine = ({
3980
4115
  const persist = async (candidate, captured) => {
3981
4116
  if (!(await isCurrent(captured))) return false;
3982
4117
  const cursor = candidate?.remoteBaseline?.changeCursor;
4118
+ candidate.syncMeta = rtlDurableIsObject(candidate.syncMeta)
4119
+ ? {...candidate.syncMeta}
4120
+ : {};
3983
4121
  if (cursor !== null && cursor !== undefined) {
3984
- const durableCursor = rtlDurableIsObject(cursor) ? clone(cursor) : {value: String(cursor)};
3985
- candidate.remoteBaseline.changeCursor = {
3986
- ...durableCursor,
4122
+ candidate.syncMeta.changeCursorBinding = {
3987
4123
  ownerUid: captured?.uid ?? null,
3988
4124
  uid: captured?.uid ?? null,
3989
4125
  workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
3990
4126
  authSessionBinding: captured?.authSessionBinding ?? null
3991
4127
  };
4128
+ } else {
4129
+ delete candidate.syncMeta.changeCursorBinding;
3992
4130
  }
3993
- const durableCandidate = rtlStripSessionTokens(candidate);
4131
+ const durableCandidate = rtlSanitizeDurableWorkspace(candidate);
3994
4132
  // The second argument is an optional adapter-side fence. The public
3995
4133
  // StoragePort remains compatible with save(workspace); adapters that can
3996
4134
  // enforce an atomic session/epoch check should consume this context before
@@ -4284,7 +4422,17 @@ export const createRecordTimeLabelSyncEngine = ({
4284
4422
  const epochMismatch = Boolean(
4285
4423
  captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
4286
4424
  );
4287
- if (ownerMismatch || epochMismatch) {
4425
+ if (ownerMismatch) {
4426
+ return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
4427
+ }
4428
+ if (epochMismatch) {
4429
+ if (
4430
+ sourceOwnerUid &&
4431
+ sourceOwnerUid === currentUid &&
4432
+ Number(currentEpoch) > Number(loadedWorkspace.workspaceEpoch)
4433
+ ) {
4434
+ return rtlRebindSameOwnerWorkspaceEpoch(loadedWorkspace, captured, now);
4435
+ }
4288
4436
  return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
4289
4437
  }
4290
4438
  const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
@@ -4358,6 +4506,30 @@ export const createRecordTimeLabelSyncEngine = ({
4358
4506
  ...extra
4359
4507
  });
4360
4508
 
4509
+ const persistHydrationBarrier = async (captured, extraMeta = {}) => {
4510
+ hydrationRequired = true;
4511
+ const candidate = clone(workspace);
4512
+ candidate.syncMeta = {
4513
+ ...candidate.syncMeta,
4514
+ hydrationRequired: true,
4515
+ ...extraMeta
4516
+ };
4517
+ if (!(await persist(candidate, captured))) return false;
4518
+ workspace = candidate;
4519
+ return true;
4520
+ };
4521
+
4522
+ const clearPersistedHydrationBarrier = (candidate) => {
4523
+ if (!rtlDurableIsObject(candidate?.syncMeta) ||
4524
+ !Object.prototype.hasOwnProperty.call(candidate.syncMeta, 'hydrationRequired')) {
4525
+ return candidate;
4526
+ }
4527
+ const nextMeta = {...candidate.syncMeta};
4528
+ delete nextMeta.hydrationRequired;
4529
+ candidate.syncMeta = nextMeta;
4530
+ return candidate;
4531
+ };
4532
+
4361
4533
  const persistTerminalFailureBlock = async ({
4362
4534
  failure,
4363
4535
  error,
@@ -4393,6 +4565,13 @@ export const createRecordTimeLabelSyncEngine = ({
4393
4565
  identityRejectedCount
4394
4566
  }) => {
4395
4567
  hydrationRequired = true;
4568
+ if (!(await persistHydrationBarrier(captured, {
4569
+ lastSyncAttemptAt: timestamp,
4570
+ lastSyncFailure: clone(failure),
4571
+ lastSyncError: failure.message || failure.code || 'bootstrap-required'
4572
+ }))) {
4573
+ return {success: false, reason: 'stale_session'};
4574
+ }
4396
4575
  if (typeof cloud?.bootstrap !== 'function') {
4397
4576
  return failureOutcome(failure, error, {
4398
4577
  bootstrapRequired: true,
@@ -4431,6 +4610,7 @@ export const createRecordTimeLabelSyncEngine = ({
4431
4610
  candidate.syncMeta.lastSyncFailure = clone(failure);
4432
4611
  candidate.syncMeta.lastSyncAttemptAt = timestamp;
4433
4612
  candidate.syncMeta.lastSyncError = null;
4613
+ clearPersistedHydrationBarrier(candidate);
4434
4614
  if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4435
4615
  workspace = candidate;
4436
4616
  hydrationRequired = false;
@@ -4614,14 +4794,19 @@ export const createRecordTimeLabelSyncEngine = ({
4614
4794
  const completedOperations = [];
4615
4795
  ready.forEach((operation) => {
4616
4796
  const reason = failure.reason || failure.code || 'operation_id_conflict';
4617
- nextRejected[operation.id] = {
4797
+ Object.defineProperty(nextRejected, operation.id, {
4798
+ value: {
4618
4799
  id: operation.id,
4619
4800
  operation: clone(operation),
4620
4801
  status: 'rejected',
4621
4802
  reason,
4622
4803
  rejectedAt: timestamp,
4623
4804
  response: rejectionResponse
4624
- };
4805
+ },
4806
+ enumerable: true,
4807
+ configurable: true,
4808
+ writable: true
4809
+ });
4625
4810
  rejectedResults.push({
4626
4811
  id: operation.id,
4627
4812
  operationId: operation.id,
@@ -4711,7 +4896,7 @@ export const createRecordTimeLabelSyncEngine = ({
4711
4896
  const remoteState = remote?.state ?? remote?.data;
4712
4897
  const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
4713
4898
  if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
4714
- if (typeof cloud?.bootstrap !== 'function') {
4899
+ if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
4715
4900
  const failure = normalizeRecordTimeLabelCloudFailure({
4716
4901
  class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4717
4902
  code: 'revision_gap',
@@ -4821,10 +5006,17 @@ export const createRecordTimeLabelSyncEngine = ({
4821
5006
 
4822
5007
  const resetForSessionIdentity = (captured) => {
4823
5008
  stopCloudSubscription();
4824
- workspace = rtlEmptyDurableWorkspace({
4825
- ownerUid: captured?.uid,
4826
- workspaceEpoch: captured?.workspaceEpoch
4827
- });
5009
+ const sameOwner = Boolean(
5010
+ rtlNormalizeUid(workspace.ownerUid) &&
5011
+ rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(captured?.uid)
5012
+ );
5013
+ const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
5014
+ workspace = sameOwner && epochIncreased
5015
+ ? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
5016
+ : rtlEmptyDurableWorkspace({
5017
+ ownerUid: captured?.uid,
5018
+ workspaceEpoch: captured?.workspaceEpoch
5019
+ });
4828
5020
  initialized = false;
4829
5021
  hydrationRequired = true;
4830
5022
  notify({type: 'session_changed'});
@@ -4857,6 +5049,7 @@ export const createRecordTimeLabelSyncEngine = ({
4857
5049
  let adapterReadyExpected = false;
4858
5050
  let firstCallbackResult = null;
4859
5051
  let firstRootObservation = null;
5052
+ let latestRootObservationRevision = null;
4860
5053
  let adapterCatchUpProof = null;
4861
5054
  const settleReadyIfProven = () => {
4862
5055
  if (!isCurrentRemoteReady(readyState) || readyState.settled ||
@@ -4873,6 +5066,31 @@ export const createRecordTimeLabelSyncEngine = ({
4873
5066
  const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
4874
5067
  ? Number(proof.caughtUpToRevision)
4875
5068
  : revision;
5069
+ if (revision < 0 || caughtUpToRevision < 0) {
5070
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5071
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5072
+ code: 'invalid_remote_ready_revision',
5073
+ reason: 'invalid_remote_ready_revision',
5074
+ message: 'invalid_remote_ready_revision'
5075
+ }));
5076
+ return;
5077
+ }
5078
+ const observedRevision = Number.isSafeInteger(latestRootObservationRevision)
5079
+ ? latestRootObservationRevision
5080
+ : baselineRevision;
5081
+ const requiredRevision = Math.max(baselineRevision, observedRevision);
5082
+ if (revision < requiredRevision || caughtUpToRevision < requiredRevision) {
5083
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5084
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5085
+ code: 'stale_remote_ready_revision',
5086
+ reason: 'stale_remote_ready_revision',
5087
+ message: 'stale_remote_ready_revision'
5088
+ }));
5089
+ return;
5090
+ }
5091
+ // A proof for a revision the root listener has not processed yet must
5092
+ // remain behind the barrier. A later root observation retries settling.
5093
+ if (revision > requiredRevision || caughtUpToRevision > requiredRevision) return;
4876
5094
  readyState.resolve({
4877
5095
  sessionKey: proof.sessionKey ??
4878
5096
  context.sessionKey ??
@@ -4883,7 +5101,7 @@ export const createRecordTimeLabelSyncEngine = ({
4883
5101
  caughtUpToRevision
4884
5102
  });
4885
5103
  };
4886
- const handleRootObservation = async (result) => {
5104
+ const handleRootObservation = async (result, observedRevision) => {
4887
5105
  if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
4888
5106
  if (result?.stale === true || result?.reason === 'stale_session') return;
4889
5107
  if (result?.success === false) {
@@ -4897,6 +5115,12 @@ export const createRecordTimeLabelSyncEngine = ({
4897
5115
  return;
4898
5116
  }
4899
5117
  firstRootObservation = result || {success: true};
5118
+ if (Number.isSafeInteger(observedRevision) && observedRevision >= 0) {
5119
+ latestRootObservationRevision = Math.max(
5120
+ latestRootObservationRevision ?? 0,
5121
+ observedRevision
5122
+ );
5123
+ }
4900
5124
  if (!adapterReadyExpected) {
4901
5125
  adapterCatchUpProof = {
4902
5126
  success: true,
@@ -4911,9 +5135,13 @@ export const createRecordTimeLabelSyncEngine = ({
4911
5135
  if (destroyed || !isCurrentRemoteReady(readyState)) {
4912
5136
  return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
4913
5137
  }
5138
+ const observedRevisionValue = Number(rtlEnvelopePayload(remoteValue)?.revision);
5139
+ const observedRevision = Number.isSafeInteger(observedRevisionValue) && observedRevisionValue >= 0
5140
+ ? observedRevisionValue
5141
+ : null;
4914
5142
  const callbackResult = enqueue(() => processRemote(remoteValue, captured));
4915
5143
  if (!firstCallbackResult) firstCallbackResult = callbackResult;
4916
- callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
5144
+ callbackResult.then((result) => handleRootObservation(result, observedRevision)).catch((error) => {
4917
5145
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
4918
5146
  readyState.reject(error);
4919
5147
  }
@@ -4931,7 +5159,7 @@ export const createRecordTimeLabelSyncEngine = ({
4931
5159
  }
4932
5160
  unsubscribeCloud = typeof disposer === 'function'
4933
5161
  ? disposer
4934
- : (typeof disposer?.unsubscribe === 'function' ? disposer.unsubscribe : null);
5162
+ : (typeof disposer?.unsubscribe === 'function' ? () => disposer.unsubscribe() : null);
4935
5163
  const onSubscriptionError = (error) => {
4936
5164
  if (!isCurrentRemoteReady(readyState)) return;
4937
5165
  stopCloudSubscription({settleReady: false});
@@ -4991,6 +5219,7 @@ export const createRecordTimeLabelSyncEngine = ({
4991
5219
  const loadedBaseline = loaded?.remoteBaseline;
4992
5220
  const committedRevision = Number(loadedBaseline?.revision);
4993
5221
  const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
5222
+ const persistedHydrationRequired = loaded?.syncMeta?.hydrationRequired === true;
4994
5223
  let candidate = normalizeLoadedWorkspace(loaded, captured);
4995
5224
  const identityChecked = rtlQuarantineOperations(
4996
5225
  candidate,
@@ -5002,17 +5231,20 @@ export const createRecordTimeLabelSyncEngine = ({
5002
5231
  const context = rtlSessionContext(captured, candidate, client);
5003
5232
 
5004
5233
  const canReuseCommitted = initialBaseline === 'reuse-committed' &&
5234
+ !persistedHydrationRequired &&
5005
5235
  captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
5006
5236
  hasValidCommittedBaseline &&
5007
5237
  candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
5008
5238
  candidate.remoteBaseline.revision === committedRevision &&
5009
5239
  (!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
5240
+ let obtainedFreshBaseline = canReuseCommitted;
5010
5241
  if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
5011
5242
  // Do not carry untrusted resume state into a fresh hydration attempt.
5012
5243
  // Only the bootstrap response may establish the next cursor/marker.
5013
5244
  candidate.remoteBaseline.changeCursor = null;
5014
5245
  candidate.syncMeta = {...candidate.syncMeta};
5015
- ['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding'].forEach((key) => {
5246
+ ['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding', 'changeCursorBinding']
5247
+ .forEach((key) => {
5016
5248
  delete candidate.syncMeta[key];
5017
5249
  });
5018
5250
  let bootstrap;
@@ -5023,15 +5255,41 @@ export const createRecordTimeLabelSyncEngine = ({
5023
5255
  ));
5024
5256
  } catch (error) {
5025
5257
  if (!(await isCurrent(captured))) return getSnapshot();
5258
+ if (persistedHydrationRequired) {
5259
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5260
+ await persist(candidate, captured);
5261
+ }
5026
5262
  rethrowClassifiedCloudFailure(captured, error);
5027
5263
  }
5028
5264
  if (!(await isCurrent(captured))) return getSnapshot();
5029
- applyRemoteBaseline(candidate, bootstrap);
5265
+ const applied = applyRemoteBaseline(candidate, bootstrap);
5266
+ if (applied.stale) {
5267
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5268
+ if (!(await persist(candidate, captured))) return getSnapshot();
5269
+ workspace = candidate;
5270
+ initialized = true;
5271
+ hydrationRequired = true;
5272
+ notify({type: 'initialized'});
5273
+ return getSnapshot();
5274
+ }
5030
5275
  delete candidate.syncMeta.terminalFailureBlock;
5276
+ clearPersistedHydrationBarrier(candidate);
5277
+ obtainedFreshBaseline = true;
5278
+ }
5279
+
5280
+ if (persistedHydrationRequired && !obtainedFreshBaseline) {
5281
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5282
+ if (!(await persist(candidate, captured))) return getSnapshot();
5283
+ workspace = candidate;
5284
+ initialized = true;
5285
+ hydrationRequired = true;
5286
+ notify({type: 'initialized'});
5287
+ return getSnapshot();
5031
5288
  }
5032
5289
 
5033
5290
  // Always persist the normalized durable shape before subscribing. This
5034
5291
  // also makes legacy migration atomic from the engine's point of view.
5292
+ clearPersistedHydrationBarrier(candidate);
5035
5293
  if (!(await persist(candidate, captured))) return getSnapshot();
5036
5294
  workspace = candidate;
5037
5295
  initialized = true;
@@ -5049,14 +5307,7 @@ export const createRecordTimeLabelSyncEngine = ({
5049
5307
  startCloudSubscription(current, workspace);
5050
5308
  return {success: true, tokenRefreshed: true};
5051
5309
  }
5052
- stopCloudSubscription();
5053
- workspace = rtlEmptyDurableWorkspace({
5054
- ownerUid: current.uid,
5055
- workspaceEpoch: current.workspaceEpoch
5056
- });
5057
- initialized = false;
5058
- hydrationRequired = true;
5059
- notify({type: 'session_changed'});
5310
+ resetForSessionIdentity(current);
5060
5311
  return {success: true, sessionChanged: true};
5061
5312
  }).catch((error) => {
5062
5313
  logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
@@ -5388,14 +5639,19 @@ export const createRecordTimeLabelSyncEngine = ({
5388
5639
  nextPending.push(makeRetryOperation(operation, result, null, timestamp));
5389
5640
  } else if (result.status === 'rejected') {
5390
5641
  completedOperations.push({operation, status: result.status});
5391
- nextRejected[operation.id] = {
5642
+ Object.defineProperty(nextRejected, operation.id, {
5643
+ value: {
5392
5644
  id: operation.id,
5393
5645
  operation: clone(operation),
5394
5646
  status: 'rejected',
5395
5647
  reason: result.reason ?? result.error ?? result.code ?? 'rejected',
5396
5648
  rejectedAt: timestamp,
5397
5649
  response: rtlStripSessionTokens(result)
5398
- };
5650
+ },
5651
+ enumerable: true,
5652
+ configurable: true,
5653
+ writable: true
5654
+ });
5399
5655
  }
5400
5656
  }
5401
5657
  const responseState = responseBaseline?.state ?? responseBaseline?.data;
@@ -5930,8 +6186,8 @@ const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payl
5930
6186
  'recordId', 'id'
5931
6187
  ]) ?? operation?.payload?.record?.id ?? null;
5932
6188
  const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5933
- 'folderId', 'id'
5934
- ]) ?? operation?.payload?.folder?.id ?? null;
6189
+ 'targetFolderId', 'folderId', 'id'
6190
+ ]) ?? operation?.payload?.folder?.id ?? operation?.payload?.record?.folderId ?? null;
5935
6191
  const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5936
6192
  'trashEntryId', 'id'
5937
6193
  ]) ?? null;
@@ -5990,6 +6246,19 @@ const rtlPayloadOrder = (operation, keys) => {
5990
6246
  }
5991
6247
  return [];
5992
6248
  };
6249
+ // Partial delete reads omit sibling records, so folder.recordOrder is the live slot.
6250
+ const rtlResolveOriginalRecordIndex = (payload, existingRecord, folders) => {
6251
+ if (Number.isInteger(payload?.originalRecordIndex) && payload.originalRecordIndex >= 0) {
6252
+ return payload.originalRecordIndex;
6253
+ }
6254
+ const recordId = normalizeId(existingRecord?.id);
6255
+ const folderId = safeFolderId(existingRecord?.folderId || payload?.folderId);
6256
+ const recordOrder = rtlMergeOrder(
6257
+ rtlOwn(folders, folderId) ? folders[folderId]?.recordOrder : []
6258
+ );
6259
+ const orderIndex = recordId ? recordOrder.indexOf(recordId) : -1;
6260
+ return orderIndex >= 0 ? orderIndex : 0;
6261
+ };
5993
6262
  const rtlHasInvalidPayloadValue = (value, depth = 0) => {
5994
6263
  if (depth > 20) return true;
5995
6264
  if (typeof value === 'number') return !Number.isFinite(value);
@@ -6002,12 +6271,13 @@ const rtlHasInvalidPayloadValue = (value, depth = 0) => {
6002
6271
  };
6003
6272
 
6004
6273
  const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6005
- const rtlValidateIdList = (value, index, field, errors) => {
6274
+ const rtlValidateIdList = (value, index, field, errors, {opaqueGroup = false} = {}) => {
6006
6275
  if (!Array.isArray(value)) {
6007
6276
  errors.push(`operation_${index}_invalid_${field}`);
6008
6277
  return;
6009
6278
  }
6010
- if (value.some((id) => !rtlIsSafeDocumentId(id))) {
6279
+ const isValid = opaqueGroup ? rtlIsSafeOpaqueGroupId : rtlIsSafeDocumentId;
6280
+ if (value.some((id) => !isValid(id))) {
6011
6281
  errors.push(`operation_${index}_invalid_${field}`);
6012
6282
  }
6013
6283
  };
@@ -6027,8 +6297,13 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
6027
6297
  break;
6028
6298
  case OPERATION_TYPES.RECORD_MOVE:
6029
6299
  if (!recordId) errors.push(`operation_${index}_missing_record_id`);
6030
- if (!normalizeId(payload.targetFolderId || payload.folderId)) {
6031
- errors.push(`operation_${index}_missing_target_folder_id`);
6300
+ {
6301
+ const targetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6302
+ if (targetFolderId === undefined || targetFolderId === null) {
6303
+ errors.push(`operation_${index}_missing_target_folder_id`);
6304
+ } else if (!rtlIsSafeDocumentId(targetFolderId)) {
6305
+ errors.push(`operation_${index}_invalid_target_folder_id`);
6306
+ }
6032
6307
  }
6033
6308
  break;
6034
6309
  case OPERATION_TYPES.RECORD_DELETE:
@@ -6076,14 +6351,21 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
6076
6351
  rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
6077
6352
  break;
6078
6353
  case OPERATION_TYPES.GROUP_REORDER:
6079
- rtlValidateIdList(payload.groupOrder || payload.order, index, 'group_order', errors);
6354
+ rtlValidateIdList(
6355
+ payload.groupOrder || payload.order,
6356
+ index,
6357
+ 'group_order',
6358
+ errors,
6359
+ {opaqueGroup: true}
6360
+ );
6080
6361
  break;
6081
6362
  case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
6082
6363
  rtlValidateIdList(
6083
6364
  payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
6084
6365
  index,
6085
6366
  'expanded_groups',
6086
- errors
6367
+ errors,
6368
+ {opaqueGroup: true}
6087
6369
  );
6088
6370
  break;
6089
6371
  case OPERATION_TYPES.SETTINGS_UPDATE:
@@ -6503,8 +6785,17 @@ export const planFirestoreV2OperationChanges = ({
6503
6785
  reason = recordId ? 'record_not_found' : 'missing_record_id';
6504
6786
  break;
6505
6787
  }
6788
+ const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6789
+ if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
6790
+ reason = 'missing_target_folder_id';
6791
+ break;
6792
+ }
6793
+ if (!rtlIsSafeDocumentId(rawTargetFolderId)) {
6794
+ reason = 'invalid_document_id';
6795
+ break;
6796
+ }
6506
6797
  const sourceFolderId = safeFolderId(existing.folderId);
6507
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
6798
+ const targetFolderId = rawTargetFolderId;
6508
6799
  applied = rtlApplyOperationToPartialDocuments({
6509
6800
  ...nextDocuments,
6510
6801
  operation: {...operation, payload: {...payload, targetFolderId}},
@@ -6599,7 +6890,21 @@ export const planFirestoreV2OperationChanges = ({
6599
6890
  break;
6600
6891
  }
6601
6892
  const sourceFolderId = safeFolderId(existingRecord.folderId);
6602
- applied = rtlApplyOperationToPartialDocuments({...nextDocuments, operation, now: operationNow});
6893
+ applied = rtlApplyOperationToPartialDocuments({
6894
+ ...nextDocuments,
6895
+ operation: {
6896
+ ...operation,
6897
+ payload: {
6898
+ ...payload,
6899
+ originalRecordIndex: rtlResolveOriginalRecordIndex(
6900
+ payload,
6901
+ existingRecord,
6902
+ nextDocuments.folders
6903
+ )
6904
+ }
6905
+ },
6906
+ now: operationNow
6907
+ });
6603
6908
  rtlSetRoot(nextDocuments.root, applied.documents.root);
6604
6909
  const trashDocument = rtlOwn(applied.documents.trash, operationTrashEntryId)
6605
6910
  ? applied.documents.trash[operationTrashEntryId]
package/src/protocol.js CHANGED
@@ -118,6 +118,7 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
118
118
  ['bulk_job_in_progress', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
119
119
  ['bootstrap_session_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
120
120
  ['daily_bootstrap_read_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
121
+ ['global_bootstrap_read_limit_exceeded', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
121
122
  ['rate_limited', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
122
123
  ['too_many_requests', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
123
124
  ['network_error', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TRANSIENT],
@@ -131,6 +132,9 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
131
132
  ['cache_ahead', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
132
133
  ['changed_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
133
134
  ['root_document_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
135
+ ['firestore_v2_root_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
136
+ ['firestore_v2_root_revision_missing', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
137
+ ['bootstrap_missing_root', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED],
134
138
  ['stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
135
139
  ['recordtimelabel_stale_session', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
136
140
  ['auth_context_changed', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.STALE_SESSION],
@@ -160,6 +164,11 @@ const CLOUD_FAILURE_CODE_CLASSES = new Map([
160
164
  ['lifecycle_generation_required', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
161
165
  ['record_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
162
166
  ['folder_not_found', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
167
+ // Snapshot workers consume this same classifier as HTTP clients. These
168
+ // states cannot become valid by retrying the immutable queued job.
169
+ ['snapshot_upload_binding_mismatch', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
170
+ ['snapshot_upload_changed_after_queue', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
171
+ ['invalid_snapshot_job_state', RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL],
163
172
  ...[...OPERATION_CONFLICT_CODES].map((code) => [
164
173
  code,
165
174
  RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL