@sovaria/sdk 0.0.0-stage → 0.4.0

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,90 @@
1
+ /**
2
+ * Offline-first documents (plan C4): keep working with no connection, sync when it comes back.
3
+ *
4
+ * ```ts
5
+ * const sov = new SovariaSDK({ projectUrl, anonKey })
6
+ * const offline = sov.enableOffline({ storage: indexedDbStorage() }) // browsers; memoryStorage() by default
7
+ * await sov.collection('todos').add({ title: 'works on a plane' }) // queued while offline, shown at once
8
+ * offline.pendingWrites() // what still has to reach the server
9
+ * ```
10
+ *
11
+ * How it works: every document request goes through this transport. Reads that reach the server
12
+ * refresh a local cache; when the network is down, single reads and queries are answered from that
13
+ * cache (queries are evaluated locally with the server's operators, ordering and cursors) and live
14
+ * listeners keep firing from it. Writes made offline are applied to the cache straight away
15
+ * (listeners see them, flagged `pending`) and kept in an ordered queue; when the connection is back
16
+ * the queue is sent one commit at a time in the original order. A write the server refuses
17
+ * (a rule, a precondition) is dropped from the queue and reported to `onSyncError`, and the cache
18
+ * is refreshed from the server so it never keeps the refused change. Transactions need the server
19
+ * (their reads must be current), so they fail fast offline, as in Firebase.
20
+ *
21
+ * Storage is pluggable: `memoryStorage()` (default; tests, servers), `indexedDbStorage()` (browsers),
22
+ * and `keyValueStorage(AsyncStorage)` for React Native or any `getItem/setItem/removeItem/getAllKeys`
23
+ * store. Each project gets its own key prefix.
24
+ */
25
+ import type { DocumentsTransport } from './collections.js';
26
+ type Json = string | number | boolean | null | Json[] | {
27
+ [key: string]: Json;
28
+ };
29
+ type JsonObject = {
30
+ [key: string]: Json;
31
+ };
32
+ export interface OfflineStorage {
33
+ get(key: string): Promise<string | null>;
34
+ set(key: string, value: string): Promise<void>;
35
+ delete(key: string): Promise<void>;
36
+ /** Keys starting with `prefix`. */
37
+ keys(prefix: string): Promise<string[]>;
38
+ }
39
+ export declare function memoryStorage(): OfflineStorage;
40
+ /** React Native AsyncStorage (or any store with getItem/setItem/removeItem/getAllKeys). */
41
+ export declare function keyValueStorage(store: {
42
+ getItem(k: string): Promise<string | null>;
43
+ setItem(k: string, v: string): Promise<void>;
44
+ removeItem(k: string): Promise<void>;
45
+ getAllKeys(): Promise<readonly string[]>;
46
+ }): OfflineStorage;
47
+ /** IndexedDB (browsers): one object store, values as JSON strings. */
48
+ export declare function indexedDbStorage(dbName?: string): OfflineStorage;
49
+ type Doc = {
50
+ id: string;
51
+ path: string;
52
+ data: JsonObject | null;
53
+ version: number;
54
+ exists: boolean;
55
+ update_time?: string;
56
+ pending?: boolean;
57
+ };
58
+ export declare function compareValues(a: unknown, b: unknown): number;
59
+ /** The query's documents from `docs` (one collection's cached documents). */
60
+ export declare function evaluateQuery(docs: Doc[], body: JsonObject): {
61
+ documents: Doc[];
62
+ has_more: boolean;
63
+ };
64
+ /** The document after `write`, or null when it is deleted (throws for an update of a missing one). */
65
+ export declare function applyWrite(current: Doc | null, write: JsonObject): JsonObject | null;
66
+ export interface OfflineOptions {
67
+ storage?: OfflineStorage;
68
+ /** Key prefix (one per project; default from the project id). */
69
+ namespace?: string;
70
+ /** A queued write the server refused when it came back (a rule, a precondition). */
71
+ onSyncError?: (error: unknown, writes: JsonObject[]) => void;
72
+ /** Called after the queue was sent (online again). */
73
+ onSynced?: (count: number) => void;
74
+ }
75
+ export interface OfflineController {
76
+ /** False after a request failed for lack of network, true after one reached the server. */
77
+ readonly online: boolean;
78
+ /** Writes made offline that haven't reached the server yet, oldest first. */
79
+ pendingWrites(): Promise<JsonObject[][]>;
80
+ /** Send queued writes now (also runs on reconnect and every 30 s while some are pending). */
81
+ flush(): Promise<number>;
82
+ /** Forget the cache and the queue (e.g. on sign-out). */
83
+ clear(): Promise<void>;
84
+ /** Stop the background sync timer and listeners. */
85
+ close(): void;
86
+ }
87
+ export declare function createOfflineTransport(inner: DocumentsTransport, options?: OfflineOptions): DocumentsTransport & {
88
+ controller: OfflineController;
89
+ };
90
+ export {};
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Offline-first documents (plan C4): keep working with no connection, sync when it comes back.
3
+ *
4
+ * ```ts
5
+ * const sov = new SovariaSDK({ projectUrl, anonKey })
6
+ * const offline = sov.enableOffline({ storage: indexedDbStorage() }) // browsers; memoryStorage() by default
7
+ * await sov.collection('todos').add({ title: 'works on a plane' }) // queued while offline, shown at once
8
+ * offline.pendingWrites() // what still has to reach the server
9
+ * ```
10
+ *
11
+ * How it works: every document request goes through this transport. Reads that reach the server
12
+ * refresh a local cache; when the network is down, single reads and queries are answered from that
13
+ * cache (queries are evaluated locally with the server's operators, ordering and cursors) and live
14
+ * listeners keep firing from it. Writes made offline are applied to the cache straight away
15
+ * (listeners see them, flagged `pending`) and kept in an ordered queue; when the connection is back
16
+ * the queue is sent one commit at a time in the original order. A write the server refuses
17
+ * (a rule, a precondition) is dropped from the queue and reported to `onSyncError`, and the cache
18
+ * is refreshed from the server so it never keeps the refused change. Transactions need the server
19
+ * (their reads must be current), so they fail fast offline, as in Firebase.
20
+ *
21
+ * Storage is pluggable: `memoryStorage()` (default; tests, servers), `indexedDbStorage()` (browsers),
22
+ * and `keyValueStorage(AsyncStorage)` for React Native or any `getItem/setItem/removeItem/getAllKeys`
23
+ * store. Each project gets its own key prefix.
24
+ */
25
+ import { SovariaError } from './errors.js';
26
+ const BASE = '/api/v1/data/doc';
27
+ export function memoryStorage() {
28
+ const data = new Map();
29
+ return {
30
+ get: async (k) => data.get(k) ?? null,
31
+ set: async (k, v) => void data.set(k, v),
32
+ delete: async (k) => void data.delete(k),
33
+ keys: async (p) => [...data.keys()].filter((k) => k.startsWith(p)),
34
+ };
35
+ }
36
+ /** React Native AsyncStorage (or any store with getItem/setItem/removeItem/getAllKeys). */
37
+ export function keyValueStorage(store) {
38
+ return {
39
+ get: (k) => store.getItem(k),
40
+ set: (k, v) => store.setItem(k, v),
41
+ delete: (k) => store.removeItem(k),
42
+ keys: async (p) => (await store.getAllKeys()).filter((k) => k.startsWith(p)),
43
+ };
44
+ }
45
+ /** IndexedDB (browsers): one object store, values as JSON strings. */
46
+ export function indexedDbStorage(dbName = 'sovaria-offline') {
47
+ const idb = globalThis.indexedDB;
48
+ if (!idb)
49
+ throw SovariaError.config('indexedDbStorage needs a browser with IndexedDB; use memoryStorage() or keyValueStorage()');
50
+ const open = new Promise((resolve, reject) => {
51
+ const req = idb.open(dbName, 1);
52
+ req.onupgradeneeded = () => req.result.createObjectStore('kv');
53
+ req.onsuccess = () => resolve(req.result);
54
+ req.onerror = () => reject(req.error);
55
+ });
56
+ const run = (mode, fn) => open.then((db) => new Promise((resolve, reject) => {
57
+ const req = fn(db.transaction('kv', mode).objectStore('kv'));
58
+ req.onsuccess = () => resolve(req.result);
59
+ req.onerror = () => reject(req.error);
60
+ }));
61
+ return {
62
+ get: async (k) => (await run('readonly', (s) => s.get(k))) ?? null,
63
+ set: async (k, v) => void (await run('readwrite', (s) => s.put(v, k))),
64
+ delete: async (k) => void (await run('readwrite', (s) => s.delete(k))),
65
+ keys: async (p) => (await run('readonly', (s) => s.getAllKeys(IDBKeyRange.bound(p, p + '￿')))),
66
+ };
67
+ }
68
+ function fieldValue(doc, field) {
69
+ if (field === '__key__' || field === '__name__')
70
+ return doc.id;
71
+ let value = doc.data;
72
+ for (const part of field.split('.')) {
73
+ if (value === null || typeof value !== 'object')
74
+ return undefined;
75
+ value = value[part.replace(/^`|`$/g, '')];
76
+ }
77
+ return value;
78
+ }
79
+ function typeRank(v) {
80
+ if (v === null || v === undefined)
81
+ return 0;
82
+ if (typeof v === 'boolean')
83
+ return 1;
84
+ if (typeof v === 'number')
85
+ return 2;
86
+ if (typeof v === 'string')
87
+ return 3;
88
+ if (Array.isArray(v))
89
+ return 4;
90
+ return 5;
91
+ }
92
+ export function compareValues(a, b) {
93
+ const ra = typeRank(a);
94
+ const rb = typeRank(b);
95
+ if (ra !== rb)
96
+ return ra - rb;
97
+ if (ra === 0)
98
+ return 0;
99
+ if (ra === 4) {
100
+ const aa = a;
101
+ const bb = b;
102
+ for (let i = 0; i < Math.min(aa.length, bb.length); i++) {
103
+ const c = compareValues(aa[i], bb[i]);
104
+ if (c)
105
+ return c;
106
+ }
107
+ return aa.length - bb.length;
108
+ }
109
+ if (ra === 5)
110
+ return JSON.stringify(a) < JSON.stringify(b) ? -1 : JSON.stringify(a) > JSON.stringify(b) ? 1 : 0;
111
+ return a < b ? -1 : a > b ? 1 : 0;
112
+ }
113
+ const equal = (a, b) => typeRank(a) === typeRank(b) && compareValues(a, b) === 0;
114
+ function matches(doc, [field, op, value]) {
115
+ const v = fieldValue(doc, field);
116
+ switch (op) {
117
+ case '==': return v !== undefined && equal(v, value);
118
+ case '!=': return v !== undefined && v !== null && !equal(v, value);
119
+ case '<': return v !== undefined && typeRank(v) === typeRank(value) && compareValues(v, value) < 0;
120
+ case '<=': return v !== undefined && typeRank(v) === typeRank(value) && compareValues(v, value) <= 0;
121
+ case '>': return v !== undefined && typeRank(v) === typeRank(value) && compareValues(v, value) > 0;
122
+ case '>=': return v !== undefined && typeRank(v) === typeRank(value) && compareValues(v, value) >= 0;
123
+ case 'in': return Array.isArray(value) && value.some((x) => equal(v, x));
124
+ case 'not-in': return v !== undefined && v !== null && Array.isArray(value) && !value.some((x) => equal(v, x));
125
+ case 'array-contains': return Array.isArray(v) && v.some((x) => equal(x, value));
126
+ case 'array-contains-any': return Array.isArray(v) && Array.isArray(value) && v.some((x) => value.some((y) => equal(x, y)));
127
+ default: return false;
128
+ }
129
+ }
130
+ /** The query's documents from `docs` (one collection's cached documents). */
131
+ export function evaluateQuery(docs, body) {
132
+ const where = body.where ?? [];
133
+ const order = (body.order_by ?? []).slice();
134
+ // Implicit ordering, like the server: inequality fields first, then the document id.
135
+ for (const [field, op] of where) {
136
+ if (['<', '<=', '>', '>=', '!=', 'not-in'].includes(op) && !order.some(([f]) => f === field))
137
+ order.push([field, 'asc']);
138
+ }
139
+ if (!order.some(([f]) => f === '__key__'))
140
+ order.push(['__key__', order.length ? order[order.length - 1][1] : 'asc']);
141
+ let out = docs.filter((d) => d.exists && where.every((w) => matches(d, w)));
142
+ // Ordering by a field leaves out documents that don't have it (server semantics).
143
+ out = out.filter((d) => order.every(([f]) => f === '__key__' || fieldValue(d, f) !== undefined));
144
+ const cmp = (a, b) => {
145
+ for (const [f, dir] of order) {
146
+ const c = compareValues(fieldValue(a, f), fieldValue(b, f));
147
+ if (c)
148
+ return dir === 'desc' ? -c : c;
149
+ }
150
+ return 0;
151
+ };
152
+ out.sort(cmp);
153
+ const cursorCmp = (d, cursor) => {
154
+ const anchor = cursor.key !== undefined ? out.find((x) => x.id === cursor.key) ?? docs.find((x) => x.id === cursor.key) : undefined;
155
+ for (let i = 0; i < order.length; i++) {
156
+ const [f, dir] = order[i];
157
+ const target = anchor ? fieldValue(anchor, f) : cursor.values?.[i];
158
+ if (target === undefined)
159
+ return 0;
160
+ const c = compareValues(fieldValue(d, f), target);
161
+ if (c)
162
+ return dir === 'desc' ? -c : c;
163
+ }
164
+ return 0;
165
+ };
166
+ const sa = body.start_at;
167
+ const sf = body.start_after;
168
+ const ea = body.end_at;
169
+ const eb = body.end_before;
170
+ if (sa)
171
+ out = out.filter((d) => cursorCmp(d, sa) >= 0);
172
+ if (sf)
173
+ out = out.filter((d) => cursorCmp(d, sf) > 0);
174
+ if (ea)
175
+ out = out.filter((d) => cursorCmp(d, ea) <= 0);
176
+ if (eb)
177
+ out = out.filter((d) => cursorCmp(d, eb) < 0);
178
+ const limit = body.limit;
179
+ const last = body.limit_to_last;
180
+ if (limit !== undefined)
181
+ return { documents: out.slice(0, limit), has_more: out.length > limit };
182
+ if (last !== undefined)
183
+ return { documents: out.slice(Math.max(0, out.length - last)), has_more: out.length > last };
184
+ return { documents: out, has_more: false };
185
+ }
186
+ // ── applying writes locally ───────────────────────────────────────────────────
187
+ function splitPath(field) {
188
+ const parts = [];
189
+ const re = /`((?:[^`\\]|\\.)*)`|([^.]+)/g;
190
+ let m;
191
+ while ((m = re.exec(field)))
192
+ parts.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2]);
193
+ return parts;
194
+ }
195
+ function setPath(obj, parts, value, remove = false) {
196
+ let node = obj;
197
+ for (const part of parts.slice(0, -1)) {
198
+ if (!node[part] || typeof node[part] !== 'object' || Array.isArray(node[part]))
199
+ node[part] = {};
200
+ node = node[part];
201
+ }
202
+ const last = parts[parts.length - 1];
203
+ if (remove)
204
+ delete node[last];
205
+ else
206
+ node[last] = value;
207
+ }
208
+ function getPath(obj, parts) {
209
+ let node = obj;
210
+ for (const part of parts) {
211
+ if (!node || typeof node !== 'object')
212
+ return undefined;
213
+ node = node[part];
214
+ }
215
+ return node;
216
+ }
217
+ function deepMerge(base, patch) {
218
+ const out = { ...base };
219
+ for (const [k, v] of Object.entries(patch)) {
220
+ out[k] = v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])
221
+ ? deepMerge(out[k], v) : v;
222
+ }
223
+ return out;
224
+ }
225
+ /** The document after `write`, or null when it is deleted (throws for an update of a missing one). */
226
+ export function applyWrite(current, write) {
227
+ const op = String(write.op);
228
+ if (op === 'delete')
229
+ return null;
230
+ const before = current?.exists ? JSON.parse(JSON.stringify(current.data ?? {})) : null;
231
+ const data = write.data ?? {};
232
+ let next;
233
+ if (op === 'update') {
234
+ if (!before)
235
+ throw new SovariaError(404, { code: 'NOT_FOUND', message: `No document to update at ${write.path}` });
236
+ next = before;
237
+ for (const [field, value] of Object.entries(data))
238
+ setPath(next, splitPath(field), value);
239
+ }
240
+ else if (op === 'create' && before) {
241
+ throw new SovariaError(409, { code: 'ALREADY_EXISTS', message: `${write.path} already exists` });
242
+ }
243
+ else if (write.merge || write.merge_fields?.length) {
244
+ next = before ?? {};
245
+ const fields = write.merge_fields;
246
+ if (fields?.length)
247
+ for (const f of fields)
248
+ setPath(next, splitPath(f), getPath(data, splitPath(f)));
249
+ else
250
+ next = deepMerge(next, data);
251
+ }
252
+ else {
253
+ next = JSON.parse(JSON.stringify(data));
254
+ }
255
+ for (const t of write.transforms ?? []) {
256
+ const parts = splitPath(String(t.field));
257
+ const cur = getPath(next, parts);
258
+ const v = t.value;
259
+ switch (t.op) {
260
+ case 'increment':
261
+ setPath(next, parts, (typeof cur === 'number' ? cur : 0) + Number(v));
262
+ break;
263
+ case 'maximum':
264
+ setPath(next, parts, typeof cur === 'number' ? Math.max(cur, Number(v)) : v);
265
+ break;
266
+ case 'minimum':
267
+ setPath(next, parts, typeof cur === 'number' ? Math.min(cur, Number(v)) : v);
268
+ break;
269
+ case 'array_union': {
270
+ const arr = Array.isArray(cur) ? [...cur] : [];
271
+ for (const x of v ?? [])
272
+ if (!arr.some((y) => equal(x, y)))
273
+ arr.push(x);
274
+ setPath(next, parts, arr);
275
+ break;
276
+ }
277
+ case 'array_remove':
278
+ setPath(next, parts, Array.isArray(cur) ? cur.filter((y) => !v.some((x) => equal(x, y))) : []);
279
+ break;
280
+ case 'server_timestamp':
281
+ setPath(next, parts, new Date().toISOString());
282
+ break;
283
+ case 'delete':
284
+ setPath(next, parts, undefined, true);
285
+ break;
286
+ }
287
+ }
288
+ return next;
289
+ }
290
+ const isNetwork = (e) => e instanceof SovariaError && e.code === 'NETWORK_ERROR';
291
+ export function createOfflineTransport(inner, options = {}) {
292
+ const store = options.storage ?? memoryStorage();
293
+ const ns = `sov:${options.namespace ?? 'default'}:`;
294
+ const docKey = (path) => `${ns}doc:${path}`;
295
+ const queuePrefix = `${ns}q:`;
296
+ let online = true;
297
+ let seq = Date.now() * 1000;
298
+ let flushing = null;
299
+ const channels = new Map();
300
+ let localListeners = 0;
301
+ const readDoc = async (path) => {
302
+ const raw = await store.get(docKey(path));
303
+ return raw ? JSON.parse(raw) : null;
304
+ };
305
+ const writeDoc = (d) => store.set(docKey(d.path), JSON.stringify(d));
306
+ const cacheServerDoc = async (raw) => {
307
+ const path = String(raw.path ?? '');
308
+ if (!path)
309
+ return;
310
+ const existing = await readDoc(path);
311
+ if (existing?.pending)
312
+ return; // a local write is waiting; keep it visible until it syncs
313
+ const exists = raw.exists !== false && raw.data !== null && raw.data !== undefined;
314
+ await writeDoc({ id: String(raw.id ?? path.split('/').pop()), path, data: exists ? raw.data : null,
315
+ version: Number(raw.version ?? 0), exists, update_time: raw.update_time });
316
+ };
317
+ const collectionDocs = async (collection) => {
318
+ const depth = collection.split('/').length + 1;
319
+ const keys = await store.keys(docKey(collection + '/'));
320
+ const docs = await Promise.all(keys.map((k) => store.get(k)));
321
+ return docs.filter((r) => !!r).map((r) => JSON.parse(r)).filter((d) => d.path.split('/').length === depth);
322
+ };
323
+ const notifyListeners = () => {
324
+ for (const { listenerId, handlers } of channels.values())
325
+ handlers.onMessage?.({ type: 'local_resync', listener_id: listenerId });
326
+ };
327
+ const pathFrom = (path, verb) => decodeURIComponent(path.slice(`${BASE}/${verb}/`.length)).split('/').map(decodeURIComponent).join('/');
328
+ const applyLocally = async (writes) => {
329
+ // All or nothing, like a commit: compute every result first, then store.
330
+ const results = [];
331
+ const seen = new Map();
332
+ for (const w of writes) {
333
+ const path = String(w.path);
334
+ const current = seen.has(path) ? seen.get(path) : await readDoc(path);
335
+ const data = applyWrite(current, w);
336
+ const doc = { id: path.split('/').pop(), path, data, exists: data !== null, version: (current?.version ?? 0) + 1,
337
+ update_time: new Date().toISOString(), pending: true };
338
+ seen.set(path, doc);
339
+ results.push(doc);
340
+ }
341
+ for (const d of results)
342
+ await writeDoc(d);
343
+ return results;
344
+ };
345
+ const flush = async () => {
346
+ if (flushing)
347
+ return flushing;
348
+ flushing = (async () => {
349
+ let sent = 0;
350
+ const keys = (await store.keys(queuePrefix)).sort();
351
+ for (const key of keys) {
352
+ const raw = await store.get(key);
353
+ if (!raw)
354
+ continue;
355
+ const writes = JSON.parse(raw);
356
+ try {
357
+ const out = await inner.request('POST', `${BASE}/_commit`, { body: { writes, reads: [] } });
358
+ online = true;
359
+ await store.delete(key);
360
+ sent++;
361
+ const results = out.results ?? [];
362
+ for (let i = 0; i < writes.length; i++) {
363
+ const d = await readDoc(String(writes[i].path));
364
+ if (d)
365
+ await writeDoc({ ...d, pending: false, version: Number(results[i]?.version ?? d.version) });
366
+ }
367
+ }
368
+ catch (error) {
369
+ if (isNetwork(error)) {
370
+ online = false;
371
+ break;
372
+ }
373
+ // Refused by the server: drop it, tell the app, and put back the server's view.
374
+ await store.delete(key);
375
+ options.onSyncError?.(error, writes);
376
+ for (const w of writes) {
377
+ const path = String(w.path);
378
+ const d = await readDoc(path);
379
+ if (d)
380
+ await writeDoc({ ...d, pending: false });
381
+ try {
382
+ await cacheServerDoc({ ...(await inner.request('GET', `${BASE}/${path.split('/').map(encodeURIComponent).join('/')}`)), path });
383
+ }
384
+ catch (e) {
385
+ if (e instanceof SovariaError && e.statusCode === 404)
386
+ await writeDoc({ id: path.split('/').pop(), path, data: null, exists: false, version: 0 });
387
+ }
388
+ }
389
+ }
390
+ }
391
+ if (sent) {
392
+ options.onSynced?.(sent);
393
+ notifyListeners();
394
+ }
395
+ return sent;
396
+ })().finally(() => { flushing = null; });
397
+ return flushing;
398
+ };
399
+ const timer = setInterval(() => {
400
+ void store.keys(queuePrefix).then((k) => { if (k.length)
401
+ void flush(); });
402
+ }, 30_000);
403
+ const onOnline = () => void flush();
404
+ const g = globalThis;
405
+ g.addEventListener?.('online', onOnline);
406
+ const request = async (method, path, opts) => {
407
+ const body = (opts?.body ?? {});
408
+ const isDocRead = method === 'GET' && path.startsWith(`${BASE}/`) && !path.startsWith(`${BASE}/_`);
409
+ const isQuery = method === 'POST' && path.startsWith(`${BASE}/_query/`);
410
+ const isCount = method === 'POST' && path.startsWith(`${BASE}/_count/`);
411
+ const isCommit = method === 'POST' && path === `${BASE}/_commit`;
412
+ const isListen = method === 'POST' && path.startsWith(`${BASE}/_listen/`);
413
+ const isLocalListener = path.startsWith(`${BASE}/_listeners/local-`);
414
+ if (isLocalListener)
415
+ return {};
416
+ if (isCommit) {
417
+ const writes = body.writes ?? [];
418
+ if ((body.reads ?? []).length)
419
+ return inner.request(method, path, opts); // transactions need the server
420
+ if ((await store.keys(queuePrefix)).length)
421
+ await flush(); // keep order: older offline writes first
422
+ if (online || !(await store.keys(queuePrefix)).length) {
423
+ try {
424
+ const out = await inner.request(method, path, opts);
425
+ online = true;
426
+ const results = out.results ?? [];
427
+ const applied = await applyLocally(writes).catch(() => []);
428
+ for (let i = 0; i < applied.length; i++)
429
+ await writeDoc({ ...applied[i], pending: false, version: Number(results[i]?.version ?? applied[i].version) });
430
+ return out;
431
+ }
432
+ catch (error) {
433
+ if (!isNetwork(error))
434
+ throw error;
435
+ online = false;
436
+ }
437
+ }
438
+ const applied = await applyLocally(writes);
439
+ await store.set(`${queuePrefix}${String(++seq).padStart(20, '0')}`, JSON.stringify(writes));
440
+ notifyListeners();
441
+ return { results: applied.map((d) => ({ path: d.path, version: d.version, pending: true })), pending: true };
442
+ }
443
+ try {
444
+ const out = await inner.request(method, path, opts);
445
+ online = true;
446
+ if (isDocRead)
447
+ await cacheServerDoc({ ...out, path: decodeURIComponent(path.slice(BASE.length + 1)).split('/').map(decodeURIComponent).join('/') });
448
+ if (isQuery || isListen)
449
+ for (const d of out.documents ?? [])
450
+ await cacheServerDoc(d);
451
+ if (online && (await store.keys(queuePrefix)).length)
452
+ void flush();
453
+ return out;
454
+ }
455
+ catch (error) {
456
+ if (!isNetwork(error)) {
457
+ if (isDocRead && error instanceof SovariaError && error.statusCode === 404) {
458
+ const p = path.slice(BASE.length + 1).split('/').map(decodeURIComponent).join('/');
459
+ const cached = await readDoc(p);
460
+ if (!cached?.pending)
461
+ await writeDoc({ id: p.split('/').pop(), path: p, data: null, exists: false, version: 0 });
462
+ }
463
+ throw error;
464
+ }
465
+ online = false;
466
+ if (isDocRead) {
467
+ const p = path.slice(BASE.length + 1).split('/').map(decodeURIComponent).join('/');
468
+ const cached = await readDoc(p);
469
+ if (!cached)
470
+ throw error;
471
+ if (!cached.exists)
472
+ throw new SovariaError(404, { code: 'NOT_FOUND', message: 'Not found (cached)' });
473
+ return { ...cached, from_cache: true };
474
+ }
475
+ if (isQuery || isCount || isListen) {
476
+ const collection = pathFrom(path, isQuery ? '_query' : isCount ? '_count' : '_listen');
477
+ const result = evaluateQuery(await collectionDocs(collection), body);
478
+ if (isCount)
479
+ return { count: result.documents.length, from_cache: true };
480
+ if (isListen) {
481
+ const id = `local-${++localListeners}`;
482
+ return { id, channel: `local:${id}`, ttl_seconds: 600, documents: result.documents, from_cache: true };
483
+ }
484
+ return { documents: result.documents, has_more: result.has_more, from_cache: true };
485
+ }
486
+ throw error;
487
+ }
488
+ };
489
+ const transport = {
490
+ request,
491
+ openChannel(name, handlers) {
492
+ const listenerId = name.startsWith('local:') ? name.slice('local:'.length) : '';
493
+ const key = `${name}#${Math.random()}`;
494
+ // Local writes and syncs ask every listener to re-read (from the cache while offline).
495
+ const entry = { listenerId, handlers };
496
+ let inner_;
497
+ if (!listenerId) {
498
+ inner_ = inner.openChannel(name, {
499
+ ...handlers,
500
+ onMessage: (payload) => {
501
+ if (payload && payload.listener_id)
502
+ entry.listenerId = String(payload.listener_id);
503
+ handlers.onMessage?.(payload);
504
+ },
505
+ });
506
+ }
507
+ channels.set(key, entry);
508
+ return { close: () => { channels.delete(key); inner_?.close(); } };
509
+ },
510
+ controller: {
511
+ get online() { return online; },
512
+ pendingWrites: async () => {
513
+ const keys = (await store.keys(queuePrefix)).sort();
514
+ return Promise.all(keys.map(async (k) => JSON.parse((await store.get(k)) ?? '[]')));
515
+ },
516
+ flush,
517
+ clear: async () => {
518
+ for (const k of await store.keys(ns))
519
+ await store.delete(k);
520
+ },
521
+ close: () => {
522
+ clearInterval(timer);
523
+ g.removeEventListener?.('online', onOnline);
524
+ },
525
+ },
526
+ };
527
+ return transport;
528
+ }