# Phase 8.3d: Node.js Bindings Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Publish `minigraf` to npm as a Node.js native addon — `napi-rs` proc-macros generate a `.node` binary with TypeScript definitions, distributed as platform-specific optional packages so `npm install minigraf` requires no build step.

**Architecture:** A `minigraf-node` workspace crate (`cdylib`) uses `napi-rs` proc-macros to expose `MiniGrafDb` to Node.js. `@napi-rs/cli` generates `index.d.ts` TypeScript definitions. The main `minigraf` npm package contains `index.js` + `index.d.ts` and declares four platform packages (`@minigraf/linux-x64-gnu`, `@minigraf/linux-arm64-gnu`, `@minigraf/darwin-universal`, `@minigraf/win32-x64-msvc`) as `optionalDependencies`. Each platform package contains only its prebuilt `.node` binary. `npm install minigraf` automatically resolves the correct binary.

**Tech Stack:** napi v2 (`napi`, `napi-derive`, `napi-build`), `@napi-rs/cli`, Node.js ≥18, node:test (built-in test runner).

---

## File Structure

| Action | Path | Responsibility |
|--------|------|----------------|
| MODIFY | `Cargo.toml` (root) | Add `minigraf-node` to workspace members |
| CREATE | `minigraf-node/Cargo.toml` | Crate metadata, napi deps |
| CREATE | `minigraf-node/build.rs` | napi-build setup (required by napi-rs) |
| CREATE | `minigraf-node/src/lib.rs` | `#[napi]` MiniGrafDb implementation |
| CREATE | `minigraf-node/package.json` | Main npm package (minigraf) |
| CREATE | `minigraf-node/index.js` | Platform binary loader |
| CREATE | `minigraf-node/index.d.ts` | TypeScript definitions (generated by @napi-rs/cli, committed) |
| CREATE | `minigraf-node/test/basic.test.mjs` | node:test test suite |
| CREATE | `.github/workflows/node-ci.yml` | PR tests (4 platforms) |
| CREATE | `.github/workflows/node-release.yml` | Release: build .node binaries + publish to npm |
| MODIFY | `Cargo.toml` (root) | Bump version to `0.25.0` |
| MODIFY | `CHANGELOG.md` | Add 8.3d entry |
| MODIFY | `ROADMAP.md` | Mark 8.3d complete |

---

## Task 1: Scaffold `minigraf-node` workspace crate

**Files:**
- Modify: `Cargo.toml` (root)
- Create: `minigraf-node/Cargo.toml`
- Create: `minigraf-node/build.rs`

- [ ] **Step 1: Add `minigraf-node` to the workspace**

In `Cargo.toml` (root), change:
```toml
[workspace]
members = [".", "minigraf-ffi", "minigraf-c"]
```
to:
```toml
[workspace]
members = [".", "minigraf-ffi", "minigraf-c", "minigraf-node"]
```

- [ ] **Step 2: Create `minigraf-node/Cargo.toml`**

```toml
[package]
name = "minigraf-node"
version = "0.25.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { version = "2.16", features = ["napi6", "async"] }
napi-derive = "2.16"
minigraf = { path = ".." }
serde_json = "1.0"

[build-dependencies]
napi-build = "2.1"
```

- [ ] **Step 3: Create `minigraf-node/build.rs`**

```rust
fn main() {
    napi_build::setup();
}
```

- [ ] **Step 4: Create placeholder `minigraf-node/src/lib.rs`**

```rust
// Implementation in Task 2
```

- [ ] **Step 5: Verify workspace compiles**

```bash
cargo check -p minigraf-node
```

Expected: no errors (empty lib).

- [ ] **Step 6: Commit**

```bash
git add Cargo.toml minigraf-node/
git commit -m "feat(node): scaffold minigraf-node workspace crate with napi-rs"
```

---

## Task 2: Implement the napi-rs API

**Files:**
- Modify: `minigraf-node/src/lib.rs`

- [ ] **Step 1: Write `minigraf-node/src/lib.rs`**

