Writing an adapter

Build your own store adapter with @siteping/adapter-kit — the engine, the contract, and the conformance suite.

The four bundled adapters cover most setups, but SitePing stores are pluggable: anything that can hold records can back the widget and the dashboard. @siteping/adapter-kit is the published package for that — it carries the SitepingStore contract, the building blocks the first-party adapters run on, and the conformance suite that proves your implementation behaves like the others.

npm i -D @siteping/adapter-kit vitest

vitest is an optional peer dependency, needed only by the @siteping/adapter-kit/testing entry. If you don't run the conformance suite, skip it. The kit itself has no runtime dependencies (@siteping/core is bundled into its dist), so a dev dependency works when you bundle your own package; if you publish unbundled output, move it to dependencies.

Why a separate package: @siteping/core is an internal package — it exports raw TypeScript and is never published to npm. The kit is the supported way to depend on those types and helpers from outside this repo.

Pick your path

Your backendWhat you writeGuide
Snapshot — KV, flat file, IndexedDB, a cookie, an arrayload / persist / generateIdSnapshot backends
Query — SQL, an ORM, an HTTP APIThe 6 SitepingStore methodsQuery backends

Snapshot backends

If your storage can hand back the whole feedback list and take a whole list back, createCollectionStore gives you a complete, conformant store. It implements every semantic of the contract — clientId deduplication, newest-first ordering, the filter and pagination pipeline, StoreNotFoundError on missing records, project-scoped bulk delete, and verifyProjectOwnership:

import { createCollectionStore, type FeedbackRecord, type SitepingStore } from "@siteping/adapter-kit";

export function createArrayStore(): SitepingStore {
  let feedbacks: FeedbackRecord[] = [];
  let counter = 1;

  return createCollectionStore({
    load: () => feedbacks,
    persist: (next) => {
      feedbacks = next;
    },
    generateId: () => `kit-${counter++}`,
    // Discussion threads — see below.
    comments: true,
  });
}

That is a real, fully conformant adapter — it's the dogfood example the kit's own test suite runs the 67 conformance tests against. Swap the three functions for your storage and you're done: MemoryStore and LocalStorageStore are this same engine with different primitives.

The three primitives:

FunctionContract
load()Return the current full snapshot of records. Sync or async. When load and persist are both sync, each write runs from load to persist in one step, so nothing else in the same JavaScript process can write in between; an async backend is only serialized against the store's own queue
persist(feedbacks)Write the full snapshot back. Throw StorePersistenceError when the write is lost (quota, storage disabled) — never swallow it
generateId()A unique id, used for feedback, annotation and comment records

createCollectionStore returns a CollectionStore — a SitepingStore where verifyProjectOwnership is guaranteed rather than optional — with createFeedbackIfAbsent guaranteed too, and addComment and deleteComment once you pass comments: true — so you can delegate to them without a null check.

A failed persist leaves your snapshot untouched. The engine never mutates the array load() returned — every write builds a new array and hands it to persist. So if load() serves a cached array and the write throws, no phantom record stays visible, and the widget's retry of the same clientId is written for real instead of being deduplicated against something that never reached storage.

Quota pressure drops screenshots first. When persist throws during createFeedback and the record carries an inline screenshot, the engine clears screenshotUrl — by far the heaviest field — and retries once, so the written comment survives a full storage bucket. If that second write also fails, the error propagates: returning the record would claim a success that never happened.

Mutations run one at a time. Every write loads the whole snapshot, changes it and persists it back, so two writes that overlapped would start from the same snapshot and the last one would silently undo the other — exactly what the widget's bulk resolve and bulk delete (a Promise.all over the selection) would trigger. The engine queues createFeedback, createFeedbackIfAbsent, updateFeedback, deleteFeedback, deleteAllFeedbacks, addComment and deleteComment so each starts from the previous one's result — a comment posted while a feedback is resolved keeps both changes; a mutation that fails rejects with its own error and never blocks the ones behind it. load and persist must always settle, though: a call that never resolves stalls every later write on that store, so give network-backed primitives a timeout. Reads aren't queued. The queue lives in the store instance, in one JavaScript process: two instances over the same storage — two browser tabs, several server processes sharing a KV or a file — are not coordinated. If your backend is shared that way, it needs its own atomic primitive (a transaction, a compare-and-set).

