diff --git a/docs/python/resource/new.mdx b/docs/python/resource/new.mdx index 943a48814..8eade9b8d 100644 --- a/docs/python/resource/new.mdx +++ b/docs/python/resource/new.mdx @@ -64,7 +64,19 @@ or ship it as a normal package with an entry point — discovered automatically jira = "mypackage.backends:JiraResource" ``` -The entry point resolves to the resource class; declare a `CONFIG_CLS` class attribute when the constructor takes a typed config. See `examples/python/other/custom_resource.py` for a complete runnable backend in one file. +The entry point resolves to the resource class; declare a `CONFIG_CLS` class attribute when the constructor takes a typed config. + +Neither step is needed to mount from YAML. A `resource` value carrying a colon names the class directly, the same way a `clis` entry's `cli` value names a spec tree, so a deployment can point at a file next to the config or at a class inside an installed package: + +```yaml +mounts: + /jira: + resource: ./jira.py:JiraResource + /wiki: + resource: mypackage.backends:WikiResource +``` + +A relative path resolves against the config file's directory, not the server's working directory. A registry name always wins over a reference, so a name can never be reread as code. See `examples/python/other/custom_resource.py` for a complete runnable backend in one file. ## Contribute a Builtin Resource diff --git a/docs/typescript/resource/new.mdx b/docs/typescript/resource/new.mdx index e87336c80..b9abd5291 100644 --- a/docs/typescript/resource/new.mdx +++ b/docs/typescript/resource/new.mdx @@ -74,6 +74,18 @@ registerResourceFactory('jira', (config) => Promise.resolve(new JiraResource(con The registry takes a factory rather than a class because a browser backend is often reached through a dynamic import; `buildResource('jira', config)` then works exactly as it does for a builtin. +Registering is not needed to mount from config. A `resource` value carrying a colon names the class directly, the same way a `clis` entry's `cli` value names a spec tree, so a deployment can point at a file next to the config or at a class inside an installed package: + +```yaml +mounts: + /jira: + resource: ./jira.mjs:JiraResource + /wiki: + resource: my-backends:WikiResource +``` + +A relative path resolves against the config file's directory, not the server's working directory; a bare specifier is Node's to resolve, so a package name is left alone. A registry name always wins over a reference, so a name can never be reread as code. A `static async create` is honored ahead of the constructor, which is how a backend whose setup needs I/O is spelled here. + Snapshots are the one place the registry does not reach: `Workspace.load` builds its mounts before any factory is consulted, so a saved custom mount has to be handed back explicitly, as `Workspace.load(state, { resources: { '/jira/': new JiraResource(cfg) } })`. `GenericResource` says so in its own state, which is what turns a forgotten override into a refusal to load rather than a mount that comes back empty. `Workspace.copy` needs nothing, since it passes the live resource through. See `examples/typescript/other/custom_resource.ts` for a complete runnable backend in one file, and `examples/python/other/custom_resource.py` for its Python twin. Both are asserted against the same truth file, so the two SDKs cannot drift. diff --git a/python/mirage/config.py b/python/mirage/config.py index f690a7a3e..56b9538ac 100644 --- a/python/mirage/config.py +++ b/python/mirage/config.py @@ -394,7 +394,12 @@ def _absolutize_scripts(raw: dict[str, Any], base: Path) -> None: for block in clis.values(): if isinstance(block, dict): _absolutize_script_key(block, base) - _absolutize_cli_ref(block, base) + _absolutize_code_ref(block, "cli", base) + mounts = raw.get("mounts") + if isinstance(mounts, dict): + for block in mounts.values(): + if isinstance(block, dict): + _absolutize_code_ref(block, "resource", base) def _absolutize_script_key(entry: dict[str, Any], base: Path) -> None: @@ -411,23 +416,25 @@ def _absolutize_script_key(entry: dict[str, Any], base: Path) -> None: entry["script"] = str(base / script.strip()) -def _absolutize_cli_ref(entry: dict[str, Any], base: Path) -> None: - """Rebase one ``clis`` entry's path-form ``cli`` reference. +def _absolutize_code_ref(entry: dict[str, Any], key: str, base: Path) -> None: + """Rebase a path-form colon reference under ``key``. - ``cli: ./tool.py:TREE`` means "next to the config file", the same - build-context rule ``script:`` follows; without this the pointer - reaches ``load_attr`` relative and resolves against the server - process's cwd. A module dotpath (``pkg.mod:TREE``) is left alone: - importlib resolves it, not the filesystem. The split matches - ``load_attr``'s own test, so the two cannot disagree about what a - path is. + ``cli: ./tool.py:TREE`` and ``resource: ./wiki.py:WikiResource`` both + mean "next to the config file", the same build-context rule + ``script:`` follows; without this the pointer reaches ``load_attr`` + relative and resolves against the server process's cwd. A module + dotpath (``pkg.mod:TREE``) is left alone: importlib resolves it, not + the filesystem. The split matches ``load_attr``'s own test, so the + two cannot disagree about what a path is. Args: - entry (dict[str, Any]): a ``clis`` mapping entry, mutated in - place. + entry (dict[str, Any]): a ``clis`` or ``mounts`` mapping entry, + mutated in place. + key (str): the field holding the reference, ``cli`` or + ``resource``. base (Path): directory containing the config file. """ - ref = entry.get("cli") + ref = entry.get(key) if not isinstance(ref, str) or ":" not in ref: return source, attr = ref.rsplit(":", 1) @@ -435,7 +442,7 @@ def _absolutize_cli_ref(entry: dict[str, Any], base: Path) -> None: return if Path(source).is_absolute(): return - entry["cli"] = f"{base / source}:{attr}" + entry[key] = f"{base / source}:{attr}" def _build_runtime_entries( diff --git a/python/mirage/resource/registry.py b/python/mirage/resource/registry.py index f3526f223..ccdf8ef28 100644 --- a/python/mirage/resource/registry.py +++ b/python/mirage/resource/registry.py @@ -258,6 +258,34 @@ def resolve_class(ref: str | type) -> type: return ref if isinstance(ref, type) else load_backend_class(ref) +def _resolve_entry(name: str) -> ResourceEntry | None: + """Find the entry a mount's ``resource`` value names, or None. + + Four rungs, in the order ``commands.cli.specs.cli_spec_for`` uses for + a ``cli`` value, because the two are the same question asked of two + tiers: builtin, explicitly registered, a colon reference naming code + directly, then ``mirage.resources`` entry points. + + The colon rung needs no loader of its own. ``resolve_class`` already + reads a ``"source:ClassName"`` string, so the reference becomes an + ordinary entry with no config class, which means an out-of-tree class + carrying ``CONFIG_CLS`` gets its typed config built exactly as a + builtin's does. It is tried before the entry points because a colon + is unambiguous: the value names code, so there is nothing to discover + and no reason to pay for a scan of every installed package. + + Args: + name (str): the mount's ``resource`` value. + """ + entry = REGISTRY.get(name) or _CUSTOM.get(name) + if entry is not None: + return entry + if ":" in name: + return ResourceEntry(name, None) + _load_entry_point_resources() + return _CUSTOM.get(name) + + def build_resource(name: str, config: dict[str, Any] | None = None) -> "BaseResource": """Construct a resource instance by its registry name. @@ -266,7 +294,10 @@ def build_resource(name: str, importing this module does not pull in every resource's dependencies. Only the resources actually used get loaded. Lookup order: builtin ``REGISTRY``, then :func:`register_resource` names, - then ``mirage.resources`` entry points from installed packages. + then a colon reference naming a class directly + (``./wiki.py:WikiResource`` or ``mypkg.backends:WikiResource``), then + ``mirage.resources`` entry points from installed packages. See + :func:`_resolve_entry`. **Synchronous on purpose. Do not make this async.** It is the door every caller who describes a mount as data comes through: the YAML @@ -289,7 +320,8 @@ def build_resource(name: str, ``static async create``. Args: - name (str): registry key such as ``"s3"`` or ``"ram"``. + name (str): registry key such as ``"s3"`` or ``"ram"``, or a + colon reference such as ``"./wiki.py:WikiResource"``. config (dict | None): kwargs for the resource's ``Config`` class when one exists; otherwise raw resource kwargs (e.g. ``{"root": "/tmp"}`` for ``"disk"``). @@ -298,13 +330,10 @@ def build_resource(name: str, BaseResource: a fresh resource instance. Raises: - KeyError: ``name`` is neither builtin, registered, nor - installed. + KeyError: ``name`` is neither builtin, registered, a colon + reference, nor installed. """ - entry = REGISTRY.get(name) - if entry is None: - _load_entry_point_resources() - entry = _CUSTOM.get(name) + entry = _resolve_entry(name) if entry is None: raise KeyError( f"unknown resource {name!r}; known: {known_resources()}") diff --git a/python/tests/config/test_loader.py b/python/tests/config/test_loader.py index b8e04b500..f436fddce 100644 --- a/python/tests/config/test_loader.py +++ b/python/tests/config/test_loader.py @@ -684,6 +684,56 @@ clis: assert ref == f"{tmp_path / 'tool.py'}:TREE" +@pytest.mark.asyncio +async def test_mounts_path_form_resource_rebases_on_the_config_dir( + tmp_path, monkeypatch): + # `resource: ./wiki.py:WikiResource` reads the same way `cli:` does, + # so it follows the same build-context rule. + (tmp_path / "wiki.py").write_text("""\ +from mirage.resource.ram.ram import RAMResource + + +class WikiResource(RAMResource): + pass +""") + cfg_file = tmp_path / "ws.yaml" + cfg_file.write_text("""\ +mounts: + /wiki: + resource: ./wiki.py:WikiResource +""") + monkeypatch.chdir(tmp_path.parent) + cfg = load_config(cfg_file) + assert cfg.mounts[ + "/wiki"].resource == f"{tmp_path / 'wiki.py'}:WikiResource" + mount = cfg.to_workspace_kwargs()["resources"]["/wiki"] + assert type(mount.resource).__name__ == "WikiResource" + + +@pytest.mark.asyncio +async def test_mounts_module_dotpath_resource_is_left_alone(tmp_path): + cfg_file = tmp_path / "ws.yaml" + cfg_file.write_text("""\ +mounts: + /wiki: + resource: mypkg.backends:WikiResource +""") + cfg = load_config(cfg_file) + assert cfg.mounts["/wiki"].resource == "mypkg.backends:WikiResource" + + +@pytest.mark.asyncio +async def test_mounts_registry_name_is_left_alone(tmp_path): + cfg_file = tmp_path / "ws.yaml" + cfg_file.write_text("""\ +mounts: + /data: + resource: ram +""") + cfg = load_config(cfg_file) + assert cfg.mounts["/data"].resource == "ram" + + @pytest.mark.asyncio async def test_clis_module_dotpath_reference_is_left_alone(tmp_path): # importlib resolves a dotpath, not the filesystem, so rebasing it diff --git a/python/tests/resource/test_registry.py b/python/tests/resource/test_registry.py index 7d4a5d735..79df88983 100644 --- a/python/tests/resource/test_registry.py +++ b/python/tests/resource/test_registry.py @@ -206,6 +206,32 @@ def test_register_resource_spec_string(clean_registry): assert built.root == "/spec" +def test_colon_reference_builds_without_a_registry(clean_registry): + # A colon means the value names code, so it resolves with nothing + # registered and no entry points scanned. + built = build_resource("tests.resource.test_registry:FakeKwargsResource", + {"root": "/ref"}) + assert isinstance(built, FakeKwargsResource) + assert built.root == "/ref" + + +def test_colon_reference_uses_the_config_cls_attribute(clean_registry): + built = build_resource( + "tests.resource.test_registry:FakeConfigClsResource", + {"url": "http://ref"}) + assert built.config.url == "http://ref" + + +def test_colon_reference_does_not_shadow_a_builtin_name(clean_registry): + # A registry name always wins, so a name can never be reread as code. + assert type(build_resource("ram")).__name__ == "RAMResource" + + +def test_colon_reference_to_a_missing_attribute_raises(clean_registry): + with pytest.raises(ValueError): + build_resource("tests.resource.test_registry:NoSuchResource") + + def test_known_resources_includes_custom(clean_registry): register_resource("fake_custom", FakeCustomResource, FakeCustomConfig) names = known_resources() diff --git a/typescript/packages/node/src/config.test.ts b/typescript/packages/node/src/config.test.ts index b5b8e8eab..8a5bc5763 100644 --- a/typescript/packages/node/src/config.test.ts +++ b/typescript/packages/node/src/config.test.ts @@ -724,6 +724,63 @@ describe('clis section', () => { }) }) +// Mirrors python/tests/config/test_loader.py's mounts `resource:` cases. +// A `resource` value carrying a colon names a class the same way `cli:` +// names a spec, which is what lets a deployment mount its own backend +// from YAML without registering a factory in a host program. +describe('mounts resource: reference', () => { + const CORE_RES = pathToFileURL( + resolve(fileURLToPath(import.meta.url), '../../../core/dist/index.js'), + ).href + const BACKEND = + `import {RAMResource} from ${JSON.stringify(CORE_RES)}\n` + + 'export class WikiResource extends RAMResource {}\n' + + it('builds a resource out of a file next to the config', async () => { + const dir = mkdtempSync(join(tmpdir(), 'mirage-res-')) + writeFileSync(join(dir, 'wiki.mjs'), BACKEND) + writeFileSync( + join(dir, 'ws.yaml'), + 'mounts:\n /wiki:\n resource: ./wiki.mjs:WikiResource\n', + ) + const cfg = loadWorkspaceConfigFile(join(dir, 'ws.yaml')) + const args = await configToWorkspaceArgs(cfg) + expect(args.resources['/wiki']?.[0]?.constructor.name).toBe('WikiResource') + rmSync(dir, { recursive: true, force: true }) + }) + + it('rebases a relative ref onto the config file directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'mirage-res-')) + writeFileSync(join(dir, 'wiki.mjs'), BACKEND) + writeFileSync( + join(dir, 'ws.yaml'), + 'mounts:\n /wiki:\n resource: ./wiki.mjs:WikiResource\n', + ) + const cfg = loadWorkspaceConfigFile(join(dir, 'ws.yaml')) + expect(cfg.mounts['/wiki']?.resource).toBe(`${join(dir, 'wiki.mjs')}:WikiResource`) + rmSync(dir, { recursive: true, force: true }) + }) + + it('leaves a package specifier alone', () => { + const dir = mkdtempSync(join(tmpdir(), 'mirage-res-')) + writeFileSync( + join(dir, 'ws.yaml'), + 'mounts:\n /wiki:\n resource: my-backends:WikiResource\n', + ) + const cfg = loadWorkspaceConfigFile(join(dir, 'ws.yaml')) + expect(cfg.mounts['/wiki']?.resource).toBe('my-backends:WikiResource') + rmSync(dir, { recursive: true, force: true }) + }) + + it('leaves a registry name alone', () => { + const dir = mkdtempSync(join(tmpdir(), 'mirage-res-')) + writeFileSync(join(dir, 'ws.yaml'), 'mounts:\n /data:\n resource: ram\n') + const cfg = loadWorkspaceConfigFile(join(dir, 'ws.yaml')) + expect(cfg.mounts['/data']?.resource).toBe('ram') + rmSync(dir, { recursive: true, force: true }) + }) +}) + // Mirrors python/tests/config/test_loader.py's `cli: ./tool.py:TREE` // cases. A `cli` value carrying a colon points at code rather than // naming a registered spec, which is what lets a deployment install its diff --git a/typescript/packages/node/src/config.ts b/typescript/packages/node/src/config.ts index 5c74bc02c..11f0a2728 100644 --- a/typescript/packages/node/src/config.ts +++ b/typescript/packages/node/src/config.ts @@ -671,7 +671,12 @@ function absolutizeScripts(raw: Record, base: string): void { for (const block of Object.values(raw.clis)) { if (!isPlainObject(block)) continue absolutizeScriptKey(block, base) - absolutizeCliRef(block, base) + absolutizeCodeRef(block, 'cli', base) + } + } + if (isPlainObject(raw.mounts)) { + for (const block of Object.values(raw.mounts)) { + if (isPlainObject(block)) absolutizeCodeRef(block, 'resource', base) } } } @@ -685,21 +690,22 @@ function absolutizeScriptKey(entry: Record, base: string): void } /** - * Rebase one `clis` entry's path-form `cli` reference onto `base`. + * Rebase a path-form colon reference under `key` onto `base`. * - * `cli: ./tool.mjs:TREE` means "next to the config file", the same - * build-context rule `script:` follows; without this the pointer reaches - * `loadAttr` relative and resolves against the server process's cwd. A - * package specifier (`my-clis:JIRA`) is left alone: Node resolves it, - * not the filesystem. The split is `splitRef`/`isModulePath`, the same - * pair `loadAttr` uses, so the two cannot disagree about what a path is. + * `cli: ./tool.mjs:TREE` and `resource: ./wiki.mjs:WikiResource` both + * mean "next to the config file", the same build-context rule `script:` + * follows; without this the pointer reaches `loadAttr` relative and + * resolves against the server process's cwd. A package specifier + * (`my-clis:JIRA`) is left alone: Node resolves it, not the filesystem. + * The split is `splitRef`/`isModulePath`, the same pair `loadAttr` uses, + * so the two cannot disagree about what a path is. */ -function absolutizeCliRef(entry: Record, base: string): void { - const ref = entry.cli +function absolutizeCodeRef(entry: Record, key: string, base: string): void { + const ref = entry[key] if (typeof ref !== 'string' || !ref.includes(':')) return const [source, attr] = splitRef(ref) if (!isModulePath(source) || isAbsolute(source)) return - entry.cli = `${join(base, source)}:${attr}` + entry[key] = `${join(base, source)}:${attr}` } /** @@ -966,12 +972,14 @@ async function buildCliEntries( if (block.config !== undefined && !isPlainObject(block.config)) { throw new Error(`clis entry '${name}': config must be a mapping`) } - // A `cli` value carrying a colon points at code the way `resource:` - // never does: `./tool.mjs:TALLY` (a file) or `my-clis:JIRA` (a - // package specifier). A bare name stays a name for the workspace to - // resolve against the registered specs. Mirrors the `":" in name` - // branch of Python's `cli_spec_for`, one layer up: `cliSpecFor` - // lives in core, which has no filesystem and is synchronous. + // A `cli` value carrying a colon points at code: `./tool.mjs:TALLY` + // (a file) or `my-clis:JIRA` (a package specifier). A bare name stays + // a name for the workspace to resolve against the registered specs. + // Mirrors the `":" in name` branch of Python's `cli_spec_for`, one + // layer up: `cliSpecFor` lives in core, which has no filesystem and + // is synchronous. A `resource:` value reads the same way, resolved by + // `buildResource` rather than here, because a mount block reaches the + // registry and a `clis` block does not. const entry = hasScript ? new CLISpec({ name, diff --git a/typescript/packages/node/src/resource/registry.test.ts b/typescript/packages/node/src/resource/registry.test.ts index 850669233..38c9500ac 100644 --- a/typescript/packages/node/src/resource/registry.test.ts +++ b/typescript/packages/node/src/resource/registry.test.ts @@ -12,9 +12,10 @@ // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { describe, expect, it } from 'vitest' import { normalizeOneDriveConfig } from '@struktoai/mirage-core/accessor/onedrive' import { tokenUrl } from '@struktoai/mirage-core/core/google/client' @@ -315,3 +316,76 @@ describe('ResourceName coverage', () => { expect(BUILTIN_RESOURCES.filter((n) => !names.has(n))).toEqual([]) }) }) + +// A colon reference names a class rather than a registry key, so it is +// exercised through a real file on disk: that is the path a deployment +// takes, and it is the only way the loader's own module resolution is +// covered. The fixture is `.mjs` because Node strips types without +// compiling them, so a `.ts` fixture using a parameter property would be +// refused at load. +describe('buildResource colon reference', () => { + const CORE = pathToFileURL( + resolve(fileURLToPath(import.meta.url), '../../../../core/dist/index.js'), + ).href + const BACKEND = + `import {RAMResource} from ${JSON.stringify(CORE)}\n` + + 'export class WikiResource extends RAMResource {\n' + + ' constructor(config) { super(); this.config = config }\n' + + '}\n' + + 'export class LateResource extends RAMResource {\n' + + ' static async create(config) { const r = new LateResource(); r.config = config; return r }\n' + + '}\n' + + 'export class NotAResource {}\n' + + 'export const NOT_A_CLASS = {name: "wiki"}\n' + + function fixture(): string { + const dir = mkdtempSync(join(tmpdir(), 'mirage-resref-')) + writeFileSync(join(dir, 'wiki.mjs'), BACKEND) + return dir + } + + it('builds from the class the ref names', async () => { + const dir = fixture() + const built = await buildResource(`${join(dir, 'wiki.mjs')}:WikiResource`, { root: '/ref' }) + expect(built.constructor.name).toBe('WikiResource') + expect((built as unknown as { config: unknown }).config).toEqual({ root: '/ref' }) + await built.close() + rmSync(dir, { recursive: true, force: true }) + }) + + it('prefers a static create over the constructor', async () => { + const dir = fixture() + const built = await buildResource(`${join(dir, 'wiki.mjs')}:LateResource`, { root: '/late' }) + expect(built.constructor.name).toBe('LateResource') + expect((built as unknown as { config: unknown }).config).toEqual({ root: '/late' }) + await built.close() + rmSync(dir, { recursive: true, force: true }) + }) + + it('never lets a ref shadow a registry name', async () => { + // A registry name always wins, so a name cannot be reread as code. + const built = await buildResource('ram') + expect(built.constructor.name).toBe('RAMResource') + await built.close() + }) + + it('refuses a ref that names something other than a class', async () => { + const dir = fixture() + await expect(buildResource(`${join(dir, 'wiki.mjs')}:NOT_A_CLASS`)).rejects.toThrow( + 'must name a class', + ) + rmSync(dir, { recursive: true, force: true }) + }) + + it('refuses a class that does not build a resource', async () => { + const dir = fixture() + await expect(buildResource(`${join(dir, 'wiki.mjs')}:NotAResource`)).rejects.toThrow( + 'did not build a resource', + ) + rmSync(dir, { recursive: true, force: true }) + }) + + it('still reports an unknown bare name as unknown', async () => { + await expect(buildResource('nope')).rejects.toThrow('unknown resource') + }) +}) diff --git a/typescript/packages/node/src/resource/registry.ts b/typescript/packages/node/src/resource/registry.ts index 04d566dac..4e404a9cb 100644 --- a/typescript/packages/node/src/resource/registry.ts +++ b/typescript/packages/node/src/resource/registry.ts @@ -19,6 +19,7 @@ import type { LanceDBConfig } from '@struktoai/mirage-core/resource/lancedb/conf import type { QdrantConfig } from '@struktoai/mirage-core/resource/qdrant/config' import { normalizeFields } from '@struktoai/mirage-core/utils/normalize' import { compareCodePoints } from '@struktoai/mirage-core/utils/sort' +import { loadAttr } from './loader.ts' /** * Construct a resource by registry name. Mirrors Python's @@ -322,18 +323,63 @@ export function register(name: string, factory: ResourceFactory): void { } /** - * Build a resource instance by registry name. Builtins win over custom - * registrations. Throws if the name is unknown. + * True when a loaded export answers the calls every mount makes on a + * resource. + * + * The check is `open`/`close`, the two the workspace calls on every mount + * whatever the backend is, so a value that passes cannot fail on the + * lifecycle. It exists because a colon reference loads whatever the file + * exports: without it a typo'd export name reaches `installMounts` and + * fails there, naming a frame the author never wrote. + */ +function looksLikeResource(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false + const node = value as Record + return typeof node.open === 'function' && typeof node.close === 'function' +} + +/** + * Build a resource from a colon reference naming a class directly. + * + * `static create` is honored ahead of the constructor because that is + * how a backend whose setup needs I/O is spelled here (github and + * databricks_volume both do), and it is the one thing this tier has that + * Python's does not: `build_resource` is synchronous there, so an + * out-of-tree Python class hydrates lazily instead. + */ +async function buildFromRef(ref: string, config: Record): Promise { + const exported = await loadAttr(ref) + if (typeof exported !== 'function') { + throw new Error(`resource ref ${JSON.stringify(ref)} must name a class, got ${typeof exported}`) + } + const cls = exported as { + create?: (config: Record) => unknown + new (config: Record): unknown + } + const built = await (typeof cls.create === 'function' ? cls.create(config) : new cls(config)) + if (!looksLikeResource(built)) { + throw new Error(`resource ref ${JSON.stringify(ref)} did not build a resource`) + } + return built as Resource +} + +/** + * Build a resource instance by registry name, or from a colon reference + * naming a class directly (`./wiki.mjs:WikiResource`, or the package + * specifier `my-pkg/backends:WikiResource`). + * + * Builtins win over custom registrations, and both win over a reference, + * so a name can never be reinterpreted as code. Throws if the name is + * neither. Mirrors the ladder in Python's `_resolve_entry`, minus its + * entry-point rung: Node has no equivalent of `importlib.metadata`, so a + * package ships a resource here by exporting it and being named. */ export async function buildResource( name: string, config: Record = {}, ): Promise { const factory = REGISTRY[name] ?? CUSTOM[name] - if (factory === undefined) { - throw new Error( - `unknown resource ${JSON.stringify(name)}; known: ${knownResources().join(', ')}`, - ) - } - return factory(config) + if (factory !== undefined) return factory(config) + if (name.includes(':')) return buildFromRef(name, config) + throw new Error(`unknown resource ${JSON.stringify(name)}; known: ${knownResources().join(', ')}`) }