@basictech/react 0.8.0-beta.4 → 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 +31 -0
- package/dist/index.d.mts +1025 -294
- package/dist/index.d.ts +1025 -294
- package/dist/index.js +2256 -1444
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2230 -1448
- package/dist/index.mjs.map +1 -1
- package/package.json +27 -8
- package/readme.md +280 -217
- package/.turbo/turbo-build.log +0 -24
- package/AUTH_IMPLEMENTATION_GUIDE.md +0 -2011
- package/src/AuthContext.tsx +0 -591
- package/src/config.ts +0 -9
- package/src/context.tsx +0 -122
- package/src/core/auth/AuthManager.ts +0 -1371
- 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 -291
- 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,242 +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
|
-
var pendingTokenUpdate = null;
|
|
75
|
-
log("Connecting to", url);
|
|
76
|
-
var ws = new WebSocket(url);
|
|
77
|
-
function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
|
|
78
|
-
log("sendChanges", changes2.length, baseRevision2);
|
|
79
|
-
++requestId;
|
|
80
|
-
acceptCallbacks[requestId.toString()] = onChangesAccepted2;
|
|
81
|
-
ws.send(
|
|
82
|
-
JSON.stringify({
|
|
83
|
-
type: "changes",
|
|
84
|
-
changes: changes2,
|
|
85
|
-
partial: partial2,
|
|
86
|
-
baseRevision: baseRevision2,
|
|
87
|
-
requestId
|
|
88
|
-
})
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
function clearRefreshTimer() {
|
|
92
|
-
if (refreshTimer) {
|
|
93
|
-
clearTimeout(refreshTimer);
|
|
94
|
-
refreshTimer = null;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
function sendTokenUpdate(token) {
|
|
98
|
-
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
99
|
-
pendingTokenUpdate = token;
|
|
100
|
-
ws.send(JSON.stringify({ type: "tokenUpdate", authToken: token }));
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
function resolveGetToken() {
|
|
104
|
-
var fn = getTokenGetter(url);
|
|
105
|
-
if (!fn) throw new Error("No token getter registered for " + url);
|
|
106
|
-
return fn;
|
|
107
|
-
}
|
|
108
|
-
function scheduleTokenRefresh(tokenStr) {
|
|
109
|
-
clearRefreshTimer();
|
|
110
|
-
var exp = decodeJwtExp(tokenStr);
|
|
111
|
-
if (!exp) return;
|
|
112
|
-
var msUntilRefresh = (exp - TOKEN_REFRESH_BUFFER) * 1e3 - Date.now();
|
|
113
|
-
if (msUntilRefresh <= 0) return;
|
|
114
|
-
log("Scheduling proactive token refresh in", Math.round(msUntilRefresh / 1e3), "s");
|
|
115
|
-
refreshTimer = setTimeout(async function() {
|
|
116
|
-
try {
|
|
117
|
-
var newToken = await resolveGetToken()({ forceRefresh: true });
|
|
118
|
-
if (sendTokenUpdate(newToken)) {
|
|
119
|
-
log("Sending tokenUpdate on existing WebSocket");
|
|
120
|
-
}
|
|
121
|
-
} catch (err) {
|
|
122
|
-
log("Proactive token refresh failed (non-fatal):", err);
|
|
123
|
-
}
|
|
124
|
-
}, msUntilRefresh);
|
|
125
|
-
}
|
|
126
|
-
ws.onopen = async function(event) {
|
|
127
|
-
try {
|
|
128
|
-
var token = await resolveGetToken()();
|
|
129
|
-
log("Opening socket - sending clientIdentity", context.clientIdentity);
|
|
130
|
-
ws.send(
|
|
131
|
-
JSON.stringify({
|
|
132
|
-
type: "clientIdentity",
|
|
133
|
-
clientIdentity: context.clientIdentity || null,
|
|
134
|
-
authToken: token,
|
|
135
|
-
schema: options.schema
|
|
136
|
-
})
|
|
137
|
-
);
|
|
138
|
-
scheduleTokenRefresh(token);
|
|
139
|
-
} catch (err) {
|
|
140
|
-
log("Failed to get token for WebSocket:", err);
|
|
141
|
-
ws.close();
|
|
142
|
-
onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
function handleVisibilityResume() {
|
|
146
|
-
if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
|
|
147
|
-
log("Page became visible - refreshing token for WebSocket");
|
|
148
|
-
resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
|
|
149
|
-
sendTokenUpdate(newToken);
|
|
150
|
-
}).catch(function(err) {
|
|
151
|
-
log("Token refresh on visibility resume failed:", err);
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (typeof document !== "undefined") {
|
|
156
|
-
document.addEventListener("visibilitychange", handleVisibilityResume);
|
|
157
|
-
}
|
|
158
|
-
function cleanupVisibilityListener() {
|
|
159
|
-
if (typeof document !== "undefined") {
|
|
160
|
-
document.removeEventListener("visibilitychange", handleVisibilityResume);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
ws.onerror = function(event) {
|
|
164
|
-
clearRefreshTimer();
|
|
165
|
-
cleanupVisibilityListener();
|
|
166
|
-
ws.close();
|
|
167
|
-
log("ws.onerror", event);
|
|
168
|
-
onError(event?.message, RECONNECT_DELAY);
|
|
169
|
-
};
|
|
170
|
-
ws.onclose = function(event) {
|
|
171
|
-
clearRefreshTimer();
|
|
172
|
-
cleanupVisibilityListener();
|
|
173
|
-
onError("Socket closed: " + event.reason, RECONNECT_DELAY);
|
|
174
|
-
};
|
|
175
|
-
var isFirstRound = true;
|
|
176
|
-
ws.onmessage = function(event) {
|
|
177
|
-
try {
|
|
178
|
-
var requestFromServer = JSON.parse(event.data);
|
|
179
|
-
log("requestFromServer", requestFromServer, { isFirstRound });
|
|
180
|
-
if (requestFromServer.type == "clientIdentity") {
|
|
181
|
-
context.clientIdentity = requestFromServer.clientIdentity;
|
|
182
|
-
context.save();
|
|
183
|
-
sendChanges(changes, baseRevision, partial, onChangesAccepted);
|
|
184
|
-
ws.send(
|
|
185
|
-
JSON.stringify({
|
|
186
|
-
type: "subscribe",
|
|
187
|
-
syncedRevision
|
|
188
|
-
})
|
|
189
|
-
);
|
|
190
|
-
} else if (requestFromServer.type == "changes") {
|
|
191
|
-
applyRemoteChanges(
|
|
192
|
-
requestFromServer.changes,
|
|
193
|
-
requestFromServer.currentRevision,
|
|
194
|
-
requestFromServer.partial
|
|
195
|
-
);
|
|
196
|
-
if (isFirstRound && !requestFromServer.partial) {
|
|
197
|
-
onSuccess({
|
|
198
|
-
// Specify a react function that will react on additional client changes
|
|
199
|
-
react: function(changes2, baseRevision2, partial2, onChangesAccepted2) {
|
|
200
|
-
sendChanges(
|
|
201
|
-
changes2,
|
|
202
|
-
baseRevision2,
|
|
203
|
-
partial2,
|
|
204
|
-
onChangesAccepted2
|
|
205
|
-
);
|
|
206
|
-
},
|
|
207
|
-
disconnect: function() {
|
|
208
|
-
clearRefreshTimer();
|
|
209
|
-
cleanupVisibilityListener();
|
|
210
|
-
ws.close();
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
isFirstRound = false;
|
|
214
|
-
}
|
|
215
|
-
} else if (requestFromServer.type == "tokenUpdateAck") {
|
|
216
|
-
if (requestFromServer.ok) {
|
|
217
|
-
scheduleTokenRefresh(requestFromServer.authToken || pendingTokenUpdate);
|
|
218
|
-
pendingTokenUpdate = null;
|
|
219
|
-
} else {
|
|
220
|
-
log("tokenUpdate rejected by server:", requestFromServer.code || requestFromServer.message);
|
|
221
|
-
pendingTokenUpdate = null;
|
|
222
|
-
ws.close(4001, requestFromServer.code || "token_update_failed");
|
|
223
|
-
onError(
|
|
224
|
-
requestFromServer.message || "Authentication refresh failed",
|
|
225
|
-
RECONNECT_DELAY
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
} else if (requestFromServer.type == "ack") {
|
|
229
|
-
var requestId2 = requestFromServer.requestId;
|
|
230
|
-
var acceptCallback = acceptCallbacks[requestId2.toString()];
|
|
231
|
-
acceptCallback();
|
|
232
|
-
delete acceptCallbacks[requestId2.toString()];
|
|
233
|
-
} else if (requestFromServer.type == "error") {
|
|
234
|
-
ws.close();
|
|
235
|
-
if (requestFromServer.code === "TOKEN_EXPIRED" || requestFromServer.code === "UNAUTHORIZED") {
|
|
236
|
-
log("Auth error from server, will reconnect with fresh token:", requestFromServer.message);
|
|
237
|
-
onError(requestFromServer.message, RECONNECT_DELAY);
|
|
238
|
-
} else {
|
|
239
|
-
onError(requestFromServer.message, Infinity);
|
|
240
|
-
}
|
|
241
|
-
} else {
|
|
242
|
-
log("unknown message", requestFromServer);
|
|
243
|
-
ws.close();
|
|
244
|
-
onError("unknown message", Infinity);
|
|
245
|
-
}
|
|
246
|
-
} catch (e) {
|
|
247
|
-
ws.close();
|
|
248
|
-
log("caught error", e);
|
|
249
|
-
onError(e, Infinity);
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
37
|
// package.json
|
|
259
38
|
var version;
|
|
260
39
|
var init_package = __esm({
|
|
261
40
|
"package.json"() {
|
|
262
|
-
version = "0.
|
|
41
|
+
version = "0.9.0-beta.0";
|
|
263
42
|
}
|
|
264
43
|
});
|
|
265
44
|
|
|
@@ -348,24 +127,6 @@ function cleanOAuthParamsFromUrl() {
|
|
|
348
127
|
log("Cleaned OAuth parameters from URL");
|
|
349
128
|
}
|
|
350
129
|
}
|
|
351
|
-
function getSyncStatus(statusCode) {
|
|
352
|
-
switch (statusCode) {
|
|
353
|
-
case -1:
|
|
354
|
-
return "ERROR";
|
|
355
|
-
case 0:
|
|
356
|
-
return "OFFLINE";
|
|
357
|
-
case 1:
|
|
358
|
-
return "CONNECTING";
|
|
359
|
-
case 2:
|
|
360
|
-
return "ONLINE";
|
|
361
|
-
case 3:
|
|
362
|
-
return "SYNCING";
|
|
363
|
-
case 4:
|
|
364
|
-
return "ERROR_WILL_RETRY";
|
|
365
|
-
default:
|
|
366
|
-
return "UNKNOWN";
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
130
|
var init_network = __esm({
|
|
370
131
|
"src/utils/network.ts"() {
|
|
371
132
|
"use strict";
|
|
@@ -374,59 +135,147 @@ var init_network = __esm({
|
|
|
374
135
|
}
|
|
375
136
|
});
|
|
376
137
|
|
|
377
|
-
// src/
|
|
378
|
-
import {
|
|
379
|
-
|
|
380
|
-
|
|
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;
|
|
381
147
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}));
|
|
210
|
+
}
|
|
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)));
|
|
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);
|
|
400
240
|
}
|
|
401
|
-
};
|
|
402
|
-
BasicContext = createContext({
|
|
403
|
-
isReady: false,
|
|
404
|
-
isSignedIn: false,
|
|
405
|
-
authStatus: "bootstrapping",
|
|
406
|
-
authErrorCode: null,
|
|
407
|
-
user: null,
|
|
408
|
-
did: null,
|
|
409
|
-
scope: null,
|
|
410
|
-
hasScope: () => false,
|
|
411
|
-
missingScopes: () => [],
|
|
412
|
-
signIn: () => Promise.resolve(),
|
|
413
|
-
signInWithHandle: () => Promise.resolve(),
|
|
414
|
-
signOut: () => Promise.resolve(),
|
|
415
|
-
signInWithCode: () => Promise.resolve({ success: false }),
|
|
416
|
-
getToken: (_options) => Promise.reject(new Error("no token")),
|
|
417
|
-
getSignInUrl: () => Promise.resolve(""),
|
|
418
|
-
db: noDb,
|
|
419
|
-
dbStatus: "LOADING" /* LOADING */,
|
|
420
|
-
dbMode: "sync",
|
|
421
|
-
devInfo: null,
|
|
422
|
-
refreshSchemaStatus: async () => {
|
|
423
|
-
},
|
|
424
|
-
isAuthReady: false,
|
|
425
|
-
signin: () => Promise.resolve(),
|
|
426
|
-
signout: () => Promise.resolve(),
|
|
427
|
-
signinWithCode: () => Promise.resolve({ success: false }),
|
|
428
|
-
getSignInLink: () => Promise.resolve("")
|
|
429
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;
|
|
430
279
|
}
|
|
431
280
|
});
|
|
432
281
|
|
|
@@ -435,18 +284,18 @@ var BasicDevToolbar_exports = {};
|
|
|
435
284
|
__export(BasicDevToolbar_exports, {
|
|
436
285
|
BasicDevToolbar: () => BasicDevToolbar
|
|
437
286
|
});
|
|
438
|
-
import { useCallback, useMemo, useState } from "react";
|
|
287
|
+
import { useCallback, useMemo as useMemo2, useState as useState2 } from "react";
|
|
439
288
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
440
289
|
function toneForAuth(isReady, isSignedIn) {
|
|
441
290
|
if (!isReady) return "muted";
|
|
442
291
|
if (isSignedIn) return "ok";
|
|
443
292
|
return "warn";
|
|
444
293
|
}
|
|
445
|
-
function
|
|
446
|
-
if (
|
|
447
|
-
if (
|
|
448
|
-
if (
|
|
449
|
-
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";
|
|
450
299
|
return "bad";
|
|
451
300
|
}
|
|
452
301
|
function toneForSchema(info) {
|
|
@@ -456,24 +305,22 @@ function toneForSchema(info) {
|
|
|
456
305
|
if (info.status === "no_schema") return "muted";
|
|
457
306
|
return "bad";
|
|
458
307
|
}
|
|
459
|
-
function
|
|
308
|
+
function syncStatusLabel(status) {
|
|
460
309
|
switch (status) {
|
|
461
|
-
case "
|
|
462
|
-
return "
|
|
463
|
-
case "
|
|
464
|
-
return "Offline";
|
|
465
|
-
case "CONNECTING" /* CONNECTING */:
|
|
310
|
+
case "idle":
|
|
311
|
+
return "Idle";
|
|
312
|
+
case "connecting":
|
|
466
313
|
return "Connecting";
|
|
467
|
-
case "
|
|
314
|
+
case "online":
|
|
468
315
|
return "Connected";
|
|
469
|
-
case "
|
|
470
|
-
return "
|
|
471
|
-
case "
|
|
472
|
-
return "
|
|
473
|
-
case "
|
|
474
|
-
return "
|
|
475
|
-
case "
|
|
476
|
-
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";
|
|
477
324
|
default:
|
|
478
325
|
return String(status);
|
|
479
326
|
}
|
|
@@ -562,7 +409,7 @@ function CopyableRow({
|
|
|
562
409
|
onCopied,
|
|
563
410
|
children
|
|
564
411
|
}) {
|
|
565
|
-
const [hover, setHover] =
|
|
412
|
+
const [hover, setHover] = useState2(false);
|
|
566
413
|
const canCopy = copyText.length > 0;
|
|
567
414
|
const handleClick = useCallback(
|
|
568
415
|
(e) => {
|
|
@@ -642,20 +489,23 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
642
489
|
did,
|
|
643
490
|
scope,
|
|
644
491
|
missingScopes,
|
|
645
|
-
|
|
646
|
-
dbStatus,
|
|
492
|
+
sync,
|
|
647
493
|
devInfo,
|
|
648
|
-
refreshSchemaStatus
|
|
494
|
+
refreshSchemaStatus,
|
|
495
|
+
client
|
|
649
496
|
} = useBasic();
|
|
650
|
-
const
|
|
651
|
-
const
|
|
652
|
-
const
|
|
653
|
-
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);
|
|
654
504
|
const show = enabled && typeof window !== "undefined" && isDevelopment(debug);
|
|
655
505
|
const authTone = toneForAuth(isReady, isSignedIn);
|
|
656
|
-
const dbTone =
|
|
506
|
+
const dbTone = toneForSync(dbMode, syncStatus);
|
|
657
507
|
const schemaTone = toneForSchema(devInfo);
|
|
658
|
-
const syncTone =
|
|
508
|
+
const syncTone = dbTone;
|
|
659
509
|
const handleRefreshSchema = useCallback(async () => {
|
|
660
510
|
setRefreshing(true);
|
|
661
511
|
try {
|
|
@@ -665,7 +515,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
665
515
|
}
|
|
666
516
|
}, [refreshSchemaStatus]);
|
|
667
517
|
const missingList = missingScopes();
|
|
668
|
-
const debugPayload =
|
|
518
|
+
const debugPayload = useMemo2(() => {
|
|
669
519
|
return {
|
|
670
520
|
sdkVersion: version,
|
|
671
521
|
isReady,
|
|
@@ -680,11 +530,12 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
680
530
|
scope,
|
|
681
531
|
missingScopes: missingList,
|
|
682
532
|
dbMode,
|
|
683
|
-
|
|
684
|
-
|
|
533
|
+
syncStatus,
|
|
534
|
+
pendingOps: sync.pendingCount,
|
|
535
|
+
indexedDbName,
|
|
685
536
|
schema: devInfo
|
|
686
537
|
};
|
|
687
|
-
}, [isReady, isSignedIn, did, user, scope, dbMode,
|
|
538
|
+
}, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, devInfo, missingList]);
|
|
688
539
|
const handleCopy = useCallback(async () => {
|
|
689
540
|
try {
|
|
690
541
|
await navigator.clipboard.writeText(JSON.stringify(debugPayload, null, 2));
|
|
@@ -767,7 +618,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
767
618
|
minWidth: 300,
|
|
768
619
|
maxWidth: "min(560px, calc(100vw - 24px))"
|
|
769
620
|
};
|
|
770
|
-
const syncStatusText =
|
|
621
|
+
const syncStatusText = dbMode === "rest" ? "REST mode" : `${syncStatusLabel(syncStatus)}${sync.pendingCount > 0 ? ` (${sync.pendingCount} pending)` : ""}`;
|
|
771
622
|
return /* @__PURE__ */ jsxs("div", { style: shell, children: [
|
|
772
623
|
open && /* @__PURE__ */ jsxs("div", { style: panel, children: [
|
|
773
624
|
/* @__PURE__ */ jsxs("div", { style: { marginBottom: 12 }, children: [
|
|
@@ -862,10 +713,10 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
862
713
|
{
|
|
863
714
|
rowKey: "indexedDb",
|
|
864
715
|
label: "IndexedDB",
|
|
865
|
-
copyText:
|
|
716
|
+
copyText: indexedDbName ?? "",
|
|
866
717
|
copiedKey: rowCopied,
|
|
867
718
|
onCopied: onRowCopied,
|
|
868
|
-
children:
|
|
719
|
+
children: indexedDbName ?? "\u2014"
|
|
869
720
|
}
|
|
870
721
|
),
|
|
871
722
|
/* @__PURE__ */ jsx(
|
|
@@ -1045,612 +896,119 @@ function BasicDevToolbar({ enabled = true, debug }) {
|
|
|
1045
896
|
)
|
|
1046
897
|
] });
|
|
1047
898
|
}
|
|
1048
|
-
var
|
|
899
|
+
var PANEL_PAD_X;
|
|
1049
900
|
var init_BasicDevToolbar = __esm({
|
|
1050
901
|
"src/dev/BasicDevToolbar.tsx"() {
|
|
1051
902
|
"use strict";
|
|
1052
903
|
"use client";
|
|
1053
|
-
|
|
904
|
+
init_hooks();
|
|
1054
905
|
init_package();
|
|
1055
906
|
init_network();
|
|
1056
|
-
INDEXED_DB_NAME = "basicdb";
|
|
1057
907
|
PANEL_PAD_X = 12;
|
|
1058
908
|
}
|
|
1059
909
|
});
|
|
1060
910
|
|
|
1061
|
-
// src/
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
useEffect,
|
|
1065
|
-
useRef,
|
|
1066
|
-
useState as useState2,
|
|
1067
|
-
Suspense,
|
|
1068
|
-
lazy
|
|
1069
|
-
} from "react";
|
|
911
|
+
// src/react/BasicProvider.tsx
|
|
912
|
+
init_context();
|
|
913
|
+
import { Suspense, lazy, useEffect as useEffect2, useRef, useSyncExternalStore as useSyncExternalStore2 } from "react";
|
|
1070
914
|
|
|
1071
|
-
// src/
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
var initPromise = null;
|
|
1079
|
-
async function initDexieExtensions() {
|
|
1080
|
-
if (dexieExtensionsLoaded) return;
|
|
1081
|
-
if (typeof window === "undefined") return;
|
|
1082
|
-
if (initPromise) return initPromise;
|
|
1083
|
-
initPromise = (async () => {
|
|
1084
|
-
try {
|
|
1085
|
-
await import("dexie-syncable");
|
|
1086
|
-
await import("dexie-observable");
|
|
1087
|
-
const { syncProtocol: syncProtocol2 } = await Promise.resolve().then(() => (init_syncProtocol(), syncProtocol_exports));
|
|
1088
|
-
syncProtocol2();
|
|
1089
|
-
dexieExtensionsLoaded = true;
|
|
1090
|
-
log("Dexie extensions loaded successfully");
|
|
1091
|
-
} catch (error) {
|
|
1092
|
-
console.error("Failed to load Dexie extensions:", error);
|
|
1093
|
-
throw error;
|
|
1094
|
-
}
|
|
1095
|
-
})();
|
|
1096
|
-
return initPromise;
|
|
1097
|
-
}
|
|
1098
|
-
var BasicSync = class extends Dexie2 {
|
|
1099
|
-
basic_schema;
|
|
1100
|
-
constructor(name, options) {
|
|
1101
|
-
super(name, options);
|
|
1102
|
-
this.basic_schema = options.schema;
|
|
1103
|
-
this.version(1).stores(this._convertSchemaToDxSchema(this.basic_schema));
|
|
1104
|
-
this.version(2).stores({});
|
|
1105
|
-
this.Collection.prototype.get = this.Collection.prototype.toArray;
|
|
1106
|
-
}
|
|
1107
|
-
async connect({ getToken, ws_url }) {
|
|
1108
|
-
const WS_URL = ws_url || "wss://pds.basic.id/ws";
|
|
1109
|
-
log("Connecting to", WS_URL);
|
|
1110
|
-
setTokenGetter(WS_URL, getToken);
|
|
1111
|
-
await this.updateSyncNodes();
|
|
1112
|
-
log("Starting connection...");
|
|
1113
|
-
return this.syncable.connect("websocket", WS_URL, { schema: this.basic_schema });
|
|
1114
|
-
}
|
|
1115
|
-
async disconnect({ ws_url } = {}) {
|
|
1116
|
-
const WS_URL = ws_url || "wss://pds.basic.id/ws";
|
|
1117
|
-
return this.syncable.disconnect(WS_URL);
|
|
1118
|
-
}
|
|
1119
|
-
async updateSyncNodes() {
|
|
1120
|
-
try {
|
|
1121
|
-
const syncNodes = await this.table("_syncNodes").toArray();
|
|
1122
|
-
const localSyncNodes = syncNodes.filter((node) => node.type === "local");
|
|
1123
|
-
log("Local sync nodes:", localSyncNodes);
|
|
1124
|
-
if (localSyncNodes.length > 1) {
|
|
1125
|
-
const largestNodeId = Math.max(...localSyncNodes.map((node) => node.id));
|
|
1126
|
-
const largestNode = localSyncNodes.find((node) => node.id === largestNodeId);
|
|
1127
|
-
if (largestNode && largestNode.isMaster === 1) {
|
|
1128
|
-
log("Largest node is already the master. No changes needed.");
|
|
1129
|
-
return;
|
|
1130
|
-
}
|
|
1131
|
-
log("Largest node id:", largestNodeId);
|
|
1132
|
-
log("HEISENBUG: More than one local sync node found.");
|
|
1133
|
-
for (const node of localSyncNodes) {
|
|
1134
|
-
log(`Local sync node keys:`, node.id, node.isMaster);
|
|
1135
|
-
await this.table("_syncNodes").update(node.id, { isMaster: node.id === largestNodeId ? 1 : 0 });
|
|
1136
|
-
log(`HEISENBUG: Setting ${node.id} to ${node.id === largestNodeId ? "master" : "0"}`);
|
|
1137
|
-
}
|
|
1138
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1139
|
-
if (typeof window !== "undefined") {
|
|
1140
|
-
window.location.reload();
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
log("Sync nodes updated");
|
|
1144
|
-
} catch (error) {
|
|
1145
|
-
console.error("Error updating _syncNodes table:", error);
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
handleStatusChange(fn) {
|
|
1149
|
-
this.syncable.on("statusChanged", fn);
|
|
1150
|
-
}
|
|
1151
|
-
_convertSchemaToDxSchema(schema) {
|
|
1152
|
-
const stores = Object.entries(schema.tables).map(([key, table]) => {
|
|
1153
|
-
const indexedFields = Object.entries(table.fields).filter(([, field]) => field.indexed).map(([fieldKey]) => `,${fieldKey}`).join("");
|
|
1154
|
-
return {
|
|
1155
|
-
[key]: "id" + indexedFields
|
|
1156
|
-
};
|
|
1157
|
-
});
|
|
1158
|
-
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);
|
|
1159
922
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
923
|
+
async set(key, value) {
|
|
924
|
+
localStorage.setItem(key, value);
|
|
1162
925
|
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
throw new Error(`Table "${name}" not found in schema`);
|
|
1166
|
-
}
|
|
1167
|
-
const table = this.table(name);
|
|
1168
|
-
return {
|
|
1169
|
-
/**
|
|
1170
|
-
* Returns the underlying Dexie table
|
|
1171
|
-
* @type {Dexie.Table}
|
|
1172
|
-
*/
|
|
1173
|
-
ref: table,
|
|
1174
|
-
// --- WRITE ---- //
|
|
1175
|
-
/**
|
|
1176
|
-
* Add a new record - returns the full object with generated id
|
|
1177
|
-
*/
|
|
1178
|
-
add: async (data) => {
|
|
1179
|
-
const valid = validateData(this.basic_schema, name, data);
|
|
1180
|
-
if (!valid.valid) {
|
|
1181
|
-
log("Invalid data", valid);
|
|
1182
|
-
throw new Error(valid.message || "Data validation failed");
|
|
1183
|
-
}
|
|
1184
|
-
const id = uuidv7();
|
|
1185
|
-
const fullData = { id, ...data };
|
|
1186
|
-
await table.add(fullData);
|
|
1187
|
-
return fullData;
|
|
1188
|
-
},
|
|
1189
|
-
/**
|
|
1190
|
-
* Put (upsert) a record - returns the full object
|
|
1191
|
-
*/
|
|
1192
|
-
put: async (data) => {
|
|
1193
|
-
if (!data.id) {
|
|
1194
|
-
throw new Error("put() requires an id field");
|
|
1195
|
-
}
|
|
1196
|
-
const valid = validateData(this.basic_schema, name, data);
|
|
1197
|
-
if (!valid.valid) {
|
|
1198
|
-
log("Invalid data", valid);
|
|
1199
|
-
throw new Error(valid.message || "Data validation failed");
|
|
1200
|
-
}
|
|
1201
|
-
await table.put(data);
|
|
1202
|
-
return data;
|
|
1203
|
-
},
|
|
1204
|
-
/**
|
|
1205
|
-
* Update an existing record - returns updated object or null
|
|
1206
|
-
*/
|
|
1207
|
-
update: async (id, data) => {
|
|
1208
|
-
if (!id) {
|
|
1209
|
-
throw new Error("update() requires an id");
|
|
1210
|
-
}
|
|
1211
|
-
const valid = validateData(this.basic_schema, name, data, false);
|
|
1212
|
-
if (!valid.valid) {
|
|
1213
|
-
log("Invalid data", valid);
|
|
1214
|
-
throw new Error(valid.message || "Data validation failed");
|
|
1215
|
-
}
|
|
1216
|
-
const updated = await table.update(id, data);
|
|
1217
|
-
if (updated === 0) {
|
|
1218
|
-
return null;
|
|
1219
|
-
}
|
|
1220
|
-
const record = await table.get(id);
|
|
1221
|
-
return record || null;
|
|
1222
|
-
},
|
|
1223
|
-
/**
|
|
1224
|
-
* Delete a record - returns true if deleted, false if not found
|
|
1225
|
-
*/
|
|
1226
|
-
delete: async (id) => {
|
|
1227
|
-
if (!id) {
|
|
1228
|
-
throw new Error("delete() requires an id");
|
|
1229
|
-
}
|
|
1230
|
-
const exists = await table.get(id);
|
|
1231
|
-
if (!exists) {
|
|
1232
|
-
return false;
|
|
1233
|
-
}
|
|
1234
|
-
await table.delete(id);
|
|
1235
|
-
return true;
|
|
1236
|
-
},
|
|
1237
|
-
// --- READ ---- //
|
|
1238
|
-
/**
|
|
1239
|
-
* Get a single record by id - returns null if not found
|
|
1240
|
-
*/
|
|
1241
|
-
get: async (id) => {
|
|
1242
|
-
if (!id) {
|
|
1243
|
-
throw new Error("get() requires an id");
|
|
1244
|
-
}
|
|
1245
|
-
const record = await table.get(id);
|
|
1246
|
-
return record || null;
|
|
1247
|
-
},
|
|
1248
|
-
/**
|
|
1249
|
-
* Get all records in the collection
|
|
1250
|
-
*/
|
|
1251
|
-
getAll: async () => {
|
|
1252
|
-
return table.toArray();
|
|
1253
|
-
},
|
|
1254
|
-
// --- QUERY ---- //
|
|
1255
|
-
/**
|
|
1256
|
-
* Filter records using a predicate function
|
|
1257
|
-
*/
|
|
1258
|
-
filter: async (fn) => {
|
|
1259
|
-
return table.filter(fn).toArray();
|
|
1260
|
-
},
|
|
1261
|
-
/**
|
|
1262
|
-
* Get the raw Dexie table for advanced queries
|
|
1263
|
-
* @deprecated Use ref instead
|
|
1264
|
-
*/
|
|
1265
|
-
query: () => table
|
|
1266
|
-
};
|
|
926
|
+
async remove(key) {
|
|
927
|
+
localStorage.removeItem(key);
|
|
1267
928
|
}
|
|
1268
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
|
+
};
|
|
1269
941
|
|
|
1270
|
-
// src/
|
|
1271
|
-
var
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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}`;
|
|
1279
951
|
}
|
|
1280
|
-
|
|
952
|
+
return projectId;
|
|
953
|
+
}
|
|
1281
954
|
|
|
1282
|
-
// src/
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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`;
|
|
1288
964
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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`);
|
|
1296
975
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
}
|
|
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}`);
|
|
1301
980
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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}`);
|
|
1311
995
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
*/
|
|
1316
|
-
async request(method, path, body, isRetry = false) {
|
|
1317
|
-
const token = await this.config.getToken();
|
|
1318
|
-
const url = `${this.config.serverUrl}${path}`;
|
|
1319
|
-
this.log(`${method} ${url}`, body ? JSON.stringify(body) : "");
|
|
1320
|
-
const headers = {
|
|
1321
|
-
"Authorization": `Bearer ${token}`
|
|
1322
|
-
};
|
|
1323
|
-
if (body) {
|
|
1324
|
-
headers["Content-Type"] = "application/json";
|
|
1325
|
-
}
|
|
1326
|
-
const response = await fetch(url, {
|
|
1327
|
-
method,
|
|
1328
|
-
headers,
|
|
1329
|
-
...body ? { body: JSON.stringify(body) } : {}
|
|
1330
|
-
});
|
|
1331
|
-
const responseData = await response.json().catch(() => ({}));
|
|
1332
|
-
if (!response.ok) {
|
|
1333
|
-
if (response.status === 401 && !isRetry) {
|
|
1334
|
-
this.log("Got 401, forcing token refresh and retrying...");
|
|
1335
|
-
await this.config.getToken({ forceRefresh: true });
|
|
1336
|
-
return this.request(method, path, body, true);
|
|
1337
|
-
}
|
|
1338
|
-
if (this.config.debug) {
|
|
1339
|
-
console.error(`[RemoteDB] Error ${response.status}:`, responseData);
|
|
1340
|
-
}
|
|
1341
|
-
if (this.config.onAuthError) {
|
|
1342
|
-
if (response.status === 401) {
|
|
1343
|
-
this.config.onAuthError({
|
|
1344
|
-
status: response.status,
|
|
1345
|
-
message: "Authentication failed",
|
|
1346
|
-
response: responseData,
|
|
1347
|
-
errorType: "expired",
|
|
1348
|
-
afterRetry: isRetry
|
|
1349
|
-
});
|
|
1350
|
-
} else if (response.status === 403) {
|
|
1351
|
-
this.config.onAuthError({
|
|
1352
|
-
status: response.status,
|
|
1353
|
-
message: responseData.message || "Forbidden - insufficient permissions or missing scope",
|
|
1354
|
-
response: responseData,
|
|
1355
|
-
errorType: "forbidden",
|
|
1356
|
-
afterRetry: isRetry
|
|
1357
|
-
});
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
const errorMessage = responseData.message || responseData.error || responseData.detail || (typeof responseData === "string" ? responseData : `API request failed: ${response.status}`);
|
|
1361
|
-
throw new RemoteDBError(errorMessage, response.status, responseData);
|
|
1362
|
-
}
|
|
1363
|
-
this.log("Response:", responseData);
|
|
1364
|
-
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}`);
|
|
1365
999
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
throw new Error(result.message || "Data validation failed");
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
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}`);
|
|
1376
1007
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
return `/account/${this.config.projectId}/db/${this.tableName}`;
|
|
1382
|
-
}
|
|
1383
|
-
/**
|
|
1384
|
-
* Add a new record to the collection
|
|
1385
|
-
* The server generates the ID
|
|
1386
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1387
|
-
*/
|
|
1388
|
-
async add(data) {
|
|
1389
|
-
this.validateData(data, true);
|
|
1390
|
-
try {
|
|
1391
|
-
const result = await this.request(
|
|
1392
|
-
"POST",
|
|
1393
|
-
this.basePath,
|
|
1394
|
-
{ value: data }
|
|
1395
|
-
);
|
|
1396
|
-
return result.data;
|
|
1397
|
-
} catch (error) {
|
|
1398
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1399
|
-
throw new NotAuthenticatedError("Sign in required to add items");
|
|
1400
|
-
}
|
|
1401
|
-
throw error;
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
/**
|
|
1405
|
-
* Put (upsert) a record - requires id
|
|
1406
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1407
|
-
*/
|
|
1408
|
-
async put(data) {
|
|
1409
|
-
if (!data.id) {
|
|
1410
|
-
throw new Error("put() requires an id field");
|
|
1411
|
-
}
|
|
1412
|
-
const { id, ...rest } = data;
|
|
1413
|
-
this.validateData(rest, true);
|
|
1414
|
-
try {
|
|
1415
|
-
const result = await this.request(
|
|
1416
|
-
"PUT",
|
|
1417
|
-
`${this.basePath}/${id}`,
|
|
1418
|
-
{ value: rest }
|
|
1419
|
-
);
|
|
1420
|
-
return result.data || data;
|
|
1421
|
-
} catch (error) {
|
|
1422
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1423
|
-
throw new NotAuthenticatedError("Sign in required to update items");
|
|
1424
|
-
}
|
|
1425
|
-
throw error;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
/**
|
|
1429
|
-
* Update an existing record by id
|
|
1430
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1431
|
-
*/
|
|
1432
|
-
async update(id, data) {
|
|
1433
|
-
if (!id) {
|
|
1434
|
-
throw new Error("update() requires an id");
|
|
1435
|
-
}
|
|
1436
|
-
this.validateData(data, false);
|
|
1437
|
-
try {
|
|
1438
|
-
const result = await this.request(
|
|
1439
|
-
"PATCH",
|
|
1440
|
-
`${this.basePath}/${id}`,
|
|
1441
|
-
{ value: data }
|
|
1442
|
-
);
|
|
1443
|
-
return result.data || null;
|
|
1444
|
-
} catch (error) {
|
|
1445
|
-
if (error instanceof RemoteDBError && error.status === 404) {
|
|
1446
|
-
return null;
|
|
1447
|
-
}
|
|
1448
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1449
|
-
throw new NotAuthenticatedError("Sign in required to update items");
|
|
1450
|
-
}
|
|
1451
|
-
throw error;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
/**
|
|
1455
|
-
* Delete a record by id
|
|
1456
|
-
* Requires authentication - throws NotAuthenticatedError if not signed in
|
|
1457
|
-
*/
|
|
1458
|
-
async delete(id) {
|
|
1459
|
-
if (!id) {
|
|
1460
|
-
throw new Error("delete() requires an id");
|
|
1461
|
-
}
|
|
1462
|
-
try {
|
|
1463
|
-
await this.request(
|
|
1464
|
-
"DELETE",
|
|
1465
|
-
`${this.basePath}/${id}`
|
|
1466
|
-
);
|
|
1467
|
-
return true;
|
|
1468
|
-
} catch (error) {
|
|
1469
|
-
if (error instanceof RemoteDBError && error.status === 404) {
|
|
1470
|
-
return false;
|
|
1471
|
-
}
|
|
1472
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1473
|
-
throw new NotAuthenticatedError("Sign in required to delete items");
|
|
1474
|
-
}
|
|
1475
|
-
throw error;
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
/**
|
|
1479
|
-
* Get a single record by id
|
|
1480
|
-
* Returns null if not authenticated (graceful degradation for read operations)
|
|
1481
|
-
*/
|
|
1482
|
-
async get(id) {
|
|
1483
|
-
if (!id) {
|
|
1484
|
-
throw new Error("get() requires an id");
|
|
1485
|
-
}
|
|
1486
|
-
try {
|
|
1487
|
-
const result = await this.request(
|
|
1488
|
-
"GET",
|
|
1489
|
-
`${this.basePath}?id=${id}`
|
|
1490
|
-
);
|
|
1491
|
-
return result.data?.[0] || null;
|
|
1492
|
-
} catch (error) {
|
|
1493
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1494
|
-
this.log("Not authenticated - returning null for get()");
|
|
1495
|
-
}
|
|
1496
|
-
return null;
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
/**
|
|
1500
|
-
* Get all records in the collection
|
|
1501
|
-
* Returns empty array if not authenticated (graceful degradation for read operations)
|
|
1502
|
-
*/
|
|
1503
|
-
async getAll() {
|
|
1504
|
-
try {
|
|
1505
|
-
const result = await this.request(
|
|
1506
|
-
"GET",
|
|
1507
|
-
this.basePath
|
|
1508
|
-
);
|
|
1509
|
-
return result.data || [];
|
|
1510
|
-
} catch (error) {
|
|
1511
|
-
if (this.isNotAuthenticatedError(error)) {
|
|
1512
|
-
this.log("Not authenticated - returning empty array for getAll()");
|
|
1513
|
-
return [];
|
|
1514
|
-
}
|
|
1515
|
-
throw error;
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
/**
|
|
1519
|
-
* Filter records using a predicate function
|
|
1520
|
-
* Note: This fetches all records and filters client-side
|
|
1521
|
-
* Returns empty array if not authenticated (graceful degradation for read operations)
|
|
1522
|
-
*/
|
|
1523
|
-
async filter(fn) {
|
|
1524
|
-
const all = await this.getAll();
|
|
1525
|
-
return all.filter(fn);
|
|
1526
|
-
}
|
|
1527
|
-
/**
|
|
1528
|
-
* ref is not available for remote collections
|
|
1529
|
-
*/
|
|
1530
|
-
ref = void 0;
|
|
1531
|
-
};
|
|
1532
|
-
|
|
1533
|
-
// src/core/db/RemoteDB.ts
|
|
1534
|
-
var RemoteDB = class {
|
|
1535
|
-
config;
|
|
1536
|
-
collections = /* @__PURE__ */ new Map();
|
|
1537
|
-
constructor(config) {
|
|
1538
|
-
this.config = config;
|
|
1539
|
-
}
|
|
1540
|
-
/**
|
|
1541
|
-
* Get a collection by name
|
|
1542
|
-
* Collections are cached for reuse
|
|
1543
|
-
*/
|
|
1544
|
-
collection(name) {
|
|
1545
|
-
if (this.collections.has(name)) {
|
|
1546
|
-
return this.collections.get(name);
|
|
1547
|
-
}
|
|
1548
|
-
if (this.config.schema?.tables && !this.config.schema.tables[name]) {
|
|
1549
|
-
throw new Error(`Table "${name}" not found in schema`);
|
|
1550
|
-
}
|
|
1551
|
-
const collection = new RemoteCollection(name, this.config);
|
|
1552
|
-
this.collections.set(name, collection);
|
|
1553
|
-
return collection;
|
|
1554
|
-
}
|
|
1555
|
-
};
|
|
1556
|
-
|
|
1557
|
-
// src/core/auth/AuthManager.ts
|
|
1558
|
-
import { jwtDecode } from "jwt-decode";
|
|
1559
|
-
|
|
1560
|
-
// src/utils/storage.ts
|
|
1561
|
-
var LocalStorageAdapter = class {
|
|
1562
|
-
async get(key) {
|
|
1563
|
-
return localStorage.getItem(key);
|
|
1564
|
-
}
|
|
1565
|
-
async set(key, value) {
|
|
1566
|
-
localStorage.setItem(key, value);
|
|
1567
|
-
}
|
|
1568
|
-
async remove(key) {
|
|
1569
|
-
localStorage.removeItem(key);
|
|
1570
|
-
}
|
|
1571
|
-
};
|
|
1572
|
-
var STORAGE_KEYS = {
|
|
1573
|
-
REFRESH_TOKEN: "basic_refresh_token",
|
|
1574
|
-
USER_INFO: "basic_user_info",
|
|
1575
|
-
AUTH_STATE: "basic_auth_state",
|
|
1576
|
-
REDIRECT_URI: "basic_redirect_uri",
|
|
1577
|
-
SERVER_URL: "basic_server_url",
|
|
1578
|
-
PDS_ENDPOINTS: "basic_pds_endpoints",
|
|
1579
|
-
LAST_CONNECT_REPORT: "basic_last_connect_report",
|
|
1580
|
-
DEBUG: "basic_debug",
|
|
1581
|
-
CODE_VERIFIER: "basic_code_verifier"
|
|
1582
|
-
};
|
|
1583
|
-
|
|
1584
|
-
// src/utils/normalizeClientId.ts
|
|
1585
|
-
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1586
|
-
function normalizeClientId(projectId, adminHostname = "api.basic.tech") {
|
|
1587
|
-
if (!projectId) return projectId;
|
|
1588
|
-
if (projectId === "self") return projectId;
|
|
1589
|
-
if (projectId.startsWith("did:")) return projectId;
|
|
1590
|
-
if (UUID_RE.test(projectId)) {
|
|
1591
|
-
const hex = projectId.replace(/-/g, "").toLowerCase();
|
|
1592
|
-
return `did:web:${adminHostname}:projects:${hex}`;
|
|
1593
|
-
}
|
|
1594
|
-
return projectId;
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
// src/utils/resolveDid.ts
|
|
1598
|
-
function resolveDidWebUrl(did) {
|
|
1599
|
-
if (!did.startsWith("did:web:")) return null;
|
|
1600
|
-
const rest = did.slice(8);
|
|
1601
|
-
if (!rest) return null;
|
|
1602
|
-
const parts = rest.split(":");
|
|
1603
|
-
const hostname = parts[0].replace(/%3A/gi, ":");
|
|
1604
|
-
if (parts.length === 1) {
|
|
1605
|
-
return `https://${hostname}/.well-known/did.json`;
|
|
1606
|
-
}
|
|
1607
|
-
const pathParts = parts.slice(1).map((p) => decodeURIComponent(p));
|
|
1608
|
-
return `https://${hostname}/${pathParts.join("/")}/did.json`;
|
|
1609
|
-
}
|
|
1610
|
-
async function resolveFromDocument(did, didDocument) {
|
|
1611
|
-
const services = didDocument.service;
|
|
1612
|
-
const pdsService = services?.find(
|
|
1613
|
-
(s) => s.id === "#basic_pds" || s.id === `${did}#basic_pds`
|
|
1614
|
-
);
|
|
1615
|
-
if (!pdsService) {
|
|
1616
|
-
throw new Error(`DID document has no #basic_pds service entry`);
|
|
1617
|
-
}
|
|
1618
|
-
const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, "");
|
|
1619
|
-
const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`);
|
|
1620
|
-
if (!oauthRes.ok) {
|
|
1621
|
-
throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`);
|
|
1622
|
-
}
|
|
1623
|
-
const oauth = await oauthRes.json();
|
|
1624
|
-
return {
|
|
1625
|
-
did,
|
|
1626
|
-
didDocument,
|
|
1627
|
-
pdsUrl,
|
|
1628
|
-
authorization_endpoint: oauth.authorization_endpoint,
|
|
1629
|
-
token_endpoint: oauth.token_endpoint,
|
|
1630
|
-
userinfo_endpoint: oauth.userinfo_endpoint
|
|
1631
|
-
};
|
|
1632
|
-
}
|
|
1633
|
-
async function resolveDid(did) {
|
|
1634
|
-
const url = resolveDidWebUrl(did);
|
|
1635
|
-
if (!url) {
|
|
1636
|
-
throw new Error(`Unsupported DID method: ${did}`);
|
|
1637
|
-
}
|
|
1638
|
-
const didRes = await fetch(url);
|
|
1639
|
-
if (!didRes.ok) {
|
|
1640
|
-
throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`);
|
|
1641
|
-
}
|
|
1642
|
-
const didDocument = await didRes.json();
|
|
1643
|
-
return resolveFromDocument(did, didDocument);
|
|
1644
|
-
}
|
|
1645
|
-
async function resolveHandle(handle) {
|
|
1646
|
-
const res = await fetch(`https://${handle}/.well-known/did.json`);
|
|
1647
|
-
if (!res.ok) {
|
|
1648
|
-
throw new Error(`Handle resolution failed for ${handle}: ${res.status}`);
|
|
1649
|
-
}
|
|
1650
|
-
const didDocument = await res.json();
|
|
1651
|
-
const did = didDocument.id;
|
|
1652
|
-
if (!did) {
|
|
1653
|
-
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`);
|
|
1654
1012
|
}
|
|
1655
1013
|
const resolved = await resolveFromDocument(did, didDocument);
|
|
1656
1014
|
resolved.handle = handle;
|
|
@@ -1750,9 +1108,6 @@ var AuthManager = class {
|
|
|
1750
1108
|
log("Received sign-out from another tab");
|
|
1751
1109
|
this.resetAuthState("signed_out");
|
|
1752
1110
|
this.notify();
|
|
1753
|
-
if (typeof window !== "undefined") {
|
|
1754
|
-
window.location.reload();
|
|
1755
|
-
}
|
|
1756
1111
|
}
|
|
1757
1112
|
if (event.data?.type === "session_invalidated") {
|
|
1758
1113
|
log("Received session invalidation from another tab");
|
|
@@ -2040,11 +1395,13 @@ var AuthManager = class {
|
|
|
2040
1395
|
}
|
|
2041
1396
|
}
|
|
2042
1397
|
/**
|
|
2043
|
-
*
|
|
2044
|
-
*
|
|
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.
|
|
2045
1401
|
*/
|
|
2046
1402
|
async signOut() {
|
|
2047
1403
|
log("signing out!");
|
|
1404
|
+
await this.revokeSessionOnServer();
|
|
2048
1405
|
this.resetAuthState("signed_out");
|
|
2049
1406
|
await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
|
|
2050
1407
|
await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
|
|
@@ -2052,6 +1409,30 @@ var AuthManager = class {
|
|
|
2052
1409
|
this.broadcastSignOut();
|
|
2053
1410
|
this.notify();
|
|
2054
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
|
+
}
|
|
2055
1436
|
async reconcileSession(reason = "manual", options) {
|
|
2056
1437
|
if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
|
|
2057
1438
|
return;
|
|
@@ -2375,11 +1756,11 @@ var AuthManager = class {
|
|
|
2375
1756
|
...isRefreshToken ? { refresh_token: "[REDACTED]" } : { code: "[REDACTED]" },
|
|
2376
1757
|
...requestBody.code_verifier ? { code_verifier: "[REDACTED]" } : {}
|
|
2377
1758
|
});
|
|
2378
|
-
const
|
|
1759
|
+
const response = await fetch(endpoints.token_endpoint, {
|
|
2379
1760
|
method: "POST",
|
|
2380
1761
|
headers: { "Content-Type": "application/json" },
|
|
2381
1762
|
body: JSON.stringify(requestBody)
|
|
2382
|
-
}).
|
|
1763
|
+
}).catch((error) => {
|
|
2383
1764
|
log("Network error fetching token:", error);
|
|
2384
1765
|
if (!this.isOnline) {
|
|
2385
1766
|
this.pendingRefresh = true;
|
|
@@ -2389,6 +1770,18 @@ var AuthManager = class {
|
|
|
2389
1770
|
}
|
|
2390
1771
|
throw new Error("Network error during token refresh");
|
|
2391
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
|
+
});
|
|
2392
1785
|
if (token.access_token) {
|
|
2393
1786
|
try {
|
|
2394
1787
|
const decoded = jwtDecode(token.access_token);
|
|
@@ -2498,7 +1891,8 @@ var AuthManager = class {
|
|
|
2498
1891
|
isNetworkError(error) {
|
|
2499
1892
|
if (error instanceof TypeError) return true;
|
|
2500
1893
|
if (error instanceof Error) {
|
|
2501
|
-
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");
|
|
2502
1896
|
}
|
|
2503
1897
|
return false;
|
|
2504
1898
|
}
|
|
@@ -2713,135 +2107,1427 @@ var AuthManager = class {
|
|
|
2713
2107
|
}
|
|
2714
2108
|
};
|
|
2715
2109
|
|
|
2716
|
-
// src/
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
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";
|
|
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;
|
|
2731
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
|
+
// -------------------------------------------------------------------
|
|
2732
2172
|
/**
|
|
2733
|
-
*
|
|
2734
|
-
*
|
|
2735
|
-
* Example: "0.7.0-beta.1" and "0.7.0" are treated as the same version
|
|
2173
|
+
* Shares granted by and received by the caller. App tokens see only
|
|
2174
|
+
* shares involving their own app (the ones they can mount).
|
|
2736
2175
|
*/
|
|
2737
|
-
async
|
|
2738
|
-
const
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
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;
|
|
2745
2204
|
}
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2205
|
+
}
|
|
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;
|
|
2214
|
+
}
|
|
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;
|
|
2750
2227
|
}
|
|
2751
|
-
|
|
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;
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
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
|
|
2326
|
+
};
|
|
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
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
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) {
|
|
2752
2388
|
try {
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
} catch (error) {
|
|
2756
|
-
console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error);
|
|
2757
|
-
throw new Error(`Migration failed: ${error}`);
|
|
2389
|
+
this.ws.close();
|
|
2390
|
+
} catch {
|
|
2758
2391
|
}
|
|
2392
|
+
this.ws = null;
|
|
2759
2393
|
}
|
|
2760
|
-
|
|
2761
|
-
return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
|
|
2394
|
+
this.setStatus("stopped");
|
|
2762
2395
|
}
|
|
2763
|
-
|
|
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;
|
|
2764
2407
|
try {
|
|
2765
|
-
const
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
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);
|
|
2771
2789
|
return null;
|
|
2772
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
|
+
}
|
|
2773
3181
|
}
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
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
|
|
2778
3202
|
};
|
|
2779
|
-
await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
|
|
2780
3203
|
}
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
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
|
|
2788
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 });
|
|
2789
3238
|
}
|
|
2790
3239
|
/**
|
|
2791
|
-
*
|
|
2792
|
-
*
|
|
3240
|
+
* Responsibilities 3+4+5: ordered apply, cursor advance, dedupe/confirm.
|
|
3241
|
+
* Runs inside the sub's serial chain.
|
|
2793
3242
|
*/
|
|
2794
|
-
|
|
2795
|
-
const
|
|
2796
|
-
const
|
|
2797
|
-
|
|
2798
|
-
|
|
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);
|
|
2799
3271
|
}
|
|
2800
|
-
return aMajorMinor.minor - bMajorMinor.minor;
|
|
2801
3272
|
}
|
|
2802
3273
|
/**
|
|
2803
|
-
*
|
|
2804
|
-
*
|
|
2805
|
-
*
|
|
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.
|
|
2806
3278
|
*/
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
const
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
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
|
+
}
|
|
2814
3328
|
}
|
|
2815
|
-
/**
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
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);
|
|
2821
3385
|
}
|
|
2822
3386
|
};
|
|
2823
|
-
function createVersionUpdater(storage, currentVersion, migrations = []) {
|
|
2824
|
-
return new VersionUpdater(storage, currentVersion, migrations);
|
|
2825
|
-
}
|
|
2826
3387
|
|
|
2827
|
-
// src/
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
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);
|
|
2835
3529
|
}
|
|
2836
3530
|
};
|
|
2837
|
-
function getMigrations() {
|
|
2838
|
-
return [
|
|
2839
|
-
addMigrationTimestamp
|
|
2840
|
-
];
|
|
2841
|
-
}
|
|
2842
|
-
|
|
2843
|
-
// src/AuthContext.tsx
|
|
2844
|
-
init_network();
|
|
2845
3531
|
|
|
2846
3532
|
// src/utils/schema.ts
|
|
2847
3533
|
init_config();
|
|
@@ -2901,477 +3587,573 @@ async function getSchemaStatus(schema) {
|
|
|
2901
3587
|
latest: latestSchema
|
|
2902
3588
|
};
|
|
2903
3589
|
}
|
|
2904
|
-
} else {
|
|
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()
|
|
3688
|
+
};
|
|
3689
|
+
await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
|
|
3690
|
+
}
|
|
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;
|
|
3698
|
+
});
|
|
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);
|
|
2905
3720
|
return {
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
latest: null
|
|
3721
|
+
major: parts[0] || 0,
|
|
3722
|
+
minor: parts[1] || 0
|
|
2909
3723
|
};
|
|
2910
3724
|
}
|
|
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));
|
|
3731
|
+
}
|
|
3732
|
+
};
|
|
3733
|
+
function createVersionUpdater(storage, currentVersion, migrations = []) {
|
|
3734
|
+
return new VersionUpdater(storage, currentVersion, migrations);
|
|
2911
3735
|
}
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
`;
|
|
2922
|
-
});
|
|
2923
|
-
console.groupEnd();
|
|
2924
|
-
return {
|
|
2925
|
-
isValid: false,
|
|
2926
|
-
schemaStatus: { valid: false },
|
|
2927
|
-
errors: valid.errors
|
|
2928
|
-
};
|
|
3736
|
+
|
|
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");
|
|
2929
3745
|
}
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
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
|
+
}
|
|
2937
3761
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3762
|
+
};
|
|
3763
|
+
function getMigrations() {
|
|
3764
|
+
return [
|
|
3765
|
+
addMigrationTimestamp,
|
|
3766
|
+
dropLegacySyncDb
|
|
3767
|
+
];
|
|
2942
3768
|
}
|
|
2943
3769
|
|
|
2944
|
-
// src/
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
var BasicDevToolbar2 = lazy(
|
|
2949
|
-
() => Promise.resolve().then(() => (init_BasicDevToolbar(), BasicDevToolbar_exports)).then((m) => ({ default: m.BasicDevToolbar }))
|
|
2950
|
-
);
|
|
2951
|
-
var DEFAULT_AUTH_CONFIG = {
|
|
3770
|
+
// src/core/BasicClient.ts
|
|
3771
|
+
init_config();
|
|
3772
|
+
init_package();
|
|
3773
|
+
var DEFAULTS = {
|
|
2952
3774
|
scopes: "profile,email,app:admin",
|
|
2953
3775
|
pds_url: "https://pds.basic.id",
|
|
2954
|
-
admin_url: "https://api.basic.tech"
|
|
2955
|
-
ws_url: "wss://pds.basic.id/ws"
|
|
3776
|
+
admin_url: "https://api.basic.tech"
|
|
2956
3777
|
};
|
|
2957
|
-
function
|
|
2958
|
-
return
|
|
2959
|
-
isSignedIn: mgr.isSignedIn,
|
|
2960
|
-
hasToken: !!mgr.token,
|
|
2961
|
-
isAuthReady: mgr.isAuthReady,
|
|
2962
|
-
authStatus: mgr.authStatus,
|
|
2963
|
-
authErrorCode: mgr.authErrorCode,
|
|
2964
|
-
user: mgr.user,
|
|
2965
|
-
did: mgr.did,
|
|
2966
|
-
tokenScope: mgr.tokenScope
|
|
2967
|
-
};
|
|
3778
|
+
function deriveSyncUrl(pdsUrl) {
|
|
3779
|
+
return pdsUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/sync/";
|
|
2968
3780
|
}
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
user: null,
|
|
3001
|
-
did: null,
|
|
3002
|
-
tokenScope: null
|
|
3003
|
-
});
|
|
3004
|
-
const authRef = useRef(null);
|
|
3005
|
-
if (!authRef.current) {
|
|
3006
|
-
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(
|
|
3007
3812
|
{
|
|
3008
|
-
projectId:
|
|
3009
|
-
scopes:
|
|
3813
|
+
projectId: this.projectId,
|
|
3814
|
+
scopes: authConfig.scopes,
|
|
3010
3815
|
pdsUrl: authConfig.pds_url,
|
|
3011
3816
|
adminUrl: authConfig.admin_url,
|
|
3012
|
-
debug
|
|
3817
|
+
debug: this.debug
|
|
3013
3818
|
},
|
|
3014
|
-
|
|
3015
|
-
() =>
|
|
3819
|
+
storage,
|
|
3820
|
+
() => this.handleAuthChange()
|
|
3016
3821
|
);
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
project_id ? {
|
|
3033
|
-
projectId: project_id,
|
|
3034
|
-
localVersion: void 0,
|
|
3035
|
-
status: "no_schema",
|
|
3036
|
-
valid: false,
|
|
3037
|
-
lastCheckedAt: Date.now()
|
|
3038
|
-
} : null
|
|
3039
|
-
);
|
|
3040
|
-
return;
|
|
3041
|
-
}
|
|
3042
|
-
const result = await validateAndCheckSchema(s);
|
|
3043
|
-
if (!result.isValid) {
|
|
3044
|
-
const errText = result.errors?.map((e) => e.message || "").join("; ") || "invalid";
|
|
3045
|
-
setSchemaDevInfo({
|
|
3046
|
-
projectId: s.project_id ?? null,
|
|
3047
|
-
localVersion: s.version,
|
|
3048
|
-
status: "invalid",
|
|
3049
|
-
valid: false,
|
|
3050
|
-
lastCheckedAt: Date.now(),
|
|
3051
|
-
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
|
|
3052
3837
|
});
|
|
3053
|
-
|
|
3838
|
+
this.syncDb = new SyncDb(this.engine, OWN_SUB);
|
|
3839
|
+
this.wireEngineEvents(this.engine);
|
|
3840
|
+
} else {
|
|
3841
|
+
this.engine = null;
|
|
3842
|
+
this.syncDb = null;
|
|
3054
3843
|
}
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
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) {
|
|
3065
3881
|
try {
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
version,
|
|
3069
|
-
getMigrations()
|
|
3070
|
-
);
|
|
3071
|
-
const updateResult = await versionUpdater.checkAndUpdate();
|
|
3072
|
-
if (updateResult.updated) {
|
|
3073
|
-
log(
|
|
3074
|
-
`App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
|
|
3075
|
-
);
|
|
3076
|
-
} else {
|
|
3077
|
-
log(`App version ${updateResult.toVersion} is current`);
|
|
3078
|
-
}
|
|
3079
|
-
} catch (error2) {
|
|
3080
|
-
log("Version update failed:", error2);
|
|
3882
|
+
fn();
|
|
3883
|
+
} catch {
|
|
3081
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))
|
|
3082
3915
|
};
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
return
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
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();
|
|
3114
3948
|
}
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
schema,
|
|
3132
|
-
debug,
|
|
3133
|
-
onAuthError: (error2) => {
|
|
3134
|
-
log("RemoteDB auth error:", error2);
|
|
3135
|
-
if (error2.errorType === "forbidden") {
|
|
3136
|
-
log("403 Forbidden - user lacks required scope, not signing out");
|
|
3137
|
-
return;
|
|
3138
|
-
}
|
|
3139
|
-
authRef.current.reconcileSession(`remote db ${error2.errorType}`, {
|
|
3140
|
-
forceRefresh: error2.errorType !== "network",
|
|
3141
|
-
throttleMs: 0
|
|
3142
|
-
}).catch((reconcileError) => {
|
|
3143
|
-
log("RemoteDB auth recovery failed:", reconcileError);
|
|
3144
|
-
});
|
|
3145
|
-
}
|
|
3146
|
-
});
|
|
3147
|
-
setDbStatus("ONLINE" /* ONLINE */);
|
|
3148
|
-
setIsDbReady(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);
|
|
3149
3965
|
}
|
|
3150
3966
|
}
|
|
3151
|
-
|
|
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 {
|
|
3152
3999
|
const result = await validateAndCheckSchema(schema);
|
|
3153
4000
|
if (!result.isValid) {
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
`;
|
|
3159
|
-
});
|
|
3160
|
-
}
|
|
3161
|
-
setSchemaDevInfo({
|
|
3162
|
-
projectId: schema?.project_id ?? null,
|
|
3163
|
-
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,
|
|
3164
4005
|
status: "invalid",
|
|
3165
4006
|
valid: false,
|
|
3166
4007
|
lastCheckedAt: Date.now(),
|
|
3167
|
-
error:
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
code: "schema_invalid",
|
|
3171
|
-
title: "Basic Schema is invalid!",
|
|
3172
|
-
message: errorMessage
|
|
3173
|
-
});
|
|
3174
|
-
setIsDbReady(true);
|
|
3175
|
-
return null;
|
|
3176
|
-
}
|
|
3177
|
-
setSchemaDevInfo({
|
|
3178
|
-
projectId: schema?.project_id ?? null,
|
|
3179
|
-
localVersion: schema?.version,
|
|
3180
|
-
status: result.schemaStatus.status ?? "unknown",
|
|
3181
|
-
valid: result.schemaStatus.valid,
|
|
3182
|
-
lastCheckedAt: Date.now()
|
|
3183
|
-
});
|
|
3184
|
-
if (dbMode === "remote") {
|
|
3185
|
-
initRemoteDb();
|
|
4008
|
+
error: errText
|
|
4009
|
+
};
|
|
4010
|
+
this.syncEnabled = false;
|
|
3186
4011
|
} else {
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
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).");
|
|
3196
4028
|
}
|
|
3197
|
-
await initSyncDb({ shouldConnect: false });
|
|
3198
4029
|
}
|
|
3199
4030
|
}
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
valid: false,
|
|
3211
|
-
lastCheckedAt: Date.now()
|
|
3212
|
-
} : null
|
|
3213
|
-
);
|
|
3214
|
-
if (dbMode === "remote" && project_id) {
|
|
3215
|
-
initRemoteDb();
|
|
3216
|
-
} else {
|
|
3217
|
-
setIsDbReady(true);
|
|
3218
|
-
}
|
|
3219
|
-
}
|
|
3220
|
-
}, []);
|
|
3221
|
-
useEffect(() => {
|
|
3222
|
-
if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
|
|
3223
|
-
log("connecting to db...");
|
|
3224
|
-
syncRef.current?.connect({
|
|
3225
|
-
getToken: (opts) => authRef.current.getToken(opts),
|
|
3226
|
-
ws_url: authConfig.ws_url
|
|
3227
|
-
}).catch((e) => {
|
|
3228
|
-
log("error connecting to db", e);
|
|
3229
|
-
});
|
|
3230
|
-
}
|
|
3231
|
-
}, [
|
|
3232
|
-
authState.authStatus,
|
|
3233
|
-
authState.isSignedIn,
|
|
3234
|
-
authState.hasToken,
|
|
3235
|
-
shouldConnect
|
|
3236
|
-
]);
|
|
3237
|
-
useEffect(() => {
|
|
3238
|
-
if (authState.authStatus !== "reauth_required" || !syncRef.current) {
|
|
3239
|
-
return;
|
|
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
|
+
};
|
|
3240
4041
|
}
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
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) {
|
|
3250
4064
|
try {
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
syncRef.current = null;
|
|
3254
|
-
} catch (error2) {
|
|
3255
|
-
console.error("Error during database cleanup:", error2);
|
|
3256
|
-
}
|
|
3257
|
-
}
|
|
3258
|
-
if (typeof window !== "undefined") {
|
|
3259
|
-
window.location.reload();
|
|
3260
|
-
}
|
|
3261
|
-
};
|
|
3262
|
-
const handleSignIn = async () => {
|
|
3263
|
-
try {
|
|
3264
|
-
await authRef.current.signIn();
|
|
3265
|
-
} catch (error2) {
|
|
3266
|
-
if (isDevMode()) {
|
|
3267
|
-
setError({
|
|
3268
|
-
code: "signin_error",
|
|
3269
|
-
title: "Sign-in Failed",
|
|
3270
|
-
message: error2.message || "An error occurred during sign-in. Please try again."
|
|
3271
|
-
});
|
|
3272
|
-
}
|
|
3273
|
-
throw error2;
|
|
3274
|
-
}
|
|
3275
|
-
};
|
|
3276
|
-
const handleSignInWithHandle = async (handle) => {
|
|
3277
|
-
try {
|
|
3278
|
-
await authRef.current.signInWithHandle(handle);
|
|
3279
|
-
} catch (error2) {
|
|
3280
|
-
if (isDevMode()) {
|
|
3281
|
-
setError({
|
|
3282
|
-
code: "signin_error",
|
|
3283
|
-
title: "Sign-in Failed",
|
|
3284
|
-
message: error2.message || "An error occurred during sign-in. Please try again."
|
|
3285
|
-
});
|
|
4065
|
+
listener();
|
|
4066
|
+
} catch {
|
|
3286
4067
|
}
|
|
3287
|
-
throw error2;
|
|
3288
|
-
}
|
|
3289
|
-
};
|
|
3290
|
-
const getCurrentDb = () => {
|
|
3291
|
-
if (dbMode === "remote") {
|
|
3292
|
-
return remoteDbRef.current || noDb;
|
|
3293
4068
|
}
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
isSignedIn: authState.isSignedIn,
|
|
3299
|
-
authStatus: authState.authStatus,
|
|
3300
|
-
authErrorCode: authState.authErrorCode,
|
|
3301
|
-
user: authState.user,
|
|
3302
|
-
did: authState.did,
|
|
3303
|
-
scope: authState.tokenScope,
|
|
3304
|
-
hasScope: (s) => authRef.current.hasScope(s),
|
|
3305
|
-
missingScopes: () => authRef.current.missingScopes(),
|
|
3306
|
-
signIn: handleSignIn,
|
|
3307
|
-
signInWithHandle: handleSignInWithHandle,
|
|
3308
|
-
signOut: handleSignOut,
|
|
3309
|
-
signInWithCode: (code, state) => authRef.current.signInWithCode(code, state),
|
|
3310
|
-
getToken: (opts) => authRef.current.getToken(opts),
|
|
3311
|
-
getSignInUrl: (redirectUri) => authRef.current.getSignInUrl(redirectUri),
|
|
3312
|
-
db: getCurrentDb(),
|
|
3313
|
-
dbStatus,
|
|
3314
|
-
dbMode,
|
|
3315
|
-
devInfo: schemaDevInfo,
|
|
3316
|
-
refreshSchemaStatus,
|
|
3317
|
-
isAuthReady: authState.isAuthReady,
|
|
3318
|
-
signin: handleSignIn,
|
|
3319
|
-
signout: handleSignOut,
|
|
3320
|
-
signinWithCode: (code, state) => authRef.current.signInWithCode(code, state),
|
|
3321
|
-
getSignInLink: (redirectUri) => authRef.current.getSignInUrl(redirectUri)
|
|
3322
|
-
};
|
|
3323
|
-
return /* @__PURE__ */ jsxs2(BasicContext.Provider, { value: contextValue, children: [
|
|
3324
|
-
error && isDevMode() && /* @__PURE__ */ jsx2(ErrorDisplay, { error }),
|
|
3325
|
-
devToolbar && isDevMode() && /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(BasicDevToolbar2, { debug }) }),
|
|
3326
|
-
isDbReady && authState.isAuthReady && children
|
|
3327
|
-
] });
|
|
4069
|
+
}
|
|
4070
|
+
};
|
|
4071
|
+
function createBasicClient(config) {
|
|
4072
|
+
return new BasicClient(config);
|
|
3328
4073
|
}
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
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
|
+
] });
|
|
3357
4116
|
}
|
|
3358
4117
|
|
|
3359
4118
|
// src/index.ts
|
|
4119
|
+
init_hooks();
|
|
3360
4120
|
init_BasicDevToolbar();
|
|
3361
|
-
import { useLiveQuery as useQuery } from "dexie-react-hooks";
|
|
3362
4121
|
export {
|
|
4122
|
+
AuthManager,
|
|
4123
|
+
BasicClient,
|
|
3363
4124
|
BasicDevToolbar,
|
|
3364
4125
|
BasicProvider,
|
|
3365
|
-
|
|
4126
|
+
DEFAULT_LIMITS,
|
|
4127
|
+
LocalStorageAdapter,
|
|
3366
4128
|
NotAuthenticatedError,
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
4129
|
+
OWN_SUB,
|
|
4130
|
+
PROTOCOL_VERSION,
|
|
4131
|
+
RestClient,
|
|
4132
|
+
RestDb,
|
|
4133
|
+
RestError,
|
|
3370
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,
|
|
3371
4146
|
resolveDid,
|
|
3372
4147
|
resolveDidWebUrl,
|
|
3373
4148
|
resolveHandle,
|
|
4149
|
+
shareSubKey,
|
|
4150
|
+
useAuth,
|
|
3374
4151
|
useBasic,
|
|
3375
|
-
|
|
4152
|
+
useBasicClient,
|
|
4153
|
+
useDb,
|
|
4154
|
+
useQuery,
|
|
4155
|
+
useShare,
|
|
4156
|
+
useShares,
|
|
4157
|
+
useSyncStatus
|
|
3376
4158
|
};
|
|
3377
4159
|
//# sourceMappingURL=index.mjs.map
|