@estra/client 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/_schema/index.d.ts +2 -0
- package/dist/_schema/index.js +2 -0
- package/dist/_schema/local.d.ts +14 -0
- package/dist/_schema/local.js +186 -0
- package/dist/_schema/query.d.ts +132 -0
- package/dist/_schema/query.js +34 -0
- package/dist/auth.d.ts +108 -0
- package/dist/auth.js +245 -0
- package/dist/channels.d.ts +42 -0
- package/dist/channels.js +176 -0
- package/dist/compat/app.d.ts +33 -0
- package/dist/compat/app.js +37 -0
- package/dist/compat/auth.d.ts +141 -0
- package/dist/compat/auth.js +221 -0
- package/dist/compat/firestore.d.ts +18 -0
- package/dist/compat/firestore.js +43 -0
- package/dist/compat/storage.d.ts +5 -0
- package/dist/compat/storage.js +5 -0
- package/dist/firestore.d.ts +256 -0
- package/dist/firestore.js +576 -0
- package/dist/functions.d.ts +20 -0
- package/dist/functions.js +43 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/local.d.ts +1 -0
- package/dist/local.js +2 -0
- package/dist/offline.d.ts +15 -0
- package/dist/offline.js +309 -0
- package/dist/realtime.d.ts +44 -0
- package/dist/realtime.js +112 -0
- package/dist/storage.d.ts +57 -0
- package/dist/storage.js +111 -0
- package/dist/store.d.ts +37 -0
- package/dist/store.js +44 -0
- package/dist/transport.d.ts +46 -0
- package/dist/transport.js +45 -0
- package/package.json +78 -3
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { getPath } from "./local.js";
|
|
2
|
+
import { EstraClientError } from "./transport.js";
|
|
3
|
+
export { EstraClientError as FirestoreError };
|
|
4
|
+
const fail = (code, msg) => {
|
|
5
|
+
throw new EstraClientError(code, msg);
|
|
6
|
+
};
|
|
7
|
+
// ---- values -------------------------------------------------------------------------------
|
|
8
|
+
export class Timestamp {
|
|
9
|
+
seconds;
|
|
10
|
+
nanoseconds;
|
|
11
|
+
constructor(seconds, nanoseconds) {
|
|
12
|
+
this.seconds = seconds;
|
|
13
|
+
this.nanoseconds = nanoseconds;
|
|
14
|
+
}
|
|
15
|
+
static now() {
|
|
16
|
+
return Timestamp.fromMillis(Date.now());
|
|
17
|
+
}
|
|
18
|
+
static fromDate(d) {
|
|
19
|
+
return Timestamp.fromMillis(d.getTime());
|
|
20
|
+
}
|
|
21
|
+
static fromMillis(ms) {
|
|
22
|
+
const s = Math.floor(ms / 1000);
|
|
23
|
+
return new Timestamp(s, Math.round((ms - s * 1000) * 1e6));
|
|
24
|
+
}
|
|
25
|
+
toMillis() {
|
|
26
|
+
return this.seconds * 1000 + Math.floor(this.nanoseconds / 1e6);
|
|
27
|
+
}
|
|
28
|
+
toDate() {
|
|
29
|
+
return new Date(this.toMillis());
|
|
30
|
+
}
|
|
31
|
+
isEqual(o) {
|
|
32
|
+
return o instanceof Timestamp && o.seconds === this.seconds && o.nanoseconds === this.nanoseconds;
|
|
33
|
+
}
|
|
34
|
+
valueOf() {
|
|
35
|
+
return String(this.toMillis()).padStart(20, "0");
|
|
36
|
+
}
|
|
37
|
+
toJSON() {
|
|
38
|
+
return { seconds: this.seconds, nanoseconds: this.nanoseconds };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class GeoPoint {
|
|
42
|
+
latitude;
|
|
43
|
+
longitude;
|
|
44
|
+
constructor(latitude, longitude) {
|
|
45
|
+
this.latitude = latitude;
|
|
46
|
+
this.longitude = longitude;
|
|
47
|
+
}
|
|
48
|
+
isEqual(o) {
|
|
49
|
+
return o instanceof GeoPoint && o.latitude === this.latitude && o.longitude === this.longitude;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class FieldValue {
|
|
53
|
+
wire;
|
|
54
|
+
constructor(wire) {
|
|
55
|
+
this.wire = wire;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export const serverTimestamp = () => new FieldValue({ $op: "now" });
|
|
59
|
+
export const increment = (n) => new FieldValue({ $op: "inc", by: n });
|
|
60
|
+
export const arrayUnion = (...values) => new FieldValue({ $op: "union", values: values.map(encode) });
|
|
61
|
+
export const arrayRemove = (...values) => new FieldValue({ $op: "remove", values: values.map(encode) });
|
|
62
|
+
export const deleteField = () => new FieldValue({ $op: "delete" });
|
|
63
|
+
export class FieldPath {
|
|
64
|
+
segments;
|
|
65
|
+
constructor(...segments) {
|
|
66
|
+
this.segments = segments;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const documentId = () => new FieldPath("__name__");
|
|
70
|
+
function encode(v) {
|
|
71
|
+
if (v === undefined)
|
|
72
|
+
return fail("invalid-argument", "undefined is not a valid value (use null or deleteField())");
|
|
73
|
+
if (v === null || typeof v !== "object")
|
|
74
|
+
return v;
|
|
75
|
+
if (v instanceof FieldValue)
|
|
76
|
+
return v.wire;
|
|
77
|
+
if (v instanceof Timestamp)
|
|
78
|
+
return { $t: v.toMillis() };
|
|
79
|
+
if (v instanceof Date)
|
|
80
|
+
return { $t: v.getTime() };
|
|
81
|
+
if (v instanceof GeoPoint)
|
|
82
|
+
return { $geo: [v.latitude, v.longitude] };
|
|
83
|
+
if (v instanceof DocumentReference)
|
|
84
|
+
return { $ref: v.path };
|
|
85
|
+
if (Array.isArray(v))
|
|
86
|
+
return v.map(encode);
|
|
87
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, encode(x)]));
|
|
88
|
+
}
|
|
89
|
+
function decode(db, v) {
|
|
90
|
+
if (v === null || typeof v !== "object")
|
|
91
|
+
return v;
|
|
92
|
+
if (Array.isArray(v))
|
|
93
|
+
return v.map((x) => decode(db, x));
|
|
94
|
+
const o = v;
|
|
95
|
+
const keys = Object.keys(o);
|
|
96
|
+
if (keys.length === 1 && typeof o.$t === "number")
|
|
97
|
+
return Timestamp.fromMillis(o.$t);
|
|
98
|
+
if (keys.length === 1 && Array.isArray(o.$geo))
|
|
99
|
+
return new GeoPoint(o.$geo[0], o.$geo[1]);
|
|
100
|
+
if (keys.length === 1 && typeof o.$ref === "string")
|
|
101
|
+
return new DocumentReference(db, o.$ref);
|
|
102
|
+
return Object.fromEntries(keys.map((k) => [k, decode(db, o[k])]));
|
|
103
|
+
}
|
|
104
|
+
const encodeData = (data) => {
|
|
105
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
106
|
+
fail("invalid-argument", "data must be an object");
|
|
107
|
+
const out = {};
|
|
108
|
+
// Dotted keys ("stats.plays") address a sub-field, as in Firestore's updateDoc.
|
|
109
|
+
for (const [k, v] of Object.entries(data))
|
|
110
|
+
out[k] = encode(v);
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
// ---- references -----------------------------------------------------------------------------
|
|
114
|
+
export class Firestore {
|
|
115
|
+
type = "firestore";
|
|
116
|
+
transport;
|
|
117
|
+
constructor(transport) {
|
|
118
|
+
this.transport = transport;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export const getFirestore = (t) => new Firestore("transport" in t ? t.transport : t);
|
|
122
|
+
const cleanPath = (parts) => {
|
|
123
|
+
const p = parts.join("/").replace(/^\/+|\/+$/g, "");
|
|
124
|
+
if (!p || p.split("/").some((s) => !s))
|
|
125
|
+
fail("invalid-argument", `bad path "${parts.join("/")}"`);
|
|
126
|
+
return p;
|
|
127
|
+
};
|
|
128
|
+
const AUTO_ID = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
129
|
+
export const autoId = () => Array.from(crypto.getRandomValues(new Uint8Array(20)), (b) => AUTO_ID[b % 62]).join("");
|
|
130
|
+
export class DocumentReference {
|
|
131
|
+
type = "document";
|
|
132
|
+
firestore;
|
|
133
|
+
path;
|
|
134
|
+
constructor(firestore, path) {
|
|
135
|
+
this.firestore = firestore;
|
|
136
|
+
this.path = path;
|
|
137
|
+
if (path.split("/").length % 2)
|
|
138
|
+
fail("invalid-argument", `"${path}" is a collection, not a document`);
|
|
139
|
+
}
|
|
140
|
+
get id() {
|
|
141
|
+
return this.path.split("/").pop();
|
|
142
|
+
}
|
|
143
|
+
get parent() {
|
|
144
|
+
return new CollectionReference(this.firestore, this.path.split("/").slice(0, -1).join("/"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export class Query {
|
|
148
|
+
type = "query";
|
|
149
|
+
firestore;
|
|
150
|
+
state;
|
|
151
|
+
constructor(firestore, state) {
|
|
152
|
+
this.firestore = firestore;
|
|
153
|
+
this.state = state;
|
|
154
|
+
}
|
|
155
|
+
ast() {
|
|
156
|
+
const s = this.state;
|
|
157
|
+
return {
|
|
158
|
+
...(s.from !== undefined ? { from: s.from } : { group: s.group }),
|
|
159
|
+
...(s.where.length ? { where: s.where.length === 1 ? s.where[0] : { and: s.where } } : {}),
|
|
160
|
+
...(s.orderBy.length ? { orderBy: s.orderBy } : {}),
|
|
161
|
+
...(s.limit !== undefined ? { limit: s.limit } : {}),
|
|
162
|
+
...(s.limitToLast !== undefined ? { limitToLast: s.limitToLast } : {}),
|
|
163
|
+
...(s.startAfter ? { startAfter: s.startAfter } : {}),
|
|
164
|
+
...(s.startAt ? { startAt: s.startAt } : {}),
|
|
165
|
+
...(s.endAt ? { endAt: s.endAt } : {}),
|
|
166
|
+
...(s.endBefore ? { endBefore: s.endBefore } : {}),
|
|
167
|
+
...(s.near ? { near: s.near } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export class CollectionReference extends Query {
|
|
172
|
+
type = "collection";
|
|
173
|
+
path;
|
|
174
|
+
constructor(firestore, path) {
|
|
175
|
+
super(firestore, { from: path, where: [], orderBy: [] });
|
|
176
|
+
this.path = path;
|
|
177
|
+
if (path.split("/").length % 2 === 0)
|
|
178
|
+
fail("invalid-argument", `"${path}" is a document, not a collection`);
|
|
179
|
+
}
|
|
180
|
+
get id() {
|
|
181
|
+
return this.path.split("/").pop();
|
|
182
|
+
}
|
|
183
|
+
get parent() {
|
|
184
|
+
const p = this.path.split("/");
|
|
185
|
+
return p.length > 1 ? new DocumentReference(this.firestore, p.slice(0, -1).join("/")) : null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function doc(parent, path, ...more) {
|
|
189
|
+
if (parent instanceof Firestore)
|
|
190
|
+
return new DocumentReference(parent, cleanPath([path ?? fail("invalid-argument", "doc() needs a path"), ...more]));
|
|
191
|
+
if (parent instanceof CollectionReference)
|
|
192
|
+
return new DocumentReference(parent.firestore, cleanPath([parent.path, path ?? autoId(), ...more]));
|
|
193
|
+
return new DocumentReference(parent.firestore, cleanPath([parent.path, path ?? fail("invalid-argument", "doc() needs a path"), ...more]));
|
|
194
|
+
}
|
|
195
|
+
export function collection(parent, path, ...more) {
|
|
196
|
+
const db = parent instanceof Firestore ? parent : parent.firestore;
|
|
197
|
+
const base = parent instanceof Firestore ? [] : [parent.path];
|
|
198
|
+
return new CollectionReference(db, cleanPath([...base, path, ...more]));
|
|
199
|
+
}
|
|
200
|
+
export const collectionGroup = (db, id) => new Query(db, { group: id, where: [], orderBy: [] });
|
|
201
|
+
const fieldName = (f) => (f instanceof FieldPath ? f.segments.join(".") : f);
|
|
202
|
+
export class QueryConstraint {
|
|
203
|
+
apply;
|
|
204
|
+
constructor(apply) {
|
|
205
|
+
this.apply = apply;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export class QueryFilterConstraint extends QueryConstraint {
|
|
209
|
+
filter;
|
|
210
|
+
constructor(filter) {
|
|
211
|
+
super((s) => ({ ...s, where: [...s.where, filter] }));
|
|
212
|
+
this.filter = filter;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function where(field, op, value) {
|
|
216
|
+
const name = fieldName(field);
|
|
217
|
+
let v = encode(value);
|
|
218
|
+
// documentId() compares ids; accept full paths or references like Firestore does.
|
|
219
|
+
if (name === "__name__") {
|
|
220
|
+
const id = (x) => (typeof x === "object" && x && "$ref" in x ? String(x.$ref) : String(x)).split("/").pop();
|
|
221
|
+
v = Array.isArray(v) ? v.map(id) : id(v);
|
|
222
|
+
}
|
|
223
|
+
return new QueryFilterConstraint({ field: name, op, value: v });
|
|
224
|
+
}
|
|
225
|
+
export const and = (...fs) => new QueryFilterConstraint({ and: fs.map((f) => f.filter) });
|
|
226
|
+
export const or = (...fs) => new QueryFilterConstraint({ or: fs.map((f) => f.filter) });
|
|
227
|
+
export const orderBy = (field, dir = "asc") => new QueryConstraint((s) => ({ ...s, orderBy: [...s.orderBy, { field: fieldName(field), dir }] }));
|
|
228
|
+
// The last limit()/limitToLast() wins, as in Firestore.
|
|
229
|
+
export const limit = (n) => new QueryConstraint((s) => ({ ...s, limit: n, limitToLast: undefined }));
|
|
230
|
+
/** The last n results in orderBy order (needs an orderBy). */
|
|
231
|
+
export const limitToLast = (n) => new QueryConstraint((s) => ({ ...s, limitToLast: n, limit: undefined }));
|
|
232
|
+
function cursorValues(s, args) {
|
|
233
|
+
if (args.length === 1 && args[0] instanceof DocumentSnapshot) {
|
|
234
|
+
const snap = args[0];
|
|
235
|
+
const raw = snap._raw ?? fail("invalid-argument", "cursor document doesn't exist");
|
|
236
|
+
return [...s.orderBy.map((o) => (o.field === "__name__" || o.field === "id" ? raw.id : getPath(raw.data, o.field))), raw.id];
|
|
237
|
+
}
|
|
238
|
+
return args.map(encode);
|
|
239
|
+
}
|
|
240
|
+
export const startAfter = (...args) => new QueryConstraint((s) => ({ ...s, startAfter: cursorValues(s, args), startAt: undefined }));
|
|
241
|
+
export const startAt = (...args) => new QueryConstraint((s) => ({ ...s, startAt: cursorValues(s, args), startAfter: undefined }));
|
|
242
|
+
/**
|
|
243
|
+
* Radius search (an Estra addition): documents whose geo field is within `radiusMi` of `center`.
|
|
244
|
+
* Without an orderBy, results sort by distance; each snapshot's `distanceMi` says how far. Works
|
|
245
|
+
* in listeners too (moving documents enter and leave the results).
|
|
246
|
+
*/
|
|
247
|
+
export const near = (field, center, radiusMi) => new QueryConstraint((s) => ({ ...s, near: { field: fieldName(field), lat: center instanceof GeoPoint ? center.latitude : center.lat, lng: center instanceof GeoPoint ? center.longitude : center.lng, radiusMi } }));
|
|
248
|
+
export const endAt = (...args) => new QueryConstraint((s) => ({ ...s, endAt: cursorValues(s, args), endBefore: undefined }));
|
|
249
|
+
export const endBefore = (...args) => new QueryConstraint((s) => ({ ...s, endBefore: cursorValues(s, args), endAt: undefined }));
|
|
250
|
+
export function query(q, ...constraints) {
|
|
251
|
+
return new Query(q.firestore, constraints.reduce((s, c) => c.apply(s), q.state));
|
|
252
|
+
}
|
|
253
|
+
const meta = (m) => {
|
|
254
|
+
const fromCache = !!m?.fromCache, hasPendingWrites = !!m?.hasPendingWrites;
|
|
255
|
+
return { fromCache, hasPendingWrites, isEqual: (o) => o.fromCache === fromCache && o.hasPendingWrites === hasPendingWrites };
|
|
256
|
+
};
|
|
257
|
+
export class DocumentSnapshot {
|
|
258
|
+
ref;
|
|
259
|
+
metadata;
|
|
260
|
+
_raw;
|
|
261
|
+
constructor(ref, raw, m) {
|
|
262
|
+
this.ref = ref;
|
|
263
|
+
this._raw = raw;
|
|
264
|
+
this.metadata = meta(m ?? raw?.meta);
|
|
265
|
+
}
|
|
266
|
+
get id() {
|
|
267
|
+
return this.ref.id;
|
|
268
|
+
}
|
|
269
|
+
exists() {
|
|
270
|
+
return this._raw !== null;
|
|
271
|
+
}
|
|
272
|
+
/** For `near` queries: miles from the query's center (an Estra addition). */
|
|
273
|
+
get distanceMi() {
|
|
274
|
+
return this._raw?.distanceMi;
|
|
275
|
+
}
|
|
276
|
+
data() {
|
|
277
|
+
return this._raw ? decode(this.ref.firestore, this._raw.data) : undefined;
|
|
278
|
+
}
|
|
279
|
+
get(field) {
|
|
280
|
+
const parts = field instanceof FieldPath ? field.segments : field.split(".");
|
|
281
|
+
let cur = this.data();
|
|
282
|
+
for (const p of parts)
|
|
283
|
+
cur = cur == null ? undefined : cur[p];
|
|
284
|
+
return cur;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export class QueryDocumentSnapshot extends DocumentSnapshot {
|
|
288
|
+
data() {
|
|
289
|
+
return super.data();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export class QuerySnapshot {
|
|
293
|
+
query;
|
|
294
|
+
docs;
|
|
295
|
+
metadata;
|
|
296
|
+
changes;
|
|
297
|
+
constructor(q, docs, changes = null, m) {
|
|
298
|
+
this.query = q;
|
|
299
|
+
this.docs = docs;
|
|
300
|
+
this.changes = changes;
|
|
301
|
+
this.metadata = meta(m ?? { fromCache: docs.some((d) => d.metadata.fromCache), hasPendingWrites: docs.some((d) => d.metadata.hasPendingWrites) });
|
|
302
|
+
}
|
|
303
|
+
/** Changes since the previous snapshot of a listener (all "added" for a one-off read). */
|
|
304
|
+
docChanges() {
|
|
305
|
+
return this.changes ?? this.docs.map((doc, i) => ({ type: "added", doc, oldIndex: -1, newIndex: i }));
|
|
306
|
+
}
|
|
307
|
+
get size() {
|
|
308
|
+
return this.docs.length;
|
|
309
|
+
}
|
|
310
|
+
get empty() {
|
|
311
|
+
return this.docs.length === 0;
|
|
312
|
+
}
|
|
313
|
+
forEach(cb) {
|
|
314
|
+
this.docs.forEach(cb);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// ---- reads ----------------------------------------------------------------------------------
|
|
318
|
+
export async function getDoc(ref) {
|
|
319
|
+
const [raw] = await ref.firestore.transport.getAll([ref.path]);
|
|
320
|
+
return new DocumentSnapshot(ref, raw ?? null);
|
|
321
|
+
}
|
|
322
|
+
/** Without a limit, pages until every match is read (like Firestore), up to 50,000 documents. */
|
|
323
|
+
const ALL_CAP = 50_000;
|
|
324
|
+
export async function getDocs(q) {
|
|
325
|
+
const db = q.firestore;
|
|
326
|
+
const ast = q.ast();
|
|
327
|
+
const out = [];
|
|
328
|
+
let page = await db.transport.query(ast);
|
|
329
|
+
const fromCache = !!page.fromCache;
|
|
330
|
+
out.push(...page.docs);
|
|
331
|
+
while (ast.limit === undefined && ast.limitToLast === undefined && page.hasMore && page.docs.length && out.length < ALL_CAP) {
|
|
332
|
+
const last = page.docs[page.docs.length - 1];
|
|
333
|
+
const cursor = [...(ast.orderBy ?? []).map((o) => (o.field === "id" || o.field === "__name__" ? last.id : getPath(last.data, o.field))), last.id];
|
|
334
|
+
page = await db.transport.query({ ...ast, startAfter: cursor, startAt: undefined });
|
|
335
|
+
out.push(...page.docs);
|
|
336
|
+
}
|
|
337
|
+
const docs = out.map((d) => new QueryDocumentSnapshot(new DocumentReference(db, d.path), d));
|
|
338
|
+
return new QuerySnapshot(q, docs, null, { fromCache, hasPendingWrites: out.some((d) => d.v < 0) });
|
|
339
|
+
}
|
|
340
|
+
const controls = (db) => db.transport;
|
|
341
|
+
export const waitForPendingWrites = (db) => controls(db).waitForPendingWrites?.() ?? Promise.resolve();
|
|
342
|
+
export const disableNetwork = (db) => controls(db).disableNetwork?.() ?? fail("unimplemented", "disableNetwork needs offline()");
|
|
343
|
+
export const enableNetwork = (db) => controls(db).enableNetwork?.() ?? Promise.resolve();
|
|
344
|
+
export const clearIndexedDbPersistence = (db) => controls(db).clearPersistence?.() ?? Promise.resolve();
|
|
345
|
+
export const clearPersistence = clearIndexedDbPersistence;
|
|
346
|
+
export async function getCountFromServer(q) {
|
|
347
|
+
const r = await q.firestore.transport.aggregate({ ...q.ast(), aggregate: { count: true } });
|
|
348
|
+
return { data: () => ({ count: Number(r.count ?? 0) }) };
|
|
349
|
+
}
|
|
350
|
+
export const count = () => ({ kind: "count" });
|
|
351
|
+
export const sum = (field) => ({ kind: "sum", field });
|
|
352
|
+
export const average = (field) => ({ kind: "avg", field });
|
|
353
|
+
const aggAst = (q, spec) => {
|
|
354
|
+
const specs = Object.values(spec);
|
|
355
|
+
return {
|
|
356
|
+
...q.ast(),
|
|
357
|
+
aggregate: {
|
|
358
|
+
count: specs.some((s) => s.kind === "count"),
|
|
359
|
+
sum: specs.flatMap((s) => (s.kind === "sum" ? [s.field] : [])),
|
|
360
|
+
avg: specs.flatMap((s) => (s.kind === "avg" ? [s.field] : [])),
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
const aggData = (spec, r) => Object.fromEntries(Object.entries(spec).map(([k, s]) => [k, s.kind === "count" ? Number(r.count ?? 0) : (r[`${s.kind}:${s.field}`] ?? null)]));
|
|
365
|
+
export async function getAggregateFromServer(q, spec) {
|
|
366
|
+
const data = aggData(spec, await q.firestore.transport.aggregate(aggAst(q, spec)));
|
|
367
|
+
return { data: () => data };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Live aggregates (an Estra addition; Firestore has none): calls `next` with the current values,
|
|
371
|
+
* then again whenever a matching document changes them. Same rules and caps as getAggregateFromServer.
|
|
372
|
+
* onAggregateSnapshot(query(collection(db, "orders"), where("open", "==", true)), { n: count(), total: sum("amount") }, (s) => show(s.data()))
|
|
373
|
+
*/
|
|
374
|
+
export function onAggregateSnapshot(q, spec, next, error) {
|
|
375
|
+
const listen = q.firestore.transport.listen ?? fail("unimplemented", "this transport has no listeners (use httpTransport with a WebSocket)");
|
|
376
|
+
return listen({ query: aggAst(q, spec) }, {
|
|
377
|
+
next: (s) => {
|
|
378
|
+
if (!s.aggregate)
|
|
379
|
+
return;
|
|
380
|
+
const data = aggData(spec, s.aggregate);
|
|
381
|
+
next({ data: () => data });
|
|
382
|
+
},
|
|
383
|
+
error: (e) => error?.(new EstraClientError(e.code, e.message)),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const setWrite = (ref, data, o) => ({ op: "set", path: ref.path, data: encodeData(data), ...(o?.merge ? { merge: true } : {}) });
|
|
387
|
+
function updateWrite(ref, a, rest) {
|
|
388
|
+
const data = typeof a === "string" || a instanceof FieldPath ? {} : a;
|
|
389
|
+
if (typeof a === "string" || a instanceof FieldPath) {
|
|
390
|
+
const pairs = [a, ...rest];
|
|
391
|
+
for (let i = 0; i < pairs.length; i += 2)
|
|
392
|
+
data[fieldName(pairs[i])] = pairs[i + 1];
|
|
393
|
+
}
|
|
394
|
+
return { op: "update", path: ref.path, data: encodeData(data) };
|
|
395
|
+
}
|
|
396
|
+
const commitOne = async (db, w) => {
|
|
397
|
+
await db.transport.commit({ writes: [w] });
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* Hot counters: adds `by` to a number field through the server's counter buffer (one commit about
|
|
401
|
+
* every second, however many calls). Resolves with the new value (stored + buffered). Needs
|
|
402
|
+
* `createEstra({ counters })`, and passes the document's update rule per call. Online only.
|
|
403
|
+
*/
|
|
404
|
+
export async function incrementBuffered(ref, field, by = 1) {
|
|
405
|
+
const inc = ref.firestore.transport.counterInc ?? fail("unimplemented", "this transport has no counters (use httpTransport)");
|
|
406
|
+
return (await inc(ref.path, fieldName(field), by)).value;
|
|
407
|
+
}
|
|
408
|
+
export const setDoc = (ref, data, o) => commitOne(ref.firestore, setWrite(ref, data, o));
|
|
409
|
+
export const updateDoc = (ref, a, ...rest) => commitOne(ref.firestore, updateWrite(ref, a, rest));
|
|
410
|
+
export const deleteDoc = (ref) => commitOne(ref.firestore, { op: "delete", path: ref.path });
|
|
411
|
+
export async function addDoc(coll, data) {
|
|
412
|
+
const ref = doc(coll);
|
|
413
|
+
await coll.firestore.transport.commit({ writes: [{ ...setWrite(ref, data), pre: { exists: false } }] });
|
|
414
|
+
return ref;
|
|
415
|
+
}
|
|
416
|
+
export class WriteBatch {
|
|
417
|
+
db;
|
|
418
|
+
writes = [];
|
|
419
|
+
constructor(db) {
|
|
420
|
+
this.db = db;
|
|
421
|
+
}
|
|
422
|
+
set(ref, data, o) {
|
|
423
|
+
this.writes.push(setWrite(ref, data, o));
|
|
424
|
+
return this;
|
|
425
|
+
}
|
|
426
|
+
update(ref, a, ...rest) {
|
|
427
|
+
this.writes.push(updateWrite(ref, a, rest));
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
delete(ref) {
|
|
431
|
+
this.writes.push({ op: "delete", path: ref.path });
|
|
432
|
+
return this;
|
|
433
|
+
}
|
|
434
|
+
async commit() {
|
|
435
|
+
if (this.writes.length)
|
|
436
|
+
await this.db.transport.commit({ writes: this.writes });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
export const writeBatch = (db) => new WriteBatch(db);
|
|
440
|
+
export class Transaction {
|
|
441
|
+
db;
|
|
442
|
+
reads = new Map();
|
|
443
|
+
writes = [];
|
|
444
|
+
constructor(db) {
|
|
445
|
+
this.db = db;
|
|
446
|
+
}
|
|
447
|
+
async get(ref) {
|
|
448
|
+
if (this.writes.length)
|
|
449
|
+
fail("invalid-argument", "transactions must read before they write");
|
|
450
|
+
const snap = await getDoc(ref);
|
|
451
|
+
this.reads.set(ref.path, snap._raw?.v ?? 0);
|
|
452
|
+
return snap;
|
|
453
|
+
}
|
|
454
|
+
set(ref, data, o) {
|
|
455
|
+
this.writes.push(setWrite(ref, data, o));
|
|
456
|
+
return this;
|
|
457
|
+
}
|
|
458
|
+
update(ref, a, ...rest) {
|
|
459
|
+
this.writes.push(updateWrite(ref, a, rest));
|
|
460
|
+
return this;
|
|
461
|
+
}
|
|
462
|
+
delete(ref) {
|
|
463
|
+
this.writes.push({ op: "delete", path: ref.path });
|
|
464
|
+
return this;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** Reads, then writes guarded on the versions read; retried when a read went stale. */
|
|
468
|
+
export async function runTransaction(db, fn, o = {}) {
|
|
469
|
+
const max = o.maxAttempts ?? 5;
|
|
470
|
+
for (let attempt = 1;; attempt++) {
|
|
471
|
+
const t = new Transaction(db);
|
|
472
|
+
const result = await fn(t);
|
|
473
|
+
const writes = t.writes.map((w) => (t.reads.has(w.path) ? { ...w, pre: { ...w.pre, version: t.reads.get(w.path) } } : w));
|
|
474
|
+
// Documents read but not written are guarded too: the commit fails if any of them changed.
|
|
475
|
+
const written = new Set(t.writes.map((w) => w.path));
|
|
476
|
+
for (const [path, version] of t.reads)
|
|
477
|
+
if (!written.has(path))
|
|
478
|
+
writes.push({ op: "check", path, pre: { version } });
|
|
479
|
+
try {
|
|
480
|
+
if (t.writes.length)
|
|
481
|
+
await db.transport.commit({ writes });
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
const stale = e instanceof EstraClientError && (e.code === "failed-precondition" || e.code === "aborted");
|
|
486
|
+
if (!stale || attempt >= max)
|
|
487
|
+
throw e;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
export function onSnapshot(target, ...args) {
|
|
492
|
+
let includeMeta = false;
|
|
493
|
+
if (args[0] && typeof args[0] === "object" && !("next" in args[0]) && !("error" in args[0]))
|
|
494
|
+
includeMeta = !!args.shift().includeMetadataChanges;
|
|
495
|
+
const obs = typeof args[0] === "function" ? { next: args[0], error: args[1] } : (args[0] ?? {});
|
|
496
|
+
const db = target.firestore;
|
|
497
|
+
const listen = db.transport.listen ?? fail("unimplemented", "this transport has no listeners (use httpTransport with a WebSocket)");
|
|
498
|
+
const onError = (e) => obs.error?.(new EstraClientError(e.code, e.message));
|
|
499
|
+
// Like Firestore, snapshots whose only change is metadata (fromCache, hasPendingWrites) are
|
|
500
|
+
// skipped unless includeMetadataChanges is set.
|
|
501
|
+
const metaKey = (m) => `${!!m?.fromCache}|${!!m?.hasPendingWrites}`;
|
|
502
|
+
const sameData = (a, b) => (!a && !b) || (!!a && !!b && (a.v === b.v || JSON.stringify(a.data) === JSON.stringify(b.data)));
|
|
503
|
+
if (target instanceof DocumentReference) {
|
|
504
|
+
let cur = null;
|
|
505
|
+
let first = true;
|
|
506
|
+
let lastMeta = "";
|
|
507
|
+
return listen({ doc: target.path }, {
|
|
508
|
+
next: (s) => {
|
|
509
|
+
const prev = cur;
|
|
510
|
+
cur = s.docs[0] ?? (s.removed?.length || s.reset ? null : cur);
|
|
511
|
+
const meta = metaKey(s.meta);
|
|
512
|
+
const quiet = !first && sameData(prev, cur) && (!includeMeta || meta === lastMeta);
|
|
513
|
+
first = false;
|
|
514
|
+
lastMeta = meta;
|
|
515
|
+
if (!quiet)
|
|
516
|
+
obs.next?.(new DocumentSnapshot(target, cur, s.meta));
|
|
517
|
+
},
|
|
518
|
+
error: onError,
|
|
519
|
+
resume: () => (first ? undefined : cur ? { [cur.path]: cur.v } : {}),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const q = target;
|
|
523
|
+
const cache = new Map();
|
|
524
|
+
let order = [];
|
|
525
|
+
let first = true;
|
|
526
|
+
let lastMeta = "";
|
|
527
|
+
return listen({ query: q.ast() }, {
|
|
528
|
+
resume: () => (first ? undefined : Object.fromEntries([...cache.values()].filter((d) => d.v > 0).map((d) => [d.path, d.v]))),
|
|
529
|
+
next: (s) => {
|
|
530
|
+
const prevOrder = order;
|
|
531
|
+
const prevDocs = new Map(prevOrder.map((p) => [p, cache.get(p)]));
|
|
532
|
+
if (s.reset)
|
|
533
|
+
cache.clear();
|
|
534
|
+
for (const p of s.removed ?? [])
|
|
535
|
+
cache.delete(p);
|
|
536
|
+
for (const d of s.docs)
|
|
537
|
+
cache.set(d.path, d);
|
|
538
|
+
order = s.order ?? order;
|
|
539
|
+
const snap = (p) => new QueryDocumentSnapshot(new DocumentReference(db, p), cache.get(p));
|
|
540
|
+
const docs = order.filter((p) => cache.has(p)).map(snap);
|
|
541
|
+
const changes = [];
|
|
542
|
+
if (!first) {
|
|
543
|
+
const now = new Set(order);
|
|
544
|
+
prevOrder.forEach((p, i) => {
|
|
545
|
+
if (!now.has(p))
|
|
546
|
+
changes.push({ type: "removed", doc: new QueryDocumentSnapshot(new DocumentReference(db, p), { path: p, id: p.split("/").pop(), v: 0, createTime: 0, updateTime: 0, data: {} }), oldIndex: i, newIndex: -1 });
|
|
547
|
+
});
|
|
548
|
+
order.forEach((p, i) => {
|
|
549
|
+
const was = prevOrder.indexOf(p);
|
|
550
|
+
if (was < 0)
|
|
551
|
+
changes.push({ type: "added", doc: snap(p), oldIndex: -1, newIndex: i });
|
|
552
|
+
else {
|
|
553
|
+
const before = prevDocs.get(p);
|
|
554
|
+
const after = cache.get(p);
|
|
555
|
+
// A data change, or (with includeMetadataChanges) a pending write that got confirmed.
|
|
556
|
+
const metaOnly = sameData(before, after) && before?.v !== after?.v && (before?.v ?? 0) < 0 !== (after?.v ?? 0) < 0;
|
|
557
|
+
if (!sameData(before, after) || (includeMeta && metaOnly))
|
|
558
|
+
changes.push({ type: "modified", doc: snap(p), oldIndex: was, newIndex: i });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
for (const p of prevOrder)
|
|
562
|
+
if (!new Set(order).has(p))
|
|
563
|
+
cache.delete(p);
|
|
564
|
+
}
|
|
565
|
+
const meta = metaKey(s.meta);
|
|
566
|
+
const moved = prevOrder.join("\n") !== order.join("\n");
|
|
567
|
+
const quiet = !first && !changes.length && !moved && (!includeMeta || meta === lastMeta);
|
|
568
|
+
const out = new QuerySnapshot(q, docs, first ? null : changes, s.meta);
|
|
569
|
+
first = false;
|
|
570
|
+
lastMeta = meta;
|
|
571
|
+
if (!quiet)
|
|
572
|
+
obs.next?.(out);
|
|
573
|
+
},
|
|
574
|
+
error: onError,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Functions {
|
|
2
|
+
url: string;
|
|
3
|
+
getToken?: () => Promise<string | null> | string | null;
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export declare class FunctionsError extends Error {
|
|
7
|
+
/** "functions/<code>" as in the Firebase SDK: functions/not-found, functions/permission-denied … */
|
|
8
|
+
readonly code: string;
|
|
9
|
+
readonly details: unknown;
|
|
10
|
+
constructor(code: string, message: string, details?: unknown);
|
|
11
|
+
}
|
|
12
|
+
export declare const getFunctions: (o: Functions) => Functions;
|
|
13
|
+
export interface HttpsCallableOptions {
|
|
14
|
+
/** Milliseconds before the call fails with functions/deadline-exceeded (default 70 s, like Firebase). */
|
|
15
|
+
timeout?: number;
|
|
16
|
+
}
|
|
17
|
+
export type HttpsCallable<Req, Res> = (data?: Req | null) => Promise<{
|
|
18
|
+
data: Res;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function httpsCallable<Req = unknown, Res = unknown>(f: Functions, name: string, o?: HttpsCallableOptions): HttpsCallable<Req, Res>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Callable functions with Firebase's shapes (firebase/functions → @estra/client/functions):
|
|
2
|
+
//
|
|
3
|
+
// const functions = getFunctions({ url: "https://api.example.com", getToken: () => auth.getIdToken() });
|
|
4
|
+
// const addMessage = httpsCallable<{ text: string }, { ok: boolean }>(functions, "addMessage");
|
|
5
|
+
// const { data } = await addMessage({ text: "hi" });
|
|
6
|
+
//
|
|
7
|
+
// Values travel as JSON (as with Firebase callables; Timestamps arrive as plain objects).
|
|
8
|
+
export class FunctionsError extends Error {
|
|
9
|
+
/** "functions/<code>" as in the Firebase SDK: functions/not-found, functions/permission-denied … */
|
|
10
|
+
code;
|
|
11
|
+
details;
|
|
12
|
+
constructor(code, message, details) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = `functions/${code}`;
|
|
15
|
+
this.details = details;
|
|
16
|
+
this.name = "FunctionsError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export const getFunctions = (o) => o;
|
|
20
|
+
export function httpsCallable(f, name, o = {}) {
|
|
21
|
+
return async (data) => {
|
|
22
|
+
const token = f.getToken ? await f.getToken() : null;
|
|
23
|
+
const headers = { "content-type": "application/json" };
|
|
24
|
+
if (token)
|
|
25
|
+
headers.authorization = `Bearer ${token}`;
|
|
26
|
+
const ac = new AbortController();
|
|
27
|
+
const timer = setTimeout(() => ac.abort(), o.timeout ?? 70_000);
|
|
28
|
+
let r;
|
|
29
|
+
try {
|
|
30
|
+
r = await (f.fetch ?? fetch)(`${f.url.replace(/\/$/, "")}/v1/fn/${encodeURIComponent(name)}`, { method: "POST", headers, body: JSON.stringify({ data: data ?? null }), signal: ac.signal });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw ac.signal.aborted ? new FunctionsError("deadline-exceeded", "the call timed out") : new FunctionsError("unavailable", "network request failed");
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
const j = (await r.json().catch(() => ({})));
|
|
39
|
+
if (!r.ok || j.error)
|
|
40
|
+
throw new FunctionsError(j.error?.code ?? (r.status >= 500 ? "internal" : "unknown"), j.error?.message ?? `HTTP ${r.status}`, j.error?.details);
|
|
41
|
+
return { data: j.result };
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/local.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { applyWrite, getPath, inCollection, matchesFilter, matchesQuery, orderComparator, runLocal } from "./_schema/index.js";
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type CacheStore } from "./store.js";
|
|
2
|
+
import { type Transport } from "./transport.js";
|
|
3
|
+
export interface OfflineOptions {
|
|
4
|
+
store?: CacheStore;
|
|
5
|
+
/** First retry delay after a failed flush (doubles up to 60 s). Default 1 s. */
|
|
6
|
+
retryMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface OfflineTransport extends Transport {
|
|
9
|
+
waitForPendingWrites(): Promise<void>;
|
|
10
|
+
disableNetwork(): Promise<void>;
|
|
11
|
+
enableNetwork(): Promise<void>;
|
|
12
|
+
clearPersistence(): Promise<void>;
|
|
13
|
+
readonly pendingWrites: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function offline(inner: Transport, o?: OfflineOptions): OfflineTransport;
|