@dxos/echo 0.10.0 → 0.11.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 (274) hide show
  1. package/dist/lib/Annotation.mjs +3 -0
  2. package/dist/lib/Blob.mjs +160 -0
  3. package/dist/lib/Blob.mjs.map +1 -0
  4. package/dist/lib/Database.mjs +2 -0
  5. package/dist/lib/Entity.mjs +231 -0
  6. package/dist/lib/Entity.mjs.map +1 -0
  7. package/dist/lib/Err.mjs +105 -0
  8. package/dist/lib/Err.mjs.map +1 -0
  9. package/dist/lib/Feed.mjs +338 -0
  10. package/dist/lib/Feed.mjs.map +1 -0
  11. package/dist/lib/Filter.mjs +380 -0
  12. package/dist/lib/Filter.mjs.map +1 -0
  13. package/dist/lib/Format.mjs +47 -0
  14. package/dist/lib/Format.mjs.map +1 -0
  15. package/dist/lib/JsonSchema.mjs +21 -0
  16. package/dist/lib/JsonSchema.mjs.map +1 -0
  17. package/dist/lib/Key.mjs +15 -0
  18. package/dist/lib/Key.mjs.map +1 -0
  19. package/dist/lib/Migration.mjs +38 -0
  20. package/dist/lib/Migration.mjs.map +1 -0
  21. package/dist/lib/Obj.mjs +660 -0
  22. package/dist/lib/Obj.mjs.map +1 -0
  23. package/dist/lib/Order.mjs +65 -0
  24. package/dist/lib/Order.mjs.map +1 -0
  25. package/dist/lib/Query.mjs +336 -0
  26. package/dist/lib/Query.mjs.map +1 -0
  27. package/dist/lib/QueryResult.mjs +7 -0
  28. package/dist/lib/QueryResult.mjs.map +1 -0
  29. package/dist/lib/Ref.mjs +44 -0
  30. package/dist/lib/Ref.mjs.map +1 -0
  31. package/dist/lib/Registry.mjs +35 -0
  32. package/dist/lib/Registry.mjs.map +1 -0
  33. package/dist/lib/Relation.mjs +397 -0
  34. package/dist/lib/Relation.mjs.map +1 -0
  35. package/dist/lib/Scope.mjs +51 -0
  36. package/dist/lib/Scope.mjs.map +1 -0
  37. package/dist/lib/Tag.mjs +70 -0
  38. package/dist/lib/Tag.mjs.map +1 -0
  39. package/dist/lib/Text.mjs +82 -0
  40. package/dist/lib/Text.mjs.map +1 -0
  41. package/dist/lib/Type.mjs +371 -0
  42. package/dist/lib/Type.mjs.map +1 -0
  43. package/dist/lib/chunk-Annotation.mjs +221 -0
  44. package/dist/lib/chunk-Annotation.mjs.map +1 -0
  45. package/dist/lib/chunk-Database.mjs +918 -0
  46. package/dist/lib/chunk-Database.mjs.map +1 -0
  47. package/dist/lib/chunk-atom-snapshot.mjs +249 -0
  48. package/dist/lib/chunk-atom-snapshot.mjs.map +1 -0
  49. package/dist/lib/chunk-internal.mjs +2775 -0
  50. package/dist/lib/chunk-internal.mjs.map +1 -0
  51. package/dist/lib/chunk-json-schema.mjs +660 -0
  52. package/dist/lib/chunk-json-schema.mjs.map +1 -0
  53. package/dist/lib/chunk-number.mjs +164 -0
  54. package/dist/lib/chunk-number.mjs.map +1 -0
  55. package/dist/lib/chunk-ref-array.mjs +31 -0
  56. package/dist/lib/chunk-ref-array.mjs.map +1 -0
  57. package/dist/lib/chunk-ref.mjs +590 -0
  58. package/dist/lib/chunk-ref.mjs.map +1 -0
  59. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  60. package/dist/lib/chunk-select.mjs +186 -0
  61. package/dist/lib/chunk-select.mjs.map +1 -0
  62. package/dist/lib/chunk-src.mjs +234 -0
  63. package/dist/lib/chunk-src.mjs.map +1 -0
  64. package/dist/lib/chunk-util.mjs +140 -0
  65. package/dist/lib/chunk-util.mjs.map +1 -0
  66. package/dist/lib/index.mjs +24 -0
  67. package/dist/lib/internal.mjs +10 -0
  68. package/dist/lib/testing.mjs +346 -0
  69. package/dist/lib/testing.mjs.map +1 -0
  70. package/dist/types/src/Aggregate.d.ts +86 -0
  71. package/dist/types/src/Aggregate.d.ts.map +1 -0
  72. package/dist/types/src/Blob.d.ts +153 -0
  73. package/dist/types/src/Blob.d.ts.map +1 -0
  74. package/dist/types/src/Blob.test.d.ts +2 -0
  75. package/dist/types/src/Blob.test.d.ts.map +1 -0
  76. package/dist/types/src/Database.d.ts +153 -4
  77. package/dist/types/src/Database.d.ts.map +1 -1
  78. package/dist/types/src/Err.d.ts +116 -0
  79. package/dist/types/src/Err.d.ts.map +1 -1
  80. package/dist/types/src/Feed.d.ts +116 -28
  81. package/dist/types/src/Feed.d.ts.map +1 -1
  82. package/dist/types/src/Feed.test.d.ts +2 -0
  83. package/dist/types/src/Feed.test.d.ts.map +1 -0
  84. package/dist/types/src/Filter.d.ts +14 -6
  85. package/dist/types/src/Filter.d.ts.map +1 -1
  86. package/dist/types/src/Hypergraph.d.ts +18 -0
  87. package/dist/types/src/Hypergraph.d.ts.map +1 -1
  88. package/dist/types/src/Json.d.ts.map +1 -1
  89. package/dist/types/src/Obj.d.ts +14 -1
  90. package/dist/types/src/Obj.d.ts.map +1 -1
  91. package/dist/types/src/Order.d.ts +9 -3
  92. package/dist/types/src/Order.d.ts.map +1 -1
  93. package/dist/types/src/Query.d.ts +107 -26
  94. package/dist/types/src/Query.d.ts.map +1 -1
  95. package/dist/types/src/Relation.d.ts +6 -0
  96. package/dist/types/src/Relation.d.ts.map +1 -1
  97. package/dist/types/src/Type.d.ts.map +1 -1
  98. package/dist/types/src/View.d.ts +1 -1
  99. package/dist/types/src/index.d.ts +2 -0
  100. package/dist/types/src/index.d.ts.map +1 -1
  101. package/dist/types/src/internal/Entity/api.d.ts +6 -0
  102. package/dist/types/src/internal/Entity/api.d.ts.map +1 -1
  103. package/dist/types/src/internal/Entity/model.d.ts +3 -2
  104. package/dist/types/src/internal/Entity/model.d.ts.map +1 -1
  105. package/dist/types/src/internal/Filter/match.d.ts.map +1 -1
  106. package/dist/types/src/internal/JsonSchema/json-schema-type.d.ts +34 -8
  107. package/dist/types/src/internal/JsonSchema/json-schema-type.d.ts.map +1 -1
  108. package/dist/types/src/internal/JsonSchema/json-schema.d.ts.map +1 -1
  109. package/dist/types/src/internal/Obj/atoms.d.ts.map +1 -1
  110. package/dist/types/src/internal/Obj/json-serializer.d.ts.map +1 -1
  111. package/dist/types/src/internal/Query/pretty.d.ts.map +1 -1
  112. package/dist/types/src/internal/Query/query-result-effect.d.ts.map +1 -1
  113. package/dist/types/src/internal/common/proxy/make-object.d.ts +13 -0
  114. package/dist/types/src/internal/common/proxy/make-object.d.ts.map +1 -1
  115. package/dist/types/src/internal/common/proxy/reactive.d.ts.map +1 -1
  116. package/dist/types/src/internal/common/proxy/symbols.d.ts.map +1 -1
  117. package/dist/types/src/internal/common/proxy/typed-handler.d.ts +12 -0
  118. package/dist/types/src/internal/common/proxy/typed-handler.d.ts.map +1 -1
  119. package/dist/types/src/internal/common/types/index.d.ts +1 -0
  120. package/dist/types/src/internal/common/types/index.d.ts.map +1 -1
  121. package/dist/types/src/internal/common/types/model-symbols.d.ts +5 -0
  122. package/dist/types/src/internal/common/types/model-symbols.d.ts.map +1 -1
  123. package/dist/types/src/internal/common/types/projection.d.ts +32 -0
  124. package/dist/types/src/internal/common/types/projection.d.ts.map +1 -0
  125. package/dist/types/tsconfig.tsbuildinfo +1 -1
  126. package/package.json +40 -35
  127. package/src/Aggregate.ts +105 -0
  128. package/src/Blob.test.ts +45 -0
  129. package/src/Blob.ts +205 -0
  130. package/src/Database.ts +207 -17
  131. package/src/Err.ts +36 -0
  132. package/src/Feed.test.ts +188 -0
  133. package/src/Feed.ts +238 -62
  134. package/src/Filter.ts +28 -10
  135. package/src/Hypergraph.ts +18 -0
  136. package/src/Json.ts +1 -0
  137. package/src/Obj.test.ts +2 -2
  138. package/src/Obj.ts +21 -1
  139. package/src/Order.ts +16 -9
  140. package/src/Query.test.ts +319 -2
  141. package/src/Query.ts +165 -51
  142. package/src/Relation.ts +14 -0
  143. package/src/Type.ts +5 -6
  144. package/src/index.ts +2 -0
  145. package/src/internal/Entity/api.ts +11 -1
  146. package/src/internal/Entity/model.ts +3 -0
  147. package/src/internal/Filter/match.ts +30 -1
  148. package/src/internal/JsonSchema/json-schema-type.ts +14 -2
  149. package/src/internal/JsonSchema/json-schema.test.ts +28 -1
  150. package/src/internal/JsonSchema/json-schema.ts +33 -6
  151. package/src/internal/Obj/atoms.ts +4 -2
  152. package/src/internal/Obj/json-serializer.test.ts +162 -2
  153. package/src/internal/Obj/json-serializer.ts +6 -0
  154. package/src/internal/Query/pretty.ts +19 -1
  155. package/src/internal/Query/query-result-effect.ts +2 -4
  156. package/src/internal/Ref/atoms.ts +1 -1
  157. package/src/internal/common/proxy/change-context.ts +1 -1
  158. package/src/internal/common/proxy/make-object.ts +42 -3
  159. package/src/internal/common/proxy/reactive.ts +5 -0
  160. package/src/internal/common/proxy/symbols.ts +1 -0
  161. package/src/internal/common/proxy/typed-handler.ts +71 -29
  162. package/src/internal/common/types/index.ts +1 -0
  163. package/src/internal/common/types/model-symbols.ts +6 -0
  164. package/src/internal/common/types/projection.ts +45 -0
  165. package/src/testing/test-data.ts +14 -14
  166. package/dist/lib/neutral/Annotation.mjs +0 -90
  167. package/dist/lib/neutral/Annotation.mjs.map +0 -7
  168. package/dist/lib/neutral/Database.mjs +0 -42
  169. package/dist/lib/neutral/Database.mjs.map +0 -7
  170. package/dist/lib/neutral/Entity.mjs +0 -76
  171. package/dist/lib/neutral/Entity.mjs.map +0 -7
  172. package/dist/lib/neutral/Err.mjs +0 -16
  173. package/dist/lib/neutral/Err.mjs.map +0 -7
  174. package/dist/lib/neutral/Feed.mjs +0 -50
  175. package/dist/lib/neutral/Feed.mjs.map +0 -7
  176. package/dist/lib/neutral/Filter.mjs +0 -72
  177. package/dist/lib/neutral/Filter.mjs.map +0 -7
  178. package/dist/lib/neutral/Format.mjs +0 -82
  179. package/dist/lib/neutral/Format.mjs.map +0 -7
  180. package/dist/lib/neutral/JsonSchema.mjs +0 -18
  181. package/dist/lib/neutral/JsonSchema.mjs.map +0 -7
  182. package/dist/lib/neutral/Key.mjs +0 -16
  183. package/dist/lib/neutral/Key.mjs.map +0 -7
  184. package/dist/lib/neutral/Migration.mjs +0 -18
  185. package/dist/lib/neutral/Migration.mjs.map +0 -7
  186. package/dist/lib/neutral/Obj.mjs +0 -128
  187. package/dist/lib/neutral/Obj.mjs.map +0 -7
  188. package/dist/lib/neutral/Order.mjs +0 -16
  189. package/dist/lib/neutral/Order.mjs.map +0 -7
  190. package/dist/lib/neutral/Query.mjs +0 -38
  191. package/dist/lib/neutral/Query.mjs.map +0 -7
  192. package/dist/lib/neutral/QueryResult.mjs +0 -2
  193. package/dist/lib/neutral/QueryResult.mjs.map +0 -7
  194. package/dist/lib/neutral/Ref.mjs +0 -25
  195. package/dist/lib/neutral/Ref.mjs.map +0 -7
  196. package/dist/lib/neutral/Registry.mjs +0 -14
  197. package/dist/lib/neutral/Registry.mjs.map +0 -7
  198. package/dist/lib/neutral/Relation.mjs +0 -97
  199. package/dist/lib/neutral/Relation.mjs.map +0 -7
  200. package/dist/lib/neutral/Scope.mjs +0 -12
  201. package/dist/lib/neutral/Scope.mjs.map +0 -7
  202. package/dist/lib/neutral/Tag.mjs +0 -32
  203. package/dist/lib/neutral/Tag.mjs.map +0 -7
  204. package/dist/lib/neutral/Text.mjs +0 -25
  205. package/dist/lib/neutral/Text.mjs.map +0 -7
  206. package/dist/lib/neutral/Type.mjs +0 -65
  207. package/dist/lib/neutral/Type.mjs.map +0 -7
  208. package/dist/lib/neutral/chunk-2HKGAQM4.mjs +0 -1394
  209. package/dist/lib/neutral/chunk-2HKGAQM4.mjs.map +0 -7
  210. package/dist/lib/neutral/chunk-2ZHECXUR.mjs +0 -95
  211. package/dist/lib/neutral/chunk-2ZHECXUR.mjs.map +0 -7
  212. package/dist/lib/neutral/chunk-3KTE2Y7Q.mjs +0 -336
  213. package/dist/lib/neutral/chunk-3KTE2Y7Q.mjs.map +0 -7
  214. package/dist/lib/neutral/chunk-3TN2VMI4.mjs +0 -714
  215. package/dist/lib/neutral/chunk-3TN2VMI4.mjs.map +0 -7
  216. package/dist/lib/neutral/chunk-42L4HJ3Z.mjs +0 -104
  217. package/dist/lib/neutral/chunk-42L4HJ3Z.mjs.map +0 -7
  218. package/dist/lib/neutral/chunk-4A2NWKRJ.mjs +0 -747
  219. package/dist/lib/neutral/chunk-4A2NWKRJ.mjs.map +0 -7
  220. package/dist/lib/neutral/chunk-7LOUAPYZ.mjs +0 -25
  221. package/dist/lib/neutral/chunk-7LOUAPYZ.mjs.map +0 -7
  222. package/dist/lib/neutral/chunk-ACFAOTKJ.mjs +0 -1531
  223. package/dist/lib/neutral/chunk-ACFAOTKJ.mjs.map +0 -7
  224. package/dist/lib/neutral/chunk-BEFR7LEA.mjs +0 -170
  225. package/dist/lib/neutral/chunk-BEFR7LEA.mjs.map +0 -7
  226. package/dist/lib/neutral/chunk-BXIKXT7Q.mjs +0 -89
  227. package/dist/lib/neutral/chunk-BXIKXT7Q.mjs.map +0 -7
  228. package/dist/lib/neutral/chunk-C57XMMBY.mjs +0 -206
  229. package/dist/lib/neutral/chunk-C57XMMBY.mjs.map +0 -7
  230. package/dist/lib/neutral/chunk-C62JYFAD.mjs +0 -183
  231. package/dist/lib/neutral/chunk-C62JYFAD.mjs.map +0 -7
  232. package/dist/lib/neutral/chunk-FSGX2R62.mjs +0 -48
  233. package/dist/lib/neutral/chunk-FSGX2R62.mjs.map +0 -7
  234. package/dist/lib/neutral/chunk-HBXT3GSB.mjs +0 -459
  235. package/dist/lib/neutral/chunk-HBXT3GSB.mjs.map +0 -7
  236. package/dist/lib/neutral/chunk-IQR45HAD.mjs +0 -31
  237. package/dist/lib/neutral/chunk-IQR45HAD.mjs.map +0 -7
  238. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  239. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  240. package/dist/lib/neutral/chunk-KCBW4BR6.mjs +0 -424
  241. package/dist/lib/neutral/chunk-KCBW4BR6.mjs.map +0 -7
  242. package/dist/lib/neutral/chunk-L3E5IXAE.mjs +0 -575
  243. package/dist/lib/neutral/chunk-L3E5IXAE.mjs.map +0 -7
  244. package/dist/lib/neutral/chunk-PRC4FEKJ.mjs +0 -56
  245. package/dist/lib/neutral/chunk-PRC4FEKJ.mjs.map +0 -7
  246. package/dist/lib/neutral/chunk-SCT6FFCR.mjs +0 -351
  247. package/dist/lib/neutral/chunk-SCT6FFCR.mjs.map +0 -7
  248. package/dist/lib/neutral/chunk-TOCVVFK2.mjs +0 -75
  249. package/dist/lib/neutral/chunk-TOCVVFK2.mjs.map +0 -7
  250. package/dist/lib/neutral/chunk-U53LSQQA.mjs +0 -41
  251. package/dist/lib/neutral/chunk-U53LSQQA.mjs.map +0 -7
  252. package/dist/lib/neutral/chunk-UGFFV2FR.mjs +0 -27
  253. package/dist/lib/neutral/chunk-UGFFV2FR.mjs.map +0 -7
  254. package/dist/lib/neutral/chunk-UWLP4RFM.mjs +0 -7
  255. package/dist/lib/neutral/chunk-UWLP4RFM.mjs.map +0 -7
  256. package/dist/lib/neutral/chunk-UZHCQ6NI.mjs +0 -283
  257. package/dist/lib/neutral/chunk-UZHCQ6NI.mjs.map +0 -7
  258. package/dist/lib/neutral/chunk-WISOH2XH.mjs +0 -36
  259. package/dist/lib/neutral/chunk-WISOH2XH.mjs.map +0 -7
  260. package/dist/lib/neutral/chunk-WKIT2YY5.mjs +0 -34
  261. package/dist/lib/neutral/chunk-WKIT2YY5.mjs.map +0 -7
  262. package/dist/lib/neutral/chunk-XB6ISRAO.mjs +0 -271
  263. package/dist/lib/neutral/chunk-XB6ISRAO.mjs.map +0 -7
  264. package/dist/lib/neutral/chunk-XXL42WCB.mjs +0 -84
  265. package/dist/lib/neutral/chunk-XXL42WCB.mjs.map +0 -7
  266. package/dist/lib/neutral/chunk-XXWK4J53.mjs +0 -117
  267. package/dist/lib/neutral/chunk-XXWK4J53.mjs.map +0 -7
  268. package/dist/lib/neutral/index.mjs +0 -115
  269. package/dist/lib/neutral/index.mjs.map +0 -7
  270. package/dist/lib/neutral/internal/index.mjs +0 -581
  271. package/dist/lib/neutral/internal/index.mjs.map +0 -7
  272. package/dist/lib/neutral/meta.json +0 -1
  273. package/dist/lib/neutral/testing/index.mjs +0 -477
  274. package/dist/lib/neutral/testing/index.mjs.map +0 -7
