@recordtimelabel/core 0.4.6 → 0.4.7

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 (3) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/index.js +144 -9
package/README.md CHANGED
@@ -131,8 +131,16 @@ content is quarantined as an ID conflict. Rejected entries are terminal diagnost
131
131
  API; an intentional retry currently requires a new operation ID.
132
132
 
133
133
  The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`,
134
- `syncUntilIdle(reason, options)`, `waitForIdle()`, `getSnapshot()`, `subscribe(listener)`,
135
- and `destroy()`. `sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
134
+ `syncUntilIdle(reason, options)`, `waitForIdle()`, `waitForRemoteReady()`, `getSnapshot()`,
135
+ `subscribe(listener)`, and `destroy()`. A cloud `subscribe(listener, context)` disposer may
136
+ carry a `.ready` promise. After `init()` settles, hosts can await `waitForRemoteReady()` to
137
+ prove that the current subscription's initial root observation and any bounded catch-up have
138
+ settled; this wait rejects when the listener fails before readiness and fails closed across a
139
+ session generation change or destroy. `init()` deliberately does not await this promise because
140
+ the initial listener callback may itself enter the engine queue. Anonymous/no-subscription use
141
+ is immediately ready.
142
+
143
+ `sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
136
144
  subsequent batches up to `RTL_MAX_SYNC_DRAIN_ROUNDS`; it stops on retry/deferred/protocol
137
145
  conditions and reports `syncDrainLimitReached` as a retryable failure instead of silently
138
146
  reporting success. Hosts may provide `beforeRound({round, pendingCount, snapshot})` to block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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,12 +31,13 @@ const REQUIRED_FOLDERS = [
31
31
  { id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
32
32
  ];
33
33
 
34
- export const RECORD_TIMELABEL_CORE_VERSION = '0.4.6';
34
+ export const RECORD_TIMELABEL_CORE_VERSION = '0.4.7';
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',
38
38
  'baseline-refresh-after-rejection',
39
39
  'sync-batch-boundary',
40
+ 'remote-subscription-readiness',
40
41
  RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
41
42
  RECORD_TIMELABEL_CAPABILITY_STRICT_OPERATION_RESULTS,
42
43
  RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
@@ -3157,6 +3158,17 @@ export const createRecordTimeLabelSyncEngine = ({
3157
3158
  let unsubscribeCloud = null;
3158
3159
  let unsubscribeSession = null;
3159
3160
  let subscriptionContext = null;
3161
+ let remoteReadyGeneration = 0;
3162
+ let remoteReadyState = {
3163
+ generation: 0,
3164
+ settled: true,
3165
+ promise: Promise.resolve({
3166
+ success: true,
3167
+ skipped: true,
3168
+ reason: 'not_initialized',
3169
+ generation: 0
3170
+ })
3171
+ };
3160
3172
  let bootstrapAttemptSequence = 0;
3161
3173
  const listeners = new Set();
3162
3174
  let queue = Promise.resolve();
@@ -3501,7 +3513,9 @@ export const createRecordTimeLabelSyncEngine = ({
3501
3513
  return {success: true, ignored: true};
3502
3514
  }
3503
3515
  let baselineValue = remoteValue;
3504
- if (remoteRevision > candidate.remoteBaseline.revision + 1) {
3516
+ const remoteState = remote?.state ?? remote?.data;
3517
+ const revisionOnlyNotification = !rtlDurableIsObject(remoteState);
3518
+ if (remoteRevision > candidate.remoteBaseline.revision + 1 || revisionOnlyNotification) {
3505
3519
  if (typeof cloud?.bootstrap !== 'function') {
3506
3520
  return {success: false, reason: 'revision_gap', bootstrapRequired: true};
3507
3521
  }
@@ -3517,7 +3531,49 @@ export const createRecordTimeLabelSyncEngine = ({
3517
3531
  return getSnapshot();
3518
3532
  };
3519
3533
 
3520
- const stopCloudSubscription = () => {
3534
+ const createRemoteReadyState = () => {
3535
+ const generation = ++remoteReadyGeneration;
3536
+ let resolvePromise;
3537
+ let rejectPromise;
3538
+ const state = {
3539
+ generation,
3540
+ settled: false,
3541
+ promise: new Promise((resolve, reject) => {
3542
+ resolvePromise = resolve;
3543
+ rejectPromise = reject;
3544
+ }),
3545
+ resolve(value = {}) {
3546
+ if (state.settled) return;
3547
+ state.settled = true;
3548
+ resolvePromise({
3549
+ success: true,
3550
+ ...(value && typeof value === 'object' ? value : {}),
3551
+ generation
3552
+ });
3553
+ },
3554
+ reject(error) {
3555
+ if (state.settled) return;
3556
+ state.settled = true;
3557
+ rejectPromise(error);
3558
+ }
3559
+ };
3560
+ // Readiness is an opt-in host boundary. Keep a rejection observable to a
3561
+ // waiter without turning clients that have not adopted it yet into an
3562
+ // unhandled-rejection source.
3563
+ state.promise.catch(() => null);
3564
+ return state;
3565
+ };
3566
+
3567
+ const staleRemoteReady = (state, reason = 'stale_session') => {
3568
+ if (!state || state.settled) return;
3569
+ state.resolve({success: false, stale: true, reason});
3570
+ };
3571
+
3572
+ const isCurrentRemoteReady = (state) => (
3573
+ !destroyed && remoteReadyState === state && remoteReadyState.generation === state.generation
3574
+ );
3575
+
3576
+ const stopCloudSubscription = ({settleReady = true} = {}) => {
3521
3577
  if (typeof unsubscribeCloud === 'function') {
3522
3578
  try { unsubscribeCloud(); } catch (error) {
3523
3579
  logger?.error?.('[RecordTimeLabelCore] durable unsubscribe failed', error);
@@ -3525,20 +3581,87 @@ export const createRecordTimeLabelSyncEngine = ({
3525
3581
  }
3526
3582
  unsubscribeCloud = null;
3527
3583
  subscriptionContext = null;
3584
+ if (settleReady) staleRemoteReady(remoteReadyState);
3528
3585
  };
3529
3586
 
3530
3587
  const startCloudSubscription = (captured, candidate) => {
3531
3588
  stopCloudSubscription();
3532
- if (typeof cloud?.subscribe !== 'function' || !initialized || hydrationRequired) return;
3589
+ const readyState = createRemoteReadyState();
3590
+ remoteReadyState = readyState;
3591
+ if (!initialized || hydrationRequired) {
3592
+ readyState.resolve({success: false, skipped: true, reason: 'hydration_required'});
3593
+ return;
3594
+ }
3595
+ if (!captured?.uid) {
3596
+ readyState.resolve({skipped: true, reason: 'anonymous'});
3597
+ return;
3598
+ }
3599
+ if (typeof cloud?.subscribe !== 'function') {
3600
+ readyState.resolve({skipped: true, reason: 'no_subscription'});
3601
+ return;
3602
+ }
3533
3603
  const context = rtlSessionContext(captured, candidate, client);
3534
3604
  subscriptionContext = context;
3535
- unsubscribeCloud = cloud.subscribe((remoteValue) => {
3536
- if (destroyed) return;
3537
- return enqueue(() => processRemote(remoteValue, captured)).catch((error) => {
3605
+ let subscriptionReturned = false;
3606
+ let adapterReadyExpected = false;
3607
+ let firstCallbackResult = null;
3608
+ const onRemote = (remoteValue) => {
3609
+ if (destroyed || !isCurrentRemoteReady(readyState)) {
3610
+ return Promise.resolve({success: false, stale: true, reason: 'stale_session'});
3611
+ }
3612
+ const callbackResult = enqueue(() => processRemote(remoteValue, captured));
3613
+ if (!firstCallbackResult) firstCallbackResult = callbackResult;
3614
+ callbackResult.then((result) => {
3615
+ if (
3616
+ subscriptionReturned &&
3617
+ !adapterReadyExpected &&
3618
+ isCurrentRemoteReady(readyState)
3619
+ ) {
3620
+ readyState.resolve({
3621
+ revision: workspace.remoteBaseline.revision,
3622
+ result
3623
+ });
3624
+ }
3625
+ }).catch((error) => {
3626
+ if (isCurrentRemoteReady(readyState) && !readyState.settled) {
3627
+ readyState.reject(error);
3628
+ }
3538
3629
  logger?.error?.('[RecordTimeLabelCore] durable remote callback failed', error);
3539
- return {success: false, error};
3540
3630
  });
3541
- }, context);
3631
+ return callbackResult;
3632
+ };
3633
+ let disposer;
3634
+ try {
3635
+ disposer = cloud.subscribe(onRemote, context);
3636
+ } catch (error) {
3637
+ readyState.reject(error);
3638
+ logger?.error?.('[RecordTimeLabelCore] durable subscribe failed', error);
3639
+ return;
3640
+ }
3641
+ unsubscribeCloud = typeof disposer === 'function' ? disposer : null;
3642
+ adapterReadyExpected = Boolean(disposer?.ready && typeof disposer.ready.then === 'function');
3643
+ subscriptionReturned = true;
3644
+ if (adapterReadyExpected) {
3645
+ Promise.resolve(disposer.ready).then((result) => {
3646
+ if (!isCurrentRemoteReady(readyState)) return;
3647
+ readyState.resolve({
3648
+ revision: workspace.remoteBaseline.revision,
3649
+ result
3650
+ });
3651
+ }).catch((error) => {
3652
+ if (isCurrentRemoteReady(readyState) && !readyState.settled) {
3653
+ readyState.reject(error);
3654
+ }
3655
+ });
3656
+ } else if (firstCallbackResult) {
3657
+ firstCallbackResult.then((result) => {
3658
+ if (!isCurrentRemoteReady(readyState)) return;
3659
+ readyState.resolve({
3660
+ revision: workspace.remoteBaseline.revision,
3661
+ result
3662
+ });
3663
+ }).catch(() => null);
3664
+ }
3542
3665
  };
3543
3666
 
3544
3667
  const initialize = async () => {
@@ -4112,6 +4235,18 @@ export const createRecordTimeLabelSyncEngine = ({
4112
4235
  return queue.catch(() => null);
4113
4236
  },
4114
4237
 
4238
+ waitForRemoteReady() {
4239
+ if (destroyed) {
4240
+ return Promise.resolve({
4241
+ success: false,
4242
+ stale: true,
4243
+ reason: 'stale_session',
4244
+ generation: remoteReadyState.generation
4245
+ });
4246
+ }
4247
+ return remoteReadyState.promise;
4248
+ },
4249
+
4115
4250
  getSnapshot,
4116
4251
 
4117
4252
  subscribe(listener) {