aiplang 2.10.7 → 2.10.8

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/bin/aiplang.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const http = require('http')
7
7
 
8
- const VERSION = '2.10.7'
8
+ const VERSION = '2.10.8'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -860,7 +860,35 @@ function applyMods(html, b) {
860
860
  function renderPage(page, allPages) {
861
861
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
862
862
  const body=page.blocks.map(b=>{try{return applyMods(renderBlock(b,page),b)}catch(e){console.error('[aiplang] Block render error:',b.kind,e.message);return ''}}).join('')
863
- const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries,stores:page.stores||[],computed:page.computed||{}}):''
863
+ // Compiled diff functions per table
864
+ const tableBlocks = page.blocks.filter(b => b.kind === 'table' && b.binding && b.cols && b.cols.length)
865
+ const numericKeys = ['score','count','total','amount','price','value','qty','age','rank','num','int','float','rate','pct','percent']
866
+ const compiledDiffs = tableBlocks.map(b => {
867
+ const binding = b.binding.replace(/^@/, '')
868
+ const colDefs = b.cols.map((col, j) => ({
869
+ key: col.key,
870
+ idx: j,
871
+ numeric: numericKeys.some(kw => col.key.toLowerCase().includes(kw))
872
+ }))
873
+ const initParts = colDefs.map(d =>
874
+ d.numeric ? `c${d.idx}:new Float64Array(rows.map(r=>+(r.${d.key})||0))`
875
+ : `c${d.idx}:rows.map(r=>r.${d.key}??'')`
876
+ ).join(',')
877
+ const diffParts = colDefs.map(d =>
878
+ d.numeric ? `if(c${d.idx}[i]!==(r.${d.key}||0)){c${d.idx}[i]=r.${d.key}||0;p.push(i<<4|${d.idx})}`
879
+ : `if(c${d.idx}[i]!==r.${d.key}){c${d.idx}[i]=r.${d.key};p.push(i<<4|${d.idx})}`
880
+ ).join(';')
881
+ return [
882
+ `window.__aip_init_${binding}=function(rows){return{${initParts}}};`,
883
+ `window.__aip_diff_${binding}=function(rows,cache){`,
884
+ `const n=rows.length,p=[],${colDefs.map(d=>`c${d.idx}=cache.c${d.idx}`).join(',')};`,
885
+ `for(let i=0;i<n;i++){const r=rows[i];${diffParts}}return p};`
886
+ ].join('')
887
+ }).join('\n')
888
+ const compiledScript = compiledDiffs.length
889
+ ? `<script>/* aiplang compiled-diffs */\n${compiledDiffs}\n</script>`
890
+ : ''
891
+ const config=needsJS?JSON.stringify({id:page.id,theme:page.theme,routes:allPages.map(p=>p.route),state:page.state,queries:page.queries,stores:page.stores||[],computed:page.computed||{},compiledTables:tableBlocks.map(b=>b.binding.replace(/^@/,''))}):''
864
892
  const hydrate=needsJS?`\n<script>window.__AIPLANG_PAGE__=${config};</script>\n<script src="./aiplang-hydrate.js" defer></script>`:''
865
893
  const customVars=page.customTheme?genCustomThemeVars(page.customTheme):''
866
894
  const themeVarCSS=page.themeVars?genThemeVarCSS(page.themeVars):''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.7",
