@dxos/echo 0.9.0 → 0.9.1-main.c7dcc2e112

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 (103) hide show
  1. package/dist/lib/neutral/Annotation.mjs +12 -4
  2. package/dist/lib/neutral/Database.mjs +5 -1
  3. package/dist/lib/neutral/Entity.mjs +12 -8
  4. package/dist/lib/neutral/Feed.mjs +12 -12
  5. package/dist/lib/neutral/Filter.mjs +8 -8
  6. package/dist/lib/neutral/JsonSchema.mjs +4 -4
  7. package/dist/lib/neutral/Migration.mjs +8 -8
  8. package/dist/lib/neutral/Obj.mjs +14 -10
  9. package/dist/lib/neutral/Query.mjs +14 -14
  10. package/dist/lib/neutral/Ref.mjs +4 -4
  11. package/dist/lib/neutral/Relation.mjs +9 -9
  12. package/dist/lib/neutral/Tag.mjs +12 -12
  13. package/dist/lib/neutral/Type.mjs +7 -7
  14. package/dist/lib/neutral/{chunk-UUP46KUQ.mjs → chunk-3JXQUMUI.mjs} +9 -9
  15. package/dist/lib/neutral/{chunk-WTQJHC75.mjs → chunk-4TPQZPLY.mjs} +37 -25
  16. package/dist/lib/neutral/chunk-4TPQZPLY.mjs.map +7 -0
  17. package/dist/lib/neutral/{chunk-ZGNNFYHS.mjs → chunk-5GAIWRQB.mjs} +4 -4
  18. package/dist/lib/neutral/{chunk-ZGNNFYHS.mjs.map → chunk-5GAIWRQB.mjs.map} +2 -2
  19. package/dist/lib/neutral/{chunk-TYGKCRMK.mjs → chunk-6CEISA3V.mjs} +15 -7
  20. package/dist/lib/neutral/{chunk-TYGKCRMK.mjs.map → chunk-6CEISA3V.mjs.map} +3 -3
  21. package/dist/lib/neutral/{chunk-RIVWNMSF.mjs → chunk-A53NTBHE.mjs} +3 -3
  22. package/dist/lib/neutral/{chunk-QQIYS74I.mjs → chunk-AFANXSGV.mjs} +6 -6
  23. package/dist/lib/neutral/{chunk-5SMDBFVB.mjs → chunk-E7IOTOZD.mjs} +6 -6
  24. package/dist/lib/neutral/{chunk-5SMDBFVB.mjs.map → chunk-E7IOTOZD.mjs.map} +2 -2
  25. package/dist/lib/neutral/{chunk-T6E37YIP.mjs → chunk-HGWQR5KH.mjs} +18 -10
  26. package/dist/lib/neutral/chunk-HGWQR5KH.mjs.map +7 -0
  27. package/dist/lib/neutral/{chunk-3PBP4V4O.mjs → chunk-JZ6LFQU5.mjs} +11 -3
  28. package/dist/lib/neutral/chunk-JZ6LFQU5.mjs.map +7 -0
  29. package/dist/lib/neutral/{chunk-7PI7C4EF.mjs → chunk-K3S7D5PR.mjs} +3 -3
  30. package/dist/lib/neutral/{chunk-5SUJPHAE.mjs → chunk-L3OBWBV2.mjs} +2 -2
  31. package/dist/lib/neutral/{chunk-6YDI3J37.mjs → chunk-NOL7O3BL.mjs} +4 -4
  32. package/dist/lib/neutral/{chunk-IGK6FN65.mjs → chunk-PLDMJJJC.mjs} +2 -2
  33. package/dist/lib/neutral/{chunk-LWXVKPPW.mjs → chunk-SZ5WB6HF.mjs} +6 -6
  34. package/dist/lib/neutral/{chunk-MZ7K3MLL.mjs → chunk-TF5BSLF6.mjs} +3 -3
  35. package/dist/lib/neutral/chunk-TF5BSLF6.mjs.map +7 -0
  36. package/dist/lib/neutral/{chunk-7FPIAJIV.mjs → chunk-VQYL2OQF.mjs} +89 -15
  37. package/dist/lib/neutral/chunk-VQYL2OQF.mjs.map +7 -0
  38. package/dist/lib/neutral/{chunk-R5W6DXR4.mjs → chunk-WZ32BF53.mjs} +26 -2
  39. package/dist/lib/neutral/chunk-WZ32BF53.mjs.map +7 -0
  40. package/dist/lib/neutral/{chunk-4ZUHOTCG.mjs → chunk-X3NUUON4.mjs} +3 -3
  41. package/dist/lib/neutral/{chunk-SBVFRTST.mjs → chunk-Z3WMRRAZ.mjs} +14 -6
  42. package/dist/lib/neutral/chunk-Z3WMRRAZ.mjs.map +7 -0
  43. package/dist/lib/neutral/index.mjs +19 -19
  44. package/dist/lib/neutral/internal/index.mjs +26 -6
  45. package/dist/lib/neutral/meta.json +1 -1
  46. package/dist/lib/neutral/testing/index.mjs +19 -19
  47. package/dist/types/src/Annotation.d.ts +1 -1
  48. package/dist/types/src/Annotation.d.ts.map +1 -1
  49. package/dist/types/src/Database.d.ts +22 -0
  50. package/dist/types/src/Database.d.ts.map +1 -1
  51. package/dist/types/src/Entity.d.ts +2 -0
  52. package/dist/types/src/Entity.d.ts.map +1 -1
  53. package/dist/types/src/Feed.d.ts +3 -0
  54. package/dist/types/src/Feed.d.ts.map +1 -1
  55. package/dist/types/src/Obj.d.ts +2 -0
  56. package/dist/types/src/Obj.d.ts.map +1 -1
  57. package/dist/types/src/Ref.d.ts +3 -3
  58. package/dist/types/src/Ref.d.ts.map +1 -1
  59. package/dist/types/src/Tag.d.ts.map +1 -1
  60. package/dist/types/src/Type.d.ts +5 -1
  61. package/dist/types/src/Type.d.ts.map +1 -1
  62. package/dist/types/src/internal/Annotation/annotations.d.ts +41 -0
  63. package/dist/types/src/internal/Annotation/annotations.d.ts.map +1 -1
  64. package/dist/types/src/internal/Obj/atoms.d.ts +5 -0
  65. package/dist/types/src/internal/Obj/atoms.d.ts.map +1 -1
  66. package/dist/types/src/internal/Ref/index.d.ts +1 -0
  67. package/dist/types/src/internal/Ref/index.d.ts.map +1 -1
  68. package/dist/types/src/internal/Ref/ref.d.ts +66 -2
  69. package/dist/types/src/internal/Ref/ref.d.ts.map +1 -1
  70. package/dist/types/src/internal/Ref/strong-deps.d.ts +35 -0
  71. package/dist/types/src/internal/Ref/strong-deps.d.ts.map +1 -0
  72. package/dist/types/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +11 -11
  74. package/src/Annotation.ts +5 -0
  75. package/src/Database.ts +33 -1
  76. package/src/Entity.ts +3 -1
  77. package/src/Feed.ts +3 -0
  78. package/src/Obj.ts +2 -0
  79. package/src/Ref.ts +3 -3
  80. package/src/Tag.ts +1 -0
  81. package/src/Type.ts +16 -2
  82. package/src/internal/Annotation/annotations.ts +56 -0
  83. package/src/internal/Obj/atoms.ts +31 -0
  84. package/src/internal/Obj/json-serializer.ts +3 -3
  85. package/src/internal/Ref/index.ts +1 -0
  86. package/src/internal/Ref/ref.ts +96 -19
  87. package/src/internal/Ref/strong-deps.ts +107 -0
  88. package/dist/lib/neutral/chunk-3PBP4V4O.mjs.map +0 -7
  89. package/dist/lib/neutral/chunk-7FPIAJIV.mjs.map +0 -7
  90. package/dist/lib/neutral/chunk-MZ7K3MLL.mjs.map +0 -7
  91. package/dist/lib/neutral/chunk-R5W6DXR4.mjs.map +0 -7
  92. package/dist/lib/neutral/chunk-SBVFRTST.mjs.map +0 -7
  93. package/dist/lib/neutral/chunk-T6E37YIP.mjs.map +0 -7
  94. package/dist/lib/neutral/chunk-WTQJHC75.mjs.map +0 -7
  95. /package/dist/lib/neutral/{chunk-UUP46KUQ.mjs.map → chunk-3JXQUMUI.mjs.map} +0 -0
  96. /package/dist/lib/neutral/{chunk-RIVWNMSF.mjs.map → chunk-A53NTBHE.mjs.map} +0 -0
  97. /package/dist/lib/neutral/{chunk-QQIYS74I.mjs.map → chunk-AFANXSGV.mjs.map} +0 -0
  98. /package/dist/lib/neutral/{chunk-7PI7C4EF.mjs.map → chunk-K3S7D5PR.mjs.map} +0 -0
  99. /package/dist/lib/neutral/{chunk-5SUJPHAE.mjs.map → chunk-L3OBWBV2.mjs.map} +0 -0
  100. /package/dist/lib/neutral/{chunk-6YDI3J37.mjs.map → chunk-NOL7O3BL.mjs.map} +0 -0
  101. /package/dist/lib/neutral/{chunk-IGK6FN65.mjs.map → chunk-PLDMJJJC.mjs.map} +0 -0
  102. /package/dist/lib/neutral/{chunk-LWXVKPPW.mjs.map → chunk-SZ5WB6HF.mjs.map} +0 -0
  103. /package/dist/lib/neutral/{chunk-4ZUHOTCG.mjs.map → chunk-X3NUUON4.mjs.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/echo",
3
- "version": "0.9.0",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "ECHO API",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -136,16 +136,16 @@
136
136
  "dependencies": {
137
137
  "@effect-atom/atom": "^0.5.3",
138
138
  "effect": "3.21.3",
139
- "@dxos/debug": "0.9.0",
140
- "@dxos/async": "0.9.0",
141
- "@dxos/echo-protocol": "0.9.0",
142
- "@dxos/effect": "0.9.0",
143
- "@dxos/errors": "0.9.0",
144
- "@dxos/keys": "0.9.0",
145
- "@dxos/node-std": "0.9.0",
146
- "@dxos/util": "0.9.0",
147
- "@dxos/log": "0.9.0",
148
- "@dxos/invariant": "0.9.0"
139
+ "@dxos/async": "0.9.1-main.c7dcc2e112",
140
+ "@dxos/echo-protocol": "0.9.1-main.c7dcc2e112",
141
+ "@dxos/effect": "0.9.1-main.c7dcc2e112",
142
+ "@dxos/errors": "0.9.1-main.c7dcc2e112",
143
+ "@dxos/keys": "0.9.1-main.c7dcc2e112",
144
+ "@dxos/invariant": "0.9.1-main.c7dcc2e112",
145
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
146
+ "@dxos/node-std": "0.9.1-main.c7dcc2e112",
147
+ "@dxos/util": "0.9.1-main.c7dcc2e112",
148
+ "@dxos/debug": "0.9.1-main.c7dcc2e112"
149
149
  },
150
150
  "devDependencies": {
151
151
  "@types/node": "22.10.2"
package/src/Annotation.ts CHANGED
@@ -9,6 +9,11 @@ export {
9
9
  FieldLookupAnnotationId,
10
10
  FormInputAnnotation,
11
11
  FormInlineAnnotation,
12
+ FormOrderedAnnotation,
13
+ FormLayoutAnnotation,
14
+ FormLayoutAnnotationId,
15
+ type FormLayoutMap,
16
+ DEFAULT_LAYOUT_NAME,
12
17
  GeneratorAnnotation,
13
18
  GeneratorAnnotationId,
14
19
  type GeneratorAnnotationValue,
package/src/Database.ts CHANGED
@@ -17,6 +17,7 @@ import { type SpaceId, type URI } from '@dxos/keys';
17
17
 
18
18
  import type * as Entity from './Entity';
19
19
  import * as Err from './Err';
20
+ import type * as Feed from './Feed';
20
21
  import type * as Filter from './Filter';
21
22
  import type * as Hypergraph from './Hypergraph';
22
23
  import { type AnyProperties, EntityKind, KindId } from './internal/common/types';
@@ -166,6 +167,19 @@ export interface Database extends Queryable {
166
167
  // TODO(burdon): Return true if removed (currently throws if not present).
167
168
  remove(obj: Entity.Unknown): void;
168
169
 
170
+ /**
171
+ * Appends entities to a feed.
172
+ *
173
+ * The feed must already be stored in the database (added via {@link add}); its underlying
174
+ * queue is addressed by the feed object's URI.
175
+ */
176
+ appendToFeed(feed: Feed.Feed, entities: Entity.Unknown[]): Promise<void>;
177
+
178
+ /**
179
+ * Removes entities from a feed.
180
+ */
181
+ deleteFromFeed(feed: Feed.Feed, entities: Entity.Unknown[]): Promise<void>;
182
+
169
183
  /**
170
184
  * Wait for all pending changes to be saved to disk.
171
185
  * Optionaly waits for changes to be propagated to indexes and event handlers.
@@ -250,7 +264,7 @@ export const resolve: {
250
264
  space: db.spaceId,
251
265
  },
252
266
  })
253
- .resolve(dxn),
267
+ .resolveLegacy(dxn),
254
268
  );
255
269
 
256
270
  if (!object) {
@@ -305,6 +319,24 @@ export const addType = <T extends Type.AnyEntity>(type: T): Effect.Effect<T, nev
305
319
  export const remove = <T extends Entity.Unknown>(obj: T): Effect.Effect<void, never, Service> =>
306
320
  Service.pipe(Effect.map(({ db }) => db.remove(obj))).pipe(Effect.withSpan('Database.remove'));
307
321
 
322
+ /**
323
+ * Appends entities to a feed.
324
+ * @see {@link Database.appendToFeed}
325
+ */
326
+ export const appendToFeed = (feed: Feed.Feed, entities: Entity.Unknown[]): Effect.Effect<void, never, Service> =>
327
+ Service.pipe(
328
+ Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.appendToFeed(feed, entities))),
329
+ ).pipe(Effect.withSpan('Database.appendToFeed'));
330
+
331
+ /**
332
+ * Removes entities from a feed.
333
+ * @see {@link Database.deleteFromFeed}
334
+ */
335
+ export const deleteFromFeed = (feed: Feed.Feed, entities: Entity.Unknown[]): Effect.Effect<void, never, Service> =>
336
+ Service.pipe(
337
+ Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.deleteFromFeed(feed, entities))),
338
+ ).pipe(Effect.withSpan('Database.deleteFromFeed'));
339
+
308
340
  /**
309
341
  * Flushes pending changes to disk.
310
342
  * @see {@link Database.flush}
package/src/Entity.ts CHANGED
@@ -190,7 +190,7 @@ export type AnyInput = Unknown | Snapshot;
190
190
  * @param options.prefer - Controls the URI form (see {@link internal.GetURIOptions}).
191
191
  */
