@treenity/core 3.0.0 → 3.0.1

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 (322) hide show
  1. package/README.md +78 -0
  2. package/dist/chain.d.ts +3 -4
  3. package/dist/chain.d.ts.map +1 -1
  4. package/dist/chain.js.map +1 -1
  5. package/dist/client/trpc.d.ts.map +1 -1
  6. package/dist/client/trpc.js +1 -0
  7. package/dist/client/trpc.js.map +1 -1
  8. package/dist/comp/index.d.ts +3 -4
  9. package/dist/comp/index.d.ts.map +1 -1
  10. package/dist/comp/index.js +5 -4
  11. package/dist/comp/index.js.map +1 -1
  12. package/dist/comp/needs.d.ts.map +1 -1
  13. package/dist/comp/needs.js +3 -3
  14. package/dist/comp/needs.js.map +1 -1
  15. package/dist/core/component.d.ts +10 -8
  16. package/dist/core/component.d.ts.map +1 -1
  17. package/dist/core/component.js +4 -8
  18. package/dist/core/component.js.map +1 -1
  19. package/dist/core/context.d.ts +2 -2
  20. package/dist/core/context.d.ts.map +1 -1
  21. package/dist/core/path.d.ts.map +1 -1
  22. package/dist/core/path.js +7 -3
  23. package/dist/core/path.js.map +1 -1
  24. package/dist/core/registry.d.ts +2 -1
  25. package/dist/core/registry.d.ts.map +1 -1
  26. package/dist/core/registry.js.map +1 -1
  27. package/dist/core.d.ts +1 -1
  28. package/dist/core.d.ts.map +1 -1
  29. package/dist/core.js +1 -1
  30. package/dist/core.js.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/log.d.ts +30 -0
  36. package/dist/log.d.ts.map +1 -1
  37. package/dist/log.js +119 -0
  38. package/dist/log.js.map +1 -1
  39. package/dist/mod/examples/ticker/service.js +2 -3
  40. package/dist/mod/examples/ticker/service.js.map +1 -1
  41. package/dist/mod/index.d.ts +1 -1
  42. package/dist/mod/index.d.ts.map +1 -1
  43. package/dist/mod/index.js +1 -1
  44. package/dist/mod/index.js.map +1 -1
  45. package/dist/mod/loader.d.ts +1 -0
  46. package/dist/mod/loader.d.ts.map +1 -1
  47. package/dist/mod/loader.js +27 -1
  48. package/dist/mod/loader.js.map +1 -1
  49. package/dist/mods/clients.d.ts +2 -0
  50. package/dist/mods/clients.d.ts.map +1 -0
  51. package/dist/mods/clients.js +3 -0
  52. package/dist/mods/clients.js.map +1 -0
  53. package/dist/mods/llm/index.js +1 -1
  54. package/dist/mods/llm/index.js.map +1 -1
  55. package/dist/mods/mcp/server.d.ts +0 -1
  56. package/dist/mods/mcp/server.js +0 -1
  57. package/dist/mods/mcp/service.d.ts +0 -1
  58. package/dist/mods/mcp/service.js +0 -1
  59. package/dist/mods/mcp/types.d.ts +0 -1
  60. package/dist/mods/mcp/types.js +0 -1
  61. package/dist/mods/servers.d.ts +4 -0
  62. package/dist/mods/servers.d.ts.map +1 -0
  63. package/dist/mods/servers.js +5 -0
  64. package/dist/mods/servers.js.map +1 -0
  65. package/dist/mods/treenity/builtins.d.ts +2 -0
  66. package/dist/mods/treenity/builtins.d.ts.map +1 -0
  67. package/dist/mods/treenity/builtins.js +18 -0
  68. package/dist/mods/treenity/builtins.js.map +1 -0
  69. package/dist/mods/treenity/logs.d.ts +18 -0
  70. package/dist/mods/treenity/logs.d.ts.map +1 -0
  71. package/dist/mods/treenity/logs.js +17 -0
  72. package/dist/mods/treenity/logs.js.map +1 -0
  73. package/dist/mods/treenity/seed.js +29 -27
  74. package/dist/mods/treenity/seed.js.map +1 -1
  75. package/dist/mods/treenity/server.d.ts +2 -0
  76. package/dist/mods/treenity/server.d.ts.map +1 -1
  77. package/dist/mods/treenity/server.js +2 -0
  78. package/dist/mods/treenity/server.js.map +1 -1
  79. package/dist/mods/uix/client.js +4 -4
  80. package/dist/mods/uix/client.js.map +1 -1
  81. package/dist/mods/uix/compile.d.ts.map +1 -1
  82. package/dist/mods/uix/compile.js +4 -2
  83. package/dist/mods/uix/compile.js.map +1 -1
  84. package/dist/schema/_test-fixture.d.ts +11 -0
  85. package/dist/schema/_test-fixture.d.ts.map +1 -0
  86. package/dist/schema/_test-fixture.js +8 -0
  87. package/dist/schema/_test-fixture.js.map +1 -0
  88. package/dist/schema/types.d.ts +1 -0
  89. package/dist/schema/types.d.ts.map +1 -1
  90. package/dist/server/actions.js +1 -1
  91. package/dist/server/auth.d.ts.map +1 -1
  92. package/dist/server/auth.js +4 -3
  93. package/dist/server/auth.js.map +1 -1
  94. package/dist/server/client.d.ts +10 -3
  95. package/dist/server/client.d.ts.map +1 -1
  96. package/dist/server/client.js +1 -1
  97. package/dist/server/client.js.map +1 -1
  98. package/dist/server/doc-index.d.ts.map +1 -1
  99. package/dist/server/doc-index.js +13 -12
  100. package/dist/server/doc-index.js.map +1 -1
  101. package/dist/server/factory.d.ts +6 -0
  102. package/dist/server/factory.d.ts.map +1 -1
  103. package/dist/server/factory.js +44 -25
  104. package/dist/server/factory.js.map +1 -1
  105. package/dist/server/main.d.ts +0 -4
  106. package/dist/server/main.d.ts.map +1 -1
  107. package/dist/server/main.js +4 -30
  108. package/dist/server/main.js.map +1 -1
  109. package/dist/server/mcp.d.ts +0 -1
  110. package/dist/server/mcp.js +0 -1
  111. package/dist/server/migrate.js +3 -3
  112. package/dist/server/migrate.js.map +1 -1
  113. package/dist/server/mods-mount.d.ts +0 -1
  114. package/dist/server/mods-mount.d.ts.map +1 -1
  115. package/dist/server/mods-mount.js +0 -1
  116. package/dist/server/mods-mount.js.map +1 -1
  117. package/dist/server/mount-adapters.js +9 -4
  118. package/dist/server/mount-adapters.js.map +1 -1
  119. package/dist/server/prefab.d.ts +2 -2
  120. package/dist/server/prefab.d.ts.map +1 -1
  121. package/dist/server/prefab.js +4 -2
  122. package/dist/server/prefab.js.map +1 -1
  123. package/dist/server/refs.d.ts +3 -0
  124. package/dist/server/refs.d.ts.map +1 -0
  125. package/dist/server/refs.js +59 -0
  126. package/dist/server/refs.js.map +1 -0
  127. package/dist/server/server.d.ts.map +1 -1
  128. package/dist/server/server.js +13 -3
  129. package/dist/server/server.js.map +1 -1
  130. package/dist/server/sub.js.map +1 -1
  131. package/dist/server/trpc.d.ts +7 -0
  132. package/dist/server/trpc.d.ts.map +1 -1
  133. package/dist/server/trpc.js +6 -2
  134. package/dist/server/trpc.js.map +1 -1
  135. package/dist/tree/fs.d.ts.map +1 -1
  136. package/dist/tree/fs.js +20 -18
  137. package/dist/tree/fs.js.map +1 -1
  138. package/dist/tree/index.d.ts.map +1 -1
  139. package/dist/tree/index.js +2 -1
  140. package/dist/tree/index.js.map +1 -1
  141. package/dist/tree-chain.d.ts +2 -1
  142. package/dist/tree-chain.d.ts.map +1 -1
  143. package/dist/tree-chain.js +5 -3
  144. package/dist/tree-chain.js.map +1 -1
  145. package/dist/uri.d.ts.map +1 -1
  146. package/dist/uri.js +8 -3
  147. package/dist/uri.js.map +1 -1
  148. package/package.json +48 -6
  149. package/src/chain.ts +7 -5
  150. package/src/client/trpc.ts +1 -0
  151. package/src/comp/index.test.ts +45 -9
  152. package/src/comp/index.ts +19 -8
  153. package/src/comp/needs.ts +3 -3
  154. package/src/core/component.ts +16 -14
  155. package/src/core/context.ts +4 -4
  156. package/src/core/index.test.ts +22 -1
  157. package/src/core/path.ts +6 -3
  158. package/src/core/registry.ts +4 -7
  159. package/src/core.ts +1 -1
  160. package/src/index.ts +1 -0
  161. package/src/log.ts +172 -1
  162. package/src/mod/docs/07-realtime.md +19 -11
  163. package/src/mod/docs/08-services.md +10 -8
  164. package/src/mod/docs/10-acl.md +1 -1
  165. package/src/mod/docs/12-conventions.md +43 -0
  166. package/src/mod/docs/13-example.md +36 -26
  167. package/src/mod/docs/14-mod-format.md +81 -8
  168. package/src/mod/examples/ticker/service.ts +2 -3
  169. package/src/mod/index.ts +1 -1
  170. package/src/mod/loader.test.ts +53 -1
  171. package/src/mod/loader.ts +34 -1
  172. package/src/mods/clients.ts +2 -0
  173. package/src/mods/llm/index.ts +1 -1
  174. package/src/mods/servers.ts +4 -0
  175. package/src/mods/treenity/builtins.ts +21 -0
  176. package/src/mods/treenity/logs.ts +26 -0
  177. package/src/mods/treenity/seed.ts +30 -28
  178. package/src/mods/treenity/server.ts +2 -0
  179. package/src/mods/uix/client.ts +4 -4
  180. package/src/mods/uix/compile.ts +4 -2
  181. package/src/schema/_test-fixture.ts +12 -0
  182. package/src/schema/generated/ai.agent.json +133 -0
  183. package/src/schema/generated/ai.approval.json +105 -0
  184. package/src/schema/generated/ai.approvals.json +24 -0
  185. package/src/schema/generated/ai.assignment.json +28 -0
  186. package/src/schema/generated/ai.plan.json +84 -0
  187. package/src/schema/generated/ai.policy.json +105 -0
  188. package/src/schema/generated/ai.pool.json +37 -0
  189. package/src/schema/generated/ai.thread.json +64 -0
  190. package/src/schema/generated/board.kanban.json +7 -0
  191. package/src/schema/generated/canary.item.json +40 -0
  192. package/src/schema/generated/claude-search.json +20 -0
  193. package/src/schema/generated/craft.product.json +47 -0
  194. package/src/schema/generated/craft.shop.json +94 -0
  195. package/src/schema/generated/craft.subscription.json +27 -0
  196. package/src/schema/generated/examples.demo.sensor.reading.json +25 -0
  197. package/src/schema/generated/flow.node.action.json +61 -0
  198. package/src/schema/generated/flow.node.code.json +43 -0
  199. package/src/schema/generated/flow.node.condition.json +37 -0
  200. package/src/schema/generated/flow.node.end.json +35 -0
  201. package/src/schema/generated/flow.node.http.json +65 -0
  202. package/src/schema/generated/flow.node.llm.json +67 -0
  203. package/src/schema/generated/flow.node.loop.json +49 -0
  204. package/src/schema/generated/flow.node.start.json +39 -0
  205. package/src/schema/generated/flow.scenario.json +118 -0
  206. package/src/schema/generated/grove.attempt.json +199 -0
  207. package/src/schema/generated/grove.path.json +93 -0
  208. package/src/schema/generated/grove.review.json +27 -0
  209. package/src/schema/generated/grove.task.json +164 -0
  210. package/src/schema/generated/intel.scenario.json +58 -0
  211. package/src/schema/generated/intel.signal.json +113 -0
  212. package/src/schema/generated/intel.world.json +15 -0
  213. package/src/schema/generated/landing.block.json +201 -0
  214. package/src/schema/generated/landing.page.json +84 -0
  215. package/src/schema/generated/metatron.config.json +119 -0
  216. package/src/schema/generated/metatron.permission.json +36 -0
  217. package/src/schema/generated/metatron.skill.json +36 -0
  218. package/src/schema/generated/metatron.task.json +114 -0
  219. package/src/schema/generated/metatron.template.json +26 -0
  220. package/src/schema/generated/metatron.workspace.json +60 -0
  221. package/src/schema/generated/order.status.json +21 -0
  222. package/src/schema/generated/polyhope.backtest.json +161 -0
  223. package/src/schema/generated/polyhope.feed.json +33 -0
  224. package/src/schema/generated/polyhope.run.json +94 -0
  225. package/src/schema/generated/polyhope.strategy.json +152 -0
  226. package/src/schema/generated/polymax.activity.json +65 -0
  227. package/src/schema/generated/polymax.aggr-feed.json +28 -0
  228. package/src/schema/generated/polymax.aggr.json +20 -0
  229. package/src/schema/generated/polymax.alert.json +56 -0
  230. package/src/schema/generated/polymax.bot-config.json +14 -0
  231. package/src/schema/generated/polymax.bot-status.json +35 -0
  232. package/src/schema/generated/polymax.classification.json +55 -0
  233. package/src/schema/generated/polymax.deposits.json +45 -0
  234. package/src/schema/generated/polymax.holding.json +55 -0
  235. package/src/schema/generated/polymax.identity.json +71 -0
  236. package/src/schema/generated/polymax.lb-entry.json +75 -0
  237. package/src/schema/generated/polymax.leaderboard.json +37 -0
  238. package/src/schema/generated/polymax.market-ref.json +25 -0
  239. package/src/schema/generated/polymax.performance.json +65 -0
  240. package/src/schema/generated/polymax.profile.json +16 -0
  241. package/src/schema/generated/polymax.scan-result.json +50 -0
  242. package/src/schema/generated/polymax.status.json +40 -0
  243. package/src/schema/generated/polymax.tags.json +53 -0
  244. package/src/schema/generated/polymax.trader.json +16 -0
  245. package/src/schema/generated/polymax.wallet-market.json +50 -0
  246. package/src/schema/generated/polymax.wallet-pnl.json +35 -0
  247. package/src/schema/generated/pult.concept.json +53 -0
  248. package/src/schema/generated/pult.config.json +227 -0
  249. package/src/schema/generated/pult.connector.json +72 -0
  250. package/src/schema/generated/pult.market.json +113 -0
  251. package/src/schema/generated/pult.order.json +113 -0
  252. package/src/schema/generated/pult.rete.json +68 -0
  253. package/src/schema/generated/pult.sensor.json +74 -0
  254. package/src/schema/generated/pult.signal.json +54 -0
  255. package/src/schema/generated/pult.synapse.json +93 -0
  256. package/src/schema/generated/pult.trade.json +93 -0
  257. package/src/schema/generated/resim.config.json +34 -0
  258. package/src/schema/generated/resim.function.json +62 -0
  259. package/src/schema/generated/resim.goal.json +22 -0
  260. package/src/schema/generated/resim.resource.json +40 -0
  261. package/src/schema/generated/resim.state.json +48 -0
  262. package/src/schema/generated/resim.world.json +40 -0
  263. package/src/schema/generated/saveme.action.save.json +29 -0
  264. package/src/schema/generated/saveme.message.json +36 -0
  265. package/src/schema/generated/saveme.router.json +31 -0
  266. package/src/schema/generated/t.coolify.json +50 -0
  267. package/src/schema/generated/t.event.json +46 -0
  268. package/src/schema/generated/t.logs.json +155 -0
  269. package/src/schema/generated/t.note.json +31 -0
  270. package/src/schema/generated/t.person.json +36 -0
  271. package/src/schema/generated/t.tenant.json +57 -0
  272. package/src/schema/generated/t.tenant.status.json +42 -0
  273. package/src/schema/generated/tagger.config.json +115 -0
  274. package/src/schema/generated/tagger.result.json +57 -0
  275. package/src/schema/generated/tagger.tree.json +36 -0
  276. package/src/schema/generated/ui.table.json +46 -0
  277. package/src/schema/types.ts +1 -0
  278. package/src/server/actions.test.ts +1 -1
  279. package/src/server/actions.ts +1 -1
  280. package/src/server/api.test.ts +9 -0
  281. package/src/server/auth.ts +4 -3
  282. package/src/server/client.ts +1 -1
  283. package/src/server/coverage.test.ts +1 -1
  284. package/src/server/doc-index.ts +13 -12
  285. package/src/server/e2e.test.ts +4 -3
  286. package/src/server/factory.ts +46 -24
  287. package/src/server/main.ts +4 -36
  288. package/src/server/migrate.ts +4 -4
  289. package/src/server/mods-mount.ts +0 -2
  290. package/src/server/mount-adapters.ts +9 -4
  291. package/src/server/mount.test.ts +73 -5
  292. package/src/server/prefab.ts +3 -2
  293. package/src/server/refs.test.ts +82 -0
  294. package/src/server/refs.ts +64 -0
  295. package/src/server/server.ts +14 -3
  296. package/src/server/sub.ts +2 -2
  297. package/src/server/trpc.ts +9 -3
  298. package/src/tree/fs.ts +21 -15
  299. package/src/tree/index.test.ts +26 -0
  300. package/src/tree/index.ts +2 -1
  301. package/src/tree-chain.test.ts +37 -44
  302. package/src/tree-chain.ts +11 -5
  303. package/src/uri.test.ts +32 -5
  304. package/src/uri.ts +4 -2
  305. package/dist/mods/mcp/server.d.ts.map +0 -1
  306. package/dist/mods/mcp/server.js.map +0 -1
  307. package/dist/mods/mcp/service.d.ts.map +0 -1
  308. package/dist/mods/mcp/service.js.map +0 -1
  309. package/dist/mods/mcp/types.d.ts.map +0 -1
  310. package/dist/mods/mcp/types.js.map +0 -1
  311. package/dist/schema/test-opaque.d.ts +0 -3
  312. package/dist/schema/test-opaque.d.ts.map +0 -1
  313. package/dist/schema/test-opaque.js +0 -43
  314. package/dist/schema/test-opaque.js.map +0 -1
  315. package/dist/server/mcp.d.ts.map +0 -1
  316. package/dist/server/mcp.js.map +0 -1
  317. package/src/mods/mcp/CLAUDE.md +0 -6
  318. package/src/mods/mcp/server.ts +0 -2
  319. package/src/mods/mcp/service.ts +0 -19
  320. package/src/mods/mcp/types.ts +0 -7
  321. package/src/schema/test-opaque.ts +0 -42
  322. package/src/server/mcp.ts +0 -326
