vybekiit 0.7.32 → 0.7.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +303 -85
- package/dist/global-skills/aws-serverless/SKILL.md +168 -32
- package/dist/global-skills/aws-serverless/references/api-gateway.md +17 -0
- package/dist/global-skills/aws-serverless/references/architecture.md +9 -13
- package/dist/global-skills/aws-serverless/references/lambda.md +3 -3
- package/dist/global-skills/aws-serverless/references/orchestration.md +46 -20
- package/dist/global-skills/aws-serverless/references/production.md +1 -1
- package/dist/global-skills/eas-simulator/SKILL.md +15 -15
- package/dist/global-skills/eas-simulator/references/controllers.md +11 -7
- package/dist/global-skills/eas-simulator/references/run-your-app.md +8 -8
- package/dist/global-skills/eas-simulator/references/troubleshooting.md +10 -6
- package/dist/global-skills/email-best-practices/references/sending-reliability.md +17 -22
- package/dist/global-skills/extension-to-functions-codebase/SKILL.md +1 -0
- package/dist/global-skills/firebase-ai-logic-basics/SKILL.md +161 -76
- package/dist/global-skills/firebase-ai-logic-basics/references/flutter_setup.md +37 -0
- package/dist/global-skills/firebase-ai-logic-basics/references/ios_setup.md +97 -3
- package/dist/global-skills/firebase-ai-logic-basics/references/usage_patterns_android.md +88 -3
- package/dist/global-skills/firebase-ai-logic-basics/references/usage_patterns_web.md +91 -3
- package/dist/global-skills/firebase-app-hosting-basics/SKILL.md +5 -1
- package/dist/global-skills/firebase-auth-basics/SKILL.md +1 -0
- package/dist/global-skills/firebase-auth-basics/references/client_sdk_android.md +9 -9
- package/dist/global-skills/firebase-auth-basics/references/client_sdk_web.md +3 -3
- package/dist/global-skills/firebase-auth-basics/references/flutter_setup.md +22 -19
- package/dist/global-skills/firebase-auth-basics/references/security_rules.md +2 -2
- package/dist/global-skills/firebase-basics/SKILL.md +6 -2
- package/dist/global-skills/firebase-basics/references/android_setup.md +101 -37
- package/dist/global-skills/firebase-basics/references/ios_setup.md +57 -0
- package/dist/global-skills/firebase-basics/references/web_setup.md +52 -0
- package/dist/global-skills/firebase-crashlytics/SKILL.md +1 -0
- package/dist/global-skills/firebase-crashlytics/references/android_setup.md +84 -84
- package/dist/global-skills/firebase-crashlytics/references/ios_setup.md +1 -0
- package/dist/global-skills/firebase-data-connect/SKILL.md +1 -0
- package/dist/global-skills/firebase-data-connect/reference/config.md +8 -8
- package/dist/global-skills/firebase-data-connect/reference/realtime.md +4 -1
- package/dist/global-skills/firebase-data-connect/reference/sdk_android.md +33 -34
- package/dist/global-skills/firebase-data-connect/reference/sdk_web.md +8 -3
- package/dist/global-skills/firebase-data-connect/reference/security.md +13 -13
- package/dist/global-skills/firebase-firestore/SKILL.md +17 -7
- package/dist/global-skills/firebase-firestore/references/enterprise/android_sdk_usage.md +32 -32
- package/dist/global-skills/firebase-firestore/references/enterprise/indexes.md +2 -1
- package/dist/global-skills/firebase-firestore/references/standard/android_sdk_usage.md +14 -14
- package/dist/global-skills/firebase-firestore/references/standard/indexes.md +2 -1
- package/dist/global-skills/firebase-hosting-basics/SKILL.md +1 -0
- package/dist/global-skills/firebase-remote-config-basics/SKILL.md +2 -1
- package/dist/global-skills/firebase-remote-config-basics/references/android_setup.md +36 -37
- package/dist/global-skills/firebase-security-rules-auditor/SKILL.md +7 -6
- package/dist/global-skills/firestore-rules-creation/SKILL.md +22 -10
- package/dist/global-skills/manifest.json +2 -1
- package/dist/global-skills/neon/SKILL.md +31 -10
- package/dist/global-skills/neon-ai-gateway/SKILL.md +43 -14
- package/dist/global-skills/neon-functions/SKILL.md +12 -11
- package/dist/global-skills/neon-functions/references/sse.md +2 -2
- package/dist/global-skills/neon-object-storage/SKILL.md +5 -4
- package/dist/global-skills/neon-postgres/SKILL.md +5 -2
- package/dist/global-skills/neon-postgres/references/full-text-search.md +27 -17
- package/dist/global-skills/neon-postgres/references/hybrid-search.md +19 -15
- package/dist/global-skills/neon-postgres/references/lakebase-search-drizzle.md +99 -14
- package/dist/global-skills/neon-postgres/references/vector-search.md +93 -10
- package/dist/global-skills/neon-realtime/SKILL.md +207 -0
- package/dist/global-skills/neon-realtime/references/mutations.md +743 -0
- package/dist/global-skills/neon-realtime/references/queries.md +509 -0
- package/dist/global-skills/neon-realtime/references/reactivity.md +407 -0
- package/dist/global-skills/neon-realtime/references/ssr.md +695 -0
- package/dist/global-skills/neon-realtime/references/subscribing.md +485 -0
- package/dist/global-skills/resend/SKILL.md +7 -16
- package/dist/global-skills/resend/references/domains.md +1 -1
- package/dist/global-skills/resend/references/inboxes.md +349 -0
- package/dist/global-skills/resend/references/installation.md +3 -3
- package/dist/global-skills/resend/references/sending/best-practices.md +3 -3
- package/dist/global-skills/resend/references/sending/single-email-examples.md +3 -3
- package/dist/global-skills/resend/references/webhooks.md +9 -0
- package/dist/global-skills/stripe-best-practices/SKILL.md +18 -18
- package/dist/global-skills/stripe-best-practices/references/tax.md +26 -14
- package/dist/global-skills/use-railway/SKILL.md +6 -6
- package/dist/global-skills/use-railway/references/iac.md +2 -1
- package/dist/global-skills/use-railway/references/tracing.md +13 -5
- package/dist/global-skills/workers-best-practices/SKILL.md +1 -1
- package/dist/global-skills/wrangler/SKILL.md +3 -3
- package/dist/global-skills/xcode-project-setup/SKILL.md +1 -0
- package/package.json +8 -8
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
<!-- Generated by scripts/sync-skill-docs.mjs from website@thruflo/realtime-2-docs:24a2fd708 (docs/realtime/guides/subscribing). Do not edit: change the docs and run npm run sync:docs. sha256:6d28cb355161f30b463590c44edc4e95dce5a899544807f30530cf4093bc074c -->
|
|
2
|
+
Canonical page: https://neon.com/docs/realtime/guides/subscribing.md
|
|
3
|
+
|
|
4
|
+
> Summary: Subscribe to live queries from trusted backend code or the browser. Fetch sealed queries from your API, handle every callback and renew sealed queries before they expire.
|
|
5
|
+
|
|
6
|
+
# Subscribing to queries
|
|
7
|
+
|
|
8
|
+
Subscribe to live queries using the Realtime client.
|
|
9
|
+
|
|
10
|
+
## Contents
|
|
11
|
+
|
|
12
|
+
- [Getting the query to subscribe to](#getting-the-query-to-subscribe-to)
|
|
13
|
+
- [Subscribe in trusted backend code](#subscribe-in-trusted-backend-code)
|
|
14
|
+
- [Fetch from an API endpoint](#fetch-from-an-api-endpoint)
|
|
15
|
+
- [Fetch from a server function](#fetch-from-a-server-function)
|
|
16
|
+
- [Subscribe to the sealed query](#subscribe-to-the-sealed-query)
|
|
17
|
+
- [Listen for changes](#listen-for-changes)
|
|
18
|
+
- [Renew the sealed query](#renew-the-sealed-query)
|
|
19
|
+
- [Handle errors](#handle-errors)
|
|
20
|
+
- [Debug subscriptions](#debug-subscriptions)
|
|
21
|
+
- [Log client diagnostics](#log-client-diagnostics)
|
|
22
|
+
- [Show full database errors](#show-full-database-errors)
|
|
23
|
+
|
|
24
|
+
Once you've [defined](queries.md) and [sealed](queries.md#sealing-queries) a query, you can [subscribe to it](#subscribe-to-the-sealed-query) using the [low‑level Realtime client](https://neon.com/docs/realtime/sdks/typescript.md#client-createrealtimeclient).
|
|
25
|
+
|
|
26
|
+
**Important: Client-side reactivity**
|
|
27
|
+
|
|
28
|
+
Manual subscriptions are a relatively low-level API. Most apps and agents can use the [higher‑level reactivity primitives](reactivity.md) that manage the subscription for you.
|
|
29
|
+
|
|
30
|
+
## Getting the query to subscribe to
|
|
31
|
+
|
|
32
|
+
Workers, trusted agents and other backend processes can [subscribe to a query directly](#subscribe-in-trusted-backend-code).
|
|
33
|
+
|
|
34
|
+
However, to subscribe to a live query in an untrusted environment, you'll need to [expose a sealed query from your backend](queries.md#exposing-queries), either a [standard API endpoint](#fetch-from-an-api-endpoint) or [using a server function](#fetch-from-a-server-function) (where [SSR](ssr.md) can embed the sealed query and the initial results).
|
|
35
|
+
|
|
36
|
+
**Tip: Running example**
|
|
37
|
+
|
|
38
|
+
The guide continues the [running example](https://github.com/neondatabase/examples/tree/main/realtime/guides-chat-app) introduced in the [Defining queries](queries.md#setup) guide.
|
|
39
|
+
|
|
40
|
+
### Subscribe in trusted backend code
|
|
41
|
+
|
|
42
|
+
Add a `url` to [`createRealtime()`](https://neon.com/docs/realtime/sdks/typescript.md#backend-createrealtime) and it returns a direct server, which seals queries with your secret and subscribes to them itself.
|
|
43
|
+
|
|
44
|
+
```ts filename="backend/worker.ts" {11,19,20,22}
|
|
45
|
+
import { createRealtime } from "@neon/realtime/server"
|
|
46
|
+
import { drizzleAdapter } from "@neon/realtime-drizzle"
|
|
47
|
+
|
|
48
|
+
import { messagesQuery } from "./queries"
|
|
49
|
+
|
|
50
|
+
// The worker acts for one known user, so its parameters are trusted constants
|
|
51
|
+
const SUPPORT_BOT_ID = "support-bot"
|
|
52
|
+
const SUPPORT_CHANNEL_ID = 2
|
|
53
|
+
|
|
54
|
+
const realtime = createRealtime({
|
|
55
|
+
url: process.env.NEON_REALTIME_URL!,
|
|
56
|
+
secret: process.env.NEON_REALTIME_SECRET!,
|
|
57
|
+
db: "neondb",
|
|
58
|
+
adapter: drizzleAdapter()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const query = messagesQuery(SUPPORT_BOT_ID, SUPPORT_CHANNEL_ID)
|
|
62
|
+
|
|
63
|
+
const subscription = await realtime.subscribe(query)
|
|
64
|
+
subscription.onChange(console.log)
|
|
65
|
+
|
|
66
|
+
process.once("SIGTERM", () => realtime.close())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Warning: Untrusted parameters**
|
|
70
|
+
|
|
71
|
+
Never fill a direct subscription's parameters from untrusted input.
|
|
72
|
+
|
|
73
|
+
`NEON_REALTIME_URL` is written by `neon env pull --service realtime`, or shown on the [Realtime page](https://neon.com/docs/realtime/quickstart.md#enable-realtime) in the Neon Console. Note the `await`: the direct server's `subscribe()` is async, because it seals the query locally before it subscribes. The browser client's `subscribe()` is synchronous. Otherwise it's the same subscription, with the same options and [callbacks](#listen-for-changes) as the client's.
|
|
74
|
+
|
|
75
|
+
The direct server renews its own sealed queries and recovers from outages until you close the subscription or it moves to `error`. An error is final, so a long-running worker should watch for it with `onStateChange` and subscribe again. `realtime.close()` closes every subscription and the shared WebSocket. The direct server opens its WebSocket with the `ws` package, which you installed in [setup](queries.md#setup), even on Node.js 22 and later.
|
|
76
|
+
|
|
77
|
+
Use direct subscriptions for workers, trusted agents and server-side fan-out.
|
|
78
|
+
|
|
79
|
+
### Fetch from an API endpoint
|
|
80
|
+
|
|
81
|
+
Most queries depend on who's asking and what they're asking for.
|
|
82
|
+
|
|
83
|
+
If you've [exposed your sealed query via an API endpoint](queries.md#exposing-an-endpoint), you can provide this context via the request data using your standard auth machinery, such as Neon's [Managed Better Auth](https://neon.com/docs/auth/overview.md):
|
|
84
|
+
|
|
85
|
+
```ts filename="frontend/auth.ts"
|
|
86
|
+
import { createAuthClient } from "@neondatabase/auth"
|
|
87
|
+
|
|
88
|
+
export const authClient = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
In our [running example](https://github.com/neondatabase/examples/tree/main/realtime/guides-chat-app), we create a typed client for the API with Hono's `hc`. It takes its routes from `AppType`, so every call is checked against the backend:
|
|
92
|
+
|
|
93
|
+
```ts filename="frontend/api.ts"
|
|
94
|
+
import type { SealedLiveQuery } from "@neon/realtime/client"
|
|
95
|
+
import { hc } from "hono/client"
|
|
96
|
+
|
|
97
|
+
import type { Message } from "../backend/schema"
|
|
98
|
+
import type { AppType } from "../backend/server"
|
|
99
|
+
import { authClient } from "./auth"
|
|
100
|
+
|
|
101
|
+
export const server = hc<AppType>(window.location.origin, {
|
|
102
|
+
headers: async (): Promise<Record<string, string>> => {
|
|
103
|
+
const { data } = await authClient.getSession()
|
|
104
|
+
const token = data?.session.token
|
|
105
|
+
|
|
106
|
+
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
export async function fetchSealedMessages(channelId: number): Promise<SealedLiveQuery<Message>> {
|
|
111
|
+
const res = await server.api.v1.messages.live.$post({ json: { channelId } })
|
|
112
|
+
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
throw new Error(`Could not fetch the sealed query (${res.status})`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (await res.json()) as SealedLiveQuery<Message>
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Hono's client types the response body as JSON, so the `Date` in `created_at` shows up as a `string`. The `as` restores the row type that the Realtime client decodes at runtime.
|
|
122
|
+
|
|
123
|
+
`getSession()` returns the cached session until its JWT is about to expire, then fetches a new one, so each request carries a valid token. Managed Better Auth tokens only last about 15 minutes. Without a session there's no token, so the API answers 401.
|
|
124
|
+
|
|
125
|
+
`window.location.origin` assumes your dev server proxies `/api` to the API, as the [example's Vite config](https://github.com/neondatabase/examples/tree/main/realtime/guides-chat-app/vite.config.ts) does.
|
|
126
|
+
|
|
127
|
+
### Fetch from a server function
|
|
128
|
+
|
|
129
|
+
If your framework supports [server functions](https://react.dev/reference/rsc/server-functions), you can expose the sealed query from one instead of an API endpoint. Client code calls a server function like a local async function, so it can take the place of `fetchSealedMessages()` [above](#fetch-from-an-api-endpoint). With [SSR](ssr.md), the page can also embed the sealed query and the initial query results on the server.
|
|
130
|
+
|
|
131
|
+
Server functions run on your app's origin, so they read the session from its cookie. See [Exposing queries](queries.md#exposing-queries), [Using server functions](queries.md#using-server-functions), the [Server-side rendering](ssr.md) guide and the [TanStack](https://neon.com/docs/realtime/examples/tanstack.md) example for more details.
|
|
132
|
+
|
|
133
|
+
**Important: Next.js uses Route Handlers**
|
|
134
|
+
|
|
135
|
+
In Next.js, server functions are server actions, which run one at a time, so renew through a [Route Handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) instead, as the [Next.js](https://neon.com/docs/realtime/examples/nextjs.md) example shows.
|
|
136
|
+
|
|
137
|
+
## Subscribe to the sealed query
|
|
138
|
+
|
|
139
|
+
Create one client for the whole app, because every subscription shares its connection:
|
|
140
|
+
|
|
141
|
+
```ts filename="frontend/client.ts"
|
|
142
|
+
import { createRealtimeClient } from "@neon/realtime/client"
|
|
143
|
+
import { drizzleParsers } from "@neon/realtime-drizzle/client"
|
|
144
|
+
|
|
145
|
+
export const client = createRealtimeClient({
|
|
146
|
+
url: import.meta.env.VITE_NEON_REALTIME_URL,
|
|
147
|
+
parsers: { ...drizzleParsers }
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The client opens its WebSocket lazily, on the first subscription, and multiplexes every subscription over that one connection. The first sealed query binds the connection to its database, so every subscription on a client must target the same `db`. The connection carries no user identity. Each subscription brings its own sealed query.
|
|
152
|
+
|
|
153
|
+
Now fetch the sealed query, subscribe to it and render every change:
|
|
154
|
+
|
|
155
|
+
```ts filename="frontend/app.ts"
|
|
156
|
+
import type { LiveQuerySnapshot, LiveQueryState } from "@neon/realtime/client"
|
|
157
|
+
|
|
158
|
+
import type { Message } from "../backend/schema"
|
|
159
|
+
import { fetchSealedMessages } from "./api"
|
|
160
|
+
import { client } from "./client"
|
|
161
|
+
|
|
162
|
+
const list = document.querySelector<HTMLUListElement>("#messages")!
|
|
163
|
+
const statusLine = document.querySelector<HTMLParagraphElement>("#status")!
|
|
164
|
+
|
|
165
|
+
// A real app takes the channel from its router
|
|
166
|
+
const channelId = 2
|
|
167
|
+
|
|
168
|
+
const sealedQuery = await fetchSealedMessages(channelId)
|
|
169
|
+
const subscription = client.subscribe(sealedQuery)
|
|
170
|
+
|
|
171
|
+
subscription.onChange(render)
|
|
172
|
+
|
|
173
|
+
window.addEventListener("beforeunload", () => {
|
|
174
|
+
subscription.unsubscribe()
|
|
175
|
+
client.close()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
function render(snapshot: LiveQuerySnapshot<Message>) {
|
|
179
|
+
statusLine.textContent = describe(snapshot)
|
|
180
|
+
list.replaceChildren(...(snapshot.data ?? []).map(renderMessage))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function describe(state: LiveQueryState): string {
|
|
184
|
+
switch (state.status) {
|
|
185
|
+
case "connecting":
|
|
186
|
+
return "Loading messages"
|
|
187
|
+
case "live":
|
|
188
|
+
return "Up to date"
|
|
189
|
+
case "stale":
|
|
190
|
+
return "Updating. Messages may be out of date."
|
|
191
|
+
case "error":
|
|
192
|
+
return `Stopped: ${state.error.message}`
|
|
193
|
+
case "closed":
|
|
194
|
+
return "Closed"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderMessage(message: Message): HTMLLIElement {
|
|
199
|
+
const item = document.createElement("li")
|
|
200
|
+
|
|
201
|
+
item.textContent = `${message.created_at.toLocaleTimeString()} ${message.body}`
|
|
202
|
+
|
|
203
|
+
return item
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`subscribe()` returns a materialized subscription, which keeps the current rows for you. `onChange` gets a snapshot of the rows and the subscription's state whenever either changes, so the render reads `data`, `status` and `error` from one consistent value. `data` is `undefined` until the first result arrives.
|
|
208
|
+
|
|
209
|
+
Rows arrive decoded, so `created_at` is a `Date`, as Drizzle's `Message` type says. The client's default parsers decode these Postgres types:
|
|
210
|
+
|
|
211
|
+
| Postgres type | Arrives as |
|
|
212
|
+
| ------------------------------------------ | ------------------------------------ |
|
|
213
|
+
| `bool` | `boolean` |
|
|
214
|
+
| `int2`, `int4`, `oid`, `float4`, `float8` | `number` |
|
|
215
|
+
| `json`, `jsonb` | The parsed JSON value |
|
|
216
|
+
| `timestamptz` | `Date` |
|
|
217
|
+
| `timestamp` | `Date` in local time |
|
|
218
|
+
| `date` | `Date` at local midnight |
|
|
219
|
+
| `bytea` | `Uint8Array` |
|
|
220
|
+
| `int8`, `numeric` | `string`, so their values stay exact |
|
|
221
|
+
| Everything else, such as `text` and `uuid` | `string`, as Postgres formats it |
|
|
222
|
+
|
|
223
|
+
`NULL` arrives as `null`, and arrays of built-in types decode element by element.
|
|
224
|
+
|
|
225
|
+
The client above spreads `drizzleParsers`, so rows match Drizzle's types: `date` stays a string and `timestamp` is read as UTC.
|
|
226
|
+
|
|
227
|
+
When you're done with a subscription, call `unsubscribe()`. It moves the subscription to `closed`, so any listeners get a final `closed` update, and then stops all further callbacks. `client.close()` does the same for every subscription on the client, and closes the WebSocket.
|
|
228
|
+
|
|
229
|
+
## Listen for changes
|
|
230
|
+
|
|
231
|
+
A subscription has three callbacks. Each one takes a listener and returns a function that removes it:
|
|
232
|
+
|
|
233
|
+
| Callback | Listener arguments | Fires |
|
|
234
|
+
| --------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `onChange` | `snapshot: LiveQuerySnapshot<Row>` | After each state change, after each `changes` update while `live`, and when a new result is complete. Materialized subscriptions only. |
|
|
236
|
+
| `onStateChange` | `state: LiveQueryState` | When the status changes. |
|
|
237
|
+
| `onUpdate` | `update: RawLiveQueryUpdate<Row>` | With `type: "rows"` and `rows`, each `{ rowId, row }`, when a complete, authoritative result replaces the previous one. With `type: "changes"`, `changes` and `batch: LiveQueryBatchInfo` when committed transactions change the result. Each change is an `upsert` with a `rowId` and `row`, or a `remove` with only a `rowId`. |
|
|
238
|
+
|
|
239
|
+
Listeners run in the order you add them, and one that throws doesn't stop the others. Each initial result and each committed transaction becomes visible atomically. Outside a listener, `getSnapshot()` and `getState()` return the current values.
|
|
240
|
+
|
|
241
|
+
The status tells you how far to trust the rows:
|
|
242
|
+
|
|
243
|
+
| Status | Meaning |
|
|
244
|
+
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
245
|
+
| `connecting` | Waiting for a result: when it starts, including with `initialData` from a [server render](ssr.md), after a reconnect, and after renewing an expired sealed query. Rows you already have stay. |
|
|
246
|
+
| `live` | The rows are authoritative and changes are streaming. It stays `live` while the server replaces the whole result. |
|
|
247
|
+
| `stale` | The sealed query expired and the subscription is waiting for a renewed one. The rows are usable but may be out of date. Render them normally. |
|
|
248
|
+
| `error` | Automatic recovery has stopped. See [handle errors](#handle-errors). |
|
|
249
|
+
| `closed` | Closed by `unsubscribe()` or `client.close()`. It doesn't reopen. |
|
|
250
|
+
|
|
251
|
+
By default, subscriptions are materialized. The client keeps the rows and applies each update before `onUpdate` fires, so `getSnapshot()` in an update listener already includes it. If your code keeps its own rows, as TanStack DB does, pass `{ materialize: false }` for a raw subscription. It has the same callbacks except `onChange`, and no `getSnapshot()`. Keep the rows in a map keyed by `rowId`, replace them on each `rows` update and apply each `changes` update's upserts and removes:
|
|
252
|
+
|
|
253
|
+
```ts filename="frontend/raw-messages.ts"
|
|
254
|
+
import type { LiveQueryState, RawLiveQueryUpdate } from "@neon/realtime/client"
|
|
255
|
+
|
|
256
|
+
import type { Message } from "../backend/schema"
|
|
257
|
+
import { fetchSealedMessages } from "./api"
|
|
258
|
+
import { client } from "./client"
|
|
259
|
+
|
|
260
|
+
const channelId = 2
|
|
261
|
+
|
|
262
|
+
// Keyed by rowId, which is only valid until the next `rows` update
|
|
263
|
+
const rows = new Map<string, Message>()
|
|
264
|
+
|
|
265
|
+
const subscription = client.subscribe(await fetchSealedMessages(channelId), { materialize: false })
|
|
266
|
+
|
|
267
|
+
subscription.onUpdate((update: RawLiveQueryUpdate<Message>) => {
|
|
268
|
+
if (update.type === "rows") {
|
|
269
|
+
rows.clear()
|
|
270
|
+
for (const { rowId, row } of update.rows) {
|
|
271
|
+
rows.set(rowId, row)
|
|
272
|
+
}
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const change of update.changes) {
|
|
277
|
+
if (change.type === "upsert") {
|
|
278
|
+
rows.set(change.rowId, change.row)
|
|
279
|
+
} else {
|
|
280
|
+
rows.delete(change.rowId)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log(`${rows.size} messages after transactions ${update.batch.txids.join(", ")}`)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
subscription.onStateChange((state: LiveQueryState) => {
|
|
288
|
+
console.log(state.status, state.error?.message ?? "")
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
A `rowId` is opaque and scoped to its subscription, and each `rows` update invalidates every earlier one. It isn't a primary key, so don't store it or match it against your own ids. A `rows` update can arrive at any time, for example after a reconnect, and it always replaces the whole result.
|
|
293
|
+
|
|
294
|
+
Each `changes` update also carries the ids of the Postgres transactions it covers, in `update.batch.txids`. The [mutations guide](mutations.md) uses them to confirm optimistic mutations.
|
|
295
|
+
|
|
296
|
+
## Renew the sealed query
|
|
297
|
+
|
|
298
|
+
Sealed queries are [short-lived](queries.md#renewing-sealed-queries), so a subscription that outlives its sealed query needs a fresh one. Fetch a replacement before `expiresAt` and pass it to `renew()`. This scheduler renews 10 seconds before expiry, retries a failed fetch after 1 second, reschedules from each new `expiresAt` and stops if `renew()` fails:
|
|
299
|
+
|
|
300
|
+
```ts filename="frontend/renewal.ts"
|
|
301
|
+
import type { RawLiveQuerySubscription, SealedLiveQuery } from "@neon/realtime/client"
|
|
302
|
+
|
|
303
|
+
// The same timing the React and TanStack DB bindings use
|
|
304
|
+
const RENEW_EARLY_MS = 10_000
|
|
305
|
+
const RETRY_MS = 1_000
|
|
306
|
+
|
|
307
|
+
export function renewBeforeExpiry<Row>(
|
|
308
|
+
subscription: RawLiveQuerySubscription<Row>,
|
|
309
|
+
sealedQuery: SealedLiveQuery<Row>,
|
|
310
|
+
fetchSealedQuery: () => Promise<SealedLiveQuery<Row>>
|
|
311
|
+
): () => void {
|
|
312
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
313
|
+
let stopped = false
|
|
314
|
+
|
|
315
|
+
function schedule(delay: number) {
|
|
316
|
+
clearTimeout(timer)
|
|
317
|
+
|
|
318
|
+
if (!stopped) {
|
|
319
|
+
timer = setTimeout(renew, delay)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function scheduleBeforeExpiry(expiresAt: number) {
|
|
324
|
+
schedule(Math.max(0, expiresAt - Date.now() - RENEW_EARLY_MS))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function renew() {
|
|
328
|
+
let next: SealedLiveQuery<Row>
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
next = await fetchSealedQuery()
|
|
332
|
+
} catch {
|
|
333
|
+
schedule(RETRY_MS)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
scheduleBeforeExpiry(next.expiresAt)
|
|
338
|
+
|
|
339
|
+
// A rejected renewal means the subscription is in `error` or the query changed
|
|
340
|
+
subscription.renew(next).catch(stop)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function stop() {
|
|
344
|
+
stopped = true
|
|
345
|
+
clearTimeout(timer)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
scheduleBeforeExpiry(sealedQuery.expiresAt)
|
|
349
|
+
|
|
350
|
+
return stop
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Start it after subscribing, with a function that fetches the same query again, and stop it when you unsubscribe:
|
|
355
|
+
|
|
356
|
+
```ts filename="frontend/app.ts" {6,16,21}
|
|
357
|
+
import type { LiveQuerySnapshot, LiveQueryState } from "@neon/realtime/client"
|
|
358
|
+
|
|
359
|
+
import type { Message } from "../backend/schema"
|
|
360
|
+
import { fetchSealedMessages } from "./api"
|
|
361
|
+
import { client } from "./client"
|
|
362
|
+
import { renewBeforeExpiry } from "./renewal"
|
|
363
|
+
|
|
364
|
+
const list = document.querySelector<HTMLUListElement>("#messages")!
|
|
365
|
+
const statusLine = document.querySelector<HTMLParagraphElement>("#status")!
|
|
366
|
+
|
|
367
|
+
// A real app takes the channel from its router
|
|
368
|
+
const channelId = 2
|
|
369
|
+
|
|
370
|
+
const sealedQuery = await fetchSealedMessages(channelId)
|
|
371
|
+
const subscription = client.subscribe(sealedQuery)
|
|
372
|
+
const stopRenewing = renewBeforeExpiry(subscription, sealedQuery, () => fetchSealedMessages(channelId))
|
|
373
|
+
|
|
374
|
+
subscription.onChange(render)
|
|
375
|
+
|
|
376
|
+
window.addEventListener("beforeunload", () => {
|
|
377
|
+
stopRenewing()
|
|
378
|
+
subscription.unsubscribe()
|
|
379
|
+
client.close()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
function render(snapshot: LiveQuerySnapshot<Message>) {
|
|
383
|
+
statusLine.textContent = describe(snapshot)
|
|
384
|
+
list.replaceChildren(...(snapshot.data ?? []).map(renderMessage))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function describe(state: LiveQueryState): string {
|
|
388
|
+
switch (state.status) {
|
|
389
|
+
case "connecting":
|
|
390
|
+
return "Loading messages"
|
|
391
|
+
case "live":
|
|
392
|
+
return "Up to date"
|
|
393
|
+
case "stale":
|
|
394
|
+
return "Updating. Messages may be out of date."
|
|
395
|
+
case "error":
|
|
396
|
+
return `Stopped: ${state.error.message}`
|
|
397
|
+
case "closed":
|
|
398
|
+
return "Closed"
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function renderMessage(message: Message): HTMLLIElement {
|
|
403
|
+
const item = document.createElement("li")
|
|
404
|
+
|
|
405
|
+
item.textContent = `${message.created_at.toLocaleTimeString()} ${message.body}`
|
|
406
|
+
|
|
407
|
+
return item
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`renew()` replaces the sealed query for the same exact query, and it keeps the subscription object, its listeners and its rows:
|
|
412
|
+
|
|
413
|
+
- Before expiry, the subscription stays `live`, with no new `rows` update.
|
|
414
|
+
- After expiry, for example when a laptop wakes from sleep, the subscription is `stale`. `renew()` moves it to `connecting` while the client subscribes again behind the same object, and to `live` when the new result arrives.
|
|
415
|
+
|
|
416
|
+
If the replacement's `queryFingerprint` doesn't match, the promise that `renew()` returns rejects. A changed parameter, like a different channel, makes a different query, so subscribe to the new one and unsubscribe from the old.
|
|
417
|
+
|
|
418
|
+
You don't write this yourself when you use the bindings. The [React and TanStack DB bindings](reactivity.md) take a `refreshQuery` function and renew with the same timing, 10 seconds before expiry with a 1-second retry. They're built on [`QueryRefreshController`](https://neon.com/docs/realtime/sdks/typescript.md#client-queryrefreshcontroller), which you can use if you're writing a binding for another framework.
|
|
419
|
+
|
|
420
|
+
## Handle errors
|
|
421
|
+
|
|
422
|
+
When automatic recovery stops, the subscription moves to `error`, and its `error` is a [`LiveQueryError`](https://neon.com/docs/realtime/sdks/typescript.md#client-livequeryerror) with:
|
|
423
|
+
|
|
424
|
+
- `code`: a stable, machine-readable error code
|
|
425
|
+
- `message`: a description of the failure
|
|
426
|
+
- `retryable`: whether reconnecting or a fresh sealed query may recover it
|
|
427
|
+
- `sqlState`: the Postgres SQLSTATE code, when [debug mode](#debug-subscriptions) is on and the database supplied one
|
|
428
|
+
- `cause`: the underlying error, when there is one
|
|
429
|
+
|
|
430
|
+
A subscription never leaves `error` by itself, and `renew()` can't bring it back. Unsubscribe, show the error, and subscribe again with a fresh sealed query, for example when the user retries.
|
|
431
|
+
|
|
432
|
+
Errors stay with their own subscription. A row that fails to decode, for example, puts only its own subscription into `error`, and the others on the client carry on.
|
|
433
|
+
|
|
434
|
+
If the connection itself fails, for example because of a fatal protocol error, every subscription on the client moves to `error` at once and the client can't reconnect. Create a new client with `createRealtimeClient()` before you subscribe again.
|
|
435
|
+
|
|
436
|
+
## Debug subscriptions
|
|
437
|
+
|
|
438
|
+
### Log client diagnostics
|
|
439
|
+
|
|
440
|
+
The client is silent by default. To see what it's doing, pass a `logLevel` to [`createRealtimeClient()`](https://neon.com/docs/realtime/sdks/typescript.md#client-createrealtimeclient): `"error"`, `"warn"`, `"info"` or `"debug"`. Each level includes the ones before it.
|
|
441
|
+
|
|
442
|
+
```ts filename="frontend/client.ts" {7}
|
|
443
|
+
import { createRealtimeClient } from "@neon/realtime/client"
|
|
444
|
+
import { drizzleParsers } from "@neon/realtime-drizzle/client"
|
|
445
|
+
|
|
446
|
+
export const client = createRealtimeClient({
|
|
447
|
+
url: import.meta.env.VITE_NEON_REALTIME_URL,
|
|
448
|
+
parsers: { ...drizzleParsers },
|
|
449
|
+
logLevel: import.meta.env.DEV ? "debug" : "warn"
|
|
450
|
+
})
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Entries go to the console unless you pass a [`logger`](https://neon.com/docs/realtime/sdks/typescript.md#client-realtimelogger), which receives each [entry](https://neon.com/docs/realtime/sdks/typescript.md#client-realtimelogentry) instead. An entry has a `level`, a `message`, a `timestamp` and a stable `event` name, like `connection_lost` or `subscription_failed`. Match on `event`, not on `message`:
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
logger: (entry) => {
|
|
457
|
+
if (entry.event === "subscription_failed") reportError(entry.error)
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Entries never include sealed queries, SQL, parameters or rows. An entry's `error` can hold an error from the server or from your own code, so handle it like any other error you log.
|
|
462
|
+
|
|
463
|
+
The React and TanStack DB bindings log through the client you give them. A direct server takes the same `logLevel` and `logger` options in `createRealtime({ url })`.
|
|
464
|
+
|
|
465
|
+
### Show full database errors
|
|
466
|
+
|
|
467
|
+
By default, subscription errors carry safe, generic messages. To see the database's own error while you develop, set `debugMode` on the server that seals your queries:
|
|
468
|
+
|
|
469
|
+
```ts filename="backend/realtime.ts" {8}
|
|
470
|
+
import { createRealtime } from "@neon/realtime/server"
|
|
471
|
+
import { drizzleAdapter } from "@neon/realtime-drizzle"
|
|
472
|
+
|
|
473
|
+
export const realtime = createRealtime({
|
|
474
|
+
secret: process.env.NEON_REALTIME_SECRET!,
|
|
475
|
+
db: "neondb",
|
|
476
|
+
adapter: drizzleAdapter(),
|
|
477
|
+
debugMode: process.env.NODE_ENV === "development"
|
|
478
|
+
})
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Every query it seals then reports errors with the database's full message, plus its SQLSTATE code in `sqlState` when there is one. It's a backend option because it travels inside each sealed query.
|
|
482
|
+
|
|
483
|
+
**Warning: Development only**
|
|
484
|
+
|
|
485
|
+
Full error details can expose schema, table and column names to anyone who subscribes, including through any error message your UI shows. Never turn on `debugMode` in production.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: resend
|
|
3
|
-
description: Use when working with the Resend email API — sending transactional emails (single or batch), receiving inbound emails via webhooks, managing email templates, tracking delivery events, managing domains, contacts, broadcasts, webhooks, API keys, automations, events, viewing API request logs, checking account usage and quotas, or setting up the Resend SDK. Always use this skill when the user mentions Resend, even for simple tasks like "send an email with Resend" — the skill contains critical gotchas (idempotency keys, webhook verification, template variable syntax) that prevent common production issues.
|
|
3
|
+
description: Use when working with the Resend email API — sending transactional emails (single or batch), receiving inbound emails via webhooks, managing email templates, tracking delivery events, managing domains, contacts, broadcasts, webhooks, API keys, inboxes (threads, labels, drafts, replies), automations, events, viewing API request logs, checking account usage and quotas, or setting up the Resend SDK. Always use this skill when the user mentions Resend, even for simple tasks like "send an email with Resend" — the skill contains critical gotchas (idempotency keys, webhook verification, template variable syntax) that prevent common production issues.
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: resend
|
|
7
|
-
version: "3.
|
|
7
|
+
version: "3.15.0"
|
|
8
8
|
homepage: https://resend.com/agent-skills
|
|
9
9
|
source: https://github.com/resend/resend-skills
|
|
10
10
|
openclaw:
|
|
@@ -45,6 +45,7 @@ references:
|
|
|
45
45
|
- automations.md
|
|
46
46
|
- events.md
|
|
47
47
|
- usage.md
|
|
48
|
+
- inboxes.md
|
|
48
49
|
- installation.md
|
|
49
50
|
- fetch-all-templates.mjs
|
|
50
51
|
disable-model-invocation: true
|
|
@@ -173,27 +174,17 @@ export async function POST(req: Request) {
|
|
|
173
174
|
| **Create automations** (event-driven workflows) | [automations.md](references/automations.md) — steps, connections, runs, conditions |
|
|
174
175
|
| **Define and send events** (automation triggers) | [events.md](references/events.md) — schemas, payloads, contact association |
|
|
175
176
|
| **Check account usage and quotas** | [usage.md](references/usage.md) — emails, contacts, segments, broadcasts, AI credits, automation runs, domains, rate limit |
|
|
177
|
+
| **Manage inboxes** (threads, replies, drafts) | [inboxes.md](references/inboxes.md) — labels, folders, search, agent settings, inbox webhook events |
|
|
176
178
|
| **Install SDK** (8+ languages) | [installation.md](references/installation.md) |
|
|
177
179
|
| **Set up an AI agent inbox** | Install the `agent-email-inbox` skill — covers security levels for untrusted input |
|
|
178
180
|
|
|
179
181
|
## SDK Version Requirements
|
|
180
182
|
|
|
181
|
-
Always install the latest SDK version.
|
|
183
|
+
Always install the latest SDK version. Older SDKs may be missing `webhooks.verify()`, `emails.receiving.get()`, or `domains.claims.*`.
|
|
182
184
|
|
|
183
|
-
|
|
184
|
-
|----------|---------|-------------|---------|
|
|
185
|
-
| Node.js | `resend` | >= 6.14.0 | `npm install resend` |
|
|
186
|
-
| Python | `resend` | >= 2.34.0 | `pip install resend` |
|
|
187
|
-
| Go | `resend-go/v3` | >= 3.11.0 | `go get github.com/resend/resend-go/v3` |
|
|
188
|
-
| Ruby | `resend` | >= 1.6.0 | `gem install resend` |
|
|
189
|
-
| PHP | `resend/resend-php` | >= 1.1.0 | `composer require resend/resend-php` |
|
|
190
|
-
| Rust | `resend-rs` | >= 0.26.1 | `cargo add resend-rs` |
|
|
191
|
-
| Java | `resend-java` | >= 4.16.0 | See [installation.md](references/installation.md) |
|
|
192
|
-
| .NET | `Resend` | >= 0.2.1 | `dotnet add package Resend` |
|
|
185
|
+
> **If the project already has a Resend SDK installed**, check its version against the minimum versions in [installation.md](references/installation.md) and upgrade if it's below.
|
|
193
186
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
See [installation.md](references/installation.md) for full installation commands, language detection, and cURL fallback.
|
|
187
|
+
See [installation.md](references/installation.md) for the minimum version per language, full installation commands, language detection, and cURL fallback.
|
|
197
188
|
|
|
198
189
|
## Common Setup
|
|
199
190
|
|
|
@@ -108,7 +108,7 @@ Claiming takes over a domain **another Resend account has already verified**. Th
|
|
|
108
108
|
Claim → Add TXT proof to DNS → Verify claim → (completed) → Update DKIM in DNS → Verify domain → Send
|
|
109
109
|
```
|
|
110
110
|
|
|
111
|
-
Claim methods are available in the **Node.js** (`resend >= 6.14.0`), **Python** (`resend >= 2.34.0`), **Ruby** (`resend >= 1.6.0`), **Go** (`resend-go/
|
|
111
|
+
Claim methods are available in the **Node.js** (`resend >= 6.14.0`), **Python** (`resend >= 2.34.0`), **Ruby** (`resend >= 1.6.0`), **Go** (`resend-go/v4 >= 4.0.0`), **Rust** (`resend-rs >= 0.26.1`), and **Java** (`resend-java >= 4.16.0`) SDKs, plus the **CLI** (`resend domains claim`) and REST API. Not yet in the PHP or .NET SDKs.
|
|
112
112
|
|
|
113
113
|
| Operation | Method | Notes |
|
|
114
114
|
|-----------|--------|-------|
|