192
192
  export const getURI = (entity: AnyInput, options?: internal.GetURIOptions): URI.URI =>
193
- isTypeEntity(entity) ? Type.getURI(entity as Type.AnyEntity) : internal.getUri(entity as Unknown, options);
193
+ isTypeEntity(entity) ? Type.getURI(entity as Type.AnyEntity, options) : internal.getUri(entity as Unknown, options);
194
194
 
195
195
  /**
196
196
  * Get the DXN of an entity's type. For object/relation instances this is the URI
@@ -343,3 +343,5 @@ export const removeTag = (entity: Mutable<Unknown>, tag: Ref.Ref<Tag.Tag>): void
343
343
  //
344
344
 
345
345
  export const atom = objInternal.makeEntity;
346
+ export const labelAtom = objInternal.makeLabelAtom;
347
+ export const labelProperty = internal.getLabelProperty;
package/src/Feed.ts CHANGED
@@ -245,6 +245,9 @@ export const remove = (feed: Feed, items: (Entity.Unknown | Obj.Snapshot)[]): Ef
245
245
  /**
246
246
  * Creates a reactive query over items in a feed.
247
247
  *
248
+ * In non-Effect code, query a feed directly through the database with a feed scope:
249
+ * `db.query(Query.select(filter).from(Scope.feed(Feed.getQueueUri(feed))))`.
250
+ *
248
251
  * @example
249
252
  * ```ts
250
253
  * const result = yield* Feed.query(feed, Filter.type(Person));
package/src/Obj.ts CHANGED
@@ -926,3 +926,5 @@ export const version = (entity: Unknown | Snapshot): Version => internal.version
926
926
  export const atom = objInternal.makeAtom;
927
927
  export const atomReactive = objInternal.makeWithReactive;
928
928
  export const atomProperty = objInternal.makeProperty;
929
+ export const labelAtom = objInternal.makeLabelAtom;
930
+ export const labelProperty = internal.getLabelProperty;
package/src/Ref.ts CHANGED
@@ -16,7 +16,7 @@ import * as refInternal from './internal/Ref';
16
16
  import type * as JsonSchema from './JsonSchema';
17
17
  import type * as Obj from './Obj';
18
18
  // eslint-disable-next-line @dxos/rules/import-as-namespace
19
- import type * as TypeNs from './Type';
19
+ import type * as Type$ from './Type';
20
20
 
21
21
  /**
22
22
  * Instance type for a reference.
@@ -51,11 +51,11 @@ export type Unknown = refInternal.Ref<Obj.Unknown>;
51
51
  * ```
52
52
  */
