@enbox/agent 0.5.9 → 0.5.11
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/dist/browser.mjs +9 -9
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/dwn-record-upgrade.js +1 -1
- package/dist/esm/dwn-record-upgrade.js.map +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +855 -0
- package/dist/esm/sync-closure-resolver.js.map +1 -0
- package/dist/esm/sync-closure-types.js +189 -0
- package/dist/esm/sync-closure-types.js.map +1 -0
- package/dist/esm/sync-engine-level.js +977 -224
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-messages.js +19 -5
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/esm/sync-replication-ledger.js +220 -0
- package/dist/esm/sync-replication-ledger.js.map +1 -0
- package/dist/esm/types/sync.js +54 -1
- package/dist/esm/types/sync.js.map +1 -1
- package/dist/types/dwn-api.d.ts.map +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/sync-closure-resolver.d.ts +19 -0
- package/dist/types/sync-closure-resolver.d.ts.map +1 -0
- package/dist/types/sync-closure-types.d.ts +122 -0
- package/dist/types/sync-closure-types.d.ts.map +1 -0
- package/dist/types/sync-engine-level.d.ts +137 -11
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +6 -1
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/sync-replication-ledger.d.ts +72 -0
- package/dist/types/sync-replication-ledger.d.ts.map +1 -0
- package/dist/types/types/sync.d.ts +188 -0
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/dwn-api.ts +2 -1
- package/src/dwn-record-upgrade.ts +1 -1
- package/src/index.ts +5 -0
- package/src/sync-closure-resolver.ts +919 -0
- package/src/sync-closure-types.ts +270 -0
- package/src/sync-engine-level.ts +1062 -255
- package/src/sync-messages.ts +21 -6
- package/src/sync-replication-ledger.ts +197 -0
- package/src/types/sync.ts +202 -0
package/src/sync-messages.ts
CHANGED
|
@@ -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';
|
|
@@ -296,6 +297,10 @@ export async function fetchRemoteMessages({ did, dwnUrl, delegateDid, protocol,
|
|
|
296
297
|
* Messages are fetched first, then sorted in dependency order (topological sort)
|
|
297
298
|
* so that initial writes come before updates, and ProtocolsConfigures come before
|
|
298
299
|
* records that reference those protocols.
|
|
300
|
+
*
|
|
301
|
+
* Returns a {@link PushResult} with per-CID outcome tracking instead of throwing
|
|
302
|
+
* on the first failure. Callers use this to advance the push checkpoint
|
|
303
|
+
* incrementally — only up to the highest contiguous success.
|
|
299
304
|
*/
|
|
300
305
|
export async function pushMessages({ did, dwnUrl, delegateDid, protocol, messageCids, agent, permissionsApi }: {
|
|
301
306
|
did: string;
|
|
@@ -305,13 +310,19 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
305
310
|
messageCids: string[];
|
|
306
311
|
agent: EnboxPlatformAgent;
|
|
307
312
|
permissionsApi: PermissionsApi;
|
|
308
|
-
}): Promise<
|
|
313
|
+
}): Promise<PushResult> {
|
|
314
|
+
const succeeded: string[] = [];
|
|
315
|
+
const failed: string[] = [];
|
|
316
|
+
|
|
309
317
|
// Step 1: Fetch all local messages (streams are pull-based, not yet consumed).
|
|
310
318
|
const fetched: SyncMessageEntry[] = [];
|
|
311
319
|
for (const messageCid of messageCids) {
|
|
312
320
|
const dwnMessage = await getLocalMessage({ author: did, messageCid, delegateDid, protocol, agent, permissionsApi });
|
|
313
321
|
if (dwnMessage) {
|
|
314
322
|
fetched.push(dwnMessage);
|
|
323
|
+
} else {
|
|
324
|
+
// Message could not be fetched locally — mark as failed.
|
|
325
|
+
failed.push(messageCid);
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
328
|
|
|
@@ -320,6 +331,7 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
320
331
|
|
|
321
332
|
// Step 3: Push messages in dependency order, consuming each stream as we go.
|
|
322
333
|
for (const entry of sorted) {
|
|
334
|
+
const cid = await getMessageCid(entry.message);
|
|
323
335
|
try {
|
|
324
336
|
const reply = await agent.rpc.sendDwnRequest({
|
|
325
337
|
dwnUrl,
|
|
@@ -328,16 +340,19 @@ export async function pushMessages({ did, dwnUrl, delegateDid, protocol, message
|
|
|
328
340
|
message : entry.message
|
|
329
341
|
});
|
|
330
342
|
|
|
331
|
-
if (
|
|
332
|
-
|
|
343
|
+
if (syncMessageReplyIsSuccessful(reply)) {
|
|
344
|
+
succeeded.push(cid);
|
|
345
|
+
} else {
|
|
333
346
|
console.error(`SyncEngineLevel: push failed for ${cid}: ${reply.status.code} ${reply.status.detail}`);
|
|
347
|
+
failed.push(cid);
|
|
334
348
|
}
|
|
335
349
|
} catch (error: any) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
throw new Error(`SyncEngineLevel: push to ${dwnUrl} failed: ${detail}`);
|
|
350
|
+
console.error(`SyncEngineLevel: push error for ${cid}: ${error.message ?? error}`);
|
|
351
|
+
failed.push(cid);
|
|
339
352
|
}
|
|
340
353
|
}
|
|
354
|
+
|
|
355
|
+
return { succeeded, failed };
|
|
341
356
|
}
|
|
342
357
|
|
|
343
358
|
/**
|
|
@@ -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,177 @@ 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 (retryable or hard error). */
|
|
198
|
+
failed: string[];
|
|
199
|
+
};
|
|
200
|
+
|
|
28
201
|
/**
|
|
29
202
|
* Parameters for {@link SyncEngine.startSync}.
|
|
30
203
|
*/
|
|
@@ -54,6 +227,28 @@ export type StartSyncParams = {
|
|
|
54
227
|
interval?: string;
|
|
55
228
|
};
|
|
56
229
|
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Sync observability events
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Events emitted by the sync engine at key state transitions.
|
|
236
|
+
* Consumers subscribe via `SyncEngine.on('event', handler)` and can
|
|
237
|
+
* hook these into metrics, logging, or UI state.
|
|
238
|
+
*/
|
|
239
|
+
export type SyncEvent =
|
|
240
|
+
| { type: 'link:status-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: LinkStatus; to: LinkStatus }
|
|
241
|
+
| { type: 'link:connectivity-change'; tenantDid: string; remoteEndpoint: string; protocol?: string; from: SyncConnectivityState; to: SyncConnectivityState }
|
|
242
|
+
| { type: 'checkpoint:pull-advance'; tenantDid: string; remoteEndpoint: string; protocol?: string; position: string; messageCid: string }
|
|
243
|
+
| { type: 'checkpoint:push-advance'; tenantDid: string; remoteEndpoint: string; protocol?: string; position: string; messageCid: string }
|
|
244
|
+
| { type: 'repair:started'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number }
|
|
245
|
+
| { type: 'repair:completed'; tenantDid: string; remoteEndpoint: string; protocol?: string }
|
|
246
|
+
| { type: 'repair:failed'; tenantDid: string; remoteEndpoint: string; protocol?: string; attempt: number; error: string }
|
|
247
|
+
| { type: 'degraded-poll:entered'; tenantDid: string; remoteEndpoint: string; protocol?: string }
|
|
248
|
+
| { type: 'gap:detected'; tenantDid: string; remoteEndpoint: string; protocol?: string; reason: string };
|
|
249
|
+
|
|
250
|
+
export type SyncEventListener = (event: SyncEvent) => void;
|
|
251
|
+
|
|
57
252
|
export interface SyncEngine {
|
|
58
253
|
/**
|
|
59
254
|
* The agent that the SyncEngine is attached to.
|
|
@@ -107,6 +302,13 @@ export interface SyncEngine {
|
|
|
107
302
|
*/
|
|
108
303
|
stopSync(timeout?: number): Promise<void>;
|
|
109
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Subscribe to sync engine events. Returns an unsubscribe function.
|
|
307
|
+
* Events are emitted at key state transitions: checkpoint advancement,
|
|
308
|
+
* link status changes, repair, degraded_poll, gap detection.
|
|
309
|
+
*/
|
|
310
|
+
on(listener: SyncEventListener): () => void;
|
|
311
|
+
|
|
110
312
|
/**
|
|
111
313
|
* Release all resources held by the sync engine (LevelDB handles, timers,
|
|
112
314
|
* WebSocket subscriptions). After calling `close()`, the engine should not
|