- F-T-003 actually closed: clients/typescript/src/subscribe.ts now bounds the 401-refresh loop at 3 consecutive refusals, surfaces an onError describing the loop, and resets on any successful stream open. New test covers the cap. The Stage 2 dashboard fix only addressed adminRequest in dashboard/src/lib/api.ts; the originally- cited TS client file was untouched. - users/invitations subtab dropped the redundant api.apps.get fetch the other 5 subtabs already shed in Stage 6. - Queue visibility-timeout validator pulled out as validate_queue_visibility_timeout, with two thresholds: hard-reject below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn- log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so operators see when their visibility is below the dispatcher's per-message executor budget. Stage 6 only had the hard floor; the reviewer caught that a 60s handler still races a 30s visibility even after the floor. Four new unit tests cover none/above-safe/ between/below-min. - pic dead-letters count subcommand: cheap headless probe for unresolved DL totals, parallels the dashboard's badge query. - pic dead-letters replay now has a happy-path integration test (replay_against_real_dl_row_succeeds): inserts a synthetic DL row directly via the rust-postgres sync driver (added as dev-dep), drives `pic dead-letters replay`, asserts the row is resolved with reason=replayed and count drops back to 0. Plus a count smoke test. All 75 CLI integration tests + 16 TS client tests + 4 new visibility-timeout unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@picloud/client
TypeScript client for PiCloud. Three capabilities, all script-mediated — there is no direct KV / docs / users access from the browser (the hybrid model, by design):
- Typed HTTP to dev-defined script endpoints.
- SSE realtime subscriptions to externally-subscribable pub/sub topics.
- Auth-flow helpers over your own dev-defined login/logout endpoints.
import { PicloudClient } from '@picloud/client';
const client = new PicloudClient({
baseURL: 'https://api.example.com',
getAuthToken: () => localStorage.getItem('auth_token')
});
// Typed HTTP
interface CreateUserReq { name: string; email?: string; role: string }
interface CreateUserRes { id: string; name: string; created_at: string }
const user = await client
.endpoint<CreateUserReq, CreateUserRes>('/api/users')
.post({ name: 'alice', role: 'admin' });
// SSE subscription
const unsubscribe = client.subscribe('chat-room-123', (event) => {
console.log('got event:', event.message);
});
unsubscribe();
// Token-gated topic (token obtained from one of YOUR script endpoints,
// which calls `pubsub::subscriber_token`)
client.subscribe('chat-room-123', cb, { token: 'eyJhbGc...' });
// Auth helpers (call dev-defined endpoints under the hood)
await client.auth.login('alice@example.com', 'password');
await client.auth.logout();
const token = client.auth.token;
React
import {
PicloudProvider,
useTopic,
useEndpointGet,
useEndpointPost
} from '@picloud/client/react';
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
function ChatRoom({ roomId }: { roomId: string }) {
const messages = useTopic<ChatMessage>(`chat-room-${roomId}`);
return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
}
function UserProfile({ id }: { id: string }) {
// Auto-fires when `id` changes; conforms to Rules of Hooks.
const { data, loading, error } = useEndpointGet<UserRes>(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <ErrorView error={error} />;
return <div>{data?.name}</div>;
}
function CreateUserForm() {
// Event-driven: NOT fired on mount; call `mutate(body)` on submit.
const { mutate, loading, error } = useEndpointPost<CreateUserReq, CreateUserRes>(
'/api/users'
);
return (
<form onSubmit={(e) => { e.preventDefault(); mutate({ name: 'Alice' }); }}>
<button disabled={loading}>Create</button>
{error ? <ErrorView error={error} /> : null}
</form>
);
}
Svelte
import { topicStore, endpointStore } from '@picloud/client/svelte';
const messages = topicStore<ChatMessage>(client, `chat-room-${roomId}`);
// $messages is an array that grows as events arrive
const userQuery = endpointStore<UserRes>(client, `/api/users/${id}`).get();
// $userQuery is { data, loading, error }
The Svelte helpers take the
clientexplicitly (a store isn't a component, so there's no React-style context to read).
Optional runtime validation (zod / valibot)
No hard dependency — the adapter is the { parse(input): T } shape. A Zod
schema satisfies it directly; wrap Valibot in one line:
import { z } from 'zod';
const UserSchema = z.object({ id: z.string(), name: z.string() });
const user = await client.endpoint('/api/users/1').get({ validate: UserSchema });
// valibot:
import * as v from 'valibot';
const schema = v.object({ id: v.string() });
const adapter = { parse: (i: unknown) => v.parse(schema, i) };
Transport notes
- SSE is implemented over streaming
fetch(not nativeEventSource) so the client can refresh an expired token on a 401, sendLast-Event-IDon resume, and apply its own exponential backoff (1s → 2s → 4s … capped at 30s). - React Native has no native
EventSource, but it also can't streamfetchbodies on all engines — if you target RN, supply a streaming-capablefetchpolyfill via thefetchoption, or use areact-native-sse-based adapter. (Server-sideLast-Event-IDreplay is not implemented in v1.1.6; the client sends the header so it's ready when the server adds replay.)
Build / test
npm install
npm run lint # tsc --noEmit (strict)
npm run test # vitest
npm run build # tsup → dist/ (ESM + CJS + .d.ts)