@raisindb/client 0.1.0 → 0.1.1
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/README.md +313 -70
- package/dist/chunk-ASOWGNQZ.mjs +532 -0
- package/dist/chunk-ASOWGNQZ.mjs.map +1 -0
- package/dist/chunk-FYWYSNQG.mjs +190 -0
- package/dist/chunk-FYWYSNQG.mjs.map +1 -0
- package/dist/chunk-NOPZAMIV.mjs +1427 -0
- package/dist/chunk-NOPZAMIV.mjs.map +1 -0
- package/dist/chunk-T2ZSNYFG.mjs +248 -0
- package/dist/chunk-T2ZSNYFG.mjs.map +1 -0
- package/dist/chunk-TPZNJNZR.mjs +866 -0
- package/dist/chunk-TPZNJNZR.mjs.map +1 -0
- package/dist/chunk-ZCRYQCXS.mjs +170 -0
- package/dist/chunk-ZCRYQCXS.mjs.map +1 -0
- package/dist/conversation-list-store-D-W6_nsv.d.mts +8447 -0
- package/dist/conversation-list-store-D-W6_nsv.d.ts +8447 -0
- package/dist/index-B4CqE3Cb.d.ts +301 -0
- package/dist/index-CeBRaSop.d.mts +301 -0
- package/dist/index.d.mts +72 -6109
- package/dist/index.d.ts +72 -6109
- package/dist/index.js +3123 -257
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2745 -3185
- package/dist/index.mjs.map +1 -0
- package/dist/react.d.mts +4 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +2569 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +9 -0
- package/dist/react.mjs.map +1 -0
- package/dist/svelte-sql-CQoB4FEm.d.mts +52 -0
- package/dist/svelte-sql-CTXjkz0R.d.ts +52 -0
- package/dist/svelte.d.mts +288 -0
- package/dist/svelte.d.ts +288 -0
- package/dist/svelte.js +2482 -0
- package/dist/svelte.js.map +1 -0
- package/dist/svelte.mjs +25 -0
- package/dist/svelte.mjs.map +1 -0
- package/dist/vue.d.mts +197 -0
- package/dist/vue.d.ts +197 -0
- package/dist/vue.js +1510 -0
- package/dist/vue.js.map +1 -0
- package/dist/vue.mjs +9 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +18 -3
package/README.md
CHANGED
|
@@ -317,6 +317,131 @@ const subscription4 = await events.subscribeToNodeType('Page', (event) => {
|
|
|
317
317
|
await subscription1.unsubscribe();
|
|
318
318
|
```
|
|
319
319
|
|
|
320
|
+
### Build a chatbox
|
|
321
|
+
|
|
322
|
+
Everything chat lives behind `db.conversations` (low-level) and
|
|
323
|
+
`ConversationStore` (framework-agnostic state container). The store creates
|
|
324
|
+
the conversation lazily on the first message, streams the agent's reply over
|
|
325
|
+
SSE, tracks in-flight tool calls, and projects plans for approval UIs.
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { ConversationStore } from '@raisindb/client';
|
|
329
|
+
|
|
330
|
+
const db = client.database('myrepo');
|
|
331
|
+
|
|
332
|
+
const store = new ConversationStore({
|
|
333
|
+
database: db,
|
|
334
|
+
// Conversation is created on the first sendMessage(). The participant is
|
|
335
|
+
// an agent path, so this becomes an `ai_chat` conversation.
|
|
336
|
+
createOptions: { participant: '/agents/support' },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
store.subscribe((s) => {
|
|
340
|
+
renderMessages(s.messages); // ChatMessage[]
|
|
341
|
+
if (s.isStreaming) renderPartial(s.streamingText); // live tokens
|
|
342
|
+
|
|
343
|
+
// Tool-call badges: "running" entries are in-flight
|
|
344
|
+
for (const tc of s.activeToolCalls) {
|
|
345
|
+
renderToolBadge(tc.functionName, tc.status); // running | completed | failed
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Plans awaiting approval
|
|
349
|
+
for (const plan of s.plans) {
|
|
350
|
+
if (plan.status === 'pending_approval') {
|
|
351
|
+
renderPlanCard(plan, {
|
|
352
|
+
approve: () => store.approvePlan(plan.planPath),
|
|
353
|
+
reject: (why) => store.rejectPlan(plan.planPath, why),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (s.error) showError(s.error);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await store.sendMessage('Plan next week\'s shifts');
|
|
362
|
+
// ...
|
|
363
|
+
store.stop(); // abort the current turn in the UI
|
|
364
|
+
store.destroy(); // on teardown
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
To resume an existing conversation, pass `conversationPath` instead of (or in
|
|
368
|
+
addition to) `createOptions` and call `store.loadMessages()` for history. To
|
|
369
|
+
build an inbox-style list of conversations, use `ConversationListStore`
|
|
370
|
+
(`load()`, `createConversation()`, `markAsRead()`, `realtime: true`).
|
|
371
|
+
|
|
372
|
+
### Chat without login (a public website)
|
|
373
|
+
|
|
374
|
+
An anonymous connection can chat with an agent that allows anonymous visitors
|
|
375
|
+
(`anonymous.enabled: true` on its `raisin:AIAgent`). No login, no server
|
|
376
|
+
endpoint of your own: the conversation lives in an ephemeral visitor session
|
|
377
|
+
that only this browser tab can read.
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
const client = new RaisinClient('wss://db.example.com/ws/website');
|
|
381
|
+
await client.connect(); // anonymous
|
|
382
|
+
const db = client.database('website');
|
|
383
|
+
|
|
384
|
+
const convo = await db.conversations.startAnonymous('/agents/website-assistant');
|
|
385
|
+
for await (const ev of db.conversations.sendMessage(convo.conversationPath, 'Hello')) {
|
|
386
|
+
if (ev.type === 'text_chunk') append(ev.text);
|
|
387
|
+
if (ev.type === 'failed') showLimit(ev.code); // RATE_LIMITED, BUSY, LIMIT_REACHED, …
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Or the store / hooks, with one extra option:
|
|
391
|
+
new ConversationStore({
|
|
392
|
+
database: db,
|
|
393
|
+
createOptions: { participant: '/agents/website-assistant', anonymous: true },
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The session secret and the conversation id are kept in `sessionStorage`
|
|
398
|
+
(`options.store` to change that), so a reload continues the chat, and the SDK
|
|
399
|
+
re-proves the session after a reconnect. Server-side limits (message size and
|
|
400
|
+
count, token budget, rate, one turn at a time) surface as `failed` events with
|
|
401
|
+
a `code`, or as a thrown `VisitorChatError`. See
|
|
402
|
+
`docs/developer/messaging/anonymous-visitors.md` in the RaisinDB repository.
|
|
403
|
+
|
|
404
|
+
### Listen to the inbox (notifications without an extra API)
|
|
405
|
+
|
|
406
|
+
Server-side messaging delivers items into the logged-in user's home inbox in
|
|
407
|
+
the `raisin:access_control` workspace. A plain node subscription on
|
|
408
|
+
`${home}/inbox/**` is all an inbox bell needs — no polling, no extra API.
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
import { normalizeHomePath } from '@raisindb/client';
|
|
412
|
+
|
|
413
|
+
const user = await client.initSession('myrepo');
|
|
414
|
+
|
|
415
|
+
// user.home may be workspace-prefixed (/raisin:access_control/users/...).
|
|
416
|
+
// Subscription paths must be workspace-relative — always normalize.
|
|
417
|
+
const home = normalizeHomePath(user!.home)!;
|
|
418
|
+
|
|
419
|
+
await db.workspace('raisin:access_control').events().subscribe(
|
|
420
|
+
{
|
|
421
|
+
path: `${home}/inbox/**`,
|
|
422
|
+
event_types: ['node:created'],
|
|
423
|
+
include_node: true, // deliver the full node so we can render a title
|
|
424
|
+
},
|
|
425
|
+
(event) => {
|
|
426
|
+
const props = event.payload.node?.properties ?? {};
|
|
427
|
+
if (props.role === 'user') return; // skip the user's own chat messages
|
|
428
|
+
bumpBadge();
|
|
429
|
+
showToast((props.title as string) ?? (props.subject as string) ?? 'New inbox item');
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**Path filter semantics** (important — there is no implicit prefix matching):
|
|
435
|
+
|
|
436
|
+
| Pattern | Matches |
|
|
437
|
+
|---------|---------|
|
|
438
|
+
| `/users/u1/inbox` | exactly that node, nothing below it |
|
|
439
|
+
| `/users/u1/inbox/*` | direct children only (`*` = one path segment) |
|
|
440
|
+
| `/users/u1/inbox/**` | the whole subtree, any depth (`**` = recursive) |
|
|
441
|
+
|
|
442
|
+
Inbox items nest (e.g. `inbox/chats/<conversation>/<message>`), so you almost
|
|
443
|
+
always want `/**`.
|
|
444
|
+
|
|
320
445
|
### Workspace Management
|
|
321
446
|
|
|
322
447
|
```typescript
|
|
@@ -446,104 +571,117 @@ const client = new RaisinClient('raisin://localhost:8080/sys/default', {
|
|
|
446
571
|
|
|
447
572
|
## Framework Integration
|
|
448
573
|
|
|
574
|
+
The SDK ships dedicated subpath exports — `@raisindb/client/react`,
|
|
575
|
+
`@raisindb/client/svelte`, and `@raisindb/client/vue`. None of them make the
|
|
576
|
+
framework a dependency: React and Vue use a "bring your own framework"
|
|
577
|
+
factory (you pass the module in once), Svelte gets framework-free adapters
|
|
578
|
+
that bind naturally to runes.
|
|
579
|
+
|
|
449
580
|
### React
|
|
450
581
|
|
|
451
582
|
```tsx
|
|
452
|
-
|
|
453
|
-
import
|
|
583
|
+
// lib/raisin-react.ts
|
|
584
|
+
import React from 'react';
|
|
585
|
+
import { RaisinClient, LocalStorageTokenStorage } from '@raisindb/client';
|
|
586
|
+
import { createRaisinReact } from '@raisindb/client/react';
|
|
454
587
|
|
|
455
|
-
const client = new RaisinClient('ws://localhost:8081/sys/default/myrepo', {
|
|
588
|
+
export const client = new RaisinClient('ws://localhost:8081/sys/default/myrepo', {
|
|
456
589
|
tokenStorage: new LocalStorageTokenStorage('myapp'),
|
|
457
590
|
});
|
|
458
591
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
useEffect(() => {
|
|
464
|
-
// Initialize session on mount
|
|
465
|
-
client.initSession('myrepo').then(setUser).finally(() => setLoading(false));
|
|
592
|
+
export const {
|
|
593
|
+
RaisinProvider, useAuth, useConnection, useSql, useSubscription,
|
|
594
|
+
useConversation, useConversationList,
|
|
595
|
+
} = createRaisinReact(React);
|
|
466
596
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
597
|
+
// App.tsx
|
|
598
|
+
function Chat() {
|
|
599
|
+
const chat = useConversation({
|
|
600
|
+
database: client.database('myrepo'),
|
|
601
|
+
createOptions: { participant: '/agents/support' },
|
|
602
|
+
});
|
|
471
603
|
|
|
472
|
-
|
|
473
|
-
|
|
604
|
+
return (
|
|
605
|
+
<div>
|
|
606
|
+
{chat.messages.map((m, i) => <p key={i}>{m.content}</p>)}
|
|
607
|
+
{chat.isStreaming && <p className="streaming">{chat.streamingText}</p>}
|
|
608
|
+
<button onClick={() => chat.sendMessage('Hello!')}>Send</button>
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
474
612
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
613
|
+
export default function App() {
|
|
614
|
+
return (
|
|
615
|
+
<RaisinProvider client={client} repository="myrepo">
|
|
616
|
+
<Chat />
|
|
617
|
+
</RaisinProvider>
|
|
618
|
+
);
|
|
478
619
|
}
|
|
479
620
|
```
|
|
480
621
|
|
|
481
|
-
###
|
|
622
|
+
### Svelte 5
|
|
482
623
|
|
|
483
624
|
```typescript
|
|
484
|
-
// lib/
|
|
485
|
-
import {
|
|
486
|
-
import {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
625
|
+
// lib/chat.svelte.ts
|
|
626
|
+
import { ConversationStore, type ConversationStoreSnapshot } from '@raisindb/client';
|
|
627
|
+
import { createConversationAdapter } from '@raisindb/client/svelte';
|
|
628
|
+
import { db } from '$lib/raisin';
|
|
629
|
+
|
|
630
|
+
const adapter = createConversationAdapter({
|
|
631
|
+
database: db,
|
|
632
|
+
createOptions: { participant: '/agents/support' },
|
|
490
633
|
});
|
|
491
634
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
export async function login(email: string, password: string) {
|
|
505
|
-
return client.loginWithEmail(email, password, 'myrepo');
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
export async function logout() {
|
|
509
|
-
return client.logout();
|
|
510
|
-
}
|
|
635
|
+
let snapshot = $state<ConversationStoreSnapshot>(adapter.getSnapshot());
|
|
636
|
+
adapter.subscribe((s) => { snapshot = s; });
|
|
637
|
+
|
|
638
|
+
export const chat = {
|
|
639
|
+
get messages() { return snapshot.messages; },
|
|
640
|
+
get isStreaming() { return snapshot.isStreaming; },
|
|
641
|
+
get streamingText() { return snapshot.streamingText; },
|
|
642
|
+
get plans() { return snapshot.plans; },
|
|
643
|
+
send: adapter.sendMessage,
|
|
644
|
+
approvePlan: adapter.approvePlan,
|
|
645
|
+
destroy: adapter.destroy,
|
|
646
|
+
};
|
|
511
647
|
```
|
|
512
648
|
|
|
513
649
|
### Vue 3
|
|
514
650
|
|
|
515
651
|
```typescript
|
|
516
|
-
//
|
|
517
|
-
import
|
|
518
|
-
import { RaisinClient, LocalStorageTokenStorage
|
|
652
|
+
// lib/raisin-vue.ts
|
|
653
|
+
import * as vue from 'vue';
|
|
654
|
+
import { RaisinClient, LocalStorageTokenStorage } from '@raisindb/client';
|
|
655
|
+
import { createRaisinVue } from '@raisindb/client/vue';
|
|
519
656
|
|
|
520
|
-
const client = new RaisinClient('ws://localhost:8081/sys/default/myrepo', {
|
|
657
|
+
export const client = new RaisinClient('ws://localhost:8081/sys/default/myrepo', {
|
|
521
658
|
tokenStorage: new LocalStorageTokenStorage('myapp'),
|
|
522
659
|
});
|
|
660
|
+
export const db = client.database('myrepo');
|
|
523
661
|
|
|
524
|
-
export
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
onMounted(async () => {
|
|
530
|
-
user.value = await client.initSession('myrepo');
|
|
531
|
-
loading.value = false;
|
|
532
|
-
|
|
533
|
-
unsubscribe = client.onAuthStateChange(({ session }) => {
|
|
534
|
-
user.value = session.user;
|
|
535
|
-
});
|
|
536
|
-
});
|
|
662
|
+
export const {
|
|
663
|
+
useAuth, useConnection, useSql, useSubscription,
|
|
664
|
+
useConversation, useConversationList,
|
|
665
|
+
} = createRaisinVue(vue);
|
|
666
|
+
```
|
|
537
667
|
|
|
538
|
-
|
|
668
|
+
```vue
|
|
669
|
+
<!-- Chat.vue -->
|
|
670
|
+
<script setup lang="ts">
|
|
671
|
+
import { db, useConversation } from '@/lib/raisin-vue';
|
|
539
672
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
673
|
+
const chat = useConversation({
|
|
674
|
+
database: db,
|
|
675
|
+
createOptions: { participant: '/agents/support' },
|
|
676
|
+
});
|
|
677
|
+
// Cleanup is automatic: the composable destroys its store onUnmounted.
|
|
678
|
+
</script>
|
|
679
|
+
|
|
680
|
+
<template>
|
|
681
|
+
<p v-for="(m, i) in chat.messages.value" :key="i">{{ m.content }}</p>
|
|
682
|
+
<p v-if="chat.isStreaming.value" class="streaming">{{ chat.streamingText.value }}</p>
|
|
683
|
+
<button @click="chat.sendMessage('Hello!')">Send</button>
|
|
684
|
+
</template>
|
|
547
685
|
```
|
|
548
686
|
|
|
549
687
|
### Server-Side Rendering (SSR)
|
|
@@ -723,6 +861,111 @@ npm run dev
|
|
|
723
861
|
npm run typecheck
|
|
724
862
|
```
|
|
725
863
|
|
|
864
|
+
## Stability options
|
|
865
|
+
|
|
866
|
+
Options and behaviors that keep chat, inbox, and workflow UIs responsive when
|
|
867
|
+
the network or server misbehaves.
|
|
868
|
+
|
|
869
|
+
### `requestTimeout` (RaisinClient / RaisinHttpClient option)
|
|
870
|
+
|
|
871
|
+
Default: `30000` ms. Applies to:
|
|
872
|
+
|
|
873
|
+
- Every WebSocket request (resolved/rejected via the internal request tracker).
|
|
874
|
+
- All HTTP auth calls (`loginWithEmail`, `registerWithEmail`, `refreshToken`,
|
|
875
|
+
`initSession` token refresh) and `signAssetUrl` — these abort the underlying
|
|
876
|
+
`fetch` after `requestTimeout` and reject with a `RaisinTimeoutError`
|
|
877
|
+
(`code: 'REQUEST_TIMEOUT'`) instead of hanging forever.
|
|
878
|
+
|
|
879
|
+
Auth failures from the identity endpoints are thrown as `RaisinAuthError`
|
|
880
|
+
instances (real `Error` subclasses) that still carry the `{ code, message }`
|
|
881
|
+
fields, so existing destructuring keeps working:
|
|
882
|
+
|
|
883
|
+
```typescript
|
|
884
|
+
try {
|
|
885
|
+
await client.loginWithEmail(email, password, 'my-repo');
|
|
886
|
+
} catch (err) {
|
|
887
|
+
if (err instanceof RaisinAuthError) {
|
|
888
|
+
console.log(err.code, err.message, err.status);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
### `sendMessage` inactivity timeout (per-turn SSE)
|
|
894
|
+
|
|
895
|
+
`db.conversations.sendMessage(path, text, options)` streams the assistant turn
|
|
896
|
+
over a single-shot SSE connection. If that stream dies mid-turn (proxy reset,
|
|
897
|
+
server restart, dead TCP), the turn now ends with a synthetic `waiting` event
|
|
898
|
+
after `inactivityTimeoutMs` of silence (default: `120000` ms, reset on every
|
|
899
|
+
byte received) instead of stalling the chat UI forever:
|
|
900
|
+
|
|
901
|
+
```typescript
|
|
902
|
+
for await (const event of db.conversations.sendMessage(path, 'Hi', {
|
|
903
|
+
inactivityTimeoutMs: 60_000, // override; 0 disables
|
|
904
|
+
})) {
|
|
905
|
+
// ... a final { type: 'waiting' } event is guaranteed even if the stream dies
|
|
906
|
+
}
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
The same option exists on the lower-level `SSEClient` (`inactivityTimeoutMs`).
|
|
910
|
+
With reconnection enabled the SSE client silently reconnects on inactivity;
|
|
911
|
+
with reconnection disabled the stream ends with a `RaisinTimeoutError`
|
|
912
|
+
(`code: 'SSE_INACTIVITY_TIMEOUT'`).
|
|
913
|
+
|
|
914
|
+
### `streamingTimeoutMs` + `watchdogIntervalMs` (ConversationStore)
|
|
915
|
+
|
|
916
|
+
`ConversationStore` adds two recovery layers on top of the SSE stream:
|
|
917
|
+
|
|
918
|
+
- `streamingTimeoutMs` (default: `120000`) — if no SSE event arrives for this
|
|
919
|
+
long while a turn is streaming, the store auto-recovers (reloads messages
|
|
920
|
+
and clears the streaming state).
|
|
921
|
+
- `watchdogIntervalMs` (default: `30000`) — while streaming, the store
|
|
922
|
+
periodically asks the backend whether the turn is actually still active
|
|
923
|
+
(`checkTurnHealth`) and recovers if the backend says it finished.
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
const store = new ConversationStore({
|
|
927
|
+
database: db,
|
|
928
|
+
conversationPath,
|
|
929
|
+
streamingTimeoutMs: 90_000,
|
|
930
|
+
watchdogIntervalMs: 15_000,
|
|
931
|
+
});
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
### Request queueing during reconnects
|
|
935
|
+
|
|
936
|
+
Requests issued while the WebSocket is temporarily down are no longer thrown
|
|
937
|
+
away. If the connection state is `Connecting`/`Reconnecting` (or
|
|
938
|
+
`Disconnected` with auto-reconnect enabled after a previous successful
|
|
939
|
+
connection), `RaisinClient` holds the request and flushes it once the
|
|
940
|
+
connection is re-established **and** re-authenticated:
|
|
941
|
+
|
|
942
|
+
- Each queued request still honors its own `requestTimeout` — it rejects if
|
|
943
|
+
the connection doesn't come back in time.
|
|
944
|
+
- The queue is bounded (100 requests); overflowing requests reject
|
|
945
|
+
immediately with a "Request queue is full" error.
|
|
946
|
+
- If re-authentication fails after reconnect, queued requests reject with a
|
|
947
|
+
`RaisinAuthError`.
|
|
948
|
+
- A permanent disconnect (manual `disconnect()`, max reconnect attempts
|
|
949
|
+
reached) cancels everything in the queue.
|
|
950
|
+
|
|
951
|
+
### `subscription_restore_failed` event
|
|
952
|
+
|
|
953
|
+
After a reconnect, the client restores event subscriptions automatically and
|
|
954
|
+
retries on failure (3 retries with 1s/3s/9s backoff, without duplicating the
|
|
955
|
+
subscriptions that already succeeded). If restoration still fails, the client
|
|
956
|
+
emits `subscription_restore_failed` so the app can resync its state:
|
|
957
|
+
|
|
958
|
+
```typescript
|
|
959
|
+
client.on('subscription_restore_failed', (error) => {
|
|
960
|
+
// Realtime events may be stale: reload lists and/or re-subscribe manually
|
|
961
|
+
console.warn('Subscriptions not restored after reconnect:', error);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
client.onReconnected(() => {
|
|
965
|
+
// Fired only after connection + auth + successful subscription restore
|
|
966
|
+
});
|
|
967
|
+
```
|
|
968
|
+
|
|
726
969
|
## License
|
|
727
970
|
|
|
728
971
|
BSL-1.1 - See LICENSE file for details.
|