@rebasepro/client 0.13.1-canary.gf57a27e → 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.es.js +169 -11
- 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/query-contract.types.d.ts +61 -1
- package/dist/sdk_query_builder.d.ts +2 -2
- package/dist/websocket.d.ts +5 -3
- package/package.json +4 -4
- package/src/collection.ts +2 -2
- package/src/index.ts +2 -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 +83 -0
- package/src/offline.test.ts +66 -1
- package/src/offline.ts +48 -6
- package/src/query-contract.types.ts +93 -1
- package/src/realtime-error-surfacing.test.ts +105 -0
- package/src/sdk_query_builder.ts +2 -2
- package/src/transport.ts +45 -2
- package/src/websocket.ts +21 -3
package/dist/index.es.js
CHANGED
|
@@ -177,6 +177,23 @@ function createTransport(config, environment) {
|
|
|
177
177
|
...init?.headers || {}
|
|
178
178
|
};
|
|
179
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
|
+
}
|
|
180
197
|
async function request(path, init) {
|
|
181
198
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
182
199
|
let activeToken = token;
|
|
@@ -194,9 +211,27 @@ function createTransport(config, environment) {
|
|
|
194
211
|
if (res.status === 204) return void 0;
|
|
195
212
|
const text = await res.text().catch(() => "");
|
|
196
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;
|
|
197
230
|
if (text) try {
|
|
198
231
|
body = JSON.parse(text, rebaseReviver);
|
|
199
|
-
} catch (e) {
|
|
232
|
+
} catch (e) {
|
|
233
|
+
unreadableBody = true;
|
|
234
|
+
}
|
|
200
235
|
const getErrorField = (obj, field) => {
|
|
201
236
|
const err = obj?.error;
|
|
202
237
|
if (err && typeof err === "object" && err !== null) return err[field];
|
|
@@ -216,9 +251,12 @@ function createTransport(config, environment) {
|
|
|
216
251
|
if (retryRes.status === 204) return void 0;
|
|
217
252
|
const retryText = await retryRes.text().catch(() => "");
|
|
218
253
|
let retryBody = {};
|
|
254
|
+
let retryUnreadable = false;
|
|
219
255
|
if (retryText) try {
|
|
220
256
|
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
221
|
-
} catch (e) {
|
|
257
|
+
} catch (e) {
|
|
258
|
+
retryUnreadable = true;
|
|
259
|
+
}
|
|
222
260
|
if (!retryRes.ok) {
|
|
223
261
|
let fallbackMessage = retryRes.statusText;
|
|
224
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.`;
|
|
@@ -228,6 +266,7 @@ function createTransport(config, environment) {
|
|
|
228
266
|
details: getErrorField(retryBody, "details")
|
|
229
267
|
});
|
|
230
268
|
}
|
|
269
|
+
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
231
270
|
return retryBody;
|
|
232
271
|
}
|
|
233
272
|
}
|
|
@@ -240,6 +279,7 @@ function createTransport(config, environment) {
|
|
|
240
279
|
details: getErrorField(body, "details")
|
|
241
280
|
});
|
|
242
281
|
}
|
|
282
|
+
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
243
283
|
return body;
|
|
244
284
|
}
|
|
245
285
|
return {
|
|
@@ -1849,9 +1889,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
1849
1889
|
*
|
|
1850
1890
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
1851
1891
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
1852
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
1853
|
-
* package root
|
|
1854
|
-
*
|
|
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.
|
|
1855
1897
|
*/
|
|
1856
1898
|
var RebaseWebSocketClient = class {
|
|
1857
1899
|
websocketUrl;
|
|
@@ -2361,6 +2403,11 @@ var RebaseWebSocketClient = class {
|
|
|
2361
2403
|
callback.onError(new RebaseApiError$1(errorMessage, { code: errorCode }));
|
|
2362
2404
|
}
|
|
2363
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}`);
|
|
2364
2411
|
}
|
|
2365
2412
|
}
|
|
2366
2413
|
async ensureAuthenticated(retryCount = 3) {
|
|
@@ -3473,11 +3520,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
|
3473
3520
|
503,
|
|
3474
3521
|
504
|
|
3475
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
|
+
}
|
|
3476
3540
|
/** Is this failure worth another attempt later? */
|
|
3477
3541
|
function isRetryableError(error) {
|
|
3478
3542
|
if (isNetworkError(error)) return true;
|
|
3479
|
-
if (error instanceof RebaseApiError) return
|
|
3480
|
-
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);
|
|
3481
3546
|
}
|
|
3482
3547
|
/**
|
|
3483
3548
|
* Did this write fail because the row is already there?
|
|
@@ -3489,10 +3554,16 @@ function isRetryableError(error) {
|
|
|
3489
3554
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
3490
3555
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
3491
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.
|
|
3492
3562
|
*/
|
|
3493
3563
|
function isDuplicateKeyError(error) {
|
|
3494
3564
|
if (!(error instanceof RebaseApiError)) return false;
|
|
3495
|
-
|
|
3565
|
+
if (error.code === "23505") return true;
|
|
3566
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
3496
3567
|
}
|
|
3497
3568
|
var ConnectivityMonitor = class {
|
|
3498
3569
|
state = "online";
|
|
@@ -4066,6 +4137,25 @@ function resolvePagination(params) {
|
|
|
4066
4137
|
offset
|
|
4067
4138
|
};
|
|
4068
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
|
+
}
|
|
4069
4159
|
/**
|
|
4070
4160
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
4071
4161
|
* assuming the cache holds every row of the collection?
|
|
@@ -4073,12 +4163,67 @@ function resolvePagination(params) {
|
|
|
4073
4163
|
* `include` pulls in rows from other collections that this evaluator never
|
|
4074
4164
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
4075
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}.
|
|
4076
4188
|
*/
|
|
4077
4189
|
function isExactlyEvaluable(params) {
|
|
4078
4190
|
if (!params) return true;
|
|
4079
4191
|
if (params.include && params.include.length > 0) return false;
|
|
4080
4192
|
if (params.searchString) return false;
|
|
4081
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
|
+
}
|
|
4082
4227
|
return true;
|
|
4083
4228
|
}
|
|
4084
4229
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
@@ -4114,6 +4259,17 @@ function generateOfflineId() {
|
|
|
4114
4259
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
4115
4260
|
}
|
|
4116
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;
|
|
4117
4273
|
var OfflineManager = class {
|
|
4118
4274
|
store;
|
|
4119
4275
|
maxCachedQueries;
|
|
@@ -4803,7 +4959,8 @@ var OfflineManager = class {
|
|
|
4803
4959
|
rows.push(entry.row);
|
|
4804
4960
|
added++;
|
|
4805
4961
|
}
|
|
4806
|
-
|
|
4962
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
4963
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
4807
4964
|
return {
|
|
4808
4965
|
data: rows,
|
|
4809
4966
|
meta: {
|
|
@@ -4814,7 +4971,7 @@ var OfflineManager = class {
|
|
|
4814
4971
|
},
|
|
4815
4972
|
fromCache,
|
|
4816
4973
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
4817
|
-
partial: !exact
|
|
4974
|
+
partial: !exact || !orderIsLocal
|
|
4818
4975
|
};
|
|
4819
4976
|
}
|
|
4820
4977
|
localFind(slug, params) {
|
|
@@ -5129,7 +5286,8 @@ var OfflineManager = class {
|
|
|
5129
5286
|
}
|
|
5130
5287
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
5131
5288
|
op.lastError = error?.message ?? String(error);
|
|
5132
|
-
|
|
5289
|
+
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
5290
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
5133
5291
|
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
5134
5292
|
this.connectivity.deferRetry();
|
|
5135
5293
|
this.patchStatus({ lastError: op.lastError });
|