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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,6 +30,16 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
30
  ));
31
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
32
 
33
+ // src/react/context.ts
34
+ var import_react, BasicClientContext;
35
+ var init_context = __esm({
36
+ "src/react/context.ts"() {
37
+ "use strict";
38
+ import_react = require("react");
39
+ BasicClientContext = (0, import_react.createContext)(null);
40
+ }
41
+ });
42
+
33
43
  // src/config.ts
34
44
  var log;
35
45
  var init_config = __esm({
@@ -46,242 +56,11 @@ var init_config = __esm({
46
56
  }
47
57
  });
48
58
 
49
- // src/sync/tokenRegistry.ts
50
- function setTokenGetter(url, fn) {
51
- registry.set(url, fn);
52
- }
53
- function getTokenGetter(url) {
54
- return registry.get(url);
55
- }
56
- var registry;
57
- var init_tokenRegistry = __esm({
58
- "src/sync/tokenRegistry.ts"() {
59
- "use strict";
60
- registry = /* @__PURE__ */ new Map();
61
- }
62
- });
63
-
64
- // src/sync/syncProtocol.js
65
- var syncProtocol_exports = {};
66
- __export(syncProtocol_exports, {
67
- syncProtocol: () => syncProtocol
68
- });
69
- function decodeJwtExp(token) {
70
- try {
71
- var parts = token.split(".");
72
- if (parts.length !== 3) return null;
73
- var payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
74
- return typeof payload.exp === "number" ? payload.exp : null;
75
- } catch (_) {
76
- return null;
77
- }
78
- }
79
- var import_dexie, syncProtocol;
80
- var init_syncProtocol = __esm({
81
- "src/sync/syncProtocol.js"() {
82
- "use strict";
83
- "use client";
84
- import_dexie = require("dexie");
85
- init_config();
86
- init_tokenRegistry();
87
- syncProtocol = function() {
88
- log("Initializing syncProtocol");
89
- var RECONNECT_DELAY = 5e3;
90
- var TOKEN_REFRESH_BUFFER = 60;
91
- import_dexie.Dexie.Syncable.registerSyncProtocol("websocket", {
92
- sync: function(context, url, options, baseRevision, syncedRevision, changes, partial, applyRemoteChanges, onChangesAccepted, onSuccess, onError) {
93
- var requestId = 0;
94
- var acceptCallbacks = {};
95
- var refreshTimer = null;
96
- var pendingTokenUpdate = null;
97
- log("Connecting to", url);
98
- var ws = new WebSocket(url);
99
- function sendChanges(changes2, baseRevision2, partial2, onChangesAccepted2) {
100
- log("sendChanges", changes2.length, baseRevision2);
101
- ++requestId;
102
- acceptCallbacks[requestId.toString()] = onChangesAccepted2;
103
- ws.send(
104
- JSON.stringify({
105
- type: "changes",
106
- changes: changes2,
107
- partial: partial2,
108
- baseRevision: baseRevision2,
109
- requestId
110
- })
111
- );
112
- }
113
- function clearRefreshTimer() {
114
- if (refreshTimer) {
115
- clearTimeout(refreshTimer);
116
- refreshTimer = null;
117
- }
118
- }
119
- function sendTokenUpdate(token) {
120
- if (ws.readyState !== WebSocket.OPEN) return false;
121
- pendingTokenUpdate = token;
122
- ws.send(JSON.stringify({ type: "tokenUpdate", authToken: token }));
123
- return true;
124
- }
125
- function resolveGetToken() {
126
- var fn = getTokenGetter(url);
127
- if (!fn) throw new Error("No token getter registered for " + url);
128
- return fn;
129
- }
130
- function scheduleTokenRefresh(tokenStr) {
131
- clearRefreshTimer();
132
- var exp = decodeJwtExp(tokenStr);
133
- if (!exp) return;
134
- var msUntilRefresh = (exp - TOKEN_REFRESH_BUFFER) * 1e3 - Date.now();
135
- if (msUntilRefresh <= 0) return;
136
- log("Scheduling proactive token refresh in", Math.round(msUntilRefresh / 1e3), "s");
137
- refreshTimer = setTimeout(async function() {
138
- try {
139
- var newToken = await resolveGetToken()({ forceRefresh: true });
140
- if (sendTokenUpdate(newToken)) {
141
- log("Sending tokenUpdate on existing WebSocket");
142
- }
143
- } catch (err) {
144
- log("Proactive token refresh failed (non-fatal):", err);
145
- }
146
- }, msUntilRefresh);
147
- }
148
- ws.onopen = async function(event) {
149
- try {
150
- var token = await resolveGetToken()();
151
- log("Opening socket - sending clientIdentity", context.clientIdentity);
152
- ws.send(
153
- JSON.stringify({
154
- type: "clientIdentity",
155
- clientIdentity: context.clientIdentity || null,
156
- authToken: token,
157
- schema: options.schema
158
- })
159
- );
160
- scheduleTokenRefresh(token);
161
- } catch (err) {
162
- log("Failed to get token for WebSocket:", err);
163
- ws.close();
164
- onError("Authentication failed: " + (err.message || err), RECONNECT_DELAY);
165
- }
166
- };
167
- function handleVisibilityResume() {
168
- if (document.visibilityState === "visible" && ws.readyState === WebSocket.OPEN) {
169
- log("Page became visible - refreshing token for WebSocket");
170
- resolveGetToken()({ forceRefresh: true }).then(function(newToken) {
171
- sendTokenUpdate(newToken);
172
- }).catch(function(err) {
173
- log("Token refresh on visibility resume failed:", err);
174
- });
175
- }
176
- }
177
- if (typeof document !== "undefined") {
178
- document.addEventListener("visibilitychange", handleVisibilityResume);
179
- }
180
- function cleanupVisibilityListener() {
181
- if (typeof document !== "undefined") {
182
- document.removeEventListener("visibilitychange", handleVisibilityResume);
183
- }
184
- }
185
- ws.onerror = function(event) {
186
- clearRefreshTimer();
187
- cleanupVisibilityListener();
188
- ws.close();
189
- log("ws.onerror", event);
190
- onError(event?.message, RECONNECT_DELAY);
191
- };
192
- ws.onclose = function(event) {
193
- clearRefreshTimer();
194
- cleanupVisibilityListener();
195
- onError("Socket closed: " + event.reason, RECONNECT_DELAY);
196
- };
197
- var isFirstRound = true;
198
- ws.onmessage = function(event) {
199
- try {
200
- var requestFromServer = JSON.parse(event.data);
201
- log("requestFromServer", requestFromServer, { isFirstRound });
202
- if (requestFromServer.type == "clientIdentity") {
203
- context.clientIdentity = requestFromServer.clientIdentity;
204
- context.save();
205
- sendChanges(changes, baseRevision, partial, onChangesAccepted);
206
- ws.send(
207
- JSON.stringify({
208
- type: "subscribe",
209
- syncedRevision
210
- })
211
- );
212
- } else if (requestFromServer.type == "changes") {
213
- applyRemoteChanges(
214
- requestFromServer.changes,
215
- requestFromServer.currentRevision,
216
- requestFromServer.partial
217
- );
218
- if (isFirstRound && !requestFromServer.partial) {
219
- onSuccess({
220
- // Specify a react function that will react on additional client changes
221
- react: function(changes2, baseRevision2, partial2, onChangesAccepted2) {
222
- sendChanges(
223
- changes2,
224
- baseRevision2,
225
- partial2,
226
- onChangesAccepted2
227
- );
228
- },
229
- disconnect: function() {
230
- clearRefreshTimer();
231
- cleanupVisibilityListener();
232
- ws.close();
233
- }
234
- });
235
- isFirstRound = false;
236
- }
237
- } else if (requestFromServer.type == "tokenUpdateAck") {
238
- if (requestFromServer.ok) {
239
- scheduleTokenRefresh(requestFromServer.authToken || pendingTokenUpdate);
240
- pendingTokenUpdate = null;
241
- } else {
242
- log("tokenUpdate rejected by server:", requestFromServer.code || requestFromServer.message);
243
- pendingTokenUpdate = null;
244
- ws.close(4001, requestFromServer.code || "token_update_failed");
245
- onError(
246
- requestFromServer.message || "Authentication refresh failed",
247
- RECONNECT_DELAY
248
- );
249
- }
250
- } else if (requestFromServer.type == "ack") {
251
- var requestId2 = requestFromServer.requestId;
252
- var acceptCallback = acceptCallbacks[requestId2.toString()];
253
- acceptCallback();
254
- delete acceptCallbacks[requestId2.toString()];
255
- } else if (requestFromServer.type == "error") {
256
- ws.close();
257
- if (requestFromServer.code === "TOKEN_EXPIRED" || requestFromServer.code === "UNAUTHORIZED") {
258
- log("Auth error from server, will reconnect with fresh token:", requestFromServer.message);
259
- onError(requestFromServer.message, RECONNECT_DELAY);
260
- } else {
261
- onError(requestFromServer.message, Infinity);
262
- }
263
- } else {
264
- log("unknown message", requestFromServer);
265
- ws.close();
266
- onError("unknown message", Infinity);
267
- }
268
- } catch (e) {
269
- ws.close();
270
- log("caught error", e);
271
- onError(e, Infinity);
272
- }
273
- };
274
- }
275
- });
276
- };
277
- }
278
- });
279
-
280
59
  // package.json
281
60
  var version;
282
61
  var init_package = __esm({
283
62
  "package.json"() {
284
- version = "0.8.0-beta.4";
63
+ version = "0.9.0-beta.0";
285
64
  }
286
65
  });
287
66
 
@@ -369,24 +148,6 @@ function cleanOAuthParamsFromUrl() {
369
148
  log("Cleaned OAuth parameters from URL");
370
149
  }
371
150
  }
372
- function getSyncStatus(statusCode) {
373
- switch (statusCode) {
374
- case -1:
375
- return "ERROR";
376
- case 0:
377
- return "OFFLINE";
378
- case 1:
379
- return "CONNECTING";
380
- case 2:
381
- return "ONLINE";
382
- case 3:
383
- return "SYNCING";
384
- case 4:
385
- return "ERROR_WILL_RETRY";
386
- default:
387
- return "UNKNOWN";
388
- }
389
- }
390
151
  var import_semver;
