@enbox/agent 0.5.13 → 0.5.15

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.
@@ -0,0 +1,24 @@
1
+ /** Separator used in compound runtime/legacy cursor keys. */
2
+ export const LINK_ID_SEPARATOR = '^';
3
+
4
+ /** Opaque runtime identifier for a replication link. */
5
+ export type LinkId = string;
6
+
7
+ /**
8
+ * Build the runtime identifier for a replication link.
9
+ *
10
+ * Runtime identity is `(tenantDid, remoteEndpoint, scopeId)`.
11
+ */
12
+ export function buildLinkId(tenantDid: string, remoteEndpoint: string, scopeId: string): LinkId {
13
+ return `${tenantDid}${LINK_ID_SEPARATOR}${remoteEndpoint}${LINK_ID_SEPARATOR}${scopeId}`;
14
+ }
15
+
16
+ /**
17
+ * Build the legacy cursor key used by the deprecated `syncCursors` sublevel.
18
+ *
19
+ * This remains only for one-time migration of pre-Phase-1f data.
20
+ */
21
+ export function buildLegacyCursorKey(tenantDid: string, remoteEndpoint: string, protocol?: string): string {
22
+ const base = `${tenantDid}${LINK_ID_SEPARATOR}${remoteEndpoint}`;
23
+ return protocol ? `${base}${LINK_ID_SEPARATOR}${protocol}` : base;
24
+ }
@@ -0,0 +1,155 @@
1
+ import type { GenericMessage, MessagesSyncDiffEntry } from '@enbox/dwn-sdk-js';
2
+
3
+ import type { PushResult } from './types/sync.js';
4
+
5
+ export type ReconcileDirection = 'pull' | 'push';
6
+
7
+ export type ReconcileTarget = {
8
+ did: string;
9
+ dwnUrl: string;
10
+ delegateDid?: string;
11
+ protocol?: string;
12
+ };
13
+
14
+ export type ReconcileOutcome = {
15
+ aborted?: boolean;
16
+ changed: boolean;
17
+ didPull: boolean;
18
+ didPush: boolean;
19
+ localRoot?: string;
20
+ remoteRoot?: string;
21
+ postLocalRoot?: string;
22
+ postRemoteRoot?: string;
23
+ converged?: boolean;
24
+ pushResult?: PushResult;
25
+ };
26
+
27
+ type ReconcileDeps = {
28
+ getLocalRoot: (did: string, delegateDid?: string, protocol?: string) => Promise<string>;
29
+ getRemoteRoot: (did: string, dwnUrl: string, delegateDid?: string, protocol?: string) => Promise<string>;
30
+ diffWithRemote: (target: ReconcileTarget) => Promise<{ onlyRemote: MessagesSyncDiffEntry[]; onlyLocal: string[] }>;
31
+ pullMessages: (params: {
32
+ did: string;
33
+ dwnUrl: string;
34
+ delegateDid?: string;
35
+ protocol?: string;
36
+ messageCids: string[];
37
+ prefetched: (MessagesSyncDiffEntry & { message: GenericMessage })[];
38
+ }) => Promise<void>;
39
+ pushMessages: (params: {
40
+ did: string;
41
+ dwnUrl: string;
42
+ delegateDid?: string;
43
+ protocol?: string;
44
+ messageCids: string[];
45
+ }) => Promise<PushResult>;
46
+ shouldContinue?: () => boolean;
47
+ };
48
+
49
+ function partitionRemoteEntries(entries: MessagesSyncDiffEntry[]): {
50
+ prefetched: (MessagesSyncDiffEntry & { message: GenericMessage })[];
51
+ needsFetchCids: string[];
52
+ } {
53
+ const prefetched: (MessagesSyncDiffEntry & { message: GenericMessage })[] = [];
54
+ const needsFetchCids: string[] = [];
55
+
56
+ for (const entry of entries) {
57
+ if (!entry.message) {
58
+ needsFetchCids.push(entry.messageCid);
59
+ } else if (
60
+ entry.message.descriptor.interface === 'Records' &&
61
+ entry.message.descriptor.method === 'Write' &&
62
+ (entry.message.descriptor as any).dataCid &&
63
+ !entry.encodedData
64
+ ) {
65
+ needsFetchCids.push(entry.messageCid);
66
+ } else {
67
+ prefetched.push(entry as MessagesSyncDiffEntry & { message: GenericMessage });
68
+ }
69
+ }
70
+
71
+ return { prefetched, needsFetchCids };
72
+ }
73
+
74
+ export class SyncLinkReconciler {
75
+ private readonly _deps: ReconcileDeps;
76
+
77
+ constructor(deps: ReconcileDeps) {
78
+ this._deps = deps;
79
+ }
80
+
81
+ public async reconcile(target: ReconcileTarget, options?: {
82
+ direction?: ReconcileDirection;
83
+ verifyConvergence?: boolean;
84
+ }): Promise<ReconcileOutcome> {
85
+ const { did, dwnUrl, delegateDid, protocol } = target;
86
+ const direction = options?.direction;
87
+ const verifyConvergence = options?.verifyConvergence ?? false;
88
+ const shouldContinue = this._deps.shouldContinue;
89
+
90
+ const localRoot = await this._deps.getLocalRoot(did, delegateDid, protocol);
91
+ if (shouldContinue && !shouldContinue()) { return { aborted: true, changed: false, didPull: false, didPush: false }; }
92
+
93
+ const remoteRoot = await this._deps.getRemoteRoot(did, dwnUrl, delegateDid, protocol);
94
+ if (shouldContinue && !shouldContinue()) { return { aborted: true, changed: false, didPull: false, didPush: false }; }
95
+
96
+ let didPull = false;
97
+ let didPush = false;
98
+ let pushResult: PushResult | undefined;
99
+
100
+ if (localRoot !== remoteRoot) {
101
+ const diff = await this._deps.diffWithRemote(target);
102
+ if (shouldContinue && !shouldContinue()) { return { aborted: true, changed: true, didPull: false, didPush: false, localRoot, remoteRoot }; }
103
+
104
+ if ((!direction || direction === 'pull') && diff.onlyRemote.length > 0) {
105
+ const { prefetched, needsFetchCids } = partitionRemoteEntries(diff.onlyRemote);
106
+ await this._deps.pullMessages({ did, dwnUrl, delegateDid, protocol, messageCids: needsFetchCids, prefetched });
107
+ if (shouldContinue && !shouldContinue()) { return { aborted: true, changed: true, didPull: true, didPush: false, localRoot, remoteRoot }; }
108
+ didPull = true;
109
+ }
110
+
111
+ if ((!direction || direction === 'push') && diff.onlyLocal.length > 0) {
112
+ pushResult = await this._deps.pushMessages({
113
+ did, dwnUrl, delegateDid, protocol, messageCids: diff.onlyLocal,
114
+ });
115
+ if (shouldContinue && !shouldContinue()) {
116
+ return { aborted: true, changed: true, didPull, didPush: true, localRoot, remoteRoot, pushResult };
117
+ }
118
+ didPush = true;
119
+ }
120
+ }
121
+
122
+ if (!verifyConvergence) {
123
+ return {
124
+ changed: localRoot !== remoteRoot,
125
+ didPull,
126
+ didPush,
127
+ localRoot,
128
+ remoteRoot,
129
+ pushResult,
130
+ };
131
+ }
132
+
133
+ const postLocalRoot = await this._deps.getLocalRoot(did, delegateDid, protocol);
134
+ if (shouldContinue && !shouldContinue()) {
135
+ return { aborted: true, changed: localRoot !== remoteRoot, didPull, didPush, localRoot, remoteRoot, pushResult };
136
+ }
137
+
138
+ const postRemoteRoot = await this._deps.getRemoteRoot(did, dwnUrl, delegateDid, protocol);
139
+ if (shouldContinue && !shouldContinue()) {
140
+ return { aborted: true, changed: localRoot !== remoteRoot, didPull, didPush, localRoot, remoteRoot, pushResult };
141
+ }
142
+
143
+ return {
144
+ changed : localRoot !== remoteRoot,
145
+ didPull,
146
+ didPush,
147
+ localRoot,
148
+ remoteRoot,
149
+ postLocalRoot,
150
+ postRemoteRoot,
151
+ converged : postLocalRoot === postRemoteRoot,
152
+ pushResult,
153
+ };
154
+ }
155
+ }
@@ -361,7 +361,14 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
361
361
  // Permanent failures (400/401/403) will never succeed — do NOT retry.
