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

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.
@@ -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.1";
6742
+ const VERSION = "0.15.0-alpha.3";
6679
6743
 
6680
6744
  /**
6681
6745
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -8983,6 +9047,45 @@ class StorageCipher {
8983
9047
  }
8984
9048
  }
8985
9049
 
9050
+ /** The server's cap on `prefix`, in code points (accelerator `personalKv/list.ts`). */
9051
+ const MAX_SERVER_PREFIX_CHARS = 1024;
9052
+ /**
9053
+ * The server handed back a cursor it had already given, so following it would
9054
+ * page forever. A fault in the answer, not a failure to reach the server: the
9055
+ * offline layer rethrows it instead of falling back to its cache.
9056
+ */
9057
+ class RepeatedCursorError extends Error {
9058
+ constructor() {
9059
+ super("personal-space list: the server repeated a cursor; stopping instead of looping");
9060
+ this.name = "RepeatedCursorError";
9061
+ }
9062
+ }
9063
+ /** Every key under `prefix` ("" = every key), in the server's order. */
9064
+ async function listPersonalKeys(http, path, prefix) {
9065
+ // A prefix over the server's cap would be refused with a 400. Every list
9066
+ // was whole before the key index, so ask for everything instead; the
9067
+ // caller filters. (`length` first: code points never outnumber units.)
9068
+ const sent = prefix.length > MAX_SERVER_PREFIX_CHARS && [...prefix].length > MAX_SERVER_PREFIX_CHARS ? "" : prefix;
9069
+ const keys = [];
9070
+ const seen = new Set();
9071
+ let cursor = null;
9072
+ for (;;) {
9073
+ const res = await http.post(path, cursor === null ? { prefix: sent } : { prefix: sent, cursor });
9074
+ keys.push(...(res?.keys ?? []));
9075
+ // Annotated: without it TypeScript infers `next` through `cursor` and
9076
+ // the request body above, a loop it reports as TS7022.
9077
+ const next = typeof res?.cursor === "string" && res.cursor !== "" ? res.cursor : null;
9078
+ if (next === null)
9079
+ return keys;
9080
+ // A server (or a cache in front of one) that hands back a cursor it
9081
+ // already gave would have this page forever.
9082
+ if (seen.has(next))
9083
+ throw new RepeatedCursorError();
9084
+ seen.add(next);
9085
+ cursor = next;
9086
+ }
9087
+ }
9088
+
8986
9089
  /**
8987
9090
  * `client.kv` — per-user key/value storage backed by the accelerator's personal
8988
9091
  * space.
@@ -8990,6 +9093,7 @@ class StorageCipher {
8990
9093
  * await client.kv.set('todos', id, { title, completed })
8991
9094
  * const todo = await client.kv.get('todos', id)
8992
9095
  * const ids = await client.kv.list('todos')
9096
+ * const keys = await client.kv.keys('todos/') // full keys, any prefix
8993
9097
  * await client.kv.delete('todos', id)
8994
9098
  * const off = client.kv.on('change', e => …) // realtime, cross-device
8995
9099
  *
@@ -9092,25 +9196,45 @@ class KvNamespace {
9092
9196
  return true; // optimistic
9093
9197
  }
9094
9198
  }
9095
- /** List the ids present in `collection`. */
9199
+ /**
9200
+ * List the ids present in `collection`. The server filters by
9201
+ * `${collection}/` and pages; values are not fetched.
9202
+ */
9096
9203
  async list(collection) {
9097
9204
  const prefix = `${collection}/`;
9205
+ return (await this.keys(prefix)).map((k) => k.slice(prefix.length));
9206
+ }
9207
+ /**
9208
+ * Every key in the signed-in user's personal space that starts with
9209
+ * `prefix` (all of them when it is ""), as full keys: `todos/t1`, not
9210
+ * `t1`. Values are not fetched. The personal space is shared by every
9211
+ * app the user signs into, so expect keys you did not write: the VFS's
9212
+ * `vfs/…`, version control's `vcs/…`, the SDK's own `chat-keys` and
9213
+ * `space-keys/…`.
9214
+ *
9215
+ * The local `startsWith` filter stays on purpose: a server older than
9216
+ * the key index ignores `prefix` and returns every key.
9217
+ */
9218
+ async keys(prefix = "") {
9098
9219
  const offline = this.deps.offline;
9099
- const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
9100
- if (!offline?.enabled) {
9101
- const res = await this.deps.http.post(this.spacePath("/list"), {});
9102
- return fromKeys(res.keys ?? []);
9103
- }
9220
+ const under = (keys) => [...new Set(keys.filter((k) => k.startsWith(prefix)))];
9221
+ const remote = () => listPersonalKeys(this.deps.http, this.spacePath("/list"), prefix);
9222
+ if (!offline?.enabled)
9223
+ return under(await remote());
9104
9224
  try {
9105
- const res = await this.deps.http.post(this.spacePath("/list"), {});
9225
+ const fromServer = await remote();
9106
9226
  // Union with cached live keys so un-synced offline creates still show.
9107
- const cached = await offline.liveKeys(this.commitment());
9108
- return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
9227
+ return under([...fromServer, ...(await offline.liveKeys(this.commitment(), prefix))]);
9109
9228
  }
9110
9229
  catch (err) {
9111
- if (err instanceof HttpError)
9230
+ // A refusal, or a server that repeats its cursor, is an answer,
9231
+ // not an outage: falling back would hand back a partial list as
9232
+ // if it were whole. Only a failure to reach the server (even
9233
+ // mid-paging) falls back to the cache, as a failed list did
9234
+ // before paging.
9235
+ if (err instanceof HttpError || err instanceof RepeatedCursorError)
9112
9236
  throw err;
9113
- return fromKeys(await offline.liveKeys(this.commitment()));
9237
+ return under(await offline.liveKeys(this.commitment(), prefix));
9114
9238
  }
9115
9239
  }
