queen-mq 1.0.0 → 1.0.3
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 +175 -0
- package/client-v2/Queen.js +54 -0
- package/client-v2/README.md +32 -0
- package/client-v2/builders/QueueBuilder.js +8 -16
- package/client-v2/builders/TimerBuilder.js +262 -0
- package/client-v2/builders/TransactionBuilder.js +185 -8
- package/client-v2/kv/Kv.js +432 -0
- package/client-v2/kv/expiry.js +148 -0
- package/package.json +11 -3
- package/test-v2/_kvtimers.js +71 -0
- package/test-v2/ackwindow.js +10 -3
- package/test-v2/consume.js +0 -2
- package/test-v2/docs.js +204 -0
- package/test-v2/http-unit/retry429.test.js +6 -1
- package/test-v2/kv-unit/_planServer.js +65 -0
- package/test-v2/kv-unit/kvWire.test.js +377 -0
- package/test-v2/kv-unit/timerWire.test.js +177 -0
- package/test-v2/kv-unit/txnWire.test.js +222 -0
- package/test-v2/kv.js +273 -0
- package/test-v2/pop.js +0 -2
- package/test-v2/push.js +0 -4
- package/test-v2/run.js +34 -4
- package/test-v2/semantics.js +16 -7
- package/test-v2/stream/_helpers.js +7 -0
- package/test-v2/stream/combined.js +4 -3
- package/test-v2/stream/cron.js +1 -1
- package/test-v2/stream/eventTime.js +8 -5
- package/test-v2/stream/operators.js +5 -5
- package/test-v2/stream/recovery.js +4 -1
- package/test-v2/stream/session.js +3 -3
- package/test-v2/stream/sliding.js +1 -1
- package/test-v2/stream/throughput.js +3 -2
- package/test-v2/stream/tumbling.js +6 -6
- package/test-v2/timers.js +209 -0
- package/test-v2/transaction.js +4 -3
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KV wire contract (PLAN_KV_TIMERS.md §5, §6.1, §8.1).
|
|
3
|
+
*
|
|
4
|
+
* These tests assert the EXACT JSON body of every KV operation against a
|
|
5
|
+
* scripted plan server. They are the only thing that catches a wrong wire
|
|
6
|
+
* shape before production: the broker's stored procedure has a closed
|
|
7
|
+
* taxonomy, so a misspelled field is not a compile error anywhere, it is a
|
|
8
|
+
* 400 on somebody's first idempotency marker.
|
|
9
|
+
*
|
|
10
|
+
* deepEqual, not "contains": an EXTRA field is as much a contract break as a
|
|
11
|
+
* missing one. `expect` sent as `null`, a `ttlMillis` invented by a client, a
|
|
12
|
+
* `tenant` echoed from a caller -- all of them are refused by the broker, and
|
|
13
|
+
* all of them would ship silently if these assertions were subset checks.
|
|
14
|
+
*
|
|
15
|
+
* The rules pinned here that live in the CLIENT and nowhere else:
|
|
16
|
+
* * ttlSeconds is canonical; `ttl` (a duration STRING) and `until` (an
|
|
17
|
+
* instant) are sugar converted at SEND time, rounded UP to the second
|
|
18
|
+
* (§20.1: a TTL rounded down can expire a marker before the window it had
|
|
19
|
+
* to cover).
|
|
20
|
+
* * an explicitly undefined/null `expect` is a client-side bug, never a
|
|
21
|
+
* silent downgrade to upsert (§5.3).
|
|
22
|
+
* * `get` returns the ROW: `found` is separate from `value`, because
|
|
23
|
+
* 'null'::jsonb is a legal value and {found:true,value:null} and
|
|
24
|
+
* {found:false} are different things (§5.5).
|
|
25
|
+
* * every write returns a WriteResult object, which is ALWAYS truthy --
|
|
26
|
+
* `if (await kv.delete(...))` is always true, so `applied` is the field
|
|
27
|
+
* (§10.4).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it } from 'node:test'
|
|
31
|
+
import assert from 'node:assert/strict'
|
|
32
|
+
|
|
33
|
+
import { Queen } from '../../client-v2/index.js'
|
|
34
|
+
import { withPlanServer, kvResults } from './_planServer.js'
|
|
35
|
+
|
|
36
|
+
const NS = 'test-kv-ns'
|
|
37
|
+
|
|
38
|
+
/** Run `fn(kv, hits)` against a plan server, closing the client afterwards. */
|
|
39
|
+
async function withKv(plan, run, clientOptions = {}) {
|
|
40
|
+
await withPlanServer(plan, kvResults({ index: 0, op: 'get', found: false, key: 'k' }), async (url, hits) => {
|
|
41
|
+
const queen = new Queen({ url, handleSignals: false, ...clientOptions })
|
|
42
|
+
try {
|
|
43
|
+
await run(queen.kv, hits, queen)
|
|
44
|
+
} finally {
|
|
45
|
+
await queen.close()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The single-op body every path in this file must produce. */
|
|
51
|
+
function oneOp(op) {
|
|
52
|
+
return { operations: [op] }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('KV wire — reads', () => {
|
|
56
|
+
it('get sends one `get` op to POST /api/v1/kv and returns the row', async () => {
|
|
57
|
+
const row = { index: 0, op: 'get', found: true, key: 'k', value: { a: 1 }, version: 91, expiresAt: '2026-08-18T00:00:00.000000Z', updatedAt: '2026-08-17T00:00:00.000000Z' }
|
|
58
|
+
await withKv([kvResults(row)], async (kv, hits) => {
|
|
59
|
+
const got = await kv.get(NS, 'k')
|
|
60
|
+
|
|
61
|
+
assert.equal(hits.length, 1)
|
|
62
|
+
assert.equal(hits[0].method, 'POST')
|
|
63
|
+
assert.equal(hits[0].url, '/api/v1/kv')
|
|
64
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'get', ns: NS, key: 'k' }))
|
|
65
|
+
|
|
66
|
+
assert.equal(got.found, true)
|
|
67
|
+
assert.deepEqual(got.value, { a: 1 })
|
|
68
|
+
assert.equal(got.version, 91)
|
|
69
|
+
assert.equal(got.expiresAt, '2026-08-18T00:00:00.000000Z')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('get never collapses {found:true,value:null} into a miss', async () => {
|
|
74
|
+
await withKv([kvResults({ index: 0, op: 'get', found: true, key: 'k', value: null, version: 5 })], async (kv) => {
|
|
75
|
+
const got = await kv.get(NS, 'k')
|
|
76
|
+
assert.equal(got.found, true, 'a JSON null is a legal VALUE, not an absence')
|
|
77
|
+
assert.equal(got.value, null)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('get reports a miss as found:false with no value', async () => {
|
|
82
|
+
await withKv([kvResults({ index: 0, op: 'get', found: false, key: 'k' })], async (kv) => {
|
|
83
|
+
const got = await kv.get(NS, 'k')
|
|
84
|
+
assert.equal(got.found, false)
|
|
85
|
+
assert.equal(got.value, undefined)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('getMany sends the keys array and returns rows plus explicit missing', async () => {
|
|
90
|
+
const answer = { index: 0, op: 'getMany', rows: [{ key: 'a', value: 1, version: 2 }], missing: ['b'], truncated: false }
|
|
91
|
+
await withKv([kvResults(answer)], async (kv, hits) => {
|
|
92
|
+
const res = await kv.getMany(NS, ['a', 'b'])
|
|
93
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'getMany', ns: NS, keys: ['a', 'b'] }))
|
|
94
|
+
assert.deepEqual(res.missing, ['b'], 'absence is a datum, never computed by difference')
|
|
95
|
+
assert.equal(res.rows.length, 1)
|
|
96
|
+
assert.equal(res.truncated, false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('getPrefix goes in the POST body — never a query string', async () => {
|
|
101
|
+
const answer = { index: 0, op: 'getPrefix', rows: [], truncated: false, nextAfter: null }
|
|
102
|
+
await withKv([kvResults(answer)], async (kv, hits) => {
|
|
103
|
+
await kv.getPrefix(NS, 'quota:acme:', { limit: 50, after: 'quota:acme:a', keysOnly: true })
|
|
104
|
+
assert.equal(hits[0].url, '/api/v1/kv', 'a prefix in a URL is recorded by every access log on the way in (§5.5)')
|
|
105
|
+
assert.deepEqual(hits[0].body, oneOp({
|
|
106
|
+
op: 'getPrefix', ns: NS, prefix: 'quota:acme:', limit: 50, after: 'quota:acme:a', keysOnly: true
|
|
107
|
+
}))
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('getPrefix omits the optional fields it was not given', async () => {
|
|
112
|
+
const answer = { index: 0, op: 'getPrefix', rows: [], truncated: false, nextAfter: null }
|
|
113
|
+
await withKv([kvResults(answer)], async (kv, hits) => {
|
|
114
|
+
await kv.getPrefix(NS, 'p')
|
|
115
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'getPrefix', ns: NS, prefix: 'p' }))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('listAll walks the keyset cursor and stops when the page is not truncated', async () => {
|
|
120
|
+
const page1 = kvResults({ index: 0, op: 'getPrefix', rows: [{ key: 'p:1', value: 1 }, { key: 'p:2', value: 2 }], truncated: true, nextAfter: 'p:2' })
|
|
121
|
+
const page2 = kvResults({ index: 0, op: 'getPrefix', rows: [{ key: 'p:3', value: 3 }], truncated: false, nextAfter: null })
|
|
122
|
+
await withKv([page1, page2], async (kv, hits) => {
|
|
123
|
+
const keys = []
|
|
124
|
+
for await (const row of kv.listAll(NS, 'p:')) keys.push(row.key)
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(keys, ['p:1', 'p:2', 'p:3'])
|
|
127
|
+
assert.equal(hits.length, 2, 'exactly one call per page')
|
|
128
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'getPrefix', ns: NS, prefix: 'p:' }))
|
|
129
|
+
assert.deepEqual(hits[1].body, oneOp({ op: 'getPrefix', ns: NS, prefix: 'p:', after: 'p:2' }),
|
|
130
|
+
'the second page carries the exclusive cursor, not an offset')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('KV wire — writes and expiry', () => {
|
|
136
|
+
const applied = (op, extra = {}) => kvResults({ index: 0, op, applied: true, key: 'k', value: { v: 1 }, version: 12, ...extra })
|
|
137
|
+
|
|
138
|
+
it('put sends ttlSeconds verbatim', async () => {
|
|
139
|
+
await withKv([applied('put')], async (kv, hits) => {
|
|
140
|
+
const res = await kv.put(NS, 'k', { v: 1 }, { ttlSeconds: 3600 })
|
|
141
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'put', ns: NS, key: 'k', value: { v: 1 }, ttlSeconds: 3600 }))
|
|
142
|
+
assert.equal(res.applied, true)
|
|
143
|
+
assert.equal(res.version, 12)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('put converts the `ttl` duration string to whole seconds, rounding UP', async () => {
|
|
148
|
+
await withKv([applied('put'), applied('put'), applied('put')], async (kv, hits) => {
|
|
149
|
+
await kv.put(NS, 'k', 1, { ttl: '24h' })
|
|
150
|
+
await kv.put(NS, 'k', 1, { ttl: '1h30m' })
|
|
151
|
+
await kv.put(NS, 'k', 1, { ttl: '1500ms' })
|
|
152
|
+
assert.equal(hits[0].body.operations[0].ttlSeconds, 86400)
|
|
153
|
+
assert.equal(hits[1].body.operations[0].ttlSeconds, 5400)
|
|
154
|
+
assert.equal(hits[2].body.operations[0].ttlSeconds, 2, 'rounded UP: a TTL rounded down expires before the window it had to cover')
|
|
155
|
+
for (const h of hits) {
|
|
156
|
+
assert.ok(!('ttl' in h.body.operations[0]), 'the sugar never reaches the wire')
|
|
157
|
+
assert.ok(!('ttlMillis' in h.body.operations[0]), 'ttlMillis does not exist in this product (§20.1)')
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('put converts `until` into a delta at SEND time, rounding UP', async () => {
|
|
163
|
+
await withKv([applied('put')], async (kv, hits) => {
|
|
164
|
+
await kv.put(NS, 'k', 1, { until: new Date(Date.now() + 10_500) })
|
|
165
|
+
const sent = hits[0].body.operations[0]
|
|
166
|
+
assert.ok(!('until' in sent), 'an absolute instant is never on the wire')
|
|
167
|
+
assert.ok(sent.ttlSeconds === 11 || sent.ttlSeconds === 10, `unexpected ttlSeconds ${sent.ttlSeconds}`)
|
|
168
|
+
assert.ok(sent.ttlSeconds >= 10)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('put rejects an `until` already in the past instead of sending a dead TTL', async () => {
|
|
173
|
+
await withKv([], async (kv, hits) => {
|
|
174
|
+
await assert.rejects(
|
|
175
|
+
() => kv.put(NS, 'k', 1, { until: new Date(Date.now() - 1000) }),
|
|
176
|
+
(e) => { assert.match(e.message, /until/); return true }
|
|
177
|
+
)
|
|
178
|
+
assert.equal(hits.length, 0, 'nothing may reach the broker')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('put sends forever:true when asked, and refuses forever:false', async () => {
|
|
183
|
+
await withKv([applied('put')], async (kv, hits) => {
|
|
184
|
+
await kv.put(NS, 'k', 1, { forever: true })
|
|
185
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'put', ns: NS, key: 'k', value: 1, forever: true }))
|
|
186
|
+
await assert.rejects(() => kv.put(NS, 'k', 1, { forever: false }))
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('put refuses two spellings of the same expiry rather than picking one', async () => {
|
|
191
|
+
await withKv([], async (kv, hits) => {
|
|
192
|
+
await assert.rejects(() => kv.put(NS, 'k', 1, { ttlSeconds: 60, ttl: '1m' }))
|
|
193
|
+
await assert.rejects(() => kv.put(NS, 'k', 1, { ttlSeconds: 60, until: new Date(Date.now() + 1000) }))
|
|
194
|
+
assert.equal(hits.length, 0)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('put with no expiry at all still goes to the broker, which owns the rule', async () => {
|
|
199
|
+
// §5.1: the "exactly one of ttlSeconds and forever" rule lives in
|
|
200
|
+
// kv_apply_v1 so that all seven clients and the embedded broker inherit
|
|
201
|
+
// it. The SDK must not re-implement it, or the two can disagree.
|
|
202
|
+
await withKv([kvResults({ index: 0, op: 'put', applied: false })], async (kv, hits) => {
|
|
203
|
+
await kv.put(NS, 'k', 1, {})
|
|
204
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'put', ns: NS, key: 'k', value: 1 }))
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('put carries expect and required exactly as given', async () => {
|
|
209
|
+
await withKv([applied('put'), applied('put')], async (kv, hits) => {
|
|
210
|
+
await kv.put(NS, 'k', 1, { ttlSeconds: 60, expect: 90101 })
|
|
211
|
+
await kv.put(NS, 'k', 1, { ttlSeconds: 60, expect: 0, required: true })
|
|
212
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'put', ns: NS, key: 'k', value: 1, ttlSeconds: 60, expect: 90101 }))
|
|
213
|
+
assert.deepEqual(hits[1].body, oneOp({ op: 'put', ns: NS, key: 'k', value: 1, ttlSeconds: 60, expect: 0, required: true }))
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('an explicitly undefined or null expect is a client-side bug, not an upsert', async () => {
|
|
218
|
+
await withKv([], async (kv, hits) => {
|
|
219
|
+
await assert.rejects(() => kv.put(NS, 'k', 1, { ttlSeconds: 60, expect: undefined }))
|
|
220
|
+
await assert.rejects(() => kv.put(NS, 'k', 1, { ttlSeconds: 60, expect: null }))
|
|
221
|
+
assert.equal(hits.length, 0, 'a silent downgrade to upsert is the failure this rule exists to prevent')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('putIfAbsent has its own op name and refuses a contradictory expect', async () => {
|
|
226
|
+
await withKv([applied('put')], async (kv, hits) => {
|
|
227
|
+
await kv.putIfAbsent(NS, 'k', { v: 1 }, { ttlSeconds: 60 })
|
|
228
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'putIfAbsent', ns: NS, key: 'k', value: { v: 1 }, ttlSeconds: 60 }))
|
|
229
|
+
await assert.rejects(() => kv.putIfAbsent(NS, 'k', 1, { ttlSeconds: 60, expect: 7 }))
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('putIfAbsent hands the loser the winner value and version, with applied:false', async () => {
|
|
234
|
+
const loser = kvResults({ index: 0, op: 'put', applied: false, reason: 'exists', key: 'k', value: { owner: 'a' }, version: 90101 })
|
|
235
|
+
await withKv([loser], async (kv) => {
|
|
236
|
+
const res = await kv.putIfAbsent(NS, 'k', { owner: 'b' }, { ttlSeconds: 60 })
|
|
237
|
+
assert.equal(res.applied, false)
|
|
238
|
+
assert.equal(res.reason, 'exists')
|
|
239
|
+
assert.deepEqual(res.value, { owner: 'a' }, 'the loser must not need a second round trip')
|
|
240
|
+
assert.equal(res.version, 90101)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('delete returns a WriteResult and carries an optional expect', async () => {
|
|
245
|
+
const gone = kvResults({ index: 0, op: 'delete', applied: true, key: 'k', value: null, version: 13 })
|
|
246
|
+
await withKv([gone, gone], async (kv, hits) => {
|
|
247
|
+
const res = await kv.delete(NS, 'k')
|
|
248
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'delete', ns: NS, key: 'k' }))
|
|
249
|
+
assert.equal(res.applied, true)
|
|
250
|
+
assert.equal(typeof res, 'object', 'a WriteResult is ALWAYS truthy: `if (await kv.delete(...))` is a bug')
|
|
251
|
+
|
|
252
|
+
await kv.delete(NS, 'k', { expect: 13, required: true })
|
|
253
|
+
assert.deepEqual(hits[1].body, oneOp({ op: 'delete', ns: NS, key: 'k', expect: 13, required: true }))
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('incr sends delta, min and max, and defaults delta to 1', async () => {
|
|
258
|
+
const bumped = kvResults({ index: 0, op: 'incr', applied: true, key: 'k', value: 3, version: 4 })
|
|
259
|
+
await withKv([bumped, bumped], async (kv, hits) => {
|
|
260
|
+
await kv.incr(NS, 'k', 2, { ttlSeconds: 60, max: 100, min: 0 })
|
|
261
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'incr', ns: NS, key: 'k', delta: 2, ttlSeconds: 60, min: 0, max: 100 }))
|
|
262
|
+
|
|
263
|
+
await kv.incr(NS, 'k', undefined, { ttl: '1m' })
|
|
264
|
+
assert.deepEqual(hits[1].body, oneOp({ op: 'incr', ns: NS, key: 'k', delta: 1, ttlSeconds: 60 }))
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('incr refuses an expect instead of letting the broker raise', async () => {
|
|
269
|
+
await withKv([], async (kv, hits) => {
|
|
270
|
+
await assert.rejects(() => kv.incr(NS, 'k', 1, { ttlSeconds: 60, expect: 3 }))
|
|
271
|
+
assert.equal(hits.length, 0)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('incr raises rather than hand back a counter JSON.parse already broke', async () => {
|
|
276
|
+
// §5.4: past 2^53 JSON.parse loses precision SILENTLY. A rate limiter that
|
|
277
|
+
// reads a wrong count is worse than one that fails.
|
|
278
|
+
const huge = kvResults({ index: 0, op: 'incr', applied: true, key: 'k', value: 9007199254740993, version: 4 })
|
|
279
|
+
await withKv([huge], async (kv) => {
|
|
280
|
+
await assert.rejects(() => kv.incr(NS, 'k', 1, { ttlSeconds: 60 }), (e) => {
|
|
281
|
+
assert.match(e.message, /precision|safe integer/i)
|
|
282
|
+
return true
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('incr with max reports the refusal as applied:false — the admission decision', async () => {
|
|
288
|
+
const refused = kvResults({ index: 0, op: 'incr', applied: false, reason: 'limit', key: 'k', value: 100, version: 9 })
|
|
289
|
+
await withKv([refused], async (kv) => {
|
|
290
|
+
const res = await kv.incr(NS, 'k', 1, { ttlSeconds: 60, max: 100 })
|
|
291
|
+
assert.equal(res.applied, false, 'with max, `applied` IS the admission decision')
|
|
292
|
+
assert.equal(res.reason, 'limit')
|
|
293
|
+
assert.equal(res.value, 100, 'the current value, never the would-be one')
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('once is putIfAbsent with a default marker value, and reports who won', async () => {
|
|
298
|
+
const won = kvResults({ index: 0, op: 'put', applied: true, key: 'k', value: true, version: 1 })
|
|
299
|
+
const lost = kvResults({ index: 0, op: 'put', applied: false, reason: 'exists', key: 'k', value: true, version: 1 })
|
|
300
|
+
await withKv([won, lost], async (kv, hits) => {
|
|
301
|
+
const first = await kv.once(NS, 'k', { ttlSeconds: 3600 })
|
|
302
|
+
assert.deepEqual(hits[0].body, oneOp({ op: 'putIfAbsent', ns: NS, key: 'k', value: true, ttlSeconds: 3600 }))
|
|
303
|
+
assert.equal(first.won, true)
|
|
304
|
+
|
|
305
|
+
const second = await kv.once(NS, 'k', { ttlSeconds: 3600 })
|
|
306
|
+
assert.equal(second.won, false)
|
|
307
|
+
assert.equal(second.value, true)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('KV wire — envelopes and errors', () => {
|
|
313
|
+
it('a short results array fails loudly instead of being misattributed', async () => {
|
|
314
|
+
await withKv([{ status: 200, body: { results: [] } }], async (kv) => {
|
|
315
|
+
await assert.rejects(() => kv.get(NS, 'k'), (e) => {
|
|
316
|
+
assert.match(e.message, /result/i)
|
|
317
|
+
return true
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('a lost `required` precondition comes back as a verdict, not a throw', async () => {
|
|
323
|
+
// §8.3: HTTP 200 with ok:false. It is the expected outcome of every
|
|
324
|
+
// legitimate redelivery and must not enter a retry policy.
|
|
325
|
+
const verdict = {
|
|
326
|
+
status: 200,
|
|
327
|
+
body: { ok: false, reason: 'kv_precondition', failedIndex: 0, kvReason: 'exists', version: 90101, value: { owner: 'a' } }
|
|
328
|
+
}
|
|
329
|
+
await withKv([verdict], async (kv) => {
|
|
330
|
+
const res = await kv.putIfAbsent(NS, 'k', 1, { ttlSeconds: 60, required: true })
|
|
331
|
+
assert.equal(res.applied, false)
|
|
332
|
+
assert.equal(res.precondition, true)
|
|
333
|
+
assert.equal(res.reason, 'exists')
|
|
334
|
+
assert.deepEqual(res.value, { owner: 'a' })
|
|
335
|
+
assert.equal(res.version, 90101)
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('the KV error envelope reaches the caller as `.code`, never as prose to match', async () => {
|
|
340
|
+
// The KV routes put the CODE in `error` (§9.5's closed taxonomy); the HTTP
|
|
341
|
+
// layer maps `body.error` onto `.message`. The SDK restores it into
|
|
342
|
+
// `.code` so callers branch on a code here exactly as they do on a proxy
|
|
343
|
+
// 403, and never on a message.
|
|
344
|
+
//
|
|
345
|
+
// The fixture used to be `404 {error:'not_found', reason:'kv_not_enabled'}`,
|
|
346
|
+
// the answer of a cell built without the KV boot flag. That flag is gone and
|
|
347
|
+
// nothing in the broker can answer 404 for a KV surface any more, so the
|
|
348
|
+
// fixture is the one refusal that IS still reachable: the operator's runtime
|
|
349
|
+
// kill switch, 503 + Retry-After. Handling it is not feature detection --
|
|
350
|
+
// it is a paused surface that will come back, and note that the client
|
|
351
|
+
// treats it as exactly that: with the default `retryAttempts: 3` this 503 is
|
|
352
|
+
// retried with backoff like any other 5xx. `retryAttempts: 1` here so the
|
|
353
|
+
// plan is one entry and the assertion is about the envelope, not the policy.
|
|
354
|
+
const paused = {
|
|
355
|
+
status: 503,
|
|
356
|
+
headers: { 'retry-after': '1' },
|
|
357
|
+
body: { error: 'kv_disabled', reason: 'kv_disabled' }
|
|
358
|
+
}
|
|
359
|
+
await withKv([paused], async (kv, hits) => {
|
|
360
|
+
await assert.rejects(() => kv.get(NS, 'k'), (e) => {
|
|
361
|
+
assert.equal(e.status, 503)
|
|
362
|
+
assert.equal(e.code, 'kv_disabled')
|
|
363
|
+
return true
|
|
364
|
+
})
|
|
365
|
+
assert.equal(hits.length, 1)
|
|
366
|
+
}, { retryAttempts: 1 })
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('refuses a missing namespace or key before spending a request', async () => {
|
|
370
|
+
await withKv([], async (kv, hits) => {
|
|
371
|
+
await assert.rejects(() => kv.get('', 'k'))
|
|
372
|
+
await assert.rejects(() => kv.get(NS, ''))
|
|
373
|
+
await assert.rejects(() => kv.put(NS, undefined, 1, { ttlSeconds: 1 }))
|
|
374
|
+
assert.equal(hits.length, 0)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timer wire contract (PLAN_KV_TIMERS.md §4, §6.2, §8.1, §9.6).
|
|
3
|
+
*
|
|
4
|
+
* What these tests exist to pin, in order of how much a mistake costs:
|
|
5
|
+
*
|
|
6
|
+
* 1. CANCEL GOES TO ITS OWN ROUTE. `DELETE /api/v1/timers/:queue/*timerKey`
|
|
7
|
+
* is the one route the proxy is forbidden to block (§9.6). A cancel sent
|
|
8
|
+
* inside `POST /api/v1/timers` inherits the schedule's authorization, so
|
|
9
|
+
* an SDK that "simplified" the two into one call would 403 the cancels of
|
|
10
|
+
* a tenant that is over quota -- while the fire never stops by itself, so
|
|
11
|
+
* that tenant keeps producing messages it cannot stop.
|
|
12
|
+
* 2. delayMs, never delaySeconds and never an absolute instant (§4.2,
|
|
13
|
+
* §20.6). One clock, Postgres's. `deliverAt` in an op is a 22023.
|
|
14
|
+
* 3. The payload is base64 (§6.2), and the SDK encodes it, so a caller never
|
|
15
|
+
* has to know.
|
|
16
|
+
* 4. `txn` is mandatory on a schedule and is echoed on `absent`, which is
|
|
17
|
+
* what makes "was it already delivered?" answerable without a second API
|
|
18
|
+
* (§4.4, §20.2).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it } from 'node:test'
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
|
|
24
|
+
import { Queen } from '../../client-v2/index.js'
|
|
25
|
+
import { withPlanServer, ok } from './_planServer.js'
|
|
26
|
+
|
|
27
|
+
const QUEUE = 'test-timers-q'
|
|
28
|
+
|
|
29
|
+
async function withQueen(plan, run) {
|
|
30
|
+
await withPlanServer(plan, ok({ results: [{ ok: true, status: 'scheduled' }] }), async (url, hits) => {
|
|
31
|
+
const queen = new Queen({ url, handleSignals: false })
|
|
32
|
+
try {
|
|
33
|
+
await run(queen, hits)
|
|
34
|
+
} finally {
|
|
35
|
+
await queen.close()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scheduled = (extra = {}) => ok({
|
|
41
|
+
results: [{ ok: true, status: 'scheduled', queue: QUEUE, timerKey: 'k', txn: 't', messageId: 'm', deliverAt: '2026-08-17T00:00:30.000000Z', ...extra }]
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const b64 = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64')
|
|
45
|
+
|
|
46
|
+
describe('Timer wire — schedule', () => {
|
|
47
|
+
it('sends one schedule op with delayMs and a base64 payload', async () => {
|
|
48
|
+
await withQueen([scheduled()], async (queen, hits) => {
|
|
49
|
+
const res = await queen.timer(QUEUE)
|
|
50
|
+
.key('order-9f1')
|
|
51
|
+
.delayMs(30_000)
|
|
52
|
+
.payload({ orderId: '9f1' })
|
|
53
|
+
.txn('11111111-1111-7111-8111-111111111111')
|
|
54
|
+
.schedule()
|
|
55
|
+
|
|
56
|
+
assert.equal(hits[0].method, 'POST')
|
|
57
|
+
assert.equal(hits[0].url, '/api/v1/timers')
|
|
58
|
+
assert.deepEqual(hits[0].body, {
|
|
59
|
+
operations: [{
|
|
60
|
+
op: 'schedule',
|
|
61
|
+
queue: QUEUE,
|
|
62
|
+
timerKey: 'order-9f1',
|
|
63
|
+
delayMs: 30000,
|
|
64
|
+
txn: '11111111-1111-7111-8111-111111111111',
|
|
65
|
+
payload: b64({ orderId: '9f1' })
|
|
66
|
+
}]
|
|
67
|
+
})
|
|
68
|
+
assert.equal(res.status, 'scheduled')
|
|
69
|
+
assert.equal(res.messageId, 'm')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('mints a txn when the caller does not supply one, and never omits it', async () => {
|
|
74
|
+
await withQueen([scheduled()], async (queen, hits) => {
|
|
75
|
+
await queen.timer(QUEUE).key('k').delayMs(1).payload({}).schedule()
|
|
76
|
+
const op = hits[0].body.operations[0]
|
|
77
|
+
assert.equal(typeof op.txn, 'string')
|
|
78
|
+
assert.ok(op.txn.length >= 32, 'txn is mandatory on a schedule (§20.2)')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('carries an explicit partition and omits it otherwise', async () => {
|
|
83
|
+
await withQueen([scheduled(), scheduled()], async (queen, hits) => {
|
|
84
|
+
await queen.timer(QUEUE).key('k').partition('eu').delayMs(1).payload({}).txn('t').schedule()
|
|
85
|
+
assert.deepEqual(hits[0].body.operations[0], {
|
|
86
|
+
op: 'schedule', queue: QUEUE, timerKey: 'k', partition: 'eu', delayMs: 1, txn: 't', payload: b64({})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await queen.timer(QUEUE).key('k').delayMs(1).payload({}).txn('t').schedule()
|
|
90
|
+
assert.ok(!('partition' in hits[1].body.operations[0]))
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('converts the `delay` duration string to milliseconds', async () => {
|
|
95
|
+
await withQueen([scheduled(), scheduled(), scheduled()], async (queen, hits) => {
|
|
96
|
+
await queen.timer(QUEUE).key('k').delay('250ms').payload({}).txn('t').schedule()
|
|
97
|
+
await queen.timer(QUEUE).key('k').delay('30s').payload({}).txn('t').schedule()
|
|
98
|
+
await queen.timer(QUEUE).key('k').delay('2h').payload({}).txn('t').schedule()
|
|
99
|
+
assert.equal(hits[0].body.operations[0].delayMs, 250, 'a 250ms retry backoff is why this wire is in ms (§20.6)')
|
|
100
|
+
assert.equal(hits[1].body.operations[0].delayMs, 30000)
|
|
101
|
+
assert.equal(hits[2].body.operations[0].delayMs, 7200000)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('sends raw bytes as-is when given a Buffer, and JSON otherwise', async () => {
|
|
106
|
+
await withQueen([scheduled()], async (queen, hits) => {
|
|
107
|
+
await queen.timer(QUEUE).key('k').delayMs(1).payload(Buffer.from([1, 2, 3])).txn('t').schedule()
|
|
108
|
+
assert.equal(hits[0].body.operations[0].payload, Buffer.from([1, 2, 3]).toString('base64'))
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('refuses the server-owned spellings instead of letting the broker 400', async () => {
|
|
113
|
+
await withQueen([], async (queen, hits) => {
|
|
114
|
+
const t = queen.timer(QUEUE).key('k').payload({}).txn('t')
|
|
115
|
+
assert.equal(typeof t.delaySeconds, 'undefined', 'delaySeconds is not expressible on this wire (§20.6)')
|
|
116
|
+
assert.equal(typeof t.at, 'undefined', 'an absolute instant is not expressible either (§4.2)')
|
|
117
|
+
await assert.rejects(() => queen.timer(QUEUE).key('k').payload({}).schedule(),
|
|
118
|
+
(e) => { assert.match(e.message, /delay/i); return true })
|
|
119
|
+
await assert.rejects(() => queen.timer(QUEUE).delayMs(1).payload({}).schedule(),
|
|
120
|
+
(e) => { assert.match(e.message, /key/i); return true })
|
|
121
|
+
await assert.rejects(() => queen.timer(QUEUE).key('k').delayMs(1).schedule(),
|
|
122
|
+
(e) => { assert.match(e.message, /payload/i); return true })
|
|
123
|
+
assert.equal(hits.length, 0)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('Timer wire — cancel, peek, list', () => {
|
|
129
|
+
it('cancel uses DELETE on its OWN route, the one that is never blockable', async () => {
|
|
130
|
+
const answer = ok({ ok: true, status: 'cancelled', queue: QUEUE, timerKey: 'order/9f1', txn: 't' })
|
|
131
|
+
await withQueen([answer], async (queen, hits) => {
|
|
132
|
+
const res = await queen.timer(QUEUE).key('order/9f1').cancel()
|
|
133
|
+
assert.equal(hits[0].method, 'DELETE', 'a cancel inside POST /api/v1/timers inherits the schedule ladder (§9.6)')
|
|
134
|
+
assert.equal(hits[0].url, `/api/v1/timers/${QUEUE}/order%2F9f1`)
|
|
135
|
+
assert.equal(hits[0].raw, '', 'the cancel route carries no body')
|
|
136
|
+
assert.equal(res.status, 'cancelled')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('cancel echoes the expected txn as the one query parameter it reads', async () => {
|
|
141
|
+
const absent = ok({ ok: false, status: 'absent', queue: QUEUE, timerKey: 'k', txn: 'tx-1' })
|
|
142
|
+
await withQueen([absent], async (queen, hits) => {
|
|
143
|
+
const res = await queen.timer(QUEUE).key('k').txn('tx-1').cancel()
|
|
144
|
+
assert.equal(hits[0].url, `/api/v1/timers/${QUEUE}/k?txn=tx-1`)
|
|
145
|
+
assert.equal(res.ok, false, 'absent carries ok:false — it MAY mean already delivered (§4.4)')
|
|
146
|
+
assert.equal(res.status, 'absent')
|
|
147
|
+
assert.equal(res.txn, 'tx-1', 'the txn comes back so the log can be checked without a second API')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('peek reads one key and reports a miss as found:false with HTTP 200', async () => {
|
|
152
|
+
await withQueen([ok({ found: false, queue: QUEUE, timerKey: 'k' })], async (queen, hits) => {
|
|
153
|
+
const res = await queen.timer(QUEUE).key('k').peek()
|
|
154
|
+
assert.equal(hits[0].method, 'GET')
|
|
155
|
+
assert.equal(hits[0].url, `/api/v1/timers/${QUEUE}/k`)
|
|
156
|
+
assert.equal(res.found, false)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('list is keyset over one queue, and the queue is mandatory', async () => {
|
|
161
|
+
await withQueen([ok({ rows: [], truncated: false, nextAfter: null }), ok({ rows: [], truncated: false, nextAfter: null })], async (queen, hits) => {
|
|
162
|
+
await queen.timer(QUEUE).list()
|
|
163
|
+
assert.equal(hits[0].url, `/api/v1/timers/${QUEUE}`)
|
|
164
|
+
|
|
165
|
+
await queen.timer(QUEUE).list({ after: 'k1', limit: 10 })
|
|
166
|
+
assert.equal(hits[1].url, `/api/v1/timers/${QUEUE}?after=k1&limit=10`)
|
|
167
|
+
|
|
168
|
+
assert.throws(() => queen.timer(''), (e) => { assert.match(e.message, /queue/i); return true })
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('peek and list do not need a payload, a delay or a txn', async () => {
|
|
173
|
+
await withQueen([ok({ found: false })], async (queen) => {
|
|
174
|
+
await queen.timer(QUEUE).key('k').peek()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|