@muhkoo/connect 0.15.0-alpha.1 → 0.15.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/blockEngine.worker.js.map +1 -1
- package/dist/browser/index.js +529 -116
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +120 -16
- package/dist/server/index.js +529 -116
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -5862,6 +5862,34 @@ class ReedSolomonCodec {
|
|
|
5862
5862
|
const SHARD_BATCH_WINDOW_MS = 6;
|
|
5863
5863
|
/** Hashes per batch. Must not exceed what the server will accept. */
|
|
5864
5864
|
const SHARD_BATCH_MAX = 256;
|
|
5865
|
+
/**
|
|
5866
|
+
* Expected response bytes per batch, which a batch is split to stay under.
|
|
5867
|
+
*
|
|
5868
|
+
* The server refuses a batch above 24 MiB of shard bytes with a 413, and the
|
|
5869
|
+
* reads in a refused batch fail. Counting hashes alone, about 25 one-MiB
|
|
5870
|
+
* shards in one window - a few large files read at once - drew it. This
|
|
5871
|
+
* leaves the server's ceiling well clear.
|
|
5872
|
+
*/
|
|
5873
|
+
const SHARD_BATCH_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
5874
|
+
/** An entry's JSON around its base64: the quoted hash, a colon, quotes and a comma, rounded up. */
|
|
5875
|
+
const SHARD_BATCH_ENTRY_BYTES = 128;
|
|
5876
|
+
/**
|
|
5877
|
+
* What a shard adds to a batch's response: base64 is 4 bytes for every 3.
|
|
5878
|
+
*
|
|
5879
|
+
* A shard whose size the caller did not give counts as its entry alone, so
|
|
5880
|
+
* reads without sizes batch by count, as they always have — kept for the
|
|
5881
|
+
* pinned "many concurrent reads → ONE request" test
|
|
5882
|
+
* (`shard-batching.test.ts`). No SDK read path is size-less today:
|
|
5883
|
+
* `FileStorage.readChunk` always passes `shardSize`. The server reads and
|
|
5884
|
+
* base64-encodes every shard before it checks the total
|
|
5885
|
+
* (`ShardsController.ts`), so a very large size-less batch can fail with a
|
|
5886
|
+
* 5xx before the client ever sees a 413. The split in `sendShardBatch` is a
|
|
5887
|
+
* best-effort net for that case, not a guarantee.
|
|
5888
|
+
*/
|
|
5889
|
+
function batchCost(size) {
|
|
5890
|
+
const bytes = typeof size === "number" && Number.isFinite(size) && size > 0 ? size : 0;
|
|
5891
|
+
return Math.ceil(bytes / 3) * 4 + SHARD_BATCH_ENTRY_BYTES;
|
|
5892
|
+
}
|
|
5865
5893
|
/** Decode base64 without `atob`'s per-character loop cost on large shards. */
|
|
5866
5894
|
function bytesFromBase64(b64) {
|
|
5867
5895
|
const binary = atob(b64);
|
|
@@ -5889,7 +5917,7 @@ class ShardClient {
|
|
|
5889
5917
|
pathPrefix;
|
|
5890
5918
|
fetchFn;
|
|
5891
5919
|
cache;
|
|
5892
|
-
/** Hashes waiting for the next batch, each with everyone awaiting it. */
|
|
5920
|
+
/** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
|
|
5893
5921
|
pendingShards;
|
|
5894
5922
|
batchTimer;
|
|
5895
5923
|
/** False once the server is known not to have the batch route. */
|
|
@@ -6038,8 +6066,11 @@ class ShardClient {
|
|
|
6038
6066
|
/**
|
|
6039
6067
|
* Fetch a shard by hash. Throws if the server returns a non-200; returns
|
|
6040
6068
|
* `null` for 404 so callers can distinguish "missing" from "errored".
|
|
6069
|
+
*
|
|
6070
|
+
* `size`, the shard's byte length when known (a manifest's `shardSize`),
|
|
6071
|
+
* lets concurrent reads be batched within what the server will send.
|
|
6041
6072
|
*/
|
|
6042
|
-
async getShard(hash) {
|
|
6073
|
+
async getShard(hash, opts = {}) {
|
|
6043
6074
|
// Cache-first: a hit is content-verified by construction (key == hash).
|
|
6044
6075
|
if (this.cache) {
|
|
6045
6076
|
const cached = await this.cache.get(hash);
|
|
@@ -6048,7 +6079,7 @@ class ShardClient {
|
|
|
6048
6079
|
}
|
|
6049
6080
|
// No peers → straight to origin.
|
|
6050
6081
|
if (!this.peers)
|
|
6051
|
-
return this.fetchFromOrigin(hash);
|
|
6082
|
+
return this.fetchFromOrigin(hash, opts.size);
|
|
6052
6083
|
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
6053
6084
|
// or missing peer stall delivery — give the peer a short head start, then
|
|
6054
6085
|
// start origin in parallel and take whichever returns the block first.
|
|
@@ -6079,7 +6110,7 @@ class ShardClient {
|
|
|
6079
6110
|
clearTimeout(timer);
|
|
6080
6111
|
timer = null;
|
|
6081
6112
|
}
|
|
6082
|
-
this.fetchFromOrigin(hash).then(win, fail);
|
|
6113
|
+
this.fetchFromOrigin(hash, opts.size).then(win, fail);
|
|
6083
6114
|
};
|
|
6084
6115
|
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
6085
6116
|
peers.getBlock(hash).then((b) => {
|
|
@@ -6113,16 +6144,17 @@ class ShardClient {
|
|
|
6113
6144
|
* Falls back permanently to one-at-a-time if the server has no batch route,
|
|
6114
6145
|
* so a new SDK keeps working against an older deployment.
|
|
6115
6146
|
*/
|
|
6116
|
-
fetchFromOrigin(hash) {
|
|
6147
|
+
fetchFromOrigin(hash, size) {
|
|
6117
6148
|
if (!this.batchSupported)
|
|
6118
6149
|
return this.fetchOneFromOrigin(hash);
|
|
6119
6150
|
return new Promise((resolve, reject) => {
|
|
6120
|
-
const
|
|
6121
|
-
if (
|
|
6122
|
-
|
|
6151
|
+
const pending = this.pendingShards.get(hash);
|
|
6152
|
+
if (pending) {
|
|
6153
|
+
pending.waiters.push({ resolve, reject });
|
|
6154
|
+
pending.size ??= size;
|
|
6123
6155
|
return;
|
|
6124
6156
|
}
|
|
6125
|
-
this.pendingShards.set(hash, [{ resolve, reject }]);
|
|
6157
|
+
this.pendingShards.set(hash, { size, waiters: [{ resolve, reject }] });
|
|
6126
6158
|
if (this.batchTimer === null) {
|
|
6127
6159
|
this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
|
|
6128
6160
|
}
|
|
@@ -6133,17 +6165,33 @@ class ShardClient {
|
|
|
6133
6165
|
this.batchTimer = null;
|
|
6134
6166
|
if (this.pendingShards.size === 0)
|
|
6135
6167
|
return;
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6168
|
+
// As many as fit: at most SHARD_BATCH_MAX hashes, and a response
|
|
6169
|
+
// expected to stay within SHARD_BATCH_BUDGET_BYTES. One that does not
|
|
6170
|
+
// fit is skipped for a smaller one behind it. The first always goes,
|
|
6171
|
+
// so a shard over the budget by itself is fetched alone.
|
|
6172
|
+
const waiters = [];
|
|
6173
|
+
let cost = 0;
|
|
6174
|
+
for (const [h, pending] of this.pendingShards) {
|
|
6175
|
+
if (waiters.length === SHARD_BATCH_MAX)
|
|
6176
|
+
break;
|
|
6177
|
+
const next = batchCost(pending.size);
|
|
6178
|
+
if (waiters.length > 0 && cost + next > SHARD_BATCH_BUDGET_BYTES)
|
|
6179
|
+
continue;
|
|
6180
|
+
waiters.push([h, pending.waiters]);
|
|
6181
|
+
cost += next;
|
|
6182
|
+
}
|
|
6183
|
+
for (const [h] of waiters)
|
|
6139
6184
|
this.pendingShards.delete(h);
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
// Anything over the cap waits for the next window rather than being
|
|
6143
|
-
// dropped or silently truncated.
|
|
6185
|
+
// Anything over the cap or the budget waits for the next window
|
|
6186
|
+
// rather than being dropped or silently truncated.
|
|
6144
6187
|
if (this.pendingShards.size > 0 && this.batchTimer === null) {
|
|
6145
6188
|
this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
|
|
6146
6189
|
}
|
|
6190
|
+
await this.sendShardBatch(waiters);
|
|
6191
|
+
}
|
|
6192
|
+
/** POST one batch and settle everyone waiting on it. Never throws. */
|
|
6193
|
+
async sendShardBatch(waiters) {
|
|
6194
|
+
const hashes = waiters.map(([h]) => h);
|
|
6147
6195
|
let shards;
|
|
6148
6196
|
try {
|
|
6149
6197
|
const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {
|
|
@@ -6151,6 +6199,21 @@ class ShardClient {
|
|
|
6151
6199
|
headers: { "Content-Type": "application/json" },
|
|
6152
6200
|
body: JSON.stringify({ hashes }),
|
|
6153
6201
|
});
|
|
6202
|
+
if (res.status === 413 && waiters.length > 1) {
|
|
6203
|
+
// Too large for the server after all: a size was not given,
|
|
6204
|
+
// or was wrong. Ask for each half on its own. Halving reaches
|
|
6205
|
+
// one hash per batch within log2(n) levels, and one hash the
|
|
6206
|
+
// server refuses is an error like any other, below.
|
|
6207
|
+
// Release the body first: under undici (the CLI, Node), an
|
|
6208
|
+
// unread body holds the connection until garbage collection.
|
|
6209
|
+
await res.body?.cancel().catch(() => { });
|
|
6210
|
+
const half = Math.ceil(waiters.length / 2);
|
|
6211
|
+
await Promise.all([
|
|
6212
|
+
this.sendShardBatch(waiters.slice(0, half)),
|
|
6213
|
+
this.sendShardBatch(waiters.slice(half)),
|
|
6214
|
+
]);
|
|
6215
|
+
return;
|
|
6216
|
+
}
|
|
6154
6217
|
if (res.status === 404 || res.status === 405) {
|
|
6155
6218
|
// No batch route on this deployment. Stop trying, and serve
|
|
6156
6219
|
// everyone individually — including the batch we just gathered.
|
|
@@ -6526,7 +6589,8 @@ class FileStorage {
|
|
|
6526
6589
|
const fetched = new Array(total).fill(null);
|
|
6527
6590
|
const fetchInto = async (indexes) => {
|
|
6528
6591
|
await this.runWithConcurrency(indexes.map((i) => async () => {
|
|
6529
|
-
|
|
6592
|
+
// The size keeps a batch of concurrent reads within what the server will send.
|
|
6593
|
+
fetched[i] = await this.shards.getShard(manifest.shardHashes[i], { size: manifest.shardSize });
|
|
6530
6594
|
}));
|
|
6531
6595
|
};
|
|
6532
6596
|
await fetchInto(Array.from({ length: dataCount }, (_, i) => i));
|
|
@@ -6675,7 +6739,7 @@ async function parseOrThrow(response, op) {
|
|
|
6675
6739
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
6676
6740
|
* `package.json` `version`.
|
|
6677
6741
|
*/
|
|
6678
|
-
const VERSION = "0.15.0-alpha.
|
|
6742
|
+
const VERSION = "0.15.0-alpha.3";
|
|
6679
6743
|
|
|
6680
6744
|
/**
|
|
6681
6745
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -8983,6 +9047,45 @@ class StorageCipher {
|
|
|
8983
9047
|
}
|
|
8984
9048
|
}
|
|
8985
9049
|
|
|
9050
|
+
/** The server's cap on `prefix`, in code points (accelerator `personalKv/list.ts`). */
|
|
9051
|
+
const MAX_SERVER_PREFIX_CHARS = 1024;
|
|
9052
|
+
/**
|
|
9053
|
+
* The server handed back a cursor it had already given, so following it would
|
|
9054
|
+
* page forever. A fault in the answer, not a failure to reach the server: the
|
|
9055
|
+
* offline layer rethrows it instead of falling back to its cache.
|
|
9056
|
+
*/
|
|
9057
|
+
class RepeatedCursorError extends Error {
|
|
9058
|
+
constructor() {
|
|
9059
|
+
super("personal-space list: the server repeated a cursor; stopping instead of looping");
|
|
9060
|
+
this.name = "RepeatedCursorError";
|
|
9061
|
+
}
|
|
9062
|
+
}
|
|
9063
|
+
/** Every key under `prefix` ("" = every key), in the server's order. */
|
|
9064
|
+
async function listPersonalKeys(http, path, prefix) {
|
|
9065
|
+
// A prefix over the server's cap would be refused with a 400. Every list
|
|
9066
|
+
// was whole before the key index, so ask for everything instead; the
|
|
9067
|
+
// caller filters. (`length` first: code points never outnumber units.)
|
|
9068
|
+
const sent = prefix.length > MAX_SERVER_PREFIX_CHARS && [...prefix].length > MAX_SERVER_PREFIX_CHARS ? "" : prefix;
|
|
9069
|
+
const keys = [];
|
|
9070
|
+
const seen = new Set();
|
|
9071
|
+
let cursor = null;
|
|
9072
|
+
for (;;) {
|
|
9073
|
+
const res = await http.post(path, cursor === null ? { prefix: sent } : { prefix: sent, cursor });
|
|
9074
|
+
keys.push(...(res?.keys ?? []));
|
|
9075
|
+
// Annotated: without it TypeScript infers `next` through `cursor` and
|
|
9076
|
+
// the request body above, a loop it reports as TS7022.
|
|
9077
|
+
const next = typeof res?.cursor === "string" && res.cursor !== "" ? res.cursor : null;
|
|
9078
|
+
if (next === null)
|
|
9079
|
+
return keys;
|
|
9080
|
+
// A server (or a cache in front of one) that hands back a cursor it
|
|
9081
|
+
// already gave would have this page forever.
|
|
9082
|
+
if (seen.has(next))
|
|
9083
|
+
throw new RepeatedCursorError();
|
|
9084
|
+
seen.add(next);
|
|
9085
|
+
cursor = next;
|
|
9086
|
+
}
|
|
9087
|
+
}
|
|
9088
|
+
|
|
8986
9089
|
/**
|
|
8987
9090
|
* `client.kv` — per-user key/value storage backed by the accelerator's personal
|
|
8988
9091
|
* space.
|
|
@@ -8990,6 +9093,7 @@ class StorageCipher {
|
|
|
8990
9093
|
* await client.kv.set('todos', id, { title, completed })
|
|
8991
9094
|
* const todo = await client.kv.get('todos', id)
|
|
8992
9095
|
* const ids = await client.kv.list('todos')
|
|
9096
|
+
* const keys = await client.kv.keys('todos/') // full keys, any prefix
|
|
8993
9097
|
* await client.kv.delete('todos', id)
|
|
8994
9098
|
* const off = client.kv.on('change', e => …) // realtime, cross-device
|
|
8995
9099
|
*
|
|
@@ -9092,25 +9196,45 @@ class KvNamespace {
|
|
|
9092
9196
|
return true; // optimistic
|
|
9093
9197
|
}
|
|
9094
9198
|
}
|
|
9095
|
-
/**
|
|
9199
|
+
/**
|
|
9200
|
+
* List the ids present in `collection`. The server filters by
|
|
9201
|
+
* `${collection}/` and pages; values are not fetched.
|
|
9202
|
+
*/
|
|
9096
9203
|
async list(collection) {
|
|
9097
9204
|
const prefix = `${collection}/`;
|
|
9205
|
+
return (await this.keys(prefix)).map((k) => k.slice(prefix.length));
|
|
9206
|
+
}
|
|
9207
|
+
/**
|
|
9208
|
+
* Every key in the signed-in user's personal space that starts with
|
|
9209
|
+
* `prefix` (all of them when it is ""), as full keys: `todos/t1`, not
|
|
9210
|
+
* `t1`. Values are not fetched. The personal space is shared by every
|
|
9211
|
+
* app the user signs into, so expect keys you did not write: the VFS's
|
|
9212
|
+
* `vfs/…`, version control's `vcs/…`, the SDK's own `chat-keys` and
|
|
9213
|
+
* `space-keys/…`.
|
|
9214
|
+
*
|
|
9215
|
+
* The local `startsWith` filter stays on purpose: a server older than
|
|
9216
|
+
* the key index ignores `prefix` and returns every key.
|
|
9217
|
+
*/
|
|
9218
|
+
async keys(prefix = "") {
|
|
9098
9219
|
const offline = this.deps.offline;
|
|
9099
|
-
const
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
return
|
|
9103
|
-
}
|
|
9220
|
+
const under = (keys) => [...new Set(keys.filter((k) => k.startsWith(prefix)))];
|
|
9221
|
+
const remote = () => listPersonalKeys(this.deps.http, this.spacePath("/list"), prefix);
|
|
9222
|
+
if (!offline?.enabled)
|
|
9223
|
+
return under(await remote());
|
|
9104
9224
|
try {
|
|
9105
|
-
const
|
|
9225
|
+
const fromServer = await remote();
|
|
9106
9226
|
// Union with cached live keys so un-synced offline creates still show.
|
|
9107
|
-
|
|
9108
|
-
return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
|
|
9227
|
+
return under([...fromServer, ...(await offline.liveKeys(this.commitment(), prefix))]);
|
|
9109
9228
|
}
|
|
9110
9229
|
catch (err) {
|
|
9111
|
-
|
|
9230
|
+
// A refusal, or a server that repeats its cursor, is an answer,
|
|
9231
|
+
// not an outage: falling back would hand back a partial list as
|
|
9232
|
+
// if it were whole. Only a failure to reach the server (even
|
|
9233
|
+
// mid-paging) falls back to the cache, as a failed list did
|
|
9234
|
+
// before paging.
|
|
9235
|
+
if (err instanceof HttpError || err instanceof RepeatedCursorError)
|
|
9112
9236
|
throw err;
|
|
9113
|
-
return
|
|
9237
|
+
return under(await offline.liveKeys(this.commitment(), prefix));
|
|
9114
9238
|
}
|
|
9115
9239
|
}
|
|
9116
9240
|
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
@@ -15082,11 +15206,20 @@ function nextTurn() {
|
|
|
15082
15206
|
* Every mutation is a read-modify-write of exactly ONE directory record, so a
|
|
15083
15207
|
* write cannot leave the tree half-updated: either the parent names the new
|
|
15084
15208
|
* child or it does not. Two tabs writing different directories never conflict;
|
|
15085
|
-
* two tabs writing the SAME directory
|
|
15086
|
-
*
|
|
15087
|
-
*
|
|
15088
|
-
*
|
|
15209
|
+
* two tabs (or devices) writing the SAME directory are last-writer-wins. A VFS
|
|
15210
|
+
* write sends no HLC and the personal space has no compare-and-swap: it applies
|
|
15211
|
+
* puts of one key in the order they arrive, so whichever arrives last is kept,
|
|
15212
|
+
* and the other tab's edit is lost. Orphaned records (a directory whose parent
|
|
15213
|
+
* entry lost the race) are unreachable rather than corrupting — they cost
|
|
15214
|
+
* storage until a future sweep, which is the trade this shape makes in
|
|
15089
15215
|
* exchange for single-record writes.
|
|
15216
|
+
*
|
|
15217
|
+
* Within ONE tab, writes of a record are queued and sent one at a time, in the
|
|
15218
|
+
* order they were made, and a read that has to fetch a record waits for its
|
|
15219
|
+
* write in flight (see `writing`). Every edit of a record is made to one
|
|
15220
|
+
* in-memory copy of it, and that copy is what is written (see `mutate`). All
|
|
15221
|
+
* of this is per namespace instance only; nothing orders writes across tabs
|
|
15222
|
+
* or devices - that would take a compare-and-swap at the server.
|
|
15090
15223
|
*/
|
|
15091
15224
|
/** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
|
|
15092
15225
|
function assertWritableDepth(path) {
|
|
@@ -15290,6 +15423,32 @@ function assertNotHidden(dir, name, path) {
|
|
|
15290
15423
|
throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
|
|
15291
15424
|
}
|
|
15292
15425
|
}
|
|
15426
|
+
/**
|
|
15427
|
+
* Is `now` still the entry an operation read at a name before it waited on
|
|
15428
|
+
* something? The same kind and id, and for a file the same version; a move
|
|
15429
|
+
* mark set or cleared in between does not count. Absent both times is the same.
|
|
15430
|
+
* Where a decision rests on the mark, `markedMeanwhile` checks that as well.
|
|
15431
|
+
*/
|
|
15432
|
+
function sameEntry(before, now) {
|
|
15433
|
+
if (!before || !now)
|
|
15434
|
+
return before === now;
|
|
15435
|
+
if (before.kind !== now.kind || before.id !== now.id)
|
|
15436
|
+
return false;
|
|
15437
|
+
return before.kind === "dir" || (now.kind === "file" && now.manifest.id === before.manifest.id);
|
|
15438
|
+
}
|
|
15439
|
+
/**
|
|
15440
|
+
* Has a move marked the entry at a name since an operation read it there
|
|
15441
|
+
* unmarked? Then a move of it is under way - see `DirEntry.moving` - and it is
|
|
15442
|
+
* about to be named in a second place, which a decision made from the unmarked
|
|
15443
|
+
* read did not allow for.
|
|
15444
|
+
*/
|
|
15445
|
+
function markedMeanwhile(before, now) {
|
|
15446
|
+
return now.moving === true && before.moving !== true;
|
|
15447
|
+
}
|
|
15448
|
+
/** Refuse a write because another write changed `path` while this one waited. See `VfsNamespace.mutate`. */
|
|
15449
|
+
function changedMeanwhile(path) {
|
|
15450
|
+
return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
|
|
15451
|
+
}
|
|
15293
15452
|
/** A JSON object, as opposed to null, an array or a scalar. */
|
|
15294
15453
|
function isObject$1(value) {
|
|
15295
15454
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -15456,8 +15615,25 @@ class VfsNamespace {
|
|
|
15456
15615
|
* The file tree re-lists constantly while typing, and every miss is a
|
|
15457
15616
|
* network round trip plus a decrypt. Invalidated precisely on write rather
|
|
15458
15617
|
* than by TTL — a stale directory listing shows files that are not there.
|
|
15618
|
+
*
|
|
15619
|
+
* What a cached read resolves to is also THE copy of that record: the one
|
|
15620
|
+
* every edit of it is made to, and the one written. See `mutate`.
|
|
15459
15621
|
*/
|
|
15460
15622
|
cache = new Map();
|
|
15623
|
+
/**
|
|
15624
|
+
* Bumped by `clearCache`, which is for a change of identity.
|
|
15625
|
+
*
|
|
15626
|
+
* A read or write begun before it can finish after it, and what it read
|
|
15627
|
+
* or wrote belongs to whoever was signed in before. So every record
|
|
15628
|
+
* carries the generation it was read under, and none goes into the cache,
|
|
15629
|
+
* or is edited as the cache's copy, under another.
|
|
15630
|
+
*/
|
|
15631
|
+
generation = 0;
|
|
15632
|
+
/**
|
|
15633
|
+
* The record each promise in `cache` resolved to, and each write in
|
|
15634
|
+
* `writing` is sending: what `editable` finds without waiting.
|
|
15635
|
+
*/
|
|
15636
|
+
copyOf = new WeakMap();
|
|
15461
15637
|
/**
|
|
15462
15638
|
* The write of each directory record still in flight, by id.
|
|
15463
15639
|
*
|
|
@@ -15467,7 +15643,9 @@ class VfsNamespace {
|
|
|
15467
15643
|
* that is often the one sent first. Then the earlier write, sealed before
|
|
15468
15644
|
* the later edit was made, undoes it. That is how a file deleted forever
|
|
15469
15645
|
* from the Trash, while the move that put it there was still being
|
|
15470
|
-
* written, came back. So each write waits for the one before it
|
|
15646
|
+
* written, came back. So each write waits for the one before it, and a
|
|
15647
|
+
* read that has to fetch the record waits for its write too: see
|
|
15648
|
+
* `openDirById`.
|
|
15471
15649
|
*/
|
|
15472
15650
|
writing = new Map();
|
|
15473
15651
|
historyLimit;
|
|
@@ -15526,6 +15704,9 @@ class VfsNamespace {
|
|
|
15526
15704
|
* identity's directories to another would be the worst possible bug here.
|
|
15527
15705
|
*/
|
|
15528
15706
|
clearCache() {
|
|
15707
|
+
// Whatever is still being read or written for the identity before
|
|
15708
|
+
// stays out of the cache from here on: see `generation`.
|
|
15709
|
+
this.generation++;
|
|
15529
15710
|
this.cache.clear();
|
|
15530
15711
|
this.rootKey = null;
|
|
15531
15712
|
this._cwd = "/"; // a different identity's tree has different directories
|
|
@@ -15831,21 +16012,26 @@ class VfsNamespace {
|
|
|
15831
16012
|
assertValidName(name);
|
|
15832
16013
|
await this.mkdir(dirname(target), { recursive: true });
|
|
15833
16014
|
const parent = await this.openDir(dirname(target));
|
|
15834
|
-
|
|
15835
|
-
if (prior?.kind === "dir")
|
|
16015
|
+
if (entryIn(parent.node, name)?.kind === "dir")
|
|
15836
16016
|
throw new VfsConflictError(`${target} is a directory`);
|
|
15837
16017
|
await this.deps.content.retain(manifest);
|
|
15838
|
-
|
|
15839
|
-
kind: "file",
|
|
15840
|
-
id: prior?.id ?? newId(),
|
|
15841
|
-
manifest,
|
|
15842
|
-
size,
|
|
15843
|
-
mtime: Date.now(),
|
|
15844
|
-
versions: asCount(prior?.versions),
|
|
15845
|
-
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
15846
|
-
...(prior?.moving === true ? { moving: true } : {}),
|
|
15847
|
-
};
|
|
16018
|
+
let entry;
|
|
15848
16019
|
await this.mutate(parent, (node) => {
|
|
16020
|
+
// Whatever is at the name now, after the wait above, is what
|
|
16021
|
+
// this replaces - and what it takes the id and history from.
|
|
16022
|
+
const prior = entryIn(node, name);
|
|
16023
|
+
if (prior?.kind === "dir")
|
|
16024
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
16025
|
+
entry = {
|
|
16026
|
+
kind: "file",
|
|
16027
|
+
id: prior?.id ?? newId(),
|
|
16028
|
+
manifest,
|
|
16029
|
+
size,
|
|
16030
|
+
mtime: Date.now(),
|
|
16031
|
+
versions: asCount(prior?.versions),
|
|
16032
|
+
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16033
|
+
...(prior?.moving === true ? { moving: true } : {}),
|
|
16034
|
+
};
|
|
15849
16035
|
node.entries[name] = entry;
|
|
15850
16036
|
});
|
|
15851
16037
|
return toStat(target, name, entry);
|
|
@@ -15882,7 +16068,7 @@ class VfsNamespace {
|
|
|
15882
16068
|
else {
|
|
15883
16069
|
if (!isLast && !opts.recursive)
|
|
15884
16070
|
throw new VfsNotFoundError(current);
|
|
15885
|
-
await this.createDir(current, name);
|
|
16071
|
+
await this.createDir(current, name, next, opts.recursive === true);
|
|
15886
16072
|
}
|
|
15887
16073
|
current = next;
|
|
15888
16074
|
}
|
|
@@ -15942,12 +16128,22 @@ class VfsNamespace {
|
|
|
15942
16128
|
let versions = asCount(prior?.versions);
|
|
15943
16129
|
if (prior)
|
|
15944
16130
|
versions = await this.pushHistory(parent, name, prior, target, manifest);
|
|
15945
|
-
|
|
15946
|
-
kind: "file", id, manifest, size, mtime: Date.now(), versions,
|
|
15947
|
-
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
15948
|
-
...(prior?.moving === true ? { moving: true } : {}),
|
|
15949
|
-
};
|
|
16131
|
+
let entry;
|
|
15950
16132
|
await this.mutate(parent, (node) => {
|
|
16133
|
+
// The version just pushed onto the history was `prior`. If
|
|
16134
|
+
// another write has replaced or removed it since, it is not the
|
|
16135
|
+
// version this one overwrites, and writing on regardless would
|
|
16136
|
+
// lose that write - from the file, and from its history.
|
|
16137
|
+
const now = entryIn(node, name);
|
|
16138
|
+
if (now?.kind === "dir")
|
|
16139
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
16140
|
+
if (!sameEntry(prior, now))
|
|
16141
|
+
throw changedMeanwhile(target);
|
|
16142
|
+
entry = {
|
|
16143
|
+
kind: "file", id, manifest, size, mtime: Date.now(), versions,
|
|
16144
|
+
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16145
|
+
...(now?.moving === true ? { moving: true } : {}),
|
|
16146
|
+
};
|
|
15951
16147
|
node.entries[name] = entry;
|
|
15952
16148
|
});
|
|
15953
16149
|
return toStat(target, name, entry);
|
|
@@ -15982,7 +16178,7 @@ class VfsNamespace {
|
|
|
15982
16178
|
const where = printable(target, 1024);
|
|
15983
16179
|
if (!opts.recursive)
|
|
15984
16180
|
throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
|
|
15985
|
-
await this.mutate(parent, () => void delete
|
|
16181
|
+
await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
|
|
15986
16182
|
this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
|
|
15987
16183
|
return;
|
|
15988
16184
|
}
|
|
@@ -16044,6 +16240,16 @@ class VfsNamespace {
|
|
|
16044
16240
|
// anything changes: a failure here leaves the tree as it was.
|
|
16045
16241
|
const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
|
|
16046
16242
|
await this.mutate(parent, (node) => {
|
|
16243
|
+
// What is let go of below is what `entry` held. Gone since, it
|
|
16244
|
+
// was let go of already; replaced, the name holds something this
|
|
16245
|
+
// delete has not read. Marked since, a move of it has begun, and
|
|
16246
|
+
// what it holds is about to be named somewhere `elsewhere` did
|
|
16247
|
+
// not see - if it was looked for at all.
|
|
16248
|
+
const now = entryIn(node, name);
|
|
16249
|
+
if (!now)
|
|
16250
|
+
throw new VfsNotFoundError(target);
|
|
16251
|
+
if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16252
|
+
throw changedMeanwhile(target);
|
|
16047
16253
|
delete node.entries[name];
|
|
16048
16254
|
});
|
|
16049
16255
|
if (elsewhere === null) {
|
|
@@ -16098,11 +16304,30 @@ class VfsNamespace {
|
|
|
16098
16304
|
// Each record is re-opened before it is written: creating the
|
|
16099
16305
|
// destination's parents above may have written to the source's.
|
|
16100
16306
|
const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
|
|
16307
|
+
// Both ends were looked at before the waits above, and another write
|
|
16308
|
+
// may have changed either since: replaced the entry being moved, or
|
|
16309
|
+
// put something at the destination. Moving on regardless would write
|
|
16310
|
+
// the old entry over the new one, or this one over the other. Or
|
|
16311
|
+
// marked the entry: another move of it has begun. Taking it as well
|
|
16312
|
+
// would name it in two places, and each move would unmark its own.
|
|
16313
|
+
const take = (node) => {
|
|
16314
|
+
const now = entryIn(node, srcName);
|
|
16315
|
+
if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16316
|
+
throw changedMeanwhile(src);
|
|
16317
|
+
return now;
|
|
16318
|
+
};
|
|
16319
|
+
const free = (node, record) => {
|
|
16320
|
+
if (entryIn(node, dstName))
|
|
16321
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
16322
|
+
assertNotHidden(record, dstName, dst);
|
|
16323
|
+
};
|
|
16101
16324
|
if (dstParent.id === srcParent.id) {
|
|
16102
16325
|
// One record, so one write: the move happens entirely or not at all.
|
|
16103
|
-
await edit(dirname(src), (node) => {
|
|
16326
|
+
await edit(dirname(src), (node, record) => {
|
|
16327
|
+
const moved = take(node);
|
|
16328
|
+
free(node, record);
|
|
16104
16329
|
delete node.entries[srcName];
|
|
16105
|
-
node.entries[dstName] = { ...
|
|
16330
|
+
node.entries[dstName] = { ...moved, mtime: Date.now() };
|
|
16106
16331
|
});
|
|
16107
16332
|
return;
|
|
16108
16333
|
}
|
|
@@ -16113,14 +16338,31 @@ class VfsNamespace {
|
|
|
16113
16338
|
// An entry already marked is half of an earlier interrupted move, and
|
|
16114
16339
|
// keeps its mark.
|
|
16115
16340
|
const marked = entry.moving === true;
|
|
16116
|
-
|
|
16117
|
-
|
|
16118
|
-
|
|
16341
|
+
let moved = entry;
|
|
16342
|
+
if (!marked) {
|
|
16343
|
+
await edit(dirname(src), (node) => {
|
|
16344
|
+
moved = take(node);
|
|
16345
|
+
node.entries[srcName] = { ...moved, moving: true };
|
|
16346
|
+
});
|
|
16347
|
+
}
|
|
16348
|
+
await edit(dirname(dst), (node, record) => {
|
|
16349
|
+
free(node, record);
|
|
16350
|
+
node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
|
|
16351
|
+
});
|
|
16119
16352
|
// A file's history is sealed with its directory's key, and from here
|
|
16120
16353
|
// on the directory that opens it is this one.
|
|
16121
16354
|
if (entry.kind === "file")
|
|
16122
16355
|
await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
|
|
16123
|
-
await edit(dirname(src), (node) =>
|
|
16356
|
+
await edit(dirname(src), (node) => {
|
|
16357
|
+
// Gone already leaves nothing to detach. Replaced, it is not this
|
|
16358
|
+
// entry to take away: the move stops here, in both places. Not
|
|
16359
|
+
// `take`: the entry is marked by now, by this move or the earlier
|
|
16360
|
+
// one it finishes.
|
|
16361
|
+
const now = entryIn(node, srcName);
|
|
16362
|
+
if (now && !sameEntry(entry, now))
|
|
16363
|
+
throw changedMeanwhile(src);
|
|
16364
|
+
delete node.entries[srcName];
|
|
16365
|
+
});
|
|
16124
16366
|
if (!marked) {
|
|
16125
16367
|
await edit(dirname(dst), (node) => {
|
|
16126
16368
|
const landed = entryIn(node, dstName);
|
|
@@ -16193,13 +16435,19 @@ class VfsNamespace {
|
|
|
16193
16435
|
// its first write, instead of after up to a whole budget's worth of
|
|
16194
16436
|
// records and references, all left unreachable. The records are
|
|
16195
16437
|
// cached by then, so the real pass costs writes, not fetches.
|
|
16438
|
+
const gen = this.generation;
|
|
16196
16439
|
const run = (write) => ({
|
|
16197
|
-
from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
|
|
16440
|
+
from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0, gen,
|
|
16198
16441
|
});
|
|
16199
16442
|
const depth = segments(dst).length;
|
|
16200
16443
|
await this.cloneEntry(found.entry, src, run(false), null, depth);
|
|
16201
16444
|
const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
|
|
16202
|
-
await this.mutate(dstParent, (node) => {
|
|
16445
|
+
await this.mutate(dstParent, (node, record) => {
|
|
16446
|
+
// Looked at before the copy was made, which takes as long as the
|
|
16447
|
+
// tree is big: another write may have taken the name since.
|
|
16448
|
+
if (entryIn(node, dstName))
|
|
16449
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
16450
|
+
assertNotHidden(record, dstName, dst);
|
|
16203
16451
|
node.entries[dstName] = copied;
|
|
16204
16452
|
});
|
|
16205
16453
|
return toStat(dst, dstName, copied);
|
|
@@ -16220,8 +16468,11 @@ class VfsNamespace {
|
|
|
16220
16468
|
* Delete metadata records no longer reachable from the root.
|
|
16221
16469
|
*
|
|
16222
16470
|
* These accumulate because every write touches exactly one record: if two
|
|
16223
|
-
* tabs edit the same directory,
|
|
16224
|
-
*
|
|
16471
|
+
* tabs or devices edit the same directory, the personal space keeps
|
|
16472
|
+
* whichever put arrives last (a VFS write sends no HLC, and the space
|
|
16473
|
+
* applies puts of one key in arrival order), which can leave a child whose
|
|
16474
|
+
* parent entry lost the race. The per-record write queue (`writing`) only
|
|
16475
|
+
* orders writes within one tab. Unreachable is harmless —
|
|
16225
16476
|
* it is invisible and undecryptable-by-nobody — but it costs storage, so
|
|
16226
16477
|
* this reclaims it.
|
|
16227
16478
|
*
|
|
@@ -16254,8 +16505,10 @@ class VfsNamespace {
|
|
|
16254
16505
|
}
|
|
16255
16506
|
// Only ever consider OUR records: the personal space is shared with
|
|
16256
16507
|
// chat keys, space keys and the legacy file mirror, and a sweep that
|
|
16257
|
-
// reached beyond its own namespace would be a data-loss bug.
|
|
16258
|
-
|
|
16508
|
+
// reached beyond its own namespace would be a data-loss bug. The store
|
|
16509
|
+
// is asked for `vfs/` only; the regex stays, because `vfs/space` is
|
|
16510
|
+
// under `vfs/` too and a store may ignore the prefix.
|
|
16511
|
+
const mine = (await this.deps.store.list("vfs/")).filter((k) => /^vfs\/[dh]\//.test(k));
|
|
16259
16512
|
const orphans = mine.filter((k) => !reachable.has(k));
|
|
16260
16513
|
// A root that read as EMPTY while orphans exist is the shape of a failed
|
|
16261
16514
|
// load, not of a fresh filesystem — deleting here would wipe a tree we
|
|
@@ -16295,16 +16548,21 @@ class VfsNamespace {
|
|
|
16295
16548
|
if (!version)
|
|
16296
16549
|
throw new VfsNotFoundError(`${target} version ${index}`);
|
|
16297
16550
|
const versions = await this.pushHistory(parent, name, entry, target, version.manifest);
|
|
16298
|
-
|
|
16299
|
-
kind: "file",
|
|
16300
|
-
id: entry.id,
|
|
16301
|
-
manifest: version.manifest,
|
|
16302
|
-
size: asCount(version.size),
|
|
16303
|
-
mtime: Date.now(),
|
|
16304
|
-
versions,
|
|
16305
|
-
...(entry.moving === true ? { moving: true } : {}),
|
|
16306
|
-
};
|
|
16551
|
+
let restored;
|
|
16307
16552
|
await this.mutate(parent, (node) => {
|
|
16553
|
+
// As for writeFile: the version pushed onto the history was `entry`.
|
|
16554
|
+
const now = entryIn(node, name);
|
|
16555
|
+
if (!sameEntry(entry, now))
|
|
16556
|
+
throw changedMeanwhile(target);
|
|
16557
|
+
restored = {
|
|
16558
|
+
kind: "file",
|
|
16559
|
+
id: entry.id,
|
|
16560
|
+
manifest: version.manifest,
|
|
16561
|
+
size: asCount(version.size),
|
|
16562
|
+
mtime: Date.now(),
|
|
16563
|
+
versions,
|
|
16564
|
+
...(now?.moving === true ? { moving: true } : {}),
|
|
16565
|
+
};
|
|
16308
16566
|
node.entries[name] = restored;
|
|
16309
16567
|
});
|
|
16310
16568
|
return toStat(target, name, restored);
|
|
@@ -16334,8 +16592,15 @@ class VfsNamespace {
|
|
|
16334
16592
|
const seed = this.deps.seed();
|
|
16335
16593
|
if (!seed)
|
|
16336
16594
|
throw new VfsLockedError();
|
|
16337
|
-
if (!this.rootKey)
|
|
16338
|
-
|
|
16595
|
+
if (!this.rootKey) {
|
|
16596
|
+
const gen = this.generation;
|
|
16597
|
+
const key = await deriveRootKey(seed);
|
|
16598
|
+
// Derived from the seed of whoever was signed in before a
|
|
16599
|
+
// clearCache: start again, with the seed of whoever is now.
|
|
16600
|
+
if (gen !== this.generation)
|
|
16601
|
+
return this.root();
|
|
16602
|
+
this.rootKey = key;
|
|
16603
|
+
}
|
|
16339
16604
|
return this.openDirById(ROOT_ID, this.rootKey, "/");
|
|
16340
16605
|
}
|
|
16341
16606
|
/** Open a directory by path, creating nothing. Throws if it is missing. */
|
|
@@ -16379,7 +16644,44 @@ class VfsNamespace {
|
|
|
16379
16644
|
// every concurrent reader to miss and fire its own request for the same
|
|
16380
16645
|
// record — which is how loading one project turned into thousands of
|
|
16381
16646
|
// identical `vfs/d/<id>` fetches, most of them in flight at once.
|
|
16382
|
-
const
|
|
16647
|
+
const gen = this.generation;
|
|
16648
|
+
// Reads `pending` only after its first `await`: before that, the const is not yet initialised.
|
|
16649
|
+
const load = async () => {
|
|
16650
|
+
// Not while a write of this record is still in flight: see
|
|
16651
|
+
// `writing`. Our own writes echo back over the personal-space
|
|
16652
|
+
// socket, and `watch` drops the record each one names, so a read
|
|
16653
|
+
// can miss with the write still on its way - and the store would
|
|
16654
|
+
// answer with the copy from BEFORE it. An edit made to that copy
|
|
16655
|
+
// is queued behind the write, and then stored over it. So wait
|
|
16656
|
+
// for the latest write queued, which waits on every one before it,
|
|
16657
|
+
// to land or fail - and then for any queued while this read
|
|
16658
|
+
// waited, since the one it waited for may have failed with that
|
|
16659
|
+
// one still on its way. A record with no write in flight is
|
|
16660
|
+
// fetched at once.
|
|
16661
|
+
let awaited;
|
|
16662
|
+
for (let inFlight = this.writing.get(id); inFlight && inFlight !== awaited; inFlight = this.writing.get(id)) {
|
|
16663
|
+
awaited = inFlight;
|
|
16664
|
+
await inFlight.catch(() => { });
|
|
16665
|
+
}
|
|
16666
|
+
// Not after a clearCache, though: the cache now is the next
|
|
16667
|
+
// identity's, and this read only answers whoever asked for it.
|
|
16668
|
+
if (awaited && gen === this.generation) {
|
|
16669
|
+
// A write that landed put the record it wrote back in the
|
|
16670
|
+
// cache, and the next edit of this directory is made to that.
|
|
16671
|
+
// Answer with it rather than fetch a second copy: two copies
|
|
16672
|
+
// of one record, each edited and written, undo each other's
|
|
16673
|
+
// edits. When the cache holds nothing else - the write failed,
|
|
16674
|
+
// or a change since has dropped the record - ask the store.
|
|
16675
|
+
const now = this.cache.get(id);
|
|
16676
|
+
if (now && now !== pending)
|
|
16677
|
+
return now;
|
|
16678
|
+
// Empty, when the write failed: it dropped what the cache held,
|
|
16679
|
+
// and after an echo that was this very read. Put it back before
|
|
16680
|
+
// asking the store, so a read that comes in meanwhile shares
|
|
16681
|
+
// this copy instead of fetching a second one.
|
|
16682
|
+
if (!now)
|
|
16683
|
+
this.cache.set(id, pending);
|
|
16684
|
+
}
|
|
16383
16685
|
const raw = await this.deps.store.get(dirKey(id));
|
|
16384
16686
|
// An absent record is an EMPTY directory, not an error: the root
|
|
16385
16687
|
// exists before anything has been written to it, and a directory is
|
|
@@ -16387,7 +16689,7 @@ class VfsNamespace {
|
|
|
16387
16689
|
// appearing.
|
|
16388
16690
|
if (raw === null || raw === undefined) {
|
|
16389
16691
|
const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
|
|
16390
|
-
return { id, key, path, node, hidden: Object.create(null) };
|
|
16692
|
+
return { id, key, path, node, hidden: Object.create(null), gen };
|
|
16391
16693
|
}
|
|
16392
16694
|
// Present but unopenable is NOT absent. `unseal` folds the two
|
|
16393
16695
|
// together, which suits a record that can simply be rebuilt; a
|
|
@@ -16404,14 +16706,18 @@ class VfsNamespace {
|
|
|
16404
16706
|
throw new VfsCorruptRecordError(id, path, err.message);
|
|
16405
16707
|
throw err;
|
|
16406
16708
|
}
|
|
16407
|
-
return this.readRecord(id, key, value, path);
|
|
16408
|
-
}
|
|
16709
|
+
return this.readRecord(id, key, value, gen, path);
|
|
16710
|
+
};
|
|
16711
|
+
const pending = load().catch((err) => {
|
|
16409
16712
|
// A failed read must not be remembered, or one blip poisons the
|
|
16410
16713
|
// directory for the life of the session.
|
|
16411
16714
|
if (this.cache.get(id) === pending)
|
|
16412
16715
|
this.cache.delete(id);
|
|
16413
16716
|
throw err;
|
|
16414
16717
|
});
|
|
16718
|
+
// Registered before anyone else can wait on it, so it is known by
|
|
16719
|
+
// the time any of them goes on to edit the record: see `editable`.
|
|
16720
|
+
pending.then((dir) => void this.copyOf.set(pending, dir), () => { });
|
|
16415
16721
|
this.cache.set(id, pending);
|
|
16416
16722
|
return pending;
|
|
16417
16723
|
}
|
|
@@ -16422,7 +16728,7 @@ class VfsNamespace {
|
|
|
16422
16728
|
* see VfsCorruptRecordError. An individual entry of the wrong shape is set
|
|
16423
16729
|
* aside in `hidden` instead: out of every read, but kept for the write.
|
|
16424
16730
|
*/
|
|
16425
|
-
readRecord(id, key, value, path) {
|
|
16731
|
+
readRecord(id, key, value, gen, path) {
|
|
16426
16732
|
const problem = recordProblem(value);
|
|
16427
16733
|
if (problem)
|
|
16428
16734
|
throw new VfsCorruptRecordError(id, path, problem);
|
|
@@ -16478,25 +16784,47 @@ class VfsNamespace {
|
|
|
16478
16784
|
this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
|
|
16479
16785
|
}
|
|
16480
16786
|
// A v1 record's unknown top-level fields survive a write too.
|
|
16481
|
-
return { id, key, path, node: { ...top, entries }, hidden };
|
|
16787
|
+
return { id, key, path, node: { ...top, entries }, hidden, gen };
|
|
16482
16788
|
}
|
|
16483
|
-
/**
|
|
16789
|
+
/**
|
|
16790
|
+
* Apply an edit to a directory record and persist it.
|
|
16791
|
+
*
|
|
16792
|
+
* `dir` says which record - its id, key and path - and nothing more. The
|
|
16793
|
+
* edit is made to THE copy of that record (see `editable`), which `dir`
|
|
16794
|
+
* may no longer be, and that copy is what is written. Editing whichever
|
|
16795
|
+
* copy the caller held let two copies of one record exist at once: an
|
|
16796
|
+
* overwrite held its directory across storing the old version's
|
|
16797
|
+
* history, a write of the directory failed meanwhile and dropped it from
|
|
16798
|
+
* the cache, and a save that then read a second copy and stored it,
|
|
16799
|
+
* successfully, was undone when the overwrite stored the first.
|
|
16800
|
+
*
|
|
16801
|
+
* So `edit` must take what it writes from the record it is given, not
|
|
16802
|
+
* from anything read off `dir` before an await - and re-check whatever it
|
|
16803
|
+
* relies on from then, throwing if another write changed it. Returning
|
|
16804
|
+
* false writes nothing after all.
|
|
16805
|
+
*/
|
|
16484
16806
|
async mutate(dir, edit) {
|
|
16485
|
-
|
|
16486
|
-
|
|
16807
|
+
// At once when the copy is to hand, which it almost always is, so
|
|
16808
|
+
// that what the caller checked just before is still true when the
|
|
16809
|
+
// edit is made.
|
|
16810
|
+
const target = this.editable(dir) ?? await this.reopen(dir);
|
|
16811
|
+
if (edit(target.node, target) === false)
|
|
16812
|
+
return;
|
|
16813
|
+
target.node.mtime = Date.now();
|
|
16487
16814
|
// A write to a hidden entry's exact name replaces it: that name now
|
|
16488
16815
|
// means what this version wrote. Forgetting the old one here is also
|
|
16489
16816
|
// what stops a later delete of the replacement from resurrecting it.
|
|
16490
|
-
for (const name of Object.keys(
|
|
16491
|
-
if (Object.hasOwn(
|
|
16492
|
-
delete
|
|
16817
|
+
for (const name of Object.keys(target.hidden)) {
|
|
16818
|
+
if (Object.hasOwn(target.node.entries, name))
|
|
16819
|
+
delete target.hidden[name];
|
|
16493
16820
|
}
|
|
16494
16821
|
// Not sent until any earlier write of this record has landed: see
|
|
16495
16822
|
// `writing`. Whether that one worked or not, this one still goes.
|
|
16496
|
-
const before = this.writing.get(
|
|
16497
|
-
const send = () => this.writeRecord(
|
|
16823
|
+
const before = this.writing.get(target.id);
|
|
16824
|
+
const send = () => this.writeRecord(target);
|
|
16498
16825
|
const write = before ? before.then(send, send) : send();
|
|
16499
|
-
this.
|
|
16826
|
+
this.copyOf.set(write, target);
|
|
16827
|
+
this.writing.set(target.id, write);
|
|
16500
16828
|
try {
|
|
16501
16829
|
await write;
|
|
16502
16830
|
}
|
|
@@ -16506,14 +16834,68 @@ class VfsNamespace {
|
|
|
16506
16834
|
// change the store never took - a deleted file gone from every
|
|
16507
16835
|
// listing while it is still stored, and a retry told it does not
|
|
16508
16836
|
// exist. Drop it, so the next read asks the store.
|
|
16509
|
-
|
|
16837
|
+
//
|
|
16838
|
+
// Unless a later write of this record is already queued. It
|
|
16839
|
+
// seals the record as it stands when it is sent, this edit
|
|
16840
|
+
// included, so the cache shows what is about to be stored, and
|
|
16841
|
+
// dropping it would only send readers to the store for an older
|
|
16842
|
+
// copy. If that write fails too, it drops the cache itself.
|
|
16843
|
+
//
|
|
16844
|
+
// Nor from a cache cleared since: that is the next identity's.
|
|
16845
|
+
if (this.writing.get(target.id) === write && target.gen === this.generation)
|
|
16846
|
+
this.cache.delete(target.id);
|
|
16510
16847
|
throw err;
|
|
16511
16848
|
}
|
|
16512
16849
|
finally {
|
|
16513
|
-
if (this.writing.get(
|
|
16514
|
-
this.writing.delete(
|
|
16850
|
+
if (this.writing.get(target.id) === write)
|
|
16851
|
+
this.writing.delete(target.id);
|
|
16515
16852
|
}
|
|
16516
|
-
this.
|
|
16853
|
+
this.adopt(target);
|
|
16854
|
+
}
|
|
16855
|
+
/**
|
|
16856
|
+
* The copy of `dir`'s record that edits are made to, if there is one
|
|
16857
|
+
* without waiting: the one the cache holds, once read.
|
|
16858
|
+
*
|
|
16859
|
+
* Failing that, the one a write in flight is sending. The record was
|
|
16860
|
+
* dropped from the cache - a change was reported, our own write's echo
|
|
16861
|
+
* among them - and a read that came in since is waiting for that write,
|
|
16862
|
+
* to answer with this very copy if it lands (see `openDirById`); if it
|
|
16863
|
+
* fails, the write an edit now queues behind it carries the copy instead.
|
|
16864
|
+
*
|
|
16865
|
+
* Undefined when the record is not cached and nothing is writing it, or
|
|
16866
|
+
* it is still being read: `reopen` waits for that. A record read before
|
|
16867
|
+
* `clearCache` is edited as it is, as it always was. It is not the copy of
|
|
16868
|
+
* anything the cache holds now, and must not be handed the next identity's.
|
|
16869
|
+
*/
|
|
16870
|
+
editable(dir) {
|
|
16871
|
+
if (dir.gen !== this.generation)
|
|
16872
|
+
return dir;
|
|
16873
|
+
const cached = this.cache.get(dir.id);
|
|
16874
|
+
const write = this.writing.get(dir.id);
|
|
16875
|
+
const copy = (cached && this.copyOf.get(cached)) ?? (write && this.copyOf.get(write));
|
|
16876
|
+
return copy?.gen === this.generation ? copy : undefined;
|
|
16877
|
+
}
|
|
16878
|
+
/**
|
|
16879
|
+
* Read `dir`'s record - the cached read, or a fetch made once any write of
|
|
16880
|
+
* it in flight has settled - and then find the copy edits are made to.
|
|
16881
|
+
*/
|
|
16882
|
+
async reopen(dir) {
|
|
16883
|
+
for (;;) {
|
|
16884
|
+
await this.openDirById(dir.id, dir.key, dir.path);
|
|
16885
|
+
// What that read answered with, unless a change has dropped it
|
|
16886
|
+
// from the cache again since: then read it again.
|
|
16887
|
+
const copy = this.editable(dir);
|
|
16888
|
+
if (copy)
|
|
16889
|
+
return copy;
|
|
16890
|
+
}
|
|
16891
|
+
}
|
|
16892
|
+
/** Make `dir` the cached copy of its record - unless the cache has been cleared since it was read. */
|
|
16893
|
+
adopt(dir) {
|
|
16894
|
+
if (dir.gen !== this.generation)
|
|
16895
|
+
return;
|
|
16896
|
+
const cached = Promise.resolve(dir);
|
|
16897
|
+
this.copyOf.set(cached, dir);
|
|
16898
|
+
this.cache.set(dir.id, cached);
|
|
16517
16899
|
}
|
|
16518
16900
|
/**
|
|
16519
16901
|
* Seal a directory record as it stands NOW and put it in the store.
|
|
@@ -16541,12 +16923,24 @@ class VfsNamespace {
|
|
|
16541
16923
|
}
|
|
16542
16924
|
await this.deps.store.put(dirKey(dir.id), sealed);
|
|
16543
16925
|
}
|
|
16544
|
-
|
|
16926
|
+
/** For `mkdir`: name a new, empty directory `path` in the one at `parentPath`. */
|
|
16927
|
+
async createDir(parentPath, name, path, recursive) {
|
|
16545
16928
|
assertValidName(name);
|
|
16546
16929
|
const parent = await this.openDir(parentPath);
|
|
16547
16930
|
const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
16548
|
-
await this.mutate(parent, (node) => {
|
|
16931
|
+
await this.mutate(parent, (node, record) => {
|
|
16932
|
+
// mkdir looked before the wait above. A directory made there
|
|
16933
|
+
// since, by another `mkdir -p` of the same path - files saved
|
|
16934
|
+
// at once into a new folder - is kept, not replaced: whatever
|
|
16935
|
+
// was saved into it would go with it.
|
|
16936
|
+
const now = entryIn(node, name);
|
|
16937
|
+
if (now?.kind === "dir" && recursive)
|
|
16938
|
+
return false;
|
|
16939
|
+
if (now)
|
|
16940
|
+
throw new VfsConflictError(`${path} ${now.kind === "dir" ? "already exists" : "is a file"}`);
|
|
16941
|
+
assertNotHidden(record, name, path);
|
|
16549
16942
|
node.entries[name] = entry;
|
|
16943
|
+
return true;
|
|
16550
16944
|
});
|
|
16551
16945
|
}
|
|
16552
16946
|
/**
|
|
@@ -16656,8 +17050,15 @@ class VfsNamespace {
|
|
|
16656
17050
|
// Opened again: this delete may have written to it since the tree was read.
|
|
16657
17051
|
const dir = await this.openDirById(holding[0].id, holding[0].key);
|
|
16658
17052
|
const name = Object.keys(dir.node.entries).find((n) => dir.node.entries[n].id === id && dir.node.entries[n].moving === true);
|
|
16659
|
-
if (name
|
|
16660
|
-
|
|
17053
|
+
if (name === undefined)
|
|
17054
|
+
return;
|
|
17055
|
+
await this.mutate(dir, (node) => {
|
|
17056
|
+
const now = entryIn(node, name);
|
|
17057
|
+
if (now?.id !== id || now.moving !== true)
|
|
17058
|
+
return false;
|
|
17059
|
+
node.entries[name] = unmarked(now);
|
|
17060
|
+
return true;
|
|
17061
|
+
});
|
|
16661
17062
|
}
|
|
16662
17063
|
catch {
|
|
16663
17064
|
// It stays marked.
|
|
@@ -16854,9 +17255,9 @@ class VfsNamespace {
|
|
|
16854
17255
|
}
|
|
16855
17256
|
if (!run.write)
|
|
16856
17257
|
return clone;
|
|
16857
|
-
const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null) };
|
|
17258
|
+
const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen };
|
|
16858
17259
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
16859
|
-
this.
|
|
17260
|
+
this.adopt(open);
|
|
16860
17261
|
return clone;
|
|
16861
17262
|
}
|
|
16862
17263
|
/**
|
|
@@ -17853,8 +18254,11 @@ class Repo {
|
|
|
17853
18254
|
async expand(prefix) {
|
|
17854
18255
|
if (!/^[0-9a-f]{4,}$/.test(prefix))
|
|
17855
18256
|
return null;
|
|
17856
|
-
const keys = await this.deps.store.list();
|
|
17857
18257
|
const wanted = objectKey(this.slug, prefix);
|
|
18258
|
+
// Only the keys under the abbreviation (usually none or one), not the
|
|
18259
|
+
// whole personal space. The filter below stays, for a store that
|
|
18260
|
+
// ignores the prefix.
|
|
18261
|
+
const keys = await this.deps.store.list(wanted);
|
|
17858
18262
|
// Only keys that could be objects: a stray key under the prefix would
|
|
17859
18263
|
// otherwise make every abbreviation of it "ambiguous".
|
|
17860
18264
|
const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
|
|
@@ -19206,11 +19610,15 @@ class KvCache {
|
|
|
19206
19610
|
const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
|
|
19207
19611
|
await this.store.put("kv-cache", k, mergeRegister(local, incoming));
|
|
19208
19612
|
}
|
|
19209
|
-
/**
|
|
19210
|
-
|
|
19211
|
-
|
|
19212
|
-
|
|
19213
|
-
|
|
19613
|
+
/**
|
|
19614
|
+
* Non-tombstoned full keys for a commitment that start with `prefix` (all
|
|
19615
|
+
* of them by default), for offline `list`. The store scans the key range
|
|
19616
|
+
* `${commitment}|${prefix}`, so other collections are never read.
|
|
19617
|
+
*/
|
|
19618
|
+
async liveKeys(commitment, prefix = "") {
|
|
19619
|
+
const scope = `${commitment}|`;
|
|
19620
|
+
const rows = await this.store.prefix("kv-cache", scope + prefix);
|
|
19621
|
+
return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(scope.length));
|
|
19214
19622
|
}
|
|
19215
19623
|
/** Queue a kv mutation for replay on reconnect. */
|
|
19216
19624
|
async enqueue(method, args, hlc, clientId) {
|
|
@@ -19366,13 +19774,17 @@ class Client {
|
|
|
19366
19774
|
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
19367
19775
|
recoverInFlight = null;
|
|
19368
19776
|
constructor(options = {}) {
|
|
19369
|
-
// Build stamp —
|
|
19370
|
-
//
|
|
19371
|
-
//
|
|
19372
|
-
|
|
19373
|
-
|
|
19777
|
+
// Build stamp — logged (independent of logLevel) so the running build
|
|
19778
|
+
// is identifiable in the console. Catches stale cached bundles during
|
|
19779
|
+
// development; bump `VERSION` (and package.json) each build.
|
|
19780
|
+
// Browser console only: in Node it lands on stdout, in front of a
|
|
19781
|
+
// CLI's own output (`muhkoo kv ls --json | jq`).
|
|
19782
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
19783
|
+
try {
|
|
19784
|
+
console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
|
|
19785
|
+
}
|
|
19786
|
+
catch { /* console may be unavailable in some runtimes */ }
|
|
19374
19787
|
}
|
|
19375
|
-
catch { /* console may be unavailable in some runtimes */ }
|
|
19376
19788
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
19377
19789
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
19378
19790
|
}
|
|
@@ -19474,12 +19886,13 @@ class Client {
|
|
|
19474
19886
|
throw new Error("client.vfs: no session — sign in first.");
|
|
19475
19887
|
await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
|
|
19476
19888
|
},
|
|
19477
|
-
list: async () => {
|
|
19889
|
+
list: async (prefix) => {
|
|
19478
19890
|
const c = this.session.commitment;
|
|
19479
19891
|
if (!c)
|
|
19480
19892
|
throw new Error("client.vfs: no session — sign in first.");
|
|
19481
|
-
|
|
19482
|
-
|
|
19893
|
+
// The server filters by prefix and pages. An older server ignores
|
|
19894
|
+
// the prefix and returns every key, which is why callers filter.
|
|
19895
|
+
return listPersonalKeys(this.http, `/api/personal/${encodeURIComponent(c)}/list`, prefix ?? "");
|
|
19483
19896
|
},
|
|
19484
19897
|
};
|
|
19485
19898
|
let contentSpace = null;
|