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

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.4";
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). */
@@ -15145,20 +15205,25 @@ function nextTurn() {
15145
15205
  *
15146
15206
  * Every mutation is a read-modify-write of exactly ONE directory record, so a
15147
15207
  * write cannot leave the tree half-updated: either the parent names the new
15148
- * child or it does not. Two tabs writing different directories never conflict;
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
15208
+ * child or it does not. A VFS write sends no HLC and the personal space has
15209
+ * no compare-and-swap: of two writes of one key it keeps whichever arrives
15210
+ * last.
15211
+ *
15212
+ * Within ONE namespace, writes of a record are sent one at a time, in the
15213
+ * order they were made - edits made while one is in flight go out together
15214
+ * in the next (see `queue`) - and a read that has to fetch a record waits for
15215
+ * its write in flight (see `writing`). Every edit of a record is made to one
15216
+ * in-memory copy of it, and that copy is what is written (see `mutate`).
15217
+ *
15218
+ * Between the tabs of an app on one device, a coordinator (`VfsCoordinator`;
15219
+ * `Client` wires one in a browser tab) gives each record one lock, and a tab
15220
+ * reads a record another tab has written again before its next write of it
15221
+ * (see `sendLocked`). Between devices - or a browser and the CLI - two writes
15222
+ * of the same directory are still last-writer-wins, and the other edit is
15223
+ * lost: that takes a compare-and-swap at the server. Orphaned records (a
15224
+ * directory whose parent entry lost the race) are unreachable rather than
15225
+ * corrupting - they cost storage until a sweep, the trade this shape makes in
15155
15226
  * 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.
15162
15227
  */
15163
15228
  /** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
15164
15229
  function assertWritableDepth(path) {
@@ -15362,6 +15427,50 @@ function assertNotHidden(dir, name, path) {
15362
15427
  throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
15363
15428
  }
15364
15429
  }
15430
+ /**
15431
+ * Is `now` still the entry an operation read at a name before it waited on
15432
+ * something? The same kind and id, and for a file the same version; a move
15433
+ * mark set or cleared in between does not count. Absent both times is the same.
15434
+ * Where a decision rests on the mark, `markedMeanwhile` checks that as well.
15435
+ */
15436
+ function sameEntry(before, now) {
15437
+ if (!before || !now)
15438
+ return before === now;
15439
+ if (before.kind !== now.kind || before.id !== now.id)
15440
+ return false;
15441
+ return before.kind === "dir" || (now.kind === "file" && now.manifest.id === before.manifest.id);
15442
+ }
15443
+ /**
15444
+ * Has a move marked the entry at a name since an operation read it there
15445
+ * unmarked? Then a move of it is under way - see `DirEntry.moving` - and it is
15446
+ * about to be named in a second place, which a decision made from the unmarked
15447
+ * read did not allow for.
15448
+ */
15449
+ function markedMeanwhile(before, now) {
15450
+ return now.moving === true && before.moving !== true;
15451
+ }
15452
+ /** Refuse a write because another write changed `path` while this one waited. See `VfsNamespace.mutate`. */
15453
+ function changedMeanwhile(path) {
15454
+ return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
15455
+ }
15456
+ /**
15457
+ * Refuse an operation on `path` because this namespace is moving it - or,
15458
+ * `inside`, something in it. See `VfsNamespace.inMotion`.
15459
+ */
15460
+ function beingMoved(path, inside = false) {
15461
+ const what = inside ? `something in ${path} is` : `${path} is`;
15462
+ return new VfsConflictError(`${what} being moved; nothing was changed - try again once the move has finished`);
15463
+ }
15464
+ /**
15465
+ * Refuse an operation on `path` because a move or a delete of it - or,
15466
+ * `inside`, of it or of something in it - holds its move lock on this
15467
+ * device: in another tab of the app, or in this one. See
15468
+ * `VfsCoordinator.holdMoving`.
15469
+ */
15470
+ function heldElsewhere(path, inside = false) {
15471
+ const what = inside ? `${path}, or something in it, is` : `${path} is`;
15472
+ return new VfsConflictError(`${what} being moved or deleted in this app, in this tab or another; nothing was changed - try again once that has finished`);
15473
+ }
15365
15474
  /** A JSON object, as opposed to null, an array or a scalar. */
15366
15475
  function isObject$1(value) {
15367
15476
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -15519,6 +15628,13 @@ function followable(dir) {
15519
15628
  function unfollowable(dir) {
15520
15629
  return Object.values(dir.hidden).filter((value) => !readableEntry(value)).length;
15521
15630
  }
15631
+ /** The id of every entry `dir` follows, and whether an entry with that id is marked: `SubtreeShape`'s view of it. */
15632
+ function namedIn(dir) {
15633
+ const named = new Map();
15634
+ for (const [, entry] of followable(dir))
15635
+ named.set(entry.id, named.get(entry.id) === true || entry.moving === true);
15636
+ return named;
15637
+ }
15522
15638
  class VfsNamespace {
15523
15639
  deps;
15524
15640
  rootKey = null;
@@ -15528,8 +15644,25 @@ class VfsNamespace {
15528
15644
  * The file tree re-lists constantly while typing, and every miss is a
15529
15645
  * network round trip plus a decrypt. Invalidated precisely on write rather
15530
15646
  * than by TTL — a stale directory listing shows files that are not there.
15647
+ *
15648
+ * What a cached read resolves to is also THE copy of that record: the one
15649
+ * every edit of it is made to, and the one written. See `mutate`.
15531
15650
  */
15532
15651
  cache = new Map();
15652
+ /**
15653
+ * Bumped by `clearCache`, which is for a change of identity.
15654
+ *
15655
+ * A read or write begun before it can finish after it, and what it read
15656
+ * or wrote belongs to whoever was signed in before. So every record
15657
+ * carries the generation it was read under, and none goes into the cache,
15658
+ * or is edited as the cache's copy, under another.
15659
+ */
15660
+ generation = 0;
15661
+ /**
15662
+ * The record each promise in `cache` resolved to, and each write in
15663
+ * `writing` is sending: what `editable` finds without waiting.
15664
+ */
15665
+ copyOf = new WeakMap();
15533
15666
  /**
15534
15667
  * The write of each directory record still in flight, by id.
15535
15668
  *
@@ -15544,6 +15677,81 @@ class VfsNamespace {
15544
15677
  * `openDirById`.
15545
15678
  */
15546
15679
  writing = new Map();
15680
+ /**
15681
+ * Every entry this namespace is moving right now, by id.
15682
+ *
15683
+ * A move between two directories marks the entry before it puts it in
15684
+ * the second (see `rename`), and a mark is all a later operation can
15685
+ * see. It cannot tell a move still writing from one that was
15686
+ * interrupted, and was taken for the second: a delete that began after
15687
+ * a move had marked a folder looked for the folder's other half, found
15688
+ * none yet, and purged it while the move was putting it in its new
15689
+ * place; a second move of a file finished the "interrupted" move and
15690
+ * left it named in two places. So a move holds its entry here until it
15691
+ * has taken it from where it was, and `rename` and `delete` refuse an
15692
+ * entry that is here - or, for a delete, a folder holding one. A delete
15693
+ * holds what it deletes the same way: see `deleting`.
15694
+ *
15695
+ * This set is one namespace's. With a coordinator, a move also holds
15696
+ * the entry's move lock for every tab on the device, for as long as the
15697
+ * entry is here: see `VfsCoordinator.holdMoving`.
15698
+ */
15699
+ inMotion = new Set();
15700
+ /**
15701
+ * What each delete under way in this namespace is deleting, by entry id,
15702
+ * one set per delete: its target from the moment it has found the target
15703
+ * not in `inMotion`, and everything it read below the target once it has
15704
+ * read it. Each delete lets go of its own set when it is done.
15705
+ *
15706
+ * A delete waits after it has read what it will let go of: on more
15707
+ * reads, and on a read of the whole tree for the other half of a move.
15708
+ * A move of any of it that started meanwhile could put it in its new
15709
+ * place, fail to take it from the old, and end, all before the delete's
15710
+ * detach. Ended, it was no longer in `inMotion`; and an entry that was
15711
+ * marked already showed no change. So the delete let go of what the
15712
+ * move had just named somewhere else. Now `rename` refuses an entry that
15713
+ * is here, or one on a way from the root that passes through a folder
15714
+ * that is: everything below a delete's target, read yet or not.
15715
+ */
15716
+ deleting = new Set();
15717
+ /**
15718
+ * The write of each record that has not gone out yet, by id: the one
15719
+ * the next edit of that record joins. See `queue`.
15720
+ */
15721
+ waiting = new Map();
15722
+ /**
15723
+ * Announcements heard from other tabs, counted. `heardAt` is the count
15724
+ * at the last one for each record, `missedAt` the count when some may
15725
+ * have been missed. A copy read after both holds every write announced:
15726
+ * see `current`.
15727
+ */
15728
+ hearing = 0;
15729
+ missedAt = 0;
15730
+ heardAt = new Map();
15731
+ /**
15732
+ * Each record a write of which this namespace marked as begun and could
15733
+ * not count, and has said so, until one is counted. And whether it has
15734
+ * said that a write it could not mark or count may be lost, which it
15735
+ * says only once. See `notCounted`.
15736
+ */
15737
+ uncounted = new Set();
15738
+ lossWarned = false;
15739
+ /**
15740
+ * The copy of each record this namespace is writing under the record's
15741
+ * lock, by id, from when it has decided what to write until it has
15742
+ * counted the write, or failed to. Its counter reads as unknown
15743
+ * meanwhile, as this write has begun and is not counted yet, but the
15744
+ * copy holds every write of the record another tab has counted, and no
15745
+ * other tab can make one until this one lets go of the lock. See
15746
+ * `dropStale`.
15747
+ */
15748
+ locked = new Map();
15749
+ /**
15750
+ * Each directory a `createDir` here has named whose write has not landed
15751
+ * yet, by entry id: a promise that settles - and never rejects - once
15752
+ * that write has landed or failed. See `mkdir`.
15753
+ */
15754
+ naming = new Map();
15547
15755
  historyLimit;
15548
15756
  maxEntries;
15549
15757
  maxPathChars;
@@ -15552,6 +15760,8 @@ class VfsNamespace {
15552
15760
  this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
15553
15761
  this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
15554
15762
  this.maxPathChars = deps.limits?.pathChars ?? DEFAULT_MAX_PATH_CHARS;
15763
+ // Another tab's write of a record leaves this tab's copy of it out of date: see `heard`.
15764
+ deps.coordinator?.onAnnounce((id) => this.heard(id));
15555
15765
  }
15556
15766
  /**
15557
15767
  * The working directory relative paths resolve against.
@@ -15600,7 +15810,11 @@ class VfsNamespace {
15600
15810
  * identity's directories to another would be the worst possible bug here.
15601
15811
  */
15602
15812
  clearCache() {
15813
+ // Whatever is still being read or written for the identity before
15814
+ // stays out of the cache from here on: see `generation`.
15815
+ this.generation++;
15603
15816
  this.cache.clear();
15817
+ this.heardAt.clear();
15604
15818
  this.rootKey = null;
15605
15819
  this._cwd = "/"; // a different identity's tree has different directories
15606
15820
  }
@@ -15905,21 +16119,26 @@ class VfsNamespace {
15905
16119
  assertValidName(name);
15906
16120
  await this.mkdir(dirname(target), { recursive: true });
15907
16121
  const parent = await this.openDir(dirname(target));
15908
- const prior = entryIn(parent.node, name);
15909
- if (prior?.kind === "dir")
16122
+ if (entryIn(parent.node, name)?.kind === "dir")
15910
16123
  throw new VfsConflictError(`${target} is a directory`);
15911
16124
  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
- };
16125
+ let entry;
15922
16126
  await this.mutate(parent, (node) => {
16127
+ // Whatever is at the name now, after the wait above, is what
16128
+ // this replaces - and what it takes the id and history from.
16129
+ const prior = entryIn(node, name);
16130
+ if (prior?.kind === "dir")
16131
+ throw new VfsConflictError(`${target} is a directory`);
16132
+ entry = {
16133
+ kind: "file",
16134
+ id: prior?.id ?? newId(),
16135
+ manifest,
16136
+ size,
16137
+ mtime: Date.now(),
16138
+ versions: asCount(prior?.versions),
16139
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
16140
+ ...(prior?.moving === true ? { moving: true } : {}),
16141
+ };
15923
16142
  node.entries[name] = entry;
15924
16143
  });
15925
16144
  return toStat(target, name, entry);
@@ -15943,8 +16162,22 @@ class VfsNamespace {
15943
16162
  const name = parts[i];
15944
16163
  const isLast = i === parts.length - 1;
15945
16164
  const next = join(current, name);
15946
- const dir = await this.openDir(current);
15947
- const existing = entryIn(dir.node, name);
16165
+ let dir = await this.openDir(current);
16166
+ let existing = entryIn(dir.node, name);
16167
+ // Named by a mkdir here whose write has not landed. Until it has,
16168
+ // the name is a promise, not a fact: with a coordinator, that edit
16169
+ // is made again under the lock to the folder as another tab left
16170
+ // it, and comes to nothing when that tab named a directory there
16171
+ // first - and whatever was saved meanwhile into the one named
16172
+ // here is in a directory nothing names. A failed write drops it
16173
+ // too. So wait for the write, and look again.
16174
+ let unwritten = existing && this.naming.get(existing.id);
16175
+ while (unwritten) {
16176
+ await unwritten;
16177
+ dir = await this.openDir(current);
16178
+ existing = entryIn(dir.node, name);
16179
+ unwritten = existing && this.naming.get(existing.id);
16180
+ }
15948
16181
  if (!existing)
15949
16182
  assertNotHidden(dir, name, next);
15950
16183
  if (existing) {
@@ -15956,7 +16189,7 @@ class VfsNamespace {
15956
16189
  else {
15957
16190
  if (!isLast && !opts.recursive)
15958
16191
  throw new VfsNotFoundError(current);
15959
- await this.createDir(current, name);
16192
+ await this.createDir(current, name, next, opts.recursive === true);
15960
16193
  }
15961
16194
  current = next;
15962
16195
  }
@@ -16016,12 +16249,22 @@ class VfsNamespace {
16016
16249
  let versions = asCount(prior?.versions);
16017
16250
  if (prior)
16018
16251
  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
- };
16252
+ let entry;
16024
16253
  await this.mutate(parent, (node) => {
16254
+ // The version just pushed onto the history was `prior`. If
16255
+ // another write has replaced or removed it since, it is not the
16256
+ // version this one overwrites, and writing on regardless would
16257
+ // lose that write - from the file, and from its history.
16258
+ const now = entryIn(node, name);
16259
+ if (now?.kind === "dir")
16260
+ throw new VfsConflictError(`${target} is a directory`);
16261
+ if (!sameEntry(prior, now))
16262
+ throw changedMeanwhile(target);
16263
+ entry = {
16264
+ kind: "file", id, manifest, size, mtime: Date.now(), versions,
16265
+ // Still half of an interrupted move, if it was: see DirEntry.moving.
16266
+ ...(now?.moving === true ? { moving: true } : {}),
16267
+ };
16025
16268
  node.entries[name] = entry;
16026
16269
  });
16027
16270
  return toStat(target, name, entry);
@@ -16056,83 +16299,223 @@ class VfsNamespace {
16056
16299
  const where = printable(target, 1024);
16057
16300
  if (!opts.recursive)
16058
16301
  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]);
16302
+ await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
16060
16303
  this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
16061
16304
  return;
16062
16305
  }
16063
- // Every directory above the target, the root first. None of them is
16064
- // part of what is being deleted, whatever an entry below says.
16065
- const above = new Set(opened.chain);
16066
- // Three steps, in this order: read everything the delete will touch,
16067
- // detach the entry, then let go of what it held.
16068
- //
16069
- // Letting go came first once, and the entry was detached last. Any
16070
- // failure in between - a network error, most simply - left the entry
16071
- // in place with some of its content already released, and the obvious
16072
- // next step, trying again, released that content a SECOND time.
16073
- // Content is reference-counted and shared by copies, so that freed
16074
- // bytes another file still used. Now a failure before the detach has
16075
- // changed nothing, and nothing after it can fail: releasing is
16076
- // best-effort, and a leak is the safe way to be wrong.
16077
- let doomed = [];
16078
- // Whether any of it is half of an interrupted move. See below.
16079
- let moving = entry.kind === "file" && entry.moving === true;
16080
- if (entry.kind === "dir" && above.has(entry.id)) {
16081
- // The entry IS a pointer to a directory above it - the root, or
16082
- // this entry's own parent. Removing the pointer is what was asked
16083
- // for; clearing out what it points at would delete everything
16084
- // around the target, the target included.
16085
- if (!opts.recursive)
16086
- throw new VfsConflictError(`${target} is not empty`);
16087
- this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
16088
- }
16089
- else if (entry.kind === "dir") {
16090
- const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
16091
- // An unreadable directory must still be removable, or the user
16092
- // is stuck with an error they can do nothing about. Its subtree
16093
- // cannot be enumerated, so it is detached rather than purged and
16094
- // whatever it holds is left for sweep(). Only when asked for
16095
- // recursively: without that we cannot know it is empty.
16096
- if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
16097
- throw err;
16098
- this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
16099
- return null;
16100
- });
16101
- if (child) {
16102
- // Entries this version cannot read still count: calling the
16103
- // directory empty would delete them along with it.
16104
- const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
16105
- if (!empty && !opts.recursive)
16306
+ // Being moved by this namespace: it is marked already, or about to be,
16307
+ // and on its way to a second place. Letting go of it here would let
16308
+ // go of what the move puts there. See `inMotion`.
16309
+ if (this.inMotion.has(entry.id))
16310
+ throw beingMoved(target);
16311
+ // And from here until it is done, a move of it, or of anything below
16312
+ // it, that begins in this namespace is refused. See `deleting`.
16313
+ const claim = new Set([entry.id]);
16314
+ this.deleting.add(claim);
16315
+ // On this device, the move locks of the target and of everything read
16316
+ // below it. See below.
16317
+ let release = null;
16318
+ try {
16319
+ // Every directory above the target, the root first. None of them is
16320
+ // part of what is being deleted, whatever an entry below says.
16321
+ const above = new Set(opened.chain);
16322
+ // Three steps, in this order: read everything the delete will touch,
16323
+ // detach the entry, then let go of what it held.
16324
+ //
16325
+ // Letting go came first once, and the entry was detached last. Any
16326
+ // failure in between - a network error, most simply - left the entry
16327
+ // in place with some of its content already released, and the obvious
16328
+ // next step, trying again, released that content a SECOND time.
16329
+ // Content is reference-counted and shared by copies, so that freed
16330
+ // bytes another file still used. Now a failure before the detach has
16331
+ // changed nothing, and nothing after it can fail: releasing is
16332
+ // best-effort, and a leak is the safe way to be wrong.
16333
+ let doomed = [];
16334
+ // What each of them named when it was read, for the check before the detach.
16335
+ let shape = new Map();
16336
+ // Every id found below the target: claimed once it has all been read,
16337
+ // re-checked against `inMotion` at the detach and, with a coordinator,
16338
+ // whose move locks it holds. See below.
16339
+ let ids = new Set();
16340
+ // Whether any of it is half of an interrupted move. See below.
16341
+ let moving = entry.kind === "file" && entry.moving === true;
16342
+ if (entry.kind === "dir" && above.has(entry.id)) {
16343
+ // The entry IS a pointer to a directory above it - the root, or
16344
+ // this entry's own parent. Removing the pointer is what was asked
16345
+ // for; clearing out what it points at would delete everything
16346
+ // around the target, the target included.
16347
+ if (!opts.recursive)
16106
16348
  throw new VfsConflictError(`${target} is not empty`);
16107
- const found = await this.subtree(child, target, above);
16108
- doomed = found.dirs;
16109
- moving = entry.moving === true || found.moving;
16349
+ this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
16350
+ }
16351
+ else if (entry.kind === "dir") {
16352
+ const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
16353
+ // An unreadable directory must still be removable, or the user
16354
+ // is stuck with an error they can do nothing about. Its subtree
16355
+ // cannot be enumerated, so it is detached rather than purged and
16356
+ // whatever it holds is left for sweep(). Only when asked for
16357
+ // recursively: without that we cannot know it is empty.
16358
+ if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
16359
+ throw err;
16360
+ this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
16361
+ return null;
16362
+ });
16363
+ if (child) {
16364
+ // Entries this version cannot read still count: calling the
16365
+ // directory empty would delete them along with it.
16366
+ const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
16367
+ if (!empty && !opts.recursive)
16368
+ throw new VfsConflictError(`${target} is not empty`);
16369
+ const found = await this.subtree(child, target, above);
16370
+ // Something in it is being moved by this namespace: the same
16371
+ // as the target being moved, a level down.
16372
+ for (const id of found.ids)
16373
+ if (this.inMotion.has(id))
16374
+ throw beingMoved(target, true);
16375
+ for (const id of found.ids)
16376
+ claim.add(id);
16377
+ doomed = found.dirs;
16378
+ shape = found.shape;
16379
+ ids = found.ids;
16380
+ moving = entry.moving === true || found.moving;
16381
+ }
16110
16382
  }
16383
+ // On this device, the move lock of the target and of everything read
16384
+ // below it, held from here until what they held has been let go of.
16385
+ // A move in another tab holds the lock of what it moves until it has
16386
+ // taken it from where it was. A delete that found it marked looked
16387
+ // for its other half before the move had put it there, found none,
16388
+ // and let go of what the move then named in its new place; one that
16389
+ // was writing the parent let a move of something below start, and
16390
+ // then purged it. Held here, a move of any of it that is under way
16391
+ // refuses this delete, and none can start until it is done. What it
16392
+ // does not cover - anything named below since it was read - the check
16393
+ // in the edit refuses. See `VfsCoordinator.holdMoving`.
16394
+ //
16395
+ // Taken before the look for the other half of a move below, not
16396
+ // after it: a move in another tab that began during that look could
16397
+ // put the entry in its new place, fail to take it from the old, and
16398
+ // let go of its lock, all before this took it - and what it named
16399
+ // was let go of here. Not before the reads above, which find what is
16400
+ // below. A move in another tab that begins during them still holds
16401
+ // its lock here if it is under way, and this delete is refused;
16402
+ // ended, it came before the look and the checks below, which read
16403
+ // again what it wrote. See below.
16404
+ const coordinator = this.deps.coordinator;
16405
+ // What the look and the checks below must read again.
16406
+ let stale = new Set();
16407
+ if (coordinator) {
16408
+ release = await this.holdAll(coordinator, [entry.id, ...ids]);
16409
+ if (!release)
16410
+ throw heldElsewhere(target, ids.size > 0);
16411
+ // A move that let go of its lock just before this took it had
16412
+ // counted its writes by then, though this tab may not have heard
16413
+ // of them: what was read above that another tab has written
16414
+ // since is read again - every record cached, for the look, which
16415
+ // reads the whole tree. A move that took a file out of a folder
16416
+ // read above, from this tab's old copy, had that file let go of
16417
+ // with the folder; one that went from the other half of a move
16418
+ // to a folder this tab's old copy did not show it in had the
16419
+ // content it took with it let go of. See `dropStale`.
16420
+ //
16421
+ // And the way to the target, looked up before anything else.
16422
+ // A delete in another tab of a folder on it had let go of the
16423
+ // target's lock, which it held, only once it had counted its
16424
+ // write of the folder above the one it deleted - but it purged
16425
+ // that one, and all below, which counts nothing. Gone on from
16426
+ // the old copies, this wrote the purged parent back and let go
16427
+ // of what the target held a second time, while a copy of it, or
16428
+ // a commit, still named that content. So what on the way has
16429
+ // been written since it was read is read again, and the way is
16430
+ // looked up again, as a move's way to where it goes is: it must
16431
+ // lead to the same entry, through the same folders, not marked
16432
+ // since.
16433
+ //
16434
+ // Looked up again even when nothing on it is out of date now:
16435
+ // a write of this tab's own may have read a folder on it again,
16436
+ // under that folder's lock, after the look above, and counted
16437
+ // its write. That copy is current by now, and no longer what the
16438
+ // look saw.
16439
+ const read = doomed.map((dir) => dir.id);
16440
+ stale = await this.dropStale(coordinator, [...opened.chain, ...(moving ? this.cache.keys() : []), ...read]);
16441
+ const again = await this.openChain(dirname(target));
16442
+ if (!again)
16443
+ throw new VfsNotFoundError(dirname(target));
16444
+ if (again.chain.some((id, i) => id !== opened.chain[i]))
16445
+ throw changedMeanwhile(dirname(target));
16446
+ const now = entryIn(again.dir.node, name);
16447
+ if (!now)
16448
+ throw new VfsNotFoundError(target);
16449
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16450
+ throw changedMeanwhile(target);
16451
+ }
16452
+ // A move between two directories that was interrupted leaves what it
16453
+ // moved named in both, and letting go of it here would take it from
16454
+ // the other copy: the directory record purged with every file in it,
16455
+ // or a file's content and history released. Only a marked entry can
16456
+ // be one of those (see `rename`), so only then is the whole tree
16457
+ // read, once, to find out what is still named elsewhere. Still before
16458
+ // anything changes: a failure here leaves the tree as it was.
16459
+ const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16460
+ // The check in the edit below looks at each record to be purged as it
16461
+ // is then, which needs it to hand: a change reported meanwhile may
16462
+ // have dropped one from the cache, and so may the counters above.
16463
+ // Read again, it is still checked against what was read before.
16464
+ for (const dir of doomed)
16465
+ if (stale.has(dir.id) || !this.editable(dir))
16466
+ await this.reopen(dir);
16467
+ let purging = doomed;
16468
+ await this.mutate(parent, (node) => {
16469
+ // What is let go of below is what `entry` held. Gone since, it
16470
+ // was let go of already; replaced, the name holds something this
16471
+ // delete has not read. Marked since, a move of it has begun, and
16472
+ // what it holds is about to be named somewhere `elsewhere` did
16473
+ // not see - if it was looked for at all.
16474
+ const now = entryIn(node, name);
16475
+ if (!now)
16476
+ throw new VfsNotFoundError(target);
16477
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16478
+ throw changedMeanwhile(target);
16479
+ // Marked already when this delete read it, a move that has since
16480
+ // taken the entry - or something below it - would not mark it a
16481
+ // second time, so `markedMeanwhile` and `currentSubtree` would not
16482
+ // see it either. `inMotion` is live, not a snapshot, so it still
16483
+ // does: re-checked here, at the last moment before the detach.
16484
+ // Since `deleting`, a move here cannot begin on any of it once
16485
+ // this delete has claimed it, so these are a second guard.
16486
+ if (this.inMotion.has(entry.id))
16487
+ throw beingMoved(target);
16488
+ for (const id of ids)
16489
+ if (this.inMotion.has(id))
16490
+ throw beingMoved(target, true);
16491
+ // And what is purged is what the records below hold now - unless
16492
+ // something in them has been moved, or begun to move, since they
16493
+ // were read; or, with a coordinator, named there since, as its
16494
+ // move lock is not held. See `currentSubtree`.
16495
+ const current = this.currentSubtree(doomed, shape, coordinator ? ids : undefined);
16496
+ if (!current)
16497
+ throw changedMeanwhile(target);
16498
+ purging = current;
16499
+ delete node.entries[name];
16500
+ });
16501
+ if (elsewhere === null) {
16502
+ // Part of the tree cannot be read, so it is unknown whether the
16503
+ // other copy is in it. Keeping what may be its contents costs
16504
+ // storage until a sweep; letting go of them could lose them.
16505
+ this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
16506
+ return;
16507
+ }
16508
+ if (entry.kind === "dir")
16509
+ await this.purge(purging, elsewhere, opts.keepContent);
16510
+ else
16511
+ await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
16512
+ if (elsewhere && entry.moving === true)
16513
+ await this.settle(entry.id, elsewhere);
16111
16514
  }
16112
- // A move between two directories that was interrupted leaves what it
16113
- // moved named in both, and letting go of it here would take it from
16114
- // the other copy: the directory record purged with every file in it,
16115
- // or a file's content and history released. Only a marked entry can
16116
- // be one of those (see `rename`), so only then is the whole tree
16117
- // read, once, to find out what is still named elsewhere. Still before
16118
- // anything changes: a failure here leaves the tree as it was.
16119
- const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
16120
- await this.mutate(parent, (node) => {
16121
- delete node.entries[name];
16122
- });
16123
- if (elsewhere === null) {
16124
- // Part of the tree cannot be read, so it is unknown whether the
16125
- // other copy is in it. Keeping what may be its contents costs
16126
- // storage until a sweep; letting go of them could lose them.
16127
- this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
16128
- return;
16515
+ finally {
16516
+ release?.();
16517
+ this.deleting.delete(claim);
16129
16518
  }
16130
- if (entry.kind === "dir")
16131
- await this.purge(doomed, elsewhere, opts.keepContent);
16132
- else
16133
- await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
16134
- if (elsewhere && entry.moving === true)
16135
- await this.settle(entry.id, elsewhere);
16136
16519
  }
16137
16520
  /**
16138
16521
  * Move or rename. Works across directories, which is the same operation:
@@ -16155,47 +16538,216 @@ class VfsNamespace {
16155
16538
  const srcName = basename(src);
16156
16539
  const dstName = basename(dst);
16157
16540
  assertValidName(dstName);
16158
- const srcParent = await this.openDir(dirname(src));
16541
+ const source = await this.openChain(dirname(src));
16542
+ if (!source)
16543
+ throw new VfsNotFoundError(dirname(src));
16544
+ const srcParent = source.dir;
16159
16545
  const entry = entryIn(srcParent.node, srcName);
16160
16546
  if (!entry)
16161
16547
  throw new VfsNotFoundError(src);
16162
- if (entry.kind === "dir") {
16163
- assertWritableDepth(dst);
16164
- if (segments(dst).length > segments(src).length)
16165
- await this.assertFitsAt(src, dst);
16166
- }
16167
- await this.mkdir(dirname(dst), { recursive: true });
16168
- const dstParent = await this.openDir(dirname(dst));
16169
- if (entryIn(dstParent.node, dstName))
16170
- throw new VfsConflictError(`${dst} already exists`);
16171
- assertNotHidden(dstParent, dstName, dst);
16548
+ // Another move of it is under way here. Its mark cannot tell that
16549
+ // move from an interrupted one, and finishing it as one named the
16550
+ // entry in two places. Held from here, before the first wait, so two
16551
+ // moves begun together cannot both get past this. See `inMotion`.
16552
+ if (this.inMotion.has(entry.id))
16553
+ throw beingMoved(src);
16554
+ // A delete here has begun on it, or on a folder on its way from the
16555
+ // root. Moved now, it could be put in its new place just before the
16556
+ // delete let go of what it holds. Not the root, which no delete is
16557
+ // of: one claims it only through an entry below that names it, and
16558
+ // lets go of nothing of it. In the same step as the check above, as
16559
+ // a delete makes its claim in the same step as its own. See
16560
+ // `deleting`.
16561
+ const lineage = [entry.id, ...source.chain.slice(1)];
16562
+ if ([...this.deleting].some((claim) => lineage.some((id) => claim.has(id))))
16563
+ throw heldElsewhere(src);
16564
+ this.inMotion.add(entry.id);
16172
16565
  // Each record is re-opened before it is written: creating the
16173
- // destination's parents above may have written to the source's.
16566
+ // destination's parents below may have written to the source's.
16174
16567
  const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
16175
- if (dstParent.id === srcParent.id) {
16176
- // One record, so one write: the move happens entirely or not at all.
16568
+ let unmarkLanded = false;
16569
+ // The same for every tab of the app on this device: the entry's move
16570
+ // lock, held for as long as it is in `inMotion`. Another tab's move
16571
+ // or delete of it holds it, and taking that move's mark for an
16572
+ // interrupted one named the entry in two places, or purged it. See
16573
+ // `VfsCoordinator.holdMoving`.
16574
+ let release = null;
16575
+ // And the locks of the folders on the way to where it goes, shared,
16576
+ // for as long. See below.
16577
+ let into = null;
16578
+ try {
16579
+ const coordinator = this.deps.coordinator;
16580
+ if (coordinator) {
16581
+ release = await coordinator.holdMoving(entry.id);
16582
+ if (!release)
16583
+ throw heldElsewhere(src);
16584
+ // The look above was at this tab's copies of the folders on the
16585
+ // way to the entry. A delete in another tab of the entry, or of
16586
+ // a folder on that way, may have finished before this took the
16587
+ // lock, which the delete held: it had counted its writes by
16588
+ // then, though this tab may not have heard of them. Moved
16589
+ // regardless, the entry was marked in a record the delete had
16590
+ // purged, and named in its new place with its content let go
16591
+ // of. So whatever on the way another tab has written since it
16592
+ // was read is read again, and the entry looked for again.
16593
+ if ((await this.dropStale(coordinator, source.chain)).size) {
16594
+ const again = await this.openChain(dirname(src));
16595
+ if (!again)
16596
+ throw new VfsNotFoundError(dirname(src));
16597
+ if (again.chain.some((id, i) => id !== source.chain[i]))
16598
+ throw changedMeanwhile(dirname(src));
16599
+ const now = entryIn(again.dir.node, srcName);
16600
+ if (!now)
16601
+ throw new VfsNotFoundError(src);
16602
+ if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
16603
+ throw changedMeanwhile(src);
16604
+ }
16605
+ }
16606
+ if (entry.kind === "dir") {
16607
+ assertWritableDepth(dst);
16608
+ if (segments(dst).length > segments(src).length)
16609
+ await this.assertFitsAt(src, dst);
16610
+ }
16611
+ await this.mkdir(dirname(dst), { recursive: true });
16612
+ const way = await this.openChain(dirname(dst));
16613
+ if (!way)
16614
+ throw new VfsNotFoundError(dirname(dst));
16615
+ let dstParent = way.dir;
16616
+ if (coordinator && dstParent.id !== ROOT_ID) {
16617
+ // The folder it goes into, and every folder above that one
16618
+ // but the root, held shared: other moves into any of them
16619
+ // share them, and a delete of one - which holds the lock of
16620
+ // its target and of everything it read below - holds it
16621
+ // alone. A move into a folder that another tab was deleting
16622
+ // put what it moved in a record the delete then purged: in no
16623
+ // listing, its content kept. Not that folder alone: it may be
16624
+ // new since the delete read the tree - made by the `mkdir`
16625
+ // above, or by another tab - and the delete does not hold a
16626
+ // lock it did not read. Its target's is on the way here. The
16627
+ // root is never deleted.
16628
+ into = await this.holdAll(coordinator, way.chain.slice(1), true);
16629
+ if (!into)
16630
+ throw heldElsewhere(dirname(dst));
16631
+ // A delete of one of them may have finished since they were
16632
+ // looked up, and counted its write of the folder above before
16633
+ // it let go of their locks, though this tab may not have
16634
+ // heard of it: read again whatever on the way another tab has
16635
+ // written since it was read, and look again. Gone, or another
16636
+ // folder anywhere on the way, the move stops here, before its
16637
+ // mark.
16638
+ await this.dropStale(coordinator, way.chain);
16639
+ const again = await this.openChain(dirname(dst));
16640
+ if (!again)
16641
+ throw new VfsNotFoundError(dirname(dst));
16642
+ if (again.chain.some((id, i) => id !== way.chain[i]))
16643
+ throw changedMeanwhile(dirname(dst));
16644
+ dstParent = again.dir;
16645
+ }
16646
+ if (entryIn(dstParent.node, dstName))
16647
+ throw new VfsConflictError(`${dst} already exists`);
16648
+ assertNotHidden(dstParent, dstName, dst);
16649
+ // Both ends were looked at before the waits above, and another write
16650
+ // may have changed either since: replaced the entry being moved, or
16651
+ // put something at the destination. Moving on regardless would write
16652
+ // the old entry over the new one, or this one over the other. Or
16653
+ // marked the entry: another move of it has begun. Taking it as well
16654
+ // would name it in two places, and each move would unmark its own.
16655
+ const take = (node) => {
16656
+ const now = entryIn(node, srcName);
16657
+ if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
16658
+ throw changedMeanwhile(src);
16659
+ return now;
16660
+ };
16661
+ const free = (node, record) => {
16662
+ if (entryIn(node, dstName))
16663
+ throw new VfsConflictError(`${dst} already exists`);
16664
+ assertNotHidden(record, dstName, dst);
16665
+ };
16666
+ if (dstParent.id === srcParent.id) {
16667
+ // One record, so one write: the move happens entirely or not at all.
16668
+ await edit(dirname(src), (node, record) => {
16669
+ const moved = take(node);
16670
+ free(node, record);
16671
+ delete node.entries[srcName];
16672
+ node.entries[dstName] = { ...moved, mtime: Date.now() };
16673
+ });
16674
+ return;
16675
+ }
16676
+ // Two records, attached before detaching: interrupted, the entry is
16677
+ // in both rather than in neither. Both copies are marked for as long
16678
+ // as that can be so, because they share everything below them, and a
16679
+ // delete of either would otherwise let go of the other's contents.
16680
+ // An entry already marked is half of an earlier interrupted move, and
16681
+ // keeps its mark.
16682
+ const marked = entry.moving === true;
16683
+ let moved = entry;
16684
+ if (!marked) {
16685
+ await edit(dirname(src), (node) => {
16686
+ moved = take(node);
16687
+ node.entries[srcName] = { ...moved, moving: true };
16688
+ });
16689
+ }
16690
+ // Whether the edit below has put the entry at the destination: set
16691
+ // as its last step, after every check, each time it runs.
16692
+ let attached = false;
16693
+ try {
16694
+ await edit(dirname(dst), (node, record) => {
16695
+ attached = false;
16696
+ free(node, record);
16697
+ node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
16698
+ attached = true;
16699
+ });
16700
+ }
16701
+ catch (err) {
16702
+ // Refused before it put anything at the destination - another
16703
+ // write took the name first - so the entry is still in one
16704
+ // place only, and the mark this move put on it would stay for
16705
+ // good: on a file, every version that falls off its history
16706
+ // kept; on either, every delete of it reading the whole tree.
16707
+ // Not when the write failed instead: the destination may hold
16708
+ // the entry by now, and the mark is what keeps a delete of
16709
+ // either copy from letting go of the other's. Best-effort.
16710
+ if (!marked && !attached) {
16711
+ await edit(dirname(src), (node) => {
16712
+ const now = entryIn(node, srcName);
16713
+ if (now?.id !== entry.id || now.moving !== true)
16714
+ return false;
16715
+ node.entries[srcName] = unmarked(now);
16716
+ return true;
16717
+ }).catch(() => { });
16718
+ }
16719
+ throw err;
16720
+ }
16721
+ // A file's history is sealed with its directory's key, and from here
16722
+ // on the directory that opens it is this one.
16723
+ if (entry.kind === "file")
16724
+ await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16177
16725
  await edit(dirname(src), (node) => {
16726
+ // Gone already leaves nothing to detach, and nothing to write.
16727
+ // Replaced, it is not this entry to take away: the move stops
16728
+ // here, in both places. Not `take`: the entry is marked by now,
16729
+ // by this move or the earlier one it finishes.
16730
+ const now = entryIn(node, srcName);
16731
+ if (!now)
16732
+ return false;
16733
+ if (!sameEntry(entry, now))
16734
+ throw changedMeanwhile(src);
16178
16735
  delete node.entries[srcName];
16179
- node.entries[dstName] = { ...entry, mtime: Date.now() };
16736
+ // Explicit, as in `createDir` and `settle`: noImplicitReturns.
16737
+ return true;
16180
16738
  });
16181
- return;
16739
+ unmarkLanded = !marked;
16182
16740
  }
16183
- // Two records, attached before detaching: interrupted, the entry is
16184
- // in both rather than in neither. Both copies are marked for as long
16185
- // as that can be so, because they share everything below them, and a
16186
- // delete of either would otherwise let go of the other's contents.
16187
- // An entry already marked is half of an earlier interrupted move, and
16188
- // keeps its mark.
16189
- 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() }));
16193
- // A file's history is sealed with its directory's key, and from here
16194
- // on the directory that opens it is this one.
16195
- if (entry.kind === "file")
16196
- await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
16197
- await edit(dirname(src), (node) => void delete node.entries[srcName]);
16198
- if (!marked) {
16741
+ finally {
16742
+ // Named in one place from here, the destination. A delete of it
16743
+ // there goes ahead as for an entry an interrupted move left marked
16744
+ // - which the Trash's "delete forever" relies on while the mark is
16745
+ // still being cleared.
16746
+ this.inMotion.delete(entry.id);
16747
+ release?.();
16748
+ into?.();
16749
+ }
16750
+ if (unmarkLanded) {
16199
16751
  await edit(dirname(dst), (node) => {
16200
16752
  const landed = entryIn(node, dstName);
16201
16753
  if (landed?.id === entry.id)
@@ -16267,13 +16819,19 @@ class VfsNamespace {
16267
16819
  // its first write, instead of after up to a whole budget's worth of
16268
16820
  // records and references, all left unreachable. The records are
16269
16821
  // cached by then, so the real pass costs writes, not fetches.
16822
+ const gen = this.generation;
16270
16823
  const run = (write) => ({
16271
- from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
16824
+ from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0, gen,
16272
16825
  });
16273
16826
  const depth = segments(dst).length;
16274
16827
  await this.cloneEntry(found.entry, src, run(false), null, depth);
16275
16828
  const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
16276
- await this.mutate(dstParent, (node) => {
16829
+ await this.mutate(dstParent, (node, record) => {
16830
+ // Looked at before the copy was made, which takes as long as the
16831
+ // tree is big: another write may have taken the name since.
16832
+ if (entryIn(node, dstName))
16833
+ throw new VfsConflictError(`${dst} already exists`);
16834
+ assertNotHidden(record, dstName, dst);
16277
16835
  node.entries[dstName] = copied;
16278
16836
  });
16279
16837
  return toStat(dst, dstName, copied);
@@ -16294,11 +16852,12 @@ class VfsNamespace {
16294
16852
  * Delete metadata records no longer reachable from the root.
16295
16853
  *
16296
16854
  * These accumulate because every write touches exactly one record: if two
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 —
16855
+ * devices edit the same directory - or two tabs, where there is no
16856
+ * coordinator - the personal space keeps whichever put arrives last (a
16857
+ * VFS write sends no HLC, and the space applies puts of one key in arrival
16858
+ * order), which can leave a child whose parent entry lost the race. The
16859
+ * per-record queue and the coordinator order writes within one device
16860
+ * only. Unreachable is harmless —
16302
16861
  * it is invisible and undecryptable-by-nobody — but it costs storage, so
16303
16862
  * this reclaims it.
16304
16863
  *
@@ -16307,6 +16866,12 @@ class VfsNamespace {
16307
16866
  * Run it when the filesystem is idle.
16308
16867
  */
16309
16868
  async sweep(opts = {}) {
16869
+ // With a coordinator: what another tab has written since this tab
16870
+ // read it is read again, not followed in an old copy that may not
16871
+ // name what it now names - which would then be deleted here.
16872
+ const coordinator = this.deps.coordinator;
16873
+ if (coordinator)
16874
+ await this.dropStale(coordinator, [...this.cache.keys()]);
16310
16875
  const root = await this.root();
16311
16876
  // A record that cannot be read throws out of here, before anything is
16312
16877
  // deleted: with part of the tree unknown, "unreachable" is a guess.
@@ -16331,8 +16896,10 @@ class VfsNamespace {
16331
16896
  }
16332
16897
  // Only ever consider OUR records: the personal space is shared with
16333
16898
  // 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));
16899
+ // reached beyond its own namespace would be a data-loss bug. The store
16900
+ // is asked for `vfs/` only; the regex stays, because `vfs/space` is
16901
+ // under `vfs/` too and a store may ignore the prefix.
16902
+ const mine = (await this.deps.store.list("vfs/")).filter((k) => /^vfs\/[dh]\//.test(k));
16336
16903
  const orphans = mine.filter((k) => !reachable.has(k));
16337
16904
  // A root that read as EMPTY while orphans exist is the shape of a failed
16338
16905
  // load, not of a fresh filesystem — deleting here would wipe a tree we
@@ -16372,16 +16939,21 @@ class VfsNamespace {
16372
16939
  if (!version)
16373
16940
  throw new VfsNotFoundError(`${target} version ${index}`);
16374
16941
  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
- };
16942
+ let restored;
16384
16943
  await this.mutate(parent, (node) => {
16944
+ // As for writeFile: the version pushed onto the history was `entry`.
16945
+ const now = entryIn(node, name);
16946
+ if (!sameEntry(entry, now))
16947
+ throw changedMeanwhile(target);
16948
+ restored = {
16949
+ kind: "file",
16950
+ id: entry.id,
16951
+ manifest: version.manifest,
16952
+ size: asCount(version.size),
16953
+ mtime: Date.now(),
16954
+ versions,
16955
+ ...(now?.moving === true ? { moving: true } : {}),
16956
+ };
16385
16957
  node.entries[name] = restored;
16386
16958
  });
16387
16959
  return toStat(target, name, restored);
@@ -16411,8 +16983,15 @@ class VfsNamespace {
16411
16983
  const seed = this.deps.seed();
16412
16984
  if (!seed)
16413
16985
  throw new VfsLockedError();
16414
- if (!this.rootKey)
16415
- this.rootKey = await deriveRootKey(seed);
16986
+ if (!this.rootKey) {
16987
+ const gen = this.generation;
16988
+ const key = await deriveRootKey(seed);
16989
+ // Derived from the seed of whoever was signed in before a
16990
+ // clearCache: start again, with the seed of whoever is now.
16991
+ if (gen !== this.generation)
16992
+ return this.root();
16993
+ this.rootKey = key;
16994
+ }
16416
16995
  return this.openDirById(ROOT_ID, this.rootKey, "/");
16417
16996
  }
16418
16997
  /** Open a directory by path, creating nothing. Throws if it is missing. */
@@ -16449,6 +17028,11 @@ class VfsNamespace {
16449
17028
  const hit = this.cache.get(id);
16450
17029
  if (hit)
16451
17030
  return hit;
17031
+ // Before anything is read, so that from here on this namespace hears
17032
+ // of any later write of the record by another tab. Starting to hear -
17033
+ // for the first time, or for someone else - clears the cache (see
17034
+ // `heard`), which is why it comes before this read is cached.
17035
+ this.deps.coordinator?.listen();
16452
17036
  // The PROMISE goes in the cache, not the resolved value.
16453
17037
  //
16454
17038
  // Reading a project means resolving hundreds of paths that share the
@@ -16456,6 +17040,7 @@ class VfsNamespace {
16456
17040
  // every concurrent reader to miss and fire its own request for the same
16457
17041
  // record — which is how loading one project turned into thousands of
16458
17042
  // identical `vfs/d/<id>` fetches, most of them in flight at once.
17043
+ const gen = this.generation;
16459
17044
  // Reads `pending` only after its first `await`: before that, the const is not yet initialised.
16460
17045
  const load = async () => {
16461
17046
  // Not while a write of this record is still in flight: see
@@ -16474,7 +17059,9 @@ class VfsNamespace {
16474
17059
  awaited = inFlight;
16475
17060
  await inFlight.catch(() => { });
16476
17061
  }
16477
- if (awaited) {
17062
+ // Not after a clearCache, though: the cache now is the next
17063
+ // identity's, and this read only answers whoever asked for it.
17064
+ if (awaited && gen === this.generation) {
16478
17065
  // A write that landed put the record it wrote back in the
16479
17066
  // cache, and the next edit of this directory is made to that.
16480
17067
  // Answer with it rather than fetch a second copy: two copies
@@ -16491,31 +17078,7 @@ class VfsNamespace {
16491
17078
  if (!now)
16492
17079
  this.cache.set(id, pending);
16493
17080
  }
16494
- const raw = await this.deps.store.get(dirKey(id));
16495
- // An absent record is an EMPTY directory, not an error: the root
16496
- // exists before anything has been written to it, and a directory is
16497
- // created by its parent naming it rather than by its own record
16498
- // appearing.
16499
- if (raw === null || raw === undefined) {
16500
- const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
16501
- return { id, key, path, node, hidden: Object.create(null) };
16502
- }
16503
- // Present but unopenable is NOT absent. `unseal` folds the two
16504
- // together, which suits a record that can simply be rebuilt; a
16505
- // directory cannot, because rebuilding it means sealing an empty
16506
- // one over whatever is really there. And it is not a raw
16507
- // DOMException either: the typed error is what delete, purge and
16508
- // sweep know how to step around, and it names the record.
16509
- let value;
16510
- try {
16511
- value = await openSealed(key, raw);
16512
- }
16513
- catch (err) {
16514
- if (err instanceof SealedRecordError)
16515
- throw new VfsCorruptRecordError(id, path, err.message);
16516
- throw err;
16517
- }
16518
- return this.readRecord(id, key, value, path);
17081
+ return this.fetchRecord(id, key, gen, path);
16519
17082
  };
16520
17083
  const pending = load().catch((err) => {
16521
17084
  // A failed read must not be remembered, or one blip poisons the
@@ -16524,9 +17087,61 @@ class VfsNamespace {
16524
17087
  this.cache.delete(id);
16525
17088
  throw err;
16526
17089
  });
17090
+ // Registered before anyone else can wait on it, so it is known by
17091
+ // the time any of them goes on to edit the record: see `editable`.
17092
+ pending.then((dir) => void this.copyOf.set(pending, dir), () => { });
16527
17093
  this.cache.set(id, pending);
16528
17094
  return pending;
16529
17095
  }
17096
+ /**
17097
+ * Fetch record `id` from the store and open it. Whatever another tab
17098
+ * announced before the fetch began is in what it returns (see
17099
+ * `current`), and so is every write its counter counts: read here, or
17100
+ * given as `counter` (see `OpenDir.counter`).
17101
+ */
17102
+ async fetchRecord(id, key, gen, path, counter) {
17103
+ const seen = this.hearing;
17104
+ // Before the fetch, never after it. A write that lands in between
17105
+ // then only makes this copy look out of date, which costs a read;
17106
+ // read after, the counter could count a write the answer missed.
17107
+ // Without a coordinator there is none, and nothing is awaited: a read
17108
+ // in Node takes the turns it always took.
17109
+ if (counter === undefined)
17110
+ counter = this.deps.coordinator ? await this.counterOf(id) : null;
17111
+ const raw = await this.deps.store.get(dirKey(id));
17112
+ // An absent record is an EMPTY directory, not an error: the root
17113
+ // exists before anything has been written to it, and a directory is
17114
+ // created by its parent naming it rather than by its own record
17115
+ // appearing.
17116
+ if (raw === null || raw === undefined) {
17117
+ const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
17118
+ return { id, key, path, node, hidden: Object.create(null), gen, seen, counter };
17119
+ }
17120
+ // Present but unopenable is NOT absent. `unseal` folds the two
17121
+ // together, which suits a record that can simply be rebuilt; a
17122
+ // directory cannot, because rebuilding it means sealing an empty
17123
+ // one over whatever is really there. And it is not a raw
17124
+ // DOMException either: the typed error is what delete, purge and
17125
+ // sweep know how to step around, and it names the record.
17126
+ let value;
17127
+ try {
17128
+ value = await openSealed(key, raw);
17129
+ }
17130
+ catch (err) {
17131
+ if (err instanceof SealedRecordError)
17132
+ throw new VfsCorruptRecordError(id, path, err.message);
17133
+ throw err;
17134
+ }
17135
+ return this.readRecord(id, key, value, gen, seen, counter, path);
17136
+ }
17137
+ /** Record `id`'s write counter now: see `OpenDir.counter`. */
17138
+ async counterOf(id) {
17139
+ const coordinator = this.deps.coordinator;
17140
+ if (!coordinator)
17141
+ return null;
17142
+ const [counter] = await coordinator.counters([id]).catch(() => [null]);
17143
+ return counter ?? null;
17144
+ }
16530
17145
  /**
16531
17146
  * Turn a decrypted record into an OpenDir, or throw.
16532
17147
  *
@@ -16534,7 +17149,7 @@ class VfsNamespace {
16534
17149
  * see VfsCorruptRecordError. An individual entry of the wrong shape is set
16535
17150
  * aside in `hidden` instead: out of every read, but kept for the write.
16536
17151
  */
16537
- readRecord(id, key, value, path) {
17152
+ readRecord(id, key, value, gen, seen, counter, path) {
16538
17153
  const problem = recordProblem(value);
16539
17154
  if (problem)
16540
17155
  throw new VfsCorruptRecordError(id, path, problem);
@@ -16590,25 +17205,46 @@ class VfsNamespace {
16590
17205
  this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
16591
17206
  }
16592
17207
  // A v1 record's unknown top-level fields survive a write too.
16593
- return { id, key, path, node: { ...top, entries }, hidden };
17208
+ return { id, key, path, node: { ...top, entries }, hidden, gen, seen, counter };
16594
17209
  }
16595
- /** Apply an edit to a directory record and persist it. */
17210
+ /**
17211
+ * Apply an edit to a directory record and persist it.
17212
+ *
17213
+ * `dir` says which record - its id, key and path - and nothing more. The
17214
+ * edit is made to THE copy of that record (see `editable`), which `dir`
17215
+ * may no longer be, and that copy is what is written. Editing whichever
17216
+ * copy the caller held let two copies of one record exist at once: an
17217
+ * overwrite held its directory across storing the old version's
17218
+ * history, a write of the directory failed meanwhile and dropped it from
17219
+ * the cache, and a save that then read a second copy and stored it,
17220
+ * successfully, was undone when the overwrite stored the first.
17221
+ *
17222
+ * So `edit` must take what it writes from the record it is given, not
17223
+ * from anything read off `dir` before an await - and re-check whatever it
17224
+ * relies on from then, throwing if another write changed it, before it
17225
+ * changes anything. Returning false writes nothing after all.
17226
+ *
17227
+ * The write that carries the edit may carry others, made while an
17228
+ * earlier write of the record was in flight (see `queue`): it lands, or
17229
+ * fails, for all of them at once.
17230
+ *
17231
+ * With a coordinator, the edit may be made a second time, to the record
17232
+ * as another tab left it, and refused then (see `replay`): an edit must
17233
+ * check everything before it changes anything, and whatever it sets
17234
+ * outside `node` and `record` must be safe to set again, since each run
17235
+ * overwrites the last.
17236
+ */
16596
17237
  async mutate(dir, edit) {
16597
- edit(dir.node);
16598
- dir.node.mtime = Date.now();
16599
- // A write to a hidden entry's exact name replaces it: that name now
16600
- // means what this version wrote. Forgetting the old one here is also
16601
- // 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];
16605
- }
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);
17238
+ // At once when the copy is to hand, which it almost always is, so
17239
+ // that what the caller checked just before is still true when the
17240
+ // edit is made.
17241
+ const target = this.editable(dir) ?? await this.reopen(dir);
17242
+ if (edit(target.node, target) === false)
17243
+ return;
17244
+ this.touch(target);
17245
+ const ticket = { edit };
17246
+ const batch = this.queue(target, ticket);
17247
+ const { write } = batch;
16612
17248
  try {
16613
17249
  await write;
16614
17250
  }
@@ -16624,15 +17260,309 @@ class VfsNamespace {
16624
17260
  // included, so the cache shows what is about to be stored, and
16625
17261
  // dropping it would only send readers to the store for an older
16626
17262
  // 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);
17263
+ //
17264
+ // Nor from a cache cleared since: that is the next identity's.
17265
+ if (this.writing.get(target.id) === write && target.gen === this.generation)
17266
+ this.cache.delete(target.id);
16629
17267
  throw err;
16630
17268
  }
16631
17269
  finally {
16632
- if (this.writing.get(dir.id) === write)
16633
- this.writing.delete(dir.id);
17270
+ if (this.writing.get(target.id) === write)
17271
+ this.writing.delete(target.id);
17272
+ }
17273
+ this.adopt(target);
17274
+ // Made again to the record as another tab left it, it no longer
17275
+ // applied - see `replay` - and nothing of it was written.
17276
+ if (batch.refused.has(ticket))
17277
+ throw batch.refused.get(ticket);
17278
+ }
17279
+ /**
17280
+ * The copy of `dir`'s record that edits are made to, if there is one
17281
+ * without waiting: the one the cache holds, once read.
17282
+ *
17283
+ * Failing that, the one a write in flight is sending. The record was
17284
+ * dropped from the cache - a change was reported, our own write's echo
17285
+ * among them - and a read that came in since is waiting for that write,
17286
+ * to answer with this very copy if it lands (see `openDirById`); if it
17287
+ * fails, the write an edit now queues behind it carries the copy instead.
17288
+ *
17289
+ * Undefined when the record is not cached and nothing is writing it, or
17290
+ * it is still being read: `reopen` waits for that. A record read before
17291
+ * `clearCache` is edited as it is, as it always was. It is not the copy of
17292
+ * anything the cache holds now, and must not be handed the next identity's.
17293
+ */
17294
+ editable(dir) {
17295
+ if (dir.gen !== this.generation)
17296
+ return dir;
17297
+ const cached = this.cache.get(dir.id);
17298
+ const write = this.writing.get(dir.id);
17299
+ const copy = (cached && this.copyOf.get(cached)) ?? (write && this.copyOf.get(write));
17300
+ return copy?.gen === this.generation ? copy : undefined;
17301
+ }
17302
+ /**
17303
+ * Read `dir`'s record - the cached read, or a fetch made once any write of
17304
+ * it in flight has settled - and then find the copy edits are made to.
17305
+ */
17306
+ async reopen(dir) {
17307
+ for (;;) {
17308
+ await this.openDirById(dir.id, dir.key, dir.path);
17309
+ // What that read answered with, unless a change has dropped it
17310
+ // from the cache again since: then read it again.
17311
+ const copy = this.editable(dir);
17312
+ if (copy)
17313
+ return copy;
17314
+ }
17315
+ }
17316
+ /** Make `dir` the cached copy of its record - unless the cache has been cleared since it was read. */
17317
+ adopt(dir) {
17318
+ if (dir.gen !== this.generation)
17319
+ return;
17320
+ const cached = Promise.resolve(dir);
17321
+ this.copyOf.set(cached, dir);
17322
+ this.cache.set(dir.id, cached);
17323
+ }
17324
+ /** After an edit of `copy`: its time, and any entry it cannot read that the edit replaced. */
17325
+ touch(copy) {
17326
+ copy.node.mtime = Date.now();
17327
+ // A write to a hidden entry's exact name replaces it: that name now
17328
+ // means what this version wrote. Forgetting the old one here is also
17329
+ // what stops a later delete of the replacement from resurrecting it.
17330
+ for (const name of Object.keys(copy.hidden)) {
17331
+ if (Object.hasOwn(copy.node.entries, name))
17332
+ delete copy.hidden[name];
17333
+ }
17334
+ }
17335
+ /**
17336
+ * The write that will carry the edit just made to `copy`.
17337
+ *
17338
+ * Writes of a record go out one at a time, each once the one before it
17339
+ * has landed or failed (see `writing`), and each seals the record as it
17340
+ * stands when it goes out. So a write still waiting carries every edit
17341
+ * made while it waits, and an edit made then joins it instead of
17342
+ * queueing a write of its own: a burst of edits of one directory while a
17343
+ * write of it is in flight costs two writes, not one per edit. Each is a
17344
+ * billed write; a 20-file upload into one folder cost twenty.
17345
+ */
17346
+ queue(copy, ticket) {
17347
+ const open = this.waiting.get(copy.id);
17348
+ if (open?.copy === copy) {
17349
+ open.edits.push(ticket);
17350
+ return open;
17351
+ }
17352
+ const before = this.writing.get(copy.id);
17353
+ const batch = { copy, edits: [ticket], refused: new Map() };
17354
+ this.waiting.set(copy.id, batch);
17355
+ // Not sent until any earlier write of this record has landed: see
17356
+ // `writing`. Whether that one worked or not, this one still goes.
17357
+ const send = () => this.send(batch);
17358
+ batch.write = before ? before.then(send, send) : send();
17359
+ this.copyOf.set(batch.write, copy);
17360
+ this.writing.set(copy.id, batch.write);
17361
+ return batch;
17362
+ }
17363
+ /**
17364
+ * Send `batch`: from here on, an edit of its record goes in the next
17365
+ * write. With a coordinator, holding the lock every tab of the device
17366
+ * takes to write this record - see `sendLocked`.
17367
+ */
17368
+ send(batch) {
17369
+ const coordinator = this.deps.coordinator;
17370
+ if (!coordinator) {
17371
+ this.close(batch);
17372
+ return this.writeRecord(batch.copy);
17373
+ }
17374
+ // Closed under the lock once it is sealed (see `sendLocked`), and
17375
+ // here too, for a lock never granted or a re-read that failed: left
17376
+ // open, a later edit of the same copy would join a write that has
17377
+ // already failed, and fail with it.
17378
+ return coordinator.lock(batch.copy.id, () => this.sendLocked(batch, coordinator)).finally(() => this.close(batch));
17379
+ }
17380
+ /** Stop `batch` taking more edits, as it is sealed. */
17381
+ close(batch) {
17382
+ if (this.waiting.get(batch.copy.id) === batch)
17383
+ this.waiting.delete(batch.copy.id);
17384
+ }
17385
+ /**
17386
+ * Send `batch` while no other tab can write its record.
17387
+ *
17388
+ * If another tab wrote the record since this copy was read, the copy is
17389
+ * out of date, and writing it would undo that tab's write. So it is read
17390
+ * again, here, under the lock, and every edit the batch carries is made
17391
+ * again to what was read (see `replay`). A copy no other tab has written
17392
+ * over is sent as it is: one tab on its own costs no extra request.
17393
+ *
17394
+ * Which it is, the record's write counter says (see
17395
+ * `VfsCoordinator.counters`). Read once the lock is granted, it counts
17396
+ * every write of the record another tab made, because each counts its
17397
+ * write, and waits for the count to be stored, before it lets go of the
17398
+ * lock. If it has moved since the copy was read, or cannot be told, the
17399
+ * copy is read again. Whether this tab has heard of another tab's write
17400
+ * does not decide it: an announcement can reach this tab after the
17401
+ * lock does, or never. One it has heard of can only add a read.
17402
+ *
17403
+ * The write is counted once it has landed or failed - one that failed
17404
+ * may have landed even so - and the other tabs are then told of it,
17405
+ * all before the lock is let go of.
17406
+ */
17407
+ async sendLocked(batch, coordinator) {
17408
+ const { copy } = batch;
17409
+ // Hearing for whoever is signed in now, before deciding: starting to
17410
+ // hear - or hearing someone else - marks every copy out of date.
17411
+ coordinator.listen();
17412
+ const now = await coordinator.begin(copy.id).catch(() => null);
17413
+ // Whether `copy` is, from here, the record as this tab leaves it.
17414
+ let decided = false;
17415
+ let sent = false;
17416
+ try {
17417
+ const current = now !== null && copy.counter === now && this.current(copy);
17418
+ const fresh = copy.gen === this.generation && !current
17419
+ ? await this.fetchRecord(copy.id, copy.key, copy.gen, copy.path, null)
17420
+ : null;
17421
+ // Nothing waits from here to the seal, so no edit can come in between.
17422
+ this.close(batch);
17423
+ decided = true;
17424
+ this.locked.set(copy.id, copy);
17425
+ if (fresh && !this.replay(batch, fresh))
17426
+ return;
17427
+ sent = true;
17428
+ await this.writeRecord(copy);
17429
+ copy.seen = this.hearing;
17430
+ }
17431
+ finally {
17432
+ const counted = await coordinator.bump(copy.id).catch(() => null);
17433
+ // Not when the read above failed: the copy is what it was, and
17434
+ // the counter it held is out of date now.
17435
+ if (decided)
17436
+ copy.counter = counted;
17437
+ if (this.locked.get(copy.id) === copy)
17438
+ this.locked.delete(copy.id);
17439
+ if (counted === null)
17440
+ this.notCounted(copy, now !== null);
17441
+ else
17442
+ this.uncounted.delete(copy.id);
17443
+ if (sent)
17444
+ coordinator.announce(copy.id);
17445
+ }
17446
+ }
17447
+ /**
17448
+ * Say that a write of `copy`'s record could not be counted.
17449
+ *
17450
+ * `marked`: `VfsCoordinator.begin` stored its mark, which stays until a
17451
+ * count can be stored, so every tab reads the record again before it
17452
+ * writes it. Said once for each record, until a write of it is counted.
17453
+ *
17454
+ * Not marked, a tab whose IndexedDB works may take its copy for current
17455
+ * and write it over this write. Said once for this namespace, whatever
17456
+ * the record: where IndexedDB cannot be used at all, no write of any
17457
+ * folder is counted, and a warning for each said nothing new. And only
17458
+ * where another tab can count its writes can one be lost, which the
17459
+ * warning says: where no tab can, every tab reads each folder again.
17460
+ */
17461
+ notCounted(copy, marked) {
17462
+ const where = copy.path ?? `record ${printable(copy.id, 40)}`;
17463
+ if (!marked) {
17464
+ if (this.lossWarned)
17465
+ return;
17466
+ this.lossWarned = true;
17467
+ this.warn(`a write of ${where} by this tab could not be counted in IndexedDB, where this app's other tabs look before writing a folder. Such a write may be lost only if another tab of this app can count its writes: that tab may write the folder without reading it again. Said once per Client, for every write of a folder that cannot be counted`);
17468
+ return;
17469
+ }
17470
+ if (this.uncounted.has(copy.id))
17471
+ return;
17472
+ this.uncounted.add(copy.id);
17473
+ this.warn(`a write of ${where} by this tab could not be counted in IndexedDB; until one can be, every tab of this app reads ${where} again before writing it`);
17474
+ }
17475
+ /**
17476
+ * Make every edit in `batch` again, in order, to `fresh` - the record as
17477
+ * another tab left it - and make that the copy. An edit that no longer
17478
+ * applies throws, as it would have the first time, and is left out; its
17479
+ * caller gets what it threw. Every edit checks before it changes
17480
+ * anything (see `mutate`), so one that throws has changed nothing. True
17481
+ * when what is left changes the record, and so needs writing.
17482
+ */
17483
+ replay(batch, fresh) {
17484
+ const { copy } = batch;
17485
+ copy.node = fresh.node;
17486
+ copy.hidden = fresh.hidden;
17487
+ copy.seen = fresh.seen;
17488
+ copy.counter = fresh.counter;
17489
+ let changed = false;
17490
+ for (const ticket of batch.edits) {
17491
+ try {
17492
+ if (ticket.edit(copy.node, copy) !== false)
17493
+ changed = true;
17494
+ }
17495
+ catch (err) {
17496
+ batch.refused.set(ticket, err);
17497
+ }
17498
+ }
17499
+ if (changed)
17500
+ this.touch(copy);
17501
+ return changed;
17502
+ }
17503
+ /**
17504
+ * Another tab wrote record `id` - or, null, this namespace may have
17505
+ * missed some announcements. What the cache holds is read again when
17506
+ * next asked for, as a change frame from the personal space would have
17507
+ * it, and a copy read before then is read again before the next write
17508
+ * of its record (see `current`). A hint, not a guarantee: an
17509
+ * announcement can reach this tab after the next lock does, or never.
17510
+ * What decides whether a copy may be written over, or relied on under a
17511
+ * move's or a delete's locks, is the record's counter: see `sendLocked`
17512
+ * and `dropStale`.
17513
+ */
17514
+ heard(id) {
17515
+ const at = ++this.hearing;
17516
+ if (id === null) {
17517
+ this.missedAt = at;
17518
+ this.cache.clear();
17519
+ return;
16634
17520
  }
16635
- this.cache.set(dir.id, Promise.resolve(dir));
17521
+ this.heardAt.set(id, at);
17522
+ this.cache.delete(id);
17523
+ }
17524
+ /**
17525
+ * Does `copy` hold every write of its record another tab has announced
17526
+ * that this one has heard of? See `heard`. Only ever a reason to read it
17527
+ * again, never to take it for current: see `sendLocked`.
17528
+ */
17529
+ current(copy) {
17530
+ return copy.seen >= Math.max(this.missedAt, this.heardAt.get(copy.id) ?? 0);
17531
+ }
17532
+ /**
17533
+ * Drop from the cache every copy of a record in `ids` that may not hold
17534
+ * every write of it another tab has made: its counter has moved since
17535
+ * the copy was read, or cannot be told, or the copy is still being read.
17536
+ * Returns every id whose copy here is not known to be current - those,
17537
+ * and those not cached - so that the next read of each fetches it again,
17538
+ * once any write of it in flight here has landed or failed. Not a copy
17539
+ * this tab is writing under its lock (see `locked`): its counter reads
17540
+ * as unknown only because that write is not counted yet, and dropped,
17541
+ * the next read would wait for the write.
17542
+ *
17543
+ * The counters are read in one go, after the caller has taken the locks
17544
+ * it relies on: another tab counts each write of a record before it lets
17545
+ * go of the locks it held while writing it, the record's own and any
17546
+ * move lock, so what it wrote before letting go of one of them is
17547
+ * counted by then. Its announcements may not have reached this tab yet,
17548
+ * or ever. See `sendLocked`.
17549
+ */
17550
+ async dropStale(coordinator, ids) {
17551
+ const asked = [...new Set(ids)];
17552
+ const now = asked.length ? await coordinator.counters(asked).catch(() => asked.map(() => null)) : [];
17553
+ const stale = new Set();
17554
+ asked.forEach((id, i) => {
17555
+ const cached = this.cache.get(id);
17556
+ const copy = cached && this.copyOf.get(cached);
17557
+ if (copy && copy.gen === this.generation && copy.counter !== null && copy.counter === now[i])
17558
+ return;
17559
+ if (copy && copy.gen === this.generation && this.locked.get(id) === copy)
17560
+ return;
17561
+ if (cached)
17562
+ this.cache.delete(id);
17563
+ stale.add(id);
17564
+ });
17565
+ return stale;
16636
17566
  }
16637
17567
  /**
16638
17568
  * Seal a directory record as it stands NOW and put it in the store.
@@ -16660,13 +17590,38 @@ class VfsNamespace {
16660
17590
  }
16661
17591
  await this.deps.store.put(dirKey(dir.id), sealed);
16662
17592
  }
16663
- async createDir(parentPath, name) {
17593
+ /** For `mkdir`: name a new, empty directory `path` in the one at `parentPath`. */
17594
+ async createDir(parentPath, name, path, recursive) {
16664
17595
  assertValidName(name);
16665
17596
  const parent = await this.openDir(parentPath);
16666
17597
  const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
16667
- await this.mutate(parent, (node) => {
17598
+ // The directory the edit below last found there, if any.
17599
+ let there;
17600
+ const named = this.mutate(parent, (node, record) => {
17601
+ // mkdir looked before the wait above. A directory made there
17602
+ // since, by another `mkdir -p` of the same path - files saved
17603
+ // at once into a new folder - is kept, not replaced: whatever
17604
+ // was saved into it would go with it.
17605
+ const now = entryIn(node, name);
17606
+ there = now;
17607
+ if (now?.kind === "dir" && recursive)
17608
+ return false;
17609
+ if (now)
17610
+ throw new VfsConflictError(`${path} ${now.kind === "dir" ? "already exists" : "is a file"}`);
17611
+ assertNotHidden(record, name, path);
16668
17612
  node.entries[name] = entry;
17613
+ return true;
16669
17614
  });
17615
+ // Set before anyone can find the entry in the cached parent - `mutate`
17616
+ // makes the edit at once, or after a re-read - so a mkdir that finds
17617
+ // it there waits for this write. See `naming`.
17618
+ this.naming.set(entry.id, named.then(() => { }, () => { }).then(() => void this.naming.delete(entry.id)));
17619
+ await named;
17620
+ // The directory kept instead may be one another mkdir here named,
17621
+ // its write not landed yet: wait for that too, as `mkdir` does.
17622
+ const kept = there && this.naming.get(there.id);
17623
+ if (kept)
17624
+ await kept;
16670
17625
  }
16671
17626
  /**
16672
17627
  * Every directory record in a subtree, each once, opened - and nothing
@@ -16682,14 +17637,22 @@ class VfsNamespace {
16682
17637
  *
16683
17638
  * `moving` says whether anything in it is marked as half of an
16684
17639
  * interrupted move, which is what makes a delete look for the other half.
17640
+ *
17641
+ * `ids` is every entry id in it, and `shape` what each record named when
17642
+ * it was read: see `currentSubtree`.
16685
17643
  */
16686
17644
  async subtree(top, target, above) {
16687
17645
  const found = new Map([[top.id, top]]);
16688
17646
  const pending = [top];
17647
+ // Every entry id in it, and what each record named as it was read.
17648
+ const ids = new Set();
17649
+ const shape = new Map();
16689
17650
  let spared = 0;
16690
17651
  let moving = false;
16691
17652
  for (let dir = pending.pop(); dir; dir = pending.pop()) {
17653
+ shape.set(dir.id, namedIn(dir));
16692
17654
  for (const [, entry] of followable(dir)) {
17655
+ ids.add(entry.id);
16693
17656
  if (entry.moving === true)
16694
17657
  moving = true;
16695
17658
  if (entry.kind !== "dir" || found.has(entry.id))
@@ -16719,7 +17682,79 @@ class VfsNamespace {
16719
17682
  const what = spared === 1 ? "a directory that is" : `${spared} directories that are`;
16720
17683
  this.warn(`leaving ${what} above ${target} in the tree alone while deleting it`);
16721
17684
  }
16722
- return { dirs: [...found.values()], moving };
17685
+ return { dirs: [...found.values()], moving, ids, shape };
17686
+ }
17687
+ /**
17688
+ * The records a recursive delete read, as they are now - or null when
17689
+ * one has changed since in a way that purging it would get wrong.
17690
+ *
17691
+ * The delete decided what to let go of, and whether to look for the
17692
+ * other half of a move, from what it read; then it waited, on reads and
17693
+ * on the write of its parent. A record that is no longer named by the
17694
+ * record it was read in has been moved out, and is not this delete's to
17695
+ * purge; an entry marked since it was read is being moved, and is about
17696
+ * to be named somewhere the delete never looked. Purging either purged
17697
+ * the folder a move had just put somewhere else. Anything else - a file
17698
+ * saved into one of them, or moved out - is purged as it now stands,
17699
+ * which is why the copies to purge are what this returns.
17700
+ *
17701
+ * Synchronous, for an edit. A record whose copy is not to hand counts as
17702
+ * changed.
17703
+ *
17704
+ * `held`, with a coordinator, is every entry whose move lock the delete
17705
+ * holds. An entry named since the records were read - saved or moved in
17706
+ * - is not among them, so nothing stops another tab moving it out while
17707
+ * the delete writes the parent, and purging it then let go of what that
17708
+ * move had put somewhere else. So with `held`, that counts as changed
17709
+ * too, and the delete is tried again from the start.
17710
+ */
17711
+ currentSubtree(dirs, shape, held) {
17712
+ const doomed = new Set(dirs.map((dir) => dir.id));
17713
+ const current = [];
17714
+ for (const dir of dirs) {
17715
+ const now = this.editable(dir);
17716
+ if (!now)
17717
+ return null;
17718
+ const read = shape.get(dir.id) ?? new Map();
17719
+ const named = namedIn(now);
17720
+ for (const [id, marked] of named)
17721
+ if (marked && read.get(id) !== true)
17722
+ return null;
17723
+ for (const id of read.keys())
17724
+ if (doomed.has(id) && !named.has(id))
17725
+ return null;
17726
+ if (held)
17727
+ for (const id of named.keys())
17728
+ if (!held.has(id))
17729
+ return null;
17730
+ current.push(now);
17731
+ }
17732
+ return current;
17733
+ }
17734
+ /**
17735
+ * Hold the move lock of every entry in `ids` - see
17736
+ * `VfsCoordinator.holdMoving` - or of none: null when another holder has
17737
+ * any of them, and then nothing is held. Otherwise the function that
17738
+ * lets go of them all. Asked for all at once: none of them waits.
17739
+ * `shared` holds them as the folders a move goes into.
17740
+ */
17741
+ async holdAll(coordinator, ids, shared = false) {
17742
+ const tries = await Promise.allSettled([...new Set(ids)].map((id) => coordinator.holdMoving(id, shared)));
17743
+ const held = tries.flatMap((tried) => (tried.status === "fulfilled" && tried.value ? [tried.value] : []));
17744
+ const release = () => {
17745
+ for (const letGo of held)
17746
+ letGo();
17747
+ };
17748
+ const failed = tries.find((tried) => tried.status === "rejected");
17749
+ if (failed) {
17750
+ release();
17751
+ throw failed.reason;
17752
+ }
17753
+ if (held.length < tries.length) {
17754
+ release();
17755
+ return null;
17756
+ }
17757
+ return release;
16723
17758
  }
16724
17759
  /**
16725
17760
  * Delete these records and let go of every file in them. The second half
@@ -16775,8 +17810,15 @@ class VfsNamespace {
16775
17810
  // Opened again: this delete may have written to it since the tree was read.
16776
17811
  const dir = await this.openDirById(holding[0].id, holding[0].key);
16777
17812
  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])));
17813
+ if (name === undefined)
17814
+ return;
17815
+ await this.mutate(dir, (node) => {
17816
+ const now = entryIn(node, name);
17817
+ if (now?.id !== id || now.moving !== true)
17818
+ return false;
17819
+ node.entries[name] = unmarked(now);
17820
+ return true;
17821
+ });
16780
17822
  }
16781
17823
  catch {
16782
17824
  // It stays marked.
@@ -16973,9 +18015,12 @@ class VfsNamespace {
16973
18015
  }
16974
18016
  if (!run.write)
16975
18017
  return clone;
16976
- const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null) };
18018
+ const counter = this.deps.coordinator ? await this.counterOf(clone.id) : null;
18019
+ const open = {
18020
+ id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen, seen: this.hearing, counter,
18021
+ };
16977
18022
  await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
16978
- this.cache.set(open.id, Promise.resolve(open));
18023
+ this.adopt(open);
16979
18024
  return clone;
16980
18025
  }
16981
18026
  /**
@@ -17972,8 +19017,11 @@ class Repo {
17972
19017
  async expand(prefix) {
17973
19018
  if (!/^[0-9a-f]{4,}$/.test(prefix))
17974
19019
  return null;
17975
- const keys = await this.deps.store.list();
17976
19020
  const wanted = objectKey(this.slug, prefix);
19021
+ // Only the keys under the abbreviation (usually none or one), not the
19022
+ // whole personal space. The filter below stays, for a store that
19023
+ // ignores the prefix.
19024
+ const keys = await this.deps.store.list(wanted);
17977
19025
  // Only keys that could be objects: a stray key under the prefix would
17978
19026
  // otherwise make every abbreviation of it "ambiguous".
17979
19027
  const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
@@ -18320,6 +19368,519 @@ class VcsNamespace {
18320
19368
  }
18321
19369
  }
18322
19370
 
19371
+ /**
19372
+ * Coordination between the tabs of an app on one device: Web Locks, the
19373
+ * Web BroadcastChannel, and IndexedDB.
19374
+ *
19375
+ * Every factory here returns undefined unless the runtime is a browser tab
19376
+ * with both Web Locks and a BroadcastChannel - not Node (which has
19377
+ * `navigator.locks` from 24.5, but per process), not a Worker, not an old
19378
+ * WebView - and its callers then keep the behaviour they had before: one
19379
+ * tab's writes ordered among themselves only. IndexedDB is not required:
19380
+ * without it no count can be told, and every write reads its folder again
19381
+ * first. A storage-partitioned iframe gets locks, a channel and IndexedDB of
19382
+ * its own, as it gets storage of its own; to the app's other tabs it is
19383
+ * another device.
19384
+ *
19385
+ * A browser can also have these and refuse them to a document: an opaque
19386
+ * origin (a sandboxed iframe, a `data:` document), or a site whose data the
19387
+ * user has blocked. Refused Web Locks, the coordinator goes on without
19388
+ * them, as in Node (see `refusesLocks`); refused a channel, it hears
19389
+ * nothing; and an IndexedDB that does not answer in time counts nothing
19390
+ * (see `writeCounts`). `client.vfs` needed none of the three before it
19391
+ * coordinated tabs, and none of them may stop it now.
19392
+ *
19393
+ * `globalThis.BroadcastChannel` is named in full on purpose: this package
19394
+ * exports a class of its own called BroadcastChannel (the E2E room channel),
19395
+ * and an import of that in scope would silently take the Web API's place.
19396
+ */
19397
+ /** How long a write waits for another tab to finish writing the same folder before it gives up. */
19398
+ const LOCK_WAIT_MS = 30_000;
19399
+ /**
19400
+ * How long each call to the write counts - `counters`, `begin` or `bump` -
19401
+ * may take before it gives up, telling nothing, as one that failed: the
19402
+ * wait for the IndexedDB open and the call's transaction together, not
19403
+ * each. Only the call gives up, not the open: one that succeeds later,
19404
+ * within `OPEN_DROP_MS`, is kept for every later call (see `writeCounts`).
19405
+ */
19406
+ const COUNT_WAIT_MS = 3_000;
19407
+ /**
19408
+ * How long an IndexedDB open of the write counts may go without any answer
19409
+ * - no success, error or blocked - before it is dropped, as one that
19410
+ * failed, and the next call asks for another. See `writeCounts`.
19411
+ */
19412
+ const OPEN_DROP_MS = 30_000;
19413
+ /**
19414
+ * Whether `err`, from a Web Locks request that was never granted, is the
19415
+ * browser refusing Web Locks to this document altogether: an opaque origin,
19416
+ * or a site whose data the user has blocked. The browser makes that
19417
+ * decision for the site's storage, so it refuses every tab of the app
19418
+ * alike, and no tab holds the lock. The tab then goes on without it, as in
19419
+ * Node. Any other error - a document no longer fully active, say - is
19420
+ * passed on.
19421
+ */
19422
+ function refusesLocks(err) {
19423
+ return err?.name === "SecurityError";
19424
+ }
19425
+ // Through `unknown`: the DOM's own LockManager and BroadcastChannel types do
19426
+ // not line up with these slices closely enough for a direct cast.
19427
+ function webLocks() {
19428
+ if (typeof window === "undefined")
19429
+ return undefined;
19430
+ const locks = globalThis.navigator?.locks;
19431
+ return typeof locks?.request === "function" ? locks : undefined;
19432
+ }
19433
+ function webChannel() {
19434
+ const Channel = globalThis.BroadcastChannel;
19435
+ return typeof Channel === "function" ? Channel : undefined;
19436
+ }
19437
+ function webIndexedDB() {
19438
+ const idb = globalThis.indexedDB;
19439
+ return typeof idb?.open === "function" ? idb : undefined;
19440
+ }
19441
+ /**
19442
+ * The IndexedDB database the write counts are kept in, one per origin, as
19443
+ * the locks are. Not one connect keeps for anything else: those are
19444
+ * `muhkoo.offline` (the offline layer), `muhkoo.trust` and `muhkoo.device.v1`.
19445
+ *
19446
+ * Its version stays 1 for good. A tab still running code that opens it at
19447
+ * version 1 would fail with a VersionError once a newer tab had made it at
19448
+ * version 2, count nothing, and so write a folder without the newer tabs
19449
+ * knowing: the writer whose write may be lost. A new store goes in a new
19450
+ * database.
19451
+ */
19452
+ const COUNTS_DB = "muhkoo.vfs";
19453
+ /** Its one store: `[commitment, record id]` to a count, and `CREATED` to a random id. */
19454
+ const COUNTS = "counts";
19455
+ const CREATED = "created";
19456
+ /**
19457
+ * How often each user's directory records have been written on this
19458
+ * device: `VfsCoordinator.counters`, `begin` and `bump`, kept in IndexedDB
19459
+ * for every tab of the origin.
19460
+ *
19461
+ * A count is odd from `begin` to `bump`, while a write is under way, and
19462
+ * even once it is counted; `counters` reports an odd one as unknown. It is
19463
+ * read as `<created>:<count>`, where `created` is a random id stored when
19464
+ * the database was made: deleted - the site's data cleared - every count
19465
+ * starts again from nothing, and a counter read before could otherwise
19466
+ * come round again with writes in between that its copy does not hold.
19467
+ *
19468
+ * Each operation is one transaction, and settles once that has completed.
19469
+ * Every failure - IndexedDB missing, an open refused, blocked or failed, a
19470
+ * transaction aborted - resolves to null, which the namespace takes for out
19471
+ * of date: it reads the record again. A failed or blocked open is not kept,
19472
+ * and nor is a connection closed under it, by a newer version of this code
19473
+ * in another tab or by the database being deleted: the next operation opens
19474
+ * the database again.
19475
+ *
19476
+ * So does an operation that has not settled within `waitMs`, the open it
19477
+ * waits for and its transaction together: these are awaited inside the
19478
+ * record locks, and one waited for without end would hold the lock, and
19479
+ * stop every read and write of the tab's folders, for as long. Only the
19480
+ * operation gives up, though, not the open: there is one open under way at
19481
+ * a time, every operation waits for that one, and once it has opened the
19482
+ * database it is kept for every later operation. Given up on too, and closed
19483
+ * when it opened at last, an open that always takes longer than `waitMs`
19484
+ * would never give the tab its counts. An open that has not answered at all
19485
+ * within `OPEN_DROP_MS` is dropped, though, as one that failed: one that
19486
+ * never will would otherwise leave the tab without counts for as long as it
19487
+ * lives. Until then every operation waits for it, `waitMs` at most, and
19488
+ * tells nothing; the next operation after that asks for one new open; and
19489
+ * the dropped one, if it opens after all, is closed. The new open is
19490
+ * dropped in turn if it goes `OPEN_DROP_MS` without an answer too, and so
19491
+ * on: where IndexedDB never answers, the tab leaves behind one more open
19492
+ * that never settles every `OPEN_DROP_MS` or a little more, for as long as
19493
+ * operations keep coming, and none while none come. An operation that
19494
+ * gives up before the database has opened makes no transaction. A
19495
+ * transaction given up on is not aborted, and may still complete: IndexedDB
19496
+ * runs the read-write transactions of one store in the order they were
19497
+ * made, so a late `begin` still marks the count before any later `bump`,
19498
+ * and a late `bump` still counts its write before any later `begin`. A tab
19499
+ * that asks meanwhile waits behind it, gives up, and reads the record
19500
+ * again.
19501
+ */
19502
+ function writeCounts(idb, waitMs) {
19503
+ // The open under way, or the one that opened `opened`: one at a time.
19504
+ let connection = null;
19505
+ // The database `connection` opened, once it has.
19506
+ let opened = null;
19507
+ // Drops `connection` while it is under way, once `OPEN_DROP_MS` have passed.
19508
+ let dropping;
19509
+ const forget = (db) => {
19510
+ if (opened !== db)
19511
+ return;
19512
+ opened = null;
19513
+ connection = null;
19514
+ db.close();
19515
+ };
19516
+ const open = () => {
19517
+ if (connection)
19518
+ return connection;
19519
+ let request;
19520
+ try {
19521
+ if (!idb)
19522
+ throw new Error("no IndexedDB");
19523
+ request = idb.open(COUNTS_DB, 1);
19524
+ }
19525
+ catch {
19526
+ // Not kept: the next operation tries again.
19527
+ return Promise.resolve(null);
19528
+ }
19529
+ const pending = new Promise((resolve) => {
19530
+ // Not kept when it failed, or was dropped: the next operation
19531
+ // tries again. Failed after it was dropped, it changes nothing.
19532
+ const fail = () => {
19533
+ if (connection === pending) {
19534
+ clearTimeout(dropping);
19535
+ connection = null;
19536
+ }
19537
+ resolve(null);
19538
+ };
19539
+ request.onupgradeneeded = () => {
19540
+ request.result.createObjectStore(COUNTS).put(generateId(), CREATED);
19541
+ };
19542
+ request.onsuccess = () => {
19543
+ const db = request.result;
19544
+ // Blocked or dropped, and given up on, before it opened - or
19545
+ // closed by `close`. Another open may be under way by now.
19546
+ if (connection !== pending) {
19547
+ db.close();
19548
+ resolve(null);
19549
+ return;
19550
+ }
19551
+ clearTimeout(dropping);
19552
+ db.onversionchange = () => forget(db);
19553
+ db.onclose = () => forget(db);
19554
+ opened = db;
19555
+ resolve(db);
19556
+ };
19557
+ // Blocked: another tab holds an older version open. Not waited for.
19558
+ request.onerror = fail;
19559
+ request.onblocked = fail;
19560
+ // No answer at all: dropped, as one that failed, and the next
19561
+ // operation asks for another - dropped in turn if it does not
19562
+ // answer either. See above.
19563
+ dropping = setTimeout(fail, OPEN_DROP_MS);
19564
+ });
19565
+ connection = pending;
19566
+ return pending;
19567
+ };
19568
+ /**
19569
+ * Run `body` in one transaction, and resolve once that has completed to
19570
+ * what the function it returns gives then - or to null, if anything
19571
+ * fails, or the database has not opened and the transaction completed
19572
+ * within `waitMs`.
19573
+ */
19574
+ const run = (mode, body) => new Promise((resolve) => {
19575
+ let done = false;
19576
+ const settle = (value) => {
19577
+ if (done)
19578
+ return;
19579
+ done = true;
19580
+ clearTimeout(timer);
19581
+ resolve(value);
19582
+ };
19583
+ // Given up on, but neither the open nor the transaction aborted: see above.
19584
+ const timer = setTimeout(() => settle(null), waitMs);
19585
+ void open().then((db) => {
19586
+ // Given up on while it opened: nothing to do.
19587
+ if (done)
19588
+ return;
19589
+ if (!db) {
19590
+ settle(null);
19591
+ return;
19592
+ }
19593
+ let tx;
19594
+ try {
19595
+ tx = db.transaction(COUNTS, mode);
19596
+ const result = body(tx.objectStore(COUNTS));
19597
+ tx.oncomplete = () => settle(result());
19598
+ tx.onabort = () => settle(null);
19599
+ }
19600
+ catch {
19601
+ try {
19602
+ tx?.abort();
19603
+ }
19604
+ catch {
19605
+ // Finished already.
19606
+ }
19607
+ // Closed under it, most likely: the next operation opens it again.
19608
+ forget(db);
19609
+ settle(null);
19610
+ }
19611
+ });
19612
+ });
19613
+ const count = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0);
19614
+ const counter = (created, n) => (typeof created === "string" ? `${created}:${n}` : null);
19615
+ return {
19616
+ async counters(who, ids) {
19617
+ const read = await run("readonly", (store) => {
19618
+ const created = store.get(CREATED);
19619
+ const counts = ids.map((id) => store.get([who, id]));
19620
+ return () => counts.map((at) => {
19621
+ const n = count(at.result);
19622
+ return n % 2 === 0 ? counter(created.result, n) : null;
19623
+ });
19624
+ });
19625
+ return read ?? ids.map(() => null);
19626
+ },
19627
+ begin(who, id) {
19628
+ return run("readwrite", (store) => {
19629
+ const created = store.get(CREATED);
19630
+ const at = store.get([who, id]);
19631
+ let n = 0;
19632
+ at.onsuccess = () => {
19633
+ n = count(at.result);
19634
+ // Odd already, a write began and was never counted: its mark stays.
19635
+ if (n % 2 === 0)
19636
+ store.put(n + 1, [who, id]);
19637
+ };
19638
+ return () => counter(created.result, n);
19639
+ });
19640
+ },
19641
+ bump(who, id) {
19642
+ return run("readwrite", (store) => {
19643
+ const created = store.get(CREATED);
19644
+ const at = store.get([who, id]);
19645
+ let next = 0;
19646
+ at.onsuccess = () => {
19647
+ const n = count(at.result);
19648
+ // Past the mark `begin` stored - or, when it could not
19649
+ // store one, past the count it found.
19650
+ next = n % 2 === 0 ? n + 2 : n + 1;
19651
+ store.put(next, [who, id]);
19652
+ };
19653
+ return () => counter(created.result, next);
19654
+ });
19655
+ },
19656
+ close() {
19657
+ // An open still under way is closed once it opens.
19658
+ connection = null;
19659
+ clearTimeout(dropping);
19660
+ if (opened)
19661
+ forget(opened);
19662
+ },
19663
+ };
19664
+ }
19665
+ /**
19666
+ * The offline queue's drain lock: `fn` runs only if no other tab is
19667
+ * draining, and `false` says another one is. See `OutboundQueue`. Where the
19668
+ * browser refuses Web Locks, `fn` runs without it, as in Node, and the
19669
+ * lock is not asked for again: see `refusesLocks`.
19670
+ */
19671
+ function drainLock() {
19672
+ const locks = webLocks();
19673
+ if (!locks)
19674
+ return undefined;
19675
+ let refused = false;
19676
+ return async (name, fn) => {
19677
+ if (!refused) {
19678
+ let answered = false;
19679
+ try {
19680
+ return await locks.request(name, { ifAvailable: true }, async (lock) => {
19681
+ answered = true;
19682
+ if (!lock)
19683
+ return false;
19684
+ await fn();
19685
+ return true;
19686
+ });
19687
+ }
19688
+ catch (err) {
19689
+ if (answered || !refusesLocks(err))
19690
+ throw err;
19691
+ refused = true;
19692
+ }
19693
+ }
19694
+ await fn();
19695
+ return true;
19696
+ };
19697
+ }
19698
+ /**
19699
+ * The `VfsCoordinator` for whoever `commitment()` names: a lock per record,
19700
+ * `muhkoo:vfs:<commitment>:<record id>`; a move lock per entry,
19701
+ * `muhkoo:vfs:<commitment>:moving:<entry id>`, taken only if free, and
19702
+ * shared by the moves into a folder; a count of each record's writes, in
19703
+ * IndexedDB `muhkoo.vfs` under `[commitment, record id]` (see
19704
+ * `writeCounts`); and announcements on the channel `muhkoo:vfs:<commitment>`
19705
+ * as `{ id, writer }`, where `writer` is this tab's random id. `close` is
19706
+ * for tests, as are `lockWaitMs` (see `LOCK_WAIT_MS`) and `countWaitMs`
19707
+ * (see `COUNT_WAIT_MS`).
19708
+ *
19709
+ * Where the browser refuses Web Locks (see `refusesLocks`), it holds no
19710
+ * lock - every write runs as it would without a coordinator, and every
19711
+ * move lock is had at once - and asks for none again.
19712
+ */
19713
+ function vfsCoordinator(commitment, opts = {}) {
19714
+ const locks = webLocks();
19715
+ const Channel = webChannel();
19716
+ if (!locks || !Channel)
19717
+ return undefined;
19718
+ const lockWaitMs = opts.lockWaitMs ?? LOCK_WAIT_MS;
19719
+ const tab = generateId();
19720
+ const counts = writeCounts(webIndexedDB(), opts.countWaitMs ?? COUNT_WAIT_MS);
19721
+ const handlers = new Set();
19722
+ // `channel` is null where the browser would not make one: see `listen`.
19723
+ let open = null;
19724
+ // Move locks this tab has let go of, until the browser has too. See `holdMoving`.
19725
+ const leaving = new Map();
19726
+ // The browser refused Web Locks: see `refusesLocks`.
19727
+ let refused = false;
19728
+ /**
19729
+ * Hear the channel of whoever is signed in now: opened the first time,
19730
+ * and opened again for someone else after a sign-in. What was announced
19731
+ * before it opened was not heard, which every handler is told.
19732
+ *
19733
+ * Where the browser will not make the channel - an opaque origin, or a
19734
+ * site whose data is blocked - this tab hears nothing and tells no one,
19735
+ * and does not ask again for the same user. That is safe: the counts,
19736
+ * not the announcements, decide whether a copy may be written over.
19737
+ */
19738
+ const listen = () => {
19739
+ const who = commitment();
19740
+ if (open?.who === who)
19741
+ return;
19742
+ open?.channel?.close();
19743
+ open = null;
19744
+ if (!who)
19745
+ return;
19746
+ let channel = null;
19747
+ try {
19748
+ channel = new Channel(`muhkoo:vfs:${who}`);
19749
+ channel.onmessage = (event) => {
19750
+ const data = event.data;
19751
+ if (typeof data?.id !== "string" || data.writer === tab)
19752
+ return;
19753
+ for (const handler of handlers)
19754
+ handler(data.id);
19755
+ };
19756
+ }
19757
+ catch {
19758
+ channel = null;
19759
+ }
19760
+ open = { who, channel };
19761
+ for (const handler of handlers)
19762
+ handler(null);
19763
+ };
19764
+ return {
19765
+ async lock(id, fn) {
19766
+ listen();
19767
+ const who = commitment();
19768
+ // Signed out: the store refuses the write itself, with a clearer
19769
+ // message. Refused Web Locks: no tab of the app can hold this
19770
+ // lock, and the write goes ahead as it would without one.
19771
+ if (!who || refused)
19772
+ return fn();
19773
+ // Only the WAIT is bounded. Once granted, the lock is held until
19774
+ // the write has landed or failed: let go of while a write is still
19775
+ // on its way, the next tab's write could land first and then be
19776
+ // undone by it - the very loss this lock is for.
19777
+ const waiting = new AbortController();
19778
+ const timer = setTimeout(() => waiting.abort(), lockWaitMs);
19779
+ let granted = false;
19780
+ try {
19781
+ return await locks.request(`muhkoo:vfs:${who}:${id}`, { signal: waiting.signal }, () => {
19782
+ granted = true;
19783
+ clearTimeout(timer);
19784
+ return fn();
19785
+ });
19786
+ }
19787
+ catch (err) {
19788
+ if (!granted && waiting.signal.aborted) {
19789
+ throw new VfsConflictError(`another tab of this app has been writing this folder for over ${Math.round(lockWaitMs / 1000)} seconds; nothing was written - try again`);
19790
+ }
19791
+ if (granted || !refusesLocks(err))
19792
+ throw err;
19793
+ refused = true;
19794
+ }
19795
+ finally {
19796
+ clearTimeout(timer);
19797
+ }
19798
+ // Refused Web Locks, before anything was written: written without one.
19799
+ return fn();
19800
+ },
19801
+ announce(id) {
19802
+ listen();
19803
+ open?.channel?.postMessage({ id, writer: tab });
19804
+ },
19805
+ onAnnounce(handler) {
19806
+ handlers.add(handler);
19807
+ return () => void handlers.delete(handler);
19808
+ },
19809
+ listen,
19810
+ // Signed out, nothing is counted: the store refuses the reads and
19811
+ // writes themselves.
19812
+ async counters(ids) {
19813
+ const who = commitment();
19814
+ return who ? counts.counters(who, ids) : ids.map(() => null);
19815
+ },
19816
+ async begin(id) {
19817
+ const who = commitment();
19818
+ return who ? counts.begin(who, id) : null;
19819
+ },
19820
+ async bump(id) {
19821
+ const who = commitment();
19822
+ return who ? counts.bump(who, id) : null;
19823
+ },
19824
+ async holdMoving(id, shared = false) {
19825
+ const who = commitment();
19826
+ // Signed out: the store refuses the writes themselves. Refused Web
19827
+ // Locks, no tab of the app can hold this lock.
19828
+ if (!who || refused)
19829
+ return () => { };
19830
+ const name = `muhkoo:vfs:${who}:moving:${id}`;
19831
+ // Let go of by this tab, but perhaps not yet by the browser, which
19832
+ // lets go only once the callback below has settled. Asked for
19833
+ // before then - a move or a delete straight after another of the
19834
+ // same entry - it is still held, and this tab refuses itself.
19835
+ const before = leaving.get(name);
19836
+ if (before)
19837
+ await before;
19838
+ return new Promise((resolve, reject) => {
19839
+ let answered = false;
19840
+ const request = locks.request(name, { ifAvailable: true, mode: shared ? "shared" : "exclusive" }, async (lock) => {
19841
+ answered = true;
19842
+ if (!lock) {
19843
+ resolve(null);
19844
+ return;
19845
+ }
19846
+ // Held until this settles: when the function handed back
19847
+ // is called, or when the tab goes and the browser lets go
19848
+ // of everything it held.
19849
+ let held = true;
19850
+ await new Promise((letGo) => resolve(() => {
19851
+ if (!held)
19852
+ return;
19853
+ held = false;
19854
+ // Settles once the browser has let go of it too,
19855
+ // and of every other hold of it this tab has let
19856
+ // go of: two moves into one folder hold it at once.
19857
+ const gone = Promise.all([leaving.get(name), request.then(() => { }, () => { })]).then(() => { });
19858
+ leaving.set(name, gone);
19859
+ void gone.then(() => {
19860
+ if (leaving.get(name) === gone)
19861
+ leaving.delete(name);
19862
+ });
19863
+ letGo();
19864
+ }));
19865
+ });
19866
+ request.catch((err) => {
19867
+ if (answered || !refusesLocks(err)) {
19868
+ reject(err);
19869
+ return;
19870
+ }
19871
+ refused = true;
19872
+ resolve(() => { });
19873
+ });
19874
+ });
19875
+ },
19876
+ close() {
19877
+ open?.channel?.close();
19878
+ open = null;
19879
+ counts.close();
19880
+ },
19881
+ };
19882
+ }
19883
+
18323
19884
  /**
18324
19885
  * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
18325
19886
  * lexicographically-sortable string so the whole offline layer (and the
@@ -18606,13 +20167,19 @@ class ConnectivityManager {
18606
20167
  * Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
18607
20168
  * by `clientId`, file PUTs are content-addressed), so re-running a partially
18608
20169
  * drained queue is safe.
20170
+ *
20171
+ * Every tab of an origin shares the queue. Given a `tryLock`, a drain runs
20172
+ * only while holding `muhkoo:offline-drain:<commitment>`, so two tabs coming
20173
+ * back online replay it once between them, not once each.
18609
20174
  */
18610
20175
  class OutboundQueue {
18611
20176
  store;
20177
+ opts;
18612
20178
  replayers = new Map();
18613
20179
  draining = null;
18614
- constructor(store) {
20180
+ constructor(store, opts = {}) {
18615
20181
  this.store = store;
20182
+ this.opts = opts;
18616
20183
  }
18617
20184
  /** A domain registers how to replay its mutations. */
18618
20185
  register(domain, replayer) {
@@ -18634,11 +20201,31 @@ class OutboundQueue {
18634
20201
  drain() {
18635
20202
  if (this.draining)
18636
20203
  return this.draining;
18637
- this.draining = this.runDrain().finally(() => {
20204
+ this.draining = this.drainOnce().finally(() => {
18638
20205
  this.draining = null;
18639
20206
  });
18640
20207
  return this.draining;
18641
20208
  }
20209
+ /**
20210
+ * One drain, holding the device-wide drain lock where there is one. A
20211
+ * tab that finds another tab holding it leaves the queue to that one,
20212
+ * which is replaying it already and removes each entry as it lands - so
20213
+ * a drain after it finds only what it left.
20214
+ */
20215
+ async drainOnce() {
20216
+ const who = this.opts.commitment?.();
20217
+ if (!this.opts.tryLock || !who)
20218
+ return this.runDrain();
20219
+ await this.opts.tryLock(`muhkoo:offline-drain:${who}`, async () => {
20220
+ // A tab that found this one draining skipped its own drain, and
20221
+ // what it queued after this pass read the queue was left to this
20222
+ // tab. So drain until a pass leaves nothing; a transient failure
20223
+ // still stops it, by throwing.
20224
+ do {
20225
+ await this.runDrain();
20226
+ } while ((await this.store.queued()).length > 0);
20227
+ });
20228
+ }
18642
20229
  async runDrain() {
18643
20230
  const entries = await this.store.queued();
18644
20231
  for (const entry of entries) {
@@ -18885,6 +20472,14 @@ const META_NODE_ID = "nodeId";
18885
20472
  * stay decoupled from IndexedDB and the clock.
18886
20473
  */
18887
20474
  const HLC_STATE_KEY = "global";
20475
+ /**
20476
+ * A random suffix for this tab's HLC node id, held in memory only - see
20477
+ * `OfflineManager.boot`. 48 bits: two tabs of one device never draw the same.
20478
+ */
20479
+ function tabSuffix() {
20480
+ const bytes = crypto.getRandomValues(new Uint8Array(6));
20481
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
20482
+ }
18888
20483
  class OfflineManager {
18889
20484
  /** True in browsers with IndexedDB; false when running over a NoopStore. */
18890
20485
  enabled;
@@ -18903,7 +20498,10 @@ class OfflineManager {
18903
20498
  constructor(deps) {
18904
20499
  this.enabled = deps.enabled;
18905
20500
  this.store = deps.store;
18906
- this.queue = new OutboundQueue(deps.store);
20501
+ this.queue = new OutboundQueue(deps.store, {
20502
+ tryLock: deps.locks,
20503
+ commitment: () => deps.session.commitment,
20504
+ });
18907
20505
  this.connectivity = new ConnectivityManager({
18908
20506
  onReconnect: () => void this.sync.run(),
18909
20507
  });
@@ -19001,14 +20599,22 @@ class OfflineManager {
19001
20599
  this.hlc = new HLC(this.nodeId);
19002
20600
  return;
19003
20601
  }
19004
- let nodeId = await this.store.get("meta", META_NODE_ID);
19005
- if (!nodeId) {
19006
- nodeId = generateId();
19007
- await this.store.put("meta", META_NODE_ID, nodeId);
19008
- }
19009
- this.nodeId = nodeId;
20602
+ let deviceId = await this.store.get("meta", META_NODE_ID);
20603
+ if (!deviceId) {
20604
+ deviceId = generateId();
20605
+ await this.store.put("meta", META_NODE_ID, deviceId);
20606
+ }
20607
+ // One node id per TAB, under the device's. Every tab of an origin
20608
+ // loads the same stored id, and two tabs stamping with it in the same
20609
+ // millisecond minted identical HLCs: the node id is the tiebreak, and
20610
+ // the server keeps the first of two equal stamps, so the second write
20611
+ // was dropped. The suffix lives in memory only. The clock state below
20612
+ // stays shared, so the clock never goes back across tabs, and stamps
20613
+ // stored already stay valid: every comparison reads a packed stamp as
20614
+ // one string and never parses the node id.
20615
+ this.nodeId = `${deviceId}.${tabSuffix()}`;
19010
20616
  const state = await this.store.get("hlc-state", HLC_STATE_KEY);
19011
- this.hlc = new HLC(nodeId, { state });
20617
+ this.hlc = new HLC(this.nodeId, { state });
19012
20618
  }
19013
20619
  /** Coalesce HLC-state writes — a burst of stamps persists once. */
19014
20620
  schedulePersist() {
@@ -19325,11 +20931,15 @@ class KvCache {
19325
20931
  const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
19326
20932
  await this.store.put("kv-cache", k, mergeRegister(local, incoming));
19327
20933
  }
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));
20934
+ /**
20935
+ * Non-tombstoned full keys for a commitment that start with `prefix` (all
20936
+ * of them by default), for offline `list`. The store scans the key range
20937
+ * `${commitment}|${prefix}`, so other collections are never read.
20938
+ */
20939
+ async liveKeys(commitment, prefix = "") {
20940
+ const scope = `${commitment}|`;
20941
+ const rows = await this.store.prefix("kv-cache", scope + prefix);
20942
+ return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(scope.length));
19333
20943
  }
19334
20944
  /** Queue a kv mutation for replay on reconnect. */
19335
20945
  async enqueue(method, args, hlc, clientId) {
@@ -19485,13 +21095,17 @@ class Client {
19485
21095
  /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
19486
21096
  recoverInFlight = null;
19487
21097
  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}`);
21098
+ // Build stamp — logged (independent of logLevel) so the running build
21099
+ // is identifiable in the console. Catches stale cached bundles during
21100
+ // development; bump `VERSION` (and package.json) each build.
21101
+ // Browser console only: in Node it lands on stdout, in front of a
21102
+ // CLI's own output (`muhkoo kv ls --json | jq`).
21103
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
21104
+ try {
21105
+ console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
21106
+ }
21107
+ catch { /* console may be unavailable in some runtimes */ }
19493
21108
  }
19494
- catch { /* console may be unavailable in some runtimes */ }
19495
21109
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
19496
21110
  globalThis.appLogger.setLevel(options.logLevel);
19497
21111
  }
@@ -19508,6 +21122,8 @@ class Client {
19508
21122
  store: offlineStore,
19509
21123
  session: this.session,
19510
21124
  enabled: offlineEnabled,
21125
+ // Every tab of the origin shares the queue: one drains it at a time.
21126
+ locks: drainLock(),
19511
21127
  });
19512
21128
  // Wrap fetch so every accelerator round-trip feeds the connectivity
19513
21129
  // detector: a thrown fetch is the strongest "offline" signal, a 2xx the
@@ -19593,12 +21209,13 @@ class Client {
19593
21209
  throw new Error("client.vfs: no session — sign in first.");
19594
21210
  await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
19595
21211
  },
19596
- list: async () => {
21212
+ list: async (prefix) => {
19597
21213
  const c = this.session.commitment;
19598
21214
  if (!c)
19599
21215
  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 ?? [];
21216
+ // The server filters by prefix and pages. An older server ignores
21217
+ // the prefix and returns every key, which is why callers filter.
21218
+ return listPersonalKeys(this.http, `/api/personal/${encodeURIComponent(c)}/list`, prefix ?? "");
19602
21219
  },
19603
21220
  };
19604
21221
  let contentSpace = null;
@@ -19627,6 +21244,9 @@ class Client {
19627
21244
  this.vfs = new VfsNamespace({
19628
21245
  store: personalStore,
19629
21246
  seed: () => this.session.seed,
21247
+ // Orders writes of a folder with this app's other tabs on the
21248
+ // device - undefined outside a browser tab with Web Locks.
21249
+ coordinator: vfsCoordinator(() => this.session.commitment),
19630
21250
  // Share the personal space's socket with `client.kv` rather than
19631
21251
  // opening a second one to the same Durable Object.
19632
21252
  subscribe: (handler) => this.kv.onRaw(handler),