From 9e3b0170e0e7865f6d65954156bc0a04623c992e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 22:16:56 +0000 Subject: [PATCH] Add Node.js integration: package, tests, and CI Ship an npm package that exports the path to the cr-sqlite loadable extension for use with node:sqlite (Node >= 22.5) or better-sqlite3. - Add package.json with ESM + CJS dual exports - Rewrite nodejs-helper.js with usage docs, add nodejs-helper.cjs - Add 12 integration tests covering extension loading, CRR operations, merge/sync between databases, and last-write-wins conflict resolution - Add GitHub Actions workflow (node-tests.yaml) running on ubuntu + macOS - Remove stale nodejs-install-helper.js and pnpm-lock.yaml (referenced deleted package.json and old vlcn-io release URLs) https://claude.ai/code/session_0156tM6LmjAC3xYz5xwETvym --- .github/workflows/node-tests.yaml | 31 ++++ core/nodejs-helper.cjs | 4 + core/nodejs-helper.d.ts | 1 + core/nodejs-helper.js | 23 ++- core/nodejs-install-helper.js | 133 ----------------- core/package.json | 43 ++++++ core/pnpm-lock.yaml | 209 -------------------------- core/test/integration.test.mjs | 240 ++++++++++++++++++++++++++++++ 8 files changed, 337 insertions(+), 347 deletions(-) create mode 100644 .github/workflows/node-tests.yaml create mode 100644 core/nodejs-helper.cjs delete mode 100644 core/nodejs-install-helper.js create mode 100644 core/package.json delete mode 100644 core/pnpm-lock.yaml create mode 100644 core/test/integration.test.mjs diff --git a/.github/workflows/node-tests.yaml b/.github/workflows/node-tests.yaml new file mode 100644 index 000000000..17311b5f9 --- /dev/null +++ b/.github/workflows/node-tests.yaml @@ -0,0 +1,31 @@ +on: + pull_request: + push: + branches: [pure-c-port] +name: "node-integration-tests" +jobs: + test: + name: Node.js on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + - os: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build extension + run: | + cd core + cmake -B build -DCMAKE_BUILD_TYPE=Release + cmake --build build + + - name: Run Node.js integration tests + run: | + cd core + node --test test/integration.test.mjs diff --git a/core/nodejs-helper.cjs b/core/nodejs-helper.cjs new file mode 100644 index 000000000..427cc8b4c --- /dev/null +++ b/core/nodejs-helper.cjs @@ -0,0 +1,4 @@ +// CJS entry point — see nodejs-helper.js for usage examples. +const { join } = require("node:path"); +const extensionPath = join(__dirname, "dist", "crsqlite"); +module.exports = { extensionPath }; diff --git a/core/nodejs-helper.d.ts b/core/nodejs-helper.d.ts index ffa4d09b7..fdaf26ded 100644 --- a/core/nodejs-helper.d.ts +++ b/core/nodejs-helper.d.ts @@ -1 +1,2 @@ +/** Absolute path to the cr-sqlite loadable extension (without file extension). */ export declare const extensionPath: string; diff --git a/core/nodejs-helper.js b/core/nodejs-helper.js index 294b78e88..f200f8730 100644 --- a/core/nodejs-helper.js +++ b/core/nodejs-helper.js @@ -1,7 +1,20 @@ -// Exports the path to the extension for those using -// crsqlite in a Node.js environment. -import * as url from "url"; -import { join } from "path"; -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +// Exports the path to the cr-sqlite loadable extension. +// Usage with node:sqlite (Node >= 22.5): +// +// import { extensionPath } from '@anthropic/crsqlite'; +// import { DatabaseSync } from 'node:sqlite'; +// const db = new DatabaseSync(':memory:', { allowExtension: true }); +// db.loadExtension(extensionPath); +// +// Usage with better-sqlite3: +// +// import { extensionPath } from '@anthropic/crsqlite'; +// import Database from 'better-sqlite3'; +// const db = new Database(':memory:'); +// db.loadExtension(extensionPath); +import { fileURLToPath } from "node:url"; +import { join, dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export const extensionPath = join(__dirname, "dist", "crsqlite"); diff --git a/core/nodejs-install-helper.js b/core/nodejs-install-helper.js deleted file mode 100644 index 9e0a54ee3..000000000 --- a/core/nodejs-install-helper.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * 1. Checks the current OS and CPU architecture - * 2. Copies pre-built binaries from the `binaries` directory to the `dist` directory if one exists - * 3. Otherwise, lets the standard install process via `make` take over - */ -import { join } from "path"; -import fs from "fs"; -import https from "https"; -import pkg from "./package.json" assert { type: "json" }; -import { exec } from "child_process"; -let { version } = pkg; - -let arch = process.arch; -let os = process.platform; -let ext = "unknown"; -version = "v" + version; - -if (process.env.CRSQLITE_NOPREBUILD) { - console.log("CRSQLITE_NOPREBUILD env variable is set. Building from source."); - buildFromSource(); -} else { - // todo: check msys? - if (["win32", "cygwin"].includes(process.platform)) { - os = "win"; - } - - // manual ovverides for testing - // arch = "x86_64"; - // os = "linux"; - // version = "prebuild-test.11"; - - switch (os) { - case "darwin": - ext = "dylib"; - break; - case "linux": - ext = "so"; - break; - case "win": - ext = "dll"; - break; - } - - switch (arch) { - case "x64": - arch = "x86_64"; - break; - case "arm64": - arch = "aarch64"; - break; - } - - const binaryUrl = `https://github.com/vlcn-io/cr-sqlite/releases/download/${version}/crsqlite-${os}-${arch}.zip`; - console.log(`Look for prebuilt binary from ${binaryUrl}`); - const distPath = join("dist", `crsqlite.${ext}`); - - if (!fs.existsSync(join(".", "dist"))) { - fs.mkdirSync(join(".", "dist")); - } - - if (fs.existsSync(distPath)) { - console.log("Binary already present and installed."); - process.exit(0); - } - - // download the file at the url, if it exists - let redirectCount = 0; - function get(url, cb) { - https.get(url, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - ++redirectCount; - if (redirectCount > 5) { - throw new Error("Too many redirects"); - } - get(res.headers.location, cb); - } else if (res.statusCode === 200) { - cb(res); - } else { - cb(null); - } - }); - } - - get(binaryUrl, (res) => { - if (res == null) { - console.log("No prebuilt binary available. Building from source."); - buildFromSource(); - return; - } - - const file = fs.createWriteStream(join("dist", "crsqlite.zip")); - res.pipe(file); - file.on("finish", () => { - file.close(); - console.log("Prebuilt binary downloaded"); - process.chdir(join(".", "dist")); - exec("unzip crsqlite.zip", (err, stdout, stderr) => { - if (err) { - console.log("Error extracting"); - console.log(err.message); - process.exit(1); - } - if (stderr) { - console.log(stderr); - } - console.log("Prebuilt binary extracted"); - process.exit(0); - }); - }); - // unzipper incorrectly unzips the file -- it becomes unloadable by sqlite. - // res.pipe(unzipper.Extract({ path: join(".", "dist") })).on("close", () => { - // console.log("Prebuilt binary downloaded"); - // process.exit(0); - // }); - }); -} - -function buildFromSource() { - console.log("Building from source"); - exec("make loadable", (err, stdout, stderr) => { - if (err) { - console.log("Error building from source"); - console.log(err.message); - process.exit(1); - } - if (stderr) { - console.log(stderr); - } - console.log("Built from source"); - console.log(stdout); - process.exit(0); - }); -} diff --git a/core/package.json b/core/package.json new file mode 100644 index 000000000..97b7b3ec2 --- /dev/null +++ b/core/package.json @@ -0,0 +1,43 @@ +{ + "name": "@anthropic/crsqlite", + "version": "0.1.0", + "description": "cr-sqlite loadable extension for Node.js — CRDT-based multi-master replication for SQLite", + "type": "module", + "main": "nodejs-helper.cjs", + "module": "nodejs-helper.js", + "exports": { + ".": { + "import": "./nodejs-helper.js", + "require": "./nodejs-helper.cjs" + } + }, + "types": "nodejs-helper.d.ts", + "files": [ + "nodejs-helper.js", + "nodejs-helper.cjs", + "nodejs-helper.d.ts", + "dist/" + ], + "scripts": { + "build": "cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build && mkdir -p dist && cp build/crsqlite.* dist/", + "test": "node --test test/integration.test.mjs", + "test:c": "cd build && ctest --output-on-failure" + }, + "engines": { + "node": ">=22.5.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/shards-lang/cr-sqlite.git", + "directory": "core" + }, + "keywords": [ + "sqlite", + "crdt", + "replication", + "sync", + "multi-master", + "extension" + ] +} diff --git a/core/pnpm-lock.yaml b/core/pnpm-lock.yaml deleted file mode 100644 index 8daaac1b4..000000000 --- a/core/pnpm-lock.yaml +++ /dev/null @@ -1,209 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - unzipper: - specifier: ^0.10.14 - version: 0.10.14 - -packages: - - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: false - - /big-integer@1.6.52: - resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} - engines: {node: '>=0.6'} - dev: false - - /binary@0.3.0: - resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} - dependencies: - buffers: 0.1.1 - chainsaw: 0.1.0 - dev: false - - /bluebird@3.4.7: - resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} - dev: false - - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: false - - /buffer-indexof-polyfill@1.0.2: - resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} - engines: {node: '>=0.10'} - dev: false - - /buffers@0.1.1: - resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} - engines: {node: '>=0.2.0'} - dev: false - - /chainsaw@0.1.0: - resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} - dependencies: - traverse: 0.3.9 - dev: false - - /concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} - dev: false - - /core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - dev: false - - /duplexer2@0.1.4: - resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} - dependencies: - readable-stream: 2.3.8 - dev: false - - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: false - - /fstream@1.0.12: - resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} - engines: {node: '>=0.6'} - dependencies: - graceful-fs: 4.2.11 - inherits: 2.0.4 - mkdirp: 0.5.6 - rimraf: 2.7.1 - dev: false - - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: false - - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: false - - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - dev: false - - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: false - - /isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - dev: false - - /listenercount@1.0.1: - resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} - dev: false - - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.11 - dev: false - - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: false - - /mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - dependencies: - minimist: 1.2.8 - dev: false - - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - dev: false - - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: false - - /process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - dev: false - - /readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - dev: false - - /rimraf@2.7.1: - resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - hasBin: true - dependencies: - glob: 7.2.3 - dev: false - - /safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: false - - /setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - dev: false - - /string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - dependencies: - safe-buffer: 5.1.2 - dev: false - - /traverse@0.3.9: - resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} - dev: false - - /unzipper@0.10.14: - resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} - dependencies: - big-integer: 1.6.52 - binary: 0.3.0 - bluebird: 3.4.7 - buffer-indexof-polyfill: 1.0.2 - duplexer2: 0.1.4 - fstream: 1.0.12 - graceful-fs: 4.2.11 - listenercount: 1.0.1 - readable-stream: 2.3.8 - setimmediate: 1.0.5 - dev: false - - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: false - - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: false diff --git a/core/test/integration.test.mjs b/core/test/integration.test.mjs new file mode 100644 index 000000000..bc6fee983 --- /dev/null +++ b/core/test/integration.test.mjs @@ -0,0 +1,240 @@ +// Integration tests for cr-sqlite with node:sqlite. +// Requires Node >= 22.5 and a built crsqlite extension in build/ or dist/. +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +// Prefer build/ (dev) over dist/ (packaged). +const buildPath = join(root, "build", "crsqlite"); +const distPath = join(root, "dist", "crsqlite"); +const extensionPath = existsSync(buildPath + ".so") || existsSync(buildPath + ".dylib") + ? buildPath + : distPath; + +function open() { + const db = new DatabaseSync(":memory:", { allowExtension: true }); + db.loadExtension(extensionPath); + return db; +} + +describe("cr-sqlite extension loading", () => { + it("registers crsql functions", () => { + const db = open(); + const funcs = db.prepare( + "SELECT name FROM pragma_function_list WHERE name LIKE 'crsql%'" + ).all(); + const names = funcs.map((r) => r.name); + assert.ok(names.includes("crsql_as_crr"), "crsql_as_crr should be registered"); + assert.ok(names.includes("crsql_site_id"), "crsql_site_id should be registered"); + assert.ok(names.includes("crsql_db_version"), "crsql_db_version should be registered"); + assert.ok(names.includes("crsql_finalize"), "crsql_finalize should be registered"); + db.exec("SELECT crsql_finalize()"); + db.close(); + }); + + it("returns a 16-byte site id", () => { + const db = open(); + const row = db.prepare("SELECT crsql_site_id() AS sid").get(); + // site_id is a 16-byte blob (UUID v4) + assert.equal(row.sid.byteLength, 16); + db.exec("SELECT crsql_finalize()"); + db.close(); + }); + + it("returns a commit sha", () => { + const db = open(); + const row = db.prepare("SELECT crsql_sha() AS sha").get(); + assert.ok(typeof row.sha === "string"); + assert.ok(row.sha.length > 0); + db.exec("SELECT crsql_finalize()"); + db.close(); + }); +}); + +describe("CRR creation and basic operations", () => { + let db; + + before(() => { + db = open(); + db.exec("CREATE TABLE todos (id INTEGER PRIMARY KEY NOT NULL, title TEXT, done INTEGER)"); + db.exec("SELECT crsql_as_crr('todos')"); + }); + + after(() => { + db.exec("SELECT crsql_finalize()"); + db.close(); + }); + + it("creates clock table", () => { + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE name LIKE '%__crsql_clock'" + ).all(); + assert.equal(tables.length, 1); + assert.equal(tables[0].name, "todos__crsql_clock"); + }); + + it("tracks inserts in crsql_changes", () => { + db.exec("INSERT INTO todos VALUES (1, 'buy milk', 0)"); + const changes = db.prepare("SELECT * FROM crsql_changes").all(); + // Should have changes for 'title' and 'done' columns + assert.ok(changes.length >= 2, `expected >= 2 changes, got ${changes.length}`); + const tables = new Set(changes.map((c) => c.table)); + assert.ok(tables.has("todos")); + }); + + it("tracks db_version as lamport clock", () => { + const v1 = db.prepare("SELECT crsql_db_version() AS v").get().v; + db.exec("INSERT INTO todos VALUES (2, 'walk dog', 1)"); + const v2 = db.prepare("SELECT crsql_db_version() AS v").get().v; + assert.ok(v2 > v1, `db_version should increase: ${v1} -> ${v2}`); + }); + + it("tracks updates in crsql_changes", () => { + db.exec("UPDATE todos SET done = 1 WHERE id = 1"); + const changes = db.prepare( + "SELECT * FROM crsql_changes WHERE [table] = 'todos' AND cid = 'done'" + ).all(); + const row1Change = changes.find((c) => c.val === 1 || c.val === "1"); + assert.ok(row1Change, "should have a change for done = 1"); + }); + + it("tracks deletes with causal length", () => { + db.exec("DELETE FROM todos WHERE id = 2"); + // After delete, there should be a sentinel entry with even cl (deleted) + const sentinel = db.prepare( + "SELECT cl FROM crsql_changes WHERE [table] = 'todos' AND cid = '-1'" + ).all(); + const deleted = sentinel.filter((r) => r.cl % 2 === 0); + assert.ok(deleted.length > 0, "deleted row should have even causal length"); + }); +}); + +describe("merge / sync", () => { + it("merges changes between two databases", () => { + const db1 = open(); + const db2 = open(); + + // Set up same schema on both + for (const db of [db1, db2]) { + db.exec("CREATE TABLE items (id INTEGER PRIMARY KEY NOT NULL, name TEXT)"); + db.exec("SELECT crsql_as_crr('items')"); + } + + // Insert different rows + db1.exec("INSERT INTO items VALUES (1, 'from-db1')"); + db2.exec("INSERT INTO items VALUES (2, 'from-db2')"); + + // Get changes from db1 and apply to db2 + const changes1 = db1.prepare("SELECT * FROM crsql_changes").all(); + const insertStmt = db2.prepare( + "INSERT INTO crsql_changes ([table], pk, cid, val, col_version, db_version, site_id, cl, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const c of changes1) { + insertStmt.run(c.table, c.pk, c.cid, c.val, c.col_version, c.db_version, c.site_id, c.cl, c.seq); + } + + // Get changes from db2 and apply to db1 + const changes2 = db2.prepare("SELECT * FROM crsql_changes WHERE db_version > 0").all(); + const insertStmt1 = db1.prepare( + "INSERT INTO crsql_changes ([table], pk, cid, val, col_version, db_version, site_id, cl, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const c of changes2) { + // Only apply changes from db2's site + const db2SiteId = db2.prepare("SELECT crsql_site_id() AS sid").get().sid; + const changeSiteId = c.site_id; + // site_id comparison: apply non-local changes + insertStmt1.run(c.table, c.pk, c.cid, c.val, c.col_version, c.db_version, c.site_id, c.cl, c.seq); + } + + // Both should now have both rows + const rows1 = db1.prepare("SELECT * FROM items ORDER BY id").all(); + const rows2 = db2.prepare("SELECT * FROM items ORDER BY id").all(); + + assert.equal(rows1.length, 2); + assert.equal(rows2.length, 2); + assert.equal(rows1[0].name, "from-db1"); + assert.equal(rows1[1].name, "from-db2"); + assert.equal(rows2[0].name, "from-db1"); + assert.equal(rows2[1].name, "from-db2"); + + for (const db of [db1, db2]) { + db.exec("SELECT crsql_finalize()"); + db.close(); + } + }); + + it("resolves concurrent updates with last-write-wins", () => { + const db1 = open(); + const db2 = open(); + + for (const db of [db1, db2]) { + db.exec("CREATE TABLE kv (key TEXT PRIMARY KEY NOT NULL, val TEXT)"); + db.exec("SELECT crsql_as_crr('kv')"); + } + + // Both write to the same key + db1.exec("INSERT INTO kv VALUES ('x', 'db1-first')"); + db2.exec("INSERT INTO kv VALUES ('x', 'db2-first')"); + + // Bump db1's version higher by doing more writes + db1.exec("UPDATE kv SET val = 'db1-second' WHERE key = 'x'"); + db1.exec("UPDATE kv SET val = 'db1-third' WHERE key = 'x'"); + + // Merge db1 -> db2 + const changes1 = db1.prepare("SELECT * FROM crsql_changes").all(); + const stmt = db2.prepare( + "INSERT INTO crsql_changes ([table], pk, cid, val, col_version, db_version, site_id, cl, seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const c of changes1) { + stmt.run(c.table, c.pk, c.cid, c.val, c.col_version, c.db_version, c.site_id, c.cl, c.seq); + } + + // db1's higher col_version should win + const result = db2.prepare("SELECT val FROM kv WHERE key = 'x'").get(); + assert.equal(result.val, "db1-third"); + + for (const db of [db1, db2]) { + db.exec("SELECT crsql_finalize()"); + db.close(); + } + }); +}); + +describe("crsql_as_table (undo CRR)", () => { + it("removes CRR tracking from a table", () => { + const db = open(); + db.exec("CREATE TABLE temp_crr (id INTEGER PRIMARY KEY NOT NULL, data TEXT)"); + db.exec("SELECT crsql_as_crr('temp_crr')"); + + // Clock table should exist + let clocks = db.prepare( + "SELECT name FROM sqlite_master WHERE name = 'temp_crr__crsql_clock'" + ).all(); + assert.equal(clocks.length, 1); + + // Undo + db.exec("SELECT crsql_as_table('temp_crr')"); + + clocks = db.prepare( + "SELECT name FROM sqlite_master WHERE name = 'temp_crr__crsql_clock'" + ).all(); + assert.equal(clocks.length, 0); + + db.exec("SELECT crsql_finalize()"); + db.close(); + }); +}); + +describe("helper module", () => { + it("exports extensionPath as a string", async () => { + const mod = await import("../nodejs-helper.js"); + assert.equal(typeof mod.extensionPath, "string"); + assert.ok(mod.extensionPath.endsWith("crsqlite")); + }); +});