@rebasepro/client 0.13.0 → 0.13.1-canary.g18cfeb7
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 +263 -73
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/dist/offline-store.d.ts +8 -1
- 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 +184 -50
- package/src/index.ts +51 -15
- package/src/like-pattern-redos.test.ts +61 -0
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +35 -8
- package/src/offline-store.ts +5 -1
- package/src/offline.test.ts +114 -1
- package/src/offline.ts +121 -7
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +22 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +18 -9
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
|
@@ -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) {
|
|
@@ -131,6 +131,13 @@ function resolveBaseUrl(configured) {
|
|
|
131
131
|
function createTransport(config, environment) {
|
|
132
132
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
133
133
|
const apiPath = config.apiPath || "/api";
|
|
134
|
+
for (const field of ["baseUrl", "storageUrlOrigin"]) {
|
|
135
|
+
const value = config[field];
|
|
136
|
+
if (!value || !apiPath) continue;
|
|
137
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
138
|
+
if (!trimmed.endsWith(apiPath)) continue;
|
|
139
|
+
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.`);
|
|
140
|
+
}
|
|
134
141
|
let token = config.token;
|
|
135
142
|
let tokenGetter;
|
|
136
143
|
let onUnauthorizedHandler = config.onUnauthorized;
|
|
@@ -316,6 +323,11 @@ function createAuth(transport, options) {
|
|
|
316
323
|
const authFlowMode = opts.authFlowMode || "json";
|
|
317
324
|
const STORAGE_KEY = "rebase_auth";
|
|
318
325
|
const REFRESH_BUFFER_MS = 12e4;
|
|
326
|
+
/**
|
|
327
|
+
* The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
|
|
328
|
+
* Anything larger is silently clamped to 1ms by Node and every browser.
|
|
329
|
+
*/
|
|
330
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
319
331
|
const MAX_REFRESH_RETRIES = 5;
|
|
320
332
|
const REFRESH_RETRY_BASE_MS = 1e3;
|
|
321
333
|
const REFRESH_RETRY_MAX_MS = 3e4;
|
|
@@ -343,7 +355,9 @@ function createAuth(transport, options) {
|
|
|
343
355
|
function emit(event, session) {
|
|
344
356
|
for (const fn of listeners) try {
|
|
345
357
|
fn(event, session);
|
|
346
|
-
} catch (e) {
|
|
358
|
+
} catch (e) {
|
|
359
|
+
console.error("Error in auth state change listener:", e);
|
|
360
|
+
}
|
|
347
361
|
}
|
|
348
362
|
function saveSession(session) {
|
|
349
363
|
if (!persistSession || authFlowMode === "cookie") return;
|
|
@@ -447,6 +461,10 @@ function createAuth(transport, options) {
|
|
|
447
461
|
attemptScheduledRefresh(0);
|
|
448
462
|
return;
|
|
449
463
|
}
|
|
464
|
+
if (delay > MAX_TIMER_DELAY_MS) {
|
|
465
|
+
refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
450
468
|
refreshTimeout = setTimeout(() => {
|
|
451
469
|
attemptScheduledRefresh(0);
|
|
452
470
|
}, delay);
|
|
@@ -1270,6 +1288,11 @@ var SDKQueryBuilder = class {
|
|
|
1270
1288
|
};
|
|
1271
1289
|
//#endregion
|
|
1272
1290
|
//#region src/collection.ts
|
|
1291
|
+
/**
|
|
1292
|
+
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
1293
|
+
* live only for the duration of the request — see `count()` for why.
|
|
1294
|
+
*/
|
|
1295
|
+
var inflightCounts = /* @__PURE__ */ new Map();
|
|
1273
1296
|
function createCollectionClient(transport, slug, ws) {
|
|
1274
1297
|
const basePath = `/data/${slug}`;
|
|
1275
1298
|
const client = {
|
|
@@ -1314,18 +1337,63 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1314
1337
|
body: JSON.stringify({
|
|
1315
1338
|
rows: data,
|
|
1316
1339
|
...options?.upsert ? { upsert: true } : {}
|
|
1317
|
-
})
|
|
1340
|
+
}),
|
|
1341
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1318
1342
|
})).data || [];
|
|
1319
1343
|
},
|
|
1344
|
+
/**
|
|
1345
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
1346
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
1347
|
+
*
|
|
1348
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
1349
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
1350
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
1351
|
+
* a route that does not exist and get a **404**, which is
|
|
1352
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
1353
|
+
* a missing record.
|
|
1354
|
+
*
|
|
1355
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
1356
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
1357
|
+
* supported server is one that serves both.
|
|
1358
|
+
*/
|
|
1320
1359
|
async update(id, data) {
|
|
1321
1360
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1322
1361
|
method: "PUT",
|
|
1323
1362
|
body: JSON.stringify(data)
|
|
1324
1363
|
});
|
|
1325
1364
|
},
|
|
1365
|
+
async updateMany(updates, options) {
|
|
1366
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
1367
|
+
if (updates.length === 0) return [];
|
|
1368
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
1369
|
+
method: "PATCH",
|
|
1370
|
+
body: JSON.stringify({ updates }),
|
|
1371
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1372
|
+
})).data || [];
|
|
1373
|
+
},
|
|
1326
1374
|
async delete(id) {
|
|
1327
1375
|
await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
|
|
1328
1376
|
},
|
|
1377
|
+
/**
|
|
1378
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
1379
|
+
*
|
|
1380
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
1381
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
1382
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
1383
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
1384
|
+
* operation, so a generated client would send the request without its
|
|
1385
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
1386
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
1387
|
+
*/
|
|
1388
|
+
async deleteMany(ids, options) {
|
|
1389
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
1390
|
+
if (ids.length === 0) return;
|
|
1391
|
+
await transport.request(`${basePath}/bulk/delete`, {
|
|
1392
|
+
method: "POST",
|
|
1393
|
+
body: JSON.stringify({ ids }),
|
|
1394
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1395
|
+
});
|
|
1396
|
+
},
|
|
1329
1397
|
async count(params) {
|
|
1330
1398
|
const qs = buildQueryString({
|
|
1331
1399
|
...params,
|
|
@@ -1333,12 +1401,28 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1333
1401
|
offset: void 0,
|
|
1334
1402
|
include: void 0
|
|
1335
1403
|
});
|
|
1336
|
-
|
|
1404
|
+
const key = basePath + "/count" + qs;
|
|
1405
|
+
const inflight = inflightCounts.get(key);
|
|
1406
|
+
if (inflight) return inflight;
|
|
1407
|
+
const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
|
|
1408
|
+
inflightCounts.set(key, request);
|
|
1409
|
+
try {
|
|
1410
|
+
return await request;
|
|
1411
|
+
} finally {
|
|
1412
|
+
inflightCounts.delete(key);
|
|
1413
|
+
}
|
|
1337
1414
|
},
|
|
1338
1415
|
observe(params, onResult, onError, options) {
|
|
1339
1416
|
let closed = false;
|
|
1340
|
-
|
|
1417
|
+
let liveDelivered = false;
|
|
1418
|
+
let signature;
|
|
1419
|
+
const deliver = (result, fromLive) => {
|
|
1341
1420
|
if (closed) return;
|
|
1421
|
+
if (fromLive) liveDelivered = true;
|
|
1422
|
+
else if (liveDelivered) return;
|
|
1423
|
+
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
1424
|
+
if (signature !== void 0 && next === signature) return;
|
|
1425
|
+
signature = next;
|
|
1342
1426
|
onResult({
|
|
1343
1427
|
...result,
|
|
1344
1428
|
fromCache: false,
|
|
@@ -1346,10 +1430,10 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1346
1430
|
partial: false
|
|
1347
1431
|
});
|
|
1348
1432
|
};
|
|
1349
|
-
client.find(params).then(
|
|
1433
|
+
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
1350
1434
|
if (!closed) onError?.(error);
|
|
1351
1435
|
});
|
|
1352
|
-
const live = options?.realtime !== false && client.listen ? client.listen(params,
|
|
1436
|
+
const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
1353
1437
|
return () => {
|
|
1354
1438
|
closed = true;
|
|
1355
1439
|
live?.();
|
|
@@ -1357,17 +1441,24 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1357
1441
|
},
|
|
1358
1442
|
observeById(id, onResult, onError, options) {
|
|
1359
1443
|
let closed = false;
|
|
1360
|
-
|
|
1444
|
+
let liveDelivered = false;
|
|
1445
|
+
let signature;
|
|
1446
|
+
const deliver = (row, fromLive) => {
|
|
1361
1447
|
if (closed) return;
|
|
1448
|
+
if (fromLive) liveDelivered = true;
|
|
1449
|
+
else if (liveDelivered) return;
|
|
1450
|
+
const next = row === void 0 ? "\0missing" : JSON.stringify(row);
|
|
1451
|
+
if (signature !== void 0 && next === signature) return;
|
|
1452
|
+
signature = next;
|
|
1362
1453
|
onResult(row, {
|
|
1363
1454
|
fromCache: false,
|
|
1364
1455
|
hasPendingWrites: false
|
|
1365
1456
|
});
|
|
1366
1457
|
};
|
|
1367
|
-
client.findById(id).then(
|
|
1458
|
+
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
1368
1459
|
if (!closed) onError?.(error);
|
|
1369
1460
|
});
|
|
1370
|
-
const live = options?.realtime !== false && client.listenById ? client.listenById(id,
|
|
1461
|
+
const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
|
|
1371
1462
|
return () => {
|
|
1372
1463
|
closed = true;
|
|
1373
1464
|
live?.();
|
|
@@ -1398,51 +1489,43 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1398
1489
|
client.listen = (params, onUpdate, onError) => {
|
|
1399
1490
|
let active = true;
|
|
1400
1491
|
let lastUpdateId = 0;
|
|
1492
|
+
let lastKnownTotal;
|
|
1493
|
+
const window = resolveFindWindow(params);
|
|
1401
1494
|
const unsub = ws.listenCollection({
|
|
1402
1495
|
path: slug,
|
|
1403
1496
|
filter: params?.where,
|
|
1497
|
+
logical: params?.logical,
|
|
1404
1498
|
limit: params?.limit,
|
|
1405
|
-
|
|
1499
|
+
offset: window.driverOffset,
|
|
1406
1500
|
orderBy: params?.orderBy?.[0],
|
|
1407
1501
|
order: params?.orderBy?.[1],
|
|
1408
1502
|
searchString: params?.searchString
|
|
1409
1503
|
}, (incomingRows) => {
|
|
1410
1504
|
const currentUpdateId = ++lastUpdateId;
|
|
1411
|
-
const requestedLimit =
|
|
1412
|
-
const offset =
|
|
1505
|
+
const requestedLimit = window.limit;
|
|
1506
|
+
const offset = window.offset;
|
|
1413
1507
|
const rows = incomingRows;
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
if (active && currentUpdateId === lastUpdateId) onUpdate({
|
|
1508
|
+
const emit = (total, hasMore) => {
|
|
1509
|
+
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
1510
|
+
onUpdate({
|
|
1418
1511
|
data: rows,
|
|
1419
1512
|
meta: {
|
|
1420
1513
|
total,
|
|
1421
1514
|
limit: requestedLimit,
|
|
1422
1515
|
offset,
|
|
1423
|
-
hasMore
|
|
1516
|
+
hasMore
|
|
1424
1517
|
}
|
|
1425
1518
|
});
|
|
1519
|
+
};
|
|
1520
|
+
const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
|
|
1521
|
+
if (client.count) client.count(params).then((total) => {
|
|
1522
|
+
lastKnownTotal = total;
|
|
1523
|
+
emit(total, offset + rows.length < total);
|
|
1426
1524
|
}).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
|
-
}
|
|
1525
|
+
if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
1526
|
+
else emitWithoutCount();
|
|
1445
1527
|
});
|
|
1528
|
+
else emitWithoutCount();
|
|
1446
1529
|
}, onError);
|
|
1447
1530
|
return () => {
|
|
1448
1531
|
active = false;
|
|
@@ -2833,15 +2916,10 @@ var RebaseWebSocketClient = class {
|
|
|
2833
2916
|
}
|
|
2834
2917
|
}
|
|
2835
2918
|
createCollectionSubscriptionKey(props) {
|
|
2919
|
+
const { collection, ...query } = props;
|
|
2836
2920
|
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
|
|
2921
|
+
...query,
|
|
2922
|
+
collection: collection?.name
|
|
2845
2923
|
};
|
|
2846
2924
|
return JSON.stringify(key, (_, value) => {
|
|
2847
2925
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
|
|
@@ -3764,14 +3842,23 @@ function looseEquals(a, b) {
|
|
|
3764
3842
|
*/
|
|
3765
3843
|
function likeToRegExp(pattern, caseInsensitive) {
|
|
3766
3844
|
let source = "^";
|
|
3845
|
+
let lastWasWildcard = false;
|
|
3767
3846
|
for (let i = 0; i < pattern.length; i++) {
|
|
3768
3847
|
const char = pattern[i];
|
|
3769
3848
|
if (char === "\\" && i + 1 < pattern.length) {
|
|
3770
3849
|
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3771
3850
|
i++;
|
|
3772
|
-
|
|
3773
|
-
else if (char === "
|
|
3774
|
-
|
|
3851
|
+
lastWasWildcard = false;
|
|
3852
|
+
} else if (char === "%") {
|
|
3853
|
+
if (!lastWasWildcard) source += "[\\s\\S]*";
|
|
3854
|
+
lastWasWildcard = true;
|
|
3855
|
+
} else if (char === "_") {
|
|
3856
|
+
source += "[\\s\\S]";
|
|
3857
|
+
lastWasWildcard = false;
|
|
3858
|
+
} else {
|
|
3859
|
+
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3860
|
+
lastWasWildcard = false;
|
|
3861
|
+
}
|
|
3775
3862
|
}
|
|
3776
3863
|
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
3777
3864
|
}
|
|
@@ -3903,12 +3990,20 @@ function sortRows(rows, orderBy) {
|
|
|
3903
3990
|
function tiebreak(a, b) {
|
|
3904
3991
|
return compareValues(a.id, b.id) ?? 0;
|
|
3905
3992
|
}
|
|
3906
|
-
/**
|
|
3993
|
+
/**
|
|
3994
|
+
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
3995
|
+
*
|
|
3996
|
+
* It did not: this defaulted an absent limit to 20 while `/api/data` pages by
|
|
3997
|
+
* 50, so the same `observe()` answered with 20 rows from the local database and
|
|
3998
|
+
* 50 from the network — a list that changed length depending on which side
|
|
3999
|
+
* answered, with `page` striding differently on each. Delegated now, so the
|
|
4000
|
+
* sentence above is true by construction rather than by agreement.
|
|
4001
|
+
*/
|
|
3907
4002
|
function resolvePagination(params) {
|
|
3908
|
-
const limit = params
|
|
4003
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
3909
4004
|
return {
|
|
3910
4005
|
limit,
|
|
3911
|
-
offset
|
|
4006
|
+
offset
|
|
3912
4007
|
};
|
|
3913
4008
|
}
|
|
3914
4009
|
/**
|
|
@@ -4238,6 +4333,72 @@ var OfflineManager = class {
|
|
|
4238
4333
|
this.notifyCollection(slug);
|
|
4239
4334
|
return rows;
|
|
4240
4335
|
},
|
|
4336
|
+
updateMany: async (updates, options) => {
|
|
4337
|
+
await this.ensureCollection(slug);
|
|
4338
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
4339
|
+
if (updates.length === 0) return [];
|
|
4340
|
+
const anyPending = updates.some((u) => this.hasPending(slug, u.id));
|
|
4341
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
4342
|
+
const rows = await inner.updateMany(updates, options);
|
|
4343
|
+
this.connectivity.markSuccess();
|
|
4344
|
+
await this.ingest(slug, rows);
|
|
4345
|
+
this.notifyCollection(slug);
|
|
4346
|
+
return rows;
|
|
4347
|
+
} catch (error) {
|
|
4348
|
+
if (!isNetworkError(error)) throw error;
|
|
4349
|
+
this.connectivity.markFailure();
|
|
4350
|
+
}
|
|
4351
|
+
const rollback = {};
|
|
4352
|
+
const optimistic = [];
|
|
4353
|
+
for (const { id, data } of updates) {
|
|
4354
|
+
const base = this.rawLocalRow(slug, id);
|
|
4355
|
+
rollback[String(id)] = base ?? null;
|
|
4356
|
+
optimistic.push({
|
|
4357
|
+
...base ?? {},
|
|
4358
|
+
...data,
|
|
4359
|
+
id
|
|
4360
|
+
});
|
|
4361
|
+
}
|
|
4362
|
+
await this.enqueue({
|
|
4363
|
+
collection: slug,
|
|
4364
|
+
type: "updateMany",
|
|
4365
|
+
updates: updates.map((u) => ({
|
|
4366
|
+
id: u.id,
|
|
4367
|
+
data: u.data
|
|
4368
|
+
})),
|
|
4369
|
+
rollback: { rows: rollback }
|
|
4370
|
+
});
|
|
4371
|
+
for (const row of optimistic) this.setLocalRow(slug, row.id, row);
|
|
4372
|
+
this.notifyCollection(slug);
|
|
4373
|
+
return optimistic;
|
|
4374
|
+
},
|
|
4375
|
+
deleteMany: async (ids, options) => {
|
|
4376
|
+
await this.ensureCollection(slug);
|
|
4377
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
4378
|
+
if (ids.length === 0) return;
|
|
4379
|
+
const anyPending = ids.some((id) => this.hasPending(slug, id));
|
|
4380
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
4381
|
+
await inner.deleteMany(ids, options);
|
|
4382
|
+
this.connectivity.markSuccess();
|
|
4383
|
+
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
4384
|
+
this.notifyCollection(slug);
|
|
4385
|
+
this.scheduleRefresh(slug);
|
|
4386
|
+
return;
|
|
4387
|
+
} catch (error) {
|
|
4388
|
+
if (!isNetworkError(error)) throw error;
|
|
4389
|
+
this.connectivity.markFailure();
|
|
4390
|
+
}
|
|
4391
|
+
const rollback = {};
|
|
4392
|
+
for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
|
|
4393
|
+
await this.enqueue({
|
|
4394
|
+
collection: slug,
|
|
4395
|
+
type: "deleteMany",
|
|
4396
|
+
ids,
|
|
4397
|
+
rollback: { rows: rollback }
|
|
4398
|
+
});
|
|
4399
|
+
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
4400
|
+
this.notifyCollection(slug);
|
|
4401
|
+
},
|
|
4241
4402
|
update: async (id, data) => {
|
|
4242
4403
|
await this.ensureCollection(slug);
|
|
4243
4404
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
@@ -4536,9 +4697,8 @@ var OfflineManager = class {
|
|
|
4536
4697
|
if (!state) return {
|
|
4537
4698
|
data: [],
|
|
4538
4699
|
meta: {
|
|
4700
|
+
...resolvePagination(params),
|
|
4539
4701
|
total: 0,
|
|
4540
|
-
limit: params?.limit ?? 20,
|
|
4541
|
-
offset: params?.offset ?? 0,
|
|
4542
4702
|
hasMore: false
|
|
4543
4703
|
},
|
|
4544
4704
|
fromCache: true,
|
|
@@ -4574,16 +4734,14 @@ var OfflineManager = class {
|
|
|
4574
4734
|
}
|
|
4575
4735
|
let added = 0;
|
|
4576
4736
|
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);
|
|
4737
|
+
if (exact && offset === 0) for (const [key, entry] of state.rows) {
|
|
4738
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
4739
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
4740
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
4741
|
+
rows.push(entry.row);
|
|
4742
|
+
added++;
|
|
4586
4743
|
}
|
|
4744
|
+
if (params?.orderBy) sortRows(rows, params.orderBy);
|
|
4587
4745
|
return {
|
|
4588
4746
|
data: rows,
|
|
4589
4747
|
meta: {
|
|
@@ -4730,17 +4888,17 @@ var OfflineManager = class {
|
|
|
4730
4888
|
return row;
|
|
4731
4889
|
}
|
|
4732
4890
|
recordSnapshot(slug, params, result) {
|
|
4891
|
+
const window = resolvePagination(params);
|
|
4733
4892
|
const meta = result.meta ?? {
|
|
4734
4893
|
total: result.data?.length ?? 0,
|
|
4735
|
-
|
|
4736
|
-
offset: 0,
|
|
4894
|
+
...window,
|
|
4737
4895
|
hasMore: false
|
|
4738
4896
|
};
|
|
4739
4897
|
const snapshot = {
|
|
4740
4898
|
ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
|
|
4741
4899
|
total: meta.total ?? result.data?.length ?? 0,
|
|
4742
|
-
limit: meta.limit ??
|
|
4743
|
-
offset: meta.offset ??
|
|
4900
|
+
limit: meta.limit ?? window.limit,
|
|
4901
|
+
offset: meta.offset ?? window.offset,
|
|
4744
4902
|
hasMore: meta.hasMore ?? false
|
|
4745
4903
|
};
|
|
4746
4904
|
const state = this.collectionState(slug);
|
|
@@ -4955,11 +5113,25 @@ var OfflineManager = class {
|
|
|
4955
5113
|
await this.adoptServerRow(op, op.id, row);
|
|
4956
5114
|
} else if (op.type === "createMany") {
|
|
4957
5115
|
const queued = op.data ?? [];
|
|
4958
|
-
const rows = await inner.createMany(queued,
|
|
5116
|
+
const rows = await inner.createMany(queued, {
|
|
5117
|
+
...op.upsert ? { upsert: true } : {},
|
|
5118
|
+
idempotencyKey: op.mutationId
|
|
5119
|
+
});
|
|
4959
5120
|
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
5121
|
+
} else if (op.type === "updateMany") {
|
|
5122
|
+
const queued = op.updates ?? [];
|
|
5123
|
+
const rows = await inner.updateMany(queued.map((u) => ({
|
|
5124
|
+
id: u.id,
|
|
5125
|
+
data: u.data
|
|
5126
|
+
})), { idempotencyKey: op.mutationId });
|
|
5127
|
+
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
4960
5128
|
} else if (op.type === "update") {
|
|
4961
5129
|
const row = await inner.update(op.id, op.data);
|
|
4962
5130
|
await this.ingestReplaced(op, op.id, row);
|
|
5131
|
+
} else if (op.type === "deleteMany") {
|
|
5132
|
+
const ids = op.ids ?? [];
|
|
5133
|
+
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
5134
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
4963
5135
|
} else if (op.type === "delete") {
|
|
4964
5136
|
await inner.delete(op.id);
|
|
4965
5137
|
this.removeLocalRow(op.collection, op.id, true);
|
|
@@ -5204,23 +5376,37 @@ var OfflineManager = class {
|
|
|
5204
5376
|
/**
|
|
5205
5377
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
5206
5378
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
5379
|
+
*
|
|
5380
|
+
* A backend mounted under a path is the reason `baseUrl` accepts one, so the
|
|
5381
|
+
* path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
|
|
5382
|
+
* relative one — resolved through `.origin` — so one deployment dialled two
|
|
5383
|
+
* different sockets depending on whether its config said `"/backend"` or
|
|
5384
|
+
* `"https://app.example.com/backend"`.
|
|
5385
|
+
*
|
|
5386
|
+
* Returns `""` when there is nothing to resolve against: a relative `baseUrl`
|
|
5387
|
+
* outside a browser has no origin, and inventing one would dial somewhere
|
|
5388
|
+
* arbitrary. The caller warns rather than leaving that silent.
|
|
5207
5389
|
*/
|
|
5208
5390
|
function deriveWebSocketUrl(baseUrl) {
|
|
5391
|
+
const toWsProtocol = (url) => {
|
|
5392
|
+
const secure = /^(https|wss):/i.test(url);
|
|
5393
|
+
return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
|
|
5394
|
+
};
|
|
5209
5395
|
if (typeof window !== "undefined") {
|
|
5210
|
-
let absoluteUrl
|
|
5396
|
+
let absoluteUrl;
|
|
5211
5397
|
if (!baseUrl) absoluteUrl = window.location.origin;
|
|
5212
5398
|
else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
|
|
5213
5399
|
else try {
|
|
5214
|
-
|
|
5400
|
+
const resolved = new URL(baseUrl, window.location.href);
|
|
5401
|
+
absoluteUrl = resolved.origin + resolved.pathname;
|
|
5215
5402
|
} catch {
|
|
5216
5403
|
absoluteUrl = window.location.origin;
|
|
5217
5404
|
}
|
|
5218
|
-
|
|
5219
|
-
return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
|
|
5405
|
+
return toWsProtocol(absoluteUrl);
|
|
5220
5406
|
}
|
|
5221
5407
|
if (!baseUrl) return "";
|
|
5222
5408
|
if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
|
|
5223
|
-
return baseUrl
|
|
5409
|
+
return toWsProtocol(baseUrl);
|
|
5224
5410
|
}
|
|
5225
5411
|
function createRebaseClient(options) {
|
|
5226
5412
|
const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
|
|
@@ -5248,7 +5434,11 @@ function createRebaseClient(options) {
|
|
|
5248
5434
|
});
|
|
5249
5435
|
return storageSourcesPromise;
|
|
5250
5436
|
};
|
|
5251
|
-
const
|
|
5437
|
+
const realtimeEnabled = options.realtime !== false;
|
|
5438
|
+
const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
5439
|
+
const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
|
|
5440
|
+
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.`;
|
|
5441
|
+
if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
|
|
5252
5442
|
let ws;
|
|
5253
5443
|
/** One channel object per name — see `realtime.channel`. */
|
|
5254
5444
|
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
@@ -5368,7 +5558,7 @@ function createRebaseClient(options) {
|
|
|
5368
5558
|
* cut off the others.
|
|
5369
5559
|
*/
|
|
5370
5560
|
channel: (name, options) => {
|
|
5371
|
-
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
5561
|
+
if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
5372
5562
|
let existing = realtimeChannels.get(name);
|
|
5373
5563
|
if (!existing) {
|
|
5374
5564
|
existing = new RebaseRealtimeChannel(name, ws, options);
|