fs9 — File System
fs9 is a TiKV-backed file system accessible from SQL. It provides scalar functions for reading and writing files, a table function for querying file contents as rows, and a WebSocket API for programmatic access from the TypeScript SDK.
Files are stored in TiKV using page-based storage (16 KB pages) with inode metadata — not on a local disk. Each database has its own isolated file system.
Installation
Section titled “Installation”CREATE EXTENSION IF NOT EXISTS fs9;FUSE Mount on macOS
Section titled “FUSE Mount on macOS”If you use db9 fs mount on macOS, install macFUSE first:
brew install --cask macfuseThen approve the macFUSE system extension in System Settings > Privacy & Security if prompted. After approval, retry the mount command.
Notes:
- This prerequisite applies to
db9 fs mountonly. - Other
db9 fscommands such asfs cp,fs ls, andfs shdo not require macFUSE. - See the CLI Reference for the command-level mount docs.
Scalar Functions
Section titled “Scalar Functions”fs9_read
Section titled “fs9_read”Read an entire file as text.
SELECT extensions.fs9_read('/logs/app.log');Returns TEXT (UTF-8, lossy conversion for non-UTF-8 bytes). Returns NULL if the path argument is NULL.
fs9_read_bytea
Section titled “fs9_read_bytea”Read an entire file as binary.
SELECT extensions.fs9_read_bytea('/data/image.png');Returns BYTEA. Use this instead of fs9_read when the file contains non-UTF-8 binary data.
fs9_write
Section titled “fs9_write”Write content to a file, creating it if it does not exist and overwriting if it does. Returns the number of bytes written.
SELECT extensions.fs9_write('/data/output.txt', 'hello world');-- Returns: 11Accepts both TEXT and BYTEA content.
fs9_append
Section titled “fs9_append”Append content to the end of a file. Creates the file if it does not exist. Returns the number of bytes appended.
SELECT extensions.fs9_append('/logs/events.jsonl', '{"event":"click","ts":"2026-03-12T10:00:00Z"}' || E'\n');fs9_read_at
Section titled “fs9_read_at”Read a range of bytes from a file. Returns TEXT.
SELECT extensions.fs9_read_at('/data/large.bin', 0, 1024);-- Reads first 1024 bytesBoth offset and length must be non-negative. Reading beyond the end of the file returns whatever bytes are available.
fs9_read_at_bytea
Section titled “fs9_read_at_bytea”Read a range of bytes from a file. Returns BYTEA.
SELECT extensions.fs9_read_at_bytea('/data/large.bin', 0, 1024);-- Reads first 1024 bytes as binarySame semantics as fs9_read_at, but returns raw bytes instead of text.
fs9_write_at
Section titled “fs9_write_at”Write data at a specific offset in a file. Creates the file if it does not exist. Fills any gap between the current end and the offset with null bytes. Returns the number of bytes written.
SELECT extensions.fs9_write_at('/data/file.bin', 100, 'data at offset 100');fs9_truncate
Section titled “fs9_truncate”Truncate a file to a specific size. If the new size is larger than the current size, the file is padded with null bytes. Returns TRUE on success.
SELECT extensions.fs9_truncate('/logs/rolling.log', 0);-- Empties the filefs9_exists
Section titled “fs9_exists”Check whether a file or directory exists.
SELECT extensions.fs9_exists('/data/config.json');-- Returns: true or falsefs9_size
Section titled “fs9_size”Return the size of a file in bytes.
SELECT extensions.fs9_size('/data/export.csv');-- Returns: 48271fs9_mtime
Section titled “fs9_mtime”Return the last modification time of a file as an RFC 3339 timestamp (UTC).
SELECT extensions.fs9_mtime('/data/export.csv');-- Returns: '2026-03-12T15:30:45Z'fs9_remove
Section titled “fs9_remove”Remove a file or directory. Returns the number of items removed. Supports glob patterns.
-- Remove a single fileSELECT extensions.fs9_remove('/tmp/scratch.txt');
-- Remove a directory and its contentsSELECT extensions.fs9_remove('/tmp/work/', true);
-- Remove files matching a globSELECT extensions.fs9_remove('/logs/2026-01-*.jsonl');The second argument (recursive) defaults to false. A non-empty directory requires recursive = true.
fs9_mkdir
Section titled “fs9_mkdir”Create a directory. Returns TRUE on success.
-- Create a single directorySELECT extensions.fs9_mkdir('/data/exports');
-- Create nested directoriesSELECT extensions.fs9_mkdir('/data/exports/2026/03', true);The second argument (recursive) defaults to false. With recursive = true, parent directories are created as needed.
fs9_storage_stats
Section titled “fs9_storage_stats”Table-valued function that returns aggregated filesystem storage statistics.
SELECT total_files, total_directories, total_logical_bytesFROM extensions.fs9_storage_stats();| Column | Type | Description |
|---|---|---|
total_files | INT64 | Total number of files |
total_directories | INT64 | Total number of directories |
total_logical_bytes | INT64 | Total logical size of all files in bytes |
Always returns a single row. Useful for monitoring filesystem usage from SQL or building storage dashboards.
fs9_cached_storage_stats
Section titled “fs9_cached_storage_stats”Table-valued function that returns the most recently cached filesystem statistics, plus the time they were computed. It only reads the cache; it never triggers a scan.
SELECT total_files, total_directories, total_logical_bytes, computed_atFROM extensions.fs9_cached_storage_stats();| Column | Type | Description |
|---|---|---|
total_files | INT64 | Total number of files |
total_directories | INT64 | Total number of directories |
total_logical_bytes | INT64 | Total logical size of all files in bytes |
computed_at | INT64 | Unix epoch seconds at which the cached values were computed — not a TIMESTAMP. Convert with TO_TIMESTAMP(computed_at) |
Always returns exactly one row — never an empty result — so check the column values, not the row
count. In practice the cache is already warm: a brand-new database returns zeros with a
computed_at of the current time rather than NULL. Handle NULL defensively anyway, since the
cold-cache path returns an all-NULL row:
SELECT CASE WHEN computed_at IS NULL THEN 'no cached stats yet' ELSE 'computed at ' || TO_TIMESTAMP(computed_at)::text END AS cache_stateFROM extensions.fs9_cached_storage_stats();Table Function
Section titled “Table Function”The extensions.fs9() table function reads files and directories as SQL rows. It operates in three modes depending on the path.
Directory listing
Section titled “Directory listing”When the path ends with /, fs9 returns directory entries:
SELECT path, type, size, mtimeFROM extensions.fs9('/logs/')ORDER BY path;| Column | Type | Description |
|---|---|---|
path | TEXT | Full path |
type | TEXT | "file" or "dir" |
size | INT64 | Size in bytes (0 for directories) |
mode | INT64 | Unix permission mode |
mtime | TEXT | Last modified (RFC 3339 UTC) |
Use recursive => true to walk subdirectories:
SELECT path, size FROM extensions.fs9('/data/', recursive => true)WHERE type = 'file'ORDER BY size DESC;Use exclude to skip files matching a pattern:
SELECT * FROM extensions.fs9('/logs/', exclude => '*.tmp');File reading
Section titled “File reading”When the path points to a file, fs9 reads its contents as rows. The format is auto-detected from the file extension:
| Extension | Format | Row schema |
|---|---|---|
.csv | CSV | _line_number INT, columns from header, _path TEXT |
.tsv | TSV | _line_number INT, columns from header, _path TEXT |
.jsonl, .ndjson | JSON Lines | _line_number INT, line JSONB, _path TEXT |
.parquet | Parquet | Schema from file metadata |
| Other | Plain text | _line_number INT, line TEXT, _path TEXT |
-- CSV with auto-detected columnsSELECT * FROM extensions.fs9('/data/users.csv');
-- JSONL as queryable JSONBSELECT line->>'level' AS level, count(*)FROM extensions.fs9('/logs/app.jsonl')GROUP BY 1;Override the format or delimiter with named parameters:
-- Force CSV format on a .dat fileSELECT * FROM extensions.fs9('/data/raw.dat', format => 'csv');
-- Pipe-delimited file without a header rowSELECT col_0, col_1FROM extensions.fs9('/data/raw.dat', format => 'csv', delimiter => '|', header => false);For CSV/TSV without a header, columns are named col_0, col_1, etc.
Invalid JSON lines in JSONL files are silently skipped.
Glob matching
Section titled “Glob matching”When the path contains *, ?, or [, fs9 expands the glob and reads all matching files:
-- All CSV files in a directorySELECT * FROM extensions.fs9('/data/sales/*.csv');
-- Recursive match across subdirectoriesSELECT _path, line->>'event' AS eventFROM extensions.fs9('/logs/**/*.jsonl')ORDER BY _path, _line_number;The schema is determined by the first matching file. All files use the same format. The _path column identifies which file each row came from.
Glob parameters:
format,delimiter,header— same as file readingexclude— glob pattern to skip (e.g.,*.tmp)
Hidden files (dotfiles) are excluded unless the pattern explicitly starts with . or contains /..
JSONL Query Function
Section titled “JSONL Query Function”extensions.fs9_jg(path TEXT, query TEXT) queries JSON Lines files directly from SQL. Each line is
parsed independently and every match produces one row, so a JSONL log becomes a relation without an
intermediate COPY.
SELECT _line_number, _match_path, valueFROM extensions.fs9_jg('/logs/app.jsonl', 'level');| Column | Type | Description |
|---|---|---|
_path | TEXT | File the match came from — useful when path is a glob |
_line_number | INT64 | 1-based line number within that file |
_match_path | TEXT | Dotted path of the matched value (array indices are rendered as tags.0) |
value | JSONB | The matched value |
path accepts a glob, so one call can span files: extensions.fs9_jg('/logs/*.jsonl', 'level').
Supported path forms, all verified against a two-line file whose objects are
{"user":{"name":…,"age":…},"tags":[…]}:
| Query | Matches |
|---|---|
user.name | one row per line — nested object member |
tags[0] | array index; _match_path comes back as tags.0 |
tags[*] | every array element |
user.* | every member of an object |
** | recursive descent — the whole document plus every subpath |
A key that does not exist returns zero rows rather than an error, so treat an empty result as “no match” only after confirming the path spelling.
Event Notifications
Section titled “Event Notifications”The fs9_events() table function returns a stream of filesystem mutation events. It powers db9 fs watch and can be queried directly from SQL for custom change-tracking.
-- All events since the beginningSELECT * FROM extensions.fs9_events();
-- Events after a stream ID you previously read back (cursor-based polling)SELECT * FROM extensions.fs9_events('1786979518971-0');
-- Events under a specific path prefixSELECT * FROM extensions.fs9_events('0', '/data/');
-- Events with a custom limit (default: 10,000)SELECT * FROM extensions.fs9_events('0', '/logs/', 5000);Parameters
Section titled “Parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
since_id | TEXT | '0' | Return events with stream ID greater than this value (Redis Stream ID format, e.g. '1711324800000-0') |
path_prefix | TEXT | NULL | Filter to events where path starts with this prefix |
limit | INT64 | 10000 | Maximum number of event rows to return |
Return columns
Section titled “Return columns”| Column | Type | Description |
|---|---|---|
stream_id | TEXT | Redis Stream ID (e.g. '1711324800000-0') |
event_type | TEXT | CREATE, WRITE, DELETE, RENAME, or MKDIR |
path | TEXT | Affected path (post-mutation) |
old_path | TEXT | Previous path (only set for RENAME events) |
inode | INT64 | Inode ID |
generation | INT64 | Post-mutation generation |
is_dir | BOOLEAN | Whether the target is a directory |
size | INT64 | Post-mutation file size (0 for directories) |
timestamp | TIMESTAMPTZ | Event timestamp |
Note:
since_idalso accepts integer values for backwards compatibility — they are converted to string internally.
Cursor-based polling pattern
Section titled “Cursor-based polling pattern”-- Initial read: get the latest stream_idSELECT stream_id FROM extensions.fs9_events('0') ORDER BY stream_id DESC LIMIT 1;
-- Subsequent polls: pass the last seen stream_id as since_idSELECT stream_id, event_type, path, old_path, size, timestampFROM extensions.fs9_events(:last_stream_id)ORDER BY stream_id;For CLI-based watching, use db9 fs watch which handles the polling loop, overflow detection, and formatting automatically. See the CLI Reference.
Symbolic Links
Section titled “Symbolic Links”fs9 supports symbolic links through the CLI FUSE mount and WebSocket API.
CLI (FUSE Mount)
Section titled “CLI (FUSE Mount)”# Create a symlinkln -s /data/report.csv /data/latest.csv
# Read the symlink targetreadlink /data/latest.csvWebSocket API
Section titled “WebSocket API”The symlink and readlink operations are available over the WebSocket protocol:
symlink(path, target)— create a symbolic link atpathpointing totargetreadlink(path)— return the target of a symbolic link
Symlink properties:
- Mode:
0o777(all permissions) - Type: reported as
"symlink"instatresponses - Maximum target length: 4096 bytes
- Writing to a symlink directly is not allowed — write to the target path instead
File Permissions (Mode Bits)
Section titled “File Permissions (Mode Bits)”When creating files and directories, fs9 preserves Unix permission bits:
| Operation | Default Mode |
|---|---|
| File create | 0644 (rw-r--r--) |
| Directory create | 0755 (rwxr-xr-x) |
| Symlink | 0777 (rwxrwxrwx) |
Executables uploaded with db9 fs cp or written via FUSE retain their original mode bits (e.g., 0755 for shell scripts). The mode field is visible in stat responses and db9 fs ls -l output.
JSONL Support in fssh
Section titled “JSONL Support in fssh”The jq builtin in db9 fs sh supports JSON Lines (JSONL) format — when input contains multiple JSON objects (one per line), the filter is applied to each object independently:
db9 fs sh mydb -c "cat /logs/events.jsonl | jq '.event'""click""pageview""submit"Practical Patterns
Section titled “Practical Patterns”Append-only log ingestion
Section titled “Append-only log ingestion”-- Write a log entrySELECT extensions.fs9_append( '/logs/agent.jsonl', '{"ts":"2026-03-12T10:00:00Z","action":"search","query":"revenue"}' || E'\n');
-- Query log entriesSELECT line->>'action' AS action, count(*)FROM extensions.fs9('/logs/agent.jsonl')GROUP BY 1 ORDER BY 2 DESC;Periodic report generation with pg_cron
Section titled “Periodic report generation with pg_cron”SELECT cron.schedule('daily-report', '0 6 * * *', $$ SELECT extensions.fs9_write( '/reports/daily-' || to_char(now(), 'YYYY-MM-DD') || '.csv', (SELECT string_agg(id || ',' || item || ',' || total, E'\n') FROM (SELECT id::text, item, sum(amount)::text AS total FROM orders WHERE created_at > now() - interval '1 day' GROUP BY id, item) t) )$$);Upload via HTTP and store in fs9
Section titled “Upload via HTTP and store in fs9”SELECT extensions.fs9_write( '/imports/feed.json', http_get('https://api.example.com/feed')->>'content');Clean up old files
Section titled “Clean up old files”-- List files older than 7 daysSELECT path, mtime FROM extensions.fs9('/logs/')WHERE type = 'file' AND mtime < to_char(now() - interval '7 days', 'YYYY-MM-DD"T"HH24:MI:SS"Z"');
-- Remove themSELECT extensions.fs9_remove('/logs/2026-02-*.jsonl');Permissions
Section titled “Permissions”All fs9 scalar functions and the table function require superuser privileges. The default admin role is a superuser. Regular database users created via db9 db users create cannot use fs9.
Connect key scopes
Section titled “Connect key scopes”When accessing fs9 via the WebSocket API (used by the TypeScript SDK), connect keys control access:
| Scope | Permissions |
|---|---|
fs9:ro | Read-only: stat, read, readdir, size, mtime, exists |
fs9:rw | Read and write: all operations |
Connect keys are created via the REST API:
curl -X POST https://api.db9.ai/customer/databases/<id>/connect-keys \ -H "Authorization: Bearer $DB9_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "agent-fs", "scopes": ["fs9:rw"]}'WebSocket API
Section titled “WebSocket API”The TypeScript SDK uses a WebSocket connection for fs9 file operations. This provides streaming support for large files and avoids SQL overhead for file I/O.
The WebSocket server listens on port 5480 by default. The protocol uses JSON frames with an op field and a request id for correlation.
Available operations: auth, stat, readdir, mkdir, read, write, pwrite, append, truncate, unlink, rm, rename, symlink, readlink.
Files larger than 1 MB are automatically streamed in 64 KB chunks.
Connection limits:
- Auth timeout: 10 seconds
- Idle timeout: 5 minutes
- Max connections per tenant: 50
- Max JSON frame: 2 MB
See the TypeScript SDK for the client-side API.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Max file size | 100 MB |
| Max total bytes per glob query | 100 MB |
| Max files per glob expansion | 10,000 |
| Concurrent read budget (scalar functions) | 128 MB |
| Page size (internal storage) | 16 KB |
| Write stream flush threshold | 256 KB |
| WebSocket max connections per tenant | 50 |
| WebSocket idle timeout | 5 minutes |
| WebSocket max JSON frame | 2 MB |
| WebSocket streaming chunk size | 64 KB |
Error Messages
Section titled “Error Messages”| Error | SQLSTATE | Cause |
|---|---|---|
fs9: permission denied (superuser required) | 42501 | Non-superuser role attempted an fs9 operation |
fs9 resource was not found | 58P01 | The path does not exist — including the parent directory of a write |
fs9 resource has the wrong object type | 42809 | The path exists but is not the kind of object the call needs (reading a directory, for example) |
fs9 resource already exists | 58P02 | Creating something that is already there — e.g. fs9_mkdir on an existing directory |
fs9 directory is not empty | 42809 | fs9_remove on a directory that still has entries; pass true as the second argument to recurse |
permission denied for fs9 resource | 42501 | The role may use fs9 but not this particular resource |
fs9 resource exceeds the supported size limit | File exceeds 100 MB | |
internal error | XX000 | Argument validation failures (a negative fs9_read_at offset or fs9_truncate size) and I/O faults both surface this way, with no detail |
Argument-validation messages are not passed through to the client. fs9_truncate still
builds the text fs9_truncate: size must be non-negative internally, but the wire only ever
carries internal error, so do not match on it.
Serverless Functions
Section titled “Serverless Functions”fs9 is also accessible from Serverless Functions via ctx.fs9 — you can read and write files from your deployed JavaScript/TypeScript code without going through SQL.
See the Serverless Functions runtime for details.
Next Steps
Section titled “Next Steps”- Analyze Agent Logs with fs9 — Tutorial: write, query, and aggregate agent logs
- Scheduled Jobs with pg_cron — Automate fs9 reports on a schedule
- HTTP from SQL — Fetch external data and store in fs9
- Extensions Overview — All 9 built-in extensions
- Limits and Quotas — All operational limits
- TypeScript SDK — Programmatic fs9 access via WebSocket