Skip to content

Commit 2d65182

Browse files
committed
perf(data-inspector): lazy-load and inline jora on the node side
jora only loads on the first actual query now — engine/query-engine.ts caches a single `await import('jora')` + `setup()` instead of paying for it eagerly at module load, which used to happen just from registering the plugin's RPC functions, whether or not anyone opens the panel. The node tsdown build inlines jora (deps.alwaysBundle) so that lazy import resolves a chunk shipped inside dist instead of a node_modules lookup consumers would otherwise need to satisfy. The browser build keeps jora external/dependency-resolved, since it already loads eagerly there for query-editor syntax gating.
1 parent 2838098 commit 2d65182

4 files changed

Lines changed: 90 additions & 61 deletions

File tree

‎plugins/data-inspector/src/engine/query-engine.ts‎

Lines changed: 65 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@
99
* forms (`{ $type: 'Map', value }`), keeping queries portable;
1010
* - suggestions come from jora's stat mode, flattened into plain
1111
* RPC-safe completion items.
12+
*
13+
* jora itself loads lazily, on the first query: `import('jora')` only runs
14+
* once `runQuery`/`runQueryAtPath`/`suggest` are actually called, so simply
15+
* registering the data-inspector's RPC functions (which happens on every
16+
* host that sets it up, whether or not anyone opens the panel) never pays
17+
* for parsing jora. The node build inlines jora into its own output (see
18+
* `tsdown.config.ts`'s `deps.alwaysBundle`) so that on-demand `import()`
19+
* resolves a local chunk instead of a `node_modules` lookup.
1220
*/
21+
import type { Jora } from 'jora'
1322
import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
1423
import type { NormalizeOptions } from './normalize'
15-
import jora from 'jora'
1624
import { navigate, normalize } from './normalize'
1725

1826
export type { SuggestItem, SuggestOutcome } from './contract'
@@ -45,51 +53,62 @@ function isSetLike(v: unknown): v is Set<unknown> {
4553
&& typeof (v as Map<unknown, unknown>).get !== 'function'
4654
}
4755

48-
const createQuery = jora.setup({
49-
methods: {
50-
/** Map(-like or normalized tag) -> plain object (string-coerced keys). */
51-
fromMap: (v) => {
52-
if (isMapLike(v))
53-
return Object.fromEntries(v.entries())
54-
if (isMapTag(v))
55-
return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value]))
56-
return v
57-
},
58-
/** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */
59-
mapEntries: (v) => {
60-
if (isMapLike(v))
61-
return [...v.entries()].map(([key, value]) => ({ key, value }))
62-
if (isMapTag(v)) {
63-
if (v.entries)
64-
return v.entries
65-
return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value }))
66-
}
67-
return []
68-
},
69-
/** Set(-like or normalized tag) -> array. */
70-
fromSet: (v) => {
71-
if (isSetLike(v))
72-
return [...v]
73-
if (isSetTag(v))
74-
return v.values ?? []
75-
return v
76-
},
77-
/** Constructor name of any value. */
78-
typeOf: (v) => {
79-
if (v === null)
80-
return 'null'
81-
if (typeof v !== 'object')
82-
return typeof v
83-
return (v as object).constructor?.name ?? 'Object'
56+
type CreateQuery = ReturnType<Jora['setup']>
57+
58+
/**
59+
* jora loads on first use and is cached for the process lifetime — a single
60+
* `import('jora')` + `setup()`, however many queries follow.
61+
*/
62+
let createQueryPromise: Promise<CreateQuery> | undefined
63+
64+
function getCreateQuery(): Promise<CreateQuery> {
65+
return createQueryPromise ??= import('jora').then(({ default: jora }) => jora.setup({
66+
methods: {
67+
/** Map(-like or normalized tag) -> plain object (string-coerced keys). */
68+
fromMap: (v) => {
69+
if (isMapLike(v))
70+
return Object.fromEntries(v.entries())
71+
if (isMapTag(v))
72+
return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value]))
73+
return v
74+
},
75+
/** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */
76+
mapEntries: (v) => {
77+
if (isMapLike(v))
78+
return [...v.entries()].map(([key, value]) => ({ key, value }))
79+
if (isMapTag(v)) {
80+
if (v.entries)
81+
return v.entries
82+
return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value }))
83+
}
84+
return []
85+
},
86+
/** Set(-like or normalized tag) -> array. */
87+
fromSet: (v) => {
88+
if (isSetLike(v))
89+
return [...v]
90+
if (isSetTag(v))
91+
return v.values ?? []
92+
return v
93+
},
94+
/** Constructor name of any value. */
95+
typeOf: (v) => {
96+
if (v === null)
97+
return 'null'
98+
if (typeof v !== 'object')
99+
return typeof v
100+
return (v as object).constructor?.name ?? 'Object'
101+
},
102+
/** All own keys (incl. non-enumerable), as strings. */
103+
ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [],
84104
},
85-
/** All own keys (incl. non-enumerable), as strings. */
86-
ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [],
87-
},
88-
})
105+
}))
106+
}
89107

