@tinycloud/replica 0.1.1-beta.1 → 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.
package/README.md CHANGED
@@ -90,6 +90,8 @@ const store = await SqliteReplicaStore.open("./replica-data", { create: true });
90
90
 
91
91
  `SqliteReplicaStore.open()` defaults to the runtime's built-in SQLite driver: `node:sqlite` on Node 22.13+ or `bun:sqlite` on Bun. For a managed CLI workflow, use `tc replica` from `@tinycloud/cli`.
92
92
 
93
+ For a standalone transport, use `kvSyncTransport(kv, { host })` when the KV client does not expose its node host. Root sync always fetches `/info` and requires `kv-sync-root`; it fails clearly before calling `changes()` when no host is available. An SDK KV service that exposes `host` can use the one-argument `kvSyncTransport(kv)` form. Folder sync does not need feature discovery.
94
+
93
95
  ## Security and storage
94
96
 
95
97
  - A replica stores only the configured KV prefix. The grant may be broader, but keys and values outside the replica prefix are not stored.
package/dist/browser.d.ts CHANGED
@@ -33,6 +33,8 @@ declare const ReplicaErrorCode: {
33
33
  readonly GRANT_NOT_COVERING: "GRANT_NOT_COVERING";
34
34
  readonly GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID";
35
35
  readonly GRANT_EXPIRED: "GRANT_EXPIRED";
36
+ /** The configured node does not advertise whole-space KV sync support. */
37
+ readonly REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED";
36
38
  readonly GRANT_REVOKED: "GRANT_REVOKED";
37
39
  /** The node refused the invocation for a reason other than revocation. */
38
40
  readonly GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED";
@@ -229,7 +231,7 @@ type BrowserReplicaOpenOptions = {
229
231
  host: string;
230
232
  /** The space id (native form) the replica covers. */
231
233
  space: string;
232
- /** The KV prefix, e.g. `notes/`. */
234
+ /** The KV prefix: `/` for the whole space, or a folder such as `notes/`. */
233
235
  prefix: string;
234
236
  /** A display name; defaults to the prefix. */
235
237
  name?: string;
package/dist/browser.js CHANGED
@@ -30,6 +30,8 @@ var ReplicaErrorCode = {
30
30
  GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
31
31
  GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
32
32
  GRANT_EXPIRED: "GRANT_EXPIRED",
33
+ /** The configured node does not advertise whole-space KV sync support. */
34
+ REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED",
33
35
  GRANT_REVOKED: "GRANT_REVOKED",
34
36
  /** The node refused the invocation for a reason other than revocation. */
35
37
  GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
@@ -67,6 +69,30 @@ var ReplicaError = class extends Error {
67
69
  }
68
70
  };
69
71
 
72
+ // ../sdk-services/dist/kv/replication/prefix.js
73
+ function assertFolderPrefix(prefix) {
74
+ const validFolder = typeof prefix === "string" && prefix.length > 0 && !prefix.startsWith("/") && !prefix.includes("//") && prefix.endsWith("/");
75
+ if (prefix !== "/" && !validFolder) {
76
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
77
+ const suggestion = folderHint.length > 0 && !folderHint.includes("//") ? ` Use ${JSON.stringify(`${folderHint}/`)}.` : ' Use "/" or a slash-terminated folder path such as "notes/".';
78
+ throw new TypeError(`Replication prefix must be "/" or a non-empty slash-terminated folder path without empty segments.${suggestion}`);
79
+ }
80
+ }
81
+
82
+ // src/scope.ts
83
+ function assertFolderPrefix2(prefix) {
84
+ try {
85
+ assertFolderPrefix(prefix);
86
+ } catch {
87
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
88
+ 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/"';
89
+ throw new ReplicaError(
90
+ ReplicaErrorCode.INVALID_ARGUMENT,
91
+ `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.`
92
+ );
93
+ }
94
+ }
95
+
70
96
  // src/did.ts
71
97
  var METHOD = /^[a-z0-9]+$/u;
72
98
  var IDCHAR = /^[A-Za-z0-9._-]$/u;
@@ -105,6 +131,7 @@ function workerFrom(source) {
105
131
  return new Worker(new URL("./replica.worker.js", import.meta.url), { type: "module" });
106
132
  }
107
133
  async function openReplica(options, events = {}) {
134
+ assertFolderPrefix2(options.prefix);
108
135
  if (typeof options.principal !== "string" || !isPrincipalDid(options.principal)) {
109
136
  throw new ReplicaError(
110
137
  ReplicaErrorCode.INVALID_ARGUMENT,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/did.ts","../src/browser/index.ts"],"sourcesContent":["/**\n * Typed replica errors. Every failure a caller can act on has its own code;\n * hosts (CLI, worker) map codes to their own exit codes or UI states.\n */\nexport const ReplicaErrorCode = {\n /** Another process holds the sync lease. */\n BUSY: \"REPLICA_BUSY\",\n /** No replica with this name exists. */\n NOT_FOUND: \"REPLICA_NOT_FOUND\",\n /** The replica exists but its stored configuration differs from the request. */\n CONFIG_MISMATCH: \"REPLICA_CONFIG_MISMATCH\",\n /** The runtime has no supported durable store (e.g. Node < 22.13). */\n RUNTIME_UNSUPPORTED: \"RUNTIME_UNSUPPORTED\",\n /** A required open() argument is missing or malformed (e.g. `principal`). */\n INVALID_ARGUMENT: \"REPLICA_INVALID_ARGUMENT\",\n /** The local store failed (I/O, corruption, schema). */\n STORAGE_ERROR: \"STORAGE_ERROR\",\n /** The local disk is full. */\n STORAGE_FULL: \"STORAGE_FULL\",\n /** The key is outside the replica's prefix. */\n NOT_COVERED: \"NOT_COVERED\",\n /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */\n SECRETS_OPT_IN_REQUIRED: \"SECRETS_OPT_IN_REQUIRED\",\n /** No grant is installed, or none covers the requested scope. */\n GRANT_MISSING: \"GRANT_MISSING\",\n /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */\n GRANT_INVALID: \"GRANT_INVALID\",\n /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */\n GRANT_FORMAT_UNSUPPORTED: \"GRANT_FORMAT_UNSUPPORTED\",\n /** The grant was issued to a different device. */\n GRANT_AUDIENCE_MISMATCH: \"GRANT_AUDIENCE_MISMATCH\",\n /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */\n GRANT_NOT_COVERING: \"GRANT_NOT_COVERING\",\n GRANT_NOT_YET_VALID: \"GRANT_NOT_YET_VALID\",\n GRANT_EXPIRED: \"GRANT_EXPIRED\",\n GRANT_REVOKED: \"GRANT_REVOKED\",\n /** The node refused the invocation for a reason other than revocation. */\n GRANT_UNAUTHORIZED: \"GRANT_UNAUTHORIZED\",\n /** The node refused the retention grant. */\n RETENTION_GRANT_REFUSED: \"RETENTION_GRANT_REFUSED\",\n /** The node could not be reached. */\n NETWORK_ERROR: \"NETWORK_ERROR\",\n /** The node answered with an unexpected status. */\n NODE_ERROR: \"NODE_ERROR\",\n /** The node's answer violates the wire contract. */\n PROTOCOL_ERROR: \"PROTOCOL_ERROR\",\n /** The node asked for a fresh bootstrap (410). Handled inside sync. */\n RESET_REQUIRED: \"RESET_REQUIRED\",\n /** The feed now comes from a different node than the one pinned. */\n SOURCE_CHANGED: \"SOURCE_CHANGED\",\n /** The feed delivered a key outside the replica's prefix. */\n SCOPE_VIOLATION: \"SCOPE_VIOLATION\",\n /** Fetched bytes do not hash to the ETag the node attested for them. */\n CONTENT_MISMATCH: \"CONTENT_MISMATCH\",\n /** A stored blob no longer hashes to its name. */\n INTEGRITY_ERROR: \"INTEGRITY_ERROR\",\n /** The replica handle was closed; in-flight calls settle with this code. */\n CLOSED: \"REPLICA_CLOSED\",\n} as const;\n\nexport type ReplicaErrorCode = (typeof ReplicaErrorCode)[keyof typeof ReplicaErrorCode];\n\n/**\n * The package ships separate entry bundles (`.`, `./sqlite`), each with its own\n * copy of this class, so identity is a registered symbol, not `instanceof`.\n */\nconst REPLICA_ERROR = Symbol.for(\"tinycloud.replica.error\");\n\nexport class ReplicaError extends Error {\n readonly [REPLICA_ERROR] = true;\n readonly code: ReplicaErrorCode;\n readonly detail?: Record<string, unknown>;\n\n constructor(code: ReplicaErrorCode, message: string, detail?: Record<string, unknown>, options?: { cause?: unknown }) {\n super(message, options);\n this.name = \"ReplicaError\";\n this.code = code;\n if (detail !== undefined) this.detail = detail;\n }\n}\n\nexport function isReplicaError(error: unknown, code?: ReplicaErrorCode): error is ReplicaError {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { [REPLICA_ERROR]?: unknown })[REPLICA_ERROR] === true &&\n (code === undefined || (error as ReplicaError).code === code)\n );\n}\n","/**\n * `did:key` verification method fragment → the principal DID.\n */\nexport function principalOf(did: string): string {\n return did.split(\"#\", 1)[0]!;\n}\n\nconst METHOD = /^[a-z0-9]+$/u;\n// DID method-specific-id: idchars ([A-Za-z0-9._-]) or %XX escapes, with `:`\n// separators allowed; the final segment must be non-empty.\nconst IDCHAR = /^[A-Za-z0-9._-]$/u;\n\n/**\n * Whether `value` is a well-formed DID the replica accepts as a `principal`\n * partition label: `did:` + a lowercase method name + a non-empty\n * method-specific-id whose segments (separated by `:`) consist of idchars or\n * valid `%XX` percent-escapes, and whose last segment is not empty. Shape\n * only — `principal` is an app-asserted label, never a credential.\n */\nexport function isPrincipalDid(value: string): boolean {\n if (typeof value !== \"string\" || !value.startsWith(\"did:\")) return false;\n const rest = value.slice(4);\n const methodEnd = rest.indexOf(\":\");\n if (methodEnd <= 0) return false;\n if (!METHOD.test(rest.slice(0, methodEnd))) return false;\n const msid = rest.slice(methodEnd + 1);\n if (msid.length === 0) return false;\n const segments = msid.split(\":\");\n const last = segments[segments.length - 1]!;\n if (last.length === 0) return false;\n for (const segment of segments) {\n for (let i = 0; i < segment.length; i += 1) {\n const c = segment[i]!;\n if (c === \"%\") {\n // A percent-escape needs exactly two hex digits.\n if (i + 2 >= segment.length || !/^[0-9a-fA-F]{2}$/u.test(segment.slice(i + 1, i + 3))) return false;\n i += 2;\n } else if (!IDCHAR.test(c)) {\n return false;\n }\n }\n }\n return true;\n}\n","/**\n * The main-thread browser API (TC-19): a thin RPC client over the replica\n * worker. IndexedDB, the network and the device key live in the worker; the\n * client's local reads never touch the network, and its sync calls are the\n * only path that does.\n *\n * The worker ships as `dist/replica.worker.js`, a self-contained ESM bundle\n * spawned via `new Worker(new URL(\"./replica.worker.js\", import.meta.url),\n * { type: \"module\" })` — webpack 5 and Vite detect that form. A `worker`\n * option accepts a `Worker` or `URL` escape hatch. In Vite dev mode, list\n * `@tinycloud/replica` in `optimizeDeps.exclude` so the worker URL resolves\n * inside the package (vitejs/vite#20859).\n *\n * Trust boundary: the browser origin, exactly as the OS user is the CLI's.\n * Run one app per origin — any same-origin script can open the IndexedDB\n * databases directly. `principal` (required) is the signed-in user's\n * identity DID; it partitions replicas per user on that origin, it is not\n * an authorization check — the node authorizes at sync time, and local\n * reads are gated by the stored grant's node-attested window, like the CLI.\n */\nimport { ReplicaError, ReplicaErrorCode } from \"../errors.js\";\nimport { isPrincipalDid } from \"../did.js\";\nimport type {\n GetResult,\n ListResult,\n OpenResult,\n ReplicaReply,\n StatusResult,\n SyncResult,\n} from \"./protocol.js\";\n\nexport { ReplicaError, ReplicaErrorCode };\n\n/**\n * `reset()` result. With `purge: true` the replica's contents are erased in\n * a single commit — the database file itself is never deleted: a small\n * empty database named by a hash remains (holding no user data) and the\n * next `openReplica` reinitializes it in place. Once the commit lands the\n * result is final `{ purged: true }` — nothing after it can change the\n * outcome.\n */\nexport type ResetResult = { reset: true; purged: boolean };\n\nexport type BrowserReplicaOpenOptions = {\n /** The TinyCloud host to sync from; pinned for the replica's life. */\n host: string;\n /** The space id (native form) the replica covers. */\n space: string;\n /** The KV prefix, e.g. `notes/`. */\n prefix: string;\n /** A display name; defaults to the prefix. */\n name?: string;\n /**\n * The signed-in user's identity DID (for example `did:pkh:eip155:1:0x…`),\n * as the app knows it. Replicas for different principals get different\n * databases on the same origin; signing in again and installing the new\n * session's grant continues the same replica from its cursor. This is an\n * app-asserted partition label, not an authorization check — the trust\n * boundary is the browser origin, and the node authorizes at sync time.\n */\n principal: string;\n /** Replicating the `secrets` space or a `vault/` prefix needs this opt-in. */\n allowSecrets?: boolean;\n /**\n * Spawn this worker instead of the bundled one (custom bundler layouts).\n * A factory (`() => Worker`) is also accepted: it runs after `principal`\n * validation, so a bad open never spawns a worker — useful for tests.\n */\n worker?: Worker | URL | string | (() => Worker);\n};\n\nexport type BrowserReplicaGetResult = GetResult;\nexport type BrowserReplicaListResult = ListResult;\nexport type BrowserReplicaStatus = StatusResult;\nexport type BrowserReplicaSyncResult = SyncResult;\nexport type BrowserReplicaOpenResult = OpenResult;\n\n/** Subscriptions for worker events (other tabs' commits, resets). */\nexport type BrowserReplicaEvents = {\n /** Another tab's worker committed a page (`serial` is monotonic). */\n onCommitted?: (serial: number) => void;\n /** The replica's authority state changed. */\n onAuthority?: (state: string) => void;\n /** The replica was reset in this tab or observed elsewhere. */\n onReset?: (reason: string) => void;\n};\n\ntype Pending = {\n resolve: (result: unknown) => void;\n reject: (error: Error) => void;\n};\n\nfunction workerFrom(source: Worker | URL | string | (() => Worker) | undefined): Worker {\n if (source !== undefined) {\n if (typeof Worker !== \"undefined\" && source instanceof Worker) return source;\n if (source instanceof URL || typeof source === \"string\") return new Worker(source, { type: \"module\" });\n if (typeof source === \"function\") return source();\n }\n return new Worker(new URL(\"./replica.worker.js\", import.meta.url), { type: \"module\" });\n}\n\n/**\n * Open (or create) a replica in a dedicated worker. The first call also asks\n * the browser for durable storage (`navigator.storage.persist()`), so an\n * eviction under pressure is less likely; `status()` reports the outcome.\n * In environments without IndexedDB the returned promise rejects with\n * `RUNTIME_UNSUPPORTED` — there is no in-memory fallback, reads would lose\n * their durability guarantee.\n *\n * `principal` partitions the replica per signed-in user and is required: the\n * same host/space/prefix under two principals yields two databases. A device\n * grant chained to a web session (about one hour under an OpenKey session)\n * gives about that session's length of offline reads; after sign-in rotates\n * the session key, install the new grant and the replica continues from its\n * cursor.\n */\nexport async function openReplica(options: BrowserReplicaOpenOptions, events: BrowserReplicaEvents = {}): Promise<BrowserReplica> {\n // Validate before workerFrom(): a bad principal never spawns a worker.\n if (typeof options.principal !== \"string\" || !isPrincipalDid(options.principal)) {\n throw new ReplicaError(\n ReplicaErrorCode.INVALID_ARGUMENT,\n `openReplica() requires \\`principal\\`, the signed-in user's identity DID (got ${JSON.stringify(options.principal)}).`,\n );\n }\n const worker = workerFrom(options.worker);\n const replica = new BrowserReplica(worker, events);\n try {\n await replica.ready;\n const persisted =\n typeof navigator !== \"undefined\" && typeof navigator.storage?.persist === \"function\"\n ? await navigator.storage.persist().catch(() => undefined)\n : undefined;\n // `worker` selects the process, it is not a wire field: do not forward it.\n return await replica.open({ ...options, worker: undefined, persisted });\n } catch (error) {\n // Any startup or open() failure abandons this replica: reject in-flight\n // calls, terminate the worker, and never leak a live Worker the caller\n // cannot reach.\n await replica.close().catch(() => undefined);\n worker.terminate();\n throw error;\n }\n}\n\nexport class BrowserReplica {\n /** The open() result: replicaId, deviceDid and the status at open time. */\n opened: OpenResult | null = null;\n\n readonly #worker: Worker;\n readonly #events: BrowserReplicaEvents;\n /** Settles when the worker reports it is listening (or fails to start). */\n readonly ready: Promise<void>;\n #nextId = 1;\n #pending = new Map<number, Pending>();\n #closed = false;\n\n /** For tests and advanced use: drive an already-spawned worker. */\n constructor(worker: Worker, events: BrowserReplicaEvents = {}) {\n this.#worker = worker;\n this.#events = events;\n const ready = Promise.withResolvers<void>();\n this.ready = ready.promise;\n // A close() before the worker reports ready rejects `ready`; not every\n // consumer awaits it, so mark that rejection handled up front.\n ready.promise.catch(() => undefined);\n this.#readyResolve = ready.resolve;\n this.#readyReject = ready.reject;\n worker.onmessage = (message: MessageEvent) => this.#onMessage(message.data);\n worker.onerror = (event) => {\n const error = new Error(event.message ?? \"The replica worker failed.\");\n this.#readyReject(error);\n this.#failAll(error);\n };\n }\n\n #readyResolve: () => void;\n #readyReject: (error: Error) => void;\n\n #onMessage(data: unknown): void {\n if (typeof data === \"object\" && data !== null && \"event\" in data) {\n const event = (data as { event: { event?: unknown } }).event;\n switch (event.event) {\n case \"ready\":\n this.#readyResolve();\n return;\n case \"committed\":\n if (typeof (event as { serial?: unknown }).serial === \"number\") {\n this.#events.onCommitted?.((event as { serial: number }).serial);\n }\n return;\n case \"authority\":\n if (typeof (event as { state?: unknown }).state === \"string\") {\n this.#events.onAuthority?.((event as { state: string }).state);\n }\n return;\n case \"reset\":\n if (typeof (event as { reason?: unknown }).reason === \"string\") {\n this.#events.onReset?.((event as { reason: string }).reason);\n }\n return;\n default:\n return;\n }\n }\n const reply = data as ReplicaReply;\n if (typeof reply.id !== \"number\") return;\n const pending = this.#pending.get(reply.id);\n if (pending === undefined) return;\n this.#pending.delete(reply.id);\n if (reply.ok) {\n pending.resolve(reply.result);\n } else {\n pending.reject(new ReplicaError(reply.err.code as ReplicaErrorCode, reply.err.message, reply.err.detail));\n }\n }\n\n #failAll(error: Error): void {\n for (const pending of this.#pending.values()) pending.reject(error);\n this.#pending.clear();\n }\n\n async #call<T>(request: Record<string, unknown>): Promise<T> {\n if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\");\n const id = this.#nextId++;\n const { promise, resolve, reject } = Promise.withResolvers<unknown>();\n this.#pending.set(id, { resolve, reject });\n this.#worker.postMessage({ id, ...request });\n return (await promise) as T;\n }\n\n /** Open or create the replica; `openReplica` calls this for you. */\n async open(options: BrowserReplicaOpenOptions & { persisted?: boolean }): Promise<BrowserReplica> {\n this.opened = await this.#call<OpenResult>({\n op: \"open\",\n host: options.host,\n space: options.space,\n prefix: options.prefix,\n principal: options.principal,\n ...(options.name === undefined ? {} : { name: options.name }),\n ...(options.allowSecrets === undefined ? {} : { allowSecrets: options.allowSecrets }),\n ...(options.persisted === undefined ? {} : { persisted: options.persisted }),\n });\n return this;\n }\n\n /** The DID the app delegates to (`tcw.delegateTo(deviceDid, […sync…])`). */\n get deviceDid(): string {\n if (this.opened === null) throw new Error(\"open() has not completed.\");\n return this.opened.deviceDid;\n }\n\n /** Install a signed grant: the compact UCAN (`delegation.delegationHeader.Authorization`). */\n installGrant(delegation: string): Promise<{ cid: string; audience: string; expiresAt: number | null }> {\n return this.#call({ op: \"installGrant\", delegation });\n }\n\n /** Pull the feed and commit each page atomically. `busy` when another tab syncs. */\n sync(options: { limit?: number } = {}): Promise<SyncResult> {\n return this.#call({ op: \"sync\", ...(options.limit === undefined ? {} : { limit: options.limit }) });\n }\n\n /** Local read: never touches the network. */\n get(key: string): Promise<GetResult> {\n return this.#call({ op: \"get\", key });\n }\n\n /** Local read: never touches the network. */\n list(options: { prefix?: string; after?: string; limit?: number } = {}): Promise<ListResult> {\n return this.#call({ op: \"list\", ...options });\n }\n\n /** Replica status plus browser persistence facts. */\n status(): Promise<StatusResult> {\n return this.#call({ op: \"status\" });\n }\n\n /** Opt in to post-expiry reads under a `tinycloud.kv/retain` grant. */\n setRetentionGrant(cid: string | null): Promise<{ retentionGrantCid: string | null }> {\n return this.#call({ op: \"setRetention\", grantCid: cid });\n }\n\n /**\n * Clear entries and cursor (keeps config and grant); `purge` erases the\n * replica permanently in place — the database file is never deleted, so\n * the result is final the moment it resolves (see {@link ResetResult}).\n */\n reset(options: { purge?: boolean } = {}): Promise<ResetResult> {\n return this.#call({ op: \"reset\", purge: options.purge === true });\n }\n\n /** Close the worker-side handles and terminate the worker. */\n async close(): Promise<void> {\n if (this.#closed) return;\n this.#closed = true;\n this.#readyReject(new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\"));\n // Shutdown starts now: every request already in flight rejects with\n // REPLICA_CLOSED before the close handshake is even posted, so a reply\n // arriving mid-close finds no entry and is ignored — never resolves with\n // data. The handshake itself is registered only after that sweep.\n this.#failAll(new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\"));\n const id = this.#nextId++;\n const { promise, resolve } = Promise.withResolvers<unknown>();\n this.#pending.set(id, { resolve, reject: resolve });\n this.#worker.postMessage({ id, op: \"close\" });\n const timeout = setTimeout(() => {\n this.#pending.delete(id);\n resolve(undefined);\n }, 2_000);\n await promise.catch(() => undefined).finally(() => clearTimeout(timeout));\n this.#worker.terminate();\n this.#failAll(new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\"));\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/errors.ts","../../sdk-services/src/kv/replication/prefix.ts","../src/scope.ts","../src/did.ts","../src/browser/index.ts"],"sourcesContent":["/**\n * Typed replica errors. Every failure a caller can act on has its own code;\n * hosts (CLI, worker) map codes to their own exit codes or UI states.\n */\nexport const ReplicaErrorCode = {\n /** Another process holds the sync lease. */\n BUSY: \"REPLICA_BUSY\",\n /** No replica with this name exists. */\n NOT_FOUND: \"REPLICA_NOT_FOUND\",\n /** The replica exists but its stored configuration differs from the request. */\n CONFIG_MISMATCH: \"REPLICA_CONFIG_MISMATCH\",\n /** The runtime has no supported durable store (e.g. Node < 22.13). */\n RUNTIME_UNSUPPORTED: \"RUNTIME_UNSUPPORTED\",\n /** A required open() argument is missing or malformed (e.g. `principal`). */\n INVALID_ARGUMENT: \"REPLICA_INVALID_ARGUMENT\",\n /** The local store failed (I/O, corruption, schema). */\n STORAGE_ERROR: \"STORAGE_ERROR\",\n /** The local disk is full. */\n STORAGE_FULL: \"STORAGE_FULL\",\n /** The key is outside the replica's prefix. */\n NOT_COVERED: \"NOT_COVERED\",\n /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */\n SECRETS_OPT_IN_REQUIRED: \"SECRETS_OPT_IN_REQUIRED\",\n /** No grant is installed, or none covers the requested scope. */\n GRANT_MISSING: \"GRANT_MISSING\",\n /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */\n GRANT_INVALID: \"GRANT_INVALID\",\n /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */\n GRANT_FORMAT_UNSUPPORTED: \"GRANT_FORMAT_UNSUPPORTED\",\n /** The grant was issued to a different device. */\n GRANT_AUDIENCE_MISMATCH: \"GRANT_AUDIENCE_MISMATCH\",\n /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */\n GRANT_NOT_COVERING: \"GRANT_NOT_COVERING\",\n GRANT_NOT_YET_VALID: \"GRANT_NOT_YET_VALID\",\n GRANT_EXPIRED: \"GRANT_EXPIRED\",\n /** The configured node does not advertise whole-space KV sync support. */\n REPLICATION_ROOT_UNSUPPORTED: \"REPLICATION_ROOT_UNSUPPORTED\",\n GRANT_REVOKED: \"GRANT_REVOKED\",\n /** The node refused the invocation for a reason other than revocation. */\n GRANT_UNAUTHORIZED: \"GRANT_UNAUTHORIZED\",\n /** The node refused the retention grant. */\n RETENTION_GRANT_REFUSED: \"RETENTION_GRANT_REFUSED\",\n /** The node could not be reached. */\n NETWORK_ERROR: \"NETWORK_ERROR\",\n /** The node answered with an unexpected status. */\n NODE_ERROR: \"NODE_ERROR\",\n /** The node's answer violates the wire contract. */\n PROTOCOL_ERROR: \"PROTOCOL_ERROR\",\n /** The node asked for a fresh bootstrap (410). Handled inside sync. */\n RESET_REQUIRED: \"RESET_REQUIRED\",\n /** The feed now comes from a different node than the one pinned. */\n SOURCE_CHANGED: \"SOURCE_CHANGED\",\n /** The feed delivered a key outside the replica's prefix. */\n SCOPE_VIOLATION: \"SCOPE_VIOLATION\",\n /** Fetched bytes do not hash to the ETag the node attested for them. */\n CONTENT_MISMATCH: \"CONTENT_MISMATCH\",\n /** A stored blob no longer hashes to its name. */\n INTEGRITY_ERROR: \"INTEGRITY_ERROR\",\n /** The replica handle was closed; in-flight calls settle with this code. */\n CLOSED: \"REPLICA_CLOSED\",\n} as const;\n\nexport type ReplicaErrorCode = (typeof ReplicaErrorCode)[keyof typeof ReplicaErrorCode];\n\n/**\n * The package ships separate entry bundles (`.`, `./sqlite`), each with its own\n * copy of this class, so identity is a registered symbol, not `instanceof`.\n */\nconst REPLICA_ERROR = Symbol.for(\"tinycloud.replica.error\");\n\nexport class ReplicaError extends Error {\n readonly [REPLICA_ERROR] = true;\n readonly code: ReplicaErrorCode;\n readonly detail?: Record<string, unknown>;\n\n constructor(code: ReplicaErrorCode, message: string, detail?: Record<string, unknown>, options?: { cause?: unknown }) {\n super(message, options);\n this.name = \"ReplicaError\";\n this.code = code;\n if (detail !== undefined) this.detail = detail;\n }\n}\n\nexport function isReplicaError(error: unknown, code?: ReplicaErrorCode): error is ReplicaError {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { [REPLICA_ERROR]?: unknown })[REPLICA_ERROR] === true &&\n (code === undefined || (error as ReplicaError).code === code)\n );\n}\n","/** Shared prefix boundary and configuration rules; no runtime dependencies. */\nexport function kvPrefixCovers(prefix: string, key: string): boolean {\n return prefix === \"/\" || key.startsWith(prefix);\n}\n\n/** Replication scopes are `/` or non-empty, slash-terminated folder paths. */\nexport function assertFolderPrefix(prefix: unknown): asserts prefix is string {\n const validFolder = typeof prefix === \"string\" &&\n prefix.length > 0 &&\n !prefix.startsWith(\"/\") &&\n !prefix.includes(\"//\") &&\n prefix.endsWith(\"/\");\n if (prefix !== \"/\" && !validFolder) {\n const folderHint = typeof prefix === \"string\"\n ? prefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\")\n : \"\";\n const suggestion = folderHint.length > 0 && !folderHint.includes(\"//\")\n ? ` Use ${JSON.stringify(`${folderHint}/`)}.`\n : ' Use \"/\" or a slash-terminated folder path such as \"notes/\".';\n throw new TypeError(`Replication prefix must be \"/\" or a non-empty slash-terminated folder path without empty segments.${suggestion}`);\n }\n}\n","import { ReplicaError, ReplicaErrorCode } from \"./errors.js\";\nimport type { AuthorityState, AuthorityWindow, GrantRecord, LocalReadPolicy } from \"./types.js\";\nimport { assertFolderPrefix as assertPureFolderPrefix } from \"@tinycloud/sdk-services/kv/replication/prefix\";\n\n/** Replication prefixes are the whole space (`/`) or a slash-terminated folder. */\nexport function assertFolderPrefix(prefix: unknown): asserts prefix is string {\n try {\n assertPureFolderPrefix(prefix);\n } catch {\n const folderHint = typeof prefix === \"string\"\n ? prefix.replace(/^\\/+/, \"\").replace(/\\/+$/, \"\")\n : \"\";\n const suggestion = prefix === \"\" ? 'use \"/\" for the whole space' :\n folderHint.length > 0 && !folderHint.includes(\"//\")\n ? `use ${JSON.stringify(`${folderHint}/`)}`\n : 'use \"/\" for the whole space or a folder path such as \"notes/\"';\n throw new ReplicaError(\n ReplicaErrorCode.INVALID_ARGUMENT,\n `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.`,\n );\n }\n}\n\n/** Prefix coverage shared with the KV replication feed. */\nexport function kvPrefixCovers(prefix: string, key: string): boolean {\n return prefix === \"/\" || key.startsWith(prefix);\n}\n/** Capability containment: exact grants cover exact keys; slash grants cover descendants. */\nexport function authorityPathCovers(grantPath: string, requestedPath: string): boolean {\n if (grantPath === \"/\") return true;\n if (grantPath.endsWith(\"/\")) return requestedPath.startsWith(grantPath);\n return requestedPath === grantPath;\n}\n\n/**\n * Syncing the owner's secrets space, or any prefix overlapping the vault\n * namespace (including bare `vault`), copies encrypted secret material to\n * disk and needs an explicit opt-in (TC-733).\n */\nexport function requiresSecretsOptIn(space: string, prefix: string): boolean {\n if (prefix === \"/\") return true;\n if (space === \"secrets\" || space.endsWith(\":secrets\")) return true;\n const firstSegment = prefix.split(\"/\", 1)[0];\n return firstSegment === \"vault\";\n}\n\n/** `\"blake3-<hex>\"` (quoted or not) → the 64-hex hash, or undefined. */\nexport function hashFromEtag(etag: string): string | undefined {\n const match = /^\"?blake3-([0-9a-f]{64})\"?$/.exec(etag);\n return match?.[1];\n}\n\nconst RFC3339 = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})$/;\n\n/** An RFC 3339 instant that parses: the only form an authority bound may take. */\nexport function isInstant(value: string): boolean {\n return RFC3339.test(value) && Number.isFinite(Date.parse(value));\n}\n\nexport type EffectiveAuthority = {\n state: AuthorityState;\n notBefore: string | null;\n expiresAt: string | null;\n retainUntil: string | null;\n /** The retention grant was revoked: after expiry, reads are revoked rather than expired. */\n retentionRevoked: boolean;\n};\n\nfunction minIso(a: string | null, bSeconds: number | null): string | null {\n if (bSeconds === null) return a;\n const b = new Date(bSeconds * 1000).toISOString();\n if (a === null) return b;\n return Date.parse(a) <= Date.parse(b) ? a : b;\n}\n\nfunction maxIso(a: string | null, bSeconds: number | null): string | null {\n if (bSeconds === null) return a;\n const b = new Date(bSeconds * 1000).toISOString();\n if (a === null) return b;\n return Date.parse(a) >= Date.parse(b) ? a : b;\n}\n\n/**\n * The window local reads are enforced against: the node-attested window from\n * the last successful sync, bounded by the installed leaf grant (an upper\n * bound only — a multi-parent ancestor can end the chain earlier, which only\n * the node can see).\n */\nexport function effectiveAuthority(input: {\n window: AuthorityWindow | null;\n grant: GrantRecord | null;\n revoked: string | null;\n retentionRevoked?: string | null;\n policy: LocalReadPolicy;\n now: number;\n}): EffectiveAuthority {\n const notBefore = maxIso(input.window?.notBefore ?? null, input.grant?.notBefore ?? null);\n const expiresAt = minIso(input.window?.expiresAt ?? null, input.grant?.expiresAt ?? null);\n const retainUntil = input.policy === \"retainAfterExpiry\" ? input.window?.retainUntil ?? null : null;\n let state: AuthorityState = \"valid\";\n if (input.revoked !== null) state = \"revoked\";\n else if (notBefore !== null && input.now < Date.parse(notBefore)) state = \"not-yet-valid\";\n else if (expiresAt !== null && input.now >= Date.parse(expiresAt)) state = \"expired\";\n return { state, notBefore, expiresAt, retainUntil, retentionRevoked: (input.retentionRevoked ?? null) !== null };\n}\n\n/**\n * Throws unless local reads are allowed now. Returns the reported authority\n * state: `valid`, or `expired` while a node-attested retention holds.\n */\nexport function assertReadable(authority: EffectiveAuthority, now: number): AuthorityState {\n switch (authority.state) {\n case \"valid\":\n return \"valid\";\n case \"revoked\":\n throw new ReplicaError(ReplicaErrorCode.GRANT_REVOKED, \"The replica's grant was revoked; local reads are blocked.\");\n case \"not-yet-valid\":\n throw new ReplicaError(\n ReplicaErrorCode.GRANT_NOT_YET_VALID,\n `The replica's grant is not valid before ${authority.notBefore}.`,\n );\n case \"expired\":\n if (authority.retentionRevoked) {\n throw new ReplicaError(\n ReplicaErrorCode.GRANT_REVOKED,\n `The replica's grant expired at ${authority.expiresAt} and its retention grant was revoked; local reads are blocked.`,\n );\n }\n if (authority.retainUntil !== null && now < Date.parse(authority.retainUntil)) return \"expired\";\n throw new ReplicaError(\n ReplicaErrorCode.GRANT_EXPIRED,\n authority.retainUntil === null\n ? `The replica's grant expired at ${authority.expiresAt}; local reads are blocked.`\n : `The replica's retention ended at ${authority.retainUntil}; local reads are blocked.`,\n );\n }\n}\n","/**\n * `did:key` verification method fragment → the principal DID.\n */\nexport function principalOf(did: string): string {\n return did.split(\"#\", 1)[0]!;\n}\n\nconst METHOD = /^[a-z0-9]+$/u;\n// DID method-specific-id: idchars ([A-Za-z0-9._-]) or %XX escapes, with `:`\n// separators allowed; the final segment must be non-empty.\nconst IDCHAR = /^[A-Za-z0-9._-]$/u;\n\n/**\n * Whether `value` is a well-formed DID the replica accepts as a `principal`\n * partition label: `did:` + a lowercase method name + a non-empty\n * method-specific-id whose segments (separated by `:`) consist of idchars or\n * valid `%XX` percent-escapes, and whose last segment is not empty. Shape\n * only — `principal` is an app-asserted label, never a credential.\n */\nexport function isPrincipalDid(value: string): boolean {\n if (typeof value !== \"string\" || !value.startsWith(\"did:\")) return false;\n const rest = value.slice(4);\n const methodEnd = rest.indexOf(\":\");\n if (methodEnd <= 0) return false;\n if (!METHOD.test(rest.slice(0, methodEnd))) return false;\n const msid = rest.slice(methodEnd + 1);\n if (msid.length === 0) return false;\n const segments = msid.split(\":\");\n const last = segments[segments.length - 1]!;\n if (last.length === 0) return false;\n for (const segment of segments) {\n for (let i = 0; i < segment.length; i += 1) {\n const c = segment[i]!;\n if (c === \"%\") {\n // A percent-escape needs exactly two hex digits.\n if (i + 2 >= segment.length || !/^[0-9a-fA-F]{2}$/u.test(segment.slice(i + 1, i + 3))) return false;\n i += 2;\n } else if (!IDCHAR.test(c)) {\n return false;\n }\n }\n }\n return true;\n}\n","/**\n * The main-thread browser API (TC-19): a thin RPC client over the replica\n * worker. IndexedDB, the network and the device key live in the worker; the\n * client's local reads never touch the network, and its sync calls are the\n * only path that does.\n *\n * The worker ships as `dist/replica.worker.js`, a self-contained ESM bundle\n * spawned via `new Worker(new URL(\"./replica.worker.js\", import.meta.url),\n * { type: \"module\" })` — webpack 5 and Vite detect that form. A `worker`\n * option accepts a `Worker` or `URL` escape hatch. In Vite dev mode, list\n * `@tinycloud/replica` in `optimizeDeps.exclude` so the worker URL resolves\n * inside the package (vitejs/vite#20859).\n *\n * Trust boundary: the browser origin, exactly as the OS user is the CLI's.\n * Run one app per origin — any same-origin script can open the IndexedDB\n * databases directly. `principal` (required) is the signed-in user's\n * identity DID; it partitions replicas per user on that origin, it is not\n * an authorization check — the node authorizes at sync time, and local\n * reads are gated by the stored grant's node-attested window, like the CLI.\n */\nimport { ReplicaError, ReplicaErrorCode } from \"../errors.js\";\nimport { assertFolderPrefix } from \"../scope.js\";\nimport { isPrincipalDid } from \"../did.js\";\nimport type {\n GetResult,\n ListResult,\n OpenResult,\n ReplicaReply,\n StatusResult,\n SyncResult,\n} from \"./protocol.js\";\n\nexport { ReplicaError, ReplicaErrorCode };\n\n/**\n * `reset()` result. With `purge: true` the replica's contents are erased in\n * a single commit — the database file itself is never deleted: a small\n * empty database named by a hash remains (holding no user data) and the\n * next `openReplica` reinitializes it in place. Once the commit lands the\n * result is final `{ purged: true }` — nothing after it can change the\n * outcome.\n */\nexport type ResetResult = { reset: true; purged: boolean };\n\nexport type BrowserReplicaOpenOptions = {\n /** The TinyCloud host to sync from; pinned for the replica's life. */\n host: string;\n /** The space id (native form) the replica covers. */\n space: string;\n /** The KV prefix: `/` for the whole space, or a folder such as `notes/`. */\n prefix: string;\n /** A display name; defaults to the prefix. */\n name?: string;\n /**\n * The signed-in user's identity DID (for example `did:pkh:eip155:1:0x…`),\n * as the app knows it. Replicas for different principals get different\n * databases on the same origin; signing in again and installing the new\n * session's grant continues the same replica from its cursor. This is an\n * app-asserted partition label, not an authorization check — the trust\n * boundary is the browser origin, and the node authorizes at sync time.\n */\n principal: string;\n /** Replicating the `secrets` space or a `vault/` prefix needs this opt-in. */\n allowSecrets?: boolean;\n /**\n * Spawn this worker instead of the bundled one (custom bundler layouts).\n * A factory (`() => Worker`) is also accepted: it runs after `principal`\n * validation, so a bad open never spawns a worker — useful for tests.\n */\n worker?: Worker | URL | string | (() => Worker);\n};\n\nexport type BrowserReplicaGetResult = GetResult;\nexport type BrowserReplicaListResult = ListResult;\nexport type BrowserReplicaStatus = StatusResult;\nexport type BrowserReplicaSyncResult = SyncResult;\nexport type BrowserReplicaOpenResult = OpenResult;\n\n/** Subscriptions for worker events (other tabs' commits, resets). */\nexport type BrowserReplicaEvents = {\n /** Another tab's worker committed a page (`serial` is monotonic). */\n onCommitted?: (serial: number) => void;\n /** The replica's authority state changed. */\n onAuthority?: (state: string) => void;\n /** The replica was reset in this tab or observed elsewhere. */\n onReset?: (reason: string) => void;\n};\n\ntype Pending = {\n resolve: (result: unknown) => void;\n reject: (error: Error) => void;\n};\n\nfunction workerFrom(source: Worker | URL | string | (() => Worker) | undefined): Worker {\n if (source !== undefined) {\n if (typeof Worker !== \"undefined\" && source instanceof Worker) return source;\n if (source instanceof URL || typeof source === \"string\") return new Worker(source, { type: \"module\" });\n if (typeof source === \"function\") return source();\n }\n return new Worker(new URL(\"./replica.worker.js\", import.meta.url), { type: \"module\" });\n}\n\n/**\n * Open (or create) a replica in a dedicated worker. The first call also asks\n * the browser for durable storage (`navigator.storage.persist()`), so an\n * eviction under pressure is less likely; `status()` reports the outcome.\n * In environments without IndexedDB the returned promise rejects with\n * `RUNTIME_UNSUPPORTED` — there is no in-memory fallback, reads would lose\n * their durability guarantee.\n *\n * `principal` partitions the replica per signed-in user and is required: the\n * same host/space/prefix under two principals yields two databases. A device\n * grant chained to a web session (about one hour under an OpenKey session)\n * gives about that session's length of offline reads; after sign-in rotates\n * the session key, install the new grant and the replica continues from its\n * cursor.\n */\nexport async function openReplica(options: BrowserReplicaOpenOptions, events: BrowserReplicaEvents = {}): Promise<BrowserReplica> {\n assertFolderPrefix(options.prefix);\n // Validate before workerFrom(): a bad principal or prefix never spawns a worker.\n if (typeof options.principal !== \"string\" || !isPrincipalDid(options.principal)) {\n throw new ReplicaError(\n ReplicaErrorCode.INVALID_ARGUMENT,\n `openReplica() requires \\`principal\\`, the signed-in user's identity DID (got ${JSON.stringify(options.principal)}).`,\n );\n }\n const worker = workerFrom(options.worker);\n const replica = new BrowserReplica(worker, events);\n try {\n await replica.ready;\n const persisted =\n typeof navigator !== \"undefined\" && typeof navigator.storage?.persist === \"function\"\n ? await navigator.storage.persist().catch(() => undefined)\n : undefined;\n // `worker` selects the process, it is not a wire field: do not forward it.\n return await replica.open({ ...options, worker: undefined, persisted });\n } catch (error) {\n // Any startup or open() failure abandons this replica: reject in-flight\n // calls, terminate the worker, and never leak a live Worker the caller\n // cannot reach.\n await replica.close().catch(() => undefined);\n worker.terminate();\n throw error;\n }\n}\n\nexport class BrowserReplica {\n /** The open() result: replicaId, deviceDid and the status at open time. */\n opened: OpenResult | null = null;\n\n readonly #worker: Worker;\n readonly #events: BrowserReplicaEvents;\n /** Settles when the worker reports it is listening (or fails to start). */\n readonly ready: Promise<void>;\n #nextId = 1;\n #pending = new Map<number, Pending>();\n #closed = false;\n\n /** For tests and advanced use: drive an already-spawned worker. */\n constructor(worker: Worker, events: BrowserReplicaEvents = {}) {\n this.#worker = worker;\n this.#events = events;\n const ready = Promise.withResolvers<void>();\n this.ready = ready.promise;\n // A close() before the worker reports ready rejects `ready`; not every\n // consumer awaits it, so mark that rejection handled up front.\n ready.promise.catch(() => undefined);\n this.#readyResolve = ready.resolve;\n this.#readyReject = ready.reject;\n worker.onmessage = (message: MessageEvent) => this.#onMessage(message.data);\n worker.onerror = (event) => {\n const error = new Error(event.message ?? \"The replica worker failed.\");\n this.#readyReject(error);\n this.#failAll(error);\n };\n }\n\n #readyResolve: () => void;\n #readyReject: (error: Error) => void;\n\n #onMessage(data: unknown): void {\n if (typeof data === \"object\" && data !== null && \"event\" in data) {\n const event = (data as { event: { event?: unknown } }).event;\n switch (event.event) {\n case \"ready\":\n this.#readyResolve();\n return;\n case \"committed\":\n if (typeof (event as { serial?: unknown }).serial === \"number\") {\n this.#events.onCommitted?.((event as { serial: number }).serial);\n }\n return;\n case \"authority\":\n if (typeof (event as { state?: unknown }).state === \"string\") {\n this.#events.onAuthority?.((event as { state: string }).state);\n }\n return;\n case \"reset\":\n if (typeof (event as { reason?: unknown }).reason === \"string\") {\n this.#events.onReset?.((event as { reason: string }).reason);\n }\n return;\n default:\n return;\n }\n }\n const reply = data as ReplicaReply;\n if (typeof reply.id !== \"number\") return;\n const pending = this.#pending.get(reply.id);\n if (pending === undefined) return;\n this.#pending.delete(reply.id);\n if (reply.ok) {\n pending.resolve(reply.result);\n } else {\n pending.reject(new ReplicaError(reply.err.code as ReplicaErrorCode, reply.err.message, reply.err.detail));\n }\n }\n\n #failAll(error: Error): void {\n for (const pending of this.#pending.values()) pending.reject(error);\n this.#pending.clear();\n }\n\n async #call<T>(request: Record<string, unknown>): Promise<T> {\n if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\");\n const id = this.#nextId++;\n const { promise, resolve, reject } = Promise.withResolvers<unknown>();\n this.#pending.set(id, { resolve, reject });\n this.#worker.postMessage({ id, ...request });\n return (await promise) as T;\n }\n\n /** Open or create the replica; `openReplica` calls this for you. */\n async open(options: BrowserReplicaOpenOptions & { persisted?: boolean }): Promise<BrowserReplica> {\n this.opened = await this.#call<OpenResult>({\n op: \"open\",\n host: options.host,\n space: options.space,\n prefix: options.prefix,\n principal: options.principal,\n ...(options.name === undefined ? {} : { name: options.name }),\n ...(options.allowSecrets === undefined ? {} : { allowSecrets: options.allowSecrets }),\n ...(options.persisted === undefined ? {} : { persisted: options.persisted }),\n });\n return this;\n }\n\n /** The DID the app delegates to (`tcw.delegateTo(deviceDid, […sync…])`). */\n get deviceDid(): string {\n if (this.opened === null) throw new Error(\"open() has not completed.\");\n return this.opened.deviceDid;\n }\n\n /** Install a signed grant: the compact UCAN (`delegation.delegationHeader.Authorization`). */\n installGrant(delegation: string): Promise<{ cid: string; audience: string; expiresAt: number | null }> {\n return this.#call({ op: \"installGrant\", delegation });\n }\n\n /** Pull the feed and commit each page atomically. `busy` when another tab syncs. */\n sync(options: { limit?: number } = {}): Promise<SyncResult> {\n return this.#call({ op: \"sync\", ...(options.limit === undefined ? {} : { limit: options.limit }) });\n }\n\n /** Local read: never touches the network. */\n get(key: string): Promise<GetResult> {\n return this.#call({ op: \"get\", key });\n }\n\n /** Local read: never touches the network. */\n list(options: { prefix?: string; after?: string; limit?: number } = {}): Promise<ListResult> {\n return this.#call({ op: \"list\", ...options });\n }\n\n /** Replica status plus browser persistence facts. */\n status(): Promise<StatusResult> {\n return this.#call({ op: \"status\" });\n }\n\n /** Opt in to post-expiry reads under a `tinycloud.kv/retain` grant. */\n setRetentionGrant(cid: string | null): Promise<{ retentionGrantCid: string | null }> {\n return this.#call({ op: \"setRetention\", grantCid: cid });\n }\n\n /**\n * Clear entries and cursor (keeps config and grant); `purge` erases the\n * replica permanently in place — the database file is never deleted, so\n * the result is final the moment it resolves (see {@link ResetResult}).\n */\n reset(options: { purge?: boolean } = {}): Promise<ResetResult> {\n return this.#call({ op: \"reset\", purge: options.purge === true });\n }\n\n /** Close the worker-side handles and terminate the worker. */\n async close(): Promise<void> {\n if (this.#closed) return;\n this.#closed = true;\n this.#readyReject(new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\"));\n // Shutdown starts now: every request already in flight rejects with\n // REPLICA_CLOSED before the close handshake is even posted, so a reply\n // arriving mid-close finds no entry and is ignored — never resolves with\n // data. The handshake itself is registered only after that sweep.\n this.#failAll(new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\"));\n const id = this.#nextId++;\n const { promise, resolve } = Promise.withResolvers<unknown>();\n this.#pending.set(id, { resolve, reject: resolve });\n this.#worker.postMessage({ id, op: \"close\" });\n const timeout = setTimeout(() => {\n this.#pending.delete(id);\n resolve(undefined);\n }, 2_000);\n await promise.catch(() => undefined).finally(() => clearTimeout(timeout));\n this.#worker.terminate();\n this.#failAll(new ReplicaError(ReplicaErrorCode.CLOSED, \"The replica is closed.\"));\n }\n}\n"],"mappings":"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","names":["assertFolderPrefix","assertFolderPrefix"]}
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(src_exports, {
23
23
  Replica: () => Replica,
24
24
  ReplicaError: () => ReplicaError,
25
25
  ReplicaErrorCode: () => ReplicaErrorCode,
26
+ assertFolderPrefix: () => assertFolderPrefix2,
26
27
  assertGrantInstallable: () => assertGrantInstallable,
27
28
  assertReadable: () => assertReadable,
28
29
  contentHash: () => contentHash,
@@ -76,6 +77,8 @@ var ReplicaErrorCode = {
76
77
  GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
77
78
  GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
78
79
  GRANT_EXPIRED: "GRANT_EXPIRED",
80
+ /** The configured node does not advertise whole-space KV sync support. */
81
+ REPLICATION_ROOT_UNSUPPORTED: "REPLICATION_ROOT_UNSUPPORTED",
79
82
  GRANT_REVOKED: "GRANT_REVOKED",
80
83
  /** The node refused the invocation for a reason other than revocation. */
81
84
  GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
@@ -116,18 +119,39 @@ function isReplicaError(error, code) {
116
119
  return typeof error === "object" && error !== null && error[REPLICA_ERROR] === true && (code === void 0 || error.code === code);
117
120
  }
118
121
 
122
+ // ../sdk-services/dist/kv/replication/prefix.js
123
+ function assertFolderPrefix(prefix) {
124
+ const validFolder = typeof prefix === "string" && prefix.length > 0 && !prefix.startsWith("/") && !prefix.includes("//") && prefix.endsWith("/");
125
+ if (prefix !== "/" && !validFolder) {
126
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
127
+ const suggestion = folderHint.length > 0 && !folderHint.includes("//") ? ` Use ${JSON.stringify(`${folderHint}/`)}.` : ' Use "/" or a slash-terminated folder path such as "notes/".';
128
+ throw new TypeError(`Replication prefix must be "/" or a non-empty slash-terminated folder path without empty segments.${suggestion}`);
129
+ }
130
+ }
131
+
119
132
  // src/scope.ts
133
+ function assertFolderPrefix2(prefix) {
134
+ try {
135
+ assertFolderPrefix(prefix);
136
+ } catch {
137
+ const folderHint = typeof prefix === "string" ? prefix.replace(/^\/+/, "").replace(/\/+$/, "") : "";
138
+ 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/"';
139
+ throw new ReplicaError(
140
+ ReplicaErrorCode.INVALID_ARGUMENT,
141
+ `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.`
142
+ );
143
+ }
144
+ }
120
145
  function kvPrefixCovers(prefix, key) {
121
- if (prefix === "") return true;
122
- if (prefix.endsWith("/")) return key.startsWith(prefix);
123
- return key === prefix || key.startsWith(`${prefix}/`);
146
+ return prefix === "/" || key.startsWith(prefix);
124
147
  }
125
148
  function authorityPathCovers(grantPath, requestedPath) {
126
- if (grantPath === "" || grantPath === "/") return true;
149
+ if (grantPath === "/") return true;
127
150
  if (grantPath.endsWith("/")) return requestedPath.startsWith(grantPath);
128
151
  return requestedPath === grantPath;
129
152
  }
130
153
  function requiresSecretsOptIn(space, prefix) {
154
+ if (prefix === "/") return true;
131
155
  if (space === "secrets" || space.endsWith(":secrets")) return true;
132
156
  const firstSegment = prefix.split("/", 1)[0];
133
157
  return firstSegment === "vault";
@@ -752,6 +776,9 @@ function parseUcanGrant(input) {
752
776
  };
753
777
  }
754
778
  function kvPathIn(resource, space) {
779
+ for (const root of [`${space}/kv`, `tinycloud://${space}/kv`]) {
780
+ if (resource === root || resource === `${root}/`) return "/";
781
+ }
755
782
  for (const base of [`${space}/kv/`, `tinycloud://${space}/kv/`]) {
756
783
  if (resource.startsWith(base)) return resource.slice(base.length);
757
784
  }
@@ -770,7 +797,7 @@ function syncGrantSpaces(grant) {
770
797
  const spaces = /* @__PURE__ */ new Set();
771
798
  for (const [resource, abilities] of Object.entries(grant.att)) {
772
799
  if (!("tinycloud.kv/sync" in abilities)) continue;
773
- const match = /^(?:tinycloud:\/\/)?(tinycloud:[^/]+)\/kv\//.exec(resource);
800
+ const match = /^(?:tinycloud:\/\/)?(tinycloud:[^/]+)\/kv(?:\/|$)/.exec(resource);
774
801
  if (match) spaces.add(match[1]);
775
802
  }
776
803
  return [...spaces];
@@ -782,9 +809,8 @@ function assertGrantInstallable(grant, input) {
782
809
  `The grant was issued to ${grant.audience}, not this device (${input.deviceDid}).`
783
810
  );
784
811
  }
785
- const requiredPaths = input.prefix.endsWith("/") ? [input.prefix] : [input.prefix, `${input.prefix}/`];
786
812
  for (const ability of ["tinycloud.kv/sync", "tinycloud.kv/get"]) {
787
- if (!requiredPaths.every((path) => grantCovers(grant, input.space, path, ability))) {
813
+ if (!grantCovers(grant, input.space, input.prefix, ability)) {
788
814
  throw new ReplicaError(
789
815
  ReplicaErrorCode.GRANT_NOT_COVERING,
790
816
  `The grant does not carry ${ability} on ${input.space}/kv/${input.prefix}.`,
@@ -808,6 +834,23 @@ function assertGrantInstallable(grant, input) {
808
834
  }
809
835
 
810
836
  // src/transport.ts
837
+ var featureCache = /* @__PURE__ */ new Map();
838
+ function featuresForHost(host, fetchFn) {
839
+ const cached = featureCache.get(host);
840
+ if (cached !== void 0) return cached;
841
+ const discovered = fetchFn(`${host.replace(/\/+$/, "")}/info`).then(async (response) => {
842
+ if (!response.ok) return void 0;
843
+ const info = await response.json();
844
+ if (typeof info !== "object" || info === null || !("features" in info)) return void 0;
845
+ const features = info.features;
846
+ return Array.isArray(features) && features.every((feature) => typeof feature === "string") ? features : void 0;
847
+ }).catch(() => void 0);
848
+ featureCache.set(host, discovered);
849
+ void discovered.then((features) => {
850
+ if (features === void 0) featureCache.delete(host);
851
+ });
852
+ return discovered;
853
+ }
811
854
  var REVOKED_CODES = /* @__PURE__ */ new Set(["AUTH_DELEGATION_REVOKED", "AUTH_DELEGATION_ANCESTOR_REVOKED"]);
812
855
  var REVOKED_BODY = /^(?:Invalid invocation: )?(?:delegation-revoked: [A-Za-z0-9]+|delegation-ancestor-revoked: ancestor=[A-Za-z0-9]+ (?:invoked|parent)=[A-Za-z0-9]+)$/;
813
856
  function isRevocation(error, status, request) {
@@ -897,9 +940,26 @@ function fetched(key, result, request) {
897
940
  }
898
941
  return { bytes: result.data.data, etag: result.data.headers.etag ?? "" };
899
942
  }
900
- function kvSyncTransport(kv) {
943
+ function kvSyncTransport(kv, options = {}) {
944
+ const host = options.host ?? kv.host;
945
+ const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
901
946
  return {
902
947
  async syncPage({ prefix, cursor, limit, retentionGrant, signal }) {
948
+ if (prefix === "/") {
949
+ if (typeof host !== "string" || host.length === 0) {
950
+ throw new ReplicaError(
951
+ ReplicaErrorCode.INVALID_ARGUMENT,
952
+ "Root sync requires a node host to check kv-sync-root; pass { host } to kvSyncTransport when the KV service does not expose its host."
953
+ );
954
+ }
955
+ const features = await featuresForHost(host, fetchFn);
956
+ if (features !== void 0 && !features.includes("kv-sync-root")) {
957
+ throw new ReplicaError(
958
+ ReplicaErrorCode.REPLICATION_ROOT_UNSUPPORTED,
959
+ "This node doesn't support whole-space sync yet."
960
+ );
961
+ }
962
+ }
903
963
  const result = await kv.changes({
904
964
  prefix,
905
965
  limit,
@@ -910,10 +970,10 @@ function kvSyncTransport(kv) {
910
970
  if (!result.ok) throw replicaErrorFromService(result.error, "Syncing");
911
971
  return checkPage(result.data);
912
972
  },
913
- async fetchContent(keys, options) {
973
+ async fetchContent(keys, options2) {
914
974
  const contents = /* @__PURE__ */ new Map();
915
975
  if (keys.length === 0) return contents;
916
- const signal = options?.signal;
976
+ const signal = options2?.signal;
917
977
  const ordered = [...keys].sort(compareKeyBytes);
918
978
  const batch = await kv.batchGet(ordered, { binary: true, ...signal === void 0 ? {} : { signal } });
919
979
  if (batch.ok) {
@@ -939,6 +999,7 @@ function kvSyncTransport(kv) {
939
999
  Replica,
940
1000
  ReplicaError,
941
1001
  ReplicaErrorCode,
1002
+ assertFolderPrefix,
942
1003
  assertGrantInstallable,
943
1004
  assertReadable,
944
1005
  contentHash,