@cioky/ripple-query 0.1.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.
Files changed (3) hide show
  1. package/README.md +81 -0
  2. package/package.json +33 -0
  3. package/src/index.ts +254 -0
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @cioky/ripple-query
2
+
3
+ Tiny, framework-agnostic query cache for Ripple TS — `Tracked`-based, GC-collected, SSR-friendly.
4
+
5
+ ```
6
+ bun add @cioky/ripple-query
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { query, invalidateKeys } from '@cioky/ripple-query'
13
+
14
+ export function TaskList() @{
15
+ let &[tasks] = query(['todos', { done: true }], () =>
16
+ fetch('/api/todos?done=true').then(r => r.json())
17
+ )
18
+
19
+ @if (tasks === undefined) {
20
+ <p>Loading...</p>
21
+ } @else {
22
+ <ul>
23
+ @for (const t of tasks) {
24
+ <li>{t.title}</li>
25
+ }
26
+ </ul>
27
+ }
28
+ }
29
+
30
+ // After a mutation:
31
+ invalidateKeys(['todos']) // refetches all matching queries
32
+ ```
33
+
34
+ ## API
35
+
36
+ ### `query(key, fetcher, options?)`
37
+
38
+ Returns `[data, info]` where both are `Tracked` signals.
39
+
40
+ | Param | Type | Default | Description |
41
+ |-------|------|---------|-------------|
42
+ | `key` | `QueryKey` | — | Serializable tuple identifying the cache entry |
43
+ | `fetcher` | `() => Promise<T>` | — | Async function that fetches fresh data |
44
+ | `options.staleTime` | `number` | `0` | ms before data is considered stale (triggers background refetch) |
45
+ | `options.gcTime` | `number` | `300000` | ms before unused cache entry is evicted (default 5 min) |
46
+
47
+ ### `invalidateKeys(prefix)`
48
+
49
+ Bump version on all entries whose serialized key starts with `prefix`. Triggers automatic refetch on active subscribers.
50
+
51
+ ```ts
52
+ invalidateKeys(['todos']) // invalidates ['todos'], ['todos', { done: true }]
53
+ invalidateKeys(['Task']) // invalidates all Task queries
54
+ ```
55
+
56
+ ### `invalidateAll()`
57
+
58
+ Invalidate every cached entry.
59
+
60
+ ### `unsubscribe(key)`
61
+
62
+ Decrement subscriber count. When count reaches zero, start GC timer. Call in block cleanup.
63
+
64
+ ### SSR
65
+
66
+ ```ts
67
+ // Server: embed in HTML
68
+ import { serializeCache } from '@cioky/ripple-query'
69
+
70
+ function onRenderHtml(pageContext) {
71
+ return { documentHtml: `...${serializeCache()}...` }
72
+ }
73
+
74
+ // Client: hydrate before first render
75
+ import { hydrateCache } from '@cioky/ripple-query'
76
+ hydrateCache()
77
+ ```
78
+
79
+ ## Peer Dependencies
80
+
81
+ - `ripple` >= 0.3.0
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@cioky/ripple-query",
3
+ "version": "0.1.0",
4
+ "description": "Tiny, reactive query cache for Ripple TS — Tracked-based, GC-collected, SSR-friendly",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/Opaius/vike-ripple.git"
18
+ },
19
+ "homepage": "https://github.com/Opaius/vike-ripple",
20
+ "bugs": {
21
+ "url": "https://github.com/Opaius/vike-ripple/issues"
22
+ },
23
+ "keywords": [
24
+ "ripple",
25
+ "query",
26
+ "cache",
27
+ "reactive"
28
+ ],
29
+ "license": "MIT",
30
+ "peerDependencies": {
31
+ "ripple": ">=0.3.0"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { track, effect } from 'ripple';
2
+ import type { Tracked } from 'ripple';
3
+
4
+ // ── Public Types ──────────────────────────────────────────
5
+
6
+ /** Serializable tuple identifying a cache entry. */
7
+ export type QueryKey = (
8
+ | string
9
+ | number
10
+ | boolean
11
+ | Record<string, unknown>
12
+ | null
13
+ | undefined
14
+ )[];
15
+
16
+ /** Reactive status companion returned by `query()`. */
17
+ export interface QueryInfo {
18
+ status: Tracked<'pending' | 'success' | 'error'>;
19
+ error: Tracked<Error | undefined>;
20
+ }
21
+
22
+ export interface QueryOptions {
23
+ /** Time in ms before data is considered stale (default: 0 — always fresh). */
24
+ staleTime?: number;
25
+ /** Time in ms before unused cache entry is garbage-collected (default: 5 min). */
26
+ gcTime?: number;
27
+ }
28
+
29
+ // ── Internal Types ────────────────────────────────────────
30
+
31
+ /** @internal Cache entry for a single query key. */
32
+ export interface QueryEntry<T = unknown> {
33
+ version: Tracked<number>;
34
+ data: Tracked<T | undefined>;
35
+ status: Tracked<'pending' | 'success' | 'error'>;
36
+ error: Tracked<Error | undefined>;
37
+ subscribers: number;
38
+ gcTimer: ReturnType<typeof setTimeout> | null;
39
+ lastFetch: number;
40
+ staleTime: number;
41
+ gcTime: number;
42
+ fetcher: (() => Promise<T>) | null;
43
+ }
44
+
45
+ const cache = new Map<string, QueryEntry>();
46
+
47
+ // ── Key Serialization ─────────────────────────────────────
48
+
49
+ function serializeKey(key: QueryKey): string {
50
+ return JSON.stringify(key, (_, v) => {
51
+ if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
52
+ return Object.keys(v)
53
+ .sort()
54
+ .reduce<Record<string, unknown>>((acc, k) => {
55
+ acc[k] = (v as Record<string, unknown>)[k];
56
+ return acc;
57
+ }, {});
58
+ }
59
+ return v;
60
+ });
61
+ }
62
+
63
+ // ── Fetch ─────────────────────────────────────────────────
64
+
65
+ async function fetchEntry<T>(
66
+ entry: QueryEntry<T>,
67
+ fetcher: () => Promise<T>,
68
+ ): Promise<void> {
69
+ entry.status.value = 'pending';
70
+ try {
71
+ const result = await fetcher();
72
+ entry.data.value = result;
73
+ entry.status.value = 'success';
74
+ entry.error.value = undefined;
75
+ entry.lastFetch = Date.now();
76
+ } catch (e: unknown) {
77
+ entry.error.value = e instanceof Error ? e : new Error(String(e));
78
+ entry.status.value = 'error';
79
+ }
80
+ }
81
+
82
+ // ── Public API ────────────────────────────────────────────
83
+
84
+ /**
85
+ * Create or retrieve a cached query signal.
86
+ *
87
+ * ```ts
88
+ * const [data, info] = query(['todos', { done: true }], () => fetchTodos())
89
+ * ```
90
+ *
91
+ * The returned `data` signal auto-refetches when the entry is invalidated
92
+ * via `invalidateKeys()` or `invalidateAll()`.
93
+ */
94
+ export function query<T>(
95
+ key: QueryKey,
96
+ fetcher: () => Promise<T>,
97
+ options: QueryOptions = {},
98
+ ): [Tracked<T | undefined>, QueryInfo] {
99
+ const k = serializeKey(key);
100
+ let entry = cache.get(k) as QueryEntry<T> | undefined;
101
+
102
+ if (!entry) {
103
+ entry = {
104
+ version: track(0),
105
+ data: track<T | undefined>(undefined),
106
+ status: track<'pending' | 'success' | 'error'>('pending'),
107
+ error: track<Error | undefined>(undefined),
108
+ subscribers: 0,
109
+ gcTimer: null,
110
+ lastFetch: 0,
111
+ staleTime: options.staleTime ?? 0,
112
+ gcTime: options.gcTime ?? 5 * 60 * 1000,
113
+ fetcher,
114
+ };
115
+ cache.set(k, entry);
116
+
117
+ // First creation: start fetch + wire up reactive refetch
118
+ queueMicrotask(() => fetchEntry(entry, fetcher));
119
+
120
+ // Use Ripple effect to re-fetch when version bumps (invalidation)
121
+ effect(() => {
122
+ entry!.version.value; // subscribe to version changes
123
+ fetchEntry(entry!, entry!.fetcher ?? fetcher);
124
+ });
125
+ } else {
126
+ entry.fetcher ??= fetcher;
127
+ }
128
+
129
+ // Subscribe — cancel any pending GC
130
+ entry.subscribers++;
131
+ clearTimeout(entry.gcTimer);
132
+ entry.gcTimer = null;
133
+
134
+ // Stale check — bump version to trigger refetch via effect
135
+ if (
136
+ entry.lastFetch > 0 &&
137
+ Date.now() - entry.lastFetch > entry.staleTime
138
+ ) {
139
+ entry.version.value += 1;
140
+ }
141
+
142
+ return [entry.data, { status: entry.status, error: entry.error }];
143
+ }
144
+
145
+ /**
146
+ * Decrement subscriber count. When count reaches zero, start the GC timer.
147
+ * Call this in a Ripple block's cleanup.
148
+ */
149
+ export function unsubscribe(key: QueryKey): void {
150
+ const k = serializeKey(key);
151
+ const entry = cache.get(k);
152
+ if (!entry) return;
153
+
154
+ entry.subscribers--;
155
+ if (entry.subscribers <= 0) {
156
+ entry.subscribers = 0;
157
+ clearTimeout(entry.gcTimer);
158
+ entry.gcTimer = setTimeout(() => {
159
+ cache.delete(k);
160
+ }, entry.gcTime);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Invalidate all cache entries whose serialized key starts with the
166
+ * given prefix.
167
+ *
168
+ * Bumps each matching entry's `version` signal — the `effect()` watcher
169
+ * picks it up and re-fetches automatically.
170
+ *
171
+ * ```ts
172
+ * invalidateKeys(['todos']) // invalidates ['todos'], ['todos', { done: true }]
173
+ * invalidateKeys(['Task', 'find']) // invalidates all Task finds
174
+ * ```
175
+ */
176
+ export function invalidateKeys(prefix: QueryKey): void {
177
+ const p = serializeKey(prefix);
178
+ for (const [k, entry] of cache) {
179
+ if (k.startsWith(p)) {
180
+ entry.version.value += 1;
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Invalidate every cached entry. */
186
+ export function invalidateAll(): void {
187
+ for (const entry of cache.values()) {
188
+ entry.version.value += 1;
189
+ }
190
+ }
191
+
192
+ /** Low-level access to the cache map (debugging / SSR serialization). */
193
+ export function getQueryCache(): Map<string, QueryEntry> {
194
+ return cache;
195
+ }
196
+
197
+ // ── SSR: Serialize → Hydrate ──────────────────────────────
198
+
199
+ const SSR_ID = '__rq_cache';
200
+
201
+ /**
202
+ * Serialize cache entries into a `<script>` tag for SSR.
203
+ * Embed this in your HTML head (e.g. in `onRenderHtml`).
204
+ */
205
+ export function serializeCache(): string {
206
+ const entries: Array<{ key: string; data: unknown }> = [];
207
+ for (const [key, entry] of cache) {
208
+ if (entry.status.value === 'success' && entry.data.value !== undefined) {
209
+ entries.push({ key, data: entry.data.value });
210
+ }
211
+ }
212
+ return `<script id="${SSR_ID}" type="application/json">${JSON.stringify(
213
+ entries,
214
+ )}</script>`;
215
+ }
216
+
217
+ /**
218
+ * Hydrate cache from serialized SSR data.
219
+ * Call once on the client before the first render.
220
+ */
221
+ export function hydrateCache(): void {
222
+ const el = document.getElementById(SSR_ID);
223
+ if (!el) return;
224
+ try {
225
+ const entries: Array<{ key: string; data: unknown }> = JSON.parse(
226
+ el.textContent ?? '[]',
227
+ );
228
+ for (const { key, data } of entries) {
229
+ const existing = cache.get(key);
230
+ if (existing) {
231
+ existing.data.value = data as never;
232
+ existing.status.value = 'success';
233
+ existing.lastFetch = Date.now();
234
+ } else {
235
+ const entry: QueryEntry = {
236
+ version: track(0),
237
+ data: track(data),
238
+ status: track<'pending' | 'success' | 'error'>('success'),
239
+ error: track<Error | undefined>(undefined),
240
+ subscribers: 0,
241
+ gcTimer: null,
242
+ lastFetch: Date.now(),
243
+ staleTime: 0,
244
+ gcTime: 5 * 60 * 1000,
245
+ fetcher: null,
246
+ };
247
+ cache.set(key, entry);
248
+ }
249
+ }
250
+ el.remove();
251
+ } catch {
252
+ // Malformed cache — skip silently
253
+ }
254
+ }