@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,189 @@
1
+ type Json = string | number | boolean | null | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ type JsonObject = {
5
+ [key: string]: Json;
6
+ };
7
+ /** What the collection API needs from the SDK (keeps this module free of a circular import). */
8
+ export interface DocumentsTransport {
9
+ request<T = unknown>(method: string, path: string, options?: {
10
+ body?: unknown;
11
+ query?: Record<string, string | number | boolean | null | undefined>;
12
+ }): Promise<T>;
13
+ openChannel(name: string, handlers: {
14
+ onMessage?: (payload: JsonObject) => void;
15
+ onStatus?: (status: string) => void;
16
+ onError?: (error: unknown) => void;
17
+ }): {
18
+ close: () => void;
19
+ };
20
+ }
21
+ export type WhereOp = '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any';
22
+ export type Direction = 'asc' | 'desc';
23
+ declare const TRANSFORM: unique symbol;
24
+ export interface FieldTransform {
25
+ readonly [TRANSFORM]: true;
26
+ readonly op: string;
27
+ readonly value?: unknown;
28
+ }
29
+ /** Atomic field updates, applied by the server inside the commit. */
30
+ export declare const FieldValue: {
31
+ increment: (n: number) => FieldTransform;
32
+ maximum: (n: number) => FieldTransform;
33
+ minimum: (n: number) => FieldTransform;
34
+ arrayUnion: (...elements: unknown[]) => FieldTransform;
35
+ arrayRemove: (...elements: unknown[]) => FieldTransform;
36
+ serverTimestamp: () => FieldTransform;
37
+ delete: () => FieldTransform;
38
+ };
39
+ /** Pull FieldValue sentinels out of nested data into the API's `transforms` list. */
40
+ export declare function splitTransforms(data: Record<string, unknown>, prefix?: string[]): {
41
+ data: Record<string, unknown>;
42
+ transforms: JsonObject[];
43
+ };
44
+ export interface DocumentSnapshot<T = JsonObject> {
45
+ id: string;
46
+ path: string;
47
+ exists: boolean;
48
+ data: T | null;
49
+ version: number;
50
+ updateTime?: string;
51
+ createTime?: string;
52
+ /** Offline mode: read from the local cache (the server couldn't be reached). */
53
+ fromCache?: boolean;
54
+ /** Offline mode: a local write that hasn't reached the server yet. */
55
+ hasPendingWrites?: boolean;
56
+ }
57
+ export interface DocumentChange<T = JsonObject> {
58
+ type: 'added' | 'modified' | 'removed';
59
+ doc: DocumentSnapshot<T>;
60
+ oldIndex: number;
61
+ newIndex: number;
62
+ }
63
+ export interface QuerySnapshot<T = JsonObject> {
64
+ docs: DocumentSnapshot<T>[];
65
+ changes: DocumentChange<T>[];
66
+ hasMore: boolean;
67
+ /** Pass to `startAfter(cursor)` for the next page. */
68
+ nextCursor: {
69
+ values: Json[];
70
+ } | null;
71
+ /** A composite index that would serve this query (create it in Collections › Indexes). */
72
+ indexHint?: {
73
+ collection: string;
74
+ fields: {
75
+ path: string;
76
+ order: string;
77
+ }[];
78
+ };
79
+ empty: boolean;
80
+ size: number;
81
+ }
82
+ export declare class DocumentReference<T = JsonObject> {
83
+ private readonly sdk;
84
+ readonly path: string;
85
+ readonly id: string;
86
+ constructor(sdk: DocumentsTransport, path: string);
87
+ get parent(): Query<T>;
88
+ collection<U = JsonObject>(name: string): Query<U>;
89
+ get(): Promise<DocumentSnapshot<T>>;
90
+ private commitOne;
91
+ /** Replace the document (or merge into it with `{ merge: true }`). */
92
+ set(data: Partial<T> & Record<string, unknown>, opts?: {
93
+ merge?: boolean;
94
+ mergeFields?: string[];
95
+ }): Promise<JsonObject>;
96
+ /** Create it; fails with 409 ALREADY_EXISTS when it exists. */
97
+ create(data: Partial<T> & Record<string, unknown>): Promise<JsonObject>;
98
+ /** Change fields (dotted paths allowed); fails with 404 when the document doesn't exist. */
99
+ update(fields: Record<string, unknown>, opts?: {
100
+ version?: number;
101
+ }): Promise<JsonObject>;
102
+ delete(opts?: {
103
+ version?: number;
104
+ }): Promise<void>;
105
+ /** Live document: `callback` runs now and after every change. Returns a function that stops it. */
106
+ onSnapshot(callback: (doc: DocumentSnapshot<T>) => void, onError?: (error: unknown) => void): Promise<() => void>;
107
+ }
108
+ export declare class Query<T = JsonObject> {
109
+ private readonly sdk;
110
+ private readonly body;
111
+ readonly path: string;
112
+ readonly id: string;
113
+ constructor(sdk: DocumentsTransport, path: string, body?: JsonObject);
114
+ private with;
115
+ where(field: string, op: WhereOp, value: unknown): Query<T>;
116
+ orderBy(field: string, direction?: Direction): Query<T>;
117
+ limit(n: number): Query<T>;
118
+ limitToLast(n: number): Query<T>;
119
+ private static cursor;
120
+ startAt(...values: unknown[]): Query<T>;
121
+ startAfter(...values: unknown[]): Query<T>;
122
+ endAt(...values: unknown[]): Query<T>;
123
+ endBefore(...values: unknown[]): Query<T>;
124
+ doc(id?: string): DocumentReference<T>;
125
+ add(data: Partial<T> & Record<string, unknown>): Promise<DocumentReference<T>>;
126
+ toJSON(): JsonObject;
127
+ get(): Promise<QuerySnapshot<T>>;
128
+ count(): Promise<number>;
129
+ /** Every matching document, one request per page. */
130
+ pages(pageSize?: number): AsyncGenerator<DocumentSnapshot<T>>;
131
+ /** Live results: `callback` runs with the first snapshot and after every change. Await it for the stop function. */
132
+ onSnapshot(callback: (snap: QuerySnapshot<T>) => void, onError?: (error: unknown) => void): Promise<() => void>;
133
+ }
134
+ export declare function newDocumentId(): string;
135
+ interface ChangeMessage {
136
+ type?: string;
137
+ listener_id?: string;
138
+ seq?: number;
139
+ part?: number;
140
+ parts?: number;
141
+ changes?: JsonObject[];
142
+ ids?: string[];
143
+ resync?: boolean;
144
+ }
145
+ export declare class LiveQuery<T = JsonObject> {
146
+ private readonly sdk;
147
+ private readonly query;
148
+ private readonly callback;
149
+ private readonly onError?;
150
+ id: string;
151
+ channel: string;
152
+ private docs;
153
+ private order;
154
+ private seq;
155
+ private pending;
156
+ private renewTimer?;
157
+ private socket?;
158
+ private closed;
159
+ constructor(sdk: DocumentsTransport, query: Query<T>, callback: (snap: QuerySnapshot<T>) => void, onError?: ((error: unknown) => void) | undefined);
160
+ start(): Promise<void>;
161
+ private emit;
162
+ applyFull(docs: DocumentSnapshot<T>[], initial?: boolean): void;
163
+ resync(): Promise<void>;
164
+ applyMessage(msg: ChangeMessage): Promise<void>;
165
+ close(): void;
166
+ }
167
+ export declare function commitWrites(sdk: DocumentsTransport, writes: JsonObject[], reads?: JsonObject[]): Promise<JsonObject>;
168
+ /** Writes committed together: every one or none (up to the plan's docs.max_transaction_writes). */
169
+ export declare class WriteBatch {
170
+ protected readonly sdk: DocumentsTransport;
171
+ protected writes: JsonObject[];
172
+ constructor(sdk: DocumentsTransport);
173
+ set(ref: DocumentReference<any>, data: Record<string, unknown>, opts?: {
174
+ merge?: boolean;
175
+ }): this;
176
+ create(ref: DocumentReference<any>, data: Record<string, unknown>): this;
177
+ update(ref: DocumentReference<any>, fields: Record<string, unknown>): this;
178
+ delete(ref: DocumentReference<any>): this;
179
+ commit(): Promise<JsonObject>;
180
+ }
181
+ export declare class Transaction extends WriteBatch {
182
+ private reads;
183
+ get<T = JsonObject>(ref: DocumentReference<T>): Promise<DocumentSnapshot<T>>;
184
+ get hasWrites(): boolean;
185
+ commit(): Promise<JsonObject>;
186
+ }
187
+ /** Run `fn` and commit its writes atomically; retried when a document it read changed meanwhile. */
188
+ export declare function runTransaction<R>(sdk: DocumentsTransport, fn: (tx: Transaction) => Promise<R>, maxAttempts?: number): Promise<R>;
189
+ export {};
@@ -0,0 +1,467 @@
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
+ // ── field values ──────────────────────────────────────────────────────────────
31
+ const TRANSFORM = Symbol('sovaria.fieldValue');
32
+ function transform(op, value) {
33
+ return { [TRANSFORM]: true, op, value };
34
+ }
35
+ function isTransform(value) {
36
+ return typeof value === 'object' && value !== null && value[TRANSFORM] === true;
37
+ }
38
+ /** Atomic field updates, applied by the server inside the commit. */
39
+ export const FieldValue = {
40
+ increment: (n) => transform('increment', n),
41
+ maximum: (n) => transform('maximum', n),
42
+ minimum: (n) => transform('minimum', n),
43
+ arrayUnion: (...elements) => transform('array_union', elements),
44
+ arrayRemove: (...elements) => transform('array_remove', elements),
45
+ serverTimestamp: () => transform('server_timestamp'),
46
+ delete: () => transform('delete'),
47
+ };
48
+ function fieldPath(parts) {
49
+ return parts.map((p) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(p) ? p : '`' + p.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`')).join('.');
50
+ }
51
+ /** Pull FieldValue sentinels out of nested data into the API's `transforms` list. */
52
+ export function splitTransforms(data, prefix = []) {
53
+ const clean = {};
54
+ const transforms = [];
55
+ for (const [key, value] of Object.entries(data)) {
56
+ const path = [...prefix, key];
57
+ if (isTransform(value)) {
58
+ transforms.push({ field: fieldPath(path), op: value.op, value: (value.value ?? null) });
59
+ }
60
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
61
+ const inner = splitTransforms(value, path);
62
+ transforms.push(...inner.transforms);
63
+ if (Object.keys(inner.data).length || !inner.transforms.length)
64
+ clean[key] = inner.data;
65
+ }
66
+ else {
67
+ clean[key] = value;
68
+ }
69
+ }
70
+ return { data: clean, transforms };
71
+ }
72
+ function setWrite(path, data, opts = {}) {
73
+ const split = splitTransforms(data);
74
+ const write = { op: opts.op ?? 'set', path, data: split.data, transforms: split.transforms };
75
+ if (opts.merge)
76
+ write.merge = true;
77
+ if (opts.mergeFields?.length)
78
+ write.merge_fields = opts.mergeFields;
79
+ return write;
80
+ }
81
+ function updateWrite(path, fields) {
82
+ const data = {};
83
+ const transforms = [];
84
+ for (const [key, value] of Object.entries(fields)) {
85
+ if (isTransform(value))
86
+ transforms.push({ field: key, op: value.op, value: (value.value ?? null) });
87
+ else
88
+ data[key] = value;
89
+ }
90
+ return { op: 'update', path, data, transforms };
91
+ }
92
+ function toSnapshot(raw) {
93
+ const exists = raw.exists !== false && raw.data !== null && raw.data !== undefined;
94
+ const path = String(raw.path ?? '');
95
+ return {
96
+ id: String(raw.id ?? path.split('/').pop() ?? ''),
97
+ path,
98
+ exists,
99
+ data: exists ? raw.data : null,
100
+ version: Number(raw.version ?? 0),
101
+ updateTime: raw.update_time ? String(raw.update_time) : undefined,
102
+ createTime: raw.create_time ? String(raw.create_time) : undefined,
103
+ ...(raw.from_cache ? { fromCache: true } : {}),
104
+ ...(raw.pending ? { hasPendingWrites: true } : {}),
105
+ };
106
+ }
107
+ function querySnapshot(docs, changes = [], extra = {}) {
108
+ return { docs, changes, hasMore: false, nextCursor: null, ...extra, empty: docs.length === 0, size: docs.length };
109
+ }
110
+ export class DocumentReference {
111
+ sdk;
112
+ path;
113
+ id;
114
+ constructor(sdk, path) {
115
+ this.sdk = sdk;
116
+ const parts = path.split('/').filter(Boolean);
117
+ if (parts.length < 2 || parts.length % 2)
118
+ throw SovariaError.config('A document path is collection/id (or collection/id/sub/id)');
119
+ this.path = parts.join('/');
120
+ this.id = parts[parts.length - 1];
121
+ }
122
+ get parent() {
123
+ return new Query(this.sdk, this.path.split('/').slice(0, -1).join('/'));
124
+ }
125
+ collection(name) {
126
+ return new Query(this.sdk, `${this.path}/${name}`);
127
+ }
128
+ async get() {
129
+ try {
130
+ const raw = await this.sdk.request('GET', `${BASE}/${quotePath(this.path)}`);
131
+ return toSnapshot({ ...raw, id: this.id, path: this.path });
132
+ }
133
+ catch (error) {
134
+ if (error instanceof SovariaError && error.statusCode === 404) {
135
+ return { id: this.id, path: this.path, exists: false, data: null, version: 0 };
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+ async commitOne(write) {
141
+ const out = await commitWrites(this.sdk, [write]);
142
+ return out.results[0];
143
+ }
144
+ /** Replace the document (or merge into it with `{ merge: true }`). */
145
+ set(data, opts = {}) {
146
+ return this.commitOne(setWrite(this.path, data, opts));
147
+ }
148
+ /** Create it; fails with 409 ALREADY_EXISTS when it exists. */
149
+ create(data) {
150
+ return this.commitOne(setWrite(this.path, data, { op: 'create' }));
151
+ }
152
+ /** Change fields (dotted paths allowed); fails with 404 when the document doesn't exist. */
153
+ update(fields, opts = {}) {
154
+ const write = updateWrite(this.path, fields);
155
+ if (opts.version !== undefined)
156
+ write.precondition = { version: opts.version };
157
+ return this.commitOne(write);
158
+ }
159
+ async delete(opts = {}) {
160
+ const write = { op: 'delete', path: this.path };
161
+ if (opts.version !== undefined)
162
+ write.precondition = { version: opts.version };
163
+ await commitWrites(this.sdk, [write]);
164
+ }
165
+ /** Live document: `callback` runs now and after every change. Returns a function that stops it. */
166
+ onSnapshot(callback, onError) {
167
+ return this.parent.where('__key__', '==', this.id).onSnapshot((snap) => {
168
+ callback(snap.docs[0] ?? { id: this.id, path: this.path, exists: false, data: null, version: 0 });
169
+ }, onError);
170
+ }
171
+ }
172
+ export class Query {
173
+ sdk;
174
+ body;
175
+ path;
176
+ id;
177
+ constructor(sdk, path, body = {}) {
178
+ this.sdk = sdk;
179
+ this.body = body;
180
+ const parts = path.split('/').filter(Boolean);
181
+ if (!parts.length || parts.length % 2 === 0)
182
+ throw SovariaError.config('A collection path is collection or collection/id/subcollection');
183
+ this.path = parts.join('/');
184
+ this.id = parts[parts.length - 1];
185
+ }
186
+ with(changes) {
187
+ const body = { ...this.body };
188
+ for (const [k, v] of Object.entries(changes)) {
189
+ if (v === undefined)
190
+ delete body[k];
191
+ else
192
+ body[k] = v;
193
+ }
194
+ return new Query(this.sdk, this.path, body);
195
+ }
196
+ where(field, op, value) {
197
+ return this.with({ where: [...(this.body.where ?? []), [field, op, value]] });
198
+ }
199
+ orderBy(field, direction = 'asc') {
200
+ return this.with({ order_by: [...(this.body.order_by ?? []), [field, direction]] });
201
+ }
202
+ limit(n) {
203
+ return this.with({ limit: n, limit_to_last: undefined });
204
+ }
205
+ limitToLast(n) {
206
+ return this.with({ limit_to_last: n, limit: undefined });
207
+ }
208
+ static cursor(args) {
209
+ const [first] = args;
210
+ if (args.length === 1 && first && typeof first === 'object' && !Array.isArray(first)) {
211
+ const obj = first;
212
+ if ('exists' in obj && typeof obj.id === 'string')
213
+ return { key: obj.id }; // a DocumentSnapshot
214
+ if (obj.values || obj.key)
215
+ return { values: obj.values, key: obj.key };
216
+ }
217
+ return { values: args };
218
+ }
219
+ startAt(...values) { return this.with({ start_at: Query.cursor(values), start_after: undefined }); }
220
+ startAfter(...values) { return this.with({ start_after: Query.cursor(values), start_at: undefined }); }
221
+ endAt(...values) { return this.with({ end_at: Query.cursor(values), end_before: undefined }); }
222
+ endBefore(...values) { return this.with({ end_before: Query.cursor(values), end_at: undefined }); }
223
+ doc(id) {
224
+ return new DocumentReference(this.sdk, `${this.path}/${id ?? newDocumentId()}`);
225
+ }
226
+ async add(data) {
227
+ const ref = this.doc();
228
+ await ref.create(data);
229
+ return ref;
230
+ }
231
+ toJSON() {
232
+ return JSON.parse(JSON.stringify(this.body));
233
+ }
234
+ async get() {
235
+ const raw = await this.sdk.request('POST', `${BASE}/_query/${quotePath(this.path)}`, { body: this.toJSON() });
236
+ const docs = (raw.documents ?? []).map((d) => toSnapshot(raw.from_cache ? { ...d, from_cache: true } : d));
237
+ return querySnapshot(docs, [], {
238
+ hasMore: Boolean(raw.has_more),
239
+ nextCursor: raw.next_cursor ?? null,
240
+ indexHint: raw.index_hint,
241
+ });
242
+ }
243
+ async count() {
244
+ const body = this.toJSON();
245
+ delete body.limit;
246
+ delete body.limit_to_last;
247
+ const raw = await this.sdk.request('POST', `${BASE}/_count/${quotePath(this.path)}`, { body });
248
+ return Number(raw.count ?? 0);
249
+ }
250
+ /** Every matching document, one request per page. */
251
+ async *pages(pageSize = 100) {
252
+ let query = this.limit(pageSize);
253
+ for (;;) {
254
+ const snap = await query.get();
255
+ yield* snap.docs;
256
+ if (!snap.hasMore || !snap.nextCursor)
257
+ return;
258
+ query = query.with({ start_after: snap.nextCursor, start_at: undefined });
259
+ }
260
+ }
261
+ /** Live results: `callback` runs with the first snapshot and after every change. Await it for the stop function. */
262
+ async onSnapshot(callback, onError) {
263
+ const listener = new LiveQuery(this.sdk, this, callback, onError);
264
+ await listener.start();
265
+ return () => listener.close();
266
+ }
267
+ }
268
+ export function newDocumentId() {
269
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
270
+ const bytes = new Uint8Array(20);
271
+ globalThis.crypto.getRandomValues(bytes);
272
+ return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');
273
+ }
274
+ export class LiveQuery {
275
+ sdk;
276
+ query;
277
+ callback;
278
+ onError;
279
+ id = '';
280
+ channel = '';
281
+ docs = new Map();
282
+ order = [];
283
+ seq = 0;
284
+ pending = [];
285
+ renewTimer;
286
+ socket;
287
+ closed = false;
288
+ constructor(sdk, query, callback, onError) {
289
+ this.sdk = sdk;
290
+ this.query = query;
291
+ this.callback = callback;
292
+ this.onError = onError;
293
+ }
294
+ async start() {
295
+ const raw = await this.sdk.request('POST', `${BASE}/_listen/${quotePath(this.query.path)}`, { body: this.query.toJSON() });
296
+ this.id = String(raw.id);
297
+ this.channel = String(raw.channel);
298
+ this.applyFull((raw.documents ?? []).map((d) => toSnapshot(d)), true);
299
+ const ttl = Number(raw.ttl_seconds ?? 600);
300
+ this.renewTimer = setInterval(() => {
301
+ this.sdk.request('POST', `${BASE}/_listeners/${this.id}/renew`).catch((error) => this.onError?.(error));
302
+ }, Math.max(30, ttl / 2) * 1000);
303
+ this.socket = this.sdk.openChannel(this.channel, {
304
+ onMessage: (payload) => void this.applyMessage(payload),
305
+ onStatus: (status) => {
306
+ // Changes published while the socket was (re)connecting: catch up with one query.
307
+ if (status === 'live')
308
+ void this.resync();
309
+ },
310
+ onError: (error) => this.onError?.(error),
311
+ });
312
+ }
313
+ emit(changes) {
314
+ try {
315
+ this.callback(querySnapshot(this.order.map((id) => this.docs.get(id)), changes));
316
+ }
317
+ catch (error) {
318
+ this.onError?.(error);
319
+ }
320
+ }
321
+ applyFull(docs, initial = false) {
322
+ const oldIndex = new Map(this.order.map((id, i) => [id, i]));
323
+ const changes = [];
324
+ const ids = new Set(docs.map((d) => d.id));
325
+ for (const [id, i] of oldIndex)
326
+ if (!ids.has(id))
327
+ changes.push({ type: 'removed', doc: this.docs.get(id), oldIndex: i, newIndex: -1 });
328
+ docs.forEach((d, i) => {
329
+ const prev = this.docs.get(d.id);
330
+ if (!prev)
331
+ changes.push({ type: 'added', doc: d, oldIndex: -1, newIndex: i });
332
+ else if (prev.version !== d.version)
333
+ changes.push({ type: 'modified', doc: d, oldIndex: oldIndex.get(d.id) ?? -1, newIndex: i });
334
+ });
335
+ this.docs = new Map(docs.map((d) => [d.id, d]));
336
+ this.order = docs.map((d) => d.id);
337
+ if (changes.length || initial)
338
+ this.emit(changes);
339
+ }
340
+ async resync() {
341
+ if (this.closed)
342
+ return;
343
+ try {
344
+ this.applyFull((await this.query.get()).docs);
345
+ }
346
+ catch (error) {
347
+ this.onError?.(error);
348
+ }
349
+ }
350
+ async applyMessage(msg) {
351
+ // Offline mode: a local write or a sync asks the listener to re-read (served from the cache offline).
352
+ if (msg.type === 'local_resync' && (!msg.listener_id || msg.listener_id === this.id))
353
+ return this.resync();
354
+ if (msg.type !== 'doc_changes' || msg.listener_id !== this.id)
355
+ return;
356
+ const seq = Number(msg.seq ?? 0);
357
+ if (seq <= this.seq && (msg.part ?? 1) === 1)
358
+ return;
359
+ if (seq > this.seq + 1 || msg.resync || (msg.changes ?? []).some((c) => c.data_omitted)) {
360
+ this.seq = seq;
361
+ await this.resync();
362
+ return;
363
+ }
364
+ for (const c of msg.changes ?? []) {
365
+ if (c.type === 'removed') {
366
+ const gone = this.docs.get(String(c.id));
367
+ if (gone) {
368
+ this.docs.delete(String(c.id));
369
+ this.pending.push({ type: 'removed', doc: gone, oldIndex: Number(c.old_index ?? -1), newIndex: -1 });
370
+ }
371
+ }
372
+ else {
373
+ const snap = toSnapshot(c);
374
+ this.docs.set(snap.id, snap);
375
+ this.pending.push({ type: c.type, doc: snap, oldIndex: Number(c.old_index ?? -1), newIndex: Number(c.new_index ?? -1) });
376
+ }
377
+ }
378
+ if ((msg.part ?? 1) < (msg.parts ?? 1))
379
+ return;
380
+ this.seq = seq;
381
+ if (msg.ids)
382
+ this.order = msg.ids.filter((id) => this.docs.has(id));
383
+ else
384
+ this.order = this.order.filter((id) => this.docs.has(id)).concat([...this.docs.keys()].filter((id) => !this.order.includes(id)));
385
+ for (const id of [...this.docs.keys()])
386
+ if (!this.order.includes(id))
387
+ this.docs.delete(id);
388
+ const changes = this.pending;
389
+ this.pending = [];
390
+ this.emit(changes);
391
+ }
392
+ close() {
393
+ this.closed = true;
394
+ if (this.renewTimer)
395
+ clearInterval(this.renewTimer);
396
+ this.socket?.close();
397
+ this.sdk.request('DELETE', `${BASE}/_listeners/${this.id}`).catch(() => undefined);
398
+ }
399
+ }
400
+ // ── batches and transactions ──────────────────────────────────────────────────
401
+ export function commitWrites(sdk, writes, reads = []) {
402
+ return sdk.request('POST', `${BASE}/_commit`, { body: { writes, reads } });
403
+ }
404
+ /** Writes committed together: every one or none (up to the plan's docs.max_transaction_writes). */
405
+ export class WriteBatch {
406
+ sdk;
407
+ writes = [];
408
+ constructor(sdk) {
409
+ this.sdk = sdk;
410
+ }
411
+ set(ref, data, opts = {}) {
412
+ this.writes.push(setWrite(ref.path, data, opts));
413
+ return this;
414
+ }
415
+ create(ref, data) {
416
+ this.writes.push(setWrite(ref.path, data, { op: 'create' }));
417
+ return this;
418
+ }
419
+ update(ref, fields) {
420
+ this.writes.push(updateWrite(ref.path, fields));
421
+ return this;
422
+ }
423
+ delete(ref) {
424
+ this.writes.push({ op: 'delete', path: ref.path });
425
+ return this;
426
+ }
427
+ commit() {
428
+ return commitWrites(this.sdk, this.writes);
429
+ }
430
+ }
431
+ export class Transaction extends WriteBatch {
432
+ reads = new Map();
433
+ async get(ref) {
434
+ if (this.writes.length)
435
+ throw SovariaError.config('Read every document before the first write in a transaction');
436
+ const raw = await this.sdk.request('POST', `${BASE}/_get`, { body: { paths: [ref.path] } });
437
+ const snap = toSnapshot(raw.documents[0]);
438
+ this.reads.set(ref.path, snap.version);
439
+ return snap;
440
+ }
441
+ get hasWrites() {
442
+ return this.writes.length > 0;
443
+ }
444
+ commit() {
445
+ return commitWrites(this.sdk, this.writes, [...this.reads].map(([path, version]) => ({ path, version })));
446
+ }
447
+ }
448
+ /** Run `fn` and commit its writes atomically; retried when a document it read changed meanwhile. */
449
+ export async function runTransaction(sdk, fn, maxAttempts = 5) {
450
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
451
+ const tx = new Transaction(sdk);
452
+ const result = await fn(tx);
453
+ if (!tx.hasWrites)
454
+ return result;
455
+ try {
456
+ await tx.commit();
457
+ return result;
458
+ }
459
+ catch (error) {
460
+ // Contention only; ALREADY_EXISTS / NOT_FOUND / FAILED_PRECONDITION are final.
461
+ if (!(error instanceof SovariaError) || error.statusCode !== 409 || error.serverCode !== 'ABORTED')
462
+ throw error;
463
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, 50 * 2 ** attempt) * (0.5 + Math.random())));
464
+ }
465
+ }
466
+ throw new SovariaError(409, { code: 'ABORTED', message: `Transaction still conflicting after ${maxAttempts} attempts` });
467
+ }