ff-serv 0.1.10 → 0.1.11

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 (52) hide show
  1. package/dist/adapter-DLhjFlOu.d.cts +31 -0
  2. package/dist/adapter-DLhjFlOu.d.ts +31 -0
  3. package/dist/cli.js +47 -47
  4. package/dist/exports/cache-bun-redis.cjs +50 -0
  5. package/dist/exports/cache-bun-redis.cjs.map +1 -0
  6. package/dist/exports/cache-bun-redis.d.cts +11 -0
  7. package/dist/exports/cache-bun-redis.d.ts +11 -0
  8. package/dist/exports/cache-bun-redis.js +23 -0
  9. package/dist/exports/cache-bun-redis.js.map +1 -0
  10. package/dist/exports/cache-ioredis.cjs +51 -0
  11. package/dist/exports/cache-ioredis.cjs.map +1 -0
  12. package/dist/exports/cache-ioredis.d.cts +11 -0
  13. package/dist/exports/cache-ioredis.d.ts +11 -0
  14. package/dist/exports/cache-ioredis.js +24 -0
  15. package/dist/exports/cache-ioredis.js.map +1 -0
  16. package/dist/exports/cache.cjs +220 -0
  17. package/dist/exports/cache.cjs.map +1 -0
  18. package/dist/exports/cache.d.cts +30 -0
  19. package/dist/exports/cache.d.ts +30 -0
  20. package/dist/exports/cache.js +199 -0
  21. package/dist/exports/cache.js.map +1 -0
  22. package/dist/exports/orpc.cjs +15 -10
  23. package/dist/exports/orpc.cjs.map +1 -1
  24. package/dist/exports/orpc.js +16 -11
  25. package/dist/exports/orpc.js.map +1 -1
  26. package/dist/index.cjs +15 -10
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +10 -7
  29. package/dist/index.d.ts +10 -7
  30. package/dist/index.js +17 -12
  31. package/dist/index.js.map +1 -1
  32. package/package.json +26 -2
  33. package/src/cache/AGENTS.md +8 -0
  34. package/src/cache/adapter.test.ts +181 -0
  35. package/src/cache/adapter.ts +120 -0
  36. package/src/cache/adapters/bun-redis.ts +29 -0
  37. package/src/cache/adapters/ioredis.ts +30 -0
  38. package/src/cache/cache.test.ts +461 -0
  39. package/src/cache/cache.ts +184 -0
  40. package/src/cli/commands/db/pull.ts +5 -15
  41. package/src/cli/commands/db/shared.ts +7 -7
  42. package/src/cli/config/index.ts +1 -3
  43. package/src/cli/index.ts +1 -1
  44. package/src/cli/utils/database-source.ts +1 -4
  45. package/src/exports/cache-bun-redis.ts +1 -0
  46. package/src/exports/cache-ioredis.ts +1 -0
  47. package/src/exports/cache.ts +6 -0
  48. package/src/exports/orpc.ts +1 -1
  49. package/src/http/fetch-handler.test.ts +1 -1
  50. package/src/index.ts +1 -0
  51. package/src/logger.test.ts +168 -0
  52. package/src/logger.ts +41 -19
