Écrire un adapter
Construisez votre propre adapter de stockage avec @siteping/adapter-kit — le moteur, le contrat et la suite de conformité.
Les quatre adapters fournis couvrent la plupart des installations, mais les stores SitePing sont enfichables : tout ce qui peut conserver des enregistrements peut alimenter le widget et le dashboard. @siteping/adapter-kit est le package publié pour ça — il porte le contrat SitepingStore, les briques sur lesquelles tournent les adapters officiels, et la suite de conformité qui prouve que votre implémentation se comporte comme les autres.
npm i -D @siteping/adapter-kit vitestvitest est une peer dependency optionnelle, nécessaire uniquement à l'entrée @siteping/adapter-kit/testing. Si vous ne lancez pas la suite de conformité, laissez-la de côté. Le kit lui-même n'a aucune dépendance runtime (@siteping/core est intégré dans son dist) : une dépendance de développement suffit si vous bundlez votre package ; si vous publiez du code non bundlé, déplacez-la dans dependencies.
Pourquoi un package séparé :
@siteping/coreest un package interne — il exporte du TypeScript brut et n'est jamais publié sur npm. Le kit est la façon supportée de dépendre de ces types et helpers depuis l'extérieur de ce dépôt.
Choisissez votre voie
| Votre backend | Ce que vous écrivez | Guide |
|---|---|---|
| Snapshot — KV, fichier plat, IndexedDB, un cookie, un tableau | load / persist / generateId | Backends snapshot |
| Requêtes — SQL, un ORM, une API HTTP | Les 6 méthodes de SitepingStore | Backends à requêtes |
Backends snapshot
Si votre stockage sait rendre la liste complète des feedbacks et en reprendre une entière, createCollectionStore vous donne un store complet et conforme. Il implémente toutes les sémantiques du contrat — déduplication par clientId, tri du plus récent au plus ancien, pipeline de filtrage et de pagination, StoreNotFoundError sur enregistrement absent, suppression en masse cadrée au projet, et 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++}`,
// Fils de discussion — voir plus bas.
comments: true,
});
}C'est un vrai adapter, pleinement conforme — c'est l'exemple contre lequel la suite de tests du kit lance elle-même les 67 tests de conformité. Remplacez les trois fonctions par votre stockage et c'est terminé : MemoryStore et LocalStorageStore sont ce même moteur avec d'autres primitives.
Les trois primitives :
| Fonction | Contrat |
|---|---|
load() | Renvoie l'instantané complet des enregistrements. Synchrone ou asynchrone. Quand load et persist sont tous deux synchrones, chaque écriture va du chargement à la persistance d'un seul tenant : rien d'autre dans le même processus JavaScript ne peut écrire entre les deux. Un backend asynchrone n'est sérialisé que vis-à-vis de la file du store |
persist(feedbacks) | Réécrit l'instantané complet. Levez StorePersistenceError quand l'écriture est perdue (quota, stockage désactivé) — ne l'avalez jamais |
generateId() | Un identifiant unique, utilisé pour les feedbacks, les annotations et les commentaires |
createCollectionStore renvoie un CollectionStore — un SitepingStore dont verifyProjectOwnership est garanti au lieu d'être optionnel — avec createFeedbackIfAbsent garanti lui aussi, et addComment et deleteComment dès que vous passez comments: true — vous pouvez donc leur déléguer sans vérification préalable.
Un persist qui échoue laisse votre instantané intact. Le moteur ne modifie jamais le tableau renvoyé par load() — chaque écriture construit un nouveau tableau et le passe à persist. Donc si load() sert un tableau en cache et que l'écriture lève, aucun enregistrement fantôme ne reste visible, et le réessai du widget avec le même clientId est écrit pour de bon au lieu d'être dédupliqué contre quelque chose qui n'a jamais atteint le stockage.
Sous pression de quota, les captures sautent en premier. Quand persist lève pendant createFeedback et que l'enregistrement porte une capture d'écran en ligne, le moteur vide screenshotUrl — de loin le champ le plus lourd — et réessaie une fois, pour que le commentaire écrit survive à un espace de stockage plein. Si cette seconde écriture échoue aussi, l'erreur remonte : renvoyer l'enregistrement annoncerait une réussite qui n'a jamais eu lieu.
Les mutations s'exécutent une par une. Chaque écriture charge l'instantané complet, le modifie et le réécrit : deux écritures qui se chevauchent partiraient du même instantané, et la dernière annulerait l'autre en silence — exactement ce que déclencheraient la résolution et la suppression en masse du widget (un Promise.all sur la sélection). Le moteur met en file createFeedback, createFeedbackIfAbsent, updateFeedback, deleteFeedback, deleteAllFeedbacks, addComment et deleteComment, pour que chacune parte du résultat de la précédente — un commentaire posté pendant qu'un feedback est résolu garde les deux changements ; une mutation qui échoue est rejetée avec sa propre erreur et ne bloque jamais celles qui suivent. load et persist doivent en revanche toujours aboutir : un appel qui ne se résout jamais bloque toutes les écritures suivantes sur ce store, donnez donc un délai d'expiration aux primitives qui passent par le réseau. Les lectures ne passent pas par la file. La file vit dans l'instance du store, dans un seul processus JavaScript : deux instances sur le même stockage — deux onglets, plusieurs processus serveur partageant un KV ou un fichier — ne sont pas coordonnées. Si votre backend est partagé de cette façon, il lui faut sa propre primitive atomique (une transaction, un compare-and-set).
Backends à requêtes
Pour SQL et les ORM, implémentez les six méthodes directement — vos clauses WHERE ont leur place dans la base, pas dans un chargement complet de la table à chaque requête. Le kit vous épargne quand même la conversion entrée → enregistrement (sur PostgreSQL ou Turso / libSQL, l'adapter Drizzle officiel fait déjà tout cela) :
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(),
});
// …insérez `record` et ses `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();
}
// …les quatre méthodes restantes
}buildFeedbackRecord normalise chaque champ optionnel à null, horodate createdAt/updatedAt, met resolvedAt à null et construit les enregistrements d'annotations (buildAnnotationRecord en traite une seule si vous les insérez séparément). Il garde l'URL data: de la capture en ligne dans screenshotUrl ; les adapters dotés d'un stockage objet externe téléversent d'abord, puis écrasent ce champ.
Le contrat d'erreurs
Les handlers et le dashboard déduisent leur comportement de ces erreurs : levez les classes du kit plutôt que celles de votre ORM.
| Situation | Ce qu'il faut faire |
|---|---|
updateFeedback / deleteFeedback sur un id inconnu | Levez StoreNotFoundError — le handler HTTP le traduit en 404 |
createFeedback avec un clientId déjà utilisé | Renvoyez l'enregistrement existant (idempotent) ou levez StoreDuplicateError. Les deux sont valides, les handlers gèrent l'un comme l'autre |
addComment sur un feedback inconnu, deleteComment d'un commentaire absent du fil de ce feedback | Levez StoreNotFoundError — un 404 |
addComment d'un commentaire client sur un fil qui en contient déjà 100 | Levez StoreLimitError — un 409 |
| Une valeur plus longue que ce que tient votre base, bien que la validation HTTP l'accepte | Levez StoreValueTooLongError — un 422, que les clients ne rejouent pas |
| Une mutation acceptée mais non persistée | Levez StorePersistenceError au lieu d'annoncer une réussite fantôme |
Résultat vide sur getFeedbacks / findByClientId | Ne levez pas — renvoyez un tableau vide ou null |
Les gardes correspondantes sont fournies : isStoreNotFound, isStoreDuplicate, isStoreLimit, isStoreValueTooLong, isStorePersistence. Utilisez-les plutôt qu'instanceof quand vous rattrapez une erreur au-delà d'une frontière de package — chaque package embarque sa propre copie des classes, donc instanceof peut échouer sur une erreur levée par un autre. Ces gardes reconnaissent aussi les codes P2025 et P2002 de Prisma.
Les sémantiques de requête à respecter
getFeedbacks prend un FeedbackQuery et renvoie { feedbacks, total }, où total est le compte avant pagination. Le comportement vérifié par la suite :
- Filtres :
projectName(toujours), puistype,status,statuses,url,urlPattern,search(sous-chaîne dansmessage). statusesest un seau — n'importe laquelle des valeurs listées — et l'emporte surstatusquand les deux sont présents. Un tableau vide vaut absence de filtre.- Du plus récent au plus ancien, par
createdAtdécroissant. pagecommence à 1,limitvaut 50 par défaut et est plafonné à 100.
Vous gardez un instantané en mémoire ? applyFeedbackFilters(records, query) est exporté précisément pour ça et implémente tout ce qui précède. Vous interrogez une base ? clampPagination(query) renvoie le { page, limit, skip } normalisé — le même plafonnement que le pipeline en mémoire et PrismaStore, à passer tel quel à votre LIMIT/OFFSET. Testez d'abord isUnreachableOffset(skip) : un page énorme venu d'un appel direct donne un offset au-delà de Number.MAX_SAFE_INTEGER (ou Infinity) que les bases SQL rejettent, et la suite attend { feedbacks: [], total } — répondez avec votre seule requête de comptage.
Appartenance au projet
verifyProjectOwnership(id, projectName) est optionnelle. Les handlers HTTP l'appellent avant PATCH et DELETE et répondent 404 quand elle renvoie false — c'est ce qui empêche un projet de modifier les feedbacks d'un autre avec un id deviné. Le typage est structurel ici, donc les adapters tiers bénéficient de la vérification sans rien câbler :
async verifyProjectOwnership(id: string, projectName: string): Promise<boolean> {
const row = await db.feedback.findUnique({ where: { id }, select: { projectName: true } });
return row?.projectName === projectName;
}Implémentez-la dès que votre store sert plus d'un projet. En son absence, les handlers sautent la vérification et se fient au seul id.
Effets de création exactement une fois
createFeedbackIfAbsent(data) est l'autre méthode optionnelle : un createFeedback qui indique aussi si cet appel a inséré l'enregistrement — { feedback, created }. Les handlers de requêtes la préfèrent à createFeedback et ne notifient les webhooks que pour created: true : deux requêtes qui se disputent le même clientId — le widget qui relance une requête expirée, arrivée sur une autre instance du serveur — ne notifient qu'une fois. Implémentez-la si votre createFeedback renvoie l'enregistrement existant sur un doublon (duplicateBehavior: "return") ; un store qui lève StoreDuplicateError donne déjà ce signal aux handlers et peut s'en passer. La vérification du doublon et l'insertion doivent être atomiques : parmi des appels concurrents avec le même clientId, un seul renvoie created: true, et tous renvoient cet enregistrement. createCollectionStore le garantit au sein d'une instance de store en mettant ses écritures en file ; un stockage partagé par plusieurs processus (ou onglets) nécessite sa propre primitive atomique — contrainte d'unicité, transaction, compare-and-set.
Fils de discussion
addComment(feedbackId, data) et deleteComment(feedbackId, commentId) sont optionnelles elles aussi. Sans elles, votre store n'a pas de fils : vos enregistrements peuvent omettre comments, le handler sert chaque feedback avec un fil vide et répond 501 aux écritures de commentaires. Implémentez les deux pour prendre en charge les fils :
- Renvoyez le fil sur chaque enregistrement, dans
comments, du plus ancien au plus récent — un tableau vide quand il n'y en a pas, à commencer par l'enregistrement que renvoiecreateFeedback.buildFeedbackRecordometcommentspour que son enregistrement corresponde toujours à votre ligne de feedback : ajoutezcomments: []vous-même. addCommentest idempotente surdata.clientId: quand un commentaire avec ceclientIdexiste, sur n'importe quel fil, renvoyez-le tel quel. Le handler refuse un envoi rejoué qui vise un autre feedback.deleteCommentse limite au fil qu'elle désigne, celui pour lequel l'appelant a été autorisé : un commentaire d'un autre fil est « introuvable ».- Un fil contient au plus
MAX_COMMENTS_PER_FEEDBACK(100) commentairesclient— lesteamne comptent pas et ne sont jamais refusés —, un commentaire ne touche pas à l'updatedAtdu feedback, et supprimer un feedback supprime son fil.
buildCommentRecord(data, { id, feedbackId }) construit l'enregistrement, et createCollectionStore implémente les deux méthodes quand vous passez comments: true. Passez-le dès que load rend le createdAt de chaque commentaire sous forme de Date — un backend JSON le ranime comme les dates de l'enregistrement. Les fils sont désactivés par défaut : un adapter snapshot écrit avant eux garde sa surface, et son format de stockage, quand le kit se met à jour.
La preuve : la suite de conformité
La même suite que celle des adapters officiels. Pointez-la vers une factory qui renvoie un store neuf et vide, et elle exerce tout le contrat — 67 tests :
// __tests__/my-store.test.ts
import { testSitepingStore } from "@siteping/adapter-kit/testing";
import { MyStore } from "../src/index.js";
testSitepingStore(() => new MyStore());En plus des appels un par un, elle lance ensemble des mises à jour, des suppressions et des créations dans un Promise.all — comme le font les actions en masse du widget — et vérifie que chaque écriture a abouti.
La factory est appelée avant chaque test et peut être asynchrone — c'est là que vous ouvrez une transaction ou créez un schéma frais. Deux réglages couvrent les variations de contrat légitimement propres au backend :
| Option | Défaut | À régler quand |
|---|---|---|
duplicateBehavior | "return" | Votre createFeedback lève StoreDuplicateError sur un clientId répété au lieu de renvoyer l'existant — passez "throw". Un store qui le renvoie mais lève quand il perd une course d'insertion concurrente reste sur "return" |
caseInsensitiveSearch | true | Votre collation rend search sensible à la casse — passez false et la suite ne teste que la correspondance à casse identique |
testSitepingStore(() => new PostgresStore(db), {
duplicateBehavior: "throw",
caseInsensitiveSearch: false,
});verifyProjectOwnership, createFeedbackIfAbsent, addComment et deleteComment sont couvertes elles aussi : les stores qui ne les implémentent pas sautent les assertions, donc ajouter l'une d'elles plus tard est vérifié automatiquement, sans toucher aux tests. Les cas de commentaires comprennent un commentaire posté en même temps que des changements de statut sur le même store.
Utiliser votre adapter
Rien d'autre ne change. Côté serveur, passez-le aux handlers de requêtes ; côté client, passez-le directement au widget ou à l'inbox :
// Serveur — les handlers de requêtes acceptent n'importe quel SitepingStore
import { createSitepingHandler } from "@siteping/server";
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ store: new MyStore() });
// Navigateur — mode côté client, aucun serveur
import { initSiteping } from "@siteping/widget";
initSiteping({ store: new MyStore(), projectName: "mon-app" });Dans le navigateur, le widget cesse d'attendre createFeedback au bout de 30 s et signale une erreur réessayable (code "TIMEOUT"), pour que la popup de commentaire ne reste jamais bloquée. Il ne peut pas annuler l'appel — SitepingStore ne prend pas de signal d'annulation — donc une écriture tardive peut encore aboutir. Si votre store passe par le réseau, bornez vous-même ses appels, et dédupliquez sur clientId : un renvoi depuis la même popup le réutilise.
Si vous en publiez un, ouvrez une issue — nous le référencerons depuis cette page.