@rebasepro/client 0.13.0 → 0.13.1-canary.g06dbe5b
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/dist/index.d.ts +1 -0
- package/dist/index.es.js +452 -84
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +59 -3
- package/dist/offline-store.d.ts +8 -1
- package/dist/query-contract.types.d.ts +144 -0
- package/dist/sdk_query_builder.d.ts +46 -4
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth-refresh-overflow.test.ts +89 -0
- package/src/auth.ts +30 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +200 -57
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +116 -2
- package/src/offline-query.ts +123 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +180 -2
- package/src/offline.ts +169 -12
- package/src/query-contract.types.ts +206 -0
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/sdk_query_builder.ts +56 -4
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +34 -0
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +34 -9
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { DEFAULT_LIST_LIMIT } from "@rebasepro/types";
|
|
3
|
+
import { createCollectionClient } from "./collection";
|
|
4
|
+
import type { FindResult } from "@rebasepro/types";
|
|
5
|
+
import type { Transport } from "./transport";
|
|
6
|
+
import type { RebaseWebSocketClient } from "./websocket";
|
|
7
|
+
|
|
8
|
+
type Row = { id: string };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `listen()` gets rows off the socket and has to describe the window they came
|
|
12
|
+
* from itself. Everything it reports is therefore something it decided, and
|
|
13
|
+
* these tests are about which of those decisions are claims it can support.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
17
|
+
|
|
18
|
+
function createWs() {
|
|
19
|
+
let push: ((rows: Record<string, unknown>[]) => void) | undefined;
|
|
20
|
+
const ws = {
|
|
21
|
+
listenCollection: (_p: unknown, onUpdate: (rows: Record<string, unknown>[]) => void) => {
|
|
22
|
+
push = onUpdate;
|
|
23
|
+
return () => { push = undefined; };
|
|
24
|
+
}
|
|
25
|
+
} as unknown as RebaseWebSocketClient;
|
|
26
|
+
return { ws, push: (rows: Record<string, unknown>[]) => push?.(rows) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createTransport(count: () => Promise<unknown>): Transport {
|
|
30
|
+
return {
|
|
31
|
+
request: jest.fn<any>((url: string) => {
|
|
32
|
+
if (url.includes("/count")) return count();
|
|
33
|
+
return Promise.resolve({ data: [], meta: {} });
|
|
34
|
+
}),
|
|
35
|
+
setToken: jest.fn(),
|
|
36
|
+
setAuthTokenGetter: jest.fn(),
|
|
37
|
+
setOnUnauthorized: jest.fn(),
|
|
38
|
+
baseUrl: "http://localhost:3000",
|
|
39
|
+
apiPath: "/api",
|
|
40
|
+
fetchFn: globalThis.fetch,
|
|
41
|
+
getHeaders: () => ({}),
|
|
42
|
+
resolveToken: jest.fn<any>().mockResolvedValue(null)
|
|
43
|
+
} as unknown as Transport;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("listen() pagination metadata", () => {
|
|
47
|
+
it("reports the page size the server actually applies when none was requested", async () => {
|
|
48
|
+
const transport = createTransport(() => Promise.resolve({ count: 3 }));
|
|
49
|
+
const { ws, push } = createWs();
|
|
50
|
+
const client = createCollectionClient<Row>(transport, "products", ws);
|
|
51
|
+
|
|
52
|
+
const updates: FindResult<Row>[] = [];
|
|
53
|
+
const stop = client.listen!(undefined, (r) => updates.push(r));
|
|
54
|
+
push([{ id: "1" }, { id: "2" }, { id: "3" }]);
|
|
55
|
+
await flush();
|
|
56
|
+
|
|
57
|
+
// Not 20. A caller who names no limit gets `DEFAULT_LIST_LIMIT` rows
|
|
58
|
+
// from the REST layer, so any other number here describes a window the
|
|
59
|
+
// rows did not come from.
|
|
60
|
+
expect(updates.at(-1)?.meta.limit).toBe(DEFAULT_LIST_LIMIT);
|
|
61
|
+
stop!();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not shrink the total to one page when the count request fails", async () => {
|
|
65
|
+
let counts = 0;
|
|
66
|
+
const transport = createTransport(() => {
|
|
67
|
+
counts += 1;
|
|
68
|
+
return counts === 1
|
|
69
|
+
? Promise.resolve({ count: 500 })
|
|
70
|
+
: Promise.reject(new Error("count unavailable"));
|
|
71
|
+
});
|
|
72
|
+
const { ws, push } = createWs();
|
|
73
|
+
const client = createCollectionClient<Row>(transport, "products", ws);
|
|
74
|
+
|
|
75
|
+
const updates: FindResult<Row>[] = [];
|
|
76
|
+
const stop = client.listen!({ limit: 2 }, (r) => updates.push(r));
|
|
77
|
+
|
|
78
|
+
push([{ id: "1" }, { id: "2" }]);
|
|
79
|
+
await flush();
|
|
80
|
+
expect(updates.at(-1)?.meta.total).toBe(500);
|
|
81
|
+
|
|
82
|
+
// The collection did not lose 498 rows because a count request failed.
|
|
83
|
+
push([{ id: "1" }, { id: "2" }]);
|
|
84
|
+
await flush();
|
|
85
|
+
expect(updates.at(-1)?.meta.total).toBe(500);
|
|
86
|
+
expect(updates.at(-1)?.meta.hasMore).toBe(true);
|
|
87
|
+
stop!();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never claims a total below the rows already skipped", async () => {
|
|
91
|
+
const transport = createTransport(() => Promise.reject(new Error("nope")));
|
|
92
|
+
const { ws, push } = createWs();
|
|
93
|
+
const client = createCollectionClient<Row>(transport, "products", ws);
|
|
94
|
+
|
|
95
|
+
const updates: FindResult<Row>[] = [];
|
|
96
|
+
const stop = client.listen!({ limit: 2, offset: 10 }, (r) => updates.push(r));
|
|
97
|
+
push([{ id: "11" }, { id: "12" }]);
|
|
98
|
+
await flush();
|
|
99
|
+
|
|
100
|
+
// With no count ever obtained the total is unknown, but it cannot be 2:
|
|
101
|
+
// ten rows were paged past to reach these.
|
|
102
|
+
expect(updates.at(-1)?.meta.total).toBe(12);
|
|
103
|
+
stop!();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createCollectionClient, type LiveResult } from "./collection";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
import type { RebaseWebSocketClient } from "./websocket";
|
|
5
|
+
|
|
6
|
+
type Row = { id: string; name: string };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `observe()` on a client without the offline layer is two sources feeding one
|
|
10
|
+
* callback: a one-shot `find()` and a realtime subscription. Neither of these
|
|
11
|
+
* tests is about the sources — they are about what the callback is allowed to
|
|
12
|
+
* be told, which is the part an app actually depends on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function deferred<T>() {
|
|
16
|
+
let resolve!: (value: T) => void;
|
|
17
|
+
let reject!: (error: unknown) => void;
|
|
18
|
+
const promise = new Promise<T>((res, rej) => {
|
|
19
|
+
resolve = res;
|
|
20
|
+
reject = rej;
|
|
21
|
+
});
|
|
22
|
+
return { promise, resolve, reject };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Let every already-queued microtask run. */
|
|
26
|
+
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
27
|
+
|
|
28
|
+
function createTransport(handlers: {
|
|
29
|
+
find?: () => Promise<unknown>;
|
|
30
|
+
count?: () => Promise<unknown>;
|
|
31
|
+
}): Transport {
|
|
32
|
+
return {
|
|
33
|
+
request: jest.fn<any>((url: string) => {
|
|
34
|
+
if (url.includes("/count")) return handlers.count?.() ?? Promise.resolve({ count: 0 });
|
|
35
|
+
return handlers.find?.() ?? Promise.resolve({ data: [], meta: {} });
|
|
36
|
+
}),
|
|
37
|
+
setToken: jest.fn(),
|
|
38
|
+
setAuthTokenGetter: jest.fn(),
|
|
39
|
+
setOnUnauthorized: jest.fn(),
|
|
40
|
+
baseUrl: "http://localhost:3000",
|
|
41
|
+
apiPath: "/api",
|
|
42
|
+
fetchFn: globalThis.fetch,
|
|
43
|
+
getHeaders: () => ({}),
|
|
44
|
+
resolveToken: jest.fn<any>().mockResolvedValue(null)
|
|
45
|
+
} as unknown as Transport;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A socket whose collection updates this test drives by hand. */
|
|
49
|
+
function createWs() {
|
|
50
|
+
let push: ((rows: Record<string, unknown>[]) => void) | undefined;
|
|
51
|
+
const ws = {
|
|
52
|
+
listenCollection: (
|
|
53
|
+
_props: unknown,
|
|
54
|
+
onUpdate: (rows: Record<string, unknown>[]) => void
|
|
55
|
+
) => {
|
|
56
|
+
push = onUpdate;
|
|
57
|
+
return () => { push = undefined; };
|
|
58
|
+
}
|
|
59
|
+
} as unknown as RebaseWebSocketClient;
|
|
60
|
+
return {
|
|
61
|
+
ws,
|
|
62
|
+
push: (rows: Record<string, unknown>[]) => push?.(rows)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("observe() without the offline layer", () => {
|
|
67
|
+
it("does not replace a live update with the initial fetch that resolved after it", async () => {
|
|
68
|
+
// The initial `find()` is in flight for the whole test; the socket
|
|
69
|
+
// answers first. This is not exotic — `listenCollection` replays cached
|
|
70
|
+
// rows synchronously when another observer already holds the same
|
|
71
|
+
// subscription, so a second component observing the same query hits it
|
|
72
|
+
// every time.
|
|
73
|
+
const find = deferred<unknown>();
|
|
74
|
+
const transport = createTransport({
|
|
75
|
+
find: () => find.promise,
|
|
76
|
+
count: () => Promise.resolve({ count: 1 })
|
|
77
|
+
});
|
|
78
|
+
const { ws, push } = createWs();
|
|
79
|
+
const client = createCollectionClient<Row>(transport, "products", ws);
|
|
80
|
+
|
|
81
|
+
const results: LiveResult<Row>[] = [];
|
|
82
|
+
const stop = client.observe(undefined, (r) => results.push(r));
|
|
83
|
+
|
|
84
|
+
push([{ id: "1", name: "live" }]);
|
|
85
|
+
await flush();
|
|
86
|
+
|
|
87
|
+
find.resolve({ data: [{ id: "1", name: "stale" }], meta: { total: 1 } });
|
|
88
|
+
await flush();
|
|
89
|
+
|
|
90
|
+
expect(results.at(-1)?.data).toEqual([{ id: "1", name: "live" }]);
|
|
91
|
+
stop();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("de-duplicates emissions, as the contract on observe() states", async () => {
|
|
95
|
+
const transport = createTransport({
|
|
96
|
+
find: () => Promise.resolve({ data: [{ id: "1", name: "a" }], meta: { total: 1 } }),
|
|
97
|
+
count: () => Promise.resolve({ count: 1 })
|
|
98
|
+
});
|
|
99
|
+
const { ws, push } = createWs();
|
|
100
|
+
const client = createCollectionClient<Row>(transport, "products", ws);
|
|
101
|
+
|
|
102
|
+
const results: LiveResult<Row>[] = [];
|
|
103
|
+
const stop = client.observe(undefined, (r) => results.push(r));
|
|
104
|
+
await flush();
|
|
105
|
+
|
|
106
|
+
const afterInitial = results.length;
|
|
107
|
+
|
|
108
|
+
// Two socket updates carrying exactly the rows the app already has.
|
|
109
|
+
push([{ id: "1", name: "a" }]);
|
|
110
|
+
await flush();
|
|
111
|
+
push([{ id: "1", name: "a" }]);
|
|
112
|
+
await flush();
|
|
113
|
+
|
|
114
|
+
expect(results.length).toBe(afterInitial);
|
|
115
|
+
|
|
116
|
+
// A real change still gets through.
|
|
117
|
+
push([{ id: "1", name: "b" }]);
|
|
118
|
+
await flush();
|
|
119
|
+
expect(results.length).toBe(afterInitial + 1);
|
|
120
|
+
expect(results.at(-1)?.data).toEqual([{ id: "1", name: "b" }]);
|
|
121
|
+
stop();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("stops emitting once unsubscribed, including for a fetch still in flight", async () => {
|
|
125
|
+
const find = deferred<unknown>();
|
|
126
|
+
const transport = createTransport({ find: () => find.promise });
|
|
127
|
+
const client = createCollectionClient<Row>(transport, "products");
|
|
128
|
+
|
|
129
|
+
const results: LiveResult<Row>[] = [];
|
|
130
|
+
const stop = client.observe(undefined, (r) => results.push(r));
|
|
131
|
+
stop();
|
|
132
|
+
|
|
133
|
+
find.resolve({ data: [{ id: "1", name: "a" }], meta: { total: 1 } });
|
|
134
|
+
await flush();
|
|
135
|
+
|
|
136
|
+
expect(results).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/collection.ts
CHANGED
|
@@ -8,13 +8,20 @@ import {
|
|
|
8
8
|
SDKCollectionClient,
|
|
9
9
|
SDKQueryBuilderInterface,
|
|
10
10
|
WhereFilterOp,
|
|
11
|
-
|
|
12
|
-
WriteOptions
|
|
11
|
+
WhereValueFor,
|
|
12
|
+
WriteOptions,
|
|
13
|
+
type ComputedSortField
|
|
13
14
|
} from "@rebasepro/types";
|
|
14
|
-
import { collectAllPages, paginateFind } from "@rebasepro/common";
|
|
15
|
+
import { collectAllPages, paginateFind, resolveFindWindow } from "@rebasepro/common";
|
|
15
16
|
|
|
16
17
|
import { SDKQueryBuilder } from "./sdk_query_builder";
|
|
17
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
|
+
|
|
18
25
|
/**
|
|
19
26
|
* A live query result: a normal {@link FindResult} plus what an interface
|
|
20
27
|
* needs to decide whether to show a "saving…" or "offline" affordance over it.
|
|
@@ -155,7 +162,7 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
155
162
|
return raw as M;
|
|
156
163
|
},
|
|
157
164
|
|
|
158
|
-
async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
|
|
165
|
+
async createMany(data: Partial<M>[], options?: { upsert?: boolean } & WriteOptions) {
|
|
159
166
|
if (!Array.isArray(data)) {
|
|
160
167
|
throw new TypeError("createMany expects an array of records.");
|
|
161
168
|
}
|
|
@@ -166,11 +173,29 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
166
173
|
body: JSON.stringify({
|
|
167
174
|
rows: data,
|
|
168
175
|
...(options?.upsert ? { upsert: true } : {})
|
|
169
|
-
})
|
|
176
|
+
}),
|
|
177
|
+
...(options?.idempotencyKey
|
|
178
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
179
|
+
: {})
|
|
170
180
|
});
|
|
171
181
|
return (raw.data || []) as M[];
|
|
172
182
|
},
|
|
173
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
186
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
187
|
+
*
|
|
188
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
189
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
190
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
191
|
+
* a route that does not exist and get a **404**, which is
|
|
192
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
193
|
+
* a missing record.
|
|
194
|
+
*
|
|
195
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
196
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
197
|
+
* supported server is one that serves both.
|
|
198
|
+
*/
|
|
174
199
|
async update(id: string | number, data: Partial<M>) {
|
|
175
200
|
const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
176
201
|
method: "PUT",
|
|
@@ -179,12 +204,54 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
179
204
|
return raw as M;
|
|
180
205
|
},
|
|
181
206
|
|
|
207
|
+
async updateMany(updates: { id: string | number; data: Partial<M> }[], options?: WriteOptions) {
|
|
208
|
+
if (!Array.isArray(updates)) {
|
|
209
|
+
throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
210
|
+
}
|
|
211
|
+
if (updates.length === 0) return [];
|
|
212
|
+
|
|
213
|
+
const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
|
|
214
|
+
method: "PATCH",
|
|
215
|
+
body: JSON.stringify({ updates }),
|
|
216
|
+
...(options?.idempotencyKey
|
|
217
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
218
|
+
: {})
|
|
219
|
+
});
|
|
220
|
+
return (raw.data || []) as M[];
|
|
221
|
+
},
|
|
222
|
+
|
|
182
223
|
async delete(id: string | number) {
|
|
183
224
|
await transport.request<void>(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
184
225
|
method: "DELETE"
|
|
185
226
|
});
|
|
186
227
|
},
|
|
187
228
|
|
|
229
|
+
/**
|
|
230
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
231
|
+
*
|
|
232
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
233
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
234
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
235
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
236
|
+
* operation, so a generated client would send the request without its
|
|
237
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
238
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
239
|
+
*/
|
|
240
|
+
async deleteMany(ids: (string | number)[], options?: WriteOptions) {
|
|
241
|
+
if (!Array.isArray(ids)) {
|
|
242
|
+
throw new TypeError("deleteMany expects an array of ids.");
|
|
243
|
+
}
|
|
244
|
+
if (ids.length === 0) return;
|
|
245
|
+
|
|
246
|
+
await transport.request<void>(`${basePath}/bulk/delete`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: JSON.stringify({ ids }),
|
|
249
|
+
...(options?.idempotencyKey
|
|
250
|
+
? { headers: { "Idempotency-Key": options.idempotencyKey } }
|
|
251
|
+
: {})
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
|
|
188
255
|
async count(params?: FindParams<M>): Promise<number> {
|
|
189
256
|
const countParams: FindParams<M> = {
|
|
190
257
|
...params,
|
|
@@ -198,8 +265,34 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
198
265
|
include: undefined
|
|
199
266
|
};
|
|
200
267
|
const qs = buildQueryString(countParams);
|
|
201
|
-
|
|
202
|
-
|
|
268
|
+
|
|
269
|
+
// One count per query in flight, not one per caller.
|
|
270
|
+
//
|
|
271
|
+
// A count is a property of the query, and every concurrent caller
|
|
272
|
+
// asking the same question wants the same answer — so they can
|
|
273
|
+
// share the one request. This is not a micro-optimisation: a live
|
|
274
|
+
// subscription re-counts on every push (see `listen` below), and
|
|
275
|
+
// `listenCollection` deliberately collapses identical queries onto
|
|
276
|
+
// a single socket subscription while keeping one callback per
|
|
277
|
+
// subscriber. Each push therefore woke N subscribers, and each of
|
|
278
|
+
// them issued its own identical count. A table showing one relation
|
|
279
|
+
// column fired one count per visible cell, on every update.
|
|
280
|
+
//
|
|
281
|
+
// The entry is dropped as soon as it settles, so this merges
|
|
282
|
+
// concurrent calls only and never serves a cached total.
|
|
283
|
+
const key = basePath + "/count" + qs;
|
|
284
|
+
const inflight = inflightCounts.get(key);
|
|
285
|
+
if (inflight) return inflight;
|
|
286
|
+
|
|
287
|
+
const request = transport
|
|
288
|
+
.request<{ count: number }>(key, { method: "GET" })
|
|
289
|
+
.then((raw) => raw.count ?? 0);
|
|
290
|
+
inflightCounts.set(key, request);
|
|
291
|
+
try {
|
|
292
|
+
return await request;
|
|
293
|
+
} finally {
|
|
294
|
+
inflightCounts.delete(key);
|
|
295
|
+
}
|
|
203
296
|
},
|
|
204
297
|
|
|
205
298
|
// Reactive reads. Without the offline layer there is no local database
|
|
@@ -212,15 +305,36 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
212
305
|
options?: ObserveOptions
|
|
213
306
|
) {
|
|
214
307
|
let closed = false;
|
|
215
|
-
|
|
308
|
+
// Two sources race into one callback: the one-shot fetch below and
|
|
309
|
+
// the subscription beside it. Whichever resolves last used to win,
|
|
310
|
+
// so a socket update that landed first was overwritten by the
|
|
311
|
+
// fetch's older snapshot and stayed wrong until the next change.
|
|
312
|
+
// `listenCollection` replays cached rows synchronously to a second
|
|
313
|
+
// subscriber, so a second component observing the same query hit
|
|
314
|
+
// that ordering every time.
|
|
315
|
+
let liveDelivered = false;
|
|
316
|
+
let signature: string | undefined;
|
|
317
|
+
|
|
318
|
+
const deliver = (result: FindResult<M>, fromLive: boolean) => {
|
|
216
319
|
if (closed) return;
|
|
320
|
+
// Once the socket has spoken, the fetch issued alongside it is
|
|
321
|
+
// older news — delivering it would move the app backwards.
|
|
322
|
+
if (fromLive) liveDelivered = true;
|
|
323
|
+
else if (liveDelivered) return;
|
|
324
|
+
// The de-duplication `observe()` documents. Keyed on the rows
|
|
325
|
+
// and the total, the same two things the offline layer keys on,
|
|
326
|
+
// so both implementations mean the same thing by "changed".
|
|
327
|
+
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
328
|
+
if (signature !== undefined && next === signature) return;
|
|
329
|
+
signature = next;
|
|
217
330
|
onResult({ ...result, fromCache: false, hasPendingWrites: false, partial: false });
|
|
218
331
|
};
|
|
219
|
-
|
|
332
|
+
|
|
333
|
+
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
220
334
|
if (!closed) onError?.(error as Error);
|
|
221
335
|
});
|
|
222
336
|
const live = options?.realtime !== false && client.listen
|
|
223
|
-
? client.listen(params,
|
|
337
|
+
? client.listen(params, (result) => deliver(result, true), onError)
|
|
224
338
|
: undefined;
|
|
225
339
|
return () => {
|
|
226
340
|
closed = true;
|
|
@@ -235,15 +349,25 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
235
349
|
options?: ObserveOptions
|
|
236
350
|
) {
|
|
237
351
|
let closed = false;
|
|
238
|
-
|
|
352
|
+
let liveDelivered = false;
|
|
353
|
+
let signature: string | undefined;
|
|
354
|
+
|
|
355
|
+
// Same ordering and de-duplication rules as `observe`, for one row.
|
|
356
|
+
const deliver = (row: M | undefined, fromLive: boolean) => {
|
|
239
357
|
if (closed) return;
|
|
358
|
+
if (fromLive) liveDelivered = true;
|
|
359
|
+
else if (liveDelivered) return;
|
|
360
|
+
const next = row === undefined ? "\u0000missing" : JSON.stringify(row);
|
|
361
|
+
if (signature !== undefined && next === signature) return;
|
|
362
|
+
signature = next;
|
|
240
363
|
onResult(row, { fromCache: false, hasPendingWrites: false });
|
|
241
364
|
};
|
|
242
|
-
|
|
365
|
+
|
|
366
|
+
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
243
367
|
if (!closed) onError?.(error as Error);
|
|
244
368
|
});
|
|
245
369
|
const live = options?.realtime !== false && client.listenById
|
|
246
|
-
? client.listenById(id,
|
|
370
|
+
? client.listenById(id, (row) => deliver(row, true), onError)
|
|
247
371
|
: undefined;
|
|
248
372
|
return () => {
|
|
249
373
|
closed = true;
|
|
@@ -257,9 +381,9 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
257
381
|
if (typeof columnOrCondition === "object") {
|
|
258
382
|
return builder.where(columnOrCondition);
|
|
259
383
|
}
|
|
260
|
-
return builder.where(columnOrCondition as keyof M & string, operator!, value as
|
|
384
|
+
return builder.where(columnOrCondition as keyof M & string, operator!, value as WhereValueFor<WhereFilterOp, M[keyof M & string]>);
|
|
261
385
|
},
|
|
262
|
-
orderBy(column: keyof M & string, direction?: "asc" | "desc") {
|
|
386
|
+
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc") {
|
|
263
387
|
return new SDKQueryBuilder<M>(client).orderBy(column, direction);
|
|
264
388
|
},
|
|
265
389
|
limit(count: number) {
|
|
@@ -268,8 +392,15 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
268
392
|
offset(count: number) {
|
|
269
393
|
return new SDKQueryBuilder<M>(client).offset(count);
|
|
270
394
|
},
|
|
271
|
-
search(searchString: string) {
|
|
272
|
-
return new SDKQueryBuilder<M>(client).search(searchString);
|
|
395
|
+
search(searchString: string, options?: { explain?: boolean }) {
|
|
396
|
+
return new SDKQueryBuilder<M>(client).search(searchString, options);
|
|
397
|
+
},
|
|
398
|
+
vectorSearch(
|
|
399
|
+
property: string,
|
|
400
|
+
vector: number[],
|
|
401
|
+
options?: { distance?: "cosine" | "l2" | "inner_product"; threshold?: number }
|
|
402
|
+
) {
|
|
403
|
+
return new SDKQueryBuilder<M>(client).vectorSearch(property, vector, options);
|
|
273
404
|
},
|
|
274
405
|
include(...relations: string[]) {
|
|
275
406
|
return new SDKQueryBuilder<M>(client).include(...relations);
|
|
@@ -280,69 +411,81 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
280
411
|
client.listen = (params: FindParams<M> | undefined, onUpdate: (response: FindResult<M>) => void, onError?: (error: Error) => void) => {
|
|
281
412
|
let active = true;
|
|
282
413
|
let lastUpdateId = 0;
|
|
414
|
+
// The last total a `count()` actually returned. A later count that
|
|
415
|
+
// fails says nothing about how big the collection is, so it must
|
|
416
|
+
// not be allowed to replace this with the length of one page.
|
|
417
|
+
let lastKnownTotal: number | undefined;
|
|
418
|
+
const window = resolveFindWindow(params);
|
|
283
419
|
const unsub = ws.listenCollection(
|
|
284
420
|
{
|
|
285
421
|
path: slug,
|
|
286
422
|
filter: params?.where,
|
|
423
|
+
// The group used to be dropped here, so a subscription
|
|
424
|
+
// filtered with `or(...)` was pushed every row instead.
|
|
425
|
+
logical: params?.logical,
|
|
287
426
|
limit: params?.limit,
|
|
288
|
-
|
|
427
|
+
// `offset` used to be stringified into `startAfter`, which
|
|
428
|
+
// is a cursor *row*, not a row count — so the server was
|
|
429
|
+
// handed "20" where it expected a keyset value, and the
|
|
430
|
+
// offset it does understand never arrived at all.
|
|
431
|
+
offset: window.driverOffset,
|
|
289
432
|
orderBy: params?.orderBy?.[0],
|
|
290
433
|
order: params?.orderBy?.[1],
|
|
291
|
-
searchString: params?.searchString
|
|
434
|
+
searchString: params?.searchString,
|
|
435
|
+
searchExplain: params?.searchExplain
|
|
292
436
|
},
|
|
293
437
|
(incomingRows: Record<string, unknown>[]) => {
|
|
294
438
|
const currentUpdateId = ++lastUpdateId;
|
|
295
|
-
|
|
296
|
-
|
|
439
|
+
// What the server pages by when the caller names no limit.
|
|
440
|
+
// A hardcoded 20 here described a window the rows had not
|
|
441
|
+
// come from, and any app sizing its next request off
|
|
442
|
+
// `meta.limit` was told the wrong number.
|
|
443
|
+
const requestedLimit = window.limit;
|
|
444
|
+
const offset = window.offset;
|
|
297
445
|
|
|
298
446
|
// WS client already delivers flat rows — just cast
|
|
299
447
|
const rows = incomingRows as M[];
|
|
300
448
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
449
|
+
const emit = (total: number, hasMore: boolean) => {
|
|
450
|
+
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
451
|
+
onUpdate({
|
|
452
|
+
data: rows,
|
|
453
|
+
meta: {
|
|
454
|
+
total,
|
|
455
|
+
limit: requestedLimit,
|
|
456
|
+
offset,
|
|
457
|
+
hasMore
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// With no count to go on, the only defensible total is a
|
|
463
|
+
// lower bound: the rows on this page plus the ones paged
|
|
464
|
+
// past to reach them. Reporting `rows.length` claimed a
|
|
465
|
+
// collection read at offset 10 held two rows.
|
|
466
|
+
const emitWithoutCount = () => emit(
|
|
467
|
+
offset + rows.length,
|
|
468
|
+
rows.length >= requestedLimit
|
|
469
|
+
);
|
|
304
470
|
|
|
305
|
-
// Try to get authoritative count; fall back to heuristic
|
|
306
471
|
if (client.count) {
|
|
307
472
|
client.count(params)
|
|
308
473
|
.then((total) => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
data: rows,
|
|
312
|
-
meta: {
|
|
313
|
-
total,
|
|
314
|
-
limit: requestedLimit,
|
|
315
|
-
offset,
|
|
316
|
-
hasMore: offset + rows.length < total
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
}
|
|
474
|
+
lastKnownTotal = total;
|
|
475
|
+
emit(total, offset + rows.length < total);
|
|
320
476
|
})
|
|
321
477
|
.catch(() => {
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
offset,
|
|
330
|
-
hasMore: heuristicHasMore
|
|
331
|
-
}
|
|
332
|
-
});
|
|
478
|
+
// A count that failed is not evidence about the
|
|
479
|
+
// size of the collection. Keep the last real
|
|
480
|
+
// answer; only guess if there has never been one.
|
|
481
|
+
if (lastKnownTotal !== undefined) {
|
|
482
|
+
emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
483
|
+
} else {
|
|
484
|
+
emitWithoutCount();
|
|
333
485
|
}
|
|
334
486
|
});
|
|
335
487
|
} else {
|
|
336
|
-
|
|
337
|
-
onUpdate({
|
|
338
|
-
data: rows,
|
|
339
|
-
meta: {
|
|
340
|
-
total: heuristicTotal,
|
|
341
|
-
limit: requestedLimit,
|
|
342
|
-
offset,
|
|
343
|
-
hasMore: heuristicHasMore
|
|
344
|
-
}
|
|
345
|
-
});
|
|
488
|
+
emitWithoutCount();
|
|
346
489
|
}
|
|
347
490
|
},
|
|
348
491
|
onError
|