@muhkoo/connect 0.15.0-alpha.3 → 0.15.0-alpha.5

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;
@@ -5261,14 +5361,19 @@ function nonceHexToFieldString(nonceHex) {
5261
5361
  */
5262
5362
  async function parseOrThrow$1(response, label) {
5263
5363
  if (!response.ok) {
5264
- let detail = "";
5364
+ let text = "";
5265
5365
  try {
5266
- const text = await response.text();
5267
- detail = text ? `: ${text}` : "";
5366
+ text = await response.text();
5268
5367
  }
5269
5368
  catch {
5270
5369
  // ignore — we'll just use the status code
5271
5370
  }
5371
+ // A put refused for payment: its own class. Every other failure is the
5372
+ // plain Error it always was.
5373
+ const refused = paymentRequiredFrom(response.status, text);
5374
+ if (refused)
5375
+ throw refused;
5376
+ const detail = text ? `: ${text}` : "";
5272
5377
  throw new Error(`PersonalSpaceClient ${label} failed (${response.status})${detail}`);
5273
5378
  }
5274
5379
  return (await response.json());
@@ -5916,6 +6021,8 @@ class ShardClient {
5916
6021
  baseUrl;
5917
6022
  pathPrefix;
5918
6023
  fetchFn;
6024
+ /** What a PUT goes through: {@link ShardClientOptions.fetchWithRecovery} when given, else {@link fetchFn}. */
6025
+ putFn;
5919
6026
  cache;
5920
6027
  /** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
5921
6028
  pendingShards;
@@ -5940,6 +6047,7 @@ class ShardClient {
5940
6047
  throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
5941
6048
  }
5942
6049
  this.fetchFn = f.bind(globalThis);
6050
+ this.putFn = opts.fetchWithRecovery ?? this.fetchFn;
5943
6051
  this.cache = opts.cache;
5944
6052
  // Coalescing state. `batchSupported` latches false against a deployment
5945
6053
  // without the batch route, so we degrade once rather than per request.
@@ -5983,7 +6091,7 @@ class ShardClient {
5983
6091
  }
5984
6092
  let response;
5985
6093
  try {
5986
- response = await this.fetchFn(url, {
6094
+ response = await this.putFn(url, {
5987
6095
  method: "PUT",
5988
6096
  headers: { "content-type": "application/octet-stream" },
5989
6097
  // BufferSource is what fetch's body expects; a Uint8Array satisfies it.
@@ -6001,7 +6109,10 @@ class ShardClient {
6001
6109
  }
6002
6110
  if (!response.ok) {
6003
6111
  const text = await safeText(response);
6004
- throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6112
+ // Refused for payment: its own class, which the offline queue keeps
6113
+ // and an app can tell from any other failure.
6114
+ throw paymentRequiredFrom(response.status, text)
6115
+ ?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6005
6116
  }
6006
6117
  let dedup = false;
6007
6118
  try {
@@ -6053,14 +6164,15 @@ class ShardClient {
6053
6164
  /** PUT the shard bytes to origin, verifying the server accepted them.
6054
6165
  * Used by the background-upload path; throws on network/HTTP failure. */
6055
6166
  async uploadToOrigin(url, hash, bytes) {
6056
- const response = await this.fetchFn(url, {
6167
+ const response = await this.putFn(url, {
6057
6168
  method: "PUT",
6058
6169
  headers: { "content-type": "application/octet-stream" },
6059
6170
  body: bytes,
6060
6171
  });
6061
6172
  if (!response.ok) {
6062
6173
  const text = await safeText(response);
6063
- throw new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6174
+ throw paymentRequiredFrom(response.status, text)
6175
+ ?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
6064
6176
  }
6065
6177
  }
6066
6178
  /**
@@ -6723,6 +6835,10 @@ async function parseOrThrow(response, op) {
6723
6835
  // non-JSON body
6724
6836
  }
6725
6837
  if (!response.ok) {
6838
+ // A manifest write refused for payment (a gated Space write): its own class.
6839
+ const refused = paymentRequiredFrom(response.status, parsed);
6840
+ if (refused)
6841
+ throw refused;
6726
6842
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6727
6843
  ? String(parsed.error)
6728
6844
  : `${response.status} ${response.statusText}`;
@@ -6739,7 +6855,7 @@ async function parseOrThrow(response, op) {
6739
6855
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6740
6856
  * `package.json` `version`.
6741
6857
  */
6742
- const VERSION = "0.15.0-alpha.3";
6858
+ const VERSION = "0.15.0-alpha.5";
6743
6859
 
6744
6860
  /**
6745
6861
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -6784,17 +6900,6 @@ function reqPath(input) {
6784
6900
  return "?";
6785
6901
  }
6786
6902
  }
6787
- /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
6788
- class HttpError extends Error {
6789
- status;
6790
- body;
6791
- constructor(message, status, body) {
6792
- super(message);
6793
- this.status = status;
6794
- this.body = body;
6795
- this.name = "HttpError";
6796
- }
6797
- }
6798
6903
  class HttpClient {
6799
6904
  baseUrl;
6800
6905
  /** Origin of {@link baseUrl} — credentials are only attached same-origin. */
@@ -6923,7 +7028,9 @@ class HttpClient {
6923
7028
  const msg = parsed && typeof parsed === "object" && "error" in parsed
6924
7029
  ? String(parsed.error)
6925
7030
  : `${res.status} ${res.statusText}`;
6926
- throw new HttpError(msg, res.status, parsed);
7031
+ // A refusal for payment is its own class, so an app can tell it from
7032
+ // any other 4xx; every other error is the HttpError it always was.
7033
+ throw paymentRequiredFrom(res.status, parsed) ?? new HttpError(msg, res.status, parsed);
6927
7034
  }
6928
7035
  return parsed;
6929
7036
  }
@@ -9667,6 +9774,7 @@ class StorageNamespace {
9667
9774
  baseUrl: this.deps.baseUrl,
9668
9775
  pathPrefix: "/api/shards",
9669
9776
  fetch: this.deps.http.fetch,
9777
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
9670
9778
  cache: this.deps.shardCache,
9671
9779
  deferUpload: this.deps.deferShardUpload,
9672
9780
  });
@@ -9685,6 +9793,59 @@ class StorageNamespace {
9685
9793
  }
9686
9794
  }
9687
9795
 
9796
+ /**
9797
+ * `POST /api/storage/commit` and `/api/storage/release`: take or drop a
9798
+ * reference to a manifest's shards.
9799
+ *
9800
+ * The commit is what meters a file's bytes to the app and reference-counts
9801
+ * its shards, so garbage collection keeps them; the release drops that
9802
+ * reference. The body is `{ hashes, bytes }` and nothing else. The accelerator
9803
+ * keys its reference ledger on the caller, the app, the bytes and the sorted
9804
+ * hashes, so this body must not change: a commit sent in another shape would
9805
+ * take a second reference instead of finding the first.
9806
+ *
9807
+ * Shared by `Space` (`putFile`, `commitFile`, `releaseFile`) and by
9808
+ * `client.vfs`, which takes and drops references here directly. A reference
9809
+ * names only shards and needs no Space; opening the content space to take one
9810
+ * created that space for a user who had never written a file, which a
9811
+ * paid-only app refuses for a Free user (accelerator spec
9812
+ * 2026-10-06-paid-accounts-and-past-due, D12).
9813
+ *
9814
+ * Internal: not re-exported from any barrel.
9815
+ */
9816
+ /**
9817
+ * Commit or release `manifest`'s shards through `fetchFn`, which should be
9818
+ * `HttpClient.fetchWithRecovery`, so a dead session is re-established once
9819
+ * rather than refused three times. Best-effort: tries three times, or once
9820
+ * for a 401, then warns and returns, without failing the caller's write.
9821
+ */
9822
+ async function commitManifest(fetchFn, httpBaseUrl, manifest, action) {
9823
+ const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
9824
+ if (hashes.length === 0)
9825
+ return;
9826
+ const body = JSON.stringify({ hashes, bytes: manifest.size });
9827
+ for (let attempt = 0; attempt < 3; attempt++) {
9828
+ try {
9829
+ const res = await fetchFn(`${httpBaseUrl}/api/storage/${action}`, {
9830
+ method: "POST", headers: { "Content-Type": "application/json" }, body,
9831
+ });
9832
+ if (res.ok)
9833
+ return;
9834
+ // A 401 that `fetchFn`'s recovery could not fix. Another try
9835
+ // would only run that recovery again (`auth.zk.recover()`, and
9836
+ // `onSessionExpired` for the app) and be refused again.
9837
+ if (res.status === 401)
9838
+ break;
9839
+ }
9840
+ catch { /* retry */ }
9841
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
9842
+ }
9843
+ // Best-effort: a missed commit leaves the shards to orphan-GC (still not
9844
+ // durable-unmetered long-term); a missed release just delays GC. Reconciled
9845
+ // by the platform sweep. Don't fail the caller's write on a metering hiccup.
9846
+ console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
9847
+ }
9848
+
9688
9849
  /**
9689
9850
  * SpaceCipher — pure crypto for the fan-out group-encryption layer.
9690
9851
  *
@@ -11068,25 +11229,10 @@ class Space {
11068
11229
  }
11069
11230
  /** Commit/release a manifest's shards with the accelerator (metering + refcount). */
11070
11231
  async commitShards(manifest, action) {
11071
- const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
11072
- if (hashes.length === 0)
11073
- return;
11074
- const body = JSON.stringify({ hashes, bytes: manifest.size });
11075
- for (let attempt = 0; attempt < 3; attempt++) {
11076
- try {
11077
- const res = await this.deps.fetch(`${this.deps.httpBaseUrl}/api/storage/${action}`, {
11078
- method: "POST", headers: { "Content-Type": "application/json" }, body,
11079
- });
11080
- if (res.ok)
11081
- return;
11082
- }
11083
- catch { /* retry */ }
11084
- await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
11085
- }
11086
- // Best-effort: a missed commit leaves the shards to orphan-GC (still not
11087
- // durable-unmetered long-term); a missed release just delays GC. Reconciled
11088
- // by the platform sweep. Don't fail the caller's write on a metering hiccup.
11089
- console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
11232
+ // Through the client's 401 recovery when there is one: the commit refuses
11233
+ // a dead session with 401, and three tries with the same token only
11234
+ // delayed the warning.
11235
+ await commitManifest(this.deps.fetchWithRecovery ?? this.deps.fetch, this.deps.httpBaseUrl, manifest, action);
11090
11236
  }
11091
11237
  fileStorage(backgroundUpload = false) {
11092
11238
  // Global, content-addressed shard store — same store `client.storage`
@@ -11097,6 +11243,7 @@ class Space {
11097
11243
  baseUrl: this.deps.httpBaseUrl,
11098
11244
  pathPrefix: "/api/shards",
11099
11245
  fetch: this.deps.fetch,
11246
+ fetchWithRecovery: this.deps.fetchWithRecovery,
11100
11247
  cache: this.deps.shardCache,
11101
11248
  deferUpload: this.deps.deferShardUpload,
11102
11249
  peers: this.peerNet?.exchange,
@@ -11500,6 +11647,7 @@ class MessageNamespace {
11500
11647
  wsBaseUrl: this.deps.wsBaseUrl,
11501
11648
  httpBaseUrl: this.deps.http.baseUrl,
11502
11649
  fetch: this.deps.http.fetch,
11650
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
11503
11651
  myId: () => this.myId(),
11504
11652
  fetchTicket: () => this.fetchTicket(),
11505
11653
  });
@@ -13821,6 +13969,7 @@ class SpaceNamespace {
13821
13969
  wsBaseUrl: this.deps.wsBaseUrl,
13822
13970
  httpBaseUrl: this.deps.http.baseUrl,
13823
13971
  fetch: this.deps.http.fetch,
13972
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13824
13973
  myId: () => memberId,
13825
13974
  fetchTicket: () => this.fetchTicket(),
13826
13975
  historyPolicy,
@@ -13876,6 +14025,7 @@ class SpaceNamespace {
13876
14025
  wsBaseUrl: this.deps.wsBaseUrl,
13877
14026
  httpBaseUrl: this.deps.http.baseUrl,
13878
14027
  fetch: this.deps.http.fetch,
14028
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
13879
14029
  myId: () => memberId,
13880
14030
  fetchTicket: () => this.fetchTicket(),
13881
14031
  keyring,
@@ -14251,6 +14401,66 @@ class AccessTokensNamespace {
14251
14401
  }
14252
14402
  }
14253
14403
 
14404
+ /**
14405
+ * `client.app` — what the signed-in user may do in this app (accelerator spec
14406
+ * 2026-10-06-paid-accounts-and-past-due, B6 and B8).
14407
+ *
14408
+ * An app's owner can turn on "Paid Muhkoo accounts only" in the portal. The
14409
+ * accelerator then refuses a Free user's writes that add data, with a 402 the
14410
+ * SDK throws as `PaymentRequiredError`. Reads, sign-in and deletes still work.
14411
+ * The same refusal reaches every user of an app whose owner's payment is past
14412
+ * due, whether or not the setting is on.
14413
+ *
14414
+ * `access()` asks up front, so an app can show the right screen before the
14415
+ * first refusal.
14416
+ */
14417
+ class AppNamespace {
14418
+ deps;
14419
+ constructor(deps) {
14420
+ this.deps = deps;
14421
+ }
14422
+ /**
14423
+ * Whether the signed-in user may add data in this app and, if not, why
14424
+ * and where to send them. Needs the app key and a session; a stale session
14425
+ * is re-established once, as for every request. The route is never
14426
+ * refused for payment. Throws `HttpError` for anything else, among them a
14427
+ * 401 without a session and a 404 from an accelerator older than the
14428
+ * route.
14429
+ *
14430
+ * Each `allowed: true` also sends any writes the offline queue kept after
14431
+ * a refusal for payment, without waiting for them. Call it after sign-in,
14432
+ * and again when the user comes back from the portal.
14433
+ */
14434
+ async access() {
14435
+ const body = await this.deps.http.get("/api/app/access");
14436
+ const access = {
14437
+ paidAccountsOnly: body?.paidAccountsOnly === true,
14438
+ // Fail open on a malformed answer: the server enforces the gate
14439
+ // whatever this says, so a wrong "allowed" costs a refused write,
14440
+ // and a wrong "not allowed" would lock the user out for nothing.
14441
+ allowed: body?.allowed !== false,
14442
+ };
14443
+ if (!access.allowed) {
14444
+ if (isPaymentRequiredCode(body?.reason))
14445
+ access.reason = body.reason;
14446
+ const upgradeUrl = absoluteHttpUrl(body?.upgradeUrl);
14447
+ if (upgradeUrl)
14448
+ access.upgradeUrl = upgradeUrl;
14449
+ }
14450
+ else if (this.deps.onAllowed) {
14451
+ // A drain that fails, or a callback that throws, must not turn a
14452
+ // good answer into an error: the writes stay queued for the next
14453
+ // reconnect.
14454
+ Promise.resolve()
14455
+ .then(() => this.deps.onAllowed())
14456
+ .catch((err) => {
14457
+ console.warn("[client.app] sending the kept writes failed (they stay queued):", err);
14458
+ });
14459
+ }
14460
+ return access;
14461
+ }
14462
+ }
14463
+
14254
14464
  /**
14255
14465
  * POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
14256
14466
  * answers a deterministic decoy, so a lookup never says whether an account
@@ -15205,21 +15415,25 @@ function nextTurn() {
15205
15415
  *
15206
15416
  * Every mutation is a read-modify-write of exactly ONE directory record, so a
15207
15417
  * write cannot leave the tree half-updated: either the parent names the new
15208
- * child or it does not. Two tabs writing different directories never conflict;
15209
- * two tabs (or devices) writing the SAME directory are last-writer-wins. A VFS
15210
- * write sends no HLC and the personal space has no compare-and-swap: it applies
15211
- * puts of one key in the order they arrive, so whichever arrives last is kept,
15212
- * and the other tab's edit is lost. Orphaned records (a directory whose parent
15213
- * entry lost the race) are unreachable rather than corrupting — they cost
15214
- * storage until a future sweep, which is the trade this shape makes in
15418
+ * child or it does not. A VFS write sends no HLC and the personal space has
15419
+ * no compare-and-swap: of two writes of one key it keeps whichever arrives
15420
+ * last.
15421
+ *
15422
+ * Within ONE namespace, writes of a record are sent one at a time, in the
15423
+ * order they were made - edits made while one is in flight go out together
15424
+ * in the next (see `queue`) - and a read that has to fetch a record waits for
15425
+ * its write in flight (see `writing`). Every edit of a record is made to one
15426
+ * in-memory copy of it, and that copy is what is written (see `mutate`).
15427
+ *
15428
+ * Between the tabs of an app on one device, a coordinator (`VfsCoordinator`;
15429
+ * `Client` wires one in a browser tab) gives each record one lock, and a tab
15430
+ * reads a record another tab has written again before its next write of it
15431
+ * (see `sendLocked`). Between devices - or a browser and the CLI - two writes
15432
+ * of the same directory are still last-writer-wins, and the other edit is
15433
+ * lost: that takes a compare-and-swap at the server. Orphaned records (a
15434
+ * directory whose parent entry lost the race) are unreachable rather than
15435
+ * corrupting - they cost storage until a sweep, the trade this shape makes in
15215
15436
  * exchange for single-record writes.
15216
- *
15217
- * Within ONE tab, writes of a record are queued and sent one at a time, in the
15218
- * order they were made, and a read that has to fetch a record waits for its
15219
- * write in flight (see `writing`). Every edit of a record is made to one
15220
- * in-memory copy of it, and that copy is what is written (see `mutate`). All
15221
- * of this is per namespace instance only; nothing orders writes across tabs
15222
- * or devices - that would take a compare-and-swap at the server.
15223
15437
  */
15224
15438
  /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
15225
15439
  function assertWritableDepth(path) {
@@ -15449,6 +15663,24 @@ function markedMeanwhile(before, now) {
15449
15663
  function changedMeanwhile(path) {
15450
15664
  return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
15451
15665
  }
15666
+ /**
15667
+ * Refuse an operation on `path` because this namespace is moving it - or,
15668
+ * `inside`, something in it. See `VfsNamespace.inMotion`.
15669
+ */
15670
+ function beingMoved(path, inside = false) {
15671
+ const what = inside ? `something in ${path} is` : `${path} is`;
15672
+ return new VfsConflictError(`${what} being moved; nothing was changed - try again once the move has finished`);
15673
+ }
15674
+ /**
15675
+ * Refuse an operation on `path` because a move or a delete of it - or,
15676
+ * `inside`, of it or of something in it - holds its move lock on this
15677
+ * device: in another tab of the app, or in this one. See
15678
+ * `VfsCoordinator.holdMoving`.
15679
+ */
15680
+ function heldElsewhere(path, inside = false) {
15681
+ const what = inside ? `${path}, or something in it, is` : `${path} is`;
15682
+ 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`);
15683
+ }
15452
15684
  /** A JSON object, as opposed to null, an array or a scalar. */
15453
15685
  function isObject$1(value) {
15454
15686
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -15606,6 +15838,13 @@ function followable(dir) {
15606
15838
  function unfollowable(dir) {
15607
15839
  return Object.values(dir.hidden).filter((value) => !readableEntry(value)).length;
15608
15840
  }
15841
+ /** The id of every entry `dir` follows, and whether an entry with that id is marked: `SubtreeShape`'s view of it. */
15842
+ function namedIn(dir) {
15843
+ const named = new Map();
15844
+ for (const [, entry] of followable(dir))
15845
+ named.set(entry.id, named.get(entry.id) === true || entry.moving === true);
15846
+ return named;
15847
+ }
15609
15848
  class VfsNamespace {
15610
15849
  deps;
15611
15850
  rootKey = null;
@@ -15648,6 +15887,81 @@ class VfsNamespace {
15648
15887
  * `openDirById`.
15649
15888
  */
15650
15889
  writing = new Map();
15890
+ /**
15891
+ * Every entry this namespace is moving right now, by id.
15892
+ *
15893
+ * A move between two directories marks the entry before it puts it in
15894
+ * the second (see `rename`), and a mark is all a later operation can
15895
+ * see. It cannot tell a move still writing from one that was
15896
+ * interrupted, and was taken for the second: a delete that began after
15897
+ * a move had marked a folder looked for the folder's other half, found
15898
+ * none yet, and purged it while the move was putting it in its new
15899
+ * place; a second move of a file finished the "interrupted" move and
15900
+ * left it named in two places. So a move holds its entry here until it
15901
+ * has taken it from where it was, and `rename` and `delete` refuse an
15902
+ * entry that is here - or, for a delete, a folder holding one. A delete
15903
+ * holds what it deletes the same way: see `deleting`.
15904
+ *
15905
+ * This set is one namespace's. With a coordinator, a move also holds
15906
+ * the entry's move lock for every tab on the device, for as long as the
15907
+ * entry is here: see `VfsCoordinator.holdMoving`.
15908
+ */
15909
+ inMotion = new Set();
15910
+ /**
15911
+ * What each delete under way in this namespace is deleting, by entry id,
15912
+ * one set per delete: its target from the moment it has found the target
15913
+ * not in `inMotion`, and everything it read below the target once it has
15914
+ * read it. Each delete lets go of its own set when it is done.
15915
+ *
15916
+ * A delete waits after it has read what it will let go of: on more
15917
+ * reads, and on a read of the whole tree for the other half of a move.
15918
+ * A move of any of it that started meanwhile could put it in its new
15919
+ * place, fail to take it from the old, and end, all before the delete's
15920
+ * detach. Ended, it was no longer in `inMotion`; and an entry that was
15921
+ * marked already showed no change. So the delete let go of what the
15922
+ * move had just named somewhere else. Now `rename` refuses an entry that
15923
+ * is here, or one on a way from the root that passes through a folder
15924
+ * that is: everything below a delete's target, read yet or not.
15925
+ */
15926
+ deleting = new Set();
15927
+ /**
15928
+ * The write of each record that has not gone out yet, by id: the one
15929
+ * the next edit of that record joins. See `queue`.
15930
+ */
15931
+ waiting = new Map();
15932
+ /**
15933
+ * Announcements heard from other tabs, counted. `heardAt` is the count
15934
+ * at the last one for each record, `missedAt` the count when some may
15935
+ * have been missed. A copy read after both holds every write announced:
15936
+ * see `current`.
15937
+ */
15938
+ hearing = 0;
15939
+ missedAt = 0;
15940
+ heardAt = new Map();
15941
+ /**
15942
+ * Each record a write of which this namespace marked as begun and could
15943
+ * not count, and has said so, until one is counted. And whether it has
15944
+ * said that a write it could not mark or count may be lost, which it
15945
+ * says only once. See `notCounted`.
15946
+ */
15947
+ uncounted = new Set();
15948
+ lossWarned = false;
15949
+ /**
15950
+ * The copy of each record this namespace is writing under the record's
15951
+ * lock, by id, from when it has decided what to write until it has
15952
+ * counted the write, or failed to. Its counter reads as unknown
15953
+ * meanwhile, as this write has begun and is not counted yet, but the
15954
+ * copy holds every write of the record another tab has counted, and no
15955
+ * other tab can make one until this one lets go of the lock. See
15956
+ * `dropStale`.
15957
+ */
15958
+ locked = new Map();
15959
+ /**
15960
+ * Each directory a `createDir` here has named whose write has not landed
15961
+ * yet, by entry id: a promise that settles - and never rejects - once
15962
+ * that write has landed or failed. See `mkdir`.
15963
+ */
15964
+ naming = new Map();
15651
15965
  historyLimit;
15652
15966
  maxEntries;
15653
15967
  maxPathChars;
@@ -15656,6 +15970,8 @@ class VfsNamespace {
15656
15970
  this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
15657
15971
  this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
15658
15972
  this.maxPathChars = deps.limits?.pathChars ?? DEFAULT_MAX_PATH_CHARS;
15973
+ // Another tab's write of a record leaves this tab's copy of it out of date: see `heard`.
15974
+ deps.coordinator?.onAnnounce((id) => this.heard(id));
15659
15975
  }
15660
15976
  /**
15661
15977
  * The working directory relative paths resolve against.
@@ -15708,6 +16024,7 @@ class VfsNamespace {
15708
16024
  // stays out of the cache from here on: see `generation`.
15709
16025
  this.generation++;
15710
16026
  this.cache.clear();
16027
+ this.heardAt.clear();
15711
16028
  this.rootKey = null;
15712
16029
  this._cwd = "/"; // a different identity's tree has different directories
15713
16030
  }
@@ -16055,8 +16372,22 @@ class VfsNamespace {
16055
16372
  const name = parts[i];
16056
16373
  const isLast = i === parts.length - 1;
16057
16374
  const next = join(current, name);
16058
- const dir = await this.openDir(current);
16059
- const existing = entryIn(dir.node, name);
16375
+ let dir = await this.openDir(current);
16376
+ let existing = entryIn(dir.node, name);
16377
+ // Named by a mkdir here whose write has not landed. Until it has,
16378
+ // the name is a promise, not a fact: with a coordinator, that edit
16379
+ // is made again under the lock to the folder as another tab left
16380
+ // it, and comes to nothing when that tab named a directory there
16381
+ // first - and whatever was saved meanwhile into the one named
16382
+ // here is in a directory nothing names. A failed write drops it
16383
+ // too. So wait for the write, and look again.
16384
+ let unwritten = existing && this.naming.get(existing.id);
16385
+ while (unwritten) {
16386
+ await unwritten;
16387
+ dir = await this.openDir(current);
16388
+ existing = entryIn(dir.node, name);
16389
+ unwritten = existing && this.naming.get(existing.id);
16390
+ }
16060
16391
  if (!existing)
16061
16392
  assertNotHidden(dir, name, next);
16062
16393
  if (existing) {
@@ -16182,89 +16513,219 @@ class VfsNamespace {
16182
16513
  this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
16183
16514
  return;
16184
16515
  }
16185
- // Every directory above the target, the root first. None of them is
16186
- // part of what is being deleted, whatever an entry below says.
16187
- const above = new Set(opened.chain);
16188
- // Three steps, in this order: read everything the delete will touch,
16189
- // detach the entry, then let go of what it held.
16190
- //
16191
- // Letting go came first once, and the entry was detached last. Any
16192
- // failure in between - a network error, most simply - left the entry
16193
- // in place with some of its content already released, and the obvious
16194
- // next step, trying again, released that content a SECOND time.
16195
- // Content is reference-counted and shared by copies, so that freed
16196
- // bytes another file still used. Now a failure before the detach has
16197
- // changed nothing, and nothing after it can fail: releasing is
16198
- // best-effort, and a leak is the safe way to be wrong.
16199
- let doomed = [];
16200
- // Whether any of it is half of an interrupted move. See below.
16201
- let moving = entry.kind === "file" && entry.moving === true;
16202
- if (entry.kind === "dir" && above.has(entry.id)) {
16203
- // The entry IS a pointer to a directory above it - the root, or
16204
- // this entry's own parent. Removing the pointer is what was asked
16205
- // for; clearing out what it points at would delete everything
16206
- // around the target, the target included.
16207
- if (!opts.recursive)
16208
- throw new VfsConflictError(`${target} is not empty`);
16209
- this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
16210
- }
16211
- else if (entry.kind === "dir") {
16212
- const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
16213
- // An unreadable directory must still be removable, or the user
16214
- // is stuck with an error they can do nothing about. Its subtree
16215
- // cannot be enumerated, so it is detached rather than purged and
16216
- // whatever it holds is left for sweep(). Only when asked for
16217
- // recursively: without that we cannot know it is empty.
16218
- if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
16219
- throw err;
16220
- this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
16221
- return null;
16222
- });
16223
- if (child) {
16224
- // Entries this version cannot read still count: calling the
16225
- // directory empty would delete them along with it.
16226
- const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
16227
- if (!empty && !opts.recursive)
16516
+ // Being moved by this namespace: it is marked already, or about to be,
16517
+ // and on its way to a second place. Letting go of it here would let
16518
+ // go of what the move puts there. See `inMotion`.
16519
+ if (this.inMotion.has(entry.id))
16520
+ throw beingMoved(target);
16521
+ // And from here until it is done, a move of it, or of anything below
16522
+ // it, that begins in this namespace is refused. See `deleting`.
16523
+ const claim = new Set([entry.id]);
16524
+ this.deleting.add(claim);
16525
+ // On this device, the move locks of the target and of everything read
16526
+ // below it. See below.
16527
+ let release = null;
16528
+ try {
16529
+ // Every directory above the target, the root first. None of them is
16530
+ // part of what is being deleted, whatever an entry below says.
16531
+ const above = new Set(opened.chain);
16532
+ // Three steps, in this order: read everything the delete will touch,
16533
+ // detach the entry, then let go of what it held.
16534
+ //
16535
+ // Letting go came first once, and the entry was detached last. Any
16536
+ // failure in between - a network error, most simply - left the entry
16537
+ // in place with some of its content already released, and the obvious
16538
+ // next step, trying again, released that content a SECOND time.
16539
+ // Content is reference-counted and shared by copies, so that freed
16540
+ // bytes another file still used. Now a failure before the detach has
16541
+ // changed nothing, and nothing after it can fail: releasing is
16542
+ // best-effort, and a leak is the safe way to be wrong.
16543
+ let doomed = [];
16544
+ // What each of them named when it was read, for the check before the detach.
16545
+ let shape = new Map();
16546
+ // Every id found below the target: claimed once it has all been read,
16547
+ // re-checked against `inMotion` at the detach and, with a coordinator,
16548
+ // whose move locks it holds. See below.
16549
+ let ids = new Set();
16550
+ // Whether any of it is half of an interrupted move. See below.
16551
+ let moving = entry.kind === "file" && entry.moving === true;
16552
+ if (entry.kind === "dir" && above.has(entry.id)) {
16553
+ // The entry IS a pointer to a directory above it - the root, or
16554
+ // this entry's own parent. Removing the pointer is what was asked
16555
+ // for; clearing out what it points at would delete everything
16556
+ // around the target, the target included.
16557
+ if (!opts.recursive)
16228
16558
  throw new VfsConflictError(`${target} is not empty`);
16229
- const found = await this.subtree(child, target, above);
16230
- doomed = found.dirs;
16231
- moving = entry.moving === true || found.moving;
16559
+ this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
16560
+ }
16561
+ else if (entry.kind === "dir") {
16562
+ const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
16563
+ // An unreadable directory must still be removable, or the user
16564
+ // is stuck with an error they can do nothing about. Its subtree
16565
+ // cannot be enumerated, so it is detached rather than purged and
16566
+ // whatever it holds is left for sweep(). Only when asked for
16567
+ // recursively: without that we cannot know it is empty.
16568
+ if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
16569
+ throw err;
16570
+ this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
16571
+ return null;
16572
+ });
16573
+ if (child) {
16574
+ // Entries this version cannot read still count: calling the
16575
+ // directory empty would delete them along with it.
16576
+ const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
16577
+ if (!empty && !opts.recursive)
16578
+ throw new VfsConflictError(`${target} is not empty`);
16579
+ const found = await this.subtree(child, target, above);
16580
+ // Something in it is being moved by this namespace: the same
16581
+ // as the target being moved, a level down.
16582
+ for (const id of found.ids)
16583
+ if (this.inMotion.has(id))
16584
+ throw beingMoved(target, true);
16585
+ for (const id of found.ids)
16586
+ claim.add(id);
16587
+ doomed = found.dirs;
16588
+ shape = found.shape;
16589
+ ids = found.ids;
16590
+ moving = entry.moving === true || found.moving;
16591
+ }
16592
+ }
16593
+ // On this device, the move lock of the target and of everything read
16594
+ // below it, held from here until what they held has been let go of.
16595
+ // A move in another tab holds the lock of what it moves until it has
16596
+ // taken it from where it was. A delete that found it marked looked
16597
+ // for its other half before the move had put it there, found none,
16598
+ // and let go of what the move then named in its new place; one that
16599
+ // was writing the parent let a move of something below start, and
16600
+ // then purged it. Held here, a move of any of it that is under way
16601
+ // refuses this delete, and none can start until it is done. What it
16602
+ // does not cover - anything named below since it was read - the check
16603
+ // in the edit refuses. See `VfsCoordinator.holdMoving`.
16604
+ //
16605
+ // Taken before the look for the other half of a move below, not
16606
+ // after it: a move in another tab that began during that look could
16607
+ // put the entry in its new place, fail to take it from the old, and
16608
+ // let go of its lock, all before this took it - and what it named
16609
+ // was let go of here. Not before the reads above, which find what is
16610
+ // below. A move in another tab that begins during them still holds
16611
+ // its lock here if it is under way, and this delete is refused;
16612
+ // ended, it came before the look and the checks below, which read
16613
+ // again what it wrote. See below.
16614
+ const coordinator = this.deps.coordinator;
16615
+ // What the look and the checks below must read again.
16616
+ let stale = new Set();
16617
+ if (coordinator) {
16618
+ release = await this.holdAll(coordinator, [entry.id, ...ids]);
16619
+ if (!release)
16620
+ throw heldElsewhere(target, ids.size > 0);
16621
+ // A move that let go of its lock just before this took it had
16622
+ // counted its writes by then, though this tab may not have heard
16623
+ // of them: what was read above that another tab has written
16624
+ // since is read again - every record cached, for the look, which
16625
+ // reads the whole tree. A move that took a file out of a folder
16626
+ // read above, from this tab's old copy, had that file let go of
16627
+ // with the folder; one that went from the other half of a move
16628
+ // to a folder this tab's old copy did not show it in had the
16629
+ // content it took with it let go of. See `dropStale`.
16630
+ //
16631
+ // And the way to the target, looked up before anything else.
16632
+ // A delete in another tab of a folder on it had let go of the
16633
+ // target's lock, which it held, only once it had counted its
16634
+ // write of the folder above the one it deleted - but it purged
16635
+ // that one, and all below, which counts nothing. Gone on from
16636
+ // the old copies, this wrote the purged parent back and let go
16637
+ // of what the target held a second time, while a copy of it, or
16638
+ // a commit, still named that content. So what on the way has
16639
+ // been written since it was read is read again, and the way is
16640
+ // looked up again, as a move's way to where it goes is: it must
16641
+ // lead to the same entry, through the same folders, not marked
16642
+ // since.
16643
+ //
16644
+ // Looked up again even when nothing on it is out of date now:
16645
+ // a write of this tab's own may have read a folder on it again,
16646
+ // under that folder's lock, after the look above, and counted
16647
+ // its write. That copy is current by now, and no longer what the
16648
+ // look saw.
16649
+ const read = doomed.map((dir) => dir.id);
16650
+ stale = await this.dropStale(coordinator, [...opened.chain, ...(moving ? this.cache.keys() : []), ...read]);
16651
+ const again = await this.openChain(dirname(target));
16652
+ if (!again)
16653
+ throw new VfsNotFoundError(dirname(target));
16654
+ if (again.chain.some((id, i) => id !== opened.chain[i]))
16655
+ throw changedMeanwhile(dirname(target));
16656
+ const now = entryIn(again.dir.node, name);
16657
+ if (!now)
16658
+ throw new VfsNotFoundError(target);
16659
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16660
+ throw changedMeanwhile(target);
16661
+ }
16662
+ // A move between two directories that was interrupted leaves what it
16663
+ // moved named in both, and letting go of it here would take it from
16664
+ // the other copy: the directory record purged with every file in it,
16665
+ // or a file's content and history released. Only a marked entry can
16666
+ // be one of those (see `rename`), so only then is the whole tree
16667
+ // read, once, to find out what is still named elsewhere. Still before
16668
+ // anything changes: a failure here leaves the tree as it was.
16669
+ const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16670
+ // The check in the edit below looks at each record to be purged as it
16671
+ // is then, which needs it to hand: a change reported meanwhile may
16672
+ // have dropped one from the cache, and so may the counters above.
16673
+ // Read again, it is still checked against what was read before.
16674
+ for (const dir of doomed)
16675
+ if (stale.has(dir.id) || !this.editable(dir))
16676
+ await this.reopen(dir);
16677
+ let purging = doomed;
16678
+ await this.mutate(parent, (node) => {
16679
+ // What is let go of below is what `entry` held. Gone since, it
16680
+ // was let go of already; replaced, the name holds something this
16681
+ // delete has not read. Marked since, a move of it has begun, and
16682
+ // what it holds is about to be named somewhere `elsewhere` did
16683
+ // not see - if it was looked for at all.
16684
+ const now = entryIn(node, name);
16685
+ if (!now)
16686
+ throw new VfsNotFoundError(target);
16687
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16688
+ throw changedMeanwhile(target);
16689
+ // Marked already when this delete read it, a move that has since
16690
+ // taken the entry - or something below it - would not mark it a
16691
+ // second time, so `markedMeanwhile` and `currentSubtree` would not
16692
+ // see it either. `inMotion` is live, not a snapshot, so it still
16693
+ // does: re-checked here, at the last moment before the detach.
16694
+ // Since `deleting`, a move here cannot begin on any of it once
16695
+ // this delete has claimed it, so these are a second guard.
16696
+ if (this.inMotion.has(entry.id))
16697
+ throw beingMoved(target);
16698
+ for (const id of ids)
16699
+ if (this.inMotion.has(id))
16700
+ throw beingMoved(target, true);
16701
+ // And what is purged is what the records below hold now - unless
16702
+ // something in them has been moved, or begun to move, since they
16703
+ // were read; or, with a coordinator, named there since, as its
16704
+ // move lock is not held. See `currentSubtree`.
16705
+ const current = this.currentSubtree(doomed, shape, coordinator ? ids : undefined);
16706
+ if (!current)
16707
+ throw changedMeanwhile(target);
16708
+ purging = current;
16709
+ delete node.entries[name];
16710
+ });
16711
+ if (elsewhere === null) {
16712
+ // Part of the tree cannot be read, so it is unknown whether the
16713
+ // other copy is in it. Keeping what may be its contents costs
16714
+ // storage until a sweep; letting go of them could lose them.
16715
+ 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`);
16716
+ return;
16232
16717
  }
16718
+ if (entry.kind === "dir")
16719
+ await this.purge(purging, elsewhere, opts.keepContent);
16720
+ else
16721
+ await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
16722
+ if (elsewhere && entry.moving === true)
16723
+ await this.settle(entry.id, elsewhere);
16233
16724
  }
16234
- // A move between two directories that was interrupted leaves what it
16235
- // moved named in both, and letting go of it here would take it from
16236
- // the other copy: the directory record purged with every file in it,
16237
- // or a file's content and history released. Only a marked entry can
16238
- // be one of those (see `rename`), so only then is the whole tree
16239
- // read, once, to find out what is still named elsewhere. Still before
16240
- // anything changes: a failure here leaves the tree as it was.
16241
- const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16242
- await this.mutate(parent, (node) => {
16243
- // What is let go of below is what `entry` held. Gone since, it
16244
- // was let go of already; replaced, the name holds something this
16245
- // delete has not read. Marked since, a move of it has begun, and
16246
- // what it holds is about to be named somewhere `elsewhere` did
16247
- // not see - if it was looked for at all.
16248
- const now = entryIn(node, name);
16249
- if (!now)
16250
- throw new VfsNotFoundError(target);
16251
- if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16252
- throw changedMeanwhile(target);
16253
- delete node.entries[name];
16254
- });
16255
- if (elsewhere === null) {
16256
- // Part of the tree cannot be read, so it is unknown whether the
16257
- // other copy is in it. Keeping what may be its contents costs
16258
- // storage until a sweep; letting go of them could lose them.
16259
- 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`);
16260
- return;
16725
+ finally {
16726
+ release?.();
16727
+ this.deleting.delete(claim);
16261
16728
  }
16262
- if (entry.kind === "dir")
16263
- await this.purge(doomed, elsewhere, opts.keepContent);
16264
- else
16265
- await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
16266
- if (elsewhere && entry.moving === true)
16267
- await this.settle(entry.id, elsewhere);
16268
16729
  }
16269
16730
  /**
16270
16731
  * Move or rename. Works across directories, which is the same operation:
@@ -16287,83 +16748,216 @@ class VfsNamespace {
16287
16748
  const srcName = basename(src);
16288
16749
  const dstName = basename(dst);
16289
16750
  assertValidName(dstName);
16290
- const srcParent = await this.openDir(dirname(src));
16751
+ const source = await this.openChain(dirname(src));
16752
+ if (!source)
16753
+ throw new VfsNotFoundError(dirname(src));
16754
+ const srcParent = source.dir;
16291
16755
  const entry = entryIn(srcParent.node, srcName);
16292
16756
  if (!entry)
16293
16757
  throw new VfsNotFoundError(src);
16294
- if (entry.kind === "dir") {
16295
- assertWritableDepth(dst);
16296
- if (segments(dst).length > segments(src).length)
16297
- await this.assertFitsAt(src, dst);
16298
- }
16299
- await this.mkdir(dirname(dst), { recursive: true });
16300
- const dstParent = await this.openDir(dirname(dst));
16301
- if (entryIn(dstParent.node, dstName))
16302
- throw new VfsConflictError(`${dst} already exists`);
16303
- assertNotHidden(dstParent, dstName, dst);
16758
+ // Another move of it is under way here. Its mark cannot tell that
16759
+ // move from an interrupted one, and finishing it as one named the
16760
+ // entry in two places. Held from here, before the first wait, so two
16761
+ // moves begun together cannot both get past this. See `inMotion`.
16762
+ if (this.inMotion.has(entry.id))
16763
+ throw beingMoved(src);
16764
+ // A delete here has begun on it, or on a folder on its way from the
16765
+ // root. Moved now, it could be put in its new place just before the
16766
+ // delete let go of what it holds. Not the root, which no delete is
16767
+ // of: one claims it only through an entry below that names it, and
16768
+ // lets go of nothing of it. In the same step as the check above, as
16769
+ // a delete makes its claim in the same step as its own. See
16770
+ // `deleting`.
16771
+ const lineage = [entry.id, ...source.chain.slice(1)];
16772
+ if ([...this.deleting].some((claim) => lineage.some((id) => claim.has(id))))
16773
+ throw heldElsewhere(src);
16774
+ this.inMotion.add(entry.id);
16304
16775
  // Each record is re-opened before it is written: creating the
16305
- // destination's parents above may have written to the source's.
16776
+ // destination's parents below may have written to the source's.
16306
16777
  const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
16307
- // Both ends were looked at before the waits above, and another write
16308
- // may have changed either since: replaced the entry being moved, or
16309
- // put something at the destination. Moving on regardless would write
16310
- // the old entry over the new one, or this one over the other. Or
16311
- // marked the entry: another move of it has begun. Taking it as well
16312
- // would name it in two places, and each move would unmark its own.
16313
- const take = (node) => {
16314
- const now = entryIn(node, srcName);
16315
- if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
16316
- throw changedMeanwhile(src);
16317
- return now;
16318
- };
16319
- const free = (node, record) => {
16320
- if (entryIn(node, dstName))
16778
+ let unmarkLanded = false;
16779
+ // The same for every tab of the app on this device: the entry's move
16780
+ // lock, held for as long as it is in `inMotion`. Another tab's move
16781
+ // or delete of it holds it, and taking that move's mark for an
16782
+ // interrupted one named the entry in two places, or purged it. See
16783
+ // `VfsCoordinator.holdMoving`.
16784
+ let release = null;
16785
+ // And the locks of the folders on the way to where it goes, shared,
16786
+ // for as long. See below.
16787
+ let into = null;
16788
+ try {
16789
+ const coordinator = this.deps.coordinator;
16790
+ if (coordinator) {
16791
+ release = await coordinator.holdMoving(entry.id);
16792
+ if (!release)
16793
+ throw heldElsewhere(src);
16794
+ // The look above was at this tab's copies of the folders on the
16795
+ // way to the entry. A delete in another tab of the entry, or of
16796
+ // a folder on that way, may have finished before this took the
16797
+ // lock, which the delete held: it had counted its writes by
16798
+ // then, though this tab may not have heard of them. Moved
16799
+ // regardless, the entry was marked in a record the delete had
16800
+ // purged, and named in its new place with its content let go
16801
+ // of. So whatever on the way another tab has written since it
16802
+ // was read is read again, and the entry looked for again.
16803
+ if ((await this.dropStale(coordinator, source.chain)).size) {
16804
+ const again = await this.openChain(dirname(src));
16805
+ if (!again)
16806
+ throw new VfsNotFoundError(dirname(src));
16807
+ if (again.chain.some((id, i) => id !== source.chain[i]))
16808
+ throw changedMeanwhile(dirname(src));
16809
+ const now = entryIn(again.dir.node, srcName);
16810
+ if (!now)
16811
+ throw new VfsNotFoundError(src);
16812
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16813
+ throw changedMeanwhile(src);
16814
+ }
16815
+ }
16816
+ if (entry.kind === "dir") {
16817
+ assertWritableDepth(dst);
16818
+ if (segments(dst).length > segments(src).length)
16819
+ await this.assertFitsAt(src, dst);
16820
+ }
16821
+ await this.mkdir(dirname(dst), { recursive: true });
16822
+ const way = await this.openChain(dirname(dst));
16823
+ if (!way)
16824
+ throw new VfsNotFoundError(dirname(dst));
16825
+ let dstParent = way.dir;
16826
+ if (coordinator && dstParent.id !== ROOT_ID) {
16827
+ // The folder it goes into, and every folder above that one
16828
+ // but the root, held shared: other moves into any of them
16829
+ // share them, and a delete of one - which holds the lock of
16830
+ // its target and of everything it read below - holds it
16831
+ // alone. A move into a folder that another tab was deleting
16832
+ // put what it moved in a record the delete then purged: in no
16833
+ // listing, its content kept. Not that folder alone: it may be
16834
+ // new since the delete read the tree - made by the `mkdir`
16835
+ // above, or by another tab - and the delete does not hold a
16836
+ // lock it did not read. Its target's is on the way here. The
16837
+ // root is never deleted.
16838
+ into = await this.holdAll(coordinator, way.chain.slice(1), true);
16839
+ if (!into)
16840
+ throw heldElsewhere(dirname(dst));
16841
+ // A delete of one of them may have finished since they were
16842
+ // looked up, and counted its write of the folder above before
16843
+ // it let go of their locks, though this tab may not have
16844
+ // heard of it: read again whatever on the way another tab has
16845
+ // written since it was read, and look again. Gone, or another
16846
+ // folder anywhere on the way, the move stops here, before its
16847
+ // mark.
16848
+ await this.dropStale(coordinator, way.chain);
16849
+ const again = await this.openChain(dirname(dst));
16850
+ if (!again)
16851
+ throw new VfsNotFoundError(dirname(dst));
16852
+ if (again.chain.some((id, i) => id !== way.chain[i]))
16853
+ throw changedMeanwhile(dirname(dst));
16854
+ dstParent = again.dir;
16855
+ }
16856
+ if (entryIn(dstParent.node, dstName))
16321
16857
  throw new VfsConflictError(`${dst} already exists`);
16322
- assertNotHidden(record, dstName, dst);
16323
- };
16324
- if (dstParent.id === srcParent.id) {
16325
- // One record, so one write: the move happens entirely or not at all.
16326
- await edit(dirname(src), (node, record) => {
16327
- const moved = take(node);
16328
- free(node, record);
16329
- delete node.entries[srcName];
16330
- node.entries[dstName] = { ...moved, mtime: Date.now() };
16331
- });
16332
- return;
16333
- }
16334
- // Two records, attached before detaching: interrupted, the entry is
16335
- // in both rather than in neither. Both copies are marked for as long
16336
- // as that can be so, because they share everything below them, and a
16337
- // delete of either would otherwise let go of the other's contents.
16338
- // An entry already marked is half of an earlier interrupted move, and
16339
- // keeps its mark.
16340
- const marked = entry.moving === true;
16341
- let moved = entry;
16342
- if (!marked) {
16858
+ assertNotHidden(dstParent, dstName, dst);
16859
+ // Both ends were looked at before the waits above, and another write
16860
+ // may have changed either since: replaced the entry being moved, or
16861
+ // put something at the destination. Moving on regardless would write
16862
+ // the old entry over the new one, or this one over the other. Or
16863
+ // marked the entry: another move of it has begun. Taking it as well
16864
+ // would name it in two places, and each move would unmark its own.
16865
+ const take = (node) => {
16866
+ const now = entryIn(node, srcName);
16867
+ if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
16868
+ throw changedMeanwhile(src);
16869
+ return now;
16870
+ };
16871
+ const free = (node, record) => {
16872
+ if (entryIn(node, dstName))
16873
+ throw new VfsConflictError(`${dst} already exists`);
16874
+ assertNotHidden(record, dstName, dst);
16875
+ };
16876
+ if (dstParent.id === srcParent.id) {
16877
+ // One record, so one write: the move happens entirely or not at all.
16878
+ await edit(dirname(src), (node, record) => {
16879
+ const moved = take(node);
16880
+ free(node, record);
16881
+ delete node.entries[srcName];
16882
+ node.entries[dstName] = { ...moved, mtime: Date.now() };
16883
+ });
16884
+ return;
16885
+ }
16886
+ // Two records, attached before detaching: interrupted, the entry is
16887
+ // in both rather than in neither. Both copies are marked for as long
16888
+ // as that can be so, because they share everything below them, and a
16889
+ // delete of either would otherwise let go of the other's contents.
16890
+ // An entry already marked is half of an earlier interrupted move, and
16891
+ // keeps its mark.
16892
+ const marked = entry.moving === true;
16893
+ let moved = entry;
16894
+ if (!marked) {
16895
+ await edit(dirname(src), (node) => {
16896
+ moved = take(node);
16897
+ node.entries[srcName] = { ...moved, moving: true };
16898
+ });
16899
+ }
16900
+ // Whether the edit below has put the entry at the destination: set
16901
+ // as its last step, after every check, each time it runs.
16902
+ let attached = false;
16903
+ try {
16904
+ await edit(dirname(dst), (node, record) => {
16905
+ attached = false;
16906
+ free(node, record);
16907
+ node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
16908
+ attached = true;
16909
+ });
16910
+ }
16911
+ catch (err) {
16912
+ // Refused before it put anything at the destination - another
16913
+ // write took the name first - so the entry is still in one
16914
+ // place only, and the mark this move put on it would stay for
16915
+ // good: on a file, every version that falls off its history
16916
+ // kept; on either, every delete of it reading the whole tree.
16917
+ // Not when the write failed instead: the destination may hold
16918
+ // the entry by now, and the mark is what keeps a delete of
16919
+ // either copy from letting go of the other's. Best-effort.
16920
+ if (!marked && !attached) {
16921
+ await edit(dirname(src), (node) => {
16922
+ const now = entryIn(node, srcName);
16923
+ if (now?.id !== entry.id || now.moving !== true)
16924
+ return false;
16925
+ node.entries[srcName] = unmarked(now);
16926
+ return true;
16927
+ }).catch(() => { });
16928
+ }
16929
+ throw err;
16930
+ }
16931
+ // A file's history is sealed with its directory's key, and from here
16932
+ // on the directory that opens it is this one.
16933
+ if (entry.kind === "file")
16934
+ await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16343
16935
  await edit(dirname(src), (node) => {
16344
- moved = take(node);
16345
- node.entries[srcName] = { ...moved, moving: true };
16936
+ // Gone already leaves nothing to detach, and nothing to write.
16937
+ // Replaced, it is not this entry to take away: the move stops
16938
+ // here, in both places. Not `take`: the entry is marked by now,
16939
+ // by this move or the earlier one it finishes.
16940
+ const now = entryIn(node, srcName);
16941
+ if (!now)
16942
+ return false;
16943
+ if (!sameEntry(entry, now))
16944
+ throw changedMeanwhile(src);
16945
+ delete node.entries[srcName];
16946
+ // Explicit, as in `createDir` and `settle`: noImplicitReturns.
16947
+ return true;
16346
16948
  });
16949
+ unmarkLanded = !marked;
16347
16950
  }
16348
- await edit(dirname(dst), (node, record) => {
16349
- free(node, record);
16350
- node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
16351
- });
16352
- // A file's history is sealed with its directory's key, and from here
16353
- // on the directory that opens it is this one.
16354
- if (entry.kind === "file")
16355
- await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16356
- await edit(dirname(src), (node) => {
16357
- // Gone already leaves nothing to detach. Replaced, it is not this
16358
- // entry to take away: the move stops here, in both places. Not
16359
- // `take`: the entry is marked by now, by this move or the earlier
16360
- // one it finishes.
16361
- const now = entryIn(node, srcName);
16362
- if (now && !sameEntry(entry, now))
16363
- throw changedMeanwhile(src);
16364
- delete node.entries[srcName];
16365
- });
16366
- if (!marked) {
16951
+ finally {
16952
+ // Named in one place from here, the destination. A delete of it
16953
+ // there goes ahead as for an entry an interrupted move left marked
16954
+ // - which the Trash's "delete forever" relies on while the mark is
16955
+ // still being cleared.
16956
+ this.inMotion.delete(entry.id);
16957
+ release?.();
16958
+ into?.();
16959
+ }
16960
+ if (unmarkLanded) {
16367
16961
  await edit(dirname(dst), (node) => {
16368
16962
  const landed = entryIn(node, dstName);
16369
16963
  if (landed?.id === entry.id)
@@ -16468,11 +17062,12 @@ class VfsNamespace {
16468
17062
  * Delete metadata records no longer reachable from the root.
16469
17063
  *
16470
17064
  * These accumulate because every write touches exactly one record: if two
16471
- * tabs or devices edit the same directory, the personal space keeps
16472
- * whichever put arrives last (a VFS write sends no HLC, and the space
16473
- * applies puts of one key in arrival order), which can leave a child whose
16474
- * parent entry lost the race. The per-record write queue (`writing`) only
16475
- * orders writes within one tab. Unreachable is harmless —
17065
+ * devices edit the same directory - or two tabs, where there is no
17066
+ * coordinator - the personal space keeps whichever put arrives last (a
17067
+ * VFS write sends no HLC, and the space applies puts of one key in arrival
17068
+ * order), which can leave a child whose parent entry lost the race. The
17069
+ * per-record queue and the coordinator order writes within one device
17070
+ * only. Unreachable is harmless —
16476
17071
  * it is invisible and undecryptable-by-nobody — but it costs storage, so
16477
17072
  * this reclaims it.
16478
17073
  *
@@ -16481,6 +17076,12 @@ class VfsNamespace {
16481
17076
  * Run it when the filesystem is idle.
16482
17077
  */
16483
17078
  async sweep(opts = {}) {
17079
+ // With a coordinator: what another tab has written since this tab
17080
+ // read it is read again, not followed in an old copy that may not
17081
+ // name what it now names - which would then be deleted here.
17082
+ const coordinator = this.deps.coordinator;
17083
+ if (coordinator)
17084
+ await this.dropStale(coordinator, [...this.cache.keys()]);
16484
17085
  const root = await this.root();
16485
17086
  // A record that cannot be read throws out of here, before anything is
16486
17087
  // deleted: with part of the tree unknown, "unreachable" is a guess.
@@ -16637,6 +17238,11 @@ class VfsNamespace {
16637
17238
  const hit = this.cache.get(id);
16638
17239
  if (hit)
16639
17240
  return hit;
17241
+ // Before anything is read, so that from here on this namespace hears
17242
+ // of any later write of the record by another tab. Starting to hear -
17243
+ // for the first time, or for someone else - clears the cache (see
17244
+ // `heard`), which is why it comes before this read is cached.
17245
+ this.deps.coordinator?.listen();
16640
17246
  // The PROMISE goes in the cache, not the resolved value.
16641
17247
  //
16642
17248
  // Reading a project means resolving hundreds of paths that share the
@@ -16682,31 +17288,7 @@ class VfsNamespace {
16682
17288
  if (!now)
16683
17289
  this.cache.set(id, pending);
16684
17290
  }
16685
- const raw = await this.deps.store.get(dirKey(id));
16686
- // An absent record is an EMPTY directory, not an error: the root
16687
- // exists before anything has been written to it, and a directory is
16688
- // created by its parent naming it rather than by its own record
16689
- // appearing.
16690
- if (raw === null || raw === undefined) {
16691
- const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
16692
- return { id, key, path, node, hidden: Object.create(null), gen };
16693
- }
16694
- // Present but unopenable is NOT absent. `unseal` folds the two
16695
- // together, which suits a record that can simply be rebuilt; a
16696
- // directory cannot, because rebuilding it means sealing an empty
16697
- // one over whatever is really there. And it is not a raw
16698
- // DOMException either: the typed error is what delete, purge and
16699
- // sweep know how to step around, and it names the record.
16700
- let value;
16701
- try {
16702
- value = await openSealed(key, raw);
16703
- }
16704
- catch (err) {
16705
- if (err instanceof SealedRecordError)
16706
- throw new VfsCorruptRecordError(id, path, err.message);
16707
- throw err;
16708
- }
16709
- return this.readRecord(id, key, value, gen, path);
17291
+ return this.fetchRecord(id, key, gen, path);
16710
17292
  };
16711
17293
  const pending = load().catch((err) => {
16712
17294
  // A failed read must not be remembered, or one blip poisons the
@@ -16721,6 +17303,55 @@ class VfsNamespace {
16721
17303
  this.cache.set(id, pending);
16722
17304
  return pending;
16723
17305
  }
17306
+ /**
17307
+ * Fetch record `id` from the store and open it. Whatever another tab
17308
+ * announced before the fetch began is in what it returns (see
17309
+ * `current`), and so is every write its counter counts: read here, or
17310
+ * given as `counter` (see `OpenDir.counter`).
17311
+ */
17312
+ async fetchRecord(id, key, gen, path, counter) {
17313
+ const seen = this.hearing;
17314
+ // Before the fetch, never after it. A write that lands in between
17315
+ // then only makes this copy look out of date, which costs a read;
17316
+ // read after, the counter could count a write the answer missed.
17317
+ // Without a coordinator there is none, and nothing is awaited: a read
17318
+ // in Node takes the turns it always took.
17319
+ if (counter === undefined)
17320
+ counter = this.deps.coordinator ? await this.counterOf(id) : null;
17321
+ const raw = await this.deps.store.get(dirKey(id));
17322
+ // An absent record is an EMPTY directory, not an error: the root
17323
+ // exists before anything has been written to it, and a directory is
17324
+ // created by its parent naming it rather than by its own record
17325
+ // appearing.
17326
+ if (raw === null || raw === undefined) {
17327
+ const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
17328
+ return { id, key, path, node, hidden: Object.create(null), gen, seen, counter };
17329
+ }
17330
+ // Present but unopenable is NOT absent. `unseal` folds the two
17331
+ // together, which suits a record that can simply be rebuilt; a
17332
+ // directory cannot, because rebuilding it means sealing an empty
17333
+ // one over whatever is really there. And it is not a raw
17334
+ // DOMException either: the typed error is what delete, purge and
17335
+ // sweep know how to step around, and it names the record.
17336
+ let value;
17337
+ try {
17338
+ value = await openSealed(key, raw);
17339
+ }
17340
+ catch (err) {
17341
+ if (err instanceof SealedRecordError)
17342
+ throw new VfsCorruptRecordError(id, path, err.message);
17343
+ throw err;
17344
+ }
17345
+ return this.readRecord(id, key, value, gen, seen, counter, path);
17346
+ }
17347
+ /** Record `id`'s write counter now: see `OpenDir.counter`. */
17348
+ async counterOf(id) {
17349
+ const coordinator = this.deps.coordinator;
17350
+ if (!coordinator)
17351
+ return null;
17352
+ const [counter] = await coordinator.counters([id]).catch(() => [null]);
17353
+ return counter ?? null;
17354
+ }
16724
17355
  /**
16725
17356
  * Turn a decrypted record into an OpenDir, or throw.
16726
17357
  *
@@ -16728,7 +17359,7 @@ class VfsNamespace {
16728
17359
  * see VfsCorruptRecordError. An individual entry of the wrong shape is set
16729
17360
  * aside in `hidden` instead: out of every read, but kept for the write.
16730
17361
  */
16731
- readRecord(id, key, value, gen, path) {
17362
+ readRecord(id, key, value, gen, seen, counter, path) {
16732
17363
  const problem = recordProblem(value);
16733
17364
  if (problem)
16734
17365
  throw new VfsCorruptRecordError(id, path, problem);
@@ -16784,7 +17415,7 @@ class VfsNamespace {
16784
17415
  this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
16785
17416
  }
16786
17417
  // A v1 record's unknown top-level fields survive a write too.
16787
- return { id, key, path, node: { ...top, entries }, hidden, gen };
17418
+ return { id, key, path, node: { ...top, entries }, hidden, gen, seen, counter };
16788
17419
  }
16789
17420
  /**
16790
17421
  * Apply an edit to a directory record and persist it.
@@ -16800,8 +17431,18 @@ class VfsNamespace {
16800
17431
  *
16801
17432
  * So `edit` must take what it writes from the record it is given, not
16802
17433
  * from anything read off `dir` before an await - and re-check whatever it
16803
- * relies on from then, throwing if another write changed it. Returning
16804
- * false writes nothing after all.
17434
+ * relies on from then, throwing if another write changed it, before it
17435
+ * changes anything. Returning false writes nothing after all.
17436
+ *
17437
+ * The write that carries the edit may carry others, made while an
17438
+ * earlier write of the record was in flight (see `queue`): it lands, or
17439
+ * fails, for all of them at once.
17440
+ *
17441
+ * With a coordinator, the edit may be made a second time, to the record
17442
+ * as another tab left it, and refused then (see `replay`): an edit must
17443
+ * check everything before it changes anything, and whatever it sets
17444
+ * outside `node` and `record` must be safe to set again, since each run
17445
+ * overwrites the last.
16805
17446
  */
16806
17447
  async mutate(dir, edit) {
16807
17448
  // At once when the copy is to hand, which it almost always is, so
@@ -16810,21 +17451,10 @@ class VfsNamespace {
16810
17451
  const target = this.editable(dir) ?? await this.reopen(dir);
16811
17452
  if (edit(target.node, target) === false)
16812
17453
  return;
16813
- target.node.mtime = Date.now();
16814
- // A write to a hidden entry's exact name replaces it: that name now
16815
- // means what this version wrote. Forgetting the old one here is also
16816
- // what stops a later delete of the replacement from resurrecting it.
16817
- for (const name of Object.keys(target.hidden)) {
16818
- if (Object.hasOwn(target.node.entries, name))
16819
- delete target.hidden[name];
16820
- }
16821
- // Not sent until any earlier write of this record has landed: see
16822
- // `writing`. Whether that one worked or not, this one still goes.
16823
- const before = this.writing.get(target.id);
16824
- const send = () => this.writeRecord(target);
16825
- const write = before ? before.then(send, send) : send();
16826
- this.copyOf.set(write, target);
16827
- this.writing.set(target.id, write);
17454
+ this.touch(target);
17455
+ const ticket = { edit };
17456
+ const batch = this.queue(target, ticket);
17457
+ const { write } = batch;
16828
17458
  try {
16829
17459
  await write;
16830
17460
  }
@@ -16851,6 +17481,10 @@ class VfsNamespace {
16851
17481
  this.writing.delete(target.id);
16852
17482
  }
16853
17483
  this.adopt(target);
17484
+ // Made again to the record as another tab left it, it no longer
17485
+ // applied - see `replay` - and nothing of it was written.
17486
+ if (batch.refused.has(ticket))
17487
+ throw batch.refused.get(ticket);
16854
17488
  }
16855
17489
  /**
16856
17490
  * The copy of `dir`'s record that edits are made to, if there is one
@@ -16897,6 +17531,249 @@ class VfsNamespace {
16897
17531
  this.copyOf.set(cached, dir);
16898
17532
  this.cache.set(dir.id, cached);
16899
17533
  }
17534
+ /** After an edit of `copy`: its time, and any entry it cannot read that the edit replaced. */
17535
+ touch(copy) {
17536
+ copy.node.mtime = Date.now();
17537
+ // A write to a hidden entry's exact name replaces it: that name now
17538
+ // means what this version wrote. Forgetting the old one here is also
17539
+ // what stops a later delete of the replacement from resurrecting it.
17540
+ for (const name of Object.keys(copy.hidden)) {
17541
+ if (Object.hasOwn(copy.node.entries, name))
17542
+ delete copy.hidden[name];
17543
+ }
17544
+ }
17545
+ /**
17546
+ * The write that will carry the edit just made to `copy`.
17547
+ *
17548
+ * Writes of a record go out one at a time, each once the one before it
17549
+ * has landed or failed (see `writing`), and each seals the record as it
17550
+ * stands when it goes out. So a write still waiting carries every edit
17551
+ * made while it waits, and an edit made then joins it instead of
17552
+ * queueing a write of its own: a burst of edits of one directory while a
17553
+ * write of it is in flight costs two writes, not one per edit. Each is a
17554
+ * billed write; a 20-file upload into one folder cost twenty.
17555
+ */
17556
+ queue(copy, ticket) {
17557
+ const open = this.waiting.get(copy.id);
17558
+ if (open?.copy === copy) {
17559
+ open.edits.push(ticket);
17560
+ return open;
17561
+ }
17562
+ const before = this.writing.get(copy.id);
17563
+ const batch = { copy, edits: [ticket], refused: new Map() };
17564
+ this.waiting.set(copy.id, batch);
17565
+ // Not sent until any earlier write of this record has landed: see
17566
+ // `writing`. Whether that one worked or not, this one still goes.
17567
+ const send = () => this.send(batch);
17568
+ batch.write = before ? before.then(send, send) : send();
17569
+ this.copyOf.set(batch.write, copy);
17570
+ this.writing.set(copy.id, batch.write);
17571
+ return batch;
17572
+ }
17573
+ /**
17574
+ * Send `batch`: from here on, an edit of its record goes in the next
17575
+ * write. With a coordinator, holding the lock every tab of the device
17576
+ * takes to write this record - see `sendLocked`.
17577
+ */
17578
+ send(batch) {
17579
+ const coordinator = this.deps.coordinator;
17580
+ if (!coordinator) {
17581
+ this.close(batch);
17582
+ return this.writeRecord(batch.copy);
17583
+ }
17584
+ // Closed under the lock once it is sealed (see `sendLocked`), and
17585
+ // here too, for a lock never granted or a re-read that failed: left
17586
+ // open, a later edit of the same copy would join a write that has
17587
+ // already failed, and fail with it.
17588
+ return coordinator.lock(batch.copy.id, () => this.sendLocked(batch, coordinator)).finally(() => this.close(batch));
17589
+ }
17590
+ /** Stop `batch` taking more edits, as it is sealed. */
17591
+ close(batch) {
17592
+ if (this.waiting.get(batch.copy.id) === batch)
17593
+ this.waiting.delete(batch.copy.id);
17594
+ }
17595
+ /**
17596
+ * Send `batch` while no other tab can write its record.
17597
+ *
17598
+ * If another tab wrote the record since this copy was read, the copy is
17599
+ * out of date, and writing it would undo that tab's write. So it is read
17600
+ * again, here, under the lock, and every edit the batch carries is made
17601
+ * again to what was read (see `replay`). A copy no other tab has written
17602
+ * over is sent as it is: one tab on its own costs no extra request.
17603
+ *
17604
+ * Which it is, the record's write counter says (see
17605
+ * `VfsCoordinator.counters`). Read once the lock is granted, it counts
17606
+ * every write of the record another tab made, because each counts its
17607
+ * write, and waits for the count to be stored, before it lets go of the
17608
+ * lock. If it has moved since the copy was read, or cannot be told, the
17609
+ * copy is read again. Whether this tab has heard of another tab's write
17610
+ * does not decide it: an announcement can reach this tab after the
17611
+ * lock does, or never. One it has heard of can only add a read.
17612
+ *
17613
+ * The write is counted once it has landed or failed - one that failed
17614
+ * may have landed even so - and the other tabs are then told of it,
17615
+ * all before the lock is let go of.
17616
+ */
17617
+ async sendLocked(batch, coordinator) {
17618
+ const { copy } = batch;
17619
+ // Hearing for whoever is signed in now, before deciding: starting to
17620
+ // hear - or hearing someone else - marks every copy out of date.
17621
+ coordinator.listen();
17622
+ const now = await coordinator.begin(copy.id).catch(() => null);
17623
+ // Whether `copy` is, from here, the record as this tab leaves it.
17624
+ let decided = false;
17625
+ let sent = false;
17626
+ try {
17627
+ const current = now !== null && copy.counter === now && this.current(copy);
17628
+ const fresh = copy.gen === this.generation && !current
17629
+ ? await this.fetchRecord(copy.id, copy.key, copy.gen, copy.path, null)
17630
+ : null;
17631
+ // Nothing waits from here to the seal, so no edit can come in between.
17632
+ this.close(batch);
17633
+ decided = true;
17634
+ this.locked.set(copy.id, copy);
17635
+ if (fresh && !this.replay(batch, fresh))
17636
+ return;
17637
+ sent = true;
17638
+ await this.writeRecord(copy);
17639
+ copy.seen = this.hearing;
17640
+ }
17641
+ finally {
17642
+ const counted = await coordinator.bump(copy.id).catch(() => null);
17643
+ // Not when the read above failed: the copy is what it was, and
17644
+ // the counter it held is out of date now.
17645
+ if (decided)
17646
+ copy.counter = counted;
17647
+ if (this.locked.get(copy.id) === copy)
17648
+ this.locked.delete(copy.id);
17649
+ if (counted === null)
17650
+ this.notCounted(copy, now !== null);
17651
+ else
17652
+ this.uncounted.delete(copy.id);
17653
+ if (sent)
17654
+ coordinator.announce(copy.id);
17655
+ }
17656
+ }
17657
+ /**
17658
+ * Say that a write of `copy`'s record could not be counted.
17659
+ *
17660
+ * `marked`: `VfsCoordinator.begin` stored its mark, which stays until a
17661
+ * count can be stored, so every tab reads the record again before it
17662
+ * writes it. Said once for each record, until a write of it is counted.
17663
+ *
17664
+ * Not marked, a tab whose IndexedDB works may take its copy for current
17665
+ * and write it over this write. Said once for this namespace, whatever
17666
+ * the record: where IndexedDB cannot be used at all, no write of any
17667
+ * folder is counted, and a warning for each said nothing new. And only
17668
+ * where another tab can count its writes can one be lost, which the
17669
+ * warning says: where no tab can, every tab reads each folder again.
17670
+ */
17671
+ notCounted(copy, marked) {
17672
+ const where = copy.path ?? `record ${printable(copy.id, 40)}`;
17673
+ if (!marked) {
17674
+ if (this.lossWarned)
17675
+ return;
17676
+ this.lossWarned = true;
17677
+ 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`);
17678
+ return;
17679
+ }
17680
+ if (this.uncounted.has(copy.id))
17681
+ return;
17682
+ this.uncounted.add(copy.id);
17683
+ 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`);
17684
+ }
17685
+ /**
17686
+ * Make every edit in `batch` again, in order, to `fresh` - the record as
17687
+ * another tab left it - and make that the copy. An edit that no longer
17688
+ * applies throws, as it would have the first time, and is left out; its
17689
+ * caller gets what it threw. Every edit checks before it changes
17690
+ * anything (see `mutate`), so one that throws has changed nothing. True
17691
+ * when what is left changes the record, and so needs writing.
17692
+ */
17693
+ replay(batch, fresh) {
17694
+ const { copy } = batch;
17695
+ copy.node = fresh.node;
17696
+ copy.hidden = fresh.hidden;
17697
+ copy.seen = fresh.seen;
17698
+ copy.counter = fresh.counter;
17699
+ let changed = false;
17700
+ for (const ticket of batch.edits) {
17701
+ try {
17702
+ if (ticket.edit(copy.node, copy) !== false)
17703
+ changed = true;
17704
+ }
17705
+ catch (err) {
17706
+ batch.refused.set(ticket, err);
17707
+ }
17708
+ }
17709
+ if (changed)
17710
+ this.touch(copy);
17711
+ return changed;
17712
+ }
17713
+ /**
17714
+ * Another tab wrote record `id` - or, null, this namespace may have
17715
+ * missed some announcements. What the cache holds is read again when
17716
+ * next asked for, as a change frame from the personal space would have
17717
+ * it, and a copy read before then is read again before the next write
17718
+ * of its record (see `current`). A hint, not a guarantee: an
17719
+ * announcement can reach this tab after the next lock does, or never.
17720
+ * What decides whether a copy may be written over, or relied on under a
17721
+ * move's or a delete's locks, is the record's counter: see `sendLocked`
17722
+ * and `dropStale`.
17723
+ */
17724
+ heard(id) {
17725
+ const at = ++this.hearing;
17726
+ if (id === null) {
17727
+ this.missedAt = at;
17728
+ this.cache.clear();
17729
+ return;
17730
+ }
17731
+ this.heardAt.set(id, at);
17732
+ this.cache.delete(id);
17733
+ }
17734
+ /**
17735
+ * Does `copy` hold every write of its record another tab has announced
17736
+ * that this one has heard of? See `heard`. Only ever a reason to read it
17737
+ * again, never to take it for current: see `sendLocked`.
17738
+ */
17739
+ current(copy) {
17740
+ return copy.seen >= Math.max(this.missedAt, this.heardAt.get(copy.id) ?? 0);
17741
+ }
17742
+ /**
17743
+ * Drop from the cache every copy of a record in `ids` that may not hold
17744
+ * every write of it another tab has made: its counter has moved since
17745
+ * the copy was read, or cannot be told, or the copy is still being read.
17746
+ * Returns every id whose copy here is not known to be current - those,
17747
+ * and those not cached - so that the next read of each fetches it again,
17748
+ * once any write of it in flight here has landed or failed. Not a copy
17749
+ * this tab is writing under its lock (see `locked`): its counter reads
17750
+ * as unknown only because that write is not counted yet, and dropped,
17751
+ * the next read would wait for the write.
17752
+ *
17753
+ * The counters are read in one go, after the caller has taken the locks
17754
+ * it relies on: another tab counts each write of a record before it lets
17755
+ * go of the locks it held while writing it, the record's own and any
17756
+ * move lock, so what it wrote before letting go of one of them is
17757
+ * counted by then. Its announcements may not have reached this tab yet,
17758
+ * or ever. See `sendLocked`.
17759
+ */
17760
+ async dropStale(coordinator, ids) {
17761
+ const asked = [...new Set(ids)];
17762
+ const now = asked.length ? await coordinator.counters(asked).catch(() => asked.map(() => null)) : [];
17763
+ const stale = new Set();
17764
+ asked.forEach((id, i) => {
17765
+ const cached = this.cache.get(id);
17766
+ const copy = cached && this.copyOf.get(cached);
17767
+ if (copy && copy.gen === this.generation && copy.counter !== null && copy.counter === now[i])
17768
+ return;
17769
+ if (copy && copy.gen === this.generation && this.locked.get(id) === copy)
17770
+ return;
17771
+ if (cached)
17772
+ this.cache.delete(id);
17773
+ stale.add(id);
17774
+ });
17775
+ return stale;
17776
+ }
16900
17777
  /**
16901
17778
  * Seal a directory record as it stands NOW and put it in the store.
16902
17779
  *
@@ -16928,12 +17805,15 @@ class VfsNamespace {
16928
17805
  assertValidName(name);
16929
17806
  const parent = await this.openDir(parentPath);
16930
17807
  const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
16931
- await this.mutate(parent, (node, record) => {
17808
+ // The directory the edit below last found there, if any.
17809
+ let there;
17810
+ const named = this.mutate(parent, (node, record) => {
16932
17811
  // mkdir looked before the wait above. A directory made there
16933
17812
  // since, by another `mkdir -p` of the same path - files saved
16934
17813
  // at once into a new folder - is kept, not replaced: whatever
16935
17814
  // was saved into it would go with it.
16936
17815
  const now = entryIn(node, name);
17816
+ there = now;
16937
17817
  if (now?.kind === "dir" && recursive)
16938
17818
  return false;
16939
17819
  if (now)
@@ -16942,6 +17822,16 @@ class VfsNamespace {
16942
17822
  node.entries[name] = entry;
16943
17823
  return true;
16944
17824
  });
17825
+ // Set before anyone can find the entry in the cached parent - `mutate`
17826
+ // makes the edit at once, or after a re-read - so a mkdir that finds
17827
+ // it there waits for this write. See `naming`.
17828
+ this.naming.set(entry.id, named.then(() => { }, () => { }).then(() => void this.naming.delete(entry.id)));
17829
+ await named;
17830
+ // The directory kept instead may be one another mkdir here named,
17831
+ // its write not landed yet: wait for that too, as `mkdir` does.
17832
+ const kept = there && this.naming.get(there.id);
17833
+ if (kept)
17834
+ await kept;
16945
17835
  }
16946
17836
  /**
16947
17837
  * Every directory record in a subtree, each once, opened - and nothing
@@ -16957,14 +17847,22 @@ class VfsNamespace {
16957
17847
  *
16958
17848
  * `moving` says whether anything in it is marked as half of an
16959
17849
  * interrupted move, which is what makes a delete look for the other half.
17850
+ *
17851
+ * `ids` is every entry id in it, and `shape` what each record named when
17852
+ * it was read: see `currentSubtree`.
16960
17853
  */
16961
17854
  async subtree(top, target, above) {
16962
17855
  const found = new Map([[top.id, top]]);
16963
17856
  const pending = [top];
17857
+ // Every entry id in it, and what each record named as it was read.
17858
+ const ids = new Set();
17859
+ const shape = new Map();
16964
17860
  let spared = 0;
16965
17861
  let moving = false;
16966
17862
  for (let dir = pending.pop(); dir; dir = pending.pop()) {
17863
+ shape.set(dir.id, namedIn(dir));
16967
17864
  for (const [, entry] of followable(dir)) {
17865
+ ids.add(entry.id);
16968
17866
  if (entry.moving === true)
16969
17867
  moving = true;
16970
17868
  if (entry.kind !== "dir" || found.has(entry.id))
@@ -16994,7 +17892,79 @@ class VfsNamespace {
16994
17892
  const what = spared === 1 ? "a directory that is" : `${spared} directories that are`;
16995
17893
  this.warn(`leaving ${what} above ${target} in the tree alone while deleting it`);
16996
17894
  }
16997
- return { dirs: [...found.values()], moving };
17895
+ return { dirs: [...found.values()], moving, ids, shape };
17896
+ }
17897
+ /**
17898
+ * The records a recursive delete read, as they are now - or null when
17899
+ * one has changed since in a way that purging it would get wrong.
17900
+ *
17901
+ * The delete decided what to let go of, and whether to look for the
17902
+ * other half of a move, from what it read; then it waited, on reads and
17903
+ * on the write of its parent. A record that is no longer named by the
17904
+ * record it was read in has been moved out, and is not this delete's to
17905
+ * purge; an entry marked since it was read is being moved, and is about
17906
+ * to be named somewhere the delete never looked. Purging either purged
17907
+ * the folder a move had just put somewhere else. Anything else - a file
17908
+ * saved into one of them, or moved out - is purged as it now stands,
17909
+ * which is why the copies to purge are what this returns.
17910
+ *
17911
+ * Synchronous, for an edit. A record whose copy is not to hand counts as
17912
+ * changed.
17913
+ *
17914
+ * `held`, with a coordinator, is every entry whose move lock the delete
17915
+ * holds. An entry named since the records were read - saved or moved in
17916
+ * - is not among them, so nothing stops another tab moving it out while
17917
+ * the delete writes the parent, and purging it then let go of what that
17918
+ * move had put somewhere else. So with `held`, that counts as changed
17919
+ * too, and the delete is tried again from the start.
17920
+ */
17921
+ currentSubtree(dirs, shape, held) {
17922
+ const doomed = new Set(dirs.map((dir) => dir.id));
17923
+ const current = [];
17924
+ for (const dir of dirs) {
17925
+ const now = this.editable(dir);
17926
+ if (!now)
17927
+ return null;
17928
+ const read = shape.get(dir.id) ?? new Map();
17929
+ const named = namedIn(now);
17930
+ for (const [id, marked] of named)
17931
+ if (marked && read.get(id) !== true)
17932
+ return null;
17933
+ for (const id of read.keys())
17934
+ if (doomed.has(id) && !named.has(id))
17935
+ return null;
17936
+ if (held)
17937
+ for (const id of named.keys())
17938
+ if (!held.has(id))
17939
+ return null;
17940
+ current.push(now);
17941
+ }
17942
+ return current;
17943
+ }
17944
+ /**
17945
+ * Hold the move lock of every entry in `ids` - see
17946
+ * `VfsCoordinator.holdMoving` - or of none: null when another holder has
17947
+ * any of them, and then nothing is held. Otherwise the function that
17948
+ * lets go of them all. Asked for all at once: none of them waits.
17949
+ * `shared` holds them as the folders a move goes into.
17950
+ */
17951
+ async holdAll(coordinator, ids, shared = false) {
17952
+ const tries = await Promise.allSettled([...new Set(ids)].map((id) => coordinator.holdMoving(id, shared)));
17953
+ const held = tries.flatMap((tried) => (tried.status === "fulfilled" && tried.value ? [tried.value] : []));
17954
+ const release = () => {
17955
+ for (const letGo of held)
17956
+ letGo();
17957
+ };
17958
+ const failed = tries.find((tried) => tried.status === "rejected");
17959
+ if (failed) {
17960
+ release();
17961
+ throw failed.reason;
17962
+ }
17963
+ if (held.length < tries.length) {
17964
+ release();
17965
+ return null;
17966
+ }
17967
+ return release;
16998
17968
  }
16999
17969
  /**
17000
17970
  * Delete these records and let go of every file in them. The second half
@@ -17255,7 +18225,10 @@ class VfsNamespace {
17255
18225
  }
17256
18226
  if (!run.write)
17257
18227
  return clone;
17258
- const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen };
18228
+ const counter = this.deps.coordinator ? await this.counterOf(clone.id) : null;
18229
+ const open = {
18230
+ id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen, seen: this.hearing, counter,
18231
+ };
17259
18232
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
17260
18233
  this.adopt(open);
17261
18234
  return clone;
@@ -18605,6 +19578,519 @@ class VcsNamespace {
18605
19578
  }
18606
19579
  }
18607
19580
 
19581
+ /**
19582
+ * Coordination between the tabs of an app on one device: Web Locks, the
19583
+ * Web BroadcastChannel, and IndexedDB.
19584
+ *
19585
+ * Every factory here returns undefined unless the runtime is a browser tab
19586
+ * with both Web Locks and a BroadcastChannel - not Node (which has
19587
+ * `navigator.locks` from 24.5, but per process), not a Worker, not an old
19588
+ * WebView - and its callers then keep the behaviour they had before: one
19589
+ * tab's writes ordered among themselves only. IndexedDB is not required:
19590
+ * without it no count can be told, and every write reads its folder again
19591
+ * first. A storage-partitioned iframe gets locks, a channel and IndexedDB of
19592
+ * its own, as it gets storage of its own; to the app's other tabs it is
19593
+ * another device.
19594
+ *
19595
+ * A browser can also have these and refuse them to a document: an opaque
19596
+ * origin (a sandboxed iframe, a `data:` document), or a site whose data the
19597
+ * user has blocked. Refused Web Locks, the coordinator goes on without
19598
+ * them, as in Node (see `refusesLocks`); refused a channel, it hears
19599
+ * nothing; and an IndexedDB that does not answer in time counts nothing
19600
+ * (see `writeCounts`). `client.vfs` needed none of the three before it
19601
+ * coordinated tabs, and none of them may stop it now.
19602
+ *
19603
+ * `globalThis.BroadcastChannel` is named in full on purpose: this package
19604
+ * exports a class of its own called BroadcastChannel (the E2E room channel),
19605
+ * and an import of that in scope would silently take the Web API's place.
19606
+ */
19607
+ /** How long a write waits for another tab to finish writing the same folder before it gives up. */
19608
+ const LOCK_WAIT_MS = 30_000;
19609
+ /**
19610
+ * How long each call to the write counts - `counters`, `begin` or `bump` -
19611
+ * may take before it gives up, telling nothing, as one that failed: the
19612
+ * wait for the IndexedDB open and the call's transaction together, not
19613
+ * each. Only the call gives up, not the open: one that succeeds later,
19614
+ * within `OPEN_DROP_MS`, is kept for every later call (see `writeCounts`).
19615
+ */
19616
+ const COUNT_WAIT_MS = 3_000;
19617
+ /**
19618
+ * How long an IndexedDB open of the write counts may go without any answer
19619
+ * - no success, error or blocked - before it is dropped, as one that
19620
+ * failed, and the next call asks for another. See `writeCounts`.
19621
+ */
19622
+ const OPEN_DROP_MS = 30_000;
19623
+ /**
19624
+ * Whether `err`, from a Web Locks request that was never granted, is the
19625
+ * browser refusing Web Locks to this document altogether: an opaque origin,
19626
+ * or a site whose data the user has blocked. The browser makes that
19627
+ * decision for the site's storage, so it refuses every tab of the app
19628
+ * alike, and no tab holds the lock. The tab then goes on without it, as in
19629
+ * Node. Any other error - a document no longer fully active, say - is
19630
+ * passed on.
19631
+ */
19632
+ function refusesLocks(err) {
19633
+ return err?.name === "SecurityError";
19634
+ }
19635
+ // Through `unknown`: the DOM's own LockManager and BroadcastChannel types do
19636
+ // not line up with these slices closely enough for a direct cast.
19637
+ function webLocks() {
19638
+ if (typeof window === "undefined")
19639
+ return undefined;
19640
+ const locks = globalThis.navigator?.locks;
19641
+ return typeof locks?.request === "function" ? locks : undefined;
19642
+ }
19643
+ function webChannel() {
19644
+ const Channel = globalThis.BroadcastChannel;
19645
+ return typeof Channel === "function" ? Channel : undefined;
19646
+ }
19647
+ function webIndexedDB() {
19648
+ const idb = globalThis.indexedDB;
19649
+ return typeof idb?.open === "function" ? idb : undefined;
19650
+ }
19651
+ /**
19652
+ * The IndexedDB database the write counts are kept in, one per origin, as
19653
+ * the locks are. Not one connect keeps for anything else: those are
19654
+ * `muhkoo.offline` (the offline layer), `muhkoo.trust` and `muhkoo.device.v1`.
19655
+ *
19656
+ * Its version stays 1 for good. A tab still running code that opens it at
19657
+ * version 1 would fail with a VersionError once a newer tab had made it at
19658
+ * version 2, count nothing, and so write a folder without the newer tabs
19659
+ * knowing: the writer whose write may be lost. A new store goes in a new
19660
+ * database.
19661
+ */
19662
+ const COUNTS_DB = "muhkoo.vfs";
19663
+ /** Its one store: `[commitment, record id]` to a count, and `CREATED` to a random id. */
19664
+ const COUNTS = "counts";
19665
+ const CREATED = "created";
19666
+ /**
19667
+ * How often each user's directory records have been written on this
19668
+ * device: `VfsCoordinator.counters`, `begin` and `bump`, kept in IndexedDB
19669
+ * for every tab of the origin.
19670
+ *
19671
+ * A count is odd from `begin` to `bump`, while a write is under way, and
19672
+ * even once it is counted; `counters` reports an odd one as unknown. It is
19673
+ * read as `<created>:<count>`, where `created` is a random id stored when
19674
+ * the database was made: deleted - the site's data cleared - every count
19675
+ * starts again from nothing, and a counter read before could otherwise
19676
+ * come round again with writes in between that its copy does not hold.
19677
+ *
19678
+ * Each operation is one transaction, and settles once that has completed.
19679
+ * Every failure - IndexedDB missing, an open refused, blocked or failed, a
19680
+ * transaction aborted - resolves to null, which the namespace takes for out
19681
+ * of date: it reads the record again. A failed or blocked open is not kept,
19682
+ * and nor is a connection closed under it, by a newer version of this code
19683
+ * in another tab or by the database being deleted: the next operation opens
19684
+ * the database again.
19685
+ *
19686
+ * So does an operation that has not settled within `waitMs`, the open it
19687
+ * waits for and its transaction together: these are awaited inside the
19688
+ * record locks, and one waited for without end would hold the lock, and
19689
+ * stop every read and write of the tab's folders, for as long. Only the
19690
+ * operation gives up, though, not the open: there is one open under way at
19691
+ * a time, every operation waits for that one, and once it has opened the
19692
+ * database it is kept for every later operation. Given up on too, and closed
19693
+ * when it opened at last, an open that always takes longer than `waitMs`
19694
+ * would never give the tab its counts. An open that has not answered at all
19695
+ * within `OPEN_DROP_MS` is dropped, though, as one that failed: one that
19696
+ * never will would otherwise leave the tab without counts for as long as it
19697
+ * lives. Until then every operation waits for it, `waitMs` at most, and
19698
+ * tells nothing; the next operation after that asks for one new open; and
19699
+ * the dropped one, if it opens after all, is closed. The new open is
19700
+ * dropped in turn if it goes `OPEN_DROP_MS` without an answer too, and so
19701
+ * on: where IndexedDB never answers, the tab leaves behind one more open
19702
+ * that never settles every `OPEN_DROP_MS` or a little more, for as long as
19703
+ * operations keep coming, and none while none come. An operation that
19704
+ * gives up before the database has opened makes no transaction. A
19705
+ * transaction given up on is not aborted, and may still complete: IndexedDB
19706
+ * runs the read-write transactions of one store in the order they were
19707
+ * made, so a late `begin` still marks the count before any later `bump`,
19708
+ * and a late `bump` still counts its write before any later `begin`. A tab
19709
+ * that asks meanwhile waits behind it, gives up, and reads the record
19710
+ * again.
19711
+ */
19712
+ function writeCounts(idb, waitMs) {
19713
+ // The open under way, or the one that opened `opened`: one at a time.
19714
+ let connection = null;
19715
+ // The database `connection` opened, once it has.
19716
+ let opened = null;
19717
+ // Drops `connection` while it is under way, once `OPEN_DROP_MS` have passed.
19718
+ let dropping;
19719
+ const forget = (db) => {
19720
+ if (opened !== db)
19721
+ return;
19722
+ opened = null;
19723
+ connection = null;
19724
+ db.close();
19725
+ };
19726
+ const open = () => {
19727
+ if (connection)
19728
+ return connection;
19729
+ let request;
19730
+ try {
19731
+ if (!idb)
19732
+ throw new Error("no IndexedDB");
19733
+ request = idb.open(COUNTS_DB, 1);
19734
+ }
19735
+ catch {
19736
+ // Not kept: the next operation tries again.
19737
+ return Promise.resolve(null);
19738
+ }
19739
+ const pending = new Promise((resolve) => {
19740
+ // Not kept when it failed, or was dropped: the next operation
19741
+ // tries again. Failed after it was dropped, it changes nothing.
19742
+ const fail = () => {
19743
+ if (connection === pending) {
19744
+ clearTimeout(dropping);
19745
+ connection = null;
19746
+ }
19747
+ resolve(null);
19748
+ };
19749
+ request.onupgradeneeded = () => {
19750
+ request.result.createObjectStore(COUNTS).put(generateId(), CREATED);
19751
+ };
19752
+ request.onsuccess = () => {
19753
+ const db = request.result;
19754
+ // Blocked or dropped, and given up on, before it opened - or
19755
+ // closed by `close`. Another open may be under way by now.
19756
+ if (connection !== pending) {
19757
+ db.close();
19758
+ resolve(null);
19759
+ return;
19760
+ }
19761
+ clearTimeout(dropping);
19762
+ db.onversionchange = () => forget(db);
19763
+ db.onclose = () => forget(db);
19764
+ opened = db;
19765
+ resolve(db);
19766
+ };
19767
+ // Blocked: another tab holds an older version open. Not waited for.
19768
+ request.onerror = fail;
19769
+ request.onblocked = fail;
19770
+ // No answer at all: dropped, as one that failed, and the next
19771
+ // operation asks for another - dropped in turn if it does not
19772
+ // answer either. See above.
19773
+ dropping = setTimeout(fail, OPEN_DROP_MS);
19774
+ });
19775
+ connection = pending;
19776
+ return pending;
19777
+ };
19778
+ /**
19779
+ * Run `body` in one transaction, and resolve once that has completed to
19780
+ * what the function it returns gives then - or to null, if anything
19781
+ * fails, or the database has not opened and the transaction completed
19782
+ * within `waitMs`.
19783
+ */
19784
+ const run = (mode, body) => new Promise((resolve) => {
19785
+ let done = false;
19786
+ const settle = (value) => {
19787
+ if (done)
19788
+ return;
19789
+ done = true;
19790
+ clearTimeout(timer);
19791
+ resolve(value);
19792
+ };
19793
+ // Given up on, but neither the open nor the transaction aborted: see above.
19794
+ const timer = setTimeout(() => settle(null), waitMs);
19795
+ void open().then((db) => {
19796
+ // Given up on while it opened: nothing to do.
19797
+ if (done)
19798
+ return;
19799
+ if (!db) {
19800
+ settle(null);
19801
+ return;
19802
+ }
19803
+ let tx;
19804
+ try {
19805
+ tx = db.transaction(COUNTS, mode);
19806
+ const result = body(tx.objectStore(COUNTS));
19807
+ tx.oncomplete = () => settle(result());
19808
+ tx.onabort = () => settle(null);
19809
+ }
19810
+ catch {
19811
+ try {
19812
+ tx?.abort();
19813
+ }
19814
+ catch {
19815
+ // Finished already.
19816
+ }
19817
+ // Closed under it, most likely: the next operation opens it again.
19818
+ forget(db);
19819
+ settle(null);
19820
+ }
19821
+ });
19822
+ });
19823
+ const count = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0);
19824
+ const counter = (created, n) => (typeof created === "string" ? `${created}:${n}` : null);
19825
+ return {
19826
+ async counters(who, ids) {
19827
+ const read = await run("readonly", (store) => {
19828
+ const created = store.get(CREATED);
19829
+ const counts = ids.map((id) => store.get([who, id]));
19830
+ return () => counts.map((at) => {
19831
+ const n = count(at.result);
19832
+ return n % 2 === 0 ? counter(created.result, n) : null;
19833
+ });
19834
+ });
19835
+ return read ?? ids.map(() => null);
19836
+ },
19837
+ begin(who, id) {
19838
+ return run("readwrite", (store) => {
19839
+ const created = store.get(CREATED);
19840
+ const at = store.get([who, id]);
19841
+ let n = 0;
19842
+ at.onsuccess = () => {
19843
+ n = count(at.result);
19844
+ // Odd already, a write began and was never counted: its mark stays.
19845
+ if (n % 2 === 0)
19846
+ store.put(n + 1, [who, id]);
19847
+ };
19848
+ return () => counter(created.result, n);
19849
+ });
19850
+ },
19851
+ bump(who, id) {
19852
+ return run("readwrite", (store) => {
19853
+ const created = store.get(CREATED);
19854
+ const at = store.get([who, id]);
19855
+ let next = 0;
19856
+ at.onsuccess = () => {
19857
+ const n = count(at.result);
19858
+ // Past the mark `begin` stored - or, when it could not
19859
+ // store one, past the count it found.
19860
+ next = n % 2 === 0 ? n + 2 : n + 1;
19861
+ store.put(next, [who, id]);
19862
+ };
19863
+ return () => counter(created.result, next);
19864
+ });
19865
+ },
19866
+ close() {
19867
+ // An open still under way is closed once it opens.
19868
+ connection = null;
19869
+ clearTimeout(dropping);
19870
+ if (opened)
19871
+ forget(opened);
19872
+ },
19873
+ };
19874
+ }
19875
+ /**
19876
+ * The offline queue's drain lock: `fn` runs only if no other tab is
19877
+ * draining, and `false` says another one is. See `OutboundQueue`. Where the
19878
+ * browser refuses Web Locks, `fn` runs without it, as in Node, and the
19879
+ * lock is not asked for again: see `refusesLocks`.
19880
+ */
19881
+ function drainLock() {
19882
+ const locks = webLocks();
19883
+ if (!locks)
19884
+ return undefined;
19885
+ let refused = false;
19886
+ return async (name, fn) => {
19887
+ if (!refused) {
19888
+ let answered = false;
19889
+ try {
19890
+ return await locks.request(name, { ifAvailable: true }, async (lock) => {
19891
+ answered = true;
19892
+ if (!lock)
19893
+ return false;
19894
+ await fn();
19895
+ return true;
19896
+ });
19897
+ }
19898
+ catch (err) {
19899
+ if (answered || !refusesLocks(err))
19900
+ throw err;
19901
+ refused = true;
19902
+ }
19903
+ }
19904
+ await fn();
19905
+ return true;
19906
+ };
19907
+ }
19908
+ /**
19909
+ * The `VfsCoordinator` for whoever `commitment()` names: a lock per record,
19910
+ * `muhkoo:vfs:<commitment>:<record id>`; a move lock per entry,
19911
+ * `muhkoo:vfs:<commitment>:moving:<entry id>`, taken only if free, and
19912
+ * shared by the moves into a folder; a count of each record's writes, in
19913
+ * IndexedDB `muhkoo.vfs` under `[commitment, record id]` (see
19914
+ * `writeCounts`); and announcements on the channel `muhkoo:vfs:<commitment>`
19915
+ * as `{ id, writer }`, where `writer` is this tab's random id. `close` is
19916
+ * for tests, as are `lockWaitMs` (see `LOCK_WAIT_MS`) and `countWaitMs`
19917
+ * (see `COUNT_WAIT_MS`).
19918
+ *
19919
+ * Where the browser refuses Web Locks (see `refusesLocks`), it holds no
19920
+ * lock - every write runs as it would without a coordinator, and every
19921
+ * move lock is had at once - and asks for none again.
19922
+ */
19923
+ function vfsCoordinator(commitment, opts = {}) {
19924
+ const locks = webLocks();
19925
+ const Channel = webChannel();
19926
+ if (!locks || !Channel)
19927
+ return undefined;
19928
+ const lockWaitMs = opts.lockWaitMs ?? LOCK_WAIT_MS;
19929
+ const tab = generateId();
19930
+ const counts = writeCounts(webIndexedDB(), opts.countWaitMs ?? COUNT_WAIT_MS);
19931
+ const handlers = new Set();
19932
+ // `channel` is null where the browser would not make one: see `listen`.
19933
+ let open = null;
19934
+ // Move locks this tab has let go of, until the browser has too. See `holdMoving`.
19935
+ const leaving = new Map();
19936
+ // The browser refused Web Locks: see `refusesLocks`.
19937
+ let refused = false;
19938
+ /**
19939
+ * Hear the channel of whoever is signed in now: opened the first time,
19940
+ * and opened again for someone else after a sign-in. What was announced
19941
+ * before it opened was not heard, which every handler is told.
19942
+ *
19943
+ * Where the browser will not make the channel - an opaque origin, or a
19944
+ * site whose data is blocked - this tab hears nothing and tells no one,
19945
+ * and does not ask again for the same user. That is safe: the counts,
19946
+ * not the announcements, decide whether a copy may be written over.
19947
+ */
19948
+ const listen = () => {
19949
+ const who = commitment();
19950
+ if (open?.who === who)
19951
+ return;
19952
+ open?.channel?.close();
19953
+ open = null;
19954
+ if (!who)
19955
+ return;
19956
+ let channel = null;
19957
+ try {
19958
+ channel = new Channel(`muhkoo:vfs:${who}`);
19959
+ channel.onmessage = (event) => {
19960
+ const data = event.data;
19961
+ if (typeof data?.id !== "string" || data.writer === tab)
19962
+ return;
19963
+ for (const handler of handlers)
19964
+ handler(data.id);
19965
+ };
19966
+ }
19967
+ catch {
19968
+ channel = null;
19969
+ }
19970
+ open = { who, channel };
19971
+ for (const handler of handlers)
19972
+ handler(null);
19973
+ };
19974
+ return {
19975
+ async lock(id, fn) {
19976
+ listen();
19977
+ const who = commitment();
19978
+ // Signed out: the store refuses the write itself, with a clearer
19979
+ // message. Refused Web Locks: no tab of the app can hold this
19980
+ // lock, and the write goes ahead as it would without one.
19981
+ if (!who || refused)
19982
+ return fn();
19983
+ // Only the WAIT is bounded. Once granted, the lock is held until
19984
+ // the write has landed or failed: let go of while a write is still
19985
+ // on its way, the next tab's write could land first and then be
19986
+ // undone by it - the very loss this lock is for.
19987
+ const waiting = new AbortController();
19988
+ const timer = setTimeout(() => waiting.abort(), lockWaitMs);
19989
+ let granted = false;
19990
+ try {
19991
+ return await locks.request(`muhkoo:vfs:${who}:${id}`, { signal: waiting.signal }, () => {
19992
+ granted = true;
19993
+ clearTimeout(timer);
19994
+ return fn();
19995
+ });
19996
+ }
19997
+ catch (err) {
19998
+ if (!granted && waiting.signal.aborted) {
19999
+ 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`);
20000
+ }
20001
+ if (granted || !refusesLocks(err))
20002
+ throw err;
20003
+ refused = true;
20004
+ }
20005
+ finally {
20006
+ clearTimeout(timer);
20007
+ }
20008
+ // Refused Web Locks, before anything was written: written without one.
20009
+ return fn();
20010
+ },
20011
+ announce(id) {
20012
+ listen();
20013
+ open?.channel?.postMessage({ id, writer: tab });
20014
+ },
20015
+ onAnnounce(handler) {
20016
+ handlers.add(handler);
20017
+ return () => void handlers.delete(handler);
20018
+ },
20019
+ listen,
20020
+ // Signed out, nothing is counted: the store refuses the reads and
20021
+ // writes themselves.
20022
+ async counters(ids) {
20023
+ const who = commitment();
20024
+ return who ? counts.counters(who, ids) : ids.map(() => null);
20025
+ },
20026
+ async begin(id) {
20027
+ const who = commitment();
20028
+ return who ? counts.begin(who, id) : null;
20029
+ },
20030
+ async bump(id) {
20031
+ const who = commitment();
20032
+ return who ? counts.bump(who, id) : null;
20033
+ },
20034
+ async holdMoving(id, shared = false) {
20035
+ const who = commitment();
20036
+ // Signed out: the store refuses the writes themselves. Refused Web
20037
+ // Locks, no tab of the app can hold this lock.
20038
+ if (!who || refused)
20039
+ return () => { };
20040
+ const name = `muhkoo:vfs:${who}:moving:${id}`;
20041
+ // Let go of by this tab, but perhaps not yet by the browser, which
20042
+ // lets go only once the callback below has settled. Asked for
20043
+ // before then - a move or a delete straight after another of the
20044
+ // same entry - it is still held, and this tab refuses itself.
20045
+ const before = leaving.get(name);
20046
+ if (before)
20047
+ await before;
20048
+ return new Promise((resolve, reject) => {
20049
+ let answered = false;
20050
+ const request = locks.request(name, { ifAvailable: true, mode: shared ? "shared" : "exclusive" }, async (lock) => {
20051
+ answered = true;
20052
+ if (!lock) {
20053
+ resolve(null);
20054
+ return;
20055
+ }
20056
+ // Held until this settles: when the function handed back
20057
+ // is called, or when the tab goes and the browser lets go
20058
+ // of everything it held.
20059
+ let held = true;
20060
+ await new Promise((letGo) => resolve(() => {
20061
+ if (!held)
20062
+ return;
20063
+ held = false;
20064
+ // Settles once the browser has let go of it too,
20065
+ // and of every other hold of it this tab has let
20066
+ // go of: two moves into one folder hold it at once.
20067
+ const gone = Promise.all([leaving.get(name), request.then(() => { }, () => { })]).then(() => { });
20068
+ leaving.set(name, gone);
20069
+ void gone.then(() => {
20070
+ if (leaving.get(name) === gone)
20071
+ leaving.delete(name);
20072
+ });
20073
+ letGo();
20074
+ }));
20075
+ });
20076
+ request.catch((err) => {
20077
+ if (answered || !refusesLocks(err)) {
20078
+ reject(err);
20079
+ return;
20080
+ }
20081
+ refused = true;
20082
+ resolve(() => { });
20083
+ });
20084
+ });
20085
+ },
20086
+ close() {
20087
+ open?.channel?.close();
20088
+ open = null;
20089
+ counts.close();
20090
+ },
20091
+ };
20092
+ }
20093
+
18608
20094
  /**
18609
20095
  * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
18610
20096
  * lexicographically-sortable string so the whole offline layer (and the
@@ -18882,35 +20368,66 @@ class ConnectivityManager {
18882
20368
  * mutation by re-invoking the real namespace method (with an `isReplay` flag so
18883
20369
  * it doesn't re-enqueue). `drain()` replays entries in HLC order:
18884
20370
  * - success → remove the entry;
18885
- * - permanent failure (4xx, or a Space whose member list refuses this user)
18886
- * → drop it and surface a `SYNC_ERROR` (a write the server will never
18887
- * accept must not wedge the queue forever);
20371
+ * - refused for payment (`PaymentRequiredError`: the app takes paid plans
20372
+ * only, or someone's payment is past due) → keep it and everything after
20373
+ * it, stop, and surface a `SYNC_ERROR` with `dropped: false`. The server
20374
+ * accepts it once the user or the app owner pays, so it waits for the next
20375
+ * reconnect, or for `client.app.access()` to report the app allowed;
20376
+ * - permanent failure (any other 4xx, or a Space whose member list refuses
20377
+ * this user) → drop it and surface a `SYNC_ERROR` (a write the server will
20378
+ * never accept must not wedge the queue forever);
18888
20379
  * - transient/network failure → stop draining and leave the rest for the next
18889
20380
  * reconnect.
18890
20381
  *
18891
20382
  * Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
18892
20383
  * by `clientId`, file PUTs are content-addressed), so re-running a partially
18893
20384
  * drained queue is safe.
20385
+ *
20386
+ * Every tab of an origin shares the queue. Given a `tryLock`, a drain runs
20387
+ * only while holding `muhkoo:offline-drain:<commitment>`, so two tabs coming
20388
+ * back online replay it once between them, not once each.
18894
20389
  */
