@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
@@ -266,6 +266,33 @@ describe('effect-to-json', () => {
266
266
  log('schema', { jsonSchema });
267
267
  });
268
268
 
269
+ test('serialize a pair of mutually-recursive schemas (A embeds B, B embeds A)', () => {
270
+ // Unlike a schema that's self-referential (caught by suspendCache within one expansion), a
271
+ // *mutual* cycle across two distinct schemas isn't: each side starts a fresh suspendCache with
272
+ // no memory of the other already being in flight, so without the `inProgress` guard this
273
+ // recurses forever (A -> B -> A -> B -> ...).
274
+ interface A {
275
+ readonly kind: 'a';
276
+ readonly b?: B;
277
+ }
278
+ interface B {
279
+ readonly kind: 'b';
280
+ readonly a?: A;
281
+ }
282
+ const A: Schema.Schema<A> = Schema.Struct({
283
+ kind: Schema.Literal('a'),
284
+ b: Schema.optional(Schema.suspend((): Schema.Schema<B> => B)),
285
+ });
286
+ const B: Schema.Schema<B> = Schema.Struct({
287
+ kind: Schema.Literal('b'),
288
+ a: Schema.optional(Schema.suspend((): Schema.Schema<A> => A)),
289
+ });
290
+
291
+ const jsonSchema = toJsonSchema(A);
292
+ expect(jsonSchema.properties?.kind).toEqual({ type: 'string', enum: ['a'] });
293
+ expect(jsonSchema.properties?.b).toBeDefined();
294
+ });
295
+
269
296
  test('tuple schema with description', () => {
270
297
  const schema = Schema.Struct({
271
298
  args: Schema.Tuple(
@@ -526,7 +553,7 @@ describe('json-to-effect', () => {
526
553
  typename: 'com.example.type.project',
527
554
  version: '0.1.0',
528
555
  });
529
- expect(getTypeIdentifierAnnotation(schema)).to.deep.eq('echo:/01JERV1HQCQZDQ4NVCJ42QB38F');
556
+ expect(getTypeIdentifierAnnotation(schema)).to.deep.eq('echo:///01JERV1HQCQZDQ4NVCJ42QB38F');
530
557
  });
531
558
 
532
559
  test('symbol annotations get compared', () => {
@@ -120,8 +120,11 @@ export const toJsonSchema = (
120
120
  return jsonSchema;
121
121
  };
122
122
 
123
- const _toJsonSchemaAST = (ast: SchemaAST.AST): Types.DeepMutable<JsonSchemaType> => {
124
- const withRefinements = withEchoRefinements(ast, '#');
123
+ const _toJsonSchemaAST = (
124
+ ast: SchemaAST.AST,
125
+ inProgress: Set<SchemaAST.AST> = new Set(),
126
+ ): Types.DeepMutable<JsonSchemaType> => {
127
+ const withRefinements = withEchoRefinements(ast, '#', new Map(), inProgress);
125
128
  const jsonSchema = JSONSchema.fromAST(withRefinements, {
126
129
  definitions: {},
127
130
  }) as Types.DeepMutable<JsonSchemaType>;
@@ -133,6 +136,9 @@ const withEchoRefinements = (
133
136
  ast: SchemaAST.AST,
134
137
  path: string | undefined,
135
138
  suspendCache = new Map<SchemaAST.AST, string>(),
139
+ // Suspended ASTs whose expansion is currently in flight — unlike `suspendCache`, this is shared
140
+ // across the fresh caches each new expansion starts with, so it also catches mutual cycles.
141
+ inProgress: Set<SchemaAST.AST> = new Set(),
136
142
  ): SchemaAST.AST => {
137
143
  if (path) {
138
144
  suspendCache.set(ast, path);
@@ -144,21 +150,41 @@ const withEchoRefinements = (
144
150
  const suspendedAst = ast.f();
145
151
  const cachedPath = suspendCache.get(suspendedAst);
146
152
  if (cachedPath) {
147
- recursiveResult = new SchemaAST.Suspend(() => withEchoRefinements(suspendedAst, path, suspendCache), {
153
+ recursiveResult = new SchemaAST.Suspend(() => withEchoRefinements(suspendedAst, path, suspendCache, inProgress), {
148
154
  [SchemaAST.JSONSchemaAnnotationId]: {
149
155
  $ref: cachedPath,
150
156
  },
151
157
  });
158
+ } else if (inProgress.has(suspendedAst)) {
159
+ // Reached via a *different* suspended type already being expanded (e.g. two mutually-recursive
160
+ // schemas, A embedding B and B embedding A) — `suspendCache` alone won't catch this, since each
161
+ // fresh expansion below starts from an empty one. Stop with a permissive "any" placeholder
162
+ // instead of expanding again. The `type` key is required: effect's JSONSchema.fromAST only
163
+ // treats this annotation as a full override (vs. merging it onto a recomputed, and for a bare
164
+ // Suspend unsupported, structural schema) when it sees type/oneOf/anyOf/$ref.
165
+ recursiveResult = new SchemaAST.Suspend(() => withEchoRefinements(suspendedAst, path, suspendCache, inProgress), {
166
+ [SchemaAST.JSONSchemaAnnotationId]: {
167
+ $id: '/schemas/any',
168
+ type: ['string', 'number', 'boolean', 'object', 'array', 'null'],
169
+ },
170
+ });
152
171
  } else {
153
- const jsonSchema = _toJsonSchemaAST(suspendedAst);
154
- recursiveResult = new SchemaAST.Suspend(() => withEchoRefinements(suspendedAst, path, suspendCache), {
172
+ inProgress.add(suspendedAst);
173
+ const jsonSchema = _toJsonSchemaAST(suspendedAst, inProgress);
174
+ inProgress.delete(suspendedAst);
175
+ recursiveResult = new SchemaAST.Suspend(() => withEchoRefinements(suspendedAst, path, suspendCache, inProgress), {
155
176
  [SchemaAST.JSONSchemaAnnotationId]: jsonSchema,
156
177
  });
157
178
  }
158
179
  } else if (SchemaAST.isTypeLiteral(ast)) {
159
180
  // Add property order annotations
160
181
  recursiveResult = SchemaEx.mapAst(ast, (ast, key) =>
161
- withEchoRefinements(ast, path && typeof key === 'string' ? `${path}/${key}` : undefined, suspendCache),
182
+ withEchoRefinements(
183
+ ast,
184
+ path && typeof key === 'string' ? `${path}/${key}` : undefined,
185
+ suspendCache,
186
+ inProgress,
187
+ ),
162
188
  );
163
189
  recursiveResult = addJsonSchemaFields(recursiveResult, {
164
190
  propertyOrder: [...ast.propertySignatures.map((p) => p.name)] as string[],
@@ -172,6 +198,7 @@ const withEchoRefinements = (
172
198
  ast,
173
199
  path && (typeof key === 'string' || typeof key === 'number') ? `${path}/${key}` : undefined,
174
200
  suspendCache,
201
+ inProgress,
175
202
  ),
176
203
  );
177
204
  }
@@ -62,14 +62,16 @@ const refFamily = Atom.family(<T extends Obj.Unknown>(ref: Ref.Ref<T>): Atom.Ato
62
62
  return Atom.make<Obj.Snapshot<T> | undefined>((get) => {
63
63
  let unsubscribeTarget: (() => void) | undefined;
64
64
 
65
- const setupTargetSubscription = (target: T): Obj.Snapshot<T> => {
65
+ const setupTargetSubscription = (target: T): Obj.Snapshot<T> | undefined => {
66
66
  unsubscribeTarget?.();
67
67
  unsubscribeTarget = subscribe(target, () => {
68
68
  // Deleted objects resolve to undefined so callers don't need to inspect isDeleted.
69
69
  // getSnapshot adds SnapshotKindId brand at runtime; cast bridges static types.
70
70
  get.setSelf(isDeleted(target) ? undefined : (getSnapshot(target) as unknown as Obj.Snapshot<T>));
71
71
  });
72
- return getSnapshot(target) as unknown as Obj.Snapshot<T>;
72
+ // Guard the initial value too: an already-deleted target must resolve to undefined, not leak a
73
+ // snapshot until the next update.
74
+ return isDeleted(target) ? undefined : (getSnapshot(target) as unknown as Obj.Snapshot<T>);
73
75
  };
74
76
 
75
77
  get.addFinalizer(() => {
@@ -8,18 +8,27 @@ import { describe, expect, test } from 'vitest';
8
8
  import { DXN, EID } from '@dxos/keys';
9
9
 
10
10
  import * as Obj from '../../Obj';
11
+ import * as Relation from '../../Relation';
11
12
  import { TestSchema } from '../../testing';
12
13
  import * as Type from '../../Type';
13
14
  import { getTypename, getTypeURI } from '../Annotation';
14
15
  import { getMetaChecked } from '../common/api';
15
- import { ATTR_TYPE, EntityKind, KindId, TypeId, getSchema } from '../common/types';
16
+ import { makeDecodedEntityLive } from '../common/proxy';
17
+ import { type AnyEntity, ATTR_TYPE, EntityKind, KindId, TypeId, getSchema } from '../common/types';
16
18
  import { MetaId } from '../common/types/model-symbols';
17
19
  import { RelationSourceId, RelationTargetId, getObjectEchoUri } from '../Entity';
18
20
  import * as JsonSchema from '../JsonSchema';
19
- import { Ref, StaticRefResolver } from '../Ref';
21
+ import { Ref, type RefResolver, StaticRefResolver } from '../Ref';
20
22
  import { createObject } from './create-object';
21
23
  import { objectFromJSON, objectToJSON } from './json-serializer';
22
24
 
25
+ /**
26
+ * Decode JSON and rewrap as a live reactive proxy — mirrors the feed hydration path
27
+ * (`objectFromJSON` then {@link makeDecodedEntityLive}).
28
+ */
29
+ const decodeLive = async (json: unknown, options?: { refResolver?: RefResolver }): Promise<AnyEntity> =>
30
+ makeDecodedEntityLive(await objectFromJSON(json, options));
31
+
23
32
  describe('Object JSON serializer', () => {
24
33
  test('should serialize and deserialize object', async () => {
25
34
  const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
@@ -159,6 +168,157 @@ describe('Object JSON serializer', () => {
159
168
  });
160
169
  });
161
170
 
171
+ describe('live decode', () => {
172
+ test('decodes as a live proxy, not a plain snapshot', async () => {
173
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
174
+ const contactJson = objectToJSON(contact);
175
+ const refResolver = new StaticRefResolver().addSchema(TestSchema.Person);
176
+
177
+ const live = (await decodeLive(contactJson, { refResolver })) as TestSchema.Person;
178
+
179
+ expect(Obj.instanceOf(TestSchema.Person)(live)).toBe(true);
180
+ expect(live.name).toBe('Alice');
181
+ });
182
+
183
+ test('Obj.update mutates synchronously and notifies subscribers', async () => {
184
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
185
+ const contactJson = objectToJSON(contact);
186
+ const refResolver = new StaticRefResolver().addSchema(TestSchema.Person);
187
+ const live = (await decodeLive(contactJson, { refResolver })) as TestSchema.Person;
188
+
189
+ let notified = 0;
190
+ const unsubscribe = Obj.subscribe(live, () => {
191
+ notified++;
192
+ });
193
+
194
+ Obj.update(live, (live) => {
195
+ live.name = 'Bob';
196
+ });
197
+
198
+ expect(live.name).toBe('Bob');
199
+ expect(notified).toBe(1);
200
+ unsubscribe();
201
+ });
202
+
203
+ test('direct assignment outside Obj.update throws', async () => {
204
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
205
+ const contactJson = objectToJSON(contact);
206
+ const refResolver = new StaticRefResolver().addSchema(TestSchema.Person);
207
+ const live = (await decodeLive(contactJson, { refResolver })) as TestSchema.Person;
208
+
209
+ expect(() => {
210
+ Reflect.set(live, 'name', 'Bob');
211
+ }).toThrow();
212
+ });
213
+
214
+ test('Obj.getSnapshot returns a non-live snapshot unaffected by later updates', async () => {
215
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
216
+ const contactJson = objectToJSON(contact);
217
+ const refResolver = new StaticRefResolver().addSchema(TestSchema.Person);
218
+ const live = (await decodeLive(contactJson, { refResolver })) as TestSchema.Person;
219
+
220
+ const snapshot = Obj.getSnapshot(live);
221
+ Obj.update(live, (live) => {
222
+ live.name = 'Bob';
223
+ });
224
+
225
+ expect(snapshot.name).toBe('Alice');
226
+ expect(live.name).toBe('Bob');
227
+ });
228
+
229
+ test('meta is mutable within a change context', async () => {
230
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
231
+ const contactJson = objectToJSON(contact);
232
+ const refResolver = new StaticRefResolver().addSchema(TestSchema.Person);
233
+ const live = (await decodeLive(contactJson, { refResolver })) as TestSchema.Person;
234
+
235
+ Obj.update(live, (live) => {
236
+ Obj.getMeta(live).keys.push({ id: 'abc', source: 'example.com' });
237
+ });
238
+
239
+ expect(Obj.getMeta(live).keys).toEqual([{ id: 'abc', source: 'example.com' }]);
240
+ });
241
+
242
+ test('live decode of a relation', async () => {
243
+ const alice = Obj.make(TestSchema.Person, { name: 'Alice' });
244
+ const org = Obj.make(TestSchema.Organization, { name: 'Acme' });
245
+ const relation = Relation.make(TestSchema.EmployedBy, {
246
+ [Relation.Source]: alice,
247
+ [Relation.Target]: org,
248
+ role: 'Engineer',
249
+ });
250
+ const relationJson = objectToJSON(relation as any);
251
+ const refResolver = new StaticRefResolver()
252
+ .addSchema(TestSchema.EmployedBy)
253
+ .addSchema(TestSchema.Person)
254
+ .addSchema(TestSchema.Organization)
255
+ .addObject(alice)
256
+ .addObject(org);
257
+
258
+ const live = (await decodeLive(relationJson, { refResolver })) as any;
259
+
260
+ let notified = 0;
261
+ const unsubscribe = Obj.subscribe(live, () => {
262
+ notified++;
263
+ });
264
+ Relation.update(live, (live) => {
265
+ live.role = 'Senior Engineer';
266
+ });
267
+ expect(live.role).toBe('Senior Engineer');
268
+ expect(notified).toBe(1);
269
+ unsubscribe();
270
+ });
271
+
272
+ test('schemaless (unresolvable type) items stay plain, non-proxy snapshots', async () => {
273
+ const contact = createObject(TestSchema.Person, { name: 'Alice' });
274
+ const contactJson = objectToJSON(contact);
275
+
276
+ const live: any = await decodeLive(contactJson);
277
+
278
+ expect(getSchema(live)).toBeUndefined();
279
+ expect(live.name).toBe('Alice');
280
+ // No schema means `Obj.update` cannot validate mutations — direct assignment remains a
281
+ // silent no-op-safe plain-object write, not a throw.
282
+ live.name = 'Bob';
283
+ expect(live.name).toBe('Bob');
284
+ });
285
+
286
+ test('a malformed decoded shape surfaces a clear error rather than a silent bad proxy', async () => {
287
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice' });
288
+ const contactJson: any = objectToJSON(contact);
289
+ // Corrupt a field so it violates the schema; live decode must not swallow this.
290
+ contactJson.age = 'not-a-number';
291
+ const refResolver = new StaticRefResolver().addSchema(TestSchema.Person);
292
+
293
+ await expect(decodeLive(contactJson, { refResolver })).rejects.toThrow();
294
+ });
295
+
296
+ test('nested array fields become reactive on live decode', async () => {
297
+ const task1 = Obj.make(TestSchema.Task, { title: 'Task 1' });
298
+ const contact = Obj.make(TestSchema.Person, { name: 'Alice', tasks: [Ref.make(task1)] });
299
+ const contactJson = objectToJSON(contact);
300
+ const refResolver = new StaticRefResolver()
301
+ .addSchema(TestSchema.Person)
302
+ .addSchema(TestSchema.Task)
303
+ .addObject(task1);
304
+
305
+ const live = (await decodeLive(contactJson, { refResolver })) as TestSchema.Person;
306
+
307
+ let notified = 0;
308
+ const unsubscribe = Obj.subscribe(live, () => {
309
+ notified++;
310
+ });
311
+ const task2 = Obj.make(TestSchema.Task, { title: 'Task 2' });
312
+ Obj.update(live, (live) => {
313
+ live.tasks!.push(Ref.make(task2));
314
+ });
315
+
316
+ expect(live.tasks).toHaveLength(2);
317
+ expect(notified).toBe(1);
318
+ unsubscribe();
319
+ });
320
+ });
321
+
162
322
  describe('Uint8Array', () => {
163
323
  const Blob = Type.makeObject(DXN.make('com.example.type.blob', '0.1.0'))(
164
324
  Schema.Struct({
@@ -33,6 +33,7 @@ import {
33
33
  ATTR_SELF_URI,
34
34
  ATTR_SELF_URI_LEGACY,
35
35
  ObjectDatabaseId,
36
+ ObjectDeletedId,
36
37
  type ObjectJSON,
37
38
  RelationSourceDXNId,
38
39
  RelationSourceId,
@@ -175,6 +176,10 @@ export const objectFromJSON = async (
175
176
  defineHiddenProperty(obj, ObjectDatabaseId, database);
176
177
  }
177
178
 
179
+ // Carry the deletion marker so a tombstone snapshot (e.g. a feed item removed via `Feed.remove`)
180
+ // hydrates with `Obj.isDeleted === true` rather than as a live object.
181
+ defineHiddenProperty(obj, ObjectDeletedId, jsonData[ATTR_DELETED] === true);
182
+
178
183
  assertObjectModel(obj);
179
184
  invariant((obj as any)[ATTR_TYPE] === undefined, 'Invalid object model');
180
185
  invariant((obj as any)[ATTR_META] === undefined, 'Invalid object model');
@@ -183,6 +188,7 @@ export const objectFromJSON = async (
183
188
  invariant((obj as any)[ATTR_SELF_URI_LEGACY] === undefined, 'Invalid object model');
184
189
  invariant((obj as any)[ATTR_RELATION_SOURCE] === undefined, 'Invalid object model');
185
190
  invariant((obj as any)[ATTR_RELATION_TARGET] === undefined, 'Invalid object model');
191
+
186
192
  return obj;
187
193
  };
188
194
 
@@ -50,6 +50,8 @@ export const prettyFilter = (filter: QueryAST.Filter): string => {
50
50
  return `Filter.${filter.operator}(${JSON.stringify(filter.value)})`;
51
51
  case 'in':
52
52
  return `Filter.in([${filter.values.map((v) => JSON.stringify(v)).join(', ')}])`;
53
+ case 'in-query':
54
+ return `Filter.in(${prettyQuery(filter.subquery)}.project(${JSON.stringify(filter.property)}))`;
53
55
  case 'contains':
54
56
  return `Filter.contains(${JSON.stringify(filter.value)})`;
55
57
  case 'tag':
@@ -113,7 +115,7 @@ export const prettyQuery = (query: QueryAST.Query): string => {
113
115
  case 'order': {
114
116
  const orders = query.order.map((o) => {
115
117
  if (o.kind === 'natural') {
116
- return 'Order.natural()';
118
+ return `Order.natural(${JSON.stringify(o.direction)})`;
117
119
  } else if (o.kind === 'rank') {
118
120
  return `Order.rank(${JSON.stringify(o.direction)})`;
119
121
  } else if (o.kind === 'timestamp') {
@@ -158,5 +160,21 @@ export const prettyQuery = (query: QueryAST.Query): string => {
158
160
  }
159
161
  case 'limit':
160
162
  return `${prettyQuery(query.query)}.limit(${query.limit})`;
163
+ case 'skip':
164
+ return `${prettyQuery(query.query)}.skip(${query.skip})`;
165
+ case 'aggregate': {
166
+ const aggregates = query.aggregates.map((aggregate) => {
167
+ const arg =
168
+ aggregate.kind === 'items'
169
+ ? aggregate.limit !== undefined
170
+ ? `{ limit: ${aggregate.limit} }`
171
+ : ''
172
+ : aggregate.kind === 'count'
173
+ ? ''
174
+ : JSON.stringify(aggregate.property);
175
+ return `${JSON.stringify(aggregate.name)}: Aggregate.${aggregate.kind}(${arg})`;
176
+ });
177
+ return `${prettyQuery(query.query)}.aggregate({ ${aggregates.join(', ')} })`;
178
+ }
161
179
  }
162
180
  };
@@ -6,8 +6,6 @@ import * as Effect from 'effect/Effect';
6
6
  import * as Effectable from 'effect/Effectable';
7
7
  import * as Option from 'effect/Option';
8
8
 
9
- import { EffectEx } from '@dxos/effect';
10
-
11
9
  import type * as QueryResult from '../../QueryResult';
12
10
 
13
11
  /**
@@ -19,9 +17,9 @@ export const makeQueryResultEffect = <T, E, R>(
19
17
  eff: Effect.Effect<QueryResult.QueryResult<T>, E, R>,
20
18
  ): QueryResult.QueryResultEffect<T, E, R> => {
21
19
  return {
22
- run: Effect.flatMap(eff, (result) => EffectEx.promiseWithCauseCapture(() => result.run())),
20
+ run: Effect.flatMap(eff, (result) => Effect.promise(() => result.run())),
23
21
  first: Effect.flatMap(eff, (result) =>
24
- EffectEx.promiseWithCauseCapture(async () => Option.fromNullable(await result.firstOrUndefined())),
22
+ Effect.promise(async () => Option.fromNullable(await result.firstOrUndefined())),
25
23
  ),
26
24
 
27
25
  // Effect internals: the result is itself an Effect, so it carries the commit prototype.
@@ -36,5 +36,5 @@ export const refSimpleFamily = Atom.family(<T>(ref: Ref<T>): Atom.Atom<T | undef
36
36
  });
37
37
 
38
38
  return loadRefTarget(ref, get, setupSubscription);
39
- });
39
+ }).pipe(Atom.keepAlive);
40
40
  });
@@ -124,7 +124,7 @@ export const executeChange = (
124
124
  batchEvents(() => callback(proxy));
125
125
  } finally {
126
126
  exitContext();
127
- // Fire primary notification.
127
+ // Fire the primary notification (a real change).
128
128
  if (hasPendingNotifications(contextKey)) {
129
129
  clearPendingNotifications(contextKey);
130
130
  (eventTarget as any)[EventId]?.emit();
@@ -7,13 +7,19 @@ import type * as Schema from 'effect/Schema';
7
7
  import { EntityId } from '@dxos/keys';
8
8
 
9
9
  import { getTypeAnnotation } from '../../Annotation/annotations';
10
- import { type AnyProperties, KindId, ParentId, SchemaKindId, StaticTypeSchemaSlot } from '../types';
10
+ import { type AnyProperties, KindId, ParentId, SchemaId, SchemaKindId, StaticTypeSchemaSlot } from '../types';
11
11
  import { type EntityMeta, EntityMetaSchema } from '../types/meta';
12
12
  import { MetaId } from '../types/model-symbols';
13
13
  import { defineHiddenProperty } from './define-hidden-property';
14
14
  import { attachTypedJsonSerializer } from './json-serializer';
15
- import { createProxy, getProxyTarget, isValidProxyTarget } from './proxy-utils';
16
- import { TypedReactiveHandler, TypeSource, prepareTypedTarget, setMetaOwner } from './typed-handler';
15
+ import { createProxy, getProxyTarget, isProxy, isValidProxyTarget } from './proxy-utils';
16
+ import {
17
+ TypedReactiveHandler,
18
+ TypeSource,
19
+ prepareDecodedTypedTarget,
20
+ prepareTypedTarget,
21
+ setMetaOwner,
22
+ } from './typed-handler';
17
23
 
18
24
  /**
19
25
  *
@@ -96,6 +102,39 @@ const createReactiveObject = <T extends AnyProperties>(
96
102
  return proxy;
97
103
  };
98
104
 
105
+ /**
106
+ * Rewrap an object decoded from JSON (via `objectFromJSON`) into a live reactive proxy, so that
107
+ * `Obj.update` synchronously mutates it and `Obj.subscribe`/atoms fire — the same contract as
108
+ * automerge-backed objects. Unlike {@link makeObject}, the target already carries `SchemaId`/
109
+ * `TypeId` stamped by `setSchema`/`setTypename` (both `configurable: false`), so this does not
110
+ * re-derive or re-stamp them — it only validates against the schema, reactifies nested arrays,
111
+ * rewraps `[MetaId]` as a live nested proxy, and wraps the whole object in a proxy.
112
+ *
113
+ * Objects whose type could not be resolved at decode time (no `SchemaId`) are returned unchanged —
114
+ * they remain plain snapshots and `Obj.update` on them stays a no-op, since there is no schema to
115
+ * validate mutations against.
116
+ */
117
+ export const makeDecodedEntityLive = <T extends AnyProperties>(obj: T): T => {
118
+ const schema = (obj as any)[SchemaId] as Schema.Schema<T, any> | undefined;
119
+ if (!schema) {
120
+ return obj;
121
+ }
122
+
123
+ prepareDecodedTypedTarget(obj, schema);
124
+
125
+ const meta = (obj as any)[MetaId];
126
+ if (meta != null && typeof meta === 'object' && !isProxy(meta)) {
127
+ prepareTypedTarget(meta, EntityMetaSchema);
128
+ const metaProxy = createProxy(meta, TypedReactiveHandler.instance);
129
+ defineHiddenProperty(obj, MetaId, metaProxy);
130
+ const metaTarget = getProxyTarget(metaProxy);
131
+ setMetaOwner(metaTarget, obj);
132
+ }
133
+
134
+ attachTypedJsonSerializer(obj);
135
+ return createProxy(obj, TypedReactiveHandler.instance);
136
+ };
137
+
99
138
  /**
100
139
  * Set ID on ECHO object targets during creation.
101
140
  * Used for objects with schema and the ones explicitly marked as Expando.
@@ -68,6 +68,11 @@ export type ChangeCallback<T> = (mutableObj: Mutable<T>) => void;
68
68
  export const change = <T>(obj: T, callback: ChangeCallback<T>): void => {
69
69
  // Check proxy first (allows handler to intercept), then fall back to target.
70
70
  // This order is important for EchoReactiveHandler which handles ChangeId in the proxy trap.
71
+ //
72
+ // The direct-callback fallback (no `[ChangeId]`) now only applies to true snapshots and
73
+ // schemaless feed items (unresolvable type at decode time, see `makeDecodedEntityLive`) — live
74
+ // feed-backed objects carry a `[ChangeId]` (via `TypedReactiveHandler`) same as any other typed
75
+ // reactive object, so they take the `changeFn` branch above like automerge-backed objects do.
71
76
  const changeFn = (obj as any)[ChangeId];
72
77
  if (changeFn) {
73
78
  changeFn(callback);
@@ -4,4 +4,5 @@
4
4
 
5
5
  // TODO(dmaretskyi): Rename all symbols that are props to end with *Key.
6
6
  export const EventId = Symbol.for('@dxos/live-object/EventId');
7
+
7
8
  export const ChangeId = Symbol.for('@dxos/live-object/ChangeId');
@@ -264,10 +264,17 @@ const compactMetadataToInstanceState = (target: ProxyTarget): void => {
264
264
  }
265
265
  const state = Object.create(TypedObjectPrototype);
266
266
  for (const symbol of Object.getOwnPropertySymbols(target)) {
267
- Object.defineProperty(state, symbol, Object.getOwnPropertyDescriptor(target, symbol)!);
268
- // Dynamic delete over arbitrary symbol keys: `ProxyTarget` declares these as required, so the
269
- // statically-typed view cannot express deleting them; the symbol set is only known at runtime.
270
- delete (target as any)[symbol];
267
+ const descriptor = Object.getOwnPropertyDescriptor(target, symbol)!;
268
+ Object.defineProperty(state, symbol, descriptor);
269
+ // Non-configurable symbols (e.g. `SchemaId`/`TypeId`, locked by `setSchema`/`setTypename` on
270
+ // objects decoded from JSON) cannot be deleted — leave them as an own property on the target,
271
+ // shadowing the copy on `state`; reads still resolve correctly since own properties take
272
+ // precedence over the prototype chain.
273
+ if (descriptor.configurable) {
274
+ // Dynamic delete over arbitrary symbol keys: `ProxyTarget` declares these as required, so the
275
+ // statically-typed view cannot express deleting them; the symbol set is only known at runtime.
276
+ delete (target as any)[symbol];
277
+ }
271
278
  }
272
279
  Object.setPrototypeOf(target, state);
273
280
  };
@@ -295,7 +302,11 @@ export class TypedReactiveHandler implements ReactiveHandler<ProxyTarget> {
295
302
  defineHiddenProperty(target, EventId, new Event());
296
303
  }
297
304
 
298
- defineHiddenProperty(target, ObjectDeletedId, false);
305
+ // Default to not-deleted, but preserve a deletion marker already stamped on the target (e.g. by
306
+ // `objectFromJSON` when hydrating a tombstone snapshot).
307
+ if (!(ObjectDeletedId in target)) {
308
+ defineHiddenProperty(target, ObjectDeletedId, false);
309
+ }
299
310
 
300
311
  // Only set owners if this is a root object (no existing owner).
301
312
  // Nested objects already have owners set by their root's initialization.
@@ -606,31 +617,43 @@ export type TypeSource = { readonly [StaticTypeSchemaSlot]?: Schema.Schema.AnyNo
606
617
 
607
618
  /**
608
619
  * Recursively set AST on all potential proxy targets.
620
+ *
621
+ * @param skipOwnStamp Skip stamping `TypeId`/`SchemaId` on `obj` itself (still recurses into
622
+ * children, which always get stamped). Used for objects decoded from JSON, whose own `TypeId`/
623
+ * `SchemaId` are already set by `setTypename`/`setSchema` as `configurable: false` — redefining
624
+ * them here (with `configurable: true`) would throw.
609
625
  */
610
- const setSchemaProperties = (obj: any, schema: Schema.Schema.AnyNoContext, typeSource?: TypeSource) => {
611
- const schemaType = getSchemaURI(schema);
612
- if (schemaType != null) {
613
- defineHiddenProperty(obj, TypeId, schemaType);
614
- }
626
+ const setSchemaProperties = (
627
+ obj: any,
628
+ schema: Schema.Schema.AnyNoContext,
629
+ typeSource?: TypeSource,
630
+ skipOwnStamp = false,
631
+ ) => {
632
+ if (!skipOwnStamp) {
633
+ const schemaType = getSchemaURI(schema);
634
+ if (schemaType != null) {
635
+ defineHiddenProperty(obj, TypeId, schemaType);
636
+ }
615
637
 
616
- if (typeSource != null) {
617
- // Keep a back-reference to the type entity so `Obj.getType` /
618
- // `Relation.getType` / `Entity.getType` can return it.
619
- defineHiddenProperty(obj, TypeEntityId, typeSource);
620
-
621
- // Install `SchemaId` as a getter that reads through the entity's static
622
- // schema slot. The three entity shapes (static / persisted / in-memory
623
- // pre-persist) each populate the slot via their own get-trap path, so
624
- // `Type.update` / `Type.addFields` mutations propagate into validation
625
- // for objects created via `Obj.make(typeEntity, ...)` without this file
626
- // having to rebuild from `jsonSchema` itself.
627
- Object.defineProperty(obj, SchemaId, {
628
- get: () => typeSource[StaticTypeSchemaSlot] ?? schema,
629
- enumerable: false,
630
- configurable: true,
631
- });
632
- } else {
633
- defineHiddenProperty(obj, SchemaId, schema);
638
+ if (typeSource != null) {
639
+ // Keep a back-reference to the type entity so `Obj.getType` /
640
+ // `Relation.getType` / `Entity.getType` can return it.
641
+ defineHiddenProperty(obj, TypeEntityId, typeSource);
642
+
643
+ // Install `SchemaId` as a getter that reads through the entity's static
644
+ // schema slot. The three entity shapes (static / persisted / in-memory
645
+ // pre-persist) each populate the slot via their own get-trap path, so
646
+ // `Type.update` / `Type.addFields` mutations propagate into validation
647
+ // for objects created via `Obj.make(typeEntity, ...)` without this file
648
+ // having to rebuild from `jsonSchema` itself.
649
+ Object.defineProperty(obj, SchemaId, {
650
+ get: () => typeSource[StaticTypeSchemaSlot] ?? schema,
651
+ enumerable: false,
652
+ configurable: true,
653
+ });
654
+ } else {
655
+ defineHiddenProperty(obj, SchemaId, schema);
656
+ }
634
657
  }
635
658
 
636
659
  if (Array.isArray(obj)) {
@@ -661,6 +684,15 @@ const setSchemaProperties = (obj: any, schema: Schema.Schema.AnyNoContext, typeS
661
684
  // whose encoded form differs (e.g. refs encode as `{ '/': uri }`) are valid here.
662
685
  export const prepareTypedTarget = <T>(target: T, schema: Schema.Schema<T, any>, typeSource?: TypeSource) => {
663
686
  // log.info('prepareTypedTarget', { target, schema });
687
+ validateAndReactifyTarget(target, schema);
688
+ setSchemaProperties(target, schema, typeSource);
689
+ };
690
+
691
+ /**
692
+ * Validate a target against its schema and convert nested arrays to `ReactiveArray`. Shared by
693
+ * {@link prepareTypedTarget} and {@link prepareDecodedTypedTarget}.
694
+ */
695
+ export const validateAndReactifyTarget = <T>(target: T, schema: Schema.Schema<T, any>) => {
664
696
  if (!SchemaAST.isTypeLiteral(schema.ast)) {
665
697
  throw new Error('schema has to describe an object type');
666
698
  }
@@ -669,7 +701,17 @@ export const prepareTypedTarget = <T>(target: T, schema: Schema.Schema<T, any>,
669
701
  const _ = Schema.asserts(schema)(target);
670
702
  SchemaValidator.assertExactProperties(schema, target, (path) => getDeep(target, path));
671
703
  makeArraysReactive(target);
672
- setSchemaProperties(target, schema, typeSource);
704
+ };
705
+
706
+ /**
707
+ * Like {@link prepareTypedTarget}, for a target whose own `SchemaId`/`TypeId` are already stamped
708
+ * and locked `configurable: false` — e.g. an object decoded from JSON via `setSchema`/`setTypename`
709
+ * (see `objectFromJSON`). Validates and reactifies as usual, then stamps `SchemaId`/`TypeId` on
710
+ * nested records/arrays only, leaving the target's own (locked) stamps untouched.
711
+ */
712
+ export const prepareDecodedTypedTarget = <T>(target: T, schema: Schema.Schema<T, any>) => {
713
+ validateAndReactifyTarget(target, schema);
714
+ setSchemaProperties(target, schema, undefined, true);
673
715
  };
674
716
 
675
717
  const makeArraysReactive = (target: any) => {