@enbox/api 0.2.3 → 0.2.4

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 (73) hide show
  1. package/README.md +235 -35
  2. package/dist/browser.mjs +13 -13
  3. package/dist/browser.mjs.map +4 -4
  4. package/dist/esm/dwn-api.js +24 -10
  5. package/dist/esm/dwn-api.js.map +1 -1
  6. package/dist/esm/index.js +6 -0
  7. package/dist/esm/index.js.map +1 -1
  8. package/dist/esm/live-query.js +34 -5
  9. package/dist/esm/live-query.js.map +1 -1
  10. package/dist/esm/permission-grant.js +3 -6
  11. package/dist/esm/permission-grant.js.map +1 -1
  12. package/dist/esm/permission-request.js +4 -7
  13. package/dist/esm/permission-request.js.map +1 -1
  14. package/dist/esm/record-data.js +131 -0
  15. package/dist/esm/record-data.js.map +1 -0
  16. package/dist/esm/record-types.js +9 -0
  17. package/dist/esm/record-types.js.map +1 -0
  18. package/dist/esm/record.js +58 -184
  19. package/dist/esm/record.js.map +1 -1
  20. package/dist/esm/repository-types.js +13 -0
  21. package/dist/esm/repository-types.js.map +1 -0
  22. package/dist/esm/repository.js +347 -0
  23. package/dist/esm/repository.js.map +1 -0
  24. package/dist/esm/typed-live-query.js +101 -0
  25. package/dist/esm/typed-live-query.js.map +1 -0
  26. package/dist/esm/typed-record.js +227 -0
  27. package/dist/esm/typed-record.js.map +1 -0
  28. package/dist/esm/typed-web5.js +134 -23
  29. package/dist/esm/typed-web5.js.map +1 -1
  30. package/dist/esm/web5.js +78 -20
  31. package/dist/esm/web5.js.map +1 -1
  32. package/dist/types/dwn-api.d.ts.map +1 -1
  33. package/dist/types/index.d.ts +6 -0
  34. package/dist/types/index.d.ts.map +1 -1
  35. package/dist/types/live-query.d.ts +43 -4
  36. package/dist/types/live-query.d.ts.map +1 -1
  37. package/dist/types/permission-grant.d.ts +1 -1
  38. package/dist/types/permission-grant.d.ts.map +1 -1
  39. package/dist/types/permission-request.d.ts +1 -1
  40. package/dist/types/permission-request.d.ts.map +1 -1
  41. package/dist/types/record-data.d.ts +49 -0
  42. package/dist/types/record-data.d.ts.map +1 -0
  43. package/dist/types/record-types.d.ts +145 -0
  44. package/dist/types/record-types.d.ts.map +1 -0
  45. package/dist/types/record.d.ts +13 -144
  46. package/dist/types/record.d.ts.map +1 -1
  47. package/dist/types/repository-types.d.ts +137 -0
  48. package/dist/types/repository-types.d.ts.map +1 -0
  49. package/dist/types/repository.d.ts +59 -0
  50. package/dist/types/repository.d.ts.map +1 -0
  51. package/dist/types/typed-live-query.d.ts +86 -0
  52. package/dist/types/typed-live-query.d.ts.map +1 -0
  53. package/dist/types/typed-record.d.ts +179 -0
  54. package/dist/types/typed-record.d.ts.map +1 -0
  55. package/dist/types/typed-web5.d.ts +55 -24
  56. package/dist/types/typed-web5.d.ts.map +1 -1
  57. package/dist/types/web5.d.ts +47 -2
  58. package/dist/types/web5.d.ts.map +1 -1
  59. package/package.json +8 -7
  60. package/src/dwn-api.ts +30 -13
  61. package/src/index.ts +6 -0
  62. package/src/live-query.ts +71 -7
  63. package/src/permission-grant.ts +2 -3
  64. package/src/permission-request.ts +3 -4
  65. package/src/record-data.ts +155 -0
  66. package/src/record-types.ts +188 -0
  67. package/src/record.ts +86 -389
  68. package/src/repository-types.ts +249 -0
  69. package/src/repository.ts +391 -0
  70. package/src/typed-live-query.ts +156 -0
  71. package/src/typed-record.ts +309 -0
  72. package/src/typed-web5.ts +202 -49
  73. package/src/web5.ts +150 -23
