@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.
- 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 +956 -37
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-messages.js +42 -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 -2
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +15 -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 +190 -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 +1041 -45
- package/src/sync-messages.ts +44 -6
- package/src/sync-replication-ledger.ts +197 -0
- package/src/types/sync.ts +204 -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';
|
|
@@ -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<
|
|
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 (
|
|
332
|
-
|
|
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
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
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
|