@basictech/react 0.9.0-beta.0 → 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.js CHANGED
@@ -60,7 +60,7 @@ var init_config = __esm({
60
60
  var version;
61
61
  var init_package = __esm({
62
62
  "package.json"() {
63
- version = "0.9.0-beta.0";
63
+ version = "0.9.0-beta.1";
64
64
  }
65
65
  });
66
66
 
@@ -159,6 +159,22 @@ var init_network = __esm({
159
159
  });
160
160
 
161
161
  // src/react/hooks.ts
162
+ function useQuery(querier, deps = []) {
163
+ const client = (0, import_react2.useContext)(BasicClientContext);
164
+ const activeUserId = (0, import_react2.useSyncExternalStore)(
165
+ client ? client.subscribe : noopSubscribe,
166
+ () => client?.getSnapshot().activeUser?.id ?? null,
167
+ () => null
168
+ );
169
+ return (0, import_dexie_react_hooks.useLiveQuery)(async () => {
170
+ try {
171
+ return await querier();
172
+ } catch (err) {
173
+ if (err instanceof Error && err.name === "DatabaseClosedError") return void 0;
174
+ throw err;
175
+ }
176
+ }, [...deps, activeUserId]);
177
+ }
162
178
  function useBasicClient() {
163
179
  const client = (0, import_react2.useContext)(BasicClientContext);
164
180
  if (!client) {
@@ -176,6 +192,7 @@ function useAuth() {
176
192
  () => ({
177
193
  isReady: snapshot.isReady,
178
194
  isSignedIn: snapshot.isSignedIn,
195
+ isAnonymous: snapshot.isAnonymous,
179
196
  status: snapshot.authStatus,
180
197
  errorCode: snapshot.authErrorCode,
181
198
  user: snapshot.user,
@@ -232,9 +249,11 @@ function useShares() {
232
249
  },
233
250
  [client]
234
251
  );
252
+ const activeUserId = snapshot.activeUser?.id ?? null;
235
253
  (0, import_react2.useEffect)(() => {
236
254
  if (isSignedIn) void refresh();
237
- }, [isSignedIn, refresh]);
255
+ else setState({ granted: [], received: [], isLoading: false, error: null });
256
+ }, [isSignedIn, refresh, activeUserId]);
238
257
  return { ...state, refresh };
239
258
  }
240
259
  function useShare(shareId) {
@@ -244,6 +263,7 @@ function useShare(shareId) {
244
263
  const [error, setError] = (0, import_react2.useState)(null);
245
264
  const [revoked, setRevoked] = (0, import_react2.useState)(false);
246
265
  const canMount = !!shareId && snapshot.isSignedIn && snapshot.authStatus !== "reauth_required";
266
+ const activeUserId = snapshot.activeUser?.id ?? null;
247
267
  (0, import_react2.useEffect)(() => {
248
268
  if (!canMount || !shareId) return;
249
269
  let cancelled = false;
@@ -267,13 +287,28 @@ function useShare(shareId) {
267
287
  void client.unmountShare(shareId).catch(() => {
268
288
  });
269
289
  };
270
- }, [client, shareId, canMount]);
290
+ }, [client, shareId, canMount, activeUserId]);
271
291
  return {
272
292
  db: handle?.db ?? null,
273
293
  status: revoked ? "revoked" : error ? "error" : handle ? "mounted" : "mounting",
274
294
  error
275
295
  };
276
296
  }
297
+ function useUsers() {
298
+ const client = useBasicClient();
299
+ const snapshot = useClientSnapshot(client);
300
+ return (0, import_react2.useMemo)(
301
+ () => ({
302
+ users: snapshot.users,
303
+ activeUser: snapshot.activeUser,
304
+ isAnonymous: snapshot.isAnonymous,
305
+ switchUser: (id) => client.switchUser(id),
306
+ addUser: () => client.addUser(),
307
+ removeUser: (id) => client.removeUser(id)
308
+ }),
309
+ [client, snapshot]
310
+ );
311
+ }
277
312
  function useBasic() {
278
313
  const client = useBasicClient();
279
314
  const snapshot = useClientSnapshot(client);
@@ -284,6 +319,8 @@ function useBasic() {
284
319
  ...auth,
285
320
  db: client.db,
286
321
  sync,
322
+ users: snapshot.users,
323
+ activeUser: snapshot.activeUser,
287
324
  devInfo: snapshot.devInfo,
288
325
  refreshSchemaStatus: () => client.refreshSchemaStatus(),
289
326
  client
@@ -291,14 +328,15 @@ function useBasic() {
291
328
  [client, snapshot, auth, sync]
292
329
  );
293
330
  }
294
- var import_react2, import_dexie_react_hooks, useQuery;
331
+ var import_react2, import_dexie_react_hooks, noopSubscribe;
295
332
  var init_hooks = __esm({
296
333
  "src/react/hooks.ts"() {
297
334
  "use strict";
298
335
  import_react2 = require("react");
299
336
  import_dexie_react_hooks = require("dexie-react-hooks");
300
337
  init_context();
301
- useQuery = import_dexie_react_hooks.useLiveQuery;
338
+ noopSubscribe = () => () => {
339
+ };
302
340
  }
303
341
  });
304
342
 
@@ -316,7 +354,8 @@ function toneForSync(mode, status) {
316
354
  if (mode === "rest") return "muted";
317
355
  if (status === "online") return "ok";
318
356
  if (status === "connecting") return "warn";
319
- if (status === "offline" || status === "idle" || status === "stopped") return "muted";
357
+ if (status === "offline" || status === "idle" || status === "stopped" || status === "local")
358
+ return "muted";
320
359
  return "bad";
321
360
  }
322
361
  function toneForSchema(info) {
@@ -330,6 +369,8 @@ function syncStatusLabel(status) {
330
369
  switch (status) {
331
370
  case "idle":
332
371
  return "Idle";
372
+ case "local":
373
+ return "Local only";
333
374
  case "connecting":
334
375
  return "Connecting";
335
376
  case "online":
@@ -506,18 +547,22 @@ function BasicDevToolbar({ enabled = true, debug }) {
506
547
  const {
507
548
  isReady,
508
549
  isSignedIn,
550
+ isAnonymous,
509
551
  user,
510
552
  did,
511
553
  scope,
512
554
  missingScopes,
513
555
  sync,
556
+ users,
557
+ activeUser,
514
558
  devInfo,
515
559
  refreshSchemaStatus,
516
560
  client
517
561
  } = useBasic();
518
562
  const dbMode = client.mode;
519
563
  const syncStatus = sync.status;
520
- const indexedDbName = dbMode === "sync" && client.projectId ? `basic-sync:${client.projectId}` : null;
564
+ const indexedDbName = dbMode === "sync" && client.projectId ? `basic-sync:${client.projectId}${activeUser?.keyspace ? `:${activeUser.keyspace}` : ""}` : null;
565
+ 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";
521
566
  const [open, setOpen] = (0, import_react3.useState)(false);
522
567
  const [refreshing, setRefreshing] = (0, import_react3.useState)(false);
523
568
  const [copied, setCopied] = (0, import_react3.useState)(false);
@@ -554,9 +599,11 @@ function BasicDevToolbar({ enabled = true, debug }) {
554
599
  syncStatus,
555
600
  pendingOps: sync.pendingCount,
556
601
  indexedDbName,
602
+ activeUser: activeUser ? { id: activeUser.id, kind: activeUser.kind, did: activeUser.did } : null,
603
+ userCount: users.length,
557
604
  schema: devInfo
558
605
  };
559
- }, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, devInfo, missingList]);
606
+ }, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, activeUser, users.length, devInfo, missingList]);
560
607
  const handleCopy = (0, import_react3.useCallback)(async () => {
561
608
  try {
562
609
  await navigator.clipboard.writeText(JSON.stringify(debugPayload, null, 2));
@@ -694,6 +741,17 @@ function BasicDevToolbar({ enabled = true, debug }) {
694
741
  children: user ? displayUserLine(user) : "\u2014"
695
742
  }
696
743
  ),
744
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
745
+ CopyableRow,
746
+ {
747
+ rowKey: "activeProfile",
748
+ label: "Profile",
749
+ copyText: activeUser?.id ?? "",
750
+ copiedKey: rowCopied,
751
+ onCopied: onRowCopied,
752
+ children: activeUserLabel
753
+ }
754
+ ),
697
755
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
698
756
  CopyableRow,
699
757
  {
@@ -943,6 +1001,7 @@ __export(index_exports, {
943
1001
  NotAuthenticatedError: () => NotAuthenticatedError,
944
1002
  OWN_SUB: () => OWN_SUB,
945
1003
  PROTOCOL_VERSION: () => PROTOCOL_VERSION,
1004
+ PrefixedStorage: () => PrefixedStorage,
946
1005
  RestClient: () => RestClient,
947
1006
  RestDb: () => RestDb,
948
1007
  RestError: () => RestError,
@@ -951,6 +1010,7 @@ __export(index_exports, {
951
1010
  SyncDb: () => SyncDb,
952
1011
  SyncEngine: () => SyncEngine,
953
1012
  SyncStore: () => SyncStore,
1013
+ UserRegistry: () => UserRegistry,
954
1014
  applyOpToData: () => applyOpToData,
955
1015
  createBasicClient: () => createBasicClient,
956
1016
  isAuthError: () => isAuthError,
@@ -969,7 +1029,8 @@ __export(index_exports, {
969
1029
  useQuery: () => useQuery,
970
1030
  useShare: () => useShare,
971
1031
  useShares: () => useShares,
972
- useSyncStatus: () => useSyncStatus
1032
+ useSyncStatus: () => useSyncStatus,
1033
+ useUsers: () => useUsers
973
1034
  });
974
1035
  module.exports = __toCommonJS(index_exports);
975
1036
 
@@ -1156,11 +1217,15 @@ var AuthManager = class {
1156
1217
  this.requestedScopes = config.scopes;
1157
1218
  this.initCrossTabSync();
1158
1219
  }
1220
+ get instanceKey() {
1221
+ return this.config.instanceKey ?? "";
1222
+ }
1159
1223
  initCrossTabSync() {
1160
1224
  if (typeof BroadcastChannel === "undefined") return;
1161
1225
  try {
1162
1226
  this.channel = new BroadcastChannel("basic-auth");
1163
1227
  this.channel.onmessage = (event) => {
1228
+ if ((event.data?.userKey ?? "") !== this.instanceKey) return;
1164
1229
  if (event.data?.type === "token_refreshed") {
1165
1230
  log("Received token refresh from another tab");
1166
1231
  void this.handleExternalTokenRefresh(event.data);
@@ -1188,19 +1253,32 @@ var AuthManager = class {
1188
1253
  broadcastTokenRefresh() {
1189
1254
  this.channel?.postMessage({
1190
1255
  type: "token_refreshed",
1256
+ userKey: this.instanceKey,
1191
1257
  accessToken: this.token?.access_token,
1192
1258
  did: this.did,
1193
1259
  tokenScope: this.tokenScope
1194
1260
  });
1195
1261
  }
1196
1262
  broadcastSignIn() {
1197
- this.channel?.postMessage({ type: "signed_in" });
1263
+ this.channel?.postMessage({ type: "signed_in", userKey: this.instanceKey });
1198
1264
  }
1199
1265
  broadcastSignOut() {
1200
- this.channel?.postMessage({ type: "signed_out" });
1266
+ this.channel?.postMessage({ type: "signed_out", userKey: this.instanceKey });
1201
1267
  }
1202
1268
  broadcastSessionInvalidated(code) {
1203
- this.channel?.postMessage({ type: "session_invalidated", code });
1269
+ this.channel?.postMessage({
1270
+ type: "session_invalidated",
1271
+ userKey: this.instanceKey,
1272
+ code
1273
+ });
1274
+ }
1275
+ /** Release resources (cross-tab channel). Used when switching users. */
1276
+ destroy() {
1277
+ try {
1278
+ this.channel?.close();
1279
+ } catch {
1280
+ }
1281
+ this.channel = null;
1204
1282
  }
1205
1283
  // ------------------------------------------------------------------
1206
1284
  // Public API
@@ -2628,6 +2706,7 @@ var SyncConnection = class {
2628
2706
  var import_dexie = __toESM(require("dexie"));
2629
2707
  var META_CURSOR = "cursor";
2630
2708
  var META_CHANNEL = "channel";
2709
+ var META_OWNER = "owner_did";
2631
2710
  var SyncStore = class {
2632
2711
  db;
2633
2712
  name;
@@ -2684,6 +2763,32 @@ var SyncStore = class {
2684
2763
  const row = await this.meta.get(META_CHANNEL);
2685
2764
  return typeof row?.value === "string" ? row.value : null;
2686
2765
  }
2766
+ /**
2767
+ * The account DID this keyspace's confirmed data belongs to. Absent for
2768
+ * anonymous-era data (which may be merged into whichever account signs in).
2769
+ */
2770
+ async getOwner() {
2771
+ const row = await this.meta.get(META_OWNER);
2772
+ return typeof row?.value === "string" ? row.value : null;
2773
+ }
2774
+ async setOwner(did) {
2775
+ await this.meta.put({ key: META_OWNER, value: did });
2776
+ }
2777
+ /**
2778
+ * Clear everything (views, server state, pending, rejected, meta) without
2779
+ * deleting the database — used when the keyspace changes owners.
2780
+ */
2781
+ async wipeAll() {
2782
+ await this.db.transaction("rw", this.allStores, async () => {
2783
+ await this.server.clear();
2784
+ await this.pending.clear();
2785
+ await this.rejected.clear();
2786
+ await this.meta.clear();
2787
+ for (const tableName of this.tableNames) {
2788
+ await this.view(tableName).clear();
2789
+ }
2790
+ });
2791
+ }
2687
2792
  // -------------------------------------------------------------------
2688
2793
  // Pending / rejected
2689
2794
  // -------------------------------------------------------------------
@@ -2896,7 +3001,11 @@ var SyncEngine = class {
2896
3001
  subs = /* @__PURE__ */ new Map();
2897
3002
  limits = { ...DEFAULT_LIMITS };
2898
3003
  actor = null;
2899
- started = false;
3004
+ /** Own-sub store is open (local reads/writes work). */
3005
+ storesOpen = false;
3006
+ /** A live connection is wanted (vs. local-only / paused). */
3007
+ connectIntended = false;
3008
+ openingLocal = null;
2900
3009
  revokedInfo = null;
2901
3010
  connectionStatus = "idle";
2902
3011
  _status = "idle";
@@ -2971,26 +3080,76 @@ var SyncEngine = class {
2971
3080
  // -------------------------------------------------------------------
2972
3081
  // Lifecycle
2973
3082
  // -------------------------------------------------------------------
2974
- /** Open the own-channel keyspace and connect. Idempotent. */
2975
- async start() {
2976
- if (this.started) {
2977
- if (this.connectionStatus === "auth_failed" || this._status === "auth_required") {
2978
- this.connection.start();
2979
- }
3083
+ /**
3084
+ * Open the own-channel keyspace for local reads/writes — no connection,
3085
+ * no token needed. This is the anonymous / offline-cold-start entry point.
3086
+ * Idempotent.
3087
+ */
3088
+ async openLocal() {
3089
+ if (this.subs.has(OWN_SUB)) {
3090
+ this.storesOpen = true;
3091
+ this.recomputeStatus();
2980
3092
  return;
2981
3093
  }
2982
- this.started = true;
2983
- this.revokedInfo = null;
2984
- if (!this.subs.has(OWN_SUB)) {
2985
- const sub = await this.openSub(OWN_SUB, null);
2986
- this.subs.set(OWN_SUB, sub);
3094
+ if (!this.openingLocal) {
3095
+ this.openingLocal = (async () => {
3096
+ const sub = await this.openSub(OWN_SUB, null);
3097
+ this.subs.set(OWN_SUB, sub);
3098
+ this.storesOpen = true;
3099
+ })().finally(() => {
3100
+ this.openingLocal = null;
3101
+ });
2987
3102
  }
3103
+ await this.openingLocal;
3104
+ this.recomputeStatus();
3105
+ }
3106
+ /**
3107
+ * Open the keyspace (if needed) and start syncing. Idempotent.
3108
+ * Note: a `CONNECTION_REVOKED` latch is NOT cleared here — reconnecting a
3109
+ * revoked app connection requires a fresh consent flow. Call
3110
+ * {@link clearRevoked} (or rebind the engine) after re-authorization.
3111
+ */
3112
+ async connect() {
3113
+ await this.openLocal();
3114
+ if (this.revokedInfo) {
3115
+ this.log("connect() ignored: app connection is revoked");
3116
+ return;
3117
+ }
3118
+ this.connectIntended = true;
2988
3119
  this.connection.start();
2989
3120
  this.recomputeStatus();
2990
3121
  }
3122
+ /** Clear the revocation latch (after the user re-authorized the app). */
3123
+ clearRevoked() {
3124
+ this.revokedInfo = null;
3125
+ this.recomputeStatus();
3126
+ }
3127
+ /** @deprecated alias of {@link connect} */
3128
+ async start() {
3129
+ return this.connect();
3130
+ }
3131
+ /**
3132
+ * Disconnect but keep stores open: local reads/writes keep working and
3133
+ * ops queue for the next connect. Used on reauth_required.
3134
+ */
3135
+ pause() {
3136
+ this.connectIntended = false;
3137
+ this.connection.stop();
3138
+ for (const t of this.timers) clearTimeout(t);
3139
+ this.timers.clear();
3140
+ for (const sub of this.subs.values()) {
3141
+ sub.active = false;
3142
+ for (const p of sub.pending) {
3143
+ p.sent = false;
3144
+ p.ackedSeq = void 0;
3145
+ }
3146
+ }
3147
+ this.recomputeStatus();
3148
+ }
2991
3149
  /** Close the socket and stores; local data is kept. */
2992
3150
  stop() {
2993
- this.started = false;
3151
+ this.connectIntended = false;
3152
+ this.storesOpen = false;
2994
3153
  this.connection.stop();
2995
3154
  for (const t of this.timers) clearTimeout(t);
2996
3155
  this.timers.clear();
@@ -3018,10 +3177,10 @@ var SyncEngine = class {
3018
3177
  try {
3019
3178
  const idb = globalThis.indexedDB;
3020
3179
  if (idb && typeof idb.databases === "function") {
3021
- const prefix = `${this.dbPrefix}:${this.projectId}`;
3180
+ const base = this.baseDbName;
3022
3181
  const dbs = await idb.databases();
3023
3182
  for (const info of dbs) {
3024
- if (info.name && info.name.startsWith(prefix)) {
3183
+ if (info.name && (info.name === base || info.name.startsWith(`${base}:share:`))) {
3025
3184
  await new Promise((resolve) => {
3026
3185
  const req = idb.deleteDatabase(info.name);
3027
3186
  req.onsuccess = req.onerror = req.onblocked = () => resolve();
@@ -3119,7 +3278,7 @@ var SyncEngine = class {
3119
3278
  // -------------------------------------------------------------------
3120
3279
  handleConnectionStatus(status) {
3121
3280
  this.connectionStatus = status;
3122
- if (status === "offline" || status === "connecting" || status === "auth_failed") {
3281
+ if (status === "offline" || status === "connecting" || status === "auth_failed" || status === "stopped") {
3123
3282
  for (const sub of this.subs.values()) {
3124
3283
  sub.active = false;
3125
3284
  for (const p of sub.pending) {
@@ -3207,13 +3366,13 @@ var SyncEngine = class {
3207
3366
  switch (msg.code) {
3208
3367
  case "CONNECTION_REVOKED":
3209
3368
  this.revokedInfo = { code: msg.code, message: msg.message };
3210
- this.started = false;
3369
+ this.connectIntended = false;
3211
3370
  this.connection.stop();
3212
3371
  this.recomputeStatus();
3213
3372
  this.emit("revoked", { code: msg.code, message: msg.message });
3214
3373
  return;
3215
3374
  case "UNSUPPORTED_VERSION":
3216
- this.started = false;
3375
+ this.connectIntended = false;
3217
3376
  this.connection.stop();
3218
3377
  this.recomputeStatus();
3219
3378
  return;
@@ -3248,7 +3407,7 @@ var SyncEngine = class {
3248
3407
  // Subscription state machine (serialized per sub via `chain`)
3249
3408
  // -------------------------------------------------------------------
3250
3409
  async openSub(key, shareId) {
3251
- const name = shareId ? `${this.dbPrefix}:${this.projectId}:share:${shareId}` : `${this.dbPrefix}:${this.projectId}`;
3410
+ const name = shareId ? `${this.baseDbName}:share:${shareId}` : this.baseDbName;
3252
3411
  const store = new SyncStore(name, this.schema);
3253
3412
  const [cursor, pendingRows] = await Promise.all([store.getCursor(), store.loadPending()]);
3254
3413
  return {
@@ -3269,6 +3428,16 @@ var SyncEngine = class {
3269
3428
  /** Bootstrap if needed, then bind the stream on the current socket. */
3270
3429
  async activateSub(sub) {
3271
3430
  if (!this.connection.isOnline) return;
3431
+ if (sub.bootstrapped && !sub.shareId && this.opts.getOwnerDid) {
3432
+ try {
3433
+ const did = await this.opts.getOwnerDid() ?? null;
3434
+ if (did) {
3435
+ const stamped = await sub.store.getOwner();
3436
+ if (stamped && stamped !== did) sub.bootstrapped = false;
3437
+ }
3438
+ } catch {
3439
+ }
3440
+ }
3272
3441
  if (!sub.bootstrapped || sub.cursor < 0) {
3273
3442
  try {
3274
3443
  await this.bootstrapSub(sub);
@@ -3287,6 +3456,26 @@ var SyncEngine = class {
3287
3456
  }
3288
3457
  /** Cold start = snapshot + tail; never log replay (§5). Pending survives. */
3289
3458
  async bootstrapSub(sub) {
3459
+ let ownerDid = null;
3460
+ if (!sub.shareId && this.opts.getOwnerDid) {
3461
+ try {
3462
+ ownerDid = await this.opts.getOwnerDid() ?? null;
3463
+ } catch {
3464
+ ownerDid = null;
3465
+ }
3466
+ if (ownerDid) {
3467
+ const stamped = await sub.store.getOwner();
3468
+ if (stamped && stamped !== ownerDid) {
3469
+ this.log(
3470
+ `keyspace owned by ${stamped} but session is ${ownerDid} \u2014 wiping local data before bootstrap`
3471
+ );
3472
+ await sub.store.wipeAll();
3473
+ sub.pending = [];
3474
+ sub.appliedOpIds.clear();
3475
+ sub.cursor = -1;
3476
+ }
3477
+ }
3478
+ }
3290
3479
  const snapshot = await this.opts.fetchSnapshot(
3291
3480
  sub.shareId ? { share: sub.shareId } : void 0
3292
3481
  );
@@ -3295,6 +3484,9 @@ var SyncEngine = class {
3295
3484
  records: snapshot.records ?? {},
3296
3485
  cursor: snapshot.cursor ?? 0
3297
3486
  });
3487
+ if (!sub.shareId && ownerDid) {
3488
+ await sub.store.setOwner(ownerDid);
3489
+ }
3298
3490
  sub.cursor = snapshot.cursor ?? 0;
3299
3491
  sub.bootstrapped = true;
3300
3492
  sub.appliedOpIds.clear();
@@ -3418,6 +3610,11 @@ var SyncEngine = class {
3418
3610
  get dbPrefix() {
3419
3611
  return this.opts.dbNamePrefix ?? "basic-sync";
3420
3612
  }
3613
+ /** Base database name for this keyspace (multi-user: includes the user id). */
3614
+ get baseDbName() {
3615
+ const base = `${this.dbPrefix}:${this.projectId}`;
3616
+ return this.opts.keyspaceId ? `${base}:${this.opts.keyspaceId}` : base;
3617
+ }
3421
3618
  enqueue(sub, task) {
3422
3619
  sub.chain = sub.chain.then(task).catch((err) => {
3423
3620
  this.log(`task failed on '${sub.key}':`, err);
@@ -3435,10 +3632,12 @@ var SyncEngine = class {
3435
3632
  let status;
3436
3633
  if (this.revokedInfo) status = "revoked";
3437
3634
  else if (this.connectionStatus === "auth_failed") status = "auth_required";
3438
- else if (!this.started) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
3635
+ else if (!this.storesOpen) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
3636
+ else if (!this.connectIntended) status = "local";
3439
3637
  else if (this.connectionStatus === "online") status = "online";
3440
3638
  else if (this.connectionStatus === "connecting") status = "connecting";
3441
3639
  else if (this.connectionStatus === "idle") status = "connecting";
3640
+ else if (this.connectionStatus === "stopped") status = "local";
3442
3641
  else status = "offline";
3443
3642
  if (status !== this._status) {
3444
3643
  this._status = status;
@@ -3464,7 +3663,7 @@ var SyncTable = class {
3464
3663
  const sub = this.engine.getSubscription(this.subKey);
3465
3664
  if (!sub) {
3466
3665
  throw new Error(
3467
- `subscription '${this.subKey}' is not open \u2014 sign in and wait for the db to be ready`
3666
+ `subscription '${this.subKey}' is not open \u2014 wait for the client to be ready (isReady) before using the db`
3468
3667
  );
3469
3668
  }
3470
3669
  return sub.store;
@@ -3594,6 +3793,171 @@ var RestDb = class {
3594
3793
  }
3595
3794
  };
3596
3795
 
3796
+ // src/core/users.ts
3797
+ init_config();
3798
+ var PrefixedStorage = class {
3799
+ constructor(inner, prefix) {
3800
+ this.inner = inner;
3801
+ this.prefix = prefix;
3802
+ }
3803
+ get(key) {
3804
+ return this.inner.get(this.prefix + key);
3805
+ }
3806
+ set(key, value) {
3807
+ return this.inner.set(this.prefix + key, value);
3808
+ }
3809
+ remove(key) {
3810
+ return this.inner.remove(this.prefix + key);
3811
+ }
3812
+ };
3813
+ function registryKey(projectId) {
3814
+ return `basic_users:${projectId}`;
3815
+ }
3816
+ function activeUserSessionKey(projectId) {
3817
+ return `basic_active_user:${projectId}`;
3818
+ }
3819
+ var UserRegistry = class {
3820
+ constructor(storage, projectId) {
3821
+ this.storage = storage;
3822
+ this.projectId = projectId;
3823
+ }
3824
+ // -------------------------------------------------------------------
3825
+ // Registry CRUD
3826
+ // -------------------------------------------------------------------
3827
+ async list() {
3828
+ const raw = await this.storage.get(registryKey(this.projectId));
3829
+ if (!raw) return [];
3830
+ try {
3831
+ const parsed = JSON.parse(raw);
3832
+ return Array.isArray(parsed) ? parsed : [];
3833
+ } catch {
3834
+ return [];
3835
+ }
3836
+ }
3837
+ async save(users) {
3838
+ await this.storage.set(registryKey(this.projectId), JSON.stringify(users));
3839
+ }
3840
+ async get(id) {
3841
+ const users = await this.list();
3842
+ return users.find((u) => u.id === id) ?? null;
3843
+ }
3844
+ async createAnon() {
3845
+ const id = mintOpId();
3846
+ const now = Date.now();
3847
+ const profile = {
3848
+ id,
3849
+ kind: "anon",
3850
+ keyspace: id,
3851
+ storagePrefix: `u:${id}:`,
3852
+ createdAt: now,
3853
+ lastActiveAt: now
3854
+ };
3855
+ const users = await this.list();
3856
+ users.push(profile);
3857
+ await this.save(users);
3858
+ log(`created anonymous user ${id}`);
3859
+ return profile;
3860
+ }
3861
+ async update(id, patch) {
3862
+ const users = await this.list();
3863
+ const idx = users.findIndex((u) => u.id === id);
3864
+ if (idx < 0) return null;
3865
+ users[idx] = { ...users[idx], ...patch };
3866
+ await this.save(users);
3867
+ return users[idx];
3868
+ }
3869
+ async remove(id) {
3870
+ const users = await this.list();
3871
+ await this.save(users.filter((u) => u.id !== id));
3872
+ if (this.getActiveIdRaw() === id) this.clearActiveId();
3873
+ }
3874
+ /** The profile (if any) already bound to an account DID. */
3875
+ async findByDid(did) {
3876
+ const users = await this.list();
3877
+ return users.find((u) => u.did === did) ?? null;
3878
+ }
3879
+ // -------------------------------------------------------------------
3880
+ // Active user (per-tab)
3881
+ // -------------------------------------------------------------------
3882
+ getActiveIdRaw() {
3883
+ try {
3884
+ return sessionStorage.getItem(activeUserSessionKey(this.projectId));
3885
+ } catch {
3886
+ return null;
3887
+ }
3888
+ }
3889
+ setActiveId(id) {
3890
+ try {
3891
+ sessionStorage.setItem(activeUserSessionKey(this.projectId), id);
3892
+ } catch {
3893
+ }
3894
+ }
3895
+ clearActiveId() {
3896
+ try {
3897
+ sessionStorage.removeItem(activeUserSessionKey(this.projectId));
3898
+ } catch {
3899
+ }
3900
+ }
3901
+ /**
3902
+ * Resolve the active profile for this tab: sessionStorage choice if it
3903
+ * still exists, else the most recently active profile, else null.
3904
+ */
3905
+ async resolveActive() {
3906
+ const users = await this.list();
3907
+ const activeId = this.getActiveIdRaw();
3908
+ if (activeId) {
3909
+ const match = users.find((u) => u.id === activeId);
3910
+ if (match) return match;
3911
+ }
3912
+ if (users.length === 0) return null;
3913
+ const recent = [...users].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0];
3914
+ this.setActiveId(recent.id);
3915
+ return recent;
3916
+ }
3917
+ async touch(id) {
3918
+ await this.update(id, { lastActiveAt: Date.now() });
3919
+ }
3920
+ // -------------------------------------------------------------------
3921
+ // Legacy adoption
3922
+ // -------------------------------------------------------------------
3923
+ /**
3924
+ * Adopt a pre-multi-user session as the first profile. Idempotent: runs
3925
+ * only when the registry is empty and a bare refresh token exists. The
3926
+ * adopted profile keeps the unprefixed storage keys and the legacy
3927
+ * keyspace name, so nothing needs to move.
3928
+ */
3929
+ async adoptLegacySession() {
3930
+ const users = await this.list();
3931
+ if (users.length > 0) return null;
3932
+ const legacyRefresh = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
3933
+ if (!legacyRefresh) return null;
3934
+ let cachedUser = null;
3935
+ try {
3936
+ const raw = await this.storage.get(STORAGE_KEYS.USER_INFO);
3937
+ if (raw) cachedUser = JSON.parse(raw);
3938
+ } catch {
3939
+ }
3940
+ const now = Date.now();
3941
+ const profile = {
3942
+ id: mintOpId(),
3943
+ kind: "account",
3944
+ did: cachedUser?.sub ?? null,
3945
+ email: cachedUser?.email ?? null,
3946
+ name: cachedUser?.name ?? null,
3947
+ picture: cachedUser?.picture ?? null,
3948
+ keyspace: "",
3949
+ // legacy `basic-sync:{projectId}` database
3950
+ storagePrefix: "",
3951
+ // legacy unprefixed auth keys
3952
+ createdAt: now,
3953
+ lastActiveAt: now
3954
+ };
3955
+ await this.save([profile]);
3956
+ log("adopted legacy single-user session as profile", profile.id);
3957
+ return profile;
3958
+ }
3959
+ };
3960
+
3597
3961
  // src/utils/schema.ts
3598
3962
  var import_schema2 = require("@basictech/schema");
3599
3963
  init_config();
@@ -3843,21 +4207,38 @@ var DEFAULTS = {
3843
4207
  function deriveSyncUrl(pdsUrl) {
3844
4208
  return pdsUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/sync/";
3845
4209
  }
4210
+ function ephemeralLegacyProfile() {
4211
+ const now = Date.now();
4212
+ return {
4213
+ id: "default",
4214
+ kind: "anon",
4215
+ keyspace: "",
4216
+ storagePrefix: "",
4217
+ createdAt: now,
4218
+ lastActiveAt: now
4219
+ };
4220
+ }
3846
4221
  var BasicClient = class {
3847
- auth;
3848
4222
  rest;
3849
- engine;
3850
4223
  mode;
3851
4224
  config;
3852
4225
  projectId;
3853
- syncDb;
4226
+ users;
4227
+ rawStorage;
3854
4228
  restDb;
3855
4229
  debug;
4230
+ anonymousEnabled;
4231
+ authConfig;
4232
+ syncUrl;
4233
+ binding;
4234
+ usersCache = [];
3856
4235
  devInfo = null;
3857
4236
  syncEnabled = false;
3858
4237
  schemaChecked = false;
3859
4238
  started = false;
3860
- cleanupFns = [];
4239
+ signOutInProgress = false;
4240
+ /** Serializes profile transitions (switch, dispose, sign-out fallthrough). */
4241
+ profileOps = Promise.resolve();
3861
4242
  mounts = /* @__PURE__ */ new Map();
3862
4243
  listeners = /* @__PURE__ */ new Set();
3863
4244
  snapshot;
@@ -3866,95 +4247,155 @@ var BasicClient = class {
3866
4247
  this.debug = config.debug ?? false;
3867
4248
  this.mode = config.mode ?? "sync";
3868
4249
  this.projectId = config.schema?.project_id || config.project_id;
3869
- const authConfig = {
4250
+ this.anonymousEnabled = this.mode === "sync" && (config.anonymous ?? true);
4251
+ this.authConfig = {
3870
4252
  scopes: Array.isArray(config.auth?.scopes) ? config.auth.scopes.join(" ") : config.auth?.scopes || DEFAULTS.scopes,
3871
4253
  pds_url: config.auth?.pds_url || DEFAULTS.pds_url,
3872
4254
  admin_url: config.auth?.admin_url || DEFAULTS.admin_url
3873
4255
  };
3874
- const syncUrl = config.auth?.sync_url || deriveSyncUrl(authConfig.pds_url);
3875
- const storage = config.storage || new LocalStorageAdapter();
3876
- this.auth = new AuthManager(
3877
- {
3878
- projectId: this.projectId,
3879
- scopes: authConfig.scopes,
3880
- pdsUrl: authConfig.pds_url,
3881
- adminUrl: authConfig.admin_url,
3882
- debug: this.debug
3883
- },
3884
- storage,
3885
- () => this.handleAuthChange()
3886
- );
4256
+ this.syncUrl = config.auth?.sync_url || deriveSyncUrl(this.authConfig.pds_url);
4257
+ this.rawStorage = config.storage || new LocalStorageAdapter();
4258
+ this.users = this.mode === "sync" && this.projectId ? new UserRegistry(this.rawStorage, this.projectId) : null;
3887
4259
  this.rest = new RestClient({
3888
- baseUrl: authConfig.pds_url,
4260
+ baseUrl: this.authConfig.pds_url,
3889
4261
  projectId: this.projectId ?? "",
3890
4262
  getToken: (opts) => this.auth.getToken(opts),
3891
4263
  log: this.debug ? log : void 0
3892
4264
  });
3893
- if (this.mode === "sync" && this.projectId && this.config.schema?.tables) {
3894
- this.engine = new SyncEngine({
3895
- projectId: this.projectId,
3896
- schema: this.config.schema,
3897
- wsUrl: syncUrl,
3898
- getToken: (opts) => this.auth.getToken(opts),
3899
- fetchSnapshot: (opts) => this.rest.getSnapshot(opts),
3900
- WebSocketImpl: config.WebSocketImpl,
3901
- log
3902
- });
3903
- this.syncDb = new SyncDb(this.engine, OWN_SUB);
3904
- this.wireEngineEvents(this.engine);
3905
- } else {
3906
- this.engine = null;
3907
- this.syncDb = null;
3908
- }
3909
4265
  this.restDb = new RestDb(this.rest, this.config.schema);
4266
+ this.binding = this.createBinding(ephemeralLegacyProfile());
3910
4267
  this.snapshot = this.buildSnapshot();
3911
4268
  }
3912
4269
  // -------------------------------------------------------------------
3913
4270
  // Public surface
3914
4271
  // -------------------------------------------------------------------
3915
- /** The database handle: offline-first in sync mode, direct API in rest mode. */
4272
+ get auth() {
4273
+ return this.binding.auth;
4274
+ }
4275
+ get engine() {
4276
+ return this.binding.engine;
4277
+ }
4278
+ /** The database handle for the active user. Identity changes on switch. */
3916
4279
  get db() {
3917
- if (this.mode === "sync" && this.syncDb) return this.syncDb;
4280
+ if (this.mode === "sync" && this.binding.syncDb) return this.binding.syncDb;
3918
4281
  return this.restDb;
3919
4282
  }
3920
- /** Bootstrap: version migrations, schema check, auth initialization. */
4283
+ get activeUser() {
4284
+ return this.users ? this.binding.profile : null;
4285
+ }
4286
+ /** Bootstrap: version migrations, profile resolution, schema check, auth init. */
3921
4287
  async start() {
3922
4288
  if (this.started) return;
3923
4289
  this.started = true;
3924
4290
  try {
3925
- const updater = createVersionUpdater(this.auth.storage, version, getMigrations());
4291
+ const updater = createVersionUpdater(this.rawStorage, version, getMigrations());
3926
4292
  const result = await updater.checkAndUpdate();
3927
4293
  if (result.updated) log(`SDK storage migrated ${result.fromVersion} \u2192 ${result.toVersion}`);
3928
4294
  } catch (err) {
3929
4295
  log("version updater failed:", err);
3930
4296
  }
3931
- void this.checkSchema().then(() => this.maybeStartSync());
3932
- await this.auth.initialize();
3933
- const teardownNetwork = this.auth.setupNetworkListeners();
3934
- this.cleanupFns.push(teardownNetwork);
4297
+ void this.checkSchema().then(() => this.syncLifecycle());
4298
+ await this.queueProfileOp(async () => {
4299
+ let profile = null;
4300
+ if (this.users) {
4301
+ await this.users.adoptLegacySession();
4302
+ profile = await this.users.resolveActive();
4303
+ if (!profile && this.anonymousEnabled) {
4304
+ profile = await this.users.createAnon();
4305
+ }
4306
+ if (profile) this.users.setActiveId(profile.id);
4307
+ }
4308
+ await this.activateProfile(profile ?? ephemeralLegacyProfile(), { initial: true });
4309
+ await this.refreshUsers();
4310
+ });
3935
4311
  }
3936
- /** Sign out: server-side revoke, local auth clear, sync teardown + purge. */
4312
+ /**
4313
+ * Sign out the active user: server-side revoke, wipe the profile's local
4314
+ * data, drop the profile, and fall through to the next (or a fresh
4315
+ * anonymous) user.
4316
+ */
3937
4317
  async signOut() {
3938
- await this.auth.signOut();
3939
- await this.teardownLocalData();
4318
+ await this.queueProfileOp(async () => {
4319
+ const { profile, auth, engine } = this.binding;
4320
+ this.signOutInProgress = true;
4321
+ try {
4322
+ await auth.signOut();
4323
+ } finally {
4324
+ this.signOutInProgress = false;
4325
+ }
4326
+ this.mounts.clear();
4327
+ try {
4328
+ await engine?.destroyLocal();
4329
+ } catch (err) {
4330
+ log("local data teardown failed:", err);
4331
+ }
4332
+ if (this.users && profile.id !== "default") {
4333
+ await this.users.remove(profile.id);
4334
+ }
4335
+ await this.activateNextProfileLocked();
4336
+ await this.refreshUsers();
4337
+ });
4338
+ }
4339
+ // ---------------- multi-user ----------------
4340
+ /** Switch this tab to another local user. */
4341
+ async switchUser(id) {
4342
+ await this.queueProfileOp(async () => {
4343
+ if (!this.users) throw new Error("multiple users require sync mode with a project id");
4344
+ if (this.binding.profile.id === id) return;
4345
+ const target = await this.users.get(id);
4346
+ if (!target) throw new Error(`unknown user '${id}'`);
4347
+ this.users.setActiveId(id);
4348
+ await this.users.touch(id);
4349
+ await this.activateProfile(target);
4350
+ await this.refreshUsers();
4351
+ });
4352
+ }
4353
+ /** Create a fresh anonymous user and switch to it. */
4354
+ async addUser() {
4355
+ let created = null;
4356
+ await this.queueProfileOp(async () => {
4357
+ if (!this.users) throw new Error("multiple users require sync mode with a project id");
4358
+ created = await this.users.createAnon();
4359
+ this.users.setActiveId(created.id);
4360
+ await this.activateProfile(created);
4361
+ await this.refreshUsers();
4362
+ });
4363
+ return created;
4364
+ }
4365
+ /**
4366
+ * Remove a local user: best-effort server-side revoke, wipe its keyspace
4367
+ * and auth storage, drop the profile. Removing the active user signs out.
4368
+ */
4369
+ async removeUser(id) {
4370
+ if (this.binding.profile.id === id) {
4371
+ return this.signOut();
4372
+ }
4373
+ await this.queueProfileOp(async () => {
4374
+ if (!this.users) return;
4375
+ const profile = await this.users.get(id);
4376
+ if (!profile) return;
4377
+ await this.disposeProfileData(profile);
4378
+ await this.users.remove(id);
4379
+ await this.refreshUsers();
4380
+ });
3940
4381
  }
3941
4382
  /** Stop connections and listeners; local data is kept. */
3942
4383
  stop() {
3943
4384
  this.started = false;
3944
- this.engine?.stop();
3945
- for (const fn of this.cleanupFns) {
4385
+ this.binding.engine?.stop();
4386
+ for (const fn of this.binding.sessionCleanup) {
3946
4387
  try {
3947
4388
  fn();
3948
4389
  } catch {
3949
4390
  }
3950
4391
  }
3951
- this.cleanupFns = [];
4392
+ this.binding.sessionCleanup = [];
3952
4393
  }
3953
4394
  /** Re-run the remote schema status check (dev toolbar). */
3954
4395
  async refreshSchemaStatus() {
3955
4396
  this.schemaChecked = false;
3956
4397
  await this.checkSchema();
3957
- this.maybeStartSync();
4398
+ this.syncLifecycle();
3958
4399
  }
3959
4400
  async listRejected() {
3960
4401
  return this.engine?.listRejected() ?? [];
@@ -3970,13 +4411,14 @@ var BasicClient = class {
3970
4411
  }
3971
4412
  /** Mount a share: separate local keyspace + subscription. */
3972
4413
  async mountShare(shareId) {
3973
- if (!this.engine) throw new Error("shares require sync mode");
4414
+ const engine = this.engine;
4415
+ if (!engine) throw new Error("shares require sync mode");
3974
4416
  const existing = this.mounts.get(shareId);
3975
4417
  if (existing) return existing;
3976
- await this.engine.mountShare(shareId);
4418
+ await engine.mountShare(shareId);
3977
4419
  const handle = {
3978
4420
  shareId,
3979
- db: new SyncDb(this.engine, shareSubKey(shareId))
4421
+ db: new SyncDb(engine, shareSubKey(shareId))
3980
4422
  };
3981
4423
  this.mounts.set(shareId, handle);
3982
4424
  this.publish();
@@ -4000,50 +4442,260 @@ var BasicClient = class {
4000
4442
  return this.snapshot;
4001
4443
  };
4002
4444
  // -------------------------------------------------------------------
4003
- // Orchestration
4445
+ // Profile binding & transitions
4004
4446
  // -------------------------------------------------------------------
4005
- handleAuthChange() {
4006
- const status = this.auth.authStatus;
4007
- if (status === "reauth_required") {
4008
- this.engine?.stop();
4009
- } else if (status === "signed_out") {
4010
- void this.teardownLocalData();
4447
+ createBinding(profile) {
4448
+ const storage = profile.storagePrefix ? new PrefixedStorage(this.rawStorage, profile.storagePrefix) : this.rawStorage;
4449
+ const binding = { profile, cleanup: [], sessionCleanup: [] };
4450
+ const auth = new AuthManager(
4451
+ {
4452
+ projectId: this.projectId,
4453
+ scopes: this.authConfig.scopes,
4454
+ pdsUrl: this.authConfig.pds_url,
4455
+ adminUrl: this.authConfig.admin_url,
4456
+ debug: this.debug,
4457
+ instanceKey: profile.storagePrefix
4458
+ },
4459
+ storage,
4460
+ () => {
4461
+ if (this.binding === binding) this.handleAuthChange();
4462
+ }
4463
+ );
4464
+ binding.auth = auth;
4465
+ if (this.mode === "sync" && this.projectId && this.config.schema?.tables) {
4466
+ const engine = new SyncEngine({
4467
+ projectId: this.projectId,
4468
+ schema: this.config.schema,
4469
+ wsUrl: this.syncUrl,
4470
+ getToken: (opts) => auth.getToken(opts),
4471
+ fetchSnapshot: (opts) => this.rest.getSnapshot(opts),
4472
+ WebSocketImpl: this.config.WebSocketImpl,
4473
+ keyspaceId: profile.keyspace,
4474
+ getOwnerDid: () => auth.did,
4475
+ log
4476
+ });
4477
+ binding.engine = engine;
4478
+ binding.syncDb = new SyncDb(engine, OWN_SUB);
4479
+ binding.cleanup.push(
4480
+ engine.on("status", () => this.publish()),
4481
+ engine.on("change", () => this.publish()),
4482
+ engine.on("rejected", ({ rejection }) => {
4483
+ log("op rejected:", rejection.error, rejection.op);
4484
+ this.publish();
4485
+ }),
4486
+ engine.on("revoked", ({ code, message }) => {
4487
+ log("connection revoked:", code, message);
4488
+ void auth.reconcileSession("connection revoked", { forceRefresh: true, throttleMs: 0 }).catch(() => {
4489
+ });
4490
+ this.publish();
4491
+ })
4492
+ );
4011
4493
  } else {
4012
- this.maybeStartSync();
4494
+ binding.engine = null;
4495
+ binding.syncDb = null;
4013
4496
  }
4497
+ return binding;
4498
+ }
4499
+ /**
4500
+ * Bind and boot a profile. Publishes the new binding first so React
4501
+ * subscriptions re-attach to the new db, then tears the old binding down
4502
+ * on the next tick (avoids in-flight live queries hitting a closed store).
4503
+ */
4504
+ async activateProfile(profile, options) {
4505
+ let old = null;
4506
+ if (options?.initial && this.bindingMatches(profile)) {
4507
+ this.binding.profile = profile;
4508
+ } else {
4509
+ old = this.binding;
4510
+ this.binding = this.createBinding(profile);
4511
+ if (options?.initial) {
4512
+ for (const fn of [...old.cleanup, ...old.sessionCleanup]) fn();
4513
+ old.engine?.stop();
4514
+ old.auth.destroy();
4515
+ old = null;
4516
+ }
4517
+ }
4518
+ await this.binding.auth.initialize();
4519
+ this.binding.sessionCleanup.push(this.binding.auth.setupNetworkListeners());
4520
+ this.mounts.clear();
4521
+ this.syncLifecycle();
4014
4522
  this.publish();
4523
+ if (old) {
4524
+ setTimeout(() => {
4525
+ try {
4526
+ for (const fn of [...old.cleanup, ...old.sessionCleanup]) fn();
4527
+ old.engine?.stop();
4528
+ old.auth.destroy();
4529
+ } catch {
4530
+ }
4531
+ }, 50);
4532
+ }
4015
4533
  }
4016
- maybeStartSync() {
4017
- if (!this.engine || !this.started) return;
4018
- if (!this.syncEnabled) return;
4019
- if (this.auth.isSignedIn && this.auth.token && this.auth.authStatus !== "reauth_required") {
4020
- void this.engine.start().catch((err) => log("sync start failed:", err));
4534
+ bindingMatches(profile) {
4535
+ return this.binding.profile.storagePrefix === profile.storagePrefix && this.binding.profile.keyspace === profile.keyspace;
4536
+ }
4537
+ /** After sign-out/disposal: resume on the next profile or a fresh anon one. */
4538
+ async activateNextProfileLocked() {
4539
+ let next = null;
4540
+ if (this.users) {
4541
+ next = await this.users.resolveActive();
4542
+ if (!next && this.anonymousEnabled) {
4543
+ next = await this.users.createAnon();
4544
+ }
4545
+ if (next) this.users.setActiveId(next.id);
4021
4546
  }
4547
+ await this.activateProfile(next ?? ephemeralLegacyProfile());
4022
4548
  }
4023
- async teardownLocalData() {
4024
- this.mounts.clear();
4025
- if (this.engine) {
4549
+ /** Wipe a (non-active) profile's local footprint: keyspace dbs + auth keys. */
4550
+ async disposeProfileData(profile) {
4551
+ const storage = profile.storagePrefix ? new PrefixedStorage(this.rawStorage, profile.storagePrefix) : this.rawStorage;
4552
+ try {
4553
+ const refreshToken = await storage.get(STORAGE_KEYS.REFRESH_TOKEN);
4554
+ if (refreshToken) {
4555
+ await fetch(`${this.authConfig.pds_url}/auth/revoke`, {
4556
+ method: "POST",
4557
+ headers: { "Content-Type": "application/json" },
4558
+ body: JSON.stringify({ token: refreshToken, token_type_hint: "refresh_token" })
4559
+ });
4560
+ }
4561
+ } catch {
4562
+ }
4563
+ for (const key of Object.values(STORAGE_KEYS)) {
4026
4564
  try {
4027
- await this.engine.destroyLocal();
4028
- } catch (err) {
4029
- log("local data teardown failed:", err);
4565
+ await storage.remove(key);
4566
+ } catch {
4030
4567
  }
4031
4568
  }
4569
+ await this.deleteKeyspaceDatabases(profile.keyspace);
4570
+ }
4571
+ async deleteKeyspaceDatabases(keyspace) {
4572
+ if (!this.projectId) return;
4573
+ const base = keyspace ? `basic-sync:${this.projectId}:${keyspace}` : `basic-sync:${this.projectId}`;
4574
+ try {
4575
+ const idb = globalThis.indexedDB;
4576
+ if (!idb) return;
4577
+ const names = [base];
4578
+ if (typeof idb.databases === "function") {
4579
+ const dbs = await idb.databases();
4580
+ for (const info of dbs) {
4581
+ if (info.name && info.name.startsWith(`${base}:share:`)) names.push(info.name);
4582
+ }
4583
+ }
4584
+ for (const name of names) {
4585
+ await new Promise((resolve) => {
4586
+ const req = idb.deleteDatabase(name);
4587
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
4588
+ });
4589
+ }
4590
+ } catch {
4591
+ }
4592
+ }
4593
+ // -------------------------------------------------------------------
4594
+ // Orchestration
4595
+ // -------------------------------------------------------------------
4596
+ /** Previous auth status, for transition detection (revoked-latch clearing). */
4597
+ lastAuthStatus = null;
4598
+ handleAuthChange() {
4599
+ const status = this.auth.authStatus;
4600
+ if (status === "authenticated" && this.lastAuthStatus !== "authenticated" && this.engine?.status === "revoked") {
4601
+ this.engine.clearRevoked();
4602
+ }
4603
+ this.lastAuthStatus = status;
4604
+ if (status === "signed_out" && !this.signOutInProgress && this.started) {
4605
+ const profile = this.binding.profile;
4606
+ if (this.users && profile.kind === "account") {
4607
+ void this.queueProfileOp(async () => {
4608
+ if (this.binding.profile.id !== profile.id) return;
4609
+ if (this.binding.auth.authStatus !== "signed_out") return;
4610
+ this.mounts.clear();
4611
+ try {
4612
+ await this.binding.engine?.destroyLocal();
4613
+ } catch {
4614
+ }
4615
+ await this.users.remove(profile.id);
4616
+ await this.activateNextProfileLocked();
4617
+ await this.refreshUsers();
4618
+ });
4619
+ return;
4620
+ }
4621
+ }
4622
+ this.syncLifecycle();
4623
+ void this.maybeUpgradeProfile();
4032
4624
  this.publish();
4033
4625
  }
4034
- wireEngineEvents(engine) {
4035
- engine.on("status", () => this.publish());
4036
- engine.on("change", () => this.publish());
4037
- engine.on("rejected", ({ rejection }) => {
4038
- log("op rejected:", rejection.error, rejection.op);
4039
- this.publish();
4040
- });
4041
- engine.on("revoked", ({ code, message }) => {
4042
- log("connection revoked:", code, message);
4043
- void this.auth.reconcileSession("connection revoked", { forceRefresh: true, throttleMs: 0 }).catch(() => {
4626
+ /**
4627
+ * Drive the engine from auth + schema state:
4628
+ * - local keyspace opens with no token (anonymous mode / offline cold start)
4629
+ * - connect when a session exists (recovering counts the connection
4630
+ * retries token acquisition itself)
4631
+ * - reauth_required pauses the connection, keeps local data usable
4632
+ */
4633
+ syncLifecycle() {
4634
+ const { engine, auth, profile } = this.binding;
4635
+ if (!engine || !this.started) return;
4636
+ const status = auth.authStatus;
4637
+ if (status === "reauth_required") {
4638
+ engine.pause();
4639
+ return;
4640
+ }
4641
+ const localAllowed = this.anonymousEnabled || auth.isSignedIn || profile.kind === "account";
4642
+ if (!localAllowed) return;
4643
+ const schemaLocallyUsable = this.devInfo === null || this.devInfo.status !== "invalid";
4644
+ if (!schemaLocallyUsable) return;
4645
+ void engine.openLocal().then(() => {
4646
+ if (this.binding.engine !== engine) return;
4647
+ if (this.syncEnabled && auth.isSignedIn && auth.authStatus !== "reauth_required") {
4648
+ return engine.connect();
4649
+ }
4650
+ }).catch((err) => log("sync lifecycle failed:", err));
4651
+ }
4652
+ /**
4653
+ * After sign-in: bind the account identity to the active profile
4654
+ * (anonymous → account upgrade) and dedupe against an existing profile
4655
+ * for the same DID.
4656
+ */
4657
+ async maybeUpgradeProfile() {
4658
+ const { profile, auth } = this.binding;
4659
+ if (!this.users || auth.authStatus !== "authenticated" || !auth.did) return;
4660
+ if (profile.id === "default") return;
4661
+ const did = auth.did;
4662
+ const user = auth.user;
4663
+ const needsUpdate = profile.did !== did || profile.kind !== "account" || profile.email !== (user?.email ?? profile.email) || profile.name !== (user?.name ?? profile.name);
4664
+ if (!needsUpdate) return;
4665
+ await this.queueProfileOp(async () => {
4666
+ if (this.binding.profile.id !== profile.id) return;
4667
+ if (!this.users) return;
4668
+ const existing = await this.users.findByDid(did);
4669
+ if (existing && existing.id !== profile.id) {
4670
+ log(`deduping user profiles for ${did}: dropping ${existing.id}`);
4671
+ await this.disposeProfileData(existing);
4672
+ await this.users.remove(existing.id);
4673
+ }
4674
+ const updated = await this.users.update(profile.id, {
4675
+ kind: "account",
4676
+ did,
4677
+ email: user?.email ?? profile.email ?? null,
4678
+ name: user?.name ?? profile.name ?? null,
4679
+ picture: user?.picture ?? profile.picture ?? null,
4680
+ lastActiveAt: Date.now()
4044
4681
  });
4045
- this.publish();
4682
+ if (updated) {
4683
+ this.binding.profile = updated;
4684
+ }
4685
+ await this.refreshUsers();
4686
+ });
4687
+ }
4688
+ async refreshUsers() {
4689
+ if (this.users) {
4690
+ this.usersCache = await this.users.list();
4691
+ }
4692
+ this.publish();
4693
+ }
4694
+ queueProfileOp(task) {
4695
+ this.profileOps = this.profileOps.then(task).catch((err) => {
4696
+ log("profile operation failed:", err);
4046
4697
  });
4698
+ return this.profileOps;
4047
4699
  }
4048
4700
  async checkSchema() {
4049
4701
  if (this.schemaChecked) return;
@@ -4087,7 +4739,7 @@ var BasicClient = class {
4087
4739
  this.syncEnabled = result.schemaStatus.valid || remoteCheckInconclusive && locallyPublishable;
4088
4740
  if (!result.schemaStatus.valid) {
4089
4741
  if (status === "unpublished") {
4090
- log("Schema not published (version 0) \u2014 sync is disabled until you publish.");
4742
+ log("Schema not published (version 0) \u2014 sync is disabled, local-only mode.");
4091
4743
  } else if (remoteCheckInconclusive && locallyPublishable) {
4092
4744
  log("Schema registry check failed \u2014 proceeding with the local schema (offline-first).");
4093
4745
  }
@@ -4108,19 +4760,23 @@ var BasicClient = class {
4108
4760
  this.publish();
4109
4761
  }
4110
4762
  buildSnapshot() {
4763
+ const { auth, engine, profile } = this.binding;
4111
4764
  return {
4112
- isReady: this.auth.isAuthReady,
4113
- isSignedIn: this.auth.isSignedIn,
4114
- authStatus: this.auth.authStatus,
4115
- authErrorCode: this.auth.authErrorCode,
4116
- user: this.auth.user,
4117
- did: this.auth.did,
4118
- scope: this.auth.tokenScope,
4119
- syncStatus: this.engine?.status ?? "idle",
4120
- pendingCount: this.engine?.pendingCount ?? 0,
4765
+ isReady: auth.isAuthReady,
4766
+ isSignedIn: auth.isSignedIn,
4767
+ authStatus: auth.authStatus,
4768
+ authErrorCode: auth.authErrorCode,
4769
+ user: auth.user,
4770
+ did: auth.did,
4771
+ scope: auth.tokenScope,
4772
+ syncStatus: engine?.status ?? "idle",
4773
+ pendingCount: engine?.pendingCount ?? 0,
4121
4774
  syncEnabled: this.syncEnabled,
4122
4775
  devInfo: this.devInfo,
4123
- mode: this.mode
4776
+ mode: this.mode,
4777
+ users: this.usersCache,
4778
+ activeUser: this.users ? profile : null,
4779
+ isAnonymous: this.users ? profile.kind === "anon" && !auth.isSignedIn : false
4124
4780
  };
4125
4781
  }
4126
4782
  publish() {
@@ -4151,6 +4807,7 @@ function BasicProvider({
4151
4807
  storage,
4152
4808
  debug = false,
4153
4809
  mode = "sync",
4810
+ anonymous = true,
4154
4811
  devToolbar = false,
4155
4812
  renderWhileLoading = false
4156
4813
  }) {
@@ -4162,7 +4819,8 @@ function BasicProvider({
4162
4819
  auth,
4163
4820
  storage,
4164
4821
  debug,
4165
- mode
4822
+ mode,
4823
+ anonymous
4166
4824
  });
4167
4825
  }
4168
4826
  const client = clientRef.current;
@@ -4194,6 +4852,7 @@ init_BasicDevToolbar();
4194
4852
  NotAuthenticatedError,
4195
4853
  OWN_SUB,
4196
4854
  PROTOCOL_VERSION,
4855
+ PrefixedStorage,
4197
4856
  RestClient,
4198
4857
  RestDb,
4199
4858
  RestError,
@@ -4202,6 +4861,7 @@ init_BasicDevToolbar();
4202
4861
  SyncDb,
4203
4862
  SyncEngine,
4204
4863
  SyncStore,
4864
+ UserRegistry,
4205
4865
  applyOpToData,
4206
4866
  createBasicClient,
4207
4867
  isAuthError,
@@ -4220,6 +4880,7 @@ init_BasicDevToolbar();
4220
4880
  useQuery,
4221
4881
  useShare,
4222
4882
  useShares,
4223
- useSyncStatus
4883
+ useSyncStatus,
4884
+ useUsers
4224
4885
  });
4225
4886
  //# sourceMappingURL=index.js.map