package/README.md CHANGED
@@ -17,6 +17,10 @@ The high-level SDK for building decentralized applications with protocol-first d
17
17
  - [Record Instances](#record-instances)
18
18
  - [LiveQuery (Subscriptions)](#livequery-subscriptions)
19
19
  - [Web5.anonymous()](#web5anonymousoptions)
20
+ - [Repository Pattern](#repository-pattern)
21
+ - [Collections vs Singletons](#collections-vs-singletons)
22
+ - [Nested Records](#nested-records-1)
23
+ - [Using Pre-built Protocols](#using-pre-built-protocols)
20
24
  - [Cookbook](#cookbook)
21
25
  - [Nested Records](#nested-records)
22
26
  - [Querying with Filters and Pagination](#querying-with-filters-and-pagination)
@@ -24,6 +28,7 @@ The high-level SDK for building decentralized applications with protocol-first d
24
28
  - [Publishing Records](#publishing-records)
25
29
  - [Reading Public Data Anonymously](#reading-public-data-anonymously)
26
30
  - [Sending Records to Remote DWNs](#sending-records-to-remote-dwns)
31
+ - [Code Generation](#code-generation)
27
32
  - [Advanced Usage](#advanced-usage)
28
33
  - [Unscoped DWN Access](#unscoped-dwn-access)
29
34
  - [Permissions](#permissions)
@@ -68,15 +73,16 @@ const notes = web5.using(NotesProtocol);
68
73
  // 4. Install the protocol on the local DWN
69
74
  await notes.configure();
70
75
 
71
- // 5. Write a record -- path, data, and schema are type-checked
72
- const { record } = await notes.records.write('note', {
76
+ // 5. Create a record -- path, data, and schema are type-checked
77
+ const { record } = await notes.records.create('note', {
73
78
  data: { title: 'Hello', body: 'World' },
74
79
  });
75
80
 
76
- // 6. Query records back
81
+ // 6. Query records back -- data is typed automatically
77
82
  const { records } = await notes.records.query('note');
78
83
  for (const r of records) {
79
- console.log(r.id, await r.data.json());
84
+ const note = await r.data.json(); // { title: string; body: string }
85
+ console.log(r.id, note.title);
80
86
  }
81
87
 
82
88
  // 7. Send to your remote DWN
@@ -169,22 +175,33 @@ Installs the protocol on the local DWN. If already installed with an identical d
169
175
  await chat.configure();
170
176
  ```
171
177
 
172
- #### `records.write(path, request)`
178
+ #### `records.create(path, request)`
173
179
 
174
- Write a record at a protocol path. The protocol URI, protocolPath, schema, and dataFormat are automatically injected.
180
+ Create a new record at a protocol path. The protocol URI, protocolPath, schema, and dataFormat are automatically injected. Returns a `TypedRecord<T>` where `T` is inferred from the schema map.
175
181
 
176
182
  ```ts
177
- const { record, status } = await chat.records.write('thread', {
183
+ const { record, status } = await chat.records.create('thread', {
178
184
  data: { title: 'General', description: 'General discussion' },
179
185
  });
180
186
 
181
187
  console.log(status.code); // 202
182
188
  console.log(record.id); // unique record ID
189
+
190
+ // record is TypedRecord<{ title: string; description?: string }>
191
+ const data = await record.data.json(); // typed -- no cast needed
192
+ ```
193
+
194
+ To mutate an existing record, use the instance method `record.update()`:
195
+
196
+ ```ts
197
+ const { record: updated } = await record.update({
198
+ data: { title: 'Updated Title', description: 'New description' },
199
+ });
183
200
  ```
184
201
 
185
202
  #### `records.query(path, request?)`
186
203
 
187
- Query records at a protocol path. Returns matching records with optional pagination.
204
+ Query records at a protocol path. Returns `TypedRecord<T>[]` with optional pagination.
188
205
 
189
206
  ```ts
190
207
  const { records, cursor } = await chat.records.query('thread', {
@@ -192,8 +209,10 @@ const { records, cursor } = await chat.records.query('thread', {
192
209
  pagination : { limit: 20 },
193
210
  });
194
211
 
212
+ // records is TypedRecord<{ title: string; description?: string }>[]
195
213
  for (const thread of records) {
196
- console.log(await thread.data.json());
214
+ const data = await thread.data.json(); // typed automatically
215
+ console.log(data.title);
197
216
  }
198
217
 
199
218
  // Fetch next page
@@ -228,17 +247,18 @@ const { status } = await chat.records.delete('thread', {
228
247
 
229
248
  #### `records.subscribe(path, request?)`
230
249
 
231
- Subscribe to real-time changes. Returns a `LiveQuery` with an initial snapshot plus a stream of change events.
250
+ Subscribe to real-time changes. Returns a `TypedLiveQuery<T>` with an initial snapshot of `TypedRecord<T>[]` plus a stream of typed change events.
232
251
 
233
252
  ```ts
234
253
  const { liveQuery } = await chat.records.subscribe('thread/message');
235
254
 
236
- // Initial snapshot
255
+ // Initial snapshot -- typed records
237
256
  for (const msg of liveQuery.records) {
238
- console.log(await msg.data.json());
257
+ const data = await msg.data.json(); // { text: string } -- no cast
258
+ console.log(data.text);
239
259
  }
240
260
 
241
- // Real-time updates
261
+ // Real-time updates -- typed records in handlers
242
262
  liveQuery.on('create', (record) => console.log('new:', record.id));
243
263
  liveQuery.on('update', (record) => console.log('updated:', record.id));
244
264
  liveQuery.on('delete', (record) => console.log('deleted:', record.id));
@@ -254,9 +274,11 @@ const { records } = await chat.records.query('thread', {
254
274
 
255
275
  ---
256
276
 
257
- ### Record Instances
277
+ ### Record Instances (`TypedRecord<T>`)
278
+
279
+ Methods like `create`, `query`, and `read` return `TypedRecord<T>` instances -- type-safe wrappers that preserve the data type `T` inferred from the schema map through the entire lifecycle (create, query, read, update, subscribe).
258
280
 
259
- Methods like `write`, `query`, and `read` return `Record` instances.
281
+ `TypedRecord<T>` exposes a typed `data.json()` that returns `Promise<T>` instead of `Promise<unknown>`, eliminating manual type casts. The underlying `Record` is accessible via `record.rawRecord` if needed.
260
282
 
261
283
  **Properties**:
262
284
 
@@ -283,11 +305,11 @@ Methods like `write`, `query`, and `read` return `Record` instances.
283
305
  **Data accessors** -- read the record payload in different formats:
284
306
 
285
307
  ```ts
286
- const text = await record.data.text(); // string
287
- const obj = await record.data.json<MyType>(); // parsed JSON (typed)
288
- const blob = await record.data.blob(); // Blob
289
- const bytes = await record.data.bytes(); // Uint8Array
290
- const stream = await record.data.stream(); // ReadableStream
308
+ const obj = await record.data.json(); // T -- automatically typed from schema map
309
+ const text = await record.data.text(); // string
310
+ const blob = await record.data.blob(); // Blob
311
+ const bytes = await record.data.bytes(); // Uint8Array
312
+ const stream = await record.data.stream(); // ReadableStream
291
313
  ```
292
314
 
293
315
  **Mutators**:
@@ -317,24 +339,25 @@ await record.import();
317
339
 
318
340
  ---
319
341
 
320
- ### LiveQuery (Subscriptions)
342
+ ### LiveQuery (Subscriptions) -- `TypedLiveQuery<T>`
321
343
 
322
- `records.subscribe()` returns a `LiveQuery` that provides an initial snapshot of existing records plus a real-time stream of deduplicated change events.
344
+ `records.subscribe()` returns a `TypedLiveQuery<T>` that provides an initial snapshot of `TypedRecord<T>[]` plus a real-time stream of deduplicated, typed change events.
323
345
 
324
346
  ```ts
325
347
  const { liveQuery } = await chat.records.subscribe('thread/message');
326
348
 
327
- // Initial snapshot
349
+ // Initial snapshot -- TypedRecord<MessageData>[]
328
350
  for (const msg of liveQuery.records) {
329
- renderMessage(msg);
351
+ const data = await msg.data.json(); // MessageData -- typed
352
+ renderMessage(data);
330
353
  }
331
354
 
332
- // Real-time changes
355
+ // Real-time changes -- handlers receive TypedRecord<MessageData>
333
356
  const offCreate = liveQuery.on('create', (record) => appendMessage(record));
334
357
  const offUpdate = liveQuery.on('update', (record) => refreshMessage(record));
335
358
  const offDelete = liveQuery.on('delete', (record) => removeMessage(record));
336
359
 
337
- // Catch-all event (receives { type, record })
360
+ // Catch-all event (receives { type: 'create'|'update'|'delete', record: TypedRecord<T> })
338
361
  liveQuery.on('change', ({ type, record }) => {
339
362
  console.log(`${type}: ${record.id}`);
340
363
  });
@@ -346,9 +369,7 @@ offCreate();
346
369
  await liveQuery.close();
347
370
  ```
348
371
 
349
- `LiveQuery` extends `EventTarget`, so standard `addEventListener` / `removeEventListener` also work. The `.on()` method is a convenience wrapper that returns an unsubscribe function.
350
-
351
- Events are automatically deduplicated against the initial snapshot -- you won't receive a `create` event for records already in the `records` array.
372
+ The underlying `LiveQuery` is accessible via `liveQuery.rawLiveQuery` if needed. Events are automatically deduplicated against the initial snapshot -- you won't receive a `create` event for records already in the `records` array.
352
373
 
353
374
  ---
354
375
 
@@ -419,12 +440,12 @@ const chat = web5.using(ChatProtocol);
419
440
  await chat.configure();
420
441
 
421
442
  // Create a parent thread
422
- const { record: thread } = await chat.records.write('thread', {
443
+ const { record: thread } = await chat.records.create('thread', {
423
444
  data: { title: 'General' },
424
445
  });
425
446
 
426
- // Write a message nested under the thread
427
- const { record: msg } = await chat.records.write('thread/message', {
447
+ // Create a message nested under the thread
448
+ const { record: msg } = await chat.records.create('thread/message', {
428
449
  parentContextId : thread.contextId,
429
450
  data : { text: 'Hello, world!' },
430
451
  });
@@ -468,7 +489,7 @@ const { records: remote } = await notes.records.query('note', {
468
489
  Tags are key-value metadata attached to records, useful for filtering without parsing record data.
469
490
 
470
491
  ```ts
471
- const { record } = await notes.records.write('note', {
492
+ const { record } = await notes.records.create('note', {
472
493
  data : { title: 'Meeting Notes', body: '...' },
473
494
  tags : { category: 'work', priority: 'high' },
474
495
  });
@@ -486,7 +507,7 @@ const { records } = await notes.records.query('note', {
486
507
  Published records are publicly readable by anyone, including anonymous readers.
487
508
 
488
509
  ```ts
489
- const { record } = await notes.records.write('note', {
510
+ const { record } = await notes.records.create('note', {
490
511
  data : { title: 'Public Note', body: 'Visible to everyone' },
491
512
  published : true,
492
513
  });
@@ -527,6 +548,175 @@ The sync engine (enabled by default at 2-minute intervals) automatically synchro
527
548
 
528
549
  ---
529
550
 
551
+ ## Repository Pattern
552
+
553
+ The `repository()` factory provides a higher-level abstraction over `TypedWeb5`. Instead of passing path strings to every call, you get a **structure-aware object** with CRUD methods directly on each protocol type -- with automatic singleton detection.
554
+
555
+ ```ts
556
+ import { defineProtocol, repository, Web5 } from '@enbox/api';
557
+
558
+ const { web5 } = await Web5.connect({ password: 'secret' });
559
+
560
+ const TaskProtocol = defineProtocol({
561
+ protocol : 'https://example.com/tasks',
562
+ published : false,
563
+ types: {
564
+ project : { schema: 'https://example.com/schemas/project', dataFormats: ['application/json'] },
565
+ task : { schema: 'https://example.com/schemas/task', dataFormats: ['application/json'] },
566
+ config : { schema: 'https://example.com/schemas/config', dataFormats: ['application/json'] },
567
+ },
568
+ structure: {
569
+ project: {
570
+ task: {}, // collection -- many tasks per project
571
+ },
572
+ config: {
573
+ $recordLimit: { max: 1, strategy: 'reject' }, // singleton
574
+ },
575
+ },
576
+ } as const, {} as {
577
+ project : { name: string; color?: string };
578
+ task : { title: string; completed: boolean };
579
+ config : { defaultView: 'list' | 'board' };
580
+ });
581
+
582
+ const repo = repository(web5.using(TaskProtocol));
583
+ await repo.configure();
584
+ ```
585
+
586
+ ### Collections vs Singletons
587
+
588
+ The repository automatically detects types with `$recordLimit: { max: 1 }` and provides different APIs:
589
+
590
+ **Collections** (default) -- `create`, `query`, `get`, `delete`, `subscribe`:
591
+
592
+ ```ts
593
+ // Create
594
+ const { record } = await repo.project.create({
595
+ data: { name: 'Website Redesign', color: '#3b82f6' },
596
+ });
597
+
598
+ // Query all
599
+ const { records } = await repo.project.query();
600
+
601
+ // Query with filters and pagination
602
+ const { records: recent, cursor } = await repo.project.query({
603
+ dateSort : 'createdDescending',
604
+ pagination : { limit: 10 },
605
+ });
606
+
607
+ // Get by record ID
608
+ const { record: project } = await repo.project.get(recordId);
609
+
610
+ // Delete
611
+ await repo.project.delete(recordId);
612
+
613
+ // Subscribe to real-time changes
614
+ const { liveQuery } = await repo.project.subscribe();
615
+ liveQuery.on('create', (record) => console.log('new project:', record.id));
616
+ ```
617
+
618
+ **Singletons** (`$recordLimit: { max: 1 }`) -- `set`, `get`, `delete`:
619
+
620
+ ```ts
621
+ // Set (creates or updates)
622
+ await repo.config.set({
623
+ data: { defaultView: 'board' },
624
+ });
625
+
626
+ // Get the single record
627
+ const { record: config } = await repo.config.get();
628
+ const { defaultView } = await config.data.json(); // 'board'
629
+
630
+ // Delete
631
+ await repo.config.delete(config.id);
632
+ ```
633
+
634
+ ### Nested Records
635
+
636
+ Nested types take `parentContextId` as the first argument:
637
+
638
+ ```ts
639
+ // Create a task under a project
640
+ const { record: task } = await repo.project.task.create(project.contextId, {
641
+ data: { title: 'Design mockups', completed: false },
642
+ });
643
+
644
+ // Query tasks within a project
645
+ const { records: tasks } = await repo.project.task.query(project.contextId);
646
+
647
+ // Subscribe to tasks within a project
648
+ const { liveQuery } = await repo.project.task.subscribe(project.contextId);
649
+ ```
650
+
651
+ ### Using Pre-built Protocols
652
+
653
+ The `@enbox/protocols` package provides production-ready protocol definitions. Combined with `repository()`, you get zero-boilerplate typed data access:
654
+
655
+ ```ts
656
+ import { repository, Web5 } from '@enbox/api';
657
+ import {
658
+ PreferencesProtocol,
659
+ ProfileProtocol,
660
+ SocialGraphProtocol,
661
+ } from '@enbox/protocols';
662
+
663
+ const { web5 } = await Web5.connect({ password: 'secret' });
664
+
665
+ // Social Graph -- friend, block, group, member
666
+ const social = repository(web5.using(SocialGraphProtocol));
667
+ await social.configure();
668
+
669
+ const { record } = await social.friend.create({
670
+ data: { did: 'did:dht:alice...', alias: 'Alice' },
671
+ });
672
+
673
+ // Profile -- profile (singleton), avatar, hero, link, privateNote
674
+ const profile = repository(web5.using(ProfileProtocol));
675
+ await profile.configure();
676
+
677
+ await profile.profile.set({
678
+ data: { displayName: 'Bob', bio: 'Building the decentralized web' },
679
+ });
680
+
681
+ // Add links nested under the profile
682
+ const { record: p } = await profile.profile.get();
683
+ await profile.profile.link.create(p.contextId, {
684
+ data: { url: 'https://github.com/bob', title: 'GitHub' },
685
+ });
686
+
687
+ // Preferences -- theme, locale, privacy (singletons), notification (collection)
688
+ const prefs = repository(web5.using(PreferencesProtocol));
689
+ await prefs.configure();
690
+
691
+ await prefs.theme.set({ data: { mode: 'dark', accentColor: '#8b5cf6' } });
692
+ await prefs.locale.set({ data: { language: 'en', timezone: 'America/New_York' } });
693
+ ```
694
+
695
+ See [`@enbox/protocols`](../protocols) for the full catalog of 6 protocols and 19 typed data shapes.
696
+
697
+ ---
698
+
699
+ ## Code Generation
700
+
701
+ For protocols defined externally (e.g. from a spec or shared JSON file), use `@enbox/protocol-codegen` to generate TypeScript types from a protocol definition and JSON Schemas:
702
+
703
+ ```bash
704
+ bunx @enbox/protocol-codegen generate \
705
+ --definition ./my-protocol.json \
706
+ --schemas ./schemas/ \
707
+ --name MyProtocol \
708
+ --output ./my-protocol.generated.ts
709
+ ```
710
+
711
+ This generates:
712
+ - TypeScript interfaces for each type's JSON Schema (via `json-schema-to-typescript`)
713
+ - A `SchemaMap` mapping type names to generated interfaces
714
+ - A ready-to-use `defineProtocol()` call
715
+
716
+ See [`@enbox/protocol-codegen`](../protocol-codegen) for full documentation.
717
+
718
+ ---
719
+
530
720
  ## Advanced Usage
531
721
 
532
722
  ### Unscoped DWN Access
@@ -579,7 +769,10 @@ const { didDocument } = await web5.did.resolve('did:dht:abc...');
579
769
  |--------|-------------|
580
770
  | `Web5` | Main entry point -- `connect()`, `anonymous()`, `using()` |
581
771
  | `defineProtocol()` | Factory for creating typed protocol definitions |
582
- | `TypedWeb5` | Protocol-scoped API returned by `web5.using()` |
772
+ | `repository()` | Factory for creating structure-aware CRUD repositories from `TypedWeb5` |
773
+ | `TypedWeb5` | Protocol-scoped API returned by `web5.using()` -- `create`, `query`, `read`, `delete`, `subscribe` |
774
+ | `TypedRecord<T>` | Type-safe record wrapper -- `data.json()` returns `Promise<T>` |
775
+ | `TypedLiveQuery<T>` | Type-safe subscription with `TypedRecord<T>[]` snapshot and typed change events |
583
776
  | `Record` | Mutable record instance with data accessors and side-effect methods |
584
777
  | `ReadOnlyRecord` | Immutable record for anonymous/read-only access |
585
778
  | `LiveQuery` | Real-time subscription with initial snapshot and change events |
@@ -604,6 +797,13 @@ const { didDocument } = await web5.did.resolve('did:dht:abc...');
604
797
  | `TypedProtocol<D, M>` | Typed protocol wrapper with definition and schema map |
605
798
  | `ProtocolPaths<D>` | Union of valid slash-delimited paths for a protocol definition |
606
799
  | `SchemaMap` | Maps protocol type names to TypeScript interfaces |
800
+ | `TypedCreateRequest<D, M, Path>` | Options for `records.create()` |
801
+ | `TypedCreateResponse<T>` | Response from `records.create()` -- `{ status, record: TypedRecord<T> }` |
802
+ | `TypedQueryRequest` | Options for `records.query()` |
803
+ | `TypedQueryResponse<T>` | Response from `records.query()` -- `{ status, records: TypedRecord<T>[], cursor? }` |
804
+ | `TypedSubscribeResponse<T>` | Response from `records.subscribe()` -- `{ status, liveQuery: TypedLiveQuery<T> }` |
805
+ | `Repository<D, M>` | Repository type -- structure-aware Proxy object with CRUD methods |
806
+ | `DataForPath<D, M, Path>` | Resolves TypeScript data type for a protocol path from the schema map |
607
807
  | `Web5ConnectOptions` | Options for `Web5.connect()` |
608
808
  | `Web5ConnectResult` | Return type of `Web5.connect()` |
609
809
  | `RecordModel` | Structured data model of a record |