@pylonsync/sync 0.3.292 → 0.3.294

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * What every caller of `pylonFetch` must supply. The SyncEngine
3
+ * passes its own config; the React free helpers pass a lightweight
4
+ * shim built from `getBaseUrl()` + `currentAuthToken()`.
5
+ */
6
+ export interface TransportConfig {
7
+ baseUrl?: string;
8
+ /** Static bearer token. Use `getToken` if the token can change. */
9
+ token?: string;
10
+ /** Lazy bearer-token resolver — called per request. Use this when
11
+ * the token can be rotated mid-session (refresh, session-changed).
12
+ * Returning null/undefined means "no auth header"; cookie-auth
13
+ * apps rely on `credentials: "include"` instead. */
14
+ getToken?: () => string | null | undefined;
15
+ /** Invoked with the value of the X-Pylon-Change-Seq response
16
+ * header when the server sets one. Returning a Promise pauses no
17
+ * one — the transport doesn't await it. */
18
+ onChangeSeq?: (seq: number) => void;
19
+ }
20
+ /**
21
+ * Per-call init shape. Mirrors the standard `RequestInit` but
22
+ * normalizes the two body shapes (JSON-encode-this vs raw-pass-
23
+ * through) into separate fields so callers don't have to remember
24
+ * to set Content-Type.
25
+ */
26
+ export interface PylonRequestInit {
27
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
28
+ /** When set, JSON-stringified into the body and Content-Type
29
+ * defaults to application/json. */
30
+ json?: unknown;
31
+ /** Raw body — passed through to fetch unchanged. Use this for file
32
+ * uploads (Blob, ArrayBuffer, FormData). */
33
+ body?: BodyInit;
34
+ /** Extra headers. Caller-supplied keys win against the transport's
35
+ * defaults. */
36
+ headers?: Record<string, string>;
37
+ /** Override the request's Accept header (useful for SSE streams). */
38
+ accept?: string;
39
+ /** AbortController signal for cancellation. */
40
+ signal?: AbortSignal;
41
+ }
42
+ /**
43
+ * Resolve the base URL. Browser-side fallback to
44
+ * `window.location.origin` matches the SDK contract: when
45
+ * `configureClient` was called with no baseUrl, calls go to the
46
+ * current document's origin so dev + prod work without env
47
+ * gymnastics.
48
+ */
49
+ export declare function resolveBaseUrl(config: TransportConfig): string;
50
+ /**
51
+ * Assemble fetch URL + RequestInit. Exposed for streaming / raw-
52
+ * response callers (SSE, file upload) that need to handle the
53
+ * Response themselves but want the transport's auth + credentials +
54
+ * URL logic.
55
+ */
56
+ export declare function buildRequest(config: TransportConfig, path: string, init?: PylonRequestInit): {
57
+ url: string;
58
+ init: RequestInit;
59
+ };
60
+ /** Error thrown by `pylonFetch` for non-2xx responses. Carries the
61
+ * status + structured error code + the parsed JSON body (if any). */
62
+ export declare class PylonHttpError extends Error {
63
+ status: number;
64
+ code?: string;
65
+ body?: unknown;
66
+ constructor(message: string, status: number, code?: string, body?: unknown);
67
+ }
68
+ /**
69
+ * The canonical client HTTP call. Returns the parsed JSON body on
70
+ * 2xx. Throws `PylonHttpError` on non-2xx. Empty 204 bodies parse
71
+ * as `null`.
72
+ *
73
+ * Surfaces `X-Pylon-Change-Seq` via `config.onChangeSeq` so the
74
+ * SyncEngine can fire a catch-up pull when a mutation's seq is
75
+ * ahead of its local cursor — matches the
76
+ * `requestWithChangeSync` shape but lifts the logic out of the
77
+ * engine so React free helpers (`callFn`, `apiRequest`) get it too.
78
+ */
79
+ export declare function pylonFetch<T = unknown>(config: TransportConfig, path: string, init?: PylonRequestInit): Promise<T>;
80
+ /**
81
+ * Streaming variant — returns the raw `Response` so callers can read
82
+ * `.body` (SSE), `.blob()` (file download), etc. Bypasses JSON
83
+ * parsing but keeps the URL + auth + credentials logic.
84
+ *
85
+ * Caller is responsible for status checking and the
86
+ * X-Pylon-Change-Seq callback (we don't read the response without
87
+ * the caller's involvement).
88
+ */
89
+ export declare function pylonFetchRaw(config: TransportConfig, path: string, init?: PylonRequestInit): Promise<Response>;
@@ -0,0 +1,19 @@
1
+ import type { Transport, TransportHost } from "./types";
2
+ export type TransportKind = "websocket" | "sse" | "poll";
3
+ export type { Transport, TransportHost } from "./types";
4
+ export { WebSocketTransport } from "./websocket";
5
+ export { SseTransport } from "./sse";
6
+ export { PollingTransport } from "./polling";
7
+ /** Build the right transport for a given kind. The host supplies all
8
+ * config + the inbound dispatch callbacks; the transport hides the
9
+ * underlying mechanism.
10
+ *
11
+ * Fallback rule: `transport: "sse"` in an environment without a
12
+ * native EventSource (Node, jsdom without a polyfill, old browsers)
13
+ * silently downgrades to polling. The pre-refactor `connectSse()`
14
+ * did this inside its constructor catch — we lift the decision to
15
+ * the factory so the engine never sees an SseTransport that can
16
+ * never connect. Clients that NEED SSE specifically can detect this
17
+ * by feature-checking `typeof EventSource` themselves before calling
18
+ * `init()`. */
19
+ export declare function createTransport(kind: TransportKind, host: TransportHost): Transport;
@@ -0,0 +1,15 @@
1
+ import type { Transport, TransportHost } from "./types";
2
+ export declare class PollingTransport implements Transport {
3
+ private readonly host;
4
+ private timer;
5
+ constructor(host: TransportHost);
6
+ start(): void;
7
+ stop(): void;
8
+ send(_msg: unknown): void;
9
+ isOpen(): boolean;
10
+ /** No-op — polling doesn't have a backoff counter; it just retries
11
+ * every interval. A 429 on the in-flight push/pull will surface as
12
+ * a thrown error on the next tick but the loop continues at the
13
+ * same cadence. */
14
+ bumpReconnect(_by?: number): void;
15
+ }
@@ -0,0 +1,20 @@
1
+ /** Stateful backoff counter. Reset to 0 attempts after a successful
2
+ * stable connection window. Increment on every failed connect /
3
+ * unexpected close. */
4
+ export declare class ReconnectBackoff {
5
+ private attempts;
6
+ private baseDelayMs;
7
+ constructor(baseDelayMs?: number);
8
+ /** Record a failed connect / unexpected close. Returns the new attempt
9
+ * count so callers can use it for bookkeeping (e.g., the 429 case
10
+ * bumps by 3 to push the next delay further out). */
11
+ bump(by?: number): number;
12
+ /** Reset the counter — call this after the connection has been
13
+ * stable long enough that subsequent reconnects shouldn't carry
14
+ * the prior backoff over. */
15
+ reset(): void;
16
+ /** Compute the next delay in ms. Always returns at least 0; never
17
+ * negative. Full-jitter over [0, exp] where exp grows
18
+ * exponentially with the attempt count and caps at MAX_DELAY_MS. */
19
+ nextDelayMs(): number;
20
+ }
@@ -0,0 +1,22 @@
1
+ import type { Transport, TransportHost } from "./types";
2
+ export declare class SseTransport implements Transport {
3
+ private readonly host;
4
+ private es;
5
+ private reconnectTimer;
6
+ private readonly backoff;
7
+ constructor(host: TransportHost);
8
+ start(): void;
9
+ stop(): void;
10
+ /** SSE is one-way. The engine still calls send() for subscribe /
11
+ * presence / topic / ping, but those frames have nowhere to go.
12
+ * Silent no-op rather than throw: a no-op keeps app code identical
13
+ * across transports. */
14
+ send(_msg: unknown): void;
15
+ /** SSE is always "open enough" to receive once the EventSource is
16
+ * attached and not in CLOSED. We expose this for the engine's
17
+ * `connected` getter so UI status matches reality. */
18
+ isOpen(): boolean;
19
+ bumpReconnect(by?: number): void;
20
+ private scheduleReconnect;
21
+ private deriveSseUrl;
22
+ }
@@ -0,0 +1,97 @@
1
+ import type { ChangeEvent, SyncConnectionStatus } from "../types";
2
+ /** Real-time transport implementations all conform to this shape. The
3
+ * engine holds exactly one. The interface intentionally hides the
4
+ * underlying mechanism (WebSocket vs EventSource vs setInterval) so
5
+ * the engine can be told "go connect" without caring which one. */
6
+ export interface Transport {
7
+ /** Connect / start the transport. Idempotent — calling twice is a
8
+ * no-op for an already-running transport. */
9
+ start(): void;
10
+ /** Disconnect / stop the transport, clearing every timer and
11
+ * releasing the socket. Idempotent. After stop() the transport is
12
+ * re-startable. */
13
+ stop(): void;
14
+ /** Send a JSON message over the transport. No-op for non-bidirectional
15
+ * transports (SSE, polling) — the engine still sends subscribe
16
+ * frames + presence + topic + ping through this entry point, and
17
+ * those frames simply never reach the wire when the transport
18
+ * doesn't support uplink. */
19
+ send(msg: unknown): void;
20
+ /** True when the underlying connection is currently open for sending.
21
+ * Used by the engine's `connected` getter and by paths that need to
22
+ * short-circuit when no socket exists (e.g., avoid throwing on a
23
+ * send-without-WS). */
24
+ isOpen(): boolean;
25
+ /** Bump the reconnect backoff counter by N attempts. Called by the
26
+ * engine when it observes a hint that the NEXT reconnect should
27
+ * wait longer — typically a 429 on pull, which would otherwise
28
+ * drive a tight 429 / reconnect / 429 loop. Transports without a
29
+ * backoff (polling) implement this as a no-op. */
30
+ bumpReconnect(by?: number): void;
31
+ }
32
+ /** Engine-side surface the transport calls into. Transport is the
33
+ * thing that knows about sockets and timers; the host (engine) is the
34
+ * thing that knows what to DO with inbound frames and lifecycle
35
+ * events. Keeping this small + explicit makes the boundary clear:
36
+ * if a transport needs a new engine capability it gets added here,
37
+ * not via reaching back into the engine instance. */
38
+ export interface TransportHost {
39
+ /** Base URL of the Pylon HTTP API (e.g. `https://api.example.com`).
40
+ * Transports derive their own URLs from this (ws upgrade path, SSE
41
+ * endpoint, poll path). */
42
+ readonly baseUrl: string;
43
+ /** Optional explicit WS URL — overrides the derived one. */
44
+ readonly wsUrl?: string;
45
+ /** WS keepalive interval. Default is implementation-defined; the
46
+ * engine accepts `pingIntervalMs` from config to tune broadcast
47
+ * latency on single-threaded server fallbacks. */
48
+ readonly pingIntervalMs?: number;
49
+ /** Base delay for reconnect backoff (full-jitter). */
50
+ readonly reconnectDelayMs?: number;
51
+ /** Polling cadence (polling transport only). */
52
+ readonly pollIntervalMs?: number;
53
+ /** Current bearer token, or undefined when anonymous. The transport
54
+ * must call this fresh on each connect / reconnect so token rotation
55
+ * is picked up automatically. */
56
+ getToken(): string | undefined;
57
+ /** Is this tab the multi-tab leader. Followers don't open their own
58
+ * transport — they mirror the leader's broadcasts. Transports check
59
+ * this defensively at start() to avoid wasting socket budget. */
60
+ isLeader(): boolean;
61
+ /** Is the engine currently running. Set false on stop(); transports
62
+ * bail out of reconnect timers when this flips. */
63
+ isRunning(): boolean;
64
+ /** Inbound: a typed change event from the server's change log.
65
+ * Engine routes through the apply queue so order is preserved. */
66
+ onChangeEvent(ev: ChangeEvent): void;
67
+ /** Inbound: a typed JSON envelope (not a ChangeEvent). The transport
68
+ * doesn't try to interpret the envelope — engine has the dispatch
69
+ * table (session-changed, reactive-result, row-revoked, presence,
70
+ * pong, etc). */
71
+ onJsonMessage(msg: Record<string, unknown>): void;
72
+ /** Inbound: a binary frame. CRDT broadcast layer ships these for
73
+ * every Loro-mode write; the engine routes via its binaryHandlers
74
+ * + multi-tab forwarders. */
75
+ onBinaryFrame(bytes: Uint8Array): void;
76
+ /** Lifecycle: the transport just connected. Engine uses this to
77
+ * replay active subscriptions, fire a catch-up pull + reconcile,
78
+ * and flip the public connection status. */
79
+ onConnected(): void;
80
+ /** Lifecycle: the transport just disconnected. Engine flips status
81
+ * to `reconnecting` while the transport's own backoff timer is
82
+ * pending; or to `offline` when `isRunning()` is false. */
83
+ onDisconnected(): void;
84
+ /** Flip the engine's public `connectionStatus`. Transports drive
85
+ * this so the UI sees `connecting` / `connected` / `reconnecting`
86
+ * / `offline` at the right moments. */
87
+ setStatus(s: SyncConnectionStatus): void;
88
+ /** Polling transport only: one iteration of the push→pull cycle.
89
+ * Implementations of polling do nothing else; they just call this
90
+ * on a timer. */
91
+ performPollTick(): Promise<void>;
92
+ /** Reconnect-aware transports (WS, SSE) need to do a catch-up pull
93
+ * before the next connect attempt so they don't open a fresh socket
94
+ * and immediately discover their cursor is stale. The engine owns
95
+ * the pull queue + reconcile path; the transport invokes this. */
96
+ performReconnectPull(): Promise<void>;
97
+ }
@@ -0,0 +1,21 @@
1
+ import type { Transport, TransportHost } from "./types";
2
+ export declare class WebSocketTransport implements Transport {
3
+ private readonly host;
4
+ private ws;
5
+ private reconnectTimer;
6
+ private stableTimer;
7
+ private pingTimer;
8
+ private readonly backoff;
9
+ constructor(host: TransportHost);
10
+ start(): void;
11
+ stop(): void;
12
+ send(msg: unknown): void;
13
+ isOpen(): boolean;
14
+ /** Bump the reconnect counter explicitly. Used for the 429 RESYNC
15
+ * case where the engine wants the next delay to be 3 steps further
16
+ * out — protects the server from a 429-loop where a 429 on pull
17
+ * triggers onclose → reconnect → pull → 429 in a tight cycle. */
18
+ bumpReconnect(by?: number): void;
19
+ private scheduleReconnect;
20
+ private deriveWsUrl;
21
+ }
@@ -0,0 +1,124 @@
1
+ export interface ChangeEvent {
2
+ seq: number;
3
+ entity: string;
4
+ row_id: string;
5
+ kind: "insert" | "update" | "delete";
6
+ data?: Record<string, unknown>;
7
+ timestamp: string;
8
+ }
9
+ export interface SyncCursor {
10
+ last_seq: number;
11
+ }
12
+ export interface PullResponse {
13
+ changes: ChangeEvent[];
14
+ cursor: SyncCursor;
15
+ has_more: boolean;
16
+ }
17
+ /**
18
+ * Server-resolved auth/session state. Shape mirrors what `/api/auth/me`
19
+ * returns (which is `AuthContext` from the Rust side, with camelCase
20
+ * normalization on the way out).
21
+ *
22
+ * `userId=null` means anonymous. `tenantId=null` means the user hasn't
23
+ * selected an org yet (or the backend is single-tenant).
24
+ */
25
+ export interface ResolvedSession {
26
+ userId: string | null;
27
+ tenantId: string | null;
28
+ isAdmin: boolean;
29
+ roles: string[];
30
+ }
31
+ /**
32
+ * Per-op result on /api/sync/push. Formal shape:
33
+ *
34
+ * { op_id, row_id, entity, kind, status, seq?, error? }
35
+ *
36
+ * Status semantics:
37
+ * - `applied` — first-time write committed at `seq`.
38
+ * - `replayed` — same op_id arrived again after a confirmed apply;
39
+ * `seq` is the cached value of the original write so
40
+ * the client can adopt it on its optimistic ghost
41
+ * without waiting for the WS rebroadcast.
42
+ * - `pending` — a concurrent push carrying this op_id is still
43
+ * in flight on the server. No `seq` yet; the
44
+ * client should retry after a short backoff.
45
+ * - `error` — the write was attempted and rejected; `error`
46
+ * carries the server's code + message.
47
+ *
48
+ * Legacy `deduped` is treated as `replayed` for compatibility — older
49
+ * servers (≤ 0.3.190) returned `deduped` for both InFlight and
50
+ * Replayed cases.
51
+ */
52
+ export interface PushOpResult {
53
+ op_id?: string | null;
54
+ entity?: string;
55
+ row_id?: string;
56
+ kind?: "insert" | "update" | "delete";
57
+ status: "applied" | "replayed" | "pending" | "error" | "deduped";
58
+ seq?: number;
59
+ error?: {
60
+ code: string;
61
+ message: string;
62
+ } | string;
63
+ }
64
+ export interface PushResponse {
65
+ applied: number;
66
+ deduped: number;
67
+ errors: string[];
68
+ results?: PushOpResult[];
69
+ cursor: SyncCursor;
70
+ }
71
+ export interface ClientChange {
72
+ entity: string;
73
+ row_id: string;
74
+ kind: "insert" | "update" | "delete";
75
+ data?: Record<string, unknown>;
76
+ /**
77
+ * Client-minted idempotency key. The server tracks recently-seen op_ids
78
+ * and returns a no-op success for replays. Supply this on every retry of
79
+ * the same logical mutation — the `MutationQueue` does so automatically.
80
+ */
81
+ op_id?: string;
82
+ }
83
+ /**
84
+ * Reactive subscription spec — what the server needs to replay a
85
+ * subscription if the client reconnects. Cached client-side so the
86
+ * `ws.onopen` reconnect sweep can re-register every active sub
87
+ * without the React hooks having to know about reconnect lifecycle.
88
+ */
89
+ export interface ReactiveSpec {
90
+ fn_name: string;
91
+ args: unknown;
92
+ }
93
+ /**
94
+ * Push message routed to a reactive subscription handler. `result`
95
+ * fires on initial run + every time the server's re-run produces a
96
+ * value whose hash differs from the last push. `error` fires when
97
+ * the server can't execute the handler (function not registered,
98
+ * reactive runtime unavailable, runtime error in user code).
99
+ */
100
+ export type ReactiveMessage = {
101
+ kind: "result";
102
+ result: unknown;
103
+ } | {
104
+ kind: "error";
105
+ code: string;
106
+ message: string;
107
+ };
108
+ export type Row = Record<string, unknown>;
109
+ export type TransportType = "websocket" | "sse" | "poll";
110
+ /**
111
+ * Coarse connection state for UI consumers.
112
+ *
113
+ * - `connecting` — engine is starting up; first WS handshake hasn't
114
+ * completed yet. Apps typically render their initial
115
+ * skeleton during this state.
116
+ * - `connected` — WS is open and we've stayed open long enough to
117
+ * consider it stable (5s on the wire). Live queries
118
+ * are receiving real-time updates.
119
+ * - `reconnecting` — WS dropped (network blip, autostop) and the
120
+ * engine is backing off + retrying.
121
+ * - `offline` — engine has been stopped via `engine.stop()` or
122
+ * was never started. No retries pending.
123
+ */
124
+ export type SyncConnectionStatus = "connecting" | "connected" | "reconnecting" | "offline";
package/package.json CHANGED
@@ -3,14 +3,20 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.292",
6
+ "version": "0.3.294",
7
7
  "type": "module",
8
- "main": "src/index.ts",
9
- "types": "src/index.ts",
8
+ "main": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
10
  "scripts": {
11
- "check": "tsc -p tsconfig.json --noEmit"
11
+ "check": "tsc -p tsconfig.json --noEmit",
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "prepack": "bun run build"
12
14
  },
13
15
  "devDependencies": {
14
16
  "fake-indexeddb": "^6.2.5"
15
- }
17
+ },
18
+ "files": [
19
+ "src",
20
+ "dist"
21
+ ]
16
22
  }
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "types": ["bun-types"]
5
- },
6
- "include": ["src"]
7
- }