craft-native 0.0.385 → 0.0.390

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/README.md CHANGED
@@ -666,12 +666,53 @@ values (including their `toJSON` representation). Invalid JSON input rejects
666
666
  without changing the previous checkpoint. Cancellation and account-scope checks
667
667
  still run before committing the captured value.
668
668
 
669
+ Browser checkpoint calls use IndexedDB read/write transactions and acknowledge
670
+ only transaction completion. Web Locks alone cannot make localStorage caches
671
+ coherent across tabs (observed in WebKit). Web Locks still serialize admission
672
+ and support queued cancellation; atomic capabilities require both APIs. Storage
673
+ exceptions reject the call. Cancelling an active transaction aborts it; an
674
+ already completed transaction cannot be undone.
675
+
676
+ Existing account-scoped Storage values are migrated once, before the first
677
+ transactional operation. localStorage remains a synchronous UI mirror and keeps
678
+ its quota checks, but the committed database is authoritative after migration.
679
+ A failed transaction attempts to restore the previous mirror; the next
680
+ successful checkpoint call repairs it from the database. Direct localStorage
681
+ edits and older Craft tabs cannot update the new checkpoint database: reload
682
+ all tabs when upgrading. Snapshots, credentials and unrelated app keys are not
683
+ migrated. This does not change browser security or promise power-loss durability.
684
+
669
685
  `craft.storage.setSync` updates the immediate UI cache and
670
686
  queues its write when Web Locks are available; it cannot acknowledge durability
671
687
  or propagate a later quota failure. Background events are an opportunity to
672
688
  checkpoint, but browsers can terminate a page before queued work finishes, so
673
689
  also save periodically during play.
674
690
 
691
+ Real-browser checkpoint acceptance runs separately from the SDK's in-memory
692
+ unit fixtures:
693
+
694
+ ```sh
695
+ bunx --no-install playwright install chromium firefox webkit
696
+ bun run test:browser-checkpoints
697
+ CRAFT_TEST_BROWSER=firefox bun run test:browser-checkpoints
698
+ CRAFT_TEST_BROWSER=webkit bun run test:browser-checkpoints
699
+ ```
700
+
701
+ The suite bundles the maintained `createBrowserPlatform` source, serves it on
702
+ loopback, and uses fresh, owned persistent profiles with real IndexedDB,
703
+ localStorage and Web Locks. It verifies acknowledged saves across reload and a graceful browser
704
+ restart, competing-tab create/CAS winners and retry loops, queued-write input
705
+ capture, reload/abort/disposal before commit, and actual quota rejection with
706
+ recovery, legacy migration, stale-cache recovery and account cleanup. Fault
707
+ injection aborts real transactions after put succeeds to verify rollback and
708
+ cancellation before commit. It never opens or modifies your personal browser
709
+ profile. CI runs all three engines and retains browser versions, the bundle
710
+ SHA-256, results and traces.
711
+ Set `CRAFT_BROWSER_ARTIFACTS` to retain these in a chosen directory; locally the
712
+ default is a printed temporary path. Profiles are removed after each test.
713
+ This proves the tested browser boundaries, not sudden process termination,
714
+ power-loss durability, storage eviction, shipping Safari/iOS versions or Android acceptance.
715
+
675
716
  Browser audio assigns one owner per sound ID. A newer play or stop invalidates
676
717
  pending media resolution for that ID; stopping all sounds, changing accounts,
677
718
  aborting a call or disposing the host also releases active audio. Superseded
@@ -46,7 +46,9 @@ import kotlin.math.roundToInt
46
46
 
47
47
  private const val CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
48
48
  private const val CRAFT_NATIVE_FETCH_TIMEOUT_MS = 30_000
49
- private const val CRAFT_NATIVE_FILE_PICK_REQUEST = 4101
49
+ private const val CRAFT_NATIVE_FILE_PICK_FIRST = 0x4000
50
+ private const val CRAFT_NATIVE_FILE_PICK_LAST = 0xffff
51
+ private const val CRAFT_NATIVE_FILE_PICK_STATE = "craft.filePicker.nextCode"
50
52
  private const val CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST = 4102
