@enbox/agent 0.5.10 → 0.5.12

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 (44) hide show
  1. package/dist/browser.mjs +9 -9
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dwn-api.js.map +1 -1
  4. package/dist/esm/dwn-record-upgrade.js +1 -1
  5. package/dist/esm/dwn-record-upgrade.js.map +1 -1
  6. package/dist/esm/index.js +4 -0
  7. package/dist/esm/index.js.map +1 -1
  8. package/dist/esm/sync-closure-resolver.js +855 -0
  9. package/dist/esm/sync-closure-resolver.js.map +1 -0
  10. package/dist/esm/sync-closure-types.js +189 -0
  11. package/dist/esm/sync-closure-types.js.map +1 -0
  12. package/dist/esm/sync-engine-level.js +956 -37
  13. package/dist/esm/sync-engine-level.js.map +1 -1
  14. package/dist/esm/sync-messages.js +42 -5
  15. package/dist/esm/sync-messages.js.map +1 -1
  16. package/dist/esm/sync-replication-ledger.js +220 -0
  17. package/dist/esm/sync-replication-ledger.js.map +1 -0
  18. package/dist/esm/types/sync.js +54 -1
  19. package/dist/esm/types/sync.js.map +1 -1
  20. package/dist/types/dwn-api.d.ts.map +1 -1
  21. package/dist/types/index.d.ts +5 -0
  22. package/dist/types/index.d.ts.map +1 -1
  23. package/dist/types/sync-closure-resolver.d.ts +19 -0
  24. package/dist/types/sync-closure-resolver.d.ts.map +1 -0
  25. package/dist/types/sync-closure-types.d.ts +122 -0
  26. package/dist/types/sync-closure-types.d.ts.map +1 -0
  27. package/dist/types/sync-engine-level.d.ts +137 -2
  28. package/dist/types/sync-engine-level.d.ts.map +1 -1
  29. package/dist/types/sync-messages.d.ts +15 -1
  30. package/dist/types/sync-messages.d.ts.map +1 -1
  31. package/dist/types/sync-replication-ledger.d.ts +72 -0
  32. package/dist/types/sync-replication-ledger.d.ts.map +1 -0
  33. package/dist/types/types/sync.d.ts +190 -0
  34. package/dist/types/types/sync.d.ts.map +1 -1
  35. package/package.json +3 -3
  36. package/src/dwn-api.ts +2 -1
  37. package/src/dwn-record-upgrade.ts +1 -1
  38. package/src/index.ts +5 -0
  39. package/src/sync-closure-resolver.ts +919 -0
  40. package/src/sync-closure-types.ts +270 -0
  41. package/src/sync-engine-level.ts +1041 -45
  42. package/src/sync-messages.ts +44 -6
  43. package/src/sync-replication-ledger.ts +197 -0
  44. package/src/types/sync.ts +204 -0
@@ -1,5 +1,6 @@
1
1
  import type { EnboxPlatformAgent } from './types/agent.js';
2
2
  import type { PermissionsApi } from './types/permissions.js';
3
+ import type { PushResult } from './types/sync.js';
3
4
  import type { GenericMessage, MessagesReadReply, MessagesSyncDiffEntry, UnionMessageReply } from '@enbox/dwn-sdk-js';
4
5
 
5
6
  import { DwnInterfaceName, DwnMethodName, Encoder, Message } from '@enbox/dwn-sdk-js';
@@ -36,6 +37,20 @@ export function syncMessageReplyIsSuccessful(reply: UnionMessageReply): boolean
36
37
  );
37
38
  }
38
39
 
40
+ /**
41
+ * Determines whether a failed push reply represents a permanent failure that
42
+ * should NOT be retried. Permanent failures include protocol violations (400),
43
+ * authorization errors (401/403), and schema validation errors that will never
44
+ * succeed regardless of retry.
45
+ *
46
+ * Transient failures (5xx, network errors) are worth retrying.
47
+ */
48
+ export function isPermanentPushFailure(reply: UnionMessageReply): boolean {
49
+ return reply.status.code === 400 ||
50
+ reply.status.code === 401 ||
51
+ reply.status.code === 403;
52
+ }
53
+
39
54
  /**
40
55
  * Helper to get the CID of a message for logging purposes.
41
56
  */
