@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/src/typed-web5.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  * and schema into every operation, and provides compile-time path
7
7
  * autocompletion plus typed data payloads via the schema map.
8
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
+ *
9
13
  * @example
10
14
  * ```ts
11
15
  * const social = web5.using(SocialProtocol);
@@ -13,29 +17,36 @@
13
17
  * // Install the protocol
14
18
  * await social.configure();
15
19
  *
16
- * // Write — path and data type are checked at compile time
17
- * const { record } = await social.records.write('thread', {
20
+ * // Create — path and data type are checked at compile time
21
+ * const { record } = await social.records.create('thread', {
18
22
  * data: { title: 'Hello World', body: '...' },
19
23
  * });
24
+ * // record is TypedRecord<ThreadData>
25
+ *
26
+ * const data = await record.data.json(); // ThreadData — no cast needed
20
27
  *
21
28
  * // Query — protocol and protocolPath are auto-injected
22
29
  * const { records } = await social.records.query('thread');
30
+ * // records is TypedRecord<ThreadData>[]
23
31
  *
24
- * // Subscribe — real-time changes via LiveQuery
32
+ * // Subscribe — real-time changes via TypedLiveQuery
25
33
  * const { liveQuery } = await social.records.subscribe('thread/reply');
26
- * liveQuery.on('create', (record) => { ... });
34
+ * liveQuery.on('create', (record) => {
35
+ * // record is TypedRecord<ReplyData>
36
+ * });
27
37
  * ```
28
38
  */
29
39
 
30
40
  import type { DwnApi } from './dwn-api.js';
31
- import type { LiveQuery } from './live-query.js';
32
41
  import type { Protocol } from './protocol.js';
33
- import type { Record } from './record.js';
34
42
 
35
43
  import type { DateSort, ProtocolDefinition, ProtocolType, RecordsFilter } from '@enbox/dwn-sdk-js';
36
44
  import type { DwnPaginationCursor, DwnResponseStatus } from '@enbox/agent';
37
45
  import type { ProtocolPaths, SchemaMap, TypedProtocol, TypeNameAtPath } from './protocol-types.js';
38
46
 
47
+ import { TypedLiveQuery } from './typed-live-query.js';
48
+ import { TypedRecord } from './typed-record.js';
49
+
39
50
  // ---------------------------------------------------------------------------
40
51
  // Helper types
41
52
  // ---------------------------------------------------------------------------
@@ -46,7 +57,7 @@ import type { ProtocolPaths, SchemaMap, TypedProtocol, TypeNameAtPath } from './
46
57
  * If the schema map contains a mapping for the type name at the given path,
47
58
  * that type is returned. Otherwise falls back to `unknown`.
48
59
  */
49
- type DataForPath<
60
+ export type DataForPath<
50
61
  _D extends ProtocolDefinition,
51
62
  M extends SchemaMap,
52
63
  Path extends string,
@@ -80,8 +91,8 @@ type DataFormatForPath<
80
91
  // Request / response types
81
92
  // ---------------------------------------------------------------------------
82
93
 
83
- /** Options for {@link TypedWeb5} `records.write()`. */
84
- export type TypedWriteRequest<
94
+ /** Options for {@link TypedWeb5} `records.create()`. */
95
+ export type TypedCreateRequest<
85
96
  D extends ProtocolDefinition,
86
97
  M extends SchemaMap,
87
98
  Path extends string,
@@ -104,9 +115,9 @@ export type TypedWriteRequest<
104
115
  encryption?: boolean;
105
116
  };
106
117
 