51
53
  private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
52
54
  private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
@@ -61,11 +63,41 @@ internal data class CraftNativeCapabilityResult(
61
63
  val message: String? = null,
62
64
  )
63
65
 
66
+ /** Never recycle a code while an old Activity result could still arrive. */
67
+ internal class CraftNativePickerCodes(private var next: Int = CRAFT_NATIVE_FILE_PICK_FIRST) {
68
+ @Synchronized fun allocate(): Int? = if (next <= CRAFT_NATIVE_FILE_PICK_LAST) next++ else null
69
+ @Synchronized fun snapshot(): Int = next
70
+ @Synchronized fun restore(value: Int) {
71
+ next = maxOf(next, value.coerceIn(CRAFT_NATIVE_FILE_PICK_FIRST, CRAFT_NATIVE_FILE_PICK_LAST + 1))
72
+ }
73
+ }
74
+
75
+ /** Injectable platform boundary; result matching and file IO stay in the host. */
76
+ internal interface CraftNativePickerHost {
77
+ fun launch(intent: Intent, requestCode: Int)
78
+ fun dismiss(requestCode: Int)
79
+ }
80
+
64
81
  /** Process-wide persistence and OS services shared by every native route. */
65
82
  internal class CraftNativeCapabilities(
66
83
  private val context: Context,
67
84
  private val config: JSONObject,
85
+ private val pickerHost: CraftNativePickerHost? = (context as? MainActivity)?.let { host ->
86
+ object : CraftNativePickerHost {
87
+ override fun launch(intent: Intent, requestCode: Int) { host.startActivityForResult(intent, requestCode) }
88
+ override fun dismiss(requestCode: Int) { host.finishActivity(requestCode) }
89
+ }
90
+ },
68
91
  ) {
92
+ companion object {
93
+ private val pickerCodes = CraftNativePickerCodes()
94
+ }
95
+
96
+ fun savePickerState(state: android.os.Bundle) { state.putInt(CRAFT_NATIVE_FILE_PICK_STATE, pickerCodes.snapshot()) }
97
+ fun restorePickerState(state: android.os.Bundle?) {
98
+ pickerCodes.restore(state?.getInt(CRAFT_NATIVE_FILE_PICK_STATE, CRAFT_NATIVE_FILE_PICK_FIRST) ?: CRAFT_NATIVE_FILE_PICK_FIRST)
99
+ }
100
+
69
101
  /** The screen's activity; null in a background refresh, where nothing can be shown. */
70
102
  private val activity: MainActivity? get() = context as? MainActivity
71
103
  private val bluetooth by lazy { CraftBluetooth(context) }
@@ -114,6 +146,7 @@ internal class CraftNativeCapabilities(
114
146
  val videoLimit: Long = 0,
115
147
  val selectionLimit: Int = 1,
116
148
  val fileLimit: Long = 0,
149
+ val requestCode: Int = -1,
117
150
  )
118
151
 
119
152
  private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -234,7 +267,12 @@ internal class CraftNativeCapabilities(
234
267
  fun cancel(requestToken: String) {
235
268
  cancelled.add(requestToken)
236
269
  fetchConnections.remove(requestToken)?.disconnect()
237
- if (pendingFilePick?.requestToken == requestToken) pendingFilePick = null
270
+ onMain {
271
+ if (pendingFilePick?.requestToken == requestToken) {
272
+ dismissFilePick()
273
+ cancelled.remove(requestToken)
274
+ }
275
+ }
238
276
  biometricPrompts.remove(requestToken)?.cancelAuthentication()
239
277
  }
240
278
 
@@ -247,13 +285,41 @@ internal class CraftNativeCapabilities(
247
285
  networkExecutor.shutdownNow()
248
286
  fetchConnections.values.forEach(HttpURLConnection::disconnect)
249
287
  fetchConnections.clear()
250
- pendingFilePick = null
288
+ onMain { dismissFilePick() }
251
289
  database?.close()
252
290
  database = null
253
291
  biometricPrompts.values.forEach { it.cancelAuthentication() }
254
292
  biometricPrompts.clear()
255
293
  }
256
294
 
295
+ private fun onMain(work: () -> Unit) {
296
+ if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) work()
297
+ else mainHandler.post { work() }
298
+ }
299
+
300
+ private fun dismissFilePick() {
301
+ val pending = pendingFilePick ?: return
302
+ pendingFilePick = null
303
+ // Clear ownership first: finishActivity may immediately return a result.
304
+ runCatching { pickerHost?.dismiss(pending.requestCode) }
305
+ }
306
+
307
+ private fun launchFilePick(host: CraftNativePickerHost, intent: Intent, pending: PendingFilePick) {
308
+ val code = pickerCodes.allocate()
309
+ if (code == null) {
310
+ complete(pending.requestToken, failure("PICKER_UNAVAILABLE", "Picker request codes exhausted; start a new Activity session"), pending.completion)
311
+ return
312
+ }
313
+ pendingFilePick = pending.copy(requestCode = code)
314
+ try { host.launch(intent, code) }
315
+ catch (error: Exception) {
316
+ if (pendingFilePick?.requestCode == code) {
317
+ pendingFilePick = null
318
+ complete(pending.requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Picker could not be opened"), pending.completion)
319
+ }
320
+ }
321
+ }
322
+
257
323
  /** The same bounded text-only fetch contract as the maintained iOS host. */
258
324
  private fun fetch(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
259
325
  val options = args.optJSONObject(0)
@@ -492,26 +558,22 @@ internal class CraftNativeCapabilities(
492
558
 
493
559
  private fun pickVideo(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
494
560
  mainHandler.post {
495
- if (fileRequestCancelled(requestToken)) return@post
496
- val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
561
+ if (fileRequestCancelled(requestToken)) { cancelled.remove(requestToken); return@post }
562
+ val host = pickerHost ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
497
563
  if (pendingFilePick != null) {
498
564
  complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
499
565
  return@post
500
566
  }
501
567
  val options = args.optJSONObject(0) ?: JSONObject()
502
568
  val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
503
- pendingFilePick = PendingFilePick(requestToken, "video", 0, 1f, completion, limit)
569
+ val pending = PendingFilePick(requestToken, "video", 0, 1f, completion, limit)
504
570
  val photos = options.optString("source", "library") != "files" && Build.VERSION.SDK_INT >= 33
505
571
  val intent = Intent(if (photos) MediaStore.ACTION_PICK_IMAGES else Intent.ACTION_OPEN_DOCUMENT).apply {
506
572
  type = "video/*"
507
573
  if (!photos) addCategory(Intent.CATEGORY_OPENABLE)
508
574
  addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
509
575
  }
510
- try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
511
- catch (error: Exception) {
512
- pendingFilePick = null
513
- complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Video picker could not be opened"), completion)
514
- }
576
+ launchFilePick(host, intent, pending)
515
577
  }
516
578
  }
517
579
 
@@ -559,8 +621,8 @@ internal class CraftNativeCapabilities(
559
621
  completion: (CraftNativeCapabilityResult) -> Unit,
560
622
  ) {
561
623
  mainHandler.post {
562
- if (fileRequestCancelled(requestToken)) return@post
563
- val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
624
+ if (fileRequestCancelled(requestToken)) { cancelled.remove(requestToken); return@post }
625
+ val host = pickerHost ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
564
626
  if (pendingFilePick != null) {
565
627
  complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
566
628
  return@post
@@ -579,25 +641,20 @@ internal class CraftNativeCapabilities(
579
641
  complete(requestToken, failure("CAPABILITY_DISABLED", "Camera is disabled"), completion)
580
642
  return@post
581
643
  }
582
- pendingFilePick = PendingFilePick(requestToken, source, maxPixels, quality, completion)
644
+ val pending = PendingFilePick(requestToken, source, maxPixels, quality, completion)
583
645
  val intent = if (source == "camera") Intent(MediaStore.ACTION_IMAGE_CAPTURE)
584
646
  else Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
585
647
  addCategory(Intent.CATEGORY_OPENABLE)
586
648
  type = "image/*"
587
649
  }
588
- try {
589
- host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST)
590
- } catch (error: Exception) {
591
- pendingFilePick = null
592
- complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Image picker could not be opened"), completion)
593
- }
650
+ launchFilePick(host, intent, pending)
594
651
  }
595
652
  }
596
653
 
597
654
  private fun pickFile(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
598
655
  mainHandler.post {
599
- if (fileRequestCancelled(requestToken)) return@post
600
- val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
656
+ if (fileRequestCancelled(requestToken)) { cancelled.remove(requestToken); return@post }
657
+ val host = pickerHost ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
601
658
  if (pendingFilePick != null) {
602
659
  complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
603
660
  return@post
@@ -610,7 +667,7 @@ internal class CraftNativeCapabilities(
610
667
  }
611
668
  val selections = options.optInt("maxSelections", 1).coerceIn(1, 20)
612
669
  val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
613
- pendingFilePick = PendingFilePick(
670
+ val pending = PendingFilePick(
614
671
  requestToken,
615
672
  "file",
616
673
  0,
@@ -626,11 +683,7 @@ internal class CraftNativeCapabilities(
626
683
  putExtra(Intent.EXTRA_ALLOW_MULTIPLE, selections > 1)
627
684
  addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
628
685
  }
629
- try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
630
- catch (error: Exception) {
631
- pendingFilePick = null
632
- complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "File picker could not be opened"), completion)
633
- }
686
+ launchFilePick(host, intent, pending)
634
687
  }
635
688
  }
636
689
 
@@ -666,36 +719,39 @@ internal class CraftNativeCapabilities(
666
719
  }
667
720
 
668
721
  fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
669
- if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false
722
+ if (requestCode !in CRAFT_NATIVE_FILE_PICK_FIRST..CRAFT_NATIVE_FILE_PICK_LAST) return false
670
723
  val pending = pendingFilePick ?: return true
724
+ if (pending.requestCode != requestCode) return true
671
725
  pendingFilePick = null
726
+ if (fileRequestCancelled(pending.requestToken)) {
727
+ cancelled.remove(pending.requestToken)
728
+ return true
729
+ }
672
730
  if (resultCode != android.app.Activity.RESULT_OK) {
673
- complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
731
+ complete(pending.requestToken, success(if (pending.source == "file") JSONArray() else JSONObject.NULL), pending.completion)
674
732
  return true
675
733
  }
676
734
  if (pending.source == "video") {
677
- networkExecutor.execute {
678
- val result = data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) }
735
+ importPickedFile(pending) {
736
+ data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) }
679
737
  ?: failure("PICKER_FAILED", "The selected video could not be read")
680
- completeFile(pending.requestToken, result, pending.completion)
681
738
  }
682
739
  return true
683
740
  }
684
741
  if (pending.source == "file") {
685
- networkExecutor.execute {
742
+ importPickedFile(pending) {
686
743
  val uris = buildList {
687
744
  data?.clipData?.let { clips ->
688
745
  for (index in 0 until clips.itemCount) add(clips.getItemAt(index).uri)
689
746
  }
690
747
  data?.data?.let { add(it) }
691
748
  }.distinct().take(pending.selectionLimit)
692
- val result = copyDocuments(pending.requestToken, uris, pending.fileLimit)
693
- completeFile(pending.requestToken, result, pending.completion)
749
+ copyDocuments(pending.requestToken, uris, pending.fileLimit)
694
750
  }
695
751
  return true
696
752
  }
697
- networkExecutor.execute {
698
- val result = runCatching {
753
+ importPickedFile(pending) {
754
+ runCatching {
699
755
  val descriptor = if (pending.source == "camera") {
700
756
  val bitmap = data?.extras?.get("data") as? Bitmap
701
757
  bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg", pending.requestToken) }
@@ -705,11 +761,23 @@ internal class CraftNativeCapabilities(
705
761
  descriptor?.let(::success) ?: if (fileRequestCancelled(pending.requestToken)) failure("CANCELLED", "Image selection was cancelled")
706
762
  else failure("PICKER_FAILED", "The selected image could not be read")
707
763
  }.getOrElse { failure("READ_ERROR", it.message ?: "The selected image could not be read") }
708
- completeFile(pending.requestToken, result, pending.completion)
709
764
  }
710
765
  return true
711
766
  }
712
767
 
768
+ private fun importPickedFile(pending: PendingFilePick, work: () -> CraftNativeCapabilityResult) {
769
+ try {
770
+ networkExecutor.execute {
771
+ val result = if (fileRequestCancelled(pending.requestToken)) failure("CANCELLED", "File selection was cancelled") else work()
772
+ completeFile(pending.requestToken, result, pending.completion)
773
+ }
774
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
775
+ // close() can shut down the executor between result matching and
776
+ // submission. Its closed flag suppresses this abandoned reply.
777
+ complete(pending.requestToken, failure("CANCELLED", "File import host is closed"), pending.completion)
778
+ }
779
+ }
780
+
713
781
  private fun copyUri(requestToken: String, uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
714
782
  if (fileRequestCancelled(requestToken)) return null
715
783
  val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
@@ -4425,6 +4425,7 @@ class MainActivity : AppCompatActivity() {
4425
4425
  }
4426
4426
  }
4427
4427
  capabilities = CraftNativeCapabilities(this, config)
4428
+ capabilities.restorePickerState(savedInstanceState)
4428
4429
  routes = CraftNativeRoutes(config)
4429
4430
  stores = CraftNativeStores.get(this, config)
4430
4431
  services = CraftNativeServices(this, config) { this }
@@ -5233,6 +5234,7 @@ class MainActivity : AppCompatActivity() {
5233
5234
  }
5234
5235
 
5235
5236
  override fun onSaveInstanceState(outState: Bundle) {
5237
+ if (::capabilities.isInitialized) capabilities.savePickerState(outState)
5236
5238
  outState.putString("craft.initialDeepLink", initialDeepLink?.toString())
5237
5239
  outState.putBoolean("craft.initialDeepLinkClaimed", initialDeepLinkClaimed)
5238
5240
  outState.putBoolean("craft.deliveredInitialDeepLink", deliveredInitialDeepLink)
@@ -0,0 +1,6 @@
1
+ /**
2
+ * localStorage is a synchronous UI mirror, not an atomic cross-process store.
3
+ * WebKit can grant a Web Lock before another process's storage cache updates.
4
+ * Keep the acknowledged checkpoint in one IndexedDB read/write transaction.
5
+ */
6
+ export declare function createCheckpointStore(mirror: Storage, namespace: string): <T>(operation: (storage: Storage) => T, signal?: AbortSignal) => Promise<T>;
package/dist/browser.js CHANGED
@@ -62,6 +62,149 @@ function secureUUID() {
62
62
  throw new Error("[Craft] No Web Crypto available; cannot generate secure IDs");
63
63
  }
64
64
 
65
+ // src/browser/checkpoints.ts
66
+ function createCheckpointStore(mirror, namespace) {
67
+ const prefix = `${namespace}:account:`;
68
+ const owned = (key) => {
69
+ if (!key.startsWith(prefix))
70
+ return false;
71
+ const scoped = key.slice(prefix.length);
72
+ const separator = scoped.indexOf(":");
73
+ return separator !== -1 && scoped.slice(separator + 1).startsWith("storage:");
74
+ };
75
+ const mirrorKeys = () => Array.from({ length: mirror.length }, (_, i) => mirror.key(i)).filter((key) => key !== null && owned(key));
76
+ return async (operation, signal) => {
77
+ signal?.throwIfAborted();
78
+ const db = await new Promise((resolve, reject) => {
79
+ const request = indexedDB.open(`${namespace}:checkpoints`, 1);
80
+ let abandoned = false;
81
+ const abort = () => {
82
+ abandoned = true;
83
+ reject(signal?.reason);
84
+ };
85
+ signal?.addEventListener("abort", abort, { once: true });
86
+ request.onupgradeneeded = () => {
87
+ request.result.createObjectStore("checkpoints");
88
+ };
89
+ request.onerror = () => {
90
+ signal?.removeEventListener("abort", abort);
91
+ reject(request.error);
92
+ };
93
+ request.onblocked = () => {
94
+ abandoned = true;
95
+ signal?.removeEventListener("abort", abort);
96
+ reject(new DOMException("Checkpoint database upgrade is blocked", "InvalidStateError"));
97
+ };
98
+ request.onsuccess = () => {
99
+ signal?.removeEventListener("abort", abort);
100
+ if (abandoned || signal?.aborted) {
101
+ request.result.close();
102
+ reject(signal?.reason);
103
+ return;
104
+ }
105
+ resolve(request.result);
106
+ };
107
+ });
108
+ try {
109
+ await new Promise((resolve, reject) => {
110
+ const transaction = db.transaction("checkpoints", "readwrite");
111
+ const store = transaction.objectStore("checkpoints");
112
+ const request = store.get("state");
113
+ let failure;
114
+ request.onsuccess = () => {
115
+ if (request.result !== undefined)
116
+ return;
117
+ try {
118
+ store.put(mirrorKeys().map((key) => [key, mirror.getItem(key)]), "state");
119
+ } catch (error) {
120
+ failure = error;
121
+ transaction.abort();
122
+ }
123
+ };
124
+ transaction.oncomplete = () => resolve();
125
+ transaction.onabort = () => reject(failure ?? transaction.error);
126
+ });
127
+ signal?.throwIfAborted();
128
+ return await new Promise((resolve, reject) => {
129
+ const transaction = db.transaction("checkpoints", "readwrite");
130
+ const store = transaction.objectStore("checkpoints");
131
+ let result;
132
+ let failure;
133
+ const before = new Map;
134
+ const abort = () => {
135
+ failure = signal?.reason;
136
+ try {
137
+ transaction.abort();
138
+ } catch {}
139
+ };
140
+ signal?.addEventListener("abort", abort, { once: true });
141
+ transaction.oncomplete = () => {
142
+ signal?.removeEventListener("abort", abort);
143
+ resolve(result);
144
+ };
145
+ transaction.onabort = () => {
146
+ signal?.removeEventListener("abort", abort);
147
+ for (const [key, value] of before) {
148
+ try {
149
+ if (value === null)
150
+ mirror.removeItem(key);
151
+ else
152
+ mirror.setItem(key, value);
153
+ } catch {}
154
+ }
155
+ reject(failure ?? transaction.error ?? new DOMException("Checkpoint transaction aborted", "AbortError"));
156
+ };
157
+ const request = store.get("state");
158
+ request.onsuccess = () => {
159
+ try {
160
+ signal?.throwIfAborted();
161
+ const values = new Map(request.result);
162
+ let dirty = false;
163
+ const storage = {
164
+ get length() {
165
+ return values.size;
166
+ },
167
+ key: (index) => [...values.keys()][index] ?? null,
168
+ getItem: (key) => values.get(key) ?? null,
169
+ setItem(key, value) {
170
+ values.set(key, String(value));
171
+ dirty = true;
172
+ },
173
+ removeItem(key) {
174
+ dirty = values.delete(key) || dirty;
175
+ },
176
+ clear() {
177
+ dirty = values.size > 0 || dirty;
178
+ values.clear();
179
+ }
180
+ };
181
+ result = operation(storage);
182
+ signal?.throwIfAborted();
183
+ for (const key of new Set([...mirrorKeys(), ...values.keys()])) {
184
+ const previous = mirror.getItem(key);
185
+ const next = values.get(key) ?? null;
186
+ if (previous === next)
187
+ continue;
188
+ before.set(key, previous);
189
+ if (next === null)
190
+ mirror.removeItem(key);
191
+ else
192
+ mirror.setItem(key, next);
193
+ }
194
+ if (dirty)
195
+ store.put([...values], "state");
196
+ } catch (error) {
197
+ failure = error;
198
+ transaction.abort();
199
+ }
200
+ };
201
+ });
202
+ } finally {
203
+ db.close();
204
+ }
205
+ };
206
+ }
207
+
65
208
  // src/browser/storage.ts
66
209
  async function waitForAccount(operation, signal) {
67
210
  if (!signal)
@@ -83,7 +226,13 @@ async function createBrowserStorage(options) {
83
226
  const { storage, namespace, tokenKey, changed } = options;
84
227
  const sessionTokenKey = options.sessionTokenKey || "auth.token";
85
228
  const prefix = `${namespace}:`;
86
- const lock = options.lock === null ? undefined : options.lock ?? (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function" ? (name, operation, settings) => navigator.locks.request(name, settings ?? {}, operation) : undefined);
229
+ const lock = options.lock === null ? undefined : options.lock ?? (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function" ? (name, operation, settings) => navigator.locks.request(name, settings ?? {}, async () => {
230
+ try {
231
+ return await operation();
232
+ } finally {
233
+ await new Promise((resolve) => setTimeout(resolve, 0));
234
+ }
235
+ }) : undefined);
87
236
  const exclusive = (operation, signal) => {
88
237
  const guarded = () => {
89
238
  signal?.throwIfAborted();
@@ -92,6 +241,12 @@ async function createBrowserStorage(options) {
92
241
  signal?.throwIfAborted();
93
242
  return lock ? lock(`${namespace}:storage`, guarded, { signal }) : Promise.resolve().then(guarded);
94
243
  };
244
+ const checkpoints = options.lock === undefined && lock && typeof indexedDB !== "undefined" ? createCheckpointStore(storage, namespace) : null;
245
+ const atomicStorage = !!lock && (!!checkpoints || typeof options.lock === "function");
246
+ const checkpoint = (operation, signal) => checkpoints ? checkpoints(operation, signal) : Promise.resolve().then(() => {
247
+ signal?.throwIfAborted();
248
+ return operation(storage);
249
+ });
95
250
  const scopeChanged = () => Object.assign(new Error("The storage account changed"), { code: "STORAGE_SCOPE_CHANGED" });
96
251
  const pending = new Map;
97
252
  let token = storage.getItem(tokenKey);
@@ -103,7 +258,7 @@ async function createBrowserStorage(options) {
103
258
  const available = () => !transitioning && storage.getItem(tokenKey) === token;
104
259
  const key = (name) => `${prefix}account:${scope}:${name}`;
105
260
  const snapshotKey = (name) => name === "app" ? `${prefix}app` : key(`snapshot:${name}`);
106
- const keys = (base) => Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter((value) => value !== null && value.startsWith(base));
261
+ const keys = (base, source = storage) => Array.from({ length: source.length }, (_, index) => source.key(index)).filter((value) => value !== null && value.startsWith(base));
107
262
  const parse = (value) => {
108
263
  if (value === null)
109
264
  return null;
@@ -137,22 +292,22 @@ async function createBrowserStorage(options) {
137
292
  const captured = capture();
138
293
  const address = key(`storage:${name}`);
139
294
  const json = value === null || value === undefined ? null : encode(value);
140
- const write = () => {
295
+ const write = (source) => {
141
296
  if (json === null)
142
- storage.removeItem(address);
297
+ source.removeItem(address);
143
298
  else
144
- storage.setItem(address, json);
299
+ source.setItem(address, json);
145
300
  };
146
301
  if (!lock) {
147
- write();
302
+ write(storage);
148
303
  return;
149
304
  }
150
305
  const entry = { json };
151
306
  pending.set(address, entry);
152
- exclusive(() => {
307
+ exclusive(() => checkpoint((source) => {
153
308
  requireScope(captured);
154
- write();
155
- }).catch((error) => {
309
+ write(source);
310
+ })).catch((error) => {
156
311
  if (error.code !== "STORAGE_SCOPE_CHANGED")
157
312
  console.error("Browser storage write failed", error);
158
313
  }).finally(() => {
@@ -172,7 +327,12 @@ async function createBrowserStorage(options) {
172
327
  token = next;
173
328
  pending.clear();
174
329
  transition = (async () => {
175
- await exclusive(() => {
330
+ await exclusive(async () => {
331
+ if (checkpoints)
332
+ await checkpoint((source) => {
333
+ for (const name of keys(`${prefix}account:${previous}:`, source))
334
+ source.removeItem(name);
335
+ });
176
336
  for (const name of keys(`${prefix}account:${previous}:`))
177
337
  storage.removeItem(name);
178
338
  for (const name of options.accountKeys ?? []) {
@@ -195,7 +355,7 @@ async function createBrowserStorage(options) {
195
355
  };
196
356
  const secureKey = (name) => name === sessionTokenKey ? tokenKey : key(`credential:${name}`);
197
357
  return {
198
- atomicStorage: lock !== undefined,
358
+ atomicStorage,
199
359
  scope: () => scope,
200
360
  accountChanged,
201
361
  craft: {
@@ -246,9 +406,9 @@ async function createBrowserStorage(options) {
246
406
  let json;
247
407
  let expected;
248
408
  if (wasAvailable && module === "Storage") {
249
- if (method === "set" || method === "setIfAbsent" && lock)
409
+ if (method === "set" || method === "setIfAbsent" && atomicStorage)
250
410
  json = encode(args[1]);
251
- if (method === "compareAndSet" && lock) {
411
+ if (method === "compareAndSet" && atomicStorage) {
252
412
  expected = parse(encode(args[1]));
253
413
  json = encode(args[2]);
254
414
  }
@@ -257,43 +417,43 @@ async function createBrowserStorage(options) {
257
417
  if (module === "Storage") {
258
418
  if (!wasAvailable)
259
419
  throw scopeChanged();
260
- return exclusive(() => {
420
+ return exclusive(() => checkpoint((source) => {
261
421
  requireScope(captured);
262
422
  const address = `${prefix}account:${captured.scope}:storage:${name}`;
263
423
  if (method === "get")
264
- return parse(storage.getItem(address));
424
+ return parse(source.getItem(address));
265
425
  if (method === "set") {
266
- storage.setItem(address, json);
426
+ source.setItem(address, json);
267
427
  return true;
268
428
  }
269
429
  if (method === "remove") {
270
- storage.removeItem(address);
430
+ source.removeItem(address);
271
431
  return true;
272
432
  }
273
433
  if (method === "keys")
274
- return keys(key("storage:")).map((item) => item.slice(key("storage:").length));
434
+ return keys(key("storage:"), source).map((item) => item.slice(key("storage:").length));
275
435
  if (method === "clear") {
276
- for (const item of keys(key("storage:")))
277
- storage.removeItem(item);
436
+ for (const item of keys(key("storage:"), source))
437
+ source.removeItem(item);
278
438
  return true;
279
439
  }
280
440
  if (method === "compareAndSet" || method === "setIfAbsent") {
281
- if (!lock)
282
- throw Object.assign(new Error("Atomic browser storage requires Web Locks"), { code: method === "setIfAbsent" ? "STORAGE_SET_IF_ABSENT_UNSUPPORTED" : "STORAGE_COMPARE_AND_SET_UNSUPPORTED" });
283
- const raw = storage.getItem(address);
441
+ if (!atomicStorage)
442
+ throw Object.assign(new Error("Atomic browser storage requires transactional persistence and Web Locks"), { code: method === "setIfAbsent" ? "STORAGE_SET_IF_ABSENT_UNSUPPORTED" : "STORAGE_COMPARE_AND_SET_UNSUPPORTED" });
443
+ const raw = source.getItem(address);
284
444
  if (method === "setIfAbsent") {
285
445
  if (raw !== null)
286
446
  return false;
287
- storage.setItem(address, json);
447
+ source.setItem(address, json);
288
448
  return true;
289
449
  }
290
450
  if (raw === null || !jsonEqual(parse(raw), expected))
291
451
  return false;
292
- storage.setItem(address, json);
452
+ source.setItem(address, json);
293
453
  return true;
294
454
  }
295
455
  throw Object.assign(new Error(`Unsupported browser storage operation ${method}`), { code: "UNSUPPORTED" });
296
- }, signal);
456
+ }, signal), signal);
297
457
  }
298
458
  if (module === "SecureStorage") {
299
459
  if (!wasAvailable)