53
53
  export const Ref: {
54
- <S extends TypeNs.AnyObj | TypeNs.AnyRelation>(type: S): RefSchema<TypeNs.InstanceType<S> & Obj.Unknown>;
54
+ <S extends Type$.AnyObj | Type$.AnyRelation>(type: S): RefSchema<Type$.InstanceType<S> & Obj.Unknown>;
55
55
 
56
56
  // `Type.Type` entities (the meta-schema kind) can be referenced too — e.g. a
57
57
  // trigger that points to a stored function/workflow definition.
58
- <T extends TypeNs.Type<any>>(type: T): RefSchema<TypeNs.InstanceType<T>>;
58
+ <T extends Type$.Type<any>>(type: T): RefSchema<Type$.InstanceType<T>>;
59
59
 
60
60
  // Schema-side overload for the well-known "any object" / "any relation" schemas.
61
61
  // Other raw `Schema.Schema` values are intentionally rejected — callers should
package/src/Tag.ts CHANGED
@@ -72,6 +72,7 @@ export const findOrCreate = async (
72
72
  }
73
73
  return existing;
74
74
  }
75
+
75
76
  return db.add(Obj.make(Tag, { [Obj.Meta]: { keys: [key] }, label, ...withHue }));
76
77
  }
77
78
 
