@tinycloud/replica 0.1.0-beta.0 → 0.1.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 ADDED
@@ -0,0 +1,100 @@
1
+ # @tinycloud/replica
2
+
3
+ A durable, read-only local replica of one TinyCloud KV prefix. Sync while online; read the latest locally observed values while offline. Replicas do not support offline writes or SQL data.
4
+
5
+ **Status: beta / release candidate (Replication RC1).** APIs, limitations, and release versions may change.
6
+
7
+ ## Requirements
8
+
9
+ - The TinyCloud node must advertise `kv-sync-v1` in its `/info` response.
10
+ - Node.js 22.13 or later, or Bun, for SQLite storage.
11
+ - Browser: IndexedDB for durable storage and Web Locks for `sync()`; without Web Locks, local reads remain available but sync is unsupported.
12
+ - A device grant covering the configured prefix with both `get` and `sync` abilities.
13
+
14
+ Install an explicit version; do not rely on a moving dist-tag:
15
+
16
+ ```sh
17
+ npm install @tinycloud/replica@0.1.0-beta.1
18
+ ```
19
+
20
+ ## Browser
21
+
22
+ Import the browser API from `@tinycloud/replica/browser`. Open a replica with the signed-in user's identity DID as `principal`, install a device UCAN grant, sync, then read locally:
23
+
24
+ ```ts
25
+ import { openReplica, ReplicaError } from "@tinycloud/replica/browser";
26
+
27
+ declare const spaceId: string;
28
+ declare const signedInUserDid: string;
29
+ declare const deviceGrant: string;
30
+
31
+ const replica = await openReplica({
32
+ host: "https://tee.node.tinycloud.xyz",
33
+ space: spaceId,
34
+ prefix: "notes/",
35
+ principal: signedInUserDid,
36
+ });
37
+
38
+ try {
39
+ // Supply the compact UCAN issued to this replica's device DID.
40
+ await replica.installGrant(deviceGrant);
41
+ const report = await replica.sync();
42
+ if (report.status === "busy") {
43
+ console.log("Another tab is syncing.");
44
+ } else {
45
+ // get() and list() only read local storage; neither falls back to the network.
46
+ const result = await replica.get("notes/today.txt");
47
+ if (result.status === "present") {
48
+ const text = new TextDecoder().decode(result.value);
49
+ console.log(text);
50
+ }
51
+ const keys = await replica.list({ prefix: "notes/" });
52
+ console.log(keys.entries.map(({ key }) => key));
53
+ }
54
+ } catch (error) {
55
+ if (error instanceof ReplicaError) {
56
+ switch (error.code) {
57
+ case "GRANT_MISSING":
58
+ case "GRANT_EXPIRED":
59
+ case "GRANT_REVOKED":
60
+ // Install or renew authority before syncing/reading.
61
+ break;
62
+ case "SECRETS_OPT_IN_REQUIRED":
63
+ case "NOT_COVERED":
64
+ case "RUNTIME_UNSUPPORTED":
65
+ case "NETWORK_ERROR":
66
+ console.error(error.code, error.message);
67
+ break;
68
+ }
69
+ }
70
+ throw error;
71
+ } finally {
72
+ await replica.close();
73
+ }
74
+ ```
75
+
76
+ `openReplica()` requires `host`, native-form `space`, `prefix`, and `principal`. The principal partitions replicas for signed-in users sharing an origin; it is not an authorization check. The node authorizes each sync. `deviceGrant` above is the compact delegation string (for example `issued.delegation.delegationHeader.Authorization`) for `replica.deviceDid`; the owner must authorize `get` and `sync` on the prefix.
77
+
78
+ `get()` may return `present`, `content_missing`, `deleted`, `absent`, `coverage_incomplete`, or `not_covered`. Check its status before using a value. `sync()` returns `synced` with a report or `busy` when another tab holds the sync lock. Errors are `ReplicaError` instances with a stable `code`; browser exports `ReplicaError` and `ReplicaErrorCode` from its entry point. Local reads never make network requests, but they remain subject to grant and retention authority.
79
+
80
+ ## Node and SQLite
81
+
82
+ The SQLite store API is exported directly from `@tinycloud/replica/sqlite`:
83
+
84
+ ```ts
85
+ import { SqliteReplicaStore, loadSqlite } from "@tinycloud/replica/sqlite";
86
+
87
+ const store = await SqliteReplicaStore.open("./replica-data", { create: true });
88
+ // Pass `store` to `new Replica({ store, transport })` from `@tinycloud/replica`.
89
+ ```
90
+
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
+
93
+ ## Security and storage
94
+
95
+ - A replica stores only the configured KV prefix. The grant may be broader, but keys and values outside the replica prefix are not stored.
96
+ - Reads stop when the sync grant expires. A separate node-attested retention grant can allow already-synced reads for a bounded period after expiry; it does not authorize more syncs. A learned revocation blocks reads.
97
+ - Replicating the secrets space or a prefix overlapping the `vault` namespace (including bare `vault`) requires explicit `allowSecrets: true` opt-in. This copies encrypted secret material; it does not grant decryption. Decrypt authority is network-wide, not limited to a secret name (TC-755).
98
+ - SQLite replica files are mode `0600`; replica data directories are mode `0700`. Browser storage is scoped to the origin; same-origin scripts can access that origin's IndexedDB.
99
+
100
+ See the [full local replicas guide](https://docs.tinycloud.xyz/guides/local-replicas) for grant setup, CLI usage, retention, error handling, and limitations.
@@ -0,0 +1,279 @@
1
+ // src/errors.ts
2
+ var ReplicaErrorCode = {
3
+ /** Another process holds the sync lease. */
4
+ BUSY: "REPLICA_BUSY",
5
+ /** No replica with this name exists. */
6
+ NOT_FOUND: "REPLICA_NOT_FOUND",
7
+ /** The replica exists but its stored configuration differs from the request. */
8
+ CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH",
9
+ /** The runtime has no supported durable store (e.g. Node < 22.13). */
10
+ RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED",
11
+ /** A required open() argument is missing or malformed (e.g. `principal`). */
12
+ INVALID_ARGUMENT: "REPLICA_INVALID_ARGUMENT",
13
+ /** The local store failed (I/O, corruption, schema). */
14
+ STORAGE_ERROR: "STORAGE_ERROR",
15
+ /** The local disk is full. */
16
+ STORAGE_FULL: "STORAGE_FULL",
17
+ /** The key is outside the replica's prefix. */
18
+ NOT_COVERED: "NOT_COVERED",
19
+ /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */
20
+ SECRETS_OPT_IN_REQUIRED: "SECRETS_OPT_IN_REQUIRED",
21
+ /** No grant is installed, or none covers the requested scope. */
22
+ GRANT_MISSING: "GRANT_MISSING",
23
+ /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */
24
+ GRANT_INVALID: "GRANT_INVALID",
25
+ /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */
26
+ GRANT_FORMAT_UNSUPPORTED: "GRANT_FORMAT_UNSUPPORTED",
27
+ /** The grant was issued to a different device. */
28
+ GRANT_AUDIENCE_MISMATCH: "GRANT_AUDIENCE_MISMATCH",
29
+ /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */
30
+ GRANT_NOT_COVERING: "GRANT_NOT_COVERING",
31
+ GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID",
32
+ GRANT_EXPIRED: "GRANT_EXPIRED",
33
+ GRANT_REVOKED: "GRANT_REVOKED",
34
+ /** The node refused the invocation for a reason other than revocation. */
35
+ GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED",
36
+ /** The node refused the retention grant. */
37
+ RETENTION_GRANT_REFUSED: "RETENTION_GRANT_REFUSED",
38
+ /** The node could not be reached. */
39
+ NETWORK_ERROR: "NETWORK_ERROR",
40
+ /** The node answered with an unexpected status. */
41
+ NODE_ERROR: "NODE_ERROR",
42
+ /** The node's answer violates the wire contract. */
43
+ PROTOCOL_ERROR: "PROTOCOL_ERROR",
44
+ /** The node asked for a fresh bootstrap (410). Handled inside sync. */
45
+ RESET_REQUIRED: "RESET_REQUIRED",
46
+ /** The feed now comes from a different node than the one pinned. */
47
+ SOURCE_CHANGED: "SOURCE_CHANGED",
48
+ /** The feed delivered a key outside the replica's prefix. */
49
+ SCOPE_VIOLATION: "SCOPE_VIOLATION",
50
+ /** Fetched bytes do not hash to the ETag the node attested for them. */
51
+ CONTENT_MISMATCH: "CONTENT_MISMATCH",
52
+ /** A stored blob no longer hashes to its name. */
53
+ INTEGRITY_ERROR: "INTEGRITY_ERROR",
54
+ /** The replica handle was closed; in-flight calls settle with this code. */
55
+ CLOSED: "REPLICA_CLOSED"
56
+ };
57
+ var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
58
+ var ReplicaError = class extends Error {
59
+ [REPLICA_ERROR] = true;
60
+ code;
61
+ detail;
62
+ constructor(code, message, detail, options) {
63
+ super(message, options);
64
+ this.name = "ReplicaError";
65
+ this.code = code;
66
+ if (detail !== void 0) this.detail = detail;
67
+ }
68
+ };
69
+
70
+ // src/did.ts
71
+ var METHOD = /^[a-z0-9]+$/u;
72
+ var IDCHAR = /^[A-Za-z0-9._-]$/u;
73
+ function isPrincipalDid(value) {
74
+ if (typeof value !== "string" || !value.startsWith("did:")) return false;
75
+ const rest = value.slice(4);
76
+ const methodEnd = rest.indexOf(":");
77
+ if (methodEnd <= 0) return false;
78
+ if (!METHOD.test(rest.slice(0, methodEnd))) return false;
79
+ const msid = rest.slice(methodEnd + 1);
80
+ if (msid.length === 0) return false;
81
+ const segments = msid.split(":");
82
+ const last = segments[segments.length - 1];
83
+ if (last.length === 0) return false;
84
+ for (const segment of segments) {
85
+ for (let i = 0; i < segment.length; i += 1) {
86
+ const c = segment[i];
87
+ if (c === "%") {
88
+ if (i + 2 >= segment.length || !/^[0-9a-fA-F]{2}$/u.test(segment.slice(i + 1, i + 3))) return false;
89
+ i += 2;
90
+ } else if (!IDCHAR.test(c)) {
91
+ return false;
92
+ }
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+
98
+ // src/browser/index.ts
99
+ function workerFrom(source) {
100
+ if (source !== void 0) {
101
+ if (typeof Worker !== "undefined" && source instanceof Worker) return source;
102
+ if (source instanceof URL || typeof source === "string") return new Worker(source, { type: "module" });
103
+ if (typeof source === "function") return source();
104
+ }
105
+ return new Worker(new URL("./replica.worker.js", import.meta.url), { type: "module" });
106
+ }
107
+ async function openReplica(options, events = {}) {
108
+ if (typeof options.principal !== "string" || !isPrincipalDid(options.principal)) {
109
+ throw new ReplicaError(
110
+ ReplicaErrorCode.INVALID_ARGUMENT,
111
+ `openReplica() requires \`principal\`, the signed-in user's identity DID (got ${JSON.stringify(options.principal)}).`
112
+ );
113
+ }
114
+ const worker = workerFrom(options.worker);
115
+ const replica = new BrowserReplica(worker, events);
116
+ try {
117
+ await replica.ready;
118
+ const persisted = typeof navigator !== "undefined" && typeof navigator.storage?.persist === "function" ? await navigator.storage.persist().catch(() => void 0) : void 0;
119
+ return await replica.open({ ...options, worker: void 0, persisted });
120
+ } catch (error) {
121
+ await replica.close().catch(() => void 0);
122
+ worker.terminate();
123
+ throw error;
124
+ }
125
+ }
126
+ var BrowserReplica = class {
127
+ /** The open() result: replicaId, deviceDid and the status at open time. */
128
+ opened = null;
129
+ #worker;
130
+ #events;
131
+ /** Settles when the worker reports it is listening (or fails to start). */
132
+ ready;
133
+ #nextId = 1;
134
+ #pending = /* @__PURE__ */ new Map();
135
+ #closed = false;
136
+ /** For tests and advanced use: drive an already-spawned worker. */
137
+ constructor(worker, events = {}) {
138
+ this.#worker = worker;
139
+ this.#events = events;
140
+ const ready = Promise.withResolvers();
141
+ this.ready = ready.promise;
142
+ ready.promise.catch(() => void 0);
143
+ this.#readyResolve = ready.resolve;
144
+ this.#readyReject = ready.reject;
145
+ worker.onmessage = (message) => this.#onMessage(message.data);
146
+ worker.onerror = (event) => {
147
+ const error = new Error(event.message ?? "The replica worker failed.");
148
+ this.#readyReject(error);
149
+ this.#failAll(error);
150
+ };
151
+ }
152
+ #readyResolve;
153
+ #readyReject;
154
+ #onMessage(data) {
155
+ if (typeof data === "object" && data !== null && "event" in data) {
156
+ const event = data.event;
157
+ switch (event.event) {
158
+ case "ready":
159
+ this.#readyResolve();
160
+ return;
161
+ case "committed":
162
+ if (typeof event.serial === "number") {
163
+ this.#events.onCommitted?.(event.serial);
164
+ }
165
+ return;
166
+ case "authority":
167
+ if (typeof event.state === "string") {
168
+ this.#events.onAuthority?.(event.state);
169
+ }
170
+ return;
171
+ case "reset":
172
+ if (typeof event.reason === "string") {
173
+ this.#events.onReset?.(event.reason);
174
+ }
175
+ return;
176
+ default:
177
+ return;
178
+ }
179
+ }
180
+ const reply = data;
181
+ if (typeof reply.id !== "number") return;
182
+ const pending = this.#pending.get(reply.id);
183
+ if (pending === void 0) return;
184
+ this.#pending.delete(reply.id);
185
+ if (reply.ok) {
186
+ pending.resolve(reply.result);
187
+ } else {
188
+ pending.reject(new ReplicaError(reply.err.code, reply.err.message, reply.err.detail));
189
+ }
190
+ }
191
+ #failAll(error) {
192
+ for (const pending of this.#pending.values()) pending.reject(error);
193
+ this.#pending.clear();
194
+ }
195
+ async #call(request) {
196
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica is closed.");
197
+ const id = this.#nextId++;
198
+ const { promise, resolve, reject } = Promise.withResolvers();
199
+ this.#pending.set(id, { resolve, reject });
200
+ this.#worker.postMessage({ id, ...request });
201
+ return await promise;
202
+ }
203
+ /** Open or create the replica; `openReplica` calls this for you. */
204
+ async open(options) {
205
+ this.opened = await this.#call({
206
+ op: "open",
207
+ host: options.host,
208
+ space: options.space,
209
+ prefix: options.prefix,
210
+ principal: options.principal,
211
+ ...options.name === void 0 ? {} : { name: options.name },
212
+ ...options.allowSecrets === void 0 ? {} : { allowSecrets: options.allowSecrets },
213
+ ...options.persisted === void 0 ? {} : { persisted: options.persisted }
214
+ });
215
+ return this;
216
+ }
217
+ /** The DID the app delegates to (`tcw.delegateTo(deviceDid, […sync…])`). */
218
+ get deviceDid() {
219
+ if (this.opened === null) throw new Error("open() has not completed.");
220
+ return this.opened.deviceDid;
221
+ }
222
+ /** Install a signed grant: the compact UCAN (`delegation.delegationHeader.Authorization`). */
223
+ installGrant(delegation) {
224
+ return this.#call({ op: "installGrant", delegation });
225
+ }
226
+ /** Pull the feed and commit each page atomically. `busy` when another tab syncs. */
227
+ sync(options = {}) {
228
+ return this.#call({ op: "sync", ...options.limit === void 0 ? {} : { limit: options.limit } });
229
+ }
230
+ /** Local read: never touches the network. */
231
+ get(key) {
232
+ return this.#call({ op: "get", key });
233
+ }
234
+ /** Local read: never touches the network. */
235
+ list(options = {}) {
236
+ return this.#call({ op: "list", ...options });
237
+ }
238
+ /** Replica status plus browser persistence facts. */
239
+ status() {
240
+ return this.#call({ op: "status" });
241
+ }
242
+ /** Opt in to post-expiry reads under a `tinycloud.kv/retain` grant. */
243
+ setRetentionGrant(cid) {
244
+ return this.#call({ op: "setRetention", grantCid: cid });
245
+ }
246
+ /**
247
+ * Clear entries and cursor (keeps config and grant); `purge` erases the
248
+ * replica permanently in place — the database file is never deleted, so
249
+ * the result is final the moment it resolves (see {@link ResetResult}).
250
+ */
251
+ reset(options = {}) {
252
+ return this.#call({ op: "reset", purge: options.purge === true });
253
+ }
254
+ /** Close the worker-side handles and terminate the worker. */
255
+ async close() {
256
+ if (this.#closed) return;
257
+ this.#closed = true;
258
+ this.#readyReject(new ReplicaError(ReplicaErrorCode.CLOSED, "The replica is closed."));
259
+ this.#failAll(new ReplicaError(ReplicaErrorCode.CLOSED, "The replica is closed."));
260
+ const id = this.#nextId++;
261
+ const { promise, resolve } = Promise.withResolvers();
262
+ this.#pending.set(id, { resolve, reject: resolve });
263
+ this.#worker.postMessage({ id, op: "close" });
264
+ const timeout = setTimeout(() => {
265
+ this.#pending.delete(id);
266
+ resolve(void 0);
267
+ }, 2e3);
268
+ await promise.catch(() => void 0).finally(() => clearTimeout(timeout));
269
+ this.#worker.terminate();
270
+ this.#failAll(new ReplicaError(ReplicaErrorCode.CLOSED, "The replica is closed."));
271
+ }
272
+ };
273
+ export {
274
+ BrowserReplica,
275
+ ReplicaError,
276
+ ReplicaErrorCode,
277
+ openReplica
278
+ };
279
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +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":[]}
package/dist/index.cjs CHANGED
@@ -54,6 +54,8 @@ var ReplicaErrorCode = {
54
54
  CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH",
55
55
  /** The runtime has no supported durable store (e.g. Node < 22.13). */
56
56
  RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED",
57
+ /** A required open() argument is missing or malformed (e.g. `principal`). */
58
+ INVALID_ARGUMENT: "REPLICA_INVALID_ARGUMENT",
57
59
  /** The local store failed (I/O, corruption, schema). */
58
60
  STORAGE_ERROR: "STORAGE_ERROR",
59
61
  /** The local disk is full. */
@@ -94,7 +96,9 @@ var ReplicaErrorCode = {
94
96
  /** Fetched bytes do not hash to the ETag the node attested for them. */
95
97
  CONTENT_MISMATCH: "CONTENT_MISMATCH",
96
98
  /** A stored blob no longer hashes to its name. */
97
- INTEGRITY_ERROR: "INTEGRITY_ERROR"
99
+ INTEGRITY_ERROR: "INTEGRITY_ERROR",
100
+ /** The replica handle was closed; in-flight calls settle with this code. */
101
+ CLOSED: "REPLICA_CLOSED"
98
102
  };
99
103
  var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
100
104
  var ReplicaError = class extends Error {
@@ -208,12 +212,14 @@ var Replica = class {
208
212
  #transportFor;
209
213
  #now;
210
214
  #leaseTtlMs;
215
+ #onCommit;
211
216
  constructor(options) {
212
217
  this.#store = options.store;
213
218
  const transport = options.transport;
214
219
  this.#transportFor = options.transportFor ?? (transport === void 0 ? void 0 : () => transport);
215
220
  this.#now = options.now ?? Date.now;
216
221
  this.#leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
222
+ this.#onCommit = options.onCommit;
217
223
  }
218
224
  async #state() {
219
225
  const state = await this.#store.open();
@@ -408,9 +414,10 @@ var Replica = class {
408
414
  let cursor = state.cursor;
409
415
  let pinned = state.nodeDid;
410
416
  let coverage = state.coverage;
417
+ const retentionGrantCid = config.retentionGrantCid;
411
418
  for (; ; ) {
412
419
  let page;
413
- const retentionGrant = config.retentionGrantCid;
420
+ const retentionGrant = retentionGrantCid;
414
421
  try {
415
422
  page = await attempt.transport.syncPage({
416
423
  prefix: config.prefix,
@@ -446,6 +453,7 @@ var Replica = class {
446
453
  authority: page.authority,
447
454
  coverage,
448
455
  at,
456
+ retentionGrantCid: retentionGrant,
449
457
  window,
450
458
  complete: !page.more,
451
459
  promoteGrant: attempt.promote
@@ -455,6 +463,7 @@ var Replica = class {
455
463
  attempt.promote = null;
456
464
  }
457
465
  report.pages += 1;
466
+ this.#onCommit?.();
458
467
  report.changes += page.changes.length;
459
468
  report.deleted += page.changes.filter((change) => change.deleted).length;
460
469
  report.fetched += verified.fetched;
@@ -464,7 +473,7 @@ var Replica = class {
464
473
  await this.#store.renewLease(attempt.lease, this.#leaseTtlMs);
465
474
  if (!page.more) break;
466
475
  }
467
- report.repaired = await this.#repair(attempt, cursor, pinned, coverage);
476
+ report.repaired = await this.#repair(attempt, cursor, pinned, coverage, retentionGrantCid);
468
477
  report.blobsCollected = await this.#store.collectGarbage(attempt.lease);
469
478
  report.coverage = coverage;
470
479
  report.contentMissing = (await this.#store.pendingRepairs(Number.MAX_SAFE_INTEGER)).length;
@@ -546,7 +555,7 @@ var Replica = class {
546
555
  }
547
556
  return { changes, blobs, fetched: fetched2 };
548
557
  }
549
- async #repair(attempt, cursor, nodeDid, coverage) {
558
+ async #repair(attempt, cursor, nodeDid, coverage, retentionGrantCid) {
550
559
  const pending = await this.#store.pendingRepairs(Number.MAX_SAFE_INTEGER);
551
560
  let repaired = 0;
552
561
  for (let start = 0; start < pending.length; start += REPAIR_BATCH) {
@@ -574,11 +583,13 @@ var Replica = class {
574
583
  authority: null,
575
584
  coverage,
576
585
  at: new Date(this.#now()).toISOString(),
586
+ retentionGrantCid,
577
587
  window: this.#syncWindow(state.authority, attempt.grant),
578
588
  complete: false,
579
589
  promoteGrant: null
580
590
  });
581
591
  repaired += changes.length;
592
+ this.#onCommit?.();
582
593
  }
583
594
  return repaired;
584
595
  }