@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,337 @@
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
+ * The functions keep Firebase's names, arguments and object shapes (`snap.data()`, `snap.exists()`,
18
+ * `docChanges()`, `onSnapshot` returning its unsubscribe function at once). Underneath it is the
19
+ * Sovaria collection API, so collection rules and row policies decide every read and write.
20
+ * Differences: timestamps are stored as ISO-8601 strings (`Timestamp` and `Date` values you write are
21
+ * converted), there is no offline cache, and `getDocFromCache` / persistence settings don't exist.
22
+ */
23
+ import { SovariaError } from './errors.js';
24
+ import { FieldValue, } from './collections.js';
25
+ /** What getFirestore needs from a Sovaria client (any `SovariaSDK`). */
26
+ import { indexedDbStorage, memoryStorage } from './offline.js';
27
+ export class Firestore {
28
+ sov;
29
+ type = 'firestore';
30
+ constructor(sov) {
31
+ this.sov = sov;
32
+ }
33
+ }
34
+ /** The Firestore handle for a Sovaria client. */
35
+ export function getFirestore(sov) {
36
+ return new Firestore(sov);
37
+ }
38
+ /** Firebase's `persistentLocalCache()`: IndexedDB in browsers (memory where there is none). */
39
+ export function persistentLocalCache(options = {}) {
40
+ let storage = options.storage;
41
+ if (!storage) {
42
+ try {
43
+ storage = indexedDbStorage();
44
+ }
45
+ catch {
46
+ storage = memoryStorage();
47
+ }
48
+ }
49
+ return { kind: 'persistent', storage };
50
+ }
51
+ /** Firebase's `memoryLocalCache()`: offline cache for this page/process only. */
52
+ export function memoryLocalCache() {
53
+ return { kind: 'memory', storage: memoryStorage() };
54
+ }
55
+ /** `initializeFirestore(sov, { localCache: persistentLocalCache() })` turns on offline mode, like Firebase. */
56
+ export function initializeFirestore(sov, settings = {}) {
57
+ if (settings.localCache)
58
+ enableOffline(sov, settings.localCache.storage);
59
+ return new Firestore(sov);
60
+ }
61
+ /** Firebase's (older) way to turn on the persistent cache. */
62
+ export async function enableIndexedDbPersistence(db) {
63
+ enableOffline(db.sov, persistentLocalCache().storage);
64
+ }
65
+ function enableOffline(sov, storage) {
66
+ const target = sov;
67
+ if (typeof target.enableOffline !== 'function') {
68
+ throw new Error('This Sovaria client has no offline mode (update @sovaria/sdk)');
69
+ }
70
+ target.enableOffline({ storage });
71
+ }
72
+ function join(path, segments) {
73
+ return [path, ...segments].join('/').split('/').filter(Boolean).join('/');
74
+ }
75
+ // ── references ────────────────────────────────────────────────────────────────
76
+ export class DocumentReference {
77
+ firestore;
78
+ _ref;
79
+ type = 'document';
80
+ constructor(firestore, _ref) {
81
+ this.firestore = firestore;
82
+ this._ref = _ref;
83
+ }
84
+ get id() { return this._ref.id; }
85
+ get path() { return this._ref.path; }
86
+ get parent() {
87
+ return new CollectionReference(this.firestore, this.firestore.sov.collection(this.path.split('/').slice(0, -1).join('/')));
88
+ }
89
+ }
90
+ export class Query {
91
+ firestore;
92
+ _query;
93
+ type = 'query';
94
+ constructor(firestore, _query) {
95
+ this.firestore = firestore;
96
+ this._query = _query;
97
+ }
98
+ }
99
+ export class CollectionReference extends Query {
100
+ type = 'collection';
101
+ get id() { return this._query.id; }
102
+ get path() { return this._query.path; }
103
+ }
104
+ /** `collection(db, 'users')`, `collection(db, 'users', uid, 'posts')`, `collection(docRef, 'posts')`. */
105
+ export function collection(parent, path, ...segments) {
106
+ const base = parent instanceof Firestore ? '' : parent.path;
107
+ const db = parent instanceof Firestore ? parent : parent.firestore;
108
+ return new CollectionReference(db, db.sov.collection(join(base ? `${base}/${path}` : path, segments)));
109
+ }
110
+ /** `doc(db, 'users', uid)`, `doc(db, 'users/uid')`, `doc(collectionRef)` (new id), `doc(collectionRef, id)`. */
111
+ export function doc(parent, path, ...segments) {
112
+ if (parent instanceof CollectionReference) {
113
+ const ref = path === undefined ? parent._query.doc() : parent._query.doc(join(path, segments));
114
+ return new DocumentReference(parent.firestore, ref);
115
+ }
116
+ const base = parent instanceof Firestore ? '' : parent.path;
117
+ const db = parent instanceof Firestore ? parent : parent.firestore;
118
+ if (path === undefined)
119
+ throw SovariaError.config('doc() needs a path');
120
+ return new DocumentReference(db, db.sov.doc(join(base ? `${base}/${path}` : path, segments)));
121
+ }
122
+ /** `where(documentId(), '==', id)` filters on the document id. */
123
+ export function documentId() {
124
+ return '__key__';
125
+ }
126
+ export function where(field, op, value) {
127
+ return (q) => q.where(field, op, toWire(value));
128
+ }
129
+ export function orderBy(field, direction = 'asc') {
130
+ return (q) => q.orderBy(field, direction);
131
+ }
132
+ export function limit(n) {
133
+ return (q) => q.limit(n);
134
+ }
135
+ export function limitToLast(n) {
136
+ return (q) => q.limitToLast(n);
137
+ }
138
+ function cursorArgs(values) {
139
+ const [first] = values;
140
+ if (values.length === 1 && first instanceof DocumentSnapshot)
141
+ return [first._snap];
142
+ return values.map(toWire);
143
+ }
144
+ export function startAt(...values) { return (q) => q.startAt(...cursorArgs(values)); }
145
+ export function startAfter(...values) { return (q) => q.startAfter(...cursorArgs(values)); }
146
+ export function endAt(...values) { return (q) => q.endAt(...cursorArgs(values)); }
147
+ export function endBefore(...values) { return (q) => q.endBefore(...cursorArgs(values)); }
148
+ export function query(base, ...constraints) {
149
+ let q = base._query;
150
+ for (const c of constraints)
151
+ q = c(q);
152
+ return new Query(base.firestore, q);
153
+ }
154
+ // ── snapshots ─────────────────────────────────────────────────────────────────
155
+ export class DocumentSnapshot {
156
+ ref;
157
+ _snap;
158
+ constructor(ref, _snap) {
159
+ this.ref = ref;
160
+ this._snap = _snap;
161
+ }
162
+ get id() { return this._snap.id; }
163
+ exists() { return this._snap.exists; }
164
+ data() { return this._snap.exists ? this._snap.data : undefined; }
165
+ /** A field by dotted path (`get('address.city')`). */
166
+ get(field) {
167
+ let value = this._snap.data;
168
+ for (const part of field.split('.'))
169
+ value = value == null ? undefined : value[part];
170
+ return value;
171
+ }
172
+ /** Offline mode (enableIndexedDbPersistence / persistentLocalCache): from the cache, or a write not yet synced. */
173
+ get metadata() { return { hasPendingWrites: Boolean(this._snap.hasPendingWrites), fromCache: Boolean(this._snap.fromCache) }; }
174
+ }
175
+ export class QueryDocumentSnapshot extends DocumentSnapshot {
176
+ data() { return this._snap.data; }
177
+ }
178
+ export class QuerySnapshot {
179
+ query;
180
+ docs;
181
+ changes;
182
+ constructor(query, raw) {
183
+ this.query = query;
184
+ const wrap = (s) => new QueryDocumentSnapshot(new DocumentReference(query.firestore, query.firestore.sov.doc(s.path)), s);
185
+ this.docs = raw.docs.map(wrap);
186
+ this.changes = raw.changes.map((c) => ({ type: c.type, doc: wrap(c.doc), oldIndex: c.oldIndex, newIndex: c.newIndex }));
187
+ }
188
+ get size() { return this.docs.length; }
189
+ get empty() { return this.docs.length === 0; }
190
+ forEach(callback) { this.docs.forEach(callback); }
191
+ docChanges() { return this.changes; }
192
+ /** Offline mode: answered from the cache, or holding writes not yet synced. */
193
+ get metadata() {
194
+ return { hasPendingWrites: this.docs.some((d) => d.metadata.hasPendingWrites), fromCache: this.docs.some((d) => d.metadata.fromCache) };
195
+ }
196
+ }
197
+ // ── reads ─────────────────────────────────────────────────────────────────────
198
+ export async function getDoc(ref) {
199
+ return new DocumentSnapshot(ref, await ref._ref.get());
200
+ }
201
+ export async function getDocs(q) {
202
+ return new QuerySnapshot(q, await q._query.get());
203
+ }
204
+ export async function getCountFromServer(q) {
205
+ const count = await q._query.count();
206
+ return { data: () => ({ count }) };
207
+ }
208
+ /** Live document or query. Returns its unsubscribe function right away, like Firebase. */
209
+ export function onSnapshot(target, onNextOrObserver, onError) {
210
+ const next = typeof onNextOrObserver === 'function' ? onNextOrObserver : onNextOrObserver.next ?? (() => undefined);
211
+ const error = (typeof onNextOrObserver === 'function' ? onError : onNextOrObserver.error) ?? ((e) => console.error(e));
212
+ let stopped = false;
213
+ let stop;
214
+ const started = target instanceof DocumentReference
215
+ ? target._ref.onSnapshot((s) => next(new DocumentSnapshot(target, s)), error)
216
+ : target._query.onSnapshot((s) => next(new QuerySnapshot(target, s)), error);
217
+ started.then((fn) => { if (stopped)
218
+ fn();
219
+ else
220
+ stop = fn; }).catch(error);
221
+ return () => {
222
+ stopped = true;
223
+ stop?.();
224
+ };
225
+ }
226
+ // ── writes ────────────────────────────────────────────────────────────────────
227
+ /** A point in time. Stored as an ISO-8601 string in Sovaria. */
228
+ export class Timestamp {
229
+ seconds;
230
+ nanoseconds;
231
+ constructor(seconds, nanoseconds) {
232
+ this.seconds = seconds;
233
+ this.nanoseconds = nanoseconds;
234
+ }
235
+ static now() { return Timestamp.fromMillis(Date.now()); }
236
+ static fromDate(date) { return Timestamp.fromMillis(date.getTime()); }
237
+ static fromMillis(ms) { return new Timestamp(Math.floor(ms / 1000), (ms % 1000) * 1e6); }
238
+ /** Read back a stored timestamp (an ISO string) as a Timestamp. */
239
+ static fromIso(value) { return Timestamp.fromMillis(Date.parse(value)); }
240
+ toMillis() { return this.seconds * 1000 + Math.floor(this.nanoseconds / 1e6); }
241
+ toDate() { return new Date(this.toMillis()); }
242
+ toISOString() { return this.toDate().toISOString(); }
243
+ isEqual(other) { return this.seconds === other.seconds && this.nanoseconds === other.nanoseconds; }
244
+ }
245
+ function toWire(value) {
246
+ if (value instanceof Timestamp)
247
+ return value.toISOString();
248
+ if (value instanceof Date)
249
+ return value.toISOString();
250
+ if (value instanceof DocumentReference)
251
+ return value.path;
252
+ if (Array.isArray(value))
253
+ return value.map(toWire);
254
+ if (value && typeof value === 'object' && !isFieldValue(value)) {
255
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toWire(v)]));
256
+ }
257
+ return value;
258
+ }
259
+ function isFieldValue(value) {
260
+ return Object.getOwnPropertySymbols(value).some((s) => s.description === 'sovaria.fieldValue');
261
+ }
262
+ export function serverTimestamp() { return FieldValue.serverTimestamp(); }
263
+ export function increment(n) { return FieldValue.increment(n); }
264
+ export function arrayUnion(...elements) { return FieldValue.arrayUnion(...elements.map(toWire)); }
265
+ export function arrayRemove(...elements) { return FieldValue.arrayRemove(...elements.map(toWire)); }
266
+ export function deleteField() { return FieldValue.delete(); }
267
+ function updateFields(dataOrField, rest) {
268
+ if (typeof dataOrField !== 'string')
269
+ return toWire(dataOrField);
270
+ const fields = { [dataOrField]: toWire(rest[0]) };
271
+ for (let i = 1; i + 1 < rest.length; i += 2)
272
+ fields[String(rest[i])] = toWire(rest[i + 1]);
273
+ return fields;
274
+ }
275
+ export async function setDoc(ref, data, options = {}) {
276
+ await ref._ref.set(toWire(data), options);
277
+ }
278
+ export async function addDoc(ref, data) {
279
+ const created = await ref._query.add(toWire(data));
280
+ return new DocumentReference(ref.firestore, created);
281
+ }
282
+ /** `updateDoc(ref, {a: 1})` or `updateDoc(ref, 'a', 1, 'b.c', 2)`. */
283
+ export async function updateDoc(ref, dataOrField, ...rest) {
284
+ await ref._ref.update(updateFields(dataOrField, rest));
285
+ }
286
+ export async function deleteDoc(ref) {
287
+ await ref._ref.delete();
288
+ }
289
+ export class WriteBatch {
290
+ batch;
291
+ constructor(batch) {
292
+ this.batch = batch;
293
+ }
294
+ set(ref, data, options = {}) {
295
+ this.batch.set(ref._ref, toWire(data), options);
296
+ return this;
297
+ }
298
+ update(ref, dataOrField, ...rest) {
299
+ this.batch.update(ref._ref, updateFields(dataOrField, rest));
300
+ return this;
301
+ }
302
+ delete(ref) {
303
+ this.batch.delete(ref._ref);
304
+ return this;
305
+ }
306
+ async commit() {
307
+ await this.batch.commit();
308
+ }
309
+ }
310
+ export function writeBatch(db) {
311
+ return new WriteBatch(db.sov.batch());
312
+ }
313
+ export class Transaction {
314
+ tx;
315
+ constructor(tx) {
316
+ this.tx = tx;
317
+ }
318
+ async get(ref) {
319
+ return new DocumentSnapshot(ref, await this.tx.get(ref._ref));
320
+ }
321
+ set(ref, data, options = {}) {
322
+ this.tx.set(ref._ref, toWire(data), options);
323
+ return this;
324
+ }
325
+ update(ref, dataOrField, ...rest) {
326
+ this.tx.update(ref._ref, updateFields(dataOrField, rest));
327
+ return this;
328
+ }
329
+ delete(ref) {
330
+ this.tx.delete(ref._ref);
331
+ return this;
332
+ }
333
+ }
334
+ /** Reads then writes atomically; retried when a document it read changed meanwhile (default 5 attempts). */
335
+ export function runTransaction(db, fn, options = {}) {
336
+ return db.sov.runTransaction((tx) => fn(new Transaction(tx)), options.maxAttempts ?? 5);
337
+ }