├── apps
└── cf-demo
│ ├── bun.lockb
│ ├── src
│ ├── App.css
│ ├── vite-env.d.ts
│ ├── lib
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── main.tsx
│ ├── components
│ │ ├── progress.tsx
│ │ ├── ui
│ │ │ ├── label.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── input.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ └── select.tsx
│ │ ├── file-viewer.tsx
│ │ ├── repo-header.tsx
│ │ └── file-explorer.tsx
│ ├── App.tsx
│ ├── app
│ │ └── page.tsx
│ ├── pages
│ │ ├── home.tsx
│ │ └── repo.tsx
│ ├── assets
│ │ ├── Cloudflare_Logo.svg
│ │ └── react.svg
│ └── index.css
│ ├── README.md
│ ├── tsconfig.worker.json
│ ├── tsconfig.json
│ ├── .gitignore
│ ├── index.html
│ ├── components.json
│ ├── vite.config.ts
│ ├── wrangler.jsonc
│ ├── tsconfig.node.json
│ ├── tsconfig.app.json
│ ├── eslint.config.js
│ ├── public
│ └── vite.svg
│ ├── package.json
│ └── worker
│ ├── index.ts
│ ├── lib
│ └── walk-tree.ts
│ └── do
│ └── readonly-repo-object.ts
├── index.ts
├── packages
├── git-sqlite-cli
│ ├── .gitignore
│ ├── src
│ │ ├── index.ts
│ │ ├── types
│ │ │ └── minimist.d.ts
│ │ ├── cli.ts
│ │ └── commands
│ │ │ ├── ls-tree.ts
│ │ │ └── clone.ts
│ ├── tsconfig.json
│ └── package.json
└── sqlite-fs
│ ├── README.md
│ ├── src
│ ├── index.ts
│ ├── path-utils.ts
│ ├── error-utils.ts
│ ├── types.ts
│ ├── errors.ts
│ ├── schema.ts
│ ├── interfaces.ts
│ ├── stats-utils.ts
│ ├── bun-sqlite-adapter.ts
│ ├── do-sqlite-adapter.ts
│ └── sqlite-fs-adapter.ts
│ ├── .gitignore
│ ├── package.json
│ ├── tsconfig.json
│ └── tests
│ ├── path-utils.test.ts
│ ├── error-utils.test.ts
│ ├── stats-utils.test.ts
│ ├── bun-sqlite-adapter.test.ts
│ └── sqlite-fs-adapter.test.ts
├── package.json
├── .gitignore
├── README.md
├── tsconfig.json
├── OpenCode.md
├── pnpm-lock.yaml
└── docs
└── steps
├── step_8.md
├── step_7.md
├── step_3.md
├── step_5.md
├── step_4.md
├── step_6.md
└── step_2.md
/apps/cf-demo/bun.lockb:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/App.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | console.log("Hello via Bun!");
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/.gitignore:
--------------------------------------------------------------------------------
1 | data
2 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/README.md:
--------------------------------------------------------------------------------
1 | # sqlite-fs
2 |
3 | To install dependencies:
4 |
5 | ```bash
6 | bun install
7 | ```
8 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./interfaces";
2 | export * from "./types";
3 | export { SQLiteFSAdapter } from "./sqlite-fs-adapter";
4 |
--------------------------------------------------------------------------------
/apps/cf-demo/README.md:
--------------------------------------------------------------------------------
1 | # Git Durable Object Demo
2 |
3 | This is a demo of using sqlite-fs together with isomorphic-git to create Git repositories backed by Cloudflare Durable Objects.
4 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export type FileEntry = {
2 | path: string;
3 | name: string;
4 | type: "file" | "directory";
5 | children?: FileEntry[];
6 | oid: string;
7 | };
8 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/src/index.ts:
--------------------------------------------------------------------------------
1 | // src/index.ts
2 | // This file serves as the main entry point for the package
3 | // It simply re-exports the CLI functionality
4 |
5 | import './cli.js';
--------------------------------------------------------------------------------
/apps/cf-demo/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/src/types/minimist.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'minimist' {
2 | export interface ParsedArgs {
3 | _: string[];
4 | [key: string]: any;
5 | }
6 |
7 | export default function parseArgs(args: string[], opts?: any): ParsedArgs;
8 | }
--------------------------------------------------------------------------------
/apps/cf-demo/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/apps/cf-demo/tsconfig.worker.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.node.json",
3 | "compilerOptions": {
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo",
5 | "types": ["@cloudflare/workers-types/2023-07-01", "vite/client", "node"]
6 | },
7 | "include": ["./worker-configuration.d.ts", "./worker"]
8 | }
9 |
--------------------------------------------------------------------------------
/apps/cf-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" },
6 | { "path": "./tsconfig.worker.json" }
7 | ],
8 | "compilerOptions": {
9 | "baseUrl": ".",
10 | "paths": {
11 | "@/*": ["./src/*"]
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/apps/cf-demo/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | .wrangler
26 | dist
27 |
--------------------------------------------------------------------------------
/apps/cf-demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Git Durable Object Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "outDir": "dist",
11 | "declaration": true,
12 | "types": ["bun-types"]
13 | },
14 | "include": ["src/**/*"],
15 | "exclude": ["node_modules", "dist"]
16 | }
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/path-utils.ts:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 |
3 | export const dirname = path.dirname;
4 | export const basename = path.basename;
5 | export const join = path.join;
6 | export const normalize = path.normalize; // Useful for handling '.' and '..'
7 |
8 | // Custom helper for handling root path case
9 | export function getParentPath(p: string): string {
10 | const parent = path.dirname(p);
11 | return parent === p ? "" : parent; // Handle root case
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cf-git-durable-object",
3 | "module": "index.ts",
4 | "type": "module",
5 | "private": true,
6 | "workspaces": [
7 | "packages/*",
8 | "apps/*"
9 | ],
10 | "devDependencies": {
11 | "@types/bun": "latest"
12 | },
13 | "peerDependencies": {
14 | "typescript": "^5"
15 | },
16 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
17 | }
18 |
--------------------------------------------------------------------------------
/apps/cf-demo/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/index.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/apps/cf-demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { cloudflare } from "@cloudflare/vite-plugin";
4 | import path from "node:path";
5 |
6 | import tailwindcss from "@tailwindcss/vite";
7 |
8 | // https://vite.dev/config/
9 | export default defineConfig({
10 | // @ts-expect-error some dependency incompatibility
11 | plugins: [tailwindcss(), react(), cloudflare()],
12 | resolve: {
13 | alias: {
14 | "@": path.resolve(__dirname, "./src"),
15 | },
16 | },
17 | });
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | # output
5 | out
6 | dist
7 | *.tgz
8 |
9 | # code coverage
10 | coverage
11 | *.lcov
12 |
13 | # logs
14 | logs
15 | _.log
16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17 |
18 | # dotenv environment variable files
19 | .env
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 | .env.local
24 |
25 | # caches
26 | .eslintcache
27 | .cache
28 | *.tsbuildinfo
29 |
30 | # IntelliJ based IDEs
31 | .idea
32 |
33 | # Finder (MacOS) folder config
34 | .DS_Store
35 | .opencode
36 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | # output
5 | out
6 | dist
7 | *.tgz
8 |
9 | # code coverage
10 | coverage
11 | *.lcov
12 |
13 | # logs
14 | logs
15 | _.log
16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17 |
18 | # dotenv environment variable files
19 | .env
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 | .env.local
24 |
25 | # caches
26 | .eslintcache
27 | .cache
28 | *.tsbuildinfo
29 |
30 | # IntelliJ based IDEs
31 | .idea
32 |
33 | # Finder (MacOS) folder config
34 | .DS_Store
35 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/progress.tsx:
--------------------------------------------------------------------------------
1 | import { Progress as ProgressBar } from "./ui/progress";
2 |
3 | export function Progress({
4 | phase,
5 | loaded,
6 | total,
7 | }: {
8 | phase: string;
9 | loaded: number;
10 | total: number;
11 | }) {
12 | return (
13 |
14 |
{phase}
15 |
18 |
{`${loaded} / ${total}`}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Switch } from "wouter";
2 | import "./App.css";
3 | import { HomePage } from "./pages/home";
4 | import { RepoPage } from "./pages/repo";
5 |
6 | function App() {
7 | return (
8 |
9 |
10 |
11 |
12 | {(params) => }
13 |
14 |
15 | {/* Default route in a switch */}
16 | 404: No such page!
17 |
18 | );
19 | }
20 |
21 | export default App;
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Git Durable Object Demo
2 |
3 | This is a proof of concept for using isomorphic-git with Cloudflare Durable Objects to create a Git repository that is backed by a SQLite database.
4 |
5 | ## Packages
6 |
7 | - [sqlite-fs](./packages/sqlite-fs): A node fs implementation that uses SQLite as a backend, provides a bun sqlite adapater and a durable object sqlite adapater.
8 | - [git-sqlite-clit](./packages/git-sqlite-cli): Proof of concept cli app using the bun sqlite backend.
9 | - [cf-demo](./apps/cf-demo): A cloudflare workers/durable object demo app showcasing `clone` and `fetch`
10 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sqlite-fs",
3 | "module": "src/index.ts",
4 | "type": "module",
5 | "exports": {
6 | ".": "./src/index.ts",
7 | "./bun": "./src/bun-sqlite-adapter.ts",
8 | "./do": "./src/do-sqlite-adapter.ts"
9 | },
10 | "scripts": {
11 | "build": "bun build src/index.ts --outdir=dist --format=esm",
12 | "typecheck": "tsc --noEmit",
13 | "test": "vitest run"
14 | },
15 | "devDependencies": {
16 | "@types/bun": "latest",
17 | "vitest": "^3.1.2"
18 | },
19 | "peerDependencies": {
20 | "typescript": "^5"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/apps/cf-demo/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "name": "git-durable-object-demo",
3 | "main": "worker/index.ts",
4 | "compatibility_date": "2025-05-01",
5 | "compatibility_flags": [
6 | "nodejs_compat"
7 | ],
8 | "assets": { "not_found_handling": "single-page-application" },
9 | "durable_objects": {
10 | "bindings": [
11 | {
12 | "name": "READONLY_REPO",
13 | "class_name": "ReadonlyRepoObject",
14 | }
15 | ]
16 | },
17 | "migrations": [
18 | {
19 | "tag": "v1",
20 | "new_sqlite_classes": [
21 | "ReadonlyRepoObject"
22 | ]
23 | }
24 | ],
25 | "observability": {
26 | "enabled": true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function Label({
7 | className,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
19 | )
20 | }
21 |
22 | export { Label }
23 |
--------------------------------------------------------------------------------
/apps/cf-demo/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "git-sqlite-cli",
3 | "module": "src/index.ts",
4 | "type": "module",
5 | "bin": {
6 | "git-sqlite": "./dist/index.js"
7 | },
8 | "scripts": {
9 | "build": "bun build src/index.ts --outdir=dist --format=esm",
10 | "typecheck": "tsc --noEmit",
11 | "test": "bun test",
12 | "start": "bun run src/cli.ts"
13 | },
14 | "devDependencies": {
15 | "@types/bun": "latest",
16 | "@types/minimist": "^1.2.5",
17 | "@types/node": "^20.11.30",
18 | "typescript": "^5"
19 | },
20 | "dependencies": {
21 | "chalk": "^5.4.1",
22 | "isomorphic-git": "^1.30.1",
23 | "minimist": "^1.2.8",
24 | "sqlite-fs": "workspace:*"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Environment setup & latest features
4 | "lib": ["ESNext"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 |
11 | // Bundler mode
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "noEmit": true,
16 |
17 | // Best practices
18 | "strict": true,
19 | "skipLibCheck": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedIndexedAccess": true,
22 |
23 | // Some stricter flags (disabled by default)
24 | "noUnusedLocals": false,
25 | "noUnusedParameters": false,
26 | "noPropertyAccessFromIndexSignature": false
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import { FileExplorer } from "@/components/file-explorer"
3 | import { FileViewer } from "@/components/file-viewer"
4 | import { RepoHeader } from "@/components/repo-header"
5 |
6 | export default function Home() {
7 | const [selectedFile, setSelectedFile] = useState(null)
8 | const [currentPath, setCurrentPath] = useState([])
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Environment setup & latest features
4 | "lib": ["ESNext"],
5 | "target": "ESNext",
6 | "module": "ESNext",
7 | "moduleDetection": "force",
8 | "jsx": "react-jsx",
9 | "allowJs": true,
10 |
11 | // Bundler mode
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "noEmit": true,
16 |
17 | // Best practices
18 | "strict": true,
19 | "skipLibCheck": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedIndexedAccess": true,
22 |
23 | // Some stricter flags (disabled by default)
24 | "noUnusedLocals": false,
25 | "noUnusedParameters": false,
26 | "noPropertyAccessFromIndexSignature": false
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/error-utils.ts:
--------------------------------------------------------------------------------
1 | // src/error-utils.ts
2 | import type { FSError } from './types';
3 |
4 | /**
5 | * Creates an error object mimicking Node.js filesystem errors.
6 | */
7 | export function createError(
8 | code: string,
9 | path?: string,
10 | syscall?: string,
11 | message?: string
12 | ): FSError {
13 | const displayPath = path ? `'${path}'` : '';
14 | const displaySyscall = syscall ? ` ${syscall}` : '';
15 | const baseMessage = message || `${code}:${displaySyscall}${displayPath}`;
16 |
17 | const error = new Error(baseMessage) as FSError;
18 | error.code = code;
19 | if (path) error.path = path;
20 | if (syscall) error.syscall = syscall;
21 |
22 | // Could potentially add errno mapping here if needed, but code is primary identifier
23 | return error;
24 | }
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/types.ts:
--------------------------------------------------------------------------------
1 | // src/types.ts
2 |
3 | // Basic structure for file system errors
4 | export interface FSError extends Error {
5 | code?: string;
6 | path?: string;
7 | syscall?: string;
8 | }
9 |
10 | // Basic structure mimicking Node.js Stats object (key properties)
11 | // We'll refine this later based on SQLiteFSAdapter needs
12 | export interface Stats {
13 | isFile(): boolean;
14 | isDirectory(): boolean;
15 | isSymbolicLink(): boolean;
16 | size: number;
17 | mtimeMs: number;
18 | mode: number;
19 | // Add other common fields with placeholder types if needed now
20 | atimeMs?: number;
21 | ctimeMs?: number;
22 | birthtimeMs?: number;
23 | dev?: number; ino?: number; nlink?: number; uid?: number; gid?: number;
24 | rdev?: number;
25 | blksize?: number;
26 | blocks?: number;
27 | }
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ProgressPrimitive from "@radix-ui/react-progress"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | function Progress({
7 | className,
8 | value,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
25 |
26 | )
27 | }
28 |
29 | export { Progress }
30 |
--------------------------------------------------------------------------------
/apps/cf-demo/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true,
23 |
24 | /* Paths for shadcn */
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/*": [
28 | "./src/*"
29 | ]
30 | }
31 | },
32 | "include": ["src"]
33 | }
34 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/errors.ts:
--------------------------------------------------------------------------------
1 | type ErrorWithMessage = {
2 | message: string;
3 | };
4 |
5 | export function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
6 | return (
7 | typeof error === "object" &&
8 | error !== null &&
9 | "message" in error &&
10 | typeof (error as Record).message === "string"
11 | );
12 | }
13 |
14 | export function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
15 | if (isErrorWithMessage(maybeError)) return maybeError;
16 |
17 | try {
18 | return new Error(JSON.stringify(maybeError));
19 | } catch {
20 | // fallback in case there's an error stringifying the maybeError
21 | // like with circular references for example.
22 | return new Error(String(maybeError));
23 | }
24 | }
25 |
26 | export function getErrorMessage(error: unknown) {
27 | return toErrorWithMessage(error).message;
28 | }
29 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/schema.ts:
--------------------------------------------------------------------------------
1 | // src/schema.ts
2 | export const CHUNK_SIZE = 1.8 * 1024 * 1024; // 1.8MB chunk size (safety margin below 2MB)
3 |
4 | export const SQL_SCHEMA = `
5 | CREATE TABLE IF NOT EXISTS file_chunks (
6 | path TEXT NOT NULL, -- The virtual filesystem path
7 | chunk_index INTEGER NOT NULL, -- 0 for the first/only chunk or metadata, 1+ for subsequent chunks
8 | type TEXT NOT NULL CHECK(type IN ('file', 'directory', 'symlink')), -- Node type
9 | content BLOB, -- File chunk data, symlink target, or NULL for directory
10 | mode INTEGER NOT NULL, -- Filesystem mode
11 | mtime TEXT NOT NULL, -- Modification time (ISO8601)
12 | total_size INTEGER NOT NULL, -- Original total size of the file (0 for dirs/links)
13 | PRIMARY KEY (path, chunk_index) -- Ensures chunk uniqueness per path
14 | );
15 |
16 | -- Add indexes for efficient lookups
17 | CREATE INDEX IF NOT EXISTS idx_file_chunks_metadata ON file_chunks (path, chunk_index) WHERE chunk_index = 0;
18 | CREATE INDEX IF NOT EXISTS idx_file_chunks_ordered ON file_chunks (path, chunk_index);
19 | `;
--------------------------------------------------------------------------------
/apps/cf-demo/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 | import reactHooks from "eslint-plugin-react-hooks";
4 | import reactRefresh from "eslint-plugin-react-refresh";
5 | import tseslint from "typescript-eslint";
6 |
7 | export default tseslint.config(
8 | { ignores: ["dist"] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ["**/*.{ts,tsx}"],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | "react-hooks": reactHooks,
18 | "react-refresh": reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | "react-refresh/only-export-components": [
23 | "warn",
24 | { allowConstantExport: true },
25 | ],
26 | "@typescript-eslint/no-explicit-any": ["warn"],
27 | "@typescript-eslint/no-unused-vars": [
28 | "warn",
29 | {
30 | argsIgnorePattern: "^_",
31 | varsIgnorePattern: "^_",
32 | ignoreRestSiblings: true,
33 | },
34 | ],
35 | },
36 | },
37 | );
38 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/file-viewer.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollArea } from "@/components/ui/scroll-area";
2 | import { FileEntry } from "@/lib/types";
3 | import "highlight.js/styles/default.css";
4 | import "react-lowlight/common";
5 | import Lowlight from "react-lowlight";
6 |
7 | interface FileViewerProps {
8 | fileContent: string | null;
9 | fileEntry: FileEntry | null;
10 | }
11 |
12 | export function FileViewer({ fileContent, fileEntry }: FileViewerProps) {
13 | if (!fileContent || !fileEntry) {
14 | return (
15 |
16 | Select a file to view its contents
17 |
18 | );
19 | }
20 |
21 | return (
22 |
23 |
24 |
25 | {fileEntry.name}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/OpenCode.md:
--------------------------------------------------------------------------------
1 | # OpenCode Guidelines
2 |
3 | This is a monorepo workspace. You will be working in one of the packages.
4 |
5 | ## Tasks
6 |
7 | Tasks are documented in files in `docs/steps/`, when instructed to do so, please check the files in that directory for the current task (you will be given the file name). If the task cannot be found, or the previous task is not complete, please stop and ask for clarification.
8 |
9 | ## Commands
10 | - Build: `cd packages/sqlite-fs && bun run build`
11 | - Typecheck: `cd packages/sqlite-fs && bun run typecheck`
12 | - Run all tests: `cd packages/sqlite-fs && bun run test`
13 | - Run single test: `cd packages/sqlite-fs && bun vitest run src/sum.test.ts`
14 | - Run specific test: `cd packages/sqlite-fs && bun vitest run -t "should execute INSERT and SELECT using all()"`
15 |
16 | ## Code Style
17 | - **Imports**: Use ESM imports with `.js` extension for local imports
18 | - **Types**: Use TypeScript interfaces for API contracts, generics for flexible typing
19 | - **Error Handling**: Log errors with context, then re-throw for caller handling
20 | - **Naming**: Use camelCase for variables/methods, PascalCase for classes/interfaces
21 | - **Formatting**: 2-space indentation, trailing commas in multi-line objects
22 | - **Comments**: JSDoc style for interfaces and public methods
23 | - **Patterns**: Implement interfaces for consistent APIs across adapters
24 | - **Testing**: Use Vitest with describe/it pattern, clear test descriptions
25 |
--------------------------------------------------------------------------------
/apps/cf-demo/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | typescript:
12 | specifier: ^5
13 | version: 5.8.3
14 | devDependencies:
15 | '@types/bun':
16 | specifier: latest
17 | version: 1.2.10
18 |
19 | packages:
20 |
21 | '@types/bun@1.2.10':
22 | resolution: {integrity: sha512-eilv6WFM3M0c9ztJt7/g80BDusK98z/FrFwseZgT4bXCq2vPhXD4z8R3oddmAn+R/Nmz9vBn4kweJKmGTZj+lg==}
23 |
24 | '@types/node@22.15.3':
25 | resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==}
26 |
27 | bun-types@1.2.10:
28 | resolution: {integrity: sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ==}
29 |
30 | typescript@5.8.3:
31 | resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
32 | engines: {node: '>=14.17'}
33 | hasBin: true
34 |
35 | undici-types@6.21.0:
36 | resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
37 |
38 | snapshots:
39 |
40 | '@types/bun@1.2.10':
41 | dependencies:
42 | bun-types: 1.2.10
43 |
44 | '@types/node@22.15.3':
45 | dependencies:
46 | undici-types: 6.21.0
47 |
48 | bun-types@1.2.10:
49 | dependencies:
50 | '@types/node': 22.15.3
51 |
52 | typescript@5.8.3: {}
53 |
54 | undici-types@6.21.0: {}
55 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/tests/path-utils.test.ts:
--------------------------------------------------------------------------------
1 | // tests/path-utils.test.ts
2 | import { describe, expect, it } from 'bun:test';
3 | import { dirname, basename, join, normalize, getParentPath } from '../src/path-utils';
4 |
5 | describe('path-utils', () => {
6 | describe('dirname', () => {
7 | it('should return the directory name of a path', () => {
8 | expect(dirname('/foo/bar/baz')).toBe('/foo/bar');
9 | expect(dirname('/foo/bar')).toBe('/foo');
10 | expect(dirname('/foo')).toBe('/');
11 | });
12 | });
13 |
14 | describe('basename', () => {
15 | it('should return the last portion of a path', () => {
16 | expect(basename('/foo/bar/baz')).toBe('baz');
17 | expect(basename('/foo/bar')).toBe('bar');
18 | expect(basename('/foo')).toBe('foo');
19 | });
20 | });
21 |
22 | describe('join', () => {
23 | it('should join path segments', () => {
24 | expect(join('/foo', 'bar', 'baz')).toBe('/foo/bar/baz');
25 | expect(join('foo', 'bar')).toBe('foo/bar');
26 | expect(join('/foo', '../bar')).toBe('/bar');
27 | });
28 | });
29 |
30 | describe('normalize', () => {
31 | it('should normalize a path', () => {
32 | expect(normalize('/foo/bar/..')).toBe('/foo');
33 | expect(normalize('/foo/./bar')).toBe('/foo/bar');
34 | expect(normalize('foo//bar')).toBe('foo/bar');
35 | });
36 | });
37 |
38 | describe('getParentPath', () => {
39 | it('should return the parent path', () => {
40 | expect(getParentPath('/foo/bar')).toBe('/foo');
41 | expect(getParentPath('/foo')).toBe('/');
42 | expect(getParentPath('/')).toBe('');
43 | });
44 | });
45 | });
--------------------------------------------------------------------------------
/apps/cf-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cf-demo",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "bunx eslint .",
10 | "preview": "vite preview",
11 | "typecheck": "tsc --noEmit"
12 | },
13 | "dependencies": {
14 | "@radix-ui/react-label": "^2.1.6",
15 | "@radix-ui/react-progress": "^1.1.6",
16 | "@radix-ui/react-scroll-area": "^1.2.8",
17 | "@radix-ui/react-select": "^2.2.4",
18 | "@radix-ui/react-slot": "^1.2.2",
19 | "@radix-ui/react-tabs": "^1.1.11",
20 | "@tailwindcss/vite": "^4.1.6",
21 | "class-variance-authority": "^0.7.1",
22 | "clsx": "^2.1.1",
23 | "date-fns": "^4.1.0",
24 | "highlight.js": "^11.11.1",
25 | "hono": "^4.7.8",
26 | "lucide-react": "^0.509.0",
27 | "react": "^19.0.0",
28 | "react-dom": "^19.0.0",
29 | "react-lowlight": "^3.1.0",
30 | "sqlite-fs": "workspace:*",
31 | "tailwind-merge": "^3.2.0",
32 | "tailwindcss": "^4.1.6",
33 | "wouter": "^3.7.0"
34 | },
35 | "devDependencies": {
36 | "@cloudflare/vite-plugin": "^1.1.0",
37 | "@cloudflare/workers-types": "^4.20250505.0",
38 | "@eslint/js": "^9.22.0",
39 | "@types/node": "^22.15.18",
40 | "@types/react": "^19.0.10",
41 | "@types/react-dom": "^19.0.4",
42 | "@vitejs/plugin-react": "^4.3.4",
43 | "eslint": "^9.22.0",
44 | "eslint-plugin-react-hooks": "^5.2.0",
45 | "eslint-plugin-react-refresh": "^0.4.19",
46 | "globals": "^16.0.0",
47 | "sqlite-fs": "workspace:*",
48 | "tw-animate-css": "^1.2.9",
49 | "typescript": "~5.7.2",
50 | "typescript-eslint": "^8.32.1",
51 | "vite": "^6.3.1",
52 | "wrangler": "^4.14.1"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | // src/interfaces.ts
2 |
3 | /**
4 | * Interface for a synchronous iterator over SQLite query results.
5 | * Adheres to the standard JavaScript Iterable/Iterator protocol.
6 | */
7 | export interface SyncSqliteIterator>
8 | extends Iterable {
9 | /** Returns the next item in the sequence. */
10 | next(): IteratorResult;
11 | }
12 |
13 | /**
14 | * Defines the core synchronous interface for interacting with different SQLite backends (PoC version without transactions).
15 | */
16 | export interface SyncSqliteDatabase {
17 | /** Executes SQL statement(s), primarily for side effects. Throws on error. */
18 | exec(sql: string, params?: any[]): void;
19 |
20 | /** Executes SELECT, returns all result rows as an array. Returns empty array if no rows. Throws on error. */
21 | all>(sql: string, params?: any[]): T[];
22 |
23 | /** Executes SELECT, returns exactly one result row. Throws if zero or >1 rows. Throws on other errors. */
24 | one>(sql: string, params?: any[]): T;
25 |
26 | /** Executes SELECT, returns a synchronous iterator over result rows. Throws on error during prep/execution. */
27 | iterator>(
28 | sql: string,
29 | params?: any[],
30 | ): SyncSqliteIterator;
31 |
32 | /** Optional: Closes the database connection if applicable. */
33 | close?(): void;
34 | }
35 |
36 | export class NoRowsError extends Error {
37 | constructor(message: string) {
38 | super(message);
39 | this.name = "NoRowsError";
40 | }
41 | }
42 |
43 | export class TooManyRowsError extends Error {
44 | constructor(message: string) {
45 | super(message);
46 | this.name = "TooManyRowsError";
47 | }
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/stats-utils.ts:
--------------------------------------------------------------------------------
1 | // src/stats-utils.ts
2 | import type { Stats } from './types';
3 |
4 | // Define the expected shape of the input row from the DB
5 | export interface DbFileRow {
6 | type: 'file' | 'directory' | 'symlink';
7 | mode: number;
8 | mtime: string; // ISO8601 string
9 | content?: Buffer | Uint8Array | null; // Assuming BLOB is retrieved as Buffer/Uint8Array
10 | total_size: number; // Total size of the file (across all chunks)
11 | }
12 |
13 | /**
14 | * Creates a Stats object from a database row.
15 | */
16 | export function createStats(row: DbFileRow): Stats {
17 | const mtimeMs = Date.parse(row.mtime);
18 | // Use total_size directly from the database row
19 | const size = row.total_size;
20 |
21 | // Create the base object matching the Stats interface
22 | const stats: Stats = {
23 | isFile: () => row.type === 'file',
24 | isDirectory: () => row.type === 'directory',
25 | isSymbolicLink: () => row.type === 'symlink',
26 | mode: row.mode,
27 | size: size,
28 | mtimeMs: mtimeMs,
29 | // Provide sensible defaults for other common Stats fields
30 | atimeMs: mtimeMs, // Use mtime for atime
31 | ctimeMs: mtimeMs, // Use mtime for ctime (metadata change time)
32 | birthtimeMs: mtimeMs, // Use mtime for birthtime
33 | dev: 0,
34 | ino: 0, // Inode numbers don't really apply
35 | nlink: 1, // Typically 1 link unless hard links were simulated
36 | uid: 0, // Default user/group IDs
37 | gid: 0,
38 | rdev: 0,
39 | blksize: 4096, // Common block size default
40 | blocks: Math.ceil(size / 512), // Estimate blocks based on size (512b blocks)
41 | };
42 |
43 | return stats;
44 | }
--------------------------------------------------------------------------------
/packages/sqlite-fs/tests/error-utils.test.ts:
--------------------------------------------------------------------------------
1 | // tests/error-utils.test.ts
2 | import { describe, expect, it } from 'bun:test';
3 | import { createError } from '../src/error-utils';
4 |
5 | describe('error-utils', () => {
6 | describe('createError', () => {
7 | it('should create an error with code only', () => {
8 | const error = createError('ENOENT');
9 | expect(error).toBeInstanceOf(Error);
10 | expect(error.code).toBe('ENOENT');
11 | expect(error.message).toBe('ENOENT:');
12 | expect(error.path).toBeUndefined();
13 | expect(error.syscall).toBeUndefined();
14 | });
15 |
16 | it('should create an error with code and path', () => {
17 | const error = createError('ENOENT', '/foo/bar');
18 | expect(error).toBeInstanceOf(Error);
19 | expect(error.code).toBe('ENOENT');
20 | expect(error.message).toBe("ENOENT:'/foo/bar'");
21 | expect(error.path).toBe('/foo/bar');
22 | expect(error.syscall).toBeUndefined();
23 | });
24 |
25 | it('should create an error with code, path, and syscall', () => {
26 | const error = createError('ENOENT', '/foo/bar', 'stat');
27 | expect(error).toBeInstanceOf(Error);
28 | expect(error.code).toBe('ENOENT');
29 | expect(error.message).toBe("ENOENT: stat'/foo/bar'");
30 | expect(error.path).toBe('/foo/bar');
31 | expect(error.syscall).toBe('stat');
32 | });
33 |
34 | it('should create an error with custom message', () => {
35 | const error = createError('ENOENT', '/foo/bar', 'stat', 'Custom error message');
36 | expect(error).toBeInstanceOf(Error);
37 | expect(error.code).toBe('ENOENT');
38 | expect(error.message).toBe('Custom error message');
39 | expect(error.path).toBe('/foo/bar');
40 | expect(error.syscall).toBe('stat');
41 | });
42 | });
43 | });
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ))
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ))
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
45 |
46 | export { ScrollArea, ScrollBar }
47 |
--------------------------------------------------------------------------------
/apps/cf-demo/worker/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "cloudflare:workers";
2 | import { Hono, type HonoRequest } from "hono";
3 |
4 | const app = new Hono();
5 |
6 | async function getRepoObject(request: HonoRequest) {
7 | const repoName = `${request.param("user")}/${request.param("repo")}`;
8 | const id = env.READONLY_REPO.idFromName(repoName);
9 | const stub = env.READONLY_REPO.get(id);
10 | await stub.initialize(repoName);
11 | return stub;
12 | }
13 |
14 | app.get("/api/", (c) => c.json({ name: "Hello Cloudflare Workers!" }));
15 | app.get("/api/:user/:repo/status", async (c) => {
16 | const stub = await getRepoObject(c.req);
17 | const status = await stub.status();
18 | return c.json({ status });
19 | });
20 |
21 | app.get("/api/:user/:repo/ws", async (c) => {
22 | const upgradeHeader = c.req.header("Upgrade");
23 | if (!upgradeHeader || upgradeHeader !== "websocket") {
24 | return new Response("Durable Object expected Upgrade: websocket", {
25 | status: 426,
26 | });
27 | }
28 |
29 | const stub = await getRepoObject(c.req);
30 |
31 | return stub.fetch(c.req.raw);
32 | });
33 |
34 | app.post("/api/:user/:repo/clone", async (c) => {
35 | const stub = await getRepoObject(c.req);
36 | await stub.clone();
37 | return c.json({ status: "ok" });
38 | });
39 | app.post("/api/:user/:repo/ls-files", async (c) => {
40 | const stub = await getRepoObject(c.req);
41 | const files = await stub.listFiles();
42 | return c.json({ files });
43 | });
44 |
45 | app.get("/api/:user/:repo/blob/:oid", async (c) => {
46 | const stub = await getRepoObject(c.req);
47 | const oid = c.req.param("oid");
48 | const blob = await stub.getBlob(oid);
49 | const contents = new TextDecoder().decode(blob.blob);
50 | return c.json({ blob: contents });
51 | });
52 |
53 | export { ReadonlyRepoObject } from "./do/readonly-repo-object";
54 |
55 | export default app;
56 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/src/cli.ts:
--------------------------------------------------------------------------------
1 | // src/cli.ts
2 | import parseArgs from 'minimist';
3 | import chalk from 'chalk'; // Optional: for colored output
4 | import { cloneCommand } from './commands/clone.js';
5 | import { lsTreeCommand } from './commands/ls-tree.js';
6 |
7 | async function main() {
8 | // Parse arguments, skipping 'bun' and the script path itself
9 | const args = parseArgs(Bun.argv.slice(2));
10 | const command = args._[0];
11 |
12 | // console.log('Args:', args); // Uncomment for debugging args
13 |
14 | switch (command) {
15 | case 'clone':
16 | if (args._.length < 3) {
17 | console.error(chalk.red('Usage: git-sqlite clone '));
18 | process.exit(1);
19 | }
20 | // Pass repository URL and DB file path to the command handler
21 | await cloneCommand(args._[1], args._[2], args);
22 | break;
23 |
24 | case 'ls-tree':
25 | if (args._.length < 2) {
26 | console.error(chalk.red('Usage: git-sqlite ls-tree [ref]'));
27 | process.exit(1);
28 | }
29 | // Default ref to 'HEAD' if not provided
30 | const ref = args._[2] || 'HEAD';
31 | // Pass DB file path and ref to the command handler
32 | await lsTreeCommand(args._[1], ref, args);
33 | break;
34 |
35 | default:
36 | console.error(chalk.red(`Unknown command: ${command || 'No command specified'}`));
37 | console.error('Available commands: clone, ls-tree');
38 | process.exit(1);
39 | }
40 | }
41 |
42 | // Execute main and handle top-level errors
43 | main().catch(err => {
44 | console.error(chalk.redBright('Error:'), err.message);
45 | // console.error(err.stack); // Uncomment for detailed stack trace
46 | process.exit(1);
47 | });
--------------------------------------------------------------------------------
/apps/cf-demo/src/pages/home.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "../components/ui/button";
3 | import {
4 | Card,
5 | CardHeader,
6 | CardTitle,
7 | CardDescription,
8 | CardContent,
9 | CardFooter,
10 | } from "@/components/ui/card";
11 | import { Input } from "@/components/ui/input";
12 | import { Label } from "@radix-ui/react-label";
13 | import { Github } from "lucide-react";
14 |
15 | export function HomePage() {
16 | const [repoName, setRepoName] = useState("");
17 |
18 | return (
19 |
20 |
Git on a Durable Object
21 |
22 |
23 |
24 |
25 |
26 |
Clone a GitHub Repo
27 |
28 |
29 |
30 | Clone a github repository into a Cloudflare Durable Object.
31 |
32 |
33 |
34 |
35 |
36 |
37 | setRepoName(e.currentTarget.value)}
42 | />
43 |
44 |
45 |
46 |
47 |
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/apps/cf-demo/worker/lib/walk-tree.ts:
--------------------------------------------------------------------------------
1 | import * as git from "isomorphic-git";
2 | import path from "path";
3 |
4 | export type FileEntry = {
5 | path: string;
6 | name: string;
7 | type: "file" | "directory";
8 | children?: FileEntry[];
9 | oid: string;
10 | };
11 |
12 | export async function walkTree({
13 | repoDir,
14 | fs,
15 | }: {
16 | repoDir: string;
17 | fs: git.FsClient;
18 | }): Promise {
19 | const cache = {};
20 |
21 | async function map(
22 | filepath: string,
23 | entries: (git.WalkerEntry | null)[],
24 | ): Promise {
25 | const entry = entries[0];
26 | if (!entry) {
27 | return null; // Should not happen if an entry exists in HEAD
28 | }
29 |
30 | const entryType = await entry.type();
31 | if (!entryType) return null;
32 |
33 | const mappedType = entryType === "tree" ? "directory" : "file";
34 |
35 | const name =
36 | filepath === "."
37 | ? path.basename(repoDir) || "root"
38 | : path.basename(filepath);
39 |
40 | return {
41 | path: filepath,
42 | name: name,
43 | type: mappedType,
44 | oid: await entry.oid(),
45 | };
46 | }
47 |
48 | async function reduce(
49 | parent: FileEntry | undefined,
50 | children: (FileEntry | null)[],
51 | ): Promise {
52 | const filteredChildren = children.filter((c) => c !== null) as FileEntry[];
53 |
54 | if (parent) {
55 | if (parent.type === "directory") {
56 | // Only add children array if there are actual children
57 | if (filteredChildren.length > 0) {
58 | parent.children = filteredChildren;
59 | } else {
60 | delete parent.children; // Or set to undefined, depending on preference
61 | }
62 | }
63 | return parent;
64 | }
65 | return filteredChildren;
66 | }
67 |
68 | const result = await git.walk({
69 | fs,
70 | dir: repoDir,
71 | trees: [git.TREE({ ref: "HEAD" })],
72 | map,
73 | reduce,
74 | cache,
75 | });
76 |
77 | if (result && !Array.isArray(result) && result.path === ".") {
78 | return result.children || []; // If root is an object, return its children
79 | }
80 | return (result as FileEntry[]) || [];
81 | }
82 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/bun-sqlite-adapter.ts:
--------------------------------------------------------------------------------
1 | // src/bun-sqlite-adapter.ts
2 | import { Database } from "bun:sqlite";
3 | import type { SyncSqliteDatabase, SyncSqliteIterator } from "./interfaces";
4 | import { NoRowsError, TooManyRowsError } from "./interfaces";
5 |
6 | export class BunSqliteAdapter implements SyncSqliteDatabase {
7 | private db: Database;
8 |
9 | constructor(db: Database) {
10 | this.db = db;
11 | }
12 |
13 | exec(sql: string, params?: any[]): void {
14 | try {
15 | this.db.query(sql).run(...(params ?? [])); // Spread params for positional binding
16 | } catch (e: any) {
17 | console.error("BunSqliteAdapter exec error:", e.message, "SQL:", sql);
18 | throw e; // Re-throw
19 | }
20 | }
21 |
22 | all>(sql: string, params?: any[]): T[] {
23 | try {
24 | return this.db.query(sql).all(...(params ?? [])) as T[];
25 | } catch (e: any) {
26 | console.error("BunSqliteAdapter all error:", e.message, "SQL:", sql);
27 | throw e; // Re-throw
28 | }
29 | }
30 |
31 | one>(sql: string, params?: any[]): T {
32 | try {
33 | const results = this.db.query(sql).all(...(params ?? []));
34 | if (results.length === 0) {
35 | throw new NoRowsError("SQLite one() error: No rows found");
36 | }
37 | if (results.length > 1) {
38 | throw new TooManyRowsError(
39 | `SQLite one() error: Expected 1 row, got ${results.length}`,
40 | );
41 | }
42 | return results[0] as T;
43 | } catch (e: any) {
44 | // Don't log the expected "No rows found" or "Expected 1 row" errors as console errors
45 | if (!e.message?.includes("SQLite one() error:")) {
46 | console.error("BunSqliteAdapter one error:", e.message, "SQL:", sql);
47 | }
48 | throw e; // Re-throw
49 | }
50 | }
51 |
52 | iterator>(
53 | sql: string,
54 | params?: any[],
55 | ): SyncSqliteIterator {
56 | try {
57 | const results = this.db.query(sql).all(...(params ?? []));
58 | return results[Symbol.iterator]() as SyncSqliteIterator;
59 | } catch (e: any) {
60 | console.error("BunSqliteAdapter iterator error:", e.message, "SQL:", sql);
61 | throw e; // Re-throw
62 | }
63 | }
64 |
65 | close(): void {
66 | this.db.close();
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : "button"
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | )
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | )
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | )
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | )
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | )
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | )
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | }
93 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/do-sqlite-adapter.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | DurableObjectStorage,
3 | SqlStorage,
4 | } from "@cloudflare/workers-types";
5 | import {
6 | NoRowsError,
7 | TooManyRowsError,
8 | type SyncSqliteDatabase,
9 | type SyncSqliteIterator,
10 | } from "sqlite-fs";
11 | import { getErrorMessage } from "./errors";
12 |
13 | export class DurableObjectSqliteAdapter implements SyncSqliteDatabase {
14 | private sql: SqlStorage;
15 |
16 | constructor(storage: DurableObjectStorage) {
17 | if (!storage.sql) {
18 | throw new Error(
19 | "DurableObjectStorage missing 'sql' property. Ensure DO uses SQLite backend.",
20 | );
21 | }
22 | this.sql = storage.sql;
23 | }
24 |
25 | exec(sql: string, params?: any[]): void {
26 | try {
27 | this.sql.exec(sql, ...(params ?? [])).toArray();
28 | } catch (e) {
29 | console.error(
30 | `DO Adapter exec Error: ${getErrorMessage(e)}`,
31 | sql,
32 | params,
33 | );
34 | throw e;
35 | }
36 | }
37 |
38 | all>(sql: string, params?: any[]): T[] {
39 | try {
40 | return this.sql.exec(sql, ...(params ?? [])).toArray() as T[];
41 | } catch (e) {
42 | console.error(`DO Adapter all Error: ${getErrorMessage(e)}`, sql, params);
43 | throw e;
44 | }
45 | }
46 |
47 | one>(sql: string, params?: any[]): T {
48 | let results;
49 | try {
50 | results = this.sql.exec(sql, ...(params ?? [])).toArray();
51 | } catch (e) {
52 | console.error(`DO Adapter one Error: ${getErrorMessage(e)}`, sql, params);
53 | throw e;
54 | }
55 | if (results.length === 0) {
56 | throw new NoRowsError("No rows found");
57 | } else if (results.length > 1) {
58 | throw new TooManyRowsError("Too many rows found");
59 | }
60 | return results[0] as T;
61 | }
62 |
63 | iterator>(
64 | sql: string,
65 | params?: any[],
66 | ): SyncSqliteIterator {
67 | try {
68 | const cursor = this.sql.exec(sql, ...(params ?? []));
69 | return {
70 | [Symbol.iterator]() {
71 | return this;
72 | },
73 | next: () => {
74 | const result = cursor.next();
75 | if (result.done) {
76 | return { done: true, value: {} as T };
77 | }
78 | return { done: false, value: result.value as T };
79 | },
80 | };
81 | } catch (e) {
82 | console.error(
83 | `DO Adapter iterator Error: ${getErrorMessage(e)}`,
84 | sql,
85 | params,
86 | );
87 | throw e;
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/repo-header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ArrowDownToLine, GitCommitHorizontal, Github } from "lucide-react";
3 | import { Progress } from "./progress";
4 |
5 | interface RepoHeaderProps {
6 | repoName: string;
7 | onFetch: () => void;
8 | status: string;
9 | progress: {
10 | phase: string;
11 | loaded: number;
12 | total: number;
13 | } | null;
14 | commitInfo?: {
15 | branch: string | undefined;
16 | commit: {
17 | oid: string;
18 | commit: {
19 | message: string;
20 | };
21 | author: {
22 | name: string;
23 | email: string;
24 | timestamp: number;
25 | timezoneOffset: number;
26 | };
27 | };
28 | };
29 | }
30 |
31 | export function RepoHeader({
32 | repoName,
33 | onFetch,
34 | status,
35 | progress,
36 | commitInfo,
37 | }: RepoHeaderProps) {
38 | return (
39 |
40 |
41 |
65 | {(status === "cloning" || status === "fetching") && progress && (
66 |
73 | )}
74 |
75 |
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/tests/stats-utils.test.ts:
--------------------------------------------------------------------------------
1 | // tests/stats-utils.test.ts
2 | import { describe, expect, it } from 'bun:test';
3 | import { createStats } from '../src/stats-utils';
4 | import type { DbFileRow } from '../src/stats-utils';
5 |
6 | describe('stats-utils', () => {
7 | describe('createStats', () => {
8 | it('should create stats for a file', () => {
9 | const content = new Uint8Array([1, 2, 3, 4, 5]);
10 | const row: DbFileRow = {
11 | type: 'file',
12 | mode: 0o100644,
13 | mtime: '2023-01-01T00:00:00.000Z',
14 | content,
15 | total_size: 5
16 | };
17 |
18 | const stats = createStats(row);
19 | expect(stats.isFile()).toBe(true);
20 | expect(stats.isDirectory()).toBe(false);
21 | expect(stats.isSymbolicLink()).toBe(false);
22 | expect(stats.size).toBe(5);
23 | expect(stats.mode).toBe(0o100644);
24 | expect(stats.mtimeMs).toBe(Date.parse('2023-01-01T00:00:00.000Z'));
25 | expect(stats.atimeMs).toBe(stats.mtimeMs);
26 | expect(stats.ctimeMs).toBe(stats.mtimeMs);
27 | expect(stats.birthtimeMs).toBe(stats.mtimeMs);
28 | expect(stats.blocks).toBe(1); // ceil(5/512) = 1
29 | });
30 |
31 | it('should create stats for a directory', () => {
32 | const row: DbFileRow = {
33 | type: 'directory',
34 | mode: 0o40755,
35 | mtime: '2023-01-01T00:00:00.000Z',
36 | content: null,
37 | total_size: 0
38 | };
39 |
40 | const stats = createStats(row);
41 | expect(stats.isFile()).toBe(false);
42 | expect(stats.isDirectory()).toBe(true);
43 | expect(stats.isSymbolicLink()).toBe(false);
44 | expect(stats.size).toBe(0);
45 | expect(stats.mode).toBe(0o40755);
46 | expect(stats.blocks).toBe(0);
47 | });
48 |
49 | it('should create stats for a symlink', () => {
50 | const content = Buffer.from('/target/path');
51 | const row: DbFileRow = {
52 | type: 'symlink',
53 | mode: 0o120755,
54 | mtime: '2023-01-01T00:00:00.000Z',
55 | content,
56 | total_size: 12
57 | };
58 |
59 | const stats = createStats(row);
60 | expect(stats.isFile()).toBe(false);
61 | expect(stats.isDirectory()).toBe(false);
62 | expect(stats.isSymbolicLink()).toBe(true);
63 | expect(stats.size).toBe(12); // Length of '/target/path'
64 | expect(stats.mode).toBe(0o120755);
65 | });
66 |
67 | it('should handle null content for files', () => {
68 | const row: DbFileRow = {
69 | type: 'file',
70 | mode: 0o100644,
71 | mtime: '2023-01-01T00:00:00.000Z',
72 | content: null,
73 | total_size: 0
74 | };
75 |
76 | const stats = createStats(row);
77 | expect(stats.isFile()).toBe(true);
78 | expect(stats.size).toBe(0);
79 | });
80 | });
81 | });
--------------------------------------------------------------------------------
/apps/cf-demo/src/assets/Cloudflare_Logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/src/commands/ls-tree.ts:
--------------------------------------------------------------------------------
1 | // src/commands/ls-tree.ts
2 | import {
3 | BunSqliteAdapter,
4 | SQLiteFSAdapter,
5 | } from "../../../sqlite-fs/src/index.js";
6 | import git from "isomorphic-git";
7 | import path from "node:path";
8 | import fs from "node:fs"; // Using Node's fs for directory/file checks via Bun
9 | import { Database } from "bun:sqlite"; // Import Bun's Database
10 | import chalk from "chalk";
11 | import type { ParsedArgs } from "minimist";
12 |
13 | // Helper function to recursively walk the tree
14 | async function walkTree(
15 | fsAdapter: SQLiteFSAdapter,
16 | oid: string,
17 | currentPath: string = "",
18 | ): Promise {
19 | const { tree } = await git.readTree({ fs: fsAdapter, dir: ".", oid });
20 |
21 | for (const entry of tree) {
22 | const entryPath = path.join(currentPath, entry.path).replace(/^\//, ""); // Build full path, remove leading slash if any
23 | if (entry.type === "blob") {
24 | // Print file entries
25 | console.log(`${entry.mode.toString()} blob ${entry.oid}\t${entryPath}`);
26 | } else if (entry.type === "tree") {
27 | // Recursively walk subtrees
28 | await walkTree(fsAdapter, entry.oid, entryPath);
29 | }
30 | // Ignore commits (submodules) for this simple ls-tree
31 | }
32 | }
33 |
34 | export async function lsTreeCommand(
35 | dbFilePath: string,
36 | ref: string,
37 | options: ParsedArgs,
38 | ): Promise {
39 | console.log(
40 | chalk.blue(
41 | `Listing tree for ref '${chalk.bold(ref)}' in ${chalk.bold(dbFilePath)}...`,
42 | ),
43 | );
44 |
45 | let db: Database | null = null;
46 | let dbAdapter: BunSqliteAdapter | null = null;
47 |
48 | try {
49 | // 1. Check if DB file exists
50 | if (!fs.existsSync(dbFilePath)) {
51 | throw new Error(`Database file not found: ${dbFilePath}`);
52 | }
53 |
54 | // 2. Create the bun:sqlite Database instance (read-only recommended)
55 | db = new Database(dbFilePath, { readonly: true });
56 |
57 | // 3. Instantiate BunSqliteAdapter with the Database instance
58 | dbAdapter = new BunSqliteAdapter(db);
59 |
60 | // 4. Instantiate SQLiteFSAdapter with the BunSqliteAdapter
61 | const fsAdapter = new SQLiteFSAdapter(dbAdapter);
62 |
63 | // 5. Resolve the ref to a commit OID
64 | let commitOid: string;
65 | try {
66 | commitOid = await git.resolveRef({ fs: fsAdapter, dir: ".", ref });
67 | } catch (e: any) {
68 | throw new Error(`Could not resolve ref '${ref}': ${e.message}`);
69 | }
70 |
71 | // 6. Read the commit to get the root tree OID
72 | let treeOid: string;
73 | try {
74 | const { commit } = await git.readCommit({
75 | fs: fsAdapter,
76 | dir: ".",
77 | oid: commitOid,
78 | });
79 | treeOid = commit.tree;
80 | } catch (e: any) {
81 | throw new Error(`Could not read commit '${commitOid}': ${e.message}`);
82 | }
83 |
84 | // 7. Walk the tree recursively and print entries
85 | console.log(chalk.yellow(`--- Tree for ${ref} (${commitOid}) ---`));
86 | await walkTree(fsAdapter, treeOid); // Start walk from root tree
87 | console.log(chalk.yellow(`--- End Tree ---`));
88 | } catch (error: any) {
89 | // Re-throw the error to be caught by the main handler in cli.ts
90 | throw new Error(`ls-tree failed: ${error.message}`);
91 | } finally {
92 | // 8. Ensure the database connection is closed
93 | if (dbAdapter) {
94 | dbAdapter.close?.();
95 | } else if (db) {
96 | db.close();
97 | }
98 | }
99 | }
100 |
101 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/git-sqlite-cli/src/commands/clone.ts:
--------------------------------------------------------------------------------
1 | // src/commands/clone.ts
2 | import { SQLiteFSAdapter } from "../../../sqlite-fs/src/index.js";
3 | import { BunSqliteAdapter } from "../../../sqlite-fs/src/bun-sqlite-adapter.js"; // Import BunSqliteAdapter
4 | import git from "isomorphic-git";
5 | import http from "isomorphic-git/http/node"; // Using Node's HTTP client via Bun
6 | import path from "node:path";
7 | import fs from "node:fs"; // Using Node's fs for directory/file checks via Bun
8 | import { Database } from "bun:sqlite"; // Import Bun's Database
9 | import chalk from "chalk";
10 | import type { ParsedArgs } from "minimist";
11 |
12 | export async function cloneCommand(
13 | repoUrl: string,
14 | dbFilePath: string,
15 | options: ParsedArgs,
16 | ): Promise {
17 | console.log(
18 | chalk.blue(
19 | `Cloning ${chalk.bold(repoUrl)} into ${chalk.bold(dbFilePath)}...`,
20 | ),
21 | );
22 |
23 | let db: Database | null = null; // Keep track of DB instance for finally block
24 | let dbAdapter: BunSqliteAdapter | null = null;
25 |
26 | try {
27 | // 1. Ensure parent directory for the database file exists
28 | const dbDir = path.dirname(dbFilePath);
29 | fs.mkdirSync(dbDir, { recursive: true });
30 |
31 | // 2. Create the bun:sqlite Database instance
32 | // Use { create: true } to ensure the file is created if it doesn't exist
33 | db = new Database(dbFilePath, { create: true });
34 | // Optional: Enable WAL mode for potentially better performance on file DBs
35 | db.exec("PRAGMA journal_mode = WAL;");
36 |
37 | // 3. Instantiate BunSqliteAdapter with the Database instance
38 | dbAdapter = new BunSqliteAdapter(db);
39 |
40 | // 4. Instantiate SQLiteFSAdapter with the BunSqliteAdapter
41 | const fsAdapter = new SQLiteFSAdapter(dbAdapter);
42 | // The adapter's constructor should handle schema initialization (CREATE TABLE IF NOT EXISTS)
43 |
44 | // 5. Define progress/message handlers for isomorphic-git
45 | const onMessage = (message: string) => {
46 | // Clean up potential trailing newlines from isomorphic-git messages
47 | process.stdout.write(message.replace(/(\r\n|\n|\r)$/, "") + "\r");
48 | };
49 | const onProgress = (progress: any) => {
50 | // Example: Log progress stage and loaded/total info if available
51 | if (
52 | progress.phase &&
53 | progress.loaded !== undefined &&
54 | progress.total !== undefined
55 | ) {
56 | process.stdout.write(
57 | `Phase: ${progress.phase}, Progress: ${progress.loaded}/${progress.total} \r`,
58 | );
59 | } else if (progress.phase) {
60 | process.stdout.write(`Phase: ${progress.phase} \r`);
61 | }
62 | };
63 |
64 | // 6. Execute the clone operation
65 | await git.clone({
66 | fs: fsAdapter,
67 | http,
68 | dir: ".", // Root directory within the virtual filesystem
69 | url: repoUrl,
70 | singleBranch: true, // Recommended for faster clones
71 | noCheckout: true,
72 | depth: 1,
73 | onMessage,
74 | onProgress,
75 | // corsProxy: '...', // Add if required for specific environments
76 | });
77 |
78 | // Clear progress line after completion
79 | process.stdout.write("\n");
80 | console.log(chalk.green("Clone completed successfully."));
81 | } catch (error: any) {
82 | process.stdout.write("\n"); // Ensure newline after potential progress messages
83 | // Re-throw the error to be caught by the main handler in cli.ts
84 | throw new Error(`Clone failed: ${error.message}`);
85 | } finally {
86 | // 7. Ensure the database connection is closed
87 | if (dbAdapter) {
88 | // The adapter's close method should call the underlying db.close()
89 | dbAdapter.close?.();
90 | // console.log(chalk.gray('Database connection closed.'));
91 | } else if (db) {
92 | // Fallback if adapter wasn't created but db was
93 | db.close();
94 | // console.log(chalk.gray('Database connection closed (fallback).'));
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/tests/bun-sqlite-adapter.test.ts:
--------------------------------------------------------------------------------
1 | // tests/bun-sqlite-adapter.test.ts
2 | import { afterEach, beforeEach, describe, expect, it } from "vitest";
3 | import { BunSqliteAdapter } from "../src/bun-sqlite-adapter";
4 | import type { SyncSqliteDatabase } from "../src/interfaces";
5 | import { Database } from "bun:sqlite";
6 |
7 | describe("BunSqliteAdapter", () => {
8 | let db: SyncSqliteDatabase;
9 |
10 | beforeEach(() => {
11 | // Use new in-memory DB for each test
12 | const bunDb = new Database(":memory:");
13 | db = new BunSqliteAdapter(bunDb);
14 | // Setup common schema if needed
15 | db.exec(`
16 | CREATE TABLE users (
17 | id INTEGER PRIMARY KEY AUTOINCREMENT,
18 | name TEXT NOT NULL,
19 | email TEXT UNIQUE
20 | );
21 | `);
22 | });
23 |
24 | afterEach(() => {
25 | db.close?.();
26 | });
27 |
28 | it("should execute INSERT and SELECT using all()", () => {
29 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
30 | "Alice",
31 | "alice@example.com",
32 | ]);
33 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
34 | "Bob",
35 | "bob@example.com",
36 | ]);
37 |
38 | const users = db.all("SELECT name, email FROM users ORDER BY name");
39 | expect(users).toEqual([
40 | { name: "Alice", email: "alice@example.com" },
41 | { name: "Bob", email: "bob@example.com" },
42 | ]);
43 | });
44 |
45 | it("should return empty array from all() when no rows match", () => {
46 | const users = db.all("SELECT name FROM users WHERE name = ?", ["Charlie"]);
47 | expect(users).toEqual([]);
48 | });
49 |
50 | it("should execute SELECT using one() successfully", () => {
51 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
52 | "Alice",
53 | "alice@example.com",
54 | ]);
55 | const user = db.one("SELECT name FROM users WHERE email = ?", [
56 | "alice@example.com",
57 | ]);
58 | expect(user).toEqual({ name: "Alice" });
59 | });
60 |
61 | it("should throw error from one() when no rows match", () => {
62 | expect(() => {
63 | db.one("SELECT name FROM users WHERE name = ?", ["Charlie"]);
64 | }).toThrow("SQLite one() error: No rows found");
65 | });
66 |
67 | it("should throw error from one() when multiple rows match", () => {
68 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
69 | "Alice",
70 | "alice1@example.com",
71 | ]);
72 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
73 | "Alice",
74 | "alice2@example.com",
75 | ]);
76 | expect(() => {
77 | db.one("SELECT email FROM users WHERE name = ?", ["Alice"]);
78 | }).toThrow("SQLite one() error: Expected 1 row, got 2");
79 | });
80 |
81 | it("should iterate results using iterator()", () => {
82 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
83 | "Alice",
84 | "alice@example.com",
85 | ]);
86 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
87 | "Bob",
88 | "bob@example.com",
89 | ]);
90 | const iter = db.iterator<{ name: string }>(
91 | "SELECT name FROM users ORDER BY name",
92 | );
93 | const names = Array.from(iter).map((row) => row.name);
94 | expect(names).toEqual(["Alice", "Bob"]);
95 | });
96 |
97 | it("should handle empty results with iterator()", () => {
98 | const iter = db.iterator("SELECT name FROM users");
99 | expect(Array.from(iter)).toEqual([]);
100 | });
101 |
102 | it("should throw error on invalid SQL", () => {
103 | expect(() => {
104 | db.exec("INSERT INTO non_existent_table VALUES (1)");
105 | }).toThrow();
106 | });
107 |
108 | it("should handle parameter binding correctly", () => {
109 | db.exec("INSERT INTO users (name, email) VALUES (?, ?)", [
110 | "Alice",
111 | "alice@example.com",
112 | ]);
113 | const user = db.one("SELECT * FROM users WHERE name = ? AND email = ?", [
114 | "Alice",
115 | "alice@example.com",
116 | ]);
117 | expect(user.name).toBe("Alice");
118 | expect(user.email).toBe("alice@example.com");
119 | });
120 | });
121 |
122 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/file-explorer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ScrollArea } from "@/components/ui/scroll-area";
3 | import { FileEntry } from "@/lib/types";
4 | import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
5 | import { useState } from "react";
6 |
7 | interface FileExplorerProps {
8 | onSelectFile: (file: FileEntry | null) => void;
9 | currentPath: string[];
10 | setCurrentPath: (path: string[]) => void;
11 | files: FileEntry[];
12 | }
13 |
14 | export function FileExplorer({
15 | onSelectFile,
16 | currentPath,
17 | setCurrentPath,
18 | files,
19 | }: FileExplorerProps) {
20 | const [expandedFolders, setExpandedFolders] = useState<
21 | Record
22 | >({});
23 |
24 | const toggleFolder = (path: string) => {
25 | setExpandedFolders((prev) => ({
26 | ...prev,
27 | [path]: !prev[path],
28 | }));
29 | };
30 |
31 | const handleFileClick = (entry: FileEntry) => {
32 | onSelectFile(entry);
33 | };
34 |
35 | const handleFolderClick = (entry: FileEntry) => {
36 | const { path } = entry;
37 | toggleFolder(path);
38 |
39 | if (!expandedFolders[path]) {
40 | setCurrentPath(path.split("/"));
41 | }
42 | };
43 |
44 | const navigateUp = () => {
45 | if (currentPath.length > 0) {
46 | setCurrentPath(currentPath.slice(0, -1));
47 | }
48 | };
49 |
50 | const renderFileTree = (structure: FileEntry[]) => {
51 | return structure.map((entry) => {
52 | const isExpanded = expandedFolders[entry.path];
53 |
54 | if (entry.type === "directory") {
55 | return (
56 |
57 |
handleFolderClick(entry)}
60 | >
61 |
76 |
77 | {entry.name}
78 |
79 | {isExpanded && (
80 |
{renderFileTree(entry.children || [])}
81 | )}
82 |
83 | );
84 | } else {
85 | return (
86 | handleFileClick(entry)}
90 | >
91 |
92 | {entry.name}
93 |
94 | );
95 | }
96 | });
97 | };
98 |
99 | const getBreadcrumbs = () => {
100 | return (
101 |
102 |
111 | {currentPath.map((segment, index) => (
112 |
113 | /
114 |
122 |
123 | ))}
124 |
125 | );
126 | };
127 |
128 | return (
129 |
130 | {getBreadcrumbs()}
131 |
132 | {renderFileTree(files)}
133 |
134 |
135 | );
136 | }
137 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/pages/repo.tsx:
--------------------------------------------------------------------------------
1 | import { FileExplorer } from "@/components/file-explorer";
2 | import { FileViewer } from "@/components/file-viewer";
3 | import { RepoHeader } from "@/components/repo-header";
4 | import { FileEntry } from "@/lib/types";
5 | import { useCallback, useEffect, useRef, useState } from "react";
6 | import {
7 | type CommitInfo,
8 | type Events,
9 | } from "../../worker/do/readonly-repo-object";
10 | import { LoaderPinwheel } from "lucide-react";
11 |
12 | export function RepoPage({ repo }: { repo: string }) {
13 | const [status, setStatus] = useState("unknown");
14 | const [files, setFiles] = useState([]);
15 | const [selectedFile, setSelectedFile] = useState(null);
16 | const [fileContent, setFileContent] = useState(null);
17 | const [currentPath, setCurrentPath] = useState([]);
18 | const [isLoading, setIsLoading] = useState(false);
19 | const [commitInfo, setCommitInfo] = useState(null);
20 | const websocket = useRef(null);
21 | const [progress, setProgress] = useState<{
22 | phase: string;
23 | loaded: number;
24 | total: number;
25 | } | null>(null);
26 |
27 | const fetchFiles = useCallback(async () => {
28 | setIsLoading(true);
29 | const res = await fetch(`/api/${repo}/ls-files`, {
30 | method: "POST",
31 | });
32 | const data = (await res.json()) as { files: FileEntry[] };
33 | setFiles(data.files);
34 | setIsLoading(false);
35 | }, [repo]);
36 |
37 | const fetchBlob = async (oid: string) => {
38 | const res = await fetch(`/api/${repo}/blob/${oid}`, {
39 | method: "GET",
40 | });
41 | const data = (await res.json()) as { blob: string };
42 | return data.blob;
43 | };
44 |
45 | const onSelectFile = async (entry: FileEntry | null) => {
46 | if (!entry) {
47 | setSelectedFile(null);
48 | return;
49 | }
50 | if (entry.type === "file") {
51 | setCurrentPath(entry.path.split("/").slice(0, -1));
52 | setSelectedFile(entry);
53 | setFileContent("Loading...");
54 | const blob = await fetchBlob(entry.oid);
55 | setFileContent(blob);
56 | } else {
57 | setCurrentPath(entry.path.split("/"));
58 | }
59 | };
60 |
61 | const fetchRepo = async () => {
62 | websocket.current?.send(JSON.stringify({ type: "fetch" }));
63 | };
64 |
65 | useEffect(() => {
66 | if (status === "new") {
67 | websocket.current?.send(
68 | JSON.stringify({
69 | type: "clone",
70 | }),
71 | );
72 | } else if (status === "ready") {
73 | fetchFiles();
74 | }
75 | }, [status, fetchFiles]);
76 |
77 | useEffect(() => {
78 | const ws = new WebSocket(`/api/${repo}/ws`);
79 | websocket.current = ws;
80 | ws.addEventListener("open", () => {
81 | console.log("WebSocket connection established");
82 | ws.send(JSON.stringify({ type: "init", repo }));
83 | });
84 | ws.addEventListener("message", (msg) => {
85 | const event = JSON.parse(msg.data) as Events;
86 | console.log("Received event:", event);
87 | if (event.type === "status") {
88 | setStatus(event.status);
89 | setCommitInfo(event.commitInfo || null);
90 | } else if (event.type === "progress") {
91 | setProgress(event.progress);
92 | }
93 | });
94 |
95 | return () => {
96 | ws.close();
97 | };
98 | }, [repo, fetchFiles]);
99 |
100 | const loaded = status === "ready" && !isLoading;
101 | let loadingMessage = "";
102 | if (status === "cloning") {
103 | loadingMessage = progress?.phase || "Cloning repository...";
104 | } else if (status === "fetching") {
105 | loadingMessage = progress?.phase || "Fetching repository...";
106 | } else if (status === "ready") {
107 | loadingMessage = "Loading files...";
108 | }
109 |
110 | return (
111 |
112 |
119 |
120 | {!loaded ? (
121 |
122 |
123 |
{loadingMessage}
124 |
125 | ) : (
126 | <>
127 |
133 |
134 | >
135 | )}
136 |
137 |
138 | );
139 | }
140 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --radius-sm: calc(var(--radius) - 4px);
8 | --radius-md: calc(var(--radius) - 2px);
9 | --radius-lg: var(--radius);
10 | --radius-xl: calc(var(--radius) + 4px);
11 | --color-background: var(--background);
12 | --color-foreground: var(--foreground);
13 | --color-card: var(--card);
14 | --color-card-foreground: var(--card-foreground);
15 | --color-popover: var(--popover);
16 | --color-popover-foreground: var(--popover-foreground);
17 | --color-primary: var(--primary);
18 | --color-primary-foreground: var(--primary-foreground);
19 | --color-secondary: var(--secondary);
20 | --color-secondary-foreground: var(--secondary-foreground);
21 | --color-muted: var(--muted);
22 | --color-muted-foreground: var(--muted-foreground);
23 | --color-accent: var(--accent);
24 | --color-accent-foreground: var(--accent-foreground);
25 | --color-destructive: var(--destructive);
26 | --color-border: var(--border);
27 | --color-input: var(--input);
28 | --color-ring: var(--ring);
29 | --color-chart-1: var(--chart-1);
30 | --color-chart-2: var(--chart-2);
31 | --color-chart-3: var(--chart-3);
32 | --color-chart-4: var(--chart-4);
33 | --color-chart-5: var(--chart-5);
34 | --color-sidebar: var(--sidebar);
35 | --color-sidebar-foreground: var(--sidebar-foreground);
36 | --color-sidebar-primary: var(--sidebar-primary);
37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
38 | --color-sidebar-accent: var(--sidebar-accent);
39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
40 | --color-sidebar-border: var(--sidebar-border);
41 | --color-sidebar-ring: var(--sidebar-ring);
42 | }
43 |
44 | :root {
45 | --radius: 0.625rem;
46 | --background: oklch(1 0 0);
47 | --foreground: oklch(0.129 0.042 264.695);
48 | --card: oklch(1 0 0);
49 | --card-foreground: oklch(0.129 0.042 264.695);
50 | --popover: oklch(1 0 0);
51 | --popover-foreground: oklch(0.129 0.042 264.695);
52 | --primary: oklch(0.208 0.042 265.755);
53 | --primary-foreground: oklch(0.984 0.003 247.858);
54 | --secondary: oklch(0.968 0.007 247.896);
55 | --secondary-foreground: oklch(0.208 0.042 265.755);
56 | --muted: oklch(0.968 0.007 247.896);
57 | --muted-foreground: oklch(0.554 0.046 257.417);
58 | --accent: oklch(0.968 0.007 247.896);
59 | --accent-foreground: oklch(0.208 0.042 265.755);
60 | --destructive: oklch(0.577 0.245 27.325);
61 | --border: oklch(0.929 0.013 255.508);
62 | --input: oklch(0.929 0.013 255.508);
63 | --ring: oklch(0.704 0.04 256.788);
64 | --chart-1: oklch(0.646 0.222 41.116);
65 | --chart-2: oklch(0.6 0.118 184.704);
66 | --chart-3: oklch(0.398 0.07 227.392);
67 | --chart-4: oklch(0.828 0.189 84.429);
68 | --chart-5: oklch(0.769 0.188 70.08);
69 | --sidebar: oklch(0.984 0.003 247.858);
70 | --sidebar-foreground: oklch(0.129 0.042 264.695);
71 | --sidebar-primary: oklch(0.208 0.042 265.755);
72 | --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
73 | --sidebar-accent: oklch(0.968 0.007 247.896);
74 | --sidebar-accent-foreground: oklch(0.208 0.042 265.755);
75 | --sidebar-border: oklch(0.929 0.013 255.508);
76 | --sidebar-ring: oklch(0.704 0.04 256.788);
77 | }
78 |
79 | .dark {
80 | --background: oklch(0.129 0.042 264.695);
81 | --foreground: oklch(0.984 0.003 247.858);
82 | --card: oklch(0.208 0.042 265.755);
83 | --card-foreground: oklch(0.984 0.003 247.858);
84 | --popover: oklch(0.208 0.042 265.755);
85 | --popover-foreground: oklch(0.984 0.003 247.858);
86 | --primary: oklch(0.929 0.013 255.508);
87 | --primary-foreground: oklch(0.208 0.042 265.755);
88 | --secondary: oklch(0.279 0.041 260.031);
89 | --secondary-foreground: oklch(0.984 0.003 247.858);
90 | --muted: oklch(0.279 0.041 260.031);
91 | --muted-foreground: oklch(0.704 0.04 256.788);
92 | --accent: oklch(0.279 0.041 260.031);
93 | --accent-foreground: oklch(0.984 0.003 247.858);
94 | --destructive: oklch(0.704 0.191 22.216);
95 | --border: oklch(1 0 0 / 10%);
96 | --input: oklch(1 0 0 / 15%);
97 | --ring: oklch(0.551 0.027 264.364);
98 | --chart-1: oklch(0.488 0.243 264.376);
99 | --chart-2: oklch(0.696 0.17 162.48);
100 | --chart-3: oklch(0.769 0.188 70.08);
101 | --chart-4: oklch(0.627 0.265 303.9);
102 | --chart-5: oklch(0.645 0.246 16.439);
103 | --sidebar: oklch(0.208 0.042 265.755);
104 | --sidebar-foreground: oklch(0.984 0.003 247.858);
105 | --sidebar-primary: oklch(0.488 0.243 264.376);
106 | --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
107 | --sidebar-accent: oklch(0.279 0.041 260.031);
108 | --sidebar-accent-foreground: oklch(0.984 0.003 247.858);
109 | --sidebar-border: oklch(1 0 0 / 10%);
110 | --sidebar-ring: oklch(0.551 0.027 264.364);
111 | }
112 |
113 | @layer base {
114 | * {
115 | @apply border-border outline-ring/50;
116 | }
117 | body {
118 | @apply bg-background text-foreground;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/apps/cf-demo/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 | {children}
132 |
133 | ))
134 | SelectItem.displayName = SelectPrimitive.Item.displayName
135 |
136 | const SelectSeparator = React.forwardRef<
137 | React.ElementRef,
138 | React.ComponentPropsWithoutRef
139 | >(({ className, ...props }, ref) => (
140 |
145 | ))
146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
147 |
148 | export {
149 | Select,
150 | SelectGroup,
151 | SelectValue,
152 | SelectTrigger,
153 | SelectContent,
154 | SelectLabel,
155 | SelectItem,
156 | SelectSeparator,
157 | SelectScrollUpButton,
158 | SelectScrollDownButton,
159 | }
160 |
--------------------------------------------------------------------------------
/apps/cf-demo/worker/do/readonly-repo-object.ts:
--------------------------------------------------------------------------------
1 | import { DurableObject } from "cloudflare:workers";
2 | import git, {
3 | type CommitObject,
4 | type GitProgressEvent,
5 | type ReadCommitResult,
6 | } from "isomorphic-git";
7 | import http from "isomorphic-git/http/web";
8 | import { DurableObjectSqliteAdapter } from "sqlite-fs/do";
9 | import { SQLiteFSAdapter } from "sqlite-fs";
10 | import { type FileEntry, walkTree } from "../lib/walk-tree";
11 |
12 | type STATUS = "new" | "cloning" | "ready" | "fetching" | "error";
13 |
14 | export interface Message {
15 | type: string;
16 | }
17 | export interface Init extends Message {
18 | type: "init";
19 | repoName: string;
20 | }
21 |
22 | export interface Clone extends Message {
23 | type: "clone";
24 | }
25 | export interface Fetch extends Message {
26 | type: "fetch";
27 | }
28 |
29 | export interface Status extends Message {
30 | type: "status";
31 | status: STATUS;
32 | commitInfo?: CommitInfo;
33 | }
34 |
35 | export interface Progress extends Message {
36 | type: "progress";
37 | progress: GitProgressEvent;
38 | }
39 |
40 | export type Commands = Init | Clone | Fetch;
41 | export type Events = Status | Progress;
42 |
43 | export type CommitInfo = {
44 | branch: string | undefined;
45 | commit: ReadCommitResult | undefined;
46 | };
47 |
48 | export class ReadonlyRepoObject extends DurableObject {
49 | private repoName: string;
50 | private status: STATUS;
51 | private _fsAdapter?: SQLiteFSAdapter;
52 | private _files: FileEntry[] | null = null;
53 | private _commitInfo: CommitInfo | null = null;
54 |
55 | constructor(ctx: DurableObjectState, env: Env) {
56 | super(ctx, env);
57 | this.repoName = "";
58 | this.status = "new";
59 | ctx.blockConcurrencyWhile(async () => {
60 | this.repoName = (await ctx.storage.get("repoName")) || "";
61 | this.status = (await ctx.storage.get("status")) || "new";
62 | });
63 | }
64 |
65 | async fetch(_request: Request): Promise {
66 | const webSocketPair = new WebSocketPair();
67 | const [client, server] = Object.values(webSocketPair);
68 |
69 | this.ctx.acceptWebSocket(server!);
70 |
71 | return new Response(null, {
72 | status: 101,
73 | webSocket: client,
74 | });
75 | }
76 |
77 | async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
78 | const msg = JSON.parse(message.toString());
79 | if (msg.type === "init") {
80 | const commitInfo = await this.getLastCommit();
81 | ws.send(
82 | JSON.stringify({ type: "status", status: this.status, commitInfo }),
83 | );
84 | } else if (msg.type === "clone") {
85 | await this.clone();
86 | } else if (msg.type === "fetch") {
87 | await this.gitFetch();
88 | }
89 | }
90 |
91 | async webSocketClose(
92 | ws: WebSocket,
93 | code: number,
94 | _reason: string,
95 | _wasClean: boolean,
96 | ) {
97 | // If the client closes the connection, the runtime will invoke the webSocketClose() handler.
98 | ws.close(code, "Durable Object is closing WebSocket");
99 | }
100 |
101 | async initialize(repoName: string) {
102 | if (this.repoName) {
103 | return;
104 | }
105 | this.repoName = repoName;
106 |
107 | this.ctx.blockConcurrencyWhile(async () => {
108 | await this.ctx.storage.put("repoName", repoName);
109 | });
110 | }
111 |
112 | async clone() {
113 | if (this.status !== "new") {
114 | return;
115 | }
116 | this.status = "cloning";
117 | await this.setStatus("cloning");
118 |
119 | const repoUrl = `https://github.com/${this.repoName}.git`;
120 | const onMessage = (message: string) => console.log(message);
121 | const onProgress = (progress: GitProgressEvent) => {
122 | this.broadcast({
123 | type: "progress",
124 | progress,
125 | });
126 | };
127 | await git.clone({
128 | fs: this.fsAdapter,
129 | http,
130 | dir: ".",
131 | url: repoUrl,
132 | singleBranch: true,
133 | noCheckout: true,
134 | depth: 1,
135 | onMessage,
136 | onProgress,
137 | });
138 | this.setStatus("ready");
139 | }
140 |
141 | async gitFetch() {
142 | if (this.status !== "ready") {
143 | return;
144 | }
145 | this.status = "fetching";
146 | this.setStatus("fetching");
147 |
148 | const repoUrl = `https://github.com/${this.repoName}.git`;
149 | const onMessage = (message: string) => console.log(message);
150 | const onProgress = (progress: GitProgressEvent) => {
151 | this.broadcast({
152 | type: "progress",
153 | progress,
154 | });
155 | };
156 | await git.fetch({
157 | fs: this.fsAdapter,
158 | http,
159 | dir: ".",
160 | url: repoUrl,
161 | singleBranch: true,
162 | depth: 1,
163 | onProgress,
164 | onMessage,
165 | });
166 | this.setStatus("ready");
167 | }
168 |
169 | async listFiles(): Promise {
170 | if (this._files) {
171 | return this._files;
172 | }
173 | this._files = await walkTree({
174 | fs: this.fsAdapter,
175 | repoDir: ".",
176 | });
177 | return this._files || [];
178 | }
179 |
180 | async getBlob(oid: string) {
181 | const blob = await git.readBlob({
182 | fs: this.fsAdapter,
183 | dir: ".",
184 | oid,
185 | });
186 | return blob;
187 | }
188 |
189 | async getStatus(): Promise {
190 | return this.status;
191 | }
192 |
193 | async getLastCommit() {
194 | if (this._commitInfo) {
195 | return this._commitInfo;
196 | }
197 | const branch = await git.currentBranch({
198 | fs: this.fsAdapter,
199 | dir: ".",
200 | });
201 | const commits = await git.log({
202 | fs: this.fsAdapter,
203 | dir: ".",
204 | depth: 1,
205 | });
206 | this._commitInfo = {
207 | branch: branch || undefined,
208 | commit: commits[0] || undefined,
209 | };
210 | return this._commitInfo;
211 | }
212 |
213 | private async setStatus(status: STATUS) {
214 | this.status = status;
215 | await this.ctx.storage.put("status", status);
216 | const commitInfo = await this.getLastCommit();
217 | this.broadcast({ type: "status", status, commitInfo });
218 | }
219 |
220 | private broadcast(message: Events) {
221 | const webSockets = this.ctx.getWebSockets();
222 | for (const ws of webSockets) {
223 | ws.send(JSON.stringify(message));
224 | }
225 | }
226 |
227 | private get fsAdapter() {
228 | if (this._fsAdapter) {
229 | return this._fsAdapter;
230 | }
231 | const dbAdapter = new DurableObjectSqliteAdapter(this.ctx.storage);
232 | this._fsAdapter = new SQLiteFSAdapter(dbAdapter);
233 | return this._fsAdapter;
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/docs/steps/step_8.md:
--------------------------------------------------------------------------------
1 | **Plan: Create `DurableObjectSqliteAdapter`**
2 |
3 | **1. Goal:**
4 |
5 | Create a class named `DurableObjectSqliteAdapter` that implements the `SyncSqliteDatabase` interface (defined in the `sqlite-fs` library). This adapter will use the synchronous `ctx.storage.sql` API provided by the Cloudflare Durable Object runtime as its backend.
6 |
7 | **2. Location:**
8 |
9 | * Create the adapter file at: `apps/cf-demo/worker/lib/durable-object-sqlite-adapter.ts`
10 |
11 | **3. Dependencies:**
12 |
13 | * Ensure the `apps/cf-demo` package has `@cloudflare/workers-types` installed as a development dependency (`npm install -D @cloudflare/workers-types` or `bun add -d @cloudflare/workers-types`).
14 | * The adapter will import `SyncSqliteDatabase` and `SyncSqliteIterator` from the `sqlite-fs` library (e.g., `import type { SyncSqliteDatabase, SyncSqliteIterator } from 'sqlite-fs';`).
15 |
16 | **4. Implementation Steps:**
17 |
18 | * **a. Create File and Basic Structure:**
19 | * Create the file `durable-object-sqlite-adapter.ts` at the specified location.
20 | * Import necessary types from `@cloudflare/workers-types` (`DurableObjectStorage`, `SqlStorage`) and `sqlite-fs` (`SyncSqliteDatabase`, `SyncSqliteIterator`).
21 | * Define the class `DurableObjectSqliteAdapter` and declare that it implements `SyncSqliteDatabase`.
22 |
23 | * **b. Implement the Constructor:**
24 | * The constructor should accept one argument: `storage: DurableObjectStorage`.
25 | * Inside the constructor, check if `storage.sql` exists. If not, throw an informative error (e.g., "DurableObjectStorage does not have the 'sql' property. Ensure the DO is configured for SQLite storage.").
26 | * Store the `storage.sql` object (which is of type `SqlStorage`) in a private instance variable (e.g., `this.sql`).
27 |
28 | * **c. Implement `exec` Method:**
29 | * **Signature:** `exec(sql: string, params?: any[]): void`
30 | * **Logic:** Use `this.sql.prepare(sql).run(...(params ?? []))` to execute the statement. The `run()` method is suitable as `exec` isn't expected to return results.
31 | * **Error Handling:** Wrap the call in a `try...catch` block. Log errors for debugging purposes but re-throw the original error.
32 |
33 | * **d. Implement `all` Method:**
34 | * **Signature:** `all>(sql: string, params?: any[]): T[]`
35 | * **Logic:** Use `this.sql.prepare(sql).all(...(params ?? []))` which directly returns an array of row objects.
36 | * **Type Casting:** Cast the result to `T[]`.
37 | * **Error Handling:** Wrap in `try...catch`, log errors, and re-throw.
38 |
39 | * **e. Implement `one` Method:**
40 | * **Signature:** `one>(sql: string, params?: any[]): T`
41 | * **Logic:** Use `this.sql.prepare(sql).one(...(params ?? []))`. The DO's `one()` method conveniently throws an exception if zero or more than one row is found, matching the interface requirement.
42 | * **Type Casting:** Cast the result to `T`.
43 | * **Error Handling:** Wrap in `try...catch`. Log errors (perhaps excluding the expected "No rows found" / "more than one row" errors from verbose logging) and re-throw the original error. The `SQLiteFSAdapter` relies on these specific errors being thrown.
44 |
45 | * **f. Implement `iterator` Method:**
46 | * **Signature:** `iterator>(sql: string, params?: any[]): SyncSqliteIterator`
47 | * **Logic:** The result of `this.sql.prepare(sql).all(...(params ?? []))` in the DO API is an iterable cursor. Return its iterator using the `Symbol.iterator` method: `return this.sql.prepare(sql).all(...(params ?? []))[Symbol.iterator]()`.
48 | * **Type Casting:** Cast the result to `SyncSqliteIterator`.
49 | * **Error Handling:** Wrap in `try...catch`, log errors, and re-throw.
50 |
51 | * **g. Implement `close` Method (Optional):**
52 | * **Signature:** `close?(): void`
53 | * **Logic:** Durable Object storage does not require explicit closing. This method can be omitted from the class implementation, as it's optional in the `SyncSqliteDatabase` interface.
54 |
55 | * **h. Export the Class:**
56 | * Add `export` before the class definition: `export class DurableObjectSqliteAdapter implements SyncSqliteDatabase { ... }`.
57 |
58 | **5. Testing Strategy:**
59 |
60 | * No testing is required for this step. The adapter will be testing in the next step.
61 |
62 | **6. Code Example Snippet (Illustrative):**
63 |
64 | ```typescript
65 | // apps/cf-demo/worker/lib/durable-object-sqlite-adapter.ts
66 | import type { DurableObjectStorage, SqlStorage } from '@cloudflare/workers-types';
67 | import type { SyncSqliteDatabase, SyncSqliteIterator } from 'sqlite-fs'; // Adjust import path if needed
68 |
69 | export class DurableObjectSqliteAdapter implements SyncSqliteDatabase {
70 | private sql: SqlStorage;
71 |
72 | constructor(storage: DurableObjectStorage) {
73 | if (!storage.sql) {
74 | throw new Error("DurableObjectStorage missing 'sql' property. Ensure DO uses SQLite backend.");
75 | }
76 | this.sql = storage.sql;
77 | }
78 |
79 | exec(sql: string, params?: any[]): void {
80 | try {
81 | this.sql.prepare(sql).run(...(params ?? []));
82 | } catch (e: any) {
83 | console.error(`DO Adapter exec Error: ${e.message}`, sql, params);
84 | throw e;
85 | }
86 | }
87 |
88 | all>(sql: string, params?: any[]): T[] {
89 | try {
90 | return this.sql.prepare(sql).all(...(params ?? [])) as T[];
91 | } catch (e: any) {
92 | console.error(`DO Adapter all Error: ${e.message}`, sql, params);
93 | throw e;
94 | }
95 | }
96 |
97 | one>(sql: string, params?: any[]): T {
98 | try {
99 | // DO's one() throws if 0 or >1 rows, matching interface requirement
100 | return this.sql.prepare(sql).one(...(params ?? [])) as T;
101 | } catch (e: any) {
102 | // Avoid excessive logging for expected "not found" errors, but still re-throw
103 | if (!e.message?.includes('exactly one row')) { // Check specific DO error message
104 | console.error(`DO Adapter one Error: ${e.message}`, sql, params);
105 | }
106 | throw e;
107 | }
108 | }
109 |
110 | iterator>(sql: string, params?: any[]): SyncSqliteIterator {
111 | try {
112 | // DO's .all() result is already iterable
113 | const cursor = this.sql.prepare(sql).all(...(params ?? []));
114 | return cursor[Symbol.iterator]() as SyncSqliteIterator;
115 | } catch (e: any) {
116 | console.error(`DO Adapter iterator Error: ${e.message}`, sql, params);
117 | throw e;
118 | }
119 | }
120 |
121 | // No close() method needed for DO storage
122 | }
123 | ```
124 |
--------------------------------------------------------------------------------
/docs/steps/step_7.md:
--------------------------------------------------------------------------------
1 | **Plan: Modify `sqlite-fs-library` to Support File Chunking (Max ~2MB)**
2 |
3 | **1. Goal:**
4 |
5 | Modify the `sqlite-fs-library` to store file content in multiple database rows ("chunks") when the file size exceeds a defined limit (~1.8MB). This ensures compatibility with storage backends like Cloudflare Durable Objects SQLite that have row/blob size limitations. This modification should be done *without*
6 | adding database transaction logic.
7 |
8 | **2. Core Problem & Strategy:**
9 |
10 | * **Problem:** Some SQLite backends limit the maximum size of data (like BLOBs) stored in a single row.
11 | * **Strategy:** We will implement a "chunking" strategy. Files larger than a predefined `CHUNK_SIZE` will have their content split across multiple rows in a new database table. Smaller files, directories, and symlinks will still primarily use a single row for their metadata and content/target.
12 |
13 | **3. Schema Changes:**
14 |
15 | * **Action:** Define and use a new SQL schema designed for chunking. Locate the existing schema definition (likely in a `src/schema.ts` or similar) and replace it with the following structure. Ensure the `SQLiteFSAdapter` constructor uses this new schema for `CREATE TABLE IF NOT EXISTS`.
16 | * **New Schema SQL:**
17 | ```sql
18 | -- Define this table structure, replacing the old one
19 | CREATE TABLE IF NOT EXISTS file_chunks (
20 | path TEXT NOT NULL, -- The virtual filesystem path
21 | chunk_index INTEGER NOT NULL, -- 0 for the first/only chunk or metadata, 1+ for subsequent chunks
22 | type TEXT NOT NULL CHECK(type IN ('file', 'directory', 'symlink')), -- Node type
23 | content BLOB, -- File chunk data, symlink target, or NULL for directory
24 | mode INTEGER NOT NULL, -- Filesystem mode
25 | mtime TEXT NOT NULL, -- Modification time (ISO8601)
26 | total_size INTEGER NOT NULL, -- Original total size of the file (0 for dirs/links)
27 | PRIMARY KEY (path, chunk_index) -- Ensures chunk uniqueness per path
28 | );
29 |
30 | -- Add indexes for efficient lookups
31 | CREATE INDEX IF NOT EXISTS idx_file_chunks_metadata ON file_chunks (path, chunk_index) WHERE chunk_index = 0;
32 | CREATE INDEX IF NOT EXISTS idx_file_chunks_ordered ON file_chunks (path, chunk_index);
33 | ```
34 |
35 | **4. Configuration:**
36 |
37 | * **Action:** Define a constant for the maximum chunk size within the library (e.g., in `src/schema.ts` or a config file).
38 | * **Value:** `export const CHUNK_SIZE = 1.8 * 1024 * 1024;` (This provides a safety margin below 2MB).
39 |
40 | **5. `SQLiteFSAdapter` Modifications:**
41 |
42 | * **General:** Update all methods that interact with the database to use the `file_chunks` table and its columns (`chunk_index`, `total_size`, etc.). Adapt SQL queries accordingly. Remember that existence checks, type checks, and metadata retrieval should primarily target the row where `chunk_index = 0`.
43 |
44 | * **`lstat` / `stat` Methods:**
45 | * **Logic:** Query the `file_chunks` table for the row where `path = ?` AND `chunk_index = 0`.
46 | * **Data:** Retrieve `type`, `mode`, `mtime`, and `total_size` from this row.
47 | * **Stats Object:** Use the retrieved `total_size` for the `Stats.size` property. Update the `createStats` utility if necessary.
48 | * **Error Handling:** If the `chunk_index = 0` row is not found, throw an `ENOENT` error.
49 |
50 | * **`readFile` Method:**
51 | * **Logic:**
52 | 1. First, check for the existence and type of the file by querying the `chunk_index = 0` row. Throw `ENOENT` if not found, `EISDIR` if it's a directory.
53 | 2. If it's a file, query *all* rows for that path, ordered by `chunk_index`: `SELECT content FROM file_chunks WHERE path = ? ORDER BY chunk_index ASC`.
54 | 3. Iterate through the results, collecting all non-null `content` BLOBs.
55 | 4. Concatenate these BLOBs (e.g., using `Buffer.concat()`) into a single Buffer.
56 | 5. Handle encoding options on the final concatenated buffer.
57 | * **Error Handling:** Handle database errors during chunk retrieval.
58 |
59 | * **`writeFile` Method:**
60 | * **Logic:**
61 | 1. Perform necessary parent directory and target path type checks (querying `chunk_index = 0` for relevant paths). Handle `ENOENT`, `ENOTDIR`, `EISDIR`.
62 | 2. Convert the input data to a Buffer and calculate its `total_size`.
63 | 3. **Delete Phase (Non-atomic):** Execute `DELETE FROM file_chunks WHERE path = ?` to remove any existing chunks/metadata for this path.
64 | 4. **Insert Phase (Non-atomic):**
65 | * Slice the data Buffer into chunks based on `CHUNK_SIZE`.
66 | * Loop through the chunks, maintaining a `chunk_index` (starting at 0).
67 | * For each chunk, execute an `INSERT INTO file_chunks (...)` statement, providing the `path`, current `chunk_index`, `type='file'`, the chunk's `content` BLOB, `mode`, `mtime`, and the calculated `total_size`.
68 | * **Error Handling:** Handle database errors during delete or insert phases. Note that failures during the insert phase might leave partial data.
69 |
70 | * **`mkdir` Method:**
71 | * **Logic:**
72 | 1. Perform existence and parent directory checks (querying `chunk_index = 0`). Handle `EEXIST`, `ENOENT`, `ENOTDIR`.
73 | 2. Execute a single `INSERT INTO file_chunks (...)` statement to create the metadata row: `path = ?`, `chunk_index = 0`, `type = 'directory'`, `content = NULL`, `mode = ?`, `mtime = ?`, `total_size = 0`.
74 | * **Error Handling:** Handle potential constraint errors during insert (e.g., `EEXIST`).
75 |
76 | * **`unlink` Method:**
77 | * **Logic:**
78 | 1. Check existence and type by querying `chunk_index = 0`. Handle `ENOENT`. Throw `EPERM` or `EISDIR` if it's a directory.
79 | 2. If it's a file or symlink, execute `DELETE FROM file_chunks WHERE path = ?` to remove all associated rows.
80 | * **Error Handling:** Handle database errors.
81 |
82 | * **`rmdir` Method:**
83 | * **Logic:**
84 | 1. Check existence and type (`type = 'directory'`) by querying `chunk_index = 0`. Handle `ENOENT`, `ENOTDIR`.
85 | 2. Check for emptiness by querying if any *other* rows exist with the target path as a prefix: `SELECT 1 FROM file_chunks WHERE path LIKE ? AND path != ? LIMIT 1` (binding `targetPath/%` and `targetPath`). If this query returns a row, throw `ENOTEMPTY`.
86 | 3. If empty, execute `DELETE FROM file_chunks WHERE path = ? AND chunk_index = 0` to remove only the directory's metadata row.
87 | * **Error Handling:** Handle database errors.
88 |
89 | **6. Utility Function Modifications:**
90 |
91 | * **`createStats` (or similar):**
92 | * **Action:** Modify the utility function that generates `Stats` objects.
93 | * **Logic:** Ensure it accepts the `total_size` value from the database row and uses it directly for the `Stats.size` property, rather than calculating size based on the `content` BLOB (which is now just a chunk for large files). Update related type definitions (like `DbFileRow`) if used.
94 |
95 | **7. Testing:**
96 |
97 | * **Action:** Add new unit tests and update existing ones in `tests/sqlite-fs-adapter.test.ts`.
98 | * **Focus:**
99 | * Verify `writeFile` correctly creates single (`chunk_index = 0`) rows for small files.
100 | * Verify `writeFile` correctly creates multiple, ordered chunk rows for large files (> `CHUNK_SIZE`).
101 | * Verify `readFile` correctly reconstructs both small and large files from the `file_chunks` table.
102 | * Verify `lstat`/`stat` report the correct `total_size` for both small and large files.
103 | * Verify `unlink` removes *all* chunks for a file.
104 | * Verify `rmdir` emptiness check works correctly with the new schema.
105 | * Ensure all existing tests for directories, errors (`ENOENT`, `EEXIST`, etc.) still pass with the new schema interaction.
106 |
--------------------------------------------------------------------------------
/docs/steps/step_3.md:
--------------------------------------------------------------------------------
1 | **Brief: Step 3 - Define Schema & Implement Utilities**
2 |
3 | **Goal:** Define the SQLite table schema for storing filesystem data. Implement and potentially test utility functions for path manipulation, error creation, and `Stats` object generation.
4 |
5 | **Tasks:**
6 |
7 | 1. **Define Filesystem Table Schema (`src/schema.ts`):**
8 | * **Action:** Create `src/schema.ts`.
9 | * **Content:** Define the SQL `CREATE TABLE` statement as an exported constant string.
10 | ```typescript
11 | // src/schema.ts
12 | export const SQL_SCHEMA = `
13 | CREATE TABLE IF NOT EXISTS files (
14 | path TEXT PRIMARY KEY NOT NULL, -- Full virtual path relative to adapter root
15 | type TEXT NOT NULL CHECK(type IN ('file', 'directory', 'symlink')), -- Type constraint
16 | content BLOB, -- File content, symlink target, or NULL for directory
17 | mode INTEGER NOT NULL, -- Numeric file mode (e.g., 0o100644, 0o40000)
18 | mtime TEXT NOT NULL -- ISO8601 timestamp string (e.g., using DATETIME('now'))
19 | );
20 | `;
21 |
22 | ```
23 | * *Rationale:* Centralizes the database schema definition required by the `SQLiteFSAdapter`. Adding `CHECK` constraints improves data integrity.
24 |
25 | 2. **Implement Path Utilities (`src/path-utils.ts`):**
26 | * **Action:** Create `src/path-utils.ts`.
27 | * **Content:** Implement helper functions for path manipulation. Using Node's built-in `path` module (available in Bun) is recommended for robustness.
28 | ```typescript
29 | // src/path-utils.ts
30 | import path from 'node:path'; // Use Node's path module via Bun
31 |
32 | // Re-export necessary functions or create simple wrappers if needed
33 | export const dirname = path.dirname;
34 | export const basename = path.basename;
35 | export const join = path.join;
36 | export const normalize = path.normalize; // Useful for handling '.' and '..'
37 |
38 | // Example custom helper if needed:
39 | // export function getParentPath(p: string): string {
40 | // const parent = path.dirname(p);
41 | // return parent === p ? '' : parent; // Handle root case
42 | // }
43 | ```
44 | * *Rationale:* Provides consistent and reliable path manipulation needed for translating filesystem paths to database keys and querying parent/child relationships.
45 |
46 | 3. **Implement Error Utilities (`src/error-utils.ts`):**
47 | * **Action:** Create `src/error-utils.ts`.
48 | * **Content:** Implement a factory function to create standardized filesystem error objects.
49 | ```typescript
50 | // src/error-utils.ts
51 | import type { FSError } from './types';
52 |
53 | /**
54 | * Creates an error object mimicking Node.js filesystem errors.
55 | */
56 | export function createError(
57 | code: string,
58 | path?: string,
59 | syscall?: string,
60 | message?: string
61 | ): FSError {
62 | const displayPath = path ? `'${path}'` : '';
63 | const displaySyscall = syscall ? ` ${syscall}` : '';
64 | const baseMessage = message || `${code}:${displaySyscall}${displayPath}`;
65 |
66 | const error = new Error(baseMessage) as FSError;
67 | error.code = code;
68 | if (path) error.path = path;
69 | if (syscall) error.syscall = syscall;
70 |
71 | // Could potentially add errno mapping here if needed, but code is primary identifier
72 | return error;
73 | }
74 | ```
75 | * *Rationale:* Ensures errors thrown by the `SQLiteFSAdapter` have the expected `code` property, which libraries like `isomorphic-git` often check.
76 |
77 | 4. **Implement Stats Utilities (`src/stats-utils.ts`):**
78 | * **Action:** Create `src/stats-utils.ts`.
79 | * **Content:** Implement a factory function to create `Stats` objects from database rows.
80 | ```typescript
81 | // src/stats-utils.ts
82 | import type { Stats } from './types';
83 |
84 | // Define the expected shape of the input row from the DB
85 | export interface DbFileRow {
86 | type: 'file' | 'directory' | 'symlink';
87 | mode: number;
88 | mtime: string; // ISO8601 string
89 | content: Buffer | Uint8Array | null; // Assuming BLOB is retrieved as Buffer/Uint8Array
90 | }
91 |
92 | /**
93 | * Creates a Stats object from a database row.
94 | */
95 | export function createStats(row: DbFileRow): Stats {
96 | const mtimeMs = Date.parse(row.mtime);
97 | // Ensure size is calculated correctly (content length or 0 for dirs)
98 | const size = row.type === 'directory' ? 0 : (row.content?.length ?? 0);
99 |
100 | // Create the base object matching the Stats interface
101 | const stats: Stats = {
102 | isFile: () => row.type === 'file',
103 | isDirectory: () => row.type === 'directory',
104 | isSymbolicLink: () => row.type === 'symlink',
105 | mode: row.mode,
106 | size: size,
107 | mtimeMs: mtimeMs,
108 | // Provide sensible defaults for other common Stats fields
109 | atimeMs: mtimeMs, // Use mtime for atime
110 | ctimeMs: mtimeMs, // Use mtime for ctime (metadata change time)
111 | birthtimeMs: mtimeMs, // Use mtime for birthtime
112 | dev: 0,
113 | ino: 0, // Inode numbers don't really apply
114 | nlink: 1, // Typically 1 link unless hard links were simulated
115 | uid: 0, // Default user/group IDs
116 | gid: 0,
117 | rdev: 0,
118 | blksize: 4096, // Common block size default
119 | blocks: Math.ceil(size / 512), // Estimate blocks based on size (512b blocks)
120 | };
121 |
122 | // Optional: Add Date getters like Node's Stats object for convenience,
123 | // though isomorphic-git likely uses the Ms properties primarily.
124 | // Object.defineProperties(stats, {
125 | // mtime: { get: () => new Date(stats.mtimeMs) },
126 | // atime: { get: () => new Date(stats.atimeMs!) },
127 | // ctime: { get: () => new Date(stats.ctimeMs!) },
128 | // birthtime: { get: () => new Date(stats.birthtimeMs!) },
129 | // });
130 |
131 | return stats;
132 | }
133 | ```
134 | * *Rationale:* Provides a consistent way to generate the `Stats` objects required by `isomorphic-git`'s `lstat`/`stat` calls, translating database information into the expected format.
135 |
136 | 5. **Testing Utilities:**
137 | * **Action:** Create corresponding test files (e.g., `tests/path-utils.test.ts`, `tests/error-utils.test.ts`, `tests/stats-utils.test.ts`).
138 | * **Content:** Write simple unit tests to verify the behavior of these utilities, especially `createStats` (ensure correct boolean methods, size calculation, timestamps) and `createError` (ensure correct properties are set). Test edge cases for path utils if not relying solely on `node:path`.
139 | * *Rationale:* Catches regressions or errors in these fundamental helper functions early.
140 |
141 | 6. **Export Utilities (Update `src/index.ts`):**
142 | * **Action:** Update `src/index.ts` to export any utilities needed externally (likely none for this step, as they are primarily internal helpers for `SQLiteFSAdapter`). Keep exports minimal.
143 | ```typescript
144 | // src/index.ts
145 | export * from './interfaces';
146 | export * from './types';
147 | export { BunSqliteAdapter } from './bun-sqlite-adapter';
148 | // export { SQL_SCHEMA } from './schema'; // Only if needed externally
149 | // Utilities are likely internal, no need to export yet
150 | // Add SQLiteFSAdapter export later
151 | ```
152 |
153 | 7. **Verify:**
154 | * Run `bun test` to execute any utility tests created.
155 | * Run `bun run typecheck` to ensure all files parse correctly.
156 | * Commit the schema definition and utility implementations/tests.
157 |
158 | **Outcome:** The project now has the defined database schema and the necessary helper functions (paths, errors, stats generation) ready to be used by the `SQLiteFSAdapter` implementation in the next steps.
159 |
--------------------------------------------------------------------------------
/docs/steps/step_5.md:
--------------------------------------------------------------------------------
1 | **Brief: Step 5 - Implement `SQLiteFSAdapter` - Core Write Methods & Tests**
2 |
3 | **Goal:** Implement the core write methods (`mkdir`, `writeFile`, `unlink`, `rmdir`) of the `SQLiteFSAdapter` class. These methods will modify the state of the virtual filesystem stored in the SQLite `files` table. Write tests using Vitest to verify the correctness of these methods, including side effects and
4 | error handling.
5 |
6 | **Prerequisites:**
7 | * Steps 2-4 are complete. The `SQLiteFSAdapter` class exists with working read methods and test setup (`tests/sqlite-fs-adapter.test.ts`).
8 |
9 | **Process (Iterative for each method):**
10 |
11 | 1. **Setup (Verify/Extend):**
12 | * Ensure the `tests/sqlite-fs-adapter.test.ts` file's `beforeEach` creates a fresh in-memory `SQLiteFSAdapter` instance for each test.
13 | * You might want helper functions within your test file to easily check the state of the DB after a write operation (e.g., `async function expectPathToExist(path, type)` or `async function expectPathToNotExist(path)` using `fs.lstat`).
14 |
15 | 2. **Implement `mkdir` (Non-Recursive):**
16 | * **Test:** In `tests/sqlite-fs-adapter.test.ts`, write tests for `mkdir`:
17 | * `it('mkdir: should create a new directory', async () => { await fs.mkdir('newDir'); const stats = await fs.lstat('newDir'); expect(stats.isDirectory()).toBe(true); });`
18 | * `it('mkdir: should set default mode on new directory', async () => { await fs.mkdir('newDir'); const stats = await fs.lstat('newDir'); expect(stats.mode & 0o777).toBe(0o755); /* Or your chosen default */ });`
19 | * `it('mkdir: should allow specifying mode', async () => { await fs.mkdir('newDirMode', { mode: 0o700 }); const stats = await fs.lstat('newDirMode'); expect(stats.mode & 0o777).toBe(0o700); });`
20 | * `it('mkdir: should throw EEXIST if path already exists (file)', async () => { /* setup file.txt */ await expect(fs.mkdir('file.txt')).rejects.toThrowError(/EEXIST/); });`
21 | * `it('mkdir: should throw EEXIST if path already exists (directory)', async () => { /* setup dir */ await expect(fs.mkdir('dir')).rejects.toThrowError(/EEXIST/); });`
22 | * `it('mkdir: should throw ENOENT if parent directory does not exist', async () => { await expect(fs.mkdir('nonexistent/newDir')).rejects.toThrowError(/ENOENT/); });`
23 | * `it('mkdir: should throw ENOTDIR if parent path is a file', async () => { /* setup file.txt */ await expect(fs.mkdir('file.txt/newDir')).rejects.toThrowError(/ENOTDIR/); });`
24 | * (Defer recursive tests unless implementing now).
25 | * **Implement:** Write the `async mkdir` method in `SQLiteFSAdapter`.
26 | * Check if the path already exists using `this.db.one()`. If it does, throw `EEXIST`. Catch the "No rows found" error and continue.
27 | * Determine the parent path using `dirname`.
28 | * If the parent is not root (`.` or `/`), check if the parent exists and is a directory using `this.db.one()`. Throw `ENOENT` or `ENOTDIR` if checks fail.
29 | * Use `this.db.exec()` to `INSERT` a new row into `files` with `type='directory'`, the specified or default `mode`, a current `mtime`, and `content=NULL`.
30 | * Use `try...catch` around DB calls, translating errors (`UNIQUE constraint` -> `EEXIST`, others -> `EIO`).
31 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `mkdir` tests pass.
32 |
33 | 3. **Implement `writeFile`:**
34 | * **Test:** Write tests for `writeFile`:
35 | * `it('writeFile: should create a new file with Buffer data', async () => { const data = Buffer.from('hello'); await fs.writeFile('newFile.txt', data); const content = await fs.readFile('newFile.txt'); expect(content).toEqual(data); const stats = await fs.lstat('newFile.txt');
36 | expect(stats.isFile()).toBe(true); expect(stats.size).toBe(data.length); });`
37 | * `it('writeFile: should create a new file with string data', async () => { const data = 'world'; await fs.writeFile('newFile2.txt', data); const content = await fs.readFile('newFile2.txt', { encoding: 'utf8' }); expect(content).toBe(data); });`
38 | * `it('writeFile: should overwrite an existing file', async () => { /* setup file.txt */ const newData = 'overwrite'; await fs.writeFile('file.txt', newData); const content = await fs.readFile('file.txt', { encoding: 'utf8' }); expect(content).toBe(newData); });`
39 | * `it('writeFile: should set default mode on new file', async () => { await fs.writeFile('newFileMode.txt', 'data'); const stats = await fs.lstat('newFileMode.txt'); expect(stats.mode & 0o777).toBe(0o644); /* Or your chosen default */ });`
40 | * `it('writeFile: should allow specifying mode', async () => { await fs.writeFile('newFileMode2.txt', 'data', { mode: 0o600 }); const stats = await fs.lstat('newFileMode2.txt'); expect(stats.mode & 0o777).toBe(0o600); });`
41 | * `it('writeFile: should throw ENOENT if parent directory does not exist', async () => { await expect(fs.writeFile('nonexistent/newFile.txt', 'data')).rejects.toThrowError(/ENOENT/); });`
42 | * `it('writeFile: should throw ENOTDIR if parent path is a file', async () => { /* setup file.txt */ await expect(fs.writeFile('file.txt/newFile.txt', 'data')).rejects.toThrowError(/ENOTDIR/); });`
43 | * `it('writeFile: should throw EISDIR if path is an existing directory', async () => { /* setup dir */ await expect(fs.writeFile('dir', 'data')).rejects.toThrowError(/EISDIR/); });`
44 | * **Implement:** Write the `async writeFile` method.
45 | * Determine parent path. Check if parent exists and is a directory (similar to `mkdir`), throwing `ENOENT` or `ENOTDIR` if needed.
46 | * Use `this.db.exec()` with `INSERT OR REPLACE INTO files (...) VALUES (?, 'file', ?, ?, ?)` to write the data. This handles both creation and overwrite atomically at the DB level. Ensure data is passed as a Buffer.
47 | * Before the `INSERT OR REPLACE`, you *could* fetch the existing entry to check if it's a directory and throw `EISDIR`, although `INSERT OR REPLACE` might just overwrite it. It's safer to check first: `SELECT type FROM files WHERE path = ?`. If it exists and is a directory, throw `EISDIR`.
48 | * Use `try...catch` for DB errors -> `EIO`.
49 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `writeFile` tests pass.
50 |
51 | 4. **Implement `unlink`:**
52 | * **Test:** Write tests for `unlink`:
53 | * `it('unlink: should delete an existing file', async () => { /* setup file.txt */ await fs.unlink('file.txt'); await expect(fs.lstat('file.txt')).rejects.toThrowError(/ENOENT/); });`
54 | * `it('unlink: should throw ENOENT for a non-existent path', async () => { await expect(fs.unlink('nonexistent')).rejects.toThrowError(/ENOENT/); });`
55 | * `it('unlink: should throw EPERM or EISDIR when trying to unlink a directory', async () => { /* setup dir */ await expect(fs.unlink('dir')).rejects.toThrowError(/EPERM|EISDIR/); });` // Check Node.js behavior for specific code
56 | * **Implement:** Write the `async unlink` method.
57 | * Use `this.db.one()` to check if the path exists and get its `type`. Handle "No rows found" -> `ENOENT`.
58 | * If `type` is 'directory', throw `createError('EPERM', path, 'unlink')` (or `EISDIR`).
59 | * If it's a file (or symlink), use `this.db.exec('DELETE FROM files WHERE path = ?', [dbPath])`.
60 | * Use `try...catch` for DB errors -> `EIO`.
61 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `unlink` tests pass.
62 |
63 | 5. **Implement `rmdir`:**
64 | * **Test:** Write tests for `rmdir`:
65 | * `it('rmdir: should delete an existing empty directory', async () => { /* setup emptyDir */ await fs.rmdir('emptyDir'); await expect(fs.lstat('emptyDir')).rejects.toThrowError(/ENOENT/); });`
66 | * `it('rmdir: should throw ENOENT for a non-existent path', async () => { await expect(fs.rmdir('nonexistent')).rejects.toThrowError(/ENOENT/); });`
67 | * `it('rmdir: should throw ENOTDIR for a file path', async () => { /* setup file.txt */ await expect(fs.rmdir('file.txt')).rejects.toThrowError(/ENOTDIR/); });`
68 | * `it('rmdir: should throw ENOTEMPTY for a non-empty directory', async () => { /* setup dir with nested.txt */ await expect(fs.rmdir('dir')).rejects.toThrowError(/ENOTEMPTY/); });`
69 | * **Implement:** Write the `async rmdir` method.
70 | * Use `this.db.one()` to check if the path exists and is a directory. Handle `ENOENT` and `ENOTDIR`.
71 | * Perform a second query to check for children: `SELECT path FROM files WHERE path LIKE ? LIMIT 1` (binding `dir/%`). Use `this.db.one()` for this. If it succeeds (finds a child), throw `ENOTEMPTY`. Catch the "No rows found" error and continue (means directory is empty).
72 | * If empty, use `this.db.exec('DELETE FROM files WHERE path = ?', [dbPath])`.
73 | * Use `try...catch` for DB errors -> `EIO`.
74 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `rmdir` tests pass.
75 |
76 | 6. **Final Review & Export:**
77 | * Run all tests together: `bun test`. Ensure everything passes.
78 | * Review the implemented write methods in `SQLiteFSAdapter` for clarity, correctness, and error handling.
79 | * Ensure `SQLiteFSAdapter` is exported from `src/index.ts`.
80 | * Commit the completed write method implementations and tests.
81 |
--------------------------------------------------------------------------------
/docs/steps/step_4.md:
--------------------------------------------------------------------------------
1 |
2 | **Brief: Step 4 - Implement `SQLiteFSAdapter` - Core Read Methods & Tests**
3 |
4 | **Goal:** Implement the core read-only methods (`lstat`, `stat`, `readFile`, `readdir`) of the `SQLiteFSAdapter` class. These methods will use the `SyncSqliteDatabase` interface (via the `BunSqliteAdapter` instance) and the utilities created in Step 4. Write tests using Vitest to verify the correctness of these
5 | methods against the `fs.promises` API contract.
6 |
7 | **Prerequisites:**
8 | * Step 2 (Implement & Test `BunSqliteAdapter`) is complete.
9 | * Step 4 (Schema & Utilities) is complete.
10 |
11 | **Tasks:**
12 |
13 | 1. **Setup `SQLiteFSAdapter` Class and Test File:**
14 | * **Action:** Create `src/sqlite-fs-adapter.ts`.
15 | * **Content:** Define the basic `SQLiteFSAdapter` class structure.
16 | * Import `SyncSqliteDatabase` interface, `BunSqliteAdapter` (or just the interface), utility functions (`createError`, `createStats`, `getDbPath` helper if needed, path utils), schema constant (`SQL_SCHEMA`), and types (`Stats`, `FSError`).
17 | * The constructor should accept an instance of `SyncSqliteDatabase` and optionally a `rootDir` string. Store the database instance.
18 | * Consider adding an `initialize()` method (or call it in the constructor) that executes the `SQL_SCHEMA` using `db.exec()` to ensure the `files` table exists. Handle potential errors if the table already exists gracefully (`CREATE TABLE IF NOT EXISTS`).
19 | * Stub out the read methods (`lstat`, `stat`, `readFile`, `readdir`) to throw `new Error('Not implemented')` initially.
20 | ```typescript
21 | // src/sqlite-fs-adapter.ts (Initial Stub)
22 | import type { SyncSqliteDatabase } from './interfaces';
23 | import type { Stats, FSError } from './types';
24 | import { SQL_SCHEMA } from './schema';
25 | import { createError } from './error-utils';
26 | import { createStats, type DbFileRow } from './stats-utils';
27 | import { dirname, basename, join, normalize } from './path-utils'; // Import path utils
28 |
29 | export class SQLiteFSAdapter {
30 | private db: SyncSqliteDatabase;
31 | private rootDir: string; // Track root directory if needed
32 |
33 | constructor(db: SyncSqliteDatabase, rootDir: string = '.') {
34 | this.db = db;
35 | this.rootDir = rootDir; // Normalize if needed
36 | // Ensure schema exists
37 | try {
38 | this.db.exec(SQL_SCHEMA);
39 | } catch (e) {
40 | console.error("Failed to initialize SQLiteFSAdapter schema", e);
41 | // Decide if this should be fatal or logged
42 | }
43 | }
44 |
45 | // Placeholder for path mapping if rootDir is used
46 | private getDbPath(fsPath: string): string {
47 | // Implement mapping based on rootDir later if needed
48 | return normalize(fsPath);
49 | }
50 |
51 | // --- Read Methods (Stubs) ---
52 | async lstat(path: string): Promise { throw new Error('Not implemented: lstat'); }
53 | async stat(path: string): Promise { throw new Error('Not implemented: stat'); }
54 | async readFile(path: string, options?: { encoding?: string }): Promise { throw new Error('Not implemented: readFile'); }
55 | async readdir(path: string): Promise { throw new Error('Not implemented: readdir'); }
56 |
57 | // --- Write Methods (Stubs for later) ---
58 | // async writeFile(...) { throw new Error('Not implemented'); }
59 | // async mkdir(...) { throw new Error('Not implemented'); }
60 | // async unlink(...) { throw new Error('Not implemented'); }
61 | // async rmdir(...) { throw new Error('Not implemented'); }
62 | }
63 | ```
64 | * **Action:** Create `tests/sqlite-fs-adapter.test.ts`.
65 | * **Content:** Import `describe`, `it`, `expect`, `beforeEach`, `afterEach` from `vitest`. Import `BunSqliteAdapter` and `SQLiteFSAdapter`.
66 | * Set up `beforeEach`:
67 | * Create a new in-memory `BunSqliteAdapter` instance (`dbAdapter = new BunSqliteAdapter()`).
68 | * Create a new `SQLiteFSAdapter` instance using the `dbAdapter` (`fs = new SQLiteFSAdapter(dbAdapter)`).
69 | * Use `dbAdapter.exec()` *directly* within `beforeEach` or specific tests to insert initial filesystem entries into the `files` table for testing read operations (e.g., insert rows representing `/file.txt`, `/dir`, `/dir/nested.txt`). Remember to include `type`, `content` (as Buffer for files), `mode`,
70 | and `mtime`.
71 | * Set up `afterEach` to call `dbAdapter.close()`.
72 |
73 | 2. **Implement `lstat`:**
74 | * **Test:** In `tests/sqlite-fs-adapter.test.ts`, write tests for `lstat`:
75 | * `it('lstat: should return Stats for an existing file', async () => { ... });` (Check `stats.isFile()`, `stats.size`, `stats.mode`, `stats.mtimeMs`).
76 | * `it('lstat: should return Stats for an existing directory', async () => { ... });` (Check `stats.isDirectory()`, `stats.size === 0`).
77 | * `it('lstat: should throw ENOENT for a non-existent path', async () => { await expect(fs.lstat('nonexistent')).rejects.toThrowError(/ENOENT/); });`
78 | * (Add tests for symlinks later if implementing `symlink`).
79 | * **Implement:** Write the `async lstat` method in `SQLiteFSAdapter`.
80 | * Use `this.db.one()` to query the `files` table for the given `path`.
81 | * Use `try...catch` around the DB call.
82 | * If `one()` throws a "No rows found" error (use `isNotFoundError` helper), catch it and throw `createError('ENOENT', path, 'lstat')`.
83 | * If successful, pass the retrieved row (`type`, `mode`, `mtime`, `content`) to `createStats()` utility.
84 | * Return the created `Stats` object.
85 | * Handle other potential DB errors by throwing `createError('EIO', path, 'lstat')`.
86 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `lstat` tests pass.
87 |
88 | 3. **Implement `stat`:**
89 | * **Test:** Write basic tests for `stat`. Since `stat` and `lstat` behave identically in this adapter (no symlink following), the tests will be very similar to `lstat`.
90 | * `it('stat: should return Stats for an existing file', async () => { ... });`
91 | * `it('stat: should throw ENOENT for a non-existent path', async () => { ... });`
92 | * **Implement:** Implement `async stat` simply by calling and returning `this.lstat(path)`.
93 | ```typescript
94 | async stat(path: string): Promise {
95 | // For this adapter, stat behaves identically to lstat
96 | return this.lstat(path);
97 | }
98 | ```
99 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `stat` tests pass.
100 |
101 | 4. **Implement `readFile`:**
102 | * **Test:** Write tests for `readFile`:
103 | * `it('readFile: should return content Buffer for an existing file', async () => { const content = await fs.readFile('file.txt'); expect(content).toBeInstanceOf(Buffer); expect(content.toString()).toBe('...'); });`
104 | * `it('readFile: should return content string for an existing file with encoding option', async () => { const content = await fs.readFile('file.txt', { encoding: 'utf8' }); expect(typeof content).toBe('string'); expect(content).toBe('...'); });`
105 | * `it('readFile: should throw ENOENT for a non-existent path', async () => { await expect(fs.readFile('nonexistent')).rejects.toThrowError(/ENOENT/); });`
106 | * `it('readFile: should throw EISDIR for a directory path', async () => { await expect(fs.readFile('dir')).rejects.toThrowError(/EISDIR/); });`
107 | * **Implement:** Write the `async readFile` method.
108 | * Use `this.db.one()` to query `content` and `type` for the `path`.
109 | * Use `try...catch`. Handle "No rows found" -> `ENOENT`.
110 | * Check if `type` is 'file'. If not (e.g., 'directory'), throw `createError('EISDIR', path, 'readFile')`.
111 | * Ensure the retrieved `content` (likely a `Uint8Array` or `Buffer` from `bun:sqlite`) is converted to a `Buffer`.
112 | * If `options.encoding` is provided, return `buffer.toString(options.encoding)`. Otherwise, return the `Buffer`.
113 | * Handle other DB errors -> `EIO`.
114 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `readFile` tests pass.
115 |
116 | 5. **Implement `readdir`:**
117 | * **Test:** Write tests for `readdir`:
118 | * `it('readdir: should return names of entries in a directory', async () => { const names = await fs.readdir('dir'); expect(names).toEqual(expect.arrayContaining(['nested.txt', /* other entries */])); expect(names.length).toBe(/* correct number */); });`
119 | * `it('readdir: should return empty array for an empty directory', async () => { /* setup empty dir */ const names = await fs.readdir('emptyDir'); expect(names).toEqual([]); });`
120 | * `it('readdir: should throw ENOENT for a non-existent path', async () => { await expect(fs.readdir('nonexistent')).rejects.toThrowError(/ENOENT/); });`
121 | * `it('readdir: should throw ENOTDIR for a file path', async () => { await expect(fs.readdir('file.txt')).rejects.toThrowError(/ENOTDIR/); });`
122 | * `it('readdir: should handle root directory (.) correctly', async () => { const names = await fs.readdir('.'); expect(names).toEqual(expect.arrayContaining(['file.txt', 'dir'])); });` (Adjust based on test setup).
123 | * **Implement:** Write the `async readdir` method.
124 | * First, check if the `path` exists and is a directory (unless it's the root `.`). Use `this.db.one()` to get the `type`. Handle `ENOENT` and `ENOTDIR` errors appropriately based on the check result.
125 | * Construct the SQL `LIKE` query to find immediate children (e.g., `SELECT path FROM files WHERE path LIKE ? AND path NOT LIKE ?`, binding `dir/%` and `dir/%/%`). Handle the root directory case (`.` or `/`) correctly in the patterns.
126 | * Use `this.db.all()` to get the full paths of children.
127 | * Map the results to extract the `basename` of each child path using the path utility.
128 | * Return the array of basenames.
129 | * Use `try...catch` for DB errors -> `EIO`.
130 | * **Run:** `bun test tests/sqlite-fs-adapter.test.ts` until `readdir` tests pass.
131 |
132 | 6. **Final Review & Export:**
133 | * Run all tests together: `bun test`. Ensure everything passes.
134 | * Review the implemented read methods in `SQLiteFSAdapter` for clarity and correctness.
135 | * Ensure `SQLiteFSAdapter` is exported from `src/index.ts`.
136 | * Commit the adapter implementation and tests for the read methods.
137 |
138 | **Outcome:** The `SQLiteFSAdapter` now has functional, tested implementations for the core read operations (`lstat`, `stat`, `readFile`, `readdir`), laying the groundwork for using `isomorphic-git`'s read functionalities. The project is ready for Step 6 (Implement Write Methods).
139 |
--------------------------------------------------------------------------------
/docs/steps/step_6.md:
--------------------------------------------------------------------------------
1 | **Development Instructions for `git-sqlite-cli`**
2 |
3 | **Goal:** Implement the `clone` and `ls-tree` commands for the CLI tool, integrating `sqlite-fs-library` with `isomorphic-git`.
4 |
5 | **Prerequisites:**
6 | * Project directory (`git-sqlite-cli`) initialized with Bun.
7 | * Dependencies installed: `isomorphic-git`, `sqlite-fs-library` (linked), `minimist`, `chalk`, `typescript`, `@types/node`, `@types/minimist`.
8 | * Basic `tsconfig.json` exists.
9 |
10 | **Steps:**
11 |
12 | **1. Create the Main CLI Entry Point (`src/cli.ts`)**
13 |
14 | * **Action:** Create the file `src/cli.ts`.
15 | * **Content:** Paste the following code. This sets up argument parsing using `minimist` and routes to command handlers.
16 | ```typescript
17 | // src/cli.ts
18 | import parseArgs from 'minimist';
19 | import chalk from 'chalk'; // Optional: for colored output
20 | import { cloneCommand } from './commands/clone';
21 | import { lsTreeCommand } from './commands/ls-tree';
22 |
23 | async function main() {
24 | // Parse arguments, skipping 'bun' and the script path itself
25 | const args = parseArgs(Bun.argv.slice(2));
26 | const command = args._[0];
27 |
28 | // console.log('Args:', args); // Uncomment for debugging args
29 |
30 | switch (command) {
31 | case 'clone':
32 | if (args._.length < 3) {
33 | console.error(chalk.red('Usage: git-sqlite clone '));
34 | process.exit(1);
35 | }
36 | // Pass repository URL and DB file path to the command handler
37 | await cloneCommand(args._[1], args._[2], args);
38 | break;
39 |
40 | case 'ls-tree':
41 | if (args._.length < 2) {
42 | console.error(chalk.red('Usage: git-sqlite ls-tree [ref]'));
43 | process.exit(1);
44 | }
45 | // Default ref to 'HEAD' if not provided
46 | const ref = args._[2] || 'HEAD';
47 | // Pass DB file path and ref to the command handler
48 | await lsTreeCommand(args._[1], ref, args);
49 | break;
50 |
51 | default:
52 | console.error(chalk.red(`Unknown command: ${command || 'No command specified'}`));
53 | console.error('Available commands: clone, ls-tree');
54 | process.exit(1);
55 | }
56 | }
57 |
58 | // Execute main and handle top-level errors
59 | main().catch(err => {
60 | console.error(chalk.redBright('Error:'), err.message);
61 | // console.error(err.stack); // Uncomment for detailed stack trace
62 | process.exit(1);
63 | });
64 | ```
65 |
66 | **2. Create the `clone` Command Handler (`src/commands/clone.ts`)**
67 |
68 | * **Action:** Create the directory `src/commands` and the file `src/commands/clone.ts`.
69 | * **Content:** Implement the logic to clone a repository into the SQLite DB. **Note the change:** We now create the `bun:sqlite` `Database` instance
70 | first and pass it to `BunSqliteAdapter`.
71 | ```typescript
72 | // src/commands/clone.ts
73 | import { BunSqliteAdapter, SQLiteFSAdapter } from 'sqlite-fs-library';
74 | import git from 'isomorphic-git';
75 | import http from 'isomorphic-git/http/node'; // Using Node's HTTP client via Bun
76 | import path from 'node:path';
77 | import fs from 'node:fs'; // Using Node's fs for directory/file checks via Bun
78 | import { Database } from 'bun:sqlite'; // Import Bun's Database
79 | import chalk from 'chalk';
80 | import type minimist from 'minimist';
81 |
82 | export async function cloneCommand(repoUrl: string, dbFilePath: string, options: minimist.ParsedArgs): Promise {
83 | console.log(chalk.blue(`Cloning ${chalk.bold(repoUrl)} into ${chalk.bold(dbFilePath)}...`));
84 |
85 | let db: Database | null = null; // Keep track of DB instance for finally block
86 | let dbAdapter: BunSqliteAdapter | null = null;
87 |
88 | try {
89 | // 1. Ensure parent directory for the database file exists
90 | const dbDir = path.dirname(dbFilePath);
91 | fs.mkdirSync(dbDir, { recursive: true });
92 |
93 | // 2. Create the bun:sqlite Database instance
94 | // Use { create: true } to ensure the file is created if it doesn't exist
95 | db = new Database(dbFilePath, { create: true });
96 | // Optional: Enable WAL mode for potentially better performance on file DBs
97 | db.exec("PRAGMA journal_mode = WAL;");
98 |
99 | // 3. Instantiate BunSqliteAdapter with the Database instance
100 | dbAdapter = new BunSqliteAdapter(db);
101 |
102 | // 4. Instantiate SQLiteFSAdapter with the BunSqliteAdapter
103 | const fsAdapter = new SQLiteFSAdapter(dbAdapter);
104 | // The adapter's constructor should handle schema initialization (CREATE TABLE IF NOT EXISTS)
105 |
106 | // 5. Define progress/message handlers for isomorphic-git
107 | const onMessage = (message: string) => {
108 | // Clean up potential trailing newlines from isomorphic-git messages
109 | process.stdout.write(message.replace(/(\r\n|\n|\r)$/, '') + '\r');
110 | };
111 | const onProgress = (progress: any) => {
112 | // Example: Log progress stage and loaded/total info if available
113 | if (progress.phase && progress.loaded !== undefined && progress.total !== undefined) {
114 | process.stdout.write(`Phase: ${progress.phase}, Progress: ${progress.loaded}/${progress.total} \r`);
115 | } else if (progress.phase) {
116 | process.stdout.write(`Phase: ${progress.phase} \r`);
117 | }
118 | };
119 |
120 | // 6. Execute the clone operation
121 | await git.clone({
122 | fs: fsAdapter,
123 | http,
124 | dir: '.', // Root directory within the virtual filesystem
125 | url: repoUrl,
126 | // ref: 'main', // Optional: Specify a branch if needed
127 | singleBranch: true, // Recommended for faster clones
128 | depth: 10, // Optional: Limit history depth (adjust as needed)
129 | onMessage,
130 | onProgress,
131 | // corsProxy: '...', // Add if required for specific environments
132 | });
133 |
134 | // Clear progress line after completion
135 | process.stdout.write('\n');
136 | console.log(chalk.green('Clone completed successfully.'));
137 |
138 | } catch (error: any) {
139 | process.stdout.write('\n'); // Ensure newline after potential progress messages
140 | // Re-throw the error to be caught by the main handler in cli.ts
141 | throw new Error(`Clone failed: ${error.message}`);
142 | } finally {
143 | // 7. Ensure the database connection is closed
144 | if (dbAdapter) {
145 | // The adapter's close method should call the underlying db.close()
146 | dbAdapter.close?.();
147 | // console.log(chalk.gray('Database connection closed.'));
148 | } else if (db) {
149 | // Fallback if adapter wasn't created but db was
150 | db.close();
151 | // console.log(chalk.gray('Database connection closed (fallback).'));
152 | }
153 | }
154 | }
155 | ```
156 |
157 | **3. Create the `ls-tree` Command Handler (`src/commands/ls-tree.ts`)**
158 |
159 | * **Action:** Create the file `src/commands/ls-tree.ts`.
160 | * **Content:** Implement the logic to read the Git tree from the SQLite DB. **Note the change:** We now create the `bun:sqlite` `Database` instance
161 | first and pass it to `BunSqliteAdapter`.
162 | ```typescript
163 | // src/commands/ls-tree.ts
164 | import { BunSqliteAdapter, SQLiteFSAdapter } from 'sqlite-fs-library';
165 | import git from 'isomorphic-git';
166 | import path from 'node:path';
167 | import fs from 'node:fs'; // Using Node's fs for directory/file checks via Bun
168 | import { Database } from 'bun:sqlite'; // Import Bun's Database
169 | import chalk from 'chalk';
170 | import type minimist from 'minimist';
171 |
172 | // Helper function to recursively walk the tree
173 | async function walkTree(fsAdapter: SQLiteFSAdapter, oid: string, currentPath: string): Promise {
174 | const { tree } = await git.readTree({ fs: fsAdapter, dir: '.', oid });
175 |
176 | for (const entry of tree) {
177 | const entryPath = path.join(currentPath, entry.path).replace(/^\//, ''); // Build full path, remove leading slash if any
178 | if (entry.type === 'blob') {
179 | // Print file entries
180 | console.log(`${entry.mode.toString(8)} blob ${entry.oid}\t${entryPath}`);
181 | } else if (entry.type === 'tree') {
182 | // Recursively walk subtrees
183 | await walkTree(fsAdapter, entry.oid, entryPath);
184 | }
185 | // Ignore commits (submodules) for this simple ls-tree
186 | }
187 | }
188 |
189 | export async function lsTreeCommand(dbFilePath: string, ref: string, options: minimist.ParsedArgs): Promise {
190 | console.log(chalk.blue(`Listing tree for ref '${chalk.bold(ref)}' in ${chalk.bold(dbFilePath)}...`));
191 |
192 | let db: Database | null = null;
193 | let dbAdapter: BunSqliteAdapter | null = null;
194 |
195 | try {
196 | // 1. Check if DB file exists
197 | if (!fs.existsSync(dbFilePath)) {
198 | throw new Error(`Database file not found: ${dbFilePath}`);
199 | }
200 |
201 | // 2. Create the bun:sqlite Database instance (read-only recommended)
202 | db = new Database(dbFilePath, { readonly: true });
203 |
204 | // 3. Instantiate BunSqliteAdapter with the Database instance
205 | dbAdapter = new BunSqliteAdapter(db);
206 |
207 | // 4. Instantiate SQLiteFSAdapter with the BunSqliteAdapter
208 | const fsAdapter = new SQLiteFSAdapter(dbAdapter);
209 |
210 | // 5. Resolve the ref to a commit OID
211 | let commitOid: string;
212 | try {
213 | commitOid = await git.resolveRef({ fs: fsAdapter, dir: '.', ref });
214 | } catch (e: any) {
215 | throw new Error(`Could not resolve ref '${ref}': ${e.message}`);
216 | }
217 |
218 | // 6. Read the commit to get the root tree OID
219 | let treeOid: string;
220 | try {
221 | const { commit } = await git.readCommit({ fs: fsAdapter, dir: '.', oid: commitOid });
222 | treeOid = commit.tree;
223 | } catch (e: any) {
224 | throw new Error(`Could not read commit '${commitOid}': ${e.message}`);
225 | }
226 |
227 | // 7. Walk the tree recursively and print entries
228 | console.log(chalk.yellow(`--- Tree for ${ref} (${commitOid}) ---`));
229 | await walkTree(fsAdapter, treeOid, ''); // Start walk from root tree
230 | console.log(chalk.yellow(`--- End Tree ---`));
231 |
232 | } catch (error: any) {
233 | // Re-throw the error to be caught by the main handler in cli.ts
234 | throw new Error(`ls-tree failed: ${error.message}`);
235 | } finally {
236 | // 8. Ensure the database connection is closed
237 | if (dbAdapter) {
238 | dbAdapter.close?.();
239 | } else if (db) {
240 | db.close();
241 | }
242 | }
243 | }
244 | ```
245 |
246 | **4. Update `src/index.ts` (if you created one for the CLI - often not needed for simple CLIs)**
247 |
248 | * If you have an `src/index.ts`, ensure it doesn't interfere with `src/cli.ts` being the main entry point defined in `package.json`. For a simple CLI
249 | like this, you often don't need an `src/index.ts`.
250 |
251 | **5. Running and Testing:**
252 |
253 | * **Clone a Repository:**
254 | ```bash
255 | # Example using a small public repo
256 | bun run src/cli.ts clone https://github.com/isomorphic-git/isomorphic-git.github.io.git ./test-repo.sqlite
257 |
258 | # Example using a local bare repo (if you have one)
259 | # bun run src/cli.ts clone /path/to/your/local/bare/repo.git ./local-repo.sqlite
260 | ```
261 | * Observe the progress output. Check for success or error messages.
262 | * Verify that the `./test-repo.sqlite` file (or your chosen path) is created.
263 |
264 | * **List the Tree:**
265 | ```bash
266 | # List tree for the default branch (HEAD)
267 | bun run src/cli.ts ls-tree ./test-repo.sqlite
268 |
269 | # List tree for a specific branch or tag (if the clone wasn't shallow/single-branch)
270 | # bun run src/cli.ts ls-tree ./test-repo.sqlite main
271 | ```
272 | * Observe the output, which should resemble the output of `git ls-tree -r HEAD`.
273 |
274 | * **Build Executable (Optional):**
275 | ```bash
276 | bun run build
277 | # Now you can run the compiled executable
278 | ./git-sqlite clone
279 | ./git-sqlite ls-tree
280 | ```
281 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/tests/sqlite-fs-adapter.test.ts:
--------------------------------------------------------------------------------
1 | // tests/sqlite-fs-adapter.test.ts
2 | import { describe, it, expect, beforeEach, afterEach } from "vitest";
3 | import { Database } from "bun:sqlite";
4 | import { BunSqliteAdapter } from "../src/bun-sqlite-adapter";
5 | import { SQLiteFSAdapter } from "../src/sqlite-fs-adapter";
6 | import { CHUNK_SIZE } from "../src/schema";
7 |
8 | describe("SQLiteFSAdapter", () => {
9 | let dbAdapter: BunSqliteAdapter;
10 | let fs: SQLiteFSAdapter;
11 |
12 | beforeEach(() => {
13 | // Create in-memory database
14 | const db = new Database(":memory:");
15 | dbAdapter = new BunSqliteAdapter(db);
16 | fs = new SQLiteFSAdapter(dbAdapter);
17 |
18 | // Set up test file system with the new schema
19 | dbAdapter.exec(`
20 | INSERT INTO file_chunks (path, chunk_index, type, content, mode, mtime, total_size) VALUES
21 | ('.', 0, 'directory', NULL, 16877, '2023-01-01T00:00:00Z', 0),
22 | ('file.txt', 0, 'file', X'48656C6C6F20576F726C64', 33188, '2023-01-01T00:00:00Z', 11),
23 | ('dir', 0, 'directory', NULL, 16877, '2023-01-01T00:00:00Z', 0),
24 | ('dir/nested.txt', 0, 'file', X'4E6573746564206669676C65', 33188, '2023-01-01T00:00:00Z', 13),
25 | ('emptyDir', 0, 'directory', NULL, 16877, '2023-01-01T00:00:00Z', 0)
26 | `);
27 | });
28 |
29 | afterEach(() => {
30 | dbAdapter.close();
31 | });
32 |
33 | // lstat tests
34 | describe("lstat", () => {
35 | it("should return Stats for an existing file", async () => {
36 | const stats = await fs.lstat("file.txt");
37 | expect(stats.isFile()).toBe(true);
38 | expect(stats.isDirectory()).toBe(false);
39 | expect(stats.size).toBe(11); // "Hello World" length
40 | expect(stats.mode).toBe(33188); // 0o100644
41 | expect(stats.mtimeMs).toBe(Date.parse("2023-01-01T00:00:00Z"));
42 | });
43 |
44 | it("should return Stats for an existing directory", async () => {
45 | const stats = await fs.lstat("dir");
46 | expect(stats.isFile()).toBe(false);
47 | expect(stats.isDirectory()).toBe(true);
48 | expect(stats.size).toBe(0);
49 | expect(stats.mode).toBe(16877); // 0o40755
50 | });
51 |
52 | it("should throw ENOENT for a non-existent path", async () => {
53 | await expect(fs.lstat("nonexistent")).rejects.toThrowError(/ENOENT/);
54 | });
55 | });
56 |
57 | // stat tests
58 | describe("stat", () => {
59 | it("should return Stats for an existing file", async () => {
60 | const stats = await fs.stat("file.txt");
61 | expect(stats.isFile()).toBe(true);
62 | expect(stats.size).toBe(11); // "Hello World" length
63 | });
64 |
65 | it("should throw ENOENT for a non-existent path", async () => {
66 | await expect(fs.stat("nonexistent")).rejects.toThrowError(/ENOENT/);
67 | });
68 | });
69 |
70 | // readFile tests
71 | describe("readFile", () => {
72 | it("should return content Buffer for an existing file", async () => {
73 | const content = await fs.readFile("file.txt");
74 | expect(content).toBeInstanceOf(Buffer);
75 | expect(content.toString()).toBe("Hello World");
76 | });
77 |
78 | it("should return content string for an existing file with encoding option", async () => {
79 | const content = await fs.readFile("file.txt", { encoding: "utf8" });
80 | expect(typeof content).toBe("string");
81 | expect(content).toBe("Hello World");
82 | });
83 |
84 | it("should throw ENOENT for a non-existent path", async () => {
85 | await expect(fs.readFile("nonexistent")).rejects.toThrowError(/ENOENT/);
86 | });
87 |
88 | it("should throw EISDIR for a directory path", async () => {
89 | await expect(fs.readFile("dir")).rejects.toThrowError(/EISDIR/);
90 | });
91 | });
92 |
93 | // readdir tests
94 | describe("readdir", () => {
95 | it("should return names of entries in a directory", async () => {
96 | const names = await fs.readdir("dir");
97 | expect(names).toEqual(expect.arrayContaining(["nested.txt"]));
98 | expect(names.length).toBe(1);
99 | });
100 |
101 | it("should return empty array for an empty directory", async () => {
102 | const names = await fs.readdir("emptyDir");
103 | expect(names).toEqual([]);
104 | });
105 |
106 | it("should throw ENOENT for a non-existent path", async () => {
107 | await expect(fs.readdir("nonexistent")).rejects.toThrowError(/ENOENT/);
108 | });
109 |
110 | it("should throw ENOTDIR for a file path", async () => {
111 | await expect(fs.readdir("file.txt")).rejects.toThrowError(/ENOTDIR/);
112 | });
113 |
114 | it("should handle root directory (.) correctly", async () => {
115 | const names = await fs.readdir(".");
116 | expect(names).toEqual(
117 | expect.arrayContaining(["file.txt", "dir", "emptyDir"]),
118 | );
119 | expect(names.length).toBe(3);
120 | });
121 | });
122 |
123 | // Helper functions for testing
124 | async function expectPathToNotExist(path: string): Promise {
125 | await expect(fs.lstat(path)).rejects.toThrowError(/ENOENT/);
126 | }
127 |
128 | // mkdir tests
129 | describe("mkdir", () => {
130 | it("mkdir: should create a new directory", async () => {
131 | await fs.mkdir("newDir");
132 | const stats = await fs.lstat("newDir");
133 | expect(stats.isDirectory()).toBe(true);
134 | });
135 |
136 | it("mkdir: should set default mode on new directory", async () => {
137 | await fs.mkdir("newDir2");
138 | const stats = await fs.lstat("newDir2");
139 | expect(stats.mode & 0o777).toBe(0o755);
140 | });
141 |
142 | it("mkdir: should allow specifying mode", async () => {
143 | await fs.mkdir("newDirMode", { mode: 0o700 });
144 | const stats = await fs.lstat("newDirMode");
145 | expect(stats.mode & 0o777).toBe(0o700);
146 | });
147 |
148 | it("mkdir: should throw EEXIST if path already exists (file)", async () => {
149 | await expect(fs.mkdir("file.txt")).rejects.toThrowError(/EEXIST/);
150 | });
151 |
152 | it("mkdir: should throw EEXIST if path already exists (directory)", async () => {
153 | await expect(fs.mkdir("dir")).rejects.toThrowError(/EEXIST/);
154 | });
155 |
156 | it("mkdir: should throw ENOENT if parent directory does not exist", async () => {
157 | await expect(fs.mkdir("nonexistent/newDir")).rejects.toThrowError(
158 | /ENOENT/,
159 | );
160 | });
161 |
162 | it("mkdir: should throw ENOTDIR if parent path is a file", async () => {
163 | await expect(fs.mkdir("file.txt/newDir")).rejects.toThrowError(/ENOTDIR/);
164 | });
165 | });
166 |
167 | // writeFile tests
168 | describe("writeFile", () => {
169 | it("writeFile: should create a new file with Buffer data", async () => {
170 | const data = Buffer.from("hello");
171 | await fs.writeFile("newFile.txt", data);
172 | const content = await fs.readFile("newFile.txt");
173 | expect(content).toEqual(data);
174 | const stats = await fs.lstat("newFile.txt");
175 | expect(stats.isFile()).toBe(true);
176 | expect(stats.size).toBe(data.length);
177 | });
178 |
179 | it("writeFile: should create a new file with string data", async () => {
180 | const data = "world";
181 | await fs.writeFile("newFile2.txt", data);
182 | const content = await fs.readFile("newFile2.txt", { encoding: "utf8" });
183 | expect(content).toBe(data);
184 | });
185 |
186 | it("writeFile: should overwrite an existing file", async () => {
187 | const newData = "overwrite";
188 | await fs.writeFile("file.txt", newData);
189 | const content = await fs.readFile("file.txt", { encoding: "utf8" });
190 | expect(content).toBe(newData);
191 | });
192 |
193 | it("writeFile: should set default mode on new file", async () => {
194 | await fs.writeFile("newFileMode.txt", "data");
195 | const stats = await fs.lstat("newFileMode.txt");
196 | expect(stats.mode & 0o777).toBe(0o644);
197 | });
198 |
199 | it("writeFile: should allow specifying mode", async () => {
200 | await fs.writeFile("newFileMode2.txt", "data", { mode: 0o600 });
201 | const stats = await fs.lstat("newFileMode2.txt");
202 | expect(stats.mode & 0o777).toBe(0o600);
203 | });
204 |
205 | it("writeFile: should throw ENOENT if parent directory does not exist", async () => {
206 | await expect(
207 | fs.writeFile("nonexistent/newFile.txt", "data"),
208 | ).rejects.toThrowError(/ENOENT/);
209 | });
210 |
211 | it("writeFile: should throw ENOTDIR if parent path is a file", async () => {
212 | await expect(
213 | fs.writeFile("file.txt/newFile.txt", "data"),
214 | ).rejects.toThrowError(/ENOTDIR/);
215 | });
216 |
217 | it("writeFile: should throw EISDIR if path is an existing directory", async () => {
218 | await expect(fs.writeFile("dir", "data")).rejects.toThrowError(/EISDIR/);
219 | });
220 |
221 | // New test for chunking
222 | it("writeFile: should store small files in a single chunk", async () => {
223 | const data = "small file content";
224 | await fs.writeFile("smallFile.txt", data);
225 |
226 | // Verify file was written correctly
227 | const content = await fs.readFile("smallFile.txt", { encoding: "utf8" });
228 | expect(content).toBe(data);
229 |
230 | // Check that only one chunk was created
231 | const chunks = dbAdapter.all<{ chunk_index: number }>(
232 | "SELECT chunk_index FROM file_chunks WHERE path = ? ORDER BY chunk_index",
233 | ["smallFile.txt"],
234 | );
235 | expect(chunks.length).toBe(1);
236 | expect(chunks[0]?.chunk_index).toBe(0);
237 | });
238 |
239 | // Test for large file chunking
240 | it("writeFile: should split large files into multiple chunks", async () => {
241 | // Create a file slightly larger than CHUNK_SIZE
242 | const chunkSize = CHUNK_SIZE;
243 | const largeData = Buffer.alloc(chunkSize + 1000, "A");
244 |
245 | await fs.writeFile("largeFile.txt", largeData);
246 |
247 | // Verify file was written correctly
248 | const content = await fs.readFile("largeFile.txt");
249 | expect(content.length).toBe(largeData.length);
250 | expect(content).toEqual(largeData);
251 |
252 | // Check that multiple chunks were created
253 | const chunks = dbAdapter.all<{ chunk_index: number }>(
254 | "SELECT chunk_index FROM file_chunks WHERE path = ? ORDER BY chunk_index",
255 | ["largeFile.txt"],
256 | );
257 | expect(chunks.length).toBe(2); // Should be split into 2 chunks
258 | expect(chunks[0]?.chunk_index).toBe(0);
259 | expect(chunks[1]?.chunk_index).toBe(1);
260 |
261 | // Verify total_size is correct
262 | const stats = await fs.lstat("largeFile.txt");
263 | expect(stats.size).toBe(largeData.length);
264 | });
265 |
266 | // Test for very large file chunking
267 | it("writeFile: should handle very large files with multiple chunks", async () => {
268 | // Create a file that will be split into 3 chunks
269 | const chunkSize = CHUNK_SIZE;
270 | const veryLargeData = Buffer.alloc(chunkSize * 2 + 1000, "B");
271 |
272 | await fs.writeFile("veryLargeFile.txt", veryLargeData);
273 |
274 | // Verify file was written correctly
275 | const content = await fs.readFile("veryLargeFile.txt");
276 | expect(content.length).toBe(veryLargeData.length);
277 | expect(content).toEqual(veryLargeData);
278 |
279 | // Check that multiple chunks were created
280 | const chunks = dbAdapter.all<{ chunk_index: number }>(
281 | "SELECT chunk_index FROM file_chunks WHERE path = ? ORDER BY chunk_index",
282 | ["veryLargeFile.txt"],
283 | );
284 | expect(chunks.length).toBe(3); // Should be split into 3 chunks
285 | expect(chunks[0]?.chunk_index).toBe(0);
286 | expect(chunks[1]?.chunk_index).toBe(1);
287 | expect(chunks[2]?.chunk_index).toBe(2);
288 |
289 | // Verify total_size is correct
290 | const stats = await fs.lstat("veryLargeFile.txt");
291 | expect(stats.size).toBe(veryLargeData.length);
292 | });
293 | });
294 |
295 | // unlink tests
296 | describe("unlink", () => {
297 | it("unlink: should delete an existing file", async () => {
298 | await fs.unlink("file.txt");
299 | await expectPathToNotExist("file.txt");
300 | });
301 |
302 | it("unlink: should throw ENOENT for a non-existent path", async () => {
303 | await expect(fs.unlink("nonexistent")).rejects.toThrowError(/ENOENT/);
304 | });
305 |
306 | it("unlink: should throw EPERM when trying to unlink a directory", async () => {
307 | await expect(fs.unlink("dir")).rejects.toThrowError(/EPERM/);
308 | });
309 |
310 | // New test for chunking
311 | it("unlink: should delete all chunks of a large file", async () => {
312 | // Create a large file first
313 | const largeData = Buffer.alloc(CHUNK_SIZE + 1000, "X");
314 | await fs.writeFile("largeFileToDelete.txt", largeData);
315 |
316 | // Verify file exists and has multiple chunks
317 | const chunksBeforeDelete = dbAdapter.all<{ chunk_index: number }>(
318 | "SELECT chunk_index FROM file_chunks WHERE path = ?",
319 | ["largeFileToDelete.txt"],
320 | );
321 | expect(chunksBeforeDelete.length).toBeGreaterThan(1);
322 |
323 | // Delete the file
324 | await fs.unlink("largeFileToDelete.txt");
325 |
326 | // Verify all chunks are deleted
327 | const chunksAfterDelete = dbAdapter.all<{ chunk_index: number }>(
328 | "SELECT chunk_index FROM file_chunks WHERE path = ?",
329 | ["largeFileToDelete.txt"],
330 | );
331 | expect(chunksAfterDelete.length).toBe(0);
332 |
333 | // Verify file doesn't exist
334 | await expectPathToNotExist("largeFileToDelete.txt");
335 | });
336 | });
337 |
338 | // rmdir tests
339 | describe("rmdir", () => {
340 | it("rmdir: should delete an existing empty directory", async () => {
341 | await fs.rmdir("emptyDir");
342 | await expectPathToNotExist("emptyDir");
343 | });
344 |
345 | it("rmdir: should throw ENOENT for a non-existent path", async () => {
346 | await expect(fs.rmdir("nonexistent")).rejects.toThrowError(/ENOENT/);
347 | });
348 |
349 | it("rmdir: should throw ENOTDIR for a file path", async () => {
350 | await expect(fs.rmdir("file.txt")).rejects.toThrowError(/ENOTDIR/);
351 | });
352 |
353 | it("rmdir: should throw ENOTEMPTY for a non-empty directory", async () => {
354 | await expect(fs.rmdir("dir")).rejects.toThrowError(/ENOTEMPTY/);
355 | });
356 | });
357 | });
358 |
359 |
--------------------------------------------------------------------------------
/docs/steps/step_2.md:
--------------------------------------------------------------------------------
1 | # Implementation Step 2
2 |
3 | **Brief: Step 2 - Define Core Interfaces & Implement/Test `BunSqliteAdapter`**
4 |
5 | **Goal:** Define the necessary synchronous database interface (`SyncSqliteDatabase`) and supporting types. Implement this interface using Bun's built-in SQLite module (`bun:sqlite`) in a `BunSqliteAdapter` class. Write unit tests using Vitest to verify the adapter's correctness against the interface contract.
6 |
7 | **Tasks:**
8 |
9 | 1. **Define Core Interfaces & Types (`src/interfaces.ts`, `src/types.ts`):**
10 | * **Action:** Create `src/interfaces.ts`.
11 | * **Content (`src/interfaces.ts`):** Define the `SyncSqliteIterator` and `SyncSqliteDatabase` interfaces exactly as specified previously (the version *without* transactions):
12 | ```typescript
13 | // src/interfaces.ts
14 |
15 | /**
16 | * Interface for a synchronous iterator over SQLite query results.
17 | * Adheres to the standard JavaScript Iterable/Iterator protocol.
18 | */
19 | export interface SyncSqliteIterator> extends Iterable {
20 | /** Returns the next item in the sequence. */
21 | next(): IteratorResult;
22 | }
23 |
24 | /**
25 | * Defines the core synchronous interface for interacting with different SQLite backends (PoC version without transactions).
26 | */
27 | export interface SyncSqliteDatabase {
28 | /** Executes SQL statement(s), primarily for side effects. Throws on error. */
29 | exec(sql: string, params?: any[]): void;
30 |
31 | /** Executes SELECT, returns all result rows as an array. Returns empty array if no rows. Throws on error. */
32 | all>(sql: string, params?: any[]): T[];
33 |
34 | /** Executes SELECT, returns exactly one result row. Throws if zero or >1 rows. Throws on other errors. */
35 | one>(sql: string, params?: any[]): T;
36 |
37 | /** Executes SELECT, returns a synchronous iterator over result rows. Throws on error during prep/execution. */
38 | iterator>(sql: string, params?: any[]): SyncSqliteIterator;
39 |
40 | /** Optional: Closes the database connection if applicable. */
41 | close?(): void;
42 | }
43 | ```
44 | * **Action:** Create `src/types.ts`.
45 | * **Content (`src/types.ts`):** Define placeholder/basic structures for `Stats` and `FSError`. These will be fleshed out more when implementing the `SQLiteFSAdapter`, but having them defined early is good practice.
46 | ```typescript
47 | // src/types.ts
48 |
49 | // Basic structure for file system errors
50 | export interface FSError extends Error {
51 | code?: string;
52 | path?: string;
53 | syscall?: string;
54 | }
55 |
56 | // Basic structure mimicking Node.js Stats object (key properties)
57 | // We'll refine this later based on SQLiteFSAdapter needs
58 | export interface Stats {
59 | isFile(): boolean;
60 | isDirectory(): boolean;
61 | isSymbolicLink(): boolean;
62 | size: number;
63 | mtimeMs: number;
64 | mode: number;
65 | // Add other common fields with placeholder types if needed now
66 | atimeMs?: number;
67 | ctimeMs?: number;
68 | birthtimeMs?: number;
69 | dev?: number; ino?: number; nlink?: number; uid?: number; gid?: number;
70 | }
71 | ```
72 | * *Rationale:* Establishes the clear contract (`SyncSqliteDatabase`) that the `BunSqliteAdapter` must adhere to and defines common types used across the library.
73 |
74 | 2. **Implement `BunSqliteAdapter` (`src/bun-sqlite-adapter.ts`):**
75 | * **Action:** Create `src/bun-sqlite-adapter.ts`.
76 | * **Content:** Implement the `BunSqliteAdapter` class.
77 | * Import `Database` and `Statement` from `bun:sqlite`.
78 | * Import `SyncSqliteDatabase`, `SyncSqliteIterator` from `./interfaces`.
79 | * The constructor should accept connection options (like filename or `:memory:`) and instantiate a `bun:sqlite` `Database`. Store the `Database` instance. Consider enabling WAL mode via `db.exec("PRAGMA journal_mode = WAL;")` in the constructor for file-based DBs if desired, though less critical for
80 | in-memory testing.
81 | * Implement the `exec`, `all`, `one`, `iterator`, and `close` methods using the corresponding `bun:sqlite` `db.query(...).run/all/get` methods, applying the necessary adaptations identified previously:
82 | * `exec(sql, params)`: Use `this.db.query(sql).run(params)`. Ignore the return value.
83 | * `all(sql, params)`: Use `this.db.query(sql).all(params) as T[]`.
84 | * `one(sql, params)`: Use `this.db.query(sql).all(params)`. Check if the result array length is exactly 1. If yes, return `results[0]`. If not, throw an appropriate `Error` (e.g., "SQLite one() error: No rows found" or "SQLite one() error: Expected 1 row, got N").
85 | * `iterator(sql, params)`: Use `const results = this.db.query(sql).all(params); return results[Symbol.iterator]() as SyncSqliteIterator;`.
86 | * `close()`: Use `this.db.close()`.
87 | * Handle potential parameter differences (e.g., `bun:sqlite` often uses objects like `{ $param: value }` or arrays for positional `?`, ensure the adapter accepts standard arrays `params?: any[]` and maps them correctly if needed, though `bun:sqlite` likely handles plain arrays for positional params
88 | directly).
89 | * **Example Snippet (Illustrative):**
90 | ```typescript
91 | // src/bun-sqlite-adapter.ts
92 | import { Database, Statement, type DatabaseOpenOptions } from 'bun:sqlite';
93 | import type { SyncSqliteDatabase, SyncSqliteIterator } from './interfaces';
94 |
95 | export class BunSqliteAdapter implements SyncSqliteDatabase {
96 | private db: Database;
97 |
98 | constructor(options?: string | DatabaseOpenOptions | Buffer | Uint8Array) {
99 | // Default to in-memory if no options provided
100 | this.db = new Database(options ?? ':memory:');
101 | // Optional: Enable WAL for file DBs if desired
102 | // if (typeof options === 'string' && options !== ':memory:') {
103 | // this.db.exec("PRAGMA journal_mode = WAL;");
104 | // }
105 | }
106 |
107 | exec(sql: string, params?: any[]): void {
108 | try {
109 | this.db.query(sql).run(...(params ?? [])); // Spread params for positional binding
110 | } catch (e: any) {
111 | console.error("BunSqliteAdapter exec error:", e.message, "SQL:", sql);
112 | throw e; // Re-throw
113 | }
114 | }
115 |
116 | all>(sql: string, params?: any[]): T[] {
117 | try {
118 | return this.db.query(sql).all(...(params ?? [])) as T[];
119 | } catch (e: any) {
120 | console.error("BunSqliteAdapter all error:", e.message, "SQL:", sql);
121 | throw e; // Re-throw
122 | }
123 | }
124 |
125 | one>(sql: string, params?: any[]): T {
126 | try {
127 | const results = this.db.query(sql).all(...(params ?? []));
128 | if (results.length === 0) {
129 | throw new Error("SQLite one() error: No rows found");
130 | }
131 | if (results.length > 1) {
132 | throw new Error(`SQLite one() error: Expected 1 row, got ${results.length}`);
133 | }
134 | return results[0] as T;
135 | } catch (e: any) {
136 | // Don't log the expected "No rows found" or "Expected 1 row" errors as console errors
137 | if (!e.message?.includes("SQLite one() error:")) {
138 | console.error("BunSqliteAdapter one error:", e.message, "SQL:", sql);
139 | }
140 | throw e; // Re-throw
141 | }
142 | }
143 |
144 | iterator>(sql: string, params?: any[]): SyncSqliteIterator {
145 | try {
146 | const results = this.db.query(sql).all(...(params ?? []));
147 | return results[Symbol.iterator]() as SyncSqliteIterator;
148 | } catch (e: any) {
149 | console.error("BunSqliteAdapter iterator error:", e.message, "SQL:", sql);
150 | throw e; // Re-throw
151 | }
152 | }
153 |
154 | close(): void {
155 | this.db.close();
156 | }
157 | }
158 | ```
159 | * *Rationale:* Provides the concrete implementation mapping the abstract `SyncSqliteDatabase` interface to the specific capabilities of `bun:sqlite`.
160 |
161 | 3. **Test `BunSqliteAdapter` (`tests/bun-sqlite-adapter.test.ts`):**
162 | * **Action:** Create `tests/bun-sqlite-adapter.test.ts`.
163 | * **Content:** Write Vitest unit tests for `BunSqliteAdapter`.
164 | * Use `beforeEach` or similar to create a *new in-memory* `BunSqliteAdapter` instance for each test to ensure isolation (`new BunSqliteAdapter(':memory:')`).
165 | * Inside tests, use the adapter's `exec` method to set up test data (e.g., `CREATE TABLE test_users (...)`, `INSERT INTO test_users (...)`).
166 | * Test each method (`exec`, `all`, `one`, `iterator`) thoroughly:
167 | * **`exec`:** Verify it runs without error for valid INSERT/UPDATE/DELETE/CREATE. Check for error throwing on invalid SQL.
168 | * **`all`:** Verify it returns correct arrays of objects for various SELECT statements. Test with zero results (empty array) and multiple results. Test parameter binding.
169 | * **`one`:** Verify it returns the single correct object when exactly one row matches. Verify it *throws* specific, identifiable errors when zero rows match and when more than one row matches. Test parameter binding.
170 | * **`iterator`:** Verify it returns an iterator. Use `Array.from(iterator)` or loop through it (`for...of`) to check if it yields the correct sequence of objects. Test with zero results (iterator yields nothing). Test parameter binding.
171 | * **`close`:** Call `close()` and potentially try another operation to ensure it throws an error indicating the database is closed (if `bun:sqlite` behaves that way).
172 | * **Example Test Snippet (Illustrative):**
173 | ```typescript
174 | // tests/bun-sqlite-adapter.test.ts
175 | import { describe, it, expect, beforeEach } from 'vitest';
176 | import { BunSqliteAdapter } from '../src/bun-sqlite-adapter';
177 | import type { SyncSqliteDatabase } from '../src/interfaces';
178 |
179 | describe('BunSqliteAdapter', () => {
180 | let db: SyncSqliteDatabase;
181 |
182 | beforeEach(() => {
183 | // Use new in-memory DB for each test
184 | db = new BunSqliteAdapter(':memory:');
185 | // Setup common schema if needed
186 | db.exec(`
187 | CREATE TABLE users (
188 | id INTEGER PRIMARY KEY AUTOINCREMENT,
189 | name TEXT NOT NULL,
190 | email TEXT UNIQUE
191 | );
192 | `);
193 | });
194 |
195 | afterEach(() => {
196 | db.close?.();
197 | });
198 |
199 | it('should execute INSERT and SELECT using all()', () => {
200 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']);
201 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Bob', 'bob@example.com']);
202 |
203 | const users = db.all('SELECT name, email FROM users ORDER BY name');
204 | expect(users).toEqual([
205 | { name: 'Alice', email: 'alice@example.com' },
206 | { name: 'Bob', email: 'bob@example.com' },
207 | ]);
208 | });
209 |
210 | it('should return empty array from all() when no rows match', () => {
211 | const users = db.all('SELECT name FROM users WHERE name = ?', ['Charlie']);
212 | expect(users).toEqual([]);
213 | });
214 |
215 | it('should execute SELECT using one() successfully', () => {
216 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']);
217 | const user = db.one('SELECT name FROM users WHERE email = ?', ['alice@example.com']);
218 | expect(user).toEqual({ name: 'Alice' });
219 | });
220 |
221 | it('should throw error from one() when no rows match', () => {
222 | expect(() => {
223 | db.one('SELECT name FROM users WHERE name = ?', ['Charlie']);
224 | }).toThrow('SQLite one() error: No rows found');
225 | });
226 |
227 | it('should throw error from one() when multiple rows match', () => {
228 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice1@example.com']);
229 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice2@example.com']);
230 | expect(() => {
231 | db.one('SELECT email FROM users WHERE name = ?', ['Alice']);
232 | }).toThrow('SQLite one() error: Expected 1 row, got 2');
233 | });
234 |
235 | it('should iterate results using iterator()', () => {
236 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Alice', 'alice@example.com']);
237 | db.exec('INSERT INTO users (name, email) VALUES (?, ?)', ['Bob', 'bob@example.com']);
238 | const iter = db.iterator<{ name: string }>('SELECT name FROM users ORDER BY name');
239 | const names = Array.from(iter).map(row => row.name);
240 | expect(names).toEqual(['Alice', 'Bob']);
241 | });
242 |
243 | it('should handle empty results with iterator()', () => {
244 | const iter = db.iterator('SELECT name FROM users');
245 | expect(Array.from(iter)).toEqual([]);
246 | });
247 |
248 | // Add more tests for exec errors, parameter types, close behavior etc.
249 | });
250 | ```
251 | * *Rationale:* Ensures the `BunSqliteAdapter` correctly implements the `SyncSqliteDatabase` interface and handles various scenarios and edge cases using the actual `bun:sqlite` driver.
252 |
253 | 4. **Export from `src/index.ts`:**
254 | * **Action:** Create or modify `src/index.ts` to export the necessary interfaces and the adapter class.
255 | * **Content (`src/index.ts`):**
256 | ```typescript
257 | export * from './interfaces';
258 | export * from './types';
259 | export { BunSqliteAdapter } from './bun-sqlite-adapter';
260 | // Add other exports as needed later (e.g., SQLiteFSAdapter)
261 | ```
262 | * *Rationale:* Creates the main entry point for the library, making the defined interfaces and the adapter implementation available for consumers.
263 |
264 | 5. **Run Tests:**
265 | * **Action:** Execute `bun test` in the terminal.
266 | * **Goal:** Verify that all tests for the `BunSqliteAdapter` pass. Debug any failures.
267 |
268 | **Outcome:** Upon completion of these combined steps, you will have:
269 | 1. Clearly defined synchronous database interfaces (`SyncSqliteDatabase`, `SyncSqliteIterator`) and basic types (`Stats`, `FSError`).
270 | 2. A working `BunSqliteAdapter` class that implements the `SyncSqliteDatabase` interface using `bun:sqlite`.
271 | 3. A suite of unit tests confirming the adapter's behavior and adherence to the interface contract.
272 | 4. The core components exported from the library's entry point (`src/index.ts`).
273 |
--------------------------------------------------------------------------------
/packages/sqlite-fs/src/sqlite-fs-adapter.ts:
--------------------------------------------------------------------------------
1 | // src/sqlite-fs-adapter.ts
2 | import { createError } from "./error-utils";
3 | import type { NoRowsError, SyncSqliteDatabase } from "./interfaces";
4 | import { basename, dirname, normalize } from "./path-utils";
5 | import { CHUNK_SIZE, SQL_SCHEMA } from "./schema";
6 | import { createStats, type DbFileRow } from "./stats-utils";
7 | import type { FSError, Stats } from "./types";
8 |
9 | // Helper to check if an error is a "not found" error from the database
10 | function isNotFoundError(error: any): error is NoRowsError {
11 | return error?.name === "NoRowsError";
12 | }
13 |
14 | export interface FileSystem {
15 | readFile: (
16 | path: string,
17 | options?: { encoding?: string },
18 | ) => Promise;
19 | writeFile: (
20 | path: string,
21 | data: string | Buffer | Uint8Array,
22 | options?: { encoding?: string; mode?: number },
23 | ) => Promise;
24 | unlink: (path: string) => Promise;
25 | readdir: (path: string) => Promise;
26 | mkdir: (path: string, options?: { mode?: number }) => Promise;
27 | rmdir: (path: string) => Promise;
28 | stat: (path: string) => Promise;
29 | lstat: (path: string) => Promise;
30 | readlink: (path: string, options?: { encoding?: string }) => Promise;
31 | symlink: (target: string, path: string) => Promise;
32 | }
33 |
34 | export class SQLiteFSAdapter implements FileSystem {
35 | private db: SyncSqliteDatabase;
36 |
37 | public readFile: (
38 | path: string,
39 | options?: { encoding?: string },
40 | ) => Promise;
41 | public writeFile: (
42 | path: string,
43 | data: string | Buffer | Uint8Array,
44 | options?: { encoding?: string; mode?: number },
45 | ) => Promise;
46 | public unlink: (path: string) => Promise;
47 | public readdir: (path: string) => Promise;
48 | public mkdir: (path: string, options?: { mode?: number }) => Promise;
49 | public rmdir: (path: string) => Promise;
50 | public stat: (path: string) => Promise;
51 | public lstat: (path: string) => Promise;
52 | public readlink: (
53 | path: string,
54 | options?: { encoding?: string },
55 | ) => Promise;
56 | public symlink: (target: string, path: string) => Promise;
57 |
58 | // Add promises property for isomorphic-git compatibility
59 | public promises: FileSystem;
60 |
61 | constructor(db: SyncSqliteDatabase) {
62 | this.db = db;
63 |
64 | // Bind methods directly to the adapter for isomorphic-git compatibility
65 | this.readFile = this._readFile.bind(this);
66 | this.writeFile = this._writeFile.bind(this);
67 | this.unlink = this._unlink.bind(this);
68 | this.readdir = this._readdir.bind(this);
69 | this.mkdir = this._mkdir.bind(this);
70 | this.rmdir = this._rmdir.bind(this);
71 | this.stat = this._stat.bind(this);
72 | this.lstat = this._lstat.bind(this);
73 | this.readlink = this._readlink.bind(this);
74 | this.symlink = this._symlink.bind(this);
75 |
76 | // Initialize the promises object with bound methods
77 | this.promises = {
78 | readFile: this._readFile.bind(this),
79 | writeFile: this._writeFile.bind(this),
80 | unlink: this._unlink.bind(this),
81 | readdir: this._readdir.bind(this),
82 | mkdir: this._mkdir.bind(this),
83 | rmdir: this._rmdir.bind(this),
84 | stat: this._stat.bind(this),
85 | lstat: this._lstat.bind(this),
86 | readlink: this._readlink.bind(this),
87 | symlink: this._symlink.bind(this),
88 | };
89 |
90 | // Ensure schema exists
91 | try {
92 | this.db.exec(SQL_SCHEMA);
93 | } catch (e) {
94 | console.error("Failed to initialize SQLiteFSAdapter schema", e);
95 | // Non-fatal error, schema might already exist or DB is read-only
96 | }
97 | }
98 |
99 | // Placeholder for path mapping if rootDir is used
100 | private getDbPath(fsPath: string): string {
101 | return normalize(fsPath);
102 | }
103 |
104 | // --- Read Methods ---
105 | async _lstat(path: string): Promise {
106 | const dbPath = this.getDbPath(path);
107 | try {
108 | const row = this.db.one(
109 | "SELECT type, mode, mtime, content, total_size FROM file_chunks WHERE path = ? AND chunk_index = 0",
110 | [dbPath],
111 | );
112 | return createStats(row);
113 | } catch (error) {
114 | if (isNotFoundError(error)) {
115 | throw createError("ENOENT", path, "lstat");
116 | }
117 | // Other database errors
118 | throw createError("EIO", path, "lstat");
119 | }
120 | }
121 |
122 | async _stat(path: string): Promise {
123 | // For this adapter, stat behaves identically to lstat
124 | return this._lstat(path);
125 | }
126 |
127 | async _readFile(
128 | path: string,
129 | options?: { encoding?: string },
130 | ): Promise {
131 | const dbPath = this.getDbPath(path);
132 |
133 | try {
134 | // First check if the file exists and get its type
135 | const metadataRow = this.db.one<{
136 | type: string;
137 | total_size: number;
138 | }>(
139 | "SELECT type, total_size FROM file_chunks WHERE path = ? AND chunk_index = 0",
140 | [dbPath],
141 | );
142 |
143 | // Check if it's a directory
144 | if (metadataRow.type === "directory") {
145 | throw createError("EISDIR", path, "readFile");
146 | }
147 |
148 | // Get all chunks for this file, ordered by chunk_index
149 | const chunkRows = this.db.all<{
150 | content: Buffer | Uint8Array | null;
151 | }>(
152 | "SELECT content FROM file_chunks WHERE path = ? ORDER BY chunk_index ASC",
153 | [dbPath],
154 | );
155 |
156 | // Collect all content chunks
157 | const contentChunks: Buffer[] = [];
158 | for (const row of chunkRows) {
159 | if (row.content) {
160 | contentChunks.push(Buffer.from(row.content));
161 | }
162 | }
163 |
164 | // Concatenate all chunks into a single buffer
165 | const buffer = Buffer.concat(contentChunks);
166 |
167 | // Return string if encoding is specified, otherwise return Buffer
168 | if (options?.encoding) {
169 | return buffer.toString(options.encoding as BufferEncoding);
170 | }
171 | return buffer;
172 | } catch (error) {
173 | // If we already created a specific error (like EISDIR), re-throw it
174 | if ((error as FSError).code) {
175 | throw error;
176 | }
177 |
178 | if (isNotFoundError(error)) {
179 | throw createError("ENOENT", path, "readFile");
180 | }
181 |
182 | // Other database errors
183 | throw createError("EIO", path, "readFile");
184 | }
185 | }
186 |
187 | async _readdir(path: string): Promise {
188 | const dbPath = this.getDbPath(path);
189 |
190 | // First check if the path exists and is a directory
191 | try {
192 | // Special case for root directory
193 | if (dbPath !== "." && dbPath !== "/") {
194 | // Check if path exists and is a directory
195 | const fileCheck = this.db.one<{ type: string }>(
196 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
197 | [dbPath],
198 | );
199 |
200 | // If it exists but is not a directory, throw ENOTDIR
201 | if (fileCheck.type !== "directory") {
202 | throw createError("ENOTDIR", path, "readdir");
203 | }
204 | }
205 |
206 | // Construct the SQL query to find immediate children
207 | let sql: string;
208 | let params: string[];
209 |
210 | if (dbPath === "." || dbPath === "/") {
211 | // For root directory, find entries without '/' in their path (except at the beginning)
212 | sql =
213 | "SELECT path FROM file_chunks WHERE chunk_index = 0 AND path != '.' AND path != '/' AND path NOT LIKE '%/%'";
214 | params = [];
215 | } else {
216 | // For other directories, find immediate children
217 | const dirPrefix = dbPath.endsWith("/") ? dbPath : `${dbPath}/`;
218 | sql =
219 | "SELECT path FROM file_chunks WHERE chunk_index = 0 AND path LIKE ? AND path NOT LIKE ? AND path != ?";
220 | params = [`${dirPrefix}%`, `${dirPrefix}%/%`, dbPath];
221 | }
222 |
223 | // Get all matching paths
224 | const rows = this.db.all<{ path: string }>(sql, params);
225 |
226 | // Extract basenames
227 | return rows.map((row) => basename(row.path));
228 | } catch (error) {
229 | if (isNotFoundError(error)) {
230 | throw createError("ENOENT", path, "readdir");
231 | }
232 | // If we already created a specific error (like ENOTDIR), re-throw it
233 | if ((error as FSError).code) {
234 | throw error;
235 | }
236 | // Other database errors
237 | throw createError("EIO", path, "readdir");
238 | }
239 | }
240 |
241 | // --- Write Methods ---
242 | async _mkdir(path: string, options?: { mode?: number }): Promise {
243 | const dbPath = this.getDbPath(path);
244 | const mode = options?.mode ?? 0o755; // Default directory mode
245 | const mtime = new Date().toISOString();
246 |
247 | try {
248 | // Check if path already exists
249 | try {
250 | this.db.one(
251 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
252 | [dbPath],
253 | );
254 | // If we get here, the path exists
255 | throw createError("EEXIST", path, "mkdir");
256 | } catch (error) {
257 | // If error is not "No rows found", rethrow it
258 | if (!isNotFoundError(error)) {
259 | throw error;
260 | }
261 | // Otherwise, path doesn't exist, continue
262 | }
263 |
264 | // Check parent directory
265 | const parentPath = dirname(dbPath);
266 | if (parentPath !== "." && parentPath !== "/") {
267 | try {
268 | const parent = this.db.one<{ type: string }>(
269 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
270 | [parentPath],
271 | );
272 | if (parent.type !== "directory") {
273 | throw createError("ENOTDIR", path, "mkdir");
274 | }
275 | } catch (error) {
276 | if (isNotFoundError(error)) {
277 | throw createError("ENOENT", path, "mkdir");
278 | }
279 | throw error;
280 | }
281 | }
282 |
283 | // Create the directory (only needs a metadata row with chunk_index = 0)
284 | this.db.exec(
285 | "INSERT INTO file_chunks (path, chunk_index, type, mode, mtime, content, total_size) VALUES (?, 0, 'directory', ?, ?, NULL, 0)",
286 | [dbPath, mode, mtime],
287 | );
288 | } catch (error) {
289 | // If we already created a specific error, re-throw it
290 | if ((error as FSError).code) {
291 | throw error;
292 | }
293 | // Other database errors
294 | throw createError("EIO", path, "mkdir");
295 | }
296 | }
297 |
298 | async _writeFile(
299 | path: string,
300 | data: string | Buffer | Uint8Array,
301 | options?: { encoding?: string; mode?: number },
302 | ): Promise {
303 | const dbPath = this.getDbPath(path);
304 | const mode = options?.mode ?? 0o644; // Default file mode
305 | const mtime = new Date().toISOString();
306 |
307 | // Convert data to Buffer if it's a string
308 | const buffer =
309 | typeof data === "string"
310 | ? Buffer.from(data, options?.encoding as BufferEncoding)
311 | : Buffer.from(data);
312 |
313 | // Calculate total size
314 | const totalSize = buffer.length;
315 |
316 | try {
317 | // Check parent directory
318 | const parentPath = dirname(dbPath);
319 | if (parentPath !== "." && parentPath !== "/") {
320 | try {
321 | const parent = this.db.one<{ type: string }>(
322 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
323 | [parentPath],
324 | );
325 | if (parent.type !== "directory") {
326 | throw createError("ENOTDIR", path, "writeFile");
327 | }
328 | } catch (error) {
329 | if (isNotFoundError(error)) {
330 | throw createError("ENOENT", path, "writeFile");
331 | }
332 | throw error;
333 | }
334 | }
335 |
336 | // Check if path exists and is a directory
337 | try {
338 | const existing = this.db.one<{ type: string }>(
339 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
340 | [dbPath],
341 | );
342 | if (existing.type === "directory") {
343 | throw createError("EISDIR", path, "writeFile");
344 | }
345 | } catch (error) {
346 | // If path doesn't exist, that's fine for writeFile
347 | if (!isNotFoundError(error)) {
348 | throw error;
349 | }
350 | }
351 |
352 | // Delete any existing chunks for this path
353 | this.db.exec("DELETE FROM file_chunks WHERE path = ?", [dbPath]);
354 |
355 | // If the file is small enough to fit in a single chunk
356 | if (totalSize <= CHUNK_SIZE) {
357 | // Write the file as a single chunk (chunk_index = 0)
358 | this.db.exec(
359 | "INSERT INTO file_chunks (path, chunk_index, type, mode, mtime, content, total_size) VALUES (?, 0, 'file', ?, ?, ?, ?)",
360 | [dbPath, mode, mtime, buffer, totalSize],
361 | );
362 | } else {
363 | // For large files, split into chunks
364 | const chunkCount = Math.ceil(totalSize / CHUNK_SIZE);
365 |
366 | // First, insert the metadata row (chunk_index = 0) with the first chunk of data
367 | const firstChunk = buffer.subarray(0, CHUNK_SIZE);
368 | this.db.exec(
369 | "INSERT INTO file_chunks (path, chunk_index, type, mode, mtime, content, total_size) VALUES (?, 0, 'file', ?, ?, ?, ?)",
370 | [dbPath, mode, mtime, firstChunk, totalSize],
371 | );
372 |
373 | // Then insert the remaining chunks
374 | for (let i = 1; i < chunkCount; i++) {
375 | const start = i * CHUNK_SIZE;
376 | const end = Math.min(start + CHUNK_SIZE, totalSize);
377 | const chunk = buffer.subarray(start, end);
378 |
379 | this.db.exec(
380 | "INSERT INTO file_chunks (path, chunk_index, type, mode, mtime, content, total_size) VALUES (?, ?, 'file', ?, ?, ?, ?)",
381 | [dbPath, i, mode, mtime, chunk, totalSize],
382 | );
383 | }
384 | }
385 | } catch (error) {
386 | // If we already created a specific error, re-throw it
387 | if ((error as FSError).code) {
388 | throw error;
389 | }
390 | // Other database errors
391 | throw createError("EIO", path, "writeFile");
392 | }
393 | }
394 |
395 | async _unlink(path: string): Promise {
396 | const dbPath = this.getDbPath(path);
397 |
398 | try {
399 | // Check if path exists and get its type
400 | const file = this.db.one<{ type: string }>(
401 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
402 | [dbPath],
403 | );
404 |
405 | // If it's a directory, throw EPERM
406 | if (file.type === "directory") {
407 | throw createError("EPERM", path, "unlink");
408 | }
409 |
410 | // Delete all chunks for this file
411 | this.db.exec("DELETE FROM file_chunks WHERE path = ?", [dbPath]);
412 | } catch (error) {
413 | // If we already created a specific error, re-throw it
414 | if ((error as FSError).code) {
415 | throw error;
416 | }
417 |
418 | if (isNotFoundError(error)) {
419 | throw createError("ENOENT", path, "unlink");
420 | }
421 |
422 | // Other database errors
423 | throw createError("EIO", path, "unlink");
424 | }
425 | }
426 |
427 | async _rmdir(path: string): Promise {
428 | const dbPath = this.getDbPath(path);
429 |
430 | try {
431 | // Check if path exists and is a directory
432 | const file = this.db.one<{ type: string }>(
433 | "SELECT type FROM file_chunks WHERE path = ? AND chunk_index = 0",
434 | [dbPath],
435 | );
436 |
437 | if (file.type !== "directory") {
438 | throw createError("ENOTDIR", path, "rmdir");
439 | }
440 |
441 | // Check if directory is empty
442 | try {
443 | const dirPrefix = dbPath.endsWith("/") ? dbPath : `${dbPath}/`;
444 | this.db.one<{ path: string }>(
445 | "SELECT path FROM file_chunks WHERE path LIKE ? AND path != ? AND chunk_index = 0 LIMIT 1",
446 | [`${dirPrefix}%`, dbPath],
447 | );
448 | // If we get here, directory has at least one child
449 | throw createError("ENOTEMPTY", path, "rmdir");
450 | } catch (error) {
451 | // If "No rows found", directory is empty, continue
452 | if (!isNotFoundError(error)) {
453 | throw error;
454 | }
455 | }
456 |
457 | // Delete the directory (only the metadata row)
458 | this.db.exec(
459 | "DELETE FROM file_chunks WHERE path = ? AND chunk_index = 0",
460 | [dbPath],
461 | );
462 | } catch (error) {
463 | // If we already created a specific error, re-throw it
464 | if ((error as FSError).code) {
465 | throw error;
466 | }
467 |
468 | if (isNotFoundError(error)) {
469 | throw createError("ENOENT", path, "rmdir");
470 | }
471 |
472 | // Other database errors
473 | throw createError("EIO", path, "rmdir");
474 | }
475 | }
476 |
477 | // --- Symlink Methods (Stubs) ---
478 | async _readlink(
479 | path: string,
480 | _options?: { encoding?: string },
481 | ): Promise {
482 | // This is a stub implementation since we don't support symlinks
483 | throw createError("EINVAL", path, "readlink");
484 | }
485 |
486 | async _symlink(_target: string, path: string): Promise {
487 | // This is a stub implementation since we don't support symlinks
488 | throw createError("EPERM", path, "symlink");
489 | }
490 | }
491 |
--------------------------------------------------------------------------------