@@ -296,6 +311,10 @@ export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol,
296
311
  * Messages are fetched first, then sorted in dependency order (topological sort)
297
312
  * so that initial writes come before updates, and ProtocolsConfigures come before
298
313
  * records that reference those protocols.
314
+ *
315
+ * Returns a {@link PushResult} with per-CID outcome tracking instead of throwing
316
+ * on the first failure. Callers use this to advance the push checkpoint
317
+ * incrementally — only up to the highest contiguous success.
299
318
  */
300
319
  export async function pushMessages({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi }: {
301
320
  did: string;
@@ -305,13 +324,20 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
305
324
  messageCids: string[];
306
325
  agent: EnboxPlatformAgent;
307
326
  permissionsApi: PermissionsApi;
308
- }): Promise<void> {
327
+ }): Promise<PushResult> {
328
+ const succeeded: string[] = [];
329
+ const failed: string[] = [];
330
+ const permanentlyFailed: string[] = [];
331
+
309
332
  // Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
310
333
  const fetched: SyncMessageEntry[] = [];
311
334
  for (const messageCid of messageCids) {
312
335
  const dwnMessage = await getLocalMessage({ author: did, messageCid, delegateDid, protocol, agent, permissionsApi });
313
336
  if (dwnMessage) {
314
337
  fetched.push(dwnMessage);
338
+ } else {
339
+ // Message could not be fetched locally — mark as failed.
340
+ failed.push(messageCid);
315
341
  }
316
342
  }
317
343
 
@@ -320,6 +346,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
320
346
 
321
347
  // Step 3: Push messages in dependency order, consuming each stream as we go.
322
348
  for (const entry of sorted) {
349
+ const cid = await getMessageCid(entry.message);
323
350
  try {
324
351
  const reply = await agent.rpc.sendDwnRequest({
325
352
  dwnUrl,
@@ -328,16 +355,27 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
328
355
  message : entry.message
329
356
  });
330
357
 
331
- if (!syncMessageReplyIsSuccessful(reply)) {
332
- const cid = await getMessageCid(entry.message);
358
+ if (syncMessageReplyIsSuccessful(reply)) {
359
+ succeeded.push(cid);
360
+ } else if (isPermanentPushFailure(reply)) {
361
+ // Permanent failures (400/401/403) will never succeed — do NOT retry.
362
+ // These include protocol violations (RecordLimitExceeded), auth errors,
363
+ // and schema validation failures.
364
+ console.warn(`SyncEngineLevel: push permanently failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
365
+ permanentlyFailed.push(cid);
366
+ } else {
367
+ // Transient failures (5xx, etc.) — worth retrying.
333
368
  console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
369
+ failed.push(cid);
334
370
  }
335
371
  } catch (error: any) {
336
- // Preserve the original error so callers can diagnose the root cause.
337
- const detail = error.message ?? error;
338
- throw new Error(`SyncEngineLevel: push to ${dwnUrl} failed: ${detail}`);
372
+ // Network errors transient, worth retrying.
373
+ console.error(`SyncEngineLevel: push error for ${cid}: ${error.message ?? error}`);
374
+ failed.push(cid);
339
375
  }
340
376
  }
377
+
378
+ return { succeeded, failed, permanentlyFailed };
341
379
  }
342
380
 
343
381
  /**
@@ -0,0 +1,197 @@
1
+ import type { AbstractLevel } from 'abstract-level';
2
+ import type { ProgressToken } from '@enbox/dwn-sdk-js';
3
+
4
+ import type { DirectionCheckpoint, LinkStatus, ReplicationLinkState, SyncScope } from './types/sync.js';
5
+
6
+ import { computeScopeId } from './types/sync.js';
7
+
8
+ /** Separator used in compound LevelDB keys. */
9
+ const KEY_SEP = '^';
10
+
11
+ /**
12
+ * Durable replication ledger — persists {@link ReplicationLinkState} for each
13
+ * sync link in a LevelDB sublevel. Provides CRUD operations and replication
14
+ * checkpoint helpers.
15
+ *
16
+ * Key format: `{tenantDid}^{remoteEndpoint}^{scopeId}`
17
+ *
18
+ * Each link tracks independent pull and push {@link DirectionCheckpoint}s.
19
+ * The ledger does not own subscriptions or timers — it is a passive state
20
+ * store called by the sync engine.
21
+ */
22
+ export class ReplicationLedger {
23
+ private readonly db: AbstractLevel<string | Buffer | Uint8Array>;
24
+ private sublevel;
25
+
26
+ constructor(db: AbstractLevel<string | Buffer | Uint8Array>) {
27
+ this.db = db;
28
+ this.sublevel = this.db.sublevel('replicationLinks');
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Key helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Build the compound key for a link. */
36
+ private static buildKey(tenantDid: string, remoteEndpoint: string, scopeId: string): string {
37
+ return `${tenantDid}${KEY_SEP}${remoteEndpoint}${KEY_SEP}${scopeId}`;
38
+ }
39
+
40
+ // Note: compound keys use raw '^' separator. This is safe because tenantDid
41
+ // (DID URI), remoteEndpoint (URL), and scopeId (base64url hash) cannot
42
+ // contain '^'. If future fields can contain '^', keys must be escaped.
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // CRUD
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Get-or-create a link. If the link does not exist, it is created with
50
+ * `initializing` status and empty checkpoints.
51
+ */
52
+ public async getOrCreateLink(params: {
53
+ tenantDid : string;
54
+ remoteEndpoint : string;
55
+ scope : SyncScope;
56
+ delegateDid? : string;
57
+ protocol? : string;
58
+ }): Promise<ReplicationLinkState> {
59
+ const scopeId = await computeScopeId(params.scope);
60
+ const key = ReplicationLedger.buildKey(params.tenantDid, params.remoteEndpoint, scopeId);
61
+
62
+ try {
63
+ const raw = await this.sublevel.get(key);
64
+ const link = JSON.parse(raw) as ReplicationLinkState;
65
+ // connectivity is runtime state — always reset to 'unknown' on load
66
+ // so stale 'online' from a previous session doesn't give false positives.
67
+ link.connectivity = 'unknown';
68
+ return link;
69
+ } catch (error) {
70
+ const e = error as { code: string };
71
+ if (e.code !== 'LEVEL_NOT_FOUND') {
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ // Create a new link with empty checkpoints.
77
+ const link: ReplicationLinkState = {
78
+ tenantDid : params.tenantDid,
79
+ remoteEndpoint : params.remoteEndpoint,
80
+ scopeId,
81
+ scope : params.scope,
82
+ status : 'initializing',
83
+ connectivity : 'unknown',
84
+ pull : {},
85
+ push : {},
86
+ delegateDid : params.delegateDid,
87
+ protocol : params.protocol,
88
+ };
89
+
90
+ await this.sublevel.put(key, JSON.stringify(link));
91
+ return link;
92
+ }
93
+
94
+ /** Persist the current state of a link. */
95
+ public async saveLink(link: ReplicationLinkState): Promise<void> {
96
+ const key = ReplicationLedger.buildKey(link.tenantDid, link.remoteEndpoint, link.scopeId);
97
+ link.lastActivityAt = new Date().toISOString();
98
+ await this.sublevel.put(key, JSON.stringify(link));
99
+ }
100
+
101
+ /** Delete a link. */
102
+ public async deleteLink(tenantDid: string, remoteEndpoint: string, scopeId: string): Promise<void> {
103
+ const key = ReplicationLedger.buildKey(tenantDid, remoteEndpoint, scopeId);
104
+ await this.sublevel.del(key);
105
+ }
106
+
107
+ /** List all links for a tenant. */
108
+ public async getLinksForTenant(tenantDid: string): Promise<ReplicationLinkState[]> {
109
+ const prefix = `${tenantDid}${KEY_SEP}`;
110
+ const links: ReplicationLinkState[] = [];
111
+ for await (const [key, value] of this.sublevel.iterator()) {
112
+ if (key.startsWith(prefix)) {
113
+ links.push(JSON.parse(value) as ReplicationLinkState);
114
+ }
115
+ }
116
+ return links;
117
+ }
118
+
119
+ /** List all links. */
120
+ public async getAllLinks(): Promise<ReplicationLinkState[]> {
121
+ const links: ReplicationLinkState[] = [];
122
+ for await (const [, value] of this.sublevel.iterator()) {
123
+ links.push(JSON.parse(value) as ReplicationLinkState);
124
+ }
125
+ return links;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Status transitions
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** Transition a link to a new status and persist. */
133
+ public async setStatus(link: ReplicationLinkState, status: LinkStatus): Promise<void> {
134
+ link.status = status;
135
+ await this.saveLink(link);
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Minimal checkpoint helpers (durable state only — no progression logic)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Compare two tokens by position (BigInt numeric comparison).
144
+ * Returns negative if a < b, zero if equal, positive if a > b.
145
+ * Caller must verify streamId and epoch match before calling.
146
+ */
147
+ public static comparePosition(a: ProgressToken, b: ProgressToken): number {
148
+ const diff = BigInt(a.position) - BigInt(b.position);
149
+ if (diff < BigInt(0)) { return -1; }
150
+ if (diff > BigInt(0)) { return 1; }
151
+ return 0;
152
+ }
153
+
154
+ /**
155
+ * Check whether a token belongs to the same domain (streamId + epoch) as
156
+ * the checkpoint's current baseline. Returns `true` if domains match or if
157
+ * the checkpoint has no baseline yet.
158
+ */
159
+ public static validateTokenDomain(checkpoint: DirectionCheckpoint, token: ProgressToken): boolean {
160
+ if (checkpoint.contiguousAppliedToken === undefined) { return true; }
161
+ return token.streamId === checkpoint.contiguousAppliedToken.streamId &&
162
+ token.epoch === checkpoint.contiguousAppliedToken.epoch;
163
+ }
164
+
165
+ /**
166
+ * Update `receivedToken` to the highest seen token (for observability).
167
+ * Does NOT advance `contiguousAppliedToken` — that is owned by the engine's
168
+ * delivery-order tracking.
169
+ */
170
+ public static setReceivedToken(checkpoint: DirectionCheckpoint, token: ProgressToken): void {
171
+ if (
172
+ checkpoint.receivedToken === undefined ||
173
+ ReplicationLedger.comparePosition(token, checkpoint.receivedToken) > 0
174
+ ) {
175
+ checkpoint.receivedToken = token;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Commit a token as the new contiguous applied baseline. The caller (engine)
181
+ * must have already verified that all earlier delivered tokens for this link
182
+ * are durably committed before calling this.
183
+ */
184
+ public static commitContiguousToken(checkpoint: DirectionCheckpoint, token: ProgressToken): void {
185
+ checkpoint.contiguousAppliedToken = token;
186
+ }
187
+
188
+ /**
189
+ * Reset a replication checkpoint (e.g., after repair or domain change).
190
+ * Clears all state.
191
+ */
192
+ public static resetCheckpoint(checkpoint: DirectionCheckpoint, token?: ProgressToken): void {
193
+ checkpoint.contiguousAppliedToken = token;
194
+ checkpoint.receivedToken = token;
195
+ }
196
+ }
197
+
package/src/types/sync.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ProgressToken } from '@enbox/dwn-sdk-js';
2
+
1
3
  import type { EnboxPlatformAgent } from './agent.js';
2
4
 
3
5
  /**
@@ -25,6 +27,179 @@ export type SyncConnectivityState = 'online' | 'offline' | 'unknown';
25
27
  */
26
28
  export type SyncMode = 'poll' | 'live';
27
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Sync scope and scope identity
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Describes what a replication link syncs. Currently whole-tenant only
36
+ * (`kind: 'full'`). Scoped subset sync (`kind: 'protocol'` with
37
+ * `protocolPathPrefixes` / `contextIdPrefixes`) is deferred to Phase 3.
38
+ */
39
+ export type SyncScope = {
40
+ /** Scope kind. Only `'full'` is implemented in Phase 1. */
41
+ kind: 'full';
42
+ } | {
43
+ /**
44
+ * Protocol-scoped sync. Deferred to Phase 3 — included here for type
45
+ * forward-compatibility only.
46
+ */
47
+ kind: 'protocol';
48
+ protocol: string;
49
+ protocolPathPrefixes?: string[];
50
+ contextIdPrefixes?: string[];
51
+ };
52
+
53
+ /**
54
+ * Computes a deterministic, collision-resistant identifier for a {@link SyncScope}.
55
+ *
56
+ * The ID is `base64url(SHA-256(canonicalJSON))` where `canonicalJSON` is the
57
+ * scope object with keys sorted alphabetically and array values sorted
58
+ * lexicographically.
59
+ *
60
+ * Used as part of the LevelDB key for the replication ledger:
61
+ * `{tenantDid}^{remoteEndpoint}^{scopeId}`.
62
+ */
63
+ export async function computeScopeId(scope: SyncScope): Promise<string> {
64
+ const canonical: Record<string, unknown> = { kind: scope.kind };
65
+ if (scope.kind === 'protocol') {
66
+ canonical.protocol = scope.protocol;
67
+ if (scope.protocolPathPrefixes !== undefined) {
68
+ canonical.protocolPathPrefixes = [...new Set(scope.protocolPathPrefixes)].sort();
69
+ }
70
+ if (scope.contextIdPrefixes !== undefined) {
71
+ canonical.contextIdPrefixes = [...new Set(scope.contextIdPrefixes)].sort();
72
+ }
73
+ }
74
+
75
+ // Stable JSON: keys sorted by construction order (kind < protocol < protocolPathPrefixes).
76
+ const json = JSON.stringify(canonical);
77
+ const bytes = new TextEncoder().encode(json);
78
+ const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
79
+ const hashArray = new Uint8Array(hashBuffer);
80
+
81
+ // base64url encode (no padding).
82
+ let base64 = '';
83
+ for (const b of hashArray) {
84
+ base64 += String.fromCharCode(b);
85
+ }
86
+ return btoa(base64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Replication checkpoint types
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Maximum number of in-flight deliveries (runtime ordinals) a link may
95
+ * accumulate before transitioning to `repairing`. This is the overflow
96
+ * threshold for the engine's in-memory delivery tracker, not for durable
97
+ * checkpoint state. Normative per the sync redesign RFC.
98
+ */
99
+ export const MAX_PENDING_TOKENS = 100;
100
+
101
+ /**
102
+ * Tracks directional (pull or push) replay progression for a single
103
+ * replication link. All tokens belong to the same `(streamId, epoch)`.
104
+ *
105
+ * This is the **durable** replication checkpoint persisted to the ledger.
106
+ * In-memory delivery-order tracking (ordinals, in-flight commits) is owned
107
+ * by the sync engine and is not persisted — on crash recovery, replay
108
+ * restarts from `contiguousAppliedToken` and idempotent apply handles
109
+ * any re-delivered events.
110
+ */
111
+ export type DirectionCheckpoint = {
112
+ /**
113
+ * The latest token received from the source (pull) or confirmed by the
114
+ * remote (push). May be ahead of `contiguousAppliedToken` when events
115
+ * arrive out of order. Used for observability.
116
+ */
117
+ receivedToken?: ProgressToken;
118
+
119
+ /**
120
+ * The highest token such that all earlier delivered tokens for this link
121
+ * have been durably applied. This is the resume point after crash/reconnect.
122
+ *
123
+ * Advancement is controlled by the engine's delivery-order tracking,
124
+ * not by position arithmetic. Positions may be sparse (filtered streams).
125
+ */
126
+ contiguousAppliedToken?: ProgressToken;
127
+ };
128
+
129
+ /**
130
+ * Status of a replication link.
131
+ *
132
+ * - `initializing` — link created, no subscriptions open yet.
133
+ * - `live` — actively receiving events via subscription.
134
+ * - `repairing` — gap detected or pending overflow; running SMT reconciliation.
135
+ * - `degraded_poll` — subscription failed; polling at reduced frequency.
136
+ * - `paused` — explicitly paused by the application.
137
+ */
138
+ export type LinkStatus = 'initializing' | 'live' | 'repairing' | 'degraded_poll' | 'paused';
139
+
140
+ /**
141
+ * Durable state of a single replication link. Persisted to LevelDB and
142
+ * loaded on startup. Each link is identified by the tuple
143
+ * `(tenantDid, remoteEndpoint, scopeId)`.
144
+ */
145
+ export type ReplicationLinkState = {
146
+ /** The tenant DID this link syncs for. */
147
+ tenantDid: string;
148
+
149
+ /** The remote DWN endpoint URL. */
150
+ remoteEndpoint: string;
151
+
152
+ /** Deterministic hash of the {@link SyncScope}. See {@link computeScopeId}. */
153
+ scopeId: string;
154
+
155
+ /** The scope definition this link covers. */
156
+ scope: SyncScope;
157
+
158
+ /** Current link status. */
159
+ status: LinkStatus;
160
+
161
+ /** Pull-direction replication checkpoint (remote → local). */
162
+ pull: DirectionCheckpoint;
163
+
164
+ /** Push-direction replication checkpoint (local → remote). */
165
+ push: DirectionCheckpoint;
166
+
167
+ /** Per-link connectivity state. Used to compute the aggregate engine-level state. */
168
+ connectivity: SyncConnectivityState;
169
+
170
+ /** Delegate DID used to sign sync messages, if any. */
171
+ delegateDid?: string;
172
+
173
+ /**
174
+ * Protocol filter for this link, if any. Duplicates the protocol in `scope`
175
+ * for operational convenience — used by permission lookups and cursor key
176
+ * building. The scope is the source of truth for what to sync; this field
177
+ * is the source of truth for how to authenticate. To be consolidated in
178
+ * Phase 3 when scope resolution is more complex.
179
+ */
180
+ protocol?: string;
181
+
182
+ /** ISO-8601 timestamp of last successful sync activity. */
183
+ lastActivityAt?: string;
184
+ };
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Push result (per-CID outcome tracking)
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Result of a batch push operation. Replaces the previous throw-on-first-failure
192
+ * pattern so callers can advance the push replication checkpoint incrementally.
193
+ */
194
+ export type PushResult = {
195
+ /** messageCids that were accepted (202/204/409 — idempotent success). */
196
+ succeeded: string[];
197
+ /** messageCids that failed with a transient error (5xx, network) — worth retrying. */
198
+ failed: string[];
199
+ /** messageCids that failed permanently (400/401/403) — will never succeed, do NOT retry. */
200
+ permanentlyFailed: string[];
201
+ };
202
+
28
203
  /**
29
204
  * Parameters for {@link SyncEngine.startSync}.
30
205
  */
@@ -54,6 +229,28 @@ export type StartSyncParams = {
54
229
  interval?: string;
55
230
  };
56
231
 
232
+ // ---------------------------------------------------------------------------
233
+ // Sync observability events
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Events emitted by the sync engine at key state transitions.
238
+ * Consumers subscribe via `SyncEngine.on('event', handler)` and can
239
+ * hook these into metrics, logging, or UI state.
240
+ */
241
+ export type SyncEvent =
242
+ | { type: 'link:status-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: LinkStatus; to: LinkStatus }
243
+ | { type: 'link:connectivity-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: SyncConnectivityState; to: SyncConnectivityState }
244
+ | { 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 }
246
+ | { type: 'repair:started'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number }
247
+ | { type: 'repair:completed'; tenantDid: string; remoteEndpoint: string; protocol?: string }
248
+ | { type: 'repair:failed'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number; error: string }
249
+ | { type: 'degraded-poll:entered'; tenantDid: string; remoteEndpoint: string; protocol?: string }
250
+ | { type: 'gap:detected'; tenantDid: string; remoteEndpoint: string; protocol?: string; reason: string };
251
+
252
+ export type SyncEventListener = (event: SyncEvent) => void;
253
+
57
254
  export interface SyncEngine {
58
255
  /**
59
256
  * The agent that the SyncEngine is attached to.
@@ -107,6 +304,13 @@ export interface SyncEngine {
107
304
  */
108
305
  stopSync(timeout?: number): Promise<void>;
109
306
 
307
+ /**
308
+ * Subscribe to sync engine events. Returns an unsubscribe function.
309
+ * Events are emitted at key state transitions: checkpoint advancement,
310
+ * link status changes, repair, degraded_poll, gap detection.
311
+ */
312
+ on(listener: SyncEventListener): () => void;
313
+
110
314
  /**
111
315
  * Release all resources held by the sync engine (LevelDB handles, timers,
112
316
  * WebSocket subscriptions). After calling `close()`, the engine should not