@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.
- package/README.md +78 -0
- package/dist/chain.d.ts +3 -4
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/client/trpc.d.ts.map +1 -1
- package/dist/client/trpc.js +1 -0
- package/dist/client/trpc.js.map +1 -1
- package/dist/comp/index.d.ts +3 -4
- package/dist/comp/index.d.ts.map +1 -1
- package/dist/comp/index.js +5 -4
- package/dist/comp/index.js.map +1 -1
- package/dist/comp/needs.d.ts.map +1 -1
- package/dist/comp/needs.js +3 -3
- package/dist/comp/needs.js.map +1 -1
- package/dist/core/component.d.ts +10 -8
- package/dist/core/component.d.ts.map +1 -1
- package/dist/core/component.js +4 -8
- package/dist/core/component.js.map +1 -1
- package/dist/core/context.d.ts +2 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/path.d.ts.map +1 -1
- package/dist/core/path.js +7 -3
- package/dist/core/path.js.map +1 -1
- package/dist/core/registry.d.ts +2 -1
- package/dist/core/registry.d.ts.map +1 -1
- package/dist/core/registry.js.map +1 -1
- package/dist/core.d.ts +1 -1
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +30 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +119 -0
- package/dist/log.js.map +1 -1
- package/dist/mod/examples/ticker/service.js +2 -3
- package/dist/mod/examples/ticker/service.js.map +1 -1
- package/dist/mod/index.d.ts +1 -1
- package/dist/mod/index.d.ts.map +1 -1
- package/dist/mod/index.js +1 -1
- package/dist/mod/index.js.map +1 -1
- package/dist/mod/loader.d.ts +1 -0
- package/dist/mod/loader.d.ts.map +1 -1
- package/dist/mod/loader.js +27 -1
- package/dist/mod/loader.js.map +1 -1
- package/dist/mods/clients.d.ts +2 -0
- package/dist/mods/clients.d.ts.map +1 -0
- package/dist/mods/clients.js +3 -0
- package/dist/mods/clients.js.map +1 -0
- package/dist/mods/llm/index.js +1 -1
- package/dist/mods/llm/index.js.map +1 -1
- package/dist/mods/mcp/server.d.ts +0 -1
- package/dist/mods/mcp/server.js +0 -1
- package/dist/mods/mcp/service.d.ts +0 -1
- package/dist/mods/mcp/service.js +0 -1
- package/dist/mods/mcp/types.d.ts +0 -1
- package/dist/mods/mcp/types.js +0 -1
- package/dist/mods/servers.d.ts +4 -0
- package/dist/mods/servers.d.ts.map +1 -0
- package/dist/mods/servers.js +5 -0
- package/dist/mods/servers.js.map +1 -0
- package/dist/mods/treenity/builtins.d.ts +2 -0
- package/dist/mods/treenity/builtins.d.ts.map +1 -0
- package/dist/mods/treenity/builtins.js +18 -0
- package/dist/mods/treenity/builtins.js.map +1 -0
- package/dist/mods/treenity/logs.d.ts +18 -0
- package/dist/mods/treenity/logs.d.ts.map +1 -0
- package/dist/mods/treenity/logs.js +17 -0
- package/dist/mods/treenity/logs.js.map +1 -0
- package/dist/mods/treenity/seed.js +29 -27
- package/dist/mods/treenity/seed.js.map +1 -1
- package/dist/mods/treenity/server.d.ts +2 -0
- package/dist/mods/treenity/server.d.ts.map +1 -1
- package/dist/mods/treenity/server.js +2 -0
- package/dist/mods/treenity/server.js.map +1 -1
- package/dist/mods/uix/client.js +4 -4
- package/dist/mods/uix/client.js.map +1 -1
- package/dist/mods/uix/compile.d.ts.map +1 -1
- package/dist/mods/uix/compile.js +4 -2
- package/dist/mods/uix/compile.js.map +1 -1
- package/dist/schema/_test-fixture.d.ts +11 -0
- package/dist/schema/_test-fixture.d.ts.map +1 -0
- package/dist/schema/_test-fixture.js +8 -0
- package/dist/schema/_test-fixture.js.map +1 -0
- package/dist/schema/types.d.ts +1 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/server/actions.js +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +4 -3
- package/dist/server/auth.js.map +1 -1
- package/dist/server/client.d.ts +10 -3
- package/dist/server/client.d.ts.map +1 -1
- package/dist/server/client.js +1 -1
- package/dist/server/client.js.map +1 -1
- package/dist/server/doc-index.d.ts.map +1 -1
- package/dist/server/doc-index.js +13 -12
- package/dist/server/doc-index.js.map +1 -1
- package/dist/server/factory.d.ts +6 -0
- package/dist/server/factory.d.ts.map +1 -1
- package/dist/server/factory.js +44 -25
- package/dist/server/factory.js.map +1 -1
- package/dist/server/main.d.ts +0 -4
- package/dist/server/main.d.ts.map +1 -1
- package/dist/server/main.js +4 -30
- package/dist/server/main.js.map +1 -1
- package/dist/server/mcp.d.ts +0 -1
- package/dist/server/mcp.js +0 -1
- package/dist/server/migrate.js +3 -3
- package/dist/server/migrate.js.map +1 -1
- package/dist/server/mods-mount.d.ts +0 -1
- package/dist/server/mods-mount.d.ts.map +1 -1
- package/dist/server/mods-mount.js +0 -1
- package/dist/server/mods-mount.js.map +1 -1
- package/dist/server/mount-adapters.js +9 -4
- package/dist/server/mount-adapters.js.map +1 -1
- package/dist/server/prefab.d.ts +2 -2
- package/dist/server/prefab.d.ts.map +1 -1
- package/dist/server/prefab.js +4 -2
- package/dist/server/prefab.js.map +1 -1
- package/dist/server/refs.d.ts +3 -0
- package/dist/server/refs.d.ts.map +1 -0
- package/dist/server/refs.js +59 -0
- package/dist/server/refs.js.map +1 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +13 -3
- package/dist/server/server.js.map +1 -1
- package/dist/server/sub.js.map +1 -1
- package/dist/server/trpc.d.ts +7 -0
- package/dist/server/trpc.d.ts.map +1 -1
- package/dist/server/trpc.js +6 -2
- package/dist/server/trpc.js.map +1 -1
- package/dist/tree/fs.d.ts.map +1 -1
- package/dist/tree/fs.js +20 -18
- package/dist/tree/fs.js.map +1 -1
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +2 -1
- package/dist/tree/index.js.map +1 -1
- package/dist/tree-chain.d.ts +2 -1
- package/dist/tree-chain.d.ts.map +1 -1
- package/dist/tree-chain.js +5 -3
- package/dist/tree-chain.js.map +1 -1
- package/dist/uri.d.ts.map +1 -1
- package/dist/uri.js +8 -3
- package/dist/uri.js.map +1 -1
- package/package.json +48 -6
- package/src/chain.ts +7 -5
- package/src/client/trpc.ts +1 -0
- package/src/comp/index.test.ts +45 -9
- package/src/comp/index.ts +19 -8
- package/src/comp/needs.ts +3 -3
- package/src/core/component.ts +16 -14
- package/src/core/context.ts +4 -4
- package/src/core/index.test.ts +22 -1
- package/src/core/path.ts +6 -3
- package/src/core/registry.ts +4 -7
- package/src/core.ts +1 -1
- package/src/index.ts +1 -0
- package/src/log.ts +172 -1
- package/src/mod/docs/07-realtime.md +19 -11
- package/src/mod/docs/08-services.md +10 -8
- package/src/mod/docs/10-acl.md +1 -1
- package/src/mod/docs/12-conventions.md +43 -0
- package/src/mod/docs/13-example.md +36 -26
- package/src/mod/docs/14-mod-format.md +81 -8
- package/src/mod/examples/ticker/service.ts +2 -3
- package/src/mod/index.ts +1 -1
- package/src/mod/loader.test.ts +53 -1
- package/src/mod/loader.ts +34 -1
- package/src/mods/clients.ts +2 -0
- package/src/mods/llm/index.ts +1 -1
- package/src/mods/servers.ts +4 -0
- package/src/mods/treenity/builtins.ts +21 -0
- package/src/mods/treenity/logs.ts +26 -0
- package/src/mods/treenity/seed.ts +30 -28
- package/src/mods/treenity/server.ts +2 -0
- package/src/mods/uix/client.ts +4 -4
- package/src/mods/uix/compile.ts +4 -2
- package/src/schema/_test-fixture.ts +12 -0
- package/src/schema/generated/ai.agent.json +133 -0
- package/src/schema/generated/ai.approval.json +105 -0
- package/src/schema/generated/ai.approvals.json +24 -0
- package/src/schema/generated/ai.assignment.json +28 -0
- package/src/schema/generated/ai.plan.json +84 -0
- package/src/schema/generated/ai.policy.json +105 -0
- package/src/schema/generated/ai.pool.json +37 -0
- package/src/schema/generated/ai.thread.json +64 -0
- package/src/schema/generated/board.kanban.json +7 -0
- package/src/schema/generated/canary.item.json +40 -0
- package/src/schema/generated/claude-search.json +20 -0
- package/src/schema/generated/craft.product.json +47 -0
- package/src/schema/generated/craft.shop.json +94 -0
- package/src/schema/generated/craft.subscription.json +27 -0
- package/src/schema/generated/examples.demo.sensor.reading.json +25 -0
- package/src/schema/generated/flow.node.action.json +61 -0
- package/src/schema/generated/flow.node.code.json +43 -0
- package/src/schema/generated/flow.node.condition.json +37 -0
- package/src/schema/generated/flow.node.end.json +35 -0
- package/src/schema/generated/flow.node.http.json +65 -0
- package/src/schema/generated/flow.node.llm.json +67 -0
- package/src/schema/generated/flow.node.loop.json +49 -0
- package/src/schema/generated/flow.node.start.json +39 -0
- package/src/schema/generated/flow.scenario.json +118 -0
- package/src/schema/generated/grove.attempt.json +199 -0
- package/src/schema/generated/grove.path.json +93 -0
- package/src/schema/generated/grove.review.json +27 -0
- package/src/schema/generated/grove.task.json +164 -0
- package/src/schema/generated/intel.scenario.json +58 -0
- package/src/schema/generated/intel.signal.json +113 -0
- package/src/schema/generated/intel.world.json +15 -0
- package/src/schema/generated/landing.block.json +201 -0
- package/src/schema/generated/landing.page.json +84 -0
- package/src/schema/generated/metatron.config.json +119 -0
- package/src/schema/generated/metatron.permission.json +36 -0
- package/src/schema/generated/metatron.skill.json +36 -0
- package/src/schema/generated/metatron.task.json +114 -0
- package/src/schema/generated/metatron.template.json +26 -0
- package/src/schema/generated/metatron.workspace.json +60 -0
- package/src/schema/generated/order.status.json +21 -0
- package/src/schema/generated/polyhope.backtest.json +161 -0
- package/src/schema/generated/polyhope.feed.json +33 -0
- package/src/schema/generated/polyhope.run.json +94 -0
- package/src/schema/generated/polyhope.strategy.json +152 -0
- package/src/schema/generated/polymax.activity.json +65 -0
- package/src/schema/generated/polymax.aggr-feed.json +28 -0
- package/src/schema/generated/polymax.aggr.json +20 -0
- package/src/schema/generated/polymax.alert.json +56 -0
- package/src/schema/generated/polymax.bot-config.json +14 -0
- package/src/schema/generated/polymax.bot-status.json +35 -0
- package/src/schema/generated/polymax.classification.json +55 -0
- package/src/schema/generated/polymax.deposits.json +45 -0
- package/src/schema/generated/polymax.holding.json +55 -0
- package/src/schema/generated/polymax.identity.json +71 -0
- package/src/schema/generated/polymax.lb-entry.json +75 -0
- package/src/schema/generated/polymax.leaderboard.json +37 -0
- package/src/schema/generated/polymax.market-ref.json +25 -0
- package/src/schema/generated/polymax.performance.json +65 -0
- package/src/schema/generated/polymax.profile.json +16 -0
- package/src/schema/generated/polymax.scan-result.json +50 -0
- package/src/schema/generated/polymax.status.json +40 -0
- package/src/schema/generated/polymax.tags.json +53 -0
- package/src/schema/generated/polymax.trader.json +16 -0
- package/src/schema/generated/polymax.wallet-market.json +50 -0
- package/src/schema/generated/polymax.wallet-pnl.json +35 -0
- package/src/schema/generated/pult.concept.json +53 -0
- package/src/schema/generated/pult.config.json +227 -0
- package/src/schema/generated/pult.connector.json +72 -0
- package/src/schema/generated/pult.market.json +113 -0
- package/src/schema/generated/pult.order.json +113 -0
- package/src/schema/generated/pult.rete.json +68 -0
- package/src/schema/generated/pult.sensor.json +74 -0
- package/src/schema/generated/pult.signal.json +54 -0
- package/src/schema/generated/pult.synapse.json +93 -0
- package/src/schema/generated/pult.trade.json +93 -0
- package/src/schema/generated/resim.config.json +34 -0
- package/src/schema/generated/resim.function.json +62 -0
- package/src/schema/generated/resim.goal.json +22 -0
- package/src/schema/generated/resim.resource.json +40 -0
- package/src/schema/generated/resim.state.json +48 -0
- package/src/schema/generated/resim.world.json +40 -0
- package/src/schema/generated/saveme.action.save.json +29 -0
- package/src/schema/generated/saveme.message.json +36 -0
- package/src/schema/generated/saveme.router.json +31 -0
- package/src/schema/generated/t.coolify.json +50 -0
- package/src/schema/generated/t.event.json +46 -0
- package/src/schema/generated/t.logs.json +155 -0
- package/src/schema/generated/t.note.json +31 -0
- package/src/schema/generated/t.person.json +36 -0
- package/src/schema/generated/t.tenant.json +57 -0
- package/src/schema/generated/t.tenant.status.json +42 -0
- package/src/schema/generated/tagger.config.json +115 -0
- package/src/schema/generated/tagger.result.json +57 -0
- package/src/schema/generated/tagger.tree.json +36 -0
- package/src/schema/generated/ui.table.json +46 -0
- package/src/schema/types.ts +1 -0
- package/src/server/actions.test.ts +1 -1
- package/src/server/actions.ts +1 -1
- package/src/server/api.test.ts +9 -0
- package/src/server/auth.ts +4 -3
- package/src/server/client.ts +1 -1
- package/src/server/coverage.test.ts +1 -1
- package/src/server/doc-index.ts +13 -12
- package/src/server/e2e.test.ts +4 -3
- package/src/server/factory.ts +46 -24
- package/src/server/main.ts +4 -36
- package/src/server/migrate.ts +4 -4
- package/src/server/mods-mount.ts +0 -2
- package/src/server/mount-adapters.ts +9 -4
- package/src/server/mount.test.ts +73 -5
- package/src/server/prefab.ts +3 -2
- package/src/server/refs.test.ts +82 -0
- package/src/server/refs.ts +64 -0
- package/src/server/server.ts +14 -3
- package/src/server/sub.ts +2 -2
- package/src/server/trpc.ts +9 -3
- package/src/tree/fs.ts +21 -15
- package/src/tree/index.test.ts +26 -0
- package/src/tree/index.ts +2 -1
- package/src/tree-chain.test.ts +37 -44
- package/src/tree-chain.ts +11 -5
- package/src/uri.test.ts +32 -5
- package/src/uri.ts +4 -2
- package/dist/mods/mcp/server.d.ts.map +0 -1
- package/dist/mods/mcp/server.js.map +0 -1
- package/dist/mods/mcp/service.d.ts.map +0 -1
- package/dist/mods/mcp/service.js.map +0 -1
- package/dist/mods/mcp/types.d.ts.map +0 -1
- package/dist/mods/mcp/types.js.map +0 -1
- package/dist/schema/test-opaque.d.ts +0 -3
- package/dist/schema/test-opaque.d.ts.map +0 -1
- package/dist/schema/test-opaque.js +0 -43
- package/dist/schema/test-opaque.js.map +0 -1
- package/dist/server/mcp.d.ts.map +0 -1
- package/dist/server/mcp.js.map +0 -1
- package/src/mods/mcp/CLAUDE.md +0 -6
- package/src/mods/mcp/server.ts +0 -2
- package/src/mods/mcp/service.ts +0 -19
- package/src/mods/mcp/types.ts +0 -7
- package/src/schema/test-opaque.ts +0 -42
- package/src/server/mcp.ts +0 -326
package/src/server/actions.ts
CHANGED
|
@@ -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(
|
|
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);
|
package/src/server/api.test.ts
CHANGED
|
@@ -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 ──
|
package/src/server/auth.ts
CHANGED
|
@@ -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
|
|
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
|
|
278
|
-
if (groups?.list) claims.push(...groups
|
|
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
|
}
|
package/src/server/client.ts
CHANGED
|
@@ -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
|
});
|
package/src/server/doc-index.ts
CHANGED
|
@@ -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/
|
|
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,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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/
|
|
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
|
}
|
package/src/server/e2e.test.ts
CHANGED
|
@@ -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: '
|
|
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, '
|
|
937
|
+
assert.equal(node.$type, 'test.task');
|
|
937
938
|
assert.equal(node.prompt, 'test task');
|
|
938
939
|
assert.equal(node.status, 'pending');
|
|
939
940
|
});
|
package/src/server/factory.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
// treenity() — server factory
|
|
2
|
-
//
|
|
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 {
|
|
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 {
|
|
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
|
|
40
|
-
|
|
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
|
|
51
|
+
// 2. Bootstrap: root node
|
|
46
52
|
const bootstrap = createMemoryTree();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
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']);
|
package/src/server/main.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
+
await t.stop();
|
|
54
22
|
server.close();
|
|
55
23
|
process.exit(0);
|
|
56
24
|
});
|
package/src/server/migrate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
node['$v'] = m.version;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function withMigration(store: Tree): Tree {
|
package/src/server/mods-mount.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
76
|
-
|
|
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];
|
package/src/server/mount.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
374
|
-
assert.
|
|
375
|
-
assert.equal(
|
|
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
|
-
|
|
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
|
+
});
|
package/src/server/prefab.ts
CHANGED
|
@@ -92,12 +92,13 @@ export async function deployByKey(
|
|
|
92
92
|
return deployNodes(store, prefab, target, opts);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** Deploy
|
|
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
|
+
}
|