@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.mjs CHANGED
@@ -38,7 +38,7 @@ var init_config = __esm({
38
38
  var version;
39
39
  var init_package = __esm({
40
40
  "package.json"() {
41
- version = "0.9.0-beta.0";
41
+ version = "0.9.0-beta.1";
42
42
  }
43
43
  });
44
44
 
@@ -138,6 +138,22 @@ var init_network = __esm({
138
138
  // src/react/hooks.ts
139
139
  import { useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
140
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
+ }
141
157
  function useBasicClient() {
142
158
  const client = useContext(BasicClientContext);
143
159
  if (!client) {
@@ -155,6 +171,7 @@ function useAuth() {
155
171
  () => ({
156
172
  isReady: snapshot.isReady,
157
173
  isSignedIn: snapshot.isSignedIn,
174
+ isAnonymous: snapshot.isAnonymous,
158
175
  status: snapshot.authStatus,
159
176
  errorCode: snapshot.authErrorCode,
160
177
  user: snapshot.user,
@@ -211,9 +228,11 @@ function useShares() {
211
228
  },
212
229
  [client]
213
230
  );
231
+ const activeUserId = snapshot.activeUser?.id ?? null;
214
232
  useEffect(() => {
215
233
  if (isSignedIn) void refresh();
216
- }, [isSignedIn, refresh]);
234
+ else setState({ granted: [], received: [], isLoading: false, error: null });
235
+ }, [isSignedIn, refresh, activeUserId]);
217
236
  return { ...state, refresh };
218
237
  }
219
238
  function useShare(shareId) {
@@ -223,6 +242,7 @@ function useShare(shareId) {
223
242
  const [error, setError] = useState(null);
224
243
  const [revoked, setRevoked] = useState(false);
225
244
  const canMount = !!shareId && snapshot.isSignedIn && snapshot.authStatus !== "reauth_required";
245
+ const activeUserId = snapshot.activeUser?.id ?? null;
226
246
  useEffect(() => {
227
247
  if (!canMount || !shareId) return;
228
248
  let cancelled = false;
@@ -246,13 +266,28 @@ function useShare(shareId) {
246
266
  void client.unmountShare(shareId).catch(() => {
247
267
  });
248
268
  };
249
- }, [client, shareId, canMount]);
269
+ }, [client, shareId, canMount, activeUserId]);
250
270
  return {
251
271
  db: handle?.db ?? null,
252
272
  status: revoked ? "revoked" : error ? "error" : handle ? "mounted" : "mounting",
253
273
  error
254
274
  };
255
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
+ }
256
291
  function useBasic() {
257
292
  const client = useBasicClient();
258
293
  const snapshot = useClientSnapshot(client);
@@ -263,6 +298,8 @@ function useBasic() {
263
298
  ...auth,
264
299
  db: client.db,
265
300
  sync,
301
+ users: snapshot.users,
302
+ activeUser: snapshot.activeUser,
266
303
  devInfo: snapshot.devInfo,
267
304
  refreshSchemaStatus: () => client.refreshSchemaStatus(),
268
305
  client
@@ -270,12 +307,13 @@ function useBasic() {
270
307
  [client, snapshot, auth, sync]
271
308
  );
272
309
  }
273
- var useQuery;
310
+ var noopSubscribe;
274
311
  var init_hooks = __esm({
275
312
  "src/react/hooks.ts"() {
276
313
  "use strict";
277
314
  init_context();
278
- useQuery = useLiveQuery;
315
+ noopSubscribe = () => () => {
316
+ };
279
317
  }
280
318
  });
281
319
 
@@ -295,7 +333,8 @@ function toneForSync(mode, status) {
295
333
  if (mode === "rest") return "muted";
296
334
  if (status === "online") return "ok";
297
335
  if (status === "connecting") return "warn";
298
- if (status === "offline" || status === "idle" || status === "stopped") return "muted";
336
+ if (status === "offline" || status === "idle" || status === "stopped" || status === "local")
337
+ return "muted";
299
338
  return "bad";
300
339
  }
301
340
  function toneForSchema(info) {
@@ -309,6 +348,8 @@ function syncStatusLabel(status) {
309
348
  switch (status) {
310
349
  case "idle":
311
350
  return "Idle";
351
+ case "local":
352
+ return "Local only";
312
353
  case "connecting":
313
354
  return "Connecting";
314
355
  case "online":
@@ -485,18 +526,22 @@ function BasicDevToolbar({ enabled = true, debug }) {
485
526
  const {
486
527
  isReady,
487
528
  isSignedIn,
529
+ isAnonymous,
488
530
  user,
489
531
  did,
490
532
  scope,
491
533
  missingScopes,
492
534
  sync,
535
+ users,
536
+ activeUser,
493
537
  devInfo,
494
538
  refreshSchemaStatus,
495
539
  client
496
540
  } = useBasic();
497
541
  const dbMode = client.mode;
498
542
  const syncStatus = sync.status;
499
- const indexedDbName = dbMode === "sync" && client.projectId ? `basic-sync:${client.projectId}` : null;
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";
500
545
  const [open, setOpen] = useState2(false);
501
546
  const [refreshing, setRefreshing] = useState2(false);
502
547
  const [copied, setCopied] = useState2(false);
@@ -533,9 +578,11 @@ function BasicDevToolbar({ enabled = true, debug }) {
533
578
  syncStatus,
534
579
  pendingOps: sync.pendingCount,
535
580
  indexedDbName,
581
+ activeUser: activeUser ? { id: activeUser.id, kind: activeUser.kind, did: activeUser.did } : null,
582
+ userCount: users.length,
536
583
  schema: devInfo
537
584
  };
538
- }, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, devInfo, missingList]);
585
+ }, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, activeUser, users.length, devInfo, missingList]);
539
586
  const handleCopy = useCallback(async () => {
540
587
  try {
541
588
  await navigator.clipboard.writeText(JSON.stringify(debugPayload, null, 2));
@@ -673,6 +720,17 @@ function BasicDevToolbar({ enabled = true, debug }) {
673
720
  children: user ? displayUserLine(user) : "\u2014"
674
721
  }
675
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
+ ),
676
734
  /* @__PURE__ */ jsx(
677
735
  CopyableRow,
678
736
  {
@@ -1091,11 +1149,15 @@ var AuthManager = class {
1091
1149
  this.requestedScopes = config.scopes;
1092
1150
  this.initCrossTabSync();
1093
1151
  }
1152
+ get instanceKey() {
1153
+ return this.config.instanceKey ?? "";
1154
+ }
1094
1155
  initCrossTabSync() {
1095
1156
  if (typeof BroadcastChannel === "undefined") return;
1096
1157
  try {
1097
1158
  this.channel = new BroadcastChannel("basic-auth");
1098
1159
  this.channel.onmessage = (event) => {
1160
+ if ((event.data?.userKey ?? "") !== this.instanceKey) return;
1099
1161
  if (event.data?.type === "token_refreshed") {
1100
1162
  log("Received token refresh from another tab");
1101
1163
  void this.handleExternalTokenRefresh(event.data);
@@ -1123,19 +1185,32 @@ var AuthManager = class {
1123
1185
  broadcastTokenRefresh() {
1124
1186
  this.channel?.postMessage({
1125
1187
  type: "token_refreshed",
1188
+ userKey: this.instanceKey,
1126
1189
  accessToken: this.token?.access_token,
1127
1190
  did: this.did,
1128
1191
  tokenScope: this.tokenScope
1129
1192
  });
1130
1193
  }
1131
1194
  broadcastSignIn() {
1132
- this.channel?.postMessage({ type: "signed_in" });
1195
+ this.channel?.postMessage({ type: "signed_in", userKey: this.instanceKey });
1133
1196
  }
1134
1197
  broadcastSignOut() {
1135
- this.channel?.postMessage({ type: "signed_out" });
1198
+ this.channel?.postMessage({ type: "signed_out", userKey: this.instanceKey });
1136
1199
  }
1137
1200
  broadcastSessionInvalidated(code) {
1138
- 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;
1139
1214
  }
1140
1215
  // ------------------------------------------------------------------
1141
1216
  // Public API
@@ -2563,6 +2638,7 @@ var SyncConnection = class {
2563
2638
  import Dexie from "dexie";
2564
2639
  var META_CURSOR = "cursor";
2565
2640
  var META_CHANNEL = "channel";
2641
+ var META_OWNER = "owner_did";
2566
2642
  var SyncStore = class {
2567
2643
  db;
2568
2644
  name;
@@ -2619,6 +2695,32 @@ var SyncStore = class {
2619
2695
  const row = await this.meta.get(META_CHANNEL);
2620
2696
  return typeof row?.value === "string" ? row.value : null;
2621
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
+ }
2622
2724
  // -------------------------------------------------------------------
2623
2725
  // Pending / rejected
2624
2726
  // -------------------------------------------------------------------
@@ -2831,7 +2933,11 @@ var SyncEngine = class {
2831
2933
  subs = /* @__PURE__ */ new Map();
2832
2934
  limits = { ...DEFAULT_LIMITS };
2833
2935
  actor = null;
2834
- started = false;
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;
2835
2941
  revokedInfo = null;
2836
2942
  connectionStatus = "idle";
2837
2943
  _status = "idle";
@@ -2906,26 +3012,76 @@ var SyncEngine = class {
2906
3012
  // -------------------------------------------------------------------
2907
3013
  // Lifecycle
2908
3014
  // -------------------------------------------------------------------
2909
- /** Open the own-channel keyspace and connect. Idempotent. */
2910
- async start() {
2911
- if (this.started) {
2912
- if (this.connectionStatus === "auth_failed" || this._status === "auth_required") {
2913
- this.connection.start();
2914
- }
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();
2915
3024
  return;
2916
3025
  }
2917
- this.started = true;
2918
- this.revokedInfo = null;
2919
- if (!this.subs.has(OWN_SUB)) {
2920
- const sub = await this.openSub(OWN_SUB, null);
2921
- this.subs.set(OWN_SUB, sub);
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
+ });
2922
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;
2923
3051
  this.connection.start();
2924
3052
  this.recomputeStatus();
2925
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
+ }
2926
3081
  /** Close the socket and stores; local data is kept. */
2927
3082
  stop() {
2928
- this.started = false;
3083
+ this.connectIntended = false;
3084
+ this.storesOpen = false;
2929
3085
  this.connection.stop();
2930
3086
  for (const t of this.timers) clearTimeout(t);
2931
3087
  this.timers.clear();
@@ -2953,10 +3109,10 @@ var SyncEngine = class {
2953
3109
  try {
2954
3110
  const idb = globalThis.indexedDB;
2955
3111
  if (idb && typeof idb.databases === "function") {
2956
- const prefix = `${this.dbPrefix}:${this.projectId}`;
3112
+ const base = this.baseDbName;
2957
3113
  const dbs = await idb.databases();
2958
3114
  for (const info of dbs) {
2959
- if (info.name && info.name.startsWith(prefix)) {
3115
+ if (info.name && (info.name === base || info.name.startsWith(`${base}:share:`))) {
2960
3116
  await new Promise((resolve) => {
2961
3117
  const req = idb.deleteDatabase(info.name);
2962
3118
  req.onsuccess = req.onerror = req.onblocked = () => resolve();
@@ -3054,7 +3210,7 @@ var SyncEngine = class {
3054
3210
  // -------------------------------------------------------------------
3055
3211
  handleConnectionStatus(status) {
3056
3212
  this.connectionStatus = status;
3057
- if (status === "offline" || status === "connecting" || status === "auth_failed") {
3213
+ if (status === "offline" || status === "connecting" || status === "auth_failed" || status === "stopped") {
3058
3214
  for (const sub of this.subs.values()) {
3059
3215
  sub.active = false;
3060
3216
  for (const p of sub.pending) {
@@ -3142,13 +3298,13 @@ var SyncEngine = class {
3142
3298
  switch (msg.code) {
3143
3299
  case "CONNECTION_REVOKED":
3144
3300
  this.revokedInfo = { code: msg.code, message: msg.message };
3145
- this.started = false;
3301
+ this.connectIntended = false;
3146
3302
  this.connection.stop();
3147
3303
  this.recomputeStatus();
3148
3304
  this.emit("revoked", { code: msg.code, message: msg.message });
3149
3305
  return;
3150
3306
  case "UNSUPPORTED_VERSION":
3151
- this.started = false;
3307
+ this.connectIntended = false;
3152
3308
  this.connection.stop();
3153
3309
  this.recomputeStatus();
3154
3310
  return;
@@ -3183,7 +3339,7 @@ var SyncEngine = class {
3183
3339
  // Subscription state machine (serialized per sub via `chain`)
3184
3340
  // -------------------------------------------------------------------
3185
3341
  async openSub(key, shareId) {
3186
- const name = shareId ? `${this.dbPrefix}:${this.projectId}:share:${shareId}` : `${this.dbPrefix}:${this.projectId}`;
3342
+ const name = shareId ? `${this.baseDbName}:share:${shareId}` : this.baseDbName;
3187
3343
  const store = new SyncStore(name, this.schema);
3188
3344
  const [cursor, pendingRows] = await Promise.all([store.getCursor(), store.loadPending()]);
3189
3345
  return {
@@ -3204,6 +3360,16 @@ var SyncEngine = class {
3204
3360
  /** Bootstrap if needed, then bind the stream on the current socket. */
3205
3361
  async activateSub(sub) {
3206
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
+ }
3207
3373
  if (!sub.bootstrapped || sub.cursor < 0) {
3208
3374
  try {
3209
3375
  await this.bootstrapSub(sub);
@@ -3222,6 +3388,26 @@ var SyncEngine = class {
3222
3388
  }
3223
3389
  /** Cold start = snapshot + tail; never log replay (§5). Pending survives. */
3224
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
+ }
3225
3411
  const snapshot = await this.opts.fetchSnapshot(
3226
3412
  sub.shareId ? { share: sub.shareId } : void 0
3227
3413
  );
@@ -3230,6 +3416,9 @@ var SyncEngine = class {
3230
3416
  records: snapshot.records ?? {},
3231
3417
  cursor: snapshot.cursor ?? 0
3232
3418
  });
3419
+ if (!sub.shareId && ownerDid) {
3420
+ await sub.store.setOwner(ownerDid);
3421
+ }
3233
3422
  sub.cursor = snapshot.cursor ?? 0;
3234
3423
  sub.bootstrapped = true;
3235
3424
  sub.appliedOpIds.clear();
@@ -3353,6 +3542,11 @@ var SyncEngine = class {
3353
3542
  get dbPrefix() {
3354
3543
  return this.opts.dbNamePrefix ?? "basic-sync";
3355
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
+ }
3356
3550
  enqueue(sub, task) {
3357
3551
  sub.chain = sub.chain.then(task).catch((err) => {
3358
3552
  this.log(`task failed on '${sub.key}':`, err);
@@ -3370,10 +3564,12 @@ var SyncEngine = class {
3370
3564
  let status;
3371
3565
  if (this.revokedInfo) status = "revoked";
3372
3566
  else if (this.connectionStatus === "auth_failed") status = "auth_required";
3373
- else if (!this.started) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
3567
+ else if (!this.storesOpen) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
3568
+ else if (!this.connectIntended) status = "local";
3374
3569
  else if (this.connectionStatus === "online") status = "online";
3375
3570
  else if (this.connectionStatus === "connecting") status = "connecting";
3376
3571
  else if (this.connectionStatus === "idle") status = "connecting";
3572
+ else if (this.connectionStatus === "stopped") status = "local";
3377
3573
  else status = "offline";
3378
3574
  if (status !== this._status) {
3379
3575
  this._status = status;
@@ -3399,7 +3595,7 @@ var SyncTable = class {
3399
3595
  const sub = this.engine.getSubscription(this.subKey);
3400
3596
  if (!sub) {
3401
3597
  throw new Error(
3402
- `subscription '${this.subKey}' is not open \u2014 sign in and wait for the db to be ready`
3598
+ `subscription '${this.subKey}' is not open \u2014 wait for the client to be ready (isReady) before using the db`
3403
3599
  );
3404
3600
  }
3405
3601
  return sub.store;
@@ -3529,6 +3725,171 @@ var RestDb = class {
3529
3725
  }
3530
3726
  };
3531
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 [];
3762
+ try {
3763
+ const parsed = JSON.parse(raw);
3764
+ return Array.isArray(parsed) ? parsed : [];
3765
+ } catch {
3766
+ return [];
3767
+ }
3768
+ }
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
3786
+ };
3787
+ const users = await this.list();
3788
+ users.push(profile);
3789
+ await this.save(users);
3790
+ log(`created anonymous user ${id}`);
3791
+ return profile;
3792
+ }
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];
3800
+ }
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 {
3831
+ }
3832
+ }
3833
+ /**
3834
+ * Resolve the active profile for this tab: sessionStorage choice if it
3835
+ * still exists, else the most recently active profile, else null.
3836
+ */
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() });
3851
+ }
3852
+ // -------------------------------------------------------------------
3853
+ // Legacy adoption
3854
+ // -------------------------------------------------------------------
3855
+ /**
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.
3860
+ */
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;
3890
+ }
3891
+ };
3892
+
3532
3893
  // src/utils/schema.ts
3533
3894
  init_config();
3534
3895
  import { validateSchema, compareSchemas } from "@basictech/schema";
@@ -3778,21 +4139,38 @@ var DEFAULTS = {
3778
4139
  function deriveSyncUrl(pdsUrl) {
3779
4140
  return pdsUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/sync/";
3780
4141
  }
4142
+ function ephemeralLegacyProfile() {
4143
+ const now = Date.now();
4144
+ return {
4145
+ id: "default",
4146
+ kind: "anon",
4147
+ keyspace: "",
4148
+ storagePrefix: "",
4149
+ createdAt: now,
4150
+ lastActiveAt: now
4151
+ };
4152
+ }
3781
4153
  var BasicClient = class {
3782
- auth;
3783
4154
  rest;
3784
- engine;
3785
4155
  mode;
3786
4156
  config;
3787
4157
  projectId;
3788
- syncDb;
4158
+ users;
4159
+ rawStorage;
3789
4160
  restDb;
3790
4161
  debug;
4162
+ anonymousEnabled;
4163
+ authConfig;
4164
+ syncUrl;
4165
+ binding;
4166
+ usersCache = [];
3791
4167
  devInfo = null;
3792
4168
  syncEnabled = false;
3793
4169
  schemaChecked = false;
3794
4170
  started = false;
3795
- cleanupFns = [];
4171
+ signOutInProgress = false;
4172
+ /** Serializes profile transitions (switch, dispose, sign-out fallthrough). */
4173
+ profileOps = Promise.resolve();
3796
4174
  mounts = /* @__PURE__ */ new Map();
3797
4175
  listeners = /* @__PURE__ */ new Set();
3798
4176
  snapshot;
@@ -3801,95 +4179,155 @@ var BasicClient = class {
3801
4179
  this.debug = config.debug ?? false;
3802
4180
  this.mode = config.mode ?? "sync";
3803
4181
  this.projectId = config.schema?.project_id || config.project_id;
3804
- const authConfig = {
4182
+ this.anonymousEnabled = this.mode === "sync" && (config.anonymous ?? true);
4183
+ this.authConfig = {
3805
4184
  scopes: Array.isArray(config.auth?.scopes) ? config.auth.scopes.join(" ") : config.auth?.scopes || DEFAULTS.scopes,
3806
4185
  pds_url: config.auth?.pds_url || DEFAULTS.pds_url,
3807
4186
  admin_url: config.auth?.admin_url || DEFAULTS.admin_url
3808
4187
  };
3809
- const syncUrl = config.auth?.sync_url || deriveSyncUrl(authConfig.pds_url);
3810
- const storage = config.storage || new LocalStorageAdapter();
3811
- this.auth = new AuthManager(
3812
- {
3813
- projectId: this.projectId,
3814
- scopes: authConfig.scopes,
3815
- pdsUrl: authConfig.pds_url,
3816
- adminUrl: authConfig.admin_url,
3817
- debug: this.debug
3818
- },
3819
- storage,
3820
- () => this.handleAuthChange()
3821
- );
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;
3822
4191
  this.rest = new RestClient({
3823
- baseUrl: authConfig.pds_url,
4192
+ baseUrl: this.authConfig.pds_url,
3824
4193
  projectId: this.projectId ?? "",
3825
4194
  getToken: (opts) => this.auth.getToken(opts),
3826
4195
  log: this.debug ? log : void 0
3827
4196
  });
3828
- if (this.mode === "sync" && this.projectId && this.config.schema?.tables) {
3829
- this.engine = new SyncEngine({
3830
- projectId: this.projectId,
3831
- schema: this.config.schema,
3832
- wsUrl: syncUrl,
3833
- getToken: (opts) => this.auth.getToken(opts),
3834
- fetchSnapshot: (opts) => this.rest.getSnapshot(opts),
3835
- WebSocketImpl: config.WebSocketImpl,
3836
- log
3837
- });
3838
- this.syncDb = new SyncDb(this.engine, OWN_SUB);
3839
- this.wireEngineEvents(this.engine);
3840
- } else {
3841
- this.engine = null;
3842
- this.syncDb = null;
3843
- }
3844
4197
  this.restDb = new RestDb(this.rest, this.config.schema);
4198
+ this.binding = this.createBinding(ephemeralLegacyProfile());
3845
4199
  this.snapshot = this.buildSnapshot();
3846
4200
  }
3847
4201
  // -------------------------------------------------------------------
3848
4202
  // Public surface
3849
4203
  // -------------------------------------------------------------------
3850
- /** The database handle: offline-first in sync mode, direct API in rest mode. */
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. */
3851
4211
  get db() {
3852
- if (this.mode === "sync" && this.syncDb) return this.syncDb;
4212
+ if (this.mode === "sync" && this.binding.syncDb) return this.binding.syncDb;
3853
4213
  return this.restDb;
3854
4214
  }
3855
- /** Bootstrap: version migrations, schema check, auth initialization. */
4215
+ get activeUser() {
4216
+ return this.users ? this.binding.profile : null;
4217
+ }
4218
+ /** Bootstrap: version migrations, profile resolution, schema check, auth init. */
3856
4219
  async start() {
3857
4220
  if (this.started) return;
3858
4221
  this.started = true;
3859
4222
  try {
3860
- const updater = createVersionUpdater(this.auth.storage, version, getMigrations());
4223
+ const updater = createVersionUpdater(this.rawStorage, version, getMigrations());
3861
4224
  const result = await updater.checkAndUpdate();
3862
4225
  if (result.updated) log(`SDK storage migrated ${result.fromVersion} \u2192 ${result.toVersion}`);
3863
4226
  } catch (err) {
3864
4227
  log("version updater failed:", err);
3865
4228
  }
3866
- void this.checkSchema().then(() => this.maybeStartSync());
3867
- await this.auth.initialize();
3868
- const teardownNetwork = this.auth.setupNetworkListeners();
3869
- this.cleanupFns.push(teardownNetwork);
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
+ });
3870
4243
  }
3871
- /** Sign out: server-side revoke, local auth clear, sync teardown + purge. */
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
+ */
3872
4249
  async signOut() {
3873
- await this.auth.signOut();
3874
- await this.teardownLocalData();
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
+ });
3875
4313
  }
3876
4314
  /** Stop connections and listeners; local data is kept. */
3877
4315
  stop() {
3878
4316
  this.started = false;
3879
- this.engine?.stop();
3880
- for (const fn of this.cleanupFns) {
4317
+ this.binding.engine?.stop();
4318
+ for (const fn of this.binding.sessionCleanup) {
3881
4319
  try {
3882
4320
  fn();
3883
4321
  } catch {
3884
4322
  }
3885
4323
  }
3886
- this.cleanupFns = [];
4324
+ this.binding.sessionCleanup = [];
3887
4325
  }
3888
4326
  /** Re-run the remote schema status check (dev toolbar). */
3889
4327
  async refreshSchemaStatus() {
3890
4328
  this.schemaChecked = false;
3891
4329
  await this.checkSchema();
3892
- this.maybeStartSync();
4330
+ this.syncLifecycle();
3893
4331
  }
3894
4332
  async listRejected() {
3895
4333
  return this.engine?.listRejected() ?? [];
@@ -3905,13 +4343,14 @@ var BasicClient = class {
3905
4343
  }
3906
4344
  /** Mount a share: separate local keyspace + subscription. */
3907
4345
  async mountShare(shareId) {
3908
- if (!this.engine) throw new Error("shares require sync mode");
4346
+ const engine = this.engine;
4347
+ if (!engine) throw new Error("shares require sync mode");
3909
4348
  const existing = this.mounts.get(shareId);
3910
4349
  if (existing) return existing;
3911
- await this.engine.mountShare(shareId);
4350
+ await engine.mountShare(shareId);
3912
4351
  const handle = {
3913
4352
  shareId,
3914
- db: new SyncDb(this.engine, shareSubKey(shareId))
4353
+ db: new SyncDb(engine, shareSubKey(shareId))
3915
4354
  };
3916
4355
  this.mounts.set(shareId, handle);
3917
4356
  this.publish();
@@ -3935,50 +4374,260 @@ var BasicClient = class {
3935
4374
  return this.snapshot;
3936
4375
  };
3937
4376
  // -------------------------------------------------------------------
3938
- // Orchestration
4377
+ // Profile binding & transitions
3939
4378
  // -------------------------------------------------------------------
3940
- handleAuthChange() {
3941
- const status = this.auth.authStatus;
3942
- if (status === "reauth_required") {
3943
- this.engine?.stop();
3944
- } else if (status === "signed_out") {
3945
- void this.teardownLocalData();
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(
4383
+ {
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
4390
+ },
4391
+ storage,
4392
+ () => {
4393
+ if (this.binding === binding) this.handleAuthChange();
4394
+ }
4395
+ );
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
+ })
4424
+ );
3946
4425
  } else {
3947
- this.maybeStartSync();
4426
+ binding.engine = null;
4427
+ binding.syncDb = null;
3948
4428
  }
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
+ }
4449
+ }
4450
+ await this.binding.auth.initialize();
4451
+ this.binding.sessionCleanup.push(this.binding.auth.setupNetworkListeners());
4452
+ this.mounts.clear();
4453
+ this.syncLifecycle();
3949
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 {
4462
+ }
4463
+ }, 50);
4464
+ }
3950
4465
  }
3951
- maybeStartSync() {
3952
- if (!this.engine || !this.started) return;
3953
- if (!this.syncEnabled) return;
3954
- if (this.auth.isSignedIn && this.auth.token && this.auth.authStatus !== "reauth_required") {
3955
- void this.engine.start().catch((err) => log("sync start failed:", err));
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();
4476
+ }
4477
+ if (next) this.users.setActiveId(next.id);
3956
4478
  }
4479
+ await this.activateProfile(next ?? ephemeralLegacyProfile());
3957
4480
  }
3958
- async teardownLocalData() {
3959
- this.mounts.clear();
3960
- if (this.engine) {
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" })
4491
+ });
4492
+ }
4493
+ } catch {
4494
+ }
4495
+ for (const key of Object.values(STORAGE_KEYS)) {
3961
4496
  try {
3962
- await this.engine.destroyLocal();
3963
- } catch (err) {
3964
- log("local data teardown failed:", err);
4497
+ await storage.remove(key);
4498
+ } catch {
3965
4499
  }
3966
4500
  }
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);
4514
+ }
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();
4520
+ });
4521
+ }
4522
+ } catch {
4523
+ }
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();
4534
+ }
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
+ }
4553
+ }
4554
+ this.syncLifecycle();
4555
+ void this.maybeUpgradeProfile();
3967
4556
  this.publish();
3968
4557
  }
3969
- wireEngineEvents(engine) {
3970
- engine.on("status", () => this.publish());
3971
- engine.on("change", () => this.publish());
3972
- engine.on("rejected", ({ rejection }) => {
3973
- log("op rejected:", rejection.error, rejection.op);
3974
- this.publish();
3975
- });
3976
- engine.on("revoked", ({ code, message }) => {
3977
- log("connection revoked:", code, message);
3978
- void this.auth.reconcileSession("connection revoked", { forceRefresh: true, throttleMs: 0 }).catch(() => {
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();
4571
+ return;
4572
+ }
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();
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()
3979
4613
  });
3980
- this.publish();
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();
4623
+ }
4624
+ this.publish();
4625
+ }
4626
+ queueProfileOp(task) {
4627
+ this.profileOps = this.profileOps.then(task).catch((err) => {
4628
+ log("profile operation failed:", err);
3981
4629
  });
4630
+ return this.profileOps;
3982
4631
  }
3983
4632
  async checkSchema() {
3984
4633
  if (this.schemaChecked) return;
@@ -4022,7 +4671,7 @@ var BasicClient = class {
4022
4671
  this.syncEnabled = result.schemaStatus.valid || remoteCheckInconclusive && locallyPublishable;
4023
4672
  if (!result.schemaStatus.valid) {
4024
4673
  if (status === "unpublished") {
4025
- log("Schema not published (version 0) \u2014 sync is disabled until you publish.");
4674
+ log("Schema not published (version 0) \u2014 sync is disabled, local-only mode.");
4026
4675
  } else if (remoteCheckInconclusive && locallyPublishable) {
4027
4676
  log("Schema registry check failed \u2014 proceeding with the local schema (offline-first).");
4028
4677
  }
@@ -4043,19 +4692,23 @@ var BasicClient = class {
4043
4692
  this.publish();
4044
4693
  }
4045
4694
  buildSnapshot() {
4695
+ const { auth, engine, profile } = this.binding;
4046
4696
  return {
4047
- isReady: this.auth.isAuthReady,
4048
- isSignedIn: this.auth.isSignedIn,
4049
- authStatus: this.auth.authStatus,
4050
- authErrorCode: this.auth.authErrorCode,
4051
- user: this.auth.user,
4052
- did: this.auth.did,
4053
- scope: this.auth.tokenScope,
4054
- syncStatus: this.engine?.status ?? "idle",
4055
- pendingCount: this.engine?.pendingCount ?? 0,
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,
4056
4706
  syncEnabled: this.syncEnabled,
4057
4707
  devInfo: this.devInfo,
4058
- mode: this.mode
4708
+ mode: this.mode,
4709
+ users: this.usersCache,
4710
+ activeUser: this.users ? profile : null,
4711
+ isAnonymous: this.users ? profile.kind === "anon" && !auth.isSignedIn : false
4059
4712
  };
4060
4713
  }
4061
4714
  publish() {
@@ -4086,6 +4739,7 @@ function BasicProvider({
4086
4739
  storage,
4087
4740
  debug = false,
4088
4741
  mode = "sync",
4742
+ anonymous = true,
4089
4743
  devToolbar = false,
4090
4744
  renderWhileLoading = false
4091
4745
  }) {
@@ -4097,7 +4751,8 @@ function BasicProvider({
4097
4751
  auth,
4098
4752
  storage,
4099
4753
  debug,
4100
- mode
4754
+ mode,
4755
+ anonymous
4101
4756
  });
4102
4757
  }
4103
4758
  const client = clientRef.current;
@@ -4128,6 +4783,7 @@ export {
4128
4783
  NotAuthenticatedError,
4129
4784
  OWN_SUB,
4130
4785
  PROTOCOL_VERSION,
4786
+ PrefixedStorage,
4131
4787
  RestClient,
4132
4788
  RestDb,
4133
4789
  RestError,
@@ -4136,6 +4792,7 @@ export {
4136
4792
  SyncDb,
4137
4793
  SyncEngine,
4138
4794
  SyncStore,
4795
+ UserRegistry,
4139
4796
  applyOpToData,
4140
4797
  createBasicClient,
4141
4798
  isAuthError,
@@ -4154,6 +4811,7 @@ export {
4154
4811
  useQuery,
4155
4812
  useShare,
4156
4813
  useShares,
4157
- useSyncStatus
4814
+ useSyncStatus,
4815
+ useUsers
4158
4816
  };
4159
4817
  //# sourceMappingURL=index.mjs.map