diff --git a/.changeset/libsql-termux-shim.md b/.changeset/libsql-termux-shim.md new file mode 100644 index 0000000..1b3fe8f --- /dev/null +++ b/.changeset/libsql-termux-shim.md @@ -0,0 +1,9 @@ +--- +'@gtbuchanan/libsql-termux-shim': minor +--- + +Add `@gtbuchanan/libsql-termux-shim`, a stand-in for libsql's native binding +implemented on `node:sqlite`. libsql publishes no `@libsql/android-arm64`, so +dependents such as promptfoo fail at startup on Termux; aliasing the missing +target to this package lets them run. Local databases only — embedded replicas +throw. diff --git a/AGENTS.md b/AGENTS.md index 1a664b5..7541296 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ packages/ eslint-plugin-md-frontmatter/ — @gtbuchanan/eslint-plugin-md-frontmatter (Markdown frontmatter validation via JSON Schema) eslint-plugin-yamllint/ — @gtbuchanan/eslint-plugin-yamllint (yamllint gap rules via ESLint) hk-config/ — @gtbuchanan/hk-config (shared hk Pkl preset; private to npm, published as a GitHub-release Pkl package). Defaults.pkl + sync-generated PklProject + libsql-termux-shim/ — @gtbuchanan/libsql-termux-shim (libsql native-binding shim on node:sqlite for Termux/Android, os: ["android"]) pnpm-termux-shim/ — @gtbuchanan/pnpm-termux-shim (pnpm bin shim for Termux/Android, os: ["android"]) tsconfig/ — @gtbuchanan/tsconfig (shared base tsconfig.json) vitest-config/ — @gtbuchanan/vitest-config (configurePackage, configureGlobal, + e2e variants) diff --git a/README.md b/README.md index fe62c1a..0af1795 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Shared build configuration monorepo for JavaScript/TypeScript projects. | [@gtbuchanan/eslint-plugin-md-frontmatter](packages/eslint-plugin-md-frontmatter) | ESLint plugin validating Markdown frontmatter via JSON Schema | | [@gtbuchanan/eslint-plugin-yamllint](packages/eslint-plugin-yamllint) | ESLint plugin for yamllint gap rules | | [@gtbuchanan/hk-config](packages/hk-config) | Shared hk pre-commit preset (Pkl, GitHub-release published) | +| [@gtbuchanan/libsql-termux-shim](packages/libsql-termux-shim) | libsql binding shim for Termux/Android (`os: ["android"]`) | | [@gtbuchanan/pnpm-termux-shim](packages/pnpm-termux-shim) | pnpm bin shim for Termux/Android (`os: ["android"]`) | | [@gtbuchanan/tsconfig](packages/tsconfig) | Shared TypeScript base configuration | | [@gtbuchanan/vitest-config](packages/vitest-config) | Shared Vitest configuration | diff --git a/codecov.yml b/codecov.yml index 5447f92..0b9c621 100644 --- a/codecov.yml +++ b/codecov.yml @@ -19,6 +19,11 @@ component_management: name: test-utils paths: - packages/test-utils/src/** + - component_id: libsql-termux-shim + name: libsql-termux-shim + paths: + - packages/libsql-termux-shim/scripts/** + - packages/libsql-termux-shim/src/** - component_id: eslint-plugin-yamllint name: eslint-plugin-yamllint paths: @@ -78,6 +83,10 @@ flags: carryforward: true paths: - packages/eslint-plugin-yamllint/ + libsql-termux-shim: + carryforward: true + paths: + - packages/libsql-termux-shim/ test-utils: carryforward: true paths: diff --git a/packages/libsql-termux-shim/README.md b/packages/libsql-termux-shim/README.md new file mode 100644 index 0000000..dbe6a17 --- /dev/null +++ b/packages/libsql-termux-shim/README.md @@ -0,0 +1,99 @@ +# @gtbuchanan/libsql-termux-shim + +Stand-in for [libsql](https://github.com/tursodatabase/libsql-js)'s native +binding on Termux/Android, implemented on Node's built-in `node:sqlite`. + +## Why + +libsql publishes prebuilt bindings for darwin, linux, and win32 only — there is +no `@libsql/android-arm64`. Anything that reaches libsql therefore fails at +startup on Termux. [promptfoo](https://promptfoo.dev) is the motivating case: its +results database is mandatory and reached through drizzle, so it dies before +running a single eval. + +```text +Database migration failed: Cannot find module '@libsql/android-arm64' +``` + +Node 24 ships SQLite in core, so the binding can be reimplemented in JavaScript +rather than cross-compiled. + +## Install + +Alias the missing target, gated to Android so every other platform keeps the real +binding: + +```json +{ + "optionalDependencies": { + "@libsql/android-arm64": "npm:@gtbuchanan/libsql-termux-shim@^0.1.0" + } +} +``` + +Node resolves `require("@libsql/android-arm64")` from inside libsql by walking up +to the project's `node_modules`, so a root-level install is found even under +pnpm's isolated layout. + +`libsql` itself declares an `os` allowlist that omits android, which makes pnpm +refuse to resolve the workspace before it ever reaches this shim. Ungate it in +`.pnpmfile.cjs` — in both hooks, since `readPackage` ungates resolution while +`afterAllResolved` drops the field pnpm records in the lockfile and rechecks on +every later install: + +```js +const readPackage = (pkg) => { + if (pkg.name !== 'libsql') return pkg; + + const { os, ...ungated } = pkg; + return ungated; +}; + +/* Lockfile keys are `@`, and the name may itself be scoped. */ +const nameOf = (id) => id.slice(0, id.lastIndexOf('@')); + +const afterAllResolved = (lockFile) => { + for (const [id, pkg] of Object.entries(lockFile.packages ?? {})) { + if (nameOf(id) === 'libsql') delete pkg.os; + } + + return lockFile; +}; + +module.exports = { hooks: { afterAllResolved, readPackage } }; +``` + +## Scope + +Local databases only. Embedded replicas (`syncUrl`, `sync()`, `syncUntil()`) need +libsql's replication protocol, which has no `node:sqlite` equivalent, so those +entry points throw rather than silently misbehave. + +Extension loading throws for a different reason: libsql permits it per call, but +`node:sqlite` decides it when the connection is constructed. Opting every database +in to `allowExtension` so an unused call could work would trade real capability +for hypothetical fidelity, so the shim reports the gap instead. + +## How it works + +libsql's JS wrapper calls each native function as `fn.call(handle, ...)`, where +the handle is whatever `databaseOpen` or `databasePrepareSync` returned. Both +sides belong to the binding, so plain objects serve as handles. +`node:sqlite`'s `DatabaseSync`/`StatementSync` are synchronous like libsql's +`*Sync` natives, and `run()` already returns libsql's +`{ changes, lastInsertRowid }` shape, so most of the mapping is direct. + +Three places where it isn't, all load-bearing: + +- **`raw(true)` must throw for a statement returning no columns.** + `@libsql/client` calls it inside a `try`/`catch` purely to detect whether a + statement yields rows, routing `BEGIN`/`COMMIT`/`INSERT` to `run()` when it + throws. Succeeding sends `BEGIN` down the query path, and the client then fails + the batch with `TRANSACTION_CLOSED`. +- **Transaction state is tracked from the SQL.** `node:sqlite` exposes no + autocommit flag. Both entry points matter: libsql's own `transaction()` issues + `BEGIN` through `exec()`, while `@libsql/client` issues it through + `prepare().run()`. +- **Rows are rebuilt as plain objects.** `node:sqlite` returns null-prototype + rows where the real binding returns ordinary ones, a difference that otherwise + leaks to anything inspecting a row's prototype. diff --git a/packages/libsql-termux-shim/eslint.config.ts b/packages/libsql-termux-shim/eslint.config.ts new file mode 100644 index 0000000..69394b3 --- /dev/null +++ b/packages/libsql-termux-shim/eslint.config.ts @@ -0,0 +1,5 @@ +import { configure } from '@gtbuchanan/eslint-config'; + +export default configure({ + tsconfigRootDir: import.meta.dirname, +}); diff --git a/packages/libsql-termux-shim/package.json b/packages/libsql-termux-shim/package.json new file mode 100644 index 0000000..fa5d4d6 --- /dev/null +++ b/packages/libsql-termux-shim/package.json @@ -0,0 +1,47 @@ +{ + "name": "@gtbuchanan/libsql-termux-shim", + "version": "0.0.0", + "description": "libsql native-binding stand-in for Termux/Android, implemented on node:sqlite, so libsql-dependent tools (e.g. promptfoo) run where libsql ships no prebuilt binding", + "homepage": "https://github.com/gtbuchanan/tooling/tree/main/packages/libsql-termux-shim", + "bugs": "https://github.com/gtbuchanan/tooling/issues", + "repository": { + "type": "git", + "url": "https://github.com/gtbuchanan/tooling.git", + "directory": "packages/libsql-termux-shim" + }, + "license": "MIT", + "type": "module", + "imports": { + "#src/*": "./src/*" + }, + "main": "./src/index.cjs", + "files": [ + "src" + ], + "scripts": { + "compile:ts": "node --experimental-strip-types scripts/build.ts", + "coverage:codecov:upload": "pnpm run gtb task coverage:codecov:upload", + "coverage:vitest:merge": "pnpm run gtb task coverage:vitest:merge", + "gtb": "node --experimental-strip-types ../../packages/cli/bin/gtb.ts", + "lint:eslint": "pnpm run gtb task lint:eslint", + "pack:npm": "pnpm run gtb task pack:npm", + "test:vitest:fast": "pnpm run gtb task test:vitest:fast", + "test:vitest:slow": "pnpm run gtb task test:vitest:slow", + "typecheck:ts": "pnpm run gtb task typecheck:ts" + }, + "devDependencies": { + "@gtbuchanan/eslint-config": "workspace:*", + "@gtbuchanan/tsconfig": "workspace:*", + "@gtbuchanan/vitest-config": "workspace:*" + }, + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "directory": "dist/source", + "linkDirectory": false, + "os": [ + "android" + ] + } +} diff --git a/packages/libsql-termux-shim/scripts/build.ts b/packages/libsql-termux-shim/scripts/build.ts new file mode 100644 index 0000000..c16f988 --- /dev/null +++ b/packages/libsql-termux-shim/scripts/build.ts @@ -0,0 +1,21 @@ +/** + * Builds the publishable layout under `dist/source/`. The shim is hand-written + * CommonJS rather than compiled TypeScript — it has to be `require()`-able by + * libsql's own CJS wrapper — so this copies it verbatim instead of running tsc. + * `publishConfig.directory` is `dist/source`, so `package.json`'s + * `./src/index.cjs` main resolves identically in the workspace and the tarball. + * Mirrors the convention used by other non-TypeScript packages in this repo + * (e.g. `@gtbuchanan/pnpm-termux-shim`). + */ + +import { copyFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; + +const pkgDir = path.join(import.meta.dirname, '..'); +const outSrcDir = path.join(pkgDir, 'dist', 'source', 'src'); + +mkdirSync(outSrcDir, { recursive: true }); +copyFileSync( + path.join(pkgDir, 'src', 'index.cjs'), + path.join(outSrcDir, 'index.cjs'), +); diff --git a/packages/libsql-termux-shim/src/index.cjs b/packages/libsql-termux-shim/src/index.cjs new file mode 100644 index 0000000..9613937 --- /dev/null +++ b/packages/libsql-termux-shim/src/index.cjs @@ -0,0 +1,421 @@ +/* + * Stand-in for libsql's native binding on Termux/Android, implemented on Node's + * built-in `node:sqlite`. + * + * libsql publishes prebuilt bindings for darwin/linux/win32 only, so + * `require("@libsql/android-arm64")` fails on Termux and every dependent dies at + * startup — for promptfoo, before it can run a single eval, because its results + * database is mandatory and reached through drizzle. + * + * This works because libsql's JS wrapper calls each native function as + * `fn.call(handle, ...)` where the handle is whatever `databaseOpen` or + * `databasePrepareSync` returned. Both sides are ours, so plain objects serve as + * handles. `node:sqlite`'s `DatabaseSync`/`StatementSync` are synchronous like + * libsql's `*Sync` natives and `run()` already returns libsql's + * `{ changes, lastInsertRowid }` shape, so most of the mapping is direct. + * + * Install it by aliasing the missing target, gated to Android so other platforms + * keep the real binding: + * + * "optionalDependencies": { + * "@libsql/android-arm64": "npm:@gtbuchanan/libsql-termux-shim@^0.1.0" + * } + * + * Local databases only. Embedded replicas need libsql's replication protocol, + * which has no `node:sqlite` equivalent, so those entry points throw. + * + * @module + */ + +/* eslint-disable unicorn/no-this-outside-of-class, unicorn/consistent-boolean-name -- + Both are dictated by the binding contract, module-wide. libsql invokes every + native function as `fn.call(handle, ...)`, so `this` is the handle and these + cannot become class methods without breaking the call convention. The exported + names (`databaseInTransaction`, `statementIsReader`) are the identifiers + libsql destructures by, so they cannot be renamed to a boolean-name prefix. */ + +const { DatabaseSync } = require('node:sqlite'); + +/** + * @typedef {object} DatabaseHandle + * @property {boolean} inTransaction Whether a transaction is currently open. + * @property {boolean} safeIntegers Whether new statements read integers as BigInt. + * @property {import('node:sqlite').DatabaseSync} sqlite Underlying connection. + */ + +/** + * @typedef {object} StatementHandle + * @property {DatabaseHandle} db Owning database, for transaction bookkeeping. + * @property {boolean} raw Whether rows are returned as arrays. + * @property {string} sql Source text, used to detect transaction statements. + * @property {import('node:sqlite').StatementSync} stmt Prepared statement. + */ + +/** + * @typedef {object} RowsHandle + * @property {number} index Position of the next row to hand back. + * @property {unknown[]} rows Materialized result set. + */ + +/** + * @param {string} feature Operation that needs the real binding. + * @returns {never} + */ +const unsupported = (feature) => { + throw new Error( + `@gtbuchanan/libsql-termux-shim: ${feature} requires the native libsql binding`, + ); +}; + +/** + * `node:sqlite` exposes no autocommit flag, so transaction state is derived from + * the SQL itself. SQLite clears autocommit the moment `BEGIN` executes — even + * `DEFERRED`, which only defers lock acquisition — so this matches what the real + * binding reports. + * + * @param {DatabaseHandle} db Database whose state should be updated. + * @param {string} sql Statement that just executed. + * @returns {void} + */ +const noteTransaction = (db, sql) => { + const pattern = /^\s*(?BEGIN|COMMIT|END|ROLLBACK)\b/iv; + const verb = pattern.exec(sql)?.groups?.['verb']?.toUpperCase(); + /* eslint-disable-next-line no-param-reassign -- + The handle is mutable state owned by this binding: libsql reads it back + through databaseInTransaction, so the update has to land on the caller's + object rather than a copy. */ + if (verb === 'BEGIN') db.inTransaction = true; + /* eslint-disable-next-line no-param-reassign -- See above. */ + else if (verb !== undefined) db.inTransaction = false; +}; + +/** + * libsql passes either a single object of named parameters or a flat array of + * positional ones; `node:sqlite` wants the object as one argument and positional + * values spread. + * + * @param {unknown} params Bind parameters as libsql supplies them. + * @returns {unknown[]} Arguments to spread into a `node:sqlite` call. + */ +const bind = (params) => { + if (params === undefined) return []; + + return Array.isArray(params) ? params : [params]; +}; + +/** + * `node:sqlite` returns rows as null-prototype objects; the real binding returns + * ordinary ones. Left alone, that difference leaks to anything testing a row's + * prototype — `instanceof`, `constructor`, deep-equality helpers — so rows are + * rebuilt as plain objects. Array rows (raw mode) already match and pass through. + * + * @param {unknown} row Row as `node:sqlite` produced it. + * @returns {unknown} Row with the prototype libsql callers expect. + */ +const plainRow = (row) => { + if (typeof row !== 'object' || row === null || Array.isArray(row)) return row; + + return { ...row }; +}; + +/** + * Opens a local database. + * + * @param {string} path File path, or `:memory:`. + * @returns {DatabaseHandle} Handle the wrapper passes back as `this`. + */ +const databaseOpen = path => ({ + inTransaction: false, + safeIntegers: false, + sqlite: new DatabaseSync(path === '' ? ':memory:' : path), +}); + +/** + * Rejects embedded replicas, which need libsql's replication protocol. + * + * @returns {never} + */ +const databaseOpenWithSync = () => unsupported('embedded replicas (syncUrl)'); + +/** + * Reports whether a transaction is open. Called with the handle as an argument + * rather than as `this`, matching the wrapper's `inTransaction` getter. + * + * @param {DatabaseHandle} db Database to inspect. + * @returns {boolean} Whether a transaction is open. + */ +const databaseInTransaction = db => db.inTransaction; + +/** + * Executes one or more statements without returning rows. + * + * @this {DatabaseHandle} + * @param {string} sql Statements to execute. + * @returns {void} + */ +function databaseExecSync(sql) { + this.sqlite.exec(sql); + noteTransaction(this, sql); +} + +/** + * Prepares a statement. + * + * @this {DatabaseHandle} + * @param {string} sql Statement to prepare. + * @returns {StatementHandle} Handle the wrapper passes back as `this`. + */ +function databasePrepareSync(sql) { + const stmt = this.sqlite.prepare(sql); + stmt.setAllowBareNamedParameters(true); + if (this.safeIntegers) stmt.setReadBigInts(true); + + return { db: this, raw: false, sql, stmt }; +} + +/** + * Closes the database. + * + * @this {DatabaseHandle} + * @returns {void} + */ +function databaseClose() { + this.sqlite.close(); +} + +/** + * No-op. `node:sqlite` has no interrupt, and these queries are synchronous and + * local, so there is no in-flight work to cancel. + * + * @returns {void} + */ +/* eslint-disable-next-line @typescript-eslint/no-empty-function -- + Genuinely nothing to do: the binding must expose this entry point, but + node:sqlite runs every query synchronously, so none is ever in flight. */ +const databaseInterrupt = () => {}; + +/** + * Sets whether statements prepared afterwards read integers as BigInt. + * + * @this {DatabaseHandle} + * @param {boolean} toggle Whether to read integers as BigInt. + * @returns {void} + */ +function databaseDefaultSafeIntegers(toggle) { + this.safeIntegers = toggle; +} + +/** + * Installs an authorizer callback. + * + * @this {DatabaseHandle} + * @param {Parameters[0]} rules Authorizer. + * @returns {void} + */ +function databaseAuthorizer(rules) { + this.sqlite.setAuthorizer(rules); +} + +/** + * Rejects extension loading. + * + * libsql permits this per call, but `node:sqlite` decides it at construction: + * without `allowExtension` on the `DatabaseSync`, `enableLoadExtension` throws. + * Opting every database in to support a call libsql's own dependents don't make + * would trade real capability for hypothetical fidelity, so this reports the gap + * directly rather than surfacing node:sqlite's construction-time complaint. + * + * @returns {never} + */ +const databaseLoadExtension = () => unsupported('loadExtension()'); + +/** + * Rejects replica synchronization. + * + * @returns {never} + */ +const databaseSyncSync = () => unsupported('sync()'); + +/** + * Rejects replica synchronization. + * + * @returns {never} + */ +const databaseSyncUntilSync = () => unsupported('syncUntil()'); + +/** + * Reports no replication index, there being no replica. + * + * @returns {undefined} Always undefined. + */ +/* eslint-disable-next-line @typescript-eslint/no-empty-function -- + Local databases have no replication index; returning undefined is how libsql + signals "not a replica". */ +const databaseMaxWriteReplicationIndex = () => {}; + +/** + * Toggles array rows. + * + * Throwing for a statement that returns no columns is load-bearing rather than + * incidental: `@libsql/client` calls this inside a `try`/`catch` purely to detect + * whether a statement yields rows, and routes `BEGIN`/`COMMIT`/`INSERT` to + * `run()` when it throws. Succeeding here sends `BEGIN` down the query path, and + * the client then fails the batch with `TRANSACTION_CLOSED`. + * + * @this {StatementHandle} + * @param {boolean} raw Whether to return rows as arrays. + * @returns {void} + */ +function statementRaw(raw) { + if (this.stmt.columns().length === 0) { + throw new TypeError('The raw() method is only for statements that return data'); + } + this.raw = raw; + this.stmt.setReturnArrays(raw); +} + +/** + * Reports whether the statement returns rows. + * + * @this {StatementHandle} + * @returns {boolean} Whether the statement returns rows. + */ +function statementIsReader() { + return this.stmt.columns().length > 0; +} + +/** + * Executes the statement and returns its first row. + * + * @this {StatementHandle} + * @param {unknown} [params] Bind parameters. + * @returns {unknown} First row, or undefined when there are none. + */ +function statementGet(params) { + return plainRow(this.stmt.get(...bind(params))); +} + +/** + * Executes the statement without returning rows. + * + * @this {StatementHandle} + * @param {unknown} [params] Bind parameters. + * @returns {{changes: number | bigint, lastInsertRowid: number | bigint}} Write summary. + */ +function statementRun(params) { + const info = this.stmt.run(...bind(params)); + noteTransaction(this.db, this.sql); + + return info; +} + +/** + * Executes the statement and returns a handle for iterating its rows. + * + * @this {StatementHandle} + * @param {unknown} [params] Bind parameters. + * @returns {RowsHandle} Handle consumed by {@link rowsNext}. + */ +function statementRowsSync(params) { + return { index: 0, rows: this.stmt.all(...bind(params)).map(plainRow) }; +} + +/** + * @typedef {object} ColumnMetadata + * @property {string | null} database_name Source database, or null. + * @property {string | null} decl_type Declared column type, or null. + * @property {string} name Column name as returned. + * @property {string | null} origin_name Underlying column name, or null. + * @property {string | null} table_name Source table, or null. + */ + +/** + * Describes the statement's result columns, renamed to libsql's field names. + * + * @this {StatementHandle} + * @returns {ColumnMetadata[]} Column metadata. + */ +function statementColumns() { + /* eslint-disable unicorn/no-null -- + libsql reports absent column metadata as null, and callers (drizzle among + them) compare against it; substituting undefined would change the shape + this shim exists to reproduce. */ + return this.stmt.columns().map(column => ({ + database_name: column.database ?? null, + decl_type: column.type ?? null, + name: column.name, + origin_name: column.column ?? null, + table_name: column.table ?? null, + })); + /* eslint-enable unicorn/no-null -- Restore for the rest of the module. */ +} + +/** + * Sets whether this statement reads integers as BigInt. + * + * @this {StatementHandle} + * @param {boolean} toggle Whether to read integers as BigInt. + * @returns {void} + */ +function statementSafeIntegers(toggle) { + this.stmt.setReadBigInts(toggle); +} + +/** + * No-op, for the same reason as {@link databaseInterrupt}. + * + * @returns {void} + */ +/* eslint-disable-next-line @typescript-eslint/no-empty-function -- + See databaseInterrupt. */ +const statementInterrupt = () => {}; + +/** + * Fills the next batch of rows. + * + * The wrapper's `iterate()` reuses one fixed-size array and stops at the first + * falsy slot, so trailing slots must be cleared rather than left holding rows + * from an earlier batch. + * + * @this {RowsHandle} + * @param {unknown[]} buffer Caller-owned array to fill. + * @returns {void} + */ +function rowsNext(buffer) { + for (let slot = 0; slot < buffer.length; slot += 1) { + const isRemaining = this.index < this.rows.length; + /* eslint-disable-next-line no-param-reassign -- + Filling the caller's array *is* the contract: libsql's iterate() hands in + a reused 100-slot buffer and reads the rows back out of it. */ + buffer[slot] = isRemaining ? this.rows[this.index] : undefined; + if (isRemaining) this.index += 1; + } +} + +module.exports = { + databaseAuthorizer, + databaseClose, + databaseDefaultSafeIntegers, + databaseExecSync, + databaseInTransaction, + databaseInterrupt, + databaseLoadExtension, + databaseMaxWriteReplicationIndex, + databaseOpen, + databaseOpenWithSync, + databasePrepareSync, + databaseSyncSync, + databaseSyncUntilSync, + rowsNext, + statementColumns, + statementGet, + statementInterrupt, + statementIsReader, + statementRaw, + statementRowsSync, + statementRun, + statementSafeIntegers, +}; + +/* eslint-enable unicorn/no-this-outside-of-class, unicorn/consistent-boolean-name -- + End of the binding surface. */ diff --git a/packages/libsql-termux-shim/test/build.test.ts b/packages/libsql-termux-shim/test/build.test.ts new file mode 100644 index 0000000..88fd7dd --- /dev/null +++ b/packages/libsql-termux-shim/test/build.test.ts @@ -0,0 +1,23 @@ +import { readFileSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'vitest'; + +/* + * `pack:npm` does not catch a missing build artifact — it exits 0 and publishes a + * tarball containing only LICENSE, package.json, and README.md, so a shim that + * never got copied would ship broken and only fail in a consumer's `require`. + * This is the check that makes that loud. + */ +const pkgDir = path.join(import.meta.dirname, '..'); +const source = path.join(pkgDir, 'src', 'index.cjs'); +const built = path.join(pkgDir, 'dist', 'source', 'src', 'index.cjs'); + +describe('build', () => { + it('copies the shim into the publishable layout', async ({ expect }) => { + rmSync(built, { force: true }); + + await import('../scripts/build.ts'); + + expect(readFileSync(built, 'utf8')).toBe(readFileSync(source, 'utf8')); + }); +}); diff --git a/packages/libsql-termux-shim/test/index.test.ts b/packages/libsql-termux-shim/test/index.test.ts new file mode 100644 index 0000000..e8fc276 --- /dev/null +++ b/packages/libsql-termux-shim/test/index.test.ts @@ -0,0 +1,324 @@ +/* eslint-disable-next-line n/no-unsupported-features/node-builtins -- + Stale plugin data, not a real gap: eslint-plugin-n records node:sqlite as + experimental from 22.5.0 with no stabilization entry, so no engines range + clears it, yet Node emits no ExperimentalWarning for it on the versions this + package supports. Drop this once the plugin records stabilization. */ +import { constants as sqliteConstants } from 'node:sqlite'; +import { describe, it } from 'vitest'; +import shim from '#src/index.cjs'; + +/* + * libsql invokes every native function as `fn.call(handle, ...)`, where the + * handle is whatever databaseOpen/databasePrepareSync returned. These tests + * drive the binding the same way, so they exercise the contract libsql + * actually depends on rather than a friendlier wrapper of our own. + */ +type Database = ReturnType; +type Rows = ReturnType; + +const openDb = (): Database => { + const db = shim.databaseOpen(':memory:'); + shim.databaseExecSync.call(db, 'CREATE TABLE t(num INTEGER, txt TEXT)'); + return db; +}; + +const prepare = (db: Database, sql: string) => + shim.databasePrepareSync.call(db, sql); + +const insert = (db: Database, num: number, txt: string) => + shim.statementRun.call(prepare(db, 'INSERT INTO t VALUES(?,?)'), [num, txt]); + +/** Drains a rows handle the way libsql's iterate() does: 100-slot batches. */ +const drain = (rows: Rows): unknown[] => { + const out: unknown[] = []; + for (;;) { + const buffer = Array.from({ length: 100 }); + shim.rowsNext.call(rows, buffer); + const batch = buffer.filter(row => row !== undefined); + out.push(...batch); + if (batch.length < buffer.length) return out; + } +}; + +describe.concurrent('statement execution', () => { + it('round-trips inserted rows through the rows iterator', ({ expect }) => { + const db = openDb(); + insert(db, 1, 'x'); + insert(db, 2, 'y'); + + const rows = shim.statementRowsSync.call(prepare(db, 'SELECT * FROM t'), []); + + expect(drain(rows)).toStrictEqual([ + { num: 1, txt: 'x' }, + { num: 2, txt: 'y' }, + ]); + }); + + it('reports changes and lastInsertRowid from run', ({ expect }) => { + const db = openDb(); + + expect(insert(db, 7, 'seven')).toMatchObject({ changes: 1, lastInsertRowid: 1 }); + }); + + it('returns the first matching row from get', ({ expect }) => { + const db = openDb(); + insert(db, 3, 'three'); + + const stmt = prepare(db, 'SELECT txt FROM t WHERE num = ?'); + + expect(shim.statementGet.call(stmt, [3])).toStrictEqual({ txt: 'three' }); + }); + + it('returns undefined from get when nothing matches', ({ expect }) => { + const db = openDb(); + + const stmt = prepare(db, 'SELECT txt FROM t WHERE num = ?'); + + expect(shim.statementGet.call(stmt, [404])).toBeUndefined(); + }); + + it('binds named parameters passed as an object', ({ expect }) => { + const db = openDb(); + insert(db, 5, 'five'); + + const stmt = prepare(db, 'SELECT txt FROM t WHERE num = :num'); + + expect(shim.statementGet.call(stmt, { num: 5 })).toStrictEqual({ txt: 'five' }); + }); +}); + +describe.concurrent('raw mode', () => { + /* + * Load-bearing, not incidental: @libsql/client calls raw(true) inside a + * try/catch purely to decide whether a statement yields rows, routing it to + * run() when it throws. A shim that succeeds here sends BEGIN down the query + * path and the client then fails the whole batch with TRANSACTION_CLOSED. + */ + it('throws for a statement that returns no columns', ({ expect }) => { + const db = openDb(); + const stmt = prepare(db, 'INSERT INTO t VALUES(1, \'x\')'); + + const toggleRaw = () => { + shim.statementRaw.call(stmt, true); + }; + + expect(toggleRaw).toThrow(TypeError); + }); + + it('does not throw for a statement that returns columns', ({ expect }) => { + const db = openDb(); + const stmt = prepare(db, 'SELECT * FROM t'); + + const toggleRaw = () => { + shim.statementRaw.call(stmt, true); + }; + + expect(toggleRaw).not.toThrow(); + }); + + it('returns to object rows when raw mode is switched back off', ({ expect }) => { + const db = openDb(); + insert(db, 4, 'four'); + const stmt = prepare(db, 'SELECT num, txt FROM t'); + shim.statementRaw.call(stmt, true); + + shim.statementRaw.call(stmt, false); + + expect(drain(shim.statementRowsSync.call(stmt, []))) + .toStrictEqual([{ num: 4, txt: 'four' }]); + }); + + it('yields array rows once raw mode is on', ({ expect }) => { + const db = openDb(); + insert(db, 9, 'nine'); + const stmt = prepare(db, 'SELECT num, txt FROM t'); + shim.statementRaw.call(stmt, true); + + const rows = shim.statementRowsSync.call(stmt, []); + + expect(drain(rows)).toStrictEqual([[9, 'nine']]); + }); +}); + +describe.concurrent('transaction state', () => { + /* + * Both paths matter: libsql's own transaction() issues BEGIN via exec(), + * while @libsql/client issues it via prepare().run(). + */ + it('tracks a transaction opened through run', ({ expect }) => { + const db = openDb(); + + expect(shim.databaseInTransaction(db)).toBe(false); + + shim.statementRun.call(prepare(db, 'BEGIN DEFERRED'), []); + + expect(shim.databaseInTransaction(db)).toBe(true); + }); + + it('clears the flag on commit', ({ expect }) => { + const db = openDb(); + shim.statementRun.call(prepare(db, 'BEGIN DEFERRED'), []); + + shim.statementRun.call(prepare(db, 'COMMIT'), []); + + expect(shim.databaseInTransaction(db)).toBe(false); + }); + + it('tracks a transaction opened through exec', ({ expect }) => { + const db = openDb(); + + shim.databaseExecSync.call(db, 'BEGIN'); + + expect(shim.databaseInTransaction(db)).toBe(true); + }); + + it('clears the flag on rollback through exec', ({ expect }) => { + const db = openDb(); + shim.databaseExecSync.call(db, 'BEGIN'); + + shim.databaseExecSync.call(db, 'ROLLBACK'); + + expect(shim.databaseInTransaction(db)).toBe(false); + }); +}); + +describe.concurrent('metadata', () => { + it('maps column metadata to libsql field names', ({ expect }) => { + const db = openDb(); + + const stmt = prepare(db, 'SELECT num FROM t'); + + expect(shim.statementColumns.call(stmt)).toStrictEqual([ + { + database_name: 'main', + decl_type: 'INTEGER', + name: 'num', + origin_name: 'num', + table_name: 't', + }, + ]); + }); + + /* An expression column has no source database, table, or origin column. */ + it('reports absent column metadata as null', ({ expect }) => { + const db = openDb(); + + const stmt = prepare(db, 'SELECT 1 AS one'); + + /* eslint-disable unicorn/no-null -- + Asserting the shape libsql actually reports; see statementColumns. */ + expect(shim.statementColumns.call(stmt)).toStrictEqual([ + { + database_name: null, + decl_type: null, + name: 'one', + origin_name: null, + table_name: null, + }, + ]); + /* eslint-enable unicorn/no-null -- Restore for the rest of the suite. */ + }); + + it('reports whether a statement returns data', ({ expect }) => { + const db = openDb(); + + expect(shim.statementIsReader.call(prepare(db, 'SELECT * FROM t'))).toBe(true); + expect(shim.statementIsReader.call(prepare(db, 'DELETE FROM t'))).toBe(false); + }); + + it('reads integers as BigInt once safe integers are on', ({ expect }) => { + const db = openDb(); + insert(db, 42, 'answer'); + const stmt = prepare(db, 'SELECT num FROM t'); + + shim.statementSafeIntegers.call(stmt, true); + + expect(shim.statementGet.call(stmt, [])).toStrictEqual({ num: 42n }); + }); +}); + +describe.concurrent('connection lifecycle', () => { + it('treats an empty path as an in-memory database', ({ expect }) => { + const db = shim.databaseOpen(''); + + shim.databaseExecSync.call(db, 'CREATE TABLE t(num INTEGER)'); + + expect(shim.statementIsReader.call(prepare(db, 'SELECT num FROM t'))).toBe(true); + }); + + it('closes the connection', ({ expect }) => { + const db = openDb(); + + shim.databaseClose.call(db); + + expect(() => prepare(db, 'SELECT 1')).toThrow(/database is not open/v); + }); + + it('applies database-level safe integers to later statements', ({ expect }) => { + const db = openDb(); + insert(db, 8, 'eight'); + + shim.databaseDefaultSafeIntegers.call(db, true); + + expect(shim.statementGet.call(prepare(db, 'SELECT num FROM t'), [])) + .toStrictEqual({ num: 8n }); + }); + + it('installs an authorizer that can deny access', ({ expect }) => { + const db = openDb(); + + shim.databaseAuthorizer.call(db, () => sqliteConstants.SQLITE_DENY); + + expect(() => shim.statementGet.call(prepare(db, 'SELECT num FROM t'), [])) + .toThrow(/not authorized/v); + }); + + it('accepts a statement invoked without bind parameters', ({ expect }) => { + const db = openDb(); + insert(db, 6, 'six'); + + const stmt = prepare(db, 'SELECT num FROM t'); + + expect(shim.statementGet.call(stmt)).toStrictEqual({ num: 6 }); + }); +}); + +describe.concurrent('unsupported operations', () => { + it('rejects embedded replicas', ({ expect }) => { + expect(() => shim.databaseOpenWithSync()).toThrow(/native libsql binding/v); + }); + + it('rejects sync', ({ expect }) => { + const db = openDb(); + + expect(() => shim.databaseSyncSync.call(db)).toThrow(/native libsql binding/v); + }); + + it('rejects syncUntil', ({ expect }) => { + const db = openDb(); + + expect(() => shim.databaseSyncUntilSync.call(db)).toThrow(/native libsql binding/v); + }); + + /* + * node:sqlite gates extension loading at construction, so the shim cannot + * honor libsql's per-call form. Reporting that beats surfacing node:sqlite's + * construction-time error from an unrelated-looking call. + */ + it('rejects extension loading', ({ expect }) => { + const db = openDb(); + + expect(() => shim.databaseLoadExtension.call(db)) + .toThrow(/native libsql binding/v); + }); + + it('reports no replication index', ({ expect }) => { + const db = openDb(); + + /* eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -- + The shim returns nothing here by design; asserting that is the point. */ + const index = shim.databaseMaxWriteReplicationIndex.call(db); + + expect(index).toBeUndefined(); + }); +}); diff --git a/packages/libsql-termux-shim/tsconfig.build.json b/packages/libsql-termux-shim/tsconfig.build.json new file mode 100644 index 0000000..eab257d --- /dev/null +++ b/packages/libsql-termux-shim/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "outDir": "dist/source", + "rootDir": "." + }, + "extends": "../../tsconfig.build.json", + "include": [ + "bin", + "src" + ] +} diff --git a/packages/libsql-termux-shim/tsconfig.json b/packages/libsql-termux-shim/tsconfig.json new file mode 100644 index 0000000..f81db4a --- /dev/null +++ b/packages/libsql-termux-shim/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "noEmit": true + }, + "extends": "../../tsconfig.base.json", + "include": [ + "bin", + "scripts", + "src", + "test", + "e2e", + "*", + ".*" + ] +} diff --git a/packages/libsql-termux-shim/vitest.config.ts b/packages/libsql-termux-shim/vitest.config.ts new file mode 100644 index 0000000..17e2af7 --- /dev/null +++ b/packages/libsql-termux-shim/vitest.config.ts @@ -0,0 +1,3 @@ +import { configurePackage } from '@gtbuchanan/vitest-config/configure'; + +export default configurePackage(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d6e0bf..82924f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,19 @@ importers: specifier: workspace:* version: link:../tsconfig + packages/libsql-termux-shim: + devDependencies: + '@gtbuchanan/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@gtbuchanan/tsconfig': + specifier: workspace:* + version: link:../tsconfig + '@gtbuchanan/vitest-config': + specifier: workspace:* + version: link:../vitest-config + publishDirectory: dist/source + packages/pnpm-termux-shim: devDependencies: '@gtbuchanan/eslint-config':