107
- /** Response from {@link TypedWeb5} `records.write()`. */
108
- export type TypedWriteResponse = DwnResponseStatus & {
109
- record: Record;
118
+ /** Response from {@link TypedWeb5} `records.create()`. */
119
+ export type TypedCreateResponse<T = unknown> = DwnResponseStatus & {
120
+ record: TypedRecord<T>;
110
121
  };
111
122
 
112
123
  /** Filter options for {@link TypedWeb5} `records.query()`. */
@@ -130,8 +141,8 @@ export type TypedQueryRequest = {
130
141
  };
131
142
 
132
143
  /** Response from {@link TypedWeb5} `records.query()`. */
133
- export type TypedQueryResponse = DwnResponseStatus & {
134
- records: Record[];
144
+ export type TypedQueryResponse<T = unknown> = DwnResponseStatus & {
145
+ records: TypedRecord<T>[];
135
146
  cursor?: DwnPaginationCursor;
136
147
  };
137
148
 
@@ -148,8 +159,8 @@ export type TypedReadRequest = {
148
159
  };
149
160
 
150
161
  /** Response from {@link TypedWeb5} `records.read()`. */
151
- export type TypedReadResponse = DwnResponseStatus & {
152
- record: Record;
162
+ export type TypedReadResponse<T = unknown> = DwnResponseStatus & {
163
+ record: TypedRecord<T>;
153
164
  };
154
165
 
155
166
  /** Options for {@link TypedWeb5} `records.delete()`. */
@@ -172,9 +183,9 @@ export type TypedSubscribeRequest = {
172
183
  };
173
184
 
174
185
  /** Response from {@link TypedWeb5} `records.subscribe()`. */
175
- export type TypedSubscribeResponse = DwnResponseStatus & {
176
- /** The live query instance, or `undefined` if the request failed. */
177
- liveQuery?: LiveQuery;
186
+ export type TypedSubscribeResponse<T = unknown> = DwnResponseStatus & {
187
+ /** The typed live query instance, or `undefined` if the request failed. */
188
+ liveQuery?: TypedLiveQuery<T>;
178
189
  };
179
190
 
180
191
  // ---------------------------------------------------------------------------
@@ -185,6 +196,10 @@ export type TypedSubscribeResponse = DwnResponseStatus & {
185
196
  * A protocol-scoped API that auto-injects `protocol`, `protocolPath`, and
186
197
  * `schema` into every DWN operation.
187
198
  *
199
+ * All record-returning methods wrap results in {@link TypedRecord} so that
200
+ * the data type `T` (resolved from the schema map) flows end-to-end — from
201
+ * write through read, query, update, and subscribe — without manual casts.
202
+ *
188
203
  * Obtain an instance via `web5.using(typedProtocol)`.
189
204
  *
190
205
  * @example
@@ -193,13 +208,17 @@ export type TypedSubscribeResponse = DwnResponseStatus & {
193
208
  *
194
209
  * await social.configure();
195
210
  *
196
- * const { record } = await social.records.write('friend', {
211
+ * const { record } = await social.records.create('friend', {
197
212
  * data: { did: 'did:example:alice', alias: 'Alice' },
198
213
  * });
214
+ * const data = await record.data.json(); // FriendData — no cast
199
215
  *
200
216
  * const { records } = await social.records.query('friend', {
201
217
  * filter: { tags: { did: 'did:example:alice' } },
202
218
  * });
219
+ * for (const r of records) {
220
+ * const d = await r.data.json(); // FriendData
221
+ * }
203
222
  * ```
204
223
  */
205
224
  export class TypedWeb5<
@@ -208,10 +227,14 @@ export class TypedWeb5<
208
227
  > {
209
228
  private _dwn: DwnApi;
210
229
  private _definition: D;
230
+ private _configured: boolean = false;
231
+ private _validPaths: Set<string>;
232
+ private _records?: TypedWeb5<D, M>['records'];
211
233
 
212
234
  constructor(dwn: DwnApi, protocol: TypedProtocol<D, M>) {
213
235
  this._dwn = dwn;
214
236
  this._definition = protocol.definition;
237
+ this._validPaths = collectPaths(this._definition.structure);
215
238
  }
216
239
 
217
240
  /** The protocol URI. */
@@ -244,15 +267,47 @@ export class TypedWeb5<
244
267
  if (protocols.length > 0) {
245
268
  const existing = protocols[0];
246
269
  if (definitionsEqual(existing.definition, this._definition)) {
270
+ this._configured = true;
247
271
  return { status: { code: 200, detail: 'OK' }, protocol: existing };
248
272
  }
249
273
  }
250
274
 
251
275
  // Not installed or definition has changed — configure the new version.
252
- return this._dwn.protocols.configure({
276
+ const result = await this._dwn.protocols.configure({
253
277
  definition : this._definition,
254
278
  encryption : options?.encryption,
255
279
  });
280
+
281
+ if (result.status.code === 202) {
282
+ this._configured = true;
283
+ }
284
+
285
+ return result;
286
+ }
287
+
288
+ /** Whether the protocol has been configured (installed) on the local DWN. */
289
+ public get isConfigured(): boolean {
290
+ return this._configured;
291
+ }
292
+
293
+ /**
294
+ * Validates that the protocol has been configured and that the path is
295
+ * recognized. Throws a descriptive error if either check fails.
296
+ */
297
+ private _assertReady(path: string): void {
298
+ if (!this._configured) {
299
+ throw new Error(
300
+ `TypedWeb5: protocol '${this._definition.protocol}' has not been configured. ` +
301
+ 'Call configure() before performing record operations.',
302
+ );
303
+ }
304
+
305
+ if (!this._validPaths.has(path)) {
306
+ throw new Error(
307
+ `TypedWeb5: invalid protocol path '${path}'. ` +
308
+ `Valid paths are: ${[...this._validPaths].join(', ')}.`,
309
+ );
310
+ }
256
311
  }
257
312
 
258
313
  /**
@@ -261,29 +316,57 @@ export class TypedWeb5<
261
316
  * Every method auto-injects the protocol URI, protocolPath, and schema
262
317
  * from the protocol definition. Path parameters provide compile-time
263
318
  * autocompletion via `ProtocolPaths<D>`.
319
+ *
320
+ * All methods return {@link TypedRecord} or {@link TypedLiveQuery} instances
321
+ * that carry the resolved data type from the schema map.
264
322
  */
265
323
  public get records(): {
266
- write: <Path extends ProtocolPaths<D> & string>(path: Path, request: TypedWriteRequest<D, M, Path>) => Promise<TypedWriteResponse>;
267
- query: <Path extends ProtocolPaths<D> & string>(path: Path, request?: TypedQueryRequest) => Promise<TypedQueryResponse>;
268
- read: <Path extends ProtocolPaths<D> & string>(path: Path, request: TypedReadRequest) => Promise<TypedReadResponse>;
269
- delete: <Path extends ProtocolPaths<D> & string>(path: Path, request: TypedDeleteRequest) => Promise<DwnResponseStatus>;
270
- subscribe: <Path extends ProtocolPaths<D> & string>(path: Path, request?: TypedSubscribeRequest) => Promise<TypedSubscribeResponse>;
324
+ create: <Path extends ProtocolPaths<D> & string>(
325
+ path: Path,
326
+ request: TypedCreateRequest<D, M, Path>,
327
+ ) => Promise<TypedCreateResponse<DataForPath<D, M, Path>>>;
328
+
329
+ query: <Path extends ProtocolPaths<D> & string>(
330
+ path: Path,
331
+ request?: TypedQueryRequest,
332
+ ) => Promise<TypedQueryResponse<DataForPath<D, M, Path>>>;
333
+
334
+ read: <Path extends ProtocolPaths<D> & string>(
335
+ path: Path,
336
+ request: TypedReadRequest,
337
+ ) => Promise<TypedReadResponse<DataForPath<D, M, Path>>>;
338
+
339
+ delete: <Path extends ProtocolPaths<D> & string>(
340
+ path: Path,
341
+ request: TypedDeleteRequest,
342
+ ) => Promise<DwnResponseStatus>;
343
+
344
+ subscribe: <Path extends ProtocolPaths<D> & string>(
345
+ path: Path,
346
+ request?: TypedSubscribeRequest,
347
+ ) => Promise<TypedSubscribeResponse<DataForPath<D, M, Path>>>;
271
348
  } {
272
- return {
349
+ if (this._records !== undefined) {
350
+ return this._records;
351
+ }
352
+
353
+ const cached = {
273
354
  /**
274
- * Write a record at the given protocol path.
355
+ * Create a new record at the given protocol path.
275
356
  *
276
357
  * @param path - The protocol path (e.g. `'friend'`, `'group/member'`).
277
- * @param request - Write options including typed `data`.
358
+ * @param request - Create options including typed `data`.
278
359
  */
279
- write: async <Path extends ProtocolPaths<D> & string>(
360
+ create: async <Path extends ProtocolPaths<D> & string>(
280
361
  path: Path,
281
- request: TypedWriteRequest<D, M, Path>,
282
- ): Promise<TypedWriteResponse> => {
283
- const typeName = lastSegment(path);
362
+ request: TypedCreateRequest<D, M, Path>,
363
+ ): Promise<TypedCreateResponse<DataForPath<D, M, Path>>> => {
364
+ const normalizedPath = normalizePath(path);
365
+ this._assertReady(normalizedPath);
366
+ const typeName = lastSegment(normalizedPath);
284
367
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
285
368
 
286
- return this._dwn.records.write({
369
+ const { status, record } = await this._dwn.records.write({
287
370
  data : request.data,
288
371
  store : request.store,
289
372
  encryption : request.encryption,
@@ -294,10 +377,15 @@ export class TypedWeb5<
294
377
  protocolRole : request.protocolRole,
295
378
  tags : request.tags,
296
379
  protocol : this._definition.protocol,
297
- protocolPath : path,
380
+ protocolPath : normalizedPath,
298
381
  ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
299
382
  dataFormat : request.dataFormat ?? typeEntry?.dataFormats?.[0],
300
383
  });
384
+
385
+ return {
386
+ status,
387
+ record: new TypedRecord<DataForPath<D, M, Path>>(record),
388
+ };
301
389
  },
302
390
 
303
391
  /**
@@ -309,23 +397,31 @@ export class TypedWeb5<
309
397
  query: async <Path extends ProtocolPaths<D> & string>(
310
398
  path: Path,
311
399
  request?: TypedQueryRequest,
312
- ): Promise<TypedQueryResponse> => {
313
- const typeName = lastSegment(path);
400
+ ): Promise<TypedQueryResponse<DataForPath<D, M, Path>>> => {
401
+ const normalizedPath = normalizePath(path);
402
+ this._assertReady(normalizedPath);
403
+ const typeName = lastSegment(normalizedPath);
314
404
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
315
405
 
316
- return this._dwn.records.query({
406
+ const { status, records, cursor } = await this._dwn.records.query({
317
407
  from : request?.from,
318
408
  encryption : request?.encryption,
319
409
  filter : {
320
410
  ...request?.filter,
321
411
  protocol : this._definition.protocol,
322
- protocolPath : path,
412
+ protocolPath : normalizedPath,
323
413
  ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
324
414
  },
325
415
  dateSort : request?.dateSort,
326
416
  pagination : request?.pagination,
327
417
  protocolRole : request?.protocolRole,
328
418
  });
419
+
420
+ return {
421
+ status,
422
+ records: records.map((r) => new TypedRecord<DataForPath<D, M, Path>>(r)),
423
+ cursor,
424
+ };
329
425
  },
330
426
 
331
427
  /**
@@ -337,21 +433,28 @@ export class TypedWeb5<
337
433
  read: async <Path extends ProtocolPaths<D> & string>(
338
434
  path: Path,
339
435
  request: TypedReadRequest,
340
- ): Promise<TypedReadResponse> => {
341
- const typeName = lastSegment(path);
436
+ ): Promise<TypedReadResponse<DataForPath<D, M, Path>>> => {
437
+ const normalizedPath = normalizePath(path);
438
+ this._assertReady(normalizedPath);
439
+ const typeName = lastSegment(normalizedPath);
342
440
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
343
441
 
344
- return this._dwn.records.read({
442
+ const { status, record } = await this._dwn.records.read({
345
443
  from : request.from,
346
444
  encryption : request.encryption,
347
445
  protocol : this._definition.protocol,
348
446
  filter : {
349
447
  ...request.filter,
350
448
  protocol : this._definition.protocol,
351
- protocolPath : path,
449
+ protocolPath : normalizedPath,
352
450
  ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
353
451
  },
354
452
  });
453
+
454
+ return {
455
+ status,
456
+ record: new TypedRecord<DataForPath<D, M, Path>>(record),
457
+ };
355
458
  },
356
459
 
357
460
  /**
@@ -364,6 +467,7 @@ export class TypedWeb5<
364
467
  _path: Path,
365
468
  request: TypedDeleteRequest,
366
469
  ): Promise<DwnResponseStatus> => {
470
+ this._assertReady(normalizePath(_path));
367
471
  return this._dwn.records.delete({
368
472
  from : request.from,
369
473
  protocol : this._definition.protocol,
@@ -374,8 +478,9 @@ export class TypedWeb5<
374
478
  /**
375
479
  * Subscribe to records at the given protocol path.
376
480
  *
377
- * Returns a {@link LiveQuery} that atomically provides an initial snapshot
378
- * and a real-time stream of deduplicated change events.
481
+ * Returns a {@link TypedLiveQuery} that atomically provides an initial
482
+ * snapshot and a real-time stream of deduplicated change events, with
483
+ * all records typed as `TypedRecord<T>`.
379
484
  *
380
485
  * @param path - The protocol path to subscribe to.
381
486
  * @param request - Optional filter and role.
@@ -383,22 +488,32 @@ export class TypedWeb5<
383
488
  subscribe: async <Path extends ProtocolPaths<D> & string>(
384
489
  path: Path,
385
490
  request?: TypedSubscribeRequest,
386
- ): Promise<TypedSubscribeResponse> => {
387
- const typeName = lastSegment(path);
491
+ ): Promise<TypedSubscribeResponse<DataForPath<D, M, Path>>> => {
492
+ const normalizedPath = normalizePath(path);
493
+ this._assertReady(normalizedPath);
494
+ const typeName = lastSegment(normalizedPath);
388
495
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
389
496
 
390
- return this._dwn.records.subscribe({
497
+ const { status, liveQuery } = await this._dwn.records.subscribe({
391
498
  from : request?.from,
392
499
  filter : {
393
500
  ...request?.filter,
394
501
  protocol : this._definition.protocol,
395
- protocolPath : path,
502
+ protocolPath : normalizedPath,
396
503
  ...(typeEntry?.schema !== undefined ? { schema: typeEntry.schema } : {}),
397
504
  },
398
505
  protocolRole: request?.protocolRole,
399
506
  });
507
+
508
+ return {
509
+ status,
510
+ liveQuery: liveQuery ? new TypedLiveQuery<DataForPath<D, M, Path>>(liveQuery) : undefined,
511
+ };
400
512
  },
401
513
  };
514
+
515
+ this._records = cached;
516
+ return cached;
402
517
  }
403
518
  }
404
519
 
@@ -417,6 +532,15 @@ function definitionsEqual(a: unknown, b: unknown): boolean {
417
532
  return stableStringify(a) === stableStringify(b);
418
533
  }
419
534
 
535
+ /**
536
+ * Strips leading and trailing slashes from a path.
537
+ *
538
+ * `'friend/'` → `'friend'`, `'/group/member/'` → `'group/member'`.
539
+ */
540
+ function normalizePath(path: string): string {
541
+ return path.replace(/^\/+|\/+$/g, '');
542
+ }
543
+
420
544
  /**
421
545
  * Returns the last segment of a slash-delimited path.
422
546
  */
@@ -425,6 +549,35 @@ function lastSegment(path: string): string {
425
549
  return parts[parts.length - 1];
426
550
  }
427
551
 
552
+ /**
553
+ * Recursively collects all valid protocol path strings from a structure object.
554
+ *
555
+ * Given `{ foo: { bar: { $actions: [...] } } }`, returns `Set(['foo', 'foo/bar'])`.
556
+ * Keys starting with `$` are skipped.
557
+ */
558
+ function collectPaths(
559
+ structure: Record<string, unknown>,
560
+ prefix: string = '',
561
+ ): Set<string> {
562
+ const paths = new Set<string>();
563
+
564
+ for (const key of Object.keys(structure)) {
565
+ if (key.startsWith('$')) { continue; }
566
+
567
+ const fullPath = prefix ? `${prefix}/${key}` : key;
568
+ paths.add(fullPath);
569
+
570
+ const child = structure[key];
571
+ if (child !== null && typeof child === 'object') {
572
+ for (const nested of collectPaths(child as Record<string, unknown>, fullPath)) {
573
+ paths.add(nested);
574
+ }
575
+ }
576
+ }
577
+
578
+ return paths;
579
+ }
580
+
428
581
  /**
429
582
  * Deterministic JSON serialization with sorted keys.
430
583
  */