@poolse/sdk 1.0.6 → 2.0.0
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/CHANGELOG.md +57 -0
- package/README.md +343 -36
- package/dist/index.cjs +31 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -21
- package/dist/index.d.ts +44 -21
- package/dist/index.js +31 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,63 @@ All notable changes to `@poolse/sdk` are documented here. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
5
|
[semver](https://semver.org).
|
|
6
6
|
|
|
7
|
+
## [2.0.0] — 2026-06-03
|
|
8
|
+
|
|
9
|
+
Lockstep release with `@poolse/react@2.0.0` and `@poolse/react-ui@2.0.0`.
|
|
10
|
+
Single load-bearing change: the SDK no longer surfaces poolse-internal
|
|
11
|
+
user uuids in identity-shaped APIs — everything is keyed by the
|
|
12
|
+
tenant's own `external_id` instead.
|
|
13
|
+
|
|
14
|
+
### Breaking
|
|
15
|
+
|
|
16
|
+
- **`PoolseConfig.userResolver` signature changes.** Was
|
|
17
|
+
`(userId: string) => UserProfile | null`; now
|
|
18
|
+
`(externalId: string) => UserProfile | null`. Your resolver receives
|
|
19
|
+
YOUR user id (the same string you pass when minting JWTs and as
|
|
20
|
+
`member_external_ids`), so no `poolse_user_id` column is needed.
|
|
21
|
+
- **`Message.sender_external_id`** added (string | null, required on
|
|
22
|
+
every wire payload that carries a sender).
|
|
23
|
+
- **`Membership.external_id`** added (string, required).
|
|
24
|
+
- **`QuotedMessagePreview.sender_external_id`** added.
|
|
25
|
+
- **`TypingEvent.external_id`** added — `useTyping` returns
|
|
26
|
+
`Set<externalId>` instead of `Set<userId>`.
|
|
27
|
+
- **`UsersResource.{peek, get, subscribe, invalidate}`** all keyed by
|
|
28
|
+
`externalId` (was `userId`). Same semantics; same caching.
|
|
29
|
+
|
|
30
|
+
### Migration
|
|
31
|
+
|
|
32
|
+
See `MIGRATING.md` at the repo root. For most apps it's a rename of
|
|
33
|
+
the resolver argument plus deleting the `poolse_user_id` column if
|
|
34
|
+
you stored one.
|
|
35
|
+
|
|
36
|
+
### Backend coupling
|
|
37
|
+
|
|
38
|
+
Pairs with the `poolse-server` change that:
|
|
39
|
+
- lazy-provisions unknown `external_id`s referenced in
|
|
40
|
+
`POST /v1/conversations` (`member_external_ids`) and
|
|
41
|
+
`POST /v1/conversations/:id/members` (`external_ids`), abuse-capped
|
|
42
|
+
at 50/hour per JWT,
|
|
43
|
+
- emits `sender_external_id` / `external_id` on every user-shaped
|
|
44
|
+
wire payload (REST, webhook, realtime).
|
|
45
|
+
|
|
46
|
+
Self-hosted `@poolse/sdk@2.0.0` against an older backend will get
|
|
47
|
+
`sender_external_id: null` everywhere — degraded display, but no crash.
|
|
48
|
+
|
|
49
|
+
## [1.1.0] — 2026-06-02
|
|
50
|
+
|
|
51
|
+
Lockstep release with `@poolse/react@1.1.0` and `@poolse/react-ui@1.1.0`.
|
|
52
|
+
No API changes in `@poolse/sdk` itself — version bumped to keep the
|
|
53
|
+
three packages in sync, so customers can pin one version across the
|
|
54
|
+
whole client surface.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- README rewritten as a full reference: authentication model, every
|
|
59
|
+
resource and method with its HTTP path, the realtime channel event
|
|
60
|
+
catalogue with payload shapes, token caching semantics, idempotency
|
|
61
|
+
conventions, retry behaviour, and the typed error hierarchy.
|
|
62
|
+
- Repo-wide prettier formatting fixes (whitespace only).
|
|
63
|
+
|
|
7
64
|
## [1.0.0-beta.0] — 2026-06-01
|
|
8
65
|
|
|
9
66
|
First beta of the stable API surface. Consolidates everything from
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# `@poolse/sdk`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Headless TypeScript SDK for [poolse](https://poolse.dev) — REST + WebSocket client for the poolse chat backend. No UI, no framework dependency. Runs in any environment with `fetch` and `WebSocket` (browsers, Node ≥ 18, Deno, Bun, React Native).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
If you're using React, you'll usually want [`@poolse/react`](https://www.npmjs.com/package/@poolse/react) (hooks) or [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui) (prebuilt chat surface). Both sit on top of this package; you can drop down to it whenever you outgrow them.
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> **⚠️ Upgrading from 1.x?** See [MIGRATING.md](https://github.com/poolse-hq/js-sdk/blob/main/MIGRATING.md). 2.0 is a breaking change: identity APIs are now keyed by your `external_id` instead of poolse uuids — `userResolver`, `useUser`, member operations all flip.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -12,67 +12,374 @@ REST + WebSocket + presence + typing + reactions + threads + attachments, all in
|
|
|
12
12
|
npm install @poolse/sdk
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
The Phoenix Channels client (`phoenix@^1.8`) is bundled into the dist — you don't need to install it separately.
|
|
16
|
+
|
|
17
|
+
## Authentication model
|
|
18
|
+
|
|
19
|
+
poolse separates **API keys** (server-side, full tenant scope) from **End User JWTs** (client-side, short-lived, single-user). The SDK is designed for the JWT side: your backend exchanges its API key for an End User JWT (`POST /v1/users/:user_id/tokens`), and the SDK uses that JWT for every REST call and the WebSocket handshake.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌────────────┐ API key ┌─────────┐ user JWT ┌─────────────┐
|
|
23
|
+
│ your │ ────────▶ │ poolse │ ─────────▶ │ your │
|
|
24
|
+
│ backend │ │ REST │ │ frontend │
|
|
25
|
+
│ (mints │ │ │ │ (this SDK) │
|
|
26
|
+
│ JWTs) │ ◀──────── │ │ ◀───────── │ │
|
|
27
|
+
└────────────┘ └─────────┘ └─────────────┘
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Never embed an API key in a client bundle. The SDK calls your `getToken` whenever it needs the JWT; cache + refresh happen inside the SDK.
|
|
31
|
+
|
|
15
32
|
## Quick start
|
|
16
33
|
|
|
17
34
|
```ts
|
|
18
35
|
import { Poolse } from '@poolse/sdk';
|
|
19
36
|
|
|
20
37
|
const chat = new Poolse({
|
|
21
|
-
apiUrl: 'https://
|
|
22
|
-
// Your backend mints a JWT for the signed-in End User and returns it.
|
|
23
|
-
// The SDK caches + refreshes via this callback — never embed an API key
|
|
24
|
-
// in the browser.
|
|
38
|
+
apiUrl: 'https://api.poolse.dev', // optional; this is the default
|
|
25
39
|
getToken: async () => {
|
|
26
40
|
const res = await fetch('/api/chat-token', { method: 'POST' });
|
|
27
41
|
const { token } = await res.json();
|
|
28
|
-
return token;
|
|
42
|
+
return token; // string, or null for an anonymous request
|
|
29
43
|
},
|
|
30
44
|
});
|
|
31
45
|
|
|
32
46
|
// REST
|
|
33
47
|
const me = await chat.me.show();
|
|
34
48
|
const { data: conversations } = await chat.conversations.list();
|
|
49
|
+
const conv = chat.conversations.one('00000000-0000-0000-0000-000000000000');
|
|
50
|
+
const { data: messages } = await conv.messages.list({ limit: 50 });
|
|
51
|
+
await conv.messages.send({ body: 'Hello' });
|
|
35
52
|
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const off = chat.realtime
|
|
41
|
-
.conversation('<conversation-id>')
|
|
42
|
-
.onMessage((msg) => console.log('new message', msg));
|
|
53
|
+
// Realtime — socket opens lazily on the first conversation() / user() call.
|
|
54
|
+
const live = chat.realtime.conversation(conv.id);
|
|
55
|
+
const off = live.onMessage((msg) => console.log('new', msg));
|
|
43
56
|
|
|
44
|
-
//
|
|
57
|
+
// Clean up when you're done.
|
|
45
58
|
off();
|
|
46
|
-
chat.destroy();
|
|
59
|
+
chat.destroy(); // closes WebSocket, leaves every joined channel
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
`new Poolse(config)` — every field but `getToken` has a default.
|
|
65
|
+
|
|
66
|
+
| Field | Type | Default | Notes |
|
|
67
|
+
| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
68
|
+
| `getToken` | `() => Promise<string \| null> \| string \| null` | — _required_ | Called when the SDK needs a JWT. Return `null` to make an unauthenticated request (rare — the server requires a JWT for most routes). |
|
|
69
|
+
| `apiUrl` | `string` | `https://api.poolse.dev` | Base URL **without** `/v1`. Strip the trailing slash if present (the SDK does this too). |
|
|
70
|
+
| `wsUrl` | `string` | `apiUrl` with `http(s)` → `ws(s)` | Override for split-host deployments where the WebSocket gateway is on a different origin. |
|
|
71
|
+
| `socketPath` | `string` | `/socket` | Phoenix Channels mount point. |
|
|
72
|
+
| `fetch` | `typeof fetch` | `globalThis.fetch` | Inject a fetch implementation (tests, restricted environments). Must be bound to `globalThis` if you pass the global one explicitly. |
|
|
73
|
+
| `maxRetries` | `number` | `3` | Retry budget for transient failures, per request. |
|
|
74
|
+
| `baseBackoffMs` | `number` | `250` | Base of the exponential backoff. |
|
|
75
|
+
| `maxBackoffMs` | `number` | `30000` | Hard ceiling on a single retry delay. |
|
|
76
|
+
| `generateIdempotencyKey` | `() => string` | `crypto.randomUUID()` | Override the key generator. Defaults throws at construction time if no `crypto.randomUUID` is available. |
|
|
77
|
+
| `onSocketError` | `(err: Error) => void` | — | Fired on non-fatal socket errors (Phoenix handles reconnect internally; this is for surface-level banners). |
|
|
78
|
+
| `userResolver` | `(externalId: string) => Promise<PoolseUserProfile \| null> \| PoolseUserProfile \| null` | — | Optional. Resolve the tenant's own user identifier (`external_id` — same string you pass when minting JWTs) to `{ displayName, avatarUrl }` from your app's user data. The UI packages pick this up automatically. |
|
|
79
|
+
|
|
80
|
+
## REST surface
|
|
81
|
+
|
|
82
|
+
All methods accept an optional `AbortSignal` (or `{ signal }`) and return parsed JSON. Errors throw typed exceptions — see [Errors](#errors) below.
|
|
83
|
+
|
|
84
|
+
### `chat.me`
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
chat.me.show(signal?): Promise<Me>; // GET /v1/me
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `chat.conversations`
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
chat.conversations.list(signal?): Promise<{ data: Conversation[] }>;
|
|
94
|
+
chat.conversations.create(attrs, signal?): Promise<Conversation>;
|
|
95
|
+
chat.conversations.one(id): ConversationHandle;
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`ConversationCreateRequest`:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
{
|
|
102
|
+
type: 'direct' | 'group';
|
|
103
|
+
name?: string | null;
|
|
104
|
+
avatar_url?: string | null;
|
|
105
|
+
member_limit?: number | null;
|
|
106
|
+
member_external_ids?: string[]; // your IDs; poolse looks them up by tenant
|
|
107
|
+
custom_data?: Record<string, unknown>;
|
|
108
|
+
settings?: Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`ConversationHandle` (returned by `chat.conversations.one(id)`):
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
handle.show(signal?): Promise<Conversation>; // GET /v1/conversations/:id
|
|
116
|
+
handle.update(attrs, signal?): Promise<Conversation>; // PATCH /v1/conversations/:id
|
|
117
|
+
|
|
118
|
+
handle.listMembers(signal?): Promise<{ data: Membership[] }>; // GET /v1/conversations/:id/members
|
|
119
|
+
handle.addMembers(externalIds, { role?, signal? }?): Promise<{ data: Membership[] }>;
|
|
120
|
+
handle.addMember(externalId, { role?, signal? }?): Promise<Membership>;
|
|
121
|
+
handle.removeMember(userId, signal?): Promise<void>;
|
|
122
|
+
|
|
123
|
+
handle.messages: ConversationMessages;
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`handle.messages`:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
handle.messages.list({ limit?, before? }?, signal?): Promise<{ data: Message[] }>;
|
|
130
|
+
handle.messages.send(attrs, signal?): Promise<Message>;
|
|
131
|
+
handle.messages.markRead(messageId, signal?): Promise<void>; // POST /v1/conversations/:id/read
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Messages are paginated **newest-first** by per-conversation `sequence`. To load older messages: `list({ limit: 50, before: oldestSequence })`.
|
|
135
|
+
|
|
136
|
+
`MessageCreateRequest`:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
{
|
|
140
|
+
id?: Uuid; // optional client-supplied id for retry-safe sends
|
|
141
|
+
body?: string | null; // optional ONLY when attachment_ids is non-empty
|
|
142
|
+
type?: 'text' | 'system' | 'custom';
|
|
143
|
+
reply_to_id?: Uuid; // setting this promotes the message to a thread reply
|
|
144
|
+
quoted_message_id?: Uuid; // WhatsApp-style quote — stays in main feed
|
|
145
|
+
mentions?: Uuid[]; // user_ids to notify
|
|
146
|
+
attachment_ids?: Uuid[]; // attach pre-uploaded files; max 10
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
If you omit `id`, `send` auto-generates one via `crypto.randomUUID()` so retries are idempotent and the realtime echo can be deduped against your optimistic insert.
|
|
151
|
+
|
|
152
|
+
### `chat.messages`
|
|
153
|
+
|
|
154
|
+
The message handle is keyed by message id (not conversation), because some operations need the message id but not the conversation id.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
chat.messages.one(id): MessageHandle;
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`MessageHandle`:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
handle.update({ body }, signal?): Promise<Message>; // PATCH /v1/messages/:id
|
|
164
|
+
handle.delete(signal?): Promise<void>; // DELETE /v1/messages/:id (soft-delete)
|
|
165
|
+
handle.replies({ limit?, after? }?, signal?): Promise<{ data: Message[] }>; // GET /v1/messages/:id/replies
|
|
166
|
+
handle.addReaction(emoji, signal?): Promise<Message>; // POST /v1/messages/:id/reactions
|
|
167
|
+
handle.removeReaction(emoji, signal?): Promise<Message>; // DELETE /v1/messages/:id/reactions/:emoji
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Thread replies are paginated **oldest-first** with an `after` cursor (opposite of the main feed). The default `limit` for replies is 500 — most threads load in one request.
|
|
171
|
+
|
|
172
|
+
### `chat.attachments`
|
|
173
|
+
|
|
174
|
+
Two-step upload: presign → PUT. The SDK has a one-call helper too.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
chat.attachments.requestUpload(attrs, opts?): Promise<AttachmentUploadResponse>; // POST /v1/attachments/upload-url
|
|
178
|
+
chat.attachments.upload(input, opts?): Promise<Attachment>; // presign + PUT, returns the row
|
|
179
|
+
chat.attachments.one(id): AttachmentHandle;
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`AttachmentUploadInput`:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
{
|
|
186
|
+
body: BodyInit; // Blob, File, ArrayBufferView, string, etc.
|
|
187
|
+
contentType: string; // matches what you'll PUT
|
|
188
|
+
byteSize: number; // matches Content-Length of `body`
|
|
189
|
+
filename?: string; // shown in the download UI
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`AttachmentOptions`:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
{
|
|
197
|
+
signal?: AbortSignal;
|
|
198
|
+
onProgress?: (event: { loaded: number; total: number }) => void;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`onProgress` switches the PUT to `XMLHttpRequest` (the only browser API that exposes upload progress). When you don't pass `onProgress`, the SDK uses `fetch` and you get no progress events. `XMLHttpRequest` is browser-only, so progress in Node will silently no-op.
|
|
203
|
+
|
|
204
|
+
To attach to a message, take the returned `attachment.id` and pass it as part of `attachment_ids` on the next `send`. The server links + flips status to `ready` in the same transaction.
|
|
205
|
+
|
|
206
|
+
`AttachmentHandle`:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
handle.downloadUrl(opts?): Promise<{ url: string; method: 'get' }>; // ~1h TTL
|
|
210
|
+
handle.delete(opts?): Promise<void>;
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### `chat.users`
|
|
214
|
+
|
|
215
|
+
A small read-through cache for the **customer-supplied** profile (display name + avatar). The SDK doesn't store these — it asks your `config.userResolver` when it doesn't have them.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
chat.users.peek(userId): PoolseUserProfile | null | undefined;
|
|
219
|
+
// undefined → not yet fetched (or no resolver)
|
|
220
|
+
// null → resolver ran, returned null
|
|
221
|
+
// profile → cached hit
|
|
222
|
+
|
|
223
|
+
chat.users.get(userId): Promise<PoolseUserProfile | null>;
|
|
224
|
+
chat.users.subscribe(userId, listener): () => void; // fires when an entry changes
|
|
225
|
+
chat.users.invalidate(userId): void;
|
|
226
|
+
chat.users.invalidateAll(): void; // sign-out, tenant swap
|
|
47
227
|
```
|
|
48
228
|
|
|
49
|
-
|
|
229
|
+
Concurrent `get(userId)` calls for the same id share one in-flight resolver promise — a busy chat with 50 messages from 5 senders calls your resolver 5 times.
|
|
230
|
+
|
|
231
|
+
### `chat.rest`
|
|
232
|
+
|
|
233
|
+
Escape hatch for endpoints not yet covered by the typed resources (e.g., admin routes). Same retry + auth + idempotency behavior as the typed methods.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const result = await chat.rest.request<MyShape>({
|
|
237
|
+
method: 'POST',
|
|
238
|
+
path: '/v1/something',
|
|
239
|
+
body: { foo: 'bar' },
|
|
240
|
+
query: { limit: 10 },
|
|
241
|
+
idempotencyKey: 'my-custom-key', // or null to omit; defaults to a fresh UUID for non-GETs
|
|
242
|
+
signal,
|
|
243
|
+
});
|
|
244
|
+
```
|
|
50
245
|
|
|
51
|
-
|
|
52
|
-
- **Realtime channels**: `message:new`, `message:updated`, `message:deleted`, `typing:start/stop`, `reaction:added/removed`, presence state + diff, per-user `mention:new` + `conversation:created`.
|
|
53
|
-
- **Token caching** — `getToken` is called once per JWT lifetime, not per request. Auto-invalidates on 401 and re-issues.
|
|
54
|
-
- **Idempotency** — every non-GET request carries an auto-generated `Idempotency-Key`, so retries (network or 5xx) never insert duplicates.
|
|
55
|
-
- **Backoff** — exponential with full jitter, honors `Retry-After` on 429, never retries `AbortError`.
|
|
56
|
-
- **Typed errors** — `AuthError` / `ApiError` / `NetworkError` / `RateLimitedError`, all `instanceof`-checkable.
|
|
57
|
-
- **Upload progress** — pass `onProgress` to `attachments.upload(...)` and the SDK switches to XHR for the PUT so byte-level progress events fire during the upload to your storage backend.
|
|
58
|
-
- **User resolution** — pass `userResolver(userId)` and `chat.users.get(userId)` returns customer-supplied `{ displayName, avatarUrl }` (in-memory cached + dedup'd). The React UI components pick this up automatically; vanilla callers can read it directly.
|
|
246
|
+
## Realtime
|
|
59
247
|
|
|
60
|
-
|
|
248
|
+
The WebSocket is **lazily opened** on the first `conversation()` or `user()` call — until then the realtime layer is dormant and won't try to authenticate.
|
|
61
249
|
|
|
250
|
+
```ts
|
|
251
|
+
chat.realtime.connect(): void; // idempotent; usually you don't call this directly
|
|
252
|
+
chat.realtime.disconnect(): void;
|
|
253
|
+
chat.realtime.getStatus(): RealtimeStatus;
|
|
254
|
+
chat.realtime.onStatus(fn): Unsubscribe;
|
|
255
|
+
chat.realtime.conversation(id): ConversationChannel;
|
|
256
|
+
chat.realtime.user(id): UserChannel;
|
|
257
|
+
chat.realtime.leave(id): void; // drop a conversation handle
|
|
62
258
|
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
259
|
+
|
|
260
|
+
`RealtimeStatus`: `'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`.
|
|
261
|
+
|
|
262
|
+
Reconnect with backoff is handled by the underlying Phoenix Socket. On reconnect the JWT is re-read via `getToken`, so a refreshed token lands on the next handshake without manual intervention.
|
|
263
|
+
|
|
264
|
+
### `ConversationChannel`
|
|
265
|
+
|
|
266
|
+
Returned by `chat.realtime.conversation(id)`. Reusing the same id returns the same handle (no second channel join).
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
channel.onMessage(fn): Unsubscribe; // message:new
|
|
270
|
+
channel.onMessageUpdated(fn): Unsubscribe; // message:updated
|
|
271
|
+
channel.onMessageDeleted(fn): Unsubscribe; // message:deleted
|
|
272
|
+
channel.onReactionAdded(fn): Unsubscribe; // reaction:added
|
|
273
|
+
channel.onReactionRemoved(fn): Unsubscribe; // reaction:removed
|
|
274
|
+
channel.onMemberRead(fn): Unsubscribe; // member:read (read receipts)
|
|
275
|
+
channel.onTypingStart(fn): Unsubscribe;
|
|
276
|
+
channel.onTypingStop(fn): Unsubscribe;
|
|
277
|
+
channel.onPresenceState(fn): Unsubscribe; // initial snapshot (replayed to late subscribers)
|
|
278
|
+
channel.onPresenceDiff(fn): Unsubscribe; // joins/leaves deltas
|
|
279
|
+
channel.getPresenceState(): PresenceSnapshot; // synchronous read
|
|
280
|
+
|
|
281
|
+
channel.sendTyping(): void; // server is rate-limited; safe to spam
|
|
69
282
|
```
|
|
70
283
|
|
|
71
|
-
The
|
|
284
|
+
The server filters `typing` to peers — you won't receive your own typing event back.
|
|
285
|
+
|
|
286
|
+
Payload shapes are exact:
|
|
287
|
+
|
|
288
|
+
| Event | Payload |
|
|
289
|
+
| ------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
290
|
+
| `message:new` / `message:updated` | `Message` (full row, with `attachments` preloaded and `quoted_message` set when applicable) |
|
|
291
|
+
| `message:deleted` | `{ id: Uuid; conversation_id: Uuid; deleted_at: string \| null }` |
|
|
292
|
+
| `reaction:added` / `reaction:removed` | `{ message_id, conversation_id, emoji, user_id }` |
|
|
293
|
+
| `member:read` | `{ user_id, conversation_id, last_read_message_id, last_read_at }` |
|
|
294
|
+
| `typing:start` / `typing:stop` | `{ user_id }` |
|
|
295
|
+
| `presence_state` | `Record<user_id, { metas: Array<{ phx_ref, online_at, external_id? }> }>` |
|
|
296
|
+
| `presence_diff` | Same shape, partitioned into `{ joins, leaves }` |
|
|
297
|
+
|
|
298
|
+
### `UserChannel`
|
|
299
|
+
|
|
300
|
+
Returned by `chat.realtime.user(id)`. Only the user matching the JWT can join their own channel — the server rejects other ids.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
channel.onMention(fn): Unsubscribe; // mention:new
|
|
304
|
+
channel.onConversationCreated(fn): Unsubscribe; // conversation:created
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`mention:new` carries `{ message_id, conversation_id, sender_id }` — fetch the full message via REST if you need the body. `conversation:created` fires whenever the user is added to a conversation (including ones they created themselves) and carries the full `Conversation`.
|
|
308
|
+
|
|
309
|
+
## Token caching
|
|
310
|
+
|
|
311
|
+
`config.getToken` is called once per JWT lifetime, not per request. The cache decodes the JWT's `exp` claim and refreshes 30s before expiry. Concurrent callers share the in-flight refresh promise.
|
|
312
|
+
|
|
313
|
+
On a 401, the SDK invalidates the cache and retries the request **once** with a fresh token. If the second attempt also 401s, `AuthError` is thrown.
|
|
314
|
+
|
|
315
|
+
If you sign the user out or rotate tenants, drop the current `Poolse` instance (or, in `@poolse/react`, give `<PoolseProvider>` a new `key`) — there's no in-place invalidate API at the SDK level because mutating connection-shaped config after construction doesn't reach the open socket anyway.
|
|
316
|
+
|
|
317
|
+
## Idempotency
|
|
318
|
+
|
|
319
|
+
Every non-GET request carries an auto-generated `Idempotency-Key` header. The server caches 2xx responses per `(tenant, method, path, key)` for 24 hours, so a retry after a flaky network drop returns the same row instead of creating a duplicate. Non-2xx responses are never cached — you can retry with the same key after fixing a validation error.
|
|
320
|
+
|
|
321
|
+
To override the key (e.g., to make a deliberate retry intentional), pass `idempotencyKey` on `chat.rest.request`. Pass `null` to omit the header entirely. Resource methods auto-generate a fresh key per call.
|
|
322
|
+
|
|
323
|
+
## Retry behavior
|
|
324
|
+
|
|
325
|
+
Transient failures (network errors, 5xx, 429) are retried with exponential backoff + full jitter, capped by `maxBackoffMs`. On a 429 the server's `Retry-After` (seconds) is honored if present and larger than the computed backoff. Aborts (`DOMException('AbortError')`) are propagated, never wrapped.
|
|
326
|
+
|
|
327
|
+
The retry budget is `maxRetries` (default 3) attempts after the initial request — up to 4 attempts total per logical call.
|
|
328
|
+
|
|
329
|
+
## Errors
|
|
330
|
+
|
|
331
|
+
All errors extend `PoolseError` and are `instanceof`-checkable:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { ApiError, AuthError, RateLimitedError, NetworkError, PoolseError } from '@poolse/sdk';
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await chat.messages.one(id).update({ body: 'edit' });
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (err instanceof AuthError) {
|
|
340
|
+
// 401: the JWT is invalid even after one refresh attempt. Re-auth the user.
|
|
341
|
+
} else if (err instanceof RateLimitedError) {
|
|
342
|
+
// 429: err.retryAfterMs is parsed from the Retry-After header (0 if absent).
|
|
343
|
+
} else if (err instanceof ApiError) {
|
|
344
|
+
// Other 4xx/5xx with a canonical envelope: err.status, err.code, err.docUrl, err.details
|
|
345
|
+
} else if (err instanceof NetworkError) {
|
|
346
|
+
// fetch() rejected; err.cause is the original failure.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The server's error envelope is exposed via the `code` / `docUrl` / `details` fields on `ApiError`. Codes are stable strings like `validation_failed`, `forbidden`, `message_deleted`, `attachment_too_large` — see the docs at `err.docUrl` for the full list per endpoint.
|
|
352
|
+
|
|
353
|
+
## Sequence numbers
|
|
354
|
+
|
|
355
|
+
Every message has a monotonic per-conversation `sequence`. It's assigned in the same transaction as the insert, so server order is unambiguous regardless of clock skew. The SDK uses it for pagination (`before` / `after` cursors) and for sorting optimistic-vs-server-echo races: the optimistic local insert can sort to the very end until the server-assigned `sequence` arrives, at which point the dedup-by-id upsert puts it in the right place.
|
|
356
|
+
|
|
357
|
+
## Cancellation
|
|
358
|
+
|
|
359
|
+
Every method that touches the network accepts an `AbortSignal`. The SDK propagates abort to the underlying `fetch` (and to `XMLHttpRequest` on the progress-enabled upload path). Aborted requests throw `DOMException('AbortError')` — they are **not** retried.
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
const controller = new AbortController();
|
|
363
|
+
const promise = chat.messages.one(id).replies({ limit: 100 }, controller.signal);
|
|
364
|
+
// later:
|
|
365
|
+
controller.abort();
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Environments
|
|
369
|
+
|
|
370
|
+
- **Browsers (modern)** — works out of the box.
|
|
371
|
+
- **Node ≥ 18** — uses the global `fetch` and `WebSocket`. Upload progress is browser-only (silently no-ops in Node).
|
|
372
|
+
- **React Native** — works on RN ≥ 0.72 (`fetch` + `WebSocket` are built in). The `phoenix` client is bundled.
|
|
373
|
+
- **Bun / Deno** — works; same caveat on upload progress.
|
|
374
|
+
- **Web Workers** — works for REST; sockets work where `WebSocket` is available.
|
|
375
|
+
|
|
376
|
+
## Exports
|
|
377
|
+
|
|
378
|
+
The package exports the `Poolse` class, the resource and channel classes (`ConversationsResource`, `ConversationHandle`, `MessagesResource`, `MessageHandle`, `AttachmentsResource`, `AttachmentHandle`, `MeResource`, `UsersResource`, `PoolseRealtime`, `ConversationChannel`, `UserChannel`), all error classes, every interface from the wire protocol (`Message`, `Conversation`, `Membership`, `User`, `Attachment`, etc.), the `RealtimeStatus` union, the `POOLSE_API_URL` default, and a `version` constant for diagnostics.
|
|
72
379
|
|
|
73
|
-
##
|
|
380
|
+
## Links
|
|
74
381
|
|
|
75
|
-
- Full
|
|
382
|
+
- Full docs — <https://poolse.dev/docs>
|
|
76
383
|
- Source — <https://github.com/poolse-hq/js-sdk>
|
|
77
384
|
- Issues — <https://github.com/poolse-hq/js-sdk/issues>
|
|
78
385
|
|
package/dist/index.cjs
CHANGED
|
@@ -815,55 +815,55 @@ var UsersResource = class {
|
|
|
815
815
|
* "not in cache yet" (different from `null`, which means "resolver
|
|
816
816
|
* ran and the user wasn't found").
|
|
817
817
|
*/
|
|
818
|
-
peek(
|
|
819
|
-
return this.cache.get(
|
|
818
|
+
peek(externalId) {
|
|
819
|
+
return this.cache.get(externalId);
|
|
820
820
|
}
|
|
821
821
|
/**
|
|
822
822
|
* Resolve a user, hitting the customer's `userResolver` on cache
|
|
823
|
-
* miss. Concurrent calls for the same
|
|
823
|
+
* miss. Concurrent calls for the same external_id share one Promise.
|
|
824
824
|
*/
|
|
825
|
-
async get(
|
|
826
|
-
if (this.cache.has(
|
|
827
|
-
return this.cache.get(
|
|
825
|
+
async get(externalId) {
|
|
826
|
+
if (this.cache.has(externalId)) {
|
|
827
|
+
return this.cache.get(externalId) ?? null;
|
|
828
828
|
}
|
|
829
|
-
const existingPending = this.pending.get(
|
|
829
|
+
const existingPending = this.pending.get(externalId);
|
|
830
830
|
if (existingPending) return existingPending;
|
|
831
831
|
const resolver = this.config.userResolver;
|
|
832
832
|
if (!resolver) {
|
|
833
|
-
this.cache.set(
|
|
834
|
-
this.notify(
|
|
833
|
+
this.cache.set(externalId, null);
|
|
834
|
+
this.notify(externalId);
|
|
835
835
|
return null;
|
|
836
836
|
}
|
|
837
|
-
const promise = Promise.resolve().then(() => resolver(
|
|
837
|
+
const promise = Promise.resolve().then(() => resolver(externalId)).then(
|
|
838
838
|
(profile) => {
|
|
839
|
-
this.cache.set(
|
|
840
|
-
this.pending.delete(
|
|
841
|
-
this.notify(
|
|
839
|
+
this.cache.set(externalId, profile ?? null);
|
|
840
|
+
this.pending.delete(externalId);
|
|
841
|
+
this.notify(externalId);
|
|
842
842
|
return profile ?? null;
|
|
843
843
|
},
|
|
844
844
|
(err) => {
|
|
845
|
-
console.error("[poolse] userResolver failed for",
|
|
846
|
-
this.cache.set(
|
|
847
|
-
this.pending.delete(
|
|
848
|
-
this.notify(
|
|
845
|
+
console.error("[poolse] userResolver failed for", externalId, err);
|
|
846
|
+
this.cache.set(externalId, null);
|
|
847
|
+
this.pending.delete(externalId);
|
|
848
|
+
this.notify(externalId);
|
|
849
849
|
return null;
|
|
850
850
|
}
|
|
851
851
|
);
|
|
852
|
-
this.pending.set(
|
|
852
|
+
this.pending.set(externalId, promise);
|
|
853
853
|
return promise;
|
|
854
854
|
}
|
|
855
855
|
/**
|
|
856
|
-
* Subscribe to changes for a single
|
|
856
|
+
* Subscribe to changes for a single external_id. The listener fires
|
|
857
857
|
* when the resolver lands (or when the entry is invalidated).
|
|
858
858
|
* Returns an unsubscribe.
|
|
859
859
|
*
|
|
860
860
|
* `useUser` in @poolse/react uses this with `useSyncExternalStore`.
|
|
861
861
|
*/
|
|
862
|
-
subscribe(
|
|
863
|
-
let set = this.listeners.get(
|
|
862
|
+
subscribe(externalId, listener) {
|
|
863
|
+
let set = this.listeners.get(externalId);
|
|
864
864
|
if (!set) {
|
|
865
865
|
set = /* @__PURE__ */ new Set();
|
|
866
|
-
this.listeners.set(
|
|
866
|
+
this.listeners.set(externalId, set);
|
|
867
867
|
}
|
|
868
868
|
set.add(listener);
|
|
869
869
|
return () => {
|
|
@@ -871,10 +871,10 @@ var UsersResource = class {
|
|
|
871
871
|
};
|
|
872
872
|
}
|
|
873
873
|
/** Drop a single cached entry — next `get` re-fetches via the resolver. */
|
|
874
|
-
invalidate(
|
|
875
|
-
this.cache.delete(
|
|
876
|
-
this.pending.delete(
|
|
877
|
-
this.notify(
|
|
874
|
+
invalidate(externalId) {
|
|
875
|
+
this.cache.delete(externalId);
|
|
876
|
+
this.pending.delete(externalId);
|
|
877
|
+
this.notify(externalId);
|
|
878
878
|
}
|
|
879
879
|
/**
|
|
880
880
|
* Drop the entire cache. Use after a sign-out, tenant swap, or any
|
|
@@ -884,12 +884,12 @@ var UsersResource = class {
|
|
|
884
884
|
invalidateAll() {
|
|
885
885
|
this.cache.clear();
|
|
886
886
|
this.pending.clear();
|
|
887
|
-
for (const
|
|
888
|
-
this.notify(
|
|
887
|
+
for (const externalId of this.listeners.keys()) {
|
|
888
|
+
this.notify(externalId);
|
|
889
889
|
}
|
|
890
890
|
}
|
|
891
|
-
notify(
|
|
892
|
-
const set = this.listeners.get(
|
|
891
|
+
notify(externalId) {
|
|
892
|
+
const set = this.listeners.get(externalId);
|
|
893
893
|
if (!set) return;
|
|
894
894
|
for (const l of set) l();
|
|
895
895
|
}
|
|
@@ -1172,7 +1172,7 @@ var Poolse = class {
|
|
|
1172
1172
|
};
|
|
1173
1173
|
|
|
1174
1174
|
// src/version.ts
|
|
1175
|
-
var version = "
|
|
1175
|
+
var version = "2.0.0";
|
|
1176
1176
|
|
|
1177
1177
|
exports.ApiError = ApiError;
|
|
1178
1178
|
exports.AttachmentHandle = AttachmentHandle;
|