391
152
  var init_network = __esm({
392
153
  "src/utils/network.ts"() {
@@ -397,59 +158,147 @@ var init_network = __esm({
397
158
  }
398
159
  });
399
160
 
400
- // src/context.tsx
401
- function useBasic() {
402
- return (0, import_react.useContext)(BasicContext);
161
+ // src/react/hooks.ts
162
+ function useBasicClient() {
163
+ const client = (0, import_react2.useContext)(BasicClientContext);
164
+ if (!client) {
165
+ throw new Error("useBasic must be used within a <BasicProvider>");
166
+ }
167
+ return client;
403
168
  }
404
- var import_react, DBStatus, noDb, BasicContext;
405
- var init_context = __esm({
406
- "src/context.tsx"() {
407
- "use strict";
408
- import_react = require("react");
409
- DBStatus = /* @__PURE__ */ ((DBStatus2) => {
410
- DBStatus2["LOADING"] = "LOADING";
411
- DBStatus2["OFFLINE"] = "OFFLINE";
412
- DBStatus2["CONNECTING"] = "CONNECTING";
413
- DBStatus2["ONLINE"] = "ONLINE";
414
- DBStatus2["SYNCING"] = "SYNCING";
415
- DBStatus2["ERROR"] = "ERROR";
416
- DBStatus2["ERROR_WILL_RETRY"] = "ERROR_WILL_RETRY";
417
- DBStatus2["ERROR_TOKEN_EXPIRED"] = "ERROR_TOKEN_EXPIRED";
418
- return DBStatus2;
419
- })(DBStatus || {});
420
- noDb = {
421
- collection: () => {
422
- throw new Error("no basicdb found - initialization failed. double check your schema.");
169
+ function useClientSnapshot(client) {
170
+ return (0, import_react2.useSyncExternalStore)(client.subscribe, client.getSnapshot, client.getSnapshot);
171
+ }
172
+ function useAuth() {
173
+ const client = useBasicClient();
174
+ const snapshot = useClientSnapshot(client);
175
+ return (0, import_react2.useMemo)(
176
+ () => ({
177
+ isReady: snapshot.isReady,
178
+ isSignedIn: snapshot.isSignedIn,
179
+ status: snapshot.authStatus,
180
+ errorCode: snapshot.authErrorCode,
181
+ user: snapshot.user,
182
+ did: snapshot.did,
183
+ scope: snapshot.scope,
184
+ hasScope: (s) => client.auth.hasScope(s),
185
+ missingScopes: () => client.auth.missingScopes(),
186
+ signIn: (redirectUri) => client.auth.signIn(redirectUri),
187
+ signInWithHandle: (handle) => client.auth.signInWithHandle(handle),
188
+ signInWithCode: (code, state) => client.auth.signInWithCode(code, state),
189
+ signOut: () => client.signOut(),
190
+ getToken: (options) => client.auth.getToken(options),
191
+ getSignInUrl: (redirectUri) => client.auth.getSignInUrl(redirectUri)
192
+ }),
193
+ [client, snapshot]
194
+ );
195
+ }
196
+ function useDb() {
197
+ const client = useBasicClient();
198
+ return client.db;
199
+ }
200
+ function useSyncStatus() {
201
+ const client = useBasicClient();
202
+ const snapshot = useClientSnapshot(client);
203
+ return (0, import_react2.useMemo)(
204
+ () => ({
205
+ status: snapshot.syncStatus,
206
+ enabled: snapshot.syncEnabled,
207
+ pendingCount: snapshot.pendingCount,
208
+ listRejected: () => client.listRejected(),
209
+ clearRejected: () => client.clearRejected()
210
+ }),
211
+ [client, snapshot]
212
+ );
213
+ }
214
+ function useShares() {
215
+ const client = useBasicClient();
216
+ const snapshot = useClientSnapshot(client);
217
+ const [state, setState] = (0, import_react2.useState)({ granted: [], received: [], isLoading: false, error: null });
218
+ const isSignedIn = snapshot.isSignedIn && snapshot.authStatus === "authenticated";
219
+ const refresh = (0, import_react2.useMemo)(
220
+ () => async () => {
221
+ setState((s) => ({ ...s, isLoading: true, error: null }));
222
+ try {
223
+ const { granted, received } = await client.listShares();
224
+ setState({ granted, received, isLoading: false, error: null });
225
+ } catch (err) {
226
+ setState((s) => ({
227
+ ...s,
228
+ isLoading: false,
229
+ error: err instanceof Error ? err : new Error(String(err))
230
+ }));
231
+ }
232
+ },
233
+ [client]
234
+ );
235
+ (0, import_react2.useEffect)(() => {
236
+ if (isSignedIn) void refresh();
237
+ }, [isSignedIn, refresh]);
238
+ return { ...state, refresh };
239
+ }
240
+ function useShare(shareId) {
241
+ const client = useBasicClient();
242
+ const snapshot = useClientSnapshot(client);
243
+ const [handle, setHandle] = (0, import_react2.useState)(null);
244
+ const [error, setError] = (0, import_react2.useState)(null);
245
+ const [revoked, setRevoked] = (0, import_react2.useState)(false);
246
+ const canMount = !!shareId && snapshot.isSignedIn && snapshot.authStatus !== "reauth_required";
247
+ (0, import_react2.useEffect)(() => {
248
+ if (!canMount || !shareId) return;
249
+ let cancelled = false;
250
+ setError(null);
251
+ setRevoked(false);
252
+ client.mountShare(shareId).then((h) => {
253
+ if (!cancelled) setHandle(h);
254
+ }).catch((err) => {
255
+ if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
256
+ });
257
+ const offSubError = client.engine?.on("suberror", ({ sub, code }) => {
258
+ if (sub === `share:${shareId}` && (code === "SHARE_REVOKED" || code === "CONNECTION_REVOKED")) {
259
+ setRevoked(true);
260
+ setHandle(null);
423
261
  }
424
- };
425
- BasicContext = (0, import_react.createContext)({
426
- isReady: false,
427
- isSignedIn: false,
428
- authStatus: "bootstrapping",
429
- authErrorCode: null,
430
- user: null,
431
- did: null,
432
- scope: null,
433
- hasScope: () => false,
434
- missingScopes: () => [],
435
- signIn: () => Promise.resolve(),
436
- signInWithHandle: () => Promise.resolve(),
437
- signOut: () => Promise.resolve(),
438
- signInWithCode: () => Promise.resolve({ success: false }),
439
- getToken: (_options) => Promise.reject(new Error("no token")),
440
- getSignInUrl: () => Promise.resolve(""),
441
- db: noDb,
442
- dbStatus: "LOADING" /* LOADING */,
443
- dbMode: "sync",
444
- devInfo: null,
445
- refreshSchemaStatus: async () => {
446
- },
447
- isAuthReady: false,
448
- signin: () => Promise.resolve(),
449
- signout: () => Promise.resolve(),
450
- signinWithCode: () => Promise.resolve({ success: false }),
451
- getSignInLink: () => Promise.resolve("")
452
262
  });
263
+ return () => {
264
+ cancelled = true;
265
+ offSubError?.();
266
+ setHandle(null);
267
+ void client.unmountShare(shareId).catch(() => {
268
+ });
269
+ };
270
+ }, [client, shareId, canMount]);
271
+ return {
272
+ db: handle?.db ?? null,
273
+ status: revoked ? "revoked" : error ? "error" : handle ? "mounted" : "mounting",
274
+ error
275
+ };
276
+ }
277
+ function useBasic() {
278
+ const client = useBasicClient();
279
+ const snapshot = useClientSnapshot(client);
280
+ const auth = useAuth();
281
+ const sync = useSyncStatus();
282
+ return (0, import_react2.useMemo)(
283
+ () => ({
284
+ ...auth,
285
+ db: client.db,
286
+ sync,
287
+ devInfo: snapshot.devInfo,
288
+ refreshSchemaStatus: () => client.refreshSchemaStatus(),
289
+ client
290
+ }),
291
+ [client, snapshot, auth, sync]
292
+ );
293
+ }
294
+ var import_react2, import_dexie_react_hooks, useQuery;
295
+ var init_hooks = __esm({
296
+ "src/react/hooks.ts"() {
297
+ "use strict";
298
+ import_react2 = require("react");
299
+ import_dexie_react_hooks = require("dexie-react-hooks");
300
+ init_context();
301
+ useQuery = import_dexie_react_hooks.useLiveQuery;
453
302
  }
454
303
  });
455
304
 
@@ -463,11 +312,11 @@ function toneForAuth(isReady, isSignedIn) {
463
312
  if (isSignedIn) return "ok";
464
313
  return "warn";
465
314
  }
466
- function toneForDb(dbMode, dbStatus) {
467
- if (dbMode === "remote") return dbStatus === "ONLINE" /* ONLINE */ ? "ok" : "warn";
468
- if (dbStatus === "ONLINE" /* ONLINE */ || dbStatus === "SYNCING" /* SYNCING */) return "ok";
469
- if (dbStatus === "CONNECTING" /* CONNECTING */ || dbStatus === "LOADING" /* LOADING */) return "warn";
470
- if (dbStatus === "OFFLINE" /* OFFLINE */) return "muted";
315
+ function toneForSync(mode, status) {
316
+ if (mode === "rest") return "muted";
317
+ if (status === "online") return "ok";
318
+ if (status === "connecting") return "warn";
319
+ if (status === "offline" || status === "idle" || status === "stopped") return "muted";
471
320
  return "bad";
472
321
  }
473
322
  function toneForSchema(info) {
@@ -477,24 +326,22 @@ function toneForSchema(info) {
477
326
  if (info.status === "no_schema") return "muted";
478
327
  return "bad";
479
328
  }
480
- function dbStatusLabel(status) {
329
+ function syncStatusLabel(status) {
481
330
  switch (status) {
482
- case "LOADING" /* LOADING */:
483
- return "Initializing";
484
- case "OFFLINE" /* OFFLINE */:
485
- return "Offline";
486
- case "CONNECTING" /* CONNECTING */:
331
+ case "idle":
332
+ return "Idle";
333
+ case "connecting":
487
334
  return "Connecting";
488
- case "ONLINE" /* ONLINE */:
335
+ case "online":
489
336
  return "Connected";
490
- case "SYNCING" /* SYNCING */:
491
- return "Syncing";
492
- case "ERROR" /* ERROR */:
493
- return "Error";
494
- case "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */:
495
- return "Retrying";
496
- case "ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */:
497
- return "Token refresh";
337
+ case "offline":
338
+ return "Offline";
339
+ case "auth_required":
340
+ return "Reauth required";
341
+ case "revoked":
342
+ return "Connection revoked";
343
+ case "stopped":
344
+ return "Stopped";
498
345
  default:
499
346
  return String(status);
500
347
  }
@@ -583,9 +430,9 @@ function CopyableRow({
583
430
  onCopied,
584
431
  children
585
432
  }) {
586
- const [hover, setHover] = (0, import_react2.useState)(false);
433
+ const [hover, setHover] = (0, import_react3.useState)(false);
587
434
  const canCopy = copyText.length > 0;
588
- const handleClick = (0, import_react2.useCallback)(
435
+ const handleClick = (0, import_react3.useCallback)(
589
436
  (e) => {
590
437
  e.stopPropagation();
591
438
  if (!canCopy) return;
@@ -663,21 +510,24 @@ function BasicDevToolbar({ enabled = true, debug }) {
663
510
  did,
664
511
  scope,
665
512
  missingScopes,
666
- dbMode,
667
- dbStatus,
513
+ sync,
668
514
  devInfo,
669
- refreshSchemaStatus
515
+ refreshSchemaStatus,
516
+ client
670
517
  } = useBasic();
671
- const [open, setOpen] = (0, import_react2.useState)(false);
672
- const [refreshing, setRefreshing] = (0, import_react2.useState)(false);
673
- const [copied, setCopied] = (0, import_react2.useState)(false);
674
- const [rowCopied, setRowCopied] = (0, import_react2.useState)(null);
518
+ const dbMode = client.mode;
519
+ const syncStatus = sync.status;
520
+ const indexedDbName = dbMode === "sync" && client.projectId ? `basic-sync:${client.projectId}` : null;
521
+ const [open, setOpen] = (0, import_react3.useState)(false);
522
+ const [refreshing, setRefreshing] = (0, import_react3.useState)(false);
523
+ const [copied, setCopied] = (0, import_react3.useState)(false);
524
+ const [rowCopied, setRowCopied] = (0, import_react3.useState)(null);
675
525
  const show = enabled && typeof window !== "undefined" && isDevelopment(debug);
676
526
  const authTone = toneForAuth(isReady, isSignedIn);
677
- const dbTone = toneForDb(dbMode, dbStatus);
527
+ const dbTone = toneForSync(dbMode, syncStatus);
678
528
  const schemaTone = toneForSchema(devInfo);
679
- const syncTone = dbMode === "remote" ? "muted" : dbTone === "ok" || dbStatus === "SYNCING" /* SYNCING */ ? "ok" : dbTone === "warn" ? "warn" : dbTone === "bad" ? "bad" : "muted";
680
- const handleRefreshSchema = (0, import_react2.useCallback)(async () => {
529
+ const syncTone = dbTone;
530
+ const handleRefreshSchema = (0, import_react3.useCallback)(async () => {
681
531
  setRefreshing(true);
682
532
  try {
683
533
  await refreshSchemaStatus();
@@ -686,7 +536,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
686
536
  }
687
537
  }, [refreshSchemaStatus]);
688
538
  const missingList = missingScopes();
689
- const debugPayload = (0, import_react2.useMemo)(() => {
539
+ const debugPayload = (0, import_react3.useMemo)(() => {
690
540
  return {
691
541
  sdkVersion: version,
692
542
  isReady,
@@ -701,12 +551,13 @@ function BasicDevToolbar({ enabled = true, debug }) {
701
551
  scope,
702
552
  missingScopes: missingList,
703
553
  dbMode,
704
- dbStatus,
705
- indexedDbName: dbMode === "sync" ? INDEXED_DB_NAME : null,
554
+ syncStatus,
555
+ pendingOps: sync.pendingCount,
556
+ indexedDbName,
706
557
  schema: devInfo
707
558
  };
708
- }, [isReady, isSignedIn, did, user, scope, dbMode, dbStatus, devInfo, missingList]);
709
- const handleCopy = (0, import_react2.useCallback)(async () => {
559
+ }, [isReady, isSignedIn, did, user, scope, dbMode, syncStatus, sync.pendingCount, indexedDbName, devInfo, missingList]);
560
+ const handleCopy = (0, import_react3.useCallback)(async () => {
710
561
  try {
711
562
  await navigator.clipboard.writeText(JSON.stringify(debugPayload, null, 2));
712
563
  setCopied(true);
@@ -714,7 +565,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
714
565
  } catch {
715
566
  }
716
567
  }, [debugPayload]);
717
- const onRowCopied = (0, import_react2.useCallback)((key) => {
568
+ const onRowCopied = (0, import_react3.useCallback)((key) => {
718
569
  setRowCopied(key);
719
570
  setTimeout(() => setRowCopied((k) => k === key ? null : k), 1500);
720
571
  }, []);
@@ -788,7 +639,7 @@ function BasicDevToolbar({ enabled = true, debug }) {
788
639
  minWidth: 300,
789
640
  maxWidth: "min(560px, calc(100vw - 24px))"
790
641
  };
791
- const syncStatusText = dbStatusLabel(dbStatus);
642
+ const syncStatusText = dbMode === "rest" ? "REST mode" : `${syncStatusLabel(syncStatus)}${sync.pendingCount > 0 ? ` (${sync.pendingCount} pending)` : ""}`;
792
643
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: shell, children: [
793
644
  open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: panel, children: [
794
645
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginBottom: 12 }, children: [
@@ -883,10 +734,10 @@ function BasicDevToolbar({ enabled = true, debug }) {
883
734
  {
884
735
  rowKey: "indexedDb",
885
736
  label: "IndexedDB",
886
- copyText: dbMode === "sync" ? INDEXED_DB_NAME : "",
737
+ copyText: indexedDbName ?? "",
887
738
  copiedKey: rowCopied,
888
739
  onCopied: onRowCopied,
889
- children: dbMode === "sync" ? INDEXED_DB_NAME : "\u2014"
740
+ children: indexedDbName ?? "\u2014"
890
741
  }
891
742
  ),
892
743
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -1066,17 +917,16 @@ function BasicDevToolbar({ enabled = true, debug }) {
1066
917
  )
1067
918
  ] });
1068
919
  }
1069
- var import_react2, import_jsx_runtime, INDEXED_DB_NAME, PANEL_PAD_X;
920
+ var import_react3, import_jsx_runtime, PANEL_PAD_X;
1070
921
  var init_BasicDevToolbar = __esm({
1071
922
  "src/dev/BasicDevToolbar.tsx"() {
1072
923
  "use strict";
1073
924
  "use client";
1074
- import_react2 = require("react");
1075
- init_context();
925
+ import_react3 = require("react");
926
+ init_hooks();
1076
927
  init_package();
1077
928
  init_network();
1078
929
  import_jsx_runtime = require("react/jsx-runtime");
1079
- INDEXED_DB_NAME = "basicdb";
1080
930
  PANEL_PAD_X = 12;
1081
931
  }
1082
932
  });
@@ -1084,608 +934,146 @@ var init_BasicDevToolbar = __esm({
1084
934
  // src/index.ts
1085
935
  var index_exports = {};
1086
936
  __export(index_exports, {
937
+ AuthManager: () => AuthManager,
938
+ BasicClient: () => BasicClient,
1087
939
  BasicDevToolbar: () => BasicDevToolbar,
1088
940
  BasicProvider: () => BasicProvider,
1089
- DBStatus: () => DBStatus,
941
+ DEFAULT_LIMITS: () => DEFAULT_LIMITS,
942
+ LocalStorageAdapter: () => LocalStorageAdapter,
1090
943
  NotAuthenticatedError: () => NotAuthenticatedError,
1091
- RemoteCollection: () => RemoteCollection,
1092
- RemoteDB: () => RemoteDB,
1093
- RemoteDBError: () => RemoteDBError,
944
+ OWN_SUB: () => OWN_SUB,
945
+ PROTOCOL_VERSION: () => PROTOCOL_VERSION,
946
+ RestClient: () => RestClient,
947
+ RestDb: () => RestDb,
948
+ RestError: () => RestError,
1094
949
  STORAGE_KEYS: () => STORAGE_KEYS,
950
+ SyncConnection: () => SyncConnection,
951
+ SyncDb: () => SyncDb,
952
+ SyncEngine: () => SyncEngine,
953
+ SyncStore: () => SyncStore,
954
+ applyOpToData: () => applyOpToData,
955
+ createBasicClient: () => createBasicClient,
956
+ isAuthError: () => isAuthError,
957
+ isRebootstrapError: () => isRebootstrapError,
958
+ isRevocationError: () => isRevocationError,
959
+ isTerminalOpError: () => isTerminalOpError,
960
+ mintOpId: () => mintOpId,
1095
961
  resolveDid: () => resolveDid,
1096
962
  resolveDidWebUrl: () => resolveDidWebUrl,
1097
963
  resolveHandle: () => resolveHandle,
964
+ shareSubKey: () => shareSubKey,
965
+ useAuth: () => useAuth,
1098
966
  useBasic: () => useBasic,
1099
- useQuery: () => import_dexie_react_hooks.useLiveQuery
967
+ useBasicClient: () => useBasicClient,
968
+ useDb: () => useDb,
969
+ useQuery: () => useQuery,
970
+ useShare: () => useShare,
971
+ useShares: () => useShares,
972
+ useSyncStatus: () => useSyncStatus
1100
973
  });
1101
974
  module.exports = __toCommonJS(index_exports);
1102
975
 
1103
- // src/AuthContext.tsx
1104
- var import_react3 = require("react");
976
+ // src/react/BasicProvider.tsx
977
+ var import_react4 = require("react");
978
+ init_context();
1105
979
 
1106
- // src/sync/index.ts
1107
- var import_uuid = require("uuid");
1108
- var import_dexie2 = require("dexie");
1109
- init_config();
1110
- var import_schema = require("@basictech/schema");
1111
- init_tokenRegistry();
1112
- var dexieExtensionsLoaded = false;
1113
- var initPromise = null;
1114
- async function initDexieExtensions() {
1115
- if (dexieExtensionsLoaded) return;
1116
- if (typeof window === "undefined") return;
1117
- if (initPromise) return initPromise;
1118
- initPromise = (async () => {
1119
- try {
1120
- await import("dexie-syncable");
1121
- await import("dexie-observable");
1122
- const { syncProtocol: syncProtocol2 } = await Promise.resolve().then(() => (init_syncProtocol(), syncProtocol_exports));
1123
- syncProtocol2();
1124
- dexieExtensionsLoaded = true;
1125
- log("Dexie extensions loaded successfully");
1126
- } catch (error) {
1127
- console.error("Failed to load Dexie extensions:", error);
1128
- throw error;
1129
- }
1130
- })();
1131
- return initPromise;
1132
- }
1133
- var BasicSync = class extends import_dexie2.Dexie {
1134
- basic_schema;
1135
- constructor(name, options) {
1136
- super(name, options);
1137
- this.basic_schema = options.schema;
1138
- this.version(1).stores(this._convertSchemaToDxSchema(this.basic_schema));
1139
- this.version(2).stores({});
1140
- this.Collection.prototype.get = this.Collection.prototype.toArray;
1141
- }
1142
- async connect({ getToken, ws_url }) {
1143
- const WS_URL = ws_url || "wss://pds.basic.id/ws";
1144
- log("Connecting to", WS_URL);
1145
- setTokenGetter(WS_URL, getToken);
1146
- await this.updateSyncNodes();
1147
- log("Starting connection...");
1148
- return this.syncable.connect("websocket", WS_URL, { schema: this.basic_schema });
1149
- }
1150
- async disconnect({ ws_url } = {}) {
1151
- const WS_URL = ws_url || "wss://pds.basic.id/ws";
1152
- return this.syncable.disconnect(WS_URL);
1153
- }
1154
- async updateSyncNodes() {
1155
- try {
1156
- const syncNodes = await this.table("_syncNodes").toArray();
1157
- const localSyncNodes = syncNodes.filter((node) => node.type === "local");
1158
- log("Local sync nodes:", localSyncNodes);
1159
- if (localSyncNodes.length > 1) {
1160
- const largestNodeId = Math.max(...localSyncNodes.map((node) => node.id));
1161
- const largestNode = localSyncNodes.find((node) => node.id === largestNodeId);
1162
- if (largestNode && largestNode.isMaster === 1) {
1163
- log("Largest node is already the master. No changes needed.");
1164
- return;
1165
- }
1166
- log("Largest node id:", largestNodeId);
1167
- log("HEISENBUG: More than one local sync node found.");
1168
- for (const node of localSyncNodes) {
1169
- log(`Local sync node keys:`, node.id, node.isMaster);
1170
- await this.table("_syncNodes").update(node.id, { isMaster: node.id === largestNodeId ? 1 : 0 });
1171
- log(`HEISENBUG: Setting ${node.id} to ${node.id === largestNodeId ? "master" : "0"}`);
1172
- }
1173
- await new Promise((resolve) => setTimeout(resolve, 1e3));
1174
- if (typeof window !== "undefined") {
1175
- window.location.reload();
1176
- }
1177
- }
1178
- log("Sync nodes updated");
1179
- } catch (error) {
1180
- console.error("Error updating _syncNodes table:", error);
1181
- }
1182
- }
1183
- handleStatusChange(fn) {
1184
- this.syncable.on("statusChanged", fn);
1185
- }
1186
- _convertSchemaToDxSchema(schema) {
1187
- const stores = Object.entries(schema.tables).map(([key, table]) => {
1188
- const indexedFields = Object.entries(table.fields).filter(([, field]) => field.indexed).map(([fieldKey]) => `,${fieldKey}`).join("");
1189
- return {
1190
- [key]: "id" + indexedFields
1191
- };
1192
- });
1193
- return Object.assign({}, ...stores);
980
+ // src/core/auth/AuthManager.ts
981
+ var import_jwt_decode = require("jwt-decode");
982
+
983
+ // src/utils/storage.ts
984
+ var LocalStorageAdapter = class {
985
+ async get(key) {
986
+ return localStorage.getItem(key);
1194
987
  }
1195
- debugeroo() {
1196
- return this.syncable;
988
+ async set(key, value) {
989
+ localStorage.setItem(key, value);
1197
990
  }
1198
- collection(name) {
1199
- if (this.basic_schema?.tables && !this.basic_schema.tables[name]) {
1200
- throw new Error(`Table "${name}" not found in schema`);
1201
- }
1202
- const table = this.table(name);
1203
- return {
1204
- /**
1205
- * Returns the underlying Dexie table
1206
- * @type {Dexie.Table}
1207
- */
1208
- ref: table,
1209
- // --- WRITE ---- //
1210
- /**
1211
- * Add a new record - returns the full object with generated id
1212
- */
1213
- add: async (data) => {
1214
- const valid = (0, import_schema.validateData)(this.basic_schema, name, data);
1215
- if (!valid.valid) {
1216
- log("Invalid data", valid);
1217
- throw new Error(valid.message || "Data validation failed");
1218
- }
1219
- const id = (0, import_uuid.v7)();
1220
- const fullData = { id, ...data };
1221
- await table.add(fullData);
1222
- return fullData;
1223
- },
1224
- /**
1225
- * Put (upsert) a record - returns the full object
1226
- */
1227
- put: async (data) => {
1228
- if (!data.id) {
1229
- throw new Error("put() requires an id field");
1230
- }
1231
- const valid = (0, import_schema.validateData)(this.basic_schema, name, data);
1232
- if (!valid.valid) {
1233
- log("Invalid data", valid);
1234
- throw new Error(valid.message || "Data validation failed");
1235
- }
1236
- await table.put(data);
1237
- return data;
1238
- },
1239
- /**
1240
- * Update an existing record - returns updated object or null
1241
- */
1242
- update: async (id, data) => {
1243
- if (!id) {
1244
- throw new Error("update() requires an id");
1245
- }
1246
- const valid = (0, import_schema.validateData)(this.basic_schema, name, data, false);
1247
- if (!valid.valid) {
1248
- log("Invalid data", valid);
1249
- throw new Error(valid.message || "Data validation failed");
1250
- }
1251
- const updated = await table.update(id, data);
1252
- if (updated === 0) {
1253
- return null;
1254
- }
1255
- const record = await table.get(id);
1256
- return record || null;
1257
- },
1258
- /**
1259
- * Delete a record - returns true if deleted, false if not found
1260
- */
1261
- delete: async (id) => {
1262
- if (!id) {
1263
- throw new Error("delete() requires an id");
1264
- }
1265
- const exists = await table.get(id);
1266
- if (!exists) {
1267
- return false;
1268
- }
1269
- await table.delete(id);
1270
- return true;
1271
- },
1272
- // --- READ ---- //
1273
- /**
1274
- * Get a single record by id - returns null if not found
1275
- */
1276
- get: async (id) => {
1277
- if (!id) {
1278
- throw new Error("get() requires an id");
1279
- }
1280
- const record = await table.get(id);
1281
- return record || null;
1282
- },
1283
- /**
1284
- * Get all records in the collection
1285
- */
1286
- getAll: async () => {
1287
- return table.toArray();
1288
- },
1289
- // --- QUERY ---- //
1290
- /**
1291
- * Filter records using a predicate function
1292
- */
1293
- filter: async (fn) => {
1294
- return table.filter(fn).toArray();
1295
- },
1296
- /**
1297
- * Get the raw Dexie table for advanced queries
1298
- * @deprecated Use ref instead
1299
- */
1300
- query: () => table
1301
- };
991
+ async remove(key) {
992
+ localStorage.removeItem(key);
1302
993
  }
1303
994
  };
995
+ var STORAGE_KEYS = {
996
+ REFRESH_TOKEN: "basic_refresh_token",
997
+ USER_INFO: "basic_user_info",
998
+ AUTH_STATE: "basic_auth_state",
999
+ REDIRECT_URI: "basic_redirect_uri",
1000
+ SERVER_URL: "basic_server_url",
1001
+ PDS_ENDPOINTS: "basic_pds_endpoints",
1002
+ LAST_CONNECT_REPORT: "basic_last_connect_report",
1003
+ DEBUG: "basic_debug",
1004
+ CODE_VERIFIER: "basic_code_verifier"
1005
+ };
1304
1006
 
1305
- // src/core/db/types.ts
1306
- var RemoteDBError = class extends Error {
1307
- status;
1308
- response;
1309
- constructor(message, status, response) {
1310
- super(message);
1311
- this.name = "RemoteDBError";
1312
- this.status = status;
1313
- this.response = response;
1007
+ // src/utils/normalizeClientId.ts
1008
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1009
+ function normalizeClientId(projectId, adminHostname = "api.basic.tech") {
1010
+ if (!projectId) return projectId;
1011
+ if (projectId === "self") return projectId;
1012
+ if (projectId.startsWith("did:")) return projectId;
1013
+ if (UUID_RE.test(projectId)) {
1014
+ const hex = projectId.replace(/-/g, "").toLowerCase();
1015
+ return `did:web:${adminHostname}:projects:${hex}`;
1314
1016
  }
1315
- };
1017
+ return projectId;
1018
+ }
1316
1019
 
1317
- // src/core/db/RemoteCollection.ts
1318
- var import_schema2 = require("@basictech/schema");
1319
- var NotAuthenticatedError = class extends Error {
1320
- constructor(message = "Not authenticated") {
1321
- super(message);
1322
- this.name = "NotAuthenticatedError";
1020
+ // src/utils/resolveDid.ts
1021
+ function resolveDidWebUrl(did) {
1022
+ if (!did.startsWith("did:web:")) return null;
1023
+ const rest = did.slice(8);
1024
+ if (!rest) return null;
1025
+ const parts = rest.split(":");
1026
+ const hostname = parts[0].replace(/%3A/gi, ":");
1027
+ if (parts.length === 1) {
1028
+ return `https://${hostname}/.well-known/did.json`;
1323
1029
  }
1324
- };
1325
- var RemoteCollection = class {
1326
- tableName;
1327
- config;
1328
- constructor(tableName, config) {
1329
- this.tableName = tableName;
1330
- this.config = config;
1030
+ const pathParts = parts.slice(1).map((p) => decodeURIComponent(p));
1031
+ return `https://${hostname}/${pathParts.join("/")}/did.json`;
1032
+ }
1033
+ async function resolveFromDocument(did, didDocument) {
1034
+ const services = didDocument.service;
1035
+ const pdsService = services?.find(
1036
+ (s) => s.id === "#basic_pds" || s.id === `${did}#basic_pds`
1037
+ );
1038
+ if (!pdsService) {
1039
+ throw new Error(`DID document has no #basic_pds service entry`);
1331
1040
  }
1332
- log(...args) {
1333
- if (this.config.debug) {
1334
- console.log("[RemoteDB]", ...args);
1335
- }
1041
+ const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, "");
1042
+ const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`);
1043
+ if (!oauthRes.ok) {
1044
+ throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`);
1336
1045
  }
1337
- /**
1338
- * Check if an error is a "not authenticated" error
1339
- */
1340
- isNotAuthenticatedError(error) {
1341
- if (error instanceof Error) {
1342
- const message = error.message.toLowerCase();
1343
- return message.includes("no token") || message.includes("not authenticated") || message.includes("please sign in");
1344
- }
1345
- return false;
1046
+ const oauth = await oauthRes.json();
1047
+ return {
1048
+ did,
1049
+ didDocument,
1050
+ pdsUrl,
1051
+ authorization_endpoint: oauth.authorization_endpoint,
1052
+ token_endpoint: oauth.token_endpoint,
1053
+ userinfo_endpoint: oauth.userinfo_endpoint
1054
+ };
1055
+ }
1056
+ async function resolveDid(did) {
1057
+ const url = resolveDidWebUrl(did);
1058
+ if (!url) {
1059
+ throw new Error(`Unsupported DID method: ${did}`);
1346
1060
  }
1347
- /**
1348
- * Helper to make authenticated API requests
1349
- * Automatically retries once on 401 (token expired) by refreshing the token
1350
- */
1351
- async request(method, path, body, isRetry = false) {
1352
- const token = await this.config.getToken();
1353
- const url = `${this.config.serverUrl}${path}`;
1354
- this.log(`${method} ${url}`, body ? JSON.stringify(body) : "");
1355
- const headers = {
1356
- "Authorization": `Bearer ${token}`
1357
- };
1358
- if (body) {
1359
- headers["Content-Type"] = "application/json";
1360
- }
1361
- const response = await fetch(url, {
1362
- method,
1363
- headers,
1364
- ...body ? { body: JSON.stringify(body) } : {}
1365
- });
1366
- const responseData = await response.json().catch(() => ({}));
1367
- if (!response.ok) {
1368
- if (response.status === 401 && !isRetry) {
1369
- this.log("Got 401, forcing token refresh and retrying...");
1370
- await this.config.getToken({ forceRefresh: true });
1371
- return this.request(method, path, body, true);
1372
- }
1373
- if (this.config.debug) {
1374
- console.error(`[RemoteDB] Error ${response.status}:`, responseData);
1375
- }
1376
- if (this.config.onAuthError) {
1377
- if (response.status === 401) {
1378
- this.config.onAuthError({
1379
- status: response.status,
1380
- message: "Authentication failed",
1381
- response: responseData,
1382
- errorType: "expired",
1383
- afterRetry: isRetry
1384
- });
1385
- } else if (response.status === 403) {
1386
- this.config.onAuthError({
1387
- status: response.status,
1388
- message: responseData.message || "Forbidden - insufficient permissions or missing scope",
1389
- response: responseData,
1390
- errorType: "forbidden",
1391
- afterRetry: isRetry
1392
- });
1393
- }
1394
- }
1395
- const errorMessage = responseData.message || responseData.error || responseData.detail || (typeof responseData === "string" ? responseData : `API request failed: ${response.status}`);
1396
- throw new RemoteDBError(errorMessage, response.status, responseData);
1397
- }
1398
- this.log("Response:", responseData);
1399
- return responseData;
1061
+ const didRes = await fetch(url);
1062
+ if (!didRes.ok) {
1063
+ throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`);
1400
1064
  }
1401
- /**
1402
- * Validate data against schema if available
1403
- */
1404
- validateData(data, checkRequired = true) {
1405
- if (this.config.schema) {
1406
- const result = (0, import_schema2.validateData)(this.config.schema, this.tableName, data, checkRequired);
1407
- if (!result.valid) {
1408
- throw new Error(result.message || "Data validation failed");
1409
- }
1410
- }
1065
+ const didDocument = await didRes.json();
1066
+ return resolveFromDocument(did, didDocument);
1067
+ }
1068
+ async function resolveHandle(handle) {
1069
+ const res = await fetch(`https://${handle}/.well-known/did.json`);
1070
+ if (!res.ok) {
1071
+ throw new Error(`Handle resolution failed for ${handle}: ${res.status}`);
1411
1072
  }
1412
- /**
1413
- * Get the base path for this collection
1414
- */
1415
- get basePath() {
1416
- return `/account/${this.config.projectId}/db/${this.tableName}`;
1417
- }
1418
- /**
1419
- * Add a new record to the collection
1420
- * The server generates the ID
1421
- * Requires authentication - throws NotAuthenticatedError if not signed in
1422
- */
1423
- async add(data) {
1424
- this.validateData(data, true);
1425
- try {
1426
- const result = await this.request(
1427
- "POST",
1428
- this.basePath,
1429
- { value: data }
1430
- );
1431
- return result.data;
1432
- } catch (error) {
1433
- if (this.isNotAuthenticatedError(error)) {
1434
- throw new NotAuthenticatedError("Sign in required to add items");
1435
- }
1436
- throw error;
1437
- }
1438
- }
1439
- /**
1440
- * Put (upsert) a record - requires id
1441
- * Requires authentication - throws NotAuthenticatedError if not signed in
1442
- */
1443
- async put(data) {
1444
- if (!data.id) {
1445
- throw new Error("put() requires an id field");
1446
- }
1447
- const { id, ...rest } = data;
1448
- this.validateData(rest, true);
1449
- try {
1450
- const result = await this.request(
1451
- "PUT",
1452
- `${this.basePath}/${id}`,
1453
- { value: rest }
1454
- );
1455
- return result.data || data;
1456
- } catch (error) {
1457
- if (this.isNotAuthenticatedError(error)) {
1458
- throw new NotAuthenticatedError("Sign in required to update items");
1459
- }
1460
- throw error;
1461
- }
1462
- }
1463
- /**
1464
- * Update an existing record by id
1465
- * Requires authentication - throws NotAuthenticatedError if not signed in
1466
- */
1467
- async update(id, data) {
1468
- if (!id) {
1469
- throw new Error("update() requires an id");
1470
- }
1471
- this.validateData(data, false);
1472
- try {
1473
- const result = await this.request(
1474
- "PATCH",
1475
- `${this.basePath}/${id}`,
1476
- { value: data }
1477
- );
1478
- return result.data || null;
1479
- } catch (error) {
1480
- if (error instanceof RemoteDBError && error.status === 404) {
1481
- return null;
1482
- }
1483
- if (this.isNotAuthenticatedError(error)) {
1484
- throw new NotAuthenticatedError("Sign in required to update items");
1485
- }
1486
- throw error;
1487
- }
1488
- }
1489
- /**
1490
- * Delete a record by id
1491
- * Requires authentication - throws NotAuthenticatedError if not signed in
1492
- */
1493
- async delete(id) {
1494
- if (!id) {
1495
- throw new Error("delete() requires an id");
1496
- }
1497
- try {
1498
- await this.request(
1499
- "DELETE",
1500
- `${this.basePath}/${id}`
1501
- );
1502
- return true;
1503
- } catch (error) {
1504
- if (error instanceof RemoteDBError && error.status === 404) {
1505
- return false;
1506
- }
1507
- if (this.isNotAuthenticatedError(error)) {
1508
- throw new NotAuthenticatedError("Sign in required to delete items");
1509
- }
1510
- throw error;
1511
- }
1512
- }
1513
- /**
1514
- * Get a single record by id
1515
- * Returns null if not authenticated (graceful degradation for read operations)
1516
- */
1517
- async get(id) {
1518
- if (!id) {
1519
- throw new Error("get() requires an id");
1520
- }
1521
- try {
1522
- const result = await this.request(
1523
- "GET",
1524
- `${this.basePath}?id=${id}`
1525
- );
1526
- return result.data?.[0] || null;
1527
- } catch (error) {
1528
- if (this.isNotAuthenticatedError(error)) {
1529
- this.log("Not authenticated - returning null for get()");
1530
- }
1531
- return null;
1532
- }
1533
- }
1534
- /**
1535
- * Get all records in the collection
1536
- * Returns empty array if not authenticated (graceful degradation for read operations)
1537
- */
1538
- async getAll() {
1539
- try {
1540
- const result = await this.request(
1541
- "GET",
1542
- this.basePath
1543
- );
1544
- return result.data || [];
1545
- } catch (error) {
1546
- if (this.isNotAuthenticatedError(error)) {
1547
- this.log("Not authenticated - returning empty array for getAll()");
1548
- return [];
1549
- }
1550
- throw error;
1551
- }
1552
- }
1553
- /**
1554
- * Filter records using a predicate function
1555
- * Note: This fetches all records and filters client-side
1556
- * Returns empty array if not authenticated (graceful degradation for read operations)
1557
- */
1558
- async filter(fn) {
1559
- const all = await this.getAll();
1560
- return all.filter(fn);
1561
- }
1562
- /**
1563
- * ref is not available for remote collections
1564
- */
1565
- ref = void 0;
1566
- };
1567
-
1568
- // src/core/db/RemoteDB.ts
1569
- var RemoteDB = class {
1570
- config;
1571
- collections = /* @__PURE__ */ new Map();
1572
- constructor(config) {
1573
- this.config = config;
1574
- }
1575
- /**
1576
- * Get a collection by name
1577
- * Collections are cached for reuse
1578
- */
1579
- collection(name) {
1580
- if (this.collections.has(name)) {
1581
- return this.collections.get(name);
1582
- }
1583
- if (this.config.schema?.tables && !this.config.schema.tables[name]) {
1584
- throw new Error(`Table "${name}" not found in schema`);
1585
- }
1586
- const collection = new RemoteCollection(name, this.config);
1587
- this.collections.set(name, collection);
1588
- return collection;
1589
- }
1590
- };
1591
-
1592
- // src/core/auth/AuthManager.ts
1593
- var import_jwt_decode = require("jwt-decode");
1594
-
1595
- // src/utils/storage.ts
1596
- var LocalStorageAdapter = class {
1597
- async get(key) {
1598
- return localStorage.getItem(key);
1599
- }
1600
- async set(key, value) {
1601
- localStorage.setItem(key, value);
1602
- }
1603
- async remove(key) {
1604
- localStorage.removeItem(key);
1605
- }
1606
- };
1607
- var STORAGE_KEYS = {
1608
- REFRESH_TOKEN: "basic_refresh_token",
1609
- USER_INFO: "basic_user_info",
1610
- AUTH_STATE: "basic_auth_state",
1611
- REDIRECT_URI: "basic_redirect_uri",
1612
- SERVER_URL: "basic_server_url",
1613
- PDS_ENDPOINTS: "basic_pds_endpoints",
1614
- LAST_CONNECT_REPORT: "basic_last_connect_report",
1615
- DEBUG: "basic_debug",
1616
- CODE_VERIFIER: "basic_code_verifier"
1617
- };
1618
-
1619
- // src/utils/normalizeClientId.ts
1620
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1621
- function normalizeClientId(projectId, adminHostname = "api.basic.tech") {
1622
- if (!projectId) return projectId;
1623
- if (projectId === "self") return projectId;
1624
- if (projectId.startsWith("did:")) return projectId;
1625
- if (UUID_RE.test(projectId)) {
1626
- const hex = projectId.replace(/-/g, "").toLowerCase();
1627
- return `did:web:${adminHostname}:projects:${hex}`;
1628
- }
1629
- return projectId;
1630
- }
1631
-
1632
- // src/utils/resolveDid.ts
1633
- function resolveDidWebUrl(did) {
1634
- if (!did.startsWith("did:web:")) return null;
1635
- const rest = did.slice(8);
1636
- if (!rest) return null;
1637
- const parts = rest.split(":");
1638
- const hostname = parts[0].replace(/%3A/gi, ":");
1639
- if (parts.length === 1) {
1640
- return `https://${hostname}/.well-known/did.json`;
1641
- }
1642
- const pathParts = parts.slice(1).map((p) => decodeURIComponent(p));
1643
- return `https://${hostname}/${pathParts.join("/")}/did.json`;
1644
- }
1645
- async function resolveFromDocument(did, didDocument) {
1646
- const services = didDocument.service;
1647
- const pdsService = services?.find(
1648
- (s) => s.id === "#basic_pds" || s.id === `${did}#basic_pds`
1649
- );
1650
- if (!pdsService) {
1651
- throw new Error(`DID document has no #basic_pds service entry`);
1652
- }
1653
- const pdsUrl = pdsService.serviceEndpoint.replace(/\/+$/, "");
1654
- const oauthRes = await fetch(`${pdsUrl}/auth/.well-known/openid-configuration`);
1655
- if (!oauthRes.ok) {
1656
- throw new Error(`Failed to fetch OpenID configuration from ${pdsUrl}: ${oauthRes.status}`);
1657
- }
1658
- const oauth = await oauthRes.json();
1659
- return {
1660
- did,
1661
- didDocument,
1662
- pdsUrl,
1663
- authorization_endpoint: oauth.authorization_endpoint,
1664
- token_endpoint: oauth.token_endpoint,
1665
- userinfo_endpoint: oauth.userinfo_endpoint
1666
- };
1667
- }
1668
- async function resolveDid(did) {
1669
- const url = resolveDidWebUrl(did);
1670
- if (!url) {
1671
- throw new Error(`Unsupported DID method: ${did}`);
1672
- }
1673
- const didRes = await fetch(url);
1674
- if (!didRes.ok) {
1675
- throw new Error(`Failed to fetch DID document at ${url}: ${didRes.status}`);
1676
- }
1677
- const didDocument = await didRes.json();
1678
- return resolveFromDocument(did, didDocument);
1679
- }
1680
- async function resolveHandle(handle) {
1681
- const res = await fetch(`https://${handle}/.well-known/did.json`);
1682
- if (!res.ok) {
1683
- throw new Error(`Handle resolution failed for ${handle}: ${res.status}`);
1684
- }
1685
- const didDocument = await res.json();
1686
- const did = didDocument.id;
1687
- if (!did) {
1688
- throw new Error(`Handle response has no 'id' field`);
1073
+ const didDocument = await res.json();
1074
+ const did = didDocument.id;
1075
+ if (!did) {
1076
+ throw new Error(`Handle response has no 'id' field`);
1689
1077
  }
1690
1078
  const resolved = await resolveFromDocument(did, didDocument);
1691
1079
  resolved.handle = handle;
@@ -1785,9 +1173,6 @@ var AuthManager = class {
1785
1173
  log("Received sign-out from another tab");
1786
1174
  this.resetAuthState("signed_out");
1787
1175
  this.notify();
1788
- if (typeof window !== "undefined") {
1789
- window.location.reload();
1790
- }
1791
1176
  }
1792
1177
  if (event.data?.type === "session_invalidated") {
1793
1178
  log("Received session invalidation from another tab");
@@ -2075,11 +1460,13 @@ var AuthManager = class {
2075
1460
  }
2076
1461
  }
2077
1462
  /**
2078
- * Clear auth state and storage. Does NOT handle sync/DB cleanup —
2079
- * the UI layer (BasicProvider) wraps this to add sync teardown.
1463
+ * Sign out: revoke the session server-side (`POST /auth/logout`, best
1464
+ * effort), then clear auth state and storage. Does NOT handle sync/DB
1465
+ * cleanup — the client layer wraps this to add sync teardown.
2080
1466
  */
2081
1467
  async signOut() {
2082
1468
  log("signing out!");
1469
+ await this.revokeSessionOnServer();
2083
1470
  this.resetAuthState("signed_out");
2084
1471
  await this.storage.remove(STORAGE_KEYS.AUTH_STATE);
2085
1472
  await this.storage.remove(STORAGE_KEYS.LAST_CONNECT_REPORT);
@@ -2087,6 +1474,30 @@ var AuthManager = class {
2087
1474
  this.broadcastSignOut();
2088
1475
  this.notify();
2089
1476
  }
1477
+ /**
1478
+ * Best-effort server-side revocation of the current device/session and
1479
+ * its refresh chain (Step 2 auth: logout is finally server-side).
1480
+ * Never blocks or fails the local sign-out.
1481
+ */
1482
+ async revokeSessionOnServer() {
1483
+ try {
1484
+ let accessToken = null;
1485
+ try {
1486
+ accessToken = await this.getToken();
1487
+ } catch {
1488
+ accessToken = this.token?.access_token ?? null;
1489
+ }
1490
+ if (!accessToken) return;
1491
+ const endpoints = await this.getActivePdsEndpoints();
1492
+ await fetch(`${endpoints.pds_url}/auth/logout`, {
1493
+ method: "POST",
1494
+ headers: { Authorization: `Bearer ${accessToken}` }
1495
+ });
1496
+ log("Server-side logout succeeded");
1497
+ } catch (error) {
1498
+ log("Server-side logout failed (non-blocking):", error);
1499
+ }
1500
+ }
2090
1501
  async reconcileSession(reason = "manual", options) {
2091
1502
  if (this.authStatus === "signed_out" || this.authStatus === "reauth_required") {
2092
1503
  return;
@@ -2410,11 +1821,11 @@ var AuthManager = class {
2410
1821
  ...isRefreshToken ? { refresh_token: "[REDACTED]" } : { code: "[REDACTED]" },
2411
1822
  ...requestBody.code_verifier ? { code_verifier: "[REDACTED]" } : {}
2412
1823
  });
2413
- const token = await fetch(endpoints.token_endpoint, {
1824
+ const response = await fetch(endpoints.token_endpoint, {
2414
1825
  method: "POST",
2415
1826
  headers: { "Content-Type": "application/json" },
2416
1827
  body: JSON.stringify(requestBody)
2417
- }).then((response) => response.json()).catch((error) => {
1828
+ }).catch((error) => {
2418
1829
  log("Network error fetching token:", error);
2419
1830
  if (!this.isOnline) {
2420
1831
  this.pendingRefresh = true;
@@ -2424,6 +1835,18 @@ var AuthManager = class {
2424
1835
  }
2425
1836
  throw new Error("Network error during token refresh");
2426
1837
  });
1838
+ if (response.status === 429) {
1839
+ log("Token endpoint rate limited (429) - will retry later");
1840
+ this.pendingRefresh = true;
1841
+ throw new Error(
1842
+ "Token endpoint rate limited - refresh will be retried"
1843
+ );
1844
+ }
1845
+ const token = await response.json().catch(() => {
1846
+ throw new Error(
1847
+ `Token endpoint returned invalid JSON (status ${response.status})`
1848
+ );
1849
+ });
2427
1850
  if (token.access_token) {
2428
1851
  try {
2429
1852
  const decoded = (0, import_jwt_decode.jwtDecode)(token.access_token);
@@ -2533,7 +1956,8 @@ var AuthManager = class {
2533
1956
  isNetworkError(error) {
2534
1957
  if (error instanceof TypeError) return true;
2535
1958
  if (error instanceof Error) {
2536
- return error.message.includes("offline") || error.message.includes("Network");
1959
+ return error.message.includes("offline") || error.message.includes("Network") || // 429 on the token endpoint: transient, keep the session alive
1960
+ error.message.includes("rate limited");
2537
1961
  }
2538
1962
  return false;
2539
1963
  }
@@ -2748,142 +2172,1434 @@ var AuthManager = class {
2748
2172
  }
2749
2173
  };
2750
2174
 
2751
- // src/AuthContext.tsx
2752
- init_config();
2753
- init_package();
2754
-
2755
- // src/updater/versionUpdater.ts
2756
- init_config();
2757
- var VersionUpdater = class {
2758
- storage;
2759
- currentVersion;
2760
- migrations;
2761
- versionKey = "basic_app_version";
2762
- constructor(storage, currentVersion, migrations = []) {
2763
- this.storage = storage;
2764
- this.currentVersion = currentVersion;
2765
- this.migrations = migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
2175
+ // src/core/http/RestClient.ts
2176
+ var RestError = class extends Error {
2177
+ status;
2178
+ code;
2179
+ response;
2180
+ constructor(message, status, code, response) {
2181
+ super(message);
2182
+ this.name = "RestError";
2183
+ this.status = status;
2184
+ this.code = code;
2185
+ this.response = response;
2186
+ }
2187
+ };
2188
+ var NotAuthenticatedError = class extends Error {
2189
+ constructor(message = "Not authenticated") {
2190
+ super(message);
2191
+ this.name = "NotAuthenticatedError";
2192
+ }
2193
+ };
2194
+ var RestClient = class {
2195
+ opts;
2196
+ constructor(opts) {
2197
+ this.opts = { ...opts, baseUrl: opts.baseUrl.replace(/\/$/, "") };
2198
+ }
2199
+ get projectId() {
2200
+ return this.opts.projectId;
2766
2201
  }
2202
+ // -------------------------------------------------------------------
2203
+ // Sync surface
2204
+ // -------------------------------------------------------------------
2205
+ /** `GET /account/:project_id/db` — tables, enforced schema version, channel head. */
2206
+ async getDbInfo() {
2207
+ const res = await this.request("GET", `${this.dbPath}`);
2208
+ return res.data;
2209
+ }
2210
+ /** Bootstrap snapshot (SPEC §5). `share` bootstraps a mount; `table` filters. */
2211
+ async getSnapshot(options) {
2212
+ const query = new URLSearchParams();
2213
+ if (options?.share) query.set("share", options.share);
2214
+ if (options?.table) query.set("table", options.table);
2215
+ const qs = query.toString();
2216
+ const res = await this.request(
2217
+ "GET",
2218
+ `${this.dbPath}/snapshot${qs ? `?${qs}` : ""}`
2219
+ );
2220
+ return res.data;
2221
+ }
2222
+ /** Pull ordered ops after a cursor — the non-WebSocket sync path. */
2223
+ async getChanges(options) {
2224
+ const query = new URLSearchParams({ cursor: String(options.cursor) });
2225
+ if (options.limit) query.set("limit", String(options.limit));
2226
+ if (options.share) query.set("share", options.share);
2227
+ if (options.table) query.set("table", options.table);
2228
+ const res = await this.request(
2229
+ "GET",
2230
+ `${this.dbPath}/changes?${query.toString()}`
2231
+ );
2232
+ return res.data;
2233
+ }
2234
+ // -------------------------------------------------------------------
2235
+ // Shares (multiplayer v1)
2236
+ // -------------------------------------------------------------------
2767
2237
  /**
2768
- * Check current stored version and run migrations if needed
2769
- * Only compares major.minor versions, ignoring beta/prerelease parts
2770
- * Example: "0.7.0-beta.1" and "0.7.0" are treated as the same version
2238
+ * Shares granted by and received by the caller. App tokens see only
2239
+ * shares involving their own app (the ones they can mount).
2771
2240
  */
2772
- async checkAndUpdate() {
2773
- const storedVersion = await this.getStoredVersion();
2774
- if (!storedVersion) {
2775
- await this.setStoredVersion(this.currentVersion);
2776
- return { updated: false, toVersion: this.currentVersion };
2777
- }
2778
- if (storedVersion === this.currentVersion) {
2779
- return { updated: false, toVersion: this.currentVersion };
2241
+ async listShares() {
2242
+ const res = await this.request(
2243
+ "GET",
2244
+ "/account/shares"
2245
+ );
2246
+ return res.data;
2247
+ }
2248
+ // -------------------------------------------------------------------
2249
+ // CRUD on materialized state (REST-mode table API)
2250
+ // -------------------------------------------------------------------
2251
+ async list(table, query) {
2252
+ const qs = query ? `?${new URLSearchParams(query).toString()}` : "";
2253
+ const res = await this.request(
2254
+ "GET",
2255
+ `${this.dbPath}/${encodeURIComponent(table)}${qs}`
2256
+ );
2257
+ return res.data ?? [];
2258
+ }
2259
+ async getRecord(table, id) {
2260
+ try {
2261
+ const res = await this.request(
2262
+ "GET",
2263
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`
2264
+ );
2265
+ return res.data ?? null;
2266
+ } catch (err) {
2267
+ if (err instanceof RestError && err.status === 404) return null;
2268
+ throw err;
2780
2269
  }
2781
- const migrationsToRun = this.getMigrationsToRun(storedVersion, this.currentVersion);
2782
- if (migrationsToRun.length === 0) {
2783
- await this.setStoredVersion(this.currentVersion);
2784
- return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
2270
+ }
2271
+ /** `POST` server mints the record id. */
2272
+ async createRecord(table, value) {
2273
+ const res = await this.request(
2274
+ "POST",
2275
+ `${this.dbPath}/${encodeURIComponent(table)}`,
2276
+ { value }
2277
+ );
2278
+ return res.data;
2279
+ }
2280
+ /** `PUT` — full replace. REST semantics: 404 for missing records. */
2281
+ async putRecord(table, id, value) {
2282
+ try {
2283
+ const res = await this.request(
2284
+ "PUT",
2285
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`,
2286
+ { value }
2287
+ );
2288
+ return res.data ?? null;
2289
+ } catch (err) {
2290
+ if (err instanceof RestError && err.status === 404) return null;
2291
+ throw err;
2785
2292
  }
2786
- for (const migration of migrationsToRun) {
2293
+ }
2294
+ /** `PATCH` — partial merge. 404 → null. */
2295
+ async patchRecord(table, id, value) {
2296
+ try {
2297
+ const res = await this.request(
2298
+ "PATCH",
2299
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`,
2300
+ { value }
2301
+ );
2302
+ return res.data ?? null;
2303
+ } catch (err) {
2304
+ if (err instanceof RestError && err.status === 404) return null;
2305
+ throw err;
2306
+ }
2307
+ }
2308
+ /** `DELETE`. Returns false when the record did not exist. */
2309
+ async deleteRecord(table, id) {
2310
+ try {
2311
+ await this.request(
2312
+ "DELETE",
2313
+ `${this.dbPath}/${encodeURIComponent(table)}/${encodeURIComponent(id)}`
2314
+ );
2315
+ return true;
2316
+ } catch (err) {
2317
+ if (err instanceof RestError && err.status === 404) return false;
2318
+ throw err;
2319
+ }
2320
+ }
2321
+ // -------------------------------------------------------------------
2322
+ // Internals
2323
+ // -------------------------------------------------------------------
2324
+ get dbPath() {
2325
+ return `/account/${encodeURIComponent(this.opts.projectId)}/db`;
2326
+ }
2327
+ /** Authenticated request; retries once with a force-refreshed token on 401. */
2328
+ async request(method, path, body, isRetry = false) {
2329
+ let token;
2330
+ try {
2331
+ token = await this.opts.getToken(isRetry ? { forceRefresh: true } : void 0);
2332
+ } catch (err) {
2333
+ throw new NotAuthenticatedError(
2334
+ err instanceof Error ? err.message : "could not get access token"
2335
+ );
2336
+ }
2337
+ const url = `${this.opts.baseUrl}${path}`;
2338
+ this.opts.log?.("[rest]", method, url);
2339
+ const headers = { Authorization: `Bearer ${token}` };
2340
+ if (body !== void 0) headers["Content-Type"] = "application/json";
2341
+ const response = await fetch(url, {
2342
+ method,
2343
+ headers,
2344
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
2345
+ });
2346
+ const responseData = await response.json().catch(() => ({}));
2347
+ if (!response.ok) {
2348
+ if (response.status === 401 && !isRetry) {
2349
+ this.opts.log?.("[rest] 401 \u2014 refreshing token and retrying once");
2350
+ return this.request(method, path, body, true);
2351
+ }
2352
+ const code = typeof responseData.error === "string" ? responseData.error : void 0;
2353
+ const message = typeof responseData.message === "string" && responseData.message || code || `request failed: ${response.status}`;
2354
+ throw new RestError(message, response.status, code, responseData);
2355
+ }
2356
+ return responseData;
2357
+ }
2358
+ };
2359
+
2360
+ // src/core/sync/SyncEngine.ts
2361
+ var import_schema = require("@basictech/schema");
2362
+
2363
+ // src/core/sync/protocol.ts
2364
+ var PROTOCOL_VERSION = 1;
2365
+ var TERMINAL_OP_ERRORS = /* @__PURE__ */ new Set([
2366
+ "SCHEMA_VALIDATION_FAILED",
2367
+ "UNKNOWN_TABLE",
2368
+ "RECORD_NOT_FOUND",
2369
+ "PERMISSION_DENIED",
2370
+ "PAYLOAD_TOO_LARGE",
2371
+ "CHANNEL_FULL",
2372
+ "BAD_MESSAGE"
2373
+ ]);
2374
+ function isTerminalOpError(code, terminalFlag) {
2375
+ if (terminalFlag !== void 0) return terminalFlag;
2376
+ return code !== void 0 && TERMINAL_OP_ERRORS.has(code);
2377
+ }
2378
+ function isRebootstrapError(code) {
2379
+ return code === "SNAPSHOT_REQUIRED" || code === "RESET_REQUIRED";
2380
+ }
2381
+ function isRevocationError(code) {
2382
+ return code === "SHARE_REVOKED" || code === "CONNECTION_REVOKED";
2383
+ }
2384
+ function isAuthError(code) {
2385
+ return code === "UNAUTHORIZED" || code === "TOKEN_EXPIRED";
2386
+ }
2387
+ var DEFAULT_LIMITS = {
2388
+ max_ops_per_push: 500,
2389
+ max_op_bytes: 64 * 1024,
2390
+ replay_limit: 1e3
2391
+ };
2392
+ function mintOpId() {
2393
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
2394
+ return crypto.randomUUID();
2395
+ }
2396
+ return "op-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
2397
+ }
2398
+ function cloneData(value) {
2399
+ return value === void 0 ? value : JSON.parse(JSON.stringify(value));
2400
+ }
2401
+ function applyOpToData(existing, op) {
2402
+ switch (op.type) {
2403
+ case "put":
2404
+ return cloneData(op.data ?? {});
2405
+ case "patch":
2406
+ if (existing === void 0) return void 0;
2407
+ return { ...existing, ...cloneData(op.data ?? {}) };
2408
+ case "delete":
2409
+ return void 0;
2410
+ }
2411
+ }
2412
+
2413
+ // src/core/sync/SyncConnection.ts
2414
+ var INITIAL_RECONNECT_DELAY_MS = 500;
2415
+ var MAX_RECONNECT_DELAY_MS = 3e4;
2416
+ var SyncConnection = class {
2417
+ opts;
2418
+ WS;
2419
+ heartbeatMs;
2420
+ ws = null;
2421
+ _status = "idle";
2422
+ stopped = true;
2423
+ reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2424
+ timers = /* @__PURE__ */ new Set();
2425
+ heartbeatTimer = null;
2426
+ /** One forced-refresh reconnect attempt per auth rejection. */
2427
+ authRetryUsed = false;
2428
+ removeOnlineListener = null;
2429
+ constructor(opts) {
2430
+ this.opts = opts;
2431
+ this.WS = opts.WebSocketImpl ?? globalThis.WebSocket;
2432
+ this.heartbeatMs = opts.heartbeatMs ?? 3e4;
2433
+ }
2434
+ get status() {
2435
+ return this._status;
2436
+ }
2437
+ get isOnline() {
2438
+ return this._status === "online" && this.ws?.readyState === 1;
2439
+ }
2440
+ start() {
2441
+ if (!this.stopped && this.ws) return;
2442
+ this.stopped = false;
2443
+ this.authRetryUsed = false;
2444
+ this.listenForNetwork();
2445
+ this.open();
2446
+ }
2447
+ stop() {
2448
+ this.stopped = true;
2449
+ this.clearTimers();
2450
+ this.removeOnlineListener?.();
2451
+ this.removeOnlineListener = null;
2452
+ if (this.ws) {
2787
2453
  try {
2788
- log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`);
2789
- await migration.migrate(this.storage);
2790
- } catch (error) {
2791
- console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error);
2792
- throw new Error(`Migration failed: ${error}`);
2454
+ this.ws.close();
2455
+ } catch {
2793
2456
  }
2457
+ this.ws = null;
2794
2458
  }
2795
- await this.setStoredVersion(this.currentVersion);
2796
- return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
2459
+ this.setStatus("stopped");
2797
2460
  }
2798
- async getStoredVersion() {
2461
+ /** Send a message; returns false when the socket is not open. */
2462
+ send(msg) {
2463
+ if (this.ws?.readyState === 1) {
2464
+ this.ws.send(JSON.stringify(msg));
2465
+ return true;
2466
+ }
2467
+ return false;
2468
+ }
2469
+ /** Refresh auth on the live socket (no reconnect). */
2470
+ async sendToken() {
2471
+ if (!this.isOnline) return;
2799
2472
  try {
2800
- const versionData = await this.storage.get(this.versionKey);
2801
- if (!versionData) return null;
2802
- const versionInfo = JSON.parse(versionData);
2803
- return versionInfo.version;
2804
- } catch (error) {
2805
- console.warn("Failed to get stored version:", error);
2806
- return null;
2473
+ const token = await this.opts.getToken();
2474
+ this.send({ type: "token", token });
2475
+ } catch {
2807
2476
  }
2808
2477
  }
2809
- async setStoredVersion(version2) {
2810
- const versionInfo = {
2811
- version: version2,
2812
- lastUpdated: Date.now()
2478
+ /**
2479
+ * The server rejected our token (`UNAUTHORIZED` / `TOKEN_EXPIRED` + close).
2480
+ * Retry once with a force-refreshed token; give up (status `auth_failed`)
2481
+ * when the refresh itself fails or a fresh token is rejected again.
2482
+ */
2483
+ handleAuthRejection() {
2484
+ if (this.stopped) return;
2485
+ if (this.authRetryUsed) {
2486
+ this.log("fresh token rejected \u2014 giving up until reauth");
2487
+ this.stopped = true;
2488
+ this.clearTimers();
2489
+ this.setStatus("auth_failed");
2490
+ return;
2491
+ }
2492
+ this.authRetryUsed = true;
2493
+ this.log("token rejected \u2014 force refreshing and reconnecting");
2494
+ void (async () => {
2495
+ try {
2496
+ await this.opts.getToken({ forceRefresh: true });
2497
+ this.scheduleReconnect(0);
2498
+ } catch (err) {
2499
+ this.log("token refresh failed after auth rejection:", err);
2500
+ this.stopped = true;
2501
+ this.setStatus("auth_failed");
2502
+ }
2503
+ })();
2504
+ }
2505
+ // -------------------------------------------------------------------
2506
+ // Internals
2507
+ // -------------------------------------------------------------------
2508
+ open() {
2509
+ if (this.stopped) return;
2510
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
2511
+ if (!this.WS) {
2512
+ this.log("no WebSocket implementation available");
2513
+ this.setStatus("offline");
2514
+ return;
2515
+ }
2516
+ this.setStatus("connecting");
2517
+ const ws = new this.WS(this.opts.wsUrl);
2518
+ this.ws = ws;
2519
+ ws.onopen = () => {
2520
+ void (async () => {
2521
+ try {
2522
+ const token = await this.opts.getToken();
2523
+ if (this.ws !== ws || ws.readyState !== 1) return;
2524
+ const hello = { type: "hello", version: PROTOCOL_VERSION, token };
2525
+ ws.send(JSON.stringify(hello));
2526
+ } catch (err) {
2527
+ this.log("could not get token for hello:", err);
2528
+ try {
2529
+ ws.close();
2530
+ } catch {
2531
+ }
2532
+ }
2533
+ })();
2534
+ };
2535
+ ws.onmessage = (event) => {
2536
+ let msg;
2537
+ try {
2538
+ const text = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
2539
+ msg = JSON.parse(text);
2540
+ } catch {
2541
+ return;
2542
+ }
2543
+ this.handleMessage(msg);
2544
+ };
2545
+ ws.onclose = () => {
2546
+ if (this.ws !== ws) return;
2547
+ this.ws = null;
2548
+ this.stopHeartbeat();
2549
+ if (this.stopped) return;
2550
+ this.setStatus("offline");
2551
+ this.scheduleReconnect();
2552
+ };
2553
+ ws.onerror = () => {
2813
2554
  };
2814
- await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
2815
2555
  }
2816
- getMigrationsToRun(fromVersion, toVersion) {
2817
- return this.migrations.filter((migration) => {
2818
- const storedLessThanMigrationTo = this.compareVersions(fromVersion, migration.toVersion) < 0;
2819
- const currentGreaterThanOrEqualMigrationTo = this.compareVersions(toVersion, migration.toVersion) >= 0;
2820
- const shouldRun = storedLessThanMigrationTo && currentGreaterThanOrEqualMigrationTo;
2821
- log(`Migration ${migration.fromVersion} \u2192 ${migration.toVersion}: shouldRun=${shouldRun}`);
2822
- return shouldRun;
2556
+ handleMessage(msg) {
2557
+ if (msg.type === "welcome") {
2558
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2559
+ this.authRetryUsed = false;
2560
+ this.setStatus("online");
2561
+ this.startHeartbeat();
2562
+ this.opts.onWelcome(msg);
2563
+ return;
2564
+ }
2565
+ if (msg.type === "error" && !("sub" in msg && msg.sub) && isAuthError(msg.code)) {
2566
+ this.handleAuthRejection();
2567
+ this.opts.onMessage(msg);
2568
+ return;
2569
+ }
2570
+ this.opts.onMessage(msg);
2571
+ }
2572
+ startHeartbeat() {
2573
+ this.stopHeartbeat();
2574
+ const tick = () => {
2575
+ if (this.stopped) return;
2576
+ this.send({ type: "ping" });
2577
+ this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
2578
+ this.timers.add(this.heartbeatTimer);
2579
+ };
2580
+ this.heartbeatTimer = setTimeout(tick, this.heartbeatMs);
2581
+ this.timers.add(this.heartbeatTimer);
2582
+ }
2583
+ stopHeartbeat() {
2584
+ if (this.heartbeatTimer) {
2585
+ clearTimeout(this.heartbeatTimer);
2586
+ this.timers.delete(this.heartbeatTimer);
2587
+ this.heartbeatTimer = null;
2588
+ }
2589
+ }
2590
+ scheduleReconnect(delayOverride) {
2591
+ if (this.stopped) return;
2592
+ const delay = delayOverride ?? this.reconnectDelay;
2593
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
2594
+ this.log(`reconnecting in ${delay}ms`);
2595
+ const timer = setTimeout(() => {
2596
+ this.timers.delete(timer);
2597
+ this.open();
2598
+ }, delay);
2599
+ this.timers.add(timer);
2600
+ }
2601
+ listenForNetwork() {
2602
+ if (this.removeOnlineListener || typeof window === "undefined") return;
2603
+ const handleOnline = () => {
2604
+ if (this.stopped) return;
2605
+ this.log("network online \u2014 reconnecting immediately");
2606
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
2607
+ this.open();
2608
+ };
2609
+ window.addEventListener("online", handleOnline);
2610
+ this.removeOnlineListener = () => window.removeEventListener("online", handleOnline);
2611
+ }
2612
+ clearTimers() {
2613
+ for (const t of this.timers) clearTimeout(t);
2614
+ this.timers.clear();
2615
+ this.heartbeatTimer = null;
2616
+ }
2617
+ setStatus(status) {
2618
+ if (this._status === status) return;
2619
+ this._status = status;
2620
+ this.opts.onStatus(status);
2621
+ }
2622
+ log(...args) {
2623
+ this.opts.log?.("[sync-connection]", ...args);
2624
+ }
2625
+ };
2626
+
2627
+ // src/core/sync/SyncStore.ts
2628
+ var import_dexie = __toESM(require("dexie"));
2629
+ var META_CURSOR = "cursor";
2630
+ var META_CHANNEL = "channel";
2631
+ var SyncStore = class {
2632
+ db;
2633
+ name;
2634
+ tableNames;
2635
+ constructor(name, schema) {
2636
+ this.name = name;
2637
+ this.tableNames = Object.keys(schema.tables);
2638
+ this.db = new import_dexie.default(name);
2639
+ const stores = {
2640
+ _server: "[table+record_id], table",
2641
+ _pending: "++idx, op_id",
2642
+ _rejected: "++idx, op_id",
2643
+ _meta: "key"
2644
+ };
2645
+ for (const [tableName, table] of Object.entries(schema.tables)) {
2646
+ const indexed = Object.entries(table.fields).filter(([, f]) => f.indexed).map(([fieldName]) => `,${fieldName}`).join("");
2647
+ stores[tableName] = "id" + indexed;
2648
+ }
2649
+ this.db.version(Math.max(schema.version ?? 1, 1)).stores(stores);
2650
+ }
2651
+ /** The Dexie view table for an app table (what liveQuery reads). */
2652
+ view(table) {
2653
+ return this.db.table(table);
2654
+ }
2655
+ hasTable(table) {
2656
+ return this.tableNames.includes(table);
2657
+ }
2658
+ get tables() {
2659
+ return [...this.tableNames];
2660
+ }
2661
+ get server() {
2662
+ return this.db.table("_server");
2663
+ }
2664
+ get pending() {
2665
+ return this.db.table("_pending");
2666
+ }
2667
+ get rejected() {
2668
+ return this.db.table("_rejected");
2669
+ }
2670
+ get meta() {
2671
+ return this.db.table("_meta");
2672
+ }
2673
+ get allStores() {
2674
+ return ["_server", "_pending", "_rejected", "_meta", ...this.tableNames];
2675
+ }
2676
+ // -------------------------------------------------------------------
2677
+ // Meta
2678
+ // -------------------------------------------------------------------
2679
+ async getCursor() {
2680
+ const row = await this.meta.get(META_CURSOR);
2681
+ return typeof row?.value === "number" ? row.value : null;
2682
+ }
2683
+ async getChannel() {
2684
+ const row = await this.meta.get(META_CHANNEL);
2685
+ return typeof row?.value === "string" ? row.value : null;
2686
+ }
2687
+ // -------------------------------------------------------------------
2688
+ // Pending / rejected
2689
+ // -------------------------------------------------------------------
2690
+ /** All pending ops in creation order (used to warm the in-memory queue). */
2691
+ async loadPending() {
2692
+ return this.pending.orderBy("idx").toArray();
2693
+ }
2694
+ async listRejected() {
2695
+ return this.rejected.orderBy("idx").toArray();
2696
+ }
2697
+ async clearRejected() {
2698
+ await this.rejected.clear();
2699
+ }
2700
+ /** Record a server ack for a pending op (echo not yet seen). */
2701
+ async markAcked(opId, seq) {
2702
+ await this.pending.where("op_id").equals(opId).modify({ acked_seq: seq });
2703
+ }
2704
+ // -------------------------------------------------------------------
2705
+ // Writes
2706
+ // -------------------------------------------------------------------
2707
+ /**
2708
+ * Enqueue a local op and apply it optimistically to the view.
2709
+ * Returns the resulting view record (null when the op deletes it).
2710
+ */
2711
+ async addPending(op) {
2712
+ return this.db.transaction("rw", this.allStores, async () => {
2713
+ await this.pending.add({ op_id: op.op_id, op: cloneData(op) });
2714
+ return this.recomputeViewRecord(op.table, op.record_id);
2823
2715
  });
2824
2716
  }
2825
2717
  /**
2826
- * Simple semantic version comparison (major.minor only, ignoring beta/prerelease)
2827
- * Returns: -1 if a < b, 0 if a === b, 1 if a > b
2718
+ * Commit a batch of incoming server ops (already filtered/deduped by the
2719
+ * engine): update `_server`, drop confirmed pending ops, advance the
2720
+ * cursor, and rebase every affected view record — in one transaction.
2828
2721
  */
2829
- compareVersions(a, b) {
2830
- const aMajorMinor = this.extractMajorMinor(a);
2831
- const bMajorMinor = this.extractMajorMinor(b);
2832
- if (aMajorMinor.major !== bMajorMinor.major) {
2833
- return aMajorMinor.major - bMajorMinor.major;
2722
+ async commitIncoming(params) {
2723
+ const { applyOps, confirmedOpIds, cursor } = params;
2724
+ await this.db.transaction("rw", this.allStores, async () => {
2725
+ const affected = /* @__PURE__ */ new Set();
2726
+ for (const op of applyOps) affected.add(`${op.table}\0${op.record_id}`);
2727
+ for (const op of applyOps) {
2728
+ await this.applyToServer(op);
2729
+ }
2730
+ if (confirmedOpIds.length > 0) {
2731
+ const confirmedRows = await this.pending.where("op_id").anyOf(confirmedOpIds).toArray();
2732
+ for (const row of confirmedRows) affected.add(`${row.op.table}\0${row.op.record_id}`);
2733
+ await this.pending.where("op_id").anyOf(confirmedOpIds).delete();
2734
+ }
2735
+ await this.meta.put({ key: META_CURSOR, value: cursor });
2736
+ for (const key of affected) {
2737
+ const [table, recordId] = key.split("\0");
2738
+ await this.recomputeViewRecord(table, recordId);
2739
+ }
2740
+ });
2741
+ }
2742
+ /** Persist a cursor advance with no ops (empty `ops` message / pushed cursor). */
2743
+ async setCursor(cursor) {
2744
+ await this.meta.put({ key: META_CURSOR, value: cursor });
2745
+ }
2746
+ /**
2747
+ * Terminal rejection: remove from pending, park in the rejected store,
2748
+ * roll the view record back to server state + remaining pending ops.
2749
+ */
2750
+ async rejectPending(opId, error, message) {
2751
+ return this.db.transaction("rw", this.allStores, async () => {
2752
+ const row = await this.pending.where("op_id").equals(opId).first();
2753
+ if (!row) return null;
2754
+ await this.pending.where("op_id").equals(opId).delete();
2755
+ const rejectedRow = {
2756
+ op_id: opId,
2757
+ op: row.op,
2758
+ error,
2759
+ message,
2760
+ rejected_at: Date.now()
2761
+ };
2762
+ await this.rejected.add(rejectedRow);
2763
+ await this.recomputeViewRecord(row.op.table, row.op.record_id);
2764
+ return rejectedRow;
2765
+ });
2766
+ }
2767
+ // -------------------------------------------------------------------
2768
+ // Bootstrap
2769
+ // -------------------------------------------------------------------
2770
+ /**
2771
+ * Replace all server state from a snapshot (cold start or
2772
+ * SNAPSHOT_REQUIRED/RESET_REQUIRED recovery). Pending ops survive and are
2773
+ * re-applied on top of the fresh state.
2774
+ */
2775
+ async replaceFromSnapshot(params) {
2776
+ const { channel, records, cursor } = params;
2777
+ await this.db.transaction("rw", this.allStores, async () => {
2778
+ await this.server.clear();
2779
+ for (const tableName of this.tableNames) {
2780
+ await this.view(tableName).clear();
2781
+ }
2782
+ for (const [tableName, tableRecords] of Object.entries(records)) {
2783
+ if (!this.hasTable(tableName)) continue;
2784
+ const serverRows = [];
2785
+ const viewRows = [];
2786
+ for (const [recordId, data] of Object.entries(tableRecords)) {
2787
+ serverRows.push({ table: tableName, record_id: recordId, data: data ?? {} });
2788
+ viewRows.push({ id: recordId, ...data ?? {} });
2789
+ }
2790
+ await this.server.bulkPut(serverRows);
2791
+ await this.view(tableName).bulkPut(viewRows);
2792
+ }
2793
+ const pendingRows = await this.pending.orderBy("idx").toArray();
2794
+ const affected = /* @__PURE__ */ new Set();
2795
+ for (const row of pendingRows) affected.add(`${row.op.table}\0${row.op.record_id}`);
2796
+ for (const key of affected) {
2797
+ const [table, recordId] = key.split("\0");
2798
+ if (this.hasTable(table)) await this.recomputeViewRecord(table, recordId);
2799
+ }
2800
+ await this.meta.put({ key: META_CURSOR, value: cursor });
2801
+ await this.meta.put({ key: META_CHANNEL, value: channel });
2802
+ });
2803
+ }
2804
+ // -------------------------------------------------------------------
2805
+ // Reads
2806
+ // -------------------------------------------------------------------
2807
+ async getViewRecord(table, id) {
2808
+ const record = await this.view(table).get(id);
2809
+ return record ?? null;
2810
+ }
2811
+ async getViewRecords(table) {
2812
+ return this.view(table).toArray();
2813
+ }
2814
+ // -------------------------------------------------------------------
2815
+ // Lifecycle
2816
+ // -------------------------------------------------------------------
2817
+ close() {
2818
+ this.db.close();
2819
+ }
2820
+ /** Delete the underlying IndexedDB database (sign-out / revoked mount). */
2821
+ async destroy() {
2822
+ this.db.close();
2823
+ await import_dexie.default.delete(this.name);
2824
+ }
2825
+ // -------------------------------------------------------------------
2826
+ // Internals
2827
+ // -------------------------------------------------------------------
2828
+ async applyToServer(op) {
2829
+ if (!this.hasTable(op.table)) return;
2830
+ const existing = await this.server.get([op.table, op.record_id]);
2831
+ const next = applyOpToData(existing?.data, op);
2832
+ if (next === void 0) {
2833
+ await this.server.delete([op.table, op.record_id]);
2834
+ } else {
2835
+ await this.server.put({ table: op.table, record_id: op.record_id, data: next });
2834
2836
  }
2835
- return aMajorMinor.minor - bMajorMinor.minor;
2836
2837
  }
2837
2838
  /**
2838
- * Extract major.minor from version string, ignoring beta/prerelease
2839
- * Examples: "0.7.0-beta.1" -> {major: 0, minor: 7}
2840
- * "1.2.3" -> {major: 1, minor: 2}
2839
+ * Rebase one record: view = server data + pending ops for that record in
2840
+ * creation order. Must run inside a transaction covering all stores.
2841
2841
  */
2842
- extractMajorMinor(version2) {
2843
- const cleanVersion = version2.split("-")[0]?.split("+")[0] || version2;
2844
- const parts = cleanVersion.split(".").map(Number);
2842
+ async recomputeViewRecord(table, recordId) {
2843
+ if (!this.hasTable(table)) return null;
2844
+ const serverRow = await this.server.get([table, recordId]);
2845
+ let data = serverRow ? cloneData(serverRow.data) : void 0;
2846
+ const pendingRows = await this.pending.orderBy("idx").toArray();
2847
+ for (const row of pendingRows) {
2848
+ if (row.op.table === table && row.op.record_id === recordId) {
2849
+ data = applyOpToData(data, row.op);
2850
+ }
2851
+ }
2852
+ if (data === void 0) {
2853
+ await this.view(table).delete(recordId);
2854
+ return null;
2855
+ }
2856
+ const viewRecord = { id: recordId, ...data };
2857
+ await this.view(table).put(viewRecord);
2858
+ return viewRecord;
2859
+ }
2860
+ };
2861
+
2862
+ // src/core/sync/SyncEngine.ts
2863
+ var OWN_SUB = "own";
2864
+ function shareSubKey(shareId) {
2865
+ return `share:${shareId}`;
2866
+ }
2867
+ var BoundedSet = class {
2868
+ constructor(cap = 2048) {
2869
+ this.cap = cap;
2870
+ }
2871
+ set = /* @__PURE__ */ new Set();
2872
+ order = [];
2873
+ has(value) {
2874
+ return this.set.has(value);
2875
+ }
2876
+ add(value) {
2877
+ if (this.set.has(value)) return;
2878
+ this.set.add(value);
2879
+ this.order.push(value);
2880
+ if (this.order.length > this.cap) {
2881
+ const evicted = this.order.shift();
2882
+ if (evicted !== void 0) this.set.delete(evicted);
2883
+ }
2884
+ }
2885
+ clear() {
2886
+ this.set.clear();
2887
+ this.order = [];
2888
+ }
2889
+ };
2890
+ var RETRY_FLUSH_DELAY_MS = 1200;
2891
+ var SyncEngine = class {
2892
+ projectId;
2893
+ schema;
2894
+ opts;
2895
+ connection;
2896
+ subs = /* @__PURE__ */ new Map();
2897
+ limits = { ...DEFAULT_LIMITS };
2898
+ actor = null;
2899
+ started = false;
2900
+ revokedInfo = null;
2901
+ connectionStatus = "idle";
2902
+ _status = "idle";
2903
+ listeners = /* @__PURE__ */ new Map();
2904
+ timers = /* @__PURE__ */ new Set();
2905
+ constructor(opts) {
2906
+ this.opts = opts;
2907
+ this.projectId = opts.projectId;
2908
+ this.schema = opts.schema;
2909
+ this.connection = new SyncConnection({
2910
+ wsUrl: opts.wsUrl,
2911
+ getToken: opts.getToken,
2912
+ WebSocketImpl: opts.WebSocketImpl,
2913
+ heartbeatMs: opts.heartbeatMs,
2914
+ onWelcome: (msg) => this.handleWelcome(msg),
2915
+ onMessage: (msg) => this.handleMessage(msg),
2916
+ onStatus: (status) => this.handleConnectionStatus(status),
2917
+ log: opts.log
2918
+ });
2919
+ }
2920
+ // -------------------------------------------------------------------
2921
+ // Events
2922
+ // -------------------------------------------------------------------
2923
+ on(event, fn) {
2924
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
2925
+ const set = this.listeners.get(event);
2926
+ set.add(fn);
2927
+ return () => set.delete(fn);
2928
+ }
2929
+ emit(event, data) {
2930
+ const set = this.listeners.get(event);
2931
+ if (!set) return;
2932
+ for (const fn of set) {
2933
+ try {
2934
+ fn(data);
2935
+ } catch (err) {
2936
+ this.log("listener error:", err);
2937
+ }
2938
+ }
2939
+ }
2940
+ // -------------------------------------------------------------------
2941
+ // Public state
2942
+ // -------------------------------------------------------------------
2943
+ get status() {
2944
+ return this._status;
2945
+ }
2946
+ get syncLimits() {
2947
+ return { ...this.limits };
2948
+ }
2949
+ get serverActor() {
2950
+ return this.actor;
2951
+ }
2952
+ getSubscription(key) {
2953
+ return this.subs.get(key);
2954
+ }
2955
+ get own() {
2956
+ return this.subs.get(OWN_SUB);
2957
+ }
2958
+ get pendingCount() {
2959
+ let n = 0;
2960
+ for (const sub of this.subs.values()) n += sub.pending.length;
2961
+ return n;
2962
+ }
2963
+ async listRejected(subKey = OWN_SUB) {
2964
+ const sub = this.subs.get(subKey);
2965
+ if (!sub) return [];
2966
+ return sub.store.listRejected();
2967
+ }
2968
+ async clearRejected(subKey = OWN_SUB) {
2969
+ await this.subs.get(subKey)?.store.clearRejected();
2970
+ }
2971
+ // -------------------------------------------------------------------
2972
+ // Lifecycle
2973
+ // -------------------------------------------------------------------
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
+ }
2980
+ return;
2981
+ }
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);
2987
+ }
2988
+ this.connection.start();
2989
+ this.recomputeStatus();
2990
+ }
2991
+ /** Close the socket and stores; local data is kept. */
2992
+ stop() {
2993
+ this.started = false;
2994
+ this.connection.stop();
2995
+ for (const t of this.timers) clearTimeout(t);
2996
+ this.timers.clear();
2997
+ for (const sub of this.subs.values()) {
2998
+ sub.active = false;
2999
+ sub.store.close();
3000
+ }
3001
+ this.subs.clear();
3002
+ this.recomputeStatus();
3003
+ }
3004
+ /**
3005
+ * Stop and delete every local database for this project (sign-out).
3006
+ * Best-effort discovery of mount keyspaces from previous sessions.
3007
+ */
3008
+ async destroyLocal() {
3009
+ const open = [...this.subs.values()];
3010
+ this.stop();
3011
+ for (const sub of open) {
3012
+ try {
3013
+ await sub.store.destroy();
3014
+ } catch (err) {
3015
+ this.log("failed deleting local db:", err);
3016
+ }
3017
+ }
3018
+ try {
3019
+ const idb = globalThis.indexedDB;
3020
+ if (idb && typeof idb.databases === "function") {
3021
+ const prefix = `${this.dbPrefix}:${this.projectId}`;
3022
+ const dbs = await idb.databases();
3023
+ for (const info of dbs) {
3024
+ if (info.name && info.name.startsWith(prefix)) {
3025
+ await new Promise((resolve) => {
3026
+ const req = idb.deleteDatabase(info.name);
3027
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
3028
+ });
3029
+ }
3030
+ }
3031
+ }
3032
+ } catch {
3033
+ }
3034
+ }
3035
+ // -------------------------------------------------------------------
3036
+ // Shares (mounts)
3037
+ // -------------------------------------------------------------------
3038
+ /**
3039
+ * Mount a share: separate keyspace `(project, share)` with its own cursor
3040
+ * and pending queue. Bootstraps + subscribes when the socket is online.
3041
+ */
3042
+ async mountShare(shareId) {
3043
+ const key = shareSubKey(shareId);
3044
+ const existing = this.subs.get(key);
3045
+ if (existing) return existing;
3046
+ const sub = await this.openSub(key, shareId);
3047
+ this.subs.set(key, sub);
3048
+ if (this.connection.isOnline) {
3049
+ this.enqueue(sub, () => this.activateSub(sub));
3050
+ }
3051
+ return sub;
3052
+ }
3053
+ /** Unsubscribe a mount. Local cache is kept unless `purge` is set. */
3054
+ async unmountShare(shareId, options) {
3055
+ const key = shareSubKey(shareId);
3056
+ const sub = this.subs.get(key);
3057
+ if (!sub) return;
3058
+ if (sub.active) {
3059
+ this.connection.send({ type: "unsubscribe", sub: key });
3060
+ }
3061
+ this.subs.delete(key);
3062
+ if (options?.purge) {
3063
+ await sub.store.destroy();
3064
+ } else {
3065
+ sub.store.close();
3066
+ }
3067
+ }
3068
+ // -------------------------------------------------------------------
3069
+ // Writes (responsibility 2 + 6: pending queue + optimistic rebase)
3070
+ // -------------------------------------------------------------------
3071
+ /**
3072
+ * Queue a local op, apply it optimistically, and push when online.
3073
+ * Returns the resulting view record (null when deleted).
3074
+ * Throws on local validation failure (fail fast — the server would
3075
+ * terminally reject it anyway).
3076
+ */
3077
+ async apply(subKey, partial) {
3078
+ const sub = this.subs.get(subKey);
3079
+ if (!sub) throw new Error(`unknown subscription '${subKey}' \u2014 is the engine started?`);
3080
+ if (!sub.store.hasTable(partial.table)) {
3081
+ throw new Error(`table "${partial.table}" not found in schema`);
3082
+ }
3083
+ if (this.opts.validateWrites !== false && partial.type !== "delete") {
3084
+ const result = (0, import_schema.validateData)(
3085
+ this.schema,
3086
+ partial.table,
3087
+ partial.data ?? {},
3088
+ partial.type === "put"
3089
+ );
3090
+ if (!result.valid) {
3091
+ throw new Error(result.message || "data validation failed");
3092
+ }
3093
+ }
3094
+ const op = {
3095
+ op_id: mintOpId(),
3096
+ type: partial.type,
3097
+ table: partial.table,
3098
+ record_id: partial.record_id,
3099
+ ...partial.type !== "delete" ? { data: partial.data ?? {} } : {},
3100
+ base_seq: Math.max(sub.cursor, 0)
3101
+ };
3102
+ const bytes = JSON.stringify(op).length;
3103
+ if (bytes > this.limits.max_op_bytes) {
3104
+ throw new Error(
3105
+ `op exceeds max_op_bytes (${bytes} > ${this.limits.max_op_bytes}) \u2014 PAYLOAD_TOO_LARGE`
3106
+ );
3107
+ }
3108
+ let view = null;
3109
+ await this.enqueue(sub, async () => {
3110
+ view = await sub.store.addPending(op);
3111
+ sub.pending.push({ op, sent: false });
3112
+ });
3113
+ this.emit("change", { sub: sub.key, tables: [op.table] });
3114
+ this.flush(sub);
3115
+ return view;
3116
+ }
3117
+ // -------------------------------------------------------------------
3118
+ // Connection handling
3119
+ // -------------------------------------------------------------------
3120
+ handleConnectionStatus(status) {
3121
+ this.connectionStatus = status;
3122
+ if (status === "offline" || status === "connecting" || status === "auth_failed") {
3123
+ for (const sub of this.subs.values()) {
3124
+ sub.active = false;
3125
+ for (const p of sub.pending) {
3126
+ p.sent = false;
3127
+ p.ackedSeq = void 0;
3128
+ }
3129
+ }
3130
+ }
3131
+ this.recomputeStatus();
3132
+ }
3133
+ handleWelcome(msg) {
3134
+ this.actor = msg.actor;
3135
+ if (msg.limits) this.limits = { ...this.limits, ...msg.limits };
3136
+ for (const sub of this.subs.values()) {
3137
+ if (sub.status === "revoked") continue;
3138
+ this.enqueue(sub, () => this.activateSub(sub));
3139
+ }
3140
+ }
3141
+ handleMessage(msg) {
3142
+ switch (msg.type) {
3143
+ case "subscribed":
3144
+ this.handleSubscribed(msg);
3145
+ return;
3146
+ case "ops": {
3147
+ const sub = this.subs.get(msg.sub);
3148
+ if (sub) this.enqueue(sub, () => this.processOps(sub, msg));
3149
+ return;
3150
+ }
3151
+ case "pushed": {
3152
+ const sub = this.subs.get(msg.sub);
3153
+ if (sub) this.enqueue(sub, () => this.processPushed(sub, msg));
3154
+ return;
3155
+ }
3156
+ case "error":
3157
+ this.handleError(msg);
3158
+ return;
3159
+ case "pong":
3160
+ case "token_ok":
3161
+ case "unsubscribed":
3162
+ case "welcome":
3163
+ return;
3164
+ default:
3165
+ return;
3166
+ }
3167
+ }
3168
+ handleSubscribed(msg) {
3169
+ const sub = this.subs.get(msg.sub);
3170
+ if (!sub) return;
3171
+ sub.active = true;
3172
+ sub.status = "live";
3173
+ sub.schemaVersion = msg.schema_version;
3174
+ this.log(`subscribed '${sub.key}' channel=${msg.channel} cursor=${msg.cursor} head=${msg.head}`);
3175
+ this.flush(sub);
3176
+ }
3177
+ handleError(msg) {
3178
+ const subKey = msg.sub;
3179
+ this.log(`server error${subKey ? ` (sub ${subKey})` : ""}: ${msg.code} \u2014 ${msg.message ?? ""}`);
3180
+ if (subKey) {
3181
+ const sub = this.subs.get(subKey);
3182
+ if (!sub) return;
3183
+ this.emit("suberror", { sub: subKey, code: msg.code, message: msg.message });
3184
+ if (isRebootstrapError(msg.code)) {
3185
+ sub.active = false;
3186
+ sub.bootstrapped = false;
3187
+ for (const p of sub.pending) {
3188
+ p.sent = false;
3189
+ p.ackedSeq = void 0;
3190
+ }
3191
+ this.enqueue(sub, async () => {
3192
+ await this.activateSub(sub);
3193
+ });
3194
+ return;
3195
+ }
3196
+ if (msg.code === "SHARE_REVOKED" || msg.code === "CONNECTION_REVOKED") {
3197
+ sub.active = false;
3198
+ sub.status = "revoked";
3199
+ sub.revokedCode = msg.code;
3200
+ this.subs.delete(subKey);
3201
+ void sub.store.destroy().catch(() => {
3202
+ });
3203
+ return;
3204
+ }
3205
+ return;
3206
+ }
3207
+ switch (msg.code) {
3208
+ case "CONNECTION_REVOKED":
3209
+ this.revokedInfo = { code: msg.code, message: msg.message };
3210
+ this.started = false;
3211
+ this.connection.stop();
3212
+ this.recomputeStatus();
3213
+ this.emit("revoked", { code: msg.code, message: msg.message });
3214
+ return;
3215
+ case "UNSUPPORTED_VERSION":
3216
+ this.started = false;
3217
+ this.connection.stop();
3218
+ this.recomputeStatus();
3219
+ return;
3220
+ case "TOO_MANY_OPS":
3221
+ case "RATE_LIMITED": {
3222
+ for (const sub of this.subs.values()) {
3223
+ for (const p of sub.pending) {
3224
+ if (p.ackedSeq === void 0) p.sent = false;
3225
+ }
3226
+ }
3227
+ this.timer(() => {
3228
+ for (const sub of this.subs.values()) this.flush(sub);
3229
+ }, RETRY_FLUSH_DELAY_MS);
3230
+ return;
3231
+ }
3232
+ case "UNAUTHORIZED":
3233
+ case "TOKEN_EXPIRED":
3234
+ return;
3235
+ case "SNAPSHOT_REQUIRED":
3236
+ case "RESET_REQUIRED":
3237
+ for (const sub of this.subs.values()) {
3238
+ sub.active = false;
3239
+ sub.bootstrapped = false;
3240
+ this.enqueue(sub, () => this.activateSub(sub));
3241
+ }
3242
+ return;
3243
+ default:
3244
+ return;
3245
+ }
3246
+ }
3247
+ // -------------------------------------------------------------------
3248
+ // Subscription state machine (serialized per sub via `chain`)
3249
+ // -------------------------------------------------------------------
3250
+ async openSub(key, shareId) {
3251
+ const name = shareId ? `${this.dbPrefix}:${this.projectId}:share:${shareId}` : `${this.dbPrefix}:${this.projectId}`;
3252
+ const store = new SyncStore(name, this.schema);
3253
+ const [cursor, pendingRows] = await Promise.all([store.getCursor(), store.loadPending()]);
2845
3254
  return {
2846
- major: parts[0] || 0,
2847
- minor: parts[1] || 0
3255
+ key,
3256
+ shareId,
3257
+ store,
3258
+ cursor: cursor ?? -1,
3259
+ // -1 = never bootstrapped
3260
+ pending: pendingRows.map((row) => ({ op: row.op, sent: false })),
3261
+ active: false,
3262
+ bootstrapped: cursor !== null,
3263
+ status: "initializing",
3264
+ appliedOpIds: new BoundedSet(),
3265
+ chain: Promise.resolve(),
3266
+ schemaVersion: null
2848
3267
  };
2849
3268
  }
3269
+ /** Bootstrap if needed, then bind the stream on the current socket. */
3270
+ async activateSub(sub) {
3271
+ if (!this.connection.isOnline) return;
3272
+ if (!sub.bootstrapped || sub.cursor < 0) {
3273
+ try {
3274
+ await this.bootstrapSub(sub);
3275
+ } catch (err) {
3276
+ this.log(`bootstrap failed for '${sub.key}':`, err);
3277
+ sub.status = "error";
3278
+ return;
3279
+ }
3280
+ }
3281
+ this.connection.send({
3282
+ type: "subscribe",
3283
+ sub: sub.key,
3284
+ cursor: sub.cursor,
3285
+ ...sub.shareId ? { share: sub.shareId } : this.opts.appName ? { app: this.opts.appName } : {}
3286
+ });
3287
+ }
3288
+ /** Cold start = snapshot + tail; never log replay (§5). Pending survives. */
3289
+ async bootstrapSub(sub) {
3290
+ const snapshot = await this.opts.fetchSnapshot(
3291
+ sub.shareId ? { share: sub.shareId } : void 0
3292
+ );
3293
+ await sub.store.replaceFromSnapshot({
3294
+ channel: snapshot.channel,
3295
+ records: snapshot.records ?? {},
3296
+ cursor: snapshot.cursor ?? 0
3297
+ });
3298
+ sub.cursor = snapshot.cursor ?? 0;
3299
+ sub.bootstrapped = true;
3300
+ sub.appliedOpIds.clear();
3301
+ this.log(`bootstrapped '${sub.key}' cursor=${sub.cursor}`);
3302
+ this.emit("change", { sub: sub.key, tables: sub.store.tables });
3303
+ }
2850
3304
  /**
2851
- * Add a migration to the updater
3305
+ * Responsibilities 3+4+5: ordered apply, cursor advance, dedupe/confirm.
3306
+ * Runs inside the sub's serial chain.
2852
3307
  */
2853
- addMigration(migration) {
2854
- this.migrations.push(migration);
2855
- this.migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
3308
+ async processOps(sub, msg) {
3309
+ const applyOps = [];
3310
+ const confirmedOpIds = [];
3311
+ for (const op of msg.ops) {
3312
+ if (typeof op.seq !== "number" || op.seq <= sub.cursor) continue;
3313
+ sub.cursor = op.seq;
3314
+ if (sub.appliedOpIds.has(op.op_id)) continue;
3315
+ const idx = sub.pending.findIndex((p) => p.op.op_id === op.op_id);
3316
+ if (idx >= 0) {
3317
+ sub.pending.splice(idx, 1);
3318
+ confirmedOpIds.push(op.op_id);
3319
+ }
3320
+ if (!sub.store.hasTable(op.table)) {
3321
+ sub.appliedOpIds.add(op.op_id);
3322
+ continue;
3323
+ }
3324
+ applyOps.push(op);
3325
+ sub.appliedOpIds.add(op.op_id);
3326
+ }
3327
+ if (typeof msg.cursor === "number" && msg.cursor > sub.cursor) {
3328
+ sub.cursor = msg.cursor;
3329
+ }
3330
+ if (applyOps.length > 0 || confirmedOpIds.length > 0) {
3331
+ await sub.store.commitIncoming({ applyOps, confirmedOpIds, cursor: sub.cursor });
3332
+ const tables = [...new Set(applyOps.map((op) => op.table))];
3333
+ this.emit("change", { sub: sub.key, tables });
3334
+ } else {
3335
+ await sub.store.setCursor(sub.cursor);
3336
+ }
3337
+ }
3338
+ /**
3339
+ * Push verdicts (§6.5, §8, §9). Success acks are recorded but the op stays
3340
+ * pending until its echo arrives in seq order — this preserves strict
3341
+ * ordered apply even when `pushed` races ahead of intermediate remote ops.
3342
+ * Terminal errors apply the poison-op rule; retryables back off.
3343
+ */
3344
+ async processPushed(sub, msg) {
3345
+ let needsRetry = false;
3346
+ const changedTables = /* @__PURE__ */ new Set();
3347
+ for (const result of msg.results) {
3348
+ const idx = sub.pending.findIndex((p) => p.op.op_id === result.op_id);
3349
+ if ("seq" in result && typeof result.seq === "number") {
3350
+ if (idx >= 0) {
3351
+ if (result.seq <= sub.cursor) {
3352
+ const [entry] = sub.pending.splice(idx, 1);
3353
+ await sub.store.commitIncoming({
3354
+ applyOps: [],
3355
+ confirmedOpIds: [entry.op.op_id],
3356
+ cursor: sub.cursor
3357
+ });
3358
+ changedTables.add(entry.op.table);
3359
+ } else {
3360
+ sub.pending[idx].ackedSeq = result.seq;
3361
+ await sub.store.markAcked(result.op_id, result.seq);
3362
+ }
3363
+ }
3364
+ continue;
3365
+ }
3366
+ if ("error" in result) {
3367
+ if (isTerminalOpError(result.error, result.terminal)) {
3368
+ if (idx >= 0) sub.pending.splice(idx, 1);
3369
+ const rejection = await sub.store.rejectPending(
3370
+ result.op_id,
3371
+ result.error,
3372
+ result.message
3373
+ );
3374
+ if (rejection) {
3375
+ changedTables.add(rejection.op.table);
3376
+ this.emit("rejected", { sub: sub.key, rejection });
3377
+ this.log(
3378
+ `op rejected (${result.error}): ${rejection.op.type} ${rejection.op.table}/${rejection.op.record_id}`
3379
+ );
3380
+ }
3381
+ } else if (idx >= 0) {
3382
+ sub.pending[idx].sent = false;
3383
+ needsRetry = true;
3384
+ }
3385
+ }
3386
+ }
3387
+ if (changedTables.size > 0) {
3388
+ this.emit("change", { sub: sub.key, tables: [...changedTables] });
3389
+ }
3390
+ if (needsRetry) {
3391
+ this.timer(() => this.flush(sub), RETRY_FLUSH_DELAY_MS);
3392
+ }
3393
+ }
3394
+ /** Push unsent pending ops, chunked to `limits.max_ops_per_push`. */
3395
+ flush(sub) {
3396
+ if (!this.connection.isOnline || !sub.active) return;
3397
+ const unsent = sub.pending.filter((p) => !p.sent && p.ackedSeq === void 0);
3398
+ if (unsent.length === 0) return;
3399
+ const chunkSize = Math.max(1, this.limits.max_ops_per_push);
3400
+ for (let i = 0; i < unsent.length; i += chunkSize) {
3401
+ const chunk = unsent.slice(i, i + chunkSize);
3402
+ for (const p of chunk) p.sent = true;
3403
+ const ok = this.connection.send({
3404
+ type: "push",
3405
+ sub: sub.key,
3406
+ ops: chunk.map((p) => p.op)
3407
+ });
3408
+ if (!ok) {
3409
+ for (const p of chunk) p.sent = false;
3410
+ return;
3411
+ }
3412
+ }
3413
+ this.log(`pushed ${unsent.length} op(s) on '${sub.key}'`);
3414
+ }
3415
+ // -------------------------------------------------------------------
3416
+ // Internals
3417
+ // -------------------------------------------------------------------
3418
+ get dbPrefix() {
3419
+ return this.opts.dbNamePrefix ?? "basic-sync";
3420
+ }
3421
+ enqueue(sub, task) {
3422
+ sub.chain = sub.chain.then(task).catch((err) => {
3423
+ this.log(`task failed on '${sub.key}':`, err);
3424
+ });
3425
+ return sub.chain;
3426
+ }
3427
+ timer(fn, ms) {
3428
+ const t = setTimeout(() => {
3429
+ this.timers.delete(t);
3430
+ fn();
3431
+ }, ms);
3432
+ this.timers.add(t);
3433
+ }
3434
+ recomputeStatus() {
3435
+ let status;
3436
+ if (this.revokedInfo) status = "revoked";
3437
+ else if (this.connectionStatus === "auth_failed") status = "auth_required";
3438
+ else if (!this.started) status = this.connectionStatus === "stopped" ? "stopped" : "idle";
3439
+ else if (this.connectionStatus === "online") status = "online";
3440
+ else if (this.connectionStatus === "connecting") status = "connecting";
3441
+ else if (this.connectionStatus === "idle") status = "connecting";
3442
+ else status = "offline";
3443
+ if (status !== this._status) {
3444
+ this._status = status;
3445
+ this.emit("status", status);
3446
+ }
3447
+ }
3448
+ log(...args) {
3449
+ this.opts.log?.("[sync-engine]", ...args);
2856
3450
  }
2857
3451
  };
2858
- function createVersionUpdater(storage, currentVersion, migrations = []) {
2859
- return new VersionUpdater(storage, currentVersion, migrations);
2860
- }
2861
3452
 
2862
- // src/updater/updateMigrations.ts
2863
- init_config();
2864
- var addMigrationTimestamp = {
2865
- fromVersion: "0.6.0",
2866
- toVersion: "0.7.0",
2867
- async migrate(storage) {
2868
- log("Running migration 0.6.0 \u2192 0.7.0");
2869
- storage.set("test_migration", "true");
3453
+ // src/core/db.ts
3454
+ function mintRecordId() {
3455
+ return mintOpId();
3456
+ }
3457
+ var SyncTable = class {
3458
+ constructor(engine, subKey, name) {
3459
+ this.engine = engine;
3460
+ this.subKey = subKey;
3461
+ this.name = name;
3462
+ }
3463
+ get store() {
3464
+ const sub = this.engine.getSubscription(this.subKey);
3465
+ if (!sub) {
3466
+ throw new Error(
3467
+ `subscription '${this.subKey}' is not open \u2014 sign in and wait for the db to be ready`
3468
+ );
3469
+ }
3470
+ return sub.store;
3471
+ }
3472
+ get ref() {
3473
+ return this.store.view(this.name);
3474
+ }
3475
+ async create(data) {
3476
+ const id = mintRecordId();
3477
+ const view = await this.engine.apply(this.subKey, {
3478
+ type: "put",
3479
+ table: this.name,
3480
+ record_id: id,
3481
+ data
3482
+ });
3483
+ return view ?? { id, ...data };
3484
+ }
3485
+ async put(id, data) {
3486
+ if (!id) throw new Error("put() requires an id");
3487
+ const view = await this.engine.apply(this.subKey, {
3488
+ type: "put",
3489
+ table: this.name,
3490
+ record_id: id,
3491
+ data
3492
+ });
3493
+ return view ?? { id, ...data };
3494
+ }
3495
+ async patch(id, data) {
3496
+ if (!id) throw new Error("patch() requires an id");
3497
+ const existing = await this.store.getViewRecord(this.name, id);
3498
+ if (!existing) return null;
3499
+ const view = await this.engine.apply(this.subKey, {
3500
+ type: "patch",
3501
+ table: this.name,
3502
+ record_id: id,
3503
+ data
3504
+ });
3505
+ return view;
3506
+ }
3507
+ async delete(id) {
3508
+ if (!id) throw new Error("delete() requires an id");
3509
+ await this.engine.apply(this.subKey, {
3510
+ type: "delete",
3511
+ table: this.name,
3512
+ record_id: id
3513
+ });
3514
+ }
3515
+ async get(id) {
3516
+ return await this.store.getViewRecord(this.name, id);
3517
+ }
3518
+ async getAll() {
3519
+ return await this.store.getViewRecords(this.name);
3520
+ }
3521
+ async find(predicate) {
3522
+ const all = await this.getAll();
3523
+ return all.filter(predicate);
3524
+ }
3525
+ };
3526
+ var SyncDb = class {
3527
+ constructor(engine, subKey = OWN_SUB) {
3528
+ this.engine = engine;
3529
+ this.subKey = subKey;
3530
+ }
3531
+ kind = "sync";
3532
+ tables = /* @__PURE__ */ new Map();
3533
+ table(name) {
3534
+ if (!this.engine.schema.tables[name]) {
3535
+ throw new Error(`table "${name}" not found in schema`);
3536
+ }
3537
+ if (!this.tables.has(name)) {
3538
+ this.tables.set(name, new SyncTable(this.engine, this.subKey, name));
3539
+ }
3540
+ return this.tables.get(name);
3541
+ }
3542
+ };
3543
+ var RestTable = class {
3544
+ constructor(rest, name) {
3545
+ this.rest = rest;
3546
+ this.name = name;
3547
+ }
3548
+ async create(data) {
3549
+ const record = await this.rest.createRecord(this.name, data);
3550
+ return record;
3551
+ }
3552
+ async put(id, data) {
3553
+ if (!id) throw new Error("put() requires an id");
3554
+ const record = await this.rest.putRecord(this.name, id, data);
3555
+ if (!record) throw new Error(`record ${this.name}/${id} not found (REST put is replace-only)`);
3556
+ return record;
3557
+ }
3558
+ async patch(id, data) {
3559
+ if (!id) throw new Error("patch() requires an id");
3560
+ const record = await this.rest.patchRecord(this.name, id, data);
3561
+ return record;
3562
+ }
3563
+ async delete(id) {
3564
+ if (!id) throw new Error("delete() requires an id");
3565
+ await this.rest.deleteRecord(this.name, id);
3566
+ }
3567
+ async get(id) {
3568
+ const record = await this.rest.getRecord(this.name, id);
3569
+ return record;
3570
+ }
3571
+ async getAll() {
3572
+ return await this.rest.list(this.name);
3573
+ }
3574
+ async find(predicate) {
3575
+ const all = await this.getAll();
3576
+ return all.filter(predicate);
3577
+ }
3578
+ };
3579
+ var RestDb = class {
3580
+ constructor(rest, schema) {
3581
+ this.rest = rest;
3582
+ this.schema = schema;
3583
+ }
3584
+ kind = "rest";
3585
+ tables = /* @__PURE__ */ new Map();
3586
+ table(name) {
3587
+ if (this.schema?.tables && !this.schema.tables[name]) {
3588
+ throw new Error(`table "${name}" not found in schema`);
3589
+ }
3590
+ if (!this.tables.has(name)) {
3591
+ this.tables.set(name, new RestTable(this.rest, name));
3592
+ }
3593
+ return this.tables.get(name);
2870
3594
  }
2871
3595
  };
2872
- function getMigrations() {
2873
- return [
2874
- addMigrationTimestamp
2875
- ];
2876
- }
2877
-
2878
- // src/AuthContext.tsx
2879
- init_network();
2880
3596
 
2881
3597
  // src/utils/schema.ts
2882
- var import_schema3 = require("@basictech/schema");
3598
+ var import_schema2 = require("@basictech/schema");
2883
3599
  init_config();
2884
3600
  async function getSchemaStatus(schema) {
2885
3601
  const projectId = schema.project_id;
2886
- const valid = (0, import_schema3.validateSchema)(schema);
3602
+ const valid = (0, import_schema2.validateSchema)(schema);
2887
3603
  if (!valid.valid) {
2888
3604
  console.warn("BasicDB Error: your local schema is invalid. Please fix errors and try again - sync is disabled");
2889
3605
  return {
@@ -2921,7 +3637,7 @@ async function getSchemaStatus(schema) {
2921
3637
  latest: latestSchema
2922
3638
  };
2923
3639
  } else if (latestSchema.version === schema.version) {
2924
- const changes = (0, import_schema3.compareSchemas)(schema, latestSchema);
3640
+ const changes = (0, import_schema2.compareSchemas)(schema, latestSchema);
2925
3641
  if (changes.valid) {
2926
3642
  return {
2927
3643
  valid: true,
@@ -2943,471 +3659,567 @@ async function getSchemaStatus(schema) {
2943
3659
  latest: null
2944
3660
  };
2945
3661
  }
2946
- }
2947
- async function validateAndCheckSchema(schema) {
2948
- const valid = (0, import_schema3.validateSchema)(schema);
2949
- if (!valid.valid) {
2950
- log("Basic Schema is invalid!", valid.errors);
2951
- console.group("Schema Errors");
2952
- let errorMessage = "";
2953
- valid.errors.forEach((error, index) => {
2954
- log(`${index + 1}:`, error.message, ` - at ${error.instancePath}`);
2955
- errorMessage += `${index + 1}: ${error.message} - at ${error.instancePath}
2956
- `;
3662
+ }
3663
+ async function validateAndCheckSchema(schema) {
3664
+ const valid = (0, import_schema2.validateSchema)(schema);
3665
+ if (!valid.valid) {
3666
+ log("Basic Schema is invalid!", valid.errors);
3667
+ console.group("Schema Errors");
3668
+ let errorMessage = "";
3669
+ valid.errors.forEach((error, index) => {
3670
+ log(`${index + 1}:`, error.message, ` - at ${error.instancePath}`);
3671
+ errorMessage += `${index + 1}: ${error.message} - at ${error.instancePath}
3672
+ `;
3673
+ });
3674
+ console.groupEnd();
3675
+ return {
3676
+ isValid: false,
3677
+ schemaStatus: { valid: false },
3678
+ errors: valid.errors
3679
+ };
3680
+ }
3681
+ let schemaStatus = { valid: false };
3682
+ if (schema.version !== 0) {
3683
+ schemaStatus = await getSchemaStatus(schema);
3684
+ log("schemaStatus", schemaStatus);
3685
+ } else {
3686
+ schemaStatus = { valid: false, status: "unpublished" };
3687
+ log("schema not published - at version 0");
3688
+ }
3689
+ return {
3690
+ isValid: true,
3691
+ schemaStatus
3692
+ };
3693
+ }
3694
+
3695
+ // src/updater/versionUpdater.ts
3696
+ init_config();
3697
+ var VersionUpdater = class {
3698
+ storage;
3699
+ currentVersion;
3700
+ migrations;
3701
+ versionKey = "basic_app_version";
3702
+ constructor(storage, currentVersion, migrations = []) {
3703
+ this.storage = storage;
3704
+ this.currentVersion = currentVersion;
3705
+ this.migrations = migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
3706
+ }
3707
+ /**
3708
+ * Check current stored version and run migrations if needed
3709
+ * Only compares major.minor versions, ignoring beta/prerelease parts
3710
+ * Example: "0.7.0-beta.1" and "0.7.0" are treated as the same version
3711
+ */
3712
+ async checkAndUpdate() {
3713
+ const storedVersion = await this.getStoredVersion();
3714
+ if (!storedVersion) {
3715
+ await this.setStoredVersion(this.currentVersion);
3716
+ return { updated: false, toVersion: this.currentVersion };
3717
+ }
3718
+ if (storedVersion === this.currentVersion) {
3719
+ return { updated: false, toVersion: this.currentVersion };
3720
+ }
3721
+ const migrationsToRun = this.getMigrationsToRun(storedVersion, this.currentVersion);
3722
+ if (migrationsToRun.length === 0) {
3723
+ await this.setStoredVersion(this.currentVersion);
3724
+ return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
3725
+ }
3726
+ for (const migration of migrationsToRun) {
3727
+ try {
3728
+ log(`Running migration from ${migration.fromVersion} to ${migration.toVersion}`);
3729
+ await migration.migrate(this.storage);
3730
+ } catch (error) {
3731
+ console.error(`Migration failed from ${migration.fromVersion} to ${migration.toVersion}:`, error);
3732
+ throw new Error(`Migration failed: ${error}`);
3733
+ }
3734
+ }
3735
+ await this.setStoredVersion(this.currentVersion);
3736
+ return { updated: true, fromVersion: storedVersion, toVersion: this.currentVersion };
3737
+ }
3738
+ async getStoredVersion() {
3739
+ try {
3740
+ const versionData = await this.storage.get(this.versionKey);
3741
+ if (!versionData) return null;
3742
+ const versionInfo = JSON.parse(versionData);
3743
+ return versionInfo.version;
3744
+ } catch (error) {
3745
+ console.warn("Failed to get stored version:", error);
3746
+ return null;
3747
+ }
3748
+ }
3749
+ async setStoredVersion(version2) {
3750
+ const versionInfo = {
3751
+ version: version2,
3752
+ lastUpdated: Date.now()
3753
+ };
3754
+ await this.storage.set(this.versionKey, JSON.stringify(versionInfo));
3755
+ }
3756
+ getMigrationsToRun(fromVersion, toVersion) {
3757
+ return this.migrations.filter((migration) => {
3758
+ const storedLessThanMigrationTo = this.compareVersions(fromVersion, migration.toVersion) < 0;
3759
+ const currentGreaterThanOrEqualMigrationTo = this.compareVersions(toVersion, migration.toVersion) >= 0;
3760
+ const shouldRun = storedLessThanMigrationTo && currentGreaterThanOrEqualMigrationTo;
3761
+ log(`Migration ${migration.fromVersion} \u2192 ${migration.toVersion}: shouldRun=${shouldRun}`);
3762
+ return shouldRun;
2957
3763
  });
2958
- console.groupEnd();
3764
+ }
3765
+ /**
3766
+ * Simple semantic version comparison (major.minor only, ignoring beta/prerelease)
3767
+ * Returns: -1 if a < b, 0 if a === b, 1 if a > b
3768
+ */
3769
+ compareVersions(a, b) {
3770
+ const aMajorMinor = this.extractMajorMinor(a);
3771
+ const bMajorMinor = this.extractMajorMinor(b);
3772
+ if (aMajorMinor.major !== bMajorMinor.major) {
3773
+ return aMajorMinor.major - bMajorMinor.major;
3774
+ }
3775
+ return aMajorMinor.minor - bMajorMinor.minor;
3776
+ }
3777
+ /**
3778
+ * Extract major.minor from version string, ignoring beta/prerelease
3779
+ * Examples: "0.7.0-beta.1" -> {major: 0, minor: 7}
3780
+ * "1.2.3" -> {major: 1, minor: 2}
3781
+ */
3782
+ extractMajorMinor(version2) {
3783
+ const cleanVersion = version2.split("-")[0]?.split("+")[0] || version2;
3784
+ const parts = cleanVersion.split(".").map(Number);
2959
3785
  return {
2960
- isValid: false,
2961
- schemaStatus: { valid: false },
2962
- errors: valid.errors
3786
+ major: parts[0] || 0,
3787
+ minor: parts[1] || 0
2963
3788
  };
2964
3789
  }
2965
- let schemaStatus = { valid: false };
2966
- if (schema.version !== 0) {
2967
- schemaStatus = await getSchemaStatus(schema);
2968
- log("schemaStatus", schemaStatus);
2969
- } else {
2970
- schemaStatus = { valid: false, status: "unpublished" };
2971
- log("schema not published - at version 0");
3790
+ /**
3791
+ * Add a migration to the updater
3792
+ */
3793
+ addMigration(migration) {
3794
+ this.migrations.push(migration);
3795
+ this.migrations.sort((a, b) => this.compareVersions(a.fromVersion, b.fromVersion));
2972
3796
  }
2973
- return {
2974
- isValid: true,
2975
- schemaStatus
2976
- };
3797
+ };
3798
+ function createVersionUpdater(storage, currentVersion, migrations = []) {
3799
+ return new VersionUpdater(storage, currentVersion, migrations);
2977
3800
  }
2978
3801
 
2979
- // src/AuthContext.tsx
2980
- init_context();
2981
- init_context();
2982
- var import_jsx_runtime2 = require("react/jsx-runtime");
2983
- var BasicDevToolbar2 = (0, import_react3.lazy)(
2984
- () => Promise.resolve().then(() => (init_BasicDevToolbar(), BasicDevToolbar_exports)).then((m) => ({ default: m.BasicDevToolbar }))
2985
- );
2986
- var DEFAULT_AUTH_CONFIG = {
3802
+ // src/updater/updateMigrations.ts
3803
+ init_config();
3804
+ var addMigrationTimestamp = {
3805
+ fromVersion: "0.6.0",
3806
+ toVersion: "0.7.0",
3807
+ async migrate(storage) {
3808
+ log("Running migration 0.6.0 \u2192 0.7.0");
3809
+ storage.set("test_migration", "true");
3810
+ }
3811
+ };
3812
+ var dropLegacySyncDb = {
3813
+ fromVersion: "0.8.0",
3814
+ toVersion: "0.9.0",
3815
+ async migrate() {
3816
+ log("Running migration 0.8.0 \u2192 0.9.0: deleting legacy basicdb");
3817
+ try {
3818
+ const idb = globalThis.indexedDB;
3819
+ if (!idb) return;
3820
+ await new Promise((resolve) => {
3821
+ const req = idb.deleteDatabase("basicdb");
3822
+ req.onsuccess = req.onerror = req.onblocked = () => resolve();
3823
+ });
3824
+ } catch {
3825
+ }
3826
+ }
3827
+ };
3828
+ function getMigrations() {
3829
+ return [
3830
+ addMigrationTimestamp,
3831
+ dropLegacySyncDb
3832
+ ];
3833
+ }
3834
+
3835
+ // src/core/BasicClient.ts
3836
+ init_config();
3837
+ init_package();
3838
+ var DEFAULTS = {
2987
3839
  scopes: "profile,email,app:admin",
2988
3840
  pds_url: "https://pds.basic.id",
2989
- admin_url: "https://api.basic.tech",
2990
- ws_url: "wss://pds.basic.id/ws"
3841
+ admin_url: "https://api.basic.tech"
2991
3842
  };
2992
- function snapshotAuth(mgr) {
2993
- return {
2994
- isSignedIn: mgr.isSignedIn,
2995
- hasToken: !!mgr.token,
2996
- isAuthReady: mgr.isAuthReady,
2997
- authStatus: mgr.authStatus,
2998
- authErrorCode: mgr.authErrorCode,
2999
- user: mgr.user,
3000
- did: mgr.did,
3001
- tokenScope: mgr.tokenScope
3002
- };
3843
+ function deriveSyncUrl(pdsUrl) {
3844
+ return pdsUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/sync/";
3003
3845
  }
3004
- function BasicProvider({
3005
- children,
3006
- project_id: project_id_prop,
3007
- schema,
3008
- debug = false,
3009
- storage,
3010
- auth,
3011
- dbMode = "sync",
3012
- devToolbar = false
3013
- }) {
3014
- const project_id = schema?.project_id || project_id_prop;
3015
- if (auth?.server_url && !auth?.pds_url) {
3016
- log("Warning: auth.server_url is deprecated, use auth.pds_url instead");
3017
- }
3018
- const authConfig = {
3019
- scopes: auth?.scopes || DEFAULT_AUTH_CONFIG.scopes,
3020
- pds_url: auth?.pds_url || auth?.server_url || DEFAULT_AUTH_CONFIG.pds_url,
3021
- admin_url: auth?.admin_url || DEFAULT_AUTH_CONFIG.admin_url,
3022
- ws_url: auth?.ws_url || DEFAULT_AUTH_CONFIG.ws_url
3023
- };
3024
- const scopesString = Array.isArray(authConfig.scopes) ? authConfig.scopes.join(" ") : authConfig.scopes;
3025
- const storageRef = (0, import_react3.useRef)(storage || new LocalStorageAdapter());
3026
- const storageAdapter = storageRef.current;
3027
- const schemaRef = (0, import_react3.useRef)(schema);
3028
- schemaRef.current = schema;
3029
- const [authState, setAuthState] = (0, import_react3.useState)({
3030
- isSignedIn: false,
3031
- hasToken: false,
3032
- isAuthReady: false,
3033
- authStatus: "bootstrapping",
3034
- authErrorCode: null,
3035
- user: null,
3036
- did: null,
3037
- tokenScope: null
3038
- });
3039
- const authRef = (0, import_react3.useRef)(null);
3040
- if (!authRef.current) {
3041
- authRef.current = new AuthManager(
3846
+ var BasicClient = class {
3847
+ auth;
3848
+ rest;
3849
+ engine;
3850
+ mode;
3851
+ config;
3852
+ projectId;
3853
+ syncDb;
3854
+ restDb;
3855
+ debug;
3856
+ devInfo = null;
3857
+ syncEnabled = false;
3858
+ schemaChecked = false;
3859
+ started = false;
3860
+ cleanupFns = [];
3861
+ mounts = /* @__PURE__ */ new Map();
3862
+ listeners = /* @__PURE__ */ new Set();
3863
+ snapshot;
3864
+ constructor(config) {
3865
+ this.config = config;
3866
+ this.debug = config.debug ?? false;
3867
+ this.mode = config.mode ?? "sync";
3868
+ this.projectId = config.schema?.project_id || config.project_id;
3869
+ const authConfig = {
3870
+ scopes: Array.isArray(config.auth?.scopes) ? config.auth.scopes.join(" ") : config.auth?.scopes || DEFAULTS.scopes,
3871
+ pds_url: config.auth?.pds_url || DEFAULTS.pds_url,
3872
+ admin_url: config.auth?.admin_url || DEFAULTS.admin_url
3873
+ };
3874
+ const syncUrl = config.auth?.sync_url || deriveSyncUrl(authConfig.pds_url);
3875
+ const storage = config.storage || new LocalStorageAdapter();
3876
+ this.auth = new AuthManager(
3042
3877
  {
3043
- projectId: project_id,
3044
- scopes: scopesString,
3878
+ projectId: this.projectId,
3879
+ scopes: authConfig.scopes,
3045
3880
  pdsUrl: authConfig.pds_url,
3046
3881
  adminUrl: authConfig.admin_url,
3047
- debug
3882
+ debug: this.debug
3048
3883
  },
3049
- storageAdapter,
3050
- () => setAuthState(snapshotAuth(authRef.current))
3884
+ storage,
3885
+ () => this.handleAuthChange()
3051
3886
  );
3052
- }
3053
- const syncRef = (0, import_react3.useRef)(null);
3054
- const remoteDbRef = (0, import_react3.useRef)(null);
3055
- const [shouldConnect, setShouldConnect] = (0, import_react3.useState)(false);
3056
- const [dbStatus, setDbStatus] = (0, import_react3.useState)("OFFLINE" /* OFFLINE */);
3057
- const [isDbReady, setIsDbReady] = (0, import_react3.useState)(false);
3058
- const [error, setError] = (0, import_react3.useState)(null);
3059
- const [schemaDevInfo, setSchemaDevInfo] = (0, import_react3.useState)(
3060
- null
3061
- );
3062
- const isDevMode = () => isDevelopment(debug);
3063
- const refreshSchemaStatus = (0, import_react3.useCallback)(async () => {
3064
- const s = schemaRef.current;
3065
- if (!s) {
3066
- setSchemaDevInfo(
3067
- project_id ? {
3068
- projectId: project_id,
3069
- localVersion: void 0,
3070
- status: "no_schema",
3071
- valid: false,
3072
- lastCheckedAt: Date.now()
3073
- } : null
3074
- );
3075
- return;
3076
- }
3077
- const result = await validateAndCheckSchema(s);
3078
- if (!result.isValid) {
3079
- const errText = result.errors?.map((e) => e.message || "").join("; ") || "invalid";
3080
- setSchemaDevInfo({
3081
- projectId: s.project_id ?? null,
3082
- localVersion: s.version,
3083
- status: "invalid",
3084
- valid: false,
3085
- lastCheckedAt: Date.now(),
3086
- error: errText
3887
+ this.rest = new RestClient({
3888
+ baseUrl: authConfig.pds_url,
3889
+ projectId: this.projectId ?? "",
3890
+ getToken: (opts) => this.auth.getToken(opts),
3891
+ log: this.debug ? log : void 0
3892
+ });
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
3087
3902
  });
3088
- return;
3903
+ this.syncDb = new SyncDb(this.engine, OWN_SUB);
3904
+ this.wireEngineEvents(this.engine);
3905
+ } else {
3906
+ this.engine = null;
3907
+ this.syncDb = null;
3089
3908
  }
3090
- setSchemaDevInfo({
3091
- projectId: s.project_id ?? null,
3092
- localVersion: s.version,
3093
- status: result.schemaStatus.status ?? "unknown",
3094
- valid: result.schemaStatus.valid,
3095
- lastCheckedAt: Date.now()
3096
- });
3097
- }, [project_id]);
3098
- (0, import_react3.useEffect)(() => {
3099
- const runVersionUpdater = async () => {
3909
+ this.restDb = new RestDb(this.rest, this.config.schema);
3910
+ this.snapshot = this.buildSnapshot();
3911
+ }
3912
+ // -------------------------------------------------------------------
3913
+ // Public surface
3914
+ // -------------------------------------------------------------------
3915
+ /** The database handle: offline-first in sync mode, direct API in rest mode. */
3916
+ get db() {
3917
+ if (this.mode === "sync" && this.syncDb) return this.syncDb;
3918
+ return this.restDb;
3919
+ }
3920
+ /** Bootstrap: version migrations, schema check, auth initialization. */
3921
+ async start() {
3922
+ if (this.started) return;
3923
+ this.started = true;
3924
+ try {
3925
+ const updater = createVersionUpdater(this.auth.storage, version, getMigrations());
3926
+ const result = await updater.checkAndUpdate();
3927
+ if (result.updated) log(`SDK storage migrated ${result.fromVersion} \u2192 ${result.toVersion}`);
3928
+ } catch (err) {
3929
+ log("version updater failed:", err);
3930
+ }
3931
+ void this.checkSchema().then(() => this.maybeStartSync());
3932
+ await this.auth.initialize();
3933
+ const teardownNetwork = this.auth.setupNetworkListeners();
3934
+ this.cleanupFns.push(teardownNetwork);
3935
+ }
3936
+ /** Sign out: server-side revoke, local auth clear, sync teardown + purge. */
3937
+ async signOut() {
3938
+ await this.auth.signOut();
3939
+ await this.teardownLocalData();
3940
+ }
3941
+ /** Stop connections and listeners; local data is kept. */
3942
+ stop() {
3943
+ this.started = false;
3944
+ this.engine?.stop();
3945
+ for (const fn of this.cleanupFns) {
3100
3946
  try {
3101
- const versionUpdater = createVersionUpdater(
3102
- storageAdapter,
3103
- version,
3104
- getMigrations()
3105
- );
3106
- const updateResult = await versionUpdater.checkAndUpdate();
3107
- if (updateResult.updated) {
3108
- log(
3109
- `App updated from ${updateResult.fromVersion} to ${updateResult.toVersion}`
3110
- );
3111
- } else {
3112
- log(`App version ${updateResult.toVersion} is current`);
3113
- }
3114
- } catch (error2) {
3115
- log("Version update failed:", error2);
3947
+ fn();
3948
+ } catch {
3116
3949
  }
3950
+ }
3951
+ this.cleanupFns = [];
3952
+ }
3953
+ /** Re-run the remote schema status check (dev toolbar). */
3954
+ async refreshSchemaStatus() {
3955
+ this.schemaChecked = false;
3956
+ await this.checkSchema();
3957
+ this.maybeStartSync();
3958
+ }
3959
+ async listRejected() {
3960
+ return this.engine?.listRejected() ?? [];
3961
+ }
3962
+ async clearRejected() {
3963
+ await this.engine?.clearRejected();
3964
+ this.publish();
3965
+ }
3966
+ // ---------------- shares ----------------
3967
+ /** Shares granted by / received by this user for this app. */
3968
+ async listShares() {
3969
+ return this.rest.listShares();
3970
+ }
3971
+ /** Mount a share: separate local keyspace + subscription. */
3972
+ async mountShare(shareId) {
3973
+ if (!this.engine) throw new Error("shares require sync mode");
3974
+ const existing = this.mounts.get(shareId);
3975
+ if (existing) return existing;
3976
+ await this.engine.mountShare(shareId);
3977
+ const handle = {
3978
+ shareId,
3979
+ db: new SyncDb(this.engine, shareSubKey(shareId))
3117
3980
  };
3118
- runVersionUpdater();
3119
- authRef.current.initialize();
3120
- return authRef.current.setupNetworkListeners();
3121
- }, []);
3122
- (0, import_react3.useEffect)(() => {
3123
- async function initSyncDb(options) {
3124
- if (!syncRef.current) {
3125
- log("Initializing Basic Sync DB");
3126
- await initDexieExtensions();
3127
- syncRef.current = new BasicSync("basicdb", { schema });
3128
- syncRef.current.syncable.on("statusChanged", (status) => {
3129
- const newStatus = getSyncStatus(status);
3130
- setDbStatus(newStatus);
3131
- if (newStatus === "ERROR_WILL_RETRY" /* ERROR_WILL_RETRY */) {
3132
- log(
3133
- "Sync entered ERROR_WILL_RETRY - reconciling auth session before retry"
3134
- );
3135
- authRef.current.reconcileSession("sync retry", {
3136
- forceRefresh: true,
3137
- throttleMs: 0
3138
- }).catch(() => {
3139
- });
3140
- }
3141
- });
3142
- if (options.shouldConnect) {
3143
- setShouldConnect(true);
3144
- } else {
3145
- log("Sync is disabled");
3146
- }
3147
- setIsDbReady(true);
3148
- }
3981
+ this.mounts.set(shareId, handle);
3982
+ this.publish();
3983
+ return handle;
3984
+ }
3985
+ async unmountShare(shareId, options) {
3986
+ if (!this.engine) return;
3987
+ await this.engine.unmountShare(shareId, options);
3988
+ this.mounts.delete(shareId);
3989
+ this.publish();
3990
+ }
3991
+ getMountedShare(shareId) {
3992
+ return this.mounts.get(shareId);
3993
+ }
3994
+ // ---------------- React subscription surface ----------------
3995
+ subscribe = (listener) => {
3996
+ this.listeners.add(listener);
3997
+ return () => this.listeners.delete(listener);
3998
+ };
3999
+ getSnapshot = () => {
4000
+ return this.snapshot;
4001
+ };
4002
+ // -------------------------------------------------------------------
4003
+ // Orchestration
4004
+ // -------------------------------------------------------------------
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();
4011
+ } else {
4012
+ this.maybeStartSync();
3149
4013
  }
3150
- function initRemoteDb() {
3151
- if (!remoteDbRef.current) {
3152
- if (!project_id) {
3153
- setError({
3154
- code: "missing_project_id",
3155
- title: "Project ID Required",
3156
- message: "Remote mode requires a project_id. Provide it via schema.project_id or the project_id prop."
3157
- });
3158
- setIsDbReady(true);
3159
- return;
3160
- }
3161
- log("Initializing Basic Remote DB");
3162
- remoteDbRef.current = new RemoteDB({
3163
- serverUrl: authConfig.pds_url,
3164
- projectId: project_id,
3165
- getToken: (opts) => authRef.current.getToken(opts),
3166
- schema,
3167
- debug,
3168
- onAuthError: (error2) => {
3169
- log("RemoteDB auth error:", error2);
3170
- if (error2.errorType === "forbidden") {
3171
- log("403 Forbidden - user lacks required scope, not signing out");
3172
- return;
3173
- }
3174
- authRef.current.reconcileSession(`remote db ${error2.errorType}`, {
3175
- forceRefresh: error2.errorType !== "network",
3176
- throttleMs: 0
3177
- }).catch((reconcileError) => {
3178
- log("RemoteDB auth recovery failed:", reconcileError);
3179
- });
3180
- }
3181
- });
3182
- setDbStatus("ONLINE" /* ONLINE */);
3183
- setIsDbReady(true);
4014
+ this.publish();
4015
+ }
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));
4021
+ }
4022
+ }
4023
+ async teardownLocalData() {
4024
+ this.mounts.clear();
4025
+ if (this.engine) {
4026
+ try {
4027
+ await this.engine.destroyLocal();
4028
+ } catch (err) {
4029
+ log("local data teardown failed:", err);
3184
4030
  }
3185
4031
  }
3186
- async function checkSchema() {
4032
+ this.publish();
4033
+ }
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(() => {
4044
+ });
4045
+ this.publish();
4046
+ });
4047
+ }
4048
+ async checkSchema() {
4049
+ if (this.schemaChecked) return;
4050
+ const schema = this.config.schema;
4051
+ if (!schema) {
4052
+ this.devInfo = this.projectId ? {
4053
+ projectId: this.projectId,
4054
+ localVersion: void 0,
4055
+ status: "no_schema",
4056
+ valid: false,
4057
+ lastCheckedAt: Date.now()
4058
+ } : null;
4059
+ this.syncEnabled = false;
4060
+ this.publish();
4061
+ return;
4062
+ }
4063
+ try {
3187
4064
  const result = await validateAndCheckSchema(schema);
3188
4065
  if (!result.isValid) {
3189
- let errorMessage = "";
3190
- if (result.errors) {
3191
- result.errors.forEach((err, index) => {
3192
- errorMessage += `${index + 1}: ${err.message} - at ${err.instancePath}
3193
- `;
3194
- });
3195
- }
3196
- setSchemaDevInfo({
3197
- projectId: schema?.project_id ?? null,
3198
- localVersion: schema?.version,
4066
+ const errText = result.errors?.map((e) => e.message || "").join("; ") || "invalid";
4067
+ this.devInfo = {
4068
+ projectId: schema.project_id ?? null,
4069
+ localVersion: schema.version,
3199
4070
  status: "invalid",
3200
4071
  valid: false,
3201
4072
  lastCheckedAt: Date.now(),
3202
- error: errorMessage.trim() || void 0
3203
- });
3204
- setError({
3205
- code: "schema_invalid",
3206
- title: "Basic Schema is invalid!",
3207
- message: errorMessage
3208
- });
3209
- setIsDbReady(true);
3210
- return null;
3211
- }
3212
- setSchemaDevInfo({
3213
- projectId: schema?.project_id ?? null,
3214
- localVersion: schema?.version,
3215
- status: result.schemaStatus.status ?? "unknown",
3216
- valid: result.schemaStatus.valid,
3217
- lastCheckedAt: Date.now()
3218
- });
3219
- if (dbMode === "remote") {
3220
- initRemoteDb();
4073
+ error: errText
4074
+ };
4075
+ this.syncEnabled = false;
3221
4076
  } else {
3222
- if (result.schemaStatus.valid) {
3223
- await initSyncDb({ shouldConnect: true });
3224
- } else {
3225
- if (result.schemaStatus.status === "unpublished") {
3226
- log(
3227
- "Schema not published yet (version 0) - sync is disabled. Publish your schema to enable sync."
3228
- );
3229
- } else {
3230
- log("Schema is invalid!", result.schemaStatus);
4077
+ const status = result.schemaStatus.status ?? "unknown";
4078
+ this.devInfo = {
4079
+ projectId: schema.project_id ?? null,
4080
+ localVersion: schema.version,
4081
+ status,
4082
+ valid: result.schemaStatus.valid,
4083
+ lastCheckedAt: Date.now()
4084
+ };
4085
+ const locallyPublishable = typeof schema.version === "number" && schema.version > 0;
4086
+ const remoteCheckInconclusive = status === "error" || status === "unknown";
4087
+ this.syncEnabled = result.schemaStatus.valid || remoteCheckInconclusive && locallyPublishable;
4088
+ if (!result.schemaStatus.valid) {
4089
+ if (status === "unpublished") {
4090
+ log("Schema not published (version 0) \u2014 sync is disabled until you publish.");
4091
+ } else if (remoteCheckInconclusive && locallyPublishable) {
4092
+ log("Schema registry check failed \u2014 proceeding with the local schema (offline-first).");
3231
4093
  }
3232
- await initSyncDb({ shouldConnect: false });
3233
4094
  }
3234
4095
  }
3235
- checkForNewVersion();
3236
- }
3237
- if (schema) {
3238
- checkSchema();
3239
- } else {
3240
- setSchemaDevInfo(
3241
- project_id ? {
3242
- projectId: project_id,
3243
- localVersion: void 0,
3244
- status: "no_schema",
3245
- valid: false,
3246
- lastCheckedAt: Date.now()
3247
- } : null
3248
- );
3249
- if (dbMode === "remote" && project_id) {
3250
- initRemoteDb();
3251
- } else {
3252
- setIsDbReady(true);
3253
- }
3254
- }
3255
- }, []);
3256
- (0, import_react3.useEffect)(() => {
3257
- if (authState.hasToken && syncRef.current && authState.isSignedIn && authState.authStatus !== "reauth_required" && shouldConnect) {
3258
- log("connecting to db...");
3259
- syncRef.current?.connect({
3260
- getToken: (opts) => authRef.current.getToken(opts),
3261
- ws_url: authConfig.ws_url
3262
- }).catch((e) => {
3263
- log("error connecting to db", e);
3264
- });
3265
- }
3266
- }, [
3267
- authState.authStatus,
3268
- authState.isSignedIn,
3269
- authState.hasToken,
3270
- shouldConnect
3271
- ]);
3272
- (0, import_react3.useEffect)(() => {
3273
- if (authState.authStatus !== "reauth_required" || !syncRef.current) {
3274
- return;
4096
+ } catch (err) {
4097
+ log("schema check failed:", err);
4098
+ this.syncEnabled = !!schema.version && schema.version > 0;
4099
+ this.devInfo = {
4100
+ projectId: schema.project_id ?? null,
4101
+ localVersion: schema.version,
4102
+ status: "unknown",
4103
+ valid: this.syncEnabled,
4104
+ lastCheckedAt: Date.now()
4105
+ };
3275
4106
  }
3276
- log("Auth requires reauthentication - disconnecting sync without deleting local DB");
3277
- setDbStatus("ERROR_TOKEN_EXPIRED" /* ERROR_TOKEN_EXPIRED */);
3278
- syncRef.current.disconnect({ ws_url: authConfig.ws_url }).catch((disconnectError) => {
3279
- log("Error disconnecting sync after auth invalidation:", disconnectError);
3280
- });
3281
- }, [authConfig.ws_url, authState.authStatus]);
3282
- const handleSignOut = async () => {
3283
- await authRef.current.signOut();
3284
- if (syncRef.current) {
4107
+ this.schemaChecked = true;
4108
+ this.publish();
4109
+ }
4110
+ buildSnapshot() {
4111
+ 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,
4121
+ syncEnabled: this.syncEnabled,
4122
+ devInfo: this.devInfo,
4123
+ mode: this.mode
4124
+ };
4125
+ }
4126
+ publish() {
4127
+ this.snapshot = this.buildSnapshot();
4128
+ for (const listener of this.listeners) {
3285
4129
  try {
3286
- await syncRef.current.close();
3287
- await syncRef.current.delete({ disableAutoOpen: false });
3288
- syncRef.current = null;
3289
- } catch (error2) {
3290
- console.error("Error during database cleanup:", error2);
3291
- }
3292
- }
3293
- if (typeof window !== "undefined") {
3294
- window.location.reload();
3295
- }
3296
- };
3297
- const handleSignIn = async () => {
3298
- try {
3299
- await authRef.current.signIn();
3300
- } catch (error2) {
3301
- if (isDevMode()) {
3302
- setError({
3303
- code: "signin_error",
3304
- title: "Sign-in Failed",
3305
- message: error2.message || "An error occurred during sign-in. Please try again."
3306
- });
3307
- }
3308
- throw error2;
3309
- }
3310
- };
3311
- const handleSignInWithHandle = async (handle) => {
3312
- try {
3313
- await authRef.current.signInWithHandle(handle);
3314
- } catch (error2) {
3315
- if (isDevMode()) {
3316
- setError({
3317
- code: "signin_error",
3318
- title: "Sign-in Failed",
3319
- message: error2.message || "An error occurred during sign-in. Please try again."
3320
- });
4130
+ listener();
4131
+ } catch {
3321
4132
  }
3322
- throw error2;
3323
4133
  }
3324
- };
3325
- const getCurrentDb = () => {
3326
- if (dbMode === "remote") {
3327
- return remoteDbRef.current || noDb;
3328
- }
3329
- return syncRef.current || noDb;
3330
- };
3331
- const contextValue = {
3332
- isReady: authState.isAuthReady,
3333
- isSignedIn: authState.isSignedIn,
3334
- authStatus: authState.authStatus,
3335
- authErrorCode: authState.authErrorCode,
3336
- user: authState.user,
3337
- did: authState.did,
3338
- scope: authState.tokenScope,
3339
- hasScope: (s) => authRef.current.hasScope(s),
3340
- missingScopes: () => authRef.current.missingScopes(),
3341
- signIn: handleSignIn,
3342
- signInWithHandle: handleSignInWithHandle,
3343
- signOut: handleSignOut,
3344
- signInWithCode: (code, state) => authRef.current.signInWithCode(code, state),
3345
- getToken: (opts) => authRef.current.getToken(opts),
3346
- getSignInUrl: (redirectUri) => authRef.current.getSignInUrl(redirectUri),
3347
- db: getCurrentDb(),
3348
- dbStatus,
3349
- dbMode,
3350
- devInfo: schemaDevInfo,
3351
- refreshSchemaStatus,
3352
- isAuthReady: authState.isAuthReady,
3353
- signin: handleSignIn,
3354
- signout: handleSignOut,
3355
- signinWithCode: (code, state) => authRef.current.signInWithCode(code, state),
3356
- getSignInLink: (redirectUri) => authRef.current.getSignInUrl(redirectUri)
3357
- };
3358
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(BasicContext.Provider, { value: contextValue, children: [
3359
- error && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ErrorDisplay, { error }),
3360
- devToolbar && isDevMode() && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BasicDevToolbar2, { debug }) }),
3361
- isDbReady && authState.isAuthReady && children
3362
- ] });
4134
+ }
4135
+ };
4136
+ function createBasicClient(config) {
4137
+ return new BasicClient(config);
3363
4138
  }
3364
- function ErrorDisplay({ error }) {
3365
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
3366
- "div",
3367
- {
3368
- style: {
3369
- position: "absolute",
3370
- top: 20,
3371
- left: 20,
3372
- color: "black",
3373
- backgroundColor: "#f8d7da",
3374
- border: "1px solid #f5c6cb",
3375
- borderRadius: "4px",
3376
- padding: "20px",
3377
- maxWidth: "400px",
3378
- margin: "20px auto",
3379
- boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
3380
- fontFamily: "monospace"
3381
- },
3382
- children: [
3383
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("h3", { style: { fontSize: "0.8rem", opacity: 0.8 }, children: [
3384
- "code: ",
3385
- error.code
3386
- ] }),
3387
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { style: { fontSize: "1.2rem", lineHeight: 1.5 }, children: error.title }),
3388
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { children: error.message })
3389
- ]
3390
- }
3391
- );
4139
+
4140
+ // src/react/BasicProvider.tsx
4141
+ init_network();
4142
+ var import_jsx_runtime2 = require("react/jsx-runtime");
4143
+ var BasicDevToolbar2 = (0, import_react4.lazy)(
4144
+ () => Promise.resolve().then(() => (init_BasicDevToolbar(), BasicDevToolbar_exports)).then((m) => ({ default: m.BasicDevToolbar }))
4145
+ );
4146
+ function BasicProvider({
4147
+ children,
4148
+ schema,
4149
+ project_id,
4150
+ auth,
4151
+ storage,
4152
+ debug = false,
4153
+ mode = "sync",
4154
+ devToolbar = false,
4155
+ renderWhileLoading = false
4156
+ }) {
4157
+ const clientRef = (0, import_react4.useRef)(null);
4158
+ if (!clientRef.current) {
4159
+ clientRef.current = new BasicClient({
4160
+ schema,
4161
+ project_id,
4162
+ auth,
4163
+ storage,
4164
+ debug,
4165
+ mode
4166
+ });
4167
+ }
4168
+ const client = clientRef.current;
4169
+ (0, import_react4.useEffect)(() => {
4170
+ void client.start();
4171
+ void checkForNewVersion();
4172
+ return () => client.stop();
4173
+ }, []);
4174
+ const snapshot = (0, import_react4.useSyncExternalStore)(client.subscribe, client.getSnapshot, client.getSnapshot);
4175
+ const showDevTools = devToolbar && isDevelopment(debug);
4176
+ const ready = snapshot.isReady;
4177
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(BasicClientContext.Provider, { value: client, children: [
4178
+ showDevTools && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BasicDevToolbar2, { debug }) }),
4179
+ (ready || renderWhileLoading) && children
4180
+ ] });
3392
4181
  }
3393
4182
 
3394
4183
  // src/index.ts
3395
- var import_dexie_react_hooks = require("dexie-react-hooks");
4184
+ init_hooks();
3396
4185
  init_BasicDevToolbar();
3397
4186
  // Annotate the CommonJS export names for ESM import in node:
3398
4187
  0 && (module.exports = {
4188
+ AuthManager,
4189
+ BasicClient,
3399
4190
  BasicDevToolbar,
3400
4191
  BasicProvider,
3401
- DBStatus,
4192
+ DEFAULT_LIMITS,
4193
+ LocalStorageAdapter,
3402
4194
  NotAuthenticatedError,
3403
- RemoteCollection,
3404
- RemoteDB,
3405
- RemoteDBError,
4195
+ OWN_SUB,
4196
+ PROTOCOL_VERSION,
4197
+ RestClient,
4198
+ RestDb,
4199
+ RestError,
3406
4200
  STORAGE_KEYS,
4201
+ SyncConnection,
4202
+ SyncDb,
4203
+ SyncEngine,
4204
+ SyncStore,
4205
+ applyOpToData,
4206
+ createBasicClient,
4207
+ isAuthError,
4208
+ isRebootstrapError,
4209
+ isRevocationError,
4210
+ isTerminalOpError,
4211
+ mintOpId,
3407
4212
  resolveDid,
3408
4213
  resolveDidWebUrl,
3409
4214
  resolveHandle,
4215
+ shareSubKey,
4216
+ useAuth,
3410
4217
  useBasic,
3411
- useQuery
4218
+ useBasicClient,
4219
+ useDb,
4220
+ useQuery,
4221
+ useShare,
4222
+ useShares,
4223
+ useSyncStatus
3412
4224
  });
3413
4225
  //# sourceMappingURL=index.js.map