@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.
- package/LICENSE +21 -0
- package/README.md +248 -2
- package/dist/analytics.d.ts +92 -0
- package/dist/analytics.js +236 -0
- package/dist/client.d.ts +146 -0
- package/dist/client.js +583 -0
- package/dist/collections.d.ts +255 -0
- package/dist/collections.js +569 -0
- package/dist/complete-api.d.ts +54 -0
- package/dist/complete-api.js +71 -0
- package/dist/crashes.d.ts +111 -0
- package/dist/crashes.js +244 -0
- package/dist/crypto.d.ts +37 -0
- package/dist/crypto.js +189 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +258 -0
- package/dist/firestore.d.ts +239 -0
- package/dist/firestore.js +431 -0
- package/dist/generated.d.ts +2462 -0
- package/dist/generated.js +1962 -0
- package/dist/inapp.d.ts +52 -0
- package/dist/inapp.js +48 -0
- package/dist/index.d.ts +3562 -0
- package/dist/index.js +3018 -0
- package/dist/offline.d.ts +92 -0
- package/dist/offline.js +575 -0
- package/dist/passkeys.d.ts +17 -0
- package/dist/passkeys.js +76 -0
- package/dist/perf.d.ts +76 -0
- package/dist/perf.js +212 -0
- package/dist/remote-config.d.ts +87 -0
- package/dist/remote-config.js +182 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.js +1 -0
- package/dist/webpush.d.ts +46 -0
- package/dist/webpush.js +99 -0
- package/package.json +74 -4
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firestore-style document collections: field queries, cursors, live listeners, batches,
|
|
3
|
+
* transactions and field transforms.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const posts = sov.collection('posts')
|
|
7
|
+
* const page = await posts.where('published', '==', true).orderBy('likes', 'desc').limit(20).get()
|
|
8
|
+
* const next = await posts.where('published', '==', true).orderBy('likes', 'desc').limit(20).startAfter(page.docs.at(-1)!).get()
|
|
9
|
+
*
|
|
10
|
+
* await sov.doc('posts/p1').update({ likes: FieldValue.increment(1), tags: FieldValue.arrayUnion('new') })
|
|
11
|
+
*
|
|
12
|
+
* await sov.runTransaction(async (tx) => {
|
|
13
|
+
* const a = await tx.get(sov.doc('accounts/a'))
|
|
14
|
+
* tx.update(sov.doc('accounts/a'), { balance: (a.data?.balance as number) - 10 })
|
|
15
|
+
* tx.update(sov.doc('accounts/b'), { balance: FieldValue.increment(10) })
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* const stop = await posts.where('room', '==', 'r1').onSnapshot((snap) => render(snap.docs))
|
|
19
|
+
* stop()
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Every call goes through the gateway with the caller's credentials, so collection rules (Guard)
|
|
23
|
+
* and the project's row policies decide what a query returns and what a write may change.
|
|
24
|
+
*/
|
|
25
|
+
import { SovariaError } from './errors.js';
|
|
26
|
+
const BASE = '/api/v1/data/doc';
|
|
27
|
+
function quotePath(path) {
|
|
28
|
+
return path.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
|
29
|
+
}
|
|
30
|
+
export function filter(field, op, value) {
|
|
31
|
+
return [field, op, value];
|
|
32
|
+
}
|
|
33
|
+
/** Matches documents that match any of `filters` (Firestore `or()`; at most 30 OR terms after expansion). */
|
|
34
|
+
export function or(...filters) {
|
|
35
|
+
return { or: filters };
|
|
36
|
+
}
|
|
37
|
+
/** Matches documents that match all of `filters` (Firestore `and()`), for nesting inside `or`. */
|
|
38
|
+
export function and(...filters) {
|
|
39
|
+
return { and: filters };
|
|
40
|
+
}
|
|
41
|
+
export const count = () => ({ op: 'count' });
|
|
42
|
+
export const sum = (field) => ({ op: 'sum', field });
|
|
43
|
+
export const average = (field) => ({ op: 'avg', field });
|
|
44
|
+
// ── field values ──────────────────────────────────────────────────────────────
|
|
45
|
+
const TRANSFORM = Symbol('sovaria.fieldValue');
|
|
46
|
+
function transform(op, value) {
|
|
47
|
+
return { [TRANSFORM]: true, op, value };
|
|
48
|
+
}
|
|
49
|
+
function isTransform(value) {
|
|
50
|
+
return typeof value === 'object' && value !== null && value[TRANSFORM] === true;
|
|
51
|
+
}
|
|
52
|
+
/** Atomic field updates, applied by the server inside the commit. */
|
|
53
|
+
export const FieldValue = {
|
|
54
|
+
increment: (n) => transform('increment', n),
|
|
55
|
+
maximum: (n) => transform('maximum', n),
|
|
56
|
+
minimum: (n) => transform('minimum', n),
|
|
57
|
+
arrayUnion: (...elements) => transform('array_union', elements),
|
|
58
|
+
arrayRemove: (...elements) => transform('array_remove', elements),
|
|
59
|
+
serverTimestamp: () => transform('server_timestamp'),
|
|
60
|
+
delete: () => transform('delete'),
|
|
61
|
+
};
|
|
62
|
+
function fieldPath(parts) {
|
|
63
|
+
return parts.map((p) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(p) ? p : '`' + p.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`')).join('.');
|
|
64
|
+
}
|
|
65
|
+
/** Pull FieldValue sentinels out of nested data into the API's `transforms` list. */
|
|
66
|
+
export function splitTransforms(data, prefix = []) {
|
|
67
|
+
const clean = {};
|
|
68
|
+
const transforms = [];
|
|
69
|
+
for (const [key, value] of Object.entries(data)) {
|
|
70
|
+
const path = [...prefix, key];
|
|
71
|
+
if (isTransform(value)) {
|
|
72
|
+
transforms.push({ field: fieldPath(path), op: value.op, value: (value.value ?? null) });
|
|
73
|
+
}
|
|
74
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
75
|
+
const inner = splitTransforms(value, path);
|
|
76
|
+
transforms.push(...inner.transforms);
|
|
77
|
+
if (Object.keys(inner.data).length || !inner.transforms.length)
|
|
78
|
+
clean[key] = inner.data;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
clean[key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { data: clean, transforms };
|
|
85
|
+
}
|
|
86
|
+
function setWrite(path, data, opts = {}) {
|
|
87
|
+
const split = splitTransforms(data);
|
|
88
|
+
const write = { op: opts.op ?? 'set', path, data: split.data, transforms: split.transforms };
|
|
89
|
+
if (opts.merge)
|
|
90
|
+
write.merge = true;
|
|
91
|
+
if (opts.mergeFields?.length)
|
|
92
|
+
write.merge_fields = opts.mergeFields;
|
|
93
|
+
return write;
|
|
94
|
+
}
|
|
95
|
+
function updateWrite(path, fields) {
|
|
96
|
+
const data = {};
|
|
97
|
+
const transforms = [];
|
|
98
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
99
|
+
if (isTransform(value))
|
|
100
|
+
transforms.push({ field: key, op: value.op, value: (value.value ?? null) });
|
|
101
|
+
else
|
|
102
|
+
data[key] = value;
|
|
103
|
+
}
|
|
104
|
+
return { op: 'update', path, data, transforms };
|
|
105
|
+
}
|
|
106
|
+
function toSnapshot(raw) {
|
|
107
|
+
const exists = raw.exists !== false && raw.data !== null && raw.data !== undefined;
|
|
108
|
+
const path = String(raw.path ?? '');
|
|
109
|
+
return {
|
|
110
|
+
id: String(raw.id ?? path.split('/').pop() ?? ''),
|
|
111
|
+
path,
|
|
112
|
+
exists,
|
|
113
|
+
data: exists ? raw.data : null,
|
|
114
|
+
version: Number(raw.version ?? 0),
|
|
115
|
+
updateTime: raw.update_time ? String(raw.update_time) : undefined,
|
|
116
|
+
createTime: raw.create_time ? String(raw.create_time) : undefined,
|
|
117
|
+
...(raw.from_cache ? { fromCache: true } : {}),
|
|
118
|
+
...(raw.pending ? { hasPendingWrites: true } : {}),
|
|
119
|
+
...(typeof raw.distance === 'number' ? { distance: raw.distance } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** A `findNearest` query: `await query.findNearest({...}).get()`. */
|
|
123
|
+
export class VectorQuery {
|
|
124
|
+
sdk;
|
|
125
|
+
path;
|
|
126
|
+
body;
|
|
127
|
+
constructor(sdk, path, body) {
|
|
128
|
+
this.sdk = sdk;
|
|
129
|
+
this.path = path;
|
|
130
|
+
this.body = body;
|
|
131
|
+
}
|
|
132
|
+
toJSON() {
|
|
133
|
+
return JSON.parse(JSON.stringify(this.body));
|
|
134
|
+
}
|
|
135
|
+
async get() {
|
|
136
|
+
const raw = await this.sdk.request('POST', `${BASE}/_nearest/${quotePath(this.path)}`, { body: this.toJSON() });
|
|
137
|
+
return querySnapshot((raw.documents ?? []).map((d) => toSnapshot(d)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function querySnapshot(docs, changes = [], extra = {}) {
|
|
141
|
+
return { docs, changes, hasMore: false, nextCursor: null, ...extra, empty: docs.length === 0, size: docs.length };
|
|
142
|
+
}
|
|
143
|
+
export class DocumentReference {
|
|
144
|
+
sdk;
|
|
145
|
+
path;
|
|
146
|
+
id;
|
|
147
|
+
constructor(sdk, path) {
|
|
148
|
+
this.sdk = sdk;
|
|
149
|
+
const parts = path.split('/').filter(Boolean);
|
|
150
|
+
if (parts.length < 2 || parts.length % 2)
|
|
151
|
+
throw SovariaError.config('A document path is collection/id (or collection/id/sub/id)');
|
|
152
|
+
this.path = parts.join('/');
|
|
153
|
+
this.id = parts[parts.length - 1];
|
|
154
|
+
}
|
|
155
|
+
get parent() {
|
|
156
|
+
return new Query(this.sdk, this.path.split('/').slice(0, -1).join('/'));
|
|
157
|
+
}
|
|
158
|
+
collection(name) {
|
|
159
|
+
return new Query(this.sdk, `${this.path}/${name}`);
|
|
160
|
+
}
|
|
161
|
+
async get() {
|
|
162
|
+
try {
|
|
163
|
+
const raw = await this.sdk.request('GET', `${BASE}/${quotePath(this.path)}`);
|
|
164
|
+
return toSnapshot({ ...raw, id: this.id, path: this.path });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error instanceof SovariaError && error.statusCode === 404) {
|
|
168
|
+
return { id: this.id, path: this.path, exists: false, data: null, version: 0 };
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async commitOne(write) {
|
|
174
|
+
const out = await commitWrites(this.sdk, [write]);
|
|
175
|
+
return out.results[0];
|
|
176
|
+
}
|
|
177
|
+
/** Replace the document (or merge into it with `{ merge: true }`). */
|
|
178
|
+
set(data, opts = {}) {
|
|
179
|
+
return this.commitOne(setWrite(this.path, data, opts));
|
|
180
|
+
}
|
|
181
|
+
/** Create it; fails with 409 ALREADY_EXISTS when it exists. */
|
|
182
|
+
create(data) {
|
|
183
|
+
return this.commitOne(setWrite(this.path, data, { op: 'create' }));
|
|
184
|
+
}
|
|
185
|
+
/** Change fields (dotted paths allowed); fails with 404 when the document doesn't exist. */
|
|
186
|
+
update(fields, opts = {}) {
|
|
187
|
+
const write = updateWrite(this.path, fields);
|
|
188
|
+
if (opts.version !== undefined)
|
|
189
|
+
write.precondition = { version: opts.version };
|
|
190
|
+
return this.commitOne(write);
|
|
191
|
+
}
|
|
192
|
+
async delete(opts = {}) {
|
|
193
|
+
const write = { op: 'delete', path: this.path };
|
|
194
|
+
if (opts.version !== undefined)
|
|
195
|
+
write.precondition = { version: opts.version };
|
|
196
|
+
await commitWrites(this.sdk, [write]);
|
|
197
|
+
}
|
|
198
|
+
/** Live document: `callback` runs now and after every change. Returns a function that stops it. */
|
|
199
|
+
onSnapshot(callback, onError) {
|
|
200
|
+
return this.parent.where('__key__', '==', this.id).onSnapshot((snap) => {
|
|
201
|
+
callback(snap.docs[0] ?? { id: this.id, path: this.path, exists: false, data: null, version: 0 });
|
|
202
|
+
}, onError);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export class Query {
|
|
206
|
+
sdk;
|
|
207
|
+
body;
|
|
208
|
+
isGroup;
|
|
209
|
+
path;
|
|
210
|
+
id;
|
|
211
|
+
/**
|
|
212
|
+
* A collection, or with `isGroup` (`sov.collectionGroup('comments')`) every collection with that id at any
|
|
213
|
+
* depth; in a group, document cursors and `__key__` are full document paths.
|
|
214
|
+
*/
|
|
215
|
+
constructor(sdk, path, body = {}, isGroup = false) {
|
|
216
|
+
this.sdk = sdk;
|
|
217
|
+
this.body = body;
|
|
218
|
+
this.isGroup = isGroup;
|
|
219
|
+
const parts = path.split('/').filter(Boolean);
|
|
220
|
+
if (isGroup && parts.length !== 1)
|
|
221
|
+
throw SovariaError.config("A collection group is one collection id, like 'comments'");
|
|
222
|
+
if (!parts.length || parts.length % 2 === 0)
|
|
223
|
+
throw SovariaError.config('A collection path is collection or collection/id/subcollection');
|
|
224
|
+
this.path = parts.join('/');
|
|
225
|
+
this.id = parts[parts.length - 1];
|
|
226
|
+
}
|
|
227
|
+
with(changes) {
|
|
228
|
+
const body = { ...this.body };
|
|
229
|
+
for (const [k, v] of Object.entries(changes)) {
|
|
230
|
+
if (v === undefined)
|
|
231
|
+
delete body[k];
|
|
232
|
+
else
|
|
233
|
+
body[k] = v;
|
|
234
|
+
}
|
|
235
|
+
return new Query(this.sdk, this.path, body, this.isGroup);
|
|
236
|
+
}
|
|
237
|
+
where(fieldOrFilter, op, value) {
|
|
238
|
+
const item = typeof fieldOrFilter === 'string' ? [fieldOrFilter, op, value] : fieldOrFilter;
|
|
239
|
+
return this.with({ where: [...(this.body.where ?? []), item] });
|
|
240
|
+
}
|
|
241
|
+
orderBy(field, direction = 'asc') {
|
|
242
|
+
return this.with({ order_by: [...(this.body.order_by ?? []), [field, direction]] });
|
|
243
|
+
}
|
|
244
|
+
limit(n) {
|
|
245
|
+
return this.with({ limit: n, limit_to_last: undefined });
|
|
246
|
+
}
|
|
247
|
+
limitToLast(n) {
|
|
248
|
+
return this.with({ limit_to_last: n, limit: undefined });
|
|
249
|
+
}
|
|
250
|
+
cursor(args) {
|
|
251
|
+
const [first] = args;
|
|
252
|
+
if (args.length === 1 && first && typeof first === 'object' && !Array.isArray(first)) {
|
|
253
|
+
const obj = first;
|
|
254
|
+
// A DocumentSnapshot: its id, or its full path in a collection group.
|
|
255
|
+
if ('exists' in obj && typeof obj.id === 'string')
|
|
256
|
+
return { key: this.isGroup && obj.path ? obj.path : obj.id };
|
|
257
|
+
if (obj.values || obj.key)
|
|
258
|
+
return { values: obj.values, key: obj.key };
|
|
259
|
+
}
|
|
260
|
+
return { values: args };
|
|
261
|
+
}
|
|
262
|
+
startAt(...values) { return this.with({ start_at: this.cursor(values), start_after: undefined }); }
|
|
263
|
+
startAfter(...values) { return this.with({ start_after: this.cursor(values), start_at: undefined }); }
|
|
264
|
+
endAt(...values) { return this.with({ end_at: this.cursor(values), end_before: undefined }); }
|
|
265
|
+
endBefore(...values) { return this.with({ end_before: this.cursor(values), end_at: undefined }); }
|
|
266
|
+
doc(id) {
|
|
267
|
+
if (this.isGroup)
|
|
268
|
+
throw SovariaError.config("A collection group has no documents of its own; use sov.doc('posts/p1/comments/c1')");
|
|
269
|
+
return new DocumentReference(this.sdk, `${this.path}/${id ?? newDocumentId()}`);
|
|
270
|
+
}
|
|
271
|
+
async add(data) {
|
|
272
|
+
const ref = this.doc();
|
|
273
|
+
await ref.create(data);
|
|
274
|
+
return ref;
|
|
275
|
+
}
|
|
276
|
+
toJSON() {
|
|
277
|
+
const body = JSON.parse(JSON.stringify(this.body));
|
|
278
|
+
if (this.isGroup)
|
|
279
|
+
body.collection_group = true;
|
|
280
|
+
return body;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Vector search like Firestore's `findNearest`: this query's `where` filters run first, then the `limit`
|
|
284
|
+
* documents whose `vectorField` is closest to `queryVector` come back closest first, each with `distance`.
|
|
285
|
+
*/
|
|
286
|
+
findNearest(opts) {
|
|
287
|
+
const body = {
|
|
288
|
+
where: this.body.where ?? [],
|
|
289
|
+
field: opts.vectorField,
|
|
290
|
+
vector: opts.queryVector.map(Number),
|
|
291
|
+
limit: opts.limit,
|
|
292
|
+
distance: String(opts.distanceMeasure ?? 'cosine').toLowerCase(),
|
|
293
|
+
};
|
|
294
|
+
if (opts.distanceResultField)
|
|
295
|
+
body.distance_field = opts.distanceResultField;
|
|
296
|
+
if (opts.distanceThreshold !== undefined)
|
|
297
|
+
body.distance_threshold = opts.distanceThreshold;
|
|
298
|
+
if (this.isGroup)
|
|
299
|
+
body.collection_group = true;
|
|
300
|
+
return new VectorQuery(this.sdk, this.path, body);
|
|
301
|
+
}
|
|
302
|
+
async get() {
|
|
303
|
+
const raw = await this.sdk.request('POST', `${BASE}/_query/${quotePath(this.path)}`, { body: this.toJSON() });
|
|
304
|
+
const docs = (raw.documents ?? []).map((d) => toSnapshot(raw.from_cache ? { ...d, from_cache: true } : d));
|
|
305
|
+
return querySnapshot(docs, [], {
|
|
306
|
+
hasMore: Boolean(raw.has_more),
|
|
307
|
+
nextCursor: raw.next_cursor ?? null,
|
|
308
|
+
indexHint: raw.index_hint,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async count() {
|
|
312
|
+
const body = this.toJSON();
|
|
313
|
+
delete body.limit;
|
|
314
|
+
delete body.limit_to_last;
|
|
315
|
+
const raw = await this.sdk.request('POST', `${BASE}/_count/${quotePath(this.path)}`, { body });
|
|
316
|
+
return Number(raw.count ?? 0);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* count / sum / average on the server, like Firestore's `getAggregateFromServer`:
|
|
320
|
+
* `await orders.where('paid', '==', true).aggregate({ n: count(), revenue: sum('total'), avg: average('total') })`.
|
|
321
|
+
* sum and average only use numeric values; average is null when there are none. With `limit`, they cover
|
|
322
|
+
* the first `limit` results; without, every match.
|
|
323
|
+
*/
|
|
324
|
+
async aggregate(spec) {
|
|
325
|
+
const body = this.toJSON();
|
|
326
|
+
body.aggregations = Object.entries(spec).map(([alias, a]) => ({ ...a, alias }));
|
|
327
|
+
const raw = await this.sdk.request('POST', `${BASE}/_aggregate/${quotePath(this.path)}`, { body });
|
|
328
|
+
return (raw.aggregations ?? {});
|
|
329
|
+
}
|
|
330
|
+
/** Every matching document, one request per page. */
|
|
331
|
+
async *pages(pageSize = 100) {
|
|
332
|
+
let query = this.limit(pageSize);
|
|
333
|
+
for (;;) {
|
|
334
|
+
const snap = await query.get();
|
|
335
|
+
yield* snap.docs;
|
|
336
|
+
if (!snap.hasMore || !snap.nextCursor)
|
|
337
|
+
return;
|
|
338
|
+
query = query.with({ start_after: snap.nextCursor, start_at: undefined });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Live results: `callback` runs with the first snapshot and after every change. Await it for the stop function. */
|
|
342
|
+
async onSnapshot(callback, onError) {
|
|
343
|
+
const listener = new LiveQuery(this.sdk, this, callback, onError);
|
|
344
|
+
await listener.start();
|
|
345
|
+
return () => listener.close();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
export function newDocumentId() {
|
|
349
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
350
|
+
const bytes = new Uint8Array(20);
|
|
351
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
352
|
+
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');
|
|
353
|
+
}
|
|
354
|
+
export class LiveQuery {
|
|
355
|
+
sdk;
|
|
356
|
+
query;
|
|
357
|
+
callback;
|
|
358
|
+
onError;
|
|
359
|
+
id = '';
|
|
360
|
+
channel = '';
|
|
361
|
+
docs = new Map();
|
|
362
|
+
order = [];
|
|
363
|
+
seq = 0;
|
|
364
|
+
pending = [];
|
|
365
|
+
renewTimer;
|
|
366
|
+
socket;
|
|
367
|
+
pollTimer;
|
|
368
|
+
closed = false;
|
|
369
|
+
/** A server without collection-group listeners (older): the group query is re-run this often (ms). */
|
|
370
|
+
static groupPollMs = 5000;
|
|
371
|
+
constructor(sdk, query, callback, onError) {
|
|
372
|
+
this.sdk = sdk;
|
|
373
|
+
this.query = query;
|
|
374
|
+
this.callback = callback;
|
|
375
|
+
this.onError = onError;
|
|
376
|
+
}
|
|
377
|
+
async start() {
|
|
378
|
+
let raw;
|
|
379
|
+
try {
|
|
380
|
+
raw = await this.sdk.request('POST', `${BASE}/_listen/${quotePath(this.query.path)}`, { body: this.query.toJSON() });
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
// A server from before collection-group listeners answers 400 UNSUPPORTED: poll the group query instead.
|
|
384
|
+
if (this.query.isGroup && error instanceof SovariaError && error.statusCode === 400 && error.serverCode === 'UNSUPPORTED') {
|
|
385
|
+
this.applyFull((await this.query.get()).docs, true);
|
|
386
|
+
this.pollTimer = setInterval(() => void this.resync(), LiveQuery.groupPollMs);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
this.id = String(raw.id);
|
|
392
|
+
this.channel = String(raw.channel);
|
|
393
|
+
this.applyFull((raw.documents ?? []).map((d) => toSnapshot(d)), true);
|
|
394
|
+
const ttl = Number(raw.ttl_seconds ?? 600);
|
|
395
|
+
this.renewTimer = setInterval(() => {
|
|
396
|
+
this.sdk.request('POST', `${BASE}/_listeners/${this.id}/renew`).catch((error) => this.onError?.(error));
|
|
397
|
+
}, Math.max(30, ttl / 2) * 1000);
|
|
398
|
+
this.socket = this.sdk.openChannel(this.channel, {
|
|
399
|
+
onMessage: (payload) => void this.applyMessage(payload),
|
|
400
|
+
onStatus: (status) => {
|
|
401
|
+
// Changes published while the socket was (re)connecting: catch up with one query.
|
|
402
|
+
if (status === 'live')
|
|
403
|
+
void this.resync();
|
|
404
|
+
},
|
|
405
|
+
onError: (error) => this.onError?.(error),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
emit(changes) {
|
|
409
|
+
try {
|
|
410
|
+
this.callback(querySnapshot(this.order.map((id) => this.docs.get(id)), changes));
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
this.onError?.(error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
applyFull(docs, initial = false) {
|
|
417
|
+
// In a collection group the same id can appear under two parents: key by path there.
|
|
418
|
+
const key = (d) => (this.query.isGroup ? d.path : d.id);
|
|
419
|
+
const oldIndex = new Map(this.order.map((id, i) => [id, i]));
|
|
420
|
+
const changes = [];
|
|
421
|
+
const ids = new Set(docs.map(key));
|
|
422
|
+
for (const [id, i] of oldIndex)
|
|
423
|
+
if (!ids.has(id))
|
|
424
|
+
changes.push({ type: 'removed', doc: this.docs.get(id), oldIndex: i, newIndex: -1 });
|
|
425
|
+
docs.forEach((d, i) => {
|
|
426
|
+
const prev = this.docs.get(key(d));
|
|
427
|
+
if (!prev)
|
|
428
|
+
changes.push({ type: 'added', doc: d, oldIndex: -1, newIndex: i });
|
|
429
|
+
else if (prev.version !== d.version)
|
|
430
|
+
changes.push({ type: 'modified', doc: d, oldIndex: oldIndex.get(key(d)) ?? -1, newIndex: i });
|
|
431
|
+
});
|
|
432
|
+
this.docs = new Map(docs.map((d) => [key(d), d]));
|
|
433
|
+
this.order = docs.map(key);
|
|
434
|
+
if (changes.length || initial)
|
|
435
|
+
this.emit(changes);
|
|
436
|
+
}
|
|
437
|
+
async resync() {
|
|
438
|
+
if (this.closed)
|
|
439
|
+
return;
|
|
440
|
+
try {
|
|
441
|
+
this.applyFull((await this.query.get()).docs);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
this.onError?.(error);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async applyMessage(msg) {
|
|
448
|
+
// Offline mode: a local write or a sync asks the listener to re-read (served from the cache offline).
|
|
449
|
+
if (msg.type === 'local_resync' && (!msg.listener_id || msg.listener_id === this.id))
|
|
450
|
+
return this.resync();
|
|
451
|
+
if (msg.type !== 'doc_changes' || msg.listener_id !== this.id)
|
|
452
|
+
return;
|
|
453
|
+
const seq = Number(msg.seq ?? 0);
|
|
454
|
+
if (seq <= this.seq && (msg.part ?? 1) === 1)
|
|
455
|
+
return;
|
|
456
|
+
if (seq > this.seq + 1 || msg.resync || (msg.changes ?? []).some((c) => c.data_omitted)) {
|
|
457
|
+
this.seq = seq;
|
|
458
|
+
await this.resync();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
// In a collection group the same id can live under two parents: documents are keyed by path there.
|
|
462
|
+
const keyOf = (c) => String(this.query.isGroup ? c.path ?? c.id : c.id);
|
|
463
|
+
for (const c of msg.changes ?? []) {
|
|
464
|
+
if (c.type === 'removed') {
|
|
465
|
+
const gone = this.docs.get(keyOf(c));
|
|
466
|
+
if (gone) {
|
|
467
|
+
this.docs.delete(keyOf(c));
|
|
468
|
+
this.pending.push({ type: 'removed', doc: gone, oldIndex: Number(c.old_index ?? -1), newIndex: -1 });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
const snap = toSnapshot(c);
|
|
473
|
+
this.docs.set(this.query.isGroup ? snap.path : snap.id, snap);
|
|
474
|
+
this.pending.push({ type: c.type, doc: snap, oldIndex: Number(c.old_index ?? -1), newIndex: Number(c.new_index ?? -1) });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if ((msg.part ?? 1) < (msg.parts ?? 1))
|
|
478
|
+
return;
|
|
479
|
+
this.seq = seq;
|
|
480
|
+
if (msg.ids)
|
|
481
|
+
this.order = msg.ids.filter((id) => this.docs.has(id));
|
|
482
|
+
else
|
|
483
|
+
this.order = this.order.filter((id) => this.docs.has(id)).concat([...this.docs.keys()].filter((id) => !this.order.includes(id)));
|
|
484
|
+
for (const id of [...this.docs.keys()])
|
|
485
|
+
if (!this.order.includes(id))
|
|
486
|
+
this.docs.delete(id);
|
|
487
|
+
const changes = this.pending;
|
|
488
|
+
this.pending = [];
|
|
489
|
+
this.emit(changes);
|
|
490
|
+
}
|
|
491
|
+
close() {
|
|
492
|
+
this.closed = true;
|
|
493
|
+
if (this.renewTimer)
|
|
494
|
+
clearInterval(this.renewTimer);
|
|
495
|
+
if (this.pollTimer)
|
|
496
|
+
clearInterval(this.pollTimer);
|
|
497
|
+
this.socket?.close();
|
|
498
|
+
if (this.id)
|
|
499
|
+
this.sdk.request('DELETE', `${BASE}/_listeners/${this.id}`).catch(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// ── batches and transactions ──────────────────────────────────────────────────
|
|
503
|
+
export function commitWrites(sdk, writes, reads = []) {
|
|
504
|
+
return sdk.request('POST', `${BASE}/_commit`, { body: { writes, reads } });
|
|
505
|
+
}
|
|
506
|
+
/** Writes committed together: every one or none (up to the plan's docs.max_transaction_writes). */
|
|
507
|
+
export class WriteBatch {
|
|
508
|
+
sdk;
|
|
509
|
+
writes = [];
|
|
510
|
+
constructor(sdk) {
|
|
511
|
+
this.sdk = sdk;
|
|
512
|
+
}
|
|
513
|
+
set(ref, data, opts = {}) {
|
|
514
|
+
this.writes.push(setWrite(ref.path, data, opts));
|
|
515
|
+
return this;
|
|
516
|
+
}
|
|
517
|
+
create(ref, data) {
|
|
518
|
+
this.writes.push(setWrite(ref.path, data, { op: 'create' }));
|
|
519
|
+
return this;
|
|
520
|
+
}
|
|
521
|
+
update(ref, fields) {
|
|
522
|
+
this.writes.push(updateWrite(ref.path, fields));
|
|
523
|
+
return this;
|
|
524
|
+
}
|
|
525
|
+
delete(ref) {
|
|
526
|
+
this.writes.push({ op: 'delete', path: ref.path });
|
|
527
|
+
return this;
|
|
528
|
+
}
|
|
529
|
+
commit() {
|
|
530
|
+
return commitWrites(this.sdk, this.writes);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
export class Transaction extends WriteBatch {
|
|
534
|
+
reads = new Map();
|
|
535
|
+
async get(ref) {
|
|
536
|
+
if (this.writes.length)
|
|
537
|
+
throw SovariaError.config('Read every document before the first write in a transaction');
|
|
538
|
+
const raw = await this.sdk.request('POST', `${BASE}/_get`, { body: { paths: [ref.path] } });
|
|
539
|
+
const snap = toSnapshot(raw.documents[0]);
|
|
540
|
+
this.reads.set(ref.path, snap.version);
|
|
541
|
+
return snap;
|
|
542
|
+
}
|
|
543
|
+
get hasWrites() {
|
|
544
|
+
return this.writes.length > 0;
|
|
545
|
+
}
|
|
546
|
+
commit() {
|
|
547
|
+
return commitWrites(this.sdk, this.writes, [...this.reads].map(([path, version]) => ({ path, version })));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/** Run `fn` and commit its writes atomically; retried when a document it read changed meanwhile. */
|
|
551
|
+
export async function runTransaction(sdk, fn, maxAttempts = 5) {
|
|
552
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
553
|
+
const tx = new Transaction(sdk);
|
|
554
|
+
const result = await fn(tx);
|
|
555
|
+
if (!tx.hasWrites)
|
|
556
|
+
return result;
|
|
557
|
+
try {
|
|
558
|
+
await tx.commit();
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
// Contention only; ALREADY_EXISTS / NOT_FOUND / FAILED_PRECONDITION are final.
|
|
563
|
+
if (!(error instanceof SovariaError) || error.statusCode !== 409 || error.serverCode !== 'ABORTED')
|
|
564
|
+
throw error;
|
|
565
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(2000, 50 * 2 ** attempt) * (0.5 + Math.random())));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
throw new SovariaError(409, { code: 'ABORTED', message: `Transaction still conflicting after ${maxAttempts} attempts` });
|
|
569
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Complete API namespace factories for SovariaSDK parity with console endpoints.
|
|
3
|
+
*/
|
|
4
|
+
import type { JsonObject, JsonValue, RequestOptions } from './index.js';
|
|
5
|
+
type RequestFn = <T = JsonValue>(method: string, path: string, options?: RequestOptions) => Promise<T>;
|
|
6
|
+
export declare function createKeysApi(request: RequestFn): {
|
|
7
|
+
list: () => Promise<JsonValue>;
|
|
8
|
+
create: (payload: JsonObject) => Promise<JsonObject>;
|
|
9
|
+
ensureDefaults: () => Promise<JsonObject>;
|
|
10
|
+
revoke: (keyId: string) => Promise<JsonObject>;
|
|
11
|
+
};
|
|
12
|
+
export declare function createGovernanceApi(request: RequestFn): {
|
|
13
|
+
listRegions: () => Promise<JsonValue>;
|
|
14
|
+
listAvailableRegions: (query?: {
|
|
15
|
+
size?: string;
|
|
16
|
+
plan?: string;
|
|
17
|
+
}) => Promise<JsonValue>;
|
|
18
|
+
patchRegion: (regionId: string, body: JsonObject) => Promise<JsonObject>;
|
|
19
|
+
listTenants: () => Promise<JsonValue>;
|
|
20
|
+
getTenant: (orgId: string) => Promise<JsonObject>;
|
|
21
|
+
setTenantStatus: (orgId: string, body: JsonObject) => Promise<JsonObject>;
|
|
22
|
+
listTenantDatabases: (orgId: string) => Promise<JsonValue>;
|
|
23
|
+
provisionDatabase: (orgId: string, body: JsonObject) => Promise<JsonObject>;
|
|
24
|
+
getDatabaseConnection: (orgId: string, projectId: string) => Promise<JsonObject>;
|
|
25
|
+
/** Staff only: the connection string, with the active access grant that allows revealing it. */
|
|
26
|
+
revealDatabaseConnection: (orgId: string, projectId: string, grantId: string) => Promise<JsonObject>;
|
|
27
|
+
pauseDatabase: (orgId: string, projectId: string) => Promise<JsonObject>;
|
|
28
|
+
resumeDatabase: (orgId: string, projectId: string) => Promise<JsonObject>;
|
|
29
|
+
resizeDatabase: (orgId: string, projectId: string, body: JsonObject) => Promise<JsonObject>;
|
|
30
|
+
deleteDatabase: (orgId: string, projectId: string, snapshot?: boolean) => Promise<JsonObject>;
|
|
31
|
+
listDatabaseBackups: (orgId: string, projectId: string, limit?: number) => Promise<JsonValue>;
|
|
32
|
+
triggerDatabaseBackup: (orgId: string, projectId: string) => Promise<JsonObject>;
|
|
33
|
+
/**
|
|
34
|
+
* Download a completed backup (plain SQL, gzip) as a Blob — the whole database, so owners, admins and
|
|
35
|
+
* developers only (`database.backup.read`). Restore it anywhere with `gunzip -c file | psql`.
|
|
36
|
+
*/
|
|
37
|
+
downloadDatabaseBackup: (orgId: string, projectId: string, backupId: number | string) => Promise<Blob>;
|
|
38
|
+
/** Download a portable export bundle (tar.gz: Postgres dump + migration metadata) as a Blob. */
|
|
39
|
+
downloadProjectExport: (orgId: string, projectId: string, exportId: string) => Promise<Blob>;
|
|
40
|
+
adminListDatabases: () => Promise<JsonValue>;
|
|
41
|
+
listHosts: (regionId?: string) => Promise<JsonValue>;
|
|
42
|
+
patchHost: (hostId: string, body: JsonObject) => Promise<JsonObject>;
|
|
43
|
+
metricsOverview: () => Promise<JsonObject>;
|
|
44
|
+
projectMetrics: (projectId: string, since?: string, limit?: number) => Promise<JsonObject>;
|
|
45
|
+
};
|
|
46
|
+
export declare function createAdminApi(request: RequestFn): {
|
|
47
|
+
listErrorTraces: (query?: Record<string, string | number | boolean | null | undefined>) => Promise<JsonValue>;
|
|
48
|
+
getErrorTrace: (traceId: string) => Promise<JsonObject>;
|
|
49
|
+
resolveErrorTrace: (traceId: string, body?: JsonObject) => Promise<JsonObject>;
|
|
50
|
+
usageOverview: (period?: string, limit?: number) => Promise<JsonObject>;
|
|
51
|
+
listAccessTransparency: (query: Record<string, string | number | boolean | null | undefined>) => Promise<JsonValue>;
|
|
52
|
+
gatewayHealth: () => Promise<JsonValue>;
|
|
53
|
+
};
|
|
54
|
+
export {};
|