feat(resource): assemble a TypeScript backend from one CommandIO table
python's GenericResource wires the whole generic command set, the glob resolver and the VFS/FUSE ops from one table, and TypeScript had every ingredient but no class that assembled them, so a custom backend there was still written out by hand. Adds GenericResource<A>, generic over the accessor so the table is checked against the core functions it holds rather than against Accessor. It keeps TypeScript's own wiring style: commands() and ops() return arrays instead of python's register() loop mutating state in the constructor. And there is no sdk.ts, because core's barrel is 99 lines and gated in both directions while the ./* exports map already makes every module importable, so the five names the new example and doc reach for are the only additions to it. Both classes gained sizes_always_known and supports_snapshot. Without the first, no user-written backend could be mounted on FSKit at all, since resolve_backend refuses a resource that cannot size its files. The two one-file examples answer one shared truth file, so the two SDKs cannot drift without a red build. docs/typescript/resource/new.mdx is the twin of the python page, whose stale du_total/du_all is corrected to du. The layout baseline drops to 255: resource/generic was one of the counted divergences.
This commit is contained in:
@@ -125,6 +125,7 @@ jobs:
|
||||
run integ/truth/watch_delta.json
|
||||
run integ/truth/permissions.json
|
||||
run integ/truth/python/custom_command.json
|
||||
run integ/truth/custom_resource.json
|
||||
run integ/truth/python/filetype.json
|
||||
run integ/truth/python/redis.json
|
||||
run integ/truth/python/redis_index.json
|
||||
|
||||
@@ -111,6 +111,7 @@ jobs:
|
||||
run integ/truth/watch_delta.json
|
||||
run integ/truth/permissions.json
|
||||
run integ/truth/typescript/custom_command.json
|
||||
run integ/truth/custom_resource.json
|
||||
run integ/truth/typescript/filetype.json
|
||||
run integ/truth/typescript/pyodide_basic.json
|
||||
run integ/truth/typescript/pyodide_env.json
|
||||
|
||||
+2
-1
@@ -581,7 +581,8 @@
|
||||
"pages": [
|
||||
"typescript/setup/trello"
|
||||
]
|
||||
}
|
||||
},
|
||||
"typescript/resource/new"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ def make_jira_resource(config) -> GenericResource:
|
||||
|
||||
Mount it like any builtin: `Workspace({"/jira/": make_jira_resource(cfg)})`.
|
||||
|
||||
The escape hatches mirror what builtins use: optional `CommandIO` fields unlock more surface (`write` enables the byte-mutation family; `find`/`du_total`/`du_all` become native fast paths), `overrides=` suppresses a generic command you replace, and `commands=[...]` adds bespoke `@command` verbs.
|
||||
The escape hatches mirror what builtins use: optional `CommandIO` fields unlock more surface (`write` enables the byte-mutation family; `find` and `du` become native fast paths), `overrides=` suppresses a generic command you replace, and `commands=[...]` adds bespoke `@command` verbs.
|
||||
|
||||
VFS/FUSE ops are derived from the same table automatically (`make_generic_ops` under the hood): read/readdir/stat plus whatever mutations the table carries. Pass `ops=[...]` only for irregular handlers (they shadow same-named derived ops), or `auto_ops=False` to opt out.
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Adding a New Resource
|
||||
icon: plus
|
||||
description: Ship your own backend with GenericResource, or contribute a builtin resource using Mirage's VFS, command, and snapshot conventions.
|
||||
---
|
||||
|
||||
A resource maps an external system to Mirage's filesystem operations and shell commands. There are two paths:
|
||||
|
||||
- **Ship your own backend** — a single TypeScript file in your own project or package, built on `GenericResource`. No Mirage fork, no edits to Mirage source.
|
||||
- **Contribute a builtin** — the four-layer layout inside the Mirage repo, mirrored in Python.
|
||||
|
||||
## Ship Your Own Backend
|
||||
|
||||
Write the core functions over your data source, put them on a `CommandIO` table, and `GenericResource` wires the full generic command set (`ls`, `cat`, `grep`, `find`, `head`, `wc`, ...) plus glob resolution and the VFS/FUSE ops:
|
||||
|
||||
```ts
|
||||
import {
|
||||
Accessor,
|
||||
type CommandIO,
|
||||
FileStat,
|
||||
GenericResource,
|
||||
MountMode,
|
||||
type PathSpec,
|
||||
streamFromBytes,
|
||||
Workspace,
|
||||
} from '@struktoai/mirage-node'
|
||||
|
||||
class JiraAccessor extends Accessor {
|
||||
constructor(readonly client: JiraClient) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
declare function readdir(accessor: JiraAccessor, path: PathSpec): Promise<string[]>
|
||||
declare function readBytes(accessor: JiraAccessor, path: PathSpec): Promise<Uint8Array>
|
||||
declare function stat(accessor: JiraAccessor, path: PathSpec): Promise<FileStat>
|
||||
|
||||
class JiraResource extends GenericResource<JiraAccessor> {
|
||||
constructor(config: JiraConfig) {
|
||||
super({
|
||||
name: 'jira',
|
||||
accessor: new JiraAccessor(makeClient(config)),
|
||||
io: {
|
||||
readdir,
|
||||
readBytes,
|
||||
readStream: (a, p, i) => streamFromBytes(readBytes, a, p, i),
|
||||
stat,
|
||||
isMounted: () => true,
|
||||
local: false,
|
||||
},
|
||||
prompt: 'Issues rendered as .json files.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new Workspace({ '/jira/': new JiraResource(cfg) }, { mode: MountMode.READ })
|
||||
```
|
||||
|
||||
The accessor is a type parameter, so the table is checked against the accessor your core functions actually take: wiring `readdir` where `stat` belongs, or an accessor from another backend, is a compile error rather than a runtime one.
|
||||
|
||||
Only four table fields are required. The optional ones unlock more surface: `write` enables the byte-mutation family, `find` and `du` become native fast paths, and a command whose requirements the table cannot meet is never registered rather than registered and broken. The escape hatches are the ones the builtins use, because `GenericResource` assembles exactly what they assemble by hand:
|
||||
|
||||
- `overrides` drops a generic command you replace, and `commands` supplies the replacement (or any bespoke verb) from `command({...})`.
|
||||
- `ops` layers an irregular VFS/FUSE handler over the derived set; one carrying no `filetype` shadows the derived op of the same name. `autoOps: false` opts out of deriving any.
|
||||
- `sizesAlwaysKnown` declares that `stat` sizes every file without fetching it, which is also what makes the mount legal on FSKit. `supportsSnapshot` declares that `stat` fills `FileStat.fingerprint`; setting it without that is not drift detection.
|
||||
|
||||
To make the backend constructible by name (workspace config, snapshots, the daemon), register a factory:
|
||||
|
||||
```ts
|
||||
import { registerResourceFactory } from '@struktoai/mirage-node'
|
||||
|
||||
registerResourceFactory('jira', (config) => Promise.resolve(new JiraResource(config as JiraConfig)))
|
||||
```
|
||||
|
||||
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. 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.
|
||||
|
||||
## Contribute a Builtin Resource
|
||||
|
||||
Builtins live inside the Mirage repo with the four-layer layout below. Keep the core I/O layer independent from command parsing, and check whether the same resource or behavior should also be added to Python.
|
||||
|
||||
Pick the package by runtime, not by preference: `packages/core` for a backend that works in both the browser and Node, `packages/node` for one that needs Node APIs, `packages/browser` for one that needs a browser transport. Use a recent resource such as Qdrant or LanceDB as the structural reference. Paths are always `PathSpec` values inside the VFS; do not pass filesystem paths as raw strings.
|
||||
|
||||
## File Structure
|
||||
|
||||
```text
|
||||
typescript/packages/<core|node|browser>/src/
|
||||
resource/<name>/
|
||||
config.ts
|
||||
prompt.ts
|
||||
<name>.ts
|
||||
accessor/<name>.ts
|
||||
core/<name>/
|
||||
read.ts
|
||||
readdir.ts
|
||||
stat.ts
|
||||
ops/<name>/
|
||||
index.ts
|
||||
commands/builtin/<name>/
|
||||
index.ts
|
||||
io.ts
|
||||
<resource-specific commands>.ts
|
||||
```
|
||||
|
||||
Tests are colocated: `<name>.test.ts` beside the source it covers.
|
||||
|
||||
## 1. Config, Accessor, and Registry
|
||||
|
||||
Define the config as an interface plus a `resolve<Name>Config` that fills every default, and keep secrets out of anything that gets serialized. Create an `Accessor` subclass that owns the client or transport. Add the resource name to `ResourceName`, and add a factory to the runtime package's `resource/registry.ts` — that entry is what workspace config, snapshots, and the daemon construct through.
|
||||
|
||||
Config keys arrive snake_case from YAML shared with Python and are mapped by `normalizeFields`, which already sends every unlisted key through `snakeToCamel`. Add a rename entry only for a key that mapping gets wrong.
|
||||
|
||||
Keep every import at module scope. If that would create a cycle, change the dependency direction instead of adding a lazy import inside a function.
|
||||
|
||||
## 2. Core VFS Operations
|
||||
|
||||
Implement only the operations the backend supports. A read-only API resource usually starts with:
|
||||
|
||||
- `readdir(accessor, path, index?)` returning child paths.
|
||||
- `read(accessor, path, index?)` returning bytes.
|
||||
- `stat(accessor, path, index?)` returning a `FileStat`.
|
||||
|
||||
`FileStat.size` must be the rendered content's byte length or `null`, never a storage-side number: a confidently wrong size makes `wc -c` and `ls -l` lie over FUSE, while `null` rides the unknown-size machinery. Put the storage number in `extra` if it is worth reporting.
|
||||
|
||||
Glob resolution is not a per-backend file: bind it from readdir with `makeResolveGlob(readdir, cap)`, or let `GenericResource` derive it from the table.
|
||||
|
||||
## 3. Ops Layer
|
||||
|
||||
Ops are the workspace dispatcher's typed adapters onto the core functions, and they are generated, not hand-written:
|
||||
|
||||
```ts
|
||||
import { QDRANT_IO } from '../../commands/builtin/qdrant/io.ts'
|
||||
import { ResourceName } from '../../types.ts'
|
||||
import { makeGenericOps } from '../generic/factory.ts'
|
||||
import type { RegisteredOp } from '../registry.ts'
|
||||
|
||||
export const QDRANT_OPS: readonly RegisteredOp[] = makeGenericOps(ResourceName.QDRANT, QDRANT_IO)
|
||||
```
|
||||
|
||||
Write an op by hand only for an irregular handler, and pass its name through `overrides` so the derived set skips it. Mark every mutation `write: true`.
|
||||
|
||||
## 4. Commands
|
||||
|
||||
Build the standard command set with `makeGenericCommands` over the same table; the generic command owns flag interpretation, so a backend wrapper is wiring only. Export the result as `<NAME>_COMMANDS` from `commands/builtin/<name>/index.ts`.
|
||||
|
||||
For a resource-specific command:
|
||||
|
||||
- Use the shared command spec (`specOf`), or a `new CommandSpec({...})` for a verb with its own grammar.
|
||||
- Mark every mutation `write: true` so `MountMode.READ` stays a real boundary.
|
||||
- Read a flag through `new FlagView(flags, specOf('<name>'))`, never `flags.get(...)`.
|
||||
- Add a provision estimator when a useful estimate is possible; otherwise the planner reports `precision: unknown`.
|
||||
|
||||
## 5. Resource Class
|
||||
|
||||
Import the command and op arrays at module scope and return them from `commands()` and `ops()`:
|
||||
|
||||
```ts
|
||||
export class MyResource extends BaseResource implements Resource {
|
||||
readonly kind: string = ResourceName.MY_RESOURCE
|
||||
readonly cachesReads: boolean = true
|
||||
readonly prompt: string = MY_PROMPT
|
||||
readonly accessor: MyAccessor
|
||||
|
||||
constructor(config: MyConfig) {
|
||||
super()
|
||||
this.config = resolveMyConfig(config)
|
||||
this.accessor = new MyAccessor(this.config)
|
||||
}
|
||||
|
||||
open(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
ops(): readonly RegisteredOp[] {
|
||||
return MY_RESOURCE_OPS
|
||||
}
|
||||
|
||||
commands(): readonly RegisteredCommand[] {
|
||||
return MY_RESOURCE_COMMANDS
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Set `cachesReads` true only for stable, read-mostly content. Override `getState()` to carry the (redacted) config, and `close()` to release any client handles — calling `super.close()`, which closes the index store.
|
||||
|
||||
## 6. Snapshot Support
|
||||
|
||||
Leave `supportsSnapshot` unset unless the complete drift contract is implemented:
|
||||
|
||||
1. `stat()` returns a stable `FileStat.fingerprint`.
|
||||
1. Every read record includes the fingerprint that produced those bytes.
|
||||
1. If the backend supports immutable revisions, reads consult the resolved revision and record it.
|
||||
|
||||
Setting the flag without recording fingerprints does not provide drift detection.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
Add tests for config resolution, path layout, every VFS op, command behavior, read-only enforcement, state redaction, and cleanup. For major features, add or update integration coverage under `integ/`, and check Python/TypeScript parity before opening the PR.
|
||||
@@ -98,8 +98,8 @@ async def wiki_titles(accessor, *texts: str, **flags: object):
|
||||
return ("\n".join(titles) + "\n").encode(), IOResult()
|
||||
|
||||
|
||||
def make_wiki_resource(pages: dict | None = None) -> GenericResource:
|
||||
io = CommandIO(
|
||||
def make_io() -> CommandIO:
|
||||
return CommandIO(
|
||||
readdir=readdir,
|
||||
read_bytes=read_bytes,
|
||||
read_stream=partial(stream_from_bytes, read_bytes),
|
||||
@@ -107,25 +107,23 @@ def make_wiki_resource(pages: dict | None = None) -> GenericResource:
|
||||
is_mounted=lambda a: True,
|
||||
local=False,
|
||||
)
|
||||
return GenericResource(
|
||||
name="wiki",
|
||||
accessor=WikiAccessor(pages or PAGES),
|
||||
io=io,
|
||||
prompt="A team wiki rendered as markdown files.",
|
||||
commands=[wiki_titles],
|
||||
)
|
||||
|
||||
|
||||
class WikiResource(GenericResource):
|
||||
"""Class form, so the backend is constructible by registry name."""
|
||||
"""The backend as a class, so the registry can build it by name."""
|
||||
|
||||
def __init__(self, pages: dict | None = None) -> None:
|
||||
wired = make_wiki_resource(pages)
|
||||
self.__dict__.update(wired.__dict__)
|
||||
super().__init__(
|
||||
name="wiki",
|
||||
accessor=WikiAccessor(pages or PAGES),
|
||||
io=make_io(),
|
||||
prompt="A team wiki rendered as markdown files.",
|
||||
commands=[wiki_titles],
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
ws = Workspace({"/wiki/": make_wiki_resource()}, mode=MountMode.READ)
|
||||
ws = Workspace({"/wiki/": WikiResource()}, mode=MountMode.READ)
|
||||
|
||||
for line in (
|
||||
"ls /wiki/guides",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import {
|
||||
Accessor,
|
||||
type CommandIO,
|
||||
CommandSpec,
|
||||
command,
|
||||
FileStat,
|
||||
FileType,
|
||||
GenericResource,
|
||||
IOResult,
|
||||
MountMode,
|
||||
type PathSpec,
|
||||
registerResourceFactory,
|
||||
streamFromBytes,
|
||||
Workspace,
|
||||
} from '@struktoai/mirage-node'
|
||||
|
||||
// A whole custom backend in one script: three core functions over your
|
||||
// data source, one CommandIO table, one GenericResource. Every generic
|
||||
// command (ls, cat, grep, find, head, wc, ...) works for free.
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
type Tree = { [name: string]: Tree | string }
|
||||
|
||||
const PAGES: Tree = {
|
||||
guides: {
|
||||
'quickstart.md': '# Quickstart\nMount anything as a filesystem.\n',
|
||||
'deploy.md': '# Deploy\nShip the gateway behind HTTP.\n',
|
||||
},
|
||||
'notes.md': 'Remember: agents just speak bash.\n',
|
||||
}
|
||||
|
||||
class WikiAccessor extends Accessor {
|
||||
constructor(readonly pages: Tree) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
function node(pages: Tree, key: string): Tree | string {
|
||||
let current: Tree | string = pages
|
||||
for (const part of key.split('/').filter((p) => p !== '')) {
|
||||
if (typeof current === 'string') throw new Error(`ENOENT: ${key}`)
|
||||
const child: Tree | string | undefined = current[part]
|
||||
if (child === undefined) throw new Error(`ENOENT: ${key}`)
|
||||
current = child
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function readdir(accessor: WikiAccessor, path: PathSpec): Promise<string[]> {
|
||||
const found = node(accessor.pages, path.resourcePath)
|
||||
if (typeof found === 'string') throw new Error(`ENOTDIR: ${path.virtual}`)
|
||||
const parent = path.virtual.replace(/\/+$/, '')
|
||||
return Promise.resolve(
|
||||
Object.entries(found).map(
|
||||
([name, child]) => `${parent}/${name}${typeof child === 'string' ? '' : '/'}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function readBytes(accessor: WikiAccessor, path: PathSpec): Promise<Uint8Array> {
|
||||
const found = node(accessor.pages, path.resourcePath)
|
||||
if (typeof found !== 'string') throw new Error(`EISDIR: ${path.virtual}`)
|
||||
return Promise.resolve(ENC.encode(found))
|
||||
}
|
||||
|
||||
function stat(accessor: WikiAccessor, path: PathSpec): Promise<FileStat> {
|
||||
const found = node(accessor.pages, path.resourcePath)
|
||||
const trimmed = path.virtual.replace(/\/+$/, '')
|
||||
const name = trimmed.slice(trimmed.lastIndexOf('/') + 1) || '/'
|
||||
if (typeof found !== 'string')
|
||||
return Promise.resolve(new FileStat({ name, size: null, type: FileType.DIRECTORY }))
|
||||
return Promise.resolve(new FileStat({ name, size: ENC.encode(found).length, type: FileType.TEXT }))
|
||||
}
|
||||
|
||||
// Optional: a bespoke domain verb, registered alongside the generics.
|
||||
const wikiTitles = command({
|
||||
name: 'wiki_titles',
|
||||
resource: 'wiki',
|
||||
spec: new CommandSpec(),
|
||||
fn: (accessor) => {
|
||||
const pages = (accessor as WikiAccessor).pages
|
||||
const titles = ['guides/quickstart.md', 'guides/deploy.md'].flatMap((page) =>
|
||||
String(node(pages, page))
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('# '))
|
||||
.map((line) => line.slice(2)),
|
||||
)
|
||||
return [ENC.encode(`${titles.join('\n')}\n`), new IOResult()]
|
||||
},
|
||||
})
|
||||
|
||||
function makeIO(): CommandIO<WikiAccessor> {
|
||||
return {
|
||||
readdir,
|
||||
readBytes,
|
||||
readStream: (a, p, i) => streamFromBytes(readBytes, a, p, i),
|
||||
stat,
|
||||
isMounted: () => true,
|
||||
local: false,
|
||||
}
|
||||
}
|
||||
|
||||
class WikiResource extends GenericResource<WikiAccessor> {
|
||||
constructor(pages: Tree = PAGES) {
|
||||
super({
|
||||
name: 'wiki',
|
||||
accessor: new WikiAccessor(pages),
|
||||
io: makeIO(),
|
||||
prompt: 'A team wiki rendered as markdown files.',
|
||||
commands: wikiTitles,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ws = new Workspace({ '/wiki/': new WikiResource() }, { mode: MountMode.READ })
|
||||
|
||||
for (const line of [
|
||||
'ls /wiki/guides',
|
||||
'cat /wiki/notes.md',
|
||||
'grep -r Quickstart /wiki/',
|
||||
"find /wiki -name '*.md'",
|
||||
'wc -l /wiki/guides/quickstart.md',
|
||||
'wiki_titles',
|
||||
]) {
|
||||
const io = await ws.execute(line)
|
||||
console.log(`$ ${line}\n${io.stdoutText}`)
|
||||
}
|
||||
|
||||
// Registered names work everywhere builtin names do (YAML, snapshots):
|
||||
registerResourceFactory('wiki', () => Promise.resolve(new WikiResource()))
|
||||
console.log("registered 'wiki' for registry-name construction")
|
||||
|
||||
await ws.close()
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"runs": {
|
||||
"python": {
|
||||
"command": [
|
||||
"./python/.venv/bin/python",
|
||||
"examples/python/other/custom_resource.py"
|
||||
]
|
||||
},
|
||||
"typescript": {
|
||||
"command": [
|
||||
"pnpm",
|
||||
"-C",
|
||||
"examples/typescript",
|
||||
"exec",
|
||||
"tsx",
|
||||
"other/custom_resource.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exit": 0,
|
||||
"lines": [
|
||||
"$ ls /wiki/guides",
|
||||
"deploy.md",
|
||||
"quickstart.md",
|
||||
"",
|
||||
"$ cat /wiki/notes.md",
|
||||
"Remember: agents just speak bash.",
|
||||
"",
|
||||
"$ grep -r Quickstart /wiki/",
|
||||
"/wiki/guides/quickstart.md:# Quickstart",
|
||||
"",
|
||||
"$ find /wiki -name '*.md'",
|
||||
"/wiki/guides/deploy.md",
|
||||
"/wiki/guides/quickstart.md",
|
||||
"/wiki/notes.md",
|
||||
"",
|
||||
"$ wc -l /wiki/guides/quickstart.md",
|
||||
"2 /wiki/guides/quickstart.md",
|
||||
"",
|
||||
"$ wiki_titles",
|
||||
"Quickstart",
|
||||
"Deploy",
|
||||
"",
|
||||
"registered 'wiki' for registry-name construction"
|
||||
]
|
||||
}
|
||||
@@ -63,6 +63,15 @@ class GenericResource(BaseResource):
|
||||
cost estimators replacing the catalog default.
|
||||
caches_reads (bool): serve repeat reads from the file cache;
|
||||
enable only for stable, read-mostly content.
|
||||
sizes_always_known (bool): whether ``io.stat`` sizes every
|
||||
regular file without fetching it. A backend that renders its
|
||||
content on read leaves this False and rides the unknown-size
|
||||
machinery; a byte store sets it, which is also what makes the
|
||||
mount legal on FSKit.
|
||||
supports_snapshot (bool): whether ``io.stat`` fills
|
||||
``FileStat.fingerprint`` with a stable per-path version
|
||||
marker. Setting it without that is not drift detection, it is
|
||||
a snapshot that claims to have one.
|
||||
index (IndexConfig | None): cache-index configuration.
|
||||
"""
|
||||
|
||||
@@ -80,6 +89,8 @@ class GenericResource(BaseResource):
|
||||
provision_overrides: dict[str, Callable[..., Any]] | None = None,
|
||||
auto_ops: bool = True,
|
||||
caches_reads: bool = False,
|
||||
sizes_always_known: bool = False,
|
||||
supports_snapshot: bool = False,
|
||||
index: IndexConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(index=index)
|
||||
@@ -91,6 +102,8 @@ class GenericResource(BaseResource):
|
||||
self.PROMPT = prompt
|
||||
self.WRITE_PROMPT = write_prompt
|
||||
self.caches_reads = caches_reads
|
||||
self.SIZES_ALWAYS_KNOWN = sizes_always_known
|
||||
self.SUPPORTS_SNAPSHOT = supports_snapshot
|
||||
self._resolve = io.resolve_glob
|
||||
for fn in make_generic_commands(
|
||||
name,
|
||||
|
||||
@@ -146,6 +146,18 @@ def test_get_state():
|
||||
assert make_resource().get_state() == {"type": "wiki"}
|
||||
|
||||
|
||||
def test_declaration_flags_forwarded():
|
||||
resource = make_resource(sizes_always_known=True, supports_snapshot=True)
|
||||
assert resource.SIZES_ALWAYS_KNOWN is True
|
||||
assert resource.SUPPORTS_SNAPSHOT is True
|
||||
|
||||
|
||||
def test_declaration_flags_default_off():
|
||||
resource = make_resource()
|
||||
assert resource.SIZES_ALWAYS_KNOWN is False
|
||||
assert resource.SUPPORTS_SNAPSHOT is False
|
||||
|
||||
|
||||
def test_prompts_set():
|
||||
resource = make_resource(prompt="wiki files", write_prompt="writable")
|
||||
assert resource.PROMPT == "wiki files"
|
||||
@@ -187,6 +199,10 @@ async def test_workspace_execution_end_to_end():
|
||||
result = await ws.execute("wiki_hello")
|
||||
assert await result.stdout_str() == "hello custom verb\n"
|
||||
|
||||
# The derived ops serve the VFS surface too, not just the commands.
|
||||
assert "/wiki/guides/quickstart.md" in await ws.readdir("/wiki/guides")
|
||||
assert (await ws.stat("/wiki/notes.md")).size == 18
|
||||
|
||||
|
||||
def test_auto_ops_derived_from_table():
|
||||
resource = make_resource()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"baseline": 256,
|
||||
"baseline": 255,
|
||||
"baseline_reason": "Every divergence below the excused ones predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a divergence is closed; the gate fails on a drop too, so an improvement cannot be silently spent. Items 31-37 of the cleanup plan are scoped from this report. The unit is one module, never one directory: a one-sided directory counts once per module inside it, so it cannot absorb new modules without moving the number. An excused directory is the deliberate exception -- it excuses its whole subtree, because the excuse is that there is no counterpart to mirror, which makes growth inside it expected rather than drift.",
|
||||
"directories": {
|
||||
"python_only": {
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
type BuilderFn,
|
||||
type CommandIO,
|
||||
type DuOps,
|
||||
type ResolveGlobOp,
|
||||
makeResolveGlob,
|
||||
overlaidStat,
|
||||
rangeOf,
|
||||
|
||||
@@ -25,10 +25,13 @@
|
||||
// did. It is a repo-root script rather than knip because knip's project
|
||||
// root is typescript/, which leaves the consumers out of view.
|
||||
|
||||
export { Accessor } from './accessor/base.ts'
|
||||
export { defaultFingerprint } from './cache/file/utils.ts'
|
||||
export { IndexEntry } from './cache/index/config.ts'
|
||||
export type { RedisIndexConfig } from './cache/index/config.ts'
|
||||
export { RedisIndexCacheStore } from './cache/index/redis.ts'
|
||||
export type { CommandIO } from './commands/builtin/generic_bind/index.ts'
|
||||
export { streamFromBytes } from './commands/builtin/utils/wrap.ts'
|
||||
export { DISCORD } from './commands/cli/builtin/discord/index.ts'
|
||||
export { GH } from './commands/cli/builtin/gh/index.ts'
|
||||
export { GIT } from './commands/cli/builtin/git/index.ts'
|
||||
@@ -40,7 +43,7 @@ export { CLISpec } from './commands/cli/types.ts'
|
||||
export type { CLIInvocation } from './commands/cli/types.ts'
|
||||
export { command } from './commands/config.ts'
|
||||
export type { CommandFnResult, CommandOpts } from './commands/config.ts'
|
||||
export { Operand, SPECS, specOf } from './commands/spec/index.ts'
|
||||
export { CommandSpec, Operand, SPECS, specOf } from './commands/spec/index.ts'
|
||||
export { MemoryOAuthClientProvider } from './core/notion/_oauth.ts'
|
||||
export { IOResult } from './io/types.ts'
|
||||
export { OpsRegistry } from './ops/registry.ts'
|
||||
@@ -58,6 +61,7 @@ export { ChromaResource } from './resource/chroma/chroma.ts'
|
||||
export { normalizeDatabricksVolumeConfig } from './resource/databricks_volume/config.ts'
|
||||
export { DevResource } from './resource/dev/dev.ts'
|
||||
export { DifyResource } from './resource/dify/dify.ts'
|
||||
export { GenericResource } from './resource/generic.ts'
|
||||
export { Mem0Resource } from './resource/mem0/mem0.ts'
|
||||
export { OneDriveResource } from './resource/onedrive/onedrive.ts'
|
||||
export { QdrantResource } from './resource/qdrant/qdrant.ts'
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Accessor } from '../accessor/base.ts'
|
||||
import type { CommandIO } from '../commands/builtin/generic_bind/index.ts'
|
||||
import { streamFromBytes } from '../commands/builtin/utils/wrap.ts'
|
||||
import { command, type RegisteredCommand } from '../commands/config.ts'
|
||||
import { CommandSpec } from '../commands/spec/types.ts'
|
||||
import { IOResult } from '../io/types.ts'
|
||||
import type { RegisteredOp } from '../ops/registry.ts'
|
||||
import { FileStat, FileType, MountMode, PathSpec } from '../types.ts'
|
||||
import { getTestParser, stdoutStr } from '../workspace/fixtures/workspace_fixture.ts'
|
||||
import { Workspace } from '../workspace/workspace/workspace.ts'
|
||||
import { GenericResource, type GenericResourceOptions } from './generic.ts'
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
interface Tree {
|
||||
[name: string]: Tree | string
|
||||
}
|
||||
|
||||
const PAGES: Tree = {
|
||||
guides: {
|
||||
'quickstart.md': '# Quickstart\nHello.\n',
|
||||
},
|
||||
'notes.md': 'agents speak bash\n',
|
||||
}
|
||||
|
||||
class WikiAccessor extends Accessor {
|
||||
constructor(readonly pages: Tree) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
function node(pages: Tree, key: string): Tree | string {
|
||||
let current: Tree | string = pages
|
||||
for (const part of key.split('/').filter((p) => p !== '')) {
|
||||
if (typeof current === 'string') throw new Error(`ENOENT: ${key}`)
|
||||
const child: Tree | string | undefined = current[part]
|
||||
if (child === undefined) throw new Error(`ENOENT: ${key}`)
|
||||
current = child
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function readdir(accessor: WikiAccessor, path: PathSpec): Promise<string[]> {
|
||||
const found = node(accessor.pages, path.resourcePath)
|
||||
if (typeof found === 'string') throw new Error(`ENOTDIR: ${path.virtual}`)
|
||||
const parent = path.virtual.replace(/\/+$/, '')
|
||||
return Promise.resolve(
|
||||
Object.entries(found).map(
|
||||
([name, child]) => `${parent}/${name}${typeof child === 'string' ? '' : '/'}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function readBytes(accessor: WikiAccessor, path: PathSpec): Promise<Uint8Array> {
|
||||
const found = node(accessor.pages, path.resourcePath)
|
||||
if (typeof found !== 'string') throw new Error(`EISDIR: ${path.virtual}`)
|
||||
return Promise.resolve(ENC.encode(found))
|
||||
}
|
||||
|
||||
function stat(accessor: WikiAccessor, path: PathSpec): Promise<FileStat> {
|
||||
const found = node(accessor.pages, path.resourcePath)
|
||||
const trimmed = path.virtual.replace(/\/+$/, '')
|
||||
const name = trimmed.slice(trimmed.lastIndexOf('/') + 1) || '/'
|
||||
if (typeof found !== 'string')
|
||||
return Promise.resolve(new FileStat({ name, size: null, type: FileType.DIRECTORY }))
|
||||
return Promise.resolve(
|
||||
new FileStat({ name, size: ENC.encode(found).length, type: FileType.TEXT }),
|
||||
)
|
||||
}
|
||||
|
||||
const wikiHello: readonly RegisteredCommand[] = command({
|
||||
name: 'wiki_hello',
|
||||
resource: 'wiki',
|
||||
spec: new CommandSpec(),
|
||||
fn: () => [ENC.encode('hello custom verb\n'), new IOResult()],
|
||||
})
|
||||
|
||||
function makeIO(): CommandIO<WikiAccessor> {
|
||||
return {
|
||||
readdir,
|
||||
readBytes,
|
||||
readStream: (a, p, i) => streamFromBytes(readBytes, a, p, i),
|
||||
stat,
|
||||
isMounted: () => true,
|
||||
local: false,
|
||||
}
|
||||
}
|
||||
|
||||
function makeResource(
|
||||
extra: Partial<GenericResourceOptions<WikiAccessor>> = {},
|
||||
): GenericResource<WikiAccessor> {
|
||||
return new GenericResource<WikiAccessor>({
|
||||
name: 'wiki',
|
||||
accessor: new WikiAccessor(PAGES),
|
||||
io: makeIO(),
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
function commandNames(resource: GenericResource<WikiAccessor>): Set<string> {
|
||||
return new Set(resource.commands().map((rc) => rc.name))
|
||||
}
|
||||
|
||||
describe('GenericResource wires a backend from one CommandIO table', () => {
|
||||
it('registers the generic command set', () => {
|
||||
const names = commandNames(makeResource())
|
||||
for (const name of ['ls', 'cat', 'grep', 'find', 'head', 'wc']) {
|
||||
expect(names).toContain(name)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves out write commands the table cannot serve', () => {
|
||||
const names = commandNames(makeResource())
|
||||
expect(names).not.toContain('tee')
|
||||
expect(names).not.toContain('rm')
|
||||
})
|
||||
|
||||
it('suppresses a generic the backend overrides', () => {
|
||||
const names = commandNames(makeResource({ overrides: new Set(['grep']) }))
|
||||
expect(names).not.toContain('grep')
|
||||
expect(names).toContain('rg')
|
||||
})
|
||||
|
||||
it('registers extra commands beside the generics', () => {
|
||||
expect(commandNames(makeResource({ commands: wikiHello }))).toContain('wiki_hello')
|
||||
})
|
||||
|
||||
it('refuses an empty name', () => {
|
||||
expect(
|
||||
() => new GenericResource({ name: '', accessor: new WikiAccessor(PAGES), io: makeIO() }),
|
||||
).toThrow(/non-empty name/)
|
||||
})
|
||||
|
||||
it('reports the name as its snapshot type', async () => {
|
||||
expect(await makeResource().getState()).toEqual({ type: 'wiki' })
|
||||
})
|
||||
|
||||
it('carries the prompts', () => {
|
||||
const resource = makeResource({ prompt: 'wiki files', writePrompt: 'writable' })
|
||||
expect(resource.prompt).toBe('wiki files')
|
||||
expect(resource.writePrompt).toBe('writable')
|
||||
})
|
||||
|
||||
it('resolves a glob through the table readdir', async () => {
|
||||
const matches = await makeResource().glob([
|
||||
new PathSpec({
|
||||
resourcePath: 'guides/quick*',
|
||||
virtual: '/guides/quick*',
|
||||
directory: '/guides',
|
||||
pattern: 'quick*',
|
||||
resolved: false,
|
||||
}),
|
||||
])
|
||||
expect(matches.map((m) => m.virtual)).toEqual(['/guides/quickstart.md'])
|
||||
})
|
||||
|
||||
it('derives the op set from the table', () => {
|
||||
const derived = new Set(
|
||||
makeResource()
|
||||
.ops()
|
||||
.map((ro) => `${ro.name}:${String(ro.write)}`),
|
||||
)
|
||||
expect(derived).toEqual(new Set(['read:false', 'readdir:false', 'stat:false']))
|
||||
})
|
||||
|
||||
it('registers no ops when autoOps is off', () => {
|
||||
expect(makeResource({ autoOps: false }).ops()).toEqual([])
|
||||
})
|
||||
|
||||
it('lets a user op shadow the derived one of the same name', () => {
|
||||
const myRead: RegisteredOp = {
|
||||
name: 'read',
|
||||
resource: 'wiki',
|
||||
filetype: null,
|
||||
fn: () => ENC.encode('custom'),
|
||||
write: false,
|
||||
}
|
||||
const reads = makeResource({ ops: [myRead] })
|
||||
.ops()
|
||||
.filter((ro) => ro.name === 'read')
|
||||
expect(reads).toHaveLength(1)
|
||||
expect(reads[0]?.fn).toBe(myRead.fn)
|
||||
})
|
||||
|
||||
it('declares the FSKit and snapshot flags it was given', () => {
|
||||
const resource = makeResource({ sizesAlwaysKnown: true, supportsSnapshot: true })
|
||||
expect(resource.sizesAlwaysKnown).toBe(true)
|
||||
expect(resource.supportsSnapshot).toBe(true)
|
||||
})
|
||||
|
||||
it('serves a mount end to end', async () => {
|
||||
const parser = await getTestParser()
|
||||
const ws = new Workspace(
|
||||
{ '/wiki/': makeResource({ commands: wikiHello }) },
|
||||
{ mode: MountMode.READ, shellParser: parser },
|
||||
)
|
||||
try {
|
||||
expect(stdoutStr(await ws.execute('ls /wiki/guides'))).toContain('quickstart.md')
|
||||
expect(stdoutStr(await ws.execute('cat /wiki/notes.md'))).toBe('agents speak bash\n')
|
||||
expect(stdoutStr(await ws.execute('grep -r Quickstart /wiki/'))).toContain(
|
||||
'/wiki/guides/quickstart.md:# Quickstart',
|
||||
)
|
||||
const found = stdoutStr(await ws.execute("find /wiki -name '*.md'"))
|
||||
expect(found).toContain('/wiki/guides/quickstart.md')
|
||||
expect(found).toContain('/wiki/notes.md')
|
||||
expect(stdoutStr(await ws.execute('wiki_hello'))).toBe('hello custom verb\n')
|
||||
// The derived ops serve the VFS surface too, not just the commands.
|
||||
expect(await ws.readdir('/wiki/guides')).toContain('/wiki/guides/quickstart.md')
|
||||
expect(await ws.stat('/wiki/notes.md')).toMatchObject({ size: 18 })
|
||||
} finally {
|
||||
await ws.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { Accessor } from '../accessor/base.ts'
|
||||
import type { IndexConfig } from '../cache/index/config.ts'
|
||||
import {
|
||||
type CommandIO,
|
||||
type ResolveGlobOp,
|
||||
makeGenericCommands,
|
||||
resolveGlobOf,
|
||||
} from '../commands/builtin/generic_bind/index.ts'
|
||||
import type { ProvisionFn, RegisteredCommand } from '../commands/config.ts'
|
||||
import { makeGenericOps } from '../ops/generic/factory.ts'
|
||||
import type { RegisteredOp } from '../ops/registry.ts'
|
||||
import type { FileStat, PathSpec } from '../types.ts'
|
||||
import { BaseResource, type Resource } from './base.ts'
|
||||
|
||||
export interface GenericResourceOptions<A extends Accessor = Accessor> {
|
||||
/**
|
||||
* Resource name the commands and ops register under, and the `type`
|
||||
* key `getState` writes into a snapshot. Also the registry key when
|
||||
* the backend is exposed through `registerResourceFactory`.
|
||||
*/
|
||||
name: string
|
||||
/** Backend handle passed to every core fn on the table. */
|
||||
accessor: A
|
||||
/** The backend's IO table. */
|
||||
io: CommandIO<A>
|
||||
/** LLM-facing description of the mounted layout. */
|
||||
prompt?: string
|
||||
/** Appended to `prompt` when the mount is writable. */
|
||||
writePrompt?: string
|
||||
/**
|
||||
* Generic command names the backend replaces. Pass the replacements
|
||||
* through `commands`.
|
||||
*/
|
||||
overrides?: ReadonlySet<string>
|
||||
/**
|
||||
* Extra commands, from `command({...})`: bespoke verbs, or the
|
||||
* replacements for whatever `overrides` suppressed.
|
||||
*/
|
||||
commands?: readonly RegisteredCommand[]
|
||||
/**
|
||||
* Irregular VFS/FUSE handlers, layered over the auto-derived set. One
|
||||
* carrying no filetype shadows the derived op of the same name.
|
||||
*
|
||||
* Plain records rather than Python's decorated functions: TypeScript's
|
||||
* `op` is a *method* decorator, so a standalone handler has no
|
||||
* decorator form to carry its registration.
|
||||
*/
|
||||
ops?: readonly RegisteredOp[]
|
||||
/** Per-command cost estimators replacing the catalog default. */
|
||||
provisionOverrides?: Record<string, ProvisionFn<A>>
|
||||
/**
|
||||
* Derive the VFS/FUSE op set from the table (read/readdir/stat plus
|
||||
* whatever mutations the table carries). Set false to register only
|
||||
* the explicit `ops`.
|
||||
*/
|
||||
autoOps?: boolean
|
||||
/** Serve repeat reads from the file cache. Read-mostly content only. */
|
||||
cachesReads?: boolean
|
||||
/**
|
||||
* Whether `io.stat` sizes every regular file without fetching it. A
|
||||
* backend that renders its content on read leaves this false and rides
|
||||
* the unknown-size machinery; a byte store sets it, which is also what
|
||||
* makes the mount legal on FSKit.
|
||||
*/
|
||||
sizesAlwaysKnown?: boolean
|
||||
/**
|
||||
* Whether `io.stat` fills `FileStat.fingerprint` with a stable
|
||||
* per-path version marker. Setting it without that is not drift
|
||||
* detection, it is a snapshot that claims to have one.
|
||||
*/
|
||||
supportsSnapshot?: boolean
|
||||
/** Cache-index configuration. Omitted leaves the lazy RAM default. */
|
||||
index?: IndexConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole backend generated from one {@link CommandIO} table.
|
||||
*
|
||||
* The one-file path for a custom backend: supply an accessor and the
|
||||
* core functions on a table (readdir/readBytes/readStream/stat at
|
||||
* minimum) and the generic command set arrives wired, along with glob
|
||||
* resolution and the VFS/FUSE ops. Optional fields on the table unlock
|
||||
* more surface (`write` enables the byte-mutation family, `find` and
|
||||
* `du` become native fast paths), and a command whose requirements the
|
||||
* table cannot meet is never registered rather than registered and
|
||||
* broken.
|
||||
*
|
||||
* The escape hatches are the ones the builtins use, because this class
|
||||
* assembles exactly what they assemble by hand: `overrides` drops a
|
||||
* generic command, `commands` appends a bespoke verb, `ops` layers an
|
||||
* irregular handler over the derived set.
|
||||
*
|
||||
* Mirrors Python `mirage.resource.generic.GenericResource`. The accessor
|
||||
* generic is the one thing it does not mirror: it type-checks the table
|
||||
* against the accessor the core fns actually take, which Python leaves
|
||||
* as `Any` for contravariance reasons documented on its own op
|
||||
* protocols.
|
||||
*/
|
||||
export class GenericResource<A extends Accessor = Accessor>
|
||||
extends BaseResource
|
||||
implements Resource
|
||||
{
|
||||
readonly kind: string
|
||||
readonly accessor: A
|
||||
readonly io: CommandIO<A>
|
||||
readonly prompt: string
|
||||
readonly writePrompt: string
|
||||
readonly cachesReads: boolean
|
||||
readonly sizesAlwaysKnown: boolean
|
||||
readonly supportsSnapshot: boolean
|
||||
readonly #commands: readonly RegisteredCommand[]
|
||||
readonly #ops: readonly RegisteredOp[]
|
||||
readonly #glob: ResolveGlobOp<A>
|
||||
|
||||
constructor(options: GenericResourceOptions<A>) {
|
||||
super()
|
||||
if (options.name === '') throw new Error('GenericResource requires a non-empty name')
|
||||
this.kind = options.name
|
||||
this.accessor = options.accessor
|
||||
this.io = options.io
|
||||
this.prompt = options.prompt ?? ''
|
||||
this.writePrompt = options.writePrompt ?? ''
|
||||
this.cachesReads = options.cachesReads ?? false
|
||||
this.sizesAlwaysKnown = options.sizesAlwaysKnown ?? false
|
||||
this.supportsSnapshot = options.supportsSnapshot ?? false
|
||||
if (options.index !== undefined) this.setIndex(options.index)
|
||||
this.#glob = resolveGlobOf(options.io)
|
||||
this.#commands = [
|
||||
...makeGenericCommands<A>(options.name, options.io, {
|
||||
...(options.overrides !== undefined ? { overrides: options.overrides } : {}),
|
||||
...(options.provisionOverrides !== undefined
|
||||
? { provisionOverrides: options.provisionOverrides }
|
||||
: {}),
|
||||
}),
|
||||
...(options.commands ?? []),
|
||||
]
|
||||
const userOps = options.ops ?? []
|
||||
// A user op carrying no filetype replaces the derived op of the same
|
||||
// name: the derived set is built with those names skipped, so
|
||||
// registering both cannot leave two handlers competing for one key.
|
||||
const shadowed = new Set(userOps.filter((ro) => ro.filetype === null).map((ro) => ro.name))
|
||||
const derived =
|
||||
options.autoOps === false
|
||||
? []
|
||||
: makeGenericOps<A>(options.name, options.io, { overrides: shadowed })
|
||||
this.#ops = [...derived, ...userOps]
|
||||
}
|
||||
|
||||
open(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
commands(): readonly RegisteredCommand[] {
|
||||
return this.#commands
|
||||
}
|
||||
|
||||
ops(): readonly RegisteredOp[] {
|
||||
return this.#ops
|
||||
}
|
||||
|
||||
glob(paths: readonly PathSpec[], _prefix = ''): Promise<PathSpec[]> {
|
||||
return this.#glob(this.accessor, paths, this.index)
|
||||
}
|
||||
|
||||
// The four table fields every backend must supply, forwarded so a
|
||||
// GenericResource answers the direct calls builtin resources answer.
|
||||
// The optional ones are deliberately absent: a forwarder that throws
|
||||
// for a table field the backend never filled would answer a feature
|
||||
// probe with a lie.
|
||||
readFile(path: PathSpec): Promise<Uint8Array> {
|
||||
return this.io.readBytes(this.accessor, path, this.index)
|
||||
}
|
||||
|
||||
readdir(path: PathSpec): Promise<string[]> {
|
||||
return this.io.readdir(this.accessor, path, this.index)
|
||||
}
|
||||
|
||||
stat(path: PathSpec): Promise<FileStat> {
|
||||
return this.io.stat(this.accessor, path, this.index)
|
||||
}
|
||||
|
||||
streamPath(path: PathSpec): AsyncIterable<Uint8Array> {
|
||||
return this.io.readStream(this.accessor, path, this.index)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user