@estra/client 0.0.0-stage → 0.1.1

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
+ import { applyWrite, getPath, inCollection, runLocal } from "./local.js";
2
+ import { memoryStore } from "./store.js";
3
+ import { EstraClientError } from "./transport.js";
4
+ const isOffline = (e) => (e instanceof EstraClientError && e.code === "unavailable") || e instanceof TypeError;
5
+ const unavailable = (m) => new EstraClientError("unavailable", m);
6
+ const pad = (n) => String(n).padStart(12, "0");
7
+ export function offline(inner, o = {}) {
8
+ const store = o.store ?? memoryStore();
9
+ const docs = new Map();
10
+ let pending = [];
11
+ const waiters = new Map();
12
+ const settled = new Map();
13
+ const listeners = new Set();
14
+ let nextSeq = 1;
15
+ let network = true;
16
+ let flushing = null;
17
+ let retryTimer = null;
18
+ let backoff = o.retryMs ?? 1000;
19
+ let ready = null;
20
+ const init = () => (ready ??= (async () => {
21
+ for (const [, d] of await store.list("d:"))
22
+ docs.set(d.path, d);
23
+ pending = (await store.list("p:")).map(([, e]) => e).sort((a, b) => a.seq - b.seq);
24
+ nextSeq = Math.max((await store.get("meta:seq")) ?? 1, (pending.at(-1)?.seq ?? 0) + 1);
25
+ if (pending.length)
26
+ void flush();
27
+ })());
28
+ const remember = async (d) => {
29
+ const { meta: _m, distanceMi: _d, ...clean } = d;
30
+ docs.set(d.path, clean);
31
+ await store.set(`d:${d.path}`, clean);
32
+ };
33
+ const forget = async (path) => {
34
+ docs.delete(path);
35
+ await store.delete(`d:${path}`);
36
+ };
37
+ /** A doc with pending writes applied (v < 0 marks a local version), or null. */
38
+ function effective(path) {
39
+ const base = docs.get(path) ?? null;
40
+ let cur = base ? { ...base, data: { ...base.data } } : null;
41
+ let local = false;
42
+ for (const e of pending) {
43
+ for (const w of e.writes) {
44
+ if (w.path !== path)
45
+ continue;
46
+ local = true;
47
+ const data = applyWrite(cur?.data ?? null, w, e.at);
48
+ const id = path.split("/").pop();
49
+ cur = data ? { path, id, v: -e.seq, createTime: cur?.createTime ?? e.at, updateTime: e.at, data } : null;
50
+ }
51
+ }
52
+ return cur && local ? { ...cur, meta: { fromCache: true, hasPendingWrites: true } } : cur;
53
+ }
54
+ const pendingPaths = () => new Set(pending.flatMap((e) => e.writes.map((w) => w.path)));
55
+ const candidates = (q, base) => {
56
+ const paths = new Set(base ?? docs.keys());
57
+ for (const p of pendingPaths())
58
+ paths.add(p);
59
+ const out = [];
60
+ for (const p of paths) {
61
+ const d = effective(p);
62
+ if (d && inCollection(d, q))
63
+ out.push(d);
64
+ }
65
+ return out;
66
+ };
67
+ function emit(l) {
68
+ let snap;
69
+ if ("doc" in l.target) {
70
+ const d = effective(l.target.doc);
71
+ if (!d && !l.fromServer && !docs.has(l.target.doc) && network)
72
+ return; // nothing to show yet
73
+ snap = { reset: true, docs: d ? [d] : [], ...(d ? {} : { removed: [l.target.doc] }) };
74
+ }
75
+ else {
76
+ const q = l.target.query;
77
+ if (!l.fromServer && network && !candidates(q).length)
78
+ return;
79
+ const res = runLocal(candidates(q, l.server ? l.server.keys() : undefined), q);
80
+ snap = { reset: true, order: res.map((d) => d.path), docs: res };
81
+ }
82
+ const hasPendingWrites = snap.docs.some((d) => d.v < 0);
83
+ snap.meta = { fromCache: !l.fromServer, hasPendingWrites };
84
+ const sig = JSON.stringify([snap.docs.map((d) => [d.path, d.v]), snap.meta]);
85
+ if (sig === l.last)
86
+ return;
87
+ l.last = sig;
88
+ l.h.next(snap);
89
+ }
90
+ const touch = (paths) => {
91
+ for (const l of listeners) {
92
+ const hit = "doc" in l.target ? paths.includes(l.target.doc) : paths.some((p) => inCollection({ path: p }, l.target.query));
93
+ if (hit)
94
+ emit(l);
95
+ }
96
+ };
97
+ async function flush() {
98
+ // A flush already in flight may have checked the queue before this call's writes arrived (or
99
+ // before the network came back): wait for it, then go again if there's still work.
100
+ if (flushing) {
101
+ await flushing;
102
+ return pending.length && network && !retryTimer ? flush() : undefined;
103
+ }
104
+ flushing = (async () => {
105
+ while (pending.length && network) {
106
+ const e = pending[0];
107
+ const paths = e.writes.map((w) => w.path);
108
+ try {
109
+ const r = await inner.commit({ writes: e.writes, opKey: e.opKey });
110
+ // Refresh from the server: its values (timestamps, transforms) are the truth.
111
+ const fresh = await inner.getAll(paths).catch(() => paths.map(() => null));
112
+ pending.shift();
113
+ await store.delete(`p:${pad(e.seq)}`);
114
+ for (let i = 0; i < paths.length; i++)
115
+ await (fresh[i] ? remember(fresh[i]) : forget(paths[i]));
116
+ backoff = o.retryMs ?? 1000;
117
+ waiters.get(e.seq)?.resolve(r);
118
+ waiters.delete(e.seq);
119
+ touch(paths);
120
+ }
121
+ catch (err) {
122
+ if (isOffline(err)) {
123
+ retryTimer ??= setTimeout(() => {
124
+ retryTimer = null;
125
+ void flush();
126
+ }, backoff);
127
+ backoff = Math.min(backoff * 2, 60_000);
128
+ break;
129
+ }
130
+ // Refused by the server (rules, schema, preconditions): drop it, roll back locally.
131
+ pending.shift();
132
+ await store.delete(`p:${pad(e.seq)}`);
133
+ waiters.get(e.seq)?.reject(err);
134
+ waiters.delete(e.seq);
135
+ touch(paths);
136
+ }
137
+ }
138
+ })().finally(() => {
139
+ flushing = null;
140
+ });
141
+ return flushing;
142
+ }
143
+ globalThis.addEventListener?.("online", () => void flush());
144
+ const t = {
145
+ get pendingWrites() {
146
+ return pending.length;
147
+ },
148
+ async getAll(paths) {
149
+ await init();
150
+ if (network) {
151
+ try {
152
+ // Docs with pending writes are answered locally: our write is about to replace the server copy.
153
+ const local = pendingPaths();
154
+ const ask = paths.filter((p) => !local.has(p));
155
+ const r = ask.length ? await inner.getAll(ask) : [];
156
+ for (let i = 0; i < ask.length; i++)
157
+ await (r[i] ? remember(r[i]) : forget(ask[i]));
158
+ return paths.map((p) => effective(p));
159
+ }
160
+ catch (e) {
161
+ if (!isOffline(e))
162
+ throw e;
163
+ }
164
+ }
165
+ return paths.map((p) => {
166
+ if (!docs.has(p) && !pendingPaths().has(p))
167
+ throw unavailable(`offline, and ${p} isn't cached`);
168
+ const d = effective(p);
169
+ return d ? { ...d, meta: { fromCache: true, hasPendingWrites: d.v < 0 } } : null;
170
+ });
171
+ },
172
+ async query(q) {
173
+ await init();
174
+ if (network) {
175
+ try {
176
+ const r = await inner.query(q);
177
+ for (const d of r.docs)
178
+ await remember(d);
179
+ // A complete, unlimited result is authoritative: cached docs it no longer includes are stale.
180
+ if (q.limit === undefined && q.limitToLast === undefined && !q.offset && !r.hasMore && !q.startAfter && !q.startAt && !q.endAt && !q.endBefore && !q.near) {
181
+ const got = new Set(r.docs.map((d) => d.path));
182
+ for (const d of runLocal(docs.values(), q))
183
+ if (!got.has(d.path))
184
+ await forget(d.path);
185
+ }
186
+ return { docs: runLocal(candidates(q, r.docs.map((d) => d.path)), q), hasMore: r.hasMore };
187
+ }
188
+ catch (e) {
189
+ if (!isOffline(e))
190
+ throw e;
191
+ }
192
+ }
193
+ return { docs: runLocal(candidates(q), q).map((d) => ({ ...d, meta: { fromCache: true, hasPendingWrites: d.v < 0 } })), hasMore: false, fromCache: true };
194
+ },
195
+ // Counters need the network (they're buffered on the server, not in this cache).
196
+ ...(inner.counterInc ? { counterInc: inner.counterInc.bind(inner) } : {}),
197
+ ...(inner.refreshAuth ? { refreshAuth: inner.refreshAuth.bind(inner) } : {}),
198
+ async aggregate(q) {
199
+ await init();
200
+ if (network && !pending.length) {
201
+ try {
202
+ return await inner.aggregate(q);
203
+ }
204
+ catch (e) {
205
+ if (!isOffline(e))
206
+ throw e;
207
+ }
208
+ }
209
+ const res = runLocal(candidates(q), { ...q, aggregate: undefined });
210
+ const out = {};
211
+ if (q.aggregate?.count)
212
+ out.count = res.length;
213
+ for (const f of q.aggregate?.sum ?? [])
214
+ out[`sum:${f}`] = res.reduce((s, d) => s + (typeof getPath(d.data, f) === "number" ? getPath(d.data, f) : 0), 0);
215
+ for (const f of q.aggregate?.avg ?? []) {
216
+ const xs = res.map((d) => getPath(d.data, f)).filter((x) => typeof x === "number");
217
+ out[`avg:${f}`] = xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : null;
218
+ }
219
+ return out;
220
+ },
221
+ async commit(req) {
222
+ await init();
223
+ // Several writes to one document apply in order (locally and on the server), as in Firestore.
224
+ const e = { seq: nextSeq++, opKey: req.opKey ?? `off:${crypto.randomUUID()}`, writes: req.writes, at: Date.now() };
225
+ pending.push(e);
226
+ await store.set(`p:${pad(e.seq)}`, e);
227
+ await store.set("meta:seq", nextSeq);
228
+ const done = new Promise((resolve, reject) => waiters.set(e.seq, { resolve, reject }));
229
+ settled.set(e.seq, done.then(() => undefined, () => undefined).finally(() => settled.delete(e.seq)));
230
+ touch(req.writes.map((w) => w.path));
231
+ void flush();
232
+ return done;
233
+ },
234
+ ...(inner.listen
235
+ ? {
236
+ listen(target, h) {
237
+ // Live aggregates come from the server only (counting needs every document).
238
+ if ("query" in target && target.query.aggregate)
239
+ return inner.listen(target, h);
240
+ const l = { target, h, server: null, fromServer: false, last: "" };
241
+ listeners.add(l);
242
+ void init().then(() => emit(l));
243
+ const off = inner.listen(target, {
244
+ next: (s) => {
245
+ void (async () => {
246
+ if (s.reset || !l.server)
247
+ l.server = new Map();
248
+ for (const p of s.removed ?? []) {
249
+ l.server.delete(p);
250
+ if ("doc" in target)
251
+ await forget(p);
252
+ }
253
+ for (const d of s.docs) {
254
+ l.server.set(d.path, d);
255
+ await remember(d);
256
+ }
257
+ if (s.order)
258
+ for (const p of [...l.server.keys()])
259
+ if (!s.order.includes(p))
260
+ l.server.delete(p);
261
+ l.fromServer = true;
262
+ emit(l);
263
+ })();
264
+ },
265
+ error: (err) => {
266
+ listeners.delete(l);
267
+ h.error(err);
268
+ },
269
+ // Resume from the server docs this listener holds.
270
+ resume: () => (l.server ? Object.fromEntries([...l.server].map(([p, d]) => [p, d.v])) : undefined),
271
+ });
272
+ return () => {
273
+ listeners.delete(l);
274
+ off();
275
+ };
276
+ },
277
+ }
278
+ : {}),
279
+ /** Resolves when the queue is empty, including writes restored from the store after a reload. */
280
+ async waitForPendingWrites() {
281
+ await init();
282
+ // Drive the queue (writes restored after a reload have no promise to wait on), then settle.
283
+ while (pending.length && network) {
284
+ await flush();
285
+ if (pending.length)
286
+ await new Promise((r) => setTimeout(r, Math.min(backoff, 250)));
287
+ }
288
+ await Promise.all([...settled.values()]);
289
+ },
290
+ async disableNetwork() {
291
+ network = false;
292
+ for (const l of listeners) {
293
+ l.fromServer = false;
294
+ emit(l);
295
+ }
296
+ },
297
+ async enableNetwork() {
298
+ network = true;
299
+ await flush();
300
+ },
301
+ async clearPersistence() {
302
+ if (pending.length)
303
+ throw new EstraClientError("failed-precondition", "writes are still pending");
304
+ await store.clear();
305
+ docs.clear();
306
+ },
307
+ };
308
+ return t;
309
+ }
@@ -0,0 +1,44 @@
1
+ import type { DocOut, Query } from "./_schema/index.js";
2
+ export type ListenTarget = {
3
+ doc: string;
4
+ } | {
5
+ query: Query;
6
+ };
7
+ export interface Snap {
8
+ reset?: boolean;
9
+ order?: string[];
10
+ docs: DocOut[];
11
+ removed?: string[];
12
+ /** Live aggregate listeners: the new count/sum/avg values. */
13
+ aggregate?: Record<string, number | null>;
14
+ /** Offline cache: whether this result came from the server and includes local writes. */
15
+ meta?: {
16
+ fromCache: boolean;
17
+ hasPendingWrites: boolean;
18
+ };
19
+ }
20
+ export interface ListenHandlers {
21
+ next(s: Snap): void;
22
+ error(e: {
23
+ code: string;
24
+ message: string;
25
+ }): void;
26
+ /**
27
+ * Resume: the docs this listener already holds (path → version), sent when it (re)subscribes so
28
+ * the server answers with a delta instead of a reset. Undefined = nothing held.
29
+ */
30
+ resume?(): Record<string, number> | undefined;
31
+ }
32
+ export type Listen = (target: ListenTarget, h: ListenHandlers) => () => void;
33
+ export interface RealtimeOptions {
34
+ url: string;
35
+ getToken?: () => Promise<string | null> | string | null;
36
+ WebSocket?: typeof WebSocket;
37
+ /** Re-send the token this often so long-lived connections outlive token expiry (default 4 min). */
38
+ tokenCheckMs?: number;
39
+ }
40
+ export declare function realtimeConnection(o: RealtimeOptions): {
41
+ listen: Listen;
42
+ close(): void;
43
+ refreshAuth(): void;
44
+ };
@@ -0,0 +1,112 @@
1
+ export function realtimeConnection(o) {
2
+ const WS = o.WebSocket ?? globalThis.WebSocket;
3
+ const wsUrl = o.url.replace(/^http/, "ws").replace(/\/$/, "") + "/v1/listen";
4
+ const subs = new Map();
5
+ let ws = null;
6
+ let ready = false;
7
+ let lastToken;
8
+ let backoff = 500;
9
+ let seq = 0;
10
+ let timer = null;
11
+ let closed = false;
12
+ const send = (m) => {
13
+ if (ws && ready)
14
+ ws.send(JSON.stringify(m));
15
+ };
16
+ const subMsg = (id, s) => {
17
+ const have = s.h.resume?.();
18
+ return { t: "sub", id, ...s.target, ...(have ? { have } : {}) };
19
+ };
20
+ const sendAuth = async (force = false) => {
21
+ const t = o.getToken ? await o.getToken() : null;
22
+ if (!force && t === lastToken)
23
+ return;
24
+ lastToken = t;
25
+ ws?.send(JSON.stringify({ t: "auth", token: t }));
26
+ };
27
+ function open() {
28
+ if (ws || closed || !subs.size)
29
+ return;
30
+ if (!WS)
31
+ throw new Error("no WebSocket available: pass RealtimeOptions.WebSocket");
32
+ const sock = new WS(wsUrl);
33
+ ws = sock;
34
+ ready = false;
35
+ sock.addEventListener("open", () => {
36
+ backoff = 500;
37
+ void sendAuth(true);
38
+ });
39
+ sock.addEventListener("message", (e) => {
40
+ let m;
41
+ try {
42
+ m = JSON.parse(String(e.data));
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ if (m.t === "ready") {
48
+ if (!ready) {
49
+ ready = true;
50
+ for (const [id, s] of subs)
51
+ send(subMsg(id, s));
52
+ }
53
+ }
54
+ else if (m.t === "snap" && m.id)
55
+ subs.get(m.id)?.h.next(m);
56
+ else if (m.t === "error") {
57
+ if (m.id) {
58
+ const s = subs.get(m.id);
59
+ subs.delete(m.id);
60
+ s?.h.error({ code: m.code ?? "internal", message: m.message ?? "listener failed" });
61
+ }
62
+ else if (m.code === "unauthenticated" && ready)
63
+ void sendAuth(true);
64
+ }
65
+ });
66
+ sock.addEventListener("close", () => {
67
+ ws = null;
68
+ ready = false;
69
+ if (closed || !subs.size)
70
+ return;
71
+ setTimeout(open, backoff);
72
+ backoff = Math.min(backoff * 2, 30_000);
73
+ });
74
+ timer ??= setInterval(() => {
75
+ if (ready)
76
+ void sendAuth();
77
+ }, o.tokenCheckMs ?? 240_000);
78
+ }
79
+ return {
80
+ /** Send the current token now (call after sign-in / sign-out so open listeners switch user). */
81
+ refreshAuth() {
82
+ if (ready)
83
+ void sendAuth();
84
+ },
85
+ listen(target, h) {
86
+ const id = `l${++seq}`;
87
+ subs.set(id, { target, h });
88
+ if (ws && ready)
89
+ send(subMsg(id, { target, h }));
90
+ else
91
+ open();
92
+ return () => {
93
+ if (!subs.delete(id))
94
+ return;
95
+ send({ t: "unsub", id });
96
+ if (!subs.size) {
97
+ ws?.close(1000, "no listeners");
98
+ if (timer)
99
+ clearInterval(timer);
100
+ timer = null;
101
+ }
102
+ };
103
+ },
104
+ close() {
105
+ closed = true;
106
+ subs.clear();
107
+ if (timer)
108
+ clearInterval(timer);
109
+ ws?.close(1000, "closed");
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,57 @@
1
+ export interface StorageOptions {
2
+ url: string;
3
+ getToken?: () => Promise<string | null> | string | null;
4
+ fetch?: typeof fetch;
5
+ }
6
+ export interface FullMetadata {
7
+ name: string;
8
+ fullPath: string;
9
+ bucket: string;
10
+ size: number;
11
+ contentType: string;
12
+ customMetadata: Record<string, string>;
13
+ timeCreated: string;
14
+ updated: string;
15
+ md5Hash: string | null;
16
+ downloadTokens?: string;
17
+ }
18
+ export declare class FirebaseStorageLike {
19
+ readonly base: string;
20
+ readonly o: StorageOptions;
21
+ constructor(o: StorageOptions);
22
+ }
23
+ export declare const getStorage: (o: StorageOptions) => FirebaseStorageLike;
24
+ export declare class StorageReference {
25
+ readonly storage: FirebaseStorageLike;
26
+ readonly fullPath: string;
27
+ constructor(storage: FirebaseStorageLike, fullPath: string);
28
+ get name(): string;
29
+ get parent(): StorageReference | null;
30
+ get root(): StorageReference;
31
+ toString(): string;
32
+ }
33
+ export declare const ref: (s: FirebaseStorageLike | StorageReference, path?: string) => StorageReference;
34
+ export declare const child: (r: StorageReference, path: string) => StorageReference;
35
+ export interface UploadMetadata {
36
+ contentType?: string;
37
+ customMetadata?: Record<string, string>;
38
+ }
39
+ export declare function uploadBytes(r: StorageReference, data: Blob | ArrayBuffer | Uint8Array, m?: UploadMetadata): Promise<{
40
+ ref: StorageReference;
41
+ metadata: FullMetadata;
42
+ }>;
43
+ export declare function getMetadata(r: StorageReference): Promise<FullMetadata>;
44
+ /** A long-lived URL (download token, like Firebase): works in <img src> without credentials. */
45
+ export declare function getDownloadURL(r: StorageReference): Promise<string>;
46
+ export declare function getBytes(r: StorageReference): Promise<ArrayBuffer>;
47
+ export declare function deleteObject(r: StorageReference): Promise<void>;
48
+ export interface ListResult {
49
+ items: StorageReference[];
50
+ prefixes: StorageReference[];
51
+ nextPageToken?: string;
52
+ }
53
+ export declare function list(r: StorageReference, o?: {
54
+ maxResults?: number;
55
+ pageToken?: string;
56
+ }): Promise<ListResult>;
57
+ export declare function listAll(r: StorageReference): Promise<ListResult>;
@@ -0,0 +1,111 @@
1
+ // Firebase Storage's modular API on Estra's storage plugin:
2
+ // const storage = getStorage({ url, getToken: () => auth.getIdToken() });
3
+ // await uploadBytes(ref(storage, "profile/u1/a.jpg"), file, { contentType: "image/jpeg" });
4
+ // const src = await getDownloadURL(ref(storage, "profile/u1/a.jpg"));
5
+ import { EstraClientError } from "./transport.js";
6
+ export class FirebaseStorageLike {
7
+ base;
8
+ o;
9
+ constructor(o) {
10
+ this.o = o;
11
+ this.base = o.url.replace(/\/$/, "");
12
+ }
13
+ }
14
+ export const getStorage = (o) => new FirebaseStorageLike(o);
15
+ export class StorageReference {
16
+ storage;
17
+ fullPath;
18
+ constructor(storage, fullPath) {
19
+ this.storage = storage;
20
+ this.fullPath = fullPath.replace(/^\/+|\/+$/g, "");
21
+ }
22
+ get name() {
23
+ return this.fullPath.split("/").pop() ?? "";
24
+ }
25
+ get parent() {
26
+ return this.fullPath ? new StorageReference(this.storage, this.fullPath.split("/").slice(0, -1).join("/")) : null;
27
+ }
28
+ get root() {
29
+ return new StorageReference(this.storage, "");
30
+ }
31
+ toString() {
32
+ return `estra://${this.fullPath}`;
33
+ }
34
+ }
35
+ export const ref = (s, path = "") => s instanceof StorageReference ? new StorageReference(s.storage, [s.fullPath, path].filter(Boolean).join("/")) : new StorageReference(s, path);
36
+ export const child = (r, path) => ref(r, path);
37
+ const enc = (p) => encodeURIComponent(p);
38
+ async function call(s, path, init = {}) {
39
+ const headers = new Headers(init.headers);
40
+ const t = s.o.getToken ? await s.o.getToken() : null;
41
+ if (t)
42
+ headers.set("authorization", `Bearer ${t}`);
43
+ let r;
44
+ try {
45
+ r = await (s.o.fetch ?? fetch)(`${s.base}${path}`, { ...init, headers });
46
+ }
47
+ catch {
48
+ throw new EstraClientError("unavailable", "network request failed");
49
+ }
50
+ if (!r.ok) {
51
+ const j = (await r.json().catch(() => ({})));
52
+ throw new EstraClientError(j.error?.code ?? "unavailable", j.error?.message ?? `HTTP ${r.status}`);
53
+ }
54
+ return r;
55
+ }
56
+ const toMeta = (m) => ({
57
+ name: m.name.split("/").pop() ?? m.name,
58
+ fullPath: m.name,
59
+ bucket: m.bucket,
60
+ size: m.size,
61
+ contentType: m.contentType,
62
+ customMetadata: m.metadata,
63
+ timeCreated: new Date(m.timeCreated).toISOString(),
64
+ updated: new Date(m.updated).toISOString(),
65
+ md5Hash: m.md5Hash,
66
+ ...(m.downloadTokens ? { downloadTokens: m.downloadTokens } : {}),
67
+ });
68
+ export async function uploadBytes(r, data, m = {}) {
69
+ const contentType = m.contentType ?? (typeof Blob !== "undefined" && data instanceof Blob && data.type ? data.type : "application/octet-stream");
70
+ const headers = { "content-type": contentType };
71
+ if (m.customMetadata)
72
+ headers["x-estra-metadata"] = JSON.stringify(m.customMetadata);
73
+ const res = await call(r.storage, `/v1/storage/o/${enc(r.fullPath)}`, { method: "PUT", headers, body: data });
74
+ return { ref: r, metadata: toMeta(await res.json()) };
75
+ }
76
+ export async function getMetadata(r) {
77
+ return toMeta(await (await call(r.storage, `/v1/storage/meta/${enc(r.fullPath)}`)).json());
78
+ }
79
+ /** A long-lived URL (download token, like Firebase): works in <img src> without credentials. */
80
+ export async function getDownloadURL(r) {
81
+ const m = await getMetadata(r);
82
+ if (!m.downloadTokens)
83
+ throw new EstraClientError("not-found", "this object has no download token");
84
+ return `${r.storage.base}/v1/storage/o/${enc(r.fullPath)}?token=${m.downloadTokens}`;
85
+ }
86
+ export async function getBytes(r) {
87
+ return (await call(r.storage, `/v1/storage/o/${enc(r.fullPath)}`)).arrayBuffer();
88
+ }
89
+ export async function deleteObject(r) {
90
+ await call(r.storage, `/v1/storage/o/${enc(r.fullPath)}`, { method: "DELETE" });
91
+ }
92
+ export async function list(r, o = {}) {
93
+ const res = await call(r.storage, "/v1/storage/list", {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify({ prefix: r.fullPath, ...(o.maxResults ? { limit: o.maxResults } : {}), ...(o.pageToken ? { cursor: o.pageToken } : {}) }),
97
+ });
98
+ const j = (await res.json());
99
+ return { items: j.items.map((i) => new StorageReference(r.storage, i.name)), prefixes: j.prefixes.map((p) => new StorageReference(r.storage, p)), ...(j.cursor ? { nextPageToken: j.cursor } : {}) };
100
+ }
101
+ export async function listAll(r) {
102
+ const out = { items: [], prefixes: [] };
103
+ let pageToken;
104
+ do {
105
+ const page = await list(r, { maxResults: 1000, ...(pageToken ? { pageToken } : {}) });
106
+ out.items.push(...page.items);
107
+ out.prefixes.push(...page.prefixes);
108
+ pageToken = page.nextPageToken;
109
+ } while (pageToken);
110
+ return out;
111
+ }
@@ -0,0 +1,37 @@
1
+ export interface CacheStore {
2
+ get<T>(key: string): Promise<T | undefined>;
3
+ set(key: string, value: unknown): Promise<void>;
4
+ delete(key: string): Promise<void>;
5
+ /** Entries whose key starts with `prefix`. */
6
+ list<T>(prefix: string): Promise<[string, T][]>;
7
+ clear(): Promise<void>;
8
+ }
9
+ export declare function memoryStore(): CacheStore;
10
+ interface Req<T> {
11
+ result: T;
12
+ error: unknown;
13
+ onsuccess: (() => void) | null;
14
+ onerror: (() => void) | null;
15
+ }
16
+ interface Store {
17
+ get(k: string): Req<any>;
18
+ put(v: unknown, k: string): Req<unknown>;
19
+ delete(k: string): Req<unknown>;
20
+ clear(): Req<unknown>;
21
+ getAllKeys(range: unknown): Req<unknown[]>;
22
+ getAll(range: unknown): Req<unknown[]>;
23
+ }
24
+ interface Db {
25
+ transaction(name: string, mode: "readonly" | "readwrite"): {
26
+ objectStore(name: string): Store;
27
+ };
28
+ createObjectStore(name: string): unknown;
29
+ }
30
+ interface Factory {
31
+ open(name: string, version: number): Req<Db> & {
32
+ onupgradeneeded: (() => void) | null;
33
+ };
34
+ }
35
+ /** One IndexedDB database with one object store; values are structured-cloned. */
36
+ export declare function indexedDbStore(name: string, idb?: Factory | undefined): CacheStore;
37
+ export {};