```rust
#![deny(clippy::all)]

use minigraf::{QueryResult, Value};
use napi::bindgen_prelude::*;
use napi_derive::napi;
use std::sync::{Arc, Mutex};

// ─── Value → JSON ─────────────────────────────────────────────────────────────

fn value_to_json(v: &Value) -> serde_json::Value {
    use serde_json::Value as J;
    match v {
        Value::String(s) => J::String(s.clone()),
        Value::Integer(i) => serde_json::json!(i),
        Value::Float(f) => serde_json::Number::from_f64(*f)
            .map(J::Number)
            .unwrap_or(J::Null),
        Value::Boolean(b) => J::Bool(*b),
        Value::Ref(u) => J::String(u.to_string()),
        Value::Keyword(k) => J::String(k.clone()),
        Value::Null => J::Null,
    }
}

fn query_result_to_json(result: QueryResult) -> String {
    let val = match result {
        QueryResult::Transacted(tx) => serde_json::json!({"transacted": tx}),
        QueryResult::Retracted(tx) => serde_json::json!({"retracted": tx}),
        QueryResult::Ok => serde_json::json!({"ok": true}),
        QueryResult::QueryResults { vars, results } => {
            let rows: Vec<Vec<serde_json::Value>> =
                results.iter().map(|r| r.iter().map(value_to_json).collect()).collect();
            serde_json::json!({"variables": vars, "results": rows})
        }
    };
    val.to_string()
}

// ─── MiniGrafDb ───────────────────────────────────────────────────────────────

#[napi]
pub struct MiniGrafDb {
    inner: Arc<Mutex<minigraf::Minigraf>>,
}

#[napi]
impl MiniGrafDb {
    /// Open a file-backed database. Throws on error.
    #[napi(constructor)]
    pub fn new(path: String) -> Result<Self> {
        let db = minigraf::Minigraf::open(&path)
            .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
        Ok(Self {
            inner: Arc::new(Mutex::new(db)),
        })
    }

    /// Open an in-memory database. Throws on error.
    #[napi(factory)]
    pub fn in_memory() -> Result<Self> {
        let db = minigraf::Minigraf::in_memory()
            .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
        Ok(Self {
            inner: Arc::new(Mutex::new(db)),
        })
    }

    /// Execute a Datalog string. Returns a JSON string. Throws on error.
    #[napi]
    pub fn execute(&self, datalog: String) -> Result<String> {
        let result = self
            .inner
            .lock()
            .map_err(|_| Error::new(Status::GenericFailure, "mutex poisoned"))?
            .execute(&datalog)
            .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?;
        Ok(query_result_to_json(result))
    }

    /// Flush the WAL to disk. Throws on error.
    #[napi]
    pub fn checkpoint(&self) -> Result<()> {
        self.inner
            .lock()
            .map_err(|_| Error::new(Status::GenericFailure, "mutex poisoned"))?
            .checkpoint()
            .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))
    }
}
```

- [ ] **Step 2: Build the addon locally to check for compilation errors**

```bash
cd minigraf-node
npm install
npx @napi-rs/cli build --platform
```

If `@napi-rs/cli` is not installed, first: `npm install --save-dev @napi-rs/cli`

Expected: produces `minigraf.linux-x64-gnu.node` (or platform equivalent) in the `minigraf-node/` directory.

- [ ] **Step 3: Commit**

```bash
git add minigraf-node/src/lib.rs
git commit -m "feat(node): implement napi-rs MiniGrafDb (open, in_memory, execute, checkpoint)"
```

---

## Task 3: Create npm package files

**Files:**
- Create: `minigraf-node/package.json`
- Create: `minigraf-node/index.js`
- Create: `minigraf-node/index.d.ts`
- Create: `minigraf-node/test/basic.test.mjs`

- [ ] **Step 1: Create `minigraf-node/package.json`**

```json
{
  "name": "minigraf",
  "version": "0.25.0",
  "description": "Zero-config, single-file, embedded graph database with bi-temporal Datalog queries",
  "main": "index.js",
  "types": "index.d.ts",
  "license": "MIT OR Apache-2.0",
  "repository": {
    "type": "git",
    "url": "https://github.com/project-minigraf/minigraf.git"
  },
  "keywords": ["graph", "datalog", "bitemporal", "embedded", "database"],
  "napi": {
    "name": "minigraf",
    "triples": {
      "defaults": true,
      "additional": [
        "aarch64-unknown-linux-gnu",
        "universal-apple-darwin"
      ]
    }
  },
  "scripts": {
    "build": "napi build --platform --release",
    "test": "node --test test/basic.test.mjs"
  },
  "devDependencies": {
    "@napi-rs/cli": "^2.18.0"
  },
  "optionalDependencies": {
    "@minigraf/linux-x64-gnu": "0.25.0",
    "@minigraf/linux-arm64-gnu": "0.25.0",
    "@minigraf/darwin-universal": "0.25.0",
    "@minigraf/win32-x64-msvc": "0.25.0"
  }
}
```

