@recordtimelabel/core 0.6.4 → 0.6.5

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.5`; 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.5"
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.5",
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.5';
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;
@@ -3288,20 +3288,20 @@ const rtlHasValidRawCommittedWorkspace = (loaded, captured) => {
3288
3288
  !Object.prototype.hasOwnProperty.call(baseline, 'revision') ||
3289
3289
  !Object.prototype.hasOwnProperty.call(baseline, 'changeCursor') ||
3290
3290
  !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
- )) {
3291
+ !rtlRawNonNegativeSafeInteger(baseline.revision)) {
3301
3292
  return false;
3302
3293
  }
3303
3294
  if (loaded.syncMeta !== undefined &&
3304
3295
  !rtlDurableIsObject(loaded.syncMeta)) return false;
3296
+ if (typeof baseline.changeCursor !== 'string' ||
3297
+ !rtlResumeIdentityMatches(
3298
+ loaded.syncMeta?.changeCursorBinding,
3299
+ captured,
3300
+ ownerUid,
3301
+ loaded.workspaceEpoch
3302
+ )) {
3303
+ return false;
3304
+ }
3305
3305
  const resumeMarker = loaded.syncMeta?.resumeMarker ??
3306
3306
  loaded.syncMeta?.resume ??
3307
3307
  loaded.syncMeta?.bootstrapMarker;
@@ -3358,11 +3358,54 @@ const rtlStripSessionTokens = (value) => {
3358
3358
  const result = {};
3359
3359
  Object.entries(value).forEach(([key, entry]) => {
3360
3360
  if (rtlIsCredentialKey(key)) return;
3361
- result[key] = rtlStripSessionTokens(entry);
3361
+ Object.defineProperty(result, key, {
3362
+ value: rtlStripSessionTokens(entry),
3363
+ enumerable: true,
3364
+ configurable: true,
3365
+ writable: true
3366
+ });
3362
3367
  });
3363
3368
  return result;
3364
3369
  };
3365
3370
 
3371
+ // Domain state and operation payloads are opaque to Core. They may legitimately
3372
+ // contain fields such as `designToken` or `session` that are part of the user's
3373
+ // data, so the session-secret scrubber is limited to Core-owned envelopes.
3374
+ const rtlSanitizeDurableOperation = (operation) => {
3375
+ const sanitized = rtlStripSessionTokens(operation);
3376
+ if (rtlDurableIsObject(operation) &&
3377
+ Object.prototype.hasOwnProperty.call(operation, 'payload')) {
3378
+ sanitized.payload = clone(operation.payload);
3379
+ }
3380
+ return sanitized;
3381
+ };
3382
+
3383
+ const rtlSanitizeDurableWorkspace = (workspace) => {
3384
+ const sanitized = rtlStripSessionTokens(workspace);
3385
+ if (!rtlDurableIsObject(workspace)) return sanitized;
3386
+ if (rtlDurableIsObject(workspace.remoteBaseline) &&
3387
+ Object.prototype.hasOwnProperty.call(workspace.remoteBaseline, 'state')) {
3388
+ sanitized.remoteBaseline = {
3389
+ ...sanitized.remoteBaseline,
3390
+ state: clone(workspace.remoteBaseline.state)
3391
+ };
3392
+ }
3393
+ if (Array.isArray(workspace.pendingOperations)) {
3394
+ sanitized.pendingOperations = workspace.pendingOperations.map(rtlSanitizeDurableOperation);
3395
+ }
3396
+ if (rtlDurableIsObject(workspace.rejectedOperations)) {
3397
+ sanitized.rejectedOperations = {...sanitized.rejectedOperations};
3398
+ Object.entries(workspace.rejectedOperations).forEach(([id, entry]) => {
3399
+ if (!rtlDurableIsObject(entry) || !rtlDurableIsObject(entry.operation)) return;
3400
+ sanitized.rejectedOperations[id] = {
3401
+ ...sanitized.rejectedOperations[id],
3402
+ operation: rtlSanitizeDurableOperation(entry.operation)
3403
+ };
3404
+ });
3405
+ }
3406
+ return sanitized;
3407
+ };
3408
+
3366
3409
  const rtlNormalizeOperation = (operation = {}, {
3367
3410
  client,
3368
3411
  clientId,
@@ -3389,7 +3432,7 @@ const rtlNormalizeOperation = (operation = {}, {
3389
3432
  ...rtlStripSessionTokens(input),
3390
3433
  // Preserve supplied IDs byte-for-byte. Validation/quarantine must see an
3391
3434
  // invalid whitespace or hostile ID instead of receiving a silently rewritten one.
3392
- id: Object.prototype.hasOwnProperty.call(input, 'id') ? input.id : generatedId,
3435
+ id: input.id === null || input.id === undefined ? generatedId : input.id,
3393
3436
  type: typeof input.type === 'string' ? input.type.trim() : input.type,
3394
3437
  payload: normalizedPayload,
3395
3438
  clientId: resolvedClientId,
@@ -3441,6 +3484,72 @@ const rtlEmptyDurableWorkspace = ({ownerUid = null, workspaceEpoch = 0} = {}) =>
3441
3484
  syncMeta: {}
3442
3485
  });
3443
3486
 
3487
+ const RTL_BOOTSTRAP_RESUME_META_KEYS = [
3488
+ 'resumeMarker',
3489
+ 'resume',
3490
+ 'bootstrapMarker',
3491
+ 'authSessionBinding',
3492
+ 'changeCursorBinding'
3493
+ ];
3494
+
3495
+ const rtlForceOperationIdentity = (operation, ownerUid, workspaceEpoch, options = {}) => ({
3496
+ ...rtlNormalizePendingOperation(operation, {
3497
+ ...options,
3498
+ ownerUid,
3499
+ workspaceEpoch
3500
+ }),
3501
+ ownerUid: rtlNormalizeUid(ownerUid),
3502
+ workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspaceEpoch, 0)
3503
+ });
3504
+
3505
+ const rtlClearBootstrapResumeMeta = (syncMeta) => {
3506
+ const next = rtlDurableIsObject(syncMeta) ? {...syncMeta} : {};
3507
+ RTL_BOOTSTRAP_RESUME_META_KEYS.forEach((key) => {
3508
+ delete next[key];
3509
+ });
3510
+ return next;
3511
+ };
3512
+
3513
+ const rtlRebindSameOwnerWorkspaceEpoch = (workspace, captured, now) => {
3514
+ const ownerUid = rtlNormalizeUid(captured?.uid);
3515
+ const workspaceEpoch = rtlNormalizeWorkspaceEpoch(captured?.workspaceEpoch, 0);
3516
+ const next = clone(workspace);
3517
+ next.ownerUid = ownerUid;
3518
+ next.workspaceEpoch = workspaceEpoch;
3519
+ next.pendingOperations = toArray(next.pendingOperations).map((operation) => (
3520
+ rtlForceOperationIdentity(operation, ownerUid, workspaceEpoch, {now})
3521
+ ));
3522
+ if (rtlDurableIsObject(next.rejectedOperations)) {
3523
+ const rejected = {};
3524
+ Object.entries(next.rejectedOperations).forEach(([id, entry]) => {
3525
+ const value = rtlDurableIsObject(entry) ? {...entry} : entry;
3526
+ if (rtlDurableIsObject(value) && value.operation) {
3527
+ value.operation = rtlForceOperationIdentity(
3528
+ value.operation,
3529
+ ownerUid,
3530
+ workspaceEpoch,
3531
+ {now}
3532
+ );
3533
+ }
3534
+ Object.defineProperty(rejected, id, {
3535
+ value,
3536
+ enumerable: true,
3537
+ configurable: true,
3538
+ writable: true
3539
+ });
3540
+ });
3541
+ next.rejectedOperations = rejected;
3542
+ }
3543
+ next.remoteBaseline = {
3544
+ ...(rtlDurableIsObject(next.remoteBaseline)
3545
+ ? next.remoteBaseline
3546
+ : rtlNormalizeRemoteBaseline({})),
3547
+ changeCursor: null
3548
+ };
3549
+ next.syncMeta = rtlClearBootstrapResumeMeta(next.syncMeta);
3550
+ return next;
3551
+ };
3552
+
3444
3553
  const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3445
3554
  const source = rtlDurableIsObject(input) ? input : {};
3446
3555
  const legacy = !source.remoteBaseline && (
@@ -3479,7 +3588,12 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3479
3588
  Object.entries(rejectedSource).forEach(([rawId, value]) => {
3480
3589
  const id = normalizeId(rawId || value?.id || value?.operationId);
3481
3590
  if (!id) return;
3482
- rejectedOperations[id] = clone(value && typeof value === 'object' ? value : {reason: value});
3591
+ Object.defineProperty(rejectedOperations, id, {
3592
+ value: clone(value && typeof value === 'object' ? value : {reason: value}),
3593
+ enumerable: true,
3594
+ configurable: true,
3595
+ writable: true
3596
+ });
3483
3597
  if (rejectedOperations[id].operation) {
3484
3598
  rejectedOperations[id].operation = rtlNormalizePendingOperation(
3485
3599
  rejectedOperations[id].operation,
@@ -3506,11 +3620,13 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
3506
3620
  const rtlOperationIdentityReason = (operation, workspace) => {
3507
3621
  const candidateIds = [
3508
3622
  operation?.id,
3623
+ operation?.payload?.id,
3509
3624
  operation?.payload?.recordId,
3510
3625
  operation?.payload?.folderId,
3511
3626
  operation?.payload?.targetFolderId,
3512
3627
  operation?.payload?.trashEntryId,
3513
3628
  operation?.payload?.record?.id,
3629
+ operation?.payload?.record?.folderId,
3514
3630
  operation?.payload?.folder?.id,
3515
3631
  ...(Array.isArray(operation?.payload?.recordIds) ? operation.payload.recordIds : []),
3516
3632
  ...(Array.isArray(operation?.payload?.folderOrder) ? operation.payload.folderOrder : [])
@@ -3651,7 +3767,8 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
3651
3767
  }
3652
3768
  rejectedCount += 1;
3653
3769
  const rejectedId = id || `identity-rejected:${timestamp}:${index}`;
3654
- rejected[rejectedId] = {
3770
+ Object.defineProperty(rejected, rejectedId, {
3771
+ value: {
3655
3772
  id: rejectedId,
3656
3773
  operation: clone(operation),
3657
3774
  status: 'rejected',
@@ -3666,7 +3783,11 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
3666
3783
  ),
3667
3784
  workspaceEpoch: rtlNormalizeWorkspaceEpoch(workspace?.workspaceEpoch, 0)
3668
3785
  }
3669
- };
3786
+ },
3787
+ enumerable: true,
3788
+ configurable: true,
3789
+ writable: true
3790
+ });
3670
3791
  });
3671
3792
  return {accepted, rejected, rejectedCount, deduplicatedCount};
3672
3793
  };
@@ -3741,8 +3862,6 @@ const rtlEnvelopePayload = (value) => {
3741
3862
  return candidate;
3742
3863
  };
3743
3864
  if (value && typeof value === 'object') {
3744
- if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
3745
- if (value.baseline) return normalizeRevisionAlias(value.baseline);
3746
3865
  // A canonical failure may carry an authoritative baseline for an
3747
3866
  // operation-id conflict. It must win over compatibility state fields
3748
3867
  // left beside `failure`; otherwise a stale outer state can be applied
@@ -3751,6 +3870,8 @@ const rtlEnvelopePayload = (value) => {
3751
3870
  const nested = rtlEnvelopePayload(value.failure);
3752
3871
  if (nested) return nested;
3753
3872
  }
3873
+ if (value.remoteBaseline) return normalizeRevisionAlias(value.remoteBaseline);
3874
+ if (value.baseline) return normalizeRevisionAlias(value.baseline);
3754
3875
  if (
3755
3876
  value.state !== undefined ||
3756
3877
  value.data !== undefined ||
@@ -3980,17 +4101,20 @@ export const createRecordTimeLabelSyncEngine = ({
3980
4101
  const persist = async (candidate, captured) => {
3981
4102
  if (!(await isCurrent(captured))) return false;
3982
4103
  const cursor = candidate?.remoteBaseline?.changeCursor;
4104
+ candidate.syncMeta = rtlDurableIsObject(candidate.syncMeta)
4105
+ ? {...candidate.syncMeta}
4106
+ : {};
3983
4107
  if (cursor !== null && cursor !== undefined) {
3984
- const durableCursor = rtlDurableIsObject(cursor) ? clone(cursor) : {value: String(cursor)};
3985
- candidate.remoteBaseline.changeCursor = {
3986
- ...durableCursor,
4108
+ candidate.syncMeta.changeCursorBinding = {
3987
4109
  ownerUid: captured?.uid ?? null,
3988
4110
  uid: captured?.uid ?? null,
3989
4111
  workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch,
3990
4112
  authSessionBinding: captured?.authSessionBinding ?? null
3991
4113
  };
4114
+ } else {
4115
+ delete candidate.syncMeta.changeCursorBinding;
3992
4116
  }
3993
- const durableCandidate = rtlStripSessionTokens(candidate);
4117
+ const durableCandidate = rtlSanitizeDurableWorkspace(candidate);
3994
4118
  // The second argument is an optional adapter-side fence. The public
3995
4119
  // StoragePort remains compatible with save(workspace); adapters that can
3996
4120
  // enforce an atomic session/epoch check should consume this context before
@@ -4284,7 +4408,17 @@ export const createRecordTimeLabelSyncEngine = ({
4284
4408
  const epochMismatch = Boolean(
4285
4409
  captured?.hasEpoch && Number(loadedWorkspace.workspaceEpoch) !== Number(currentEpoch)
4286
4410
  );
4287
- if (ownerMismatch || epochMismatch) {
4411
+ if (ownerMismatch) {
4412
+ return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
4413
+ }
4414
+ if (epochMismatch) {
4415
+ if (
4416
+ sourceOwnerUid &&
4417
+ sourceOwnerUid === currentUid &&
4418
+ Number(currentEpoch) > Number(loadedWorkspace.workspaceEpoch)
4419
+ ) {
4420
+ return rtlRebindSameOwnerWorkspaceEpoch(loadedWorkspace, captured, now);
4421
+ }
4288
4422
  return rtlEmptyDurableWorkspace({ownerUid: currentUid, workspaceEpoch: currentEpoch});
4289
4423
  }
4290
4424
  const currentView = Array.isArray(loadedWorkspace.syncMeta?.migratedExpandedGroups)
@@ -4358,6 +4492,30 @@ export const createRecordTimeLabelSyncEngine = ({
4358
4492
  ...extra
4359
4493
  });
4360
4494
 
4495
+ const persistHydrationBarrier = async (captured, extraMeta = {}) => {
4496
+ hydrationRequired = true;
4497
+ const candidate = clone(workspace);
4498
+ candidate.syncMeta = {
4499
+ ...candidate.syncMeta,
4500
+ hydrationRequired: true,
4501
+ ...extraMeta
4502
+ };
4503
+ if (!(await persist(candidate, captured))) return false;
4504
+ workspace = candidate;
4505
+ return true;
4506
+ };
4507
+
4508
+ const clearPersistedHydrationBarrier = (candidate) => {
4509
+ if (!rtlDurableIsObject(candidate?.syncMeta) ||
4510
+ !Object.prototype.hasOwnProperty.call(candidate.syncMeta, 'hydrationRequired')) {
4511
+ return candidate;
4512
+ }
4513
+ const nextMeta = {...candidate.syncMeta};
4514
+ delete nextMeta.hydrationRequired;
4515
+ candidate.syncMeta = nextMeta;
4516
+ return candidate;
4517
+ };
4518
+
4361
4519
  const persistTerminalFailureBlock = async ({
4362
4520
  failure,
4363
4521
  error,
@@ -4393,6 +4551,13 @@ export const createRecordTimeLabelSyncEngine = ({
4393
4551
  identityRejectedCount
4394
4552
  }) => {
4395
4553
  hydrationRequired = true;
4554
+ if (!(await persistHydrationBarrier(captured, {
4555
+ lastSyncAttemptAt: timestamp,
4556
+ lastSyncFailure: clone(failure),
4557
+ lastSyncError: failure.message || failure.code || 'bootstrap-required'
4558
+ }))) {
4559
+ return {success: false, reason: 'stale_session'};
4560
+ }
4396
4561
  if (typeof cloud?.bootstrap !== 'function') {
4397
4562
  return failureOutcome(failure, error, {
4398
4563
  bootstrapRequired: true,
@@ -4431,6 +4596,7 @@ export const createRecordTimeLabelSyncEngine = ({
4431
4596
  candidate.syncMeta.lastSyncFailure = clone(failure);
4432
4597
  candidate.syncMeta.lastSyncAttemptAt = timestamp;
4433
4598
  candidate.syncMeta.lastSyncError = null;
4599
+ clearPersistedHydrationBarrier(candidate);
4434
4600
  if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
4435
4601
  workspace = candidate;
4436
4602
  hydrationRequired = false;
@@ -4614,14 +4780,19 @@ export const createRecordTimeLabelSyncEngine = ({
4614
4780
  const completedOperations = [];
4615
4781
  ready.forEach((operation) => {
4616
4782
  const reason = failure.reason || failure.code || 'operation_id_conflict';
4617
- nextRejected[operation.id] = {
4783
+ Object.defineProperty(nextRejected, operation.id, {
4784
+ value: {
4618
4785
  id: operation.id,
4619
4786
  operation: clone(operation),
4620
4787
  status: 'rejected',
4621
4788
  reason,
4622
4789
  rejectedAt: timestamp,
4623
4790
  response: rejectionResponse
4624
- };
4791
+ },
4792
+ enumerable: true,
4793
+ configurable: true,
4794
+ writable: true
4795
+ });
4625
4796
  rejectedResults.push({
4626
4797
  id: operation.id,
4627
4798
  operationId: operation.id,
@@ -4711,7 +4882,7 @@ export const createRecordTimeLabelSyncEngine = ({
4711
4882
  const remoteState = remote?.state ?? remote?.data;
4712
4883
  const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
4713
4884
  if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
4714
- if (typeof cloud?.bootstrap !== 'function') {
4885
+ if (typeof cloud?.catchUp !== 'function' && typeof cloud?.bootstrap !== 'function') {
4715
4886
  const failure = normalizeRecordTimeLabelCloudFailure({
4716
4887
  class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.BOOTSTRAP_REQUIRED,
4717
4888
  code: 'revision_gap',
@@ -4821,10 +4992,17 @@ export const createRecordTimeLabelSyncEngine = ({
4821
4992
 
4822
4993
  const resetForSessionIdentity = (captured) => {
4823
4994
  stopCloudSubscription();
4824
- workspace = rtlEmptyDurableWorkspace({
4825
- ownerUid: captured?.uid,
4826
- workspaceEpoch: captured?.workspaceEpoch
4827
- });
4995
+ const sameOwner = Boolean(
4996
+ rtlNormalizeUid(workspace.ownerUid) &&
4997
+ rtlNormalizeUid(workspace.ownerUid) === rtlNormalizeUid(captured?.uid)
4998
+ );
4999
+ const epochIncreased = Number(captured?.workspaceEpoch) > Number(workspace.workspaceEpoch);
5000
+ workspace = sameOwner && epochIncreased
5001
+ ? rtlRebindSameOwnerWorkspaceEpoch(workspace, captured, now)
5002
+ : rtlEmptyDurableWorkspace({
5003
+ ownerUid: captured?.uid,
5004
+ workspaceEpoch: captured?.workspaceEpoch
5005
+ });
4828
5006
  initialized = false;
4829
5007
  hydrationRequired = true;
4830
5008
  notify({type: 'session_changed'});
@@ -4857,6 +5035,7 @@ export const createRecordTimeLabelSyncEngine = ({
4857
5035
  let adapterReadyExpected = false;
4858
5036
  let firstCallbackResult = null;
4859
5037
  let firstRootObservation = null;
5038
+ let latestRootObservationRevision = null;
4860
5039
  let adapterCatchUpProof = null;
4861
5040
  const settleReadyIfProven = () => {
4862
5041
  if (!isCurrentRemoteReady(readyState) || readyState.settled ||
@@ -4873,6 +5052,31 @@ export const createRecordTimeLabelSyncEngine = ({
4873
5052
  const caughtUpToRevision = Number.isSafeInteger(Number(proof.caughtUpToRevision))
4874
5053
  ? Number(proof.caughtUpToRevision)
4875
5054
  : revision;
5055
+ if (revision < 0 || caughtUpToRevision < 0) {
5056
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5057
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5058
+ code: 'invalid_remote_ready_revision',
5059
+ reason: 'invalid_remote_ready_revision',
5060
+ message: 'invalid_remote_ready_revision'
5061
+ }));
5062
+ return;
5063
+ }
5064
+ const observedRevision = Number.isSafeInteger(latestRootObservationRevision)
5065
+ ? latestRootObservationRevision
5066
+ : baselineRevision;
5067
+ const requiredRevision = Math.max(baselineRevision, observedRevision);
5068
+ if (revision < requiredRevision || caughtUpToRevision < requiredRevision) {
5069
+ readyState.reject(toRecordTimeLabelCloudFailureError({
5070
+ class: RECORD_TIMELABEL_CLOUD_FAILURE_CLASSES.TERMINAL,
5071
+ code: 'stale_remote_ready_revision',
5072
+ reason: 'stale_remote_ready_revision',
5073
+ message: 'stale_remote_ready_revision'
5074
+ }));
5075
+ return;
5076
+ }
5077
+ // A proof for a revision the root listener has not processed yet must
5078
+ // remain behind the barrier. A later root observation retries settling.
5079
+ if (revision > requiredRevision || caughtUpToRevision > requiredRevision) return;
4876
5080
  readyState.resolve({
4877
5081
  sessionKey: proof.sessionKey ??
4878
5082
  context.sessionKey ??
@@ -4883,7 +5087,7 @@ export const createRecordTimeLabelSyncEngine = ({
4883
5087
  caughtUpToRevision
4884
5088
  });
4885
5089
  };
4886
- const handleRootObservation = async (result) => {
5090
+ const handleRootObservation = async (result, observedRevision) => {
4887
5091
  if (!(await isCurrent(captured)) || !isCurrentRemoteReady(readyState)) return;
4888
5092
  if (result?.stale === true || result?.reason === 'stale_session') return;
4889
5093
  if (result?.success === false) {
@@ -4897,6 +5101,12 @@ export const createRecordTimeLabelSyncEngine = ({
4897
5101
  return;
4898
5102
  }
4899
5103
  firstRootObservation = result || {success: true};
5104
+ if (Number.isSafeInteger(observedRevision) && observedRevision >= 0) {
5105
+ latestRootObservationRevision = Math.max(
5106
+ latestRootObservationRevision ?? 0,
5107
+ observedRevision
5108
+ );
5109
+ }
4900
5110
  if (!adapterReadyExpected) {
4901
5111
  adapterCatchUpProof = {
4902
5112
  success: true,
@@ -4911,9 +5121,13 @@ export const createRecordTimeLabelSyncEngine = ({
4911
5121
  if (destroyed || !isCurrentRemoteReady(readyState)) {
4912
5122
  return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
4913
5123
  }
5124
+ const observedRevisionValue = Number(rtlEnvelopePayload(remoteValue)?.revision);
5125
+ const observedRevision = Number.isSafeInteger(observedRevisionValue) && observedRevisionValue >= 0
5126
+ ? observedRevisionValue
5127
+ : null;
4914
5128
  const callbackResult = enqueue(() => processRemote(remoteValue, captured));
4915
5129
  if (!firstCallbackResult) firstCallbackResult = callbackResult;
4916
- callbackResult.then((result) => handleRootObservation(result)).catch((error) => {
5130
+ callbackResult.then((result) => handleRootObservation(result, observedRevision)).catch((error) => {
4917
5131
  if (isCurrentRemoteReady(readyState) && !readyState.settled) {
4918
5132
  readyState.reject(error);
4919
5133
  }
@@ -4931,7 +5145,7 @@ export const createRecordTimeLabelSyncEngine = ({
4931
5145
  }
4932
5146
  unsubscribeCloud = typeof disposer === 'function'
4933
5147
  ? disposer
4934
- : (typeof disposer?.unsubscribe === 'function' ? disposer.unsubscribe : null);
5148
+ : (typeof disposer?.unsubscribe === 'function' ? () => disposer.unsubscribe() : null);
4935
5149
  const onSubscriptionError = (error) => {
4936
5150
  if (!isCurrentRemoteReady(readyState)) return;
4937
5151
  stopCloudSubscription({settleReady: false});
@@ -4991,6 +5205,7 @@ export const createRecordTimeLabelSyncEngine = ({
4991
5205
  const loadedBaseline = loaded?.remoteBaseline;
4992
5206
  const committedRevision = Number(loadedBaseline?.revision);
4993
5207
  const hasValidCommittedBaseline = rtlHasValidRawCommittedWorkspace(loaded, captured);
5208
+ const persistedHydrationRequired = loaded?.syncMeta?.hydrationRequired === true;
4994
5209
  let candidate = normalizeLoadedWorkspace(loaded, captured);
4995
5210
  const identityChecked = rtlQuarantineOperations(
4996
5211
  candidate,
@@ -5002,17 +5217,20 @@ export const createRecordTimeLabelSyncEngine = ({
5002
5217
  const context = rtlSessionContext(captured, candidate, client);
5003
5218
 
5004
5219
  const canReuseCommitted = initialBaseline === 'reuse-committed' &&
5220
+ !persistedHydrationRequired &&
5005
5221
  captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
5006
5222
  hasValidCommittedBaseline &&
5007
5223
  candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
5008
5224
  candidate.remoteBaseline.revision === committedRevision &&
5009
5225
  (!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
5226
+ let obtainedFreshBaseline = canReuseCommitted;
5010
5227
  if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
5011
5228
  // Do not carry untrusted resume state into a fresh hydration attempt.
5012
5229
  // Only the bootstrap response may establish the next cursor/marker.
5013
5230
  candidate.remoteBaseline.changeCursor = null;
5014
5231
  candidate.syncMeta = {...candidate.syncMeta};
5015
- ['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding'].forEach((key) => {
5232
+ ['resumeMarker', 'resume', 'bootstrapMarker', 'authSessionBinding', 'changeCursorBinding']
5233
+ .forEach((key) => {
5016
5234
  delete candidate.syncMeta[key];
5017
5235
  });
5018
5236
  let bootstrap;
@@ -5023,15 +5241,41 @@ export const createRecordTimeLabelSyncEngine = ({
5023
5241
  ));
5024
5242
  } catch (error) {
5025
5243
  if (!(await isCurrent(captured))) return getSnapshot();
5244
+ if (persistedHydrationRequired) {
5245
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5246
+ await persist(candidate, captured);
5247
+ }
5026
5248
  rethrowClassifiedCloudFailure(captured, error);
5027
5249
  }
5028
5250
  if (!(await isCurrent(captured))) return getSnapshot();
5029
- applyRemoteBaseline(candidate, bootstrap);
5251
+ const applied = applyRemoteBaseline(candidate, bootstrap);
5252
+ if (applied.stale) {
5253
+ candidate.syncMeta = {...candidate.syncMeta, hydrationRequired: true};
5254
+ if (!(await persist(candidate, captured))) return getSnapshot();
5255
+ workspace = candidate;
5256
+ initialized = true;
5257
+ hydrationRequired = true;
5258
+ notify({type: 'initialized'});
5259
+ return getSnapshot();
5260
+ }
5030
5261
  delete candidate.syncMeta.terminalFailureBlock;
5262
+ clearPersistedHydrationBarrier(candidate);
5263
+ obtainedFreshBaseline = true;
5264
+ }
5265
+
5266
+ if (persistedHydrationRequired && !obtainedFreshBaseline) {
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();
5031
5274
  }
5032
5275
 
5033
5276
  // Always persist the normalized durable shape before subscribing. This
5034
5277
  // also makes legacy migration atomic from the engine's point of view.
5278
+ clearPersistedHydrationBarrier(candidate);
5035
5279
  if (!(await persist(candidate, captured))) return getSnapshot();
5036
5280
  workspace = candidate;
5037
5281
  initialized = true;
@@ -5049,14 +5293,7 @@ export const createRecordTimeLabelSyncEngine = ({
5049
5293
  startCloudSubscription(current, workspace);
5050
5294
  return {success: true, tokenRefreshed: true};
5051
5295
  }
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'});
5296
+ resetForSessionIdentity(current);
5060
5297
  return {success: true, sessionChanged: true};
5061
5298
  }).catch((error) => {
5062
5299
  logger?.error?.('[RecordTimeLabelCore] durable session callback failed', error);
@@ -5388,14 +5625,19 @@ export const createRecordTimeLabelSyncEngine = ({
5388
5625
  nextPending.push(makeRetryOperation(operation, result, null, timestamp));
5389
5626
  } else if (result.status === 'rejected') {
5390
5627
  completedOperations.push({operation, status: result.status});
5391
- nextRejected[operation.id] = {
5628
+ Object.defineProperty(nextRejected, operation.id, {
5629
+ value: {
5392
5630
  id: operation.id,
5393
5631
  operation: clone(operation),
5394
5632
  status: 'rejected',
5395
5633
  reason: result.reason ?? result.error ?? result.code ?? 'rejected',
5396
5634
  rejectedAt: timestamp,
5397
5635
  response: rtlStripSessionTokens(result)
5398
- };
5636
+ },
5637
+ enumerable: true,
5638
+ configurable: true,
5639
+ writable: true
5640
+ });
5399
5641
  }
5400
5642
  }
5401
5643
  const responseState = responseBaseline?.state ?? responseBaseline?.data;
@@ -5930,8 +6172,8 @@ const rtlOperationRecordId = (operation = {}) => rtlFirstPresent(operation?.payl
5930
6172
  'recordId', 'id'
5931
6173
  ]) ?? operation?.payload?.record?.id ?? null;
5932
6174
  const rtlOperationFolderId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5933
- 'folderId', 'id'
5934
- ]) ?? operation?.payload?.folder?.id ?? null;
6175
+ 'targetFolderId', 'folderId', 'id'
6176
+ ]) ?? operation?.payload?.folder?.id ?? operation?.payload?.record?.folderId ?? null;
5935
6177
  const rtlOperationTrashEntryId = (operation = {}) => rtlFirstPresent(operation?.payload, [
5936
6178
  'trashEntryId', 'id'
5937
6179
  ]) ?? null;
@@ -6002,12 +6244,13 @@ const rtlHasInvalidPayloadValue = (value, depth = 0) => {
6002
6244
  };
6003
6245
 
6004
6246
  const rtlIsObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6005
- const rtlValidateIdList = (value, index, field, errors) => {
6247
+ const rtlValidateIdList = (value, index, field, errors, {opaqueGroup = false} = {}) => {
6006
6248
  if (!Array.isArray(value)) {
6007
6249
  errors.push(`operation_${index}_invalid_${field}`);
6008
6250
  return;
6009
6251
  }
6010
- if (value.some((id) => !rtlIsSafeDocumentId(id))) {
6252
+ const isValid = opaqueGroup ? rtlIsSafeOpaqueGroupId : rtlIsSafeDocumentId;
6253
+ if (value.some((id) => !isValid(id))) {
6011
6254
  errors.push(`operation_${index}_invalid_${field}`);
6012
6255
  }
6013
6256
  };
@@ -6027,8 +6270,13 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
6027
6270
  break;
6028
6271
  case OPERATION_TYPES.RECORD_MOVE:
6029
6272
  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`);
