Skip to content

Commit e59c031

Browse files
test(store): demonstrate native claim composition (#799)
* feat(store): native tryClaim fast path for redis and upstash via setNx Store.tryClaim falls back to a read-modify-write over `update` when an adapter has no native primitive. On redis/upstash that fallback needs WATCH/MULTI (and Upstash REST has no WATCH at all), while a replay claim is really just one atomic set-if-absent with an expiry. Add an optional `setNx(key, value, expires)` to the redis and upstash Parameters. When present, the adapter exposes a native tryClaim that maps to `SET ... PXAT <expires> NX` (or upstash `set(key, value, { nx: true, pxat })`). `expires` is an absolute Unix-ms timestamp, so it maps to PXAT/pxat, never a relative PX. Existing callers passing `{ get, set, del, update? }` are unaffected; the key-prefix wrapper already forwards a native tryClaim. * test(store): cover native claim composition * chore: add empty changeset * chore: format empty changeset --------- Co-authored-by: EfeDurmaz16 <efebarandurmaz05@gmail.com>
1 parent dad7286 commit e59c031

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

.changeset/clean-stores-compose.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

src/Store.test-d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ test('from with keyPrefix preserves AtomicStore', () => {
3535
expectTypeOf(store).toEqualTypeOf<Store.AtomicStore>()
3636
})
3737

38+
test('from preserves a composed tryClaim implementation', () => {
39+
const adapter = Store.redis({
40+
get: async () => null,
41+
set: async () => null,
42+
del: async () => null,
43+
update: async (_key, fn) => fn(null).result,
44+
})
45+
const store = Store.from({
46+
...adapter,
47+
tryClaim: async (_key: string, _expires: number) => true,
48+
})
49+
50+
expectTypeOf(store.tryClaim).toEqualTypeOf<(key: string, expires: number) => Promise<boolean>>()
51+
expectTypeOf(Store.tryClaim(store, 'k', 1)).toEqualTypeOf<boolean | Promise<boolean>>()
52+
})
53+
3854
test('from with keyPrefix preserves typed Store', () => {
3955
type ItemMap = { [key: `mppx:charge:${string}`]: number }
4056
const store = {} as Store.Store<ItemMap>

src/Store.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ function fakeUnknownKv() {
6060
}
6161
}
6262

63+
function fakeTryClaim(seen?: string[]) {
64+
const claimed = new Set<string>()
65+
return async (key: string, _expires: number) => {
66+
seen?.push(key)
67+
if (claimed.has(key)) return false
68+
claimed.add(key)
69+
return true
70+
}
71+
}
72+
6373
describe.each([
6474
{ label: 'memory', create: () => Store.memory() },
6575
{ label: 'cloudflare', create: () => Store.cloudflare(fakeStringKv()) },
@@ -237,6 +247,59 @@ describe('tryClaim', () => {
237247

238248
expect(await Store.tryClaim(store, 'k', Date.now() + 60_000)).toBe(false)
239249
})
250+
251+
test('composes a Redis-native claim with the adapter', async () => {
252+
const kv = fakeStringKv()
253+
let updateCalls = 0
254+
const store = Store.from({
255+
...Store.redis({
256+
get: kv.get,
257+
set: kv.put,
258+
del: kv.delete,
259+
update: (key, fn) => {
260+
updateCalls++
261+
return kv.update(key, fn)
262+
},
263+
}),
264+
tryClaim: fakeTryClaim(),
265+
})
266+
const expires = Date.now() + 60_000
267+
268+
expect(await Store.tryClaim(store, 'k', expires)).toBe(true)
269+
expect(await Store.tryClaim(store, 'k', expires)).toBe(false)
270+
expect(updateCalls).toBe(0)
271+
})
272+
273+
test('composes an Upstash-native claim with the adapter', async () => {
274+
const store = Store.from({
275+
...Store.upstash(fakeUnknownKv()),
276+
tryClaim: fakeTryClaim(),
277+
})
278+
const expires = Date.now() + 60_000
279+
280+
expect(await Store.tryClaim(store, 'k', expires)).toBe(true)
281+
expect(await Store.tryClaim(store, 'k', expires)).toBe(false)
282+
})
283+
284+
test('prefixes a composed native claim', async () => {
285+
const kv = fakeStringKv()
286+
const seen: string[] = []
287+
const store = Store.from(
288+
{
289+
...Store.redis({
290+
get: kv.get,
291+
set: kv.put,
292+
del: kv.delete,
293+
update: kv.update,
294+
}),
295+
tryClaim: fakeTryClaim(seen),
296+
},
297+
{ keyPrefix: 'p:' },
298+
)
299+
300+
expect(await Store.tryClaim(store, 'k', Date.now() + 60_000)).toBe(true)
301+
expect(seen).toEqual(['p:k'])
302+
})
240303
})
241304

242305
describe('json roundtrip behavior', () => {

0 commit comments

Comments
 (0)