@basictech/react 0.8.0-beta.3 → 0.9.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +37 -0
- package/dist/index.d.mts +1029 -267
- package/dist/index.d.ts +1029 -267
- package/dist/index.js +2725 -1517
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2693 -1508
- package/dist/index.mjs.map +1 -1
- package/package.json +27 -8
- package/readme.md +292 -227
- package/.turbo/turbo-build.log +0 -24
- package/AUTH_IMPLEMENTATION_GUIDE.md +0 -2011
- package/src/AuthContext.tsx +0 -493
- package/src/config.ts +0 -9
- package/src/context.tsx +0 -104
- package/src/core/auth/AuthManager.ts +0 -882
- package/src/core/db/RemoteCollection.ts +0 -308
- package/src/core/db/RemoteDB.ts +0 -40
- package/src/core/db/index.ts +0 -7
- package/src/core/db/types.ts +0 -140
- package/src/dev/BasicDevToolbar.tsx +0 -665
- package/src/index.ts +0 -36
- package/src/sync/index.ts +0 -288
- package/src/sync/syncProtocol.js +0 -275
- package/src/sync/tokenRegistry.ts +0 -20
- package/src/updater/updateMigrations.ts +0 -22
- package/src/updater/versionUpdater.ts +0 -153
- package/src/utils/network.ts +0 -135
- package/src/utils/normalizeClientId.ts +0 -22
- package/src/utils/resolveDid.ts +0 -101
- package/src/utils/schema.ts +0 -119
- package/src/utils/storage.ts +0 -67
- package/tsconfig.json +0 -9
- package/tsup.config.ts +0 -11
package/dist/index.js
CHANGED
|
@@ -30,6 +30,16 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
30
|
));
|
|
31
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
32
|
|
|
33
|
+
// src/react/context.ts
|
|
34
|
+
var import_react, BasicClientContext;
|
|
35
|
+
var init_context = __esm({
|
|
36
|
+
"src/react/context.ts"() {
|
|
37
|
+
"use strict";
|
|
38
|
+
import_react = require("react");
|
|
39
|
+
BasicClientContext = (0, import_react.createContext)(null);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
33
43
|
// src/config.ts
|
|
34
44
|
var log;
|
|
35
45
|
var init_config = __esm({
|
|
@@ -46,227 +56,11 @@ var init_config = __esm({
|
|
|
46
56
|
}
|
|
47
57
|
});
|
|
48
58
|
|
|
49
|
-
// src/sync/tokenRegistry.ts
|
|
50
|
-
function setTokenGetter(url, fn) {
|
|
51
|
-
registry.set(url, fn);
|
|
52
|
-
}
|
|
53
|
-
function getTokenGetter(url) {
|
|
54
|
-
return registry.get(url);
|
|
55
|
-
}
|
|
56
|
-
var registry;
|
|
57
|
-
var init_tokenRegistry = __esm({
|
|
58
|
-
"src/sync/tokenRegistry.ts"() {
|
|
59
|
-
"use strict";
|
|
60
|
-
registry = /* @__PURE__ */ new Map();
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// src/sync/syncProtocol.js
|
|
65
|
-
var syncProtocol_exports = {};
|
|
66
|
-
__export(syncProtocol_exports, {
|
|
67
|
-
syncProtocol: () => syncProtocol
|
|
68
|
-
});
|
|
69
|
-
function decodeJwtExp(token) {
|
|
70
|
-
try {
|
|
71
|
-
var parts = token.split(".");
|
|
72
|
-
if (parts.length !== 3) return null;
|
|
73
|
-
var payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
74
|
-
return typeof payload.exp === "number" ? payload.exp : null;
|
|
75
|
-
} catch (_) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
var import_dexie, syncProtocol;
|
|
80
|
-
var init_syncProtocol = __esm({
|
|
81
|
-
"src/sync/syncProtocol.js"() {
|
|
82
|
-
"use strict";
|
|
83
|
-
"use client";
|
|
84
|
-
import_dexie = require("dexie");
|
|
85
|
-
init_config();
|
|
86
|
-
init_tokenRegistry();
|
|
87
|
-
syncProtocol = function() {
|
|
88
|
-
log("Initializing syncProtocol");
|
|
89
|
-
var RECONNECT_DELAY = 5e3;
|
|
90
|
-
var TOKEN_REFRESH_BUFFER = 60;
|
|
91
|
-
import_dexie.Dexie.Syncable.registerSyncProtocol("websocket", {
|
|
92
|
-
sync: function(context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
|
|
93
|
-
var requestId = 0;
|
|
94
|
-
var acceptCallbacks = {};
|
|
95
|
-
var refreshTimer = null;
|
|
96
|
-
log("Connecting to", url);
|
|
97
|
-
var ws = new WebSocket(url);
|
|
98
|
-
function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
|
|
99
|
-
log("sendChanges", changes2.length, baseRevision2);
|
|
100
|
-
++requestId;
|
|
101
|
-
acceptCallbacks[requestId.toString()] = onChangesAccepted2;
|
|
102
|
-
ws.send(
|
|
103
|
-
JSON.stringify({
|
|
104
|
-
type: "changes",
|
|
105
|
-
changes: changes2,
|
|
106
|
-
partial: partial2,
|
|
107
|
-
baseRevision: baseRevision2,
|
|
108
|
-
requestId
|
|
109
|
-
})
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
function clearRefreshTimer() {
|
|
113
|
-
if (refreshTimer) {
|
|
114
|
-
clearTimeout(refreshTimer);
|
|
115
|
-
refreshTimer = null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
function resolveGetToken() {
|
|
119
|
-
var fn = getTokenGetter(url);
|
|
120
|
-
if (!fn) throw new Error("No token getter registered for " + url);
|
|
121
|
-
return fn;
|
|
122
|
-
}
|
|
123
|
-
function scheduleTokenRefresh(tokenStr) {
|
|
124
|
-
clearRefreshTimer();
|
|
125
|
-
var exp = decodeJwtExp(tokenStr);
|
|
126
|
-
if (!exp) return;
|
|
127
|
-
var msUntilRefresh = (exp - TOKEN_REFRESH_BUFFER) * 1e3 - Date.now();
|
|
128
|
-
if (msUntilRefresh <= 0) return;
|
|
129
|
-
log("Scheduling proactive token refresh in", Math.round(msUntilRefresh / 1e3), "s");
|
|
130
|
-
refreshTimer = setTimeout(async function() {
|
|
131
|
-
try {
|
|
132
|
-
var newToken = await resolveGetToken()({ forceRefresh: true });
|
|
133
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
134
|
-
log("Sending tokenUpdate on existing WebSocket");
|
|
135
|
-
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
|
|
136
|
-
scheduleTokenRefresh(newToken);
|
|
137
|
-
}
|
|
138
|
-
} catch (err) {
|
|
139
|
-
log("Proactive token refresh failed (non-fatal):", err);
|
|
140
|
-
}
|
|
141
|
-
}, msUntilRefresh);
|
|
142
|
-
}
|
|
143
|
-
ws.onopen = async function(event) {
|
|
144
|
-
try {
|
|
145
|
-
var token = await resolveGetToken()();
|
|
146
|
-
log("Opening socket - sending clientIdentity", context.clientIdentity);
|
|
147
|
-
ws.send(
|
|
148
|
-
JSON.stringify({
|
|
149
|
-
type: "clientIdentity",
|
|
150
|
-
clientIdentity: context.clientIdentity || null,
|
|
151
|
-
authToken: token,
|
|
152
|
-
schema: options.schema
|
|
153
|
-
})
|
|
154
|
-
);
|
|
155
|
-
scheduleTokenRefresh(token);
|
|
156
|
-
} catch (err) {
|
|
157
|
-
log("Failed to get token for WebSocket:", err);
|
|
158
|
-
ws.close();
|
|
159
|
-
onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
function handleVisibilityResume() {
|
|
163
|
-
if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
|
|
164
|
-
log("Page became visible - refreshing token for WebSocket");
|
|
165
|
-
resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
|
|
166
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
167
|
-
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: newToken }));
|
|
168
|
-
scheduleTokenRefresh(newToken);
|
|
169
|
-
}
|
|
170
|
-
}).catch(function(err) {
|
|
171
|
-
log("Token refresh on visibility resume failed:", err);
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (typeof document !== "undefined") {
|
|
176
|
-
document.addEventListener("visibilitychange", handleVisibilityResume);
|
|
177
|
-
}
|
|
178
|
-
function cleanupVisibilityListener() {
|
|
179
|
-
if (typeof document !== "undefined") {
|
|
180
|
-
document.removeEventListener("visibilitychange", handleVisibilityResume);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
ws.onerror = function(event) {
|
|
184
|
-
clearRefreshTimer();
|
|
185
|
-
cleanupVisibilityListener();
|
|
186
|
-
ws.close();
|
|
187
|
-
log("ws.onerror", event);
|
|
188
|
-
onError(event?.message, RECONNECT_DELAY);
|
|
189
|
-
};
|
|
190
|
-
ws.onclose = function(event) {
|
|
191
|
-
clearRefreshTimer();
|
|
192
|
-
cleanupVisibilityListener();
|
|
193
|
-
onError("Socket closed: " + event.reason, RECONNECT_DELAY);
|
|
194
|
-
};
|
|
195
|
-
var isFirstRound = true;
|
|
196
|
-
ws.onmessage = function(event) {
|
|
197
|
-
try {
|
|
198
|
-
var requestFromServer = JSON.parse(event.data);
|
|
199
|
-
log("requestFromServer", requestFromServer, { isFirstRound });
|
|
200
|
-
if (requestFromServer.type == "clientIdentity") {
|
|
201
|
-
context.clientIdentity = requestFromServer.clientIdentity;
|
|
202
|
-
context.save();
|
|
203
|
-
sendChanges(changes, baseRevision, partial, onChangesAccepted);
|
|
204
|
-
ws.send(
|
|
205
|
-
JSON.stringify({
|
|
206
|
-
type: "subscribe",
|
|
207
|
-
syncedRevision
|
|
208
|
-
})
|
|
209
|
-
);
|
|
210
|
-
} else if (requestFromServer.type == "changes") {
|
|
211
|
-
applyRemoteChanges(
|
|
212
|
-
requestFromServer.changes,
|
|
213
|
-
requestFromServer.currentRevision,
|
|
214
|
-
requestFromServer.partial
|
|
215
|
-
);
|
|
216
|
-
if (isFirstRound && !requestFromServer.partial) {
|
|
217
|
-
onSuccess({
|
|
218
|
-
// Specify a react function that will react on additional client changes
|
|
219
|
-
react: function(changes2, baseRevision2, partial2, onChangesAccepted2) {
|
|
220
|
-
sendChanges(
|
|
221
|
-
changes2,
|
|
222
|
-
baseRevision2,
|
|
223
|
-
partial2,
|
|
224
|
-
onChangesAccepted2
|
|
225
|
-
);
|
|
226
|
-
},
|
|
227
|
-
disconnect: function() {
|
|
228
|
-
clearRefreshTimer();
|
|
229
|
-
cleanupVisibilityListener();
|
|
230
|
-
ws.close();
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
isFirstRound = false;
|
|
234
|
-
}
|
|
235
|
-
} else if (requestFromServer.type == "ack") {
|
|
236
|
-
var requestId2 = requestFromServer.requestId;
|
|
237
|
-
var acceptCallback = acceptCallbacks[requestId2.toString()];
|
|
238
|
-
acceptCallback();
|
|
239
|
-
delete acceptCallbacks[requestId2.toString()];
|
|
240
|
-
} else if (requestFromServer.type == "error") {
|
|
241
|
-
ws.close();
|
|
242
|
-
if (requestFromServer.code === "TOKEN_EXPIRED" || requestFromServer.code === "UNAUTHORIZED") {
|
|
243
|
-
log("Auth error from server, will reconnect with fresh token:", requestFromServer.message);
|
|
244
|
-
onError(requestFromServer.message, RECONNECT_DELAY);
|
|
245
|
-
} else {
|
|
246
|
-
onError(requestFromServer.message, Infinity);
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
log("unknown message", requestFromServer);
|
|
250
|
-
ws.close();
|
|
251
|
-
onError("unknown message", Infinity);
|
|
252
|
-
}
|
|
253
|
-
} catch (e) {
|
|
254
|
-
ws.close();
|
|
255
|
-
log("caught error", e);
|
|
256
|
-
onError(e, Infinity);
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
|
|
265
59
|
// package.json
|
|
266
60
|
var version;
|
|
267
61
|
var init_package = __esm({
|
|
268
62
|
"package.json"() {
|
|
269
|
-
version = "0.
|
|
63
|
+
version = "0.9.0-beta.0";
|
|
270
64
|
}
|
|
271
65
|
});
|
|
272
66
|
|
|
@@ -354,24 +148,6 @@ function cleanOAuthParamsFromUrl() {
|
|
|
354
148
|
log("Cleaned OAuth parameters from URL");
|
|
355
149
|
}
|
|
356
150
|
}
|
|
357
|
-
function getSyncStatus(statusCode) {
|
|
358
|
-
switch (statusCode) {
|
|
359
|
-
case -1:
|
|
360
|
-
return "ERROR";
|
|
361
|
-
case 0:
|
|
362
|
-
return "OFFLINE";
|
|
363
|
-
case 1:
|
|
364
|
-
return "CONNECTING";
|
|
365
|
-
case 2:
|
|
366
|
-
return "ONLINE";
|
|
367
|
-
case 3:
|
|
368
|
-
return "SYNCING";
|
|
369
|
-
case 4:
|
|
370
|
-
return "ERROR_WILL_RETRY";
|
|
371
|
-
default:
|
|
372
|
-
return "UNKNOWN";
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
151
|
var import_semver;
|
|
376
152
|
var init_network = __esm({
|
|
377
153
|
"src/utils/network.ts"() {
|
|
@@ -382,57 +158,147 @@ var init_network = __esm({
|
|
|
382
158
|
}
|
|
383
159
|
});
|
|
384
160
|
|
|
385
|
-
// src/
|
|
386
|
-
function
|
|
387
|
-
|
|
161
|
+
// src/react/hooks.ts
|
|
162
|
+
function useBasicClient() {
|
|
163
|
+
const client = (0, import_react2.useContext)(BasicClientContext);
|
|
164
|
+
if (!client) {
|
|
165
|
+
throw new Error("useBasic must be used within a <BasicProvider>");
|
|
166
|
+
}
|
|
167
|
+
return client;
|
|
388
168
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
169
|
+
function useClientSnapshot(client) {
|
|
170
|
+
return (0, import_react2.useSyncExternalStore)(client.subscribe, client.getSnapshot, client.getSnapshot);
|
|
171
|
+
}
|
|
172
|
+
function useAuth() {
|
|
173
|
+
const client = useBasicClient();
|
|
174
|
+
const snapshot = useClientSnapshot(client);
|
|
175
|
+
return (0, import_react2.useMemo)(
|
|
176
|
+
() => ({
|
|
177
|
+
isReady: snapshot.isReady,
|
|
178
|
+
isSignedIn: snapshot.isSignedIn,
|
|
179
|
+
status: snapshot.authStatus,
|
|
180
|
+
errorCode: snapshot.authErrorCode,
|
|
181
|
+
user: snapshot.user,
|
|
182
|
+
did: snapshot.did,
|
|
183
|
+
scope: snapshot.scope,
|
|
184
|
+
hasScope: (s) => client.auth.hasScope(s),
|
|
185
|
+
missingScopes: () => client.auth.missingScopes(),
|
|
186
|
+
signIn: (redirectUri) => client.auth.signIn(redirectUri),
|
|
187
|
+
signInWithHandle: (handle) => client.auth.signInWithHandle(handle),
|
|
188
|
+
signInWithCode: (code, state) => client.auth.signInWithCode(code, state),
|
|
189
|
+
signOut: () => client.signOut(),
|
|
190
|
+
getToken: (options) => client.auth.getToken(options),
|
|
191
|
+
getSignInUrl: (redirectUri) => client.auth.getSignInUrl(redirectUri)
|
|
192
|
+
}),
|
|
193
|
+
[client, snapshot]
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
function useDb() {
|
|
197
|
+
const client = useBasicClient();
|
|
198
|
+
return client.db;
|
|
199
|
+
}
|
|
200
|
+
function useSyncStatus() {
|
|
201
|
+
const client = useBasicClient();
|
|
202
|
+
const snapshot = useClientSnapshot(client);
|
|
203
|
+
return (0, import_react2.useMemo)(
|
|
204
|
+
() => ({
|
|
205
|
+
status: snapshot.syncStatus,
|
|
206
|
+
enabled: snapshot.syncEnabled,
|
|
207
|
+
pendingCount: snapshot.pendingCount,
|
|
208
|
+
listRejected: () => client.listRejected(),
|
|
209
|
+
clearRejected: () => client.clearRejected()
|
|
210
|
+
}),
|
|
211
|
+
[client, snapshot]
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
function useShares() {
|
|
215
|
+
const client = useBasicClient();
|
|
216
|
+
const snapshot = useClientSnapshot(client);
|
|
217
|
+
const [state, setState] = (0, import_react2.useState)({ granted: [], received: [], isLoading: false, error: null });
|
|
218
|
+
const isSignedIn = snapshot.isSignedIn && snapshot.authStatus === "authenticated";
|
|
219
|
+
const refresh = (0, import_react2.useMemo)(
|
|
220
|
+
() => async () => {
|
|
221
|
+
setState((s) => ({ ...s, isLoading: true, error: null }));
|
|
222
|
+
try {
|
|
223
|
+
const { granted, received } = await client.listShares();
|
|
224
|
+
setState({ granted, received, isLoading: false, error: null });
|
|
225
|
+
} catch (err) {
|
|
226
|
+
setState((s) => ({
|
|
227
|
+
...s,
|
|
228
|
+
isLoading: false,
|
|
229
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
230
|
+
}));
|
|
408
231
|
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
signinWithCode: () => Promise.resolve({ success: false }),
|
|
434
|
-
getSignInLink: () => Promise.resolve("")
|
|
232
|
+
},
|
|
233
|
+
[client]
|
|
234
|
+
);
|
|
235
|
+
(0, import_react2.useEffect)(() => {
|
|
236
|
+
if (isSignedIn) void refresh();
|
|
237
|
+
}, [isSignedIn, refresh]);
|
|
238
|
+
return { ...state, refresh };
|
|
239
|
+
}
|
|
240
|
+
function useShare(shareId) {
|
|
241
|
+
const client = useBasicClient();
|
|
242
|
+
const snapshot = useClientSnapshot(client);
|
|
243
|
+
const [handle, setHandle] = (0, import_react2.useState)(null);
|
|
244
|
+
const [error, setError] = (0, import_react2.useState)(null);
|
|
245
|
+
const [revoked, setRevoked] = (0, import_react2.useState)(false);
|
|
246
|
+
const canMount = !!shareId && snapshot.isSignedIn && snapshot.authStatus !== "reauth_required";
|
|
247
|
+
(0, import_react2.useEffect)(() => {
|
|
248
|
+
if (!canMount || !shareId) return;
|
|
249
|
+
let cancelled = false;
|
|
250
|
+
setError(null);
|
|
251
|
+
setRevoked(false);
|
|
252
|
+
client.mountShare(shareId).then((h) => {
|
|
253
|
+
if (!cancelled) setHandle(h);
|
|
254
|
+
}).catch((err) => {
|
|
255
|
+
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
|
|
435
256
|
});
|
|
257
|
+
const offSubError = client.engine?.on("suberror", ({ sub, code }) => {
|
|
258
|
+
if (sub === `share:${shareId}` && (code === "SHARE_REVOKED" || code === "CONNECTION_REVOKED")) {
|
|
259
|
+
setRevoked(true);
|
|
260
|
+
setHandle(null);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return () => {
|
|
264
|
+
cancelled = true;
|
|
265
|
+
offSubError?.();
|
|
266
|
+
setHandle(null);
|
|
267
|
+
void client.unmountShare(shareId).catch(() => {
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
}, [client, shareId, canMount]);
|
|
271
|
+
return {
|
|
272
|
+
db: handle?.db ?? null,
|
|
273
|
+
status: revoked ? "revoked" : error ? "error" : handle ? "mounted" : "mounting",
|
|
274
|
+
error
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function useBasic() {
|
|
278
|
+
const client = useBasicClient();
|
|
279
|
+
const snapshot = useClientSnapshot(client);
|
|
280
|
+
const auth = useAuth();
|
|
281
|
+
const sync = useSyncStatus();
|
|
282
|
+
return (0, import_react2.useMemo)(
|
|
283
|
+
() => ({
|
|
284
|
+
...auth,
|
|
285
|
+
db: client.db,
|
|
286
|
+
sync,
|
|
287
|
+
devInfo: snapshot.devInfo,
|
|
288
|
+
refreshSchemaStatus: () => client.refreshSchemaStatus(),
|
|
289
|
+
client
|
|
290
|
+
}),
|
|
291
|
+
[client, snapshot, auth, sync]
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
var import_react2, import_dexie_react_hooks, useQuery;
|
|
295
|
+
var init_hooks = __esm({
|
|
296
|
+
"src/react/hooks.ts"() {
|
|
297
|
+
"use strict";
|
|
298
|
+
import_react2 = require("react");
|
|
299
|
+
import_dexie_react_hooks = require("dexie-react-hooks");
|
|
300
|
+
init_context();
|
|
301
|
+
useQuery = import_dexie_react_hooks.useLiveQuery;
|
|
436
302
|
}
|
|
437
303
|
});
|
|
438
304
|
|
|
@@ -446,11 +312,11 @@ function toneForAuth(isReady, isSignedIn) {
|
|
|
446
312
|
if (isSignedIn) return "ok";
|
|
447
313
|
return "warn";
|
|
448
314
|
}
|
|
449
|
-
function
|
|
450
|
-
if (
|
|
451
|
-
if (
|
|
452
|
-
if (
|
|
453
|
-
if (
|
|
315
|
+
function toneForSync(mode, status) {
|
|
316
|
+
if (mode === "rest") return "muted";
|
|
317
|
+
if (status === "online") return "ok";
|
|
318
|
+
if (status === "connecting") return "warn";
|
|
319
|
+
if (status === "offline" || status === "idle" || status === "stopped") return "muted";
|
|
454
320
|
return "bad";
|
|
455
321
|
}
|
|
456
322
|
function toneForSchema(info) {
|
|
@@ -460,24 +326,22 @@ function toneForSchema(info) {
|
|
|
460
326
|
if (info.status === "no_schema") return "muted";
|
|
461
327
|
return "bad";
|
|
462
328
|
}
|
|
463
|
-
function
|
|
329
|
+
function syncStatusLabel(status) {
|
|
464
330
|
switch (status) {
|
|
465
|
-
case "
|
|
466
|
-
return "
|
|
467
|
-
case "
|
|
468
|
-
return "Offline";
|
|
469
|
-
case "CONNECTING" /* CONNECTING */:
|
|
331
|
+
case "idle":
|
|
332
|
+
return "Idle";
|
|
333
|
+
case "connecting":
|
|
470
334
|
return "Connecting";
|
|
471
|
-
case "
|
|
335
|
+
case "online":
|
|
472
336
|
return "Connected";
|
|
473
|
-
case "
|
|
474
|
-
return "
|
|
475
|
-
case "
|
|
476
|
-
return "
|
|
477
|
-
case "
|
|
478
|
-
return "
|
|
479
|
-
case "
|
|
480
|
-
return "
|
|
337
|
+
case "offline":
|
|
338
|
+
return "Offline";
|
|
339
|
+
case "auth_required":
|
|
340
|
+
return "Reauth required";
|
|
341
|
+
case "revoked":
|
|
342
|
+
return "Connection revoked";
|
|
343
|
+
case "stopped":
|
|
344
|
+
return "Stopped";
|
|
481
345
|
default:
|
|
482
346
|
return String(status);
|
|
483
347
|
}
|
|
@@ -566,9 +430,9 @@ function CopyableRow({
|
|
|
566
430
|
onCopied,
|
|
567
431
|
children
|
|
568
432
|
}) {
|
|
569
|
-
const [hover, setHover] = (0,
|
|
433
|
+
const [hover, setHover] = (0, import_react3.useState)(false);
|
|
570
434
|
const canCopy = copyText.length > 0;
|
|
571
|
-
const handleClick = (0,
|
|
435
|
+
const handleClick = (0, import_react3.useCallback)(
|
|
572
436
|
(e) => {
|
|
573
437
|
e.stopPropagation();
|
|
574
438
|
if (!canCopy) return;
|
|
@@ -646,21 +510,24 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
646
510
|
did,
|
|
647
511
|
scope,
|
|
648
512
|
missingScopes,
|
|
649
|
-
|
|
650
|
-
dbStatus,
|
|
513
|
+
sync,
|
|
651
514
|
devInfo,
|
|
652
|
-
refreshSchemaStatus
|
|
515
|
+
refreshSchemaStatus,
|
|
516
|
+
client
|
|
653
517
|
} = useBasic();
|
|
654
|
-
const
|
|
655
|
-
const
|
|
656
|
-
const
|
|
657
|
-
const [
|
|
518
|
+
const dbMode = client.mode;
|
|
519
|
+
const syncStatus = sync.status;
|
|
520
|
+
const indexedDbName = dbMode === "sync" && client.projectId ? `basic-sync:${client.projectId}` : null;
|
|
521
|
+
const [open, setOpen] = (0, import_react3.useState)(false);
|
|
522
|
+
const [refreshing, setRefreshing] = (0, import_react3.useState)(false);
|
|
523
|
+
const [copied, setCopied] = (0, import_react3.useState)(false);
|
|
524
|
+
const [rowCopied, setRowCopied] = (0, import_react3.useState)(null);
|
|
658
525
|
const show = enabled && typeof window !== "undefined" && isDevelopment(debug);
|
|
659
526
|
const authTone = toneForAuth(isReady, isSignedIn);
|
|
660
|
-
const dbTone =
|
|
527
|
+
const dbTone = toneForSync(dbMode, syncStatus);
|
|
661
528
|
const schemaTone = toneForSchema(devInfo);
|
|
662
|
-
const syncTone =
|
|
663
|
-
const handleRefreshSchema = (0,
|
|
529
|
+
const syncTone = dbTone;
|
|
530
|
+
const handleRefreshSchema = (0, import_react3.useCallback)(async () => {
|
|
664
531
|
setRefreshing(true);
|
|
665
532
|
try {
|
|
666
533
|
await refreshSchemaStatus();
|
|
@@ -669,7 +536,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
669
536
|
}
|
|
670
537
|
}, [refreshSchemaStatus]);
|
|
671
538
|
const missingList = missingScopes();
|
|
672
|
-
const debugPayload = (0,
|
|
539
|
+
const debugPayload = (0, import_react3.useMemo)(() => {
|
|
673
540
|
return {
|
|
674
541
|
sdkVersion: version,
|
|
675
542
|
isReady,
|
|
@@ -684,12 +551,13 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
684
551
|
scope,
|
|
685
552
|
missingScopes: missingList,
|
|
686
553
|
dbMode,
|
|
687
|
-
|
|
688
|
-
|
|
554
|
+
syncStatus,
|
|
555
|
+
pendingOps: sync.pendingCount,
|
|
556
|
+
indexedDbName,
|
|
689
557
|
schema: devInfo
|
|
690
558
|
};
|
|
691
|
-
}, [isReady, isSignedIn, did, user, scope, dbMode,
|
|
692
|
-
const handleCopy = (0,
|
|
559
|
+
}, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, devInfo, missingList]);
|
|
560
|
+
const handleCopy = (0, import_react3.useCallback)(async () => {
|
|
693
561
|
try {
|
|
694
562
|
await navigator.clipboard.writeText(JSON.stringify(debugPayload, null, 2));
|
|
695
563
|
setCopied(true);
|
|
@@ -697,7 +565,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
697
565
|
} catch {
|
|
698
566
|
}
|
|
699
567
|
}, [debugPayload]);
|
|
700
|
-
const onRowCopied = (0,
|
|
568
|
+
const onRowCopied = (0, import_react3.useCallback)((key) => {
|
|
701
569
|
setRowCopied(key);
|
|
702
570
|
setTimeout(() => setRowCopied((k) => k === key ? null : k), 1500);
|
|
703
571
|
}, []);
|
|
@@ -771,7 +639,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
771
639
|
minWidth: 300,
|
|
772
640
|
maxWidth: "min(560px, calc(100vw - 24px))"
|
|
773
641
|
};
|
|
774
|
-
const syncStatusText =
|
|
642
|
+
const syncStatusText = dbMode === "rest" ? "REST mode" : `${syncStatusLabel(syncStatus)}${sync.pendingCount > 0 ? ` (${sync.pendingCount} pending)` : ""}`;
|
|
775
643
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: shell, children: [
|
|
776
644
|
open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: panel, children: [
|
|
777
645
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginBottom: 12 }, children: [
|
|
@@ -866,10 +734,10 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
866
734
|
{
|
|
867
735
|
rowKey: "indexedDb",
|
|
868
736
|
label: "IndexedDB",
|
|
869
|
-
copyText:
|
|
737
|
+
copyText: indexedDbName ?? "",
|
|
870
738
|
copiedKey: rowCopied,
|
|
871
739
|
onCopied: onRowCopied,
|
|
872
|
-
children:
|
|
740
|
+
children: indexedDbName ?? "\u2014"
|
|
873
741
|
}
|
|
874
742
|
),
|
|
875
743
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
@@ -1049,17 +917,16 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
1049
917
|
)
|
|
1050
918
|
] });
|
|
1051
919
|
}
|
|
1052
|
-
var
|
|
920
|
+
var import_react3, import_jsx_runtime, PANEL_PAD_X;
|
|
1053
921
|
var init_BasicDevToolbar = __esm({
|
|
1054
922
|
"src/dev/BasicDevToolbar.tsx"() {
|
|
1055
923
|
"use strict";
|
|
1056
924
|
"use client";
|
|
1057
|
-
|
|
1058
|
-
|
|
925
|
+
import_react3 = require("react");
|
|
926
|
+
init_hooks();
|
|
1059
927
|
init_package();
|
|
1060
928
|
init_network();
|
|
1061
929
|
import_jsx_runtime = require("react/jsx-runtime");
|
|
1062
|
-
INDEXED_DB_NAME = "basicdb";
|
|
1063
930
|
PANEL_PAD_X = 12;
|
|
1064
931
|
}
|
|
1065
932
|
});
|
|
@@ -1067,608 +934,146 @@ var init_BasicDevToolbar = __esm({
|
|
|
1067
934
|
// src/index.ts
|
|
1068
935
|
var index_exports = {};
|
|
1069
936
|
__export(index_exports, {
|
|
937
|
+
AuthManager: () => AuthManager,
|
|
938
|
+
BasicClient: () => BasicClient,
|
|
1070
939
|
BasicDevToolbar: () => BasicDevToolbar,
|
|
1071
940
|
BasicProvider: () => BasicProvider,
|
|
1072
|
-
|
|
941
|
+
DEFAULT_LIMITS: () => DEFAULT_LIMITS,
|
|
942
|
+
LocalStorageAdapter: () => LocalStorageAdapter,
|
|
1073
943
|
NotAuthenticatedError: () => NotAuthenticatedError,
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
944
|
+
OWN_SUB: () => OWN_SUB,
|
|
945
|
+
PROTOCOL_VERSION: () => PROTOCOL_VERSION,
|
|
946
|
+
RestClient: () => RestClient,
|
|
947
|
+
RestDb: () => RestDb,
|
|
948
|
+
RestError: () => RestError,
|
|
1077
949
|
STORAGE_KEYS: () => STORAGE_KEYS,
|
|
950
|
+
SyncConnection: () => SyncConnection,
|
|
951
|
+
SyncDb: () => SyncDb,
|
|
952
|
+
SyncEngine: () => SyncEngine,
|
|
953
|
+
SyncStore: () => SyncStore,
|
|
954
|
+
applyOpToData: () => applyOpToData,
|
|
955
|
+
createBasicClient: () => createBasicClient,
|
|
956
|
+
isAuthError: () => isAuthError,
|
|
957
|
+
isRebootstrapError: () => isRebootstrapError,
|
|
958
|
+
isRevocationError: () => isRevocationError,
|
|
959
|
+
isTerminalOpError: () => isTerminalOpError,
|
|
960
|
+
mintOpId: () => mintOpId,
|
|
1078
961
|
resolveDid: () => resolveDid,
|
|
1079
962
|
resolveDidWebUrl: () => resolveDidWebUrl,
|
|
1080
963
|
resolveHandle: () => resolveHandle,
|
|
964
|
+
shareSubKey: () => shareSubKey,
|
|
965
|
+
useAuth: () => useAuth,
|
|
1081
966
|
useBasic: () => useBasic,
|
|
1082
|
-
|
|
967
|
+
useBasicClient: () => useBasicClient,
|
|
968
|
+
useDb: () => useDb,
|
|
969
|
+
useQuery: () => useQuery,
|
|
970
|
+
useShare: () => useShare,
|
|
971
|
+
useShares: () => useShares,
|
|
972
|
+
useSyncStatus: () => useSyncStatus
|
|
1083
973
|
});
|
|
1084
974
|
module.exports = __toCommonJS(index_exports);
|
|
1085
975
|
|
|
1086
|
-
// src/
|
|
1087
|
-
var
|
|
976
|
+
// src/react/BasicProvider.tsx
|
|
977
|
+
var import_react4 = require("react");
|
|
978
|
+
init_context();
|
|
1088
979
|
|
|
1089
|
-
// src/
|
|
1090
|
-
var
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
var
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
var initPromise = null;
|
|
1097
|
-
async function initDexieExtensions() {
|
|
1098
|
-
if (dexieExtensionsLoaded) return;
|
|
1099
|
-
if (typeof window === "undefined") return;
|
|
1100
|
-
if (initPromise) return initPromise;
|
|
1101
|
-
initPromise = (async () => {
|
|
1102
|
-
try {
|
|
1103
|
-
await import("dexie-syncable");
|
|
1104
|
-
await import("dexie-observable");
|
|
1105
|
-
const { syncProtocol: syncProtocol2 } = await Promise.resolve().then(() => (init_syncProtocol(), syncProtocol_exports));
|
|
1106
|
-
syncProtocol2();
|
|
1107
|
-
dexieExtensionsLoaded = true;
|
|
1108
|
-
log("Dexie extensions loaded successfully");
|
|
1109
|
-
} catch (error) {
|
|
1110
|
-
console.error("Failed to load Dexie extensions:", error);
|
|
1111
|
-
throw error;
|
|
1112
|
-
}
|
|
1113
|
-
})();
|
|
1114
|
-
return initPromise;
|
|
1115
|
-
}
|
|
1116
|
-
var BasicSync = class extends import_dexie2.Dexie {
|
|
1117
|
-
basic_schema;
|
|
1118
|
-
constructor(name, options) {
|
|
1119
|
-
super(name, options);
|
|
1120
|
-
this.basic_schema = options.schema;
|
|
1121
|
-
this.version(1).stores(this._convertSchemaToDxSchema(this.basic_schema));
|
|
1122
|
-
this.version(2).stores({});
|
|
1123
|
-
this.Collection.prototype.get = this.Collection.prototype.toArray;
|
|
1124
|
-
}
|
|
1125
|
-
async connect({ getToken, ws_url }) {
|
|
1126
|
-
const WS_URL = ws_url || "wss://pds.basic.id/ws";
|
|
1127
|
-
log("Connecting to", WS_URL);
|
|
1128
|
-
setTokenGetter(WS_URL, getToken);
|
|
1129
|
-
await this.updateSyncNodes();
|
|
1130
|
-
log("Starting connection...");
|
|
1131
|
-
return this.syncable.connect("websocket", WS_URL, { schema: this.basic_schema });
|
|
1132
|
-
}
|
|
1133
|
-
async disconnect({ ws_url } = {}) {
|
|
1134
|
-
const WS_URL = ws_url || "wss://pds.basic.id/ws";
|
|
1135
|
-
return this.syncable.disconnect(WS_URL);
|
|
1136
|
-
}
|
|
1137
|
-
async updateSyncNodes() {
|
|
1138
|
-
try {
|
|
1139
|
-
const syncNodes = await this.table("_syncNodes").toArray();
|
|
1140
|
-
const localSyncNodes = syncNodes.filter((node) => node.type === "local");
|
|
1141
|
-
log("Local sync nodes:", localSyncNodes);
|
|
1142
|
-
if (localSyncNodes.length > 1) {
|
|
1143
|
-
const largestNodeId = Math.max(...localSyncNodes.map((node) => node.id));
|
|
1144
|
-
const largestNode = localSyncNodes.find((node) => node.id === largestNodeId);
|
|
1145
|
-
if (largestNode && largestNode.isMaster === 1) {
|
|
1146
|
-
log("Largest node is already the master. No changes needed.");
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
log("Largest node id:", largestNodeId);
|
|
1150
|
-
log("HEISENBUG: More than one local sync node found.");
|
|
1151
|
-
for (const node of localSyncNodes) {
|
|
1152
|
-
log(`Local sync node keys:`, node.id, node.isMaster);
|
|
1153
|
-
await this.table("_syncNodes").update(node.id, { isMaster: node.id === largestNodeId ? 1 : 0 });
|
|
1154
|
-
log(`HEISENBUG: Setting ${node.id} to ${node.id === largestNodeId ? "master" : "0"}`);
|
|
1155
|
-
}
|
|
1156
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1157
|
-
if (typeof window !== "undefined") {
|
|
1158
|
-
window.location.reload();
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
log("Sync nodes updated");
|
|
1162
|
-
} catch (error) {
|
|
1163
|
-
console.error("Error updating _syncNodes table:", error);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
handleStatusChange(fn) {
|
|
1167
|
-
this.syncable.on("statusChanged", fn);
|
|
1168
|
-
}
|
|
1169
|
-
_convertSchemaToDxSchema(schema) {
|
|
1170
|
-
const stores = Object.entries(schema.tables).map(([key, table]) => {
|
|
1171
|
-
const indexedFields = Object.entries(table.fields).filter(([, field]) => field.indexed).map(([fieldKey]) => `,${fieldKey}`).join("");
|
|
1172
|
-
return {
|
|
1173
|
-
[key]: "id" + indexedFields
|
|
1174
|
-
};
|
|
1175
|
-
});
|
|
1176
|
-
return Object.assign({}, ...stores);
|
|
980
|
+
// src/core/auth/AuthManager.ts
|
|
981
|
+
var import_jwt_decode = require("jwt-decode");
|
|
982
|
+
|
|
983
|
+
// src/utils/storage.ts
|
|
984
|
+
var LocalStorageAdapter = class {
|
|
985
|
+
async get(key) {
|
|
986
|
+
return localStorage.getItem(key);
|
|
1177
987
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
988
|
+
async set(key, value) {
|
|
989
|
+
localStorage.setItem(key, value);
|
|
1180
990
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
throw new Error(`Table "${name}" not found in schema`);
|
|
1184
|
-
}
|
|
1185
|
-
const table = this.table(name);
|
|
1186
|
-
return {
|
|
1187
|
-
/**
|
|
1188
|
-
* Returns the underlying Dexie table
|
|
1189
|
-
* @type {Dexie.Table}
|
|
1190
|
-
*/
|
|
1191
|
-
ref: table,
|
|
1192
|
-
// --- WRITE ---- //
|
|
1193
|
-
/**
|
|
1194
|
-
* Add a new record - returns the full object with generated id
|
|
1195
|
-
*/
|
|
1196
|
-
add: async (data) => {
|
|
1197
|
-
const valid = (0, import_schema.validateData)(this.basic_schema, name, data);
|
|
1198
|
-
if (!valid.valid) {
|
|
1199
|
-
log("Invalid data", valid);
|
|
1200
|
-
throw new Error(valid.message || "Data validation failed");
|
|
1201
|
-
}
|
|
1202
|
-
const id = (0, import_uuid.v7)();
|
|
1203
|
-
const fullData = { id, ...data };
|
|
1204
|
-
await table.add(fullData);
|
|
1205
|
-
return fullData;
|
|
1206
|
-
},
|
|
1207
|
-
/**
|
|
1208
|
-
* Put (upsert) a record - returns the full object
|
|
1209
|
-
*/
|
|
1210
|
-
put: async (data) => {
|
|
1211
|
-
if (!data.id) {
|
|
1212
|
-
throw new Error("put() requires an id field");
|
|
1213
|
-
}
|
|
1214
|
-
const valid = (0, import_schema.validateData)(this.basic_schema, name, data);
|
|
1215
|
-
if (!valid.valid) {
|
|
1216
|
-
log("Invalid data", valid);
|
|
1217
|
-
throw new Error(valid.message || "Data validation failed");
|
|
1218
|
-
}
|
|
1219
|
-
await table.put(data);
|
|
1220
|
-
return data;
|
|
1221
|
-
},
|
|
1222
|
-
/**
|
|
1223
|
-
* Update an existing record - returns updated object or null
|
|
1224
|
-
*/
|
|
1225
|
-
update: async (id, data) => {
|
|
1226
|
-
if (!id) {
|
|
1227
|
-
throw new Error("update() requires an id");
|
|
1228
|
-
}
|
|
1229
|
-
const valid = (0, import_schema.validateData)(this.basic_schema, name, data, false);
|
|
1230
|
-
if (!valid.valid) {
|
|
1231
|
-
log("Invalid data", valid);
|
|
1232
|
-
throw new Error(valid.message || "Data validation failed");
|
|
1233
|
-
}
|
|
1234
|
-
const updated = await table.update(id, data);
|
|
1235
|
-
if (updated === 0) {
|
|
1236
|
-
return null;
|
|
1237
|
-
}
|
|
1238
|
-
const record = await table.get(id);
|
|
1239
|
-
return record || null;
|
|
1240
|
-
},
|
|
1241
|
-
/**
|
|
1242
|
-
* Delete a record - returns true if deleted, false if not found
|
|
1243
|
-
*/
|
|
1244
|
-
delete: async (id) => {
|
|
1245
|
-
if (!id) {
|
|
1246
|
-
throw new Error("delete() requires an id");
|
|
1247
|
-
}
|
|
1248
|
-
const exists = await table.get(id);
|
|
1249
|
-
if (!exists) {
|
|
1250
|
-
return false;
|
|
1251
|
-
}
|
|
1252
|
-
await table.delete(id);
|
|
1253
|
-
return true;
|
|
1254
|
-
},
|
|
1255
|
-
// --- READ ---- //
|
|
1256
|
-
/**
|
|
1257
|
-
* Get a single record by id - returns null if not found
|
|
1258
|
-
*/
|
|
1259
|
-
get: async (id) => {
|
|
1260
|
-
if (!id) {
|
|
1261
|
-
throw new Error("get() requires an id");
|
|
1262
|
-
}
|
|
1263
|
-
const record = await table.get(id);
|
|
1264
|
-
return record || null;
|
|
1265
|
-
},
|
|
1266
|
-
/**
|
|
1267
|
-
* Get all records in the collection
|
|
1268
|
-
*/
|
|
1269
|
-
getAll: async () => {
|
|
1270
|
-
return table.toArray();
|
|
1271
|
-
},
|
|
1272
|
-
// --- QUERY ---- //
|
|
1273
|
-
/**
|
|
1274
|
-
* Filter records using a predicate function
|
|
1275
|
-
*/
|
|
1276
|
-
filter: async (fn) => {
|
|
1277
|
-
return table.filter(fn).toArray();
|
|
1278
|
-
},
|
|
1279
|
-
/**
|
|
1280
|
-
* Get the raw Dexie table for advanced queries
|
|
1281
|
-
* @deprecated Use ref instead
|
|
1282
|
-
*/
|
|
1283
|
-
query: () => table
|
|
1284
|
-
};
|
|
991
|
+
async remove(key) {
|
|
992
|
+
localStorage.removeItem(key);
|
|
1285
993
|
}
|
|
1286
994
|
};
|
|
995
|
+
var STORAGE_KEYS = {
|
|
996
|
+
REFRESH_TOKEN: "basic_refresh_token",
|
|
997
|
+
USER_INFO: "basic_user_info",
|
|
998
|
+
AUTH_STATE: "basic_auth_state",
|
|
999
|
+
REDIRECT_URI: "basic_redirect_uri",
|
|
1000
|
+
SERVER_URL: "basic_server_url",
|
|
1001
|
+
PDS_ENDPOINTS: "basic_pds_endpoints",
|
|
1002
|
+
LAST_CONNECT_REPORT: "basic_last_connect_report",
|
|
1003
|
+
DEBUG: "basic_debug",
|
|
1004
|
+
CODE_VERIFIER: "basic_code_verifier"
|
|
1005
|
+
};
|
|
1287
1006
|
|
|
1288
|
-
// src/
|
|
1289
|
-
var
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1007
|
+
// src/utils/normalizeClientId.ts
|
|
1008
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1009
|
+
function normalizeClientId(projectId, adminHostname = "api.basic.tech") {
|
|
1010
|
+
if (!projectId) return projectId;
|
|
1011
|
+
if (projectId === "self") return projectId;
|
|
1012
|
+
if (projectId.startsWith("did:")) return projectId;
|
|
1013
|
+
if (UUID_RE.test(projectId)) {
|
|
1014
|
+
const hex = projectId.replace(/-/g, "").toLowerCase();
|
|
1015
|
+
return `did:web:${adminHostname}:projects:${hex}`;
|
|
1297
1016
|
}
|
|
1298
|
-
|
|
1017
|
+
return projectId;
|
|
1018
|
+
}
|
|
1299
1019
|
|
|
1300
|
-
// src/
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1020
|
+
// src/utils/resolveDid.ts
|
|
1021
|
+
function resolveDidWebUrl(did) {
|
|
1022
|
+
if (!did.startsWith("did:web:")) return null;
|
|
1023
|
+
const rest = did.slice(8);
|
|
1024
|
+
if (!rest) return null;
|
|
1025
|
+
const parts = rest.split(":");
|
|
1026
|
+
const hostname = parts[0].replace(/%3A/gi, ":");
|
|
1027
|
+
if (parts.length === 1) {
|
|
1028
|
+
return `https://${hostname}/.well-known/did.json`;
|
|
1306
1029
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1030
|
+
const pathParts = parts.slice(1).map((p) => decodeURIComponent(p));
|
|
1031
|
+
return `https://${hostname}/${pathParts.join("/")}/did.json`;
|
|
1032
|
+
}
|
|
1033
|
+
async function resolveFromDocument(did, didDocument) {
|
|
1034
|
+
const services = didDocument.service;
|
|
1035
|
+
const pdsService = services?.find(
|
|
1036
|
+
(s) => s.id === "#basic_pds" || s.id === `${did}#basic_pds`
|
|
1037
|
+
);
|
|
1038
|
+
if (!pdsService) {
|
|
1039
|
+
throw new Error(`DID document has no #basic_pds service entry`);
|
|
1314
1040
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
}
|
|
1041
|
+
const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, "");
|
|
1042
|
+
const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`);
|
|
1043
|
+
if (!oauthRes.ok) {
|
|
1044
|
+
throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`);
|
|
1319
1045
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1046
|
+
const oauth = await oauthRes.json();
|
|
1047
|
+
return {
|
|
1048
|
+
did,
|
|
1049
|
+
didDocument,
|
|
1050
|
+
pdsUrl,
|
|
1051
|
+
authorization_endpoint: oauth.authorization_endpoint,
|
|
1052
|
+
token_endpoint: oauth.token_endpoint,
|
|
1053
|
+
userinfo_endpoint: oauth.userinfo_endpoint
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
async function resolveDid(did) {
|
|
1057
|
+
const url = resolveDidWebUrl(did);
|
|
1058
|
+
if (!url) {
|
|
1059
|
+
throw new Error(`Unsupported DID method: ${did}`);
|
|
1329
1060
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
*/
|
|
1334
|
-
async request(method, path, body, isRetry = false) {
|
|
1335
|
-
const token = await this.config.getToken();
|
|
1336
|
-
const url = `${this.config.serverUrl}${path}`;
|
|
1337
|
-
this.log(`${method} ${url}`, body ? JSON.stringify(body) : "");
|
|
1338
|
-
const headers = {
|
|
1339
|
-
"Authorization": `Bearer ${token}`
|
|
1340
|
-
};
|
|
1341
|
-
if (body) {
|
|
1342
|
-
headers["Content-Type"] = "application/json";
|
|
1343
|
-
}
|
|
1344
|
-
const response = await fetch(url, {
|
|
1345
|
-
method,
|
|
1346
|
-
headers,
|
|
1347
|
-
...body ? { body: JSON.stringify(body) } : {}
|
|
1348
|
-
});
|
|
1349
|
-
const responseData = await response.json().catch(() => ({}));
|
|
1350
|
-
if (!response.ok) {
|
|
1351
|
-
if (response.status === 401 && !isRetry) {
|
|
1352
|
-
this.log("Got 401, forcing token refresh and retrying...");
|
|
1353
|
-
await this.config.getToken({ forceRefresh: true });
|
|
1354
|
-
return this.request(method, path, body, true);
|
|
1355
|
-
}
|
|
1356
|
-
if (this.config.debug) {
|
|
1357
|
-
console.error(`[RemoteDB] Error ${response.status}:`, responseData);
|
|
1358
|
-
}
|
|
1359
|
-
if (this.config.onAuthError) {
|
|
1360
|
-
if (response.status === 401) {
|
|
1361
|
-
this.config.onAuthError({
|
|
1362
|
-
status: response.status,
|
|
1363
|
-
message: "Authentication failed",
|
|
1364
|
-
response: responseData,
|
|
1365
|
-
errorType: "expired",
|
|
1366
|
-
afterRetry: isRetry
|
|
1367
|
-
});
|
|
1368
|
-
} else if (response.status === 403) {
|
|
1369
|
-
this.config.onAuthError({
|
|
1370
|
-
status: response.status,
|
|
1371
|
-
message: responseData.message || "Forbidden - insufficient permissions or missing scope",
|
|
1372
|
-
response: responseData,
|
|
1373
|
-
errorType: "forbidden",
|
|
1374
|
-
afterRetry: isRetry
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
const errorMessage = responseData.message || responseData.error || responseData.detail || (typeof responseData === "string" ? responseData : `API request failed: ${response.status}`);
|
|
1379
|
-
throw new RemoteDBError(errorMessage, response.status, responseData);
|
|
1380
|
-
}
|
|
1381
|
-
this.log("Response:", responseData);
|
|
1382
|
-
return responseData;
|
|
1061
|
+
const didRes = await fetch(url);
|
|
1062
|
+
if (!didRes.ok) {
|
|
1063
|
+
throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`);
|
|
1383
1064
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
throw new Error(result.message || "Data validation failed");
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1065
|
+
const didDocument = await didRes.json();
|
|
1066
|
+
return resolveFromDocument(did, didDocument);
|
|
1067
|
+
}
|
|
1068
|
+
async function resolveHandle(handle) {
|
|
1069
|
+
const res = await fetch(`https://${handle}/.well-known/did.json`);
|
|
1070
|
+
if (!res.ok) {
|
|
1071
|
+
throw new Error(`Handle resolution failed for ${handle}: ${res.status}`);
|
|
1394
1072
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
return `/account/${this.config.projectId}/db/${this.tableName}`;
|
|
1400
|
-
}
|
|
1401
|
-
/**
|
|
1402
|
-
* Add a new record to the collection
|
|
1403
|
-
* The server generates the ID
|
|
1404
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1405
|
-
*/
|
|
1406
|
-
async add(data) {
|
|
1407
|
-
this.validateData(data, true);
|
|
1408
|
-
try {
|
|
1409
|
-
const result = await this.request(
|
|
1410
|
-
"POST",
|
|
1411
|
-
this.basePath,
|
|
1412
|
-
{ value: data }
|
|
1413
|
-
);
|
|
1414
|
-
return result.data;
|
|
1415
|
-
} catch (error) {
|
|
1416
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1417
|
-
throw new NotAuthenticatedError("Sign in required to add items");
|
|
1418
|
-
}
|
|
1419
|
-
throw error;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
/**
|
|
1423
|
-
* Put (upsert) a record - requires id
|
|
1424
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1425
|
-
*/
|
|
1426
|
-
async put(data) {
|
|
1427
|
-
if (!data.id) {
|
|
1428
|
-
throw new Error("put() requires an id field");
|
|
1429
|
-
}
|
|
1430
|
-
const { id, ...rest } = data;
|
|
1431
|
-
this.validateData(rest, true);
|
|
1432
|
-
try {
|
|
1433
|
-
const result = await this.request(
|
|
1434
|
-
"PUT",
|
|
1435
|
-
`${this.basePath}/${id}`,
|
|
1436
|
-
{ value: rest }
|
|
1437
|
-
);
|
|
1438
|
-
return result.data || data;
|
|
1439
|
-
} catch (error) {
|
|
1440
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1441
|
-
throw new NotAuthenticatedError("Sign in required to update items");
|
|
1442
|
-
}
|
|
1443
|
-
throw error;
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
/**
|
|
1447
|
-
* Update an existing record by id
|
|
1448
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1449
|
-
*/
|
|
1450
|
-
async update(id, data) {
|
|
1451
|
-
if (!id) {
|
|
1452
|
-
throw new Error("update() requires an id");
|
|
1453
|
-
}
|
|
1454
|
-
this.validateData(data, false);
|
|
1455
|
-
try {
|
|
1456
|
-
const result = await this.request(
|
|
1457
|
-
"PATCH",
|
|
1458
|
-
`${this.basePath}/${id}`,
|
|
1459
|
-
{ value: data }
|
|
1460
|
-
);
|
|
1461
|
-
return result.data || null;
|
|
1462
|
-
} catch (error) {
|
|
1463
|
-
if (error instanceof RemoteDBError && error.status === 404) {
|
|
1464
|
-
return null;
|
|
1465
|
-
}
|
|
1466
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1467
|
-
throw new NotAuthenticatedError("Sign in required to update items");
|
|
1468
|
-
}
|
|
1469
|
-
throw error;
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
/**
|
|
1473
|
-
* Delete a record by id
|
|
1474
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1475
|
-
*/
|
|
1476
|
-
async delete(id) {
|
|
1477
|
-
if (!id) {
|
|
1478
|
-
throw new Error("delete() requires an id");
|
|
1479
|
-
}
|
|
1480
|
-
try {
|
|
1481
|
-
await this.request(
|
|
1482
|
-
"DELETE",
|
|
1483
|
-
`${this.basePath}/${id}`
|
|
1484
|
-
);
|
|
1485
|
-
return true;
|
|
1486
|
-
} catch (error) {
|
|
1487
|
-
if (error instanceof RemoteDBError && error.status === 404) {
|
|
1488
|
-
return false;
|
|
1489
|
-
}
|
|
1490
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1491
|
-
throw new NotAuthenticatedError("Sign in required to delete items");
|
|
1492
|
-
}
|
|
1493
|
-
throw error;
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
/**
|
|
1497
|
-
* Get a single record by id
|
|
1498
|
-
* Returns null if not authenticated (graceful degradation for read operations)
|
|
1499
|
-
*/
|
|
1500
|
-
async get(id) {
|
|
1501
|
-
if (!id) {
|
|
1502
|
-
throw new Error("get() requires an id");
|
|
1503
|
-
}
|
|
1504
|
-
try {
|
|
1505
|
-
const result = await this.request(
|
|
1506
|
-
"GET",
|
|
1507
|
-
`${this.basePath}?id=${id}`
|
|
1508
|
-
);
|
|
1509
|
-
return result.data?.[0] || null;
|
|
1510
|
-
} catch (error) {
|
|
1511
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1512
|
-
this.log("Not authenticated - returning null for get()");
|
|
1513
|
-
}
|
|
1514
|
-
return null;
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
/**
|
|
1518
|
-
* Get all records in the collection
|
|
1519
|
-
* Returns empty array if not authenticated (graceful degradation for read operations)
|
|
1520
|
-
*/
|
|
1521
|
-
async getAll() {
|
|
1522
|
-
try {
|
|
1523
|
-
const result = await this.request(
|
|
1524
|
-
"GET",
|
|
1525
|
-
this.basePath
|
|
1526
|
-
);
|
|
1527
|
-
return result.data || [];
|
|
1528
|
-
} catch (error) {
|
|
1529
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1530
|
-
this.log("Not authenticated - returning empty array for getAll()");
|
|
1531
|
-
return [];
|
|
1532
|
-
}
|
|
1533
|
-
throw error;
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
/**
|
|
1537
|
-
* Filter records using a predicate function
|
|
1538
|
-
* Note: This fetches all records and filters client-side
|
|
1539
|
-
* Returns empty array if not authenticated (graceful degradation for read operations)
|
|
1540
|
-
*/
|
|
1541
|
-
async filter(fn) {
|
|
1542
|
-
const all = await this.getAll();
|
|
1543
|
-
return all.filter(fn);
|
|
1544
|
-
}
|
|
1545
|
-
/**
|
|
1546
|
-
* ref is not available for remote collections
|
|
1547
|
-
*/
|
|
1548
|
-
ref = void 0;
|
|
1549
|
-
};
|
|
1550
|
-
|
|
1551
|
-
// src/core/db/RemoteDB.ts
|
|
1552
|
-
var RemoteDB = class {
|
|
1553
|
-
config;
|
|
1554
|
-
collections = /* @__PURE__ */ new Map();
|
|
1555
|
-
constructor(config) {
|
|
1556
|
-
this.config = config;
|
|
1557
|
-
}
|
|
1558
|
-
/**
|
|
1559
|
-
* Get a collection by name
|
|
1560
|
-
* Collections are cached for reuse
|
|
1561
|
-
*/
|
|
1562
|
-
collection(name) {
|
|
1563
|
-
if (this.collections.has(name)) {
|
|
1564
|
-
return this.collections.get(name);
|
|
1565
|
-
}
|
|
1566
|
-
if (this.config.schema?.tables && !this.config.schema.tables[name]) {
|
|
1567
|
-
throw new Error(`Table "${name}" not found in schema`);
|
|
1568
|
-
}
|
|
1569
|
-
const collection = new RemoteCollection(name, this.config);
|
|
1570
|
-
this.collections.set(name, collection);
|
|
1571
|
-
return collection;
|
|
1572
|
-
}
|
|
1573
|
-
};
|
|
1574
|
-
|
|
1575
|
-
// src/core/auth/AuthManager.ts
|
|
1576
|
-
var import_jwt_decode = require("jwt-decode");
|
|
1577
|
-
|
|
1578
|
-
// src/utils/storage.ts
|
|
1579
|
-
var LocalStorageAdapter = class {
|
|
1580
|
-
async get(key) {
|
|
1581
|
-
return localStorage.getItem(key);
|
|
1582
|
-
}
|
|
1583
|
-
async set(key, value) {
|
|
1584
|
-
localStorage.setItem(key, value);
|
|
1585
|
-
}
|
|
1586
|
-
async remove(key) {
|
|
1587
|
-
localStorage.removeItem(key);
|
|
1588
|
-
}
|
|
1589
|
-
};
|
|
1590
|
-
var STORAGE_KEYS = {
|
|
1591
|
-
REFRESH_TOKEN: "basic_refresh_token",
|
|
1592
|
-
USER_INFO: "basic_user_info",
|
|
1593
|
-
AUTH_STATE: "basic_auth_state",
|
|
1594
|
-
REDIRECT_URI: "basic_redirect_uri",
|
|
1595
|
-
SERVER_URL: "basic_server_url",
|
|
1596
|
-
PDS_ENDPOINTS: "basic_pds_endpoints",
|
|
1597
|
-
LAST_CONNECT_REPORT: "basic_last_connect_report",
|
|
1598
|
-
DEBUG: "basic_debug",
|
|
1599
|
-
CODE_VERIFIER: "basic_code_verifier"
|
|
1600
|
-
};
|
|
1601
|
-
|
|
1602
|
-
// src/utils/normalizeClientId.ts
|
|
1603
|
-
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1604
|
-
function normalizeClientId(projectId, adminHostname = "api.basic.tech") {
|
|
1605
|
-
if (!projectId) return projectId;
|
|
1606
|
-
if (projectId === "self") return projectId;
|
|
1607
|
-
if (projectId.startsWith("did:")) return projectId;
|
|
1608
|
-
if (UUID_RE.test(projectId)) {
|
|
1609
|
-
const hex = projectId.replace(/-/g, "").toLowerCase();
|
|
1610
|
-
return `did:web:${adminHostname}:projects:${hex}`;
|
|
1611
|
-
}
|
|
1612
|
-
return projectId;
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
// src/utils/resolveDid.ts
|
|
1616
|
-
function resolveDidWebUrl(did) {
|
|
1617
|
-
if (!did.startsWith("did:web:")) return null;
|
|
1618
|
-
const rest = did.slice(8);
|
|
1619
|
-
if (!rest) return null;
|
|
1620
|
-
const parts = rest.split(":");
|
|
1621
|
-
const hostname = parts[0].replace(/%3A/gi, ":");
|
|
1622
|
-
if (parts.length === 1) {
|
|
1623
|
-
return `https://${hostname}/.well-known/did.json`;
|
|
1624
|
-
}
|
|
1625
|
-
const pathParts = parts.slice(1).map((p) => decodeURIComponent(p));
|
|
1626
|
-
return `https://${hostname}/${pathParts.join("/")}/did.json`;
|
|
1627
|
-
}
|
|
1628
|
-
async function resolveFromDocument(did, didDocument) {
|
|
1629
|
-
const services = didDocument.service;
|
|
1630
|
-
const pdsService = services?.find(
|
|
1631
|
-
(s) => s.id === "#basic_pds" || s.id === `${did}#basic_pds`
|
|
1632
|
-
);
|
|
1633
|
-
if (!pdsService) {
|
|
1634
|
-
throw new Error(`DID document has no #basic_pds service entry`);
|
|
1635
|
-
}
|
|
1636
|
-
const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, "");
|
|
1637
|
-
const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`);
|
|
1638
|
-
if (!oauthRes.ok) {
|
|
1639
|
-
throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`);
|
|
1640
|
-
}
|
|
1641
|
-
const oauth = await oauthRes.json();
|
|
1642
|
-
return {
|
|
1643
|
-
did,
|
|
1644
|
-
didDocument,
|
|
1645
|
-
pdsUrl,
|
|
1646
|
-
authorization_endpoint: oauth.authorization_endpoint,
|
|
1647
|
-
token_endpoint: oauth.token_endpoint,
|
|
1648
|
-
userinfo_endpoint: oauth.userinfo_endpoint
|
|
1649
|
-
};
|
|
1650
|
-
}
|
|
1651
|
-
async function resolveDid(did) {
|
|
1652
|
-
const url = resolveDidWebUrl(did);
|
|
1653
|
-
if (!url) {
|
|
1654
|
-
throw new Error(`Unsupported DID method: ${did}`);
|
|
1655
|
-
}
|
|
1656
|
-
const didRes = await fetch(url);
|
|
1657
|
-
if (!didRes.ok) {
|
|
1658
|
-
throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`);
|
|
1659
|
-
}
|
|
1660
|
-
const didDocument = await didRes.json();
|
|
1661
|
-
return resolveFromDocument(did, didDocument);
|
|
1662
|
-
}
|
|
1663
|
-
async function resolveHandle(handle) {
|
|
1664
|
-
const res = await fetch(`https://${handle}/.well-known/did.json`);
|
|
1665
|
-
if (!res.ok) {
|
|
1666
|
-
throw new Error(`Handle resolution failed for ${handle}: ${res.status}`);
|
|
1667
|
-
}
|
|
1668
|
-
const didDocument = await res.json();
|
|
1669
|
-
const did = didDocument.id;
|
|
1670
|
-
if (!did) {
|
|
1671
|
-
throw new Error(`Handle response has no 'id' field`);
|
|
1073
|
+
const didDocument = await res.json();
|
|
1074
|
+
const did = didDocument.id;
|
|
1075
|
+
if (!did) {
|
|
1076
|
+
throw new Error(`Handle response has no 'id' field`);
|
|
1672
1077
|
}
|
|
1673
1078
|
const resolved = await resolveFromDocument(did, didDocument);
|
|
1674
1079
|
resolved.handle = handle;
|
|
@@ -1678,6 +1083,21 @@ async function resolveHandle(handle) {
|
|
|
1678
1083
|
// src/core/auth/AuthManager.ts
|
|
1679
1084
|
init_network();
|
|
1680
1085
|
init_config();
|
|
1086
|
+
var DEFINITIVE_TOKEN_ERRORS = /* @__PURE__ */ new Set([
|
|
1087
|
+
"invalid_grant",
|
|
1088
|
+
"invalid_client",
|
|
1089
|
+
"unauthorized_client"
|
|
1090
|
+
]);
|
|
1091
|
+
var USER_RECOVERY_RETRY_COOLDOWN_MS = 3e4;
|
|
1092
|
+
var SESSION_RECONCILE_THROTTLE_MS = 5e3;
|
|
1093
|
+
var DefinitiveAuthError = class extends Error {
|
|
1094
|
+
code;
|
|
1095
|
+
constructor(code) {
|
|
1096
|
+
super(`Definitive auth failure: ${code}`);
|
|
1097
|
+
this.name = "DefinitiveAuthError";
|
|
1098
|
+
this.code = code;
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1681
1101
|
function generateCodeVerifier() {
|
|
1682
1102
|
const array = new Uint8Array(32);
|
|
1683
1103
|
crypto.getRandomValues(array);
|
|
@@ -1685,7 +1105,9 @@ function generateCodeVerifier() {
|
|
|
1685
1105
|
}
|
|
1686
1106
|
async function generateCodeChallenge(verifier) {
|
|
1687
1107
|
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
1688
|
-
log(
|
|
1108
|
+
log(
|
|
1109
|
+
"crypto.subtle unavailable (non-secure context?) -- falling back to plain PKCE challenge"
|
|
1110
|
+
);
|
|
1689
1111
|
return { challenge: verifier, method: "plain" };
|
|
1690
1112
|
}
|
|
1691
1113
|
const encoder = new TextEncoder();
|
|
@@ -1706,6 +1128,8 @@ var AuthManager = class {
|
|
|
1706
1128
|
user = null;
|
|
1707
1129
|
isSignedIn = false;
|
|
1708
1130
|
isAuthReady = false;
|
|
1131
|
+
authStatus = "bootstrapping";
|
|
1132
|
+
authErrorCode = null;
|
|
1709
1133
|
did = null;
|
|
1710
1134
|
/** Space-separated scopes granted in the current access token */
|
|
1711
1135
|
tokenScope = null;
|
|
@@ -1722,6 +1146,9 @@ var AuthManager = class {
|
|
|
1722
1146
|
pendingRefresh = false;
|
|
1723
1147
|
isOnline = typeof navigator !== "undefined" ? navigator.onLine : true;
|
|
1724
1148
|
channel = null;
|
|
1149
|
+
nextUserRecoveryAt = 0;
|
|
1150
|
+
sessionCheckPromise = null;
|
|
1151
|
+
lastSessionCheckAt = 0;
|
|
1725
1152
|
constructor(config, storage, notify) {
|
|
1726
1153
|
this.config = config;
|
|
1727
1154
|
this.storage = storage;
|
|
@@ -1736,30 +1163,22 @@ var AuthManager = class {
|
|
|
1736
1163
|
this.channel.onmessage = (event) => {
|
|
1737
1164
|
if (event.data?.type === "token_refreshed") {
|
|
1738
1165
|
log("Received token refresh from another tab");
|
|
1739
|
-
|
|
1740
|
-
this.token = { ...this.token, access_token: event.data.accessToken };
|
|
1741
|
-
}
|
|
1742
|
-
if (event.data.did) this.did = event.data.did;
|
|
1743
|
-
if (event.data.tokenScope) this.tokenScope = event.data.tokenScope;
|
|
1744
|
-
this.notify();
|
|
1166
|
+
void this.handleExternalTokenRefresh(event.data);
|
|
1745
1167
|
}
|
|
1746
1168
|
if (event.data?.type === "signed_in") {
|
|
1747
|
-
log("Received sign-in from another tab,
|
|
1748
|
-
|
|
1749
|
-
window.location.reload();
|
|
1750
|
-
}
|
|
1169
|
+
log("Received sign-in from another tab, restoring session");
|
|
1170
|
+
void this.restoreStoredSession("cross-tab sign-in");
|
|
1751
1171
|
}
|
|
1752
1172
|
if (event.data?.type === "signed_out") {
|
|
1753
|
-
log("Received sign-out from another tab
|
|
1754
|
-
this.
|
|
1755
|
-
this.isSignedIn = false;
|
|
1756
|
-
this.token = null;
|
|
1757
|
-
this.did = null;
|
|
1758
|
-
this.tokenScope = null;
|
|
1173
|
+
log("Received sign-out from another tab");
|
|
1174
|
+
this.resetAuthState("signed_out");
|
|
1759
1175
|
this.notify();
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1176
|
+
}
|
|
1177
|
+
if (event.data?.type === "session_invalidated") {
|
|
1178
|
+
log("Received session invalidation from another tab");
|
|
1179
|
+
void this.markReauthRequired(event.data.code || "invalid_grant", {
|
|
1180
|
+
broadcast: false
|
|
1181
|
+
});
|
|
1763
1182
|
}
|
|
1764
1183
|
};
|
|
1765
1184
|
} catch {
|
|
@@ -1780,6 +1199,9 @@ var AuthManager = class {
|
|
|
1780
1199
|
broadcastSignOut() {
|
|
1781
1200
|
this.channel?.postMessage({ type: "signed_out" });
|
|
1782
1201
|
}
|
|
1202
|
+
broadcastSessionInvalidated(code) {
|
|
1203
|
+
this.channel?.postMessage({ type: "session_invalidated", code });
|
|
1204
|
+
}
|
|
1783
1205
|
// ------------------------------------------------------------------
|
|
1784
1206
|
// Public API
|
|
1785
1207
|
// ------------------------------------------------------------------
|
|
@@ -1788,7 +1210,11 @@ var AuthManager = class {
|
|
|
1788
1210
|
* from refresh token, or load cached user for offline mode.
|
|
1789
1211
|
*/
|
|
1790
1212
|
async initialize() {
|
|
1791
|
-
|
|
1213
|
+
this.updateAuthStatus("bootstrapping");
|
|
1214
|
+
await this.storage.set(
|
|
1215
|
+
STORAGE_KEYS.DEBUG,
|
|
1216
|
+
this.config.debug ? "true" : "false"
|
|
1217
|
+
);
|
|
1792
1218
|
const storedServerUrl = await this.storage.get(STORAGE_KEYS.SERVER_URL);
|
|
1793
1219
|
if (storedServerUrl && storedServerUrl !== this.config.pdsUrl) {
|
|
1794
1220
|
log("PDS URL changed, clearing stored tokens");
|
|
@@ -1800,7 +1226,7 @@ var AuthManager = class {
|
|
|
1800
1226
|
if (params.has("code")) {
|
|
1801
1227
|
const code = params.get("code");
|
|
1802
1228
|
if (!code) {
|
|
1803
|
-
this.
|
|
1229
|
+
this.updateAuthStatus("signed_out");
|
|
1804
1230
|
this.notify();
|
|
1805
1231
|
return;
|
|
1806
1232
|
}
|
|
@@ -1808,7 +1234,7 @@ var AuthManager = class {
|
|
|
1808
1234
|
const urlState = params.get("state");
|
|
1809
1235
|
if (!state || state !== urlState) {
|
|
1810
1236
|
log("error: auth state does not match");
|
|
1811
|
-
this.
|
|
1237
|
+
this.updateAuthStatus("signed_out");
|
|
1812
1238
|
this.notify();
|
|
1813
1239
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
1814
1240
|
cleanOAuthParamsFromUrl();
|
|
@@ -1819,34 +1245,18 @@ var AuthManager = class {
|
|
|
1819
1245
|
this.freshSignIn = true;
|
|
1820
1246
|
this.exchangeToken(code, false).catch((error) => {
|
|
1821
1247
|
log("Error fetching token:", error);
|
|
1248
|
+
this.freshSignIn = false;
|
|
1249
|
+
void this.restoreCachedUser({
|
|
1250
|
+
hasRecoverableSession: !this.isDefinitiveAuthFailure(error)
|
|
1251
|
+
});
|
|
1822
1252
|
});
|
|
1823
1253
|
} else {
|
|
1824
|
-
|
|
1825
|
-
if (refreshToken) {
|
|
1826
|
-
log("Found refresh token in storage, attempting to refresh access token");
|
|
1827
|
-
this.exchangeToken(refreshToken, true).catch(async (error) => {
|
|
1828
|
-
log("Error fetching refresh token:", error);
|
|
1829
|
-
if (this.isNetworkError(error)) {
|
|
1830
|
-
await this.restoreCachedUser();
|
|
1831
|
-
}
|
|
1832
|
-
});
|
|
1833
|
-
} else {
|
|
1834
|
-
const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
1835
|
-
if (cachedUserInfo) {
|
|
1836
|
-
try {
|
|
1837
|
-
this.user = JSON.parse(cachedUserInfo);
|
|
1838
|
-
this.isSignedIn = true;
|
|
1839
|
-
log("Loaded cached user info for offline mode");
|
|
1840
|
-
} catch (error) {
|
|
1841
|
-
log("Error parsing cached user info:", error);
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
this.isAuthReady = true;
|
|
1845
|
-
this.notify();
|
|
1846
|
-
}
|
|
1254
|
+
await this.restoreStoredSession("initialize");
|
|
1847
1255
|
}
|
|
1848
1256
|
} catch (e) {
|
|
1849
1257
|
log("error getting token", e);
|
|
1258
|
+
this.updateAuthStatus("signed_out");
|
|
1259
|
+
this.notify();
|
|
1850
1260
|
}
|
|
1851
1261
|
}
|
|
1852
1262
|
/**
|
|
@@ -1856,7 +1266,7 @@ var AuthManager = class {
|
|
|
1856
1266
|
async getToken(options) {
|
|
1857
1267
|
log("getting token...");
|
|
1858
1268
|
if (!this.token) {
|
|
1859
|
-
const refreshToken = await this.
|
|
1269
|
+
const refreshToken = await this.getRefreshToken();
|
|
1860
1270
|
if (refreshToken) {
|
|
1861
1271
|
log("No token in memory, attempting to refresh from storage");
|
|
1862
1272
|
if (this.refreshPromise) {
|
|
@@ -1879,7 +1289,12 @@ var AuthManager = class {
|
|
|
1879
1289
|
} catch (error) {
|
|
1880
1290
|
log("Failed to refresh token from storage:", error);
|
|
1881
1291
|
if (this.isNetworkError(error)) {
|
|
1882
|
-
throw new Error(
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
"Network offline - authentication will be retried when online"
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1297
|
+
throw error;
|
|
1883
1298
|
}
|
|
1884
1299
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1885
1300
|
}
|
|
@@ -1892,12 +1307,15 @@ var AuthManager = class {
|
|
|
1892
1307
|
const isExpired = decoded.exp && decoded.exp < Date.now() / 1e3 + expirationBuffer;
|
|
1893
1308
|
const shouldRefresh = isExpired || options?.forceRefresh === true;
|
|
1894
1309
|
if (shouldRefresh) {
|
|
1895
|
-
log(
|
|
1310
|
+
log(
|
|
1311
|
+
options?.forceRefresh ? "force refreshing token..." : "token is expired - refreshing ..."
|
|
1312
|
+
);
|
|
1896
1313
|
if (this.refreshPromise) {
|
|
1897
1314
|
log("Token refresh already in progress, waiting...");
|
|
1898
1315
|
try {
|
|
1899
1316
|
const newToken = await this.refreshPromise;
|
|
1900
|
-
if (!newToken?.access_token)
|
|
1317
|
+
if (!newToken?.access_token)
|
|
1318
|
+
throw new Error("Token refresh returned empty access token");
|
|
1901
1319
|
return newToken.access_token;
|
|
1902
1320
|
} catch (error) {
|
|
1903
1321
|
log("In-flight refresh failed:", error);
|
|
@@ -1908,11 +1326,12 @@ var AuthManager = class {
|
|
|
1908
1326
|
throw error;
|
|
1909
1327
|
}
|
|
1910
1328
|
}
|
|
1911
|
-
const refreshToken =
|
|
1329
|
+
const refreshToken = await this.getRefreshToken();
|
|
1912
1330
|
if (refreshToken) {
|
|
1913
1331
|
try {
|
|
1914
1332
|
const newToken = await this.exchangeToken(refreshToken, true);
|
|
1915
|
-
if (!newToken?.access_token)
|
|
1333
|
+
if (!newToken?.access_token)
|
|
1334
|
+
throw new Error("Token refresh returned empty access token");
|
|
1916
1335
|
return newToken.access_token;
|
|
1917
1336
|
} catch (error) {
|
|
1918
1337
|
log("Failed to refresh expired token:", error);
|
|
@@ -1920,13 +1339,17 @@ var AuthManager = class {
|
|
|
1920
1339
|
log("Network issue - using expired token until network is restored");
|
|
1921
1340
|
return this.token.access_token;
|
|
1922
1341
|
}
|
|
1342
|
+
if (!this.isDefinitiveAuthFailure(error)) {
|
|
1343
|
+
throw error;
|
|
1344
|
+
}
|
|
1923
1345
|
throw new Error("Authentication expired. Please sign in again.");
|
|
1924
1346
|
}
|
|
1925
1347
|
} else {
|
|
1926
1348
|
throw new Error("no refresh token available");
|
|
1927
1349
|
}
|
|
1928
1350
|
}
|
|
1929
|
-
if (!this.token.access_token)
|
|
1351
|
+
if (!this.token.access_token)
|
|
1352
|
+
throw new Error("Token exists but access_token is empty");
|
|
1930
1353
|
return this.token.access_token;
|
|
1931
1354
|
}
|
|
1932
1355
|
async getSignInUrl(redirectUri, endpoints) {
|
|
@@ -1935,8 +1358,13 @@ var AuthManager = class {
|
|
|
1935
1358
|
throw new Error("Project ID is required to generate sign-in link");
|
|
1936
1359
|
}
|
|
1937
1360
|
const pdsEndpoints = endpoints || this.defaultPdsEndpoints();
|
|
1938
|
-
await this.storage.set(
|
|
1939
|
-
|
|
1361
|
+
await this.storage.set(
|
|
1362
|
+
STORAGE_KEYS.PDS_ENDPOINTS,
|
|
1363
|
+
JSON.stringify(pdsEndpoints)
|
|
1364
|
+
);
|
|
1365
|
+
const randomState = base64UrlEncode(
|
|
1366
|
+
crypto.getRandomValues(new Uint8Array(16))
|
|
1367
|
+
);
|
|
1940
1368
|
await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState);
|
|
1941
1369
|
const redirectUrl = redirectUri || window.location.href;
|
|
1942
1370
|
if (!redirectUrl || !redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
|
|
@@ -2005,7 +1433,10 @@ var AuthManager = class {
|
|
|
2005
1433
|
if (state) {
|
|
2006
1434
|
const storedState = await this.storage.get(STORAGE_KEYS.AUTH_STATE);
|
|
2007
1435
|
if (storedState && storedState !== state) {
|
|
2008
|
-
log("State parameter mismatch:", {
|
|
1436
|
+
log("State parameter mismatch:", {
|
|
1437
|
+
provided: state,
|
|
1438
|
+
stored: storedState
|
|
1439
|
+
});
|
|
2009
1440
|
return { success: false, error: "State parameter mismatch" };
|
|
2010
1441
|
}
|
|
2011
1442
|
}
|
|
@@ -2021,6 +1452,7 @@ var AuthManager = class {
|
|
|
2021
1452
|
}
|
|
2022
1453
|
} catch (error) {
|
|
2023
1454
|
log("signInWithCode error:", error);
|
|
1455
|
+
this.freshSignIn = false;
|
|
2024
1456
|
return {
|
|
2025
1457
|
success: false,
|
|
2026
1458
|
error: error.message || "Authentication failed"
|
|
@@ -2028,18 +1460,95 @@ var AuthManager = class {
|
|
|
2028
1460
|
}
|
|
2029
1461
|
}
|
|
2030
1462
|
/**
|
|
2031
|
-
*
|
|
2032
|
-
*
|
|
1463
|
+
* Sign out: revoke the session server-side (`POST /auth/logout`, best
|
|
1464
|
+
* effort), then clear auth state and storage. Does NOT handle sync/DB
|
|
1465
|
+
* cleanup — the client layer wraps this to add sync teardown.
|
|
2033
1466
|
*/
|
|
2034
1467
|
async signOut() {
|
|
2035
1468
|
log("signing out!");
|
|
2036
|
-
this.
|
|
1469
|
+
await this.revokeSessionOnServer();
|
|
1470
|
+
this.resetAuthState("signed_out");
|
|
2037
1471
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
2038
1472
|
await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
|
|
2039
1473
|
await this.clearStoredAuth();
|
|
2040
1474
|
this.broadcastSignOut();
|
|
2041
1475
|
this.notify();
|
|
2042
1476
|
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Best-effort server-side revocation of the current device/session and
|
|
1479
|
+
* its refresh chain (Step 2 auth: logout is finally server-side).
|
|
1480
|
+
* Never blocks or fails the local sign-out.
|
|
1481
|
+
*/
|
|
1482
|
+
async revokeSessionOnServer() {
|
|
1483
|
+
try {
|
|
1484
|
+
let accessToken = null;
|
|
1485
|
+
try {
|
|
1486
|
+
accessToken = await this.getToken();
|
|
1487
|
+
} catch {
|
|
1488
|
+
accessToken = this.token?.access_token ?? null;
|
|
1489
|
+
}
|
|
1490
|
+
if (!accessToken) return;
|
|
1491
|
+
const endpoints = await this.getActivePdsEndpoints();
|
|
1492
|
+
await fetch(`${endpoints.pds_url}/auth/logout`, {
|
|
1493
|
+
method: "POST",
|
|
1494
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1495
|
+
});
|
|
1496
|
+
log("Server-side logout succeeded");
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
log("Server-side logout failed (non-blocking):", error);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
async reconcileSession(reason = "manual", options) {
|
|
1502
|
+
if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (!this.isOnline) {
|
|
1506
|
+
this.updateAuthStatus("recovering", this.authErrorCode);
|
|
1507
|
+
this.notify();
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
const throttleMs = options?.throttleMs ?? SESSION_RECONCILE_THROTTLE_MS;
|
|
1511
|
+
const forceRefresh = options?.forceRefresh === true;
|
|
1512
|
+
const now = Date.now();
|
|
1513
|
+
if (this.sessionCheckPromise) {
|
|
1514
|
+
return this.sessionCheckPromise;
|
|
1515
|
+
}
|
|
1516
|
+
if (!forceRefresh && now - this.lastSessionCheckAt < throttleMs) {
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
this.lastSessionCheckAt = now;
|
|
1520
|
+
let sessionCheck = null;
|
|
1521
|
+
sessionCheck = (async () => {
|
|
1522
|
+
try {
|
|
1523
|
+
const accessToken = await this.getToken(
|
|
1524
|
+
forceRefresh ? { forceRefresh: true } : void 0
|
|
1525
|
+
);
|
|
1526
|
+
const currentSession = await this.fetchCurrentSession(accessToken);
|
|
1527
|
+
if (currentSession?.active) {
|
|
1528
|
+
this.updateAuthStatus("authenticated");
|
|
1529
|
+
this.notify();
|
|
1530
|
+
if (!this.user) {
|
|
1531
|
+
await this.recoverMissingUserProfile(reason, accessToken);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
log(`Session reconciliation failed on ${reason}:`, error);
|
|
1536
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (this.isNetworkError(error)) {
|
|
1540
|
+
this.updateAuthStatus("recovering", this.authErrorCode);
|
|
1541
|
+
this.notify();
|
|
1542
|
+
}
|
|
1543
|
+
} finally {
|
|
1544
|
+
if (this.sessionCheckPromise === sessionCheck) {
|
|
1545
|
+
this.sessionCheckPromise = null;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
})();
|
|
1549
|
+
this.sessionCheckPromise = sessionCheck;
|
|
1550
|
+
return sessionCheck;
|
|
1551
|
+
}
|
|
2043
1552
|
hasScope(scope) {
|
|
2044
1553
|
if (!this.tokenScope) return false;
|
|
2045
1554
|
return this.tokenScope.split(/[\s,]+/).filter(Boolean).includes(scope);
|
|
@@ -2065,16 +1574,26 @@ var AuthManager = class {
|
|
|
2065
1574
|
const handleOnline = async () => {
|
|
2066
1575
|
log("Network came back online");
|
|
2067
1576
|
this.isOnline = true;
|
|
2068
|
-
if (this.pendingRefresh
|
|
1577
|
+
if (this.pendingRefresh) {
|
|
2069
1578
|
log("Retrying pending token refresh");
|
|
2070
1579
|
this.pendingRefresh = false;
|
|
2071
|
-
const refreshToken =
|
|
1580
|
+
const refreshToken = await this.getRefreshToken();
|
|
2072
1581
|
if (refreshToken) {
|
|
2073
1582
|
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
2074
1583
|
log("Retry refresh failed:", error);
|
|
2075
1584
|
});
|
|
2076
1585
|
}
|
|
2077
1586
|
}
|
|
1587
|
+
if (this.isSignedIn) {
|
|
1588
|
+
this.reconcileSession("online event", {
|
|
1589
|
+
forceRefresh: true,
|
|
1590
|
+
throttleMs: 0
|
|
1591
|
+
}).catch((error) => {
|
|
1592
|
+
log("Session reconciliation on online failed:", error);
|
|
1593
|
+
});
|
|
1594
|
+
} else if (this.user) {
|
|
1595
|
+
await this.restoreStoredSession("online restore");
|
|
1596
|
+
}
|
|
2078
1597
|
};
|
|
2079
1598
|
const handleOffline = () => {
|
|
2080
1599
|
log("Network went offline");
|
|
@@ -2082,9 +1601,11 @@ var AuthManager = class {
|
|
|
2082
1601
|
};
|
|
2083
1602
|
const handleVisibilityChange = () => {
|
|
2084
1603
|
if (document.visibilityState === "visible" && this.isSignedIn) {
|
|
2085
|
-
log("App became visible -
|
|
2086
|
-
this.
|
|
2087
|
-
|
|
1604
|
+
log("App became visible - reconciling auth session");
|
|
1605
|
+
this.reconcileSession("visibility resume", {
|
|
1606
|
+
forceRefresh: true
|
|
1607
|
+
}).catch((err) => {
|
|
1608
|
+
log("Session reconciliation on visibility resume failed:", err);
|
|
2088
1609
|
});
|
|
2089
1610
|
}
|
|
2090
1611
|
};
|
|
@@ -2137,12 +1658,18 @@ var AuthManager = class {
|
|
|
2137
1658
|
if (elapsed < 24 * 60 * 60 * 1e3) return;
|
|
2138
1659
|
}
|
|
2139
1660
|
try {
|
|
2140
|
-
await fetch(
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
1661
|
+
await fetch(
|
|
1662
|
+
`${this.config.adminUrl}/project/${this.config.projectId}/user/connect`,
|
|
1663
|
+
{
|
|
1664
|
+
method: "POST",
|
|
1665
|
+
headers: { "Content-Type": "application/json" },
|
|
1666
|
+
body: JSON.stringify({ token: accessToken })
|
|
1667
|
+
}
|
|
1668
|
+
);
|
|
1669
|
+
await this.storage.set(
|
|
1670
|
+
STORAGE_KEYS.LAST_CONNECT_REPORT,
|
|
1671
|
+
Date.now().toString()
|
|
1672
|
+
);
|
|
2146
1673
|
log("Reported connection to admin server");
|
|
2147
1674
|
} catch (err) {
|
|
2148
1675
|
log("Failed to report connection (non-blocking):", err);
|
|
@@ -2153,32 +1680,37 @@ var AuthManager = class {
|
|
|
2153
1680
|
*/
|
|
2154
1681
|
async processNewToken() {
|
|
2155
1682
|
if (!this.token) {
|
|
2156
|
-
this.
|
|
1683
|
+
this.updateAuthStatus("signed_out");
|
|
2157
1684
|
this.notify();
|
|
2158
1685
|
return;
|
|
2159
1686
|
}
|
|
2160
1687
|
try {
|
|
2161
1688
|
const decoded = (0, import_jwt_decode.jwtDecode)(this.token.access_token);
|
|
2162
|
-
|
|
2163
|
-
|
|
1689
|
+
this.applyTokenClaims(decoded);
|
|
1690
|
+
this.updateAuthStatus("authenticated");
|
|
1691
|
+
this.notify();
|
|
1692
|
+
this.broadcastSessionUpdate();
|
|
2164
1693
|
await this.fetchUser(this.token.access_token);
|
|
2165
1694
|
} catch (error) {
|
|
2166
1695
|
log("Error processing token:", error);
|
|
2167
|
-
this.
|
|
1696
|
+
this.updateAuthStatus("recovering");
|
|
2168
1697
|
this.notify();
|
|
2169
1698
|
}
|
|
2170
1699
|
}
|
|
2171
|
-
async restoreCachedUser() {
|
|
1700
|
+
async restoreCachedUser(options) {
|
|
2172
1701
|
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2173
|
-
if (cached) {
|
|
1702
|
+
if (cached && options?.hasRecoverableSession) {
|
|
2174
1703
|
try {
|
|
2175
1704
|
this.user = JSON.parse(cached);
|
|
2176
|
-
|
|
2177
|
-
log("Restored cached user info for offline mode");
|
|
1705
|
+
log("Restored cached user info for recoverable session");
|
|
2178
1706
|
} catch {
|
|
2179
1707
|
}
|
|
1708
|
+
} else {
|
|
1709
|
+
this.user = null;
|
|
2180
1710
|
}
|
|
2181
|
-
this.
|
|
1711
|
+
this.updateAuthStatus(
|
|
1712
|
+
options?.hasRecoverableSession ? "recovering" : "signed_out"
|
|
1713
|
+
);
|
|
2182
1714
|
this.notify();
|
|
2183
1715
|
}
|
|
2184
1716
|
async fetchUser(accessToken) {
|
|
@@ -2187,7 +1719,7 @@ var AuthManager = class {
|
|
|
2187
1719
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2188
1720
|
const response = await fetch(endpoints.userinfo_endpoint, {
|
|
2189
1721
|
method: "GET",
|
|
2190
|
-
headers: {
|
|
1722
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2191
1723
|
});
|
|
2192
1724
|
if (!response.ok) {
|
|
2193
1725
|
throw new Error(`Failed to fetch user info: ${response.status}`);
|
|
@@ -2198,28 +1730,22 @@ var AuthManager = class {
|
|
|
2198
1730
|
throw new Error(`User info error: ${user.error}`);
|
|
2199
1731
|
}
|
|
2200
1732
|
if (this.token?.refresh_token) {
|
|
2201
|
-
await this.storage.set(
|
|
1733
|
+
await this.storage.set(
|
|
1734
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
1735
|
+
this.token.refresh_token
|
|
1736
|
+
);
|
|
2202
1737
|
}
|
|
2203
1738
|
await this.storage.set(STORAGE_KEYS.USER_INFO, JSON.stringify(user));
|
|
2204
1739
|
log("Cached user info in storage");
|
|
2205
1740
|
this.user = user;
|
|
2206
|
-
this.
|
|
2207
|
-
|
|
2208
|
-
if (this.freshSignIn) {
|
|
2209
|
-
this.freshSignIn = false;
|
|
2210
|
-
this.broadcastSignIn();
|
|
2211
|
-
} else {
|
|
2212
|
-
this.broadcastTokenRefresh();
|
|
1741
|
+
if (this.authStatus !== "reauth_required") {
|
|
1742
|
+
this.updateAuthStatus("authenticated");
|
|
2213
1743
|
}
|
|
1744
|
+
this.nextUserRecoveryAt = 0;
|
|
2214
1745
|
this.notify();
|
|
2215
1746
|
} catch (error) {
|
|
2216
1747
|
log("Failed to fetch user info:", error);
|
|
2217
|
-
|
|
2218
|
-
await this.restoreCachedUser();
|
|
2219
|
-
} else {
|
|
2220
|
-
this.isAuthReady = true;
|
|
2221
|
-
this.notify();
|
|
2222
|
-
}
|
|
1748
|
+
await this.handleUserFetchFailure();
|
|
2223
1749
|
}
|
|
2224
1750
|
}
|
|
2225
1751
|
/**
|
|
@@ -2246,7 +1772,9 @@ var AuthManager = class {
|
|
|
2246
1772
|
if (!this.isOnline) {
|
|
2247
1773
|
log("Network is offline, marking refresh as pending");
|
|
2248
1774
|
this.pendingRefresh = true;
|
|
2249
|
-
throw new Error(
|
|
1775
|
+
throw new Error(
|
|
1776
|
+
"Network offline - refresh will be retried when online"
|
|
1777
|
+
);
|
|
2250
1778
|
}
|
|
2251
1779
|
const endpoints = await this.getActivePdsEndpoints();
|
|
2252
1780
|
let requestBody;
|
|
@@ -2256,26 +1784,36 @@ var AuthManager = class {
|
|
|
2256
1784
|
refresh_token: codeOrRefreshToken
|
|
2257
1785
|
};
|
|
2258
1786
|
if (this.config.projectId) {
|
|
2259
|
-
requestBody.client_id = normalizeClientId(
|
|
1787
|
+
requestBody.client_id = normalizeClientId(
|
|
1788
|
+
this.config.projectId,
|
|
1789
|
+
this.adminHostname
|
|
1790
|
+
);
|
|
2260
1791
|
}
|
|
2261
1792
|
} else {
|
|
2262
1793
|
requestBody = {
|
|
2263
1794
|
grant_type: "authorization_code",
|
|
2264
1795
|
code: codeOrRefreshToken
|
|
2265
1796
|
};
|
|
2266
|
-
const storedRedirectUri = await this.storage.get(
|
|
1797
|
+
const storedRedirectUri = await this.storage.get(
|
|
1798
|
+
STORAGE_KEYS.REDIRECT_URI
|
|
1799
|
+
);
|
|
2267
1800
|
if (storedRedirectUri) {
|
|
2268
1801
|
requestBody.redirect_uri = storedRedirectUri;
|
|
2269
1802
|
log("Including redirect_uri in token exchange:", storedRedirectUri);
|
|
2270
1803
|
} else {
|
|
2271
1804
|
log("Warning: No redirect_uri found in storage for token exchange");
|
|
2272
1805
|
}
|
|
2273
|
-
const codeVerifier = await this.storage.get(
|
|
1806
|
+
const codeVerifier = await this.storage.get(
|
|
1807
|
+
STORAGE_KEYS.CODE_VERIFIER
|
|
1808
|
+
);
|
|
2274
1809
|
if (codeVerifier) {
|
|
2275
1810
|
requestBody.code_verifier = codeVerifier;
|
|
2276
1811
|
}
|
|
2277
1812
|
if (this.config.projectId) {
|
|
2278
|
-
requestBody.client_id = normalizeClientId(
|
|
1813
|
+
requestBody.client_id = normalizeClientId(
|
|
1814
|
+
this.config.projectId,
|
|
1815
|
+
this.adminHostname
|
|
1816
|
+
);
|
|
2279
1817
|
}
|
|
2280
1818
|
}
|
|
2281
1819
|
log("Token exchange request body:", {
|
|
@@ -2283,56 +1821,83 @@ var AuthManager = class {
|
|
|
2283
1821
|
...isRefreshToken ? { refresh_token: "[REDACTED]" } : { code: "[REDACTED]" },
|
|
2284
1822
|
...requestBody.code_verifier ? { code_verifier: "[REDACTED]" } : {}
|
|
2285
1823
|
});
|
|
2286
|
-
const
|
|
1824
|
+
const response = await fetch(endpoints.token_endpoint, {
|
|
2287
1825
|
method: "POST",
|
|
2288
1826
|
headers: { "Content-Type": "application/json" },
|
|
2289
1827
|
body: JSON.stringify(requestBody)
|
|
2290
|
-
}).
|
|
1828
|
+
}).catch((error) => {
|
|
2291
1829
|
log("Network error fetching token:", error);
|
|
2292
1830
|
if (!this.isOnline) {
|
|
2293
1831
|
this.pendingRefresh = true;
|
|
2294
|
-
throw new Error(
|
|
1832
|
+
throw new Error(
|
|
1833
|
+
"Network offline - refresh will be retried when online"
|
|
1834
|
+
);
|
|
2295
1835
|
}
|
|
2296
1836
|
throw new Error("Network error during token refresh");
|
|
2297
1837
|
});
|
|
1838
|
+
if (response.status === 429) {
|
|
1839
|
+
log("Token endpoint rate limited (429) - will retry later");
|
|
1840
|
+
this.pendingRefresh = true;
|
|
1841
|
+
throw new Error(
|
|
1842
|
+
"Token endpoint rate limited - refresh will be retried"
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
const token = await response.json().catch(() => {
|
|
1846
|
+
throw new Error(
|
|
1847
|
+
`Token endpoint returned invalid JSON (status ${response.status})`
|
|
1848
|
+
);
|
|
1849
|
+
});
|
|
2298
1850
|
if (token.access_token) {
|
|
2299
1851
|
try {
|
|
2300
1852
|
const decoded = (0, import_jwt_decode.jwtDecode)(token.access_token);
|
|
2301
1853
|
if (decoded.typ === "refresh") {
|
|
2302
1854
|
log("Error: received refresh token as access token");
|
|
2303
|
-
throw new Error(
|
|
1855
|
+
throw new Error(
|
|
1856
|
+
"Invalid token: received refresh token instead of access token"
|
|
1857
|
+
);
|
|
2304
1858
|
}
|
|
2305
1859
|
} catch (decodeError) {
|
|
2306
1860
|
if (decodeError.message.includes("Invalid token")) {
|
|
2307
1861
|
throw decodeError;
|
|
2308
1862
|
}
|
|
2309
|
-
log(
|
|
1863
|
+
log(
|
|
1864
|
+
"Warning: could not decode access token for type check:",
|
|
1865
|
+
decodeError
|
|
1866
|
+
);
|
|
2310
1867
|
}
|
|
2311
1868
|
}
|
|
2312
1869
|
if (token.error) {
|
|
2313
1870
|
log("error fetching token", token.error);
|
|
2314
1871
|
if (typeof token.error === "string" && (token.error.includes("network") || token.error.includes("timeout"))) {
|
|
2315
1872
|
this.pendingRefresh = true;
|
|
2316
|
-
throw new Error(
|
|
1873
|
+
throw new Error(
|
|
1874
|
+
"Network issue - refresh will be retried when online"
|
|
1875
|
+
);
|
|
2317
1876
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
this.resetAuthState();
|
|
2322
|
-
this.notify();
|
|
1877
|
+
if (this.isDefinitiveTokenErrorCode(token.error)) {
|
|
1878
|
+
await this.markReauthRequired(token.error);
|
|
1879
|
+
throw new DefinitiveAuthError(token.error);
|
|
2323
1880
|
}
|
|
2324
1881
|
throw new Error(`Token refresh failed: ${token.error}`);
|
|
2325
1882
|
} else {
|
|
1883
|
+
if (!token.access_token) {
|
|
1884
|
+
throw new Error("Token response missing access token");
|
|
1885
|
+
}
|
|
2326
1886
|
this.token = token;
|
|
2327
1887
|
this.pendingRefresh = false;
|
|
2328
1888
|
if (token.refresh_token) {
|
|
2329
|
-
await this.storage.set(
|
|
1889
|
+
await this.storage.set(
|
|
1890
|
+
STORAGE_KEYS.REFRESH_TOKEN,
|
|
1891
|
+
token.refresh_token
|
|
1892
|
+
);
|
|
2330
1893
|
log("Updated refresh token in storage");
|
|
2331
1894
|
}
|
|
2332
1895
|
if (!isRefreshToken) {
|
|
2333
1896
|
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2334
1897
|
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2335
|
-
log(
|
|
1898
|
+
log(
|
|
1899
|
+
"Cleaned up redirect_uri and code_verifier from storage after successful exchange"
|
|
1900
|
+
);
|
|
2336
1901
|
}
|
|
2337
1902
|
this.reportConnection(token.access_token).catch(() => {
|
|
2338
1903
|
});
|
|
@@ -2341,12 +1906,12 @@ var AuthManager = class {
|
|
|
2341
1906
|
return token;
|
|
2342
1907
|
} catch (error) {
|
|
2343
1908
|
log("Token refresh error:", error);
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
if (
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
1909
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
1910
|
+
log("Preserving cleared auth state after definitive token rejection");
|
|
1911
|
+
} else if (this.isNetworkError(error)) {
|
|
1912
|
+
log("Recoverable network auth failure - preserving session state");
|
|
1913
|
+
} else {
|
|
1914
|
+
log("Recoverable auth failure - preserving session state");
|
|
2350
1915
|
}
|
|
2351
1916
|
throw error;
|
|
2352
1917
|
}
|
|
@@ -2370,13 +1935,15 @@ var AuthManager = class {
|
|
|
2370
1935
|
}
|
|
2371
1936
|
return tokenPromise;
|
|
2372
1937
|
}
|
|
2373
|
-
resetAuthState() {
|
|
2374
|
-
this.user = null;
|
|
2375
|
-
this.isSignedIn = false;
|
|
1938
|
+
resetAuthState(status = "signed_out") {
|
|
1939
|
+
this.user = status === "reauth_required" ? this.user : null;
|
|
2376
1940
|
this.token = null;
|
|
2377
|
-
|
|
1941
|
+
if (status !== "reauth_required") {
|
|
1942
|
+
this.did = null;
|
|
1943
|
+
}
|
|
2378
1944
|
this.tokenScope = null;
|
|
2379
|
-
this.
|
|
1945
|
+
this.nextUserRecoveryAt = 0;
|
|
1946
|
+
this.updateAuthStatus(status);
|
|
2380
1947
|
}
|
|
2381
1948
|
async clearStoredAuth() {
|
|
2382
1949
|
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
@@ -2389,148 +1956,1650 @@ var AuthManager = class {
|
|
|
2389
1956
|
isNetworkError(error) {
|
|
2390
1957
|
if (error instanceof TypeError) return true;
|
|
2391
1958
|
if (error instanceof Error) {
|
|
2392
|
-
return error.message.includes("offline") || error.message.includes("Network")
|
|
1959
|
+
return error.message.includes("offline") || error.message.includes("Network") || // 429 on the token endpoint: transient, keep the session alive
|
|
1960
|
+
error.message.includes("rate limited");
|
|
2393
1961
|
}
|
|
2394
1962
|
return false;
|
|
2395
1963
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
1964
|
+
async getRefreshToken() {
|
|
1965
|
+
const storedRefreshToken = await this.storage.get(
|
|
1966
|
+
STORAGE_KEYS.REFRESH_TOKEN
|
|
1967
|
+
);
|
|
1968
|
+
if (storedRefreshToken) {
|
|
1969
|
+
log("Using refresh token from storage");
|
|
1970
|
+
if (this.token && this.token.refresh_token !== storedRefreshToken) {
|
|
1971
|
+
this.token = { ...this.token, refresh_token: storedRefreshToken };
|
|
1972
|
+
}
|
|
1973
|
+
return storedRefreshToken;
|
|
1974
|
+
}
|
|
1975
|
+
const memoryRefreshToken = this.token?.refresh_token ?? null;
|
|
1976
|
+
if (memoryRefreshToken) {
|
|
1977
|
+
log("Using refresh token from memory fallback");
|
|
1978
|
+
} else {
|
|
1979
|
+
log("No refresh token available in storage or memory");
|
|
1980
|
+
}
|
|
1981
|
+
return memoryRefreshToken;
|
|
1982
|
+
}
|
|
1983
|
+
async syncRefreshTokenFromStorage() {
|
|
1984
|
+
const storedRefreshToken = await this.storage.get(
|
|
1985
|
+
STORAGE_KEYS.REFRESH_TOKEN
|
|
1986
|
+
);
|
|
1987
|
+
if (storedRefreshToken && this.token && this.token.refresh_token !== storedRefreshToken) {
|
|
1988
|
+
this.token = { ...this.token, refresh_token: storedRefreshToken };
|
|
1989
|
+
log("Synced refresh token from shared storage into memory");
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
applyTokenClaims(decoded) {
|
|
1993
|
+
this.did = decoded.sub || null;
|
|
1994
|
+
this.tokenScope = decoded.scope || null;
|
|
1995
|
+
}
|
|
1996
|
+
broadcastSessionUpdate() {
|
|
1997
|
+
if (this.freshSignIn) {
|
|
1998
|
+
this.freshSignIn = false;
|
|
1999
|
+
this.broadcastSignIn();
|
|
2000
|
+
} else {
|
|
2001
|
+
this.broadcastTokenRefresh();
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
async handleUserFetchFailure() {
|
|
2005
|
+
if (this.isCompatibleUser(this.user)) {
|
|
2006
|
+
log("Preserving existing user after userinfo failure");
|
|
2007
|
+
} else if (this.user) {
|
|
2008
|
+
log("Discarding stale in-memory user after userinfo failure");
|
|
2009
|
+
this.user = null;
|
|
2010
|
+
}
|
|
2011
|
+
if (!this.user) {
|
|
2012
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2013
|
+
if (cached) {
|
|
2014
|
+
try {
|
|
2015
|
+
const parsed = JSON.parse(cached);
|
|
2016
|
+
if (this.isCompatibleUser(parsed)) {
|
|
2017
|
+
this.user = parsed;
|
|
2018
|
+
log("Recovered cached user after userinfo failure");
|
|
2019
|
+
} else {
|
|
2020
|
+
log("Cached user did not match the active session");
|
|
2021
|
+
}
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
log("Failed to parse cached user after userinfo failure:", error);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (!this.user) {
|
|
2028
|
+
log("No compatible cached user available after userinfo failure");
|
|
2029
|
+
this.nextUserRecoveryAt = Date.now() + USER_RECOVERY_RETRY_COOLDOWN_MS;
|
|
2030
|
+
} else {
|
|
2031
|
+
this.nextUserRecoveryAt = 0;
|
|
2032
|
+
}
|
|
2033
|
+
if (this.authStatus === "bootstrapping") {
|
|
2034
|
+
this.updateAuthStatus(this.token ? "authenticated" : "recovering");
|
|
2035
|
+
}
|
|
2036
|
+
this.notify();
|
|
2037
|
+
}
|
|
2038
|
+
isCompatibleUser(user) {
|
|
2039
|
+
if (!user) return false;
|
|
2040
|
+
if (!this.did) return true;
|
|
2041
|
+
return user.sub === this.did;
|
|
2042
|
+
}
|
|
2043
|
+
async recoverMissingUserProfile(reason, accessToken) {
|
|
2044
|
+
if (!this.isSignedIn || this.user) return;
|
|
2045
|
+
const now = Date.now();
|
|
2046
|
+
if (this.nextUserRecoveryAt > now) {
|
|
2047
|
+
log(
|
|
2048
|
+
`Skipping user profile recovery on ${reason} until ${new Date(this.nextUserRecoveryAt).toISOString()}`
|
|
2049
|
+
);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
log(`Attempting user profile recovery on ${reason}`);
|
|
2053
|
+
const token = accessToken ?? await this.getToken();
|
|
2054
|
+
if (this.user) return;
|
|
2055
|
+
await this.fetchUser(token);
|
|
2056
|
+
}
|
|
2057
|
+
isDefinitiveTokenErrorCode(code) {
|
|
2058
|
+
return typeof code === "string" && DEFINITIVE_TOKEN_ERRORS.has(code);
|
|
2059
|
+
}
|
|
2060
|
+
isDefinitiveAuthFailure(error) {
|
|
2061
|
+
return error instanceof DefinitiveAuthError;
|
|
2413
2062
|
}
|
|
2414
2063
|
/**
|
|
2415
|
-
*
|
|
2416
|
-
*
|
|
2417
|
-
*
|
|
2064
|
+
* Centralised auth status setter. Derives `isSignedIn` and `isAuthReady`
|
|
2065
|
+
* from the status so they stay consistent.
|
|
2066
|
+
*
|
|
2067
|
+
* `isSignedIn` is intentionally `true` during `reauth_required` so the
|
|
2068
|
+
* UI layer can still display user info while prompting re-authentication.
|
|
2069
|
+
* Consumers should check `authStatus` (or a future convenience getter)
|
|
2070
|
+
* when they need to distinguish "healthy session" from "needs re-auth".
|
|
2418
2071
|
*/
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2072
|
+
updateAuthStatus(status, errorCode = null) {
|
|
2073
|
+
this.authStatus = status;
|
|
2074
|
+
this.authErrorCode = errorCode;
|
|
2075
|
+
this.isSignedIn = status === "authenticated" || status === "recovering" || status === "reauth_required";
|
|
2076
|
+
this.isAuthReady = status !== "bootstrapping";
|
|
2077
|
+
}
|
|
2078
|
+
async clearStoredSessionTokens() {
|
|
2079
|
+
await this.storage.remove(STORAGE_KEYS.REFRESH_TOKEN);
|
|
2080
|
+
await this.storage.remove(STORAGE_KEYS.REDIRECT_URI);
|
|
2081
|
+
await this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
2082
|
+
}
|
|
2083
|
+
async restoreStoredSession(reason) {
|
|
2084
|
+
const refreshToken = await this.getRefreshToken();
|
|
2085
|
+
if (!refreshToken) {
|
|
2086
|
+
log(`No stored refresh token available during ${reason}`);
|
|
2087
|
+
await this.restoreCachedUser({ hasRecoverableSession: false });
|
|
2088
|
+
return;
|
|
2427
2089
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
return
|
|
2090
|
+
log(`Restoring stored session during ${reason}`);
|
|
2091
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2092
|
+
if (!this.isOnline) {
|
|
2093
|
+
return;
|
|
2432
2094
|
}
|
|
2433
|
-
|
|
2095
|
+
this.exchangeToken(refreshToken, true).catch(async (error) => {
|
|
2096
|
+
log(`Stored session refresh failed during ${reason}:`, error);
|
|
2097
|
+
if (this.isDefinitiveAuthFailure(error)) {
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
async handleExternalTokenRefresh(data) {
|
|
2104
|
+
await this.syncRefreshTokenFromStorage();
|
|
2105
|
+
const refreshToken = await this.getRefreshToken();
|
|
2106
|
+
if (data.accessToken && refreshToken) {
|
|
2434
2107
|
try {
|
|
2435
|
-
|
|
2436
|
-
|
|
2108
|
+
const decoded = (0, import_jwt_decode.jwtDecode)(data.accessToken);
|
|
2109
|
+
const expiresIn = decoded.exp != null ? Math.max(0, decoded.exp - Math.floor(Date.now() / 1e3)) : 0;
|
|
2110
|
+
this.token = {
|
|
2111
|
+
access_token: data.accessToken,
|
|
2112
|
+
token_type: "Bearer",
|
|
2113
|
+
expires_in: expiresIn,
|
|
2114
|
+
refresh_token: refreshToken
|
|
2115
|
+
};
|
|
2116
|
+
this.applyTokenClaims(decoded);
|
|
2437
2117
|
} catch (error) {
|
|
2438
|
-
|
|
2439
|
-
|
|
2118
|
+
log("Failed to decode token refreshed by another tab:", error);
|
|
2119
|
+
this.token = {
|
|
2120
|
+
access_token: data.accessToken,
|
|
2121
|
+
token_type: "Bearer",
|
|
2122
|
+
expires_in: 0,
|
|
2123
|
+
refresh_token: refreshToken
|
|
2124
|
+
};
|
|
2440
2125
|
}
|
|
2126
|
+
if (this.authStatus !== "reauth_required") {
|
|
2127
|
+
this.updateAuthStatus("authenticated");
|
|
2128
|
+
}
|
|
2129
|
+
} else if (refreshToken) {
|
|
2130
|
+
await this.restoreCachedUser({ hasRecoverableSession: true });
|
|
2441
2131
|
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2132
|
+
if (data.did) this.did = data.did;
|
|
2133
|
+
if (data.tokenScope) this.tokenScope = data.tokenScope;
|
|
2134
|
+
this.notify();
|
|
2444
2135
|
}
|
|
2445
|
-
async
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2136
|
+
async fetchCurrentSession(accessToken) {
|
|
2137
|
+
const endpoints = await this.getActivePdsEndpoints();
|
|
2138
|
+
const response = await fetch(`${endpoints.pds_url}/auth/session`, {
|
|
2139
|
+
method: "GET",
|
|
2140
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2141
|
+
});
|
|
2142
|
+
const data = await response.json().catch(() => ({}));
|
|
2143
|
+
if (response.status === 401 && data.reauth_required) {
|
|
2144
|
+
await this.markReauthRequired(data.error || "invalid_session");
|
|
2145
|
+
throw new DefinitiveAuthError(data.error || "invalid_session");
|
|
2146
|
+
}
|
|
2147
|
+
if (!response.ok) {
|
|
2148
|
+
throw new Error(`Failed to reconcile session: ${response.status}`);
|
|
2454
2149
|
}
|
|
2150
|
+
return data;
|
|
2455
2151
|
}
|
|
2456
|
-
async
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2152
|
+
async markReauthRequired(code, options) {
|
|
2153
|
+
log("Marking auth session as requiring reauthentication:", code);
|
|
2154
|
+
await this.clearStoredSessionTokens();
|
|
2155
|
+
if (!this.user) {
|
|
2156
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO);
|
|
2157
|
+
if (cached) {
|
|
2158
|
+
try {
|
|
2159
|
+
this.user = JSON.parse(cached);
|
|
2160
|
+
} catch {
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
this.token = null;
|
|
2165
|
+
this.tokenScope = null;
|
|
2166
|
+
this.nextUserRecoveryAt = 0;
|
|
2167
|
+
this.updateAuthStatus("reauth_required", code);
|
|
2168
|
+
if (options?.broadcast !== false) {
|
|
2169
|
+
this.broadcastSessionInvalidated(code);
|
|
2170
|
+
}
|
|
2171
|
+
this.notify();
|
|
2462
2172
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2173
|
+
};
|
|
2174
|
+
|
|
2175
|
+
// src/core/http/RestClient.ts
|
|
2176
|
+
var RestError = class extends Error {
|
|
2177
|
+
status;
|
|
2178
|
+
code;
|
|
2179
|
+
response;
|
|
2180
|
+
constructor(message, status, code, response) {
|
|
2181
|
+
super(message);
|
|
2182
|
+
this.name = "RestError";
|
|
2183
|
+
this.status = status;
|
|
2184
|
+
this.code = code;
|
|
2185
|
+
this.response = response;
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2188
|
+
var NotAuthenticatedError = class extends Error {
|
|
2189
|
+
constructor(message = "Not authenticated") {
|
|
2190
|
+
super(message);
|
|
2191
|
+
this.name = "NotAuthenticatedError";
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
var RestClient = class {
|
|
2195
|
+
opts;
|
|
2196
|
+
constructor(opts) {
|
|
2197
|
+
this.opts = { ...opts, baseUrl: opts.baseUrl.replace(/\/$/, "") };
|
|
2198
|
+
}
|
|
2199
|
+
get projectId() {
|
|
2200
|
+
return this.opts.projectId;
|
|
2201
|
+
}
|
|
2202
|
+
// -------------------------------------------------------------------
|
|
2203
|
+
// Sync surface
|
|
2204
|
+
// -------------------------------------------------------------------
|
|
2205
|
+
/** `GET /account/:project_id/db` — tables, enforced schema version, channel head. */
|
|
2206
|
+
async getDbInfo() {
|
|
2207
|
+
const res = await this.request("GET", `${this.dbPath}`);
|
|
2208
|
+
return res.data;
|
|
2209
|
+
}
|
|
2210
|
+
/** Bootstrap snapshot (SPEC §5). `share` bootstraps a mount; `table` filters. */
|
|
2211
|
+
async getSnapshot(options) {
|
|
2212
|
+
const query = new URLSearchParams();
|
|
2213
|
+
if (options?.share) query.set("share", options.share);
|
|
2214
|
+
if (options?.table) query.set("table", options.table);
|
|
2215
|
+
const qs = query.toString();
|
|
2216
|
+
const res = await this.request(
|
|
2217
|
+
"GET",
|
|
2218
|
+
`${this.dbPath}/snapshot${qs ? `?${qs}` : ""}`
|
|
2219
|
+
);
|
|
2220
|
+
return res.data;
|
|
2221
|
+
}
|
|
2222
|
+
/** Pull ordered ops after a cursor — the non-WebSocket sync path. */
|
|
2223
|
+
async getChanges(options) {
|
|
2224
|
+
const query = new URLSearchParams({ cursor: String(options.cursor) });
|
|
2225
|
+
if (options.limit) query.set("limit", String(options.limit));
|
|
2226
|
+
if (options.share) query.set("share", options.share);
|
|
2227
|
+
if (options.table) query.set("table", options.table);
|
|
2228
|
+
const res = await this.request(
|
|
2229
|
+
"GET",
|
|
2230
|
+
`${this.dbPath}/changes?${query.toString()}`
|
|
2231
|
+
);
|
|
2232
|
+
return res.data;
|
|
2471
2233
|
}
|
|
2234
|
+
// -------------------------------------------------------------------
|
|
2235
|
+
// Shares (multiplayer v1)
|
|
2236
|
+
// -------------------------------------------------------------------
|
|
2472
2237
|
/**
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2238
|
+
* Shares granted by and received by the caller. App tokens see only
|
|
2239
|
+
* shares involving their own app (the ones they can mount).
|
|
2475
2240
|
*/
|
|
2476
|
-
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2241
|
+
async listShares() {
|
|
2242
|
+
const res = await this.request(
|
|
2243
|
+
"GET",
|
|
2244
|
+
"/account/shares"
|
|
2245
|
+
);
|
|
2246
|
+
return res.data;
|
|
2247
|
+
}
|
|
2248
|
+
// -------------------------------------------------------------------
|
|
2249
|
+
// CRUD on materialized state (REST-mode table API)
|
|
2250
|
+
// -------------------------------------------------------------------
|
|
2251
|
+
async list(table, query) {
|
|
2252
|
+
const qs = query ? `?${new URLSearchParams(query).toString()}` : "";
|
|
2253
|
+
const res = await this.request(
|
|
2254
|
+
"GET",
|
|
2255
|
+
`${this.dbPath}/${encodeURIComponent(table)}${qs}`
|
|
2256
|
+
);
|
|
2257
|
+
return res.data ?? [];
|
|
2258
|
+
}
|
|
2259
|
+
async getRecord(table, id) {
|
|
2260
|
+
try {
|
|
2261
|
+
const res = await this.request(
|
|
2262
|
+
"GET",
|
|
2263
|
+
`${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`
|
|
2264
|
+
);
|
|
2265
|
+
return res.data ?? null;
|
|
2266
|
+
} catch (err) {
|
|
2267
|
+
if (err instanceof RestError && err.status === 404) return null;
|
|
2268
|
+
throw err;
|
|
2481
2269
|
}
|
|
2482
|
-
return aMajorMinor.minor - bMajorMinor.minor;
|
|
2483
2270
|
}
|
|
2484
|
-
/**
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
return {
|
|
2493
|
-
major: parts[0] || 0,
|
|
2494
|
-
minor: parts[1] || 0
|
|
2495
|
-
};
|
|
2271
|
+
/** `POST` — server mints the record id. */
|
|
2272
|
+
async createRecord(table, value) {
|
|
2273
|
+
const res = await this.request(
|
|
2274
|
+
"POST",
|
|
2275
|
+
`${this.dbPath}/${encodeURIComponent(table)}`,
|
|
2276
|
+
{ value }
|
|
2277
|
+
);
|
|
2278
|
+
return res.data;
|
|
2496
2279
|
}
|
|
2497
|
-
/**
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2280
|
+
/** `PUT` — full replace. REST semantics: 404 for missing records. */
|
|
2281
|
+
async putRecord(table, id, value) {
|
|
2282
|
+
try {
|
|
2283
|
+
const res = await this.request(
|
|
2284
|
+
"PUT",
|
|
2285
|
+
`${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`,
|
|
2286
|
+
{ value }
|
|
2287
|
+
);
|
|
2288
|
+
return res.data ?? null;
|
|
2289
|
+
} catch (err) {
|
|
2290
|
+
if (err instanceof RestError && err.status === 404) return null;
|
|
2291
|
+
throw err;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/** `PATCH` — partial merge. 404 → null. */
|
|
2295
|
+
async patchRecord(table, id, value) {
|
|
2296
|
+
try {
|
|
2297
|
+
const res = await this.request(
|
|
2298
|
+
"PATCH",
|
|
2299
|
+
`${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`,
|
|
2300
|
+
{ value }
|
|
2301
|
+
);
|
|
2302
|
+
return res.data ?? null;
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
if (err instanceof RestError && err.status === 404) return null;
|
|
2305
|
+
throw err;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
/** `DELETE`. Returns false when the record did not exist. */
|
|
2309
|
+
async deleteRecord(table, id) {
|
|
2310
|
+
try {
|
|
2311
|
+
await this.request(
|
|
2312
|
+
"DELETE",
|
|
2313
|
+
`${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`
|
|
2314
|
+
);
|
|
2315
|
+
return true;
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
if (err instanceof RestError && err.status === 404) return false;
|
|
2318
|
+
throw err;
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
// -------------------------------------------------------------------
|
|
2322
|
+
// Internals
|
|
2323
|
+
// -------------------------------------------------------------------
|
|
2324
|
+
get dbPath() {
|
|
2325
|
+
return `/account/${encodeURIComponent(this.opts.projectId)}/db`;
|
|
2326
|
+
}
|
|
2327
|
+
/** Authenticated request; retries once with a force-refreshed token on 401. */
|
|
2328
|
+
async request(method, path, body, isRetry = false) {
|
|
2329
|
+
let token;
|
|
2330
|
+
try {
|
|
2331
|
+
token = await this.opts.getToken(isRetry ? { forceRefresh: true } : void 0);
|
|
2332
|
+
} catch (err) {
|
|
2333
|
+
throw new NotAuthenticatedError(
|
|
2334
|
+
err instanceof Error ? err.message : "could not get access token"
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
const url = `${this.opts.baseUrl}${path}`;
|
|
2338
|
+
this.opts.log?.("[rest]", method, url);
|
|
2339
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
2340
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
2341
|
+
const response = await fetch(url, {
|
|
2342
|
+
method,
|
|
2343
|
+
headers,
|
|
2344
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
2345
|
+
});
|
|
2346
|
+
const responseData = await response.json().catch(() => ({}));
|
|
2347
|
+
if (!response.ok) {
|
|
2348
|
+
if (response.status === 401 && !isRetry) {
|
|
2349
|
+
this.opts.log?.("[rest] 401 \u2014 refreshing token and retrying once");
|
|
2350
|
+
return this.request(method, path, body, true);
|
|
2351
|
+
}
|
|
2352
|
+
const code = typeof responseData.error === "string" ? responseData.error : void 0;
|
|
2353
|
+
const message = typeof responseData.message === "string" && responseData.message || code || `request failed: ${response.status}`;
|
|
2354
|
+
throw new RestError(message, response.status, code, responseData);
|
|
2355
|
+
}
|
|
2356
|
+
return responseData;
|
|
2503
2357
|
}
|
|
2504
2358
|
};
|
|
2505
|
-
function createVersionUpdater(storage, currentVersion, migrations = []) {
|
|
2506
|
-
return new VersionUpdater(storage, currentVersion, migrations);
|
|
2507
|
-
}
|
|
2508
2359
|
|
|
2509
|
-
// src/
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2360
|
+
// src/core/sync/SyncEngine.ts
|
|
2361
|
+
var import_schema = require("@basictech/schema");
|
|
2362
|
+
|
|
2363
|
+
// src/core/sync/protocol.ts
|
|
2364
|
+
var PROTOCOL_VERSION = 1;
|
|
2365
|
+
var TERMINAL_OP_ERRORS = /* @__PURE__ */ new Set([
|
|
2366
|
+
"SCHEMA_VALIDATION_FAILED",
|
|
2367
|
+
"UNKNOWN_TABLE",
|
|
2368
|
+
"RECORD_NOT_FOUND",
|
|
2369
|
+
"PERMISSION_DENIED",
|
|
2370
|
+
"PAYLOAD_TOO_LARGE",
|
|
2371
|
+
"CHANNEL_FULL",
|
|
2372
|
+
"BAD_MESSAGE"
|
|
2373
|
+
]);
|
|
2374
|
+
function isTerminalOpError(code, terminalFlag) {
|
|
2375
|
+
if (terminalFlag !== void 0) return terminalFlag;
|
|
2376
|
+
return code !== void 0 && TERMINAL_OP_ERRORS.has(code);
|
|
2377
|
+
}
|
|
2378
|
+
function isRebootstrapError(code) {
|
|
2379
|
+
return code === "SNAPSHOT_REQUIRED" || code === "RESET_REQUIRED";
|
|
2380
|
+
}
|
|
2381
|
+
function isRevocationError(code) {
|
|
2382
|
+
return code === "SHARE_REVOKED" || code === "CONNECTION_REVOKED";
|
|
2383
|
+
}
|
|
2384
|
+
function isAuthError(code) {
|
|
2385
|
+
return code === "UNAUTHORIZED" || code === "TOKEN_EXPIRED";
|
|
2386
|
+
}
|
|
2387
|
+
var DEFAULT_LIMITS = {
|
|
2388
|
+
max_ops_per_push: 500,
|
|
2389
|
+
max_op_bytes: 64 * 1024,
|
|
2390
|
+
replay_limit: 1e3
|
|
2518
2391
|
};
|
|
2519
|
-
function
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2392
|
+
function mintOpId() {
|
|
2393
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
2394
|
+
return crypto.randomUUID();
|
|
2395
|
+
}
|
|
2396
|
+
return "op-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
2397
|
+
}
|
|
2398
|
+
function cloneData(value) {
|
|
2399
|
+
return value === void 0 ? value : JSON.parse(JSON.stringify(value));
|
|
2400
|
+
}
|
|
2401
|
+
function applyOpToData(existing, op) {
|
|
2402
|
+
switch (op.type) {
|
|
2403
|
+
case "put":
|
|
2404
|
+
return cloneData(op.data ?? {});
|
|
2405
|
+
case "patch":
|
|
2406
|
+
if (existing === void 0) return void 0;
|
|
2407
|
+
return { ...existing, ...cloneData(op.data ?? {}) };
|
|
2408
|
+
case "delete":
|
|
2409
|
+
return void 0;
|
|
2410
|
+
}
|
|
2523
2411
|
}
|
|
2524
2412
|
|
|
2525
|
-
// src/
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2413
|
+
// src/core/sync/SyncConnection.ts
|
|
2414
|
+
var INITIAL_RECONNECT_DELAY_MS = 500;
|
|
2415
|
+
var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
2416
|
+
var SyncConnection = class {
|
|
2417
|
+
opts;
|
|
2418
|
+
WS;
|
|
2419
|
+
heartbeatMs;
|
|
2420
|
+
ws = null;
|
|
2421
|
+
_status = "idle";
|
|
2422
|
+
stopped = true;
|
|
2423
|
+
reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
2424
|
+
timers = /* @__PURE__ */ new Set();
|
|
2425
|
+
heartbeatTimer = null;
|
|
2426
|
+
/** One forced-refresh reconnect attempt per auth rejection. */
|
|
2427
|
+
authRetryUsed = false;
|
|
2428
|
+
removeOnlineListener = null;
|
|
2429
|
+
constructor(opts) {
|
|
2430
|
+
this.opts = opts;
|
|
2431
|
+
this.WS = opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
2432
|
+
this.heartbeatMs = opts.heartbeatMs ?? 3e4;
|
|
2433
|
+
}
|
|
2434
|
+
get status() {
|
|
2435
|
+
return this._status;
|
|
2436
|
+
}
|
|
2437
|
+
get isOnline() {
|
|
2438
|
+
return this._status === "online" && this.ws?.readyState === 1;
|
|
2439
|
+
}
|
|
2440
|
+
start() {
|
|
2441
|
+
if (!this.stopped && this.ws) return;
|
|
2442
|
+
this.stopped = false;
|
|
2443
|
+
this.authRetryUsed = false;
|
|
2444
|
+
this.listenForNetwork();
|
|
2445
|
+
this.open();
|
|
2446
|
+
}
|
|
2447
|
+
stop() {
|
|
2448
|
+
this.stopped = true;
|
|
2449
|
+
this.clearTimers();
|
|
2450
|
+
this.removeOnlineListener?.();
|
|
2451
|
+
this.removeOnlineListener = null;
|
|
2452
|
+
if (this.ws) {
|
|
2453
|
+
try {
|
|
2454
|
+
this.ws.close();
|
|
2455
|
+
} catch {
|
|
2456
|
+
}
|
|
2457
|
+
this.ws = null;
|
|
2458
|
+
}
|
|
2459
|
+
this.setStatus("stopped");
|
|
2460
|
+
}
|
|
2461
|
+
/** Send a message; returns false when the socket is not open. */
|
|
2462
|
+
send(msg) {
|
|
2463
|
+
if (this.ws?.readyState === 1) {
|
|
2464
|
+
this.ws.send(JSON.stringify(msg));
|
|
2465
|
+
return true;
|
|
2466
|
+
}
|
|
2467
|
+
return false;
|
|
2468
|
+
}
|
|
2469
|
+
/** Refresh auth on the live socket (no reconnect). */
|
|
2470
|
+
async sendToken() {
|
|
2471
|
+
if (!this.isOnline) return;
|
|
2472
|
+
try {
|
|
2473
|
+
const token = await this.opts.getToken();
|
|
2474
|
+
this.send({ type: "token", token });
|
|
2475
|
+
} catch {
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* The server rejected our token (`UNAUTHORIZED` / `TOKEN_EXPIRED` + close).
|
|
2480
|
+
* Retry once with a force-refreshed token; give up (status `auth_failed`)
|
|
2481
|
+
* when the refresh itself fails or a fresh token is rejected again.
|
|
2482
|
+
*/
|
|
2483
|
+
handleAuthRejection() {
|
|
2484
|
+
if (this.stopped) return;
|
|
2485
|
+
if (this.authRetryUsed) {
|
|
2486
|
+
this.log("fresh token rejected \u2014 giving up until reauth");
|
|
2487
|
+
this.stopped = true;
|
|
2488
|
+
this.clearTimers();
|
|
2489
|
+
this.setStatus("auth_failed");
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
this.authRetryUsed = true;
|
|
2493
|
+
this.log("token rejected \u2014 force refreshing and reconnecting");
|
|
2494
|
+
void (async () => {
|
|
2495
|
+
try {
|
|
2496
|
+
await this.opts.getToken({ forceRefresh: true });
|
|
2497
|
+
this.scheduleReconnect(0);
|
|
2498
|
+
} catch (err) {
|
|
2499
|
+
this.log("token refresh failed after auth rejection:", err);
|
|
2500
|
+
this.stopped = true;
|
|
2501
|
+
this.setStatus("auth_failed");
|
|
2502
|
+
}
|
|
2503
|
+
})();
|
|
2504
|
+
}
|
|
2505
|
+
// -------------------------------------------------------------------
|
|
2506
|
+
// Internals
|
|
2507
|
+
// -------------------------------------------------------------------
|
|
2508
|
+
open() {
|
|
2509
|
+
if (this.stopped) return;
|
|
2510
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
|
2511
|
+
if (!this.WS) {
|
|
2512
|
+
this.log("no WebSocket implementation available");
|
|
2513
|
+
this.setStatus("offline");
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
this.setStatus("connecting");
|
|
2517
|
+
const ws = new this.WS(this.opts.wsUrl);
|
|
2518
|
+
this.ws = ws;
|
|
2519
|
+
ws.onopen = () => {
|
|
2520
|
+
void (async () => {
|
|
2521
|
+
try {
|
|
2522
|
+
const token = await this.opts.getToken();
|
|
2523
|
+
if (this.ws !== ws || ws.readyState !== 1) return;
|
|
2524
|
+
const hello = { type: "hello", version: PROTOCOL_VERSION, token };
|
|
2525
|
+
ws.send(JSON.stringify(hello));
|
|
2526
|
+
} catch (err) {
|
|
2527
|
+
this.log("could not get token for hello:", err);
|
|
2528
|
+
try {
|
|
2529
|
+
ws.close();
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
})();
|
|
2534
|
+
};
|
|
2535
|
+
ws.onmessage = (event) => {
|
|
2536
|
+
let msg;
|
|
2537
|
+
try {
|
|
2538
|
+
const text = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
|
|
2539
|
+
msg = JSON.parse(text);
|
|
2540
|
+
} catch {
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
this.handleMessage(msg);
|
|
2544
|
+
};
|
|
2545
|
+
ws.onclose = () => {
|
|
2546
|
+
if (this.ws !== ws) return;
|
|
2547
|
+
this.ws = null;
|
|
2548
|
+
this.stopHeartbeat();
|
|
2549
|
+
if (this.stopped) return;
|
|
2550
|
+
this.setStatus("offline");
|
|
2551
|
+
this.scheduleReconnect();
|
|
2552
|
+
};
|
|
2553
|
+
ws.onerror = () => {
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
handleMessage(msg) {
|
|
2557
|
+
if (msg.type === "welcome") {
|
|
2558
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
2559
|
+
this.authRetryUsed = false;
|
|
2560
|
+
this.setStatus("online");
|
|
2561
|
+
this.startHeartbeat();
|
|
2562
|
+
this.opts.onWelcome(msg);
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
if (msg.type === "error" && !("sub" in msg && msg.sub) && isAuthError(msg.code)) {
|
|
2566
|
+
this.handleAuthRejection();
|
|
2567
|
+
this.opts.onMessage(msg);
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
this.opts.onMessage(msg);
|
|
2571
|
+
}
|
|
2572
|
+
startHeartbeat() {
|
|
2573
|
+
this.stopHeartbeat();
|
|
2574
|
+
const tick = () => {
|
|
2575
|
+
if (this.stopped) return;
|
|
2576
|
+
this.send({ type: "ping" });
|
|
2577
|
+
this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
|
|
2578
|
+
this.timers.add(this.heartbeatTimer);
|
|
2579
|
+
};
|
|
2580
|
+
this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
|
|
2581
|
+
this.timers.add(this.heartbeatTimer);
|
|
2582
|
+
}
|
|
2583
|
+
stopHeartbeat() {
|
|
2584
|
+
if (this.heartbeatTimer) {
|
|
2585
|
+
clearTimeout(this.heartbeatTimer);
|
|
2586
|
+
this.timers.delete(this.heartbeatTimer);
|
|
2587
|
+
this.heartbeatTimer = null;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
scheduleReconnect(delayOverride) {
|
|
2591
|
+
if (this.stopped) return;
|
|
2592
|
+
const delay = delayOverride ?? this.reconnectDelay;
|
|
2593
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
|
|
2594
|
+
this.log(`reconnecting in ${delay}ms`);
|
|
2595
|
+
const timer = setTimeout(() => {
|
|
2596
|
+
this.timers.delete(timer);
|
|
2597
|
+
this.open();
|
|
2598
|
+
}, delay);
|
|
2599
|
+
this.timers.add(timer);
|
|
2600
|
+
}
|
|
2601
|
+
listenForNetwork() {
|
|
2602
|
+
if (this.removeOnlineListener || typeof window === "undefined") return;
|
|
2603
|
+
const handleOnline = () => {
|
|
2604
|
+
if (this.stopped) return;
|
|
2605
|
+
this.log("network online \u2014 reconnecting immediately");
|
|
2606
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
2607
|
+
this.open();
|
|
2608
|
+
};
|
|
2609
|
+
window.addEventListener("online", handleOnline);
|
|
2610
|
+
this.removeOnlineListener = () => window.removeEventListener("online", handleOnline);
|
|
2611
|
+
}
|
|
2612
|
+
clearTimers() {
|
|
2613
|
+
for (const t of this.timers) clearTimeout(t);
|
|
2614
|
+
this.timers.clear();
|
|
2615
|
+
this.heartbeatTimer = null;
|
|
2616
|
+
}
|
|
2617
|
+
setStatus(status) {
|
|
2618
|
+
if (this._status === status) return;
|
|
2619
|
+
this._status = status;
|
|
2620
|
+
this.opts.onStatus(status);
|
|
2621
|
+
}
|
|
2622
|
+
log(...args) {
|
|
2623
|
+
this.opts.log?.("[sync-connection]", ...args);
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2626
|
+
|
|
2627
|
+
// src/core/sync/SyncStore.ts
|
|
2628
|
+
var import_dexie = __toESM(require("dexie"));
|
|
2629
|
+
var META_CURSOR = "cursor";
|
|
2630
|
+
var META_CHANNEL = "channel";
|
|
2631
|
+
var SyncStore = class {
|
|
2632
|
+
db;
|
|
2633
|
+
name;
|
|
2634
|
+
tableNames;
|
|
2635
|
+
constructor(name, schema) {
|
|
2636
|
+
this.name = name;
|
|
2637
|
+
this.tableNames = Object.keys(schema.tables);
|
|
2638
|
+
this.db = new import_dexie.default(name);
|
|
2639
|
+
const stores = {
|
|
2640
|
+
_server: "[table+record_id], table",
|
|
2641
|
+
_pending: "++idx, op_id",
|
|
2642
|
+
_rejected: "++idx, op_id",
|
|
2643
|
+
_meta: "key"
|
|
2644
|
+
};
|
|
2645
|
+
for (const [tableName, table] of Object.entries(schema.tables)) {
|
|
2646
|
+
const indexed = Object.entries(table.fields).filter(([, f]) => f.indexed).map(([fieldName]) => `,${fieldName}`).join("");
|
|
2647
|
+
stores[tableName] = "id" + indexed;
|
|
2648
|
+
}
|
|
2649
|
+
this.db.version(Math.max(schema.version ?? 1, 1)).stores(stores);
|
|
2650
|
+
}
|
|
2651
|
+
/** The Dexie view table for an app table (what liveQuery reads). */
|
|
2652
|
+
view(table) {
|
|
2653
|
+
return this.db.table(table);
|
|
2654
|
+
}
|
|
2655
|
+
hasTable(table) {
|
|
2656
|
+
return this.tableNames.includes(table);
|
|
2657
|
+
}
|
|
2658
|
+
get tables() {
|
|
2659
|
+
return [...this.tableNames];
|
|
2660
|
+
}
|
|
2661
|
+
get server() {
|
|
2662
|
+
return this.db.table("_server");
|
|
2663
|
+
}
|
|
2664
|
+
get pending() {
|
|
2665
|
+
return this.db.table("_pending");
|
|
2666
|
+
}
|
|
2667
|
+
get rejected() {
|
|
2668
|
+
return this.db.table("_rejected");
|
|
2669
|
+
}
|
|
2670
|
+
get meta() {
|
|
2671
|
+
return this.db.table("_meta");
|
|
2672
|
+
}
|
|
2673
|
+
get allStores() {
|
|
2674
|
+
return ["_server", "_pending", "_rejected", "_meta", ...this.tableNames];
|
|
2675
|
+
}
|
|
2676
|
+
// -------------------------------------------------------------------
|
|
2677
|
+
// Meta
|
|
2678
|
+
// -------------------------------------------------------------------
|
|
2679
|
+
async getCursor() {
|
|
2680
|
+
const row = await this.meta.get(META_CURSOR);
|
|
2681
|
+
return typeof row?.value === "number" ? row.value : null;
|
|
2682
|
+
}
|
|
2683
|
+
async getChannel() {
|
|
2684
|
+
const row = await this.meta.get(META_CHANNEL);
|
|
2685
|
+
return typeof row?.value === "string" ? row.value : null;
|
|
2686
|
+
}
|
|
2687
|
+
// -------------------------------------------------------------------
|
|
2688
|
+
// Pending / rejected
|
|
2689
|
+
// -------------------------------------------------------------------
|
|
2690
|
+
/** All pending ops in creation order (used to warm the in-memory queue). */
|
|
2691
|
+
async loadPending() {
|
|
2692
|
+
return this.pending.orderBy("idx").toArray();
|
|
2693
|
+
}
|
|
2694
|
+
async listRejected() {
|
|
2695
|
+
return this.rejected.orderBy("idx").toArray();
|
|
2696
|
+
}
|
|
2697
|
+
async clearRejected() {
|
|
2698
|
+
await this.rejected.clear();
|
|
2699
|
+
}
|
|
2700
|
+
/** Record a server ack for a pending op (echo not yet seen). */
|
|
2701
|
+
async markAcked(opId, seq) {
|
|
2702
|
+
await this.pending.where("op_id").equals(opId).modify({ acked_seq: seq });
|
|
2703
|
+
}
|
|
2704
|
+
// -------------------------------------------------------------------
|
|
2705
|
+
// Writes
|
|
2706
|
+
// -------------------------------------------------------------------
|
|
2707
|
+
/**
|
|
2708
|
+
* Enqueue a local op and apply it optimistically to the view.
|
|
2709
|
+
* Returns the resulting view record (null when the op deletes it).
|
|
2710
|
+
*/
|
|
2711
|
+
async addPending(op) {
|
|
2712
|
+
return this.db.transaction("rw", this.allStores, async () => {
|
|
2713
|
+
await this.pending.add({ op_id: op.op_id, op: cloneData(op) });
|
|
2714
|
+
return this.recomputeViewRecord(op.table, op.record_id);
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
/**
|
|
2718
|
+
* Commit a batch of incoming server ops (already filtered/deduped by the
|
|
2719
|
+
* engine): update `_server`, drop confirmed pending ops, advance the
|
|
2720
|
+
* cursor, and rebase every affected view record — in one transaction.
|
|
2721
|
+
*/
|
|
2722
|
+
async commitIncoming(params) {
|
|
2723
|
+
const { applyOps, confirmedOpIds, cursor } = params;
|
|
2724
|
+
await this.db.transaction("rw", this.allStores, async () => {
|
|
2725
|
+
const affected = /* @__PURE__ */ new Set();
|
|
2726
|
+
for (const op of applyOps) affected.add(`${op.table}\0${op.record_id}`);
|
|
2727
|
+
for (const op of applyOps) {
|
|
2728
|
+
await this.applyToServer(op);
|
|
2729
|
+
}
|
|
2730
|
+
if (confirmedOpIds.length > 0) {
|
|
2731
|
+
const confirmedRows = await this.pending.where("op_id").anyOf(confirmedOpIds).toArray();
|
|
2732
|
+
for (const row of confirmedRows) affected.add(`${row.op.table}\0${row.op.record_id}`);
|
|
2733
|
+
await this.pending.where("op_id").anyOf(confirmedOpIds).delete();
|
|
2734
|
+
}
|
|
2735
|
+
await this.meta.put({ key: META_CURSOR, value: cursor });
|
|
2736
|
+
for (const key of affected) {
|
|
2737
|
+
const [table, recordId] = key.split("\0");
|
|
2738
|
+
await this.recomputeViewRecord(table, recordId);
|
|
2739
|
+
}
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
/** Persist a cursor advance with no ops (empty `ops` message / pushed cursor). */
|
|
2743
|
+
async setCursor(cursor) {
|
|
2744
|
+
await this.meta.put({ key: META_CURSOR, value: cursor });
|
|
2745
|
+
}
|
|
2746
|
+
/**
|
|
2747
|
+
* Terminal rejection: remove from pending, park in the rejected store,
|
|
2748
|
+
* roll the view record back to server state + remaining pending ops.
|
|
2749
|
+
*/
|
|
2750
|
+
async rejectPending(opId, error, message) {
|
|
2751
|
+
return this.db.transaction("rw", this.allStores, async () => {
|
|
2752
|
+
const row = await this.pending.where("op_id").equals(opId).first();
|
|
2753
|
+
if (!row) return null;
|
|
2754
|
+
await this.pending.where("op_id").equals(opId).delete();
|
|
2755
|
+
const rejectedRow = {
|
|
2756
|
+
op_id: opId,
|
|
2757
|
+
op: row.op,
|
|
2758
|
+
error,
|
|
2759
|
+
message,
|
|
2760
|
+
rejected_at: Date.now()
|
|
2761
|
+
};
|
|
2762
|
+
await this.rejected.add(rejectedRow);
|
|
2763
|
+
await this.recomputeViewRecord(row.op.table, row.op.record_id);
|
|
2764
|
+
return rejectedRow;
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
// -------------------------------------------------------------------
|
|
2768
|
+
// Bootstrap
|
|
2769
|
+
// -------------------------------------------------------------------
|
|
2770
|
+
/**
|
|
2771
|
+
* Replace all server state from a snapshot (cold start or
|
|
2772
|
+
* SNAPSHOT_REQUIRED/RESET_REQUIRED recovery). Pending ops survive and are
|
|
2773
|
+
* re-applied on top of the fresh state.
|
|
2774
|
+
*/
|
|
2775
|
+
async replaceFromSnapshot(params) {
|
|
2776
|
+
const { channel, records, cursor } = params;
|
|
2777
|
+
await this.db.transaction("rw", this.allStores, async () => {
|
|
2778
|
+
await this.server.clear();
|
|
2779
|
+
for (const tableName of this.tableNames) {
|
|
2780
|
+
await this.view(tableName).clear();
|
|
2781
|
+
}
|
|
2782
|
+
for (const [tableName, tableRecords] of Object.entries(records)) {
|
|
2783
|
+
if (!this.hasTable(tableName)) continue;
|
|
2784
|
+
const serverRows = [];
|
|
2785
|
+
const viewRows = [];
|
|
2786
|
+
for (const [recordId, data] of Object.entries(tableRecords)) {
|
|
2787
|
+
serverRows.push({ table: tableName, record_id: recordId, data: data ?? {} });
|
|
2788
|
+
viewRows.push({ id: recordId, ...data ?? {} });
|
|
2789
|
+
}
|
|
2790
|
+
await this.server.bulkPut(serverRows);
|
|
2791
|
+
await this.view(tableName).bulkPut(viewRows);
|
|
2792
|
+
}
|
|
2793
|
+
const pendingRows = await this.pending.orderBy("idx").toArray();
|
|
2794
|
+
const affected = /* @__PURE__ */ new Set();
|
|
2795
|
+
for (const row of pendingRows) affected.add(`${row.op.table}\0${row.op.record_id}`);
|
|
2796
|
+
for (const key of affected) {
|
|
2797
|
+
const [table, recordId] = key.split("\0");
|
|
2798
|
+
if (this.hasTable(table)) await this.recomputeViewRecord(table, recordId);
|
|
2799
|
+
}
|
|
2800
|
+
await this.meta.put({ key: META_CURSOR, value: cursor });
|
|
2801
|
+
await this.meta.put({ key: META_CHANNEL, value: channel });
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
// -------------------------------------------------------------------
|
|
2805
|
+
// Reads
|
|
2806
|
+
// -------------------------------------------------------------------
|
|
2807
|
+
async getViewRecord(table, id) {
|
|
2808
|
+
const record = await this.view(table).get(id);
|
|
2809
|
+
return record ?? null;
|
|
2810
|
+
}
|
|
2811
|
+
async getViewRecords(table) {
|
|
2812
|
+
return this.view(table).toArray();
|
|
2813
|
+
}
|
|
2814
|
+
// -------------------------------------------------------------------
|
|
2815
|
+
// Lifecycle
|
|
2816
|
+
// -------------------------------------------------------------------
|
|
2817
|
+
close() {
|
|
2818
|
+
this.db.close();
|
|
2819
|
+
}
|
|
2820
|
+
/** Delete the underlying IndexedDB database (sign-out / revoked mount). */
|
|
2821
|
+
async destroy() {
|
|
2822
|
+
this.db.close();
|
|
2823
|
+
await import_dexie.default.delete(this.name);
|
|
2824
|
+
}
|
|
2825
|
+
// -------------------------------------------------------------------
|
|
2826
|
+
// Internals
|
|
2827
|
+
// -------------------------------------------------------------------
|
|
2828
|
+
async applyToServer(op) {
|
|
2829
|
+
if (!this.hasTable(op.table)) return;
|
|
2830
|
+
const existing = await this.server.get([op.table, op.record_id]);
|
|
2831
|
+
const next = applyOpToData(existing?.data, op);
|
|
2832
|
+
if (next === void 0) {
|
|
2833
|
+
await this.server.delete([op.table, op.record_id]);
|
|
2834
|
+
} else {
|
|
2835
|
+
await this.server.put({ table: op.table, record_id: op.record_id, data: next });
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
/**
|
|
2839
|
+
* Rebase one record: view = server data + pending ops for that record in
|
|
2840
|
+
* creation order. Must run inside a transaction covering all stores.
|
|
2841
|
+
*/
|
|
2842
|
+
async recomputeViewRecord(table, recordId) {
|
|
2843
|
+
if (!this.hasTable(table)) return null;
|
|
2844
|
+
const serverRow = await this.server.get([table, recordId]);
|
|
2845
|
+
let data = serverRow ? cloneData(serverRow.data) : void 0;
|
|
2846
|
+
const pendingRows = await this.pending.orderBy("idx").toArray();
|
|
2847
|
+
for (const row of pendingRows) {
|
|
2848
|
+
if (row.op.table === table && row.op.record_id === recordId) {
|
|
2849
|
+
data = applyOpToData(data, row.op);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
if (data === void 0) {
|
|
2853
|
+
await this.view(table).delete(recordId);
|
|
2854
|
+
return null;
|
|
2855
|
+
}
|
|
2856
|
+
const viewRecord = { id: recordId, ...data };
|
|
2857
|
+
await this.view(table).put(viewRecord);
|
|
2858
|
+
return viewRecord;
|
|
2859
|
+
}
|
|
2860
|
+
};
|
|
2861
|
+
|
|
2862
|
+
// src/core/sync/SyncEngine.ts
|
|
2863
|
+
var OWN_SUB = "own";
|
|
2864
|
+
function shareSubKey(shareId) {
|
|
2865
|
+
return `share:${shareId}`;
|
|
2866
|
+
}
|
|
2867
|
+
var BoundedSet = class {
|
|
2868
|
+
constructor(cap = 2048) {
|
|
2869
|
+
this.cap = cap;
|
|
2870
|
+
}
|
|
2871
|
+
set = /* @__PURE__ */ new Set();
|
|
2872
|
+
order = [];
|
|
2873
|
+
has(value) {
|
|
2874
|
+
return this.set.has(value);
|
|
2875
|
+
}
|
|
2876
|
+
add(value) {
|
|
2877
|
+
if (this.set.has(value)) return;
|
|
2878
|
+
this.set.add(value);
|
|
2879
|
+
this.order.push(value);
|
|
2880
|
+
if (this.order.length > this.cap) {
|
|
2881
|
+
const evicted = this.order.shift();
|
|
2882
|
+
if (evicted !== void 0) this.set.delete(evicted);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
clear() {
|
|
2886
|
+
this.set.clear();
|
|
2887
|
+
this.order = [];
|
|
2888
|
+
}
|
|
2889
|
+
};
|
|
2890
|
+
var RETRY_FLUSH_DELAY_MS = 1200;
|
|
2891
|
+
var SyncEngine = class {
|
|
2892
|
+
projectId;
|
|
2893
|
+
schema;
|
|
2894
|
+
opts;
|
|
2895
|
+
connection;
|
|
2896
|
+
subs = /* @__PURE__ */ new Map();
|
|
2897
|
+
limits = { ...DEFAULT_LIMITS };
|
|
2898
|
+
actor = null;
|
|
2899
|
+
started = false;
|
|
2900
|
+
revokedInfo = null;
|
|
2901
|
+
connectionStatus = "idle";
|
|
2902
|
+
_status = "idle";
|
|
2903
|
+
listeners = /* @__PURE__ */ new Map();
|
|
2904
|
+
timers = /* @__PURE__ */ new Set();
|
|
2905
|
+
constructor(opts) {
|
|
2906
|
+
this.opts = opts;
|
|
2907
|
+
this.projectId = opts.projectId;
|
|
2908
|
+
this.schema = opts.schema;
|
|
2909
|
+
this.connection = new SyncConnection({
|
|
2910
|
+
wsUrl: opts.wsUrl,
|
|
2911
|
+
getToken: opts.getToken,
|
|
2912
|
+
WebSocketImpl: opts.WebSocketImpl,
|
|
2913
|
+
heartbeatMs: opts.heartbeatMs,
|
|
2914
|
+
onWelcome: (msg) => this.handleWelcome(msg),
|
|
2915
|
+
onMessage: (msg) => this.handleMessage(msg),
|
|
2916
|
+
onStatus: (status) => this.handleConnectionStatus(status),
|
|
2917
|
+
log: opts.log
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
2920
|
+
// -------------------------------------------------------------------
|
|
2921
|
+
// Events
|
|
2922
|
+
// -------------------------------------------------------------------
|
|
2923
|
+
on(event, fn) {
|
|
2924
|
+
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2925
|
+
const set = this.listeners.get(event);
|
|
2926
|
+
set.add(fn);
|
|
2927
|
+
return () => set.delete(fn);
|
|
2928
|
+
}
|
|
2929
|
+
emit(event, data) {
|
|
2930
|
+
const set = this.listeners.get(event);
|
|
2931
|
+
if (!set) return;
|
|
2932
|
+
for (const fn of set) {
|
|
2933
|
+
try {
|
|
2934
|
+
fn(data);
|
|
2935
|
+
} catch (err) {
|
|
2936
|
+
this.log("listener error:", err);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
// -------------------------------------------------------------------
|
|
2941
|
+
// Public state
|
|
2942
|
+
// -------------------------------------------------------------------
|
|
2943
|
+
get status() {
|
|
2944
|
+
return this._status;
|
|
2945
|
+
}
|
|
2946
|
+
get syncLimits() {
|
|
2947
|
+
return { ...this.limits };
|
|
2948
|
+
}
|
|
2949
|
+
get serverActor() {
|
|
2950
|
+
return this.actor;
|
|
2951
|
+
}
|
|
2952
|
+
getSubscription(key) {
|
|
2953
|
+
return this.subs.get(key);
|
|
2954
|
+
}
|
|
2955
|
+
get own() {
|
|
2956
|
+
return this.subs.get(OWN_SUB);
|
|
2957
|
+
}
|
|
2958
|
+
get pendingCount() {
|
|
2959
|
+
let n = 0;
|
|
2960
|
+
for (const sub of this.subs.values()) n += sub.pending.length;
|
|
2961
|
+
return n;
|
|
2962
|
+
}
|
|
2963
|
+
async listRejected(subKey = OWN_SUB) {
|
|
2964
|
+
const sub = this.subs.get(subKey);
|
|
2965
|
+
if (!sub) return [];
|
|
2966
|
+
return sub.store.listRejected();
|
|
2967
|
+
}
|
|
2968
|
+
async clearRejected(subKey = OWN_SUB) {
|
|
2969
|
+
await this.subs.get(subKey)?.store.clearRejected();
|
|
2970
|
+
}
|
|
2971
|
+
// -------------------------------------------------------------------
|
|
2972
|
+
// Lifecycle
|
|
2973
|
+
// -------------------------------------------------------------------
|
|
2974
|
+
/** Open the own-channel keyspace and connect. Idempotent. */
|
|
2975
|
+
async start() {
|
|
2976
|
+
if (this.started) {
|
|
2977
|
+
if (this.connectionStatus === "auth_failed" || this._status === "auth_required") {
|
|
2978
|
+
this.connection.start();
|
|
2979
|
+
}
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
this.started = true;
|
|
2983
|
+
this.revokedInfo = null;
|
|
2984
|
+
if (!this.subs.has(OWN_SUB)) {
|
|
2985
|
+
const sub = await this.openSub(OWN_SUB, null);
|
|
2986
|
+
this.subs.set(OWN_SUB, sub);
|
|
2987
|
+
}
|
|
2988
|
+
this.connection.start();
|
|
2989
|
+
this.recomputeStatus();
|
|
2990
|
+
}
|
|
2991
|
+
/** Close the socket and stores; local data is kept. */
|
|
2992
|
+
stop() {
|
|
2993
|
+
this.started = false;
|
|
2994
|
+
this.connection.stop();
|
|
2995
|
+
for (const t of this.timers) clearTimeout(t);
|
|
2996
|
+
this.timers.clear();
|
|
2997
|
+
for (const sub of this.subs.values()) {
|
|
2998
|
+
sub.active = false;
|
|
2999
|
+
sub.store.close();
|
|
3000
|
+
}
|
|
3001
|
+
this.subs.clear();
|
|
3002
|
+
this.recomputeStatus();
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Stop and delete every local database for this project (sign-out).
|
|
3006
|
+
* Best-effort discovery of mount keyspaces from previous sessions.
|
|
3007
|
+
*/
|
|
3008
|
+
async destroyLocal() {
|
|
3009
|
+
const open = [...this.subs.values()];
|
|
3010
|
+
this.stop();
|
|
3011
|
+
for (const sub of open) {
|
|
3012
|
+
try {
|
|
3013
|
+
await sub.store.destroy();
|
|
3014
|
+
} catch (err) {
|
|
3015
|
+
this.log("failed deleting local db:", err);
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
try {
|
|
3019
|
+
const idb = globalThis.indexedDB;
|
|
3020
|
+
if (idb && typeof idb.databases === "function") {
|
|
3021
|
+
const prefix = `${this.dbPrefix}:${this.projectId}`;
|
|
3022
|
+
const dbs = await idb.databases();
|
|
3023
|
+
for (const info of dbs) {
|
|
3024
|
+
if (info.name && info.name.startsWith(prefix)) {
|
|
3025
|
+
await new Promise((resolve) => {
|
|
3026
|
+
const req = idb.deleteDatabase(info.name);
|
|
3027
|
+
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
} catch {
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
// -------------------------------------------------------------------
|
|
3036
|
+
// Shares (mounts)
|
|
3037
|
+
// -------------------------------------------------------------------
|
|
3038
|
+
/**
|
|
3039
|
+
* Mount a share: separate keyspace `(project, share)` with its own cursor
|
|
3040
|
+
* and pending queue. Bootstraps + subscribes when the socket is online.
|
|
3041
|
+
*/
|
|
3042
|
+
async mountShare(shareId) {
|
|
3043
|
+
const key = shareSubKey(shareId);
|
|
3044
|
+
const existing = this.subs.get(key);
|
|
3045
|
+
if (existing) return existing;
|
|
3046
|
+
const sub = await this.openSub(key, shareId);
|
|
3047
|
+
this.subs.set(key, sub);
|
|
3048
|
+
if (this.connection.isOnline) {
|
|
3049
|
+
this.enqueue(sub, () => this.activateSub(sub));
|
|
3050
|
+
}
|
|
3051
|
+
return sub;
|
|
3052
|
+
}
|
|
3053
|
+
/** Unsubscribe a mount. Local cache is kept unless `purge` is set. */
|
|
3054
|
+
async unmountShare(shareId, options) {
|
|
3055
|
+
const key = shareSubKey(shareId);
|
|
3056
|
+
const sub = this.subs.get(key);
|
|
3057
|
+
if (!sub) return;
|
|
3058
|
+
if (sub.active) {
|
|
3059
|
+
this.connection.send({ type: "unsubscribe", sub: key });
|
|
3060
|
+
}
|
|
3061
|
+
this.subs.delete(key);
|
|
3062
|
+
if (options?.purge) {
|
|
3063
|
+
await sub.store.destroy();
|
|
3064
|
+
} else {
|
|
3065
|
+
sub.store.close();
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
// -------------------------------------------------------------------
|
|
3069
|
+
// Writes (responsibility 2 + 6: pending queue + optimistic rebase)
|
|
3070
|
+
// -------------------------------------------------------------------
|
|
3071
|
+
/**
|
|
3072
|
+
* Queue a local op, apply it optimistically, and push when online.
|
|
3073
|
+
* Returns the resulting view record (null when deleted).
|
|
3074
|
+
* Throws on local validation failure (fail fast — the server would
|
|
3075
|
+
* terminally reject it anyway).
|
|
3076
|
+
*/
|
|
3077
|
+
async apply(subKey, partial) {
|
|
3078
|
+
const sub = this.subs.get(subKey);
|
|
3079
|
+
if (!sub) throw new Error(`unknown subscription '${subKey}' \u2014 is the engine started?`);
|
|
3080
|
+
if (!sub.store.hasTable(partial.table)) {
|
|
3081
|
+
throw new Error(`table "${partial.table}" not found in schema`);
|
|
3082
|
+
}
|
|
3083
|
+
if (this.opts.validateWrites !== false && partial.type !== "delete") {
|
|
3084
|
+
const result = (0, import_schema.validateData)(
|
|
3085
|
+
this.schema,
|
|
3086
|
+
partial.table,
|
|
3087
|
+
partial.data ?? {},
|
|
3088
|
+
partial.type === "put"
|
|
3089
|
+
);
|
|
3090
|
+
if (!result.valid) {
|
|
3091
|
+
throw new Error(result.message || "data validation failed");
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
const op = {
|
|
3095
|
+
op_id: mintOpId(),
|
|
3096
|
+
type: partial.type,
|
|
3097
|
+
table: partial.table,
|
|
3098
|
+
record_id: partial.record_id,
|
|
3099
|
+
...partial.type !== "delete" ? { data: partial.data ?? {} } : {},
|
|
3100
|
+
base_seq: Math.max(sub.cursor, 0)
|
|
3101
|
+
};
|
|
3102
|
+
const bytes = JSON.stringify(op).length;
|
|
3103
|
+
if (bytes > this.limits.max_op_bytes) {
|
|
3104
|
+
throw new Error(
|
|
3105
|
+
`op exceeds max_op_bytes (${bytes} > ${this.limits.max_op_bytes}) \u2014 PAYLOAD_TOO_LARGE`
|
|
3106
|
+
);
|
|
3107
|
+
}
|
|
3108
|
+
let view = null;
|
|
3109
|
+
await this.enqueue(sub, async () => {
|
|
3110
|
+
view = await sub.store.addPending(op);
|
|
3111
|
+
sub.pending.push({ op, sent: false });
|
|
3112
|
+
});
|
|
3113
|
+
this.emit("change", { sub: sub.key, tables: [op.table] });
|
|
3114
|
+
this.flush(sub);
|
|
3115
|
+
return view;
|
|
3116
|
+
}
|
|
3117
|
+
// -------------------------------------------------------------------
|
|
3118
|
+
// Connection handling
|
|
3119
|
+
// -------------------------------------------------------------------
|
|
3120
|
+
handleConnectionStatus(status) {
|
|
3121
|
+
this.connectionStatus = status;
|
|
3122
|
+
if (status === "offline" || status === "connecting" || status === "auth_failed") {
|
|
3123
|
+
for (const sub of this.subs.values()) {
|
|
3124
|
+
sub.active = false;
|
|
3125
|
+
for (const p of sub.pending) {
|
|
3126
|
+
p.sent = false;
|
|
3127
|
+
p.ackedSeq = void 0;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
this.recomputeStatus();
|
|
3132
|
+
}
|
|
3133
|
+
handleWelcome(msg) {
|
|
3134
|
+
this.actor = msg.actor;
|
|
3135
|
+
if (msg.limits) this.limits = { ...this.limits, ...msg.limits };
|
|
3136
|
+
for (const sub of this.subs.values()) {
|
|
3137
|
+
if (sub.status === "revoked") continue;
|
|
3138
|
+
this.enqueue(sub, () => this.activateSub(sub));
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
handleMessage(msg) {
|
|
3142
|
+
switch (msg.type) {
|
|
3143
|
+
case "subscribed":
|
|
3144
|
+
this.handleSubscribed(msg);
|
|
3145
|
+
return;
|
|
3146
|
+
case "ops": {
|
|
3147
|
+
const sub = this.subs.get(msg.sub);
|
|
3148
|
+
if (sub) this.enqueue(sub, () => this.processOps(sub, msg));
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
case "pushed": {
|
|
3152
|
+
const sub = this.subs.get(msg.sub);
|
|
3153
|
+
if (sub) this.enqueue(sub, () => this.processPushed(sub, msg));
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
case "error":
|
|
3157
|
+
this.handleError(msg);
|
|
3158
|
+
return;
|
|
3159
|
+
case "pong":
|
|
3160
|
+
case "token_ok":
|
|
3161
|
+
case "unsubscribed":
|
|
3162
|
+
case "welcome":
|
|
3163
|
+
return;
|
|
3164
|
+
default:
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
handleSubscribed(msg) {
|
|
3169
|
+
const sub = this.subs.get(msg.sub);
|
|
3170
|
+
if (!sub) return;
|
|
3171
|
+
sub.active = true;
|
|
3172
|
+
sub.status = "live";
|
|
3173
|
+
sub.schemaVersion = msg.schema_version;
|
|
3174
|
+
this.log(`subscribed '${sub.key}' channel=${msg.channel} cursor=${msg.cursor} head=${msg.head}`);
|
|
3175
|
+
this.flush(sub);
|
|
3176
|
+
}
|
|
3177
|
+
handleError(msg) {
|
|
3178
|
+
const subKey = msg.sub;
|
|
3179
|
+
this.log(`server error${subKey ? ` (sub ${subKey})` : ""}: ${msg.code} \u2014 ${msg.message ?? ""}`);
|
|
3180
|
+
if (subKey) {
|
|
3181
|
+
const sub = this.subs.get(subKey);
|
|
3182
|
+
if (!sub) return;
|
|
3183
|
+
this.emit("suberror", { sub: subKey, code: msg.code, message: msg.message });
|
|
3184
|
+
if (isRebootstrapError(msg.code)) {
|
|
3185
|
+
sub.active = false;
|
|
3186
|
+
sub.bootstrapped = false;
|
|
3187
|
+
for (const p of sub.pending) {
|
|
3188
|
+
p.sent = false;
|
|
3189
|
+
p.ackedSeq = void 0;
|
|
3190
|
+
}
|
|
3191
|
+
this.enqueue(sub, async () => {
|
|
3192
|
+
await this.activateSub(sub);
|
|
3193
|
+
});
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
if (msg.code === "SHARE_REVOKED" || msg.code === "CONNECTION_REVOKED") {
|
|
3197
|
+
sub.active = false;
|
|
3198
|
+
sub.status = "revoked";
|
|
3199
|
+
sub.revokedCode = msg.code;
|
|
3200
|
+
this.subs.delete(subKey);
|
|
3201
|
+
void sub.store.destroy().catch(() => {
|
|
3202
|
+
});
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
switch (msg.code) {
|
|
3208
|
+
case "CONNECTION_REVOKED":
|
|
3209
|
+
this.revokedInfo = { code: msg.code, message: msg.message };
|
|
3210
|
+
this.started = false;
|
|
3211
|
+
this.connection.stop();
|
|
3212
|
+
this.recomputeStatus();
|
|
3213
|
+
this.emit("revoked", { code: msg.code, message: msg.message });
|
|
3214
|
+
return;
|
|
3215
|
+
case "UNSUPPORTED_VERSION":
|
|
3216
|
+
this.started = false;
|
|
3217
|
+
this.connection.stop();
|
|
3218
|
+
this.recomputeStatus();
|
|
3219
|
+
return;
|
|
3220
|
+
case "TOO_MANY_OPS":
|
|
3221
|
+
case "RATE_LIMITED": {
|
|
3222
|
+
for (const sub of this.subs.values()) {
|
|
3223
|
+
for (const p of sub.pending) {
|
|
3224
|
+
if (p.ackedSeq === void 0) p.sent = false;
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
this.timer(() => {
|
|
3228
|
+
for (const sub of this.subs.values()) this.flush(sub);
|
|
3229
|
+
}, RETRY_FLUSH_DELAY_MS);
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
case "UNAUTHORIZED":
|
|
3233
|
+
case "TOKEN_EXPIRED":
|
|
3234
|
+
return;
|
|
3235
|
+
case "SNAPSHOT_REQUIRED":
|
|
3236
|
+
case "RESET_REQUIRED":
|
|
3237
|
+
for (const sub of this.subs.values()) {
|
|
3238
|
+
sub.active = false;
|
|
3239
|
+
sub.bootstrapped = false;
|
|
3240
|
+
this.enqueue(sub, () => this.activateSub(sub));
|
|
3241
|
+
}
|
|
3242
|
+
return;
|
|
3243
|
+
default:
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
// -------------------------------------------------------------------
|
|
3248
|
+
// Subscription state machine (serialized per sub via `chain`)
|
|
3249
|
+
// -------------------------------------------------------------------
|
|
3250
|
+
async openSub(key, shareId) {
|
|
3251
|
+
const name = shareId ? `${this.dbPrefix}:${this.projectId}:share:${shareId}` : `${this.dbPrefix}:${this.projectId}`;
|
|
3252
|
+
const store = new SyncStore(name, this.schema);
|
|
3253
|
+
const [cursor, pendingRows] = await Promise.all([store.getCursor(), store.loadPending()]);
|
|
3254
|
+
return {
|
|
3255
|
+
key,
|
|
3256
|
+
shareId,
|
|
3257
|
+
store,
|
|
3258
|
+
cursor: cursor ?? -1,
|
|
3259
|
+
// -1 = never bootstrapped
|
|
3260
|
+
pending: pendingRows.map((row) => ({ op: row.op, sent: false })),
|
|
3261
|
+
active: false,
|
|
3262
|
+
bootstrapped: cursor !== null,
|
|
3263
|
+
status: "initializing",
|
|
3264
|
+
appliedOpIds: new BoundedSet(),
|
|
3265
|
+
chain: Promise.resolve(),
|
|
3266
|
+
schemaVersion: null
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
/** Bootstrap if needed, then bind the stream on the current socket. */
|
|
3270
|
+
async activateSub(sub) {
|
|
3271
|
+
if (!this.connection.isOnline) return;
|
|
3272
|
+
if (!sub.bootstrapped || sub.cursor < 0) {
|
|
3273
|
+
try {
|
|
3274
|
+
await this.bootstrapSub(sub);
|
|
3275
|
+
} catch (err) {
|
|
3276
|
+
this.log(`bootstrap failed for '${sub.key}':`, err);
|
|
3277
|
+
sub.status = "error";
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
this.connection.send({
|
|
3282
|
+
type: "subscribe",
|
|
3283
|
+
sub: sub.key,
|
|
3284
|
+
cursor: sub.cursor,
|
|
3285
|
+
...sub.shareId ? { share: sub.shareId } : this.opts.appName ? { app: this.opts.appName } : {}
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
/** Cold start = snapshot + tail; never log replay (§5). Pending survives. */
|
|
3289
|
+
async bootstrapSub(sub) {
|
|
3290
|
+
const snapshot = await this.opts.fetchSnapshot(
|
|
3291
|
+
sub.shareId ? { share: sub.shareId } : void 0
|
|
3292
|
+
);
|
|
3293
|
+
await sub.store.replaceFromSnapshot({
|
|
3294
|
+
channel: snapshot.channel,
|
|
3295
|
+
records: snapshot.records ?? {},
|
|
3296
|
+
cursor: snapshot.cursor ?? 0
|
|
3297
|
+
});
|
|
3298
|
+
sub.cursor = snapshot.cursor ?? 0;
|
|
3299
|
+
sub.bootstrapped = true;
|
|
3300
|
+
sub.appliedOpIds.clear();
|
|
3301
|
+
this.log(`bootstrapped '${sub.key}' cursor=${sub.cursor}`);
|
|
3302
|
+
this.emit("change", { sub: sub.key, tables: sub.store.tables });
|
|
3303
|
+
}
|
|
3304
|
+
/**
|
|
3305
|
+
* Responsibilities 3+4+5: ordered apply, cursor advance, dedupe/confirm.
|
|
3306
|
+
* Runs inside the sub's serial chain.
|
|
3307
|
+
*/
|
|
3308
|
+
async processOps(sub, msg) {
|
|
3309
|
+
const applyOps = [];
|
|
3310
|
+
const confirmedOpIds = [];
|
|
3311
|
+
for (const op of msg.ops) {
|
|
3312
|
+
if (typeof op.seq !== "number" || op.seq <= sub.cursor) continue;
|
|
3313
|
+
sub.cursor = op.seq;
|
|
3314
|
+
if (sub.appliedOpIds.has(op.op_id)) continue;
|
|
3315
|
+
const idx = sub.pending.findIndex((p) => p.op.op_id === op.op_id);
|
|
3316
|
+
if (idx >= 0) {
|
|
3317
|
+
sub.pending.splice(idx, 1);
|
|
3318
|
+
confirmedOpIds.push(op.op_id);
|
|
3319
|
+
}
|
|
3320
|
+
if (!sub.store.hasTable(op.table)) {
|
|
3321
|
+
sub.appliedOpIds.add(op.op_id);
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
applyOps.push(op);
|
|
3325
|
+
sub.appliedOpIds.add(op.op_id);
|
|
3326
|
+
}
|
|
3327
|
+
if (typeof msg.cursor === "number" && msg.cursor > sub.cursor) {
|
|
3328
|
+
sub.cursor = msg.cursor;
|
|
3329
|
+
}
|
|
3330
|
+
if (applyOps.length > 0 || confirmedOpIds.length > 0) {
|
|
3331
|
+
await sub.store.commitIncoming({ applyOps, confirmedOpIds, cursor: sub.cursor });
|
|
3332
|
+
const tables = [...new Set(applyOps.map((op) => op.table))];
|
|
3333
|
+
this.emit("change", { sub: sub.key, tables });
|
|
3334
|
+
} else {
|
|
3335
|
+
await sub.store.setCursor(sub.cursor);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
/**
|
|
3339
|
+
* Push verdicts (§6.5, §8, §9). Success acks are recorded but the op stays
|
|
3340
|
+
* pending until its echo arrives in seq order — this preserves strict
|
|
3341
|
+
* ordered apply even when `pushed` races ahead of intermediate remote ops.
|
|
3342
|
+
* Terminal errors apply the poison-op rule; retryables back off.
|
|
3343
|
+
*/
|
|
3344
|
+
async processPushed(sub, msg) {
|
|
3345
|
+
let needsRetry = false;
|
|
3346
|
+
const changedTables = /* @__PURE__ */ new Set();
|
|
3347
|
+
for (const result of msg.results) {
|
|
3348
|
+
const idx = sub.pending.findIndex((p) => p.op.op_id === result.op_id);
|
|
3349
|
+
if ("seq" in result && typeof result.seq === "number") {
|
|
3350
|
+
if (idx >= 0) {
|
|
3351
|
+
if (result.seq <= sub.cursor) {
|
|
3352
|
+
const [entry] = sub.pending.splice(idx, 1);
|
|
3353
|
+
await sub.store.commitIncoming({
|
|
3354
|
+
applyOps: [],
|
|
3355
|
+
confirmedOpIds: [entry.op.op_id],
|
|
3356
|
+
cursor: sub.cursor
|
|
3357
|
+
});
|
|
3358
|
+
changedTables.add(entry.op.table);
|
|
3359
|
+
} else {
|
|
3360
|
+
sub.pending[idx].ackedSeq = result.seq;
|
|
3361
|
+
await sub.store.markAcked(result.op_id, result.seq);
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
if ("error" in result) {
|
|
3367
|
+
if (isTerminalOpError(result.error, result.terminal)) {
|
|
3368
|
+
if (idx >= 0) sub.pending.splice(idx, 1);
|
|
3369
|
+
const rejection = await sub.store.rejectPending(
|
|
3370
|
+
result.op_id,
|
|
3371
|
+
result.error,
|
|
3372
|
+
result.message
|
|
3373
|
+
);
|
|
3374
|
+
if (rejection) {
|
|
3375
|
+
changedTables.add(rejection.op.table);
|
|
3376
|
+
this.emit("rejected", { sub: sub.key, rejection });
|
|
3377
|
+
this.log(
|
|
3378
|
+
`op rejected (${result.error}): ${rejection.op.type} ${rejection.op.table}/${rejection.op.record_id}`
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
} else if (idx >= 0) {
|
|
3382
|
+
sub.pending[idx].sent = false;
|
|
3383
|
+
needsRetry = true;
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
if (changedTables.size > 0) {
|
|
3388
|
+
this.emit("change", { sub: sub.key, tables: [...changedTables] });
|
|
3389
|
+
}
|
|
3390
|
+
if (needsRetry) {
|
|
3391
|
+
this.timer(() => this.flush(sub), RETRY_FLUSH_DELAY_MS);
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
/** Push unsent pending ops, chunked to `limits.max_ops_per_push`. */
|
|
3395
|
+
flush(sub) {
|
|
3396
|
+
if (!this.connection.isOnline || !sub.active) return;
|
|
3397
|
+
const unsent = sub.pending.filter((p) => !p.sent && p.ackedSeq === void 0);
|
|
3398
|
+
if (unsent.length === 0) return;
|
|
3399
|
+
const chunkSize = Math.max(1, this.limits.max_ops_per_push);
|
|
3400
|
+
for (let i = 0; i < unsent.length; i += chunkSize) {
|
|
3401
|
+
const chunk = unsent.slice(i, i + chunkSize);
|
|
3402
|
+
for (const p of chunk) p.sent = true;
|
|
3403
|
+
const ok = this.connection.send({
|
|
3404
|
+
type: "push",
|
|
3405
|
+
sub: sub.key,
|
|
3406
|
+
ops: chunk.map((p) => p.op)
|
|
3407
|
+
});
|
|
3408
|
+
if (!ok) {
|
|
3409
|
+
for (const p of chunk) p.sent = false;
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
this.log(`pushed ${unsent.length} op(s) on '${sub.key}'`);
|
|
3414
|
+
}
|
|
3415
|
+
// -------------------------------------------------------------------
|
|
3416
|
+
// Internals
|
|
3417
|
+
// -------------------------------------------------------------------
|
|
3418
|
+
get dbPrefix() {
|
|
3419
|
+
return this.opts.dbNamePrefix ?? "basic-sync";
|
|
3420
|
+
}
|
|
3421
|
+
enqueue(sub, task) {
|
|
3422
|
+
sub.chain = sub.chain.then(task).catch((err) => {
|
|
3423
|
+
this.log(`task failed on '${sub.key}':`, err);
|
|
3424
|
+
});
|
|
3425
|
+
return sub.chain;
|
|
3426
|
+
}
|
|
3427
|
+
timer(fn, ms) {
|
|
3428
|
+
const t = setTimeout(() => {
|
|
3429
|
+
this.timers.delete(t);
|
|
3430
|
+
fn();
|
|
3431
|
+
}, ms);
|
|
3432
|
+
this.timers.add(t);
|
|
3433
|
+
}
|
|
3434
|
+
recomputeStatus() {
|
|
3435
|
+
let status;
|
|
3436
|
+
if (this.revokedInfo) status = "revoked";
|
|
3437
|
+
else if (this.connectionStatus === "auth_failed") status = "auth_required";
|
|
3438
|
+
else if (!this.started) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
|
|
3439
|
+
else if (this.connectionStatus === "online") status = "online";
|
|
3440
|
+
else if (this.connectionStatus === "connecting") status = "connecting";
|
|
3441
|
+
else if (this.connectionStatus === "idle") status = "connecting";
|
|
3442
|
+
else status = "offline";
|
|
3443
|
+
if (status !== this._status) {
|
|
3444
|
+
this._status = status;
|
|
3445
|
+
this.emit("status", status);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
log(...args) {
|
|
3449
|
+
this.opts.log?.("[sync-engine]", ...args);
|
|
3450
|
+
}
|
|
3451
|
+
};
|
|
3452
|
+
|
|
3453
|
+
// src/core/db.ts
|
|
3454
|
+
function mintRecordId() {
|
|
3455
|
+
return mintOpId();
|
|
3456
|
+
}
|
|
3457
|
+
var SyncTable = class {
|
|
3458
|
+
constructor(engine, subKey, name) {
|
|
3459
|
+
this.engine = engine;
|
|
3460
|
+
this.subKey = subKey;
|
|
3461
|
+
this.name = name;
|
|
3462
|
+
}
|
|
3463
|
+
get store() {
|
|
3464
|
+
const sub = this.engine.getSubscription(this.subKey);
|
|
3465
|
+
if (!sub) {
|
|
3466
|
+
throw new Error(
|
|
3467
|
+
`subscription '${this.subKey}' is not open \u2014 sign in and wait for the db to be ready`
|
|
3468
|
+
);
|
|
3469
|
+
}
|
|
3470
|
+
return sub.store;
|
|
3471
|
+
}
|
|
3472
|
+
get ref() {
|
|
3473
|
+
return this.store.view(this.name);
|
|
3474
|
+
}
|
|
3475
|
+
async create(data) {
|
|
3476
|
+
const id = mintRecordId();
|
|
3477
|
+
const view = await this.engine.apply(this.subKey, {
|
|
3478
|
+
type: "put",
|
|
3479
|
+
table: this.name,
|
|
3480
|
+
record_id: id,
|
|
3481
|
+
data
|
|
3482
|
+
});
|
|
3483
|
+
return view ?? { id, ...data };
|
|
3484
|
+
}
|
|
3485
|
+
async put(id, data) {
|
|
3486
|
+
if (!id) throw new Error("put() requires an id");
|
|
3487
|
+
const view = await this.engine.apply(this.subKey, {
|
|
3488
|
+
type: "put",
|
|
3489
|
+
table: this.name,
|
|
3490
|
+
record_id: id,
|
|
3491
|
+
data
|
|
3492
|
+
});
|
|
3493
|
+
return view ?? { id, ...data };
|
|
3494
|
+
}
|
|
3495
|
+
async patch(id, data) {
|
|
3496
|
+
if (!id) throw new Error("patch() requires an id");
|
|
3497
|
+
const existing = await this.store.getViewRecord(this.name, id);
|
|
3498
|
+
if (!existing) return null;
|
|
3499
|
+
const view = await this.engine.apply(this.subKey, {
|
|
3500
|
+
type: "patch",
|
|
3501
|
+
table: this.name,
|
|
3502
|
+
record_id: id,
|
|
3503
|
+
data
|
|
3504
|
+
});
|
|
3505
|
+
return view;
|
|
3506
|
+
}
|
|
3507
|
+
async delete(id) {
|
|
3508
|
+
if (!id) throw new Error("delete() requires an id");
|
|
3509
|
+
await this.engine.apply(this.subKey, {
|
|
3510
|
+
type: "delete",
|
|
3511
|
+
table: this.name,
|
|
3512
|
+
record_id: id
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
async get(id) {
|
|
3516
|
+
return await this.store.getViewRecord(this.name, id);
|
|
3517
|
+
}
|
|
3518
|
+
async getAll() {
|
|
3519
|
+
return await this.store.getViewRecords(this.name);
|
|
3520
|
+
}
|
|
3521
|
+
async find(predicate) {
|
|
3522
|
+
const all = await this.getAll();
|
|
3523
|
+
return all.filter(predicate);
|
|
3524
|
+
}
|
|
3525
|
+
};
|
|
3526
|
+
var SyncDb = class {
|
|
3527
|
+
constructor(engine, subKey = OWN_SUB) {
|
|
3528
|
+
this.engine = engine;
|
|
3529
|
+
this.subKey = subKey;
|
|
3530
|
+
}
|
|
3531
|
+
kind = "sync";
|
|
3532
|
+
tables = /* @__PURE__ */ new Map();
|
|
3533
|
+
table(name) {
|
|
3534
|
+
if (!this.engine.schema.tables[name]) {
|
|
3535
|
+
throw new Error(`table "${name}" not found in schema`);
|
|
3536
|
+
}
|
|
3537
|
+
if (!this.tables.has(name)) {
|
|
3538
|
+
this.tables.set(name, new SyncTable(this.engine, this.subKey, name));
|
|
3539
|
+
}
|
|
3540
|
+
return this.tables.get(name);
|
|
3541
|
+
}
|
|
3542
|
+
};
|
|
3543
|
+
var RestTable = class {
|
|
3544
|
+
constructor(rest, name) {
|
|
3545
|
+
this.rest = rest;
|
|
3546
|
+
this.name = name;
|
|
3547
|
+
}
|
|
3548
|
+
async create(data) {
|
|
3549
|
+
const record = await this.rest.createRecord(this.name, data);
|
|
3550
|
+
return record;
|
|
3551
|
+
}
|
|
3552
|
+
async put(id, data) {
|
|
3553
|
+
if (!id) throw new Error("put() requires an id");
|
|
3554
|
+
const record = await this.rest.putRecord(this.name, id, data);
|
|
3555
|
+
if (!record) throw new Error(`record ${this.name}/${id} not found (REST put is replace-only)`);
|
|
3556
|
+
return record;
|
|
3557
|
+
}
|
|
3558
|
+
async patch(id, data) {
|
|
3559
|
+
if (!id) throw new Error("patch() requires an id");
|
|
3560
|
+
const record = await this.rest.patchRecord(this.name, id, data);
|
|
3561
|
+
return record;
|
|
3562
|
+
}
|
|
3563
|
+
async delete(id) {
|
|
3564
|
+
if (!id) throw new Error("delete() requires an id");
|
|
3565
|
+
await this.rest.deleteRecord(this.name, id);
|
|
3566
|
+
}
|
|
3567
|
+
async get(id) {
|
|
3568
|
+
const record = await this.rest.getRecord(this.name, id);
|
|
3569
|
+
return record;
|
|
3570
|
+
}
|
|
3571
|
+
async getAll() {
|
|
3572
|
+
return await this.rest.list(this.name);
|
|
3573
|
+
}
|
|
3574
|
+
async find(predicate) {
|
|
3575
|
+
const all = await this.getAll();
|
|
3576
|
+
return all.filter(predicate);
|
|
3577
|
+
}
|
|
3578
|
+
};
|
|
3579
|
+
var RestDb = class {
|
|
3580
|
+
constructor(rest, schema) {
|
|
3581
|
+
this.rest = rest;
|
|
3582
|
+
this.schema = schema;
|
|
3583
|
+
}
|
|
3584
|
+
kind = "rest";
|
|
3585
|
+
tables = /* @__PURE__ */ new Map();
|
|
3586
|
+
table(name) {
|
|
3587
|
+
if (this.schema?.tables && !this.schema.tables[name]) {
|
|
3588
|
+
throw new Error(`table "${name}" not found in schema`);
|
|
3589
|
+
}
|
|
3590
|
+
if (!this.tables.has(name)) {
|
|
3591
|
+
this.tables.set(name, new RestTable(this.rest, name));
|
|
3592
|
+
}
|
|
3593
|
+
return this.tables.get(name);
|
|
3594
|
+
}
|
|
3595
|
+
};
|
|
3596
|
+
|
|
3597
|
+
// src/utils/schema.ts
|
|
3598
|
+
var import_schema2 = require("@basictech/schema");
|
|
3599
|
+
init_config();
|
|
2531
3600
|
async function getSchemaStatus(schema) {
|
|
2532
3601
|
const projectId = schema.project_id;
|
|
2533
|
-
const valid = (0,
|
|
3602
|
+
const valid = (0, import_schema2.validateSchema)(schema);
|
|
2534
3603
|
if (!valid.valid) {
|
|
2535
3604
|
console.warn("BasicDB Error: your local schema is invalid. Please fix errors and try again - sync is disabled");
|
|
2536
3605
|
return {
|
|
@@ -2568,7 +3637,7 @@ async function getSchemaStatus(schema) {
|
|
|
2568
3637
|
latest: latestSchema
|
|
2569
3638
|
};
|
|
2570
3639
|
} else if (latestSchema.version === schema.version) {
|
|
2571
|
-
const changes = (0,
|
|
3640
|
+
const changes = (0, import_schema2.compareSchemas)(schema, latestSchema);
|
|
2572
3641
|
if (changes.valid) {
|
|
2573
3642
|
return {
|
|
2574
3643
|
valid: true,
|
|
@@ -2583,435 +3652,574 @@ async function getSchemaStatus(schema) {
|
|
|
2583
3652
|
latest: latestSchema
|
|
2584
3653
|
};
|
|
2585
3654
|
}
|
|
2586
|
-
} else {
|
|
2587
|
-
return {
|
|
2588
|
-
valid: false,
|
|
2589
|
-
status: "error",
|
|
2590
|
-
latest: null
|
|
3655
|
+
} else {
|
|
3656
|
+
return {
|
|
3657
|
+
valid: false,
|
|
3658
|
+
status: "error",
|
|
3659
|
+
latest: null
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
async function validateAndCheckSchema(schema) {
|
|
3664
|
+
const valid = (0, import_schema2.validateSchema)(schema);
|
|
3665
|
+
if (!valid.valid) {
|
|
3666
|
+
log("Basic Schema is invalid!", valid.errors);
|
|
3667
|
+
console.group("Schema Errors");
|
|
3668
|
+
let errorMessage = "";
|
|
3669
|
+
valid.errors.forEach((error, index) => {
|
|
3670
|
+
log(`${index + 1}:`, error.message, ` - at ${error.instancePath}`);
|
|
3671
|
+
errorMessage += `${index + 1}: ${error.message} - at ${error.instancePath}
|
|
3672
|
+
`;
|
|
3673
|
+
});
|
|
3674
|
+
console.groupEnd();
|
|
3675
|
+
return {
|
|
3676
|
+
isValid: false,
|
|
3677
|
+
schemaStatus: { valid: false },
|
|
3678
|
+
errors: valid.errors
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3681
|
+
let schemaStatus = { valid: false };
|
|
3682
|
+
if (schema.version !== 0) {
|
|
3683
|
+
schemaStatus = await getSchemaStatus(schema);
|
|
3684
|
+
log("schemaStatus", schemaStatus);
|
|
3685
|
+
} else {
|
|
3686
|
+
schemaStatus = { valid: false, status: "unpublished" };
|
|
3687
|
+
log("schema not published - at version 0");
|
|
3688
|
+
}
|
|
3689
|
+
return {
|
|
3690
|
+
isValid: true,
|
|
3691
|
+
schemaStatus
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// src/updater/versionUpdater.ts
|
|
3696
|
+
init_config();
|
|
3697
|
+
var VersionUpdater = class {
|
|
3698
|
+
storage;
|
|
3699
|
+
currentVersion;
|
|
3700
|
+
migrations;
|
|
3701
|
+
versionKey = "basic_app_version";
|
|
3702
|
+
constructor(storage, currentVersion, migrations = []) {
|
|
3703
|
+
this.storage = storage;
|
|
3704
|
+
this.currentVersion = currentVersion;
|
|
3705
|
+
this.migrations = migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
|
|
3706
|
+
}
|
|
3707
|
+
/**
|
|
3708
|
+
* Check current stored version and run migrations if needed
|
|
3709
|
+
* Only compares major.minor versions, ignoring beta/prerelease parts
|
|
3710
|
+
* Example: "0.7.0-beta.1" and "0.7.0" are treated as the same version
|
|
3711
|
+
*/
|
|
3712
|
+
async checkAndUpdate() {
|
|
3713
|
+
const storedVersion = await this.getStoredVersion();
|
|
3714
|
+
if (!storedVersion) {
|
|
3715
|
+
await this.setStoredVersion(this.currentVersion);
|
|
3716
|
+
return { updated: false, toVersion: this.currentVersion };
|
|
3717
|
+
}
|
|
3718
|
+
if (storedVersion === this.currentVersion) {
|
|
3719
|
+
return { updated: false, toVersion: this.currentVersion };
|
|
3720
|
+
}
|
|
3721
|
+
const migrationsToRun = this.getMigrationsToRun(storedVersion, this.currentVersion);
|
|
3722
|
+
if (migrationsToRun.length === 0) {
|
|
3723
|
+
await this.setStoredVersion(this.currentVersion);
|
|
3724
|
+
return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
|
|
3725
|
+
}
|
|
3726
|
+
for (const migration of migrationsToRun) {
|
|
3727
|
+
try {
|
|
3728
|
+
log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`);
|
|
3729
|
+
await migration.migrate(this.storage);
|
|
3730
|
+
} catch (error) {
|
|
3731
|
+
console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error);
|
|
3732
|
+
throw new Error(`Migration failed: ${error}`);
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
await this.setStoredVersion(this.currentVersion);
|
|
3736
|
+
return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
|
|
3737
|
+
}
|
|
3738
|
+
async getStoredVersion() {
|
|
3739
|
+
try {
|
|
3740
|
+
const versionData = await this.storage.get(this.versionKey);
|
|
3741
|
+
if (!versionData) return null;
|
|
3742
|
+
const versionInfo = JSON.parse(versionData);
|
|
3743
|
+
return versionInfo.version;
|
|
3744
|
+
} catch (error) {
|
|
3745
|
+
console.warn("Failed to get stored version:", error);
|
|
3746
|
+
return null;
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
async setStoredVersion(version2) {
|
|
3750
|
+
const versionInfo = {
|
|
3751
|
+
version: version2,
|
|
3752
|
+
lastUpdated: Date.now()
|
|
2591
3753
|
};
|
|
3754
|
+
await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
|
|
2592
3755
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
valid.errors.forEach((error, index) => {
|
|
2601
|
-
log(`${index + 1}:`, error.message, ` - at ${error.instancePath}`);
|
|
2602
|
-
errorMessage += `${index + 1}: ${error.message} - at ${error.instancePath}
|
|
2603
|
-
`;
|
|
3756
|
+
getMigrationsToRun(fromVersion, toVersion) {
|
|
3757
|
+
return this.migrations.filter((migration) => {
|
|
3758
|
+
const storedLessThanMigrationTo = this.compareVersions(fromVersion, migration.toVersion) < 0;
|
|
3759
|
+
const currentGreaterThanOrEqualMigrationTo = this.compareVersions(toVersion, migration.toVersion) >= 0;
|
|
3760
|
+
const shouldRun = storedLessThanMigrationTo && currentGreaterThanOrEqualMigrationTo;
|
|
3761
|
+
log(`Migration ${migration.fromVersion} \u2192 ${migration.toVersion}: shouldRun=${shouldRun}`);
|
|
3762
|
+
return shouldRun;
|
|
2604
3763
|
});
|
|
2605
|
-
|
|
3764
|
+
}
|
|
3765
|
+
/**
|
|
3766
|
+
* Simple semantic version comparison (major.minor only, ignoring beta/prerelease)
|
|
3767
|
+
* Returns: -1 if a < b, 0 if a === b, 1 if a > b
|
|
3768
|
+
*/
|
|
3769
|
+
compareVersions(a, b) {
|
|
3770
|
+
const aMajorMinor = this.extractMajorMinor(a);
|
|
3771
|
+
const bMajorMinor = this.extractMajorMinor(b);
|
|
3772
|
+
if (aMajorMinor.major !== bMajorMinor.major) {
|
|
3773
|
+
return aMajorMinor.major - bMajorMinor.major;
|
|
3774
|
+
}
|
|
3775
|
+
return aMajorMinor.minor - bMajorMinor.minor;
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Extract major.minor from version string, ignoring beta/prerelease
|
|
3779
|
+
* Examples: "0.7.0-beta.1" -> {major: 0, minor: 7}
|
|
3780
|
+
* "1.2.3" -> {major: 1, minor: 2}
|
|
3781
|
+
*/
|
|
3782
|
+
extractMajorMinor(version2) {
|
|
3783
|
+
const cleanVersion = version2.split("-")[0]?.split("+")[0] || version2;
|
|
3784
|
+
const parts = cleanVersion.split(".").map(Number);
|
|
2606
3785
|
return {
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
errors: valid.errors
|
|
3786
|
+
major: parts[0] || 0,
|
|
3787
|
+
minor: parts[1] || 0
|
|
2610
3788
|
};
|
|
2611
3789
|
}
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
log("schema not published - at version 0");
|
|
3790
|
+
/**
|
|
3791
|
+
* Add a migration to the updater
|
|
3792
|
+
*/
|
|
3793
|
+
addMigration(migration) {
|
|
3794
|
+
this.migrations.push(migration);
|
|
3795
|
+
this.migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
|
|
2619
3796
|
}
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
};
|
|
3797
|
+
};
|
|
3798
|
+
function createVersionUpdater(storage, currentVersion, migrations = []) {
|
|
3799
|
+
return new VersionUpdater(storage, currentVersion, migrations);
|
|
2624
3800
|
}
|
|
2625
3801
|
|
|
2626
|
-
// src/
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
);
|
|
2633
|
-
|
|
3802
|
+
// src/updater/updateMigrations.ts
|
|
3803
|
+
init_config();
|
|
3804
|
+
var addMigrationTimestamp = {
|
|
3805
|
+
fromVersion: "0.6.0",
|
|
3806
|
+
toVersion: "0.7.0",
|
|
3807
|
+
async migrate(storage) {
|
|
3808
|
+
log("Running migration 0.6.0 \u2192 0.7.0");
|
|
3809
|
+
storage.set("test_migration", "true");
|
|
3810
|
+
}
|
|
3811
|
+
};
|
|
3812
|
+
var dropLegacySyncDb = {
|
|
3813
|
+
fromVersion: "0.8.0",
|
|
3814
|
+
toVersion: "0.9.0",
|
|
3815
|
+
async migrate() {
|
|
3816
|
+
log("Running migration 0.8.0 \u2192 0.9.0: deleting legacy basicdb");
|
|
3817
|
+
try {
|
|
3818
|
+
const idb = globalThis.indexedDB;
|
|
3819
|
+
if (!idb) return;
|
|
3820
|
+
await new Promise((resolve) => {
|
|
3821
|
+
const req = idb.deleteDatabase("basicdb");
|
|
3822
|
+
req.onsuccess = req.onerror = req.onblocked = () => resolve();
|
|
3823
|
+
});
|
|
3824
|
+
} catch {
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
};
|
|
3828
|
+
function getMigrations() {
|
|
3829
|
+
return [
|
|
3830
|
+
addMigrationTimestamp,
|
|
3831
|
+
dropLegacySyncDb
|
|
3832
|
+
];
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
// src/core/BasicClient.ts
|
|
3836
|
+
init_config();
|
|
3837
|
+
init_package();
|
|
3838
|
+
var DEFAULTS = {
|
|
2634
3839
|
scopes: "profile,email,app:admin",
|
|
2635
3840
|
pds_url: "https://pds.basic.id",
|
|
2636
|
-
admin_url: "https://api.basic.tech"
|
|
2637
|
-
ws_url: "wss://pds.basic.id/ws"
|
|
3841
|
+
admin_url: "https://api.basic.tech"
|
|
2638
3842
|
};
|
|
2639
|
-
function
|
|
2640
|
-
return
|
|
2641
|
-
isSignedIn: mgr.isSignedIn,
|
|
2642
|
-
hasToken: !!mgr.token,
|
|
2643
|
-
isAuthReady: mgr.isAuthReady,
|
|
2644
|
-
user: mgr.user,
|
|
2645
|
-
did: mgr.did,
|
|
2646
|
-
tokenScope: mgr.tokenScope
|
|
2647
|
-
};
|
|
3843
|
+
function deriveSyncUrl(pdsUrl) {
|
|
3844
|
+
return pdsUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/sync/";
|
|
2648
3845
|
}
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
tokenScope: null
|
|
2681
|
-
});
|
|
2682
|
-
const authRef = (0, import_react3.useRef)(null);
|
|
2683
|
-
if (!authRef.current) {
|
|
2684
|
-
authRef.current = new AuthManager(
|
|
3846
|
+
var BasicClient = class {
|
|
3847
|
+
auth;
|
|
3848
|
+
rest;
|
|
3849
|
+
engine;
|
|
3850
|
+
mode;
|
|
3851
|
+
config;
|
|
3852
|
+
projectId;
|
|
3853
|
+
syncDb;
|
|
3854
|
+
restDb;
|
|
3855
|
+
debug;
|
|
3856
|
+
devInfo = null;
|
|
3857
|
+
syncEnabled = false;
|
|
3858
|
+
schemaChecked = false;
|
|
3859
|
+
started = false;
|
|
3860
|
+
cleanupFns = [];
|
|
3861
|
+
mounts = /* @__PURE__ */ new Map();
|
|
3862
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3863
|
+
snapshot;
|
|
3864
|
+
constructor(config) {
|
|
3865
|
+
this.config = config;
|
|
3866
|
+
this.debug = config.debug ?? false;
|
|
3867
|
+
this.mode = config.mode ?? "sync";
|
|
3868
|
+
this.projectId = config.schema?.project_id || config.project_id;
|
|
3869
|
+
const authConfig = {
|
|
3870
|
+
scopes: Array.isArray(config.auth?.scopes) ? config.auth.scopes.join(" ") : config.auth?.scopes || DEFAULTS.scopes,
|
|
3871
|
+
pds_url: config.auth?.pds_url || DEFAULTS.pds_url,
|
|
3872
|
+
admin_url: config.auth?.admin_url || DEFAULTS.admin_url
|
|
3873
|
+
};
|
|
3874
|
+
const syncUrl = config.auth?.sync_url || deriveSyncUrl(authConfig.pds_url);
|
|
3875
|
+
const storage = config.storage || new LocalStorageAdapter();
|
|
3876
|
+
this.auth = new AuthManager(
|
|
2685
3877
|
{
|
|
2686
|
-
projectId:
|
|
2687
|
-
scopes:
|
|
3878
|
+
projectId: this.projectId,
|
|
3879
|
+
scopes: authConfig.scopes,
|
|
2688
3880
|
pdsUrl: authConfig.pds_url,
|
|
2689
3881
|
adminUrl: authConfig.admin_url,
|
|
2690
|
-
debug
|
|
3882
|
+
debug: this.debug
|
|
2691
3883
|
},
|
|
2692
|
-
|
|
2693
|
-
() =>
|
|
3884
|
+
storage,
|
|
3885
|
+
() => this.handleAuthChange()
|
|
2694
3886
|
);
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
localVersion: void 0,
|
|
2711
|
-
status: "no_schema",
|
|
2712
|
-
valid: false,
|
|
2713
|
-
lastCheckedAt: Date.now()
|
|
2714
|
-
} : null
|
|
2715
|
-
);
|
|
2716
|
-
return;
|
|
2717
|
-
}
|
|
2718
|
-
const result = await validateAndCheckSchema(s);
|
|
2719
|
-
if (!result.isValid) {
|
|
2720
|
-
const errText = result.errors?.map((e) => e.message || "").join("; ") || "invalid";
|
|
2721
|
-
setSchemaDevInfo({
|
|
2722
|
-
projectId: s.project_id ?? null,
|
|
2723
|
-
localVersion: s.version,
|
|
2724
|
-
status: "invalid",
|
|
2725
|
-
valid: false,
|
|
2726
|
-
lastCheckedAt: Date.now(),
|
|
2727
|
-
error: errText
|
|
3887
|
+
this.rest = new RestClient({
|
|
3888
|
+
baseUrl: authConfig.pds_url,
|
|
3889
|
+
projectId: this.projectId ?? "",
|
|
3890
|
+
getToken: (opts) => this.auth.getToken(opts),
|
|
3891
|
+
log: this.debug ? log : void 0
|
|
3892
|
+
});
|
|
3893
|
+
if (this.mode === "sync" && this.projectId && this.config.schema?.tables) {
|
|
3894
|
+
this.engine = new SyncEngine({
|
|
3895
|
+
projectId: this.projectId,
|
|
3896
|
+
schema: this.config.schema,
|
|
3897
|
+
wsUrl: syncUrl,
|
|
3898
|
+
getToken: (opts) => this.auth.getToken(opts),
|
|
3899
|
+
fetchSnapshot: (opts) => this.rest.getSnapshot(opts),
|
|
3900
|
+
WebSocketImpl: config.WebSocketImpl,
|
|
3901
|
+
log
|
|
2728
3902
|
});
|
|
2729
|
-
|
|
3903
|
+
this.syncDb = new SyncDb(this.engine, OWN_SUB);
|
|
3904
|
+
this.wireEngineEvents(this.engine);
|
|
3905
|
+
} else {
|
|
3906
|
+
this.engine = null;
|
|
3907
|
+
this.syncDb = null;
|
|
2730
3908
|
}
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
3909
|
+
this.restDb = new RestDb(this.rest, this.config.schema);
|
|
3910
|
+
this.snapshot = this.buildSnapshot();
|
|
3911
|
+
}
|
|
3912
|
+
// -------------------------------------------------------------------
|
|
3913
|
+
// Public surface
|
|
3914
|
+
// -------------------------------------------------------------------
|
|
3915
|
+
/** The database handle: offline-first in sync mode, direct API in rest mode. */
|
|
3916
|
+
get db() {
|
|
3917
|
+
if (this.mode === "sync" && this.syncDb) return this.syncDb;
|
|
3918
|
+
return this.restDb;
|
|
3919
|
+
}
|
|
3920
|
+
/** Bootstrap: version migrations, schema check, auth initialization. */
|
|
3921
|
+
async start() {
|
|
3922
|
+
if (this.started) return;
|
|
3923
|
+
this.started = true;
|
|
3924
|
+
try {
|
|
3925
|
+
const updater = createVersionUpdater(this.auth.storage, version, getMigrations());
|
|
3926
|
+
const result = await updater.checkAndUpdate();
|
|
3927
|
+
if (result.updated) log(`SDK storage migrated ${result.fromVersion} \u2192 ${result.toVersion}`);
|
|
3928
|
+
} catch (err) {
|
|
3929
|
+
log("version updater failed:", err);
|
|
3930
|
+
}
|
|
3931
|
+
void this.checkSchema().then(() => this.maybeStartSync());
|
|
3932
|
+
await this.auth.initialize();
|
|
3933
|
+
const teardownNetwork = this.auth.setupNetworkListeners();
|
|
3934
|
+
this.cleanupFns.push(teardownNetwork);
|
|
3935
|
+
}
|
|
3936
|
+
/** Sign out: server-side revoke, local auth clear, sync teardown + purge. */
|
|
3937
|
+
async signOut() {
|
|
3938
|
+
await this.auth.signOut();
|
|
3939
|
+
await this.teardownLocalData();
|
|
3940
|
+
}
|
|
3941
|
+
/** Stop connections and listeners; local data is kept. */
|
|
3942
|
+
stop() {
|
|
3943
|
+
this.started = false;
|
|
3944
|
+
this.engine?.stop();
|
|
3945
|
+
for (const fn of this.cleanupFns) {
|
|
2741
3946
|
try {
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
if (updateResult.updated) {
|
|
2745
|
-
log(`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`);
|
|
2746
|
-
} else {
|
|
2747
|
-
log(`App version ${updateResult.toVersion} is current`);
|
|
2748
|
-
}
|
|
2749
|
-
} catch (error2) {
|
|
2750
|
-
log("Version update failed:", error2);
|
|
3947
|
+
fn();
|
|
3948
|
+
} catch {
|
|
2751
3949
|
}
|
|
3950
|
+
}
|
|
3951
|
+
this.cleanupFns = [];
|
|
3952
|
+
}
|
|
3953
|
+
/** Re-run the remote schema status check (dev toolbar). */
|
|
3954
|
+
async refreshSchemaStatus() {
|
|
3955
|
+
this.schemaChecked = false;
|
|
3956
|
+
await this.checkSchema();
|
|
3957
|
+
this.maybeStartSync();
|
|
3958
|
+
}
|
|
3959
|
+
async listRejected() {
|
|
3960
|
+
return this.engine?.listRejected() ?? [];
|
|
3961
|
+
}
|
|
3962
|
+
async clearRejected() {
|
|
3963
|
+
await this.engine?.clearRejected();
|
|
3964
|
+
this.publish();
|
|
3965
|
+
}
|
|
3966
|
+
// ---------------- shares ----------------
|
|
3967
|
+
/** Shares granted by / received by this user for this app. */
|
|
3968
|
+
async listShares() {
|
|
3969
|
+
return this.rest.listShares();
|
|
3970
|
+
}
|
|
3971
|
+
/** Mount a share: separate local keyspace + subscription. */
|
|
3972
|
+
async mountShare(shareId) {
|
|
3973
|
+
if (!this.engine) throw new Error("shares require sync mode");
|
|
3974
|
+
const existing = this.mounts.get(shareId);
|
|
3975
|
+
if (existing) return existing;
|
|
3976
|
+
await this.engine.mountShare(shareId);
|
|
3977
|
+
const handle = {
|
|
3978
|
+
shareId,
|
|
3979
|
+
db: new SyncDb(this.engine, shareSubKey(shareId))
|
|
2752
3980
|
};
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
return
|
|
2756
|
-
}
|
|
2757
|
-
(
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3981
|
+
this.mounts.set(shareId, handle);
|
|
3982
|
+
this.publish();
|
|
3983
|
+
return handle;
|
|
3984
|
+
}
|
|
3985
|
+
async unmountShare(shareId, options) {
|
|
3986
|
+
if (!this.engine) return;
|
|
3987
|
+
await this.engine.unmountShare(shareId, options);
|
|
3988
|
+
this.mounts.delete(shareId);
|
|
3989
|
+
this.publish();
|
|
3990
|
+
}
|
|
3991
|
+
getMountedShare(shareId) {
|
|
3992
|
+
return this.mounts.get(shareId);
|
|
3993
|
+
}
|
|
3994
|
+
// ---------------- React subscription surface ----------------
|
|
3995
|
+
subscribe = (listener) => {
|
|
3996
|
+
this.listeners.add(listener);
|
|
3997
|
+
return () => this.listeners.delete(listener);
|
|
3998
|
+
};
|
|
3999
|
+
getSnapshot = () => {
|
|
4000
|
+
return this.snapshot;
|
|
4001
|
+
};
|
|
4002
|
+
// -------------------------------------------------------------------
|
|
4003
|
+
// Orchestration
|
|
4004
|
+
// -------------------------------------------------------------------
|
|
4005
|
+
handleAuthChange() {
|
|
4006
|
+
const status = this.auth.authStatus;
|
|
4007
|
+
if (status === "reauth_required") {
|
|
4008
|
+
this.engine?.stop();
|
|
4009
|
+
} else if (status === "signed_out") {
|
|
4010
|
+
void this.teardownLocalData();
|
|
4011
|
+
} else {
|
|
4012
|
+
this.maybeStartSync();
|
|
2779
4013
|
}
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
schema,
|
|
2797
|
-
debug,
|
|
2798
|
-
onAuthError: (error2) => {
|
|
2799
|
-
log("RemoteDB auth error:", error2);
|
|
2800
|
-
if (error2.errorType === "forbidden") {
|
|
2801
|
-
log("403 Forbidden - user lacks required scope, not signing out");
|
|
2802
|
-
return;
|
|
2803
|
-
}
|
|
2804
|
-
handleSignOut();
|
|
2805
|
-
}
|
|
2806
|
-
});
|
|
2807
|
-
setDbStatus("ONLINE" /* ONLINE */);
|
|
2808
|
-
setIsReady(true);
|
|
4014
|
+
this.publish();
|
|
4015
|
+
}
|
|
4016
|
+
maybeStartSync() {
|
|
4017
|
+
if (!this.engine || !this.started) return;
|
|
4018
|
+
if (!this.syncEnabled) return;
|
|
4019
|
+
if (this.auth.isSignedIn && this.auth.token && this.auth.authStatus !== "reauth_required") {
|
|
4020
|
+
void this.engine.start().catch((err) => log("sync start failed:", err));
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
async teardownLocalData() {
|
|
4024
|
+
this.mounts.clear();
|
|
4025
|
+
if (this.engine) {
|
|
4026
|
+
try {
|
|
4027
|
+
await this.engine.destroyLocal();
|
|
4028
|
+
} catch (err) {
|
|
4029
|
+
log("local data teardown failed:", err);
|
|
2809
4030
|
}
|
|
2810
4031
|
}
|
|
2811
|
-
|
|
4032
|
+
this.publish();
|
|
4033
|
+
}
|
|
4034
|
+
wireEngineEvents(engine) {
|
|
4035
|
+
engine.on("status", () => this.publish());
|
|
4036
|
+
engine.on("change", () => this.publish());
|
|
4037
|
+
engine.on("rejected", ({ rejection }) => {
|
|
4038
|
+
log("op rejected:", rejection.error, rejection.op);
|
|
4039
|
+
this.publish();
|
|
4040
|
+
});
|
|
4041
|
+
engine.on("revoked", ({ code, message }) => {
|
|
4042
|
+
log("connection revoked:", code, message);
|
|
4043
|
+
void this.auth.reconcileSession("connection revoked", { forceRefresh: true, throttleMs: 0 }).catch(() => {
|
|
4044
|
+
});
|
|
4045
|
+
this.publish();
|
|
4046
|
+
});
|
|
4047
|
+
}
|
|
4048
|
+
async checkSchema() {
|
|
4049
|
+
if (this.schemaChecked) return;
|
|
4050
|
+
const schema = this.config.schema;
|
|
4051
|
+
if (!schema) {
|
|
4052
|
+
this.devInfo = this.projectId ? {
|
|
4053
|
+
projectId: this.projectId,
|
|
4054
|
+
localVersion: void 0,
|
|
4055
|
+
status: "no_schema",
|
|
4056
|
+
valid: false,
|
|
4057
|
+
lastCheckedAt: Date.now()
|
|
4058
|
+
} : null;
|
|
4059
|
+
this.syncEnabled = false;
|
|
4060
|
+
this.publish();
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
try {
|
|
2812
4064
|
const result = await validateAndCheckSchema(schema);
|
|
2813
4065
|
if (!result.isValid) {
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
`;
|
|
2819
|
-
});
|
|
2820
|
-
}
|
|
2821
|
-
setSchemaDevInfo({
|
|
2822
|
-
projectId: schema?.project_id ?? null,
|
|
2823
|
-
localVersion: schema?.version,
|
|
4066
|
+
const errText = result.errors?.map((e) => e.message || "").join("; ") || "invalid";
|
|
4067
|
+
this.devInfo = {
|
|
4068
|
+
projectId: schema.project_id ?? null,
|
|
4069
|
+
localVersion: schema.version,
|
|
2824
4070
|
status: "invalid",
|
|
2825
4071
|
valid: false,
|
|
2826
4072
|
lastCheckedAt: Date.now(),
|
|
2827
|
-
error:
|
|
2828
|
-
}
|
|
2829
|
-
|
|
2830
|
-
code: "schema_invalid",
|
|
2831
|
-
title: "Basic Schema is invalid!",
|
|
2832
|
-
message: errorMessage
|
|
2833
|
-
});
|
|
2834
|
-
setIsReady(true);
|
|
2835
|
-
return null;
|
|
2836
|
-
}
|
|
2837
|
-
setSchemaDevInfo({
|
|
2838
|
-
projectId: schema?.project_id ?? null,
|
|
2839
|
-
localVersion: schema?.version,
|
|
2840
|
-
status: result.schemaStatus.status ?? "unknown",
|
|
2841
|
-
valid: result.schemaStatus.valid,
|
|
2842
|
-
lastCheckedAt: Date.now()
|
|
2843
|
-
});
|
|
2844
|
-
if (dbMode === "remote") {
|
|
2845
|
-
initRemoteDb();
|
|
4073
|
+
error: errText
|
|
4074
|
+
};
|
|
4075
|
+
this.syncEnabled = false;
|
|
2846
4076
|
} else {
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
4077
|
+
const status = result.schemaStatus.status ?? "unknown";
|
|
4078
|
+
this.devInfo = {
|
|
4079
|
+
projectId: schema.project_id ?? null,
|
|
4080
|
+
localVersion: schema.version,
|
|
4081
|
+
status,
|
|
4082
|
+
valid: result.schemaStatus.valid,
|
|
4083
|
+
lastCheckedAt: Date.now()
|
|
4084
|
+
};
|
|
4085
|
+
const locallyPublishable = typeof schema.version === "number" && schema.version > 0;
|
|
4086
|
+
const remoteCheckInconclusive = status === "error" || status === "unknown";
|
|
4087
|
+
this.syncEnabled = result.schemaStatus.valid || remoteCheckInconclusive && locallyPublishable;
|
|
4088
|
+
if (!result.schemaStatus.valid) {
|
|
4089
|
+
if (status === "unpublished") {
|
|
4090
|
+
log("Schema not published (version 0) \u2014 sync is disabled until you publish.");
|
|
4091
|
+
} else if (remoteCheckInconclusive && locallyPublishable) {
|
|
4092
|
+
log("Schema registry check failed \u2014 proceeding with the local schema (offline-first).");
|
|
2854
4093
|
}
|
|
2855
|
-
await initSyncDb({ shouldConnect: false });
|
|
2856
4094
|
}
|
|
2857
4095
|
}
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
valid: false,
|
|
2869
|
-
lastCheckedAt: Date.now()
|
|
2870
|
-
} : null
|
|
2871
|
-
);
|
|
2872
|
-
if (dbMode === "remote" && project_id) {
|
|
2873
|
-
initRemoteDb();
|
|
2874
|
-
} else {
|
|
2875
|
-
setIsReady(true);
|
|
2876
|
-
}
|
|
2877
|
-
}
|
|
2878
|
-
}, []);
|
|
2879
|
-
(0, import_react3.useEffect)(() => {
|
|
2880
|
-
if (authState.hasToken && syncRef.current && authState.isSignedIn && shouldConnect) {
|
|
2881
|
-
log("connecting to db...");
|
|
2882
|
-
syncRef.current?.connect({
|
|
2883
|
-
getToken: (opts) => authRef.current.getToken(opts),
|
|
2884
|
-
ws_url: authConfig.ws_url
|
|
2885
|
-
}).catch((e) => {
|
|
2886
|
-
log("error connecting to db", e);
|
|
2887
|
-
});
|
|
4096
|
+
} catch (err) {
|
|
4097
|
+
log("schema check failed:", err);
|
|
4098
|
+
this.syncEnabled = !!schema.version && schema.version > 0;
|
|
4099
|
+
this.devInfo = {
|
|
4100
|
+
projectId: schema.project_id ?? null,
|
|
4101
|
+
localVersion: schema.version,
|
|
4102
|
+
status: "unknown",
|
|
4103
|
+
valid: this.syncEnabled,
|
|
4104
|
+
lastCheckedAt: Date.now()
|
|
4105
|
+
};
|
|
2888
4106
|
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
4107
|
+
this.schemaChecked = true;
|
|
4108
|
+
this.publish();
|
|
4109
|
+
}
|
|
4110
|
+
buildSnapshot() {
|
|
4111
|
+
return {
|
|
4112
|
+
isReady: this.auth.isAuthReady,
|
|
4113
|
+
isSignedIn: this.auth.isSignedIn,
|
|
4114
|
+
authStatus: this.auth.authStatus,
|
|
4115
|
+
authErrorCode: this.auth.authErrorCode,
|
|
4116
|
+
user: this.auth.user,
|
|
4117
|
+
did: this.auth.did,
|
|
4118
|
+
scope: this.auth.tokenScope,
|
|
4119
|
+
syncStatus: this.engine?.status ?? "idle",
|
|
4120
|
+
pendingCount: this.engine?.pendingCount ?? 0,
|
|
4121
|
+
syncEnabled: this.syncEnabled,
|
|
4122
|
+
devInfo: this.devInfo,
|
|
4123
|
+
mode: this.mode
|
|
4124
|
+
};
|
|
4125
|
+
}
|
|
4126
|
+
publish() {
|
|
4127
|
+
this.snapshot = this.buildSnapshot();
|
|
4128
|
+
for (const listener of this.listeners) {
|
|
2893
4129
|
try {
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
syncRef.current = null;
|
|
2897
|
-
window?.location?.reload();
|
|
2898
|
-
} catch (error2) {
|
|
2899
|
-
console.error("Error during database cleanup:", error2);
|
|
2900
|
-
}
|
|
2901
|
-
}
|
|
2902
|
-
};
|
|
2903
|
-
const handleSignIn = async () => {
|
|
2904
|
-
try {
|
|
2905
|
-
await authRef.current.signIn();
|
|
2906
|
-
} catch (error2) {
|
|
2907
|
-
if (isDevMode()) {
|
|
2908
|
-
setError({
|
|
2909
|
-
code: "signin_error",
|
|
2910
|
-
title: "Sign-in Failed",
|
|
2911
|
-
message: error2.message || "An error occurred during sign-in. Please try again."
|
|
2912
|
-
});
|
|
2913
|
-
}
|
|
2914
|
-
throw error2;
|
|
2915
|
-
}
|
|
2916
|
-
};
|
|
2917
|
-
const handleSignInWithHandle = async (handle) => {
|
|
2918
|
-
try {
|
|
2919
|
-
await authRef.current.signInWithHandle(handle);
|
|
2920
|
-
} catch (error2) {
|
|
2921
|
-
if (isDevMode()) {
|
|
2922
|
-
setError({
|
|
2923
|
-
code: "signin_error",
|
|
2924
|
-
title: "Sign-in Failed",
|
|
2925
|
-
message: error2.message || "An error occurred during sign-in. Please try again."
|
|
2926
|
-
});
|
|
4130
|
+
listener();
|
|
4131
|
+
} catch {
|
|
2927
4132
|
}
|
|
2928
|
-
throw error2;
|
|
2929
4133
|
}
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
}
|
|
2935
|
-
return syncRef.current || noDb;
|
|
2936
|
-
};
|
|
2937
|
-
const contextValue = {
|
|
2938
|
-
isReady: authState.isAuthReady,
|
|
2939
|
-
isSignedIn: authState.isSignedIn,
|
|
2940
|
-
user: authState.user,
|
|
2941
|
-
did: authState.did,
|
|
2942
|
-
scope: authState.tokenScope,
|
|
2943
|
-
hasScope: (s) => authRef.current.hasScope(s),
|
|
2944
|
-
missingScopes: () => authRef.current.missingScopes(),
|
|
2945
|
-
signIn: handleSignIn,
|
|
2946
|
-
signInWithHandle: handleSignInWithHandle,
|
|
2947
|
-
signOut: handleSignOut,
|
|
2948
|
-
signInWithCode: (code, state) => authRef.current.signInWithCode(code, state),
|
|
2949
|
-
getToken: (opts) => authRef.current.getToken(opts),
|
|
2950
|
-
getSignInUrl: (redirectUri) => authRef.current.getSignInUrl(redirectUri),
|
|
2951
|
-
db: getCurrentDb(),
|
|
2952
|
-
dbStatus,
|
|
2953
|
-
dbMode,
|
|
2954
|
-
devInfo: schemaDevInfo,
|
|
2955
|
-
refreshSchemaStatus,
|
|
2956
|
-
isAuthReady: authState.isAuthReady,
|
|
2957
|
-
signin: handleSignIn,
|
|
2958
|
-
signout: handleSignOut,
|
|
2959
|
-
signinWithCode: (code, state) => authRef.current.signInWithCode(code, state),
|
|
2960
|
-
getSignInLink: (redirectUri) => authRef.current.getSignInUrl(redirectUri)
|
|
2961
|
-
};
|
|
2962
|
-
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(BasicContext.Provider, { value: contextValue, children: [
|
|
2963
|
-
error && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ErrorDisplay, { error }),
|
|
2964
|
-
devToolbar && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BasicDevToolbar2, { debug }) }),
|
|
2965
|
-
isReady && children
|
|
2966
|
-
] });
|
|
4134
|
+
}
|
|
4135
|
+
};
|
|
4136
|
+
function createBasicClient(config) {
|
|
4137
|
+
return new BasicClient(config);
|
|
2967
4138
|
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
4139
|
+
|
|
4140
|
+
// src/react/BasicProvider.tsx
|
|
4141
|
+
init_network();
|
|
4142
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
4143
|
+
var BasicDevToolbar2 = (0, import_react4.lazy)(
|
|
4144
|
+
() => Promise.resolve().then(() => (init_BasicDevToolbar(), BasicDevToolbar_exports)).then((m) => ({ default: m.BasicDevToolbar }))
|
|
4145
|
+
);
|
|
4146
|
+
function BasicProvider({
|
|
4147
|
+
children,
|
|
4148
|
+
schema,
|
|
4149
|
+
project_id,
|
|
4150
|
+
auth,
|
|
4151
|
+
storage,
|
|
4152
|
+
debug = false,
|
|
4153
|
+
mode = "sync",
|
|
4154
|
+
devToolbar = false,
|
|
4155
|
+
renderWhileLoading = false
|
|
4156
|
+
}) {
|
|
4157
|
+
const clientRef = (0, import_react4.useRef)(null);
|
|
4158
|
+
if (!clientRef.current) {
|
|
4159
|
+
clientRef.current = new BasicClient({
|
|
4160
|
+
schema,
|
|
4161
|
+
project_id,
|
|
4162
|
+
auth,
|
|
4163
|
+
storage,
|
|
4164
|
+
debug,
|
|
4165
|
+
mode
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
4168
|
+
const client = clientRef.current;
|
|
4169
|
+
(0, import_react4.useEffect)(() => {
|
|
4170
|
+
void client.start();
|
|
4171
|
+
void checkForNewVersion();
|
|
4172
|
+
return () => client.stop();
|
|
4173
|
+
}, []);
|
|
4174
|
+
const snapshot = (0, import_react4.useSyncExternalStore)(client.subscribe, client.getSnapshot, client.getSnapshot);
|
|
4175
|
+
const showDevTools = devToolbar && isDevelopment(debug);
|
|
4176
|
+
const ready = snapshot.isReady;
|
|
4177
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(BasicClientContext.Provider, { value: client, children: [
|
|
4178
|
+
showDevTools && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BasicDevToolbar2, { debug }) }),
|
|
4179
|
+
(ready || renderWhileLoading) && children
|
|
4180
|
+
] });
|
|
2996
4181
|
}
|
|
2997
4182
|
|
|
2998
4183
|
// src/index.ts
|
|
2999
|
-
|
|
4184
|
+
init_hooks();
|
|
3000
4185
|
init_BasicDevToolbar();
|
|
3001
4186
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3002
4187
|
0 && (module.exports = {
|
|
4188
|
+
AuthManager,
|
|
4189
|
+
BasicClient,
|
|
3003
4190
|
BasicDevToolbar,
|
|
3004
4191
|
BasicProvider,
|
|
3005
|
-
|
|
4192
|
+
DEFAULT_LIMITS,
|
|
4193
|
+
LocalStorageAdapter,
|
|
3006
4194
|
NotAuthenticatedError,
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
4195
|
+
OWN_SUB,
|
|
4196
|
+
PROTOCOL_VERSION,
|
|
4197
|
+
RestClient,
|
|
4198
|
+
RestDb,
|
|
4199
|
+
RestError,
|
|
3010
4200
|
STORAGE_KEYS,
|
|
4201
|
+
SyncConnection,
|
|
4202
|
+
SyncDb,
|
|
4203
|
+
SyncEngine,
|
|
4204
|
+
SyncStore,
|
|
4205
|
+
applyOpToData,
|
|
4206
|
+
createBasicClient,
|
|
4207
|
+
isAuthError,
|
|
4208
|
+
isRebootstrapError,
|
|
4209
|
+
isRevocationError,
|
|
4210
|
+
isTerminalOpError,
|
|
4211
|
+
mintOpId,
|
|
3011
4212
|
resolveDid,
|
|
3012
4213
|
resolveDidWebUrl,
|
|
3013
4214
|
resolveHandle,
|
|
4215
|
+
shareSubKey,
|
|
4216
|
+
useAuth,
|
|
3014
4217
|
useBasic,
|
|
3015
|
-
|
|
4218
|
+
useBasicClient,
|
|
4219
|
+
useDb,
|
|
4220
|
+
useQuery,
|
|
4221
|
+
useShare,
|
|
4222
|
+
useShares,
|
|
4223
|
+
useSyncStatus
|
|
3016
4224
|
});
|
|
3017
4225
|
//# sourceMappingURL=index.js.map
|