@sovaria/sdk 0.0.0-stage → 0.5.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,431 @@
1
+ /**
2
+ * Firebase Firestore's modular API on Sovaria documents: move a Firebase app by changing imports.
3
+ *
4
+ * ```ts
5
+ * // before: import { getFirestore, collection, query, where, getDocs, … } from 'firebase/firestore'
6
+ * import { getFirestore, collection, query, where, orderBy, limit, getDocs, onSnapshot,
7
+ * doc, setDoc, updateDoc, increment, serverTimestamp, runTransaction } from '@sovaria/sdk/firestore'
8
+ *
9
+ * const db = getFirestore(sov) // sov = createClient(...)
10
+ * const q = query(collection(db, 'posts'), where('room', '==', 'r1'), orderBy('at', 'desc'), limit(20))
11
+ * const snap = await getDocs(q)
12
+ * snap.forEach((d) => console.log(d.id, d.data()))
13
+ * const stop = onSnapshot(q, (s) => render(s.docs.map((d) => d.data())))
14
+ * await updateDoc(doc(db, 'posts', 'p1'), { likes: increment(1), at: serverTimestamp() })
15
+ * ```
16
+ *
17
+ * Collection groups and vector search work too:
18
+ *
19
+ * ```ts
20
+ * const flagged = await getDocs(query(collectionGroup(db, 'comments'), where('flagged', '==', true)))
21
+ * const near = await getDocs(findNearest(collection(db, 'items'), {
22
+ * vectorField: 'embedding', queryVector: vector([0.1, 0.2, 0.3]), limit: 5, distanceMeasure: 'COSINE' }))
23
+ * ```
24
+ *
25
+ * The functions keep Firebase's names, arguments and object shapes (`snap.data()`, `snap.exists()`,
26
+ * `docChanges()`, `onSnapshot` returning its unsubscribe function at once). Underneath it is the
27
+ * Sovaria collection API, so collection rules and row policies decide every read and write.
28
+ * Differences: timestamps are stored as ISO-8601 strings (`Timestamp` and `Date` values you write are
29
+ * converted), there is no offline cache, and `getDocFromCache` / persistence settings don't exist.
30
+ */
31
+ import { SovariaError } from './errors.js';
32
+ import { FieldValue, } from './collections.js';
33
+ /** What getFirestore needs from a Sovaria client (any `SovariaSDK`). */
34
+ import { indexedDbStorage, memoryStorage } from './offline.js';
35
+ export class Firestore {
36
+ sov;
37
+ type = 'firestore';
38
+ constructor(sov) {
39
+ this.sov = sov;
40
+ }
41
+ }
42
+ /** The Firestore handle for a Sovaria client. */
43
+ export function getFirestore(sov) {
44
+ return new Firestore(sov);
45
+ }
46
+ /** Firebase's `persistentLocalCache()`: IndexedDB in browsers (memory where there is none). */
47
+ export function persistentLocalCache(options = {}) {
48
+ let storage = options.storage;
49
+ if (!storage) {
50
+ try {
51
+ storage = indexedDbStorage();
52
+ }
53
+ catch {
54
+ storage = memoryStorage();
55
+ }
56
+ }
57
+ return { kind: 'persistent', storage };
58
+ }
59
+ /** Firebase's `memoryLocalCache()`: offline cache for this page/process only. */
60
+ export function memoryLocalCache() {
61
+ return { kind: 'memory', storage: memoryStorage() };
62
+ }
63
+ /** `initializeFirestore(sov, { localCache: persistentLocalCache() })` turns on offline mode, like Firebase. */
64
+ export function initializeFirestore(sov, settings = {}) {
65
+ if (settings.localCache)
66
+ enableOffline(sov, settings.localCache.storage);
67
+ return new Firestore(sov);
68
+ }
69
+ /** Firebase's (older) way to turn on the persistent cache. */
70
+ export async function enableIndexedDbPersistence(db) {
71
+ enableOffline(db.sov, persistentLocalCache().storage);
72
+ }
73
+ function enableOffline(sov, storage) {
74
+ const target = sov;
75
+ if (typeof target.enableOffline !== 'function') {
76
+ throw new Error('This Sovaria client has no offline mode (update @sovaria/sdk)');
77
+ }
78
+ target.enableOffline({ storage });
79
+ }
80
+ function join(path, segments) {
81
+ return [path, ...segments].join('/').split('/').filter(Boolean).join('/');
82
+ }
83
+ // ── references ────────────────────────────────────────────────────────────────
84
+ export class DocumentReference {
85
+ firestore;
86
+ _ref;
87
+ type = 'document';
88
+ constructor(firestore, _ref) {
89
+ this.firestore = firestore;
90
+ this._ref = _ref;
91
+ }
92
+ get id() { return this._ref.id; }
93
+ get path() { return this._ref.path; }
94
+ get parent() {
95
+ return new CollectionReference(this.firestore, this.firestore.sov.collection(this.path.split('/').slice(0, -1).join('/')));
96
+ }
97
+ }
98
+ export class Query {
99
+ firestore;
100
+ _query;
101
+ type = 'query';
102
+ constructor(firestore, _query) {
103
+ this.firestore = firestore;
104
+ this._query = _query;
105
+ }
106
+ /** Vector search, Admin-SDK style: `collection(db, 'items').findNearest({...}).get()`. */
107
+ findNearest(options) {
108
+ return findNearest(this, options);
109
+ }
110
+ }
111
+ /** A vector value (Firebase `vector([...])`); stored as an array of numbers. */
112
+ export class VectorValue {
113
+ type = 'vector';
114
+ values;
115
+ constructor(values) { this.values = values.map(Number); }
116
+ toArray() { return [...this.values]; }
117
+ isEqual(other) {
118
+ const a = other.toArray();
119
+ return a.length === this.values.length && a.every((v, i) => v === this.values[i]);
120
+ }
121
+ }
122
+ export function vector(values) {
123
+ return new VectorValue(values);
124
+ }
125
+ /** A nearest-neighbour query; read it with `getDocs(vq)` or `vq.get()`. */
126
+ export class VectorQuery {
127
+ firestore;
128
+ _vector;
129
+ type = 'vectorQuery';
130
+ constructor(firestore, _vector) {
131
+ this.firestore = firestore;
132
+ this._vector = _vector;
133
+ }
134
+ get() { return getDocs(this); }
135
+ }
136
+ /** Firestore `findNearest`: the query's filters first, then the `limit` closest documents (closest first). */
137
+ export function findNearest(q, options) {
138
+ const queryVector = options.queryVector instanceof VectorValue ? options.queryVector.toArray() : options.queryVector;
139
+ return new VectorQuery(q.firestore, q._query.findNearest({
140
+ vectorField: options.vectorField,
141
+ queryVector,
142
+ limit: options.limit,
143
+ distanceMeasure: options.distanceMeasure ?? 'COSINE',
144
+ distanceResultField: options.distanceResultField,
145
+ distanceThreshold: options.distanceThreshold,
146
+ }));
147
+ }
148
+ export class CollectionReference extends Query {
149
+ type = 'collection';
150
+ get id() { return this._query.id; }
151
+ get path() { return this._query.path; }
152
+ }
153
+ /** `collection(db, 'users')`, `collection(db, 'users', uid, 'posts')`, `collection(docRef, 'posts')`. */
154
+ export function collection(parent, path, ...segments) {
155
+ const base = parent instanceof Firestore ? '' : parent.path;
156
+ const db = parent instanceof Firestore ? parent : parent.firestore;
157
+ return new CollectionReference(db, db.sov.collection(join(base ? `${base}/${path}` : path, segments)));
158
+ }
159
+ /** Every collection with this id at any depth (`comments`, `posts/p1/comments`, …). */
160
+ export function collectionGroup(db, collectionId) {
161
+ if (collectionId.includes('/'))
162
+ throw SovariaError.config(`collectionGroup('${collectionId}'): a collection id, not a path`);
163
+ if (typeof db.sov.collectionGroup !== 'function')
164
+ throw SovariaError.config('This Sovaria client has no collectionGroup (update @sovaria/sdk)');
165
+ return new Query(db, db.sov.collectionGroup(collectionId));
166
+ }
167
+ /** `doc(db, 'users', uid)`, `doc(db, 'users/uid')`, `doc(collectionRef)` (new id), `doc(collectionRef, id)`. */
168
+ export function doc(parent, path, ...segments) {
169
+ if (parent instanceof CollectionReference) {
170
+ const ref = path === undefined ? parent._query.doc() : parent._query.doc(join(path, segments));
171
+ return new DocumentReference(parent.firestore, ref);
172
+ }
173
+ const base = parent instanceof Firestore ? '' : parent.path;
174
+ const db = parent instanceof Firestore ? parent : parent.firestore;
175
+ if (path === undefined)
176
+ throw SovariaError.config('doc() needs a path');
177
+ return new DocumentReference(db, db.sov.doc(join(base ? `${base}/${path}` : path, segments)));
178
+ }
179
+ /** `where(documentId(), '==', id)` filters on the document id. */
180
+ export function documentId() {
181
+ return '__key__';
182
+ }
183
+ function filterConstraint(filter) {
184
+ return Object.assign((q) => q.where(filter), { _filter: filter });
185
+ }
186
+ export function where(field, op, value) {
187
+ return filterConstraint([field, op, toWire(value)]);
188
+ }
189
+ /** Firebase `or()`: documents matching any of the filters (at most 30 OR terms after expansion). */
190
+ export function or(...filters) {
191
+ return filterConstraint({ or: filters.map((f) => f._filter) });
192
+ }
193
+ /** Firebase `and()`: documents matching all of the filters, for nesting inside `or()`. */
194
+ export function and(...filters) {
195
+ return filterConstraint({ and: filters.map((f) => f._filter) });
196
+ }
197
+ export function orderBy(field, direction = 'asc') {
198
+ return (q) => q.orderBy(field, direction);
199
+ }
200
+ export function limit(n) {
201
+ return (q) => q.limit(n);
202
+ }
203
+ export function limitToLast(n) {
204
+ return (q) => q.limitToLast(n);
205
+ }
206
+ function cursorArgs(values) {
207
+ const [first] = values;
208
+ if (values.length === 1 && first instanceof DocumentSnapshot)
209
+ return [first._snap];
210
+ return values.map(toWire);
211
+ }
212
+ export function startAt(...values) { return (q) => q.startAt(...cursorArgs(values)); }
213
+ export function startAfter(...values) { return (q) => q.startAfter(...cursorArgs(values)); }
214
+ export function endAt(...values) { return (q) => q.endAt(...cursorArgs(values)); }
215
+ export function endBefore(...values) { return (q) => q.endBefore(...cursorArgs(values)); }
216
+ export function query(base, ...constraints) {
217
+ let q = base._query;
218
+ for (const c of constraints)
219
+ q = c(q);
220
+ return new Query(base.firestore, q);
221
+ }
222
+ // ── snapshots ─────────────────────────────────────────────────────────────────
223
+ export class DocumentSnapshot {
224
+ ref;
225
+ _snap;
226
+ constructor(ref, _snap) {
227
+ this.ref = ref;
228
+ this._snap = _snap;
229
+ }
230
+ get id() { return this._snap.id; }
231
+ exists() { return this._snap.exists; }
232
+ data() { return this._snap.exists ? this._snap.data : undefined; }
233
+ /** A field by dotted path (`get('address.city')`). */
234
+ get(field) {
235
+ let value = this._snap.data;
236
+ for (const part of field.split('.'))
237
+ value = value == null ? undefined : value[part];
238
+ return value;
239
+ }
240
+ /** Offline mode (enableIndexedDbPersistence / persistentLocalCache): from the cache, or a write not yet synced. */
241
+ get metadata() { return { hasPendingWrites: Boolean(this._snap.hasPendingWrites), fromCache: Boolean(this._snap.fromCache) }; }
242
+ }
243
+ export class QueryDocumentSnapshot extends DocumentSnapshot {
244
+ data() { return this._snap.data; }
245
+ }
246
+ export class QuerySnapshot {
247
+ query;
248
+ docs;
249
+ changes;
250
+ constructor(query, raw) {
251
+ this.query = query;
252
+ const wrap = (s) => new QueryDocumentSnapshot(new DocumentReference(query.firestore, query.firestore.sov.doc(s.path)), s);
253
+ this.docs = raw.docs.map(wrap);
254
+ this.changes = raw.changes.map((c) => ({ type: c.type, doc: wrap(c.doc), oldIndex: c.oldIndex, newIndex: c.newIndex }));
255
+ }
256
+ get size() { return this.docs.length; }
257
+ get empty() { return this.docs.length === 0; }
258
+ forEach(callback) { this.docs.forEach(callback); }
259
+ docChanges() { return this.changes; }
260
+ /** Offline mode: answered from the cache, or holding writes not yet synced. */
261
+ get metadata() {
262
+ return { hasPendingWrites: this.docs.some((d) => d.metadata.hasPendingWrites), fromCache: this.docs.some((d) => d.metadata.fromCache) };
263
+ }
264
+ }
265
+ // ── reads ─────────────────────────────────────────────────────────────────────
266
+ export async function getDoc(ref) {
267
+ return new DocumentSnapshot(ref, await ref._ref.get());
268
+ }
269
+ export async function getDocs(q) {
270
+ if (q instanceof VectorQuery)
271
+ return new QuerySnapshot(q, await q._vector.get());
272
+ return new QuerySnapshot(q, await q._query.get());
273
+ }
274
+ export async function getCountFromServer(q) {
275
+ const count = await q._query.count();
276
+ return { data: () => ({ count }) };
277
+ }
278
+ /** Firebase aggregate fields for `getAggregateFromServer`. */
279
+ export class AggregateField {
280
+ aggregateType;
281
+ _field;
282
+ _r;
283
+ type = 'AggregateField';
284
+ /** @internal */
285
+ constructor(aggregateType, _field, _r) {
286
+ this.aggregateType = aggregateType;
287
+ this._field = _field;
288
+ this._r = _r;
289
+ }
290
+ }
291
+ export function count() { return new AggregateField('count'); }
292
+ export function sum(field) { return new AggregateField('sum', field); }
293
+ export function average(field) { return new AggregateField('avg', field); }
294
+ /** Firebase `getAggregateFromServer(q, { total: sum('price'), avg: average('rating'), n: count() })`. */
295
+ export async function getAggregateFromServer(q, spec) {
296
+ const out = await q._query.aggregate(Object.fromEntries(Object.entries(spec).map(([alias, f]) => [alias, f._field ? { op: f.aggregateType, field: f._field } : { op: f.aggregateType }])));
297
+ return { data: () => out };
298
+ }
299
+ export const getAggregate = getAggregateFromServer;
300
+ /** Live document or query. Returns its unsubscribe function right away, like Firebase. */
301
+ export function onSnapshot(target, onNextOrObserver, onError) {
302
+ const next = typeof onNextOrObserver === 'function' ? onNextOrObserver : onNextOrObserver.next ?? (() => undefined);
303
+ const error = (typeof onNextOrObserver === 'function' ? onError : onNextOrObserver.error) ?? ((e) => console.error(e));
304
+ let stopped = false;
305
+ let stop;
306
+ const started = target instanceof DocumentReference
307
+ ? target._ref.onSnapshot((s) => next(new DocumentSnapshot(target, s)), error)
308
+ : target._query.onSnapshot((s) => next(new QuerySnapshot(target, s)), error);
309
+ started.then((fn) => { if (stopped)
310
+ fn();
311
+ else
312
+ stop = fn; }).catch(error);
313
+ return () => {
314
+ stopped = true;
315
+ stop?.();
316
+ };
317
+ }
318
+ // ── writes ────────────────────────────────────────────────────────────────────
319
+ /** A point in time. Stored as an ISO-8601 string in Sovaria. */
320
+ export class Timestamp {
321
+ seconds;
322
+ nanoseconds;
323
+ constructor(seconds, nanoseconds) {
324
+ this.seconds = seconds;
325
+ this.nanoseconds = nanoseconds;
326
+ }
327
+ static now() { return Timestamp.fromMillis(Date.now()); }
328
+ static fromDate(date) { return Timestamp.fromMillis(date.getTime()); }
329
+ static fromMillis(ms) { return new Timestamp(Math.floor(ms / 1000), (ms % 1000) * 1e6); }
330
+ /** Read back a stored timestamp (an ISO string) as a Timestamp. */
331
+ static fromIso(value) { return Timestamp.fromMillis(Date.parse(value)); }
332
+ toMillis() { return this.seconds * 1000 + Math.floor(this.nanoseconds / 1e6); }
333
+ toDate() { return new Date(this.toMillis()); }
334
+ toISOString() { return this.toDate().toISOString(); }
335
+ isEqual(other) { return this.seconds === other.seconds && this.nanoseconds === other.nanoseconds; }
336
+ }
337
+ function toWire(value) {
338
+ if (value instanceof VectorValue)
339
+ return value.toArray();
340
+ if (value instanceof Timestamp)
341
+ return value.toISOString();
342
+ if (value instanceof Date)
343
+ return value.toISOString();
344
+ if (value instanceof DocumentReference)
345
+ return value.path;
346
+ if (Array.isArray(value))
347
+ return value.map(toWire);
348
+ if (value && typeof value === 'object' && !isFieldValue(value)) {
349
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toWire(v)]));
350
+ }
351
+ return value;
352
+ }
353
+ function isFieldValue(value) {
354
+ return Object.getOwnPropertySymbols(value).some((s) => s.description === 'sovaria.fieldValue');
355
+ }
356
+ export function serverTimestamp() { return FieldValue.serverTimestamp(); }
357
+ export function increment(n) { return FieldValue.increment(n); }
358
+ export function arrayUnion(...elements) { return FieldValue.arrayUnion(...elements.map(toWire)); }
359
+ export function arrayRemove(...elements) { return FieldValue.arrayRemove(...elements.map(toWire)); }
360
+ export function deleteField() { return FieldValue.delete(); }
361
+ function updateFields(dataOrField, rest) {
362
+ if (typeof dataOrField !== 'string')
363
+ return toWire(dataOrField);
364
+ const fields = { [dataOrField]: toWire(rest[0]) };
365
+ for (let i = 1; i + 1 < rest.length; i += 2)
366
+ fields[String(rest[i])] = toWire(rest[i + 1]);
367
+ return fields;
368
+ }
369
+ export async function setDoc(ref, data, options = {}) {
370
+ await ref._ref.set(toWire(data), options);
371
+ }
372
+ export async function addDoc(ref, data) {
373
+ const created = await ref._query.add(toWire(data));
374
+ return new DocumentReference(ref.firestore, created);
375
+ }
376
+ /** `updateDoc(ref, {a: 1})` or `updateDoc(ref, 'a', 1, 'b.c', 2)`. */
377
+ export async function updateDoc(ref, dataOrField, ...rest) {
378
+ await ref._ref.update(updateFields(dataOrField, rest));
379
+ }
380
+ export async function deleteDoc(ref) {
381
+ await ref._ref.delete();
382
+ }
383
+ export class WriteBatch {
384
+ batch;
385
+ constructor(batch) {
386
+ this.batch = batch;
387
+ }
388
+ set(ref, data, options = {}) {
389
+ this.batch.set(ref._ref, toWire(data), options);
390
+ return this;
391
+ }
392
+ update(ref, dataOrField, ...rest) {
393
+ this.batch.update(ref._ref, updateFields(dataOrField, rest));
394
+ return this;
395
+ }
396
+ delete(ref) {
397
+ this.batch.delete(ref._ref);
398
+ return this;
399
+ }
400
+ async commit() {
401
+ await this.batch.commit();
402
+ }
403
+ }
404
+ export function writeBatch(db) {
405
+ return new WriteBatch(db.sov.batch());
406
+ }
407
+ export class Transaction {
408
+ tx;
409
+ constructor(tx) {
410
+ this.tx = tx;
411
+ }
412
+ async get(ref) {
413
+ return new DocumentSnapshot(ref, await this.tx.get(ref._ref));
414
+ }
415
+ set(ref, data, options = {}) {
416
+ this.tx.set(ref._ref, toWire(data), options);
417
+ return this;
418
+ }
419
+ update(ref, dataOrField, ...rest) {
420
+ this.tx.update(ref._ref, updateFields(dataOrField, rest));
421
+ return this;
422
+ }
423
+ delete(ref) {
424
+ this.tx.delete(ref._ref);
425
+ return this;
426
+ }
427
+ }
428
+ /** Reads then writes atomically; retried when a document it read changed meanwhile (default 5 attempts). */
429
+ export function runTransaction(db, fn, options = {}) {
430
+ return db.sov.runTransaction((tx) => fn(new Transaction(tx)), options.maxAttempts ?? 5);
431
+ }