@rebasepro/client 0.13.1-canary.gef9608c → 0.14.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/dist/index.d.ts +1 -0
- package/dist/index.es.js +385 -28
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -0
- package/dist/offline-query.d.ts +41 -0
- 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/dist/websocket.d.ts +5 -3
- package/package.json +4 -4
- package/src/collection.ts +113 -12
- package/src/index.ts +5 -2
- package/src/offline-connectivity.test.ts +35 -1
- package/src/offline-connectivity.ts +35 -3
- package/src/offline-query.test.ts +88 -0
- package/src/offline-query.ts +88 -0
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +180 -2
- package/src/offline.ts +163 -8
- package/src/query-contract.types.ts +206 -0
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/sdk_query_builder.ts +56 -4
- package/src/transport.ts +57 -2
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket.ts +21 -3
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { type OfflineApi, type OfflineConfig } from "./offline";
|
|
|
12
12
|
import { InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf } from "@rebasepro/types";
|
|
13
13
|
export { RebaseApiError } from "./transport";
|
|
14
14
|
export { RebaseClientError } from "./errors";
|
|
15
|
+
export type { RebaseErrorCode } from "@rebasepro/types";
|
|
15
16
|
export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
|
|
16
17
|
export type { CollectionClient } from "./collection";
|
|
17
18
|
export type { FindResult, SDKCollectionClient, SDKQueryBuilderInterface, PaginationMeta } from "@rebasepro/types";
|
package/dist/index.es.js
CHANGED
|
@@ -91,7 +91,17 @@ function buildQueryString(params) {
|
|
|
91
91
|
const wire = serializeOrderBy(params.orderBy);
|
|
92
92
|
if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
|
|
93
93
|
}
|
|
94
|
-
if (params.searchString)
|
|
94
|
+
if (params.searchString) {
|
|
95
|
+
parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
|
|
96
|
+
if (params.searchExplain) parts.push("searchExplain=true");
|
|
97
|
+
}
|
|
98
|
+
if (params.vectorSearch) {
|
|
99
|
+
const vs = params.vectorSearch;
|
|
100
|
+
parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
|
|
101
|
+
parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
|
|
102
|
+
if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
|
|
103
|
+
if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
|
|
104
|
+
}
|
|
95
105
|
if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
|
|
96
106
|
if (params.logical) {
|
|
97
107
|
const root = params.logical;
|
|
@@ -167,6 +177,23 @@ function createTransport(config, environment) {
|
|
|
167
177
|
...init?.headers || {}
|
|
168
178
|
};
|
|
169
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The refusal for a success status carrying a body this client cannot read.
|
|
182
|
+
*
|
|
183
|
+
* The first 120 characters go in the message because they identify the
|
|
184
|
+
* sender at a glance: `<!doctype html>` says "you are talking to a web
|
|
185
|
+
* server, not to this API" faster than any wording here could.
|
|
186
|
+
*
|
|
187
|
+
* One function for both the first attempt and the post-refresh retry — the
|
|
188
|
+
* retry is a second copy of this whole response-reading path, and copies
|
|
189
|
+
* are how one of them ends up fixed and the other not.
|
|
190
|
+
*/
|
|
191
|
+
function unreadableResponse(status, text) {
|
|
192
|
+
return new RebaseApiError$1(`The server answered ${status} with a body that is not JSON, so there is nothing to return. This usually means the request reached something other than the Rebase API — a single-page-app fallback serving index.html, or a proxy error page — so check the API URL configuration (e.g. VITE_API_URL). The body began: ${JSON.stringify(text.slice(0, 120))}`, {
|
|
193
|
+
status,
|
|
194
|
+
code: "INVALID_JSON_RESPONSE"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
170
197
|
async function request(path, init) {
|
|
171
198
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
172
199
|
let activeToken = token;
|
|
@@ -184,9 +211,27 @@ function createTransport(config, environment) {
|
|
|
184
211
|
if (res.status === 204) return void 0;
|
|
185
212
|
const text = await res.text().catch(() => "");
|
|
186
213
|
let body = {};
|
|
214
|
+
/**
|
|
215
|
+
* Whether the body was there and could not be read as JSON.
|
|
216
|
+
*
|
|
217
|
+
* On an error status this does not matter — the status is the answer
|
|
218
|
+
* and the message falls back to `statusText`. On a *success* status it
|
|
219
|
+
* is the whole answer, and `{}` was being returned as though the server
|
|
220
|
+
* had sent it: `find()` answered `{}` instead of an array, `getOne()`
|
|
221
|
+
* an empty object, with nothing thrown.
|
|
222
|
+
*
|
|
223
|
+
* The case that produces it is not exotic. Point `VITE_API_URL` at the
|
|
224
|
+
* frontend's own host and `/api/data/posts` lands on the SPA fallback,
|
|
225
|
+
* which answers `200` with `index.html` — so the misconfiguration the
|
|
226
|
+
* 404 branch below spends four lines explaining reaches the caller, in
|
|
227
|
+
* its most common form, as an empty success.
|
|
228
|
+
*/
|
|
229
|
+
let unreadableBody = false;
|
|
187
230
|
if (text) try {
|
|
188
231
|
body = JSON.parse(text, rebaseReviver);
|
|
189
|
-
} catch (e) {
|
|
232
|
+
} catch (e) {
|
|
233
|
+
unreadableBody = true;
|
|
234
|
+
}
|
|
190
235
|
const getErrorField = (obj, field) => {
|
|
191
236
|
const err = obj?.error;
|
|
192
237
|
if (err && typeof err === "object" && err !== null) return err[field];
|
|
@@ -206,9 +251,12 @@ function createTransport(config, environment) {
|
|
|
206
251
|
if (retryRes.status === 204) return void 0;
|
|
207
252
|
const retryText = await retryRes.text().catch(() => "");
|
|
208
253
|
let retryBody = {};
|
|
254
|
+
let retryUnreadable = false;
|
|
209
255
|
if (retryText) try {
|
|
210
256
|
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
211
|
-
} catch (e) {
|
|
257
|
+
} catch (e) {
|
|
258
|
+
retryUnreadable = true;
|
|
259
|
+
}
|
|
212
260
|
if (!retryRes.ok) {
|
|
213
261
|
let fallbackMessage = retryRes.statusText;
|
|
214
262
|
if (retryRes.status === 404 && !fallbackMessage) fallbackMessage = `Endpoint not found (${init?.method || "GET"} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
|
|
@@ -218,6 +266,7 @@ function createTransport(config, environment) {
|
|
|
218
266
|
details: getErrorField(retryBody, "details")
|
|
219
267
|
});
|
|
220
268
|
}
|
|
269
|
+
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
221
270
|
return retryBody;
|
|
222
271
|
}
|
|
223
272
|
}
|
|
@@ -230,6 +279,7 @@ function createTransport(config, environment) {
|
|
|
230
279
|
details: getErrorField(body, "details")
|
|
231
280
|
});
|
|
232
281
|
}
|
|
282
|
+
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
233
283
|
return body;
|
|
234
284
|
}
|
|
235
285
|
return {
|
|
@@ -1248,9 +1298,55 @@ var SDKQueryBuilder = class {
|
|
|
1248
1298
|
}
|
|
1249
1299
|
/**
|
|
1250
1300
|
* Set a free-text search string if supported by the backend.
|
|
1301
|
+
*
|
|
1302
|
+
* By default this is a substring match across the collection's top-level
|
|
1303
|
+
* string properties. A Postgres collection that declares a `search` block
|
|
1304
|
+
* gets ranked full-text matching over the fields it named instead, and each
|
|
1305
|
+
* row comes back with a `_score` you can sort on:
|
|
1306
|
+
*
|
|
1307
|
+
* ```ts
|
|
1308
|
+
* client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
|
|
1309
|
+
* ```
|
|
1310
|
+
*
|
|
1311
|
+
* Pass `{ explain: true }` to have each row report which of the declared
|
|
1312
|
+
* fields matched, with a highlighted snippet, on `_matches`:
|
|
1313
|
+
*
|
|
1314
|
+
* ```ts
|
|
1315
|
+
* const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
|
|
1316
|
+
* data[0]._matches
|
|
1317
|
+
* // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
|
|
1318
|
+
* ```
|
|
1251
1319
|
*/
|
|
1252
|
-
search(searchString) {
|
|
1320
|
+
search(searchString, options) {
|
|
1253
1321
|
this.params.searchString = searchString;
|
|
1322
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
1323
|
+
return this;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Order rows by nearest-neighbour distance to `vector`.
|
|
1327
|
+
*
|
|
1328
|
+
* The server has supported this from the REST layer since vectors landed;
|
|
1329
|
+
* this is the SDK reaching it. Results come back closest-first with a
|
|
1330
|
+
* `_distance` on each row, and any `where` / `orderBy` on the same query is
|
|
1331
|
+
* a filter applied before the ordering — distance decides the order.
|
|
1332
|
+
*
|
|
1333
|
+
* You supply the query vector. Rebase stores and searches embeddings; it
|
|
1334
|
+
* does not produce them, so this is where whatever model you already use
|
|
1335
|
+
* for the stored vectors gets called.
|
|
1336
|
+
*
|
|
1337
|
+
* @param property - Name of the `vector` property to compare against.
|
|
1338
|
+
* @param vector - The query embedding. Its length must match the property's
|
|
1339
|
+
* declared `dimensions`, or the server answers 400.
|
|
1340
|
+
* @example
|
|
1341
|
+
* client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
|
|
1342
|
+
*/
|
|
1343
|
+
vectorSearch(property, vector, options) {
|
|
1344
|
+
this.params.vectorSearch = {
|
|
1345
|
+
property,
|
|
1346
|
+
vector,
|
|
1347
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
1348
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
1349
|
+
};
|
|
1254
1350
|
return this;
|
|
1255
1351
|
}
|
|
1256
1352
|
/**
|
|
@@ -1288,6 +1384,11 @@ var SDKQueryBuilder = class {
|
|
|
1288
1384
|
};
|
|
1289
1385
|
//#endregion
|
|
1290
1386
|
//#region src/collection.ts
|
|
1387
|
+
/**
|
|
1388
|
+
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
1389
|
+
* live only for the duration of the request — see `count()` for why.
|
|
1390
|
+
*/
|
|
1391
|
+
var inflightCounts = /* @__PURE__ */ new Map();
|
|
1291
1392
|
function createCollectionClient(transport, slug, ws) {
|
|
1292
1393
|
const basePath = `/data/${slug}`;
|
|
1293
1394
|
const client = {
|
|
@@ -1332,18 +1433,63 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1332
1433
|
body: JSON.stringify({
|
|
1333
1434
|
rows: data,
|
|
1334
1435
|
...options?.upsert ? { upsert: true } : {}
|
|
1335
|
-
})
|
|
1436
|
+
}),
|
|
1437
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1336
1438
|
})).data || [];
|
|
1337
1439
|
},
|
|
1440
|
+
/**
|
|
1441
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
1442
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
1443
|
+
*
|
|
1444
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
1445
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
1446
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
1447
|
+
* a route that does not exist and get a **404**, which is
|
|
1448
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
1449
|
+
* a missing record.
|
|
1450
|
+
*
|
|
1451
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
1452
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
1453
|
+
* supported server is one that serves both.
|
|
1454
|
+
*/
|
|
1338
1455
|
async update(id, data) {
|
|
1339
1456
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1340
1457
|
method: "PUT",
|
|
1341
1458
|
body: JSON.stringify(data)
|
|
1342
1459
|
});
|
|
1343
1460
|
},
|
|
1461
|
+
async updateMany(updates, options) {
|
|
1462
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
1463
|
+
if (updates.length === 0) return [];
|
|
1464
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
1465
|
+
method: "PATCH",
|
|
1466
|
+
body: JSON.stringify({ updates }),
|
|
1467
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1468
|
+
})).data || [];
|
|
1469
|
+
},
|
|
1344
1470
|
async delete(id) {
|
|
1345
1471
|
await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
|
|
1346
1472
|
},
|
|
1473
|
+
/**
|
|
1474
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
1475
|
+
*
|
|
1476
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
1477
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
1478
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
1479
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
1480
|
+
* operation, so a generated client would send the request without its
|
|
1481
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
1482
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
1483
|
+
*/
|
|
1484
|
+
async deleteMany(ids, options) {
|
|
1485
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
1486
|
+
if (ids.length === 0) return;
|
|
1487
|
+
await transport.request(`${basePath}/bulk/delete`, {
|
|
1488
|
+
method: "POST",
|
|
1489
|
+
body: JSON.stringify({ ids }),
|
|
1490
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1491
|
+
});
|
|
1492
|
+
},
|
|
1347
1493
|
async count(params) {
|
|
1348
1494
|
const qs = buildQueryString({
|
|
1349
1495
|
...params,
|
|
@@ -1351,7 +1497,16 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1351
1497
|
offset: void 0,
|
|
1352
1498
|
include: void 0
|
|
1353
1499
|
});
|
|
1354
|
-
|
|
1500
|
+
const key = basePath + "/count" + qs;
|
|
1501
|
+
const inflight = inflightCounts.get(key);
|
|
1502
|
+
if (inflight) return inflight;
|
|
1503
|
+
const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
|
|
1504
|
+
inflightCounts.set(key, request);
|
|
1505
|
+
try {
|
|
1506
|
+
return await request;
|
|
1507
|
+
} finally {
|
|
1508
|
+
inflightCounts.delete(key);
|
|
1509
|
+
}
|
|
1355
1510
|
},
|
|
1356
1511
|
observe(params, onResult, onError, options) {
|
|
1357
1512
|
let closed = false;
|
|
@@ -1419,8 +1574,11 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1419
1574
|
offset(count) {
|
|
1420
1575
|
return new SDKQueryBuilder(client).offset(count);
|
|
1421
1576
|
},
|
|
1422
|
-
search(searchString) {
|
|
1423
|
-
return new SDKQueryBuilder(client).search(searchString);
|
|
1577
|
+
search(searchString, options) {
|
|
1578
|
+
return new SDKQueryBuilder(client).search(searchString, options);
|
|
1579
|
+
},
|
|
1580
|
+
vectorSearch(property, vector, options) {
|
|
1581
|
+
return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
|
|
1424
1582
|
},
|
|
1425
1583
|
include(...relations) {
|
|
1426
1584
|
return new SDKQueryBuilder(client).include(...relations);
|
|
@@ -1440,7 +1598,8 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1440
1598
|
offset: window.driverOffset,
|
|
1441
1599
|
orderBy: params?.orderBy?.[0],
|
|
1442
1600
|
order: params?.orderBy?.[1],
|
|
1443
|
-
searchString: params?.searchString
|
|
1601
|
+
searchString: params?.searchString,
|
|
1602
|
+
searchExplain: params?.searchExplain
|
|
1444
1603
|
}, (incomingRows) => {
|
|
1445
1604
|
const currentUpdateId = ++lastUpdateId;
|
|
1446
1605
|
const requestedLimit = window.limit;
|
|
@@ -1730,9 +1889,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
1730
1889
|
*
|
|
1731
1890
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
1732
1891
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
1733
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
1734
|
-
* package root
|
|
1735
|
-
*
|
|
1892
|
+
* `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
|
|
1893
|
+
* package root for the same reason it always was — a data-source driver may
|
|
1894
|
+
* instantiate it directly — but nothing in this repo does since
|
|
1895
|
+
* `@rebasepro/client-postgres` was removed; its surface may change without a
|
|
1896
|
+
* major bump.
|
|
1736
1897
|
*/
|
|
1737
1898
|
var RebaseWebSocketClient = class {
|
|
1738
1899
|
websocketUrl;
|
|
@@ -2242,6 +2403,11 @@ var RebaseWebSocketClient = class {
|
|
|
2242
2403
|
callback.onError(new RebaseApiError$1(errorMessage, { code: errorCode }));
|
|
2243
2404
|
}
|
|
2244
2405
|
} else callback.onUpdate(message);
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
if (type === "ERROR" || type === "error" || message.error) {
|
|
2409
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
2410
|
+
console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
|
|
2245
2411
|
}
|
|
2246
2412
|
}
|
|
2247
2413
|
async ensureAuthenticated(retryCount = 3) {
|
|
@@ -3354,11 +3520,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
|
3354
3520
|
503,
|
|
3355
3521
|
504
|
|
3356
3522
|
]);
|
|
3523
|
+
/**
|
|
3524
|
+
* The server holds this key for a request it has not answered yet.
|
|
3525
|
+
*
|
|
3526
|
+
* It is a 409 like a duplicate row is a 409, and nothing but the code separates
|
|
3527
|
+
* them — one means "your write is already there", the other means "your write
|
|
3528
|
+
* may not have happened at all, ask again".
|
|
3529
|
+
*/
|
|
3530
|
+
var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
3531
|
+
/**
|
|
3532
|
+
* Is the server still answering an earlier attempt of this same write?
|
|
3533
|
+
*
|
|
3534
|
+
* The only correct response is to ask again — which is exactly what the
|
|
3535
|
+
* server's own message says, and exactly what this SDK used not to do.
|
|
3536
|
+
*/
|
|
3537
|
+
function isIdempotencyInProgressError(error) {
|
|
3538
|
+
return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
3539
|
+
}
|
|
3357
3540
|
/** Is this failure worth another attempt later? */
|
|
3358
3541
|
function isRetryableError(error) {
|
|
3359
3542
|
if (isNetworkError(error)) return true;
|
|
3360
|
-
if (error instanceof RebaseApiError) return
|
|
3361
|
-
return
|
|
3543
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
3544
|
+
if (isIdempotencyInProgressError(error)) return true;
|
|
3545
|
+
return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
|
|
3362
3546
|
}
|
|
3363
3547
|
/**
|
|
3364
3548
|
* Did this write fail because the row is already there?
|
|
@@ -3370,10 +3554,16 @@ function isRetryableError(error) {
|
|
|
3370
3554
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
3371
3555
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
3372
3556
|
* the server can only be rejecting it because the first attempt actually landed.
|
|
3557
|
+
*
|
|
3558
|
+
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
3559
|
+
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
3560
|
+
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
3561
|
+
* concluded there was nothing left to do and deleted the write from the queue.
|
|
3373
3562
|
*/
|
|
3374
3563
|
function isDuplicateKeyError(error) {
|
|
3375
3564
|
if (!(error instanceof RebaseApiError)) return false;
|
|
3376
|
-
|
|
3565
|
+
if (error.code === "23505") return true;
|
|
3566
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
3377
3567
|
}
|
|
3378
3568
|
var ConnectivityMonitor = class {
|
|
3379
3569
|
state = "online";
|
|
@@ -3947,6 +4137,25 @@ function resolvePagination(params) {
|
|
|
3947
4137
|
offset
|
|
3948
4138
|
};
|
|
3949
4139
|
}
|
|
4140
|
+
/** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
|
|
4141
|
+
var ORDERING_OPS = /* @__PURE__ */ new Set([
|
|
4142
|
+
"<",
|
|
4143
|
+
"<=",
|
|
4144
|
+
">",
|
|
4145
|
+
">="
|
|
4146
|
+
]);
|
|
4147
|
+
/** Does any condition in this `where` clause order its operands? */
|
|
4148
|
+
function whereOrders(where) {
|
|
4149
|
+
if (!where) return false;
|
|
4150
|
+
for (const condition of Object.values(where)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
|
|
4151
|
+
return false;
|
|
4152
|
+
}
|
|
4153
|
+
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
4154
|
+
function logicalOrders(condition, depth = 0) {
|
|
4155
|
+
if (!condition || depth > 32) return false;
|
|
4156
|
+
if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
|
|
4157
|
+
return ORDERING_OPS.has(condition.operator);
|
|
4158
|
+
}
|
|
3950
4159
|
/**
|
|
3951
4160
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
3952
4161
|
* assuming the cache holds every row of the collection?
|
|
@@ -3954,11 +4163,67 @@ function resolvePagination(params) {
|
|
|
3954
4163
|
* `include` pulls in rows from other collections that this evaluator never
|
|
3955
4164
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
3956
4165
|
* best effort rather than an equivalent one.
|
|
4166
|
+
*
|
|
4167
|
+
* **Ordering comparisons are refused, and that is the interesting one.**
|
|
4168
|
+
* `compareValues` falls back to an `Intl.Collator` for operands it cannot read
|
|
4169
|
+
* as numbers or instants. PostgreSQL orders text by the *database's* collation,
|
|
4170
|
+
* which is a property of the server this process has never been told: under the
|
|
4171
|
+
* C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
|
|
4172
|
+
* and the collator says true. So `["<", "Banana"]` selects a different set here
|
|
4173
|
+
* than it does there — silently, and in whichever direction the deployment
|
|
4174
|
+
* happens to have been created.
|
|
4175
|
+
*
|
|
4176
|
+
* The refusal covers *every* ordering comparison rather than only the ones with
|
|
4177
|
+
* a string operand, because the operand type does not settle it: a numeric
|
|
4178
|
+
* bound against a text column (`["<", 10]` on a `varchar`) also reaches the
|
|
4179
|
+
* collator, and nothing in `params` says what the column holds. Conservative on
|
|
4180
|
+
* purpose — the cost is that a query combining an ordering filter with
|
|
4181
|
+
* *unsynced local writes* stops placing those writes optimistically, which is a
|
|
4182
|
+
* degraded answer rather than a wrong one. Claiming exactness we do not have is
|
|
4183
|
+
* the other way round.
|
|
4184
|
+
*
|
|
4185
|
+
* This says nothing about ordering *results*; that is a separate claim with a
|
|
4186
|
+
* separate answer, because a sort changes which rows come first and not which
|
|
4187
|
+
* rows match. See {@link isLocallySortable}.
|
|
3957
4188
|
*/
|
|
3958
4189
|
function isExactlyEvaluable(params) {
|
|
3959
4190
|
if (!params) return true;
|
|
3960
4191
|
if (params.include && params.include.length > 0) return false;
|
|
3961
4192
|
if (params.searchString) return false;
|
|
4193
|
+
if (params.vectorSearch) return false;
|
|
4194
|
+
if (whereOrders(params.where)) return false;
|
|
4195
|
+
if (logicalOrders(params.logical)) return false;
|
|
4196
|
+
return true;
|
|
4197
|
+
}
|
|
4198
|
+
/**
|
|
4199
|
+
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
4200
|
+
*
|
|
4201
|
+
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
4202
|
+
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
4203
|
+
* collator only when it cannot read both operands as numbers, and `toComparable`
|
|
4204
|
+
* has already turned dates and relations into numbers and ids by then. If every
|
|
4205
|
+
* value on the sort column normalises to a number, the collator is unreachable
|
|
4206
|
+
* and the local order is the server's order.
|
|
4207
|
+
*
|
|
4208
|
+
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
4209
|
+
* the two cannot be made to agree — and so is a column this page happens to see
|
|
4210
|
+
* only as strings, which is the same thing from here.
|
|
4211
|
+
*
|
|
4212
|
+
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
4213
|
+
* ascending, first descending) that matches Postgres and never reaches the
|
|
4214
|
+
* comparator.
|
|
4215
|
+
*/
|
|
4216
|
+
function isLocallySortable(rows, orderBy) {
|
|
4217
|
+
if (!orderBy) return true;
|
|
4218
|
+
const [field] = orderBy;
|
|
4219
|
+
for (const row of rows) {
|
|
4220
|
+
const value = toComparable(row[field]);
|
|
4221
|
+
if (isNullish(value)) continue;
|
|
4222
|
+
if (typeof value === "number" || typeof value === "boolean") continue;
|
|
4223
|
+
if (typeof value === "bigint") continue;
|
|
4224
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
|
|
4225
|
+
return false;
|
|
4226
|
+
}
|
|
3962
4227
|
return true;
|
|
3963
4228
|
}
|
|
3964
4229
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
@@ -3994,6 +4259,17 @@ function generateOfflineId() {
|
|
|
3994
4259
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
3995
4260
|
}
|
|
3996
4261
|
var MISSING = "\0missing";
|
|
4262
|
+
/**
|
|
4263
|
+
* Replays to spend on a mutation whose idempotency key the server is still
|
|
4264
|
+
* holding, when the app has not asked for more.
|
|
4265
|
+
*
|
|
4266
|
+
* Retries double from a second and cap at the sync interval, so the default
|
|
4267
|
+
* budget of five covers about half a minute — less than the lease a server
|
|
4268
|
+
* gives a claim nobody came back for. This many outlast it with room for a slow
|
|
4269
|
+
* batch, and the count is what stops a server that never releases the key from
|
|
4270
|
+
* blocking the queue behind it indefinitely.
|
|
4271
|
+
*/
|
|
4272
|
+
var IN_PROGRESS_MIN_RETRIES = 12;
|
|
3997
4273
|
var OfflineManager = class {
|
|
3998
4274
|
store;
|
|
3999
4275
|
maxCachedQueries;
|
|
@@ -4274,6 +4550,72 @@ var OfflineManager = class {
|
|
|
4274
4550
|
this.notifyCollection(slug);
|
|
4275
4551
|
return rows;
|
|
4276
4552
|
},
|
|
4553
|
+
updateMany: async (updates, options) => {
|
|
4554
|
+
await this.ensureCollection(slug);
|
|
4555
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
4556
|
+
if (updates.length === 0) return [];
|
|
4557
|
+
const anyPending = updates.some((u) => this.hasPending(slug, u.id));
|
|
4558
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
4559
|
+
const rows = await inner.updateMany(updates, options);
|
|
4560
|
+
this.connectivity.markSuccess();
|
|
4561
|
+
await this.ingest(slug, rows);
|
|
4562
|
+
this.notifyCollection(slug);
|
|
4563
|
+
return rows;
|
|
4564
|
+
} catch (error) {
|
|
4565
|
+
if (!isNetworkError(error)) throw error;
|
|
4566
|
+
this.connectivity.markFailure();
|
|
4567
|
+
}
|
|
4568
|
+
const rollback = {};
|
|
4569
|
+
const optimistic = [];
|
|
4570
|
+
for (const { id, data } of updates) {
|
|
4571
|
+
const base = this.rawLocalRow(slug, id);
|
|
4572
|
+
rollback[String(id)] = base ?? null;
|
|
4573
|
+
optimistic.push({
|
|
4574
|
+
...base ?? {},
|
|
4575
|
+
...data,
|
|
4576
|
+
id
|
|
4577
|
+
});
|
|
4578
|
+
}
|
|
4579
|
+
await this.enqueue({
|
|
4580
|
+
collection: slug,
|
|
4581
|
+
type: "updateMany",
|
|
4582
|
+
updates: updates.map((u) => ({
|
|
4583
|
+
id: u.id,
|
|
4584
|
+
data: u.data
|
|
4585
|
+
})),
|
|
4586
|
+
rollback: { rows: rollback }
|
|
4587
|
+
});
|
|
4588
|
+
for (const row of optimistic) this.setLocalRow(slug, row.id, row);
|
|
4589
|
+
this.notifyCollection(slug);
|
|
4590
|
+
return optimistic;
|
|
4591
|
+
},
|
|
4592
|
+
deleteMany: async (ids, options) => {
|
|
4593
|
+
await this.ensureCollection(slug);
|
|
4594
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
4595
|
+
if (ids.length === 0) return;
|
|
4596
|
+
const anyPending = ids.some((id) => this.hasPending(slug, id));
|
|
4597
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
4598
|
+
await inner.deleteMany(ids, options);
|
|
4599
|
+
this.connectivity.markSuccess();
|
|
4600
|
+
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
4601
|
+
this.notifyCollection(slug);
|
|
4602
|
+
this.scheduleRefresh(slug);
|
|
4603
|
+
return;
|
|
4604
|
+
} catch (error) {
|
|
4605
|
+
if (!isNetworkError(error)) throw error;
|
|
4606
|
+
this.connectivity.markFailure();
|
|
4607
|
+
}
|
|
4608
|
+
const rollback = {};
|
|
4609
|
+
for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
|
|
4610
|
+
await this.enqueue({
|
|
4611
|
+
collection: slug,
|
|
4612
|
+
type: "deleteMany",
|
|
4613
|
+
ids,
|
|
4614
|
+
rollback: { rows: rollback }
|
|
4615
|
+
});
|
|
4616
|
+
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
4617
|
+
this.notifyCollection(slug);
|
|
4618
|
+
},
|
|
4277
4619
|
update: async (id, data) => {
|
|
4278
4620
|
await this.ensureCollection(slug);
|
|
4279
4621
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
@@ -4352,7 +4694,8 @@ var OfflineManager = class {
|
|
|
4352
4694
|
orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
|
|
4353
4695
|
limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
|
|
4354
4696
|
offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
|
|
4355
|
-
search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
|
|
4697
|
+
search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
|
|
4698
|
+
vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
|
|
4356
4699
|
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
|
|
4357
4700
|
};
|
|
4358
4701
|
if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
@@ -4609,16 +4952,15 @@ var OfflineManager = class {
|
|
|
4609
4952
|
}
|
|
4610
4953
|
let added = 0;
|
|
4611
4954
|
const offset = snapshot.offset ?? 0;
|
|
4612
|
-
if (exact && offset === 0) {
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
added++;
|
|
4619
|
-
}
|
|
4620
|
-
if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
|
|
4955
|
+
if (exact && offset === 0) for (const [key, entry] of state.rows) {
|
|
4956
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
4957
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
4958
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
4959
|
+
rows.push(entry.row);
|
|
4960
|
+
added++;
|
|
4621
4961
|
}
|
|
4962
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
4963
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
4622
4964
|
return {
|
|
4623
4965
|
data: rows,
|
|
4624
4966
|
meta: {
|
|
@@ -4629,7 +4971,7 @@ var OfflineManager = class {
|
|
|
4629
4971
|
},
|
|
4630
4972
|
fromCache,
|
|
4631
4973
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
4632
|
-
partial: !exact
|
|
4974
|
+
partial: !exact || !orderIsLocal
|
|
4633
4975
|
};
|
|
4634
4976
|
}
|
|
4635
4977
|
localFind(slug, params) {
|
|
@@ -4944,7 +5286,8 @@ var OfflineManager = class {
|
|
|
4944
5286
|
}
|
|
4945
5287
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
4946
5288
|
op.lastError = error?.message ?? String(error);
|
|
4947
|
-
|
|
5289
|
+
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
5290
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
4948
5291
|
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
4949
5292
|
this.connectivity.deferRetry();
|
|
4950
5293
|
this.patchStatus({ lastError: op.lastError });
|
|
@@ -4990,11 +5333,25 @@ var OfflineManager = class {
|
|
|
4990
5333
|
await this.adoptServerRow(op, op.id, row);
|
|
4991
5334
|
} else if (op.type === "createMany") {
|
|
4992
5335
|
const queued = op.data ?? [];
|
|
4993
|
-
const rows = await inner.createMany(queued,
|
|
5336
|
+
const rows = await inner.createMany(queued, {
|
|
5337
|
+
...op.upsert ? { upsert: true } : {},
|
|
5338
|
+
idempotencyKey: op.mutationId
|
|
5339
|
+
});
|
|
4994
5340
|
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
5341
|
+
} else if (op.type === "updateMany") {
|
|
5342
|
+
const queued = op.updates ?? [];
|
|
5343
|
+
const rows = await inner.updateMany(queued.map((u) => ({
|
|
5344
|
+
id: u.id,
|
|
5345
|
+
data: u.data
|
|
5346
|
+
})), { idempotencyKey: op.mutationId });
|
|
5347
|
+
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
4995
5348
|
} else if (op.type === "update") {
|
|
4996
5349
|
const row = await inner.update(op.id, op.data);
|
|
4997
5350
|
await this.ingestReplaced(op, op.id, row);
|
|
5351
|
+
} else if (op.type === "deleteMany") {
|
|
5352
|
+
const ids = op.ids ?? [];
|
|
5353
|
+
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
5354
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
4998
5355
|
} else if (op.type === "delete") {
|
|
4999
5356
|
await inner.delete(op.id);
|
|
5000
5357
|
this.removeLocalRow(op.collection, op.id, true);
|