18895
20390
  class OutboundQueue {
18896
20391
  store;
20392
+ opts;
18897
20393
  replayers = new Map();
18898
20394
  draining = null;
18899
- constructor(store) {
20395
+ constructor(store, opts = {}) {
18900
20396
  this.store = store;
20397
+ this.opts = opts;
18901
20398
  }
18902
20399
  /** A domain registers how to replay its mutations. */
18903
20400
  register(domain, replayer) {
18904
20401
  this.replayers.set(domain, replayer);
18905
20402
  }
18906
- /** Record a mutation for later replay; resolves with its assigned seq. */
20403
+ /**
20404
+ * Record a mutation for later replay; resolves with its assigned seq.
20405
+ * Stamped with the signed-in user's commitment, so that it is only ever
20406
+ * sent under their session (see {@link waiting}).
20407
+ */
18907
20408
  enqueue(entry) {
18908
- return this.store.enqueue(entry);
20409
+ const who = entry.commitment ?? this.opts.commitment?.();
20410
+ return this.store.enqueue(who ? { ...entry, commitment: who } : entry);
18909
20411
  }
18910
- /** Number of writes still awaiting sync. */
20412
+ /** Number of writes still awaiting sync, every user's. */
18911
20413
  async pending() {
18912
20414
  return (await this.store.queued()).length;
18913
20415
  }
20416
+ /**
20417
+ * The writes a drain would send now, in order: the signed-in user's, and
20418
+ * any an older SDK queued without saying whose they are. Every tab of an
20419
+ * origin shares this queue, and every replayer builds its request from
20420
+ * the session signed in when it runs, so another user's write sent now
20421
+ * would land in this user's space, or overwrite their data. It stays
20422
+ * queued, as it is, until its own user signs in on this device again.
20423
+ */
20424
+ async waiting() {
20425
+ return (await this.store.queued()).filter((entry) => this.sendable(entry));
20426
+ }
20427
+ /** True for an entry this drain may send: the signed-in user's, or one with no user. */
20428
+ sendable(entry) {
20429
+ return !entry.commitment || entry.commitment === this.opts.commitment?.();
20430
+ }
18914
20431
  /**
18915
20432
  * Replay queued mutations in HLC order. Coalesces concurrent calls into a
18916
20433
  * single in-flight drain. Stops on the first transient failure, leaving the
@@ -18919,14 +20436,49 @@ class OutboundQueue {
18919
20436
  drain() {
18920
20437
  if (this.draining)
18921
20438
  return this.draining;
18922
- this.draining = this.runDrain().finally(() => {
20439
+ this.draining = this.drainOnce().finally(() => {
18923
20440
  this.draining = null;
18924
20441
  });
18925
20442
  return this.draining;
18926
20443
  }
20444
+ /**
20445
+ * One drain, holding the device-wide drain lock where there is one. A
20446
+ * tab that finds another tab holding it leaves the queue to that one,
20447
+ * which is replaying it already and removes each entry as it lands - so
20448
+ * a drain after it finds only what it left.
20449
+ */
20450
+ async drainOnce() {
20451
+ const who = this.opts.commitment?.();
20452
+ if (!this.opts.tryLock || !who) {
20453
+ await this.runDrain();
20454
+ return;
20455
+ }
20456
+ await this.opts.tryLock(`muhkoo:offline-drain:${who}`, async () => {
20457
+ // A tab that found this one draining skipped its own drain, and
20458
+ // what it queued after this pass read the queue was left to this
20459
+ // tab. So drain until a pass leaves nothing of this user's; a
20460
+ // transient failure still stops it, by throwing. So does a write
20461
+ // refused for payment, which stays queued: another pass would
20462
+ // only be refused again, at once and without end. Another user's
20463
+ // writes never leave, so counting them would loop for ever too.
20464
+ do {
20465
+ if (!(await this.runDrain()))
20466
+ return;
20467
+ } while ((await this.waiting()).length > 0);
20468
+ });
20469
+ }
20470
+ /**
20471
+ * One pass over the queue. False when a write refused for payment stopped
20472
+ * it, which leaves that write and everything after it queued.
20473
+ */
18927
20474
  async runDrain() {
18928
20475
  const entries = await this.store.queued();
18929
20476
  for (const entry of entries) {
20477
+ // Another user's write, theirs to send under their own session.
20478
+ // Asked per entry, so a sign-out in the middle of a pass stops
20479
+ // the rest of it going out under the next user's session.
20480
+ if (!this.sendable(entry))
20481
+ continue;
18930
20482
  const replayer = this.replayers.get(entry.domain);
18931
20483
  if (!replayer) {
18932
20484
  // No handler registered (namespace not wired) — drop it rather
@@ -18943,6 +20495,22 @@ class OutboundQueue {
18943
20495
  });
18944
20496
  }
18945
20497
  catch (err) {
20498
+ // Asked before `isPermanent`, which would drop it as a 4xx.
20499
+ if (err instanceof PaymentRequiredError) {
20500
+ // The user's plan, or a payment past due: the server will
20501
+ // accept this write once that changes. Keep it, and keep
20502
+ // every write after it in order; a delete queued after an
20503
+ // upload never goes first. Stop rather than send the rest,
20504
+ // which the server would refuse for the same reason.
20505
+ EventCore.emit(EventCoreEvents.SYNC_ERROR, {
20506
+ domain: entry.domain,
20507
+ method: entry.method,
20508
+ error: err.message,
20509
+ code: err.code,
20510
+ dropped: false,
20511
+ });
20512
+ return false;
20513
+ }
18946
20514
  if (isPermanent(err)) {
18947
20515
  await this.store.dequeue(entry.seq);
18948
20516
  EventCore.emit(EventCoreEvents.SYNC_ERROR, {
@@ -18957,12 +20525,14 @@ class OutboundQueue {
18957
20525
  throw err;
18958
20526
  }
18959
20527
  }
20528
+ return true;
18960
20529
  }
18961
20530
  }
18962
20531
  /** A 4xx is the server permanently refusing the write, and so is a Space whose
18963
20532
  * member list does not admit this user (design §4.5.12): the relay refuses
18964
20533
  * their frames. Anything else, a locked session included, is worth retrying
18965
- * on the next reconnect. */
20534
+ * on the next reconnect. A refusal for payment is a 4xx that is not
20535
+ * permanent; `runDrain` keeps it before asking this. */
18966
20536
  function isPermanent(err) {
18967
20537
  if (err instanceof AdmissionDeniedError)
18968
20538
  return true;
@@ -19170,6 +20740,14 @@ const META_NODE_ID = "nodeId";
19170
20740
  * stay decoupled from IndexedDB and the clock.
19171
20741
  */
19172
20742
  const HLC_STATE_KEY = "global";
20743
+ /**
20744
+ * A random suffix for this tab's HLC node id, held in memory only - see
20745
+ * `OfflineManager.boot`. 48 bits: two tabs of one device never draw the same.
20746
+ */
20747
+ function tabSuffix() {
20748
+ const bytes = crypto.getRandomValues(new Uint8Array(6));
20749
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
20750
+ }
19173
20751
  class OfflineManager {
19174
20752
  /** True in browsers with IndexedDB; false when running over a NoopStore. */
19175
20753
  enabled;
@@ -19188,7 +20766,10 @@ class OfflineManager {
19188
20766
  constructor(deps) {
19189
20767
  this.enabled = deps.enabled;
19190
20768
  this.store = deps.store;
19191
- this.queue = new OutboundQueue(deps.store);
20769
+ this.queue = new OutboundQueue(deps.store, {
20770
+ tryLock: deps.locks,
20771
+ commitment: () => deps.session.commitment,
20772
+ });
19192
20773
  this.connectivity = new ConnectivityManager({
19193
20774
  onReconnect: () => void this.sync.run(),
19194
20775
  });
@@ -19224,6 +20805,22 @@ class OfflineManager {
19224
20805
  args: { hash },
19225
20806
  });
19226
20807
  }
20808
+ /**
20809
+ * Replay the queue now, as a reconnect does, if anything is waiting in it.
20810
+ * `client.app.access()` calls this each time it reports the app allowed,
20811
+ * so writes kept after a refusal for payment go out once the user or the
20812
+ * app owner has paid, without waiting for the next reconnect. One drain per
20813
+ * call, never on a timer. With nothing of the signed-in user's queued it
20814
+ * does not drain at all, so the app sees no "syncing". A drain also runs the
20815
+ * catch-up tasks (Space history paging), as a reconnect does.
20816
+ */
20817
+ async resume() {
20818
+ if (!this.enabled)
20819
+ return;
20820
+ if ((await this.queue.waiting()).length === 0)
20821
+ return;
20822
+ await this.sync.run();
20823
+ }
19227
20824
  /** Resolve once the clock + node id are loaded. */
19228
20825
  ready() {
19229
20826
  return this.bootPromise;
@@ -19286,14 +20883,22 @@ class OfflineManager {
19286
20883
  this.hlc = new HLC(this.nodeId);
19287
20884
  return;
19288
20885
  }
19289
- let nodeId = await this.store.get("meta", META_NODE_ID);
19290
- if (!nodeId) {
19291
- nodeId = generateId();
19292
- await this.store.put("meta", META_NODE_ID, nodeId);
19293
- }
19294
- this.nodeId = nodeId;
20886
+ let deviceId = await this.store.get("meta", META_NODE_ID);
20887
+ if (!deviceId) {
20888
+ deviceId = generateId();
20889
+ await this.store.put("meta", META_NODE_ID, deviceId);
20890
+ }
20891
+ // One node id per TAB, under the device's. Every tab of an origin
20892
+ // loads the same stored id, and two tabs stamping with it in the same
20893
+ // millisecond minted identical HLCs: the node id is the tiebreak, and
20894
+ // the server keeps the first of two equal stamps, so the second write
20895
+ // was dropped. The suffix lives in memory only. The clock state below
20896
+ // stays shared, so the clock never goes back across tabs, and stamps
20897
+ // stored already stay valid: every comparison reads a packed stamp as
20898
+ // one string and never parses the node id.
20899
+ this.nodeId = `${deviceId}.${tabSuffix()}`;
19295
20900
  const state = await this.store.get("hlc-state", HLC_STATE_KEY);
19296
- this.hlc = new HLC(nodeId, { state });
20901
+ this.hlc = new HLC(this.nodeId, { state });
19297
20902
  }
19298
20903
  /** Coalesce HLC-state writes — a burst of stamps persists once. */
19299
20904
  schedulePersist() {
@@ -19765,6 +21370,8 @@ class Client {
19765
21370
  functions;
19766
21371
  /** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
19767
21372
  accessTokens;
21373
+ /** This app — `client.app.access()`: may the signed-in user add data here? */
21374
+ app;
19768
21375
  /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
19769
21376
  offline;
19770
21377
  session;
@@ -19801,6 +21408,8 @@ class Client {
19801
21408
  store: offlineStore,
19802
21409
  session: this.session,
19803
21410
  enabled: offlineEnabled,
21411
+ // Every tab of the origin shares the queue: one drains it at a time.
21412
+ locks: drainLock(),
19804
21413
  });
19805
21414
  // Wrap fetch so every accelerator round-trip feeds the connectivity
19806
21415
  // detector: a thrown fetch is the strongest "offline" signal, a 2xx the
@@ -19921,6 +21530,9 @@ class Client {
19921
21530
  this.vfs = new VfsNamespace({
19922
21531
  store: personalStore,
19923
21532
  seed: () => this.session.seed,
21533
+ // Orders writes of a folder with this app's other tabs on the
21534
+ // device - undefined outside a browser tab with Web Locks.
21535
+ coordinator: vfsCoordinator(() => this.session.commitment),
19924
21536
  // Share the personal space's socket with `client.kv` rather than
19925
21537
  // opening a second one to the same Durable Object.
19926
21538
  subscribe: (handler) => this.kv.onRaw(handler),
@@ -19939,12 +21551,15 @@ class Client {
19939
21551
  // the capability path, so it keeps working for a file whose
19940
21552
  // handle was shared from someone else's filesystem.
19941
21553
  read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
19942
- release: async (manifest) => {
19943
- await (await resolveContentSpace()).releaseFile(manifest);
19944
- },
19945
- retain: async (manifest) => {
19946
- await (await resolveContentSpace()).commitFile(manifest);
19947
- },
21554
+ // A reference names only shards, so taking or dropping one needs
21555
+ // no Space. Opening the content space here created it for a user
21556
+ // who had never written a file: a checkout, a copy, a delete, or
21557
+ // Cloud Drive's move of its pre-VFS files. A paid-only app refuses
21558
+ // that creation for a Free user (accelerator spec
21559
+ // 2026-10-06-paid-accounts-and-past-due, D12). Same body as
21560
+ // `Space.commitFile`, so it is the same reference.
21561
+ release: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "release"),
21562
+ retain: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "commit"),
19948
21563
  },
19949
21564
  });
19950
21565
  // Sealed into the SAME personal store as the VFS metadata, so a project
@@ -20013,6 +21628,9 @@ class Client {
20013
21628
  this.agents = new AgentsNamespace({ http: this.http });
20014
21629
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
20015
21630
  this.accessTokens = new AccessTokensNamespace({ http: this.http });
21631
+ // Each "allowed" sends what the offline queue kept after a refusal for
21632
+ // payment, without waiting for the next reconnect.
21633
+ this.app = new AppNamespace({ http: this.http, onAllowed: () => this.offline.resume() });
20016
21634
  // Replay deferred shard uploads on reconnect. The bytes live in the
20017
21635
  // Cache API keyed by hash; re-PUT them through a (defer-free) shard
20018
21636
  // client so a failure surfaces as transient and the entry stays queued.
@@ -20021,6 +21639,7 @@ class Client {
20021
21639
  baseUrl: this.baseUrl,
20022
21640
  pathPrefix: "/api/shards",
20023
21641
  fetch: this.http.fetch,
21642
+ fetchWithRecovery: this.http.fetchWithRecovery,
20024
21643
  cache: this.offline.fileCache,
20025
21644
  });
20026
21645
  this.offline.registerReplayer("file", async (entry) => {
@@ -20432,5 +22051,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
20432
22051
  default: loadRsWasm
20433
22052
  });
20434
22053
 
20435
- 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 };
22054
+ 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 };
20436
22055
  //# sourceMappingURL=index.js.map