@muhkoo/connect 0.15.0-alpha.2 → 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.
@@ -6739,7 +6739,7 @@ async function parseOrThrow(response, op) {
6739
6739
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6740
6740
  * `package.json` `version`.
6741
6741
  */
6742
- const VERSION = "0.15.0-alpha.2";
6742
+ const VERSION = "0.15.0-alpha.3";
6743
6743
 
6744
6744
  /**
6745
6745
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -9047,6 +9047,45 @@ class StorageCipher {
9047
9047
  }
9048
9048
  }
9049
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
+
9050
9089
  /**
9051
9090
  * `client.kv` — per-user key/value storage backed by the accelerator's personal
9052
9091
  * space.
@@ -9054,6 +9093,7 @@ class StorageCipher {
9054
9093
  * await client.kv.set('todos', id, { title, completed })
9055
9094
  * const todo = await client.kv.get('todos', id)
9056
9095
  * const ids = await client.kv.list('todos')
9096
+ * const keys = await client.kv.keys('todos/') // full keys, any prefix
9057
9097
  * await client.kv.delete('todos', id)
9058
9098
  * const off = client.kv.on('change', e => …) // realtime, cross-device
9059
9099
  *
@@ -9156,25 +9196,45 @@ class KvNamespace {
9156
9196
  return true; // optimistic
9157
9197
  }
9158
9198
  }
9159
- /** 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
+ */
9160
9203
  async list(collection) {
9161
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 = "") {
9162
9219
  const offline = this.deps.offline;
9163
- const fromKeys = (keys) => keys.filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
9164
- if (!offline?.enabled) {
9165
- const res = await this.deps.http.post(this.spacePath("/list"), {});
9166
- return fromKeys(res.keys ?? []);
9167
- }
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());
9168
9224
  try {
9169
- const res = await this.deps.http.post(this.spacePath("/list"), {});
9225
+ const fromServer = await remote();
9170
9226
  // Union with cached live keys so un-synced offline creates still show.
9171
- const cached = await offline.liveKeys(this.commitment());
9172
- 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))]);
9173
9228
  }
9174
9229
  catch (err) {
9175
- 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)
9176
9236
  throw err;
9177
- return fromKeys(await offline.liveKeys(this.commitment()));
9237
+ return under(await offline.liveKeys(this.commitment(), prefix));
9178
9238
  }
9179
9239
  }
9180
9240
  /** Reserved — server-side query is deferred (see the overhaul plan). */