- [ ] **Step 2: Create `minigraf-node/index.js`**

```javascript
'use strict'

const { platform, arch } = process

let nativeBinding = null
const loadErrors = []

if (platform === 'linux') {
  if (arch === 'x64') {
    try { nativeBinding = require('@minigraf/linux-x64-gnu') }
    catch (e) { loadErrors.push(e) }
  } else if (arch === 'arm64') {
    try { nativeBinding = require('@minigraf/linux-arm64-gnu') }
    catch (e) { loadErrors.push(e) }
  }
} else if (platform === 'darwin') {
  try { nativeBinding = require('@minigraf/darwin-universal') }
  catch (e) { loadErrors.push(e) }
} else if (platform === 'win32') {
  if (arch === 'x64') {
    try { nativeBinding = require('@minigraf/win32-x64-msvc') }
    catch (e) { loadErrors.push(e) }
  }
}

if (!nativeBinding) {
  const errorMessages = loadErrors.map(e => e.message).join('\n')
  throw new Error(
    `Failed to load native Minigraf addon for ${platform}-${arch}.\n` +
    `This platform may not be supported yet.\n` +
    (errorMessages ? `Errors:\n${errorMessages}` : '')
  )
}

module.exports = nativeBinding
```

- [ ] **Step 3: Generate `index.d.ts`**

After building the addon (Task 2 Step 2), generate TypeScript definitions:

```bash
cd minigraf-node
npx @napi-rs/cli dts
```

Expected output in `minigraf-node/index.d.ts`:

```typescript
/* tslint:disable */
/* eslint-disable */

export class MiniGrafDb {
  constructor(path: string)
  static inMemory(): MiniGrafDb
  execute(datalog: string): string
  checkpoint(): void
}
```

If the generated file matches this shape, commit it. If `napi dts` isn't available in the CLI version, write the above manually — it accurately reflects the Rust API.

- [ ] **Step 4: Create `minigraf-node/test/basic.test.mjs`**

```javascript
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { MiniGrafDb } from '../index.js'

test('open in-memory database', () => {
  const db = MiniGrafDb.inMemory()
  assert.ok(db, 'expected non-null db')
})

test('transact and query', () => {
  const db = MiniGrafDb.inMemory()
  const txJson = db.execute('(transact [[:alice :name "Alice"]])')
  const tx = JSON.parse(txJson)
  assert.ok('transacted' in tx, 'expected transacted key')

  const queryJson = db.execute('(query [:find ?n :where [?e :name ?n]])')
  const qr = JSON.parse(queryJson)
  assert.equal(qr.results[0][0], 'Alice')
})

test('invalid datalog throws', () => {
  const db = MiniGrafDb.inMemory()
  assert.throws(() => db.execute('not valid datalog !!!'))
})

test('file-backed roundtrip', async (t) => {
  const os = await import('node:os')
  const path = await import('node:path')
  const fs = await import('node:fs')

  const tmpDir = os.default.tmpdir()
  const dbPath = path.default.join(tmpDir, `minigraf_node_test_${Date.now()}.graph`)
  const walPath = dbPath + '.wal'

  t.after(() => {
    try { fs.default.unlinkSync(dbPath) } catch {}
    try { fs.default.unlinkSync(walPath) } catch {}
  })

  {
    const db = new MiniGrafDb(dbPath)
    db.execute('(transact [[:bob :name "Bob"]])')
    db.checkpoint()
  }

  const db2 = new MiniGrafDb(dbPath)
  const qr = JSON.parse(db2.execute('(query [:find ?n :where [?e :name ?n]])'))
  assert.equal(qr.results[0][0], 'Bob')
})
```

> **Note:** The `MiniGrafDb` file-backed constructor will trigger the platform binary load via `index.js`. This test will fail locally unless the `.node` binary is present. Build it first with `npm run build` in `minigraf-node/`.

- [ ] **Step 5: Commit**

```bash
git add minigraf-node/package.json minigraf-node/index.js minigraf-node/index.d.ts \
        minigraf-node/test/
git commit -m "feat(node): add npm package files, TypeScript types, and node:test suite"
```

---

## Task 4: Add PR CI workflow

**Files:**
- Create: `.github/workflows/node-ci.yml`

- [ ] **Step 1: Create `.github/workflows/node-ci.yml`**

```yaml
name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Node.js tests (${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, ubuntu-24.04-arm, macos-14, windows-latest]

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install npm dependencies
        working-directory: minigraf-node
        run: npm install

      - name: Build native addon
        working-directory: minigraf-node
        run: npx @napi-rs/cli build --platform --release

      - name: Run tests
        working-directory: minigraf-node
        run: node --test test/basic.test.mjs
```

