@muhkoo/connect 0.15.0-alpha.2 → 0.15.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/index.js +389 -95
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +94 -10
- package/dist/server/index.js +389 -95
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/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.3";
|
|
6743
6743
|
|
|
6744
6744
|
/**
|
|
6745
6745
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -9047,6 +9047,45 @@ class StorageCipher {
|
|
|
9047
9047
|
}
|
|
9048
9048
|
}
|
|
9049
9049
|
|
|
9050
|
+
/** The server's cap on `prefix`, in code points (accelerator `personalKv/list.ts`). */
|
|
9051
|
+
const MAX_SERVER_PREFIX_CHARS = 1024;
|
|
9052
|
+
/**
|
|
9053
|
+
* The server handed back a cursor it had already given, so following it would
|
|
9054
|
+
* page forever. A fault in the answer, not a failure to reach the server: the
|
|
9055
|
+
* offline layer rethrows it instead of falling back to its cache.
|
|
9056
|
+
*/
|
|
9057
|
+
class RepeatedCursorError extends Error {
|
|
9058
|
+
constructor() {
|
|
9059
|
+
super("personal-space list: the server repeated a cursor; stopping instead of looping");
|
|
9060
|
+
this.name = "RepeatedCursorError";
|
|
9061
|
+
}
|
|
9062
|
+
}
|
|
9063
|
+
/** Every key under `prefix` ("" = every key), in the server's order. */
|
|
9064
|
+
async function listPersonalKeys(http, path, prefix) {
|
|
9065
|
+
// A prefix over the server's cap would be refused with a 400. Every list
|
|
9066
|
+
// was whole before the key index, so ask for everything instead; the
|
|
9067
|
+
// caller filters. (`length` first: code points never outnumber units.)
|
|
9068
|
+
const sent = prefix.length > MAX_SERVER_PREFIX_CHARS && [...prefix].length > MAX_SERVER_PREFIX_CHARS ? "" : prefix;
|
|
9069
|
+
const keys = [];
|
|
9070
|
+
const seen = new Set();
|
|
9071
|
+
let cursor = null;
|
|
9072
|
+
for (;;) {
|
|
9073
|
+
const res = await http.post(path, cursor === null ? { prefix: sent } : { prefix: sent, cursor });
|
|
9074
|
+
keys.push(...(res?.keys ?? []));
|
|
9075
|
+
// Annotated: without it TypeScript infers `next` through `cursor` and
|
|
9076
|
+
// the request body above, a loop it reports as TS7022.
|
|
9077
|
+
const next = typeof res?.cursor === "string" && res.cursor !== "" ? res.cursor : null;
|
|
9078
|
+
if (next === null)
|
|
9079
|
+
return keys;
|
|
9080
|
+
// A server (or a cache in front of one) that hands back a cursor it
|
|
9081
|
+
// already gave would have this page forever.
|
|
9082
|
+
if (seen.has(next))
|
|
9083
|
+
throw new RepeatedCursorError();
|
|
9084
|
+
seen.add(next);
|
|
9085
|
+
cursor = next;
|
|
9086
|
+
}
|
|
9087
|
+
}
|
|
9088
|
+
|
|
9050
9089
|
/**
|
|
9051
9090
|
* `client.kv` — per-user key/value storage backed by the accelerator's personal
|
|
9052
9091
|
* space.
|
|
@@ -9054,6 +9093,7 @@ class StorageCipher {
|
|
|
9054
9093
|
* await client.kv.set('todos', id, { title, completed })
|
|
9055
9094
|
* const todo = await client.kv.get('todos', id)
|
|
9056
9095
|
* const ids = await client.kv.list('todos')
|
|
9096
|
+
* const keys = await client.kv.keys('todos/') // full keys, any prefix
|
|
9057
9097
|
* await client.kv.delete('todos', id)
|
|
9058
9098
|
* const off = client.kv.on('change', e => …) // realtime, cross-device
|
|
9059
9099
|
*
|
|
@@ -9156,25 +9196,45 @@ class KvNamespace {
|
|
|
9156
9196
|
return true; // optimistic
|
|
9157
9197
|
}
|
|
9158
9198
|
}
|
|
9159
|
-
/**
|
|
9199
|
+
/**
|
|
9200
|
+
* List the ids present in `collection`. The server filters by
|
|
9201
|
+
* `${collection}/` and pages; values are not fetched.
|
|
9202
|
+
*/
|
|
9160
9203
|
async list(collection) {
|
|
9161
9204
|
const prefix = `${collection}/`;
|
|
9205
|
+
return (await this.keys(prefix)).map((k) => k.slice(prefix.length));
|
|
9206
|
+
}
|
|
9207
|
+
/**
|
|
9208
|
+
* Every key in the signed-in user's personal space that starts with
|
|
9209
|
+
* `prefix` (all of them when it is ""), as full keys: `todos/t1`, not
|
|
9210
|
+
* `t1`. Values are not fetched. The personal space is shared by every
|
|
9211
|
+
* app the user signs into, so expect keys you did not write: the VFS's
|
|
9212
|
+
* `vfs/…`, version control's `vcs/…`, the SDK's own `chat-keys` and
|
|
9213
|
+
* `space-keys/…`.
|
|
9214
|
+
*
|
|
9215
|
+
* The local `startsWith` filter stays on purpose: a server older than
|
|
9216
|
+
* the key index ignores `prefix` and returns every key.
|
|
9217
|
+
*/
|
|
9218
|
+
async keys(prefix = "") {
|
|
9162
9219
|
const offline = this.deps.offline;
|
|
9163
|
-
const
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
return
|
|
9167
|
-
}
|
|
9220
|
+
const under = (keys) => [...new Set(keys.filter((k) => k.startsWith(prefix)))];
|
|
9221
|
+
const remote = () => listPersonalKeys(this.deps.http, this.spacePath("/list"), prefix);
|
|
9222
|
+
if (!offline?.enabled)
|
|
9223
|
+
return under(await remote());
|
|
9168
9224
|
try {
|
|
9169
|
-
const
|
|
9225
|
+
const fromServer = await remote();
|
|
9170
9226
|
// Union with cached live keys so un-synced offline creates still show.
|
|
9171
|
-
|
|
9172
|
-
return [...new Set([...(res.keys ?? []), ...cached].filter((k) => k.startsWith(prefix)))].map((k) => k.slice(prefix.length));
|
|
9227
|
+
return under([...fromServer, ...(await offline.liveKeys(this.commitment(), prefix))]);
|
|
9173
9228
|
}
|
|
9174
9229
|
catch (err) {
|
|
9175
|
-
|
|
9230
|
+
// A refusal, or a server that repeats its cursor, is an answer,
|
|
9231
|
+
// not an outage: falling back would hand back a partial list as
|
|
9232
|
+
// if it were whole. Only a failure to reach the server (even
|
|
9233
|
+
// mid-paging) falls back to the cache, as a failed list did
|
|
9234
|
+
// before paging.
|
|
9235
|
+
if (err instanceof HttpError || err instanceof RepeatedCursorError)
|
|
9176
9236
|
throw err;
|
|
9177
|
-
return
|
|
9237
|
+
return under(await offline.liveKeys(this.commitment(), prefix));
|
|
9178
9238
|
}
|
|
9179
9239
|
}
|
|
9180
9240
|
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
@@ -15156,9 +15216,10 @@ function nextTurn() {
|
|
|
15156
15216
|
*
|
|
15157
15217
|
* Within ONE tab, writes of a record are queued and sent one at a time, in the
|
|
15158
15218
|
* order they were made, and a read that has to fetch a record waits for its
|
|
15159
|
-
* write in flight (see `writing`).
|
|
15160
|
-
*
|
|
15161
|
-
*
|
|
15219
|
+
* write in flight (see `writing`). Every edit of a record is made to one
|
|
15220
|
+
* in-memory copy of it, and that copy is what is written (see `mutate`). All
|
|
15221
|
+
* of this is per namespace instance only; nothing orders writes across tabs
|
|
15222
|
+
* or devices - that would take a compare-and-swap at the server.
|
|
15162
15223
|
*/
|
|
15163
15224
|
/** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
|
|
15164
15225
|
function assertWritableDepth(path) {
|
|
@@ -15362,6 +15423,32 @@ function assertNotHidden(dir, name, path) {
|
|
|
15362
15423
|
throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
|
|
15363
15424
|
}
|
|
15364
15425
|
}
|
|
15426
|
+
/**
|
|
15427
|
+
* Is `now` still the entry an operation read at a name before it waited on
|
|
15428
|
+
* something? The same kind and id, and for a file the same version; a move
|
|
15429
|
+
* mark set or cleared in between does not count. Absent both times is the same.
|
|
15430
|
+
* Where a decision rests on the mark, `markedMeanwhile` checks that as well.
|
|
15431
|
+
*/
|
|
15432
|
+
function sameEntry(before, now) {
|
|
15433
|
+
if (!before || !now)
|
|
15434
|
+
return before === now;
|
|
15435
|
+
if (before.kind !== now.kind || before.id !== now.id)
|
|
15436
|
+
return false;
|
|
15437
|
+
return before.kind === "dir" || (now.kind === "file" && now.manifest.id === before.manifest.id);
|
|
15438
|
+
}
|
|
15439
|
+
/**
|
|
15440
|
+
* Has a move marked the entry at a name since an operation read it there
|
|
15441
|
+
* unmarked? Then a move of it is under way - see `DirEntry.moving` - and it is
|
|
15442
|
+
* about to be named in a second place, which a decision made from the unmarked
|
|
15443
|
+
* read did not allow for.
|
|
15444
|
+
*/
|
|
15445
|
+
function markedMeanwhile(before, now) {
|
|
15446
|
+
return now.moving === true && before.moving !== true;
|
|
15447
|
+
}
|
|
15448
|
+
/** Refuse a write because another write changed `path` while this one waited. See `VfsNamespace.mutate`. */
|
|
15449
|
+
function changedMeanwhile(path) {
|
|
15450
|
+
return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
|
|
15451
|
+
}
|
|
15365
15452
|
/** A JSON object, as opposed to null, an array or a scalar. */
|
|
15366
15453
|
function isObject$1(value) {
|
|
15367
15454
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -15528,8 +15615,25 @@ class VfsNamespace {
|
|
|
15528
15615
|
* The file tree re-lists constantly while typing, and every miss is a
|
|
15529
15616
|
* network round trip plus a decrypt. Invalidated precisely on write rather
|
|
15530
15617
|
* than by TTL — a stale directory listing shows files that are not there.
|
|
15618
|
+
*
|
|
15619
|
+
* What a cached read resolves to is also THE copy of that record: the one
|
|
15620
|
+
* every edit of it is made to, and the one written. See `mutate`.
|
|
15531
15621
|
*/
|
|
15532
15622
|
cache = new Map();
|
|
15623
|
+
/**
|
|
15624
|
+
* Bumped by `clearCache`, which is for a change of identity.
|
|
15625
|
+
*
|
|
15626
|
+
* A read or write begun before it can finish after it, and what it read
|
|
15627
|
+
* or wrote belongs to whoever was signed in before. So every record
|
|
15628
|
+
* carries the generation it was read under, and none goes into the cache,
|
|
15629
|
+
* or is edited as the cache's copy, under another.
|
|
15630
|
+
*/
|
|
15631
|
+
generation = 0;
|
|
15632
|
+
/**
|
|
15633
|
+
* The record each promise in `cache` resolved to, and each write in
|
|
15634
|
+
* `writing` is sending: what `editable` finds without waiting.
|
|
15635
|
+
*/
|
|
15636
|
+
copyOf = new WeakMap();
|
|
15533
15637
|
/**
|
|
15534
15638
|
* The write of each directory record still in flight, by id.
|
|
15535
15639
|
*
|
|
@@ -15600,6 +15704,9 @@ class VfsNamespace {
|
|
|
15600
15704
|
* identity's directories to another would be the worst possible bug here.
|
|
15601
15705
|
*/
|
|
15602
15706
|
clearCache() {
|
|
15707
|
+
// Whatever is still being read or written for the identity before
|
|
15708
|
+
// stays out of the cache from here on: see `generation`.
|
|
15709
|
+
this.generation++;
|
|
15603
15710
|
this.cache.clear();
|
|
15604
15711
|
this.rootKey = null;
|
|
15605
15712
|
this._cwd = "/"; // a different identity's tree has different directories
|
|
@@ -15905,21 +16012,26 @@ class VfsNamespace {
|
|
|
15905
16012
|
assertValidName(name);
|
|
15906
16013
|
await this.mkdir(dirname(target), { recursive: true });
|
|
15907
16014
|
const parent = await this.openDir(dirname(target));
|
|
15908
|
-
|
|
15909
|
-
if (prior?.kind === "dir")
|
|
16015
|
+
if (entryIn(parent.node, name)?.kind === "dir")
|
|
15910
16016
|
throw new VfsConflictError(`${target} is a directory`);
|
|
15911
16017
|
await this.deps.content.retain(manifest);
|
|
15912
|
-
|
|
15913
|
-
kind: "file",
|
|
15914
|
-
id: prior?.id ?? newId(),
|
|
15915
|
-
manifest,
|
|
15916
|
-
size,
|
|
15917
|
-
mtime: Date.now(),
|
|
15918
|
-
versions: asCount(prior?.versions),
|
|
15919
|
-
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
15920
|
-
...(prior?.moving === true ? { moving: true } : {}),
|
|
15921
|
-
};
|
|
16018
|
+
let entry;
|
|
15922
16019
|
await this.mutate(parent, (node) => {
|
|
16020
|
+
// Whatever is at the name now, after the wait above, is what
|
|
16021
|
+
// this replaces - and what it takes the id and history from.
|
|
16022
|
+
const prior = entryIn(node, name);
|
|
16023
|
+
if (prior?.kind === "dir")
|
|
16024
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
16025
|
+
entry = {
|
|
16026
|
+
kind: "file",
|
|
16027
|
+
id: prior?.id ?? newId(),
|
|
16028
|
+
manifest,
|
|
16029
|
+
size,
|
|
16030
|
+
mtime: Date.now(),
|
|
16031
|
+
versions: asCount(prior?.versions),
|
|
16032
|
+
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16033
|
+
...(prior?.moving === true ? { moving: true } : {}),
|
|
16034
|
+
};
|
|
15923
16035
|
node.entries[name] = entry;
|
|
15924
16036
|
});
|
|
15925
16037
|
return toStat(target, name, entry);
|
|
@@ -15956,7 +16068,7 @@ class VfsNamespace {
|
|
|
15956
16068
|
else {
|
|
15957
16069
|
if (!isLast && !opts.recursive)
|
|
15958
16070
|
throw new VfsNotFoundError(current);
|
|
15959
|
-
await this.createDir(current, name);
|
|
16071
|
+
await this.createDir(current, name, next, opts.recursive === true);
|
|
15960
16072
|
}
|
|
15961
16073
|
current = next;
|
|
15962
16074
|
}
|
|
@@ -16016,12 +16128,22 @@ class VfsNamespace {
|
|
|
16016
16128
|
let versions = asCount(prior?.versions);
|
|
16017
16129
|
if (prior)
|
|
16018
16130
|
versions = await this.pushHistory(parent, name, prior, target, manifest);
|
|
16019
|
-
|
|
16020
|
-
kind: "file", id, manifest, size, mtime: Date.now(), versions,
|
|
16021
|
-
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16022
|
-
...(prior?.moving === true ? { moving: true } : {}),
|
|
16023
|
-
};
|
|
16131
|
+
let entry;
|
|
16024
16132
|
await this.mutate(parent, (node) => {
|
|
16133
|
+
// The version just pushed onto the history was `prior`. If
|
|
16134
|
+
// another write has replaced or removed it since, it is not the
|
|
16135
|
+
// version this one overwrites, and writing on regardless would
|
|
16136
|
+
// lose that write - from the file, and from its history.
|
|
16137
|
+
const now = entryIn(node, name);
|
|
16138
|
+
if (now?.kind === "dir")
|
|
16139
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
16140
|
+
if (!sameEntry(prior, now))
|
|
16141
|
+
throw changedMeanwhile(target);
|
|
16142
|
+
entry = {
|
|
16143
|
+
kind: "file", id, manifest, size, mtime: Date.now(), versions,
|
|
16144
|
+
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16145
|
+
...(now?.moving === true ? { moving: true } : {}),
|
|
16146
|
+
};
|
|
16025
16147
|
node.entries[name] = entry;
|
|
16026
16148
|
});
|
|
16027
16149
|
return toStat(target, name, entry);
|
|
@@ -16056,7 +16178,7 @@ class VfsNamespace {
|
|
|
16056
16178
|
const where = printable(target, 1024);
|
|
16057
16179
|
if (!opts.recursive)
|
|
16058
16180
|
throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
|
|
16059
|
-
await this.mutate(parent, () => void delete
|
|
16181
|
+
await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
|
|
16060
16182
|
this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
|
|
16061
16183
|
return;
|
|
16062
16184
|
}
|
|
@@ -16118,6 +16240,16 @@ class VfsNamespace {
|
|
|
16118
16240
|
// anything changes: a failure here leaves the tree as it was.
|
|
16119
16241
|
const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
|
|
16120
16242
|
await this.mutate(parent, (node) => {
|
|
16243
|
+
// What is let go of below is what `entry` held. Gone since, it
|
|
16244
|
+
// was let go of already; replaced, the name holds something this
|
|
16245
|
+
// delete has not read. Marked since, a move of it has begun, and
|
|
16246
|
+
// what it holds is about to be named somewhere `elsewhere` did
|
|
16247
|
+
// not see - if it was looked for at all.
|
|
16248
|
+
const now = entryIn(node, name);
|
|
16249
|
+
if (!now)
|
|
16250
|
+
throw new VfsNotFoundError(target);
|
|
16251
|
+
if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16252
|
+
throw changedMeanwhile(target);
|
|
16121
16253
|
delete node.entries[name];
|
|
16122
16254
|
});
|
|
16123
16255
|
if (elsewhere === null) {
|
|
@@ -16172,11 +16304,30 @@ class VfsNamespace {
|
|
|
16172
16304
|
// Each record is re-opened before it is written: creating the
|
|
16173
16305
|
// destination's parents above may have written to the source's.
|
|
16174
16306
|
const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
|
|
16307
|
+
// Both ends were looked at before the waits above, and another write
|
|
16308
|
+
// may have changed either since: replaced the entry being moved, or
|
|
16309
|
+
// put something at the destination. Moving on regardless would write
|
|
16310
|
+
// the old entry over the new one, or this one over the other. Or
|
|
16311
|
+
// marked the entry: another move of it has begun. Taking it as well
|
|
16312
|
+
// would name it in two places, and each move would unmark its own.
|
|
16313
|
+
const take = (node) => {
|
|
16314
|
+
const now = entryIn(node, srcName);
|
|
16315
|
+
if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16316
|
+
throw changedMeanwhile(src);
|
|
16317
|
+
return now;
|
|
16318
|
+
};
|
|
16319
|
+
const free = (node, record) => {
|
|
16320
|
+
if (entryIn(node, dstName))
|
|
16321
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
16322
|
+
assertNotHidden(record, dstName, dst);
|
|
16323
|
+
};
|
|
16175
16324
|
if (dstParent.id === srcParent.id) {
|
|
16176
16325
|
// One record, so one write: the move happens entirely or not at all.
|
|
16177
|
-
await edit(dirname(src), (node) => {
|
|
16326
|
+
await edit(dirname(src), (node, record) => {
|
|
16327
|
+
const moved = take(node);
|
|
16328
|
+
free(node, record);
|
|
16178
16329
|
delete node.entries[srcName];
|
|
16179
|
-
node.entries[dstName] = { ...
|
|
16330
|
+
node.entries[dstName] = { ...moved, mtime: Date.now() };
|
|
16180
16331
|
});
|
|
16181
16332
|
return;
|
|
16182
16333
|
}
|
|
@@ -16187,14 +16338,31 @@ class VfsNamespace {
|
|
|
16187
16338
|
// An entry already marked is half of an earlier interrupted move, and
|
|
16188
16339
|
// keeps its mark.
|
|
16189
16340
|
const marked = entry.moving === true;
|
|
16190
|
-
|
|
16191
|
-
|
|
16192
|
-
|
|
16341
|
+
let moved = entry;
|
|
16342
|
+
if (!marked) {
|
|
16343
|
+
await edit(dirname(src), (node) => {
|
|
16344
|
+
moved = take(node);
|
|
16345
|
+
node.entries[srcName] = { ...moved, moving: true };
|
|
16346
|
+
});
|
|
16347
|
+
}
|
|
16348
|
+
await edit(dirname(dst), (node, record) => {
|
|
16349
|
+
free(node, record);
|
|
16350
|
+
node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
|
|
16351
|
+
});
|
|
16193
16352
|
// A file's history is sealed with its directory's key, and from here
|
|
16194
16353
|
// on the directory that opens it is this one.
|
|
16195
16354
|
if (entry.kind === "file")
|
|
16196
16355
|
await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
|
|
16197
|
-
await edit(dirname(src), (node) =>
|
|
16356
|
+
await edit(dirname(src), (node) => {
|
|
16357
|
+
// Gone already leaves nothing to detach. Replaced, it is not this
|
|
16358
|
+
// entry to take away: the move stops here, in both places. Not
|
|
16359
|
+
// `take`: the entry is marked by now, by this move or the earlier
|
|
16360
|
+
// one it finishes.
|
|
16361
|
+
const now = entryIn(node, srcName);
|
|
16362
|
+
if (now && !sameEntry(entry, now))
|
|
16363
|
+
throw changedMeanwhile(src);
|
|
16364
|
+
delete node.entries[srcName];
|
|
16365
|
+
});
|
|
16198
16366
|
if (!marked) {
|
|
16199
16367
|
await edit(dirname(dst), (node) => {
|
|
16200
16368
|
const landed = entryIn(node, dstName);
|
|
@@ -16267,13 +16435,19 @@ class VfsNamespace {
|
|
|
16267
16435
|
// its first write, instead of after up to a whole budget's worth of
|
|
16268
16436
|
// records and references, all left unreachable. The records are
|
|
16269
16437
|
// cached by then, so the real pass costs writes, not fetches.
|
|
16438
|
+
const gen = this.generation;
|
|
16270
16439
|
const run = (write) => ({
|
|
16271
|
-
from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
|
|
16440
|
+
from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0, gen,
|
|
16272
16441
|
});
|
|
16273
16442
|
const depth = segments(dst).length;
|
|
16274
16443
|
await this.cloneEntry(found.entry, src, run(false), null, depth);
|
|
16275
16444
|
const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
|
|
16276
|
-
await this.mutate(dstParent, (node) => {
|
|
16445
|
+
await this.mutate(dstParent, (node, record) => {
|
|
16446
|
+
// Looked at before the copy was made, which takes as long as the
|
|
16447
|
+
// tree is big: another write may have taken the name since.
|
|
16448
|
+
if (entryIn(node, dstName))
|
|
16449
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
16450
|
+
assertNotHidden(record, dstName, dst);
|
|
16277
16451
|
node.entries[dstName] = copied;
|
|
16278
16452
|
});
|
|
16279
16453
|
return toStat(dst, dstName, copied);
|
|
@@ -16331,8 +16505,10 @@ class VfsNamespace {
|
|
|
16331
16505
|
}
|
|
16332
16506
|
// Only ever consider OUR records: the personal space is shared with
|
|
16333
16507
|
// chat keys, space keys and the legacy file mirror, and a sweep that
|
|
16334
|
-
// reached beyond its own namespace would be a data-loss bug.
|
|
16335
|
-
|
|
16508
|
+
// reached beyond its own namespace would be a data-loss bug. The store
|
|
16509
|
+
// is asked for `vfs/` only; the regex stays, because `vfs/space` is
|
|
16510
|
+
// under `vfs/` too and a store may ignore the prefix.
|
|
16511
|
+
const mine = (await this.deps.store.list("vfs/")).filter((k) => /^vfs\/[dh]\//.test(k));
|
|
16336
16512
|
const orphans = mine.filter((k) => !reachable.has(k));
|
|
16337
16513
|
// A root that read as EMPTY while orphans exist is the shape of a failed
|
|
16338
16514
|
// load, not of a fresh filesystem — deleting here would wipe a tree we
|
|
@@ -16372,16 +16548,21 @@ class VfsNamespace {
|
|
|
16372
16548
|
if (!version)
|
|
16373
16549
|
throw new VfsNotFoundError(`${target} version ${index}`);
|
|
16374
16550
|
const versions = await this.pushHistory(parent, name, entry, target, version.manifest);
|
|
16375
|
-
|
|
16376
|
-
kind: "file",
|
|
16377
|
-
id: entry.id,
|
|
16378
|
-
manifest: version.manifest,
|
|
16379
|
-
size: asCount(version.size),
|
|
16380
|
-
mtime: Date.now(),
|
|
16381
|
-
versions,
|
|
16382
|
-
...(entry.moving === true ? { moving: true } : {}),
|
|
16383
|
-
};
|
|
16551
|
+
let restored;
|
|
16384
16552
|
await this.mutate(parent, (node) => {
|
|
16553
|
+
// As for writeFile: the version pushed onto the history was `entry`.
|
|
16554
|
+
const now = entryIn(node, name);
|
|
16555
|
+
if (!sameEntry(entry, now))
|
|
16556
|
+
throw changedMeanwhile(target);
|
|
16557
|
+
restored = {
|
|
16558
|
+
kind: "file",
|
|
16559
|
+
id: entry.id,
|
|
16560
|
+
manifest: version.manifest,
|
|
16561
|
+
size: asCount(version.size),
|
|
16562
|
+
mtime: Date.now(),
|
|
16563
|
+
versions,
|
|
16564
|
+
...(now?.moving === true ? { moving: true } : {}),
|
|
16565
|
+
};
|
|
16385
16566
|
node.entries[name] = restored;
|
|
16386
16567
|
});
|
|
16387
16568
|
return toStat(target, name, restored);
|
|
@@ -16411,8 +16592,15 @@ class VfsNamespace {
|
|
|
16411
16592
|
const seed = this.deps.seed();
|
|
16412
16593
|
if (!seed)
|
|
16413
16594
|
throw new VfsLockedError();
|
|
16414
|
-
if (!this.rootKey)
|
|
16415
|
-
|
|
16595
|
+
if (!this.rootKey) {
|
|
16596
|
+
const gen = this.generation;
|
|
16597
|
+
const key = await deriveRootKey(seed);
|
|
16598
|
+
// Derived from the seed of whoever was signed in before a
|
|
16599
|
+
// clearCache: start again, with the seed of whoever is now.
|
|
16600
|
+
if (gen !== this.generation)
|
|
16601
|
+
return this.root();
|
|
16602
|
+
this.rootKey = key;
|
|
16603
|
+
}
|
|
16416
16604
|
return this.openDirById(ROOT_ID, this.rootKey, "/");
|
|
16417
16605
|
}
|
|
16418
16606
|
/** Open a directory by path, creating nothing. Throws if it is missing. */
|
|
@@ -16456,6 +16644,7 @@ class VfsNamespace {
|
|
|
16456
16644
|
// every concurrent reader to miss and fire its own request for the same
|
|
16457
16645
|
// record — which is how loading one project turned into thousands of
|
|
16458
16646
|
// identical `vfs/d/<id>` fetches, most of them in flight at once.
|
|
16647
|
+
const gen = this.generation;
|
|
16459
16648
|
// Reads `pending` only after its first `await`: before that, the const is not yet initialised.
|
|
16460
16649
|
const load = async () => {
|
|
16461
16650
|
// Not while a write of this record is still in flight: see
|
|
@@ -16474,7 +16663,9 @@ class VfsNamespace {
|
|
|
16474
16663
|
awaited = inFlight;
|
|
16475
16664
|
await inFlight.catch(() => { });
|
|
16476
16665
|
}
|
|
16477
|
-
|
|
16666
|
+
// Not after a clearCache, though: the cache now is the next
|
|
16667
|
+
// identity's, and this read only answers whoever asked for it.
|
|
16668
|
+
if (awaited && gen === this.generation) {
|
|
16478
16669
|
// A write that landed put the record it wrote back in the
|
|
16479
16670
|
// cache, and the next edit of this directory is made to that.
|
|
16480
16671
|
// Answer with it rather than fetch a second copy: two copies
|
|
@@ -16498,7 +16689,7 @@ class VfsNamespace {
|
|
|
16498
16689
|
// appearing.
|
|
16499
16690
|
if (raw === null || raw === undefined) {
|
|
16500
16691
|
const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
|
|
16501
|
-
return { id, key, path, node, hidden: Object.create(null) };
|
|
16692
|
+
return { id, key, path, node, hidden: Object.create(null), gen };
|
|
16502
16693
|
}
|
|
16503
16694
|
// Present but unopenable is NOT absent. `unseal` folds the two
|
|
16504
16695
|
// together, which suits a record that can simply be rebuilt; a
|
|
@@ -16515,7 +16706,7 @@ class VfsNamespace {
|
|
|
16515
16706
|
throw new VfsCorruptRecordError(id, path, err.message);
|
|
16516
16707
|
throw err;
|
|
16517
16708
|
}
|
|
16518
|
-
return this.readRecord(id, key, value, path);
|
|
16709
|
+
return this.readRecord(id, key, value, gen, path);
|
|
16519
16710
|
};
|
|
16520
16711
|
const pending = load().catch((err) => {
|
|
16521
16712
|
// A failed read must not be remembered, or one blip poisons the
|
|
@@ -16524,6 +16715,9 @@ class VfsNamespace {
|
|
|
16524
16715
|
this.cache.delete(id);
|
|
16525
16716
|
throw err;
|
|
16526
16717
|
});
|
|
16718
|
+
// Registered before anyone else can wait on it, so it is known by
|
|
16719
|
+
// the time any of them goes on to edit the record: see `editable`.
|
|
16720
|
+
pending.then((dir) => void this.copyOf.set(pending, dir), () => { });
|
|
16527
16721
|
this.cache.set(id, pending);
|
|
16528
16722
|
return pending;
|
|
16529
16723
|
}
|
|
@@ -16534,7 +16728,7 @@ class VfsNamespace {
|
|
|
16534
16728
|
* see VfsCorruptRecordError. An individual entry of the wrong shape is set
|
|
16535
16729
|
* aside in `hidden` instead: out of every read, but kept for the write.
|
|
16536
16730
|
*/
|
|
16537
|
-
readRecord(id, key, value, path) {
|
|
16731
|
+
readRecord(id, key, value, gen, path) {
|
|
16538
16732
|
const problem = recordProblem(value);
|
|
16539
16733
|
if (problem)
|
|
16540
16734
|
throw new VfsCorruptRecordError(id, path, problem);
|
|
@@ -16590,25 +16784,47 @@ class VfsNamespace {
|
|
|
16590
16784
|
this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
|
|
16591
16785
|
}
|
|
16592
16786
|
// A v1 record's unknown top-level fields survive a write too.
|
|
16593
|
-
return { id, key, path, node: { ...top, entries }, hidden };
|
|
16787
|
+
return { id, key, path, node: { ...top, entries }, hidden, gen };
|
|
16594
16788
|
}
|
|
16595
|
-
/**
|
|
16789
|
+
/**
|
|
16790
|
+
* Apply an edit to a directory record and persist it.
|
|
16791
|
+
*
|
|
16792
|
+
* `dir` says which record - its id, key and path - and nothing more. The
|
|
16793
|
+
* edit is made to THE copy of that record (see `editable`), which `dir`
|
|
16794
|
+
* may no longer be, and that copy is what is written. Editing whichever
|
|
16795
|
+
* copy the caller held let two copies of one record exist at once: an
|
|
16796
|
+
* overwrite held its directory across storing the old version's
|
|
16797
|
+
* history, a write of the directory failed meanwhile and dropped it from
|
|
16798
|
+
* the cache, and a save that then read a second copy and stored it,
|
|
16799
|
+
* successfully, was undone when the overwrite stored the first.
|
|
16800
|
+
*
|
|
16801
|
+
* So `edit` must take what it writes from the record it is given, not
|
|
16802
|
+
* from anything read off `dir` before an await - and re-check whatever it
|
|
16803
|
+
* relies on from then, throwing if another write changed it. Returning
|
|
16804
|
+
* false writes nothing after all.
|
|
16805
|
+
*/
|
|
16596
16806
|
async mutate(dir, edit) {
|
|
16597
|
-
|
|
16598
|
-
|
|
16807
|
+
// At once when the copy is to hand, which it almost always is, so
|
|
16808
|
+
// that what the caller checked just before is still true when the
|
|
16809
|
+
// edit is made.
|
|
16810
|
+
const target = this.editable(dir) ?? await this.reopen(dir);
|
|
16811
|
+
if (edit(target.node, target) === false)
|
|
16812
|
+
return;
|
|
16813
|
+
target.node.mtime = Date.now();
|
|
16599
16814
|
// A write to a hidden entry's exact name replaces it: that name now
|
|
16600
16815
|
// means what this version wrote. Forgetting the old one here is also
|
|
16601
16816
|
// what stops a later delete of the replacement from resurrecting it.
|
|
16602
|
-
for (const name of Object.keys(
|
|
16603
|
-
if (Object.hasOwn(
|
|
16604
|
-
delete
|
|
16817
|
+
for (const name of Object.keys(target.hidden)) {
|
|
16818
|
+
if (Object.hasOwn(target.node.entries, name))
|
|
16819
|
+
delete target.hidden[name];
|
|
16605
16820
|
}
|
|
16606
16821
|
// Not sent until any earlier write of this record has landed: see
|
|
16607
16822
|
// `writing`. Whether that one worked or not, this one still goes.
|
|
16608
|
-
const before = this.writing.get(
|
|
16609
|
-
const send = () => this.writeRecord(
|
|
16823
|
+
const before = this.writing.get(target.id);
|
|
16824
|
+
const send = () => this.writeRecord(target);
|
|
16610
16825
|
const write = before ? before.then(send, send) : send();
|
|
16611
|
-
this.
|
|
16826
|
+
this.copyOf.set(write, target);
|
|
16827
|
+
this.writing.set(target.id, write);
|
|
16612
16828
|
try {
|
|
16613
16829
|
await write;
|
|
16614
16830
|
}
|
|
@@ -16624,15 +16840,62 @@ class VfsNamespace {
|
|
|
16624
16840
|
// included, so the cache shows what is about to be stored, and
|
|
16625
16841
|
// dropping it would only send readers to the store for an older
|
|
16626
16842
|
// copy. If that write fails too, it drops the cache itself.
|
|
16627
|
-
|
|
16628
|
-
|
|
16843
|
+
//
|
|
16844
|
+
// Nor from a cache cleared since: that is the next identity's.
|
|
16845
|
+
if (this.writing.get(target.id) === write && target.gen === this.generation)
|
|
16846
|
+
this.cache.delete(target.id);
|
|
16629
16847
|
throw err;
|
|
16630
16848
|
}
|
|
16631
16849
|
finally {
|
|
16632
|
-
if (this.writing.get(
|
|
16633
|
-
this.writing.delete(
|
|
16850
|
+
if (this.writing.get(target.id) === write)
|
|
16851
|
+
this.writing.delete(target.id);
|
|
16634
16852
|
}
|
|
16635
|
-
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);
|
|
16636
16899
|
}
|
|
16637
16900
|
/**
|
|
16638
16901
|
* Seal a directory record as it stands NOW and put it in the store.
|
|
@@ -16660,12 +16923,24 @@ class VfsNamespace {
|
|
|
16660
16923
|
}
|
|
16661
16924
|
await this.deps.store.put(dirKey(dir.id), sealed);
|
|
16662
16925
|
}
|
|
16663
|
-
|
|
16926
|
+
/** For `mkdir`: name a new, empty directory `path` in the one at `parentPath`. */
|
|
16927
|
+
async createDir(parentPath, name, path, recursive) {
|
|
16664
16928
|
assertValidName(name);
|
|
16665
16929
|
const parent = await this.openDir(parentPath);
|
|
16666
16930
|
const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
16667
|
-
await this.mutate(parent, (node) => {
|
|
16931
|
+
await this.mutate(parent, (node, record) => {
|
|
16932
|
+
// mkdir looked before the wait above. A directory made there
|
|
16933
|
+
// since, by another `mkdir -p` of the same path - files saved
|
|
16934
|
+
// at once into a new folder - is kept, not replaced: whatever
|
|
16935
|
+
// was saved into it would go with it.
|
|
16936
|
+
const now = entryIn(node, name);
|
|
16937
|
+
if (now?.kind === "dir" && recursive)
|
|
16938
|
+
return false;
|
|
16939
|
+
if (now)
|
|
16940
|
+
throw new VfsConflictError(`${path} ${now.kind === "dir" ? "already exists" : "is a file"}`);
|
|
16941
|
+
assertNotHidden(record, name, path);
|
|
16668
16942
|
node.entries[name] = entry;
|
|
16943
|
+
return true;
|
|
16669
16944
|
});
|
|
16670
16945
|
}
|
|
16671
16946
|
/**
|
|
@@ -16775,8 +17050,15 @@ class VfsNamespace {
|
|
|
16775
17050
|
// Opened again: this delete may have written to it since the tree was read.
|
|
16776
17051
|
const dir = await this.openDirById(holding[0].id, holding[0].key);
|
|
16777
17052
|
const name = Object.keys(dir.node.entries).find((n) => dir.node.entries[n].id === id && dir.node.entries[n].moving === true);
|
|
16778
|
-
if (name
|
|
16779
|
-
|
|
17053
|
+
if (name === undefined)
|
|
17054
|
+
return;
|
|
17055
|
+
await this.mutate(dir, (node) => {
|
|
17056
|
+
const now = entryIn(node, name);
|
|
17057
|
+
if (now?.id !== id || now.moving !== true)
|
|
17058
|
+
return false;
|
|
17059
|
+
node.entries[name] = unmarked(now);
|
|
17060
|
+
return true;
|
|
17061
|
+
});
|
|
16780
17062
|
}
|
|
16781
17063
|
catch {
|
|
16782
17064
|
// It stays marked.
|
|
@@ -16973,9 +17255,9 @@ class VfsNamespace {
|
|
|
16973
17255
|
}
|
|
16974
17256
|
if (!run.write)
|
|
16975
17257
|
return clone;
|
|
16976
|
-
const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null) };
|
|
17258
|
+
const open = { id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen };
|
|
16977
17259
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
16978
|
-
this.
|
|
17260
|
+
this.adopt(open);
|
|
16979
17261
|
return clone;
|
|
16980
17262
|
}
|
|
16981
17263
|
/**
|
|
@@ -17972,8 +18254,11 @@ class Repo {
|
|
|
17972
18254
|
async expand(prefix) {
|
|
17973
18255
|
if (!/^[0-9a-f]{4,}$/.test(prefix))
|
|
17974
18256
|
return null;
|
|
17975
|
-
const keys = await this.deps.store.list();
|
|
17976
18257
|
const wanted = objectKey(this.slug, prefix);
|
|
18258
|
+
// Only the keys under the abbreviation (usually none or one), not the
|
|
18259
|
+
// whole personal space. The filter below stays, for a store that
|
|
18260
|
+
// ignores the prefix.
|
|
18261
|
+
const keys = await this.deps.store.list(wanted);
|
|
17977
18262
|
// Only keys that could be objects: a stray key under the prefix would
|
|
17978
18263
|
// otherwise make every abbreviation of it "ambiguous".
|
|
17979
18264
|
const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
|
|
@@ -19325,11 +19610,15 @@ class KvCache {
|
|
|
19325
19610
|
const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
|
|
19326
19611
|
await this.store.put("kv-cache", k, mergeRegister(local, incoming));
|
|
19327
19612
|
}
|
|
19328
|
-
/**
|
|
19329
|
-
|
|
19330
|
-
|
|
19331
|
-
|
|
19332
|
-
|
|
19613
|
+
/**
|
|
19614
|
+
* Non-tombstoned full keys for a commitment that start with `prefix` (all
|
|
19615
|
+
* of them by default), for offline `list`. The store scans the key range
|
|
19616
|
+
* `${commitment}|${prefix}`, so other collections are never read.
|
|
19617
|
+
*/
|
|
19618
|
+
async liveKeys(commitment, prefix = "") {
|
|
19619
|
+
const scope = `${commitment}|`;
|
|
19620
|
+
const rows = await this.store.prefix("kv-cache", scope + prefix);
|
|
19621
|
+
return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(scope.length));
|
|
19333
19622
|
}
|
|
19334
19623
|
/** Queue a kv mutation for replay on reconnect. */
|
|
19335
19624
|
async enqueue(method, args, hlc, clientId) {
|
|
@@ -19485,13 +19774,17 @@ class Client {
|
|
|
19485
19774
|
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
19486
19775
|
recoverInFlight = null;
|
|
19487
19776
|
constructor(options = {}) {
|
|
19488
|
-
// Build stamp —
|
|
19489
|
-
//
|
|
19490
|
-
//
|
|
19491
|
-
|
|
19492
|
-
|
|
19777
|
+
// Build stamp — logged (independent of logLevel) so the running build
|
|
19778
|
+
// is identifiable in the console. Catches stale cached bundles during
|
|
19779
|
+
// development; bump `VERSION` (and package.json) each build.
|
|
19780
|
+
// Browser console only: in Node it lands on stdout, in front of a
|
|
19781
|
+
// CLI's own output (`muhkoo kv ls --json | jq`).
|
|
19782
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
19783
|
+
try {
|
|
19784
|
+
console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
|
|
19785
|
+
}
|
|
19786
|
+
catch { /* console may be unavailable in some runtimes */ }
|
|
19493
19787
|
}
|
|
19494
|
-
catch { /* console may be unavailable in some runtimes */ }
|
|
19495
19788
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
19496
19789
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
19497
19790
|
}
|
|
@@ -19593,12 +19886,13 @@ class Client {
|
|
|
19593
19886
|
throw new Error("client.vfs: no session — sign in first.");
|
|
19594
19887
|
await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
|
|
19595
19888
|
},
|
|
19596
|
-
list: async () => {
|
|
19889
|
+
list: async (prefix) => {
|
|
19597
19890
|
const c = this.session.commitment;
|
|
19598
19891
|
if (!c)
|
|
19599
19892
|
throw new Error("client.vfs: no session — sign in first.");
|
|
19600
|
-
|
|
19601
|
-
|
|
19893
|
+
// The server filters by prefix and pages. An older server ignores
|
|
19894
|
+
// the prefix and returns every key, which is why callers filter.
|
|
19895
|
+
return listPersonalKeys(this.http, `/api/personal/${encodeURIComponent(c)}/list`, prefix ?? "");
|
|
19602
19896
|
},
|
|
19603
19897
|
};
|
|
19604
19898
|
let contentSpace = null;
|