diff --git a/PARITY.md b/PARITY.md index e262c598e..1b76c6df5 100644 --- a/PARITY.md +++ b/PARITY.md @@ -136,20 +136,18 @@ SDKs via the binding's `extensions` + `suppress_config_extensions` / still loaded by the core directly. Parity restored across Python and TypeScript; Rust stays file-path-only by design (epic #227 carve-out). -**Where the planning lives — temporary drift -([#1111](https://github.com/thekevinscott/dirsql/issues/1111)).** The decisions -inside that helper — does any entry name a package, what does each literal path -resolve to against its own config's directory, which installed file is *the* -loadable — are language-neutral, and since -[#1119](https://github.com/thekevinscott/dirsql/issues/1119) they live once in -`dirsql::extension_resolution` (`#[doc(hidden)]`, not a public Rust API). -Python reached through the binding in -[#1120](https://github.com/thekevinscott/dirsql/issues/1120) -(`dirsql._dirsql.plan_config_extensions` / `select_loadable` / `is_bare_name`); -TypeScript still carries its own port and is tracked by -[#1121](https://github.com/thekevinscott/dirsql/issues/1121). Each host keeps -only what it alone can do: reading the config files, and locating an installed -package (`importlib.util.find_spec` / `require.resolve`). +**Where the planning lives.** The decisions inside that helper — does any entry +name a package, what does each literal path resolve to against its own config's +directory, which installed file is *the* loadable — are language-neutral, and +since [#1119](https://github.com/thekevinscott/dirsql/issues/1119) they live +once in `dirsql::extension_resolution` (`#[doc(hidden)]`, not a public Rust +API). Both SDKs reach it through their binding — +[#1120](https://github.com/thekevinscott/dirsql/issues/1120) for Python +(`dirsql._dirsql.plan_config_extensions` / `select_loadable` / `is_bare_name`), +[#1121](https://github.com/thekevinscott/dirsql/issues/1121) for TypeScript +(the addon's `planConfigExtensions` / `selectLoadable` / `isBareName`). Each +host keeps only what it alone can do: reading the config files, and locating an +installed package (`importlib.util.find_spec` / `require.resolve`). **Config-flag scanning breadth ([#754](https://github.com/thekevinscott/dirsql/issues/754) / diff --git a/packages/ts/changelog.d/2026-09-17-extension-resolution-via-core.md b/packages/ts/changelog.d/2026-09-17-extension-resolution-via-core.md new file mode 100644 index 000000000..3277929e5 --- /dev/null +++ b/packages/ts/changelog.d/2026-09-17-extension-resolution-via-core.md @@ -0,0 +1,7 @@ +**Changed** + +- **Extension resolution now plans in the Rust core.** `resolveConfigsExtensionSpecs` reads each config off disk and hands `{ path, contents }` pairs to the addon's `planConfigExtensions`; `resolvePackage` hands the located package directory and its files to `selectLoadable`; `resolveExtensionPath` classifies through `isBareName`. TypeScript keeps only the two host-specific halves — reading the files, and `require.resolve`. Six internal modules go away (`is-bare-name`, `has-bare-name`, `load-extension-entries`, `resolve-entries`, `platform-suffixes`, `resolve-config-extension-specs`) against two added (`read-config`, `plan-entry-path`), and with them the `smol-toml` dependency and the lazy-load guard it needed (#720). No change to the published API. (#1121) + +**Fixed** + +- The error raised when a config's `[[dirsql.extension]]` names an installed package with no loadable file now writes the searched suffixes as glob patterns — `no loadable extension file (*.so / *.node) found in package 'x' (searched /dir)` — matching the Python SDK. (#1121) diff --git a/packages/ts/e2e-attestations/claude-1121-ts-ext.json b/packages/ts/e2e-attestations/claude-1121-ts-ext.json new file mode 100644 index 000000000..f9b0e8f4b --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1121-ts-ext.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1789683288, + "exit_code": 0, + "commit": "3cab5ee29ace70626e8042059bb065888302650b", + "branch": "claude/1121-ts-ext" +} diff --git a/packages/ts/migrations.d/2026-09-17-extension-resolution-via-core.md b/packages/ts/migrations.d/2026-09-17-extension-resolution-via-core.md new file mode 100644 index 000000000..62f20b32e --- /dev/null +++ b/packages/ts/migrations.d/2026-09-17-extension-resolution-via-core.md @@ -0,0 +1,37 @@ +### Missing-loadable error wording is now glob-shaped (#1121) + +#### Summary + +Extension resolution moved onto the Rust core's plan contract +(`dirsql::extension_resolution`), so the "no loadable extension file" error is +now rendered by the core rather than by the TypeScript port of it. The core +writes each searched suffix as a glob, which changes the message text. + +#### Required changes + +_None._ No exported symbol, argument, or config key changed. + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- A config `[[dirsql.extension]]` naming an installed package that ships no + loadable file rejects with `no loadable extension file (*.so / *.node) found + in package 'x' (searched /dir)`. It previously read `(.so / .node)`. Code + matching that string exactly must be updated; the Python SDK already emitted + the glob form, so the two now agree. +- `smol-toml` is no longer a runtime dependency. Nothing imported it from + outside the package, but a consumer relying on it being hoisted into + `node_modules` by `dirsql` must depend on it directly. + +#### Verification + +```bash +mkdir -p node_modules/empty-ext +echo '{"name":"empty-ext","version":"1.0.0"}' > node_modules/empty-ext/package.json +printf '[[dirsql.extension]]\npath = "empty-ext"\n' > .dirsql.toml +npx dirsql query "SELECT 1" --config .dirsql.toml +# -> no loadable extension file (*.so / *.node) found in package 'empty-ext' (searched …/node_modules/empty-ext) +``` diff --git a/packages/ts/napi/src/lib.rs b/packages/ts/napi/src/lib.rs index 40ac2b722..5afcac236 100644 --- a/packages/ts/napi/src/lib.rs +++ b/packages/ts/napi/src/lib.rs @@ -18,6 +18,7 @@ //! the TS wrapper exposes a matching overloaded constructor so callers can //! write either `new DirSQL(configPath)` or `new DirSQL({ root, tables, ... })`. +use dirsql::extension_resolution::{self, ConfigSource as CoreConfigSource, PlanEntry}; use dirsql::{ DirSQL as CoreDirSQL, Extension, PreparedBuild, RawFileEvent, Row, RowEvent as CoreRowEvent, Table, Value, @@ -27,7 +28,7 @@ use napi::bindgen_prelude::*; use napi_derive::napi; use std::cell::RefCell; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -54,6 +55,106 @@ pub fn config_paths_from_argv(argv: Vec) -> Vec { dirsql::launcher::config_paths_from_argv(&argv) } +/// The loadable-file suffixes a Node host recognizes. +const NODE_SUFFIXES: &[&str] = &[".so", ".dylib", ".dll", ".node"]; + +/// The subset this platform actually builds, used when picking a package's +/// loadable out of everything installed beside it. +#[cfg(target_os = "macos")] +const PLATFORM_SUFFIXES: &[&str] = &[".dylib", ".node"]; +#[cfg(target_os = "windows")] +const PLATFORM_SUFFIXES: &[&str] = &[".dll", ".node"]; +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +const PLATFORM_SUFFIXES: &[&str] = &[".so", ".node"]; + +/// One config file for the extension planner: its absolute path, and its +/// contents or `null` when the launcher could not read it. +#[napi(object)] +pub struct ConfigSource { + pub path: String, + pub contents: Option, +} + +/// One planned `[[dirsql.extension]]` entry. +/// +/// Exactly one of `path` and `package` is set: a `path` is ready to load, a +/// `package` must be located with `require.resolve` first — unless `shadow` +/// names an existing file, which takes precedence over the package. +#[napi(object, object_from_js = false)] +pub struct ExtensionPlanEntry { + pub path: Option, + pub package: Option, + pub shadow: Option, + pub entrypoint: Option, +} + +fn plan_entry_to_js(entry: PlanEntry) -> ExtensionPlanEntry { + match entry { + PlanEntry::Literal { path, entrypoint } => ExtensionPlanEntry { + path: Some(display(&path)), + package: None, + shadow: None, + entrypoint, + }, + PlanEntry::Package { + name, + shadow, + entrypoint, + } => ExtensionPlanEntry { + path: None, + package: Some(name), + shadow: Some(display(&shadow)), + entrypoint, + }, + } +} + +fn display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// Plan several configs' `[[dirsql.extension]]` entries. +/// +/// `null` back means no entry names a package, so the core's own config +/// loading handles them all. +#[napi(js_name = "planConfigExtensions")] +pub fn plan_config_extensions(configs: Vec) -> Option> { + let sources: Vec> = configs + .iter() + .map(|source| CoreConfigSource { + path: Path::new(&source.path), + contents: source.contents.as_deref(), + }) + .collect(); + let plan = extension_resolution::plan_config_extensions(&sources, NODE_SUFFIXES)?; + Some(plan.into_iter().map(plan_entry_to_js).collect()) +} + +/// Pick package `name`'s single loadable file out of `candidates`. +#[napi(js_name = "selectLoadable")] +pub fn select_loadable(name: String, dirs: Vec, candidates: Vec) -> Result { + selected_loadable(&name, dirs, candidates).map_err(Error::from_reason) +} + +/// The JS-free half of [`select_loadable`], so it stays unit-testable. +fn selected_loadable( + name: &str, + dirs: Vec, + candidates: Vec, +) -> std::result::Result { + let dirs: Vec = dirs.into_iter().map(PathBuf::from).collect(); + let candidates: Vec = candidates.into_iter().map(PathBuf::from).collect(); + extension_resolution::select_loadable(name, &dirs, &candidates, PLATFORM_SUFFIXES) + .map(|path| display(&path)) + .map_err(|err| err.to_string()) +} + +/// Whether an extension entry names a package rather than a file. +#[napi(js_name = "isBareName")] +pub fn is_bare_name(path: String) -> bool { + extension_resolution::is_bare_name(&path, NODE_SUFFIXES) +} + /// A row-level event emitted by the file watcher. /// /// `table` is nullable because error events may occur before a file has @@ -764,6 +865,87 @@ fn row_event_to_js(event: &CoreRowEvent) -> Result { mod tests { use super::*; + fn config(path: &str, contents: Option<&str>) -> ConfigSource { + ConfigSource { + path: path.to_string(), + contents: contents.map(str::to_string), + } + } + + #[test] + fn plan_config_extensions_is_none_without_a_package_name() { + assert!( + plan_config_extensions(vec![config( + "/cfg/.dirsql.toml", + Some("[[dirsql.extension]]\npath = \"./vec0.so\"\n"), + )]) + .is_none() + ); + } + + #[test] + fn plan_config_extensions_resolves_literals_against_their_config_dir() { + let plan = plan_config_extensions(vec![config( + "/cfg/.dirsql.toml", + Some( + "[[dirsql.extension]]\npath = \"./vec0.so\"\n\n[[dirsql.extension]]\npath = \"sqlite_vec\"\n", + ), + )]) + .expect("a package name forces a plan"); + assert_eq!(plan[0].path.as_deref(), Some("/cfg/./vec0.so")); + assert_eq!(plan[1].package.as_deref(), Some("sqlite_vec")); + assert_eq!(plan[1].shadow.as_deref(), Some("/cfg/sqlite_vec")); + } + + #[test] + fn plan_config_extensions_skips_an_unreadable_config() { + assert!(plan_config_extensions(vec![config("/cfg/.dirsql.toml", None)]).is_none()); + } + + #[test] + fn plan_config_extensions_carries_the_entrypoint() { + let plan = plan_config_extensions(vec![config( + "/cfg/.dirsql.toml", + Some("[[dirsql.extension]]\npath = \"sqlite_vec\"\nentrypoint = \"init\"\n"), + )]) + .expect("a package name forces a plan"); + assert_eq!(plan[0].entrypoint.as_deref(), Some("init")); + } + + #[test] + fn select_loadable_returns_the_single_match() { + let found = selected_loadable( + "vec", + vec!["/pkg".into()], + vec![ + "/pkg/README.md".into(), + format!("/pkg/vec0{}", PLATFORM_SUFFIXES[0]), + ], + ) + .expect("one loadable file"); + assert_eq!(found, format!("/pkg/vec0{}", PLATFORM_SUFFIXES[0])); + } + + #[test] + fn select_loadable_carries_the_cores_message_verbatim() { + let err = + selected_loadable("vec", vec!["/pkg".into()], vec![]).expect_err("no loadable file"); + assert!(err.starts_with("no loadable extension file (*"), "{err}"); + assert!( + err.ends_with("found in package 'vec' (searched /pkg)"), + "{err}" + ); + } + + #[test] + fn is_bare_name_applies_the_node_suffix_list() { + assert!(is_bare_name("sqlite-vec".into())); + assert!(!is_bare_name("vec0.node".into())); + assert!(!is_bare_name("vec0.so".into())); + assert!(is_bare_name("vec0.pyd".into())); + assert!(!is_bare_name("ext/vec0".into())); + } + fn one_row() -> HashMap { HashMap::from([("k".to_string(), Value::Integer(7))]) } diff --git a/packages/ts/package.json b/packages/ts/package.json index ecf2a46e4..95788a0c1 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -159,8 +159,7 @@ } }, "dependencies": { - "bin-shim": "^0.2.0", - "smol-toml": "^1.6.1" + "bin-shim": "^0.2.0" }, "devDependencies": { "@biomejs/biome": "^1.9.0", diff --git a/packages/ts/pnpm-lock.yaml b/packages/ts/pnpm-lock.yaml index da1b3cc30..8a9be033c 100644 --- a/packages/ts/pnpm-lock.yaml +++ b/packages/ts/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: bin-shim: specifier: ^0.2.0 version: 0.2.0 - smol-toml: - specifier: ^1.6.1 - version: 1.7.0 devDependencies: '@biomejs/biome': specifier: ^1.9.0 @@ -1502,10 +1499,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} - engines: {node: '>= 18'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2911,8 +2904,6 @@ snapshots: signal-exit@4.1.0: {} - smol-toml@1.7.0: {} - source-map-js@1.2.1: {} sql.js@1.14.1: {} diff --git a/packages/ts/src/cli/resolve-config-extensions.test.ts b/packages/ts/src/cli/resolve-config-extensions.test.ts index 82c5ebe29..99407a3d7 100644 --- a/packages/ts/src/cli/resolve-config-extensions.test.ts +++ b/packages/ts/src/cli/resolve-config-extensions.test.ts @@ -1,10 +1,8 @@ -import { existsSync } from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getCore } from "../core.js"; import { resolveConfigsExtensionSpecs } from "../resolve-config-extensions.js"; import { withResolvedExtensions } from "./resolve-config-extensions.js"; -vi.mock("node:fs"); vi.mock("../core.js"); vi.mock("../resolve-config-extensions.js", async () => ({ ...(await vi.importActual( @@ -18,7 +16,6 @@ describe("withResolvedExtensions", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(existsSync).mockReturnValue(true); vi.mocked(getCore).mockReturnValue({ configPathsFromArgv } as never); configPathsFromArgv.mockReturnValue(["/x/.dirsql.toml"]); }); @@ -45,9 +42,9 @@ describe("withResolvedExtensions", () => { "cfg.cjs", ]); const argv = ["--config", "dirsql.config.mjs"]; + vi.mocked(resolveConfigsExtensionSpecs).mockReturnValue(null); expect(await withResolvedExtensions(argv)).toBe(argv); - expect(existsSync).not.toHaveBeenCalled(); - expect(resolveConfigsExtensionSpecs).not.toHaveBeenCalled(); + expect(resolveConfigsExtensionSpecs).toHaveBeenCalledWith([]); }); it("drops native configs but resolves the TOML ones", async () => { @@ -63,19 +60,9 @@ describe("withResolvedExtensions", () => { ]); }); - it("passes through without loading the parser when no config file exists", async () => { - vi.mocked(existsSync).mockReturnValue(false); - configPathsFromArgv.mockReturnValue(["/gone/.dirsql.toml"]); - const argv = ["--config", "/gone/.dirsql.toml"]; - expect(await withResolvedExtensions(argv)).toBe(argv); - expect(existsSync).toHaveBeenCalledWith("/gone/.dirsql.toml"); - expect(resolveConfigsExtensionSpecs).not.toHaveBeenCalled(); - }); - - it("consults the resolver with every path when at least one config exists", async () => { - // The resolver skips missing configs itself; the existsSync guard only - // keeps the TOML parser off the launch path when NO config exists. - vi.mocked(existsSync).mockImplementation((p) => p === "/b/.dirsql.toml"); + it("consults the resolver with every path, missing configs included", async () => { + // The resolver hands each config to the core, which skips the ones it + // could not read. configPathsFromArgv.mockReturnValue(["/a/.dirsql.toml", "/b/.dirsql.toml"]); vi.mocked(resolveConfigsExtensionSpecs).mockReturnValue(null); await withResolvedExtensions([ diff --git a/packages/ts/src/cli/resolve-config-extensions.ts b/packages/ts/src/cli/resolve-config-extensions.ts index 631ce7e20..01b22684f 100644 --- a/packages/ts/src/cli/resolve-config-extensions.ts +++ b/packages/ts/src/cli/resolve-config-extensions.ts @@ -1,18 +1,18 @@ // Launcher-side resolution of the TOML configs' `[[dirsql.extension]]` entries. // -// The compiled `dirsql` binary loads config extensions literally — it has no -// `require.resolve`, so it cannot resolve a bare **package name**. When any -// TOML config in argv names an extension by package name, the shared SDK -// resolver resolves every config's entries and this launcher passes the -// resolved literal paths to the binary via repeatable `--extension` flags; -// the binary then loads those and ignores the configs' own extension entries. +// The core loads config extensions literally — it has no `require.resolve`, +// so it cannot resolve a bare **package name**. When any TOML config in argv +// names an extension by package name, the shared SDK resolver resolves every +// config's entries and this launcher passes the resolved literal paths on via +// repeatable `--extension` flags; the core then loads those and ignores the +// configs' own extension entries. // // Native-language configs (`.py`/`.js`/`.mjs`/`.cjs`) are untouched: the binary // dispatches those to `dirsql interpret`, whose handshake already carries // resolved paths. -import { existsSync } from "node:fs"; import { getCore } from "../core.js"; +import { resolveConfigsExtensionSpecs } from "../resolve-config-extensions.js"; // Config extensions the binary dispatches to `dirsql interpret`; never // pre-resolved here (that path resolves via the handshake). @@ -33,16 +33,6 @@ export async function withResolvedExtensions( const configPaths = getCore() .configPathsFromArgv(argv) .filter((p) => !NATIVE_CONFIG_SUFFIXES.some((s) => p.endsWith(s))); - // The shared resolver pulls in smol-toml, which only a TOML config on disk - // can need. Guarding on the same `existsSync` the resolver itself starts - // with keeps the parser off the common launch path entirely (#720); the - // resolver skips any individually-missing config on its own. - if (!configPaths.some((p) => existsSync(p))) { - return argv; - } - const { resolveConfigsExtensionSpecs } = await import( - "../resolve-config-extensions.js" - ); const specs = resolveConfigsExtensionSpecs(configPaths); if (specs === null) { return argv; diff --git a/packages/ts/src/core.test.ts b/packages/ts/src/core.test.ts index 06cba3f88..63410e7db 100644 --- a/packages/ts/src/core.test.ts +++ b/packages/ts/src/core.test.ts @@ -25,18 +25,27 @@ describe("getCore", () => { describe("CoreModule contract", () => { it("carries the core's argv config-path scan beside the DirSQL class", () => { const configPathsFromArgv = vi.fn((argv: string[]) => argv); - // Typed against the real interface, not cast: dropping the scan from + const planConfigExtensions = vi.fn(() => null); + const selectLoadable = vi.fn(() => "/d/ext.so"); + const isBareName = vi.fn(() => true); + // Typed against the real interface, not cast: dropping a member from // `CoreModule` would make this literal fail to compile, which pins the - // member the npm launcher reaches for before the core parses argv. + // surface the SDK reaches for before the core parses argv. const core: CoreModule = { DirSQL: {} as NativeDirSQLConstructor, configPathsFromArgv, + planConfigExtensions, + selectLoadable, + isBareName, }; expect(core.configPathsFromArgv(["-c", "a.toml"])).toEqual([ "-c", "a.toml", ]); expect(configPathsFromArgv).toHaveBeenCalledWith(["-c", "a.toml"]); + expect(core.planConfigExtensions([{ path: "a.toml" }])).toBeNull(); + expect(core.selectLoadable("ext", ["/d"], ["/d/ext.so"])).toBe("/d/ext.so"); + expect(core.isBareName("ext")).toBe(true); }); }); diff --git a/packages/ts/src/core.ts b/packages/ts/src/core.ts index a163d9e21..282f6526b 100644 --- a/packages/ts/src/core.ts +++ b/packages/ts/src/core.ts @@ -30,11 +30,40 @@ export interface NativeDirSQLConstructor { ): Promise; } +/// One config file handed to the core's extension planner: its absolute path, +/// and its contents, omitted when it could not be read. +export interface ConfigSource { + path: string; + contents?: string; +} + +/** One planned `[[dirsql.extension]]` entry, as the core plans it. + * + * Exactly one of `path` and `package` is set: a `path` is ready to load, a + * `package` must be located with `require.resolve` first — unless `shadow` + * names an existing file, which takes precedence over the package. + */ +// napi omits a `None` field rather than emitting `null`, so the absent side +// of each variant is `undefined`. +export type ExtensionPlanEntry = + | { + path: string; + package?: undefined; + shadow?: undefined; + entrypoint?: string; + } + | { path?: undefined; package: string; shadow: string; entrypoint?: string }; + // Core module shape. The real implementation comes from the napi-rs // native binary (`dirsql.node`); tests may substitute a fake. export interface CoreModule { DirSQL: NativeDirSQLConstructor; configPathsFromArgv(argv: string[]): string[]; + // `null` when no config names an extension by package name: the core loads + // every config's entries itself. + planConfigExtensions(configs: ConfigSource[]): ExtensionPlanEntry[] | null; + selectLoadable(name: string, dirs: string[], candidates: string[]): string; + isBareName(path: string): boolean; } // Unit tests `vi.mock("./core.js")` to fake `getCore` directly, so diff --git a/packages/ts/src/has-bare-name.test.ts b/packages/ts/src/has-bare-name.test.ts deleted file mode 100644 index dfa483193..000000000 --- a/packages/ts/src/has-bare-name.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { hasBareName } from "./has-bare-name.js"; -import { isBareName } from "./is-bare-name.js"; - -vi.mock("./is-bare-name.js", async () => ({ - ...(await vi.importActual( - "./is-bare-name.js", - )), - isBareName: vi.fn(), -})); - -afterEach(() => vi.resetAllMocks()); - -describe("hasBareName", () => { - it("is false for no entries", () => { - expect(hasBareName([])).toBe(false); - expect(isBareName).not.toHaveBeenCalled(); - }); - - it("asks isBareName about each string path", () => { - vi.mocked(isBareName).mockReturnValue(false); - expect(hasBareName([{ path: "ext/a.so" }, { path: "ext/b.so" }])).toBe( - false, - ); - expect(vi.mocked(isBareName).mock.calls).toEqual([ - ["ext/a.so"], - ["ext/b.so"], - ]); - }); - - it("is true when some entry is a bare name", () => { - vi.mocked(isBareName).mockImplementation((p) => p === "sqlite_vec"); - expect(hasBareName([{ path: "ext/a.so" }, { path: "sqlite_vec" }])).toBe( - true, - ); - }); - - it("skips an entry whose path is not a string", () => { - vi.mocked(isBareName).mockReturnValue(true); - expect(hasBareName([{ path: 42 }])).toBe(false); - expect(isBareName).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/ts/src/has-bare-name.ts b/packages/ts/src/has-bare-name.ts deleted file mode 100644 index 24da1aeef..000000000 --- a/packages/ts/src/has-bare-name.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { isBareName } from "./is-bare-name.js"; -import type { Toml } from "./load-extension-entries.js"; - -/** True when some entry's `path` names an extension by bare package name. */ -export function hasBareName(entries: Toml[]): boolean { - return entries.some((e) => typeof e.path === "string" && isBareName(e.path)); -} diff --git a/packages/ts/src/is-bare-name.test.ts b/packages/ts/src/is-bare-name.test.ts deleted file mode 100644 index 8feb6297e..000000000 --- a/packages/ts/src/is-bare-name.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isBareName } from "./is-bare-name.js"; - -describe("isBareName", () => { - it("treats a separatorful value as a path", () => { - expect(isBareName("ext/vec0.so")).toBe(false); - expect(isBareName("C:\\ext\\vec0.dll")).toBe(false); - }); - - it("treats a forward slash alone as a path", () => { - expect(isBareName("scope/pkg")).toBe(false); - }); - - it("treats a backslash alone as a path", () => { - expect(isBareName("a\\b")).toBe(false); - }); - - it("treats a loadable suffix as a path", () => { - expect(isBareName("vec0.so")).toBe(false); - expect(isBareName("vec0.dylib")).toBe(false); - expect(isBareName("vec0.dll")).toBe(false); - expect(isBareName("vec0.node")).toBe(false); - }); - - it("treats a plain identifier as a bare name", () => { - expect(isBareName("sqlite-vec")).toBe(true); - }); - - it("only matches a loadable suffix at the end", () => { - expect(isBareName("so-package")).toBe(true); - expect(isBareName("node-thing")).toBe(true); - }); -}); diff --git a/packages/ts/src/is-bare-name.ts b/packages/ts/src/is-bare-name.ts deleted file mode 100644 index 200e76cab..000000000 --- a/packages/ts/src/is-bare-name.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** True when `path` is a bare package name rather than a file path. */ -export function isBareName(path: string): boolean { - // Suffixes that mark a value as "already a file path" (so package resolution is - // never attempted). - const loadableSuffixes = [".so", ".dylib", ".dll", ".node"]; - if (path.includes("/") || path.includes("\\")) { - return false; - } - return !loadableSuffixes.some((s) => path.endsWith(s)); -} diff --git a/packages/ts/src/load-extension-entries.test.ts b/packages/ts/src/load-extension-entries.test.ts deleted file mode 100644 index 71ca23e8b..000000000 --- a/packages/ts/src/load-extension-entries.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { parse as parseToml } from "smol-toml"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadExtensionEntries } from "./load-extension-entries.js"; - -vi.mock("node:fs", async () => ({ - ...(await vi.importActual("node:fs")), - existsSync: vi.fn(), - readFileSync: vi.fn(() => ""), -})); -vi.mock("smol-toml", async () => ({ - ...(await vi.importActual("smol-toml")), - parse: vi.fn(), -})); - -function stubConfig(doc: unknown, exists = true) { - vi.mocked(existsSync).mockReturnValue(exists); - vi.mocked(parseToml).mockReturnValue(doc as ReturnType); -} - -describe("loadExtensionEntries", () => { - afterEach(() => vi.resetAllMocks()); - - it("returns null when the config file does not exist", () => { - vi.mocked(existsSync).mockReturnValue(false); - expect(loadExtensionEntries("/nope/.dirsql.toml")).toBeNull(); - expect(existsSync).toHaveBeenCalledWith("/nope/.dirsql.toml"); - expect(readFileSync).not.toHaveBeenCalled(); - }); - - it("returns null when the TOML fails to parse", () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(parseToml).mockImplementation(() => { - throw new Error("bad toml"); - }); - expect(loadExtensionEntries("/x/.dirsql.toml")).toBeNull(); - }); - - it("returns null when the config has no [dirsql] section at all", () => { - stubConfig({ - table: [{ name: "t", ddl: "CREATE TABLE t (x TEXT)", glob: "*" }], - }); - expect(loadExtensionEntries("/x/.dirsql.toml")).toBeNull(); - }); - - it("returns null when the config declares no extensions", () => { - stubConfig({ dirsql: { ignore: ["x"] } }); - expect(loadExtensionEntries("/x/.dirsql.toml")).toBeNull(); - }); - - it("returns null when `extension` is not an array", () => { - stubConfig({ dirsql: { extension: "not-a-list" } }); - expect(loadExtensionEntries("/x/.dirsql.toml")).toBeNull(); - }); - - it("returns the entries with the config's parent directory", () => { - const entries = [{ path: "sqlite_vec" }, { path: "ext/a.so" }]; - stubConfig({ dirsql: { extension: entries } }); - expect(loadExtensionEntries("/cfg/nested/.dirsql.toml")).toEqual({ - entries, - base: "/cfg/nested", - }); - }); - - it("reads the config as utf8 text and parses that text", () => { - vi.mocked(readFileSync).mockReturnValue("[dirsql]\n"); - stubConfig({ dirsql: { extension: [] } }); - expect(loadExtensionEntries("/cfg/.dirsql.toml")).toEqual({ - entries: [], - base: "/cfg", - }); - expect(readFileSync).toHaveBeenCalledWith("/cfg/.dirsql.toml", "utf8"); - expect(parseToml).toHaveBeenCalledWith("[dirsql]\n"); - }); - - it("resolves a relative config path against the cwd", () => { - stubConfig({ dirsql: { extension: [] } }); - const loaded = loadExtensionEntries(".dirsql.toml"); - expect(loaded?.base).toBe(process.cwd()); - }); -}); diff --git a/packages/ts/src/load-extension-entries.ts b/packages/ts/src/load-extension-entries.ts deleted file mode 100644 index 3858a24ef..000000000 --- a/packages/ts/src/load-extension-entries.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Read a TOML config's `[[dirsql.extension]]` array off disk. - -import { existsSync, readFileSync } from "node:fs"; -import { dirname, resolve as resolvePath } from "node:path"; -import { parse as parseToml } from "smol-toml"; - -// biome-ignore lint/suspicious/noExplicitAny: TOML root has a dynamic shape. -export type Toml = Record; - -/** Load a config's `[[dirsql.extension]]` entries with its base directory. - * - * `null` when the config is missing, unreadable/malformed, or declares no - * extension array — the caller leaves such configs to the core. - */ -export function loadExtensionEntries( - configPath: string, -): { entries: Toml[]; base: string } | null { - if (!existsSync(configPath)) { - return null; - } - let doc: Toml; - try { - doc = parseToml(readFileSync(configPath, "utf8")) as Toml; - } catch { - // Leave a malformed config for the core to report. - return null; - } - const dirsql = (doc.dirsql ?? {}) as Toml; - if (!Array.isArray(dirsql.extension)) { - return null; - } - return { entries: dirsql.extension, base: dirname(resolvePath(configPath)) }; -} diff --git a/packages/ts/src/plan-entry-path.test.ts b/packages/ts/src/plan-entry-path.test.ts new file mode 100644 index 000000000..25e20e4ef --- /dev/null +++ b/packages/ts/src/plan-entry-path.test.ts @@ -0,0 +1,83 @@ +import { existsSync, statSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionPlanEntry } from "./core.js"; +import { defaultResolver } from "./default-resolver.js"; +import type { PackageResolver } from "./package-dir.js"; +import { planEntryPath } from "./plan-entry-path.js"; +import { resolvePackage } from "./resolve-package.js"; + +vi.mock("node:fs", async () => ({ + ...(await vi.importActual("node:fs")), + existsSync: vi.fn(), + statSync: vi.fn(), +})); +vi.mock("./default-resolver.js", async () => ({ + ...(await vi.importActual( + "./default-resolver.js", + )), + defaultResolver: vi.fn(), +})); +vi.mock("./resolve-package.js", async () => ({ + ...(await vi.importActual( + "./resolve-package.js", + )), + resolvePackage: vi.fn(), +})); + +const resolver = { resolve: vi.fn(), paths: vi.fn() } as PackageResolver; + +function packageEntry(): ExtensionPlanEntry { + return { package: "sqlite_vec", shadow: "/cfg/sqlite_vec" }; +} + +function stat(isFile: boolean) { + vi.mocked(statSync).mockReturnValue({ + isFile: () => isFile, + } as unknown as ReturnType); +} + +afterEach(() => vi.resetAllMocks()); + +describe("planEntryPath", () => { + it("returns a literal entry's path without probing the filesystem", () => { + const entry: ExtensionPlanEntry = { path: "/cfg/vec0.so" }; + expect(planEntryPath(entry, resolver)).toBe("/cfg/vec0.so"); + expect(existsSync).not.toHaveBeenCalled(); + expect(resolvePackage).not.toHaveBeenCalled(); + }); + + it("prefers a same-named file shadowing the package", () => { + vi.mocked(existsSync).mockReturnValue(true); + stat(true); + expect(planEntryPath(packageEntry(), resolver)).toBe("/cfg/sqlite_vec"); + expect(resolvePackage).not.toHaveBeenCalled(); + }); + + it("ignores a shadow that is a directory", () => { + vi.mocked(existsSync).mockReturnValue(true); + stat(false); + vi.mocked(resolvePackage).mockReturnValue("/nm/sqlite_vec/vec0.so"); + expect(planEntryPath(packageEntry(), resolver)).toBe( + "/nm/sqlite_vec/vec0.so", + ); + }); + + it("falls back to the installed package", () => { + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(resolvePackage).mockReturnValue("/nm/sqlite_vec/vec0.so"); + expect(planEntryPath(packageEntry(), resolver)).toBe( + "/nm/sqlite_vec/vec0.so", + ); + expect(resolvePackage).toHaveBeenCalledWith("sqlite_vec", resolver); + }); + + it("defaults to the require.resolve-backed resolver", () => { + const fallback = { resolve: vi.fn(), paths: vi.fn() } as PackageResolver; + vi.mocked(defaultResolver).mockReturnValue(fallback); + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(resolvePackage).mockReturnValue("/nm/sqlite_vec/vec0.so"); + + expect(planEntryPath(packageEntry())).toBe("/nm/sqlite_vec/vec0.so"); + expect(resolvePackage).toHaveBeenCalledWith("sqlite_vec", fallback); + }); +}); diff --git a/packages/ts/src/plan-entry-path.ts b/packages/ts/src/plan-entry-path.ts new file mode 100644 index 000000000..395ed452e --- /dev/null +++ b/packages/ts/src/plan-entry-path.ts @@ -0,0 +1,25 @@ +// The loadable path one planned extension entry resolves to. + +import { existsSync, statSync } from "node:fs"; +import type { ExtensionPlanEntry } from "./core.js"; +import { defaultResolver } from "./default-resolver.js"; +import type { PackageResolver } from "./package-dir.js"; +import { resolvePackage } from "./resolve-package.js"; + +/** Resolve a planned entry to a concrete file. + * + * A literal entry is already resolved. A package entry prefers a same-named + * file shadowing it next to the config, then the installed package. + */ +export function planEntryPath( + entry: ExtensionPlanEntry, + resolver: PackageResolver = defaultResolver(), +): string { + if (entry.path !== undefined) { + return entry.path; + } + if (existsSync(entry.shadow) && statSync(entry.shadow).isFile()) { + return entry.shadow; + } + return resolvePackage(entry.package, resolver); +} diff --git a/packages/ts/src/platform-suffixes.test.ts b/packages/ts/src/platform-suffixes.test.ts deleted file mode 100644 index 9bf0e45f6..000000000 --- a/packages/ts/src/platform-suffixes.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { platformSuffixes } from "./platform-suffixes.js"; - -const original = process.platform; - -function pinPlatform(value: NodeJS.Platform) { - Object.defineProperty(process, "platform", { value, configurable: true }); -} - -describe("platformSuffixes", () => { - afterEach(() => - Object.defineProperty(process, "platform", { - value: original, - configurable: true, - }), - ); - - it("globs dylib and node on macOS", () => { - pinPlatform("darwin"); - expect(platformSuffixes()).toEqual([".dylib", ".node"]); - }); - - it("globs dll and node on windows", () => { - pinPlatform("win32"); - expect(platformSuffixes()).toEqual([".dll", ".node"]); - }); - - it("globs so and node everywhere else", () => { - pinPlatform("linux"); - expect(platformSuffixes()).toEqual([".so", ".node"]); - pinPlatform("freebsd"); - expect(platformSuffixes()).toEqual([".so", ".node"]); - }); -}); diff --git a/packages/ts/src/platform-suffixes.ts b/packages/ts/src/platform-suffixes.ts deleted file mode 100644 index 5623f1204..000000000 --- a/packages/ts/src/platform-suffixes.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Loadable-file glob suffix(es) for the current platform. */ -export function platformSuffixes(): string[] { - if (process.platform === "darwin") { - return [".dylib", ".node"]; - } - if (process.platform === "win32") { - return [".dll", ".node"]; - } - return [".so", ".node"]; -} diff --git a/packages/ts/src/read-config.test.ts b/packages/ts/src/read-config.test.ts new file mode 100644 index 000000000..1e459a35d --- /dev/null +++ b/packages/ts/src/read-config.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readConfig } from "./read-config.js"; + +vi.mock("node:fs", async () => ({ + ...(await vi.importActual("node:fs")), + readFileSync: vi.fn(), +})); + +afterEach(() => vi.resetAllMocks()); + +describe("readConfig", () => { + it("returns the config's text", () => { + vi.mocked(readFileSync).mockReturnValue("name = 1\n"); + expect(readConfig("/a/.dirsql.toml")).toBe("name = 1\n"); + expect(readFileSync).toHaveBeenCalledWith("/a/.dirsql.toml", "utf8"); + }); + + it("returns undefined when the config cannot be read", () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(readConfig("/gone/.dirsql.toml")).toBeUndefined(); + }); +}); diff --git a/packages/ts/src/read-config.ts b/packages/ts/src/read-config.ts new file mode 100644 index 000000000..d02bf7d37 --- /dev/null +++ b/packages/ts/src/read-config.ts @@ -0,0 +1,13 @@ +// Reading one TOML config off disk for the core's extension planner. + +import { readFileSync } from "node:fs"; + +/** The config's text, or `undefined` when it cannot be read. + * + * A missing or unreadable config is left for the core to report. + */ +export function readConfig(configPath: string): string | undefined { + try { + return readFileSync(configPath, "utf8"); + } catch {} +} diff --git a/packages/ts/src/resolve-config-extension-specs.test.ts b/packages/ts/src/resolve-config-extension-specs.test.ts deleted file mode 100644 index d4543d235..000000000 --- a/packages/ts/src/resolve-config-extension-specs.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { hasBareName } from "./has-bare-name.js"; -import { type Toml, loadExtensionEntries } from "./load-extension-entries.js"; -import { resolveConfigExtensionSpecs } from "./resolve-config-extension-specs.js"; -import { resolveEntries } from "./resolve-entries.js"; - -vi.mock("./has-bare-name.js", async () => ({ - ...(await vi.importActual( - "./has-bare-name.js", - )), - hasBareName: vi.fn(), -})); -vi.mock("./load-extension-entries.js", async () => ({ - ...(await vi.importActual( - "./load-extension-entries.js", - )), - loadExtensionEntries: vi.fn(), -})); -vi.mock("./resolve-entries.js", async () => ({ - ...(await vi.importActual( - "./resolve-entries.js", - )), - resolveEntries: vi.fn(() => []), -})); - -function loaded(base: string, entries: Toml[]) { - return { entries, base }; -} - -afterEach(() => vi.resetAllMocks()); - -describe("resolveConfigExtensionSpecs", () => { - it("returns null when the config yields no entries", () => { - vi.mocked(loadExtensionEntries).mockReturnValue(null); - - expect(resolveConfigExtensionSpecs("/nope/.dirsql.toml")).toBeNull(); - expect(loadExtensionEntries).toHaveBeenCalledWith("/nope/.dirsql.toml"); - expect(hasBareName).not.toHaveBeenCalled(); - expect(resolveEntries).not.toHaveBeenCalled(); - }); - - it("returns null when all extension paths are literal (no package name)", () => { - vi.mocked(loadExtensionEntries).mockReturnValue( - loaded("/x", [{ path: "ext/a.so" }]), - ); - vi.mocked(hasBareName).mockReturnValue(false); - - expect(resolveConfigExtensionSpecs("/x/.dirsql.toml")).toBeNull(); - expect(hasBareName).toHaveBeenCalledWith([{ path: "ext/a.so" }]); - expect(resolveEntries).not.toHaveBeenCalled(); - }); - - it("resolves every entry against the config's dir when a path is a bare name", () => { - const entries = [{ path: "sqlite_vec" }, { path: "ext/local.so" }]; - vi.mocked(loadExtensionEntries).mockReturnValue(loaded("/cfg", entries)); - vi.mocked(hasBareName).mockReturnValue(true); - vi.mocked(resolveEntries).mockReturnValue([ - { path: "/cfg/vec0.so", entrypoint: "sqlite3_vec_init" }, - ]); - - expect(resolveConfigExtensionSpecs("/cfg/.dirsql.toml")).toEqual([ - { path: "/cfg/vec0.so", entrypoint: "sqlite3_vec_init" }, - ]); - expect(resolveEntries).toHaveBeenCalledWith(entries, "/cfg"); - }); -}); diff --git a/packages/ts/src/resolve-config-extension-specs.ts b/packages/ts/src/resolve-config-extension-specs.ts deleted file mode 100644 index 6157ff7d2..000000000 --- a/packages/ts/src/resolve-config-extension-specs.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ExtensionSpec } from "./dirsql.js"; -import { hasBareName } from "./has-bare-name.js"; -import { loadExtensionEntries } from "./load-extension-entries.js"; -import { resolveEntries } from "./resolve-entries.js"; - -/** - * Resolve a TOML config's `[[dirsql.extension]]` entries to literal paths. - * - * Returns the resolved specs — every entry resolved via - * `resolveExtensionPath` against the config file's parent directory — when at - * least one entry's `path` is a bare package name. Returns `null` when the - * caller should not intervene: the config is missing, malformed, declares no - * extensions, or uses only literal paths — leaving the core's own loading - * (and error reporting) untouched. Throws if a package name cannot be - * resolved. - */ -export function resolveConfigExtensionSpecs( - configPath: string, -): ExtensionSpec[] | null { - const loaded = loadExtensionEntries(configPath); - if (loaded === null || !hasBareName(loaded.entries)) { - return null; - } - return resolveEntries(loaded.entries, loaded.base); -} diff --git a/packages/ts/src/resolve-config-extensions.test.ts b/packages/ts/src/resolve-config-extensions.test.ts index 49d1c8db2..32882380d 100644 --- a/packages/ts/src/resolve-config-extensions.test.ts +++ b/packages/ts/src/resolve-config-extensions.test.ts @@ -1,86 +1,83 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { hasBareName } from "./has-bare-name.js"; -import { type Toml, loadExtensionEntries } from "./load-extension-entries.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionPlanEntry } from "./core.js"; +import { getCore } from "./core.js"; +import { planEntryPath } from "./plan-entry-path.js"; +import { readConfig } from "./read-config.js"; import { resolveConfigsExtensionSpecs } from "./resolve-config-extensions.js"; -import { resolveEntries } from "./resolve-entries.js"; -vi.mock("./has-bare-name.js", async () => ({ - ...(await vi.importActual( - "./has-bare-name.js", +vi.mock("./core.js"); +vi.mock("./plan-entry-path.js", async () => ({ + ...(await vi.importActual( + "./plan-entry-path.js", )), - hasBareName: vi.fn(), + planEntryPath: vi.fn(), })); -vi.mock("./load-extension-entries.js", async () => ({ - ...(await vi.importActual( - "./load-extension-entries.js", +vi.mock("./read-config.js", async () => ({ + ...(await vi.importActual( + "./read-config.js", )), - loadExtensionEntries: vi.fn(), -})); -vi.mock("./resolve-entries.js", async () => ({ - ...(await vi.importActual( - "./resolve-entries.js", - )), - resolveEntries: vi.fn(() => []), + readConfig: vi.fn(), })); -function loaded(base: string, entries: Toml[]) { - return { entries, base }; +const planConfigExtensions = + vi.fn<(configs: unknown[]) => ExtensionPlanEntry[] | null>(); + +function packageEntry(name: string, entrypoint?: string): ExtensionPlanEntry { + return { package: name, shadow: `/cfg/${name}`, entrypoint }; } +beforeEach(() => { + vi.mocked(getCore).mockReturnValue({ planConfigExtensions } as never); +}); + afterEach(() => vi.resetAllMocks()); describe("resolveConfigsExtensionSpecs", () => { - it("returns null for an empty list", () => { - expect(resolveConfigsExtensionSpecs([])).toBeNull(); - expect(loadExtensionEntries).not.toHaveBeenCalled(); - }); + it("hands the core each config's absolute path and contents", () => { + vi.mocked(readConfig).mockReturnValue('[[dirsql.extension]]\npath = "x"\n'); + planConfigExtensions.mockReturnValue(null); - it("returns null when no config uses a bare package name", () => { - vi.mocked(loadExtensionEntries) - .mockReturnValueOnce(loaded("/a", [{ path: "ext/a.so" }])) - .mockReturnValueOnce(null); - vi.mocked(hasBareName).mockReturnValue(false); + resolveConfigsExtensionSpecs(["/a/.dirsql.toml"]); - expect(resolveConfigsExtensionSpecs(["/a.toml", "/b.toml"])).toBeNull(); - expect(resolveEntries).not.toHaveBeenCalled(); + expect(planConfigExtensions).toHaveBeenCalledWith([ + { + path: "/a/.dirsql.toml", + contents: '[[dirsql.extension]]\npath = "x"\n', + }, + ]); }); - it("resolves every config in order when one uses a bare package name", () => { - vi.mocked(loadExtensionEntries) - .mockReturnValueOnce(loaded("/a", [{ path: "ext/a.so" }])) - .mockReturnValueOnce(loaded("/b", [{ path: "sqlite_vec" }])); - vi.mocked(hasBareName).mockImplementation( - (entries) => entries[0]?.path === "sqlite_vec", - ); - vi.mocked(resolveEntries).mockImplementation((entries, base) => - entries.map((e) => ({ - path: `${base}/${e.path}`, - entrypoint: undefined, - })), - ); + it("leaves an unreadable config's contents undefined", () => { + vi.mocked(readConfig).mockReturnValue(undefined); + planConfigExtensions.mockReturnValue(null); - expect(resolveConfigsExtensionSpecs(["/a.toml", "/b.toml"])).toEqual([ - { path: "/a/ext/a.so", entrypoint: undefined }, - { path: "/b/sqlite_vec", entrypoint: undefined }, - ]); - expect(vi.mocked(resolveEntries).mock.calls).toEqual([ - [[{ path: "ext/a.so" }], "/a"], - [[{ path: "sqlite_vec" }], "/b"], + resolveConfigsExtensionSpecs(["/gone/.dirsql.toml"]); + + expect(planConfigExtensions).toHaveBeenCalledWith([ + { path: "/gone/.dirsql.toml", contents: undefined }, ]); }); - it("skips a missing config but resolves the rest", () => { - vi.mocked(loadExtensionEntries) - .mockReturnValueOnce(null) - .mockReturnValueOnce(loaded("/b", [{ path: "sqlite_vec" }])); - vi.mocked(hasBareName).mockReturnValue(true); - vi.mocked(resolveEntries).mockReturnValue([ - { path: "/nm/vec0.so", entrypoint: undefined }, + it("returns null when the core does not plan anything", () => { + planConfigExtensions.mockReturnValue(null); + expect(resolveConfigsExtensionSpecs(["/a/.dirsql.toml"])).toBeNull(); + expect(planEntryPath).not.toHaveBeenCalled(); + }); + + it("resolves every planned entry, in order", () => { + planConfigExtensions.mockReturnValue([ + packageEntry("sqlite_vec", "sqlite3_vec_init"), + packageEntry("spellfix"), ]); + vi.mocked(planEntryPath).mockImplementation( + (entry) => `/nm/${entry.package}.so`, + ); - expect(resolveConfigsExtensionSpecs(["/missing.toml", "/b.toml"])).toEqual([ - { path: "/nm/vec0.so", entrypoint: undefined }, + expect( + resolveConfigsExtensionSpecs(["/a/.dirsql.toml", "/b/.dirsql.toml"]), + ).toEqual([ + { path: "/nm/sqlite_vec.so", entrypoint: "sqlite3_vec_init" }, + { path: "/nm/spellfix.so", entrypoint: undefined }, ]); - expect(resolveEntries).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/ts/src/resolve-config-extensions.ts b/packages/ts/src/resolve-config-extensions.ts index eecbe71af..abf7dac58 100644 --- a/packages/ts/src/resolve-config-extensions.ts +++ b/packages/ts/src/resolve-config-extensions.ts @@ -1,17 +1,18 @@ -// SDK-side resolution of a TOML config's `[[dirsql.extension]]` entries. +// SDK-side resolution of the TOML configs' `[[dirsql.extension]]` entries. // -// The Rust core loads config extensions literally — it has no -// `require.resolve`, so it cannot resolve a bare **package name**. When a -// config names an extension by package name, the SDK resolves every entry -// here, hands the core the resolved literal paths, and suppresses the core's -// own config-extension loading so the entries are not loaded twice. +// The planning — which configs parse, whether any entry names a package rather +// than a file, and what each literal path resolves to against its own config's +// directory — lives in the Rust core (`planConfigExtensions`). This module +// supplies the two host-specific halves the core cannot have: reading the +// files, and locating an installed package with `require.resolve`. // // Shared by the `DirSQL` constructor (`config` option) and the CLI launcher. +import { resolve as resolvePath } from "node:path"; +import { getCore } from "./core.js"; import type { ExtensionSpec } from "./dirsql.js"; -import { hasBareName } from "./has-bare-name.js"; -import { loadExtensionEntries } from "./load-extension-entries.js"; -import { resolveEntries } from "./resolve-entries.js"; +import { planEntryPath } from "./plan-entry-path.js"; +import { readConfig } from "./read-config.js"; /** * Resolve the `[[dirsql.extension]]` entries of several configs, in order. @@ -27,15 +28,17 @@ import { resolveEntries } from "./resolve-entries.js"; export function resolveConfigsExtensionSpecs( configPaths: string[], ): ExtensionSpec[] | null { - const loaded = configPaths.map(loadExtensionEntries); - if (!loaded.some((item) => item !== null && hasBareName(item.entries))) { + const plan = getCore().planConfigExtensions( + configPaths.map((path) => ({ + path: resolvePath(path), + contents: readConfig(path), + })), + ); + if (plan === null) { return null; } - const specs: ExtensionSpec[] = []; - for (const item of loaded) { - if (item !== null) { - specs.push(...resolveEntries(item.entries, item.base)); - } - } - return specs; + return plan.map((entry) => ({ + path: planEntryPath(entry), + entrypoint: entry.entrypoint, + })); } diff --git a/packages/ts/src/resolve-entries.test.ts b/packages/ts/src/resolve-entries.test.ts deleted file mode 100644 index e994ebcf2..000000000 --- a/packages/ts/src/resolve-entries.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { resolveEntries } from "./resolve-entries.js"; -import { resolveExtensionPath } from "./resolve-extension.js"; - -vi.mock("./resolve-extension.js", async () => ({ - ...(await vi.importActual( - "./resolve-extension.js", - )), - resolveExtensionPath: vi.fn((path: string) => `R:${path}`), -})); - -describe("resolveEntries", () => { - it("resolves every path against the config's own directory", () => { - expect( - resolveEntries( - [{ path: "sqlite_vec" }, { path: "ext/local.so" }], - "/cfg", - ), - ).toEqual([ - { path: "R:sqlite_vec", entrypoint: undefined }, - { path: "R:ext/local.so", entrypoint: undefined }, - ]); - expect(vi.mocked(resolveExtensionPath).mock.calls).toEqual([ - ["sqlite_vec", "/cfg", true], - ["ext/local.so", "/cfg", true], - ]); - }); - - it("keeps a string entrypoint", () => { - expect( - resolveEntries([{ path: "v", entrypoint: "sqlite3_vec_init" }], "/cfg"), - ).toEqual([{ path: "R:v", entrypoint: "sqlite3_vec_init" }]); - }); - - it("normalizes a non-string entrypoint to undefined", () => { - expect(resolveEntries([{ path: "v", entrypoint: 42 }], "/cfg")).toEqual([ - { path: "R:v", entrypoint: undefined }, - ]); - }); - - it("returns an empty list for no entries", () => { - expect(resolveEntries([], "/cfg")).toEqual([]); - }); -}); diff --git a/packages/ts/src/resolve-entries.ts b/packages/ts/src/resolve-entries.ts deleted file mode 100644 index f1e06034b..000000000 --- a/packages/ts/src/resolve-entries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExtensionSpec } from "./dirsql.js"; -import type { Toml } from "./load-extension-entries.js"; -import { resolveExtensionPath } from "./resolve-extension.js"; - -/** Resolve a config's entries to specs, against that config's own directory. */ -export function resolveEntries(entries: Toml[], base: string): ExtensionSpec[] { - return entries.map((e) => ({ - path: resolveExtensionPath(e.path as string, base, true), - entrypoint: typeof e.entrypoint === "string" ? e.entrypoint : undefined, - })); -} diff --git a/packages/ts/src/resolve-extension.test.ts b/packages/ts/src/resolve-extension.test.ts index bf8801426..84d214f4c 100644 --- a/packages/ts/src/resolve-extension.test.ts +++ b/packages/ts/src/resolve-extension.test.ts @@ -1,7 +1,7 @@ import { existsSync, statSync } from "node:fs"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCore } from "./core.js"; import { defaultResolver } from "./default-resolver.js"; -import { isBareName } from "./is-bare-name.js"; import type { PackageResolver } from "./package-dir.js"; import { resolveExtensionPath } from "./resolve-extension.js"; import { resolvePackage } from "./resolve-package.js"; @@ -19,12 +19,7 @@ vi.mock("./default-resolver.js", async () => ({ defaultResolver: vi.fn(), })); -vi.mock("./is-bare-name.js", async () => ({ - ...(await vi.importActual( - "./is-bare-name.js", - )), - isBareName: vi.fn(), -})); +vi.mock("./core.js"); vi.mock("./resolve-package.js", async () => ({ ...(await vi.importActual( @@ -33,6 +28,8 @@ vi.mock("./resolve-package.js", async () => ({ resolvePackage: vi.fn(), })); +const isBareName = vi.fn<(path: string) => boolean>(); + function fakeResolver(): PackageResolver { return { resolve: vi.fn(() => { @@ -43,11 +40,15 @@ function fakeResolver(): PackageResolver { } describe("resolveExtensionPath", () => { + beforeEach(() => { + vi.mocked(getCore).mockReturnValue({ isBareName } as never); + }); + afterEach(() => vi.resetAllMocks()); describe("path-looking values", () => { it("makes a relative path absolute when resolveRelative", () => { - vi.mocked(isBareName).mockReturnValue(false); + isBareName.mockReturnValue(false); expect(resolveExtensionPath("ext/a.so", "/cfg", true)).toBe( "/cfg/ext/a.so", ); @@ -55,19 +56,19 @@ describe("resolveExtensionPath", () => { }); it("preserves an absolute path when resolveRelative", () => { - vi.mocked(isBareName).mockReturnValue(false); + isBareName.mockReturnValue(false); expect(resolveExtensionPath("/abs/a.so", "/cfg", true)).toBe("/abs/a.so"); }); it("returns a path verbatim when not resolveRelative", () => { - vi.mocked(isBareName).mockReturnValue(false); + isBareName.mockReturnValue(false); expect(resolveExtensionPath("rel/a.so", "/cfg", false)).toBe("rel/a.so"); }); }); describe("bare-name shadowing", () => { it("uses a same-named local file when present", () => { - vi.mocked(isBareName).mockReturnValue(true); + isBareName.mockReturnValue(true); vi.mocked(existsSync).mockReturnValue(true); vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType< typeof statSync @@ -77,7 +78,7 @@ describe("resolveExtensionPath", () => { }); it("falls through when the same-named local entry is not a file", () => { - vi.mocked(isBareName).mockReturnValue(true); + isBareName.mockReturnValue(true); vi.mocked(resolvePackage).mockReturnValue("/nm/pkg/vec0.so"); vi.mocked(existsSync).mockReturnValue(true); vi.mocked(statSync).mockReturnValue({ isFile: () => false } as ReturnType< @@ -91,7 +92,7 @@ describe("resolveExtensionPath", () => { describe("bare-name package resolution", () => { it("delegates to resolvePackage with the injected resolver", () => { - vi.mocked(isBareName).mockReturnValue(true); + isBareName.mockReturnValue(true); vi.mocked(resolvePackage).mockReturnValue("/nm/pkg/vec0.so"); vi.mocked(existsSync).mockReturnValue(false); const resolver = fakeResolver(); @@ -104,7 +105,7 @@ describe("resolveExtensionPath", () => { it("hands defaultResolver() to resolvePackage when none is injected", () => { const fallback = fakeResolver(); - vi.mocked(isBareName).mockReturnValue(true); + isBareName.mockReturnValue(true); vi.mocked(defaultResolver).mockReturnValue(fallback); vi.mocked(resolvePackage).mockReturnValue("/nm/pkg/vec0.so"); vi.mocked(existsSync).mockReturnValue(false); diff --git a/packages/ts/src/resolve-extension.ts b/packages/ts/src/resolve-extension.ts index 04fd562e1..abdfe8781 100644 --- a/packages/ts/src/resolve-extension.ts +++ b/packages/ts/src/resolve-extension.ts @@ -14,8 +14,8 @@ import { existsSync, statSync } from "node:fs"; import { isAbsolute, resolve as resolvePath } from "node:path"; +import { getCore } from "./core.js"; import { defaultResolver } from "./default-resolver.js"; -import { isBareName } from "./is-bare-name.js"; import type { PackageResolver } from "./package-dir.js"; import { resolvePackage } from "./resolve-package.js"; @@ -32,7 +32,7 @@ export function resolveExtensionPath( resolveRelative: boolean, resolver: PackageResolver = defaultResolver(), ): string { - if (!isBareName(path)) { + if (!getCore().isBareName(path)) { if (resolveRelative && !isAbsolute(path)) { return resolvePath(base, path); } diff --git a/packages/ts/src/resolve-package.test.ts b/packages/ts/src/resolve-package.test.ts index ac03e5cc6..b9455db4a 100644 --- a/packages/ts/src/resolve-package.test.ts +++ b/packages/ts/src/resolve-package.test.ts @@ -1,14 +1,14 @@ import { readdirSync } from "node:fs"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCore } from "./core.js"; import { type PackageResolver, packageDir } from "./package-dir.js"; -import { platformSuffixes } from "./platform-suffixes.js"; import { resolvePackage } from "./resolve-package.js"; vi.mock("node:fs", async () => ({ ...(await vi.importActual("node:fs")), readdirSync: vi.fn(), })); - +vi.mock("./core.js"); vi.mock("./package-dir.js", async () => ({ ...(await vi.importActual( "./package-dir.js", @@ -16,81 +16,54 @@ vi.mock("./package-dir.js", async () => ({ packageDir: vi.fn(), })); -vi.mock("./platform-suffixes.js", async () => ({ - ...(await vi.importActual( - "./platform-suffixes.js", - )), - platformSuffixes: vi.fn(), -})); +const selectLoadable = + vi.fn<(name: string, dirs: string[], candidates: string[]) => string>(); function fakeResolver(): PackageResolver { - return { - resolve: vi.fn(() => { - throw new Error("not resolvable"); - }), - paths: vi.fn(() => []), - }; + return { resolve: vi.fn(), paths: vi.fn(() => []) }; } -function stubDir(dir: string, entries: string[], suffixes = [".so", ".node"]) { - vi.mocked(packageDir).mockReturnValue(dir); - vi.mocked(platformSuffixes).mockReturnValue(suffixes); - vi.mocked(readdirSync).mockReturnValue( - entries as unknown as ReturnType, - ); -} +beforeEach(() => { + vi.mocked(getCore).mockReturnValue({ selectLoadable } as never); +}); -describe("resolvePackage", () => { - afterEach(() => vi.resetAllMocks()); +afterEach(() => vi.resetAllMocks()); - it("globs the platform loadable inside the package dir", () => { - stubDir("/nm/sqlite-vec", ["README.md", "vec0.so"]); +describe("resolvePackage", () => { + it("hands the core every file under the package dir", () => { + vi.mocked(packageDir).mockReturnValue("/nm/sqlite-vec"); + vi.mocked(readdirSync).mockReturnValue([ + "README.md", + "dist/vec0.so", + ] as unknown as ReturnType); + selectLoadable.mockReturnValue("/nm/sqlite-vec/dist/vec0.so"); const resolver = fakeResolver(); expect(resolvePackage("sqlite-vec", resolver)).toBe( - "/nm/sqlite-vec/vec0.so", + "/nm/sqlite-vec/dist/vec0.so", ); expect(packageDir).toHaveBeenCalledWith("sqlite-vec", resolver); expect(readdirSync).toHaveBeenCalledWith("/nm/sqlite-vec", { recursive: true, }); - }); - - it("matches any suffix the current platform globs for", () => { - stubDir("/p/x", ["README.md", "y.node"]); - expect(resolvePackage("x", fakeResolver())).toBe("/p/x/y.node"); - }); - - it("takes its suffixes from platformSuffixes, not a hardcoded list", () => { - stubDir("/p/x", ["y.so", "y.dylib"], [".dylib"]); - expect(resolvePackage("x", fakeResolver())).toBe("/p/x/y.dylib"); - }); - - it("ignores a loadable belonging to another platform", () => { - stubDir("/p/x", ["y.dylib", "y.dll"]); - expect(() => resolvePackage("x", fakeResolver())).toThrow( - "no loadable extension file (.so / .node) found in package 'x' (searched /p/x)", - ); - }); - - it("errors when no loadable file is found", () => { - stubDir("/p/x", ["README.md"]); - expect(() => resolvePackage("x", fakeResolver())).toThrow( - "no loadable extension file (.so / .node) found in package 'x' (searched /p/x)", + expect(selectLoadable).toHaveBeenCalledWith( + "sqlite-vec", + ["/nm/sqlite-vec"], + ["/nm/sqlite-vec/README.md", "/nm/sqlite-vec/dist/vec0.so"], ); }); - it("names the globbed suffixes in the not-found message", () => { - stubDir("/p/x", ["README.md"], [".dll", ".node"]); - expect(() => resolvePackage("x", fakeResolver())).toThrow( - "no loadable extension file (.dll / .node) found in package 'x' (searched /p/x)", + it("propagates the core's selection error", () => { + vi.mocked(packageDir).mockReturnValue("/p/x"); + vi.mocked(readdirSync).mockReturnValue( + [] as unknown as ReturnType, ); - }); + selectLoadable.mockImplementation(() => { + throw new Error("no loadable extension file (*.so) found in package 'x'"); + }); - it("errors when multiple loadable files are found, sorted", () => { - stubDir("/p/x", ["b.so", "a.so"]); expect(() => resolvePackage("x", fakeResolver())).toThrow( - "multiple loadable extension files found in package 'x': /p/x/a.so, /p/x/b.so; disambiguate with a literal path", + "no loadable extension file (*.so) found in package 'x'", ); }); }); diff --git a/packages/ts/src/resolve-package.ts b/packages/ts/src/resolve-package.ts index de8db5505..93d9410d5 100644 --- a/packages/ts/src/resolve-package.ts +++ b/packages/ts/src/resolve-package.ts @@ -1,32 +1,22 @@ -// Glob a bare package name's platform loadable out of its installed directory. +// Locate a bare package name's platform loadable inside its installed dir. +// +// `require.resolve` finds the package's directory; the core picks the one +// loadable file inside it (and throws on zero or several — the caller must +// then disambiguate with a literal path). import { readdirSync } from "node:fs"; import { join } from "node:path"; +import { getCore } from "./core.js"; import { type PackageResolver, packageDir } from "./package-dir.js"; -import { platformSuffixes } from "./platform-suffixes.js"; -/** Glob the platform loadable inside a bare name's package dir. */ +/** Pick the platform loadable inside a bare name's package dir. */ export function resolvePackage( name: string, resolver: PackageResolver, ): string { const dir = packageDir(name, resolver); - const suffixes = platformSuffixes(); - const matches = (readdirSync(dir, { recursive: true }) as string[]) - .filter((entry) => suffixes.some((s) => entry.endsWith(s))) - .map((entry) => join(dir, entry)) - .sort(); - - const desc = suffixes.join(" / "); - if (matches.length === 0) { - throw new Error( - `no loadable extension file (${desc}) found in package '${name}' (searched ${dir})`, - ); - } - if (matches.length > 1) { - throw new Error( - `multiple loadable extension files found in package '${name}': ${matches.join(", ")}; disambiguate with a literal path`, - ); - } - return matches[0] as string; + const candidates = (readdirSync(dir, { recursive: true }) as string[]).map( + (entry) => join(dir, entry), + ); + return getCore().selectLoadable(name, [dir], candidates); } diff --git a/packages/ts/stryker-setup-12.js b/packages/ts/stryker-setup-12.js new file mode 100644 index 000000000..aecfacbe4 --- /dev/null +++ b/packages/ts/stryker-setup-12.js @@ -0,0 +1,71 @@ +import { beforeEach, afterAll, beforeAll, afterEach, inject, } from 'vitest'; +// This file is copied to the sandbox dir, don't import anything local! +// See https://github.com/stryker-mutator/stryker-js/issues/5305 +const globalNamespace = inject('globalNamespace'); +const mutantActivation = inject('mutantActivation'); +const mode = inject('mode'); +const ns = globalThis[globalNamespace] || (globalThis[globalNamespace] = {}); +ns.hitLimit = inject('hitLimit'); +const isGreaterThanVitest4Point1 = inject('isGreaterThanVitest4Point1'); +if (mode === 'mutant') { + beforeAll(() => { + ns.hitCount = 0; + }); + if (mutantActivation === 'static') { + ns.activeMutant = inject('activeMutant'); + } + else { + beforeAll(() => { + ns.activeMutant = inject('activeMutant'); + }); + } + if (isGreaterThanVitest4Point1) { + // Vitest's hooks API requires this empty destructure to allow access to suite.meta + // eslint-disable-next-line no-empty-pattern + afterAll(({}, suite) => { + suite.meta.hitCount = ns.hitCount; + }); + } + else { + // @ts-expect-error This was changed in Vitest v4.1 + afterAll(({ meta }) => { + meta.hitCount = ns.hitCount; + }); + } +} +else { + ns.activeMutant = undefined; + beforeEach(({ task }) => { + ns.currentTestId = toRawTestId(task); + }); + afterEach(() => { + ns.currentTestId = undefined; + }); + if (isGreaterThanVitest4Point1) { + // Vitest's hooks API requires this empty destructure to allow access to suite.meta + // eslint-disable-next-line no-empty-pattern + afterAll(({}, suite) => { + suite.meta.mutantCoverage = ns.mutantCoverage; + }); + } + else { + // @ts-expect-error This was changed in Vitest v4.1 + afterAll(({ meta }) => { + meta.mutantCoverage = ns.mutantCoverage; + }); + } +} +// Stryker disable all: this file is copied to the sandbox dir +function collectTestName({ name, suite, }) { + const nameParts = [name]; + let currentSuite = suite; + while (currentSuite) { + nameParts.unshift(currentSuite.name); + currentSuite = currentSuite.suite; + } + return nameParts.join(' ').trim(); +} +function toRawTestId(test) { + return `${test.file?.filepath ?? 'unknown.js'}#${collectTestName(test)}`; +} +//# sourceMappingURL=stryker-setup.js.map \ No newline at end of file diff --git a/packages/ts/stryker-setup-13.js b/packages/ts/stryker-setup-13.js new file mode 100644 index 000000000..aecfacbe4 --- /dev/null +++ b/packages/ts/stryker-setup-13.js @@ -0,0 +1,71 @@ +import { beforeEach, afterAll, beforeAll, afterEach, inject, } from 'vitest'; +// This file is copied to the sandbox dir, don't import anything local! +// See https://github.com/stryker-mutator/stryker-js/issues/5305 +const globalNamespace = inject('globalNamespace'); +const mutantActivation = inject('mutantActivation'); +const mode = inject('mode'); +const ns = globalThis[globalNamespace] || (globalThis[globalNamespace] = {}); +ns.hitLimit = inject('hitLimit'); +const isGreaterThanVitest4Point1 = inject('isGreaterThanVitest4Point1'); +if (mode === 'mutant') { + beforeAll(() => { + ns.hitCount = 0; + }); + if (mutantActivation === 'static') { + ns.activeMutant = inject('activeMutant'); + } + else { + beforeAll(() => { + ns.activeMutant = inject('activeMutant'); + }); + } + if (isGreaterThanVitest4Point1) { + // Vitest's hooks API requires this empty destructure to allow access to suite.meta + // eslint-disable-next-line no-empty-pattern + afterAll(({}, suite) => { + suite.meta.hitCount = ns.hitCount; + }); + } + else { + // @ts-expect-error This was changed in Vitest v4.1 + afterAll(({ meta }) => { + meta.hitCount = ns.hitCount; + }); + } +} +else { + ns.activeMutant = undefined; + beforeEach(({ task }) => { + ns.currentTestId = toRawTestId(task); + }); + afterEach(() => { + ns.currentTestId = undefined; + }); + if (isGreaterThanVitest4Point1) { + // Vitest's hooks API requires this empty destructure to allow access to suite.meta + // eslint-disable-next-line no-empty-pattern + afterAll(({}, suite) => { + suite.meta.mutantCoverage = ns.mutantCoverage; + }); + } + else { + // @ts-expect-error This was changed in Vitest v4.1 + afterAll(({ meta }) => { + meta.mutantCoverage = ns.mutantCoverage; + }); + } +} +// Stryker disable all: this file is copied to the sandbox dir +function collectTestName({ name, suite, }) { + const nameParts = [name]; + let currentSuite = suite; + while (currentSuite) { + nameParts.unshift(currentSuite.name); + currentSuite = currentSuite.suite; + } + return nameParts.join(' ').trim(); +} +function toRawTestId(test) { + return `${test.file?.filepath ?? 'unknown.js'}#${collectTestName(test)}`; +} +//# sourceMappingURL=stryker-setup.js.map \ No newline at end of file diff --git a/packages/ts/tests/e2e/extension-package.test.ts b/packages/ts/tests/e2e/extension-package.test.ts index 61d3364f2..34e3538c7 100644 --- a/packages/ts/tests/e2e/extension-package.test.ts +++ b/packages/ts/tests/e2e/extension-package.test.ts @@ -215,6 +215,26 @@ function httpQuery(port: number, sql: string): Promise { }); } +interface LauncherResult { + code: number | null; + stderr: string; +} + +function runLauncher(args: string[]): Promise { + return new Promise((resolve) => { + const proc = spawn(process.execPath, [LAUNCHER, ...args], { + stdio: "pipe", + env: { ...process.env, PATH: `${SHIM_DIR}:${process.env.PATH ?? ""}` }, + }); + let stderr = ""; + proc.stderr?.setEncoding("utf8"); + proc.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + proc.on("close", (code) => resolve({ code, stderr })); + }); +} + async function runServerAndQuery(configPath: string): Promise { const port = await freePort(); // Drive the real Node launcher (not the binary directly): the launcher is @@ -273,3 +293,45 @@ describe("dirsql CLI --config: extension by package name (#299)", () => { expect(rows).toEqual([{ a: 42 }]); }, 30_000); }); + +describe("dirsql CLI --config: extension package with no loadable file", () => { + const emptyPkg = "dirsql-testext-empty-pkg"; + const emptyPkgDir = join(PKG_ROOT, "node_modules", emptyPkg); + + beforeAll(async () => { + await mkdir(emptyPkgDir, { recursive: true }); + await writeFile( + join(emptyPkgDir, "package.json"), + JSON.stringify({ name: emptyPkg, version: "0.0.0" }), + ); + await writeFile(join(emptyPkgDir, "README.md"), "no loadable here\n"); + }); + + afterAll(async () => { + await rm(emptyPkgDir, { recursive: true, force: true }); + }); + + it("reports the searched suffixes as glob patterns", async () => { + const dir = await mkdtemp(join(tmpdir(), "dirsql-ext-empty-")); + await writeFile(join(dir, "a.txt"), "x"); + const configPath = join(dir, ".dirsql.toml"); + await writeFile( + configPath, + `[[dirsql.extension]]\npath = "${emptyPkg}"\n\n[[table]]\nname = "files"\nddl = "CREATE TABLE files (path TEXT)"\nglob = "*.txt"\non-file = "cat {path}"\n`, + ); + + const { code, stderr } = await runLauncher([ + "query", + "SELECT 1", + "--config", + configPath, + ]); + + expect(code).not.toBe(0); + expect(stderr).toMatch( + new RegExp( + `no loadable extension file \\(\\*\\.[a-z]+( / \\*\\.[a-z]+)*\\) found in package '${emptyPkg}'`, + ), + ); + }, 30_000); +}); diff --git a/packages/ts/tests/integration/binding/extension-package-missing-loadable.test.ts b/packages/ts/tests/integration/binding/extension-package-missing-loadable.test.ts new file mode 100644 index 000000000..c37d84c30 --- /dev/null +++ b/packages/ts/tests/integration/binding/extension-package-missing-loadable.test.ts @@ -0,0 +1,50 @@ +// A real installed package containing no loadable file at all: the SDK must +// report which suffix patterns it looked for. The wording comes from the +// core's `SelectError`, so the patterns render as globs (`*.so`), not bare +// suffixes. Real `node_modules` layout, no mocks. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { DirSQL } from "dirsql"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const tsNodeModules = resolve( + import.meta.dirname, + "..", + "..", + "..", + "node_modules", +); + +describe("DirSQL extension package with no loadable file", () => { + // Must stay unique across test files: suites run in parallel workers and + // share this `node_modules`. + const pkgName = "dirsql-testext-empty-pkg"; + const pkgDir = join(tsNodeModules, pkgName); + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "dirsql-ext-empty-")); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name: pkgName, version: "0.0.0" }), + ); + writeFileSync(join(pkgDir, "README.md"), "no loadable here\n"); + }); + + afterEach(() => { + rmSync(pkgDir, { recursive: true, force: true }); + rmSync(tmp, { recursive: true, force: true }); + }); + + it("names the searched suffixes as glob patterns", async () => { + const db = new DirSQL({ root: tmp, extensions: [{ path: pkgName }] }); + await expect(db.ready).rejects.toThrow( + new RegExp( + `no loadable extension file \\(\\*\\.[a-z]+( / \\*\\.[a-z]+)*\\) found in package '${pkgName}' \\(searched ${pkgDir}\\)`, + ), + ); + }); +}); diff --git a/packages/ts/tests/integration/hermetic/extensions.test.ts b/packages/ts/tests/integration/hermetic/extensions.test.ts index de8c4c2bd..2908f55c7 100644 --- a/packages/ts/tests/integration/hermetic/extensions.test.ts +++ b/packages/ts/tests/integration/hermetic/extensions.test.ts @@ -1,7 +1,8 @@ // Hermetic: first-party code runs for real; the mocked boundaries are // `node:fs` probes and `node:module`'s `createRequire` (behind which both -// the napi binary and package resolution sit). Real extension *loading* is -// covered by `tests/binding/`. +// the napi binary and package resolution sit). The bare-name test and the +// config planner now live in the core, so the fake core answers for them +// here; the real ones are covered by `tests/integration/binding/`. import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { DirSQL } from "dirsql"; @@ -10,7 +11,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Fake native core module, delivered through the mocked `createRequire` so // the real `loadNativeCore()` / `getCore()` chain runs unmodified. const { fakeCore } = vi.hoisted(() => ({ - fakeCore: { DirSQL: { openAsync: vi.fn() } }, + fakeCore: { + DirSQL: { openAsync: vi.fn() }, + isBareName: vi.fn(), + selectLoadable: vi.fn(), + planConfigExtensions: vi.fn(), + }, })); vi.mock("node:fs", async (importOriginal) => ({ @@ -40,10 +46,13 @@ vi.mock("node:module", async (importOriginal) => { }); const openAsync = fakeCore.DirSQL.openAsync; +const { isBareName, selectLoadable, planConfigExtensions } = fakeCore; beforeEach(() => { // Reset (not just clear) so per-test fs implementations never leak. vi.resetAllMocks(); + isBareName.mockReturnValue(false); + planConfigExtensions.mockReturnValue(null); openAsync.mockResolvedValue({ query: vi.fn().mockResolvedValue([]), startWatcher: vi.fn().mockResolvedValue(undefined), @@ -75,12 +84,14 @@ describe("extensions option (hermetic, #230/#299)", () => { }); it("resolves a bare package name to the installed loadable", async () => { + isBareName.mockReturnValue(true); // No same-named local file shadows the package... vi.mocked(existsSync).mockReturnValue(false); - // ...and the resolved package dir contains exactly one platform loadable. + // ...and the core picks the one loadable in the resolved package dir. vi.mocked(readdirSync).mockReturnValue([ "libtestext.so", ] as unknown as ReturnType); + selectLoadable.mockReturnValue("/nm/dirsql-testext-pkg/libtestext.so"); const db = new DirSQL({ root: "/data", @@ -93,6 +104,7 @@ describe("extensions option (hermetic, #230/#299)", () => { }); it("prefers a same-named local file over the installed package", async () => { + isBareName.mockReturnValue(true); const cwd = vi.spyOn(process, "cwd").mockReturnValue("/cwd"); vi.mocked(existsSync).mockReturnValue(true); vi.mocked(statSync).mockReturnValue({ @@ -117,9 +129,13 @@ describe("extensions option (hermetic, #230/#299)", () => { vi.mocked(readFileSync).mockReturnValue( '[[dirsql.extension]]\npath = "dirsql-testext-pkg"\n', ); + planConfigExtensions.mockReturnValue([ + { package: "dirsql-testext-pkg", shadow: "/cfg/dirsql-testext-pkg" }, + ]); vi.mocked(readdirSync).mockReturnValue([ "libtestext.so", ] as unknown as ReturnType); + selectLoadable.mockReturnValue("/nm/dirsql-testext-pkg/libtestext.so"); const db = new DirSQL({ root: "/data", config: "/cfg/.dirsql.toml" }); await db.ready; @@ -131,6 +147,7 @@ describe("extensions option (hermetic, #230/#299)", () => { }); it("rejects ready when a bare package name is not installed", async () => { + isBareName.mockReturnValue(true); vi.mocked(existsSync).mockReturnValue(false); const db = new DirSQL({ diff --git a/packages/ts/tests/integration/hermetic/index.test.ts b/packages/ts/tests/integration/hermetic/index.test.ts index 833ebef67..8365db839 100644 --- a/packages/ts/tests/integration/hermetic/index.test.ts +++ b/packages/ts/tests/integration/hermetic/index.test.ts @@ -12,6 +12,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { fakeCore } = vi.hoisted(() => ({ fakeCore: { DirSQL: { openAsync: vi.fn() }, + // The config planner lives in the core; hermetic tests never exercise a + // config's extension entries, so it plans nothing. + planConfigExtensions: vi.fn(() => null), }, }));