362
362
  // These include protocol violations (RecordLimitExceeded), auth errors,
363
363
  // and schema validation failures.
364
- console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
364
+ if (reply.status.code === 400 && reply.status.detail?.includes('record limit')) {
365
+ // Expected for singleton convergence in multi-device scenarios:
366
+ // one device created a singleton record, this device has a
367
+ // different one, and the remote rejects the duplicate.
368
+ console.debug(`SyncEngineLevel: singleton already exists on remote, skipping push for ${cid}`);
369
+ } else {
370
+ console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
371
+ }
365
372
  permanentlyFailed.push(cid);
366
373
  } else {
367
374
  // Transient failures (5xx, etc.) — worth retrying.
@@ -62,6 +62,7 @@ export class ReplicationLedger {
62
62
  try {
63
63
  const raw = await this.sublevel.get(key);
64
64
  const link = JSON.parse(raw) as ReplicationLinkState;
65
+ delete (link as any).push; // strip legacy push field from old persisted links
65
66
  // connectivity is runtime state — always reset to 'unknown' on load
66
67
  // so stale 'online' from a previous session doesn't give false positives.
67
68
  link.connectivity = 'unknown';
@@ -82,7 +83,7 @@ export class ReplicationLedger {
82
83
  status : 'initializing',
83
84
  connectivity : 'unknown',
84
85
  pull : {},
85
- push : {},
86
+ needsReconcile : false,
86
87
  delegateDid : params.delegateDid,
87
88
  protocol : params.protocol,
88
89
  };
@@ -193,5 +194,30 @@ export class ReplicationLedger {
193
194
  checkpoint.contiguousAppliedToken = token;
194
195
  checkpoint.receivedToken = token;
195
196
  }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Reconciliation helpers
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * Mark a link as needing SMT reconciliation and persist.
204
+ * Idempotent — no-op if already set.
205
+ */
206
+ public async markNeedsReconcile(link: ReplicationLinkState, _reason?: string): Promise<void> {
207
+ if (!link.needsReconcile) {
208
+ link.needsReconcile = true;
209
+ await this.saveLink(link);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Clear the reconciliation flag after successful SMT reconciliation.
215
+ */
216
+ public async clearNeedsReconcile(link: ReplicationLinkState): Promise<void> {
217
+ if (link.needsReconcile) {
218
+ link.needsReconcile = false;
219
+ await this.saveLink(link);
220
+ }
221
+ }
196
222
  }
197
223
 
package/src/types/sync.ts CHANGED
@@ -161,8 +161,13 @@ export type ReplicationLinkState = {
161
161
  /** Pull-direction replication checkpoint (remote → local). */
162
162
  pull: DirectionCheckpoint;
163
163
 
164
- /** Push-direction replication checkpoint (local → remote). */
165
- push: DirectionCheckpoint;
164
+ /**
165
+ * Whether this link needs SMT reconciliation. Set when push fails after
166
+ * retry exhaustion, when the link reconnects after an outage, or when
167
+ * the remote epoch changes. Cleared after successful reconciliation.
168
+ * Persisted so recovery survives app/browser restart.
169
+ */
170
+ needsReconcile?: boolean;
166
171
 
167
172
  /** Per-link connectivity state. Used to compute the aggregate engine-level state. */
168
173
  connectivity: SyncConnectivityState;
@@ -242,7 +247,8 @@ export type SyncEvent =
242
247
  | { type: 'link:status-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: LinkStatus; to: LinkStatus }
243
248
  | { type: 'link:connectivity-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: SyncConnectivityState; to: SyncConnectivityState }
244
249
  | { type: 'checkpoint:pull-advance'; tenantDid: string; remoteEndpoint: string; protocol?: string; position: string; messageCid: string }
245
- | { type: 'checkpoint:push-advance'; tenantDid: string; remoteEndpoint: string; protocol?: string; position: string; messageCid: string }
250
+ | { type: 'reconcile:needed'; tenantDid: string; remoteEndpoint: string; protocol?: string; reason: string }
251
+ | { type: 'reconcile:completed'; tenantDid: string; remoteEndpoint: string; protocol?: string }
246
252
  | { type: 'repair:started'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number }
247
253
  | { type: 'repair:completed'; tenantDid: string; remoteEndpoint: string; protocol?: string }
248
254
  | { type: 'repair:failed'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number; error: string }