@rebasepro/server 0.10.0 → 0.10.1-canary.18115ba
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/auth/interfaces.d.ts +65 -2
- package/dist/auth/jwt.d.ts +17 -0
- package/dist/auth/routes.d.ts +16 -0
- package/dist/index.es.js +2093 -32
- package/dist/index.es.js.map +1 -1
- package/dist/{jwt-B3zjddCa.js → jwt-Dj7r7QX7.js} +36 -24
- package/dist/{jwt-B3zjddCa.js.map → jwt-Dj7r7QX7.js.map} +1 -1
- package/package.json +5 -5
package/dist/index.es.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { a as
|
|
4
|
+
import { _ as __toESM, a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, g as __require, h as __exportAll, i as generateDownloadToken, l as hashRefreshToken, m as __commonJSMin, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-Dj7r7QX7.js";
|
|
5
5
|
import { _ as RebaseApiError, a as policy, c as isPostgresCollectionConfig, d as REST_TO_CANONICAL, f as toCanonicalOp, g as Vector, h as GeoPoint, i as isSQLAdmin, l as CANONICAL_TO_REST, m as EntityRelation, o as getCollectionDataPath, p as EntityReference, r as getDataSourceCapabilities, s as getDeclaredSubcollections, t as DEFAULT_STORAGE_SOURCE_KEY, u as NULL_OPS, v as RebaseClientError } from "./src-CsHhSKbi.js";
|
|
6
6
|
import { t as logger } from "./logger-BYU66ENZ.js";
|
|
7
7
|
import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
|
|
@@ -13,7 +13,7 @@ import path, { join } from "path";
|
|
|
13
13
|
import { pathToFileURL } from "url";
|
|
14
14
|
import { Hono } from "hono";
|
|
15
15
|
import { promisify } from "util";
|
|
16
|
-
import * as crypto from "crypto";
|
|
16
|
+
import * as crypto$1 from "crypto";
|
|
17
17
|
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
18
18
|
import { bodyLimit } from "hono/body-limit";
|
|
19
19
|
import { csrf } from "hono/csrf";
|
|
@@ -1007,7 +1007,7 @@ function deepClone(value) {
|
|
|
1007
1007
|
function isObject$1(item) {
|
|
1008
1008
|
return !!item && typeof item === "object" && !Array.isArray(item);
|
|
1009
1009
|
}
|
|
1010
|
-
function isPlainObject$
|
|
1010
|
+
function isPlainObject$2(obj) {
|
|
1011
1011
|
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) return false;
|
|
1012
1012
|
return Object.getPrototypeOf(obj) === Object.prototype;
|
|
1013
1013
|
}
|
|
@@ -1022,7 +1022,7 @@ function mergeDeep(target, source, ignoreUndefined = false) {
|
|
|
1022
1022
|
const outputValue = output[key];
|
|
1023
1023
|
if (ignoreUndefined && sourceValue === void 0) continue;
|
|
1024
1024
|
if (sourceValue instanceof Date) output[key] = new Date(sourceValue.getTime());
|
|
1025
|
-
else if (Array.isArray(sourceValue)) if (Array.isArray(outputValue)) if (!(sourceValue.some(isPlainObject$
|
|
1025
|
+
else if (Array.isArray(sourceValue)) if (Array.isArray(outputValue)) if (!(sourceValue.some(isPlainObject$2) || outputValue.some(isPlainObject$2))) output[key] = [...sourceValue];
|
|
1026
1026
|
else {
|
|
1027
1027
|
const newArray = [];
|
|
1028
1028
|
const maxLength = Math.max(outputValue.length, sourceValue.length);
|
|
@@ -1032,13 +1032,13 @@ function mergeDeep(target, source, ignoreUndefined = false) {
|
|
|
1032
1032
|
if (i >= sourceValue.length) newArray[i] = targetItem;
|
|
1033
1033
|
else if (i >= outputValue.length) newArray[i] = sourceItem;
|
|
1034
1034
|
else if (sourceItem === null) newArray[i] = targetItem;
|
|
1035
|
-
else if (isPlainObject$
|
|
1035
|
+
else if (isPlainObject$2(sourceItem) && isPlainObject$2(targetItem)) newArray[i] = mergeDeep(targetItem, sourceItem, ignoreUndefined);
|
|
1036
1036
|
else newArray[i] = sourceItem;
|
|
1037
1037
|
}
|
|
1038
1038
|
output[key] = newArray;
|
|
1039
1039
|
}
|
|
1040
1040
|
else output[key] = [...sourceValue];
|
|
1041
|
-
else if (isPlainObject$
|
|
1041
|
+
else if (isPlainObject$2(sourceValue)) if (isPlainObject$2(outputValue)) output[key] = mergeDeep(outputValue, sourceValue, ignoreUndefined);
|
|
1042
1042
|
else output[key] = sourceValue;
|
|
1043
1043
|
else if (isObject$1(sourceValue)) output[key] = sourceValue;
|
|
1044
1044
|
else output[key] = sourceValue;
|
|
@@ -1051,7 +1051,7 @@ function removeFunctions(o) {
|
|
|
1051
1051
|
if (o === null) return null;
|
|
1052
1052
|
if (typeof o === "object") {
|
|
1053
1053
|
if (Array.isArray(o)) return o.map((v) => removeFunctions(v));
|
|
1054
|
-
if (!isPlainObject$
|
|
1054
|
+
if (!isPlainObject$2(o)) return o;
|
|
1055
1055
|
return Object.entries(o).filter(([_, value]) => typeof value !== "function").map(([key, value]) => {
|
|
1056
1056
|
if (Array.isArray(value)) return { [key]: value.map((v) => removeFunctions(v)) };
|
|
1057
1057
|
else if (typeof value === "object") return { [key]: removeFunctions(value) };
|
|
@@ -6114,7 +6114,7 @@ var allowsEval = /* @__PURE__*/ cached(() => {
|
|
|
6114
6114
|
return false;
|
|
6115
6115
|
}
|
|
6116
6116
|
});
|
|
6117
|
-
function isPlainObject(o) {
|
|
6117
|
+
function isPlainObject$1(o) {
|
|
6118
6118
|
if (isObject(o) === false) return false;
|
|
6119
6119
|
const ctor = o.constructor;
|
|
6120
6120
|
if (ctor === void 0) return true;
|
|
@@ -6125,7 +6125,7 @@ function isPlainObject(o) {
|
|
|
6125
6125
|
return true;
|
|
6126
6126
|
}
|
|
6127
6127
|
function shallowClone(o) {
|
|
6128
|
-
if (isPlainObject(o)) return { ...o };
|
|
6128
|
+
if (isPlainObject$1(o)) return { ...o };
|
|
6129
6129
|
if (Array.isArray(o)) return [...o];
|
|
6130
6130
|
if (o instanceof Map) return new Map(o);
|
|
6131
6131
|
if (o instanceof Set) return new Set(o);
|
|
@@ -6202,7 +6202,7 @@ function omit(schema, mask) {
|
|
|
6202
6202
|
}));
|
|
6203
6203
|
}
|
|
6204
6204
|
function extend(schema, shape) {
|
|
6205
|
-
if (!isPlainObject(shape)) throw new Error("Invalid input to extend: expected a plain object");
|
|
6205
|
+
if (!isPlainObject$1(shape)) throw new Error("Invalid input to extend: expected a plain object");
|
|
6206
6206
|
const checks = schema._zod.def.checks;
|
|
6207
6207
|
if (checks && checks.length > 0) {
|
|
6208
6208
|
const existingShape = schema._zod.def.shape;
|
|
@@ -6218,7 +6218,7 @@ function extend(schema, shape) {
|
|
|
6218
6218
|
} }));
|
|
6219
6219
|
}
|
|
6220
6220
|
function safeExtend(schema, shape) {
|
|
6221
|
-
if (!isPlainObject(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
|
|
6221
|
+
if (!isPlainObject$1(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
|
|
6222
6222
|
return clone(schema, mergeDefs(schema._zod.def, { get shape() {
|
|
6223
6223
|
const _shape = {
|
|
6224
6224
|
...schema._zod.def.shape,
|
|
@@ -7569,7 +7569,7 @@ function mergeValues(a, b) {
|
|
|
7569
7569
|
valid: true,
|
|
7570
7570
|
data: a
|
|
7571
7571
|
};
|
|
7572
|
-
if (isPlainObject(a) && isPlainObject(b)) {
|
|
7572
|
+
if (isPlainObject$1(a) && isPlainObject$1(b)) {
|
|
7573
7573
|
const bKeys = Object.keys(b);
|
|
7574
7574
|
const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1);
|
|
7575
7575
|
const newObj = {
|
|
@@ -9888,7 +9888,10 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9888
9888
|
const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
|
|
9889
9889
|
const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
|
|
9890
9890
|
const refreshToken = generateRefreshToken();
|
|
9891
|
-
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown"
|
|
9891
|
+
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
|
|
9892
|
+
id: randomUUID$1(),
|
|
9893
|
+
startedAt: /* @__PURE__ */ new Date()
|
|
9894
|
+
});
|
|
9892
9895
|
if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
|
|
9893
9896
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
9894
9897
|
});
|
|
@@ -9959,7 +9962,8 @@ function setRefreshCookie(c, refreshToken, config) {
|
|
|
9959
9962
|
let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
|
|
9960
9963
|
if (isSecure) cookie += "; Secure";
|
|
9961
9964
|
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
9962
|
-
|
|
9965
|
+
const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
|
|
9966
|
+
cookie += `; Max-Age=${maxAgeSeconds}`;
|
|
9963
9967
|
c.header("Set-Cookie", cookie, { append: true });
|
|
9964
9968
|
}
|
|
9965
9969
|
/**
|
|
@@ -10025,12 +10029,14 @@ function mountSessionRoutes(opts) {
|
|
|
10025
10029
|
const refreshToken = readRefreshToken(c, parseBody(logoutSchema, await c.req.json().catch(() => ({}))), config.cookieAuth);
|
|
10026
10030
|
if (refreshToken) {
|
|
10027
10031
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
10028
|
-
await authRepo.
|
|
10032
|
+
const sessionId = (await authRepo.findRefreshTokenByHash(tokenHash).catch(() => null))?.sessionId;
|
|
10033
|
+
if (sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(sessionId);
|
|
10034
|
+
else await authRepo.deleteRefreshToken(tokenHash);
|
|
10029
10035
|
}
|
|
10030
10036
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10031
10037
|
const authHeader = c.req.header("authorization");
|
|
10032
10038
|
if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
|
|
10033
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
10039
|
+
const { verifyAccessToken } = await import("./jwt-Dj7r7QX7.js").then((n) => n.u);
|
|
10034
10040
|
const payload = verifyAccessToken(authHeader.substring(7));
|
|
10035
10041
|
if (payload) ops.afterLogout(payload.uid).catch((err) => {
|
|
10036
10042
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
@@ -10047,12 +10053,20 @@ function mountSessionRoutes(opts) {
|
|
|
10047
10053
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10048
10054
|
const currentRefreshToken = c.req.header("x-refresh-token");
|
|
10049
10055
|
const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
|
|
10050
|
-
const
|
|
10056
|
+
const tokens = await authRepo.listRefreshTokensForUser(userCtx.uid);
|
|
10057
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
10058
|
+
for (const token of tokens) {
|
|
10059
|
+
if (token.revoked) continue;
|
|
10060
|
+
const key = token.sessionId ?? token.id;
|
|
10061
|
+
const existing = bySession.get(key);
|
|
10062
|
+
if (!existing || existing.createdAt < token.createdAt) bySession.set(key, token);
|
|
10063
|
+
}
|
|
10064
|
+
const mappedSessions = [...bySession.values()].map((s) => ({
|
|
10051
10065
|
id: s.id,
|
|
10052
10066
|
userAgent: s.userAgent,
|
|
10053
10067
|
ipAddress: s.ipAddress,
|
|
10054
|
-
createdAt: s.createdAt,
|
|
10055
|
-
isCurrentSession: currentTokenHash ?
|
|
10068
|
+
createdAt: s.sessionStartedAt ?? s.createdAt,
|
|
10069
|
+
isCurrentSession: currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
|
|
10056
10070
|
}));
|
|
10057
10071
|
return c.json({ sessions: mappedSessions });
|
|
10058
10072
|
});
|
|
@@ -10064,6 +10078,7 @@ function mountSessionRoutes(opts) {
|
|
|
10064
10078
|
const userCtx = c.get("user");
|
|
10065
10079
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10066
10080
|
await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
|
|
10081
|
+
await authRepo.setTokensValidAfter?.(userCtx.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
10067
10082
|
return c.json({
|
|
10068
10083
|
success: true,
|
|
10069
10084
|
message: "All sessions revoked successfully"
|
|
@@ -10078,7 +10093,9 @@ function mountSessionRoutes(opts) {
|
|
|
10078
10093
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10079
10094
|
const id = c.req.param("id");
|
|
10080
10095
|
if (!id) throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
|
|
10081
|
-
await authRepo.
|
|
10096
|
+
const target = (await authRepo.listRefreshTokensForUser(userCtx.uid).catch(() => [])).find((t) => t.id === id);
|
|
10097
|
+
if (target?.sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(target.sessionId);
|
|
10098
|
+
else await authRepo.deleteRefreshTokenById(id, userCtx.uid);
|
|
10082
10099
|
return c.json({
|
|
10083
10100
|
success: true,
|
|
10084
10101
|
message: "Session revoked successfully"
|
|
@@ -10442,7 +10459,10 @@ function createAuthRoutes(config) {
|
|
|
10442
10459
|
}
|
|
10443
10460
|
const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
|
|
10444
10461
|
const refreshToken = generateRefreshToken();
|
|
10445
|
-
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress
|
|
10462
|
+
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
10463
|
+
id: randomUUID$1(),
|
|
10464
|
+
startedAt: /* @__PURE__ */ new Date()
|
|
10465
|
+
});
|
|
10446
10466
|
return {
|
|
10447
10467
|
roleIds,
|
|
10448
10468
|
accessToken,
|
|
@@ -10675,6 +10695,7 @@ function createAuthRoutes(config) {
|
|
|
10675
10695
|
await authRepo.updatePassword(storedToken.uid, passwordHash);
|
|
10676
10696
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
10677
10697
|
await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
|
|
10698
|
+
await authRepo.setTokensValidAfter?.(storedToken.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
10678
10699
|
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
10679
10700
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
10680
10701
|
});
|
|
@@ -10699,6 +10720,7 @@ function createAuthRoutes(config) {
|
|
|
10699
10720
|
const passwordHash = await ops.hashPassword(newPassword);
|
|
10700
10721
|
await authRepo.updatePassword(user.id, passwordHash);
|
|
10701
10722
|
await authRepo.deleteAllRefreshTokensForUser(user.id);
|
|
10723
|
+
await authRepo.setTokensValidAfter?.(user.id, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
10702
10724
|
return c.json({
|
|
10703
10725
|
success: true,
|
|
10704
10726
|
message: "Password has been changed successfully"
|
|
@@ -10762,15 +10784,34 @@ function createAuthRoutes(config) {
|
|
|
10762
10784
|
if (!refreshToken) throw ApiError.unauthenticated("No refresh token presented", "NO_SESSION");
|
|
10763
10785
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
10764
10786
|
const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
|
|
10765
|
-
if (!storedToken)
|
|
10787
|
+
if (!storedToken) throw ApiError.unauthorized("Invalid refresh token", "INVALID_TOKEN");
|
|
10788
|
+
if (storedToken.revoked) {
|
|
10766
10789
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10767
|
-
throw ApiError.unauthorized("
|
|
10790
|
+
throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
10768
10791
|
}
|
|
10769
10792
|
if (/* @__PURE__ */ new Date() > storedToken.expiresAt) {
|
|
10770
10793
|
await authRepo.deleteRefreshToken(tokenHash);
|
|
10771
10794
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10772
10795
|
throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
|
|
10773
10796
|
}
|
|
10797
|
+
const sessionStartedAt = storedToken.sessionStartedAt ?? storedToken.createdAt;
|
|
10798
|
+
const tokensValidAfter = await authRepo.getTokensValidAfter?.(storedToken.uid).catch(() => null);
|
|
10799
|
+
if (tokensValidAfter && sessionStartedAt < tokensValidAfter) {
|
|
10800
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
10801
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
10802
|
+
throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
10803
|
+
}
|
|
10804
|
+
const reuseWindowMs = Math.max(0, config.refreshTokenReuseIntervalSeconds ?? 10) * 1e3;
|
|
10805
|
+
const supersededAt = storedToken.rotatedAt ? new Date(storedToken.rotatedAt) : null;
|
|
10806
|
+
if (supersededAt && Date.now() - supersededAt.getTime() > reuseWindowMs) {
|
|
10807
|
+
logger.warn("[Auth] Refresh token replayed after the reuse window", {
|
|
10808
|
+
uid: storedToken.uid,
|
|
10809
|
+
sessionId: storedToken.sessionId,
|
|
10810
|
+
supersededSecondsAgo: Math.round((Date.now() - supersededAt.getTime()) / 1e3),
|
|
10811
|
+
userAgent: c.req.header("user-agent") || "unknown"
|
|
10812
|
+
});
|
|
10813
|
+
throw ApiError.unauthorized("Refresh token already used", "TOKEN_ALREADY_USED");
|
|
10814
|
+
}
|
|
10774
10815
|
const roleIds = (await authRepo.getUserRoles(storedToken.uid)).map((r) => r.id);
|
|
10775
10816
|
const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
|
|
10776
10817
|
logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
|
|
@@ -10792,8 +10833,16 @@ function createAuthRoutes(config) {
|
|
|
10792
10833
|
const newRefreshToken = generateRefreshToken();
|
|
10793
10834
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
10794
10835
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
10795
|
-
|
|
10796
|
-
|
|
10836
|
+
const session = {
|
|
10837
|
+
id: storedToken.sessionId ?? storedToken.id,
|
|
10838
|
+
startedAt: sessionStartedAt
|
|
10839
|
+
};
|
|
10840
|
+
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
10841
|
+
else await authRepo.deleteRefreshToken(tokenHash);
|
|
10842
|
+
await authRepo.createRefreshToken(storedToken.uid, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, session);
|
|
10843
|
+
authRepo.pruneRefreshTokens?.(storedToken.uid, session.id, new Date(Date.now() - reuseWindowMs)).catch((err) => {
|
|
10844
|
+
logger.warn("[Auth] Refresh token prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
10845
|
+
});
|
|
10797
10846
|
const tokensOnlyResponse = { tokens: {
|
|
10798
10847
|
accessToken: newAccessToken,
|
|
10799
10848
|
refreshToken: newRefreshToken,
|
|
@@ -15032,19 +15081,39 @@ function createAuth(transport, options) {
|
|
|
15032
15081
|
*/
|
|
15033
15082
|
function isFatalRefreshError(err) {
|
|
15034
15083
|
if (!(err instanceof RebaseApiError)) return false;
|
|
15084
|
+
if (err.code === "TOKEN_ALREADY_USED") return false;
|
|
15035
15085
|
if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
|
|
15036
15086
|
return err.status === 401 || err.status === 403;
|
|
15037
15087
|
}
|
|
15088
|
+
/**
|
|
15089
|
+
* Drop this client's session without telling the server.
|
|
15090
|
+
*
|
|
15091
|
+
* `signOut()` is a user action: it POSTs /logout, which revokes the whole
|
|
15092
|
+
* sign-in. That is the wrong hammer for a refresh that failed. Our token
|
|
15093
|
+
* may be stale precisely because a sibling tab holds a live one, and
|
|
15094
|
+
* logging out on its behalf would turn one tab's bad luck into everybody
|
|
15095
|
+
* being signed out — the exact failure this work exists to remove.
|
|
15096
|
+
*/
|
|
15097
|
+
function abandonSessionLocally() {
|
|
15098
|
+
currentSession = null;
|
|
15099
|
+
clearStoredSession();
|
|
15100
|
+
if (refreshTimeout) {
|
|
15101
|
+
clearTimeout(refreshTimeout);
|
|
15102
|
+
refreshTimeout = null;
|
|
15103
|
+
}
|
|
15104
|
+
transport.setToken(null);
|
|
15105
|
+
emit("SIGNED_OUT", null);
|
|
15106
|
+
}
|
|
15038
15107
|
async function attemptScheduledRefresh(attempt) {
|
|
15039
15108
|
try {
|
|
15040
15109
|
await refreshSession();
|
|
15041
15110
|
} catch (err) {
|
|
15042
15111
|
if (isFatalRefreshError(err)) {
|
|
15043
|
-
|
|
15112
|
+
abandonSessionLocally();
|
|
15044
15113
|
return;
|
|
15045
15114
|
}
|
|
15046
15115
|
if (attempt >= MAX_REFRESH_RETRIES) {
|
|
15047
|
-
|
|
15116
|
+
abandonSessionLocally();
|
|
15048
15117
|
return;
|
|
15049
15118
|
}
|
|
15050
15119
|
const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
|
|
@@ -15265,9 +15334,42 @@ function createAuth(transport, options) {
|
|
|
15265
15334
|
transport.setToken(null);
|
|
15266
15335
|
emit("SIGNED_OUT", null);
|
|
15267
15336
|
}
|
|
15337
|
+
/**
|
|
15338
|
+
* Serialise refreshes across TABS, not just within one.
|
|
15339
|
+
*
|
|
15340
|
+
* The in-flight promise below covers callers inside a single JavaScript
|
|
15341
|
+
* context. It does nothing about the far more common case: two tabs of the
|
|
15342
|
+
* same app booting together, each firing its own /refresh with the same
|
|
15343
|
+
* cookie. The server tolerates that now (superseded tokens stay usable for
|
|
15344
|
+
* a grace window), but tolerating a stampede is not the same as avoiding
|
|
15345
|
+
* one, and every extra rotation is another chance to end up holding a
|
|
15346
|
+
* token whose response never arrived.
|
|
15347
|
+
*
|
|
15348
|
+
* Web Locks are best-effort on purpose. supabase-js shipped this and then
|
|
15349
|
+
* spent a year fielding deadlock reports — a lock held by a crashed or
|
|
15350
|
+
* frozen tab must never be able to wedge sign-in — so a lock we cannot
|
|
15351
|
+
* take within the timeout is simply not taken, and the refresh proceeds
|
|
15352
|
+
* unserialised, exactly as it did before.
|
|
15353
|
+
*/
|
|
15354
|
+
const REFRESH_LOCK_NAME = "rebase-auth-refresh";
|
|
15355
|
+
const REFRESH_LOCK_TIMEOUT_MS = 5e3;
|
|
15356
|
+
async function withRefreshLock(fn) {
|
|
15357
|
+
const locks = globalThis.navigator?.locks;
|
|
15358
|
+
if (!locks?.request) return fn();
|
|
15359
|
+
const controller = new AbortController();
|
|
15360
|
+
const giveUp = setTimeout(() => controller.abort(), REFRESH_LOCK_TIMEOUT_MS);
|
|
15361
|
+
try {
|
|
15362
|
+
return await locks.request(REFRESH_LOCK_NAME, { signal: controller.signal }, async () => fn());
|
|
15363
|
+
} catch (e) {
|
|
15364
|
+
if (e?.name !== "AbortError") throw e;
|
|
15365
|
+
return fn();
|
|
15366
|
+
} finally {
|
|
15367
|
+
clearTimeout(giveUp);
|
|
15368
|
+
}
|
|
15369
|
+
}
|
|
15268
15370
|
function refreshSession() {
|
|
15269
15371
|
if (inFlightRefresh) return inFlightRefresh;
|
|
15270
|
-
inFlightRefresh = doRefreshSession().finally(() => {
|
|
15372
|
+
inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
|
|
15271
15373
|
inFlightRefresh = null;
|
|
15272
15374
|
});
|
|
15273
15375
|
return inFlightRefresh;
|
|
@@ -15833,6 +15935,44 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
15833
15935
|
});
|
|
15834
15936
|
return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
|
|
15835
15937
|
},
|
|
15938
|
+
observe(params, onResult, onError, options) {
|
|
15939
|
+
let closed = false;
|
|
15940
|
+
const emit = (result) => {
|
|
15941
|
+
if (closed) return;
|
|
15942
|
+
onResult({
|
|
15943
|
+
...result,
|
|
15944
|
+
fromCache: false,
|
|
15945
|
+
hasPendingWrites: false,
|
|
15946
|
+
partial: false
|
|
15947
|
+
});
|
|
15948
|
+
};
|
|
15949
|
+
client.find(params).then(emit).catch((error) => {
|
|
15950
|
+
if (!closed) onError?.(error);
|
|
15951
|
+
});
|
|
15952
|
+
const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
|
|
15953
|
+
return () => {
|
|
15954
|
+
closed = true;
|
|
15955
|
+
live?.();
|
|
15956
|
+
};
|
|
15957
|
+
},
|
|
15958
|
+
observeById(id, onResult, onError, options) {
|
|
15959
|
+
let closed = false;
|
|
15960
|
+
const emit = (row) => {
|
|
15961
|
+
if (closed) return;
|
|
15962
|
+
onResult(row, {
|
|
15963
|
+
fromCache: false,
|
|
15964
|
+
hasPendingWrites: false
|
|
15965
|
+
});
|
|
15966
|
+
};
|
|
15967
|
+
client.findById(id).then(emit).catch((error) => {
|
|
15968
|
+
if (!closed) onError?.(error);
|
|
15969
|
+
});
|
|
15970
|
+
const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
|
|
15971
|
+
return () => {
|
|
15972
|
+
closed = true;
|
|
15973
|
+
live?.();
|
|
15974
|
+
};
|
|
15975
|
+
},
|
|
15836
15976
|
where(columnOrCondition, operator, value) {
|
|
15837
15977
|
const builder = new SDKQueryBuilder(client);
|
|
15838
15978
|
if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
|
|
@@ -17633,6 +17773,1915 @@ var RebaseRealtimeChannel = class {
|
|
|
17633
17773
|
}
|
|
17634
17774
|
};
|
|
17635
17775
|
/**
|
|
17776
|
+
* Lossless round-tripping of rows through the offline store.
|
|
17777
|
+
*
|
|
17778
|
+
* Both persistence backends move values by structured clone, which keeps
|
|
17779
|
+
* `Date` but flattens every class instance to a plain object. For
|
|
17780
|
+
* `EntityReference`/`EntityRelation` that is harmless — they carry their own
|
|
17781
|
+
* `__type` discriminator, so the JSON reviver can rebuild them — but
|
|
17782
|
+
* `GeoPoint` and `Vector` do not, and would come back out of the cache as
|
|
17783
|
+
* anonymous `{ latitude, longitude }` / `{ value }` bags. A row read from the
|
|
17784
|
+
* cache must be indistinguishable from the same row read from the network, so
|
|
17785
|
+
* those two are tagged on the way in and revived on the way out.
|
|
17786
|
+
*
|
|
17787
|
+
* Type tests here are structural rather than `instanceof`, because a structured
|
|
17788
|
+
* clone can arrive from another realm — an iframe, a worker, or the polyfill
|
|
17789
|
+
* the tests run against — where the constructor identity differs but the value
|
|
17790
|
+
* is the real thing. Only *plain* objects are walked; anything else is passed
|
|
17791
|
+
* through whole, so a class instance is never quietly reduced to `{}`.
|
|
17792
|
+
*/
|
|
17793
|
+
function isDate(value) {
|
|
17794
|
+
return Object.prototype.toString.call(value) === "[object Date]";
|
|
17795
|
+
}
|
|
17796
|
+
/** An object literal — not a Date, RegExp, Map, or any class instance. */
|
|
17797
|
+
function isPlainObject(value) {
|
|
17798
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
17799
|
+
const proto = Object.getPrototypeOf(value);
|
|
17800
|
+
if (proto === null || proto === Object.prototype) return true;
|
|
17801
|
+
return proto.constructor?.name === "Object";
|
|
17802
|
+
}
|
|
17803
|
+
function dehydrateValue(value) {
|
|
17804
|
+
if (value === null || value === void 0) return value;
|
|
17805
|
+
if (value instanceof GeoPoint) return {
|
|
17806
|
+
__type: "GeoPoint",
|
|
17807
|
+
latitude: value.latitude,
|
|
17808
|
+
longitude: value.longitude
|
|
17809
|
+
};
|
|
17810
|
+
if (value instanceof Vector) return {
|
|
17811
|
+
__type: "Vector",
|
|
17812
|
+
value: [...value.value]
|
|
17813
|
+
};
|
|
17814
|
+
if (value instanceof EntityReference || value instanceof EntityRelation) return value;
|
|
17815
|
+
if (Array.isArray(value)) return value.map(dehydrateValue);
|
|
17816
|
+
if (isPlainObject(value)) {
|
|
17817
|
+
const out = {};
|
|
17818
|
+
for (const [key, inner] of Object.entries(value)) out[key] = dehydrateValue(inner);
|
|
17819
|
+
return out;
|
|
17820
|
+
}
|
|
17821
|
+
return value;
|
|
17822
|
+
}
|
|
17823
|
+
function hydrateValue(value) {
|
|
17824
|
+
if (value === null || value === void 0 || isDate(value)) return value;
|
|
17825
|
+
if (Array.isArray(value)) return value.map(hydrateValue);
|
|
17826
|
+
if (typeof value === "object") {
|
|
17827
|
+
const revived = rebaseReviver("", value);
|
|
17828
|
+
if (revived !== value) return revived;
|
|
17829
|
+
if (!isPlainObject(value)) return value;
|
|
17830
|
+
const out = {};
|
|
17831
|
+
for (const [key, inner] of Object.entries(value)) out[key] = hydrateValue(inner);
|
|
17832
|
+
return out;
|
|
17833
|
+
}
|
|
17834
|
+
return value;
|
|
17835
|
+
}
|
|
17836
|
+
/** Prepare a row for the store. */
|
|
17837
|
+
function dehydrateRow(row) {
|
|
17838
|
+
return dehydrateValue(row);
|
|
17839
|
+
}
|
|
17840
|
+
/** Restore a row read back from the store. */
|
|
17841
|
+
function hydrateRow(row) {
|
|
17842
|
+
return hydrateValue(row);
|
|
17843
|
+
}
|
|
17844
|
+
/**
|
|
17845
|
+
* Whether the network is worth trying, and when to try again after it wasn't.
|
|
17846
|
+
*
|
|
17847
|
+
* `navigator.onLine` is necessary but not sufficient: it reports the state of
|
|
17848
|
+
* the network interface, so it stays `true` behind a captive portal, on a
|
|
17849
|
+
* connection that resolves DNS but reaches nothing, and while the API itself
|
|
17850
|
+
* is down. This tracks what actually happened to requests as well, so the
|
|
17851
|
+
* first failure is the only one an app pays for — everything after it inside
|
|
17852
|
+
* the backoff window skips the doomed round trip and answers from the local
|
|
17853
|
+
* store immediately, which is the difference between an app that freezes when
|
|
17854
|
+
* the wifi drops and one that does not.
|
|
17855
|
+
*/
|
|
17856
|
+
/** The request never reached the server, so nothing was decided by it. */
|
|
17857
|
+
function isNetworkError(error) {
|
|
17858
|
+
if (error instanceof RebaseApiError) return error.status === 0;
|
|
17859
|
+
if (error instanceof TypeError) return true;
|
|
17860
|
+
const name = error?.name;
|
|
17861
|
+
return name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
|
|
17862
|
+
}
|
|
17863
|
+
/**
|
|
17864
|
+
* Statuses that mean "not now" rather than "not ever": a queued write that
|
|
17865
|
+
* gets one of these is worth replaying, while a 400 or a 403 never will be.
|
|
17866
|
+
* 500 is deliberately absent — an unhandled server error is far more often a
|
|
17867
|
+
* bug the same payload will hit again than a blip, and retrying it forever
|
|
17868
|
+
* jams every write behind it.
|
|
17869
|
+
*/
|
|
17870
|
+
var RETRYABLE_STATUSES = new Set([
|
|
17871
|
+
408,
|
|
17872
|
+
425,
|
|
17873
|
+
429,
|
|
17874
|
+
502,
|
|
17875
|
+
503,
|
|
17876
|
+
504
|
|
17877
|
+
]);
|
|
17878
|
+
/** Is this failure worth another attempt later? */
|
|
17879
|
+
function isRetryableError(error) {
|
|
17880
|
+
if (isNetworkError(error)) return true;
|
|
17881
|
+
if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
|
|
17882
|
+
return false;
|
|
17883
|
+
}
|
|
17884
|
+
var ConnectivityMonitor = class {
|
|
17885
|
+
state = "online";
|
|
17886
|
+
backoffMs;
|
|
17887
|
+
initialBackoffMs;
|
|
17888
|
+
maxBackoffMs;
|
|
17889
|
+
retryAt = 0;
|
|
17890
|
+
timer;
|
|
17891
|
+
listeners = /* @__PURE__ */ new Set();
|
|
17892
|
+
respectBackoff;
|
|
17893
|
+
now;
|
|
17894
|
+
setTimer;
|
|
17895
|
+
clearTimer;
|
|
17896
|
+
/** Called when the backoff window expires, to drive an automatic retry. */
|
|
17897
|
+
onRetryDue;
|
|
17898
|
+
handleOnline = () => {
|
|
17899
|
+
this.retryAt = 0;
|
|
17900
|
+
this.backoffMs = this.initialBackoffMs;
|
|
17901
|
+
this.clearPendingTimer();
|
|
17902
|
+
this.setState("online");
|
|
17903
|
+
this.onRetryDue?.();
|
|
17904
|
+
};
|
|
17905
|
+
handleOffline = () => {
|
|
17906
|
+
this.setState("offline");
|
|
17907
|
+
};
|
|
17908
|
+
constructor(options = {}) {
|
|
17909
|
+
this.initialBackoffMs = options.initialBackoffMs ?? 1e3;
|
|
17910
|
+
this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 6e4);
|
|
17911
|
+
this.backoffMs = this.initialBackoffMs;
|
|
17912
|
+
this.respectBackoff = options.respectBackoff ?? true;
|
|
17913
|
+
this.now = options.now ?? (() => Date.now());
|
|
17914
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
17915
|
+
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
17916
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
17917
|
+
window.addEventListener("online", this.handleOnline);
|
|
17918
|
+
window.addEventListener("offline", this.handleOffline);
|
|
17919
|
+
}
|
|
17920
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) this.state = "offline";
|
|
17921
|
+
}
|
|
17922
|
+
/** What the app should be told: are we connected? */
|
|
17923
|
+
isOnline() {
|
|
17924
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
|
|
17925
|
+
return this.state === "online";
|
|
17926
|
+
}
|
|
17927
|
+
/**
|
|
17928
|
+
* Should this request even be sent? False means "answer from the local
|
|
17929
|
+
* store instead" — the request would only burn a timeout to reach the same
|
|
17930
|
+
* conclusion the last one already did.
|
|
17931
|
+
*/
|
|
17932
|
+
shouldAttempt() {
|
|
17933
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
|
|
17934
|
+
if (this.state === "online" || !this.respectBackoff) return true;
|
|
17935
|
+
return this.now() >= this.retryAt;
|
|
17936
|
+
}
|
|
17937
|
+
/** A request reached the server. */
|
|
17938
|
+
markSuccess() {
|
|
17939
|
+
this.backoffMs = this.initialBackoffMs;
|
|
17940
|
+
this.retryAt = 0;
|
|
17941
|
+
this.clearPendingTimer();
|
|
17942
|
+
this.setState("online");
|
|
17943
|
+
}
|
|
17944
|
+
/** A request did not reach the server: we are offline until proven otherwise. */
|
|
17945
|
+
markFailure() {
|
|
17946
|
+
this.deferRetry();
|
|
17947
|
+
this.setState("offline");
|
|
17948
|
+
}
|
|
17949
|
+
/**
|
|
17950
|
+
* Back off and try again later without claiming the connection is gone.
|
|
17951
|
+
* This is what a 429 or a 503 deserves — the server answered, so the app
|
|
17952
|
+
* is demonstrably online; it just should not hammer.
|
|
17953
|
+
*/
|
|
17954
|
+
deferRetry() {
|
|
17955
|
+
const jitter = .8 + Math.random() * .4;
|
|
17956
|
+
this.retryAt = this.now() + this.backoffMs * jitter;
|
|
17957
|
+
const delay = Math.max(0, this.retryAt - this.now());
|
|
17958
|
+
this.backoffMs = Math.min(this.maxBackoffMs, this.backoffMs * 2);
|
|
17959
|
+
this.scheduleRetry(delay);
|
|
17960
|
+
}
|
|
17961
|
+
/** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
|
|
17962
|
+
msUntilRetry() {
|
|
17963
|
+
if (this.state === "online") return 0;
|
|
17964
|
+
return Math.max(0, this.retryAt - this.now());
|
|
17965
|
+
}
|
|
17966
|
+
onChange(listener) {
|
|
17967
|
+
this.listeners.add(listener);
|
|
17968
|
+
return () => this.listeners.delete(listener);
|
|
17969
|
+
}
|
|
17970
|
+
dispose() {
|
|
17971
|
+
if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
|
|
17972
|
+
window.removeEventListener("online", this.handleOnline);
|
|
17973
|
+
window.removeEventListener("offline", this.handleOffline);
|
|
17974
|
+
}
|
|
17975
|
+
this.clearPendingTimer();
|
|
17976
|
+
this.listeners.clear();
|
|
17977
|
+
this.onRetryDue = void 0;
|
|
17978
|
+
}
|
|
17979
|
+
scheduleRetry(delay) {
|
|
17980
|
+
this.clearPendingTimer();
|
|
17981
|
+
if (!this.onRetryDue) return;
|
|
17982
|
+
this.timer = this.setTimer(() => {
|
|
17983
|
+
this.timer = void 0;
|
|
17984
|
+
this.onRetryDue?.();
|
|
17985
|
+
}, delay);
|
|
17986
|
+
this.timer.unref?.();
|
|
17987
|
+
}
|
|
17988
|
+
clearPendingTimer() {
|
|
17989
|
+
if (this.timer !== void 0) {
|
|
17990
|
+
this.clearTimer(this.timer);
|
|
17991
|
+
this.timer = void 0;
|
|
17992
|
+
}
|
|
17993
|
+
}
|
|
17994
|
+
setState(next) {
|
|
17995
|
+
if (this.state === next) return;
|
|
17996
|
+
this.state = next;
|
|
17997
|
+
const online = this.isOnline();
|
|
17998
|
+
for (const listener of this.listeners) listener(online);
|
|
17999
|
+
}
|
|
18000
|
+
};
|
|
18001
|
+
/**
|
|
18002
|
+
* Monotonic within a tab, unique across tabs, and sortable as a plain string:
|
|
18003
|
+
* `<ms base36, padded>-<counter>-<random>`. The padding is what keeps
|
|
18004
|
+
* lexicographic order equal to chronological order, and the random suffix is
|
|
18005
|
+
* what stops two tabs from writing the same queue key in the same millisecond
|
|
18006
|
+
* — which would silently drop one of the two writes.
|
|
18007
|
+
*/
|
|
18008
|
+
var mutationCounter = 0;
|
|
18009
|
+
function createMutationId(now = Date.now()) {
|
|
18010
|
+
return `${now.toString(36).padStart(10, "0")}-${(mutationCounter = (mutationCounter + 1) % 1679616).toString(36).padStart(4, "0")}-${Math.random().toString(36).slice(2, 10).padStart(8, "0")}`;
|
|
18011
|
+
}
|
|
18012
|
+
/**
|
|
18013
|
+
* In-memory store: the default outside the browser and the workhorse of the
|
|
18014
|
+
* test suite. Values are deep-copied on the way in and out so a caller
|
|
18015
|
+
* mutating a returned row cannot silently edit the "persisted" copy — the
|
|
18016
|
+
* IndexedDB implementation gets the same guarantee for free from structured
|
|
18017
|
+
* cloning, and the two must not differ in aliasing behaviour.
|
|
18018
|
+
*/
|
|
18019
|
+
var MemoryOfflineStore = class {
|
|
18020
|
+
cache = /* @__PURE__ */ new Map();
|
|
18021
|
+
queue = /* @__PURE__ */ new Map();
|
|
18022
|
+
async getCache(key) {
|
|
18023
|
+
const entry = this.cache.get(key);
|
|
18024
|
+
return entry ? structuredClone(entry) : void 0;
|
|
18025
|
+
}
|
|
18026
|
+
async setCache(key, entry) {
|
|
18027
|
+
this.cache.set(key, structuredClone(entry));
|
|
18028
|
+
}
|
|
18029
|
+
async setCacheMany(entries) {
|
|
18030
|
+
for (const { key, entry } of entries) this.cache.set(key, structuredClone(entry));
|
|
18031
|
+
}
|
|
18032
|
+
async deleteCache(keys) {
|
|
18033
|
+
for (const key of keys) this.cache.delete(key);
|
|
18034
|
+
}
|
|
18035
|
+
async listCache(prefix) {
|
|
18036
|
+
const out = [];
|
|
18037
|
+
for (const [key, entry] of this.cache) if (key.startsWith(prefix)) out.push({
|
|
18038
|
+
key,
|
|
18039
|
+
cachedAt: entry.cachedAt
|
|
18040
|
+
});
|
|
18041
|
+
return out;
|
|
18042
|
+
}
|
|
18043
|
+
async listCacheEntries(prefix) {
|
|
18044
|
+
const out = [];
|
|
18045
|
+
for (const [key, entry] of this.cache) if (key.startsWith(prefix)) out.push({
|
|
18046
|
+
key,
|
|
18047
|
+
...structuredClone(entry)
|
|
18048
|
+
});
|
|
18049
|
+
out.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
18050
|
+
return out;
|
|
18051
|
+
}
|
|
18052
|
+
async enqueue(key, mutation) {
|
|
18053
|
+
this.queue.set(key, structuredClone(mutation));
|
|
18054
|
+
}
|
|
18055
|
+
async dequeue(key) {
|
|
18056
|
+
this.queue.delete(key);
|
|
18057
|
+
}
|
|
18058
|
+
async listQueue(prefix) {
|
|
18059
|
+
return [...this.queue.entries()].filter(([key]) => key.startsWith(prefix)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, mutation]) => structuredClone(mutation));
|
|
18060
|
+
}
|
|
18061
|
+
async clear(prefix) {
|
|
18062
|
+
for (const key of [...this.cache.keys()]) if (key.startsWith(prefix)) this.cache.delete(key);
|
|
18063
|
+
for (const key of [...this.queue.keys()]) if (key.startsWith(prefix)) this.queue.delete(key);
|
|
18064
|
+
}
|
|
18065
|
+
};
|
|
18066
|
+
var IDB_NAME = "rebase-offline";
|
|
18067
|
+
/**
|
|
18068
|
+
* v2 introduced the normalized row cache and string mutation ids. A v1
|
|
18069
|
+
* database holds whole-response blobs under keys this version cannot read and
|
|
18070
|
+
* queue entries ordered by a numeric `seq` this version no longer writes, so
|
|
18071
|
+
* the upgrade drops both stores rather than trying to translate them. Offline
|
|
18072
|
+
* support had not shipped in a release when v2 landed, so nothing in the wild
|
|
18073
|
+
* loses a queued write to this.
|
|
18074
|
+
*/
|
|
18075
|
+
var IDB_VERSION = 2;
|
|
18076
|
+
var CACHE_STORE = "cache";
|
|
18077
|
+
var QUEUE_STORE = "queue";
|
|
18078
|
+
/** The exclusive upper bound of an IDBKeyRange covering every key under `prefix`. */
|
|
18079
|
+
function prefixRange(prefix) {
|
|
18080
|
+
return IDBKeyRange.bound(prefix, prefix + "", false, false);
|
|
18081
|
+
}
|
|
18082
|
+
function requestToPromise(request) {
|
|
18083
|
+
return new Promise((resolve, reject) => {
|
|
18084
|
+
request.onsuccess = () => resolve(request.result);
|
|
18085
|
+
request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("IndexedDB request failed"));
|
|
18086
|
+
});
|
|
18087
|
+
}
|
|
18088
|
+
/** Resolve when the whole transaction commits, not just when the last request returns. */
|
|
18089
|
+
function transactionDone(tx) {
|
|
18090
|
+
return new Promise((resolve, reject) => {
|
|
18091
|
+
tx.oncomplete = () => resolve();
|
|
18092
|
+
tx.onabort = tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("IndexedDB transaction failed"));
|
|
18093
|
+
});
|
|
18094
|
+
}
|
|
18095
|
+
/**
|
|
18096
|
+
* IndexedDB-backed store — the browser default, so cached rows and queued
|
|
18097
|
+
* writes survive a reload or a browser restart. Everything lives in one
|
|
18098
|
+
* database with two object stores; keys are the manager's full prefixed
|
|
18099
|
+
* strings, so multiple users (scopes) share the database without ever
|
|
18100
|
+
* sharing entries.
|
|
18101
|
+
*/
|
|
18102
|
+
var IndexedDBOfflineStore = class {
|
|
18103
|
+
dbPromise;
|
|
18104
|
+
open() {
|
|
18105
|
+
if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
|
|
18106
|
+
const request = indexedDB.open(IDB_NAME, IDB_VERSION);
|
|
18107
|
+
request.onupgradeneeded = (event) => {
|
|
18108
|
+
const db = request.result;
|
|
18109
|
+
if (event.oldVersion > 0 && event.oldVersion < 2) {
|
|
18110
|
+
if (db.objectStoreNames.contains(CACHE_STORE)) db.deleteObjectStore(CACHE_STORE);
|
|
18111
|
+
if (db.objectStoreNames.contains(QUEUE_STORE)) db.deleteObjectStore(QUEUE_STORE);
|
|
18112
|
+
}
|
|
18113
|
+
if (!db.objectStoreNames.contains(CACHE_STORE)) db.createObjectStore(CACHE_STORE);
|
|
18114
|
+
if (!db.objectStoreNames.contains(QUEUE_STORE)) db.createObjectStore(QUEUE_STORE);
|
|
18115
|
+
};
|
|
18116
|
+
request.onsuccess = () => {
|
|
18117
|
+
const db = request.result;
|
|
18118
|
+
db.onversionchange = () => {
|
|
18119
|
+
db.close();
|
|
18120
|
+
this.dbPromise = void 0;
|
|
18121
|
+
};
|
|
18122
|
+
resolve(db);
|
|
18123
|
+
};
|
|
18124
|
+
request.onerror = () => {
|
|
18125
|
+
this.dbPromise = void 0;
|
|
18126
|
+
reject(request.error ?? /* @__PURE__ */ new Error("Failed to open IndexedDB"));
|
|
18127
|
+
};
|
|
18128
|
+
request.onblocked = () => {
|
|
18129
|
+
this.dbPromise = void 0;
|
|
18130
|
+
reject(/* @__PURE__ */ new Error("IndexedDB upgrade blocked by another tab"));
|
|
18131
|
+
};
|
|
18132
|
+
});
|
|
18133
|
+
return this.dbPromise;
|
|
18134
|
+
}
|
|
18135
|
+
async store(name, mode) {
|
|
18136
|
+
return (await this.open()).transaction(name, mode).objectStore(name);
|
|
18137
|
+
}
|
|
18138
|
+
async getCache(key) {
|
|
18139
|
+
return await requestToPromise((await this.store(CACHE_STORE, "readonly")).get(key));
|
|
18140
|
+
}
|
|
18141
|
+
async setCache(key, entry) {
|
|
18142
|
+
await requestToPromise((await this.store(CACHE_STORE, "readwrite")).put(entry, key));
|
|
18143
|
+
}
|
|
18144
|
+
async setCacheMany(entries) {
|
|
18145
|
+
if (entries.length === 0) return;
|
|
18146
|
+
const store = await this.store(CACHE_STORE, "readwrite");
|
|
18147
|
+
for (const { key, entry } of entries) store.put(entry, key);
|
|
18148
|
+
await transactionDone(store.transaction);
|
|
18149
|
+
}
|
|
18150
|
+
async deleteCache(keys) {
|
|
18151
|
+
if (keys.length === 0) return;
|
|
18152
|
+
const store = await this.store(CACHE_STORE, "readwrite");
|
|
18153
|
+
for (const key of keys) store.delete(key);
|
|
18154
|
+
await transactionDone(store.transaction);
|
|
18155
|
+
}
|
|
18156
|
+
async listCache(prefix) {
|
|
18157
|
+
const store = await this.store(CACHE_STORE, "readonly");
|
|
18158
|
+
const [keys, entries] = await Promise.all([requestToPromise(store.getAllKeys(prefixRange(prefix))), requestToPromise(store.getAll(prefixRange(prefix)))]);
|
|
18159
|
+
return keys.map((key, i) => ({
|
|
18160
|
+
key: String(key),
|
|
18161
|
+
cachedAt: entries[i]?.cachedAt ?? 0
|
|
18162
|
+
}));
|
|
18163
|
+
}
|
|
18164
|
+
async listCacheEntries(prefix) {
|
|
18165
|
+
const store = await this.store(CACHE_STORE, "readonly");
|
|
18166
|
+
const [keys, entries] = await Promise.all([requestToPromise(store.getAllKeys(prefixRange(prefix))), requestToPromise(store.getAll(prefixRange(prefix)))]);
|
|
18167
|
+
return keys.map((key, i) => {
|
|
18168
|
+
const entry = entries[i];
|
|
18169
|
+
return {
|
|
18170
|
+
key: String(key),
|
|
18171
|
+
value: entry?.value,
|
|
18172
|
+
cachedAt: entry?.cachedAt ?? 0
|
|
18173
|
+
};
|
|
18174
|
+
});
|
|
18175
|
+
}
|
|
18176
|
+
async enqueue(key, mutation) {
|
|
18177
|
+
await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).put(mutation, key));
|
|
18178
|
+
}
|
|
18179
|
+
async dequeue(key) {
|
|
18180
|
+
await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).delete(key));
|
|
18181
|
+
}
|
|
18182
|
+
async listQueue(prefix) {
|
|
18183
|
+
return await requestToPromise((await this.store(QUEUE_STORE, "readonly")).getAll(prefixRange(prefix)));
|
|
18184
|
+
}
|
|
18185
|
+
async clear(prefix) {
|
|
18186
|
+
await requestToPromise((await this.store(CACHE_STORE, "readwrite")).delete(prefixRange(prefix)));
|
|
18187
|
+
await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).delete(prefixRange(prefix)));
|
|
18188
|
+
}
|
|
18189
|
+
};
|
|
18190
|
+
/**
|
|
18191
|
+
* A local evaluator for `FindParams`, so cached rows can answer a query the
|
|
18192
|
+
* client has never sent to the server — and so a row written offline shows up
|
|
18193
|
+
* in every filtered list it belongs to, not just in unfiltered ones.
|
|
18194
|
+
*
|
|
18195
|
+
* This mirrors the Postgres driver's semantics rather than JavaScript's:
|
|
18196
|
+
*
|
|
18197
|
+
* - Comparing against NULL is *unknown*, not false-or-true. `status != "done"`
|
|
18198
|
+
* excludes rows where `status` is null, exactly as SQL does — a JS `!==`
|
|
18199
|
+
* would have included them.
|
|
18200
|
+
* - `ORDER BY` puts nulls last ascending and first descending, which is the
|
|
18201
|
+
* Postgres default.
|
|
18202
|
+
* - The wire format carries no types, so values arriving as strings are
|
|
18203
|
+
* compared numerically against numeric columns and as instants against
|
|
18204
|
+
* date columns. `["==", "3"]` matches the number `3`, as it does server-side.
|
|
18205
|
+
*
|
|
18206
|
+
* Two things it deliberately approximates, both flagged by
|
|
18207
|
+
* {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
|
|
18208
|
+
* substring scan over the row's string fields (the server runs real full-text
|
|
18209
|
+
* search over the collection's configured columns), and `include` cannot be
|
|
18210
|
+
* evaluated at all, because the related rows live in collections this query
|
|
18211
|
+
* knows nothing about.
|
|
18212
|
+
*/
|
|
18213
|
+
var collator = typeof Intl !== "undefined" && typeof Intl.Collator === "function" ? new Intl.Collator(void 0, {
|
|
18214
|
+
numeric: false,
|
|
18215
|
+
sensitivity: "variant"
|
|
18216
|
+
}) : void 0;
|
|
18217
|
+
function isNullish(value) {
|
|
18218
|
+
return value === null || value === void 0;
|
|
18219
|
+
}
|
|
18220
|
+
/**
|
|
18221
|
+
* Reduce a value to something comparable. Relations compare by the id they
|
|
18222
|
+
* point at — the column holds a foreign key, so that is what the server
|
|
18223
|
+
* compares too.
|
|
18224
|
+
*/
|
|
18225
|
+
function toComparable(value) {
|
|
18226
|
+
if (value instanceof Date) return value.getTime();
|
|
18227
|
+
if (value instanceof EntityRelation) return value.id;
|
|
18228
|
+
if (value && typeof value === "object") {
|
|
18229
|
+
const record = value;
|
|
18230
|
+
if (typeof record.__type === "string" && "id" in record) return record.id;
|
|
18231
|
+
}
|
|
18232
|
+
return value;
|
|
18233
|
+
}
|
|
18234
|
+
/**
|
|
18235
|
+
* Three-way compare with SQL's type coercion but not its collation. Returns
|
|
18236
|
+
* `undefined` when the two values are not ordered relative to each other,
|
|
18237
|
+
* which is how NULL propagates through a comparison.
|
|
18238
|
+
*/
|
|
18239
|
+
function compareValues(a, b) {
|
|
18240
|
+
const left = toComparable(a);
|
|
18241
|
+
const right = toComparable(b);
|
|
18242
|
+
if (isNullish(left) || isNullish(right)) return void 0;
|
|
18243
|
+
if (typeof left === "boolean" || typeof right === "boolean") return (left === true || left === "true" || left === 1 ? 1 : 0) - (right === true || right === "true" || right === 1 ? 1 : 0);
|
|
18244
|
+
const leftNum = typeof left === "number" ? left : numericOrNaN(left);
|
|
18245
|
+
const rightNum = typeof right === "number" ? right : numericOrNaN(right);
|
|
18246
|
+
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
|
|
18247
|
+
if (typeof left === "number" || typeof right === "number") {
|
|
18248
|
+
const leftTime = toTime(left);
|
|
18249
|
+
const rightTime = toTime(right);
|
|
18250
|
+
if (leftTime !== void 0 && rightTime !== void 0) return leftTime < rightTime ? -1 : leftTime > rightTime ? 1 : 0;
|
|
18251
|
+
}
|
|
18252
|
+
const leftStr = String(left);
|
|
18253
|
+
const rightStr = String(right);
|
|
18254
|
+
if (collator) return collator.compare(leftStr, rightStr);
|
|
18255
|
+
return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
|
|
18256
|
+
}
|
|
18257
|
+
function numericOrNaN(value) {
|
|
18258
|
+
if (typeof value === "number") return value;
|
|
18259
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
18260
|
+
const n = Number(value);
|
|
18261
|
+
return Number.isNaN(n) ? NaN : n;
|
|
18262
|
+
}
|
|
18263
|
+
if (typeof value === "bigint") return Number(value);
|
|
18264
|
+
return NaN;
|
|
18265
|
+
}
|
|
18266
|
+
function toTime(value) {
|
|
18267
|
+
if (typeof value === "number") return value;
|
|
18268
|
+
if (typeof value === "string") {
|
|
18269
|
+
const t = Date.parse(value);
|
|
18270
|
+
return Number.isNaN(t) ? void 0 : t;
|
|
18271
|
+
}
|
|
18272
|
+
}
|
|
18273
|
+
/** Equality with the wire's type erasure allowed for, but never across NULL. */
|
|
18274
|
+
function looseEquals(a, b) {
|
|
18275
|
+
const left = toComparable(a);
|
|
18276
|
+
const right = toComparable(b);
|
|
18277
|
+
if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
|
|
18278
|
+
if (left === right) return true;
|
|
18279
|
+
return compareValues(left, right) === 0;
|
|
18280
|
+
}
|
|
18281
|
+
/**
|
|
18282
|
+
* Translate a SQL `LIKE` pattern to an anchored regular expression.
|
|
18283
|
+
* `%` matches any run of characters, `_` exactly one, and a backslash escapes
|
|
18284
|
+
* either of them.
|
|
18285
|
+
*/
|
|
18286
|
+
function likeToRegExp(pattern, caseInsensitive) {
|
|
18287
|
+
let source = "^";
|
|
18288
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
18289
|
+
const char = pattern[i];
|
|
18290
|
+
if (char === "\\" && i + 1 < pattern.length) {
|
|
18291
|
+
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18292
|
+
i++;
|
|
18293
|
+
} else if (char === "%") source += "[\\s\\S]*";
|
|
18294
|
+
else if (char === "_") source += "[\\s\\S]";
|
|
18295
|
+
else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
18296
|
+
}
|
|
18297
|
+
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
18298
|
+
}
|
|
18299
|
+
function asArray(value) {
|
|
18300
|
+
if (Array.isArray(value)) return value;
|
|
18301
|
+
if (value === void 0) return [];
|
|
18302
|
+
return [value];
|
|
18303
|
+
}
|
|
18304
|
+
/** Evaluate one canonical operator against one row value. */
|
|
18305
|
+
function matchesOperator(rowValue, op, filterValue) {
|
|
18306
|
+
switch (op) {
|
|
18307
|
+
case "is-null": return isNullish(rowValue);
|
|
18308
|
+
case "is-not-null": return !isNullish(rowValue);
|
|
18309
|
+
case "==": return looseEquals(rowValue, filterValue);
|
|
18310
|
+
case "!=":
|
|
18311
|
+
if (isNullish(rowValue)) return false;
|
|
18312
|
+
return !looseEquals(rowValue, filterValue);
|
|
18313
|
+
case "<":
|
|
18314
|
+
case "<=":
|
|
18315
|
+
case ">":
|
|
18316
|
+
case ">=": {
|
|
18317
|
+
const cmp = compareValues(rowValue, filterValue);
|
|
18318
|
+
if (cmp === void 0) return false;
|
|
18319
|
+
if (op === "<") return cmp < 0;
|
|
18320
|
+
if (op === "<=") return cmp <= 0;
|
|
18321
|
+
if (op === ">") return cmp > 0;
|
|
18322
|
+
return cmp >= 0;
|
|
18323
|
+
}
|
|
18324
|
+
case "in":
|
|
18325
|
+
if (isNullish(rowValue)) return false;
|
|
18326
|
+
return asArray(filterValue).some((v) => looseEquals(rowValue, v));
|
|
18327
|
+
case "not-in":
|
|
18328
|
+
if (isNullish(rowValue)) return false;
|
|
18329
|
+
return !asArray(filterValue).some((v) => looseEquals(rowValue, v));
|
|
18330
|
+
case "array-contains":
|
|
18331
|
+
if (!Array.isArray(rowValue)) return false;
|
|
18332
|
+
return rowValue.some((v) => looseEquals(v, filterValue));
|
|
18333
|
+
case "array-contains-any": {
|
|
18334
|
+
if (!Array.isArray(rowValue)) return false;
|
|
18335
|
+
const wanted = asArray(filterValue);
|
|
18336
|
+
return rowValue.some((v) => wanted.some((w) => looseEquals(v, w)));
|
|
18337
|
+
}
|
|
18338
|
+
case "like":
|
|
18339
|
+
case "not-like":
|
|
18340
|
+
case "ilike":
|
|
18341
|
+
case "not-ilike": {
|
|
18342
|
+
if (isNullish(rowValue)) return false;
|
|
18343
|
+
const insensitive = op === "ilike" || op === "not-ilike";
|
|
18344
|
+
const negated = op === "not-like" || op === "not-ilike";
|
|
18345
|
+
const matched = likeToRegExp(String(filterValue), insensitive).test(String(rowValue));
|
|
18346
|
+
return negated ? !matched : matched;
|
|
18347
|
+
}
|
|
18348
|
+
default: return true;
|
|
18349
|
+
}
|
|
18350
|
+
}
|
|
18351
|
+
function isTuple(value) {
|
|
18352
|
+
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && toCanonicalOp(value[0]) !== void 0;
|
|
18353
|
+
}
|
|
18354
|
+
/** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
|
|
18355
|
+
function matchesWhere(row, where) {
|
|
18356
|
+
if (!where) return true;
|
|
18357
|
+
for (const [field, condition] of Object.entries(where)) {
|
|
18358
|
+
if (condition === void 0) continue;
|
|
18359
|
+
const tuples = isTuple(condition) ? [condition] : Array.isArray(condition) ? condition.filter(isTuple) : [];
|
|
18360
|
+
for (const [rawOp, value] of tuples) {
|
|
18361
|
+
const op = toCanonicalOp(rawOp) ?? rawOp;
|
|
18362
|
+
if (!matchesOperator(row[field], op, value)) return false;
|
|
18363
|
+
}
|
|
18364
|
+
}
|
|
18365
|
+
return true;
|
|
18366
|
+
}
|
|
18367
|
+
/** Evaluate a nested and/or tree. */
|
|
18368
|
+
function matchesLogical(row, condition) {
|
|
18369
|
+
if (!condition) return true;
|
|
18370
|
+
if ("type" in condition) {
|
|
18371
|
+
const children = condition.conditions ?? [];
|
|
18372
|
+
if (children.length === 0) return true;
|
|
18373
|
+
return condition.type === "or" ? children.some((c) => matchesLogical(row, c)) : children.every((c) => matchesLogical(row, c));
|
|
18374
|
+
}
|
|
18375
|
+
const op = toCanonicalOp(condition.operator) ?? condition.operator;
|
|
18376
|
+
return matchesOperator(row[condition.column], op, condition.value);
|
|
18377
|
+
}
|
|
18378
|
+
/**
|
|
18379
|
+
* Approximate the server's full-text search with a case-insensitive substring
|
|
18380
|
+
* scan over the row's own string fields. Narrower than the real thing (no
|
|
18381
|
+
* stemming, no configured search columns), and it never matches a field the
|
|
18382
|
+
* cached row does not carry — a local list may therefore be missing rows the
|
|
18383
|
+
* server would have returned, which is why {@link isExactlyEvaluable} refuses
|
|
18384
|
+
* to call a search query exact.
|
|
18385
|
+
*/
|
|
18386
|
+
function matchesSearch(row, searchString) {
|
|
18387
|
+
if (!searchString) return true;
|
|
18388
|
+
const needle = searchString.trim().toLowerCase();
|
|
18389
|
+
if (!needle) return true;
|
|
18390
|
+
for (const value of Object.values(row)) {
|
|
18391
|
+
if (typeof value === "string" && value.toLowerCase().includes(needle)) return true;
|
|
18392
|
+
if (typeof value === "number" && String(value).includes(needle)) return true;
|
|
18393
|
+
}
|
|
18394
|
+
return false;
|
|
18395
|
+
}
|
|
18396
|
+
/** Does this row belong in the result set for `params`, ignoring pagination? */
|
|
18397
|
+
function matchesParams(row, params) {
|
|
18398
|
+
if (!params) return true;
|
|
18399
|
+
return matchesWhere(row, params.where) && matchesLogical(row, params.logical) && matchesSearch(row, params.searchString);
|
|
18400
|
+
}
|
|
18401
|
+
/**
|
|
18402
|
+
* Sort in place, Postgres-style: nulls last ascending, first descending, with
|
|
18403
|
+
* the row id as a tiebreak so paging through an unsorted-but-equal run does
|
|
18404
|
+
* not shuffle rows between pages.
|
|
18405
|
+
*/
|
|
18406
|
+
function sortRows(rows, orderBy) {
|
|
18407
|
+
if (!orderBy) return rows;
|
|
18408
|
+
const [field, direction = "asc"] = orderBy;
|
|
18409
|
+
const sign = direction === "desc" ? -1 : 1;
|
|
18410
|
+
return rows.sort((a, b) => {
|
|
18411
|
+
const av = a[field];
|
|
18412
|
+
const bv = b[field];
|
|
18413
|
+
const aNull = isNullish(toComparable(av));
|
|
18414
|
+
const bNull = isNullish(toComparable(bv));
|
|
18415
|
+
if (aNull || bNull) {
|
|
18416
|
+
if (aNull && bNull) return tiebreak(a, b);
|
|
18417
|
+
return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
|
|
18418
|
+
}
|
|
18419
|
+
const cmp = compareValues(av, bv);
|
|
18420
|
+
if (cmp === void 0 || cmp === 0) return tiebreak(a, b);
|
|
18421
|
+
return cmp * sign;
|
|
18422
|
+
});
|
|
18423
|
+
}
|
|
18424
|
+
function tiebreak(a, b) {
|
|
18425
|
+
return compareValues(a.id, b.id) ?? 0;
|
|
18426
|
+
}
|
|
18427
|
+
/** Resolve `page`/`offset`/`limit` the way the server does. */
|
|
18428
|
+
function resolvePagination(params) {
|
|
18429
|
+
const limit = params?.limit ?? 20;
|
|
18430
|
+
return {
|
|
18431
|
+
limit,
|
|
18432
|
+
offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
|
|
18433
|
+
};
|
|
18434
|
+
}
|
|
18435
|
+
/**
|
|
18436
|
+
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
18437
|
+
* assuming the cache holds every row of the collection?
|
|
18438
|
+
*
|
|
18439
|
+
* `include` pulls in rows from other collections that this evaluator never
|
|
18440
|
+
* sees, and `searchString` is only approximated — both make the local answer a
|
|
18441
|
+
* best effort rather than an equivalent one.
|
|
18442
|
+
*/
|
|
18443
|
+
function isExactlyEvaluable(params) {
|
|
18444
|
+
if (!params) return true;
|
|
18445
|
+
if (params.include && params.include.length > 0) return false;
|
|
18446
|
+
if (params.searchString) return false;
|
|
18447
|
+
return true;
|
|
18448
|
+
}
|
|
18449
|
+
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
18450
|
+
function runLocalQuery(rows, params) {
|
|
18451
|
+
const matched = rows.filter((row) => matchesParams(row, params));
|
|
18452
|
+
sortRows(matched, params?.orderBy);
|
|
18453
|
+
const { limit, offset } = resolvePagination(params);
|
|
18454
|
+
const page = matched.slice(offset, offset + limit);
|
|
18455
|
+
return {
|
|
18456
|
+
data: page,
|
|
18457
|
+
meta: {
|
|
18458
|
+
total: matched.length,
|
|
18459
|
+
limit,
|
|
18460
|
+
offset,
|
|
18461
|
+
hasMore: offset + page.length < matched.length
|
|
18462
|
+
}
|
|
18463
|
+
};
|
|
18464
|
+
}
|
|
18465
|
+
function offlineError(message) {
|
|
18466
|
+
return new RebaseApiError(message, {
|
|
18467
|
+
status: 0,
|
|
18468
|
+
code: "offline"
|
|
18469
|
+
});
|
|
18470
|
+
}
|
|
18471
|
+
function generateOfflineId() {
|
|
18472
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
18473
|
+
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
18474
|
+
}
|
|
18475
|
+
var MISSING = "\0missing";
|
|
18476
|
+
var OfflineManager = class {
|
|
18477
|
+
store;
|
|
18478
|
+
maxCachedQueries;
|
|
18479
|
+
maxCachedRows;
|
|
18480
|
+
maxRetries;
|
|
18481
|
+
onSyncError;
|
|
18482
|
+
createInner;
|
|
18483
|
+
inners = /* @__PURE__ */ new Map();
|
|
18484
|
+
connectivity;
|
|
18485
|
+
scope = "anon";
|
|
18486
|
+
/** The local database: normalized rows and query snapshots per collection. */
|
|
18487
|
+
collections = /* @__PURE__ */ new Map();
|
|
18488
|
+
/** In-memory mirror of the current scope's queue, in replay order. */
|
|
18489
|
+
queue = [];
|
|
18490
|
+
queueLoad;
|
|
18491
|
+
/** Serializes enqueues so concurrent writes keep the order the app made them. */
|
|
18492
|
+
enqueueChain = Promise.resolve();
|
|
18493
|
+
flushPromise;
|
|
18494
|
+
queueListeners = /* @__PURE__ */ new Set();
|
|
18495
|
+
statusListeners = /* @__PURE__ */ new Set();
|
|
18496
|
+
observers = /* @__PURE__ */ new Map();
|
|
18497
|
+
refreshPending = /* @__PURE__ */ new Set();
|
|
18498
|
+
revCounter = 0;
|
|
18499
|
+
disposed = false;
|
|
18500
|
+
currentStatus = {
|
|
18501
|
+
online: true,
|
|
18502
|
+
syncing: false,
|
|
18503
|
+
pending: 0
|
|
18504
|
+
};
|
|
18505
|
+
channel;
|
|
18506
|
+
tabId = createMutationId();
|
|
18507
|
+
api;
|
|
18508
|
+
constructor(config, createInner) {
|
|
18509
|
+
this.store = config.store ?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
|
|
18510
|
+
this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
|
|
18511
|
+
this.maxCachedRows = config.maxCachedRowsPerCollection ?? 5e3;
|
|
18512
|
+
this.maxRetries = config.maxRetries ?? 5;
|
|
18513
|
+
this.onSyncError = config.onSyncError;
|
|
18514
|
+
this.createInner = createInner;
|
|
18515
|
+
const maxBackoffMs = config.syncIntervalMs ?? 6e4;
|
|
18516
|
+
this.connectivity = new ConnectivityMonitor({
|
|
18517
|
+
maxBackoffMs: Math.max(1e3, maxBackoffMs),
|
|
18518
|
+
respectBackoff: maxBackoffMs > 0
|
|
18519
|
+
});
|
|
18520
|
+
if (maxBackoffMs > 0) this.connectivity.onRetryDue = () => {
|
|
18521
|
+
this.sync().catch(() => void 0);
|
|
18522
|
+
};
|
|
18523
|
+
this.connectivity.onChange((online) => {
|
|
18524
|
+
this.patchStatus({ online });
|
|
18525
|
+
if (online) this.revalidateAll();
|
|
18526
|
+
});
|
|
18527
|
+
this.currentStatus.online = this.connectivity.isOnline();
|
|
18528
|
+
if ((config.crossTab ?? this.store instanceof IndexedDBOfflineStore) && typeof BroadcastChannel !== "undefined") try {
|
|
18529
|
+
this.channel = new BroadcastChannel("rebase-offline");
|
|
18530
|
+
this.channel.onmessage = (event) => this.onBroadcast(event.data);
|
|
18531
|
+
this.channel.unref?.();
|
|
18532
|
+
} catch {}
|
|
18533
|
+
this.api = {
|
|
18534
|
+
sync: () => this.sync(),
|
|
18535
|
+
pending: async () => {
|
|
18536
|
+
await this.ensureQueueLoaded();
|
|
18537
|
+
return this.queue.map((m) => structuredClone(m));
|
|
18538
|
+
},
|
|
18539
|
+
status: () => ({ ...this.currentStatus }),
|
|
18540
|
+
onStatusChange: (listener) => {
|
|
18541
|
+
this.statusListeners.add(listener);
|
|
18542
|
+
return () => this.statusListeners.delete(listener);
|
|
18543
|
+
},
|
|
18544
|
+
clear: async () => {
|
|
18545
|
+
await this.store.clear(`${this.scope}|`);
|
|
18546
|
+
this.queue = [];
|
|
18547
|
+
this.resetCollections();
|
|
18548
|
+
this.patchStatus({
|
|
18549
|
+
pending: 0,
|
|
18550
|
+
lastError: void 0
|
|
18551
|
+
});
|
|
18552
|
+
this.notifyQueue();
|
|
18553
|
+
for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
|
|
18554
|
+
},
|
|
18555
|
+
onQueueChange: (listener) => {
|
|
18556
|
+
this.queueListeners.add(listener);
|
|
18557
|
+
return () => this.queueListeners.delete(listener);
|
|
18558
|
+
}
|
|
18559
|
+
};
|
|
18560
|
+
}
|
|
18561
|
+
/**
|
|
18562
|
+
* Cache and queue are partitioned per signed-in user: cached rows are
|
|
18563
|
+
* RLS-filtered for the user who fetched them, and queued writes must
|
|
18564
|
+
* replay under the credentials that made them — so neither may ever leak
|
|
18565
|
+
* across a sign-out/sign-in on a shared browser.
|
|
18566
|
+
*/
|
|
18567
|
+
setScope(uid) {
|
|
18568
|
+
const next = uid || "anon";
|
|
18569
|
+
if (next === this.scope) return;
|
|
18570
|
+
this.scope = next;
|
|
18571
|
+
this.queueLoad = void 0;
|
|
18572
|
+
this.queue = [];
|
|
18573
|
+
this.resetCollections();
|
|
18574
|
+
this.patchStatus({
|
|
18575
|
+
pending: 0,
|
|
18576
|
+
lastError: void 0
|
|
18577
|
+
});
|
|
18578
|
+
this.notifyQueue();
|
|
18579
|
+
for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
|
|
18580
|
+
this.revalidateAll();
|
|
18581
|
+
this.sync().catch(() => void 0);
|
|
18582
|
+
}
|
|
18583
|
+
/**
|
|
18584
|
+
* Throw away every local row, for a scope change or an explicit clear.
|
|
18585
|
+
*
|
|
18586
|
+
* The state objects are replaced rather than emptied, so a load still in
|
|
18587
|
+
* flight for the previous user fails its identity check and discards what
|
|
18588
|
+
* it read instead of grafting it onto the new one. The replacements are
|
|
18589
|
+
* marked ready: nothing needs loading until something asks, and observers
|
|
18590
|
+
* have to be told *now* that the rows they are showing are gone.
|
|
18591
|
+
*/
|
|
18592
|
+
resetCollections() {
|
|
18593
|
+
const slugs = [...this.collections.keys()];
|
|
18594
|
+
this.collections = /* @__PURE__ */ new Map();
|
|
18595
|
+
for (const slug of slugs) this.collections.set(slug, {
|
|
18596
|
+
rows: /* @__PURE__ */ new Map(),
|
|
18597
|
+
snapshots: /* @__PURE__ */ new Map(),
|
|
18598
|
+
fresh: /* @__PURE__ */ new Set(),
|
|
18599
|
+
freshRows: /* @__PURE__ */ new Set(),
|
|
18600
|
+
absent: /* @__PURE__ */ new Set(),
|
|
18601
|
+
ready: true
|
|
18602
|
+
});
|
|
18603
|
+
}
|
|
18604
|
+
/** Release listeners, timers and the cross-tab channel (client.close()). */
|
|
18605
|
+
dispose() {
|
|
18606
|
+
this.disposed = true;
|
|
18607
|
+
this.connectivity.dispose();
|
|
18608
|
+
try {
|
|
18609
|
+
this.channel?.close();
|
|
18610
|
+
} catch {}
|
|
18611
|
+
this.observers.clear();
|
|
18612
|
+
this.queueListeners.clear();
|
|
18613
|
+
this.statusListeners.clear();
|
|
18614
|
+
}
|
|
18615
|
+
wrap(slug, inner) {
|
|
18616
|
+
this.inners.set(slug, inner);
|
|
18617
|
+
const wrapped = {
|
|
18618
|
+
find: async (params) => {
|
|
18619
|
+
const state = await this.ensureCollection(slug);
|
|
18620
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18621
|
+
const res = await inner.find(params);
|
|
18622
|
+
this.connectivity.markSuccess();
|
|
18623
|
+
await this.ingest(slug, res.data ?? []);
|
|
18624
|
+
const snapshot = this.recordSnapshot(slug, params, res);
|
|
18625
|
+
const answer = this.answer(slug, params, snapshot);
|
|
18626
|
+
this.notifyCollection(slug, false);
|
|
18627
|
+
return {
|
|
18628
|
+
data: answer.data,
|
|
18629
|
+
meta: answer.meta
|
|
18630
|
+
};
|
|
18631
|
+
} catch (error) {
|
|
18632
|
+
if (!isNetworkError(error)) {
|
|
18633
|
+
if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params)) {
|
|
18634
|
+
const answer = this.answer(slug, params, this.snapshotFor(slug, params));
|
|
18635
|
+
return {
|
|
18636
|
+
data: answer.data,
|
|
18637
|
+
meta: answer.meta
|
|
18638
|
+
};
|
|
18639
|
+
}
|
|
18640
|
+
throw error;
|
|
18641
|
+
}
|
|
18642
|
+
this.connectivity.markFailure();
|
|
18643
|
+
}
|
|
18644
|
+
const answer = this.localFind(slug, params);
|
|
18645
|
+
this.notifyCollection(slug, false);
|
|
18646
|
+
return {
|
|
18647
|
+
data: answer.data,
|
|
18648
|
+
meta: answer.meta
|
|
18649
|
+
};
|
|
18650
|
+
},
|
|
18651
|
+
findById: async (id) => {
|
|
18652
|
+
await this.ensureCollection(slug);
|
|
18653
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18654
|
+
const row = await inner.findById(id);
|
|
18655
|
+
this.connectivity.markSuccess();
|
|
18656
|
+
if (row !== void 0) await this.ingest(slug, [row]);
|
|
18657
|
+
else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
|
|
18658
|
+
this.notifyCollection(slug, false);
|
|
18659
|
+
return this.localRow(slug, id);
|
|
18660
|
+
} catch (error) {
|
|
18661
|
+
if (!isNetworkError(error)) throw error;
|
|
18662
|
+
this.connectivity.markFailure();
|
|
18663
|
+
}
|
|
18664
|
+
const local = this.localRow(slug, id);
|
|
18665
|
+
if (local !== void 0 || this.hasPending(slug, id)) return local;
|
|
18666
|
+
if (this.collections.get(slug)?.absent.has(String(id))) return void 0;
|
|
18667
|
+
throw offlineError(`Offline: "${slug}" row ${String(id)} is not in the local database.`);
|
|
18668
|
+
},
|
|
18669
|
+
create: async (data, id) => {
|
|
18670
|
+
await this.ensureCollection(slug);
|
|
18671
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18672
|
+
const row = await inner.create(data, id);
|
|
18673
|
+
this.connectivity.markSuccess();
|
|
18674
|
+
await this.ingest(slug, [row]);
|
|
18675
|
+
this.notifyCollection(slug);
|
|
18676
|
+
this.scheduleRefresh(slug);
|
|
18677
|
+
return row;
|
|
18678
|
+
} catch (error) {
|
|
18679
|
+
if (!isNetworkError(error)) throw error;
|
|
18680
|
+
this.connectivity.markFailure();
|
|
18681
|
+
}
|
|
18682
|
+
const providedId = id ?? data.id;
|
|
18683
|
+
const rowId = providedId ?? generateOfflineId();
|
|
18684
|
+
const row = {
|
|
18685
|
+
...data,
|
|
18686
|
+
id: rowId
|
|
18687
|
+
};
|
|
18688
|
+
await this.enqueue({
|
|
18689
|
+
collection: slug,
|
|
18690
|
+
type: "create",
|
|
18691
|
+
id: rowId,
|
|
18692
|
+
data: row,
|
|
18693
|
+
generatedId: providedId === void 0,
|
|
18694
|
+
rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
|
|
18695
|
+
});
|
|
18696
|
+
this.setLocalRow(slug, rowId, row);
|
|
18697
|
+
this.notifyCollection(slug);
|
|
18698
|
+
return row;
|
|
18699
|
+
},
|
|
18700
|
+
createMany: async (data, options) => {
|
|
18701
|
+
await this.ensureCollection(slug);
|
|
18702
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
18703
|
+
if (data.length === 0) return [];
|
|
18704
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18705
|
+
const rows = await inner.createMany(data, options);
|
|
18706
|
+
this.connectivity.markSuccess();
|
|
18707
|
+
await this.ingest(slug, rows);
|
|
18708
|
+
this.notifyCollection(slug);
|
|
18709
|
+
this.scheduleRefresh(slug);
|
|
18710
|
+
return rows;
|
|
18711
|
+
} catch (error) {
|
|
18712
|
+
if (!isNetworkError(error)) throw error;
|
|
18713
|
+
this.connectivity.markFailure();
|
|
18714
|
+
}
|
|
18715
|
+
const rows = data.map((r) => ({
|
|
18716
|
+
...r,
|
|
18717
|
+
id: r.id ?? generateOfflineId()
|
|
18718
|
+
}));
|
|
18719
|
+
const rollback = {};
|
|
18720
|
+
for (const row of rows) {
|
|
18721
|
+
const key = String(row.id);
|
|
18722
|
+
rollback[key] = this.rawLocalRow(slug, row.id) ?? null;
|
|
18723
|
+
}
|
|
18724
|
+
await this.enqueue({
|
|
18725
|
+
collection: slug,
|
|
18726
|
+
type: "createMany",
|
|
18727
|
+
data: rows,
|
|
18728
|
+
upsert: options?.upsert,
|
|
18729
|
+
rollback: { rows: rollback }
|
|
18730
|
+
});
|
|
18731
|
+
for (const row of rows) this.setLocalRow(slug, row.id, row);
|
|
18732
|
+
this.notifyCollection(slug);
|
|
18733
|
+
return rows;
|
|
18734
|
+
},
|
|
18735
|
+
update: async (id, data) => {
|
|
18736
|
+
await this.ensureCollection(slug);
|
|
18737
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18738
|
+
const row = await inner.update(id, data);
|
|
18739
|
+
this.connectivity.markSuccess();
|
|
18740
|
+
await this.ingest(slug, [row]);
|
|
18741
|
+
this.notifyCollection(slug);
|
|
18742
|
+
return row;
|
|
18743
|
+
} catch (error) {
|
|
18744
|
+
if (!isNetworkError(error)) throw error;
|
|
18745
|
+
this.connectivity.markFailure();
|
|
18746
|
+
}
|
|
18747
|
+
const base = this.rawLocalRow(slug, id);
|
|
18748
|
+
await this.enqueue({
|
|
18749
|
+
collection: slug,
|
|
18750
|
+
type: "update",
|
|
18751
|
+
id,
|
|
18752
|
+
data,
|
|
18753
|
+
rollback: { rows: { [String(id)]: base ?? null } }
|
|
18754
|
+
});
|
|
18755
|
+
const optimistic = {
|
|
18756
|
+
...base ?? {},
|
|
18757
|
+
...data,
|
|
18758
|
+
id
|
|
18759
|
+
};
|
|
18760
|
+
this.setLocalRow(slug, id, optimistic);
|
|
18761
|
+
this.notifyCollection(slug);
|
|
18762
|
+
return optimistic;
|
|
18763
|
+
},
|
|
18764
|
+
delete: async (id) => {
|
|
18765
|
+
await this.ensureCollection(slug);
|
|
18766
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18767
|
+
await inner.delete(id);
|
|
18768
|
+
this.connectivity.markSuccess();
|
|
18769
|
+
this.removeLocalRow(slug, id, true);
|
|
18770
|
+
this.notifyCollection(slug);
|
|
18771
|
+
this.scheduleRefresh(slug);
|
|
18772
|
+
return;
|
|
18773
|
+
} catch (error) {
|
|
18774
|
+
if (!isNetworkError(error)) throw error;
|
|
18775
|
+
this.connectivity.markFailure();
|
|
18776
|
+
}
|
|
18777
|
+
await this.enqueue({
|
|
18778
|
+
collection: slug,
|
|
18779
|
+
type: "delete",
|
|
18780
|
+
id,
|
|
18781
|
+
rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
|
|
18782
|
+
});
|
|
18783
|
+
this.removeLocalRow(slug, id);
|
|
18784
|
+
this.notifyCollection(slug);
|
|
18785
|
+
},
|
|
18786
|
+
count: async (params) => {
|
|
18787
|
+
await this.ensureCollection(slug);
|
|
18788
|
+
if (this.connectivity.shouldAttempt()) try {
|
|
18789
|
+
const n = await inner.count(params);
|
|
18790
|
+
this.connectivity.markSuccess();
|
|
18791
|
+
this.writeCache(this.countKey(slug, params), n);
|
|
18792
|
+
return Math.max(0, n + this.pendingDelta(slug, params));
|
|
18793
|
+
} catch (error) {
|
|
18794
|
+
if (!isNetworkError(error)) throw error;
|
|
18795
|
+
this.connectivity.markFailure();
|
|
18796
|
+
}
|
|
18797
|
+
const cached = await this.readCache(this.countKey(slug, params));
|
|
18798
|
+
if (cached !== void 0) return Math.max(0, cached + this.pendingDelta(slug, params));
|
|
18799
|
+
const state = this.collections.get(slug);
|
|
18800
|
+
if (state && state.rows.size > 0) return runLocalQuery([...state.rows.values()].map((e) => e.row), params).meta.total;
|
|
18801
|
+
throw offlineError(`Offline: no cached count for "${slug}".`);
|
|
18802
|
+
},
|
|
18803
|
+
observe: (params, onResult, onError, options) => this.observe(slug, wrapped, inner, params, onResult, onError, options),
|
|
18804
|
+
observeById: (id, onResult, onError, options) => this.observeById(slug, wrapped, inner, id, onResult, onError, options),
|
|
18805
|
+
where(columnOrCondition, operator, value) {
|
|
18806
|
+
const builder = new SDKQueryBuilder(wrapped);
|
|
18807
|
+
if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
|
|
18808
|
+
return builder.where(columnOrCondition, operator, value);
|
|
18809
|
+
},
|
|
18810
|
+
orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
|
|
18811
|
+
limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
|
|
18812
|
+
offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
|
|
18813
|
+
search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
|
|
18814
|
+
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
|
|
18815
|
+
};
|
|
18816
|
+
if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
18817
|
+
this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
|
|
18818
|
+
onUpdate(response);
|
|
18819
|
+
}, onError);
|
|
18820
|
+
if (inner.listenById) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
|
|
18821
|
+
if (row) this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
|
|
18822
|
+
onUpdate(row);
|
|
18823
|
+
}, onError);
|
|
18824
|
+
return wrapped;
|
|
18825
|
+
}
|
|
18826
|
+
observe(slug, wrapped, inner, params, onResult, onError, options) {
|
|
18827
|
+
let closed = false;
|
|
18828
|
+
let unlisten;
|
|
18829
|
+
const observer = {
|
|
18830
|
+
slug,
|
|
18831
|
+
params,
|
|
18832
|
+
settled: false,
|
|
18833
|
+
refresh: () => wrapped.find(params).catch(() => void 0),
|
|
18834
|
+
emit: () => {
|
|
18835
|
+
if (closed || !this.collections.get(slug)?.ready) return;
|
|
18836
|
+
const result = this.answer(slug, params, this.snapshotFor(slug, params));
|
|
18837
|
+
const signature = `${result.fromCache ? "c" : "s"}${result.hasPendingWrites ? "p" : "-"}` + this.signature(slug, result.data, result.meta.total);
|
|
18838
|
+
if (observer.settled && signature === observer.signature) return;
|
|
18839
|
+
observer.signature = signature;
|
|
18840
|
+
observer.settled = true;
|
|
18841
|
+
onResult(observer.error ? {
|
|
18842
|
+
...result,
|
|
18843
|
+
error: observer.error
|
|
18844
|
+
} : result);
|
|
18845
|
+
}
|
|
18846
|
+
};
|
|
18847
|
+
this.observersFor(slug).add(observer);
|
|
18848
|
+
(async () => {
|
|
18849
|
+
await this.ensureCollection(slug);
|
|
18850
|
+
if (closed) return;
|
|
18851
|
+
if (this.hasLocalAnswer(this.collections.get(slug), slug, params)) observer.emit();
|
|
18852
|
+
try {
|
|
18853
|
+
await wrapped.find(params);
|
|
18854
|
+
observer.error = void 0;
|
|
18855
|
+
} catch (error) {
|
|
18856
|
+
observer.error = error;
|
|
18857
|
+
if (closed) return;
|
|
18858
|
+
if (!observer.settled) {
|
|
18859
|
+
onError?.(error);
|
|
18860
|
+
return;
|
|
18861
|
+
}
|
|
18862
|
+
}
|
|
18863
|
+
if (!closed) observer.emit();
|
|
18864
|
+
})();
|
|
18865
|
+
if (options?.realtime !== false && inner.listen) unlisten = inner.listen(params, (response) => {
|
|
18866
|
+
this.ingest(slug, response.data ?? []).then(() => {
|
|
18867
|
+
this.recordSnapshot(slug, params, response);
|
|
18868
|
+
this.notifyCollection(slug, false);
|
|
18869
|
+
});
|
|
18870
|
+
}, onError);
|
|
18871
|
+
return () => {
|
|
18872
|
+
closed = true;
|
|
18873
|
+
this.observersFor(slug).delete(observer);
|
|
18874
|
+
unlisten?.();
|
|
18875
|
+
};
|
|
18876
|
+
}
|
|
18877
|
+
observeById(slug, wrapped, inner, id, onResult, onError, options) {
|
|
18878
|
+
let closed = false;
|
|
18879
|
+
let unlisten;
|
|
18880
|
+
const observer = {
|
|
18881
|
+
slug,
|
|
18882
|
+
id,
|
|
18883
|
+
settled: false,
|
|
18884
|
+
refresh: () => wrapped.findById(id).catch(() => void 0),
|
|
18885
|
+
emit: () => {
|
|
18886
|
+
if (closed || !this.collections.get(slug)?.ready) return;
|
|
18887
|
+
const row = this.localRow(slug, id);
|
|
18888
|
+
const entry = this.collections.get(slug)?.rows.get(String(id));
|
|
18889
|
+
const fromCache = !this.collections.get(slug)?.freshRows.has(String(id));
|
|
18890
|
+
const hasPendingWrites = this.hasPending(slug, id);
|
|
18891
|
+
const signature = `${fromCache ? "c" : "s"}${hasPendingWrites ? "p" : "-"}|` + (row === void 0 ? MISSING : `${String(id)}:${entry?.rev ?? 0}`);
|
|
18892
|
+
if (observer.settled && signature === observer.signature) return;
|
|
18893
|
+
observer.signature = signature;
|
|
18894
|
+
observer.settled = true;
|
|
18895
|
+
onResult(row, {
|
|
18896
|
+
fromCache,
|
|
18897
|
+
hasPendingWrites
|
|
18898
|
+
});
|
|
18899
|
+
}
|
|
18900
|
+
};
|
|
18901
|
+
this.observersFor(slug).add(observer);
|
|
18902
|
+
(async () => {
|
|
18903
|
+
await this.ensureCollection(slug);
|
|
18904
|
+
if (closed) return;
|
|
18905
|
+
if (this.localRow(slug, id) !== void 0) observer.emit();
|
|
18906
|
+
try {
|
|
18907
|
+
await wrapped.findById(id);
|
|
18908
|
+
} catch (error) {
|
|
18909
|
+
if (closed) return;
|
|
18910
|
+
if (!observer.settled) {
|
|
18911
|
+
onError?.(error);
|
|
18912
|
+
return;
|
|
18913
|
+
}
|
|
18914
|
+
}
|
|
18915
|
+
if (!closed) observer.emit();
|
|
18916
|
+
})();
|
|
18917
|
+
if (options?.realtime !== false && inner.listenById) unlisten = inner.listenById(id, (row) => {
|
|
18918
|
+
if (!row) {
|
|
18919
|
+
if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
|
|
18920
|
+
this.notifyCollection(slug, false);
|
|
18921
|
+
return;
|
|
18922
|
+
}
|
|
18923
|
+
this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
|
|
18924
|
+
}, onError);
|
|
18925
|
+
return () => {
|
|
18926
|
+
closed = true;
|
|
18927
|
+
this.observersFor(slug).delete(observer);
|
|
18928
|
+
unlisten?.();
|
|
18929
|
+
};
|
|
18930
|
+
}
|
|
18931
|
+
observersFor(slug) {
|
|
18932
|
+
let set = this.observers.get(slug);
|
|
18933
|
+
if (!set) {
|
|
18934
|
+
set = /* @__PURE__ */ new Set();
|
|
18935
|
+
this.observers.set(slug, set);
|
|
18936
|
+
}
|
|
18937
|
+
return set;
|
|
18938
|
+
}
|
|
18939
|
+
/** Cheap change detection: which rows, in what order, at which revision. */
|
|
18940
|
+
signature(slug, rows, total) {
|
|
18941
|
+
const state = this.collections.get(slug);
|
|
18942
|
+
return `${total}|${rows.map((row) => {
|
|
18943
|
+
const key = String(row.id);
|
|
18944
|
+
return `${key}:${state?.rows.get(key)?.rev ?? 0}`;
|
|
18945
|
+
}).join(",")}`;
|
|
18946
|
+
}
|
|
18947
|
+
notifyCollection(slug, broadcast = true) {
|
|
18948
|
+
const set = this.observers.get(slug);
|
|
18949
|
+
if (set) for (const observer of [...set]) observer.emit();
|
|
18950
|
+
if (broadcast) this.broadcast({
|
|
18951
|
+
type: "rows",
|
|
18952
|
+
slugs: [slug]
|
|
18953
|
+
});
|
|
18954
|
+
}
|
|
18955
|
+
/** Connectivity came back (or the user changed): re-read everything live. */
|
|
18956
|
+
revalidateAll() {
|
|
18957
|
+
for (const slug of this.observers.keys()) {
|
|
18958
|
+
this.notifyCollection(slug, false);
|
|
18959
|
+
this.scheduleRefresh(slug);
|
|
18960
|
+
}
|
|
18961
|
+
}
|
|
18962
|
+
collectionState(slug) {
|
|
18963
|
+
let state = this.collections.get(slug);
|
|
18964
|
+
if (!state) {
|
|
18965
|
+
state = {
|
|
18966
|
+
rows: /* @__PURE__ */ new Map(),
|
|
18967
|
+
snapshots: /* @__PURE__ */ new Map(),
|
|
18968
|
+
fresh: /* @__PURE__ */ new Set(),
|
|
18969
|
+
freshRows: /* @__PURE__ */ new Set(),
|
|
18970
|
+
absent: /* @__PURE__ */ new Set(),
|
|
18971
|
+
ready: false
|
|
18972
|
+
};
|
|
18973
|
+
this.collections.set(slug, state);
|
|
18974
|
+
}
|
|
18975
|
+
return state;
|
|
18976
|
+
}
|
|
18977
|
+
ensureCollection(slug) {
|
|
18978
|
+
const state = this.collectionState(slug);
|
|
18979
|
+
if (!state.loaded) {
|
|
18980
|
+
const scope = this.scope;
|
|
18981
|
+
state.loaded = (async () => {
|
|
18982
|
+
await this.ensureQueueLoaded();
|
|
18983
|
+
const [rows, snapshots, absent] = await Promise.all([
|
|
18984
|
+
this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
|
|
18985
|
+
this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
|
|
18986
|
+
this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
|
|
18987
|
+
]);
|
|
18988
|
+
if (this.scope !== scope || this.collections.get(slug) !== state) return;
|
|
18989
|
+
for (const entry of rows) {
|
|
18990
|
+
const row = entry.value;
|
|
18991
|
+
if (!row || row.id === void 0 || row.id === null) continue;
|
|
18992
|
+
state.rows.set(String(row.id), {
|
|
18993
|
+
row: hydrateRow(row),
|
|
18994
|
+
cachedAt: entry.cachedAt,
|
|
18995
|
+
rev: ++this.revCounter
|
|
18996
|
+
});
|
|
18997
|
+
}
|
|
18998
|
+
for (const entry of snapshots) {
|
|
18999
|
+
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
19000
|
+
if (entry.value) state.snapshots.set(key, entry.value);
|
|
19001
|
+
}
|
|
19002
|
+
for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
|
|
19003
|
+
})().catch(() => void 0).finally(() => {
|
|
19004
|
+
state.ready = true;
|
|
19005
|
+
});
|
|
19006
|
+
}
|
|
19007
|
+
return state.loaded.then(() => state);
|
|
19008
|
+
}
|
|
19009
|
+
snapshotFor(slug, params) {
|
|
19010
|
+
return this.collections.get(slug)?.snapshots.get(buildQueryString(params));
|
|
19011
|
+
}
|
|
19012
|
+
hasLocalAnswer(state, slug, params) {
|
|
19013
|
+
if (!state) return false;
|
|
19014
|
+
return state.snapshots.has(buildQueryString(params)) || state.rows.size > 0;
|
|
19015
|
+
}
|
|
19016
|
+
/**
|
|
19017
|
+
* Answer a query from the local database.
|
|
19018
|
+
*
|
|
19019
|
+
* With a snapshot, the server's own page — its ids, order and total — is
|
|
19020
|
+
* the skeleton, and the local rows fill it in: rows deleted locally drop
|
|
19021
|
+
* out, rows edited locally show the edit, and rows *created* locally join
|
|
19022
|
+
* the first page if they match. Without one, the query is evaluated
|
|
19023
|
+
* outright over every cached row, which is the best that can be done for a
|
|
19024
|
+
* query the server has never answered here.
|
|
19025
|
+
*/
|
|
19026
|
+
answer(slug, params, snapshot) {
|
|
19027
|
+
const state = this.collections.get(slug);
|
|
19028
|
+
const exact = isExactlyEvaluable(params);
|
|
19029
|
+
const fromCache = !state?.fresh.has(buildQueryString(params));
|
|
19030
|
+
if (!state) return {
|
|
19031
|
+
data: [],
|
|
19032
|
+
meta: {
|
|
19033
|
+
total: 0,
|
|
19034
|
+
limit: params?.limit ?? 20,
|
|
19035
|
+
offset: params?.offset ?? 0,
|
|
19036
|
+
hasMore: false
|
|
19037
|
+
},
|
|
19038
|
+
fromCache: true,
|
|
19039
|
+
hasPendingWrites: false,
|
|
19040
|
+
partial: true
|
|
19041
|
+
};
|
|
19042
|
+
if (!snapshot) {
|
|
19043
|
+
const local = runLocalQuery([...state.rows.values()].map((e) => e.row), params);
|
|
19044
|
+
return {
|
|
19045
|
+
...local,
|
|
19046
|
+
fromCache,
|
|
19047
|
+
hasPendingWrites: local.data.some((row) => this.hasPending(slug, row.id)),
|
|
19048
|
+
partial: true
|
|
19049
|
+
};
|
|
19050
|
+
}
|
|
19051
|
+
const rows = [];
|
|
19052
|
+
const seen = /* @__PURE__ */ new Set();
|
|
19053
|
+
/** Rows the server counted that we know are no longer in the result. */
|
|
19054
|
+
let removed = 0;
|
|
19055
|
+
for (const id of snapshot.ids) {
|
|
19056
|
+
const key = String(id);
|
|
19057
|
+
const entry = state.rows.get(key);
|
|
19058
|
+
if (!entry) {
|
|
19059
|
+
if (state.absent.has(key) || this.hasPending(slug, key)) removed++;
|
|
19060
|
+
continue;
|
|
19061
|
+
}
|
|
19062
|
+
if (exact && this.hasPending(slug, key) && !matchesParams(entry.row, params)) {
|
|
19063
|
+
removed++;
|
|
19064
|
+
continue;
|
|
19065
|
+
}
|
|
19066
|
+
rows.push(entry.row);
|
|
19067
|
+
seen.add(key);
|
|
19068
|
+
}
|
|
19069
|
+
let added = 0;
|
|
19070
|
+
const offset = snapshot.offset ?? 0;
|
|
19071
|
+
if (exact && offset === 0) {
|
|
19072
|
+
for (const [key, entry] of state.rows) {
|
|
19073
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
19074
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
19075
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
19076
|
+
rows.push(entry.row);
|
|
19077
|
+
added++;
|
|
19078
|
+
}
|
|
19079
|
+
if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
|
|
19080
|
+
}
|
|
19081
|
+
return {
|
|
19082
|
+
data: rows,
|
|
19083
|
+
meta: {
|
|
19084
|
+
total: Math.max(rows.length, snapshot.total - removed + added),
|
|
19085
|
+
limit: snapshot.limit,
|
|
19086
|
+
offset,
|
|
19087
|
+
hasMore: snapshot.hasMore
|
|
19088
|
+
},
|
|
19089
|
+
fromCache,
|
|
19090
|
+
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
19091
|
+
partial: !exact
|
|
19092
|
+
};
|
|
19093
|
+
}
|
|
19094
|
+
localFind(slug, params) {
|
|
19095
|
+
const state = this.collections.get(slug);
|
|
19096
|
+
const snapshot = this.snapshotFor(slug, params);
|
|
19097
|
+
state?.fresh.delete(buildQueryString(params));
|
|
19098
|
+
if (!snapshot && (!state || state.rows.size === 0)) throw offlineError(`Offline: no cached data for "${slug}".`);
|
|
19099
|
+
const answer = this.answer(slug, params, snapshot);
|
|
19100
|
+
return snapshot ? answer : {
|
|
19101
|
+
...answer,
|
|
19102
|
+
partial: true
|
|
19103
|
+
};
|
|
19104
|
+
}
|
|
19105
|
+
rawLocalRow(slug, id) {
|
|
19106
|
+
const entry = this.collections.get(slug)?.rows.get(String(id));
|
|
19107
|
+
return entry ? { ...entry.row } : void 0;
|
|
19108
|
+
}
|
|
19109
|
+
localRow(slug, id) {
|
|
19110
|
+
return this.collections.get(slug)?.rows.get(String(id))?.row;
|
|
19111
|
+
}
|
|
19112
|
+
setLocalRow(slug, id, row) {
|
|
19113
|
+
const state = this.collectionState(slug);
|
|
19114
|
+
const key = String(id);
|
|
19115
|
+
const cachedAt = Date.now();
|
|
19116
|
+
state.rows.set(key, {
|
|
19117
|
+
row: { ...row },
|
|
19118
|
+
cachedAt,
|
|
19119
|
+
rev: ++this.revCounter
|
|
19120
|
+
});
|
|
19121
|
+
state.freshRows.delete(key);
|
|
19122
|
+
this.forgetTombstone(slug, key);
|
|
19123
|
+
this.writeCache(this.rowKey(slug, key), dehydrateRow(row), cachedAt);
|
|
19124
|
+
this.evictRows(slug);
|
|
19125
|
+
}
|
|
19126
|
+
/**
|
|
19127
|
+
* Drop a row and, when the server is the one saying it is gone, remember
|
|
19128
|
+
* that. "I looked it up and it does not exist" is real knowledge: without
|
|
19129
|
+
* it, opening a deleted row while offline would report a missing local
|
|
19130
|
+
* database instead of a missing row.
|
|
19131
|
+
*/
|
|
19132
|
+
removeLocalRow(slug, id, known = false) {
|
|
19133
|
+
const state = this.collectionState(slug);
|
|
19134
|
+
const key = String(id);
|
|
19135
|
+
const existed = state.rows.delete(key);
|
|
19136
|
+
if (known) {
|
|
19137
|
+
state.absent.add(key);
|
|
19138
|
+
state.freshRows.add(key);
|
|
19139
|
+
this.writeCache(this.absentKey(slug, key), true);
|
|
19140
|
+
} else state.freshRows.delete(key);
|
|
19141
|
+
if (existed) this.deleteCache([this.rowKey(slug, key)]);
|
|
19142
|
+
}
|
|
19143
|
+
forgetTombstone(slug, key) {
|
|
19144
|
+
if (!this.collectionState(slug).absent.delete(key)) return;
|
|
19145
|
+
this.deleteCache([this.absentKey(slug, key)]);
|
|
19146
|
+
}
|
|
19147
|
+
/**
|
|
19148
|
+
* Merge server rows into the local database. A row with unsynced local
|
|
19149
|
+
* writes keeps them: the server's copy is the base the queued mutations
|
|
19150
|
+
* are re-applied to, not a replacement for what the user did.
|
|
19151
|
+
*
|
|
19152
|
+
* Rows that came back unchanged keep their identity and revision, so a
|
|
19153
|
+
* refetch that changed nothing does not re-render every live query that
|
|
19154
|
+
* touches them — or rewrite them all to disk.
|
|
19155
|
+
*/
|
|
19156
|
+
async ingest(slug, rows) {
|
|
19157
|
+
if (rows.length === 0) return;
|
|
19158
|
+
const state = await this.ensureCollection(slug);
|
|
19159
|
+
const cachedAt = Date.now();
|
|
19160
|
+
const writes = [];
|
|
19161
|
+
const deletes = [];
|
|
19162
|
+
for (const raw of rows) {
|
|
19163
|
+
if (!raw || raw.id === void 0 || raw.id === null) continue;
|
|
19164
|
+
const key = String(raw.id);
|
|
19165
|
+
const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, { ...raw }) : { ...raw };
|
|
19166
|
+
if (merged === void 0) {
|
|
19167
|
+
state.rows.delete(key);
|
|
19168
|
+
deletes.push(this.rowKey(slug, key));
|
|
19169
|
+
continue;
|
|
19170
|
+
}
|
|
19171
|
+
this.forgetTombstone(slug, key);
|
|
19172
|
+
state.freshRows.add(key);
|
|
19173
|
+
const existing = state.rows.get(key);
|
|
19174
|
+
if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
|
|
19175
|
+
existing.cachedAt = cachedAt;
|
|
19176
|
+
continue;
|
|
19177
|
+
}
|
|
19178
|
+
state.rows.set(key, {
|
|
19179
|
+
row: merged,
|
|
19180
|
+
cachedAt,
|
|
19181
|
+
rev: ++this.revCounter
|
|
19182
|
+
});
|
|
19183
|
+
writes.push({
|
|
19184
|
+
key: this.rowKey(slug, key),
|
|
19185
|
+
entry: {
|
|
19186
|
+
value: dehydrateRow(merged),
|
|
19187
|
+
cachedAt
|
|
19188
|
+
}
|
|
19189
|
+
});
|
|
19190
|
+
}
|
|
19191
|
+
if (writes.length > 0) this.store.setCacheMany(writes).catch(() => void 0);
|
|
19192
|
+
if (deletes.length > 0) this.deleteCache(deletes);
|
|
19193
|
+
this.evictRows(slug);
|
|
19194
|
+
}
|
|
19195
|
+
/**
|
|
19196
|
+
* Fold the queued mutations for one row over a base, newest last.
|
|
19197
|
+
* `afterMutationId` skips everything up to and including that mutation,
|
|
19198
|
+
* which is how a just-replayed write avoids being applied on top of the
|
|
19199
|
+
* server's response to it.
|
|
19200
|
+
*/
|
|
19201
|
+
applyPendingToRow(slug, idKey, base, afterMutationId) {
|
|
19202
|
+
let row = base;
|
|
19203
|
+
let skipping = afterMutationId !== void 0;
|
|
19204
|
+
for (const op of this.queue) {
|
|
19205
|
+
if (skipping) {
|
|
19206
|
+
if (op.mutationId === afterMutationId) skipping = false;
|
|
19207
|
+
continue;
|
|
19208
|
+
}
|
|
19209
|
+
if (op.collection !== slug) continue;
|
|
19210
|
+
if (op.type === "createMany") {
|
|
19211
|
+
const match = op.data?.find((r) => String(r.id) === idKey);
|
|
19212
|
+
if (match) row = { ...match };
|
|
19213
|
+
continue;
|
|
19214
|
+
}
|
|
19215
|
+
if (op.id === void 0 || String(op.id) !== idKey) continue;
|
|
19216
|
+
if (op.type === "create") row = { ...op.data };
|
|
19217
|
+
else if (op.type === "update") row = {
|
|
19218
|
+
...row ?? {},
|
|
19219
|
+
...op.data,
|
|
19220
|
+
id: op.id
|
|
19221
|
+
};
|
|
19222
|
+
else if (op.type === "delete") row = void 0;
|
|
19223
|
+
}
|
|
19224
|
+
return row;
|
|
19225
|
+
}
|
|
19226
|
+
recordSnapshot(slug, params, result) {
|
|
19227
|
+
const meta = result.meta ?? {
|
|
19228
|
+
total: result.data?.length ?? 0,
|
|
19229
|
+
limit: 20,
|
|
19230
|
+
offset: 0,
|
|
19231
|
+
hasMore: false
|
|
19232
|
+
};
|
|
19233
|
+
const snapshot = {
|
|
19234
|
+
ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
|
|
19235
|
+
total: meta.total ?? result.data?.length ?? 0,
|
|
19236
|
+
limit: meta.limit ?? params?.limit ?? 20,
|
|
19237
|
+
offset: meta.offset ?? params?.offset ?? 0,
|
|
19238
|
+
hasMore: meta.hasMore ?? false
|
|
19239
|
+
};
|
|
19240
|
+
const state = this.collectionState(slug);
|
|
19241
|
+
const key = buildQueryString(params);
|
|
19242
|
+
state.snapshots.set(key, snapshot);
|
|
19243
|
+
state.fresh.add(key);
|
|
19244
|
+
this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot);
|
|
19245
|
+
this.evictSnapshots(slug);
|
|
19246
|
+
return snapshot;
|
|
19247
|
+
}
|
|
19248
|
+
/**
|
|
19249
|
+
* A write changed which rows belong in a list, and only the server can say
|
|
19250
|
+
* how — a row it generated is in no cached page, and the totals moved.
|
|
19251
|
+
* Re-run every live query on the collection; queries nobody is watching
|
|
19252
|
+
* are corrected by their next `find`.
|
|
19253
|
+
*
|
|
19254
|
+
* Coalesced per microtask so a burst of writes costs one round trip, and
|
|
19255
|
+
* skipped entirely while offline, where the local database is already the
|
|
19256
|
+
* best answer available.
|
|
19257
|
+
*/
|
|
19258
|
+
scheduleRefresh(slug) {
|
|
19259
|
+
if (this.refreshPending.has(slug)) return;
|
|
19260
|
+
const observers = this.observers.get(slug);
|
|
19261
|
+
if (!observers || observers.size === 0) return;
|
|
19262
|
+
this.refreshPending.add(slug);
|
|
19263
|
+
Promise.resolve().then(() => {
|
|
19264
|
+
this.refreshPending.delete(slug);
|
|
19265
|
+
if (this.disposed || !this.connectivity.shouldAttempt()) return;
|
|
19266
|
+
for (const observer of [...this.observers.get(slug) ?? []]) observer.refresh();
|
|
19267
|
+
});
|
|
19268
|
+
}
|
|
19269
|
+
evictRows(slug) {
|
|
19270
|
+
const state = this.collections.get(slug);
|
|
19271
|
+
if (!state || state.rows.size <= this.maxCachedRows) return;
|
|
19272
|
+
const evictable = [...state.rows.entries()].filter(([key]) => !this.hasPending(slug, key)).sort((a, b) => a[1].cachedAt - b[1].cachedAt);
|
|
19273
|
+
const excess = state.rows.size - this.maxCachedRows;
|
|
19274
|
+
const doomed = evictable.slice(0, excess);
|
|
19275
|
+
for (const [key] of doomed) state.rows.delete(key);
|
|
19276
|
+
if (doomed.length > 0) this.deleteCache(doomed.map(([key]) => this.rowKey(slug, key)));
|
|
19277
|
+
if (state.absent.size > this.maxCachedRows) {
|
|
19278
|
+
const stale = [...state.absent].slice(0, state.absent.size - this.maxCachedRows);
|
|
19279
|
+
for (const key of stale) state.absent.delete(key);
|
|
19280
|
+
this.deleteCache(stale.map((key) => this.absentKey(slug, key)));
|
|
19281
|
+
}
|
|
19282
|
+
}
|
|
19283
|
+
evictSnapshots(slug) {
|
|
19284
|
+
const state = this.collections.get(slug);
|
|
19285
|
+
if (!state || state.snapshots.size <= this.maxCachedQueries) return;
|
|
19286
|
+
const excess = state.snapshots.size - this.maxCachedQueries;
|
|
19287
|
+
const doomed = [...state.snapshots.keys()].slice(0, excess);
|
|
19288
|
+
for (const key of doomed) state.snapshots.delete(key);
|
|
19289
|
+
this.deleteCache(doomed.map((key) => `${this.scope}|q|${slug}|${key}`));
|
|
19290
|
+
}
|
|
19291
|
+
ensureQueueLoaded() {
|
|
19292
|
+
if (!this.queueLoad) {
|
|
19293
|
+
const scope = this.scope;
|
|
19294
|
+
this.queueLoad = this.store.listQueue(`${scope}|`).then((queue) => {
|
|
19295
|
+
if (this.scope !== scope) return;
|
|
19296
|
+
this.queue = queue;
|
|
19297
|
+
this.patchStatus({ pending: queue.length });
|
|
19298
|
+
this.notifyQueue();
|
|
19299
|
+
}).catch(() => void 0);
|
|
19300
|
+
}
|
|
19301
|
+
return this.queueLoad;
|
|
19302
|
+
}
|
|
19303
|
+
enqueue(mutation) {
|
|
19304
|
+
const result = this.enqueueChain.then(async () => {
|
|
19305
|
+
await this.ensureQueueLoaded();
|
|
19306
|
+
if (mutation.type === "update") {
|
|
19307
|
+
const tail = this.queue[this.queue.length - 1];
|
|
19308
|
+
if (tail && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
|
|
19309
|
+
tail.data = {
|
|
19310
|
+
...tail.data,
|
|
19311
|
+
...mutation.data,
|
|
19312
|
+
id: tail.id
|
|
19313
|
+
};
|
|
19314
|
+
await this.store.enqueue(this.queueKey(tail), tail);
|
|
19315
|
+
return;
|
|
19316
|
+
}
|
|
19317
|
+
}
|
|
19318
|
+
if (mutation.type === "delete") {
|
|
19319
|
+
if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true)) {
|
|
19320
|
+
const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update"));
|
|
19321
|
+
for (const op of doomed) await this.store.dequeue(this.queueKey(op));
|
|
19322
|
+
this.queue = this.queue.filter((m) => !doomed.includes(m));
|
|
19323
|
+
this.afterQueueChange();
|
|
19324
|
+
return;
|
|
19325
|
+
}
|
|
19326
|
+
}
|
|
19327
|
+
const full = {
|
|
19328
|
+
...mutation,
|
|
19329
|
+
mutationId: createMutationId(),
|
|
19330
|
+
queuedAt: Date.now()
|
|
19331
|
+
};
|
|
19332
|
+
await this.store.enqueue(this.queueKey(full), full);
|
|
19333
|
+
this.queue.push(full);
|
|
19334
|
+
this.afterQueueChange();
|
|
19335
|
+
});
|
|
19336
|
+
this.enqueueChain = result.catch(() => void 0);
|
|
19337
|
+
return result;
|
|
19338
|
+
}
|
|
19339
|
+
hasPending(slug, id) {
|
|
19340
|
+
const key = String(id);
|
|
19341
|
+
return this.queue.some((op) => {
|
|
19342
|
+
if (op.collection !== slug) return false;
|
|
19343
|
+
if (op.type === "createMany") return op.data?.some((r) => String(r.id) === key) ?? false;
|
|
19344
|
+
return op.id !== void 0 && String(op.id) === key;
|
|
19345
|
+
});
|
|
19346
|
+
}
|
|
19347
|
+
/** Is this row one the server has never been told about? */
|
|
19348
|
+
isLocallyCreated(slug, idKey) {
|
|
19349
|
+
return this.queue.some((op) => {
|
|
19350
|
+
if (op.collection !== slug) return false;
|
|
19351
|
+
if (op.type === "create") return op.id !== void 0 && String(op.id) === idKey;
|
|
19352
|
+
if (op.type === "createMany") return op.data?.some((r) => String(r.id) === idKey) ?? false;
|
|
19353
|
+
return false;
|
|
19354
|
+
});
|
|
19355
|
+
}
|
|
19356
|
+
/** How many rows the queue adds to (or removes from) a server-side count. */
|
|
19357
|
+
pendingDelta(slug, params) {
|
|
19358
|
+
if (!isExactlyEvaluable(params)) return 0;
|
|
19359
|
+
let delta = 0;
|
|
19360
|
+
for (const op of this.queue) {
|
|
19361
|
+
if (op.collection !== slug) continue;
|
|
19362
|
+
if (op.type === "create") {
|
|
19363
|
+
if (matchesParams(op.data, params)) delta++;
|
|
19364
|
+
} else if (op.type === "createMany") {
|
|
19365
|
+
for (const row of op.data ?? []) if (matchesParams(row, params)) delta++;
|
|
19366
|
+
} else if (op.type === "delete") {
|
|
19367
|
+
const before = op.rollback?.rows?.[String(op.id)];
|
|
19368
|
+
if (before && matchesParams(before, params)) delta--;
|
|
19369
|
+
}
|
|
19370
|
+
}
|
|
19371
|
+
return delta;
|
|
19372
|
+
}
|
|
19373
|
+
sync() {
|
|
19374
|
+
if (this.flushPromise) return this.flushPromise;
|
|
19375
|
+
this.flushPromise = this.withLock(() => this.flush()).finally(() => {
|
|
19376
|
+
this.flushPromise = void 0;
|
|
19377
|
+
});
|
|
19378
|
+
return this.flushPromise;
|
|
19379
|
+
}
|
|
19380
|
+
async flush() {
|
|
19381
|
+
await this.ensureQueueLoaded();
|
|
19382
|
+
await this.reloadQueue();
|
|
19383
|
+
if (this.queue.length === 0) return {
|
|
19384
|
+
flushed: 0,
|
|
19385
|
+
remaining: 0
|
|
19386
|
+
};
|
|
19387
|
+
this.patchStatus({ syncing: true });
|
|
19388
|
+
const touched = /* @__PURE__ */ new Set();
|
|
19389
|
+
const queuedAtStart = this.queue.length;
|
|
19390
|
+
let flushed = 0;
|
|
19391
|
+
try {
|
|
19392
|
+
while (this.queue.length > 0 && !this.disposed) {
|
|
19393
|
+
const op = this.queue[0];
|
|
19394
|
+
touched.add(op.collection);
|
|
19395
|
+
try {
|
|
19396
|
+
await this.replay(op);
|
|
19397
|
+
} catch (error) {
|
|
19398
|
+
if (isNetworkError(error)) {
|
|
19399
|
+
this.connectivity.markFailure();
|
|
19400
|
+
break;
|
|
19401
|
+
}
|
|
19402
|
+
op.attempts = (op.attempts ?? 0) + 1;
|
|
19403
|
+
op.lastError = error?.message ?? String(error);
|
|
19404
|
+
if (isRetryableError(error) && op.attempts < this.maxRetries) {
|
|
19405
|
+
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
19406
|
+
this.connectivity.deferRetry();
|
|
19407
|
+
this.patchStatus({ lastError: op.lastError });
|
|
19408
|
+
break;
|
|
19409
|
+
}
|
|
19410
|
+
await this.rejectMutation(op, error);
|
|
19411
|
+
continue;
|
|
19412
|
+
}
|
|
19413
|
+
this.connectivity.markSuccess();
|
|
19414
|
+
await this.drop(op);
|
|
19415
|
+
flushed++;
|
|
19416
|
+
}
|
|
19417
|
+
} finally {
|
|
19418
|
+
this.patchStatus({ syncing: false });
|
|
19419
|
+
}
|
|
19420
|
+
if (this.queue.length !== queuedAtStart) {
|
|
19421
|
+
for (const slug of touched) {
|
|
19422
|
+
this.notifyCollection(slug);
|
|
19423
|
+
this.scheduleRefresh(slug);
|
|
19424
|
+
}
|
|
19425
|
+
this.broadcast({ type: "queue" });
|
|
19426
|
+
}
|
|
19427
|
+
if (this.queue.length === 0) this.patchStatus({ lastSyncedAt: Date.now() });
|
|
19428
|
+
return {
|
|
19429
|
+
flushed,
|
|
19430
|
+
remaining: this.queue.length
|
|
19431
|
+
};
|
|
19432
|
+
}
|
|
19433
|
+
async replay(op) {
|
|
19434
|
+
const inner = this.innerFor(op.collection);
|
|
19435
|
+
if (op.type === "create") {
|
|
19436
|
+
const row = await inner.create(op.data);
|
|
19437
|
+
await this.adoptServerRow(op, op.id, row);
|
|
19438
|
+
} else if (op.type === "createMany") {
|
|
19439
|
+
const queued = op.data ?? [];
|
|
19440
|
+
const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
|
|
19441
|
+
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
19442
|
+
} else if (op.type === "update") {
|
|
19443
|
+
const row = await inner.update(op.id, op.data);
|
|
19444
|
+
await this.ingestReplaced(op, op.id, row);
|
|
19445
|
+
} else if (op.type === "delete") {
|
|
19446
|
+
await inner.delete(op.id);
|
|
19447
|
+
this.removeLocalRow(op.collection, op.id, true);
|
|
19448
|
+
}
|
|
19449
|
+
}
|
|
19450
|
+
/**
|
|
19451
|
+
* Take the server's version of a row the client created offline.
|
|
19452
|
+
*
|
|
19453
|
+
* The server may have assigned a different id — a serial column ignores
|
|
19454
|
+
* the id we invented — in which case every local trace of the temporary id
|
|
19455
|
+
* has to move with it, including queued writes that were made against it
|
|
19456
|
+
* before it was ever sent.
|
|
19457
|
+
*/
|
|
19458
|
+
async adoptServerRow(op, localId, row) {
|
|
19459
|
+
if (!row) return;
|
|
19460
|
+
const slug = op.collection;
|
|
19461
|
+
const serverId = row.id;
|
|
19462
|
+
if (localId !== void 0 && serverId !== void 0 && String(serverId) !== String(localId)) {
|
|
19463
|
+
const oldKey = String(localId);
|
|
19464
|
+
this.removeLocalRow(slug, localId);
|
|
19465
|
+
for (const queued of this.queue) {
|
|
19466
|
+
if (queued.collection !== slug) continue;
|
|
19467
|
+
let dirty = false;
|
|
19468
|
+
if (queued.id !== void 0 && String(queued.id) === oldKey) {
|
|
19469
|
+
queued.id = serverId;
|
|
19470
|
+
if (queued.data && !Array.isArray(queued.data)) queued.data.id = serverId;
|
|
19471
|
+
dirty = true;
|
|
19472
|
+
}
|
|
19473
|
+
const rollbackRows = queued.rollback?.rows;
|
|
19474
|
+
if (rollbackRows && oldKey in rollbackRows) {
|
|
19475
|
+
rollbackRows[String(serverId)] = rollbackRows[oldKey];
|
|
19476
|
+
delete rollbackRows[oldKey];
|
|
19477
|
+
dirty = true;
|
|
19478
|
+
}
|
|
19479
|
+
if (dirty) await this.store.enqueue(this.queueKey(queued), queued).catch(() => void 0);
|
|
19480
|
+
}
|
|
19481
|
+
}
|
|
19482
|
+
await this.ingestReplaced(op, serverId ?? localId, row);
|
|
19483
|
+
}
|
|
19484
|
+
/**
|
|
19485
|
+
* Write a server row over the local one, ignoring the mutation that just
|
|
19486
|
+
* produced it — re-applying that would put the pre-server values back on
|
|
19487
|
+
* top of the server's answer — but keeping every write queued *after* it.
|
|
19488
|
+
* Those are still unsent, and dropping them here would make the row snap
|
|
19489
|
+
* back to the server's version in front of the user, only to change again
|
|
19490
|
+
* when they replay a moment later.
|
|
19491
|
+
*/
|
|
19492
|
+
async ingestReplaced(op, id, row) {
|
|
19493
|
+
const slug = op.collection;
|
|
19494
|
+
const state = await this.ensureCollection(slug);
|
|
19495
|
+
const key = String(id);
|
|
19496
|
+
const merged = this.applyPendingToRow(slug, key, { ...row }, op.mutationId);
|
|
19497
|
+
if (merged === void 0) {
|
|
19498
|
+
this.removeLocalRow(slug, key);
|
|
19499
|
+
return;
|
|
19500
|
+
}
|
|
19501
|
+
const cachedAt = Date.now();
|
|
19502
|
+
state.rows.set(key, {
|
|
19503
|
+
row: merged,
|
|
19504
|
+
cachedAt,
|
|
19505
|
+
rev: ++this.revCounter
|
|
19506
|
+
});
|
|
19507
|
+
if (this.applyPendingToRow(slug, key, void 0, op.mutationId) === void 0) state.freshRows.add(key);
|
|
19508
|
+
this.writeCache(this.rowKey(slug, key), dehydrateRow(merged), cachedAt);
|
|
19509
|
+
}
|
|
19510
|
+
/**
|
|
19511
|
+
* The server refused a mutation. Put back what it changed, and discard the
|
|
19512
|
+
* queued writes that were built on top of it: an edit to a row whose
|
|
19513
|
+
* creation was rejected can only fail the same way, and applying it would
|
|
19514
|
+
* leave the local database claiming a row the server does not have.
|
|
19515
|
+
*
|
|
19516
|
+
* The cascade stops the moment a later write stops *depending* on the
|
|
19517
|
+
* rejected one. An `update` reads the row it edits, so it is doomed with
|
|
19518
|
+
* it; a `create` overwrites the row outright and a `delete` needs nothing
|
|
19519
|
+
* of it, so both stand on their own and are kept — dropping them would
|
|
19520
|
+
* silently lose writes the server would have accepted.
|
|
19521
|
+
*/
|
|
19522
|
+
async rejectMutation(op, error) {
|
|
19523
|
+
const ids = new Set(Object.keys(op.rollback?.rows ?? {}));
|
|
19524
|
+
if (op.id !== void 0) ids.add(String(op.id));
|
|
19525
|
+
const doomed = [op];
|
|
19526
|
+
const orphaned = new Set(ids);
|
|
19527
|
+
const position = this.queue.indexOf(op);
|
|
19528
|
+
for (const later of this.queue.slice(position + 1)) {
|
|
19529
|
+
if (later.collection !== op.collection) continue;
|
|
19530
|
+
const hit = this.idsOf(later).filter((id) => orphaned.has(id));
|
|
19531
|
+
if (hit.length === 0) continue;
|
|
19532
|
+
if (later.type === "update") doomed.push(later);
|
|
19533
|
+
else for (const id of hit) orphaned.delete(id);
|
|
19534
|
+
}
|
|
19535
|
+
for (const dropped of doomed) await this.drop(dropped);
|
|
19536
|
+
for (const [idKey, previous] of Object.entries(op.rollback?.rows ?? {})) {
|
|
19537
|
+
const restored = this.applyPendingToRow(op.collection, idKey, previous ?? void 0);
|
|
19538
|
+
if (restored === void 0) this.removeLocalRow(op.collection, idKey);
|
|
19539
|
+
else this.setLocalRow(op.collection, idKey, restored);
|
|
19540
|
+
}
|
|
19541
|
+
this.patchStatus({ lastError: error.message });
|
|
19542
|
+
this.notifyCollection(op.collection);
|
|
19543
|
+
this.scheduleRefresh(op.collection);
|
|
19544
|
+
for (const dropped of doomed) this.onSyncError?.(error, dropped);
|
|
19545
|
+
}
|
|
19546
|
+
/** Every row id a mutation writes to. */
|
|
19547
|
+
idsOf(op) {
|
|
19548
|
+
if (op.type === "createMany") return (op.data ?? []).map((r) => String(r.id));
|
|
19549
|
+
return op.id === void 0 ? [] : [String(op.id)];
|
|
19550
|
+
}
|
|
19551
|
+
async drop(op) {
|
|
19552
|
+
await this.store.dequeue(this.queueKey(op)).catch(() => void 0);
|
|
19553
|
+
this.queue = this.queue.filter((m) => m.mutationId !== op.mutationId);
|
|
19554
|
+
this.afterQueueChange(false);
|
|
19555
|
+
}
|
|
19556
|
+
/** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
|
|
19557
|
+
innerFor(slug) {
|
|
19558
|
+
let inner = this.inners.get(slug);
|
|
19559
|
+
if (!inner) {
|
|
19560
|
+
inner = this.createInner(slug);
|
|
19561
|
+
this.inners.set(slug, inner);
|
|
19562
|
+
}
|
|
19563
|
+
return inner;
|
|
19564
|
+
}
|
|
19565
|
+
async withLock(fn) {
|
|
19566
|
+
const locks = globalThis.navigator?.locks;
|
|
19567
|
+
if (!locks?.request) return fn();
|
|
19568
|
+
try {
|
|
19569
|
+
return await locks.request(`rebase-offline-sync:${this.scope}`, fn);
|
|
19570
|
+
} catch {
|
|
19571
|
+
return fn();
|
|
19572
|
+
}
|
|
19573
|
+
}
|
|
19574
|
+
broadcast(message) {
|
|
19575
|
+
if (!this.channel) return;
|
|
19576
|
+
try {
|
|
19577
|
+
this.channel.postMessage({
|
|
19578
|
+
...message,
|
|
19579
|
+
scope: this.scope,
|
|
19580
|
+
sender: this.tabId
|
|
19581
|
+
});
|
|
19582
|
+
} catch {}
|
|
19583
|
+
}
|
|
19584
|
+
onBroadcast(message) {
|
|
19585
|
+
if (this.disposed || !message || typeof message !== "object") return;
|
|
19586
|
+
const msg = message;
|
|
19587
|
+
if (msg.sender === this.tabId || msg.scope !== this.scope) return;
|
|
19588
|
+
if (msg.type === "rows") for (const slug of msg.slugs ?? []) this.reloadCollection(slug);
|
|
19589
|
+
else if (msg.type === "queue") this.reloadQueue();
|
|
19590
|
+
}
|
|
19591
|
+
/** Re-read one collection from the store, replacing what is in memory. */
|
|
19592
|
+
async reloadCollection(slug) {
|
|
19593
|
+
const state = this.collections.get(slug);
|
|
19594
|
+
if (!state?.loaded) return;
|
|
19595
|
+
await this.reloadQueue();
|
|
19596
|
+
const scope = this.scope;
|
|
19597
|
+
const [rows, snapshots, absent] = await Promise.all([
|
|
19598
|
+
this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
|
|
19599
|
+
this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
|
|
19600
|
+
this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
|
|
19601
|
+
]);
|
|
19602
|
+
if (this.scope !== scope || this.collections.get(slug) !== state) return;
|
|
19603
|
+
const next = /* @__PURE__ */ new Map();
|
|
19604
|
+
for (const entry of rows) {
|
|
19605
|
+
const row = entry.value;
|
|
19606
|
+
if (!row || row.id === void 0 || row.id === null) continue;
|
|
19607
|
+
const key = String(row.id);
|
|
19608
|
+
const existing = state.rows.get(key);
|
|
19609
|
+
const hydrated = hydrateRow(row);
|
|
19610
|
+
const unchanged = existing && JSON.stringify(existing.row) === JSON.stringify(hydrated);
|
|
19611
|
+
next.set(key, {
|
|
19612
|
+
row: hydrated,
|
|
19613
|
+
cachedAt: entry.cachedAt,
|
|
19614
|
+
rev: unchanged ? existing.rev : ++this.revCounter
|
|
19615
|
+
});
|
|
19616
|
+
}
|
|
19617
|
+
state.rows = next;
|
|
19618
|
+
state.snapshots = /* @__PURE__ */ new Map();
|
|
19619
|
+
for (const entry of snapshots) {
|
|
19620
|
+
const key = entry.key.slice(`${scope}|q|${slug}|`.length);
|
|
19621
|
+
if (entry.value) state.snapshots.set(key, entry.value);
|
|
19622
|
+
}
|
|
19623
|
+
state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
|
|
19624
|
+
this.notifyCollection(slug, false);
|
|
19625
|
+
}
|
|
19626
|
+
async reloadQueue() {
|
|
19627
|
+
const scope = this.scope;
|
|
19628
|
+
const queue = await this.store.listQueue(`${scope}|`).catch(() => void 0);
|
|
19629
|
+
if (!queue || this.scope !== scope) return;
|
|
19630
|
+
this.queue = queue;
|
|
19631
|
+
this.afterQueueChange(false);
|
|
19632
|
+
}
|
|
19633
|
+
afterQueueChange(broadcast = true) {
|
|
19634
|
+
this.patchStatus({ pending: this.queue.length });
|
|
19635
|
+
this.notifyQueue();
|
|
19636
|
+
if (broadcast) this.broadcast({ type: "queue" });
|
|
19637
|
+
}
|
|
19638
|
+
notifyQueue() {
|
|
19639
|
+
for (const listener of this.queueListeners) listener(this.queue.length);
|
|
19640
|
+
}
|
|
19641
|
+
patchStatus(patch) {
|
|
19642
|
+
let changed = false;
|
|
19643
|
+
for (const [key, value] of Object.entries(patch)) if (this.currentStatus[key] !== value) {
|
|
19644
|
+
this.currentStatus[key] = value;
|
|
19645
|
+
changed = true;
|
|
19646
|
+
}
|
|
19647
|
+
if (!changed) return;
|
|
19648
|
+
const snapshot = { ...this.currentStatus };
|
|
19649
|
+
for (const listener of this.statusListeners) listener(snapshot);
|
|
19650
|
+
}
|
|
19651
|
+
countKey(slug, params) {
|
|
19652
|
+
return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
|
|
19653
|
+
}
|
|
19654
|
+
rowKey(slug, id) {
|
|
19655
|
+
return `${this.scope}|row|${slug}|${String(id)}`;
|
|
19656
|
+
}
|
|
19657
|
+
absentKey(slug, id) {
|
|
19658
|
+
return `${this.scope}|abs|${slug}|${String(id)}`;
|
|
19659
|
+
}
|
|
19660
|
+
queueKey(mutation) {
|
|
19661
|
+
return `${this.scope}|${mutation.mutationId}`;
|
|
19662
|
+
}
|
|
19663
|
+
async readCache(key) {
|
|
19664
|
+
try {
|
|
19665
|
+
return (await this.store.getCache(key))?.value;
|
|
19666
|
+
} catch {
|
|
19667
|
+
return;
|
|
19668
|
+
}
|
|
19669
|
+
}
|
|
19670
|
+
async writeCache(key, value, cachedAt = Date.now()) {
|
|
19671
|
+
try {
|
|
19672
|
+
await this.store.setCache(key, {
|
|
19673
|
+
value,
|
|
19674
|
+
cachedAt
|
|
19675
|
+
});
|
|
19676
|
+
} catch {}
|
|
19677
|
+
}
|
|
19678
|
+
async deleteCache(keys) {
|
|
19679
|
+
try {
|
|
19680
|
+
await this.store.deleteCache(keys);
|
|
19681
|
+
} catch {}
|
|
19682
|
+
}
|
|
19683
|
+
};
|
|
19684
|
+
/**
|
|
17636
19685
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
17637
19686
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
17638
19687
|
*/
|
|
@@ -17755,10 +19804,20 @@ function createRebaseClient(options) {
|
|
|
17755
19804
|
if (diffs <= 1) return key;
|
|
17756
19805
|
}
|
|
17757
19806
|
}
|
|
19807
|
+
const offlineManager = options.offline ? new OfflineManager(typeof options.offline === "object" ? options.offline : {}, (slug) => createCollectionClient(transport, slug)) : void 0;
|
|
19808
|
+
if (offlineManager) {
|
|
19809
|
+
offlineManager.setScope(auth.getSession()?.user?.uid);
|
|
19810
|
+
auth.onAuthStateChange((event, session) => {
|
|
19811
|
+
offlineManager.setScope(event === "SIGNED_OUT" ? void 0 : session?.user?.uid);
|
|
19812
|
+
});
|
|
19813
|
+
}
|
|
17758
19814
|
const collectionClients = /* @__PURE__ */ new Map();
|
|
17759
19815
|
let untypedWarned = false;
|
|
17760
19816
|
function collection(slug) {
|
|
17761
|
-
if (!collectionClients.has(slug))
|
|
19817
|
+
if (!collectionClients.has(slug)) {
|
|
19818
|
+
const inner = createCollectionClient(transport, slug, ws);
|
|
19819
|
+
collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
|
|
19820
|
+
}
|
|
17762
19821
|
return collectionClients.get(slug);
|
|
17763
19822
|
}
|
|
17764
19823
|
const dataProxy = new Proxy({ collection }, { get(_target, prop) {
|
|
@@ -17822,6 +19881,7 @@ channel: (name, options) => {
|
|
|
17822
19881
|
for (const channel of realtimeChannels.values()) channel.leave();
|
|
17823
19882
|
realtimeChannels.clear();
|
|
17824
19883
|
ws?.disconnect(true);
|
|
19884
|
+
offlineManager?.dispose();
|
|
17825
19885
|
},
|
|
17826
19886
|
setToken: transport.setToken,
|
|
17827
19887
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
@@ -17837,7 +19897,8 @@ channel: (name, options) => {
|
|
|
17837
19897
|
});
|
|
17838
19898
|
return res.data ?? res;
|
|
17839
19899
|
},
|
|
17840
|
-
data: dataProxy
|
|
19900
|
+
data: dataProxy,
|
|
19901
|
+
...offlineManager ? { offline: offlineManager.api } : {}
|
|
17841
19902
|
};
|
|
17842
19903
|
}
|
|
17843
19904
|
//#endregion
|
|
@@ -19779,7 +21840,7 @@ var authJwt = () => {
|
|
|
19779
21840
|
* avoids the need for hardcoded dev secrets.
|
|
19780
21841
|
*/
|
|
19781
21842
|
function generateSecret(bytes = 48) {
|
|
19782
|
-
return crypto.randomBytes(bytes).toString("hex");
|
|
21843
|
+
return crypto$1.randomBytes(bytes).toString("hex");
|
|
19783
21844
|
}
|
|
19784
21845
|
/**
|
|
19785
21846
|
* Zod coercion helper: transforms `"true"` → `true`, everything else → `false`.
|
|
@@ -19837,7 +21898,7 @@ var rebaseEnvSchema = object({
|
|
|
19837
21898
|
ADMIN_CONNECTION_STRING: string().url().optional(),
|
|
19838
21899
|
JWT_SECRET: string().min(32, "JWT_SECRET must be at least 32 characters long"),
|
|
19839
21900
|
JWT_ACCESS_EXPIRES_IN: string().default("1h"),
|
|
19840
|
-
JWT_REFRESH_EXPIRES_IN: string().default("
|
|
21901
|
+
JWT_REFRESH_EXPIRES_IN: string().default("400d"),
|
|
19841
21902
|
GOOGLE_CLIENT_ID: string().optional(),
|
|
19842
21903
|
GOOGLE_CLIENT_SECRET: string().optional(),
|
|
19843
21904
|
REBASE_SERVICE_KEY: string().optional(),
|