@enbox/agent 0.7.7 → 0.7.8
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 +3 -2
- package/dist/esm/dwn-api.js.map +1 -1
- package/dist/esm/enbox-connect-protocol.js +5 -5
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/permissions-api.js +7 -34
- package/dist/esm/permissions-api.js.map +1 -1
- package/dist/esm/sync-closure-resolver.js +229 -110
- package/dist/esm/sync-closure-resolver.js.map +1 -1
- package/dist/esm/sync-closure-types.js +24 -7
- package/dist/esm/sync-closure-types.js.map +1 -1
- package/dist/esm/sync-engine-level.js +1961 -764
- package/dist/esm/sync-engine-level.js.map +1 -1
- package/dist/esm/sync-link-id.js +4 -13
- package/dist/esm/sync-link-id.js.map +1 -1
- package/dist/esm/sync-link-reconciler.js +26 -8
- package/dist/esm/sync-link-reconciler.js.map +1 -1
- package/dist/esm/sync-messages.js +218 -154
- package/dist/esm/sync-messages.js.map +1 -1
- package/dist/esm/sync-permission-grants.js +208 -0
- package/dist/esm/sync-permission-grants.js.map +1 -0
- package/dist/esm/sync-replication-ledger.js +23 -40
- package/dist/esm/sync-replication-ledger.js.map +1 -1
- package/dist/esm/sync-scope-acceptance.js +126 -0
- package/dist/esm/sync-scope-acceptance.js.map +1 -0
- package/dist/esm/sync-topological-sort.js +57 -15
- package/dist/esm/sync-topological-sort.js.map +1 -1
- package/dist/esm/types/sync.js +130 -22
- 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 +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/permissions-api.d.ts +1 -2
- package/dist/types/permissions-api.d.ts.map +1 -1
- package/dist/types/sync-closure-resolver.d.ts.map +1 -1
- package/dist/types/sync-closure-types.d.ts +14 -3
- package/dist/types/sync-closure-types.d.ts.map +1 -1
- package/dist/types/sync-engine-level.d.ts +127 -25
- package/dist/types/sync-engine-level.d.ts.map +1 -1
- package/dist/types/sync-link-id.d.ts +3 -9
- package/dist/types/sync-link-id.d.ts.map +1 -1
- package/dist/types/sync-link-reconciler.d.ts +12 -2
- package/dist/types/sync-link-reconciler.d.ts.map +1 -1
- package/dist/types/sync-messages.d.ts +16 -13
- package/dist/types/sync-messages.d.ts.map +1 -1
- package/dist/types/sync-permission-grants.d.ts +52 -0
- package/dist/types/sync-permission-grants.d.ts.map +1 -0
- package/dist/types/sync-replication-ledger.d.ts +5 -13
- package/dist/types/sync-replication-ledger.d.ts.map +1 -1
- package/dist/types/sync-scope-acceptance.d.ts +28 -0
- package/dist/types/sync-scope-acceptance.d.ts.map +1 -0
- package/dist/types/sync-topological-sort.d.ts +2 -1
- package/dist/types/sync-topological-sort.d.ts.map +1 -1
- package/dist/types/types/permissions.d.ts +2 -0
- package/dist/types/types/permissions.d.ts.map +1 -1
- package/dist/types/types/sync.d.ts +137 -75
- package/dist/types/types/sync.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/dwn-api.ts +3 -2
- package/src/enbox-connect-protocol.ts +5 -5
- package/src/index.ts +10 -1
- package/src/permissions-api.ts +11 -42
- package/src/sync-closure-resolver.ts +306 -126
- package/src/sync-closure-types.ts +38 -9
- package/src/sync-engine-level.ts +2560 -797
- package/src/sync-link-id.ts +9 -14
- package/src/sync-link-reconciler.ts +43 -10
- package/src/sync-messages.ts +263 -159
- package/src/sync-permission-grants.ts +297 -0
- package/src/sync-replication-ledger.ts +55 -50
- package/src/sync-scope-acceptance.ts +186 -0
- package/src/sync-topological-sort.ts +89 -21
- package/src/types/permissions.ts +2 -0
- package/src/types/sync.ts +235 -62
|
@@ -1,14 +1,71 @@
|
|
|
1
|
-
import type { GenericMessage } from '@enbox/dwn-sdk-js';
|
|
1
|
+
import type { GenericMessage, ProtocolDefinition } from '@enbox/dwn-sdk-js';
|
|
2
2
|
|
|
3
|
+
import { getInvokedPermissionGrantIds } from './sync-permission-grants.js';
|
|
3
4
|
import { DwnInterfaceName, DwnMethodName, PermissionsProtocol } from '@enbox/dwn-sdk-js';
|
|
4
5
|
|
|
6
|
+
type DescriptorWithRecordId = GenericMessage['descriptor'] & {
|
|
7
|
+
recordId?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type RecordsDescriptor = GenericMessage['descriptor'] & {
|
|
11
|
+
dateCreated?: string;
|
|
12
|
+
parentId?: string;
|
|
13
|
+
protocol?: string;
|
|
14
|
+
protocolPath?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ProtocolsConfigureDescriptor = GenericMessage['descriptor'] & {
|
|
18
|
+
definition?: Partial<ProtocolDefinition>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function isRecordsDescriptor(descriptor: GenericMessage['descriptor']): descriptor is RecordsDescriptor {
|
|
22
|
+
return descriptor.interface === DwnInterfaceName.Records;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecordsWriteDescriptor(descriptor: GenericMessage['descriptor']): descriptor is RecordsDescriptor {
|
|
26
|
+
return descriptor.interface === DwnInterfaceName.Records && descriptor.method === DwnMethodName.Write;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecordsDeleteDescriptor(descriptor: GenericMessage['descriptor']): descriptor is DescriptorWithRecordId {
|
|
30
|
+
return descriptor.interface === DwnInterfaceName.Records && descriptor.method === DwnMethodName.Delete;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isProtocolsConfigureDescriptor(
|
|
34
|
+
descriptor: GenericMessage['descriptor']
|
|
35
|
+
): descriptor is ProtocolsConfigureDescriptor {
|
|
36
|
+
return descriptor.interface === DwnInterfaceName.Protocols && descriptor.method === DwnMethodName.Configure;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getRecordId(message: GenericMessage): string | undefined {
|
|
40
|
+
return (message as GenericMessage & { recordId?: string }).recordId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getProtocolDefinition(message: GenericMessage): Partial<ProtocolDefinition> | undefined {
|
|
44
|
+
const { descriptor } = message;
|
|
45
|
+
return isProtocolsConfigureDescriptor(descriptor) ? descriptor.definition : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getConfiguredProtocol(message: GenericMessage): string | undefined {
|
|
49
|
+
const protocol = getProtocolDefinition(message)?.protocol;
|
|
50
|
+
return typeof protocol === 'string' ? protocol : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getComposedProtocolDependencies(message: GenericMessage): string[] {
|
|
54
|
+
const uses = getProtocolDefinition(message)?.uses;
|
|
55
|
+
if (!uses) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Object.values(uses).filter((protocol): protocol is string => typeof protocol === 'string');
|
|
60
|
+
}
|
|
61
|
+
|
|
5
62
|
/**
|
|
6
63
|
* Checks whether a message is an initial RecordsWrite (not an update).
|
|
7
64
|
* An initial write has dateCreated === messageTimestamp (first write for this recordId).
|
|
8
65
|
*/
|
|
9
66
|
function isInitialWrite(message: GenericMessage): boolean {
|
|
10
|
-
const desc = message.descriptor
|
|
11
|
-
if (desc
|
|
67
|
+
const desc = message.descriptor;
|
|
68
|
+
if (!isRecordsWriteDescriptor(desc)) {
|
|
12
69
|
return false;
|
|
13
70
|
}
|
|
14
71
|
// A RecordsWrite is initial if dateCreated === messageTimestamp (first write for this recordId).
|
|
@@ -21,9 +78,10 @@ function isInitialWrite(message: GenericMessage): boolean {
|
|
|
21
78
|
*
|
|
22
79
|
* Dependencies:
|
|
23
80
|
* - ProtocolsConfigure must come before any RecordsWrite using that protocol
|
|
81
|
+
* - Composed ProtocolsConfigure must come after ProtocolsConfigure messages for protocols in `uses`
|
|
24
82
|
* - Parent record must come before child record (via parentId)
|
|
25
83
|
* - Initial write must come before update writes (same recordId, not initial)
|
|
26
|
-
* - Permission
|
|
84
|
+
* - Permission grants must come before messages that invoke them
|
|
27
85
|
*/
|
|
28
86
|
export function topologicalSort<T extends { message: GenericMessage }>(
|
|
29
87
|
messages: T[]
|
|
@@ -44,14 +102,14 @@ export function topologicalSort<T extends { message: GenericMessage }>(
|
|
|
44
102
|
const desc = entry.message.descriptor;
|
|
45
103
|
|
|
46
104
|
if (desc.interface === DwnInterfaceName.Protocols && desc.method === DwnMethodName.Configure) {
|
|
47
|
-
const protocolUrl = (
|
|
105
|
+
const protocolUrl = getConfiguredProtocol(entry.message);
|
|
48
106
|
if (protocolUrl) {
|
|
49
107
|
protocolConfigureIndex.set(protocolUrl, i);
|
|
50
108
|
}
|
|
51
109
|
}
|
|
52
110
|
|
|
53
|
-
if (desc
|
|
54
|
-
const recordId = (entry.message
|
|
111
|
+
if (isRecordsWriteDescriptor(desc)) {
|
|
112
|
+
const recordId = getRecordId(entry.message);
|
|
55
113
|
const initial = isInitialWrite(entry.message);
|
|
56
114
|
if (initial && recordId) {
|
|
57
115
|
initialWriteIndex.set(recordId, i);
|
|
@@ -59,8 +117,8 @@ export function topologicalSort<T extends { message: GenericMessage }>(
|
|
|
59
117
|
|
|
60
118
|
// Index permission grants by recordId so dependents can reference them.
|
|
61
119
|
if (
|
|
62
|
-
|
|
63
|
-
|
|
120
|
+
desc.protocol === PermissionsProtocol.uri &&
|
|
121
|
+
desc.protocolPath === PermissionsProtocol.grantPath &&
|
|
64
122
|
recordId
|
|
65
123
|
) {
|
|
66
124
|
grantIndex.set(recordId, i);
|
|
@@ -89,42 +147,52 @@ export function topologicalSort<T extends { message: GenericMessage }>(
|
|
|
89
147
|
for (let i = 0; i < messages.length; i++) {
|
|
90
148
|
const desc = messages[i].message.descriptor;
|
|
91
149
|
|
|
150
|
+
// Composition dependency: a composed protocol depends on its referenced protocol definitions.
|
|
151
|
+
if (isProtocolsConfigureDescriptor(desc)) {
|
|
152
|
+
for (const protocol of getComposedProtocolDependencies(messages[i].message)) {
|
|
153
|
+
if (protocolConfigureIndex.has(protocol)) {
|
|
154
|
+
addEdge(protocolConfigureIndex.get(protocol)!, i);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
92
159
|
// Protocol dependency: RecordsWrite depends on ProtocolsConfigure for its protocol.
|
|
93
|
-
if (desc
|
|
94
|
-
const protocol =
|
|
160
|
+
if (isRecordsDescriptor(desc)) {
|
|
161
|
+
const protocol = desc.protocol;
|
|
95
162
|
if (protocol && protocolConfigureIndex.has(protocol)) {
|
|
96
163
|
addEdge(protocolConfigureIndex.get(protocol)!, i);
|
|
97
164
|
}
|
|
98
165
|
}
|
|
99
166
|
|
|
100
167
|
// Parent dependency: child record depends on parent record.
|
|
101
|
-
if (desc
|
|
102
|
-
const parentId =
|
|
168
|
+
if (isRecordsDescriptor(desc) && desc.parentId) {
|
|
169
|
+
const parentId = desc.parentId;
|
|
103
170
|
if (initialWriteIndex.has(parentId)) {
|
|
104
171
|
addEdge(initialWriteIndex.get(parentId)!, i);
|
|
105
172
|
}
|
|
106
173
|
}
|
|
107
174
|
|
|
108
175
|
// Initial write dependency: update depends on initial write.
|
|
109
|
-
if (desc
|
|
110
|
-
const recordId = (messages[i].message
|
|
176
|
+
if (isRecordsWriteDescriptor(desc)) {
|
|
177
|
+
const recordId = getRecordId(messages[i].message);
|
|
111
178
|
if (recordId && !isInitialWrite(messages[i].message) && initialWriteIndex.has(recordId)) {
|
|
112
179
|
addEdge(initialWriteIndex.get(recordId)!, i);
|
|
113
180
|
}
|
|
114
181
|
}
|
|
115
182
|
|
|
116
183
|
// Delete depends on initial write.
|
|
117
|
-
if (desc
|
|
118
|
-
const recordId =
|
|
184
|
+
if (isRecordsDeleteDescriptor(desc)) {
|
|
185
|
+
const recordId = desc.recordId;
|
|
119
186
|
if (recordId && initialWriteIndex.has(recordId)) {
|
|
120
187
|
addEdge(initialWriteIndex.get(recordId)!, i);
|
|
121
188
|
}
|
|
122
189
|
}
|
|
123
190
|
|
|
124
|
-
// Permission grant dependency: message depends on
|
|
125
|
-
const permissionGrantId
|
|
126
|
-
|
|
127
|
-
|
|
191
|
+
// Permission grant dependency: message depends on each grant it references.
|
|
192
|
+
for (const permissionGrantId of getInvokedPermissionGrantIds(messages[i].message)) {
|
|
193
|
+
if (grantIndex.has(permissionGrantId)) {
|
|
194
|
+
addEdge(grantIndex.get(permissionGrantId)!, i);
|
|
195
|
+
}
|
|
128
196
|
}
|
|
129
197
|
}
|
|
130
198
|
|
package/src/types/permissions.ts
CHANGED
package/src/types/sync.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import type { ProgressToken } from '@enbox/dwn-sdk-js';
|
|
1
|
+
import type { NormalizedRecordsProjectionScope, ProgressToken, RecordsProjectionScope } from '@enbox/dwn-sdk-js';
|
|
2
2
|
|
|
3
3
|
import type { EnboxPlatformAgent } from './agent.js';
|
|
4
4
|
|
|
5
|
+
import { RECORDS_PROJECTION_ROOT_VERSION, RecordsProjection } from '@enbox/dwn-sdk-js';
|
|
6
|
+
|
|
7
|
+
/** Deterministic bytewise string comparator for hash inputs and canonical IDs. */
|
|
8
|
+
export function lexicographicalCompare(a: string, b: string): number {
|
|
9
|
+
if (a > b) { return 1; }
|
|
10
|
+
if (a < b) { return -1; }
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
/**
|
|
6
15
|
* The SyncEngine is responsible for syncing messages between the agent and the platform.
|
|
7
16
|
*/
|
|
@@ -14,6 +23,10 @@ export type SyncIdentityOptions = {
|
|
|
14
23
|
* The protocols that should be synced for this identity.
|
|
15
24
|
* - `'all'` — sync all protocols (full replica).
|
|
16
25
|
* - `string[]` — sync only the listed protocol URIs.
|
|
26
|
+
*
|
|
27
|
+
* Composed protocols are not expanded automatically. If a protocol definition
|
|
28
|
+
* declares `uses`, include every referenced protocol that the local DWN must
|
|
29
|
+
* install or use while applying that projection.
|
|
17
30
|
*/
|
|
18
31
|
protocols: 'all' | [string, ...string[]];
|
|
19
32
|
};
|
|
@@ -34,64 +47,204 @@ export type SyncMode = 'poll' | 'live';
|
|
|
34
47
|
// ---------------------------------------------------------------------------
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
50
|
+
* Projection-root algorithm used by StateIndex full/protocol sync scopes.
|
|
51
|
+
*
|
|
52
|
+
* Sync compares either the existing full-tenant StateIndex root or existing
|
|
53
|
+
* per-protocol StateIndex roots. Records-primary projection scopes use the
|
|
54
|
+
* DWN SDK's `RECORDS_PROJECTION_ROOT_VERSION` instead.
|
|
55
|
+
*/
|
|
56
|
+
export const SYNC_PROJECTION_ROOT_VERSION = 'state-index-full-protocol-root-v1';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Authorization-epoch algorithm used by delegated Messages.Read sync links.
|
|
60
|
+
*
|
|
61
|
+
* The authorization epoch is separate from the projection ID: re-granting the
|
|
62
|
+
* same scope or changing delegate grants must invalidate in-flight work without
|
|
63
|
+
* changing the primary CID set being compared.
|
|
64
|
+
*/
|
|
65
|
+
export const SYNC_AUTHORIZATION_EPOCH_VERSION = 'messages-read-grants-v1';
|
|
66
|
+
|
|
67
|
+
/** A non-empty, sorted, duplicate-free string list. */
|
|
68
|
+
export type NonEmptyStringArray = [string, ...string[]];
|
|
69
|
+
|
|
70
|
+
/** A non-empty, sorted, duplicate-free, subsumption-reduced Records projection scope union. */
|
|
71
|
+
export type NonEmptyRecordsProjectionScopes = [NormalizedRecordsProjectionScope, ...NormalizedRecordsProjectionScope[]];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Describes the primary CID set a replication link syncs.
|
|
75
|
+
*
|
|
76
|
+
* Full and protocol-set sync use the existing StateIndex roots. Records
|
|
77
|
+
* projections use the `records-primary-scope-root-v1` root over latest Records
|
|
78
|
+
* primary messages selected by protocol, exact protocolPath, or context
|
|
79
|
+
* subtree. For composed protocols, the scope must include the composed protocol
|
|
80
|
+
* and any `uses` targets required for local protocol installation and closure
|
|
81
|
+
* evaluation.
|
|
40
82
|
*/
|
|
41
83
|
export type SyncScope = {
|
|
42
|
-
/**
|
|
84
|
+
/** Full-tenant projection. Valid only for owner sync or unscoped delegated grants. */
|
|
43
85
|
kind: 'full';
|
|
44
86
|
} | {
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
contextIdPrefixes?: string[];
|
|
87
|
+
/** Protocol-set projection over one or more protocol roots. */
|
|
88
|
+
kind: 'protocolSet';
|
|
89
|
+
protocols: NonEmptyStringArray;
|
|
90
|
+
} | {
|
|
91
|
+
/** Records-primary projected root over protocol/path/context scope entries. */
|
|
92
|
+
kind: 'recordsProjection';
|
|
93
|
+
scopes: NonEmptyRecordsProjectionScopes;
|
|
53
94
|
};
|
|
54
95
|
|
|
55
96
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* The ID is `base64url(SHA-256(canonicalJSON))` where `canonicalJSON` is the
|
|
59
|
-
* scope object with keys sorted alphabetically and array values sorted
|
|
60
|
-
* lexicographically.
|
|
61
|
-
*
|
|
62
|
-
* Used as part of the LevelDB key for the replication ledger:
|
|
63
|
-
* `{tenantDid}^{remoteEndpoint}^{scopeId}`.
|
|
97
|
+
* Authorization context for a link. Owner links do not invoke grants. Delegate
|
|
98
|
+
* links carry the active Messages.Read grants that authorize the scope union.
|
|
64
99
|
*/
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
export type SyncAuthorization =
|
|
101
|
+
| { kind: 'owner' }
|
|
102
|
+
| {
|
|
103
|
+
kind: 'delegate';
|
|
104
|
+
delegateDid: string;
|
|
105
|
+
permissionGrantIds: NonEmptyStringArray;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Grant metadata that participates in delegated authorization-epoch hashing. */
|
|
109
|
+
export type SyncAuthorizationGrant = {
|
|
110
|
+
id: string;
|
|
111
|
+
dateExpires: string;
|
|
112
|
+
dateGranted?: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Normalizes a protocol list into canonical scope-union order.
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeSyncProtocols(protocols: [string, ...string[]] | string[]): NonEmptyStringArray {
|
|
119
|
+
const unique = [...new Set(protocols)].sort(lexicographicalCompare);
|
|
120
|
+
if (unique.length === 0) {
|
|
121
|
+
throw new Error('SyncScope: protocol-set scope requires at least one protocol URI.');
|
|
75
122
|
}
|
|
123
|
+
return unique as NonEmptyStringArray;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Converts persisted identity options into the canonical sync scope. */
|
|
127
|
+
export function syncScopeFromProtocols(protocols: SyncIdentityOptions['protocols']): SyncScope {
|
|
128
|
+
return protocols === 'all'
|
|
129
|
+
? { kind: 'full' }
|
|
130
|
+
: { kind: 'protocolSet', protocols: normalizeSyncProtocols(protocols) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Converts Records projection scopes into the canonical sync scope shape. */
|
|
134
|
+
export function syncScopeFromRecordsProjectionScopes(
|
|
135
|
+
scopes: readonly [RecordsProjectionScope, ...RecordsProjectionScope[]],
|
|
136
|
+
): Extract<SyncScope, { kind: 'recordsProjection' }> {
|
|
137
|
+
return {
|
|
138
|
+
kind : 'recordsProjection',
|
|
139
|
+
scopes : RecordsProjection.normalizeScopes(scopes),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Returns the protocol list covered by a scope, or `undefined` for full scope. */
|
|
144
|
+
export function protocolsForSyncScope(scope: SyncScope): NonEmptyStringArray | undefined {
|
|
145
|
+
if (scope.kind === 'full') {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (scope.kind === 'protocolSet') {
|
|
150
|
+
return scope.protocols;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return normalizeSyncProtocols(scope.scopes.map(scope => scope.protocol));
|
|
154
|
+
}
|
|
76
155
|
|
|
77
|
-
|
|
78
|
-
|
|
156
|
+
/** Returns the single protocol root covered by a protocol-set scope, if any. */
|
|
157
|
+
export function singleProtocolForSyncScope(scope: SyncScope): string | undefined {
|
|
158
|
+
return scope.kind === 'protocolSet' && scope.protocols.length === 1 ? scope.protocols[0] : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Stable base64url SHA-256 hash for canonical JSON objects. */
|
|
162
|
+
async function hashCanonicalObject(value: Record<string, unknown>): Promise<string> {
|
|
163
|
+
const json = JSON.stringify(value);
|
|
79
164
|
const bytes = new TextEncoder().encode(json);
|
|
80
165
|
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
|
81
166
|
const hashArray = new Uint8Array(hashBuffer);
|
|
82
167
|
|
|
83
|
-
// base64url encode (no padding).
|
|
84
168
|
let base64 = '';
|
|
85
169
|
for (const b of hashArray) {
|
|
86
170
|
base64 += String.fromCharCode(b);
|
|
87
171
|
}
|
|
88
172
|
const result = btoa(base64).replaceAll('+', '-').replaceAll('/', '_');
|
|
89
|
-
// Strip trailing '=' padding without regex quantifiers (avoids ReDoS scanners).
|
|
90
173
|
let end = result.length;
|
|
91
|
-
while (end > 0 && result.codePointAt(end - 1) === 61) { end--; }
|
|
174
|
+
while (end > 0 && result.codePointAt(end - 1) === 61) { end--; }
|
|
92
175
|
return end === result.length ? result : result.slice(0, end);
|
|
93
176
|
}
|
|
94
177
|
|
|
178
|
+
/** Returns a canonical JSON-ready representation of a sync scope. */
|
|
179
|
+
export function canonicalizeSyncScope(scope: SyncScope): SyncScope {
|
|
180
|
+
if (scope.kind === 'full') {
|
|
181
|
+
return { kind: 'full' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (scope.kind === 'protocolSet') {
|
|
185
|
+
return { kind: 'protocolSet', protocols: normalizeSyncProtocols(scope.protocols) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
kind : 'recordsProjection',
|
|
190
|
+
scopes : RecordsProjection.normalizeScopes(scope.scopes),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Computes a deterministic, collision-resistant projection ID.
|
|
196
|
+
*
|
|
197
|
+
* The projection ID is derived from tenant DID, normalized scope, and the
|
|
198
|
+
* projection-root algorithm version. It intentionally excludes endpoint,
|
|
199
|
+
* grant IDs, authorization epoch, and remote diff contents.
|
|
200
|
+
*/
|
|
201
|
+
export async function computeProjectionId(tenantDid: string, scope: SyncScope): Promise<string> {
|
|
202
|
+
const canonicalScope = canonicalizeSyncScope(scope);
|
|
203
|
+
const version = canonicalScope.kind === 'recordsProjection'
|
|
204
|
+
? RECORDS_PROJECTION_ROOT_VERSION
|
|
205
|
+
: SYNC_PROJECTION_ROOT_VERSION;
|
|
206
|
+
|
|
207
|
+
return hashCanonicalObject({
|
|
208
|
+
scope: canonicalScope,
|
|
209
|
+
tenantDid,
|
|
210
|
+
version,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Computes the authorization epoch for a link.
|
|
216
|
+
*
|
|
217
|
+
* Owner epochs are stable for the owner projection. Delegate epochs are derived
|
|
218
|
+
* from the delegate DID plus the active grant IDs and expiry metadata. A changed
|
|
219
|
+
* grant set creates a new link key even when the projection ID is unchanged.
|
|
220
|
+
*/
|
|
221
|
+
export async function computeAuthorizationEpoch(input:
|
|
222
|
+
| { kind: 'owner' }
|
|
223
|
+
| { kind: 'delegate'; delegateDid: string; grants: [SyncAuthorizationGrant, ...SyncAuthorizationGrant[]] }
|
|
224
|
+
): Promise<string> {
|
|
225
|
+
if (input.kind === 'owner') {
|
|
226
|
+
return hashCanonicalObject({
|
|
227
|
+
kind : 'owner',
|
|
228
|
+
version : SYNC_AUTHORIZATION_EPOCH_VERSION,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const grants = [...input.grants]
|
|
233
|
+
.sort((a, b) => lexicographicalCompare(a.id, b.id))
|
|
234
|
+
.map(grant => ({
|
|
235
|
+
dateExpires : grant.dateExpires,
|
|
236
|
+
dateGranted : grant.dateGranted,
|
|
237
|
+
id : grant.id,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
return hashCanonicalObject({
|
|
241
|
+
delegateDid : input.delegateDid,
|
|
242
|
+
grants,
|
|
243
|
+
kind : 'delegate',
|
|
244
|
+
version : SYNC_AUTHORIZATION_EPOCH_VERSION,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
95
248
|
// ---------------------------------------------------------------------------
|
|
96
249
|
// Replication checkpoint types
|
|
97
250
|
// ---------------------------------------------------------------------------
|
|
@@ -137,16 +290,18 @@ export type DirectionCheckpoint = {
|
|
|
137
290
|
*
|
|
138
291
|
* - `initializing` — link created, no subscriptions open yet.
|
|
139
292
|
* - `live` — actively receiving events via subscription.
|
|
293
|
+
* - `polling` — current link is reconciled by periodic SMT sync; live subscription is not supported for its scope.
|
|
140
294
|
* - `repairing` — gap detected or pending overflow; running SMT reconciliation.
|
|
141
295
|
* - `degraded_poll` — subscription failed; polling at reduced frequency.
|
|
296
|
+
* - `terminal_incomplete` — closure failed with a terminal dependency error; requires a new scope/authorization epoch.
|
|
142
297
|
* - `paused` — explicitly paused by the application.
|
|
143
298
|
*/
|
|
144
|
-
export type LinkStatus = 'initializing' | 'live' | 'repairing' | 'degraded_poll' | 'paused';
|
|
299
|
+
export type LinkStatus = 'initializing' | 'live' | 'polling' | 'repairing' | 'degraded_poll' | 'terminal_incomplete' | 'paused';
|
|
145
300
|
|
|
146
301
|
/**
|
|
147
302
|
* Durable state of a single replication link. Persisted to LevelDB and
|
|
148
303
|
* loaded on startup. Each link is identified by the tuple
|
|
149
|
-
* `(tenantDid, remoteEndpoint,
|
|
304
|
+
* `(tenantDid, remoteEndpoint, projectionId, authorizationEpoch)`.
|
|
150
305
|
*/
|
|
151
306
|
export type ReplicationLinkState = {
|
|
152
307
|
/** The tenant DID this link syncs for. */
|
|
@@ -155,12 +310,18 @@ export type ReplicationLinkState = {
|
|
|
155
310
|
/** The remote DWN endpoint URL. */
|
|
156
311
|
remoteEndpoint: string;
|
|
157
312
|
|
|
158
|
-
/** Deterministic hash of
|
|
159
|
-
|
|
313
|
+
/** Deterministic hash of tenant DID, normalized scope, and root algorithm version. */
|
|
314
|
+
projectionId: string;
|
|
315
|
+
|
|
316
|
+
/** Deterministic hash of owner/delegate grant context for this link. */
|
|
317
|
+
authorizationEpoch: string;
|
|
160
318
|
|
|
161
319
|
/** The scope definition this link covers. */
|
|
162
320
|
scope: SyncScope;
|
|
163
321
|
|
|
322
|
+
/** Owner/delegate authorization context used to sign sync messages. */
|
|
323
|
+
authorization: SyncAuthorization;
|
|
324
|
+
|
|
164
325
|
/** Current link status. */
|
|
165
326
|
status: LinkStatus;
|
|
166
327
|
|
|
@@ -181,15 +342,6 @@ export type ReplicationLinkState = {
|
|
|
181
342
|
/** Delegate DID used to sign sync messages, if any. */
|
|
182
343
|
delegateDid?: string;
|
|
183
344
|
|
|
184
|
-
/**
|
|
185
|
-
* Protocol filter for this link, if any. Duplicates the protocol in `scope`
|
|
186
|
-
* for operational convenience — used by permission lookups and cursor key
|
|
187
|
-
* building. The scope is the source of truth for what to sync; this field
|
|
188
|
-
* is the source of truth for how to authenticate. To be consolidated in
|
|
189
|
-
* Phase 3 when scope resolution is more complex.
|
|
190
|
-
*/
|
|
191
|
-
protocol?: string;
|
|
192
|
-
|
|
193
345
|
/** ISO-8601 timestamp of last successful sync activity. */
|
|
194
346
|
lastActivityAt?: string;
|
|
195
347
|
};
|
|
@@ -230,7 +382,7 @@ export type StartSyncParams = {
|
|
|
230
382
|
* push. Falls back to SMT reconciliation on cold start or long disconnect.
|
|
231
383
|
* An infrequent SMT integrity check still runs at `interval`.
|
|
232
384
|
*
|
|
233
|
-
* - `'poll'`:
|
|
385
|
+
* - `'poll'`: Performs a full SMT set-reconciliation sync on a
|
|
234
386
|
* fixed interval. No WebSocket subscriptions are used.
|
|
235
387
|
*/
|
|
236
388
|
mode?: SyncMode;
|
|
@@ -251,22 +403,35 @@ export type StartSyncParams = {
|
|
|
251
403
|
// Sync observability events
|
|
252
404
|
// ---------------------------------------------------------------------------
|
|
253
405
|
|
|
406
|
+
/** Sync scope metadata attached to observability events. */
|
|
407
|
+
export type SyncEventScope = {
|
|
408
|
+
/** Present only when the event belongs to a single-protocol link. */
|
|
409
|
+
protocol?: string;
|
|
410
|
+
/** Present when the event belongs to a protocol-set link. */
|
|
411
|
+
protocols?: NonEmptyStringArray;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
type SyncEventBase = {
|
|
415
|
+
tenantDid: string;
|
|
416
|
+
remoteEndpoint: string;
|
|
417
|
+
} & SyncEventScope;
|
|
418
|
+
|
|
254
419
|
/**
|
|
255
420
|
* Events emitted by the sync engine at key state transitions.
|
|
256
421
|
* Consumers subscribe via `SyncEngine.on('event', handler)` and can
|
|
257
422
|
* hook these into metrics, logging, or UI state.
|
|
258
423
|
*/
|
|
259
424
|
export type SyncEvent =
|
|
260
|
-
| { type: 'link:status-change';
|
|
261
|
-
| { type: 'link:connectivity-change';
|
|
262
|
-
| { type: 'checkpoint:pull-advance';
|
|
263
|
-
| { type: 'reconcile:needed';
|
|
264
|
-
| { type: 'reconcile:completed'
|
|
265
|
-
| { type: 'repair:started';
|
|
266
|
-
| { type: 'repair:completed'
|
|
267
|
-
| { type: 'repair:failed';
|
|
268
|
-
| { type: 'degraded-poll:entered'
|
|
269
|
-
| { type: 'gap:detected';
|
|
425
|
+
| SyncEventBase & { type: 'link:status-change'; from: LinkStatus; to: LinkStatus }
|
|
426
|
+
| SyncEventBase & { type: 'link:connectivity-change'; from: SyncConnectivityState; to: SyncConnectivityState }
|
|
427
|
+
| SyncEventBase & { type: 'checkpoint:pull-advance'; position: string; messageCid: string }
|
|
428
|
+
| SyncEventBase & { type: 'reconcile:needed'; reason: string }
|
|
429
|
+
| SyncEventBase & { type: 'reconcile:completed' }
|
|
430
|
+
| SyncEventBase & { type: 'repair:started'; attempt: number }
|
|
431
|
+
| SyncEventBase & { type: 'repair:completed' }
|
|
432
|
+
| SyncEventBase & { type: 'repair:failed'; attempt: number; error: string }
|
|
433
|
+
| SyncEventBase & { type: 'degraded-poll:entered' }
|
|
434
|
+
| SyncEventBase & { type: 'gap:detected'; reason: string };
|
|
270
435
|
|
|
271
436
|
export type SyncEventListener = (event: SyncEvent) => void;
|
|
272
437
|
|
|
@@ -275,7 +440,7 @@ export type SyncEventListener = (event: SyncEvent) => void;
|
|
|
275
440
|
// ---------------------------------------------------------------------------
|
|
276
441
|
|
|
277
442
|
/** Category of sync failure for dead letter entries. */
|
|
278
|
-
export type DeadLetterCategory = 'push-permanent' | 'push-exhausted' | 'pull-processing' | 'closure';
|
|
443
|
+
export type DeadLetterCategory = 'push-permanent' | 'push-exhausted' | 'pull-processing' | 'pull-scope-rejected' | 'closure';
|
|
279
444
|
|
|
280
445
|
/** A message that permanently failed to sync. */
|
|
281
446
|
export type DeadLetterEntry = {
|
|
@@ -312,8 +477,16 @@ export type SyncHealthSummary = {
|
|
|
312
477
|
* the engine self-heals — entries are auto-cleared on later success.
|
|
313
478
|
*/
|
|
314
479
|
failedMessageCount: number;
|
|
315
|
-
/**
|
|
480
|
+
/**
|
|
481
|
+
* Number of current closure failures. A link can have matching sync roots
|
|
482
|
+
* while still being unusable because required dependencies are missing; this
|
|
483
|
+
* count keeps that state visible to callers.
|
|
484
|
+
*/
|
|
485
|
+
closureFailureCount: number;
|
|
486
|
+
/** Number of current sync links in 'repairing', 'degraded_poll', or terminal-incomplete status. */
|
|
316
487
|
degradedLinkCount: number;
|
|
488
|
+
/** True only when there are no failed messages and no degraded links. */
|
|
489
|
+
syncHealthy: boolean;
|
|
317
490
|
};
|
|
318
491
|
|
|
319
492
|
export interface SyncEngine {
|