@basictech/react 0.8.0-beta.4 → 0.9.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.8.0-beta.4";
41
+ version = "0.9.0-beta.1";
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,185 @@ var init_network = __esm({
374
135
  }
375
136
  });
376
137
 
377
- // src/context.tsx
378
- import { createContext, useContext } from "react";
138
+ // src/react/hooks.ts
139
+ import { useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
140
+ import { useLiveQuery } from "dexie-react-hooks";
141
+ function useQuery(querier, deps = []) {
142
+ const client = useContext(BasicClientContext);
143
+ const activeUserId = useSyncExternalStore(
144
+ client ? client.subscribe : noopSubscribe,
145
+ () => client?.getSnapshot().activeUser?.id ?? null,
146
+ () => null
147
+ );
148
+ return useLiveQuery(async () => {
149
+ try {
150
+ return await querier();
151
+ } catch (err) {
152
+ if (err instanceof Error && err.name === "DatabaseClosedError") return void 0;
153
+ throw err;
154
+ }
155
+ }, [...deps, activeUserId]);
156
+ }
157
+ function useBasicClient() {
158
+ const client = useContext(BasicClientContext);
159
+ if (!client) {
160
+ throw new Error("useBasic must be used within a <BasicProvider>");
161
+ }
162
+ return client;
163
+ }
164
+ function useClientSnapshot(client) {
165
+ return useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
166
+ }
167
+ function useAuth() {
168
+ const client = useBasicClient();
169
+ const snapshot = useClientSnapshot(client);
170
+ return useMemo(
171
+ () => ({
172
+ isReady: snapshot.isReady,
173
+ isSignedIn: snapshot.isSignedIn,
174
+ isAnonymous: snapshot.isAnonymous,
175
+ status: snapshot.authStatus,
176
+ errorCode: snapshot.authErrorCode,
177
+ user: snapshot.user,
178
+ did: snapshot.did,
179
+ scope: snapshot.scope,
180
+ hasScope: (s) => client.auth.hasScope(s),
181
+ missingScopes: () => client.auth.missingScopes(),
182
+ signIn: (redirectUri) => client.auth.signIn(redirectUri),
183
+ signInWithHandle: (handle) => client.auth.signInWithHandle(handle),
184
+ signInWithCode: (code, state) => client.auth.signInWithCode(code, state),
185
+ signOut: () => client.signOut(),
186
+ getToken: (options) => client.auth.getToken(options),
187
+ getSignInUrl: (redirectUri) => client.auth.getSignInUrl(redirectUri)
188
+ }),
189
+ [client, snapshot]
190
+ );
191
+ }
192
+ function useDb() {
193
+ const client = useBasicClient();
194
+ return client.db;
195
+ }
196
+ function useSyncStatus() {
197
+ const client = useBasicClient();
198
+ const snapshot = useClientSnapshot(client);
199
+ return useMemo(
200
+ () => ({
201
+ status: snapshot.syncStatus,
202
+ enabled: snapshot.syncEnabled,
203
+ pendingCount: snapshot.pendingCount,
204
+ listRejected: () => client.listRejected(),
205
+ clearRejected: () => client.clearRejected()
206
+ }),
207
+ [client, snapshot]
208
+ );
209
+ }
210
+ function useShares() {
211
+ const client = useBasicClient();
212
+ const snapshot = useClientSnapshot(client);
213
+ const [state, setState] = useState({ granted: [], received: [], isLoading: false, error: null });
214
+ const isSignedIn = snapshot.isSignedIn && snapshot.authStatus === "authenticated";
215
+ const refresh = useMemo(
216
+ () => async () => {
217
+ setState((s) => ({ ...s, isLoading: true, error: null }));
218
+ try {
219
+ const { granted, received } = await client.listShares();
220
+ setState({ granted, received, isLoading: false, error: null });
221
+ } catch (err) {
222
+ setState((s) => ({
223
+ ...s,
224
+ isLoading: false,
225
+ error: err instanceof Error ? err : new Error(String(err))
226
+ }));
227
+ }
228
+ },
229
+ [client]
230
+ );
231
+ const activeUserId = snapshot.activeUser?.id ?? null;
232
+ useEffect(() => {
233
+ if (isSignedIn) void refresh();
234
+ else setState({ granted: [], received: [], isLoading: false, error: null });
235
+ }, [isSignedIn, refresh, activeUserId]);
236
+ return { ...state, refresh };
237
+ }
238
+ function useShare(shareId) {
239
+ const client = useBasicClient();
240
+ const snapshot = useClientSnapshot(client);
241
+ const [handle, setHandle] = useState(null);
242
+ const [error, setError] = useState(null);
243
+ const [revoked, setRevoked] = useState(false);
244
+ const canMount = !!shareId && snapshot.isSignedIn && snapshot.authStatus !== "reauth_required";
245
+ const activeUserId = snapshot.activeUser?.id ?? null;
246
+ useEffect(() => {
247
+ if (!canMount || !shareId) return;
248
+ let cancelled = false;
249
+ setError(null);
250
+ setRevoked(false);
251
+ client.mountShare(shareId).then((h) => {
252
+ if (!cancelled) setHandle(h);
253
+ }).catch((err) => {
254
+ if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
255
+ });
256
+ const offSubError = client.engine?.on("suberror", ({ sub, code }) => {
257
+ if (sub === `share:${shareId}` && (code === "SHARE_REVOKED" || code === "CONNECTION_REVOKED")) {
258
+ setRevoked(true);
259
+ setHandle(null);
260
+ }
261
+ });
262
+ return () => {
263
+ cancelled = true;
264
+ offSubError?.();
265
+ setHandle(null);
266
+ void client.unmountShare(shareId).catch(() => {
267
+ });
268
+ };
269
+ }, [client, shareId, canMount, activeUserId]);
270
+ return {
271
+ db: handle?.db ?? null,
272
+ status: revoked ? "revoked" : error ? "error" : handle ? "mounted" : "mounting",
273
+ error
274
+ };
275
+ }
276
+ function useUsers() {
277
+ const client = useBasicClient();
278
+ const snapshot = useClientSnapshot(client);
279
+ return useMemo(
280
+ () => ({
281
+ users: snapshot.users,
282
+ activeUser: snapshot.activeUser,
283
+ isAnonymous: snapshot.isAnonymous,
284
+ switchUser: (id) => client.switchUser(id),
285
+ addUser: () => client.addUser(),
286
+ removeUser: (id) => client.removeUser(id)
287
+ }),
288
+ [client, snapshot]
289
+ );
290
+ }
379
291
  function useBasic() {
380
- return useContext(BasicContext);
292
+ const client = useBasicClient();
293
+ const snapshot = useClientSnapshot(client);
294
+ const auth = useAuth();
295
+ const sync = useSyncStatus();
296
+ return useMemo(
297
+ () => ({
298
+ ...auth,
299
+ db: client.db,
300
+ sync,
301
+ users: snapshot.users,
302
+ activeUser: snapshot.activeUser,
303
+ devInfo: snapshot.devInfo,
304
+ refreshSchemaStatus: () => client.refreshSchemaStatus(),
305
+ client
306
+ }),
307
+ [client, snapshot, auth, sync]
308
+ );
381
309
  }
382
- var DBStatus, noDb, BasicContext;
383
- var init_context = __esm({
384
- "src/context.tsx"() {
310
+ var noopSubscribe;
311
+ var init_hooks = __esm({
312
+ "src/react/hooks.ts"() {
385
313
  "use strict";
386
- DBStatus = /* @__PURE__ */ ((DBStatus2) => {
387
- DBStatus2["LOADING"] = "LOADING";
388
- DBStatus2["OFFLINE"] = "OFFLINE";
389
- DBStatus2["CONNECTING"] = "CONNECTING";
390
- DBStatus2["ONLINE"] = "ONLINE";
391
- DBStatus2["SYNCING"] = "SYNCING";
392
- DBStatus2["ERROR"] = "ERROR";
393
- DBStatus2["ERROR_WILL_RETRY"] = "ERROR_WILL_RETRY";
394
- DBStatus2["ERROR_TOKEN_EXPIRED"] = "ERROR_TOKEN_EXPIRED";
395
- return DBStatus2;
396
- })(DBStatus || {});
397
- noDb = {
398
- collection: () => {
399
- throw new Error("no basicdb found - initialization failed. double check your schema.");
400
- }
314
+ init_context();
315
+ noopSubscribe = () => () => {
401
316
  };
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
- });
430
317
  }
431
318
  });
432
319
 
@@ -435,18 +322,19 @@ var BasicDevToolbar_exports = {};
435
322
  __export(BasicDevToolbar_exports, {
436
323
  BasicDevToolbar: () => BasicDevToolbar
437
324
  });
438
- import { useCallback, useMemo, useState } from "react";
325
+ import { useCallback, useMemo as useMemo2, useState as useState2 } from "react";
439
326
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
440
327
  function toneForAuth(isReady, isSignedIn) {
441
328
  if (!isReady) return "muted";
442
329
  if (isSignedIn) return "ok";
443
330
  return "warn";
444
331
  }
445
- function toneForDb(dbMode, dbStatus) {
446
- if (dbMode === "remote") return dbStatus === "ONLINE" /* ONLINE */ ? "ok" : "warn";
447
- if (dbStatus === "ONLINE" /* ONLINE */ || dbStatus === "SYNCING" /* SYNCING */) return "ok";
448
- if (dbStatus === "CONNECTING" /* CONNECTING */ || dbStatus === "LOADING" /* LOADING */) return "warn";
449
- if (dbStatus === "OFFLINE" /* OFFLINE */) return "muted";
332
+ function toneForSync(mode, status) {
333
+ if (mode === "rest") return "muted";
334
+ if (status === "online") return "ok";
335
+ if (status === "connecting") return "warn";
336
+ if (status === "offline" || status === "idle" || status === "stopped" || status === "local")
337
+ return "muted";
450
338
  return "bad";
451
339
  }
452
340
  function toneForSchema(info) {
@@ -456,24 +344,24 @@ function toneForSchema(info) {
456
344
  if (info.status === "no_schema") return "muted";
457
345
  return "bad";
458
346
  }
459
- function dbStatusLabel(status) {
347
+ function syncStatusLabel(status) {
460
348
  switch (status) {
461
- case "LOADING" /* LOADING */:
462
- return "Initializing";
463
- case "OFFLINE" /* OFFLINE */:
464
- return "Offline";
465
- case "CONNECTING" /* CONNECTING */:
349
+ case "idle":
350
+ return "Idle";
351
+ case "local":
352
+ return "Local only";
353
+ case "connecting":
466
354
  return "Connecting";
467
- case "ONLINE" /* ONLINE */:
355
+ case "online":
468
356
  return "Connected";
469
- case "SYNCING" /* SYNCING */:
470
- return "Syncing";
471
- case "ERROR" /* ERROR */:
472
- return "Error";
473
- case "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */:
474
- return "Retrying";
475
- case "ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */:
476
- return "Token refresh";
357
+ case "offline":
358
+ return "Offline";
359
+ case "auth_required":
360
+ return "Reauth required";
361
+ case "revoked":
362
+ return "Connection revoked";
363
+ case "stopped":
364
+ return "Stopped";
477
365
  default:
478
366
  return String(status);
479
367
  }
@@ -562,7 +450,7 @@ function CopyableRow({
562
450
  onCopied,
563
451
  children
564
452
  }) {
565
- const [hover, setHover] = useState(false);
453
+ const [hover, setHover] = useState2(false);
566
454
  const canCopy = copyText.length > 0;
567
455
  const handleClick = useCallback(
568
456
  (e) => {
@@ -638,24 +526,31 @@ function BasicDevToolbar({ enabled = true, debug }) {
638
526
  const {
639
527
  isReady,
640
528
  isSignedIn,
529
+ isAnonymous,
641
530
  user,
642
531
  did,
643
532
  scope,
644
533
  missingScopes,
645
- dbMode,
646
- dbStatus,
534
+ sync,
535
+ users,
536
+ activeUser,
647
537
  devInfo,
648
- refreshSchemaStatus
538
+ refreshSchemaStatus,
539
+ client
649
540
  } = useBasic();
650
- const [open, setOpen] = useState(false);
651
- const [refreshing, setRefreshing] = useState(false);
652
- const [copied, setCopied] = useState(false);
653
- const [rowCopied, setRowCopied] = useState(null);
541
+ const dbMode = client.mode;
542
+ const syncStatus = sync.status;
543
+ const indexedDbName = dbMode === "sync" && client.projectId ? `basic-sync:${client.projectId}${activeUser?.keyspace ? `:${activeUser.keyspace}` : ""}` : null;
544
+ const activeUserLabel = activeUser ? `${isAnonymous ? "anonymous" : activeUser.email || activeUser.name || activeUser.did || "account"} (${activeUser.id.slice(0, 8)}\u2026)${users.length > 1 ? ` \xB7 ${users.length} users` : ""}` : "\u2014";
545
+ const [open, setOpen] = useState2(false);
546
+ const [refreshing, setRefreshing] = useState2(false);
547
+ const [copied, setCopied] = useState2(false);
548
+ const [rowCopied, setRowCopied] = useState2(null);
654
549
  const show = enabled && typeof window !== "undefined" && isDevelopment(debug);
655
550
  const authTone = toneForAuth(isReady, isSignedIn);
656
- const dbTone = toneForDb(dbMode, dbStatus);
551
+ const dbTone = toneForSync(dbMode, syncStatus);
657
552
  const schemaTone = toneForSchema(devInfo);
658
- const syncTone = dbMode === "remote" ? "muted" : dbTone === "ok" || dbStatus === "SYNCING" /* SYNCING */ ? "ok" : dbTone === "warn" ? "warn" : dbTone === "bad" ? "bad" : "muted";
553
+ const syncTone = dbTone;
659
554
  const handleRefreshSchema = useCallback(async () => {
660
555
  setRefreshing(true);
661
556
  try {
@@ -665,7 +560,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
665
560
  }
666
561
  }, [refreshSchemaStatus]);
667
562
  const missingList = missingScopes();
668
- const debugPayload = useMemo(() => {
563
+ const debugPayload = useMemo2(() => {
669
564
  return {
670
565
  sdkVersion: version,
671
566
  isReady,
@@ -680,11 +575,14 @@ function BasicDevToolbar({ enabled = true, debug }) {
680
575
  scope,
681
576
  missingScopes: missingList,
682
577
  dbMode,
683
- dbStatus,
684
- indexedDbName: dbMode === "sync" ? INDEXED_DB_NAME : null,
578
+ syncStatus,
579
+ pendingOps: sync.pendingCount,
580
+ indexedDbName,
581
+ activeUser: activeUser ? { id: activeUser.id, kind: activeUser.kind, did: activeUser.did } : null,
582
+ userCount: users.length,
685
583
  schema: devInfo
686
584
  };
687
- }, [isReady, isSignedIn, did, user, scope, dbMode, dbStatus, devInfo, missingList]);
585
+ }, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, activeUser, users.length, devInfo, missingList]);
688
586
  const handleCopy = useCallback(async () => {
689
587
  try {
690
588
  await navigator.clipboard.writeText(JSON.stringify(debugPayload, null, 2));
@@ -767,7 +665,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
767
665
  minWidth: 300,
768
666
  maxWidth: "min(560px, calc(100vw - 24px))"
769
667
  };
770
- const syncStatusText = dbStatusLabel(dbStatus);
668
+ const syncStatusText = dbMode === "rest" ? "REST mode" : `${syncStatusLabel(syncStatus)}${sync.pendingCount > 0 ? ` (${sync.pendingCount} pending)` : ""}`;
771
669
  return /* @__PURE__ */ jsxs("div", { style: shell, children: [
772
670
  open && /* @__PURE__ */ jsxs("div", { style: panel, children: [
773
671
  /* @__PURE__ */ jsxs("div", { style: { marginBottom: 12 }, children: [
@@ -822,6 +720,17 @@ function BasicDevToolbar({ enabled = true, debug }) {
822
720
  children: user ? displayUserLine(user) : "\u2014"
823
721
  }
824
722
  ),
723
+ /* @__PURE__ */ jsx(
724
+ CopyableRow,
725
+ {
726
+ rowKey: "activeProfile",
727
+ label: "Profile",
728
+ copyText: activeUser?.id ?? "",
729
+ copiedKey: rowCopied,
730
+ onCopied: onRowCopied,
731
+ children: activeUserLabel
732
+ }
733
+ ),
825
734
  /* @__PURE__ */ jsx(
826
735
  CopyableRow,
827
736
  {
@@ -862,10 +771,10 @@ function BasicDevToolbar({ enabled = true, debug }) {
862
771
  {
863
772
  rowKey: "indexedDb",
864
773
  label: "IndexedDB",
865
- copyText: dbMode === "sync" ? INDEXED_DB_NAME : "",
774
+ copyText: indexedDbName ?? "",
866
775
  copiedKey: rowCopied,
867
776
  onCopied: onRowCopied,
868
- children: dbMode === "sync" ? INDEXED_DB_NAME : "\u2014"
777
+ children: indexedDbName ?? "\u2014"
869
778
  }
870
779
  ),
871
780
  /* @__PURE__ */ jsx(
@@ -1045,599 +954,106 @@ function BasicDevToolbar({ enabled = true, debug }) {
1045
954
  )
1046
955
  ] });
1047
956
  }
1048
- var INDEXED_DB_NAME, PANEL_PAD_X;
957
+ var PANEL_PAD_X;
1049
958
  var init_BasicDevToolbar = __esm({
1050
959
  "src/dev/BasicDevToolbar.tsx"() {
1051
960
  "use strict";
1052
961
  "use client";
1053
- init_context();
962
+ init_hooks();
1054
963
  init_package();
1055
964
  init_network();
1056
- INDEXED_DB_NAME = "basicdb";
1057
965
  PANEL_PAD_X = 12;
1058
966
  }
1059
967
  });
1060
968
 
1061
- // src/AuthContext.tsx
1062
- import {
1063
- useCallback as useCallback2,
1064
- useEffect,
1065
- useRef,
1066
- useState as useState2,
1067
- Suspense,
1068
- lazy
1069
- } from "react";
969
+ // src/react/BasicProvider.tsx
970
+ init_context();
971
+ import { Suspense, lazy, useEffect as useEffect2, useRef, useSyncExternalStore as useSyncExternalStore2 } from "react";
972
+
973
+ // src/core/auth/AuthManager.ts
974
+ import { jwtDecode } from "jwt-decode";
1070
975
 
1071
- // src/sync/index.ts
1072
- init_config();
1073
- init_tokenRegistry();
1074
- import { v7 as uuidv7 } from "uuid";
1075
- import { Dexie as Dexie2 } from "dexie";
1076
- import { validateData } from "@basictech/schema";
1077
- var dexieExtensionsLoaded = false;
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);
976
+ // src/utils/storage.ts
977
+ var LocalStorageAdapter = class {
978
+ async get(key) {
979
+ return localStorage.getItem(key);
1159
980
  }
1160
- debugeroo() {
1161
- return this.syncable;
981
+ async set(key, value) {
982
+ localStorage.setItem(key, value);
1162
983
  }
1163
- collection(name) {
1164
- if (this.basic_schema?.tables && !this.basic_schema.tables[name]) {
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
- };
984
+ async remove(key) {
985
+ localStorage.removeItem(key);
1267
986
  }
1268
987
  };
1269
-
1270
- // src/core/db/types.ts
1271
- var RemoteDBError = class extends Error {
1272
- status;
1273
- response;
1274
- constructor(message, status, response) {
1275
- super(message);
1276
- this.name = "RemoteDBError";
1277
- this.status = status;
1278
- this.response = response;
1279
- }
988
+ var STORAGE_KEYS = {
989
+ REFRESH_TOKEN: "basic_refresh_token",
990
+ USER_INFO: "basic_user_info",
991
+ AUTH_STATE: "basic_auth_state",
992
+ REDIRECT_URI: "basic_redirect_uri",
993
+ SERVER_URL: "basic_server_url",
994
+ PDS_ENDPOINTS: "basic_pds_endpoints",
995
+ LAST_CONNECT_REPORT: "basic_last_connect_report",
996
+ DEBUG: "basic_debug",
997
+ CODE_VERIFIER: "basic_code_verifier"
1280
998
  };
1281
999
 
1282
- // src/core/db/RemoteCollection.ts
1283
- import { validateData as validateData2 } from "@basictech/schema";
1284
- var NotAuthenticatedError = class extends Error {
1285
- constructor(message = "Not authenticated") {
1286
- super(message);
1287
- this.name = "NotAuthenticatedError";
1288
- }
1289
- };
1290
- var RemoteCollection = class {
1291
- tableName;
1292
- config;
1293
- constructor(tableName, config) {
1294
- this.tableName = tableName;
1295
- this.config = config;
1000
+ // src/utils/normalizeClientId.ts
1001
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1002
+ function normalizeClientId(projectId, adminHostname = "api.basic.tech") {
1003
+ if (!projectId) return projectId;
1004
+ if (projectId === "self") return projectId;
1005
+ if (projectId.startsWith("did:")) return projectId;
1006
+ if (UUID_RE.test(projectId)) {
1007
+ const hex = projectId.replace(/-/g, "").toLowerCase();
1008
+ return `did:web:${adminHostname}:projects:${hex}`;
1296
1009
  }
1297
- log(...args) {
1298
- if (this.config.debug) {
1299
- console.log("[RemoteDB]", ...args);
1300
- }
1010
+ return projectId;
1011
+ }
1012
+
1013
+ // src/utils/resolveDid.ts
1014
+ function resolveDidWebUrl(did) {
1015
+ if (!did.startsWith("did:web:")) return null;
1016
+ const rest = did.slice(8);
1017
+ if (!rest) return null;
1018
+ const parts = rest.split(":");
1019
+ const hostname = parts[0].replace(/%3A/gi, ":");
1020
+ if (parts.length === 1) {
1021
+ return `https://${hostname}/.well-known/did.json`;
1301
1022
  }
1302
- /**
1303
- * Check if an error is a "not authenticated" error
1304
- */
1305
- isNotAuthenticatedError(error) {
1306
- if (error instanceof Error) {
1307
- const message = error.message.toLowerCase();
1308
- return message.includes("no token") || message.includes("not authenticated") || message.includes("please sign in");
1309
- }
1310
- return false;
1023
+ const pathParts = parts.slice(1).map((p) => decodeURIComponent(p));
1024
+ return `https://${hostname}/${pathParts.join("/")}/did.json`;
1025
+ }
1026
+ async function resolveFromDocument(did, didDocument) {
1027
+ const services = didDocument.service;
1028
+ const pdsService = services?.find(
1029
+ (s) => s.id === "#basic_pds" || s.id === `${did}#basic_pds`
1030
+ );
1031
+ if (!pdsService) {
1032
+ throw new Error(`DID document has no #basic_pds service entry`);
1311
1033
  }
1312
- /**
1313
- * Helper to make authenticated API requests
1314
- * Automatically retries once on 401 (token expired) by refreshing the token
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;
1034
+ const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, "");
1035
+ const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`);
1036
+ if (!oauthRes.ok) {
1037
+ throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`);
1365
1038
  }
1366
- /**
1367
- * Validate data against schema if available
1368
- */
1369
- validateData(data, checkRequired = true) {
1370
- if (this.config.schema) {
1371
- const result = validateData2(this.config.schema, this.tableName, data, checkRequired);
1372
- if (!result.valid) {
1373
- throw new Error(result.message || "Data validation failed");
1374
- }
1375
- }
1039
+ const oauth = await oauthRes.json();
1040
+ return {
1041
+ did,
1042
+ didDocument,
1043
+ pdsUrl,
1044
+ authorization_endpoint: oauth.authorization_endpoint,
1045
+ token_endpoint: oauth.token_endpoint,
1046
+ userinfo_endpoint: oauth.userinfo_endpoint
1047
+ };
1048
+ }
1049
+ async function resolveDid(did) {
1050
+ const url = resolveDidWebUrl(did);
1051
+ if (!url) {
1052
+ throw new Error(`Unsupported DID method: ${did}`);
1376
1053
  }
1377
- /**
1378
- * Get the base path for this collection
1379
- */
1380
- get basePath() {
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}`);
1054
+ const didRes = await fetch(url);
1055
+ if (!didRes.ok) {
1056
+ throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`);
1641
1057
  }
1642
1058
  const didDocument = await didRes.json();
1643
1059
  return resolveFromDocument(did, didDocument);
@@ -1733,11 +1149,15 @@ var AuthManager = class {
1733
1149
  this.requestedScopes = config.scopes;
1734
1150
  this.initCrossTabSync();
1735
1151
  }
1152
+ get instanceKey() {
1153
+ return this.config.instanceKey ?? "";
1154
+ }
1736
1155
  initCrossTabSync() {
1737
1156
  if (typeof BroadcastChannel === "undefined") return;
1738
1157
  try {
1739
1158
  this.channel = new BroadcastChannel("basic-auth");
1740
1159
  this.channel.onmessage = (event) => {
1160
+ if ((event.data?.userKey ?? "") !== this.instanceKey) return;
1741
1161
  if (event.data?.type === "token_refreshed") {
1742
1162
  log("Received token refresh from another tab");
1743
1163
  void this.handleExternalTokenRefresh(event.data);
@@ -1750,9 +1170,6 @@ var AuthManager = class {
1750
1170
  log("Received sign-out from another tab");
1751
1171
  this.resetAuthState("signed_out");
1752
1172
  this.notify();
1753
- if (typeof window !== "undefined") {
1754
- window.location.reload();
1755
- }
1756
1173
  }
1757
1174
  if (event.data?.type === "session_invalidated") {
1758
1175
  log("Received session invalidation from another tab");
@@ -1768,19 +1185,32 @@ var AuthManager = class {
1768
1185
  broadcastTokenRefresh() {
1769
1186
  this.channel?.postMessage({
1770
1187
  type: "token_refreshed",
1188
+ userKey: this.instanceKey,
1771
1189
  accessToken: this.token?.access_token,
1772
1190
  did: this.did,
1773
1191
  tokenScope: this.tokenScope
1774
1192
  });
1775
1193
  }
1776
1194
  broadcastSignIn() {
1777
- this.channel?.postMessage({ type: "signed_in" });
1195
+ this.channel?.postMessage({ type: "signed_in", userKey: this.instanceKey });
1778
1196
  }
1779
1197
  broadcastSignOut() {
1780
- this.channel?.postMessage({ type: "signed_out" });
1198
+ this.channel?.postMessage({ type: "signed_out", userKey: this.instanceKey });
1781
1199
  }
1782
1200
  broadcastSessionInvalidated(code) {
1783
- this.channel?.postMessage({ type: "session_invalidated", code });
1201
+ this.channel?.postMessage({
1202
+ type: "session_invalidated",
1203
+ userKey: this.instanceKey,
1204
+ code
1205
+ });
1206
+ }
1207
+ /** Release resources (cross-tab channel). Used when switching users. */
1208
+ destroy() {
1209
+ try {
1210
+ this.channel?.close();
1211
+ } catch {
1212
+ }
1213
+ this.channel = null;
1784
1214
  }
1785
1215
  // ------------------------------------------------------------------
1786
1216
  // Public API
@@ -2040,11 +1470,13 @@ var AuthManager = class {
2040
1470
  }
2041
1471
  }
2042
1472
  /**
2043
- * Clear auth state and storage. Does NOT handle sync/DB cleanup —
2044
- * the UI layer (BasicProvider) wraps this to add sync teardown.
1473
+ * Sign out: revoke the session server-side (`POST /auth/logout`, best
1474
+ * effort), then clear auth state and storage. Does NOT handle sync/DB
1475
+ * cleanup — the client layer wraps this to add sync teardown.
2045
1476
  */
2046
1477
  async signOut() {
2047
1478
  log("signing out!");
1479
+ await this.revokeSessionOnServer();
2048
1480
  this.resetAuthState("signed_out");
2049
1481
  await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
2050
1482
  await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
@@ -2052,6 +1484,30 @@ var AuthManager = class {
2052
1484
  this.broadcastSignOut();
2053
1485
  this.notify();
2054
1486
  }
1487
+ /**
1488
+ * Best-effort server-side revocation of the current device/session and
1489
+ * its refresh chain (Step 2 auth: logout is finally server-side).
1490
+ * Never blocks or fails the local sign-out.
1491
+ */
1492
+ async revokeSessionOnServer() {
1493
+ try {
1494
+ let accessToken = null;
1495
+ try {
1496
+ accessToken = await this.getToken();
1497
+ } catch {
1498
+ accessToken = this.token?.access_token ?? null;
1499
+ }
1500
+ if (!accessToken) return;
1501
+ const endpoints = await this.getActivePdsEndpoints();
1502
+ await fetch(`${endpoints.pds_url}/auth/logout`, {
1503
+ method: "POST",
1504
+ headers: { Authorization: `Bearer ${accessToken}` }
1505
+ });
1506
+ log("Server-side logout succeeded");
1507
+ } catch (error) {
1508
+ log("Server-side logout failed (non-blocking):", error);
1509
+ }
1510
+ }
2055
1511
  async reconcileSession(reason = "manual", options) {
2056
1512
  if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
2057
1513
  return;
@@ -2375,11 +1831,11 @@ var AuthManager = class {
2375
1831
  ...isRefreshToken ? { refresh_token: "[REDACTED]" } : { code: "[REDACTED]" },
2376
1832
  ...requestBody.code_verifier ? { code_verifier: "[REDACTED]" } : {}
2377
1833
  });
2378
- const token = await fetch(endpoints.token_endpoint, {
1834
+ const response = await fetch(endpoints.token_endpoint, {
2379
1835
  method: "POST",
2380
1836
  headers: { "Content-Type": "application/json" },
2381
1837
  body: JSON.stringify(requestBody)
2382
- }).then((response) => response.json()).catch((error) => {
1838
+ }).catch((error) => {
2383
1839
  log("Network error fetching token:", error);
2384
1840
  if (!this.isOnline) {
2385
1841
  this.pendingRefresh = true;
@@ -2389,6 +1845,18 @@ var AuthManager = class {
2389
1845
  }
2390
1846
  throw new Error("Network error during token refresh");
2391
1847
  });
1848
+ if (response.status === 429) {
1849
+ log("Token endpoint rate limited (429) - will retry later");
1850
+ this.pendingRefresh = true;
1851
+ throw new Error(
1852
+ "Token endpoint rate limited - refresh will be retried"
1853
+ );
1854
+ }
1855
+ const token = await response.json().catch(() => {
1856
+ throw new Error(
1857
+ `Token endpoint returned invalid JSON (status ${response.status})`
1858
+ );
1859
+ });
2392
1860
  if (token.access_token) {
2393
1861
  try {
2394
1862
  const decoded = jwtDecode(token.access_token);
@@ -2498,7 +1966,8 @@ var AuthManager = class {
2498
1966
  isNetworkError(error) {
2499
1967
  if (error instanceof TypeError) return true;
2500
1968
  if (error instanceof Error) {
2501
- return error.message.includes("offline") || error.message.includes("Network");
1969
+ return error.message.includes("offline") || error.message.includes("Network") || // 429 on the token endpoint: transient, keep the session alive
1970
+ error.message.includes("rate limited");
2502
1971
  }
2503
1972
  return false;
2504
1973
  }
@@ -2713,135 +2182,1713 @@ var AuthManager = class {
2713
2182
  }
2714
2183
  };
2715
2184
 
2716
- // src/AuthContext.tsx
2717
- init_config();
2718
- init_package();
2719
-
2720
- // src/updater/versionUpdater.ts
2721
- init_config();
2722
- var VersionUpdater = class {
2723
- storage;
2724
- currentVersion;
2725
- migrations;
2726
- versionKey = "basic_app_version";
2727
- constructor(storage, currentVersion, migrations = []) {
2728
- this.storage = storage;
2729
- this.currentVersion = currentVersion;
2730
- this.migrations = migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
2185
+ // src/core/http/RestClient.ts
2186
+ var RestError = class extends Error {
2187
+ status;
2188
+ code;
2189
+ response;
2190
+ constructor(message, status, code, response) {
2191
+ super(message);
2192
+ this.name = "RestError";
2193
+ this.status = status;
2194
+ this.code = code;
2195
+ this.response = response;
2196
+ }
2197
+ };
2198
+ var NotAuthenticatedError = class extends Error {
2199
+ constructor(message = "Not authenticated") {
2200
+ super(message);
2201
+ this.name = "NotAuthenticatedError";
2731
2202
  }
2203
+ };
2204
+ var RestClient = class {
2205
+ opts;
2206
+ constructor(opts) {
2207
+ this.opts = { ...opts, baseUrl: opts.baseUrl.replace(/\/$/, "") };
2208
+ }
2209
+ get projectId() {
2210
+ return this.opts.projectId;
2211
+ }
2212
+ // -------------------------------------------------------------------
2213
+ // Sync surface
2214
+ // -------------------------------------------------------------------
2215
+ /** `GET /account/:project_id/db` — tables, enforced schema version, channel head. */
2216
+ async getDbInfo() {
2217
+ const res = await this.request("GET", `${this.dbPath}`);
2218
+ return res.data;
2219
+ }
2220
+ /** Bootstrap snapshot (SPEC §5). `share` bootstraps a mount; `table` filters. */
2221
+ async getSnapshot(options) {
2222
+ const query = new URLSearchParams();
2223
+ if (options?.share) query.set("share", options.share);
2224
+ if (options?.table) query.set("table", options.table);
2225
+ const qs = query.toString();
2226
+ const res = await this.request(
2227
+ "GET",
2228
+ `${this.dbPath}/snapshot${qs ? `?${qs}` : ""}`
2229
+ );
2230
+ return res.data;
2231
+ }
2232
+ /** Pull ordered ops after a cursor — the non-WebSocket sync path. */
2233
+ async getChanges(options) {
2234
+ const query = new URLSearchParams({ cursor: String(options.cursor) });
2235
+ if (options.limit) query.set("limit", String(options.limit));
2236
+ if (options.share) query.set("share", options.share);
2237
+ if (options.table) query.set("table", options.table);
2238
+ const res = await this.request(
2239
+ "GET",
2240
+ `${this.dbPath}/changes?${query.toString()}`
2241
+ );
2242
+ return res.data;
2243
+ }
2244
+ // -------------------------------------------------------------------
2245
+ // Shares (multiplayer v1)
2246
+ // -------------------------------------------------------------------
2732
2247
  /**
2733
- * Check current stored version and run migrations if needed
2734
- * Only compares major.minor versions, ignoring beta/prerelease parts
2735
- * Example: "0.7.0-beta.1" and "0.7.0" are treated as the same version
2248
+ * Shares granted by and received by the caller. App tokens see only
2249
+ * shares involving their own app (the ones they can mount).
2736
2250
  */
2737
- async checkAndUpdate() {
2738
- const storedVersion = await this.getStoredVersion();
2739
- if (!storedVersion) {
2740
- await this.setStoredVersion(this.currentVersion);
2741
- return { updated: false, toVersion: this.currentVersion };
2742
- }
2743
- if (storedVersion === this.currentVersion) {
2744
- return { updated: false, toVersion: this.currentVersion };
2745
- }
2746
- const migrationsToRun = this.getMigrationsToRun(storedVersion, this.currentVersion);
2747
- if (migrationsToRun.length === 0) {
2748
- await this.setStoredVersion(this.currentVersion);
2749
- return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
2251
+ async listShares() {
2252
+ const res = await this.request(
2253
+ "GET",
2254
+ "/account/shares"
2255
+ );
2256
+ return res.data;
2257
+ }
2258
+ // -------------------------------------------------------------------
2259
+ // CRUD on materialized state (REST-mode table API)
2260
+ // -------------------------------------------------------------------
2261
+ async list(table, query) {
2262
+ const qs = query ? `?${new URLSearchParams(query).toString()}` : "";
2263
+ const res = await this.request(
2264
+ "GET",
2265
+ `${this.dbPath}/${encodeURIComponent(table)}${qs}`
2266
+ );
2267
+ return res.data ?? [];
2268
+ }
2269
+ async getRecord(table, id) {
2270
+ try {
2271
+ const res = await this.request(
2272
+ "GET",
2273
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`
2274
+ );
2275
+ return res.data ?? null;
2276
+ } catch (err) {
2277
+ if (err instanceof RestError && err.status === 404) return null;
2278
+ throw err;
2750
2279
  }
2751
- for (const migration of migrationsToRun) {
2280
+ }
2281
+ /** `POST` — server mints the record id. */
2282
+ async createRecord(table, value) {
2283
+ const res = await this.request(
2284
+ "POST",
2285
+ `${this.dbPath}/${encodeURIComponent(table)}`,
2286
+ { value }
2287
+ );
2288
+ return res.data;
2289
+ }
2290
+ /** `PUT` — full replace. REST semantics: 404 for missing records. */
2291
+ async putRecord(table, id, value) {
2292
+ try {
2293
+ const res = await this.request(
2294
+ "PUT",
2295
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`,
2296
+ { value }
2297
+ );
2298
+ return res.data ?? null;
2299
+ } catch (err) {
2300
+ if (err instanceof RestError && err.status === 404) return null;
2301
+ throw err;
2302
+ }
2303
+ }
2304
+ /** `PATCH` — partial merge. 404 → null. */
2305
+ async patchRecord(table, id, value) {
2306
+ try {
2307
+ const res = await this.request(
2308
+ "PATCH",
2309
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`,
2310
+ { value }
2311
+ );
2312
+ return res.data ?? null;
2313
+ } catch (err) {
2314
+ if (err instanceof RestError && err.status === 404) return null;
2315
+ throw err;
2316
+ }
2317
+ }
2318
+ /** `DELETE`. Returns false when the record did not exist. */
2319
+ async deleteRecord(table, id) {
2320
+ try {
2321
+ await this.request(
2322
+ "DELETE",
2323
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`
2324
+ );
2325
+ return true;
2326
+ } catch (err) {
2327
+ if (err instanceof RestError && err.status === 404) return false;
2328
+ throw err;
2329
+ }
2330
+ }
2331
+ // -------------------------------------------------------------------
2332
+ // Internals
2333
+ // -------------------------------------------------------------------
2334
+ get dbPath() {
2335
+ return `/account/${encodeURIComponent(this.opts.projectId)}/db`;
2336
+ }
2337
+ /** Authenticated request; retries once with a force-refreshed token on 401. */
2338
+ async request(method, path, body, isRetry = false) {
2339
+ let token;
2340
+ try {
2341
+ token = await this.opts.getToken(isRetry ? { forceRefresh: true } : void 0);
2342
+ } catch (err) {
2343
+ throw new NotAuthenticatedError(
2344
+ err instanceof Error ? err.message : "could not get access token"
2345
+ );
2346
+ }
2347
+ const url = `${this.opts.baseUrl}${path}`;
2348
+ this.opts.log?.("[rest]", method, url);
2349
+ const headers = { Authorization: `Bearer ${token}` };
2350
+ if (body !== void 0) headers["Content-Type"] = "application/json";
2351
+ const response = await fetch(url, {
2352
+ method,
2353
+ headers,
2354
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
2355
+ });
2356
+ const responseData = await response.json().catch(() => ({}));
2357
+ if (!response.ok) {
2358
+ if (response.status === 401 && !isRetry) {
2359
+ this.opts.log?.("[rest] 401 \u2014 refreshing token and retrying once");
2360
+ return this.request(method, path, body, true);
2361
+ }
2362
+ const code = typeof responseData.error === "string" ? responseData.error : void 0;
2363
+ const message = typeof responseData.message === "string" && responseData.message || code || `request failed: ${response.status}`;
2364
+ throw new RestError(message, response.status, code, responseData);
2365
+ }
2366
+ return responseData;
2367
+ }
2368
+ };
2369
+
2370
+ // src/core/sync/SyncEngine.ts
2371
+ import { validateData } from "@basictech/schema";
2372
+
2373
+ // src/core/sync/protocol.ts
2374
+ var PROTOCOL_VERSION = 1;
2375
+ var TERMINAL_OP_ERRORS = /* @__PURE__ */ new Set([
2376
+ "SCHEMA_VALIDATION_FAILED",
2377
+ "UNKNOWN_TABLE",
2378
+ "RECORD_NOT_FOUND",
2379
+ "PERMISSION_DENIED",
2380
+ "PAYLOAD_TOO_LARGE",
2381
+ "CHANNEL_FULL",
2382
+ "BAD_MESSAGE"
2383
+ ]);
2384
+ function isTerminalOpError(code, terminalFlag) {
2385
+ if (terminalFlag !== void 0) return terminalFlag;
2386
+ return code !== void 0 && TERMINAL_OP_ERRORS.has(code);
2387
+ }
2388
+ function isRebootstrapError(code) {
2389
+ return code === "SNAPSHOT_REQUIRED" || code === "RESET_REQUIRED";
2390
+ }
2391
+ function isRevocationError(code) {
2392
+ return code === "SHARE_REVOKED" || code === "CONNECTION_REVOKED";
2393
+ }
2394
+ function isAuthError(code) {
2395
+ return code === "UNAUTHORIZED" || code === "TOKEN_EXPIRED";
2396
+ }
2397
+ var DEFAULT_LIMITS = {
2398
+ max_ops_per_push: 500,
2399
+ max_op_bytes: 64 * 1024,
2400
+ replay_limit: 1e3
2401
+ };
2402
+ function mintOpId() {
2403
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
2404
+ return crypto.randomUUID();
2405
+ }
2406
+ return "op-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
2407
+ }
2408
+ function cloneData(value) {
2409
+ return value === void 0 ? value : JSON.parse(JSON.stringify(value));
2410
+ }
2411
+ function applyOpToData(existing, op) {
2412
+ switch (op.type) {
2413
+ case "put":
2414
+ return cloneData(op.data ?? {});
2415
+ case "patch":
2416
+ if (existing === void 0) return void 0;
2417
+ return { ...existing, ...cloneData(op.data ?? {}) };
2418
+ case "delete":
2419
+ return void 0;
2420
+ }
2421
+ }
2422
+
2423
+ // src/core/sync/SyncConnection.ts
2424
+ var INITIAL_RECONNECT_DELAY_MS = 500;
2425
+ var MAX_RECONNECT_DELAY_MS = 3e4;
2426
+ var SyncConnection = class {
2427
+ opts;
2428
+ WS;
2429
+ heartbeatMs;
2430
+ ws = null;
2431
+ _status = "idle";
2432
+ stopped = true;
2433
+ reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2434
+ timers = /* @__PURE__ */ new Set();
2435
+ heartbeatTimer = null;
2436
+ /** One forced-refresh reconnect attempt per auth rejection. */
2437
+ authRetryUsed = false;
2438
+ removeOnlineListener = null;
2439
+ constructor(opts) {
2440
+ this.opts = opts;
2441
+ this.WS = opts.WebSocketImpl ?? globalThis.WebSocket;
2442
+ this.heartbeatMs = opts.heartbeatMs ?? 3e4;
2443
+ }
2444
+ get status() {
2445
+ return this._status;
2446
+ }
2447
+ get isOnline() {
2448
+ return this._status === "online" && this.ws?.readyState === 1;
2449
+ }
2450
+ start() {
2451
+ if (!this.stopped && this.ws) return;
2452
+ this.stopped = false;
2453
+ this.authRetryUsed = false;
2454
+ this.listenForNetwork();
2455
+ this.open();
2456
+ }
2457
+ stop() {
2458
+ this.stopped = true;
2459
+ this.clearTimers();
2460
+ this.removeOnlineListener?.();
2461
+ this.removeOnlineListener = null;
2462
+ if (this.ws) {
2752
2463
  try {
2753
- log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`);
2754
- await migration.migrate(this.storage);
2755
- } catch (error) {
2756
- console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error);
2757
- throw new Error(`Migration failed: ${error}`);
2464
+ this.ws.close();
2465
+ } catch {
2466
+ }
2467
+ this.ws = null;
2468
+ }
2469
+ this.setStatus("stopped");
2470
+ }
2471
+ /** Send a message; returns false when the socket is not open. */
2472
+ send(msg) {
2473
+ if (this.ws?.readyState === 1) {
2474
+ this.ws.send(JSON.stringify(msg));
2475
+ return true;
2476
+ }
2477
+ return false;
2478
+ }
2479
+ /** Refresh auth on the live socket (no reconnect). */
2480
+ async sendToken() {
2481
+ if (!this.isOnline) return;
2482
+ try {
2483
+ const token = await this.opts.getToken();
2484
+ this.send({ type: "token", token });
2485
+ } catch {
2486
+ }
2487
+ }
2488
+ /**
2489
+ * The server rejected our token (`UNAUTHORIZED` / `TOKEN_EXPIRED` + close).
2490
+ * Retry once with a force-refreshed token; give up (status `auth_failed`)
2491
+ * when the refresh itself fails or a fresh token is rejected again.
2492
+ */
2493
+ handleAuthRejection() {
2494
+ if (this.stopped) return;
2495
+ if (this.authRetryUsed) {
2496
+ this.log("fresh token rejected \u2014 giving up until reauth");
2497
+ this.stopped = true;
2498
+ this.clearTimers();
2499
+ this.setStatus("auth_failed");
2500
+ return;
2501
+ }
2502
+ this.authRetryUsed = true;
2503
+ this.log("token rejected \u2014 force refreshing and reconnecting");
2504
+ void (async () => {
2505
+ try {
2506
+ await this.opts.getToken({ forceRefresh: true });
2507
+ this.scheduleReconnect(0);
2508
+ } catch (err) {
2509
+ this.log("token refresh failed after auth rejection:", err);
2510
+ this.stopped = true;
2511
+ this.setStatus("auth_failed");
2512
+ }
2513
+ })();
2514
+ }
2515
+ // -------------------------------------------------------------------
2516
+ // Internals
2517
+ // -------------------------------------------------------------------
2518
+ open() {
2519
+ if (this.stopped) return;
2520
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
2521
+ if (!this.WS) {
2522
+ this.log("no WebSocket implementation available");
2523
+ this.setStatus("offline");
2524
+ return;
2525
+ }
2526
+ this.setStatus("connecting");
2527
+ const ws = new this.WS(this.opts.wsUrl);
2528
+ this.ws = ws;
2529
+ ws.onopen = () => {
2530
+ void (async () => {
2531
+ try {
2532
+ const token = await this.opts.getToken();
2533
+ if (this.ws !== ws || ws.readyState !== 1) return;
2534
+ const hello = { type: "hello", version: PROTOCOL_VERSION, token };
2535
+ ws.send(JSON.stringify(hello));
2536
+ } catch (err) {
2537
+ this.log("could not get token for hello:", err);
2538
+ try {
2539
+ ws.close();
2540
+ } catch {
2541
+ }
2542
+ }
2543
+ })();
2544
+ };
2545
+ ws.onmessage = (event) => {
2546
+ let msg;
2547
+ try {
2548
+ const text = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
2549
+ msg = JSON.parse(text);
2550
+ } catch {
2551
+ return;
2758
2552
  }
2553
+ this.handleMessage(msg);
2554
+ };
2555
+ ws.onclose = () => {
2556
+ if (this.ws !== ws) return;
2557
+ this.ws = null;
2558
+ this.stopHeartbeat();
2559
+ if (this.stopped) return;
2560
+ this.setStatus("offline");
2561
+ this.scheduleReconnect();
2562
+ };
2563
+ ws.onerror = () => {
2564
+ };
2565
+ }
2566
+ handleMessage(msg) {
2567
+ if (msg.type === "welcome") {
2568
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2569
+ this.authRetryUsed = false;
2570
+ this.setStatus("online");
2571
+ this.startHeartbeat();
2572
+ this.opts.onWelcome(msg);
2573
+ return;
2574
+ }
2575
+ if (msg.type === "error" && !("sub" in msg && msg.sub) && isAuthError(msg.code)) {
2576
+ this.handleAuthRejection();
2577
+ this.opts.onMessage(msg);
2578
+ return;
2579
+ }
2580
+ this.opts.onMessage(msg);
2581
+ }
2582
+ startHeartbeat() {
2583
+ this.stopHeartbeat();
2584
+ const tick = () => {
2585
+ if (this.stopped) return;
2586
+ this.send({ type: "ping" });
2587
+ this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
2588
+ this.timers.add(this.heartbeatTimer);
2589
+ };
2590
+ this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
2591
+ this.timers.add(this.heartbeatTimer);
2592
+ }
2593
+ stopHeartbeat() {
2594
+ if (this.heartbeatTimer) {
2595
+ clearTimeout(this.heartbeatTimer);
2596
+ this.timers.delete(this.heartbeatTimer);
2597
+ this.heartbeatTimer = null;
2598
+ }
2599
+ }
2600
+ scheduleReconnect(delayOverride) {
2601
+ if (this.stopped) return;
2602
+ const delay = delayOverride ?? this.reconnectDelay;
2603
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
2604
+ this.log(`reconnecting in ${delay}ms`);
2605
+ const timer = setTimeout(() => {
2606
+ this.timers.delete(timer);
2607
+ this.open();
2608
+ }, delay);
2609
+ this.timers.add(timer);
2610
+ }
2611
+ listenForNetwork() {
2612
+ if (this.removeOnlineListener || typeof window === "undefined") return;
2613
+ const handleOnline = () => {
2614
+ if (this.stopped) return;
2615
+ this.log("network online \u2014 reconnecting immediately");
2616
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2617
+ this.open();
2618
+ };
2619
+ window.addEventListener("online", handleOnline);
2620
+ this.removeOnlineListener = () => window.removeEventListener("online", handleOnline);
2621
+ }
2622
+ clearTimers() {
2623
+ for (const t of this.timers) clearTimeout(t);
2624
+ this.timers.clear();
2625
+ this.heartbeatTimer = null;
2626
+ }
2627
+ setStatus(status) {
2628
+ if (this._status === status) return;
2629
+ this._status = status;
2630
+ this.opts.onStatus(status);
2631
+ }
2632
+ log(...args) {
2633
+ this.opts.log?.("[sync-connection]", ...args);
2634
+ }
2635
+ };
2636
+
2637
+ // src/core/sync/SyncStore.ts
2638
+ import Dexie from "dexie";
2639
+ var META_CURSOR = "cursor";
2640
+ var META_CHANNEL = "channel";
2641
+ var META_OWNER = "owner_did";
2642
+ var SyncStore = class {
2643
+ db;
2644
+ name;
2645
+ tableNames;
2646
+ constructor(name, schema) {
2647
+ this.name = name;
2648
+ this.tableNames = Object.keys(schema.tables);
2649
+ this.db = new Dexie(name);
2650
+ const stores = {
2651
+ _server: "[table+record_id], table",
2652
+ _pending: "++idx, op_id",
2653
+ _rejected: "++idx, op_id",
2654
+ _meta: "key"
2655
+ };
2656
+ for (const [tableName, table] of Object.entries(schema.tables)) {
2657
+ const indexed = Object.entries(table.fields).filter(([, f]) => f.indexed).map(([fieldName]) => `,${fieldName}`).join("");
2658
+ stores[tableName] = "id" + indexed;
2659
+ }
2660
+ this.db.version(Math.max(schema.version ?? 1, 1)).stores(stores);
2661
+ }
2662
+ /** The Dexie view table for an app table (what liveQuery reads). */
2663
+ view(table) {
2664
+ return this.db.table(table);
2665
+ }
2666
+ hasTable(table) {
2667
+ return this.tableNames.includes(table);
2668
+ }
2669
+ get tables() {
2670
+ return [...this.tableNames];
2671
+ }
2672
+ get server() {
2673
+ return this.db.table("_server");
2674
+ }
2675
+ get pending() {
2676
+ return this.db.table("_pending");
2677
+ }
2678
+ get rejected() {
2679
+ return this.db.table("_rejected");
2680
+ }
2681
+ get meta() {
2682
+ return this.db.table("_meta");
2683
+ }
2684
+ get allStores() {
2685
+ return ["_server", "_pending", "_rejected", "_meta", ...this.tableNames];
2686
+ }
2687
+ // -------------------------------------------------------------------
2688
+ // Meta
2689
+ // -------------------------------------------------------------------
2690
+ async getCursor() {
2691
+ const row = await this.meta.get(META_CURSOR);
2692
+ return typeof row?.value === "number" ? row.value : null;
2693
+ }
2694
+ async getChannel() {
2695
+ const row = await this.meta.get(META_CHANNEL);
2696
+ return typeof row?.value === "string" ? row.value : null;
2697
+ }
2698
+ /**
2699
+ * The account DID this keyspace's confirmed data belongs to. Absent for
2700
+ * anonymous-era data (which may be merged into whichever account signs in).
2701
+ */
2702
+ async getOwner() {
2703
+ const row = await this.meta.get(META_OWNER);
2704
+ return typeof row?.value === "string" ? row.value : null;
2705
+ }
2706
+ async setOwner(did) {
2707
+ await this.meta.put({ key: META_OWNER, value: did });
2708
+ }
2709
+ /**
2710
+ * Clear everything (views, server state, pending, rejected, meta) without
2711
+ * deleting the database — used when the keyspace changes owners.
2712
+ */
2713
+ async wipeAll() {
2714
+ await this.db.transaction("rw", this.allStores, async () => {
2715
+ await this.server.clear();
2716
+ await this.pending.clear();
2717
+ await this.rejected.clear();
2718
+ await this.meta.clear();
2719
+ for (const tableName of this.tableNames) {
2720
+ await this.view(tableName).clear();
2721
+ }
2722
+ });
2723
+ }
2724
+ // -------------------------------------------------------------------
2725
+ // Pending / rejected
2726
+ // -------------------------------------------------------------------
2727
+ /** All pending ops in creation order (used to warm the in-memory queue). */
2728
+ async loadPending() {
2729
+ return this.pending.orderBy("idx").toArray();
2730
+ }
2731
+ async listRejected() {
2732
+ return this.rejected.orderBy("idx").toArray();
2733
+ }
2734
+ async clearRejected() {
2735
+ await this.rejected.clear();
2736
+ }
2737
+ /** Record a server ack for a pending op (echo not yet seen). */
2738
+ async markAcked(opId, seq) {
2739
+ await this.pending.where("op_id").equals(opId).modify({ acked_seq: seq });
2740
+ }
2741
+ // -------------------------------------------------------------------
2742
+ // Writes
2743
+ // -------------------------------------------------------------------
2744
+ /**
2745
+ * Enqueue a local op and apply it optimistically to the view.
2746
+ * Returns the resulting view record (null when the op deletes it).
2747
+ */
2748
+ async addPending(op) {
2749
+ return this.db.transaction("rw", this.allStores, async () => {
2750
+ await this.pending.add({ op_id: op.op_id, op: cloneData(op) });
2751
+ return this.recomputeViewRecord(op.table, op.record_id);
2752
+ });
2753
+ }
2754
+ /**
2755
+ * Commit a batch of incoming server ops (already filtered/deduped by the
2756
+ * engine): update `_server`, drop confirmed pending ops, advance the
2757
+ * cursor, and rebase every affected view record — in one transaction.
2758
+ */
2759
+ async commitIncoming(params) {
2760
+ const { applyOps, confirmedOpIds, cursor } = params;
2761
+ await this.db.transaction("rw", this.allStores, async () => {
2762
+ const affected = /* @__PURE__ */ new Set();
2763
+ for (const op of applyOps) affected.add(`${op.table}\0${op.record_id}`);
2764
+ for (const op of applyOps) {
2765
+ await this.applyToServer(op);
2766
+ }
2767
+ if (confirmedOpIds.length > 0) {
2768
+ const confirmedRows = await this.pending.where("op_id").anyOf(confirmedOpIds).toArray();
2769
+ for (const row of confirmedRows) affected.add(`${row.op.table}\0${row.op.record_id}`);
2770
+ await this.pending.where("op_id").anyOf(confirmedOpIds).delete();
2771
+ }
2772
+ await this.meta.put({ key: META_CURSOR, value: cursor });
2773
+ for (const key of affected) {
2774
+ const [table, recordId] = key.split("\0");
2775
+ await this.recomputeViewRecord(table, recordId);
2776
+ }
2777
+ });
2778
+ }
2779
+ /** Persist a cursor advance with no ops (empty `ops` message / pushed cursor). */
2780
+ async setCursor(cursor) {
2781
+ await this.meta.put({ key: META_CURSOR, value: cursor });
2782
+ }
2783
+ /**
2784
+ * Terminal rejection: remove from pending, park in the rejected store,
2785
+ * roll the view record back to server state + remaining pending ops.
2786
+ */
2787
+ async rejectPending(opId, error, message) {
2788
+ return this.db.transaction("rw", this.allStores, async () => {
2789
+ const row = await this.pending.where("op_id").equals(opId).first();
2790
+ if (!row) return null;
2791
+ await this.pending.where("op_id").equals(opId).delete();
2792
+ const rejectedRow = {
2793
+ op_id: opId,
2794
+ op: row.op,
2795
+ error,
2796
+ message,
2797
+ rejected_at: Date.now()
2798
+ };
2799
+ await this.rejected.add(rejectedRow);
2800
+ await this.recomputeViewRecord(row.op.table, row.op.record_id);
2801
+ return rejectedRow;
2802
+ });
2803
+ }
2804
+ // -------------------------------------------------------------------
2805
+ // Bootstrap
2806
+ // -------------------------------------------------------------------
2807
+ /**
2808
+ * Replace all server state from a snapshot (cold start or
2809
+ * SNAPSHOT_REQUIRED/RESET_REQUIRED recovery). Pending ops survive and are
2810
+ * re-applied on top of the fresh state.
2811
+ */
2812
+ async replaceFromSnapshot(params) {
2813
+ const { channel, records, cursor } = params;
2814
+ await this.db.transaction("rw", this.allStores, async () => {
2815
+ await this.server.clear();
2816
+ for (const tableName of this.tableNames) {
2817
+ await this.view(tableName).clear();
2818
+ }
2819
+ for (const [tableName, tableRecords] of Object.entries(records)) {
2820
+ if (!this.hasTable(tableName)) continue;
2821
+ const serverRows = [];
2822
+ const viewRows = [];
2823
+ for (const [recordId, data] of Object.entries(tableRecords)) {
2824
+ serverRows.push({ table: tableName, record_id: recordId, data: data ?? {} });
2825
+ viewRows.push({ id: recordId, ...data ?? {} });
2826
+ }
2827
+ await this.server.bulkPut(serverRows);
2828
+ await this.view(tableName).bulkPut(viewRows);
2829
+ }
2830
+ const pendingRows = await this.pending.orderBy("idx").toArray();
2831
+ const affected = /* @__PURE__ */ new Set();
2832
+ for (const row of pendingRows) affected.add(`${row.op.table}\0${row.op.record_id}`);
2833
+ for (const key of affected) {
2834
+ const [table, recordId] = key.split("\0");
2835
+ if (this.hasTable(table)) await this.recomputeViewRecord(table, recordId);
2836
+ }
2837
+ await this.meta.put({ key: META_CURSOR, value: cursor });
2838
+ await this.meta.put({ key: META_CHANNEL, value: channel });
2839
+ });
2840
+ }
2841
+ // -------------------------------------------------------------------
2842
+ // Reads
2843
+ // -------------------------------------------------------------------
2844
+ async getViewRecord(table, id) {
2845
+ const record = await this.view(table).get(id);
2846
+ return record ?? null;
2847
+ }
2848
+ async getViewRecords(table) {
2849
+ return this.view(table).toArray();
2850
+ }
2851
+ // -------------------------------------------------------------------
2852
+ // Lifecycle
2853
+ // -------------------------------------------------------------------
2854
+ close() {
2855
+ this.db.close();
2856
+ }
2857
+ /** Delete the underlying IndexedDB database (sign-out / revoked mount). */
2858
+ async destroy() {
2859
+ this.db.close();
2860
+ await Dexie.delete(this.name);
2861
+ }
2862
+ // -------------------------------------------------------------------
2863
+ // Internals
2864
+ // -------------------------------------------------------------------
2865
+ async applyToServer(op) {
2866
+ if (!this.hasTable(op.table)) return;
2867
+ const existing = await this.server.get([op.table, op.record_id]);
2868
+ const next = applyOpToData(existing?.data, op);
2869
+ if (next === void 0) {
2870
+ await this.server.delete([op.table, op.record_id]);
2871
+ } else {
2872
+ await this.server.put({ table: op.table, record_id: op.record_id, data: next });
2873
+ }
2874
+ }
2875
+ /**
2876
+ * Rebase one record: view = server data + pending ops for that record in
2877
+ * creation order. Must run inside a transaction covering all stores.
2878
+ */
2879
+ async recomputeViewRecord(table, recordId) {
2880
+ if (!this.hasTable(table)) return null;
2881
+ const serverRow = await this.server.get([table, recordId]);
2882
+ let data = serverRow ? cloneData(serverRow.data) : void 0;
2883
+ const pendingRows = await this.pending.orderBy("idx").toArray();
2884
+ for (const row of pendingRows) {
2885
+ if (row.op.table === table && row.op.record_id === recordId) {
2886
+ data = applyOpToData(data, row.op);
2887
+ }
2888
+ }
2889
+ if (data === void 0) {
2890
+ await this.view(table).delete(recordId);
2891
+ return null;
2892
+ }
2893
+ const viewRecord = { id: recordId, ...data };
2894
+ await this.view(table).put(viewRecord);
2895
+ return viewRecord;
2896
+ }
2897
+ };
2898
+
2899
+ // src/core/sync/SyncEngine.ts
2900
+ var OWN_SUB = "own";
2901
+ function shareSubKey(shareId) {
2902
+ return `share:${shareId}`;
2903
+ }
2904
+ var BoundedSet = class {
2905
+ constructor(cap = 2048) {
2906
+ this.cap = cap;
2907
+ }
2908
+ set = /* @__PURE__ */ new Set();
2909
+ order = [];
2910
+ has(value) {
2911
+ return this.set.has(value);
2912
+ }
2913
+ add(value) {
2914
+ if (this.set.has(value)) return;
2915
+ this.set.add(value);
2916
+ this.order.push(value);
2917
+ if (this.order.length > this.cap) {
2918
+ const evicted = this.order.shift();
2919
+ if (evicted !== void 0) this.set.delete(evicted);
2920
+ }
2921
+ }
2922
+ clear() {
2923
+ this.set.clear();
2924
+ this.order = [];
2925
+ }
2926
+ };
2927
+ var RETRY_FLUSH_DELAY_MS = 1200;
2928
+ var SyncEngine = class {
2929
+ projectId;
2930
+ schema;
2931
+ opts;
2932
+ connection;
2933
+ subs = /* @__PURE__ */ new Map();
2934
+ limits = { ...DEFAULT_LIMITS };
2935
+ actor = null;
2936
+ /** Own-sub store is open (local reads/writes work). */
2937
+ storesOpen = false;
2938
+ /** A live connection is wanted (vs. local-only / paused). */
2939
+ connectIntended = false;
2940
+ openingLocal = null;
2941
+ revokedInfo = null;
2942
+ connectionStatus = "idle";
2943
+ _status = "idle";
2944
+ listeners = /* @__PURE__ */ new Map();
2945
+ timers = /* @__PURE__ */ new Set();
2946
+ constructor(opts) {
2947
+ this.opts = opts;
2948
+ this.projectId = opts.projectId;
2949
+ this.schema = opts.schema;
2950
+ this.connection = new SyncConnection({
2951
+ wsUrl: opts.wsUrl,
2952
+ getToken: opts.getToken,
2953
+ WebSocketImpl: opts.WebSocketImpl,
2954
+ heartbeatMs: opts.heartbeatMs,
2955
+ onWelcome: (msg) => this.handleWelcome(msg),
2956
+ onMessage: (msg) => this.handleMessage(msg),
2957
+ onStatus: (status) => this.handleConnectionStatus(status),
2958
+ log: opts.log
2959
+ });
2960
+ }
2961
+ // -------------------------------------------------------------------
2962
+ // Events
2963
+ // -------------------------------------------------------------------
2964
+ on(event, fn) {
2965
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
2966
+ const set = this.listeners.get(event);
2967
+ set.add(fn);
2968
+ return () => set.delete(fn);
2969
+ }
2970
+ emit(event, data) {
2971
+ const set = this.listeners.get(event);
2972
+ if (!set) return;
2973
+ for (const fn of set) {
2974
+ try {
2975
+ fn(data);
2976
+ } catch (err) {
2977
+ this.log("listener error:", err);
2978
+ }
2979
+ }
2980
+ }
2981
+ // -------------------------------------------------------------------
2982
+ // Public state
2983
+ // -------------------------------------------------------------------
2984
+ get status() {
2985
+ return this._status;
2986
+ }
2987
+ get syncLimits() {
2988
+ return { ...this.limits };
2989
+ }
2990
+ get serverActor() {
2991
+ return this.actor;
2992
+ }
2993
+ getSubscription(key) {
2994
+ return this.subs.get(key);
2995
+ }
2996
+ get own() {
2997
+ return this.subs.get(OWN_SUB);
2998
+ }
2999
+ get pendingCount() {
3000
+ let n = 0;
3001
+ for (const sub of this.subs.values()) n += sub.pending.length;
3002
+ return n;
3003
+ }
3004
+ async listRejected(subKey = OWN_SUB) {
3005
+ const sub = this.subs.get(subKey);
3006
+ if (!sub) return [];
3007
+ return sub.store.listRejected();
3008
+ }
3009
+ async clearRejected(subKey = OWN_SUB) {
3010
+ await this.subs.get(subKey)?.store.clearRejected();
3011
+ }
3012
+ // -------------------------------------------------------------------
3013
+ // Lifecycle
3014
+ // -------------------------------------------------------------------
3015
+ /**
3016
+ * Open the own-channel keyspace for local reads/writes — no connection,
3017
+ * no token needed. This is the anonymous / offline-cold-start entry point.
3018
+ * Idempotent.
3019
+ */
3020
+ async openLocal() {
3021
+ if (this.subs.has(OWN_SUB)) {
3022
+ this.storesOpen = true;
3023
+ this.recomputeStatus();
3024
+ return;
3025
+ }
3026
+ if (!this.openingLocal) {
3027
+ this.openingLocal = (async () => {
3028
+ const sub = await this.openSub(OWN_SUB, null);
3029
+ this.subs.set(OWN_SUB, sub);
3030
+ this.storesOpen = true;
3031
+ })().finally(() => {
3032
+ this.openingLocal = null;
3033
+ });
3034
+ }
3035
+ await this.openingLocal;
3036
+ this.recomputeStatus();
3037
+ }
3038
+ /**
3039
+ * Open the keyspace (if needed) and start syncing. Idempotent.
3040
+ * Note: a `CONNECTION_REVOKED` latch is NOT cleared here — reconnecting a
3041
+ * revoked app connection requires a fresh consent flow. Call
3042
+ * {@link clearRevoked} (or rebind the engine) after re-authorization.
3043
+ */
3044
+ async connect() {
3045
+ await this.openLocal();
3046
+ if (this.revokedInfo) {
3047
+ this.log("connect() ignored: app connection is revoked");
3048
+ return;
3049
+ }
3050
+ this.connectIntended = true;
3051
+ this.connection.start();
3052
+ this.recomputeStatus();
3053
+ }
3054
+ /** Clear the revocation latch (after the user re-authorized the app). */
3055
+ clearRevoked() {
3056
+ this.revokedInfo = null;
3057
+ this.recomputeStatus();
3058
+ }
3059
+ /** @deprecated alias of {@link connect} */
3060
+ async start() {
3061
+ return this.connect();
3062
+ }
3063
+ /**
3064
+ * Disconnect but keep stores open: local reads/writes keep working and
3065
+ * ops queue for the next connect. Used on reauth_required.
3066
+ */
3067
+ pause() {
3068
+ this.connectIntended = false;
3069
+ this.connection.stop();
3070
+ for (const t of this.timers) clearTimeout(t);
3071
+ this.timers.clear();
3072
+ for (const sub of this.subs.values()) {
3073
+ sub.active = false;
3074
+ for (const p of sub.pending) {
3075
+ p.sent = false;
3076
+ p.ackedSeq = void 0;
3077
+ }
3078
+ }
3079
+ this.recomputeStatus();
3080
+ }
3081
+ /** Close the socket and stores; local data is kept. */
3082
+ stop() {
3083
+ this.connectIntended = false;
3084
+ this.storesOpen = false;
3085
+ this.connection.stop();
3086
+ for (const t of this.timers) clearTimeout(t);
3087
+ this.timers.clear();
3088
+ for (const sub of this.subs.values()) {
3089
+ sub.active = false;
3090
+ sub.store.close();
3091
+ }
3092
+ this.subs.clear();
3093
+ this.recomputeStatus();
3094
+ }
3095
+ /**
3096
+ * Stop and delete every local database for this project (sign-out).
3097
+ * Best-effort discovery of mount keyspaces from previous sessions.
3098
+ */
3099
+ async destroyLocal() {
3100
+ const open = [...this.subs.values()];
3101
+ this.stop();
3102
+ for (const sub of open) {
3103
+ try {
3104
+ await sub.store.destroy();
3105
+ } catch (err) {
3106
+ this.log("failed deleting local db:", err);
3107
+ }
3108
+ }
3109
+ try {
3110
+ const idb = globalThis.indexedDB;
3111
+ if (idb && typeof idb.databases === "function") {
3112
+ const base = this.baseDbName;
3113
+ const dbs = await idb.databases();
3114
+ for (const info of dbs) {
3115
+ if (info.name && (info.name === base || info.name.startsWith(`${base}:share:`))) {
3116
+ await new Promise((resolve) => {
3117
+ const req = idb.deleteDatabase(info.name);
3118
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
3119
+ });
3120
+ }
3121
+ }
3122
+ }
3123
+ } catch {
3124
+ }
3125
+ }
3126
+ // -------------------------------------------------------------------
3127
+ // Shares (mounts)
3128
+ // -------------------------------------------------------------------
3129
+ /**
3130
+ * Mount a share: separate keyspace `(project, share)` with its own cursor
3131
+ * and pending queue. Bootstraps + subscribes when the socket is online.
3132
+ */
3133
+ async mountShare(shareId) {
3134
+ const key = shareSubKey(shareId);
3135
+ const existing = this.subs.get(key);
3136
+ if (existing) return existing;
3137
+ const sub = await this.openSub(key, shareId);
3138
+ this.subs.set(key, sub);
3139
+ if (this.connection.isOnline) {
3140
+ this.enqueue(sub, () => this.activateSub(sub));
3141
+ }
3142
+ return sub;
3143
+ }
3144
+ /** Unsubscribe a mount. Local cache is kept unless `purge` is set. */
3145
+ async unmountShare(shareId, options) {
3146
+ const key = shareSubKey(shareId);
3147
+ const sub = this.subs.get(key);
3148
+ if (!sub) return;
3149
+ if (sub.active) {
3150
+ this.connection.send({ type: "unsubscribe", sub: key });
3151
+ }
3152
+ this.subs.delete(key);
3153
+ if (options?.purge) {
3154
+ await sub.store.destroy();
3155
+ } else {
3156
+ sub.store.close();
3157
+ }
3158
+ }
3159
+ // -------------------------------------------------------------------
3160
+ // Writes (responsibility 2 + 6: pending queue + optimistic rebase)
3161
+ // -------------------------------------------------------------------
3162
+ /**
3163
+ * Queue a local op, apply it optimistically, and push when online.
3164
+ * Returns the resulting view record (null when deleted).
3165
+ * Throws on local validation failure (fail fast — the server would
3166
+ * terminally reject it anyway).
3167
+ */
3168
+ async apply(subKey, partial) {
3169
+ const sub = this.subs.get(subKey);
3170
+ if (!sub) throw new Error(`unknown subscription '${subKey}' \u2014 is the engine started?`);
3171
+ if (!sub.store.hasTable(partial.table)) {
3172
+ throw new Error(`table "${partial.table}" not found in schema`);
3173
+ }
3174
+ if (this.opts.validateWrites !== false && partial.type !== "delete") {
3175
+ const result = validateData(
3176
+ this.schema,
3177
+ partial.table,
3178
+ partial.data ?? {},
3179
+ partial.type === "put"
3180
+ );
3181
+ if (!result.valid) {
3182
+ throw new Error(result.message || "data validation failed");
3183
+ }
3184
+ }
3185
+ const op = {
3186
+ op_id: mintOpId(),
3187
+ type: partial.type,
3188
+ table: partial.table,
3189
+ record_id: partial.record_id,
3190
+ ...partial.type !== "delete" ? { data: partial.data ?? {} } : {},
3191
+ base_seq: Math.max(sub.cursor, 0)
3192
+ };
3193
+ const bytes = JSON.stringify(op).length;
3194
+ if (bytes > this.limits.max_op_bytes) {
3195
+ throw new Error(
3196
+ `op exceeds max_op_bytes (${bytes} > ${this.limits.max_op_bytes}) \u2014 PAYLOAD_TOO_LARGE`
3197
+ );
3198
+ }
3199
+ let view = null;
3200
+ await this.enqueue(sub, async () => {
3201
+ view = await sub.store.addPending(op);
3202
+ sub.pending.push({ op, sent: false });
3203
+ });
3204
+ this.emit("change", { sub: sub.key, tables: [op.table] });
3205
+ this.flush(sub);
3206
+ return view;
3207
+ }
3208
+ // -------------------------------------------------------------------
3209
+ // Connection handling
3210
+ // -------------------------------------------------------------------
3211
+ handleConnectionStatus(status) {
3212
+ this.connectionStatus = status;
3213
+ if (status === "offline" || status === "connecting" || status === "auth_failed" || status === "stopped") {
3214
+ for (const sub of this.subs.values()) {
3215
+ sub.active = false;
3216
+ for (const p of sub.pending) {
3217
+ p.sent = false;
3218
+ p.ackedSeq = void 0;
3219
+ }
3220
+ }
3221
+ }
3222
+ this.recomputeStatus();
3223
+ }
3224
+ handleWelcome(msg) {
3225
+ this.actor = msg.actor;
3226
+ if (msg.limits) this.limits = { ...this.limits, ...msg.limits };
3227
+ for (const sub of this.subs.values()) {
3228
+ if (sub.status === "revoked") continue;
3229
+ this.enqueue(sub, () => this.activateSub(sub));
3230
+ }
3231
+ }
3232
+ handleMessage(msg) {
3233
+ switch (msg.type) {
3234
+ case "subscribed":
3235
+ this.handleSubscribed(msg);
3236
+ return;
3237
+ case "ops": {
3238
+ const sub = this.subs.get(msg.sub);
3239
+ if (sub) this.enqueue(sub, () => this.processOps(sub, msg));
3240
+ return;
3241
+ }
3242
+ case "pushed": {
3243
+ const sub = this.subs.get(msg.sub);
3244
+ if (sub) this.enqueue(sub, () => this.processPushed(sub, msg));
3245
+ return;
3246
+ }
3247
+ case "error":
3248
+ this.handleError(msg);
3249
+ return;
3250
+ case "pong":
3251
+ case "token_ok":
3252
+ case "unsubscribed":
3253
+ case "welcome":
3254
+ return;
3255
+ default:
3256
+ return;
3257
+ }
3258
+ }
3259
+ handleSubscribed(msg) {
3260
+ const sub = this.subs.get(msg.sub);
3261
+ if (!sub) return;
3262
+ sub.active = true;
3263
+ sub.status = "live";
3264
+ sub.schemaVersion = msg.schema_version;
3265
+ this.log(`subscribed '${sub.key}' channel=${msg.channel} cursor=${msg.cursor} head=${msg.head}`);
3266
+ this.flush(sub);
3267
+ }
3268
+ handleError(msg) {
3269
+ const subKey = msg.sub;
3270
+ this.log(`server error${subKey ? ` (sub ${subKey})` : ""}: ${msg.code} \u2014 ${msg.message ?? ""}`);
3271
+ if (subKey) {
3272
+ const sub = this.subs.get(subKey);
3273
+ if (!sub) return;
3274
+ this.emit("suberror", { sub: subKey, code: msg.code, message: msg.message });
3275
+ if (isRebootstrapError(msg.code)) {
3276
+ sub.active = false;
3277
+ sub.bootstrapped = false;
3278
+ for (const p of sub.pending) {
3279
+ p.sent = false;
3280
+ p.ackedSeq = void 0;
3281
+ }
3282
+ this.enqueue(sub, async () => {
3283
+ await this.activateSub(sub);
3284
+ });
3285
+ return;
3286
+ }
3287
+ if (msg.code === "SHARE_REVOKED" || msg.code === "CONNECTION_REVOKED") {
3288
+ sub.active = false;
3289
+ sub.status = "revoked";
3290
+ sub.revokedCode = msg.code;
3291
+ this.subs.delete(subKey);
3292
+ void sub.store.destroy().catch(() => {
3293
+ });
3294
+ return;
3295
+ }
3296
+ return;
3297
+ }
3298
+ switch (msg.code) {
3299
+ case "CONNECTION_REVOKED":
3300
+ this.revokedInfo = { code: msg.code, message: msg.message };
3301
+ this.connectIntended = false;
3302
+ this.connection.stop();
3303
+ this.recomputeStatus();
3304
+ this.emit("revoked", { code: msg.code, message: msg.message });
3305
+ return;
3306
+ case "UNSUPPORTED_VERSION":
3307
+ this.connectIntended = false;
3308
+ this.connection.stop();
3309
+ this.recomputeStatus();
3310
+ return;
3311
+ case "TOO_MANY_OPS":
3312
+ case "RATE_LIMITED": {
3313
+ for (const sub of this.subs.values()) {
3314
+ for (const p of sub.pending) {
3315
+ if (p.ackedSeq === void 0) p.sent = false;
3316
+ }
3317
+ }
3318
+ this.timer(() => {
3319
+ for (const sub of this.subs.values()) this.flush(sub);
3320
+ }, RETRY_FLUSH_DELAY_MS);
3321
+ return;
3322
+ }
3323
+ case "UNAUTHORIZED":
3324
+ case "TOKEN_EXPIRED":
3325
+ return;
3326
+ case "SNAPSHOT_REQUIRED":
3327
+ case "RESET_REQUIRED":
3328
+ for (const sub of this.subs.values()) {
3329
+ sub.active = false;
3330
+ sub.bootstrapped = false;
3331
+ this.enqueue(sub, () => this.activateSub(sub));
3332
+ }
3333
+ return;
3334
+ default:
3335
+ return;
3336
+ }
3337
+ }
3338
+ // -------------------------------------------------------------------
3339
+ // Subscription state machine (serialized per sub via `chain`)
3340
+ // -------------------------------------------------------------------
3341
+ async openSub(key, shareId) {
3342
+ const name = shareId ? `${this.baseDbName}:share:${shareId}` : this.baseDbName;
3343
+ const store = new SyncStore(name, this.schema);
3344
+ const [cursor, pendingRows] = await Promise.all([store.getCursor(), store.loadPending()]);
3345
+ return {
3346
+ key,
3347
+ shareId,
3348
+ store,
3349
+ cursor: cursor ?? -1,
3350
+ // -1 = never bootstrapped
3351
+ pending: pendingRows.map((row) => ({ op: row.op, sent: false })),
3352
+ active: false,
3353
+ bootstrapped: cursor !== null,
3354
+ status: "initializing",
3355
+ appliedOpIds: new BoundedSet(),
3356
+ chain: Promise.resolve(),
3357
+ schemaVersion: null
3358
+ };
3359
+ }
3360
+ /** Bootstrap if needed, then bind the stream on the current socket. */
3361
+ async activateSub(sub) {
3362
+ if (!this.connection.isOnline) return;
3363
+ if (sub.bootstrapped && !sub.shareId && this.opts.getOwnerDid) {
3364
+ try {
3365
+ const did = await this.opts.getOwnerDid() ?? null;
3366
+ if (did) {
3367
+ const stamped = await sub.store.getOwner();
3368
+ if (stamped && stamped !== did) sub.bootstrapped = false;
3369
+ }
3370
+ } catch {
3371
+ }
3372
+ }
3373
+ if (!sub.bootstrapped || sub.cursor < 0) {
3374
+ try {
3375
+ await this.bootstrapSub(sub);
3376
+ } catch (err) {
3377
+ this.log(`bootstrap failed for '${sub.key}':`, err);
3378
+ sub.status = "error";
3379
+ return;
3380
+ }
3381
+ }
3382
+ this.connection.send({
3383
+ type: "subscribe",
3384
+ sub: sub.key,
3385
+ cursor: sub.cursor,
3386
+ ...sub.shareId ? { share: sub.shareId } : this.opts.appName ? { app: this.opts.appName } : {}
3387
+ });
3388
+ }
3389
+ /** Cold start = snapshot + tail; never log replay (§5). Pending survives. */
3390
+ async bootstrapSub(sub) {
3391
+ let ownerDid = null;
3392
+ if (!sub.shareId && this.opts.getOwnerDid) {
3393
+ try {
3394
+ ownerDid = await this.opts.getOwnerDid() ?? null;
3395
+ } catch {
3396
+ ownerDid = null;
3397
+ }
3398
+ if (ownerDid) {
3399
+ const stamped = await sub.store.getOwner();
3400
+ if (stamped && stamped !== ownerDid) {
3401
+ this.log(
3402
+ `keyspace owned by ${stamped} but session is ${ownerDid} \u2014 wiping local data before bootstrap`
3403
+ );
3404
+ await sub.store.wipeAll();
3405
+ sub.pending = [];
3406
+ sub.appliedOpIds.clear();
3407
+ sub.cursor = -1;
3408
+ }
3409
+ }
3410
+ }
3411
+ const snapshot = await this.opts.fetchSnapshot(
3412
+ sub.shareId ? { share: sub.shareId } : void 0
3413
+ );
3414
+ await sub.store.replaceFromSnapshot({
3415
+ channel: snapshot.channel,
3416
+ records: snapshot.records ?? {},
3417
+ cursor: snapshot.cursor ?? 0
3418
+ });
3419
+ if (!sub.shareId && ownerDid) {
3420
+ await sub.store.setOwner(ownerDid);
3421
+ }
3422
+ sub.cursor = snapshot.cursor ?? 0;
3423
+ sub.bootstrapped = true;
3424
+ sub.appliedOpIds.clear();
3425
+ this.log(`bootstrapped '${sub.key}' cursor=${sub.cursor}`);
3426
+ this.emit("change", { sub: sub.key, tables: sub.store.tables });
3427
+ }
3428
+ /**
3429
+ * Responsibilities 3+4+5: ordered apply, cursor advance, dedupe/confirm.
3430
+ * Runs inside the sub's serial chain.
3431
+ */
3432
+ async processOps(sub, msg) {
3433
+ const applyOps = [];
3434
+ const confirmedOpIds = [];
3435
+ for (const op of msg.ops) {
3436
+ if (typeof op.seq !== "number" || op.seq <= sub.cursor) continue;
3437
+ sub.cursor = op.seq;
3438
+ if (sub.appliedOpIds.has(op.op_id)) continue;
3439
+ const idx = sub.pending.findIndex((p) => p.op.op_id === op.op_id);
3440
+ if (idx >= 0) {
3441
+ sub.pending.splice(idx, 1);
3442
+ confirmedOpIds.push(op.op_id);
3443
+ }
3444
+ if (!sub.store.hasTable(op.table)) {
3445
+ sub.appliedOpIds.add(op.op_id);
3446
+ continue;
3447
+ }
3448
+ applyOps.push(op);
3449
+ sub.appliedOpIds.add(op.op_id);
3450
+ }
3451
+ if (typeof msg.cursor === "number" && msg.cursor > sub.cursor) {
3452
+ sub.cursor = msg.cursor;
3453
+ }
3454
+ if (applyOps.length > 0 || confirmedOpIds.length > 0) {
3455
+ await sub.store.commitIncoming({ applyOps, confirmedOpIds, cursor: sub.cursor });
3456
+ const tables = [...new Set(applyOps.map((op) => op.table))];
3457
+ this.emit("change", { sub: sub.key, tables });
3458
+ } else {
3459
+ await sub.store.setCursor(sub.cursor);
3460
+ }
3461
+ }
3462
+ /**
3463
+ * Push verdicts (§6.5, §8, §9). Success acks are recorded but the op stays
3464
+ * pending until its echo arrives in seq order — this preserves strict
3465
+ * ordered apply even when `pushed` races ahead of intermediate remote ops.
3466
+ * Terminal errors apply the poison-op rule; retryables back off.
3467
+ */
3468
+ async processPushed(sub, msg) {
3469
+ let needsRetry = false;
3470
+ const changedTables = /* @__PURE__ */ new Set();
3471
+ for (const result of msg.results) {
3472
+ const idx = sub.pending.findIndex((p) => p.op.op_id === result.op_id);
3473
+ if ("seq" in result && typeof result.seq === "number") {
3474
+ if (idx >= 0) {
3475
+ if (result.seq <= sub.cursor) {
3476
+ const [entry] = sub.pending.splice(idx, 1);
3477
+ await sub.store.commitIncoming({
3478
+ applyOps: [],
3479
+ confirmedOpIds: [entry.op.op_id],
3480
+ cursor: sub.cursor
3481
+ });
3482
+ changedTables.add(entry.op.table);
3483
+ } else {
3484
+ sub.pending[idx].ackedSeq = result.seq;
3485
+ await sub.store.markAcked(result.op_id, result.seq);
3486
+ }
3487
+ }
3488
+ continue;
3489
+ }
3490
+ if ("error" in result) {
3491
+ if (isTerminalOpError(result.error, result.terminal)) {
3492
+ if (idx >= 0) sub.pending.splice(idx, 1);
3493
+ const rejection = await sub.store.rejectPending(
3494
+ result.op_id,
3495
+ result.error,
3496
+ result.message
3497
+ );
3498
+ if (rejection) {
3499
+ changedTables.add(rejection.op.table);
3500
+ this.emit("rejected", { sub: sub.key, rejection });
3501
+ this.log(
3502
+ `op rejected (${result.error}): ${rejection.op.type} ${rejection.op.table}/${rejection.op.record_id}`
3503
+ );
3504
+ }
3505
+ } else if (idx >= 0) {
3506
+ sub.pending[idx].sent = false;
3507
+ needsRetry = true;
3508
+ }
3509
+ }
3510
+ }
3511
+ if (changedTables.size > 0) {
3512
+ this.emit("change", { sub: sub.key, tables: [...changedTables] });
3513
+ }
3514
+ if (needsRetry) {
3515
+ this.timer(() => this.flush(sub), RETRY_FLUSH_DELAY_MS);
3516
+ }
3517
+ }
3518
+ /** Push unsent pending ops, chunked to `limits.max_ops_per_push`. */
3519
+ flush(sub) {
3520
+ if (!this.connection.isOnline || !sub.active) return;
3521
+ const unsent = sub.pending.filter((p) => !p.sent && p.ackedSeq === void 0);
3522
+ if (unsent.length === 0) return;
3523
+ const chunkSize = Math.max(1, this.limits.max_ops_per_push);
3524
+ for (let i = 0; i < unsent.length; i += chunkSize) {
3525
+ const chunk = unsent.slice(i, i + chunkSize);
3526
+ for (const p of chunk) p.sent = true;
3527
+ const ok = this.connection.send({
3528
+ type: "push",
3529
+ sub: sub.key,
3530
+ ops: chunk.map((p) => p.op)
3531
+ });
3532
+ if (!ok) {
3533
+ for (const p of chunk) p.sent = false;
3534
+ return;
3535
+ }
3536
+ }
3537
+ this.log(`pushed ${unsent.length} op(s) on '${sub.key}'`);
3538
+ }
3539
+ // -------------------------------------------------------------------
3540
+ // Internals
3541
+ // -------------------------------------------------------------------
3542
+ get dbPrefix() {
3543
+ return this.opts.dbNamePrefix ?? "basic-sync";
3544
+ }
3545
+ /** Base database name for this keyspace (multi-user: includes the user id). */
3546
+ get baseDbName() {
3547
+ const base = `${this.dbPrefix}:${this.projectId}`;
3548
+ return this.opts.keyspaceId ? `${base}:${this.opts.keyspaceId}` : base;
3549
+ }
3550
+ enqueue(sub, task) {
3551
+ sub.chain = sub.chain.then(task).catch((err) => {
3552
+ this.log(`task failed on '${sub.key}':`, err);
3553
+ });
3554
+ return sub.chain;
3555
+ }
3556
+ timer(fn, ms) {
3557
+ const t = setTimeout(() => {
3558
+ this.timers.delete(t);
3559
+ fn();
3560
+ }, ms);
3561
+ this.timers.add(t);
3562
+ }
3563
+ recomputeStatus() {
3564
+ let status;
3565
+ if (this.revokedInfo) status = "revoked";
3566
+ else if (this.connectionStatus === "auth_failed") status = "auth_required";
3567
+ else if (!this.storesOpen) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
3568
+ else if (!this.connectIntended) status = "local";
3569
+ else if (this.connectionStatus === "online") status = "online";
3570
+ else if (this.connectionStatus === "connecting") status = "connecting";
3571
+ else if (this.connectionStatus === "idle") status = "connecting";
3572
+ else if (this.connectionStatus === "stopped") status = "local";
3573
+ else status = "offline";
3574
+ if (status !== this._status) {
3575
+ this._status = status;
3576
+ this.emit("status", status);
3577
+ }
3578
+ }
3579
+ log(...args) {
3580
+ this.opts.log?.("[sync-engine]", ...args);
3581
+ }
3582
+ };
3583
+
3584
+ // src/core/db.ts
3585
+ function mintRecordId() {
3586
+ return mintOpId();
3587
+ }
3588
+ var SyncTable = class {
3589
+ constructor(engine, subKey, name) {
3590
+ this.engine = engine;
3591
+ this.subKey = subKey;
3592
+ this.name = name;
3593
+ }
3594
+ get store() {
3595
+ const sub = this.engine.getSubscription(this.subKey);
3596
+ if (!sub) {
3597
+ throw new Error(
3598
+ `subscription '${this.subKey}' is not open \u2014 wait for the client to be ready (isReady) before using the db`
3599
+ );
3600
+ }
3601
+ return sub.store;
3602
+ }
3603
+ get ref() {
3604
+ return this.store.view(this.name);
3605
+ }
3606
+ async create(data) {
3607
+ const id = mintRecordId();
3608
+ const view = await this.engine.apply(this.subKey, {
3609
+ type: "put",
3610
+ table: this.name,
3611
+ record_id: id,
3612
+ data
3613
+ });
3614
+ return view ?? { id, ...data };
3615
+ }
3616
+ async put(id, data) {
3617
+ if (!id) throw new Error("put() requires an id");
3618
+ const view = await this.engine.apply(this.subKey, {
3619
+ type: "put",
3620
+ table: this.name,
3621
+ record_id: id,
3622
+ data
3623
+ });
3624
+ return view ?? { id, ...data };
3625
+ }
3626
+ async patch(id, data) {
3627
+ if (!id) throw new Error("patch() requires an id");
3628
+ const existing = await this.store.getViewRecord(this.name, id);
3629
+ if (!existing) return null;
3630
+ const view = await this.engine.apply(this.subKey, {
3631
+ type: "patch",
3632
+ table: this.name,
3633
+ record_id: id,
3634
+ data
3635
+ });
3636
+ return view;
3637
+ }
3638
+ async delete(id) {
3639
+ if (!id) throw new Error("delete() requires an id");
3640
+ await this.engine.apply(this.subKey, {
3641
+ type: "delete",
3642
+ table: this.name,
3643
+ record_id: id
3644
+ });
3645
+ }
3646
+ async get(id) {
3647
+ return await this.store.getViewRecord(this.name, id);
3648
+ }
3649
+ async getAll() {
3650
+ return await this.store.getViewRecords(this.name);
3651
+ }
3652
+ async find(predicate) {
3653
+ const all = await this.getAll();
3654
+ return all.filter(predicate);
3655
+ }
3656
+ };
3657
+ var SyncDb = class {
3658
+ constructor(engine, subKey = OWN_SUB) {
3659
+ this.engine = engine;
3660
+ this.subKey = subKey;
3661
+ }
3662
+ kind = "sync";
3663
+ tables = /* @__PURE__ */ new Map();
3664
+ table(name) {
3665
+ if (!this.engine.schema.tables[name]) {
3666
+ throw new Error(`table "${name}" not found in schema`);
2759
3667
  }
2760
- await this.setStoredVersion(this.currentVersion);
2761
- return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
3668
+ if (!this.tables.has(name)) {
3669
+ this.tables.set(name, new SyncTable(this.engine, this.subKey, name));
3670
+ }
3671
+ return this.tables.get(name);
2762
3672
  }
2763
- async getStoredVersion() {
3673
+ };
3674
+ var RestTable = class {
3675
+ constructor(rest, name) {
3676
+ this.rest = rest;
3677
+ this.name = name;
3678
+ }
3679
+ async create(data) {
3680
+ const record = await this.rest.createRecord(this.name, data);
3681
+ return record;
3682
+ }
3683
+ async put(id, data) {
3684
+ if (!id) throw new Error("put() requires an id");
3685
+ const record = await this.rest.putRecord(this.name, id, data);
3686
+ if (!record) throw new Error(`record ${this.name}/${id} not found (REST put is replace-only)`);
3687
+ return record;
3688
+ }
3689
+ async patch(id, data) {
3690
+ if (!id) throw new Error("patch() requires an id");
3691
+ const record = await this.rest.patchRecord(this.name, id, data);
3692
+ return record;
3693
+ }
3694
+ async delete(id) {
3695
+ if (!id) throw new Error("delete() requires an id");
3696
+ await this.rest.deleteRecord(this.name, id);
3697
+ }
3698
+ async get(id) {
3699
+ const record = await this.rest.getRecord(this.name, id);
3700
+ return record;
3701
+ }
3702
+ async getAll() {
3703
+ return await this.rest.list(this.name);
3704
+ }
3705
+ async find(predicate) {
3706
+ const all = await this.getAll();
3707
+ return all.filter(predicate);
3708
+ }
3709
+ };
3710
+ var RestDb = class {
3711
+ constructor(rest, schema) {
3712
+ this.rest = rest;
3713
+ this.schema = schema;
3714
+ }
3715
+ kind = "rest";
3716
+ tables = /* @__PURE__ */ new Map();
3717
+ table(name) {
3718
+ if (this.schema?.tables && !this.schema.tables[name]) {
3719
+ throw new Error(`table "${name}" not found in schema`);
3720
+ }
3721
+ if (!this.tables.has(name)) {
3722
+ this.tables.set(name, new RestTable(this.rest, name));
3723
+ }
3724
+ return this.tables.get(name);
3725
+ }
3726
+ };
3727
+
3728
+ // src/core/users.ts
3729
+ init_config();
3730
+ var PrefixedStorage = class {
3731
+ constructor(inner, prefix) {
3732
+ this.inner = inner;
3733
+ this.prefix = prefix;
3734
+ }
3735
+ get(key) {
3736
+ return this.inner.get(this.prefix + key);
3737
+ }
3738
+ set(key, value) {
3739
+ return this.inner.set(this.prefix + key, value);
3740
+ }
3741
+ remove(key) {
3742
+ return this.inner.remove(this.prefix + key);
3743
+ }
3744
+ };
3745
+ function registryKey(projectId) {
3746
+ return `basic_users:${projectId}`;
3747
+ }
3748
+ function activeUserSessionKey(projectId) {
3749
+ return `basic_active_user:${projectId}`;
3750
+ }
3751
+ var UserRegistry = class {
3752
+ constructor(storage, projectId) {
3753
+ this.storage = storage;
3754
+ this.projectId = projectId;
3755
+ }
3756
+ // -------------------------------------------------------------------
3757
+ // Registry CRUD
3758
+ // -------------------------------------------------------------------
3759
+ async list() {
3760
+ const raw = await this.storage.get(registryKey(this.projectId));
3761
+ if (!raw) return [];
2764
3762
  try {
2765
- const versionData = await this.storage.get(this.versionKey);
2766
- if (!versionData) return null;
2767
- const versionInfo = JSON.parse(versionData);
2768
- return versionInfo.version;
2769
- } catch (error) {
2770
- console.warn("Failed to get stored version:", error);
2771
- return null;
3763
+ const parsed = JSON.parse(raw);
3764
+ return Array.isArray(parsed) ? parsed : [];
3765
+ } catch {
3766
+ return [];
2772
3767
  }
2773
3768
  }
2774
- async setStoredVersion(version2) {
2775
- const versionInfo = {
2776
- version: version2,
2777
- lastUpdated: Date.now()
3769
+ async save(users) {
3770
+ await this.storage.set(registryKey(this.projectId), JSON.stringify(users));
3771
+ }
3772
+ async get(id) {
3773
+ const users = await this.list();
3774
+ return users.find((u) => u.id === id) ?? null;
3775
+ }
3776
+ async createAnon() {
3777
+ const id = mintOpId();
3778
+ const now = Date.now();
3779
+ const profile = {
3780
+ id,
3781
+ kind: "anon",
3782
+ keyspace: id,
3783
+ storagePrefix: `u:${id}:`,
3784
+ createdAt: now,
3785
+ lastActiveAt: now
2778
3786
  };
2779
- await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
3787
+ const users = await this.list();
3788
+ users.push(profile);
3789
+ await this.save(users);
3790
+ log(`created anonymous user ${id}`);
3791
+ return profile;
2780
3792
  }
2781
- getMigrationsToRun(fromVersion, toVersion) {
2782
- return this.migrations.filter((migration) => {
2783
- const storedLessThanMigrationTo = this.compareVersions(fromVersion, migration.toVersion) < 0;
2784
- const currentGreaterThanOrEqualMigrationTo = this.compareVersions(toVersion, migration.toVersion) >= 0;
2785
- const shouldRun = storedLessThanMigrationTo && currentGreaterThanOrEqualMigrationTo;
2786
- log(`Migration ${migration.fromVersion} \u2192 ${migration.toVersion}: shouldRun=${shouldRun}`);
2787
- return shouldRun;
2788
- });
3793
+ async update(id, patch) {
3794
+ const users = await this.list();
3795
+ const idx = users.findIndex((u) => u.id === id);
3796
+ if (idx < 0) return null;
3797
+ users[idx] = { ...users[idx], ...patch };
3798
+ await this.save(users);
3799
+ return users[idx];
2789
3800
  }
2790
- /**
2791
- * Simple semantic version comparison (major.minor only, ignoring beta/prerelease)
2792
- * Returns: -1 if a < b, 0 if a === b, 1 if a > b
2793
- */
2794
- compareVersions(a, b) {
2795
- const aMajorMinor = this.extractMajorMinor(a);
2796
- const bMajorMinor = this.extractMajorMinor(b);
2797
- if (aMajorMinor.major !== bMajorMinor.major) {
2798
- return aMajorMinor.major - bMajorMinor.major;
3801
+ async remove(id) {
3802
+ const users = await this.list();
3803
+ await this.save(users.filter((u) => u.id !== id));
3804
+ if (this.getActiveIdRaw() === id) this.clearActiveId();
3805
+ }
3806
+ /** The profile (if any) already bound to an account DID. */
3807
+ async findByDid(did) {
3808
+ const users = await this.list();
3809
+ return users.find((u) => u.did === did) ?? null;
3810
+ }
3811
+ // -------------------------------------------------------------------
3812
+ // Active user (per-tab)
3813
+ // -------------------------------------------------------------------
3814
+ getActiveIdRaw() {
3815
+ try {
3816
+ return sessionStorage.getItem(activeUserSessionKey(this.projectId));
3817
+ } catch {
3818
+ return null;
3819
+ }
3820
+ }
3821
+ setActiveId(id) {
3822
+ try {
3823
+ sessionStorage.setItem(activeUserSessionKey(this.projectId), id);
3824
+ } catch {
3825
+ }
3826
+ }
3827
+ clearActiveId() {
3828
+ try {
3829
+ sessionStorage.removeItem(activeUserSessionKey(this.projectId));
3830
+ } catch {
2799
3831
  }
2800
- return aMajorMinor.minor - bMajorMinor.minor;
2801
3832
  }
2802
3833
  /**
2803
- * Extract major.minor from version string, ignoring beta/prerelease
2804
- * Examples: "0.7.0-beta.1" -> {major: 0, minor: 7}
2805
- * "1.2.3" -> {major: 1, minor: 2}
3834
+ * Resolve the active profile for this tab: sessionStorage choice if it
3835
+ * still exists, else the most recently active profile, else null.
2806
3836
  */
2807
- extractMajorMinor(version2) {
2808
- const cleanVersion = version2.split("-")[0]?.split("+")[0] || version2;
2809
- const parts = cleanVersion.split(".").map(Number);
2810
- return {
2811
- major: parts[0] || 0,
2812
- minor: parts[1] || 0
2813
- };
3837
+ async resolveActive() {
3838
+ const users = await this.list();
3839
+ const activeId = this.getActiveIdRaw();
3840
+ if (activeId) {
3841
+ const match = users.find((u) => u.id === activeId);
3842
+ if (match) return match;
3843
+ }
3844
+ if (users.length === 0) return null;
3845
+ const recent = [...users].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0];
3846
+ this.setActiveId(recent.id);
3847
+ return recent;
3848
+ }
3849
+ async touch(id) {
3850
+ await this.update(id, { lastActiveAt: Date.now() });
2814
3851
  }
3852
+ // -------------------------------------------------------------------
3853
+ // Legacy adoption
3854
+ // -------------------------------------------------------------------
2815
3855
  /**
2816
- * Add a migration to the updater
3856
+ * Adopt a pre-multi-user session as the first profile. Idempotent: runs
3857
+ * only when the registry is empty and a bare refresh token exists. The
3858
+ * adopted profile keeps the unprefixed storage keys and the legacy
3859
+ * keyspace name, so nothing needs to move.
2817
3860
  */
2818
- addMigration(migration) {
2819
- this.migrations.push(migration);
2820
- this.migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
2821
- }
2822
- };
2823
- function createVersionUpdater(storage, currentVersion, migrations = []) {
2824
- return new VersionUpdater(storage, currentVersion, migrations);
2825
- }
2826
-
2827
- // src/updater/updateMigrations.ts
2828
- init_config();
2829
- var addMigrationTimestamp = {
2830
- fromVersion: "0.6.0",
2831
- toVersion: "0.7.0",
2832
- async migrate(storage) {
2833
- log("Running migration 0.6.0 \u2192 0.7.0");
2834
- storage.set("test_migration", "true");
3861
+ async adoptLegacySession() {
3862
+ const users = await this.list();
3863
+ if (users.length > 0) return null;
3864
+ const legacyRefresh = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
3865
+ if (!legacyRefresh) return null;
3866
+ let cachedUser = null;
3867
+ try {
3868
+ const raw = await this.storage.get(STORAGE_KEYS.USER_INFO);
3869
+ if (raw) cachedUser = JSON.parse(raw);
3870
+ } catch {
3871
+ }
3872
+ const now = Date.now();
3873
+ const profile = {
3874
+ id: mintOpId(),
3875
+ kind: "account",
3876
+ did: cachedUser?.sub ?? null,
3877
+ email: cachedUser?.email ?? null,
3878
+ name: cachedUser?.name ?? null,
3879
+ picture: cachedUser?.picture ?? null,
3880
+ keyspace: "",
3881
+ // legacy `basic-sync:{projectId}` database
3882
+ storagePrefix: "",
3883
+ // legacy unprefixed auth keys
3884
+ createdAt: now,
3885
+ lastActiveAt: now
3886
+ };
3887
+ await this.save([profile]);
3888
+ log("adopted legacy single-user session as profile", profile.id);
3889
+ return profile;
2835
3890
  }
2836
3891
  };
2837
- function getMigrations() {
2838
- return [
2839
- addMigrationTimestamp
2840
- ];
2841
- }
2842
-
2843
- // src/AuthContext.tsx
2844
- init_network();
2845
3892
 
2846
3893
  // src/utils/schema.ts
2847
3894
  init_config();
@@ -2927,451 +3974,844 @@ async function validateAndCheckSchema(schema) {
2927
3974
  errors: valid.errors
2928
3975
  };
2929
3976
  }
2930
- let schemaStatus = { valid: false };
2931
- if (schema.version !== 0) {
2932
- schemaStatus = await getSchemaStatus(schema);
2933
- log("schemaStatus", schemaStatus);
2934
- } else {
2935
- schemaStatus = { valid: false, status: "unpublished" };
2936
- log("schema not published - at version 0");
3977
+ let schemaStatus = { valid: false };
3978
+ if (schema.version !== 0) {
3979
+ schemaStatus = await getSchemaStatus(schema);
3980
+ log("schemaStatus", schemaStatus);
3981
+ } else {
3982
+ schemaStatus = { valid: false, status: "unpublished" };
3983
+ log("schema not published - at version 0");
3984
+ }
3985
+ return {
3986
+ isValid: true,
3987
+ schemaStatus
3988
+ };
3989
+ }
3990
+
3991
+ // src/updater/versionUpdater.ts
3992
+ init_config();
3993
+ var VersionUpdater = class {
3994
+ storage;
3995
+ currentVersion;
3996
+ migrations;
3997
+ versionKey = "basic_app_version";
3998
+ constructor(storage, currentVersion, migrations = []) {
3999
+ this.storage = storage;
4000
+ this.currentVersion = currentVersion;
4001
+ this.migrations = migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
4002
+ }
4003
+ /**
4004
+ * Check current stored version and run migrations if needed
4005
+ * Only compares major.minor versions, ignoring beta/prerelease parts
4006
+ * Example: "0.7.0-beta.1" and "0.7.0" are treated as the same version
4007
+ */
4008
+ async checkAndUpdate() {
4009
+ const storedVersion = await this.getStoredVersion();
4010
+ if (!storedVersion) {
4011
+ await this.setStoredVersion(this.currentVersion);
4012
+ return { updated: false, toVersion: this.currentVersion };
4013
+ }
4014
+ if (storedVersion === this.currentVersion) {
4015
+ return { updated: false, toVersion: this.currentVersion };
4016
+ }
4017
+ const migrationsToRun = this.getMigrationsToRun(storedVersion, this.currentVersion);
4018
+ if (migrationsToRun.length === 0) {
4019
+ await this.setStoredVersion(this.currentVersion);
4020
+ return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
4021
+ }
4022
+ for (const migration of migrationsToRun) {
4023
+ try {
4024
+ log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`);
4025
+ await migration.migrate(this.storage);
4026
+ } catch (error) {
4027
+ console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error);
4028
+ throw new Error(`Migration failed: ${error}`);
4029
+ }
4030
+ }
4031
+ await this.setStoredVersion(this.currentVersion);
4032
+ return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
4033
+ }
4034
+ async getStoredVersion() {
4035
+ try {
4036
+ const versionData = await this.storage.get(this.versionKey);
4037
+ if (!versionData) return null;
4038
+ const versionInfo = JSON.parse(versionData);
4039
+ return versionInfo.version;
4040
+ } catch (error) {
4041
+ console.warn("Failed to get stored version:", error);
4042
+ return null;
4043
+ }
4044
+ }
4045
+ async setStoredVersion(version2) {
4046
+ const versionInfo = {
4047
+ version: version2,
4048
+ lastUpdated: Date.now()
4049
+ };
4050
+ await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
4051
+ }
4052
+ getMigrationsToRun(fromVersion, toVersion) {
4053
+ return this.migrations.filter((migration) => {
4054
+ const storedLessThanMigrationTo = this.compareVersions(fromVersion, migration.toVersion) < 0;
4055
+ const currentGreaterThanOrEqualMigrationTo = this.compareVersions(toVersion, migration.toVersion) >= 0;
4056
+ const shouldRun = storedLessThanMigrationTo && currentGreaterThanOrEqualMigrationTo;
4057
+ log(`Migration ${migration.fromVersion} \u2192 ${migration.toVersion}: shouldRun=${shouldRun}`);
4058
+ return shouldRun;
4059
+ });
4060
+ }
4061
+ /**
4062
+ * Simple semantic version comparison (major.minor only, ignoring beta/prerelease)
4063
+ * Returns: -1 if a < b, 0 if a === b, 1 if a > b
4064
+ */
4065
+ compareVersions(a, b) {
4066
+ const aMajorMinor = this.extractMajorMinor(a);
4067
+ const bMajorMinor = this.extractMajorMinor(b);
4068
+ if (aMajorMinor.major !== bMajorMinor.major) {
4069
+ return aMajorMinor.major - bMajorMinor.major;
4070
+ }
4071
+ return aMajorMinor.minor - bMajorMinor.minor;
4072
+ }
4073
+ /**
4074
+ * Extract major.minor from version string, ignoring beta/prerelease
4075
+ * Examples: "0.7.0-beta.1" -> {major: 0, minor: 7}
4076
+ * "1.2.3" -> {major: 1, minor: 2}
4077
+ */
4078
+ extractMajorMinor(version2) {
4079
+ const cleanVersion = version2.split("-")[0]?.split("+")[0] || version2;
4080
+ const parts = cleanVersion.split(".").map(Number);
4081
+ return {
4082
+ major: parts[0] || 0,
4083
+ minor: parts[1] || 0
4084
+ };
4085
+ }
4086
+ /**
4087
+ * Add a migration to the updater
4088
+ */
4089
+ addMigration(migration) {
4090
+ this.migrations.push(migration);
4091
+ this.migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
4092
+ }
4093
+ };
4094
+ function createVersionUpdater(storage, currentVersion, migrations = []) {
4095
+ return new VersionUpdater(storage, currentVersion, migrations);
4096
+ }
4097
+
4098
+ // src/updater/updateMigrations.ts
4099
+ init_config();
4100
+ var addMigrationTimestamp = {
4101
+ fromVersion: "0.6.0",
4102
+ toVersion: "0.7.0",
4103
+ async migrate(storage) {
4104
+ log("Running migration 0.6.0 \u2192 0.7.0");
4105
+ storage.set("test_migration", "true");
4106
+ }
4107
+ };
4108
+ var dropLegacySyncDb = {
4109
+ fromVersion: "0.8.0",
4110
+ toVersion: "0.9.0",
4111
+ async migrate() {
4112
+ log("Running migration 0.8.0 \u2192 0.9.0: deleting legacy basicdb");
4113
+ try {
4114
+ const idb = globalThis.indexedDB;
4115
+ if (!idb) return;
4116
+ await new Promise((resolve) => {
4117
+ const req = idb.deleteDatabase("basicdb");
4118
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
4119
+ });
4120
+ } catch {
4121
+ }
2937
4122
  }
2938
- return {
2939
- isValid: true,
2940
- schemaStatus
2941
- };
4123
+ };
4124
+ function getMigrations() {
4125
+ return [
4126
+ addMigrationTimestamp,
4127
+ dropLegacySyncDb
4128
+ ];
2942
4129
  }
2943
4130
 
2944
- // src/AuthContext.tsx
2945
- init_context();
2946
- init_context();
2947
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2948
- var BasicDevToolbar2 = lazy(
2949
- () => Promise.resolve().then(() => (init_BasicDevToolbar(), BasicDevToolbar_exports)).then((m) => ({ default: m.BasicDevToolbar }))
2950
- );
2951
- var DEFAULT_AUTH_CONFIG = {
4131
+ // src/core/BasicClient.ts
4132
+ init_config();
4133
+ init_package();
4134
+ var DEFAULTS = {
2952
4135
  scopes: "profile,email,app:admin",
2953
4136
  pds_url: "https://pds.basic.id",
2954
- admin_url: "https://api.basic.tech",
2955
- ws_url: "wss://pds.basic.id/ws"
4137
+ admin_url: "https://api.basic.tech"
2956
4138
  };
2957
- function snapshotAuth(mgr) {
4139
+ function deriveSyncUrl(pdsUrl) {
4140
+ return pdsUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/sync/";
4141
+ }
4142
+ function ephemeralLegacyProfile() {
4143
+ const now = Date.now();
2958
4144
  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
4145
+ id: "default",
4146
+ kind: "anon",
4147
+ keyspace: "",
4148
+ storagePrefix: "",
4149
+ createdAt: now,
4150
+ lastActiveAt: now
2967
4151
  };
2968
4152
  }
2969
- function BasicProvider({
2970
- children,
2971
- project_id: project_id_prop,
2972
- schema,
2973
- debug = false,
2974
- storage,
2975
- auth,
2976
- dbMode = "sync",
2977
- devToolbar = false
2978
- }) {
2979
- const project_id = schema?.project_id || project_id_prop;
2980
- if (auth?.server_url && !auth?.pds_url) {
2981
- log("Warning: auth.server_url is deprecated, use auth.pds_url instead");
2982
- }
2983
- const authConfig = {
2984
- scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
2985
- pds_url: auth?.pds_url || auth?.server_url || DEFAULT_AUTH_CONFIG.pds_url,
2986
- admin_url: auth?.admin_url || DEFAULT_AUTH_CONFIG.admin_url,
2987
- ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url
4153
+ var BasicClient = class {
4154
+ rest;
4155
+ mode;
4156
+ config;
4157
+ projectId;
4158
+ users;
4159
+ rawStorage;
4160
+ restDb;
4161
+ debug;
4162
+ anonymousEnabled;
4163
+ authConfig;
4164
+ syncUrl;
4165
+ binding;
4166
+ usersCache = [];
4167
+ devInfo = null;
4168
+ syncEnabled = false;
4169
+ schemaChecked = false;
4170
+ started = false;
4171
+ signOutInProgress = false;
4172
+ /** Serializes profile transitions (switch, dispose, sign-out fallthrough). */
4173
+ profileOps = Promise.resolve();
4174
+ mounts = /* @__PURE__ */ new Map();
4175
+ listeners = /* @__PURE__ */ new Set();
4176
+ snapshot;
4177
+ constructor(config) {
4178
+ this.config = config;
4179
+ this.debug = config.debug ?? false;
4180
+ this.mode = config.mode ?? "sync";
4181
+ this.projectId = config.schema?.project_id || config.project_id;
4182
+ this.anonymousEnabled = this.mode === "sync" && (config.anonymous ?? true);
4183
+ this.authConfig = {
4184
+ scopes: Array.isArray(config.auth?.scopes) ? config.auth.scopes.join(" ") : config.auth?.scopes || DEFAULTS.scopes,
4185
+ pds_url: config.auth?.pds_url || DEFAULTS.pds_url,
4186
+ admin_url: config.auth?.admin_url || DEFAULTS.admin_url
4187
+ };
4188
+ this.syncUrl = config.auth?.sync_url || deriveSyncUrl(this.authConfig.pds_url);
4189
+ this.rawStorage = config.storage || new LocalStorageAdapter();
4190
+ this.users = this.mode === "sync" && this.projectId ? new UserRegistry(this.rawStorage, this.projectId) : null;
4191
+ this.rest = new RestClient({
4192
+ baseUrl: this.authConfig.pds_url,
4193
+ projectId: this.projectId ?? "",
4194
+ getToken: (opts) => this.auth.getToken(opts),
4195
+ log: this.debug ? log : void 0
4196
+ });
4197
+ this.restDb = new RestDb(this.rest, this.config.schema);
4198
+ this.binding = this.createBinding(ephemeralLegacyProfile());
4199
+ this.snapshot = this.buildSnapshot();
4200
+ }
4201
+ // -------------------------------------------------------------------
4202
+ // Public surface
4203
+ // -------------------------------------------------------------------
4204
+ get auth() {
4205
+ return this.binding.auth;
4206
+ }
4207
+ get engine() {
4208
+ return this.binding.engine;
4209
+ }
4210
+ /** The database handle for the active user. Identity changes on switch. */
4211
+ get db() {
4212
+ if (this.mode === "sync" && this.binding.syncDb) return this.binding.syncDb;
4213
+ return this.restDb;
4214
+ }
4215
+ get activeUser() {
4216
+ return this.users ? this.binding.profile : null;
4217
+ }
4218
+ /** Bootstrap: version migrations, profile resolution, schema check, auth init. */
4219
+ async start() {
4220
+ if (this.started) return;
4221
+ this.started = true;
4222
+ try {
4223
+ const updater = createVersionUpdater(this.rawStorage, version, getMigrations());
4224
+ const result = await updater.checkAndUpdate();
4225
+ if (result.updated) log(`SDK storage migrated ${result.fromVersion} \u2192 ${result.toVersion}`);
4226
+ } catch (err) {
4227
+ log("version updater failed:", err);
4228
+ }
4229
+ void this.checkSchema().then(() => this.syncLifecycle());
4230
+ await this.queueProfileOp(async () => {
4231
+ let profile = null;
4232
+ if (this.users) {
4233
+ await this.users.adoptLegacySession();
4234
+ profile = await this.users.resolveActive();
4235
+ if (!profile && this.anonymousEnabled) {
4236
+ profile = await this.users.createAnon();
4237
+ }
4238
+ if (profile) this.users.setActiveId(profile.id);
4239
+ }
4240
+ await this.activateProfile(profile ?? ephemeralLegacyProfile(), { initial: true });
4241
+ await this.refreshUsers();
4242
+ });
4243
+ }
4244
+ /**
4245
+ * Sign out the active user: server-side revoke, wipe the profile's local
4246
+ * data, drop the profile, and fall through to the next (or a fresh
4247
+ * anonymous) user.
4248
+ */
4249
+ async signOut() {
4250
+ await this.queueProfileOp(async () => {
4251
+ const { profile, auth, engine } = this.binding;
4252
+ this.signOutInProgress = true;
4253
+ try {
4254
+ await auth.signOut();
4255
+ } finally {
4256
+ this.signOutInProgress = false;
4257
+ }
4258
+ this.mounts.clear();
4259
+ try {
4260
+ await engine?.destroyLocal();
4261
+ } catch (err) {
4262
+ log("local data teardown failed:", err);
4263
+ }
4264
+ if (this.users && profile.id !== "default") {
4265
+ await this.users.remove(profile.id);
4266
+ }
4267
+ await this.activateNextProfileLocked();
4268
+ await this.refreshUsers();
4269
+ });
4270
+ }
4271
+ // ---------------- multi-user ----------------
4272
+ /** Switch this tab to another local user. */
4273
+ async switchUser(id) {
4274
+ await this.queueProfileOp(async () => {
4275
+ if (!this.users) throw new Error("multiple users require sync mode with a project id");
4276
+ if (this.binding.profile.id === id) return;
4277
+ const target = await this.users.get(id);
4278
+ if (!target) throw new Error(`unknown user '${id}'`);
4279
+ this.users.setActiveId(id);
4280
+ await this.users.touch(id);
4281
+ await this.activateProfile(target);
4282
+ await this.refreshUsers();
4283
+ });
4284
+ }
4285
+ /** Create a fresh anonymous user and switch to it. */
4286
+ async addUser() {
4287
+ let created = null;
4288
+ await this.queueProfileOp(async () => {
4289
+ if (!this.users) throw new Error("multiple users require sync mode with a project id");
4290
+ created = await this.users.createAnon();
4291
+ this.users.setActiveId(created.id);
4292
+ await this.activateProfile(created);
4293
+ await this.refreshUsers();
4294
+ });
4295
+ return created;
4296
+ }
4297
+ /**
4298
+ * Remove a local user: best-effort server-side revoke, wipe its keyspace
4299
+ * and auth storage, drop the profile. Removing the active user signs out.
4300
+ */
4301
+ async removeUser(id) {
4302
+ if (this.binding.profile.id === id) {
4303
+ return this.signOut();
4304
+ }
4305
+ await this.queueProfileOp(async () => {
4306
+ if (!this.users) return;
4307
+ const profile = await this.users.get(id);
4308
+ if (!profile) return;
4309
+ await this.disposeProfileData(profile);
4310
+ await this.users.remove(id);
4311
+ await this.refreshUsers();
4312
+ });
4313
+ }
4314
+ /** Stop connections and listeners; local data is kept. */
4315
+ stop() {
4316
+ this.started = false;
4317
+ this.binding.engine?.stop();
4318
+ for (const fn of this.binding.sessionCleanup) {
4319
+ try {
4320
+ fn();
4321
+ } catch {
4322
+ }
4323
+ }
4324
+ this.binding.sessionCleanup = [];
4325
+ }
4326
+ /** Re-run the remote schema status check (dev toolbar). */
4327
+ async refreshSchemaStatus() {
4328
+ this.schemaChecked = false;
4329
+ await this.checkSchema();
4330
+ this.syncLifecycle();
4331
+ }
4332
+ async listRejected() {
4333
+ return this.engine?.listRejected() ?? [];
4334
+ }
4335
+ async clearRejected() {
4336
+ await this.engine?.clearRejected();
4337
+ this.publish();
4338
+ }
4339
+ // ---------------- shares ----------------
4340
+ /** Shares granted by / received by this user for this app. */
4341
+ async listShares() {
4342
+ return this.rest.listShares();
4343
+ }
4344
+ /** Mount a share: separate local keyspace + subscription. */
4345
+ async mountShare(shareId) {
4346
+ const engine = this.engine;
4347
+ if (!engine) throw new Error("shares require sync mode");
4348
+ const existing = this.mounts.get(shareId);
4349
+ if (existing) return existing;
4350
+ await engine.mountShare(shareId);
4351
+ const handle = {
4352
+ shareId,
4353
+ db: new SyncDb(engine, shareSubKey(shareId))
4354
+ };
4355
+ this.mounts.set(shareId, handle);
4356
+ this.publish();
4357
+ return handle;
4358
+ }
4359
+ async unmountShare(shareId, options) {
4360
+ if (!this.engine) return;
4361
+ await this.engine.unmountShare(shareId, options);
4362
+ this.mounts.delete(shareId);
4363
+ this.publish();
4364
+ }
4365
+ getMountedShare(shareId) {
4366
+ return this.mounts.get(shareId);
4367
+ }
4368
+ // ---------------- React subscription surface ----------------
4369
+ subscribe = (listener) => {
4370
+ this.listeners.add(listener);
4371
+ return () => this.listeners.delete(listener);
2988
4372
  };
2989
- const scopesString = Array.isArray(authConfig.scopes) ? authConfig.scopes.join(" ") : authConfig.scopes;
2990
- const storageRef = useRef(storage || new LocalStorageAdapter());
2991
- const storageAdapter = storageRef.current;
2992
- const schemaRef = useRef(schema);
2993
- schemaRef.current = schema;
2994
- const [authState, setAuthState] = useState2({
2995
- isSignedIn: false,
2996
- hasToken: false,
2997
- isAuthReady: false,
2998
- authStatus: "bootstrapping",
2999
- authErrorCode: null,
3000
- user: null,
3001
- did: null,
3002
- tokenScope: null
3003
- });
3004
- const authRef = useRef(null);
3005
- if (!authRef.current) {
3006
- authRef.current = new AuthManager(
4373
+ getSnapshot = () => {
4374
+ return this.snapshot;
4375
+ };
4376
+ // -------------------------------------------------------------------
4377
+ // Profile binding & transitions
4378
+ // -------------------------------------------------------------------
4379
+ createBinding(profile) {
4380
+ const storage = profile.storagePrefix ? new PrefixedStorage(this.rawStorage, profile.storagePrefix) : this.rawStorage;
4381
+ const binding = { profile, cleanup: [], sessionCleanup: [] };
4382
+ const auth = new AuthManager(
3007
4383
  {
3008
- projectId: project_id,
3009
- scopes: scopesString,
3010
- pdsUrl: authConfig.pds_url,
3011
- adminUrl: authConfig.admin_url,
3012
- debug
4384
+ projectId: this.projectId,
4385
+ scopes: this.authConfig.scopes,
4386
+ pdsUrl: this.authConfig.pds_url,
4387
+ adminUrl: this.authConfig.admin_url,
4388
+ debug: this.debug,
4389
+ instanceKey: profile.storagePrefix
3013
4390
  },
3014
- storageAdapter,
3015
- () => setAuthState(snapshotAuth(authRef.current))
4391
+ storage,
4392
+ () => {
4393
+ if (this.binding === binding) this.handleAuthChange();
4394
+ }
3016
4395
  );
3017
- }
3018
- const syncRef = useRef(null);
3019
- const remoteDbRef = useRef(null);
3020
- const [shouldConnect, setShouldConnect] = useState2(false);
3021
- const [dbStatus, setDbStatus] = useState2("OFFLINE" /* OFFLINE */);
3022
- const [isDbReady, setIsDbReady] = useState2(false);
3023
- const [error, setError] = useState2(null);
3024
- const [schemaDevInfo, setSchemaDevInfo] = useState2(
3025
- null
3026
- );
3027
- const isDevMode = () => isDevelopment(debug);
3028
- const refreshSchemaStatus = useCallback2(async () => {
3029
- const s = schemaRef.current;
3030
- if (!s) {
3031
- setSchemaDevInfo(
3032
- project_id ? {
3033
- projectId: project_id,
3034
- localVersion: void 0,
3035
- status: "no_schema",
3036
- valid: false,
3037
- lastCheckedAt: Date.now()
3038
- } : null
4396
+ binding.auth = auth;
4397
+ if (this.mode === "sync" && this.projectId && this.config.schema?.tables) {
4398
+ const engine = new SyncEngine({
4399
+ projectId: this.projectId,
4400
+ schema: this.config.schema,
4401
+ wsUrl: this.syncUrl,
4402
+ getToken: (opts) => auth.getToken(opts),
4403
+ fetchSnapshot: (opts) => this.rest.getSnapshot(opts),
4404
+ WebSocketImpl: this.config.WebSocketImpl,
4405
+ keyspaceId: profile.keyspace,
4406
+ getOwnerDid: () => auth.did,
4407
+ log
4408
+ });
4409
+ binding.engine = engine;
4410
+ binding.syncDb = new SyncDb(engine, OWN_SUB);
4411
+ binding.cleanup.push(
4412
+ engine.on("status", () => this.publish()),
4413
+ engine.on("change", () => this.publish()),
4414
+ engine.on("rejected", ({ rejection }) => {
4415
+ log("op rejected:", rejection.error, rejection.op);
4416
+ this.publish();
4417
+ }),
4418
+ engine.on("revoked", ({ code, message }) => {
4419
+ log("connection revoked:", code, message);
4420
+ void auth.reconcileSession("connection revoked", { forceRefresh: true, throttleMs: 0 }).catch(() => {
4421
+ });
4422
+ this.publish();
4423
+ })
3039
4424
  );
3040
- return;
4425
+ } else {
4426
+ binding.engine = null;
4427
+ binding.syncDb = null;
3041
4428
  }
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
3052
- });
3053
- return;
4429
+ return binding;
4430
+ }
4431
+ /**
4432
+ * Bind and boot a profile. Publishes the new binding first so React
4433
+ * subscriptions re-attach to the new db, then tears the old binding down
4434
+ * on the next tick (avoids in-flight live queries hitting a closed store).
4435
+ */
4436
+ async activateProfile(profile, options) {
4437
+ let old = null;
4438
+ if (options?.initial && this.bindingMatches(profile)) {
4439
+ this.binding.profile = profile;
4440
+ } else {
4441
+ old = this.binding;
4442
+ this.binding = this.createBinding(profile);
4443
+ if (options?.initial) {
4444
+ for (const fn of [...old.cleanup, ...old.sessionCleanup]) fn();
4445
+ old.engine?.stop();
4446
+ old.auth.destroy();
4447
+ old = null;
4448
+ }
3054
4449
  }
3055
- setSchemaDevInfo({
3056
- projectId: s.project_id ?? null,
3057
- localVersion: s.version,
3058
- status: result.schemaStatus.status ?? "unknown",
3059
- valid: result.schemaStatus.valid,
3060
- lastCheckedAt: Date.now()
3061
- });
3062
- }, [project_id]);
3063
- useEffect(() => {
3064
- const runVersionUpdater = async () => {
3065
- try {
3066
- const versionUpdater = createVersionUpdater(
3067
- storageAdapter,
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`);
4450
+ await this.binding.auth.initialize();
4451
+ this.binding.sessionCleanup.push(this.binding.auth.setupNetworkListeners());
4452
+ this.mounts.clear();
4453
+ this.syncLifecycle();
4454
+ this.publish();
4455
+ if (old) {
4456
+ setTimeout(() => {
4457
+ try {
4458
+ for (const fn of [...old.cleanup, ...old.sessionCleanup]) fn();
4459
+ old.engine?.stop();
4460
+ old.auth.destroy();
4461
+ } catch {
3078
4462
  }
3079
- } catch (error2) {
3080
- log("Version update failed:", error2);
4463
+ }, 50);
4464
+ }
4465
+ }
4466
+ bindingMatches(profile) {
4467
+ return this.binding.profile.storagePrefix === profile.storagePrefix && this.binding.profile.keyspace === profile.keyspace;
4468
+ }
4469
+ /** After sign-out/disposal: resume on the next profile or a fresh anon one. */
4470
+ async activateNextProfileLocked() {
4471
+ let next = null;
4472
+ if (this.users) {
4473
+ next = await this.users.resolveActive();
4474
+ if (!next && this.anonymousEnabled) {
4475
+ next = await this.users.createAnon();
3081
4476
  }
3082
- };
3083
- runVersionUpdater();
3084
- authRef.current.initialize();
3085
- return authRef.current.setupNetworkListeners();
3086
- }, []);
3087
- useEffect(() => {
3088
- async function initSyncDb(options) {
3089
- if (!syncRef.current) {
3090
- log("Initializing Basic Sync DB");
3091
- await initDexieExtensions();
3092
- syncRef.current = new BasicSync("basicdb", { schema });
3093
- syncRef.current.syncable.on("statusChanged", (status) => {
3094
- const newStatus = getSyncStatus(status);
3095
- setDbStatus(newStatus);
3096
- if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
3097
- log(
3098
- "Sync entered ERROR_WILL_RETRY - reconciling auth session before retry"
3099
- );
3100
- authRef.current.reconcileSession("sync retry", {
3101
- forceRefresh: true,
3102
- throttleMs: 0
3103
- }).catch(() => {
3104
- });
3105
- }
4477
+ if (next) this.users.setActiveId(next.id);
4478
+ }
4479
+ await this.activateProfile(next ?? ephemeralLegacyProfile());
4480
+ }
4481
+ /** Wipe a (non-active) profile's local footprint: keyspace dbs + auth keys. */
4482
+ async disposeProfileData(profile) {
4483
+ const storage = profile.storagePrefix ? new PrefixedStorage(this.rawStorage, profile.storagePrefix) : this.rawStorage;
4484
+ try {
4485
+ const refreshToken = await storage.get(STORAGE_KEYS.REFRESH_TOKEN);
4486
+ if (refreshToken) {
4487
+ await fetch(`${this.authConfig.pds_url}/auth/revoke`, {
4488
+ method: "POST",
4489
+ headers: { "Content-Type": "application/json" },
4490
+ body: JSON.stringify({ token: refreshToken, token_type_hint: "refresh_token" })
3106
4491
  });
3107
- if (options.shouldConnect) {
3108
- setShouldConnect(true);
3109
- } else {
3110
- log("Sync is disabled");
3111
- }
3112
- setIsDbReady(true);
3113
4492
  }
4493
+ } catch {
3114
4494
  }
3115
- function initRemoteDb() {
3116
- if (!remoteDbRef.current) {
3117
- if (!project_id) {
3118
- setError({
3119
- code: "missing_project_id",
3120
- title: "Project ID Required",
3121
- message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
3122
- });
3123
- setIsDbReady(true);
3124
- return;
3125
- }
3126
- log("Initializing Basic Remote DB");
3127
- remoteDbRef.current = new RemoteDB({
3128
- serverUrl: authConfig.pds_url,
3129
- projectId: project_id,
3130
- getToken: (opts) => authRef.current.getToken(opts),
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);
4495
+ for (const key of Object.values(STORAGE_KEYS)) {
4496
+ try {
4497
+ await storage.remove(key);
4498
+ } catch {
3149
4499
  }
3150
4500
  }
3151
- async function checkSchema() {
3152
- const result = await validateAndCheckSchema(schema);
3153
- if (!result.isValid) {
3154
- let errorMessage = "";
3155
- if (result.errors) {
3156
- result.errors.forEach((err, index) => {
3157
- errorMessage += `${index + 1}: ${err.message} - at ${err.instancePath}
3158
- `;
3159
- });
4501
+ await this.deleteKeyspaceDatabases(profile.keyspace);
4502
+ }
4503
+ async deleteKeyspaceDatabases(keyspace) {
4504
+ if (!this.projectId) return;
4505
+ const base = keyspace ? `basic-sync:${this.projectId}:${keyspace}` : `basic-sync:${this.projectId}`;
4506
+ try {
4507
+ const idb = globalThis.indexedDB;
4508
+ if (!idb) return;
4509
+ const names = [base];
4510
+ if (typeof idb.databases === "function") {
4511
+ const dbs = await idb.databases();
4512
+ for (const info of dbs) {
4513
+ if (info.name && info.name.startsWith(`${base}:share:`)) names.push(info.name);
3160
4514
  }
3161
- setSchemaDevInfo({
3162
- projectId: schema?.project_id ?? null,
3163
- localVersion: schema?.version,
3164
- status: "invalid",
3165
- valid: false,
3166
- lastCheckedAt: Date.now(),
3167
- error: errorMessage.trim() || void 0
3168
- });
3169
- setError({
3170
- code: "schema_invalid",
3171
- title: "Basic Schema is invalid!",
3172
- message: errorMessage
4515
+ }
4516
+ for (const name of names) {
4517
+ await new Promise((resolve) => {
4518
+ const req = idb.deleteDatabase(name);
4519
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
3173
4520
  });
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();
3186
- } else {
3187
- if (result.schemaStatus.valid) {
3188
- await initSyncDb({ shouldConnect: true });
3189
- } else {
3190
- if (result.schemaStatus.status === "unpublished") {
3191
- log(
3192
- "Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
3193
- );
3194
- } else {
3195
- log("Schema is invalid!", result.schemaStatus);
3196
- }
3197
- await initSyncDb({ shouldConnect: false });
3198
- }
3199
4521
  }
3200
- checkForNewVersion();
4522
+ } catch {
3201
4523
  }
3202
- if (schema) {
3203
- checkSchema();
3204
- } else {
3205
- setSchemaDevInfo(
3206
- project_id ? {
3207
- projectId: project_id,
3208
- localVersion: void 0,
3209
- status: "no_schema",
3210
- valid: false,
3211
- lastCheckedAt: Date.now()
3212
- } : null
3213
- );
3214
- if (dbMode === "remote" && project_id) {
3215
- initRemoteDb();
3216
- } else {
3217
- setIsDbReady(true);
3218
- }
4524
+ }
4525
+ // -------------------------------------------------------------------
4526
+ // Orchestration
4527
+ // -------------------------------------------------------------------
4528
+ /** Previous auth status, for transition detection (revoked-latch clearing). */
4529
+ lastAuthStatus = null;
4530
+ handleAuthChange() {
4531
+ const status = this.auth.authStatus;
4532
+ if (status === "authenticated" && this.lastAuthStatus !== "authenticated" && this.engine?.status === "revoked") {
4533
+ this.engine.clearRevoked();
3219
4534
  }
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
- });
4535
+ this.lastAuthStatus = status;
4536
+ if (status === "signed_out" && !this.signOutInProgress && this.started) {
4537
+ const profile = this.binding.profile;
4538
+ if (this.users && profile.kind === "account") {
4539
+ void this.queueProfileOp(async () => {
4540
+ if (this.binding.profile.id !== profile.id) return;
4541
+ if (this.binding.auth.authStatus !== "signed_out") return;
4542
+ this.mounts.clear();
4543
+ try {
4544
+ await this.binding.engine?.destroyLocal();
4545
+ } catch {
4546
+ }
4547
+ await this.users.remove(profile.id);
4548
+ await this.activateNextProfileLocked();
4549
+ await this.refreshUsers();
4550
+ });
4551
+ return;
4552
+ }
3230
4553
  }
3231
- }, [
3232
- authState.authStatus,
3233
- authState.isSignedIn,
3234
- authState.hasToken,
3235
- shouldConnect
3236
- ]);
3237
- useEffect(() => {
3238
- if (authState.authStatus !== "reauth_required" || !syncRef.current) {
4554
+ this.syncLifecycle();
4555
+ void this.maybeUpgradeProfile();
4556
+ this.publish();
4557
+ }
4558
+ /**
4559
+ * Drive the engine from auth + schema state:
4560
+ * - local keyspace opens with no token (anonymous mode / offline cold start)
4561
+ * - connect when a session exists (recovering counts the connection
4562
+ * retries token acquisition itself)
4563
+ * - reauth_required pauses the connection, keeps local data usable
4564
+ */
4565
+ syncLifecycle() {
4566
+ const { engine, auth, profile } = this.binding;
4567
+ if (!engine || !this.started) return;
4568
+ const status = auth.authStatus;
4569
+ if (status === "reauth_required") {
4570
+ engine.pause();
3239
4571
  return;
3240
4572
  }
3241
- log("Auth requires reauthentication - disconnecting sync without deleting local DB");
3242
- setDbStatus("ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */);
3243
- syncRef.current.disconnect({ ws_url: authConfig.ws_url }).catch((disconnectError) => {
3244
- log("Error disconnecting sync after auth invalidation:", disconnectError);
3245
- });
3246
- }, [authConfig.ws_url, authState.authStatus]);
3247
- const handleSignOut = async () => {
3248
- await authRef.current.signOut();
3249
- if (syncRef.current) {
3250
- try {
3251
- await syncRef.current.close();
3252
- await syncRef.current.delete({ disableAutoOpen: false });
3253
- syncRef.current = null;
3254
- } catch (error2) {
3255
- console.error("Error during database cleanup:", error2);
4573
+ const localAllowed = this.anonymousEnabled || auth.isSignedIn || profile.kind === "account";
4574
+ if (!localAllowed) return;
4575
+ const schemaLocallyUsable = this.devInfo === null || this.devInfo.status !== "invalid";
4576
+ if (!schemaLocallyUsable) return;
4577
+ void engine.openLocal().then(() => {
4578
+ if (this.binding.engine !== engine) return;
4579
+ if (this.syncEnabled && auth.isSignedIn && auth.authStatus !== "reauth_required") {
4580
+ return engine.connect();
3256
4581
  }
4582
+ }).catch((err) => log("sync lifecycle failed:", err));
4583
+ }
4584
+ /**
4585
+ * After sign-in: bind the account identity to the active profile
4586
+ * (anonymous → account upgrade) and dedupe against an existing profile
4587
+ * for the same DID.
4588
+ */
4589
+ async maybeUpgradeProfile() {
4590
+ const { profile, auth } = this.binding;
4591
+ if (!this.users || auth.authStatus !== "authenticated" || !auth.did) return;
4592
+ if (profile.id === "default") return;
4593
+ const did = auth.did;
4594
+ const user = auth.user;
4595
+ const needsUpdate = profile.did !== did || profile.kind !== "account" || profile.email !== (user?.email ?? profile.email) || profile.name !== (user?.name ?? profile.name);
4596
+ if (!needsUpdate) return;
4597
+ await this.queueProfileOp(async () => {
4598
+ if (this.binding.profile.id !== profile.id) return;
4599
+ if (!this.users) return;
4600
+ const existing = await this.users.findByDid(did);
4601
+ if (existing && existing.id !== profile.id) {
4602
+ log(`deduping user profiles for ${did}: dropping ${existing.id}`);
4603
+ await this.disposeProfileData(existing);
4604
+ await this.users.remove(existing.id);
4605
+ }
4606
+ const updated = await this.users.update(profile.id, {
4607
+ kind: "account",
4608
+ did,
4609
+ email: user?.email ?? profile.email ?? null,
4610
+ name: user?.name ?? profile.name ?? null,
4611
+ picture: user?.picture ?? profile.picture ?? null,
4612
+ lastActiveAt: Date.now()
4613
+ });
4614
+ if (updated) {
4615
+ this.binding.profile = updated;
4616
+ }
4617
+ await this.refreshUsers();
4618
+ });
4619
+ }
4620
+ async refreshUsers() {
4621
+ if (this.users) {
4622
+ this.usersCache = await this.users.list();
3257
4623
  }
3258
- if (typeof window !== "undefined") {
3259
- window.location.reload();
4624
+ this.publish();
4625
+ }
4626
+ queueProfileOp(task) {
4627
+ this.profileOps = this.profileOps.then(task).catch((err) => {
4628
+ log("profile operation failed:", err);
4629
+ });
4630
+ return this.profileOps;
4631
+ }
4632
+ async checkSchema() {
4633
+ if (this.schemaChecked) return;
4634
+ const schema = this.config.schema;
4635
+ if (!schema) {
4636
+ this.devInfo = this.projectId ? {
4637
+ projectId: this.projectId,
4638
+ localVersion: void 0,
4639
+ status: "no_schema",
4640
+ valid: false,
4641
+ lastCheckedAt: Date.now()
4642
+ } : null;
4643
+ this.syncEnabled = false;
4644
+ this.publish();
4645
+ return;
3260
4646
  }
3261
- };
3262
- const handleSignIn = async () => {
3263
4647
  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
- });
4648
+ const result = await validateAndCheckSchema(schema);
4649
+ if (!result.isValid) {
4650
+ const errText = result.errors?.map((e) => e.message || "").join("; ") || "invalid";
4651
+ this.devInfo = {
4652
+ projectId: schema.project_id ?? null,
4653
+ localVersion: schema.version,
4654
+ status: "invalid",
4655
+ valid: false,
4656
+ lastCheckedAt: Date.now(),
4657
+ error: errText
4658
+ };
4659
+ this.syncEnabled = false;
4660
+ } else {
4661
+ const status = result.schemaStatus.status ?? "unknown";
4662
+ this.devInfo = {
4663
+ projectId: schema.project_id ?? null,
4664
+ localVersion: schema.version,
4665
+ status,
4666
+ valid: result.schemaStatus.valid,
4667
+ lastCheckedAt: Date.now()
4668
+ };
4669
+ const locallyPublishable = typeof schema.version === "number" && schema.version > 0;
4670
+ const remoteCheckInconclusive = status === "error" || status === "unknown";
4671
+ this.syncEnabled = result.schemaStatus.valid || remoteCheckInconclusive && locallyPublishable;
4672
+ if (!result.schemaStatus.valid) {
4673
+ if (status === "unpublished") {
4674
+ log("Schema not published (version 0) \u2014 sync is disabled, local-only mode.");
4675
+ } else if (remoteCheckInconclusive && locallyPublishable) {
4676
+ log("Schema registry check failed \u2014 proceeding with the local schema (offline-first).");
4677
+ }
4678
+ }
3272
4679
  }
3273
- throw error2;
4680
+ } catch (err) {
4681
+ log("schema check failed:", err);
4682
+ this.syncEnabled = !!schema.version && schema.version > 0;
4683
+ this.devInfo = {
4684
+ projectId: schema.project_id ?? null,
4685
+ localVersion: schema.version,
4686
+ status: "unknown",
4687
+ valid: this.syncEnabled,
4688
+ lastCheckedAt: Date.now()
4689
+ };
3274
4690
  }
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
- });
4691
+ this.schemaChecked = true;
4692
+ this.publish();
4693
+ }
4694
+ buildSnapshot() {
4695
+ const { auth, engine, profile } = this.binding;
4696
+ return {
4697
+ isReady: auth.isAuthReady,
4698
+ isSignedIn: auth.isSignedIn,
4699
+ authStatus: auth.authStatus,
4700
+ authErrorCode: auth.authErrorCode,
4701
+ user: auth.user,
4702
+ did: auth.did,
4703
+ scope: auth.tokenScope,
4704
+ syncStatus: engine?.status ?? "idle",
4705
+ pendingCount: engine?.pendingCount ?? 0,
4706
+ syncEnabled: this.syncEnabled,
4707
+ devInfo: this.devInfo,
4708
+ mode: this.mode,
4709
+ users: this.usersCache,
4710
+ activeUser: this.users ? profile : null,
4711
+ isAnonymous: this.users ? profile.kind === "anon" && !auth.isSignedIn : false
4712
+ };
4713
+ }
4714
+ publish() {
4715
+ this.snapshot = this.buildSnapshot();
4716
+ for (const listener of this.listeners) {
4717
+ try {
4718
+ listener();
4719
+ } catch {
3286
4720
  }
3287
- throw error2;
3288
- }
3289
- };
3290
- const getCurrentDb = () => {
3291
- if (dbMode === "remote") {
3292
- return remoteDbRef.current || noDb;
3293
4721
  }
3294
- return syncRef.current || noDb;
3295
- };
3296
- const contextValue = {
3297
- isReady: authState.isAuthReady,
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
- ] });
4722
+ }
4723
+ };
4724
+ function createBasicClient(config) {
4725
+ return new BasicClient(config);
3328
4726
  }
3329
- function ErrorDisplay({ error }) {
3330
- return /* @__PURE__ */ jsxs2(
3331
- "div",
3332
- {
3333
- style: {
3334
- position: "absolute",
3335
- top: 20,
3336
- left: 20,
3337
- color: "black",
3338
- backgroundColor: "#f8d7da",
3339
- border: "1px solid #f5c6cb",
3340
- borderRadius: "4px",
3341
- padding: "20px",
3342
- maxWidth: "400px",
3343
- margin: "20px auto",
3344
- boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
3345
- fontFamily: "monospace"
3346
- },
3347
- children: [
3348
- /* @__PURE__ */ jsxs2("h3", { style: { fontSize: "0.8rem", opacity: 0.8 }, children: [
3349
- "code: ",
3350
- error.code
3351
- ] }),
3352
- /* @__PURE__ */ jsx2("h1", { style: { fontSize: "1.2rem", lineHeight: 1.5 }, children: error.title }),
3353
- /* @__PURE__ */ jsx2("p", { children: error.message })
3354
- ]
3355
- }
3356
- );
4727
+
4728
+ // src/react/BasicProvider.tsx
4729
+ init_network();
4730
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
4731
+ var BasicDevToolbar2 = lazy(
4732
+ () => Promise.resolve().then(() => (init_BasicDevToolbar(), BasicDevToolbar_exports)).then((m) => ({ default: m.BasicDevToolbar }))
4733
+ );
4734
+ function BasicProvider({
4735
+ children,
4736
+ schema,
4737
+ project_id,
4738
+ auth,
4739
+ storage,
4740
+ debug = false,
4741
+ mode = "sync",
4742
+ anonymous = true,
4743
+ devToolbar = false,
4744
+ renderWhileLoading = false
4745
+ }) {
4746
+ const clientRef = useRef(null);
4747
+ if (!clientRef.current) {
4748
+ clientRef.current = new BasicClient({
4749
+ schema,
4750
+ project_id,
4751
+ auth,
4752
+ storage,
4753
+ debug,
4754
+ mode,
4755
+ anonymous
4756
+ });
4757
+ }
4758
+ const client = clientRef.current;
4759
+ useEffect2(() => {
4760
+ void client.start();
4761
+ void checkForNewVersion();
4762
+ return () => client.stop();
4763
+ }, []);
4764
+ const snapshot = useSyncExternalStore2(client.subscribe, client.getSnapshot, client.getSnapshot);
4765
+ const showDevTools = devToolbar && isDevelopment(debug);
4766
+ const ready = snapshot.isReady;
4767
+ return /* @__PURE__ */ jsxs2(BasicClientContext.Provider, { value: client, children: [
4768
+ showDevTools && /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(BasicDevToolbar2, { debug }) }),
4769
+ (ready || renderWhileLoading) && children
4770
+ ] });
3357
4771
  }
3358
4772
 
3359
4773
  // src/index.ts
4774
+ init_hooks();
3360
4775
  init_BasicDevToolbar();
3361
- import { useLiveQuery as useQuery } from "dexie-react-hooks";
3362
4776
  export {
4777
+ AuthManager,
4778
+ BasicClient,
3363
4779
  BasicDevToolbar,
3364
4780
  BasicProvider,
3365
- DBStatus,
4781
+ DEFAULT_LIMITS,
4782
+ LocalStorageAdapter,
3366
4783
  NotAuthenticatedError,
3367
- RemoteCollection,
3368
- RemoteDB,
3369
- RemoteDBError,
4784
+ OWN_SUB,
4785
+ PROTOCOL_VERSION,
4786
+ PrefixedStorage,
4787
+ RestClient,
4788
+ RestDb,
4789
+ RestError,
3370
4790
  STORAGE_KEYS,
4791
+ SyncConnection,
4792
+ SyncDb,
4793
+ SyncEngine,
4794
+ SyncStore,
4795
+ UserRegistry,
4796
+ applyOpToData,
4797
+ createBasicClient,
4798
+ isAuthError,
4799
+ isRebootstrapError,
4800
+ isRevocationError,
4801
+ isTerminalOpError,
4802
+ mintOpId,
3371
4803
  resolveDid,
3372
4804
  resolveDidWebUrl,
3373
4805
  resolveHandle,
4806
+ shareSubKey,
4807
+ useAuth,
3374
4808
  useBasic,
3375
- useQuery
4809
+ useBasicClient,
4810
+ useDb,
4811
+ useQuery,
4812
+ useShare,
4813
+ useShares,
4814
+ useSyncStatus,
4815
+ useUsers
3376
4816
  };
3377
4817
  //# sourceMappingURL=index.mjs.map