@rebasepro/client 0.17.3 → 0.18.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/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- package/src/websocket.ts +0 -1837
package/src/collection.ts
DELETED
|
@@ -1,525 +0,0 @@
|
|
|
1
|
-
import { buildQueryString, FindParams, RebaseApiError, Transport } from "./transport";
|
|
2
|
-
import { RebaseWebSocketClient } from "./websocket";
|
|
3
|
-
import {
|
|
4
|
-
FindAllParams,
|
|
5
|
-
FindResult,
|
|
6
|
-
IterateParams,
|
|
7
|
-
LogicalCondition,
|
|
8
|
-
SDKCollectionClient,
|
|
9
|
-
SDKQueryBuilderInterface,
|
|
10
|
-
WhereFilterOp,
|
|
11
|
-
WhereValueFor,
|
|
12
|
-
WriteOptions,
|
|
13
|
-
type ComputedSortField
|
|
14
|
-
} from "@rebasepro/types";
|
|
15
|
-
import { collectAllPages, normalizeOrderBy, paginateFind, resolveFindWindow } from "@rebasepro/common";
|
|
16
|
-
|
|
17
|
-
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
21
|
-
* live only for the duration of the request — see `count()` for why.
|
|
22
|
-
*/
|
|
23
|
-
const inflightCounts = new Map<string, Promise<number>>();
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* A live query result: a normal {@link FindResult} plus what an interface
|
|
27
|
-
* needs to decide whether to show a "saving…" or "offline" affordance over it.
|
|
28
|
-
*
|
|
29
|
-
* The three flags are always `false` on a client without offline support —
|
|
30
|
-
* every result there came straight from the server.
|
|
31
|
-
*/
|
|
32
|
-
export interface LiveResult<M extends Record<string, unknown>> extends FindResult<M> {
|
|
33
|
-
/** The data came from the local database, not from a completed request. */
|
|
34
|
-
fromCache: boolean;
|
|
35
|
-
/** At least one row here carries a write the server has not accepted yet. */
|
|
36
|
-
hasPendingWrites: boolean;
|
|
37
|
-
/**
|
|
38
|
-
* The local database may not hold every row the server would have
|
|
39
|
-
* returned, so treat this as a best effort rather than a complete answer.
|
|
40
|
-
*/
|
|
41
|
-
partial: boolean;
|
|
42
|
-
/** The most recent revalidation failure, when the last one failed. */
|
|
43
|
-
error?: Error;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Snapshot metadata for a single observed row. */
|
|
47
|
-
export interface RowSnapshotMeta {
|
|
48
|
-
fromCache: boolean;
|
|
49
|
-
hasPendingWrites: boolean;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface ObserveOptions {
|
|
53
|
-
/**
|
|
54
|
-
* Keep the subscription live off the realtime socket, so changes made by
|
|
55
|
-
* other clients arrive without a refetch. On by default whenever realtime
|
|
56
|
-
* is enabled on the client; pass `false` for a one-shot read that still
|
|
57
|
-
* reports offline/pending metadata.
|
|
58
|
-
*/
|
|
59
|
-
realtime?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The concrete, HTTP-backed implementation of the public
|
|
64
|
-
* {@link SDKCollectionClient} contract — flat rows (no Entity wrapper), plus
|
|
65
|
-
* fluent query-builder methods (`.where()`, `.orderBy()`, …).
|
|
66
|
-
*
|
|
67
|
-
* This is what `createRebaseClient().data.<collection>` returns. It is not a
|
|
68
|
-
* separate API from {@link SDKCollectionClient}; it only widens it with
|
|
69
|
-
* `count()` and the reactive `observe()` pair. Program against
|
|
70
|
-
* {@link SDKCollectionClient} when you want a transport-agnostic type.
|
|
71
|
-
*/
|
|
72
|
-
export interface CollectionClient<
|
|
73
|
-
M extends Record<string, unknown> = Record<string, unknown>,
|
|
74
|
-
I = Partial<M>,
|
|
75
|
-
U = Partial<M>
|
|
76
|
-
> extends SDKCollectionClient<M, I, U> {
|
|
77
|
-
count(params?: FindParams<M>): Promise<number>;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Subscribe to a query's results.
|
|
81
|
-
*
|
|
82
|
-
* This is the reactive read primitive, and the one to reach for in a UI:
|
|
83
|
-
* unlike `find()` it keeps emitting. On a client with `offline` enabled it
|
|
84
|
-
* is local-first — the first emission comes from the local database, with
|
|
85
|
-
* no request in the way — and re-emits on every local write, every queued
|
|
86
|
-
* write reaching the server, and every rollback. With realtime enabled it
|
|
87
|
-
* also re-emits on changes made by other clients.
|
|
88
|
-
*
|
|
89
|
-
* Emissions are de-duplicated: a refresh that changes nothing does not
|
|
90
|
-
* call back.
|
|
91
|
-
*
|
|
92
|
-
* @returns An unsubscribe function.
|
|
93
|
-
*/
|
|
94
|
-
observe(
|
|
95
|
-
params: FindParams<M> | undefined,
|
|
96
|
-
onResult: (result: LiveResult<M>) => void,
|
|
97
|
-
onError?: (error: Error) => void,
|
|
98
|
-
options?: ObserveOptions
|
|
99
|
-
): () => void;
|
|
100
|
-
|
|
101
|
-
/** {@link CollectionClient.observe} for a single row. */
|
|
102
|
-
observeById(
|
|
103
|
-
id: string | number,
|
|
104
|
-
onResult: (row: M | undefined, meta: RowSnapshotMeta) => void,
|
|
105
|
-
onError?: (error: Error) => void,
|
|
106
|
-
options?: ObserveOptions
|
|
107
|
-
): () => void;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function createCollectionClient<M extends Record<string, unknown> = Record<string, unknown>>(transport: Transport, slug: string, ws?: RebaseWebSocketClient): CollectionClient<M> {
|
|
111
|
-
const basePath = `/data/${slug}`;
|
|
112
|
-
|
|
113
|
-
const client: CollectionClient<M> = {
|
|
114
|
-
async find(params?: FindParams<M>): Promise<FindResult<M>> {
|
|
115
|
-
const qs = buildQueryString(params);
|
|
116
|
-
const raw = await transport.request<{
|
|
117
|
-
data: Record<string, unknown>[];
|
|
118
|
-
meta: FindResult<M>["meta"]
|
|
119
|
-
}>(basePath + qs, { method: "GET" });
|
|
120
|
-
return {
|
|
121
|
-
data: (raw.data || []) as M[],
|
|
122
|
-
meta: raw.meta
|
|
123
|
-
};
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
// The pagination engine lives in `@rebasepro/common`, shared with the
|
|
127
|
-
// in-process accessor: `iterate()` has to mean the same thing whichever
|
|
128
|
-
// transport the caller happens to be holding.
|
|
129
|
-
iterate(params?: IterateParams<M>) {
|
|
130
|
-
return paginateFind<M>((p) => client.find(p), params, slug);
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
findAll(params?: FindAllParams<M>) {
|
|
134
|
-
return collectAllPages<M>((p) => client.find(p), params, slug);
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
async findById(id: string | number) {
|
|
138
|
-
try {
|
|
139
|
-
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
|
|
140
|
-
if (!raw) return undefined;
|
|
141
|
-
return raw as M;
|
|
142
|
-
} catch (err) {
|
|
143
|
-
if (err instanceof RebaseApiError && err.status === 404) {
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
throw err;
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
|
|
150
|
-
async create(data: Partial<M>, id?: string | number, options?: WriteOptions) {
|
|
151
|
-
const body: Record<string, unknown> = { ...data };
|
|
152
|
-
if (id !== undefined) {
|
|
153
|
-
body.id = id;
|
|
154
|
-
}
|
|
155
|
-
const raw = await transport.request<Record<string, unknown>>(basePath, {
|
|
156
|
-
method: "POST",
|
|
157
|
-
body: JSON.stringify(body),
|
|
158
|
-
...(options?.idempotencyKey
|
|
159
|
-
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
160
|
-
: {})
|
|
161
|
-
});
|
|
162
|
-
return raw as M;
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
async createMany(data: Partial<M>[], options?: { upsert?: boolean } & WriteOptions) {
|
|
166
|
-
if (!Array.isArray(data)) {
|
|
167
|
-
throw new TypeError("createMany expects an array of records.");
|
|
168
|
-
}
|
|
169
|
-
if (data.length === 0) return [];
|
|
170
|
-
|
|
171
|
-
const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
|
|
172
|
-
method: "POST",
|
|
173
|
-
body: JSON.stringify({
|
|
174
|
-
rows: data,
|
|
175
|
-
...(options?.upsert ? { upsert: true } : {})
|
|
176
|
-
}),
|
|
177
|
-
...(options?.idempotencyKey
|
|
178
|
-
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
179
|
-
: {})
|
|
180
|
-
});
|
|
181
|
-
return (raw.data || []) as M[];
|
|
182
|
-
},
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* `PATCH`, the verb for a merge — `update(id, data: Partial<M>)` sends
|
|
186
|
-
* only the keys the caller named and the server merges the rest.
|
|
187
|
-
*
|
|
188
|
-
* It was `PUT` for a while, on a route that served both so older
|
|
189
|
-
* clients kept working. The alias is gone: the OpenAPI spec, the SDK
|
|
190
|
-
* and the route now name one verb, and a spec-validating gateway in
|
|
191
|
-
* front of the API sees the operation the server actually implements.
|
|
192
|
-
*/
|
|
193
|
-
async update(id: string | number, data: Partial<M>) {
|
|
194
|
-
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
195
|
-
method: "PATCH",
|
|
196
|
-
body: JSON.stringify(data)
|
|
197
|
-
});
|
|
198
|
-
return raw as M;
|
|
199
|
-
},
|
|
200
|
-
|
|
201
|
-
async updateMany(updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) {
|
|
202
|
-
if (!Array.isArray(updates)) {
|
|
203
|
-
throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
204
|
-
}
|
|
205
|
-
if (updates.length === 0) return [];
|
|
206
|
-
|
|
207
|
-
const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
|
|
208
|
-
method: "PATCH",
|
|
209
|
-
body: JSON.stringify({ updates }),
|
|
210
|
-
...(options?.idempotencyKey
|
|
211
|
-
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
212
|
-
: {})
|
|
213
|
-
});
|
|
214
|
-
return (raw.data || []) as M[];
|
|
215
|
-
},
|
|
216
|
-
|
|
217
|
-
async delete(id: string | number) {
|
|
218
|
-
await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
219
|
-
method: "DELETE"
|
|
220
|
-
});
|
|
221
|
-
},
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
225
|
-
*
|
|
226
|
-
* The honest verb would take the ids in a DELETE body, and that is the
|
|
227
|
-
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
228
|
-
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
229
|
-
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
230
|
-
* operation, so a generated client would send the request without its
|
|
231
|
-
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
232
|
-
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
233
|
-
*/
|
|
234
|
-
async deleteMany(ids: (string | number)[], options?: WriteOptions) {
|
|
235
|
-
if (!Array.isArray(ids)) {
|
|
236
|
-
throw new TypeError("deleteMany expects an array of ids.");
|
|
237
|
-
}
|
|
238
|
-
if (ids.length === 0) return;
|
|
239
|
-
|
|
240
|
-
await transport.request<void>(`${basePath}/bulk/delete`, {
|
|
241
|
-
method: "POST",
|
|
242
|
-
body: JSON.stringify({ ids }),
|
|
243
|
-
...(options?.idempotencyKey
|
|
244
|
-
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
245
|
-
: {})
|
|
246
|
-
});
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
async count(params?: FindParams<M>): Promise<number> {
|
|
250
|
-
const countParams: FindParams<M> = {
|
|
251
|
-
...params,
|
|
252
|
-
limit: undefined,
|
|
253
|
-
offset: undefined,
|
|
254
|
-
// A count reads no relation data, so `include` can only add
|
|
255
|
-
// joins — and a join that does not match drops rows, which
|
|
256
|
-
// would make the total disagree with the `find()` it describes.
|
|
257
|
-
// Same reasoning as limit/offset: parameters that cannot affect
|
|
258
|
-
// the answer are not forwarded.
|
|
259
|
-
include: undefined
|
|
260
|
-
};
|
|
261
|
-
const qs = buildQueryString(countParams);
|
|
262
|
-
|
|
263
|
-
// One count per query in flight, not one per caller.
|
|
264
|
-
//
|
|
265
|
-
// A count is a property of the query, and every concurrent caller
|
|
266
|
-
// asking the same question wants the same answer — so they can
|
|
267
|
-
// share the one request. This is not a micro-optimisation: a live
|
|
268
|
-
// subscription re-counts on every push (see `listen` below), and
|
|
269
|
-
// `listenCollection` deliberately collapses identical queries onto
|
|
270
|
-
// a single socket subscription while keeping one callback per
|
|
271
|
-
// subscriber. Each push therefore woke N subscribers, and each of
|
|
272
|
-
// them issued its own identical count. A table showing one relation
|
|
273
|
-
// column fired one count per visible cell, on every update.
|
|
274
|
-
//
|
|
275
|
-
// The entry is dropped as soon as it settles, so this merges
|
|
276
|
-
// concurrent calls only and never serves a cached total.
|
|
277
|
-
const key = basePath + "/count" + qs;
|
|
278
|
-
const inflight = inflightCounts.get(key);
|
|
279
|
-
if (inflight) return inflight;
|
|
280
|
-
|
|
281
|
-
const request = transport
|
|
282
|
-
.request<{ count: number }>(key, { method: "GET" })
|
|
283
|
-
.then((raw) => raw.count ?? 0);
|
|
284
|
-
inflightCounts.set(key, request);
|
|
285
|
-
try {
|
|
286
|
-
return await request;
|
|
287
|
-
} finally {
|
|
288
|
-
inflightCounts.delete(key);
|
|
289
|
-
}
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
// Reactive reads. Without the offline layer there is no local database
|
|
293
|
-
// to read from, so this is a fetch plus — when realtime is available —
|
|
294
|
-
// the live subscription that keeps it current.
|
|
295
|
-
observe(
|
|
296
|
-
params: FindParams<M> | undefined,
|
|
297
|
-
onResult: (result: LiveResult<M>) => void,
|
|
298
|
-
onError?: (error: Error) => void,
|
|
299
|
-
options?: ObserveOptions
|
|
300
|
-
) {
|
|
301
|
-
let closed = false;
|
|
302
|
-
// Two sources race into one callback: the one-shot fetch below and
|
|
303
|
-
// the subscription beside it. Whichever resolves last used to win,
|
|
304
|
-
// so a socket update that landed first was overwritten by the
|
|
305
|
-
// fetch's older snapshot and stayed wrong until the next change.
|
|
306
|
-
// `listenCollection` replays cached rows synchronously to a second
|
|
307
|
-
// subscriber, so a second component observing the same query hit
|
|
308
|
-
// that ordering every time.
|
|
309
|
-
let liveDelivered = false;
|
|
310
|
-
let signature: string | undefined;
|
|
311
|
-
|
|
312
|
-
const deliver = (result: FindResult<M>, fromLive: boolean) => {
|
|
313
|
-
if (closed) return;
|
|
314
|
-
// Once the socket has spoken, the fetch issued alongside it is
|
|
315
|
-
// older news — delivering it would move the app backwards.
|
|
316
|
-
if (fromLive) liveDelivered = true;
|
|
317
|
-
else if (liveDelivered) return;
|
|
318
|
-
// The de-duplication `observe()` documents. Keyed on the rows
|
|
319
|
-
// and the total, the same two things the offline layer keys on,
|
|
320
|
-
// so both implementations mean the same thing by "changed".
|
|
321
|
-
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
322
|
-
if (signature !== undefined && next === signature) return;
|
|
323
|
-
signature = next;
|
|
324
|
-
onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
328
|
-
if (!closed) onError?.(error as Error);
|
|
329
|
-
});
|
|
330
|
-
const live = options?.realtime !== false && client.listen
|
|
331
|
-
? client.listen(params, (result) => deliver(result, true), onError)
|
|
332
|
-
: undefined;
|
|
333
|
-
return () => {
|
|
334
|
-
closed = true;
|
|
335
|
-
live?.();
|
|
336
|
-
};
|
|
337
|
-
},
|
|
338
|
-
|
|
339
|
-
observeById(
|
|
340
|
-
id: string | number,
|
|
341
|
-
onResult: (row: M | undefined, meta: RowSnapshotMeta) => void,
|
|
342
|
-
onError?: (error: Error) => void,
|
|
343
|
-
options?: ObserveOptions
|
|
344
|
-
) {
|
|
345
|
-
let closed = false;
|
|
346
|
-
let liveDelivered = false;
|
|
347
|
-
let signature: string | undefined;
|
|
348
|
-
|
|
349
|
-
// Same ordering and de-duplication rules as `observe`, for one row.
|
|
350
|
-
const deliver = (row: M | undefined, fromLive: boolean) => {
|
|
351
|
-
if (closed) return;
|
|
352
|
-
if (fromLive) liveDelivered = true;
|
|
353
|
-
else if (liveDelivered) return;
|
|
354
|
-
const next = row === undefined ? "\u0000missing" : JSON.stringify(row);
|
|
355
|
-
if (signature !== undefined && next === signature) return;
|
|
356
|
-
signature = next;
|
|
357
|
-
onResult(row, { fromCache: false, hasPendingWrites: false });
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
361
|
-
if (!closed) onError?.(error as Error);
|
|
362
|
-
});
|
|
363
|
-
const live = options?.realtime !== false && client.listenById
|
|
364
|
-
? client.listenById(id, (row) => deliver(row, true), onError)
|
|
365
|
-
: undefined;
|
|
366
|
-
return () => {
|
|
367
|
-
closed = true;
|
|
368
|
-
live?.();
|
|
369
|
-
};
|
|
370
|
-
},
|
|
371
|
-
|
|
372
|
-
// Fluent builder instantiation
|
|
373
|
-
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
|
|
374
|
-
const builder = new SDKQueryBuilder<M>(client);
|
|
375
|
-
if (typeof columnOrCondition === "object") {
|
|
376
|
-
return builder.where(columnOrCondition);
|
|
377
|
-
}
|
|
378
|
-
return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
|
|
379
|
-
},
|
|
380
|
-
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc") {
|
|
381
|
-
return new SDKQueryBuilder<M>(client).orderBy(column, direction);
|
|
382
|
-
},
|
|
383
|
-
limit(count: number) {
|
|
384
|
-
return new SDKQueryBuilder<M>(client).limit(count);
|
|
385
|
-
},
|
|
386
|
-
offset(count: number) {
|
|
387
|
-
return new SDKQueryBuilder<M>(client).offset(count);
|
|
388
|
-
},
|
|
389
|
-
search(searchString: string, options?: { explain?: boolean }) {
|
|
390
|
-
return new SDKQueryBuilder<M>(client).search(searchString, options);
|
|
391
|
-
},
|
|
392
|
-
vectorSearch(
|
|
393
|
-
property: string,
|
|
394
|
-
vector: number[],
|
|
395
|
-
options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
|
|
396
|
-
) {
|
|
397
|
-
return new SDKQueryBuilder<M>(client).vectorSearch(property, vector, options);
|
|
398
|
-
},
|
|
399
|
-
include(...relations: string[]) {
|
|
400
|
-
return new SDKQueryBuilder<M>(client).include(...relations);
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
if (ws) {
|
|
405
|
-
client.listen = (params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
|
|
406
|
-
let active = true;
|
|
407
|
-
let lastUpdateId = 0;
|
|
408
|
-
// The last total a `count()` actually returned. A later count that
|
|
409
|
-
// fails says nothing about how big the collection is, so it must
|
|
410
|
-
// not be allowed to replace this with the length of one page.
|
|
411
|
-
let lastKnownTotal: number | undefined;
|
|
412
|
-
const window = resolveFindWindow(params);
|
|
413
|
-
const unsub = ws.listenCollection(
|
|
414
|
-
{
|
|
415
|
-
path: slug,
|
|
416
|
-
filter: params?.where,
|
|
417
|
-
// The group used to be dropped here, so a subscription
|
|
418
|
-
// filtered with `or(...)` was pushed every row instead.
|
|
419
|
-
logical: params?.logical,
|
|
420
|
-
limit: params?.limit,
|
|
421
|
-
// `offset` used to be stringified into `startAfter`, which
|
|
422
|
-
// is a cursor *row*, not a row count — so the server was
|
|
423
|
-
// handed "20" where it expected a keyset value, and the
|
|
424
|
-
// offset it does understand never arrived at all.
|
|
425
|
-
offset: window.driverOffset,
|
|
426
|
-
// The list form, so a multi-key sort reaches the socket
|
|
427
|
-
// whole. Indexing `[0]`/`[1]` here read a tuple-of-tuples as
|
|
428
|
-
// a field name and a direction, and a live subscription came
|
|
429
|
-
// back in a different order from the same query fetched.
|
|
430
|
-
orderBy: normalizeOrderBy(params?.orderBy),
|
|
431
|
-
searchString: params?.searchString,
|
|
432
|
-
searchExplain: params?.searchExplain,
|
|
433
|
-
// Forwarded so the SERVER can refuse it. `realtimeService`
|
|
434
|
-
// rejects a subscription carrying `vectorSearch` — a
|
|
435
|
-
// subscription is re-run on every matching write and
|
|
436
|
-
// nothing there computes distances — and the docs promise
|
|
437
|
-
// that refusal. Both producers hand-list their fields and
|
|
438
|
-
// both omitted this one, so the guard could not fire and
|
|
439
|
-
// `.vectorSearch(…).listen()` returned an ordinary
|
|
440
|
-
// `id DESC` listing with no `_distance` and no error.
|
|
441
|
-
vectorSearch: params?.vectorSearch
|
|
442
|
-
},
|
|
443
|
-
(incomingRows: Record<string, unknown>[]) => {
|
|
444
|
-
const currentUpdateId = ++lastUpdateId;
|
|
445
|
-
// What the server pages by when the caller names no limit.
|
|
446
|
-
// A hardcoded 20 here described a window the rows had not
|
|
447
|
-
// come from, and any app sizing its next request off
|
|
448
|
-
// `meta.limit` was told the wrong number.
|
|
449
|
-
const requestedLimit = window.limit;
|
|
450
|
-
const offset = window.offset;
|
|
451
|
-
|
|
452
|
-
// WS client already delivers flat rows — just cast
|
|
453
|
-
const rows = incomingRows as M[];
|
|
454
|
-
|
|
455
|
-
const emit = (total: number, hasMore: boolean) => {
|
|
456
|
-
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
457
|
-
onUpdate({
|
|
458
|
-
data: rows,
|
|
459
|
-
meta: {
|
|
460
|
-
total,
|
|
461
|
-
limit: requestedLimit,
|
|
462
|
-
offset,
|
|
463
|
-
hasMore
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
// With no count to go on, the only defensible total is a
|
|
469
|
-
// lower bound: the rows on this page plus the ones paged
|
|
470
|
-
// past to reach them. Reporting `rows.length` claimed a
|
|
471
|
-
// collection read at offset 10 held two rows.
|
|
472
|
-
const emitWithoutCount = () => emit(
|
|
473
|
-
offset + rows.length,
|
|
474
|
-
rows.length >= requestedLimit
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
if (client.count) {
|
|
478
|
-
client.count(params)
|
|
479
|
-
.then((total) => {
|
|
480
|
-
lastKnownTotal = total;
|
|
481
|
-
emit(total, offset + rows.length < total);
|
|
482
|
-
})
|
|
483
|
-
.catch(() => {
|
|
484
|
-
// A count that failed is not evidence about the
|
|
485
|
-
// size of the collection. Keep the last real
|
|
486
|
-
// answer; only guess if there has never been one.
|
|
487
|
-
if (lastKnownTotal !== undefined) {
|
|
488
|
-
emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
489
|
-
} else {
|
|
490
|
-
emitWithoutCount();
|
|
491
|
-
}
|
|
492
|
-
});
|
|
493
|
-
} else {
|
|
494
|
-
emitWithoutCount();
|
|
495
|
-
}
|
|
496
|
-
},
|
|
497
|
-
onError
|
|
498
|
-
);
|
|
499
|
-
|
|
500
|
-
return () => {
|
|
501
|
-
active = false;
|
|
502
|
-
unsub();
|
|
503
|
-
};
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
client.listenById = (id: string | number, onUpdate: (data: M | undefined) => void, onError?: (error: Error) => void) => {
|
|
507
|
-
return ws.listenOne(
|
|
508
|
-
{
|
|
509
|
-
path: slug,
|
|
510
|
-
id: String(id)
|
|
511
|
-
},
|
|
512
|
-
(row: Record<string, unknown> | null) => {
|
|
513
|
-
if (row) {
|
|
514
|
-
onUpdate(row as M);
|
|
515
|
-
} else {
|
|
516
|
-
onUpdate(undefined);
|
|
517
|
-
}
|
|
518
|
-
},
|
|
519
|
-
onError
|
|
520
|
-
);
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
return client;
|
|
525
|
-
}
|
package/src/cron.test.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, jest, beforeEach } from "@jest/globals";
|
|
2
|
-
import { createCron } from "./cron";
|
|
3
|
-
import type { Transport } from "./transport";
|
|
4
|
-
|
|
5
|
-
// ─── Mock Transport ─────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
function createMockTransport() {
|
|
8
|
-
const requestMock = jest.fn() as unknown as jest.MockedFunction<Transport["request"]>;
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
request: requestMock,
|
|
12
|
-
setToken: jest.fn(),
|
|
13
|
-
setAuthTokenGetter: jest.fn(),
|
|
14
|
-
setOnUnauthorized: jest.fn(),
|
|
15
|
-
get baseUrl() { return "http://test"; },
|
|
16
|
-
get apiPath() { return "/api"; },
|
|
17
|
-
get fetchFn() { return globalThis.fetch; },
|
|
18
|
-
getHeaders: () => ({}),
|
|
19
|
-
resolveToken: async () => null
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ─── Tests ──────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
describe("createCron", () => {
|
|
26
|
-
let transport: ReturnType<typeof createMockTransport>;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
transport = createMockTransport();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("listJobs", () => {
|
|
33
|
-
it("calls GET /cron", async () => {
|
|
34
|
-
transport.request.mockResolvedValue({ jobs: [] });
|
|
35
|
-
const cron = createCron(transport);
|
|
36
|
-
|
|
37
|
-
const result = await cron.listJobs();
|
|
38
|
-
|
|
39
|
-
expect(transport.request).toHaveBeenCalledWith("/cron", { method: "GET" });
|
|
40
|
-
expect(result).toEqual({ jobs: [] });
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("getJob", () => {
|
|
45
|
-
it("calls GET /cron/:id with encoded ID", async () => {
|
|
46
|
-
transport.request.mockResolvedValue({ job: { id: "my-job" } });
|
|
47
|
-
const cron = createCron(transport);
|
|
48
|
-
|
|
49
|
-
await cron.getJob("my-job");
|
|
50
|
-
|
|
51
|
-
expect(transport.request).toHaveBeenCalledWith("/cron/my-job", { method: "GET" });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("encodes special characters in job ID", async () => {
|
|
55
|
-
transport.request.mockResolvedValue({ job: {} });
|
|
56
|
-
const cron = createCron(transport);
|
|
57
|
-
|
|
58
|
-
await cron.getJob("job with spaces");
|
|
59
|
-
|
|
60
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
61
|
-
"/cron/job%20with%20spaces",
|
|
62
|
-
{ method: "GET" }
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("triggerJob", () => {
|
|
68
|
-
it("calls POST /cron/:id/trigger", async () => {
|
|
69
|
-
transport.request.mockResolvedValue({ log: {},
|
|
70
|
-
job: {} });
|
|
71
|
-
const cron = createCron(transport);
|
|
72
|
-
|
|
73
|
-
await cron.triggerJob("my-job");
|
|
74
|
-
|
|
75
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
76
|
-
"/cron/my-job/trigger",
|
|
77
|
-
{ method: "POST" }
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("getJobLogs", () => {
|
|
83
|
-
it("calls GET /cron/:id/logs without limit", async () => {
|
|
84
|
-
transport.request.mockResolvedValue({ logs: [] });
|
|
85
|
-
const cron = createCron(transport);
|
|
86
|
-
|
|
87
|
-
await cron.getJobLogs("my-job");
|
|
88
|
-
|
|
89
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
90
|
-
"/cron/my-job/logs",
|
|
91
|
-
{ method: "GET" }
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("appends limit query param when provided", async () => {
|
|
96
|
-
transport.request.mockResolvedValue({ logs: [] });
|
|
97
|
-
const cron = createCron(transport);
|
|
98
|
-
|
|
99
|
-
await cron.getJobLogs("my-job", { limit: 5 });
|
|
100
|
-
|
|
101
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
102
|
-
"/cron/my-job/logs?limit=5",
|
|
103
|
-
{ method: "GET" }
|
|
104
|
-
);
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe("toggleJob", () => {
|
|
109
|
-
it("calls PUT /cron/:id with enabled=true", async () => {
|
|
110
|
-
transport.request.mockResolvedValue({ job: {} });
|
|
111
|
-
const cron = createCron(transport);
|
|
112
|
-
|
|
113
|
-
await cron.toggleJob("my-job", true);
|
|
114
|
-
|
|
115
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
116
|
-
"/cron/my-job",
|
|
117
|
-
{
|
|
118
|
-
method: "PUT",
|
|
119
|
-
body: JSON.stringify({ enabled: true })
|
|
120
|
-
}
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("calls PUT /cron/:id with enabled=false", async () => {
|
|
125
|
-
transport.request.mockResolvedValue({ job: {} });
|
|
126
|
-
const cron = createCron(transport);
|
|
127
|
-
|
|
128
|
-
await cron.toggleJob("my-job", false);
|
|
129
|
-
|
|
130
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
131
|
-
"/cron/my-job",
|
|
132
|
-
{
|
|
133
|
-
method: "PUT",
|
|
134
|
-
body: JSON.stringify({ enabled: false })
|
|
135
|
-
}
|
|
136
|
-
);
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe("custom cronPath", () => {
|
|
141
|
-
it("uses custom path prefix", async () => {
|
|
142
|
-
transport.request.mockResolvedValue({ jobs: [] });
|
|
143
|
-
const cron = createCron(transport, { cronPath: "/admin/scheduled" });
|
|
144
|
-
|
|
145
|
-
await cron.listJobs();
|
|
146
|
-
|
|
147
|
-
expect(transport.request).toHaveBeenCalledWith(
|
|
148
|
-
"/admin/scheduled",
|
|
149
|
-
{ method: "GET" }
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("custom path applies to all methods", async () => {
|
|
154
|
-
transport.request.mockResolvedValue({});
|
|
155
|
-
const cron = createCron(transport, { cronPath: "/v2/cron" });
|
|
156
|
-
|
|
157
|
-
await cron.getJob("x");
|
|
158
|
-
expect(transport.request).toHaveBeenCalledWith("/v2/cron/x", { method: "GET" });
|
|
159
|
-
|
|
160
|
-
await cron.triggerJob("x");
|
|
161
|
-
expect(transport.request).toHaveBeenCalledWith("/v2/cron/x/trigger", { method: "POST" });
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
});
|