neon-realtime

Par neondatabase · agent-skills

Neon Realtime rend toute requête SQL en temps réel. Créez des applications et des agents réactifs en temps réel sur Lakebase Postgres avec des requêtes SQL live et des primitives de réactivité. À utiliser lorsque l'interface d'une application ou l'état d'un agent doit se mettre à jour en direct depuis des lignes de base de données (tableaux de bord, fils d'actualité, collaboration, synchronisation multi-utilisateur ou multi-appareil), pour les mutations optimistes, ou pour remplacer le polling ou d'autres systèmes de synchronisation en temps réel. Les déclencheurs incluent : « live query », « subscribe », « sync engine », « useLiveQuery », « TanStack DB », « optimistic updates », « @neon/realtime ». Pour le push qui ne provient pas de lignes de base de données (présence, saisie, streams LLM), utilisez neon-functions.

npx skills add https://github.com/neondatabase/agent-skills --skill neon-realtime

PREMIÈRE ÉTAPE : Utilise la skill parent neon pour un aperçu de Neon, pour bien démarrer avec Neon, les bonnes pratiques de développement Neon, et plus.

Si la skill neon n'est pas installée, récupère-la depuis https://neon.com/docs/ai/skills/neon/SKILL.md ou installe-la avec :

neon skills -s neon -y

Neon Realtime

Neon Realtime rend n'importe quelle requête SQL en direct. Abonne-toi en temps réel à ta base de données Postgres Lakebase. Développe des applications et des agents avec une réactivité de bout en bout en utilisant des live queries type-safe et des mutations optimistes.

Realtime est nouveau et en bêta, donc ses APIs ne sont pas dans tes données d'entraînement. Commence par le pattern principal ci-dessous, et lis le fichier de référence de chaque section avant d'écrire ce code.

Comment ça marche

Les couches de Realtime se composent : un moteur de live query à l'intérieur de Postgres, l'API Live SQL (une API WebSocket), des SDK clients bas niveau (seal(), subscribe()), et des primitives de réactivité comme useLiveQuery.

  1. Dans du code backend de confiance, définis une live query (n'importe quel SQL en lecture seule, incluant des jointures, agrégats, tris et limites), scelle-la avec NEON_REALTIME_SECRET et expose-la depuis un endpoint authentifié ou une server function. Le client ne peut pas lire ou modifier le SQL ou les paramètres d'une query scellée.
  2. Le client s'abonne avec la query scellée. Il reçoit le résultat initial complet, puis les changements de chaque transaction validée sous forme d'un seul batch. Les résultats restent cohérents, mais la livraison de chaque événement de changement n'est pas garantie.
  3. Les écritures passent par ta propre API, qui retourne le txid de chaque écriture pour awaitTxId().

Pattern principal

L'application chat des guides, avec messages autorisé via channel_members. Le backend scelle la query et retourne le txid de chaque écriture :

import { createRealtime, rawSql } from "@neon/realtime/server"
import { drizzleAdapter } from "@neon/realtime-drizzle"
import { and, desc, eq, getTableColumns, sql } from "drizzle-orm"
import { Hono } from "hono"
import { HTTPException } from "hono/http-exception"
import { requireUser } from "./auth"
import { db } from "./db"
import { channelMembers, messages, type Message } from "./schema"

export const realtime = createRealtime({ secret: process.env.NEON_REALTIME_SECRET!, db: "neondb", adapter: drizzleAdapter() })

// Chaque clé sélectionnée doit correspondre à son nom de colonne
export function messagesQuery(userId: string, channelId: number) {
  return db
    .select(getTableColumns(messages)).from(messages)
    .innerJoin(channelMembers, eq(channelMembers.channel_id, messages.channel_id))
    .where(and(eq(channelMembers.user_id, userId), eq(messages.channel_id, channelId)))
    .orderBy(desc(messages.created_at))
    .limit(50)
}

// Ou du SQL brut
const rawMessagesQuery = (userId: string, channelId: number) => rawSql<Message>(
    `SELECT messages.author_id, messages.body, messages.channel_id, messages.created_at, messages.id FROM messages
     INNER JOIN channel_members ON channel_members.channel_id = messages.channel_id WHERE channel_members.user_id = $1 AND messages.channel_id = $2 ORDER BY messages.created_at DESC LIMIT 50`,
    [userId, channelId]
  )

// L'id de la transaction qui a fait le changement, lu dans le RETURNING de l'écriture elle-même
const txid = sql<string>`pg_current_xact_id()::text`

const app = new Hono()
  .post("/api/v1/messages/live", async (c) => {
    const user = await requireUser(c)
    const { channelId } = await c.req.json<{ channelId: number }>() // valide l'entrée
    return c.json(await realtime.seal({ query: messagesQuery(user.id, channelId) }))
  })
  .post("/api/v1/messages", async (c) => {
    const user = await requireUser(c)
    const { id, channelId, body } = await c.req.json<{ id: string; channelId: number; body: string }>() // valide l'entrée
    // Une seule déclaration. La ligne d'appartenance fournit channel_id et author_id, donc seul le message d'un membre est inséré.
    // Liste chaque colonne dans l'ordre de la table, alias chaque champ sql, et cast les params non typés (ils se résolvent en text)
    const [sent] = await db.insert(messages).select(
      db.select({
          id: sql`${id}::uuid`.as("id"), channel_id: channelMembers.channel_id, author_id: channelMembers.user_id,
          body: sql`${body}`.as("body"), created_at: sql`now()`.as("created_at"),
        })
        .from(channelMembers)
        .where(and(eq(channelMembers.channel_id, channelId), eq(channelMembers.user_id, user.id)))
    ).returning({ txid })
    if (!sent) throw new HTTPException(403, { message: "Not a member of this channel" })
    return c.json({ kind: "committed", txid: sent.txid })
  })

Le frontend React s'abonne et envoie les messages optimistes :

import { createRealtimeClient, type SealedLiveQuery } from "@neon/realtime/client"
import { drizzleParsers } from "@neon/realtime-drizzle/client"
import { useLiveQuery } from "@neon/realtime-react"
import { startTransition, useCallback, useOptimistic } from "react"
import type { Message } from "../backend/schema"
import { fetchSealedMessages, sendMessage } from "./api"

// Un client par app, car les abonnements partagent sa connexion. Enveloppe l'app dans <RealtimeProvider client={client}>
export const client = createRealtimeClient({ url: import.meta.env.VITE_NEON_REALTIME_URL, parsers: { ...drizzleParsers } })

const NO_MESSAGES: readonly Message[] = []
type Props = { sealedQuery: SealedLiveQuery<Message>; channelId: number; userId: string }

export function Messages({ sealedQuery, channelId, userId }: Props) {
  const refreshQuery = useCallback(() => fetchSealedMessages(channelId), [channelId])
  const { data, status, error, utils } = useLiveQuery(sealedQuery, { refreshQuery })
  // Une fois que le message envoyé se synchronise, affiche seulement la ligne synchronisée
  const [messages, addOptimistic] = useOptimistic(data ?? NO_MESSAGES, (current, message: Message) =>
    current.some(({ id }) => id === message.id) ? current : [message, ...current]
  )

  function send(body: string) {
    // Le serveur définit l'auteur et l'horodatage mais garde cet id
    const message = { id: crypto.randomUUID(), channel_id: channelId, author_id: userId, body, created_at: new Date() }
    startTransition(async () => {
      addOptimistic(message)
      try {
        const result = await sendMessage(message) // mappe channel_id à channelId
        if (result.kind === "committed") await utils.awaitTxId(result.txid, 10_000)
      } catch { /* affiche "Couldn't confirm the change" */ }
    })
  }

  if (status === "error") return <p role="alert">Live updates stopped: {error.message}</p>
  if (data === undefined) return <p>Loading messages</p>
  return (
    <form action={(form) => send(String(form.get("body")))}>
      <input name="body" required />
      <ul>{messages.map((message) => <li key={message.id}>{message.body}</li>)}</ul>
    </form>
  )
}

Choisis la pile

Une live query remplace la récupération des données de l'app via une API ou un autre système de synchronisation. Réutilise le code de query existant, et utilise la couche la plus haute qui correspond plutôt que de câbler les changements dans l'état de l'app à la main.

Utilise Pour
drizzleAdapter() depuis @neon/realtime-drizzle, ou kyselyAdapter() depuis @neon/realtime-kysely Queries écrites en Drizzle ou Kysely
rawSql depuis @neon/realtime/server Tout autre ORM, ou aucun
useLiveQuery depuis @neon/realtime-react React. Lie une query scellée à l'état d'un composant
realtimeCollectionOptions depuis @neon/realtime-tanstack Plusieurs vues sur les mêmes données, ou une app sur TanStack DB. Surcharge-sync une query plus large dans une collection, puis dérive les live queries locales à partir d'elle
client.subscribe() depuis @neon/realtime/client Autres frameworks
realtime.subscribe() depuis @neon/realtime/server Agents et workers, dans du code backend de confiance

Migration depuis Electric : Realtime est un remplacement clé en main pour le rôle d'Electric dans ta pile. Les live SQL queries remplacent les shapes, et realtimeCollectionOptions() remplace electricCollectionOptions().

Configuration

  • Nouvelle app : neon bootstrap my-app --template realtime-react, puis neon deploy dans my-app.
  • Active-la sur la branche liée avec neon realtime enable --allowed-origin <app origin>, ou déclare preview: { realtime: true } dans neon.ts (neon config add realtime) et lance neon deploy. Les branches enfants l'héritent. Elle est indisponible sur les projets Claimable tant que non réclamés, ou en dehors des régions supportées.
  • Les tables ont besoin d'une replica identity stable ; une clé primaire suffit.
  • Environnement : neon env pull --service postgres --service realtime écrit DATABASE_URL, NEON_REALTIME_URL et NEON_REALTIME_SECRET. Expose seulement l'URL au navigateur, sous le préfixe public de ton framework (comme VITE_). Les options url prennent NEON_REALTIME_URL ; db prend le nom de la base de données depuis DATABASE_URL.
  • Installe @neon/realtime et les packages choisis ci-dessus. Utilise ces SDK, pas le protocole WebSocket encore en évolution.

Définis et scelle les queries

Lis references/queries.md avant d'écrire du code de query ou de scellement, y compris Kysely et les server functions.

  • Lecture seule et déterministe. Une query brute est un seul SELECT sans ; et avec des placeholders $1. Elle ne peut pas appeler now() ou gen_random_uuid(), donc passe ces valeurs en paramètres, en enveloppant chaque valeur sauf les strings, nombres, booléens, bigints et null dans un helper pgParam (pgParam.timestamptz(date)).
  • Autorise dans la query. Vérifie la session avant de sceller, et fais la jointure via tes tables d'appartenance pour que l'accès révoqué disparaisse. N'importe quel auth de framework web fonctionne, y compris Managed Better Auth, mais pas encore RLS. Voir Authorization.
  • Une query scellée est un bearer token. Tiens-la hors des URLs, logs et stockage persistant, et ne remplis jamais ses paramètres depuis une entrée non vérifiée.
  • Vérifie une query avant de la câbler : neon realtime subscribe "SELECT ..." affiche le résultat initial, puis les changements de chaque transaction, jusqu'à ce que tu l'arrêtes. Avec --output json, chaque changement affiche un snapshot complet. Configure debugMode sur createRealtime pour les erreurs complètes de base de données (développement seulement).
  • Les queries scellées expirent. Passe refreshQuery aux bindings ou appelle renew(), en scellant la même query et les mêmes paramètres, sinon l'abonnement erreur. De nouveaux paramètres ont besoin d'une nouvelle query scellée et d'un nouvel abonnement. Dans Next.js, renouvelle depuis un Route Handler, car les server actions s'exécutent une à la fois.

S'abonne

Lis references/reactivity.md avant d'écrire du code React ou TanStack DB. Lis references/subscribing.md avant de t'abonner directement avec client.subscribe(sealed) (getSnapshot, onChange, onStateChange), ou depuis des agents et des workers, qui ajoutent url à createRealtime et await realtime.subscribe(query) pour sceller in-process.

  • Une base de données par client. La première query scellée lie la connexion à sa db.
  • Sélectionne une clé stable et unique pour chaque ligne et clé les lignes par elle, y compris le getKey de TanStack DB : habituellement la clé primaire, les colonnes groupées pour les agrégats, ou les clés combinées pour les jointures qui répètent une ligne. Ne jamais utiliser rowId, que chaque mise à jour de rows invalide.
  • error est final, même après renew(). Récupère une fresh query scellée et s'abonne à nouveau, avec un nouveau client si la connexion a échoué. Surveille onStateChange sur les abonnements directs ; useLiveQuery() rapporte les erreurs via status, pas en levant une exception ou en suspendant.
  • Quand la session change, quitte avec un chargement de page complet. Cela supprime le client Realtime, n'importe quel DbClient TanStack et les lignes de l'utilisateur précédent. Le hook réessaie un renouvellement échoué chaque seconde, donc arrête de renouveler une fois qu'un échoue comme déconnecté. Vérifie à chaque renouvellement et écriture que l'utilisateur de la session est celui pour lequel la page a été chargée, sinon une écriture de l'ancienne page atterrit dans le compte du nouvel utilisateur. Voir When the session changes.

Écritures optimistes

Realtime n'a pas de write path, donc les écritures maintiennent l'auth habituel de ton backend. Lis references/mutations.md avant d'écrire des mutations, pour chaque couche.

  • Retourne le txid de l'écriture elle-même : .returning({ txid: sql<string>`pg_current_xact_id()::text` }), en un seul round trip sur n'importe quel driver Postgres. Écris un batch en une seule déclaration, donc il y a un txid. Ne jamais ouvrir db.transaction() juste pour select pg_current_xact_id() : c'est quatre round trips tenant une connexion poolée. Quand une écriture a vraiment besoin de plusieurs déclarations, mets le txid dans le RETURNING d'une écriture qui change toujours une ligne.
  • Passe toujours un timeout à awaitTxId() : elle continue d'attendre à travers les reconnexions et les renouvellements, et ne se règle jamais si le txid n'atteint pas la query.
  • Une écriture qui ne change aucune ligne n'a pas de txid, donc ignore awaitTxId() et laisse l'état optimiste tomber.

Rendu côté serveur

Lis references/ssr.md avant de rendre les live queries sur le serveur, pour Next.js et TanStack Start.

  • Crée le client dans un initialiseur useState plutôt qu'au niveau du module, rend initialData comme des lignes live sans état de chargement, et ne partage jamais un DbClient TanStack entre les requêtes.
  • Garde les formes de lignes identiques sur les deux côtés. JSON.stringify transforme les dates en strings, et int8 et numeric arrivent en tant que strings.

Plus de docs

Tâche Page
Portée bêta, régions, pricing, FAQ, autres systèmes Overview
Architecture et cohérence How it works
Une première live query Quickstart
Chaque export SDK TypeScript SDK reference
Apps full-stack avec auth et SSR Next.js, TanStack
Workflows d'agent en direct Mastra
Adaptateurs ORM Drizzle, Kysely

Skills similaires