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