@muhkoo/connect 0.15.0-alpha.3 → 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.
- package/dist/browser/index.js +1552 -226
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +414 -23
- package/dist/server/index.js +1552 -226
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -15205,21 +15205,25 @@ function nextTurn() {
|
|
|
15205
15205
|
*
|
|
15206
15206
|
* Every mutation is a read-modify-write of exactly ONE directory record, so a
|
|
15207
15207
|
* write cannot leave the tree half-updated: either the parent names the new
|
|
15208
|
-
* child or it does not.
|
|
15209
|
-
*
|
|
15210
|
-
*
|
|
15211
|
-
*
|
|
15212
|
-
*
|
|
15213
|
-
*
|
|
15214
|
-
*
|
|
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
|
|
15215
15226
|
* exchange for single-record writes.
|
|
15216
|
-
*
|
|
15217
|
-
* Within ONE tab, writes of a record are queued and sent one at a time, in the
|
|
15218
|
-
* order they were made, and a read that has to fetch a record waits for its
|
|
15219
|
-
* write in flight (see `writing`). Every edit of a record is made to one
|
|
15220
|
-
* in-memory copy of it, and that copy is what is written (see `mutate`). All
|
|
15221
|
-
* of this is per namespace instance only; nothing orders writes across tabs
|
|
15222
|
-
* or devices - that would take a compare-and-swap at the server.
|
|
15223
15227
|
*/
|
|
15224
15228
|
/** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
|
|
15225
15229
|
function assertWritableDepth(path) {
|
|
@@ -15449,6 +15453,24 @@ function markedMeanwhile(before, now) {
|
|
|
15449
15453
|
function changedMeanwhile(path) {
|
|
15450
15454
|
return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
|
|
15451
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
|
+
}
|
|
15452
15474
|
/** A JSON object, as opposed to null, an array or a scalar. */
|
|
15453
15475
|
function isObject$1(value) {
|
|
15454
15476
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -15606,6 +15628,13 @@ function followable(dir) {
|
|
|
15606
15628
|
function unfollowable(dir) {
|
|
15607
15629
|
return Object.values(dir.hidden).filter((value) => !readableEntry(value)).length;
|
|
15608
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
|
+
}
|
|
15609
15638
|
class VfsNamespace {
|
|
15610
15639
|
deps;
|
|
15611
15640
|
rootKey = null;
|
|
@@ -15648,6 +15677,81 @@ class VfsNamespace {
|
|
|
15648
15677
|
* `openDirById`.
|
|
15649
15678
|
*/
|
|
15650
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();
|
|
15651
15755
|
historyLimit;
|
|
15652
15756
|
maxEntries;
|
|
15653
15757
|
maxPathChars;
|
|
@@ -15656,6 +15760,8 @@ class VfsNamespace {
|
|
|
15656
15760
|
this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
15657
15761
|
this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
|
|
15658
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));
|
|
15659
15765
|
}
|
|
15660
15766
|
/**
|
|
15661
15767
|
* The working directory relative paths resolve against.
|
|
@@ -15708,6 +15814,7 @@ class VfsNamespace {
|
|
|
15708
15814
|
// stays out of the cache from here on: see `generation`.
|
|
15709
15815
|
this.generation++;
|
|
15710
15816
|
this.cache.clear();
|
|
15817
|
+
this.heardAt.clear();
|
|
15711
15818
|
this.rootKey = null;
|
|
15712
15819
|
this._cwd = "/"; // a different identity's tree has different directories
|
|
15713
15820
|
}
|
|
@@ -16055,8 +16162,22 @@ class VfsNamespace {
|
|
|
16055
16162
|
const name = parts[i];
|
|
16056
16163
|
const isLast = i === parts.length - 1;
|
|
16057
16164
|
const next = join(current, name);
|
|
16058
|
-
|
|
16059
|
-
|
|
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
|
+
}
|
|
16060
16181
|
if (!existing)
|
|
16061
16182
|
assertNotHidden(dir, name, next);
|
|
16062
16183
|
if (existing) {
|
|
@@ -16182,89 +16303,219 @@ class VfsNamespace {
|
|
|
16182
16303
|
this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
|
|
16183
16304
|
return;
|
|
16184
16305
|
}
|
|
16185
|
-
//
|
|
16186
|
-
//
|
|
16187
|
-
|
|
16188
|
-
|
|
16189
|
-
|
|
16190
|
-
//
|
|
16191
|
-
//
|
|
16192
|
-
|
|
16193
|
-
|
|
16194
|
-
//
|
|
16195
|
-
//
|
|
16196
|
-
|
|
16197
|
-
|
|
16198
|
-
|
|
16199
|
-
|
|
16200
|
-
|
|
16201
|
-
|
|
16202
|
-
|
|
16203
|
-
//
|
|
16204
|
-
//
|
|
16205
|
-
//
|
|
16206
|
-
//
|
|
16207
|
-
|
|
16208
|
-
|
|
16209
|
-
|
|
16210
|
-
|
|
16211
|
-
|
|
16212
|
-
|
|
16213
|
-
|
|
16214
|
-
|
|
16215
|
-
|
|
16216
|
-
|
|
16217
|
-
|
|
16218
|
-
|
|
16219
|
-
|
|
16220
|
-
|
|
16221
|
-
|
|
16222
|
-
|
|
16223
|
-
|
|
16224
|
-
//
|
|
16225
|
-
//
|
|
16226
|
-
|
|
16227
|
-
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)
|
|
16228
16348
|
throw new VfsConflictError(`${target} is not empty`);
|
|
16229
|
-
|
|
16230
|
-
|
|
16231
|
-
|
|
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
|
+
}
|
|
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);
|
|
16232
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);
|
|
16233
16514
|
}
|
|
16234
|
-
|
|
16235
|
-
|
|
16236
|
-
|
|
16237
|
-
// or a file's content and history released. Only a marked entry can
|
|
16238
|
-
// be one of those (see `rename`), so only then is the whole tree
|
|
16239
|
-
// read, once, to find out what is still named elsewhere. Still before
|
|
16240
|
-
// anything changes: a failure here leaves the tree as it was.
|
|
16241
|
-
const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
|
|
16242
|
-
await this.mutate(parent, (node) => {
|
|
16243
|
-
// What is let go of below is what `entry` held. Gone since, it
|
|
16244
|
-
// was let go of already; replaced, the name holds something this
|
|
16245
|
-
// delete has not read. Marked since, a move of it has begun, and
|
|
16246
|
-
// what it holds is about to be named somewhere `elsewhere` did
|
|
16247
|
-
// not see - if it was looked for at all.
|
|
16248
|
-
const now = entryIn(node, name);
|
|
16249
|
-
if (!now)
|
|
16250
|
-
throw new VfsNotFoundError(target);
|
|
16251
|
-
if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16252
|
-
throw changedMeanwhile(target);
|
|
16253
|
-
delete node.entries[name];
|
|
16254
|
-
});
|
|
16255
|
-
if (elsewhere === null) {
|
|
16256
|
-
// Part of the tree cannot be read, so it is unknown whether the
|
|
16257
|
-
// other copy is in it. Keeping what may be its contents costs
|
|
16258
|
-
// storage until a sweep; letting go of them could lose them.
|
|
16259
|
-
this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
|
|
16260
|
-
return;
|
|
16515
|
+
finally {
|
|
16516
|
+
release?.();
|
|
16517
|
+
this.deleting.delete(claim);
|
|
16261
16518
|
}
|
|
16262
|
-
if (entry.kind === "dir")
|
|
16263
|
-
await this.purge(doomed, elsewhere, opts.keepContent);
|
|
16264
|
-
else
|
|
16265
|
-
await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
|
|
16266
|
-
if (elsewhere && entry.moving === true)
|
|
16267
|
-
await this.settle(entry.id, elsewhere);
|
|
16268
16519
|
}
|
|
16269
16520
|
/**
|
|
16270
16521
|
* Move or rename. Works across directories, which is the same operation:
|
|
@@ -16287,83 +16538,216 @@ class VfsNamespace {
|
|
|
16287
16538
|
const srcName = basename(src);
|
|
16288
16539
|
const dstName = basename(dst);
|
|
16289
16540
|
assertValidName(dstName);
|
|
16290
|
-
const
|
|
16541
|
+
const source = await this.openChain(dirname(src));
|
|
16542
|
+
if (!source)
|
|
16543
|
+
throw new VfsNotFoundError(dirname(src));
|
|
16544
|
+
const srcParent = source.dir;
|
|
16291
16545
|
const entry = entryIn(srcParent.node, srcName);
|
|
16292
16546
|
if (!entry)
|
|
16293
16547
|
throw new VfsNotFoundError(src);
|
|
16294
|
-
|
|
16295
|
-
|
|
16296
|
-
|
|
16297
|
-
|
|
16298
|
-
|
|
16299
|
-
|
|
16300
|
-
|
|
16301
|
-
|
|
16302
|
-
|
|
16303
|
-
|
|
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);
|
|
16304
16565
|
// Each record is re-opened before it is written: creating the
|
|
16305
|
-
// destination's parents
|
|
16566
|
+
// destination's parents below may have written to the source's.
|
|
16306
16567
|
const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
|
|
16307
|
-
|
|
16308
|
-
//
|
|
16309
|
-
//
|
|
16310
|
-
//
|
|
16311
|
-
//
|
|
16312
|
-
//
|
|
16313
|
-
|
|
16314
|
-
|
|
16315
|
-
|
|
16316
|
-
|
|
16317
|
-
|
|
16318
|
-
|
|
16319
|
-
|
|
16320
|
-
|
|
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))
|
|
16321
16647
|
throw new VfsConflictError(`${dst} already exists`);
|
|
16322
|
-
assertNotHidden(
|
|
16323
|
-
|
|
16324
|
-
|
|
16325
|
-
//
|
|
16326
|
-
|
|
16327
|
-
|
|
16328
|
-
|
|
16329
|
-
|
|
16330
|
-
|
|
16331
|
-
|
|
16332
|
-
|
|
16333
|
-
|
|
16334
|
-
|
|
16335
|
-
|
|
16336
|
-
|
|
16337
|
-
|
|
16338
|
-
|
|
16339
|
-
|
|
16340
|
-
|
|
16341
|
-
|
|
16342
|
-
|
|
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);
|
|
16343
16725
|
await edit(dirname(src), (node) => {
|
|
16344
|
-
|
|
16345
|
-
|
|
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);
|
|
16735
|
+
delete node.entries[srcName];
|
|
16736
|
+
// Explicit, as in `createDir` and `settle`: noImplicitReturns.
|
|
16737
|
+
return true;
|
|
16346
16738
|
});
|
|
16739
|
+
unmarkLanded = !marked;
|
|
16347
16740
|
}
|
|
16348
|
-
|
|
16349
|
-
|
|
16350
|
-
|
|
16351
|
-
|
|
16352
|
-
|
|
16353
|
-
|
|
16354
|
-
|
|
16355
|
-
|
|
16356
|
-
|
|
16357
|
-
|
|
16358
|
-
// entry to take away: the move stops here, in both places. Not
|
|
16359
|
-
// `take`: the entry is marked by now, by this move or the earlier
|
|
16360
|
-
// one it finishes.
|
|
16361
|
-
const now = entryIn(node, srcName);
|
|
16362
|
-
if (now && !sameEntry(entry, now))
|
|
16363
|
-
throw changedMeanwhile(src);
|
|
16364
|
-
delete node.entries[srcName];
|
|
16365
|
-
});
|
|
16366
|
-
if (!marked) {
|
|
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) {
|
|
16367
16751
|
await edit(dirname(dst), (node) => {
|
|
16368
16752
|
const landed = entryIn(node, dstName);
|
|
16369
16753
|
if (landed?.id === entry.id)
|
|
@@ -16468,11 +16852,12 @@ class VfsNamespace {
|
|
|
16468
16852
|
* Delete metadata records no longer reachable from the root.
|
|
16469
16853
|
*
|
|
16470
16854
|
* These accumulate because every write touches exactly one record: if two
|
|
16471
|
-
*
|
|
16472
|
-
* whichever put arrives last (a
|
|
16473
|
-
* applies puts of one key in arrival
|
|
16474
|
-
* parent entry lost the race. The
|
|
16475
|
-
*
|
|
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 —
|
|
16476
16861
|
* it is invisible and undecryptable-by-nobody — but it costs storage, so
|
|
16477
16862
|
* this reclaims it.
|
|
16478
16863
|
*
|
|
@@ -16481,6 +16866,12 @@ class VfsNamespace {
|
|
|
16481
16866
|
* Run it when the filesystem is idle.
|
|
16482
16867
|
*/
|
|
16483
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()]);
|
|
16484
16875
|
const root = await this.root();
|
|
16485
16876
|
// A record that cannot be read throws out of here, before anything is
|
|
16486
16877
|
// deleted: with part of the tree unknown, "unreachable" is a guess.
|
|
@@ -16637,6 +17028,11 @@ class VfsNamespace {
|
|
|
16637
17028
|
const hit = this.cache.get(id);
|
|
16638
17029
|
if (hit)
|
|
16639
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();
|
|
16640
17036
|
// The PROMISE goes in the cache, not the resolved value.
|
|
16641
17037
|
//
|
|
16642
17038
|
// Reading a project means resolving hundreds of paths that share the
|
|
@@ -16682,31 +17078,7 @@ class VfsNamespace {
|
|
|
16682
17078
|
if (!now)
|
|
16683
17079
|
this.cache.set(id, pending);
|
|
16684
17080
|
}
|
|
16685
|
-
|
|
16686
|
-
// An absent record is an EMPTY directory, not an error: the root
|
|
16687
|
-
// exists before anything has been written to it, and a directory is
|
|
16688
|
-
// created by its parent naming it rather than by its own record
|
|
16689
|
-
// appearing.
|
|
16690
|
-
if (raw === null || raw === undefined) {
|
|
16691
|
-
const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
|
|
16692
|
-
return { id, key, path, node, hidden: Object.create(null), gen };
|
|
16693
|
-
}
|
|
16694
|
-
// Present but unopenable is NOT absent. `unseal` folds the two
|
|
16695
|
-
// together, which suits a record that can simply be rebuilt; a
|
|
16696
|
-
// directory cannot, because rebuilding it means sealing an empty
|
|
16697
|
-
// one over whatever is really there. And it is not a raw
|
|
16698
|
-
// DOMException either: the typed error is what delete, purge and
|
|
16699
|
-
// sweep know how to step around, and it names the record.
|
|
16700
|
-
let value;
|
|
16701
|
-
try {
|
|
16702
|
-
value = await openSealed(key, raw);
|
|
16703
|
-
}
|
|
16704
|
-
catch (err) {
|
|
16705
|
-
if (err instanceof SealedRecordError)
|
|
16706
|
-
throw new VfsCorruptRecordError(id, path, err.message);
|
|
16707
|
-
throw err;
|
|
16708
|
-
}
|
|
16709
|
-
return this.readRecord(id, key, value, gen, path);
|
|
17081
|
+
return this.fetchRecord(id, key, gen, path);
|
|
16710
17082
|
};
|
|
16711
17083
|
const pending = load().catch((err) => {
|
|
16712
17084
|
// A failed read must not be remembered, or one blip poisons the
|
|
@@ -16721,6 +17093,55 @@ class VfsNamespace {
|
|
|
16721
17093
|
this.cache.set(id, pending);
|
|
16722
17094
|
return pending;
|
|
16723
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
|
+
}
|
|
16724
17145
|
/**
|
|
16725
17146
|
* Turn a decrypted record into an OpenDir, or throw.
|
|
16726
17147
|
*
|
|
@@ -16728,7 +17149,7 @@ class VfsNamespace {
|
|
|
16728
17149
|
* see VfsCorruptRecordError. An individual entry of the wrong shape is set
|
|
16729
17150
|
* aside in `hidden` instead: out of every read, but kept for the write.
|
|
16730
17151
|
*/
|
|
16731
|
-
readRecord(id, key, value, gen, path) {
|
|
17152
|
+
readRecord(id, key, value, gen, seen, counter, path) {
|
|
16732
17153
|
const problem = recordProblem(value);
|
|
16733
17154
|
if (problem)
|
|
16734
17155
|
throw new VfsCorruptRecordError(id, path, problem);
|
|
@@ -16784,7 +17205,7 @@ class VfsNamespace {
|
|
|
16784
17205
|
this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
|
|
16785
17206
|
}
|
|
16786
17207
|
// A v1 record's unknown top-level fields survive a write too.
|
|
16787
|
-
return { id, key, path, node: { ...top, entries }, hidden, gen };
|
|
17208
|
+
return { id, key, path, node: { ...top, entries }, hidden, gen, seen, counter };
|
|
16788
17209
|
}
|
|
16789
17210
|
/**
|
|
16790
17211
|
* Apply an edit to a directory record and persist it.
|
|
@@ -16800,8 +17221,18 @@ class VfsNamespace {
|
|
|
16800
17221
|
*
|
|
16801
17222
|
* So `edit` must take what it writes from the record it is given, not
|
|
16802
17223
|
* from anything read off `dir` before an await - and re-check whatever it
|
|
16803
|
-
* relies on from then, throwing if another write changed it
|
|
16804
|
-
* false writes nothing after all.
|
|
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.
|
|
16805
17236
|
*/
|
|
16806
17237
|
async mutate(dir, edit) {
|
|
16807
17238
|
// At once when the copy is to hand, which it almost always is, so
|
|
@@ -16810,21 +17241,10 @@ class VfsNamespace {
|
|
|
16810
17241
|
const target = this.editable(dir) ?? await this.reopen(dir);
|
|
16811
17242
|
if (edit(target.node, target) === false)
|
|
16812
17243
|
return;
|
|
16813
|
-
|
|
16814
|
-
|
|
16815
|
-
|
|
16816
|
-
|
|
16817
|
-
for (const name of Object.keys(target.hidden)) {
|
|
16818
|
-
if (Object.hasOwn(target.node.entries, name))
|
|
16819
|
-
delete target.hidden[name];
|
|
16820
|
-
}
|
|
16821
|
-
// Not sent until any earlier write of this record has landed: see
|
|
16822
|
-
// `writing`. Whether that one worked or not, this one still goes.
|
|
16823
|
-
const before = this.writing.get(target.id);
|
|
16824
|
-
const send = () => this.writeRecord(target);
|
|
16825
|
-
const write = before ? before.then(send, send) : send();
|
|
16826
|
-
this.copyOf.set(write, target);
|
|
16827
|
-
this.writing.set(target.id, write);
|
|
17244
|
+
this.touch(target);
|
|
17245
|
+
const ticket = { edit };
|
|
17246
|
+
const batch = this.queue(target, ticket);
|
|
17247
|
+
const { write } = batch;
|
|
16828
17248
|
try {
|
|
16829
17249
|
await write;
|
|
16830
17250
|
}
|
|
@@ -16851,6 +17271,10 @@ class VfsNamespace {
|
|
|
16851
17271
|
this.writing.delete(target.id);
|
|
16852
17272
|
}
|
|
16853
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);
|
|
16854
17278
|
}
|
|
16855
17279
|
/**
|
|
16856
17280
|
* The copy of `dir`'s record that edits are made to, if there is one
|
|
@@ -16897,6 +17321,249 @@ class VfsNamespace {
|
|
|
16897
17321
|
this.copyOf.set(cached, dir);
|
|
16898
17322
|
this.cache.set(dir.id, cached);
|
|
16899
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;
|
|
17520
|
+
}
|
|
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;
|
|
17566
|
+
}
|
|
16900
17567
|
/**
|
|
16901
17568
|
* Seal a directory record as it stands NOW and put it in the store.
|
|
16902
17569
|
*
|
|
@@ -16928,12 +17595,15 @@ class VfsNamespace {
|
|
|
16928
17595
|
assertValidName(name);
|
|
16929
17596
|
const parent = await this.openDir(parentPath);
|
|
16930
17597
|
const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
16931
|
-
|
|
17598
|
+
// The directory the edit below last found there, if any.
|
|
17599
|
+
let there;
|
|
17600
|
+
const named = this.mutate(parent, (node, record) => {
|
|
16932
17601
|
// mkdir looked before the wait above. A directory made there
|
|
16933
17602
|
// since, by another `mkdir -p` of the same path - files saved
|
|
16934
17603
|
// at once into a new folder - is kept, not replaced: whatever
|
|
16935
17604
|
// was saved into it would go with it.
|
|
16936
17605
|
const now = entryIn(node, name);
|
|
17606
|
+
there = now;
|
|
16937
17607
|
if (now?.kind === "dir" && recursive)
|
|
16938
17608
|
return false;
|
|
16939
17609
|
if (now)
|
|
@@ -16942,6 +17612,16 @@ class VfsNamespace {
|
|
|
16942
17612
|
node.entries[name] = entry;
|
|
16943
17613
|
return true;
|
|
16944
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;
|
|
16945
17625
|
}
|
|
16946
17626
|
/**
|
|
16947
17627
|
* Every directory record in a subtree, each once, opened - and nothing
|
|
@@ -16957,14 +17637,22 @@ class VfsNamespace {
|
|
|
16957
17637
|
*
|
|
16958
17638
|
* `moving` says whether anything in it is marked as half of an
|
|
16959
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`.
|
|
16960
17643
|
*/
|
|
16961
17644
|
async subtree(top, target, above) {
|
|
16962
17645
|
const found = new Map([[top.id, top]]);
|
|
16963
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();
|
|
16964
17650
|
let spared = 0;
|
|
16965
17651
|
let moving = false;
|
|
16966
17652
|
for (let dir = pending.pop(); dir; dir = pending.pop()) {
|
|
17653
|
+
shape.set(dir.id, namedIn(dir));
|
|
16967
17654
|
for (const [, entry] of followable(dir)) {
|
|
17655
|
+
ids.add(entry.id);
|
|
16968
17656
|
if (entry.moving === true)
|
|
16969
17657
|
moving = true;
|
|
16970
17658
|
if (entry.kind !== "dir" || found.has(entry.id))
|
|
@@ -16994,7 +17682,79 @@ class VfsNamespace {
|
|
|
16994
17682
|
const what = spared === 1 ? "a directory that is" : `${spared} directories that are`;
|
|
16995
17683
|
this.warn(`leaving ${what} above ${target} in the tree alone while deleting it`);
|
|
16996
17684
|
}
|
|
16997
|
-
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;
|
|
16998
17758
|
}
|
|
16999
17759
|
/**
|
|
17000
17760
|
* Delete these records and let go of every file in them. The second half
|
|
@@ -17255,7 +18015,10 @@ class VfsNamespace {
|
|
|
17255
18015
|
}
|
|
17256
18016
|
if (!run.write)
|
|
17257
18017
|
return clone;
|
|
17258
|
-
const
|
|
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
|
+
};
|
|
17259
18022
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
17260
18023
|
this.adopt(open);
|
|
17261
18024
|
return clone;
|
|
@@ -18605,6 +19368,519 @@ class VcsNamespace {
|
|
|
18605
19368
|
}
|
|
18606
19369
|
}
|
|
18607
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
|
+
|
|
18608
19884
|
/**
|
|
18609
19885
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
18610
19886
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -18891,13 +20167,19 @@ class ConnectivityManager {
|
|
|
18891
20167
|
* Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
|
|
18892
20168
|
* by `clientId`, file PUTs are content-addressed), so re-running a partially
|
|
18893
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.
|
|
18894
20174
|
*/
|
|
18895
20175
|
class OutboundQueue {
|
|
18896
20176
|
store;
|
|
20177
|
+
opts;
|
|
18897
20178
|
replayers = new Map();
|
|
18898
20179
|
draining = null;
|
|
18899
|
-
constructor(store) {
|
|
20180
|
+
constructor(store, opts = {}) {
|
|
18900
20181
|
this.store = store;
|
|
20182
|
+
this.opts = opts;
|
|
18901
20183
|
}
|
|
18902
20184
|
/** A domain registers how to replay its mutations. */
|
|
18903
20185
|
register(domain, replayer) {
|
|
@@ -18919,11 +20201,31 @@ class OutboundQueue {
|
|
|
18919
20201
|
drain() {
|
|
18920
20202
|
if (this.draining)
|
|
18921
20203
|
return this.draining;
|
|
18922
|
-
this.draining = this.
|
|
20204
|
+
this.draining = this.drainOnce().finally(() => {
|
|
18923
20205
|
this.draining = null;
|
|
18924
20206
|
});
|
|
18925
20207
|
return this.draining;
|
|
18926
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
|
+
}
|
|
18927
20229
|
async runDrain() {
|
|
18928
20230
|
const entries = await this.store.queued();
|
|
18929
20231
|
for (const entry of entries) {
|
|
@@ -19170,6 +20472,14 @@ const META_NODE_ID = "nodeId";
|
|
|
19170
20472
|
* stay decoupled from IndexedDB and the clock.
|
|
19171
20473
|
*/
|
|
19172
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
|
+
}
|
|
19173
20483
|
class OfflineManager {
|
|
19174
20484
|
/** True in browsers with IndexedDB; false when running over a NoopStore. */
|
|
19175
20485
|
enabled;
|
|
@@ -19188,7 +20498,10 @@ class OfflineManager {
|
|
|
19188
20498
|
constructor(deps) {
|
|
19189
20499
|
this.enabled = deps.enabled;
|
|
19190
20500
|
this.store = deps.store;
|
|
19191
|
-
this.queue = new OutboundQueue(deps.store
|
|
20501
|
+
this.queue = new OutboundQueue(deps.store, {
|
|
20502
|
+
tryLock: deps.locks,
|
|
20503
|
+
commitment: () => deps.session.commitment,
|
|
20504
|
+
});
|
|
19192
20505
|
this.connectivity = new ConnectivityManager({
|
|
19193
20506
|
onReconnect: () => void this.sync.run(),
|
|
19194
20507
|
});
|
|
@@ -19286,14 +20599,22 @@ class OfflineManager {
|
|
|
19286
20599
|
this.hlc = new HLC(this.nodeId);
|
|
19287
20600
|
return;
|
|
19288
20601
|
}
|
|
19289
|
-
let
|
|
19290
|
-
if (!
|
|
19291
|
-
|
|
19292
|
-
await this.store.put("meta", META_NODE_ID,
|
|
19293
|
-
}
|
|
19294
|
-
|
|
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()}`;
|
|
19295
20616
|
const state = await this.store.get("hlc-state", HLC_STATE_KEY);
|
|
19296
|
-
this.hlc = new HLC(nodeId, { state });
|
|
20617
|
+
this.hlc = new HLC(this.nodeId, { state });
|
|
19297
20618
|
}
|
|
19298
20619
|
/** Coalesce HLC-state writes — a burst of stamps persists once. */
|
|
19299
20620
|
schedulePersist() {
|
|
@@ -19801,6 +21122,8 @@ class Client {
|
|
|
19801
21122
|
store: offlineStore,
|
|
19802
21123
|
session: this.session,
|
|
19803
21124
|
enabled: offlineEnabled,
|
|
21125
|
+
// Every tab of the origin shares the queue: one drains it at a time.
|
|
21126
|
+
locks: drainLock(),
|
|
19804
21127
|
});
|
|
19805
21128
|
// Wrap fetch so every accelerator round-trip feeds the connectivity
|
|
19806
21129
|
// detector: a thrown fetch is the strongest "offline" signal, a 2xx the
|
|
@@ -19921,6 +21244,9 @@ class Client {
|
|
|
19921
21244
|
this.vfs = new VfsNamespace({
|
|
19922
21245
|
store: personalStore,
|
|
19923
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),
|
|
19924
21250
|
// Share the personal space's socket with `client.kv` rather than
|
|
19925
21251
|
// opening a second one to the same Durable Object.
|
|
19926
21252
|
subscribe: (handler) => this.kv.onRaw(handler),
|