@pylonsync/sync 0.3.188 → 0.3.192

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,309 @@
1
+ // ---------------------------------------------------------------------------
2
+ // In-memory replica of server state.
3
+ //
4
+ // Tracks tables (entity → id → row), tombstones (delete seqs that
5
+ // fence out late-arriving stale inserts), and optimistic tombstones
6
+ // (pending client deletes that block incoming inserts until the
7
+ // server's authoritative delete arrives). Persistence backends
8
+ // (IndexedDB) wire through `_persistFn`.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import type { ChangeEvent, Row } from "./types";
12
+
13
+ export class LocalStore {
14
+ private tables: Map<string, Map<string, Row>> = new Map();
15
+ /**
16
+ * `(entity, row_id) → deletedAt seq`. A row in this map has been
17
+ * deleted; any insert/update event older than its tombstone seq is
18
+ * ignored so an out-of-order replay can't resurrect it. Without
19
+ * this, a delete followed by a reconnect-driven replay of the
20
+ * original insert would re-materialize the row.
21
+ *
22
+ * Real server-issued tombstones use the event's own seq. Optimistic
23
+ * client tombstones live in `optimisticTombstones` instead.
24
+ */
25
+ private tombstones: Map<string, Map<string, number>> = new Map();
26
+ /**
27
+ * Pending optimistic deletes — `(entity, row_id)` pairs the client
28
+ * has dropped but the server hasn't yet confirmed. Stored
29
+ * separately from `tombstones` so the real server-issued delete
30
+ * (with its real, smaller seq) can supersede the optimistic entry
31
+ * without being max-merged out.
32
+ *
33
+ * Invariant: a future legitimate re-create of the same id must
34
+ * succeed once the server's authoritative delete arrives. Test:
35
+ * `optimistic_delete_releases_id_when_server_confirms`.
36
+ */
37
+ private optimisticTombstones: Map<string, Set<string>> = new Map();
38
+ private listeners: Set<() => void> = new Set();
39
+
40
+ /** Get all rows for an entity. */
41
+ list(entity: string): Row[] {
42
+ const table = this.tables.get(entity);
43
+ if (!table) return [];
44
+ return Array.from(table.values());
45
+ }
46
+
47
+ /** Get a row by ID. */
48
+ get(entity: string, id: string): Row | null {
49
+ return this.tables.get(entity)?.get(id) ?? null;
50
+ }
51
+
52
+ /** Snapshot of every entity name with at least one local row. Used
53
+ * by `SyncEngine.reconcile` to know which tables to diff against
54
+ * server truth. */
55
+ entityNames(): string[] {
56
+ const names: string[] = [];
57
+ for (const [name, table] of this.tables) {
58
+ if (table.size > 0) names.push(name);
59
+ }
60
+ return names;
61
+ }
62
+
63
+ /**
64
+ * Remove a row whose absence was confirmed by the server-truth
65
+ * reconciler. Records a tombstone at `tombstoneSeq` so a stale
66
+ * insert/update replayed afterwards (e.g. a slow WS frame) can't
67
+ * resurrect it. Callers pass the current sync cursor — any future
68
+ * change events have higher seqs and pass the tombstone check,
69
+ * while older replays are filtered.
70
+ *
71
+ * Differs from `optimisticDelete`: this is "the server says this
72
+ * row is gone right now"; that one is "the client wants this row
73
+ * gone before the server has confirmed."
74
+ */
75
+ reconcileRemove(entity: string, id: string, tombstoneSeq: number): boolean {
76
+ const table = this.tables.get(entity);
77
+ if (!table || !table.has(id)) return false;
78
+ table.delete(id);
79
+ this.recordTombstone(entity, id, tombstoneSeq);
80
+ return true;
81
+ }
82
+
83
+ private isTombstoned(entity: string, id: string, at_seq?: number): boolean {
84
+ if (this.optimisticTombstones.get(entity)?.has(id)) return true;
85
+ const tombSeq = this.tombstones.get(entity)?.get(id);
86
+ if (tombSeq === undefined) return false;
87
+ // No-seq caller means "this change has no provenance" — treat as
88
+ // older than any tombstone (safer default than admitting it).
89
+ if (at_seq === undefined) return true;
90
+ return at_seq < tombSeq;
91
+ }
92
+
93
+ private recordTombstone(entity: string, id: string, seq: number): void {
94
+ // A real (server-issued) tombstone supersedes any pending
95
+ // optimistic entry for this id. Drop the optimistic guard so a
96
+ // future legitimate re-create of the same id isn't blocked.
97
+ this.optimisticTombstones.get(entity)?.delete(id);
98
+ if (!this.tombstones.has(entity)) {
99
+ this.tombstones.set(entity, new Map());
100
+ }
101
+ const existing = this.tombstones.get(entity)!.get(id);
102
+ if (existing === undefined || seq > existing) {
103
+ this.tombstones.get(entity)!.set(id, seq);
104
+ }
105
+ }
106
+
107
+ /** Apply a change event to the local store. */
108
+ applyChange(change: ChangeEvent): void {
109
+ if (!this.tables.has(change.entity)) {
110
+ this.tables.set(change.entity, new Map());
111
+ }
112
+ const table = this.tables.get(change.entity)!;
113
+
114
+ // Drop insert/update events that arrive AFTER a delete for the
115
+ // same row. The tombstone map records the seq of the delete;
116
+ // anything strictly older is a stale resurrect.
117
+ if (
118
+ (change.kind === "insert" || change.kind === "update") &&
119
+ this.isTombstoned(change.entity, change.row_id, change.seq)
120
+ ) {
121
+ return;
122
+ }
123
+
124
+ switch (change.kind) {
125
+ case "insert":
126
+ if (change.data) {
127
+ // Spread data FIRST, then force `id = change.row_id` —
128
+ // otherwise an `id` field in `data` could shadow the
129
+ // authoritative row_id and corrupt the replica's primary key
130
+ // on reload.
131
+ table.set(change.row_id, {
132
+ ...change.data,
133
+ id: change.row_id,
134
+ });
135
+ }
136
+ break;
137
+ case "update":
138
+ if (change.data) {
139
+ const existing = table.get(change.row_id) ?? { id: change.row_id };
140
+ table.set(change.row_id, {
141
+ ...existing,
142
+ ...change.data,
143
+ id: change.row_id,
144
+ });
145
+ }
146
+ break;
147
+ case "delete":
148
+ table.delete(change.row_id);
149
+ this.recordTombstone(change.entity, change.row_id, change.seq);
150
+ break;
151
+ }
152
+ }
153
+
154
+ /** Apply multiple changes synchronously. Persistence runs fire-
155
+ * and-forget. Prefer `applyChangesAsync` when you plan to advance
156
+ * a cursor after — otherwise a crash can save the cursor before
157
+ * rows hit disk, causing permanent missed changes on restart. */
158
+ applyChanges(changes: ChangeEvent[]): void {
159
+ for (const change of changes) {
160
+ this.applyChange(change);
161
+ }
162
+ this.notify();
163
+
164
+ if (this._persistFn) {
165
+ for (const change of changes) {
166
+ const merged = this.hydrateFromMemory(change);
167
+ void this._persistFn(merged);
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Apply + persist, awaiting disk writes before returning. Callers
174
+ * that are about to advance a cursor based on `changes` MUST use
175
+ * this path — otherwise cursor durability is broken: a crash
176
+ * between the memory apply and the eventual disk write can persist
177
+ * a cursor that's ahead of the replica, skipping those rows
178
+ * forever on restart.
179
+ */
180
+ async applyChangesAsync(changes: ChangeEvent[]): Promise<void> {
181
+ for (const change of changes) {
182
+ this.applyChange(change);
183
+ }
184
+ this.notify();
185
+ if (this._persistFn) {
186
+ // Sequential await — concurrent IDB writes can resolve out of
187
+ // order, racing an update behind its own delete on disk. The
188
+ // engine's `applyQueue` sequences events into here; we
189
+ // preserve that order down to the disk layer.
190
+ for (const change of changes) {
191
+ const result = this._persistFn(this.hydrateFromMemory(change));
192
+ if (result instanceof Promise) {
193
+ await result;
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Reshape a change event so its `data` field matches the row as it
201
+ * now exists in memory after `applyChange` merged the patch.
202
+ * Persistence callers (IndexedDB) save the full row, which only
203
+ * works if they receive the full row. Deletes pass through
204
+ * untouched.
205
+ */
206
+ private hydrateFromMemory(change: ChangeEvent): ChangeEvent {
207
+ if (change.kind === "delete") return change;
208
+ const merged = this.tables.get(change.entity)?.get(change.row_id);
209
+ if (!merged) return change;
210
+ return { ...change, data: merged };
211
+ }
212
+
213
+ /** Persistence callback for auto-saving changes. Returns
214
+ * `Promise<void>` so callers can await. Void-returning callbacks
215
+ * are accepted for backwards compatibility (just not awaitable). */
216
+ _persistFn: ((change: ChangeEvent) => void | Promise<void>) | null = null;
217
+
218
+ /** Subscribe to store changes. Returns unsubscribe function. */
219
+ subscribe(listener: () => void): () => void {
220
+ this.listeners.add(listener);
221
+ return () => this.listeners.delete(listener);
222
+ }
223
+
224
+ notify(): void {
225
+ for (const listener of this.listeners) {
226
+ listener();
227
+ }
228
+ }
229
+
230
+ /** Apply an optimistic insert. Returns a temporary id. */
231
+ optimisticInsert(entity: string, data: Row): string {
232
+ const tempId = `_pending_${Date.now()}_${Math.random().toString(36).slice(2)}`;
233
+ if (!this.tables.has(entity)) {
234
+ this.tables.set(entity, new Map());
235
+ }
236
+ this.tables.get(entity)!.set(tempId, { id: tempId, ...data });
237
+ this.notify();
238
+ return tempId;
239
+ }
240
+
241
+ /**
242
+ * Apply an optimistic insert with a caller-provided id.
243
+ *
244
+ * Used by `useMutation({ optimistic })`: the React hook generates a
245
+ * Pylon-shaped id (40-char hex via `generateId()`), threads it
246
+ * through the mutation args as `_optimisticId`, and the server
247
+ * function honors it on `ctx.db.insert("Entity", { id, ... })`.
248
+ * Because the optimistic ghost and the canonical row share the
249
+ * same row_id, the WS broadcast that follows lands as a field-
250
+ * level merge on top of the optimistic — no delete-then-replace
251
+ * flash, no temp-row swap.
252
+ */
253
+ optimisticInsertWithId(entity: string, id: string, data: Row): void {
254
+ if (!this.tables.has(entity)) {
255
+ this.tables.set(entity, new Map());
256
+ }
257
+ this.tables.get(entity)!.set(id, { ...data, id });
258
+ this.notify();
259
+ }
260
+
261
+ /**
262
+ * Roll back an optimistic insert without leaving a tombstone.
263
+ *
264
+ * Counterpart to `optimisticInsertWithId`. On rejection the ghost
265
+ * row must go away, but we MUST NOT leave a tombstone — a future
266
+ * legitimate insert with the same id (user retries, workflow
267
+ * eventually creates the row) must not be blocked.
268
+ * `optimisticDelete` records a MAX_SAFE_INTEGER tombstone, which is
269
+ * the wrong semantic here; this is a plain remove.
270
+ */
271
+ rollbackOptimisticInsert(entity: string, id: string): void {
272
+ const removed = this.tables.get(entity)?.delete(id);
273
+ if (removed) this.notify();
274
+ }
275
+
276
+ /** Apply an optimistic update. */
277
+ optimisticUpdate(entity: string, id: string, data: Partial<Row>): void {
278
+ const table = this.tables.get(entity);
279
+ if (!table) return;
280
+ const existing = table.get(id);
281
+ if (existing) {
282
+ table.set(id, { ...existing, ...data });
283
+ this.notify();
284
+ }
285
+ }
286
+
287
+ /** Apply an optimistic delete. Block any incoming insert/update
288
+ * for this id until the server's authoritative delete arrives. */
289
+ optimisticDelete(entity: string, id: string): void {
290
+ this.tables.get(entity)?.delete(id);
291
+ if (!this.optimisticTombstones.has(entity)) {
292
+ this.optimisticTombstones.set(entity, new Set());
293
+ }
294
+ this.optimisticTombstones.get(entity)!.add(id);
295
+ this.notify();
296
+ }
297
+
298
+ /**
299
+ * Drop every table + tombstone in-place, then notify. Used by the
300
+ * sync engine's `resetReplica()` on identity flip (token or tenant
301
+ * changed — the old replica reflects a different visible set).
302
+ */
303
+ clearAll(): void {
304
+ this.tables.clear();
305
+ this.tombstones.clear();
306
+ this.optimisticTombstones.clear();
307
+ this.notify();
308
+ }
309
+ }
@@ -0,0 +1,153 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Offline-safe write queue.
3
+ //
4
+ // Memory-only by default. When a persistence backend is attached
5
+ // (`attachPersistence`), every state transition writes through to
6
+ // disk and `hydrate()` restores pending/failed mutations on startup.
7
+ //
8
+ // The mutation id is reused as the server-side `op_id` for idempotent
9
+ // replay — a retry carrying the same id short-circuits on the server
10
+ // instead of re-applying.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type { ClientChange } from "./types";
14
+
15
+ export interface PendingMutation {
16
+ id: string;
17
+ change: ClientChange;
18
+ status: "pending" | "applied" | "failed";
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * Optional persistence backend. The default IndexedDB persistence
24
+ * layer provides `savePending`/`loadPending`. Callers can supply a
25
+ * custom backend for tests or alternative storage.
26
+ */
27
+ export interface MutationQueuePersistence {
28
+ saveAll(mutations: PendingMutation[]): Promise<void>;
29
+ loadAll(): Promise<PendingMutation[]>;
30
+ }
31
+
32
+ export class MutationQueue {
33
+ private queue: PendingMutation[] = [];
34
+ private persistence?: MutationQueuePersistence;
35
+
36
+ constructor(persistence?: MutationQueuePersistence) {
37
+ this.persistence = persistence;
38
+ }
39
+
40
+ /**
41
+ * Attach a persistence backend after construction. The SyncEngine
42
+ * swaps in IndexedDB-backed persistence once the DB has opened.
43
+ * Public so it doesn't need a `// @ts-expect-error` to reach in
44
+ * from the same package.
45
+ */
46
+ attachPersistence(persistence: MutationQueuePersistence): void {
47
+ this.persistence = persistence;
48
+ }
49
+
50
+ /** Load persisted queue state. Call once at startup. */
51
+ async hydrate(): Promise<void> {
52
+ if (!this.persistence) return;
53
+ try {
54
+ const loaded = await this.persistence.loadAll();
55
+ // Merge in-memory with on-disk. An `add()` that ran while
56
+ // hydrate was awaiting `loadAll()` will already have flushed a
57
+ // snapshot that didn't include the loaded rows — re-flush
58
+ // after merge so disk matches memory again.
59
+ const existingIds = new Set(this.queue.map((m) => m.id));
60
+ let mergedAny = false;
61
+ for (const m of loaded) {
62
+ if (!existingIds.has(m.id)) {
63
+ this.queue.push(m);
64
+ mergedAny = true;
65
+ }
66
+ }
67
+ if (mergedAny) this.flush();
68
+ } catch (err) {
69
+ // Broken storage shouldn't prevent the app from running — warn
70
+ // and degrade to memory-only mode.
71
+ console.warn("[sync] mutation-queue hydrate failed:", err);
72
+ }
73
+ }
74
+
75
+ /** Add a pending mutation. Returns the op_id used for server
76
+ * idempotency. */
77
+ add(change: ClientChange): string {
78
+ const id = `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
79
+ const changeWithOp: ClientChange = { ...change, op_id: id };
80
+ this.queue.push({ id, change: changeWithOp, status: "pending" });
81
+ this.flush();
82
+ return id;
83
+ }
84
+
85
+ pending(): PendingMutation[] {
86
+ return this.queue.filter((m) => m.status === "pending");
87
+ }
88
+
89
+ /**
90
+ * Set of `${entity}/${row_id}` keys for every mutation currently
91
+ * in Pending or Failed state. Used by reconcile() to skip rows
92
+ * whose canonical state on the server hasn't caught up with the
93
+ * local optimistic ghost yet — otherwise reconcile would tombstone
94
+ * the row (it's not yet on the server) and the still-pending push
95
+ * would later re-apply against the tombstone, fighting the local
96
+ * replica.
97
+ *
98
+ * Failed mutations are included too: a user-visible failure is
99
+ * recoverable, and sweeping the row would discard the local edit
100
+ * the user is still trying to push.
101
+ */
102
+ pendingRowKeys(): Set<string> {
103
+ const out = new Set<string>();
104
+ for (const m of this.queue) {
105
+ if (m.status === "pending" || m.status === "failed") {
106
+ out.add(`${m.change.entity}/${m.change.row_id}`);
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ markApplied(id: string): void {
113
+ const m = this.queue.find((m) => m.id === id);
114
+ if (m) m.status = "applied";
115
+ this.flush();
116
+ }
117
+
118
+ markFailed(id: string, error: string): void {
119
+ const m = this.queue.find((m) => m.id === id);
120
+ if (m) {
121
+ m.status = "failed";
122
+ m.error = error;
123
+ }
124
+ this.flush();
125
+ }
126
+
127
+ /**
128
+ * Prune applied mutations. Failed mutations are KEPT so the UI can
129
+ * surface them to the user and so retries are possible.
130
+ */
131
+ clear(): void {
132
+ this.queue = this.queue.filter(
133
+ (m) => m.status === "pending" || m.status === "failed",
134
+ );
135
+ this.flush();
136
+ }
137
+
138
+ /** Remove a specific mutation by id. Used by the UI after user
139
+ * ack of failures. */
140
+ remove(id: string): void {
141
+ this.queue = this.queue.filter((m) => m.id !== id);
142
+ this.flush();
143
+ }
144
+
145
+ /** Fire-and-forget persistence write. */
146
+ private flush(): void {
147
+ if (!this.persistence) return;
148
+ const snapshot = this.queue.slice();
149
+ this.persistence.saveAll(snapshot).catch((err) => {
150
+ console.warn("[sync] mutation-queue persist failed:", err);
151
+ });
152
+ }
153
+ }
@@ -0,0 +1,205 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared client HTTP transport.
3
+ //
4
+ // One helper that every browser-side Pylon call funnels through:
5
+ // `pylonFetch(config, path, init)`. Consistently handles:
6
+ //
7
+ // - base URL resolution (config.baseUrl, falling back to
8
+ // window.location.origin on the browser when omitted)
9
+ // - bearer token attachment (config.token or token-getter callback)
10
+ // - `credentials: "include"` for cookie-auth apps
11
+ // - request-body JSON serialization (when `init.json` is set)
12
+ // - response JSON parsing with structured error envelope:
13
+ // { error: { code, message } }
14
+ // - non-JSON error bodies (HTML proxy pages, empty 204s) → synthetic
15
+ // Error with status + code
16
+ // - X-Pylon-Change-Seq response header surfaced via the optional
17
+ // `onChangeSeq` callback (so callers can trigger catch-up pulls
18
+ // when a mutation's seq exceeds their local cursor)
19
+ //
20
+ // Streaming + raw-binary callers (file uploads, SSE) bypass the
21
+ // JSON-parse step but reuse the URL/auth/credentials logic via
22
+ // `buildRequest`.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * What every caller of `pylonFetch` must supply. The SyncEngine
27
+ * passes its own config; the React free helpers pass a lightweight
28
+ * shim built from `getBaseUrl()` + `currentAuthToken()`.
29
+ */
30
+ export interface TransportConfig {
31
+ baseUrl?: string;
32
+ /** Static bearer token. Use `getToken` if the token can change. */
33
+ token?: string;
34
+ /** Lazy bearer-token resolver — called per request. Use this when
35
+ * the token can be rotated mid-session (refresh, session-changed).
36
+ * Returning null/undefined means "no auth header"; cookie-auth
37
+ * apps rely on `credentials: "include"` instead. */
38
+ getToken?: () => string | null | undefined;
39
+ /** Invoked with the value of the X-Pylon-Change-Seq response
40
+ * header when the server sets one. Returning a Promise pauses no
41
+ * one — the transport doesn't await it. */
42
+ onChangeSeq?: (seq: number) => void;
43
+ }
44
+
45
+ /**
46
+ * Per-call init shape. Mirrors the standard `RequestInit` but
47
+ * normalizes the two body shapes (JSON-encode-this vs raw-pass-
48
+ * through) into separate fields so callers don't have to remember
49
+ * to set Content-Type.
50
+ */
51
+ export interface PylonRequestInit {
52
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
53
+ /** When set, JSON-stringified into the body and Content-Type
54
+ * defaults to application/json. */
55
+ json?: unknown;
56
+ /** Raw body — passed through to fetch unchanged. Use this for file
57
+ * uploads (Blob, ArrayBuffer, FormData). */
58
+ body?: BodyInit;
59
+ /** Extra headers. Caller-supplied keys win against the transport's
60
+ * defaults. */
61
+ headers?: Record<string, string>;
62
+ /** Override the request's Accept header (useful for SSE streams). */
63
+ accept?: string;
64
+ /** AbortController signal for cancellation. */
65
+ signal?: AbortSignal;
66
+ }
67
+
68
+ /**
69
+ * Resolve the base URL. Browser-side fallback to
70
+ * `window.location.origin` matches the SDK contract: when
71
+ * `configureClient` was called with no baseUrl, calls go to the
72
+ * current document's origin so dev + prod work without env
73
+ * gymnastics.
74
+ */
75
+ export function resolveBaseUrl(config: TransportConfig): string {
76
+ if (config.baseUrl) return config.baseUrl.replace(/\/+$/, "");
77
+ if (typeof window !== "undefined" && window.location?.origin) {
78
+ return window.location.origin;
79
+ }
80
+ return "";
81
+ }
82
+
83
+ /**
84
+ * Assemble fetch URL + RequestInit. Exposed for streaming / raw-
85
+ * response callers (SSE, file upload) that need to handle the
86
+ * Response themselves but want the transport's auth + credentials +
87
+ * URL logic.
88
+ */
89
+ export function buildRequest(
90
+ config: TransportConfig,
91
+ path: string,
92
+ init: PylonRequestInit = {},
93
+ ): { url: string; init: RequestInit } {
94
+ const base = resolveBaseUrl(config);
95
+ const url = `${base}${path}`;
96
+ const headers: Record<string, string> = { ...(init.headers ?? {}) };
97
+ // Auth: explicit getToken wins over static token. Either may be
98
+ // null/undefined — cookie-auth apps rely solely on credentials.
99
+ const token = config.getToken?.() ?? config.token;
100
+ if (token) headers["Authorization"] = `Bearer ${token}`;
101
+ if (init.accept) headers["Accept"] = init.accept;
102
+ let body: BodyInit | undefined = init.body;
103
+ if (init.json !== undefined) {
104
+ if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
105
+ body = JSON.stringify(init.json);
106
+ }
107
+ return {
108
+ url,
109
+ init: {
110
+ method: init.method ?? (body !== undefined ? "POST" : "GET"),
111
+ headers,
112
+ // `credentials: "include"` is mandatory for cookie-auth apps.
113
+ // Bearer-auth apps don't care (no cookie to send) so the cost
114
+ // is the same. Browser CORS rules require the server to set
115
+ // `Access-Control-Allow-Credentials: true` and a specific
116
+ // origin (not `*`) for the cookie to be sent — Pylon's CORS
117
+ // middleware does this by default.
118
+ credentials: "include",
119
+ body,
120
+ signal: init.signal,
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Error thrown by `pylonFetch` for non-2xx responses. Carries the
126
+ * status + structured error code + the parsed JSON body (if any). */
127
+ export class PylonHttpError extends Error {
128
+ status: number;
129
+ code?: string;
130
+ body?: unknown;
131
+ constructor(message: string, status: number, code?: string, body?: unknown) {
132
+ super(message);
133
+ this.name = "PylonHttpError";
134
+ this.status = status;
135
+ this.code = code;
136
+ this.body = body;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The canonical client HTTP call. Returns the parsed JSON body on
142
+ * 2xx. Throws `PylonHttpError` on non-2xx. Empty 204 bodies parse
143
+ * as `null`.
144
+ *
145
+ * Surfaces `X-Pylon-Change-Seq` via `config.onChangeSeq` so the
146
+ * SyncEngine can fire a catch-up pull when a mutation's seq is
147
+ * ahead of its local cursor — matches the
148
+ * `requestWithChangeSync` shape but lifts the logic out of the
149
+ * engine so React free helpers (`callFn`, `apiRequest`) get it too.
150
+ */
151
+ export async function pylonFetch<T = unknown>(
152
+ config: TransportConfig,
153
+ path: string,
154
+ init: PylonRequestInit = {},
155
+ ): Promise<T> {
156
+ const { url, init: req } = buildRequest(config, path, init);
157
+ const res = await fetch(url, req);
158
+ const text = await res.text();
159
+ let parsed: unknown = null;
160
+ if (text) {
161
+ try {
162
+ parsed = JSON.parse(text);
163
+ } catch {
164
+ // Non-JSON body — proxy HTML error page, etc. Fall through;
165
+ // the !res.ok branch synthesizes a useful error.
166
+ }
167
+ }
168
+ // Surface change-seq for catch-up logic. Best-effort: a malformed
169
+ // header doesn't fail the request.
170
+ if (config.onChangeSeq) {
171
+ const header = res.headers.get("x-pylon-change-seq");
172
+ if (header) {
173
+ const seq = Number.parseInt(header, 10);
174
+ if (Number.isFinite(seq) && seq > 0) {
175
+ config.onChangeSeq(seq);
176
+ }
177
+ }
178
+ }
179
+ if (!res.ok) {
180
+ const err = parsed as { error?: { code?: string; message?: string } } | null;
181
+ const message =
182
+ err?.error?.message ??
183
+ `${req.method ?? "GET"} ${path} failed: ${res.status}`;
184
+ throw new PylonHttpError(message, res.status, err?.error?.code, parsed);
185
+ }
186
+ return parsed as T;
187
+ }
188
+
189
+ /**
190
+ * Streaming variant — returns the raw `Response` so callers can read
191
+ * `.body` (SSE), `.blob()` (file download), etc. Bypasses JSON
192
+ * parsing but keeps the URL + auth + credentials logic.
193
+ *
194
+ * Caller is responsible for status checking and the
195
+ * X-Pylon-Change-Seq callback (we don't read the response without
196
+ * the caller's involvement).
197
+ */
198
+ export async function pylonFetchRaw(
199
+ config: TransportConfig,
200
+ path: string,
201
+ init: PylonRequestInit = {},
202
+ ): Promise<Response> {
203
+ const { url, init: req } = buildRequest(config, path, init);
204
+ return fetch(url, req);
205
+ }