@rebasepro/client 0.17.3-canary.gdd23447 → 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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. 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
- });