@@ -0,0 +1,461 @@
1
+ import { it } from '@effect/vitest';
2
+ import { Clock, Duration, Effect, Option, Ref, TestClock } from 'effect';
3
+ import { describe, expect } from 'vitest';
4
+ import type { CacheAdapter, CacheEntry } from './adapter.js';
5
+ import { Cache } from './cache.js';
6
+
7
+ function makeTestAdapter<Key, Value>() {
8
+ const store = new Map<string, CacheEntry<Value>>();
9
+ const adapter: CacheAdapter<Key, Value> = {
10
+ get: (key) =>
11
+ Effect.sync(() => {
12
+ const entry = store.get(JSON.stringify(key));
13
+ return entry ? Option.some(entry) : Option.none();
14
+ }),
15
+ set: (key, entry, _ttl) =>
16
+ Effect.sync(() => {
17
+ store.set(JSON.stringify(key), entry);
18
+ }),
19
+ remove: (key) =>
20
+ Effect.sync(() => {
21
+ store.delete(JSON.stringify(key));
22
+ }),
23
+ removeAll: Effect.sync(() => {
24
+ store.clear();
25
+ }),
26
+ };
27
+ return { adapter, store };
28
+ }
29
+
30
+ describe('Cache', () => {
31
+ describe('core', () => {
32
+ it.effect('calls lookup and returns value', () =>
33
+ Effect.gen(function* () {
34
+ const cache = yield* Cache.make({
35
+ ttl: Duration.minutes(5),
36
+ lookup: (id: number) => Effect.succeed(`user-${id}`),
37
+ });
38
+ const value = yield* cache.get(1);
39
+ expect(value).toBe('user-1');
40
+ }),
41
+ );
42
+
43
+ it.effect('caches lookup result', () =>
44
+ Effect.gen(function* () {
45
+ const callCount = yield* Ref.make(0);
46
+ const cache = yield* Cache.make({
47
+ ttl: Duration.minutes(5),
48
+ lookup: (id: number) =>
49
+ Ref.update(callCount, (n) => n + 1).pipe(
50
+ Effect.map(() => `user-${id}`),
51
+ ),
52
+ });
53
+ yield* cache.get(1);
54
+ yield* cache.get(1);
55
+ expect(yield* Ref.get(callCount)).toBe(1);
56
+ }),
57
+ );
58
+
59
+ it.effect('calls lookup for different keys', () =>
60
+ Effect.gen(function* () {
61
+ const callCount = yield* Ref.make(0);
62
+ const cache = yield* Cache.make({
63
+ ttl: Duration.minutes(5),
64
+ lookup: (id: number) =>
65
+ Ref.update(callCount, (n) => n + 1).pipe(
66
+ Effect.map(() => `user-${id}`),
67
+ ),
68
+ });
69
+ yield* cache.get(1);
70
+ yield* cache.get(2);
71
+ expect(yield* Ref.get(callCount)).toBe(2);
72
+ }),
73
+ );
74
+
75
+ it.effect('expires entries after TTL', () =>
76
+ Effect.gen(function* () {
77
+ const callCount = yield* Ref.make(0);
78
+ const cache = yield* Cache.make({
79
+ ttl: Duration.minutes(5),
80
+ lookup: (id: number) =>
81
+ Ref.update(callCount, (n) => n + 1).pipe(
82
+ Effect.map(() => `user-${id}`),
83
+ ),
84
+ });
85
+ yield* cache.get(1);
86
+ yield* TestClock.adjust(Duration.minutes(6));
87
+ yield* cache.get(1);
88
+ expect(yield* Ref.get(callCount)).toBe(2);
89
+ }),
90
+ );
91
+
92
+ it.effect('propagates lookup errors', () =>
93
+ Effect.gen(function* () {
94
+ const cache = yield* Cache.make({
95
+ ttl: Duration.minutes(5),
96
+ lookup: (_id: number) => Effect.fail('lookup-error' as const),
97
+ });
98
+ const result = yield* cache.get(1).pipe(Effect.either);
99
+ expect(result._tag).toBe('Left');
100
+ }),
101
+ );
102
+
103
+ it.effect('invalidates a single key', () =>
104
+ Effect.gen(function* () {
105
+ const callCount = yield* Ref.make(0);
106
+ const cache = yield* Cache.make({
107
+ ttl: Duration.minutes(5),
108
+ lookup: (id: number) =>
109
+ Ref.update(callCount, (n) => n + 1).pipe(
110
+ Effect.map(() => `user-${id}`),
111
+ ),
112
+ });
113
+ yield* cache.get(1);
114
+ yield* cache.invalidate(1);
115
+ yield* cache.get(1);
116
+ expect(yield* Ref.get(callCount)).toBe(2);
117
+ }),
118
+ );
119
+
120
+ it.effect('invalidates all keys', () =>
121
+ Effect.gen(function* () {
122
+ const callCount = yield* Ref.make(0);
123
+ const cache = yield* Cache.make({
124
+ ttl: Duration.minutes(5),
125
+ lookup: (id: number) =>
126
+ Ref.update(callCount, (n) => n + 1).pipe(
127
+ Effect.map(() => `user-${id}`),
128
+ ),
129
+ });
130
+ yield* cache.get(1);
131
+ yield* cache.get(2);
132
+ yield* cache.invalidateAll;
133
+ yield* cache.get(1);
134
+ yield* cache.get(2);
135
+ expect(yield* Ref.get(callCount)).toBe(4);
136
+ }),
137
+ );
138
+ });
139
+
140
+ describe('SWR', () => {
141
+ it.effect('returns stale value within SWR window', () =>
142
+ Effect.gen(function* () {
143
+ const callCount = yield* Ref.make(0);
144
+ const cache = yield* Cache.make({
145
+ ttl: Duration.minutes(5),
146
+ swr: Duration.minutes(10),
147
+ lookup: (id: number) =>
148
+ Effect.gen(function* () {
149
+ yield* Ref.update(callCount, (n) => n + 1);
150
+ const count = yield* Ref.get(callCount);
151
+ return `user-${id}-v${count}`;
152
+ }),
153
+ });
154
+
155
+ const v1 = yield* cache.get(1);
156
+ expect(v1).toBe('user-1-v1');
157
+
158
+ // Advance past TTL but within SWR window
159
+ yield* TestClock.adjust(Duration.minutes(7));
160
+ const v2 = yield* cache.get(1);
161
+ // Should return stale value immediately
162
+ expect(v2).toBe('user-1-v1');
163
+ }),
164
+ );
165
+
166
+ it.effect('triggers background refresh after stale read', () =>
167
+ Effect.gen(function* () {
168
+ const callCount = yield* Ref.make(0);
169
+ const cache = yield* Cache.make({
170
+ ttl: Duration.minutes(5),
171
+ swr: Duration.minutes(10),
172
+ lookup: (id: number) =>
173
+ Effect.gen(function* () {
174
+ yield* Ref.update(callCount, (n) => n + 1);
175
+ const count = yield* Ref.get(callCount);
176
+ return `user-${id}-v${count}`;
177
+ }),
178
+ });
179
+
180
+ yield* cache.get(1);
181
+ yield* TestClock.adjust(Duration.minutes(7));
182
+
183
+ // This returns stale but triggers background refresh
184
+ yield* cache.get(1);
185
+ // Let the background fiber run
186
+ yield* Effect.yieldNow();
187
+ yield* TestClock.adjust(Duration.zero);
188
+ yield* Effect.yieldNow();
189
+
190
+ // Background refresh should have been triggered
191
+ expect(yield* Ref.get(callCount)).toBe(2);
192
+ }),
193
+ );
194
+
195
+ it.effect('serves fresh value after background refresh', () =>
196
+ Effect.gen(function* () {
197
+ const callCount = yield* Ref.make(0);
198
+ const cache = yield* Cache.make({
199
+ ttl: Duration.minutes(5),
200
+ swr: Duration.minutes(10),
201
+ lookup: (id: number) =>
202
+ Effect.gen(function* () {
203
+ yield* Ref.update(callCount, (n) => n + 1);
204
+ const count = yield* Ref.get(callCount);
205
+ return `user-${id}-v${count}`;
206
+ }),
207
+ });
208
+
209
+ yield* cache.get(1);
210
+ yield* TestClock.adjust(Duration.minutes(7));
211
+ yield* cache.get(1);
212
+ yield* Effect.yieldNow();
213
+ yield* TestClock.adjust(Duration.zero);
214
+ yield* Effect.yieldNow();
215
+
216
+ // Next get should return the refreshed value
217
+ const v3 = yield* cache.get(1);
218
+ expect(v3).toBe('user-1-v2');
219
+ }),
220
+ );
221
+
222
+ it.effect('does not serve stale when swr is not set', () =>
223
+ Effect.gen(function* () {
224
+ const callCount = yield* Ref.make(0);
225
+ const cache = yield* Cache.make({
226
+ ttl: Duration.minutes(5),
227
+ lookup: (id: number) =>
228
+ Effect.gen(function* () {
229
+ yield* Ref.update(callCount, (n) => n + 1);
230
+ const count = yield* Ref.get(callCount);
231
+ return `user-${id}-v${count}`;
232
+ }),
233
+ });
234
+
235
+ yield* cache.get(1);
236
+ yield* TestClock.adjust(Duration.minutes(6));
237
+
238
+ // No SWR — should block and call lookup again
239
+ const v2 = yield* cache.get(1);
240
+ expect(v2).toBe('user-1-v2');
241
+ expect(yield* Ref.get(callCount)).toBe(2);
242
+ }),
243
+ );
244
+
245
+ it.effect('blocks for fresh value past SWR window', () =>
246
+ Effect.gen(function* () {
247
+ const callCount = yield* Ref.make(0);
248
+ const cache = yield* Cache.make({
249
+ ttl: Duration.minutes(5),
250
+ swr: Duration.minutes(10),
251
+ lookup: (id: number) =>
252
+ Effect.gen(function* () {
253
+ yield* Ref.update(callCount, (n) => n + 1);
254
+ const count = yield* Ref.get(callCount);
255
+ return `user-${id}-v${count}`;
256
+ }),
257
+ });
258
+
259
+ yield* cache.get(1);
260
+ // Advance past TTL + SWR window
261
+ yield* TestClock.adjust(Duration.minutes(16));
262
+ const v2 = yield* cache.get(1);
263
+ expect(v2).toBe('user-1-v2');
264
+ expect(yield* Ref.get(callCount)).toBe(2);
265
+ }),
266
+ );
267
+ });
268
+
269
+ describe('dynamic TTL', () => {
270
+ it.effect('Cache.entry overrides TTL', () =>
271
+ Effect.gen(function* () {
272
+ const callCount = yield* Ref.make(0);
273
+ const cache = yield* Cache.make({
274
+ ttl: Duration.minutes(5),
275
+ lookup: (id: number) =>
276
+ Effect.gen(function* () {
277
+ yield* Ref.update(callCount, (n) => n + 1);
278
+ const count = yield* Ref.get(callCount);
279
+ return Cache.entry(`user-${id}-v${count}`, {
280
+ ttl: Duration.hours(1),
281
+ });
282
+ }),
283
+ });
284
+
285
+ yield* cache.get(1);
286
+ // Advance past default TTL (5m) but within custom TTL (1h)
287
+ yield* TestClock.adjust(Duration.minutes(30));
288
+ yield* cache.get(1);
289
+ // Should still be cached — custom TTL is 1h
290
+ expect(yield* Ref.get(callCount)).toBe(1);
291
+ }),
292
+ );
293
+
294
+ it.effect('Cache.entry overrides SWR', () =>
295
+ Effect.gen(function* () {
296
+ const callCount = yield* Ref.make(0);
297
+ const cache = yield* Cache.make({
298
+ ttl: Duration.minutes(5),
299
+ swr: Duration.minutes(10),
300
+ lookup: (id: number) =>
301
+ Effect.gen(function* () {
302
+ yield* Ref.update(callCount, (n) => n + 1);
303
+ const count = yield* Ref.get(callCount);
304
+ return Cache.entry(`user-${id}-v${count}`, {
305
+ ttl: Duration.minutes(1),
306
+ swr: Duration.minutes(2),
307
+ });
308
+ }),
309
+ });
310
+
311
+ yield* cache.get(1);
312
+ // Advance past custom TTL (1m) but within custom SWR (1m + 2m = 3m)
313
+ yield* TestClock.adjust(Duration.minutes(2));
314
+ const stale = yield* cache.get(1);
315
+ // Should return stale value (SWR active)
316
+ expect(stale).toBe('user-1-v1');
317
+ }),
318
+ );
319
+
320
+ it.effect('plain return uses default TTL/SWR', () =>
321
+ Effect.gen(function* () {
322
+ const callCount = yield* Ref.make(0);
323
+ const cache = yield* Cache.make({
324
+ ttl: Duration.minutes(5),
325
+ swr: Duration.minutes(10),
326
+ lookup: (id: number) =>
327
+ Ref.update(callCount, (n) => n + 1).pipe(
328
+ Effect.map(() => `user-${id}`),
329
+ ),
330
+ });
331
+
332
+ yield* cache.get(1);
333
+ // Within default TTL
334
+ yield* TestClock.adjust(Duration.minutes(3));
335
+ yield* cache.get(1);
336
+ expect(yield* Ref.get(callCount)).toBe(1);
337
+
338
+ // Past default TTL but within SWR
339
+ yield* TestClock.adjust(Duration.minutes(4));
340
+ const stale = yield* cache.get(1);
341
+ expect(stale).toBe('user-1');
342
+ }),
343
+ );
344
+
345
+ it.effect('mixed entries use their own TTL', () =>
346
+ Effect.gen(function* () {
347
+ const callCountA = yield* Ref.make(0);
348
+ const callCountB = yield* Ref.make(0);
349
+ const cache = yield* Cache.make({
350
+ ttl: Duration.minutes(5),
351
+ lookup: (id: string) =>
352
+ Effect.gen(function* () {
353
+ if (id === 'a') {
354
+ yield* Ref.update(callCountA, (n) => n + 1);
355
+ return `val-a`;
356
+ }
357
+ yield* Ref.update(callCountB, (n) => n + 1);
358
+ return Cache.entry(`val-b`, { ttl: Duration.hours(1) });
359
+ }),
360
+ });
361
+
362
+ yield* cache.get('a');
363
+ yield* cache.get('b');
364
+
365
+ // Advance past default TTL but within custom TTL
366
+ yield* TestClock.adjust(Duration.minutes(6));
367
+
368
+ yield* cache.get('a'); // should re-fetch (past 5m TTL)
369
+ yield* cache.get('b'); // should still be cached (within 1h TTL)
370
+
371
+ expect(yield* Ref.get(callCountA)).toBe(2);
372
+ expect(yield* Ref.get(callCountB)).toBe(1);
373
+ }),
374
+ );
375
+ });
376
+
377
+ describe('adapter', () => {
378
+ it.effect('uses adapter data on cold start without calling lookup', () =>
379
+ Effect.gen(function* () {
380
+ const { adapter, store } = makeTestAdapter<number, string>();
381
+ const now = yield* Clock.currentTimeMillis;
382
+ store.set(JSON.stringify(1), { value: 'cached-user-1', storedAt: now });
383
+
384
+ const callCount = yield* Ref.make(0);
385
+ const cache = yield* Cache.make({
386
+ ttl: Duration.minutes(5),
387
+ lookup: (id: number) =>
388
+ Ref.update(callCount, (n) => n + 1).pipe(
389
+ Effect.map(() => `user-${id}`),
390
+ ),
391
+ adapter,
392
+ });
393
+
394
+ const value = yield* cache.get(1);
395
+ expect(value).toBe('cached-user-1');
396
+ expect(yield* Ref.get(callCount)).toBe(0);
397
+ }),
398
+ );
399
+
400
+ it.effect(
401
+ 'SWR refresh calls lookup instead of short-circuiting with adapter',
402
+ () =>
403
+ Effect.gen(function* () {
404
+ const { adapter } = makeTestAdapter<number, string>();
405
+ const callCount = yield* Ref.make(0);
406
+ const cache = yield* Cache.make({
407
+ ttl: Duration.minutes(5),
408
+ swr: Duration.minutes(10),
409
+ lookup: (id: number) =>
410
+ Effect.gen(function* () {
411
+ yield* Ref.update(callCount, (n) => n + 1);
412
+ const count = yield* Ref.get(callCount);
413
+ return `user-${id}-v${count}`;
414
+ }),
415
+ adapter,
416
+ });
417
+
418
+ yield* cache.get(1);
419
+ yield* TestClock.adjust(Duration.minutes(7));
420
+
421
+ // Trigger SWR refresh
422
+ yield* cache.get(1);
423
+ yield* Effect.yieldNow();
424
+ yield* TestClock.adjust(Duration.zero);
425
+ yield* Effect.yieldNow();
426
+
427
+ // lookup must have been called twice (initial + refresh)
428
+ expect(yield* Ref.get(callCount)).toBe(2);
429
+ }),
430
+ );
431
+
432
+ it.effect('adapter updated after SWR refresh', () =>
433
+ Effect.gen(function* () {
434
+ const { adapter, store } = makeTestAdapter<number, string>();
435
+ const callCount = yield* Ref.make(0);
436
+ const cache = yield* Cache.make({
437
+ ttl: Duration.minutes(5),
438
+ swr: Duration.minutes(10),
439
+ lookup: (id: number) =>
440
+ Effect.gen(function* () {
441
+ yield* Ref.update(callCount, (n) => n + 1);
442
+ const count = yield* Ref.get(callCount);
443
+ return `user-${id}-v${count}`;
444
+ }),
445
+ adapter,
446
+ });
447
+
448
+ yield* cache.get(1);
449
+ yield* TestClock.adjust(Duration.minutes(7));
450
+
451
+ yield* cache.get(1);
452
+ yield* Effect.yieldNow();
453
+ yield* TestClock.adjust(Duration.zero);
454
+ yield* Effect.yieldNow();
455
+
456
+ const entry = store.get(JSON.stringify(1));
457
+ expect(entry?.value).toBe('user-1-v2');
458
+ }),
459
+ );
460
+ });
461
+ });
@@ -0,0 +1,184 @@
1
+ import {
2
+ Clock,
3
+ Duration,
4
+ Effect,
5
+ Cache as EffectCache,
6
+ Exit,
7
+ Option,
8
+ } from 'effect';
9
+ import type { CacheAdapter, CacheEntry } from './adapter.js';
10
+
11
+ // Bundles value with resolved TTL/SWR so the SWR check at read time uses per-entry durations
12
+ type CacheValue<Value> = {
13
+ readonly value: Value;
14
+ readonly ttlMs: number;
15
+ readonly swrMs: number;
16
+ };
17
+
18
+ export type CacheInstance<Key, Value, Error> = {
19
+ readonly get: (key: Key) => Effect.Effect<Value, Error>;
20
+ readonly invalidate: (key: Key) => Effect.Effect<void>;
21
+ readonly invalidateAll: Effect.Effect<void>;
22
+ };
23
+
24
+ export namespace Cache {
25
+ export type Entry<Value> = {
26
+ readonly _tag: 'CacheEntry';
27
+ readonly value: Value;
28
+ readonly ttl: Duration.DurationInput;
29
+ readonly swr?: Duration.DurationInput;
30
+ };
31
+
32
+ export function entry<Value>(
33
+ value: Value,
34
+ opts: { ttl: Duration.DurationInput; swr?: Duration.DurationInput },
35
+ ): Entry<Value> {
36
+ return { _tag: 'CacheEntry', value, ttl: opts.ttl, swr: opts.swr };
37
+ }
38
+
39
+ export type LookupResult<Value> = Value | Entry<Value>;
40
+
41
+ export function make<Key, Value, Error = never, R = never>(opts: {
42
+ ttl: Duration.DurationInput;
43
+ swr?: Duration.DurationInput;
44
+ lookup: (key: Key) => Effect.Effect<LookupResult<Value>, Error, R>;
45
+ adapter?: CacheAdapter<Key, Value>;
46
+ }): Effect.Effect<CacheInstance<Key, Value, Error>, never, R> {
47
+ return Effect.gen(function* () {
48
+ const adapter = opts.adapter;
49
+ const defaultTtlMs = Duration.toMillis(Duration.decode(opts.ttl));
50
+ const defaultSwrMs = opts.swr
51
+ ? Duration.toMillis(Duration.decode(opts.swr))
52
+ : 0;
53
+ const capacity = adapter?.capacity ?? Number.MAX_SAFE_INTEGER;
54
+
55
+ // Safe without synchronization — no yield points between has() and add() (cooperative scheduling)
56
+ const refreshingKeys = new Set<string>();
57
+
58
+ // makeWith uses `timeToLive: (exit) => Duration` — the lookup stores CacheValue
59
+ // so timeToLive can extract the total window (ttl + swr) from the exit result
60
+ const inner = yield* EffectCache.makeWith({
61
+ capacity,
62
+ lookup: (key: Key) =>
63
+ Effect.gen(function* () {
64
+ const isRefreshing = refreshingKeys.has(JSON.stringify(key));
65
+
66
+ if (adapter && !isRefreshing) {
67
+ const cached = yield* adapter.get(key);
68
+ if (Option.isSome(cached)) {
69
+ const now = yield* Clock.currentTimeMillis;
70
+ const age = now - cached.value.storedAt;
71
+ const totalWindow = defaultTtlMs + defaultSwrMs;
72
+ if (age < totalWindow) {
73
+ // Adjust remaining TTL/SWR for elapsed age so SWR triggers at correct real-world time
74
+ return {
75
+ value: cached.value.value,
76
+ ttlMs: Math.max(0, defaultTtlMs - age),
77
+ swrMs: Math.max(
78
+ 0,
79
+ defaultSwrMs - Math.max(0, age - defaultTtlMs),
80
+ ),
81
+ } satisfies CacheValue<Value>;
82
+ }
83
+ }
84
+ }
85
+
86
+ const result = yield* opts.lookup(key);
87
+ const cv = resolveLookupResult(result, defaultTtlMs, defaultSwrMs);
88
+
89
+ if (adapter) {
90
+ const now = yield* Clock.currentTimeMillis;
91
+ yield* adapter.set(
92
+ key,
93
+ { value: cv.value, storedAt: now } satisfies CacheEntry<Value>,
94
+ Duration.millis(cv.ttlMs + cv.swrMs),
95
+ );
96
+ }
97
+
98
+ return cv;
99
+ }),
100
+ timeToLive: (exit) => {
101
+ if (Exit.isSuccess(exit)) {
102
+ return Duration.millis(exit.value.ttlMs + exit.value.swrMs);
103
+ }
104
+ return Duration.zero;
105
+ },
106
+ });
107
+
108
+ const get = (key: Key) =>
109
+ Effect.gen(function* () {
110
+ const cv = yield* inner.get(key);
111
+
112
+ if (cv.swrMs > 0) {
113
+ const stats = yield* inner.entryStats(key);
114
+ if (Option.isSome(stats)) {
115
+ const now = yield* Clock.currentTimeMillis;
116
+ const age = now - stats.value.loadedMillis;
117
+ if (age > cv.ttlMs) {
118
+ const keyStr = JSON.stringify(key);
119
+ if (!refreshingKeys.has(keyStr)) {
120
+ refreshingKeys.add(keyStr);
121
+ // refresh() recomputes without invalidating, so stale value remains available during recomputation
122
+ yield* Effect.forkDaemon(
123
+ inner.refresh(key).pipe(
124
+ Effect.ensuring(
125
+ Effect.sync(() => {
126
+ refreshingKeys.delete(keyStr);
127
+ }),
128
+ ),
129
+ Effect.ignore,
130
+ ),
131
+ );
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ return cv.value;
138
+ });
139
+
140
+ const invalidate = (key: Key) =>
141
+ Effect.gen(function* () {
142
+ yield* inner.invalidate(key);
143
+ if (adapter) yield* adapter.remove(key);
144
+ });
145
+
146
+ const invalidateAll = Effect.gen(function* () {
147
+ yield* inner.invalidateAll;
148
+ if (adapter) yield* adapter.removeAll;
149
+ });
150
+
151
+ return { get, invalidate, invalidateAll } satisfies CacheInstance<
152
+ Key,
153
+ Value,
154
+ Error
155
+ >;
156
+ });
157
+ }
158
+ }
159
+
160
+ function isCacheEntry<Value>(
161
+ result: Cache.LookupResult<Value>,
162
+ ): result is Cache.Entry<Value> {
163
+ return (
164
+ typeof result === 'object' &&
165
+ result !== null &&
166
+ '_tag' in result &&
167
+ (result as Cache.Entry<Value>)._tag === 'CacheEntry'
168
+ );
169
+ }
170
+
171
+ function resolveLookupResult<Value>(
172
+ result: Cache.LookupResult<Value>,
173
+ defaultTtlMs: number,
174
+ defaultSwrMs: number,
175
+ ): CacheValue<Value> {
176
+ if (isCacheEntry(result)) {
177
+ return {
178
+ value: result.value,
179
+ ttlMs: Duration.toMillis(Duration.decode(result.ttl)),
180
+ swrMs: result.swr ? Duration.toMillis(Duration.decode(result.swr)) : 0,
181
+ };
182
+ }
183
+ return { value: result, ttlMs: defaultTtlMs, swrMs: defaultSwrMs };
184
+ }
@@ -1,6 +1,6 @@
1
1
  import * as cli from '@effect/cli';
2
2
  import * as platform from '@effect/platform';
3
- import { Effect, Option, Cause } from 'effect';
3
+ import { type Cause, Effect, Option } from 'effect';
4
4
  import inquirer from 'inquirer';
5
5
  import postgres from 'postgres';
6
6
  import { loadConfig } from '../../config/index.js';
@@ -35,10 +35,7 @@ const parsePostgresUrl = (url: string): DatabaseInfo => {
35
35
  };
36
36
  };
37
37
 
38
- const confirmDatabaseUrls = (
39
- sourceUrl: string | null,
40
- targetUrl: string,
41
- ) =>
38
+ const confirmDatabaseUrls = (sourceUrl: string | null, targetUrl: string) =>
42
39
  Effect.gen(function* () {
43
40
  const target = parsePostgresUrl(targetUrl);
44
41
 
@@ -170,9 +167,7 @@ const truncateAllTables = (
170
167
  `Truncating ${tables.length} table(s) in schema "${schema}"...`,
171
168
  );
172
169
 
173
- const tableNames = tables
174
- .map((t) => `"${schema}"."${t}"`)
175
- .join(', ');
170
+ const tableNames = tables.map((t) => `"${schema}"."${t}"`).join(', ');
176
171
 
177
172
  yield* Effect.tryPromise(() =>
178
173
  conn.unsafe(`TRUNCATE ${tableNames} RESTART IDENTITY CASCADE`),
@@ -282,10 +277,7 @@ export const pullCommand = cli.Command.make(
282
277
 
283
278
  const sourceUrl = yield* getDatabaseUrlFromSource(source);
284
279
 
285
- const urlsConfirmed = yield* confirmDatabaseUrls(
286
- sourceUrl,
287
- targetUrl,
288
- );
280
+ const urlsConfirmed = yield* confirmDatabaseUrls(sourceUrl, targetUrl);
289
281
  if (!urlsConfirmed) {
290
282
  yield* Effect.log('Operation cancelled');
291
283
  return;
@@ -321,9 +313,7 @@ export const pullCommand = cli.Command.make(
321
313
  );
322
314
 
323
315
  if (!Option.isSome(fromDump)) {
324
- const shouldCleanup = yield* promptCleanupDump(
325
- dumpState.filePath,
326
- );
316
+ const shouldCleanup = yield* promptCleanupDump(dumpState.filePath);
327
317
  if (shouldCleanup) {
328
318
  const fs = yield* platform.FileSystem.FileSystem;
329
319
  yield* fs.remove(dumpState.filePath, { recursive: true });