A PostgreSQL-backed filesystem provider for just-bash. Implements the IFileSystem interface using a single fs_nodes table with ltree for hierarchy, built-in full-text search, optional pgvector semantic search, and row-level security for per-session isolation.
- Full
IFileSystemimplementation -- files, directories, symlinks, hard links, chmod, stat, recursive cp/rm/mv - Full-text search -- PostgreSQL
tsvectorwith weighted filename + content ranking - Semantic search -- pgvector cosine similarity with any embedding provider
- Hybrid search -- combined text + vector ranking with configurable weights
- Multi-tenant isolation -- per-session scoping via
sessionId, enforced by both application logic and RLS - Idempotent schema setup -- safe to call
setup()on every startup
bun add just-bash-postgresOr with npm:
npm install just-bash-postgres- Bun >= 1.0
- PostgreSQL 14+ with the ltree extension (included in most distributions)
- pgvector extension (optional, only needed for semantic/hybrid search)
import postgres from "postgres";
import { PgFileSystem } from "just-bash-postgres";
import { Bash } from "just-bash";
const sql = postgres("postgres://user:pass@localhost:5432/mydb");
const fs = new PgFileSystem({ sql, sessionId: 1 });
await fs.setup(); // creates tables, indexes, and RLS policies
const bash = new Bash({ fs, cwd: "/", defenseInDepth: false });
await bash.exec('echo "hello" > /greeting.txt');
const result = await bash.exec("cat /greeting.txt");
console.log(result.stdout); // "hello\n"Note:
defenseInDepth: falseis required when using just-bash with postgres.js because the defense-in-depth sandbox restricts raw network access that postgres.js needs for its connection.
fs.setup() runs an idempotent migration that creates the fs_nodes table, indexes, and RLS policies, along with the root directory for the session. Safe to call on every startup -- all statements use IF NOT EXISTS guards.
If you pass embeddingDimensions in the options, setup() also creates the pgvector extension and adds an embedding column with an HNSW index.
Three search methods are available beyond the standard IFileSystem interface.
Always available. Uses PostgreSQL's tsvector with filename weighted higher than content.
const results = await fs.search("database migration");
// [{ path: "/docs/migration-guide.txt", name: "migration-guide.txt", rank: 0.8, snippet: "..." }]Requires an embedding provider. Uses pgvector cosine similarity over HNSW indexes.
const fs = new PgFileSystem({
sql,
sessionId: 1,
embed: async (text) =>
openai.embeddings
.create({ input: text, model: "text-embedding-3-small" })
.then((r) => r.data[0].embedding),
embeddingDimensions: 1536,
});
await fs.setup();
const results = await fs.semanticSearch("how to deploy the app");Combines full-text and vector search with configurable weights (default: 0.4 text, 0.6 vector).
const results = await fs.hybridSearch("deployment guide", {
textWeight: 0.3,
vectorWeight: 0.7,
limit: 10,
});All search methods accept an optional path parameter to scope results to a subtree:
const results = await fs.search("config", { path: "/app/settings" });interface SearchResult {
path: string;
name: string;
rank: number;
snippet?: string; // only present for full-text search
}Each PgFileSystem instance is bound to a sessionId. All queries include WHERE session_id = $sessionId, and the database schema enforces the same constraint via RLS policies. Sessions cannot see or modify each other's files.
const sessionAFs = new PgFileSystem({ sql, sessionId: 1 });
const sessionBFs = new PgFileSystem({ sql, sessionId: 2 });
await sessionAFs.setup();
await sessionBFs.setup();
await sessionAFs.writeFile("/secret.txt", "session A data");
await sessionBFs.exists("/secret.txt"); // false -- completely isolatedNo sessions table is required. sessionId is just a positive integer; session management is the consuming application's responsibility.
interface PgFileSystemOptions {
/** postgres.js connection instance */
sql: postgres.Sql;
/** Positive integer session ID for isolation. All operations are scoped to this session. */
sessionId: number;
/** Maximum file size in bytes (default: 100MB) */
maxFileSize?: number;
/** Statement timeout in milliseconds (default: 30000) */
statementTimeout?: number;
/** Async function that returns an embedding vector for text content.
When provided, writeFile generates embeddings automatically. */
embed?: (text: string) => Promise<number[]>;
/** Dimension of embedding vectors. Required if embed is provided.
Must match your embed function output (e.g. 1536 for text-embedding-3-small). */
embeddingDimensions?: number;
}| Method | Description |
|---|---|
setup() |
Create schema, indexes, RLS policies, and root directory |
writeFile(path, content) |
Create or overwrite a file |
readFile(path) |
Read file as UTF-8 string |
readFileBuffer(path) |
Read file as Uint8Array |
appendFile(path, content) |
Append to a file |
exists(path) |
Check if path exists |
stat(path) |
Get file stats (follows symlinks) |
lstat(path) |
Get file stats (does not follow symlinks) |
mkdir(path, options?) |
Create directory ({ recursive: true } supported) |
readdir(path) |
List directory entries as strings |
readdirWithFileTypes(path) |
List directory entries with type info |
rm(path, options?) |
Delete file or directory ({ recursive: true } supported) |
mv(src, dest) |
Move/rename file or directory |
cp(src, dest, options?) |
Copy file or directory ({ recursive: true } supported) |
chmod(path, mode) |
Change file mode |
utimes(path, atime, mtime) |
Update modification time |
symlink(target, path) |
Create a symbolic link |
readlink(path) |
Read symlink target |
link(src, dest) |
Create a hard link (copies content) |
realpath(path) |
Resolve symlinks (max 16 levels) |
| Method | Description |
|---|---|
search(query, options?) |
Full-text search with websearch syntax |
semanticSearch(query, options?) |
Vector cosine similarity search |
hybridSearch(query, options?) |
Combined text + vector search |
| Export | Description |
|---|---|
setupSchema(sql) |
Run schema migration standalone |
setupVectorColumn(sql, dimensions) |
Add vector column standalone |
FsError |
Error class with POSIX codes (ENOENT, EISDIR, etc.) |
The sessionId is trusted without verification. The library assumes the consuming application has validated the session before constructing a PgFileSystem instance.
Use TLS for database connections in production:
const sql = postgres("postgres://user:pass@host:5432/db?sslmode=require");Isolation is enforced at two levels: application-level WHERE session_id = ... on every query, and database-level RLS policies. For RLS to be effective, connect as a non-superuser role -- PostgreSQL superusers bypass RLS.
setup() automatically grants permissions to a role named fs_app if it exists:
CREATE ROLE fs_app LOGIN PASSWORD 'your-password';
GRANT CONNECT ON DATABASE your_db TO fs_app;Run setup() once with a superuser connection to create the schema, then use fs_app for normal operations:
const sql = postgres("postgres://fs_app:your-password@localhost:5432/mydb");Setting defenseInDepth: false on the just-bash Bash instance disables just-bash's built-in sandbox, which is necessary because postgres.js requires raw network access. Compensate with network-level controls (firewall rules, VPC configuration) to restrict what the host can reach.
git clone https://github.com/F1nnM/just-bash-postgres.git
cd just-bash-postgres
bun install
docker compose up -dTests run against a real PostgreSQL instance (110+ tests across 7 files):
docker compose up -d
bun testBy default, tests connect to postgres://postgres@localhost:5433/just_bash_postgres_test. Override with TEST_DATABASE_URL.
bun run typecheck