fastify-ata 0.9.2 → 0.9.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.
@@ -0,0 +1,18 @@
1
+ version: 2
2
+ updates:
3
+ # ata-validator moves faster than this package does. A bump PR lands here on
4
+ # its own so the pin never silently falls behind what is published.
5
+ - package-ecosystem: "npm"
6
+ directory: "/"
7
+ schedule:
8
+ interval: "daily"
9
+ open-pull-requests-limit: 5
10
+ commit-message:
11
+ prefix: "chore"
12
+
13
+ - package-ecosystem: "github-actions"
14
+ directory: "/"
15
+ schedule:
16
+ interval: "weekly"
17
+ commit-message:
18
+ prefix: "chore"
package/README.md CHANGED
@@ -227,26 +227,82 @@ Works with Fastify v5's Standard Schema support, tRPC, TanStack Form, Drizzle OR
227
227
 
228
228
  All numbers below are reproducible on M4 Pro / Node 25 with the benchmarks in this repo and in `ata-validator/benchmark`. Run-to-run noise is roughly +/- 5% at these scales.
229
229
 
230
- ### Fastify pipeline (autocannon, 10 connections, pipelining 10)
230
+ ### Throughput: not a reason to switch
231
231
 
232
- | Payload | ajv (default) | ata | delta |
232
+ `bench-realtime.js` reports about 15% more requests per second than ajv on a
233
+ 50-route app whose handlers do nothing. That number is reproducible and it is also
234
+ misleading, so here is the whole picture. Sweeping the route count, echo handlers,
235
+ 50 connections:
236
+
237
+ | Routes | ajv req/s | ata req/s | delta |
238
+ |---|---|---|---|
239
+ | 1 | 75,053 | 75,360 | +0.4% |
240
+ | 10 | 64,333 | 74,259 | +15.4% |
241
+ | 50 | 64,554 | 74,643 | +15.6% |
242
+ | 200 | 66,486 | 65,491 | -1.5% |
243
+
244
+ The gap is not a property of the validator, it is a dispatch effect that appears
245
+ once there are enough distinct compiled validators to spoil V8's call-site caching
246
+ and disappears again once there are many. Give the handler real work and it goes
247
+ away entirely, one route, 50 to 200 connections:
248
+
249
+ | Per-request work | ajv req/s | ata req/s | delta |
233
250
  |---|---|---|---|
234
- | valid (10 fields) | ~70,000 req/s | ~70,500 req/s | tied |
235
- | invalid (10 fields) | ~51,000 req/s | ~52,500 req/s | +3% |
236
- | invalid (abortEarly) | ~51,000 req/s | ~52,800 req/s | +3.5% |
251
+ | none | 75,142 | 74,989 | -0.2% |
252
+ | 1 ms | 70,342 | 71,418 | +1.5% |
253
+ | 5 ms | 33,040 | 33,016 | -0.1% |
254
+ | 20 ms | 9,310 | 9,236 | -0.8% |
237
255
 
238
- HTTP + JSON.parse + routing dominate the pipeline, so validator choice is small on throughput. The real difference is elsewhere.
256
+ `profile-fastify.mjs` explains why: in one request `JSON.parse` is 92% of the cost
257
+ and validation 7%. If your handler touches a database, the validator is not what
258
+ decides your throughput. Switch for the boot numbers below, or for the errors,
259
+ types and portability. Do not switch for requests per second.
239
260
 
240
261
  ### Where ata-validator moves the needle
241
262
 
