diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8788dd9e..49c891e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,9 @@ jobs: uses: sxzz/workflows/.github/workflows/unit-test.yml@main with: build: pnpm run ci:build - # The Build step above already produced a fresh dist/ for this exact - # checkout, so skip `test`'s own `build && vitest` - running plain - # vitest halves the number of full-monorepo `turbo run build` passes - # per job, which is where the flaky Windows native-toolchain crash - # (see scripts/ci-retry.ts) shows up. test: pnpm exec vitest lint: pnpm run lint && pnpm run knip + build-for-lint: true e2e: runs-on: ubuntu-latest diff --git a/packages/devframe/src/rpc/wire-codec.ts b/packages/devframe/src/rpc/wire-codec.ts index 5d3eca59..2f6f1b04 100644 --- a/packages/devframe/src/rpc/wire-codec.ts +++ b/packages/devframe/src/rpc/wire-codec.ts @@ -6,7 +6,6 @@ import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from './serialization' * The per-connection `serialize`/`deserialize` pair for a live RPC wire. * * @internal - * implementations; not part of the stable public API. */ export interface RpcWireCodec { serialize: (msg: any) => string @@ -25,7 +24,6 @@ const EMPTY_WIRE_DEFS: ReadonlyMap> = EMPTY_WIRE_DEFS, @@ -72,7 +70,6 @@ export function createRpcWireCodec( * handed to birpc proper. * * @internal - * implementations; not part of the stable public API. */ export function peekRpcWireFrame(raw: string): { t?: string, i?: string } { try { diff --git a/plugins/data-inspector/package.json b/plugins/data-inspector/package.json index 76d1116d..f7a2e59a 100644 --- a/plugins/data-inspector/package.json +++ b/plugins/data-inspector/package.json @@ -62,8 +62,7 @@ } }, "dependencies": { - "cac": "catalog:deps", - "jora": "catalog:deps" + "cac": "catalog:deps" }, "devDependencies": { "@antfu/design": "catalog:frontend", @@ -79,6 +78,7 @@ "devframe": "workspace:*", "dompurify": "catalog:frontend", "floating-vue": "catalog:frontend", + "jora": "catalog:inlined", "reka-ui": "catalog:frontend", "splitpanes": "catalog:frontend", "storybook": "catalog:storybook", diff --git a/plugins/data-inspector/src/engine/query-engine.ts b/plugins/data-inspector/src/engine/query-engine.ts index 09d55204..c67a9964 100644 --- a/plugins/data-inspector/src/engine/query-engine.ts +++ b/plugins/data-inspector/src/engine/query-engine.ts @@ -9,10 +9,20 @@ * forms (`{ $type: 'Map', value }`), keeping queries portable; * - suggestions come from jora's stat mode, flattened into plain * RPC-safe completion items. + * + * jora itself loads lazily, on the first query: `import('jora')` only runs + * once `runQuery`/`runQueryAtPath`/`suggest` are actually called, so simply + * registering the data-inspector's RPC functions (which happens on every + * host that sets it up, whether or not anyone opens the panel) never pays + * for parsing jora. jora is a `devDependency` (`catalog:inlined` in the + * workspace catalog) rather than a regular `dependency`, so tsdown vendors + * it straight into this package's own `dist` on both the node and browser + * builds — the on-demand `import()` resolves a local chunk, and neither + * side needs consumers to install jora themselves. */ +import type { Jora } from 'jora' import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract' import type { NormalizeOptions } from './normalize' -import jora from 'jora' import { navigate, normalize } from './normalize' export type { SuggestItem, SuggestOutcome } from './contract' @@ -45,51 +55,62 @@ function isSetLike(v: unknown): v is Set { && typeof (v as Map).get !== 'function' } -const createQuery = jora.setup({ - methods: { - /** Map(-like or normalized tag) -> plain object (string-coerced keys). */ - fromMap: (v) => { - if (isMapLike(v)) - return Object.fromEntries(v.entries()) - if (isMapTag(v)) - return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value])) - return v - }, - /** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */ - mapEntries: (v) => { - if (isMapLike(v)) - return [...v.entries()].map(([key, value]) => ({ key, value })) - if (isMapTag(v)) { - if (v.entries) - return v.entries - return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value })) - } - return [] - }, - /** Set(-like or normalized tag) -> array. */ - fromSet: (v) => { - if (isSetLike(v)) - return [...v] - if (isSetTag(v)) - return v.values ?? [] - return v - }, - /** Constructor name of any value. */ - typeOf: (v) => { - if (v === null) - return 'null' - if (typeof v !== 'object') - return typeof v - return (v as object).constructor?.name ?? 'Object' +type CreateQuery = ReturnType + +/** + * jora loads on first use and is cached for the process lifetime — a single + * `import('jora')` + `setup()`, however many queries follow. + */ +let createQueryPromise: Promise | undefined + +function getCreateQuery(): Promise { + return createQueryPromise ??= import('jora').then(({ default: jora }) => jora.setup({ + methods: { + /** Map(-like or normalized tag) -> plain object (string-coerced keys). */ + fromMap: (v) => { + if (isMapLike(v)) + return Object.fromEntries(v.entries()) + if (isMapTag(v)) + return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value])) + return v + }, + /** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */ + mapEntries: (v) => { + if (isMapLike(v)) + return [...v.entries()].map(([key, value]) => ({ key, value })) + if (isMapTag(v)) { + if (v.entries) + return v.entries + return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value })) + } + return [] + }, + /** Set(-like or normalized tag) -> array. */ + fromSet: (v) => { + if (isSetLike(v)) + return [...v] + if (isSetTag(v)) + return v.values ?? [] + return v + }, + /** Constructor name of any value. */ + typeOf: (v) => { + if (v === null) + return 'null' + if (typeof v !== 'object') + return typeof v + return (v as object).constructor?.name ?? 'Object' + }, + /** All own keys (incl. non-enumerable), as strings. */ + ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [], }, - /** All own keys (incl. non-enumerable), as strings. */ - ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [], - }, -}) + })) +} -export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome { +export async function runQuery(target: unknown, query: string, options?: NormalizeOptions): Promise { try { const started = performance.now() + const createQuery = await getCreateQuery() const raw = createQuery(query)(target) const queryMs = Math.round((performance.now() - started) * 100) / 100 const { data, stats } = normalize(raw, options) @@ -110,9 +131,10 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti * 'depth'` marker the client is expanding, so the same filter options must be * threaded through (they shift array indices and drop keys). */ -export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome { +export async function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): Promise { try { const started = performance.now() + const createQuery = await getCreateQuery() const raw = createQuery(query)(target) const node = navigate(raw, path, options) const queryMs = Math.round((performance.now() - started) * 100) / 100 @@ -140,9 +162,10 @@ interface JoraStatEntry { * its candidates in a nested `suggestions` array — flattened here into plain, * RPC-safe completion items. */ -export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome { +export async function suggest(target: unknown, query: string, pos: number, limit = 30): Promise { try { const started = performance.now() + const createQuery = await getCreateQuery() const statApi = createQuery(query, { tolerant: true, stat: true })(target) as { suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null } diff --git a/plugins/data-inspector/test/engine.test.ts b/plugins/data-inspector/test/engine.test.ts index 0602c576..deb23069 100644 --- a/plugins/data-inspector/test/engine.test.ts +++ b/plugins/data-inspector/test/engine.test.ts @@ -101,8 +101,8 @@ describe('depth truncation + lazy expand', () => { expect(navigate(g, [['k', 'map'], ['mv', 0]])).toBe('v') }) - it('runQueryAtPath re-runs and returns a fresh slice of the subtree', () => { - const out = runQueryAtPath(deep(), '$', [['k', 'level0'], ['k', 'level1']], { maxDepth: 3 }) + it('runQueryAtPath re-runs and returns a fresh slice of the subtree', async () => { + const out = await runQueryAtPath(deep(), '$', [['k', 'level0'], ['k', 'level1']], { maxDepth: 3 }) expect(out.ok).toBe(true) if (out.ok) { // The subtree normalizes from level2 with a fresh budget, reaching the leaf. @@ -110,45 +110,45 @@ describe('depth truncation + lazy expand', () => { } }) - it('runQueryAtPath fails soft on a broken base query', () => { - expect(runQueryAtPath(deep(), 'nope.method()', []).ok).toBe(false) + it('runQueryAtPath fails soft on a broken base query', async () => { + expect((await runQueryAtPath(deep(), 'nope.method()', [])).ok).toBe(false) }) }) describe('runQuery (live)', () => { - it('queries live Maps and Sets through the bridge methods', () => { - const out = runQuery(liveGraph(), 'store.entries.mapEntries().key') + it('queries live Maps and Sets through the bridge methods', async () => { + const out = await runQuery(liveGraph(), 'store.entries.mapEntries().key') expect(out).toMatchObject({ ok: true, result: ['a', 'b'] }) - const set = runQuery(liveGraph(), 'tags.fromSet()') + const set = await runQuery(liveGraph(), 'tags.fromSet()') expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] }) }) - it('reports payload size and timings', () => { - const out = runQuery(liveGraph(), 'store.name') + it('reports payload size and timings', async () => { + const out = await runQuery(liveGraph(), 'store.name') expect(out.ok && out.stats.payloadBytes).toBeGreaterThan(0) }) - it('fails soft with an error envelope', () => { - const out = runQuery(liveGraph(), 'nope.method()') + it('fails soft with an error envelope', async () => { + const out = await runQuery(liveGraph(), 'nope.method()') expect(out.ok).toBe(false) }) }) describe('runQuery (static portability)', () => { - it('the same query works against the NORMALIZED form of the data', () => { + it('the same query works against the NORMALIZED form of the data', async () => { const { data } = normalize(liveGraph()) // `store.entries` is now a `{ $type: 'Map', value }` tag; the bridge // methods duck-type it so live-authored queries stay portable. - const out = runQuery(data, 'store.entries.mapEntries().key') + const out = await runQuery(data, 'store.entries.mapEntries().key') expect(out).toMatchObject({ ok: true, result: ['a', 'b'] }) - const set = runQuery(data, 'tags.fromSet()') + const set = await runQuery(data, 'tags.fromSet()') expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] }) }) }) describe('suggest', () => { - it('returns flattened, prefix-ranged completion items', () => { - const out = suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4) + it('returns flattened, prefix-ranged completion items', async () => { + const out = await suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4) expect(out.ok).toBe(true) expect(out.suggestions.map(s => s.value)).toEqual(['bar', 'baz']) expect(out.suggestions[0]).toMatchObject({ from: 4, to: 4, current: '' }) diff --git a/plugins/data-inspector/test/registry.test.ts b/plugins/data-inspector/test/registry.test.ts index f8403cc9..b6609547 100644 --- a/plugins/data-inspector/test/registry.test.ts +++ b/plugins/data-inspector/test/registry.test.ts @@ -158,7 +158,7 @@ describe('example source', () => { const data = await resolveSourceData(getDataSource(entry.id)!) const { runQuery } = await import('../src/engine/query-engine') for (const recipe of entry.queries ?? []) { - const out = runQuery(data, recipe.query.trim() || '$', recipe) + const out = await runQuery(data, recipe.query.trim() || '$', recipe) expect(out.ok, `suggested query "${recipe.title}" must run`).toBe(true) } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecc5af87..f4b7ea68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,9 +85,6 @@ catalogs: immer: specifier: ^11.1.16 version: 11.1.16 - jora: - specifier: ^1.0.0-beta.16 - version: 1.0.0-beta.16 launch-editor: specifier: ^2.14.1 version: 2.14.1 @@ -268,6 +265,9 @@ catalogs: '@antfu/utils': specifier: ^9.3.0 version: 9.3.0 + jora: + specifier: ^1.0.0-beta.16 + version: 1.0.0-beta.16 ua-parser-modern: specifier: ^0.1.1 version: 0.1.1 @@ -1691,9 +1691,6 @@ importers: cac: specifier: catalog:deps version: 7.0.0 - jora: - specifier: catalog:deps - version: 1.0.0-beta.16 devDependencies: '@antfu/design': specifier: catalog:frontend @@ -1734,6 +1731,9 @@ importers: floating-vue: specifier: catalog:frontend version: 5.2.2(vue@3.5.41(typescript@6.0.3)) + jora: + specifier: catalog:inlined + version: 1.0.0-beta.16 reka-ui: specifier: catalog:frontend version: 2.10.3(vue@3.5.41(typescript@6.0.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92b92c6f..f9d14f0a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -89,7 +89,6 @@ catalogs: hono: ^4.13.1 image-meta: ^0.2.2 immer: ^11.1.16 - jora: ^1.0.0-beta.16 launch-editor: ^2.14.1 mrmime: ^2.0.1 nitro: ^3.0.260610-beta @@ -152,6 +151,7 @@ catalogs: vue: ^3.5.41 inlined: '@antfu/utils': ^9.3.0 + jora: ^1.0.0-beta.16 ua-parser-modern: ^0.1.1 storybook: '@storybook/addon-a11y': *storybook diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts index 99f26a25..5042815b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts @@ -148,12 +148,12 @@ export declare function normalize(_: unknown, _?: NormalizeOptions): { data: unknown; stats: NormalizeStats; }; -export declare function runQuery(_: unknown, _: string, _?: NormalizeOptions): QueryOutcome; -export declare function runQueryAtPath(_: unknown, _: string, _: NodePath, _?: NormalizeOptions): QueryOutcome; +export declare function runQuery(_: unknown, _: string, _?: NormalizeOptions): Promise; +export declare function runQueryAtPath(_: unknown, _: string, _: NodePath, _?: NormalizeOptions): Promise; export declare function skeletonOf(_: unknown, _?: SkeletonOptions): { skeleton: unknown; nodes: number; ms: number; }; -export declare function suggest(_: unknown, _: string, _: number, _?: number): SuggestOutcome; +export declare function suggest(_: unknown, _: string, _: number, _?: number): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js index 2fceb801..cba124dc 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js @@ -6,8 +6,8 @@ export function applyWrite(_, _, _) {} export function isExcludedKey(_, _) {} export function navigate(_, _, _) {} export function normalize(_, _) {} -export function runQuery(_, _, _) {} -export function runQueryAtPath(_, _, _, _) {} +export async function runQuery(_, _, _) {} +export async function runQueryAtPath(_, _, _, _) {} export function skeletonOf(_, _) {} -export function suggest(_, _, _, _) {} +export async function suggest(_, _, _, _) {} // #endregion \ No newline at end of file