@recordtimelabel/core 0.4.4 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +49 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.4",
3
+ "version": "0.4.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
@@ -31,7 +31,7 @@ const REQUIRED_FOLDERS = [
31
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
32
32
  ];
33
33
 
34
- export const RECORD_TIMELABEL_CORE_VERSION = '0.4.4';
34
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.4.5';
35
35
  export const RTL_SYNC_PROTOCOL_VERSION = 2;
36
36
  export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
37
37
  'fifo-retry-fence',
@@ -3134,11 +3134,15 @@ export const createRecordTimeLabelSyncEngine = ({
3134
3134
  session,
3135
3135
  client = 'recordtimelabel-client',
3136
3136
  clock = () => Date.now(),
3137
- logger = console
3137
+ logger = console,
3138
+ initialBaseline = 'require-fresh'
3138
3139
  } = {}) => {
3139
3140
  if (!storage?.load || !storage?.save) {
3140
3141
  throw new Error('createRecordTimeLabelSyncEngine requires storage.load() and storage.save()');
3141
3142
  }
3143
+ if (initialBaseline !== 'require-fresh' && initialBaseline !== 'reuse-committed') {
3144
+ throw new Error('recordtimelabel_invalid_initial_baseline_policy');
3145
+ }
3142
3146
 
3143
3147
  let workspace = rtlEmptyDurableWorkspace();
3144
3148
  let initialized = false;
@@ -3277,6 +3281,37 @@ export const createRecordTimeLabelSyncEngine = ({
3277
3281
  return baseline;
3278
3282
  };
3279
3283
 
3284
+ // Optional bounded catch-up port. `cloud.catchUp(context, {fromRevision,
3285
+ // targetRevision, attemptId, mode})` resolves a FULL baseline object
3286
+ // (`{success: true, state, revision, changeCursor?}`, the same shape
3287
+ // normalizeBootstrapResponse accepts) or `{success: false,
3288
+ // bootstrapRequired: true}`; it may throw on transport errors. Gap
3289
+ // recovery prefers it over `cloud.bootstrap` and falls back to a fresh
3290
+ // bootstrap walk whenever it fails or is unavailable.
3291
+ const recoverBaseline = async (context, targetRevision, mode) => {
3292
+ if (typeof cloud?.catchUp === 'function') {
3293
+ try {
3294
+ const caught = await cloud.catchUp(context, {
3295
+ fromRevision: workspace.remoteBaseline.revision,
3296
+ targetRevision,
3297
+ ...bootstrapAttemptOptions(`${mode}-catchup`)
3298
+ });
3299
+ if (rtlEnvelopeSuccess(caught)) {
3300
+ return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
3301
+ }
3302
+ } catch (error) {
3303
+ logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
3304
+ }
3305
+ }
3306
+ return requireBootstrapRevision(
3307
+ normalizeBootstrapResponse(await cloud.bootstrap(
3308
+ context,
3309
+ bootstrapAttemptOptions(mode)
3310
+ )),
3311
+ targetRevision
3312
+ );
3313
+ };
3314
+
3280
3315
  const normalizeLoadedWorkspace = (loaded, captured) => {
3281
3316
  const source = loaded && typeof loaded === 'object' ? loaded : {};
3282
3317
  const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
@@ -3460,13 +3495,7 @@ export const createRecordTimeLabelSyncEngine = ({
3460
3495
  return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3461
3496
  }
3462
3497
  const context = rtlSessionContext(captured, candidate, client);
3463
- baselineValue = requireBootstrapRevision(
3464
- normalizeBootstrapResponse(await cloud.bootstrap(
3465
- context,
3466
- bootstrapAttemptOptions('gap-recovery')
3467
- )),
3468
- remoteRevision
3469
- );
3498
+ baselineValue = await recoverBaseline(context, remoteRevision, 'gap-recovery');
3470
3499
  if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
3471
3500
  }
3472
3501
  const applied = applyRemoteBaseline(candidate, baselineValue);
@@ -3505,6 +3534,12 @@ export const createRecordTimeLabelSyncEngine = ({
3505
3534
  const captured = capture();
3506
3535
  const loaded = await storage.load();
3507
3536
  if (!(await isCurrent(captured))) return getSnapshot();
3537
+ // The reuse predicate must read the RAW loaded owner before
3538
+ // normalizeLoadedWorkspace adopts the current session identity onto the
3539
+ // workspace; epochs are intentionally not compared (consumers may advance
3540
+ // the persisted epoch on every launch).
3541
+ const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
3542
+ const committedRevision = Number(loaded?.remoteBaseline?.revision || 0);
3508
3543
  let candidate = normalizeLoadedWorkspace(loaded, captured);
3509
3544
  const identityChecked = rtlQuarantineOperations(
3510
3545
  candidate,
@@ -3515,7 +3550,10 @@ export const createRecordTimeLabelSyncEngine = ({
3515
3550
  candidate.rejectedOperations = identityChecked.rejected;
3516
3551
  const context = rtlSessionContext(captured, candidate, client);
3517
3552
 
3518
- if (captured?.uid && typeof cloud?.bootstrap === 'function') {
3553
+ const canReuseCommitted = initialBaseline === 'reuse-committed' &&
3554
+ captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
3555
+ committedRevision > 0;
3556
+ if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
3519
3557
  let bootstrap;
3520
3558
  try {
3521
3559
  bootstrap = normalizeBootstrapResponse(await cloud.bootstrap(
@@ -3836,13 +3874,7 @@ export const createRecordTimeLabelSyncEngine = ({
3836
3874
  responseRevision > candidate.remoteBaseline.revision + 1 &&
3837
3875
  typeof cloud?.bootstrap === 'function'
3838
3876
  ) {
3839
- const freshBaseline = requireBootstrapRevision(
3840
- normalizeBootstrapResponse(await cloud.bootstrap(
3841
- context,
3842
- bootstrapAttemptOptions('gap-recovery')
3843
- )),
3844
- responseRevision
3845
- );
3877
+ const freshBaseline = await recoverBaseline(context, responseRevision, 'gap-recovery');
3846
3878
  if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
3847
3879
  applyRemoteBaseline(candidate, freshBaseline);
3848
3880
  }