@lapub974/pgbase
v0.6.0
Published
SDK TypeScript sans dépendance pour PgBase et PgHost : PostgreSQL, Auth, Realtime, Storage et RPC
Maintainers
Readme
pgbase — SDK TypeScript officiel
Client typé pour PgBase et PgHost. Les requêtes et réponses sont typées ; en
combinant avec les types générés depuis votre schéma, pb.collection('posts')
renvoie des enregistrements Post complets (autocomplétion + vérification).
Installation
npm install @lapub974/pgbaseAucune dépendance runtime. Le paquet est ESM et nécessite fetch (Node ≥ 18 ou
navigateur). Pour le temps réel sous Node, injectez un WebSocket (voir plus bas).
Pour contribuer au SDK depuis le monorepo sans passer par npm :
npm install ../pocketpostgres/sdk/typescriptProjet hébergé : démarrage rapide
Le client d'un projet se crée comme avec Supabase : l'URL dédiée et la clé
publishable affichées dans Studio → Clés API. Cette clé peut être placée
dans une application web ou mobile ; elle identifie le projet mais ne contourne
jamais le RLS. Les autorisations viennent ensuite de la session utilisateur et
des politiques PostgreSQL.
import { createClient } from '@lapub974/pgbase'
import type { Schema, RPC } from './pgbase-types'
export const pg = createClient<Schema, RPC>(
'https://ma-boutique.pg.monappli.re',
import.meta.env.VITE_PGBASE_PUBLISHABLE_KEY,
)L'ancienne forme reste compatible pendant la migration :
const pg = createClient('https://pg.monappli.re', 'ma-boutique')Cette forme historique sans clé reste disponible uniquement pour les projets qui n'ont encore jamais créé de clé. Dès qu'une première clé existe, PgBase refuse tout appel applicatif sans clé, même si toutes les clés ont ensuite expiré ou été révoquées.
Authentification des utilisateurs du projet
Chaque projet possède sa propre collection users, ses propres refresh tokens
et un espace de signature JWT distinct. Un compte ou un token d'un projet ne
peut pas être utilisé dans un autre projet.
// Inscription avec métadonnées de profil
const { data, error } = await pg.auth.signUp({
email: '[email protected]',
password: 'mot-de-passe-solide',
options: { data: { name: 'Alice' } },
})
// Connexion
const login = await pg.auth.signInWithPassword({
email: '[email protected]',
password: 'mot-de-passe-solide',
})
// Connexion sans mot de passe : le lien reçu contient token_hash
await pg.auth.signInWithOtp({
email: '[email protected]',
options: {
shouldCreateUser: false,
emailRedirectTo: 'https://app.example.com/auth/callback',
},
})
const magicSession = await pg.auth.verifyOtp({
token_hash: new URLSearchParams(location.search).get('token_hash')!,
type: 'magiclink',
})
// Session locale et utilisateur vérifié côté API
const { data: sessionData } = await pg.auth.getSession()
const { data: userData } = await pg.auth.getUser()
// Événements : INITIAL_SESSION, SIGNED_IN, TOKEN_REFRESHED, SIGNED_OUT
const { data: listener } = pg.auth.onAuthStateChange((event, session) => {
console.log(event, session?.user)
})
await pg.auth.signOut()
listener.subscription.unsubscribe()Connexion Google et Meta/Facebook
Dans Studio → Auth & utilisateurs → Fournisseurs sociaux OAuth, renseignez le Client ID/Secret Google ou l'App ID/Secret Meta, puis copiez l'unique URI de callback affichée vers la console du fournisseur. Les identifiants sont propres à la base du projet et les secrets sont chiffrés dans PostgreSQL.
L'usage côté application reprend supabase-js :
// Google
const { data, error } = await pg.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: 'https://shop.example.com/auth/callback' },
})
// Meta Login conserve le nom de provider compatible Supabase : "facebook"
await pg.auth.signInWithOAuth({
provider: 'facebook',
options: { redirectTo: 'https://shop.example.com/auth/callback' },
})redirectTo doit correspondre exactement à l'URL principale ou à une entrée de
la liste autorisée du projet. Au retour, le SDK détecte automatiquement
?pgbase_oauth=1&code=…, échange ce code à usage unique et émet SIGNED_IN.
Pour gérer la callback vous-même :
const pg = createClient(projectUrl, publishableKey, { detectSessionInUrl: false })
const code = new URL(location.href).searchParams.get('code')!
const { data, error } = await pg.auth.exchangeCodeForSession(code)Google doit autoriser les scopes openid email profile. Dans Meta Login,
activez public_profile et email ; sans adresse confirmée renvoyée par Meta,
PgBase refuse la création du compte. PgBase ne conserve jamais le token du
fournisseur, seulement le lien entre son identifiant stable et l'utilisateur.
Guides de configuration : Google
et Meta/Facebook.
Chaque connexion est une session serveur révocable avec son appareil, sa date d'expiration et sa dernière activité. L'adresse IP n'est conservée que sous forme de HMAC :
const { data: sessions } = await pg.auth.getSessions()
await pg.auth.revokeSession(sessions![0].id)
await pg.auth.signOut({ scope: 'local' }) // cette session
await pg.auth.signOut({ scope: 'others' }) // toutes les autres
await pg.auth.signOut({ scope: 'global' }) // toutes les sessionsLe SDK conserve la session dans un espace localStorage propre à l'origine du
projet : une API locale et une API de production ne peuvent donc jamais partager
leurs jetons. Il renouvelle automatiquement le JWT avant
expiration. Le refresh token est à usage unique et tourne à chaque renouvellement.
Les méthodes et redirections disponibles dépendent de la politique réglée dans Studio → Auth & utilisateurs. Une redirection de lien magique doit correspondre exactement à l'URL principale ou à une URL explicitement autorisée ; la même liste stricte protège les retours OAuth.
Les parcours de récupération et de sécurité sont également disponibles :
await pg.auth.resetPasswordForEmail('[email protected]')
await pg.auth.confirmPasswordReset(token, 'nouveau-mot-de-passe')
await pg.auth.requestEmailVerification('[email protected]')
await pg.auth.confirmEmailVerification(token)
await pg.auth.changePassword('mot-de-passe-actuel', 'nouveau-mot-de-passe')
await pg.auth.changeEmail('[email protected]', 'mot-de-passe-actuel')Un visiteur peut commencer sans e-mail puis conserver le même identifiant et ses données lorsqu'il crée son compte définitif :
await pg.auth.signInAnonymously()
await pg.auth.convertAnonymous({
email: '[email protected]',
password: 'mot-de-passe-solide',
})Le MFA TOTP reprend l'ergonomie Supabase et élève la session en aal2. Le secret
TOTP est chiffré côté serveur et les codes de récupération ne sont affichés
qu'à la première validation :
const { data: enrollment } = await pg.auth.mfa.enroll({
factorType: 'totp', friendlyName: 'Téléphone',
})
const { data: challenge } = await pg.auth.mfa.challenge({ factorId: enrollment!.factor.id })
const { data: verified } = await pg.auth.mfa.verify({
factorId: enrollment!.factor.id,
challengeId: challenge!.id,
code: codeTOTP,
})
console.log(verified?.aal) // aal2Requêtes façon Supabase
const { data: posts, error, count } = await pg
.from('posts')
.select('id,title,status,created')
.eq('status', 'published')
.order('created', { ascending: false })
.limit(20)
const { data: matches } = await pg
.from('posts')
.select('id,title,status')
.ilike('title', '%postgres%')
.in('status', ['draft', 'published'])
.not('archived', 'eq', true)
.or([
{ column: 'featured', operator: 'is', value: true },
{ column: 'views', operator: 'gte', value: 1000 },
])
.search('optimisation')
.order('created', { ascending: false })
.range(0, 19) // positions inclusives, comme Supabase
const { data: post } = await pg
.from('posts')
.select()
.eq('id', postId)
.single()
const { data: created } = await pg
.from('posts')
.insert({ title: 'Bonjour', status: 'draft' })
.select('id,title,status')
.single()
await pg.from('posts').update({ status: 'published' }).eq('id', postId)
await pg.from('posts').delete().eq('id', postId)
const controller = new AbortController()
const pending = pg.from('posts').select().abortSignal(controller.signal)
controller.abort()
await pendingLes projets récents utilisent le RLS PostgreSQL natif configuré dans Studio :
politiques permissives (OR), restrictives (AND), rôles anon et
authenticated, expressions USING/WITH CHECK, et droits par colonne. Les
helpers auth.uid(), auth.role() et auth.jwt() sont disponibles dans les
expressions. Les anciennes règles list/view/create/update/delete restent le
mode de compatibilité tant que le RLS natif d'une collection n'est pas activé.
Une erreur est renvoyée dans error; ajoutez .throwOnError() si
vous préférez les exceptions. Les filtres du builder sont transmis sous forme
d'arbre JSON puis validés et paramétrés côté serveur : aucune valeur utilisateur
n'est interprétée comme du SQL. or() accepte des descripteurs typés plutôt que
la chaîne PostgREST brute de Supabase.
Les insertions en lot, upserts et mutations filtrées sont exécutés côté PostgreSQL dans une transaction, sans boucle de requêtes dans le navigateur :
await pg.from('products').insert([{ sku: 'A' }, { sku: 'B' }]).select()
await pg.from('products').upsert({ sku: 'A', stock: 12 }, { onConflict: 'sku' }).select()
await pg.from('products').update({ published: true }).eq('category', 'books').select()
await pg.from('products').delete().lt('stock', 1)Une mutation sans filtre est refusée pour prévenir une modification accidentelle
de toute la table. L'upsert utilise INSERT … ON CONFLICT et la cible
onConflict doit correspondre à une contrainte unique réelle.
Storage par buckets (local ou S3)
Les buckets se configurent dans Studio → Storage. Un bucket privé applique
par défaut la propriété utilisateur (owner_id = @request.auth.id) à la lecture,
à l'écrasement et à la suppression ; l'upload nécessite une session. Le Studio
permet de régler séparément les quatre règles, la limite par fichier, les types
MIME et le caractère public.
const avatars = pg.storage.from('avatars')
const objectPath = `users/${user.id}/avatar.webp`
const { data: uploaded, error } = await avatars.upload(objectPath, file, {
contentType: 'image/webp',
cacheControl: 3600,
upsert: true,
metadata: { variant: 'profile' },
})
const { data: objects } = await avatars.list('users', {
search: 'avatar',
sortBy: 'updated',
order: 'desc',
})
const { data: signed } = await avatars.createSignedUrl(objectPath, 300)
image.src = signed!.signedUrl // accès privé exact, valable 5 minutes
const { data: uploadGrant } = await avatars.createSignedUploadUrl(objectPath)
await avatars.uploadToSignedUrl(objectPath, uploadGrant!.token, file)
await avatars.copy(objectPath, 'archive/avatar.webp')
await avatars.move('archive/avatar.webp', 'archive/avatar-final.webp')
let resume
await avatars.uploadResumable('videos/demo.mp4', file, {
onUploadCreated: (session) => { resume = session }, // à persister si nécessaire
onProgress: (uploaded, total) => console.log(uploaded / total),
// resume: { uploadId: resume.uploadId, token: resume.token },
})
const { data: blob } = await avatars.download(objectPath)
await avatars.remove([objectPath])Pour un bucket public, l'URL ne nécessite aucun aller-retour :
const { data } = avatars.getPublicUrl(objectPath)
image.src = data!.publicUrlLe backend physique est transparent pour l'application : disque local par
défaut, ou AWS S3/MinIO/Cloudflare R2/Wasabi/Spaces avec
STORAGE_DRIVER=s3. Les clés S3 restent uniquement sur le serveur et ne sont
jamais envoyées au SDK.
Queues PostgreSQL
Les producteurs et consommateurs partagent une file durable sans Redis. Une
lecture crée un lease et un receiptToken; il faut ensuite acquitter, remettre
en file ou archiver le message :
const emails = pg.queues.from<{ userId: string }>('emails')
await emails.send({ userId }, { delaySeconds: 5 })
const { data: messages } = await emails.read({ limit: 10 })
for (const message of messages ?? []) {
try {
await sendWelcomeEmail(message.payload.userId)
await emails.ack(message)
} catch {
await emails.nack(message, { delaySeconds: 30 })
}
}La création/configuration d'une file est réservée à un client backend initialisé
avec une clé pgb_secret_… possédant le scope queues :
await serverPg.queues.create('emails', {
visibilityTimeoutSeconds: 60,
maxAttempts: 5,
allowAuthEnqueue: true,
allowAuthDequeue: false,
})Fonctions PostgreSQL avec rpc()
Créez ou adoptez une fonction dans Studio → Fonctions RPC, testez-la, puis activez explicitement son endpoint. Aucune fonction n'est exposée simplement parce qu'elle existe dans PostgreSQL.
const { data, error, count, durationMs } = await pg.rpc('search_products', {
term: 'café',
max_results: 20,
})
if (error) console.error(error.message, error.details)
else console.log(data, count, durationMs)Une RPC peut être publique ou réservée aux utilisateurs connectés. Dans une
fonction, l'identité courante est disponible via les paramètres transactionnels
request.jwt.claims, request.jwt.sub, request.jwt.role et
request.jwt.collection. Les résultats SETOF/RETURNS TABLE sont renvoyés
comme tableaux (1 000 lignes maximum par appel), les autres comme une valeur.
Générer les types de vos collections
Téléchargez les interfaces générées depuis votre instance (admin ou clé API) :
curl https://api.exemple.re/api/types/typescript \
-H "X-API-Key: pgb_VOTRE_CLE" > pgbase-types.tsCela produit une interface par collection, une map Schema et une map RPC :
export interface PostsRecord {
id: string; created: string; updated: string
title: string
status: "draft" | "published"
views: number | null
}
export type Schema = { posts: PostsRecord; users: UsersRecord }
export type RPC = {
search_products: {
Args: { term: string | null; max_results?: number | null }
Returns: unknown
}
}Utilisation
import { PgBase } from '@lapub974/pgbase'
import type { Schema } from './pgbase-types'
const pb = new PgBase<Schema>('https://api.exemple.re')
// Auth utilisateur (collection auth « users »)
const { record } = await pb.collection('users').authWithPassword('[email protected]', 'motdepasse')
// le token est mémorisé dans pb.authStore et joint automatiquement
// Records typés
const { items } = await pb.collection('posts').getList({ page: 1, filter: 'status="published"', sort: '-created' })
const post = await pb.collection('posts').getOne(id, { expand: 'author' })
const created = await pb.collection('posts').create({ title: 'Bonjour', status: 'draft' })
await pb.collection('posts').update(id, { status: 'published' })
await pb.collection('posts').delete(id)
// Tout récupérer, agréger, importer
const all = await pb.collection('posts').getFullList({ filter: 'views>=100' })
const stats = await pb.collection('posts').aggregate({ op: 'sum', field: 'views', groupBy: ['status'] })
const res = await pb.collection('posts').import(rows, { atomic: false }) // { inserted, failed, errors }Côté serveur (secret = service_role)
const pgAdmin = createClient<Schema>(
'https://ma-boutique.pg.monappli.re',
process.env.PGBASE_SECRET_KEY!,
)
// ⚠️ uniquement côté serveur : pgb_secret_… contourne le RLS du projet.Les clés ont des scopes (auth, data.read, data.write, storage,
realtime, rpc, queues), une expiration facultative, une rotation atomique et une
révocation. Leur valeur complète n'est affichée qu'à la création/rotation.
Admin (console)
await pb.admins.login('[email protected]', 'motdepasse')
const me = await pb.admins.me()Fichiers attachés aux enregistrements
// upload via FormData sur create/update
const fd = new FormData()
fd.append('title', 'Avec image')
fd.append('cover', fileInput.files[0])
const rec = await pb.collection('posts').create(fd)
// URL de téléchargement (token court pour <img>)
const { token } = await pb.files.getToken()
const url = pb.files.getUrl('posts', rec.id, rec.cover[0], { token })Cette API historique lie le fichier au champ file d'un enregistrement. Pour
des chemins libres, des buckets et des URLs signées, utilisez pb.storage.
Temps réel
const unsub = await pb.collection('posts').subscribe((e) => {
// e.action: 'insert' | 'update' | 'delete', e.record typé
console.log(e.action, e.record)
})
// plus tard : unsub()Broadcast, Presence et changements PostgreSQL peuvent aussi être multiplexés dans un canal façon Supabase :
const room = pb.realtime.channel('private:checkout', { config: { private: true } })
.on('broadcast', { event: 'cart-updated' }, ({ payload }) => console.log(payload))
.on('presence', { event: 'sync' }, () => console.log(room.presenceState()))
.on('postgres_changes', { event: '*', schema: 'public', table: 'orders' }, console.log)
.subscribe()
await room.track({ online: true })
await room.send({ type: 'broadcast', event: 'cart-updated', payload: { count: 3 } })
await room.unsubscribe()La connexion se reconnecte automatiquement (backoff) après une coupure et
ré-émet l'abonnement avec le token courant (y compris après login). Le temps réel
s'authentifie avec la clé projet pendant le handshake et par token JWT pour
l'identité utilisateur. Une clé publishable sans login reste anonyme et est
filtrée par RLS ; une clé secret est service_role et doit donc rester côté
serveur. Le SDK transporte la clé dans un sous-protocole WebSocket, jamais dans
l'URL, afin qu'elle ne soit pas enregistrée par les journaux d'accès usuels.
Sous Node (pas de WebSocket global) :
import WebSocket from 'ws'
const pb = new PgBase<Schema>(url, { webSocket: WebSocket })À la déconnexion, utilisez pb.logout() (vide la session et ferme le temps réel).
Gestion des erreurs
Toute réponse non-2xx lève une PgBaseError portant l'enveloppe d'erreur PgBase :
import { PgBaseError } from '@lapub974/pgbase'
try {
await pb.collection('posts').getOne('inexistant')
} catch (e) {
if (e instanceof PgBaseError) console.log(e.status, e.message, e.details)
}Auth & jetons
pb.authStore mémorise token, refreshToken, sessionId et le modèle. En navigateur il
persiste dans un localStorage isolé par endpoint et projet ; passez votre
propre AuthStore ou storage au constructeur pour personnaliser.
auth.refreshSession() fait tourner le refresh token et met à jour la session
de façon atomique.
Build
npm ci
npm test # build TypeScript + tests du SDK
npm run build # tsc -> dist/ (ESM + .d.ts)
npm pack --dry-runLes versions publiques suivent Semantic Versioning. Les
changements sont consignés dans CHANGELOG.md.
