@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.
- package/README.md +10 -2
- package/package.json +1 -1
- 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()`, `
|
|
135
|
-
and `destroy()`.
|
|
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
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
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
|
-
|
|
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) {
|