@enbox/api 0.3.1 → 0.4.0

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 (102) hide show
  1. package/README.md +63 -0
  2. package/dist/browser.mjs +11 -28
  3. package/dist/browser.mjs.map +4 -4
  4. package/dist/esm/advanced.js +1 -1
  5. package/dist/esm/define-protocol.js +3 -3
  6. package/dist/esm/did-api.js +1 -1
  7. package/dist/esm/did-api.js.map +1 -1
  8. package/dist/esm/dwn-api.js +6 -6
  9. package/dist/esm/dwn-api.js.map +1 -1
  10. package/dist/esm/dwn-reader-api.js +2 -2
  11. package/dist/esm/enbox.js +205 -0
  12. package/dist/esm/enbox.js.map +1 -0
  13. package/dist/esm/index.js +16 -15
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/protocol.js +2 -2
  16. package/dist/esm/protocol.js.map +1 -1
  17. package/dist/esm/record-data.js +79 -5
  18. package/dist/esm/record-data.js.map +1 -1
  19. package/dist/esm/record.js +49 -10
  20. package/dist/esm/record.js.map +1 -1
  21. package/dist/esm/repository.js +7 -7
  22. package/dist/esm/repository.js.map +1 -1
  23. package/dist/esm/typed-enbox.js +583 -0
  24. package/dist/esm/typed-enbox.js.map +1 -0
  25. package/dist/esm/typed-live-query.js +1 -1
  26. package/dist/esm/typed-record.js +370 -46
  27. package/dist/esm/typed-record.js.map +1 -1
  28. package/dist/esm/utils.js +25 -0
  29. package/dist/esm/utils.js.map +1 -1
  30. package/dist/esm/vc-api.js.map +1 -1
  31. package/dist/types/advanced.d.ts +1 -1
  32. package/dist/types/define-protocol.d.ts +3 -3
  33. package/dist/types/did-api.d.ts +4 -4
  34. package/dist/types/did-api.d.ts.map +1 -1
  35. package/dist/types/dwn-api.d.ts +12 -7
  36. package/dist/types/dwn-api.d.ts.map +1 -1
  37. package/dist/types/dwn-reader-api.d.ts +2 -2
  38. package/dist/types/enbox.d.ts +202 -0
  39. package/dist/types/enbox.d.ts.map +1 -0
  40. package/dist/types/grant-revocation.d.ts +2 -2
  41. package/dist/types/grant-revocation.d.ts.map +1 -1
  42. package/dist/types/index.d.ts +16 -15
  43. package/dist/types/index.d.ts.map +1 -1
  44. package/dist/types/live-query.d.ts +2 -2
  45. package/dist/types/live-query.d.ts.map +1 -1
  46. package/dist/types/permission-grant.d.ts +2 -2
  47. package/dist/types/permission-grant.d.ts.map +1 -1
  48. package/dist/types/permission-request.d.ts +2 -2
  49. package/dist/types/permission-request.d.ts.map +1 -1
  50. package/dist/types/protocol-types.d.ts +2 -2
  51. package/dist/types/protocol.d.ts +7 -7
  52. package/dist/types/protocol.d.ts.map +1 -1
  53. package/dist/types/record-data.d.ts +17 -0
  54. package/dist/types/record-data.d.ts.map +1 -1
  55. package/dist/types/record.d.ts +24 -10
  56. package/dist/types/record.d.ts.map +1 -1
  57. package/dist/types/repository-types.d.ts +19 -11
  58. package/dist/types/repository-types.d.ts.map +1 -1
  59. package/dist/types/repository.d.ts +7 -7
  60. package/dist/types/repository.d.ts.map +1 -1
  61. package/dist/types/typed-enbox.d.ts +613 -0
  62. package/dist/types/typed-enbox.d.ts.map +1 -0
  63. package/dist/types/typed-live-query.d.ts +1 -1
  64. package/dist/types/typed-record.d.ts +427 -53
  65. package/dist/types/typed-record.d.ts.map +1 -1
  66. package/dist/types/utils.d.ts +23 -0
  67. package/dist/types/utils.d.ts.map +1 -1
  68. package/dist/types/vc-api.d.ts +3 -3
  69. package/dist/types/vc-api.d.ts.map +1 -1
  70. package/package.json +12 -11
  71. package/src/advanced.ts +1 -1
  72. package/src/define-protocol.ts +3 -3
  73. package/src/did-api.ts +5 -5
  74. package/src/dwn-api.ts +22 -17
  75. package/src/dwn-reader-api.ts +2 -2
  76. package/src/enbox.ts +281 -0
  77. package/src/grant-revocation.ts +3 -3
  78. package/src/index.ts +17 -16
  79. package/src/live-query.ts +2 -2
  80. package/src/permission-grant.ts +4 -4
  81. package/src/permission-request.ts +3 -3
  82. package/src/protocol-types.ts +2 -2
  83. package/src/protocol.ts +8 -8
  84. package/src/record-data.ts +86 -5
  85. package/src/record.ts +54 -13
  86. package/src/repository-types.ts +19 -7
  87. package/src/repository.ts +15 -15
  88. package/src/typed-enbox.ts +1169 -0
  89. package/src/typed-live-query.ts +1 -1
  90. package/src/typed-record.ts +431 -53
  91. package/src/utils.ts +27 -0
  92. package/src/vc-api.ts +4 -4
  93. package/dist/esm/typed-web5.js +0 -339
  94. package/dist/esm/typed-web5.js.map +0 -1
  95. package/dist/esm/web5.js +0 -410
  96. package/dist/esm/web5.js.map +0 -1
  97. package/dist/types/typed-web5.d.ts +0 -221
  98. package/dist/types/typed-web5.d.ts.map +0 -1
  99. package/dist/types/web5.d.ts +0 -346
  100. package/dist/types/web5.d.ts.map +0 -1
  101. package/src/typed-web5.ts +0 -598
  102. package/src/web5.ts +0 -754
