@tinycloud/replica 0.1.0-beta.0 → 0.1.0-beta.1

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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Typed replica errors. Every failure a caller can act on has its own code;
3
+ * hosts (CLI, worker) map codes to their own exit codes or UI states.
4
+ */
5
+ declare const ReplicaErrorCode: {
6
+ /** Another process holds the sync lease. */
7
+ readonly BUSY: "REPLICA_BUSY";
8
+ /** No replica with this name exists. */
9
+ readonly NOT_FOUND: "REPLICA_NOT_FOUND";
10
+ /** The replica exists but its stored configuration differs from the request. */
11
+ readonly CONFIG_MISMATCH: "REPLICA_CONFIG_MISMATCH";
12
+ /** The runtime has no supported durable store (e.g. Node < 22.13). */
13
+ readonly RUNTIME_UNSUPPORTED: "RUNTIME_UNSUPPORTED";
14
+ /** A required open() argument is missing or malformed (e.g. `principal`). */
15
+ readonly INVALID_ARGUMENT: "REPLICA_INVALID_ARGUMENT";
16
+ /** The local store failed (I/O, corruption, schema). */
17
+ readonly STORAGE_ERROR: "STORAGE_ERROR";
18
+ /** The local disk is full. */
19
+ readonly STORAGE_FULL: "STORAGE_FULL";
20
+ /** The key is outside the replica's prefix. */
21
+ readonly NOT_COVERED: "NOT_COVERED";
22
+ /** Syncing the secrets space or the vault namespace needs an explicit opt-in. */
23
+ readonly SECRETS_OPT_IN_REQUIRED: "SECRETS_OPT_IN_REQUIRED";
24
+ /** No grant is installed, or none covers the requested scope. */
25
+ readonly GRANT_MISSING: "GRANT_MISSING";
26
+ /** The grant bytes are not a verifiable UCAN, or its signature is wrong. */
27
+ readonly GRANT_INVALID: "GRANT_INVALID";
28
+ /** SIWE/CACAO and other non-UCAN grants cannot be used for a replica. */
29
+ readonly GRANT_FORMAT_UNSUPPORTED: "GRANT_FORMAT_UNSUPPORTED";
30
+ /** The grant was issued to a different device. */
31
+ readonly GRANT_AUDIENCE_MISMATCH: "GRANT_AUDIENCE_MISMATCH";
32
+ /** The grant lacks `tinycloud.kv/sync` or `tinycloud.kv/get` on the prefix. */
33
+ readonly GRANT_NOT_COVERING: "GRANT_NOT_COVERING";
34
+ readonly GRANT_NOT_YET_VALID: "GRANT_NOT_YET_VALID";
35
+ readonly GRANT_EXPIRED: "GRANT_EXPIRED";
36
+ readonly GRANT_REVOKED: "GRANT_REVOKED";
37
+ /** The node refused the invocation for a reason other than revocation. */
38
+ readonly GRANT_UNAUTHORIZED: "GRANT_UNAUTHORIZED";
39
+ /** The node refused the retention grant. */
40
+ readonly RETENTION_GRANT_REFUSED: "RETENTION_GRANT_REFUSED";
41
+ /** The node could not be reached. */
42
+ readonly NETWORK_ERROR: "NETWORK_ERROR";
43
+ /** The node answered with an unexpected status. */
44
+ readonly NODE_ERROR: "NODE_ERROR";
45
+ /** The node's answer violates the wire contract. */
46
+ readonly PROTOCOL_ERROR: "PROTOCOL_ERROR";
47
+ /** The node asked for a fresh bootstrap (410). Handled inside sync. */
48
+ readonly RESET_REQUIRED: "RESET_REQUIRED";
49
+ /** The feed now comes from a different node than the one pinned. */
50
+ readonly SOURCE_CHANGED: "SOURCE_CHANGED";
51
+ /** The feed delivered a key outside the replica's prefix. */
52
+ readonly SCOPE_VIOLATION: "SCOPE_VIOLATION";
53
+ /** Fetched bytes do not hash to the ETag the node attested for them. */
54
+ readonly CONTENT_MISMATCH: "CONTENT_MISMATCH";
55
+ /** A stored blob no longer hashes to its name. */
56
+ readonly INTEGRITY_ERROR: "INTEGRITY_ERROR";
57
+ /** The replica handle was closed; in-flight calls settle with this code. */
58
+ readonly CLOSED: "REPLICA_CLOSED";
59
+ };
60
+ type ReplicaErrorCode = (typeof ReplicaErrorCode)[keyof typeof ReplicaErrorCode];
61
+ /**
62
+ * The package ships separate entry bundles (`.`, `./sqlite`), each with its own
63
+ * copy of this class, so identity is a registered symbol, not `instanceof`.
64
+ */
65
+ declare const REPLICA_ERROR: unique symbol;
66
+ declare class ReplicaError extends Error {
67
+ readonly [REPLICA_ERROR] = true;
68
+ readonly code: ReplicaErrorCode;
69
+ readonly detail?: Record<string, unknown>;
70
+ constructor(code: ReplicaErrorCode, message: string, detail?: Record<string, unknown>, options?: {
71
+ cause?: unknown;
72
+ });
73
+ }
74
+
75
+ type LocalReadPolicy = "whileGrantValid" | "retainAfterExpiry";
76
+ type Coverage = "empty" | "bootstrapping" | "complete";
77
+ type AuthorityState = "valid" | "expired" | "revoked" | "not-yet-valid";
78
+ type ReplicaLastError = {
79
+ at: string;
80
+ code: string;
81
+ message: string;
82
+ };
83
+ type ReplicaStatus = {
84
+ name: string;
85
+ replicaId: string;
86
+ source: {
87
+ host: string;
88
+ nodeDid: string | null;
89
+ space: string;
90
+ prefix: string;
91
+ };
92
+ device: {
93
+ did: string;
94
+ delegationCid: string | null;
95
+ pendingDelegationCid: string | null;
96
+ pendingDelegationError: ReplicaLastError | null;
97
+ };
98
+ authority: {
99
+ state: AuthorityState;
100
+ notBefore: string | null;
101
+ expiresAt: string | null;
102
+ retainUntil: string | null;
103
+ localReadPolicy: LocalReadPolicy;
104
+ retentionGrantCid: string | null;
105
+ revokedDetail: string | null;
106
+ retentionRevokedDetail: string | null;
107
+ };
108
+ consistency: "observed";
109
+ coverage: Coverage;
110
+ lastSyncAt: string | null;
111
+ lastCompleteAt: string | null;
112
+ counts: {
113
+ keys: number;
114
+ contentMissing: number;
115
+ tombstones: number;
116
+ };
117
+ bytes: number;
118
+ durability: string;
119
+ syncing: boolean;
120
+ /** A revocation purge has not removed every content file yet; the next open retries it. */
121
+ purgePending: boolean;
122
+ lastError: ReplicaLastError | null;
123
+ lastReset: {
124
+ at: string;
125
+ reason: string;
126
+ } | null;
127
+ };
128
+
129
+ type SyncReport = {
130
+ pages: number;
131
+ changes: number;
132
+ deleted: number;
133
+ fetched: number;
134
+ contentMissing: number;
135
+ repaired: number;
136
+ resets: number;
137
+ coverage: Coverage;
138
+ cursorAdvanced: boolean;
139
+ promotedGrant: boolean;
140
+ blobsCollected: number;
141
+ };
142
+
143
+ /**
144
+ * The main-thread ↔ worker RPC contract (TC-19). Requests are `{id, op, …}`;
145
+ * replies are `{id, ok:true, result}` or `{id, ok:false, err}`; the worker
146
+ * posts unsolicited `{event}` messages. Values cross as transferable
147
+ * ArrayBuffers, never as broadcast events.
148
+ */
149
+
150
+ type OpenResult = {
151
+ replicaId: string;
152
+ deviceDid: string;
153
+ /** The verification method (DID fragment form) the app delegates to. */
154
+ verificationMethod: string;
155
+ status: ReplicaStatus | null;
156
+ created: boolean;
157
+ };
158
+ type SyncResult = {
159
+ status: "synced";
160
+ } & SyncReport | {
161
+ status: "busy";
162
+ };
163
+ type GetResult = {
164
+ status: "present";
165
+ key: string;
166
+ value: ArrayBuffer;
167
+ etag: string;
168
+ metadata: Record<string, string>;
169
+ } | {
170
+ status: "content_missing";
171
+ key: string;
172
+ etag: string;
173
+ metadata: Record<string, string>;
174
+ } | {
175
+ status: "deleted" | "absent" | "coverage_incomplete" | "not_covered";
176
+ key: string;
177
+ };
178
+ type ListResult = {
179
+ entries: Array<{
180
+ key: string;
181
+ etag: string;
182
+ metadata: Record<string, string>;
183
+ content: boolean;
184
+ }>;
185
+ };
186
+ type StatusResult = {
187
+ status: ReplicaStatus;
188
+ persistence: {
189
+ persisted: boolean | null;
190
+ locksSupported: boolean;
191
+ };
192
+ };
193
+
194
+ /**
195
+ * The main-thread browser API (TC-19): a thin RPC client over the replica
196
+ * worker. IndexedDB, the network and the device key live in the worker; the
197
+ * client's local reads never touch the network, and its sync calls are the
198
+ * only path that does.
199
+ *
200
+ * The worker ships as `dist/replica.worker.js`, a self-contained ESM bundle
201
+ * spawned via `new Worker(new URL("./replica.worker.js", import.meta.url),
202
+ * { type: "module" })` — webpack 5 and Vite detect that form. A `worker`
203
+ * option accepts a `Worker` or `URL` escape hatch. In Vite dev mode, list
204
+ * `@tinycloud/replica` in `optimizeDeps.exclude` so the worker URL resolves
205
+ * inside the package (vitejs/vite#20859).
206
+ *
207
+ * Trust boundary: the browser origin, exactly as the OS user is the CLI's.
208
+ * Run one app per origin — any same-origin script can open the IndexedDB
209
+ * databases directly. `principal` (required) is the signed-in user's
210
+ * identity DID; it partitions replicas per user on that origin, it is not
211
+ * an authorization check — the node authorizes at sync time, and local
212
+ * reads are gated by the stored grant's node-attested window, like the CLI.
213
+ */
214
+
215
+ /**
216
+ * `reset()` result. With `purge: true` the replica's contents are erased in
217
+ * a single commit — the database file itself is never deleted: a small
218
+ * empty database named by a hash remains (holding no user data) and the
219
+ * next `openReplica` reinitializes it in place. Once the commit lands the
220
+ * result is final `{ purged: true }` — nothing after it can change the
221
+ * outcome.
222
+ */
223
+ type ResetResult = {
224
+ reset: true;
225
+ purged: boolean;
226
+ };
227
+ type BrowserReplicaOpenOptions = {
228
+ /** The TinyCloud host to sync from; pinned for the replica's life. */
229
+ host: string;
230
+ /** The space id (native form) the replica covers. */
231
+ space: string;
232
+ /** The KV prefix, e.g. `notes/`. */
233
+ prefix: string;
234
+ /** A display name; defaults to the prefix. */
235
+ name?: string;
236
+ /**
237
+ * The signed-in user's identity DID (for example `did:pkh:eip155:1:0x…`),
238
+ * as the app knows it. Replicas for different principals get different
239
+ * databases on the same origin; signing in again and installing the new
240
+ * session's grant continues the same replica from its cursor. This is an
241
+ * app-asserted partition label, not an authorization check — the trust
242
+ * boundary is the browser origin, and the node authorizes at sync time.
243
+ */
244
+ principal: string;
245
+ /** Replicating the `secrets` space or a `vault/` prefix needs this opt-in. */
246
+ allowSecrets?: boolean;
247
+ /**
248
+ * Spawn this worker instead of the bundled one (custom bundler layouts).
249
+ * A factory (`() => Worker`) is also accepted: it runs after `principal`
250
+ * validation, so a bad open never spawns a worker — useful for tests.
251
+ */
252
+ worker?: Worker | URL | string | (() => Worker);
253
+ };
254
+ type BrowserReplicaGetResult = GetResult;
255
+ type BrowserReplicaListResult = ListResult;
256
+ type BrowserReplicaStatus = StatusResult;
257
+ type BrowserReplicaSyncResult = SyncResult;
258
+ type BrowserReplicaOpenResult = OpenResult;
259
+ /** Subscriptions for worker events (other tabs' commits, resets). */
260
+ type BrowserReplicaEvents = {
261
+ /** Another tab's worker committed a page (`serial` is monotonic). */
262
+ onCommitted?: (serial: number) => void;
263
+ /** The replica's authority state changed. */
264
+ onAuthority?: (state: string) => void;
265
+ /** The replica was reset in this tab or observed elsewhere. */
266
+ onReset?: (reason: string) => void;
267
+ };
268
+ /**
269
+ * Open (or create) a replica in a dedicated worker. The first call also asks
270
+ * the browser for durable storage (`navigator.storage.persist()`), so an
271
+ * eviction under pressure is less likely; `status()` reports the outcome.
272
+ * In environments without IndexedDB the returned promise rejects with
273
+ * `RUNTIME_UNSUPPORTED` — there is no in-memory fallback, reads would lose
274
+ * their durability guarantee.
275
+ *
276
+ * `principal` partitions the replica per signed-in user and is required: the
277
+ * same host/space/prefix under two principals yields two databases. A device
278
+ * grant chained to a web session (about one hour under an OpenKey session)
279
+ * gives about that session's length of offline reads; after sign-in rotates
280
+ * the session key, install the new grant and the replica continues from its
281
+ * cursor.
282
+ */
283
+ declare function openReplica(options: BrowserReplicaOpenOptions, events?: BrowserReplicaEvents): Promise<BrowserReplica>;
284
+ declare class BrowserReplica {
285
+ #private;
286
+ /** The open() result: replicaId, deviceDid and the status at open time. */
287
+ opened: OpenResult | null;
288
+ /** Settles when the worker reports it is listening (or fails to start). */
289
+ readonly ready: Promise<void>;
290
+ /** For tests and advanced use: drive an already-spawned worker. */
291
+ constructor(worker: Worker, events?: BrowserReplicaEvents);
292
+ /** Open or create the replica; `openReplica` calls this for you. */
293
+ open(options: BrowserReplicaOpenOptions & {
294
+ persisted?: boolean;
295
+ }): Promise<BrowserReplica>;
296
+ /** The DID the app delegates to (`tcw.delegateTo(deviceDid, […sync…])`). */
297
+ get deviceDid(): string;
298
+ /** Install a signed grant: the compact UCAN (`delegation.delegationHeader.Authorization`). */
299
+ installGrant(delegation: string): Promise<{
300
+ cid: string;
301
+ audience: string;
302
+ expiresAt: number | null;
303
+ }>;
304
+ /** Pull the feed and commit each page atomically. `busy` when another tab syncs. */
305
+ sync(options?: {
306
+ limit?: number;
307
+ }): Promise<SyncResult>;
308
+ /** Local read: never touches the network. */
309
+ get(key: string): Promise<GetResult>;
310
+ /** Local read: never touches the network. */
311
+ list(options?: {
312
+ prefix?: string;
313
+ after?: string;
314
+ limit?: number;
315
+ }): Promise<ListResult>;
316
+ /** Replica status plus browser persistence facts. */
317
+ status(): Promise<StatusResult>;
318
+ /** Opt in to post-expiry reads under a `tinycloud.kv/retain` grant. */
319
+ setRetentionGrant(cid: string | null): Promise<{
320
+ retentionGrantCid: string | null;
321
+ }>;
322
+ /**
323
+ * Clear entries and cursor (keeps config and grant); `purge` erases the
324
+ * replica permanently in place — the database file is never deleted, so
325
+ * the result is final the moment it resolves (see {@link ResetResult}).
326
+ */
327
+ reset(options?: {
328
+ purge?: boolean;
329
+ }): Promise<ResetResult>;
330
+ /** Close the worker-side handles and terminate the worker. */
331
+ close(): Promise<void>;
332
+ }
333
+
334
+ export { BrowserReplica, type BrowserReplicaEvents, type BrowserReplicaGetResult, type BrowserReplicaListResult, type BrowserReplicaOpenOptions, type BrowserReplicaOpenResult, type BrowserReplicaStatus, type BrowserReplicaSyncResult, ReplicaError, ReplicaErrorCode, type ResetResult, openReplica };
@@ -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":";AAIO,IAAM,mBAAmB;AAAA;AAAA,EAE9B,MAAM;AAAA;AAAA,EAEN,WAAW;AAAA;AAAA,EAEX,iBAAiB;AAAA;AAAA,EAEjB,qBAAqB;AAAA;AAAA,EAErB,kBAAkB;AAAA;AAAA,EAElB,eAAe;AAAA;AAAA,EAEf,cAAc;AAAA;AAAA,EAEd,aAAa;AAAA;AAAA,EAEb,yBAAyB;AAAA;AAAA,EAEzB,eAAe;AAAA;AAAA,EAEf,eAAe;AAAA;AAAA,EAEf,0BAA0B;AAAA;AAAA,EAE1B,yBAAyB;AAAA;AAAA,EAEzB,oBAAoB;AAAA,EACpB,qBAAqB;AAAA,EACrB,eAAe;AAAA,EACf,eAAe;AAAA;AAAA,EAEf,oBAAoB;AAAA;AAAA,EAEpB,yBAAyB;AAAA;AAAA,EAEzB,eAAe;AAAA;AAAA,EAEf,YAAY;AAAA;AAAA,EAEZ,gBAAgB;AAAA;AAAA,EAEhB,gBAAgB;AAAA;AAAA,EAEhB,gBAAgB;AAAA;AAAA,EAEhB,iBAAiB;AAAA;AAAA,EAEjB,kBAAkB;AAAA;AAAA,EAElB,iBAAiB;AAAA;AAAA,EAEjB,QAAQ;AACV;AAQA,IAAM,gBAAgB,uBAAO,IAAI,yBAAyB;AAEnD,IAAM,eAAN,cAA2B,MAAM;AAAA,EACtC,CAAU,aAAa,IAAI;AAAA,EAClB;AAAA,EACA;AAAA,EAET,YAAY,MAAwB,SAAiB,QAAkC,SAA+B;AACpH,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,QAAI,WAAW,OAAW,MAAK,SAAS;AAAA,EAC1C;AACF;;;ACxEA,IAAM,SAAS;AAGf,IAAM,SAAS;AASR,SAAS,eAAe,OAAwB;AACrD,MAAI,OAAO,UAAU,YAAY,CAAC,MAAM,WAAW,MAAM,EAAG,QAAO;AACnE,QAAM,OAAO,MAAM,MAAM,CAAC;AAC1B,QAAM,YAAY,KAAK,QAAQ,GAAG;AAClC,MAAI,aAAa,EAAG,QAAO;AAC3B,MAAI,CAAC,OAAO,KAAK,KAAK,MAAM,GAAG,SAAS,CAAC,EAAG,QAAO;AACnD,QAAM,OAAO,KAAK,MAAM,YAAY,CAAC;AACrC,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,WAAW,KAAK,MAAM,GAAG;AAC/B,QAAM,OAAO,SAAS,SAAS,SAAS,CAAC;AACzC,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,aAAW,WAAW,UAAU;AAC9B,aAAS,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK,GAAG;AAC1C,YAAM,IAAI,QAAQ,CAAC;AACnB,UAAI,MAAM,KAAK;AAEb,YAAI,IAAI,KAAK,QAAQ,UAAU,CAAC,oBAAoB,KAAK,QAAQ,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,EAAG,QAAO;AAC9F,aAAK;AAAA,MACP,WAAW,CAAC,OAAO,KAAK,CAAC,GAAG;AAC1B,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;ACiDA,SAAS,WAAW,QAAoE;AACtF,MAAI,WAAW,QAAW;AACxB,QAAI,OAAO,WAAW,eAAe,kBAAkB,OAAQ,QAAO;AACtE,QAAI,kBAAkB,OAAO,OAAO,WAAW,SAAU,QAAO,IAAI,OAAO,QAAQ,EAAE,MAAM,SAAS,CAAC;AACrG,QAAI,OAAO,WAAW,WAAY,QAAO,OAAO;AAAA,EAClD;AACA,SAAO,IAAI,OAAO,IAAI,IAAI,uBAAuB,YAAY,GAAG,GAAG,EAAE,MAAM,SAAS,CAAC;AACvF;AAiBA,eAAsB,YAAY,SAAoC,SAA+B,CAAC,GAA4B;AAEhI,MAAI,OAAO,QAAQ,cAAc,YAAY,CAAC,eAAe,QAAQ,SAAS,GAAG;AAC/E,UAAM,IAAI;AAAA,MACR,iBAAiB;AAAA,MACjB,gFAAgF,KAAK,UAAU,QAAQ,SAAS,CAAC;AAAA,IACnH;AAAA,EACF;AACA,QAAM,SAAS,WAAW,QAAQ,MAAM;AACxC,QAAM,UAAU,IAAI,eAAe,QAAQ,MAAM;AACjD,MAAI;AACF,UAAM,QAAQ;AACd,UAAM,YACJ,OAAO,cAAc,eAAe,OAAO,UAAU,SAAS,YAAY,aACtE,MAAM,UAAU,QAAQ,QAAQ,EAAE,MAAM,MAAM,MAAS,IACvD;AAEN,WAAO,MAAM,QAAQ,KAAK,EAAE,GAAG,SAAS,QAAQ,QAAW,UAAU,CAAC;AAAA,EACxE,SAAS,OAAO;AAId,UAAM,QAAQ,MAAM,EAAE,MAAM,MAAM,MAAS;AAC3C,WAAO,UAAU;AACjB,UAAM;AAAA,EACR;AACF;AAEO,IAAM,iBAAN,MAAqB;AAAA;AAAA,EAE1B,SAA4B;AAAA,EAEnB;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACT,UAAU;AAAA,EACV,WAAW,oBAAI,IAAqB;AAAA,EACpC,UAAU;AAAA;AAAA,EAGV,YAAY,QAAgB,SAA+B,CAAC,GAAG;AAC7D,SAAK,UAAU;AACf,SAAK,UAAU;AACf,UAAM,QAAQ,QAAQ,cAAoB;AAC1C,SAAK,QAAQ,MAAM;AAGnB,UAAM,QAAQ,MAAM,MAAM,MAAS;AACnC,SAAK,gBAAgB,MAAM;AAC3B,SAAK,eAAe,MAAM;AAC1B,WAAO,YAAY,CAAC,YAA0B,KAAK,WAAW,QAAQ,IAAI;AAC1E,WAAO,UAAU,CAAC,UAAU;AAC1B,YAAM,QAAQ,IAAI,MAAM,MAAM,WAAW,4BAA4B;AACrE,WAAK,aAAa,KAAK;AACvB,WAAK,SAAS,KAAK;AAAA,IACrB;AAAA,EACF;AAAA,EAEA;AAAA,EACA;AAAA,EAEA,WAAW,MAAqB;AAC9B,QAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,WAAW,MAAM;AAChE,YAAM,QAAS,KAAwC;AACvD,cAAQ,MAAM,OAAO;AAAA,QACnB,KAAK;AACH,eAAK,cAAc;AACnB;AAAA,QACF,KAAK;AACH,cAAI,OAAQ,MAA+B,WAAW,UAAU;AAC9D,iBAAK,QAAQ,cAAe,MAA6B,MAAM;AAAA,UACjE;AACA;AAAA,QACF,KAAK;AACH,cAAI,OAAQ,MAA8B,UAAU,UAAU;AAC5D,iBAAK,QAAQ,cAAe,MAA4B,KAAK;AAAA,UAC/D;AACA;AAAA,QACF,KAAK;AACH,cAAI,OAAQ,MAA+B,WAAW,UAAU;AAC9D,iBAAK,QAAQ,UAAW,MAA6B,MAAM;AAAA,UAC7D;AACA;AAAA,QACF;AACE;AAAA,MACJ;AAAA,IACF;AACA,UAAM,QAAQ;AACd,QAAI,OAAO,MAAM,OAAO,SAAU;AAClC,UAAM,UAAU,KAAK,SAAS,IAAI,MAAM,EAAE;AAC1C,QAAI,YAAY,OAAW;AAC3B,SAAK,SAAS,OAAO,MAAM,EAAE;AAC7B,QAAI,MAAM,IAAI;AACZ,cAAQ,QAAQ,MAAM,MAAM;AAAA,IAC9B,OAAO;AACL,cAAQ,OAAO,IAAI,aAAa,MAAM,IAAI,MAA0B,MAAM,IAAI,SAAS,MAAM,IAAI,MAAM,CAAC;AAAA,IAC1G;AAAA,EACF;AAAA,EAEA,SAAS,OAAoB;AAC3B,eAAW,WAAW,KAAK,SAAS,OAAO,EAAG,SAAQ,OAAO,KAAK;AAClE,SAAK,SAAS,MAAM;AAAA,EACtB;AAAA,EAEA,MAAM,MAAS,SAA8C;AAC3D,QAAI,KAAK,QAAS,OAAM,IAAI,aAAa,iBAAiB,QAAQ,wBAAwB;AAC1F,UAAM,KAAK,KAAK;AAChB,UAAM,EAAE,SAAS,SAAS,OAAO,IAAI,QAAQ,cAAuB;AACpE,SAAK,SAAS,IAAI,IAAI,EAAE,SAAS,OAAO,CAAC;AACzC,SAAK,QAAQ,YAAY,EAAE,IAAI,GAAG,QAAQ,CAAC;AAC3C,WAAQ,MAAM;AAAA,EAChB;AAAA;AAAA,EAGA,MAAM,KAAK,SAAuF;AAChG,SAAK,SAAS,MAAM,KAAK,MAAkB;AAAA,MACzC,IAAI;AAAA,MACJ,MAAM,QAAQ;AAAA,MACd,OAAO,QAAQ;AAAA,MACf,QAAQ,QAAQ;AAAA,MAChB,WAAW,QAAQ;AAAA,MACnB,GAAI,QAAQ,SAAS,SAAY,CAAC,IAAI,EAAE,MAAM,QAAQ,KAAK;AAAA,MAC3D,GAAI,QAAQ,iBAAiB,SAAY,CAAC,IAAI,EAAE,cAAc,QAAQ,aAAa;AAAA,MACnF,GAAI,QAAQ,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,QAAQ,UAAU;AAAA,IAC5E,CAAC;AACD,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,IAAI,YAAoB;AACtB,QAAI,KAAK,WAAW,KAAM,OAAM,IAAI,MAAM,2BAA2B;AACrE,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA;AAAA,EAGA,aAAa,YAA0F;AACrG,WAAO,KAAK,MAAM,EAAE,IAAI,gBAAgB,WAAW,CAAC;AAAA,EACtD;AAAA;AAAA,EAGA,KAAK,UAA8B,CAAC,GAAwB;AAC1D,WAAO,KAAK,MAAM,EAAE,IAAI,QAAQ,GAAI,QAAQ,UAAU,SAAY,CAAC,IAAI,EAAE,OAAO,QAAQ,MAAM,EAAG,CAAC;AAAA,EACpG;AAAA;AAAA,EAGA,IAAI,KAAiC;AACnC,WAAO,KAAK,MAAM,EAAE,IAAI,OAAO,IAAI,CAAC;AAAA,EACtC;AAAA;AAAA,EAGA,KAAK,UAA+D,CAAC,GAAwB;AAC3F,WAAO,KAAK,MAAM,EAAE,IAAI,QAAQ,GAAG,QAAQ,CAAC;AAAA,EAC9C;AAAA;AAAA,EAGA,SAAgC;AAC9B,WAAO,KAAK,MAAM,EAAE,IAAI,SAAS,CAAC;AAAA,EACpC;AAAA;AAAA,EAGA,kBAAkB,KAAmE;AACnF,WAAO,KAAK,MAAM,EAAE,IAAI,gBAAgB,UAAU,IAAI,CAAC;AAAA,EACzD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,UAA+B,CAAC,GAAyB;AAC7D,WAAO,KAAK,MAAM,EAAE,IAAI,SAAS,OAAO,QAAQ,UAAU,KAAK,CAAC;AAAA,EAClE;AAAA;AAAA,EAGA,MAAM,QAAuB;AAC3B,QAAI,KAAK,QAAS;AAClB,SAAK,UAAU;AACf,SAAK,aAAa,IAAI,aAAa,iBAAiB,QAAQ,wBAAwB,CAAC;AAKrF,SAAK,SAAS,IAAI,aAAa,iBAAiB,QAAQ,wBAAwB,CAAC;AACjF,UAAM,KAAK,KAAK;AAChB,UAAM,EAAE,SAAS,QAAQ,IAAI,QAAQ,cAAuB;AAC5D,SAAK,SAAS,IAAI,IAAI,EAAE,SAAS,QAAQ,QAAQ,CAAC;AAClD,SAAK,QAAQ,YAAY,EAAE,IAAI,IAAI,QAAQ,CAAC;AAC5C,UAAM,UAAU,WAAW,MAAM;AAC/B,WAAK,SAAS,OAAO,EAAE;AACvB,cAAQ,MAAS;AAAA,IACnB,GAAG,GAAK;AACR,UAAM,QAAQ,MAAM,MAAM,MAAS,EAAE,QAAQ,MAAM,aAAa,OAAO,CAAC;AACxE,SAAK,QAAQ,UAAU;AACvB,SAAK,SAAS,IAAI,aAAa,iBAAiB,QAAQ,wBAAwB,CAAC;AAAA,EACnF;AACF;","names":[]}