@tinycloud/replica 0.1.1-beta.0 → 0.2.0-beta.2

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.
@@ -9434,6 +9434,7 @@ var KVService = class extends BaseService {
9434
9434
  "kv"
9435
9435
  ));
9436
9436
  }
9437
+ const requestPrefix = options.prefix === "/" ? "" : options.prefix;
9437
9438
  const headers = {};
9438
9439
  if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
9439
9440
  if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
@@ -9443,7 +9444,7 @@ var KVService = class extends BaseService {
9443
9444
  const request = this.createRequestSignal(options.signal, options.timeout);
9444
9445
  try {
9445
9446
  const response = await this.invokeOperation(
9446
- options.prefix,
9447
+ requestPrefix,
9447
9448
  KVAction.SYNC,
9448
9449
  void 0,
9449
9450
  request.signal,
@@ -9459,7 +9460,7 @@ var KVService = class extends BaseService {
9459
9460
  } catch {
9460
9461
  body = void 0;
9461
9462
  }
9462
- const page = this.normalizeChangesResponse(body);
9463
+ const page = this.normalizeChangesResponse(body, options.prefix);
9463
9464
  if (!page) {
9464
9465
  return err(serviceError(
9465
9466
  ErrorCodes.NETWORK_ERROR,
@@ -9539,7 +9540,7 @@ var KVService = class extends BaseService {
9539
9540
  { meta }
9540
9541
  ));
9541
9542
  }
9542
- normalizeChangesResponse(data) {
9543
+ normalizeChangesResponse(data, requestedPrefix) {
9543
9544
  if (!isJsonObject(data)) return void 0;
9544
9545
  const { changes, more, cursor, source, authority } = data;
9545
9546
  if (!Array.isArray(changes) || typeof more !== "boolean" || typeof cursor !== "string" || !isJsonObject(source) || typeof source.nodeDid !== "string" || typeof source.space !== "string" || typeof source.prefix !== "string" || !isJsonObject(authority)) {
@@ -9571,7 +9572,11 @@ var KVService = class extends BaseService {
9571
9572
  changes: parsed,
9572
9573
  more,
9573
9574
  cursor,
9574
- source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
9575
+ source: {
9576
+ nodeDid: source.nodeDid,
9577
+ space: source.space,
9578
+ prefix: requestedPrefix === "/" && source.prefix === "" ? "/" : source.prefix
9579
+ },
9575
9580
  authority: { notBefore, expiresAt, retainUntil }
9576
9581
  };
9577
9582
  }
@@ -14553,6 +14558,8 @@ var ReplicaErrorCode = {
14553
14558
  GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
14554
14559
  GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
14555
14560
  GRANT_EXPIRED: "GRANT_EXPIRED",
14561
+ /** The configured node does not advertise whole-space KV sync support. */
14562
+ REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED",
14556
14563
  GRANT_REVOKED: "GRANT_REVOKED",
14557
14564
  /** The node refused the invocation for a reason other than revocation. */
14558
14565
  GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
@@ -14593,18 +14600,39 @@ function isReplicaError(error, code) {
14593
14600
  return typeof error === "object" && error !== null && error[REPLICA_ERROR] === true && (code === void 0 || error.code === code);
14594
14601
  }
14595
14602
 
14603
+ // ../sdk-services/dist/kv/replication/prefix.js
14604
+ function assertFolderPrefix(prefix) {
14605
+ const validFolder = typeof prefix === "string" && prefix.length > 0 && !prefix.startsWith("/") && !prefix.includes("//") && prefix.endsWith("/");
14606
+ if (prefix !== "/" && !validFolder) {
14607
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
14608
+ const suggestion = folderHint.length > 0 && !folderHint.includes("//") ? ` Use ${JSON.stringify(`${folderHint}/`)}.` : ' Use "/" or a slash-terminated folder path such as "notes/".';
14609
+ throw new TypeError(`Replication prefix must be "/" or a non-empty slash-terminated folder path without empty segments.${suggestion}`);
14610
+ }
14611
+ }
14612
+
14596
14613
  // src/scope.ts
14614
+ function assertFolderPrefix2(prefix) {
14615
+ try {
14616
+ assertFolderPrefix(prefix);
14617
+ } catch {
14618
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
14619
+ const suggestion = prefix === "" ? 'use "/" for the whole space' : folderHint.length > 0 && !folderHint.includes("//") ? `use ${JSON.stringify(`${folderHint}/`)}` : 'use "/" for the whole space or a folder path such as "notes/"';
14620
+ throw new ReplicaError(
14621
+ ReplicaErrorCode.INVALID_ARGUMENT,
14622
+ `Replication prefix ${JSON.stringify(prefix)} must be "/" or a non-empty folder path ending in / without empty segments; ${suggestion}. Run \`tc replica reset --replica <name> --purge\` and recreate it with the folder prefix.`
14623
+ );
14624
+ }
14625
+ }
14597
14626
  function kvPrefixCovers(prefix, key) {
14598
- if (prefix === "") return true;
14599
- if (prefix.endsWith("/")) return key.startsWith(prefix);
14600
- return key === prefix || key.startsWith(`${prefix}/`);
14627
+ return prefix === "/" || key.startsWith(prefix);
14601
14628
  }
14602
14629
  function authorityPathCovers(grantPath, requestedPath) {
14603
- if (grantPath === "" || grantPath === "/") return true;
14630
+ if (grantPath === "/") return true;
14604
14631
  if (grantPath.endsWith("/")) return requestedPath.startsWith(grantPath);
14605
14632
  return requestedPath === grantPath;
14606
14633
  }
14607
14634
  function requiresSecretsOptIn(space, prefix) {
14635
+ if (prefix === "/") return true;
14608
14636
  if (space === "secrets" || space.endsWith(":secrets")) return true;
14609
14637
  const firstSegment = prefix.split("/", 1)[0];
14610
14638
  return firstSegment === "vault";
@@ -16709,6 +16737,9 @@ function parseUcanGrant(input) {
16709
16737
  };
16710
16738
  }
16711
16739
  function kvPathIn(resource, space) {
16740
+ for (const root of [`${space}/kv`, `tinycloud://${space}/kv`]) {
16741
+ if (resource === root || resource === `${root}/`) return "/";
16742
+ }
16712
16743
  for (const base of [`${space}/kv/`, `tinycloud://${space}/kv/`]) {
16713
16744
  if (resource.startsWith(base)) return resource.slice(base.length);
16714
16745
  }
@@ -16730,9 +16761,8 @@ function assertGrantInstallable(grant, input) {
16730
16761
  `The grant was issued to ${grant.audience}, not this device (${input.deviceDid}).`
16731
16762
  );
16732
16763
  }
16733
- const requiredPaths = input.prefix.endsWith("/") ? [input.prefix] : [input.prefix, `${input.prefix}/`];
16734
16764
  for (const ability of ["tinycloud.kv/sync", "tinycloud.kv/get"]) {
16735
- if (!requiredPaths.every((path) => grantCovers(grant, input.space, path, ability))) {
16765
+ if (!grantCovers(grant, input.space, input.prefix, ability)) {
16736
16766
  throw new ReplicaError(
16737
16767
  ReplicaErrorCode.GRANT_NOT_COVERING,
16738
16768
  `The grant does not carry ${ability} on ${input.space}/kv/${input.prefix}.`,
@@ -16787,6 +16817,23 @@ function isPrincipalDid(value) {
16787
16817
  }
16788
16818
 
16789
16819
  // src/transport.ts
16820
+ var featureCache = /* @__PURE__ */ new Map();
16821
+ function featuresForHost(host, fetchFn) {
16822
+ const cached = featureCache.get(host);
16823
+ if (cached !== void 0) return cached;
16824
+ const discovered = fetchFn(`${host.replace(/\/+$/, "")}/info`).then(async (response) => {
16825
+ if (!response.ok) return void 0;
16826
+ const info = await response.json();
16827
+ if (typeof info !== "object" || info === null || !("features" in info)) return void 0;
16828
+ const features = info.features;
16829
+ return Array.isArray(features) && features.every((feature) => typeof feature === "string") ? features : void 0;
16830
+ }).catch(() => void 0);
16831
+ featureCache.set(host, discovered);
16832
+ void discovered.then((features) => {
16833
+ if (features === void 0) featureCache.delete(host);
16834
+ });
16835
+ return discovered;
16836
+ }
16790
16837
  var REVOKED_CODES = /* @__PURE__ */ new Set(["AUTH_DELEGATION_REVOKED", "AUTH_DELEGATION_ANCESTOR_REVOKED"]);
16791
16838
  var REVOKED_BODY2 = /^(?:Invalid invocation: )?(?:delegation-revoked: [A-Za-z0-9]+|delegation-ancestor-revoked: ancestor=[A-Za-z0-9]+ (?:invoked|parent)=[A-Za-z0-9]+)$/;
16792
16839
  function isRevocation(error, status, request) {
@@ -16876,9 +16923,26 @@ function fetched(key, result, request) {
16876
16923
  }
16877
16924
  return { bytes: result.data.data, etag: result.data.headers.etag ?? "" };
16878
16925
  }
16879
- function kvSyncTransport(kv) {
16926
+ function kvSyncTransport(kv, options = {}) {
16927
+ const host = options.host ?? kv.host;
16928
+ const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
16880
16929
  return {
16881
16930
  async syncPage({ prefix, cursor, limit, retentionGrant, signal }) {
16931
+ if (prefix === "/") {
16932
+ if (typeof host !== "string" || host.length === 0) {
16933
+ throw new ReplicaError(
16934
+ ReplicaErrorCode.INVALID_ARGUMENT,
16935
+ "Root sync requires a node host to check kv-sync-root; pass { host } to kvSyncTransport when the KV service does not expose its host."
16936
+ );
16937
+ }
16938
+ const features = await featuresForHost(host, fetchFn);
16939
+ if (features !== void 0 && !features.includes("kv-sync-root")) {
16940
+ throw new ReplicaError(
16941
+ ReplicaErrorCode.REPLICATION_ROOT_UNSUPPORTED,
16942
+ "This node doesn't support whole-space sync yet."
16943
+ );
16944
+ }
16945
+ }
16882
16946
  const result = await kv.changes({
16883
16947
  prefix,
16884
16948
  limit,
@@ -16889,10 +16953,10 @@ function kvSyncTransport(kv) {
16889
16953
  if (!result.ok) throw replicaErrorFromService(result.error, "Syncing");
16890
16954
  return checkPage(result.data);
16891
16955
  },
16892
- async fetchContent(keys, options) {
16956
+ async fetchContent(keys, options2) {
16893
16957
  const contents = /* @__PURE__ */ new Map();
16894
16958
  if (keys.length === 0) return contents;
16895
- const signal = options?.signal;
16959
+ const signal = options2?.signal;
16896
16960
  const ordered = [...keys].sort(compareKeyBytes);
16897
16961
  const batch = await kv.batchGet(ordered, { binary: true, ...signal === void 0 ? {} : { signal } });
16898
16962
  if (batch.ok) {
@@ -17165,6 +17229,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
17165
17229
  throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was purged or reopened; open it again to continue.");
17166
17230
  }
17167
17231
  if (state === void 0 || state.purged === true) return null;
17232
+ assertFolderPrefix2(state.config.prefix);
17168
17233
  return {
17169
17234
  config: state.config,
17170
17235
  grant: state.grant,
@@ -17185,6 +17250,7 @@ var IndexedDbReplicaStore = class _IndexedDbReplicaStore {
17185
17250
  };
17186
17251
  }
17187
17252
  async init(c) {
17253
+ assertFolderPrefix2(c.prefix);
17188
17254
  const generation = await this.#writeTx(
17189
17255
  [STORE_META],
17190
17256
  "Creating the replica",
@@ -18014,7 +18080,7 @@ async function handleSync(request) {
18014
18080
  if (state === null) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
18015
18081
  const engine = new Replica({
18016
18082
  store: s.store,
18017
- transportFor: (grant) => kvSyncTransport(boundKv(grant, s)),
18083
+ transportFor: (grant) => kvSyncTransport(boundKv(grant, s), { host: s.host }),
18018
18084
  // Siblings hear every committed page and repair batch, not only whole
18019
18085
  // syncs: a sync that fails on page 2 still reports page 1.
18020
18086
  onCommit: () => broadcastCommitted(s)