Query backends

For SQL and ORMs, implement the six methods directly — you want your WHERE clauses in the database, not a full table load per request. The kit still saves you the input-to-record conversion (on PostgreSQL or Turso / libSQL, the first-party Drizzle adapter already does all of this):

import {
  buildFeedbackRecord,
  StoreNotFoundError,
  type FeedbackCreateInput,
  type FeedbackRecord,
  type SitepingStore,
} from "@siteping/adapter-kit";

export class PostgresStore implements SitepingStore {
  async createFeedback(data: FeedbackCreateInput): Promise<FeedbackRecord> {
    const record = buildFeedbackRecord(data, {
      id: crypto.randomUUID(),
      annotationId: () => crypto.randomUUID(),
    });
    // …insert `record` and its `record.annotations`
    return record;
  }

  async deleteFeedback(id: string): Promise<void> {
    const deleted = await db.delete(feedbacks).where(eq(feedbacks.id, id)).returning();
    if (deleted.length === 0) throw new StoreNotFoundError();
  }
  // …the four remaining methods
}

buildFeedbackRecord normalizes every optional field to null, stamps createdAt/updatedAt, sets resolvedAt to null, and builds the annotation records (buildAnnotationRecord does one annotation on its own if you insert them separately). It keeps the screenshot data URL inline on screenshotUrl; adapters with external object storage upload first and overwrite that field.

The error contract

Handlers and the dashboard read behavior off these errors, so throw the kit's classes rather than your ORM's:

SituationWhat to do
updateFeedback / deleteFeedback on an unknown idThrow StoreNotFoundError — the HTTP handler turns it into a 404
createFeedback with an already-used clientIdEither return the existing record (idempotent) or throw StoreDuplicateError. Both are valid; handlers cope with either
addComment on an unknown feedback, deleteComment of a comment not on that feedback's threadThrow StoreNotFoundError — a 404
addComment of a client comment on a thread that already holds 100 of themThrow StoreLimitError — a 409
A value longer than your database holds, though the HTTP validation accepts itThrow StoreValueTooLongError — a 422, which clients do not retry
A mutation accepted but not persistedThrow StorePersistenceError instead of reporting a phantom success
Empty results on getFeedbacks / findByClientIdDon't throw — return an empty array or null

Matching guards ship alongside: isStoreNotFound, isStoreDuplicate, isStoreLimit, isStoreValueTooLong, isStorePersistence. Use them instead of instanceof when you catch across package boundaries — every package bundles its own copy of the classes, so instanceof can fail on an error thrown by another one. The guards also recognize Prisma's P2025 and P2002 codes.

Query semantics to match

getFeedbacks takes a FeedbackQuery and returns { feedbacks, total }, where total is the count before pagination. The behavior the suite checks:

  • Filters: projectName (always), then type, status, statuses, url, urlPattern, search (substring on message).
  • statuses is a bucket — any of the listed values — and wins over status when both are set. An empty array means no status filter.
  • Newest first, by createdAt descending.
  • page is 1-based, limit defaults to 50 and is capped at 100.

Holding a snapshot in memory? applyFeedbackFilters(records, query) is exported for exactly this and implements all of the above. Querying a database? clampPagination(query) returns the normalized { page, limit, skip } — the same clamp the in-memory pipeline and PrismaStore use, so pass its limit and skip straight to your LIMIT/OFFSET. Check isUnreachableOffset(skip) first: a huge page from a direct caller yields an offset past Number.MAX_SAFE_INTEGER (or Infinity) that SQL backends reject, and the suite expects { feedbacks: [], total } for it — answer with your count query alone.

Project ownership

verifyProjectOwnership(id, projectName) is optional. HTTP handlers call it before PATCH and DELETE and answer 404 when it returns false — that's what stops one project from mutating another's feedback with a guessed id. Stores are duck-typed here, so third-party adapters get the check for free:

async verifyProjectOwnership(id: string, projectName: string): Promise<boolean> {
  const row = await db.feedback.findUnique({ where: { id }, select: { projectName: true } });
  return row?.projectName === projectName;
}

Implement it whenever your store serves more than one project. When it's absent, handlers skip the check and trust the id alone.

