Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions MIGRATION-PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Bun → Node.js Migration Plan

Status: In progress. See below for completed and remaining steps.

## Completed

### Phase 4 (early): Package Manager Switch
- [x] Changed `packageManager` from `bun@1.3.13` to `pnpm@10.11.0`
- [x] Moved `patchedDependencies` into `pnpm` config section
- [x] Added `onlyBuiltDependencies: ["esbuild"]`
- [x] Added phantom deps as explicit devDependencies: `@sentry/core`, `@clack/prompts`
- [x] Generated `pnpm-lock.yaml`
- [x] Verified all patches apply (including cross-version: `@stricli/core@1.2.5` patch on `1.2.7`)

### Phase 2, Group D: SQLite Adapter
- [x] Created `src/lib/db/sqlite.ts` — runtime-detecting adapter (bun:sqlite under Bun, node:sqlite under Node.js)
- [x] Updated 4 source files: `db/index.ts`, `schema.ts`, `migration.ts`, `utils.ts`
- [x] Updated 3 test files: `fix.test.ts`, `telemetry.test.ts`, `schema.test.ts`
- [x] Zero `bun:sqlite` imports remain in `src/` or `test/`

## Remaining

### Phase 2: Source Code Migration (replace Bun.* APIs in `src/`)

**Group A: File I/O** — Replace `Bun.file()` / `Bun.write()` with `node:fs/promises`
- `Bun.file(path).text()` → `readFile(path, "utf-8")`
- `Bun.file(path).json()` → `readFile(path, "utf-8")` then `JSON.parse()`
- `Bun.file(path).exists()` → `access(path).then(() => true, () => false)`
- `Bun.write(path, content)` → `writeFile(path, content)`
- Scan all of `src/` for occurrences

**Group B: Process/System APIs** — Replace Bun.which / Bun.spawn / Bun.sleep
- `Bun.which("cmd")` → `which` from a Node.js-compatible package or custom implementation
- `Bun.spawn()` / `Bun.spawnSync()` → `child_process.spawn()` / `spawnSync()`
- `Bun.sleep(ms)` → `setTimeout` promise wrapper

**Group C: Miscellaneous Bun APIs**
- `Bun.Glob` → `tinyglobby` or `picomatch` (already in devDependencies)
- `Bun.randomUUIDv7()` → `uuidv7` package (already in devDependencies)
- `Bun.semver.order()` → `semver.compare()` (already in devDependencies)
- `Bun.zstdCompressSync()` / `Bun.zstdDecompressSync()` → Node.js zlib or `zstd-napi` package

**Group E: Unpolyfilled APIs**
- `bspatch.ts` and `upgrade.ts` — Replace any Bun-specific APIs not covered by node-polyfills.ts

### Phase 3: Test Migration (`bun:test` → Vitest)

- Add `vitest` as devDependency
- Replace `import { ... } from "bun:test"` with Vitest equivalents
- Replace `bun test` scripts with `vitest`
- Key differences:
- `bun:test`'s `mock.module()` → Vitest's `vi.mock()`
- `bun:test`'s `spyOn` → Vitest's `vi.spyOn()`
- Test file discovery patterns may differ
- `--isolate --parallel` behavior needs Vitest equivalent

### Phase 4: CI & Dev Scripts (remaining)

- Update `package.json` scripts: `bun run` → `pnpm run` where appropriate
- Replace `bun run src/bin.ts` with `tsx src/bin.ts` (add `tsx` devDependency)
- Replace `bun run script/*.ts` with `tsx script/*.ts`
- Replace `bunx` with `pnpm exec`
- Update GitHub Actions workflows to use pnpm + Node.js instead of Bun
- Update `Dockerfile` / build scripts if applicable

### Phase 5: Cleanup

- Remove `@types/bun` from devDependencies
- Remove `bun.lock` (replaced by `pnpm-lock.yaml`)
- Remove or update `script/node-polyfills.ts` (may become unnecessary)
- Update `AGENTS.md` Bun API reference table
- Remove Bun-specific `.cursor/rules/bun-cli.mdc` or update for Node.js
- Clean up any remaining `Bun.*` references in comments/docs

