Drizzle adapter
A production store on PostgreSQL or Turso / libSQL through Drizzle ORM — tables you own in your schema, migrations through drizzle-kit.
@siteping/adapter-drizzle is a SitepingStore backed by Drizzle ORM. It has one entry per database family:
| Entry | Database | Drivers |
|---|---|---|
@siteping/adapter-drizzle/pg | PostgreSQL | Any Drizzle PostgreSQL driver — node-postgres, postgres.js, Neon (serverless and HTTP), Vercel Postgres, Supabase, PGlite… |
@siteping/adapter-drizzle/libsql | Turso / libSQL | drizzle-orm/libsql only — Turso remote, embedded replicas, local files |
It requires Node 20+ and drizzle-orm 0.45 or later (below 1.0), a peer dependency. @siteping/server serves the store over HTTP (step 3):
npm i @siteping/adapter-drizzle @siteping/server drizzle-ormAdd your database driver (pg, postgres, @neondatabase/serverless, @electric-sql/pglite, @libsql/client…) and drizzle-kit for migrations.
1. Add the tables to your schema
The adapter never creates tables at runtime: it gives you Drizzle table definitions to export from your own schema, so drizzle-kit manages them like any other table.
// db/schema.ts — PostgreSQL
import { createSitepingPgTables } from "@siteping/adapter-drizzle/pg";
export const { sitepingFeedbacks, sitepingAnnotations } = createSitepingPgTables();// db/schema.ts — Turso / libSQL
import { createSitepingSqliteTables } from "@siteping/adapter-drizzle/libsql";
export const { sitepingFeedbacks, sitepingAnnotations } = createSitepingSqliteTables();Then generate and apply the migration as usual, with a drizzle.config.ts pointing at that schema (dialect: "postgresql" or dialect: "turso"):
npx drizzle-kit generate # writes the SQL migration
npx drizzle-kit migrate # applies it — or `npx drizzle-kit push` while prototypingWhat gets created
Two tables, siteping_feedbacks and siteping_annotations by default. Annotations reference their feedback with ON DELETE CASCADE.
| Index | Columns |
|---|---|
<feedbacks>_client_id_key (unique) | client_id — arbitrates duplicate submissions |
<feedbacks>_project_status_created_idx | project_name, status, created_at |
<feedbacks>_project_url_idx | project_name, url |
<annotations>_feedback_id_idx | feedback_id |
Column types follow the dialect: on PostgreSQL, JSON fields are jsonb and timestamps are timestamptz(3); on libSQL, JSON fields are stored as text and timestamps as epoch milliseconds.
Custom table names
Pass { feedbacks, annotations } to rename the tables (index names follow), and hand the same tables to the store through the tables option — otherwise it queries the default names:
// db/schema.ts
export const { sitepingFeedbacks, sitepingAnnotations } = createSitepingPgTables({
feedbacks: "client_feedbacks",
annotations: "client_feedback_annotations",
});
// server
const store = createPgSitepingStore(db, { tables: { sitepingFeedbacks, sitepingAnnotations } });The defaults are exported as DEFAULT_SITEPING_TABLE_NAMES.
2. Create the store
// PostgreSQL — node-postgres
import { drizzle } from "drizzle-orm/node-postgres";
import { createPgSitepingStore } from "@siteping/adapter-drizzle/pg";
export const store = createPgSitepingStore(drizzle(process.env.DATABASE_URL!), {
screenshotStorage,
logger: console,
});Every PostgreSQL driver works the same way — swap the drizzle import (drizzle-orm/neon-http, drizzle-orm/postgres-js, drizzle-orm/pglite…). The store never opens an interactive db.transaction: every write is a single statement, so drivers without interactive transactions, like Neon HTTP, are fully supported.
// Turso / libSQL
import { drizzle } from "drizzle-orm/libsql";
import { createLibSQLSitepingStore } from "@siteping/adapter-drizzle/libsql";
export const store = createLibSQLSitepingStore(
drizzle({ connection: { url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN } }),
{ screenshotStorage, logger: console },
);3. Serve it
The store is storage only. Mount it behind createSitepingHandler({ store }) from @siteping/server to get validation, auth, CORS, redaction and webhooks:
// app/api/siteping/route.ts
import { createSitepingHandler } from "@siteping/server";
import { store } from "@/lib/siteping-store";
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({
store,
apiKey: process.env.SITEPING_API_KEY,
});Options
Both factories take the same options:
| Option | Type | Default | What it does |
|---|---|---|---|
tables | SitepingPgTables / SitepingSqliteTables | default-named tables | The tables from your schema — required when you renamed them |
screenshotStorage | ScreenshotStorage | — | Upload screenshots elsewhere and store only the URL — see screenshots |
logger | { warn(message, context) } | none — events are dropped | Degraded-but-non-fatal events: a failed upload (the feedback is saved without its screenshot), a failed cleanup, inline screenshots. The store never picks a logging backend itself — pass your application's logger, or console |
now | () => Date | the system clock | Clock for createdAt on create and updatedAt on status updates — inject one for tests or a controlled time source |
Screenshots
Without screenshotStorage, the screenshot's base64 data URL is stored inline in screenshot_url (with a one-time warning to the logger) — fine for development, heavy for production.
With screenshotStorage, upload(dataUrl, { feedbackId, mimeType }) runs before the insert and only the returned URL is stored:
feedbackIdis the id the record will be stored under — generated server-side, fresh for every create attempt, never the client'sclientId.mimeTypeis the media type the data URL declares —image/jpeg,image/pngorimage/webp, lowercased. Anything else (an SVG, which is script-capable when served inline, or no type at all) is reported asimage/jpeg, the widget's capture format. Use it as the object'sContent-Type.- If
uploadthrows, the feedback is still saved withscreenshotUrl: null, and the failure goes to thelogger.
The returned URL must be unique to the upload. Key objects by a fresh random value — never by a content hash, a fixed name or feedbackId alone. feedbackId happens to be unique per attempt with this store, but a ScreenshotStorage does not know which store calls it: passed to Prisma, where feedbackId is the client's clientId, an object keyed by it is rewritten by every retry. The store treats each URL as owned by the one feedback that stores it and may pass it to delete without coordinating with concurrent creates; a URL shared by several feedbacks can lose an object another feedback still points at.
Ready-made storage. @siteping/screenshot-storage provides a screenshotStorage for S3-compatible buckets, Cloudflare Images or your disk, with random keys and deletes scoped to its own objects. Its /drizzle-pg and /drizzle-libsql entries can also keep screenshots in a table of this same database, for small or self-contained deployments; in production, object storage is the better choice.
Cleanup. Add an optional delete(url) and the store removes screenshots it no longer needs: on deleteFeedback, on deleteAllFeedbacks, when a submission loses a race against a duplicate of itself (it uploaded its own object, now an orphan — discarded even when reading the winning submission back then fails), and when an insert fails. Before any delete it checks that no stored feedback still references the URL — an insert reported as failed may have committed after all — and if that check itself fails, every object is kept. Deletes run at most 8 at a time, so a project delete freeing thousands of objects does not flood your storage. Cleanup is best-effort: a failing delete is logged and never fails the operation. Inline data: URLs are never passed to delete.
Large projects. With a delete hook, deleteAllFeedbacks needs the removed URLs back, so it deletes the project in chunks of 500 rows — each chunk atomically, its screenshots cleaned up before the next — and no driver response or in-memory list grows with the project. If a chunk fails, the call throws StorePersistenceError: the earlier chunks stay deleted and cleaned up, and retrying the call removes the rest. Without a delete hook the whole project goes in one atomic statement.
Concurrency guarantees
- Duplicate submissions are atomic. The store implements
createFeedbackIfAbsent: the uniqueclient_idindex plusON CONFLICT DO NOTHINGarbitrate concurrent submissions of the same feedback, across store instances and processes. Exactly one caller getscreated: true, so the handler notifies your webhooks once, whichever server instance each request reached. - Annotations land with their feedback, or not at all. On PostgreSQL the feedback and its annotations are inserted by one statement (a data-modifying CTE). On libSQL, multi-statement writes go through
db.batch, which runs as one transaction and never holds the write lock across anawait— your application can keep writing to the same database concurrently. - Ordering is stable. Lists come back newest first.
createdAtis the clock value at insert time, and rows created in the same millisecond — by one store instance, several processes, or your own code inserting through the exported tables — are ordered by an insertion ordinal the database itself assigns on every insert: the internalcreation_sequenceidentity on PostgreSQL, SQLite's implicitrowidon libSQL. A later insert always lists first, and offset pages never overlap or skip rows. updatedAtnever precedescreatedAt. Status updates clamp it in SQL against the row's own timestamps, which holds across processes whose clocks disagree.- Project ownership. The store implements
verifyProjectOwnership, so the handler answers404to a PATCH/DELETE addressing an id from another project.
Internal columns
The tables carry a few columns of the store's own, never part of the returned records:
| Column | Table | Purpose |
|---|---|---|
position | annotations | Integer (default 0) keeping each feedback's annotations in submission order — annotations[0] is the primary anchor |
creation_sequence | feedbacks (PostgreSQL only) | Breaks created_at ties: a bigint identity the database assigns on every insert, the store's or yours. libSQL has no such column — it orders by the implicit rowid |
message_search | feedbacks | Nullable text: the message lowercased in JavaScript, read by the text search — see below |
Text search and non-ASCII case
getFeedbacks({ search }) — the search query parameter — matches the message case-insensitively with JavaScript's Unicode-aware toLowerCase(), the same folding as the memory and localStorage stores: échec finds Échec and äöü finds ÄÖÜ, whatever the database's own rules. That is what message_search is for: SQLite's LIKE folds only ASCII case, and PostgreSQL's ILIKE folds with the column collation / LC_CTYPE (ASCII-only under C). The store fills message_search on insert and matches the lowercased term against it with a plain LIKE. Wildcards typed in the search (%, _, \) are matched literally.
Rows without it — inserted by your own code outside the store — fall back to searching message with the database's folding (LIKE on libSQL, ILIKE on PostgreSQL). Backfill them once so every row folds Unicode case:
import { eq, isNull } from "drizzle-orm";
const rows = await db
.select({ id: sitepingFeedbacks.id, message: sitepingFeedbacks.message })
.from(sitepingFeedbacks)
.where(isNull(sitepingFeedbacks.messageSearch));
for (const row of rows) {
await db
.update(sitepingFeedbacks)
.set({ messageSearch: row.message.toLowerCase() })
.where(eq(sitepingFeedbacks.id, row.id));
}Run the backfill in JavaScript, not with SQL lower(): the database's lower() follows the same locale rules the column exists to avoid.
Errors
- A database failure on a write (read-only or full database, lost connection, rejected statement…) surfaces as
StorePersistenceError, with the driver error ascause. Detect it withisStorePersistence. updateFeedbackreads the (immutable) annotations before updating the row, so no database call runs after the update commits: a failed annotation read leaves the row untouched, and an applied update is never reported as failed because of a read that followed it.updateFeedbackanddeleteFeedbackon an unknown id throwStoreNotFoundError.
Both entries re-export StorePersistenceError, isStorePersistence, StoreNotFoundError and StoreDuplicateError, so you can catch them without another import.
Limitations
- PostgreSQL and libSQL only. There is no MySQL entry yet.
- libSQL only through
drizzle-orm/libsql. Other SQLite drivers (better-sqlite3, Cloudflare D1) are not supported. - One local libSQL file shared by several processes. SQLite runs one writer at a time: set the client's busy timeout —
drizzle({ connection: { url: "file:siteping.db", timeout: 5000 } })— so concurrent writes queue instead of failing withSQLITE_BUSY. - Foreign keys on libSQL. Cascades need
PRAGMA foreign_keys = ON, which libSQL does not guarantee, so the store deletes annotations explicitly alongside their feedback. Feedbacks you delete yourself outside the store need the same care. - Search on rows without
message_searchfolds case the database's way (ASCII-only on libSQL, and on PostgreSQL under aCcollation /LC_CTYPE) until you backfill them.