@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.
@@ -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
- `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
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
- if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
9123
- return err(serviceError(
9124
- ErrorCodes.KV_CONFLICT,
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
- `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
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
- `Failed to list keys: ${response.status} - ${errorText}`,
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
- if (response.status === 503 && options?.ifMatch !== void 0) {
9688
- return err(serviceError(
9689
- ErrorCodes.KV_CONFLICT,
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
- `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
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
- `Failed to get metadata for key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
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
- return await this.#syncUnder(attempt, options.limit ?? DEFAULT_PAGE_LIMIT);
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.oncomplete = () => resolve();
17056
- tx.onabort = () => reject(tx.error ?? new DOMException("Transaction aborted", "AbortError"));
17057
- tx.onerror = () => reject(tx.error ?? new DOMException("Transaction failed", "UnknownError"));
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
- #replicaId;
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, holder, now, replicaId) {
17307
+ constructor(db, options) {
17178
17308
  this.#db = db;
17179
- this.#holder = holder;
17180
- this.#now = now;
17181
- this.#replicaId = replicaId;
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, options.holder, options.now ?? Date.now, replicaId);
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 store.#rawMeta();
17194
- store.#generation = state?.generation ?? 0;
17195
- await store.finishPurgeIfPending();
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 idbError(error, "Opening the replica");
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
- * A read transaction. `meta` is always in scope and its row is checked
17216
- * against this store's generation inside the transaction before `body`
17217
- * (which receives the already-read meta row) runs.
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 #readTx(stores, body) {
17220
- if (this.#closed) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica store is closed.");
17221
- const all = stores.includes(STORE_META) ? [...stores] : [STORE_META, ...stores];
17222
- let tx;
17223
- try {
17224
- tx = this.#db.transaction(all, "readonly");
17225
- } catch (error) {
17226
- throw idbError(error, "Reading the replica");
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 state = await this.#meta(tx);
17229
- this.#assertLive(state);
17230
- return body(tx, state);
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
- * A write transaction with `strict` durability — `meta` first, the
17234
- * generation fence inside the transaction, then `body` (which receives
17235
- * the already-read meta row). `body` may only await IDB requests issued
17236
- * on `tx`; the returned promise settles after the writes, and
17237
- * `transactionDone` settles after the commit. `skipFence` is for `init`:
17238
- * it checks the marker and generation itself because a purge marker
17239
- * belonging to this store's generation is the reinitialize-in-place case.
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 #writeTx(stores, action, body, options = {}) {
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 all = stores.includes(STORE_META) ? [...stores] : [STORE_META, ...stores];
17244
- let tx;
17245
- try {
17246
- tx = this.#db.transaction(all, "readwrite", { durability: "strict" });
17247
- } catch (error) {
17248
- throw idbError(error, action);
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
- const done = transactionDone(tx);
17471
+ let tx;
17251
17472
  try {
17252
- const state = await this.#meta(tx);
17253
- if (options.skipFence !== true) this.#assertLive(state);
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
- /** The raw `meta` row — used at open time, before the generation is set. */
17267
- async #rawMeta() {
17268
- const tx = this.#db.transaction(STORE_META, "readonly");
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
- async #meta(tx) {
17272
- return requestAsPromise(tx.objectStore(STORE_META).get("state"));
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, "state");
17496
+ return tx.objectStore(STORE_META).put(state, this.#layout.metaKey);
17283
17497
  }
17284
17498
  async open() {
17285
- if (this.#closed) throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "The replica store is closed.");
17286
- let tx;
17287
- try {
17288
- tx = this.#db.transaction([STORE_META], "readonly");
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
- blobs.put({ hash, bytes, size: bytes.length });
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
- if (change.deleted) {
17551
- entries.put({ key: change.key, deleted: true });
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 (p.promoteGrant !== null) {
17579
- state.grant = p.promoteGrant;
17580
- if (state.pendingGrant?.cid === p.promoteGrant.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 hashes = await requestAsPromise(tx.objectStore(STORE_BLOBS).getAllKeys());
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
- for (const hash of hashes) {
17604
- if (typeof hash === "string" && !keep.has(hash)) {
17605
- tx.objectStore(STORE_BLOBS).delete(hash);
17606
- removed += 1;
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.objectStore(STORE_ENTRIES).clear();
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.objectStore(STORE_ENTRIES).clear();
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.objectStore(STORE_ENTRIES).clear();
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.objectStore(STORE_ENTRIES).clear();
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