@@ -117,7 +117,7 @@ async function resolveActionHandler(
117
117
 
118
118
  let deps: ResolvedDeps = await _collectDeps(node, fieldKey!, action, store);
119
119
 
120
- let handler = resolve(comp, `action:${action}`);
120
+ let handler = resolve(type, `action:${action}`);
121
121
 
122
122
  // Fallback: try loading dynamic action from type definition node
123
123
  if (!handler) handler = await loadDynamicAction(store, type, action);
@@ -312,6 +312,15 @@ describe('tRPC API integration', () => {
312
312
  const result = await caller.getChildren({ path: '/private' });
313
313
  assert.equal(result.items.length, 0);
314
314
  });
315
+
316
+ it('public cannot execute action on denied node', async () => {
317
+ // Node is inaccessible → execute returns NOT_FOUND (security: don't reveal existence)
318
+ await rawStore.set(createNode('/private/order', 'order.status', { status: 'new' }));
319
+ await assert.rejects(
320
+ () => caller.execute({ path: '/private/order', action: 'cook' }),
321
+ (e: any) => e.code === 'NOT_FOUND',
322
+ );
323
+ });
315
324
  });
316
325
 
317
326
  // ── Events ──
@@ -270,12 +270,13 @@ export function stripComponents(node: NodeData, userId: string | null, claims: s
270
270
  // ── Build claims ──
271
271
 
272
272
  export async function buildClaims(store: Tree, userId: string): Promise<string[]> {
273
- const claims = [`u:${userId}`, 'authenticated'];
273
+ const group = userId.startsWith('anon:') ? 'public' : 'authenticated';
274
+ const claims = [`u:${userId}`, group];
274
275
  const userNode = await store.get(`/auth/users/${userId}`);
275
276
  if (userNode) {
276
277
  const gv = userNode['groups'];
277
- const groups = isComponent(gv) ? gv as { $type: string; list?: string[] } : undefined;
278
- if (groups?.list) claims.push(...groups.list);
278
+ const groups = isComponent(gv) ? gv : undefined;
279
+ if (Array.isArray(groups?.['list'])) claims.push(...groups['list']);
279
280
  }
280
281
  return claims;
281
282
  }
@@ -17,7 +17,7 @@ export function createClient(url: string, token?: string) {
17
17
  EventSource: EventSource as any,
18
18
  connectionParams: () => (token ? { token } : {}),
19
19
  }),
