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.
- 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_SECRETet 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. - 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.
- Les écritures passent par ta propre API, qui retourne le
txidde chaque écriture pourawaitTxId().
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, puisneon deploydansmy-app. - Active-la sur la branche liée avec
neon realtime enable --allowed-origin <app origin>, ou déclarepreview: { realtime: true }dansneon.ts(neon config add realtime) et lanceneon 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écritDATABASE_URL,NEON_REALTIME_URLetNEON_REALTIME_SECRET. Expose seulement l'URL au navigateur, sous le préfixe public de ton framework (commeVITE_). Les optionsurlprennentNEON_REALTIME_URL;dbprend le nom de la base de données depuisDATABASE_URL. - Installe
@neon/realtimeet 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
SELECTsans;et avec des placeholders$1. Elle ne peut pas appelernow()ougen_random_uuid(), donc passe ces valeurs en paramètres, en enveloppant chaque valeur sauf les strings, nombres, booléens, bigints et null dans un helperpgParam(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. ConfiguredebugModesurcreateRealtimepour les erreurs complètes de base de données (développement seulement). - Les queries scellées expirent. Passe
refreshQueryaux bindings ou appellerenew(), 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
getKeyde 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 utiliserrowId, que chaque mise à jour derowsinvalide. errorest final, même aprèsrenew(). Récupère une fresh query scellée et s'abonne à nouveau, avec un nouveau client si la connexion a échoué. SurveilleonStateChangesur les abonnements directs ;useLiveQuery()rapporte les erreurs viastatus, 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
DbClientTanStack 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 ouvrirdb.transaction()juste pourselect 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 leRETURNINGd'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
useStateplutôt qu'au niveau du module, rendinitialDatacomme des lignes live sans état de chargement, et ne partage jamais unDbClientTanStack entre les requêtes. - Garde les formes de lignes identiques sur les deux côtés.
JSON.stringifytransforme les dates en strings, etint8etnumericarrivent 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 |