Adaptateurs

É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 vitest

vitest 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/core est 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 backendCe que vous écrivezGuide
Snapshot — KV, fichier plat, IndexedDB, un cookie, un tableauload / persist / generateIdBackends snapshot
Requêtes — SQL, un ORM, une API HTTPLes 6 méthodes de SitepingStoreBackends à 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 :

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

SituationCe qu'il faut faire
updateFeedback / deleteFeedback sur un id inconnuLevez 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 feedbackLevez StoreNotFoundError — un 404
addComment d'un commentaire client sur un fil qui en contient déjà 100Levez StoreLimitError — un 409
Une valeur plus longue que ce que tient votre base, bien que la validation HTTP l'accepteLevez StoreValueTooLongError — un 422, que les clients ne rejouent pas
Une mutation acceptée mais non persistéeLevez StorePersistenceError au lieu d'annoncer une réussite fantôme
Résultat vide sur getFeedbacks / findByClientIdNe 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), puis type, status, statuses, url, urlPattern, search (sous-chaîne dans message).
  • statuses est un seau — n'importe laquelle des valeurs listées — et l'emporte sur status quand les deux sont présents. Un tableau vide vaut absence de filtre.
  • Du plus récent au plus ancien, par createdAt décroissant.
  • page commence à 1, limit vaut 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 renvoie createFeedback. buildFeedbackRecord omet comments pour que son enregistrement corresponde toujours à votre ligne de feedback : ajoutez comments: [] vous-même.
  • addComment est idempotente sur data.clientId : quand un commentaire avec ce clientId existe, sur n'importe quel fil, renvoyez-le tel quel. Le handler refuse un envoi rejoué qui vise un autre feedback.
  • deleteComment se 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) commentaires client — les team ne comptent pas et ne sont jamais refusés —, un commentaire ne touche pas à l'updatedAt du 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 :

OptionDé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"
caseInsensitiveSearchtrueVotre 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.

Modifier sur GitHub

Sur cette page