20
- false: httpBatchLink({ url, headers: () => headers }),
20
+ false: httpBatchLink({ url, maxURLLength: 2048, headers: () => headers }),
21
21
  }),
22
22
  ],
23
23
  });
@@ -51,7 +51,7 @@ describe('Mount adapters', () => {
51
51
  register('t.mount.query', 'mount', (config: any, parentStore: Tree, _ctx: any, globalStore?: Tree) => {
52
52
  const n = config as NodeData;
53
53
  const qv = n['query'];
54
- const query = isComponent(qv) ? qv as { source: string; match: Record<string, unknown> } : undefined;
54
+ const query = isComponent(qv) ? qv as unknown as { source: string; match: Record<string, unknown> } : undefined;
55
55
  if (!query?.source || !query?.match) throw new Error("t.mount.query requires 'query' component with source and match");
56
56
  return createQueryTree({ source: query.source, match: query.match }, globalStore || parentStore);
57
57
  });
@@ -116,23 +116,24 @@ function collectFiles(projectRoot: string, memoryDir?: string): string[] {
116
116
  for (const f of walkDir(memoryDir, new Set(['.md']))) files.add(f);
117
117
  }
118
118
 
119
- // Core layers: full source
120
- for (const d of ['src/core', 'src/store', 'src/comp', 'src/server']) {
119
+ // Core layers: full source (engine/core/src/*)
120
+ for (const d of ['engine/core/src/core', 'engine/core/src/tree', 'engine/core/src/comp', 'engine/core/src/server', 'engine/core/src/schema', 'engine/core/src/client']) {
121
121
  for (const f of walkDir(join(projectRoot, d), new Set(['.ts']), /\.test\.ts$/)) files.add(f);
122
122
  }
123
123
 
124
- // Mods: types.ts, service.ts, client.ts, CLAUDE.md (CLAUDE.md already caught above)
125
- for (const f of walkDir(join(projectRoot, 'src/mods'), new Set(['.ts']), /\.test\.ts$/)) {
126
- const name = basename(f);
127
- if (['types.ts', 'service.ts', 'client.ts', 'server.ts', 'mcp.ts', 'view.tsx'].includes(name)) files.add(f);
128
- }
129
- // Also include .tsx views from mods
130
- for (const f of walkDir(join(projectRoot, 'src/mods'), new Set(['.tsx']), /\.test\.tsx$/)) {
131
- files.add(f);
124
+ // Mods: types.ts, service.ts, etc. engine/core/src/mods, engine/mods/*, mods/*
125
+ const modKeyFiles = new Set(['types.ts', 'service.ts', 'client.ts', 'server.ts', 'mcp.ts', 'view.tsx']);
126
+ for (const modDir of ['engine/core/src/mods', 'engine/mods', 'mods']) {
127
+ for (const f of walkDir(join(projectRoot, modDir), new Set(['.ts']), /\.test\.ts$/)) {
128
+ if (modKeyFiles.has(basename(f))) files.add(f);
129
+ }
130
+ for (const f of walkDir(join(projectRoot, modDir), new Set(['.tsx']), /\.test\.tsx$/)) {
131
+ files.add(f);
132
+ }
132
133
  }
133
134
 
134
- // Frontend key files
135
- for (const f of ['src/front/App.tsx', 'src/front/hooks.ts', 'src/front/cache.ts', 'src/front/trpc.ts', 'src/front/Inspector.tsx']) {
135
+ // Frontend key files (engine/packages/react/src/*)
136
+ for (const f of ['engine/packages/react/src/App.tsx', 'engine/packages/react/src/hooks.ts', 'engine/packages/react/src/cache.ts', 'engine/packages/react/src/trpc.ts']) {
136
137
  const full = join(projectRoot, f);
137
138
  if (existsSync(full)) files.add(full);
138
139
  }
@@ -3,7 +3,7 @@
3
3
  // ACL security (no leaked data), and action return values.
4
4
 
5
5
  import { registerType } from '#comp';
6
- import { createNode, R, S, W } from '#core';
6
+ import { createNode, R, S, W, register } from '#core';
7
7
  import { createMemoryTree } from '#tree';
8
8
  import assert from 'node:assert/strict';
9
9
  import type { Socket } from 'node:net';
@@ -97,6 +97,7 @@ describe('e2e: tRPC over HTTP', () => {
97
97
  registerType('streamer', Streamer);
98
98
  registerType('secret', Secret);
99
99
  registerType('task.priority', Priority);
100
+ register('test.task', 'schema', () => ({ type: 'object', title: 'Test Task' }));
100
101
  });
101
102
 
102
103
  beforeEach(async () => {
@@ -924,7 +925,7 @@ describe('e2e: tRPC over HTTP', () => {
924
925
 
925
926
  // Create new child (simulates action:task creating a task node)
926
927
  await pub.set.mutate({ node: {
927
- $path: '/agent-test/tasks/t-1', $type: 'agent.task',
928
+ $path: '/agent-test/tasks/t-1', $type: 'test.task',
928
929
  prompt: 'test task', status: 'pending', createdAt: 12345,
929
930
  } });
930
931
 
@@ -933,7 +934,7 @@ describe('e2e: tRPC over HTTP', () => {
933
934
  assert.equal(received[0].type, 'set', 'Event type should be "set"');
934
935
  assert.equal(received[0].path, '/agent-test/tasks/t-1');
935
936
  const node = (received[0] as any).node;
936
- assert.equal(node.$type, 'agent.task');
937
+ assert.equal(node.$type, 'test.task');
937
938
  assert.equal(node.prompt, 'test task');
938
939
  assert.equal(node.status, 'pending');
939
940
  });
@@ -1,15 +1,23 @@
1
- // treenity() — server factory
2
- // Builds the full pipeline from config, returns a composable server instance.
1
+ // treenity() — universal server factory
2
+ // Single entry point: loads infrastructure, mods, builds pipeline, wires logging.
3
+
4
+ import '#contexts/schema/index';
5
+ import '#contexts/text/index';
6
+ import '#schema/load';
7
+ import './mount-adapters';
3
8
 
4
9
  import { type ServiceHandle, startServices } from '#contexts/service/index';
5
- import { A, createNode, R, S, W } from '#core';
6
- import { loadLocalMods } from '#mod';
10
+ import { A, createNode, type NodeData, R, S, W } from '#core';
11
+ import { addOnLog, makeLogPath } from '#log';
12
+ import { loadAllMods } from '#mod';
7
13
  import { createMemoryTree, type Tree } from '#tree';
8
14
  import type { Server } from 'node:http';
9
- import { createEnsure, type Ensure, seed as defaultSeed } from './seed';
15
+ import { deploySeedPrefabs } from './prefab';
16
+ import { createEnsure, type Ensure } from './seed';
10
17
  import { createHttpServer, createPipeline, type Pipeline } from './server';
11
18
 
12
19
  export type TreenityConfig = {
20
+ rootNode?: NodeData;
13
21
  dataDir?: string;
14
22
  modsDir?: string | false;
15
23
  seed?: (store: Tree, ensure: Ensure) => Promise<void>;
@@ -36,35 +44,49 @@ export async function treenity(config?: TreenityConfig): Promise<TreenityServer>
36
44
 
37
45
  // 1. Load mods
38
46
  if (config?.modsDir !== false) {
39
- const modsDir = config?.modsDir ?? new URL('../mods', import.meta.url).pathname;
40
- const { loaded, failed } = await loadLocalMods(modsDir, 'server');
41
- if (failed.length) console.error('failed mods:', failed.map(f => `${f.name}: ${f.error.message}`).join(', '));
42
- if (loaded.length) console.log(`mods: ${loaded.join(', ')}`);
47
+ const extraDirs = config?.modsDir ? [config.modsDir] : [];
48
+ await loadAllMods('server', ...extraDirs);
43
49
  }
44
50
 
45
- // 2. Bootstrap: root node with overlay(base, work)
51
+ // 2. Bootstrap: root node
46
52
  const bootstrap = createMemoryTree();
47
- const rootNode = createNode('/', 'root', {}, {
48
- mount: { $type: 't.mount.overlay', layers: ['base', 'work'] },
49
- base: { $type: 't.mount.fs', root: dataDir + '/base' },
50
- work: { $type: 't.mount.fs', root: dataDir + '/work' },
51
- });
52
- rootNode.$acl = [
53
- { g: 'public', p: R },
54
- { g: 'authenticated', p: R | S },
55
- { g: 'admins', p: R | W | A | S },
56
- ];
53
+ let rootNode: NodeData;
54
+ if (config?.rootNode) {
55
+ rootNode = config.rootNode;
56
+ } else {
57
+ rootNode = createNode('/', 'root', {}, {
58
+ mount: { $type: 't.mount.overlay', layers: ['base', 'work'] },
59
+ base: { $type: 't.mount.fs', root: dataDir + '/base' },
60
+ work: { $type: 't.mount.fs', root: dataDir + '/work' },
61
+ });
62
+ rootNode.$acl = [
63
+ { g: 'authenticated', p: R | S },
64
+ { g: 'admins', p: R | W | A | S },
65
+ ];
66
+ }
57
67
  await bootstrap.set(rootNode);
58
68
 
59
69
  // 3. Build pipeline
60
70
  const pipeline = createPipeline(bootstrap);
61
71
  const { store, mountable } = pipeline;
62
72
 
63
- // 4. Seed
64
- const seedFn = config?.seed ?? defaultSeed;
65
- await seedFn(mountable, createEnsure(mountable));
73
+ // 4. Seed — if rootNode declares seeds, only deploy those mods
74
+ if (config?.seed) {
75
+ await config.seed(mountable, createEnsure(mountable));
76
+ } else {
77
+ const seedFilter = (rootNode as Record<string, unknown>).seeds as string[] | undefined;
78
+ await deploySeedPrefabs(mountable, seedFilter);
79
+ }
80
+
81
+ // 5. Wire log → tree
82
+ addOnLog(entry => {
83
+ const p = makeLogPath()
84
+ // process.stderr.write(`[log→tree] ${p} ${entry.level}: ${entry.msg.slice(0, 60)}\n`)
85
+ mountable.set({ $path: p, $type: 't.log', ...entry })
86
+ .catch(e => process.stderr.write(`[log write err] ${e.message}\n`))
87
+ })
66
88
 
67
- // 5. Autostart services
89
+ // 6. Autostart services
68
90
  let serviceHandle: ServiceHandle | null = null;
69
91
  if (autostart) {
70
92
  serviceHandle = await startServices(store, store.subscribe.bind(store) as import('#contexts/service/index').ServiceCtx['subscribe']);
@@ -1,56 +1,24 @@
1
1
  import 'dotenv/config';
2
- import { startServices } from '#contexts/service/index';
3
2
  import { type NodeData } from '#core';
4
- import { loadLocalMods } from '#mod';
5
- import { createMemoryTree } from '#tree';
6
- import './mount-adapters';
7
3
  import { readFile } from 'node:fs/promises';
8
4
  import { resolve } from 'node:path';
9
- import { seed } from './seed';
10
- import '#schema/load';
11
- import '#contexts/text/index';
12
- import '#contexts/schema/index';
13
- import { createTreenityServer } from './server';
5
+ import { treenity } from './factory';
14
6
 
15
7
  // Lock CWD — no library may change it
16
8
  process.chdir = () => { throw new Error('process.chdir is forbidden'); };
17
9
 
18
- // ── Root config ──
19
10
  const rootPath = resolve(process.argv[2] || 'root.json');
20
11
  const rootNode = JSON.parse(await readFile(rootPath, 'utf-8')) as NodeData;
21
12
 
22
- // Internal mods (core/src/mods/)
23
- const internalModsDir = new URL('../mods', import.meta.url).pathname;
24
- const internal = await loadLocalMods(internalModsDir, 'server');
25
-
26
- // External mods (root mods/)
27
- const externalModsDir = new URL('../../../mods', import.meta.url).pathname;
28
- const external = await loadLocalMods(externalModsDir, 'server');
29
-
30
- const allFailed = [...internal.failed, ...external.failed];
31
- if (allFailed.length) console.error('failed mods:', allFailed.map(f => `${f.name}: ${f.error.message}`).join(', '));
32
- console.log(`mods: ${[...internal.loaded, ...external.loaded].join(', ')}`);
33
-
13
+ const t = await treenity({ rootNode });
34
14
  const port = Number(process.env.PORT) || 3211;
35
-
36
- // ── Bootstrap from root.json ──
37
- const bootstrap = createMemoryTree();
38
- await bootstrap.set(rootNode);
39
-
40
- const { server, store, mountable } = createTreenityServer(bootstrap);
41
- await seed(mountable);
42
-
43
- // MCP now starts via autostart service (/sys/autostart/mcp → /sys/mcp)
44
- const serviceHandle = process.env.NO_SERVICES ? null
45
- : await startServices(store, store.subscribe.bind(store) as import('#contexts/service/index').ServiceCtx['subscribe']);
46
-
47
15
  const host = process.env.HOST || '127.0.0.1';
48
- server.listen(port, host, () => console.log(`treenity trpc ${host}:${port}`));
16
+ const server = await t.listen(port, { host });
49
17
 
50
18
  process.on('unhandledRejection', (err) => console.error('[UNHANDLED]', err));
51
19
 
52
20
  process.on('SIGTERM', async () => {
53
- await serviceHandle?.stop();
21
+ await t.stop();
54
22
  server.close();
55
23
  process.exit(0);
56
24
  });
@@ -12,7 +12,7 @@ type Migrations = Record<number, Migrator>;
12
12
  function getMigrations(type: string): { migrations: Migrations; version: number } | null {
13
13
  const handler = resolveExact(type, 'migrate');
14
14
  if (!handler) return null;
15
- const migrations = handler() as unknown as Migrations;
15
+ const migrations = handler() as Migrations;
16
16
  const keys = Object.keys(migrations).map(Number).sort((a, b) => a - b);
17
17
  if (!keys.length) return null;
18
18
  return { migrations, version: keys[keys.length - 1] };
@@ -22,21 +22,21 @@ function migrateNode(node: NodeData): NodeData {
22
22
  const m = getMigrations(node.$type);
23
23
  if (!m) return node;
24
24
 
25
- const nodeV = (node as any).$v as number ?? 0;
25
+ const nodeV = (node['$v'] as number) ?? 0;
26
26
  if (nodeV >= m.version) return node;
27
27
 
28
28
  const clone = structuredClone(node);
29
29
  for (const [v, fn] of Object.entries(m.migrations).sort(([a], [b]) => +a - +b)) {
30
30
  if (+v > nodeV) fn(clone as Record<string, unknown>);
31
31
  }
32
- (clone as any).$v = m.version;
32
+ clone['$v'] = m.version;
33
33
  return clone;
34
34
  }
35
35
 
36
36
  function stampVersion(node: NodeData): void {
37
37
  const m = getMigrations(node.$type);
38
38
  if (!m) return;
39
- (node as any).$v = m.version;
39
+ node['$v'] = m.version;
40
40
  }
41
41
 
42
42
  export function withMigration(store: Tree): Tree {
@@ -11,8 +11,6 @@
11
11
  // /sys/mods/{mod}/prefabs/{name}/ → prefab root (dir)
12
12
  // /sys/mods/{mod}/prefabs/{name}/{...} → prefab nodes
13
13
 
14
- import '#mods/treenity/mod-type';
15
-
16
14
  import { createNode, type NodeData } from '#core';
17
15
  import { getLoadedMods } from '#mod/loader';
18
16
  import { getModPrefabs, getPrefab, getRegisteredMods } from '#mod/prefab';
@@ -44,13 +44,17 @@ register('t.mount.query', 'mount', (config: NodeData, parentStore, ctx, globalSt
44
44
  register('t.mount.fs', 'mount', async (config: NodeData) => {
45
45
  const root = config['root'] as string | undefined;
46
46
  if (!root) throw new Error('t.mount.fs: root required');
47
- return createFsTree(root);
47
+ const tree = await createFsTree(root);
48
+ // shared: true — full tree paths (multiple mount points into one dir)
49
+ // default: dedicated — repath to local /
50
+ return config['shared'] ? tree : createRepathTree(tree, config.$path, '/');
48
51
  });
49
52
 
50
53
  register('t.mount.rawfs', 'mount', async (config: NodeData) => {
51
54
  const root = config['root'] as string | undefined;
52
55
  if (!root) throw new Error('t.mount.rawfs: root required');
53
- return createRawFsStore(root);
56
+ const tree = await createRawFsStore(root);
57
+ return config['shared'] ? tree : createRepathTree(tree, config.$path, '/');
54
58
  });
55
59
 
56
60
  // Federation: mount a remote Treenity instance's tree via tRPC.
@@ -72,8 +76,9 @@ register('t.mount.overlay', 'mount', async (config: NodeData, parentStore, ctx,
72
76
  if (!isComponent(comp)) throw new Error(`t.mount.overlay: component "${name}" not found`);
73
77
  const adapter = resolve(comp.$type, 'mount');
74
78
  if (!adapter) throw new Error(`No mount adapter for "${comp.$type}"`);
75
- // Overlay sub-adapters receive a component, not full node — they don't need $path
76
- stores.push(await adapter(comp as NodeData, stores[0] ?? ({} as Tree), ctx, globalStore));
79
+ // Propagate parent $path so sub-adapters can repath correctly
80
+ const subConfig = { ...comp, $path: config.$path } as NodeData;
81
+ stores.push(await adapter(subConfig, stores[0] ?? ({} as Tree), ctx, globalStore));
77
82
  }
78
83
  // First = lower (base), last = upper (writes go here)
79
84
  let result = stores[0];
@@ -1,9 +1,14 @@
1
1
  import { createNode, ref, register } from '#core';
2
2
  import { clearRegistry } from '#core/index.test';
3
3
  import { createMemoryTree, type Tree } from '#tree';
4
+ import { createFsTree } from '#tree/fs';
4
5
  import { createQueryTree } from '#tree/query';
6
+ import { createRepathTree } from '#tree/repath';
5
7
  import assert from 'node:assert/strict';
6
- import { beforeEach, describe, it } from 'node:test';
8
+ import { mkdtemp, readdir, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { afterEach, beforeEach, describe, it } from 'node:test';
7
12
  import { withMounts } from './mount';
8
13
  import { createTypesStore } from './types-mount';
9
14
 
@@ -370,9 +375,9 @@ describe('Types mount adapter', () => {
370
375
  register('test.block.text', 'schema', () => ({ label: 'Text', fields: {} }));
371
376
  const ts = createTypesStore(backingStore, '/types');
372
377
  const children = await ts.getChildren('/types');
373
- assert.equal(children.items.length, 1); // /types/test folder
374
- assert.equal(children.items[0].$path, '/types/test');
375
- assert.equal(children.items[0].$type, 't.dir');
378
+ const testFolder = children.items.find(n => n.$path === '/types/test');
379
+ assert.ok(testFolder, '/types/test folder should exist');
380
+ assert.equal(testFolder.$type, 't.dir');
376
381
  });
377
382
 
378
383
  it('getChildren returns type nodes in category', async () => {
@@ -404,7 +409,9 @@ describe('Types mount adapter', () => {
404
409
  await backingStore.set(createNode('/types/custom', 'dir'));
405
410
  const ts = createTypesStore(backingStore, '/types');
406
411
  const children = await ts.getChildren('/types');
407
- assert.equal(children.items.length, 2); // /types/test + /types/custom
412
+ const paths = children.items.map(n => n.$path);
413
+ assert.ok(paths.includes('/types/test'), '/types/test should exist');
414
+ assert.ok(paths.includes('/types/custom'), '/types/custom should exist');
408
415
  });
409
416
 
410
417
  it('registry wins on conflict', async () => {
@@ -495,3 +502,64 @@ describe('Types mount adapter', () => {
495
502
  ]);
496
503
  });
497
504
  });
505
+
506
+ // Regression: FS mount at nested path should not duplicate prefix in file paths
507
+ describe('FS mount repath (dedicated)', () => {
508
+ let rootStore: Tree;
509
+ let tmpDir: string;
510
+
511
+ beforeEach(async () => {
512
+ clearRegistry();
513
+ rootStore = createMemoryTree();
514
+ tmpDir = await mkdtemp(join(tmpdir(), 'treenity-fs-mount-'));
515
+ });
516
+
517
+ afterEach(async () => {
518
+ await rm(tmpDir, { recursive: true, force: true });
519
+ });
520
+
521
+ it('dedicated FS mount stores files without mount prefix', async () => {
522
+ const fsTree = await createFsTree(tmpDir);
523
+ const repathed = createRepathTree(fsTree, '/data/files', '/');
524
+
525
+ register('test.mount.fs', 'mount', () => repathed);
526
+ await rootStore.set(
527
+ createNode('/data/files', 'mount-point', {}, {
528
+ mount: { $type: 'test.mount.fs' },
529
+ }),
530
+ );
531
+
532
+ const ms = withMounts(rootStore);
533
+ await ms.set(createNode('/data/files/doc', 'document'));
534
+
535
+ // FS dir should contain doc.json, NOT data/files/doc.json
536
+ const entries = await readdir(tmpDir);
537
+ assert.ok(entries.includes('doc.json'), `expected doc.json in ${tmpDir}, got: ${entries}`);
538
+
539
+ // Read back through mount — path should be full tree path
540
+ const node = await ms.get('/data/files/doc');
541
+ assert.equal(node?.$path, '/data/files/doc');
542
+ assert.equal(node?.$type, 't.document');
543
+ });
544
+
545
+ it('getChildren returns full tree paths', async () => {
546
+ const fsTree = await createFsTree(tmpDir);
547
+ const repathed = createRepathTree(fsTree, '/data/files', '/');
548
+
549
+ register('test.mount.fs', 'mount', () => repathed);
550
+ await rootStore.set(
551
+ createNode('/data/files', 'mount-point', {}, {
552
+ mount: { $type: 'test.mount.fs' },
553
+ }),
554
+ );
555
+
556
+ const ms = withMounts(rootStore);
557
+ await ms.set(createNode('/data/files/a', 'item'));
558
+ await ms.set(createNode('/data/files/b', 'item'));
559
+
560
+ const children = await ms.getChildren('/data/files');
561
+ assert.equal(children.items.length, 2);
562
+ const paths = children.items.map(n => n.$path).sort();
563
+ assert.deepEqual(paths, ['/data/files/a', '/data/files/b']);
564
+ });
565
+ });
@@ -92,12 +92,13 @@ export async function deployByKey(
92
92
  return deployNodes(store, prefab, target, opts);
93
93
  }
94
94
 
95
- /** Deploy all seed prefabs. Respects TENANT env (only core-tier seeds). */
96
- export async function deploySeedPrefabs(store: Tree): Promise<void> {
95
+ /** Deploy seed prefabs. If filter provided, only deploy seeds whose mod is in the list. */
96
+ export async function deploySeedPrefabs(store: Tree, filter?: string[]): Promise<void> {
97
97
  const isTenant = !!process.env.TENANT;
98
98
  const seeds = getSeedPrefabs();
99
99
 
100
100
  for (const [mod, prefab] of seeds) {
101
+ if (filter && !filter.includes(mod)) continue;
101
102
  if (isTenant && prefab.meta?.tier !== 'core') continue;
102
103
  await deployNodes(store, prefab, '/', { allowAbsolute: true, params: { store } });
103
104
  }
@@ -0,0 +1,82 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createMemoryTree } from '#tree';
4
+ import { withRefIndex } from './refs';
5
+
6
+ describe('withRefIndex', () => {
7
+ it('extracts $ref fields into $refs', async () => {
8
+ const tree = withRefIndex(createMemoryTree());
9
+ await tree.set({
10
+ $path: '/order/1',
11
+ $type: 'cafe.order',
12
+ customer: { $type: 'ref', $ref: '/customers/ivan' },
13
+ items: [{ $type: 'ref', $ref: '/menu/latte' }],
14
+ });
15
+
16
+ const node = await tree.get('/order/1');
17
+ assert.ok(node?.$refs);
18
+ assert.equal(node.$refs.length, 2);
19
+ assert.ok(node.$refs.some(r => r.t === '/customers/ivan' && r.f === '#customer'));
20
+ assert.ok(node.$refs.some(r => r.t === '/menu/latte' && r.f === '#items.0'));
21
+ });
22
+
23
+ it('preserves standalone refs (no f:)', async () => {
24
+ const tree = withRefIndex(createMemoryTree());
25
+ await tree.set({
26
+ $path: '/factory',
27
+ $type: 'mfg.factory',
28
+ $refs: [{ t: '/suppliers/bob', d: { $type: 'supplies', since: '2025-01' } }],
29
+ });
30
+
31
+ const node = await tree.get('/factory');
32
+ assert.ok(node?.$refs);
33
+ assert.equal(node.$refs.length, 1);
34
+ assert.equal(node.$refs[0].t, '/suppliers/bob');
35
+ assert.equal(node.$refs[0].d?.$type, 'supplies');
36
+ assert.equal(node.$refs[0].f, undefined);
37
+ });
38
+
39
+ it('merges standalone + derived refs', async () => {
40
+ const tree = withRefIndex(createMemoryTree());
41
+ await tree.set({
42
+ $path: '/order/2',
43
+ $type: 'cafe.order',
44
+ customer: { $type: 'ref', $ref: '/customers/ivan' },
45
+ $refs: [{ t: '/promos/summer', d: { $type: 'applied-promo' } }],
46
+ });
47
+
48
+ const node = await tree.get('/order/2');
49
+ assert.ok(node?.$refs);
50
+ assert.equal(node.$refs.length, 2);
51
+ // standalone first, then derived
52
+ assert.equal(node.$refs[0].t, '/promos/summer');
53
+ assert.equal(node.$refs[1].t, '/customers/ivan');
54
+ });
55
+
56
+ it('returns undefined $refs when no refs exist', async () => {
57
+ const tree = withRefIndex(createMemoryTree());
58
+ await tree.set({ $path: '/plain', $type: 'dir' });
59
+
60
+ const node = await tree.get('/plain');
61
+ assert.equal(node?.$refs, undefined);
62
+ });
63
+
64
+ it('scans nested component refs', async () => {
65
+ const tree = withRefIndex(createMemoryTree());
66
+ await tree.set({
67
+ $path: '/node',
68
+ $type: 'test',
69
+ delivery: {
70
+ $type: 'logistics.delivery',
71
+ courier: { $type: 'ref', $ref: '/couriers/alex' },
72
+ warehouse: { $type: 'ref', $ref: '/warehouses/main' },
73
+ },
74
+ });
75
+
76
+ const node = await tree.get('/node');
77
+ assert.ok(node?.$refs);
78
+ assert.equal(node.$refs.length, 2);
79
+ assert.ok(node.$refs.some(r => r.t === '/couriers/alex' && r.f === '#delivery.courier'));
80
+ assert.ok(node.$refs.some(r => r.t === '/warehouses/main' && r.f === '#delivery.warehouse'));
81
+ });
82
+ });
@@ -0,0 +1,64 @@
1
+ // withRefIndex — store combinator that auto-populates $refs on set()
2
+ // Scans node fields for { $ref } entries, builds $refs array.
3
+ // Standalone refs (no f:) pass through untouched.
4
+
5
+ import { isRef, type NodeData, type RefEntry } from '#core';
6
+ import type { Tree } from '#tree';
7
+
8
+ /** Deep-scan node for $ref fields, return derived RefEntries */
9
+ function extractRefs(node: NodeData): RefEntry[] {
10
+ const refs: RefEntry[] = [];
11
+
12
+ function scan(obj: unknown, prefix: string) {
13
+ if (!obj || typeof obj !== 'object') return;
14
+ if (isRef(obj)) {
15
+ refs.push({ t: obj.$ref, f: prefix || undefined });
16
+ return;
17
+ }
18
+ if (Array.isArray(obj)) {
19
+ for (let i = 0; i < obj.length; i++) scan(obj[i], `${prefix}.${i}`);
20
+ return;
21
+ }
22
+ for (const [k, v] of Object.entries(obj)) {
23
+ if (k.startsWith('$')) continue;
24
+ const path = prefix ? `${prefix}.${k}` : `#${k}`;
25
+ scan(v, path);
26
+ }
27
+ }
28
+
29
+ for (const [k, v] of Object.entries(node)) {
30
+ if (k.startsWith('$')) continue;
31
+ scan(v, `#${k}`);
32
+ }
33
+
34
+ return refs;
35
+ }
36
+
37
+ /** Merge derived refs with standalone refs (those without f:) */
38
+ function buildRefs(node: NodeData): RefEntry[] | undefined {
39
+ const derived = extractRefs(node);
40
+ const standalone = node.$refs?.filter(r => !r.f) ?? [];
41
+ const merged = [...standalone, ...derived];
42
+ return merged.length ? merged : undefined;
43
+ }
44
+
45
+ export function withRefIndex(inner: Tree): Tree {
46
+ return {
47
+ ...inner,
48
+
49
+ async set(node, ctx) {
50
+ node.$refs = buildRefs(node);
51
+ return inner.set(node, ctx);
52
+ },
53
+
54
+ async patch(path, ops, ctx) {
55
+ await inner.patch(path, ops, ctx);
56
+ // Re-extract refs after patch
57
+ const updated = await inner.get(path, ctx);
58
+ if (updated) {
59
+ updated.$refs = buildRefs(updated);
60
+ await inner.set(updated, ctx);
61
+ }
62
+ },
63
+ };
64
+ }