## Known Issues

- `test/lib/index.test.ts` — `sdk.run throws when auth is required but missing` fails under pnpm's strict `node_modules`. The mock fetch returns empty 200s which prevents the expected auth error from being thrown. Pre-existing test fragility, not caused by migration changes.
5 changes: 3 additions & 2 deletions src/lib/db/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* SQLite database connection manager for CLI configuration storage.
* Uses bun:sqlite natively; Node.js uses a polyfill in node-polyfills.ts.
* Uses the sqlite.ts adapter which wraps node:sqlite's DatabaseSync
* with a bun:sqlite-compatible API surface.
*/

import { Database } from "bun:sqlite";
import { Database } from "./sqlite.js";
import { chmodSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { getEnv } from "../env.js";
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* One-time migration from config.json to SQLite.
*/

import type { Database } from "bun:sqlite";
import type { Database } from "./sqlite.js";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { logger } from "../logger.js";
Expand Down
2 changes: 1 addition & 1 deletion src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - Migration checks
*/

import type { Database } from "bun:sqlite";
import type { Database } from "./sqlite.js";
import { getEnv } from "../env.js";
import { stringifyUnknown } from "../errors.js";
import { logger } from "../logger.js";
Comment on lines 11 to 17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The check for error.name === "SQLiteError" may fail for errors from node:sqlite, silently disabling database error handling and auto-repair for Node.js users.
Severity: HIGH

Suggested Fix

Instead of checking error.name, consider checking a more reliable property on the error object from node:sqlite, such as error.code. Alternatively, wrap errors from node:sqlite in a custom error class that correctly sets the name property to "SQLiteError" before they are processed by the error handling logic.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/db/schema.ts#L11-L17

Potential issue: The `isSchemaError` and `isReadonlyError` functions check if
`error.name === "SQLiteError"` to identify specific database issues. However, errors
originating from the `node:sqlite` library may not have this `name` property. Standard
JavaScript Error subclasses require the `name` property to be set explicitly. If
`node:sqlite`'s native `SQLiteError` does not do this, these checks will consistently
fail for users on Node.js. This will silently disable critical error handling logic,
such as the database auto-repair mechanism used in `tryRepairAndRetry`.

Did we get this right? 👍 / 👎 to inform future reviews.

Expand Down
124 changes: 124 additions & 0 deletions src/lib/db/sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* SQLite adapter that provides a unified API surface for database access.
* This module is the single import point for all SQLite access in the
* codebase, replacing direct `bun:sqlite` imports.
*
* Runtime detection:
* - **Bun**: Re-exports `bun:sqlite`'s Database directly (zero overhead).
* - **Node.js**: Wraps `node:sqlite`'s DatabaseSync with a bun:sqlite-
* compatible API (`.query()` → `.prepare()`, manual transaction wrapper).
*
* Call sites continue to use `.query(sql).get()` / `.all()` / `.run()`
* and `db.transaction()` exactly as before — no migration churn needed.
*/

/** Valid SQLite binding value */
export type SQLQueryBindings =
| string
| number
| bigint
| null
| Uint8Array
| undefined;

// ---------------------------------------------------------------------------
// Runtime detection
// ---------------------------------------------------------------------------

const isBun = typeof globalThis.Bun !== "undefined";

// ---------------------------------------------------------------------------
// Node.js implementation (wraps node:sqlite DatabaseSync)
// ---------------------------------------------------------------------------

/**
* Minimal statement wrapper matching the bun:sqlite query API.
* Wraps node:sqlite's StatementSync to expose `.get()`, `.all()`, `.run()`.
*/
class NodeStatementWrapper {
// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
private readonly stmt: any;

// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
constructor(stmt: any) {
this.stmt = stmt;
}

get(
...params: SQLQueryBindings[]
): Record<string, SQLQueryBindings> | undefined {
return this.stmt.get(...params) as
| Record<string, SQLQueryBindings>
| undefined;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adapter get() returns undefined vs null across runtimes

Medium Severity

The adapter's goal is a "unified API surface," but NodeStatementWrapper.get() passes through node:sqlite's return value of undefined for no-row-found, while under Bun the re-exported bun:sqlite Statement.get() returns null. This means .get() has different no-match semantics depending on which runtime is active. The current codebase happens to use ??, ?., and truthiness checks that tolerate both, but any future code using === null would silently break under Node.js. Normalizing the return (e.g., ?? null or converting null to undefined) would make the adapter truly portable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f6160a2. Configure here.


all(...params: SQLQueryBindings[]): Record<string, SQLQueryBindings>[] {
return this.stmt.all(...params) as Record<string, SQLQueryBindings>[];
}

run(...params: SQLQueryBindings[]): void {
this.stmt.run(...params);
}
}

/**
* Node.js SQLite database wrapper with bun:sqlite-compatible API.
* Used only when running under Node.js (not Bun).
*/
class NodeDatabase {
// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
private readonly db: any;

constructor(path: string) {
// Lazy-load node:sqlite to avoid crashing on runtimes without it.
// biome-ignore lint/suspicious/noExplicitAny: node:sqlite types loaded lazily
const { DatabaseSync } = require("node:sqlite") as any;
this.db = new DatabaseSync(path);
}

exec(sql: string): void {
this.db.exec(sql);
}

query(sql: string): NodeStatementWrapper {
return new NodeStatementWrapper(this.db.prepare(sql));
}

close(): void {
this.db.close();
}

transaction<T>(fn: () => T): () => T {
return () => {
this.db.exec("BEGIN");
try {
const result = fn();
this.db.exec("COMMIT");
return result;
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transaction() doesn't forward arguments to callback

Low Severity

bun:sqlite's transaction() signature is (fn: (...args: A) => T): (...args: A) => T, forwarding arguments from the returned wrapper to the inner callback. The NodeDatabase.transaction() uses (fn: () => T): () => T, discarding any arguments. All current call sites use zero-argument callbacks so this doesn't break today, but it makes the adapter incompatible with the full bun:sqlite transaction API.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 298f436. Configure here.

}

// ---------------------------------------------------------------------------
// Unified export — picks the right implementation at runtime
// ---------------------------------------------------------------------------

/**
* SQLite Database class.
*
* Under Bun this is the native `bun:sqlite` Database (zero-overhead re-export).
* Under Node.js this is a wrapper around `node:sqlite`'s DatabaseSync that
* provides the same `.exec()`, `.query()`, `.close()`, `.transaction()` API.
*/
// biome-ignore lint/suspicious/noExplicitAny: conditional runtime export
export const Database: any = isBun
? require("bun:sqlite").Database
: NodeDatabase;

// Re-export the type so `import type { Database }` works correctly.
// The type matches the bun:sqlite Database shape used across the codebase.
export type Database = InstanceType<typeof Database>;
4 changes: 2 additions & 2 deletions src/lib/db/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* Reduces boilerplate for UPSERT and other repetitive patterns.
*/

import type { SQLQueryBindings } from "bun:sqlite";
import type { SQLQueryBindings } from "./sqlite.js";

import { getDatabase } from "./index.js";

/** Valid SQLite binding value (matches bun:sqlite's SQLQueryBindings) */
/** Valid SQLite binding value (re-exported from sqlite.ts adapter) */
export type SqlValue = SQLQueryBindings;

/**
Expand Down
2 changes: 1 addition & 1 deletion test/commands/cli/fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* and code spans have backticks stripped.
*/

import { Database } from "bun:sqlite";
import { Database } from "../../../src/lib/db/sqlite.js";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { chmodSync, statSync } from "node:fs";
import { join } from "node:path";
Expand Down
2 changes: 1 addition & 1 deletion test/lib/db/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Tests for database schema repair functions.
*/

import { Database } from "bun:sqlite";
import { Database } from "../../../src/lib/db/sqlite.js";
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import {
Expand Down
2 changes: 1 addition & 1 deletion test/lib/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Tests for withTelemetry wrapper and opt-out behavior.
*/

import { Database } from "bun:sqlite";
import { Database } from "../../src/lib/db/sqlite.js";
import {
afterAll,
afterEach,
Expand Down
Loading