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-prisma

Mounting

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

OptionTypeDefaultWhat it does
prismaSitepingPrismaClient—Your Prisma client. Required unless you pass store
storeSitepingStore—Use any store instead of Prisma (takes precedence)
screenshotStorageScreenshotStorage—Upload screenshots somewhere real instead of inlining data URLs. Ignored when you pass a custom store
caseInsensitiveSearchbooleanauto-detectedDetected 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.Text targets PostgreSQL and MySQL. SQLite and CockroachDB reject it, and plain String is already unbounded there — sync reads your datasource provider and leaves @db.Text out on those databases. Writing the schema by hand? Drop those attributes yourself.

On MySQL, a plain String is a VARCHAR(191), shorter than what the server accepts for projectName, url, urlPattern, userAgent, names, emails and clientId, and for an annotation's fingerprint and anchorKey. 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 from getPageScope — makes the POST answer 422, 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 client

On 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.

Edit on GitHub

On this page