feat(db): move from Turso/libSQL to Postgres via @profullstack/libsql-pg - #239
Merged
Merged
Conversation
packages/shared/lib/db.js now builds its client with @profullstack/libsql-pg,
which keeps the @libsql/client surface (execute / batch / transaction) the
layer was written against over a pg pool and rewrites the SQLite idioms the
queries picked up on Turso (strftime('%Y-%m-%dT%H:%M:%fZ','now'), `?`
placeholders, INSERT OR IGNORE) per statement. The exported `db` object
(one / oneOrNone / manyOrNone / none / tx / batch) is unchanged, so no call
site moved.
- DATABASE_URL (postgres://) is the only accepted setting; unset, libsql://
or file: throw with the reason, and a leftover TURSO_DATABASE_URL is named
with the copy recipe. The orchestrator checks it at start. No file fallback.
- migrations-pg/ holds the Postgres schema, generated with
`npx libsql-pg convert-schema` and hand-fixed: gen_random_uuid()::text ids
(the carried-over randomblob()/random() expression does not run on
Postgres), one BEFORE UPDATE trigger function for updated_at (the SQLite
AFTER UPDATE triggers were emitted as TODOs), the `default 'x' check (...)`
columns the converter mangled, identity bigint for the rowid-alias ids, and
every timestamp kept as ISO text via meshhook_now_iso() so the app's
`set updated_at = strftime(...)` keeps assigning text to text.
- scripts/db-migrate.js applies migrations-pg, one transaction per file, and
records checksums in schema_migrations as before; verify-migration.js reads
information_schema / pg_indexes instead of sqlite_master and drops the
PRAGMA check. setup.js collects a Postgres URL. The SQLite migrations stay
in migrations/ until the cutover is proven.
- The in-process write lock and SQLITE_BUSY retry (for libSQL's single
connection) are gone; a retry for 40001/40P01 replaces them.
- Tests: the queue, auth and orchestrator suites need a database and skip
unless TEST_DATABASE_URL is set (each test then gets its own schema);
db.test.js covers the guard and placeholder rewrite without one. The
SvelteKit build externalises @profullstack/libsql-pg and pg.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
|
||
| const close = db.close; | ||
| db.close = async () => { | ||
| await db.pool.query(`drop schema "${schema}" cascade`).catch(() => {}); |
|
|
||
| afterAll(async () => { | ||
| if (!hasTestDb) return; | ||
| await getClient().pool.query(`drop schema "${schema}" cascade`).catch(() => {}); |
ThreatCrush Security Scan11 finding(s) HIGH/CRITICAL: 5 | MEDIUM: 3 | LOW: 3
Snippets are redacted; ThreatCrush never prints matched credential material. |
Contributor
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
ThreatCrush flags any user:password@ database URL, placeholder or not. The example and test URLs now carry no password; the real one lives in the vault. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Sep 25, 2026
… never share one (#240) Queue.read() selected the visible candidates and then bumped their vt in a second statement. On libSQL the single multiplexed connection and the in-process write lock serialised readers, so that was safe; on the Postgres pool (#239) two concurrent readers see the same candidates and both lease them. The integration test 'never hands the same message to two readers' failed against a real Postgres (36 leases of 10 messages). The select now locks the rows it is about to lease and skips rows another reader holds. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
MeshHook's database layer moves from Turso/libSQL to Postgres at the code level. Nothing here touches a server or deploys anything.
packages/shared/lib/db.jsbuilds its client with@profullstack/libsql-pg@0.1.1(createClient({ url: process.env.DATABASE_URL })). It keeps the@libsql/clientsurface over apgpool and rewrites the SQLite idioms the queries picked up on Turso (strftime('%Y-%m-%dT%H:%M:%fZ','now'),?placeholders,INSERT OR IGNORE) per statement. The exporteddbobject (one/oneOrNone/manyOrNone/none/tx/batch) is unchanged, so no call site moved.@libsql/clientis removed from@meshhook/sharedand@meshhook/web.DATABASE_URLmust bepostgres:///postgresql://. Unset,libsql://orfile:throws with the reason; a leftoverTURSO_DATABASE_URLis named together with the copy recipe.workers/orchestrator.mjschecks it at start. No fallback to a file database.migrations-pg/(5 files). The SQLite files inmigrations/stay until the cutover is proven. Design decisions, all written at the top ofmigrations-pg/0001_core_tables.sql:text, withmeshhook_now_iso()(ato_char(now() at time zone 'utc', ...)wrapper) as the default. The converter suggestedtimestamptz, but the app issuesset updated_at = strftime(...)andwhere expires_at <= strftime(...), and Postgres will not assign or comparetextagainsttimestamptz;gen_random_uuid()::text;update_*_updated_attriggers become oneBEFORE UPDATEtrigger function, with theworkflow_runsWHENguard preserved so the orchestrator's ownupdated_atwrite is not overwritten;integer primary key(rowid alias) columns are identitybigint, solastInsertRowidkeeps working (Queue.sendrelies on it; libsql-pg appendsRETURNING msg_id);bigintwith theirCHECK.scripts/db-migrate.jsappliesmigrations-pgone transaction per file with the same checksum ledger;scripts/verify-migration.jsreadsinformation_schema/pg_indexesinstead ofsqlite_masterand drops thePRAGMA foreign_keyscheck (a PRAGMA is a no-op through the shim, and destructuring its empty result would have thrown);scripts/setup.jscollects a Postgres URL and writesDATABASE_URL.SQLITE_BUSYretry (there for libSQL's single multiplexed connection) are gone; a retry on40001/40P01replaces them, so the workers get real concurrency out of the pool.apps/web/vite.config.jsexternalises@profullstack/libsql-pg,pgandpg-nativeinstead of the libsql bindings..env.exampleand README updated.Idioms fixed by hand
lower(hex(randomblob(4)) || ... substr('89ab', abs(random()) % 4 + 1, 1) ...) over asgen_random_bytes+abs(random()) % 4, which Postgres rejects (random()isdouble precision, no%operator). Replaced withgen_random_uuid()::text.status text default 'draft' check (status in (...))came out asdefault 'draft' (check) (status in (...))(see package bug below). Written by hand.texttimestamp columns the converter promoted totimestamptzare kept astextfor the reason above (workers/orchestrator.mjs,packages/shared/lib/auth.jsandscripts/db-migrate.jsall assignstrftime(...)output to them).scripts/verify-migration.js:PRAGMA foreign_keysandsqlite_masterqueries replaced.packages/shared/lib/db.js:toLibsqlSql($nto?) is kept becauseworkers/http-exec.mjsstill uses$1/$2.rowidandjson_extractappear only in comments;coalesce(max(version), 0),count(*),min/maxon text,limit ? offset ?,on conflict (...) do update set ... = excluded...,returning *all pass through. No?NNN,json_each,COLLATE NOCASE,last_insert_rowid(),LIMIT -1, FTS5,GLOBorIS NOT <value>.Tests
pnpm testpasses with no database: vitest 56 passed / 85 skipped (the queue, auth and orchestrator suites need a database and aredescribe.skipIf(!TEST_DATABASE_URL); with it set, each test gets its own schema viaoptions=-c search_path=...and the realmigrations-pgapplied), node:test 258 passed.packages/shared/lib/db.test.js(new) covers the URL guard and the placeholder rewrite.pnpm --filter @meshhook/web run buildpasses with the new externals.The DB-bound suites have therefore not been run against Postgres from here (no database access on this box). The coordinator should run
TEST_DATABASE_URL=postgres://... pnpm testonce against a scratch database before the cutover; the suites create and drop their own schemas.Cutover recipe (coordinator)
Beyond the recipe: the copier resets identity sequences (
workflow_events.id,audit_log.id,queue_messages.msg_id) withsetval(max);queue_archive.msg_idis a plain bigint PK, nothing to reset.queue_configis seeded by the migrations, so copy it with--upsertor--exclude queue_configto avoid a conflict onqueue_name. RunANALYZEafter the load. Theschema_migrationstable on Turso has different columns (version/checksum) from the one the runner creates; exclude it from the copy too.Package bugs found (
@profullstack/libsql-pg@0.1.1,convert-schema)DEFAULT '<literal>' CHECK (...)loses the CHECK. Input:status text default 'draft' check (status in ('draft', 'published', 'archived')). Expected: unchanged. Got:status text default 'draft' (check) (status in ('draft', 'published', 'archived'))plus the notestatus: default check -> (check).rewriteDefaultruns its\sdefault\s+regex on the code mask, where the blanked literal reads as whitespace, so the "default expression" it finds is the wordcheck. Same fortheme_preference text default 'light' check (...).random()in a default is carried over unchanged (only a warning inunsupportedIdioms, none in the converter). Input: the SQLite v4-UUID default above. Got:abs(random()) % 4 + 1insidegen_random_bytes(...)concatenation, which Postgres rejects (operator does not exist: double precision % integer). Expected: a TODO, orgen_random_uuid()::textwhen the expression matches the well-known randomblob UUID idiom.promoteTextTimestamps: falseexists inConvertOptionsbutconvert-schemaexposes no flag for it, and the promotion is wrong for any app that assignsstrftime(...)to those columns (Postgres refusestextintotimestamptz). Every promoted column here had to be reverted by hand.🤖 Generated with Claude Code