Skip to content

Commit efce507

Browse files
antfubotantfu
andauthored
perf(data-inspector): lazy-load and inline jora on the node side (#250)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 1b4b9df commit efce507

10 files changed

Lines changed: 100 additions & 84 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,9 @@ jobs:
1414
uses: sxzz/workflows/.github/workflows/unit-test.yml@main
1515
with:
1616
build: pnpm run ci:build
17-
# The Build step above already produced a fresh dist/ for this exact
18-
# checkout, so skip `test`'s own `build && vitest` - running plain
19-
# vitest halves the number of full-monorepo `turbo run build` passes
20-
# per job, which is where the flaky Windows native-toolchain crash
21-
# (see scripts/ci-retry.ts) shows up.
2217
test: pnpm exec vitest
2318
lint: pnpm run lint && pnpm run knip
19+
build-for-lint: true
2420

2521
e2e:
2622
runs-on: ubuntu-latest

packages/devframe/src/rpc/wire-codec.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from './serialization'
66
* The per-connection `serialize`/`deserialize` pair for a live RPC wire.
77
*
88
* @internal
9-
* implementations; not part of the stable public API.
109
*/
1110
export interface RpcWireCodec {
1211
serialize: (msg: any) => string
@@ -25,7 +24,6 @@ const EMPTY_WIRE_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonS
2524
* don't collide across connections.
2625
*
2726
* @internal
28-
* implementations; not part of the stable public API.
2927
*/
3028
export function createRpcWireCodec(
3129
definitions: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>> = EMPTY_WIRE_DEFS,
@@ -72,7 +70,6 @@ export function createRpcWireCodec(
7270
* handed to birpc proper.
7371
*
7472
* @internal
75-
* implementations; not part of the stable public API.
7673
*/
7774
export function peekRpcWireFrame(raw: string): { t?: string, i?: string } {
7875
try {

plugins/data-inspector/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@
6262
}
6363
},
6464
"dependencies": {
65-
"cac": "catalog:deps",
66-
"jora": "catalog:deps"
65+
"cac": "catalog:deps"
6766
},
6867
"devDependencies": {
6968
"@antfu/design": "catalog:frontend",
@@ -79,6 +78,7 @@
7978
"devframe": "workspace:*",
8079
"dompurify": "catalog:frontend",
8180
"floating-vue": "catalog:frontend",
81+
"jora": "catalog:inlined",
8282
"reka-ui": "catalog:frontend",
8383
"splitpanes": "catalog:frontend",
8484
"storybook": "catalog:storybook",

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

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@
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. jora is a `devDependency` (`catalog:inlined` in the
18+
* workspace catalog) rather than a regular `dependency`, so tsdown vendors
19+
* it straight into this package's own `dist` on both the node and browser
20+
* builds — the on-demand `import()` resolves a local chunk, and neither
21+
* side needs consumers to install jora themselves.
1222
*/
23+
import type { Jora } from 'jora'
1324
import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
1425
import type { NormalizeOptions } from './normalize'
15-
import jora from 'jora'
1626
import { navigate, normalize } from './normalize'
1727

1828
export type { SuggestItem, SuggestOutcome } from './contract'
@@ -45,51 +55,62 @@ function isSetLike(v: unknown): v is Set<unknown> {
4555
&& typeof (v as Map<unknown, unknown>).get !== 'function'
4656
}
4757

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

90-
export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome {
110+
export async function runQuery(target: unknown, query: string, options?: NormalizeOptions): Promise<QueryOutcome> {
91111
try {
92112
const started = performance.now()
113+
const createQuery = await getCreateQuery()
93114
const raw = createQuery(query)(target)
94115
const queryMs = Math.round((performance.now() - started) * 100) / 100
95116
const { data, stats } = normalize(raw, options)
@@ -110,9 +131,10 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti
110131
* 'depth'` marker the client is expanding, so the same filter options must be
111132
* threaded through (they shift array indices and drop keys).
112133
*/
113-
export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome {
134+
export async function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): Promise<QueryOutcome> {
114135
try {
115136
const started = performance.now()
137+
const createQuery = await getCreateQuery()
116138
const raw = createQuery(query)(target)
117139
const node = navigate(raw, path, options)
118140
const queryMs = Math.round((performance.now() - started) * 100) / 100
@@ -140,9 +162,10 @@ interface JoraStatEntry {
140162
* its candidates in a nested `suggestions` array — flattened here into plain,
141163
* RPC-safe completion items.
142164
*/
143-
export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome {
165+
export async function suggest(target: unknown, query: string, pos: number, limit = 30): Promise<SuggestOutcome> {
144166
try {
145167
const started = performance.now()
168+
const createQuery = await getCreateQuery()
146169
const statApi = createQuery(query, { tolerant: true, stat: true })(target) as {
147170
suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null
148171
}

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
})

pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ catalogs:
8989
hono: ^4.13.1
9090
image-meta: ^0.2.2
9191
immer: ^11.1.16
92-
jora: ^1.0.0-beta.16
9392
launch-editor: ^2.14.1
9493
mrmime: ^2.0.1
9594
nitro: ^3.0.260610-beta
@@ -152,6 +151,7 @@ catalogs:
152151
vue: ^3.5.41
153152
inlined:
154153
'@antfu/utils': ^9.3.0
154+
jora: ^1.0.0-beta.16
155155
ua-parser-modern: ^0.1.1
156156
storybook:
157157
'@storybook/addon-a11y': *storybook

tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,12 @@ export declare function normalize(_: unknown, _?: NormalizeOptions): {
148148
data: unknown;
149149
stats: NormalizeStats;
150150
};
151-
export declare function runQuery(_: unknown, _: string, _?: NormalizeOptions): QueryOutcome;
152-
export declare function runQueryAtPath(_: unknown, _: string, _: NodePath, _?: NormalizeOptions): QueryOutcome;
151+
export declare function runQuery(_: unknown, _: string, _?: NormalizeOptions): Promise<QueryOutcome>;
152+
export declare function runQueryAtPath(_: unknown, _: string, _: NodePath, _?: NormalizeOptions): Promise<QueryOutcome>;
153153
export declare function skeletonOf(_: unknown, _?: SkeletonOptions): {
154154
skeleton: unknown;
155155
nodes: number;
156156
ms: number;
157157
};
158-
export declare function suggest(_: unknown, _: string, _: number, _?: number): SuggestOutcome;
158+
export declare function suggest(_: unknown, _: string, _: number, _?: number): Promise<SuggestOutcome>;
159159
// #endregion

tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ export function applyWrite(_, _, _) {}
66
export function isExcludedKey(_, _) {}
77
export function navigate(_, _, _) {}
88
export function normalize(_, _) {}
9-
export function runQuery(_, _, _) {}
10-
export function runQueryAtPath(_, _, _, _) {}
9+
export async function runQuery(_, _, _) {}
10+
export async function runQueryAtPath(_, _, _, _) {}
1111
export function skeletonOf(_, _) {}
12-
export function suggest(_, _, _, _) {}
12+
export async function suggest(_, _, _, _) {}
1313
// #endregion

0 commit comments

Comments
 (0)