9116
9240
  /** Reserved — server-side query is deferred (see the overhaul plan). */
@@ -15082,11 +15206,20 @@ function nextTurn() {
15082
15206
  * Every mutation is a read-modify-write of exactly ONE directory record, so a
15083
15207
  * write cannot leave the tree half-updated: either the parent names the new
15084
15208
  * 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
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
15089
15215
  * 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.
15090
15223
  */
15091
15224
  /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
15092
15225
  function assertWritableDepth(path) {
@@ -15290,6 +15423,32 @@ function assertNotHidden(dir, name, path) {
15290
15423
  throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
15291
15424
  }
15292
15425
  }
15426
+ /**
15427
+ * Is `now` still the entry an operation read at a name before it waited on
15428
+ * something? The same kind and id, and for a file the same version; a move
15429
+ * mark set or cleared in between does not count. Absent both times is the same.
15430
+ * Where a decision rests on the mark, `markedMeanwhile` checks that as well.
15431
+ */
15432
+ function sameEntry(before, now) {
15433
+ if (!before || !now)
15434
+ return before === now;
15435
+ if (before.kind !== now.kind || before.id !== now.id)
15436
+ return false;
15437
+ return before.kind === "dir" || (now.kind === "file" && now.manifest.id === before.manifest.id);
15438
+ }
15439
+ /**
15440
+ * Has a move marked the entry at a name since an operation read it there
15441
+ * unmarked? Then a move of it is under way - see `DirEntry.moving` - and it is
15442
+ * about to be named in a second place, which a decision made from the unmarked
15443
+ * read did not allow for.
15444
+ */
15445
+ function markedMeanwhile(before, now) {
15446
+ return now.moving === true && before.moving !== true;
15447
+ }
15448
+ /** Refuse a write because another write changed `path` while this one waited. See `VfsNamespace.mutate`. */
15449
+ function changedMeanwhile(path) {
15450
+ return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
15451
+ }
15293
15452
  /** A JSON object, as opposed to null, an array or a scalar. */
15294
15453
  function isObject$1(value) {
15295
15454
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -15456,8 +15615,25 @@ class VfsNamespace {
15456
15615
  * The file tree re-lists constantly while typing, and every miss is a
15457
15616
  * network round trip plus a decrypt. Invalidated precisely on write rather
15458
15617
  * than by TTL — a stale directory listing shows files that are not there.
15618
+ *
15619
+ * What a cached read resolves to is also THE copy of that record: the one
15620
+ * every edit of it is made to, and the one written. See `mutate`.
15459
15621
  */
15460
15622
  cache = new Map();
15623
+ /**
15624
+ * Bumped by `clearCache`, which is for a change of identity.
15625
+ *
15626
+ * A read or write begun before it can finish after it, and what it read
15627
+ * or wrote belongs to whoever was signed in before. So every record
15628
+ * carries the generation it was read under, and none goes into the cache,
15629
+ * or is edited as the cache's copy, under another.
15630
+ */
15631
+ generation = 0;
15632
+ /**
15633
+ * The record each promise in `cache` resolved to, and each write in
15634
+ * `writing` is sending: what `editable` finds without waiting.
15635
+ */
15636
+ copyOf = new WeakMap();
15461
15637
  /**
15462
15638
  * The write of each directory record still in flight, by id.
15463
15639
  *
@@ -15467,7 +15643,9 @@ class VfsNamespace {
15467
15643
  * that is often the one sent first. Then the earlier write, sealed before
15468
15644
  * the later edit was made, undoes it. That is how a file deleted forever
15469
15645
  * from the Trash, while the move that put it there was still being
15470
- * written, came back. So each write waits for the one before it.
15646
+ * written, came back. So each write waits for the one before it, and a
15647
+ * read that has to fetch the record waits for its write too: see
15648
+ * `openDirById`.
15471
15649
  */
15472
15650
  writing = new Map();
15473
15651
  historyLimit;
@@ -15526,6 +15704,9 @@ class VfsNamespace {
15526
15704
  * identity's directories to another would be the worst possible bug here.
15527
15705
  */
15528
15706
  clearCache() {
15707
+ // Whatever is still being read or written for the identity before
15708
+ // stays out of the cache from here on: see `generation`.
15709
+ this.generation++;
15529
15710
  this.cache.clear();
15530
15711
  this.rootKey = null;
15531
15712
  this._cwd = "/"; // a different identity's tree has different directories
@@ -15831,21 +16012,26 @@ class VfsNamespace {
15831
16012
  assertValidName(name);
15832
16013
  await this.mkdir(dirname(target), { recursive: true });
15833
16014
  const parent = await this.openDir(dirname(target));
15834
- const prior = entryIn(parent.node, name);
15835
- if (prior?.kind === "dir")
16015
+ if (entryIn(parent.node, name)?.kind === "dir")
15836
16016
  throw new VfsConflictError(`${target} is a directory`);
15837
16017
  await this.deps.content.retain(manifest);
15838
- const entry = {
15839
- kind: "file",
15840
- id: prior?.id ?? newId(),
15841
- manifest,
15842
- size,
15843
- mtime: Date.now(),
15844
- versions: asCount(prior?.versions),
15845
- // Still half of an interrupted move, if it was: see DirEntry.moving.
15846
- ...(prior?.moving === true ? { moving: true } : {}),
15847
- };
16018
+ let entry;
15848
16019
  await this.mutate(parent, (node) => {
16020
+ // Whatever is at the name now, after the wait above, is what
16021
+ // this replaces - and what it takes the id and history from.
16022
+ const prior = entryIn(node, name);
16023
+ if (prior?.kind === "dir")
16024
+ throw new VfsConflictError(`${target} is a directory`);
16025
+ entry = {
16026
+ kind: "file",
16027
+ id: prior?.id ?? newId(),
16028
+ manifest,
16029
+ size,
16030
+ mtime: Date.now(),
16031
+ versions: asCount(prior?.versions),
16032
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
16033
+ ...(prior?.moving === true ? { moving: true } : {}),
16034
+ };
15849
16035
  node.entries[name] = entry;
15850
16036
  });
15851
16037
  return toStat(target, name, entry);
@@ -15882,7 +16068,7 @@ class VfsNamespace {
15882
16068
  else {
15883
16069
  if (!isLast && !opts.recursive)
15884
16070
  throw new VfsNotFoundError(current);
15885
- await this.createDir(current, name);
16071
+ await this.createDir(current, name, next, opts.recursive === true);
15886
16072
  }
15887
16073
  current = next;
15888
16074
  }
@@ -15942,12 +16128,22 @@ class VfsNamespace {
15942
16128
  let versions = asCount(prior?.versions);
15943
16129
  if (prior)
15944
16130
  versions = await this.pushHistory(parent, name, prior, target, manifest);
15945
- const entry = {
15946
- kind: "file", id, manifest, size, mtime: Date.now(), versions,
15947
- // Still half of an interrupted move, if it was: see DirEntry.moving.
15948
- ...(prior?.moving === true ? { moving: true } : {}),
15949
- };
16131
+ let entry;
15950
16132
  await this.mutate(parent, (node) => {
16133
+ // The version just pushed onto the history was `prior`. If
16134
+ // another write has replaced or removed it since, it is not the
16135
+ // version this one overwrites, and writing on regardless would
16136
+ // lose that write - from the file, and from its history.
16137
+ const now = entryIn(node, name);
16138
+ if (now?.kind === "dir")
16139
+ throw new VfsConflictError(`${target} is a directory`);
16140
+ if (!sameEntry(prior, now))
16141
+ throw changedMeanwhile(target);
16142
+ entry = {
16143
+ kind: "file", id, manifest, size, mtime: Date.now(), versions,
16144
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
16145
+ ...(now?.moving === true ? { moving: true } : {}),
16146
+ };
15951
16147
  node.entries[name] = entry;
15952
16148
  });
15953
16149
  return toStat(target, name, entry);
@@ -15982,7 +16178,7 @@ class VfsNamespace {
15982
16178
  const where = printable(target, 1024);
15983
16179
  if (!opts.recursive)
15984
16180
  throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
15985
- await this.mutate(parent, () => void delete parent.hidden[name]);
16181
+ await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
15986
16182
  this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
15987
16183
  return;
15988
16184
  }
@@ -16044,6 +16240,16 @@ class VfsNamespace {
16044
16240
  // anything changes: a failure here leaves the tree as it was.
16045
16241
  const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16046
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);
16047
16253
  delete node.entries[name];
16048
16254
  });
16049
16255
  if (elsewhere === null) {
@@ -16098,11 +16304,30 @@ class VfsNamespace {
16098
16304
  // Each record is re-opened before it is written: creating the
16099
16305
  // destination's parents above may have written to the source's.
16100
16306
  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))
16321
+ throw new VfsConflictError(`${dst} already exists`);
16322
+ assertNotHidden(record, dstName, dst);
16323
+ };
16101
16324
  if (dstParent.id === srcParent.id) {
16102
16325
  // One record, so one write: the move happens entirely or not at all.
16103
- await edit(dirname(src), (node) => {
16326
+ await edit(dirname(src), (node, record) => {
16327
+ const moved = take(node);
16328
+ free(node, record);
16104
16329
  delete node.entries[srcName];
16105
- node.entries[dstName] = { ...entry, mtime: Date.now() };
16330
+ node.entries[dstName] = { ...moved, mtime: Date.now() };
16106
16331
  });
16107
16332
  return;
16108
16333
  }
@@ -16113,14 +16338,31 @@ class VfsNamespace {
16113
16338
  // An entry already marked is half of an earlier interrupted move, and
16114
16339
  // keeps its mark.
16115
16340
  const marked = entry.moving === true;
16116
- if (!marked)
16117
- await edit(dirname(src), (node) => void (node.entries[srcName] = { ...entry, moving: true }));
16118
- await edit(dirname(dst), (node) => void (node.entries[dstName] = { ...entry, moving: true, mtime: Date.now() }));
16341
+ let moved = entry;
16342
+ if (!marked) {
16343
+ await edit(dirname(src), (node) => {
16344
+ moved = take(node);
16345
+ node.entries[srcName] = { ...moved, moving: true };
16346
+ });
16347
+ }
16348
+ await edit(dirname(dst), (node, record) => {
16349
+ free(node, record);
16350
+ node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
16351
+ });
16119
16352
  // A file's history is sealed with its directory's key, and from here
16120
16353
  // on the directory that opens it is this one.
16121
16354
  if (entry.kind === "file")
16122
16355
  await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16123
- await edit(dirname(src), (node) => void delete node.entries[srcName]);
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
+ });
16124
16366
  if (!marked) {
16125
16367
  await edit(dirname(dst), (node) => {
16126
16368
  const landed = entryIn(node, dstName);
@@ -16193,13 +16435,19 @@ class VfsNamespace {
16193
16435
  // its first write, instead of after up to a whole budget's worth of
16194
16436
  // records and references, all left unreachable. The records are
16195
16437
  // cached by then, so the real pass costs writes, not fetches.
16438
+ const gen = this.generation;
16196
16439
  const run = (write) => ({
16197
- from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
16440
+ from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0, gen,
16198
16441
  });
16199
16442
  const depth = segments(dst).length;
16200
16443
  await this.cloneEntry(found.entry, src, run(false), null, depth);
16201
16444
  const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
16202
- await this.mutate(dstParent, (node) => {
16445
+ await this.mutate(dstParent, (node, record) => {
16446
+ // Looked at before the copy was made, which takes as long as the
16447
+ // tree is big: another write may have taken the name since.
16448
+ if (entryIn(node, dstName))
16449
+ throw new VfsConflictError(`${dst} already exists`);
16450
+ assertNotHidden(record, dstName, dst);
16203
16451
  node.entries[dstName] = copied;
16204
16452
  });
16205
16453
  return toStat(dst, dstName, copied);
@@ -16220,8 +16468,11 @@ class VfsNamespace {
16220
16468
  * Delete metadata records no longer reachable from the root.
16221
16469
  *
16222
16470
  * These accumulate because every write touches exactly one record: if two
16223
- * tabs edit the same directory, last-writer-wins at the personal space can
16224
- * leave a child whose parent entry lost the race. Unreachable is harmless —
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 —
16225
16476
  * it is invisible and undecryptable-by-nobody — but it costs storage, so
16226
16477
  * this reclaims it.
16227
16478
  *
@@ -16254,8 +16505,10 @@ class VfsNamespace {
16254
16505
  }
16255
16506
  // Only ever consider OUR records: the personal space is shared with
16256
16507
  // chat keys, space keys and the legacy file mirror, and a sweep that
16257
- // reached beyond its own namespace would be a data-loss bug.
16258
- const mine = (await this.deps.store.list()).filter((k) => /^vfs\/[dh]\//.test(k));
16508
+ // reached beyond its own namespace would be a data-loss bug. The store
16509
+ // is asked for `vfs/` only; the regex stays, because `vfs/space` is
16510
+ // under `vfs/` too and a store may ignore the prefix.
16511
+ const mine = (await this.deps.store.list("vfs/")).filter((k) => /^vfs\/[dh]\//.test(k));
16259
16512
  const orphans = mine.filter((k) => !reachable.has(k));
16260
16513
  // A root that read as EMPTY while orphans exist is the shape of a failed
16261
16514
  // load, not of a fresh filesystem — deleting here would wipe a tree we
@@ -16295,16 +16548,21 @@ class VfsNamespace {
16295
16548
  if (!version)
16296
16549
  throw new VfsNotFoundError(`${target} version ${index}`);
16297
16550
  const versions = await this.pushHistory(parent, name, entry, target, version.manifest);
16298
- const restored = {
16299
- kind: "file",
16300
- id: entry.id,
16301
- manifest: version.manifest,
16302
- size: asCount(version.size),
16303
- mtime: Date.now(),
16304
- versions,
16305
- ...(entry.moving === true ? { moving: true } : {}),
16306
- };
16551
+ let restored;
16307
16552
  await this.mutate(parent, (node) => {
16553
+ // As for writeFile: the version pushed onto the history was `entry`.
16554
+ const now = entryIn(node, name);
16555
+ if (!sameEntry(entry, now))
16556
+ throw changedMeanwhile(target);
16557
+ restored = {
16558
+ kind: "file",
16559
+ id: entry.id,
16560
+ manifest: version.manifest,
16561
+ size: asCount(version.size),
16562
+ mtime: Date.now(),
16563
+ versions,
16564
+ ...(now?.moving === true ? { moving: true } : {}),
16565
+ };
16308
16566
  node.entries[name] = restored;
16309
16567
  });
16310
16568
  return toStat(target, name, restored);
@@ -16334,8 +16592,15 @@ class VfsNamespace {
16334
16592
  const seed = this.deps.seed();
16335
16593
  if (!seed)
16336
16594
  throw new VfsLockedError();
16337
- if (!this.rootKey)
16338
- this.rootKey = await deriveRootKey(seed);
16595
+ if (!this.rootKey) {
16596
+ const gen = this.generation;
16597
+ const key = await deriveRootKey(seed);
16598
+ // Derived from the seed of whoever was signed in before a
16599
+ // clearCache: start again, with the seed of whoever is now.
16600
+ if (gen !== this.generation)
16601
+ return this.root();
16602
+ this.rootKey = key;
16603
+ }
16339
16604
  return this.openDirById(ROOT_ID, this.rootKey, "/");
16340
16605
  }
16341
16606
  /** Open a directory by path, creating nothing. Throws if it is missing. */
@@ -16379,7 +16644,44 @@ class VfsNamespace {
16379
16644
  // every concurrent reader to miss and fire its own request for the same
16380
16645
  // record — which is how loading one project turned into thousands of
16381
16646
  // identical `vfs/d/<id>` fetches, most of them in flight at once.
16382
- const pending = (async () => {
16647
+ const gen = this.generation;
16648
+ // Reads `pending` only after its first `await`: before that, the const is not yet initialised.
16649
+ const load = async () => {
16650
+ // Not while a write of this record is still in flight: see
16651
+ // `writing`. Our own writes echo back over the personal-space
16652
+ // socket, and `watch` drops the record each one names, so a read
16653
+ // can miss with the write still on its way - and the store would
16654
+ // answer with the copy from BEFORE it. An edit made to that copy
16655
+ // is queued behind the write, and then stored over it. So wait
16656
+ // for the latest write queued, which waits on every one before it,
16657
+ // to land or fail - and then for any queued while this read
16658
+ // waited, since the one it waited for may have failed with that
16659
+ // one still on its way. A record with no write in flight is
16660
+ // fetched at once.
16661
+ let awaited;
16662
+ for (let inFlight = this.writing.get(id); inFlight && inFlight !== awaited; inFlight = this.writing.get(id)) {
16663
+ awaited = inFlight;
16664
+ await inFlight.catch(() => { });
16665
+ }
16666
+ // Not after a clearCache, though: the cache now is the next
16667
+ // identity's, and this read only answers whoever asked for it.
16668
+ if (awaited && gen === this.generation) {
16669
+ // A write that landed put the record it wrote back in the
16670
+ // cache, and the next edit of this directory is made to that.
16671
+ // Answer with it rather than fetch a second copy: two copies
16672
+ // of one record, each edited and written, undo each other's
16673
+ // edits. When the cache holds nothing else - the write failed,
16674
+ // or a change since has dropped the record - ask the store.
16675
+ const now = this.cache.get(id);
16676
+ if (now && now !== pending)
16677
+ return now;
16678
+ // Empty, when the write failed: it dropped what the cache held,
16679
+ // and after an echo that was this very read. Put it back before
16680
+ // asking the store, so a read that comes in meanwhile shares
16681
+ // this copy instead of fetching a second one.
16682
+ if (!now)
16683
+ this.cache.set(id, pending);
16684
+ }
16383
16685
  const raw = await this.deps.store.get(dirKey(id));
16384
16686
  // An absent record is an EMPTY directory, not an error: the root
16385
16687
  // exists before anything has been written to it, and a directory is
@@ -16387,7 +16689,7 @@ class VfsNamespace {
16387
16689
  // appearing.
16388
16690
  if (raw === null || raw === undefined) {
16389
16691
  const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
16390
- return { id, key, path, node, hidden: Object.create(null) };
16692
+ return { id, key, path, node, hidden: Object.create(null), gen };
16391
16693
  }
16392
16694
  // Present but unopenable is NOT absent. `unseal` folds the two
16393
16695
  // together, which suits a record that can simply be rebuilt; a
@@ -16404,14 +16706,18 @@ class VfsNamespace {
16404
16706
  throw new VfsCorruptRecordError(id, path, err.message);
16405
16707
  throw err;
16406
16708
  }
16407
- return this.readRecord(id, key, value, path);
16408
- })().catch((err) => {
16709
+ return this.readRecord(id, key, value, gen, path);
16710
+ };
16711
+ const pending = load().catch((err) => {
16409
16712
  // A failed read must not be remembered, or one blip poisons the
16410
16713
  // directory for the life of the session.
16411
16714
  if (this.cache.get(id) === pending)
16412
16715
  this.cache.delete(id);
16413
16716
  throw err;
16414
16717
  });
16718
+ // Registered before anyone else can wait on it, so it is known by
16719
+ // the time any of them goes on to edit the record: see `editable`.
16720
+ pending.then((dir) => void this.copyOf.set(pending, dir), () => { });
16415
16721
  this.cache.set(id, pending);
16416
16722
  return pending;
16417
16723
  }
@@ -16422,7 +16728,7 @@ class VfsNamespace {
16422
16728
  * see VfsCorruptRecordError. An individual entry of the wrong shape is set
16423
16729
  * aside in `hidden` instead: out of every read, but kept for the write.
16424
16730
  */
16425
- readRecord(id, key, value, path) {
16731
+ readRecord(id, key, value, gen, path) {
16426
16732
  const problem = recordProblem(value);
16427
16733
  if (problem)
16428
16734
  throw new VfsCorruptRecordError(id, path, problem);
@@ -16478,25 +16784,47 @@ class VfsNamespace {
16478
16784
  this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
16479
16785
  }
16480
16786
  // A v1 record's unknown top-level fields survive a write too.
16481
- return { id, key, path, node: { ...top, entries }, hidden };
16787
+ return { id, key, path, node: { ...top, entries }, hidden, gen };
16482
16788
  }
16483
- /** Apply an edit to a directory record and persist it. */
16789
+ /**
16790
+ * Apply an edit to a directory record and persist it.
16791
+ *
16792
+ * `dir` says which record - its id, key and path - and nothing more. The
16793
+ * edit is made to THE copy of that record (see `editable`), which `dir`
16794
+ * may no longer be, and that copy is what is written. Editing whichever
16795
+ * copy the caller held let two copies of one record exist at once: an
16796
+ * overwrite held its directory across storing the old version's
16797
+ * history, a write of the directory failed meanwhile and dropped it from
16798
+ * the cache, and a save that then read a second copy and stored it,
16799
+ * successfully, was undone when the overwrite stored the first.
16800
+ *
16801
+ * So `edit` must take what it writes from the record it is given, not
16802
+ * 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.
16805
+ */
16484
16806
  async mutate(dir, edit) {
16485
- edit(dir.node);
16486
- dir.node.mtime = Date.now();
16807
+ // At once when the copy is to hand, which it almost always is, so
16808
+ // that what the caller checked just before is still true when the
16809
+ // edit is made.
16810
+ const target = this.editable(dir) ?? await this.reopen(dir);
16811
+ if (edit(target.node, target) === false)
16812
+ return;
16813
+ target.node.mtime = Date.now();
16487
16814
  // A write to a hidden entry's exact name replaces it: that name now
16488
16815
  // means what this version wrote. Forgetting the old one here is also
16489
16816
  // what stops a later delete of the replacement from resurrecting it.
16490
- for (const name of Object.keys(dir.hidden)) {
16491
- if (Object.hasOwn(dir.node.entries, name))
16492
- delete dir.hidden[name];
16817
+ for (const name of Object.keys(target.hidden)) {
16818
+ if (Object.hasOwn(target.node.entries, name))
16819
+ delete target.hidden[name];
16493
16820
  }
16494
16821
  // Not sent until any earlier write of this record has landed: see
16495
16822
  // `writing`. Whether that one worked or not, this one still goes.
16496
- const before = this.writing.get(dir.id);
16497
- const send = () => this.writeRecord(dir);
16823
+ const before = this.writing.get(target.id);
16824
+ const send = () => this.writeRecord(target);
16498
16825
  const write = before ? before.then(send, send) : send();
16499
- this.writing.set(dir.id, write);
16826
+ this.copyOf.set(write, target);
16827
+ this.writing.set(target.id, write);
16500
16828
  try {
16501
16829
  await write;
16502
16830
  }
@@ -16506,14 +16834,68 @@ class VfsNamespace {
16506
16834
  // change the store never took - a deleted file gone from every
16507
16835
  // listing while it is still stored, and a retry told it does not
16508
16836
  // exist. Drop it, so the next read asks the store.
16509
- this.cache.delete(dir.id);
16837
+ //
16838
+ // Unless a later write of this record is already queued. It
16839
+ // seals the record as it stands when it is sent, this edit
16840
+ // included, so the cache shows what is about to be stored, and
16841
+ // dropping it would only send readers to the store for an older
16842
+ // copy. If that write fails too, it drops the cache itself.
16843
+ //
16844
+ // Nor from a cache cleared since: that is the next identity's.
16845
+ if (this.writing.get(target.id) === write && target.gen === this.generation)
16846
+ this.cache.delete(target.id);
16510
16847
  throw err;
16511
16848
  }
16512
16849
  finally {
16513
- if (this.writing.get(dir.id) === write)
16514
- this.writing.delete(dir.id);
16850
+ if (this.writing.get(target.id) === write)
16851
+ this.writing.delete(target.id);
16515
16852
  }
16516
- this.cache.set(dir.id, Promise.resolve(dir));
16853
+ this.adopt(target);
16854
+ }
16855
+ /**
16856
+ * The copy of `dir`'s record that edits are made to, if there is one
16857
+ * without waiting: the one the cache holds, once read.
16858
+ *
16859
+ * Failing that, the one a write in flight is sending. The record was
16860
+ * dropped from the cache - a change was reported, our own write's echo
16861
+ * among them - and a read that came in since is waiting for that write,
16862
+ * to answer with this very copy if it lands (see `openDirById`); if it
16863
+ * fails, the write an edit now queues behind it carries the copy instead.
16864
+ *
16865
+ * Undefined when the record is not cached and nothing is writing it, or
16866
+ * it is still being read: `reopen` waits for that. A record read before
16867
+ * `clearCache` is edited as it is, as it always was. It is not the copy of
16868
+ * anything the cache holds now, and must not be handed the next identity's.
16869
+ */
16870
+ editable(dir) {
16871
+ if (dir.gen !== this.generation)
16872
+ return dir;
16873
+ const cached = this.cache.get(dir.id);
16874
+ const write = this.writing.get(dir.id);
16875
+ const copy = (cached && this.copyOf.get(cached)) ?? (write && this.copyOf.get(write));
16876
+ return copy?.gen === this.generation ? copy : undefined;
16877
+ }
16878
+ /**
16879
+ * Read `dir`'s record - the cached read, or a fetch made once any write of
16880
+ * it in flight has settled - and then find the copy edits are made to.
16881
+ */
16882
+ async reopen(dir) {
16883
+ for (;;) {
16884
+ await this.openDirById(dir.id, dir.key, dir.path);
16885
+ // What that read answered with, unless a change has dropped it
16886
+ // from the cache again since: then read it again.
16887
+ const copy = this.editable(dir);
16888
+ if (copy)
16889
+ return copy;
16890
+ }
16891
+ }
16892
+ /** Make `dir` the cached copy of its record - unless the cache has been cleared since it was read. */
16893
+ adopt(dir) {
16894
+ if (dir.gen !== this.generation)
16895
+ return;
16896
+ const cached = Promise.resolve(dir);
16897
+ this.copyOf.set(cached, dir);
16898
+ this.cache.set(dir.id, cached);
16517
16899
  }
16518
16900
  /**
16519
16901
  * Seal a directory record as it stands NOW and put it in the store.
@@ -16541,12 +16923,24 @@ class VfsNamespace {
16541
16923
  }
16542
16924
  await this.deps.store.put(dirKey(dir.id), sealed);
16543
16925
  }
16544
- async createDir(parentPath, name) {
16926
+ /** For `mkdir`: name a new, empty directory `path` in the one at `parentPath`. */
16927
+ async createDir(parentPath, name, path, recursive) {
16545
16928
  assertValidName(name);
16546
16929
  const parent = await this.openDir(parentPath);
16547
16930
  const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
16548
- await this.mutate(parent, (node) => {
16931
+ await this.mutate(parent, (node, record) => {
16932
+ // mkdir looked before the wait above. A directory made there
16933
+ // since, by another `mkdir -p` of the same path - files saved
16934
+ // at once into a new folder - is kept, not replaced: whatever
16935
+ // was saved into it would go with it.
16936
+ const now = entryIn(node, name);
16937
+ if (now?.kind === "dir" && recursive)
16938
+ return false;
16939
+ if (now)
16940
+ throw new VfsConflictError(`${path} ${now.kind === "dir" ? "already exists" : "is a file"}`);
16941
+ assertNotHidden(record, name, path);
16549
16942
  node.entries[name] = entry;
16943
+ return true;
16550
16944
  });
16551
16945
  }
16552
16946
  /**
@@ -16656,8 +17050,15 @@ class VfsNamespace {
16656
17050
  // Opened again: this delete may have written to it since the tree was read.
16657
17051
  const dir = await this.openDirById(holding[0].id, holding[0].key);
16658
17052
  const name = Object.keys(dir.node.entries).find((n) => dir.node.entries[n].id === id && dir.node.entries[n].moving === true);
16659
- if (name !== undefined)
16660
- await this.mutate(dir, (node) => void (node.entries[name] = unmarked(node.entries[name])));
17053
+ if (name === undefined)
17054
+ return;
17055
+ await this.mutate(dir, (node) => {
17056
+ const now = entryIn(node, name);
17057
+ if (now?.id !== id || now.moving !== true)
17058
+ return false;
17059
+ node.entries[name] = unmarked(now);
17060
+ return true;
17061
+ });
16661
17062
  }
16662
17063
  catch {
16663
17064
  // It stays marked.
@@ -16854,9 +17255,9 @@ class VfsNamespace {
16854
17255
  }
16855
17256
  if (!run.write)
16856
17257
  return clone;
16857
- const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null) };
17258
+ const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen };
16858
17259
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
16859
- this.cache.set(open.id, Promise.resolve(open));
17260
+ this.adopt(open);
16860
17261
  return clone;
16861
17262
  }
16862
17263
  /**
@@ -17853,8 +18254,11 @@ class Repo {
17853
18254
  async expand(prefix) {
17854
18255
  if (!/^[0-9a-f]{4,}$/.test(prefix))
17855
18256
  return null;
17856
- const keys = await this.deps.store.list();
17857
18257
  const wanted = objectKey(this.slug, prefix);
18258
+ // Only the keys under the abbreviation (usually none or one), not the
18259
+ // whole personal space. The filter below stays, for a store that
18260
+ // ignores the prefix.
18261
+ const keys = await this.deps.store.list(wanted);
17858
18262
  // Only keys that could be objects: a stray key under the prefix would
17859
18263
  // otherwise make every abbreviation of it "ambiguous".
17860
18264
  const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
@@ -19206,11 +19610,15 @@ class KvCache {
19206
19610
  const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
19207
19611
  await this.store.put("kv-cache", k, mergeRegister(local, incoming));
19208
19612
  }
19209
- /** Non-tombstoned full keys for a commitment (used for offline `list`). */
19210
- async liveKeys(commitment) {
19211
- const prefix = `${commitment}|`;
19212
- const rows = await this.store.prefix("kv-cache", prefix);
19213
- return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(prefix.length));
19613
+ /**
19614
+ * Non-tombstoned full keys for a commitment that start with `prefix` (all
19615
+ * of them by default), for offline `list`. The store scans the key range
19616
+ * `${commitment}|${prefix}`, so other collections are never read.
19617
+ */
19618
+ async liveKeys(commitment, prefix = "") {
19619
+ const scope = `${commitment}|`;
19620
+ const rows = await this.store.prefix("kv-cache", scope + prefix);
19621
+ return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(scope.length));
19214
19622
  }
19215
19623
  /** Queue a kv mutation for replay on reconnect. */
19216
19624
  async enqueue(method, args, hlc, clientId) {
@@ -19366,13 +19774,17 @@ class Client {
19366
19774
  /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
19367
19775
  recoverInFlight = null;
19368
19776
  constructor(options = {}) {
19369
- // Build stamp — always logged (independent of logLevel) so the running
19370
- // build is identifiable in the console. Catches stale cached bundles
19371
- // during development; bump `VERSION` (and package.json) each build.
19372
- try {
19373
- console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
19777
+ // Build stamp — logged (independent of logLevel) so the running build
19778
+ // is identifiable in the console. Catches stale cached bundles during
19779
+ // development; bump `VERSION` (and package.json) each build.
19780
+ // Browser console only: in Node it lands on stdout, in front of a
19781
+ // CLI's own output (`muhkoo kv ls --json | jq`).
19782
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
19783
+ try {
19784
+ console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
19785
+ }
19786
+ catch { /* console may be unavailable in some runtimes */ }
19374
19787
  }
19375
- catch { /* console may be unavailable in some runtimes */ }
19376
19788
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
19377
19789
  globalThis.appLogger.setLevel(options.logLevel);
19378
19790
  }
@@ -19474,12 +19886,13 @@ class Client {
19474
19886
  throw new Error("client.vfs: no session — sign in first.");
19475
19887
  await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
19476
19888
  },
19477
- list: async () => {
19889
+ list: async (prefix) => {
19478
19890
  const c = this.session.commitment;
19479
19891
  if (!c)
19480
19892
  throw new Error("client.vfs: no session — sign in first.");
19481
- const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
19482
- return res?.keys ?? [];
19893
+ // The server filters by prefix and pages. An older server ignores
19894
+ // the prefix and returns every key, which is why callers filter.
19895
+ return listPersonalKeys(this.http, `/api/personal/${encodeURIComponent(c)}/list`, prefix ?? "");
19483
19896
  },
19484
19897
  };
19485
19898
  let contentSpace = null;