dalila 1.4.2 → 1.4.4
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/context/auto-scope.d.ts +167 -0
- package/dist/context/auto-scope.js +381 -0
- package/dist/context/context.d.ts +111 -0
- package/dist/context/context.js +283 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/raw.d.ts +2 -0
- package/dist/context/raw.js +2 -0
- package/dist/core/dev.d.ts +7 -0
- package/dist/core/dev.js +14 -0
- package/dist/core/for.d.ts +42 -0
- package/dist/core/for.js +311 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +14 -0
- package/dist/core/key.d.ts +33 -0
- package/dist/core/key.js +83 -0
- package/dist/core/match.d.ts +22 -0
- package/dist/core/match.js +175 -0
- package/dist/core/mutation.d.ts +55 -0
- package/dist/core/mutation.js +128 -0
- package/dist/core/persist.d.ts +63 -0
- package/dist/core/persist.js +371 -0
- package/dist/core/query.d.ts +72 -0
- package/dist/core/query.js +184 -0
- package/dist/core/resource.d.ts +299 -0
- package/dist/core/resource.js +924 -0
- package/dist/core/scheduler.d.ts +111 -0
- package/dist/core/scheduler.js +243 -0
- package/dist/core/scope.d.ts +74 -0
- package/dist/core/scope.js +171 -0
- package/dist/core/signal.d.ts +88 -0
- package/dist/core/signal.js +451 -0
- package/dist/core/store.d.ts +130 -0
- package/dist/core/store.js +234 -0
- package/dist/core/virtual.d.ts +26 -0
- package/dist/core/virtual.js +277 -0
- package/dist/core/watch-testing.d.ts +13 -0
- package/dist/core/watch-testing.js +16 -0
- package/dist/core/watch.d.ts +81 -0
- package/dist/core/watch.js +353 -0
- package/dist/core/when.d.ts +23 -0
- package/dist/core/when.js +124 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/internal/watch-testing.d.ts +1 -0
- package/dist/internal/watch-testing.js +8 -0
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.js +1 -0
- package/dist/router/route.d.ts +23 -0
- package/dist/router/route.js +48 -0
- package/dist/router/router.d.ts +23 -0
- package/dist/router/router.js +169 -0
- package/dist/runtime/bind.d.ts +65 -0
- package/dist/runtime/bind.js +616 -0
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +9 -0
- package/dist/simple.d.ts +11 -0
- package/dist/simple.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { effect } from './signal.js';
|
|
2
|
+
import { getCurrentScope } from './scope.js';
|
|
3
|
+
const defaultSerializer = {
|
|
4
|
+
serialize: JSON.stringify,
|
|
5
|
+
deserialize: JSON.parse,
|
|
6
|
+
};
|
|
7
|
+
function isPromiseLike(v) {
|
|
8
|
+
return !!v && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function';
|
|
9
|
+
}
|
|
10
|
+
function safeDefaultStorage() {
|
|
11
|
+
if (typeof window === 'undefined')
|
|
12
|
+
return undefined;
|
|
13
|
+
try {
|
|
14
|
+
return window.localStorage;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function toError(error) {
|
|
21
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
22
|
+
}
|
|
23
|
+
function parseVersion(v) {
|
|
24
|
+
if (typeof v !== 'string')
|
|
25
|
+
return 0;
|
|
26
|
+
const n = parseInt(v, 10);
|
|
27
|
+
return Number.isFinite(n) ? n : 0;
|
|
28
|
+
}
|
|
29
|
+
function isPlainObject(v) {
|
|
30
|
+
if (!v || typeof v !== 'object')
|
|
31
|
+
return false;
|
|
32
|
+
if (Array.isArray(v))
|
|
33
|
+
return false;
|
|
34
|
+
return Object.getPrototypeOf(v) === Object.prototype;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a persisted signal that automatically syncs with storage.
|
|
38
|
+
*/
|
|
39
|
+
export function persist(baseSignal, options) {
|
|
40
|
+
const { name, storage = safeDefaultStorage(), serializer = defaultSerializer, version, merge = 'replace', onRehydrate, onError, migrate, } = options;
|
|
41
|
+
if (!name) {
|
|
42
|
+
throw new Error('[Dalila] persist() requires a "name" option');
|
|
43
|
+
}
|
|
44
|
+
if (!storage) {
|
|
45
|
+
console.warn(`[Dalila] persist(): storage not available for "${name}", persistence disabled`);
|
|
46
|
+
return baseSignal;
|
|
47
|
+
}
|
|
48
|
+
const storageKey = name;
|
|
49
|
+
const versionKey = version !== undefined ? `${name}:version` : undefined;
|
|
50
|
+
// ---- Core safety flags ----
|
|
51
|
+
// hydrated=false blocks writes so we don't overwrite storage before async read resolves.
|
|
52
|
+
let hydrated = false;
|
|
53
|
+
// becomes true if user changes signal before hydration completes
|
|
54
|
+
let dirtyBeforeHydrate = false;
|
|
55
|
+
/**
|
|
56
|
+
* Hydration write guard:
|
|
57
|
+
* The dirty listener below uses baseSignal.on(), which would also fire for the
|
|
58
|
+
* hydration set() itself. This guard prevents hydration from counting as "user dirty".
|
|
59
|
+
*/
|
|
60
|
+
let isHydrationWrite = false;
|
|
61
|
+
// temporary listener to detect changes before hydration completes
|
|
62
|
+
let removeDirtyListener = null;
|
|
63
|
+
/**
|
|
64
|
+
* Write-back dedupe (best effort):
|
|
65
|
+
* Avoid rewriting the same serialized value back to storage after hydration.
|
|
66
|
+
*
|
|
67
|
+
* - lastSaved: what we believe is currently in storage
|
|
68
|
+
* - pendingSaved: what we've already queued to write
|
|
69
|
+
*
|
|
70
|
+
* This prevents: storage -> hydrate -> effect runs -> serialize -> setItem(same value)
|
|
71
|
+
*/
|
|
72
|
+
let lastSaved = null;
|
|
73
|
+
let pendingSaved = null;
|
|
74
|
+
// Same idea for version key (when enabled)
|
|
75
|
+
let lastSavedVersion = null;
|
|
76
|
+
let pendingSavedVersion = null;
|
|
77
|
+
const handleError = (err, ctx) => {
|
|
78
|
+
const e = toError(err);
|
|
79
|
+
if (onError)
|
|
80
|
+
onError(e);
|
|
81
|
+
else
|
|
82
|
+
console.error(`[Dalila] persist(): ${ctx} "${name}"`, e);
|
|
83
|
+
};
|
|
84
|
+
const applyHydration = (value) => {
|
|
85
|
+
// Ensure dirty listener does not treat hydration as user mutation.
|
|
86
|
+
const setHydratedValue = (next) => {
|
|
87
|
+
isHydrationWrite = true;
|
|
88
|
+
try {
|
|
89
|
+
baseSignal.set(next);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
isHydrationWrite = false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (merge === 'shallow' && isPlainObject(value)) {
|
|
96
|
+
const current = baseSignal.peek();
|
|
97
|
+
if (isPlainObject(current)) {
|
|
98
|
+
setHydratedValue({ ...current, ...value });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
setHydratedValue(value);
|
|
103
|
+
};
|
|
104
|
+
// Ensure async writes don't land out-of-order (queue them)
|
|
105
|
+
let writeChain = Promise.resolve();
|
|
106
|
+
const queueWrite = (fn) => {
|
|
107
|
+
writeChain = writeChain
|
|
108
|
+
.then(() => fn())
|
|
109
|
+
.catch((err) => {
|
|
110
|
+
// If a queued write fails, clear pending so future writes aren't blocked.
|
|
111
|
+
pendingSaved = null;
|
|
112
|
+
pendingSavedVersion = null;
|
|
113
|
+
handleError(err, 'failed to save');
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Enqueue write with dedupe:
|
|
118
|
+
* - if value/version already matches what's saved (or already queued), no-op.
|
|
119
|
+
*/
|
|
120
|
+
const enqueuePersist = (serialized, versionStr) => {
|
|
121
|
+
const needsValue = serialized !== lastSaved && serialized !== pendingSaved;
|
|
122
|
+
const needsVersion = !!versionKey &&
|
|
123
|
+
versionStr !== null &&
|
|
124
|
+
versionStr !== lastSavedVersion &&
|
|
125
|
+
versionStr !== pendingSavedVersion;
|
|
126
|
+
if (!needsValue && !needsVersion)
|
|
127
|
+
return;
|
|
128
|
+
if (needsValue)
|
|
129
|
+
pendingSaved = serialized;
|
|
130
|
+
if (needsVersion)
|
|
131
|
+
pendingSavedVersion = versionStr;
|
|
132
|
+
queueWrite(async () => {
|
|
133
|
+
// Write value
|
|
134
|
+
if (needsValue) {
|
|
135
|
+
const r1 = storage.setItem(storageKey, serialized);
|
|
136
|
+
if (isPromiseLike(r1))
|
|
137
|
+
await r1;
|
|
138
|
+
lastSaved = serialized;
|
|
139
|
+
if (pendingSaved === serialized)
|
|
140
|
+
pendingSaved = null;
|
|
141
|
+
}
|
|
142
|
+
// Write version
|
|
143
|
+
if (needsVersion && versionKey && versionStr !== null) {
|
|
144
|
+
const r2 = storage.setItem(versionKey, versionStr);
|
|
145
|
+
if (isPromiseLike(r2))
|
|
146
|
+
await r2;
|
|
147
|
+
lastSavedVersion = versionStr;
|
|
148
|
+
if (pendingSavedVersion === versionStr)
|
|
149
|
+
pendingSavedVersion = null;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
const persistValue = (value) => {
|
|
154
|
+
// Don't write until hydration finishes (prevents overwriting stored state).
|
|
155
|
+
if (!hydrated)
|
|
156
|
+
return;
|
|
157
|
+
let serialized;
|
|
158
|
+
try {
|
|
159
|
+
serialized = serializer.serialize(value);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
handleError(err, 'failed to serialize');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const versionStr = versionKey ? String(version) : null;
|
|
166
|
+
enqueuePersist(serialized, versionStr);
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Hydrate from already-read storage strings.
|
|
170
|
+
* For sync storage we can read versionKey synchronously too.
|
|
171
|
+
*/
|
|
172
|
+
const hydrateFromStored = (storedValue, storedVersionRaw) => {
|
|
173
|
+
// Track current storage contents to avoid immediate write-back.
|
|
174
|
+
lastSaved = storedValue;
|
|
175
|
+
pendingSaved = null;
|
|
176
|
+
if (versionKey) {
|
|
177
|
+
lastSavedVersion = storedVersionRaw;
|
|
178
|
+
pendingSavedVersion = null;
|
|
179
|
+
}
|
|
180
|
+
const deserialized = serializer.deserialize(storedValue);
|
|
181
|
+
if (version !== undefined && versionKey && migrate) {
|
|
182
|
+
const storedVersion = parseVersion(storedVersionRaw);
|
|
183
|
+
if (storedVersion !== version) {
|
|
184
|
+
const migrated = migrate(deserialized, storedVersion);
|
|
185
|
+
applyHydration(migrated);
|
|
186
|
+
// Save migrated data to storage (don't leave old data with new version)
|
|
187
|
+
let migratedSerialized;
|
|
188
|
+
try {
|
|
189
|
+
migratedSerialized = serializer.serialize(baseSignal.peek());
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
handleError(err, 'failed to serialize migrated data');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
enqueuePersist(migratedSerialized, String(version));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
applyHydration(deserialized);
|
|
200
|
+
};
|
|
201
|
+
const finalizeHydration = (didUserChangeBefore) => {
|
|
202
|
+
hydrated = true;
|
|
203
|
+
// Remove temporary dirty listener (no longer needed after hydration)
|
|
204
|
+
if (removeDirtyListener) {
|
|
205
|
+
removeDirtyListener();
|
|
206
|
+
removeDirtyListener = null;
|
|
207
|
+
}
|
|
208
|
+
// If user changed before hydrate finished, we must persist current value at least once,
|
|
209
|
+
// because the change already happened while hydrated=false and won't re-trigger.
|
|
210
|
+
if (didUserChangeBefore) {
|
|
211
|
+
persistValue(baseSignal.peek());
|
|
212
|
+
}
|
|
213
|
+
if (onRehydrate) {
|
|
214
|
+
try {
|
|
215
|
+
onRehydrate(baseSignal.peek());
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
handleError(err, 'onRehydrate threw');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const hydrate = () => {
|
|
223
|
+
try {
|
|
224
|
+
const stored = storage.getItem(storageKey);
|
|
225
|
+
// async storage
|
|
226
|
+
if (isPromiseLike(stored)) {
|
|
227
|
+
return stored
|
|
228
|
+
.then(async (storedValue) => {
|
|
229
|
+
if (storedValue === null) {
|
|
230
|
+
finalizeHydration(dirtyBeforeHydrate);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Track value even if we skip applying due to dirty (helps dedupe correctness).
|
|
234
|
+
lastSaved = storedValue;
|
|
235
|
+
pendingSaved = null;
|
|
236
|
+
// If user changed before hydration finished, prefer local state:
|
|
237
|
+
// do NOT apply storedValue (prevents "rollback" to old storage).
|
|
238
|
+
if (dirtyBeforeHydrate) {
|
|
239
|
+
// Best-effort track version too (if enabled), not required for correctness.
|
|
240
|
+
if (versionKey) {
|
|
241
|
+
try {
|
|
242
|
+
const v = await storage.getItem(versionKey);
|
|
243
|
+
lastSavedVersion = typeof v === 'string' ? v : null;
|
|
244
|
+
pendingSavedVersion = null;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// ignore
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finalizeHydration(true);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
let storedVersionRaw = null;
|
|
254
|
+
if (versionKey) {
|
|
255
|
+
const v = await storage.getItem(versionKey);
|
|
256
|
+
storedVersionRaw = typeof v === 'string' ? v : null;
|
|
257
|
+
}
|
|
258
|
+
hydrateFromStored(storedValue, storedVersionRaw);
|
|
259
|
+
finalizeHydration(false);
|
|
260
|
+
})
|
|
261
|
+
.catch((err) => {
|
|
262
|
+
handleError(err, 'failed to hydrate');
|
|
263
|
+
// Even on error, allow future writes (otherwise persist becomes inert)
|
|
264
|
+
finalizeHydration(dirtyBeforeHydrate);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// sync storage
|
|
268
|
+
if (stored === null) {
|
|
269
|
+
finalizeHydration(false);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let storedVersionRaw = null;
|
|
273
|
+
if (versionKey) {
|
|
274
|
+
const v = storage.getItem(versionKey);
|
|
275
|
+
storedVersionRaw = isPromiseLike(v) ? null : typeof v === 'string' ? v : null;
|
|
276
|
+
}
|
|
277
|
+
hydrateFromStored(stored, storedVersionRaw);
|
|
278
|
+
finalizeHydration(false);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
handleError(err, 'failed to hydrate');
|
|
282
|
+
finalizeHydration(dirtyBeforeHydrate);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
// ---- Set up persistence + dirty tracking ----
|
|
286
|
+
// Perfect dirty detection: track any changes before hydration completes.
|
|
287
|
+
// This catches even synchronous sets before the effect's first run.
|
|
288
|
+
if (!hydrated) {
|
|
289
|
+
removeDirtyListener = baseSignal.on(() => {
|
|
290
|
+
// Ignore hydration writes (hydration must NOT count as "user dirty").
|
|
291
|
+
if (!hydrated && !isHydrationWrite)
|
|
292
|
+
dirtyBeforeHydrate = true;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const scope = getCurrentScope();
|
|
296
|
+
if (scope) {
|
|
297
|
+
effect(() => {
|
|
298
|
+
const value = baseSignal();
|
|
299
|
+
// After hydration completes, persist normally
|
|
300
|
+
if (hydrated) {
|
|
301
|
+
persistValue(value);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// Clean up temporary dirty listener when scope disposes (if still active)
|
|
305
|
+
if (removeDirtyListener) {
|
|
306
|
+
scope.onCleanup(removeDirtyListener);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// No scope: use manual subscription.
|
|
311
|
+
// Dirty-before-hydrate is still handled by the temporary dirty listener above.
|
|
312
|
+
baseSignal.on((value) => {
|
|
313
|
+
if (hydrated) {
|
|
314
|
+
persistValue(value);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// Hydrate after wiring subscribers so async hydration sets can trigger persist if needed.
|
|
319
|
+
const hydration = hydrate();
|
|
320
|
+
if (isPromiseLike(hydration)) {
|
|
321
|
+
hydration.catch((err) => {
|
|
322
|
+
// already handled inside hydrate(), but keep as last-resort safety
|
|
323
|
+
console.error(`[Dalila] persist(): hydration promise rejected for "${name}"`, err);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return baseSignal;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Helper to create JSON storage wrapper
|
|
330
|
+
*/
|
|
331
|
+
export function createJSONStorage(getStorage) {
|
|
332
|
+
return getStorage();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Clear persisted data for a given key
|
|
336
|
+
*/
|
|
337
|
+
export function clearPersisted(name, storage = safeDefaultStorage() ?? {}) {
|
|
338
|
+
if (!storage.removeItem) {
|
|
339
|
+
console.warn(`[Dalila] clearPersisted(): storage.removeItem not available for "${name}"`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
void storage.removeItem(name);
|
|
344
|
+
void storage.removeItem(`${name}:version`);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
console.error(`[Dalila] clearPersisted(): failed for "${name}"`, err);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Generate a minimal inline script to prevent FOUC.
|
|
352
|
+
*
|
|
353
|
+
* NOTE: Assumes the value in storage was JSON serialized (default serializer).
|
|
354
|
+
*/
|
|
355
|
+
export function createPreloadScript(options) {
|
|
356
|
+
const { storageKey, defaultValue, target = 'documentElement', attribute = 'data-theme', storageType = 'localStorage', } = options;
|
|
357
|
+
// Use JSON.stringify to safely embed strings (avoid breaking quotes / injection)
|
|
358
|
+
const k = JSON.stringify(storageKey);
|
|
359
|
+
const d = JSON.stringify(defaultValue);
|
|
360
|
+
const a = JSON.stringify(attribute);
|
|
361
|
+
// Still minified
|
|
362
|
+
return `(function(){try{var s=${storageType}.getItem(${k});var v=s==null?${d}:JSON.parse(s);document.${target}.setAttribute(${a},v)}catch(e){document.${target}.setAttribute(${a},${d})}})();`;
|
|
363
|
+
}
|
|
364
|
+
export function createThemeScript(storageKey, defaultTheme = 'light') {
|
|
365
|
+
return createPreloadScript({
|
|
366
|
+
storageKey,
|
|
367
|
+
defaultValue: defaultTheme,
|
|
368
|
+
target: 'documentElement',
|
|
369
|
+
attribute: 'data-theme',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { key as keyBuilder, type QueryKey } from "./key.js";
|
|
2
|
+
import { type MutationConfig, type MutationState } from "./mutation.js";
|
|
3
|
+
export interface QueryConfig<TKey extends QueryKey, TResult> {
|
|
4
|
+
/** Reactive key (stable identity + encodable). */
|
|
5
|
+
key: () => TKey;
|
|
6
|
+
/** Optional tags registered on the cached resource (for invalidation). */
|
|
7
|
+
tags?: readonly string[];
|
|
8
|
+
/** Fetch function for the given key (AbortSignal is provided). */
|
|
9
|
+
fetch: (signal: AbortSignal, key: TKey) => Promise<TResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Optional stale revalidation window (ms).
|
|
12
|
+
* After a successful fetch, schedules a refresh after `staleTime`.
|
|
13
|
+
*/
|
|
14
|
+
staleTime?: number;
|
|
15
|
+
/** Optional initial value (treated as already-known data). */
|
|
16
|
+
initialValue?: TResult;
|
|
17
|
+
onSuccess?: (data: TResult) => void;
|
|
18
|
+
onError?: (error: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface QueryState<TResult> {
|
|
21
|
+
data: () => TResult | null;
|
|
22
|
+
loading: () => boolean;
|
|
23
|
+
error: () => Error | null;
|
|
24
|
+
/** Manual refresh of the underlying resource. */
|
|
25
|
+
refresh: (opts?: {
|
|
26
|
+
force?: boolean;
|
|
27
|
+
}) => Promise<void>;
|
|
28
|
+
/** Derived status for convenience. */
|
|
29
|
+
status: () => "loading" | "error" | "success";
|
|
30
|
+
/** Current encoded cache key. */
|
|
31
|
+
cacheKey: () => string;
|
|
32
|
+
}
|
|
33
|
+
export interface QueryClient {
|
|
34
|
+
key: typeof keyBuilder;
|
|
35
|
+
/** Safe-by-default: requires scope for caching. Outside scope, does NOT cache. */
|
|
36
|
+
query: <TKey extends QueryKey, TResult>(cfg: QueryConfig<TKey, TResult>) => QueryState<TResult>;
|
|
37
|
+
/** Explicit global caching (persist). */
|
|
38
|
+
queryGlobal: <TKey extends QueryKey, TResult>(cfg: QueryConfig<TKey, TResult>) => QueryState<TResult>;
|
|
39
|
+
mutation: <TInput, TResult>(cfg: MutationConfig<TInput, TResult>) => MutationState<TInput, TResult>;
|
|
40
|
+
invalidateKey: (key: QueryKey, opts?: {
|
|
41
|
+
revalidate?: boolean;
|
|
42
|
+
force?: boolean;
|
|
43
|
+
}) => void;
|
|
44
|
+
invalidateTag: (tag: string, opts?: {
|
|
45
|
+
revalidate?: boolean;
|
|
46
|
+
force?: boolean;
|
|
47
|
+
}) => void;
|
|
48
|
+
invalidateTags: (tags: readonly string[], opts?: {
|
|
49
|
+
revalidate?: boolean;
|
|
50
|
+
force?: boolean;
|
|
51
|
+
}) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Query client (React Query-like API on top of Dalila resources).
|
|
55
|
+
*
|
|
56
|
+
* Design goals:
|
|
57
|
+
* - DOM-first + signals: queries are just resources exposed as reactive getters.
|
|
58
|
+
* - Cache safety: by default, caching requires a scope; global cache is opt-in.
|
|
59
|
+
* - Keyed caching: results are cached by encoded key.
|
|
60
|
+
* - Stale revalidation: optionally schedule a refresh after a successful fetch.
|
|
61
|
+
*
|
|
62
|
+
* Implementation notes:
|
|
63
|
+
* - The underlying cached resource is created inside a computed() so it can react
|
|
64
|
+
* to key changes.
|
|
65
|
+
* - computed() is lazy, so we "kick" it once and also install an effect() that
|
|
66
|
+
* re-reads it, ensuring key changes recreate the resource even if nobody reads
|
|
67
|
+
* data() yet.
|
|
68
|
+
* - staleTime revalidation is guarded by `expectedCk` so a timer from an old key
|
|
69
|
+
* cannot refresh a new key’s resource.
|
|
70
|
+
* - If created inside a scope, staleTime timers are cleared on scope cleanup.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createQueryClient(): QueryClient;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { computed, effect } from "./signal.js";
|
|
2
|
+
import { getCurrentScope, createScope, withScope } from "./scope.js";
|
|
3
|
+
import { key as keyBuilder, encodeKey } from "./key.js";
|
|
4
|
+
import { createCachedResource, invalidateResourceCache, invalidateResourceTag, invalidateResourceTags, } from "./resource.js";
|
|
5
|
+
import { createMutation } from "./mutation.js";
|
|
6
|
+
import { isInDevMode } from "./dev.js";
|
|
7
|
+
/**
|
|
8
|
+
* Query client (React Query-like API on top of Dalila resources).
|
|
9
|
+
*
|
|
10
|
+
* Design goals:
|
|
11
|
+
* - DOM-first + signals: queries are just resources exposed as reactive getters.
|
|
12
|
+
* - Cache safety: by default, caching requires a scope; global cache is opt-in.
|
|
13
|
+
* - Keyed caching: results are cached by encoded key.
|
|
14
|
+
* - Stale revalidation: optionally schedule a refresh after a successful fetch.
|
|
15
|
+
*
|
|
16
|
+
* Implementation notes:
|
|
17
|
+
* - The underlying cached resource is created inside a computed() so it can react
|
|
18
|
+
* to key changes.
|
|
19
|
+
* - computed() is lazy, so we "kick" it once and also install an effect() that
|
|
20
|
+
* re-reads it, ensuring key changes recreate the resource even if nobody reads
|
|
21
|
+
* data() yet.
|
|
22
|
+
* - staleTime revalidation is guarded by `expectedCk` so a timer from an old key
|
|
23
|
+
* cannot refresh a new key’s resource.
|
|
24
|
+
* - If created inside a scope, staleTime timers are cleared on scope cleanup.
|
|
25
|
+
*/
|
|
26
|
+
export function createQueryClient() {
|
|
27
|
+
function makeQuery(cfg, behavior) {
|
|
28
|
+
const scope = getCurrentScope();
|
|
29
|
+
const parentScope = scope;
|
|
30
|
+
const staleTime = cfg.staleTime ?? 0;
|
|
31
|
+
let staleTimer = null;
|
|
32
|
+
let cleanupRegistered = false;
|
|
33
|
+
let keyScope = null;
|
|
34
|
+
let keyScopeCk = null;
|
|
35
|
+
if (isInDevMode() && !parentScope && behavior.persist === false) {
|
|
36
|
+
console.warn(`[Dalila] q.query() called outside a scope. ` +
|
|
37
|
+
`It will not cache and may leak. Use within a scope or q.queryGlobal().`);
|
|
38
|
+
}
|
|
39
|
+
function ensureKeyScope(ck) {
|
|
40
|
+
if (!parentScope)
|
|
41
|
+
return null;
|
|
42
|
+
if (keyScope && keyScopeCk === ck)
|
|
43
|
+
return keyScope;
|
|
44
|
+
// cancel any pending stale timer from the previous key
|
|
45
|
+
if (staleTimer != null) {
|
|
46
|
+
clearTimeout(staleTimer);
|
|
47
|
+
staleTimer = null;
|
|
48
|
+
}
|
|
49
|
+
keyScope?.dispose();
|
|
50
|
+
keyScopeCk = ck;
|
|
51
|
+
keyScope = createScope(parentScope);
|
|
52
|
+
return keyScope;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Schedules a stale-time revalidation after success.
|
|
56
|
+
*
|
|
57
|
+
* Race safety:
|
|
58
|
+
* - Captures `expectedCk` so a timer created for an old key cannot refresh
|
|
59
|
+
* after the key changes.
|
|
60
|
+
*
|
|
61
|
+
* Lifetime:
|
|
62
|
+
* - If in a scope, the timer is cleared on scope cleanup.
|
|
63
|
+
*/
|
|
64
|
+
const scheduleStaleRevalidate = (r, expectedCk) => {
|
|
65
|
+
if (staleTime <= 0)
|
|
66
|
+
return;
|
|
67
|
+
if (!scope) {
|
|
68
|
+
if (isInDevMode()) {
|
|
69
|
+
console.warn(`[Dalila] staleTime requires a scope for cleanup. ` +
|
|
70
|
+
`Run the query inside a scope or disable staleTime.`);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (encodeKey(cfg.key()) !== expectedCk)
|
|
75
|
+
return;
|
|
76
|
+
// Register cleanup once (if we have a scope).
|
|
77
|
+
if (!cleanupRegistered) {
|
|
78
|
+
cleanupRegistered = true;
|
|
79
|
+
scope.onCleanup(() => {
|
|
80
|
+
if (staleTimer != null)
|
|
81
|
+
clearTimeout(staleTimer);
|
|
82
|
+
staleTimer = null;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Only one pending stale timer per query instance.
|
|
86
|
+
if (staleTimer != null) {
|
|
87
|
+
clearTimeout(staleTimer);
|
|
88
|
+
staleTimer = null;
|
|
89
|
+
}
|
|
90
|
+
staleTimer = setTimeout(() => {
|
|
91
|
+
// Guard against revalidating stale keys when key changed during staleTime.
|
|
92
|
+
if (encodeKey(cfg.key()) !== expectedCk)
|
|
93
|
+
return;
|
|
94
|
+
r.refresh({ force: false }).catch(() => { });
|
|
95
|
+
}, staleTime);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Underlying cached resource.
|
|
99
|
+
*
|
|
100
|
+
* Important: we capture `r` via `let r!` so `onSuccess` can schedule the
|
|
101
|
+
* stale revalidation against the correct instance.
|
|
102
|
+
*/
|
|
103
|
+
const resource = computed(() => {
|
|
104
|
+
const k = cfg.key();
|
|
105
|
+
const ck = encodeKey(k);
|
|
106
|
+
let r;
|
|
107
|
+
const ks = ensureKeyScope(ck);
|
|
108
|
+
const opts = {
|
|
109
|
+
onError: cfg.onError,
|
|
110
|
+
onSuccess: (data) => {
|
|
111
|
+
cfg.onSuccess?.(data);
|
|
112
|
+
scheduleStaleRevalidate(r, ck);
|
|
113
|
+
},
|
|
114
|
+
persist: behavior.persist,
|
|
115
|
+
warnPersistWithoutTtl: behavior.warnPersistWithoutTtl,
|
|
116
|
+
fetchScope: ks ?? undefined,
|
|
117
|
+
};
|
|
118
|
+
if (cfg.initialValue !== undefined)
|
|
119
|
+
opts.initialValue = cfg.initialValue;
|
|
120
|
+
// Keyed cache entry (scope-safe unless persist is enabled).
|
|
121
|
+
const make = () => createCachedResource(ck, async (sig) => {
|
|
122
|
+
await Promise.resolve(); // break reactive tracking
|
|
123
|
+
return cfg.fetch(sig, k);
|
|
124
|
+
}, { ...opts, tags: cfg.tags });
|
|
125
|
+
r = ks ? withScope(ks, make) : make();
|
|
126
|
+
return r;
|
|
127
|
+
});
|
|
128
|
+
/** Convenience derived status from the underlying resource. */
|
|
129
|
+
const status = computed(() => {
|
|
130
|
+
const r = resource();
|
|
131
|
+
if (r.loading())
|
|
132
|
+
return "loading";
|
|
133
|
+
if (r.error())
|
|
134
|
+
return "error";
|
|
135
|
+
return "success";
|
|
136
|
+
});
|
|
137
|
+
/** Expose the current encoded key as a computed signal. */
|
|
138
|
+
const cacheKeySig = computed(() => encodeKey(cfg.key()));
|
|
139
|
+
/**
|
|
140
|
+
* Kick once so the initial query starts immediately.
|
|
141
|
+
* Then keep it reactive so key changes recreate the resource
|
|
142
|
+
* even if nobody reads data() / loading() / error().
|
|
143
|
+
*/
|
|
144
|
+
resource();
|
|
145
|
+
effect(() => {
|
|
146
|
+
resource();
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
data: () => resource().data(),
|
|
150
|
+
loading: () => resource().loading(),
|
|
151
|
+
error: () => resource().error(),
|
|
152
|
+
refresh: (opts) => resource().refresh(opts),
|
|
153
|
+
status: () => status(),
|
|
154
|
+
cacheKey: () => cacheKeySig(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function query(cfg) {
|
|
158
|
+
return makeQuery(cfg, { persist: false });
|
|
159
|
+
}
|
|
160
|
+
function queryGlobal(cfg) {
|
|
161
|
+
return makeQuery(cfg, { persist: true, warnPersistWithoutTtl: false });
|
|
162
|
+
}
|
|
163
|
+
function mutation(cfg) {
|
|
164
|
+
return createMutation(cfg);
|
|
165
|
+
}
|
|
166
|
+
function invalidateKey(k, opts = {}) {
|
|
167
|
+
invalidateResourceCache(encodeKey(k), opts);
|
|
168
|
+
}
|
|
169
|
+
function invalidateTag(tag, opts = {}) {
|
|
170
|
+
invalidateResourceTag(tag, opts);
|
|
171
|
+
}
|
|
172
|
+
function invalidateTags(tags, opts = {}) {
|
|
173
|
+
invalidateResourceTags(tags, opts);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
key: keyBuilder,
|
|
177
|
+
query,
|
|
178
|
+
queryGlobal,
|
|
179
|
+
mutation,
|
|
180
|
+
invalidateKey,
|
|
181
|
+
invalidateTag,
|
|
182
|
+
invalidateTags,
|
|
183
|
+
};
|
|
184
|
+
}
|