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.
- package/dist/adapter-DLhjFlOu.d.cts +31 -0
- package/dist/adapter-DLhjFlOu.d.ts +31 -0
- package/dist/cli.js +47 -47
- package/dist/exports/cache-bun-redis.cjs +50 -0
- package/dist/exports/cache-bun-redis.cjs.map +1 -0
- package/dist/exports/cache-bun-redis.d.cts +11 -0
- package/dist/exports/cache-bun-redis.d.ts +11 -0
- package/dist/exports/cache-bun-redis.js +23 -0
- package/dist/exports/cache-bun-redis.js.map +1 -0
- package/dist/exports/cache-ioredis.cjs +51 -0
- package/dist/exports/cache-ioredis.cjs.map +1 -0
- package/dist/exports/cache-ioredis.d.cts +11 -0
- package/dist/exports/cache-ioredis.d.ts +11 -0
- package/dist/exports/cache-ioredis.js +24 -0
- package/dist/exports/cache-ioredis.js.map +1 -0
- package/dist/exports/cache.cjs +220 -0
- package/dist/exports/cache.cjs.map +1 -0
- package/dist/exports/cache.d.cts +30 -0
- package/dist/exports/cache.d.ts +30 -0
- package/dist/exports/cache.js +199 -0
- package/dist/exports/cache.js.map +1 -0
- package/dist/exports/orpc.cjs +15 -10
- package/dist/exports/orpc.cjs.map +1 -1
- package/dist/exports/orpc.js +16 -11
- package/dist/exports/orpc.js.map +1 -1
- package/dist/index.cjs +15 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -7
- package/dist/index.d.ts +10 -7
- package/dist/index.js +17 -12
- package/dist/index.js.map +1 -1
- package/package.json +26 -2
- package/src/cache/AGENTS.md +8 -0
- package/src/cache/adapter.test.ts +181 -0
- package/src/cache/adapter.ts +120 -0
- package/src/cache/adapters/bun-redis.ts +29 -0
- package/src/cache/adapters/ioredis.ts +30 -0
- package/src/cache/cache.test.ts +461 -0
- package/src/cache/cache.ts +184 -0
- package/src/cli/commands/db/pull.ts +5 -15
- package/src/cli/commands/db/shared.ts +7 -7
- package/src/cli/config/index.ts +1 -3
- package/src/cli/index.ts +1 -1
- package/src/cli/utils/database-source.ts +1 -4
- package/src/exports/cache-bun-redis.ts +1 -0
- package/src/exports/cache-ioredis.ts +1 -0
- package/src/exports/cache.ts +6 -0
- package/src/exports/orpc.ts +1 -1
- package/src/http/fetch-handler.test.ts +1 -1
- package/src/index.ts +1 -0
- package/src/logger.test.ts +168 -0
- 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
|
|
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 });
|