snapback4 0.0.1 → 0.0.2

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/dist/client.js ADDED
@@ -0,0 +1,262 @@
1
+ // The online tier (LLP 2000.000 §7.4): no store, no interpreter. Reads go
2
+ // to the server and are `fresh` when it answered; an observed read re-runs
3
+ // when a commit touches its tables; a write is sent with a client-minted id
4
+ // and applies at most once, and waits as `pending` while the link is down.
5
+ // Every word a screen reads is on the card (contract.ts).
6
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
7
+ /** A time-ordered 128-bit id: 48 bits of milliseconds, 80 bits of entropy, 26 base32 digits. */
8
+ export function mintId(now = Date.now()) {
9
+ const bytes = new Uint8Array(10);
10
+ if (typeof crypto !== "undefined" && crypto.getRandomValues)
11
+ crypto.getRandomValues(bytes);
12
+ else
13
+ for (let i = 0; i < bytes.length; i++)
14
+ bytes[i] = Math.floor(Math.random() * 256);
15
+ let value = BigInt(now) << 80n;
16
+ for (const byte of bytes)
17
+ value = (value << 8n) | BigInt(byte);
18
+ let out = "";
19
+ for (let i = 0; i < 26; i++) {
20
+ out = ALPHABET[Number(value & 31n)] + out;
21
+ value >>= 5n;
22
+ }
23
+ return out;
24
+ }
25
+ export function createClient(options) {
26
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
27
+ const base = options.url.replace(/\/$/, "");
28
+ const headers = { "content-type": "application/json" };
29
+ if (options.persona)
30
+ headers["x-snapback-persona"] = options.persona;
31
+ const token = options.session?.token ?? options.token;
32
+ if (token)
33
+ headers.authorization = `Bearer ${token}`;
34
+ const viewer = options.persona ? `dev:${options.persona}` : options.session ? options.session.principal : null;
35
+ let link = viewer ? "online" : "signed-out";
36
+ let closed = false;
37
+ const aborter = new AbortController();
38
+ let seq = 0;
39
+ const linkListeners = new Set();
40
+ const watches = new Map();
41
+ const pending = [];
42
+ const setLink = (next) => {
43
+ if (link === next)
44
+ return;
45
+ link = next;
46
+ for (const listener of linkListeners)
47
+ listener(next);
48
+ if (next === "online") {
49
+ void flush();
50
+ for (const watch of watches.values())
51
+ void refresh(watch);
52
+ }
53
+ else {
54
+ for (const watch of watches.values()) {
55
+ if (watch.lastServer) {
56
+ watch.snapshot = { ...watch.lastServer, fresh: false, since: watch.lastServerAt };
57
+ notify(watch);
58
+ }
59
+ }
60
+ }
61
+ };
62
+ const offline = () => ({ code: "E_OFFLINE", family: "link", message: "the server is unreachable", retryable: true });
63
+ async function post(path, body) {
64
+ try {
65
+ const response = await doFetch(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
66
+ const json = (await response.json());
67
+ if (response.status === 503)
68
+ return { ok: false };
69
+ if (link !== "online" && viewer)
70
+ setLink("online");
71
+ return { ok: true, json };
72
+ }
73
+ catch {
74
+ if (viewer)
75
+ setLink("offline");
76
+ return { ok: false };
77
+ }
78
+ }
79
+ async function runQuery(op, args) {
80
+ if (!viewer)
81
+ return { read: { denied: { code: "E_AUTH", family: "auth", message: "sign in to read" } }, tables: [], seq };
82
+ const result = await post(`/q/${op.name}`, { args });
83
+ if (!result.ok)
84
+ return { read: { denied: offline() }, tables: [], seq };
85
+ const json = result.json;
86
+ if (json.denied)
87
+ return { read: { denied: json.denied }, tables: [], seq };
88
+ const at = Number(json.seq ?? 0);
89
+ if (at > seq)
90
+ seq = at;
91
+ const next = json.next ?? null;
92
+ return { read: { data: json.data, complete: json.complete !== false, fresh: true, next }, tables: json.tables ?? [], seq: at };
93
+ }
94
+ function notify(watch) {
95
+ for (const subscriber of watch.subscribers)
96
+ subscriber();
97
+ }
98
+ async function refresh(watch) {
99
+ if (watch.inFlight) {
100
+ watch.dirty = true;
101
+ return;
102
+ }
103
+ watch.inFlight = true;
104
+ try {
105
+ const { read, tables } = await runQuery(watch.op, watch.args);
106
+ if (closed)
107
+ return;
108
+ if ("data" in read) {
109
+ watch.lastServer = read;
110
+ watch.lastServerAt = Date.now();
111
+ watch.tables = new Set(tables);
112
+ watch.snapshot = read;
113
+ }
114
+ else if ("denied" in read && read.denied.code === "E_OFFLINE" && watch.lastServer) {
115
+ watch.snapshot = { ...watch.lastServer, fresh: false, since: watch.lastServerAt };
116
+ }
117
+ else {
118
+ watch.snapshot = read;
119
+ }
120
+ notify(watch);
121
+ }
122
+ finally {
123
+ watch.inFlight = false;
124
+ if (watch.dirty) {
125
+ watch.dirty = false;
126
+ void refresh(watch);
127
+ }
128
+ }
129
+ }
130
+ let polling = false;
131
+ async function poll() {
132
+ if (polling)
133
+ return;
134
+ polling = true;
135
+ while (!closed && watches.size > 0) {
136
+ if (link !== "online") {
137
+ await new Promise((r) => setTimeout(r, 1000));
138
+ if (viewer && link === "offline") {
139
+ const probe = await post("/q/__probe", {});
140
+ if (probe.ok)
141
+ setLink("online");
142
+ }
143
+ continue;
144
+ }
145
+ try {
146
+ const response = await doFetch(`${base}/changes?since=${seq}&wait=${options.waitSeconds ?? 25}`, { headers, signal: aborter.signal });
147
+ const json = (await response.json());
148
+ if (json.seq > seq) {
149
+ seq = json.seq;
150
+ const touched = new Set(json.tables);
151
+ for (const watch of watches.values()) {
152
+ if ([...watch.tables].some((t) => touched.has(t)))
153
+ void refresh(watch);
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ if (closed)
159
+ break;
160
+ if (viewer)
161
+ setLink("offline");
162
+ await new Promise((r) => setTimeout(r, 1000));
163
+ }
164
+ }
165
+ polling = false;
166
+ }
167
+ async function send(entry) {
168
+ const result = await post(`/m/${entry.op.name}`, { id: entry.id, args: entry.args });
169
+ if (!result.ok)
170
+ return false;
171
+ const json = result.json;
172
+ if (json.state === "sent") {
173
+ const at = Number(json.seq ?? 0);
174
+ if (at > seq)
175
+ seq = at;
176
+ entry.resolve({ state: "sent", id: entry.id, seq: at });
177
+ for (const watch of watches.values())
178
+ void refresh(watch);
179
+ }
180
+ else {
181
+ entry.resolve({ state: "failed", id: entry.id, why: json.why ?? offline() });
182
+ }
183
+ return true;
184
+ }
185
+ async function flush() {
186
+ while (pending.length > 0 && link === "online") {
187
+ const entry = pending[0];
188
+ const sent = await send(entry);
189
+ if (!sent)
190
+ return;
191
+ pending.shift();
192
+ }
193
+ }
194
+ const client = {
195
+ async query(op, args) {
196
+ return (await runQuery(op, args)).read;
197
+ },
198
+ observe(op, args) {
199
+ const key = `${op.name}:${JSON.stringify(args)}`;
200
+ let watch = watches.get(key);
201
+ if (!watch) {
202
+ watch = { op: op, args, key, tables: new Set(), snapshot: { loading: true }, lastServer: null, lastServerAt: 0, subscribers: new Set(), inFlight: false, dirty: false };
203
+ watches.set(key, watch);
204
+ void refresh(watch);
205
+ void poll();
206
+ }
207
+ const own = watch;
208
+ const mine = new Set();
209
+ return {
210
+ getSnapshot: () => own.snapshot,
211
+ subscribe(notifyMe) {
212
+ mine.add(notifyMe);
213
+ own.subscribers.add(notifyMe);
214
+ return () => {
215
+ mine.delete(notifyMe);
216
+ own.subscribers.delete(notifyMe);
217
+ };
218
+ },
219
+ close() {
220
+ for (const s of mine)
221
+ own.subscribers.delete(s);
222
+ if (own.subscribers.size === 0)
223
+ watches.delete(own.key);
224
+ },
225
+ };
226
+ },
227
+ async mutate(op, args) {
228
+ const id = mintId();
229
+ if (!viewer)
230
+ return { state: "failed", id, why: { code: "E_AUTH", family: "auth", message: "sign in to write" } };
231
+ let settle;
232
+ const settled = new Promise((resolve) => { settle = resolve; });
233
+ const entry = { id, op: op, args, resolve: settle };
234
+ if (link === "offline" || pending.length > 0) {
235
+ pending.push(entry);
236
+ void flush();
237
+ return { state: "pending", id };
238
+ }
239
+ pending.push(entry);
240
+ const sent = await send(entry);
241
+ if (sent) {
242
+ pending.shift();
243
+ return settled;
244
+ }
245
+ // Durable admission on this device: the write waits for the link.
246
+ return { state: "pending", id };
247
+ },
248
+ link: () => link,
249
+ onLink(listener) {
250
+ linkListeners.add(listener);
251
+ return () => linkListeners.delete(listener);
252
+ },
253
+ viewer: () => viewer,
254
+ async close() {
255
+ closed = true;
256
+ aborter.abort();
257
+ watches.clear();
258
+ linkListeners.clear();
259
+ },
260
+ };
261
+ return client;
262
+ }
@@ -0,0 +1,34 @@
1
+ import type { Client } from "./contract.ts";
2
+ import { Replica } from "./replica/replica.ts";
3
+ import { type Row, type SchemaJson, type Store } from "./replica/store.ts";
4
+ import { type Session, type SessionStore } from "./auth.ts";
5
+ export interface LocalOptions {
6
+ readonly url: string;
7
+ /** A development persona (loopback only): `alice` acts as `dev:alice`. */
8
+ readonly persona?: string;
9
+ /** A session from `guest()`, `login()` or `signup()`; the client keeps it
10
+ * and restores it on the next start, so an app passes it once. */
11
+ readonly session?: Session;
12
+ /** With no session kept for this server, mint a guest (`use identity guests`). */
13
+ readonly guest?: boolean;
14
+ /** Where the session is kept: `localStorage` by default, memory without one. */
15
+ readonly sessionStore?: SessionStore;
16
+ readonly fetch?: typeof fetch;
17
+ /** `"indexeddb"` on the web (the default when available), `"memory"` for
18
+ * tests, or an opener for another store (`openSqlite` from
19
+ * `snapback4/replica` on Expo and Node). */
20
+ readonly store?: "indexeddb" | "memory" | {
21
+ open(name: string, schema: SchemaJson): Promise<Store>;
22
+ };
23
+ readonly waitSeconds?: number;
24
+ }
25
+ export type LocalClient = Client & {
26
+ readonly replica: Replica;
27
+ sync(): Promise<void>;
28
+ /** The session this device holds, or null once the server has refused it. */
29
+ session(): Session | null;
30
+ /** End the session on the server and forget it here; the client closes. */
31
+ signOut(): Promise<void>;
32
+ };
33
+ export declare function createLocalClient(options: LocalOptions): Promise<LocalClient>;
34
+ export type { Row };
package/dist/local.js ADDED
@@ -0,0 +1,358 @@
1
+ // The local-first tier (LLP 2000.000 §7): a replica of the viewer's
2
+ // partitions, a durable outbox of client-minted writes, and the interpreter,
3
+ // on the same card as the online tier. Queries run locally and are
4
+ // `complete` inside the horizon; `fresh` says the replica has everything the
5
+ // server had at the last sync; a predictable mutation renders its rows as
6
+ // `pending` before the server confirms them.
7
+ import { mintId } from "./client.js";
8
+ import { Interpreter, Refused } from "./replica/interpreter.js";
9
+ import { Replica } from "./replica/replica.js";
10
+ import { MemoryStore, openIndexedDb } from "./replica/store.js";
11
+ import { defaultSessionStore, forgetSession, guest, keepSession, logout, restoreSession } from "./auth.js";
12
+ export async function createLocalClient(options) {
13
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
14
+ const base = options.url.replace(/\/$/, "");
15
+ const headers = { "content-type": "application/json" };
16
+ const sessions = options.sessionStore ?? defaultSessionStore;
17
+ let session = null;
18
+ if (options.persona)
19
+ headers["x-snapback-persona"] = options.persona;
20
+ else {
21
+ // The device's session: the one passed, else the one kept for this
22
+ // server, else a fresh guest when the app allows one.
23
+ if (options.session) {
24
+ session = options.session;
25
+ await keepSession(base, session, sessions);
26
+ }
27
+ else
28
+ session = await restoreSession(base, sessions);
29
+ if (!session && options.guest) {
30
+ const signedIn = await guest(base, doFetch);
31
+ if (!signedIn.ok)
32
+ throw new Error(`createLocalClient: ${signedIn.why.code}: ${signedIn.why.message}`);
33
+ session = signedIn.session;
34
+ await keepSession(base, session, sessions);
35
+ }
36
+ if (!session)
37
+ throw new Error(`createLocalClient: no session for ${base}; pass one from guest(), login() or signup(), or say guest: true`);
38
+ headers.authorization = `Bearer ${session.token}`;
39
+ }
40
+ const viewer = (options.persona ? `dev:${options.persona}` : session.principal);
41
+ const principal = viewer;
42
+ let sessionDead = false;
43
+ const refusedSession = async (denied) => {
44
+ if (session && !sessionDead && denied?.code === "E_AUTH") {
45
+ sessionDead = true;
46
+ await forgetSession(base, sessions);
47
+ }
48
+ };
49
+ const backend = await loadBackend(doFetch, base, headers);
50
+ const storeName = `snapback4:${base}:${viewer}`;
51
+ const useIdb = options.store === "indexeddb" || (options.store === undefined && typeof indexedDB !== "undefined");
52
+ const store = typeof options.store === "object" ? await options.store.open(storeName, backend.schema) : useIdb ? await openIndexedDb(storeName, backend.schema) : new MemoryStore(backend.schema);
53
+ let link = "online";
54
+ let closed = false;
55
+ let seq = 0;
56
+ const aborter = new AbortController();
57
+ const linkListeners = new Set();
58
+ const watches = new Map();
59
+ const offline = () => ({ code: "E_OFFLINE", family: "link", message: "the server is unreachable", retryable: true });
60
+ const setLink = (next) => {
61
+ if (link === next)
62
+ return;
63
+ link = next;
64
+ for (const listener of linkListeners)
65
+ listener(next);
66
+ if (next === "online")
67
+ void flush();
68
+ for (const watch of watches.values())
69
+ void rerun(watch);
70
+ };
71
+ async function post(path, body) {
72
+ try {
73
+ const response = await doFetch(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
74
+ if (response.status >= 500)
75
+ return { ok: false };
76
+ const json = (await response.json());
77
+ if (link !== "online")
78
+ setLink("online");
79
+ return { ok: true, json };
80
+ }
81
+ catch {
82
+ setLink("offline");
83
+ return { ok: false };
84
+ }
85
+ }
86
+ const replica = new Replica(store, backend.schema, {
87
+ async sync(from, limit) {
88
+ try {
89
+ const response = await doFetch(`${base}/sync?from=${from}&limit=${limit}`, { headers });
90
+ if (response.status >= 500)
91
+ return { denied: offline() };
92
+ const page = (await response.json());
93
+ if (page.denied) {
94
+ await refusedSession(page.denied);
95
+ return { denied: page.denied };
96
+ }
97
+ if (link !== "online")
98
+ setLink("online");
99
+ if (page.watermark > seq)
100
+ seq = page.watermark;
101
+ if (page.generation !== undefined && page.generation !== backend.generation) {
102
+ // Day two: the server adopted a new generation. Load it, then let
103
+ // the replica re-derive the partition on its next round.
104
+ const next = await loadBackend(doFetch, base, headers);
105
+ backend.generation = next.generation;
106
+ backend.schema = next.schema;
107
+ backend.programs = next.programs;
108
+ replica.adopt(next.schema, next.generation);
109
+ return { denied: { code: "E_GENERATION", family: "generation", message: "the backend changed; re-deriving the partition", retryable: true } };
110
+ }
111
+ return page;
112
+ }
113
+ catch {
114
+ setLink("offline");
115
+ return { denied: offline() };
116
+ }
117
+ },
118
+ }, backend.generation);
119
+ // Every applied batch re-runs the queries whose tables it touched.
120
+ replica.onChange((touched) => {
121
+ for (const watch of watches.values())
122
+ if ([...watch.tables].some((t) => touched.has(t)))
123
+ void rerun(watch);
124
+ });
125
+ async function waitForChange() {
126
+ if (link !== "online") {
127
+ await new Promise((r) => setTimeout(r, 1000));
128
+ const probe = await post("/q/__probe", {});
129
+ if (probe.ok)
130
+ setLink("online");
131
+ return;
132
+ }
133
+ try {
134
+ const response = await doFetch(`${base}/changes?since=${seq}&wait=${options.waitSeconds ?? 25}`, { headers, signal: aborter.signal });
135
+ const json = (await response.json());
136
+ if (json.seq > seq)
137
+ seq = json.seq;
138
+ }
139
+ catch {
140
+ if (closed)
141
+ return;
142
+ setLink("offline");
143
+ await new Promise((r) => setTimeout(r, 1000));
144
+ }
145
+ }
146
+ async function runLocal(op, args) {
147
+ const program = backend.programs[op.name];
148
+ if (!program)
149
+ return { read: { denied: { code: "E_OP", family: "op", message: `unknown query ${op.name}` } }, tables: new Set() };
150
+ try {
151
+ const outcome = await store.read((tx) => new Interpreter(tx, backend.schema, { viewer: principal, args: args, now: Date.now(), newIds: [], mint: mintId }, "query").run(program));
152
+ const read = { data: outcome.data, complete: outcome.complete, fresh: link === "online" && replica.isCaughtUp, next: outcome.next, ...(link === "online" && replica.isCaughtUp ? {} : { since: lastSyncAt }) };
153
+ return { read, tables: outcome.tables };
154
+ }
155
+ catch (error) {
156
+ if (error instanceof Refused)
157
+ return { read: { denied: error.refusal }, tables: new Set() };
158
+ throw error;
159
+ }
160
+ }
161
+ let lastSyncAt = Date.now();
162
+ function notify(watch) {
163
+ for (const subscriber of watch.subscribers)
164
+ subscriber();
165
+ }
166
+ async function rerun(watch) {
167
+ if (watch.running) {
168
+ watch.dirty = true;
169
+ return;
170
+ }
171
+ watch.running = true;
172
+ try {
173
+ const { read, tables } = await runLocal(watch.op, watch.args);
174
+ if (closed)
175
+ return;
176
+ watch.tables = tables;
177
+ watch.snapshot = read;
178
+ notify(watch);
179
+ }
180
+ finally {
181
+ watch.running = false;
182
+ if (watch.dirty) {
183
+ watch.dirty = false;
184
+ void rerun(watch);
185
+ }
186
+ }
187
+ }
188
+ // ----- the outbox -----
189
+ async function queued() {
190
+ return store.read(async (tx) => (await tx.side("outbox").all()).map((e) => e.value).sort((a, b) => (a.id < b.id ? -1 : 1)));
191
+ }
192
+ let flushing = false;
193
+ async function flush() {
194
+ if (flushing)
195
+ return;
196
+ flushing = true;
197
+ try {
198
+ for (const entry of await queued()) {
199
+ if (link !== "online")
200
+ return;
201
+ const result = await post(`/m/${entry.op}`, { id: entry.id, args: entry.args, newIds: entry.newIds });
202
+ if (!result.ok)
203
+ return;
204
+ const json = result.json;
205
+ await store.write(async (tx) => {
206
+ await tx.side("outbox").delete(entry.id);
207
+ if (json.state !== "sent") {
208
+ // The prediction is withdrawn: the rows it wrote go, the stream
209
+ // restores anything it replaced.
210
+ for (const key of entry.predicted) {
211
+ const [table, id] = key.split("/");
212
+ const row = await tx.get(table, id);
213
+ if (row && row.pending)
214
+ await tx.delete(table, id);
215
+ }
216
+ }
217
+ });
218
+ if (json.denied)
219
+ await refusedSession(json.denied);
220
+ settled.get(entry.id)?.(json.state === "sent" ? { state: "sent", id: entry.id, seq: Number(json.seq ?? 0) } : { state: "failed", id: entry.id, why: json.why ?? json.denied ?? offline() });
221
+ settled.delete(entry.id);
222
+ if (json.state === "sent" && Number(json.seq) > seq)
223
+ seq = Number(json.seq);
224
+ if (json.state === "sent")
225
+ await replica.syncOnce();
226
+ else
227
+ for (const watch of watches.values())
228
+ void rerun(watch);
229
+ }
230
+ }
231
+ finally {
232
+ flushing = false;
233
+ }
234
+ }
235
+ const settled = new Map();
236
+ const client = {
237
+ replica,
238
+ async sync() {
239
+ await replica.syncOnce();
240
+ lastSyncAt = Date.now();
241
+ },
242
+ async query(op, args) {
243
+ return (await runLocal(op, args)).read;
244
+ },
245
+ observe(op, args) {
246
+ const key = `${op.name}:${JSON.stringify(args)}`;
247
+ let watch = watches.get(key);
248
+ if (!watch) {
249
+ watch = { op: op, args, key, tables: new Set(), snapshot: { loading: true }, lastServerAt: 0, subscribers: new Set(), running: false, dirty: false };
250
+ watches.set(key, watch);
251
+ void rerun(watch);
252
+ }
253
+ const own = watch;
254
+ const mine = new Set();
255
+ return {
256
+ getSnapshot: () => own.snapshot,
257
+ subscribe(listener) {
258
+ mine.add(listener);
259
+ own.subscribers.add(listener);
260
+ return () => { mine.delete(listener); own.subscribers.delete(listener); };
261
+ },
262
+ close() {
263
+ for (const s of mine)
264
+ own.subscribers.delete(s);
265
+ if (own.subscribers.size === 0)
266
+ watches.delete(own.key);
267
+ },
268
+ };
269
+ },
270
+ async mutate(op, args) {
271
+ const id = mintId();
272
+ const program = backend.programs[op.name];
273
+ if (!program)
274
+ return { state: "failed", id, why: { code: "E_OP", family: "op", message: `unknown mutation ${op.name}` } };
275
+ const newIds = [];
276
+ const mint = () => { const minted = mintId(); newIds.push(minted); return minted; };
277
+ // Predict: run the mutation over the replica; its rows render as pending.
278
+ let predicted = [];
279
+ let refused = null;
280
+ try {
281
+ await store.write(async (tx) => {
282
+ const interpreter = new Interpreter(tx, backend.schema, { viewer: principal, args: args, now: Date.now(), newIds: [], mint }, "mutation");
283
+ await interpreter.run(program);
284
+ predicted = interpreter.writes.map((w) => `${w.table}/${w.row.id}`);
285
+ await tx.side("outbox").put(id, { id, op: op.name, args, newIds: [...newIds], predicted });
286
+ });
287
+ }
288
+ catch (error) {
289
+ if (!(error instanceof Refused))
290
+ throw error;
291
+ if (error.refusal.code === "E_PREDICT") {
292
+ // Not predictable here: queue it without local rows.
293
+ await store.write((tx) => tx.side("outbox").put(id, { id, op: op.name, args, newIds: [], predicted: [] }));
294
+ }
295
+ else
296
+ refused = error.refusal;
297
+ }
298
+ if (refused)
299
+ return { state: "failed", id, why: refused };
300
+ for (const watch of watches.values())
301
+ void rerun(watch);
302
+ const outcome = new Promise((resolve) => settled.set(id, resolve));
303
+ void flush();
304
+ if (link !== "online")
305
+ return { state: "pending", id };
306
+ const first = await Promise.race([outcome, new Promise((resolve) => setTimeout(() => resolve({ state: "pending", id }), 4_000))]);
307
+ return first;
308
+ },
309
+ link: () => link,
310
+ onLink(listener) {
311
+ linkListeners.add(listener);
312
+ return () => linkListeners.delete(listener);
313
+ },
314
+ viewer: () => viewer,
315
+ session: () => (sessionDead ? null : session),
316
+ async signOut() {
317
+ if (session && !sessionDead)
318
+ await logout(base, session.token, doFetch);
319
+ await forgetSession(base, sessions);
320
+ sessionDead = true;
321
+ await client.close();
322
+ },
323
+ async close() {
324
+ closed = true;
325
+ aborter.abort();
326
+ watches.clear();
327
+ linkListeners.clear();
328
+ await replica.close();
329
+ },
330
+ };
331
+ // First: catch up, then re-apply any predictions still in the outbox.
332
+ await client.sync();
333
+ await reapplyPredictions();
334
+ replica.follow(waitForChange);
335
+ void flush();
336
+ async function reapplyPredictions() {
337
+ for (const entry of await queued()) {
338
+ const program = backend.programs[entry.op];
339
+ if (!program || entry.predicted.length === 0)
340
+ continue;
341
+ try {
342
+ await store.write(async (tx) => {
343
+ const interpreter = new Interpreter(tx, backend.schema, { viewer: principal, args: entry.args, now: Date.now(), newIds: [...entry.newIds], mint: mintId }, "mutation");
344
+ await interpreter.run(program);
345
+ });
346
+ }
347
+ catch {
348
+ // The server will judge it; the rows stay absent until then.
349
+ }
350
+ }
351
+ }
352
+ return client;
353
+ }
354
+ async function loadBackend(doFetch, base, headers) {
355
+ const response = await doFetch(`${base}/schema`, { headers });
356
+ const json = (await response.json());
357
+ return { generation: json.generation, schema: json.schema, programs: Object.fromEntries(json.programs.map((p) => [p.name, p])) };
358
+ }
@@ -0,0 +1,15 @@
1
+ export type ColumnTypeJson = unknown;
2
+ export interface CursorPosition {
3
+ table: string;
4
+ index: string;
5
+ order: "Asc" | "Desc";
6
+ suffix: {
7
+ name: string;
8
+ kind: ColumnTypeJson;
9
+ }[];
10
+ values: unknown[];
11
+ }
12
+ /** Canonical JSON: compact, object keys sorted, as serde_json writes a BTreeMap. */
13
+ export declare function canonicalJson(value: unknown): string;
14
+ export declare function encodeCursor(position: CursorPosition): string;
15
+ export declare function decodeCursor(token: string): CursorPosition | null;