Exactly-once creation effects

createFeedbackIfAbsent(data) is the other optional method: createFeedback that also returns whether this call inserted the record — { feedback, created }. The request handlers prefer it over createFeedback, and notify webhooks only for created: true, so two requests racing on the same clientId — the widget retrying a request that timed out, landing on another server instance — notify once. Implement it if your createFeedback returns the existing record on a duplicate (duplicateBehavior: "return"); a store that throws StoreDuplicateError already gives the handlers that signal and can leave it out. The dedup check and the insert must be atomic: of concurrent calls with the same clientId, exactly one reports created: true, and all return that record. createCollectionStore guarantees it within one store instance by queueing its writes; storage shared by several processes (or browser tabs) needs an atomic primitive of its own — a unique constraint, a transaction, a compare-and-set.

Discussion threads

addComment(feedbackId, data) and deleteComment(feedbackId, commentId) are optional too. Without them your store has no threads: your records can leave comments out, the handler serves every feedback with an empty thread and answers comment writes with 501. Implement both to support threads:

  • Return the thread on every record, as comments, oldest first — an empty array when there is none, starting with the record createFeedback returns. buildFeedbackRecord leaves comments out so its record still maps to your feedback row: add comments: [] yourself.
  • addComment is idempotent on data.clientId: when a comment with that clientId exists, on any thread, return it unchanged. The handler refuses a replay aimed at another feedback.
  • deleteComment is scoped to the thread it names, which the caller was authorized for: a comment of another thread is "not found".
  • A thread holds at most MAX_COMMENTS_PER_FEEDBACK (100) client comments — team ones neither count nor are refused —, a comment leaves the feedback's updatedAt alone, and deleting a feedback deletes its thread.

buildCommentRecord(data, { id, feedbackId }) builds the record, and createCollectionStore implements both methods when you pass comments: true. Pass it once load hands back each comment's createdAt as a Date — a JSON backend revives it like the record's own dates. Threads are off by default, so a snapshot adapter written before them keeps its surface, and its storage format, when the kit updates.

Prove it: the conformance suite

The same suite the first-party adapters run. Point it at a factory that returns a fresh, empty store and it exercises the whole contract — 67 tests:

// __tests__/my-store.test.ts
import { testSitepingStore } from "@siteping/adapter-kit/testing";
import { MyStore } from "../src/index.js";

testSitepingStore(() => new MyStore());

Besides one call at a time, it fires updates, deletes and creates together in a Promise.all — the way the widget's bulk actions do — and checks that every write landed.

The factory runs before each test and may be async, which is where you spin up a transaction or a fresh schema. Two knobs cover contract variations that are legitimately backend-specific:

OptionDefaultSet it when
duplicateBehavior"return"Your createFeedback throws StoreDuplicateError on a repeated clientId instead of returning the existing record — pass "throw". A store that returns it but throws when it loses a concurrent insert race stays on "return"
caseInsensitiveSearchtrueYour collation makes search case-sensitive — pass false and the suite only asserts same-case substring matching
testSitepingStore(() => new PostgresStore(db), {
  duplicateBehavior: "throw",
  caseInsensitiveSearch: false,
});

verifyProjectOwnership, createFeedbackIfAbsent, addComment and deleteComment are covered too: stores that don't implement them skip the assertions, so adding any of them later is checked automatically with no test changes. The comment cases include a comment racing status changes on the same store.

Using your adapter

Nothing else changes. Server-side, hand it to the request handlers; client-side, hand it straight to the widget or the inbox:

// Server — the request handlers accept any SitepingStore
import { createSitepingHandler } from "@siteping/server";
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ store: new MyStore() });

// Browser — client-side mode, no server at all
import { initSiteping } from "@siteping/widget";
initSiteping({ store: new MyStore(), projectName: "my-app" });

In the browser, the widget stops waiting on createFeedback after 30 s and reports a retryable error (code "TIMEOUT") so the comment popup never stays busy. It cannot cancel the call — SitepingStore takes no abort signal — so a late write may still land. If your store talks to the network, bound its own calls, and deduplicate on clientId: a resend from the same popup reuses it.

If you publish one, open an issue — we'll link it from this page.

Edit on GitHub

On this page