@recordtimelabel/core 0.4.5 → 0.4.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 +22 -9
- package/package.json +1 -1
- package/src/index.js +180 -12
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 current strict durable transport contract is prepared in package version `0.4.
|
|
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 current strict durable transport contract is prepared in package version `0.4.6` (publish it before updating consumers):
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.4.
|
|
23
|
+
"@recordtimelabel/core": "0.4.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.
|
|
@@ -33,6 +33,8 @@ If this checkout's `package.json` is ahead of the published version, publish the
|
|
|
33
33
|
- `applyOperation(state, operation)`
|
|
34
34
|
- `applyRecordTimeLabelOperation(state, operation)`
|
|
35
35
|
- `createRecordTimeLabelSyncEngine({ storage, cloud, session, client, clock, logger })`
|
|
36
|
+
- `RTL_MAX_SYNC_DRAIN_ROUNDS`
|
|
37
|
+
- `RTL_SYNC_DRAIN_RETRY_DELAY_MS`
|
|
36
38
|
- `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
|
|
37
39
|
- `RECORD_TIMELABEL_PROTOCOL_CAPABILITIES`
|
|
38
40
|
- `toRecordTimeLabelWireOperation(operation)`
|
|
@@ -121,13 +123,24 @@ modes with a unique `attemptId`; all require a fresh logical attempt. Structured
|
|
|
121
123
|
failures, missing state, and missing/invalid revisions fail closed. UID or workspace
|
|
122
124
|
epoch changes clear the visible workspace and require `init()` before another dispatch
|
|
123
125
|
or sync; token refresh for the same UID/epoch only refreshes the subscription fence. An
|
|
124
|
-
operation acknowledgement records its revision diagnostically
|
|
125
|
-
`remoteBaseline.revision`
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
operation acknowledgement records its revision diagnostically and advances
|
|
127
|
+
`remoteBaseline.revision` with a complete state, or with an all-applied no-state ACK at
|
|
128
|
+
exactly the next revision; larger revision gaps trigger a fresh bootstrap. Re-dispatching
|
|
129
|
+
the same operation ID and wire content is reported as deduplicated, while different wire
|
|
130
|
+
content is quarantined as an ID conflict. Rejected entries are terminal diagnostics in this
|
|
131
|
+
API; an intentional retry currently requires a new operation ID.
|
|
132
|
+
|
|
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
|
|
136
|
+
subsequent batches up to `RTL_MAX_SYNC_DRAIN_ROUNDS`; it stops on retry/deferred/protocol
|
|
137
|
+
conditions and reports `syncDrainLimitReached` as a retryable failure instead of silently
|
|
138
|
+
reporting success. Hosts may provide `beforeRound({round, pendingCount, snapshot})` to block
|
|
139
|
+
gateway writes while a host-specific hydration/import gate is active, and `runRound({round,
|
|
140
|
+
pendingCount, run})` to serialize that gate check and the gateway call with host mutations.
|
|
141
|
+
`getSnapshot().state` is derived by replaying pending operations over `remoteBaseline.state`;
|
|
142
|
+
rejected operations are kept in diagnostics and are not replayed. Session tokens are used for
|
|
143
|
+
fencing but are never persisted. The legacy
|
|
131
144
|
`createSyncEngine` and app adapters remain available and are not implicitly migrated by this API.
|
|
132
145
|
|
|
133
146
|
`expandedGroups` is a local view projection, not durable cloud domain state. Durable workspaces,
|
package/package.json
CHANGED
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.
|
|
34
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.6';
|
|
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',
|
|
@@ -42,6 +42,8 @@ export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
|
42
42
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE
|
|
43
43
|
]);
|
|
44
44
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
45
|
+
export const RTL_MAX_SYNC_DRAIN_ROUNDS = 50;
|
|
46
|
+
export const RTL_SYNC_DRAIN_RETRY_DELAY_MS = 1000;
|
|
45
47
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
46
48
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
47
49
|
export const RTL_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -2954,6 +2956,7 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2954
2956
|
});
|
|
2955
2957
|
}
|
|
2956
2958
|
let rejectedCount = 0;
|
|
2959
|
+
let deduplicatedCount = 0;
|
|
2957
2960
|
toArray(operations).forEach((operation, index) => {
|
|
2958
2961
|
const id = normalizeId(operation?.id);
|
|
2959
2962
|
const fingerprint = rtlOperationWireFingerprint(operation);
|
|
@@ -2961,7 +2964,10 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2961
2964
|
let reason = rtlOperationIdentityReason(operation, workspace);
|
|
2962
2965
|
if (!reason) reason = rtlLifecycleOperationReason(operation, lifecycleState);
|
|
2963
2966
|
if (!reason && knownFingerprint) {
|
|
2964
|
-
if (knownFingerprint === fingerprint)
|
|
2967
|
+
if (knownFingerprint === fingerprint) {
|
|
2968
|
+
deduplicatedCount += 1;
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2965
2971
|
reason = RECORD_TIMELABEL_OPERATION_IDENTITY_REASONS.ID_CONFLICT;
|
|
2966
2972
|
}
|
|
2967
2973
|
if (!reason) {
|
|
@@ -2991,7 +2997,7 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2991
2997
|
}
|
|
2992
2998
|
};
|
|
2993
2999
|
});
|
|
2994
|
-
return {accepted, rejected, rejectedCount};
|
|
3000
|
+
return {accepted, rejected, rejectedCount, deduplicatedCount};
|
|
2995
3001
|
};
|
|
2996
3002
|
|
|
2997
3003
|
const rtlExtractSession = (session, fallbackEpoch = 0) => {
|
|
@@ -3287,7 +3293,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3287
3293
|
// normalizeBootstrapResponse accepts) or `{success: false,
|
|
3288
3294
|
// bootstrapRequired: true}`; it may throw on transport errors. Gap
|
|
3289
3295
|
// recovery prefers it over `cloud.bootstrap` and falls back to a fresh
|
|
3290
|
-
// bootstrap walk
|
|
3296
|
+
// bootstrap walk on transport/protocol failure. A successful but stale
|
|
3297
|
+
// catch-up response is fail-closed: falling back would hide a revision
|
|
3298
|
+
// contract violation and could apply a baseline older than the ACK.
|
|
3291
3299
|
const recoverBaseline = async (context, targetRevision, mode) => {
|
|
3292
3300
|
if (typeof cloud?.catchUp === 'function') {
|
|
3293
3301
|
try {
|
|
@@ -3300,6 +3308,9 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3300
3308
|
return requireBootstrapRevision(normalizeBootstrapResponse(caught), targetRevision);
|
|
3301
3309
|
}
|
|
3302
3310
|
} catch (error) {
|
|
3311
|
+
if (error?.code === 'recordtimelabel_bootstrap_revision_behind_required') {
|
|
3312
|
+
throw error;
|
|
3313
|
+
}
|
|
3303
3314
|
logger?.warn?.('[RecordTimeLabelCore] catch-up failed, falling back to bootstrap', error);
|
|
3304
3315
|
}
|
|
3305
3316
|
}
|
|
@@ -3534,12 +3545,17 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3534
3545
|
const captured = capture();
|
|
3535
3546
|
const loaded = await storage.load();
|
|
3536
3547
|
if (!(await isCurrent(captured))) return getSnapshot();
|
|
3537
|
-
//
|
|
3538
|
-
//
|
|
3539
|
-
//
|
|
3540
|
-
//
|
|
3548
|
+
// Read the raw owner and baseline before normalizeLoadedWorkspace adopts
|
|
3549
|
+
// the current session identity. Reuse still validates the normalized
|
|
3550
|
+
// candidate below because owner/epoch fences may replace loaded data with
|
|
3551
|
+
// an empty workspace.
|
|
3541
3552
|
const loadedOwnerUid = rtlNormalizeUid(loaded?.ownerUid);
|
|
3542
|
-
const
|
|
3553
|
+
const loadedBaseline = loaded?.remoteBaseline;
|
|
3554
|
+
const committedRevision = Number(loadedBaseline?.revision);
|
|
3555
|
+
const hasValidCommittedBaseline = rtlDurableIsObject(loadedBaseline) &&
|
|
3556
|
+
rtlDurableIsObject(loadedBaseline.state) &&
|
|
3557
|
+
Number.isFinite(committedRevision) &&
|
|
3558
|
+
committedRevision > 0;
|
|
3543
3559
|
let candidate = normalizeLoadedWorkspace(loaded, captured);
|
|
3544
3560
|
const identityChecked = rtlQuarantineOperations(
|
|
3545
3561
|
candidate,
|
|
@@ -3552,7 +3568,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3552
3568
|
|
|
3553
3569
|
const canReuseCommitted = initialBaseline === 'reuse-committed' &&
|
|
3554
3570
|
captured?.uid && loadedOwnerUid === rtlNormalizeUid(captured.uid) &&
|
|
3555
|
-
|
|
3571
|
+
hasValidCommittedBaseline &&
|
|
3572
|
+
candidate.ownerUid === rtlNormalizeUid(captured.uid) &&
|
|
3573
|
+
candidate.remoteBaseline.revision === committedRevision &&
|
|
3574
|
+
(!captured.hasEpoch || Number(candidate.workspaceEpoch) === Number(captured.workspaceEpoch));
|
|
3556
3575
|
if (captured?.uid && typeof cloud?.bootstrap === 'function' && !canReuseCommitted) {
|
|
3557
3576
|
let bootstrap;
|
|
3558
3577
|
try {
|
|
@@ -3671,10 +3690,20 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3671
3690
|
type: 'local_applied',
|
|
3672
3691
|
operations: clone([...durableOperations, ...viewOperations]),
|
|
3673
3692
|
operation: clone(viewOperations.at(-1) || durableOperations.at(-1)),
|
|
3674
|
-
rejectedCount: identityChecked.rejectedCount
|
|
3693
|
+
rejectedCount: identityChecked.rejectedCount,
|
|
3694
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3695
|
+
});
|
|
3696
|
+
} else if (identityChecked.deduplicatedCount > 0 && identityChecked.rejectedCount === 0) {
|
|
3697
|
+
notify({
|
|
3698
|
+
type: 'operations_deduplicated',
|
|
3699
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3675
3700
|
});
|
|
3676
3701
|
} else {
|
|
3677
|
-
notify({
|
|
3702
|
+
notify({
|
|
3703
|
+
type: 'operations_rejected',
|
|
3704
|
+
rejectedCount: identityChecked.rejectedCount,
|
|
3705
|
+
deduplicatedCount: identityChecked.deduplicatedCount
|
|
3706
|
+
});
|
|
3678
3707
|
}
|
|
3679
3708
|
return getSnapshot();
|
|
3680
3709
|
};
|
|
@@ -3868,6 +3897,21 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3868
3897
|
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
3869
3898
|
if (rtlDurableIsObject(responseState)) {
|
|
3870
3899
|
applyRemoteBaseline(candidate, responseBaseline);
|
|
3900
|
+
} else if (
|
|
3901
|
+
rejectedCount === 0 &&
|
|
3902
|
+
!sawRetryable &&
|
|
3903
|
+
appliedOperations.length === completedOperations.length &&
|
|
3904
|
+
appliedOperations.length > 0 &&
|
|
3905
|
+
Number.isFinite(responseRevision) &&
|
|
3906
|
+
responseRevision === candidate.remoteBaseline.revision + 1
|
|
3907
|
+
) {
|
|
3908
|
+
// The v2 gateway ACK is intentionally compact and omits state. Promote
|
|
3909
|
+
// only an all-applied transaction at exactly the next revision; any
|
|
3910
|
+
// larger jump still needs an authoritative bootstrap baseline.
|
|
3911
|
+
candidate.remoteBaseline.revision = responseRevision;
|
|
3912
|
+
if (Object.prototype.hasOwnProperty.call(responseBaseline || {}, 'changeCursor')) {
|
|
3913
|
+
candidate.remoteBaseline.changeCursor = responseBaseline.changeCursor ?? null;
|
|
3914
|
+
}
|
|
3871
3915
|
} else if (
|
|
3872
3916
|
rejectedCount === 0 &&
|
|
3873
3917
|
Number.isFinite(responseRevision) &&
|
|
@@ -3930,6 +3974,120 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3930
3974
|
};
|
|
3931
3975
|
};
|
|
3932
3976
|
|
|
3977
|
+
// `sync()` intentionally sends only one immutable FIFO batch. Consumers
|
|
3978
|
+
// that own the outbox scan may use this method to drain subsequent batches
|
|
3979
|
+
// without reimplementing the stop conditions in each host application.
|
|
3980
|
+
// `beforeRound` is a host gate (hydration/import/account state); `runRound`
|
|
3981
|
+
// can serialize that gate check and the gateway call with host mutations.
|
|
3982
|
+
const syncUntilIdleInternal = async (reason, options = {}) => {
|
|
3983
|
+
const maxRounds = Number.isSafeInteger(Number(options?.maxRounds)) &&
|
|
3984
|
+
Number(options.maxRounds) > 0
|
|
3985
|
+
? Number(options.maxRounds)
|
|
3986
|
+
: RTL_MAX_SYNC_DRAIN_ROUNDS;
|
|
3987
|
+
const beforeRound = typeof options?.beforeRound === 'function'
|
|
3988
|
+
? options.beforeRound
|
|
3989
|
+
: null;
|
|
3990
|
+
const runRound = typeof options?.runRound === 'function'
|
|
3991
|
+
? options.runRound
|
|
3992
|
+
: null;
|
|
3993
|
+
const totals = {
|
|
3994
|
+
appliedCount: 0,
|
|
3995
|
+
syncedCount: 0,
|
|
3996
|
+
rejectedCount: 0,
|
|
3997
|
+
retryCount: 0,
|
|
3998
|
+
identityRejectedCount: 0
|
|
3999
|
+
};
|
|
4000
|
+
let lastResult = null;
|
|
4001
|
+
let rounds = 0;
|
|
4002
|
+
|
|
4003
|
+
for (let round = 0; round < maxRounds; round += 1) {
|
|
4004
|
+
rounds += 1;
|
|
4005
|
+
const executeRound = async () => {
|
|
4006
|
+
if (beforeRound) {
|
|
4007
|
+
const gate = await beforeRound({
|
|
4008
|
+
round,
|
|
4009
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4010
|
+
snapshot: getSnapshot()
|
|
4011
|
+
});
|
|
4012
|
+
if (gate === false || gate?.allowed === false) {
|
|
4013
|
+
const details = gate && typeof gate === 'object' ? {...gate} : {};
|
|
4014
|
+
delete details.allowed;
|
|
4015
|
+
return {
|
|
4016
|
+
...details,
|
|
4017
|
+
success: true,
|
|
4018
|
+
deferred: true,
|
|
4019
|
+
pendingSync: workspace.pendingOperations.length > 0,
|
|
4020
|
+
reason: details.reason || 'sync_gate_blocked',
|
|
4021
|
+
pendingCount: workspace.pendingOperations.length
|
|
4022
|
+
};
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
return syncInternal(reason);
|
|
4026
|
+
};
|
|
4027
|
+
const result = runRound
|
|
4028
|
+
? await runRound({
|
|
4029
|
+
round,
|
|
4030
|
+
pendingCount: workspace.pendingOperations.length,
|
|
4031
|
+
run: executeRound
|
|
4032
|
+
})
|
|
4033
|
+
: await executeRound();
|
|
4034
|
+
lastResult = result && typeof result === 'object'
|
|
4035
|
+
? result
|
|
4036
|
+
: {success: false, reason: 'sync_round_missing_result'};
|
|
4037
|
+
Object.keys(totals).forEach((key) => {
|
|
4038
|
+
const value = Number(lastResult?.[key]);
|
|
4039
|
+
if (Number.isFinite(value)) totals[key] += value;
|
|
4040
|
+
});
|
|
4041
|
+
|
|
4042
|
+
const pendingCount = Number(lastResult?.pendingCount ?? workspace.pendingOperations.length);
|
|
4043
|
+
const madeProgress = Number(lastResult?.syncedCount || lastResult?.appliedCount || 0) > 0 ||
|
|
4044
|
+
Number(lastResult?.rejectedCount || 0) > 0 ||
|
|
4045
|
+
Number(lastResult?.retryCount || 0) > 0 ||
|
|
4046
|
+
Number(lastResult?.identityRejectedCount || 0) > 0;
|
|
4047
|
+
if (
|
|
4048
|
+
lastResult.deferred ||
|
|
4049
|
+
lastResult.success === false ||
|
|
4050
|
+
lastResult.retryable ||
|
|
4051
|
+
lastResult.protocolError ||
|
|
4052
|
+
lastResult.skipped ||
|
|
4053
|
+
lastResult.retryCount > 0 ||
|
|
4054
|
+
pendingCount === 0 ||
|
|
4055
|
+
!madeProgress
|
|
4056
|
+
) break;
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
const pendingCount = Number(lastResult?.pendingCount ?? workspace.pendingOperations.length);
|
|
4060
|
+
const result = {
|
|
4061
|
+
...(lastResult || {success: true}),
|
|
4062
|
+
...totals,
|
|
4063
|
+
pendingCount,
|
|
4064
|
+
syncDrainRounds: rounds,
|
|
4065
|
+
drained: pendingCount === 0
|
|
4066
|
+
};
|
|
4067
|
+
if (
|
|
4068
|
+
pendingCount > 0 &&
|
|
4069
|
+
rounds >= maxRounds &&
|
|
4070
|
+
!result.deferred &&
|
|
4071
|
+
result.success !== false &&
|
|
4072
|
+
!result.retryable &&
|
|
4073
|
+
!result.skipped
|
|
4074
|
+
) {
|
|
4075
|
+
const retryAt = now() + RTL_SYNC_DRAIN_RETRY_DELAY_MS;
|
|
4076
|
+
return {
|
|
4077
|
+
...result,
|
|
4078
|
+
success: false,
|
|
4079
|
+
retryable: true,
|
|
4080
|
+
pendingSync: true,
|
|
4081
|
+
reason: 'sync_drain_limit_reached',
|
|
4082
|
+
syncDrainLimitReached: true,
|
|
4083
|
+
drainLimitReached: true,
|
|
4084
|
+
retryAfterMs: RTL_SYNC_DRAIN_RETRY_DELAY_MS,
|
|
4085
|
+
retryAt
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
return result;
|
|
4089
|
+
};
|
|
4090
|
+
|
|
3933
4091
|
const engine = {
|
|
3934
4092
|
init() {
|
|
3935
4093
|
return enqueue(async () => {
|
|
@@ -3946,6 +4104,14 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3946
4104
|
return enqueue(() => syncInternal(reason));
|
|
3947
4105
|
},
|
|
3948
4106
|
|
|
4107
|
+
syncUntilIdle(reason, options = {}) {
|
|
4108
|
+
return enqueue(() => syncUntilIdleInternal(reason, options));
|
|
4109
|
+
},
|
|
4110
|
+
|
|
4111
|
+
waitForIdle() {
|
|
4112
|
+
return queue.catch(() => null);
|
|
4113
|
+
},
|
|
4114
|
+
|
|
3949
4115
|
getSnapshot,
|
|
3950
4116
|
|
|
3951
4117
|
subscribe(listener) {
|
|
@@ -4996,6 +5162,8 @@ export default {
|
|
|
4996
5162
|
RECORD_TIMELABEL_PROTOCOL_CAPABILITIES,
|
|
4997
5163
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
4998
5164
|
RTL_MAX_OPERATIONS_PER_REQUEST,
|
|
5165
|
+
RTL_MAX_SYNC_DRAIN_ROUNDS,
|
|
5166
|
+
RTL_SYNC_DRAIN_RETRY_DELAY_MS,
|
|
4999
5167
|
RTL_MAX_REQUEST_BYTES,
|
|
5000
5168
|
RTL_MAX_TARGET_WRITES,
|
|
5001
5169
|
RTL_TRASH_RETENTION_MS,
|