@enbox/dwn-sdk-js 0.2.0 → 0.2.2

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 (29) hide show
  1. package/dist/browser.mjs +8 -8
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/generated/precompiled-validators.js +164 -52
  4. package/dist/esm/generated/precompiled-validators.js.map +1 -1
  5. package/dist/esm/src/handlers/messages-sync.js +231 -0
  6. package/dist/esm/src/handlers/messages-sync.js.map +1 -1
  7. package/dist/esm/src/handlers/protocols-configure.js +9 -0
  8. package/dist/esm/src/handlers/protocols-configure.js.map +1 -1
  9. package/dist/esm/src/interfaces/messages-sync.js +2 -0
  10. package/dist/esm/src/interfaces/messages-sync.js.map +1 -1
  11. package/dist/esm/tests/handlers/messages-sync.spec.js +147 -0
  12. package/dist/esm/tests/handlers/messages-sync.spec.js.map +1 -1
  13. package/dist/types/generated/precompiled-validators.d.ts.map +1 -1
  14. package/dist/types/src/handlers/messages-sync.d.ts +36 -0
  15. package/dist/types/src/handlers/messages-sync.d.ts.map +1 -1
  16. package/dist/types/src/handlers/protocols-configure.d.ts.map +1 -1
  17. package/dist/types/src/index.d.ts +1 -1
  18. package/dist/types/src/index.d.ts.map +1 -1
  19. package/dist/types/src/interfaces/messages-sync.d.ts +4 -0
  20. package/dist/types/src/interfaces/messages-sync.d.ts.map +1 -1
  21. package/dist/types/src/types/messages-types.d.ts +30 -1
  22. package/dist/types/src/types/messages-types.d.ts.map +1 -1
  23. package/dist/types/tests/handlers/messages-sync.spec.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/handlers/messages-sync.ts +280 -1
  26. package/src/handlers/protocols-configure.ts +10 -0
  27. package/src/index.ts +1 -1
  28. package/src/interfaces/messages-sync.ts +6 -0
  29. package/src/types/messages-types.ts +31 -1
@@ -1,8 +1,10 @@
1
+ import type { GenericMessage } from '../types/message-types.js';
1
2
  import type { MessageStore } from '../types/message-store.js';
2
3
  import type { HandlerDependencies, MethodHandler } from '../types/method-handler.js';
3
- import type { MessagesSyncMessage, MessagesSyncReply } from '../types/messages-types.js';
4
+ import type { MessagesSyncDiffEntry, MessagesSyncMessage, MessagesSyncReply } from '../types/messages-types.js';
4
5
 
5
6
  import { authenticate } from '../core/auth.js';
7
+ import { Encoder } from '../utils/encoder.js';
6
8
  import { hashToHex } from '../smt/smt-utils.js';
7
9
  import { messageReplyFromError } from '../core/message-reply.js';
8
10
  import { MessagesGrantAuthorization } from '../core/messages-grant-authorization.js';
@@ -10,6 +12,13 @@ import { MessagesSync } from '../interfaces/messages-sync.js';
10
12
  import { PermissionsProtocol } from '../protocols/permissions.js';
11
13
  import { DwnError, DwnErrorCode } from '../core/dwn-error.js';
12
14
 
15
+ /**
16
+ * Default maximum inline data size for diff responses (256 KB).
17
+ * RecordsWrite data payloads smaller than this are base64url-encoded and
18
+ * included directly in the diff reply, avoiding a separate MessagesRead round-trip.
19
+ */
20
+ const DEFAULT_MAX_INLINE_DATA_SIZE = 262_144;
21
+
13
22
 
14
23
  export class MessagesSyncHandler implements MethodHandler {
15
24
 
@@ -70,6 +79,10 @@ export class MessagesSyncHandler implements MethodHandler {
70
79
  };
71
80
  }
72
81
 