6273
+ {
6274
+ const targetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6275
+ if (targetFolderId === undefined || targetFolderId === null) {
6276
+ errors.push(`operation_${index}_missing_target_folder_id`);
6277
+ } else if (!rtlIsSafeDocumentId(targetFolderId)) {
6278
+ errors.push(`operation_${index}_invalid_target_folder_id`);
6279
+ }
6032
6280
  }
6033
6281
  break;
6034
6282
  case OPERATION_TYPES.RECORD_DELETE:
@@ -6076,14 +6324,21 @@ const rtlValidateOperationPayload = (operation, index, errors, options = {}) =>
6076
6324
  rtlValidateIdList(payload.folderOrder || payload.order, index, 'folder_order', errors);
6077
6325
  break;
6078
6326
  case OPERATION_TYPES.GROUP_REORDER:
6079
- rtlValidateIdList(payload.groupOrder || payload.order, index, 'group_order', errors);
6327
+ rtlValidateIdList(
6328
+ payload.groupOrder || payload.order,
6329
+ index,
6330
+ 'group_order',
6331
+ errors,
6332
+ {opaqueGroup: true}
6333
+ );
6080
6334
  break;
6081
6335
  case OPERATION_TYPES.EXPANDED_GROUPS_UPDATE:
6082
6336
  rtlValidateIdList(
6083
6337
  payload.expandedGroups || payload.groupIds || payload.order || payload.ids,
6084
6338
  index,
6085
6339
  'expanded_groups',
6086
- errors
6340
+ errors,
6341
+ {opaqueGroup: true}
6087
6342
  );
6088
6343
  break;
6089
6344
  case OPERATION_TYPES.SETTINGS_UPDATE:
@@ -6503,8 +6758,17 @@ export const planFirestoreV2OperationChanges = ({
6503
6758
  reason = recordId ? 'record_not_found' : 'missing_record_id';
6504
6759
  break;
6505
6760
  }
6761
+ const rawTargetFolderId = rtlFirstPresent(payload, ['targetFolderId', 'folderId']);
6762
+ if (rawTargetFolderId === undefined || rawTargetFolderId === null) {
6763
+ reason = 'missing_target_folder_id';
6764
+ break;
6765
+ }
6766
+ if (!rtlIsSafeDocumentId(rawTargetFolderId)) {
6767
+ reason = 'invalid_document_id';
6768
+ break;
6769
+ }
6506
6770
  const sourceFolderId = safeFolderId(existing.folderId);
6507
- const targetFolderId = safeFolderId(payload.targetFolderId || payload.folderId);
6771
+ const targetFolderId = rawTargetFolderId;
6508
6772
  applied = rtlApplyOperationToPartialDocuments({
6509
6773
  ...nextDocuments,
6510
6774
  operation: {...operation, payload: {...payload, targetFolderId}},
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