From e42a2fce47b68df3fd2d7c6ce3ad82963e1409d4 Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Thu, 30 Jul 2026 07:44:28 -0500 Subject: [PATCH 1/3] Add @gtbuchanan/libsql-termux-shim package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsql publishes prebuilt native bindings for darwin, linux, and win32 only, so `require("@libsql/android-arm64")` fails on Termux and every dependent dies at startup. promptfoo is the motivating case: its results database is mandatory and reached through drizzle, so it cannot run a single eval on Android. No env var disables the database. Node ships SQLite in core, so reimplement the binding in JavaScript rather than cross-compile. libsql invokes each native function as `fn.call(handle, ...)` where the handle is whatever databaseOpen or databasePrepareSync returned, so both sides belong to the binding and plain objects serve as handles. DatabaseSync/StatementSync are synchronous like libsql's *Sync natives, and run() already returns the { changes, lastInsertRowid } shape, making most of the mapping direct. Three places where it is not, 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 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 derived from the SQL, there being no autocommit accessor. Both entry points matter: libsql's transaction() issues BEGIN through exec(), @libsql/client through prepare().run(). - Rows are rebuilt as plain objects, since node:sqlite returns null-prototype rows where the real binding returns ordinary ones. Scoped to local databases; embedded replicas need libsql's replication protocol and throw. Ships CommonJS because libsql's own wrapper is CJS, with `type: module` retained so the package's TypeScript files stay ESM. The two file-level lint suppressions are forced by the binding contract: `this` is the handle, and the exported names are what libsql destructures by. Consumers alias the missing target as an `os: ["android"]` optional dependency, and must also ungate libsql's own `os` allowlist in `.pnpmfile.cjs` — pnpm otherwise refuses to resolve before reaching the shim. See the package README. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/libsql-termux-shim.md | 9 + AGENTS.md | 1 + README.md | 1 + codecov.yml | 9 + packages/libsql-termux-shim/README.md | 80 ++++ packages/libsql-termux-shim/eslint.config.ts | 5 + packages/libsql-termux-shim/package.json | 47 ++ packages/libsql-termux-shim/scripts/build.ts | 21 + packages/libsql-termux-shim/src/index.cjs | 420 ++++++++++++++++++ .../libsql-termux-shim/test/index.test.ts | 212 +++++++++ .../libsql-termux-shim/tsconfig.build.json | 11 + packages/libsql-termux-shim/tsconfig.json | 15 + packages/libsql-termux-shim/vitest.config.ts | 3 + pnpm-lock.yaml | 13 + 14 files changed, 847 insertions(+) create mode 100644 .changeset/libsql-termux-shim.md create mode 100644 packages/libsql-termux-shim/README.md create mode 100644 packages/libsql-termux-shim/eslint.config.ts create mode 100644 packages/libsql-termux-shim/package.json create mode 100644 packages/libsql-termux-shim/scripts/build.ts create mode 100644 packages/libsql-termux-shim/src/index.cjs create mode 100644 packages/libsql-termux-shim/test/index.test.ts create mode 100644 packages/libsql-termux-shim/tsconfig.build.json create mode 100644 packages/libsql-termux-shim/tsconfig.json create mode 100644 packages/libsql-termux-shim/vitest.config.ts 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..b1c77dc --- /dev/null +++ b/packages/libsql-termux-shim/README.md @@ -0,0 +1,80 @@ +# @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; +}; +``` + +## 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. + +## 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..3d0127c --- /dev/null +++ b/packages/libsql-termux-shim/src/index.cjs @@ -0,0 +1,420 @@ +/* + * 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); +} + +/** + * Loads SQLite extensions. + * + * @this {DatabaseHandle} + * @param {...string} args Extension paths. + * @returns {void} + */ +function databaseLoadExtension(...args) { + this.sqlite.enableLoadExtension(true); + for (const arg of args) this.sqlite.loadExtension(arg); +} + +/** + * 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/index.test.ts b/packages/libsql-termux-shim/test/index.test.ts new file mode 100644 index 0000000..9d3bf06 --- /dev/null +++ b/packages/libsql-termux-shim/test/index.test.ts @@ -0,0 +1,212 @@ +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('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', + }, + ]); + }); + + 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('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); + }); +}); 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': From b5c349f4352834d568b19b6e202ce073762a95b2 Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Thu, 30 Jul 2026 08:31:14 -0500 Subject: [PATCH 2/3] Close libsql shim coverage and extension-loading gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged missing coverage. Chasing it surfaced two real problems rather than just untested lines. pack:npm does not fail when the build artifact is missing. Deleting dist/source/src/index.cjs and packing exits 0 and produces a tarball containing only LICENSE, package.json, and README.md — no shim at all. The package would publish completely broken and only fail inside a consumer's require. Nothing else asserts that copy happens, so scripts/build.ts earns a test: it clears the artifact, runs the script, and compares the result against the source byte for byte. databaseLoadExtension could never have worked. libsql permits extension loading per call, but node:sqlite decides it when the connection is constructed, so enableLoadExtension throws "disabled at database creation" for every database this shim opens. Opting every connection in to allowExtension so an unused call could work trades real capability for hypothetical fidelity, so the entry point now reports the gap the way the replica methods do. Also covers the paths that were merely untested — close, database-level safe integers, the authorizer, syncUntil, an empty path, a statement invoked without bind parameters, and the null column metadata an expression column produces — taking src and scripts to full line and branch coverage. The bind parameter is documented optional, which is what the shim already implemented. The node:sqlite import suppression records stale plugin data rather than a real gap: eslint-plugin-n lists the module experimental from 22.5.0 with no stabilization entry, so no engines range clears it, yet Node emits no ExperimentalWarning on the versions this package supports. Co-Authored-By: Claude Opus 5 (1M context) --- packages/libsql-termux-shim/README.md | 5 + packages/libsql-termux-shim/src/index.cjs | 23 ++-- .../libsql-termux-shim/test/build.test.ts | 23 ++++ .../libsql-termux-shim/test/index.test.ts | 100 ++++++++++++++++++ 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 packages/libsql-termux-shim/test/build.test.ts diff --git a/packages/libsql-termux-shim/README.md b/packages/libsql-termux-shim/README.md index b1c77dc..1134724 100644 --- a/packages/libsql-termux-shim/README.md +++ b/packages/libsql-termux-shim/README.md @@ -55,6 +55,11 @@ Local databases only. Embedded replicas (`syncUrl`, `sync()`, `syncUntil()`) nee 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 diff --git a/packages/libsql-termux-shim/src/index.cjs b/packages/libsql-termux-shim/src/index.cjs index 3d0127c..9613937 100644 --- a/packages/libsql-termux-shim/src/index.cjs +++ b/packages/libsql-termux-shim/src/index.cjs @@ -217,16 +217,17 @@ function databaseAuthorizer(rules) { } /** - * Loads SQLite extensions. + * Rejects extension loading. * - * @this {DatabaseHandle} - * @param {...string} args Extension paths. - * @returns {void} + * 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} */ -function databaseLoadExtension(...args) { - this.sqlite.enableLoadExtension(true); - for (const arg of args) this.sqlite.loadExtension(arg); -} +const databaseLoadExtension = () => unsupported('loadExtension()'); /** * Rejects replica synchronization. @@ -287,7 +288,7 @@ function statementIsReader() { * Executes the statement and returns its first row. * * @this {StatementHandle} - * @param {unknown} params Bind parameters. + * @param {unknown} [params] Bind parameters. * @returns {unknown} First row, or undefined when there are none. */ function statementGet(params) { @@ -298,7 +299,7 @@ function statementGet(params) { * Executes the statement without returning rows. * * @this {StatementHandle} - * @param {unknown} params Bind parameters. + * @param {unknown} [params] Bind parameters. * @returns {{changes: number | bigint, lastInsertRowid: number | bigint}} Write summary. */ function statementRun(params) { @@ -312,7 +313,7 @@ function statementRun(params) { * Executes the statement and returns a handle for iterating its rows. * * @this {StatementHandle} - * @param {unknown} params Bind parameters. + * @param {unknown} [params] Bind parameters. * @returns {RowsHandle} Handle consumed by {@link rowsNext}. */ function statementRowsSync(params) { 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 index 9d3bf06..43834f3 100644 --- a/packages/libsql-termux-shim/test/index.test.ts +++ b/packages/libsql-termux-shim/test/index.test.ts @@ -1,3 +1,9 @@ +/* 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'; @@ -181,6 +187,26 @@ describe.concurrent('metadata', () => { ]); }); + /* 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(); @@ -199,6 +225,52 @@ describe.concurrent('metadata', () => { }); }); +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); @@ -209,4 +281,32 @@ describe.concurrent('unsupported operations', () => { 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(); + }); }); From cbd216a23349d664b8b45627415fc25d7b6b43e3 Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Thu, 30 Jul 2026 08:37:14 -0500 Subject: [PATCH 3/3] Complete pnpmfile example and cover raw-mode reset Addresses review feedback on the shim. The README showed only `readPackage` while the prose said both hooks are required, which would have left a consumer's lockfile still carrying libsql's `os` field and failing the next install. The example now defines `afterAllResolved` and exports both. Adds the raw-mode reset case: `raw(false)` after `raw(true)` should return object rows again. That round trip was the one statementRaw behavior nothing exercised. Co-Authored-By: Claude Opus 5 (1M context) --- packages/libsql-termux-shim/README.md | 14 ++++++++++++++ packages/libsql-termux-shim/test/index.test.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/libsql-termux-shim/README.md b/packages/libsql-termux-shim/README.md index 1134724..dbe6a17 100644 --- a/packages/libsql-termux-shim/README.md +++ b/packages/libsql-termux-shim/README.md @@ -44,9 +44,23 @@ 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 diff --git a/packages/libsql-termux-shim/test/index.test.ts b/packages/libsql-termux-shim/test/index.test.ts index 43834f3..e8fc276 100644 --- a/packages/libsql-termux-shim/test/index.test.ts +++ b/packages/libsql-termux-shim/test/index.test.ts @@ -116,6 +116,18 @@ describe.concurrent('raw mode', () => { 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');