package/src/Database.ts CHANGED
@@ -2,17 +2,17 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- // @import-as-namespace
6
-
7
5
  import * as Context from 'effect/Context';
8
6
  import * as Effect from 'effect/Effect';
9
7
  import * as Layer from 'effect/Layer';
10
8
  import * as Schema from 'effect/Schema';
9
+ import * as Stream from 'effect/Stream';
11
10
 
12
- import { EffectEx } from '@dxos/effect';
11
+ import type { CleanupFn } from '@dxos/async';
13
12
  import { invariant } from '@dxos/invariant';
14
13
  import { type SpaceId, type URI } from '@dxos/keys';
15
14
 
15
+ import type * as Blob from './Blob';
16
16
  import type * as Entity from './Entity';
17
17
  import * as Err from './Err';
18
18
  import type * as Feed from './Feed';
@@ -62,6 +62,14 @@ export type AddOptions = {
62
62
  * @default 'linked-doc'
63
63
  */
64
64
  placeIn?: ObjectPlacement;
65
+
66
+ /**
67
+ * Append the object to this feed instead of the automerge-backed space database. The object is
68
+ * returned synchronously (a live feed object) and persisted in the background — confirm the write
69
+ * completed with {@link Database.flush}. Synchronous alternative to the async
70
+ * {@link Database.appendToFeed}; `placeIn` is ignored when set.
71
+ */
72
+ to?: Feed.Feed;
65
73
  };
