@recordtimelabel/core 0.4.3 → 0.4.4
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 -6
- package/package.json +1 -1
- package/src/index.js +110 -25
- package/src/protocol.js +39 -5
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 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.4` (publish it before updating consumers):
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
|
-
"@recordtimelabel/core": "0.4.
|
|
23
|
+
"@recordtimelabel/core": "0.4.4"
|
|
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.
|
|
@@ -111,14 +111,18 @@ Pending operations also carry an internal `syncBatchId`. Operations dispatched i
|
|
|
111
111
|
one call share a batch boundary, while legacy pending operations are assigned one
|
|
112
112
|
stable boundary during migration. The engine sends only the first FIFO batch boundary,
|
|
113
113
|
capped at 20 operations, and derives a stable request ID from that boundary and the
|
|
114
|
-
ordered
|
|
115
|
-
|
|
114
|
+
ordered canonical wire operations. Durable retry metadata does not affect request identity,
|
|
115
|
+
while reusing an operation ID with different wire content is quarantined. A bounded history
|
|
116
|
+
of completed operation fingerprints is kept in `syncMeta`; `syncBatchId`, identity fences,
|
|
117
|
+
and retry scheduling fields stay outside canonical wire operations.
|
|
116
118
|
|
|
117
119
|
Bootstrap attempts use `initial-hydration`, `rejection-rebase`, or `gap-recovery`
|
|
118
120
|
modes with a unique `attemptId`; all require a fresh logical attempt. Structured
|
|
119
121
|
failures, missing state, and missing/invalid revisions fail closed. UID or workspace
|
|
120
122
|
epoch changes clear the visible workspace and require `init()` before another dispatch
|
|
121
|
-
or sync; token refresh for the same UID/epoch only refreshes the subscription fence.
|
|
123
|
+
or sync; token refresh for the same UID/epoch only refreshes the subscription fence. An
|
|
124
|
+
operation acknowledgement records its revision diagnostically but advances
|
|
125
|
+
`remoteBaseline.revision` only with a complete state; revision gaps trigger a fresh bootstrap.
|
|
122
126
|
|
|
123
127
|
The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`, `getSnapshot()`,
|
|
124
128
|
`subscribe(listener)`, and `destroy()`. `getSnapshot().state` is derived by replaying pending
|
|
@@ -149,7 +153,7 @@ durable state.
|
|
|
149
153
|
Gateway-only compatibility code may opt into successful legacy envelopes with
|
|
150
154
|
`{allowLegacySuccessWithoutResults: true}`. New durable clients never enable that fallback.
|
|
151
155
|
Capabilities `operation-conflict-quarantine`, `strict-operation-results`, and
|
|
152
|
-
`lifecycle-generation-fence` gate the corresponding 0.4.
|
|
156
|
+
`lifecycle-generation-fence` gate the corresponding 0.4.4 behavior. Single
|
|
153
157
|
`record.restore`, `folder.restore`, and `trash.purge` operations require a positive
|
|
154
158
|
`expectedGeneration`; batch lifecycle operations retain their existing contract. The gateway
|
|
155
159
|
validator derives enforcement from `client.capabilities`. The planner is strict by default;
|
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.4';
|
|
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',
|
|
@@ -2648,6 +2648,7 @@ export const createRecordTimeLabelController = ({
|
|
|
2648
2648
|
const RTL_DURABLE_SCHEMA_VERSION = 1;
|
|
2649
2649
|
const RTL_RETRY_BASE_MS = 1000;
|
|
2650
2650
|
const RTL_RETRY_MAX_MS = 60 * 1000;
|
|
2651
|
+
const RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT = 512;
|
|
2651
2652
|
|
|
2652
2653
|
const rtlDurableIsObject = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
2653
2654
|
|
|
@@ -2763,13 +2764,10 @@ const rtlNormalizePendingOperation = (operation, options = {}) => {
|
|
|
2763
2764
|
};
|
|
2764
2765
|
|
|
2765
2766
|
const rtlStableSyncBatchId = (operations = []) => {
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
hash = Math.imul(hash, 16777619);
|
|
2771
|
-
}
|
|
2772
|
-
return `durable-batch:${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
2767
|
+
return buildRecordTimeLabelRequestId(
|
|
2768
|
+
'durable-batch',
|
|
2769
|
+
toArray(operations).map((operation) => toRecordTimeLabelWireOperation(operation))
|
|
2770
|
+
);
|
|
2773
2771
|
};
|
|
2774
2772
|
|
|
2775
2773
|
const rtlNormalizeRemoteBaseline = (value = {}) => {
|
|
@@ -2883,6 +2881,34 @@ const rtlOperationWireFingerprint = (operation) => JSON.stringify(
|
|
|
2883
2881
|
canonicalizeFirestoreV2DocumentValue(toRecordTimeLabelWireOperation(operation))
|
|
2884
2882
|
);
|
|
2885
2883
|
|
|
2884
|
+
const rtlCompletedOperationFingerprint = (value) => (
|
|
2885
|
+
typeof value === 'string' ? value : value?.fingerprint
|
|
2886
|
+
);
|
|
2887
|
+
|
|
2888
|
+
const rtlRememberCompletedOperations = (workspace, completedOperations, timestamp) => {
|
|
2889
|
+
const existing = rtlDurableIsObject(workspace?.syncMeta?.completedOperationFingerprints)
|
|
2890
|
+
? workspace.syncMeta.completedOperationFingerprints
|
|
2891
|
+
: {};
|
|
2892
|
+
const history = {...existing};
|
|
2893
|
+
toArray(completedOperations).forEach(({operation, status}) => {
|
|
2894
|
+
const id = normalizeId(operation?.id);
|
|
2895
|
+
if (!id) return;
|
|
2896
|
+
delete history[id];
|
|
2897
|
+
history[id] = {
|
|
2898
|
+
fingerprint: rtlOperationWireFingerprint(operation),
|
|
2899
|
+
completedAt: timestamp,
|
|
2900
|
+
status
|
|
2901
|
+
};
|
|
2902
|
+
});
|
|
2903
|
+
const boundedHistory = Object.fromEntries(
|
|
2904
|
+
Object.entries(history).slice(-RTL_COMPLETED_OPERATION_FINGERPRINT_LIMIT)
|
|
2905
|
+
);
|
|
2906
|
+
workspace.syncMeta = {
|
|
2907
|
+
...(workspace.syncMeta || {}),
|
|
2908
|
+
completedOperationFingerprints: boundedHistory
|
|
2909
|
+
};
|
|
2910
|
+
};
|
|
2911
|
+
|
|
2886
2912
|
const rtlLifecycleOperationReason = (operation, state) => {
|
|
2887
2913
|
if (![
|
|
2888
2914
|
OPERATION_TYPES.RECORD_RESTORE,
|
|
@@ -2905,6 +2931,19 @@ const rtlQuarantineOperations = (workspace, operations, timestamp = Date.now())
|
|
|
2905
2931
|
const accepted = [];
|
|
2906
2932
|
const rejected = {...(workspace?.rejectedOperations || {})};
|
|
2907
2933
|
const knownById = new Map();
|
|
2934
|
+
Object.entries(workspace?.syncMeta?.completedOperationFingerprints || {}).forEach(([rawId, value]) => {
|
|
2935
|
+
const id = normalizeId(rawId);
|
|
2936
|
+
const fingerprint = rtlCompletedOperationFingerprint(value);
|
|
2937
|
+
if (id && typeof fingerprint === 'string' && !knownById.has(id)) {
|
|
2938
|
+
knownById.set(id, fingerprint);
|
|
2939
|
+
}
|
|
2940
|
+
});
|
|
2941
|
+
Object.values(workspace?.rejectedOperations || {}).forEach((entry) => {
|
|
2942
|
+
const id = normalizeId(entry?.operation?.id || entry?.id);
|
|
2943
|
+
if (id && entry?.operation && !knownById.has(id)) {
|
|
2944
|
+
knownById.set(id, rtlOperationWireFingerprint(entry.operation));
|
|
2945
|
+
}
|
|
2946
|
+
});
|
|
2908
2947
|
let lifecycleState = operations === workspace?.pendingOperations
|
|
2909
2948
|
? normalizeRecordTimeLabelDomainState(workspace?.remoteBaseline?.state || {})
|
|
2910
2949
|
: rtlDeriveDurableVisibleState(workspace);
|
|
@@ -3226,6 +3265,18 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3226
3265
|
return {state, revision, changeCursor: remote.changeCursor ?? null};
|
|
3227
3266
|
};
|
|
3228
3267
|
|
|
3268
|
+
const requireBootstrapRevision = (baseline, minimumRevision) => {
|
|
3269
|
+
const minimum = Number(minimumRevision);
|
|
3270
|
+
if (Number.isFinite(minimum) && Number(baseline?.revision) < minimum) {
|
|
3271
|
+
const error = new Error('recordtimelabel_bootstrap_revision_behind_required');
|
|
3272
|
+
error.code = 'recordtimelabel_bootstrap_revision_behind_required';
|
|
3273
|
+
error.bootstrapRevision = Number(baseline?.revision);
|
|
3274
|
+
error.minimumRevision = minimum;
|
|
3275
|
+
throw error;
|
|
3276
|
+
}
|
|
3277
|
+
return baseline;
|
|
3278
|
+
};
|
|
3279
|
+
|
|
3229
3280
|
const normalizeLoadedWorkspace = (loaded, captured) => {
|
|
3230
3281
|
const source = loaded && typeof loaded === 'object' ? loaded : {};
|
|
3231
3282
|
const hasDurableShape = source.remoteBaseline || source.pendingOperations || source.schemaVersion === RTL_DURABLE_SCHEMA_VERSION;
|
|
@@ -3302,6 +3353,10 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3302
3353
|
);
|
|
3303
3354
|
});
|
|
3304
3355
|
});
|
|
3356
|
+
const migrationBatchId = rtlStableSyncBatchId(migratedOperations);
|
|
3357
|
+
migratedOperations.forEach((operation) => {
|
|
3358
|
+
if (!operation.syncBatchId) operation.syncBatchId = migrationBatchId;
|
|
3359
|
+
});
|
|
3305
3360
|
loadedWorkspace.remoteBaseline = rtlNormalizeRemoteBaseline({});
|
|
3306
3361
|
loadedWorkspace.pendingOperations = [
|
|
3307
3362
|
...migratedOperations,
|
|
@@ -3405,10 +3460,13 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3405
3460
|
return {success: false, reason: 'revision_gap', bootstrapRequired: true};
|
|
3406
3461
|
}
|
|
3407
3462
|
const context = rtlSessionContext(captured, candidate, client);
|
|
3408
|
-
baselineValue =
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3463
|
+
baselineValue = requireBootstrapRevision(
|
|
3464
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3465
|
+
context,
|
|
3466
|
+
bootstrapAttemptOptions('gap-recovery')
|
|
3467
|
+
)),
|
|
3468
|
+
remoteRevision
|
|
3469
|
+
);
|
|
3412
3470
|
if (!(await isCurrent(captured))) return {stale: true, reason: 'stale_session'};
|
|
3413
3471
|
}
|
|
3414
3472
|
const applied = applyRemoteBaseline(candidate, baselineValue);
|
|
@@ -3727,10 +3785,12 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3727
3785
|
return {success: false, error, protocolError: true};
|
|
3728
3786
|
}
|
|
3729
3787
|
}
|
|
3788
|
+
const rejectedCount = parsed.results.filter((result) => result.status === 'rejected').length;
|
|
3730
3789
|
const candidate = clone(workspace);
|
|
3731
3790
|
const resultById = new Map(parsed.results.map((result) => [result.id, result]));
|
|
3732
3791
|
const readyIds = new Set(ready.map((operation) => operation.id));
|
|
3733
3792
|
const appliedOperations = [];
|
|
3793
|
+
const completedOperations = [];
|
|
3734
3794
|
const nextPending = [];
|
|
3735
3795
|
const nextRejected = {...candidate.rejectedOperations};
|
|
3736
3796
|
for (const operation of candidate.pendingOperations) {
|
|
@@ -3741,12 +3801,15 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3741
3801
|
const result = resultById.get(operation.id);
|
|
3742
3802
|
if (result.status === 'applied') {
|
|
3743
3803
|
appliedOperations.push(operation);
|
|
3804
|
+
completedOperations.push({operation, status: result.status});
|
|
3744
3805
|
} else if (result.status === 'noop') {
|
|
3745
3806
|
// A noop is acknowledged but intentionally not promoted onto the
|
|
3746
3807
|
// baseline: the server says the operation had no effect.
|
|
3808
|
+
completedOperations.push({operation, status: result.status});
|
|
3747
3809
|
} else if (result.status === 'retryable') {
|
|
3748
3810
|
nextPending.push(makeRetryOperation(operation, result, null, timestamp));
|
|
3749
3811
|
} else if (result.status === 'rejected') {
|
|
3812
|
+
completedOperations.push({operation, status: result.status});
|
|
3750
3813
|
nextRejected[operation.id] = {
|
|
3751
3814
|
id: operation.id,
|
|
3752
3815
|
operation: clone(operation),
|
|
@@ -3763,13 +3826,25 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3763
3826
|
);
|
|
3764
3827
|
});
|
|
3765
3828
|
const responseRevision = Number(response.revision ?? response.remoteRevision);
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3829
|
+
const responseBaseline = rtlEnvelopePayload(response);
|
|
3830
|
+
const responseState = responseBaseline?.state ?? responseBaseline?.data;
|
|
3831
|
+
if (rtlDurableIsObject(responseState)) {
|
|
3832
|
+
applyRemoteBaseline(candidate, responseBaseline);
|
|
3833
|
+
} else if (
|
|
3834
|
+
rejectedCount === 0 &&
|
|
3835
|
+
Number.isFinite(responseRevision) &&
|
|
3836
|
+
responseRevision > candidate.remoteBaseline.revision + 1 &&
|
|
3837
|
+
typeof cloud?.bootstrap === 'function'
|
|
3838
|
+
) {
|
|
3839
|
+
const freshBaseline = requireBootstrapRevision(
|
|
3840
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3841
|
+
context,
|
|
3842
|
+
bootstrapAttemptOptions('gap-recovery')
|
|
3843
|
+
)),
|
|
3844
|
+
responseRevision
|
|
3845
|
+
);
|
|
3846
|
+
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3847
|
+
applyRemoteBaseline(candidate, freshBaseline);
|
|
3773
3848
|
}
|
|
3774
3849
|
candidate.pendingOperations = nextPending;
|
|
3775
3850
|
candidate.rejectedOperations = nextRejected;
|
|
@@ -3778,16 +3853,26 @@ export const createRecordTimeLabelSyncEngine = ({
|
|
|
3778
3853
|
lastSyncedAt: timestamp,
|
|
3779
3854
|
lastSyncAttemptAt: timestamp,
|
|
3780
3855
|
lastSyncReason: reason ?? null,
|
|
3781
|
-
lastSyncError: null
|
|
3856
|
+
lastSyncError: null,
|
|
3857
|
+
...(Number.isFinite(responseRevision) ? {
|
|
3858
|
+
lastAcknowledgedRevision: Math.max(
|
|
3859
|
+
rtlToFiniteNumber(candidate.syncMeta?.lastAcknowledgedRevision, 0),
|
|
3860
|
+
candidate.remoteBaseline.revision,
|
|
3861
|
+
responseRevision
|
|
3862
|
+
)
|
|
3863
|
+
} : {})
|
|
3782
3864
|
};
|
|
3783
|
-
|
|
3865
|
+
rtlRememberCompletedOperations(candidate, completedOperations, timestamp);
|
|
3784
3866
|
if (rejectedCount > 0 && typeof cloud?.bootstrap === 'function') {
|
|
3785
3867
|
let freshBaseline;
|
|
3786
3868
|
try {
|
|
3787
|
-
freshBaseline =
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3869
|
+
freshBaseline = requireBootstrapRevision(
|
|
3870
|
+
normalizeBootstrapResponse(await cloud.bootstrap(
|
|
3871
|
+
context,
|
|
3872
|
+
bootstrapAttemptOptions('rejection-rebase')
|
|
3873
|
+
)),
|
|
3874
|
+
responseRevision
|
|
3875
|
+
);
|
|
3791
3876
|
} catch (error) {
|
|
3792
3877
|
if (!(await isCurrent(captured))) return {success: false, reason: 'stale_session'};
|
|
3793
3878
|
throw error;
|
package/src/protocol.js
CHANGED
|
@@ -59,6 +59,37 @@ const fnv1a = (value) => {
|
|
|
59
59
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
const djb2 = (value) => {
|
|
63
|
+
let hash = 5381;
|
|
64
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
65
|
+
hash = Math.imul(hash, 33) ^ value.charCodeAt(index);
|
|
66
|
+
}
|
|
67
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const canonicalizeRequestValue = (value, seen = new WeakSet()) => {
|
|
71
|
+
if (value === null || value === undefined || typeof value !== 'object') return value;
|
|
72
|
+
if (seen.has(value)) throw new TypeError('operation_request_circular_value');
|
|
73
|
+
seen.add(value);
|
|
74
|
+
try {
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.map((entry) => canonicalizeRequestValue(entry, seen));
|
|
77
|
+
}
|
|
78
|
+
if (typeof value.toJSON === 'function') {
|
|
79
|
+
return canonicalizeRequestValue(value.toJSON(), seen);
|
|
80
|
+
}
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
Object.keys(value)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((key) => [key, canonicalizeRequestValue(value[key], seen)])
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
seen.delete(value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const requestDigest = (value) => `${fnv1a(value)}${djb2(value)}`;
|
|
92
|
+
|
|
62
93
|
export const toRecordTimeLabelWireOperation = (operation = {}) => {
|
|
63
94
|
const source = asObject(operation);
|
|
64
95
|
return {
|
|
@@ -73,14 +104,17 @@ export const toRecordTimeLabelWireOperation = (operation = {}) => {
|
|
|
73
104
|
export const buildRecordTimeLabelRequestId = (namespace, operations = []) => {
|
|
74
105
|
const safeNamespace = normalizeId(namespace)
|
|
75
106
|
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
76
|
-
.slice(0,
|
|
77
|
-
const
|
|
78
|
-
? operations.map((operation) =>
|
|
107
|
+
.slice(0, 48) || 'recordtimelabel';
|
|
108
|
+
const wireOperations = Array.isArray(operations)
|
|
109
|
+
? operations.map((operation) => toRecordTimeLabelWireOperation(operation))
|
|
79
110
|
: [];
|
|
111
|
+
const orderedIds = wireOperations.map((operation) => normalizeId(operation.id));
|
|
80
112
|
const firstOperationId = (orderedIds[0] || 'empty')
|
|
81
113
|
.replace(/[^A-Za-z0-9._:-]+/g, '-')
|
|
82
|
-
.slice(0,
|
|
83
|
-
|
|
114
|
+
.slice(0, 56) || 'empty';
|
|
115
|
+
const orderedIdDigest = requestDigest(orderedIds.join('\u001f'));
|
|
116
|
+
const wireDigest = requestDigest(JSON.stringify(canonicalizeRequestValue(wireOperations)));
|
|
117
|
+
return `rtl:${safeNamespace}:${firstOperationId}:${orderedIdDigest}:${wireDigest}`.slice(0, 160);
|
|
84
118
|
};
|
|
85
119
|
|
|
86
120
|
const normalizeStatus = (result) => {
|