From c8715811a08243b65e176b88a2dc358b99939277 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:16:17 +0000 Subject: [PATCH 1/6] dofs: Paginate directory entries Return file size and modification time from readdir, and apply limit and offset after pending write buffers are merged into stable name order. --- packages/dofs/src/fs/readdir.test.ts | 60 +++++++++++- packages/dofs/src/fs/readdir.ts | 132 ++++++++++++++++++++------- 2 files changed, 155 insertions(+), 37 deletions(-) diff --git a/packages/dofs/src/fs/readdir.test.ts b/packages/dofs/src/fs/readdir.test.ts index 77b69ea6..9580954b 100644 --- a/packages/dofs/src/fs/readdir.test.ts +++ b/packages/dofs/src/fs/readdir.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; import { mkdir } from "./mkdir.js"; import { readdir } from "./readdir.js"; import { withDB } from "./with-db.js"; +import { + openWriteBufferForCreateSync, + releaseWriteBufferSync, + writeRangeSync, +} from "./writeFile.js"; import { writeFile } from "./writeFile.js"; describe("readdir", () => { @@ -22,6 +27,8 @@ describe("readdir", () => { expect(entries).toContainEqual({ name: "file.txt", parentPath: "/", + size: 1, + mtime: 0, isFile: true, isDirectory: false, isSymbolicLink: false, @@ -29,6 +36,8 @@ describe("readdir", () => { expect(entries).toContainEqual({ name: "sub", parentPath: "/", + size: 0, + mtime: 0, isFile: false, isDirectory: true, isSymbolicLink: false, @@ -45,10 +54,53 @@ describe("readdir", () => { }); }); - it("limits committed entries before materializing the result", async () => { + it("paginates committed entries by stable name order", async () => { await withDB(async (db) => { - for (const name of ["a", "b", "c"]) await writeFile(db, `/${name}`, "", {}, () => 0); - expect(readdir(db, "/", { limit: 2 }).map((entry) => entry.name)).toEqual(["a", "b"]); + for (const name of ["a", "b", "c", "d"]) { + await writeFile(db, `/${name}`, name, {}, () => 0); + } + expect(readdir(db, "/", { limit: 2, offset: 0 }).map((entry) => entry.name)).toEqual([ + "a", + "b", + ]); + expect(readdir(db, "/", { limit: 2, offset: 2 }).map((entry) => entry.name)).toEqual([ + "c", + "d", + ]); + }); + }); + + it("merges pending files before applying pagination", async () => { + await withDB(async (db) => { + for (const name of ["a", "c", "e"]) { + await writeFile(db, `/${name}`, name, {}, () => 0); + } + openWriteBufferForCreateSync(db, "/b", {}, () => 10); + writeRangeSync(db, "/b", new TextEncoder().encode("pending"), 0, {}, () => 11); + + expect(readdir(db, "/", { limit: 2, offset: 0 }).map((entry) => entry.name)).toEqual([ + "a", + "b", + ]); + expect(readdir(db, "/", { limit: 2, offset: 2 }).map((entry) => entry.name)).toEqual([ + "c", + "e", + ]); + expect(readdir(db, "/", { limit: 2, offset: 0 })[1]).toMatchObject({ + name: "b", + size: 7, + mtime: 10, + }); + + releaseWriteBufferSync(db, "/b", () => 12); + }); + }); + + it("rejects invalid offsets", async () => { + await withDB((db) => { + expect(() => readdir(db, "/", { offset: -1 })).toThrowError( + "readdir offset must be a non-negative safe integer", + ); }); }); @@ -62,6 +114,8 @@ describe("readdir", () => { { name: "leaf.txt", parentPath: "/a/b", + size: 1, + mtime: 0, isFile: true, isDirectory: false, isSymbolicLink: false, diff --git a/packages/dofs/src/fs/readdir.ts b/packages/dofs/src/fs/readdir.ts index dddfa8d1..90e603f4 100644 --- a/packages/dofs/src/fs/readdir.ts +++ b/packages/dofs/src/fs/readdir.ts @@ -2,24 +2,32 @@ import { createWorkspaceError } from "../errors.js"; import { canonicalizePath } from "../path.js"; import type { Database } from "../storage.js"; import { resolveInode } from "./resolve.js"; -import { listPendingByParent } from "./writeBuffer.js"; +import { getWriteBuffer, listPendingByParent } from "./writeBuffer.js"; export interface WorkspaceDirentResult { name: string; parentPath: string; + size: number; + mtime: number; isFile: boolean; isDirectory: boolean; isSymbolicLink: boolean; } interface DirentRow { + inode: number; name: string; type: "file" | "dir" | "symlink"; + size: number; + mtime: number; + link_target: string | null; } export interface ReaddirOptions { - /** Maximum committed entries to materialize. Pending entries may extend the result. */ + /** Maximum entries to return. */ limit?: number; + /** Number of entries to skip in name order. */ + offset?: number; } export function readdir( @@ -40,45 +48,101 @@ export function readdir( if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) { throw new TypeError("readdir limit must be a non-negative safe integer"); } - const rows = db.all( - `SELECT d.name AS name, n.type AS type + const offset = options.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new TypeError("readdir offset must be a non-negative safe integer"); + } + if (limit === 0) return []; + + const pending = listPendingByParent(db, node.inode) + .filter((entry) => entry.pending !== undefined) + .map( + (entry): WorkspaceDirentResult => ({ + name: entry.pending?.leafName ?? "", + parentPath: canonical, + size: entry.size, + mtime: entry.pending?.mtime ?? 0, + isFile: true, + isDirectory: false, + isSymbolicLink: false, + }), + ); + + // Pending creates live outside SQLite until their final release. If there + // are none, let SQLite apply the requested page directly. Otherwise fetch + // only enough committed rows to merge the requested page in name order. + const queryOffset = pending.length === 0 ? offset : 0; + const queryLimit = + pending.length === 0 + ? limit + : limit === undefined + ? undefined + : Math.min(Number.MAX_SAFE_INTEGER, offset + limit); + const rows = readRows(db, node.inode, queryLimit, queryOffset); + const entries = rows.map((row) => toResult(db, canonical, row)); + + if (pending.length === 0) return entries; + + const seen = new Set(entries.map((entry) => entry.name)); + for (const entry of pending) { + if (!seen.has(entry.name)) entries.push(entry); + } + entries.sort(compareByName); + return entries.slice(offset, limit === undefined ? undefined : offset + limit); +} + +function readRows( + db: Database, + parentInode: number, + limit: number | undefined, + offset: number, +): DirentRow[] { + const pagination = + limit !== undefined ? "LIMIT ? OFFSET ?" : offset > 0 ? "LIMIT -1 OFFSET ?" : ""; + const params = + limit !== undefined + ? [parentInode, limit, offset] + : offset > 0 + ? [parentInode, offset] + : [parentInode]; + return db.all( + `SELECT n.inode AS inode, + d.name AS name, + n.type AS type, + n.size AS size, + n.mtime AS mtime, + n.link_target AS link_target FROM vfs_dirents d JOIN vfs_nodes n ON n.inode = d.child_inode WHERE d.parent_inode = ? ORDER BY d.name - ${limit === undefined ? "" : "LIMIT ?"}`, - ...(limit === undefined ? [node.inode] : [node.inode, limit]), + ${pagination}`, + ...params, ); +} - const entries = rows.map((row) => ({ +function toResult(db: Database, parentPath: string, row: DirentRow): WorkspaceDirentResult { + const isFile = row.type === "file"; + const isSymbolicLink = row.type === "symlink"; + const buffered = isFile ? getWriteBuffer(db, row.inode) : undefined; + const size = isFile + ? buffered?.dirty + ? buffered.size + : row.size + : isSymbolicLink + ? (row.link_target ?? "").length + : 0; + return { name: row.name, - parentPath: canonical, - isFile: row.type === "file", + parentPath, + size, + mtime: row.mtime, + isFile, isDirectory: row.type === "dir", - isSymbolicLink: row.type === "symlink", - })); - - // Merge in pending-create buffers parented under this directory so - // a `readdir` between FUSE create and release still surfaces the - // file. Skip any whose name already appears in the SQL rows (in - // case a concurrent commit just landed it). - const pending = listPendingByParent(db, node.inode); - if (pending.length > 0) { - const seen = new Set(entries.map((e) => e.name)); - for (const entry of pending) { - if (entry.pending === undefined) continue; - const { leafName } = entry.pending; - if (seen.has(leafName)) continue; - entries.push({ - name: leafName, - parentPath: canonical, - isFile: true, - isDirectory: false, - isSymbolicLink: false, - }); - } - entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - } + isSymbolicLink, + }; +} - return entries; +function compareByName(a: WorkspaceDirentResult, b: WorkspaceDirentResult): number { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; } From a69e407aa0ffe691ee07048c2e4ba21a2d2629f2 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:17:05 +0000 Subject: [PATCH 2/6] dofs: Expose bounded file reads Add readRange to WorkspaceFilesystem so callers can read a byte window without materializing or transferring the whole file. --- packages/dofs/src/fs/filesystem.test.ts | 8 ++++++++ packages/dofs/src/fs/filesystem.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/dofs/src/fs/filesystem.test.ts b/packages/dofs/src/fs/filesystem.test.ts index 3e2842d3..ff4169c4 100644 --- a/packages/dofs/src/fs/filesystem.test.ts +++ b/packages/dofs/src/fs/filesystem.test.ts @@ -47,6 +47,14 @@ describe("WorkspaceFilesystem", () => { }); }); + it("readRange returns only the requested bytes", async () => { + await withFs(async (fs) => { + await fs.writeFile("/bin", new Uint8Array([1, 2, 3, 4, 5])); + expect(Array.from(await fs.readRange("/bin", 1, 3))).toEqual([2, 3, 4]); + expect(await fs.readRange("/bin", 5, 3)).toEqual(new Uint8Array()); + }); + }); + it("stat returns the documented shape for a file", async () => { await withFs(async (fs) => { await fs.writeFile("/a.txt", "ab"); diff --git a/packages/dofs/src/fs/filesystem.ts b/packages/dofs/src/fs/filesystem.ts index 82ea588c..a818c744 100644 --- a/packages/dofs/src/fs/filesystem.ts +++ b/packages/dofs/src/fs/filesystem.ts @@ -20,7 +20,7 @@ import { type GrepOptions, grep, type WorkspaceGrepMatch } from "./grep.js"; import { ls } from "./ls.js"; import { type MkdirOptions, mkdir } from "./mkdir.js"; import { type ReaddirOptions, readdir, type WorkspaceDirentResult } from "./readdir.js"; -import { type ReadFileOptions, readFile } from "./readFile.js"; +import { type ReadFileOptions, readFile, readRangeSync } from "./readFile.js"; import { readlink } from "./readlink.js"; import { type RmOptions, rm } from "./rm.js"; import { lstat, stat, type WorkspaceStatResult } from "./stat.js"; @@ -60,6 +60,10 @@ export class WorkspaceFilesystem { return readFile(this.db, path, optionsOrEncoding as ReadFileOptions); } + async readRange(path: string, offset: number, length: number): Promise { + return readRangeSync(this.db, path, offset, length); + } + async stat(path: string): Promise { return stat(this.db, path); } From c978462d788167512a96552203d7366eeeaf4c2b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:18:15 +0000 Subject: [PATCH 3/6] dofs: Match single-character globs Teach find that a question mark matches one non-separator character so its glob surface covers the pattern documented by the AI find tool. --- packages/dofs/src/fs/find.test.ts | 11 +++++++++++ packages/dofs/src/fs/find.ts | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/packages/dofs/src/fs/find.test.ts b/packages/dofs/src/fs/find.test.ts index f2e8d064..bd47264c 100644 --- a/packages/dofs/src/fs/find.test.ts +++ b/packages/dofs/src/fs/find.test.ts @@ -54,6 +54,17 @@ describe("find", () => { }); }); + it("matches ? as one non-separator character", async () => { + await withDB(async (db) => { + mkdir(db, "/a", {}, () => 0); + await writeFile(db, "/a/a.ts", "", {}, () => 0); + await writeFile(db, "/a/ab.ts", "", {}, () => 0); + await writeFile(db, "/a/b.ts", "", {}, () => 0); + const paths = find(db, "/a", "?.ts").map((entry) => entry.path); + expect(paths).toEqual(["/a/a.ts", "/a/b.ts"]); + }); + }); + it("matches ** recursively", async () => { await withDB(async (db) => { mkdir(db, "/a/b/c", { recursive: true }, () => 0); diff --git a/packages/dofs/src/fs/find.ts b/packages/dofs/src/fs/find.ts index 67e1f747..9caaf2da 100644 --- a/packages/dofs/src/fs/find.ts +++ b/packages/dofs/src/fs/find.ts @@ -65,6 +65,7 @@ function walk(db: Database, parentInode: number, parentPath: string, out: Worksp // Compile a simple glob into a regex. Supported: // * matches any run of characters except '/' // ** matches any run of characters including '/' +// ? matches one character except '/' // Anything else is a literal. Regex metacharacters in literals are // escaped so '.' in '*.ts' doesn't match an arbitrary character. function compileGlob(pattern: string): RegExp { @@ -89,6 +90,11 @@ function compileGlob(pattern: string): RegExp { } continue; } + if (ch === "?") { + re += "[^/]"; + i += 1; + continue; + } if (REGEX_METACHARS.has(ch)) { re += `\\${ch}`; } else { From e583527a08651b83a7504b500404cf6d6e3aa070 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:20:31 +0000 Subject: [PATCH 4/6] dofs: Add bounded grep options Keep literal, case-sensitive defaults while adding regular expressions, explicit case handling, numbered context, and limit and offset controls for tool callers. --- packages/dofs/src/fs/grep.test.ts | 53 ++++++- packages/dofs/src/fs/grep.ts | 222 +++++++++++++++++++++++------- packages/dofs/src/index.ts | 6 +- 3 files changed, 226 insertions(+), 55 deletions(-) diff --git a/packages/dofs/src/fs/grep.test.ts b/packages/dofs/src/fs/grep.test.ts index aef8fbcf..1823851d 100644 --- a/packages/dofs/src/fs/grep.test.ts +++ b/packages/dofs/src/fs/grep.test.ts @@ -43,14 +43,65 @@ describe("grep", () => { }); }); - it("respects ignoreCase", async () => { + it("respects explicit case options and the ignoreCase alias", async () => { await withDB(async (db) => { await writeFile(db, "/a.txt", "todo\nTODO\nTodo\n", {}, () => 0); expect((await grep(db, "TODO", "/a.txt", { ignoreCase: true })).length).toBe(3); + expect((await grep(db, "TODO", "/a.txt", { caseSensitive: false })).length).toBe(3); + expect((await grep(db, "TODO", "/a.txt", { caseSensitive: true })).length).toBe(1); expect((await grep(db, "TODO", "/a.txt")).length).toBe(1); }); }); + it("supports regular expressions and fixed strings", async () => { + await withDB(async (db) => { + await writeFile(db, "/a.txt", "task 12\ntask \\d+\ntask xx\n", {}, () => 0); + expect( + (await grep(db, String.raw`task \d+`, "/a.txt", { fixedString: false })).map( + (match) => match.line, + ), + ).toEqual([1]); + expect( + (await grep(db, String.raw`task \d+`, "/a.txt", { fixedString: true })).map( + (match) => match.line, + ), + ).toEqual([2]); + await expect(grep(db, "[", "/a.txt", { fixedString: false })).rejects.toThrow( + "Invalid regular expression", + ); + }); + }); + + it("returns numbered context around matches", async () => { + await withDB(async (db) => { + await writeFile(db, "/a.txt", "one\ntwo\nTODO\nfour\nfive\n", {}, () => 0); + expect(await grep(db, "TODO", "/a.txt", { contextLines: 1 })).toEqual([ + { + path: "/a.txt", + line: 3, + text: "TODO", + context: [ + { line: 2, text: "two", isMatch: false }, + { line: 3, text: "TODO", isMatch: true }, + { line: 4, text: "four", isMatch: false }, + ], + }, + ]); + }); + }); + + it("applies offset and limit across files in path and line order", async () => { + await withDB(async (db) => { + await writeFile(db, "/a.txt", "TODO a1\nTODO a2\n", {}, () => 0); + await writeFile(db, "/b.txt", "TODO b1\nTODO b2\n", {}, () => 0); + expect( + (await grep(db, "TODO", "/", { offset: 1, limit: 2 })).map( + (match) => `${match.path}:${match.line}`, + ), + ).toEqual(["/a.txt:2", "/b.txt:1"]); + }); + }); + it("matches across a chunk boundary", async () => { await withDB(async (db) => { // Lay out a file whose line straddles the 512KiB chunk boundary. diff --git a/packages/dofs/src/fs/grep.ts b/packages/dofs/src/fs/grep.ts index fd28471c..3d81e209 100644 --- a/packages/dofs/src/fs/grep.ts +++ b/packages/dofs/src/fs/grep.ts @@ -5,14 +5,47 @@ import { find } from "./find.js"; import { readFile } from "./readFile.js"; import { resolveInode } from "./resolve.js"; +export interface WorkspaceGrepContextLine { + line: number; + text: string; + isMatch: boolean; +} + export interface WorkspaceGrepMatch { path: string; line: number; text: string; + context?: WorkspaceGrepContextLine[]; } export interface GrepOptions { + /** Compatibility alias for `caseSensitive: false`. */ ignoreCase?: boolean; + /** Match letter case. Defaults to true. */ + caseSensitive?: boolean; + /** Treat the pattern as plain text. Defaults to true. */ + fixedString?: boolean; + /** Lines of context to include before and after each match. */ + contextLines?: number; + /** Maximum matches to return. */ + limit?: number; + /** Matching lines to skip before collecting results. */ + offset?: number; +} + +interface ScanState { + seen: number; + accepted: number; +} + +interface NumberedLine { + line: number; + text: string; +} + +interface PendingMatch { + match: WorkspaceGrepMatch; + remaining: number; } export async function grep( @@ -27,81 +60,164 @@ export async function grep( throw createWorkspaceError("ENOENT", `no such path: ${canonical}`, canonical); } + const settings = normalizeOptions(options); + if (settings.limit === 0) return []; + const matcher = compileMatcher(pattern, settings.fixedString, settings.caseSensitive); const filePaths = node.type === "file" ? [canonical] : find(db, canonical) .filter((entry) => entry.type === "file") - .map((entry) => entry.path); + .map((entry) => entry.path) + .sort(); const matches: WorkspaceGrepMatch[] = []; + const state: ScanState = { seen: 0, accepted: 0 }; for (const filePath of filePaths) { - await scanFile(db, filePath, pattern, options, matches); + const complete = await scanFile( + db, + filePath, + matcher, + settings.contextLines, + settings.offset, + settings.limit, + state, + matches, + ); + if (complete) break; } return matches; } -// Stream the file in chunks so very large files don't load fully into -// memory. Carry a partial-line tail between chunks (everything after -// the last '\n') so a line that straddles a chunk boundary still -// matches as one line. Line numbers are 1-indexed. +function normalizeOptions(options: GrepOptions): { + caseSensitive: boolean; + fixedString: boolean; + contextLines: number; + limit: number; + offset: number; +} { + if ( + options.caseSensitive !== undefined && + options.ignoreCase !== undefined && + options.caseSensitive === options.ignoreCase + ) { + throw new TypeError("caseSensitive conflicts with ignoreCase"); + } + const contextLines = options.contextLines ?? 0; + if (!Number.isSafeInteger(contextLines) || contextLines < 0) { + throw new TypeError("grep contextLines must be a non-negative safe integer"); + } + const limit = options.limit ?? Number.MAX_SAFE_INTEGER; + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new TypeError("grep limit must be a non-negative safe integer"); + } + const offset = options.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new TypeError("grep offset must be a non-negative safe integer"); + } + return { + caseSensitive: options.caseSensitive ?? options.ignoreCase !== true, + fixedString: options.fixedString ?? true, + contextLines, + limit, + offset, + }; +} + +function compileMatcher(pattern: string, fixedString: boolean, caseSensitive: boolean): RegExp { + const source = fixedString ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern; + try { + return new RegExp(source, caseSensitive ? "" : "i"); + } catch (error) { + throw new TypeError( + `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + async function scanFile( db: Database, path: string, - pattern: string, - options: GrepOptions, + matcher: RegExp, + contextLines: number, + offset: number, + limit: number, + state: ScanState, out: WorkspaceGrepMatch[], -): Promise { - const stream = await readFile(db, path); - const reader = stream.getReader(); - const decoder = new TextDecoder("utf-8", { fatal: false }); - const needle = options.ignoreCase ? pattern.toUpperCase() : pattern; +): Promise { + const before: NumberedLine[] = []; + const pending: PendingMatch[] = []; - let tail = ""; - let lineNo = 1; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value === undefined) continue; - const text = tail + decoder.decode(value, { stream: true }); - const newlineIdx = text.lastIndexOf("\n"); - const ready = newlineIdx === -1 ? "" : text.slice(0, newlineIdx); - tail = newlineIdx === -1 ? text : text.slice(newlineIdx + 1); - if (ready.length > 0) { - lineNo = scanLines(ready, lineNo, needle, options.ignoreCase === true, path, out); + for await (const current of readLines(db, path)) { + for (const item of pending) { + item.match.context?.push({ ...current, isMatch: false }); + item.remaining -= 1; } + flushReady(pending, out); + if (state.accepted >= limit && pending.length === 0) return true; + + if (matcher.test(current.text)) { + const matchIndex = state.seen; + state.seen += 1; + if (matchIndex >= offset && state.accepted < limit) { + const match: WorkspaceGrepMatch = { path, ...current }; + if (contextLines > 0) { + match.context = [ + ...before.map((line) => ({ ...line, isMatch: false })), + { ...current, isMatch: true }, + ]; + pending.push({ match, remaining: contextLines }); + } else { + out.push(match); + } + state.accepted += 1; + } + } + + before.push(current); + if (before.length > contextLines) before.shift(); + if (state.accepted >= limit && pending.length === 0) return true; } - // Drain the decoder and scan whatever's left (final line without a - // trailing newline). - tail += decoder.decode(); - if (tail.length > 0) { - scanLines(tail, lineNo, needle, options.ignoreCase === true, path, out); + + for (const item of pending) out.push(item.match); + return state.accepted >= limit; +} + +function flushReady(pending: PendingMatch[], out: WorkspaceGrepMatch[]): void { + while (pending[0]?.remaining === 0) { + const item = pending.shift(); + if (item !== undefined) out.push(item.match); } } -// Walk `block` line-by-line, push matches into `out`, return the next -// 1-indexed line number to use for the following block. -function scanLines( - block: string, - startLine: number, - needle: string, - ignoreCase: boolean, - path: string, - out: WorkspaceGrepMatch[], -): number { - let line = startLine; - let cursor = 0; - while (cursor <= block.length) { - const next = block.indexOf("\n", cursor); - const end = next === -1 ? block.length : next; - const text = block.slice(cursor, end); - const haystack = ignoreCase ? text.toUpperCase() : text; - if (haystack.includes(needle)) { - out.push({ path, line, text }); +async function* readLines(db: Database, path: string): AsyncIterable { + const stream = await readFile(db, path); + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let tail = ""; + let line = 1; + let completed = false; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + completed = true; + break; + } + if (value === undefined) continue; + tail += decoder.decode(value, { stream: true }); + let newline = tail.indexOf("\n"); + while (newline !== -1) { + yield { line, text: tail.slice(0, newline) }; + line += 1; + tail = tail.slice(newline + 1); + newline = tail.indexOf("\n"); + } } - line += 1; - if (next === -1) break; - cursor = next + 1; + tail += decoder.decode(); + if (tail.length > 0) yield { line, text: tail }; + } finally { + if (!completed) await reader.cancel(); + reader.releaseLock(); } - return line; } diff --git a/packages/dofs/src/index.ts b/packages/dofs/src/index.ts index 76ef05a5..3924fc9b 100644 --- a/packages/dofs/src/index.ts +++ b/packages/dofs/src/index.ts @@ -6,7 +6,11 @@ export { type WorkspaceFilesystemOptions, } from "./fs/filesystem.js"; export type { WorkspaceFoundEntry } from "./fs/find.js"; -export type { GrepOptions, WorkspaceGrepMatch } from "./fs/grep.js"; +export type { + GrepOptions, + WorkspaceGrepContextLine, + WorkspaceGrepMatch, +} from "./fs/grep.js"; export { link } from "./fs/link.js"; export type { MkdirOptions } from "./fs/mkdir.js"; // Read-only mount enforcement. The workspace-side indexer writes From 93b389e9e7f1baa3fd5040b0023b36d4b92df1fc Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:49:16 +0000 Subject: [PATCH 5/6] dofs: Organize readdir test imports Keep the new pagination tests compliant with the repository-wide Biome import ordering check. --- packages/dofs/src/fs/readdir.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dofs/src/fs/readdir.test.ts b/packages/dofs/src/fs/readdir.test.ts index 9580954b..bf1d24d3 100644 --- a/packages/dofs/src/fs/readdir.test.ts +++ b/packages/dofs/src/fs/readdir.test.ts @@ -6,9 +6,9 @@ import { withDB } from "./with-db.js"; import { openWriteBufferForCreateSync, releaseWriteBufferSync, + writeFile, writeRangeSync, } from "./writeFile.js"; -import { writeFile } from "./writeFile.js"; describe("readdir", () => { it("returns an empty array for an empty directory", async () => { From 80d82a83c3c9ff2303d1cea3876166bfedd2ed23 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:23:57 +0000 Subject: [PATCH 6/6] dofs: Add bounded filesystem changeset Record the directory, range-read, glob, and grep additions with the storage package that introduces them. --- .changeset/dofs-bounded-filesystem.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dofs-bounded-filesystem.md diff --git a/.changeset/dofs-bounded-filesystem.md b/.changeset/dofs-bounded-filesystem.md new file mode 100644 index 00000000..4b76e8ac --- /dev/null +++ b/.changeset/dofs-bounded-filesystem.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/dofs": minor +--- + +Add stable directory pagination with metadata, bounded byte reads, single-character globs, and configurable bounded grep results.