66
74
 
67
75
  /**
@@ -94,6 +102,18 @@ export type FlushOptions = {
94
102
  updates?: boolean;
95
103
  };
96
104
 
105
+ /**
106
+ * A caller-owned, writable **independent instance** of one object bound to one branch: a distinct
107
+ * object instance (not a UI surface), separate from the device-global canonical object.
108
+ * @see Database.branch
109
+ */
110
+ export type BranchBinding<T extends Obj.Unknown = Obj.Unknown> = {
111
+ /** Live object bound to the branch document (`'main'` -> the canonical live object). */
112
+ readonly object: T;
113
+ /** Release the binding (drops the doc-handle listener; never deletes the branch document). */
114
+ dispose(): void;
115
+ };
116
+
97
117
  /**
98
118
  * Identifier denoting an ECHO Database.
99
119
  */
@@ -148,6 +168,8 @@ export interface Database extends Queryable {
148
168
  *
149
169
  * Only Object and Relation entities are accepted. To persist a Type definition use
150
170
  * {@link addType} — passing a Type entity is rejected at compile time (and at runtime).
171
+ *
172
+ * Pass `{ to: feed }` to append to a feed instead (synchronous; confirm with {@link flush}).
151
173
  */
152
174
  add<T extends Entity.Unknown = Entity.Unknown>(obj: T & RejectTypeEntity<T>, opts?: AddOptions): T;
153
175
 
@@ -185,15 +207,64 @@ export interface Database extends Queryable {
185
207
  */
186
208
  flush(opts?: FlushOptions): Promise<void>;
187
209
 
210
+ //
211
+ // Branching. A branch is a writable alternate timeline of an object subtree (same object ids,
212
+ // shared automerge history, true CRDT merge-back). The registry is synced on the space root;
213
+ // the currently-viewed branch stays device-local.
214
+ //
215
+
188
216
  /**
189
- * Removes feed items by ID.
217
+ * The device-global current branch for an object id (`'main'` by default).
218
+ * @deprecated Prefer `Obj.getBranch(obj)` — it takes the object and reports the branch of that
219
+ * specific instance (including `db.branch()` independent instances), not just the device selection.
190
220
  */
191
- removeFeedItemsByIds(feed: Feed.Feed, ids: string[]): Promise<void>;
221
+ getCurrentBranch(objectId: string): string;
222
+
223
+ /**
224
+ * An immutable snapshot of the object at the given historical heads — a detached instance, not a
225
+ * pin on the live object. Prefer `Obj.getVersion(obj, heads)`.
226
+ */
227
+ getVersion<T extends Obj.Unknown>(obj: T, heads: readonly string[]): Obj.Snapshot<T>;
228
+
229
+ /** All branch names available for an object, including the implicit `'main'` (always first). */
230
+ listBranches(objectId: string): string[];
231
+
232
+ /**
233
+ * Fork the object and its referenced subtree into a new branch (does not switch to it).
234
+ * @param opts.fromHeads Fork from a historical frontier instead of the tip (a bare heads array
235
+ * applies to the root only; a map forks each member from its own frontier).
236
+ */
237
+ createBranch(
238
+ rootObjectId: string,
239
+ name: string,
240
+ opts?: { fromHeads?: readonly string[] | Record<string, readonly string[]> },
241
+ ): Promise<void>;
242
+
243
+ /** Switch the object's subtree to a branch (or back to `'main'`). Device-local; cascades to children. */
244
+ switchBranch(rootObjectId: string, name: string): Promise<void>;
245
+
246
+ /** Merge a branch back into main across the subtree, then switch back to main. */
247
+ mergeBranch(rootObjectId: string, name: string, opts?: { deleteAfter?: boolean }): Promise<void>;
248
+
249
+ /** Fold main's changes into a branch across the subtree (the reverse of {@link mergeBranch}). */
250
+ syncBranch(rootObjectId: string, name: string): Promise<void>;
251
+
252
+ /** Delete a branch (its documents lose their sync reference). Cannot delete `'main'`. */
253
+ deleteBranch(rootObjectId: string, name: string): void;
254
+
255
+ /**
256
+ * Create a caller-owned, writable binding to one branch of one object — a live object whose reads
257
+ * resolve the branch document and whose writes land on the branch document only. Multiple bindings
258
+ * to different branches of the same object may coexist; the device-global current branch and other
259
+ * bindings are unaffected. Binding to `'main'` returns the canonical live object. Bindings are
260
+ * ephemeral and never persisted — the caller must `dispose()`.
261
+ */
262
+ branch<T extends Obj.Unknown>(obj: T, name: string): Promise<BranchBinding<T>>;
192
263
 
193
264
  /**
194
- * Queries items in a feed associated with this database.
265
+ * Removes feed items by ID.
195
266
  */
196
- queryFeed(feed: Feed.Feed, queryOrFilter: Query.Any | Filter.Any): QueryResult.QueryResult<any>;
267
+ removeFeedItemsByIds(feed: Feed.Feed, ids: string[]): Promise<void>;
197
268
 
198
269
  /**
199
270
  * Syncs a feed with the server.
@@ -204,6 +275,49 @@ export interface Database extends Queryable {
204
275
  * Returns queue replication backlog for the feed's namespace.
205
276
  */
206
277
  getFeedSyncState(feed: Feed.Feed): Promise<Feed.SyncState>;
278
+
279
+ /**
280
+ * Disposes and drops the in-memory handle (live working-set / core cache) for a feed, so the next
281
+ * access re-reads it cold. Advanced cache-control; primarily used by tests to model a spawned
282
+ * process reading the feed with an empty in-memory cache. Public (not `_`-prefixed) so it survives
283
+ * declaration stripping for cross-package test use.
284
+ */
285
+ evictFeedHandle(feed: Feed.Feed): Promise<void>;
286
+
287
+ /**
288
+ * Hashes and uploads `bytes` via the chosen storage backend, returning an un-added Blob object.
289
+ * Rejects with `Err.BlobTooLargeError` (over inline storage's fixed cap, or the backend's own
290
+ * `maxSize`), `Err.BlobWriteError` (backend upload failure), or `Err.BlobNotAvailableError`
291
+ * (`reason: 'backend-not-registered'` — the requested storage name has no registered backend).
292
+ */
293
+ createBlob(bytes: Uint8Array, options?: { type?: string; storage?: string }): Promise<Blob.Blob>;
294
+
295
+ /**
296
+ * Loads a blob's bytes. Rejects with `Err.BlobNotAvailableError` if the backend for the blob's
297
+ * storage scheme is not registered, offline, or cannot find the bytes.
298
+ */
299
+ readBlob(blob: Blob.Blob): Promise<Uint8Array>;
300
+
301
+ /**
302
+ * Checks whether a blob's bytes are currently available.
303
+ */
304
+ blobExists(blob: Blob.Blob): Promise<boolean>;
305
+
306
+ /**
307
+ * Returns a renderable URL for the blob, if one can be produced.
308
+ */
309
+ getBlobUrl(blob: Blob.Blob): Promise<string | undefined>;
310
+
311
+ /**
312
+ * Get the current combined (automerge documents + feed blocks) sync state, reported against a
313
+ * single remote peer.
314
+ */
315
+ getSyncState(options?: GetSyncStateOptions): Promise<SyncState>;
316
+
317
+ /**
318
+ * Subscribe to combined sync state changes.
319
+ */
320
+ subscribeToSyncState(cb: (state: SyncState) => void, options?: GetSyncStateOptions): CleanupFn;
207
321
  }
208
322
 
209
323
  export const isDatabase = (obj: unknown): obj is Database => {
@@ -276,7 +390,7 @@ export const resolve: {
276
390
  Effect.gen(function* () {
277
391
  const { db } = yield* Service;
278
392
  const dxn = typeof ref === 'string' ? ref : ref.uri;
279
- const object = yield* EffectEx.promiseWithCauseCapture(() =>
393
+ const object = yield* Effect.promise(() =>
280
394
  db.graph
281
395
  .createRefResolver({
282
396
  context: {
@@ -307,7 +421,7 @@ export const resolve: {
307
421
  */
308
422
  export const load: <T>(ref: Ref<T>) => Effect.Effect<T, Err.EntityNotFoundError, never> = Effect.fn('Database.load')(
309
423
  function* (ref) {
310
- const object = yield* EffectEx.promiseWithCauseCapture(() => ref.tryLoad());
424
+ const object = yield* Effect.promise(() => ref.tryLoad());
311
425
  if (!object) {
312
426
  return yield* Effect.fail(new Err.EntityNotFoundError(ref.uri));
313
427
  }
@@ -319,6 +433,9 @@ export const load: <T>(ref: Ref<T>) => Effect.Effect<T, Err.EntityNotFoundError,
319
433
  * Adds an object or relation to the database.
320
434
  * @see {@link Database.add}
321
435
  */
436
+ // The Effect wrapper intentionally omits the method's `opts` (e.g. `{ to: feed }`): it is applied
437
+ // point-free (`Effect.forEach(Database.add)`), where a second parameter would collide with the
438
+ // iteratee index. Effect-style feed appends go through `Database.appendToFeed` / `Feed.append`.
322
439
  export const add = <T extends Entity.Unknown>(obj: T & RejectTypeEntity<T>): Effect.Effect<T, never, Service> =>
323
440
  Service.pipe(Effect.map(({ db }) => db.add<T>(obj))).pipe(Effect.withSpan('Database.add'));
324
441
 
@@ -327,7 +444,7 @@ export const add = <T extends Entity.Unknown>(obj: T & RejectTypeEntity<T>): Eff
327
444
  * @see {@link Database.addType}
328
445
  */
329
446
  export const addType = <T extends Type.AnyEntity>(type: T): Effect.Effect<T, never, Service> =>
330
- Service.pipe(Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.addType(type)))).pipe(
447
+ Service.pipe(Effect.flatMap(({ db }) => Effect.promise(() => db.addType(type)))).pipe(
331
448
  Effect.withSpan('Database.addType'),
332
449
  );
333
450
 
@@ -343,25 +460,25 @@ export const remove = <T extends Entity.Unknown>(obj: T): Effect.Effect<void, ne
343
460
  * @see {@link Database.appendToFeed}
344
461
  */
345
462
  export const appendToFeed = (feed: Feed.Feed, entities: Entity.Unknown[]): Effect.Effect<void, never, Service> =>
346
- Service.pipe(
347
- Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.appendToFeed(feed, entities))),
348
- ).pipe(Effect.withSpan('Database.appendToFeed'));
463
+ Service.pipe(Effect.flatMap(({ db }) => Effect.promise(() => db.appendToFeed(feed, entities)))).pipe(
464
+ Effect.withSpan('Database.appendToFeed'),
465
+ );
349
466
 
350
467
  /**
351
468
  * Removes entities from a feed.
352
469
  * @see {@link Database.deleteFromFeed}
353
470
  */
354
471
  export const deleteFromFeed = (feed: Feed.Feed, entities: Entity.Unknown[]): Effect.Effect<void, never, Service> =>
355
- Service.pipe(
356
- Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.deleteFromFeed(feed, entities))),
357
- ).pipe(Effect.withSpan('Database.deleteFromFeed'));
472
+ Service.pipe(Effect.flatMap(({ db }) => Effect.promise(() => db.deleteFromFeed(feed, entities)))).pipe(
473
+ Effect.withSpan('Database.deleteFromFeed'),
474
+ );
358
475
 
359
476
  /**
360
477
  * Flushes pending changes to disk.
361
478
  * @see {@link Database.flush}
362
479
  */
363
480
  export const flush = (opts?: FlushOptions) =>
364
- Service.pipe(Effect.flatMap(({ db }) => EffectEx.promiseWithCauseCapture(() => db.flush(opts)))).pipe(
481
+ Service.pipe(Effect.flatMap(({ db }) => Effect.promise(() => db.flush(opts)))).pipe(
365
482
  Effect.withSpan('Database.flush'),
366
483
  );
367
484
 
@@ -377,3 +494,76 @@ export const query: {
377
494
  Effect.withSpan('Database.query'),
378
495
  queryInternal.makeQueryResultEffect,
379
496
  );
497
+
498
+ /**
499
+ * Sync state of the database in relation to EDGE.
500
+ */
501
+ export interface SyncState {
502
+ //
503
+ // Automerge
504
+ //
505
+
506
+ /**
507
+ * Total number of documents locally.
508
+ */
509
+ readonly localDocumentCount: number;
510
+ /**
511
+ * Total number of documents on the remote peer.
512
+ */
513
+ readonly remoteDocumentCount: number;
514
+ /**
515
+ * Total number of documents across this peer and the remote peer.
516
+ */
517
+ readonly totalDocumentCount: number;
518
+ /**
519
+ * Total number of documents that are not synced.
520
+ * Includes documents that are present only locally, only on the remote peer, or whether the peers have different versions.
521
+ */
522
+ readonly unsyncedDocumentCount: number;
523
+
524
+ //
525
+ // Feeds.
526
+ //
527
+
528
+ /**
529
+ * Blocks still to pull from remote. 0 when caught up.
530
+ */
531
+ readonly blocksToPull: string;
532
+ /**
533
+ * Unpositioned blocks still to push to remote. 0 when caught up.
534
+ */
535
+ readonly blocksToPush: string;
536
+ /**
537
+ * Total blocks stored locally for this namespace in the space.
538
+ */
539
+ readonly totalBlocks: string;
540
+ }
541
+
542
+ /**
543
+ * Options for reading combined sync state.
544
+ */
545
+ export interface GetSyncStateOptions {
546
+ /**
547
+ * Peer to report the automerge document backlog against. Defaults to the EDGE peer.
548
+ * Provide explicitly in local/test topologies where there is no EDGE peer.
549
+ */
550
+ readonly peerId?: string;
551
+ }
552
+
553
+ /**
554
+ * Get the current sync state.
555
+ */
556
+ export const getSyncState = (options?: GetSyncStateOptions): Effect.Effect<SyncState, never, Service> =>
557
+ Service.pipe(Effect.flatMap(({ db }) => Effect.promise(() => db.getSyncState(options))));
558
+
559
+ /**
560
+ * Subscribe to sync state changes.
561
+ */
562
+ export const subscribeToSyncState = (options?: GetSyncStateOptions): Stream.Stream<SyncState, never, Service> =>
563
+ Stream.asyncScoped((emit) =>
564
+ Effect.gen(function* () {
565
+ const { db } = yield* Service;
566
+ const cleanup = db.subscribeToSyncState((state) => emit.single(state), options);
567
+ yield* Effect.addFinalizer(() => Effect.sync(cleanup));
568
+ }),
569
+ );
package/src/Err.ts CHANGED
@@ -41,6 +41,42 @@ export class TextEditNotFoundError extends BaseError.extend('TextEditNotFoundErr
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * Thrown when a blob's bytes exceed the size limit of the storage backend writing them (inline
46
+ * storage's fixed cap, or a registered backend's own `maxSize`).
47
+ */
48
+ export class BlobTooLargeError extends BaseError.extend('BlobTooLargeError', 'Blob is too large for its backend') {
49
+ constructor(context: { size: number; limit: number }, options?: BaseErrorOptions) {
50
+ super({ context, ...options });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Reason why a blob's bytes could not be read.
56
+ * - `offline`: The backend's transport failed (e.g. network unavailable).
57
+ * - `not-found`: The backend could not locate the bytes for the given URI.
58
+ * - `backend-not-registered`: No backend is registered for the URI's scheme.
59
+ */
60
+ export type BlobNotAvailableReason = 'offline' | 'not-found' | 'backend-not-registered';
61
+
62
+ /**
63
+ * Thrown when a blob's bytes cannot be read from its backend.
64
+ */
65
+ export class BlobNotAvailableError extends BaseError.extend('BlobNotAvailableError', 'Blob is not available') {
66
+ constructor(context: { backend: string; key: string; reason: BlobNotAvailableReason }, options?: BaseErrorOptions) {
67
+ super({ context, ...options });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Thrown when a blob's bytes fail to upload to its backend.
73
+ */
74
+ export class BlobWriteError extends BaseError.extend('BlobWriteError', 'Failed to write blob') {
75
+ constructor(context: { backend: string }, options?: BaseErrorOptions) {
76
+ super({ context, ...options });
77
+ }
78
+ }
79
+
44
80
  /**
45
81
  * Reason why getting a reactive object from a snapshot failed.
46
82
  * - `no-database`: The snapshot is not associated with a database.
@@ -0,0 +1,188 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as Feed from './Feed';
8
+ import * as Obj from './Obj';
9
+ import { TestSchema } from './testing';
10
+
11
+ describe('Feed', () => {
12
+ describe('getParent / setParent', () => {
13
+ test('an item has no explicit parent by default', ({ expect }) => {
14
+ expect(Feed.getParent(message('m1'))).toBeUndefined();
15
+ });
16
+
17
+ test('round-trips a parent set from an item', ({ expect }) => {
18
+ const m1 = message('m1');
19
+ const m2 = message('m2');
20
+ Feed.setParent(m2, m1);
21
+ expect(Feed.getParent(m2)).toBe(m1.id);
22
+ });
23
+
24
+ test('round-trips a parent set from an id', ({ expect }) => {
25
+ const m1 = message('m1');
26
+ const m2 = message('m2');
27
+ Feed.setParent(m2, m1.id);
28
+ expect(Feed.getParent(m2)).toBe(m1.id);
29
+ });
30
+
31
+ test('re-setting replaces rather than accumulates', ({ expect }) => {
32
+ const m1 = message('m1');
33
+ const m2 = message('m2');
34
+ const m3 = message('m3');
35
+ Feed.setParent(m3, m1);
36
+ Feed.setParent(m3, m2);
37
+ expect(Feed.getParent(m3)).toBe(m2.id);
38
+ expect(Obj.getKeys(m3, Feed.PARENT_KEY)).toHaveLength(1);
39
+ });
40
+
41
+ test('undefined clears the parent', ({ expect }) => {
42
+ const m1 = message('m1');
43
+ const m2 = message('m2');
44
+ Feed.setParent(m2, m1);
45
+ Feed.setParent(m2, undefined);
46
+ expect(Feed.getParent(m2)).toBeUndefined();
47
+ });
48
+ });
49
+
50
+ describe('history', () => {
51
+ test('a feed with no lineage resolves to itself', ({ expect }) => {
52
+ const items = [message('m1'), message('m2'), message('m3')];
53
+ const history = Feed.history(items);
54
+ expect(titles(history)).toEqual(['m1', 'm2', 'm3']);
55
+ expect(history.shallow).toBe(false);
56
+ });
57
+
58
+ test('an empty feed resolves to nothing', ({ expect }) => {
59
+ const history = Feed.history([]);
60
+ expect(history.items).toEqual([]);
61
+ expect(history.shallow).toBe(false);
62
+ });
63
+
64
+ // The worked example: forking from m3 abandons m4 without removing it from the log.
65
+ test('a fork discards the items appended between the parent and the fork', ({ expect }) => {
66
+ const [m1, m2, m3, m4, m5] = [message('m1'), message('m2'), message('m3'), message('m4'), message('m5')];
67
+ Feed.setParent(m5, m3);
68
+ const history = Feed.history([m1, m2, m3, m4, m5]);
69
+ expect(titles(history)).toEqual(['m1', 'm2', 'm3', 'm5']);
70
+ expect(history.shallow).toBe(false);
71
+ });
72
+
73
+ // Latest-wins: a second fork from m4 makes m4's branch live again and hides m5.
74
+ test('the most recently appended branch wins', ({ expect }) => {
75
+ const [m1, m2, m3, m4, m5, m6] = [
76
+ message('m1'),
77
+ message('m2'),
78
+ message('m3'),
79
+ message('m4'),
80
+ message('m5'),
81
+ message('m6'),
82
+ ];
83
+ Feed.setParent(m5, m3);
84
+ Feed.setParent(m6, m4);
85
+ const history = Feed.history([m1, m2, m3, m4, m5, m6]);
86
+ expect(titles(history)).toEqual(['m1', 'm2', 'm3', 'm4', 'm6']);
87
+ expect(history.shallow).toBe(false);
88
+ });
89
+
90
+ test('items appended after a fork chain onto it implicitly', ({ expect }) => {
91
+ const [m1, m2, m3, m4, m5, m6] = [
92
+ message('m1'),
93
+ message('m2'),
94
+ message('m3'),
95
+ message('m4'),
96
+ message('m5'),
97
+ message('m6'),
98
+ ];
99
+ Feed.setParent(m5, m3);
100
+ const history = Feed.history([m1, m2, m3, m4, m5, m6]);
101
+ expect(titles(history)).toEqual(['m1', 'm2', 'm3', 'm5', 'm6']);
102
+ });
103
+
104
+ test('consecutive forks from the same parent keep only the last', ({ expect }) => {
105
+ const [m1, m2, m3, m4] = [message('m1'), message('m2'), message('m3'), message('m4')];
106
+ Feed.setParent(m3, m1);
107
+ Feed.setParent(m4, m1);
108
+ const history = Feed.history([m1, m2, m3, m4]);
109
+ expect(titles(history)).toEqual(['m1', 'm4']);
110
+ });
111
+
112
+ describe('head', () => {
113
+ test('resolves an abandoned branch when given its leaf', ({ expect }) => {
114
+ const [m1, m2, m3, m4, m5] = [message('m1'), message('m2'), message('m3'), message('m4'), message('m5')];
115
+ Feed.setParent(m5, m3);
116
+ const history = Feed.history([m1, m2, m3, m4, m5], { head: m4 });
117
+ expect(titles(history)).toEqual(['m1', 'm2', 'm3', 'm4']);
118
+ expect(history.shallow).toBe(false);
119
+ });
120
+
121
+ test('accepts an id', ({ expect }) => {
122
+ const [m1, m2, m3] = [message('m1'), message('m2'), message('m3')];
123
+ const history = Feed.history([m1, m2, m3], { head: m2.id });
124
+ expect(titles(history)).toEqual(['m1', 'm2']);
125
+ });
126
+
127
+ test('an unknown head resolves to nothing and reports truncation', ({ expect }) => {
128
+ const history = Feed.history([message('m1')], { head: message('absent') });
129
+ expect(history.items).toEqual([]);
130
+ expect(history.shallow).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe('truncation', () => {
135
+ // A parent may legitimately be missing under partial replication, or because the caller's
136
+ // filter excluded it.
137
+ test('an absent parent stops the walk', ({ expect }) => {
138
+ const [m1, m2] = [message('m1'), message('m2')];
139
+ Feed.setParent(m2, message('unreplicated'));
140
+ const history = Feed.history([m1, m2]);
141
+ expect(titles(history)).toEqual(['m2']);
142
+ expect(history.shallow).toBe(true);
143
+ });
144
+
145
+ test('a forward reference terminates the walk', ({ expect }) => {
146
+ const [m1, m2] = [message('m1'), message('m2')];
147
+ Feed.setParent(m1, m2);
148
+ const history = Feed.history([m1, m2]);
149
+ expect(titles(history)).toEqual(['m1', 'm2']);
150
+ expect(history.shallow).toBe(true);
151
+ });
152
+
153
+ test('a self-reference terminates the walk', ({ expect }) => {
154
+ const m1 = message('m1');
155
+ Feed.setParent(m1, m1);
156
+ const history = Feed.history([m1]);
157
+ expect(titles(history)).toEqual(['m1']);
158
+ expect(history.shallow).toBe(true);
159
+ });
160
+
161
+ // A malformed replicated id must not read as "no parent" — that would resolve the fork as an
162
+ // implicit continuation and resurrect the items it abandoned.
163
+ test('a malformed parent id truncates rather than falling through to the predecessor', ({ expect }) => {
164
+ const [m1, m2, m3] = [message('m1'), message('m2'), message('m3')];
165
+ corruptParent(m3);
166
+ const history = Feed.history([m1, m2, m3]);
167
+ expect(titles(history)).toEqual(['m3']);
168
+ expect(history.shallow).toBe(true);
169
+ });
170
+
171
+ test('getParent reports a malformed parent as undefined', ({ expect }) => {
172
+ const m1 = message('m1');
173
+ corruptParent(m1);
174
+ expect(Feed.getParent(m1)).toBeUndefined();
175
+ });
176
+ });
177
+ });
178
+ });
179
+
180
+ const message = (title: string) => Obj.make(TestSchema.Task, { title });
181
+
182
+ const titles = (history: Feed.History<TestSchema.Task>) => history.items.map((item) => item.title);
183
+
184
+ /** Writes an unparseable lineage id, standing in for a corrupted or future-format replicated block. */
185
+ const corruptParent = (task: TestSchema.Task) =>
186
+ Obj.update(task, (task) => {
187
+ Obj.getMeta(task).keys.push({ source: Feed.PARENT_KEY, id: 'not-a-ulid' });
188
+ });