@@ -15156,9 +15216,10 @@ function nextTurn() {
15156
15216
  *
15157
15217
  * Within ONE tab, writes of a record are queued and sent one at a time, in the
15158
15218
  * 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.
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.
15162
15223
  */
15163
15224
  /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
15164
15225
  function assertWritableDepth(path) {
@@ -15362,6 +15423,32 @@ function assertNotHidden(dir, name, path) {
15362
15423
  throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
15363
15424
  }
15364
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
+ }
15365
15452
  /** A JSON object, as opposed to null, an array or a scalar. */
15366
15453
  function isObject$1(value) {
15367
15454
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -15528,8 +15615,25 @@ class VfsNamespace {
15528
15615
  * The file tree re-lists constantly while typing, and every miss is a
15529
15616
  * network round trip plus a decrypt. Invalidated precisely on write rather
15530
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`.
15531
15621
  */
15532
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();
15533
15637
  /**
15534
15638
  * The write of each directory record still in flight, by id.
15535
15639
  *
@@ -15600,6 +15704,9 @@ class VfsNamespace {
15600
15704
  * identity's directories to another would be the worst possible bug here.
15601
15705
  */
15602
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++;
15603
15710
  this.cache.clear();
15604
15711
  this.rootKey = null;
15605
15712
  this._cwd = "/"; // a different identity's tree has different directories
@@ -15905,21 +16012,26 @@ class VfsNamespace {
15905
16012
  assertValidName(name);
15906
16013
  await this.mkdir(dirname(target), { recursive: true });
15907
16014
  const parent = await this.openDir(dirname(target));
15908
- const prior = entryIn(parent.node, name);
15909
- if (prior?.kind === "dir")
16015
+ if (entryIn(parent.node, name)?.kind === "dir")
15910
16016
  throw new VfsConflictError(`${target} is a directory`);
15911
16017
  await this.deps.content.retain(manifest);
15912
- const entry = {
15913
- kind: "file",
15914
- id: prior?.id ?? newId(),
15915
- manifest,
15916
- size,
15917
- mtime: Date.now(),
15918
- versions: asCount(prior?.versions),
15919
- // Still half of an interrupted move, if it was: see DirEntry.moving.
15920
- ...(prior?.moving === true ? { moving: true } : {}),
15921
- };
16018
+ let entry;
15922
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
+ };
15923
16035
  node.entries[name] = entry;
15924
16036
  });
15925
16037
  return toStat(target, name, entry);
@@ -15956,7 +16068,7 @@ class VfsNamespace {
15956
16068
  else {
15957
16069
  if (!isLast && !opts.recursive)
15958
16070
  throw new VfsNotFoundError(current);
15959
- await this.createDir(current, name);
16071
+ await this.createDir(current, name, next, opts.recursive === true);
15960
16072
  }
15961
16073
  current = next;
15962
16074
  }
@@ -16016,12 +16128,22 @@ class VfsNamespace {
16016
16128
  let versions = asCount(prior?.versions);
16017
16129
  if (prior)
16018
16130
  versions = await this.pushHistory(parent, name, prior, target, manifest);
16019
- const entry = {
16020
- kind: "file", id, manifest, size, mtime: Date.now(), versions,
16021
- // Still half of an interrupted move, if it was: see DirEntry.moving.
16022
- ...(prior?.moving === true ? { moving: true } : {}),
16023
- };
16131
+ let entry;
16024
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
+ };
16025
16147
  node.entries[name] = entry;
16026
16148
  });
16027
16149
  return toStat(target, name, entry);
@@ -16056,7 +16178,7 @@ class VfsNamespace {
16056
16178
  const where = printable(target, 1024);
16057
16179
  if (!opts.recursive)
16058
16180
  throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
16059
- await this.mutate(parent, () => void delete parent.hidden[name]);
16181
+ await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
16060
16182
  this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
16061
16183
  return;
16062
16184
  }
@@ -16118,6 +16240,16 @@ class VfsNamespace {
16118
16240
  // anything changes: a failure here leaves the tree as it was.
16119
16241
  const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16120
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);
16121
16253
  delete node.entries[name];
16122
16254
  });
16123
16255
  if (elsewhere === null) {
@@ -16172,11 +16304,30 @@ class VfsNamespace {
16172
16304
  // Each record is re-opened before it is written: creating the
16173
16305
  // destination's parents above may have written to the source's.
16174
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
+ };
16175
16324
  if (dstParent.id === srcParent.id) {
16176
16325
  // One record, so one write: the move happens entirely or not at all.
16177
- await edit(dirname(src), (node) => {
16326
+ await edit(dirname(src), (node, record) => {
16327
+ const moved = take(node);
16328
+ free(node, record);
16178
16329
  delete node.entries[srcName];
16179
- node.entries[dstName] = { ...entry, mtime: Date.now() };
16330
+ node.entries[dstName] = { ...moved, mtime: Date.now() };
16180
16331
  });
16181
16332
  return;
16182
16333
  }
@@ -16187,14 +16338,31 @@ class VfsNamespace {
16187
16338
  // An entry already marked is half of an earlier interrupted move, and
16188
16339
  // keeps its mark.
16189
16340
  const marked = entry.moving === true;
16190
- if (!marked)
16191
- await edit(dirname(src), (node) => void (node.entries[srcName] = { ...entry, moving: true }));
16192
- 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
+ });
16193
16352
  // A file's history is sealed with its directory's key, and from here
16194
16353
  // on the directory that opens it is this one.
16195
16354
  if (entry.kind === "file")
16196
16355
  await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16197
- 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
+ });
16198
16366
  if (!marked) {
16199
16367
  await edit(dirname(dst), (node) => {
16200
16368
  const landed = entryIn(node, dstName);
@@ -16267,13 +16435,19 @@ class VfsNamespace {
16267
16435
  // its first write, instead of after up to a whole budget's worth of
16268
16436
  // records and references, all left unreachable. The records are
16269
16437
  // cached by then, so the real pass costs writes, not fetches.
16438
+ const gen = this.generation;
16270
16439
  const run = (write) => ({
16271
- 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,
16272
16441
  });
16273
16442
  const depth = segments(dst).length;
16274
16443
  await this.cloneEntry(found.entry, src, run(false), null, depth);
16275
16444
  const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
16276
- 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);
16277
16451
  node.entries[dstName] = copied;
16278
16452
  });
16279
16453
  return toStat(dst, dstName, copied);
@@ -16331,8 +16505,10 @@ class VfsNamespace {
16331
16505
  }
16332
16506
  // Only ever consider OUR records: the personal space is shared with
16333
16507
  // chat keys, space keys and the legacy file mirror, and a sweep that
16334
- // reached beyond its own namespace would be a data-loss bug.
16335
- 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));
16336
16512
  const orphans = mine.filter((k) => !reachable.has(k));
16337
16513
  // A root that read as EMPTY while orphans exist is the shape of a failed
16338
16514
  // load, not of a fresh filesystem — deleting here would wipe a tree we
@@ -16372,16 +16548,21 @@ class VfsNamespace {
16372
16548
  if (!version)
16373
16549
  throw new VfsNotFoundError(`${target} version ${index}`);
16374
16550
  const versions = await this.pushHistory(parent, name, entry, target, version.manifest);
16375
- const restored = {
16376
- kind: "file",
16377
- id: entry.id,
16378
- manifest: version.manifest,
16379
- size: asCount(version.size),
16380
- mtime: Date.now(),
16381
- versions,
16382
- ...(entry.moving === true ? { moving: true } : {}),
16383
- };
16551
+ let restored;
16384
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
+ };
16385
16566
  node.entries[name] = restored;
16386
16567
  });
16387
16568
  return toStat(target, name, restored);
@@ -16411,8 +16592,15 @@ class VfsNamespace {
16411
16592
  const seed = this.deps.seed();
16412
16593
  if (!seed)
16413
16594
  throw new VfsLockedError();
16414
- if (!this.rootKey)
16415
- 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
+ }
16416
16604
  return this.openDirById(ROOT_ID, this.rootKey, "/");
16417
16605
  }
16418
16606
  /** Open a directory by path, creating nothing. Throws if it is missing. */
@@ -16456,6 +16644,7 @@ class VfsNamespace {
16456
16644
  // every concurrent reader to miss and fire its own request for the same
16457
16645
  // record — which is how loading one project turned into thousands of
16458
16646
  // identical `vfs/d/<id>` fetches, most of them in flight at once.
16647
+ const gen = this.generation;
16459
16648
  // Reads `pending` only after its first `await`: before that, the const is not yet initialised.
16460
16649
  const load = async () => {
16461
16650
  // Not while a write of this record is still in flight: see
@@ -16474,7 +16663,9 @@ class VfsNamespace {
16474
16663
  awaited = inFlight;
16475
16664
  await inFlight.catch(() => { });
16476
16665
  }
16477
- if (awaited) {
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) {
16478
16669
  // A write that landed put the record it wrote back in the
16479
16670
  // cache, and the next edit of this directory is made to that.
16480
16671
  // Answer with it rather than fetch a second copy: two copies
@@ -16498,7 +16689,7 @@ class VfsNamespace {
16498
16689
  // appearing.
16499
16690
  if (raw === null || raw === undefined) {
16500
16691
  const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
16501
- return { id, key, path, node, hidden: Object.create(null) };
16692
+ return { id, key, path, node, hidden: Object.create(null), gen };
16502
16693
  }
16503
16694
  // Present but unopenable is NOT absent. `unseal` folds the two
16504
16695
  // together, which suits a record that can simply be rebuilt; a
@@ -16515,7 +16706,7 @@ class VfsNamespace {
16515
16706
  throw new VfsCorruptRecordError(id, path, err.message);
16516
16707
  throw err;
16517
16708
  }
16518
- return this.readRecord(id, key, value, path);
16709
+ return this.readRecord(id, key, value, gen, path);
16519
16710
  };
16520
16711
  const pending = load().catch((err) => {
16521
16712
  // A failed read must not be remembered, or one blip poisons the
@@ -16524,6 +16715,9 @@ class VfsNamespace {
16524
16715
  this.cache.delete(id);
16525
16716
  throw err;
16526
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), () => { });
16527
16721
  this.cache.set(id, pending);
16528
16722
  return pending;
16529
16723
  }
@@ -16534,7 +16728,7 @@ class VfsNamespace {
16534
16728
  * see VfsCorruptRecordError. An individual entry of the wrong shape is set
16535
16729
  * aside in `hidden` instead: out of every read, but kept for the write.
16536
16730
  */
16537
- readRecord(id, key, value, path) {
16731
+ readRecord(id, key, value, gen, path) {
16538
16732
  const problem = recordProblem(value);
16539
16733
  if (problem)
16540
16734
  throw new VfsCorruptRecordError(id, path, problem);
@@ -16590,25 +16784,47 @@ class VfsNamespace {
16590
16784
  this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
16591
16785
  }
16592
16786
  // A v1 record's unknown top-level fields survive a write too.
16593
- return { id, key, path, node: { ...top, entries }, hidden };
16787
+ return { id, key, path, node: { ...top, entries }, hidden, gen };
16594
16788
  }
16595
- /** 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
+ */
16596
16806
  async mutate(dir, edit) {
16597
- edit(dir.node);
16598
- 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();
16599
16814
  // A write to a hidden entry's exact name replaces it: that name now
16600
16815
  // means what this version wrote. Forgetting the old one here is also
16601
16816
  // what stops a later delete of the replacement from resurrecting it.
16602
- for (const name of Object.keys(dir.hidden)) {
16603
- if (Object.hasOwn(dir.node.entries, name))
16604
- 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];
16605
16820
  }
16606
16821
  // Not sent until any earlier write of this record has landed: see
16607
16822
  // `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);
16823
+ const before = this.writing.get(target.id);
16824
+ const send = () => this.writeRecord(target);
16610
16825
  const write = before ? before.then(send, send) : send();
16611
- this.writing.set(dir.id, write);
16826
+ this.copyOf.set(write, target);
16827
+ this.writing.set(target.id, write);
16612
16828
  try {
16613
16829
  await write;
16614
16830
  }
@@ -16624,15 +16840,62 @@ class VfsNamespace {
16624
16840
  // included, so the cache shows what is about to be stored, and
16625
16841
  // dropping it would only send readers to the store for an older
16626
16842
  // 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);
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);
16629
16847
  throw err;
16630
16848
  }
16631
16849
  finally {
16632
- if (this.writing.get(dir.id) === write)
16633
- this.writing.delete(dir.id);
16850
+ if (this.writing.get(target.id) === write)
16851
+ this.writing.delete(target.id);
16634
16852
  }
16635
- 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);
16636
16899
  }
