@recordtimelabel/core 0.4.1 → 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 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.1` (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.1"
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.1",
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.1';
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)
@@ -3374,6 +3393,10 @@ export const createRecordTimeLabelSyncEngine = ({
3374
3393
  ownerUid: captured?.uid ?? candidate.ownerUid,
3375
3394
  workspaceEpoch: captured?.workspaceEpoch ?? candidate.workspaceEpoch
3376
3395
  }));
3396
+ const syncBatchId = rtlStableSyncBatchId(normalized);
3397
+ normalized.forEach((operation) => {
3398
+ if (!operation.syncBatchId) operation.syncBatchId = syncBatchId;
3399
+ });
3377
3400
  const identityChecked = rtlQuarantineOperations(candidate, normalized, now());
3378
3401
  const viewOperations = identityChecked.accepted.filter((operation) => (
3379
3402
  operation.type === OPERATION_TYPES.EXPANDED_GROUPS_UPDATE
@@ -3446,9 +3469,15 @@ export const createRecordTimeLabelSyncEngine = ({
3446
3469
  workspace = candidate;
3447
3470
  notify({type: 'operations_rejected', rejectedCount: identityRejectedCount});
3448
3471
  }
3449
- const ready = workspace.pendingOperations.filter((operation) => (
3450
- !Number.isFinite(Number(operation.nextRetryAt)) || Number(operation.nextRetryAt) <= timestamp
3451
- ));
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
+ }
3452
3481
  if (ready.length === 0) {
3453
3482
  return {
3454
3483
  success: true,
@@ -3590,6 +3619,18 @@ export const createRecordTimeLabelSyncEngine = ({
3590
3619
  lastSyncReason: reason ?? null,
3591
3620
  lastSyncError: null
3592
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
+ }
3593
3634
  if (!(await persist(candidate, captured))) return {success: false, reason: 'stale_session'};
3594
3635
  workspace = candidate;
3595
3636
  notify({type: 'synced', operations: clone(parsed.results)});
@@ -3600,7 +3641,7 @@ export const createRecordTimeLabelSyncEngine = ({
3600
3641
  success: true,
3601
3642
  appliedCount: appliedOperations.length,
3602
3643
  syncedCount: appliedOperations.length + parsed.results.filter((result) => result.status === 'noop').length,
3603
- rejectedCount: parsed.results.filter((result) => result.status === 'rejected').length,
3644
+ rejectedCount,
3604
3645
  retryCount: parsed.results.filter((result) => result.status === 'retryable').length,
3605
3646
  retryAt: Number.isFinite(retryAt) ? retryAt : null,
3606
3647
  pendingCount: workspace.pendingOperations.length,
@@ -4634,6 +4675,7 @@ export const buildOperationsFromSnapshotDiff = ({
4634
4675
 
4635
4676
  export default {
4636
4677
  RECORD_TIMELABEL_CORE_VERSION,
4678
+ RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
4637
4679
  RECORD_TIMELABEL_OPERATION_RESULT_STATUSES,
4638
4680
  RTL_SYNC_PROTOCOL_VERSION,
4639
4681
  RTL_MAX_OPERATIONS_PER_REQUEST,