@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.
Files changed (3) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. 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.0` (publish it before updating consumers):
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.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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
@@ -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.0';
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 code = String(error?.code ?? value?.code ?? '').toLowerCase();
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
- code === 'bulk_job_in_progress' || code === 'retryable' || code === 'temporarily_unavailable' ||
2937
- code === 'unavailable' || code === 'deadline_exceeded'
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 retryAfter = Number(error?.retryAfterMs ?? value?.retryAfterMs);
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 after = Number(result?.retryAfterMs ?? retryAfterMs);
3269
- if (Number.isFinite(after) && after >= 0) return timestamp + after;
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
- const ready = workspace.pendingOperations.filter((operation) => (
3432
- !Number.isFinite(Number(operation.nextRetryAt)) || Number(operation.nextRetryAt) <= timestamp
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: parsed.results.filter((result) => result.status === 'rejected').length,
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,