82
+ case 'diff': {
83
+ return this.handleDiff(tenant, message);
84
+ }
85
+
73
86
  default: {
74
87
  return {
75
88
  status: { code: 400, detail: `Unknown action: ${action as string}` },
@@ -81,6 +94,272 @@ export class MessagesSyncHandler implements MethodHandler {
81
94
  }
82
95
  }
83
96
 
97
+ /**
98
+ * Handle the 'diff' action: the client sends its subtree hashes at a given
99
+ * depth, and the server compares them against its own tree to compute the
100
+ * set difference in a single round-trip.
101
+ *
102
+ * Response includes:
103
+ * - `onlyRemote`: messages the server has that the client doesn't, with
104
+ * inline data for small payloads.
105
+ * - `onlyLocal`: bit prefixes where the client has entries the server
106
+ * doesn't (client can enumerate its own leaves for these prefixes).
107
+ */
108
+ private async handleDiff(
109
+ tenant: string,
110
+ message: MessagesSyncMessage,
111
+ ): Promise<MessagesSyncReply> {
112
+ const { protocol, hashes: clientHashes, depth } = message.descriptor;
113
+
114
+ if (!clientHashes || depth === undefined) {
115
+ return {
116
+ status: { code: 400, detail: 'diff action requires hashes and depth' },
117
+ };
118
+ }
119
+
120
+ const stateIndex = this.deps.stateIndex!;
121
+ const onlyRemoteCids: string[] = [];
122
+ const onlyLocalPrefixes: string[] = [];
123
+
124
+ // Get the default (empty subtree) hash at the given depth so we can
125
+ // filter out client entries that represent empty subtrees.
126
+ const defaultHashHex = await this.getDefaultHashHex(depth);
127
+
128
+ // Build the set of all prefixes at the given depth that either side has.
129
+ // Filter out client prefixes whose hash equals the default (empty subtree)
130
+ // hash — these represent empty subtrees and should be treated the same as
131
+ // omitted prefixes.
132
+ const allPrefixes = new Set<string>();
133
+ for (const [pfx, hash] of Object.entries(clientHashes)) {
134
+ if (hash !== defaultHashHex) {
135
+ allPrefixes.add(pfx);
136
+ }
137
+ }
138
+
139
+ // Enumerate server-side non-empty prefixes by walking the server's
140
+ // tree to the given depth and collecting leaf prefixes.
141
+ const serverHashes = await this.collectSubtreeHashes(tenant, protocol, depth);
142
+ for (const prefix of Object.keys(serverHashes)) {
143
+ allPrefixes.add(prefix);
144
+ }
145
+
146
+ // Compare each prefix's hash between client and server.
147
+ for (const pfx of allPrefixes) {
148
+ const clientHash = clientHashes[pfx]; // undefined if client has empty subtree
149
+ const serverHash = serverHashes[pfx]; // undefined if server has empty subtree
150
+
151
+ if (clientHash === serverHash) {
152
+ // Identical subtree — skip.
153
+ continue;
154
+ }
155
+
156
+ if (serverHash === undefined) {
157
+ // Client has entries the server doesn't.
158
+ onlyLocalPrefixes.push(pfx);
159
+ continue;
160
+ }
161
+
162
+ if (clientHash === undefined) {
163
+ // Server has entries the client doesn't — enumerate server leaves.
164
+ const bitPath = MessagesSyncHandler.parseBitPrefix(pfx);
165
+ const leaves = protocol !== undefined
166
+ ? await stateIndex.getProtocolLeaves(tenant, protocol, bitPath)
167
+ : await stateIndex.getLeaves(tenant, bitPath);
168
+ onlyRemoteCids.push(...leaves);
169
+ continue;
170
+ }
171
+
172
+ // Both sides have entries but they differ — enumerate both and set-diff.
173
+ const bitPath = MessagesSyncHandler.parseBitPrefix(pfx);
174
+ const serverLeaves = protocol !== undefined
175
+ ? await stateIndex.getProtocolLeaves(tenant, protocol, bitPath)
176
+ : await stateIndex.getLeaves(tenant, bitPath);
177
+
178
+ // We don't have the client's leaves, so we report all server leaves
179
+ // as onlyRemote (the client will de-duplicate locally). We also
180
+ // report this prefix as onlyLocal so the client can check its own leaves.
181
+ onlyRemoteCids.push(...serverLeaves);
182
+ onlyLocalPrefixes.push(pfx);
183
+ }
184
+
185
+ // Build response entries with inline message data where possible.
186
+ const onlyRemote = await this.buildDiffEntries(tenant, onlyRemoteCids);
187
+
188
+ return {
189
+ status : { code: 200, detail: 'OK' },
190
+ onlyRemote,
191
+ onlyLocal : onlyLocalPrefixes,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Walk the server's SMT to the given depth and collect all non-empty
197
+ * subtree hashes as a `{ prefix: hexHash }` map.
198
+ */
199
+ private async collectSubtreeHashes(
200
+ tenant: string,
201
+ protocol: string | undefined,
202
+ depth: number,
203
+ ): Promise<Record<string, string>> {
204
+ const stateIndex = this.deps.stateIndex!;
205
+ const result: Record<string, string> = {};
206
+ const defaultHashHex = await this.getDefaultHashHex(depth);
207
+
208
+ const walk = async (prefix: string, currentDepth: number): Promise<void> => {
209
+ const bitPath = MessagesSyncHandler.parseBitPrefix(prefix);
210
+ const hash = protocol !== undefined
211
+ ? await stateIndex.getProtocolSubtreeHash(tenant, protocol, bitPath)
212
+ : await stateIndex.getSubtreeHash(tenant, bitPath);
213
+ const hexHash = hashToHex(hash);
214
+
215
+ if (hexHash === defaultHashHex) {
216
+ // Empty subtree — don't include in the result.
217
+ return;
218
+ }
219
+
220
+ if (currentDepth >= depth) {
221
+ // Reached target depth with a non-empty subtree.
222
+ result[prefix] = hexHash;
223
+ return;
224
+ }
225
+
226
+ // Recurse into children.
227
+ await Promise.all([
228
+ walk(prefix + '0', currentDepth + 1),
229
+ walk(prefix + '1', currentDepth + 1),
230
+ ]);
231
+ };
232
+
233
+ await walk('', 0);
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Get the hex-encoded default hash for a given depth. Lazily cached.
239
+ */
240
+ private _defaultHashHexCache?: Map<number, string>;
241
+ private async getDefaultHashHex(depth: number): Promise<string> {
242
+ if (this._defaultHashHexCache === undefined) {
243
+ const { initDefaultHashes } = await import('../smt/smt-utils.js');
244
+ const defaults = await initDefaultHashes();
245
+ this._defaultHashHexCache = new Map<number, string>();
246
+ for (let d = 0; d <= 256; d++) {
247
+ this._defaultHashHexCache.set(d, hashToHex(defaults[d]));
248
+ }
249
+ }
250
+ return this._defaultHashHexCache.get(depth) ?? '';
251
+ }
252
+
253
+ /**
254
+ * Build diff response entries for the given messageCids.
255
+ * Reads each message from the MessageStore and, for small RecordsWrite
256
+ * payloads, inlines the data as base64url.
257
+ */
258
+ private async buildDiffEntries(
259
+ tenant: string,
260
+ messageCids: string[],
261
+ ): Promise<MessagesSyncDiffEntry[]> {
262
+ const entries: MessagesSyncDiffEntry[] = [];
263
+
264
+ for (const messageCid of messageCids) {
265
+ const { message, encodedData: inlineData, data } = await this.readMessageByCid(tenant, messageCid);
266
+ if (!message) {
267
+ // Message was deleted between diff computation and read — skip.
268
+ continue;
269
+ }
270
+
271
+ const entry: MessagesSyncDiffEntry = { messageCid, message };
272
+
273
+ // Use inline data from the MessageStore if available (small payloads).
274
+ if (inlineData) {
275
+ entry.encodedData = inlineData;
276
+ } else if (data) {
277
+ // Data is in the DataStore — inline it if small enough.
278
+ const bytes = await MessagesSyncHandler.streamToBytes(data);
279
+ if (bytes.byteLength <= DEFAULT_MAX_INLINE_DATA_SIZE) {
280
+ entry.encodedData = Encoder.bytesToBase64Url(bytes);
281
+ }
282
+ // Large payloads are NOT inlined — client fetches via MessagesRead.
283
+ }
284
+
285
+ entries.push(entry);
286
+ }
287
+
288
+ return entries;
289
+ }
290
+
291
+ /**
292
+ * Read a message and its data from the MessageStore + DataStore by CID.
293
+ */
294
+ private async readMessageByCid(
295
+ tenant: string,
296
+ messageCid: string,
297
+ ): Promise<{ message?: GenericMessage; encodedData?: string; data?: ReadableStream<Uint8Array> }> {
298
+ const storedMessage = await this.deps.messageStore.get(tenant, messageCid);
299
+ if (!storedMessage) {
300
+ return {};
301
+ }
302
+
303
+ // Extract and strip `encodedData` from the stored message.
304
+ // `encodedData` is an internal storage optimization for small payloads
305
+ // that are stored inline in the MessageStore rather than in the DataStore.
306
+ // It must not be included in the wire-format message (the recipient's
307
+ // DWN would reject it as an unexpected top-level property).
308
+ let inlineEncodedData: string | undefined;
309
+ if ('encodedData' in storedMessage) {
310
+ inlineEncodedData = (storedMessage as any).encodedData as string;
311
+ delete (storedMessage as any).encodedData;
312
+ }
313
+
314
+ let data: ReadableStream<Uint8Array> | undefined;
315
+
316
+ // Check if this is a RecordsWrite with data.
317
+ if (storedMessage.descriptor.interface === 'Records' &&
318
+ storedMessage.descriptor.method === 'Write') {
319
+ const dataCid = (storedMessage as any).descriptor?.dataCid as string | undefined;
320
+ if (dataCid) {
321
+ // Try DataStore first (for large payloads stored externally).
322
+ if (this.deps.dataStore) {
323
+ // DataStore uses recordId, not messageCid. For RecordsWrite, the
324
+ // recordId is in the descriptor.
325
+ const recordId = (storedMessage as any).descriptor?.recordId as string | undefined;
326
+ if (recordId) {
327
+ const dataResult = await this.deps.dataStore.get(tenant, recordId, dataCid);
328
+ if (dataResult?.dataStream) {
329
+ data = dataResult.dataStream;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ return { message: storedMessage, encodedData: inlineEncodedData, data };
337
+ }
338
+
339
+ /**
340
+ * Read a ReadableStream to completion and return the bytes.
341
+ */
342
+ private static async streamToBytes(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
343
+ const reader = stream.getReader();
344
+ const chunks: Uint8Array[] = [];
345
+ let totalSize = 0;
346
+
347
+ for (;;) {
348
+ const { done, value } = await reader.read();
349
+ if (done) { break; }
350
+ chunks.push(value);
351
+ totalSize += value.byteLength;
352
+ }
353
+
354
+ const result = new Uint8Array(totalSize);
355
+ let offset = 0;
356
+ for (const chunk of chunks) {
357
+ result.set(chunk, offset);
358
+ offset += chunk.byteLength;
359
+ }
360
+ return result;
361
+ }
362
+
84
363
  /**
85
364
  * Parse a bit prefix string (e.g. "0110101") into a boolean array.
86
365
  */
@@ -54,6 +54,16 @@ export class ProtocolsConfigureHandler implements MethodHandler {
54
54
  };
55
55
  const { messages: existingMessages } = await this.deps.messageStore.query(tenant, [ query ]);
56
56
 
57
+ // If the exact same message already exists, return 409 immediately.
58
+ // This prevents duplicate key violations in the MessageStore and StateIndex
59
+ // when sync pushes a message that the remote already has.
60
+ const incomingCid = await Message.getCid(message);
61
+ for (const existing of existingMessages) {
62
+ if (await Message.getCid(existing) === incomingCid) {
63
+ return { status: { code: 409, detail: 'Conflict' } };
64
+ }
65
+ }
66
+
57
67
  // find newest message, and if the incoming message is the newest
58
68
  let newestMessage = await Message.getNewestMessage(existingMessages);
59
69
  let incomingMessageIsNewest = false;
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export type { DwnConfig } from './dwn.js';
3
3
  export type { EventListener, EventLog, EventLogEntry, EventLogReadOptions, EventLogReadResult, EventLogSubscribeOptions, EventSubscription, MessageEvent, SubscriptionEose, SubscriptionEvent, SubscriptionListener, SubscriptionMessage, SubscriptionReply } from './types/subscriptions.js';
4
4
  export type { AuthorizationModel, Descriptor, DelegatedGrantRecordsWriteMessage, GenericMessage, GenericMessageReply, GenericSignaturePayload, MessageSort, MessageSubscription, Pagination, QueryResultEntry, Status } from './types/message-types.js';
5
- export type { MessagesFilter, MessagesReadMessage as MessagesReadMessage, MessagesReadReply as MessagesReadReply, MessagesReadReplyEntry as MessagesReadReplyEntry, MessagesReadDescriptor, MessagesSubscribeDescriptor, MessagesSubscribeMessage, MessagesSubscribeReply, MessagesSubscribeMessageOptions, MessagesSyncAction, MessagesSyncDescriptor, MessagesSyncMessage, MessagesSyncReply } from './types/messages-types.js';
5
+ export type { MessagesFilter, MessagesReadMessage as MessagesReadMessage, MessagesReadReply as MessagesReadReply, MessagesReadReplyEntry as MessagesReadReplyEntry, MessagesReadDescriptor, MessagesSubscribeDescriptor, MessagesSubscribeMessage, MessagesSubscribeReply, MessagesSubscribeMessageOptions, MessagesSyncAction, MessagesSyncDescriptor, MessagesSyncDiffEntry, MessagesSyncMessage, MessagesSyncReply } from './types/messages-types.js';
6
6
  export type { GT, LT, Filter, FilterValue, KeyValues, EqualFilter, OneOfFilter, RangeFilter, RangeCriterion, PaginationCursor, QueryOptions, RangeValue, StartsWithFilter } from './types/query-types.js';
7
7
  export type { ProtocolsConfigureDescriptor, ProtocolDefinition, ProtocolTypes, ProtocolRuleSet, ProtocolsQueryFilter, ProtocolsConfigureMessage, ProtocolsQueryMessage, ProtocolsQueryReply, ProtocolActionRule, ProtocolDeliveryStrategy, ProtocolPathEncryption, ProtocolsQueryDescriptor, ProtocolRecordLimitDefinition, ProtocolSizeDefinition, ProtocolTagsDefinition, ProtocolTagSchema, ProtocolType, ProtocolUses } from './types/protocols-types.js';
8
8
  export { ProtocolRecordLimitStrategy } from './types/protocols-types.js';
@@ -15,6 +15,10 @@ export type MessagesSyncOptions = {
15
15
  prefix? : string;
16
16
  messageTimestamp? : string;
17
17
  permissionGrantId? : string;
18
+ /** For `action: 'diff'`: client's subtree hashes at `depth`. */
19
+ hashes? : Record<string, string>;
20
+ /** For `action: 'diff'`: bit depth at which hashes were computed. */
21
+ depth? : number;
18
22
  };
19
23
 
20
24
  export class MessagesSync extends AbstractMessage<MessagesSyncMessage> {
@@ -39,6 +43,8 @@ export class MessagesSync extends AbstractMessage<MessagesSyncMessage> {
39
43
  protocol : options.protocol,
40
44
  prefix : options.prefix,
41
45
  permissionGrantId : options.permissionGrantId,
46
+ hashes : options.hashes,
47
+ depth : options.depth,
42
48
  };
43
49
 
44
50
  removeUndefinedProperties(descriptor);
@@ -36,7 +36,7 @@ export type MessagesReadReply = GenericMessageReply & {
36
36
  entry?: MessagesReadReplyEntry;
37
37
  };
38
38
 
39
- export type MessagesSyncAction = 'root' | 'subtree' | 'leaves';
39
+ export type MessagesSyncAction = 'root' | 'subtree' | 'leaves' | 'diff';
40
40
 
41
41
  export type MessagesSyncDescriptor = {
42
42
  interface : DwnInterfaceName.Messages;
@@ -46,6 +46,20 @@ export type MessagesSyncDescriptor = {
46
46
  protocol? : string; // optional protocol scope
47
47
  prefix? : string; // bit path for subtree/leaves (e.g. "0110101...")
48
48
  permissionGrantId? : string;
49
+ /**
50
+ * For `action: 'diff'`: a map of `{ bitPrefix: hexHash }` representing the client's
51
+ * subtree hashes at `depth`. The server compares each hash against its own tree
52
+ * and returns the set difference in a single round-trip.
53
+ *
54
+ * Only non-default (non-empty) subtree hashes need to be included — prefixes
55
+ * whose hash equals the default empty-subtree hash may be omitted.
56
+ */
57
+ hashes? : Record<string, string>;
58
+ /**
59
+ * For `action: 'diff'`: the bit depth at which the client computed its subtree
60
+ * hashes. Required when `action` is `'diff'`.
61
+ */
62
+ depth? : number;
49
63
  };
50
64
 
51
65
  export type MessagesSyncMessage = GenericMessage & {
@@ -53,10 +67,26 @@ export type MessagesSyncMessage = GenericMessage & {
53
67
  descriptor : MessagesSyncDescriptor;
54
68
  };
55
69
 
70
+ /**
71
+ * Entry in a diff response representing a message the server has that the
72
+ * client does not (or vice versa). Optionally includes the full message
73
+ * and inline data for small payloads.
74
+ */
75
+ export type MessagesSyncDiffEntry = {
76
+ messageCid : string;
77
+ message? : GenericMessage;
78
+ /** Base64url-encoded data for small RecordsWrite payloads (≤ maxInlineDataSize). */
79
+ encodedData? : string;
80
+ };
81
+
56
82
  export type MessagesSyncReply = GenericMessageReply & {
57
83
  root? : string; // hex-encoded root hash (for 'root' action)
58
84
  hash? : string; // hex-encoded subtree hash (for 'subtree' action)
59
85
  entries? : string[]; // messageCid[] (for 'leaves' action)
86
+ /** For 'diff' action: messageCids (or full messages) that the server has but the client doesn't. */
87
+ onlyRemote? : MessagesSyncDiffEntry[];
88
+ /** For 'diff' action: bit prefixes where the client has entries the server doesn't. */
89
+ onlyLocal? : string[];
60
90
  };
61
91
 
62
92
  export type MessagesSubscribeMessageOptions = {