- [ ] **Step 2: Commit**

```bash
git add .github/workflows/node-ci.yml
git commit -m "ci(node): add PR test matrix for Node.js addon (4 platforms)"
```

---

## Task 5: Add release workflow

**Files:**
- Create: `.github/workflows/node-release.yml`

- [ ] **Step 1: Create `.github/workflows/node-release.yml`**

```yaml
name: Node.js Release

on:
  workflow_call:
    inputs:
      tag:
        required: true
        type: string
  workflow_dispatch:
    inputs:
      tag:
        required: true
        type: string

jobs:
  build:
    name: Build .node binary (${{ matrix.settings.host }})
    runs-on: ${{ matrix.settings.host }}
    strategy:
      fail-fast: false
      matrix:
        settings:
          - host: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            npm-package: '@minigraf/linux-x64-gnu'
          - host: ubuntu-24.04-arm
            target: aarch64-unknown-linux-gnu
            npm-package: '@minigraf/linux-arm64-gnu'
          - host: macos-14
            target: universal-apple-darwin
            npm-package: '@minigraf/darwin-universal'
          - host: windows-latest
            target: x86_64-pc-windows-msvc
            npm-package: '@minigraf/win32-x64-msvc'

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.settings.target == 'universal-apple-darwin' && 'aarch64-apple-darwin x86_64-apple-darwin' || matrix.settings.target }}

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - name: Install npm dependencies
        working-directory: minigraf-node
        run: npm install

      - name: Build native addon
        working-directory: minigraf-node
        run: npx @napi-rs/cli build --platform --release --target ${{ matrix.settings.target }}

      - name: Upload .node binary
        uses: actions/upload-artifact@v4
        with:
          name: node-binary-${{ matrix.settings.npm-package }}
          path: minigraf-node/*.node

  publish:
    name: Publish to npm
    needs: build
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - name: Download all binaries
        uses: actions/download-artifact@v4
        with:
          pattern: node-binary-*
          path: minigraf-node
          merge-multiple: true

      - name: Publish platform packages
        working-directory: minigraf-node
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npx @napi-rs/cli publish --prefix packages

      - name: Publish main minigraf package
        working-directory: minigraf-node
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npm publish --access public
```

- [ ] **Step 2: Commit**

```bash
git add .github/workflows/node-release.yml
git commit -m "ci(node): add release workflow — platform .node binaries + npm publish"
```

---

## Task 6: Bump version and update docs

**Files:**
- Modify: `Cargo.toml` (root)
- Modify: `minigraf-node/Cargo.toml`
- Modify: `minigraf-node/package.json`
- Modify: `CHANGELOG.md`
- Modify: `ROADMAP.md`

- [ ] **Step 1: Bump Rust crate versions to `0.25.0`**

In `Cargo.toml` (root): `version = "0.25.0"`
In `minigraf-node/Cargo.toml`: `version = "0.25.0"` (already set — verify)

- [ ] **Step 2: Verify `minigraf-node/package.json` version**

Check `"version": "0.25.0"` — already set in Task 3. Verify it matches.

- [ ] **Step 3: Run `cargo check`**

```bash
cargo check --workspace
```

Expected: no errors.

- [ ] **Step 4: Add CHANGELOG entry**

```markdown
## [0.25.0] — 2026-04-XX

### Added
- **Phase 8.3d**: Node.js bindings published to npm as `minigraf`.
  Install with `npm install minigraf`. No build step required — prebuilt
  `.node` binaries for Linux x86_64/aarch64, macOS universal2, Windows x86_64.
  API: `new MiniGrafDb(path)`, `MiniGrafDb.inMemory()`, `.execute(datalog)`,
  `.checkpoint()`. Full TypeScript definitions included.
```

- [ ] **Step 5: Mark 8.3d complete in ROADMAP.md**

Update Phase 8.3d status to `✅ COMPLETE` and mark all of Phase 8.3 complete.

- [ ] **Step 6: Commit and tag**

```bash
git add Cargo.toml minigraf-node/Cargo.toml minigraf-node/package.json \
        CHANGELOG.md ROADMAP.md
git commit -m "chore(release): bump version to v0.25.0 — Phase 8.3d Node.js bindings"
git tag -a v0.25.0 -m "Phase 8.3d complete — Node.js bindings published to npm; Phase 8.3 complete"
git push origin v0.25.0
```
