@drakkar.software/starfish-client 3.0.0-alpha.11 → 3.0.0-alpha.12

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/dist/client.js ADDED
@@ -0,0 +1,391 @@
1
+ import { AUTHOR_PUBKEY_FIELD, AUTHOR_SIGNATURE_FIELD, DATA_FIELD, TS_FIELD, BASE_HASH_FIELD, PUSH_PATH_PREFIX, HEADER_AUTHORIZATION, HEADER_SIG, HEADER_TS, HEADER_NONCE, HEADER_PUB, HEADER_CONTENT_TYPE, HEADER_ACCEPT, signAppendAuthor, signRequest, stableStringify, } from "@drakkar.software/starfish-protocol";
2
+ import { ConflictError, StarfishHttpError } from "./types.js";
3
+ const APPEND_DEFAULT_FIELD = "items";
4
+ /** The storage `documentKey` for a push `path`: the path with the `/push/`
5
+ * action prefix stripped (the namespace lives only in the URL). The author
6
+ * signature binds to this key. */
7
+ export function stripPushPrefix(path) {
8
+ return path.startsWith(PUSH_PATH_PREFIX) ? path.slice(PUSH_PATH_PREFIX.length) : path;
9
+ }
10
+ /**
11
+ * Base64-encode the canonical stable-stringification of a cap-cert.
12
+ *
13
+ * Used as the value of the `Authorization: Cap <…>` header in v3.0. We rely
14
+ * on the host's `btoa` for browsers and fall back to `Buffer` in Node so the
15
+ * client stays free of native dependencies.
16
+ */
17
+ function encodeCapAuth(cap) {
18
+ const json = stableStringify(cap);
19
+ if (typeof btoa === "function") {
20
+ return btoa(json);
21
+ }
22
+ const bufCtor = globalThis.Buffer;
23
+ if (bufCtor)
24
+ return bufCtor.from(json, "utf-8").toString("base64");
25
+ throw new Error("No base64 encoder available");
26
+ }
27
+ /**
28
+ * Low-level HTTP client for the Starfish sync protocol.
29
+ * Handles auth headers and response parsing.
30
+ */
31
+ export class StarfishClient {
32
+ baseUrl;
33
+ namespace;
34
+ capProvider;
35
+ fetch;
36
+ /**
37
+ * Installed client-side plugins. Currently stored as inert data; no
38
+ * hooks fire yet. Extensions can inspect this list if needed.
39
+ */
40
+ plugins;
41
+ constructor(options) {
42
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
43
+ // Empty string ⇒ no namespace (treat like unset), so a falsy env value
44
+ // doesn't produce a malformed `/v1//…` path.
45
+ this.namespace = options.namespace || undefined;
46
+ this.capProvider = options.capProvider;
47
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
48
+ this.plugins = options.plugins ? [...options.plugins] : [];
49
+ }
50
+ /**
51
+ * Resolve the host portion of the URL the client will send to. The host
52
+ * is folded into the signed canonical input as the `h` field so the
53
+ * server can refuse a signature that was minted against a different
54
+ * Starfish host (replay-across-servers defence).
55
+ *
56
+ * When `baseUrl` is relative — e.g. the consumer passed a custom `fetch`
57
+ * that resolves relative URLs in its own context — there is no parseable
58
+ * host; we return `""` so signing still proceeds. The server-side
59
+ * verifier will also reconstruct host from its inbound URL, so the
60
+ * empty-host case still verifies symmetrically when both sides agree.
61
+ */
62
+ signingHost() {
63
+ try {
64
+ return new URL(this.baseUrl).host;
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ }
70
+ /**
71
+ * Rewrite a request path for the configured namespace. A no-op when no
72
+ * namespace is set; otherwise `/{action}/…` becomes `/v1/{namespace}/{action}/…`
73
+ * (the `/v1` protocol-version segment is part of the namespaced route, matching
74
+ * the Python client and the server's namespace mount).
75
+ *
76
+ * Applied to the path used for BOTH the signature and the URL so the canonical
77
+ * path the client signs equals the path the server reconstructs from the URL.
78
+ * Covers SDK-helper-built paths too — that's the point: a namespace-unaware
79
+ * helper passing `/push/spaces/x/_keyring` reaches `/v1/{ns}/push/spaces/x/_keyring`.
80
+ */
81
+ applyNamespace(path) {
82
+ return this.namespace ? `/v1/${this.namespace}${path}` : path;
83
+ }
84
+ /**
85
+ * Build auth headers for a request. When a `capProvider` is set, signs the
86
+ * request with the device's Ed25519 private key and returns the v3 header
87
+ * set (`Authorization: Cap …`, `X-Starfish-Sig`, `X-Starfish-Ts`,
88
+ * `X-Starfish-Nonce`). Empty when no provider is configured (public reads).
89
+ *
90
+ * Body bytes signed MUST equal the bytes sent on the wire — callers pass
91
+ * the already-serialized body string here so signing and transmission agree.
92
+ * The host bound into the signature is derived from `baseUrl` once per call.
93
+ */
94
+ async buildAuthHeaders(method, pathAndQuery, body) {
95
+ if (!this.capProvider)
96
+ return {};
97
+ const capCtx = await this.capProvider.getCap();
98
+ return this.capRequestHeaders(capCtx, method, pathAndQuery, body);
99
+ }
100
+ /**
101
+ * Build the request-signing headers from an ALREADY-fetched cap context. Split
102
+ * out of {@link buildAuthHeaders} so {@link append} can fetch the cap once and
103
+ * reuse it for BOTH the author signature (over the element data) and the
104
+ * request signature (over the body), without redeeming the cap twice — a
105
+ * second `getCap()` could rotate keys and break the `authorPubkey ===
106
+ * presenter` bind the server checks.
107
+ */
108
+ async capRequestHeaders(capCtx, method, pathAndQuery, body) {
109
+ const { cap, devEdPrivHex, pubHex } = capCtx;
110
+ const req = {
111
+ method,
112
+ pathAndQuery,
113
+ body,
114
+ host: this.signingHost(),
115
+ };
116
+ const { sig, ts, nonce } = await signRequest(req, devEdPrivHex);
117
+ const headers = {
118
+ [HEADER_AUTHORIZATION]: `Cap ${encodeCapAuth(cap)}`,
119
+ [HEADER_SIG]: sig,
120
+ [HEADER_TS]: String(ts),
121
+ [HEADER_NONCE]: nonce,
122
+ };
123
+ // Audience (public-link) caps bind no single subject, so the server needs
124
+ // the presenter's pubkey to verify the signature and check the allow-list.
125
+ if (pubHex !== undefined)
126
+ headers[HEADER_PUB] = pubHex;
127
+ return headers;
128
+ }
129
+ /**
130
+ * Resolve the author public key to attach to a signed append: the redeemer's
131
+ * `pubHex` for an audience cap, else the cert subject `cap.sub` for a
132
+ * device/member cap. This is the SAME key that signs the request, so a server
133
+ * enforcing author proof can bind the stored element to its writer. Returns
134
+ * undefined only for a (malformed) cap with neither — the append then goes
135
+ * unsigned and a server requiring signatures rejects it.
136
+ */
137
+ appendAuthorKey(capCtx) {
138
+ const { cap, pubHex } = capCtx;
139
+ const authorPubHex = pubHex ?? cap.sub;
140
+ if (authorPubHex === undefined)
141
+ return null;
142
+ return { authorPubHex };
143
+ }
144
+ async pull(path, checkpointOrOptions) {
145
+ let pathAndQuery = this.applyNamespace(path);
146
+ let appendField;
147
+ if (typeof checkpointOrOptions === "number") {
148
+ if (checkpointOrOptions)
149
+ pathAndQuery += `?checkpoint=${checkpointOrOptions}`;
150
+ }
151
+ else if (checkpointOrOptions != null) {
152
+ // Disambiguate AppendPullOptions vs PullOptions.
153
+ //
154
+ // PullOptions are identified by the presence of `withKeyring` or
155
+ // `checkpoint` keys (which AppendPullOptions does not have — append
156
+ // uses `since`, not `checkpoint`). Anything else, including an empty
157
+ // `{}` object, retains the historical behavior of AppendPullOptions
158
+ // (extracts `data.items` with `?` query).
159
+ const opts = checkpointOrOptions;
160
+ const isPullOptions = opts.withKeyring !== undefined || opts.checkpoint !== undefined;
161
+ const params = new URLSearchParams();
162
+ if (isPullOptions) {
163
+ if (opts.checkpoint != null && opts.checkpoint > 0) {
164
+ params.set("checkpoint", String(opts.checkpoint));
165
+ }
166
+ if (opts.withKeyring) {
167
+ params.set("withKeyring", "1");
168
+ }
169
+ }
170
+ else {
171
+ appendField = opts.appendField ?? APPEND_DEFAULT_FIELD;
172
+ if (opts.since != null) {
173
+ if (opts.since < 0)
174
+ throw new Error("since must be non-negative");
175
+ params.set("checkpoint", String(opts.since));
176
+ }
177
+ if (opts.last != null) {
178
+ if (opts.last < 0)
179
+ throw new Error("last must be non-negative");
180
+ params.set("last", String(opts.last));
181
+ }
182
+ }
183
+ if (params.size > 0)
184
+ pathAndQuery += `?${params.toString()}`;
185
+ }
186
+ const url = `${this.baseUrl}${pathAndQuery}`;
187
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, undefined);
188
+ const res = await this.fetch(url, {
189
+ method: "GET",
190
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
191
+ });
192
+ if (!res.ok) {
193
+ throw new StarfishHttpError(res.status, await res.text());
194
+ }
195
+ const result = await res.json();
196
+ if (appendField !== undefined) {
197
+ const list = result.data?.[appendField];
198
+ return (Array.isArray(list) ? list : []);
199
+ }
200
+ return result;
201
+ }
202
+ /**
203
+ * Pull several documents in one round-trip via `/batch/pull`. `collections` is
204
+ * the list of distinct collection names; `opts.params` supplies, per collection,
205
+ * an ARRAY of path-param sets — one per document to read — so the SAME collection
206
+ * can fan in many documents (e.g. many users' `profile`) in a single request.
207
+ * The server auto-fills the `{identity}` param from the authenticated caller for
208
+ * any set that omits it, so a self-doc collection needs no params. Returns a map
209
+ * of collection name → an ARRAY of pulled documents (or per-document `{ error }`),
210
+ * in request order. Honors the configured namespace.
211
+ *
212
+ * For the common "many docs of one collection" case prefer {@link batchPullMany}.
213
+ *
214
+ * Note: not append/checkpoint-aware — for incremental append-only reads use
215
+ * `pull(path, { since })` (or `AppendLogCursor`) per collection.
216
+ */
217
+ async batchPull(collections, opts = {}) {
218
+ const search = new URLSearchParams();
219
+ search.set("collections", collections.join(","));
220
+ if (opts.params && Object.keys(opts.params).length > 0) {
221
+ search.set("params", JSON.stringify(opts.params));
222
+ }
223
+ const pathAndQuery = `${this.applyNamespace("/batch/pull")}?${search.toString()}`;
224
+ const url = `${this.baseUrl}${pathAndQuery}`;
225
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, undefined);
226
+ const res = await this.fetch(url, {
227
+ method: "GET",
228
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders },
229
+ });
230
+ if (!res.ok) {
231
+ throw new StarfishHttpError(res.status, await res.text());
232
+ }
233
+ return await res.json();
234
+ }
235
+ /**
236
+ * Convenience over {@link batchPull} for reading MANY documents of ONE
237
+ * collection in a single round-trip: pass the per-document param-sets and get
238
+ * back the {@link BatchPullEntry} array aligned to `paramsList` by index (each
239
+ * entry is `{ data, hash, timestamp }` or `{ error }`). An empty `paramsList`
240
+ * issues no request and returns `[]`.
241
+ */
242
+ async batchPullMany(collection, paramsList) {
243
+ if (paramsList.length === 0)
244
+ return [];
245
+ const res = await this.batchPull([collection], { params: { [collection]: paramsList } });
246
+ return res.collections[collection] ?? [];
247
+ }
248
+ /**
249
+ * Push synced data to the server.
250
+ * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
251
+ * @param data - The full document data to push
252
+ * @param baseHash - Hash of the document this push is based on (null for first push)
253
+ *
254
+ * v3 author proof (`authorPubkey` + `authorSignature`) is passed via `author`
255
+ * (produced by `SyncManager` when a `signer` is configured) and sent as
256
+ * top-level body siblings of `data`, where the server verifies it.
257
+ * @throws {ConflictError} if the server detects a hash mismatch (409)
258
+ */
259
+ async push(path, data, baseHash, author) {
260
+ const body = JSON.stringify({
261
+ [DATA_FIELD]: data,
262
+ [BASE_HASH_FIELD]: baseHash,
263
+ ...(author && {
264
+ [AUTHOR_PUBKEY_FIELD]: author.authorPubkey,
265
+ [AUTHOR_SIGNATURE_FIELD]: author.authorSignature,
266
+ }),
267
+ });
268
+ const sendPath = this.applyNamespace(path);
269
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, body);
270
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
271
+ method: "POST",
272
+ headers: {
273
+ [HEADER_CONTENT_TYPE]: "application/json",
274
+ [HEADER_ACCEPT]: "application/json",
275
+ ...authHeaders,
276
+ },
277
+ body,
278
+ });
279
+ if (res.status === 409) {
280
+ throw new ConflictError();
281
+ }
282
+ if (!res.ok) {
283
+ throw new StarfishHttpError(res.status, await res.text());
284
+ }
285
+ return res.json();
286
+ }
287
+ /**
288
+ * Append an element to an appendOnly (`by_timestamp`) collection.
289
+ *
290
+ * Unlike {@link push}, appendOnly writes carry no hash/conflict check — an
291
+ * authorized append is always accepted. Each element is stored server-side as
292
+ * `{ts, data}` and pulls can filter by `ts` via `since`/`checkpoint`.
293
+ *
294
+ * @param path - the push endpoint (e.g. "/push/events")
295
+ * @param data - the element payload. For a `delegated` collection, encrypt it
296
+ * first (e.g. `createKeyringEncryptor(keyring, kem).encrypt(data)`); the
297
+ * server stores it opaquely and never reads it.
298
+ * @param opts.ts - optional client-supplied element timestamp (ms). Must be a
299
+ * non-negative integer strictly greater than the latest stored element's ts
300
+ * (else the server responds 409). Omit to let the server assign one.
301
+ * @throws {StarfishHttpError} on a non-2xx response — e.g. 409
302
+ * `{ error: "non_monotonic_timestamp" }` for a non-monotonic timestamp, or
303
+ * `{ error: "append_limit_exceeded", limit }` if the collection's `maxItems`
304
+ * cap is reached (partition by a path parameter for higher volume).
305
+ */
306
+ async append(path, data, opts = {}) {
307
+ const sendPath = this.applyNamespace(path);
308
+ const bodyObj = { [DATA_FIELD]: data };
309
+ if (opts.ts !== undefined)
310
+ bodyObj[TS_FIELD] = opts.ts;
311
+ // Author proof. Fetch the cap ONCE and reuse it for both the author
312
+ // signature (over the element `data`) and the request signature (over the
313
+ // final body) — see {@link capRequestHeaders}. The author fields are signed
314
+ // with the same key that authenticates the request, so a collection with
315
+ // `requireAuthorSignature` (the default) binds the stored element to its
316
+ // writer. Without a cap provider the append is sent unsigned and such a
317
+ // collection rejects it.
318
+ const capCtx = this.capProvider ? await this.capProvider.getCap() : null;
319
+ if (capCtx) {
320
+ const authorKey = this.appendAuthorKey(capCtx);
321
+ if (authorKey) {
322
+ // The signature binds the author to BOTH the element data AND the
323
+ // document it is written to (the storage path = `path` minus the
324
+ // `/push/` action prefix; the namespace lives only in the URL).
325
+ const documentKey = stripPushPrefix(path);
326
+ const { authorPubkey, authorSignature } = signAppendAuthor(documentKey, data, authorKey.authorPubHex, capCtx.devEdPrivHex);
327
+ bodyObj[AUTHOR_PUBKEY_FIELD] = authorPubkey;
328
+ bodyObj[AUTHOR_SIGNATURE_FIELD] = authorSignature;
329
+ }
330
+ }
331
+ const body = JSON.stringify(bodyObj);
332
+ const authHeaders = capCtx
333
+ ? await this.capRequestHeaders(capCtx, "POST", sendPath, body)
334
+ : {};
335
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
336
+ method: "POST",
337
+ headers: {
338
+ [HEADER_CONTENT_TYPE]: "application/json",
339
+ [HEADER_ACCEPT]: "application/json",
340
+ ...authHeaders,
341
+ },
342
+ body,
343
+ });
344
+ if (!res.ok) {
345
+ throw new StarfishHttpError(res.status, await res.text());
346
+ }
347
+ return res.json();
348
+ }
349
+ /**
350
+ * Pull binary data from a blob collection.
351
+ * Returns raw bytes with the content hash from the ETag header.
352
+ */
353
+ async pullBlob(path) {
354
+ const sendPath = this.applyNamespace(path);
355
+ const authHeaders = await this.buildAuthHeaders("GET", sendPath, undefined);
356
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
357
+ method: "GET",
358
+ headers: { [HEADER_ACCEPT]: "*/*", ...authHeaders },
359
+ });
360
+ if (!res.ok) {
361
+ throw new StarfishHttpError(res.status, await res.text());
362
+ }
363
+ const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
364
+ const contentType = res.headers.get(HEADER_CONTENT_TYPE) ?? "application/octet-stream";
365
+ const data = await res.arrayBuffer();
366
+ return { data, hash: etag, contentType };
367
+ }
368
+ /**
369
+ * Push binary data to a blob collection.
370
+ * Binary collections use last-write-wins (no conflict detection).
371
+ */
372
+ async pushBlob(path, data, contentType) {
373
+ // Blobs are not JSON; we leave body undefined when signing — server-side
374
+ // verification is expected to use a separate path for blob uploads.
375
+ const sendPath = this.applyNamespace(path);
376
+ const authHeaders = await this.buildAuthHeaders("POST", sendPath, undefined);
377
+ const res = await this.fetch(`${this.baseUrl}${sendPath}`, {
378
+ method: "POST",
379
+ headers: {
380
+ [HEADER_CONTENT_TYPE]: contentType,
381
+ [HEADER_ACCEPT]: "application/json",
382
+ ...authHeaders,
383
+ },
384
+ body: data,
385
+ });
386
+ if (!res.ok) {
387
+ throw new StarfishHttpError(res.status, await res.text());
388
+ }
389
+ return res.json();
390
+ }
391
+ }
package/dist/config.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Fetch the server's collection manifest from GET /config.
3
+ *
4
+ * @param baseUrl - Base URL of the Starfish server (e.g. `"https://api.example.com/v1"`).
5
+ * @param options.headers - Optional request headers (e.g. `Authorization`).
6
+ * @throws {Error} if the server returns a non-2xx response.
7
+ */
8
+ export async function fetchServerConfig(baseUrl, options) {
9
+ const url = `${baseUrl.replace(/\/$/, "")}/config`;
10
+ const res = await fetch(url, {
11
+ method: "GET",
12
+ headers: options?.headers,
13
+ });
14
+ if (!res.ok) {
15
+ throw new Error(`fetchServerConfig: ${res.status} ${res.statusText}`);
16
+ }
17
+ return res.json();
18
+ }
@@ -0,0 +1,120 @@
1
+ // ── Implementation ────────────────────────────────────────────────────────────
2
+ const DEFAULT_DELAY_MS = 2000;
3
+ const DEFAULT_WARN_BYTES = 900 * 1024; // 900 KB
4
+ const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB
5
+ /** Returns true if the push should be blocked. */
6
+ function checkPayloadSize(doc, opts) {
7
+ // Estimate encrypted payload size. AES-GCM output is similar to input size;
8
+ // base64 encoding adds ~33% overhead, plus a small IV/tag overhead.
9
+ const estimatedBytes = Math.ceil(JSON.stringify(doc).length * 1.34);
10
+ if (estimatedBytes > opts.maxBytes) {
11
+ if (opts.onSizeExceeded) {
12
+ opts.onSizeExceeded(estimatedBytes);
13
+ }
14
+ else {
15
+ console.error(`[starfish] Push blocked: estimated payload ${(estimatedBytes / 1024).toFixed(0)} KB ` +
16
+ `exceeds limit of ${(opts.maxBytes / 1024).toFixed(0)} KB. Prune your data before syncing.`);
17
+ }
18
+ return true;
19
+ }
20
+ if (estimatedBytes > opts.warnBytes) {
21
+ if (opts.onSizeWarning) {
22
+ opts.onSizeWarning(estimatedBytes);
23
+ }
24
+ else {
25
+ console.warn(`[starfish] Payload approaching limit: estimated ${(estimatedBytes / 1024).toFixed(0)} KB ` +
26
+ `(warn threshold: ${(opts.warnBytes / 1024).toFixed(0)} KB).`);
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+ /**
32
+ * Creates a debounced push helper that coalesces rapid mutations into a single sync.
33
+ *
34
+ * Designed to be called on every domain store mutation (e.g., every keystroke).
35
+ * The push is delayed by `delayMs` after the **last** call, so typing quickly
36
+ * results in one push, not one per character.
37
+ *
38
+ * Also estimates the encrypted payload size before pushing and warns / blocks
39
+ * if it approaches the server's body size limit.
40
+ *
41
+ * ```ts
42
+ * const { notify } = createDebouncedSync(starfishStore, {
43
+ * serialize: () => ({ tasks: taskStore.getState().tasks }),
44
+ * })
45
+ *
46
+ * // Call on every domain store mutation:
47
+ * taskStore.subscribe(() => notify())
48
+ * ```
49
+ */
50
+ export function createDebouncedSync(store, options = {}) {
51
+ const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, } = options;
52
+ let timer = null;
53
+ function cancel() {
54
+ if (timer !== null) {
55
+ clearTimeout(timer);
56
+ timer = null;
57
+ }
58
+ }
59
+ function notify() {
60
+ cancel();
61
+ timer = setTimeout(() => {
62
+ timer = null;
63
+ const current = store.getState().data;
64
+ const doc = serialize ? serialize(current) : current;
65
+ if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded }))
66
+ return;
67
+ store.getState().set(() => doc);
68
+ }, delayMs);
69
+ }
70
+ return { notify, cancel };
71
+ }
72
+ /**
73
+ * Creates a debounced push helper that calls `syncManager.push()` directly,
74
+ * without requiring a Zustand store.
75
+ *
76
+ * Use this for one-way publishing workflows: public pages, derived snapshots,
77
+ * or any case where you want to push data without a full `createStarfishStore` setup.
78
+ *
79
+ * ```ts
80
+ * const syncManager = new SyncManager({ client, pullPath, pushPath })
81
+ *
82
+ * const { notify, cancel } = createDebouncedPush(syncManager, {
83
+ * serialize: () => buildPublicPageDocument(),
84
+ * })
85
+ *
86
+ * // Push after every relevant store mutation:
87
+ * planningStore.subscribe(() => notify())
88
+ *
89
+ * // Clean up on teardown:
90
+ * cancel()
91
+ * ```
92
+ */
93
+ export function createDebouncedPush(syncManager, options) {
94
+ const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, onError, } = options;
95
+ let timer = null;
96
+ function cancel() {
97
+ if (timer !== null) {
98
+ clearTimeout(timer);
99
+ timer = null;
100
+ }
101
+ }
102
+ function notify() {
103
+ cancel();
104
+ timer = setTimeout(() => {
105
+ timer = null;
106
+ const doc = serialize();
107
+ if (checkPayloadSize(doc, { warnBytes, maxBytes, onSizeWarning, onSizeExceeded }))
108
+ return;
109
+ syncManager.push(doc).catch((err) => {
110
+ if (onError) {
111
+ onError(err);
112
+ }
113
+ else {
114
+ console.warn("[starfish] Push failed:", err);
115
+ }
116
+ });
117
+ }, delayMs);
118
+ }
119
+ return { notify, cancel };
120
+ }
package/dist/dedup.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Request deduplication: prevents multiple concurrent identical GET requests.
3
+ * If a GET request is in-flight for a URL, subsequent identical GET requests
4
+ * return the same Promise. POST/PUT/DELETE/PATCH are never deduped.
5
+ */
6
+ export function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
7
+ const inflightGets = new Map();
8
+ return (async (input, init) => {
9
+ const method = (init?.method ?? "GET").toUpperCase();
10
+ // Only dedup GET requests
11
+ if (method !== "GET") {
12
+ return baseFetch(input, init);
13
+ }
14
+ const url = typeof input === "string"
15
+ ? input
16
+ : input instanceof URL
17
+ ? input.toString()
18
+ : input.url;
19
+ const existing = inflightGets.get(url);
20
+ if (existing) {
21
+ // Return a clone — the original is reserved for cloning only
22
+ return existing.then((res) => res.clone());
23
+ }
24
+ // Store a promise that resolves to a response we keep solely for cloning.
25
+ // The first caller also gets a clone, ensuring the "master" body is never consumed.
26
+ const promise = baseFetch(input, init)
27
+ .then((res) => res)
28
+ .finally(() => {
29
+ inflightGets.delete(url);
30
+ });
31
+ inflightGets.set(url, promise);
32
+ // First caller also gets a clone so the cached response body stays unconsumed
33
+ return promise.then((res) => res.clone());
34
+ });
35
+ }
package/dist/export.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Data export/import helpers for Starfish sync data.
3
+ * Supports JSON and CSV formats.
4
+ */
5
+ /**
6
+ * Export data to a string representation.
7
+ * JSON: serializes the full object.
8
+ * CSV: flattens top-level keys into columns. Array values are JSON-encoded.
9
+ */
10
+ export function exportData(data, opts) {
11
+ const format = opts?.format ?? "json";
12
+ if (format === "json") {
13
+ return opts?.pretty
14
+ ? JSON.stringify(data, null, 2)
15
+ : JSON.stringify(data);
16
+ }
17
+ // CSV export: each top-level key becomes a column
18
+ return toCsv(data);
19
+ }
20
+ /**
21
+ * Import data from a string representation.
22
+ */
23
+ export function importData(raw, format = "json") {
24
+ if (format === "json") {
25
+ const parsed = JSON.parse(raw);
26
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
27
+ throw new Error("Expected a JSON object");
28
+ }
29
+ return parsed;
30
+ }
31
+ return fromCsv(raw);
32
+ }
33
+ /**
34
+ * Export data to a Blob suitable for download.
35
+ */
36
+ export function exportToBlob(data, opts) {
37
+ const format = opts?.format ?? "json";
38
+ const content = exportData(data, opts);
39
+ const mimeType = format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8";
40
+ return new Blob([content], { type: mimeType });
41
+ }
42
+ function toCsv(data) {
43
+ const keys = Object.keys(data);
44
+ const header = keys.map(escapeCsvField).join(",");
45
+ const values = keys.map((k) => {
46
+ const v = data[k];
47
+ if (v === null || v === undefined)
48
+ return "";
49
+ if (typeof v === "object")
50
+ return escapeCsvField(JSON.stringify(v));
51
+ return escapeCsvField(String(v));
52
+ });
53
+ return `${header}\n${values.join(",")}`;
54
+ }
55
+ function fromCsv(raw) {
56
+ const lines = raw.trim().split("\n");
57
+ if (lines.length < 2) {
58
+ throw new Error("CSV must have at least a header row and a data row");
59
+ }
60
+ const headers = parseCsvLine(lines[0]);
61
+ const values = parseCsvLine(lines[1]);
62
+ const result = {};
63
+ for (let i = 0; i < headers.length; i++) {
64
+ const key = headers[i];
65
+ const val = values[i] ?? "";
66
+ // Try to parse JSON values
67
+ try {
68
+ result[key] = JSON.parse(val);
69
+ }
70
+ catch {
71
+ result[key] = val;
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function escapeCsvField(field) {
77
+ if (field.includes(",") || field.includes('"') || field.includes("\n")) {
78
+ return `"${field.replace(/"/g, '""')}"`;
79
+ }
80
+ return field;
81
+ }
82
+ function parseCsvLine(line) {
83
+ const result = [];
84
+ let current = "";
85
+ let inQuotes = false;
86
+ for (let i = 0; i < line.length; i++) {
87
+ const ch = line[i];
88
+ if (inQuotes) {
89
+ if (ch === '"' && line[i + 1] === '"') {
90
+ current += '"';
91
+ i++;
92
+ }
93
+ else if (ch === '"') {
94
+ inQuotes = false;
95
+ }
96
+ else {
97
+ current += ch;
98
+ }
99
+ }
100
+ else {
101
+ if (ch === '"') {
102
+ inQuotes = true;
103
+ }
104
+ else if (ch === ",") {
105
+ result.push(current);
106
+ current = "";
107
+ }
108
+ else {
109
+ current += ch;
110
+ }
111
+ }
112
+ }
113
+ result.push(current);
114
+ return result;
115
+ }