16637
16900
  /**
16638
16901
  * Seal a directory record as it stands NOW and put it in the store.
@@ -16660,12 +16923,24 @@ class VfsNamespace {
16660
16923
  }
16661
16924
  await this.deps.store.put(dirKey(dir.id), sealed);
16662
16925
  }
16663
- 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) {
16664
16928
  assertValidName(name);
16665
16929
  const parent = await this.openDir(parentPath);
16666
16930
  const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
16667
- 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);
16668
16942
  node.entries[name] = entry;
16943
+ return true;
16669
16944
  });
16670
16945
  }
16671
16946
  /**
@@ -16775,8 +17050,15 @@ class VfsNamespace {
16775
17050
  // Opened again: this delete may have written to it since the tree was read.
16776
17051
  const dir = await this.openDirById(holding[0].id, holding[0].key);
16777
17052
  const name = Object.keys(dir.node.entries).find((n) => dir.node.entries[n].id === id && dir.node.entries[n].moving === true);
16778
- if (name !== undefined)
16779
- 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
+ });
16780
17062
  }
16781
17063
  catch {
16782
17064
  // It stays marked.
@@ -16973,9 +17255,9 @@ class VfsNamespace {
16973
17255
  }
16974
17256
  if (!run.write)
16975
17257
  return clone;
16976
- 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 };
16977
17259
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
16978
- this.cache.set(open.id, Promise.resolve(open));
17260
+ this.adopt(open);
16979
17261
  return clone;
16980
17262
  }
16981
17263
  /**
@@ -17972,8 +18254,11 @@ class Repo {
17972
18254
  async expand(prefix) {
17973
18255
  if (!/^[0-9a-f]{4,}$/.test(prefix))
17974
18256
  return null;
17975
- const keys = await this.deps.store.list();
17976
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);
17977
18262
  // Only keys that could be objects: a stray key under the prefix would
17978
18263
  // otherwise make every abbreviation of it "ambiguous".
17979
18264
  const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
@@ -19325,11 +19610,15 @@ class KvCache {
19325
19610
  const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
19326
19611
  await this.store.put("kv-cache", k, mergeRegister(local, incoming));
19327
19612
  }
19328
- /** Non-tombstoned full keys for a commitment (used for offline `list`). */
19329
- async liveKeys(commitment) {
19330
- const prefix = `${commitment}|`;
19331
- const rows = await this.store.prefix("kv-cache", prefix);
19332
- 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));
19333
19622
  }
