@rebasepro/client 0.13.0 → 0.13.1-canary.g1822133
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 +499 -89
- 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/dist/websocket.d.ts +5 -3
- 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 +53 -17
- 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 +79 -2
- package/src/vector-search-query.test.ts +55 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +39 -12
package/dist/index.es.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, Vector, isPublicStoragePath, toCanonicalOp } from "@rebasepro/types";
|
|
2
|
-
import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, or, paginateFind, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
2
|
+
import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, or, paginateFind, resolveFindWindow, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
4
4
|
//#region src/reviver.ts
|
|
5
5
|
function rebaseReviver(_key, value) {
|
|
@@ -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;
|
|
@@ -131,6 +141,13 @@ function resolveBaseUrl(configured) {
|
|
|
131
141
|
function createTransport(config, environment) {
|
|
132
142
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
133
143
|
const apiPath = config.apiPath || "/api";
|
|
144
|
+
for (const field of ["baseUrl", "storageUrlOrigin"]) {
|
|
145
|
+
const value = config[field];
|
|
146
|
+
if (!value || !apiPath) continue;
|
|
147
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
148
|
+
if (!trimmed.endsWith(apiPath)) continue;
|
|
149
|
+
console.warn(`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ${JSON.stringify(apiPath)}, which is appended to it — requests will go to ${trimmed}${apiPath}/… and 404. Pass the origin only (${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set \`apiPath\` if the server really does mount the API one level deeper.`);
|
|
150
|
+
}
|
|
134
151
|
let token = config.token;
|
|
135
152
|
let tokenGetter;
|
|
136
153
|
let onUnauthorizedHandler = config.onUnauthorized;
|
|
@@ -160,6 +177,23 @@ function createTransport(config, environment) {
|
|
|
160
177
|
...init?.headers || {}
|
|
161
178
|
};
|
|
162
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
|
+
}
|
|
163
197
|
async function request(path, init) {
|
|
164
198
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
165
199
|
let activeToken = token;
|
|
@@ -177,9 +211,27 @@ function createTransport(config, environment) {
|
|
|
177
211
|
if (res.status === 204) return void 0;
|
|
178
212
|
const text = await res.text().catch(() => "");
|
|
179
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;
|
|
180
230
|
if (text) try {
|
|
181
231
|
body = JSON.parse(text, rebaseReviver);
|
|
182
|
-
} catch (e) {
|
|
232
|
+
} catch (e) {
|
|
233
|
+
unreadableBody = true;
|
|
234
|
+
}
|
|
183
235
|
const getErrorField = (obj, field) => {
|
|
184
236
|
const err = obj?.error;
|
|
185
237
|
if (err && typeof err === "object" && err !== null) return err[field];
|
|
@@ -199,9 +251,12 @@ function createTransport(config, environment) {
|
|
|
199
251
|
if (retryRes.status === 204) return void 0;
|
|
200
252
|
const retryText = await retryRes.text().catch(() => "");
|
|
201
253
|
let retryBody = {};
|
|
254
|
+
let retryUnreadable = false;
|
|
202
255
|
if (retryText) try {
|
|
203
256
|
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
204
|
-
} catch (e) {
|
|
257
|
+
} catch (e) {
|
|
258
|
+
retryUnreadable = true;
|
|
259
|
+
}
|
|
205
260
|
if (!retryRes.ok) {
|
|
206
261
|
let fallbackMessage = retryRes.statusText;
|
|
207
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.`;
|
|
@@ -211,6 +266,7 @@ function createTransport(config, environment) {
|
|
|
211
266
|
details: getErrorField(retryBody, "details")
|
|
212
267
|
});
|
|
213
268
|
}
|
|
269
|
+
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
214
270
|
return retryBody;
|
|
215
271
|
}
|
|
216
272
|
}
|
|
@@ -223,6 +279,7 @@ function createTransport(config, environment) {
|
|
|
223
279
|
details: getErrorField(body, "details")
|
|
224
280
|
});
|
|
225
281
|
}
|
|
282
|
+
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
226
283
|
return body;
|
|
227
284
|
}
|
|
228
285
|
return {
|
|
@@ -316,6 +373,11 @@ function createAuth(transport, options) {
|
|
|
316
373
|
const authFlowMode = opts.authFlowMode || "json";
|
|
317
374
|
const STORAGE_KEY = "rebase_auth";
|
|
318
375
|
const REFRESH_BUFFER_MS = 12e4;
|
|
376
|
+
/**
|
|
377
|
+
* The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
|
|
378
|
+
* Anything larger is silently clamped to 1ms by Node and every browser.
|
|
379
|
+
*/
|
|
380
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
319
381
|
const MAX_REFRESH_RETRIES = 5;
|
|
320
382
|
const REFRESH_RETRY_BASE_MS = 1e3;
|
|
321
383
|
const REFRESH_RETRY_MAX_MS = 3e4;
|
|
@@ -343,7 +405,9 @@ function createAuth(transport, options) {
|
|
|
343
405
|
function emit(event, session) {
|
|
344
406
|
for (const fn of listeners) try {
|
|
345
407
|
fn(event, session);
|
|
346
|
-
} catch (e) {
|
|
408
|
+
} catch (e) {
|
|
409
|
+
console.error("Error in auth state change listener:", e);
|
|
410
|
+
}
|
|
347
411
|
}
|
|
348
412
|
function saveSession(session) {
|
|
349
413
|
if (!persistSession || authFlowMode === "cookie") return;
|
|
@@ -447,6 +511,10 @@ function createAuth(transport, options) {
|
|
|
447
511
|
attemptScheduledRefresh(0);
|
|
448
512
|
return;
|
|
449
513
|
}
|
|
514
|
+
if (delay > MAX_TIMER_DELAY_MS) {
|
|
515
|
+
refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
450
518
|
refreshTimeout = setTimeout(() => {
|
|
451
519
|
attemptScheduledRefresh(0);
|
|
452
520
|
}, delay);
|
|
@@ -1230,9 +1298,55 @@ var SDKQueryBuilder = class {
|
|
|
1230
1298
|
}
|
|
1231
1299
|
/**
|
|
1232
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
|
+
* ```
|
|
1233
1319
|
*/
|
|
1234
|
-
search(searchString) {
|
|
1320
|
+
search(searchString, options) {
|
|
1235
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
|
+
};
|
|
1236
1350
|
return this;
|
|
1237
1351
|
}
|
|
1238
1352
|
/**
|
|
@@ -1270,6 +1384,11 @@ var SDKQueryBuilder = class {
|
|
|
1270
1384
|
};
|
|
1271
1385
|
//#endregion
|
|
1272
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();
|
|
1273
1392
|
function createCollectionClient(transport, slug, ws) {
|
|
1274
1393
|
const basePath = `/data/${slug}`;
|
|
1275
1394
|
const client = {
|
|
@@ -1314,18 +1433,63 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1314
1433
|
body: JSON.stringify({
|
|
1315
1434
|
rows: data,
|
|
1316
1435
|
...options?.upsert ? { upsert: true } : {}
|
|
1317
|
-
})
|
|
1436
|
+
}),
|
|
1437
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1318
1438
|
})).data || [];
|
|
1319
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
|
+
*/
|
|
1320
1455
|
async update(id, data) {
|
|
1321
1456
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1322
1457
|
method: "PUT",
|
|
1323
1458
|
body: JSON.stringify(data)
|
|
1324
1459
|
});
|
|
1325
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
|
+
},
|
|
1326
1470
|
async delete(id) {
|
|
1327
1471
|
await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
|
|
1328
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
|
+
},
|
|
1329
1493
|
async count(params) {
|
|
1330
1494
|
const qs = buildQueryString({
|
|
1331
1495
|
...params,
|
|
@@ -1333,12 +1497,28 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1333
1497
|
offset: void 0,
|
|
1334
1498
|
include: void 0
|
|
1335
1499
|
});
|
|
1336
|
-
|
|
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
|
+
}
|
|
1337
1510
|
},
|
|
1338
1511
|
observe(params, onResult, onError, options) {
|
|
1339
1512
|
let closed = false;
|
|
1340
|
-
|
|
1513
|
+
let liveDelivered = false;
|
|
1514
|
+
let signature;
|
|
1515
|
+
const deliver = (result, fromLive) => {
|
|
1341
1516
|
if (closed) return;
|
|
1517
|
+
if (fromLive) liveDelivered = true;
|
|
1518
|
+
else if (liveDelivered) return;
|
|
1519
|
+
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
1520
|
+
if (signature !== void 0 && next === signature) return;
|
|
1521
|
+
signature = next;
|
|
1342
1522
|
onResult({
|
|
1343
1523
|
...result,
|
|
1344
1524
|
fromCache: false,
|
|
@@ -1346,10 +1526,10 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1346
1526
|
partial: false
|
|
1347
1527
|
});
|
|
1348
1528
|
};
|
|
1349
|
-
client.find(params).then(
|
|
1529
|
+
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
1350
1530
|
if (!closed) onError?.(error);
|
|
1351
1531
|
});
|
|
1352
|
-
const live = options?.realtime !== false && client.listen ? client.listen(params,
|
|
1532
|
+
const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
1353
1533
|
return () => {
|
|
1354
1534
|
closed = true;
|
|
1355
1535
|
live?.();
|
|
@@ -1357,17 +1537,24 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1357
1537
|
},
|
|
1358
1538
|
observeById(id, onResult, onError, options) {
|
|
1359
1539
|
let closed = false;
|
|
1360
|
-
|
|
1540
|
+
let liveDelivered = false;
|
|
1541
|
+
let signature;
|
|
1542
|
+
const deliver = (row, fromLive) => {
|
|
1361
1543
|
if (closed) return;
|
|
1544
|
+
if (fromLive) liveDelivered = true;
|
|
1545
|
+
else if (liveDelivered) return;
|
|
1546
|
+
const next = row === void 0 ? "\0missing" : JSON.stringify(row);
|
|
1547
|
+
if (signature !== void 0 && next === signature) return;
|
|
1548
|
+
signature = next;
|
|
1362
1549
|
onResult(row, {
|
|
1363
1550
|
fromCache: false,
|
|
1364
1551
|
hasPendingWrites: false
|
|
1365
1552
|
});
|
|
1366
1553
|
};
|
|
1367
|
-
client.findById(id).then(
|
|
1554
|
+
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
1368
1555
|
if (!closed) onError?.(error);
|
|
1369
1556
|
});
|
|
1370
|
-
const live = options?.realtime !== false && client.listenById ? client.listenById(id,
|
|
1557
|
+
const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
|
|
1371
1558
|
return () => {
|
|
1372
1559
|
closed = true;
|
|
1373
1560
|
live?.();
|
|
@@ -1387,8 +1574,11 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1387
1574
|
offset(count) {
|
|
1388
1575
|
return new SDKQueryBuilder(client).offset(count);
|
|
1389
1576
|
},
|
|
1390
|
-
search(searchString) {
|
|
1391
|
-
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);
|
|
1392
1582
|
},
|
|
1393
1583
|
include(...relations) {
|
|
1394
1584
|
return new SDKQueryBuilder(client).include(...relations);
|
|
@@ -1398,51 +1588,44 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1398
1588
|
client.listen = (params, onUpdate, onError) => {
|
|
1399
1589
|
let active = true;
|
|
1400
1590
|
let lastUpdateId = 0;
|
|
1591
|
+
let lastKnownTotal;
|
|
1592
|
+
const window = resolveFindWindow(params);
|
|
1401
1593
|
const unsub = ws.listenCollection({
|
|
1402
1594
|
path: slug,
|
|
1403
1595
|
filter: params?.where,
|
|
1596
|
+
logical: params?.logical,
|
|
1404
1597
|
limit: params?.limit,
|
|
1405
|
-
|
|
1598
|
+
offset: window.driverOffset,
|
|
1406
1599
|
orderBy: params?.orderBy?.[0],
|
|
1407
1600
|
order: params?.orderBy?.[1],
|
|
1408
|
-
searchString: params?.searchString
|
|
1601
|
+
searchString: params?.searchString,
|
|
1602
|
+
searchExplain: params?.searchExplain
|
|
1409
1603
|
}, (incomingRows) => {
|
|
1410
1604
|
const currentUpdateId = ++lastUpdateId;
|
|
1411
|
-
const requestedLimit =
|
|
1412
|
-
const offset =
|
|
1605
|
+
const requestedLimit = window.limit;
|
|
1606
|
+
const offset = window.offset;
|
|
1413
1607
|
const rows = incomingRows;
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
if (active && currentUpdateId === lastUpdateId) onUpdate({
|
|
1608
|
+
const emit = (total, hasMore) => {
|
|
1609
|
+
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
1610
|
+
onUpdate({
|
|
1418
1611
|
data: rows,
|
|
1419
1612
|
meta: {
|
|
1420
1613
|
total,
|
|
1421
1614
|
limit: requestedLimit,
|
|
1422
1615
|
offset,
|
|
1423
|
-
hasMore
|
|
1616
|
+
hasMore
|
|
1424
1617
|
}
|
|
1425
1618
|
});
|
|
1619
|
+
};
|
|
1620
|
+
const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
|
|
1621
|
+
if (client.count) client.count(params).then((total) => {
|
|
1622
|
+
lastKnownTotal = total;
|
|
1623
|
+
emit(total, offset + rows.length < total);
|
|
1426
1624
|
}).catch(() => {
|
|
1427
|
-
if (
|
|
1428
|
-
|
|
1429
|
-
meta: {
|
|
1430
|
-
total: heuristicTotal,
|
|
1431
|
-
limit: requestedLimit,
|
|
1432
|
-
offset,
|
|
1433
|
-
hasMore: heuristicHasMore
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
});
|
|
1437
|
-
else onUpdate({
|
|
1438
|
-
data: rows,
|
|
1439
|
-
meta: {
|
|
1440
|
-
total: heuristicTotal,
|
|
1441
|
-
limit: requestedLimit,
|
|
1442
|
-
offset,
|
|
1443
|
-
hasMore: heuristicHasMore
|
|
1444
|
-
}
|
|
1625
|
+
if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
1626
|
+
else emitWithoutCount();
|
|
1445
1627
|
});
|
|
1628
|
+
else emitWithoutCount();
|
|
1446
1629
|
}, onError);
|
|
1447
1630
|
return () => {
|
|
1448
1631
|
active = false;
|
|
@@ -1706,9 +1889,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
1706
1889
|
*
|
|
1707
1890
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
1708
1891
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
1709
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
1710
|
-
* package root
|
|
1711
|
-
*
|
|
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.
|
|
1712
1897
|
*/
|
|
1713
1898
|
var RebaseWebSocketClient = class {
|
|
1714
1899
|
websocketUrl;
|
|
@@ -2218,6 +2403,11 @@ var RebaseWebSocketClient = class {
|
|
|
2218
2403
|
callback.onError(new RebaseApiError$1(errorMessage, { code: errorCode }));
|
|
2219
2404
|
}
|
|
2220
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}`);
|
|
2221
2411
|
}
|
|
2222
2412
|
}
|
|
2223
2413
|
async ensureAuthenticated(retryCount = 3) {
|
|
@@ -2833,15 +3023,10 @@ var RebaseWebSocketClient = class {
|
|
|
2833
3023
|
}
|
|
2834
3024
|
}
|
|
2835
3025
|
createCollectionSubscriptionKey(props) {
|
|
3026
|
+
const { collection, ...query } = props;
|
|
2836
3027
|
const key = {
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
limit: props.limit,
|
|
2840
|
-
startAfter: props.startAfter,
|
|
2841
|
-
orderBy: props.orderBy,
|
|
2842
|
-
order: props.order,
|
|
2843
|
-
searchString: props.searchString,
|
|
2844
|
-
collection: props.collection?.name
|
|
3028
|
+
...query,
|
|
3029
|
+
collection: collection?.name
|
|
2845
3030
|
};
|
|
2846
3031
|
return JSON.stringify(key, (_, value) => {
|
|
2847
3032
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
|
|
@@ -3335,11 +3520,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
|
3335
3520
|
503,
|
|
3336
3521
|
504
|
|
3337
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
|
+
}
|
|
3338
3540
|
/** Is this failure worth another attempt later? */
|
|
3339
3541
|
function isRetryableError(error) {
|
|
3340
3542
|
if (isNetworkError(error)) return true;
|
|
3341
|
-
if (error instanceof RebaseApiError) return
|
|
3342
|
-
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);
|
|
3343
3546
|
}
|
|
3344
3547
|
/**
|
|
3345
3548
|
* Did this write fail because the row is already there?
|
|
@@ -3351,10 +3554,16 @@ function isRetryableError(error) {
|
|
|
3351
3554
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
3352
3555
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
3353
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.
|
|
3354
3562
|
*/
|
|
3355
3563
|
function isDuplicateKeyError(error) {
|
|
3356
3564
|
if (!(error instanceof RebaseApiError)) return false;
|
|
3357
|
-
|
|
3565
|
+
if (error.code === "23505") return true;
|
|
3566
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
3358
3567
|
}
|
|
3359
3568
|
var ConnectivityMonitor = class {
|
|
3360
3569
|
state = "online";
|
|
@@ -3764,14 +3973,23 @@ function looseEquals(a, b) {
|
|
|
3764
3973
|
*/
|
|
3765
3974
|
function likeToRegExp(pattern, caseInsensitive) {
|
|
3766
3975
|
let source = "^";
|
|
3976
|
+
let lastWasWildcard = false;
|
|
3767
3977
|
for (let i = 0; i < pattern.length; i++) {
|
|
3768
3978
|
const char = pattern[i];
|
|
3769
3979
|
if (char === "\\" && i + 1 < pattern.length) {
|
|
3770
3980
|
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3771
3981
|
i++;
|
|
3772
|
-
|
|
3773
|
-
else if (char === "
|
|
3774
|
-
|
|
3982
|
+
lastWasWildcard = false;
|
|
3983
|
+
} else if (char === "%") {
|
|
3984
|
+
if (!lastWasWildcard) source += "[\\s\\S]*";
|
|
3985
|
+
lastWasWildcard = true;
|
|
3986
|
+
} else if (char === "_") {
|
|
3987
|
+
source += "[\\s\\S]";
|
|
3988
|
+
lastWasWildcard = false;
|
|
3989
|
+
} else {
|
|
3990
|
+
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3991
|
+
lastWasWildcard = false;
|
|
3992
|
+
}
|
|
3775
3993
|
}
|
|
3776
3994
|
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
3777
3995
|
}
|
|
@@ -3903,14 +4121,41 @@ function sortRows(rows, orderBy) {
|
|
|
3903
4121
|
function tiebreak(a, b) {
|
|
3904
4122
|
return compareValues(a.id, b.id) ?? 0;
|
|
3905
4123
|
}
|
|
3906
|
-
/**
|
|
4124
|
+
/**
|
|
4125
|
+
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
4126
|
+
*
|
|
4127
|
+
* It did not: this defaulted an absent limit to 20 while `/api/data` pages by
|
|
4128
|
+
* 50, so the same `observe()` answered with 20 rows from the local database and
|
|
4129
|
+
* 50 from the network — a list that changed length depending on which side
|
|
4130
|
+
* answered, with `page` striding differently on each. Delegated now, so the
|
|
4131
|
+
* sentence above is true by construction rather than by agreement.
|
|
4132
|
+
*/
|
|
3907
4133
|
function resolvePagination(params) {
|
|
3908
|
-
const limit = params
|
|
4134
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
3909
4135
|
return {
|
|
3910
4136
|
limit,
|
|
3911
|
-
offset
|
|
4137
|
+
offset
|
|
3912
4138
|
};
|
|
3913
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
|
+
}
|
|
3914
4159
|
/**
|
|
3915
4160
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
3916
4161
|
* assuming the cache holds every row of the collection?
|
|
@@ -3918,11 +4163,67 @@ function resolvePagination(params) {
|
|
|
3918
4163
|
* `include` pulls in rows from other collections that this evaluator never
|
|
3919
4164
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
3920
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}.
|
|
3921
4188
|
*/
|
|
3922
4189
|
function isExactlyEvaluable(params) {
|
|
3923
4190
|
if (!params) return true;
|
|
3924
4191
|
if (params.include && params.include.length > 0) return false;
|
|
3925
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
|
+
}
|
|
3926
4227
|
return true;
|
|
3927
4228
|
}
|
|
3928
4229
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
@@ -3958,6 +4259,17 @@ function generateOfflineId() {
|
|
|
3958
4259
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
3959
4260
|
}
|
|
3960
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;
|
|
3961
4273
|
var OfflineManager = class {
|
|
3962
4274
|
store;
|
|
3963
4275
|
maxCachedQueries;
|
|
@@ -4238,6 +4550,72 @@ var OfflineManager = class {
|
|
|
4238
4550
|
this.notifyCollection(slug);
|
|
4239
4551
|
return rows;
|
|
4240
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
|
+
},
|
|
4241
4619
|
update: async (id, data) => {
|
|
4242
4620
|
await this.ensureCollection(slug);
|
|
4243
4621
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
@@ -4316,7 +4694,8 @@ var OfflineManager = class {
|
|
|
4316
4694
|
orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
|
|
4317
4695
|
limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
|
|
4318
4696
|
offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
|
|
4319
|
-
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),
|
|
4320
4699
|
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
|
|
4321
4700
|
};
|
|
4322
4701
|
if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
@@ -4536,9 +4915,8 @@ var OfflineManager = class {
|
|
|
4536
4915
|
if (!state) return {
|
|
4537
4916
|
data: [],
|
|
4538
4917
|
meta: {
|
|
4918
|
+
...resolvePagination(params),
|
|
4539
4919
|
total: 0,
|
|
4540
|
-
limit: params?.limit ?? 20,
|
|
4541
|
-
offset: params?.offset ?? 0,
|
|
4542
4920
|
hasMore: false
|
|
4543
4921
|
},
|
|
4544
4922
|
fromCache: true,
|
|
@@ -4574,16 +4952,15 @@ var OfflineManager = class {
|
|
|
4574
4952
|
}
|
|
4575
4953
|
let added = 0;
|
|
4576
4954
|
const offset = snapshot.offset ?? 0;
|
|
4577
|
-
if (exact && offset === 0) {
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
added++;
|
|
4584
|
-
}
|
|
4585
|
-
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++;
|
|
4586
4961
|
}
|
|
4962
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
4963
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
4587
4964
|
return {
|
|
4588
4965
|
data: rows,
|
|
4589
4966
|
meta: {
|
|
@@ -4594,7 +4971,7 @@ var OfflineManager = class {
|
|
|
4594
4971
|
},
|
|
4595
4972
|
fromCache,
|
|
4596
4973
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
4597
|
-
partial: !exact
|
|
4974
|
+
partial: !exact || !orderIsLocal
|
|
4598
4975
|
};
|
|
4599
4976
|
}
|
|
4600
4977
|
localFind(slug, params) {
|
|
@@ -4730,17 +5107,17 @@ var OfflineManager = class {
|
|
|
4730
5107
|
return row;
|
|
4731
5108
|
}
|
|
4732
5109
|
recordSnapshot(slug, params, result) {
|
|
5110
|
+
const window = resolvePagination(params);
|
|
4733
5111
|
const meta = result.meta ?? {
|
|
4734
5112
|
total: result.data?.length ?? 0,
|
|
4735
|
-
|
|
4736
|
-
offset: 0,
|
|
5113
|
+
...window,
|
|
4737
5114
|
hasMore: false
|
|
4738
5115
|
};
|
|
4739
5116
|
const snapshot = {
|
|
4740
5117
|
ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
|
|
4741
5118
|
total: meta.total ?? result.data?.length ?? 0,
|
|
4742
|
-
limit: meta.limit ??
|
|
4743
|
-
offset: meta.offset ??
|
|
5119
|
+
limit: meta.limit ?? window.limit,
|
|
5120
|
+
offset: meta.offset ?? window.offset,
|
|
4744
5121
|
hasMore: meta.hasMore ?? false
|
|
4745
5122
|
};
|
|
4746
5123
|
const state = this.collectionState(slug);
|
|
@@ -4909,7 +5286,8 @@ var OfflineManager = class {
|
|
|
4909
5286
|
}
|
|
4910
5287
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
4911
5288
|
op.lastError = error?.message ?? String(error);
|
|
4912
|
-
|
|
5289
|
+
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
5290
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
4913
5291
|
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
4914
5292
|
this.connectivity.deferRetry();
|
|
4915
5293
|
this.patchStatus({ lastError: op.lastError });
|
|
@@ -4955,11 +5333,25 @@ var OfflineManager = class {
|
|
|
4955
5333
|
await this.adoptServerRow(op, op.id, row);
|
|
4956
5334
|
} else if (op.type === "createMany") {
|
|
4957
5335
|
const queued = op.data ?? [];
|
|
4958
|
-
const rows = await inner.createMany(queued,
|
|
5336
|
+
const rows = await inner.createMany(queued, {
|
|
5337
|
+
...op.upsert ? { upsert: true } : {},
|
|
5338
|
+
idempotencyKey: op.mutationId
|
|
5339
|
+
});
|
|
4959
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]);
|
|
4960
5348
|
} else if (op.type === "update") {
|
|
4961
5349
|
const row = await inner.update(op.id, op.data);
|
|
4962
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);
|
|
4963
5355
|
} else if (op.type === "delete") {
|
|
4964
5356
|
await inner.delete(op.id);
|
|
4965
5357
|
this.removeLocalRow(op.collection, op.id, true);
|
|
@@ -5204,23 +5596,37 @@ var OfflineManager = class {
|
|
|
5204
5596
|
/**
|
|
5205
5597
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
5206
5598
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
5599
|
+
*
|
|
5600
|
+
* A backend mounted under a path is the reason `baseUrl` accepts one, so the
|
|
5601
|
+
* path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
|
|
5602
|
+
* relative one — resolved through `.origin` — so one deployment dialled two
|
|
5603
|
+
* different sockets depending on whether its config said `"/backend"` or
|
|
5604
|
+
* `"https://app.example.com/backend"`.
|
|
5605
|
+
*
|
|
5606
|
+
* Returns `""` when there is nothing to resolve against: a relative `baseUrl`
|
|
5607
|
+
* outside a browser has no origin, and inventing one would dial somewhere
|
|
5608
|
+
* arbitrary. The caller warns rather than leaving that silent.
|
|
5207
5609
|
*/
|
|
5208
5610
|
function deriveWebSocketUrl(baseUrl) {
|
|
5611
|
+
const toWsProtocol = (url) => {
|
|
5612
|
+
const secure = /^(https|wss):/i.test(url);
|
|
5613
|
+
return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
|
|
5614
|
+
};
|
|
5209
5615
|
if (typeof window !== "undefined") {
|
|
5210
|
-
let absoluteUrl
|
|
5616
|
+
let absoluteUrl;
|
|
5211
5617
|
if (!baseUrl) absoluteUrl = window.location.origin;
|
|
5212
5618
|
else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
|
|
5213
5619
|
else try {
|
|
5214
|
-
|
|
5620
|
+
const resolved = new URL(baseUrl, window.location.href);
|
|
5621
|
+
absoluteUrl = resolved.origin + resolved.pathname;
|
|
5215
5622
|
} catch {
|
|
5216
5623
|
absoluteUrl = window.location.origin;
|
|
5217
5624
|
}
|
|
5218
|
-
|
|
5219
|
-
return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
|
|
5625
|
+
return toWsProtocol(absoluteUrl);
|
|
5220
5626
|
}
|
|
5221
5627
|
if (!baseUrl) return "";
|
|
5222
5628
|
if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
|
|
5223
|
-
return baseUrl
|
|
5629
|
+
return toWsProtocol(baseUrl);
|
|
5224
5630
|
}
|
|
5225
5631
|
function createRebaseClient(options) {
|
|
5226
5632
|
const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
|
|
@@ -5248,7 +5654,11 @@ function createRebaseClient(options) {
|
|
|
5248
5654
|
});
|
|
5249
5655
|
return storageSourcesPromise;
|
|
5250
5656
|
};
|
|
5251
|
-
const
|
|
5657
|
+
const realtimeEnabled = options.realtime !== false;
|
|
5658
|
+
const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
5659
|
+
const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
|
|
5660
|
+
const unreachableReason = `no WebSocket URL could be derived from baseUrl ${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin to resolve a relative URL against. Pass an absolute \`baseUrl\`, set \`websocketUrl\` explicitly, or pass \`realtime: false\` to say this was intended.`;
|
|
5661
|
+
if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
|
|
5252
5662
|
let ws;
|
|
5253
5663
|
/** One channel object per name — see `realtime.channel`. */
|
|
5254
5664
|
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
@@ -5368,7 +5778,7 @@ function createRebaseClient(options) {
|
|
|
5368
5778
|
* cut off the others.
|
|
5369
5779
|
*/
|
|
5370
5780
|
channel: (name, options) => {
|
|
5371
|
-
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
5781
|
+
if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
5372
5782
|
let existing = realtimeChannels.get(name);
|
|
5373
5783
|
if (!existing) {
|
|
5374
5784
|
existing = new RebaseRealtimeChannel(name, ws, options);
|