@muhkoo/connect 0.15.0-alpha.0 → 0.15.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/connect.d.ts CHANGED
@@ -2463,7 +2463,7 @@ declare class ShardClient {
2463
2463
  private readonly pathPrefix;
2464
2464
  private readonly fetchFn;
2465
2465
  private readonly cache?;
2466
- /** Hashes waiting for the next batch, each with everyone awaiting it. */
2466
+ /** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
2467
2467
  private pendingShards;
2468
2468
  private batchTimer;
2469
2469
  /** False once the server is known not to have the batch route. */
@@ -2496,8 +2496,13 @@ declare class ShardClient {
2496
2496
  /**
2497
2497
  * Fetch a shard by hash. Throws if the server returns a non-200; returns
2498
2498
  * `null` for 404 so callers can distinguish "missing" from "errored".
2499
+ *
2500
+ * `size`, the shard's byte length when known (a manifest's `shardSize`),
2501
+ * lets concurrent reads be batched within what the server will send.
2499
2502
  */
2500
- getShard(hash: string): Promise<Uint8Array | null>;
2503
+ getShard(hash: string, opts?: {
2504
+ size?: number;
2505
+ }): Promise<Uint8Array | null>;
2501
2506
  /**
2502
2507
  * Fetch from origin, COALESCING concurrent requests into one batch.
2503
2508
  *
@@ -2520,6 +2525,8 @@ declare class ShardClient {
2520
2525
  private fetchFromOrigin;
2521
2526
  /** Send one batch of whatever has accumulated, and settle those callers. */
2522
2527
  private flushShardBatch;
2528
+ /** POST one batch and settle everyone waiting on it. Never throws. */
2529
+ private sendShardBatch;
2523
2530
  /** Fetch a shard from origin. Returns `null` for 404 (or offline when a
2524
2531
  * cache is present, so RS recovery can proceed); throws otherwise. */
2525
2532
  private fetchOneFromOrigin;
@@ -6737,11 +6744,19 @@ declare class VfsLimitError extends Error {
6737
6744
  * Every mutation is a read-modify-write of exactly ONE directory record, so a
6738
6745
  * write cannot leave the tree half-updated: either the parent names the new
6739
6746
  * child or it does not. Two tabs writing different directories never conflict;
6740
- * two tabs writing the SAME directory resolve last-writer-wins at the personal
6741
- * space, which carries an HLC per key. Orphaned records (a directory whose
6742
- * parent entry lost the race) are unreachable rather than corrupting — they
6743
- * cost storage until a future sweep, which is the trade this shape makes in
6747
+ * two tabs (or devices) writing the SAME directory are last-writer-wins. A VFS
6748
+ * write sends no HLC and the personal space has no compare-and-swap: it applies
6749
+ * puts of one key in the order they arrive, so whichever arrives last is kept,
6750
+ * and the other tab's edit is lost. Orphaned records (a directory whose parent
6751
+ * entry lost the race) are unreachable rather than corrupting — they cost
6752
+ * storage until a future sweep, which is the trade this shape makes in
6744
6753
  * exchange for single-record writes.
6754
+ *
6755
+ * Within ONE tab, writes of a record are queued and sent one at a time, in the
6756
+ * order they were made, and a read that has to fetch a record waits for its
6757
+ * write in flight (see `writing`). That ordering is per namespace instance
6758
+ * only; nothing orders writes across tabs or devices - that would take a
6759
+ * compare-and-swap at the server.
6745
6760
  */
6746
6761
 
6747
6762
  /**
@@ -6757,6 +6772,8 @@ interface VfsContentStore {
6757
6772
  write(data: Uint8Array | Blob, meta: {
6758
6773
  name: string;
6759
6774
  type: string;
6775
+ }, opts?: {
6776
+ onProgress?: (done: number, total: number) => void;
6760
6777
  }): Promise<{
6761
6778
  manifest: FileManifest;
6762
6779
  size: number;
@@ -6817,6 +6834,20 @@ declare class VfsNamespace {
6817
6834
  * than by TTL — a stale directory listing shows files that are not there.
6818
6835
  */
6819
6836
  private cache;
6837
+ /**
6838
+ * The write of each directory record still in flight, by id.
6839
+ *
6840
+ * Two writes of one record must land in the order they were made. The
6841
+ * personal space has no compare-and-swap: of two writes of one key that
6842
+ * are in flight together it keeps whichever request finishes last, and
6843
+ * that is often the one sent first. Then the earlier write, sealed before
6844
+ * the later edit was made, undoes it. That is how a file deleted forever
6845
+ * from the Trash, while the move that put it there was still being
6846
+ * written, came back. So each write waits for the one before it, and a
6847
+ * read that has to fetch the record waits for its write too: see
6848
+ * `openDirById`.
6849
+ */
6850
+ private readonly writing;
6820
6851
  private readonly historyLimit;
6821
6852
  private readonly maxEntries;
6822
6853
  private readonly maxPathChars;
@@ -6975,6 +7006,7 @@ declare class VfsNamespace {
6975
7006
  */
6976
7007
  writeFile(path: string, data: Uint8Array | string | Blob, opts?: {
6977
7008
  type?: string;
7009
+ onProgress?: (done: number, total: number) => void;
6978
7010
  }): Promise<VfsStat>;
6979
7011
  /**
6980
7012
  * Delete a file, or a directory when `recursive`.
@@ -7037,8 +7069,11 @@ declare class VfsNamespace {
7037
7069
  * Delete metadata records no longer reachable from the root.
7038
7070
  *
7039
7071
  * These accumulate because every write touches exactly one record: if two
7040
- * tabs edit the same directory, last-writer-wins at the personal space can
7041
- * leave a child whose parent entry lost the race. Unreachable is harmless —
7072
+ * tabs or devices edit the same directory, the personal space keeps
7073
+ * whichever put arrives last (a VFS write sends no HLC, and the space
7074
+ * applies puts of one key in arrival order), which can leave a child whose
7075
+ * parent entry lost the race. The per-record write queue (`writing`) only
7076
+ * orders writes within one tab. Unreachable is harmless —
7042
7077
  * it is invisible and undecryptable-by-nobody — but it costs storage, so
7043
7078
  * this reclaims it.
7044
7079
  *
@@ -7088,6 +7123,14 @@ declare class VfsNamespace {
7088
7123
  private readRecord;
7089
7124
  /** Apply an edit to a directory record and persist it. */
7090
7125
  private mutate;
7126
+ /**
7127
+ * Seal a directory record as it stands NOW and put it in the store.
7128
+ *
7129
+ * Read at the moment of sending, not when the edit was made, so a write
7130
+ * that waited behind another carries every edit made to the record by
7131
+ * then, its own included.
7132
+ */
7133
+ private writeRecord;
7091
7134
  private createDir;
7092
7135
  /**
7093
7136
  * Every directory record in a subtree, each once, opened - and nothing
@@ -8975,7 +9018,7 @@ declare function getId(row: {
8975
9018
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
8976
9019
  * `package.json` `version`.
8977
9020
  */
8978
- declare const VERSION = "0.15.0-alpha.0";
9021
+ declare const VERSION = "0.15.0-alpha.2";
8979
9022
 
8980
9023
  /**
8981
9024
  * The SDK's process-global logger.
@@ -5862,6 +5862,34 @@ class ReedSolomonCodec {
5862
5862
  const SHARD_BATCH_WINDOW_MS = 6;
5863
5863
  /** Hashes per batch. Must not exceed what the server will accept. */
5864
5864
  const SHARD_BATCH_MAX = 256;
5865
+ /**
5866
+ * Expected response bytes per batch, which a batch is split to stay under.
5867
+ *
5868
+ * The server refuses a batch above 24 MiB of shard bytes with a 413, and the
5869
+ * reads in a refused batch fail. Counting hashes alone, about 25 one-MiB
5870
+ * shards in one window - a few large files read at once - drew it. This
5871
+ * leaves the server's ceiling well clear.
5872
+ */
5873
+ const SHARD_BATCH_BUDGET_BYTES = 16 * 1024 * 1024;
5874
+ /** An entry's JSON around its base64: the quoted hash, a colon, quotes and a comma, rounded up. */
5875
+ const SHARD_BATCH_ENTRY_BYTES = 128;
5876
+ /**
5877
+ * What a shard adds to a batch's response: base64 is 4 bytes for every 3.
5878
+ *
5879
+ * A shard whose size the caller did not give counts as its entry alone, so
5880
+ * reads without sizes batch by count, as they always have — kept for the
5881
+ * pinned "many concurrent reads → ONE request" test
5882
+ * (`shard-batching.test.ts`). No SDK read path is size-less today:
5883
+ * `FileStorage.readChunk` always passes `shardSize`. The server reads and
5884
+ * base64-encodes every shard before it checks the total
5885
+ * (`ShardsController.ts`), so a very large size-less batch can fail with a
5886
+ * 5xx before the client ever sees a 413. The split in `sendShardBatch` is a
5887
+ * best-effort net for that case, not a guarantee.
5888
+ */
5889
+ function batchCost(size) {
5890
+ const bytes = typeof size === "number" && Number.isFinite(size) && size > 0 ? size : 0;
5891
+ return Math.ceil(bytes / 3) * 4 + SHARD_BATCH_ENTRY_BYTES;
5892
+ }
5865
5893
  /** Decode base64 without `atob`'s per-character loop cost on large shards. */
5866
5894
  function bytesFromBase64(b64) {
5867
5895
  const binary = atob(b64);
@@ -5889,7 +5917,7 @@ class ShardClient {
5889
5917
  pathPrefix;
5890
5918
  fetchFn;
5891
5919
  cache;
5892
- /** Hashes waiting for the next batch, each with everyone awaiting it. */
5920
+ /** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
5893
5921
  pendingShards;
5894
5922
  batchTimer;
5895
5923
  /** False once the server is known not to have the batch route. */
@@ -6038,8 +6066,11 @@ class ShardClient {
6038
6066
  /**
6039
6067
  * Fetch a shard by hash. Throws if the server returns a non-200; returns
6040
6068
  * `null` for 404 so callers can distinguish "missing" from "errored".
6069
+ *
6070
+ * `size`, the shard's byte length when known (a manifest's `shardSize`),
6071
+ * lets concurrent reads be batched within what the server will send.
6041
6072
  */
6042
- async getShard(hash) {
6073
+ async getShard(hash, opts = {}) {
6043
6074
  // Cache-first: a hit is content-verified by construction (key == hash).
6044
6075
  if (this.cache) {
6045
6076
  const cached = await this.cache.get(hash);
@@ -6048,7 +6079,7 @@ class ShardClient {
6048
6079
  }
6049
6080
  // No peers → straight to origin.
6050
6081
  if (!this.peers)
6051
- return this.fetchFromOrigin(hash);
6082
+ return this.fetchFromOrigin(hash, opts.size);
6052
6083
  // HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
6053
6084
  // or missing peer stall delivery — give the peer a short head start, then
6054
6085
  // start origin in parallel and take whichever returns the block first.
@@ -6079,7 +6110,7 @@ class ShardClient {
6079
6110
  clearTimeout(timer);
6080
6111
  timer = null;
6081
6112
  }
6082
- this.fetchFromOrigin(hash).then(win, fail);
6113
+ this.fetchFromOrigin(hash, opts.size).then(win, fail);
6083
6114
  };
6084
6115
  timer = setTimeout(startOrigin, HEDGE_MS);
6085
6116
  peers.getBlock(hash).then((b) => {
@@ -6113,16 +6144,17 @@ class ShardClient {
6113
6144
  * Falls back permanently to one-at-a-time if the server has no batch route,
6114
6145
  * so a new SDK keeps working against an older deployment.
6115
6146
  */
6116
- fetchFromOrigin(hash) {
6147
+ fetchFromOrigin(hash, size) {
6117
6148
  if (!this.batchSupported)
6118
6149
  return this.fetchOneFromOrigin(hash);
6119
6150
  return new Promise((resolve, reject) => {
6120
- const waiting = this.pendingShards.get(hash);
6121
- if (waiting) {
6122
- waiting.push({ resolve, reject });
6151
+ const pending = this.pendingShards.get(hash);
6152
+ if (pending) {
6153
+ pending.waiters.push({ resolve, reject });
6154
+ pending.size ??= size;
6123
6155
  return;
6124
6156
  }
6125
- this.pendingShards.set(hash, [{ resolve, reject }]);
6157
+ this.pendingShards.set(hash, { size, waiters: [{ resolve, reject }] });
6126
6158
  if (this.batchTimer === null) {
6127
6159
  this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
6128
6160
  }
@@ -6133,17 +6165,33 @@ class ShardClient {
6133
6165
  this.batchTimer = null;
6134
6166
  if (this.pendingShards.size === 0)
6135
6167
  return;
6136
- const hashes = [...this.pendingShards.keys()].slice(0, SHARD_BATCH_MAX);
6137
- const waiters = hashes.map((h) => {
6138
- const w = this.pendingShards.get(h);
6168
+ // As many as fit: at most SHARD_BATCH_MAX hashes, and a response
6169
+ // expected to stay within SHARD_BATCH_BUDGET_BYTES. One that does not
6170
+ // fit is skipped for a smaller one behind it. The first always goes,
6171
+ // so a shard over the budget by itself is fetched alone.
6172
+ const waiters = [];
6173
+ let cost = 0;
6174
+ for (const [h, pending] of this.pendingShards) {
6175
+ if (waiters.length === SHARD_BATCH_MAX)
6176
+ break;
6177
+ const next = batchCost(pending.size);
6178
+ if (waiters.length > 0 && cost + next > SHARD_BATCH_BUDGET_BYTES)
6179
+ continue;
6180
+ waiters.push([h, pending.waiters]);
6181
+ cost += next;
6182
+ }
6183
+ for (const [h] of waiters)
6139
6184
  this.pendingShards.delete(h);
6140
- return [h, w];
6141
- });
6142
- // Anything over the cap waits for the next window rather than being
6143
- // dropped or silently truncated.
6185
+ // Anything over the cap or the budget waits for the next window
6186
+ // rather than being dropped or silently truncated.
6144
6187
  if (this.pendingShards.size > 0 && this.batchTimer === null) {
6145
6188
  this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
6146
6189
  }
6190
+ await this.sendShardBatch(waiters);
6191
+ }
6192
+ /** POST one batch and settle everyone waiting on it. Never throws. */
6193
+ async sendShardBatch(waiters) {
6194
+ const hashes = waiters.map(([h]) => h);
6147
6195
  let shards;
6148
6196
  try {
6149
6197
  const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {
@@ -6151,6 +6199,21 @@ class ShardClient {
6151
6199
  headers: { "Content-Type": "application/json" },
6152
6200
  body: JSON.stringify({ hashes }),
6153
6201
  });
6202
+ if (res.status === 413 && waiters.length > 1) {
6203
+ // Too large for the server after all: a size was not given,
6204
+ // or was wrong. Ask for each half on its own. Halving reaches
6205
+ // one hash per batch within log2(n) levels, and one hash the
6206
+ // server refuses is an error like any other, below.
6207
+ // Release the body first: under undici (the CLI, Node), an
6208
+ // unread body holds the connection until garbage collection.
6209
+ await res.body?.cancel().catch(() => { });
6210
+ const half = Math.ceil(waiters.length / 2);
6211
+ await Promise.all([
6212
+ this.sendShardBatch(waiters.slice(0, half)),
6213
+ this.sendShardBatch(waiters.slice(half)),
6214
+ ]);
6215
+ return;
6216
+ }
6154
6217
  if (res.status === 404 || res.status === 405) {
6155
6218
  // No batch route on this deployment. Stop trying, and serve
6156
6219
  // everyone individually — including the batch we just gathered.
@@ -6526,7 +6589,8 @@ class FileStorage {
6526
6589
  const fetched = new Array(total).fill(null);
6527
6590
  const fetchInto = async (indexes) => {
6528
6591
  await this.runWithConcurrency(indexes.map((i) => async () => {
6529
- fetched[i] = await this.shards.getShard(manifest.shardHashes[i]);
6592
+ // The size keeps a batch of concurrent reads within what the server will send.
6593
+ fetched[i] = await this.shards.getShard(manifest.shardHashes[i], { size: manifest.shardSize });
6530
6594
  }));
6531
6595
  };
6532
6596
  await fetchInto(Array.from({ length: dataCount }, (_, i) => i));
@@ -6675,7 +6739,7 @@ async function parseOrThrow(response, op) {
6675
6739
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6676
6740
  * `package.json` `version`.
6677
6741
  */
6678
- const VERSION = "0.15.0-alpha.0";
6742
+ const VERSION = "0.15.0-alpha.2";
6679
6743
 
6680
6744
  /**
6681
6745
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -15082,11 +15146,19 @@ function nextTurn() {
15082
15146
  * Every mutation is a read-modify-write of exactly ONE directory record, so a
15083
15147
  * write cannot leave the tree half-updated: either the parent names the new
15084
15148
  * child or it does not. Two tabs writing different directories never conflict;
15085
- * two tabs writing the SAME directory resolve last-writer-wins at the personal
15086
- * space, which carries an HLC per key. Orphaned records (a directory whose
15087
- * parent entry lost the race) are unreachable rather than corrupting — they
15088
- * cost storage until a future sweep, which is the trade this shape makes in
15149
+ * two tabs (or devices) writing the SAME directory are last-writer-wins. A VFS
15150
+ * write sends no HLC and the personal space has no compare-and-swap: it applies
15151
+ * puts of one key in the order they arrive, so whichever arrives last is kept,
15152
+ * and the other tab's edit is lost. Orphaned records (a directory whose parent
15153
+ * entry lost the race) are unreachable rather than corrupting — they cost
15154
+ * storage until a future sweep, which is the trade this shape makes in
15089
15155
  * exchange for single-record writes.
15156
+ *
15157
+ * Within ONE tab, writes of a record are queued and sent one at a time, in the
15158
+ * order they were made, and a read that has to fetch a record waits for its
15159
+ * write in flight (see `writing`). That ordering is per namespace instance
15160
+ * only; nothing orders writes across tabs or devices - that would take a
15161
+ * compare-and-swap at the server.
15090
15162
  */
15091
15163
  /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
15092
15164
  function assertWritableDepth(path) {
@@ -15458,6 +15530,20 @@ class VfsNamespace {
15458
15530
  * than by TTL — a stale directory listing shows files that are not there.
15459
15531
  */
15460
15532
  cache = new Map();
15533
+ /**
15534
+ * The write of each directory record still in flight, by id.
15535
+ *
15536
+ * Two writes of one record must land in the order they were made. The
15537
+ * personal space has no compare-and-swap: of two writes of one key that
15538
+ * are in flight together it keeps whichever request finishes last, and
15539
+ * that is often the one sent first. Then the earlier write, sealed before
15540
+ * the later edit was made, undoes it. That is how a file deleted forever
15541
+ * from the Trash, while the move that put it there was still being
15542
+ * written, came back. So each write waits for the one before it, and a
15543
+ * read that has to fetch the record waits for its write too: see
15544
+ * `openDirById`.
15545
+ */
15546
+ writing = new Map();
15461
15547
  historyLimit;
15462
15548
  maxEntries;
15463
15549
  maxPathChars;
@@ -15921,10 +16007,7 @@ class VfsNamespace {
15921
16007
  const parentPath = dirname(target);
15922
16008
  await this.mkdir(parentPath, { recursive: true });
15923
16009
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
15924
- const { manifest, size } = await this.deps.content.write(bytes, {
15925
- name,
15926
- type: opts.type ?? contentTypeFor(name),
15927
- });
16010
+ const { manifest, size } = await this.deps.content.write(bytes, { name, type: opts.type ?? contentTypeFor(name) }, { onProgress: opts.onProgress });
15928
16011
  const parent = await this.openDir(parentPath);
15929
16012
  const prior = entryIn(parent.node, name);
15930
16013
  if (prior?.kind === "dir")
@@ -16211,8 +16294,11 @@ class VfsNamespace {
16211
16294
  * Delete metadata records no longer reachable from the root.
16212
16295
  *
16213
16296
  * These accumulate because every write touches exactly one record: if two
16214
- * tabs edit the same directory, last-writer-wins at the personal space can
16215
- * leave a child whose parent entry lost the race. Unreachable is harmless —
16297
+ * tabs or devices edit the same directory, the personal space keeps
16298
+ * whichever put arrives last (a VFS write sends no HLC, and the space
16299
+ * applies puts of one key in arrival order), which can leave a child whose
16300
+ * parent entry lost the race. The per-record write queue (`writing`) only
16301
+ * orders writes within one tab. Unreachable is harmless —
16216
16302
  * it is invisible and undecryptable-by-nobody — but it costs storage, so
16217
16303
  * this reclaims it.
16218
16304
  *
@@ -16370,7 +16456,41 @@ class VfsNamespace {
16370
16456
  // every concurrent reader to miss and fire its own request for the same
16371
16457
  // record — which is how loading one project turned into thousands of
16372
16458
  // identical `vfs/d/<id>` fetches, most of them in flight at once.
16373
- const pending = (async () => {
16459
+ // Reads `pending` only after its first `await`: before that, the const is not yet initialised.
16460
+ const load = async () => {
16461
+ // Not while a write of this record is still in flight: see
16462
+ // `writing`. Our own writes echo back over the personal-space
16463
+ // socket, and `watch` drops the record each one names, so a read
16464
+ // can miss with the write still on its way - and the store would
16465
+ // answer with the copy from BEFORE it. An edit made to that copy
16466
+ // is queued behind the write, and then stored over it. So wait
16467
+ // for the latest write queued, which waits on every one before it,
16468
+ // to land or fail - and then for any queued while this read
16469
+ // waited, since the one it waited for may have failed with that
16470
+ // one still on its way. A record with no write in flight is
16471
+ // fetched at once.
16472
+ let awaited;
16473
+ for (let inFlight = this.writing.get(id); inFlight && inFlight !== awaited; inFlight = this.writing.get(id)) {
16474
+ awaited = inFlight;
16475
+ await inFlight.catch(() => { });
16476
+ }
16477
+ if (awaited) {
16478
+ // A write that landed put the record it wrote back in the
16479
+ // cache, and the next edit of this directory is made to that.
16480
+ // Answer with it rather than fetch a second copy: two copies
16481
+ // of one record, each edited and written, undo each other's
16482
+ // edits. When the cache holds nothing else - the write failed,
16483
+ // or a change since has dropped the record - ask the store.
16484
+ const now = this.cache.get(id);
16485
+ if (now && now !== pending)
16486
+ return now;
16487
+ // Empty, when the write failed: it dropped what the cache held,
16488
+ // and after an echo that was this very read. Put it back before
16489
+ // asking the store, so a read that comes in meanwhile shares
16490
+ // this copy instead of fetching a second one.
16491
+ if (!now)
16492
+ this.cache.set(id, pending);
16493
+ }
16374
16494
  const raw = await this.deps.store.get(dirKey(id));
16375
16495
  // An absent record is an EMPTY directory, not an error: the root
16376
16496
  // exists before anything has been written to it, and a directory is
@@ -16396,7 +16516,8 @@ class VfsNamespace {
16396
16516
  throw err;
16397
16517
  }
16398
16518
  return this.readRecord(id, key, value, path);
16399
- })().catch((err) => {
16519
+ };
16520
+ const pending = load().catch((err) => {
16400
16521
  // A failed read must not be remembered, or one blip poisons the
16401
16522
  // directory for the life of the session.
16402
16523
  if (this.cache.get(id) === pending)
@@ -16482,24 +16603,14 @@ class VfsNamespace {
16482
16603
  if (Object.hasOwn(dir.node.entries, name))
16483
16604
  delete dir.hidden[name];
16484
16605
  }
16485
- const stored = Object.keys(dir.hidden).length
16486
- ? { ...dir.node, entries: Object.assign(Object.create(null), dir.hidden, dir.node.entries) }
16487
- : dir.node;
16606
+ // Not sent until any earlier write of this record has landed: see
16607
+ // `writing`. Whether that one worked or not, this one still goes.
16608
+ const before = this.writing.get(dir.id);
16609
+ const send = () => this.writeRecord(dir);
16610
+ const write = before ? before.then(send, send) : send();
16611
+ this.writing.set(dir.id, write);
16488
16612
  try {
16489
- let sealed;
16490
- try {
16491
- sealed = await seal(dir.key, stored);
16492
- }
16493
- catch (err) {
16494
- // JSON.stringify's own failures: a value too deeply nested to
16495
- // write. Reading drops those from a stored record, so this is
16496
- // what the write itself was given.
16497
- if (!(err instanceof RangeError || err instanceof TypeError))
16498
- throw err;
16499
- const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
16500
- throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
16501
- }
16502
- await this.deps.store.put(dirKey(dir.id), sealed);
16613
+ await write;
16503
16614
  }
16504
16615
  catch (err) {
16505
16616
  // The edit above was made in place, on the very object the cache
@@ -16507,11 +16618,48 @@ class VfsNamespace {
16507
16618
  // change the store never took - a deleted file gone from every
16508
16619
  // listing while it is still stored, and a retry told it does not
16509
16620
  // exist. Drop it, so the next read asks the store.
16510
- this.cache.delete(dir.id);
16621
+ //
16622
+ // Unless a later write of this record is already queued. It
16623
+ // seals the record as it stands when it is sent, this edit
16624
+ // included, so the cache shows what is about to be stored, and
16625
+ // dropping it would only send readers to the store for an older
16626
+ // copy. If that write fails too, it drops the cache itself.
16627
+ if (this.writing.get(dir.id) === write)
16628
+ this.cache.delete(dir.id);
16511
16629
  throw err;
16512
16630
  }
16631
+ finally {
16632
+ if (this.writing.get(dir.id) === write)
16633
+ this.writing.delete(dir.id);
16634
+ }
16513
16635
  this.cache.set(dir.id, Promise.resolve(dir));
16514
16636
  }
16637
+ /**
16638
+ * Seal a directory record as it stands NOW and put it in the store.
16639
+ *
16640
+ * Read at the moment of sending, not when the edit was made, so a write
16641
+ * that waited behind another carries every edit made to the record by
16642
+ * then, its own included.
16643
+ */
16644
+ async writeRecord(dir) {
16645
+ const stored = Object.keys(dir.hidden).length
16646
+ ? { ...dir.node, entries: Object.assign(Object.create(null), dir.hidden, dir.node.entries) }
16647
+ : dir.node;
16648
+ let sealed;
16649
+ try {
16650
+ sealed = await seal(dir.key, stored);
16651
+ }
16652
+ catch (err) {
16653
+ // JSON.stringify's own failures: a value too deeply nested to
16654
+ // write. Reading drops those from a stored record, so this is
16655
+ // what the write itself was given.
16656
+ if (!(err instanceof RangeError || err instanceof TypeError))
16657
+ throw err;
16658
+ const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
16659
+ throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
16660
+ }
16661
+ await this.deps.store.put(dirKey(dir.id), sealed);
16662
+ }
16515
16663
  async createDir(parentPath, name) {
16516
16664
  assertValidName(name);
16517
16665
  const parent = await this.openDir(parentPath);
@@ -19488,9 +19636,9 @@ class Client {
19488
19636
  // GC. `storage.writeFile` would additionally index the manifest
19489
19637
  // in the space — a second, diverging source of truth for a file
19490
19638
  // whose handle the VFS already holds.
19491
- write: async (data, meta) => {
19639
+ write: async (data, meta, opts) => {
19492
19640
  const space = await resolveContentSpace();
19493
- const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
19641
+ const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type }, { onProgress: opts?.onProgress });
19494
19642
  return { manifest, size: stat.size };
19495
19643
  },
19496
19644
  // Reads go through the manifest alone — no space needed. That is