@recordtimelabel/core 0.4.0 → 0.4.2
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 +8 -2
- package/package.json +1 -1
- package/src/index.js +71 -11
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 durable identity and explicit entrypoint 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 durable identity and explicit entrypoint contract is prepared in package version `0.4.2` (publish it before updating consumers):
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.4.
|
|
23
|
+
"@recordtimelabel/core": "0.4.2"
|
|
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,7 @@ 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
|
+
- `RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES`
|
|
36
37
|
- `normalizeRecordTimeLabelOperationResults(operations, results)`
|
|
37
38
|
- `normalizeRecordTimeLabelEnvelopeResponse(operations, response)`
|
|
38
39
|
- `createSyncEngine({ storageAdapter, cloudAdapter, clientId, clock, logger })`
|
|
@@ -102,6 +103,11 @@ operations missing them inherit the migrated workspace identity. An operation wh
|
|
|
102
103
|
explicit identity does not match the current workspace is moved to
|
|
103
104
|
`rejectedOperations` with a stable identity-mismatch reason before any cloud apply.
|
|
104
105
|
|
|
106
|
+
Pending operations also carry an internal `syncBatchId`. Operations dispatched in
|
|
107
|
+
one call share a batch boundary, while legacy pending operations are assigned one
|
|
108
|
+
stable boundary during migration. Adapters may use this field to preserve immutable
|
|
109
|
+
request envelopes across worker restarts; it must not be sent to the gateway.
|
|
110
|
+
|
|
105
111
|
The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
|
|
106
112
|
`subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
|
|
107
113
|
operations over `remoteBaseline.state`; rejected operations are kept in diagnostics and are not
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -17,8 +17,13 @@ const REQUIRED_FOLDERS = [
|
|
|
17
17
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
-
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.
|
|
20
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.4.2';
|
|
21
21
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
22
|
+
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
23
|
+
'fifo-retry-fence',
|
|
24
|
+
'baseline-refresh-after-rejection',
|
|
25
|
+
'sync-batch-boundary'
|
|
26
|
+
]);
|
|
22
27
|
export const RTL_MAX_OPERATIONS_PER_REQUEST = 20;
|
|
23
28
|
export const RTL_MAX_REQUEST_BYTES = 256 * 1024;
|
|
24
29
|
export const RTL_MAX_TARGET_WRITES = 100;
|
|
@@ -2729,6 +2734,16 @@ const rtlNormalizePendingOperation = (operation, options = {}) => {
|
|
|
2729
2734
|
return normalized;
|
|
2730
2735
|
};
|
|
2731
2736
|
|
|
2737
|
+
const rtlStableSyncBatchId = (operations = []) => {
|
|
2738
|
+
const source = toArray(operations).map((operation) => normalizeId(operation?.id)).join('|');
|
|
2739
|
+
let hash = 2166136261;
|
|
2740
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
2741
|
+
hash ^= source.charCodeAt(index);
|
|
2742
|
+
hash = Math.imul(hash, 16777619);
|
|
2743
|
+
}
|
|
2744
|
+
return `durable-batch:${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
2745
|
+
};
|
|
2746
|
+
|
|
2732
2747
|
const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
2733
2748
|
const baseline = rtlDurableIsObject(value) ? value : {};
|
|
2734
2749
|
return {
|
|
@@ -2772,6 +2787,10 @@ const rtlNormalizeDurableWorkspace = (input = {}, options = {}) => {
|
|
|
2772
2787
|
const pendingOperations = toArray(pendingSource)
|
|
2773
2788
|
.filter(Boolean)
|
|
2774
2789
|
.map((operation) => rtlNormalizePendingOperation(operation, options));
|
|
2790
|
+
const legacyBatchId = rtlStableSyncBatchId(pendingOperations);
|
|
2791
|
+
pendingOperations.forEach((operation) => {
|
|
2792
|
+
if (!operation.syncBatchId) operation.syncBatchId = legacyBatchId;
|
|
2793
|
+
});
|
|
2775
2794
|
const legacyExpandedGroups = Array.isArray(source.syncMeta?.legacyExpandedGroups)
|
|
2776
2795
|
? normalizeOrder(source.syncMeta.legacyExpandedGroups)
|
|
2777
2796
|
: Array.isArray(source.state?.expandedGroups)
|
|
@@ -2928,19 +2947,34 @@ const rtlEnvelopeSuccess = (value) => {
|
|
|
2928
2947
|
const rtlRetryableEnvelopeError = (value) => {
|
|
2929
2948
|
const error = value?.error && typeof value.error === 'object' ? value.error : value;
|
|
2930
2949
|
const status = Number(error?.status ?? error?.statusCode ?? value?.status ?? value?.statusCode);
|
|
2931
|
-
const
|
|
2950
|
+
const details = [
|
|
2951
|
+
error?.name,
|
|
2952
|
+
error?.code,
|
|
2953
|
+
error?.reason,
|
|
2954
|
+
error?.message,
|
|
2955
|
+
value?.code,
|
|
2956
|
+
value?.reason,
|
|
2957
|
+
value?.message
|
|
2958
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
2932
2959
|
return Boolean(
|
|
2933
2960
|
error?.retryable === true ||
|
|
2934
2961
|
value?.retryable === true ||
|
|
2935
2962
|
status === 408 || status === 409 || status === 425 || status === 429 || status >= 500 ||
|
|
2936
|
-
|
|
2937
|
-
|
|
2963
|
+
details.includes('bulk_job_in_progress') || details.includes('retryable') ||
|
|
2964
|
+
details.includes('temporarily_unavailable') || details.includes('unavailable') ||
|
|
2965
|
+
details.includes('deadline_exceeded') || details.includes('failed to fetch') ||
|
|
2966
|
+
details.includes('fetch failed') || details.includes('network-request-failed') ||
|
|
2967
|
+
details.includes('network request failed') || details.includes('network_error') ||
|
|
2968
|
+
details.includes('err_network') || details.includes('econnreset') ||
|
|
2969
|
+
details.includes('etimedout')
|
|
2938
2970
|
);
|
|
2939
2971
|
};
|
|
2940
2972
|
|
|
2941
2973
|
const rtlRetryAfterMs = (value) => {
|
|
2942
2974
|
const error = value?.error && typeof value.error === 'object' ? value.error : value;
|
|
2943
|
-
const
|
|
2975
|
+
const rawRetryAfter = error?.retryAfterMs ?? value?.retryAfterMs;
|
|
2976
|
+
if (rawRetryAfter === null || rawRetryAfter === undefined || rawRetryAfter === '') return null;
|
|
2977
|
+
const retryAfter = Number(rawRetryAfter);
|
|
2944
2978
|
return Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
|
|
2945
2979
|
};
|
|
2946
2980
|
|
|
@@ -3265,8 +3299,11 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3265
3299
|
const operationRetryAt = (operation, result, retryAfterMs, timestamp) => {
|
|
3266
3300
|
const direct = Number(result?.nextRetryAt);
|
|
3267
3301
|
if (Number.isFinite(direct)) return direct;
|
|
3268
|
-
const
|
|
3269
|
-
if (
|
|
3302
|
+
const rawAfter = result?.retryAfterMs ?? retryAfterMs;
|
|
3303
|
+
if (rawAfter !== null && rawAfter !== undefined && rawAfter !== '') {
|
|
3304
|
+
const after = Number(rawAfter);
|
|
3305
|
+
if (Number.isFinite(after) && after >= 0) return timestamp + after;
|
|
3306
|
+
}
|
|
3270
3307
|
const attempts = Math.max(0, Number(operation?.retryCount || operation?.retryAttempts || 0));
|
|
3271
3308
|
return timestamp + Math.min(RTL_RETRY_MAX_MS, RTL_RETRY_BASE_MS * (2 ** attempts));
|
|
3272
3309
|
};
|
|
@@ -3356,6 +3393,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3356
3393
|
ownerUid: captured?.uid ?? candidate.ownerUid,
|
|
3357
3394
|
workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
|
|
3358
3395
|
}));
|
|
3396
|
+
const syncBatchId = rtlStableSyncBatchId(normalized);
|
|
3397
|
+
normalized.forEach((operation) => {
|
|
3398
|
+
if (!operation.syncBatchId) operation.syncBatchId = syncBatchId;
|
|
3399
|
+
});
|
|
3359
3400
|
const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
|
|
3360
3401
|
const viewOperations = identityChecked.accepted.filter((operation) => (
|
|
3361
3402
|
operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
|
|
@@ -3428,9 +3469,15 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3428
3469
|
workspace = candidate;
|
|
3429
3470
|
notify({type: 'operations_rejected', rejectedCount: identityRejectedCount});
|
|
3430
3471
|
}
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3472
|
+
// Pending operations are a FIFO log. A retry deadline on an earlier entry
|
|
3473
|
+
// is a head-of-line barrier; dependent operations behind it must not
|
|
3474
|
+
// overtake it merely because they do not have their own deadline yet.
|
|
3475
|
+
const ready = [];
|
|
3476
|
+
for (const operation of workspace.pendingOperations) {
|
|
3477
|
+
if (Number.isFinite(Number(operation.nextRetryAt)) &&
|
|
3478
|
+
Number(operation.nextRetryAt) > timestamp) break;
|
|
3479
|
+
ready.push(operation);
|
|
3480
|
+
}
|
|
3434
3481
|
if (ready.length === 0) {
|
|
3435
3482
|
return {
|
|
3436
3483
|
success: true,
|
|
@@ -3572,6 +3619,18 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3572
3619
|
lastSyncReason: reason ?? null,
|
|
3573
3620
|
lastSyncError: null
|
|
3574
3621
|
};
|
|
3622
|
+
const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
|
|
3623
|
+
if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
|
|
3624
|
+
let freshBaseline;
|
|
3625
|
+
try {
|
|
3626
|
+
freshBaseline = await cloud.bootstrap(context);
|
|
3627
|
+
} catch (error) {
|
|
3628
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3629
|
+
throw error;
|
|
3630
|
+
}
|
|
3631
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3632
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
3633
|
+
}
|
|
3575
3634
|
if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
|
|
3576
3635
|
workspace = candidate;
|
|
3577
3636
|
notify({type: 'synced', operations: clone(parsed.results)});
|
|
@@ -3582,7 +3641,7 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3582
3641
|
success: true,
|
|
3583
3642
|
appliedCount: appliedOperations.length,
|
|
3584
3643
|
syncedCount: appliedOperations.length + parsed.results.filter((result) => result.status === 'noop').length,
|
|
3585
|
-
rejectedCount
|
|
3644
|
+
rejectedCount,
|
|
3586
3645
|
retryCount: parsed.results.filter((result) => result.status === 'retryable').length,
|
|
3587
3646
|
retryAt: Number.isFinite(retryAt) ? retryAt : null,
|
|
3588
3647
|
pendingCount: workspace.pendingOperations.length,
|
|
@@ -4616,6 +4675,7 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
4616
4675
|
|
|
4617
4676
|
export default {
|
|
4618
4677
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
4678
|
+
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
4619
4679
|
RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
|
|
4620
4680
|
RTL_SYNC_PROTOCOL_VERSION,
|
|
4621
4681
|
RTL_MAX_OPERATIONS_PER_REQUEST,
|