19334
19623
  /** Queue a kv mutation for replay on reconnect. */
19335
19624
  async enqueue(method, args, hlc, clientId) {
@@ -19485,13 +19774,17 @@ class Client {
19485
19774
  /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
19486
19775
  recoverInFlight = null;
19487
19776
  constructor(options = {}) {
19488
- // Build stamp — always logged (independent of logLevel) so the running
19489
- // build is identifiable in the console. Catches stale cached bundles
19490
- // during development; bump `VERSION` (and package.json) each build.
19491
- try {
19492
- 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 */ }
19493
19787
  }
19494
- catch { /* console may be unavailable in some runtimes */ }
19495
19788
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
19496
19789
  globalThis.appLogger.setLevel(options.logLevel);
19497
19790
  }
@@ -19593,12 +19886,13 @@ class Client {
19593
19886
  throw new Error("client.vfs: no session — sign in first.");
19594
19887
  await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
19595
19888
  },
19596
- list: async () => {
19889
+ list: async (prefix) => {
19597
19890
  const c = this.session.commitment;
19598
19891
  if (!c)
19599
19892
  throw new Error("client.vfs: no session — sign in first.");
19600
- const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
19601
- 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 ?? "");
19602
19896
  },
19603
19897
  };
19604
19898
  let contentSpace = null;