Prisma adapter
The production adapter — HTTP handlers backed by your database, with auth, CORS, redaction, and webhooks.
@siteping/adapter-prisma turns one URL of your app into a complete feedback API. It supports Prisma Client v5, v6, and v7, and requires Node 20+.
npm i @siteping/adapter-prismaMounting
The factory returns one Web-standard handler (Request → Response) per HTTP method — mounting them is your framework's job:
// app/api/siteping/route.ts — Next.js App Router
import { createSitepingHandler } from "@siteping/adapter-prisma";
import { prisma } from "@/lib/prisma";
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ prisma });This is @siteping/server's handler with a Prisma store built in: the same HTTP surface, security model and mounting recipes for Hono, Express and other frameworks.
Options
| Option | Type | Default | What it does |
|---|---|---|---|
prisma | SitepingPrismaClient | — | Your Prisma client. Required unless you pass store |
store | SitepingStore | — | Use any store instead of Prisma (takes precedence) |
screenshotStorage | ScreenshotStorage | — | Upload screenshots somewhere real instead of inlining data URLs. Ignored when you pass a custom store |
caseInsensitiveSearch | boolean | auto-detected | Detected from the Prisma provider (on for PostgreSQL, MongoDB, CockroachDB). Only applies when prisma is given |
Every @siteping/server option works too — apiKey, allowedOrigins, webhooks, access, hooks… The one addition: when a SitePing table is missing, the 500 tells you to run npx prisma db push (a describeError you pass takes precedence). Their types (SitepingAccessControl, SitepingLifecycleHooks, SitepingLogger…) are re-exported here, so a strict package manager such as pnpm needs no direct @siteping/server dependency to type a policy on its own.
Database schema
This is the exact schema the adapter needs — it's what npx @siteping/cli sync generates. Prefer the CLI; if you write it by hand, don't drop columns: the adapter writes urlPattern, screenshotUrl, and anchorKey on every insert, so a partial schema fails on the first submission.
model SitepingFeedback {
id String @id @default(cuid())
projectName String
type String
message String @db.Text
status String @default("open")
url String
urlPattern String?
screenshotUrl String? @db.Text
screenshotRegion Json?
diagnostics Json?
viewport String
userAgent String
authorName String
authorEmail String
clientId String @unique
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
annotations SitepingAnnotation[]
comments SitepingComment[]
@@index([projectName])
@@index([projectName, status, createdAt])
@@index([projectName, url])
}
model SitepingAnnotation {
id String @id @default(cuid())
feedbackId String
feedback SitepingFeedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
cssSelector String @db.Text
xpath String @db.Text
textSnippet String @db.Text
elementTag String
elementId String?
textPrefix String @db.Text
textSuffix String @db.Text
fingerprint String
neighborText String @db.Text
anchorKey String?
xPct Float
yPct Float
wPct Float
hPct Float
scrollX Float
scrollY Float
viewportW Int
viewportH Int
devicePixelRatio Float @default(1)
createdAt DateTime @default(now())
@@index([feedbackId])
}
model SitepingComment {
id String @id @default(cuid())
feedbackId String
feedback SitepingFeedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
body String @db.Text
authorName String
authorEmail String
authorRole String @default("client")
clientId String @unique
createdAt DateTime @default(now())
@@index([feedbackId, createdAt])
}
@db.Texttargets PostgreSQL and MySQL. SQLite and CockroachDB reject it, and plainStringis already unbounded there —syncreads your datasourceproviderand leaves@db.Textout on those databases. Writing the schema by hand? Drop those attributes yourself.
On MySQL, a plain
Stringis aVARCHAR(191), shorter than what the server accepts forprojectName,url,urlPattern,userAgent, names, emails andclientId, and for an annotation'sfingerprintandanchorKey. A value that does not fit — the user agent of Instagram's or Facebook's in-app browser, over 200 characters, a full page URL fromgetPageScope— makes thePOSTanswer422, which the widget does not retry, and the handler logs[siteping] A value is too long for the store.
Discussion threads
The SitepingComment model holds discussion threads. A schema synced before it keeps working: without the model, the generated client has no sitepingComment delegate, so PrismaStore reads every thread empty and the handler answers comment writes with 501. To turn threads on:
npx @siteping/cli sync # adds SitepingComment and SitepingFeedback.comments
npx prisma db push # or: npx prisma migrate dev --name siteping-comments
npx prisma generate # Prisma 7: neither command above regenerates the clientOn Prisma 5 and 6, db push and migrate dev also regenerate the client; Prisma 7 leaves that to prisma generate, harmless on 5 and 6. Skip it on 7 and the client has no sitepingComment delegate: threads stay off, with nothing logged. Apply the schema to the database before you deploy that client: it reads the comments table on every list, so until the table exists, lists answer 500 with the missing-table hint.
A thread comes back oldest first. A post connects to its feedback, so an unknown feedback is a 404 on every provider, and the unique clientId makes a replayed post return the stored comment. The cap of 100 client comments is a count before the insert: posts racing for the last free slot may overshoot it.
Screenshot storage
By default, screenshots are stored inline as data: URLs in the screenshotUrl column — fine for trying things out, heavy for a real database. For production, plug in a ScreenshotStorage. @siteping/screenshot-storage provides one for Cloudflare R2, AWS S3 and other S3-compatible buckets, Cloudflare Images, your disk or your database:
import { createScreenshotStorage } from "@siteping/screenshot-storage";
import { createS3ObjectStore } from "@siteping/screenshot-storage/s3";
createSitepingHandler({
prisma,
screenshotStorage: createScreenshotStorage(
createS3ObjectStore({ endpoint, bucket, accessKeyId, secretAccessKey, publicBaseUrl }),
),
});Or write your own:
createSitepingHandler({
prisma,
screenshotStorage: {
async upload(dataUrl, { mimeType }) {
const url = await uploadToS3(dataUrl, `siteping/${crypto.randomUUID()}`, mimeType);
return { url };
},
},
});Give every upload a URL no other upload gets: a fresh random key, never one derived from feedbackId — here it is the client's clientId, which every retry resends — nor from the image's content.
If an upload fails, the feedback is still saved (with screenshotUrl: null) and a warning is logged — a broken bucket never loses a client's comment.
Return an https: URL: the widget's detail view only renders https: images, plus plain http: on this machine (localhost, *.localhost, 127.0.0.1, [::1]) so a local MinIO or localstack works in development.
Add an optional delete(url) and the adapter calls it when a feedback is deleted — single DELETE and deleteAll alike — so nothing is left behind in your bucket. It is best-effort: a failing delete is logged and never fails the deletion (an orphaned object beats a row that refuses to go away). Inline data: URLs are never passed to it. Deletes run at most 32 at a time, so a project delete freeing thousands of objects neither floods your storage nor runs out of sockets on a serverless function. The delete waits for them: with thousands of screenshots and a slow or unreachable bucket, it can outlast a serverless function's time limit, and the objects it has not removed yet stay in the bucket.
It is also called when a create is rejected because its clientId is already stored (a retry racing the first attempt), to drop the screenshot that attempt uploaded — unless the stored feedback points at that same URL. That happens with a key derived from feedbackId: the replay rewrote the stored feedback's own object, so it is kept — and replaced its image, which a random key avoids. A screenshot whose insert failed for any other reason stays in your bucket, since a retry on another instance may be about to reference it.
Using a custom store
Not on Prisma? Mount your store with @siteping/server directly — same handler, nothing Prisma-related to install. createSitepingHandler({ store }) keeps working here too: @prisma/client is an optional peer dependency, installed only when you pass prisma.
screenshotStorage and caseInsensitiveSearch only apply to the built-in Prisma path; with a custom store, handle screenshots inside your store.