90-
export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome {
108+
export async function runQuery(target: unknown, query: string, options?: NormalizeOptions): Promise<QueryOutcome> {
91109
try {
92110
const started = performance.now()
111+
const createQuery = await getCreateQuery()
93112
const raw = createQuery(query)(target)
94113
const queryMs = Math.round((performance.now() - started) * 100) / 100
95114
const { data, stats } = normalize(raw, options)
@@ -110,9 +129,10 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti
110129
* 'depth'` marker the client is expanding, so the same filter options must be
111130
* threaded through (they shift array indices and drop keys).
112131
*/
113-
export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome {
132+
export async function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): Promise<QueryOutcome> {
114133
try {
115134
const started = performance.now()
135+
const createQuery = await getCreateQuery()
116136
const raw = createQuery(query)(target)
117137
const node = navigate(raw, path, options)
118138
const queryMs = Math.round((performance.now() - started) * 100) / 100
@@ -140,9 +160,10 @@ interface JoraStatEntry {
140160
* its candidates in a nested `suggestions` array — flattened here into plain,
141161
* RPC-safe completion items.
142162
*/
143-
export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome {
163+
export async function suggest(target: unknown, query: string, pos: number, limit = 30): Promise<SuggestOutcome> {
144164
try {
145165
const started = performance.now()
166+
const createQuery = await getCreateQuery()
146167
const statApi = createQuery(query, { tolerant: true, stat: true })(target) as {
147168
suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null
148169
}

‎plugins/data-inspector/test/engine.test.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -101,54 +101,54 @@ describe('depth truncation + lazy expand', () => {
101101
expect(navigate(g, [['k', 'map'], ['mv', 0]])).toBe('v')
102102
})
103103

104-
it('runQueryAtPath re-runs and returns a fresh slice of the subtree', () => {
105-
const out = runQueryAtPath(deep(), '$', [['k', 'level0'], ['k', 'level1']], { maxDepth: 3 })
104+
it('runQueryAtPath re-runs and returns a fresh slice of the subtree', async () => {
105+
const out = await runQueryAtPath(deep(), '$', [['k', 'level0'], ['k', 'level1']], { maxDepth: 3 })
106106
expect(out.ok).toBe(true)
107107
if (out.ok) {
108108
// The subtree normalizes from level2 with a fresh budget, reaching the leaf.
109109
expect(out.result).toMatchObject({ level2: { level3: { leaf: 'found' } } })
110110
}
111111
})
112112

113-
it('runQueryAtPath fails soft on a broken base query', () => {
114-
expect(runQueryAtPath(deep(), 'nope.method()', []).ok).toBe(false)
113+
it('runQueryAtPath fails soft on a broken base query', async () => {
114+
expect((await runQueryAtPath(deep(), 'nope.method()', [])).ok).toBe(false)
115115
})
116116
})
117117

118118
describe('runQuery (live)', () => {
119-
it('queries live Maps and Sets through the bridge methods', () => {
120-
const out = runQuery(liveGraph(), 'store.entries.mapEntries().key')
119+
it('queries live Maps and Sets through the bridge methods', async () => {
120+
const out = await runQuery(liveGraph(), 'store.entries.mapEntries().key')
121121
expect(out).toMatchObject({ ok: true, result: ['a', 'b'] })
122-
const set = runQuery(liveGraph(), 'tags.fromSet()')
122+
const set = await runQuery(liveGraph(), 'tags.fromSet()')
123123
expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] })
124124
})
125125

126-
it('reports payload size and timings', () => {
127-
const out = runQuery(liveGraph(), 'store.name')
126+
it('reports payload size and timings', async () => {
127+
const out = await runQuery(liveGraph(), 'store.name')
128128
expect(out.ok && out.stats.payloadBytes).toBeGreaterThan(0)
129129
})
130130

131-
it('fails soft with an error envelope', () => {
132-
const out = runQuery(liveGraph(), 'nope.method()')
131+
it('fails soft with an error envelope', async () => {
132+
const out = await runQuery(liveGraph(), 'nope.method()')
133133
expect(out.ok).toBe(false)
134134
})
135135
})
136136

137137
describe('runQuery (static portability)', () => {
138-
it('the same query works against the NORMALIZED form of the data', () => {
138+
it('the same query works against the NORMALIZED form of the data', async () => {
139139
const { data } = normalize(liveGraph())
140140
// `store.entries` is now a `{ $type: 'Map', value }` tag; the bridge
141141
// methods duck-type it so live-authored queries stay portable.
142-
const out = runQuery(data, 'store.entries.mapEntries().key')
142+
const out = await runQuery(data, 'store.entries.mapEntries().key')
143143
expect(out).toMatchObject({ ok: true, result: ['a', 'b'] })
144-
const set = runQuery(data, 'tags.fromSet()')
144+
const set = await runQuery(data, 'tags.fromSet()')
145145
expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] })
146146
})
147147
})
148148

149149
describe('suggest', () => {
150-
it('returns flattened, prefix-ranged completion items', () => {
151-
const out = suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4)
150+
it('returns flattened, prefix-ranged completion items', async () => {
151+
const out = await suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4)
152152
expect(out.ok).toBe(true)
153153
expect(out.suggestions.map(s => s.value)).toEqual(['bar', 'baz'])
154154
expect(out.suggestions[0]).toMatchObject({ from: 4, to: 4, current: '' })

‎plugins/data-inspector/test/registry.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ describe('example source', () => {
158158
const data = await resolveSourceData(getDataSource(entry.id)!)
159159
const { runQuery } = await import('../src/engine/query-engine')
160160
for (const recipe of entry.queries ?? []) {
161-
const out = runQuery(data, recipe.query.trim() || '$', recipe)
161+
const out = await runQuery(data, recipe.query.trim() || '$', recipe)
162162
expect(out.ok, `suggested query "${recipe.title}" must run`).toBe(true)
163163
}
164164
})

‎plugins/data-inspector/tsdown.config.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ export default defineConfig([
3535
tsconfig,
3636
dts: false,
3737
entry: serverEntries,
38+
// jora is loaded via a lazy `import('jora')` in `engine/query-engine.ts`
39+
// (only paid for on the first query), and inlined here so that lazy
40+
// import resolves a chunk shipped inside this package's own `dist`
41+
// instead of a `node_modules` lookup consumers would otherwise need to
42+
// satisfy just to load the RPC functions. The browser build (SPA,
43+
// `engine/index` client entry) keeps jora external/dependency-resolved,
44+
// since jora already loads eagerly there for query-editor syntax gating.
45+
deps: { alwaysBundle: ['jora'] },
3846
},
3947
// One dts graph PER entry: a single-entry graph can never split shared
4048
// chunks, so declarations always inline and the emitted .d.mts files are

0 commit comments

Comments
 (0)