From c1e24fd7ecbe7058eb0dbfa2def91171e41bd9da Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 09:55:44 +0000 Subject: [PATCH] feat: Bun SQL, libsql-pg, bun:sqlite and better-sqlite3 (0.1.1) The fleet survey found these four drivers behind ~20 signup flows. libsql-pg has the @libsql/client API but speaks Postgres, so it is detected by protocol postgres rather than given SQLite DDL. Sync prepare() drivers get an adapter. Timestamp casts are gone: Postgres infers the type from the column. createProfileStore takes a dialect override for anything ambiguous. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- index.d.ts | 9 ++- package-lock.json | 193 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 56 ++++++++++--- src/cli.js | 2 +- src/store.js | 40 +++++++--- test/store.test.js | 31 +++++--- 7 files changed, 298 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d1406c4..ab7dbc2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ What the package gives a property: - **Validation and normalization.** `acme.com/about`, `https://www.Acme.com` and `@acme.com` all become `acme.com`. Any LinkedIn URL, `in/x` or bare handle becomes the canonical URL. A link in a name box is refused as spam. Names are never "corrected" (McDonald stays McDonald). - **Company from a work email.** A blank company domain is filled from the signup email unless it is a personal or relay mailbox (gmail, proton, passmail and so on). - **Signup form fields.** As an HTML string for server-rendered pages, and as a React component, with the right `autocomplete` tokens so browsers fill them. -- **Storage on whatever the property already runs:** postgres.js, node-postgres, libSQL/Turso, or supabase-js on the server. Either its own `user_profiles` table, or the columns added to an existing `profiles`/`users` table. +- **Storage on whatever the property already runs:** postgres.js, Bun SQL, node-postgres, libSQL/Turso, @profullstack/libsql-pg, bun:sqlite, better-sqlite3, or supabase-js on the server. Either its own `user_profiles` table, or the columns added to an existing `profiles`/`users` table. - **`GET`/`PUT`/`PATCH /api/v1/me/profile`** handlers on Web-standard Request/Response, for Next and Hono alike. - **A CLI and an MCP server** (`profile`), including `profile export` in contract order. diff --git a/index.d.ts b/index.d.ts index fdfa704..ca67167 100644 --- a/index.d.ts +++ b/index.d.ts @@ -89,6 +89,8 @@ export interface StoreOptions { table?: string; key?: string; keyType?: string; + /** Force the SQL dialect for a client that could be either. */ + dialect?: "postgres" | "sqlite"; } export interface ProfileStore { readonly table: string; @@ -105,14 +107,17 @@ export interface ProfileStore { }>; all(opts?: { limit?: number; since?: string | null }): AsyncGenerator; } -/** postgres.js sql, a pg Pool/Client, an @libsql/client, a server-side supabase-js client, or a DbAdapter. */ +/** + * postgres.js or Bun SQL, a pg Pool/Client, @libsql/client or @profullstack/libsql-pg, + * bun:sqlite or better-sqlite3, a server-side supabase-js client, or a DbAdapter. + */ export function createProfileStore(db: unknown, options?: StoreOptions): ProfileStore; export function migrate( dbOrStore: unknown, options?: StoreOptions, ): Promise<{ created: boolean; added: string[] }>; export function schemaSql(options?: StoreOptions & { dialect?: "postgres" | "sqlite" }): string; -export function adapt(db: unknown): DbAdapter | { dialect: "supabase"; client: unknown }; +export function adapt(db: unknown, dialect?: "postgres" | "sqlite"): DbAdapter | { dialect: "supabase"; client: unknown }; export interface SessionUser { id: string | number; diff --git a/package-lock.json b/package-lock.json index 2070dc6..d82b4dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@profullstack/profile", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@profullstack/profile", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "bin": { "profile": "src/cli.js" }, "devDependencies": { "@libsql/client": "^0.15.15", + "@profullstack/libsql-pg": "^0.1.4", "postgres": "^3.4.7", "react": "^19.1.0", "react-dom": "^19.1.0" @@ -228,6 +229,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@profullstack/libsql-pg": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@profullstack/libsql-pg/-/libsql-pg-0.1.4.tgz", + "integrity": "sha512-FB+rGGmM9Ua5ZqcrZQvjhagjDDIXZRj9mTC2wLsluD4MTk7/XcjHbOsHb6uMh5gEzhmH2yZz/yGpKCsMyVLujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@profullstack/libsql-pg": "^0.1.1", + "pg": "^8.13.0" + }, + "bin": { + "libsql-pg": "bin/libsql-pg.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@libsql/client": "^0.15.0" + }, + "peerDependencies": { + "@libsql/client": ">=0.5.0" + }, + "peerDependenciesMeta": { + "@libsql/client": { + "optional": true + } + } + }, "node_modules/@types/node": { "version": "26.6.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", @@ -385,6 +414,103 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/postgres": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", @@ -399,6 +525,49 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/promise-limit": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", @@ -436,6 +605,16 @@ "dev": true, "license": "MIT" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -474,6 +653,16 @@ "optional": true } } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index 0204d48..6841bd7 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,19 @@ { "name": "@profullstack/profile", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "description": "One user profile for every property: email, first_name, last_name, company_domain, job_title, linkedin_url. Validation, signup form fields, storage on Postgres/Turso/Supabase, HTTP handlers, CSV export, CLI and MCP.", - "keywords": ["profile", "signup", "user-profile", "lead", "postgres", "turso", "libsql", "supabase", "mcp"], + "keywords": [ + "profile", + "signup", + "user-profile", + "lead", + "postgres", + "turso", + "libsql", + "supabase", + "mcp" + ], "license": "MIT", "repository": { "type": "git", @@ -11,12 +21,27 @@ }, "homepage": "https://github.com/profullstack/profile#readme", "exports": { - ".": { "types": "./index.d.ts", "import": "./src/index.js" }, - "./react": { "types": "./react.d.ts", "import": "./src/react.js" }, - "./cli": { "import": "./src/cli.js" } + ".": { + "types": "./index.d.ts", + "import": "./src/index.js" + }, + "./react": { + "types": "./react.d.ts", + "import": "./src/react.js" + }, + "./cli": { + "import": "./src/cli.js" + } }, - "bin": { "profile": "./src/cli.js" }, - "files": ["src", "index.d.ts", "react.d.ts", "README.md"], + "bin": { + "profile": "./src/cli.js" + }, + "files": [ + "src", + "index.d.ts", + "react.d.ts", + "README.md" + ], "scripts": { "test": "node --test test/*.test.js" }, @@ -26,15 +51,24 @@ "react": ">=18" }, "peerDependenciesMeta": { - "@libsql/client": { "optional": true }, - "postgres": { "optional": true }, - "react": { "optional": true } + "@libsql/client": { + "optional": true + }, + "postgres": { + "optional": true + }, + "react": { + "optional": true + } }, "devDependencies": { "@libsql/client": "^0.15.15", + "@profullstack/libsql-pg": "^0.1.4", "postgres": "^3.4.7", "react": "^19.1.0", "react-dom": "^19.1.0" }, - "engines": { "node": ">=20" } + "engines": { + "node": ">=20" + } } diff --git a/src/cli.js b/src/cli.js index 9e8212e..259d418 100644 --- a/src/cli.js +++ b/src/cli.js @@ -20,7 +20,7 @@ import { csvRow, } from "./index.js"; -const VERSION = "0.1.0"; +const VERSION = "0.1.1"; const USAGE = `profile - signup profile fields (${COLUMNS.join(", ")}) diff --git a/src/store.js b/src/store.js index f10267c..9312941 100644 --- a/src/store.js +++ b/src/store.js @@ -24,20 +24,42 @@ function checkIdent(name, what) { return name; } -/** Wrap a driver in `{ dialect, query(text, params) -> rows }`; `?` placeholders. */ -export function adapt(db) { +/** + * Wrap a driver in `{ dialect, query(text, params) -> rows }`; `?` placeholders. + * `dialect` forces "postgres" or "sqlite" when a client could be either. + */ +export function adapt(db, dialect) { if (!db) throw new Error("profile: a database client is required"); if (db.dialect && typeof db.query === "function") return db; if (typeof db.from === "function" && db.auth) return { dialect: "supabase", client: db }; + // postgres.js and Bun's SQL: a tagged-template function with .unsafe(). if (typeof db === "function" && typeof db.unsafe === "function") { return { dialect: "postgres", query: (text, params = []) => db.unsafe(dollars(text), params) }; } + // @libsql/client, and @profullstack/libsql-pg, which has the same API but + // speaks Postgres (protocol "postgres") and translates `?` itself. if (typeof db.execute === "function") { return { - dialect: "sqlite", + dialect: dialect ?? (db.protocol === "postgres" ? "postgres" : "sqlite"), query: async (text, params = []) => (await db.execute({ sql: text, args: params })).rows, }; } + // bun:sqlite and better-sqlite3: synchronous prepared statements. A + // better-sqlite3 statement that returns no rows must be run(), not all(). + if (typeof db.prepare === "function") { + return { + dialect: dialect ?? "sqlite", + query: async (text, params = []) => { + const stmt = db.prepare(text); + if (stmt.reader === false) { + stmt.run(...params); + return []; + } + return stmt.all(...params); + }, + }; + } + // node-postgres Pool or Client. if (typeof db.query === "function") { return { dialect: "postgres", query: async (text, params = []) => (await db.query(dollars(text), params)).rows }; } @@ -151,18 +173,18 @@ function toProfile(row, key) { /** * Create a store. Options: `table` (default "user_profiles"), `key` (default * "user_id"; "id" for a Supabase-style `profiles` table), `keyType` (DDL only; - * default uuid on Postgres, text on SQLite). + * default uuid on Postgres, text on SQLite), `dialect` (force "postgres" or + * "sqlite" for a client that could be either). */ -export function createProfileStore(dbClient, { table = "user_profiles", key = "user_id", keyType } = {}) { +export function createProfileStore(dbClient, { table = "user_profiles", key = "user_id", keyType, dialect } = {}) { checkIdent(table, "table"); checkIdent(key, "key"); - const db = adapt(dbClient); + const db = adapt(dbClient, dialect); if (db.dialect === "supabase") return supabaseStore(db.client, { table, key, keyType, db }); const t = quote(table); const k = `"${key}"`; const now = () => new Date().toISOString(); - const cast = db.dialect === "postgres" ? "::timestamptz" : ""; const cols = [key, ...COLUMNS, "created_at", "updated_at"].map((c) => `"${c}"`).join(", "); async function write(userId, profile, mode) { @@ -172,7 +194,7 @@ export function createProfileStore(dbClient, { table = "user_profiles", key = "u const stamp = now(); const insertCols = [k, ...names.map((n) => `"${n}"`), `"created_at"`, `"updated_at"`]; const values = [String(userId), ...names.map((n) => fields[n]), stamp, stamp]; - const placeholders = insertCols.map((c) => (c.endsWith(`_at"`) ? `?${cast}` : "?")); + const placeholders = insertCols.map(() => "?"); const set = names.map((n) => mode === "fill" ? `"${n}" = coalesce(p."${n}", excluded."${n}")` : `"${n}" = excluded."${n}"`, ); @@ -224,7 +246,7 @@ export function createProfileStore(dbClient, { table = "user_profiles", key = "u const where = []; const params = []; if (after != null) (where.push(`${k} > ?`), params.push(String(after))); - if (since) (where.push(`"updated_at" >= ?${cast}`), params.push(since)); + if (since) (where.push(`"updated_at" >= ?`), params.push(since)); params.push(Math.min(Math.max(1, Number(limit) || 500), 5000)); const rows = await db.query( `select ${cols} from ${t} ${where.length ? `where ${where.join(" and ")}` : ""} diff --git a/test/store.test.js b/test/store.test.js index f9b4775..99a3325 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -7,16 +7,32 @@ import { createProfileStore, migrate, createProfileHandlers, saveSignupProfile } // Every behaviour is checked on both dialects. Postgres runs when // TEST_DATABASE_URL is set (CI starts one); libSQL always runs in memory. const backends = [ - ["sqlite", async () => { + ["libsql", async () => { const client = createClient({ url: ":memory:" }); - return { db: client, close: () => client.close() }; + return { db: client, exec: (q) => client.execute(q), close: () => client.close() }; + }], + // node:sqlite has the synchronous prepare() API of bun:sqlite and better-sqlite3. + ["node:sqlite", async () => { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(":memory:"); + return { db, exec: (q) => db.exec(q), close: () => db.close() }; }], ]; if (process.env.TEST_DATABASE_URL) { + const fresh = async (sql) => sql.unsafe("drop table if exists user_profiles, profiles cascade"); backends.push(["postgres", async () => { const sql = postgres(process.env.TEST_DATABASE_URL, { onnotice: () => {}, max: 4 }); - await sql`drop table if exists user_profiles, profiles cascade`; - return { db: sql, close: () => sql.end() }; + await fresh(sql); + return { db: sql, exec: (q) => sql.unsafe(q), close: () => sql.end() }; + }]); + // @profullstack/libsql-pg looks like @libsql/client but speaks Postgres. + backends.push(["libsql-pg", async () => { + const { createClient: pgClient } = await import("@profullstack/libsql-pg"); + const sql = postgres(process.env.TEST_DATABASE_URL, { onnotice: () => {}, max: 1 }); + await fresh(sql); + await sql.end(); + const client = pgClient({ url: process.env.TEST_DATABASE_URL }); + return { db: client, exec: (q) => client.execute(q), close: () => client.close() }; }]); } @@ -95,11 +111,8 @@ for (const [name, open] of backends) { }); test("an existing profiles table gets only the missing columns", async () => { - const db = conn.db; - const ddl = `create table profiles (id text primary key, first_name text, avatar_url text)`; - if (name === "postgres") await db.unsafe(ddl); - else await db.execute(ddl); - const s = createProfileStore(db, { table: "profiles", key: "id" }); + await conn.exec(`create table profiles (id text primary key, first_name text, avatar_url text)`); + const s = createProfileStore(conn.db, { table: "profiles", key: "id" }); const r = await migrate(s); assert.equal(r.created, false); assert.deepEqual(r.added.sort(), [