3
+ "version": "2.10.8",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -105,14 +105,55 @@ function resolvePath(tmpl, row) {
105
105
 
106
106
  const _intervals = []
107
107
 
108
+ // Persistent session ID for delta tracking
109
+ const _sid = Math.random().toString(36).slice(2)
110
+
108
111
  async function runQuery(q) {
109
112
  const path = resolve(q.path)
110
- const opts = { method: q.method, headers: { 'Content-Type': 'application/json' } }
113
+ const isGet = (q.method || 'GET').toUpperCase() === 'GET'
114
+ const opts = {
115
+ method: q.method || 'GET',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ 'Accept': 'application/msgpack, application/json',
119
+ 'x-session-id': _sid
120
+ }
121
+ }
122
+ // Enable delta updates for GET requests after first load
123
+ if (isGet && q._deltaReady) opts.headers['x-aiplang-delta'] = '1'
111
124
  if (q.body) opts.body = JSON.stringify(q.body)
112
125
  try {
113
- const res = await fetch(path, opts)
126
+ const res = await fetch(path, opts)
127
+ // 304 = nothing changed — skip re-render
128
+ if (res.status === 304) { q._deltaReady = true; return null }
114
129
  if (!res.ok) throw new Error('HTTP ' + res.status)
115
- const data = await res.json()
130
+ const ct = res.headers.get('Content-Type') || ''
131
+ let data
132
+ if (ct.includes('msgpack')) {
133
+ const buf = await res.arrayBuffer()
134
+ data = _mp.decode(new Uint8Array(buf))
135
+ } else {
136
+ data = await res.json()
137
+ }
138
+ // Delta response: merge into existing state instead of replacing
139
+ if (data && data.__delta) {
140
+ const key = q.target ? q.target.replace(/^@/, '') : null
141
+ if (key) {
142
+ const current = [...(get(key) || [])]
143
+ // Apply changes
144
+ for (const row of (data.changed || [])) {
145
+ const idx = current.findIndex(r => r.id === row.id)
146
+ if (idx >= 0) current[idx] = row; else current.push(row)
147
+ }
148
+ // Remove deleted
149
+ const delSet = new Set(data.deleted || [])
150
+ const merged = current.filter(r => !delSet.has(r.id))
151
+ set(key, merged)
152
+ q._deltaReady = true
153
+ return merged
154
+ }
155
+ }
156
+ q._deltaReady = true
116
157
  applyAction(data, q.target, q.action)
117
158
  return data
118
159
  } catch (e) {
@@ -484,10 +525,17 @@ function hydrateTables() {
484
525
  frag.appendChild(tr)
485
526
  }
486
527
  tbody.appendChild(frag)
487
- // Build TypedArray cache for ultra-fast subsequent diffs (beats Vue Vapor)
528
+ // Build compiled cache (Tier 1) + typed cache (Tier 2)
488
529
  try {
489
530
  const tc = _buildTypedCache(rows, _colKeys)
490
531
  _rowCache._typed = tc
532
+ // Compiled diff: use __aip_init_binding if available
533
+ const compiledInit = window['__aip_init_' + stateKey]
534
+ if (compiledInit) {
535
+ _rowCache._compiled = compiledInit(rows)
536
+ _rowCache._compiled_n = rows.length
537
+ _rowCache._ids = rows.map(r => r.id != null ? r.id : null)
538
+ }
491
539
  } catch {}
492
540
  } else {
493
541
  // UPDATE: off-main-thread diff + requestIdleCallback
@@ -833,12 +881,24 @@ function injectActionCSS() {
833
881
  // Main thread only handles tiny DOM patches — never competes with animations.
834
882
  // ═══════════════════════════════════════════════════════════════════
835
883
 
836
- const _workerSrc = `'use strict'
884
+ const _workerSrc = `
885
+ 'use strict'
886
+ // aiplang Diff Worker v2 — SharedArrayBuffer edition
887
+ // Uses SAB when available (HTTPS + COOP/COEP headers)
888
+ // Falls back to postMessage for unsupported environments
889
+
890
+ let _sab = null, _sabView = null
891
+
837
892
  self.onmessage = function(e) {
838
- const { type, rows, colKeys, cache, reqId } = e.data
893
+ const { type, rows, colKeys, cache, reqId, sab } = e.data
894
+
895
+ // Accept SharedArrayBuffer reference
896
+ if (sab) { _sab = sab; _sabView = new Int32Array(sab) }
897
+
839
898
  if (type !== 'diff') return
840
899
  const patches = [], inserts = [], deletes = []
841
900
  const seenIds = new Set()
901
+
842
902
  for (let i = 0; i < rows.length; i++) {
843
903
  const row = rows[i]
844
904
  const id = row.id != null ? row.id : i
@@ -852,15 +912,32 @@ self.onmessage = function(e) {
852
912
  }
853
913
  }
854
914
  for (const id in cache) {
855
- const nid = typeof id === 'number' ? id : (isNaN(id) ? id : Number(id))
915
+ const nid = isNaN(id) ? id : Number(id)
856
916
  if (!seenIds.has(String(id)) && !seenIds.has(nid)) deletes.push(id)
857
917
  }
858
- self.postMessage({ type: 'patches', patches, inserts, deletes, reqId })
859
- }`
918
+
919
+ // Write to SharedArrayBuffer if available (zero-copy)
920
+ if (_sabView && patches.length < 8000) {
921
+ Atomics.store(_sabView, 0, patches.length)
922
+ for (let i = 0; i < patches.length; i++) {
923
+ // Pack: patchIdx[i*3+0]=id_hash, [i*3+1]=col, [i*3+2]=encoded_val
924
+ _sabView[1 + i*3] = typeof patches[i].id === 'number' ? patches[i].id : patches[i].id.length
925
+ _sabView[1 + i*3+1] = patches[i].col
926
+ _sabView[1 + i*3+2] = 0 // signal: read from patches array
927
+ }
928
+ Atomics.store(_sabView, 0, patches.length | 0x80000000) // MSB = done flag
929
+ // Still send full patch data for non-numeric values
930
+ self.postMessage({ type: 'patches', patches, inserts, deletes, reqId, usedSAB: true })
931
+ } else {
932
+ self.postMessage({ type: 'patches', patches, inserts, deletes, reqId, usedSAB: false })
933
+ }
934
+ }
935
+ `
860
936
 
861
937
  let _diffWorker = null
862
938
  const _wCbs = new Map()
863
939
  let _wReq = 0
940
+ let _wSAB = null // SharedArrayBuffer view for zero-copy transfers
864
941
 
865
942
  function _getWorker() {
866
943
  if (_diffWorker) return _diffWorker
@@ -871,6 +948,12 @@ function _getWorker() {
871
948
  if (cb) { cb(e.data); _wCbs.delete(e.data.reqId) }
872
949
  }
873
950
  _diffWorker.onerror = () => { _diffWorker = null }
951
+ // Share a buffer for high-frequency zero-copy patch transfer
952
+ try {
953
+ const sab = new SharedArrayBuffer(8000 * 3 * 4 + 4) // 8k patches × 3 ints × 4 bytes + header
954
+ _diffWorker.postMessage({ type: 'init_sab', sab }, [])
955
+ _wSAB = new Int32Array(sab)
956
+ } catch {} // SAB not available (requires HTTPS + COOP headers)
874
957
  } catch { _diffWorker = null }
875
958
  return _diffWorker
876
959
  }
@@ -925,14 +1008,33 @@ function _diffSync(rows, colKeys, rowCache) {
925
1008
  const patches = [], inserts = [], deletes = [], seen = new Set()
926
1009
  const nCols = colKeys.length
927
1010
 
928
- // FAST PATH: TypedArray positional scan beats Vue Vapor at all sizes
929
- // Condition: row count unchanged (typical for polling updates)
1011
+ // TIER 1 COMPILED DIFF: use generated monomorphic function if available
1012
+ // Generated at build time specific field access, no generic loops
1013
+ const compiledKey = stateKey // table binding name
1014
+ const compiledInit = window['__aip_init_' + compiledKey]
1015
+ const compiledDiff = window['__aip_diff_' + compiledKey]
1016
+
1017
+ if (compiledDiff && rowCache._compiled && rows.length === rowCache._compiled_n) {
1018
+ // Decode bitpacked patches: i<<4|colIdx
1019
+ const raw = compiledDiff(rows, rowCache._compiled)
1020
+ for (const pack of raw) {
1021
+ const i = pack >> 4, col = pack & 0xf
1022
+ const id = rowCache._ids[i]
1023
+ if (id != null) {
1024
+ seen.add(id)
1025
+ patches.push({ id, col, val: rows[i][colKeys[col]] })
1026
+ }
1027
+ }
1028
+ for (const [id] of rowCache) if (id !== '_compiled' && id !== '_ids' && id !== '_compiled_n' && !seen.has(id)) deletes.push(id)
1029
+ return { patches, inserts, deletes }
1030
+ }
1031
+
1032
+ // TIER 2 — TYPED CACHE fast path
930
1033
  const tc = rowCache._typed
931
1034
  if (tc && rows.length === tc.n) {
932
1035
  for (let i = 0; i < rows.length; i++) {
933
1036
  const r = rows[i], id = tc.ids[i]
934
1037
  seen.add(id)
935
- // Per-column diff using per-column strategy
936
1038
  for (let j = 0; j < nCols; j++) {
937
1039
  const k = colKeys[j]
938
1040
  const newVal = r[k]
@@ -1018,6 +1120,48 @@ const _STATUS_INT = {active:0,inactive:1,pending:2,blocked:3,
1018
1120
  pending:2,done:3,todo:0,doing:1,done:2,
1019
1121
  new:0,open:1,closed:2,resolved:3}
1020
1122
 
1123
+ // ── Minimal MessagePack decoder (~1.8KB) ─────────────────────────
1124
+ // Handles all types produced by aiplang server's msgpack encoder
1125
+ // No external dependencies — AI-maintained, human-unreadable by design
1126
+ const _mp = (() => {
1127
+ const td = new TextDecoder()
1128
+ function decode(buf) {
1129
+ const b = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf
1130
+ return _read(b, {p:0})
1131
+ }
1132
+ function _read(b, s) {
1133
+ const t = b[s.p++]
1134
+ if (t <= 0x7f) return t // positive fixint
1135
+ if (t >= 0xe0) return t - 256 // negative fixint
1136
+ if ((t & 0xe0) === 0xa0) return _str(b, s, t & 0x1f) // fixstr
1137
+ if ((t & 0xf0) === 0x90) return _arr(b, s, t & 0xf) // fixarray
1138
+ if ((t & 0xf0) === 0x80) return _map(b, s, t & 0xf) // fixmap
1139
+ switch (t) {
1140
+ case 0xc0: return null
1141
+ case 0xc2: return false
1142
+ case 0xc3: return true
1143
+ case 0xca: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return v.getFloat32(0) }
1144
+ case 0xcb: { const v=new DataView(b.buffer,b.byteOffset+s.p,8); s.p+=8; return v.getFloat64(0) }
1145
+ case 0xcc: return b[s.p++]
1146
+ case 0xcd: { const v=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return v }
1147
+ case 0xce: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return v.getUint32(0) }
1148
+ case 0xd0: { const v=b[s.p++]; return v>127?v-256:v }
1149
+ case 0xd1: { const v=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return v>32767?v-65536:v }
1150
+ case 0xd2: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return v.getInt32(0) }
1151
+ case 0xd9: { const n=b[s.p++]; return _str(b,s,n) }
1152
+ case 0xda: { const n=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return _str(b,s,n) }
1153
+ case 0xdc: { const n=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return _arr(b,s,n) }
1154
+ case 0xdd: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return _arr(b,s,v.getUint32(0)) }
1155
+ case 0xde: { const n=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return _map(b,s,n) }
1156
+ default: return null
1157
+ }
1158
+ }
1159
+ function _str(b,s,n){const v=td.decode(b.subarray(s.p,s.p+n));s.p+=n;return v}
1160
+ function _arr(b,s,n){const a=[];for(let i=0;i<n;i++)a.push(_read(b,s));return a}
1161
+ function _map(b,s,n){const o={};for(let i=0;i<n;i++){const k=_read(b,s);o[k]=_read(b,s)}return o}
1162
+ return { decode }
1163
+ })()
1164
+
1021
1165
  function loadSSRData() {
1022
1166
  const ssr = window.__SSR_DATA__
1023
1167
  if (!ssr) return
package/server/server.js CHANGED
@@ -246,6 +246,59 @@ async function processQueue() {
246
246
 
247
247
  // ── Cache in-memory com TTL ──────────────────────────────────────
248
248
  const _cache = new Map()
249
+ // ── Delta update cache ─────────────────────────────────────────────
250
+ // Stores a hash of each row per client session to send only changes
251
+ // Key: sessionId:binding → Map<rowId, hash>
252
+ const _deltaCache = new Map()
253
+ const _DELTA_TTL = 60000 // 1 min: expire client state
254
+
255
+ function _rowHash(row) {
256
+ // Fast hash for change detection — FNV-1a variant
257
+ let h = 2166136261
258
+ const s = JSON.stringify(row)
259
+ for (let i = 0; i < s.length; i++) {
260
+ h ^= s.charCodeAt(i)
261
+ h = (h * 16777619) >>> 0
262
+ }
263
+ return h
264
+ }
265
+
266
+ function _getDelta(sessionId, binding, rows) {
267
+ const key = sessionId + ':' + binding
268
+ const prev = _deltaCache.get(key)
269
+ // First request — send full dataset, store hashes
270
+ if (!prev) {
271
+ const hashes = new Map(rows.map(r => [r.id, _rowHash(r)]))
272
+ _deltaCache.set(key, { hashes, ts: Date.now() })
273
+ return { full: true, rows }
274
+ }
275
+ // Subsequent: compute delta
276
+ const changed = [], deleted = []
277
+ const newHashes = new Map()
278
+ for (const row of rows) {
279
+ const h = _rowHash(row)
280
+ newHashes.set(row.id, h)
281
+ if (prev.hashes.get(row.id) !== h) changed.push(row)
282
+ }
283
+ for (const [id] of prev.hashes) {
284
+ if (!newHashes.has(id)) deleted.push(id)
285
+ }
286
+ prev.hashes = newHashes; prev.ts = Date.now()
287
+ // Nothing changed — return 304-like empty delta
288
+ if (!changed.length && !deleted.length) return { full: false, changed: [], deleted: [], version: prev.ts }
289
+ return { full: false, changed, deleted, version: Date.now() }
290
+ }
291
+
292
+ // Clean up stale delta caches
293
+ setInterval(() => {
294
+ const now = Date.now()
295
+ for (const [k, v] of _deltaCache) if (now - v.ts > _DELTA_TTL * 5) _deltaCache.delete(k)
296
+ }, _DELTA_TTL)
297
+
298
+ // ── MessagePack encoder (AI-maintained) ────────────────────────────
299
+ const _mpEnc=(()=>{const te=new TextEncoder();function encode(v){const b=[];_w(v,b);return Buffer.from(b)}function _w(v,b){if(v===null||v===undefined){b.push(0xc0);return}if(v===false){b.push(0xc2);return}if(v===true){b.push(0xc3);return}const t=typeof v;if(t==='number'){if(Number.isInteger(v)&&v>=0&&v<=127){b.push(v);return}if(Number.isInteger(v)&&v>=-32&&v<0){b.push(v+256);return}if(Number.isInteger(v)&&v>=0&&v<=65535){b.push(0xcd,v>>8,v&0xff);return}const dv=new DataView(new ArrayBuffer(8));dv.setFloat64(0,v);b.push(0xcb,...new Uint8Array(dv.buffer));return}if(t==='string'){const e=te.encode(v);const n=e.length;if(n<=31)b.push(0xa0|n);else if(n<=255)b.push(0xd9,n);else b.push(0xda,n>>8,n&0xff);b.push(...e);return}if(Array.isArray(v)){const n=v.length;if(n<=15)b.push(0x90|n);else b.push(0xdc,n>>8,n&0xff);v.forEach(x=>_w(x,b));return}if(t==='object'){const ks=Object.keys(v);const n=ks.length;if(n<=15)b.push(0x80|n);else b.push(0xde,n>>8,n&0xff);ks.forEach(k=>{_w(k,b);_w(v[k],b)});return}}return{encode}})()
300
+
301
+
249
302
  function cacheSet(key, value, ttlMs = 60000) {
250
303
  _cache.set(key, { value, expires: Date.now() + ttlMs })
251
304
  }
@@ -975,6 +1028,26 @@ async function execOp(line, ctx, server) {
975
1028
  const exprParts=isNaN(parseInt(p[p.length-1]))?p:p.slice(0,-1)
976
1029
  let result=evalExpr(exprParts.join(' '),ctx,server)
977
1030
  if(result===null||result===undefined)result=ctx.vars['inserted']||ctx.vars['updated']||{}
1031
+ // Delta update — only active when client explicitly requests it
1032
+ if (Array.isArray(result) && ctx.req?.headers?.['x-aiplang-delta'] === '1' && result.length > 0) {
1033
+ try {
1034
+ const _req = ctx.req
1035
+ const sid = (_req.headers['x-session-id'] || _req.socket?.remoteAddress || 'default').slice(0,64)
1036
+ const binding = (_req.url?.split('?')[0]?.replace(/^\/api\//,'')?.split('/')[0]) || 'data'
1037
+ const delta = _getDelta(sid, binding, result)
1038
+ if (delta.full) {
1039
+ ctx.res.json(status, result); return '__DONE__'
1040
+ }
1041
+ if (!delta.changed.length && !delta.deleted.length) {
1042
+ ctx.res.json(304, { __delta: true, changed: [], deleted: [], version: delta.version })
1043
+ return '__DONE__'
1044
+ }
1045
+ ctx.res.json(status, { __delta: true, changed: delta.changed, deleted: delta.deleted, version: delta.version })
1046
+ return '__DONE__'
1047
+ } catch(deltaErr) {
1048
+ // Delta failed — fall through to normal response
1049
+ }
1050
+ }
978
1051
  ctx.res.json(status,result); return '__DONE__'
979
1052
  }
980
1053
 
@@ -1244,7 +1317,23 @@ class AiplangServer {
1244
1317
  if (route.method !== req.method) continue
1245
1318
  const match = matchRoute(route.path, req.path); if (!match) continue
1246
1319
  req.params = match
1247
- res.json = (s, d) => { if(typeof s==='object'){d=s;s=200}; res.writeHead(s,{'Content-Type':'application/json'}); res.end(JSON.stringify(d)) }
1320
+ res.json = (s, d) => {
1321
+ if(typeof s==='object'){d=s;s=200}
1322
+ const accept = req.headers['accept']||''
1323
+ const ae = req.headers['accept-encoding']||''
1324
+ if(accept.includes('application/msgpack')){
1325
+ try{ const buf=_mpEnc.encode(d); res.writeHead(s,{'Content-Type':'application/msgpack','Content-Length':buf.length}); res.end(buf); return }catch{}
1326
+ }
1327
+ const body=JSON.stringify(d)
1328
+ if(ae.includes('gzip')&&body.length>512){
1329
+ require('zlib').gzip(body,(err,buf)=>{
1330
+ if(err){res.writeHead(s,{'Content-Type':'application/json'});res.end(body);return}
1331
+ res.writeHead(s,{'Content-Type':'application/json','Content-Encoding':'gzip'});res.end(buf)
1332
+ })
1333
+ } else {
1334
+ res.writeHead(s,{'Content-Type':'application/json'}); res.end(body)
1335
+ }
1336
+ }
1248
1337
  res.error = (s, m) => res.json(s, {error:m})
1249
1338
  res.noContent = () => { res.writeHead(204); res.end() }
1250
1339
  res.redirect = (u) => { res.writeHead(302,{Location:u}); res.end() }
@@ -1839,7 +1928,7 @@ async function startServer(aipFile, port = 3000) {
1839
1928
 
1840
1929
  // Health
1841
1930
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1842
- status:'ok', version:'2.10.7',
1931
+ status:'ok', version:'2.10.8',
1843
1932
  models: app.models.map(m=>m.name),
1844
1933
  routes: app.apis.length, pages: app.pages.length,
1845
1934
  admin: app.admin?.prefix || null,