@@ -0,0 +1,1169 @@
1
+ /**
2
+ * A protocol-scoped API returned by {@link Enbox.using}.
3
+ *
4
+ * `TypedEnbox` is the **primary developer interface** for interacting with
5
+ * protocol-backed records. It auto-injects the protocol URI, protocolPath,
6
+ * and schema into every operation, and provides compile-time path
7
+ * autocompletion plus typed data payloads via the schema map.
8
+ *
9
+ * All record-returning methods wrap the underlying `Record` instances in
10
+ * {@link TypedRecord} so that type information flows through reads, queries,
11
+ * updates, and subscriptions without manual casts.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const social = enbox.using(SocialProtocol);
16
+ *
17
+ * // Install the protocol
18
+ * await social.configure();
19
+ *
20
+ * // Create — path and data type are checked at compile time
21
+ * const { record } = await social.records.create('thread', {
22
+ * data: { title: 'Hello World', body: '...' },
23
+ * });
24
+ * // record is TypedRecord<ThreadData>
25
+ *
26
+ * const data = await record.data.json(); // ThreadData — no cast needed
27
+ *
28
+ * // Query — protocol and protocolPath are auto-injected
29
+ * const { records } = await social.records.query('thread');
30
+ * // records is TypedRecord<ThreadData>[]
31
+ *
32
+ * // Subscribe — real-time changes via TypedLiveQuery
33
+ * const { liveQuery } = await social.records.subscribe('thread/reply');
34
+ * liveQuery.on('create', (record) => {
35
+ * // record is TypedRecord<ReplyData>
36
+ * });
37
+ * ```
38
+ */
39
+
40
+ import type { DwnApi } from './dwn-api.js';
41
+ import type { Protocol } from './protocol.js';
42
+
43
+ import type { DateSort, ProtocolDefinition, ProtocolType, RecordsFilter } from '@enbox/dwn-sdk-js';
44
+ import type { DwnPaginationCursor, DwnResponseStatus } from '@enbox/agent';
45
+ import type { ProtocolPaths, SchemaMap, TypedProtocol, TypeNameAtPath } from './protocol-types.js';
46
+
47
+ import { TypedLiveQuery } from './typed-live-query.js';
48
+ import { TypedRecord } from './typed-record.js';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Helper types
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Resolves the TypeScript data type for a given protocol path.
56
+ *
57
+ * If the schema map contains a mapping for the type name at the given path,
58
+ * that type is returned. Otherwise falls back to `unknown`.
59
+ */
60
+ export type DataForPath<
61
+ _D extends ProtocolDefinition,
62
+ M extends SchemaMap,
63
+ Path extends string,
64
+ > = TypeNameAtPath<Path> extends keyof M ? M[TypeNameAtPath<Path>] : unknown;
65
+
66
+ /**
67
+ * Resolves the `ProtocolType` entry for a given protocol path.
68
+ */
69
+ type ProtocolTypeForPath<
70
+ D extends ProtocolDefinition,
71
+ Path extends string,
72
+ > = TypeNameAtPath<Path> extends keyof D['types']
73
+ ? D['types'][TypeNameAtPath<Path>] extends ProtocolType
74
+ ? D['types'][TypeNameAtPath<Path>]
75
+ : undefined
76
+ : undefined;
77
+
78
+ /**
79
+ * Resolves a `dataFormat` string literal union for a path, or `string` if none.
80
+ */
81
+ type DataFormatForPath<
82
+ D extends ProtocolDefinition,
83
+ Path extends string,
84
+ > = ProtocolTypeForPath<D, Path> extends { dataFormats: infer F }
85
+ ? F extends readonly string[]
86
+ ? F[number]
87
+ : string
88
+ : string;
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Request / response types
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Options for {@link TypedEnbox} `records.create()`.
96
+ *
97
+ * The `data` field is type-checked against the protocol's schema map for
98
+ * the given path, providing compile-time safety for record payloads.
99
+ *
100
+ * @typeParam D - The protocol definition type.
101
+ * @typeParam M - The schema map mapping type names to TypeScript types.
102
+ * @typeParam Path - The protocol path string literal.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * await proto.records.create('notebook/page', {
107
+ * data: { title: 'My Page', body: '...' }, // type-checked as PageData
108
+ * parentContextId: notebook.contextId, // link to parent
109
+ * tags: { category: 'draft' },
110
+ * });
111
+ * ```
112
+ */
113
+ export type TypedCreateRequest<
114
+ D extends ProtocolDefinition,
115
+ M extends SchemaMap,
116
+ Path extends string,
117
+ > = {
118
+ /** The data payload. Type-checked against the schema map for the given path. */
119
+ data: DataForPath<D, M, Path>;
120
+
121
+ /**
122
+ * The context ID of the parent record.
123
+ *
124
+ * Required when creating a child record under a parent in a hierarchical
125
+ * protocol structure. Use the parent record's {@link TypedRecord.contextId | contextId}
126
+ * as this value.
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * const { record: notebook } = await proto.records.create('notebook', {
131
+ * data: { name: 'My Notebook' },
132
+ * });
133
+ *
134
+ * // Create a page under the notebook
135
+ * await proto.records.create('notebook/page', {
136
+ * data: { title: 'Page 1' },
137
+ * parentContextId: notebook.contextId,
138
+ * });
139
+ * ```
140
+ */
141
+ parentContextId?: string;
142
+
143
+ /**
144
+ * Whether the record should be publicly published.
145
+ *
146
+ * Published records can be read by anyone without authorization.
147
+ */
148
+ published?: boolean;
149
+
150
+ /**
151
+ * ISO 8601 timestamp for when the record is considered published.
152
+ * Only meaningful when `published` is `true`.
153
+ */
154
+ datePublished?: string;
155
+
156
+ /**
157
+ * The DID of the intended recipient.
158
+ *
159
+ * Sets the recipient for permission-scoped records. The recipient may
160
+ * have special read/write permissions as defined by the protocol's
161
+ * `$actions` rules.
162
+ */
163
+ recipient?: string;
164
+
165
+ /**
166
+ * The protocol role under which to create this record.
167
+ *
168
+ * The DWN will verify that the author is authorized to write under
169
+ * this role per the protocol's `$actions` configuration.
170
+ */
171
+ protocolRole?: string;
172
+
173
+ /**
174
+ * The MIME type / data format for the record.
175
+ *
176
+ * If omitted, defaults to the first entry in the protocol type's
177
+ * `dataFormats` array (typically `'application/json'`).
178
+ */
179
+ dataFormat?: DataFormatForPath<D, Path>;
180
+
181
+ /**
182
+ * Key-value metadata tags to attach to the record.
183
+ *
184
+ * Tags are indexed by the DWN and can be used in query filters for
185
+ * efficient lookups. Values can be strings, numbers, booleans, or
186
+ * arrays of strings/numbers.
187
+ */
188
+ tags?: globalThis.Record<string, string | number | boolean | string[] | number[]>;
189
+
190
+ /**
191
+ * Whether to persist the record to the local DWN immediately.
192
+ *
193
+ * Defaults to `true`. Set to `false` to create the record in memory
194
+ * only — you can call {@link TypedRecord.store | record.store()} later.
195
+ */
196
+ store?: boolean;
197
+
198
+ /**
199
+ * Whether to auto-encrypt the record.
200
+ *
201
+ * If omitted, encryption follows the protocol definition. Set to `true`
202
+ * to force encryption or `false` to skip it.
203
+ */
204
+ encryption?: boolean;
205
+ };
206
+
207
+ /**
208
+ * Response from {@link TypedEnbox} `records.create()`.
209
+ *
210
+ * Uses a discriminated union so that TypeScript narrows `record` to
211
+ * `TypedRecord<T>` after a `status.code` check:
212
+ *
213
+ * ```ts
214
+ * const result = await proto.records.create('notebook', { data });
215
+ * if (result.record) {
216
+ * // TypeScript knows `record` is TypedRecord<NotebookData> here
217
+ * console.log(result.record.id);
218
+ * }
219
+ * ```
220
+ *
221
+ * @typeParam T - The data type of the created record.
222
+ */
223
+ export type TypedCreateResponse<T = unknown> =
224
+ | (DwnResponseStatus & { record: TypedRecord<T> })
225
+ | (DwnResponseStatus & { record: undefined });
226
+
227
+ /**
228
+ * Filter options for {@link TypedEnbox} `records.query()` and `records.subscribe()`.
229
+ *
230
+ * The `protocol`, `protocolPath`, and `schema` fields are automatically
231
+ * injected by {@link TypedEnbox} — you only need to supply additional
232
+ * filter criteria.
233
+ *
234
+ * Common filter fields inherited from `RecordsFilter`:
235
+ *
236
+ * - **`parentId`** — Filter by parent context ID. Despite the name, this
237
+ * filters on the parent record's **context ID** (i.e. pass
238
+ * `parent.contextId`, not `parent.id`). Use this to find child records
239
+ * under a specific parent in a hierarchical protocol.
240
+ * - **`recordId`** — Match a specific record by its unique ID.
241
+ * - **`recipient`** — Filter by recipient DID.
242
+ * - **`dataFormat`** — Filter by MIME type.
243
+ * - **`dateCreated`** — Range filter on creation date.
244
+ * - **`contextId`** — Filter by context ID directly.
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * // Find pages under a specific notebook
249
+ * const { records } = await proto.records.query('notebook/page', {
250
+ * filter: {
251
+ * parentId: notebook.contextId, // filters by parent's context ID
252
+ * tags: { status: 'published' },
253
+ * },
254
+ * });
255
+ * ```
256
+ */
257
+ export type TypedQueryFilter = Omit<RecordsFilter, 'protocol' | 'protocolPath' | 'schema'> & {
258
+ /**
259
+ * Filter records by tag values.
260
+ *
261
+ * Only records whose tags match **all** specified key-value pairs are
262
+ * returned. Array values match if the record's tag contains any of the
263
+ * specified values.
264
+ */
265
+ tags?: globalThis.Record<string, string | number | boolean | (string | number)[]>;
266
+ /** Alias for `parentId` — filters records by parent context ID. */
267
+ parentContextId?: string;
268
+ };
269
+
270
+ /**
271
+ * Options for {@link TypedEnbox} `records.query()`.
272
+ *
273
+ * All fields are optional — calling `query(path)` with no request object
274
+ * returns all records at that path.
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * const { records, cursor } = await proto.records.query('notebook', {
279
+ * filter: { tags: { archived: false } },
280
+ * dateSort: DateSort.CreatedDescending,
281
+ * pagination: { limit: 25 },
282
+ * });
283
+ *
284
+ * // Paginate for more
285
+ * if (cursor) {
286
+ * const { records: next } = await proto.records.query('notebook', {
287
+ * pagination: { limit: 25, cursor },
288
+ * });
289
+ * }
290
+ * ```
291
+ */
292
+ export type TypedQueryRequest = {
293
+ /**
294
+ * A remote DWN DID to query from.
295
+ *
296
+ * When set, the query is sent to the specified DID's remote DWN instead
297
+ * of the local DWN.
298
+ */
299
+ from?: string;
300
+
301
+ /**
302
+ * Filter criteria for the query.
303
+ *
304
+ * The `protocol`, `protocolPath`, and `schema` fields are auto-injected.
305
+ * See {@link TypedQueryFilter} for available filter fields.
306
+ */
307
+ filter?: TypedQueryFilter;
308
+
309
+ /**
310
+ * Sort order for the returned records.
311
+ *
312
+ * Use `DateSort.CreatedAscending`, `DateSort.CreatedDescending`,
313
+ * `DateSort.PublishedAscending`, or `DateSort.PublishedDescending`.
314
+ */
315
+ dateSort?: DateSort;
316
+
317
+ /**
318
+ * Pagination options.
319
+ *
320
+ * - `limit` — Maximum number of records to return.
321
+ * - `cursor` — A pagination cursor from a previous query response to
322
+ * resume from where the last page left off.
323
+ */
324
+ pagination?: { limit?: number; cursor?: DwnPaginationCursor };
325
+
326
+ /**
327
+ * The protocol role under which to execute the query.
328
+ *
329
+ * Required when the protocol's `$actions` rules restrict read access
330
+ * to specific roles.
331
+ */
332
+ protocolRole?: string;
333
+
334
+ /**
335
+ * When `true`, automatically decrypts encrypted records in the results.
336
+ *
337
+ * If omitted, encrypted records are returned as-is (data accessors will
338
+ * return encrypted bytes).
339
+ */
340
+ encryption?: boolean;
341
+ };
342
+
343
+ /**
344
+ * Response from {@link TypedEnbox} `records.query()`.
345
+ *
346
+ * @typeParam T - The data type of the queried records.
347
+ */
348
+ export type TypedQueryResponse<T = unknown> = DwnResponseStatus & {
349
+ /**
350
+ * The matching records, each wrapped as {@link TypedRecord | TypedRecord<T>}.
351
+ *
352
+ * The array is empty if no records match the filter criteria.
353
+ */
354
+ records: TypedRecord<T>[];
355
+
356
+ /**
357
+ * A pagination cursor for fetching the next page of results.
358
+ *
359
+ * Pass this to a subsequent `query()` call's `pagination.cursor` to
360
+ * continue from where this page ended. `undefined` when there are no
361
+ * more results.
362
+ */
363
+ cursor?: DwnPaginationCursor;
364
+ };
365
+
366
+ /**
367
+ * Options for {@link TypedEnbox} `records.read()`.
368
+ *
369
+ * A `filter` is required to identify which record to read. The most common
370
+ * approach is to filter by `recordId`.
371
+ *
372
+ * @example
373
+ * ```ts
374
+ * // Read a specific record by ID
375
+ * const { record } = await proto.records.read('notebook', {
376
+ * filter: { recordId: notebookId },
377
+ * });
378
+ *
379
+ * // Read from a remote DWN
380
+ * const { record: remote } = await proto.records.read('notebook', {
381
+ * from: 'did:example:alice',
382
+ * filter: { recordId: notebookId },
383
+ * });
384
+ * ```
385
+ */
386
+ export type TypedReadRequest = {
387
+ /**
388
+ * A remote DWN DID to read from.
389
+ *
390
+ * When set, the read is performed against the specified DID's remote
391
+ * DWN instead of the local DWN.
392
+ */
393
+ from?: string;
394
+
395
+ /**
396
+ * Filter to identify the record to read.
397
+ *
398
+ * The `protocol`, `protocolPath`, and `schema` fields are auto-injected.
399
+ * Typically you filter by `recordId` to read a specific record. Other
400
+ * fields from `RecordsFilter` (like `contextId`, `parentId`, `recipient`)
401
+ * are also available.
402
+ */
403
+ filter: Omit<RecordsFilter, 'protocol' | 'protocolPath' | 'schema'> & {
404
+ /** Alias for `parentId` — filters records by parent context ID. */
405
+ parentContextId?: string;
406
+ };
407
+
408
+ /**
409
+ * When `true`, automatically decrypts the record if it is encrypted.
410
+ *
411
+ * If omitted, encrypted records are returned as-is.
412
+ */
413
+ encryption?: boolean;
414
+ };
415
+
416
+ /**
417
+ * Response from {@link TypedEnbox} `records.read()`.
418
+ *
419
+ * Uses a discriminated union so that TypeScript narrows `record` to
420
+ * `TypedRecord<T>` after a truthiness check:
421
+ *
422
+ * ```ts
423
+ * const result = await proto.records.read('notebook', { filter: { recordId } });
424
+ * if (result.record) {
425
+ * const data = await result.record.data.json(); // NotebookData
426
+ * }
427
+ * ```
428
+ *
429
+ * @typeParam T - The data type of the read record.
430
+ */
431
+ export type TypedReadResponse<T = unknown> =
432
+ | (DwnResponseStatus & { record: TypedRecord<T> })
433
+ | (DwnResponseStatus & { record: undefined });
434
+
435
+ /**
436
+ * Options for {@link TypedEnbox} `records.delete()`.
437
+ *
438
+ * @example
439
+ * ```ts
440
+ * const { status } = await proto.records.delete('notebook', {
441
+ * recordId: notebook.id,
442
+ * });
443
+ * ```
444
+ */
445
+ export type TypedDeleteRequest = {
446
+ /**
447
+ * A remote DWN DID to delete from.
448
+ *
449
+ * When set, the delete is performed on the specified DID's remote DWN.
450
+ */
451
+ from?: string;
452
+
453
+ /**
454
+ * The unique `recordId` of the record to delete.
455
+ *
456
+ * Use {@link TypedRecord.id | record.id} to obtain this value.
457
+ */
458
+ recordId: string;
459
+ };
460
+
461
+ /**
462
+ * Options for {@link TypedEnbox} `records.subscribe()`.
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * const { liveQuery } = await proto.records.subscribe('notebook/page', {
467
+ * filter: { parentId: notebook.contextId },
468
+ * });
469
+ *
470
+ * liveQuery.on('create', (record) => {
471
+ * console.log('New page:', await record.data.json());
472
+ * });
473
+ * ```
474
+ */
475
+ export type TypedSubscribeRequest = {
476
+ /**
477
+ * A remote DWN DID to subscribe to.
478
+ *
479
+ * When set, the subscription listens to the specified DID's remote DWN.
480
+ */
481
+ from?: string;
482
+
483
+ /**
484
+ * Filter criteria for the subscription.
485
+ *
486
+ * The `protocol`, `protocolPath`, and `schema` fields are auto-injected.
487
+ * See {@link TypedQueryFilter} for available filter fields.
488
+ */
489
+ filter?: TypedQueryFilter;
490
+
491
+ /**
492
+ * The protocol role under which to subscribe.
493
+ *
494
+ * Required when the protocol's `$actions` rules restrict read access
495
+ * to specific roles.
496
+ */
497
+ protocolRole?: string;
498
+ };
499
+
500
+ /**
501
+ * Response from {@link TypedEnbox} `records.subscribe()`.
502
+ *
503
+ * @typeParam T - The data type of records in the subscription.
504
+ */
505
+ export type TypedSubscribeResponse<T = unknown> = DwnResponseStatus & {
506
+ /**
507
+ * The typed live query instance for receiving real-time record changes.
508
+ *
509
+ * `undefined` if the subscription request failed (check `status.code`).
510
+ * When defined, use `liveQuery.on('create' | 'update' | 'delete', callback)`
511
+ * to react to changes. Call `liveQuery.close()` to stop the subscription.
512
+ */
513
+ liveQuery?: TypedLiveQuery<T>;
514
+ };
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // TypedEnbox class
518
+ // ---------------------------------------------------------------------------
519
+
520
+ /**
521
+ * A protocol-scoped API that auto-injects `protocol`, `protocolPath`, and
522
+ * `schema` into every DWN operation.
523
+ *
524
+ * All record-returning methods wrap results in {@link TypedRecord} so that
525
+ * the data type `T` (resolved from the schema map) flows end-to-end — from
526
+ * write through read, query, update, and subscribe — without manual casts.
527
+ *
528
+ * Obtain an instance via `enbox.using(typedProtocol)`.
529
+ *
530
+ * @example
531
+ * ```ts
532
+ * const social = enbox.using(SocialProtocol);
533
+ *
534
+ * await social.configure();
535
+ *
536
+ * const { record } = await social.records.create('friend', {
537
+ * data: { did: 'did:example:alice', alias: 'Alice' },
538
+ * });
539
+ * const data = await record.data.json(); // FriendData — no cast
540
+ *
541
+ * const { records } = await social.records.query('friend', {
542
+ * filter: { tags: { did: 'did:example:alice' } },
543
+ * });
544
+ * for (const r of records) {
545
+ * const d = await r.data.json(); // FriendData
546
+ * }
547
+ * ```
548
+ */
549
+ export class TypedEnbox<
550
+ D extends ProtocolDefinition = ProtocolDefinition,
551
+ M extends SchemaMap = SchemaMap,
552
+ > {
553
+ /** @internal */
554
+ private _dwn: DwnApi;
555
+ /** @internal */
556
+ private _definition: D;
557
+ /** @internal */
558
+ private _configured: boolean = false;
559
+ /** @internal */
560
+ private _ensureReadyPromise: Promise<void> | null = null;
561
+ /** @internal */
562
+ private _validPaths: Set<string>;
563
+ /** @internal */
564
+ private _records?: TypedEnbox<D, M>['records'];
565
+
566
+ /**
567
+ * @internal Create a new `TypedEnbox` instance. Use `enbox.using(protocol)` instead.
568
+ * @param dwn - The underlying DWN API instance.
569
+ * @param protocol - The typed protocol containing the definition and schema map.
570
+ */
571
+ constructor(dwn: DwnApi, protocol: TypedProtocol<D, M>) {
572
+ this._dwn = dwn;
573
+ this._definition = protocol.definition;
574
+ this._validPaths = collectPaths(this._definition.structure);
575
+ }
576
+
577
+ /**
578
+ * The protocol URI string (e.g. `'https://example.com/social'`).
579
+ *
580
+ * This is the globally unique identifier for the protocol and is
581
+ * auto-injected into every record operation.
582
+ */
583
+ public get protocol(): string {
584
+ return this._definition.protocol;
585
+ }
586
+
587
+ /**
588
+ * The raw protocol definition object.
589
+ *
590
+ * Contains the full `protocol`, `types`, and `structure` that define
591
+ * the protocol's schema and permission rules.
592
+ */
593
+ public get definition(): D {
594
+ return this._definition;
595
+ }
596
+
597
+ /**
598
+ * Configures (installs) this protocol on the local DWN.
599
+ *
600
+ * If the protocol is already installed with an identical definition,
601
+ * this is a no-op and returns the existing protocol with status `200`.
602
+ * If the definition has changed (e.g. new types, modified structure),
603
+ * the protocol is re-configured with the updated definition and returns
604
+ * status `202`.
605
+ *
606
+ * **Must be called before any record operations.** Methods like
607
+ * `records.create()`, `records.query()`, etc. will throw if the protocol
608
+ * has not been configured.
609
+ *
610
+ * @param options - Optional configuration overrides.
611
+ * @param options.encryption - Whether to enable auto-encryption for the
612
+ * protocol. If omitted, follows the protocol definition defaults.
613
+ * @returns The DWN response status and the installed protocol object.
614
+ *
615
+ * @example
616
+ * ```ts
617
+ * const proto = enbox.using(NotebookProtocol);
618
+ *
619
+ * const { status, protocol } = await proto.configure();
620
+ * console.log(status.code); // 202 (first install) or 200 (already installed)
621
+ *
622
+ * // Now you can use records.create(), records.query(), etc.
623
+ * ```
624
+ */
625
+ public async configure(options?: { encryption?: boolean }): Promise<DwnResponseStatus & { protocol?: Protocol }> {
626
+ // Query for an existing installation of this protocol.
627
+ const { protocols } = await this._dwn.protocols.query({
628
+ filter: { protocol: this._definition.protocol },
629
+ });
630
+
631
+ // If already installed with the same definition, return it as-is.
632
+ if (protocols.length > 0) {
633
+ const existing = protocols[0];
634
+ if (definitionsEqual(existing.definition, this._definition)) {
635
+ this._configured = true;
636
+ return { status: { code: 200, detail: 'OK' }, protocol: existing };
637
+ }
638
+ }
639
+
640
+ // Not installed or definition has changed — configure the new version.
641
+ const result = await this._dwn.protocols.configure({
642
+ definition : this._definition,
643
+ encryption : options?.encryption,
644
+ });
645
+
646
+ if (result.status.code === 202) {
647
+ this._configured = true;
648
+ }
649
+
650
+ return result;
651
+ }
652
+
653
+ /**
654
+ * Whether the protocol has been configured (installed) on the local DWN.
655
+ *
656
+ * Returns `true` after a successful call to {@link TypedEnbox.configure | configure()}.
657
+ * Record operations will throw if this is `false`.
658
+ */
659
+ public get isConfigured(): boolean {
660
+ return this._configured;
661
+ }
662
+
663
+ /**
664
+ * Validates that the path is recognized.
665
+ * Throws a descriptive error if the path is not a valid protocol path.
666
+ */
667
+ private _assertValidPath(path: string): void {
668
+ if (!this._validPaths.has(path)) {
669
+ throw new Error(
670
+ `TypedEnbox: invalid protocol path '${path}'. ` +
671
+ `Valid paths are: ${[...this._validPaths].join(', ')}.`,
672
+ );
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Ensures the protocol is configured before performing record operations.
678
+ *
679
+ * On first call, queries for an existing protocol installation:
680
+ * - If found with an identical definition → marks as configured.
681
+ * - If found with a different definition → marks as configured but warns
682
+ * that the local definition differs from the installed one.
683
+ * - If not found → installs the protocol via `protocols.configure()`.
684
+ *
685
+ * Concurrent calls are deduplicated via a shared Promise so the network
686
+ * call only happens once.
687
+ */
688
+ private async _ensureReady(path: string): Promise<void> {
689
+ if (this._configured) {
690
+ this._assertValidPath(path);
691
+ return;
692
+ }
693
+
694
+ if (this._ensureReadyPromise === null) {
695
+ this._ensureReadyPromise = this._autoConfigureOnce();
696
+ }
697
+
698
+ await this._ensureReadyPromise;
699
+ this._assertValidPath(path);
700
+ }
701
+
702
+ /**
703
+ * Performs the one-time auto-configuration check. Called at most once;
704
+ * subsequent calls reuse the same Promise via `_ensureReadyPromise`.
705
+ */
706
+ private async _autoConfigureOnce(): Promise<void> {
707
+ const { protocols } = await this._dwn.protocols.query({
708
+ filter: { protocol: this._definition.protocol },
709
+ });
710
+
711
+ if (protocols.length > 0) {
712
+ const existing = protocols[0];
713
+ if (definitionsEqual(existing.definition, this._definition)) {
714
+ this._configured = true;
715
+ return;
716
+ }
717
+
718
+ // Installed but definitions differ — allow operations but warn.
719
+ console.warn(
720
+ `TypedEnbox: installed protocol '${this._definition.protocol}' differs from the provided definition. ` +
721
+ 'Call configure() to update it.',
722
+ );
723
+ this._configured = true;
724
+ return;
725
+ }
726
+
727
+ // Not installed — configure it now.
728
+ const result = await this._dwn.protocols.configure({
729
+ definition: this._definition,
730
+ });
731
+
732
+ if (result.status.code === 202) {
733
+ this._configured = true;
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Protocol-scoped record operations.
739
+ *
740
+ * Every method auto-injects the `protocol`, `protocolPath`, and `schema`
741
+ * from the protocol definition — you never need to specify them manually.
742
+ * Path parameters provide **compile-time autocompletion** via
743
+ * `ProtocolPaths<D>`, and data types are resolved from the schema map.
744
+ *
745
+ * All methods return {@link TypedRecord} or {@link TypedLiveQuery} instances
746
+ * that carry the resolved data type from the schema map, providing
747
+ * end-to-end type safety.
748
+ *
749
+ * Available methods:
750
+ * - {@link TypedEnbox.records.create | create(path, request)} — Create a new record
751
+ * - {@link TypedEnbox.records.query | query(path, request?)} — Query records with filters
752
+ * - {@link TypedEnbox.records.read | read(path, request)} — Read a single record
753
+ * - {@link TypedEnbox.records.delete | delete(path, request)} — Delete a record by ID
754
+ * - {@link TypedEnbox.records.subscribe | subscribe(path, request?)} — Real-time subscription
755
+ */
756
+ public get records(): {
757
+ create: <Path extends ProtocolPaths<D> & string>(
758
+ path: Path,
759
+ request: TypedCreateRequest<D, M, Path>,
760
+ ) => Promise<TypedCreateResponse<DataForPath<D, M, Path>>>;
761
+
762
+ query: <Path extends ProtocolPaths<D> & string>(
763
+ path: Path,
764
+ request?: TypedQueryRequest,
765
+ ) => Promise<TypedQueryResponse<DataForPath<D, M, Path>>>;
766
+
767
+ read: <Path extends ProtocolPaths<D> & string>(
768
+ path: Path,
769
+ request: TypedReadRequest,
770
+ ) => Promise<TypedReadResponse<DataForPath<D, M, Path>>>;
771
+
772
+ delete: <Path extends ProtocolPaths<D> & string>(
773
+ path: Path,
774
+ request: TypedDeleteRequest,
775
+ ) => Promise<DwnResponseStatus>;
776
+
777
+ subscribe: <Path extends ProtocolPaths<D> & string>(
778
+ path: Path,
779
+ request?: TypedSubscribeRequest,
780
+ ) => Promise<TypedSubscribeResponse<DataForPath<D, M, Path>>>;
781
+ } {
782
+ if (this._records !== undefined) {
783
+ return this._records;
784
+ }
785
+
786
+ const cached = {
787
+ /**
788
+ * Create a new record at the given protocol path.
789
+ *
790
+ * The `protocol`, `protocolPath`, and `schema` are auto-injected from
791
+ * the protocol definition. The `data` field is type-checked against
792
+ * the schema map for the given path.
793
+ *
794
+ * @param path - The protocol path (e.g. `'notebook'`, `'notebook/page'`).
795
+ * Provides compile-time autocompletion for valid paths.
796
+ * @param request - Create options including the typed `data` payload
797
+ * and optional fields like `parentContextId`, `tags`, `recipient`.
798
+ * @returns A {@link TypedCreateResponse} containing the DWN response
799
+ * `status` and the created {@link TypedRecord}.
800
+ *
801
+ * @example
802
+ * ```ts
803
+ * const { status, record } = await proto.records.create('notebook', {
804
+ * data: { name: 'My Notebook' },
805
+ * });
806
+ *
807
+ * // Create a child page under the notebook's context
808
+ * const { record: page } = await proto.records.create('notebook/page', {
809
+ * data: { title: 'First Page', body: '' },
810
+ * parentContextId: record.contextId,
811
+ * });
812
+ * ```
813
+ */
814
+ create: async <Path extends ProtocolPaths<D> & string>(
815
+ path: Path,
816
+ request: TypedCreateRequest<D, M, Path>,
817
+ ): Promise<TypedCreateResponse<DataForPath<D, M, Path>>> => {
818
+ const normalizedPath = normalizePath(path);
819
+ await this._ensureReady(normalizedPath);
820
+ const typeName = lastSegment(normalizedPath);
821
+ const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
822
+
823
+ const { status, record } = await this._dwn.records.write({
824
+ data : request.data,
825
+ store : request.store,
826
+ encryption : request.encryption,
827
+ parentContextId : request.parentContextId,
828
+ published : request.published,
829
+ datePublished : request.datePublished,
830
+ recipient : request.recipient,
831
+ protocolRole : request.protocolRole,
832
+ tags : request.tags,
833
+ protocol : this._definition.protocol,
834
+ protocolPath : normalizedPath,
835
+ ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
836
+ dataFormat : request.dataFormat ?? typeEntry?.dataFormats?.[0],
837
+ });
838
+
839
+ return {
840
+ status,
841
+ record: record ? new TypedRecord<DataForPath<D, M, Path>>(record) : undefined,
842
+ };
843
+ },
844
+
845
+ /**
846
+ * Query records at the given protocol path.
847
+ *
848
+ * Returns all matching records as an array of typed records, with
849
+ * an optional pagination cursor for fetching additional pages.
850
+ *
851
+ * @param path - The protocol path to query (e.g. `'notebook'`,
852
+ * `'notebook/page'`).
853
+ * @param request - Optional filter, sort, and pagination options.
854
+ * Omit entirely to return all records at the path.
855
+ * @returns A {@link TypedQueryResponse} containing `status`, `records`
856
+ * (as {@link TypedRecord | TypedRecord<T>[]}), and an optional
857
+ * `cursor` for pagination.
858
+ *
859
+ * @example
860
+ * ```ts
861
+ * // Query all notebooks
862
+ * const { records } = await proto.records.query('notebook');
863
+ *
864
+ * // Query pages under a specific notebook
865
+ * const { records: pages } = await proto.records.query('notebook/page', {
866
+ * filter: { parentId: notebook.contextId },
867
+ * });
868
+ *
869
+ * for (const page of pages) {
870
+ * const data = await page.data.json(); // PageData
871
+ * }
872
+ *
873
+ * // Paginated query
874
+ * const { records: batch, cursor } = await proto.records.query('notebook', {
875
+ * pagination: { limit: 10 },
876
+ * dateSort: DateSort.CreatedDescending,
877
+ * });
878
+ * ```
879
+ */
880
+ query: async <Path extends ProtocolPaths<D> & string>(
881
+ path: Path,
882
+ request?: TypedQueryRequest,
883
+ ): Promise<TypedQueryResponse<DataForPath<D, M, Path>>> => {
884
+ const normalizedPath = normalizePath(path);
885
+ await this._ensureReady(normalizedPath);
886
+ const typeName = lastSegment(normalizedPath);
887
+ const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
888
+
889
+ const queryFilter = mapParentContextId(request?.filter);
890
+
891
+ const { status, records, cursor } = await this._dwn.records.query({
892
+ from : request?.from,
893
+ encryption : request?.encryption,
894
+ filter : {
895
+ ...queryFilter,
896
+ protocol : this._definition.protocol,
897
+ protocolPath : normalizedPath,
898
+ ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
899
+ },
900
+ dateSort : request?.dateSort,
901
+ pagination : request?.pagination,
902
+ protocolRole : request?.protocolRole,
903
+ });
904
+
905
+ return {
906
+ status,
907
+ records: records.map((r) => new TypedRecord<DataForPath<D, M, Path>>(r)),
908
+ cursor,
909
+ };
910
+ },
911
+
912
+ /**
913
+ * Read a single record at the given protocol path.
914
+ *
915
+ * Unlike `query()`, which returns an array, `read()` returns exactly
916
+ * one record. Use `filter.recordId` to target a specific record.
917
+ *
918
+ * @param path - The protocol path to read from.
919
+ * @param request - Read options including a `filter` to identify the
920
+ * record. See {@link TypedReadRequest} for details.
921
+ * @returns A {@link TypedReadResponse} containing `status` and the
922
+ * matching {@link TypedRecord}.
923
+ *
924
+ * @example
925
+ * ```ts
926
+ * const { record } = await proto.records.read('notebook', {
927
+ * filter: { recordId: notebookId },
928
+ * });
929
+ *
930
+ * const data = await record.data.json(); // NotebookData
931
+ * console.log(data.name);
932
+ * ```
933
+ */
934
+ read: async <Path extends ProtocolPaths<D> & string>(
935
+ path: Path,
936
+ request: TypedReadRequest,
937
+ ): Promise<TypedReadResponse<DataForPath<D, M, Path>>> => {
938
+ const normalizedPath = normalizePath(path);
939
+ await this._ensureReady(normalizedPath);
940
+ const typeName = lastSegment(normalizedPath);
941
+ const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
942
+
943
+ const readFilter = mapParentContextId(request.filter);
944
+
945
+ const { status, record } = await this._dwn.records.read({
946
+ from : request.from,
947
+ encryption : request.encryption,
948
+ protocol : this._definition.protocol,
949
+ filter : {
950
+ ...readFilter,
951
+ protocol : this._definition.protocol,
952
+ protocolPath : normalizedPath,
953
+ ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
954
+ },
955
+ });
956
+
957
+ return {
958
+ status,
959
+ record: record ? new TypedRecord<DataForPath<D, M, Path>>(record) : undefined,
960
+ };
961
+ },
962
+
963
+ /**
964
+ * Delete a record at the given protocol path.
965
+ *
966
+ * The path is used for protocol validation and permission scoping,
967
+ * while the actual record is identified by `recordId`.
968
+ *
969
+ * @param path - The protocol path (used for permission scoping and
970
+ * path validation).
971
+ * @param request - Delete options. `recordId` is required; `from` is
972
+ * optional for remote deletes.
973
+ * @returns The DWN response status.
974
+ *
975
+ * @example
976
+ * ```ts
977
+ * const { status } = await proto.records.delete('notebook', {
978
+ * recordId: notebook.id,
979
+ * });
980
+ *
981
+ * if (status.code === 202) {
982
+ * console.log('Notebook deleted');
983
+ * }
984
+ * ```
985
+ */
986
+ delete: async <Path extends ProtocolPaths<D> & string>(
987
+ _path: Path,
988
+ request: TypedDeleteRequest,
989
+ ): Promise<DwnResponseStatus> => {
990
+ await this._ensureReady(normalizePath(_path));
991
+ return this._dwn.records.delete({
992
+ from : request.from,
993
+ protocol : this._definition.protocol,
994
+ recordId : request.recordId,
995
+ });
996
+ },
997
+
998
+ /**
999
+ * Subscribe to real-time changes at the given protocol path.
1000
+ *
1001
+ * Returns a {@link TypedLiveQuery} that atomically provides an initial
1002
+ * snapshot and a real-time stream of deduplicated change events, with
1003
+ * all records typed as `TypedRecord<T>`.
1004
+ *
1005
+ * @param path - The protocol path to subscribe to.
1006
+ * @param request - Optional filter and role. Use `filter.parentId`
1007
+ * to scope the subscription to children of a specific parent.
1008
+ * @returns A {@link TypedSubscribeResponse} containing `status` and
1009
+ * a {@link TypedLiveQuery} for receiving events.
1010
+ *
1011
+ * @example
1012
+ * ```ts
1013
+ * const { liveQuery } = await proto.records.subscribe('notebook/page', {
1014
+ * filter: { parentId: notebook.contextId },
1015
+ * });
1016
+ *
1017
+ * liveQuery.on('create', (record) => {
1018
+ * // record is TypedRecord<PageData>
1019
+ * console.log('New page created');
1020
+ * });
1021
+ *
1022
+ * liveQuery.on('update', (record) => {
1023
+ * const data = await record.data.json(); // PageData
1024
+ * });
1025
+ *
1026
+ * liveQuery.on('delete', (record) => {
1027
+ * console.log('Page deleted:', record.id);
1028
+ * });
1029
+ *
1030
+ * // Stop listening
1031
+ * liveQuery.close();
1032
+ * ```
1033
+ */
1034
+ subscribe: async <Path extends ProtocolPaths<D> & string>(
1035
+ path: Path,
1036
+ request?: TypedSubscribeRequest,
1037
+ ): Promise<TypedSubscribeResponse<DataForPath<D, M, Path>>> => {
1038
+ const normalizedPath = normalizePath(path);
1039
+ await this._ensureReady(normalizedPath);
1040
+ const typeName = lastSegment(normalizedPath);
1041
+ const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
1042
+
1043
+ const subFilter = mapParentContextId(request?.filter);
1044
+
1045
+ const { status, liveQuery } = await this._dwn.records.subscribe({
1046
+ from : request?.from,
1047
+ filter : {
1048
+ ...subFilter,
1049
+ protocol : this._definition.protocol,
1050
+ protocolPath : normalizedPath,
1051
+ ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
1052
+ },
1053
+ protocolRole: request?.protocolRole,
1054
+ });
1055
+
1056
+ return {
1057
+ status,
1058
+ liveQuery: liveQuery ? new TypedLiveQuery<DataForPath<D, M, Path>>(liveQuery) : undefined,
1059
+ };
1060
+ },
1061
+ };
1062
+
1063
+ this._records = cached;
1064
+ return cached;
1065
+ }
1066
+ }
1067
+
1068
+ // ---------------------------------------------------------------------------
1069
+ // Helpers
1070
+ // ---------------------------------------------------------------------------
1071
+
1072
+ /**
1073
+ * Maps the `parentContextId` alias to the underlying `parentId` field
1074
+ * expected by the DWN SDK. If both are provided, `parentId` takes precedence.
1075
+ * Returns a new object (or `undefined` if the input was `undefined`).
1076
+ */
1077
+ function mapParentContextId<T extends Record<string, unknown>>(
1078
+ filter: T | undefined,
1079
+ ): Omit<T, 'parentContextId'> | undefined {
1080
+ if (!filter) { return undefined; }
1081
+ const { parentContextId, ...rest } = filter as Record<string, unknown>;
1082
+ if (parentContextId !== undefined && rest.parentId === undefined) {
1083
+ rest.parentId = parentContextId;
1084
+ }
1085
+ return rest as Omit<T, 'parentContextId'>;
1086
+ }
1087
+
1088
+ /**
1089
+ * Compares two protocol definitions for deep equality using deterministic
1090
+ * JSON serialization.
1091
+ *
1092
+ * Keys are sorted recursively so that semantically identical definitions
1093
+ * with different key ordering are treated as equal.
1094
+ */
1095
+ function definitionsEqual(a: unknown, b: unknown): boolean {
1096
+ return stableStringify(a) === stableStringify(b);
1097
+ }
1098
+
1099
+ /**
1100
+ * Strips leading and trailing slashes from a path.
1101
+ *
1102
+ * `'friend/'` → `'friend'`, `'/group/member/'` → `'group/member'`.
1103
+ */
1104
+ function normalizePath(path: string): string {
1105
+ return path.replace(/^\/+|\/+$/g, '');
1106
+ }
1107
+
1108
+ /**
1109
+ * Returns the last segment of a slash-delimited path.
1110
+ */
1111
+ function lastSegment(path: string): string {
1112
+ const parts = path.split('/');
1113
+ return parts[parts.length - 1];
1114
+ }
1115
+
1116
+ /**
1117
+ * Recursively collects all valid protocol path strings from a structure object.
1118
+ *
1119
+ * Given `{ foo: { bar: { $actions: [...] } } }`, returns `Set(['foo', 'foo/bar'])`.
1120
+ * Keys starting with `$` are skipped.
1121
+ */
1122
+ function collectPaths(
1123
+ structure: Record<string, unknown>,
1124
+ prefix: string = '',
1125
+ ): Set<string> {
1126
+ const paths = new Set<string>();
1127
+
1128
+ for (const key of Object.keys(structure)) {
1129
+ if (key.startsWith('$')) { continue; }
1130
+
1131
+ const fullPath = prefix ? `${prefix}/${key}` : key;
1132
+ paths.add(fullPath);
1133
+
1134
+ const child = structure[key];
1135
+ if (child !== null && typeof child === 'object') {
1136
+ for (const nested of collectPaths(child as Record<string, unknown>, fullPath)) {
1137
+ paths.add(nested);
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ return paths;
1143
+ }
1144
+
1145
+ /**
1146
+ * Deterministic JSON serialization with sorted keys.
1147
+ */
1148
+ function stableStringify(value: unknown): string {
1149
+ if (value === null || value === undefined || typeof value !== 'object') {
1150
+ return JSON.stringify(value);
1151
+ }
1152
+
1153
+ if (Array.isArray(value)) {
1154
+ return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
1155
+ }
1156
+
1157
+ const keys = Object.keys(value as globalThis.Record<string, unknown>).sort();
1158
+ const pairs = keys.map((key) =>
1159
+ JSON.stringify(key) + ':' + stableStringify((value as globalThis.Record<string, unknown>)[key])
1160
+ );
1161
+ return '{' + pairs.join(',') + '}';
1162
+ }
1163
+
1164
+ // ---------------------------------------------------------------------------
1165
+ // Deprecated alias — migration aid
1166
+ // ---------------------------------------------------------------------------
1167
+
1168
+ /** @deprecated Use {@link TypedEnbox} instead. Will be removed in a future version. */
1169
+ export const TypedWeb5 = TypedEnbox;