easy-pingv0.7.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Adapters / Writing an adapter

Edit this page

Writing an adapter

The DatabaseAdapter interface, and the conformance suite that proves an implementation actually holds the claim guarantee.

The interface

type DatabaseAdapter = {
  name: string;
  naming?: "snake_case" | "preserve";
  serializesJson?: boolean;

  createNotifications(rows): Promise<{ created: string[]; deduped: string[] }>;
  claimPendingDeliveries(args): Promise<ClaimedDelivery[]>;
  releaseDeliveries(releases): Promise<void>;

  listNotifications(query): Promise<FeedPage>;
  countUnseen(userId): Promise<number>;
  markSeen(userId, before): Promise<void>;
  markRead(userId, ids): Promise<number>;
  markAllRead(userId): Promise<number>;

  getFailedDeliveries(args): Promise<DeliveryRecord[]>;

  // Generic access for plugin-declared tables — reached only through the
  // scoped PluginStore, which validates every table and column first.
  queryTable(table, where, options): Promise<Record<string, unknown>[]>;
  insertRows(table, rows, onConflict?): Promise<number>;
  updateRows(table, where, set): Promise<number>;
  deleteRows(table, where): Promise<number>;
};

claimPendingDeliveries / releaseDeliveries are the one operation with no equivalent in most libraries — without an atomic claim, two concurrent sweeps send the same email twice. It doesn't have to be FOR UPDATE SKIP LOCKED; MongoDB implements the same contract with a loop of atomic findOneAndUpdate calls instead. What matters is that two concurrent claims return disjoint sets and neither blocks behind the other.

Declaring your dialect

naming and serializesJson tell the plugin storage layer how to talk to your driver, instead of it assuming Postgres conventions:

  • naming: "snake_case" — a SQL adapter with columns named user_id. "preserve" — a document store that keeps the declared camelCase field names as-is.
  • serializesJson: true — the driver needs JSON.stringifyd text for a json column (true of postgres-js, which can't bind a plain object). false — the driver stores an object natively.

Get this wrong and the failure is silent rather than a type error: fields come back undefined at runtime while everything still typechecks, because the store is reading from the wrong key.

The conformance suite

import { adapterConformanceCases } from "easy-ping/testing";

for (const testCase of adapterConformanceCases) {
  if (testCase.requires === "rowLock" && !myBackendHasRowLocks) continue;

  it(testCase.name, () => testCase.run({
    adapter: myAdapter,
    reset: async () => { /* empty the tables between cases */ },
    setAttempts: async (deliveryId, attempts) => { /* force a delivery's attempt count */ },
    lockRow: async (deliveryId, fn) => { /* hold a real lock, only if your backend has one */ },
  }));
}

Thirteen cases, portable across any backend. The one that actually distinguishes a correct adapter holds a row lock on a separate connection and asserts a concurrent claim skips it rather than blocking — that's tagged requires: "rowLock", and a backend whose claim is a single atomic operation (nothing to lock, nothing to skip) filters it out rather than faking a pass. Both shipped adapters run the exact same suite; see Drizzle · Postgres and MongoDB for what each one does differently to satisfy it.