@ozsarman/clarityjs 0.6.0

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,555 @@
1
+ /**
2
+ * Clarity.js — Async State Management
3
+ *
4
+ * Reactive data fetching with cache, stale-while-revalidate, optimistic updates,
5
+ * and automatic deduplication. Inspired by TanStack Query / SWR but built
6
+ * natively on Clarity signals.
7
+ *
8
+ * Quick reference:
9
+ *
10
+ * const users = createQuery(() => fetch('/api/users').then(r => r.json()))
11
+ *
12
+ * // In a component render:
13
+ * users.data.get() // current data (or undefined while loading)
14
+ * users.loading.get() // true while the first fetch is in-flight
15
+ * users.error.get() // Error | null
16
+ * users.stale.get() // true when showing cached data, refetch in progress
17
+ *
18
+ * users.refetch() // manual refetch
19
+ * users.mutate(newData, { revalidate: true }) // optimistic update + sync
20
+ * users.invalidate() // mark cache stale and trigger background refetch
21
+ *
22
+ * Author: Claude (Anthropic) + Özdemir Sarman
23
+ */
24
+
25
+ import { signal, effect, batch } from './runtime.js';
26
+
27
+ // ─── Global query cache ───────────────────────────────────────────────────────
28
+ // Key → { data, error, timestamp, subscribers, inFlight }
29
+
30
+ const _cache = new Map();
31
+ const _DEFAULT_STALE_TIME = 30_000; // 30 s
32
+ const _DEFAULT_CACHE_TIME = 5 * 60_000; // 5 min
33
+ const _DEFAULT_RETRY = 3;
34
+ const _DEFAULT_RETRY_DELAY = (attempt) => Math.min(1000 * 2 ** attempt, 30_000);
35
+
36
+ // ─── QueryState ───────────────────────────────────────────────────────────────
37
+
38
+ class QueryState {
39
+ constructor(key, fetchFn, options) {
40
+ this._key = key;
41
+ this._fetchFn = fetchFn;
42
+ this._opts = options;
43
+
44
+ // Public signals (readable from components)
45
+ this.data = signal(undefined);
46
+ this.error = signal(null);
47
+ this.loading = signal(false);
48
+ this.stale = signal(false);
49
+
50
+ // Internal
51
+ this._inFlight = false;
52
+ this._retryCount = 0;
53
+ this._lastFetched = 0;
54
+ this._gcTimer = null;
55
+ }
56
+
57
+ // ── Core fetch logic ────────────────────────────────────────────────────────
58
+
59
+ async fetch({ force = false } = {}) {
60
+ const opts = this._opts;
61
+
62
+ // Deduplicate: if already in-flight, skip
63
+ if (this._inFlight && !force) return;
64
+
65
+ // Stale-while-revalidate: use cached data if fresh
66
+ const now = Date.now();
67
+ const staleMs = opts.staleTime ?? _DEFAULT_STALE_TIME;
68
+ const isFresh = this._lastFetched > 0 && (now - this._lastFetched) < staleMs;
69
+
70
+ if (isFresh && !force) return; // still fresh, no need to refetch
71
+
72
+ // If we have data but it's stale, mark as stale (SWR)
73
+ if (this.data.peek() !== undefined) {
74
+ this.stale.set(true);
75
+ } else {
76
+ this.loading.set(true);
77
+ }
78
+
79
+ this._inFlight = true;
80
+ this._retryCount = 0;
81
+
82
+ await this._fetchWithRetry();
83
+ }
84
+
85
+ async _fetchWithRetry() {
86
+ const opts = this._opts;
87
+ const maxRetry = opts.retry ?? _DEFAULT_RETRY;
88
+ const delay = opts.retryDelay ?? _DEFAULT_RETRY_DELAY;
89
+
90
+ while (this._retryCount <= maxRetry) {
91
+ try {
92
+ const result = await this._fetchFn(this._key);
93
+
94
+ // Success — update signals atomically
95
+ batch(() => {
96
+ this.data.set(result);
97
+ this.error.set(null);
98
+ this.loading.set(false);
99
+ this.stale.set(false);
100
+ });
101
+
102
+ this._lastFetched = Date.now();
103
+ this._inFlight = false;
104
+ this._retryCount = 0;
105
+
106
+ // Notify cache
107
+ _cache.set(this._key, {
108
+ data: result,
109
+ timestamp: this._lastFetched,
110
+ });
111
+
112
+ // Schedule garbage collection
113
+ this._scheduleGC();
114
+ return;
115
+
116
+ } catch (err) {
117
+ this._retryCount++;
118
+
119
+ if (this._retryCount > maxRetry) {
120
+ // All retries exhausted
121
+ batch(() => {
122
+ this.error.set(err);
123
+ this.loading.set(false);
124
+ this.stale.set(false);
125
+ });
126
+ this._inFlight = false;
127
+
128
+ opts.onError?.(err, this._key);
129
+ return;
130
+ }
131
+
132
+ // Wait before retry
133
+ const waitMs = typeof delay === 'function'
134
+ ? delay(this._retryCount)
135
+ : delay;
136
+
137
+ await _sleep(waitMs);
138
+ }
139
+ }
140
+ }
141
+
142
+ // ── Public API ──────────────────────────────────────────────────────────────
143
+
144
+ /** Force a refetch regardless of cache freshness. */
145
+ refetch() {
146
+ return this.fetch({ force: true });
147
+ }
148
+
149
+ /**
150
+ * Optimistic update — set data immediately, optionally revalidate.
151
+ *
152
+ * @param {* | ((current: *) => *)} updater - New value or updater function
153
+ * @param {{ revalidate?: boolean }} options
154
+ */
155
+ mutate(updater, { revalidate = false } = {}) {
156
+ const current = this.data.peek();
157
+ const next = typeof updater === 'function' ? updater(current) : updater;
158
+ this.data.set(next);
159
+
160
+ _cache.set(this._key, { data: next, timestamp: Date.now() });
161
+
162
+ if (revalidate) {
163
+ // Sync in background
164
+ this.fetch({ force: true }).catch(err => {
165
+ console.error('[Clarity createQuery] revalidate after mutate failed:', err);
166
+ });
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Mark as stale — triggers a background refetch on next read or immediately.
172
+ */
173
+ invalidate() {
174
+ this._lastFetched = 0;
175
+ if (this._opts.invalidateImmediately !== false) {
176
+ this.fetch({ force: false });
177
+ }
178
+ }
179
+
180
+ /** Cancel any pending in-flight request (best-effort via AbortController). */
181
+ cancel() {
182
+ this._inFlight = false;
183
+ }
184
+
185
+ /** Clean up timers and listeners. */
186
+ dispose() {
187
+ if (this._gcTimer) clearTimeout(this._gcTimer);
188
+ }
189
+
190
+ // ── Internals ───────────────────────────────────────────────────────────────
191
+
192
+ _scheduleGC() {
193
+ const cacheTime = this._opts.cacheTime ?? _DEFAULT_CACHE_TIME;
194
+ if (this._gcTimer) clearTimeout(this._gcTimer);
195
+ this._gcTimer = setTimeout(() => {
196
+ _cache.delete(this._key);
197
+ }, cacheTime);
198
+ }
199
+ }
200
+
201
+ // ─── createQuery ─────────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Create a reactive, cached, auto-fetching data source.
205
+ *
206
+ * @param {string | (() => string)} key - Cache key (string or reactive function)
207
+ * @param {(key: string) => Promise<*>} fetchFn - Async data fetcher
208
+ * @param {CreateQueryOptions} [options]
209
+ * @returns {QueryState}
210
+ *
211
+ * @typedef {object} CreateQueryOptions
212
+ * @property {boolean} [enabled=true] - Set false to disable auto-fetch
213
+ * @property {number} [staleTime=30000] - Ms until cached data is stale
214
+ * @property {number} [cacheTime=300000] - Ms to keep data in cache
215
+ * @property {number} [retry=3] - Retry count on failure
216
+ * @property {number|Function} [retryDelay] - Ms or fn(attempt)→ms
217
+ * @property {boolean} [refetchOnWindowFocus=true] - Refetch on tab focus
218
+ * @property {number|false} [refetchInterval=false] - Polling interval (ms)
219
+ * @property {Function} [onSuccess] - (data, key) → void
220
+ * @property {Function} [onError] - (err, key) → void
221
+ * @property {*} [initialData] - Pre-seed data (skips first fetch)
222
+ * @property {boolean} [invalidateImmediately=true] - Fetch on invalidate()
223
+ *
224
+ * @example
225
+ * const todos = createQuery(
226
+ * 'todos',
227
+ * () => fetch('/api/todos').then(r => r.json()),
228
+ * { staleTime: 60_000 }
229
+ * );
230
+ *
231
+ * // Dynamic key (re-fetches when userId changes)
232
+ * const post = createQuery(
233
+ * () => `post/${userId.get()}`,
234
+ * (key) => fetch(`/api/${key}`).then(r => r.json())
235
+ * );
236
+ */
237
+ export function createQuery(key, fetchFn, options = {}) {
238
+ // Support key-less API: createQuery(fetchFn, options)
239
+ if (typeof key === 'function' && typeof fetchFn !== 'function') {
240
+ options = fetchFn ?? {};
241
+ fetchFn = key;
242
+ key = null; // computed key
243
+ }
244
+
245
+ const isReactiveKey = typeof key === 'function';
246
+ const resolvedKey = isReactiveKey ? key() : (key ?? crypto.randomUUID?.() ?? Math.random().toString(36));
247
+
248
+ const state = new QueryState(resolvedKey, fetchFn, options);
249
+
250
+ // Seed initial data if provided
251
+ if (options.initialData !== undefined) {
252
+ state.data.set(options.initialData);
253
+ state._lastFetched = Date.now();
254
+ } else {
255
+ // Restore from cache if available
256
+ const cached = _cache.get(resolvedKey);
257
+ if (cached) {
258
+ state.data.set(cached.data);
259
+ state._lastFetched = cached.timestamp;
260
+ }
261
+ }
262
+
263
+ // Auto-fetch if enabled
264
+ const enabled = options.enabled !== false;
265
+ if (enabled) {
266
+ // Defer to microtask so the component can finish rendering first
267
+ Promise.resolve().then(() => state.fetch());
268
+ }
269
+
270
+ // Re-fetch when reactive key changes
271
+ if (isReactiveKey) {
272
+ effect(() => {
273
+ const newKey = key();
274
+ if (newKey !== state._key) {
275
+ state._key = newKey;
276
+ state._lastFetched = 0;
277
+ state.fetch({ force: false });
278
+ }
279
+ });
280
+ }
281
+
282
+ // Polling support
283
+ if (options.refetchInterval && options.refetchInterval > 0) {
284
+ const intervalId = setInterval(() => {
285
+ state.fetch({ force: false });
286
+ }, options.refetchInterval);
287
+
288
+ // Return dispose function attached to state
289
+ const origDispose = state.dispose.bind(state);
290
+ state.dispose = () => {
291
+ clearInterval(intervalId);
292
+ origDispose();
293
+ };
294
+ }
295
+
296
+ // Window focus refetch
297
+ if (options.refetchOnWindowFocus !== false &&
298
+ typeof window !== 'undefined' &&
299
+ typeof window.addEventListener === 'function') {
300
+ const onFocus = () => state.fetch({ force: false });
301
+ window.addEventListener('focus', onFocus);
302
+ const origDispose = state.dispose.bind(state);
303
+ state.dispose = () => {
304
+ window.removeEventListener('focus', onFocus);
305
+ origDispose();
306
+ };
307
+ }
308
+
309
+ return state;
310
+ }
311
+
312
+ // ─── useFetch ─────────────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * useFetch — Nuxt-style alias over createQuery().
316
+ *
317
+ * A familiar entry point for developers coming from Nuxt. It is a thin wrapper
318
+ * around createQuery() with one ergonomic addition: if the first argument is a
319
+ * string or URL, it is treated as a URL to fetch (JSON), so the common case
320
+ * needs no fetcher function at all.
321
+ *
322
+ * @example — URL shorthand (auto fetch + JSON parse)
323
+ * const posts = useFetch('/api/posts');
324
+ * // posts.data() · posts.loading() · posts.error() · posts.refetch()
325
+ *
326
+ * @example — Reactive URL (re-fetches when the signal changes)
327
+ * const post = useFetch(() => `/api/posts/${id.get()}`);
328
+ *
329
+ * @example — Custom fetcher / options (identical to createQuery)
330
+ * const users = useFetch('users', () => api.getUsers(), { staleTime: 60_000 });
331
+ *
332
+ * @param {string | URL | Function} keyOrUrl — URL string, reactive URL fn, or query key
333
+ * @param {Function | object} [fetchFn] — fetcher (defaults to fetch+JSON for URLs)
334
+ * @param {object} [options] — same options as createQuery()
335
+ * @returns {QueryState}
336
+ */
337
+ export function useFetch(keyOrUrl, fetchFn, options = {}) {
338
+ // URL shorthand: useFetch('/api/x'[, options]) or useFetch(() => url[, options])
339
+ const looksLikeUrl =
340
+ typeof keyOrUrl === 'string' ||
341
+ keyOrUrl instanceof URL ||
342
+ (typeof keyOrUrl === 'function' && typeof fetchFn !== 'function');
343
+
344
+ if (looksLikeUrl && typeof fetchFn !== 'function') {
345
+ // Second arg (if any) is the options object.
346
+ options = fetchFn ?? {};
347
+ const urlOf = typeof keyOrUrl === 'function' ? keyOrUrl : () => String(keyOrUrl);
348
+ const key = typeof keyOrUrl === 'function' ? keyOrUrl : String(keyOrUrl);
349
+ const fetcher = async () => {
350
+ const res = await fetch(urlOf());
351
+ if (!res.ok) throw new Error(`[Clarity useFetch] HTTP ${res.status} for ${urlOf()}`);
352
+ const ct = res.headers?.get?.('content-type') ?? '';
353
+ return ct.includes('application/json') ? res.json() : res.text();
354
+ };
355
+ return createQuery(key, fetcher, options);
356
+ }
357
+
358
+ // Full pass-through to createQuery (key, fetchFn, options).
359
+ return createQuery(keyOrUrl, fetchFn, options);
360
+ }
361
+
362
+ // ─── createMutation ───────────────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Create a reactive mutation — for POST, PUT, DELETE operations.
366
+ * Supports TanStack Query-style optimistic updates with automatic rollback.
367
+ *
368
+ * @param {(variables: *) => Promise<*>} mutateFn — async operation
369
+ * @param {object} [options]
370
+ * @param {Function} [options.onMutate] — (variables) → Promise<context> | context
371
+ * Called before mutation fires.
372
+ * Return a context object for rollback.
373
+ * @param {Function} [options.optimistic] — (variables, currentData) → newData
374
+ * Instantly update data before server responds.
375
+ * Rolled back automatically on error.
376
+ * @param {Function} [options.onSuccess] — (data, variables, context) → void
377
+ * @param {Function} [options.onError] — (err, variables, context) → void
378
+ * Called with the rollback context.
379
+ * @param {Function} [options.onSettled] — (data|null, err|null, variables, context) → void
380
+ * @param {string | string[]} [options.invalidates] — query keys to invalidate on success
381
+ * @returns {{ mutate, mutateAsync, loading, error, data, reset, variables }}
382
+ *
383
+ * @example — Basic
384
+ * const addTodo = createMutation(
385
+ * (title) => fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }).then(r => r.json()),
386
+ * { onSuccess: () => todos.invalidate() }
387
+ * );
388
+ * addTodo.mutate('Buy milk');
389
+ *
390
+ * @example — Optimistic update
391
+ * const toggleTodo = createMutation(
392
+ * ({ id, done }) => fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ done }) }).then(r => r.json()),
393
+ * {
394
+ * optimistic: ({ id, done }, currentTodos) =>
395
+ * currentTodos.map(t => t.id === id ? { ...t, done } : t),
396
+ * onError: (err, vars, ctx) => {
397
+ * todos.mutate(ctx.previousData); // manual rollback if needed
398
+ * },
399
+ * }
400
+ * );
401
+ *
402
+ * @example — onMutate context
403
+ * const deleteTodo = createMutation(deleteApi, {
404
+ * onMutate: async (id) => {
405
+ * const prev = todos.data.peek();
406
+ * todos.mutate(prev.filter(t => t.id !== id)); // optimistic remove
407
+ * return { previousTodos: prev }; // rollback context
408
+ * },
409
+ * onError: (err, id, ctx) => {
410
+ * todos.mutate(ctx.previousTodos); // undo on failure
411
+ * },
412
+ * });
413
+ */
414
+ export function createMutation(mutateFn, options = {}) {
415
+ const loading = signal(false);
416
+ const error = signal(null);
417
+ const data = signal(undefined);
418
+ const variables = signal(undefined); // last invocation variables
419
+
420
+ // Track optimistic snapshot for rollback
421
+ let _prevData = undefined;
422
+
423
+ /**
424
+ * Async version — returns Promise, throws on failure.
425
+ */
426
+ async function mutateAsync(vars) {
427
+ variables.set(vars);
428
+
429
+ batch(() => {
430
+ loading.set(true);
431
+ error.set(null);
432
+ });
433
+
434
+ // ── onMutate (may return rollback context) ─────────────────────────────
435
+ let context;
436
+ try {
437
+ if (typeof options.onMutate === 'function') {
438
+ context = await options.onMutate(vars);
439
+ }
440
+ } catch (mutateErr) {
441
+ // onMutate failure aborts the mutation
442
+ batch(() => { error.set(mutateErr); loading.set(false); });
443
+ throw mutateErr;
444
+ }
445
+
446
+ // ── Optimistic update ──────────────────────────────────────────────────
447
+ if (typeof options.optimistic === 'function') {
448
+ _prevData = data.peek();
449
+ data.set(options.optimistic(vars, _prevData));
450
+ }
451
+
452
+ // ── Execute mutation ───────────────────────────────────────────────────
453
+ try {
454
+ const result = await mutateFn(vars);
455
+
456
+ batch(() => {
457
+ data.set(result);
458
+ loading.set(false);
459
+ });
460
+ _prevData = undefined;
461
+
462
+ // Callbacks
463
+ options.onSuccess?.(result, vars, context);
464
+ options.onSettled?.(result, null, vars, context);
465
+
466
+ // Invalidate related queries
467
+ if (options.invalidates) {
468
+ const keys = Array.isArray(options.invalidates) ? options.invalidates : [options.invalidates];
469
+ for (const k of keys) invalidateQuery(k);
470
+ }
471
+
472
+ return result;
473
+
474
+ } catch (err) {
475
+ // ── Rollback optimistic update ───────────────────────────────────────
476
+ if (typeof options.optimistic === 'function' && _prevData !== undefined) {
477
+ data.set(_prevData);
478
+ _prevData = undefined;
479
+ }
480
+
481
+ batch(() => {
482
+ error.set(err);
483
+ loading.set(false);
484
+ });
485
+
486
+ options.onError?.(err, vars, context);
487
+ options.onSettled?.(null, err, vars, context);
488
+
489
+ throw err;
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Trigger the mutation. Returns the underlying promise so callers may
495
+ * `await` it to read the resolved value; it rejects on failure (the error is
496
+ * ALSO captured in the `error` signal, so awaiting is optional for UI code
497
+ * that just renders from the signals).
498
+ */
499
+ function mutate(vars) {
500
+ return mutateAsync(vars);
501
+ }
502
+
503
+ function reset() {
504
+ _prevData = undefined;
505
+ batch(() => {
506
+ loading.set(false);
507
+ error.set(null);
508
+ data.set(undefined);
509
+ variables.set(undefined);
510
+ });
511
+ }
512
+
513
+ return { mutate, mutateAsync, loading, error, data, variables, reset };
514
+ }
515
+
516
+ // ─── Cache utilities ──────────────────────────────────────────────────────────
517
+
518
+ /**
519
+ * Manually invalidate a cached query by key.
520
+ * @param {string} key
521
+ */
522
+ export function invalidateQuery(key) {
523
+ _cache.delete(key);
524
+ }
525
+
526
+ /**
527
+ * Pre-populate the cache with data (useful for SSR → client handoff).
528
+ * @param {string} key
529
+ * @param {*} data
530
+ */
531
+ export function prefetchQuery(key, data) {
532
+ _cache.set(key, { data, timestamp: Date.now() });
533
+ }
534
+
535
+ /**
536
+ * Clear the entire query cache.
537
+ */
538
+ export function clearQueryCache() {
539
+ _cache.clear();
540
+ }
541
+
542
+ /**
543
+ * Read raw cache entry (useful for testing or SSR state transfer).
544
+ * @param {string} key
545
+ * @returns {{ data: *, timestamp: number } | undefined}
546
+ */
547
+ export function getCacheEntry(key) {
548
+ return _cache.get(key);
549
+ }
550
+
551
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
552
+
553
+ function _sleep(ms) {
554
+ return new Promise(resolve => setTimeout(resolve, ms));
555
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Bundles clarity-runtime.js into a browser-ready IIFE
3
+ * Output: dist/clarity-runtime.browser.js
4
+ * Usage: <script src="clarity-runtime.browser.js"></script>
5
+ * window.ClarityRuntime.signal(0) etc.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+
10
+ const runtimeSrc = readFileSync('./src/runtime.js', 'utf8');
11
+
12
+ // Strip ES module exports — convert to IIFE
13
+ const iife = `
14
+ // Clarity.js Runtime v0.0.1 — Browser Build
15
+ // AI-Native UI Framework by Claude (Anthropic)
16
+ (function(global) {
17
+ 'use strict';
18
+
19
+ ${runtimeSrc
20
+ .replace(/^export function /gm, 'function ')
21
+ .replace(/^export const /gm, 'const ')
22
+ .replace(/^export \{[^}]+\};?/gm, '')
23
+ }
24
+
25
+ // Expose to global scope
26
+ global.ClarityRuntime = {
27
+ signal, effect, computed, batch, mount, h, appendChild, when, list, __dev__
28
+ };
29
+
30
+ })(typeof window !== 'undefined' ? window : globalThis);
31
+ `.trim();
32
+
33
+ mkdirSync('./dist', { recursive: true });
34
+ writeFileSync('./dist/clarity-runtime.browser.js', iife, 'utf8');
35
+ console.log('✓ dist/clarity-runtime.browser.js written');