@muhkoo/connect 0.15.0-alpha.1 → 0.15.0-alpha.10
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 +2410 -329
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +720 -31
- package/dist/connect.workers.d.ts +55 -2
- package/dist/server/index.js +2410 -329
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +62 -1
- package/dist/workers/index.js.map +1 -1
- package/package.json +17 -7
package/dist/browser/index.js
CHANGED
|
@@ -2742,6 +2742,106 @@ class BroadcastChannel {
|
|
|
2742
2742
|
}
|
|
2743
2743
|
}
|
|
2744
2744
|
|
|
2745
|
+
/**
|
|
2746
|
+
* `HttpError` — thrown for any non-2xx accelerator response.
|
|
2747
|
+
*
|
|
2748
|
+
* In a module of its own so that `PaymentRequiredError` can extend it while
|
|
2749
|
+
* `HttpClient`, which throws both, imports `PaymentRequiredError`: with the
|
|
2750
|
+
* class in `HttpClient.ts`, the two modules would import each other, and
|
|
2751
|
+
* whichever loaded second would extend a class not yet defined. `HttpClient`
|
|
2752
|
+
* re-exports it, so `import { HttpError } from "./HttpClient"` still works.
|
|
2753
|
+
*/
|
|
2754
|
+
/** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
|
|
2755
|
+
class HttpError extends Error {
|
|
2756
|
+
status;
|
|
2757
|
+
body;
|
|
2758
|
+
constructor(message, status, body) {
|
|
2759
|
+
super(message);
|
|
2760
|
+
this.status = status;
|
|
2761
|
+
this.body = body;
|
|
2762
|
+
this.name = "HttpError";
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/**
|
|
2767
|
+
* `PaymentRequiredError` — a write the accelerator refused for payment
|
|
2768
|
+
* (accelerator spec 2026-10-06-paid-accounts-and-past-due, "Interfaces and
|
|
2769
|
+
* contracts").
|
|
2770
|
+
*
|
|
2771
|
+
* The accelerator answers such a write with a 402 whose body carries one of
|
|
2772
|
+
* three codes:
|
|
2773
|
+
* - `user_plan_required`: the app takes paid Muhkoo plans only, and the
|
|
2774
|
+
* signed-in user is on Free;
|
|
2775
|
+
* - `user_payment_past_due`: the user's own Muhkoo payment is past due. Only
|
|
2776
|
+
* first-party apps are told this; every other app is told
|
|
2777
|
+
* `user_plan_required` instead;
|
|
2778
|
+
* - `owner_payment_past_due`: the app owner's payment is past due, so the
|
|
2779
|
+
* app stores nothing new for anyone.
|
|
2780
|
+
* Reads, sign-in and deletes still work in all three cases.
|
|
2781
|
+
*
|
|
2782
|
+
* Every other 402 — the Free-tier refusals that carry `quotaExceeded`,
|
|
2783
|
+
* `requiresPaidTier` or `freeTierFull` — has no `code`, and stays a plain
|
|
2784
|
+
* `HttpError`, as before.
|
|
2785
|
+
*/
|
|
2786
|
+
const CODES = new Set([
|
|
2787
|
+
"user_plan_required",
|
|
2788
|
+
"user_payment_past_due",
|
|
2789
|
+
"owner_payment_past_due",
|
|
2790
|
+
]);
|
|
2791
|
+
/** True for one of the three {@link PaymentRequiredCode}s. */
|
|
2792
|
+
function isPaymentRequiredCode(value) {
|
|
2793
|
+
return typeof value === "string" && CODES.has(value);
|
|
2794
|
+
}
|
|
2795
|
+
/** `url` when it is an absolute http(s) URL, else undefined: an app may put it straight into a link. */
|
|
2796
|
+
function absoluteHttpUrl(url) {
|
|
2797
|
+
return typeof url === "string" && /^https?:\/\/[^/]/i.test(url) ? url : undefined;
|
|
2798
|
+
}
|
|
2799
|
+
/** Thrown for a 402 whose body carries one of the three {@link PaymentRequiredCode}s. */
|
|
2800
|
+
class PaymentRequiredError extends HttpError {
|
|
2801
|
+
/** Why the write was refused. */
|
|
2802
|
+
code;
|
|
2803
|
+
/**
|
|
2804
|
+
* Where to send the user: the portal's upgrade page or its billing page,
|
|
2805
|
+
* as an absolute URL. Set only when the server sent an absolute http(s)
|
|
2806
|
+
* URL. The relative `upgradeUrl` of the Free-tier refusals is never here:
|
|
2807
|
+
* those are not this error.
|
|
2808
|
+
*/
|
|
2809
|
+
upgradeUrl;
|
|
2810
|
+
constructor(message, code, body, upgradeUrl) {
|
|
2811
|
+
super(message, 402, body);
|
|
2812
|
+
this.name = "PaymentRequiredError";
|
|
2813
|
+
this.code = code;
|
|
2814
|
+
if (upgradeUrl !== undefined)
|
|
2815
|
+
this.upgradeUrl = upgradeUrl;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* The error to throw for a refused response: a {@link PaymentRequiredError}
|
|
2820
|
+
* for a 402 whose body carries one of the three codes, and null for anything
|
|
2821
|
+
* else, so the caller throws what it threw before. `body` is the parsed JSON,
|
|
2822
|
+
* or the raw text of the response, which is parsed here.
|
|
2823
|
+
*/
|
|
2824
|
+
function paymentRequiredFrom(status, body) {
|
|
2825
|
+
if (status !== 402)
|
|
2826
|
+
return null;
|
|
2827
|
+
let parsed = body;
|
|
2828
|
+
if (typeof parsed === "string") {
|
|
2829
|
+
try {
|
|
2830
|
+
parsed = JSON.parse(parsed);
|
|
2831
|
+
}
|
|
2832
|
+
catch {
|
|
2833
|
+
return null;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
if (!parsed || typeof parsed !== "object")
|
|
2837
|
+
return null;
|
|
2838
|
+
const { code, error, upgradeUrl } = parsed;
|
|
2839
|
+
if (!isPaymentRequiredCode(code))
|
|
2840
|
+
return null;
|
|
2841
|
+
const message = typeof error === "string" && error ? error : `Payment required (${code})`;
|
|
2842
|
+
return new PaymentRequiredError(message, code, parsed, absoluteHttpUrl(upgradeUrl));
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2745
2845
|
function _loadWasmModule (sync, filepath, src, imports) {
|
|
2746
2846
|
function _instantiateOrCompile(source, imports, stream) {
|
|
2747
2847
|
var instantiateFunc = WebAssembly.instantiate;
|
|
@@ -3084,29 +3184,30 @@ class Field {
|
|
|
3084
3184
|
// Import Poseidon from circomlibjs
|
|
3085
3185
|
let poseidonModule = null;
|
|
3086
3186
|
let poseidonF = null;
|
|
3187
|
+
/**
|
|
3188
|
+
* Thrown (internal; match on `name`) when circomlibjs cannot be loaded or built. There is deliberately no
|
|
3189
|
+
* fallback hash: any substitute yields values that differ from real Poseidon,
|
|
3190
|
+
* silently producing wrong commitments and weakened crypto.
|
|
3191
|
+
*/
|
|
3192
|
+
class PoseidonUnavailableError extends Error {
|
|
3193
|
+
constructor(cause) {
|
|
3194
|
+
super('Poseidon unavailable: circomlibjs failed to load', { cause });
|
|
3195
|
+
this.name = 'PoseidonUnavailableError';
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3087
3198
|
async function loadPoseidon$1() {
|
|
3088
3199
|
if (!poseidonModule) {
|
|
3089
3200
|
try {
|
|
3090
|
-
// Try to load circomlibjs poseidon
|
|
3091
3201
|
const buildPoseidon = (await import('circomlibjs')).buildPoseidon;
|
|
3092
3202
|
const poseidonBuilder = await buildPoseidon();
|
|
3093
3203
|
poseidonModule = poseidonBuilder;
|
|
3094
3204
|
poseidonF = poseidonBuilder.F;
|
|
3095
3205
|
}
|
|
3096
3206
|
catch (error) {
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
const encoder = new TextEncoder();
|
|
3102
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data));
|
|
3103
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
3104
|
-
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
3105
|
-
return BigInt('0x' + hashHex) % Field.FIELD_SIZE;
|
|
3106
|
-
};
|
|
3107
|
-
poseidonF = {
|
|
3108
|
-
toString: (val) => val.toString()
|
|
3109
|
-
};
|
|
3207
|
+
// Leave module state unset so a later call retries.
|
|
3208
|
+
poseidonModule = null;
|
|
3209
|
+
poseidonF = null;
|
|
3210
|
+
throw new PoseidonUnavailableError(error);
|
|
3110
3211
|
}
|
|
3111
3212
|
}
|
|
3112
3213
|
return { poseidon: poseidonModule, F: poseidonF };
|
|
@@ -5261,14 +5362,19 @@ function nonceHexToFieldString(nonceHex) {
|
|
|
5261
5362
|
*/
|
|
5262
5363
|
async function parseOrThrow$1(response, label) {
|
|
5263
5364
|
if (!response.ok) {
|
|
5264
|
-
let
|
|
5365
|
+
let text = "";
|
|
5265
5366
|
try {
|
|
5266
|
-
|
|
5267
|
-
detail = text ? `: ${text}` : "";
|
|
5367
|
+
text = await response.text();
|
|
5268
5368
|
}
|
|
5269
5369
|
catch {
|
|
5270
5370
|
// ignore — we'll just use the status code
|
|
5271
5371
|
}
|
|
5372
|
+
// A put refused for payment: its own class. Every other failure is the
|
|
5373
|
+
// plain Error it always was.
|
|
5374
|
+
const refused = paymentRequiredFrom(response.status, text);
|
|
5375
|
+
if (refused)
|
|
5376
|
+
throw refused;
|
|
5377
|
+
const detail = text ? `: ${text}` : "";
|
|
5272
5378
|
throw new Error(`PersonalSpaceClient ${label} failed (${response.status})${detail}`);
|
|
5273
5379
|
}
|
|
5274
5380
|
return (await response.json());
|
|
@@ -5862,6 +5968,34 @@ class ReedSolomonCodec {
|
|
|
5862
5968
|
const SHARD_BATCH_WINDOW_MS = 6;
|
|
5863
5969
|
/** Hashes per batch. Must not exceed what the server will accept. */
|
|
5864
5970
|
const SHARD_BATCH_MAX = 256;
|
|
5971
|
+
/**
|
|
5972
|
+
* Expected response bytes per batch, which a batch is split to stay under.
|
|
5973
|
+
*
|
|
5974
|
+
* The server refuses a batch above 24 MiB of shard bytes with a 413, and the
|
|
5975
|
+
* reads in a refused batch fail. Counting hashes alone, about 25 one-MiB
|
|
5976
|
+
* shards in one window - a few large files read at once - drew it. This
|
|
5977
|
+
* leaves the server's ceiling well clear.
|
|
5978
|
+
*/
|
|
5979
|
+
const SHARD_BATCH_BUDGET_BYTES = 16 * 1024 * 1024;
|
|
5980
|
+
/** An entry's JSON around its base64: the quoted hash, a colon, quotes and a comma, rounded up. */
|
|
5981
|
+
const SHARD_BATCH_ENTRY_BYTES = 128;
|
|
5982
|
+
/**
|
|
5983
|
+
* What a shard adds to a batch's response: base64 is 4 bytes for every 3.
|
|
5984
|
+
*
|
|
5985
|
+
* A shard whose size the caller did not give counts as its entry alone, so
|
|
5986
|
+
* reads without sizes batch by count, as they always have — kept for the
|
|
5987
|
+
* pinned "many concurrent reads → ONE request" test
|
|
5988
|
+
* (`shard-batching.test.ts`). No SDK read path is size-less today:
|
|
5989
|
+
* `FileStorage.readChunk` always passes `shardSize`. The server reads and
|
|
5990
|
+
* base64-encodes every shard before it checks the total
|
|
5991
|
+
* (`ShardsController.ts`), so a very large size-less batch can fail with a
|
|
5992
|
+
* 5xx before the client ever sees a 413. The split in `sendShardBatch` is a
|
|
5993
|
+
* best-effort net for that case, not a guarantee.
|
|
5994
|
+
*/
|
|
5995
|
+
function batchCost(size) {
|
|
5996
|
+
const bytes = typeof size === "number" && Number.isFinite(size) && size > 0 ? size : 0;
|
|
5997
|
+
return Math.ceil(bytes / 3) * 4 + SHARD_BATCH_ENTRY_BYTES;
|
|
5998
|
+
}
|
|
5865
5999
|
/** Decode base64 without `atob`'s per-character loop cost on large shards. */
|
|
5866
6000
|
function bytesFromBase64(b64) {
|
|
5867
6001
|
const binary = atob(b64);
|
|
@@ -5888,8 +6022,10 @@ class ShardClient {
|
|
|
5888
6022
|
baseUrl;
|
|
5889
6023
|
pathPrefix;
|
|
5890
6024
|
fetchFn;
|
|
6025
|
+
/** What a PUT goes through: {@link ShardClientOptions.fetchWithRecovery} when given, else {@link fetchFn}. */
|
|
6026
|
+
putFn;
|
|
5891
6027
|
cache;
|
|
5892
|
-
/** Hashes waiting for the next batch, each with everyone awaiting it. */
|
|
6028
|
+
/** Hashes waiting for the next batch, each with everyone awaiting it and its size if a caller gave it. */
|
|
5893
6029
|
pendingShards;
|
|
5894
6030
|
batchTimer;
|
|
5895
6031
|
/** False once the server is known not to have the batch route. */
|
|
@@ -5912,6 +6048,7 @@ class ShardClient {
|
|
|
5912
6048
|
throw new Error("ShardClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
|
|
5913
6049
|
}
|
|
5914
6050
|
this.fetchFn = f.bind(globalThis);
|
|
6051
|
+
this.putFn = opts.fetchWithRecovery ?? this.fetchFn;
|
|
5915
6052
|
this.cache = opts.cache;
|
|
5916
6053
|
// Coalescing state. `batchSupported` latches false against a deployment
|
|
5917
6054
|
// without the batch route, so we degrade once rather than per request.
|
|
@@ -5955,7 +6092,7 @@ class ShardClient {
|
|
|
5955
6092
|
}
|
|
5956
6093
|
let response;
|
|
5957
6094
|
try {
|
|
5958
|
-
response = await this.
|
|
6095
|
+
response = await this.putFn(url, {
|
|
5959
6096
|
method: "PUT",
|
|
5960
6097
|
headers: { "content-type": "application/octet-stream" },
|
|
5961
6098
|
// BufferSource is what fetch's body expects; a Uint8Array satisfies it.
|
|
@@ -5973,7 +6110,10 @@ class ShardClient {
|
|
|
5973
6110
|
}
|
|
5974
6111
|
if (!response.ok) {
|
|
5975
6112
|
const text = await safeText(response);
|
|
5976
|
-
|
|
6113
|
+
// Refused for payment: its own class, which the offline queue keeps
|
|
6114
|
+
// and an app can tell from any other failure.
|
|
6115
|
+
throw paymentRequiredFrom(response.status, text)
|
|
6116
|
+
?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
|
|
5977
6117
|
}
|
|
5978
6118
|
let dedup = false;
|
|
5979
6119
|
try {
|
|
@@ -6025,21 +6165,25 @@ class ShardClient {
|
|
|
6025
6165
|
/** PUT the shard bytes to origin, verifying the server accepted them.
|
|
6026
6166
|
* Used by the background-upload path; throws on network/HTTP failure. */
|
|
6027
6167
|
async uploadToOrigin(url, hash, bytes) {
|
|
6028
|
-
const response = await this.
|
|
6168
|
+
const response = await this.putFn(url, {
|
|
6029
6169
|
method: "PUT",
|
|
6030
6170
|
headers: { "content-type": "application/octet-stream" },
|
|
6031
6171
|
body: bytes,
|
|
6032
6172
|
});
|
|
6033
6173
|
if (!response.ok) {
|
|
6034
6174
|
const text = await safeText(response);
|
|
6035
|
-
throw
|
|
6175
|
+
throw paymentRequiredFrom(response.status, text)
|
|
6176
|
+
?? new Error(`ShardClient.putShard(${hash}) failed (${response.status}): ${text}`);
|
|
6036
6177
|
}
|
|
6037
6178
|
}
|
|
6038
6179
|
/**
|
|
6039
6180
|
* Fetch a shard by hash. Throws if the server returns a non-200; returns
|
|
6040
6181
|
* `null` for 404 so callers can distinguish "missing" from "errored".
|
|
6182
|
+
*
|
|
6183
|
+
* `size`, the shard's byte length when known (a manifest's `shardSize`),
|
|
6184
|
+
* lets concurrent reads be batched within what the server will send.
|
|
6041
6185
|
*/
|
|
6042
|
-
async getShard(hash) {
|
|
6186
|
+
async getShard(hash, opts = {}) {
|
|
6043
6187
|
// Cache-first: a hit is content-verified by construction (key == hash).
|
|
6044
6188
|
if (this.cache) {
|
|
6045
6189
|
const cached = await this.cache.get(hash);
|
|
@@ -6048,7 +6192,7 @@ class ShardClient {
|
|
|
6048
6192
|
}
|
|
6049
6193
|
// No peers → straight to origin.
|
|
6050
6194
|
if (!this.peers)
|
|
6051
|
-
return this.fetchFromOrigin(hash);
|
|
6195
|
+
return this.fetchFromOrigin(hash, opts.size);
|
|
6052
6196
|
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
6053
6197
|
// or missing peer stall delivery — give the peer a short head start, then
|
|
6054
6198
|
// start origin in parallel and take whichever returns the block first.
|
|
@@ -6079,7 +6223,7 @@ class ShardClient {
|
|
|
6079
6223
|
clearTimeout(timer);
|
|
6080
6224
|
timer = null;
|
|
6081
6225
|
}
|
|
6082
|
-
this.fetchFromOrigin(hash).then(win, fail);
|
|
6226
|
+
this.fetchFromOrigin(hash, opts.size).then(win, fail);
|
|
6083
6227
|
};
|
|
6084
6228
|
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
6085
6229
|
peers.getBlock(hash).then((b) => {
|
|
@@ -6113,16 +6257,17 @@ class ShardClient {
|
|
|
6113
6257
|
* Falls back permanently to one-at-a-time if the server has no batch route,
|
|
6114
6258
|
* so a new SDK keeps working against an older deployment.
|
|
6115
6259
|
*/
|
|
6116
|
-
fetchFromOrigin(hash) {
|
|
6260
|
+
fetchFromOrigin(hash, size) {
|
|
6117
6261
|
if (!this.batchSupported)
|
|
6118
6262
|
return this.fetchOneFromOrigin(hash);
|
|
6119
6263
|
return new Promise((resolve, reject) => {
|
|
6120
|
-
const
|
|
6121
|
-
if (
|
|
6122
|
-
|
|
6264
|
+
const pending = this.pendingShards.get(hash);
|
|
6265
|
+
if (pending) {
|
|
6266
|
+
pending.waiters.push({ resolve, reject });
|
|
6267
|
+
pending.size ??= size;
|
|
6123
6268
|
return;
|
|
6124
6269
|
}
|
|
6125
|
-
this.pendingShards.set(hash, [{ resolve, reject }]);
|
|
6270
|
+
this.pendingShards.set(hash, { size, waiters: [{ resolve, reject }] });
|
|
6126
6271
|
if (this.batchTimer === null) {
|
|
6127
6272
|
this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
|
|
6128
6273
|
}
|
|
@@ -6133,17 +6278,33 @@ class ShardClient {
|
|
|
6133
6278
|
this.batchTimer = null;
|
|
6134
6279
|
if (this.pendingShards.size === 0)
|
|
6135
6280
|
return;
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6281
|
+
// As many as fit: at most SHARD_BATCH_MAX hashes, and a response
|
|
6282
|
+
// expected to stay within SHARD_BATCH_BUDGET_BYTES. One that does not
|
|
6283
|
+
// fit is skipped for a smaller one behind it. The first always goes,
|
|
6284
|
+
// so a shard over the budget by itself is fetched alone.
|
|
6285
|
+
const waiters = [];
|
|
6286
|
+
let cost = 0;
|
|
6287
|
+
for (const [h, pending] of this.pendingShards) {
|
|
6288
|
+
if (waiters.length === SHARD_BATCH_MAX)
|
|
6289
|
+
break;
|
|
6290
|
+
const next = batchCost(pending.size);
|
|
6291
|
+
if (waiters.length > 0 && cost + next > SHARD_BATCH_BUDGET_BYTES)
|
|
6292
|
+
continue;
|
|
6293
|
+
waiters.push([h, pending.waiters]);
|
|
6294
|
+
cost += next;
|
|
6295
|
+
}
|
|
6296
|
+
for (const [h] of waiters)
|
|
6139
6297
|
this.pendingShards.delete(h);
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
// Anything over the cap waits for the next window rather than being
|
|
6143
|
-
// dropped or silently truncated.
|
|
6298
|
+
// Anything over the cap or the budget waits for the next window
|
|
6299
|
+
// rather than being dropped or silently truncated.
|
|
6144
6300
|
if (this.pendingShards.size > 0 && this.batchTimer === null) {
|
|
6145
6301
|
this.batchTimer = setTimeout(() => { void this.flushShardBatch(); }, SHARD_BATCH_WINDOW_MS);
|
|
6146
6302
|
}
|
|
6303
|
+
await this.sendShardBatch(waiters);
|
|
6304
|
+
}
|
|
6305
|
+
/** POST one batch and settle everyone waiting on it. Never throws. */
|
|
6306
|
+
async sendShardBatch(waiters) {
|
|
6307
|
+
const hashes = waiters.map(([h]) => h);
|
|
6147
6308
|
let shards;
|
|
6148
6309
|
try {
|
|
6149
6310
|
const res = await this.fetchFn(`${this.baseUrl}${this.pathPrefix}/batch`, {
|
|
@@ -6151,6 +6312,21 @@ class ShardClient {
|
|
|
6151
6312
|
headers: { "Content-Type": "application/json" },
|
|
6152
6313
|
body: JSON.stringify({ hashes }),
|
|
6153
6314
|
});
|
|
6315
|
+
if (res.status === 413 && waiters.length > 1) {
|
|
6316
|
+
// Too large for the server after all: a size was not given,
|
|
6317
|
+
// or was wrong. Ask for each half on its own. Halving reaches
|
|
6318
|
+
// one hash per batch within log2(n) levels, and one hash the
|
|
6319
|
+
// server refuses is an error like any other, below.
|
|
6320
|
+
// Release the body first: under undici (the CLI, Node), an
|
|
6321
|
+
// unread body holds the connection until garbage collection.
|
|
6322
|
+
await res.body?.cancel().catch(() => { });
|
|
6323
|
+
const half = Math.ceil(waiters.length / 2);
|
|
6324
|
+
await Promise.all([
|
|
6325
|
+
this.sendShardBatch(waiters.slice(0, half)),
|
|
6326
|
+
this.sendShardBatch(waiters.slice(half)),
|
|
6327
|
+
]);
|
|
6328
|
+
return;
|
|
6329
|
+
}
|
|
6154
6330
|
if (res.status === 404 || res.status === 405) {
|
|
6155
6331
|
// No batch route on this deployment. Stop trying, and serve
|
|
6156
6332
|
// everyone individually — including the batch we just gathered.
|
|
@@ -6281,6 +6457,15 @@ async function safeText(response) {
|
|
|
6281
6457
|
* manifest and reassembles the plaintext; {@link FileStorage.readFile} first
|
|
6282
6458
|
* fetches the manifest from the space.
|
|
6283
6459
|
*/
|
|
6460
|
+
/**
|
|
6461
|
+
* How many chunks past the one being read a multi-chunk read fetches ahead
|
|
6462
|
+
* (see `chunksInOrder`). Two: enough to keep requests overlapping on a link
|
|
6463
|
+
* where each one waits on its round trip. While the caller holds chunk k,
|
|
6464
|
+
* up to `READ_AHEAD_CHUNKS + 1` more chunks can be in flight (the next
|
|
6465
|
+
* starts as k is handed over), so at the default 4 MB chunk about 16 MB of
|
|
6466
|
+
* reads in flight per file, on top of the chunk held.
|
|
6467
|
+
*/
|
|
6468
|
+
const READ_AHEAD_CHUNKS = 2;
|
|
6284
6469
|
class FileStorage {
|
|
6285
6470
|
space;
|
|
6286
6471
|
shards;
|
|
@@ -6386,11 +6571,10 @@ class FileStorage {
|
|
|
6386
6571
|
await this.codec.ready(this.rsWasmModule);
|
|
6387
6572
|
// Materialize each chunk into plaintext, then concatenate. We don't
|
|
6388
6573
|
// stream across chunks here because the consuming surface returns
|
|
6389
|
-
// `Uint8Array`;
|
|
6574
|
+
// `Uint8Array`; `readChunksFromShards` is the streaming variant.
|
|
6390
6575
|
const plaintextChunks = [];
|
|
6391
|
-
for (const
|
|
6392
|
-
plaintextChunks.push(
|
|
6393
|
-
}
|
|
6576
|
+
for await (const chunk of this.chunksInOrder(manifest))
|
|
6577
|
+
plaintextChunks.push(chunk);
|
|
6394
6578
|
let total = 0;
|
|
6395
6579
|
for (const c of plaintextChunks)
|
|
6396
6580
|
total += c.length;
|
|
@@ -6411,8 +6595,48 @@ class FileStorage {
|
|
|
6411
6595
|
*/
|
|
6412
6596
|
async *readChunksFromShards(manifest) {
|
|
6413
6597
|
await this.codec.ready(this.rsWasmModule);
|
|
6414
|
-
|
|
6415
|
-
|
|
6598
|
+
yield* this.chunksInOrder(manifest);
|
|
6599
|
+
}
|
|
6600
|
+
/**
|
|
6601
|
+
* Each chunk's plaintext, in order, with the next {@link READ_AHEAD_CHUNKS}
|
|
6602
|
+
* already being fetched.
|
|
6603
|
+
*
|
|
6604
|
+
* One chunk at a time, a large file cost a full round trip per 4 MB chunk
|
|
6605
|
+
* with nothing overlapping it: a 33 MB file was eight serial requests,
|
|
6606
|
+
* each mostly waiting. Within a chunk the shard reads were already
|
|
6607
|
+
* parallel; this overlaps the chunks too.
|
|
6608
|
+
*
|
|
6609
|
+
* Bounded on purpose: while the caller holds a yielded chunk, at most
|
|
6610
|
+
* `1 + READ_AHEAD_CHUNKS` more are in flight, and nothing new starts until
|
|
6611
|
+
* it asks for the next — a slow consumer of the stream is not run away
|
|
6612
|
+
* from. A failure surfaces at its own chunk, in order; reads already
|
|
6613
|
+
* started past it are let finish, their failures swallowed rather than
|
|
6614
|
+
* left unhandled. If the caller stops early (`break` or `return()`), no
|
|
6615
|
+
* further read starts and the ones in flight settle on their own; they are
|
|
6616
|
+
* not awaited, so `return()` is not delayed by a slow shard.
|
|
6617
|
+
*/
|
|
6618
|
+
async *chunksInOrder(manifest) {
|
|
6619
|
+
const ordered = [...manifest.chunks].sort((a, b) => a.chunkIndex - b.chunkIndex);
|
|
6620
|
+
const ahead = [];
|
|
6621
|
+
let next = 0;
|
|
6622
|
+
const start = () => {
|
|
6623
|
+
const read = this.readChunk(ordered[next++]);
|
|
6624
|
+
read.catch(() => { }); // reported when its turn comes
|
|
6625
|
+
ahead.push(read);
|
|
6626
|
+
};
|
|
6627
|
+
while (next < ordered.length && ahead.length < 1 + READ_AHEAD_CHUNKS)
|
|
6628
|
+
start();
|
|
6629
|
+
try {
|
|
6630
|
+
while (ahead.length > 0) {
|
|
6631
|
+
const chunk = await ahead.shift();
|
|
6632
|
+
if (next < ordered.length)
|
|
6633
|
+
start();
|
|
6634
|
+
yield chunk;
|
|
6635
|
+
}
|
|
6636
|
+
}
|
|
6637
|
+
finally {
|
|
6638
|
+
next = ordered.length; // nothing more is started
|
|
6639
|
+
ahead.length = 0; // in-flight reads settle unobserved; each has its own .catch
|
|
6416
6640
|
}
|
|
6417
6641
|
}
|
|
6418
6642
|
// -------------------------------------------------------------------------
|
|
@@ -6526,7 +6750,8 @@ class FileStorage {
|
|
|
6526
6750
|
const fetched = new Array(total).fill(null);
|
|
6527
6751
|
const fetchInto = async (indexes) => {
|
|
6528
6752
|
await this.runWithConcurrency(indexes.map((i) => async () => {
|
|
6529
|
-
|
|
6753
|
+
// The size keeps a batch of concurrent reads within what the server will send.
|
|
6754
|
+
fetched[i] = await this.shards.getShard(manifest.shardHashes[i], { size: manifest.shardSize });
|
|
6530
6755
|
}));
|
|
6531
6756
|
};
|
|
6532
6757
|
await fetchInto(Array.from({ length: dataCount }, (_, i) => i));
|
|
@@ -6659,6 +6884,10 @@ async function parseOrThrow(response, op) {
|
|
|
6659
6884
|
// non-JSON body
|
|
6660
6885
|
}
|
|
6661
6886
|
if (!response.ok) {
|
|
6887
|
+
// A manifest write refused for payment (a gated Space write): its own class.
|
|
6888
|
+
const refused = paymentRequiredFrom(response.status, parsed);
|
|
6889
|
+
if (refused)
|
|
6890
|
+
throw refused;
|
|
6662
6891
|
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
6663
6892
|
? String(parsed.error)
|
|
6664
6893
|
: `${response.status} ${response.statusText}`;
|
|
@@ -6675,7 +6904,7 @@ async function parseOrThrow(response, op) {
|
|
|
6675
6904
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
6676
6905
|
* `package.json` `version`.
|
|
6677
6906
|
*/
|
|
6678
|
-
const VERSION = "0.15.0-alpha.
|
|
6907
|
+
const VERSION = "0.15.0-alpha.10";
|
|
6679
6908
|
|
|
6680
6909
|
/**
|
|
6681
6910
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -6720,17 +6949,6 @@ function reqPath(input) {
|
|
|
6720
6949
|
return "?";
|
|
6721
6950
|
}
|
|
6722
6951
|
}
|
|
6723
|
-
/** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
|
|
6724
|
-
class HttpError extends Error {
|
|
6725
|
-
status;
|
|
6726
|
-
body;
|
|
6727
|
-
constructor(message, status, body) {
|
|
6728
|
-
super(message);
|
|
6729
|
-
this.status = status;
|
|
6730
|
-
this.body = body;
|
|
6731
|
-
this.name = "HttpError";
|
|
6732
|
-
}
|
|
6733
|
-
}
|
|
6734
6952
|
class HttpClient {
|
|
6735
6953
|
baseUrl;
|
|
6736
6954
|
/** Origin of {@link baseUrl} — credentials are only attached same-origin. */
|
|
@@ -6859,7 +7077,9 @@ class HttpClient {
|
|
|
6859
7077
|
const msg = parsed && typeof parsed === "object" && "error" in parsed
|
|
6860
7078
|
? String(parsed.error)
|
|
6861
7079
|
: `${res.status} ${res.statusText}`;
|
|
6862
|
-
|
|
7080
|
+
// A refusal for payment is its own class, so an app can tell it from
|
|
7081
|
+
// any other 4xx; every other error is the HttpError it always was.
|
|
7082
|
+
throw paymentRequiredFrom(res.status, parsed) ?? new HttpError(msg, res.status, parsed);
|
|
6863
7083
|
}
|
|
6864
7084
|
return parsed;
|
|
6865
7085
|
}
|
|
@@ -8983,6 +9203,45 @@ class StorageCipher {
|
|
|
8983
9203
|
}
|
|
8984
9204
|
}
|
|
8985
9205
|
|
|
9206
|
+
/** The server's cap on `prefix`, in code points (accelerator `personalKv/list.ts`). */
|
|
9207
|
+
const MAX_SERVER_PREFIX_CHARS = 1024;
|
|
9208
|
+
/**
|
|
9209
|
+
* The server handed back a cursor it had already given, so following it would
|
|
9210
|
+
* page forever. A fault in the answer, not a failure to reach the server: the
|
|
9211
|
+
* offline layer rethrows it instead of falling back to its cache.
|
|
9212
|
+
*/
|
|
9213
|
+
class RepeatedCursorError extends Error {
|
|
9214
|
+
constructor() {
|
|
9215
|
+
super("personal-space list: the server repeated a cursor; stopping instead of looping");
|
|
9216
|
+
this.name = "RepeatedCursorError";
|
|
9217
|
+
}
|
|
9218
|
+
}
|
|
9219
|
+
/** Every key under `prefix` ("" = every key), in the server's order. */
|
|
9220
|
+
async function listPersonalKeys(http, path, prefix) {
|
|
9221
|
+
// A prefix over the server's cap would be refused with a 400. Every list
|
|
9222
|
+
// was whole before the key index, so ask for everything instead; the
|
|
9223
|
+
// caller filters. (`length` first: code points never outnumber units.)
|
|
9224
|
+
const sent = prefix.length > MAX_SERVER_PREFIX_CHARS && [...prefix].length > MAX_SERVER_PREFIX_CHARS ? "" : prefix;
|
|
9225
|
+
const keys = [];
|
|
9226
|
+
const seen = new Set();
|
|
9227
|
+
let cursor = null;
|
|
9228
|
+
for (;;) {
|
|
9229
|
+
const res = await http.post(path, cursor === null ? { prefix: sent } : { prefix: sent, cursor });
|
|
9230
|
+
keys.push(...(res?.keys ?? []));
|
|
9231
|
+
// Annotated: without it TypeScript infers `next` through `cursor` and
|
|
9232
|
+
// the request body above, a loop it reports as TS7022.
|
|
9233
|
+
const next = typeof res?.cursor === "string" && res.cursor !== "" ? res.cursor : null;
|
|
9234
|
+
if (next === null)
|
|
9235
|
+
return keys;
|
|
9236
|
+
// A server (or a cache in front of one) that hands back a cursor it
|
|
9237
|
+
// already gave would have this page forever.
|
|
9238
|
+
if (seen.has(next))
|
|
9239
|
+
throw new RepeatedCursorError();
|
|
9240
|
+
seen.add(next);
|
|
9241
|
+
cursor = next;
|
|
9242
|
+
}
|
|
9243
|
+
}
|
|
9244
|
+
|
|
8986
9245
|
/**
|
|
8987
9246
|
* `client.kv` — per-user key/value storage backed by the accelerator's personal
|
|
8988
9247
|
* space.
|
|
@@ -8990,6 +9249,7 @@ class StorageCipher {
|
|
|
8990
9249
|
* await client.kv.set('todos', id, { title, completed })
|
|
8991
9250
|
* const todo = await client.kv.get('todos', id)
|
|
8992
9251
|
* const ids = await client.kv.list('todos')
|
|
9252
|
+
* const keys = await client.kv.keys('todos/') // full keys, any prefix
|
|
8993
9253
|
* await client.kv.delete('todos', id)
|
|
8994
9254
|
* const off = client.kv.on('change', e => …) // realtime, cross-device
|
|
8995
9255
|
*
|
|
@@ -9092,25 +9352,45 @@ class KvNamespace {
|
|
|
9092
9352
|
return true; // optimistic
|
|
9093
9353
|
}
|
|
9094
9354
|
}
|
|
9095
|
-
/**
|
|
9355
|
+
/**
|
|
9356
|
+
* List the ids present in `collection`. The server filters by
|
|
9357
|
+
* `${collection}/` and pages; values are not fetched.
|
|
9358
|
+
*/
|
|
9096
9359
|
async list(collection) {
|
|
9097
9360
|
const prefix = `${collection}/`;
|
|
9361
|
+
return (await this.keys(prefix)).map((k) => k.slice(prefix.length));
|
|
9362
|
+
}
|
|
9363
|
+
/**
|
|
9364
|
+
* Every key in the signed-in user's personal space that starts with
|
|
9365
|
+
* `prefix` (all of them when it is ""), as full keys: `todos/t1`, not
|
|
9366
|
+
* `t1`. Values are not fetched. The personal space is shared by every
|
|
9367
|
+
* app the user signs into, so expect keys you did not write: the VFS's
|
|
9368
|
+
* `vfs/…`, version control's `vcs/…`, the SDK's own `chat-keys` and
|
|
9369
|
+
* `space-keys/…`.
|
|
9370
|
+
*
|
|
9371
|
+
* The local `startsWith` filter stays on purpose: a server older than
|
|
9372
|
+
* the key index ignores `prefix` and returns every key.
|
|
9373
|
+
*/
|
|
9374
|
+
async keys(prefix = "") {
|
|
9098
9375
|
const offline = this.deps.offline;
|
|
9099
|
-
const
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
return
|
|
9103
|
-
}
|
|
9376
|
+
const under = (keys) => [...new Set(keys.filter((k) => k.startsWith(prefix)))];
|
|
9377
|
+
const remote = () => listPersonalKeys(this.deps.http, this.spacePath("/list"), prefix);
|
|
9378
|
+
if (!offline?.enabled)
|
|
9379
|
+
return under(await remote());
|
|
9104
9380
|
try {
|
|
9105
|
-
const
|
|
9381
|
+
const fromServer = await remote();
|
|
9106
9382
|
// 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));
|
|
9383
|
+
return under([...fromServer, ...(await offline.liveKeys(this.commitment(), prefix))]);
|
|
9109
9384
|
}
|
|
9110
9385
|
catch (err) {
|
|
9111
|
-
|
|
9386
|
+
// A refusal, or a server that repeats its cursor, is an answer,
|
|
9387
|
+
// not an outage: falling back would hand back a partial list as
|
|
9388
|
+
// if it were whole. Only a failure to reach the server (even
|
|
9389
|
+
// mid-paging) falls back to the cache, as a failed list did
|
|
9390
|
+
// before paging.
|
|
9391
|
+
if (err instanceof HttpError || err instanceof RepeatedCursorError)
|
|
9112
9392
|
throw err;
|
|
9113
|
-
return
|
|
9393
|
+
return under(await offline.liveKeys(this.commitment(), prefix));
|
|
9114
9394
|
}
|
|
9115
9395
|
}
|
|
9116
9396
|
/** Reserved — server-side query is deferred (see the overhaul plan). */
|
|
@@ -9543,6 +9823,7 @@ class StorageNamespace {
|
|
|
9543
9823
|
baseUrl: this.deps.baseUrl,
|
|
9544
9824
|
pathPrefix: "/api/shards",
|
|
9545
9825
|
fetch: this.deps.http.fetch,
|
|
9826
|
+
fetchWithRecovery: this.deps.http.fetchWithRecovery,
|
|
9546
9827
|
cache: this.deps.shardCache,
|
|
9547
9828
|
deferUpload: this.deps.deferShardUpload,
|
|
9548
9829
|
});
|
|
@@ -9561,6 +9842,59 @@ class StorageNamespace {
|
|
|
9561
9842
|
}
|
|
9562
9843
|
}
|
|
9563
9844
|
|
|
9845
|
+
/**
|
|
9846
|
+
* `POST /api/storage/commit` and `/api/storage/release`: take or drop a
|
|
9847
|
+
* reference to a manifest's shards.
|
|
9848
|
+
*
|
|
9849
|
+
* The commit is what meters a file's bytes to the app and reference-counts
|
|
9850
|
+
* its shards, so garbage collection keeps them; the release drops that
|
|
9851
|
+
* reference. The body is `{ hashes, bytes }` and nothing else. The accelerator
|
|
9852
|
+
* keys its reference ledger on the caller, the app, the bytes and the sorted
|
|
9853
|
+
* hashes, so this body must not change: a commit sent in another shape would
|
|
9854
|
+
* take a second reference instead of finding the first.
|
|
9855
|
+
*
|
|
9856
|
+
* Shared by `Space` (`putFile`, `commitFile`, `releaseFile`) and by
|
|
9857
|
+
* `client.vfs`, which takes and drops references here directly. A reference
|
|
9858
|
+
* names only shards and needs no Space; opening the content space to take one
|
|
9859
|
+
* created that space for a user who had never written a file, which a
|
|
9860
|
+
* paid-only app refuses for a Free user (accelerator spec
|
|
9861
|
+
* 2026-10-06-paid-accounts-and-past-due, D12).
|
|
9862
|
+
*
|
|
9863
|
+
* Internal: not re-exported from any barrel.
|
|
9864
|
+
*/
|
|
9865
|
+
/**
|
|
9866
|
+
* Commit or release `manifest`'s shards through `fetchFn`, which should be
|
|
9867
|
+
* `HttpClient.fetchWithRecovery`, so a dead session is re-established once
|
|
9868
|
+
* rather than refused three times. Best-effort: tries three times, or once
|
|
9869
|
+
* for a 401, then warns and returns, without failing the caller's write.
|
|
9870
|
+
*/
|
|
9871
|
+
async function commitManifest(fetchFn, httpBaseUrl, manifest, action) {
|
|
9872
|
+
const hashes = manifest.chunks.flatMap((c) => c.shardHashes);
|
|
9873
|
+
if (hashes.length === 0)
|
|
9874
|
+
return;
|
|
9875
|
+
const body = JSON.stringify({ hashes, bytes: manifest.size });
|
|
9876
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
9877
|
+
try {
|
|
9878
|
+
const res = await fetchFn(`${httpBaseUrl}/api/storage/${action}`, {
|
|
9879
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body,
|
|
9880
|
+
});
|
|
9881
|
+
if (res.ok)
|
|
9882
|
+
return;
|
|
9883
|
+
// A 401 that `fetchFn`'s recovery could not fix. Another try
|
|
9884
|
+
// would only run that recovery again (`auth.zk.recover()`, and
|
|
9885
|
+
// `onSessionExpired` for the app) and be refused again.
|
|
9886
|
+
if (res.status === 401)
|
|
9887
|
+
break;
|
|
9888
|
+
}
|
|
9889
|
+
catch { /* retry */ }
|
|
9890
|
+
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
|
9891
|
+
}
|
|
9892
|
+
// Best-effort: a missed commit leaves the shards to orphan-GC (still not
|
|
9893
|
+
// durable-unmetered long-term); a missed release just delays GC. Reconciled
|
|
9894
|
+
// by the platform sweep. Don't fail the caller's write on a metering hiccup.
|
|
9895
|
+
console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
|
|
9896
|
+
}
|
|
9897
|
+
|
|
9564
9898
|
/**
|
|
9565
9899
|
* SpaceCipher — pure crypto for the fan-out group-encryption layer.
|
|
9566
9900
|
*
|
|
@@ -10944,25 +11278,10 @@ class Space {
|
|
|
10944
11278
|
}
|
|
10945
11279
|
/** Commit/release a manifest's shards with the accelerator (metering + refcount). */
|
|
10946
11280
|
async commitShards(manifest, action) {
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
10951
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
10952
|
-
try {
|
|
10953
|
-
const res = await this.deps.fetch(`${this.deps.httpBaseUrl}/api/storage/${action}`, {
|
|
10954
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body,
|
|
10955
|
-
});
|
|
10956
|
-
if (res.ok)
|
|
10957
|
-
return;
|
|
10958
|
-
}
|
|
10959
|
-
catch { /* retry */ }
|
|
10960
|
-
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
|
10961
|
-
}
|
|
10962
|
-
// Best-effort: a missed commit leaves the shards to orphan-GC (still not
|
|
10963
|
-
// durable-unmetered long-term); a missed release just delays GC. Reconciled
|
|
10964
|
-
// by the platform sweep. Don't fail the caller's write on a metering hiccup.
|
|
10965
|
-
console.warn(`[muhkoo] storage ${action} did not confirm for ${hashes.length} shard(s)`);
|
|
11281
|
+
// Through the client's 401 recovery when there is one: the commit refuses
|
|
11282
|
+
// a dead session with 401, and three tries with the same token only
|
|
11283
|
+
// delayed the warning.
|
|
11284
|
+
await commitManifest(this.deps.fetchWithRecovery ?? this.deps.fetch, this.deps.httpBaseUrl, manifest, action);
|
|
10966
11285
|
}
|
|
10967
11286
|
fileStorage(backgroundUpload = false) {
|
|
10968
11287
|
// Global, content-addressed shard store — same store `client.storage`
|
|
@@ -10973,6 +11292,7 @@ class Space {
|
|
|
10973
11292
|
baseUrl: this.deps.httpBaseUrl,
|
|
10974
11293
|
pathPrefix: "/api/shards",
|
|
10975
11294
|
fetch: this.deps.fetch,
|
|
11295
|
+
fetchWithRecovery: this.deps.fetchWithRecovery,
|
|
10976
11296
|
cache: this.deps.shardCache,
|
|
10977
11297
|
deferUpload: this.deps.deferShardUpload,
|
|
10978
11298
|
peers: this.peerNet?.exchange,
|
|
@@ -11376,6 +11696,7 @@ class MessageNamespace {
|
|
|
11376
11696
|
wsBaseUrl: this.deps.wsBaseUrl,
|
|
11377
11697
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
11378
11698
|
fetch: this.deps.http.fetch,
|
|
11699
|
+
fetchWithRecovery: this.deps.http.fetchWithRecovery,
|
|
11379
11700
|
myId: () => this.myId(),
|
|
11380
11701
|
fetchTicket: () => this.fetchTicket(),
|
|
11381
11702
|
});
|
|
@@ -13697,6 +14018,7 @@ class SpaceNamespace {
|
|
|
13697
14018
|
wsBaseUrl: this.deps.wsBaseUrl,
|
|
13698
14019
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
13699
14020
|
fetch: this.deps.http.fetch,
|
|
14021
|
+
fetchWithRecovery: this.deps.http.fetchWithRecovery,
|
|
13700
14022
|
myId: () => memberId,
|
|
13701
14023
|
fetchTicket: () => this.fetchTicket(),
|
|
13702
14024
|
historyPolicy,
|
|
@@ -13752,6 +14074,7 @@ class SpaceNamespace {
|
|
|
13752
14074
|
wsBaseUrl: this.deps.wsBaseUrl,
|
|
13753
14075
|
httpBaseUrl: this.deps.http.baseUrl,
|
|
13754
14076
|
fetch: this.deps.http.fetch,
|
|
14077
|
+
fetchWithRecovery: this.deps.http.fetchWithRecovery,
|
|
13755
14078
|
myId: () => memberId,
|
|
13756
14079
|
fetchTicket: () => this.fetchTicket(),
|
|
13757
14080
|
keyring,
|
|
@@ -14127,6 +14450,66 @@ class AccessTokensNamespace {
|
|
|
14127
14450
|
}
|
|
14128
14451
|
}
|
|
14129
14452
|
|
|
14453
|
+
/**
|
|
14454
|
+
* `client.app` — what the signed-in user may do in this app (accelerator spec
|
|
14455
|
+
* 2026-10-06-paid-accounts-and-past-due, B6 and B8).
|
|
14456
|
+
*
|
|
14457
|
+
* An app's owner can turn on "Paid Muhkoo accounts only" in the portal. The
|
|
14458
|
+
* accelerator then refuses a Free user's writes that add data, with a 402 the
|
|
14459
|
+
* SDK throws as `PaymentRequiredError`. Reads, sign-in and deletes still work.
|
|
14460
|
+
* The same refusal reaches every user of an app whose owner's payment is past
|
|
14461
|
+
* due, whether or not the setting is on.
|
|
14462
|
+
*
|
|
14463
|
+
* `access()` asks up front, so an app can show the right screen before the
|
|
14464
|
+
* first refusal.
|
|
14465
|
+
*/
|
|
14466
|
+
class AppNamespace {
|
|
14467
|
+
deps;
|
|
14468
|
+
constructor(deps) {
|
|
14469
|
+
this.deps = deps;
|
|
14470
|
+
}
|
|
14471
|
+
/**
|
|
14472
|
+
* Whether the signed-in user may add data in this app and, if not, why
|
|
14473
|
+
* and where to send them. Needs the app key and a session; a stale session
|
|
14474
|
+
* is re-established once, as for every request. The route is never
|
|
14475
|
+
* refused for payment. Throws `HttpError` for anything else, among them a
|
|
14476
|
+
* 401 without a session and a 404 from an accelerator older than the
|
|
14477
|
+
* route.
|
|
14478
|
+
*
|
|
14479
|
+
* Each `allowed: true` also sends any writes the offline queue kept after
|
|
14480
|
+
* a refusal for payment, without waiting for them. Call it after sign-in,
|
|
14481
|
+
* and again when the user comes back from the portal.
|
|
14482
|
+
*/
|
|
14483
|
+
async access() {
|
|
14484
|
+
const body = await this.deps.http.get("/api/app/access");
|
|
14485
|
+
const access = {
|
|
14486
|
+
paidAccountsOnly: body?.paidAccountsOnly === true,
|
|
14487
|
+
// Fail open on a malformed answer: the server enforces the gate
|
|
14488
|
+
// whatever this says, so a wrong "allowed" costs a refused write,
|
|
14489
|
+
// and a wrong "not allowed" would lock the user out for nothing.
|
|
14490
|
+
allowed: body?.allowed !== false,
|
|
14491
|
+
};
|
|
14492
|
+
if (!access.allowed) {
|
|
14493
|
+
if (isPaymentRequiredCode(body?.reason))
|
|
14494
|
+
access.reason = body.reason;
|
|
14495
|
+
const upgradeUrl = absoluteHttpUrl(body?.upgradeUrl);
|
|
14496
|
+
if (upgradeUrl)
|
|
14497
|
+
access.upgradeUrl = upgradeUrl;
|
|
14498
|
+
}
|
|
14499
|
+
else if (this.deps.onAllowed) {
|
|
14500
|
+
// A drain that fails, or a callback that throws, must not turn a
|
|
14501
|
+
// good answer into an error: the writes stay queued for the next
|
|
14502
|
+
// reconnect.
|
|
14503
|
+
Promise.resolve()
|
|
14504
|
+
.then(() => this.deps.onAllowed())
|
|
14505
|
+
.catch((err) => {
|
|
14506
|
+
console.warn("[client.app] sending the kept writes failed (they stay queued):", err);
|
|
14507
|
+
});
|
|
14508
|
+
}
|
|
14509
|
+
return access;
|
|
14510
|
+
}
|
|
14511
|
+
}
|
|
14512
|
+
|
|
14130
14513
|
/**
|
|
14131
14514
|
* POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
|
|
14132
14515
|
* answers a deterministic decoy, so a lookup never says whether an account
|
|
@@ -15081,11 +15464,24 @@ function nextTurn() {
|
|
|
15081
15464
|
*
|
|
15082
15465
|
* Every mutation is a read-modify-write of exactly ONE directory record, so a
|
|
15083
15466
|
* write cannot leave the tree half-updated: either the parent names the new
|
|
15084
|
-
* child or it does not.
|
|
15085
|
-
*
|
|
15086
|
-
*
|
|
15087
|
-
*
|
|
15088
|
-
*
|
|
15467
|
+
* child or it does not. A VFS write sends no HLC and the personal space has
|
|
15468
|
+
* no compare-and-swap: of two writes of one key it keeps whichever arrives
|
|
15469
|
+
* last.
|
|
15470
|
+
*
|
|
15471
|
+
* Within ONE namespace, writes of a record are sent one at a time, in the
|
|
15472
|
+
* order they were made - edits made while one is in flight go out together
|
|
15473
|
+
* in the next (see `queue`) - and a read that has to fetch a record waits for
|
|
15474
|
+
* its write in flight (see `writing`). Every edit of a record is made to one
|
|
15475
|
+
* in-memory copy of it, and that copy is what is written (see `mutate`).
|
|
15476
|
+
*
|
|
15477
|
+
* Between the tabs of an app on one device, a coordinator (`VfsCoordinator`;
|
|
15478
|
+
* `Client` wires one in a browser tab) gives each record one lock, and a tab
|
|
15479
|
+
* reads a record another tab has written again before its next write of it
|
|
15480
|
+
* (see `sendLocked`). Between devices - or a browser and the CLI - two writes
|
|
15481
|
+
* of the same directory are still last-writer-wins, and the other edit is
|
|
15482
|
+
* lost: that takes a compare-and-swap at the server. Orphaned records (a
|
|
15483
|
+
* directory whose parent entry lost the race) are unreachable rather than
|
|
15484
|
+
* corrupting - they cost storage until a sweep, the trade this shape makes in
|
|
15089
15485
|
* exchange for single-record writes.
|
|
15090
15486
|
*/
|
|
15091
15487
|
/** Refuse a directory at `path` if it would be deeper than MAX_DEPTH from the root. */
|
|
@@ -15290,6 +15686,50 @@ function assertNotHidden(dir, name, path) {
|
|
|
15290
15686
|
throw new VfsConflictError(`${printable(path, 1024)} already exists, but this version cannot read it; a recursive delete removes it`);
|
|
15291
15687
|
}
|
|
15292
15688
|
}
|
|
15689
|
+
/**
|
|
15690
|
+
* Is `now` still the entry an operation read at a name before it waited on
|
|
15691
|
+
* something? The same kind and id, and for a file the same version; a move
|
|
15692
|
+
* mark set or cleared in between does not count. Absent both times is the same.
|
|
15693
|
+
* Where a decision rests on the mark, `markedMeanwhile` checks that as well.
|
|
15694
|
+
*/
|
|
15695
|
+
function sameEntry(before, now) {
|
|
15696
|
+
if (!before || !now)
|
|
15697
|
+
return before === now;
|
|
15698
|
+
if (before.kind !== now.kind || before.id !== now.id)
|
|
15699
|
+
return false;
|
|
15700
|
+
return before.kind === "dir" || (now.kind === "file" && now.manifest.id === before.manifest.id);
|
|
15701
|
+
}
|
|
15702
|
+
/**
|
|
15703
|
+
* Has a move marked the entry at a name since an operation read it there
|
|
15704
|
+
* unmarked? Then a move of it is under way - see `DirEntry.moving` - and it is
|
|
15705
|
+
* about to be named in a second place, which a decision made from the unmarked
|
|
15706
|
+
* read did not allow for.
|
|
15707
|
+
*/
|
|
15708
|
+
function markedMeanwhile(before, now) {
|
|
15709
|
+
return now.moving === true && before.moving !== true;
|
|
15710
|
+
}
|
|
15711
|
+
/** Refuse a write because another write changed `path` while this one waited. See `VfsNamespace.mutate`. */
|
|
15712
|
+
function changedMeanwhile(path) {
|
|
15713
|
+
return new VfsConflictError(`${path} was changed by another write while this one was in progress; nothing was written over it`);
|
|
15714
|
+
}
|
|
15715
|
+
/**
|
|
15716
|
+
* Refuse an operation on `path` because this namespace is moving it - or,
|
|
15717
|
+
* `inside`, something in it. See `VfsNamespace.inMotion`.
|
|
15718
|
+
*/
|
|
15719
|
+
function beingMoved(path, inside = false) {
|
|
15720
|
+
const what = inside ? `something in ${path} is` : `${path} is`;
|
|
15721
|
+
return new VfsConflictError(`${what} being moved; nothing was changed - try again once the move has finished`);
|
|
15722
|
+
}
|
|
15723
|
+
/**
|
|
15724
|
+
* Refuse an operation on `path` because a move or a delete of it - or,
|
|
15725
|
+
* `inside`, of it or of something in it - holds its move lock on this
|
|
15726
|
+
* device: in another tab of the app, or in this one. See
|
|
15727
|
+
* `VfsCoordinator.holdMoving`.
|
|
15728
|
+
*/
|
|
15729
|
+
function heldElsewhere(path, inside = false) {
|
|
15730
|
+
const what = inside ? `${path}, or something in it, is` : `${path} is`;
|
|
15731
|
+
return new VfsConflictError(`${what} being moved or deleted in this app, in this tab or another; nothing was changed - try again once that has finished`);
|
|
15732
|
+
}
|
|
15293
15733
|
/** A JSON object, as opposed to null, an array or a scalar. */
|
|
15294
15734
|
function isObject$1(value) {
|
|
15295
15735
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -15447,6 +15887,13 @@ function followable(dir) {
|
|
|
15447
15887
|
function unfollowable(dir) {
|
|
15448
15888
|
return Object.values(dir.hidden).filter((value) => !readableEntry(value)).length;
|
|
15449
15889
|
}
|
|
15890
|
+
/** The id of every entry `dir` follows, and whether an entry with that id is marked: `SubtreeShape`'s view of it. */
|
|
15891
|
+
function namedIn(dir) {
|
|
15892
|
+
const named = new Map();
|
|
15893
|
+
for (const [, entry] of followable(dir))
|
|
15894
|
+
named.set(entry.id, named.get(entry.id) === true || entry.moving === true);
|
|
15895
|
+
return named;
|
|
15896
|
+
}
|
|
15450
15897
|
class VfsNamespace {
|
|
15451
15898
|
deps;
|
|
15452
15899
|
rootKey = null;
|
|
@@ -15456,8 +15903,25 @@ class VfsNamespace {
|
|
|
15456
15903
|
* The file tree re-lists constantly while typing, and every miss is a
|
|
15457
15904
|
* network round trip plus a decrypt. Invalidated precisely on write rather
|
|
15458
15905
|
* than by TTL — a stale directory listing shows files that are not there.
|
|
15906
|
+
*
|
|
15907
|
+
* What a cached read resolves to is also THE copy of that record: the one
|
|
15908
|
+
* every edit of it is made to, and the one written. See `mutate`.
|
|
15459
15909
|
*/
|
|
15460
15910
|
cache = new Map();
|
|
15911
|
+
/**
|
|
15912
|
+
* Bumped by `clearCache`, which is for a change of identity.
|
|
15913
|
+
*
|
|
15914
|
+
* A read or write begun before it can finish after it, and what it read
|
|
15915
|
+
* or wrote belongs to whoever was signed in before. So every record
|
|
15916
|
+
* carries the generation it was read under, and none goes into the cache,
|
|
15917
|
+
* or is edited as the cache's copy, under another.
|
|
15918
|
+
*/
|
|
15919
|
+
generation = 0;
|
|
15920
|
+
/**
|
|
15921
|
+
* The record each promise in `cache` resolved to, and each write in
|
|
15922
|
+
* `writing` is sending: what `editable` finds without waiting.
|
|
15923
|
+
*/
|
|
15924
|
+
copyOf = new WeakMap();
|
|
15461
15925
|
/**
|
|
15462
15926
|
* The write of each directory record still in flight, by id.
|
|
15463
15927
|
*
|
|
@@ -15467,9 +15931,86 @@ class VfsNamespace {
|
|
|
15467
15931
|
* that is often the one sent first. Then the earlier write, sealed before
|
|
15468
15932
|
* the later edit was made, undoes it. That is how a file deleted forever
|
|
15469
15933
|
* 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
|
|
15934
|
+
* written, came back. So each write waits for the one before it, and a
|
|
15935
|
+
* read that has to fetch the record waits for its write too: see
|
|
15936
|
+
* `openDirById`.
|
|
15471
15937
|
*/
|
|
15472
15938
|
writing = new Map();
|
|
15939
|
+
/**
|
|
15940
|
+
* Every entry this namespace is moving right now, by id.
|
|
15941
|
+
*
|
|
15942
|
+
* A move between two directories marks the entry before it puts it in
|
|
15943
|
+
* the second (see `rename`), and a mark is all a later operation can
|
|
15944
|
+
* see. It cannot tell a move still writing from one that was
|
|
15945
|
+
* interrupted, and was taken for the second: a delete that began after
|
|
15946
|
+
* a move had marked a folder looked for the folder's other half, found
|
|
15947
|
+
* none yet, and purged it while the move was putting it in its new
|
|
15948
|
+
* place; a second move of a file finished the "interrupted" move and
|
|
15949
|
+
* left it named in two places. So a move holds its entry here until it
|
|
15950
|
+
* has taken it from where it was, and `rename` and `delete` refuse an
|
|
15951
|
+
* entry that is here - or, for a delete, a folder holding one. A delete
|
|
15952
|
+
* holds what it deletes the same way: see `deleting`.
|
|
15953
|
+
*
|
|
15954
|
+
* This set is one namespace's. With a coordinator, a move also holds
|
|
15955
|
+
* the entry's move lock for every tab on the device, for as long as the
|
|
15956
|
+
* entry is here: see `VfsCoordinator.holdMoving`.
|
|
15957
|
+
*/
|
|
15958
|
+
inMotion = new Set();
|
|
15959
|
+
/**
|
|
15960
|
+
* What each delete under way in this namespace is deleting, by entry id,
|
|
15961
|
+
* one set per delete: its target from the moment it has found the target
|
|
15962
|
+
* not in `inMotion`, and everything it read below the target once it has
|
|
15963
|
+
* read it. Each delete lets go of its own set when it is done.
|
|
15964
|
+
*
|
|
15965
|
+
* A delete waits after it has read what it will let go of: on more
|
|
15966
|
+
* reads, and on a read of the whole tree for the other half of a move.
|
|
15967
|
+
* A move of any of it that started meanwhile could put it in its new
|
|
15968
|
+
* place, fail to take it from the old, and end, all before the delete's
|
|
15969
|
+
* detach. Ended, it was no longer in `inMotion`; and an entry that was
|
|
15970
|
+
* marked already showed no change. So the delete let go of what the
|
|
15971
|
+
* move had just named somewhere else. Now `rename` refuses an entry that
|
|
15972
|
+
* is here, or one on a way from the root that passes through a folder
|
|
15973
|
+
* that is: everything below a delete's target, read yet or not.
|
|
15974
|
+
*/
|
|
15975
|
+
deleting = new Set();
|
|
15976
|
+
/**
|
|
15977
|
+
* The write of each record that has not gone out yet, by id: the one
|
|
15978
|
+
* the next edit of that record joins. See `queue`.
|
|
15979
|
+
*/
|
|
15980
|
+
waiting = new Map();
|
|
15981
|
+
/**
|
|
15982
|
+
* Announcements heard from other tabs, counted. `heardAt` is the count
|
|
15983
|
+
* at the last one for each record, `missedAt` the count when some may
|
|
15984
|
+
* have been missed. A copy read after both holds every write announced:
|
|
15985
|
+
* see `current`.
|
|
15986
|
+
*/
|
|
15987
|
+
hearing = 0;
|
|
15988
|
+
missedAt = 0;
|
|
15989
|
+
heardAt = new Map();
|
|
15990
|
+
/**
|
|
15991
|
+
* Each record a write of which this namespace marked as begun and could
|
|
15992
|
+
* not count, and has said so, until one is counted. And whether it has
|
|
15993
|
+
* said that a write it could not mark or count may be lost, which it
|
|
15994
|
+
* says only once. See `notCounted`.
|
|
15995
|
+
*/
|
|
15996
|
+
uncounted = new Set();
|
|
15997
|
+
lossWarned = false;
|
|
15998
|
+
/**
|
|
15999
|
+
* The copy of each record this namespace is writing under the record's
|
|
16000
|
+
* lock, by id, from when it has decided what to write until it has
|
|
16001
|
+
* counted the write, or failed to. Its counter reads as unknown
|
|
16002
|
+
* meanwhile, as this write has begun and is not counted yet, but the
|
|
16003
|
+
* copy holds every write of the record another tab has counted, and no
|
|
16004
|
+
* other tab can make one until this one lets go of the lock. See
|
|
16005
|
+
* `dropStale`.
|
|
16006
|
+
*/
|
|
16007
|
+
locked = new Map();
|
|
16008
|
+
/**
|
|
16009
|
+
* Each directory a `createDir` here has named whose write has not landed
|
|
16010
|
+
* yet, by entry id: a promise that settles - and never rejects - once
|
|
16011
|
+
* that write has landed or failed. See `mkdir`.
|
|
16012
|
+
*/
|
|
16013
|
+
naming = new Map();
|
|
15473
16014
|
historyLimit;
|
|
15474
16015
|
maxEntries;
|
|
15475
16016
|
maxPathChars;
|
|
@@ -15478,6 +16019,8 @@ class VfsNamespace {
|
|
|
15478
16019
|
this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
15479
16020
|
this.maxEntries = deps.limits?.entries ?? DEFAULT_MAX_ENTRIES;
|
|
15480
16021
|
this.maxPathChars = deps.limits?.pathChars ?? DEFAULT_MAX_PATH_CHARS;
|
|
16022
|
+
// Another tab's write of a record leaves this tab's copy of it out of date: see `heard`.
|
|
16023
|
+
deps.coordinator?.onAnnounce((id) => this.heard(id));
|
|
15481
16024
|
}
|
|
15482
16025
|
/**
|
|
15483
16026
|
* The working directory relative paths resolve against.
|
|
@@ -15526,7 +16069,11 @@ class VfsNamespace {
|
|
|
15526
16069
|
* identity's directories to another would be the worst possible bug here.
|
|
15527
16070
|
*/
|
|
15528
16071
|
clearCache() {
|
|
16072
|
+
// Whatever is still being read or written for the identity before
|
|
16073
|
+
// stays out of the cache from here on: see `generation`.
|
|
16074
|
+
this.generation++;
|
|
15529
16075
|
this.cache.clear();
|
|
16076
|
+
this.heardAt.clear();
|
|
15530
16077
|
this.rootKey = null;
|
|
15531
16078
|
this._cwd = "/"; // a different identity's tree has different directories
|
|
15532
16079
|
}
|
|
@@ -15831,21 +16378,26 @@ class VfsNamespace {
|
|
|
15831
16378
|
assertValidName(name);
|
|
15832
16379
|
await this.mkdir(dirname(target), { recursive: true });
|
|
15833
16380
|
const parent = await this.openDir(dirname(target));
|
|
15834
|
-
|
|
15835
|
-
if (prior?.kind === "dir")
|
|
16381
|
+
if (entryIn(parent.node, name)?.kind === "dir")
|
|
15836
16382
|
throw new VfsConflictError(`${target} is a directory`);
|
|
15837
16383
|
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
|
-
};
|
|
16384
|
+
let entry;
|
|
15848
16385
|
await this.mutate(parent, (node) => {
|
|
16386
|
+
// Whatever is at the name now, after the wait above, is what
|
|
16387
|
+
// this replaces - and what it takes the id and history from.
|
|
16388
|
+
const prior = entryIn(node, name);
|
|
16389
|
+
if (prior?.kind === "dir")
|
|
16390
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
16391
|
+
entry = {
|
|
16392
|
+
kind: "file",
|
|
16393
|
+
id: prior?.id ?? newId(),
|
|
16394
|
+
manifest,
|
|
16395
|
+
size,
|
|
16396
|
+
mtime: Date.now(),
|
|
16397
|
+
versions: asCount(prior?.versions),
|
|
16398
|
+
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16399
|
+
...(prior?.moving === true ? { moving: true } : {}),
|
|
16400
|
+
};
|
|
15849
16401
|
node.entries[name] = entry;
|
|
15850
16402
|
});
|
|
15851
16403
|
return toStat(target, name, entry);
|
|
@@ -15869,8 +16421,22 @@ class VfsNamespace {
|
|
|
15869
16421
|
const name = parts[i];
|
|
15870
16422
|
const isLast = i === parts.length - 1;
|
|
15871
16423
|
const next = join(current, name);
|
|
15872
|
-
|
|
15873
|
-
|
|
16424
|
+
let dir = await this.openDir(current);
|
|
16425
|
+
let existing = entryIn(dir.node, name);
|
|
16426
|
+
// Named by a mkdir here whose write has not landed. Until it has,
|
|
16427
|
+
// the name is a promise, not a fact: with a coordinator, that edit
|
|
16428
|
+
// is made again under the lock to the folder as another tab left
|
|
16429
|
+
// it, and comes to nothing when that tab named a directory there
|
|
16430
|
+
// first - and whatever was saved meanwhile into the one named
|
|
16431
|
+
// here is in a directory nothing names. A failed write drops it
|
|
16432
|
+
// too. So wait for the write, and look again.
|
|
16433
|
+
let unwritten = existing && this.naming.get(existing.id);
|
|
16434
|
+
while (unwritten) {
|
|
16435
|
+
await unwritten;
|
|
16436
|
+
dir = await this.openDir(current);
|
|
16437
|
+
existing = entryIn(dir.node, name);
|
|
16438
|
+
unwritten = existing && this.naming.get(existing.id);
|
|
16439
|
+
}
|
|
15874
16440
|
if (!existing)
|
|
15875
16441
|
assertNotHidden(dir, name, next);
|
|
15876
16442
|
if (existing) {
|
|
@@ -15882,7 +16448,7 @@ class VfsNamespace {
|
|
|
15882
16448
|
else {
|
|
15883
16449
|
if (!isLast && !opts.recursive)
|
|
15884
16450
|
throw new VfsNotFoundError(current);
|
|
15885
|
-
await this.createDir(current, name);
|
|
16451
|
+
await this.createDir(current, name, next, opts.recursive === true);
|
|
15886
16452
|
}
|
|
15887
16453
|
current = next;
|
|
15888
16454
|
}
|
|
@@ -15942,12 +16508,22 @@ class VfsNamespace {
|
|
|
15942
16508
|
let versions = asCount(prior?.versions);
|
|
15943
16509
|
if (prior)
|
|
15944
16510
|
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
|
-
};
|
|
16511
|
+
let entry;
|
|
15950
16512
|
await this.mutate(parent, (node) => {
|
|
16513
|
+
// The version just pushed onto the history was `prior`. If
|
|
16514
|
+
// another write has replaced or removed it since, it is not the
|
|
16515
|
+
// version this one overwrites, and writing on regardless would
|
|
16516
|
+
// lose that write - from the file, and from its history.
|
|
16517
|
+
const now = entryIn(node, name);
|
|
16518
|
+
if (now?.kind === "dir")
|
|
16519
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
16520
|
+
if (!sameEntry(prior, now))
|
|
16521
|
+
throw changedMeanwhile(target);
|
|
16522
|
+
entry = {
|
|
16523
|
+
kind: "file", id, manifest, size, mtime: Date.now(), versions,
|
|
16524
|
+
// Still half of an interrupted move, if it was: see DirEntry.moving.
|
|
16525
|
+
...(now?.moving === true ? { moving: true } : {}),
|
|
16526
|
+
};
|
|
15951
16527
|
node.entries[name] = entry;
|
|
15952
16528
|
});
|
|
15953
16529
|
return toStat(target, name, entry);
|
|
@@ -15982,83 +16558,223 @@ class VfsNamespace {
|
|
|
15982
16558
|
const where = printable(target, 1024);
|
|
15983
16559
|
if (!opts.recursive)
|
|
15984
16560
|
throw new VfsConflictError(`${where} cannot be read by this version; a recursive delete removes it`);
|
|
15985
|
-
await this.mutate(parent, () => void delete
|
|
16561
|
+
await this.mutate(parent, (_node, record) => void delete record.hidden[name]);
|
|
15986
16562
|
this.warn(`removed ${where} without clearing out what it held: this version cannot read it`);
|
|
15987
16563
|
return;
|
|
15988
16564
|
}
|
|
15989
|
-
//
|
|
15990
|
-
//
|
|
15991
|
-
|
|
15992
|
-
|
|
15993
|
-
|
|
15994
|
-
//
|
|
15995
|
-
//
|
|
15996
|
-
|
|
15997
|
-
|
|
15998
|
-
//
|
|
15999
|
-
//
|
|
16000
|
-
|
|
16001
|
-
|
|
16002
|
-
|
|
16003
|
-
|
|
16004
|
-
|
|
16005
|
-
|
|
16006
|
-
|
|
16007
|
-
//
|
|
16008
|
-
//
|
|
16009
|
-
//
|
|
16010
|
-
//
|
|
16011
|
-
|
|
16012
|
-
|
|
16013
|
-
|
|
16014
|
-
|
|
16015
|
-
|
|
16016
|
-
|
|
16017
|
-
|
|
16018
|
-
|
|
16019
|
-
|
|
16020
|
-
|
|
16021
|
-
|
|
16022
|
-
|
|
16023
|
-
|
|
16024
|
-
|
|
16025
|
-
|
|
16026
|
-
|
|
16027
|
-
|
|
16028
|
-
//
|
|
16029
|
-
//
|
|
16030
|
-
|
|
16031
|
-
if (!empty && !opts.recursive)
|
|
16565
|
+
// Being moved by this namespace: it is marked already, or about to be,
|
|
16566
|
+
// and on its way to a second place. Letting go of it here would let
|
|
16567
|
+
// go of what the move puts there. See `inMotion`.
|
|
16568
|
+
if (this.inMotion.has(entry.id))
|
|
16569
|
+
throw beingMoved(target);
|
|
16570
|
+
// And from here until it is done, a move of it, or of anything below
|
|
16571
|
+
// it, that begins in this namespace is refused. See `deleting`.
|
|
16572
|
+
const claim = new Set([entry.id]);
|
|
16573
|
+
this.deleting.add(claim);
|
|
16574
|
+
// On this device, the move locks of the target and of everything read
|
|
16575
|
+
// below it. See below.
|
|
16576
|
+
let release = null;
|
|
16577
|
+
try {
|
|
16578
|
+
// Every directory above the target, the root first. None of them is
|
|
16579
|
+
// part of what is being deleted, whatever an entry below says.
|
|
16580
|
+
const above = new Set(opened.chain);
|
|
16581
|
+
// Three steps, in this order: read everything the delete will touch,
|
|
16582
|
+
// detach the entry, then let go of what it held.
|
|
16583
|
+
//
|
|
16584
|
+
// Letting go came first once, and the entry was detached last. Any
|
|
16585
|
+
// failure in between - a network error, most simply - left the entry
|
|
16586
|
+
// in place with some of its content already released, and the obvious
|
|
16587
|
+
// next step, trying again, released that content a SECOND time.
|
|
16588
|
+
// Content is reference-counted and shared by copies, so that freed
|
|
16589
|
+
// bytes another file still used. Now a failure before the detach has
|
|
16590
|
+
// changed nothing, and nothing after it can fail: releasing is
|
|
16591
|
+
// best-effort, and a leak is the safe way to be wrong.
|
|
16592
|
+
let doomed = [];
|
|
16593
|
+
// What each of them named when it was read, for the check before the detach.
|
|
16594
|
+
let shape = new Map();
|
|
16595
|
+
// Every id found below the target: claimed once it has all been read,
|
|
16596
|
+
// re-checked against `inMotion` at the detach and, with a coordinator,
|
|
16597
|
+
// whose move locks it holds. See below.
|
|
16598
|
+
let ids = new Set();
|
|
16599
|
+
// Whether any of it is half of an interrupted move. See below.
|
|
16600
|
+
let moving = entry.kind === "file" && entry.moving === true;
|
|
16601
|
+
if (entry.kind === "dir" && above.has(entry.id)) {
|
|
16602
|
+
// The entry IS a pointer to a directory above it - the root, or
|
|
16603
|
+
// this entry's own parent. Removing the pointer is what was asked
|
|
16604
|
+
// for; clearing out what it points at would delete everything
|
|
16605
|
+
// around the target, the target included.
|
|
16606
|
+
if (!opts.recursive)
|
|
16032
16607
|
throw new VfsConflictError(`${target} is not empty`);
|
|
16033
|
-
|
|
16034
|
-
|
|
16035
|
-
|
|
16608
|
+
this.warn(`detaching ${target} without clearing it out: it is a directory above it in the tree`);
|
|
16609
|
+
}
|
|
16610
|
+
else if (entry.kind === "dir") {
|
|
16611
|
+
const child = await this.openDirById(entry.id, fromBase64(entry.key), target).catch((err) => {
|
|
16612
|
+
// An unreadable directory must still be removable, or the user
|
|
16613
|
+
// is stuck with an error they can do nothing about. Its subtree
|
|
16614
|
+
// cannot be enumerated, so it is detached rather than purged and
|
|
16615
|
+
// whatever it holds is left for sweep(). Only when asked for
|
|
16616
|
+
// recursively: without that we cannot know it is empty.
|
|
16617
|
+
if (!(err instanceof VfsCorruptRecordError) || !opts.recursive)
|
|
16618
|
+
throw err;
|
|
16619
|
+
this.warn(`detaching ${target} without clearing it out: its record cannot be read`);
|
|
16620
|
+
return null;
|
|
16621
|
+
});
|
|
16622
|
+
if (child) {
|
|
16623
|
+
// Entries this version cannot read still count: calling the
|
|
16624
|
+
// directory empty would delete them along with it.
|
|
16625
|
+
const empty = !Object.keys(child.node.entries).length && !Object.keys(child.hidden).length;
|
|
16626
|
+
if (!empty && !opts.recursive)
|
|
16627
|
+
throw new VfsConflictError(`${target} is not empty`);
|
|
16628
|
+
const found = await this.subtree(child, target, above);
|
|
16629
|
+
// Something in it is being moved by this namespace: the same
|
|
16630
|
+
// as the target being moved, a level down.
|
|
16631
|
+
for (const id of found.ids)
|
|
16632
|
+
if (this.inMotion.has(id))
|
|
16633
|
+
throw beingMoved(target, true);
|
|
16634
|
+
for (const id of found.ids)
|
|
16635
|
+
claim.add(id);
|
|
16636
|
+
doomed = found.dirs;
|
|
16637
|
+
shape = found.shape;
|
|
16638
|
+
ids = found.ids;
|
|
16639
|
+
moving = entry.moving === true || found.moving;
|
|
16640
|
+
}
|
|
16641
|
+
}
|
|
16642
|
+
// On this device, the move lock of the target and of everything read
|
|
16643
|
+
// below it, held from here until what they held has been let go of.
|
|
16644
|
+
// A move in another tab holds the lock of what it moves until it has
|
|
16645
|
+
// taken it from where it was. A delete that found it marked looked
|
|
16646
|
+
// for its other half before the move had put it there, found none,
|
|
16647
|
+
// and let go of what the move then named in its new place; one that
|
|
16648
|
+
// was writing the parent let a move of something below start, and
|
|
16649
|
+
// then purged it. Held here, a move of any of it that is under way
|
|
16650
|
+
// refuses this delete, and none can start until it is done. What it
|
|
16651
|
+
// does not cover - anything named below since it was read - the check
|
|
16652
|
+
// in the edit refuses. See `VfsCoordinator.holdMoving`.
|
|
16653
|
+
//
|
|
16654
|
+
// Taken before the look for the other half of a move below, not
|
|
16655
|
+
// after it: a move in another tab that began during that look could
|
|
16656
|
+
// put the entry in its new place, fail to take it from the old, and
|
|
16657
|
+
// let go of its lock, all before this took it - and what it named
|
|
16658
|
+
// was let go of here. Not before the reads above, which find what is
|
|
16659
|
+
// below. A move in another tab that begins during them still holds
|
|
16660
|
+
// its lock here if it is under way, and this delete is refused;
|
|
16661
|
+
// ended, it came before the look and the checks below, which read
|
|
16662
|
+
// again what it wrote. See below.
|
|
16663
|
+
const coordinator = this.deps.coordinator;
|
|
16664
|
+
// What the look and the checks below must read again.
|
|
16665
|
+
let stale = new Set();
|
|
16666
|
+
if (coordinator) {
|
|
16667
|
+
release = await this.holdAll(coordinator, [entry.id, ...ids]);
|
|
16668
|
+
if (!release)
|
|
16669
|
+
throw heldElsewhere(target, ids.size > 0);
|
|
16670
|
+
// A move that let go of its lock just before this took it had
|
|
16671
|
+
// counted its writes by then, though this tab may not have heard
|
|
16672
|
+
// of them: what was read above that another tab has written
|
|
16673
|
+
// since is read again - every record cached, for the look, which
|
|
16674
|
+
// reads the whole tree. A move that took a file out of a folder
|
|
16675
|
+
// read above, from this tab's old copy, had that file let go of
|
|
16676
|
+
// with the folder; one that went from the other half of a move
|
|
16677
|
+
// to a folder this tab's old copy did not show it in had the
|
|
16678
|
+
// content it took with it let go of. See `dropStale`.
|
|
16679
|
+
//
|
|
16680
|
+
// And the way to the target, looked up before anything else.
|
|
16681
|
+
// A delete in another tab of a folder on it had let go of the
|
|
16682
|
+
// target's lock, which it held, only once it had counted its
|
|
16683
|
+
// write of the folder above the one it deleted - but it purged
|
|
16684
|
+
// that one, and all below, which counts nothing. Gone on from
|
|
16685
|
+
// the old copies, this wrote the purged parent back and let go
|
|
16686
|
+
// of what the target held a second time, while a copy of it, or
|
|
16687
|
+
// a commit, still named that content. So what on the way has
|
|
16688
|
+
// been written since it was read is read again, and the way is
|
|
16689
|
+
// looked up again, as a move's way to where it goes is: it must
|
|
16690
|
+
// lead to the same entry, through the same folders, not marked
|
|
16691
|
+
// since.
|
|
16692
|
+
//
|
|
16693
|
+
// Looked up again even when nothing on it is out of date now:
|
|
16694
|
+
// a write of this tab's own may have read a folder on it again,
|
|
16695
|
+
// under that folder's lock, after the look above, and counted
|
|
16696
|
+
// its write. That copy is current by now, and no longer what the
|
|
16697
|
+
// look saw.
|
|
16698
|
+
const read = doomed.map((dir) => dir.id);
|
|
16699
|
+
stale = await this.dropStale(coordinator, [...opened.chain, ...(moving ? this.cache.keys() : []), ...read]);
|
|
16700
|
+
const again = await this.openChain(dirname(target));
|
|
16701
|
+
if (!again)
|
|
16702
|
+
throw new VfsNotFoundError(dirname(target));
|
|
16703
|
+
if (again.chain.some((id, i) => id !== opened.chain[i]))
|
|
16704
|
+
throw changedMeanwhile(dirname(target));
|
|
16705
|
+
const now = entryIn(again.dir.node, name);
|
|
16706
|
+
if (!now)
|
|
16707
|
+
throw new VfsNotFoundError(target);
|
|
16708
|
+
if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16709
|
+
throw changedMeanwhile(target);
|
|
16036
16710
|
}
|
|
16711
|
+
// A move between two directories that was interrupted leaves what it
|
|
16712
|
+
// moved named in both, and letting go of it here would take it from
|
|
16713
|
+
// the other copy: the directory record purged with every file in it,
|
|
16714
|
+
// or a file's content and history released. Only a marked entry can
|
|
16715
|
+
// be one of those (see `rename`), so only then is the whole tree
|
|
16716
|
+
// read, once, to find out what is still named elsewhere. Still before
|
|
16717
|
+
// anything changes: a failure here leaves the tree as it was.
|
|
16718
|
+
const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
|
|
16719
|
+
// The check in the edit below looks at each record to be purged as it
|
|
16720
|
+
// is then, which needs it to hand: a change reported meanwhile may
|
|
16721
|
+
// have dropped one from the cache, and so may the counters above.
|
|
16722
|
+
// Read again, it is still checked against what was read before.
|
|
16723
|
+
for (const dir of doomed)
|
|
16724
|
+
if (stale.has(dir.id) || !this.editable(dir))
|
|
16725
|
+
await this.reopen(dir);
|
|
16726
|
+
let purging = doomed;
|
|
16727
|
+
await this.mutate(parent, (node) => {
|
|
16728
|
+
// What is let go of below is what `entry` held. Gone since, it
|
|
16729
|
+
// was let go of already; replaced, the name holds something this
|
|
16730
|
+
// delete has not read. Marked since, a move of it has begun, and
|
|
16731
|
+
// what it holds is about to be named somewhere `elsewhere` did
|
|
16732
|
+
// not see - if it was looked for at all.
|
|
16733
|
+
const now = entryIn(node, name);
|
|
16734
|
+
if (!now)
|
|
16735
|
+
throw new VfsNotFoundError(target);
|
|
16736
|
+
if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16737
|
+
throw changedMeanwhile(target);
|
|
16738
|
+
// Marked already when this delete read it, a move that has since
|
|
16739
|
+
// taken the entry - or something below it - would not mark it a
|
|
16740
|
+
// second time, so `markedMeanwhile` and `currentSubtree` would not
|
|
16741
|
+
// see it either. `inMotion` is live, not a snapshot, so it still
|
|
16742
|
+
// does: re-checked here, at the last moment before the detach.
|
|
16743
|
+
// Since `deleting`, a move here cannot begin on any of it once
|
|
16744
|
+
// this delete has claimed it, so these are a second guard.
|
|
16745
|
+
if (this.inMotion.has(entry.id))
|
|
16746
|
+
throw beingMoved(target);
|
|
16747
|
+
for (const id of ids)
|
|
16748
|
+
if (this.inMotion.has(id))
|
|
16749
|
+
throw beingMoved(target, true);
|
|
16750
|
+
// And what is purged is what the records below hold now - unless
|
|
16751
|
+
// something in them has been moved, or begun to move, since they
|
|
16752
|
+
// were read; or, with a coordinator, named there since, as its
|
|
16753
|
+
// move lock is not held. See `currentSubtree`.
|
|
16754
|
+
const current = this.currentSubtree(doomed, shape, coordinator ? ids : undefined);
|
|
16755
|
+
if (!current)
|
|
16756
|
+
throw changedMeanwhile(target);
|
|
16757
|
+
purging = current;
|
|
16758
|
+
delete node.entries[name];
|
|
16759
|
+
});
|
|
16760
|
+
if (elsewhere === null) {
|
|
16761
|
+
// Part of the tree cannot be read, so it is unknown whether the
|
|
16762
|
+
// other copy is in it. Keeping what may be its contents costs
|
|
16763
|
+
// storage until a sweep; letting go of them could lose them.
|
|
16764
|
+
this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
|
|
16765
|
+
return;
|
|
16766
|
+
}
|
|
16767
|
+
if (entry.kind === "dir")
|
|
16768
|
+
await this.purge(purging, elsewhere, opts.keepContent);
|
|
16769
|
+
else
|
|
16770
|
+
await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
|
|
16771
|
+
if (elsewhere && entry.moving === true)
|
|
16772
|
+
await this.settle(entry.id, elsewhere);
|
|
16037
16773
|
}
|
|
16038
|
-
|
|
16039
|
-
|
|
16040
|
-
|
|
16041
|
-
// or a file's content and history released. Only a marked entry can
|
|
16042
|
-
// be one of those (see `rename`), so only then is the whole tree
|
|
16043
|
-
// read, once, to find out what is still named elsewhere. Still before
|
|
16044
|
-
// anything changes: a failure here leaves the tree as it was.
|
|
16045
|
-
const elsewhere = moving ? await this.namedElsewhere(parent.id, name) : undefined;
|
|
16046
|
-
await this.mutate(parent, (node) => {
|
|
16047
|
-
delete node.entries[name];
|
|
16048
|
-
});
|
|
16049
|
-
if (elsewhere === null) {
|
|
16050
|
-
// Part of the tree cannot be read, so it is unknown whether the
|
|
16051
|
-
// other copy is in it. Keeping what may be its contents costs
|
|
16052
|
-
// storage until a sweep; letting go of them could lose them.
|
|
16053
|
-
this.warn(`detached ${target} but kept what it held: a move of it was interrupted, and part of the tree cannot be read to find the other copy`);
|
|
16054
|
-
return;
|
|
16774
|
+
finally {
|
|
16775
|
+
release?.();
|
|
16776
|
+
this.deleting.delete(claim);
|
|
16055
16777
|
}
|
|
16056
|
-
if (entry.kind === "dir")
|
|
16057
|
-
await this.purge(doomed, elsewhere, opts.keepContent);
|
|
16058
|
-
else
|
|
16059
|
-
await this.letGo(new Map([[entry.id, [{ dir: parent, entry }]]]), elsewhere, opts.keepContent);
|
|
16060
|
-
if (elsewhere && entry.moving === true)
|
|
16061
|
-
await this.settle(entry.id, elsewhere);
|
|
16062
16778
|
}
|
|
16063
16779
|
/**
|
|
16064
16780
|
* Move or rename. Works across directories, which is the same operation:
|
|
@@ -16081,47 +16797,216 @@ class VfsNamespace {
|
|
|
16081
16797
|
const srcName = basename(src);
|
|
16082
16798
|
const dstName = basename(dst);
|
|
16083
16799
|
assertValidName(dstName);
|
|
16084
|
-
const
|
|
16800
|
+
const source = await this.openChain(dirname(src));
|
|
16801
|
+
if (!source)
|
|
16802
|
+
throw new VfsNotFoundError(dirname(src));
|
|
16803
|
+
const srcParent = source.dir;
|
|
16085
16804
|
const entry = entryIn(srcParent.node, srcName);
|
|
16086
16805
|
if (!entry)
|
|
16087
16806
|
throw new VfsNotFoundError(src);
|
|
16088
|
-
|
|
16089
|
-
|
|
16090
|
-
|
|
16091
|
-
|
|
16092
|
-
|
|
16093
|
-
|
|
16094
|
-
|
|
16095
|
-
|
|
16096
|
-
|
|
16097
|
-
|
|
16807
|
+
// Another move of it is under way here. Its mark cannot tell that
|
|
16808
|
+
// move from an interrupted one, and finishing it as one named the
|
|
16809
|
+
// entry in two places. Held from here, before the first wait, so two
|
|
16810
|
+
// moves begun together cannot both get past this. See `inMotion`.
|
|
16811
|
+
if (this.inMotion.has(entry.id))
|
|
16812
|
+
throw beingMoved(src);
|
|
16813
|
+
// A delete here has begun on it, or on a folder on its way from the
|
|
16814
|
+
// root. Moved now, it could be put in its new place just before the
|
|
16815
|
+
// delete let go of what it holds. Not the root, which no delete is
|
|
16816
|
+
// of: one claims it only through an entry below that names it, and
|
|
16817
|
+
// lets go of nothing of it. In the same step as the check above, as
|
|
16818
|
+
// a delete makes its claim in the same step as its own. See
|
|
16819
|
+
// `deleting`.
|
|
16820
|
+
const lineage = [entry.id, ...source.chain.slice(1)];
|
|
16821
|
+
if ([...this.deleting].some((claim) => lineage.some((id) => claim.has(id))))
|
|
16822
|
+
throw heldElsewhere(src);
|
|
16823
|
+
this.inMotion.add(entry.id);
|
|
16098
16824
|
// Each record is re-opened before it is written: creating the
|
|
16099
|
-
// destination's parents
|
|
16825
|
+
// destination's parents below may have written to the source's.
|
|
16100
16826
|
const edit = (path, change) => this.openDir(path).then((dir) => this.mutate(dir, change));
|
|
16101
|
-
|
|
16102
|
-
|
|
16827
|
+
let unmarkLanded = false;
|
|
16828
|
+
// The same for every tab of the app on this device: the entry's move
|
|
16829
|
+
// lock, held for as long as it is in `inMotion`. Another tab's move
|
|
16830
|
+
// or delete of it holds it, and taking that move's mark for an
|
|
16831
|
+
// interrupted one named the entry in two places, or purged it. See
|
|
16832
|
+
// `VfsCoordinator.holdMoving`.
|
|
16833
|
+
let release = null;
|
|
16834
|
+
// And the locks of the folders on the way to where it goes, shared,
|
|
16835
|
+
// for as long. See below.
|
|
16836
|
+
let into = null;
|
|
16837
|
+
try {
|
|
16838
|
+
const coordinator = this.deps.coordinator;
|
|
16839
|
+
if (coordinator) {
|
|
16840
|
+
release = await coordinator.holdMoving(entry.id);
|
|
16841
|
+
if (!release)
|
|
16842
|
+
throw heldElsewhere(src);
|
|
16843
|
+
// The look above was at this tab's copies of the folders on the
|
|
16844
|
+
// way to the entry. A delete in another tab of the entry, or of
|
|
16845
|
+
// a folder on that way, may have finished before this took the
|
|
16846
|
+
// lock, which the delete held: it had counted its writes by
|
|
16847
|
+
// then, though this tab may not have heard of them. Moved
|
|
16848
|
+
// regardless, the entry was marked in a record the delete had
|
|
16849
|
+
// purged, and named in its new place with its content let go
|
|
16850
|
+
// of. So whatever on the way another tab has written since it
|
|
16851
|
+
// was read is read again, and the entry looked for again.
|
|
16852
|
+
if ((await this.dropStale(coordinator, source.chain)).size) {
|
|
16853
|
+
const again = await this.openChain(dirname(src));
|
|
16854
|
+
if (!again)
|
|
16855
|
+
throw new VfsNotFoundError(dirname(src));
|
|
16856
|
+
if (again.chain.some((id, i) => id !== source.chain[i]))
|
|
16857
|
+
throw changedMeanwhile(dirname(src));
|
|
16858
|
+
const now = entryIn(again.dir.node, srcName);
|
|
16859
|
+
if (!now)
|
|
16860
|
+
throw new VfsNotFoundError(src);
|
|
16861
|
+
if (!sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16862
|
+
throw changedMeanwhile(src);
|
|
16863
|
+
}
|
|
16864
|
+
}
|
|
16865
|
+
if (entry.kind === "dir") {
|
|
16866
|
+
assertWritableDepth(dst);
|
|
16867
|
+
if (segments(dst).length > segments(src).length)
|
|
16868
|
+
await this.assertFitsAt(src, dst);
|
|
16869
|
+
}
|
|
16870
|
+
await this.mkdir(dirname(dst), { recursive: true });
|
|
16871
|
+
const way = await this.openChain(dirname(dst));
|
|
16872
|
+
if (!way)
|
|
16873
|
+
throw new VfsNotFoundError(dirname(dst));
|
|
16874
|
+
let dstParent = way.dir;
|
|
16875
|
+
if (coordinator && dstParent.id !== ROOT_ID) {
|
|
16876
|
+
// The folder it goes into, and every folder above that one
|
|
16877
|
+
// but the root, held shared: other moves into any of them
|
|
16878
|
+
// share them, and a delete of one - which holds the lock of
|
|
16879
|
+
// its target and of everything it read below - holds it
|
|
16880
|
+
// alone. A move into a folder that another tab was deleting
|
|
16881
|
+
// put what it moved in a record the delete then purged: in no
|
|
16882
|
+
// listing, its content kept. Not that folder alone: it may be
|
|
16883
|
+
// new since the delete read the tree - made by the `mkdir`
|
|
16884
|
+
// above, or by another tab - and the delete does not hold a
|
|
16885
|
+
// lock it did not read. Its target's is on the way here. The
|
|
16886
|
+
// root is never deleted.
|
|
16887
|
+
into = await this.holdAll(coordinator, way.chain.slice(1), true);
|
|
16888
|
+
if (!into)
|
|
16889
|
+
throw heldElsewhere(dirname(dst));
|
|
16890
|
+
// A delete of one of them may have finished since they were
|
|
16891
|
+
// looked up, and counted its write of the folder above before
|
|
16892
|
+
// it let go of their locks, though this tab may not have
|
|
16893
|
+
// heard of it: read again whatever on the way another tab has
|
|
16894
|
+
// written since it was read, and look again. Gone, or another
|
|
16895
|
+
// folder anywhere on the way, the move stops here, before its
|
|
16896
|
+
// mark.
|
|
16897
|
+
await this.dropStale(coordinator, way.chain);
|
|
16898
|
+
const again = await this.openChain(dirname(dst));
|
|
16899
|
+
if (!again)
|
|
16900
|
+
throw new VfsNotFoundError(dirname(dst));
|
|
16901
|
+
if (again.chain.some((id, i) => id !== way.chain[i]))
|
|
16902
|
+
throw changedMeanwhile(dirname(dst));
|
|
16903
|
+
dstParent = again.dir;
|
|
16904
|
+
}
|
|
16905
|
+
if (entryIn(dstParent.node, dstName))
|
|
16906
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
16907
|
+
assertNotHidden(dstParent, dstName, dst);
|
|
16908
|
+
// Both ends were looked at before the waits above, and another write
|
|
16909
|
+
// may have changed either since: replaced the entry being moved, or
|
|
16910
|
+
// put something at the destination. Moving on regardless would write
|
|
16911
|
+
// the old entry over the new one, or this one over the other. Or
|
|
16912
|
+
// marked the entry: another move of it has begun. Taking it as well
|
|
16913
|
+
// would name it in two places, and each move would unmark its own.
|
|
16914
|
+
const take = (node) => {
|
|
16915
|
+
const now = entryIn(node, srcName);
|
|
16916
|
+
if (!now || !sameEntry(entry, now) || markedMeanwhile(entry, now))
|
|
16917
|
+
throw changedMeanwhile(src);
|
|
16918
|
+
return now;
|
|
16919
|
+
};
|
|
16920
|
+
const free = (node, record) => {
|
|
16921
|
+
if (entryIn(node, dstName))
|
|
16922
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
16923
|
+
assertNotHidden(record, dstName, dst);
|
|
16924
|
+
};
|
|
16925
|
+
if (dstParent.id === srcParent.id) {
|
|
16926
|
+
// One record, so one write: the move happens entirely or not at all.
|
|
16927
|
+
await edit(dirname(src), (node, record) => {
|
|
16928
|
+
const moved = take(node);
|
|
16929
|
+
free(node, record);
|
|
16930
|
+
delete node.entries[srcName];
|
|
16931
|
+
node.entries[dstName] = { ...moved, mtime: Date.now() };
|
|
16932
|
+
});
|
|
16933
|
+
return;
|
|
16934
|
+
}
|
|
16935
|
+
// Two records, attached before detaching: interrupted, the entry is
|
|
16936
|
+
// in both rather than in neither. Both copies are marked for as long
|
|
16937
|
+
// as that can be so, because they share everything below them, and a
|
|
16938
|
+
// delete of either would otherwise let go of the other's contents.
|
|
16939
|
+
// An entry already marked is half of an earlier interrupted move, and
|
|
16940
|
+
// keeps its mark.
|
|
16941
|
+
const marked = entry.moving === true;
|
|
16942
|
+
let moved = entry;
|
|
16943
|
+
if (!marked) {
|
|
16944
|
+
await edit(dirname(src), (node) => {
|
|
16945
|
+
moved = take(node);
|
|
16946
|
+
node.entries[srcName] = { ...moved, moving: true };
|
|
16947
|
+
});
|
|
16948
|
+
}
|
|
16949
|
+
// Whether the edit below has put the entry at the destination: set
|
|
16950
|
+
// as its last step, after every check, each time it runs.
|
|
16951
|
+
let attached = false;
|
|
16952
|
+
try {
|
|
16953
|
+
await edit(dirname(dst), (node, record) => {
|
|
16954
|
+
attached = false;
|
|
16955
|
+
free(node, record);
|
|
16956
|
+
node.entries[dstName] = { ...moved, moving: true, mtime: Date.now() };
|
|
16957
|
+
attached = true;
|
|
16958
|
+
});
|
|
16959
|
+
}
|
|
16960
|
+
catch (err) {
|
|
16961
|
+
// Refused before it put anything at the destination - another
|
|
16962
|
+
// write took the name first - so the entry is still in one
|
|
16963
|
+
// place only, and the mark this move put on it would stay for
|
|
16964
|
+
// good: on a file, every version that falls off its history
|
|
16965
|
+
// kept; on either, every delete of it reading the whole tree.
|
|
16966
|
+
// Not when the write failed instead: the destination may hold
|
|
16967
|
+
// the entry by now, and the mark is what keeps a delete of
|
|
16968
|
+
// either copy from letting go of the other's. Best-effort.
|
|
16969
|
+
if (!marked && !attached) {
|
|
16970
|
+
await edit(dirname(src), (node) => {
|
|
16971
|
+
const now = entryIn(node, srcName);
|
|
16972
|
+
if (now?.id !== entry.id || now.moving !== true)
|
|
16973
|
+
return false;
|
|
16974
|
+
node.entries[srcName] = unmarked(now);
|
|
16975
|
+
return true;
|
|
16976
|
+
}).catch(() => { });
|
|
16977
|
+
}
|
|
16978
|
+
throw err;
|
|
16979
|
+
}
|
|
16980
|
+
// A file's history is sealed with its directory's key, and from here
|
|
16981
|
+
// on the directory that opens it is this one.
|
|
16982
|
+
if (entry.kind === "file")
|
|
16983
|
+
await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
|
|
16103
16984
|
await edit(dirname(src), (node) => {
|
|
16985
|
+
// Gone already leaves nothing to detach, and nothing to write.
|
|
16986
|
+
// Replaced, it is not this entry to take away: the move stops
|
|
16987
|
+
// here, in both places. Not `take`: the entry is marked by now,
|
|
16988
|
+
// by this move or the earlier one it finishes.
|
|
16989
|
+
const now = entryIn(node, srcName);
|
|
16990
|
+
if (!now)
|
|
16991
|
+
return false;
|
|
16992
|
+
if (!sameEntry(entry, now))
|
|
16993
|
+
throw changedMeanwhile(src);
|
|
16104
16994
|
delete node.entries[srcName];
|
|
16105
|
-
|
|
16995
|
+
// Explicit, as in `createDir` and `settle`: noImplicitReturns.
|
|
16996
|
+
return true;
|
|
16106
16997
|
});
|
|
16107
|
-
|
|
16998
|
+
unmarkLanded = !marked;
|
|
16108
16999
|
}
|
|
16109
|
-
|
|
16110
|
-
|
|
16111
|
-
|
|
16112
|
-
|
|
16113
|
-
|
|
16114
|
-
|
|
16115
|
-
|
|
16116
|
-
|
|
16117
|
-
|
|
16118
|
-
|
|
16119
|
-
// A file's history is sealed with its directory's key, and from here
|
|
16120
|
-
// on the directory that opens it is this one.
|
|
16121
|
-
if (entry.kind === "file")
|
|
16122
|
-
await this.rekeyHistory(entry.id, [srcParent.key], dstParent.key);
|
|
16123
|
-
await edit(dirname(src), (node) => void delete node.entries[srcName]);
|
|
16124
|
-
if (!marked) {
|
|
17000
|
+
finally {
|
|
17001
|
+
// Named in one place from here, the destination. A delete of it
|
|
17002
|
+
// there goes ahead as for an entry an interrupted move left marked
|
|
17003
|
+
// - which the Trash's "delete forever" relies on while the mark is
|
|
17004
|
+
// still being cleared.
|
|
17005
|
+
this.inMotion.delete(entry.id);
|
|
17006
|
+
release?.();
|
|
17007
|
+
into?.();
|
|
17008
|
+
}
|
|
17009
|
+
if (unmarkLanded) {
|
|
16125
17010
|
await edit(dirname(dst), (node) => {
|
|
16126
17011
|
const landed = entryIn(node, dstName);
|
|
16127
17012
|
if (landed?.id === entry.id)
|
|
@@ -16193,13 +17078,19 @@ class VfsNamespace {
|
|
|
16193
17078
|
// its first write, instead of after up to a whole budget's worth of
|
|
16194
17079
|
// records and references, all left unreachable. The records are
|
|
16195
17080
|
// cached by then, so the real pass costs writes, not fetches.
|
|
17081
|
+
const gen = this.generation;
|
|
16196
17082
|
const run = (write) => ({
|
|
16197
|
-
from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0,
|
|
17083
|
+
from: src, to: dst, write, entries: this.maxEntries, pathChars: this.maxPathChars, reached: new Map(), sinceTurn: 0, gen,
|
|
16198
17084
|
});
|
|
16199
17085
|
const depth = segments(dst).length;
|
|
16200
17086
|
await this.cloneEntry(found.entry, src, run(false), null, depth);
|
|
16201
17087
|
const copied = await this.cloneEntry(found.entry, src, run(true), null, depth);
|
|
16202
|
-
await this.mutate(dstParent, (node) => {
|
|
17088
|
+
await this.mutate(dstParent, (node, record) => {
|
|
17089
|
+
// Looked at before the copy was made, which takes as long as the
|
|
17090
|
+
// tree is big: another write may have taken the name since.
|
|
17091
|
+
if (entryIn(node, dstName))
|
|
17092
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
17093
|
+
assertNotHidden(record, dstName, dst);
|
|
16203
17094
|
node.entries[dstName] = copied;
|
|
16204
17095
|
});
|
|
16205
17096
|
return toStat(dst, dstName, copied);
|
|
@@ -16220,8 +17111,12 @@ class VfsNamespace {
|
|
|
16220
17111
|
* Delete metadata records no longer reachable from the root.
|
|
16221
17112
|
*
|
|
16222
17113
|
* These accumulate because every write touches exactly one record: if two
|
|
16223
|
-
*
|
|
16224
|
-
*
|
|
17114
|
+
* devices edit the same directory - or two tabs, where there is no
|
|
17115
|
+
* coordinator - the personal space keeps whichever put arrives last (a
|
|
17116
|
+
* VFS write sends no HLC, and the space applies puts of one key in arrival
|
|
17117
|
+
* order), which can leave a child whose parent entry lost the race. The
|
|
17118
|
+
* per-record queue and the coordinator order writes within one device
|
|
17119
|
+
* only. Unreachable is harmless —
|
|
16225
17120
|
* it is invisible and undecryptable-by-nobody — but it costs storage, so
|
|
16226
17121
|
* this reclaims it.
|
|
16227
17122
|
*
|
|
@@ -16230,6 +17125,12 @@ class VfsNamespace {
|
|
|
16230
17125
|
* Run it when the filesystem is idle.
|
|
16231
17126
|
*/
|
|
16232
17127
|
async sweep(opts = {}) {
|
|
17128
|
+
// With a coordinator: what another tab has written since this tab
|
|
17129
|
+
// read it is read again, not followed in an old copy that may not
|
|
17130
|
+
// name what it now names - which would then be deleted here.
|
|
17131
|
+
const coordinator = this.deps.coordinator;
|
|
17132
|
+
if (coordinator)
|
|
17133
|
+
await this.dropStale(coordinator, [...this.cache.keys()]);
|
|
16233
17134
|
const root = await this.root();
|
|
16234
17135
|
// A record that cannot be read throws out of here, before anything is
|
|
16235
17136
|
// deleted: with part of the tree unknown, "unreachable" is a guess.
|
|
@@ -16254,8 +17155,10 @@ class VfsNamespace {
|
|
|
16254
17155
|
}
|
|
16255
17156
|
// Only ever consider OUR records: the personal space is shared with
|
|
16256
17157
|
// 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
|
-
|
|
17158
|
+
// reached beyond its own namespace would be a data-loss bug. The store
|
|
17159
|
+
// is asked for `vfs/` only; the regex stays, because `vfs/space` is
|
|
17160
|
+
// under `vfs/` too and a store may ignore the prefix.
|
|
17161
|
+
const mine = (await this.deps.store.list("vfs/")).filter((k) => /^vfs\/[dh]\//.test(k));
|
|
16259
17162
|
const orphans = mine.filter((k) => !reachable.has(k));
|
|
16260
17163
|
// A root that read as EMPTY while orphans exist is the shape of a failed
|
|
16261
17164
|
// load, not of a fresh filesystem — deleting here would wipe a tree we
|
|
@@ -16295,16 +17198,21 @@ class VfsNamespace {
|
|
|
16295
17198
|
if (!version)
|
|
16296
17199
|
throw new VfsNotFoundError(`${target} version ${index}`);
|
|
16297
17200
|
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
|
-
};
|
|
17201
|
+
let restored;
|
|
16307
17202
|
await this.mutate(parent, (node) => {
|
|
17203
|
+
// As for writeFile: the version pushed onto the history was `entry`.
|
|
17204
|
+
const now = entryIn(node, name);
|
|
17205
|
+
if (!sameEntry(entry, now))
|
|
17206
|
+
throw changedMeanwhile(target);
|
|
17207
|
+
restored = {
|
|
17208
|
+
kind: "file",
|
|
17209
|
+
id: entry.id,
|
|
17210
|
+
manifest: version.manifest,
|
|
17211
|
+
size: asCount(version.size),
|
|
17212
|
+
mtime: Date.now(),
|
|
17213
|
+
versions,
|
|
17214
|
+
...(now?.moving === true ? { moving: true } : {}),
|
|
17215
|
+
};
|
|
16308
17216
|
node.entries[name] = restored;
|
|
16309
17217
|
});
|
|
16310
17218
|
return toStat(target, name, restored);
|
|
@@ -16334,8 +17242,15 @@ class VfsNamespace {
|
|
|
16334
17242
|
const seed = this.deps.seed();
|
|
16335
17243
|
if (!seed)
|
|
16336
17244
|
throw new VfsLockedError();
|
|
16337
|
-
if (!this.rootKey)
|
|
16338
|
-
|
|
17245
|
+
if (!this.rootKey) {
|
|
17246
|
+
const gen = this.generation;
|
|
17247
|
+
const key = await deriveRootKey(seed);
|
|
17248
|
+
// Derived from the seed of whoever was signed in before a
|
|
17249
|
+
// clearCache: start again, with the seed of whoever is now.
|
|
17250
|
+
if (gen !== this.generation)
|
|
17251
|
+
return this.root();
|
|
17252
|
+
this.rootKey = key;
|
|
17253
|
+
}
|
|
16339
17254
|
return this.openDirById(ROOT_ID, this.rootKey, "/");
|
|
16340
17255
|
}
|
|
16341
17256
|
/** Open a directory by path, creating nothing. Throws if it is missing. */
|
|
@@ -16372,6 +17287,11 @@ class VfsNamespace {
|
|
|
16372
17287
|
const hit = this.cache.get(id);
|
|
16373
17288
|
if (hit)
|
|
16374
17289
|
return hit;
|
|
17290
|
+
// Before anything is read, so that from here on this namespace hears
|
|
17291
|
+
// of any later write of the record by another tab. Starting to hear -
|
|
17292
|
+
// for the first time, or for someone else - clears the cache (see
|
|
17293
|
+
// `heard`), which is why it comes before this read is cached.
|
|
17294
|
+
this.deps.coordinator?.listen();
|
|
16375
17295
|
// The PROMISE goes in the cache, not the resolved value.
|
|
16376
17296
|
//
|
|
16377
17297
|
// Reading a project means resolving hundreds of paths that share the
|
|
@@ -16379,50 +17299,116 @@ class VfsNamespace {
|
|
|
16379
17299
|
// every concurrent reader to miss and fire its own request for the same
|
|
16380
17300
|
// record — which is how loading one project turned into thousands of
|
|
16381
17301
|
// identical `vfs/d/<id>` fetches, most of them in flight at once.
|
|
16382
|
-
const
|
|
16383
|
-
|
|
16384
|
-
|
|
16385
|
-
//
|
|
16386
|
-
//
|
|
16387
|
-
//
|
|
16388
|
-
|
|
16389
|
-
|
|
16390
|
-
|
|
16391
|
-
|
|
16392
|
-
//
|
|
16393
|
-
//
|
|
16394
|
-
//
|
|
16395
|
-
//
|
|
16396
|
-
|
|
16397
|
-
|
|
16398
|
-
|
|
16399
|
-
|
|
16400
|
-
value = await openSealed(key, raw);
|
|
17302
|
+
const gen = this.generation;
|
|
17303
|
+
// Reads `pending` only after its first `await`: before that, the const is not yet initialised.
|
|
17304
|
+
const load = async () => {
|
|
17305
|
+
// Not while a write of this record is still in flight: see
|
|
17306
|
+
// `writing`. Our own writes echo back over the personal-space
|
|
17307
|
+
// socket, and `watch` drops the record each one names, so a read
|
|
17308
|
+
// can miss with the write still on its way - and the store would
|
|
17309
|
+
// answer with the copy from BEFORE it. An edit made to that copy
|
|
17310
|
+
// is queued behind the write, and then stored over it. So wait
|
|
17311
|
+
// for the latest write queued, which waits on every one before it,
|
|
17312
|
+
// to land or fail - and then for any queued while this read
|
|
17313
|
+
// waited, since the one it waited for may have failed with that
|
|
17314
|
+
// one still on its way. A record with no write in flight is
|
|
17315
|
+
// fetched at once.
|
|
17316
|
+
let awaited;
|
|
17317
|
+
for (let inFlight = this.writing.get(id); inFlight && inFlight !== awaited; inFlight = this.writing.get(id)) {
|
|
17318
|
+
awaited = inFlight;
|
|
17319
|
+
await inFlight.catch(() => { });
|
|
16401
17320
|
}
|
|
16402
|
-
|
|
16403
|
-
|
|
16404
|
-
|
|
16405
|
-
|
|
17321
|
+
// Not after a clearCache, though: the cache now is the next
|
|
17322
|
+
// identity's, and this read only answers whoever asked for it.
|
|
17323
|
+
if (awaited && gen === this.generation) {
|
|
17324
|
+
// A write that landed put the record it wrote back in the
|
|
17325
|
+
// cache, and the next edit of this directory is made to that.
|
|
17326
|
+
// Answer with it rather than fetch a second copy: two copies
|
|
17327
|
+
// of one record, each edited and written, undo each other's
|
|
17328
|
+
// edits. When the cache holds nothing else - the write failed,
|
|
17329
|
+
// or a change since has dropped the record - ask the store.
|
|
17330
|
+
const now = this.cache.get(id);
|
|
17331
|
+
if (now && now !== pending)
|
|
17332
|
+
return now;
|
|
17333
|
+
// Empty, when the write failed: it dropped what the cache held,
|
|
17334
|
+
// and after an echo that was this very read. Put it back before
|
|
17335
|
+
// asking the store, so a read that comes in meanwhile shares
|
|
17336
|
+
// this copy instead of fetching a second one.
|
|
17337
|
+
if (!now)
|
|
17338
|
+
this.cache.set(id, pending);
|
|
16406
17339
|
}
|
|
16407
|
-
return this.
|
|
16408
|
-
}
|
|
17340
|
+
return this.fetchRecord(id, key, gen, path);
|
|
17341
|
+
};
|
|
17342
|
+
const pending = load().catch((err) => {
|
|
16409
17343
|
// A failed read must not be remembered, or one blip poisons the
|
|
16410
17344
|
// directory for the life of the session.
|
|
16411
17345
|
if (this.cache.get(id) === pending)
|
|
16412
17346
|
this.cache.delete(id);
|
|
16413
17347
|
throw err;
|
|
16414
17348
|
});
|
|
17349
|
+
// Registered before anyone else can wait on it, so it is known by
|
|
17350
|
+
// the time any of them goes on to edit the record: see `editable`.
|
|
17351
|
+
pending.then((dir) => void this.copyOf.set(pending, dir), () => { });
|
|
16415
17352
|
this.cache.set(id, pending);
|
|
16416
17353
|
return pending;
|
|
16417
17354
|
}
|
|
16418
17355
|
/**
|
|
16419
|
-
*
|
|
16420
|
-
*
|
|
16421
|
-
*
|
|
16422
|
-
*
|
|
17356
|
+
* Fetch record `id` from the store and open it. Whatever another tab
|
|
17357
|
+
* announced before the fetch began is in what it returns (see
|
|
17358
|
+
* `current`), and so is every write its counter counts: read here, or
|
|
17359
|
+
* given as `counter` (see `OpenDir.counter`).
|
|
17360
|
+
*/
|
|
17361
|
+
async fetchRecord(id, key, gen, path, counter) {
|
|
17362
|
+
const seen = this.hearing;
|
|
17363
|
+
// Before the fetch, never after it. A write that lands in between
|
|
17364
|
+
// then only makes this copy look out of date, which costs a read;
|
|
17365
|
+
// read after, the counter could count a write the answer missed.
|
|
17366
|
+
// Without a coordinator there is none, and nothing is awaited: a read
|
|
17367
|
+
// in Node takes the turns it always took.
|
|
17368
|
+
if (counter === undefined)
|
|
17369
|
+
counter = this.deps.coordinator ? await this.counterOf(id) : null;
|
|
17370
|
+
const raw = await this.deps.store.get(dirKey(id));
|
|
17371
|
+
// An absent record is an EMPTY directory, not an error: the root
|
|
17372
|
+
// exists before anything has been written to it, and a directory is
|
|
17373
|
+
// created by its parent naming it rather than by its own record
|
|
17374
|
+
// appearing.
|
|
17375
|
+
if (raw === null || raw === undefined) {
|
|
17376
|
+
const node = { v: 1, entries: emptyEntries(), mtime: Date.now() };
|
|
17377
|
+
return { id, key, path, node, hidden: Object.create(null), gen, seen, counter };
|
|
17378
|
+
}
|
|
17379
|
+
// Present but unopenable is NOT absent. `unseal` folds the two
|
|
17380
|
+
// together, which suits a record that can simply be rebuilt; a
|
|
17381
|
+
// directory cannot, because rebuilding it means sealing an empty
|
|
17382
|
+
// one over whatever is really there. And it is not a raw
|
|
17383
|
+
// DOMException either: the typed error is what delete, purge and
|
|
17384
|
+
// sweep know how to step around, and it names the record.
|
|
17385
|
+
let value;
|
|
17386
|
+
try {
|
|
17387
|
+
value = await openSealed(key, raw);
|
|
17388
|
+
}
|
|
17389
|
+
catch (err) {
|
|
17390
|
+
if (err instanceof SealedRecordError)
|
|
17391
|
+
throw new VfsCorruptRecordError(id, path, err.message);
|
|
17392
|
+
throw err;
|
|
17393
|
+
}
|
|
17394
|
+
return this.readRecord(id, key, value, gen, seen, counter, path);
|
|
17395
|
+
}
|
|
17396
|
+
/** Record `id`'s write counter now: see `OpenDir.counter`. */
|
|
17397
|
+
async counterOf(id) {
|
|
17398
|
+
const coordinator = this.deps.coordinator;
|
|
17399
|
+
if (!coordinator)
|
|
17400
|
+
return null;
|
|
17401
|
+
const [counter] = await coordinator.counters([id]).catch(() => [null]);
|
|
17402
|
+
return counter ?? null;
|
|
17403
|
+
}
|
|
17404
|
+
/**
|
|
17405
|
+
* Turn a decrypted record into an OpenDir, or throw.
|
|
17406
|
+
*
|
|
17407
|
+
* The record is THROWN on, never read as empty, when its shape is wrong -
|
|
17408
|
+
* see VfsCorruptRecordError. An individual entry of the wrong shape is set
|
|
16423
17409
|
* aside in `hidden` instead: out of every read, but kept for the write.
|
|
16424
17410
|
*/
|
|
16425
|
-
readRecord(id, key, value, path) {
|
|
17411
|
+
readRecord(id, key, value, gen, seen, counter, path) {
|
|
16426
17412
|
const problem = recordProblem(value);
|
|
16427
17413
|
if (problem)
|
|
16428
17414
|
throw new VfsCorruptRecordError(id, path, problem);
|
|
@@ -16478,25 +17464,46 @@ class VfsNamespace {
|
|
|
16478
17464
|
this.warn(`ignoring ${what} in ${path ?? `record ${printable(id, 40)}`}`);
|
|
16479
17465
|
}
|
|
16480
17466
|
// A v1 record's unknown top-level fields survive a write too.
|
|
16481
|
-
return { id, key, path, node: { ...top, entries }, hidden };
|
|
17467
|
+
return { id, key, path, node: { ...top, entries }, hidden, gen, seen, counter };
|
|
16482
17468
|
}
|
|
16483
|
-
/**
|
|
17469
|
+
/**
|
|
17470
|
+
* Apply an edit to a directory record and persist it.
|
|
17471
|
+
*
|
|
17472
|
+
* `dir` says which record - its id, key and path - and nothing more. The
|
|
17473
|
+
* edit is made to THE copy of that record (see `editable`), which `dir`
|
|
17474
|
+
* may no longer be, and that copy is what is written. Editing whichever
|
|
17475
|
+
* copy the caller held let two copies of one record exist at once: an
|
|
17476
|
+
* overwrite held its directory across storing the old version's
|
|
17477
|
+
* history, a write of the directory failed meanwhile and dropped it from
|
|
17478
|
+
* the cache, and a save that then read a second copy and stored it,
|
|
17479
|
+
* successfully, was undone when the overwrite stored the first.
|
|
17480
|
+
*
|
|
17481
|
+
* So `edit` must take what it writes from the record it is given, not
|
|
17482
|
+
* from anything read off `dir` before an await - and re-check whatever it
|
|
17483
|
+
* relies on from then, throwing if another write changed it, before it
|
|
17484
|
+
* changes anything. Returning false writes nothing after all.
|
|
17485
|
+
*
|
|
17486
|
+
* The write that carries the edit may carry others, made while an
|
|
17487
|
+
* earlier write of the record was in flight (see `queue`): it lands, or
|
|
17488
|
+
* fails, for all of them at once.
|
|
17489
|
+
*
|
|
17490
|
+
* With a coordinator, the edit may be made a second time, to the record
|
|
17491
|
+
* as another tab left it, and refused then (see `replay`): an edit must
|
|
17492
|
+
* check everything before it changes anything, and whatever it sets
|
|
17493
|
+
* outside `node` and `record` must be safe to set again, since each run
|
|
17494
|
+
* overwrites the last.
|
|
17495
|
+
*/
|
|
16484
17496
|
async mutate(dir, edit) {
|
|
16485
|
-
|
|
16486
|
-
|
|
16487
|
-
//
|
|
16488
|
-
|
|
16489
|
-
|
|
16490
|
-
|
|
16491
|
-
|
|
16492
|
-
|
|
16493
|
-
|
|
16494
|
-
|
|
16495
|
-
// `writing`. Whether that one worked or not, this one still goes.
|
|
16496
|
-
const before = this.writing.get(dir.id);
|
|
16497
|
-
const send = () => this.writeRecord(dir);
|
|
16498
|
-
const write = before ? before.then(send, send) : send();
|
|
16499
|
-
this.writing.set(dir.id, write);
|
|
17497
|
+
// At once when the copy is to hand, which it almost always is, so
|
|
17498
|
+
// that what the caller checked just before is still true when the
|
|
17499
|
+
// edit is made.
|
|
17500
|
+
const target = this.editable(dir) ?? await this.reopen(dir);
|
|
17501
|
+
if (edit(target.node, target) === false)
|
|
17502
|
+
return;
|
|
17503
|
+
this.touch(target);
|
|
17504
|
+
const ticket = { edit };
|
|
17505
|
+
const batch = this.queue(target, ticket);
|
|
17506
|
+
const { write } = batch;
|
|
16500
17507
|
try {
|
|
16501
17508
|
await write;
|
|
16502
17509
|
}
|
|
@@ -16506,14 +17513,315 @@ class VfsNamespace {
|
|
|
16506
17513
|
// change the store never took - a deleted file gone from every
|
|
16507
17514
|
// listing while it is still stored, and a retry told it does not
|
|
16508
17515
|
// exist. Drop it, so the next read asks the store.
|
|
16509
|
-
|
|
17516
|
+
//
|
|
17517
|
+
// Unless a later write of this record is already queued. It
|
|
17518
|
+
// seals the record as it stands when it is sent, this edit
|
|
17519
|
+
// included, so the cache shows what is about to be stored, and
|
|
17520
|
+
// dropping it would only send readers to the store for an older
|
|
17521
|
+
// copy. If that write fails too, it drops the cache itself.
|
|
17522
|
+
//
|
|
17523
|
+
// Nor from a cache cleared since: that is the next identity's.
|
|
17524
|
+
if (this.writing.get(target.id) === write && target.gen === this.generation)
|
|
17525
|
+
this.cache.delete(target.id);
|
|
16510
17526
|
throw err;
|
|
16511
17527
|
}
|
|
16512
17528
|
finally {
|
|
16513
|
-
if (this.writing.get(
|
|
16514
|
-
this.writing.delete(
|
|
17529
|
+
if (this.writing.get(target.id) === write)
|
|
17530
|
+
this.writing.delete(target.id);
|
|
16515
17531
|
}
|
|
16516
|
-
this.
|
|
17532
|
+
this.adopt(target);
|
|
17533
|
+
// Made again to the record as another tab left it, it no longer
|
|
17534
|
+
// applied - see `replay` - and nothing of it was written.
|
|
17535
|
+
if (batch.refused.has(ticket))
|
|
17536
|
+
throw batch.refused.get(ticket);
|
|
17537
|
+
}
|
|
17538
|
+
/**
|
|
17539
|
+
* The copy of `dir`'s record that edits are made to, if there is one
|
|
17540
|
+
* without waiting: the one the cache holds, once read.
|
|
17541
|
+
*
|
|
17542
|
+
* Failing that, the one a write in flight is sending. The record was
|
|
17543
|
+
* dropped from the cache - a change was reported, our own write's echo
|
|
17544
|
+
* among them - and a read that came in since is waiting for that write,
|
|
17545
|
+
* to answer with this very copy if it lands (see `openDirById`); if it
|
|
17546
|
+
* fails, the write an edit now queues behind it carries the copy instead.
|
|
17547
|
+
*
|
|
17548
|
+
* Undefined when the record is not cached and nothing is writing it, or
|
|
17549
|
+
* it is still being read: `reopen` waits for that. A record read before
|
|
17550
|
+
* `clearCache` is edited as it is, as it always was. It is not the copy of
|
|
17551
|
+
* anything the cache holds now, and must not be handed the next identity's.
|
|
17552
|
+
*/
|
|
17553
|
+
editable(dir) {
|
|
17554
|
+
if (dir.gen !== this.generation)
|
|
17555
|
+
return dir;
|
|
17556
|
+
const cached = this.cache.get(dir.id);
|
|
17557
|
+
const write = this.writing.get(dir.id);
|
|
17558
|
+
const copy = (cached && this.copyOf.get(cached)) ?? (write && this.copyOf.get(write));
|
|
17559
|
+
return copy?.gen === this.generation ? copy : undefined;
|
|
17560
|
+
}
|
|
17561
|
+
/**
|
|
17562
|
+
* Read `dir`'s record - the cached read, or a fetch made once any write of
|
|
17563
|
+
* it in flight has settled - and then find the copy edits are made to.
|
|
17564
|
+
*/
|
|
17565
|
+
async reopen(dir) {
|
|
17566
|
+
for (;;) {
|
|
17567
|
+
await this.openDirById(dir.id, dir.key, dir.path);
|
|
17568
|
+
// What that read answered with, unless a change has dropped it
|
|
17569
|
+
// from the cache again since: then read it again.
|
|
17570
|
+
const copy = this.editable(dir);
|
|
17571
|
+
if (copy)
|
|
17572
|
+
return copy;
|
|
17573
|
+
}
|
|
17574
|
+
}
|
|
17575
|
+
/** Make `dir` the cached copy of its record - unless the cache has been cleared since it was read. */
|
|
17576
|
+
adopt(dir) {
|
|
17577
|
+
if (dir.gen !== this.generation)
|
|
17578
|
+
return;
|
|
17579
|
+
const cached = Promise.resolve(dir);
|
|
17580
|
+
this.copyOf.set(cached, dir);
|
|
17581
|
+
this.cache.set(dir.id, cached);
|
|
17582
|
+
}
|
|
17583
|
+
/** After an edit of `copy`: its time, and any entry it cannot read that the edit replaced. */
|
|
17584
|
+
touch(copy) {
|
|
17585
|
+
copy.node.mtime = Date.now();
|
|
17586
|
+
// A write to a hidden entry's exact name replaces it: that name now
|
|
17587
|
+
// means what this version wrote. Forgetting the old one here is also
|
|
17588
|
+
// what stops a later delete of the replacement from resurrecting it.
|
|
17589
|
+
for (const name of Object.keys(copy.hidden)) {
|
|
17590
|
+
if (Object.hasOwn(copy.node.entries, name))
|
|
17591
|
+
delete copy.hidden[name];
|
|
17592
|
+
}
|
|
17593
|
+
}
|
|
17594
|
+
/**
|
|
17595
|
+
* The write that will carry the edit just made to `copy`.
|
|
17596
|
+
*
|
|
17597
|
+
* Writes of a record go out one at a time, each once the one before it
|
|
17598
|
+
* has landed or failed (see `writing`), and each seals the record as it
|
|
17599
|
+
* stands when it goes out. So a write still waiting carries every edit
|
|
17600
|
+
* made while it waits, and an edit made then joins it instead of
|
|
17601
|
+
* queueing a write of its own: a burst of edits of one directory while a
|
|
17602
|
+
* write of it is in flight costs two writes, not one per edit. Each is a
|
|
17603
|
+
* billed write; a 20-file upload into one folder cost twenty.
|
|
17604
|
+
*/
|
|
17605
|
+
queue(copy, ticket) {
|
|
17606
|
+
const open = this.waiting.get(copy.id);
|
|
17607
|
+
if (open?.copy === copy) {
|
|
17608
|
+
open.edits.push(ticket);
|
|
17609
|
+
return open;
|
|
17610
|
+
}
|
|
17611
|
+
const before = this.writing.get(copy.id);
|
|
17612
|
+
const batch = { copy, edits: [ticket], refused: new Map() };
|
|
17613
|
+
this.waiting.set(copy.id, batch);
|
|
17614
|
+
// Not sent until any earlier write of this record has landed: see
|
|
17615
|
+
// `writing`. Whether that one worked or not, this one still goes.
|
|
17616
|
+
const send = () => this.send(batch);
|
|
17617
|
+
batch.write = before ? before.then(send, send) : send();
|
|
17618
|
+
this.copyOf.set(batch.write, copy);
|
|
17619
|
+
this.writing.set(copy.id, batch.write);
|
|
17620
|
+
return batch;
|
|
17621
|
+
}
|
|
17622
|
+
/**
|
|
17623
|
+
* Send `batch`: from here on, an edit of its record goes in the next
|
|
17624
|
+
* write. With a coordinator, holding the lock every tab of the device
|
|
17625
|
+
* takes to write this record - see `sendLocked`.
|
|
17626
|
+
*/
|
|
17627
|
+
send(batch) {
|
|
17628
|
+
const coordinator = this.deps.coordinator;
|
|
17629
|
+
if (!coordinator) {
|
|
17630
|
+
this.close(batch);
|
|
17631
|
+
return this.writeRecord(batch.copy);
|
|
17632
|
+
}
|
|
17633
|
+
// Closed under the lock once it is sealed (see `sendLocked`), and
|
|
17634
|
+
// here too, for a lock never granted or a re-read that failed: left
|
|
17635
|
+
// open, a later edit of the same copy would join a write that has
|
|
17636
|
+
// already failed, and fail with it.
|
|
17637
|
+
return coordinator.lock(batch.copy.id, () => this.sendLocked(batch, coordinator)).finally(() => this.close(batch));
|
|
17638
|
+
}
|
|
17639
|
+
/** Stop `batch` taking more edits, as it is sealed. */
|
|
17640
|
+
close(batch) {
|
|
17641
|
+
if (this.waiting.get(batch.copy.id) === batch)
|
|
17642
|
+
this.waiting.delete(batch.copy.id);
|
|
17643
|
+
}
|
|
17644
|
+
/**
|
|
17645
|
+
* Send `batch` while no other tab can write its record.
|
|
17646
|
+
*
|
|
17647
|
+
* If another tab wrote the record since this copy was read, the copy is
|
|
17648
|
+
* out of date, and writing it would undo that tab's write. So it is read
|
|
17649
|
+
* again, here, under the lock, and every edit the batch carries is made
|
|
17650
|
+
* again to what was read (see `replay`). A copy no other tab has written
|
|
17651
|
+
* over is sent as it is: one tab on its own costs no extra request.
|
|
17652
|
+
*
|
|
17653
|
+
* Which it is, the record's write counter says (see
|
|
17654
|
+
* `VfsCoordinator.counters`). Read once the lock is granted, it counts
|
|
17655
|
+
* every write of the record another tab made, because each counts its
|
|
17656
|
+
* write, and waits for the count to be stored, before it lets go of the
|
|
17657
|
+
* lock. If it has moved since the copy was read, or cannot be told, the
|
|
17658
|
+
* copy is read again. Whether this tab has heard of another tab's write
|
|
17659
|
+
* does not decide it: an announcement can reach this tab after the
|
|
17660
|
+
* lock does, or never. One it has heard of can only add a read.
|
|
17661
|
+
*
|
|
17662
|
+
* The write is counted once it has landed or failed - one that failed
|
|
17663
|
+
* may have landed even so - and the other tabs are then told of it,
|
|
17664
|
+
* all before the lock is let go of.
|
|
17665
|
+
*/
|
|
17666
|
+
async sendLocked(batch, coordinator) {
|
|
17667
|
+
const { copy } = batch;
|
|
17668
|
+
// Hearing for whoever is signed in now, before deciding: starting to
|
|
17669
|
+
// hear - or hearing someone else - marks every copy out of date.
|
|
17670
|
+
coordinator.listen();
|
|
17671
|
+
const now = await coordinator.begin(copy.id).catch(() => null);
|
|
17672
|
+
// Whether `copy` is, from here, the record as this tab leaves it.
|
|
17673
|
+
let decided = false;
|
|
17674
|
+
let sent = false;
|
|
17675
|
+
try {
|
|
17676
|
+
const current = now !== null && copy.counter === now && this.current(copy);
|
|
17677
|
+
const fresh = copy.gen === this.generation && !current
|
|
17678
|
+
? await this.fetchRecord(copy.id, copy.key, copy.gen, copy.path, null)
|
|
17679
|
+
: null;
|
|
17680
|
+
// Nothing waits from here to the seal, so no edit can come in between.
|
|
17681
|
+
this.close(batch);
|
|
17682
|
+
decided = true;
|
|
17683
|
+
this.locked.set(copy.id, copy);
|
|
17684
|
+
if (fresh && !this.replay(batch, fresh))
|
|
17685
|
+
return;
|
|
17686
|
+
sent = true;
|
|
17687
|
+
await this.writeRecord(copy);
|
|
17688
|
+
copy.seen = this.hearing;
|
|
17689
|
+
}
|
|
17690
|
+
finally {
|
|
17691
|
+
const counted = await coordinator.bump(copy.id).catch(() => null);
|
|
17692
|
+
// Not when the read above failed: the copy is what it was, and
|
|
17693
|
+
// the counter it held is out of date now.
|
|
17694
|
+
if (decided)
|
|
17695
|
+
copy.counter = counted;
|
|
17696
|
+
if (this.locked.get(copy.id) === copy)
|
|
17697
|
+
this.locked.delete(copy.id);
|
|
17698
|
+
if (counted === null)
|
|
17699
|
+
this.notCounted(copy, now !== null);
|
|
17700
|
+
else
|
|
17701
|
+
this.uncounted.delete(copy.id);
|
|
17702
|
+
if (sent)
|
|
17703
|
+
coordinator.announce(copy.id);
|
|
17704
|
+
}
|
|
17705
|
+
}
|
|
17706
|
+
/**
|
|
17707
|
+
* Say that a write of `copy`'s record could not be counted.
|
|
17708
|
+
*
|
|
17709
|
+
* `marked`: `VfsCoordinator.begin` stored its mark, which stays until a
|
|
17710
|
+
* count can be stored, so every tab reads the record again before it
|
|
17711
|
+
* writes it. Said once for each record, until a write of it is counted.
|
|
17712
|
+
*
|
|
17713
|
+
* Not marked, a tab whose IndexedDB works may take its copy for current
|
|
17714
|
+
* and write it over this write. Said once for this namespace, whatever
|
|
17715
|
+
* the record: where IndexedDB cannot be used at all, no write of any
|
|
17716
|
+
* folder is counted, and a warning for each said nothing new. And only
|
|
17717
|
+
* where another tab can count its writes can one be lost, which the
|
|
17718
|
+
* warning says: where no tab can, every tab reads each folder again.
|
|
17719
|
+
*/
|
|
17720
|
+
notCounted(copy, marked) {
|
|
17721
|
+
const where = copy.path ?? `record ${printable(copy.id, 40)}`;
|
|
17722
|
+
if (!marked) {
|
|
17723
|
+
if (this.lossWarned)
|
|
17724
|
+
return;
|
|
17725
|
+
this.lossWarned = true;
|
|
17726
|
+
this.warn(`a write of ${where} by this tab could not be counted in IndexedDB, where this app's other tabs look before writing a folder. Such a write may be lost only if another tab of this app can count its writes: that tab may write the folder without reading it again. Said once per Client, for every write of a folder that cannot be counted`);
|
|
17727
|
+
return;
|
|
17728
|
+
}
|
|
17729
|
+
if (this.uncounted.has(copy.id))
|
|
17730
|
+
return;
|
|
17731
|
+
this.uncounted.add(copy.id);
|
|
17732
|
+
this.warn(`a write of ${where} by this tab could not be counted in IndexedDB; until one can be, every tab of this app reads ${where} again before writing it`);
|
|
17733
|
+
}
|
|
17734
|
+
/**
|
|
17735
|
+
* Make every edit in `batch` again, in order, to `fresh` - the record as
|
|
17736
|
+
* another tab left it - and make that the copy. An edit that no longer
|
|
17737
|
+
* applies throws, as it would have the first time, and is left out; its
|
|
17738
|
+
* caller gets what it threw. Every edit checks before it changes
|
|
17739
|
+
* anything (see `mutate`), so one that throws has changed nothing. True
|
|
17740
|
+
* when what is left changes the record, and so needs writing.
|
|
17741
|
+
*/
|
|
17742
|
+
replay(batch, fresh) {
|
|
17743
|
+
const { copy } = batch;
|
|
17744
|
+
copy.node = fresh.node;
|
|
17745
|
+
copy.hidden = fresh.hidden;
|
|
17746
|
+
copy.seen = fresh.seen;
|
|
17747
|
+
copy.counter = fresh.counter;
|
|
17748
|
+
let changed = false;
|
|
17749
|
+
for (const ticket of batch.edits) {
|
|
17750
|
+
try {
|
|
17751
|
+
if (ticket.edit(copy.node, copy) !== false)
|
|
17752
|
+
changed = true;
|
|
17753
|
+
}
|
|
17754
|
+
catch (err) {
|
|
17755
|
+
batch.refused.set(ticket, err);
|
|
17756
|
+
}
|
|
17757
|
+
}
|
|
17758
|
+
if (changed)
|
|
17759
|
+
this.touch(copy);
|
|
17760
|
+
return changed;
|
|
17761
|
+
}
|
|
17762
|
+
/**
|
|
17763
|
+
* Another tab wrote record `id` - or, null, this namespace may have
|
|
17764
|
+
* missed some announcements. What the cache holds is read again when
|
|
17765
|
+
* next asked for, as a change frame from the personal space would have
|
|
17766
|
+
* it, and a copy read before then is read again before the next write
|
|
17767
|
+
* of its record (see `current`). A hint, not a guarantee: an
|
|
17768
|
+
* announcement can reach this tab after the next lock does, or never.
|
|
17769
|
+
* What decides whether a copy may be written over, or relied on under a
|
|
17770
|
+
* move's or a delete's locks, is the record's counter: see `sendLocked`
|
|
17771
|
+
* and `dropStale`.
|
|
17772
|
+
*/
|
|
17773
|
+
heard(id) {
|
|
17774
|
+
const at = ++this.hearing;
|
|
17775
|
+
if (id === null) {
|
|
17776
|
+
this.missedAt = at;
|
|
17777
|
+
this.cache.clear();
|
|
17778
|
+
return;
|
|
17779
|
+
}
|
|
17780
|
+
this.heardAt.set(id, at);
|
|
17781
|
+
this.cache.delete(id);
|
|
17782
|
+
}
|
|
17783
|
+
/**
|
|
17784
|
+
* Does `copy` hold every write of its record another tab has announced
|
|
17785
|
+
* that this one has heard of? See `heard`. Only ever a reason to read it
|
|
17786
|
+
* again, never to take it for current: see `sendLocked`.
|
|
17787
|
+
*/
|
|
17788
|
+
current(copy) {
|
|
17789
|
+
return copy.seen >= Math.max(this.missedAt, this.heardAt.get(copy.id) ?? 0);
|
|
17790
|
+
}
|
|
17791
|
+
/**
|
|
17792
|
+
* Drop from the cache every copy of a record in `ids` that may not hold
|
|
17793
|
+
* every write of it another tab has made: its counter has moved since
|
|
17794
|
+
* the copy was read, or cannot be told, or the copy is still being read.
|
|
17795
|
+
* Returns every id whose copy here is not known to be current - those,
|
|
17796
|
+
* and those not cached - so that the next read of each fetches it again,
|
|
17797
|
+
* once any write of it in flight here has landed or failed. Not a copy
|
|
17798
|
+
* this tab is writing under its lock (see `locked`): its counter reads
|
|
17799
|
+
* as unknown only because that write is not counted yet, and dropped,
|
|
17800
|
+
* the next read would wait for the write.
|
|
17801
|
+
*
|
|
17802
|
+
* The counters are read in one go, after the caller has taken the locks
|
|
17803
|
+
* it relies on: another tab counts each write of a record before it lets
|
|
17804
|
+
* go of the locks it held while writing it, the record's own and any
|
|
17805
|
+
* move lock, so what it wrote before letting go of one of them is
|
|
17806
|
+
* counted by then. Its announcements may not have reached this tab yet,
|
|
17807
|
+
* or ever. See `sendLocked`.
|
|
17808
|
+
*/
|
|
17809
|
+
async dropStale(coordinator, ids) {
|
|
17810
|
+
const asked = [...new Set(ids)];
|
|
17811
|
+
const now = asked.length ? await coordinator.counters(asked).catch(() => asked.map(() => null)) : [];
|
|
17812
|
+
const stale = new Set();
|
|
17813
|
+
asked.forEach((id, i) => {
|
|
17814
|
+
const cached = this.cache.get(id);
|
|
17815
|
+
const copy = cached && this.copyOf.get(cached);
|
|
17816
|
+
if (copy && copy.gen === this.generation && copy.counter !== null && copy.counter === now[i])
|
|
17817
|
+
return;
|
|
17818
|
+
if (copy && copy.gen === this.generation && this.locked.get(id) === copy)
|
|
17819
|
+
return;
|
|
17820
|
+
if (cached)
|
|
17821
|
+
this.cache.delete(id);
|
|
17822
|
+
stale.add(id);
|
|
17823
|
+
});
|
|
17824
|
+
return stale;
|
|
16517
17825
|
}
|
|
16518
17826
|
/**
|
|
16519
17827
|
* Seal a directory record as it stands NOW and put it in the store.
|
|
@@ -16541,13 +17849,38 @@ class VfsNamespace {
|
|
|
16541
17849
|
}
|
|
16542
17850
|
await this.deps.store.put(dirKey(dir.id), sealed);
|
|
16543
17851
|
}
|
|
16544
|
-
|
|
17852
|
+
/** For `mkdir`: name a new, empty directory `path` in the one at `parentPath`. */
|
|
17853
|
+
async createDir(parentPath, name, path, recursive) {
|
|
16545
17854
|
assertValidName(name);
|
|
16546
17855
|
const parent = await this.openDir(parentPath);
|
|
16547
17856
|
const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
16548
|
-
|
|
17857
|
+
// The directory the edit below last found there, if any.
|
|
17858
|
+
let there;
|
|
17859
|
+
const named = this.mutate(parent, (node, record) => {
|
|
17860
|
+
// mkdir looked before the wait above. A directory made there
|
|
17861
|
+
// since, by another `mkdir -p` of the same path - files saved
|
|
17862
|
+
// at once into a new folder - is kept, not replaced: whatever
|
|
17863
|
+
// was saved into it would go with it.
|
|
17864
|
+
const now = entryIn(node, name);
|
|
17865
|
+
there = now;
|
|
17866
|
+
if (now?.kind === "dir" && recursive)
|
|
17867
|
+
return false;
|
|
17868
|
+
if (now)
|
|
17869
|
+
throw new VfsConflictError(`${path} ${now.kind === "dir" ? "already exists" : "is a file"}`);
|
|
17870
|
+
assertNotHidden(record, name, path);
|
|
16549
17871
|
node.entries[name] = entry;
|
|
17872
|
+
return true;
|
|
16550
17873
|
});
|
|
17874
|
+
// Set before anyone can find the entry in the cached parent - `mutate`
|
|
17875
|
+
// makes the edit at once, or after a re-read - so a mkdir that finds
|
|
17876
|
+
// it there waits for this write. See `naming`.
|
|
17877
|
+
this.naming.set(entry.id, named.then(() => { }, () => { }).then(() => void this.naming.delete(entry.id)));
|
|
17878
|
+
await named;
|
|
17879
|
+
// The directory kept instead may be one another mkdir here named,
|
|
17880
|
+
// its write not landed yet: wait for that too, as `mkdir` does.
|
|
17881
|
+
const kept = there && this.naming.get(there.id);
|
|
17882
|
+
if (kept)
|
|
17883
|
+
await kept;
|
|
16551
17884
|
}
|
|
16552
17885
|
/**
|
|
16553
17886
|
* Every directory record in a subtree, each once, opened - and nothing
|
|
@@ -16563,14 +17896,22 @@ class VfsNamespace {
|
|
|
16563
17896
|
*
|
|
16564
17897
|
* `moving` says whether anything in it is marked as half of an
|
|
16565
17898
|
* interrupted move, which is what makes a delete look for the other half.
|
|
17899
|
+
*
|
|
17900
|
+
* `ids` is every entry id in it, and `shape` what each record named when
|
|
17901
|
+
* it was read: see `currentSubtree`.
|
|
16566
17902
|
*/
|
|
16567
17903
|
async subtree(top, target, above) {
|
|
16568
17904
|
const found = new Map([[top.id, top]]);
|
|
16569
17905
|
const pending = [top];
|
|
17906
|
+
// Every entry id in it, and what each record named as it was read.
|
|
17907
|
+
const ids = new Set();
|
|
17908
|
+
const shape = new Map();
|
|
16570
17909
|
let spared = 0;
|
|
16571
17910
|
let moving = false;
|
|
16572
17911
|
for (let dir = pending.pop(); dir; dir = pending.pop()) {
|
|
17912
|
+
shape.set(dir.id, namedIn(dir));
|
|
16573
17913
|
for (const [, entry] of followable(dir)) {
|
|
17914
|
+
ids.add(entry.id);
|
|
16574
17915
|
if (entry.moving === true)
|
|
16575
17916
|
moving = true;
|
|
16576
17917
|
if (entry.kind !== "dir" || found.has(entry.id))
|
|
@@ -16600,7 +17941,79 @@ class VfsNamespace {
|
|
|
16600
17941
|
const what = spared === 1 ? "a directory that is" : `${spared} directories that are`;
|
|
16601
17942
|
this.warn(`leaving ${what} above ${target} in the tree alone while deleting it`);
|
|
16602
17943
|
}
|
|
16603
|
-
return { dirs: [...found.values()], moving };
|
|
17944
|
+
return { dirs: [...found.values()], moving, ids, shape };
|
|
17945
|
+
}
|
|
17946
|
+
/**
|
|
17947
|
+
* The records a recursive delete read, as they are now - or null when
|
|
17948
|
+
* one has changed since in a way that purging it would get wrong.
|
|
17949
|
+
*
|
|
17950
|
+
* The delete decided what to let go of, and whether to look for the
|
|
17951
|
+
* other half of a move, from what it read; then it waited, on reads and
|
|
17952
|
+
* on the write of its parent. A record that is no longer named by the
|
|
17953
|
+
* record it was read in has been moved out, and is not this delete's to
|
|
17954
|
+
* purge; an entry marked since it was read is being moved, and is about
|
|
17955
|
+
* to be named somewhere the delete never looked. Purging either purged
|
|
17956
|
+
* the folder a move had just put somewhere else. Anything else - a file
|
|
17957
|
+
* saved into one of them, or moved out - is purged as it now stands,
|
|
17958
|
+
* which is why the copies to purge are what this returns.
|
|
17959
|
+
*
|
|
17960
|
+
* Synchronous, for an edit. A record whose copy is not to hand counts as
|
|
17961
|
+
* changed.
|
|
17962
|
+
*
|
|
17963
|
+
* `held`, with a coordinator, is every entry whose move lock the delete
|
|
17964
|
+
* holds. An entry named since the records were read - saved or moved in
|
|
17965
|
+
* - is not among them, so nothing stops another tab moving it out while
|
|
17966
|
+
* the delete writes the parent, and purging it then let go of what that
|
|
17967
|
+
* move had put somewhere else. So with `held`, that counts as changed
|
|
17968
|
+
* too, and the delete is tried again from the start.
|
|
17969
|
+
*/
|
|
17970
|
+
currentSubtree(dirs, shape, held) {
|
|
17971
|
+
const doomed = new Set(dirs.map((dir) => dir.id));
|
|
17972
|
+
const current = [];
|
|
17973
|
+
for (const dir of dirs) {
|
|
17974
|
+
const now = this.editable(dir);
|
|
17975
|
+
if (!now)
|
|
17976
|
+
return null;
|
|
17977
|
+
const read = shape.get(dir.id) ?? new Map();
|
|
17978
|
+
const named = namedIn(now);
|
|
17979
|
+
for (const [id, marked] of named)
|
|
17980
|
+
if (marked && read.get(id) !== true)
|
|
17981
|
+
return null;
|
|
17982
|
+
for (const id of read.keys())
|
|
17983
|
+
if (doomed.has(id) && !named.has(id))
|
|
17984
|
+
return null;
|
|
17985
|
+
if (held)
|
|
17986
|
+
for (const id of named.keys())
|
|
17987
|
+
if (!held.has(id))
|
|
17988
|
+
return null;
|
|
17989
|
+
current.push(now);
|
|
17990
|
+
}
|
|
17991
|
+
return current;
|
|
17992
|
+
}
|
|
17993
|
+
/**
|
|
17994
|
+
* Hold the move lock of every entry in `ids` - see
|
|
17995
|
+
* `VfsCoordinator.holdMoving` - or of none: null when another holder has
|
|
17996
|
+
* any of them, and then nothing is held. Otherwise the function that
|
|
17997
|
+
* lets go of them all. Asked for all at once: none of them waits.
|
|
17998
|
+
* `shared` holds them as the folders a move goes into.
|
|
17999
|
+
*/
|
|
18000
|
+
async holdAll(coordinator, ids, shared = false) {
|
|
18001
|
+
const tries = await Promise.allSettled([...new Set(ids)].map((id) => coordinator.holdMoving(id, shared)));
|
|
18002
|
+
const held = tries.flatMap((tried) => (tried.status === "fulfilled" && tried.value ? [tried.value] : []));
|
|
18003
|
+
const release = () => {
|
|
18004
|
+
for (const letGo of held)
|
|
18005
|
+
letGo();
|
|
18006
|
+
};
|
|
18007
|
+
const failed = tries.find((tried) => tried.status === "rejected");
|
|
18008
|
+
if (failed) {
|
|
18009
|
+
release();
|
|
18010
|
+
throw failed.reason;
|
|
18011
|
+
}
|
|
18012
|
+
if (held.length < tries.length) {
|
|
18013
|
+
release();
|
|
18014
|
+
return null;
|
|
18015
|
+
}
|
|
18016
|
+
return release;
|
|
16604
18017
|
}
|
|
16605
18018
|
/**
|
|
16606
18019
|
* Delete these records and let go of every file in them. The second half
|
|
@@ -16656,8 +18069,15 @@ class VfsNamespace {
|
|
|
16656
18069
|
// Opened again: this delete may have written to it since the tree was read.
|
|
16657
18070
|
const dir = await this.openDirById(holding[0].id, holding[0].key);
|
|
16658
18071
|
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
|
-
|
|
18072
|
+
if (name === undefined)
|
|
18073
|
+
return;
|
|
18074
|
+
await this.mutate(dir, (node) => {
|
|
18075
|
+
const now = entryIn(node, name);
|
|
18076
|
+
if (now?.id !== id || now.moving !== true)
|
|
18077
|
+
return false;
|
|
18078
|
+
node.entries[name] = unmarked(now);
|
|
18079
|
+
return true;
|
|
18080
|
+
});
|
|
16661
18081
|
}
|
|
16662
18082
|
catch {
|
|
16663
18083
|
// It stays marked.
|
|
@@ -16854,9 +18274,12 @@ class VfsNamespace {
|
|
|
16854
18274
|
}
|
|
16855
18275
|
if (!run.write)
|
|
16856
18276
|
return clone;
|
|
16857
|
-
const
|
|
18277
|
+
const counter = this.deps.coordinator ? await this.counterOf(clone.id) : null;
|
|
18278
|
+
const open = {
|
|
18279
|
+
id: clone.id, key: fromBase64(clone.key), node, hidden: Object.create(null), gen: run.gen, seen: this.hearing, counter,
|
|
18280
|
+
};
|
|
16858
18281
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
16859
|
-
this.
|
|
18282
|
+
this.adopt(open);
|
|
16860
18283
|
return clone;
|
|
16861
18284
|
}
|
|
16862
18285
|
/**
|
|
@@ -17853,8 +19276,11 @@ class Repo {
|
|
|
17853
19276
|
async expand(prefix) {
|
|
17854
19277
|
if (!/^[0-9a-f]{4,}$/.test(prefix))
|
|
17855
19278
|
return null;
|
|
17856
|
-
const keys = await this.deps.store.list();
|
|
17857
19279
|
const wanted = objectKey(this.slug, prefix);
|
|
19280
|
+
// Only the keys under the abbreviation (usually none or one), not the
|
|
19281
|
+
// whole personal space. The filter below stays, for a store that
|
|
19282
|
+
// ignores the prefix.
|
|
19283
|
+
const keys = await this.deps.store.list(wanted);
|
|
17858
19284
|
// Only keys that could be objects: a stray key under the prefix would
|
|
17859
19285
|
// otherwise make every abbreviation of it "ambiguous".
|
|
17860
19286
|
const hits = keys.filter((k) => k.startsWith(wanted) && HASH.test(k.slice(objectKey(this.slug, "").length)));
|
|
@@ -18201,6 +19627,519 @@ class VcsNamespace {
|
|
|
18201
19627
|
}
|
|
18202
19628
|
}
|
|
18203
19629
|
|
|
19630
|
+
/**
|
|
19631
|
+
* Coordination between the tabs of an app on one device: Web Locks, the
|
|
19632
|
+
* Web BroadcastChannel, and IndexedDB.
|
|
19633
|
+
*
|
|
19634
|
+
* Every factory here returns undefined unless the runtime is a browser tab
|
|
19635
|
+
* with both Web Locks and a BroadcastChannel - not Node (which has
|
|
19636
|
+
* `navigator.locks` from 24.5, but per process), not a Worker, not an old
|
|
19637
|
+
* WebView - and its callers then keep the behaviour they had before: one
|
|
19638
|
+
* tab's writes ordered among themselves only. IndexedDB is not required:
|
|
19639
|
+
* without it no count can be told, and every write reads its folder again
|
|
19640
|
+
* first. A storage-partitioned iframe gets locks, a channel and IndexedDB of
|
|
19641
|
+
* its own, as it gets storage of its own; to the app's other tabs it is
|
|
19642
|
+
* another device.
|
|
19643
|
+
*
|
|
19644
|
+
* A browser can also have these and refuse them to a document: an opaque
|
|
19645
|
+
* origin (a sandboxed iframe, a `data:` document), or a site whose data the
|
|
19646
|
+
* user has blocked. Refused Web Locks, the coordinator goes on without
|
|
19647
|
+
* them, as in Node (see `refusesLocks`); refused a channel, it hears
|
|
19648
|
+
* nothing; and an IndexedDB that does not answer in time counts nothing
|
|
19649
|
+
* (see `writeCounts`). `client.vfs` needed none of the three before it
|
|
19650
|
+
* coordinated tabs, and none of them may stop it now.
|
|
19651
|
+
*
|
|
19652
|
+
* `globalThis.BroadcastChannel` is named in full on purpose: this package
|
|
19653
|
+
* exports a class of its own called BroadcastChannel (the E2E room channel),
|
|
19654
|
+
* and an import of that in scope would silently take the Web API's place.
|
|
19655
|
+
*/
|
|
19656
|
+
/** How long a write waits for another tab to finish writing the same folder before it gives up. */
|
|
19657
|
+
const LOCK_WAIT_MS = 30_000;
|
|
19658
|
+
/**
|
|
19659
|
+
* How long each call to the write counts - `counters`, `begin` or `bump` -
|
|
19660
|
+
* may take before it gives up, telling nothing, as one that failed: the
|
|
19661
|
+
* wait for the IndexedDB open and the call's transaction together, not
|
|
19662
|
+
* each. Only the call gives up, not the open: one that succeeds later,
|
|
19663
|
+
* within `OPEN_DROP_MS`, is kept for every later call (see `writeCounts`).
|
|
19664
|
+
*/
|
|
19665
|
+
const COUNT_WAIT_MS = 3_000;
|
|
19666
|
+
/**
|
|
19667
|
+
* How long an IndexedDB open of the write counts may go without any answer
|
|
19668
|
+
* - no success, error or blocked - before it is dropped, as one that
|
|
19669
|
+
* failed, and the next call asks for another. See `writeCounts`.
|
|
19670
|
+
*/
|
|
19671
|
+
const OPEN_DROP_MS = 30_000;
|
|
19672
|
+
/**
|
|
19673
|
+
* Whether `err`, from a Web Locks request that was never granted, is the
|
|
19674
|
+
* browser refusing Web Locks to this document altogether: an opaque origin,
|
|
19675
|
+
* or a site whose data the user has blocked. The browser makes that
|
|
19676
|
+
* decision for the site's storage, so it refuses every tab of the app
|
|
19677
|
+
* alike, and no tab holds the lock. The tab then goes on without it, as in
|
|
19678
|
+
* Node. Any other error - a document no longer fully active, say - is
|
|
19679
|
+
* passed on.
|
|
19680
|
+
*/
|
|
19681
|
+
function refusesLocks(err) {
|
|
19682
|
+
return err?.name === "SecurityError";
|
|
19683
|
+
}
|
|
19684
|
+
// Through `unknown`: the DOM's own LockManager and BroadcastChannel types do
|
|
19685
|
+
// not line up with these slices closely enough for a direct cast.
|
|
19686
|
+
function webLocks() {
|
|
19687
|
+
if (typeof window === "undefined")
|
|
19688
|
+
return undefined;
|
|
19689
|
+
const locks = globalThis.navigator?.locks;
|
|
19690
|
+
return typeof locks?.request === "function" ? locks : undefined;
|
|
19691
|
+
}
|
|
19692
|
+
function webChannel() {
|
|
19693
|
+
const Channel = globalThis.BroadcastChannel;
|
|
19694
|
+
return typeof Channel === "function" ? Channel : undefined;
|
|
19695
|
+
}
|
|
19696
|
+
function webIndexedDB() {
|
|
19697
|
+
const idb = globalThis.indexedDB;
|
|
19698
|
+
return typeof idb?.open === "function" ? idb : undefined;
|
|
19699
|
+
}
|
|
19700
|
+
/**
|
|
19701
|
+
* The IndexedDB database the write counts are kept in, one per origin, as
|
|
19702
|
+
* the locks are. Not one connect keeps for anything else: those are
|
|
19703
|
+
* `muhkoo.offline` (the offline layer), `muhkoo.trust` and `muhkoo.device.v1`.
|
|
19704
|
+
*
|
|
19705
|
+
* Its version stays 1 for good. A tab still running code that opens it at
|
|
19706
|
+
* version 1 would fail with a VersionError once a newer tab had made it at
|
|
19707
|
+
* version 2, count nothing, and so write a folder without the newer tabs
|
|
19708
|
+
* knowing: the writer whose write may be lost. A new store goes in a new
|
|
19709
|
+
* database.
|
|
19710
|
+
*/
|
|
19711
|
+
const COUNTS_DB = "muhkoo.vfs";
|
|
19712
|
+
/** Its one store: `[commitment, record id]` to a count, and `CREATED` to a random id. */
|
|
19713
|
+
const COUNTS = "counts";
|
|
19714
|
+
const CREATED = "created";
|
|
19715
|
+
/**
|
|
19716
|
+
* How often each user's directory records have been written on this
|
|
19717
|
+
* device: `VfsCoordinator.counters`, `begin` and `bump`, kept in IndexedDB
|
|
19718
|
+
* for every tab of the origin.
|
|
19719
|
+
*
|
|
19720
|
+
* A count is odd from `begin` to `bump`, while a write is under way, and
|
|
19721
|
+
* even once it is counted; `counters` reports an odd one as unknown. It is
|
|
19722
|
+
* read as `<created>:<count>`, where `created` is a random id stored when
|
|
19723
|
+
* the database was made: deleted - the site's data cleared - every count
|
|
19724
|
+
* starts again from nothing, and a counter read before could otherwise
|
|
19725
|
+
* come round again with writes in between that its copy does not hold.
|
|
19726
|
+
*
|
|
19727
|
+
* Each operation is one transaction, and settles once that has completed.
|
|
19728
|
+
* Every failure - IndexedDB missing, an open refused, blocked or failed, a
|
|
19729
|
+
* transaction aborted - resolves to null, which the namespace takes for out
|
|
19730
|
+
* of date: it reads the record again. A failed or blocked open is not kept,
|
|
19731
|
+
* and nor is a connection closed under it, by a newer version of this code
|
|
19732
|
+
* in another tab or by the database being deleted: the next operation opens
|
|
19733
|
+
* the database again.
|
|
19734
|
+
*
|
|
19735
|
+
* So does an operation that has not settled within `waitMs`, the open it
|
|
19736
|
+
* waits for and its transaction together: these are awaited inside the
|
|
19737
|
+
* record locks, and one waited for without end would hold the lock, and
|
|
19738
|
+
* stop every read and write of the tab's folders, for as long. Only the
|
|
19739
|
+
* operation gives up, though, not the open: there is one open under way at
|
|
19740
|
+
* a time, every operation waits for that one, and once it has opened the
|
|
19741
|
+
* database it is kept for every later operation. Given up on too, and closed
|
|
19742
|
+
* when it opened at last, an open that always takes longer than `waitMs`
|
|
19743
|
+
* would never give the tab its counts. An open that has not answered at all
|
|
19744
|
+
* within `OPEN_DROP_MS` is dropped, though, as one that failed: one that
|
|
19745
|
+
* never will would otherwise leave the tab without counts for as long as it
|
|
19746
|
+
* lives. Until then every operation waits for it, `waitMs` at most, and
|
|
19747
|
+
* tells nothing; the next operation after that asks for one new open; and
|
|
19748
|
+
* the dropped one, if it opens after all, is closed. The new open is
|
|
19749
|
+
* dropped in turn if it goes `OPEN_DROP_MS` without an answer too, and so
|
|
19750
|
+
* on: where IndexedDB never answers, the tab leaves behind one more open
|
|
19751
|
+
* that never settles every `OPEN_DROP_MS` or a little more, for as long as
|
|
19752
|
+
* operations keep coming, and none while none come. An operation that
|
|
19753
|
+
* gives up before the database has opened makes no transaction. A
|
|
19754
|
+
* transaction given up on is not aborted, and may still complete: IndexedDB
|
|
19755
|
+
* runs the read-write transactions of one store in the order they were
|
|
19756
|
+
* made, so a late `begin` still marks the count before any later `bump`,
|
|
19757
|
+
* and a late `bump` still counts its write before any later `begin`. A tab
|
|
19758
|
+
* that asks meanwhile waits behind it, gives up, and reads the record
|
|
19759
|
+
* again.
|
|
19760
|
+
*/
|
|
19761
|
+
function writeCounts(idb, waitMs) {
|
|
19762
|
+
// The open under way, or the one that opened `opened`: one at a time.
|
|
19763
|
+
let connection = null;
|
|
19764
|
+
// The database `connection` opened, once it has.
|
|
19765
|
+
let opened = null;
|
|
19766
|
+
// Drops `connection` while it is under way, once `OPEN_DROP_MS` have passed.
|
|
19767
|
+
let dropping;
|
|
19768
|
+
const forget = (db) => {
|
|
19769
|
+
if (opened !== db)
|
|
19770
|
+
return;
|
|
19771
|
+
opened = null;
|
|
19772
|
+
connection = null;
|
|
19773
|
+
db.close();
|
|
19774
|
+
};
|
|
19775
|
+
const open = () => {
|
|
19776
|
+
if (connection)
|
|
19777
|
+
return connection;
|
|
19778
|
+
let request;
|
|
19779
|
+
try {
|
|
19780
|
+
if (!idb)
|
|
19781
|
+
throw new Error("no IndexedDB");
|
|
19782
|
+
request = idb.open(COUNTS_DB, 1);
|
|
19783
|
+
}
|
|
19784
|
+
catch {
|
|
19785
|
+
// Not kept: the next operation tries again.
|
|
19786
|
+
return Promise.resolve(null);
|
|
19787
|
+
}
|
|
19788
|
+
const pending = new Promise((resolve) => {
|
|
19789
|
+
// Not kept when it failed, or was dropped: the next operation
|
|
19790
|
+
// tries again. Failed after it was dropped, it changes nothing.
|
|
19791
|
+
const fail = () => {
|
|
19792
|
+
if (connection === pending) {
|
|
19793
|
+
clearTimeout(dropping);
|
|
19794
|
+
connection = null;
|
|
19795
|
+
}
|
|
19796
|
+
resolve(null);
|
|
19797
|
+
};
|
|
19798
|
+
request.onupgradeneeded = () => {
|
|
19799
|
+
request.result.createObjectStore(COUNTS).put(generateId(), CREATED);
|
|
19800
|
+
};
|
|
19801
|
+
request.onsuccess = () => {
|
|
19802
|
+
const db = request.result;
|
|
19803
|
+
// Blocked or dropped, and given up on, before it opened - or
|
|
19804
|
+
// closed by `close`. Another open may be under way by now.
|
|
19805
|
+
if (connection !== pending) {
|
|
19806
|
+
db.close();
|
|
19807
|
+
resolve(null);
|
|
19808
|
+
return;
|
|
19809
|
+
}
|
|
19810
|
+
clearTimeout(dropping);
|
|
19811
|
+
db.onversionchange = () => forget(db);
|
|
19812
|
+
db.onclose = () => forget(db);
|
|
19813
|
+
opened = db;
|
|
19814
|
+
resolve(db);
|
|
19815
|
+
};
|
|
19816
|
+
// Blocked: another tab holds an older version open. Not waited for.
|
|
19817
|
+
request.onerror = fail;
|
|
19818
|
+
request.onblocked = fail;
|
|
19819
|
+
// No answer at all: dropped, as one that failed, and the next
|
|
19820
|
+
// operation asks for another - dropped in turn if it does not
|
|
19821
|
+
// answer either. See above.
|
|
19822
|
+
dropping = setTimeout(fail, OPEN_DROP_MS);
|
|
19823
|
+
});
|
|
19824
|
+
connection = pending;
|
|
19825
|
+
return pending;
|
|
19826
|
+
};
|
|
19827
|
+
/**
|
|
19828
|
+
* Run `body` in one transaction, and resolve once that has completed to
|
|
19829
|
+
* what the function it returns gives then - or to null, if anything
|
|
19830
|
+
* fails, or the database has not opened and the transaction completed
|
|
19831
|
+
* within `waitMs`.
|
|
19832
|
+
*/
|
|
19833
|
+
const run = (mode, body) => new Promise((resolve) => {
|
|
19834
|
+
let done = false;
|
|
19835
|
+
const settle = (value) => {
|
|
19836
|
+
if (done)
|
|
19837
|
+
return;
|
|
19838
|
+
done = true;
|
|
19839
|
+
clearTimeout(timer);
|
|
19840
|
+
resolve(value);
|
|
19841
|
+
};
|
|
19842
|
+
// Given up on, but neither the open nor the transaction aborted: see above.
|
|
19843
|
+
const timer = setTimeout(() => settle(null), waitMs);
|
|
19844
|
+
void open().then((db) => {
|
|
19845
|
+
// Given up on while it opened: nothing to do.
|
|
19846
|
+
if (done)
|
|
19847
|
+
return;
|
|
19848
|
+
if (!db) {
|
|
19849
|
+
settle(null);
|
|
19850
|
+
return;
|
|
19851
|
+
}
|
|
19852
|
+
let tx;
|
|
19853
|
+
try {
|
|
19854
|
+
tx = db.transaction(COUNTS, mode);
|
|
19855
|
+
const result = body(tx.objectStore(COUNTS));
|
|
19856
|
+
tx.oncomplete = () => settle(result());
|
|
19857
|
+
tx.onabort = () => settle(null);
|
|
19858
|
+
}
|
|
19859
|
+
catch {
|
|
19860
|
+
try {
|
|
19861
|
+
tx?.abort();
|
|
19862
|
+
}
|
|
19863
|
+
catch {
|
|
19864
|
+
// Finished already.
|
|
19865
|
+
}
|
|
19866
|
+
// Closed under it, most likely: the next operation opens it again.
|
|
19867
|
+
forget(db);
|
|
19868
|
+
settle(null);
|
|
19869
|
+
}
|
|
19870
|
+
});
|
|
19871
|
+
});
|
|
19872
|
+
const count = (value) => (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0);
|
|
19873
|
+
const counter = (created, n) => (typeof created === "string" ? `${created}:${n}` : null);
|
|
19874
|
+
return {
|
|
19875
|
+
async counters(who, ids) {
|
|
19876
|
+
const read = await run("readonly", (store) => {
|
|
19877
|
+
const created = store.get(CREATED);
|
|
19878
|
+
const counts = ids.map((id) => store.get([who, id]));
|
|
19879
|
+
return () => counts.map((at) => {
|
|
19880
|
+
const n = count(at.result);
|
|
19881
|
+
return n % 2 === 0 ? counter(created.result, n) : null;
|
|
19882
|
+
});
|
|
19883
|
+
});
|
|
19884
|
+
return read ?? ids.map(() => null);
|
|
19885
|
+
},
|
|
19886
|
+
begin(who, id) {
|
|
19887
|
+
return run("readwrite", (store) => {
|
|
19888
|
+
const created = store.get(CREATED);
|
|
19889
|
+
const at = store.get([who, id]);
|
|
19890
|
+
let n = 0;
|
|
19891
|
+
at.onsuccess = () => {
|
|
19892
|
+
n = count(at.result);
|
|
19893
|
+
// Odd already, a write began and was never counted: its mark stays.
|
|
19894
|
+
if (n % 2 === 0)
|
|
19895
|
+
store.put(n + 1, [who, id]);
|
|
19896
|
+
};
|
|
19897
|
+
return () => counter(created.result, n);
|
|
19898
|
+
});
|
|
19899
|
+
},
|
|
19900
|
+
bump(who, id) {
|
|
19901
|
+
return run("readwrite", (store) => {
|
|
19902
|
+
const created = store.get(CREATED);
|
|
19903
|
+
const at = store.get([who, id]);
|
|
19904
|
+
let next = 0;
|
|
19905
|
+
at.onsuccess = () => {
|
|
19906
|
+
const n = count(at.result);
|
|
19907
|
+
// Past the mark `begin` stored - or, when it could not
|
|
19908
|
+
// store one, past the count it found.
|
|
19909
|
+
next = n % 2 === 0 ? n + 2 : n + 1;
|
|
19910
|
+
store.put(next, [who, id]);
|
|
19911
|
+
};
|
|
19912
|
+
return () => counter(created.result, next);
|
|
19913
|
+
});
|
|
19914
|
+
},
|
|
19915
|
+
close() {
|
|
19916
|
+
// An open still under way is closed once it opens.
|
|
19917
|
+
connection = null;
|
|
19918
|
+
clearTimeout(dropping);
|
|
19919
|
+
if (opened)
|
|
19920
|
+
forget(opened);
|
|
19921
|
+
},
|
|
19922
|
+
};
|
|
19923
|
+
}
|
|
19924
|
+
/**
|
|
19925
|
+
* The offline queue's drain lock: `fn` runs only if no other tab is
|
|
19926
|
+
* draining, and `false` says another one is. See `OutboundQueue`. Where the
|
|
19927
|
+
* browser refuses Web Locks, `fn` runs without it, as in Node, and the
|
|
19928
|
+
* lock is not asked for again: see `refusesLocks`.
|
|
19929
|
+
*/
|
|
19930
|
+
function drainLock() {
|
|
19931
|
+
const locks = webLocks();
|
|
19932
|
+
if (!locks)
|
|
19933
|
+
return undefined;
|
|
19934
|
+
let refused = false;
|
|
19935
|
+
return async (name, fn) => {
|
|
19936
|
+
if (!refused) {
|
|
19937
|
+
let answered = false;
|
|
19938
|
+
try {
|
|
19939
|
+
return await locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
19940
|
+
answered = true;
|
|
19941
|
+
if (!lock)
|
|
19942
|
+
return false;
|
|
19943
|
+
await fn();
|
|
19944
|
+
return true;
|
|
19945
|
+
});
|
|
19946
|
+
}
|
|
19947
|
+
catch (err) {
|
|
19948
|
+
if (answered || !refusesLocks(err))
|
|
19949
|
+
throw err;
|
|
19950
|
+
refused = true;
|
|
19951
|
+
}
|
|
19952
|
+
}
|
|
19953
|
+
await fn();
|
|
19954
|
+
return true;
|
|
19955
|
+
};
|
|
19956
|
+
}
|
|
19957
|
+
/**
|
|
19958
|
+
* The `VfsCoordinator` for whoever `commitment()` names: a lock per record,
|
|
19959
|
+
* `muhkoo:vfs:<commitment>:<record id>`; a move lock per entry,
|
|
19960
|
+
* `muhkoo:vfs:<commitment>:moving:<entry id>`, taken only if free, and
|
|
19961
|
+
* shared by the moves into a folder; a count of each record's writes, in
|
|
19962
|
+
* IndexedDB `muhkoo.vfs` under `[commitment, record id]` (see
|
|
19963
|
+
* `writeCounts`); and announcements on the channel `muhkoo:vfs:<commitment>`
|
|
19964
|
+
* as `{ id, writer }`, where `writer` is this tab's random id. `close` is
|
|
19965
|
+
* for tests, as are `lockWaitMs` (see `LOCK_WAIT_MS`) and `countWaitMs`
|
|
19966
|
+
* (see `COUNT_WAIT_MS`).
|
|
19967
|
+
*
|
|
19968
|
+
* Where the browser refuses Web Locks (see `refusesLocks`), it holds no
|
|
19969
|
+
* lock - every write runs as it would without a coordinator, and every
|
|
19970
|
+
* move lock is had at once - and asks for none again.
|
|
19971
|
+
*/
|
|
19972
|
+
function vfsCoordinator(commitment, opts = {}) {
|
|
19973
|
+
const locks = webLocks();
|
|
19974
|
+
const Channel = webChannel();
|
|
19975
|
+
if (!locks || !Channel)
|
|
19976
|
+
return undefined;
|
|
19977
|
+
const lockWaitMs = opts.lockWaitMs ?? LOCK_WAIT_MS;
|
|
19978
|
+
const tab = generateId();
|
|
19979
|
+
const counts = writeCounts(webIndexedDB(), opts.countWaitMs ?? COUNT_WAIT_MS);
|
|
19980
|
+
const handlers = new Set();
|
|
19981
|
+
// `channel` is null where the browser would not make one: see `listen`.
|
|
19982
|
+
let open = null;
|
|
19983
|
+
// Move locks this tab has let go of, until the browser has too. See `holdMoving`.
|
|
19984
|
+
const leaving = new Map();
|
|
19985
|
+
// The browser refused Web Locks: see `refusesLocks`.
|
|
19986
|
+
let refused = false;
|
|
19987
|
+
/**
|
|
19988
|
+
* Hear the channel of whoever is signed in now: opened the first time,
|
|
19989
|
+
* and opened again for someone else after a sign-in. What was announced
|
|
19990
|
+
* before it opened was not heard, which every handler is told.
|
|
19991
|
+
*
|
|
19992
|
+
* Where the browser will not make the channel - an opaque origin, or a
|
|
19993
|
+
* site whose data is blocked - this tab hears nothing and tells no one,
|
|
19994
|
+
* and does not ask again for the same user. That is safe: the counts,
|
|
19995
|
+
* not the announcements, decide whether a copy may be written over.
|
|
19996
|
+
*/
|
|
19997
|
+
const listen = () => {
|
|
19998
|
+
const who = commitment();
|
|
19999
|
+
if (open?.who === who)
|
|
20000
|
+
return;
|
|
20001
|
+
open?.channel?.close();
|
|
20002
|
+
open = null;
|
|
20003
|
+
if (!who)
|
|
20004
|
+
return;
|
|
20005
|
+
let channel = null;
|
|
20006
|
+
try {
|
|
20007
|
+
channel = new Channel(`muhkoo:vfs:${who}`);
|
|
20008
|
+
channel.onmessage = (event) => {
|
|
20009
|
+
const data = event.data;
|
|
20010
|
+
if (typeof data?.id !== "string" || data.writer === tab)
|
|
20011
|
+
return;
|
|
20012
|
+
for (const handler of handlers)
|
|
20013
|
+
handler(data.id);
|
|
20014
|
+
};
|
|
20015
|
+
}
|
|
20016
|
+
catch {
|
|
20017
|
+
channel = null;
|
|
20018
|
+
}
|
|
20019
|
+
open = { who, channel };
|
|
20020
|
+
for (const handler of handlers)
|
|
20021
|
+
handler(null);
|
|
20022
|
+
};
|
|
20023
|
+
return {
|
|
20024
|
+
async lock(id, fn) {
|
|
20025
|
+
listen();
|
|
20026
|
+
const who = commitment();
|
|
20027
|
+
// Signed out: the store refuses the write itself, with a clearer
|
|
20028
|
+
// message. Refused Web Locks: no tab of the app can hold this
|
|
20029
|
+
// lock, and the write goes ahead as it would without one.
|
|
20030
|
+
if (!who || refused)
|
|
20031
|
+
return fn();
|
|
20032
|
+
// Only the WAIT is bounded. Once granted, the lock is held until
|
|
20033
|
+
// the write has landed or failed: let go of while a write is still
|
|
20034
|
+
// on its way, the next tab's write could land first and then be
|
|
20035
|
+
// undone by it - the very loss this lock is for.
|
|
20036
|
+
const waiting = new AbortController();
|
|
20037
|
+
const timer = setTimeout(() => waiting.abort(), lockWaitMs);
|
|
20038
|
+
let granted = false;
|
|
20039
|
+
try {
|
|
20040
|
+
return await locks.request(`muhkoo:vfs:${who}:${id}`, { signal: waiting.signal }, () => {
|
|
20041
|
+
granted = true;
|
|
20042
|
+
clearTimeout(timer);
|
|
20043
|
+
return fn();
|
|
20044
|
+
});
|
|
20045
|
+
}
|
|
20046
|
+
catch (err) {
|
|
20047
|
+
if (!granted && waiting.signal.aborted) {
|
|
20048
|
+
throw new VfsConflictError(`another tab of this app has been writing this folder for over ${Math.round(lockWaitMs / 1000)} seconds; nothing was written - try again`);
|
|
20049
|
+
}
|
|
20050
|
+
if (granted || !refusesLocks(err))
|
|
20051
|
+
throw err;
|
|
20052
|
+
refused = true;
|
|
20053
|
+
}
|
|
20054
|
+
finally {
|
|
20055
|
+
clearTimeout(timer);
|
|
20056
|
+
}
|
|
20057
|
+
// Refused Web Locks, before anything was written: written without one.
|
|
20058
|
+
return fn();
|
|
20059
|
+
},
|
|
20060
|
+
announce(id) {
|
|
20061
|
+
listen();
|
|
20062
|
+
open?.channel?.postMessage({ id, writer: tab });
|
|
20063
|
+
},
|
|
20064
|
+
onAnnounce(handler) {
|
|
20065
|
+
handlers.add(handler);
|
|
20066
|
+
return () => void handlers.delete(handler);
|
|
20067
|
+
},
|
|
20068
|
+
listen,
|
|
20069
|
+
// Signed out, nothing is counted: the store refuses the reads and
|
|
20070
|
+
// writes themselves.
|
|
20071
|
+
async counters(ids) {
|
|
20072
|
+
const who = commitment();
|
|
20073
|
+
return who ? counts.counters(who, ids) : ids.map(() => null);
|
|
20074
|
+
},
|
|
20075
|
+
async begin(id) {
|
|
20076
|
+
const who = commitment();
|
|
20077
|
+
return who ? counts.begin(who, id) : null;
|
|
20078
|
+
},
|
|
20079
|
+
async bump(id) {
|
|
20080
|
+
const who = commitment();
|
|
20081
|
+
return who ? counts.bump(who, id) : null;
|
|
20082
|
+
},
|
|
20083
|
+
async holdMoving(id, shared = false) {
|
|
20084
|
+
const who = commitment();
|
|
20085
|
+
// Signed out: the store refuses the writes themselves. Refused Web
|
|
20086
|
+
// Locks, no tab of the app can hold this lock.
|
|
20087
|
+
if (!who || refused)
|
|
20088
|
+
return () => { };
|
|
20089
|
+
const name = `muhkoo:vfs:${who}:moving:${id}`;
|
|
20090
|
+
// Let go of by this tab, but perhaps not yet by the browser, which
|
|
20091
|
+
// lets go only once the callback below has settled. Asked for
|
|
20092
|
+
// before then - a move or a delete straight after another of the
|
|
20093
|
+
// same entry - it is still held, and this tab refuses itself.
|
|
20094
|
+
const before = leaving.get(name);
|
|
20095
|
+
if (before)
|
|
20096
|
+
await before;
|
|
20097
|
+
return new Promise((resolve, reject) => {
|
|
20098
|
+
let answered = false;
|
|
20099
|
+
const request = locks.request(name, { ifAvailable: true, mode: shared ? "shared" : "exclusive" }, async (lock) => {
|
|
20100
|
+
answered = true;
|
|
20101
|
+
if (!lock) {
|
|
20102
|
+
resolve(null);
|
|
20103
|
+
return;
|
|
20104
|
+
}
|
|
20105
|
+
// Held until this settles: when the function handed back
|
|
20106
|
+
// is called, or when the tab goes and the browser lets go
|
|
20107
|
+
// of everything it held.
|
|
20108
|
+
let held = true;
|
|
20109
|
+
await new Promise((letGo) => resolve(() => {
|
|
20110
|
+
if (!held)
|
|
20111
|
+
return;
|
|
20112
|
+
held = false;
|
|
20113
|
+
// Settles once the browser has let go of it too,
|
|
20114
|
+
// and of every other hold of it this tab has let
|
|
20115
|
+
// go of: two moves into one folder hold it at once.
|
|
20116
|
+
const gone = Promise.all([leaving.get(name), request.then(() => { }, () => { })]).then(() => { });
|
|
20117
|
+
leaving.set(name, gone);
|
|
20118
|
+
void gone.then(() => {
|
|
20119
|
+
if (leaving.get(name) === gone)
|
|
20120
|
+
leaving.delete(name);
|
|
20121
|
+
});
|
|
20122
|
+
letGo();
|
|
20123
|
+
}));
|
|
20124
|
+
});
|
|
20125
|
+
request.catch((err) => {
|
|
20126
|
+
if (answered || !refusesLocks(err)) {
|
|
20127
|
+
reject(err);
|
|
20128
|
+
return;
|
|
20129
|
+
}
|
|
20130
|
+
refused = true;
|
|
20131
|
+
resolve(() => { });
|
|
20132
|
+
});
|
|
20133
|
+
});
|
|
20134
|
+
},
|
|
20135
|
+
close() {
|
|
20136
|
+
open?.channel?.close();
|
|
20137
|
+
open = null;
|
|
20138
|
+
counts.close();
|
|
20139
|
+
},
|
|
20140
|
+
};
|
|
20141
|
+
}
|
|
20142
|
+
|
|
18204
20143
|
/**
|
|
18205
20144
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
18206
20145
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -18478,35 +20417,66 @@ class ConnectivityManager {
|
|
|
18478
20417
|
* mutation by re-invoking the real namespace method (with an `isReplay` flag so
|
|
18479
20418
|
* it doesn't re-enqueue). `drain()` replays entries in HLC order:
|
|
18480
20419
|
* - success → remove the entry;
|
|
18481
|
-
* -
|
|
18482
|
-
*
|
|
18483
|
-
*
|
|
20420
|
+
* - refused for payment (`PaymentRequiredError`: the app takes paid plans
|
|
20421
|
+
* only, or someone's payment is past due) → keep it and everything after
|
|
20422
|
+
* it, stop, and surface a `SYNC_ERROR` with `dropped: false`. The server
|
|
20423
|
+
* accepts it once the user or the app owner pays, so it waits for the next
|
|
20424
|
+
* reconnect, or for `client.app.access()` to report the app allowed;
|
|
20425
|
+
* - permanent failure (any other 4xx, or a Space whose member list refuses
|
|
20426
|
+
* this user) → drop it and surface a `SYNC_ERROR` (a write the server will
|
|
20427
|
+
* never accept must not wedge the queue forever);
|
|
18484
20428
|
* - transient/network failure → stop draining and leave the rest for the next
|
|
18485
20429
|
* reconnect.
|
|
18486
20430
|
*
|
|
18487
20431
|
* Replays are idempotent by construction (kv/db are LWW-by-key, messages dedupe
|
|
18488
20432
|
* by `clientId`, file PUTs are content-addressed), so re-running a partially
|
|
18489
20433
|
* drained queue is safe.
|
|
20434
|
+
*
|
|
20435
|
+
* Every tab of an origin shares the queue. Given a `tryLock`, a drain runs
|
|
20436
|
+
* only while holding `muhkoo:offline-drain:<commitment>`, so two tabs coming
|
|
20437
|
+
* back online replay it once between them, not once each.
|
|
18490
20438
|
*/
|
|
18491
20439
|
class OutboundQueue {
|
|
18492
20440
|
store;
|
|
20441
|
+
opts;
|
|
18493
20442
|
replayers = new Map();
|
|
18494
20443
|
draining = null;
|
|
18495
|
-
constructor(store) {
|
|
20444
|
+
constructor(store, opts = {}) {
|
|
18496
20445
|
this.store = store;
|
|
20446
|
+
this.opts = opts;
|
|
18497
20447
|
}
|
|
18498
20448
|
/** A domain registers how to replay its mutations. */
|
|
18499
20449
|
register(domain, replayer) {
|
|
18500
20450
|
this.replayers.set(domain, replayer);
|
|
18501
20451
|
}
|
|
18502
|
-
/**
|
|
20452
|
+
/**
|
|
20453
|
+
* Record a mutation for later replay; resolves with its assigned seq.
|
|
20454
|
+
* Stamped with the signed-in user's commitment, so that it is only ever
|
|
20455
|
+
* sent under their session (see {@link waiting}).
|
|
20456
|
+
*/
|
|
18503
20457
|
enqueue(entry) {
|
|
18504
|
-
|
|
20458
|
+
const who = entry.commitment ?? this.opts.commitment?.();
|
|
20459
|
+
return this.store.enqueue(who ? { ...entry, commitment: who } : entry);
|
|
18505
20460
|
}
|
|
18506
|
-
/** Number of writes still awaiting sync. */
|
|
20461
|
+
/** Number of writes still awaiting sync, every user's. */
|
|
18507
20462
|
async pending() {
|
|
18508
20463
|
return (await this.store.queued()).length;
|
|
18509
20464
|
}
|
|
20465
|
+
/**
|
|
20466
|
+
* The writes a drain would send now, in order: the signed-in user's, and
|
|
20467
|
+
* any an older SDK queued without saying whose they are. Every tab of an
|
|
20468
|
+
* origin shares this queue, and every replayer builds its request from
|
|
20469
|
+
* the session signed in when it runs, so another user's write sent now
|
|
20470
|
+
* would land in this user's space, or overwrite their data. It stays
|
|
20471
|
+
* queued, as it is, until its own user signs in on this device again.
|
|
20472
|
+
*/
|
|
20473
|
+
async waiting() {
|
|
20474
|
+
return (await this.store.queued()).filter((entry) => this.sendable(entry));
|
|
20475
|
+
}
|
|
20476
|
+
/** True for an entry this drain may send: the signed-in user's, or one with no user. */
|
|
20477
|
+
sendable(entry) {
|
|
20478
|
+
return !entry.commitment || entry.commitment === this.opts.commitment?.();
|
|
20479
|
+
}
|
|
18510
20480
|
/**
|
|
18511
20481
|
* Replay queued mutations in HLC order. Coalesces concurrent calls into a
|
|
18512
20482
|
* single in-flight drain. Stops on the first transient failure, leaving the
|
|
@@ -18515,14 +20485,49 @@ class OutboundQueue {
|
|
|
18515
20485
|
drain() {
|
|
18516
20486
|
if (this.draining)
|
|
18517
20487
|
return this.draining;
|
|
18518
|
-
this.draining = this.
|
|
20488
|
+
this.draining = this.drainOnce().finally(() => {
|
|
18519
20489
|
this.draining = null;
|
|
18520
20490
|
});
|
|
18521
20491
|
return this.draining;
|
|
18522
20492
|
}
|
|
20493
|
+
/**
|
|
20494
|
+
* One drain, holding the device-wide drain lock where there is one. A
|
|
20495
|
+
* tab that finds another tab holding it leaves the queue to that one,
|
|
20496
|
+
* which is replaying it already and removes each entry as it lands - so
|
|
20497
|
+
* a drain after it finds only what it left.
|
|
20498
|
+
*/
|
|
20499
|
+
async drainOnce() {
|
|
20500
|
+
const who = this.opts.commitment?.();
|
|
20501
|
+
if (!this.opts.tryLock || !who) {
|
|
20502
|
+
await this.runDrain();
|
|
20503
|
+
return;
|
|
20504
|
+
}
|
|
20505
|
+
await this.opts.tryLock(`muhkoo:offline-drain:${who}`, async () => {
|
|
20506
|
+
// A tab that found this one draining skipped its own drain, and
|
|
20507
|
+
// what it queued after this pass read the queue was left to this
|
|
20508
|
+
// tab. So drain until a pass leaves nothing of this user's; a
|
|
20509
|
+
// transient failure still stops it, by throwing. So does a write
|
|
20510
|
+
// refused for payment, which stays queued: another pass would
|
|
20511
|
+
// only be refused again, at once and without end. Another user's
|
|
20512
|
+
// writes never leave, so counting them would loop for ever too.
|
|
20513
|
+
do {
|
|
20514
|
+
if (!(await this.runDrain()))
|
|
20515
|
+
return;
|
|
20516
|
+
} while ((await this.waiting()).length > 0);
|
|
20517
|
+
});
|
|
20518
|
+
}
|
|
20519
|
+
/**
|
|
20520
|
+
* One pass over the queue. False when a write refused for payment stopped
|
|
20521
|
+
* it, which leaves that write and everything after it queued.
|
|
20522
|
+
*/
|
|
18523
20523
|
async runDrain() {
|
|
18524
20524
|
const entries = await this.store.queued();
|
|
18525
20525
|
for (const entry of entries) {
|
|
20526
|
+
// Another user's write, theirs to send under their own session.
|
|
20527
|
+
// Asked per entry, so a sign-out in the middle of a pass stops
|
|
20528
|
+
// the rest of it going out under the next user's session.
|
|
20529
|
+
if (!this.sendable(entry))
|
|
20530
|
+
continue;
|
|
18526
20531
|
const replayer = this.replayers.get(entry.domain);
|
|
18527
20532
|
if (!replayer) {
|
|
18528
20533
|
// No handler registered (namespace not wired) — drop it rather
|
|
@@ -18539,6 +20544,22 @@ class OutboundQueue {
|
|
|
18539
20544
|
});
|
|
18540
20545
|
}
|
|
18541
20546
|
catch (err) {
|
|
20547
|
+
// Asked before `isPermanent`, which would drop it as a 4xx.
|
|
20548
|
+
if (err instanceof PaymentRequiredError) {
|
|
20549
|
+
// The user's plan, or a payment past due: the server will
|
|
20550
|
+
// accept this write once that changes. Keep it, and keep
|
|
20551
|
+
// every write after it in order; a delete queued after an
|
|
20552
|
+
// upload never goes first. Stop rather than send the rest,
|
|
20553
|
+
// which the server would refuse for the same reason.
|
|
20554
|
+
EventCore.emit(EventCoreEvents.SYNC_ERROR, {
|
|
20555
|
+
domain: entry.domain,
|
|
20556
|
+
method: entry.method,
|
|
20557
|
+
error: err.message,
|
|
20558
|
+
code: err.code,
|
|
20559
|
+
dropped: false,
|
|
20560
|
+
});
|
|
20561
|
+
return false;
|
|
20562
|
+
}
|
|
18542
20563
|
if (isPermanent(err)) {
|
|
18543
20564
|
await this.store.dequeue(entry.seq);
|
|
18544
20565
|
EventCore.emit(EventCoreEvents.SYNC_ERROR, {
|
|
@@ -18553,12 +20574,14 @@ class OutboundQueue {
|
|
|
18553
20574
|
throw err;
|
|
18554
20575
|
}
|
|
18555
20576
|
}
|
|
20577
|
+
return true;
|
|
18556
20578
|
}
|
|
18557
20579
|
}
|
|
18558
20580
|
/** A 4xx is the server permanently refusing the write, and so is a Space whose
|
|
18559
20581
|
* member list does not admit this user (design §4.5.12): the relay refuses
|
|
18560
20582
|
* their frames. Anything else, a locked session included, is worth retrying
|
|
18561
|
-
* on the next reconnect.
|
|
20583
|
+
* on the next reconnect. A refusal for payment is a 4xx that is not
|
|
20584
|
+
* permanent; `runDrain` keeps it before asking this. */
|
|
18562
20585
|
function isPermanent(err) {
|
|
18563
20586
|
if (err instanceof AdmissionDeniedError)
|
|
18564
20587
|
return true;
|
|
@@ -18766,6 +20789,14 @@ const META_NODE_ID = "nodeId";
|
|
|
18766
20789
|
* stay decoupled from IndexedDB and the clock.
|
|
18767
20790
|
*/
|
|
18768
20791
|
const HLC_STATE_KEY = "global";
|
|
20792
|
+
/**
|
|
20793
|
+
* A random suffix for this tab's HLC node id, held in memory only - see
|
|
20794
|
+
* `OfflineManager.boot`. 48 bits: two tabs of one device never draw the same.
|
|
20795
|
+
*/
|
|
20796
|
+
function tabSuffix() {
|
|
20797
|
+
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
|
20798
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
20799
|
+
}
|
|
18769
20800
|
class OfflineManager {
|
|
18770
20801
|
/** True in browsers with IndexedDB; false when running over a NoopStore. */
|
|
18771
20802
|
enabled;
|
|
@@ -18784,7 +20815,10 @@ class OfflineManager {
|
|
|
18784
20815
|
constructor(deps) {
|
|
18785
20816
|
this.enabled = deps.enabled;
|
|
18786
20817
|
this.store = deps.store;
|
|
18787
|
-
this.queue = new OutboundQueue(deps.store
|
|
20818
|
+
this.queue = new OutboundQueue(deps.store, {
|
|
20819
|
+
tryLock: deps.locks,
|
|
20820
|
+
commitment: () => deps.session.commitment,
|
|
20821
|
+
});
|
|
18788
20822
|
this.connectivity = new ConnectivityManager({
|
|
18789
20823
|
onReconnect: () => void this.sync.run(),
|
|
18790
20824
|
});
|
|
@@ -18820,6 +20854,22 @@ class OfflineManager {
|
|
|
18820
20854
|
args: { hash },
|
|
18821
20855
|
});
|
|
18822
20856
|
}
|
|
20857
|
+
/**
|
|
20858
|
+
* Replay the queue now, as a reconnect does, if anything is waiting in it.
|
|
20859
|
+
* `client.app.access()` calls this each time it reports the app allowed,
|
|
20860
|
+
* so writes kept after a refusal for payment go out once the user or the
|
|
20861
|
+
* app owner has paid, without waiting for the next reconnect. One drain per
|
|
20862
|
+
* call, never on a timer. With nothing of the signed-in user's queued it
|
|
20863
|
+
* does not drain at all, so the app sees no "syncing". A drain also runs the
|
|
20864
|
+
* catch-up tasks (Space history paging), as a reconnect does.
|
|
20865
|
+
*/
|
|
20866
|
+
async resume() {
|
|
20867
|
+
if (!this.enabled)
|
|
20868
|
+
return;
|
|
20869
|
+
if ((await this.queue.waiting()).length === 0)
|
|
20870
|
+
return;
|
|
20871
|
+
await this.sync.run();
|
|
20872
|
+
}
|
|
18823
20873
|
/** Resolve once the clock + node id are loaded. */
|
|
18824
20874
|
ready() {
|
|
18825
20875
|
return this.bootPromise;
|
|
@@ -18882,14 +20932,22 @@ class OfflineManager {
|
|
|
18882
20932
|
this.hlc = new HLC(this.nodeId);
|
|
18883
20933
|
return;
|
|
18884
20934
|
}
|
|
18885
|
-
let
|
|
18886
|
-
if (!
|
|
18887
|
-
|
|
18888
|
-
await this.store.put("meta", META_NODE_ID,
|
|
18889
|
-
}
|
|
18890
|
-
|
|
20935
|
+
let deviceId = await this.store.get("meta", META_NODE_ID);
|
|
20936
|
+
if (!deviceId) {
|
|
20937
|
+
deviceId = generateId();
|
|
20938
|
+
await this.store.put("meta", META_NODE_ID, deviceId);
|
|
20939
|
+
}
|
|
20940
|
+
// One node id per TAB, under the device's. Every tab of an origin
|
|
20941
|
+
// loads the same stored id, and two tabs stamping with it in the same
|
|
20942
|
+
// millisecond minted identical HLCs: the node id is the tiebreak, and
|
|
20943
|
+
// the server keeps the first of two equal stamps, so the second write
|
|
20944
|
+
// was dropped. The suffix lives in memory only. The clock state below
|
|
20945
|
+
// stays shared, so the clock never goes back across tabs, and stamps
|
|
20946
|
+
// stored already stay valid: every comparison reads a packed stamp as
|
|
20947
|
+
// one string and never parses the node id.
|
|
20948
|
+
this.nodeId = `${deviceId}.${tabSuffix()}`;
|
|
18891
20949
|
const state = await this.store.get("hlc-state", HLC_STATE_KEY);
|
|
18892
|
-
this.hlc = new HLC(nodeId, { state });
|
|
20950
|
+
this.hlc = new HLC(this.nodeId, { state });
|
|
18893
20951
|
}
|
|
18894
20952
|
/** Coalesce HLC-state writes — a burst of stamps persists once. */
|
|
18895
20953
|
schedulePersist() {
|
|
@@ -19206,11 +21264,15 @@ class KvCache {
|
|
|
19206
21264
|
const local = (await this.store.get("kv-cache", k)) ?? emptyRegister();
|
|
19207
21265
|
await this.store.put("kv-cache", k, mergeRegister(local, incoming));
|
|
19208
21266
|
}
|
|
19209
|
-
/**
|
|
19210
|
-
|
|
19211
|
-
|
|
19212
|
-
|
|
19213
|
-
|
|
21267
|
+
/**
|
|
21268
|
+
* Non-tombstoned full keys for a commitment that start with `prefix` (all
|
|
21269
|
+
* of them by default), for offline `list`. The store scans the key range
|
|
21270
|
+
* `${commitment}|${prefix}`, so other collections are never read.
|
|
21271
|
+
*/
|
|
21272
|
+
async liveKeys(commitment, prefix = "") {
|
|
21273
|
+
const scope = `${commitment}|`;
|
|
21274
|
+
const rows = await this.store.prefix("kv-cache", scope + prefix);
|
|
21275
|
+
return rows.filter((r) => !r.value.deleted).map((r) => r.key.slice(scope.length));
|
|
19214
21276
|
}
|
|
19215
21277
|
/** Queue a kv mutation for replay on reconnect. */
|
|
19216
21278
|
async enqueue(method, args, hlc, clientId) {
|
|
@@ -19357,6 +21419,8 @@ class Client {
|
|
|
19357
21419
|
functions;
|
|
19358
21420
|
/** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
|
|
19359
21421
|
accessTokens;
|
|
21422
|
+
/** This app — `client.app.access()`: may the signed-in user add data here? */
|
|
21423
|
+
app;
|
|
19360
21424
|
/** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
|
|
19361
21425
|
offline;
|
|
19362
21426
|
session;
|
|
@@ -19366,13 +21430,17 @@ class Client {
|
|
|
19366
21430
|
/** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
|
|
19367
21431
|
recoverInFlight = null;
|
|
19368
21432
|
constructor(options = {}) {
|
|
19369
|
-
// Build stamp —
|
|
19370
|
-
//
|
|
19371
|
-
//
|
|
19372
|
-
|
|
19373
|
-
|
|
21433
|
+
// Build stamp — logged (independent of logLevel) so the running build
|
|
21434
|
+
// is identifiable in the console. Catches stale cached bundles during
|
|
21435
|
+
// development; bump `VERSION` (and package.json) each build.
|
|
21436
|
+
// Browser console only: in Node it lands on stdout, in front of a
|
|
21437
|
+
// CLI's own output (`muhkoo kv ls --json | jq`).
|
|
21438
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
21439
|
+
try {
|
|
21440
|
+
console.info(`[@muhkoo/connect] initialized — v${VERSION}`);
|
|
21441
|
+
}
|
|
21442
|
+
catch { /* console may be unavailable in some runtimes */ }
|
|
19374
21443
|
}
|
|
19375
|
-
catch { /* console may be unavailable in some runtimes */ }
|
|
19376
21444
|
if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
|
|
19377
21445
|
globalThis.appLogger.setLevel(options.logLevel);
|
|
19378
21446
|
}
|
|
@@ -19389,6 +21457,8 @@ class Client {
|
|
|
19389
21457
|
store: offlineStore,
|
|
19390
21458
|
session: this.session,
|
|
19391
21459
|
enabled: offlineEnabled,
|
|
21460
|
+
// Every tab of the origin shares the queue: one drains it at a time.
|
|
21461
|
+
locks: drainLock(),
|
|
19392
21462
|
});
|
|
19393
21463
|
// Wrap fetch so every accelerator round-trip feeds the connectivity
|
|
19394
21464
|
// detector: a thrown fetch is the strongest "offline" signal, a 2xx the
|
|
@@ -19474,12 +21544,13 @@ class Client {
|
|
|
19474
21544
|
throw new Error("client.vfs: no session — sign in first.");
|
|
19475
21545
|
await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
|
|
19476
21546
|
},
|
|
19477
|
-
list: async () => {
|
|
21547
|
+
list: async (prefix) => {
|
|
19478
21548
|
const c = this.session.commitment;
|
|
19479
21549
|
if (!c)
|
|
19480
21550
|
throw new Error("client.vfs: no session — sign in first.");
|
|
19481
|
-
|
|
19482
|
-
|
|
21551
|
+
// The server filters by prefix and pages. An older server ignores
|
|
21552
|
+
// the prefix and returns every key, which is why callers filter.
|
|
21553
|
+
return listPersonalKeys(this.http, `/api/personal/${encodeURIComponent(c)}/list`, prefix ?? "");
|
|
19483
21554
|
},
|
|
19484
21555
|
};
|
|
19485
21556
|
let contentSpace = null;
|
|
@@ -19508,6 +21579,9 @@ class Client {
|
|
|
19508
21579
|
this.vfs = new VfsNamespace({
|
|
19509
21580
|
store: personalStore,
|
|
19510
21581
|
seed: () => this.session.seed,
|
|
21582
|
+
// Orders writes of a folder with this app's other tabs on the
|
|
21583
|
+
// device - undefined outside a browser tab with Web Locks.
|
|
21584
|
+
coordinator: vfsCoordinator(() => this.session.commitment),
|
|
19511
21585
|
// Share the personal space's socket with `client.kv` rather than
|
|
19512
21586
|
// opening a second one to the same Durable Object.
|
|
19513
21587
|
subscribe: (handler) => this.kv.onRaw(handler),
|
|
@@ -19526,12 +21600,15 @@ class Client {
|
|
|
19526
21600
|
// the capability path, so it keeps working for a file whose
|
|
19527
21601
|
// handle was shared from someone else's filesystem.
|
|
19528
21602
|
read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
|
|
19529
|
-
|
|
19530
|
-
|
|
19531
|
-
|
|
19532
|
-
|
|
19533
|
-
|
|
19534
|
-
|
|
21603
|
+
// A reference names only shards, so taking or dropping one needs
|
|
21604
|
+
// no Space. Opening the content space here created it for a user
|
|
21605
|
+
// who had never written a file: a checkout, a copy, a delete, or
|
|
21606
|
+
// Cloud Drive's move of its pre-VFS files. A paid-only app refuses
|
|
21607
|
+
// that creation for a Free user (accelerator spec
|
|
21608
|
+
// 2026-10-06-paid-accounts-and-past-due, D12). Same body as
|
|
21609
|
+
// `Space.commitFile`, so it is the same reference.
|
|
21610
|
+
release: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "release"),
|
|
21611
|
+
retain: (manifest) => commitManifest(this.http.fetchWithRecovery, this.baseUrl, manifest, "commit"),
|
|
19535
21612
|
},
|
|
19536
21613
|
});
|
|
19537
21614
|
// Sealed into the SAME personal store as the VFS metadata, so a project
|
|
@@ -19600,6 +21677,9 @@ class Client {
|
|
|
19600
21677
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
19601
21678
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|
|
19602
21679
|
this.accessTokens = new AccessTokensNamespace({ http: this.http });
|
|
21680
|
+
// Each "allowed" sends what the offline queue kept after a refusal for
|
|
21681
|
+
// payment, without waiting for the next reconnect.
|
|
21682
|
+
this.app = new AppNamespace({ http: this.http, onAllowed: () => this.offline.resume() });
|
|
19603
21683
|
// Replay deferred shard uploads on reconnect. The bytes live in the
|
|
19604
21684
|
// Cache API keyed by hash; re-PUT them through a (defer-free) shard
|
|
19605
21685
|
// client so a failure surfaces as transient and the entry stays queued.
|
|
@@ -19608,6 +21688,7 @@ class Client {
|
|
|
19608
21688
|
baseUrl: this.baseUrl,
|
|
19609
21689
|
pathPrefix: "/api/shards",
|
|
19610
21690
|
fetch: this.http.fetch,
|
|
21691
|
+
fetchWithRecovery: this.http.fetchWithRecovery,
|
|
19611
21692
|
cache: this.offline.fileCache,
|
|
19612
21693
|
});
|
|
19613
21694
|
this.offline.registerReplayer("file", async (entry) => {
|
|
@@ -20019,5 +22100,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
20019
22100
|
default: loadRsWasm
|
|
20020
22101
|
});
|
|
20021
22102
|
|
|
20022
|
-
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
22103
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AppNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PaymentRequiredError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
20023
22104
|
//# sourceMappingURL=index.js.map
|