263
+ Schema compilation at boot, from `bench-startup.js`. Each figure is the median of
264
+ nine process-isolated runs with a no-route Fastify baseline subtracted, so what is
265
+ left is the cost of compiling the route schemas and nothing else.
266
+
267
+ | Routes | ajv | ata | delta |
268
+ |---|---|---|---|
269
+ | 50 | 41.6 ms | 1.4 ms | **30x faster** |
270
+ | 100 | 70.0 ms | 4.2 ms | **17x faster** |
271
+ | 250 | 142.7 ms | 11.4 ms | **13x faster** |
272
+ | 500 | 263.0 ms | 18.8 ms | **14x faster** |
273
+ | 1000 | 548.4 ms | 41.2 ms | **13x faster** |
274
+
275
+ Total boot time, baseline included, is 83.6 ms against 583.9 ms at 1000 routes. Below
276
+ about 20 routes the difference is under measurement noise and not worth quoting.
277
+
278
+ ### Against the standalone build step (`bench-standalone-vs.js`)
279
+
280
+ Fastify's documented route to the fastest startup is
281
+ [`@fastify/ajv-compiler` in standalone mode](https://backend.cafe/how-to-unlock-the-fastest-fastify-server-startup):
282
+ compile every route schema to a file at build time, load the files at boot. The
283
+ table is process start to `app.ready()`, five schema types reused across routes,
284
+ median of three runs.
285
+
286
+ | Routes | ajv default | ajv standalone | ata, no build step | ata precompiled |
287
+ |---|---|---|---|---|
288
+ | 50 | 62 ms | 40 ms | 43 ms | 37 ms |
289
+ | 100 | 77 ms | 45 ms | 47 ms | 40 ms |
290
+ | 200 | 111 ms | 55 ms | 50 ms | 41 ms |
291
+ | 500 | 182 ms | 81 ms | **59 ms** | 46 ms |
292
+
293
+ Past about 200 routes, installing ata and doing nothing else boots faster than ajv
294
+ with the build step in place: 59 ms against 81 ms at 500 routes. Precompiling with
295
+ `fastify-ata/standalone` takes another 20% off, but it is an optimization rather
296
+ than the thing that makes the difference. Under 100 routes the four are close
297
+ enough that boot time should not decide anything.
298
+
242
299
  | Scenario | ajv | ata | delta |
243
300
  |---|---|---|---|
244
- | **Serverless cold start** (10 routes, first request) | 12.4 ms | 0.5 ms | **24x faster** |
245
- | **Startup** (200 routes) | 7.0 ms | 2.4 ms | **2.9x faster** |
246
- | **Invalid validation** (with abortEarly) | ~15 ns/op | 3.7 ns/op | **4x faster** |
247
301
  | **ReDoS pattern** `^(a+)+$` | 765 ms | 0.3 ms | **immune (RE2)** |
248
302
 
249
- Serverless cold start is the scenario that matters for Vercel, Cloudflare Workers, Fly.io and similar platforms. On a long-running box the gap closes, so classic servers will not see a throughput jump.
303
+ Boot cost is the scenario that matters for Vercel, Fly.io and similar platforms, where
304
+ a process starts far more often than a long-running box does. On a box that stays up,
305
+ the gap is paid once.
250
306
 
251
307
  ### Build-time compile (optional)
252
308
 
@@ -273,7 +329,7 @@ Generated file has zero runtime dependency on `ata-validator`. `isValid` is emit
273
329
  - **Multi-core** - `countValid(ndjsonBuf)` validates many messages in one native call
274
330
  - **Standard Schema V1** - native support, works with Fastify v5, tRPC, TanStack Form, Drizzle
275
331
  - **Draft 2020-12 and Draft 7** - every applicable draft 2020-12 case in the official JSON Schema Test Suite passes with the native engine installed (1190/1190); 99.8% pure JS
276
- - **Fastify's own suite** - 181 of 187 tests pass with ata as the default validator; the remaining six test the default validator's private extension API. See [compat/COMPATIBILITY.md](compat/COMPATIBILITY.md)
332
+ - **Fastify's own suite** - 178 of 184 tests pass with ata as the default validator; the remaining six test the default validator's private extension API rather than validation behaviour. See [compat/COMPATIBILITY.md](compat/COMPATIBILITY.md)
277
333
 
278
334
  ## License
279
335
 
@@ -0,0 +1,131 @@
1
+ 'use strict'
2
+
3
+ // Real-time Fastify simulation: actual HTTP requests with autocannon
4
+ // Shows: startup time + request throughput + latency
5
+
6
+ const { writeFileSync, unlinkSync } = require('fs')
7
+ const { fork, execSync } = require('child_process')
8
+ const path = require('path')
9
+
10
+ const ROUTES = 50
11
+ const DURATION = 5 // seconds
12
+ const CONNECTIONS = 50
13
+
14
+ function makeRoutes(count) {
15
+ return Array.from({ length: count }, (_, i) => ({
16
+ path: `/api/v1/resource${i}`,
17
+ schema: {
18
+ body: {
19
+ type: 'object',
20
+ properties: {
21
+ id: { type: 'integer', minimum: 1 },
22
+ name: { type: 'string', minLength: 1, maxLength: 100 },
23
+ email: { type: 'string', format: 'email' },
24
+ age: { type: 'integer', minimum: 0, maximum: 150 },
25
+ active: { type: 'boolean' },
26
+ role: { enum: ['admin', 'user', 'moderator'] },
27
+ },
28
+ required: ['id', 'name', 'email', 'active', 'role'],
29
+ },
30
+ },
31
+ }))
32
+ }
33
+
34
+ const routes = makeRoutes(ROUTES)
35
+ const payload = JSON.stringify({
36
+ id: 42, name: 'Mert', email: 'mert@example.com',
37
+ age: 26, active: true, role: 'admin'
38
+ })
39
+
40
+ function serverScript(useAta) {
41
+ return `
42
+ 'use strict'
43
+ const t0 = process.hrtime.bigint()
44
+ const fastify = require('fastify')()
45
+ ${useAta ? "fastify.register(require('./index'))" : ''}
46
+ const routes = ${JSON.stringify(routes)}
47
+ for (const r of routes) {
48
+ fastify.post(r.path, { schema: r.schema }, (req, reply) => {
49
+ reply.send({ ok: true, id: req.body.id })
50
+ })
51
+ }
52
+ fastify.listen({ port: 0 }).then(() => {
53
+ const startupMs = Number(process.hrtime.bigint() - t0) / 1e6
54
+ const port = fastify.server.address().port
55
+ process.send({ port, startupMs })
56
+ })
57
+ `
58
+ }
59
+
60
+ async function runTest(label, useAta) {
61
+ const scriptPath = path.join(__dirname, '_realtime_server.js')
62
+ writeFileSync(scriptPath, serverScript(useAta))
63
+
64
+ const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
65
+ const { port, startupMs } = await new Promise(resolve => {
66
+ child.on('message', msg => resolve(msg))
67
+ })
68
+
69
+ // Pick a random route for each request
70
+ const targetRoute = routes[Math.floor(Math.random() * routes.length)].path
71
+ const escaped = payload.replace(/'/g, "'\\''")
72
+
73
+ let result
74
+ try {
75
+ const raw = execSync(
76
+ `npx autocannon -c ${CONNECTIONS} -d ${DURATION} -j http://localhost:${port}${targetRoute} -m POST -H "content-type: application/json" -b '${escaped}'`,
77
+ { cwd: __dirname, timeout: 30000 }
78
+ ).toString()
79
+ result = JSON.parse(raw)
80
+ } catch (e) {
81
+ child.kill()
82
+ try { unlinkSync(scriptPath) } catch {}
83
+ return null
84
+ }
85
+
86
+ child.kill()
87
+ try { unlinkSync(scriptPath) } catch {}
88
+
89
+ return {
90
+ label,
91
+ startupMs,
92
+ reqPerSec: result.requests.average,
93
+ latencyAvg: result.latency.average,
94
+ latencyP99: result.latency.p99,
95
+ throughputMB: (result.throughput.average / 1024 / 1024).toFixed(1),
96
+ errors: result.errors,
97
+ timeouts: result.timeouts,
98
+ }
99
+ }
100
+
101
+ async function main() {
102
+ console.log('\n' + '='.repeat(60))
103
+ console.log(' Real-Time Fastify Simulation')
104
+ console.log(` ${ROUTES} routes, ${CONNECTIONS} connections, ${DURATION}s duration`)
105
+ console.log(' POST with 6-field validated body')
106
+ console.log('='.repeat(60) + '\n')
107
+
108
+ const ata = await runTest('fastify-ata', true)
109
+ const ajv = await runTest('fastify (ajv)', false)
110
+
111
+ if (!ata || !ajv) { console.log('Benchmark failed'); return }
112
+
113
+ console.log(' fastify-ata fastify (ajv)')
114
+ console.log(' ─────────────────────────────────────────────────')
115
+ console.log(` Startup ${ata.startupMs.toFixed(1).padStart(8)} ms ${ajv.startupMs.toFixed(1).padStart(8)} ms`)
116
+ console.log(` Requests/sec ${ata.reqPerSec.toLocaleString().padStart(8)} ${ajv.reqPerSec.toLocaleString().padStart(8)}`)
117
+ console.log(` Latency (avg) ${ata.latencyAvg.toFixed(2).padStart(8)} ms ${ajv.latencyAvg.toFixed(2).padStart(8)} ms`)
118
+ console.log(` Latency (p99) ${ata.latencyP99.toFixed(2).padStart(8)} ms ${ajv.latencyP99.toFixed(2).padStart(8)} ms`)
119
+ console.log(` Throughput ${ata.throughputMB.padStart(8)} MB/s ${ajv.throughputMB.padStart(8)} MB/s`)
120
+ console.log(` Errors ${String(ata.errors).padStart(8)} ${String(ajv.errors).padStart(8)}`)
121
+ console.log(` Timeouts ${String(ata.timeouts).padStart(8)} ${String(ajv.timeouts).padStart(8)}`)
122
+ console.log()
123
+
124
+ const startupRatio = ajv.startupMs / ata.startupMs
125
+ const rpsRatio = ata.reqPerSec / ajv.reqPerSec
126
+ console.log(` Startup: ata ${startupRatio.toFixed(1)}x faster`)
127
+ console.log(` Throughput: ata ${rpsRatio.toFixed(2)}x ${rpsRatio >= 1 ? 'faster' : 'slower'}`)
128
+ console.log()
129
+ }
130
+
131
+ main().catch(err => { console.error(err); process.exit(1) })
@@ -0,0 +1,59 @@
1
+ 'use strict'
2
+ // How much of the ata-vs-ajv throughput difference survives once a route does
3
+ // real work? Echo handlers make the validator look decisive. A handler that
4
+ // awaits anything at all does not. This is the benchmark behind the second
5
+ // table in the README's throughput section.
6
+ const { fork } = require('child_process')
7
+ const { writeFileSync, unlinkSync } = require('fs')
8
+ const path = require('path')
9
+ const autocannon = require('autocannon')
10
+
11
+ const schema = { type:'object', properties:{
12
+ id:{type:'integer'}, name:{type:'string'}, email:{type:'string'},
13
+ age:{type:'integer',minimum:0}, active:{type:'boolean'}, role:{type:'string',enum:['admin','user']} },
14
+ required:['id','name','email','age','active','role'] }
15
+
16
+ function server(useAta, workMs, port) {
17
+ return `
18
+ 'use strict'
19
+ const fastify = require('fastify')()
20
+ ${useAta ? `fastify.register(require(${JSON.stringify(path.resolve(__dirname,'index.js'))}))` : ''}
21
+ const schema = ${JSON.stringify(schema)}
22
+ const WORK = ${workMs}
23
+ fastify.post('/u', { schema: { body: schema } }, async (req, reply) => {
24
+ if (WORK > 0) await new Promise(r => setTimeout(r, WORK))
25
+ return { ok: true }
26
+ })
27
+ fastify.listen({ port: ${port} }).then(() => process.send({ up: true }))
28
+ `
29
+ }
30
+
31
+ function start(src) {
32
+ return new Promise((res) => {
33
+ const f = path.join(__dirname, '_srv_' + Math.random().toString(36).slice(2) + '.js')
34
+ writeFileSync(f, src)
35
+ const child = fork(f, { silent: true })
36
+ child.on('message', () => res({ child, f }))
37
+ })
38
+ }
39
+
40
+ async function bench(port, connections) {
41
+ const r = await autocannon({ url: 'http://127.0.0.1:' + port + '/u', method: 'POST',
42
+ headers: { 'content-type': 'application/json' },
43
+ body: JSON.stringify({ id: 1, name: 'Ada', email: 'a@e.com', age: 36, active: true, role: 'admin' }),
44
+ connections, duration: 5 })
45
+ return r.requests.average
46
+ }
47
+
48
+ ;(async () => {
49
+ console.log('per-request work ajv req/s ata req/s delta')
50
+ let port = 3310
51
+ for (const workMs of [0, 1, 5, 20]) {
52
+ const conns = workMs === 0 ? 50 : 200
53
+ const a = await start(server(false, workMs, port)); const ajv = await bench(port, conns); a.child.kill(); unlinkSync(a.f); port++
54
+ const b = await start(server(true, workMs, port)); const ata = await bench(port, conns); b.child.kill(); unlinkSync(b.f); port++
55
+ const label = workMs === 0 ? 'none (echo)' : workMs + ' ms async'
56
+ console.log(label.padEnd(18), String(Math.round(ajv)).padStart(9), String(Math.round(ata)).padStart(12),
57
+ (' ' + (((ata - ajv) / ajv) * 100).toFixed(1) + '%').padStart(8))
58
+ }
59
+ })()
@@ -114,6 +114,23 @@ app.ready().then(() => {
114
114
  }
115
115
 
116
116
  // ============================================================
117
+ // 0. ata plain: just register the plugin, no build step, no precompile
118
+ // ============================================================
119
+ function ataPlainScript(routes) {
120
+ return `
121
+ 'use strict'
122
+ const start = performance.now()
123
+ const fastify = require('fastify')()
124
+ fastify.register(require(${JSON.stringify(path.resolve(__dirname, 'index.js'))}))
125
+ const routes = ${JSON.stringify(routes)}
126
+ routes.forEach(r => { fastify.post(r.url, { schema: { body: r.schema } }, (req, reply) => reply.send({ ok: true })) })
127
+ fastify.ready().then(() => {
128
+ process.send({ ms: performance.now() - start })
129
+ process.exit()
130
+ })
131
+ `
132
+ }
133
+
117
134
  // 3. ata compact standalone
118
135
  // ============================================================
119
136
  function ataStandaloneScript(routes, bundlePath) {
@@ -205,13 +222,21 @@ async function main() {
205
222
  const ataStandalone = times3[1]
206
223
  try { unlinkSync(bundlePath) } catch {}
207
224
 
208
- console.log(` ajv default: ${ajvDefault.toFixed(0)}ms`)
209
- console.log(` ajv standalone: ${ajvStandalone >= 0 ? ajvStandalone.toFixed(0) + 'ms' : 'FAILED'}`)
210
- console.log(` ata compact: ${ataStandalone.toFixed(0)}ms`)
225
+ // 0b. ata plain, no build step at all
226
+ const times0 = []
227
+ for (let r = 0; r < 3; r++) times0.push(await runScript(ataPlainScript(routes)))
228
+ times0.sort((a, b) => a - b)
229
+ const ataPlain = times0[1]
230
+
231
+ console.log(` ajv default: ${ajvDefault.toFixed(0)}ms`)
232
+ console.log(` ajv standalone: ${ajvStandalone >= 0 ? ajvStandalone.toFixed(0) + 'ms' : 'FAILED'}`)
233
+ console.log(` ata plain: ${ataPlain.toFixed(0)}ms (no build step)`)
234
+ console.log(` ata compact: ${ataStandalone.toFixed(0)}ms (precompiled bundle)`)
211
235
  if (ajvStandalone > 0) {
212
- console.log(` ata vs ajv standalone: ${(ajvStandalone / ataStandalone).toFixed(1)}x faster`)
236
+ console.log(` ata plain vs ajv standalone: ${(ajvStandalone / ataPlain).toFixed(1)}x faster`)
237
+ console.log(` ata compact vs ajv standalone: ${(ajvStandalone / ataStandalone).toFixed(1)}x faster`)
213
238
  }
214
- console.log(` ata vs ajv default: ${(ajvDefault / ataStandalone).toFixed(1)}x faster`)
239
+ console.log(` ata plain vs ajv default: ${(ajvDefault / ataPlain).toFixed(1)}x faster`)
215
240
  console.log()
216
241
  }
217
242
  }
package/bench-startup.js CHANGED
@@ -1,11 +1,24 @@
1
1
  'use strict'
2
2
 
3
- // Matteo's scenario: Fastify startup time with N routes
4
- // This is what he cares about most
3
+ // Fastify startup cost, split into the part that is Fastify booting and the
4
+ // part that is compiling route schemas.
5
+ //
6
+ // Total boot time hides the interesting number: a bare Fastify instance costs
7
+ // tens of milliseconds before any schema is involved, so comparing totals
8
+ // understates the difference between validators. Subtracting a no-schema
9
+ // baseline leaves the schema compilation cost on its own.
10
+ //
11
+ // TypeBox is deliberately not in the comparison. It builds schemas, it does not
12
+ // compile them; under Fastify a TypeBox schema is still compiled by whichever
13
+ // validator is installed, so it has no separate compile cost to measure.
5
14
 
6
15
  const { writeFileSync, unlinkSync } = require('fs')
16
+ const { fork } = require('child_process')
7
17
  const path = require('path')
8
18
 
19
+ const ROUTE_COUNTS = [0, 10, 50, 100, 250, 500, 1000]
20
+ const RUNS = 9
21
+
9
22
  function makeRouteSchemas(count) {
10
23
  const routes = []
11
24
  for (let i = 0; i < count; i++) {
@@ -38,63 +51,100 @@ function makeRouteSchemas(count) {
38
51
 
39
52
  function serverScript(useAta, routeCount) {
40
53
  const routes = makeRouteSchemas(routeCount)
54
+ const register = useAta
55
+ ? `fastify.register(require(${JSON.stringify(path.join(__dirname, 'index.js'))}))`
56
+ : ''
41
57
  return `
42
58
  'use strict'
43
59
  const t0 = process.hrtime.bigint()
44
60
  const fastify = require('fastify')()
45
- ${useAta ? "fastify.register(require('./index'))" : ''}
61
+ ${register}
46
62
  const routes = ${JSON.stringify(routes)}
47
63
  for (const r of routes) {
48
- fastify.post(r.path, { schema: r.schema }, (req, reply) => {
49
- reply.send({ ok: true })
50
- })
64
+ fastify.post(r.path, { schema: r.schema }, (req, reply) => reply.send({ ok: true }))
51
65
  }
52
66
  fastify.ready().then(() => {
53
- const dt = Number(process.hrtime.bigint() - t0) / 1e6
54
- process.send({ startupMs: dt })
67
+ process.send({ startupMs: Number(process.hrtime.bigint() - t0) / 1e6 })
55
68
  process.exit(0)
56
69
  })
57
70
  `
58
71
  }
59
72
 
60
- async function measureStartup(useAta, routeCount, runs) {
61
- const scriptPath = path.join(__dirname, '_startup_bench.js')
73
+ async function measure(useAta, routeCount, runs) {
74
+ const scriptPath = path.join(
75
+ __dirname,
76
+ `_startup_bench_${useAta ? 'ata' : 'ajv'}_${routeCount}.js`,
77
+ )
78
+ writeFileSync(scriptPath, serverScript(useAta, routeCount))
62
79
  const times = []
63
80
 
64
81
  for (let r = 0; r < runs; r++) {
65
- writeFileSync(scriptPath, serverScript(useAta, routeCount))
66
- const { fork } = require('child_process')
67
82
  const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
68
83
  const ms = await new Promise((resolve, reject) => {
69
- child.on('message', msg => resolve(msg.startupMs))
84
+ child.on('message', (msg) => resolve(msg.startupMs))
70
85
  child.on('error', reject)
71
- setTimeout(() => { child.kill(); reject(new Error('timeout')) }, 10000)
86
+ setTimeout(() => {
87
+ child.kill()
88
+ reject(new Error('timeout'))
89
+ }, 30000)
72
90
  })
73
91
  times.push(ms)
74
92
  }
75
93
 
76
- try { unlinkSync(scriptPath) } catch {}
94
+ try {
95
+ unlinkSync(scriptPath)
96
+ } catch {}
77
97
  times.sort((a, b) => a - b)
78
- return times[Math.floor(times.length / 2)]
98
+ return {
99
+ median: times[Math.floor(times.length / 2)],
100
+ min: times[0],
101
+ max: times[times.length - 1],
102
+ }
79
103
  }
80
104
 
105
+ const fmt = (n) => n.toFixed(1).padStart(7)
106
+
81
107
  async function main() {
82
- console.log('\n==============================================')
83
- console.log(' Fastify Startup Benchmark (cold start)')
84
- console.log(' Median of 5 runs, process-isolated')
85
- console.log('==============================================\n')
108
+ console.log(`\nFastify startup, median of ${RUNS} process-isolated runs`)
109
+ console.log(`node ${process.version}\n`)
110
+
111
+ const baseline = { ata: null, ajv: null }
112
+ const rows = []
86
113
 
87
- for (const count of [5, 10, 25, 50, 100]) {
88
- console.log(`--- ${count} routes (body + querystring schemas) ---`)
114
+ for (const count of ROUTE_COUNTS) {
115
+ const ata = await measure(true, count, RUNS)
116
+ const ajv = await measure(false, count, RUNS)
89
117
 
90
- const ata = await measureStartup(true, count, 5)
91
- const ajv = await measureStartup(false, count, 5)
118
+ if (count === 0) {
119
+ baseline.ata = ata.median
120
+ baseline.ajv = ajv.median
121
+ console.log('Baseline, no routes and no schemas:')
122
+ console.log(` with ata plugin ${fmt(ata.median)} ms (${fmt(ata.min)} to ${fmt(ata.max)})`)
123
+ console.log(` stock fastify ${fmt(ajv.median)} ms (${fmt(ajv.min)} to ${fmt(ajv.max)})`)
124
+ console.log('\nSchema compilation cost, baseline subtracted:\n')
125
+ console.log(' routes ata ajv ratio')
126
+ console.log(' ------------------------------------------')
127
+ continue
128
+ }
92
129
 
93
- console.log(` ata: ${ata.toFixed(1).padStart(8)} ms`)
94
- console.log(` ajv: ${ajv.toFixed(1).padStart(8)} ms`)
95
- console.log(` >>> ata ${(ajv / ata).toFixed(1)}x faster startup`)
96
- console.log()
130
+ const ataCost = ata.median - baseline.ata
131
+ const ajvCost = ajv.median - baseline.ajv
132
+ rows.push({ count, ataCost, ajvCost, ataTotal: ata.median, ajvTotal: ajv.median })
133
+ console.log(
134
+ ` ${String(count).padStart(5)} ${fmt(ataCost)} ms ${fmt(ajvCost)} ms ${(ajvCost / ataCost).toFixed(1)}x`,
135
+ )
97
136
  }
137
+
138
+ console.log('\nTotal boot time for reference:\n')
139
+ console.log(' routes ata ajv')
140
+ console.log(' ------------------------------')
141
+ for (const r of rows) {
142
+ console.log(` ${String(r.count).padStart(5)} ${fmt(r.ataTotal)} ms ${fmt(r.ajvTotal)} ms`)
143
+ }
144
+ console.log()
98
145
  }
99
146
 
100
- main().catch(err => { console.error(err); process.exit(1) })
147
+ main().catch((err) => {
148
+ console.error(err)
149
+ process.exit(1)
150
+ })
package/bench-turbo.js ADDED
@@ -0,0 +1,118 @@
1
+ 'use strict'
2
+
3
+ const { execSync } = require('child_process')
4
+ const { writeFileSync, unlinkSync } = require('fs')
5
+ const path = require('path')
6
+
7
+ const schema = {
8
+ body: {
9
+ type: 'object',
10
+ properties: {
11
+ users: {
12
+ type: 'array',
13
+ items: {
14
+ type: 'object',
15
+ properties: {
16
+ id: { type: 'integer', minimum: 1 },
17
+ name: { type: 'string', minLength: 1 },
18
+ email: { type: 'string', format: 'email' },
19
+ age: { type: 'integer', minimum: 0, maximum: 150 },
20
+ active: { type: 'boolean' },
21
+ role: { enum: ['admin', 'user', 'moderator'] },
22
+ },
23
+ required: ['id', 'name', 'email', 'active', 'role'],
24
+ },
25
+ },
26
+ metadata: {
27
+ type: 'object',
28
+ properties: {
29
+ total: { type: 'integer' },
30
+ page: { type: 'integer', minimum: 1 },
31
+ },
32
+ required: ['total', 'page'],
33
+ },
34
+ },
35
+ required: ['users', 'metadata'],
36
+ },
37
+ }
38
+
39
+ function makePayload(n) {
40
+ const users = []
41
+ for (let i = 0; i < n; i++) {
42
+ users.push({
43
+ id: i + 1,
44
+ name: `User ${i}`,
45
+ email: `user${i}@example.com`,
46
+ age: 25,
47
+ active: true,
48
+ role: 'user',
49
+ })
50
+ }
51
+ return JSON.stringify({ users, metadata: { total: n, page: 1 } })
52
+ }
53
+
54
+ function serverScript(turbo) {
55
+ return `
56
+ 'use strict'
57
+ const fastify = require('fastify')()
58
+ fastify.register(require('./index'), { turbo: ${turbo} })
59
+ const schema = ${JSON.stringify(schema)}
60
+ fastify.post('/users', { schema }, (req, reply) => {
61
+ reply.send({ ok: true, count: req.body.users.length })
62
+ })
63
+ fastify.listen({ port: 0 }).then(() => {
64
+ process.send({ port: fastify.server.address().port })
65
+ })
66
+ `
67
+ }
68
+
69
+ async function bench(label, turbo, payload) {
70
+ const scriptPath = path.join(__dirname, '_bench_turbo_server.js')
71
+ writeFileSync(scriptPath, serverScript(turbo))
72
+
73
+ const { fork } = require('child_process')
74
+ const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
75
+ const port = await new Promise((resolve) => {
76
+ child.on('message', (msg) => resolve(msg.port))
77
+ })
78
+
79
+ const escaped = payload.replace(/'/g, "'\\''")
80
+ const result = execSync(
81
+ `npx autocannon -c 10 -d 5 -j http://localhost:${port}/users -m POST -H "content-type: application/json" -b '${escaped}'`,
82
+ { cwd: __dirname, timeout: 30000 },
83
+ ).toString()
84
+
85
+ child.kill()
86
+ try { unlinkSync(scriptPath) } catch {}
87
+
88
+ const parsed = JSON.parse(result)
89
+ return parsed.requests.average
90
+ }
91
+
92
+ async function main() {
93
+ console.log('\n==============================================')
94
+ console.log(' Turbo Mode Benchmark: normal vs turbo')
95
+ console.log(' 10 connections, 5 seconds, real HTTP')
96
+ console.log('==============================================\n')
97
+
98
+ for (const count of [1, 10, 50, 100]) {
99
+ const payload = makePayload(count)
100
+ console.log(`--- ${count} users (${(payload.length / 1024).toFixed(1)} KB) ---`)
101
+
102
+ const normal = await bench('normal', false, payload)
103
+ const turbo = await bench('turbo', true, payload)
104
+
105
+ console.log(` normal: ${normal.toLocaleString().padStart(10)} req/sec`)
106
+ console.log(` turbo: ${turbo.toLocaleString().padStart(10)} req/sec`)
107
+
108
+ const ratio = turbo / normal
109
+ if (ratio >= 1) console.log(` >>> turbo ${ratio.toFixed(2)}x faster`)
110
+ else console.log(` >>> normal ${(1 / ratio).toFixed(2)}x faster`)
111
+ console.log()
112
+ }
113
+ }
114
+
115
+ main().catch((err) => {
116
+ console.error(err)
117
+ process.exit(1)
118
+ })
@@ -12,7 +12,7 @@ The preload swaps the single `require('@fastify/ajv-compiler')` inside Fastify's
12
12
 
13
13
  ## Current score
14
14
 
15
- **182 of 188 tests pass** (Fastify v5.8.5 checkout, ata-validator 1.3.0, fastify-ata main).
15
+ **178 of 184 tests pass** (Fastify main at dce0d47a, ata-validator 1.11.0, fastify-ata 0.9.4).
16
16
 
17
17
  Everything Fastify's suite asserts about validation behavior passes: type checks and coercion (including the `array` coercion mode), defaults, `removeAdditional`, required and enum handling, cross-schema `$ref` through `addSchema`, draft-07 `$id` anchors, `nullable`, `oneOf`/`anyOf` branching, custom error messages via `errorMessage`, `$merge`/`$patch` keywords, fail-fast startup errors for unresolvable references, encapsulation scoping, error shape (exact default error object layout, mutable for plugins like ajv-i18n), the error paths in `validation-error-handling`, and shared-schema `$ref` into `/definitions` across the validator AND the serializer (ata never mutates caller-provided schema objects, so fast-json-stringify sees them untouched).
18
18
 
@@ -33,4 +33,4 @@ All six are AJV-identity tests. The former seventh (ajv-errors message ordering)
33
33
 
34
34
  ## Reading the number honestly
35
35
 
36
- The suite contains 187 tests because it grew around AJV; a hypothetical perfect drop-in that is not AJV tops out at 181. ata passes all 181. If you find a behavior difference not covered here, that is a bug in fastify-ata or ata: please open an issue.
36
+ The suite grew around AJV, so six of its 184 tests assert that the validator is AJV rather than that validation is correct. A perfect drop-in that is not AJV tops out at 178. ata passes all 178. If you find a behavior difference not covered here, that is a bug in fastify-ata or ata: please open an issue.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-ata",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Fastify plugin for ata-validator. Runtime-competitive with the default, 24x faster serverless cold start, Standard Schema V1.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "homepage": "https://github.com/ata-core/fastify-ata#readme",
28
28
  "dependencies": {
29
- "ata-validator": "^1.7.2",
29
+ "ata-validator": "^1.11.0",
30
30
  "fastify-plugin": "^5.1.0",
31
31
  "sanitize-filename": "^1.6.4"
32
32
  },
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+
3
+ // Profile validation overhead in Fastify context
4
+ import { createRequire } from 'module'
5
+ const require = createRequire(import.meta.url)
6
+
7
+ const { Validator } = require('ata-validator')
8
+
9
+ // Simulate what Fastify does: JSON.parse + validate
10
+ const schema = {
11
+ type: 'object',
12
+ properties: {
13
+ users: {
14
+ type: 'array',
15
+ items: {
16
+ type: 'object',
17
+ properties: {
18
+ id: { type: 'integer', minimum: 1 },
19
+ name: { type: 'string', minLength: 1 },
20
+ email: { type: 'string', format: 'email' },
21
+ age: { type: 'integer', minimum: 0, maximum: 150 },
22
+ active: { type: 'boolean' },
23
+ role: { enum: ['admin', 'user', 'moderator'] },
24
+ },
25
+ required: ['id', 'name', 'email', 'active', 'role'],
26
+ },
27
+ },
28
+ metadata: {
29
+ type: 'object',
30
+ properties: {
31
+ total: { type: 'integer' },
32
+ page: { type: 'integer', minimum: 1 },
33
+ },
34
+ required: ['total', 'page'],
35
+ },
36
+ },
37
+ required: ['users', 'metadata'],
38
+ }
39
+
40
+ function makePayload(n) {
41
+ const users = []
42
+ for (let i = 0; i < n; i++) {
43
+ users.push({ id: i+1, name: `User ${i}`, email: `u${i}@x.com`, age: 25, active: true, role: 'user' })
44
+ }
45
+ return JSON.stringify({ users, metadata: { total: n, page: 1 } })
46
+ }
47
+
48
+ const v = new Validator(schema)
49
+ const payloads = [1, 10, 50, 100].map(n => ({ n, json: makePayload(n) }))
50
+
51
+ // Warmup
52
+ for (const p of payloads) {
53
+ for (let i = 0; i < 1000; i++) {
54
+ const obj = JSON.parse(p.json)
55
+ v.validate(obj)
56
+ }
57
+ }
58
+
59
+ console.log('Breakdown: JSON.parse vs validate vs total\n')
60
+
61
+ const N = 100000
62
+ for (const p of payloads) {
63
+ // JSON.parse only
64
+ let t = process.hrtime.bigint()
65
+ for (let i = 0; i < N; i++) JSON.parse(p.json)
66
+ const dtParse = Number(process.hrtime.bigint() - t) / 1e6
67
+
68
+ // validate only (pre-parsed)
69
+ const obj = JSON.parse(p.json)
70
+ t = process.hrtime.bigint()
71
+ for (let i = 0; i < N; i++) v.validate(obj)
72
+ const dtValidate = Number(process.hrtime.bigint() - t) / 1e6
73
+
74
+ // parse + validate
75
+ t = process.hrtime.bigint()
76
+ for (let i = 0; i < N; i++) { const o = JSON.parse(p.json); v.validate(o) }
77
+ const dtTotal = Number(process.hrtime.bigint() - t) / 1e6
78
+
79
+ const parsePct = (dtParse / dtTotal * 100).toFixed(0)
80
+ const valPct = (dtValidate / dtTotal * 100).toFixed(0)
81
+
82
+ console.log(`${p.n} users (${(p.json.length/1024).toFixed(1)}KB):`)
83
+ console.log(` JSON.parse: ${(dtParse/N*1000).toFixed(1)} us (${parsePct}%)`)
84
+ console.log(` validate: ${(dtValidate/N*1000).toFixed(1)} us (${valPct}%)`)
85
+ console.log(` total: ${(dtTotal/N*1000).toFixed(1)} us`)
86
+ console.log()
87
+ }