@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.
- package/README.md +81 -0
- package/package.json +33 -0
- 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
|
+
}
|