package/src/Type.ts CHANGED
@@ -375,17 +375,31 @@ export type AnyRef = Schema.Schema<internal.Ref<any>, EncodedReference>;
375
375
  * - Persisted `Type.Type` instance (has `id`) → local `EID` (`echo:/<objectId>`).
376
376
  * - In-memory `Type.Type` draft (has `id`, no typename) → local `EID`.
377
377
  *
378
+ * When `options.prefer === 'named'` the result is forced to a DXN — for
379
+ * persisted type entities the typename + version from meta are used to
380
+ * construct the DXN; static types already return a DXN by default.
381
+ *
378
382
  * Only accepts `Type.AnyEntity` entities. Raw `Schema.Schema` values and the
379
383
  * branded `Obj.Unknown` / `Relation.Unknown` schemas are intentionally not
380
384
  * supported — use `internal.getSchemaURI` or the schema's typename annotation
381
385
  * directly when working at the schema level.
382
386
  */
383
- export const getURI = (input: AnyEntity): URI.URI => {
387
+ export const getURI = (input: AnyEntity, options?: internal.GetURIOptions): URI.URI => {
384
388
  // For Type entities, route through `getTypeURIFromSpecifier` (id → EID,
385
389
  // typename/version → DXN). For Obj/Relation entities, unwrap to the source
386
390
  // Effect Schema first and read its annotations.
387
391
  if (isType(input)) {
388
- return internal.getTypeURIFromSpecifier(input);
392
+ const uri = internal.getTypeURIFromSpecifier(input);
393
+ if (options?.prefer === 'named' && !DXN.isDXN(uri)) {
394
+ // Persisted type — the auto-detected URI is an EID, but caller wants a
395
+ // DXN. Construct from typename + version (both live in EntityMeta).
396
+ const typename = getTypename(input);
397
+ const version = getMeta(input).version;
398
+ if (typename && version) {
399
+ return DXN.make(typename, version);
400
+ }
401
+ }
402
+ return uri;
389
403
  }
390
404
  return internal.getSchemaURI(getSchema(input)) ?? raise(new TypeError('Type entity has no URI'));
391
405
  };
@@ -413,6 +413,47 @@ export const FormInputAnnotation = createAnnotationHelper<boolean>(FormInputAnno
413
413
  export const FormInlineAnnotationId = Symbol.for('@dxos/schema/annotation/FormInline');
414
414
  export const FormInlineAnnotation = createAnnotationHelper<boolean>(FormInlineAnnotationId);
415
415
 
416
+ /**
417
+ * When set on an array property, the form renders it as an ordered,
418
+ * drag-to-reorder list (drag handles per row). Element order is meaningful and
419
+ * user-controllable; reordering rewrites the array.
420
+ */
421
+ export const FormOrderedAnnotationId = Symbol.for('@dxos/schema/annotation/FormOrdered');
422
+ export const FormOrderedAnnotation = createAnnotationHelper<boolean>(FormOrderedAnnotationId);
423
+
424
+ /**
425
+ * Annotation carrying one or more named layout DSL templates that control how a
426
+ * form arranges a schema's fields (consumed by `@dxos/react-ui-form`'s
427
+ * `Form.Layout` / `Form.FieldSet`). Callers select a variant by name; the
428
+ * implicit name is `DEFAULT_LAYOUT_NAME` (`'default'`).
429
+ *
430
+ * Templates use a minimal XML grammar. Example:
431
+ *
432
+ * FormLayoutAnnotation.set({
433
+ * default: `
434
+ * <grid cols="2">
435
+ * <field name="origin"/>
436
+ * <field name="destination"/>
437
+ * <field name="provider" span="2"/>
438
+ * </grid>
439
+ * `,
440
+ * card: `
441
+ * <grid cols="1">
442
+ * <field name="provider"/>
443
+ * <field name="number"/>
444
+ * </grid>
445
+ * `,
446
+ * })
447
+ */
448
+ export const FormLayoutAnnotationId = Symbol.for('@dxos/react-ui-form/annotation/Layout');
449
+
450
+ export type FormLayoutMap = Record<string, string>;
451
+
452
+ export const FormLayoutAnnotation = createAnnotationHelper<FormLayoutMap>(FormLayoutAnnotationId);
453
+
454
+ /** Name used when no explicit form-layout variant is requested. */
455
+ export const DEFAULT_LAYOUT_NAME = 'default';
456
+
416
457
  /**
417
458
  * Default field to be used on referenced schema to lookup the value.
418
459
  */
@@ -557,6 +598,21 @@ export const setLabel = (entity: Mutable<AnyProperties>, label: string) => {
557
598
  }
558
599
  };
559
600
 
601
+ /**
602
+ * Returns the primary label property key for an entity.
603
+ * Reads the first accessor from {@link LabelAnnotation}, defaulting to 'name'.
604
+ */
605
+ export const getLabelProperty = (entity: AnyProperties): string => {
606
+ const schema = getSchema(entity);
607
+ if (schema == null) {
608
+ return 'name';
609
+ }
610
+ return LabelAnnotation.get(schema).pipe(
611
+ Option.flatMap((fields) => Option.fromNullable(fields[0])),
612
+ Option.getOrElse(() => 'name'),
613
+ );
614
+ };
615
+
560
616
  /**
561
617
  * Get the description of an entity.
562
618
  * Accepts both reactive entities and snapshots.
@@ -14,6 +14,7 @@ import type * as Entity from '../../Entity';
14
14
  import type * as Obj from '../../Obj';
15
15
  import type * as Ref from '../../Ref';
16
16
  import type * as Relation from '../../Relation';
17
+ import { getLabel } from '../Annotation';
17
18
  import { subscribe } from '../common/proxy/reactive';
18
19
  import { isEntity, getDatabase } from '../Entity';
19
20
  import { RefTypeId } from '../Ref/ref';
@@ -242,3 +243,33 @@ export const makeRelation = <T extends Relation.Unknown>(relation: T): Atom.Atom
242
243
  assertArgument(isEntity(relation), 'relation', 'Must be a reactive ECHO relation');
243
244
  return relationFamily(relation);
244
245
  };
246
+
247
+ /**
248
+ * Atom family for an entity's label string.
249
+ * Fires only when the computed label actually changes, not on every entity mutation.
250
+ */
251
+ const labelAtomFamily = Atom.family(<T extends Entity.Unknown>(entity: T): Atom.Atom<string | undefined> => {
252
+ return Atom.make<string | undefined>((get) => {
253
+ let previous = getLabel(entity);
254
+
255
+ const unsubscribe = subscribe(entity, () => {
256
+ const next = getLabel(entity);
257
+ if (next !== previous) {
258
+ previous = next;
259
+ get.setSelf(next);
260
+ }
261
+ });
262
+
263
+ get.addFinalizer(() => unsubscribe());
264
+ return previous;
265
+ }).pipe(Atom.keepAlive);
266
+ });
267
+
268
+ /**
269
+ * Create a read-only atom for the label of a reactive ECHO entity.
270
+ * Re-evaluates on entity mutation; only propagates when the label string changes.
271
+ */
272
+ export const makeLabelAtom = <T extends Entity.Unknown>(entity: T): Atom.Atom<string | undefined> => {
273
+ assertArgument(isEntity(entity), 'entity', 'Must be a reactive ECHO entity');
274
+ return labelAtomFamily(entity);
275
+ };
@@ -129,8 +129,8 @@ export const objectFromJSON = async (
129
129
  const sourceDxn = jsonData[ATTR_RELATION_SOURCE] ?? raise(new TypeError('Missing relation source'));
130
130
  const targetDxn = jsonData[ATTR_RELATION_TARGET] ?? raise(new TypeError('Missing relation target'));
131
131
 
132
- const source = (await refResolver?.resolve(sourceDxn)) as AnyEntity | undefined;
133
- const target = (await refResolver?.resolve(targetDxn)) as AnyEntity | undefined;
132
+ const source = (await refResolver?.resolveLegacy(sourceDxn)) as AnyEntity | undefined;
133
+ const target = (await refResolver?.resolveLegacy(targetDxn)) as AnyEntity | undefined;
134
134
 
135
135
  defineHiddenProperty(obj, KindId, EntityKind.Relation);
136
136
  defineHiddenProperty(obj, RelationSourceDXNId, sourceDxn);
@@ -161,7 +161,7 @@ export const objectFromJSON = async (
161
161
 
162
162
  if (jsonData[ATTR_PARENT]) {
163
163
  const parentDxn = jsonData[ATTR_PARENT];
164
- const resolvedParent = (await refResolver?.resolve(parentDxn)) as Obj.Unknown | undefined;
164
+ const resolvedParent = (await refResolver?.resolveLegacy(parentDxn)) as Obj.Unknown | undefined;
165
165
  defineHiddenProperty(obj, ParentId, resolvedParent);
166
166
  } else if (parent) {
167
167
  defineHiddenProperty(obj, ParentId, parent);
@@ -4,3 +4,4 @@
4
4
 
5
5
  export * from './ref';
6
6
  export * from './ref-array';
7
+ export * from './strong-deps';
@@ -81,7 +81,8 @@ export const getReferenceAst = (ast: SchemaAST.AST): RefereneAST | undefined =>
81
81
  };
82
82
  };
83
83
 
84
- export const RefTypeId: unique symbol = Symbol('@dxos/echo/internal/Ref');
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ export const RefTypeId: unique symbol = Symbol.for('@dxos/echo/internal/Ref') as any;
85
86
 
86
87
  /**
87
88
  * Reference Schema.
@@ -253,7 +254,7 @@ export declare namespace Ref {
253
254
  }
254
255
 
255
256
  Ref.isRef = (obj: any): obj is Ref<any> => {
256
- return obj && typeof obj === 'object' && RefTypeId in obj;
257
+ return obj != null && typeof obj === 'object' && RefTypeId in obj;
257
258
  };
258
259
 
259
260
  Ref.hasEntityId = (id: EntityId) => (ref: Ref<any>) => {
@@ -278,7 +279,8 @@ Ref.make = <T extends AnyProperties>(obj: T): Ref<T> => {
278
279
  const id = obj.id;
279
280
  invariant(EntityId.isValid(id), 'Invalid object ID');
280
281
  const uri = EID.make({ entityId: id });
281
- return new RefImpl(uri, obj);
282
+ const ref = new RefImpl(uri, obj);
283
+ return ref;
282
284
  };
283
285
 
284
286
  Ref.fromURI = (uri: URI.URI): Ref<any> => {
@@ -380,22 +382,79 @@ const getSchemaExpectedName = (ast: SchemaAST.Annotated): string | undefined =>
380
382
  );
381
383
  };
382
384
 
385
+ /**
386
+ * Load-source ceiling for a resolution request. Cheaper tiers are always probed first; the
387
+ * ceiling caps how far a request is allowed to reach:
388
+ * - `working-set`: synchronous, in-memory only. A miss settles `unavailable` immediately.
389
+ * - `disk`: probe the working set, then local storage; never the network.
390
+ * - `network`: probe the working set, disk, then fetch from peers.
391
+ *
392
+ * `unavailable` is always relative to the requested ceiling (disk-unavailable ≠ network-unavailable).
393
+ */
394
+ export type RefSource = 'working-set' | 'disk' | 'network';
395
+
396
+ /**
397
+ * A stateful, closure-aware handle to an in-flight (or settled) reference resolution.
398
+ *
399
+ * `ready` means the entity is FULLY USABLE: its own body and its strong-dependency closure are all
400
+ * materialized. The body-vs-closure split is internal; `requesting` transparently covers "a
401
+ * dependency is still loading".
402
+ */
403
+ export interface RefResolverRequest {
404
+ /**
405
+ * `pending` is a one-way entry state (never re-entered). `requesting` means the body and/or
406
+ * closure is still loading. `ready` means fully usable. `unavailable` means unreachable at the
407
+ * requested ceiling.
408
+ */
409
+ readonly state: 'pending' | 'requesting' | 'ready' | 'unavailable';
410
+
411
+ /**
412
+ * Fires (deferred to a microtask) whenever {@link state} changes.
413
+ */
414
+ readonly stateChanged: Event<void>;
415
+
416
+ /**
417
+ * Synchronous snapshot of the resolved entity; defined iff `state === 'ready'`.
418
+ */
419
+ getResult(): AnyProperties | undefined;
420
+
421
+ /**
422
+ * Settles when `state ∈ {ready, unavailable}`.
423
+ */
424
+ wait(): Promise<AnyProperties | undefined>;
425
+
426
+ /**
427
+ * Decrements the refcount on the shared load op(s); cancels the underlying IO at zero.
428
+ */
429
+ abort(): void;
430
+ }
431
+
383
432
  export interface RefResolver {
433
+ /**
434
+ * Resolve a reference, returning a stateful handle. `source` is a required ceiling; cheaper
435
+ * tiers are always probed first.
436
+ */
437
+ resolve(uri: URI.URI, options: { source: RefSource }): RefResolverRequest;
438
+
384
439
  /**
385
440
  * Resolve ref synchronously from the objects in the working set.
386
441
  *
387
442
  * @param uri
388
443
  * @param load If true the resolver should attempt to load the object from disk.
389
444
  * @param onLoad Callback to call when the object is loaded.
445
+ * @deprecated Use {@link resolve} with `{ source: 'working-set' }`. Removed in Task 11.
390
446
  */
391
447
  resolveSync(uri: URI.URI, load: boolean, onLoad?: () => void): AnyProperties | undefined;
392
448
 
393
449
  /**
394
450
  * Resolver ref asynchronously.
451
+ * @deprecated Use {@link resolve} with `{ source: 'network' }`. Removed in Task 11.
395
452
  */
396
- resolve(uri: URI.URI): Promise<AnyProperties | undefined>;
453
+ resolveLegacy(uri: URI.URI): Promise<AnyProperties | undefined>;
397
454
 
398
- // TODO(dmaretskyi): Combine with `resolve`.
455
+ /**
456
+ * @deprecated Use {@link resolve} + `Type.getSchema`. Removed in Task 11.
457
+ */
399
458
  resolveSchema(uri: URI.URI): Promise<Schema.Schema.AnyNoContext | undefined>;
400
459
 
401
460
  /**
@@ -404,10 +463,26 @@ export interface RefResolver {
404
463
  * via `Obj.getType` / `Entity.getType`. Optional — resolvers that only
405
464
  * carry raw schemas may leave this unimplemented; the deserializer falls
406
465
  * back to leaving the type entity unset.
466
+ * @deprecated Use {@link resolve}. Removed in Task 11.
407
467
  */
408
468
  resolveType?(uri: URI.URI): Promise<unknown | undefined>;
409
469
  }
410
470
 
471
+ /**
472
+ * A settled {@link RefResolverRequest} with a fixed `state` and `result`, used by resolvers
473
+ * whose backends are fully synchronous (e.g. {@link StaticRefResolver}).
474
+ */
475
+ export const makeSettledRequest = (
476
+ state: RefResolverRequest['state'],
477
+ result: AnyProperties | undefined,
478
+ ): RefResolverRequest => ({
479
+ state,
480
+ stateChanged: new Event<void>(),
481
+ getResult: () => (state === 'ready' ? result : undefined),
482
+ wait: async () => (state === 'ready' ? result : undefined),
483
+ abort: () => {},
484
+ });
485
+
411
486
  export class RefImpl<T> implements Ref<T> {
412
487
  #uri: URI.URI;
413
488
  #resolver?: RefResolver = undefined;
@@ -465,7 +540,7 @@ export class RefImpl<T> implements Ref<T> {
465
540
  return this.#target;
466
541
  }
467
542
  invariant(this.#resolver, 'Resolver is not set');
468
- const obj = await this.#resolver.resolve(this.#uri);
543
+ const obj = await this.#resolver.resolveLegacy(this.#uri);
469
544
  if (obj == null) {
470
545
  throw new Error('Object not found');
471
546
  }
@@ -480,7 +555,7 @@ export class RefImpl<T> implements Ref<T> {
480
555
  return this.#target;
481
556
  }
482
557
  invariant(this.#resolver, 'Resolver is not set');
483
- return (await this.#resolver.resolve(this.#uri)) as T | undefined;
558
+ return (await this.#resolver.resolveLegacy(this.#uri)) as T | undefined;
484
559
  }
485
560
 
486
561
  /**
@@ -624,27 +699,29 @@ export class StaticRefResolver implements RefResolver {
624
699
  return this;
625
700
  }
626
701
 
702
+ resolve(uri: URI.URI, _options: { source: RefSource }): RefResolverRequest {
703
+ const obj = this.#lookup(uri);
704
+ return makeSettledRequest(obj ? 'ready' : 'unavailable', obj);
705
+ }
706
+
627
707
  resolveSync(uri: URI.URI, _load: boolean, _onLoad?: () => void): AnyProperties | undefined {
628
- const echoUri = EID.tryParse(uri);
629
- const id = echoUri ? EID.getEntityId(echoUri) : undefined;
630
- if (id == null) {
631
- return undefined;
632
- }
708
+ return this.#lookup(uri);
709
+ }
633
710
 
634
- return this.objects.get(id);
711
+ async resolveLegacy(uri: URI.URI): Promise<AnyProperties | undefined> {
712
+ return this.#lookup(uri);
713
+ }
714
+
715
+ async resolveSchema(uri: URI.URI): Promise<Schema.Schema.AnyNoContext | undefined> {
716
+ return this.schemas.get(uri);
635
717
  }
636
718
 
637
- async resolve(uri: URI.URI): Promise<AnyProperties | undefined> {
719
+ #lookup(uri: URI.URI): AnyProperties | undefined {
638
720
  const echoUri = EID.tryParse(uri);
639
721
  const id = echoUri ? EID.getEntityId(echoUri) : undefined;
640
722
  if (id == null) {
641
723
  return undefined;
642
724
  }
643
-
644
725
  return this.objects.get(id);
645
726
  }
646
-
647
- async resolveSchema(uri: URI.URI): Promise<Schema.Schema.AnyNoContext | undefined> {
648
- return this.schemas.get(uri);
649
- }
650
727
  }
@@ -0,0 +1,107 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { EID, type SpaceId, type URI } from '@dxos/keys';
6
+
7
+ import { EntityKind } from '../common/types/entity';
8
+ import { RelationSourceDXNId, RelationTargetDXNId } from '../common/types/model-symbols';
9
+ import { ParentId, TypeId } from '../common/types/typename';
10
+ import { getObjectEchoUri } from '../Entity/util';
11
+
12
+ /**
13
+ * Normalized view of the references that make up an entity's strong-dependency set, store-agnostic
14
+ * so it can be filled from an {@link ObjectCore} (db) or a decoded entity (queue item / snapshot).
15
+ */
16
+ export interface StrongDepRefs {
17
+ /** Entity kind — relations additionally depend on their source/target. */
18
+ kind: EntityKind;
19
+ /** Type reference URI (`echo:` for a persisted stored schema, `dxn:` for a static/registry type). */
20
+ type?: URI.URI;
21
+ /** Relation source reference URI. */
22
+ source?: URI.URI;
23
+ /** Relation target reference URI. */
24
+ target?: URI.URI;
25
+ /** Parent reference URI. */
26
+ parent?: URI.URI;
27
+ }
28
+
29
+ /**
30
+ * Direct strong-dependency URIs of an entity: the schema (only when stored as an object), the
31
+ * relation source/target, and the parent. Returns URIs (cross-space `echo:` EIDs and queue-item
32
+ * EIDs included); a persisted schema is an `echo:` URI, a static/registry type is a `dxn:` URI.
33
+ *
34
+ * A static/registry type (`dxn:`) is intentionally NOT a strong dep: it is always synchronously
35
+ * available through the registry, so gating on it would needlessly delay surfacing. Only a
36
+ * persisted (db-backed) schema, addressed by an `echo:` URI, must load before its instances.
37
+ */
38
+ export const getStrongDependencyUris = (refs: StrongDepRefs, owningSpaceId?: SpaceId): URI.URI[] => {
39
+ const res: URI.URI[] = [];
40
+
41
+ if (refs.type != null && EID.tryParse(refs.type) != null) {
42
+ res.push(qualifyToSpace(refs.type, owningSpaceId));
43
+ }
44
+
45
+ if (refs.kind === EntityKind.Relation) {
46
+ if (refs.source != null) {
47
+ res.push(qualifyToSpace(refs.source, owningSpaceId));
48
+ }
49
+ if (refs.target != null) {
50
+ res.push(qualifyToSpace(refs.target, owningSpaceId));
51
+ }
52
+ }
53
+
54
+ if (refs.parent != null) {
55
+ res.push(qualifyToSpace(refs.parent, owningSpaceId));
56
+ }
57
+
58
+ return res;
59
+ };
60
+
61
+ /**
62
+ * Qualifies a space-less (relative) `echo:` dependency URI with the space of the entity that owns it.
63
+ * Same-space references are persisted relative; resolution needs them absolute to route to a single
64
+ * space. Already-qualified URIs (cross-space) and non-`echo:` URIs (registry `dxn:`) pass through.
65
+ */
66
+ const qualifyToSpace = (uri: URI.URI, owningSpaceId: SpaceId | undefined): URI.URI => {
67
+ if (owningSpaceId == null) {
68
+ return uri;
69
+ }
70
+ const eid = EID.tryParse(uri);
71
+ if (eid == null || !EID.isLocal(eid)) {
72
+ return uri;
73
+ }
74
+ const entityId = EID.getEntityId(eid);
75
+ return entityId != null ? EID.make({ spaceId: owningSpaceId, entityId }) : uri;
76
+ };
77
+
78
+ /**
79
+ * Direct strong-dependency URIs of a live (decoded) entity — a queue item, cross-space proxy, or
80
+ * snapshot. Reads the reference URIs off the entity's hidden model symbols. ECHO db cores compute
81
+ * the same set from their raw encoded references via {@link getStrongDependencyUris}.
82
+ */
83
+ export const getStrongDependencies = (entity: unknown): URI.URI[] => {
84
+ if (entity == null || typeof entity !== 'object') {
85
+ return [];
86
+ }
87
+ const props = entity as Record<symbol, unknown>;
88
+ const kind =
89
+ props[RelationSourceDXNId] != null || props[RelationTargetDXNId] != null ? EntityKind.Relation : EntityKind.Object;
90
+ const parent = props[ParentId];
91
+
92
+ // The entity's own space qualifies any relative dependency URIs it carries.
93
+ const selfUri = getObjectEchoUri(entity);
94
+ const owningSpaceId = selfUri != null ? EID.getSpaceId(selfUri) : undefined;
95
+
96
+ return getStrongDependencyUris(
97
+ {
98
+ kind,
99
+ type: typeof props[TypeId] === 'string' ? (props[TypeId] as URI.URI) : undefined,
100
+ source: props[RelationSourceDXNId] as URI.URI | undefined,
101
+ target: props[RelationTargetDXNId] as URI.URI | undefined,
102
+ // A parent is held as the resolved entity; derive its absolute URI for the dependency edge.
103
+ parent: parent != null ? getObjectEchoUri(parent) : undefined,
104
+ },
105
+ owningSpaceId,
106
+ );
107
+ };