@tinycloud/replica 0.2.4-beta.2 → 0.3.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +7 -1
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +20 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -3
- package/dist/index.d.ts +15 -3
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/kv-storage-testing.d.ts +83 -0
- package/dist/kv-storage-testing.js +1145 -0
- package/dist/kv-storage-testing.js.map +1 -0
- package/dist/kv-storage.d.ts +155 -0
- package/dist/kv-storage.js +2587 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/kv-storage.worker.618d3a20b6f3f54ed78b68f1dc244dddc3daba46e91e9ae2b577685cc9339315.js +18891 -0
- package/dist/kv-storage.worker.d.ts +2 -0
- package/dist/kv-storage.worker.js +18891 -0
- package/dist/kv-storage.worker.js.map +1 -0
- package/dist/profile-gate.cjs +560 -0
- package/dist/profile-gate.cjs.map +1 -0
- package/dist/profile-gate.d.cts +89 -0
- package/dist/profile-gate.d.ts +89 -0
- package/dist/profile-gate.js +517 -0
- package/dist/profile-gate.js.map +1 -0
- package/dist/replica.worker.js +444 -171
- package/dist/replica.worker.js.map +1 -1
- package/dist/sqlite.cjs +926 -239
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +77 -8
- package/dist/sqlite.d.ts +77 -8
- package/dist/sqlite.js +914 -229
- package/dist/sqlite.js.map +1 -1
- package/dist/{types-f3-kYLQZ.d.cts → types-CQ61VnsP.d.cts} +1 -1
- package/dist/{types-f3-kYLQZ.d.ts → types-CQ61VnsP.d.ts} +1 -1
- package/package.json +20 -3
package/dist/replica.worker.js
CHANGED
|
@@ -6806,6 +6806,14 @@ var ErrorCodes = {
|
|
|
6806
6806
|
ABORTED: "ABORTED",
|
|
6807
6807
|
INVALID_INPUT: "INVALID_INPUT",
|
|
6808
6808
|
PERMISSION_DENIED: "PERMISSION_DENIED",
|
|
6809
|
+
// Node retry signals (tinycloud-node #347). `meta.retryable` says whether a
|
|
6810
|
+
// retry is safe; `meta.retryAfterMs` carries the node's Retry-After.
|
|
6811
|
+
/** HTTP 503 `sql_busy:<stage>` / `unavailable_retryable`: nothing was applied; retry after `meta.retryAfterMs`. */
|
|
6812
|
+
NODE_BUSY: "NODE_BUSY",
|
|
6813
|
+
/** HTTP 503 `request_timeout`: the node's deadline passed before any write began; nothing was applied. */
|
|
6814
|
+
REQUEST_TIMEOUT: "REQUEST_TIMEOUT",
|
|
6815
|
+
/** HTTP 500 `outcome_unknown`: a write may have been applied; read back before retrying a non-idempotent operation. */
|
|
6816
|
+
OUTCOME_UNKNOWN: "OUTCOME_UNKNOWN",
|
|
6809
6817
|
// KV-specific errors
|
|
6810
6818
|
KV_NOT_FOUND: "KV_NOT_FOUND",
|
|
6811
6819
|
KV_WRITE_FAILED: "KV_WRITE_FAILED",
|
|
@@ -7620,6 +7628,40 @@ var ServiceContext = class {
|
|
|
7620
7628
|
return combined.signal;
|
|
7621
7629
|
}
|
|
7622
7630
|
};
|
|
7631
|
+
function parseServiceErrorBody(errorText) {
|
|
7632
|
+
try {
|
|
7633
|
+
return JSON.parse(errorText);
|
|
7634
|
+
} catch {
|
|
7635
|
+
return {};
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
function formatServiceResponseError(serviceLabel, operation, status, errorText, parsed) {
|
|
7639
|
+
if (parsed.message) {
|
|
7640
|
+
return compactMessage(parsed.message);
|
|
7641
|
+
}
|
|
7642
|
+
if (looksLikeHtml(errorText)) {
|
|
7643
|
+
if (status === 524 || /524\s*[:-]/i.test(errorText) || /a timeout occurred/i.test(errorText)) {
|
|
7644
|
+
return `${serviceLabel} ${operation} failed: upstream request timed out. Please retry.`;
|
|
7645
|
+
}
|
|
7646
|
+
return `${serviceLabel} ${operation} failed: upstream service returned an HTML error page (${status}).`;
|
|
7647
|
+
}
|
|
7648
|
+
return `${serviceLabel} ${operation} failed: ${status} - ${compactMessage(errorText)}`;
|
|
7649
|
+
}
|
|
7650
|
+
function responseErrorMeta(status, statusText, errorText) {
|
|
7651
|
+
const meta = { status, statusText };
|
|
7652
|
+
const snippet = compactMessage(errorText);
|
|
7653
|
+
if (snippet) {
|
|
7654
|
+
meta.responseSnippet = snippet.slice(0, 300);
|
|
7655
|
+
}
|
|
7656
|
+
return meta;
|
|
7657
|
+
}
|
|
7658
|
+
function looksLikeHtml(text) {
|
|
7659
|
+
return /<!doctype html/i.test(text) || /<html[\s>]/i.test(text);
|
|
7660
|
+
}
|
|
7661
|
+
function compactMessage(message) {
|
|
7662
|
+
const compact = message.replace(/\s+/g, " ").trim();
|
|
7663
|
+
return compact.length > 500 ? `${compact.slice(0, 500)}...` : compact;
|
|
7664
|
+
}
|
|
7623
7665
|
var URN_PREFIX = "urn:tinycloud:encryption:";
|
|
7624
7666
|
var NETWORK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
7625
7667
|
var PKH_EIP155_DID_RE = /^did:pkh:eip155:(\d+):(0x[a-fA-F0-9]{40})$/;
|
|
@@ -7732,6 +7774,30 @@ function authRequiredError(service) {
|
|
|
7732
7774
|
service
|
|
7733
7775
|
};
|
|
7734
7776
|
}
|
|
7777
|
+
function nodeRetryError(service, message, response, errorText) {
|
|
7778
|
+
const body = errorText.trim();
|
|
7779
|
+
const [, token, stage] = /^([a-z_]+)(?::\s*([a-z_]+):)?/.exec(body) ?? [];
|
|
7780
|
+
let code;
|
|
7781
|
+
if (response.status === 503 && (token === "sql_busy" || token === "unavailable_retryable")) {
|
|
7782
|
+
code = ErrorCodes.NODE_BUSY;
|
|
7783
|
+
} else if (response.status === 503 && token === "request_timeout") {
|
|
7784
|
+
code = ErrorCodes.REQUEST_TIMEOUT;
|
|
7785
|
+
} else if (response.status === 500 && token === "outcome_unknown") {
|
|
7786
|
+
code = ErrorCodes.OUTCOME_UNKNOWN;
|
|
7787
|
+
} else {
|
|
7788
|
+
return void 0;
|
|
7789
|
+
}
|
|
7790
|
+
const retryable = code !== ErrorCodes.OUTCOME_UNKNOWN;
|
|
7791
|
+
const retryAfter = response.headers.get("retry-after")?.trim();
|
|
7792
|
+
return serviceError(code, message, service, {
|
|
7793
|
+
meta: {
|
|
7794
|
+
...responseErrorMeta(response.status, response.statusText, errorText),
|
|
7795
|
+
retryable,
|
|
7796
|
+
...token === "sql_busy" && stage ? { stage } : {},
|
|
7797
|
+
...retryable && retryAfter && /^\d{1,9}$/.test(retryAfter) ? { retryAfterMs: Number(retryAfter) * 1e3 } : {}
|
|
7798
|
+
}
|
|
7799
|
+
});
|
|
7800
|
+
}
|
|
7735
7801
|
var RequestTimeoutError = class extends Error {
|
|
7736
7802
|
constructor(timeoutMs) {
|
|
7737
7803
|
super(`Request timed out after ${timeoutMs}ms.`);
|
|
@@ -9035,9 +9101,12 @@ var KVService = class extends BaseService {
|
|
|
9035
9101
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9036
9102
|
));
|
|
9037
9103
|
}
|
|
9104
|
+
const message = `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`;
|
|
9105
|
+
const retryError = nodeRetryError("kv", message, response, errorText);
|
|
9106
|
+
if (retryError) return err(retryError);
|
|
9038
9107
|
return err(serviceError(
|
|
9039
9108
|
ErrorCodes.NETWORK_ERROR,
|
|
9040
|
-
|
|
9109
|
+
message,
|
|
9041
9110
|
"kv",
|
|
9042
9111
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9043
9112
|
));
|
|
@@ -9119,19 +9188,14 @@ var KVService = class extends BaseService {
|
|
|
9119
9188
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9120
9189
|
));
|
|
9121
9190
|
}
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
`Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
|
|
9126
|
-
"kv",
|
|
9127
|
-
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9128
|
-
));
|
|
9129
|
-
}
|
|
9191
|
+
const message = `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`;
|
|
9192
|
+
const retryError = nodeRetryError("kv", message, response, errorText);
|
|
9193
|
+
if (retryError) return err(retryError);
|
|
9130
9194
|
const quotaError = this.handleQuotaErrorResponse(response, errorText, key);
|
|
9131
9195
|
if (quotaError) return quotaError;
|
|
9132
9196
|
return err(serviceError(
|
|
9133
9197
|
ErrorCodes.KV_WRITE_FAILED,
|
|
9134
|
-
|
|
9198
|
+
message,
|
|
9135
9199
|
"kv",
|
|
9136
9200
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9137
9201
|
));
|
|
@@ -9431,9 +9495,12 @@ var KVService = class extends BaseService {
|
|
|
9431
9495
|
);
|
|
9432
9496
|
}
|
|
9433
9497
|
const errorText = await response.text();
|
|
9498
|
+
const message = `Failed to list keys: ${response.status} - ${errorText}`;
|
|
9499
|
+
const retryError = nodeRetryError("kv", message, response, errorText);
|
|
9500
|
+
if (retryError) return err(retryError);
|
|
9434
9501
|
return err(serviceError(
|
|
9435
9502
|
ErrorCodes.NETWORK_ERROR,
|
|
9436
|
-
|
|
9503
|
+
message,
|
|
9437
9504
|
"kv",
|
|
9438
9505
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9439
9506
|
));
|
|
@@ -9684,17 +9751,12 @@ var KVService = class extends BaseService {
|
|
|
9684
9751
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9685
9752
|
));
|
|
9686
9753
|
}
|
|
9687
|
-
|
|
9688
|
-
|
|
9689
|
-
|
|
9690
|
-
`Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
|
|
9691
|
-
"kv",
|
|
9692
|
-
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9693
|
-
));
|
|
9694
|
-
}
|
|
9754
|
+
const message = `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`;
|
|
9755
|
+
const retryError = nodeRetryError("kv", message, response, errorText);
|
|
9756
|
+
if (retryError) return err(retryError);
|
|
9695
9757
|
return err(serviceError(
|
|
9696
9758
|
ErrorCodes.NETWORK_ERROR,
|
|
9697
|
-
|
|
9759
|
+
message,
|
|
9698
9760
|
"kv",
|
|
9699
9761
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9700
9762
|
));
|
|
@@ -9735,10 +9797,13 @@ var KVService = class extends BaseService {
|
|
|
9735
9797
|
return await this.classifyNotFound(response, key);
|
|
9736
9798
|
}
|
|
9737
9799
|
const errorText = await response.text();
|
|
9800
|
+
const message = `Failed to get metadata for key ${JSON.stringify(key)}: ${response.status} - ${errorText}`;
|
|
9801
|
+
const retryError = nodeRetryError("kv", message, response, errorText);
|
|
9802
|
+
if (retryError) return err(retryError);
|
|
9738
9803
|
return err(
|
|
9739
9804
|
serviceError(
|
|
9740
9805
|
ErrorCodes.NETWORK_ERROR,
|
|
9741
|
-
|
|
9806
|
+
message,
|
|
9742
9807
|
"kv",
|
|
9743
9808
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
9744
9809
|
)
|
|
@@ -9867,40 +9932,6 @@ var KVService = class extends BaseService {
|
|
|
9867
9932
|
}
|
|
9868
9933
|
};
|
|
9869
9934
|
KVService.serviceName = "kv";
|
|
9870
|
-
function parseServiceErrorBody(errorText) {
|
|
9871
|
-
try {
|
|
9872
|
-
return JSON.parse(errorText);
|
|
9873
|
-
} catch {
|
|
9874
|
-
return {};
|
|
9875
|
-
}
|
|
9876
|
-
}
|
|
9877
|
-
function formatServiceResponseError(serviceLabel, operation, status, errorText, parsed) {
|
|
9878
|
-
if (parsed.message) {
|
|
9879
|
-
return compactMessage(parsed.message);
|
|
9880
|
-
}
|
|
9881
|
-
if (looksLikeHtml(errorText)) {
|
|
9882
|
-
if (status === 524 || /524\s*[:-]/i.test(errorText) || /a timeout occurred/i.test(errorText)) {
|
|
9883
|
-
return `${serviceLabel} ${operation} failed: upstream request timed out. Please retry.`;
|
|
9884
|
-
}
|
|
9885
|
-
return `${serviceLabel} ${operation} failed: upstream service returned an HTML error page (${status}).`;
|
|
9886
|
-
}
|
|
9887
|
-
return `${serviceLabel} ${operation} failed: ${status} - ${compactMessage(errorText)}`;
|
|
9888
|
-
}
|
|
9889
|
-
function responseErrorMeta(status, statusText, errorText) {
|
|
9890
|
-
const meta = { status, statusText };
|
|
9891
|
-
const snippet = compactMessage(errorText);
|
|
9892
|
-
if (snippet) {
|
|
9893
|
-
meta.responseSnippet = snippet.slice(0, 300);
|
|
9894
|
-
}
|
|
9895
|
-
return meta;
|
|
9896
|
-
}
|
|
9897
|
-
function looksLikeHtml(text) {
|
|
9898
|
-
return /<!doctype html/i.test(text) || /<html[\s>]/i.test(text);
|
|
9899
|
-
}
|
|
9900
|
-
function compactMessage(message) {
|
|
9901
|
-
const compact = message.replace(/\s+/g, " ").trim();
|
|
9902
|
-
return compact.length > 500 ? `${compact.slice(0, 500)}...` : compact;
|
|
9903
|
-
}
|
|
9904
9935
|
var SQLMigrations = class {
|
|
9905
9936
|
constructor(service, dbName) {
|
|
9906
9937
|
this.service = service;
|
|
@@ -10308,6 +10339,8 @@ var SQLService = class extends BaseService {
|
|
|
10308
10339
|
if (response.status === 413 && parseStorageRejection(errorText)) {
|
|
10309
10340
|
return err(storageRejectionError("sql", 413, meta, errorText));
|
|
10310
10341
|
}
|
|
10342
|
+
const retryError = nodeRetryError("sql", `SQL ${operation} failed: ${response.status} - ${errorText}`, response, errorText);
|
|
10343
|
+
if (retryError) return err(retryError);
|
|
10311
10344
|
const errorBody = parseServiceErrorBody(errorText);
|
|
10312
10345
|
const errorCode = this.mapHttpStatusToErrorCode(
|
|
10313
10346
|
response.status,
|
|
@@ -10770,6 +10803,8 @@ var DuckDbService = class extends BaseService {
|
|
|
10770
10803
|
if (response.status === 413 && parseStorageRejection(errorText)) {
|
|
10771
10804
|
return err(storageRejectionError("duckdb", 413, meta, errorText));
|
|
10772
10805
|
}
|
|
10806
|
+
const retryError = nodeRetryError("duckdb", `DuckDB ${operation} failed: ${response.status} - ${errorText}`, response, errorText);
|
|
10807
|
+
if (retryError) return err(retryError);
|
|
10773
10808
|
const errorBody = parseServiceErrorBody(errorText);
|
|
10774
10809
|
const errorCode = this.mapHttpStatusToErrorCode(
|
|
10775
10810
|
response.status,
|
|
@@ -14647,7 +14682,13 @@ var ReplicaErrorCode = {
|
|
|
14647
14682
|
/** A stored blob no longer hashes to its name. */
|
|
14648
14683
|
INTEGRITY_ERROR: "INTEGRITY_ERROR",
|
|
14649
14684
|
/** The replica handle was closed; in-flight calls settle with this code. */
|
|
14650
|
-
CLOSED: "REPLICA_CLOSED"
|
|
14685
|
+
CLOSED: "REPLICA_CLOSED",
|
|
14686
|
+
/** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
|
|
14687
|
+
GATED: "REPLICA_GATED",
|
|
14688
|
+
/** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
|
|
14689
|
+
STORAGE_BUDGET: "STORAGE_BUDGET",
|
|
14690
|
+
/** The consent deadline passed: the grant is not served until the user consents again. */
|
|
14691
|
+
CONSENT_EXPIRED: "CONSENT_EXPIRED"
|
|
14651
14692
|
};
|
|
14652
14693
|
var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
|
|
14653
14694
|
var ReplicaError = class extends Error {
|
|
@@ -14900,6 +14941,11 @@ var Replica = class {
|
|
|
14900
14941
|
* active grant serves: a revoked pending grant is discarded, any other
|
|
14901
14942
|
* refusal is recorded on it. A revocation of the grant serving the replica,
|
|
14902
14943
|
* learned anywhere in the sync, purges the replica before this returns.
|
|
14944
|
+
*
|
|
14945
|
+
* `signal` cancels between commits: an aborted sync commits nothing past
|
|
14946
|
+
* its last complete page. `finish` runs once every feed page, repair batch
|
|
14947
|
+
* and the GC have committed, still under the sync lease — a sync that fails
|
|
14948
|
+
* or aborts earlier never reaches it.
|
|
14903
14949
|
*/
|
|
14904
14950
|
async sync(options = {}) {
|
|
14905
14951
|
const transportFor = this.#transportFor;
|
|
@@ -14923,7 +14969,12 @@ var Replica = class {
|
|
|
14923
14969
|
signal: options.signal
|
|
14924
14970
|
};
|
|
14925
14971
|
try {
|
|
14926
|
-
|
|
14972
|
+
const report = await this.#syncUnder(attempt, options.limit ?? DEFAULT_PAGE_LIMIT);
|
|
14973
|
+
if (options.finish !== void 0) {
|
|
14974
|
+
options.signal?.throwIfAborted();
|
|
14975
|
+
await options.finish(lease);
|
|
14976
|
+
}
|
|
14977
|
+
return report;
|
|
14927
14978
|
} catch (error) {
|
|
14928
14979
|
const canFallBack = pending && attempt.promote !== null && index < candidates.length - 1;
|
|
14929
14980
|
if (canFallBack && isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) {
|
|
@@ -15020,6 +15071,7 @@ var Replica = class {
|
|
|
15020
15071
|
const verified = await this.#verify(page, attempt);
|
|
15021
15072
|
coverage = page.more ? coverage === "complete" ? "complete" : "bootstrapping" : "complete";
|
|
15022
15073
|
const at = new Date(this.#now()).toISOString();
|
|
15074
|
+
attempt.signal?.throwIfAborted();
|
|
15023
15075
|
await this.#store.applyPage(attempt.lease, {
|
|
15024
15076
|
changes: verified.changes,
|
|
15025
15077
|
blobs: verified.blobs,
|
|
@@ -15150,6 +15202,7 @@ var Replica = class {
|
|
|
15150
15202
|
await this.#store.renewLease(attempt.lease, this.#leaseTtlMs);
|
|
15151
15203
|
if (changes.length === 0) continue;
|
|
15152
15204
|
const state = await this.#state();
|
|
15205
|
+
attempt.signal?.throwIfAborted();
|
|
15153
15206
|
await this.#store.applyPage(attempt.lease, {
|
|
15154
15207
|
changes,
|
|
15155
15208
|
blobs,
|
|
@@ -17052,9 +17105,9 @@ function requestAsPromise(request) {
|
|
|
17052
17105
|
}
|
|
17053
17106
|
function transactionDone(tx) {
|
|
17054
17107
|
const { promise, resolve, reject } = Promise.withResolvers();
|
|
17055
|
-
tx.
|
|
17056
|
-
tx.
|
|
17057
|
-
tx.
|
|
17108
|
+
tx.addEventListener("complete", () => resolve(), { once: true });
|
|
17109
|
+
tx.addEventListener("abort", () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError")), { once: true });
|
|
17110
|
+
tx.addEventListener("error", () => reject(tx.error ?? new DOMException("Transaction failed", "UnknownError")), { once: true });
|
|
17058
17111
|
return promise;
|
|
17059
17112
|
}
|
|
17060
17113
|
function openDatabase(name, upgrade) {
|
|
@@ -17106,6 +17159,32 @@ function idbError(error, action) {
|
|
|
17106
17159
|
return new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, `${action}: ${message}`, void 0, { cause: error });
|
|
17107
17160
|
}
|
|
17108
17161
|
|
|
17162
|
+
// ../sdk-services/dist/kv/replication/grantPolicy.js
|
|
17163
|
+
var HOUR_MS = 60 * 60 * 1e3;
|
|
17164
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
17165
|
+
var MAX_GRANT_TTL_MS = 7 * DAY_MS;
|
|
17166
|
+
var CONSENT_WINDOW_MS = 7 * DAY_MS;
|
|
17167
|
+
var EXPIRED = Number.NEGATIVE_INFINITY;
|
|
17168
|
+
var consentDeadlineMs = (anchor) => Number.isFinite(anchor.at) ? anchor.at + CONSENT_WINDOW_MS : EXPIRED;
|
|
17169
|
+
var clampConsentAnchor = (anchor, now) => {
|
|
17170
|
+
if (anchor === null || !Number.isFinite(anchor.at) || !Number.isFinite(now)) return null;
|
|
17171
|
+
return anchor.at > now ? { at: now, source: anchor.source } : anchor;
|
|
17172
|
+
};
|
|
17173
|
+
var effectiveGrantExpiry = ({
|
|
17174
|
+
signedExpiresAt,
|
|
17175
|
+
installedAt,
|
|
17176
|
+
grantTtlMs,
|
|
17177
|
+
consentDeadline
|
|
17178
|
+
}) => {
|
|
17179
|
+
const signedValid = signedExpiresAt === null || Number.isFinite(signedExpiresAt);
|
|
17180
|
+
if (!signedValid || !Number.isFinite(installedAt) || !Number.isFinite(grantTtlMs) || !Number.isFinite(consentDeadline)) {
|
|
17181
|
+
return EXPIRED;
|
|
17182
|
+
}
|
|
17183
|
+
const ttl = Math.min(grantTtlMs, MAX_GRANT_TTL_MS);
|
|
17184
|
+
const localBound = Math.min(installedAt + ttl, consentDeadline);
|
|
17185
|
+
return signedExpiresAt === null ? localBound : Math.min(signedExpiresAt, localBound);
|
|
17186
|
+
};
|
|
17187
|
+
|
|
17109
17188
|
// src/browser/store.ts
|
|
17110
17189
|
var DURABILITY = "indexeddb-strict";
|
|
17111
17190
|
var STORE_META = "meta";
|
|
@@ -17113,6 +17192,21 @@ var STORE_ENTRIES = "entries";
|
|
|
17113
17192
|
var STORE_BLOBS = "blobs";
|
|
17114
17193
|
var ALL_STORES = [STORE_META, STORE_ENTRIES, STORE_BLOBS];
|
|
17115
17194
|
var DEVICE_DATABASE = "tinycloud-replica-device";
|
|
17195
|
+
function rc1Layout(replicaId) {
|
|
17196
|
+
return {
|
|
17197
|
+
partitionId: replicaId,
|
|
17198
|
+
readScope: [],
|
|
17199
|
+
writeScope: [],
|
|
17200
|
+
metaKey: "state",
|
|
17201
|
+
outOfLineKeys: false,
|
|
17202
|
+
entryKey: (key) => key,
|
|
17203
|
+
blobKey: (hash) => hash,
|
|
17204
|
+
blobHashOf: (key) => typeof key === "string" ? key : void 0,
|
|
17205
|
+
range: null,
|
|
17206
|
+
tag: {},
|
|
17207
|
+
blobSizeIndex: null
|
|
17208
|
+
};
|
|
17209
|
+
}
|
|
17116
17210
|
function replicaDatabaseName(replicaId) {
|
|
17117
17211
|
return `tinycloud-replica/${replicaId}`;
|
|
17118
17212
|
}
|
|
@@ -17122,6 +17216,39 @@ function createStores(db) {
|
|
|
17122
17216
|
entries.createIndex("byHash", "hash", { unique: false });
|
|
17123
17217
|
db.createObjectStore(STORE_BLOBS, { keyPath: "hash" });
|
|
17124
17218
|
}
|
|
17219
|
+
function clearReplicaRows(tx, layout) {
|
|
17220
|
+
for (const name of [STORE_ENTRIES, STORE_BLOBS]) {
|
|
17221
|
+
const store = tx.objectStore(name);
|
|
17222
|
+
if (layout.range === null) store.clear();
|
|
17223
|
+
else store.delete(layout.range);
|
|
17224
|
+
}
|
|
17225
|
+
}
|
|
17226
|
+
async function blobSizes(tx, layout) {
|
|
17227
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
17228
|
+
if (layout.blobSizeIndex === null) return sizes;
|
|
17229
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
17230
|
+
const request = tx.objectStore(STORE_BLOBS).index(layout.blobSizeIndex).openKeyCursor(layout.range);
|
|
17231
|
+
request.onsuccess = () => {
|
|
17232
|
+
const cursor = request.result;
|
|
17233
|
+
if (cursor === null) {
|
|
17234
|
+
resolve();
|
|
17235
|
+
return;
|
|
17236
|
+
}
|
|
17237
|
+
const hash = layout.blobHashOf(cursor.primaryKey);
|
|
17238
|
+
const key = cursor.key;
|
|
17239
|
+
const size = Array.isArray(key) ? key[key.length - 1] : void 0;
|
|
17240
|
+
if (hash !== void 0 && typeof size === "number") sizes.set(hash, size);
|
|
17241
|
+
cursor.continue();
|
|
17242
|
+
};
|
|
17243
|
+
request.onerror = () => reject(request.error);
|
|
17244
|
+
await promise;
|
|
17245
|
+
return sizes;
|
|
17246
|
+
}
|
|
17247
|
+
function sumSizes(sizes) {
|
|
17248
|
+
let total = 0;
|
|
17249
|
+
for (const size of sizes.values()) total += size;
|
|
17250
|
+
return total;
|
|
17251
|
+
}
|
|
17125
17252
|
async function deviceIdentity(getKey) {
|
|
17126
17253
|
let db;
|
|
17127
17254
|
try {
|
|
@@ -17165,7 +17292,10 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17165
17292
|
#db;
|
|
17166
17293
|
#holder;
|
|
17167
17294
|
#now;
|
|
17168
|
-
#
|
|
17295
|
+
#layout;
|
|
17296
|
+
#hooks;
|
|
17297
|
+
/** False when the connection is shared (the origin database): close() leaves it open. */
|
|
17298
|
+
#ownsDb;
|
|
17169
17299
|
#closed = false;
|
|
17170
17300
|
/**
|
|
17171
17301
|
* The generation this connection opened under, read from `meta` at open.
|
|
@@ -17174,11 +17304,13 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17174
17304
|
* with RESET_REQUIRED, checked durably inside the transaction itself.
|
|
17175
17305
|
*/
|
|
17176
17306
|
#generation = 0;
|
|
17177
|
-
constructor(db,
|
|
17307
|
+
constructor(db, options) {
|
|
17178
17308
|
this.#db = db;
|
|
17179
|
-
this.#holder = holder;
|
|
17180
|
-
this.#now = now;
|
|
17181
|
-
this.#
|
|
17309
|
+
this.#holder = options.holder;
|
|
17310
|
+
this.#now = options.now;
|
|
17311
|
+
this.#layout = options.layout;
|
|
17312
|
+
this.#hooks = options.hooks;
|
|
17313
|
+
this.#ownsDb = options.ownsDb;
|
|
17182
17314
|
}
|
|
17183
17315
|
static async open(replicaId, options) {
|
|
17184
17316
|
let db;
|
|
@@ -17188,16 +17320,43 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17188
17320
|
if (isReplicaError(error)) throw error;
|
|
17189
17321
|
throw idbError(error, "Opening the replica");
|
|
17190
17322
|
}
|
|
17191
|
-
const store = new _IndexedDbReplicaStore(db,
|
|
17323
|
+
const store = new _IndexedDbReplicaStore(db, {
|
|
17324
|
+
holder: options.holder,
|
|
17325
|
+
now: options.now ?? Date.now,
|
|
17326
|
+
layout: rc1Layout(replicaId),
|
|
17327
|
+
hooks: {},
|
|
17328
|
+
ownsDb: true
|
|
17329
|
+
});
|
|
17330
|
+
await store.#start({ finishPurge: true });
|
|
17331
|
+
return store;
|
|
17332
|
+
}
|
|
17333
|
+
/**
|
|
17334
|
+
* One replica inside an already-open shared database (the origin
|
|
17335
|
+
* database of `@tinycloud/replica/kv-storage`). `ownsDb` hands the
|
|
17336
|
+
* connection to the store, which then closes it on `close()`. With
|
|
17337
|
+
* `inspect` the store never writes: a torn revocation purge is left for
|
|
17338
|
+
* the next writable open.
|
|
17339
|
+
*/
|
|
17340
|
+
static async openIn(db, options) {
|
|
17341
|
+
const store = new _IndexedDbReplicaStore(db, {
|
|
17342
|
+
holder: options.holder,
|
|
17343
|
+
now: options.now ?? Date.now,
|
|
17344
|
+
layout: options.layout,
|
|
17345
|
+
hooks: options.hooks ?? {},
|
|
17346
|
+
ownsDb: options.ownsDb === true
|
|
17347
|
+
});
|
|
17348
|
+
await store.#start({ finishPurge: options.inspect !== true });
|
|
17349
|
+
return store;
|
|
17350
|
+
}
|
|
17351
|
+
async #start(options) {
|
|
17192
17352
|
try {
|
|
17193
|
-
const state = await
|
|
17194
|
-
|
|
17195
|
-
await
|
|
17353
|
+
const state = await this.#transact("readonly", [], "Opening the replica", async (_tx, row) => row, { skipFence: true });
|
|
17354
|
+
this.#generation = state?.generation ?? 0;
|
|
17355
|
+
if (options.finishPurge) await this.finishPurgeIfPending();
|
|
17196
17356
|
} catch (error) {
|
|
17197
|
-
db.close();
|
|
17198
|
-
throw
|
|
17357
|
+
if (this.#ownsDb) this.#db.close();
|
|
17358
|
+
throw error;
|
|
17199
17359
|
}
|
|
17200
|
-
return store;
|
|
17201
17360
|
}
|
|
17202
17361
|
/**
|
|
17203
17362
|
* The fence: the durable `meta` row must match the generation this store
|
|
@@ -17212,64 +17371,119 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17212
17371
|
}
|
|
17213
17372
|
}
|
|
17214
17373
|
/**
|
|
17215
|
-
*
|
|
17216
|
-
*
|
|
17217
|
-
*
|
|
17374
|
+
* Reads the durable CID timestamp and identity anchor inside the caller's
|
|
17375
|
+
* guarded transaction. Never uses the current time as a substitute for a
|
|
17376
|
+
* missing/corrupt install timestamp: reopening cannot renew a grant.
|
|
17218
17377
|
*/
|
|
17219
|
-
async #
|
|
17220
|
-
|
|
17221
|
-
|
|
17222
|
-
|
|
17223
|
-
|
|
17224
|
-
|
|
17225
|
-
|
|
17226
|
-
|
|
17378
|
+
async #policyView(tx, state) {
|
|
17379
|
+
const binding = this.#hooks.grantPolicy;
|
|
17380
|
+
if (binding === void 0) return void 0;
|
|
17381
|
+
const device = tx.objectStore("device");
|
|
17382
|
+
const stored = await requestAsPromise(device.get(binding.firstInstallKey));
|
|
17383
|
+
const now = this.#now();
|
|
17384
|
+
const anchor = clampConsentAnchor(binding.policy.consentAnchor, now) ?? (stored === void 0 ? null : clampConsentAnchor({ at: stored.at, source: "first_install" }, now));
|
|
17385
|
+
const consentDeadline = anchor === null ? Number.NEGATIVE_INFINITY : consentDeadlineMs(anchor);
|
|
17386
|
+
const grants = {};
|
|
17387
|
+
for (const record of [state.grant, state.pendingGrant]) {
|
|
17388
|
+
if (record === null || grants[record.cid] !== void 0) continue;
|
|
17389
|
+
const installed = await requestAsPromise(device.get(binding.installedAtKey(record.cid)));
|
|
17390
|
+
const installedAt = installed?.at;
|
|
17391
|
+
grants[record.cid] = {
|
|
17392
|
+
installedAt,
|
|
17393
|
+
effectiveExpiresAt: effectiveGrantExpiry({
|
|
17394
|
+
signedExpiresAt: record.expiresAt === null ? null : record.expiresAt * 1e3,
|
|
17395
|
+
installedAt: installedAt ?? Number.NEGATIVE_INFINITY,
|
|
17396
|
+
grantTtlMs: binding.policy.grantTtlMs,
|
|
17397
|
+
consentDeadline
|
|
17398
|
+
})
|
|
17399
|
+
};
|
|
17400
|
+
}
|
|
17401
|
+
return { anchor, consentDeadline, grants };
|
|
17402
|
+
}
|
|
17403
|
+
#cappedGrant(record, view) {
|
|
17404
|
+
if (record === null || view === void 0) return record;
|
|
17405
|
+
const expiry = view.grants[record.cid]?.effectiveExpiresAt;
|
|
17406
|
+
return { ...record, expiresAt: expiry !== void 0 && Number.isFinite(expiry) ? expiry / 1e3 : 0 };
|
|
17407
|
+
}
|
|
17408
|
+
async #assertServing(tx, state) {
|
|
17409
|
+
const view = await this.#policyView(tx, state);
|
|
17410
|
+
if (view === void 0) return;
|
|
17411
|
+
const now = this.#now();
|
|
17412
|
+
if (!Number.isFinite(view.consentDeadline) || !Number.isFinite(now) || now >= view.consentDeadline) {
|
|
17413
|
+
throw new ReplicaError(ReplicaErrorCode.CONSENT_EXPIRED, "The replica consent window ended; local reads are blocked.");
|
|
17227
17414
|
}
|
|
17228
|
-
const
|
|
17229
|
-
|
|
17230
|
-
|
|
17415
|
+
const grant = this.#cappedGrant(state.grant, view);
|
|
17416
|
+
if (grant !== null && (!Number.isFinite(view.grants[grant.cid]?.effectiveExpiresAt) || now >= view.grants[grant.cid].effectiveExpiresAt)) {
|
|
17417
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The effective grant expired; local reads are blocked.");
|
|
17418
|
+
}
|
|
17419
|
+
assertReadable(effectiveAuthority({
|
|
17420
|
+
window: state.authority,
|
|
17421
|
+
grant,
|
|
17422
|
+
revoked: state.revoked,
|
|
17423
|
+
retentionRevoked: state.retentionRevoked,
|
|
17424
|
+
policy: "whileGrantValid",
|
|
17425
|
+
now
|
|
17426
|
+
}), now);
|
|
17231
17427
|
}
|
|
17232
17428
|
/**
|
|
17233
|
-
*
|
|
17234
|
-
*
|
|
17235
|
-
*
|
|
17236
|
-
*
|
|
17237
|
-
*
|
|
17238
|
-
*
|
|
17239
|
-
*
|
|
17429
|
+
* One transaction over `stores` plus `meta`. With a gate (`hooks.run`) the
|
|
17430
|
+
* gate opens it — adding its control stores and asserting the handle's
|
|
17431
|
+
* access before anything else; otherwise this store opens it over the
|
|
17432
|
+
* layout's scope. Inside, the state row is read and, unless `skipFence`,
|
|
17433
|
+
* fenced against this store's generation before `body` runs. `body` may
|
|
17434
|
+
* only await IDB requests issued on `tx`. Write transactions use `strict`
|
|
17435
|
+
* durability and settle after the commit; any failure aborts them.
|
|
17436
|
+
* `skipFence` is for `init` and the open-time reads, which handle the
|
|
17437
|
+
* tombstone and generation themselves.
|
|
17240
17438
|
*/
|
|
17241
|
-
async #
|
|
17439
|
+
async #transact(mode, stores, action, body, options = {}) {
|
|
17242
17440
|
if (this.#closed) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica store is closed.");
|
|
17243
|
-
const
|
|
17244
|
-
|
|
17245
|
-
|
|
17246
|
-
|
|
17247
|
-
|
|
17248
|
-
|
|
17441
|
+
const mapped = (error) => typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error : idbError(error, action);
|
|
17442
|
+
const data = [.../* @__PURE__ */ new Set([STORE_META, ...stores])];
|
|
17443
|
+
const execute = async (tx2) => {
|
|
17444
|
+
const done = mode === "readwrite" ? transactionDone(tx2) : void 0;
|
|
17445
|
+
try {
|
|
17446
|
+
const state = await requestAsPromise(tx2.objectStore(STORE_META).get(this.#layout.metaKey));
|
|
17447
|
+
if (options.skipFence !== true) this.#assertLive(state);
|
|
17448
|
+
if (options.serving === true) await this.#assertServing(tx2, this.#requireMeta(state));
|
|
17449
|
+
const result = await body(tx2, state);
|
|
17450
|
+
await done;
|
|
17451
|
+
return result;
|
|
17452
|
+
} catch (error) {
|
|
17453
|
+
if (done !== void 0) {
|
|
17454
|
+
try {
|
|
17455
|
+
tx2.abort();
|
|
17456
|
+
} catch {
|
|
17457
|
+
}
|
|
17458
|
+
await done.catch(() => void 0);
|
|
17459
|
+
}
|
|
17460
|
+
throw mapped(error);
|
|
17461
|
+
}
|
|
17462
|
+
};
|
|
17463
|
+
const run = this.#hooks.run;
|
|
17464
|
+
if (run !== void 0) {
|
|
17465
|
+
try {
|
|
17466
|
+
return await run(data, mode, execute);
|
|
17467
|
+
} catch (error) {
|
|
17468
|
+
throw mapped(error);
|
|
17469
|
+
}
|
|
17249
17470
|
}
|
|
17250
|
-
|
|
17471
|
+
let tx;
|
|
17251
17472
|
try {
|
|
17252
|
-
const
|
|
17253
|
-
|
|
17254
|
-
const result = await body(tx, state);
|
|
17255
|
-
await done;
|
|
17256
|
-
return result;
|
|
17473
|
+
const scope = [.../* @__PURE__ */ new Set([...data, ...mode === "readonly" ? this.#layout.readScope : this.#layout.writeScope])];
|
|
17474
|
+
tx = mode === "readonly" ? this.#db.transaction(scope, mode) : this.#db.transaction(scope, mode, { durability: "strict" });
|
|
17257
17475
|
} catch (error) {
|
|
17258
|
-
try {
|
|
17259
|
-
tx.abort();
|
|
17260
|
-
} catch {
|
|
17261
|
-
}
|
|
17262
|
-
await done.catch(() => void 0);
|
|
17263
17476
|
throw idbError(error, action);
|
|
17264
17477
|
}
|
|
17478
|
+
return execute(tx);
|
|
17265
17479
|
}
|
|
17266
|
-
/**
|
|
17267
|
-
|
|
17268
|
-
|
|
17269
|
-
return requestAsPromise(tx.objectStore(STORE_META).get("state"));
|
|
17480
|
+
/** A fenced read transaction; serving reads assert grant policy before entries or bytes. */
|
|
17481
|
+
#readTx(stores, body, options = {}) {
|
|
17482
|
+
return this.#transact("readonly", options.serving && this.#hooks.grantPolicy ? [...stores, "device"] : stores, "Reading the replica", body, options);
|
|
17270
17483
|
}
|
|
17271
|
-
|
|
17272
|
-
|
|
17484
|
+
/** A fenced write transaction (`skipFence` for `init`); `body` receives the already-read state row. */
|
|
17485
|
+
#writeTx(stores, action, body, options = {}) {
|
|
17486
|
+
return this.#transact("readwrite", stores, action, body, options);
|
|
17273
17487
|
}
|
|
17274
17488
|
#requireMeta(state) {
|
|
17275
17489
|
if (state === void 0) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
|
|
@@ -17279,17 +17493,13 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17279
17493
|
return state;
|
|
17280
17494
|
}
|
|
17281
17495
|
#putMeta(tx, state) {
|
|
17282
|
-
return tx.objectStore(STORE_META).put(state,
|
|
17496
|
+
return tx.objectStore(STORE_META).put(state, this.#layout.metaKey);
|
|
17283
17497
|
}
|
|
17284
17498
|
async open() {
|
|
17285
|
-
|
|
17286
|
-
|
|
17287
|
-
|
|
17288
|
-
|
|
17289
|
-
} catch (error) {
|
|
17290
|
-
throw idbError(error, "Reading the replica");
|
|
17291
|
-
}
|
|
17292
|
-
const state = await this.#meta(tx);
|
|
17499
|
+
const { state, view } = await this.#transact("readonly", this.#hooks.grantPolicy ? ["device"] : [], "Reading the replica", async (tx, row) => ({
|
|
17500
|
+
state: row,
|
|
17501
|
+
view: row !== void 0 && row.purged === false ? await this.#policyView(tx, row) : void 0
|
|
17502
|
+
}), { skipFence: true });
|
|
17293
17503
|
if (state !== void 0 && state.generation !== this.#generation) {
|
|
17294
17504
|
throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was purged or reopened; open it again to continue.");
|
|
17295
17505
|
}
|
|
@@ -17297,8 +17507,9 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17297
17507
|
assertFolderPrefix2(state.config.prefix);
|
|
17298
17508
|
return {
|
|
17299
17509
|
config: state.config,
|
|
17300
|
-
grant: state.grant,
|
|
17301
|
-
pendingGrant: state.pendingGrant,
|
|
17510
|
+
grant: this.#cappedGrant(state.grant, view),
|
|
17511
|
+
pendingGrant: this.#cappedGrant(state.pendingGrant, view),
|
|
17512
|
+
...view === void 0 ? {} : { grantPolicyView: view },
|
|
17302
17513
|
nodeDid: state.nodeDid,
|
|
17303
17514
|
authority: state.authority,
|
|
17304
17515
|
revoked: state.revoked,
|
|
@@ -17329,6 +17540,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17329
17540
|
);
|
|
17330
17541
|
}
|
|
17331
17542
|
const next = existing.generation + 1;
|
|
17543
|
+
await this.#hooks.indexPartition?.(tx);
|
|
17332
17544
|
this.#putMeta(tx, this.#freshMeta(c, next));
|
|
17333
17545
|
return next;
|
|
17334
17546
|
}
|
|
@@ -17343,6 +17555,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17343
17555
|
if (same) return existing.generation;
|
|
17344
17556
|
throw new ReplicaError(ReplicaErrorCode.CONFIG_MISMATCH, `Replica ${c.name} already exists.`);
|
|
17345
17557
|
}
|
|
17558
|
+
await this.#hooks.indexPartition?.(tx);
|
|
17346
17559
|
this.#putMeta(tx, this.#freshMeta(c, 0));
|
|
17347
17560
|
return 0;
|
|
17348
17561
|
},
|
|
@@ -17394,8 +17607,23 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17394
17607
|
});
|
|
17395
17608
|
}
|
|
17396
17609
|
async installGrant(g) {
|
|
17397
|
-
await this.#writeTx([STORE_META], "Installing the grant", async (tx, meta) => {
|
|
17610
|
+
await this.#writeTx(this.#hooks.grantPolicy ? [STORE_META, "device"] : [STORE_META], "Installing the grant", async (tx, meta) => {
|
|
17398
17611
|
const state = this.#requireMeta(meta);
|
|
17612
|
+
const binding = this.#hooks.grantPolicy;
|
|
17613
|
+
if (binding !== void 0) {
|
|
17614
|
+
const device = tx.objectStore("device");
|
|
17615
|
+
const timestampKey = binding.installedAtKey(g.cid);
|
|
17616
|
+
const existing = await requestAsPromise(device.get(timestampKey));
|
|
17617
|
+
const now = this.#now();
|
|
17618
|
+
if (existing === void 0) {
|
|
17619
|
+
if (state.grant?.cid === g.cid || state.pendingGrant?.cid === g.cid) {
|
|
17620
|
+
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The original install time for this grant is missing.");
|
|
17621
|
+
}
|
|
17622
|
+
device.put({ at: now }, timestampKey);
|
|
17623
|
+
}
|
|
17624
|
+
const anchor = await requestAsPromise(device.get(binding.firstInstallKey));
|
|
17625
|
+
if (anchor === void 0) device.put({ at: now }, binding.firstInstallKey);
|
|
17626
|
+
}
|
|
17399
17627
|
if (state.grant?.cid === g.cid || state.pendingGrant?.cid === g.cid) return;
|
|
17400
17628
|
state.pendingGrant = g;
|
|
17401
17629
|
state.pendingGrantError = null;
|
|
@@ -17481,7 +17709,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17481
17709
|
async #hashesWithEntries(tx, wanted) {
|
|
17482
17710
|
const found = /* @__PURE__ */ new Set();
|
|
17483
17711
|
const { promise, resolve, reject } = Promise.withResolvers();
|
|
17484
|
-
const request = tx.objectStore(STORE_ENTRIES).index("byHash").openCursor();
|
|
17712
|
+
const request = tx.objectStore(STORE_ENTRIES).index("byHash").openCursor(this.#layout.range);
|
|
17485
17713
|
request.onsuccess = () => {
|
|
17486
17714
|
const cursor = request.result;
|
|
17487
17715
|
if (cursor === null) {
|
|
@@ -17501,6 +17729,9 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17501
17729
|
return this.#readTx([STORE_ENTRIES], (tx) => this.#hashesWithEntries(tx, wanted));
|
|
17502
17730
|
}
|
|
17503
17731
|
async applyPage(t, p) {
|
|
17732
|
+
const layout = this.#layout;
|
|
17733
|
+
const freeBytes = await this.#hooks.freeBytes?.() ?? null;
|
|
17734
|
+
const signedPromotion = this.#hooks.grantPolicy && p.promoteGrant !== null ? parseUcanGrant(p.promoteGrant.bytes) : p.promoteGrant;
|
|
17504
17735
|
await this.#writeTx(ALL_STORES, "Committing a sync page", async (tx, meta) => {
|
|
17505
17736
|
const state = this.#requireMeta(meta);
|
|
17506
17737
|
this.#checkFence(state, t);
|
|
@@ -17529,12 +17760,12 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17529
17760
|
sizes.set(change.hash, bytes.length);
|
|
17530
17761
|
continue;
|
|
17531
17762
|
}
|
|
17532
|
-
const previous = await requestAsPromise(entries.get(change.key));
|
|
17763
|
+
const previous = await requestAsPromise(entries.get(layout.entryKey(change.key)));
|
|
17533
17764
|
if (previous !== void 0 && !previous.deleted && previous.content && previous.hash === change.hash && previous.size !== null) {
|
|
17534
17765
|
sizes.set(change.hash, previous.size);
|
|
17535
17766
|
continue;
|
|
17536
17767
|
}
|
|
17537
|
-
const held = await requestAsPromise(blobs.get(change.hash));
|
|
17768
|
+
const held = await requestAsPromise(blobs.get(layout.blobKey(change.hash)));
|
|
17538
17769
|
if (held !== void 0) sizes.set(change.hash, held.size);
|
|
17539
17770
|
}
|
|
17540
17771
|
for (const change of p.changes) {
|
|
@@ -17543,15 +17774,21 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17543
17774
|
throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, `No stored content backs ${JSON.stringify(change.key)}.`);
|
|
17544
17775
|
}
|
|
17545
17776
|
}
|
|
17777
|
+
let blobBytes = 0;
|
|
17778
|
+
if (this.#hooks.admitPage !== void 0 || this.#hooks.accountCommit !== void 0) {
|
|
17779
|
+
for (const [hash, bytes] of p.blobs) {
|
|
17780
|
+
if (await requestAsPromise(blobs.count(layout.blobKey(hash))) === 0) blobBytes += bytes.length;
|
|
17781
|
+
}
|
|
17782
|
+
}
|
|
17783
|
+
await this.#hooks.admitPage?.(tx, { partitionId: layout.partitionId, blobBytes, entries: p.changes.length, freeBytes });
|
|
17546
17784
|
for (const [hash, bytes] of p.blobs) {
|
|
17547
|
-
|
|
17785
|
+
const record = { ...layout.tag, hash, bytes, size: bytes.length };
|
|
17786
|
+
if (layout.outOfLineKeys) blobs.put(record, layout.blobKey(hash));
|
|
17787
|
+
else blobs.put(record);
|
|
17548
17788
|
}
|
|
17549
17789
|
for (const change of p.changes) {
|
|
17550
|
-
|
|
17551
|
-
|
|
17552
|
-
continue;
|
|
17553
|
-
}
|
|
17554
|
-
entries.put({
|
|
17790
|
+
const record = change.deleted ? { ...layout.tag, key: change.key, deleted: true } : {
|
|
17791
|
+
...layout.tag,
|
|
17555
17792
|
key: change.key,
|
|
17556
17793
|
deleted: false,
|
|
17557
17794
|
etag: change.etag,
|
|
@@ -17559,8 +17796,11 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17559
17796
|
metadata: change.metadata,
|
|
17560
17797
|
content: change.content,
|
|
17561
17798
|
size: change.content ? sizes.get(change.hash) ?? null : null
|
|
17562
|
-
}
|
|
17799
|
+
};
|
|
17800
|
+
if (layout.outOfLineKeys) entries.put(record, layout.entryKey(change.key));
|
|
17801
|
+
else entries.put(record);
|
|
17563
17802
|
}
|
|
17803
|
+
await this.#hooks.accountCommit?.(tx, { partitionId: layout.partitionId, deltaBytes: blobBytes });
|
|
17564
17804
|
state.cursor = p.cursor;
|
|
17565
17805
|
state.coverage = p.coverage;
|
|
17566
17806
|
state.serial += 1;
|
|
@@ -17575,9 +17815,9 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17575
17815
|
state.lastCompleteAt = p.at;
|
|
17576
17816
|
state.lastError = null;
|
|
17577
17817
|
}
|
|
17578
|
-
if (
|
|
17579
|
-
state.grant =
|
|
17580
|
-
if (state.pendingGrant?.cid ===
|
|
17818
|
+
if (signedPromotion !== null) {
|
|
17819
|
+
state.grant = state.pendingGrant?.cid === signedPromotion.cid ? state.pendingGrant : state.grant?.cid === signedPromotion.cid ? state.grant : signedPromotion;
|
|
17820
|
+
if (state.pendingGrant?.cid === signedPromotion.cid) {
|
|
17581
17821
|
state.pendingGrant = null;
|
|
17582
17822
|
state.pendingGrantError = null;
|
|
17583
17823
|
}
|
|
@@ -17594,27 +17834,40 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17594
17834
|
return state?.serial ?? 0;
|
|
17595
17835
|
}
|
|
17596
17836
|
async collectGarbage(t) {
|
|
17837
|
+
const layout = this.#layout;
|
|
17597
17838
|
return this.#writeTx(ALL_STORES, "Collecting unused content", async (tx, meta) => {
|
|
17598
17839
|
const state = this.#requireMeta(meta);
|
|
17599
17840
|
this.#checkFence(state, t);
|
|
17600
17841
|
const keep = await this.#hashesWithEntries(tx, null);
|
|
17601
|
-
const
|
|
17842
|
+
const sizes = layout.blobSizeIndex === null ? new Map(
|
|
17843
|
+
(await requestAsPromise(tx.objectStore(STORE_BLOBS).getAllKeys(layout.range))).flatMap((key) => {
|
|
17844
|
+
const hash = layout.blobHashOf(key);
|
|
17845
|
+
return hash === void 0 ? [] : [[hash, 0]];
|
|
17846
|
+
})
|
|
17847
|
+
) : await blobSizes(tx, layout);
|
|
17602
17848
|
let removed = 0;
|
|
17603
|
-
|
|
17604
|
-
|
|
17605
|
-
|
|
17606
|
-
|
|
17607
|
-
|
|
17608
|
-
|
|
17849
|
+
let bytes = 0;
|
|
17850
|
+
for (const [hash, size] of sizes) {
|
|
17851
|
+
if (keep.has(hash)) continue;
|
|
17852
|
+
tx.objectStore(STORE_BLOBS).delete(layout.blobKey(hash));
|
|
17853
|
+
removed += 1;
|
|
17854
|
+
bytes += size;
|
|
17855
|
+
}
|
|
17856
|
+
if (removed > 0) await this.#hooks.accountErase?.(tx, { partitionId: layout.partitionId, bytes });
|
|
17609
17857
|
return removed;
|
|
17610
17858
|
});
|
|
17611
17859
|
}
|
|
17860
|
+
/** Clear this replica's entries and blobs inside `tx`, accounting the erased bytes. */
|
|
17861
|
+
async #clearRows(tx) {
|
|
17862
|
+
const bytes = this.#hooks.accountErase === void 0 ? 0 : sumSizes(await blobSizes(tx, this.#layout));
|
|
17863
|
+
clearReplicaRows(tx, this.#layout);
|
|
17864
|
+
await this.#hooks.accountErase?.(tx, { partitionId: this.#layout.partitionId, bytes });
|
|
17865
|
+
}
|
|
17612
17866
|
async reset(t, reason, options) {
|
|
17613
17867
|
const generation = await this.#writeTx(ALL_STORES, "Resetting the replica", async (tx, meta) => {
|
|
17614
17868
|
const state = this.#requireMeta(meta);
|
|
17615
17869
|
this.#checkFence(state, t);
|
|
17616
|
-
tx
|
|
17617
|
-
tx.objectStore(STORE_BLOBS).clear();
|
|
17870
|
+
await this.#clearRows(tx);
|
|
17618
17871
|
state.cursor = null;
|
|
17619
17872
|
if (options?.keepSource !== true) state.nodeDid = null;
|
|
17620
17873
|
state.coverage = "empty";
|
|
@@ -17650,7 +17903,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17650
17903
|
}
|
|
17651
17904
|
if (state.purged === true) return;
|
|
17652
17905
|
this.#checkFence(state, t, { allowRevoked: true });
|
|
17653
|
-
this.#eraseContents(tx, state);
|
|
17906
|
+
await this.#eraseContents(tx, state);
|
|
17654
17907
|
const tombstone = {
|
|
17655
17908
|
purged: true,
|
|
17656
17909
|
generation: state.generation + 1,
|
|
@@ -17671,9 +17924,8 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17671
17924
|
* the lease and the epoch bump kills the writer identity, so an in-flight
|
|
17672
17925
|
* commit, GC or reset aborts before touching a row.
|
|
17673
17926
|
*/
|
|
17674
|
-
#eraseContents(tx, state) {
|
|
17675
|
-
tx
|
|
17676
|
-
tx.objectStore(STORE_BLOBS).clear();
|
|
17927
|
+
async #eraseContents(tx, state) {
|
|
17928
|
+
await this.#clearRows(tx);
|
|
17677
17929
|
state.cursor = null;
|
|
17678
17930
|
state.coverage = "empty";
|
|
17679
17931
|
state.lastSyncAt = null;
|
|
@@ -17688,12 +17940,11 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17688
17940
|
async markRevoked(detail) {
|
|
17689
17941
|
await this.#writeTx(ALL_STORES, "Recording the revocation", async (tx, meta) => {
|
|
17690
17942
|
if (meta === void 0) {
|
|
17691
|
-
tx
|
|
17692
|
-
tx.objectStore(STORE_BLOBS).clear();
|
|
17943
|
+
await this.#clearRows(tx);
|
|
17693
17944
|
return;
|
|
17694
17945
|
}
|
|
17695
17946
|
const state = this.#requireMeta(meta);
|
|
17696
|
-
this.#eraseContents(tx, state);
|
|
17947
|
+
await this.#eraseContents(tx, state);
|
|
17697
17948
|
state.revoked = detail;
|
|
17698
17949
|
state.lastError = { at: new Date(this.#now()).toISOString(), code: ReplicaErrorCode.GRANT_REVOKED, message: detail };
|
|
17699
17950
|
state.purgePending = true;
|
|
@@ -17767,8 +18018,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17767
18018
|
}
|
|
17768
18019
|
if (state === void 0 || state.purged === true) return;
|
|
17769
18020
|
if (state.purgePending !== true) return;
|
|
17770
|
-
tx
|
|
17771
|
-
tx.objectStore(STORE_BLOBS).clear();
|
|
18021
|
+
await this.#clearRows(tx);
|
|
17772
18022
|
state.purgePending = false;
|
|
17773
18023
|
this.#putMeta(tx, state);
|
|
17774
18024
|
},
|
|
@@ -17783,12 +18033,33 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17783
18033
|
this.#putMeta(tx, state);
|
|
17784
18034
|
});
|
|
17785
18035
|
}
|
|
18036
|
+
/**
|
|
18037
|
+
* Finish a sync under its live lease: persist the epoch the caller
|
|
18038
|
+
* captured before the sync began (`syncedThroughEpoch`) together with the
|
|
18039
|
+
* completion marker, in one transaction. Call it only after the feed's
|
|
18040
|
+
* last page, repairs and GC have all committed — a sync that fails
|
|
18041
|
+
* earlier never reaches it, so its fence stays where it was.
|
|
18042
|
+
*/
|
|
18043
|
+
async completeSync(t, syncStartEpoch) {
|
|
18044
|
+
await this.#writeTx([STORE_META], "Finishing the sync", async (tx, meta) => {
|
|
18045
|
+
const state = this.#requireMeta(meta);
|
|
18046
|
+
this.#checkFence(state, t);
|
|
18047
|
+
state.syncedThroughEpoch = syncStartEpoch;
|
|
18048
|
+
state.syncCompletedAt = new Date(this.#now()).toISOString();
|
|
18049
|
+
this.#putMeta(tx, state);
|
|
18050
|
+
});
|
|
18051
|
+
}
|
|
18052
|
+
/** The epoch persisted by the last completed sync (0 before the first). */
|
|
18053
|
+
async syncFence() {
|
|
18054
|
+
const state = await this.#readTx([], async (_tx, meta) => this.#requireMeta(meta));
|
|
18055
|
+
return { syncedThroughEpoch: state.syncedThroughEpoch ?? 0, syncCompletedAt: state.syncCompletedAt ?? null };
|
|
18056
|
+
}
|
|
17786
18057
|
async get(key) {
|
|
17787
18058
|
try {
|
|
17788
18059
|
return await this.#readTx([STORE_ENTRIES], async (tx) => {
|
|
17789
|
-
const record = await requestAsPromise(tx.objectStore(STORE_ENTRIES).get(key));
|
|
18060
|
+
const record = await requestAsPromise(tx.objectStore(STORE_ENTRIES).get(this.#layout.entryKey(key)));
|
|
17790
18061
|
return record === void 0 ? void 0 : entryOf(record);
|
|
17791
|
-
});
|
|
18062
|
+
}, { serving: true });
|
|
17792
18063
|
} catch (error) {
|
|
17793
18064
|
throw idbError(error, "Reading the replica");
|
|
17794
18065
|
}
|
|
@@ -17797,7 +18068,8 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17797
18068
|
try {
|
|
17798
18069
|
const records = await this.#readTx(
|
|
17799
18070
|
[STORE_ENTRIES],
|
|
17800
|
-
(tx) => requestAsPromise(tx.objectStore(STORE_ENTRIES).getAll())
|
|
18071
|
+
(tx) => requestAsPromise(tx.objectStore(STORE_ENTRIES).getAll(this.#layout.range)),
|
|
18072
|
+
{ serving: true }
|
|
17801
18073
|
);
|
|
17802
18074
|
let rows = records.filter((record) => !record.deleted);
|
|
17803
18075
|
if (o.prefix !== void 0 && o.prefix !== "") rows = rows.filter((record) => record.key.startsWith(o.prefix));
|
|
@@ -17813,7 +18085,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17813
18085
|
try {
|
|
17814
18086
|
const records = await this.#readTx(
|
|
17815
18087
|
[STORE_ENTRIES],
|
|
17816
|
-
(tx) => requestAsPromise(tx.objectStore(STORE_ENTRIES).getAll())
|
|
18088
|
+
(tx) => requestAsPromise(tx.objectStore(STORE_ENTRIES).getAll(this.#layout.range))
|
|
17817
18089
|
);
|
|
17818
18090
|
return records.filter((record) => !record.deleted && !record.content).sort((a, b) => compareKeyBytes2(a.key, b.key)).slice(0, limit).map(entryOf);
|
|
17819
18091
|
} catch (error) {
|
|
@@ -17824,9 +18096,9 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17824
18096
|
try {
|
|
17825
18097
|
return await this.#readTx([STORE_BLOBS], async (tx) => {
|
|
17826
18098
|
if (!isHash(hash)) return void 0;
|
|
17827
|
-
const record = await requestAsPromise(tx.objectStore(STORE_BLOBS).get(hash));
|
|
18099
|
+
const record = await requestAsPromise(tx.objectStore(STORE_BLOBS).get(this.#layout.blobKey(hash)));
|
|
17828
18100
|
return record?.bytes;
|
|
17829
|
-
});
|
|
18101
|
+
}, { serving: true });
|
|
17830
18102
|
} catch (error) {
|
|
17831
18103
|
throw idbError(error, "Reading replica content");
|
|
17832
18104
|
}
|
|
@@ -17834,11 +18106,12 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17834
18106
|
async status(now = this.#now()) {
|
|
17835
18107
|
let state;
|
|
17836
18108
|
let records;
|
|
18109
|
+
let view;
|
|
17837
18110
|
try {
|
|
17838
|
-
({ state, records } = await this.#readTx([STORE_ENTRIES], async (tx, meta) => {
|
|
18111
|
+
({ state, records, view } = await this.#readTx(this.#hooks.grantPolicy ? [STORE_ENTRIES, "device"] : [STORE_ENTRIES], async (tx, meta) => {
|
|
17839
18112
|
const s = this.#requireMeta(meta);
|
|
17840
|
-
const rs = await requestAsPromise(tx.objectStore(STORE_ENTRIES).getAll());
|
|
17841
|
-
return { state: s, records: rs };
|
|
18113
|
+
const rs = await requestAsPromise(tx.objectStore(STORE_ENTRIES).getAll(this.#layout.range));
|
|
18114
|
+
return { state: s, records: rs, view: await this.#policyView(tx, s) };
|
|
17842
18115
|
}));
|
|
17843
18116
|
} catch (error) {
|
|
17844
18117
|
throw idbError(error, "Reading the replica");
|
|
@@ -17858,7 +18131,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17858
18131
|
}
|
|
17859
18132
|
const { retentionRevoked: _retentionRevoked, ...authority } = effectiveAuthority({
|
|
17860
18133
|
window: state.authority,
|
|
17861
|
-
grant: state.grant,
|
|
18134
|
+
grant: this.#cappedGrant(state.grant, view),
|
|
17862
18135
|
revoked: state.revoked,
|
|
17863
18136
|
retentionRevoked: state.retentionRevoked,
|
|
17864
18137
|
policy: state.config.localReadPolicy,
|
|
@@ -17896,7 +18169,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
|
|
|
17896
18169
|
}
|
|
17897
18170
|
async close() {
|
|
17898
18171
|
this.#closed = true;
|
|
17899
|
-
this.#db.close();
|
|
18172
|
+
if (this.#ownsDb) this.#db.close();
|
|
17900
18173
|
}
|
|
17901
18174
|
};
|
|
17902
18175
|
|