@rebasepro/client 0.17.3 → 0.18.1
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/README.md +4 -0
- package/dist/auth.d.ts +80 -0
- package/dist/functions.d.ts +6 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.es.js +471 -94
- package/dist/index.es.js.map +1 -1
- package/dist/offline-connectivity.d.ts +12 -1
- package/dist/offline.d.ts +23 -1
- package/dist/query-contract.types.d.ts +30 -0
- package/dist/realtime-channel.d.ts +29 -1
- package/dist/sdk_query_builder.d.ts +21 -2
- package/dist/transport.d.ts +24 -0
- package/package.json +28 -15
- package/src/admin.ts +0 -90
- package/src/anonymous-client-guard.test.ts +0 -190
- package/src/api-keys.ts +0 -87
- package/src/auth-listener-errors.test.ts +0 -57
- package/src/auth-refresh-overflow.test.ts +0 -89
- package/src/auth.ts +0 -982
- package/src/backups.ts +0 -40
- package/src/client-close.test.ts +0 -80
- package/src/collection-listen-meta.test.ts +0 -105
- package/src/collection-observe.test.ts +0 -138
- package/src/collection.test.ts +0 -293
- package/src/collection.ts +0 -525
- package/src/cron.test.ts +0 -164
- package/src/cron.ts +0 -62
- package/src/data-proxy.test.ts +0 -183
- package/src/errors.ts +0 -9
- package/src/functions.ts +0 -82
- package/src/index.ts +0 -639
- package/src/like-pattern-redos.test.ts +0 -61
- package/src/offline-codec.ts +0 -79
- package/src/offline-connectivity.test.ts +0 -191
- package/src/offline-connectivity.ts +0 -255
- package/src/offline-idb-store.test.ts +0 -340
- package/src/offline-integration.test.ts +0 -180
- package/src/offline-query.test.ts +0 -431
- package/src/offline-query.ts +0 -529
- package/src/offline-store.ts +0 -357
- package/src/offline-sync-engine.test.ts +0 -857
- package/src/offline.test.ts +0 -897
- package/src/offline.ts +0 -1928
- package/src/query-contract.types.ts +0 -206
- package/src/query_builder.ts +0 -1
- package/src/realtime-channel.test.ts +0 -542
- package/src/realtime-channel.ts +0 -539
- package/src/realtime-concurrent-subscribe.test.ts +0 -102
- package/src/realtime-error-surfacing.test.ts +0 -105
- package/src/realtime-optout.test.ts +0 -279
- package/src/realtime-row-identity.test.ts +0 -254
- package/src/realtime-subscription-key.test.ts +0 -92
- package/src/reviver.ts +0 -39
- package/src/sdk_query_builder.ts +0 -206
- package/src/storage-key-encoding.test.ts +0 -65
- package/src/storage-registry.ts +0 -102
- package/src/storage.ts +0 -253
- package/src/transport-baseurl.test.ts +0 -101
- package/src/transport.ts +0 -505
- package/src/vector-search-listen.test.ts +0 -42
- package/src/vector-search-query.test.ts +0 -55
- package/src/websocket-url.test.ts +0 -97
- package/src/websocket.ts +0 -1837
package/dist/index.es.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, Vector, isPublicStoragePath, parseRelationAggregateSort, toCanonicalOp } from "@rebasepro/types";
|
|
1
|
+
import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, SCHEMA_VERSION_HEADER, Vector, isPublicStoragePath, isUnsupported, parseRelationAggregateSort, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
|
|
2
2
|
import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, normalizeOrderBy, or, paginateFind, resolveFindWindow, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
4
4
|
//#region src/reviver.ts
|
|
@@ -67,7 +67,13 @@ var ANONYMOUS_SERVER_CLIENT_WARNING = "[rebase] This client was created outside
|
|
|
67
67
|
*/
|
|
68
68
|
function assertNoUndefinedFilterValues(where) {
|
|
69
69
|
const reject = (field, op) => {
|
|
70
|
-
throw new RebaseClientError$1(`Filter on "${field}" has an undefined value (["${String(op)}", undefined]). Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL
|
|
70
|
+
throw new RebaseClientError$1(`Filter on "${field}" has an undefined value (["${String(op)}", undefined]). Omit "${field}" from \`where\` to skip the filter, or use ["is-null", null] to match SQL NULL.`, {
|
|
71
|
+
code: "INVALID_FILTER",
|
|
72
|
+
details: {
|
|
73
|
+
field,
|
|
74
|
+
operator: String(op)
|
|
75
|
+
}
|
|
76
|
+
});
|
|
71
77
|
};
|
|
72
78
|
for (const [field, condition] of Object.entries(where)) {
|
|
73
79
|
if (condition === void 0) continue;
|
|
@@ -139,7 +145,40 @@ function resolveBaseUrl(configured) {
|
|
|
139
145
|
return "";
|
|
140
146
|
}
|
|
141
147
|
function createTransport(config, environment) {
|
|
142
|
-
const
|
|
148
|
+
const rawFetch = config.fetch || globalThis.fetch;
|
|
149
|
+
/**
|
|
150
|
+
* `fetch`, but its rejection is a `RebaseApiError` like everything else
|
|
151
|
+
* this client throws.
|
|
152
|
+
*
|
|
153
|
+
* A transport failure — DNS, a refused connection, CORS, an aborted
|
|
154
|
+
* request — rejects with whatever the runtime's `fetch` felt like: a
|
|
155
|
+
* `TypeError` reading "Failed to fetch" in a browser, a `TypeError` with a
|
|
156
|
+
* `cause` in undici, a `DOMException` on abort. So the one class the SDK
|
|
157
|
+
* documents as "a `catch` block only ever needs to check for this" did not
|
|
158
|
+
* cover the most common failure of all, and `e.status` / `e.code` were
|
|
159
|
+
* undefined on the error every app hits first: the server being down.
|
|
160
|
+
*
|
|
161
|
+
* `status: 0` rather than a made-up 5xx. There was no response, and 0 is
|
|
162
|
+
* how `XMLHttpRequest` has always spelled that; a fabricated 503 would be
|
|
163
|
+
* indistinguishable from one the server actually sent. The original error
|
|
164
|
+
* is on `cause`, so nothing is hidden.
|
|
165
|
+
*
|
|
166
|
+
* Wrapped once, at construction, because the request path reads `fetchFn`
|
|
167
|
+
* twice — the first attempt and the post-refresh retry — and a wrapper
|
|
168
|
+
* applied at one call site is a wrapper missing from the other.
|
|
169
|
+
*/
|
|
170
|
+
const fetchFn = async (input, init) => {
|
|
171
|
+
try {
|
|
172
|
+
return await rawFetch(input, init);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (e instanceof RebaseApiError$1) throw e;
|
|
175
|
+
throw new RebaseApiError$1(`Could not reach the server at ${typeof input === "string" ? input : String(input.url ?? input)}: ${e instanceof Error ? e.message : String(e)}`, {
|
|
176
|
+
status: 0,
|
|
177
|
+
code: "NETWORK_ERROR",
|
|
178
|
+
cause: e
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
};
|
|
143
182
|
const apiPath = config.apiPath || "/api";
|
|
144
183
|
for (const field of ["baseUrl", "storageUrlOrigin"]) {
|
|
145
184
|
const value = config[field];
|
|
@@ -170,9 +209,14 @@ function createTransport(config, environment) {
|
|
|
170
209
|
anonymousWarningIssued = true;
|
|
171
210
|
console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
|
|
172
211
|
}
|
|
212
|
+
const defaultHeaders = {
|
|
213
|
+
...config.headers ?? {},
|
|
214
|
+
...config.schemaVersion ? { [SCHEMA_VERSION_HEADER]: config.schemaVersion } : {}
|
|
215
|
+
};
|
|
173
216
|
function getHeaders(activeToken, init) {
|
|
174
217
|
return {
|
|
175
218
|
"Content-Type": "application/json",
|
|
219
|
+
...defaultHeaders,
|
|
176
220
|
...activeToken ? { Authorization: `Bearer ${activeToken}` } : {},
|
|
177
221
|
...init?.headers || {}
|
|
178
222
|
};
|
|
@@ -194,6 +238,52 @@ function createTransport(config, environment) {
|
|
|
194
238
|
code: "INVALID_JSON_RESPONSE"
|
|
195
239
|
});
|
|
196
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* The server always emits the canonical
|
|
243
|
+
* `{ error: { message, code, details?, requestId? } }` envelope (formatted
|
|
244
|
+
* by the central `errorHandler`), so a field is read strictly from
|
|
245
|
+
* `body.error.*` and never from the top level.
|
|
246
|
+
*/
|
|
247
|
+
function getErrorField(obj, field) {
|
|
248
|
+
const err = obj?.error;
|
|
249
|
+
if (err && typeof err === "object" && err !== null) return err[field];
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* `Retry-After` as whole seconds, or `undefined`.
|
|
253
|
+
*
|
|
254
|
+
* The header is either a number of seconds or an HTTP date; both spellings
|
|
255
|
+
* are legal and servers use both, so both are read. A date in the past —
|
|
256
|
+
* which happens on a clock skew — is clamped to 0 rather than negative.
|
|
257
|
+
*/
|
|
258
|
+
function parseRetryAfter(header) {
|
|
259
|
+
if (!header) return void 0;
|
|
260
|
+
const seconds = Number(header.trim());
|
|
261
|
+
if (Number.isFinite(seconds)) return Math.max(0, Math.floor(seconds));
|
|
262
|
+
const at = Date.parse(header);
|
|
263
|
+
if (Number.isNaN(at)) return void 0;
|
|
264
|
+
return Math.max(0, Math.round((at - Date.now()) / 1e3));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Everything a failed response says about itself, in one place.
|
|
268
|
+
*
|
|
269
|
+
* The request path reads a response twice — the first attempt and the
|
|
270
|
+
* post-refresh retry — and the two used to build their error separately.
|
|
271
|
+
* Which is how they came to disagree: both dropped `requestId` and
|
|
272
|
+
* `Retry-After`, and a fix applied to one would have missed the other.
|
|
273
|
+
*/
|
|
274
|
+
function errorFrom(res, body, fallbackMessage) {
|
|
275
|
+
const requestId = getErrorField(body, "requestId") ?? res.headers?.get("X-Request-ID") ?? void 0;
|
|
276
|
+
return new RebaseApiError$1(String(getErrorField(body, "message") || fallbackMessage || `Request failed with status ${res.status}`), {
|
|
277
|
+
status: res.status,
|
|
278
|
+
code: getErrorField(body, "code"),
|
|
279
|
+
details: getErrorField(body, "details"),
|
|
280
|
+
...typeof requestId === "string" && requestId ? { requestId } : {},
|
|
281
|
+
...(() => {
|
|
282
|
+
const retryAfter = parseRetryAfter(res.headers?.get("Retry-After") ?? null);
|
|
283
|
+
return retryAfter === void 0 ? {} : { retryAfterSeconds: retryAfter };
|
|
284
|
+
})()
|
|
285
|
+
});
|
|
286
|
+
}
|
|
197
287
|
async function request(path, init) {
|
|
198
288
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
199
289
|
let activeToken = token;
|
|
@@ -232,10 +322,6 @@ function createTransport(config, environment) {
|
|
|
232
322
|
} catch (e) {
|
|
233
323
|
unreadableBody = true;
|
|
234
324
|
}
|
|
235
|
-
const getErrorField = (obj, field) => {
|
|
236
|
-
const err = obj?.error;
|
|
237
|
-
if (err && typeof err === "object" && err !== null) return err[field];
|
|
238
|
-
};
|
|
239
325
|
if (res.status === 401 && onUnauthorizedHandler) {
|
|
240
326
|
if (await onUnauthorizedHandler()) {
|
|
241
327
|
let retryToken = token;
|
|
@@ -260,11 +346,7 @@ function createTransport(config, environment) {
|
|
|
260
346
|
if (!retryRes.ok) {
|
|
261
347
|
let fallbackMessage = retryRes.statusText;
|
|
262
348
|
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.`;
|
|
263
|
-
throw
|
|
264
|
-
status: retryRes.status,
|
|
265
|
-
code: getErrorField(retryBody, "code"),
|
|
266
|
-
details: getErrorField(retryBody, "details")
|
|
267
|
-
});
|
|
349
|
+
throw errorFrom(retryRes, retryBody, fallbackMessage);
|
|
268
350
|
}
|
|
269
351
|
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
270
352
|
return retryBody;
|
|
@@ -273,11 +355,7 @@ function createTransport(config, environment) {
|
|
|
273
355
|
if (!res.ok) {
|
|
274
356
|
let fallbackMessage = res.statusText;
|
|
275
357
|
if (res.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.`;
|
|
276
|
-
throw
|
|
277
|
-
status: res.status,
|
|
278
|
-
code: getErrorField(body, "code"),
|
|
279
|
-
details: getErrorField(body, "details")
|
|
280
|
-
});
|
|
358
|
+
throw errorFrom(res, body, fallbackMessage);
|
|
281
359
|
}
|
|
282
360
|
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
283
361
|
return body;
|
|
@@ -783,7 +861,7 @@ function createAuth(transport, options) {
|
|
|
783
861
|
return inFlightRefresh;
|
|
784
862
|
}
|
|
785
863
|
async function doRefreshSession() {
|
|
786
|
-
if (authFlowMode !== "cookie" && !currentSession?.refreshToken) throw new
|
|
864
|
+
if (authFlowMode !== "cookie" && !currentSession?.refreshToken) throw new RebaseClientError$1("No active session to refresh. Sign in first, or set `authFlowMode: \"cookie\"` if the refresh token is held in an HttpOnly cookie.", { code: "NOT_SIGNED_IN" });
|
|
787
865
|
const res = await getFetch()(authUrl("/refresh"), {
|
|
788
866
|
method: "POST",
|
|
789
867
|
headers: { "Content-Type": "application/json" },
|
|
@@ -979,6 +1057,136 @@ function createAuth(transport, options) {
|
|
|
979
1057
|
refreshToken: session.refreshToken
|
|
980
1058
|
};
|
|
981
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Sign in without credentials, as a throwaway account.
|
|
1062
|
+
*
|
|
1063
|
+
* The account is real — it has an id, roles and a session, so row-level
|
|
1064
|
+
* security scopes its data exactly as it would a signed-up user's. What it
|
|
1065
|
+
* does not have is a way back: nobody can sign in *as* it a second time, so
|
|
1066
|
+
* everything it owns is lost when the session is. `linkAnonymous` is how it
|
|
1067
|
+
* stops being throwaway.
|
|
1068
|
+
*
|
|
1069
|
+
* Refused with `ANONYMOUS_AUTH_DISABLED` unless the backend has it on.
|
|
1070
|
+
*/
|
|
1071
|
+
async function signInAnonymously() {
|
|
1072
|
+
const res = await getFetch()(authUrl("/anonymous"), {
|
|
1073
|
+
method: "POST",
|
|
1074
|
+
headers: { "Content-Type": "application/json" },
|
|
1075
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
1076
|
+
});
|
|
1077
|
+
const body = await res.json().catch(() => ({}));
|
|
1078
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
1079
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
1080
|
+
return {
|
|
1081
|
+
user: session.user,
|
|
1082
|
+
accessToken: session.accessToken,
|
|
1083
|
+
refreshToken: session.refreshToken
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Put an email and password on the anonymous account this client holds.
|
|
1088
|
+
*
|
|
1089
|
+
* The user keeps their id, so everything they created while anonymous stays
|
|
1090
|
+
* theirs — which is the point: the alternative is asking a visitor to sign
|
|
1091
|
+
* up before they have any reason to.
|
|
1092
|
+
*
|
|
1093
|
+
* Requires a live anonymous session. `NOT_ANONYMOUS` (400) if the session
|
|
1094
|
+
* belongs to an ordinary account, `EMAIL_EXISTS` (409) if the address is
|
|
1095
|
+
* taken.
|
|
1096
|
+
*/
|
|
1097
|
+
async function linkAnonymous(email, password) {
|
|
1098
|
+
const session = handleAuthResponse(await transport.request(authPath + "/anonymous/link", {
|
|
1099
|
+
method: "POST",
|
|
1100
|
+
body: JSON.stringify({
|
|
1101
|
+
email,
|
|
1102
|
+
password
|
|
1103
|
+
})
|
|
1104
|
+
}), "USER_UPDATED");
|
|
1105
|
+
return {
|
|
1106
|
+
user: session.user,
|
|
1107
|
+
accessToken: session.accessToken,
|
|
1108
|
+
refreshToken: session.refreshToken
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Multi-factor authentication: TOTP factors, and the challenge that raises
|
|
1113
|
+
* a session to `aal2`.
|
|
1114
|
+
*
|
|
1115
|
+
* The backend has served all six of these since MFA landed; the SDK exposed
|
|
1116
|
+
* none of them, so every app that wanted MFA hand-wrote `fetch` calls
|
|
1117
|
+
* against `/auth/mfa/*` — including the auth header and the cookie flag,
|
|
1118
|
+
* which is exactly what a client is for.
|
|
1119
|
+
*/
|
|
1120
|
+
const mfa = {
|
|
1121
|
+
/**
|
|
1122
|
+
* Start enrolling a TOTP factor.
|
|
1123
|
+
*
|
|
1124
|
+
* Returns the secret and an `otpauth://` URI to render as a QR code,
|
|
1125
|
+
* plus ten single-use recovery codes. **Show the recovery codes once
|
|
1126
|
+
* and never again** — only their hashes are stored.
|
|
1127
|
+
*
|
|
1128
|
+
* The factor is not usable until `verify` confirms the user's
|
|
1129
|
+
* authenticator produced a correct code from it.
|
|
1130
|
+
*/
|
|
1131
|
+
async enroll(options) {
|
|
1132
|
+
return transport.request(authPath + "/mfa/enroll", {
|
|
1133
|
+
method: "POST",
|
|
1134
|
+
body: JSON.stringify(options ?? {})
|
|
1135
|
+
});
|
|
1136
|
+
},
|
|
1137
|
+
/** Confirm an enrolment with a code from the authenticator app. */
|
|
1138
|
+
async verify(factorId, code) {
|
|
1139
|
+
return transport.request(authPath + "/mfa/verify", {
|
|
1140
|
+
method: "POST",
|
|
1141
|
+
body: JSON.stringify({
|
|
1142
|
+
factorId,
|
|
1143
|
+
code
|
|
1144
|
+
})
|
|
1145
|
+
});
|
|
1146
|
+
},
|
|
1147
|
+
/** The factors on this account, verified or not. */
|
|
1148
|
+
async listFactors() {
|
|
1149
|
+
return (await transport.request(authPath + "/mfa/factors", { method: "GET" })).factors;
|
|
1150
|
+
},
|
|
1151
|
+
/**
|
|
1152
|
+
* Remove a factor. Requires an `aal2` session — one that has already
|
|
1153
|
+
* answered a challenge — so a stolen `aal1` token cannot turn MFA off.
|
|
1154
|
+
*/
|
|
1155
|
+
async unenroll(factorId) {
|
|
1156
|
+
return transport.request(authPath + "/mfa/unenroll", {
|
|
1157
|
+
method: "DELETE",
|
|
1158
|
+
body: JSON.stringify({ factorId })
|
|
1159
|
+
});
|
|
1160
|
+
},
|
|
1161
|
+
/** Open a challenge against a verified factor. Expires in five minutes. */
|
|
1162
|
+
async challenge(factorId) {
|
|
1163
|
+
return transport.request(authPath + "/mfa/challenge", {
|
|
1164
|
+
method: "POST",
|
|
1165
|
+
body: JSON.stringify({ factorId })
|
|
1166
|
+
});
|
|
1167
|
+
},
|
|
1168
|
+
/**
|
|
1169
|
+
* Answer a challenge with a TOTP code or a recovery code.
|
|
1170
|
+
*
|
|
1171
|
+
* This is where an MFA-enrolled account's real session is minted, at
|
|
1172
|
+
* `aal2` — so the returned tokens replace the ones the sign-in handed
|
|
1173
|
+
* back, and this client adopts them.
|
|
1174
|
+
*/
|
|
1175
|
+
async verifyChallenge(challengeId, code) {
|
|
1176
|
+
const session = handleAuthResponse(await transport.request(authPath + "/mfa/challenge/verify", {
|
|
1177
|
+
method: "POST",
|
|
1178
|
+
body: JSON.stringify({
|
|
1179
|
+
challengeId,
|
|
1180
|
+
code
|
|
1181
|
+
})
|
|
1182
|
+
}), "SIGNED_IN");
|
|
1183
|
+
return {
|
|
1184
|
+
user: session.user,
|
|
1185
|
+
accessToken: session.accessToken,
|
|
1186
|
+
refreshToken: session.refreshToken
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
982
1190
|
async function getSessions() {
|
|
983
1191
|
return (await transport.request(authPath + "/sessions", { method: "GET" })).sessions;
|
|
984
1192
|
}
|
|
@@ -1054,6 +1262,9 @@ function createAuth(transport, options) {
|
|
|
1054
1262
|
signInWithBitbucket,
|
|
1055
1263
|
signInWithSlack,
|
|
1056
1264
|
signInWithSpotify,
|
|
1265
|
+
signInAnonymously,
|
|
1266
|
+
linkAnonymous,
|
|
1267
|
+
mfa,
|
|
1057
1268
|
signOut,
|
|
1058
1269
|
stopAutoRefresh,
|
|
1059
1270
|
refreshSession,
|
|
@@ -1303,7 +1514,11 @@ var SDKQueryBuilder = class {
|
|
|
1303
1514
|
}
|
|
1304
1515
|
where(columnOrCondition, operator, value) {
|
|
1305
1516
|
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
|
1306
|
-
|
|
1517
|
+
const next = columnOrCondition;
|
|
1518
|
+
this.params.logical = this.params.logical ? {
|
|
1519
|
+
type: "and",
|
|
1520
|
+
conditions: [this.params.logical, next]
|
|
1521
|
+
} : next;
|
|
1307
1522
|
return this;
|
|
1308
1523
|
}
|
|
1309
1524
|
if (!this.params.where) this.params.where = {};
|
|
@@ -1330,7 +1545,7 @@ var SDKQueryBuilder = class {
|
|
|
1330
1545
|
*/
|
|
1331
1546
|
orderBy(column, direction = "asc") {
|
|
1332
1547
|
const existing = normalizeOrderBy(this.params.orderBy) ?? [];
|
|
1333
|
-
this.params.orderBy = [...existing, [column, direction]];
|
|
1548
|
+
this.params.orderBy = [...existing, [sortKeyToString(column), direction]];
|
|
1334
1549
|
return this;
|
|
1335
1550
|
}
|
|
1336
1551
|
/**
|
|
@@ -1419,27 +1634,81 @@ var SDKQueryBuilder = class {
|
|
|
1419
1634
|
return this.collection.find(this.params);
|
|
1420
1635
|
}
|
|
1421
1636
|
/**
|
|
1637
|
+
* Page through everything this query matches, one row at a time.
|
|
1638
|
+
*
|
|
1639
|
+
* `.limit()` on the builder becomes the page size, so the ceiling on a
|
|
1640
|
+
* single `find()` is not a ceiling on what the query can read.
|
|
1641
|
+
*/
|
|
1642
|
+
iterate(options) {
|
|
1643
|
+
return this.collection.iterate({
|
|
1644
|
+
...this.params,
|
|
1645
|
+
...this.params.limit !== void 0 && { pageSize: this.params.limit },
|
|
1646
|
+
...options
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
/** Collect everything this query matches into one array. */
|
|
1650
|
+
findAll(options) {
|
|
1651
|
+
return this.collection.findAll({
|
|
1652
|
+
...this.params,
|
|
1653
|
+
...this.params.limit !== void 0 && { pageSize: this.params.limit },
|
|
1654
|
+
...options
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1422
1658
|
* Count the records matching this query.
|
|
1423
1659
|
*/
|
|
1424
1660
|
async count() {
|
|
1425
|
-
if (!this.collection.count) throw new Error("count() is not supported by this collection client.");
|
|
1426
1661
|
return this.collection.count(this.params);
|
|
1427
1662
|
}
|
|
1428
1663
|
/**
|
|
1429
1664
|
* Listen to realtime updates matching this query.
|
|
1665
|
+
*
|
|
1666
|
+
* The guard that used to stand here — `if (!this.collection.listen) throw`
|
|
1667
|
+
* — has moved onto the client itself, which is where the same failure had
|
|
1668
|
+
* to be diagnosed anyway: `client.data.posts.listen(…)`, one call short of
|
|
1669
|
+
* this builder, answered `undefined is not a function`. The message is now
|
|
1670
|
+
* the same either way.
|
|
1430
1671
|
*/
|
|
1431
1672
|
listen(onUpdate, onError) {
|
|
1432
|
-
if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.");
|
|
1433
1673
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
1434
1674
|
}
|
|
1435
1675
|
};
|
|
1436
1676
|
//#endregion
|
|
1437
1677
|
//#region src/collection.ts
|
|
1438
1678
|
/**
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1679
|
+
* What `listen`/`listenById` say on a client that has no socket.
|
|
1680
|
+
*
|
|
1681
|
+
* One sentence, in one place: it used to live in `SDKQueryBuilder.listen` only,
|
|
1682
|
+
* so the same failure reached callers of `client.data.posts.listen` as
|
|
1683
|
+
* `undefined is not a function`.
|
|
1441
1684
|
*/
|
|
1442
|
-
var
|
|
1685
|
+
var NO_SOCKET = "Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.";
|
|
1686
|
+
/**
|
|
1687
|
+
* Counts currently in flight, **per client**, keyed by the exact request they
|
|
1688
|
+
* issue. Entries live only for the duration of the request — see `count()` for
|
|
1689
|
+
* why they exist at all.
|
|
1690
|
+
*
|
|
1691
|
+
* The map used to be module-level, shared by every client in the process. A
|
|
1692
|
+
* request key is a path and a query string: it says nothing about *who* is
|
|
1693
|
+
* asking. So two clients holding different credentials — an admin panel beside
|
|
1694
|
+
* a signed-in user's client, a per-request server client in a Node process, a
|
|
1695
|
+
* test asserting one client's calls — counting the same collection would share
|
|
1696
|
+
* one request, and whichever arrived second was answered with the first one's
|
|
1697
|
+
* total. Row-level security makes those genuinely different numbers.
|
|
1698
|
+
*
|
|
1699
|
+
* Keyed on the transport instead, which is the object a credential lives on.
|
|
1700
|
+
* A `WeakMap`, so a closed client's entry goes with it.
|
|
1701
|
+
*/
|
|
1702
|
+
var inflightCounts = /* @__PURE__ */ new WeakMap();
|
|
1703
|
+
/** The in-flight map for one transport, created on first use. */
|
|
1704
|
+
function inflightCountsFor(transport) {
|
|
1705
|
+
let map = inflightCounts.get(transport);
|
|
1706
|
+
if (!map) {
|
|
1707
|
+
map = /* @__PURE__ */ new Map();
|
|
1708
|
+
inflightCounts.set(transport, map);
|
|
1709
|
+
}
|
|
1710
|
+
return map;
|
|
1711
|
+
}
|
|
1443
1712
|
function createCollectionClient(transport, slug, ws) {
|
|
1444
1713
|
const basePath = `/data/${slug}`;
|
|
1445
1714
|
const client = {
|
|
@@ -1467,6 +1736,14 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1467
1736
|
throw err;
|
|
1468
1737
|
}
|
|
1469
1738
|
},
|
|
1739
|
+
async get(id) {
|
|
1740
|
+
const row = await client.findById(id);
|
|
1741
|
+
if (row === void 0) throw new RebaseApiError(`No record with id ${JSON.stringify(String(id))} in "${slug}".`, {
|
|
1742
|
+
status: 404,
|
|
1743
|
+
code: "NOT_FOUND"
|
|
1744
|
+
});
|
|
1745
|
+
return row;
|
|
1746
|
+
},
|
|
1470
1747
|
async create(data, id, options) {
|
|
1471
1748
|
const body = { ...data };
|
|
1472
1749
|
if (id !== void 0) body.id = id;
|
|
@@ -1497,10 +1774,11 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1497
1774
|
* and the route now name one verb, and a spec-validating gateway in
|
|
1498
1775
|
* front of the API sees the operation the server actually implements.
|
|
1499
1776
|
*/
|
|
1500
|
-
async update(id, data) {
|
|
1777
|
+
async update(id, data, options) {
|
|
1501
1778
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
1502
1779
|
method: "PATCH",
|
|
1503
|
-
body: JSON.stringify(data)
|
|
1780
|
+
body: JSON.stringify(data),
|
|
1781
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
1504
1782
|
});
|
|
1505
1783
|
},
|
|
1506
1784
|
async updateMany(updates, options) {
|
|
@@ -1543,14 +1821,15 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1543
1821
|
include: void 0
|
|
1544
1822
|
});
|
|
1545
1823
|
const key = basePath + "/count" + qs;
|
|
1546
|
-
const inflight =
|
|
1547
|
-
|
|
1824
|
+
const inflight = inflightCountsFor(transport);
|
|
1825
|
+
const existing = inflight.get(key);
|
|
1826
|
+
if (existing) return existing;
|
|
1548
1827
|
const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
|
|
1549
|
-
|
|
1828
|
+
inflight.set(key, request);
|
|
1550
1829
|
try {
|
|
1551
1830
|
return await request;
|
|
1552
1831
|
} finally {
|
|
1553
|
-
|
|
1832
|
+
inflight.delete(key);
|
|
1554
1833
|
}
|
|
1555
1834
|
},
|
|
1556
1835
|
observe(params, onResult, onError, options) {
|
|
@@ -1574,7 +1853,7 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1574
1853
|
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
1575
1854
|
if (!closed) onError?.(error);
|
|
1576
1855
|
});
|
|
1577
|
-
const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
1856
|
+
const live = options?.realtime !== false && !isUnsupported(client.listen) ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
1578
1857
|
return () => {
|
|
1579
1858
|
closed = true;
|
|
1580
1859
|
live?.();
|
|
@@ -1599,7 +1878,7 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1599
1878
|
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
1600
1879
|
if (!closed) onError?.(error);
|
|
1601
1880
|
});
|
|
1602
|
-
const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
|
|
1881
|
+
const live = options?.realtime !== false && !isUnsupported(client.listenById) ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
|
|
1603
1882
|
return () => {
|
|
1604
1883
|
closed = true;
|
|
1605
1884
|
live?.();
|
|
@@ -1627,7 +1906,9 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1627
1906
|
},
|
|
1628
1907
|
include(...relations) {
|
|
1629
1908
|
return new SDKQueryBuilder(client).include(...relations);
|
|
1630
|
-
}
|
|
1909
|
+
},
|
|
1910
|
+
listen: unsupportedMethod(NO_SOCKET),
|
|
1911
|
+
listenById: unsupportedMethod(NO_SOCKET)
|
|
1631
1912
|
};
|
|
1632
1913
|
if (ws) {
|
|
1633
1914
|
client.listen = (params, onUpdate, onError) => {
|
|
@@ -1662,15 +1943,16 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1662
1943
|
}
|
|
1663
1944
|
});
|
|
1664
1945
|
};
|
|
1665
|
-
|
|
1666
|
-
if (client.count) client.count(params).then((total) => {
|
|
1946
|
+
client.count(params).then((total) => {
|
|
1667
1947
|
lastKnownTotal = total;
|
|
1668
1948
|
emit(total, offset + rows.length < total);
|
|
1669
1949
|
}).catch(() => {
|
|
1670
|
-
if (lastKnownTotal !== void 0)
|
|
1671
|
-
|
|
1950
|
+
if (lastKnownTotal !== void 0) {
|
|
1951
|
+
emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
emit(offset + rows.length, rows.length >= requestedLimit);
|
|
1672
1955
|
});
|
|
1673
|
-
else emitWithoutCount();
|
|
1674
1956
|
}, onError);
|
|
1675
1957
|
return () => {
|
|
1676
1958
|
active = false;
|
|
@@ -1704,6 +1986,10 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
1704
1986
|
*/
|
|
1705
1987
|
function createFunctionsClient(transport) {
|
|
1706
1988
|
return { async invoke(name, payload, options) {
|
|
1989
|
+
if (name.includes("/")) throw new RebaseClientError$1(`Invalid function name "${name}": a function name is a single path segment. Pass anything after it as \`options.path\` — invoke("${name.split("/")[0]}", payload, { path: "${name.split("/").slice(1).join("/")}" }).`, {
|
|
1990
|
+
code: "INVALID_FUNCTION_NAME",
|
|
1991
|
+
details: { name }
|
|
1992
|
+
});
|
|
1707
1993
|
const method = options?.method ?? "POST";
|
|
1708
1994
|
const rawPath = options?.path;
|
|
1709
1995
|
const subPath = rawPath ? /^[?#]/.test(rawPath) ? rawPath : `/${rawPath.replace(/^\//, "")}` : "";
|
|
@@ -2325,7 +2611,7 @@ var RebaseWebSocketClient = class {
|
|
|
2325
2611
|
}
|
|
2326
2612
|
return;
|
|
2327
2613
|
}
|
|
2328
|
-
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
|
|
2614
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history" || type === "ERROR" || type === "error")) {
|
|
2329
2615
|
const handlers = this.channelHandlers.get(message.channel);
|
|
2330
2616
|
if (handlers) for (const handler of [...handlers]) try {
|
|
2331
2617
|
handler(message);
|
|
@@ -2361,39 +2647,6 @@ var RebaseWebSocketClient = class {
|
|
|
2361
2647
|
}
|
|
2362
2648
|
}
|
|
2363
2649
|
}
|
|
2364
|
-
if (subscriptionId && type === "collection_patch") {
|
|
2365
|
-
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
2366
|
-
if (subscriptionKey) {
|
|
2367
|
-
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
2368
|
-
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
2369
|
-
const patchWireEntity = message.row ?? null;
|
|
2370
|
-
const patchMessage = message;
|
|
2371
|
-
const patchEntityId = patchMessage.id;
|
|
2372
|
-
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
2373
|
-
const patchRow = patchWireEntity ? patchWireEntity : null;
|
|
2374
|
-
let updated;
|
|
2375
|
-
if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
|
|
2376
|
-
else {
|
|
2377
|
-
const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
|
|
2378
|
-
if (idx >= 0) {
|
|
2379
|
-
updated = [...collectionSub.latestData];
|
|
2380
|
-
updated[idx] = patchRow;
|
|
2381
|
-
} else updated = [patchRow, ...collectionSub.latestData];
|
|
2382
|
-
}
|
|
2383
|
-
collectionSub.latestData = updated;
|
|
2384
|
-
collectionSub.lastUpdated = Date.now();
|
|
2385
|
-
collectionSub.callbacks.forEach((callback) => {
|
|
2386
|
-
try {
|
|
2387
|
-
callback.onUpdate(updated);
|
|
2388
|
-
} catch (error) {
|
|
2389
|
-
console.error("Error in collection patch callback:", error);
|
|
2390
|
-
if (callback.onError) callback.onError(error instanceof Error ? error : new Error(String(error)));
|
|
2391
|
-
}
|
|
2392
|
-
});
|
|
2393
|
-
return;
|
|
2394
|
-
}
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
2650
|
if (subscriptionId && type === "single_update") {
|
|
2398
2651
|
const subscriptionKey = this.backendToEntityKey.get(subscriptionId);
|
|
2399
2652
|
if (subscriptionKey) {
|
|
@@ -3116,6 +3369,7 @@ var RebaseRealtimeChannel = class {
|
|
|
3116
3369
|
transport;
|
|
3117
3370
|
presenceHandlers = /* @__PURE__ */ new Set();
|
|
3118
3371
|
broadcastHandlers = /* @__PURE__ */ new Set();
|
|
3372
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
3119
3373
|
unsubscribers = [];
|
|
3120
3374
|
/** Last known roster, kept so handlers always get a full picture. */
|
|
3121
3375
|
presences = {};
|
|
@@ -3303,7 +3557,16 @@ var RebaseRealtimeChannel = class {
|
|
|
3303
3557
|
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
3304
3558
|
return () => this.presenceHandlers.delete(handler);
|
|
3305
3559
|
}
|
|
3306
|
-
/**
|
|
3560
|
+
/**
|
|
3561
|
+
* Send a broadcast. The sender does not receive its own message.
|
|
3562
|
+
*
|
|
3563
|
+
* Resolves when the frame is written to the socket, **not** when the server
|
|
3564
|
+
* has accepted it. Channel frames are fire-and-forget by design — a
|
|
3565
|
+
* collaborative app broadcasts a cursor position sixty times a second, and
|
|
3566
|
+
* waiting for an acknowledgement on each would make that a round trip.
|
|
3567
|
+
*
|
|
3568
|
+
* A refusal therefore arrives on {@link onError}, not as a rejection here.
|
|
3569
|
+
*/
|
|
3307
3570
|
async broadcast(event, payload) {
|
|
3308
3571
|
await this.join();
|
|
3309
3572
|
await this.send("broadcast", {
|
|
@@ -3311,6 +3574,27 @@ var RebaseRealtimeChannel = class {
|
|
|
3311
3574
|
payload
|
|
3312
3575
|
});
|
|
3313
3576
|
}
|
|
3577
|
+
/**
|
|
3578
|
+
* Observe refusals about this channel.
|
|
3579
|
+
*
|
|
3580
|
+
* `CHANNEL_FORBIDDEN` when an authorizer refuses a join, a broadcast or a
|
|
3581
|
+
* history read; `RATE_LIMITED` past the channel frame budget;
|
|
3582
|
+
* `CHANNEL_HISTORY_WRITE_FAILED` / `CHANNEL_HISTORY_READ_FAILED` when
|
|
3583
|
+
* retention cannot be served; `CHANNEL_BUS_PAYLOAD_TOO_LARGE` when a
|
|
3584
|
+
* broadcast on an ephemeral channel is too big to cross the bus.
|
|
3585
|
+
*
|
|
3586
|
+
* These used to be dropped on the floor — there is no promise to reject on
|
|
3587
|
+
* a fire-and-forget frame, so a forbidden broadcast looked exactly like a
|
|
3588
|
+
* delivered one. With no handler attached they are logged as a warning,
|
|
3589
|
+
* because an unobserved refusal is the failure this exists for.
|
|
3590
|
+
*
|
|
3591
|
+
* @returns An unsubscribe function.
|
|
3592
|
+
*/
|
|
3593
|
+
onError(handler) {
|
|
3594
|
+
this.errorHandlers.add(handler);
|
|
3595
|
+
this.join();
|
|
3596
|
+
return () => this.errorHandlers.delete(handler);
|
|
3597
|
+
}
|
|
3314
3598
|
onBroadcast(eventOrHandler, maybeHandler) {
|
|
3315
3599
|
const wrapped = typeof eventOrHandler === "string" ? (e) => {
|
|
3316
3600
|
if (e.event === eventOrHandler) maybeHandler(e.payload);
|
|
@@ -3443,6 +3727,23 @@ var RebaseRealtimeChannel = class {
|
|
|
3443
3727
|
this.flushPendingLive();
|
|
3444
3728
|
break;
|
|
3445
3729
|
}
|
|
3730
|
+
case "ERROR":
|
|
3731
|
+
case "error": {
|
|
3732
|
+
const raw = message.payload?.error;
|
|
3733
|
+
const asObject = typeof raw === "object" && raw !== null ? raw : void 0;
|
|
3734
|
+
const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ?? (typeof message.error === "string" ? message.error : void 0) ?? "The server refused a channel operation.";
|
|
3735
|
+
const error = new RebaseApiError$1(text, { ...asObject?.code ? { code: asObject.code } : {} });
|
|
3736
|
+
if (this.errorHandlers.size === 0) {
|
|
3737
|
+
console.warn(`[Rebase] Channel "${this.name}" error${asObject?.code ? ` (${asObject.code})` : ""}: ${text}. Attach \`channel.onError(...)\` to handle it.`);
|
|
3738
|
+
break;
|
|
3739
|
+
}
|
|
3740
|
+
for (const handler of [...this.errorHandlers]) try {
|
|
3741
|
+
handler(error);
|
|
3742
|
+
} catch (e) {
|
|
3743
|
+
console.error("Error in channel error handler:", e);
|
|
3744
|
+
}
|
|
3745
|
+
break;
|
|
3746
|
+
}
|
|
3446
3747
|
}
|
|
3447
3748
|
}
|
|
3448
3749
|
/** Deliver everything held back during a catch-up, in sequence order. */
|
|
@@ -3552,12 +3853,20 @@ function hydrateRow(row) {
|
|
|
3552
3853
|
* store immediately, which is the difference between an app that freezes when
|
|
3553
3854
|
* the wifi drops and one that does not.
|
|
3554
3855
|
*/
|
|
3555
|
-
/**
|
|
3856
|
+
/**
|
|
3857
|
+
* The request never reached the server, so nothing was decided by it.
|
|
3858
|
+
*
|
|
3859
|
+
* One question, one answer: `status === 0` is how a transport spells "there
|
|
3860
|
+
* was no response at all", and the transport now wraps every `fetch` rejection
|
|
3861
|
+
* into a `RebaseApiError` carrying it. This used to also sniff for the shapes
|
|
3862
|
+
* a raw `fetch` rejects with — a `TypeError` in a browser, an `AbortError`, a
|
|
3863
|
+
* `TimeoutError` — which is a list that has to be kept in step with three
|
|
3864
|
+
* runtimes and was wrong in both directions: it missed anything they add, and
|
|
3865
|
+
* it claimed a `TypeError` thrown by application code inside a callback was a
|
|
3866
|
+
* network failure, which quietly served a cached answer in its place.
|
|
3867
|
+
*/
|
|
3556
3868
|
function isNetworkError(error) {
|
|
3557
|
-
|
|
3558
|
-
if (error instanceof TypeError) return true;
|
|
3559
|
-
const name = error?.name;
|
|
3560
|
-
return name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
|
|
3869
|
+
return error instanceof RebaseApiError && error.status === 0;
|
|
3561
3870
|
}
|
|
3562
3871
|
/**
|
|
3563
3872
|
* Statuses that mean "not now" rather than "not ever": a queued write that
|
|
@@ -4326,20 +4635,51 @@ function runLocalQuery(rows, params) {
|
|
|
4326
4635
|
}
|
|
4327
4636
|
//#endregion
|
|
4328
4637
|
//#region src/offline.ts
|
|
4329
|
-
/**
|
|
4638
|
+
/**
|
|
4639
|
+
* True when a read failed because there was neither network nor local data.
|
|
4640
|
+
*
|
|
4641
|
+
* The code is `OFFLINE`. It was `"offline"`, the one lowercase code in a
|
|
4642
|
+
* surface whose every other code is `SCREAMING_SNAKE_CASE` — including the
|
|
4643
|
+
* `NETWORK_ERROR` it sits next to — so `e.code === "OFFLINE"`, which is what
|
|
4644
|
+
* anyone writes, never matched.
|
|
4645
|
+
*/
|
|
4330
4646
|
function isOfflineError(error) {
|
|
4331
|
-
return error instanceof RebaseApiError && error.code === "
|
|
4647
|
+
return error instanceof RebaseApiError && error.code === "OFFLINE";
|
|
4332
4648
|
}
|
|
4333
4649
|
function offlineError(message) {
|
|
4334
4650
|
return new RebaseApiError(message, {
|
|
4335
4651
|
status: 0,
|
|
4336
|
-
code: "
|
|
4652
|
+
code: "OFFLINE"
|
|
4337
4653
|
});
|
|
4338
4654
|
}
|
|
4339
|
-
function
|
|
4655
|
+
function generateOfflineStringId() {
|
|
4340
4656
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
4341
4657
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
4342
4658
|
}
|
|
4659
|
+
/**
|
|
4660
|
+
* The last temporary numeric id minted, so the next one is smaller.
|
|
4661
|
+
*
|
|
4662
|
+
* Module-scoped and strictly decreasing: two temporary ids in the same session
|
|
4663
|
+
* must not collide, and neither must one minted a millisecond after another.
|
|
4664
|
+
*/
|
|
4665
|
+
var lastOfflineNumericId = 0;
|
|
4666
|
+
/**
|
|
4667
|
+
* A temporary id for a collection whose ids are numbers.
|
|
4668
|
+
*
|
|
4669
|
+
* **Negative**, which is the convention every local-first engine settles on for
|
|
4670
|
+
* the same reason: a real key is a positive integer or an identity sequence, so
|
|
4671
|
+
* the sign alone says "this one is not the server's yet" — and it stays a
|
|
4672
|
+
* number, which is what matters. A UUID string was minted for every collection,
|
|
4673
|
+
* so a `create` while offline handed back `{ id: "526e2faf-…" }` on a row the
|
|
4674
|
+
* generated types declare `id: number`. Anything that put that in a URL, a
|
|
4675
|
+
* foreign key or an `id.toFixed()` was wrong or threw until the replay
|
|
4676
|
+
* reconciled it — and the SDK's own types said it could not happen.
|
|
4677
|
+
*/
|
|
4678
|
+
function generateOfflineNumericId() {
|
|
4679
|
+
const candidate = -Date.now();
|
|
4680
|
+
lastOfflineNumericId = lastOfflineNumericId === 0 ? candidate : Math.min(candidate, lastOfflineNumericId - 1);
|
|
4681
|
+
return lastOfflineNumericId;
|
|
4682
|
+
}
|
|
4343
4683
|
var MISSING = "\0missing";
|
|
4344
4684
|
/**
|
|
4345
4685
|
* Replays to spend on a mutation whose idempotency key the server is still
|
|
@@ -4566,6 +4906,14 @@ var OfflineManager = class {
|
|
|
4566
4906
|
if (this.collections.get(slug)?.absent.has(String(id))) return void 0;
|
|
4567
4907
|
throw offlineError(`Offline: "${slug}" row ${String(id)} is not in the local database.`);
|
|
4568
4908
|
},
|
|
4909
|
+
get: async (id) => {
|
|
4910
|
+
const row = await wrapped.findById(id);
|
|
4911
|
+
if (row === void 0) throw new RebaseApiError(`No record with id ${JSON.stringify(String(id))} in "${slug}".`, {
|
|
4912
|
+
status: 404,
|
|
4913
|
+
code: "NOT_FOUND"
|
|
4914
|
+
});
|
|
4915
|
+
return row;
|
|
4916
|
+
},
|
|
4569
4917
|
create: async (data, id) => {
|
|
4570
4918
|
await this.ensureCollection(slug);
|
|
4571
4919
|
if (this.connectivity.shouldAttempt()) try {
|
|
@@ -4580,7 +4928,7 @@ var OfflineManager = class {
|
|
|
4580
4928
|
this.connectivity.markFailure();
|
|
4581
4929
|
}
|
|
4582
4930
|
const providedId = id ?? data.id;
|
|
4583
|
-
const rowId = providedId ??
|
|
4931
|
+
const rowId = providedId ?? this.mintOfflineId(slug);
|
|
4584
4932
|
const row = {
|
|
4585
4933
|
...data,
|
|
4586
4934
|
id: rowId
|
|
@@ -4614,7 +4962,7 @@ var OfflineManager = class {
|
|
|
4614
4962
|
}
|
|
4615
4963
|
const rows = data.map((r) => ({
|
|
4616
4964
|
...r,
|
|
4617
|
-
id: r.id ??
|
|
4965
|
+
id: r.id ?? this.mintOfflineId(slug)
|
|
4618
4966
|
}));
|
|
4619
4967
|
const rollback = {};
|
|
4620
4968
|
for (const row of rows) {
|
|
@@ -4778,13 +5126,15 @@ var OfflineManager = class {
|
|
|
4778
5126
|
offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
|
|
4779
5127
|
search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
|
|
4780
5128
|
vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
|
|
4781
|
-
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
|
|
5129
|
+
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations),
|
|
5130
|
+
listen: inner.listen,
|
|
5131
|
+
listenById: inner.listenById
|
|
4782
5132
|
};
|
|
4783
|
-
if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
5133
|
+
if (!isUnsupported(inner.listen)) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
4784
5134
|
this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
|
|
4785
5135
|
onUpdate(response);
|
|
4786
5136
|
}, onError);
|
|
4787
|
-
if (inner.listenById) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
5137
|
+
if (!isUnsupported(inner.listenById)) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
4788
5138
|
if (row) this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
|
|
4789
5139
|
onUpdate(row);
|
|
4790
5140
|
}, onError);
|
|
@@ -4829,7 +5179,7 @@ var OfflineManager = class {
|
|
|
4829
5179
|
}
|
|
4830
5180
|
if (!closed) observer.emit();
|
|
4831
5181
|
})();
|
|
4832
|
-
if (options?.realtime !== false && inner.listen) unlisten = inner.listen(params, (response) => {
|
|
5182
|
+
if (options?.realtime !== false && !isUnsupported(inner.listen)) unlisten = inner.listen(params, (response) => {
|
|
4833
5183
|
this.ingest(slug, response.data ?? []).then(() => {
|
|
4834
5184
|
this.recordSnapshot(slug, params, response);
|
|
4835
5185
|
this.notifyCollection(slug, false);
|
|
@@ -4881,7 +5231,7 @@ var OfflineManager = class {
|
|
|
4881
5231
|
}
|
|
4882
5232
|
if (!closed) observer.emit();
|
|
4883
5233
|
})();
|
|
4884
|
-
if (options?.realtime !== false && inner.listenById) unlisten = inner.listenById(id, (row) => {
|
|
5234
|
+
if (options?.realtime !== false && !isUnsupported(inner.listenById)) unlisten = inner.listenById(id, (row) => {
|
|
4885
5235
|
if (!row) {
|
|
4886
5236
|
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
|
|
4887
5237
|
this.notifyCollection(slug, false);
|
|
@@ -5067,6 +5417,28 @@ var OfflineManager = class {
|
|
|
5067
5417
|
partial: true
|
|
5068
5418
|
};
|
|
5069
5419
|
}
|
|
5420
|
+
/**
|
|
5421
|
+
* A temporary id of the type this collection's ids actually have.
|
|
5422
|
+
*
|
|
5423
|
+
* There is no schema here to read it off — the generated `Database` types
|
|
5424
|
+
* are erased at compile time, and the whole point of this path is that the
|
|
5425
|
+
* server is unreachable. So it is read off the rows the local database
|
|
5426
|
+
* already holds: a collection whose cached ids are numbers gets a negative
|
|
5427
|
+
* number, and everything else gets a UUID.
|
|
5428
|
+
*
|
|
5429
|
+
* A collection with nothing cached falls back to a UUID, which is the same
|
|
5430
|
+
* guess as before — but that is a first write on a device that has never
|
|
5431
|
+
* read the collection, which is also the case where nothing local can tell
|
|
5432
|
+
* the difference. Reading one row first is enough to fix it.
|
|
5433
|
+
*/
|
|
5434
|
+
mintOfflineId(slug) {
|
|
5435
|
+
for (const entry of this.collections.get(slug)?.rows.values() ?? []) {
|
|
5436
|
+
const id = entry.row.id;
|
|
5437
|
+
if (typeof id === "number") return generateOfflineNumericId();
|
|
5438
|
+
if (typeof id === "string") break;
|
|
5439
|
+
}
|
|
5440
|
+
return generateOfflineStringId();
|
|
5441
|
+
}
|
|
5070
5442
|
rawLocalRow(slug, id) {
|
|
5071
5443
|
const entry = this.collections.get(slug)?.rows.get(String(id));
|
|
5072
5444
|
return entry ? { ...entry.row } : void 0;
|
|
@@ -5829,7 +6201,13 @@ function createRebaseClient(options) {
|
|
|
5829
6201
|
let msg = `Unknown collection accessor "${prop}". Known collections: ${knownKeys.join(", ")}.`;
|
|
5830
6202
|
if (suggestion) msg += ` Did you mean "${suggestion}"?`;
|
|
5831
6203
|
msg += ` Use data.collection("<slug>") for dynamic slugs.`;
|
|
5832
|
-
throw new RebaseClientError(msg
|
|
6204
|
+
throw new RebaseClientError(msg, {
|
|
6205
|
+
code: "UNKNOWN_COLLECTION",
|
|
6206
|
+
details: {
|
|
6207
|
+
accessor: prop,
|
|
6208
|
+
known: knownKeys
|
|
6209
|
+
}
|
|
6210
|
+
});
|
|
5833
6211
|
}
|
|
5834
6212
|
if (!untypedWarned) {
|
|
5835
6213
|
untypedWarned = true;
|
|
@@ -5860,7 +6238,7 @@ function createRebaseClient(options) {
|
|
|
5860
6238
|
* cut off the others.
|
|
5861
6239
|
*/
|
|
5862
6240
|
channel: (name, options) => {
|
|
5863
|
-
if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
6241
|
+
if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.", { code: "REALTIME_DISABLED" });
|
|
5864
6242
|
let existing = realtimeChannels.get(name);
|
|
5865
6243
|
if (!existing) {
|
|
5866
6244
|
existing = new RebaseRealtimeChannel(name, ws, options);
|
|
@@ -5891,11 +6269,10 @@ channel: (name, options) => {
|
|
|
5891
6269
|
collection,
|
|
5892
6270
|
call: async (endpoint, payload) => {
|
|
5893
6271
|
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
5894
|
-
|
|
6272
|
+
return transport.request(`${prefix}${endpoint}`, {
|
|
5895
6273
|
method: "POST",
|
|
5896
6274
|
body: payload ? JSON.stringify(payload) : void 0
|
|
5897
6275
|
});
|
|
5898
|
-
return res.data ?? res;
|
|
5899
6276
|
},
|
|
5900
6277
|
data: dataProxy,
|
|
5901
6278
|
...offlineManager ? { offline: offlineManager.api } : {}
|