@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.
- package/package.json +1 -1
- package/src/ids.ts +60 -0
- package/src/index.ts +165 -716
- package/src/local-store.ts +309 -0
- package/src/mutation-queue.ts +153 -0
- package/src/transport.ts +205 -0
- package/src/types.ts +136 -0
|
@@ -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
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -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
|
+
}
|