@muhkoo/connect 0.15.0-alpha.1 → 0.15.0-alpha.10

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.
@@ -2742,6 +2742,106 @@ class BroadcastChannel {
2742
2742
  }
2743
2743
  }
2744
2744
 
2745
+ /**
2746
+ * `HttpError` — thrown for any non-2xx accelerator response.
2747
+ *
2748
+ * In a module of its own so that `PaymentRequiredError` can extend it while
2749
+ * `HttpClient`, which throws both, imports `PaymentRequiredError`: with the
2750
+ * class in `HttpClient.ts`, the two modules would import each other, and
2751
+ * whichever loaded second would extend a class not yet defined. `HttpClient`
2752
+ * re-exports it, so `import { HttpError } from "./HttpClient"` still works.
2753
+ */
2754
+ /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
2755
+ class HttpError extends Error {
2756
+ status;
2757
+ body;
2758
+ constructor(message, status, body) {
2759
+ super(message);
2760
+ this.status = status;
2761
+ this.body = body;
2762
+ this.name = "HttpError";
2763
+ }
2764
+ }
2765
+
2766
+ /**
2767
+ * `PaymentRequiredError` — a write the accelerator refused for payment
2768
+ * (accelerator spec 2026-10-06-paid-accounts-and-past-due, "Interfaces and
2769
+ * contracts").
2770
+ *
2771
+ * The accelerator answers such a write with a 402 whose body carries one of
2772
+ * three codes:
2773
+ * - `user_plan_required`: the app takes paid Muhkoo plans only, and the
2774
+ * signed-in user is on Free;
2775
+ * - `user_payment_past_due`: the user's own Muhkoo payment is past due. Only
2776
+ * first-party apps are told this; every other app is told
2777
+ * `user_plan_required` instead;
2778
+ * - `owner_payment_past_due`: the app owner's payment is past due, so the
2779
+ * app stores nothing new for anyone.
2780
+ * Reads, sign-in and deletes still work in all three cases.
2781
+ *
2782
+ * Every other 402 — the Free-tier refusals that carry `quotaExceeded`,
2783
+ * `requiresPaidTier` or `freeTierFull` — has no `code`, and stays a plain
2784
+ * `HttpError`, as before.
2785
+ */
2786
+ const CODES = new Set([
2787
+ "user_plan_required",
2788
+ "user_payment_past_due",
2789
+ "owner_payment_past_due",
2790
+ ]);
2791
+ /** True for one of the three {@link PaymentRequiredCode}s. */
2792
+ function isPaymentRequiredCode(value) {
2793
+ return typeof value === "string" && CODES.has(value);
2794
+ }
2795
+ /** `url` when it is an absolute http(s) URL, else undefined: an app may put it straight into a link. */
2796
+ function absoluteHttpUrl(url) {
2797
+ return typeof url === "string" && /^https?:\/\/[^/]/i.test(url) ? url : undefined;
2798
+ }
2799
+ /** Thrown for a 402 whose body carries one of the three {@link PaymentRequiredCode}s. */
2800
+ class PaymentRequiredError extends HttpError {
2801
+ /** Why the write was refused. */
2802
+ code;
2803
+ /**
2804
+ * Where to send the user: the portal's upgrade page or its billing page,
2805
+ * as an absolute URL. Set only when the server sent an absolute http(s)
2806
+ * URL. The relative `upgradeUrl` of the Free-tier refusals is never here:
2807
+ * those are not this error.
2808
+ */
2809
+ upgradeUrl;
2810
+ constructor(message, code, body, upgradeUrl) {
2811
+ super(message, 402, body);
2812
+ this.name = "PaymentRequiredError";
2813
+ this.code = code;
2814
+ if (upgradeUrl !== undefined)
2815
+ this.upgradeUrl = upgradeUrl;
2816
+ }
2817
+ }
2818
+ /**
2819
+ * The error to throw for a refused response: a {@link PaymentRequiredError}
2820
+ * for a 402 whose body carries one of the three codes, and null for anything
2821
+ * else, so the caller throws what it threw before. `body` is the parsed JSON,
2822
+ * or the raw text of the response, which is parsed here.
2823
+ */
2824
+ function paymentRequiredFrom(status, body) {
2825
+ if (status !== 402)
2826
+ return null;
2827
+ let parsed = body;
2828
+ if (typeof parsed === "string") {
2829
+ try {
2830
+ parsed = JSON.parse(parsed);
2831
+ }
2832
+ catch {
2833
+ return null;
2834
+ }
2835
+ }
2836
+ if (!parsed || typeof parsed !== "object")
2837
+ return null;
2838
+ const { code, error, upgradeUrl } = parsed;
2839
+ if (!isPaymentRequiredCode(code))
2840
+ return null;
2841
+ const message = typeof error === "string" && error ? error : `Payment required (${code})`;
2842
+ return new PaymentRequiredError(message, code, parsed, absoluteHttpUrl(upgradeUrl));
2843
+ }
2844
+
2745
2845
  function _loadWasmModule (sync, filepath, src, imports) {
2746
2846
  function _instantiateOrCompile(source, imports, stream) {
2747
2847
  var instantiateFunc = WebAssembly.instantiate;
@@ -3084,29 +3184,30 @@ class Field {
3084
3184
  // Import Poseidon from circomlibjs
3085
3185
  let poseidonModule = null;
3086
3186
  let poseidonF = null;
3187
+ /**
3188
+ * Thrown (internal; match on `name`) when circomlibjs cannot be loaded or built. There is deliberately no
3189
+ * fallback hash: any substitute yields values that differ from real Poseidon,
3190
+ * silently producing wrong commitments and weakened crypto.
3191
+ */
3192
+ class PoseidonUnavailableError extends Error {
3193
+ constructor(cause) {
3194
+ super('Poseidon unavailable: circomlibjs failed to load', { cause });
3195
+ this.name = 'PoseidonUnavailableError';
3196
+ }
3197
+ }
3087
3198
  async function loadPoseidon$1() {
3088
3199
  if (!poseidonModule) {
3089
3200
  try {
3090
- // Try to load circomlibjs poseidon
3091
3201
  const buildPoseidon = (await import('circomlibjs')).buildPoseidon;
3092
3202
  const poseidonBuilder = await buildPoseidon();
3093
3203
  poseidonModule = poseidonBuilder;
3094
3204
  poseidonF = poseidonBuilder.F;
3095
3205
  }
3096
3206
  catch (error) {
3097
- console.warn('Failed to load circomlibjs poseidon, using fallback:', error);
3098
- // Fallback implementation for testing - this is NOT cryptographically secure
3099
- poseidonModule = async (inputs) => {
3100
- const data = inputs.map(i => i.toString()).join('');
3101
- const encoder = new TextEncoder();
3102
- const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data));
3103
- const hashArray = Array.from(new Uint8Array(hashBuffer));
3104
- const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
3105
- return BigInt('0x' + hashHex) % Field.FIELD_SIZE;
3106
- };
3107
- poseidonF = {
3108
- toString: (val) => val.toString()
3109
- };
3207
+ // Leave module state unset so a later call retries.
3208
+ poseidonModule = null;
3209
+ poseidonF = null;
3210
+ throw new PoseidonUnavailableError(error);
3110
3211
  }
3111
3212
  }
3112
3213
  return { poseidon: poseidonModule, F: poseidonF };
@@ -5261,14 +5362,19 @@ function nonceHexToFieldString(nonceHex) {
5261
5362
  */
5262
5363
  async function parseOrThrow$1(response, label) {
5263
5364
  if (!response.ok) {
5264
- let detail = "";
5365
+ let text = "";
5265
5366
  try {
5266
- const text = await response.text();
5267
- detail = text ? `: ${text}` : "";
5367
+ text = await response.text();
5268
5368
  }
5269
5369
  catch {
5270
5370
  // ignore — we'll just use the status code
5271
5371
  }
5372
+ // A put refused for payment: its own class. Every other failure is the
5373
+ // plain Error it always was.
5374
+ const refused = paymentRequiredFrom(response.status, text);
5375
+ if (refused)
5376
+ throw refused;
5377
+ const detail = text ? `: ${text}` : "";
5272
5378
  throw new Error(`PersonalSpaceClient ${label} failed (${response.status})${detail}`);
5273
5379
  }
5274
5380
  return (await response.json());
@@ -5862,6 +5968,34 @@ class ReedSolomonCodec {
5862
5968
  const SHARD_BATCH_WINDOW_MS = 6;
5863
5969
  /** Hashes per batch. Must not exceed what the server will accept. */
5864
5970
  const SHARD_BATCH_MAX = 256;
5971
+ /**
5972
+ * Expected response bytes per batch, which a batch is split to stay under.
5973
+ *
5974
+ * The server refuses a batch above 24 MiB of shard bytes with a 413, and the
5975
+ * reads in a refused batch fail. Counting hashes alone, about 25 one-MiB
5976
+ * shards in one window - a few large files read at once - drew it. This
5977
+ * leaves the server's ceiling well clear.
5978
+ */
5979
+ const SHARD_BATCH_BUDGET_BYTES = 16 * 1024 * 1024;
5980
+ /** An entry's JSON around its base64: the quoted hash, a colon, quotes and a comma, rounded up. */
5981
+ const SHARD_BATCH_ENTRY_BYTES = 128;
5982
+ /**
5983
+ * What a shard adds to a batch's response: base64 is 4 bytes for every 3.
5984
+ *
5985
+ * A shard whose size the caller did not give counts as its entry alone, so
5986
+ * reads without sizes batch by count, as they always have — kept for the
5987
+ * pinned "many concurrent reads → ONE request" test
5988
+ * (`shard-batching.test.ts`). No SDK read path is size-less today:
5989
+ * `FileStorage.readChunk` always passes `shardSize`. The server reads and
5990
+ * base64-encodes every shard before it checks the total
5991
+ * (`ShardsController.ts`), so a very large size-less batch can fail with a
5992
+ * 5xx before the client ever sees a 413. The split in `sendShardBatch` is a
5993
+ * best-effort net for that case, not a guarantee.
5994
+ */
5995
+ function batchCost(size) {
5996
+ const bytes = typeof size === "number" && Number.isFinite(size) && size > 0 ? size : 0;
5997
+ return Math.ceil(bytes / 3) * 4 + SHARD_BATCH_ENTRY_BYTES;
5998
+ }
5865
5999
  /** Decode base64 without `atob`'s per-character loop cost on large shards. */
5866
6000
  function bytesFromBase64(b64) {
5867
6001
  const binary = atob(b64);
@@ -5888,8 +6022,10 @@ class ShardClient {
5888
6022
  baseUrl;
5889
6023
  pathPrefix;
5890
6024
  fetchFn;
6025
+ /** What a PUT goes through: {@link ShardClientOptions.fetchWithRecovery} when given, else {@link fetchFn}. */
6026
+ putFn;
5891
6027
  cache;
5892
- /** Hashes waiting for the next batch, each with everyone awaiting it. */
6028
+ /** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
5893
6029
  pendingShards;
5894
6030
  batchTimer;
5895
6031
  /** False once the server is known not to have the batch route. */
@@ -5912,6 +6048,7 @@ class ShardClient {
5912
6048
  throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
5913
6049
  }
5914
6050
  this.fetchFn = f.bind(globalThis);
6051
+ this.putFn = opts.fetchWithRecovery ?? this.fetchFn;
5915
6052
  this.cache = opts.cache;
5916
6053
  // Coalescing state. `batchSupported` latches false against a deployment
5917
6054
  // without the batch route, so we degrade once rather than per request.
@@ -5955,7 +6092,7 @@ class ShardClient {
5955
6092
  }
5956
6093
  let response;
5957
6094
  try {
5958
- response = await this.fetchFn(url, {
6095
+ response = await this.putFn(url, {
5959
6096
  method: "PUT",
5960
6097
  headers: { "content-type": "application/octet-stream" },
5961
6098
  // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
@@ -5973,7 +6110,10 @@ class ShardClient {
5973
6110
  }
5974
6111
  if (!response.ok) {
5975
6112
  const text = await safeText(response);
5976
- throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6113
+ // Refused for payment: its own class, which the offline queue keeps
6114
+ // and an app can tell from any other failure.
6115
+ throw paymentRequiredFrom(response.status, text)
6116
+ ?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
5977
6117
  }
5978
6118
  let dedup = false;
5979
6119
  try {
@@ -6025,21 +6165,25 @@ class ShardClient {
6025
6165
  /** PUT the shard bytes to origin, verifying the server accepted them.
6026
6166
  * Used by the background-upload path; throws on network/HTTP failure. */
6027
6167
  async uploadToOrigin(url, hash, bytes) {
6028
- const response = await this.fetchFn(url, {
6168
+ const response = await this.putFn(url, {
6029
6169
  method: "PUT",
6030
6170
  headers: { "content-type": "application/octet-stream" },
6031
6171
  body: bytes,
6032
6172
  });
6033
6173
  if (!response.ok) {
6034
6174
  const text = await safeText(response);
6035
- throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6175
+ throw paymentRequiredFrom(response.status, text)
6176
+ ?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6036
6177
  }
6037
6178
  }
6038
6179
  /**
6039
6180
  * Fetch a shard by hash. Throws if the server returns a non-200; returns
6040
6181
  * `null` for 404 so callers can distinguish "missing" from "errored".
6182
+ *
6183
+ * `size`, the shard's byte length when known (a manifest's `shardSize`),
6184
+ * lets concurrent reads be batched within what the server will send.
6041
6185
  */
6042
- async getShard(hash) {
6186
+ async getShard(hash, opts = {}) {
6043
6187
  // Cache-first: a hit is content-verified by construction (key == hash).
6044
6188
  if (this.cache) {
6045
6189
  const cached = await this.cache.get(hash);
@@ -6048,7 +6192,7 @@ class ShardClient {
6048
6192
  }
6049
6193
  // No peers → straight to origin.
6050
6194
  if (!this.peers)
6051
- return this.fetchFromOrigin(hash);
6195
+ return this.fetchFromOrigin(hash, opts.size);
6052
6196
  // HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
6053
6197
  // or missing peer stall delivery — give the peer a short head start, then
6054
6198
  // start origin in parallel and take whichever returns the block first.
@@ -6079,7 +6223,7 @@ class ShardClient {
6079
6223
  clearTimeout(timer);
6080
6224
  timer = null;
6081
6225
  }
6082
- this.fetchFromOrigin(hash).then(win, fail);
6226
+ this.fetchFromOrigin(hash, opts.size).then(win, fail);
6083
6227
  };
6084
6228
  timer = setTimeout(startOrigin, HEDGE_MS);
6085
6229
  peers.getBlock(hash).then((b) => {
@@ -6113,16 +6257,17 @@ class ShardClient {
6113
6257
  * Falls back permanently to one-at-a-time if the server has no batch route,
6114
6258
  * so a new SDK keeps working against an older deployment.
6115
6259
  */
6116
- fetchFromOrigin(hash) {
6260
+ fetchFromOrigin(hash, size) {
6117
6261
  if (!this.batchSupported)
6118
6262
  return this.fetchOneFromOrigin(hash);
6119
6263
  return new Promise((resolve, reject) => {
6120
- const waiting = this.pendingShards.get(hash);
6121
- if (waiting) {
6122
- waiting.push({ resolve, reject });
6264
+ const pending = this.pendingShards.get(hash);
6265
+ if (pending) {
6266
+ pending.waiters.push({ resolve, reject });
6267
+ pending.size ??= size;
6123
6268
  return;
6124
6269
  }
6125
- this.pendingShards.set(hash, [{ resolve, reject }]);
6270
+ this.pendingShards.set(hash, { size, waiters: [{ resolve, reject }] });
6126
6271
  if (this.batchTimer === null) {
6127
6272
  this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
6128
6273
  }
@@ -6133,17 +6278,33 @@ class ShardClient {
6133
6278
  this.batchTimer = null;
6134
6279
  if (this.pendingShards.size === 0)
6135
6280
  return;
6136
- const hashes = [...this.pendingShards.keys()].slice(0, SHARD_BATCH_MAX);
6137
- const waiters = hashes.map((h) => {
6138
- const w = this.pendingShards.get(h);
6281
+ // As many as fit: at most SHARD_BATCH_MAX hashes, and a response
6282
+ // expected to stay within SHARD_BATCH_BUDGET_BYTES. One that does not
6283
+ // fit is skipped for a smaller one behind it. The first always goes,
6284
+ // so a shard over the budget by itself is fetched alone.
6285
+ const waiters = [];
6286
+ let cost = 0;
6287
+ for (const [h, pending] of this.pendingShards) {
6288
+ if (waiters.length === SHARD_BATCH_MAX)
6289
+ break;
6290
+ const next = batchCost(pending.size);
6291
+ if (waiters.length > 0 && cost + next > SHARD_BATCH_BUDGET_BYTES)
6292
+ continue;
6293
+ waiters.push([h, pending.waiters]);
6294
+ cost += next;
6295
+ }
6296
+ for (const [h] of waiters)
6139
6297
  this.pendingShards.delete(h);
6140
- return [h, w];
6141
- });
6142
- // Anything over the cap waits for the next window rather than being
6143
- // dropped or silently truncated.
6298
+ // Anything over the cap or the budget waits for the next window
6299
+ // rather than being dropped or silently truncated.
6144
6300
  if (this.pendingShards.size > 0 && this.batchTimer === null) {
6145
6301
  this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
6146
6302
  }
6303
+ await this.sendShardBatch(waiters);
6304
+ }
6305
+ /** POST one batch and settle everyone waiting on it. Never throws. */
6306
+ async sendShardBatch(waiters) {
6307
+ const hashes = waiters.map(([h]) => h);
6147
6308
  let shards;
6148
6309
  try {
6149
6310
  const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {
@@ -6151,6 +6312,21 @@ class ShardClient {
6151
6312
  headers: { "Content-Type": "application/json" },
6152
6313
  body: JSON.stringify({ hashes }),
6153
6314
  });
6315
+ if (res.status === 413 && waiters.length > 1) {
6316
+ // Too large for the server after all: a size was not given,
6317
+ // or was wrong. Ask for each half on its own. Halving reaches
6318
+ // one hash per batch within log2(n) levels, and one hash the
6319
+ // server refuses is an error like any other, below.
6320
+ // Release the body first: under undici (the CLI, Node), an
6321
+ // unread body holds the connection until garbage collection.
6322
+ await res.body?.cancel().catch(() => { });
6323
+ const half = Math.ceil(waiters.length / 2);
6324
+ await Promise.all([
6325
+ this.sendShardBatch(waiters.slice(0, half)),
6326
+ this.sendShardBatch(waiters.slice(half)),
6327
+ ]);
6328
+ return;
6329
+ }
6154
6330
  if (res.status === 404 || res.status === 405) {
6155
6331
  // No batch route on this deployment. Stop trying, and serve
6156
6332
  // everyone individually — including the batch we just gathered.
@@ -6281,6 +6457,15 @@ async function safeText(response) {
6281
6457
  * manifest and reassembles the plaintext; {@link FileStorage.readFile} first
6282
6458
  * fetches the manifest from the space.
6283
6459
  */
6460
+ /**
6461
+ * How many chunks past the one being read a multi-chunk read fetches ahead
6462
+ * (see `chunksInOrder`). Two: enough to keep requests overlapping on a link
6463
+ * where each one waits on its round trip. While the caller holds chunk k,
6464
+ * up to `READ_AHEAD_CHUNKS + 1` more chunks can be in flight (the next
6465
+ * starts as k is handed over), so at the default 4 MB chunk about 16 MB of
6466
+ * reads in flight per file, on top of the chunk held.
6467
+ */
6468
+ const READ_AHEAD_CHUNKS = 2;
6284
6469
  class FileStorage {
6285
6470
  space;
6286
6471
  shards;
@@ -6386,11 +6571,10 @@ class FileStorage {
6386
6571
  await this.codec.ready(this.rsWasmModule);
6387
6572
  // Materialize each chunk into plaintext, then concatenate. We don't
6388
6573
  // stream across chunks here because the consuming surface returns
6389
- // `Uint8Array`; a streaming variant would reuse `readChunk` unchanged.
6574
+ // `Uint8Array`; `readChunksFromShards` is the streaming variant.
6390
6575
  const plaintextChunks = [];
6391
- for (const chunkManifest of [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex)) {
6392
- plaintextChunks.push(await this.readChunk(chunkManifest));
6393
- }
6576
+ for await (const chunk of this.chunksInOrder(manifest))
6577
+ plaintextChunks.push(chunk);
6394
6578
  let total = 0;
6395
6579
  for (const c of plaintextChunks)
6396
6580
  total += c.length;
@@ -6411,8 +6595,48 @@ class FileStorage {
6411
6595
  */
6412
6596
  async *readChunksFromShards(manifest) {
6413
6597
  await this.codec.ready(this.rsWasmModule);
6414
- for (const chunkManifest of [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex)) {
6415
- yield await this.readChunk(chunkManifest);
6598
+ yield* this.chunksInOrder(manifest);
6599
+ }
6600
+ /**
6601
+ * Each chunk's plaintext, in order, with the next {@link READ_AHEAD_CHUNKS}
6602
+ * already being fetched.
6603
+ *
6604
+ * One chunk at a time, a large file cost a full round trip per 4 MB chunk
6605
+ * with nothing overlapping it: a 33 MB file was eight serial requests,
6606
+ * each mostly waiting. Within a chunk the shard reads were already
6607
+ * parallel; this overlaps the chunks too.
6608
+ *
6609
+ * Bounded on purpose: while the caller holds a yielded chunk, at most
6610
+ * `1 + READ_AHEAD_CHUNKS` more are in flight, and nothing new starts until
6611
+ * it asks for the next — a slow consumer of the stream is not run away
6612
+ * from. A failure surfaces at its own chunk, in order; reads already
6613
+ * started past it are let finish, their failures swallowed rather than
6614
+ * left unhandled. If the caller stops early (`break` or `return()`), no
6615
+ * further read starts and the ones in flight settle on their own; they are
6616
+ * not awaited, so `return()` is not delayed by a slow shard.
6617
+ */
6618
+ async *chunksInOrder(manifest) {
6619
+ const ordered = [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex);
6620
+ const ahead = [];
6621
+ let next = 0;
6622
+ const start = () => {
6623
+ const read = this.readChunk(ordered[next++]);
6624
+ read.catch(() => { }); // reported when its turn comes
6625
+ ahead.push(read);
6626
+ };
6627
+ while (next < ordered.length && ahead.length < 1 + READ_AHEAD_CHUNKS)
6628
+ start();
6629
+ try {
6630
+ while (ahead.length > 0) {
6631
+ const chunk = await ahead.shift();
6632
+ if (next < ordered.length)
6633
+ start();
6634
+ yield chunk;
6635
+ }
6636
+ }
6637
+ finally {
6638
+ next = ordered.length; // nothing more is started
6639
+ ahead.length = 0; // in-flight reads settle unobserved; each has its own .catch
6416
6640
  }
6417
6641
  }
6418
6642
  // -------------------------------------------------------------------------
@@ -6526,7 +6750,8 @@ class FileStorage {
6526
6750
  const fetched = new Array(total).fill(null);
6527
6751
  const fetchInto = async (indexes) => {
6528
6752
  await this.runWithConcurrency(indexes.map((i) => async () => {
6529
- fetched[i] = await this.shards.getShard(manifest.shardHashes[i]);
6753
+ // The size keeps a batch of concurrent reads within what the server will send.
6754
+ fetched[i] = await this.shards.getShard(manifest.shardHashes[i], { size: manifest.shardSize });
6530
6755
  }));
6531
6756
  };
6532
6757
  await fetchInto(Array.from({ length: dataCount }, (_, i) => i));
@@ -6659,6 +6884,10 @@ async function parseOrThrow(response, op) {
6659
6884
  // non-JSON body
6660
6885
  }
6661
6886
  if (!response.ok) {
6887
+ // A manifest write refused for payment (a gated Space write): its own class.
6888
+ const refused = paymentRequiredFrom(response.status, parsed);
6889
+ if (refused)
6890
+ throw refused;
6662
6891
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6663
6892
  ? String(parsed.error)
6664
6893
  : `${response.status} ${response.statusText}`;
@@ -6675,7 +6904,7 @@ async function parseOrThrow(response, op) {
6675
6904
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6676
6905
  * `package.json` `version`.
6677
6906
  */
6678
- const VERSION = "0.15.0-alpha.1";
6907
+ const VERSION = "0.15.0-alpha.10";
6679
6908
 
6680
6909
  /**
6681
6910
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -6720,17 +6949,6 @@ function reqPath(input) {
6720
6949
  return "?";
6721
6950
  }
6722
6951
  }
6723
- /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
6724
- class HttpError extends Error {
6725
- status;
6726
- body;
6727
- constructor(message, status, body) {
6728
- super(message);
6729
- this.status = status;
6730
- this.body = body;
6731
- this.name = "HttpError";
6732
- }
6733
- }
6734
6952
  class HttpClient {
6735
6953
  baseUrl;
6736
6954
  /** Origin of {@link baseUrl} — credentials are only attached same-origin. */
@@ -6859,7 +7077,9 @@ class HttpClient {
6859
7077
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6860
7078
  ? String(parsed.error)
6861
7079
  : `${res.status} ${res.statusText}`;
6862
- throw new HttpError(msg, res.status, parsed);
7080
+ // A refusal for payment is its own class, so an app can tell it from
7081
+ // any other 4xx; every other error is the HttpError it always was.
7082
+ throw paymentRequiredFrom(res.status, parsed) ?? new HttpError(msg, res.status, parsed);
6863
7083
  }
6864
7084
  return parsed;
6865
7085
  }
@@ -8983,6 +9203,45 @@ class StorageCipher {
8983
9203
  }
8984
9204
  }
8985
9205
 
9206
+ /** The server's cap on `prefix`, in code points (accelerator `personalKv/list.ts`). */
9207
+ const MAX_SERVER_PREFIX_CHARS = 1024;
9208
+ /**
9209
+ * The server handed back a cursor it had already given, so following it would
9210
+ * page forever. A fault in the answer, not a failure to reach the server: the
9211
+ * offline layer rethrows it instead of falling back to its cache.
9212
+ */
9213
+ class RepeatedCursorError extends Error {
9214
+ constructor() {
9215
+ super("personal-space list: the server repeated a cursor; stopping instead of looping");
9216
+ this.name = "RepeatedCursorError";
9217
+ }
9218
+ }
9219
+ /** Every key under `prefix` ("" = every key), in the server's order. */
9220
+ async function listPersonalKeys(http, path, prefix) {
9221
+ // A prefix over the server's cap would be refused with a 400. Every list
9222
+ // was whole before the key index, so ask for everything instead; the
9223
+ // caller filters. (`length` first: code points never outnumber units.)
9224
+ const sent = prefix.length > MAX_SERVER_PREFIX_CHARS && [...prefix].length > MAX_SERVER_PREFIX_CHARS ? "" : prefix;
9225
+ const keys = [];
9226
+ const seen = new Set();
9227
+ let cursor = null;
9228
+ for (;;) {
9229
+ const res = await http.post(path, cursor === null ? { prefix: sent } : { prefix: sent, cursor });
9230
+ keys.push(...(res?.keys ?? []));
9231
+ // Annotated: without it TypeScript infers `next` through `cursor` and
9232
+ // the request body above, a loop it reports as TS7022.
9233
+ const next = typeof res?.cursor === "string" && res.cursor !== "" ? res.cursor : null;
9234
+ if (next === null)
9235
+ return keys;
9236
+ // A server (or a cache in front of one) that hands back a cursor it
9237
+ // already gave would have this page forever.
9238
+ if (seen.has(next))
9239
+ throw new RepeatedCursorError();
9240
+ seen.add(next);
9241
+ cursor = next;
9242
+ }
9243
+ }
9244
+
8986
9245
  /**
8987
9246
  * `client.kv` — per-user key/value storage backed by the accelerator's personal
8988
9247
  * space.
@@ -8990,6 +9249,7 @@ class StorageCipher {
8990
9249
  * await client.kv.set('todos', id, { title, completed })
8991
9250
  * const todo = await client.kv.get('todos', id)
8992
9251
  * const ids = await client.kv.list('todos')
9252
+ * const keys = await client.kv.keys('todos/') // full keys, any prefix
8993
9253
  * await client.kv.delete('todos', id)
8994
9254
  * const off = client.kv.on('change', e => …) // realtime, cross-device
8995
9255
  *
@@ -9092,25 +9352,45 @@ class KvNamespace {
9092
9352
  return true; // optimistic
9093
9353
  }
9094
9354
  }
9095
- /** List the ids present in `collection`. */
9355
+ /**
9356
+ * List the ids present in `collection`. The server filters by
9357
+ * `${collection}/` and pages; values are not fetched.
9358
+ */
9096
9359
  async list(collection) {
9097
9360
  const prefix = `${collection}/`;
9361
+ return (await this.keys(prefix)).map((k) => k.slice(prefix.length));
9362
+ }
9363
+ /**
9364
+ * Every key in the signed-in user's personal space that starts with
9365
+ * `prefix` (all of them when it is ""), as full keys: `todos/t1`, not
9366
+ * `t1`. Values are not fetched. The personal space is shared by every
9367
+ * app the user signs into, so expect keys you did not write: the VFS's
9368
+ * `vfs/…`, version control's `vcs/…`, the SDK's own `chat-keys` and
9369
+ * `space-keys/…`.
9370
+ *
9371
+ * The local `startsWith` filter stays on purpose: a server older than
9372
+ * the key index ignores `prefix` and returns every key.
9373
+ */
9374
+ async keys(prefix = "") {
9098
9375
  const offline = this.deps.offline;
9099
- const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
9100
- if (!offline?.enabled) {
9101
- const res = await this.deps.http.post(this.spacePath("/list"), {});
9102
- return fromKeys(res.keys ?? []);
9103
- }
9376
+ const under = (keys) => [...new Set(keys.filter((k) => k.startsWith(prefix)))];
9377
+ const remote = () => listPersonalKeys(this.deps.http, this.spacePath("/list"), prefix);
9378
+ if (!offline?.enabled)
9379
+ return under(await remote());
9104
9380
  try {
9105
- const res = await this.deps.http.post(this.spacePath("/list"), {});
9381
+ const fromServer = await remote();
9106
9382
  // Union with cached live keys so un-synced offline creates still show.
9107
- const cached = await offline.liveKeys(this.commitment());
9108
- return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
9383
+ return under([...fromServer, ...(await offline.liveKeys(this.commitment(), prefix))]);
9109
9384
  }
9110
9385
  catch (err) {
9111
- if (err instanceof HttpError)
9386
+ // A refusal, or a server that repeats its cursor, is an answer,
9387
+ // not an outage: falling back would hand back a partial list as
9388
+ // if it were whole. Only a failure to reach the server (even
9389
+ // mid-paging) falls back to the cache, as a failed list did
9390
+ // before paging.
9391
+ if (err instanceof HttpError || err instanceof RepeatedCursorError)
9112
9392
  throw err;
9113
- return fromKeys(await offline.liveKeys(this.commitment()));
9393
+ return under(await offline.liveKeys(this.commitment(), prefix));
9114
9394
  }
9115
9395
  }
9116
9396
  /** Reserved — server-side query is deferred (see the overhaul plan). */
@@ -9543,6 +9823,7 @@ class StorageNamespace {
9543
9823
  baseUrl: this.deps.baseUrl,
9544
9824
  pathPrefix: "/api/shards",
9545
9825
  fetch: this.deps.http.fetch,
9826
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
9546
9827
  cache: this.deps.shardCache,
9547
9828
  deferUpload: this.deps.deferShardUpload,
9548
9829
  });
@@ -9561,6 +9842,59 @@ class StorageNamespace {
9561
9842
  }
9562
9843
  }
9563
9844
 
9845
+ /**
9846
+ * `POST /api/storage/commit` and `/api/storage/release`: take or drop a
9847
+ * reference to a manifest's shards.
9848
+ *
9849
+ * The commit is what meters a file's bytes to the app and reference-counts
9850
+ * its shards, so garbage collection keeps them; the release drops that
9851
+ * reference. The body is `{ hashes, bytes }` and nothing else. The accelerator
9852
+ * keys its reference ledger on the caller, the app, the bytes and the sorted
9853
+ * hashes, so this body must not change: a commit sent in another shape would
9854
+ * take a second reference instead of finding the first.
9855
+ *
9856
+ * Shared by `Space` (`putFile`, `commitFile`, `releaseFile`) and by
9857
+ * `client.vfs`, which takes and drops references here directly. A reference
9858
+ * names only shards and needs no Space; opening the content space to take one
9859
+ * created that space for a user who had never written a file, which a
9860
+ * paid-only app refuses for a Free user (accelerator spec
9861
+ * 2026-10-06-paid-accounts-and-past-due, D12).
9862
+ *
9863
+ * Internal: not re-exported from any barrel.
9864
+ */
9865
+ /**
9866
+ * Commit or release `manifest`'s shards through `fetchFn`, which should be
9867
+ * `HttpClient.fetchWithRecovery`, so a dead session is re-established once
9868
+ * rather than refused three times. Best-effort: tries three times, or once
9869
+ * for a 401, then warns and returns, without failing the caller's write.
9870
+ */
9871
+ async function commitManifest(fetchFn, httpBaseUrl, manifest, action) {
9872
+ const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
9873
+ if (hashes.length === 0)
9874
+ return;
9875
+ const body = JSON.stringify({ hashes, bytes: manifest.size });
9876
+ for (let attempt = 0; attempt < 3; attempt++) {
9877
+ try {
9878
+ const res = await fetchFn(`${httpBaseUrl}/api/storage/${action}`, {
9879
+ method: "POST", headers: { "Content-Type": "application/json" }, body,
9880
+ });
9881
+ if (res.ok)
9882
+ return;
9883
+ // A 401 that `fetchFn`'s recovery could not fix. Another try
9884
+ // would only run that recovery again (`auth.zk.recover()`, and
9885
+ // `onSessionExpired` for the app) and be refused again.
9886
+ if (res.status === 401)
9887
+ break;
9888
+ }
9889
+ catch { /* retry */ }
9890
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
9891
+ }
9892
+ // Best-effort: a missed commit leaves the shards to orphan-GC (still not
9893
+ // durable-unmetered long-term); a missed release just delays GC. Reconciled
9894
+ // by the platform sweep. Don't fail the caller's write on a metering hiccup.
9895
+ console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
9896
+ }
9897
+
9564
9898
  /**
9565
9899
  * SpaceCipher — pure crypto for the fan-out group-encryption layer.
9566
9900
  *
@@ -10944,25 +11278,10 @@ class Space {
10944
11278
  }
10945
11279
  /** Commit/release a manifest's shards with the accelerator (metering + refcount). */
10946
11280
  async commitShards(manifest, action) {
10947
- const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
10948
- if (hashes.length === 0)
10949
- return;
10950
- const body = JSON.stringify({ hashes, bytes: manifest.size });
10951
- for (let attempt = 0; attempt < 3; attempt++) {
10952
- try {
10953
- const res = await this.deps.fetch(`${this.deps.httpBaseUrl}/api/storage/${action}`, {
10954
- method: "POST", headers: { "Content-Type": "application/json" }, body,
10955
- });
10956
- if (res.ok)
10957
- return;
10958
- }
10959
- catch { /* retry */ }
10960
- await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
10961
- }
10962
- // Best-effort: a missed commit leaves the shards to orphan-GC (still not
10963
- // durable-unmetered long-term); a missed release just delays GC. Reconciled
10964
- // by the platform sweep. Don't fail the caller's write on a metering hiccup.
10965
- console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
11281
+ // Through the client's 401 recovery when there is one: the commit refuses
11282
+ // a dead session with 401, and three tries with the same token only
11283
+ // delayed the warning.
11284
+ await commitManifest(this.deps.fetchWithRecovery ?? this.deps.fetch, this.deps.httpBaseUrl, manifest, action);
10966
11285
  }
10967
11286
  fileStorage(backgroundUpload = false) {
10968
11287
  // Global, content-addressed shard store — same store `client.storage`
@@ -10973,6 +11292,7 @@ class Space {
10973
11292
  baseUrl: this.deps.httpBaseUrl,
10974
11293
  pathPrefix: "/api/shards",
10975
11294
  fetch: this.deps.fetch,
11295
+ fetchWithRecovery: this.deps.fetchWithRecovery,
10976
11296
  cache: this.deps.shardCache,
10977
11297
  deferUpload: this.deps.deferShardUpload,
10978
11298
  peers: this.peerNet?.exchange,
@@ -11376,6 +11696,7 @@ class MessageNamespace {
11376
11696
  wsBaseUrl: this.deps.wsBaseUrl,
11377
11697
  httpBaseUrl: this.deps.http.baseUrl,
11378
11698
  fetch: this.deps.http.fetch,
11699
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
11379
11700
  myId: () => this.myId(),
11380
11701
  fetchTicket: () => this.fetchTicket(),
11381
11702
  });
@@ -13697,6 +14018,7 @@ class SpaceNamespace {
13697
14018
  wsBaseUrl: this.deps.wsBaseUrl,
13698
14019
  httpBaseUrl: this.deps.http.baseUrl,
13699
14020
  fetch: this.deps.http.fetch,
14021
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13700
14022
  myId: () => memberId,
13701
14023
  fetchTicket: () => this.fetchTicket(),
13702
14024
  historyPolicy,
@@ -13752,6 +14074,7 @@ class SpaceNamespace {
13752
14074
  wsBaseUrl: this.deps.wsBaseUrl,
13753
14075
  httpBaseUrl: this.deps.http.baseUrl,
13754
14076
  fetch: this.deps.http.fetch,
14077
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13755
14078
  myId: () => memberId,
13756
14079
  fetchTicket: () => this.fetchTicket(),
13757
14080
  keyring,
@@ -14127,6 +14450,66 @@ class AccessTokensNamespace {
14127
14450
  }
14128
14451
  }
14129
14452
 
14453
+ /**
14454
+ * `client.app` — what the signed-in user may do in this app (accelerator spec
14455
+ * 2026-10-06-paid-accounts-and-past-due, B6 and B8).
14456
+ *
14457
+ * An app's owner can turn on "Paid Muhkoo accounts only" in the portal. The
14458
+ * accelerator then refuses a Free user's writes that add data, with a 402 the
14459
+ * SDK throws as `PaymentRequiredError`. Reads, sign-in and deletes still work.
14460
+ * The same refusal reaches every user of an app whose owner's payment is past
14461
+ * due, whether or not the setting is on.
14462
+ *
14463
+ * `access()` asks up front, so an app can show the right screen before the
14464
+ * first refusal.
14465
+ */
14466
+ class AppNamespace {
14467
+ deps;
14468
+ constructor(deps) {
14469
+ this.deps = deps;
14470
+ }
14471
+ /**
14472
+ * Whether the signed-in user may add data in this app and, if not, why
14473
+ * and where to send them. Needs the app key and a session; a stale session
14474
+ * is re-established once, as for every request. The route is never
14475
+ * refused for payment. Throws `HttpError` for anything else, among them a
14476
+ * 401 without a session and a 404 from an accelerator older than the
14477
+ * route.
14478
+ *
14479
+ * Each `allowed: true` also sends any writes the offline queue kept after
14480
+ * a refusal for payment, without waiting for them. Call it after sign-in,
14481
+ * and again when the user comes back from the portal.
14482
+ */
14483
+ async access() {
14484
+ const body = await this.deps.http.get("/api/app/access");
14485
+ const access = {
14486
+ paidAccountsOnly: body?.paidAccountsOnly === true,
14487
+ // Fail open on a malformed answer: the server enforces the gate
14488
+ // whatever this says, so a wrong "allowed" costs a refused write,
14489
+ // and a wrong "not allowed" would lock the user out for nothing.
14490
+ allowed: body?.allowed !== false,
14491
+ };
14492
+ if (!access.allowed) {
14493
+ if (isPaymentRequiredCode(body?.reason))
14494
+ access.reason = body.reason;
14495
+ const upgradeUrl = absoluteHttpUrl(body?.upgradeUrl);
14496
+ if (upgradeUrl)
14497
+ access.upgradeUrl = upgradeUrl;
14498
+ }
14499
+ else if (this.deps.onAllowed) {
14500
+ // A drain that fails, or a callback that throws, must not turn a
14501
+ // good answer into an error: the writes stay queued for the next
14502
+ // reconnect.
14503
+ Promise.resolve()
14504
+ .then(() => this.deps.onAllowed())
14505
+ .catch((err) => {
14506
+ console.warn("[client.app] sending the kept writes failed (they stay queued):", err);
14507
+ });
14508
+ }
14509
+ return access;
14510
+ }
14511
+ }
14512
+
14130
14513
  /**
14131
14514
  * POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
14132
14515
  * answers a deterministic decoy, so a lookup never says whether an account
@@ -15081,11 +15464,24 @@ function nextTurn() {
15081
15464
  *
15082
15465
  * Every mutation is a read-modify-write of exactly ONE directory record, so a
15083
15466
  * write cannot leave the tree half-updated: either the parent names the new
15084
- * child or it does not. Two tabs writing different directories never conflict;
15085
- * two tabs writing the SAME directory resolve last-writer-wins at the personal
15086
- * space, which carries an HLC per key. Orphaned records (a directory whose
15087
- * parent entry lost the race) are unreachable rather than corrupting — they
15088
- * cost storage until a future sweep, which is the trade this shape makes in
15467
+ * child or it does not. A VFS write sends no HLC and the personal space has
15468
+ * no compare-and-swap: of two writes of one key it keeps whichever arrives
15469
+ * last.
15470
+ *
15471
+ * Within ONE namespace, writes of a record are sent one at a time, in the
15472
+ * order they were made - edits made while one is in flight go out together
15473
+ * in the next (see `queue`) - and a read that has to fetch a record waits for
15474
+ * its write in flight (see `writing`). Every edit of a record is made to one
15475
+ * in-memory copy of it, and that copy is what is written (see `mutate`).
15476
+ *
15477
+ * Between the tabs of an app on one device, a coordinator (`VfsCoordinator`;
15478
+ * `Client` wires one in a browser tab) gives each record one lock, and a tab
15479
+ * reads a record another tab has written again before its next write of it
15480
+ * (see `sendLocked`). Between devices - or a browser and the CLI - two writes
15481
+ * of the same directory are still last-writer-wins, and the other edit is
15482
+ * lost: that takes a compare-and-swap at the server. Orphaned records (a
15483
+ * directory whose parent entry lost the race) are unreachable rather than
15484
+ * corrupting - they cost storage until a sweep, the trade this shape makes in
15089
15485
  * exchange for single-record writes.
15090
15486
  */
15091
15487
  /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
@@ -15290,6 +15686,50 @@ function assertNotHidden(dir, name, path) {
15290
15686
  throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
15291
15687
  }
15292
15688
  }
15689
+ /**
15690
+ * Is `now` still the entry an operation read at a name before it waited on
15691
+ * something? The same kind and id, and for a file the same version; a move
15692
+ * mark set or cleared in between does not count. Absent both times is the same.
15693
+ * Where a decision rests on the mark, `markedMeanwhile` checks that as well.
15694
+ */
15695
+ function sameEntry(before, now) {
15696
+ if (!before || !now)
15697
+ return before === now;
15698
+ if (before.kind !== now.kind || before.id !== now.id)
15699
+ return false;
15700
+ return before.kind === "dir" || (now.kind === "file" && now.manifest.id === before.manifest.id);
15701
+ }
15702
+ /**
15703
+ * Has a move marked the entry at a name since an operation read it there
15704
+ * unmarked? Then a move of it is under way - see `DirEntry.moving` - and it is
15705
+ * about to be named in a second place, which a decision made from the unmarked
15706
+ * read did not allow for.
15707
+ */
15708
+ function markedMeanwhile(before, now) {
15709
+ return now.moving === true && before.moving !== true;
15710
+ }
15711
+ /** Refuse a write because another write changed `path` while this one waited. See `VfsNamespace.mutate`. */
15712
+ function changedMeanwhile(path) {
15713
+ return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
15714
+ }
15715
+ /**
15716
+ * Refuse an operation on `path` because this namespace is moving it - or,
15717
+ * `inside`, something in it. See `VfsNamespace.inMotion`.
15718
+ */
15719
+ function beingMoved(path, inside = false) {
15720
+ const what = inside ? `something in ${path} is` : `${path} is`;
15721
+ return new VfsConflictError(`${what} being moved; nothing was changed - try again once the move has finished`);
15722
+ }
15723
+ /**
15724
+ * Refuse an operation on `path` because a move or a delete of it - or,
15725
+ * `inside`, of it or of something in it - holds its move lock on this
15726
+ * device: in another tab of the app, or in this one. See
15727
+ * `VfsCoordinator.holdMoving`.
15728
+ */
15729
+ function heldElsewhere(path, inside = false) {
15730
+ const what = inside ? `${path}, or something in it, is` : `${path} is`;
15731
+ return new VfsConflictError(`${what} being moved or deleted in this app, in this tab or another; nothing was changed - try again once that has finished`);
15732
+ }
15293
15733
  /** A JSON object, as opposed to null, an array or a scalar. */
15294
15734
  function isObject$1(value) {
15295
15735
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -15447,6 +15887,13 @@ function followable(dir) {
15447
15887
  function unfollowable(dir) {
15448
15888
  return Object.values(dir.hidden).filter((value) => !readableEntry(value)).length;
15449
15889
  }
15890
+ /** The id of every entry `dir` follows, and whether an entry with that id is marked: `SubtreeShape`'s view of it. */
15891
+ function namedIn(dir) {
15892
+ const named = new Map();
15893
+ for (const [, entry] of followable(dir))
15894
+ named.set(entry.id, named.get(entry.id) === true || entry.moving === true);
15895
+ return named;
15896
+ }
15450
15897
  class VfsNamespace {
15451
15898
  deps;
15452
15899
  rootKey = null;
@@ -15456,8 +15903,25 @@ class VfsNamespace {
15456
15903
  * The file tree re-lists constantly while typing, and every miss is a
15457
15904
  * network round trip plus a decrypt. Invalidated precisely on write rather
15458
15905
  * than by TTL — a stale directory listing shows files that are not there.
15906
+ *
15907
+ * What a cached read resolves to is also THE copy of that record: the one
15908
+ * every edit of it is made to, and the one written. See `mutate`.
15459
15909
  */
15460
15910
  cache = new Map();
15911
+ /**
15912
+ * Bumped by `clearCache`, which is for a change of identity.
15913
+ *
15914
+ * A read or write begun before it can finish after it, and what it read
15915
+ * or wrote belongs to whoever was signed in before. So every record
15916
+ * carries the generation it was read under, and none goes into the cache,
15917
+ * or is edited as the cache's copy, under another.
15918
+ */
15919
+ generation = 0;
15920
+ /**
15921
+ * The record each promise in `cache` resolved to, and each write in
15922
+ * `writing` is sending: what `editable` finds without waiting.
15923
+ */
15924
+ copyOf = new WeakMap();
15461
15925
  /**
15462
15926
  * The write of each directory record still in flight, by id.
15463
15927
  *
@@ -15467,9 +15931,86 @@ class VfsNamespace {
15467
15931
  * that is often the one sent first. Then the earlier write, sealed before
15468
15932
  * the later edit was made, undoes it. That is how a file deleted forever
15469
15933
  * from the Trash, while the move that put it there was still being
15470
- * written, came back. So each write waits for the one before it.
15934
+ * written, came back. So each write waits for the one before it, and a
15935
+ * read that has to fetch the record waits for its write too: see
15936
+ * `openDirById`.
15471
15937
  */
15472
15938
  writing = new Map();
15939
+ /**
15940
+ * Every entry this namespace is moving right now, by id.
15941
+ *
15942
+ * A move between two directories marks the entry before it puts it in
15943
+ * the second (see `rename`), and a mark is all a later operation can
15944
+ * see. It cannot tell a move still writing from one that was
15945
+ * interrupted, and was taken for the second: a delete that began after
15946
+ * a move had marked a folder looked for the folder's other half, found
15947
+ * none yet, and purged it while the move was putting it in its new
15948
+ * place; a second move of a file finished the "interrupted" move and
15949
+ * left it named in two places. So a move holds its entry here until it
15950
+ * has taken it from where it was, and `rename` and `delete` refuse an
15951
+ * entry that is here - or, for a delete, a folder holding one. A delete
15952
+ * holds what it deletes the same way: see `deleting`.
15953
+ *
15954
+ * This set is one namespace's. With a coordinator, a move also holds
15955
+ * the entry's move lock for every tab on the device, for as long as the
15956
+ * entry is here: see `VfsCoordinator.holdMoving`.
15957
+ */
15958
+ inMotion = new Set();
15959
+ /**
15960
+ * What each delete under way in this namespace is deleting, by entry id,
15961
+ * one set per delete: its target from the moment it has found the target
15962
+ * not in `inMotion`, and everything it read below the target once it has
15963
+ * read it. Each delete lets go of its own set when it is done.
15964
+ *
15965
+ * A delete waits after it has read what it will let go of: on more
15966
+ * reads, and on a read of the whole tree for the other half of a move.
15967
+ * A move of any of it that started meanwhile could put it in its new
15968
+ * place, fail to take it from the old, and end, all before the delete's
15969
+ * detach. Ended, it was no longer in `inMotion`; and an entry that was
15970
+ * marked already showed no change. So the delete let go of what the
15971
+ * move had just named somewhere else. Now `rename` refuses an entry that
15972
+ * is here, or one on a way from the root that passes through a folder
15973
+ * that is: everything below a delete's target, read yet or not.
15974
+ */
15975
+ deleting = new Set();
15976
+ /**
15977
+ * The write of each record that has not gone out yet, by id: the one
15978
+ * the next edit of that record joins. See `queue`.
15979
+ */
15980
+ waiting = new Map();
15981
+ /**
15982
+ * Announcements heard from other tabs, counted. `heardAt` is the count
15983
+ * at the last one for each record, `missedAt` the count when some may
15984
+ * have been missed. A copy read after both holds every write announced:
15985
+ * see `current`.
15986
+ */
15987
+ hearing = 0;
15988
+ missedAt = 0;
15989
+ heardAt = new Map();
15990
+ /**
15991
+ * Each record a write of which this namespace marked as begun and could
15992
+ * not count, and has said so, until one is counted. And whether it has
15993
+ * said that a write it could not mark or count may be lost, which it
15994
+ * says only once. See `notCounted`.
15995
+ */
15996
+ uncounted = new Set();
15997
+ lossWarned = false;
15998
+ /**
15999
+ * The copy of each record this namespace is writing under the record's
16000
+ * lock, by id, from when it has decided what to write until it has
16001
+ * counted the write, or failed to. Its counter reads as unknown
16002
+ * meanwhile, as this write has begun and is not counted yet, but the
16003
+ * copy holds every write of the record another tab has counted, and no
16004
+ * other tab can make one until this one lets go of the lock. See
16005
+ * `dropStale`.
16006
+ */
16007
+ locked = new Map();
16008
+ /**
16009
+ * Each directory a `createDir` here has named whose write has not landed
16010
+ * yet, by entry id: a promise that settles - and never rejects - once
16011
+ * that write has landed or failed. See `mkdir`.
16012
+ */
16013
+ naming = new Map();
15473
16014
  historyLimit;
15474
16015
  maxEntries;
15475
16016
  maxPathChars;
@@ -15478,6 +16019,8 @@ class VfsNamespace {
15478
16019
  this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
15479
16020
  this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
15480
16021
  this.maxPathChars = deps.limits?.pathChars ?? DEFAULT_MAX_PATH_CHARS;
16022
+ // Another tab's write of a record leaves this tab's copy of it out of date: see `heard`.
16023
+ deps.coordinator?.onAnnounce((id) => this.heard(id));
15481
16024
  }
15482
16025
  /**
15483
16026
  * The working directory relative paths resolve against.
@@ -15526,7 +16069,11 @@ class VfsNamespace {
15526
16069
  * identity's directories to another would be the worst possible bug here.
15527
16070
  */
15528
16071
  clearCache() {
16072
+ // Whatever is still being read or written for the identity before
16073
+ // stays out of the cache from here on: see `generation`.
16074
+ this.generation++;
15529
16075
  this.cache.clear();
16076
+ this.heardAt.clear();
15530
16077
  this.rootKey = null;
15531
16078
  this._cwd = "/"; // a different identity's tree has different directories
15532
16079
  }
@@ -15831,21 +16378,26 @@ class VfsNamespace {
15831
16378
  assertValidName(name);
15832
16379
  await this.mkdir(dirname(target), { recursive: true });
15833
16380
  const parent = await this.openDir(dirname(target));
15834
- const prior = entryIn(parent.node, name);
15835
- if (prior?.kind === "dir")
16381
+ if (entryIn(parent.node, name)?.kind === "dir")
15836
16382
  throw new VfsConflictError(`${target} is a directory`);
15837
16383
  await this.deps.content.retain(manifest);
15838
- const entry = {
15839
- kind: "file",
15840
- id: prior?.id ?? newId(),
15841
- manifest,
15842
- size,
15843
- mtime: Date.now(),
15844
- versions: asCount(prior?.versions),
15845
- // Still half of an interrupted move, if it was: see DirEntry.moving.
15846
- ...(prior?.moving === true ? { moving: true } : {}),
15847
- };
16384
+ let entry;
15848
16385
  await this.mutate(parent, (node) => {
16386
+ // Whatever is at the name now, after the wait above, is what
16387
+ // this replaces - and what it takes the id and history from.
16388
+ const prior = entryIn(node, name);
16389
+ if (prior?.kind === "dir")
16390
+ throw new VfsConflictError(`${target} is a directory`);
16391
+ entry = {
16392
+ kind: "file",
16393
+ id: prior?.id ?? newId(),
16394
+ manifest,
16395
+ size,
16396
+ mtime: Date.now(),
16397
+ versions: asCount(prior?.versions),
16398
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
16399
+ ...(prior?.moving === true ? { moving: true } : {}),
16400
+ };
15849
16401
  node.entries[name] = entry;
15850
16402
  });
15851
16403
  return toStat(target, name, entry);
@@ -15869,8 +16421,22 @@ class VfsNamespace {
15869
16421
  const name = parts[i];
15870
16422
  const isLast = i === parts.length - 1;
15871
16423
  const next = join(current, name);
15872
- const dir = await this.openDir(current);
15873
- const existing = entryIn(dir.node, name);
16424
+ let dir = await this.openDir(current);
16425
+ let existing = entryIn(dir.node, name);
16426
+ // Named by a mkdir here whose write has not landed. Until it has,
16427
+ // the name is a promise, not a fact: with a coordinator, that edit
16428
+ // is made again under the lock to the folder as another tab left
16429
+ // it, and comes to nothing when that tab named a directory there
16430
+ // first - and whatever was saved meanwhile into the one named
16431
+ // here is in a directory nothing names. A failed write drops it
16432
+ // too. So wait for the write, and look again.
16433
+ let unwritten = existing && this.naming.get(existing.id);
16434
+ while (unwritten) {
16435
+ await unwritten;
16436
+ dir = await this.openDir(current);
16437
+ existing = entryIn(dir.node, name);
16438
+ unwritten = existing && this.naming.get(existing.id);
16439
+ }
15874
16440
  if (!existing)
15875
16441
  assertNotHidden(dir, name, next);
15876
16442
  if (existing) {
@@ -15882,7 +16448,7 @@ class VfsNamespace {
15882
16448
  else {
15883
16449
  if (!isLast && !opts.recursive)
15884
16450
  throw new VfsNotFoundError(current);
15885
- await this.createDir(current, name);
16451
+ await this.createDir(current, name, next, opts.recursive === true);
15886
16452
  }
15887
16453
  current = next;
15888
16454
  }
@@ -15942,12 +16508,22 @@ class VfsNamespace {
15942
16508
  let versions = asCount(prior?.versions);
15943
16509
  if (prior)
15944
16510
  versions = await this.pushHistory(parent, name, prior, target, manifest);
15945
- const entry = {
15946
- kind: "file", id, manifest, size, mtime: Date.now(), versions,
15947
- // Still half of an interrupted move, if it was: see DirEntry.moving.
15948
- ...(prior?.moving === true ? { moving: true } : {}),
15949
- };
16511
+ let entry;
15950
16512
  await this.mutate(parent, (node) => {
16513
+ // The version just pushed onto the history was `prior`. If
16514
+ // another write has replaced or removed it since, it is not the
16515
+ // version this one overwrites, and writing on regardless would
16516
+ // lose that write - from the file, and from its history.
16517
+ const now = entryIn(node, name);
16518
+ if (now?.kind === "dir")
16519
+ throw new VfsConflictError(`${target} is a directory`);
16520
+ if (!sameEntry(prior, now))
16521
+ throw changedMeanwhile(target);
16522
+ entry = {
16523
+ kind: "file", id, manifest, size, mtime: Date.now(), versions,
16524
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
16525
+ ...(now?.moving === true ? { moving: true } : {}),
16526
+ };
15951
16527
  node.entries[name] = entry;
15952
16528
  });
15953
16529
  return toStat(target, name, entry);
@@ -15982,83 +16558,223 @@ class VfsNamespace {
15982
16558
  const where = printable(target, 1024);
15983
16559
  if (!opts.recursive)
15984
16560
  throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
15985
- await this.mutate(parent, () => void delete parent.hidden[name]);
16561
+ await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
15986
16562
  this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
15987
16563
  return;
15988
16564
  }
15989
- // Every directory above the target, the root first. None of them is
15990
- // part of what is being deleted, whatever an entry below says.
15991
- const above = new Set(opened.chain);
15992
- // Three steps, in this order: read everything the delete will touch,
15993
- // detach the entry, then let go of what it held.
15994
- //
15995
- // Letting go came first once, and the entry was detached last. Any
15996
- // failure in between - a network error, most simply - left the entry
15997
- // in place with some of its content already released, and the obvious
15998
- // next step, trying again, released that content a SECOND time.
15999
- // Content is reference-counted and shared by copies, so that freed
16000
- // bytes another file still used. Now a failure before the detach has
16001
- // changed nothing, and nothing after it can fail: releasing is
16002
- // best-effort, and a leak is the safe way to be wrong.
16003
- let doomed = [];
16004
- // Whether any of it is half of an interrupted move. See below.
16005
- let moving = entry.kind === "file" && entry.moving === true;
16006
- if (entry.kind === "dir" && above.has(entry.id)) {
16007
- // The entry IS a pointer to a directory above it - the root, or
16008
- // this entry's own parent. Removing the pointer is what was asked
16009
- // for; clearing out what it points at would delete everything
16010
- // around the target, the target included.
16011
- if (!opts.recursive)
16012
- throw new VfsConflictError(`${target} is not empty`);
16013
- this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
16014
- }
16015
- else if (entry.kind === "dir") {
16016
- const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
16017
- // An unreadable directory must still be removable, or the user
16018
- // is stuck with an error they can do nothing about. Its subtree
16019
- // cannot be enumerated, so it is detached rather than purged and
16020
- // whatever it holds is left for sweep(). Only when asked for
16021
- // recursively: without that we cannot know it is empty.
16022
- if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
16023
- throw err;
16024
- this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
16025
- return null;
16026
- });
16027
- if (child) {
16028
- // Entries this version cannot read still count: calling the
16029
- // directory empty would delete them along with it.
16030
- const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
16031
- if (!empty && !opts.recursive)
16565
+ // Being moved by this namespace: it is marked already, or about to be,
16566
+ // and on its way to a second place. Letting go of it here would let
16567
+ // go of what the move puts there. See `inMotion`.
16568
+ if (this.inMotion.has(entry.id))
16569
+ throw beingMoved(target);
16570
+ // And from here until it is done, a move of it, or of anything below
16571
+ // it, that begins in this namespace is refused. See `deleting`.
16572
+ const claim = new Set([entry.id]);
16573
+ this.deleting.add(claim);
16574
+ // On this device, the move locks of the target and of everything read
16575
+ // below it. See below.
16576
+ let release = null;
16577
+ try {
16578
+ // Every directory above the target, the root first. None of them is
16579
+ // part of what is being deleted, whatever an entry below says.
16580
+ const above = new Set(opened.chain);
16581
+ // Three steps, in this order: read everything the delete will touch,
16582
+ // detach the entry, then let go of what it held.
16583
+ //
16584
+ // Letting go came first once, and the entry was detached last. Any
16585
+ // failure in between - a network error, most simply - left the entry
16586
+ // in place with some of its content already released, and the obvious
16587
+ // next step, trying again, released that content a SECOND time.
16588
+ // Content is reference-counted and shared by copies, so that freed
16589
+ // bytes another file still used. Now a failure before the detach has
16590
+ // changed nothing, and nothing after it can fail: releasing is
16591
+ // best-effort, and a leak is the safe way to be wrong.
16592
+ let doomed = [];
16593
+ // What each of them named when it was read, for the check before the detach.
16594
+ let shape = new Map();
16595
+ // Every id found below the target: claimed once it has all been read,
16596
+ // re-checked against `inMotion` at the detach and, with a coordinator,
16597
+ // whose move locks it holds. See below.
16598
+ let ids = new Set();
16599
+ // Whether any of it is half of an interrupted move. See below.
16600
+ let moving = entry.kind === "file" && entry.moving === true;
16601
+ if (entry.kind === "dir" && above.has(entry.id)) {
16602
+ // The entry IS a pointer to a directory above it - the root, or
16603
+ // this entry's own parent. Removing the pointer is what was asked
16604
+ // for; clearing out what it points at would delete everything
16605
+ // around the target, the target included.
16606
+ if (!opts.recursive)
16032
16607
  throw new VfsConflictError(`${target} is not empty`);
16033
- const found = await this.subtree(child, target, above);
16034
- doomed = found.dirs;
16035
- moving = entry.moving === true || found.moving;
16608
+ this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
16609
+ }
16610
+ else if (entry.kind === "dir") {
16611
+ const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
16612
+ // An unreadable directory must still be removable, or the user
16613
+ // is stuck with an error they can do nothing about. Its subtree
16614
+ // cannot be enumerated, so it is detached rather than purged and
16615
+ // whatever it holds is left for sweep(). Only when asked for
16616
+ // recursively: without that we cannot know it is empty.
16617
+ if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
16618
+ throw err;
16619
+ this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
16620
+ return null;
16621
+ });
16622
+ if (child) {
16623
+ // Entries this version cannot read still count: calling the
16624
+ // directory empty would delete them along with it.
16625
+ const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
16626
+ if (!empty && !opts.recursive)
16627
+ throw new VfsConflictError(`${target} is not empty`);
16628
+ const found = await this.subtree(child, target, above);
16629
+ // Something in it is being moved by this namespace: the same
16630
+ // as the target being moved, a level down.
16631
+ for (const id of found.ids)
16632
+ if (this.inMotion.has(id))
16633
+ throw beingMoved(target, true);
16634
+ for (const id of found.ids)
16635
+ claim.add(id);
16636
+ doomed = found.dirs;
16637
+ shape = found.shape;
16638
+ ids = found.ids;
16639
+ moving = entry.moving === true || found.moving;
16640
+ }
16641
+ }
16642
+ // On this device, the move lock of the target and of everything read
16643
+ // below it, held from here until what they held has been let go of.
16644
+ // A move in another tab holds the lock of what it moves until it has
16645
+ // taken it from where it was. A delete that found it marked looked
16646
+ // for its other half before the move had put it there, found none,
16647
+ // and let go of what the move then named in its new place; one that
16648
+ // was writing the parent let a move of something below start, and
16649
+ // then purged it. Held here, a move of any of it that is under way
16650
+ // refuses this delete, and none can start until it is done. What it
16651
+ // does not cover - anything named below since it was read - the check
16652
+ // in the edit refuses. See `VfsCoordinator.holdMoving`.
16653
+ //
16654
+ // Taken before the look for the other half of a move below, not
16655
+ // after it: a move in another tab that began during that look could
16656
+ // put the entry in its new place, fail to take it from the old, and
16657
+ // let go of its lock, all before this took it - and what it named
16658
+ // was let go of here. Not before the reads above, which find what is
16659
+ // below. A move in another tab that begins during them still holds
16660
+ // its lock here if it is under way, and this delete is refused;
16661
+ // ended, it came before the look and the checks below, which read
16662
+ // again what it wrote. See below.
16663
+ const coordinator = this.deps.coordinator;
16664
+ // What the look and the checks below must read again.
16665
+ let stale = new Set();
16666
+ if (coordinator) {
16667
+ release = await this.holdAll(coordinator, [entry.id, ...ids]);
16668
+ if (!release)
16669
+ throw heldElsewhere(target, ids.size > 0);
16670
+ // A move that let go of its lock just before this took it had
16671
+ // counted its writes by then, though this tab may not have heard
16672
+ // of them: what was read above that another tab has written
16673
+ // since is read again - every record cached, for the look, which
16674
+ // reads the whole tree. A move that took a file out of a folder
16675
+ // read above, from this tab's old copy, had that file let go of
16676
+ // with the folder; one that went from the other half of a move
16677
+ // to a folder this tab's old copy did not show it in had the
16678
+ // content it took with it let go of. See `dropStale`.
16679
+ //
16680
+ // And the way to the target, looked up before anything else.
16681
+ // A delete in another tab of a folder on it had let go of the
16682
+ // target's lock, which it held, only once it had counted its
16683
+ // write of the folder above the one it deleted - but it purged
16684
+ // that one, and all below, which counts nothing. Gone on from
16685
+ // the old copies, this wrote the purged parent back and let go
16686
+ // of what the target held a second time, while a copy of it, or
16687
+ // a commit, still named that content. So what on the way has
16688
+ // been written since it was read is read again, and the way is
16689
+ // looked up again, as a move's way to where it goes is: it must
16690
+ // lead to the same entry, through the same folders, not marked
16691
+ // since.
16692
+ //
16693
+ // Looked up again even when nothing on it is out of date now:
16694
+ // a write of this tab's own may have read a folder on it again,
16695
+ // under that folder's lock, after the look above, and counted
16696
+ // its write. That copy is current by now, and no longer what the
16697
+ // look saw.
16698
+ const read = doomed.map((dir) => dir.id);
16699
+ stale = await this.dropStale(coordinator, [...opened.chain, ...(moving ? this.cache.keys() : []), ...read]);
16700
+ const again = await this.openChain(dirname(target));
16701
+ if (!again)
16702
+ throw new VfsNotFoundError(dirname(target));
16703
+ if (again.chain.some((id, i) => id !== opened.chain[i]))
16704
+ throw changedMeanwhile(dirname(target));
16705
+ const now = entryIn(again.dir.node, name);
16706
+ if (!now)
16707
+ throw new VfsNotFoundError(target);
16708
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16709
+ throw changedMeanwhile(target);
16036
16710
  }
16711
+ // A move between two directories that was interrupted leaves what it
16712
+ // moved named in both, and letting go of it here would take it from
16713
+ // the other copy: the directory record purged with every file in it,
16714
+ // or a file's content and history released. Only a marked entry can
16715
+ // be one of those (see `rename`), so only then is the whole tree
16716
+ // read, once, to find out what is still named elsewhere. Still before
16717
+ // anything changes: a failure here leaves the tree as it was.
16718
+ const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16719
+ // The check in the edit below looks at each record to be purged as it
16720
+ // is then, which needs it to hand: a change reported meanwhile may
16721
+ // have dropped one from the cache, and so may the counters above.
16722
+ // Read again, it is still checked against what was read before.
16723
+ for (const dir of doomed)
16724
+ if (stale.has(dir.id) || !this.editable(dir))
16725
+ await this.reopen(dir);
16726
+ let purging = doomed;
16727
+ await this.mutate(parent, (node) => {
16728
+ // What is let go of below is what `entry` held. Gone since, it
16729
+ // was let go of already; replaced, the name holds something this
16730
+ // delete has not read. Marked since, a move of it has begun, and
16731
+ // what it holds is about to be named somewhere `elsewhere` did
16732
+ // not see - if it was looked for at all.
16733
+ const now = entryIn(node, name);
16734
+ if (!now)
16735
+ throw new VfsNotFoundError(target);
16736
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16737
+ throw changedMeanwhile(target);
16738
+ // Marked already when this delete read it, a move that has since
16739
+ // taken the entry - or something below it - would not mark it a
16740
+ // second time, so `markedMeanwhile` and `currentSubtree` would not
16741
+ // see it either. `inMotion` is live, not a snapshot, so it still
16742
+ // does: re-checked here, at the last moment before the detach.
16743
+ // Since `deleting`, a move here cannot begin on any of it once
16744
+ // this delete has claimed it, so these are a second guard.
16745
+ if (this.inMotion.has(entry.id))
16746
+ throw beingMoved(target);
16747
+ for (const id of ids)
16748
+ if (this.inMotion.has(id))
16749
+ throw beingMoved(target, true);
16750
+ // And what is purged is what the records below hold now - unless
16751
+ // something in them has been moved, or begun to move, since they
16752
+ // were read; or, with a coordinator, named there since, as its
16753
+ // move lock is not held. See `currentSubtree`.
16754
+ const current = this.currentSubtree(doomed, shape, coordinator ? ids : undefined);
16755
+ if (!current)
16756
+ throw changedMeanwhile(target);
16757
+ purging = current;
16758
+ delete node.entries[name];
16759
+ });
16760
+ if (elsewhere === null) {
16761
+ // Part of the tree cannot be read, so it is unknown whether the
16762
+ // other copy is in it. Keeping what may be its contents costs
16763
+ // storage until a sweep; letting go of them could lose them.
16764
+ this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
16765
+ return;
16766
+ }
16767
+ if (entry.kind === "dir")
16768
+ await this.purge(purging, elsewhere, opts.keepContent);
16769
+ else
16770
+ await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
16771
+ if (elsewhere && entry.moving === true)
16772
+ await this.settle(entry.id, elsewhere);
16037
16773
  }
16038
- // A move between two directories that was interrupted leaves what it
16039
- // moved named in both, and letting go of it here would take it from
16040
- // the other copy: the directory record purged with every file in it,
16041
- // or a file's content and history released. Only a marked entry can
16042
- // be one of those (see `rename`), so only then is the whole tree
16043
- // read, once, to find out what is still named elsewhere. Still before
16044
- // anything changes: a failure here leaves the tree as it was.
16045
- const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16046
- await this.mutate(parent, (node) => {
16047
- delete node.entries[name];
16048
- });
16049
- if (elsewhere === null) {
16050
- // Part of the tree cannot be read, so it is unknown whether the
16051
- // other copy is in it. Keeping what may be its contents costs
16052
- // storage until a sweep; letting go of them could lose them.
16053
- this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
16054
- return;
16774
+ finally {
16775
+ release?.();
16776
+ this.deleting.delete(claim);
16055
16777
  }
16056
- if (entry.kind === "dir")
16057
- await this.purge(doomed, elsewhere, opts.keepContent);
16058
- else
16059
- await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
16060
- if (elsewhere && entry.moving === true)
16061
- await this.settle(entry.id, elsewhere);
16062
16778
  }
16063
16779
  /**
16064
16780
  * Move or rename. Works across directories, which is the same operation:
@@ -16081,47 +16797,216 @@ class VfsNamespace {
16081
16797
  const srcName = basename(src);
16082
16798
  const dstName = basename(dst);
16083
16799
  assertValidName(dstName);
16084
- const srcParent = await this.openDir(dirname(src));
16800
+ const source = await this.openChain(dirname(src));
16801
+ if (!source)
16802
+ throw new VfsNotFoundError(dirname(src));
16803
+ const srcParent = source.dir;
16085
16804
  const entry = entryIn(srcParent.node, srcName);
16086
16805
  if (!entry)
16087
16806
  throw new VfsNotFoundError(src);
16088
- if (entry.kind === "dir") {
16089
- assertWritableDepth(dst);
16090
- if (segments(dst).length > segments(src).length)
16091
- await this.assertFitsAt(src, dst);
16092
- }
16093
- await this.mkdir(dirname(dst), { recursive: true });
16094
- const dstParent = await this.openDir(dirname(dst));
16095
- if (entryIn(dstParent.node, dstName))
16096
- throw new VfsConflictError(`${dst} already exists`);
16097
- assertNotHidden(dstParent, dstName, dst);
16807
+ // Another move of it is under way here. Its mark cannot tell that
16808
+ // move from an interrupted one, and finishing it as one named the
16809
+ // entry in two places. Held from here, before the first wait, so two
16810
+ // moves begun together cannot both get past this. See `inMotion`.
16811
+ if (this.inMotion.has(entry.id))
16812
+ throw beingMoved(src);
16813
+ // A delete here has begun on it, or on a folder on its way from the
16814
+ // root. Moved now, it could be put in its new place just before the
16815
+ // delete let go of what it holds. Not the root, which no delete is
16816
+ // of: one claims it only through an entry below that names it, and
16817
+ // lets go of nothing of it. In the same step as the check above, as
16818
+ // a delete makes its claim in the same step as its own. See
16819
+ // `deleting`.
16820
+ const lineage = [entry.id, ...source.chain.slice(1)];
16821
+ if ([...this.deleting].some((claim) => lineage.some((id) => claim.has(id))))
16822
+ throw heldElsewhere(src);
16823
+ this.inMotion.add(entry.id);
16098
16824
  // Each record is re-opened before it is written: creating the
16099
- // destination's parents above may have written to the source's.
16825
+ // destination's parents below may have written to the source's.
16100
16826
  const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
16101
- if (dstParent.id === srcParent.id) {
16102
- // One record, so one write: the move happens entirely or not at all.
16827
+ let unmarkLanded = false;
16828
+ // The same for every tab of the app on this device: the entry's move
16829
+ // lock, held for as long as it is in `inMotion`. Another tab's move
16830
+ // or delete of it holds it, and taking that move's mark for an
16831
+ // interrupted one named the entry in two places, or purged it. See
16832
+ // `VfsCoordinator.holdMoving`.
16833
+ let release = null;
16834
+ // And the locks of the folders on the way to where it goes, shared,
16835
+ // for as long. See below.
16836
+ let into = null;
16837
+ try {
16838
+ const coordinator = this.deps.coordinator;
16839
+ if (coordinator) {
16840
+ release = await coordinator.holdMoving(entry.id);
16841
+ if (!release)
16842
+ throw heldElsewhere(src);
16843
+ // The look above was at this tab's copies of the folders on the
16844
+ // way to the entry. A delete in another tab of the entry, or of
16845
+ // a folder on that way, may have finished before this took the
16846
+ // lock, which the delete held: it had counted its writes by
16847
+ // then, though this tab may not have heard of them. Moved
16848
+ // regardless, the entry was marked in a record the delete had
16849
+ // purged, and named in its new place with its content let go
16850
+ // of. So whatever on the way another tab has written since it
16851
+ // was read is read again, and the entry looked for again.
16852
+ if ((await this.dropStale(coordinator, source.chain)).size) {
16853
+ const again = await this.openChain(dirname(src));
16854
+ if (!again)
16855
+ throw new VfsNotFoundError(dirname(src));
16856
+ if (again.chain.some((id, i) => id !== source.chain[i]))
16857
+ throw changedMeanwhile(dirname(src));
16858
+ const now = entryIn(again.dir.node, srcName);
16859
+ if (!now)
16860
+ throw new VfsNotFoundError(src);
16861
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16862
+ throw changedMeanwhile(src);
16863
+ }
16864
+ }
16865
+ if (entry.kind === "dir") {
16866
+ assertWritableDepth(dst);
16867
+ if (segments(dst).length > segments(src).length)
16868
+ await this.assertFitsAt(src, dst);
16869
+ }
16870
+ await this.mkdir(dirname(dst), { recursive: true });
16871
+ const way = await this.openChain(dirname(dst));
16872
+ if (!way)
16873
+ throw new VfsNotFoundError(dirname(dst));
16874
+ let dstParent = way.dir;
16875
+ if (coordinator && dstParent.id !== ROOT_ID) {
16876
+ // The folder it goes into, and every folder above that one
16877
+ // but the root, held shared: other moves into any of them
16878
+ // share them, and a delete of one - which holds the lock of
16879
+ // its target and of everything it read below - holds it
16880
+ // alone. A move into a folder that another tab was deleting
16881
+ // put what it moved in a record the delete then purged: in no
16882
+ // listing, its content kept. Not that folder alone: it may be
16883
+ // new since the delete read the tree - made by the `mkdir`
16884
+ // above, or by another tab - and the delete does not hold a
16885
+ // lock it did not read. Its target's is on the way here. The
16886
+ // root is never deleted.
16887
+ into = await this.holdAll(coordinator, way.chain.slice(1), true);
16888
+ if (!into)
16889
+ throw heldElsewhere(dirname(dst));
16890
+ // A delete of one of them may have finished since they were
16891
+ // looked up, and counted its write of the folder above before
16892
+ // it let go of their locks, though this tab may not have
16893
+ // heard of it: read again whatever on the way another tab has
16894
+ // written since it was read, and look again. Gone, or another
16895
+ // folder anywhere on the way, the move stops here, before its
16896
+ // mark.
16897
+ await this.dropStale(coordinator, way.chain);
16898
+ const again = await this.openChain(dirname(dst));
16899
+ if (!again)
16900
+ throw new VfsNotFoundError(dirname(dst));
16901
+ if (again.chain.some((id, i) => id !== way.chain[i]))
16902
+ throw changedMeanwhile(dirname(dst));
16903
+ dstParent = again.dir;
16904
+ }
16905
+ if (entryIn(dstParent.node, dstName))
16906
+ throw new VfsConflictError(`${dst} already exists`);
16907
+ assertNotHidden(dstParent, dstName, dst);
16908
+ // Both ends were looked at before the waits above, and another write
16909
+ // may have changed either since: replaced the entry being moved, or
16910
+ // put something at the destination. Moving on regardless would write
16911
+ // the old entry over the new one, or this one over the other. Or
16912
+ // marked the entry: another move of it has begun. Taking it as well
16913
+ // would name it in two places, and each move would unmark its own.
16914
+ const take = (node) => {
16915
+ const now = entryIn(node, srcName);
16916
+ if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
16917
+ throw changedMeanwhile(src);
16918
+ return now;
16919
+ };
16920
+ const free = (node, record) => {
16921
+ if (entryIn(node, dstName))
16922
+ throw new VfsConflictError(`${dst} already exists`);
16923
+ assertNotHidden(record, dstName, dst);
16924
+ };
16925
+ if (dstParent.id === srcParent.id) {
16926
+ // One record, so one write: the move happens entirely or not at all.
16927
+ await edit(dirname(src), (node, record) => {
16928
+ const moved = take(node);
16929
+ free(node, record);
16930
+ delete node.entries[srcName];
16931
+ node.entries[dstName] = { ...moved, mtime: Date.now() };
16932
+ });
16933
+ return;
16934
+ }
16935
+ // Two records, attached before detaching: interrupted, the entry is
16936
+ // in both rather than in neither. Both copies are marked for as long
16937
+ // as that can be so, because they share everything below them, and a
16938
+ // delete of either would otherwise let go of the other's contents.
16939
+ // An entry already marked is half of an earlier interrupted move, and
16940
+ // keeps its mark.
16941
+ const marked = entry.moving === true;
16942
+ let moved = entry;
16943
+ if (!marked) {
16944
+ await edit(dirname(src), (node) => {
16945
+ moved = take(node);
16946
+ node.entries[srcName] = { ...moved, moving: true };
16947
+ });
16948
+ }
16949
+ // Whether the edit below has put the entry at the destination: set
16950
+ // as its last step, after every check, each time it runs.
16951
+ let attached = false;
16952
+ try {
16953
+ await edit(dirname(dst), (node, record) => {
16954
+ attached = false;
16955
+ free(node, record);
16956
+ node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
16957
+ attached = true;
16958
+ });
16959
+ }
16960
+ catch (err) {
16961
+ // Refused before it put anything at the destination - another
16962
+ // write took the name first - so the entry is still in one
16963
+ // place only, and the mark this move put on it would stay for
16964
+ // good: on a file, every version that falls off its history
16965
+ // kept; on either, every delete of it reading the whole tree.
16966
+ // Not when the write failed instead: the destination may hold
16967
+ // the entry by now, and the mark is what keeps a delete of
16968
+ // either copy from letting go of the other's. Best-effort.
16969
+ if (!marked && !attached) {
16970
+ await edit(dirname(src), (node) => {
16971
+ const now = entryIn(node, srcName);
16972
+ if (now?.id !== entry.id || now.moving !== true)
16973
+ return false;
16974
+ node.entries[srcName] = unmarked(now);
16975
+ return true;
16976
+ }).catch(() => { });
16977
+ }
16978
+ throw err;
16979
+ }
16980
+ // A file's history is sealed with its directory's key, and from here
16981
+ // on the directory that opens it is this one.
16982
+ if (entry.kind === "file")
16983
+ await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16103
16984
  await edit(dirname(src), (node) => {
16985
+ // Gone already leaves nothing to detach, and nothing to write.
16986
+ // Replaced, it is not this entry to take away: the move stops
16987
+ // here, in both places. Not `take`: the entry is marked by now,
16988
+ // by this move or the earlier one it finishes.
16989
+ const now = entryIn(node, srcName);
16990
+ if (!now)
16991
+ return false;
16992
+ if (!sameEntry(entry, now))
16993
+ throw changedMeanwhile(src);
16104
16994
  delete node.entries[srcName];
16105
- node.entries[dstName] = { ...entry, mtime: Date.now() };
16995
+ // Explicit, as in `createDir` and `settle`: noImplicitReturns.
16996
+ return true;
16106
16997
  });
16107
- return;
16998
+ unmarkLanded = !marked;
16108
16999
  }
16109
- // Two records, attached before detaching: interrupted, the entry is
16110
- // in both rather than in neither. Both copies are marked for as long
16111
- // as that can be so, because they share everything below them, and a
16112
- // delete of either would otherwise let go of the other's contents.
16113
- // An entry already marked is half of an earlier interrupted move, and
16114
- // keeps its mark.
16115
- const marked = entry.moving === true;
16116
- if (!marked)
16117
- await edit(dirname(src), (node) => void (node.entries[srcName] = { ...entry, moving: true }));
16118
- await edit(dirname(dst), (node) => void (node.entries[dstName] = { ...entry, moving: true, mtime: Date.now() }));
16119
- // A file's history is sealed with its directory's key, and from here
16120
- // on the directory that opens it is this one.
16121
- if (entry.kind === "file")
16122
- await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16123
- await edit(dirname(src), (node) => void delete node.entries[srcName]);
16124
- if (!marked) {
17000
+ finally {
17001
+ // Named in one place from here, the destination. A delete of it
17002
+ // there goes ahead as for an entry an interrupted move left marked
17003
+ // - which the Trash's "delete forever" relies on while the mark is
17004
+ // still being cleared.
17005
+ this.inMotion.delete(entry.id);
17006
+ release?.();
17007
+ into?.();
17008
+ }
17009
+ if (unmarkLanded) {
16125
17010
  await edit(dirname(dst), (node) => {
16126
17011
  const landed = entryIn(node, dstName);
16127
17012
  if (landed?.id === entry.id)
@@ -16193,13 +17078,19 @@ class VfsNamespace {
16193
17078
  // its first write, instead of after up to a whole budget's worth of
16194
17079
  // records and references, all left unreachable. The records are
16195
17080
  // cached by then, so the real pass costs writes, not fetches.
17081
+ const gen = this.generation;
16196
17082
  const run = (write) => ({
16197
- from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
17083
+ from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0, gen,
16198
17084
  });
16199
17085
  const depth = segments(dst).length;
16200
17086
  await this.cloneEntry(found.entry, src, run(false), null, depth);
16201
17087
  const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
16202
- await this.mutate(dstParent, (node) => {
17088
+ await this.mutate(dstParent, (node, record) => {
17089
+ // Looked at before the copy was made, which takes as long as the
17090
+ // tree is big: another write may have taken the name since.
17091
+ if (entryIn(node, dstName))
17092
+ throw new VfsConflictError(`${dst} already exists`);
17093
+ assertNotHidden(record, dstName, dst);
16203
17094
  node.entries[dstName] = copied;
16204
17095
  });
16205
17096
  return toStat(dst, dstName, copied);
@@ -16220,8 +17111,12 @@ class VfsNamespace {
16220
17111
  * Delete metadata records no longer reachable from the root.
16221
17112
  *
16222
17113
  * These accumulate because every write touches exactly one record: if two
16223
- * tabs edit the same directory, last-writer-wins at the personal space can
16224
- * leave a child whose parent entry lost the race. Unreachable is harmless —
17114
+ * devices edit the same directory - or two tabs, where there is no
17115
+ * coordinator - the personal space keeps whichever put arrives last (a
17116
+ * VFS write sends no HLC, and the space applies puts of one key in arrival
17117
+ * order), which can leave a child whose parent entry lost the race. The
17118
+ * per-record queue and the coordinator order writes within one device
17119
+ * only. Unreachable is harmless —
16225
17120
  * it is invisible and undecryptable-by-nobody — but it costs storage, so
16226
17121
  * this reclaims it.
16227
17122
  *
@@ -16230,6 +17125,12 @@ class VfsNamespace {
16230
17125
  * Run it when the filesystem is idle.
16231
17126
  */
16232
17127
  async sweep(opts = {}) {
17128
+ // With a coordinator: what another tab has written since this tab
17129
+ // read it is read again, not followed in an old copy that may not
17130
+ // name what it now names - which would then be deleted here.
17131
+ const coordinator = this.deps.coordinator;
17132
+ if (coordinator)
17133
+ await this.dropStale(coordinator, [...this.cache.keys()]);
16233
17134
  const root = await this.root();
16234
17135
  // A record that cannot be read throws out of here, before anything is
16235
17136
  // deleted: with part of the tree unknown, "unreachable" is a guess.
@@ -16254,8 +17155,10 @@ class VfsNamespace {
16254
17155
  }
16255
17156
  // Only ever consider OUR records: the personal space is shared with
16256
17157
  // chat keys, space keys and the legacy file mirror, and a sweep that
16257
- // reached beyond its own namespace would be a data-loss bug.
16258
- const mine = (await this.deps.store.list()).filter((k) => /^vfs\/[dh]\//.test(k));
17158
+ // reached beyond its own namespace would be a data-loss bug. The store
17159
+ // is asked for `vfs/` only; the regex stays, because `vfs/space` is
17160
+ // under `vfs/` too and a store may ignore the prefix.
17161
+ const mine = (await this.deps.store.list("vfs/")).filter((k) => /^vfs\/[dh]\//.test(k));
16259
17162
  const orphans = mine.filter((k) => !reachable.has(k));
16260
17163
  // A root that read as EMPTY while orphans exist is the shape of a failed
16261
17164
  // load, not of a fresh filesystem — deleting here would wipe a tree we
@@ -16295,16 +17198,21 @@ class VfsNamespace {
16295
17198
  if (!version)
16296
17199
  throw new VfsNotFoundError(`${target} version ${index}`);
16297
17200
  const versions = await this.pushHistory(parent, name, entry, target, version.manifest);
16298
- const restored = {
16299
- kind: "file",
16300
- id: entry.id,
16301
- manifest: version.manifest,
16302
- size: asCount(version.size),
16303
- mtime: Date.now(),
16304
- versions,
16305
- ...(entry.moving === true ? { moving: true } : {}),
16306
- };
17201
+ let restored;
16307
17202
  await this.mutate(parent, (node) => {
17203
+ // As for writeFile: the version pushed onto the history was `entry`.
17204
+ const now = entryIn(node, name);
17205
+ if (!sameEntry(entry, now))
17206
+ throw changedMeanwhile(target);
17207
+ restored = {
17208
+ kind: "file",
17209
+ id: entry.id,
17210
+ manifest: version.manifest,
17211
+ size: asCount(version.size),
17212
+ mtime: Date.now(),
17213
+ versions,
17214
+ ...(now?.moving === true ? { moving: true } : {}),
17215
+ };
16308
17216
  node.entries[name] = restored;
16309
17217
  });
16310
17218
  return toStat(target, name, restored);
@@ -16334,8 +17242,15 @@ class VfsNamespace {
16334
17242
  const seed = this.deps.seed();
16335
17243
  if (!seed)
16336
17244
  throw new VfsLockedError();
16337
- if (!this.rootKey)
16338
- this.rootKey = await deriveRootKey(seed);
17245
+ if (!this.rootKey) {
17246
+ const gen = this.generation;
17247
+ const key = await deriveRootKey(seed);
17248
+ // Derived from the seed of whoever was signed in before a
17249
+ // clearCache: start again, with the seed of whoever is now.
17250
+ if (gen !== this.generation)
17251
+ return this.root();
17252
+ this.rootKey = key;
17253
+ }
16339
17254
  return this.openDirById(ROOT_ID, this.rootKey, "/");
16340
17255
  }
16341
17256
  /** Open a directory by path, creating nothing. Throws if it is missing. */
@@ -16372,6 +17287,11 @@ class VfsNamespace {
16372
17287
  const hit = this.cache.get(id);
16373
17288
  if (hit)
16374
17289
  return hit;
17290
+ // Before anything is read, so that from here on this namespace hears
17291
+ // of any later write of the record by another tab. Starting to hear -
17292
+ // for the first time, or for someone else - clears the cache (see
17293
+ // `heard`), which is why it comes before this read is cached.
17294
+ this.deps.coordinator?.listen();
16375
17295
  // The PROMISE goes in the cache, not the resolved value.
16376
17296
  //
16377
17297
  // Reading a project means resolving hundreds of paths that share the
@@ -16379,50 +17299,116 @@ class VfsNamespace {
16379
17299
  // every concurrent reader to miss and fire its own request for the same
16380
17300
  // record — which is how loading one project turned into thousands of
16381
17301
  // identical `vfs/d/<id>` fetches, most of them in flight at once.
16382
- const pending = (async () => {
16383
- const raw = await this.deps.store.get(dirKey(id));
16384
- // An absent record is an EMPTY directory, not an error: the root
16385
- // exists before anything has been written to it, and a directory is
16386
- // created by its parent naming it rather than by its own record
16387
- // appearing.
16388
- if (raw === null || raw === undefined) {
16389
- const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
16390
- return { id, key, path, node, hidden: Object.create(null) };
16391
- }
16392
- // Present but unopenable is NOT absent. `unseal` folds the two
16393
- // together, which suits a record that can simply be rebuilt; a
16394
- // directory cannot, because rebuilding it means sealing an empty
16395
- // one over whatever is really there. And it is not a raw
16396
- // DOMException either: the typed error is what delete, purge and
16397
- // sweep know how to step around, and it names the record.
16398
- let value;
16399
- try {
16400
- value = await openSealed(key, raw);
17302
+ const gen = this.generation;
17303
+ // Reads `pending` only after its first `await`: before that, the const is not yet initialised.
17304
+ const load = async () => {
17305
+ // Not while a write of this record is still in flight: see
17306
+ // `writing`. Our own writes echo back over the personal-space
17307
+ // socket, and `watch` drops the record each one names, so a read
17308
+ // can miss with the write still on its way - and the store would
17309
+ // answer with the copy from BEFORE it. An edit made to that copy
17310
+ // is queued behind the write, and then stored over it. So wait
17311
+ // for the latest write queued, which waits on every one before it,
17312
+ // to land or fail - and then for any queued while this read
17313
+ // waited, since the one it waited for may have failed with that
17314
+ // one still on its way. A record with no write in flight is
17315
+ // fetched at once.
17316
+ let awaited;
17317
+ for (let inFlight = this.writing.get(id); inFlight && inFlight !== awaited; inFlight = this.writing.get(id)) {
17318
+ awaited = inFlight;
17319
+ await inFlight.catch(() => { });
16401
17320
  }
16402
- catch (err) {
16403
- if (err instanceof SealedRecordError)
16404
- throw new VfsCorruptRecordError(id, path, err.message);
16405
- throw err;
17321
+ // Not after a clearCache, though: the cache now is the next
17322
+ // identity's, and this read only answers whoever asked for it.
17323
+ if (awaited && gen === this.generation) {
17324
+ // A write that landed put the record it wrote back in the
17325
+ // cache, and the next edit of this directory is made to that.
17326
+ // Answer with it rather than fetch a second copy: two copies
17327
+ // of one record, each edited and written, undo each other's
17328
+ // edits. When the cache holds nothing else - the write failed,
17329
+ // or a change since has dropped the record - ask the store.
17330
+ const now = this.cache.get(id);
17331
+ if (now && now !== pending)
17332
+ return now;
17333
+ // Empty, when the write failed: it dropped what the cache held,
17334
+ // and after an echo that was this very read. Put it back before
17335
+ // asking the store, so a read that comes in meanwhile shares
17336
+ // this copy instead of fetching a second one.
17337
+ if (!now)
17338
+ this.cache.set(id, pending);
16406
17339
  }
16407
- return this.readRecord(id, key, value, path);
16408
- })().catch((err) => {
17340
+ return this.fetchRecord(id, key, gen, path);
17341
+ };
17342
+ const pending = load().catch((err) => {
16409
17343
  // A failed read must not be remembered, or one blip poisons the
16410
17344
  // directory for the life of the session.
16411
17345
  if (this.cache.get(id) === pending)
16412
17346
  this.cache.delete(id);
16413
17347
  throw err;
16414
17348
  });
17349
+ // Registered before anyone else can wait on it, so it is known by
17350
+ // the time any of them goes on to edit the record: see `editable`.
17351
+ pending.then((dir) => void this.copyOf.set(pending, dir), () => { });
16415
17352
  this.cache.set(id, pending);
16416
17353
  return pending;
16417
17354
  }
16418
17355
  /**
16419
- * Turn a decrypted record into an OpenDir, or throw.
16420
- *
16421
- * The record is THROWN on, never read as empty, when its shape is wrong -
16422
- * see VfsCorruptRecordError. An individual entry of the wrong shape is set
17356
+ * Fetch record `id` from the store and open it. Whatever another tab
17357
+ * announced before the fetch began is in what it returns (see
17358
+ * `current`), and so is every write its counter counts: read here, or
17359
+ * given as `counter` (see `OpenDir.counter`).
17360
+ */
17361
+ async fetchRecord(id, key, gen, path, counter) {
17362
+ const seen = this.hearing;
17363
+ // Before the fetch, never after it. A write that lands in between
17364
+ // then only makes this copy look out of date, which costs a read;
17365
+ // read after, the counter could count a write the answer missed.
17366
+ // Without a coordinator there is none, and nothing is awaited: a read
17367
+ // in Node takes the turns it always took.
17368
+ if (counter === undefined)
17369
+ counter = this.deps.coordinator ? await this.counterOf(id) : null;
17370
+ const raw = await this.deps.store.get(dirKey(id));
17371
+ // An absent record is an EMPTY directory, not an error: the root
17372
+ // exists before anything has been written to it, and a directory is
17373
+ // created by its parent naming it rather than by its own record
17374
+ // appearing.
17375
+ if (raw === null || raw === undefined) {
17376
+ const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
17377
+ return { id, key, path, node, hidden: Object.create(null), gen, seen, counter };
17378
+ }
17379
+ // Present but unopenable is NOT absent. `unseal` folds the two
17380
+ // together, which suits a record that can simply be rebuilt; a
17381
+ // directory cannot, because rebuilding it means sealing an empty
17382
+ // one over whatever is really there. And it is not a raw
17383
+ // DOMException either: the typed error is what delete, purge and
17384
+ // sweep know how to step around, and it names the record.
17385
+ let value;
17386
+ try {
17387
+ value = await openSealed(key, raw);
17388
+ }
17389
+ catch (err) {
17390
+ if (err instanceof SealedRecordError)
17391
+ throw new VfsCorruptRecordError(id, path, err.message);
17392
+ throw err;
17393
+ }
17394
+ return this.readRecord(id, key, value, gen, seen, counter, path);
17395
+ }
17396
+ /** Record `id`'s write counter now: see `OpenDir.counter`. */
17397
+ async counterOf(id) {
17398
+ const coordinator = this.deps.coordinator;
17399
+ if (!coordinator)
17400
+ return null;
17401
+ const [counter] = await coordinator.counters([id]).catch(() => [null]);
17402
+ return counter ?? null;
17403
+ }
17404
+ /**
17405
+ * Turn a decrypted record into an OpenDir, or throw.
17406
+ *
17407
+ * The record is THROWN on, never read as empty, when its shape is wrong -
17408
+ * see VfsCorruptRecordError. An individual entry of the wrong shape is set
16423
17409
  * aside in `hidden` instead: out of every read, but kept for the write.
16424
17410
  */
16425
- readRecord(id, key, value, path) {
17411
+ readRecord(id, key, value, gen, seen, counter, path) {
16426
17412
  const problem = recordProblem(value);
16427
17413
  if (problem)
16428
17414
  throw new VfsCorruptRecordError(id, path, problem);
@@ -16478,25 +17464,46 @@ class VfsNamespace {
16478
17464
  this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
16479
17465
  }
16480
17466
  // A v1 record's unknown top-level fields survive a write too.
16481
- return { id, key, path, node: { ...top, entries }, hidden };
17467
+ return { id, key, path, node: { ...top, entries }, hidden, gen, seen, counter };
16482
17468
  }
16483
- /** Apply an edit to a directory record and persist it. */
17469
+ /**
17470
+ * Apply an edit to a directory record and persist it.
17471
+ *
17472
+ * `dir` says which record - its id, key and path - and nothing more. The
17473
+ * edit is made to THE copy of that record (see `editable`), which `dir`
17474
+ * may no longer be, and that copy is what is written. Editing whichever
17475
+ * copy the caller held let two copies of one record exist at once: an
17476
+ * overwrite held its directory across storing the old version's
17477
+ * history, a write of the directory failed meanwhile and dropped it from
17478
+ * the cache, and a save that then read a second copy and stored it,
17479
+ * successfully, was undone when the overwrite stored the first.
17480
+ *
17481
+ * So `edit` must take what it writes from the record it is given, not
17482
+ * from anything read off `dir` before an await - and re-check whatever it
17483
+ * relies on from then, throwing if another write changed it, before it
17484
+ * changes anything. Returning false writes nothing after all.
17485
+ *
17486
+ * The write that carries the edit may carry others, made while an
17487
+ * earlier write of the record was in flight (see `queue`): it lands, or
17488
+ * fails, for all of them at once.
17489
+ *
17490
+ * With a coordinator, the edit may be made a second time, to the record
17491
+ * as another tab left it, and refused then (see `replay`): an edit must
17492
+ * check everything before it changes anything, and whatever it sets
17493
+ * outside `node` and `record` must be safe to set again, since each run
17494
+ * overwrites the last.
17495
+ */
16484
17496
  async mutate(dir, edit) {
16485
- edit(dir.node);
16486
- dir.node.mtime = Date.now();
16487
- // A write to a hidden entry's exact name replaces it: that name now
16488
- // means what this version wrote. Forgetting the old one here is also
16489
- // what stops a later delete of the replacement from resurrecting it.
16490
- for (const name of Object.keys(dir.hidden)) {
16491
- if (Object.hasOwn(dir.node.entries, name))
16492
- delete dir.hidden[name];
16493
- }
16494
- // Not sent until any earlier write of this record has landed: see
16495
- // `writing`. Whether that one worked or not, this one still goes.
16496
- const before = this.writing.get(dir.id);
16497
- const send = () => this.writeRecord(dir);
16498
- const write = before ? before.then(send, send) : send();
16499
- this.writing.set(dir.id, write);
17497
+ // At once when the copy is to hand, which it almost always is, so
17498
+ // that what the caller checked just before is still true when the
17499
+ // edit is made.
17500
+ const target = this.editable(dir) ?? await this.reopen(dir);
17501
+ if (edit(target.node, target) === false)
17502
+ return;
17503
+ this.touch(target);
17504
+ const ticket = { edit };
17505
+ const batch = this.queue(target, ticket);
17506
+ const { write } = batch;
16500
17507
  try {
16501
17508
  await write;
16502
17509
  }
@@ -16506,14 +17513,315 @@ class VfsNamespace {
16506
17513
  // change the store never took - a deleted file gone from every
16507
17514
  // listing while it is still stored, and a retry told it does not
16508
17515
  // exist. Drop it, so the next read asks the store.
16509
- this.cache.delete(dir.id);
17516
+ //
17517
+ // Unless a later write of this record is already queued. It
17518
+ // seals the record as it stands when it is sent, this edit
17519
+ // included, so the cache shows what is about to be stored, and
17520
+ // dropping it would only send readers to the store for an older
17521
+ // copy. If that write fails too, it drops the cache itself.
17522
+ //
17523
+ // Nor from a cache cleared since: that is the next identity's.
17524
+ if (this.writing.get(target.id) === write && target.gen === this.generation)
17525
+ this.cache.delete(target.id);
16510
17526
  throw err;
16511
17527
  }
16512
17528
  finally {
16513
- if (this.writing.get(dir.id) === write)
16514
- this.writing.delete(dir.id);
17529
+ if (this.writing.get(target.id) === write)
17530
+ this.writing.delete(target.id);
16515
17531
  }
16516
- this.cache.set(dir.id, Promise.resolve(dir));
17532
+ this.adopt(target);
17533
+ // Made again to the record as another tab left it, it no longer
17534
+ // applied - see `replay` - and nothing of it was written.
17535
+ if (batch.refused.has(ticket))
17536
+ throw batch.refused.get(ticket);
17537
+ }
17538
+ /**
17539
+ * The copy of `dir`'s record that edits are made to, if there is one
17540
+ * without waiting: the one the cache holds, once read.
17541
+ *
17542
+ * Failing that, the one a write in flight is sending. The record was
17543
+ * dropped from the cache - a change was reported, our own write's echo
17544
+ * among them - and a read that came in since is waiting for that write,
17545
+ * to answer with this very copy if it lands (see `openDirById`); if it
17546
+ * fails, the write an edit now queues behind it carries the copy instead.
17547
+ *
17548
+ * Undefined when the record is not cached and nothing is writing it, or
17549
+ * it is still being read: `reopen` waits for that. A record read before
17550
+ * `clearCache` is edited as it is, as it always was. It is not the copy of
17551
+ * anything the cache holds now, and must not be handed the next identity's.
17552
+ */
17553
+ editable(dir) {
17554
+ if (dir.gen !== this.generation)
17555
+ return dir;
17556
+ const cached = this.cache.get(dir.id);
17557
+ const write = this.writing.get(dir.id);
17558
+ const copy = (cached && this.copyOf.get(cached)) ?? (write && this.copyOf.get(write));
17559
+ return copy?.gen === this.generation ? copy : undefined;
17560
+ }
17561
+ /**
17562
+ * Read `dir`'s record - the cached read, or a fetch made once any write of
17563
+ * it in flight has settled - and then find the copy edits are made to.
17564
+ */
17565
+ async reopen(dir) {
17566
+ for (;;) {
17567
+ await this.openDirById(dir.id, dir.key, dir.path);
17568
+ // What that read answered with, unless a change has dropped it
17569
+ // from the cache again since: then read it again.
17570
+ const copy = this.editable(dir);
17571
+ if (copy)
17572
+ return copy;
17573
+ }
17574
+ }
17575
+ /** Make `dir` the cached copy of its record - unless the cache has been cleared since it was read. */
17576
+ adopt(dir) {
17577
+ if (dir.gen !== this.generation)
17578
+ return;
17579
+ const cached = Promise.resolve(dir);
17580
+ this.copyOf.set(cached, dir);
17581
+ this.cache.set(dir.id, cached);
17582
+ }
17583
+ /** After an edit of `copy`: its time, and any entry it cannot read that the edit replaced. */
17584
+ touch(copy) {
17585
+ copy.node.mtime = Date.now();
17586
+ // A write to a hidden entry's exact name replaces it: that name now
17587
+ // means what this version wrote. Forgetting the old one here is also
17588
+ // what stops a later delete of the replacement from resurrecting it.
17589
+ for (const name of Object.keys(copy.hidden)) {
17590
+ if (Object.hasOwn(copy.node.entries, name))
17591
+ delete copy.hidden[name];
17592
+ }
17593
+ }
17594
+ /**
17595
+ * The write that will carry the edit just made to `copy`.
17596
+ *
17597
+ * Writes of a record go out one at a time, each once the one before it
17598
+ * has landed or failed (see `writing`), and each seals the record as it
17599
+ * stands when it goes out. So a write still waiting carries every edit
17600
+ * made while it waits, and an edit made then joins it instead of
17601
+ * queueing a write of its own: a burst of edits of one directory while a
17602
+ * write of it is in flight costs two writes, not one per edit. Each is a
17603
+ * billed write; a 20-file upload into one folder cost twenty.
17604
+ */
17605
+ queue(copy, ticket) {
17606
+ const open = this.waiting.get(copy.id);
17607
+ if (open?.copy === copy) {
17608
+ open.edits.push(ticket);
17609
+ return open;
17610
+ }
17611
+ const before = this.writing.get(copy.id);
17612
+ const batch = { copy, edits: [ticket], refused: new Map() };
17613
+ this.waiting.set(copy.id, batch);
17614
+ // Not sent until any earlier write of this record has landed: see
17615
+ // `writing`. Whether that one worked or not, this one still goes.
17616
+ const send = () => this.send(batch);
17617
+ batch.write = before ? before.then(send, send) : send();
17618
+ this.copyOf.set(batch.write, copy);
17619
+ this.writing.set(copy.id, batch.write);
17620
+ return batch;
17621
+ }
17622
+ /**
17623
+ * Send `batch`: from here on, an edit of its record goes in the next
17624
+ * write. With a coordinator, holding the lock every tab of the device
17625
+ * takes to write this record - see `sendLocked`.
17626
+ */
17627
+ send(batch) {
17628
+ const coordinator = this.deps.coordinator;
17629
+ if (!coordinator) {
17630
+ this.close(batch);
17631
+ return this.writeRecord(batch.copy);
17632
+ }
17633
+ // Closed under the lock once it is sealed (see `sendLocked`), and
17634
+ // here too, for a lock never granted or a re-read that failed: left
17635
+ // open, a later edit of the same copy would join a write that has
17636
+ // already failed, and fail with it.
17637
+ return coordinator.lock(batch.copy.id, () => this.sendLocked(batch, coordinator)).finally(() => this.close(batch));
17638
+ }
17639
+ /** Stop `batch` taking more edits, as it is sealed. */
17640
+ close(batch) {
17641
+ if (this.waiting.get(batch.copy.id) === batch)
17642
+ this.waiting.delete(batch.copy.id);
17643
+ }
17644
+ /**
17645
+ * Send `batch` while no other tab can write its record.
17646
+ *
17647
+ * If another tab wrote the record since this copy was read, the copy is
17648
+ * out of date, and writing it would undo that tab's write. So it is read
17649
+ * again, here, under the lock, and every edit the batch carries is made
17650
+ * again to what was read (see `replay`). A copy no other tab has written
17651
+ * over is sent as it is: one tab on its own costs no extra request.
17652
+ *
17653
+ * Which it is, the record's write counter says (see
17654
+ * `VfsCoordinator.counters`). Read once the lock is granted, it counts
17655
+ * every write of the record another tab made, because each counts its
17656
+ * write, and waits for the count to be stored, before it lets go of the
17657
+ * lock. If it has moved since the copy was read, or cannot be told, the
17658
+ * copy is read again. Whether this tab has heard of another tab's write
17659
+ * does not decide it: an announcement can reach this tab after the
17660
+ * lock does, or never. One it has heard of can only add a read.
17661
+ *
17662
+ * The write is counted once it has landed or failed - one that failed
17663
+ * may have landed even so - and the other tabs are then told of it,
17664
+ * all before the lock is let go of.
17665
+ */
17666
+ async sendLocked(batch, coordinator) {
17667
+ const { copy } = batch;
17668
+ // Hearing for whoever is signed in now, before deciding: starting to
17669
+ // hear - or hearing someone else - marks every copy out of date.
17670
+ coordinator.listen();
17671
+ const now = await coordinator.begin(copy.id).catch(() => null);
17672
+ // Whether `copy` is, from here, the record as this tab leaves it.
17673
+ let decided = false;
17674
+ let sent = false;
17675
+ try {
17676
+ const current = now !== null && copy.counter === now && this.current(copy);
17677
+ const fresh = copy.gen === this.generation && !current
17678
+ ? await this.fetchRecord(copy.id, copy.key, copy.gen, copy.path, null)
17679
+ : null;
17680
+ // Nothing waits from here to the seal, so no edit can come in between.
17681
+ this.close(batch);
17682
+ decided = true;
17683
+ this.locked.set(copy.id, copy);
17684
+ if (fresh && !this.replay(batch, fresh))
17685
+ return;
17686
+ sent = true;
17687
+ await this.writeRecord(copy);
17688
+ copy.seen = this.hearing;
17689
+ }
17690
+ finally {
17691
+ const counted = await coordinator.bump(copy.id).catch(() => null);
17692
+ // Not when the read above failed: the copy is what it was, and
17693
+ // the counter it held is out of date now.
17694
+ if (decided)
17695
+ copy.counter = counted;
17696
+ if (this.locked.get(copy.id) === copy)
17697
+ this.locked.delete(copy.id);
17698
+ if (counted === null)
17699
+ this.notCounted(copy, now !== null);
17700
+ else
17701
+ this.uncounted.delete(copy.id);
17702
+ if (sent)
17703
+ coordinator.announce(copy.id);
17704
+ }
17705
+ }
17706
+ /**
17707
+ * Say that a write of `copy`'s record could not be counted.
17708
+ *
17709
+ * `marked`: `VfsCoordinator.begin` stored its mark, which stays until a
17710
+ * count can be stored, so every tab reads the record again before it
17711
+ * writes it. Said once for each record, until a write of it is counted.
17712
+ *
17713
+ * Not marked, a tab whose IndexedDB works may take its copy for current
17714
+ * and write it over this write. Said once for this namespace, whatever
17715
+ * the record: where IndexedDB cannot be used at all, no write of any
17716
+ * folder is counted, and a warning for each said nothing new. And only
17717
+ * where another tab can count its writes can one be lost, which the
17718
+ * warning says: where no tab can, every tab reads each folder again.
17719
+ */
17720
+ notCounted(copy, marked) {
17721
+ const where = copy.path ?? `record ${printable(copy.id, 40)}`;
17722
+ if (!marked) {
17723
+ if (this.lossWarned)
17724
+ return;
17725
+ this.lossWarned = true;
17726
+ this.warn(`a write of ${where} by this tab could not be counted in IndexedDB, where this app's other tabs look before writing a folder. Such a write may be lost only if another tab of this app can count its writes: that tab may write the folder without reading it again. Said once per Client, for every write of a folder that cannot be counted`);
17727
+ return;
17728
+ }
17729
+ if (this.uncounted.has(copy.id))
17730
+ return;
17731
+ this.uncounted.add(copy.id);
17732
+ this.warn(`a write of ${where} by this tab could not be counted in IndexedDB; until one can be, every tab of this app reads ${where} again before writing it`);
17733
+ }
17734
+ /**
17735
+ * Make every edit in `batch` again, in order, to `fresh` - the record as
17736
+ * another tab left it - and make that the copy. An edit that no longer
17737
+ * applies throws, as it would have the first time, and is left out; its
17738
+ * caller gets what it threw. Every edit checks before it changes
17739
+ * anything (see `mutate`), so one that throws has changed nothing. True
17740
+ * when what is left changes the record, and so needs writing.
17741
+ */
17742
+ replay(batch, fresh) {
17743
+ const { copy } = batch;
17744
+ copy.node = fresh.node;
17745
+ copy.hidden = fresh.hidden;
17746
+ copy.seen = fresh.seen;
17747
+ copy.counter = fresh.counter;
17748
+ let changed = false;
17749
+ for (const ticket of batch.edits) {
17750
+ try {
17751
+ if (ticket.edit(copy.node, copy) !== false)
17752
+ changed = true;
17753
+ }
17754
+ catch (err) {
17755
+ batch.refused.set(ticket, err);
17756
+ }
17757
+ }
17758
+ if (changed)
17759
+ this.touch(copy);
17760
+ return changed;
17761
+ }
17762
+ /**
17763
+ * Another tab wrote record `id` - or, null, this namespace may have
17764
+ * missed some announcements. What the cache holds is read again when
17765
+ * next asked for, as a change frame from the personal space would have
17766
+ * it, and a copy read before then is read again before the next write
17767
+ * of its record (see `current`). A hint, not a guarantee: an
17768
+ * announcement can reach this tab after the next lock does, or never.
17769
+ * What decides whether a copy may be written over, or relied on under a
17770
+ * move's or a delete's locks, is the record's counter: see `sendLocked`
17771
+ * and `dropStale`.
17772
+ */
17773
+ heard(id) {
17774
+ const at = ++this.hearing;
17775
+ if (id === null) {
17776
+ this.missedAt = at;
17777
+ this.cache.clear();
17778
+ return;
17779
+ }
17780
+ this.heardAt.set(id, at);
17781
+ this.cache.delete(id);
17782
+ }
17783
+ /**
17784
+ * Does `copy` hold every write of its record another tab has announced
17785
+ * that this one has heard of? See `heard`. Only ever a reason to read it
17786
+ * again, never to take it for current: see `sendLocked`.
17787
+ */
17788
+ current(copy) {
17789
+ return copy.seen >= Math.max(this.missedAt, this.heardAt.get(copy.id) ?? 0);
17790
+ }
17791
+ /**
17792
+ * Drop from the cache every copy of a record in `ids` that may not hold
17793
+ * every write of it another tab has made: its counter has moved since
17794
+ * the copy was read, or cannot be told, or the copy is still being read.
17795
+ * Returns every id whose copy here is not known to be current - those,
17796
+ * and those not cached - so that the next read of each fetches it again,
17797
+ * once any write of it in flight here has landed or failed. Not a copy
17798
+ * this tab is writing under its lock (see `locked`): its counter reads
17799
+ * as unknown only because that write is not counted yet, and dropped,
17800
+ * the next read would wait for the write.
17801
+ *
17802
+ * The counters are read in one go, after the caller has taken the locks
17803
+ * it relies on: another tab counts each write of a record before it lets
17804
+ * go of the locks it held while writing it, the record's own and any
17805
+ * move lock, so what it wrote before letting go of one of them is
17806
+ * counted by then. Its announcements may not have reached this tab yet,
17807
+ * or ever. See `sendLocked`.
17808
+ */
17809
+ async dropStale(coordinator, ids) {
17810
+ const asked = [...new Set(ids)];
17811
+ const now = asked.length ? await coordinator.counters(asked).catch(() => asked.map(() => null)) : [];
17812
+ const stale = new Set();
17813
+ asked.forEach((id, i) => {
17814
+ const cached = this.cache.get(id);
17815
+ const copy = cached && this.copyOf.get(cached);
17816
+ if (copy && copy.gen === this.generation && copy.counter !== null && copy.counter === now[i])
17817
+ return;
17818
+ if (copy && copy.gen === this.generation && this.locked.get(id) === copy)
17819
+ return;
17820
+ if (cached)
17821
+ this.cache.delete(id);
17822
+ stale.add(id);
17823
+ });
17824
+ return stale;
16517
17825
  }
16518
17826
  /**
16519
17827
  * Seal a directory record as it stands NOW and put it in the store.
@@ -16541,13 +17849,38 @@ class VfsNamespace {
16541
17849
  }
16542
17850
  await this.deps.store.put(dirKey(dir.id), sealed);
16543
17851
  }
16544
- async createDir(parentPath, name) {
17852
+ /** For `mkdir`: name a new, empty directory `path` in the one at `parentPath`. */
17853
+ async createDir(parentPath, name, path, recursive) {
16545
17854
  assertValidName(name);
16546
17855
  const parent = await this.openDir(parentPath);
16547
17856
  const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
16548
- await this.mutate(parent, (node) => {
17857
+ // The directory the edit below last found there, if any.
17858
+ let there;
17859
+ const named = this.mutate(parent, (node, record) => {
17860
+ // mkdir looked before the wait above. A directory made there
17861
+ // since, by another `mkdir -p` of the same path - files saved
17862
+ // at once into a new folder - is kept, not replaced: whatever
17863
+ // was saved into it would go with it.
17864
+ const now = entryIn(node, name);
17865
+ there = now;
17866
+ if (now?.kind === "dir" && recursive)
17867
+ return false;
17868
+ if (now)
17869
+ throw new VfsConflictError(`${path} ${now.kind === "dir" ? "already exists" : "is a file"}`);
17870
+ assertNotHidden(record, name, path);
16549
17871
  node.entries[name] = entry;
17872
+ return true;
16550
17873
  });
17874
+ // Set before anyone can find the entry in the cached parent - `mutate`
17875
+ // makes the edit at once, or after a re-read - so a mkdir that finds
17876
+ // it there waits for this write. See `naming`.
17877
+ this.naming.set(entry.id, named.then(() => { }, () => { }).then(() => void this.naming.delete(entry.id)));
17878
+ await named;
17879
+ // The directory kept instead may be one another mkdir here named,
17880
+ // its write not landed yet: wait for that too, as `mkdir` does.
17881
+ const kept = there && this.naming.get(there.id);
17882
+ if (kept)
17883
+ await kept;
16551
17884
  }
16552
17885
  /**
16553
17886
  * Every directory record in a subtree, each once, opened - and nothing
@@ -16563,14 +17896,22 @@ class VfsNamespace {
16563
17896
  *
16564
17897
  * `moving` says whether anything in it is marked as half of an
16565
17898
  * interrupted move, which is what makes a delete look for the other half.
17899
+ *
17900
+ * `ids` is every entry id in it, and `shape` what each record named when
17901
+ * it was read: see `currentSubtree`.
16566
17902
  */
16567
17903
  async subtree(top, target, above) {
16568
17904
  const found = new Map([[top.id, top]]);
16569
17905
  const pending = [top];
17906
+ // Every entry id in it, and what each record named as it was read.
17907
+ const ids = new Set();
17908
+ const shape = new Map();
16570
17909
  let spared = 0;
16571
17910
  let moving = false;
16572
17911
  for (let dir = pending.pop(); dir; dir = pending.pop()) {
17912
+ shape.set(dir.id, namedIn(dir));
16573
17913
  for (const [, entry] of followable(dir)) {
17914
+ ids.add(entry.id);
16574
17915
  if (entry.moving === true)
16575
17916
  moving = true;
16576
17917
  if (entry.kind !== "dir" || found.has(entry.id))
@@ -16600,7 +17941,79 @@ class VfsNamespace {
16600
17941
  const what = spared === 1 ? "a directory that is" : `${spared} directories that are`;
16601
17942
  this.warn(`leaving ${what} above ${target} in the tree alone while deleting it`);
16602
17943
  }
16603
- return { dirs: [...found.values()], moving };
17944
+ return { dirs: [...found.values()], moving, ids, shape };
17945
+ }
17946
+ /**
17947
+ * The records a recursive delete read, as they are now - or null when
17948
+ * one has changed since in a way that purging it would get wrong.
17949
+ *
17950
+ * The delete decided what to let go of, and whether to look for the
17951
+ * other half of a move, from what it read; then it waited, on reads and
17952
+ * on the write of its parent. A record that is no longer named by the
17953
+ * record it was read in has been moved out, and is not this delete's to
17954
+ * purge; an entry marked since it was read is being moved, and is about
17955
+ * to be named somewhere the delete never looked. Purging either purged
17956
+ * the folder a move had just put somewhere else. Anything else - a file
17957
+ * saved into one of them, or moved out - is purged as it now stands,
17958
+ * which is why the copies to purge are what this returns.
17959
+ *
17960
+ * Synchronous, for an edit. A record whose copy is not to hand counts as
17961
+ * changed.
17962
+ *
17963
+ * `held`, with a coordinator, is every entry whose move lock the delete
17964
+ * holds. An entry named since the records were read - saved or moved in
17965
+ * - is not among them, so nothing stops another tab moving it out while
17966
+ * the delete writes the parent, and purging it then let go of what that
17967
+ * move had put somewhere else. So with `held`, that counts as changed
17968
+ * too, and the delete is tried again from the start.
17969
+ */
17970
+ currentSubtree(dirs, shape, held) {
17971
+ const doomed = new Set(dirs.map((dir) => dir.id));
17972
+ const current = [];
17973
+ for (const dir of dirs) {
17974
+ const now = this.editable(dir);
17975
+ if (!now)
17976
+ return null;
17977
+ const read = shape.get(dir.id) ?? new Map();
17978
+ const named = namedIn(now);
17979
+ for (const [id, marked] of named)
17980
+ if (marked && read.get(id) !== true)
17981
+ return null;
17982
+ for (const id of read.keys())
17983
+ if (doomed.has(id) && !named.has(id))
17984
+ return null;
17985
+ if (held)
17986
+ for (const id of named.keys())
17987
+ if (!held.has(id))
17988
+ return null;
17989
+ current.push(now);
17990
+ }
17991
+ return current;
17992
+ }
17993
+ /**
17994
+ * Hold the move lock of every entry in `ids` - see
17995
+ * `VfsCoordinator.holdMoving` - or of none: null when another holder has
17996
+ * any of them, and then nothing is held. Otherwise the function that
17997
+ * lets go of them all. Asked for all at once: none of them waits.
17998
+ * `shared` holds them as the folders a move goes into.
17999
+ */
18000
+ async holdAll(coordinator, ids, shared = false) {
18001
+ const tries = await Promise.allSettled([...new Set(ids)].map((id) => coordinator.holdMoving(id, shared)));
18002
+ const held = tries.flatMap((tried) => (tried.status === "fulfilled" && tried.value ? [tried.value] : []));
18003
+ const release = () => {
18004
+ for (const letGo of held)
18005
+ letGo();
18006
+ };
18007
+ const failed = tries.find((tried) => tried.status === "rejected");
18008
+ if (failed) {
18009
+ release();
18010
+ throw failed.reason;
18011
+ }
18012
+ if (held.length < tries.length) {
18013
+ release();
18014
+ return null;
18015
+ }
18016
+ return release;
16604
18017
  }
16605
18018
  /**
16606
18019
  * Delete these records and let go of every file in them. The second half
@@ -16656,8 +18069,15 @@ class VfsNamespace {
16656
18069
  // Opened again: this delete may have written to it since the tree was read.
16657
18070
  const dir = await this.openDirById(holding[0].id, holding[0].key);
16658
18071
  const name = Object.keys(dir.node.entries).find((n) => dir.node.entries[n].id === id && dir.node.entries[n].moving === true);
16659
- if (name !== undefined)
16660
- await this.mutate(dir, (node) => void (node.entries[name] = unmarked(node.entries[name])));
18072
+ if (name === undefined)
18073
+ return;
18074
+ await this.mutate(dir, (node) => {
18075
+ const now = entryIn(node, name);
18076
+ if (now?.id !== id || now.moving !== true)
18077
+ return false;
18078
+ node.entries[name] = unmarked(now);
18079
+ return true;
18080
+ });
16661
18081
  }
16662
18082
  catch {
16663
18083
  // It stays marked.
@@ -16854,9 +18274,12 @@ class VfsNamespace {
16854
18274
  }
16855
18275
  if (!run.write)
16856
18276
  return clone;
16857
- const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null) };
18277
+ const counter = this.deps.coordinator ? await this.counterOf(clone.id) : null;
18278
+ const open = {
18279
+ id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen, seen: this.hearing, counter,
18280
+ };
16858
18281
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
16859
- this.cache.set(open.id, Promise.resolve(open));
18282
+ this.adopt(open);
16860
18283
  return clone;
16861
18284
  }
16862
18285
  /**
@@ -17853,8 +19276,11 @@ class Repo {
17853
19276
  async expand(prefix) {
17854
19277
  if (!/^[0-9a-f]{4,}$/.test(prefix))
17855
19278
  return null;
17856
- const keys = await this.deps.store.list();
17857
19279
  const wanted = objectKey(this.slug, prefix);
19280
+ // Only the keys under the abbreviation (usually none or one), not the
19281
+ // whole personal space. The filter below stays, for a store that
19282
+ // ignores the prefix.
19283
+ const keys = await this.deps.store.list(wanted);
17858
19284
  // Only keys that could be objects: a stray key under the prefix would
17859
19285
  // otherwise make every abbreviation of it "ambiguous".
17860
19286
  const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
@@ -18201,6 +19627,519 @@ class VcsNamespace {
18201
19627
  }
18202
19628
  }
18203
19629
 
19630
+ /**
19631
+ * Coordination between the tabs of an app on one device: Web Locks, the
19632
+ * Web BroadcastChannel, and IndexedDB.
19633
+ *
19634
+ * Every factory here returns undefined unless the runtime is a browser tab
19635
+ * with both Web Locks and a BroadcastChannel - not Node (which has
19636
+ * `navigator.locks` from 24.5, but per process), not a Worker, not an old
19637
+ * WebView - and its callers then keep the behaviour they had before: one
19638
+ * tab's writes ordered among themselves only. IndexedDB is not required:
19639
+ * without it no count can be told, and every write reads its folder again
19640
+ * first. A storage-partitioned iframe gets locks, a channel and IndexedDB of
19641
+ * its own, as it gets storage of its own; to the app's other tabs it is
19642
+ * another device.
19643
+ *
19644
+ * A browser can also have these and refuse them to a document: an opaque
19645
+ * origin (a sandboxed iframe, a `data:` document), or a site whose data the
19646
+ * user has blocked. Refused Web Locks, the coordinator goes on without
19647
+ * them, as in Node (see `refusesLocks`); refused a channel, it hears
19648
+ * nothing; and an IndexedDB that does not answer in time counts nothing
19649
+ * (see `writeCounts`). `client.vfs` needed none of the three before it
19650
+ * coordinated tabs, and none of them may stop it now.
19651
+ *
19652
+ * `globalThis.BroadcastChannel` is named in full on purpose: this package
19653
+ * exports a class of its own called BroadcastChannel (the E2E room channel),
19654
+ * and an import of that in scope would silently take the Web API's place.
19655
+ */
19656
+ /** How long a write waits for another tab to finish writing the same folder before it gives up. */
19657
+ const LOCK_WAIT_MS = 30_000;
19658
+ /**
19659
+ * How long each call to the write counts - `counters`, `begin` or `bump` -
19660
+ * may take before it gives up, telling nothing, as one that failed: the
19661
+ * wait for the IndexedDB open and the call's transaction together, not
19662
+ * each. Only the call gives up, not the open: one that succeeds later,
19663
+ * within `OPEN_DROP_MS`, is kept for every later call (see `writeCounts`).
19664
+ */
19665
+ const COUNT_WAIT_MS = 3_000;
19666
+ /**
19667
+ * How long an IndexedDB open of the write counts may go without any answer
19668
+ * - no success, error or blocked - before it is dropped, as one that
19669
+ * failed, and the next call asks for another. See `writeCounts`.
19670
+ */
19671
+ const OPEN_DROP_MS = 30_000;
19672
+ /**
19673
+ * Whether `err`, from a Web Locks request that was never granted, is the
19674
+ * browser refusing Web Locks to this document altogether: an opaque origin,
19675
+ * or a site whose data the user has blocked. The browser makes that
19676
+ * decision for the site's storage, so it refuses every tab of the app
19677
+ * alike, and no tab holds the lock. The tab then goes on without it, as in
19678
+ * Node. Any other error - a document no longer fully active, say - is
19679
+ * passed on.
19680
+ */
19681
+ function refusesLocks(err) {
19682
+ return err?.name === "SecurityError";
19683
+ }
19684
+ // Through `unknown`: the DOM's own LockManager and BroadcastChannel types do
19685
+ // not line up with these slices closely enough for a direct cast.
19686
+ function webLocks() {
19687
+ if (typeof window === "undefined")
19688
+ return undefined;
19689
+ const locks = globalThis.navigator?.locks;
19690
+ return typeof locks?.request === "function" ? locks : undefined;
19691
+ }
19692
+ function webChannel() {
19693
+ const Channel = globalThis.BroadcastChannel;
19694
+ return typeof Channel === "function" ? Channel : undefined;
19695
+ }
19696
+ function webIndexedDB() {
19697
+ const idb = globalThis.indexedDB;
19698
+ return typeof idb?.open === "function" ? idb : undefined;
19699
+ }
19700
+ /**
19701
+ * The IndexedDB database the write counts are kept in, one per origin, as
19702
+ * the locks are. Not one connect keeps for anything else: those are
19703
+ * `muhkoo.offline` (the offline layer), `muhkoo.trust` and `muhkoo.device.v1`.
19704
+ *
19705
+ * Its version stays 1 for good. A tab still running code that opens it at
19706
+ * version 1 would fail with a VersionError once a newer tab had made it at
19707
+ * version 2, count nothing, and so write a folder without the newer tabs
19708
+ * knowing: the writer whose write may be lost. A new store goes in a new
19709
+ * database.
19710
+ */
19711
+ const COUNTS_DB = "muhkoo.vfs";
19712
+ /** Its one store: `[commitment, record id]` to a count, and `CREATED` to a random id. */
19713
+ const COUNTS = "counts";
19714
+ const CREATED = "created";
19715
+ /**
19716
+ * How often each user's directory records have been written on this
19717
+ * device: `VfsCoordinator.counters`, `begin` and `bump`, kept in IndexedDB
19718
+ * for every tab of the origin.
19719
+ *
19720
+ * A count is odd from `begin` to `bump`, while a write is under way, and
19721
+ * even once it is counted; `counters` reports an odd one as unknown. It is
19722
+ * read as `<created>:<count>`, where `created` is a random id stored when
19723
+ * the database was made: deleted - the site's data cleared - every count
19724
+ * starts again from nothing, and a counter read before could otherwise
19725
+ * come round again with writes in between that its copy does not hold.
19726
+ *
19727
+ * Each operation is one transaction, and settles once that has completed.
19728
+ * Every failure - IndexedDB missing, an open refused, blocked or failed, a
19729
+ * transaction aborted - resolves to null, which the namespace takes for out
19730
+ * of date: it reads the record again. A failed or blocked open is not kept,
19731
+ * and nor is a connection closed under it, by a newer version of this code
19732
+ * in another tab or by the database being deleted: the next operation opens
19733
+ * the database again.
19734
+ *
19735
+ * So does an operation that has not settled within `waitMs`, the open it
19736
+ * waits for and its transaction together: these are awaited inside the
19737
+ * record locks, and one waited for without end would hold the lock, and
19738
+ * stop every read and write of the tab's folders, for as long. Only the
19739
+ * operation gives up, though, not the open: there is one open under way at
19740
+ * a time, every operation waits for that one, and once it has opened the
19741
+ * database it is kept for every later operation. Given up on too, and closed
19742
+ * when it opened at last, an open that always takes longer than `waitMs`
19743
+ * would never give the tab its counts. An open that has not answered at all
19744
+ * within `OPEN_DROP_MS` is dropped, though, as one that failed: one that
19745
+ * never will would otherwise leave the tab without counts for as long as it
19746
+ * lives. Until then every operation waits for it, `waitMs` at most, and
19747
+ * tells nothing; the next operation after that asks for one new open; and
19748
+ * the dropped one, if it opens after all, is closed. The new open is
19749
+ * dropped in turn if it goes `OPEN_DROP_MS` without an answer too, and so
19750
+ * on: where IndexedDB never answers, the tab leaves behind one more open
19751
+ * that never settles every `OPEN_DROP_MS` or a little more, for as long as
19752
+ * operations keep coming, and none while none come. An operation that
19753
+ * gives up before the database has opened makes no transaction. A
19754
+ * transaction given up on is not aborted, and may still complete: IndexedDB
19755
+ * runs the read-write transactions of one store in the order they were
19756
+ * made, so a late `begin` still marks the count before any later `bump`,
19757
+ * and a late `bump` still counts its write before any later `begin`. A tab
19758
+ * that asks meanwhile waits behind it, gives up, and reads the record
19759
+ * again.
19760
+ */
19761
+ function writeCounts(idb, waitMs) {
19762
+ // The open under way, or the one that opened `opened`: one at a time.
19763
+ let connection = null;
19764
+ // The database `connection` opened, once it has.
19765
+ let opened = null;
19766
+ // Drops `connection` while it is under way, once `OPEN_DROP_MS` have passed.
19767
+ let dropping;
19768
+ const forget = (db) => {
19769
+ if (opened !== db)
19770
+ return;
19771
+ opened = null;
19772
+ connection = null;
19773
+ db.close();
19774
+ };
19775
+ const open = () => {
19776
+ if (connection)
19777
+ return connection;
19778
+ let request;
19779
+ try {
19780
+ if (!idb)
19781
+ throw new Error("no IndexedDB");
19782
+ request = idb.open(COUNTS_DB, 1);
19783
+ }
19784
+ catch {
19785
+ // Not kept: the next operation tries again.
19786
+ return Promise.resolve(null);
19787
+ }
19788
+ const pending = new Promise((resolve) => {
19789
+ // Not kept when it failed, or was dropped: the next operation
19790
+ // tries again. Failed after it was dropped, it changes nothing.
19791
+ const fail = () => {
19792
+ if (connection === pending) {
19793
+ clearTimeout(dropping);
19794
+ connection = null;
19795
+ }
19796
+ resolve(null);
19797
+ };
19798
+ request.onupgradeneeded = () => {
19799
+ request.result.createObjectStore(COUNTS).put(generateId(), CREATED);
19800
+ };
19801
+ request.onsuccess = () => {
19802
+ const db = request.result;
19803
+ // Blocked or dropped, and given up on, before it opened - or
19804
+ // closed by `close`. Another open may be under way by now.
19805
+ if (connection !== pending) {
19806
+ db.close();
19807
+ resolve(null);
19808
+ return;
19809
+ }
19810
+ clearTimeout(dropping);
19811
+ db.onversionchange = () => forget(db);
19812
+ db.onclose = () => forget(db);
19813
+ opened = db;
19814
+ resolve(db);
19815
+ };
19816
+ // Blocked: another tab holds an older version open. Not waited for.
19817
+ request.onerror = fail;
19818
+ request.onblocked = fail;
19819
+ // No answer at all: dropped, as one that failed, and the next
19820
+ // operation asks for another - dropped in turn if it does not
19821
+ // answer either. See above.
19822
+ dropping = setTimeout(fail, OPEN_DROP_MS);
19823
+ });
19824
+ connection = pending;
19825
+ return pending;
19826
+ };
19827
+ /**
19828
+ * Run `body` in one transaction, and resolve once that has completed to
19829
+ * what the function it returns gives then - or to null, if anything
19830
+ * fails, or the database has not opened and the transaction completed
19831
+ * within `waitMs`.
19832
+ */
19833
+ const run = (mode, body) => new Promise((resolve) => {
19834
+ let done = false;
19835
+ const settle = (value) => {
19836
+ if (done)
19837
+ return;
19838
+ done = true;
19839
+ clearTimeout(timer);
19840
+ resolve(value);
19841
+ };
19842
+ // Given up on, but neither the open nor the transaction aborted: see above.
19843
+ const timer = setTimeout(() => settle(null), waitMs);
19844
+ void open().then((db) => {
19845
+ // Given up on while it opened: nothing to do.
19846
+ if (done)
19847
+ return;
19848
+ if (!db) {
19849
+ settle(null);
19850
+ return;
19851
+ }
19852
+ let tx;
19853
+ try {
19854
+ tx = db.transaction(COUNTS, mode);
19855
+ const result = body(tx.objectStore(COUNTS));
19856
+ tx.oncomplete = () => settle(result());
19857
+ tx.onabort = () => settle(null);
19858
+ }
19859
+ catch {
19860
+ try {
19861
+ tx?.abort();
19862
+ }
19863
+ catch {
19864
+ // Finished already.
19865
+ }
19866
+ // Closed under it, most likely: the next operation opens it again.
19867
+ forget(db);
19868
+ settle(null);
19869
+ }
19870
+ });
19871
+ });
19872
+ const count = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0);
19873
+ const counter = (created, n) => (typeof created === "string" ? `${created}:${n}` : null);
19874
+ return {
19875
+ async counters(who, ids) {
19876
+ const read = await run("readonly", (store) => {
19877
+ const created = store.get(CREATED);
19878
+ const counts = ids.map((id) => store.get([who, id]));
19879
+ return () => counts.map((at) => {
19880
+ const n = count(at.result);
19881
+ return n % 2 === 0 ? counter(created.result, n) : null;
19882
+ });
19883
+ });
19884
+ return read ?? ids.map(() => null);
19885
+ },
19886
+ begin(who, id) {
19887
+ return run("readwrite", (store) => {
19888
+ const created = store.get(CREATED);
19889
+ const at = store.get([who, id]);
19890
+ let n = 0;
19891
+ at.onsuccess = () => {
19892
+ n = count(at.result);
19893
+ // Odd already, a write began and was never counted: its mark stays.
19894
+ if (n % 2 === 0)
19895
+ store.put(n + 1, [who, id]);
19896
+ };
19897
+ return () => counter(created.result, n);
19898
+ });
19899
+ },
19900
+ bump(who, id) {
19901
+ return run("readwrite", (store) => {
19902
+ const created = store.get(CREATED);
19903
+ const at = store.get([who, id]);
19904
+ let next = 0;
19905
+ at.onsuccess = () => {
19906
+ const n = count(at.result);
19907
+ // Past the mark `begin` stored - or, when it could not
19908
+ // store one, past the count it found.
19909
+ next = n % 2 === 0 ? n + 2 : n + 1;
19910
+ store.put(next, [who, id]);
19911
+ };
19912
+ return () => counter(created.result, next);
19913
+ });
19914
+ },
19915
+ close() {
19916
+ // An open still under way is closed once it opens.
19917
+ connection = null;
19918
+ clearTimeout(dropping);
19919
+ if (opened)
19920
+ forget(opened);
19921
+ },
19922
+ };
19923
+ }
19924
+ /**
19925
+ * The offline queue's drain lock: `fn` runs only if no other tab is
19926
+ * draining, and `false` says another one is. See `OutboundQueue`. Where the
19927
+ * browser refuses Web Locks, `fn` runs without it, as in Node, and the
19928
+ * lock is not asked for again: see `refusesLocks`.
19929
+ */
19930
+ function drainLock() {
19931
+ const locks = webLocks();
19932
+ if (!locks)
19933
+ return undefined;
19934
+ let refused = false;
19935
+ return async (name, fn) => {
19936
+ if (!refused) {
19937
+ let answered = false;
19938
+ try {
19939
+ return await locks.request(name, { ifAvailable: true }, async (lock) => {
19940
+ answered = true;
19941
+ if (!lock)
19942
+ return false;
19943
+ await fn();
19944
+ return true;
19945
+ });
19946
+ }
19947
+ catch (err) {
19948
+ if (answered || !refusesLocks(err))
19949
+ throw err;
19950
+ refused = true;
19951
+ }
19952
+ }
19953
+ await fn();
19954
+ return true;
19955
+ };
19956
+ }
19957
+ /**
19958
+ * The `VfsCoordinator` for whoever `commitment()` names: a lock per record,
19959
+ * `muhkoo:vfs:<commitment>:<record id>`; a move lock per entry,
19960
+ * `muhkoo:vfs:<commitment>:moving:<entry id>`, taken only if free, and
19961
+ * shared by the moves into a folder; a count of each record's writes, in
19962
+ * IndexedDB `muhkoo.vfs` under `[commitment, record id]` (see
19963
+ * `writeCounts`); and announcements on the channel `muhkoo:vfs:<commitment>`
19964
+ * as `{ id, writer }`, where `writer` is this tab's random id. `close` is
19965
+ * for tests, as are `lockWaitMs` (see `LOCK_WAIT_MS`) and `countWaitMs`
19966
+ * (see `COUNT_WAIT_MS`).
19967
+ *
19968
+ * Where the browser refuses Web Locks (see `refusesLocks`), it holds no
19969
+ * lock - every write runs as it would without a coordinator, and every
19970
+ * move lock is had at once - and asks for none again.
19971
+ */
19972
+ function vfsCoordinator(commitment, opts = {}) {
19973
+ const locks = webLocks();
19974
+ const Channel = webChannel();
19975
+ if (!locks || !Channel)
19976
+ return undefined;
19977
+ const lockWaitMs = opts.lockWaitMs ?? LOCK_WAIT_MS;
19978
+ const tab = generateId();
19979
+ const counts = writeCounts(webIndexedDB(), opts.countWaitMs ?? COUNT_WAIT_MS);
19980
+ const handlers = new Set();
19981
+ // `channel` is null where the browser would not make one: see `listen`.
19982
+ let open = null;
19983
+ // Move locks this tab has let go of, until the browser has too. See `holdMoving`.
19984
+ const leaving = new Map();
19985
+ // The browser refused Web Locks: see `refusesLocks`.
19986
+ let refused = false;
19987
+ /**
19988
+ * Hear the channel of whoever is signed in now: opened the first time,
19989
+ * and opened again for someone else after a sign-in. What was announced
19990
+ * before it opened was not heard, which every handler is told.
19991
+ *
19992
+ * Where the browser will not make the channel - an opaque origin, or a
19993
+ * site whose data is blocked - this tab hears nothing and tells no one,
19994
+ * and does not ask again for the same user. That is safe: the counts,
19995
+ * not the announcements, decide whether a copy may be written over.
19996
+ */
19997
+ const listen = () => {
19998
+ const who = commitment();
19999
+ if (open?.who === who)
20000
+ return;
20001
+ open?.channel?.close();
20002
+ open = null;
20003
+ if (!who)
20004
+ return;
20005
+ let channel = null;
20006
+ try {
20007
+ channel = new Channel(`muhkoo:vfs:${who}`);
20008
+ channel.onmessage = (event) => {
20009
+ const data = event.data;
20010
+ if (typeof data?.id !== "string" || data.writer === tab)
20011
+ return;
20012
+ for (const handler of handlers)
20013
+ handler(data.id);
20014
+ };
20015
+ }
20016
+ catch {
20017
+ channel = null;
20018
+ }
20019
+ open = { who, channel };
20020
+ for (const handler of handlers)
20021
+ handler(null);
20022
+ };
20023
+ return {
20024
+ async lock(id, fn) {
20025
+ listen();
20026
+ const who = commitment();
20027
+ // Signed out: the store refuses the write itself, with a clearer
20028
+ // message. Refused Web Locks: no tab of the app can hold this
20029
+ // lock, and the write goes ahead as it would without one.
20030
+ if (!who || refused)
20031
+ return fn();
20032
+ // Only the WAIT is bounded. Once granted, the lock is held until
20033
+ // the write has landed or failed: let go of while a write is still
20034
+ // on its way, the next tab's write could land first and then be
20035
+ // undone by it - the very loss this lock is for.
20036
+ const waiting = new AbortController();
20037
+ const timer = setTimeout(() => waiting.abort(), lockWaitMs);
20038
+ let granted = false;
20039
+ try {
20040
+ return await locks.request(`muhkoo:vfs:${who}:${id}`, { signal: waiting.signal }, () => {
20041
+ granted = true;
20042
+ clearTimeout(timer);
20043
+ return fn();
20044
+ });
20045
+ }
20046
+ catch (err) {
20047
+ if (!granted && waiting.signal.aborted) {
20048
+ throw new VfsConflictError(`another tab of this app has been writing this folder for over ${Math.round(lockWaitMs / 1000)} seconds; nothing was written - try again`);
20049
+ }
20050
+ if (granted || !refusesLocks(err))
20051
+ throw err;
20052
+ refused = true;
20053
+ }
20054
+ finally {
20055
+ clearTimeout(timer);
20056
+ }
20057
+ // Refused Web Locks, before anything was written: written without one.
20058
+ return fn();
20059
+ },
20060
+ announce(id) {
20061
+ listen();
20062
+ open?.channel?.postMessage({ id, writer: tab });
20063
+ },
20064
+ onAnnounce(handler) {
20065
+ handlers.add(handler);
20066
+ return () => void handlers.delete(handler);
20067
+ },
20068
+ listen,
20069
+ // Signed out, nothing is counted: the store refuses the reads and
20070
+ // writes themselves.
20071
+ async counters(ids) {
20072
+ const who = commitment();
20073
+ return who ? counts.counters(who, ids) : ids.map(() => null);
20074
+ },
20075
+ async begin(id) {
20076
+ const who = commitment();
20077
+ return who ? counts.begin(who, id) : null;
20078
+ },
20079
+ async bump(id) {
20080
+ const who = commitment();
20081
+ return who ? counts.bump(who, id) : null;
20082
+ },
20083
+ async holdMoving(id, shared = false) {
20084
+ const who = commitment();
20085
+ // Signed out: the store refuses the writes themselves. Refused Web
20086
+ // Locks, no tab of the app can hold this lock.
20087
+ if (!who || refused)
20088
+ return () => { };
20089
+ const name = `muhkoo:vfs:${who}:moving:${id}`;
20090
+ // Let go of by this tab, but perhaps not yet by the browser, which
20091
+ // lets go only once the callback below has settled. Asked for
20092
+ // before then - a move or a delete straight after another of the
20093
+ // same entry - it is still held, and this tab refuses itself.
20094
+ const before = leaving.get(name);
20095
+ if (before)
20096
+ await before;
20097
+ return new Promise((resolve, reject) => {
20098
+ let answered = false;
20099
+ const request = locks.request(name, { ifAvailable: true, mode: shared ? "shared" : "exclusive" }, async (lock) => {
20100
+ answered = true;
20101
+ if (!lock) {
20102
+ resolve(null);
20103
+ return;
20104
+ }
20105
+ // Held until this settles: when the function handed back
20106
+ // is called, or when the tab goes and the browser lets go
20107
+ // of everything it held.
20108
+ let held = true;
20109
+ await new Promise((letGo) => resolve(() => {
20110
+ if (!held)
20111
+ return;
20112
+ held = false;
20113
+ // Settles once the browser has let go of it too,
20114
+ // and of every other hold of it this tab has let
20115
+ // go of: two moves into one folder hold it at once.
20116
+ const gone = Promise.all([leaving.get(name), request.then(() => { }, () => { })]).then(() => { });
20117
+ leaving.set(name, gone);
20118
+ void gone.then(() => {
20119
+ if (leaving.get(name) === gone)
20120
+ leaving.delete(name);
20121
+ });
20122
+ letGo();
20123
+ }));
20124
+ });
20125
+ request.catch((err) => {
20126
+ if (answered || !refusesLocks(err)) {
20127
+ reject(err);
20128
+ return;
20129
+ }
20130
+ refused = true;
20131
+ resolve(() => { });
20132
+ });
20133
+ });
20134
+ },
20135
+ close() {
20136
+ open?.channel?.close();
20137
+ open = null;
20138
+ counts.close();
20139
+ },
20140
+ };
20141
+ }
20142
+
18204
20143
  /**
18205
20144
  * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
18206
20145
  * lexicographically-sortable string so the whole offline layer (and the
@@ -18478,35 +20417,66 @@ class ConnectivityManager {
18478
20417
  * mutation by re-invoking the real namespace method (with an `isReplay` flag so
18479
20418
  * it doesn't re-enqueue). `drain()` replays entries in HLC order:
18480
20419
  * - success → remove the entry;
18481
- * - permanent failure (4xx, or a Space whose member list refuses this user)
18482
- * → drop it and surface a `SYNC_ERROR` (a write the server will never
18483
- * accept must not wedge the queue forever);
20420
+ * - refused for payment (`PaymentRequiredError`: the app takes paid plans
20421
+ * only, or someone's payment is past due) → keep it and everything after
20422
+ * it, stop, and surface a `SYNC_ERROR` with `dropped: false`. The server
20423
+ * accepts it once the user or the app owner pays, so it waits for the next
20424
+ * reconnect, or for `client.app.access()` to report the app allowed;
20425
+ * - permanent failure (any other 4xx, or a Space whose member list refuses
20426
+ * this user) → drop it and surface a `SYNC_ERROR` (a write the server will
20427
+ * never accept must not wedge the queue forever);
18484
20428
  * - transient/network failure → stop draining and leave the rest for the next
18485
20429
  * reconnect.
18486
20430
  *
18487
20431
  * Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
18488
20432
  * by `clientId`, file PUTs are content-addressed), so re-running a partially
18489
20433
  * drained queue is safe.
20434
+ *
20435
+ * Every tab of an origin shares the queue. Given a `tryLock`, a drain runs
20436
+ * only while holding `muhkoo:offline-drain:<commitment>`, so two tabs coming
20437
+ * back online replay it once between them, not once each.
18490
20438
  */
18491
20439
  class OutboundQueue {
18492
20440
  store;
20441
+ opts;
18493
20442
  replayers = new Map();
18494
20443
  draining = null;
18495
- constructor(store) {
20444
+ constructor(store, opts = {}) {
18496
20445
  this.store = store;
20446
+ this.opts = opts;
18497
20447
  }
18498
20448
  /** A domain registers how to replay its mutations. */
18499
20449
  register(domain, replayer) {
18500
20450
  this.replayers.set(domain, replayer);
18501
20451
  }
18502
- /** Record a mutation for later replay; resolves with its assigned seq. */
20452
+ /**
20453
+ * Record a mutation for later replay; resolves with its assigned seq.
20454
+ * Stamped with the signed-in user's commitment, so that it is only ever
20455
+ * sent under their session (see {@link waiting}).
20456
+ */
18503
20457
  enqueue(entry) {
18504
- return this.store.enqueue(entry);
20458
+ const who = entry.commitment ?? this.opts.commitment?.();
20459
+ return this.store.enqueue(who ? { ...entry, commitment: who } : entry);
18505
20460
  }
18506
- /** Number of writes still awaiting sync. */
20461
+ /** Number of writes still awaiting sync, every user's. */
18507
20462
  async pending() {
18508
20463
  return (await this.store.queued()).length;
18509
20464
  }
20465
+ /**
20466
+ * The writes a drain would send now, in order: the signed-in user's, and
20467
+ * any an older SDK queued without saying whose they are. Every tab of an
20468
+ * origin shares this queue, and every replayer builds its request from
20469
+ * the session signed in when it runs, so another user's write sent now
20470
+ * would land in this user's space, or overwrite their data. It stays
20471
+ * queued, as it is, until its own user signs in on this device again.
20472
+ */
20473
+ async waiting() {
20474
+ return (await this.store.queued()).filter((entry) => this.sendable(entry));
20475
+ }
20476
+ /** True for an entry this drain may send: the signed-in user's, or one with no user. */
20477
+ sendable(entry) {
20478
+ return !entry.commitment || entry.commitment === this.opts.commitment?.();
20479
+ }
18510
20480
  /**
18511
20481
  * Replay queued mutations in HLC order. Coalesces concurrent calls into a
18512
20482
  * single in-flight drain. Stops on the first transient failure, leaving the
@@ -18515,14 +20485,49 @@ class OutboundQueue {
18515
20485
  drain() {
18516
20486
  if (this.draining)
18517
20487
  return this.draining;
18518
- this.draining = this.runDrain().finally(() => {
20488
+ this.draining = this.drainOnce().finally(() => {
18519
20489
  this.draining = null;
18520
20490
  });
18521
20491
  return this.draining;
18522
20492
  }
20493
+ /**
20494
+ * One drain, holding the device-wide drain lock where there is one. A
20495
+ * tab that finds another tab holding it leaves the queue to that one,
20496
+ * which is replaying it already and removes each entry as it lands - so
20497
+ * a drain after it finds only what it left.
20498
+ */
20499
+ async drainOnce() {
20500
+ const who = this.opts.commitment?.();
20501
+ if (!this.opts.tryLock || !who) {
20502
+ await this.runDrain();
20503
+ return;
20504
+ }
20505
+ await this.opts.tryLock(`muhkoo:offline-drain:${who}`, async () => {
20506
+ // A tab that found this one draining skipped its own drain, and
20507
+ // what it queued after this pass read the queue was left to this
20508
+ // tab. So drain until a pass leaves nothing of this user's; a
20509
+ // transient failure still stops it, by throwing. So does a write
20510
+ // refused for payment, which stays queued: another pass would
20511
+ // only be refused again, at once and without end. Another user's
20512
+ // writes never leave, so counting them would loop for ever too.
20513
+ do {
20514
+ if (!(await this.runDrain()))
20515
+ return;
20516
+ } while ((await this.waiting()).length > 0);
20517
+ });
20518
+ }
20519
+ /**
20520
+ * One pass over the queue. False when a write refused for payment stopped
20521
+ * it, which leaves that write and everything after it queued.
20522
+ */
18523
20523
  async runDrain() {
18524
20524
  const entries = await this.store.queued();
18525
20525
  for (const entry of entries) {
20526
+ // Another user's write, theirs to send under their own session.
20527
+ // Asked per entry, so a sign-out in the middle of a pass stops
20528
+ // the rest of it going out under the next user's session.
20529
+ if (!this.sendable(entry))
20530
+ continue;
18526
20531
  const replayer = this.replayers.get(entry.domain);
18527
20532
  if (!replayer) {
18528
20533
  // No handler registered (namespace not wired) — drop it rather
@@ -18539,6 +20544,22 @@ class OutboundQueue {
18539
20544
  });
18540
20545
  }
18541
20546
  catch (err) {
20547
+ // Asked before `isPermanent`, which would drop it as a 4xx.
20548
+ if (err instanceof PaymentRequiredError) {
20549
+ // The user's plan, or a payment past due: the server will
20550
+ // accept this write once that changes. Keep it, and keep
20551
+ // every write after it in order; a delete queued after an
20552
+ // upload never goes first. Stop rather than send the rest,
20553
+ // which the server would refuse for the same reason.
20554
+ EventCore.emit(EventCoreEvents.SYNC_ERROR, {
20555
+ domain: entry.domain,
20556
+ method: entry.method,
20557
+ error: err.message,
20558
+ code: err.code,
20559
+ dropped: false,
20560
+ });
20561
+ return false;
20562
+ }
18542
20563
  if (isPermanent(err)) {
18543
20564
  await this.store.dequeue(entry.seq);
18544
20565
  EventCore.emit(EventCoreEvents.SYNC_ERROR, {
@@ -18553,12 +20574,14 @@ class OutboundQueue {
18553
20574
  throw err;
18554
20575
  }
18555
20576
  }
20577
+ return true;
18556
20578
  }
18557
20579
  }
18558
20580
  /** A 4xx is the server permanently refusing the write, and so is a Space whose
18559
20581
  * member list does not admit this user (design §4.5.12): the relay refuses
18560
20582
  * their frames. Anything else, a locked session included, is worth retrying
18561
- * on the next reconnect. */
20583
+ * on the next reconnect. A refusal for payment is a 4xx that is not
20584
+ * permanent; `runDrain` keeps it before asking this. */
18562
20585
  function isPermanent(err) {
18563
20586
  if (err instanceof AdmissionDeniedError)
18564
20587
  return true;
@@ -18766,6 +20789,14 @@ const META_NODE_ID = "nodeId";
18766
20789
  * stay decoupled from IndexedDB and the clock.
18767
20790
  */
18768
20791
  const HLC_STATE_KEY = "global";
20792
+ /**
20793
+ * A random suffix for this tab's HLC node id, held in memory only - see
20794
+ * `OfflineManager.boot`. 48 bits: two tabs of one device never draw the same.
20795
+ */
20796
+ function tabSuffix() {
20797
+ const bytes = crypto.getRandomValues(new Uint8Array(6));
20798
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
20799
+ }
18769
20800
  class OfflineManager {
18770
20801
  /** True in browsers with IndexedDB; false when running over a NoopStore. */
18771
20802
  enabled;
@@ -18784,7 +20815,10 @@ class OfflineManager {
18784
20815
  constructor(deps) {
18785
20816
  this.enabled = deps.enabled;
18786
20817
  this.store = deps.store;
18787
- this.queue = new OutboundQueue(deps.store);
20818
+ this.queue = new OutboundQueue(deps.store, {
20819
+ tryLock: deps.locks,
20820
+ commitment: () => deps.session.commitment,
20821
+ });
18788
20822
  this.connectivity = new ConnectivityManager({
18789
20823
  onReconnect: () => void this.sync.run(),
18790
20824
  });
@@ -18820,6 +20854,22 @@ class OfflineManager {
18820
20854
  args: { hash },
18821
20855
  });
18822
20856
  }
20857
+ /**
20858
+ * Replay the queue now, as a reconnect does, if anything is waiting in it.
20859
+ * `client.app.access()` calls this each time it reports the app allowed,
20860
+ * so writes kept after a refusal for payment go out once the user or the
20861
+ * app owner has paid, without waiting for the next reconnect. One drain per
20862
+ * call, never on a timer. With nothing of the signed-in user's queued it
20863
+ * does not drain at all, so the app sees no "syncing". A drain also runs the
20864
+ * catch-up tasks (Space history paging), as a reconnect does.
20865
+ */
20866
+ async resume() {
20867
+ if (!this.enabled)
20868
+ return;
20869
+ if ((await this.queue.waiting()).length === 0)
20870
+ return;
20871
+ await this.sync.run();
20872
+ }
18823
20873
  /** Resolve once the clock + node id are loaded. */
18824
20874
  ready() {
18825
20875
  return this.bootPromise;
@@ -18882,14 +20932,22 @@ class OfflineManager {
18882
20932
  this.hlc = new HLC(this.nodeId);
18883
20933
  return;
18884
20934
  }
18885
- let nodeId = await this.store.get("meta", META_NODE_ID);
18886
- if (!nodeId) {
18887
- nodeId = generateId();
18888
- await this.store.put("meta", META_NODE_ID, nodeId);
18889
- }
18890
- this.nodeId = nodeId;
20935
+ let deviceId = await this.store.get("meta", META_NODE_ID);
20936
+ if (!deviceId) {
20937
+ deviceId = generateId();
20938
+ await this.store.put("meta", META_NODE_ID, deviceId);
20939
+ }
20940
+ // One node id per TAB, under the device's. Every tab of an origin
20941
+ // loads the same stored id, and two tabs stamping with it in the same
20942
+ // millisecond minted identical HLCs: the node id is the tiebreak, and
20943
+ // the server keeps the first of two equal stamps, so the second write
20944
+ // was dropped. The suffix lives in memory only. The clock state below
20945
+ // stays shared, so the clock never goes back across tabs, and stamps
20946
+ // stored already stay valid: every comparison reads a packed stamp as
20947
+ // one string and never parses the node id.
20948
+ this.nodeId = `${deviceId}.${tabSuffix()}`;
18891
20949
  const state = await this.store.get("hlc-state", HLC_STATE_KEY);
18892
- this.hlc = new HLC(nodeId, { state });
20950
+ this.hlc = new HLC(this.nodeId, { state });
18893
20951
  }
18894
20952
  /** Coalesce HLC-state writes — a burst of stamps persists once. */
18895
20953
  schedulePersist() {
@@ -19206,11 +21264,15 @@ class KvCache {
19206
21264
  const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
19207
21265
  await this.store.put("kv-cache", k, mergeRegister(local, incoming));
19208
21266
  }
19209
- /** Non-tombstoned full keys for a commitment (used for offline `list`). */
19210
- async liveKeys(commitment) {
19211
- const prefix = `${commitment}|`;
19212
- const rows = await this.store.prefix("kv-cache", prefix);
19213
- return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(prefix.length));
21267
+ /**
21268
+ * Non-tombstoned full keys for a commitment that start with `prefix` (all
21269
+ * of them by default), for offline `list`. The store scans the key range
21270
+ * `${commitment}|${prefix}`, so other collections are never read.
21271
+ */
21272
+ async liveKeys(commitment, prefix = "") {
21273
+ const scope = `${commitment}|`;
21274
+ const rows = await this.store.prefix("kv-cache", scope + prefix);
21275
+ return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(scope.length));
19214
21276
  }
19215
21277
  /** Queue a kv mutation for replay on reconnect. */
19216
21278
  async enqueue(method, args, hlc, clientId) {
@@ -19357,6 +21419,8 @@ class Client {
19357
21419
  functions;
19358
21420
  /** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
19359
21421
  accessTokens;
21422
+ /** This app — `client.app.access()`: may the signed-in user add data here? */
21423
+ app;
19360
21424
  /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
19361
21425
  offline;
19362
21426
  session;
@@ -19366,13 +21430,17 @@ class Client {
19366
21430
  /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
19367
21431
  recoverInFlight = null;
19368
21432
  constructor(options = {}) {
19369
- // Build stamp — always logged (independent of logLevel) so the running
19370
- // build is identifiable in the console. Catches stale cached bundles
19371
- // during development; bump `VERSION` (and package.json) each build.
19372
- try {
19373
- console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
21433
+ // Build stamp — logged (independent of logLevel) so the running build
21434
+ // is identifiable in the console. Catches stale cached bundles during
21435
+ // development; bump `VERSION` (and package.json) each build.
21436
+ // Browser console only: in Node it lands on stdout, in front of a
21437
+ // CLI's own output (`muhkoo kv ls --json | jq`).
21438
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
21439
+ try {
21440
+ console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
21441
+ }
21442
+ catch { /* console may be unavailable in some runtimes */ }
19374
21443
  }
19375
- catch { /* console may be unavailable in some runtimes */ }
19376
21444
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
19377
21445
  globalThis.appLogger.setLevel(options.logLevel);
19378
21446
  }
@@ -19389,6 +21457,8 @@ class Client {
19389
21457
  store: offlineStore,
19390
21458
  session: this.session,
19391
21459
  enabled: offlineEnabled,
21460
+ // Every tab of the origin shares the queue: one drains it at a time.
21461
+ locks: drainLock(),
19392
21462
  });
19393
21463
  // Wrap fetch so every accelerator round-trip feeds the connectivity
19394
21464
  // detector: a thrown fetch is the strongest "offline" signal, a 2xx the
@@ -19474,12 +21544,13 @@ class Client {
19474
21544
  throw new Error("client.vfs: no session — sign in first.");
19475
21545
  await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
19476
21546
  },
19477
- list: async () => {
21547
+ list: async (prefix) => {
19478
21548
  const c = this.session.commitment;
19479
21549
  if (!c)
19480
21550
  throw new Error("client.vfs: no session — sign in first.");
19481
- const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
19482
- return res?.keys ?? [];
21551
+ // The server filters by prefix and pages. An older server ignores
21552
+ // the prefix and returns every key, which is why callers filter.
21553
+ return listPersonalKeys(this.http, `/api/personal/${encodeURIComponent(c)}/list`, prefix ?? "");
19483
21554
  },
19484
21555
  };
19485
21556
  let contentSpace = null;
@@ -19508,6 +21579,9 @@ class Client {
19508
21579
  this.vfs = new VfsNamespace({
19509
21580
  store: personalStore,
19510
21581
  seed: () => this.session.seed,
21582
+ // Orders writes of a folder with this app's other tabs on the
21583
+ // device - undefined outside a browser tab with Web Locks.
21584
+ coordinator: vfsCoordinator(() => this.session.commitment),
19511
21585
  // Share the personal space's socket with `client.kv` rather than
19512
21586
  // opening a second one to the same Durable Object.
19513
21587
  subscribe: (handler) => this.kv.onRaw(handler),
@@ -19526,12 +21600,15 @@ class Client {
19526
21600
  // the capability path, so it keeps working for a file whose
19527
21601
  // handle was shared from someone else's filesystem.
19528
21602
  read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
19529
- release: async (manifest) => {
19530
- await (await resolveContentSpace()).releaseFile(manifest);
19531
- },
19532
- retain: async (manifest) => {
19533
- await (await resolveContentSpace()).commitFile(manifest);
19534
- },
21603
+ // A reference names only shards, so taking or dropping one needs
21604
+ // no Space. Opening the content space here created it for a user
21605
+ // who had never written a file: a checkout, a copy, a delete, or
21606
+ // Cloud Drive's move of its pre-VFS files. A paid-only app refuses
21607
+ // that creation for a Free user (accelerator spec
21608
+ // 2026-10-06-paid-accounts-and-past-due, D12). Same body as
21609
+ // `Space.commitFile`, so it is the same reference.
21610
+ release: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "release"),
21611
+ retain: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "commit"),
19535
21612
  },
19536
21613
  });
19537
21614
  // Sealed into the SAME personal store as the VFS metadata, so a project
@@ -19600,6 +21677,9 @@ class Client {
19600
21677
  this.agents = new AgentsNamespace({ http: this.http });
19601
21678
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
19602
21679
  this.accessTokens = new AccessTokensNamespace({ http: this.http });
21680
+ // Each "allowed" sends what the offline queue kept after a refusal for
21681
+ // payment, without waiting for the next reconnect.
21682
+ this.app = new AppNamespace({ http: this.http, onAllowed: () => this.offline.resume() });
19603
21683
  // Replay deferred shard uploads on reconnect. The bytes live in the
19604
21684
  // Cache API keyed by hash; re-PUT them through a (defer-free) shard
19605
21685
  // client so a failure surfaces as transient and the entry stays queued.
@@ -19608,6 +21688,7 @@ class Client {
19608
21688
  baseUrl: this.baseUrl,
19609
21689
  pathPrefix: "/api/shards",
19610
21690
  fetch: this.http.fetch,
21691
+ fetchWithRecovery: this.http.fetchWithRecovery,
19611
21692
  cache: this.offline.fileCache,
19612
21693
  });
19613
21694
  this.offline.registerReplayer("file", async (entry) => {
@@ -20019,5 +22100,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
20019
22100
  default: loadRsWasm
20020
22101
  });
20021
22102
 
20022
- export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
22103
+ export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AppNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PaymentRequiredError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
20023
22104
  //# sourceMappingURL=index.js.map