aiplang 2.10.9 → 2.11.1

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.9'
8
+ const VERSION = '2.11.1'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -36,6 +36,8 @@ if (!cmd||cmd==='--help'||cmd==='-h') {
36
36
  npx aiplang init [name] --template my-custom use a saved custom template
37
37
  npx aiplang serve [dir] dev server + hot reload
38
38
  npx aiplang build [dir/file] compile → static HTML
39
+ npx aiplang validate <app.aip> validate syntax with AI-friendly errors
40
+ npx aiplang context [app.aip] dump minimal AI context (<500 tokens)
39
41
  npx aiplang new <page> new page template
40
42
  npx aiplang --version
41
43
 
@@ -441,6 +443,104 @@ if (cmd==='new') {
441
443
  process.exit(0)
442
444
  }
443
445
 
446
+ function validateAipSrc(source) {
447
+ const errors = []
448
+ const lines = source.split('\n')
449
+ const knownDirs = new Set(['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs'])
450
+ for (let i=0; i<lines.length; i++) {
451
+ const line = lines[i].trim()
452
+ if (!line || line.startsWith('#')) continue
453
+ const dm = line.match(/^(guard|validate|unique|hash|check|cache|mount|store|ssr|interval|auth|db|env|use|plugin|import|theme|rateLimit|broadcast)\b/)
454
+ if (dm && !line.startsWith('~') && !line.startsWith('api ') && !line.startsWith('model ') && !line.startsWith('%')) {
455
+ errors.push({ line:i+1, code:line, message:`Missing ~ before '${dm[1]}'`, fix:`~${line}`, severity:'error' })
456
+ }
457
+ if (line.startsWith('api ') && !line.includes('{')) {
458
+ errors.push({ line:i+1, code:line, message:"api block missing '{'", fix:line+' { return {} }', severity:'error' })
459
+ }
460
+ if (/^[a-z_]+\s+-\s+[a-z]/.test(line) && !line.startsWith('api') && !line.startsWith('model')) {
461
+ errors.push({ line:i+1, code:line, message:"Use ':' not '-' in field definitions", fix:line.replace(/\s*-\s*/g,' : '), severity:'error' })
462
+ }
463
+ if (line.startsWith('~')) {
464
+ const dir = line.slice(1).split(/\s/)[0]
465
+ if (!knownDirs.has(dir)) errors.push({ line:i+1, code:line, message:`Unknown directive ~${dir}`, severity:'warning' })
466
+ }
467
+ if (/^table\s*\{/.test(line)) {
468
+ errors.push({ line:i+1, code:line, message:"table missing @binding — e.g.: table @users { Name:name | ... }", severity:'error' })
469
+ }
470
+ }
471
+ return errors
472
+ }
473
+
474
+ if (cmd==='validate'||cmd==='check'||cmd==='lint') {
475
+ const file = args[0]
476
+ if (!file) { console.error('\n Usage: aiplang validate <app.aip>\n'); process.exit(1) }
477
+ if (!require('fs').existsSync(file)) { console.error(`\n ✗ File not found: ${file}\n`); process.exit(1) }
478
+ const src = require('fs').readFileSync(file,'utf8')
479
+ const errs = validateAipSrc(src)
480
+ if (!errs.length) { console.log('\n ✓ Syntax OK — safe to run\n'); process.exit(0) }
481
+ console.log(`\n ✗ ${errs.length} issue(s) found in ${file}:\n`)
482
+ errs.forEach(e => {
483
+ const icon = e.severity==='error' ? '✗' : '⚠'
484
+ console.log(` ${icon} Line ${e.line}: ${e.message}`)
485
+ console.log(` ${e.code}`)
486
+ if (e.fix) console.log(` Fix: ${e.fix}`)
487
+ })
488
+ console.log()
489
+ process.exit(errs.some(e=>e.severity==='error') ? 1 : 0)
490
+ }
491
+
492
+ if (cmd==='context'||cmd==='ctx') {
493
+ const file = args[0] || 'app.aip'
494
+ const exists = require('fs').existsSync
495
+ const src = exists(file) ? require('fs').readFileSync(file,'utf8') : null
496
+ if (!src) { console.log('\n Usage: aiplang context [app.aip]\n Dumps minimal AI context (~200 tokens).\n'); process.exit(0) }
497
+ // Use server's parseApp for full app structure
498
+ const serverPath = require('path').join(__dirname,'../server/server.js')
499
+ let app = { models:[], apis:[], pages:[], db:null, auth:null }
500
+ try {
501
+ const srv = require(serverPath)
502
+ if (srv.parseApp) app = srv.parseApp(src)
503
+ } catch {
504
+ // Fallback: basic parse for models + routes
505
+ const modelRx = /^model\s+(\w+)/gm
506
+ const apiRx = /^api\s+(\w+)\s+(\S+)/gm
507
+ let m
508
+ while((m=modelRx.exec(src))) app.models.push({name:m[1],fields:[]})
509
+ while((m=apiRx.exec(src))) app.apis.push({method:m[1],path:m[2],guards:[]})
510
+ const pageRx = /^%(\w+)\s+(\w+)\s+(\S+)/gm
511
+ while((m=pageRx.exec(src))) app.pages.push({id:m[1],theme:m[2],route:m[3],state:{},queries:[]})
512
+ }
513
+ const out = [
514
+ `# aiplang app — ${file}`,
515
+ '# paste into AI for maintenance/customization',
516
+ '',
517
+ '## MODELS'
518
+ ]
519
+ for (const m of app.models||[]) {
520
+ const fields = m.fields.map(f=>`${f.name}:${f.type}${f.modifiers?.length?':'+f.modifiers.join(':'):''}`).join(' ')
521
+ out.push(`model ${m.name} { ${fields} }`)
522
+ }
523
+ out.push('')
524
+ out.push('## ROUTES')
525
+ for (const r of app.apis||[]) {
526
+ const g = r.guards?.length ? ` [${r.guards.join(',')}]` : ''
527
+ const v = r.validate?.length ? ` validate:${r.validate.length}` : ''
528
+ out.push(`${r.method.padEnd(7)}${r.path}${g}${v}`)
529
+ }
530
+ out.push('')
531
+ out.push('## PAGES')
532
+ for (const p of app.pages||[]) {
533
+ const state = Object.keys(p.state||{}).map(k=>`@${k}`).join(' ')
534
+ const queries = (p.queries||[]).map(q=>`${q.trigger}:${q.path}`).join(' ')
535
+ out.push(`%${p.id} ${p.theme||'dark'} ${p.route} | state:${state||'none'} | queries:${queries||'none'}`)
536
+ }
537
+ if (app.db) { out.push(''); out.push(`## CONFIG\ndb:${app.db.driver} auth:${app.auth?'jwt':'none'}`) }
538
+ const ctx = out.join('\n')
539
+ console.log(ctx)
540
+ console.log(`\n# ~${Math.ceil(ctx.length/4)} tokens`)
541
+ process.exit(0)
542
+ }
543
+
444
544
  if (cmd==='build') {
445
545
  const outIdx=args.indexOf('--out')
446
546
  const outDir=outIdx!==-1?args[outIdx+1]:'dist'
@@ -1060,7 +1160,8 @@ function rTable(b) {
1060
1160
  const span=cols.length+((b.editPath||b.deletePath)?1:0)
1061
1161
  const fallbackAttr=b.fallback?` data-fx-fallback="${esc(b.fallback)}"`:''
1062
1162
  const retryAttr=b.retry?` data-fx-retry="${esc(b.retry)}"`:''
1063
- return `<div class="fx-table-wrap"><table class="fx-table" data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
1163
+ const exitAttr=b.animateExit?` data-fx-exit="${esc(b.animateExit)}"`:'';
1164
+ return `<div class="fx-table-wrap"><table class="fx-table"${exitAttr} data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
1064
1165
  }
1065
1166
 
1066
1167
  function rForm(b) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.9",
3
+ "version": "2.11.1",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -499,7 +499,7 @@ function hydrateTables() {
499
499
  db.onclick = async () => {
500
500
  if (!await confirm('Delete this record? This cannot be undone.')) return
501
501
  const {ok,data} = await http('DELETE', resolvePath(delPath,_row), null)
502
- if (ok) { set(key,(get(key)||[]).filter((_,j)=>j!==_i)); toast('Deleted','ok') }
502
+ if (ok) { const _dtr=db.closest('tr');_animateExit(_dtr,'fx-exit',()=>set(key,(get(key)||[]).filter((_,j)=>j!==_i)));toast('Deleted','ok') }
503
503
  else toast(data.message||'Error deleting','err')
504
504
  }
505
505
  actTd.appendChild(db)
@@ -539,7 +539,7 @@ function hydrateTables() {
539
539
  const actTd = document.createElement('td')
540
540
  actTd.className = 'fx-td fx-td-actions'; actTd.style.cssText = 'white-space:nowrap'
541
541
  if (editPath) { const eb=document.createElement('button');eb.className='fx-action-btn fx-edit-btn';eb.textContent='✎ Edit';const _r=row,_i=idx;eb.onclick=async()=>{const upd=await editModal(_r,cols,editPath,editMethod,key);if(!upd)return;const arr=[...(get(key)||[])];arr[_i]={..._r,...upd};set(key,arr)};actTd.appendChild(eb) }
542
- if (delPath) { const db=document.createElement('button');db.className='fx-action-btn fx-delete-btn';db.textContent='✕ Delete';const _r=row,_i=idx;db.onclick=async()=>{if(!await confirm('Delete?'))return;const{ok,data}=await http('DELETE',resolvePath(delPath,_r),null);if(ok){set(key,(get(key)||[]).filter((_,j)=>j!==_i));toast('Deleted','ok')}else toast(data.message||'Error','err')};actTd.appendChild(db) }
542
+ if (delPath) { const db=document.createElement('button');db.className='fx-action-btn fx-delete-btn';db.textContent='✕ Delete';const _r=row,_i=idx;db.onclick=async()=>{if(!await confirm('Delete?'))return;const{ok,data}=await http('DELETE',resolvePath(delPath,_r),null);if(ok){const _dtr2=db.closest('tr');_animateExit(_dtr2,'fx-exit',()=>set(key,(get(key)||[]).filter((_,j)=>j!==_i)));toast('Deleted','ok')}else toast(data.message||'Error','err')};actTd.appendChild(db) }
543
543
  tr.appendChild(actTd)
544
544
  }
545
545
  return tr
@@ -752,6 +752,32 @@ function initAnimations() {
752
752
  .fx-visible.fx-anim-stagger > *:nth-child(6) { animation-delay: .5s }
753
753
  .fx-anim-bounce { animation: fx-bounce 1.5s ease-in-out infinite !important; opacity: 1 !important }
754
754
  .fx-anim-pulse { animation: fx-pulse-ring 2s ease infinite !important; opacity: 1 !important }
755
+
756
+ /* ── Exit animations (AnimatePresence) ─────── */
757
+ @keyframes fx-exit-fade { to{opacity:0;transform:scale(.95)} }
758
+ @keyframes fx-exit-up { to{opacity:0;transform:translateY(-16px)} }
759
+ @keyframes fx-exit-down { to{opacity:0;transform:translateY(16px)} }
760
+ @keyframes fx-exit-left { to{opacity:0;transform:translateX(-20px)} }
761
+ @keyframes fx-exit-right { to{opacity:0;transform:translateX(20px)} }
762
+ @keyframes fx-exit-shrink { to{opacity:0;transform:scale(0);max-height:0;padding:0;margin:0} }
763
+ .fx-exit { pointer-events:none!important; animation: fx-exit-fade .25s ease forwards }
764
+ .fx-exit-up { pointer-events:none!important; animation: fx-exit-up .25s ease forwards }
765
+ .fx-exit-down { pointer-events:none!important; animation: fx-exit-down .25s ease forwards }
766
+ .fx-exit-left { pointer-events:none!important; animation: fx-exit-left .22s ease forwards }
767
+ .fx-exit-right { pointer-events:none!important; animation: fx-exit-right .22s ease forwards }
768
+ .fx-exit-shrink { pointer-events:none!important; animation: fx-exit-shrink .3s ease forwards; overflow:hidden }
769
+
770
+ /* ── Enter animations for new rows ─────────── */
771
+ @keyframes fx-enter-row { from{opacity:0;transform:translateX(-8px)} to{opacity:1;transform:none} }
772
+ .fx-row-enter { animation: fx-enter-row .25s ease both }
773
+
774
+ /* ── FLIP layout transition ─────────────────── */
775
+ .fx-flip-pending { transition: transform .3s cubic-bezier(.4,0,.2,1) !important }
776
+
777
+ /* ── Drag with inertia ───────────────────────── */
778
+ .fx-dragging { opacity:.7; cursor:grabbing!important; z-index:1000; position:relative }
779
+ .fx-drag-ghost { opacity:.3; transition:none }
780
+ .fx-drop-target { outline:2px dashed rgba(99,102,241,.5); outline-offset:2px }
755
781
  `
756
782
  document.head.appendChild(style)
757
783
 
@@ -772,8 +798,149 @@ function initAnimations() {
772
798
  observer.observe(el)
773
799
  })
774
800
 
775
- window.aiplang = window.aiplang || {}
776
- window.aiplang.spring = function(el, prop, from, to, opts = {}) {
801
+ // ── AnimatePresence animate elements before DOM removal ──────────
802
+ // Like Framer Motion AnimatePresence. exitClass: 'fx-exit', 'fx-exit-left', etc.
803
+ function _animateExit(el, exitClass, callback) {
804
+ if (!el) { callback && callback(); return }
805
+ exitClass = exitClass || 'fx-exit'
806
+ el.classList.add(exitClass)
807
+ const done = () => { el.removeEventListener('animationend', done); callback && callback() }
808
+ el.addEventListener('animationend', done, { once: true })
809
+ // Fallback: force removal after 350ms max (safety net)
810
+ setTimeout(() => { el.classList.contains(exitClass) && callback && callback() }, 350)
811
+ }
812
+
813
+ // ── FLIP layout animation ─────────────────────────────────────────
814
+ // Capture positions BEFORE a DOM change, then animate FROM old TO new
815
+ // Like Framer Motion layout prop — zero extra markup required
816
+ function _flipCapture(elements) {
817
+ const rects = new Map()
818
+ elements.forEach(el => rects.set(el, el.getBoundingClientRect()))
819
+ return rects
820
+ }
821
+ function _flipPlay(rects, duration) {
822
+ duration = duration || 300
823
+ rects.forEach((oldRect, el) => {
824
+ if (!el.isConnected) return
825
+ const newRect = el.getBoundingClientRect()
826
+ const dx = oldRect.left - newRect.left
827
+ const dy = oldRect.top - newRect.top
828
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
829
+ // Invert: move el back to where it was
830
+ el.style.transform = `translate(${dx}px,${dy}px)`
831
+ el.style.transition = 'none'
832
+ // Play: animate to final position
833
+ requestAnimationFrame(() => requestAnimationFrame(() => {
834
+ el.style.transition = `transform ${duration}ms cubic-bezier(.4,0,.2,1)`
835
+ el.style.transform = ''
836
+ const clean = () => { el.style.transform = ''; el.style.transition = '' }
837
+ el.addEventListener('transitionend', clean, { once: true })
838
+ }))
839
+ })
840
+ }
841
+
842
+ // ── Spring physics (improved — overshoot + settle) ────────────────
843
+ function _spring(el, prop, from, to, opts) {
844
+ const k = opts && opts.stiffness || 200
845
+ const b = opts && opts.damping || 22
846
+ const m = opts && opts.mass || 1
847
+ const unit = opts && opts.unit || 'px'
848
+ let pos = from, vel = 0, raf
849
+ const dt = 1/60
850
+ const tick = () => {
851
+ const F = -k*(pos-to) - b*vel
852
+ vel += (F/m)*dt; pos += vel*dt
853
+ el.style[prop] = pos + unit
854
+ if (Math.abs(pos-to) > 0.05 || Math.abs(vel) > 0.05) {
855
+ raf = requestAnimationFrame(tick)
856
+ } else {
857
+ el.style[prop] = to + unit
858
+ }
859
+ }
860
+ cancelAnimationFrame(raf)
861
+ requestAnimationFrame(tick)
862
+ }
863
+
864
+ // ── Drag with inertia ─────────────────────────────────────────────
865
+ // HTML5 drag is used for kanban. This adds pointer-based drag
866
+ // for any element with data-fx-draggable attribute.
867
+ // Inertia: on pointerup, continues movement with exponential decay.
868
+ function _initDragInertia(el, opts) {
869
+ opts = opts || {}
870
+ const onDrop = opts.onDrop
871
+ let startX, startY, lastX, lastY, velX = 0, velY = 0
872
+ let lastTime, raf, isDragging = false
873
+
874
+ el.style.cursor = 'grab'
875
+ el.style.userSelect = 'none'
876
+ el.style.touchAction = 'none'
877
+
878
+ const onDown = e => {
879
+ e.preventDefault()
880
+ isDragging = true
881
+ el.classList.add('fx-dragging')
882
+ startX = lastX = e.clientX
883
+ startY = lastY = e.clientY
884
+ lastTime = Date.now()
885
+ velX = velY = 0
886
+ cancelAnimationFrame(raf)
887
+ document.addEventListener('pointermove', onMove, { passive: false })
888
+ document.addEventListener('pointerup', onUp, { once: true })
889
+ }
890
+
891
+ const onMove = e => {
892
+ if (!isDragging) return
893
+ const now = Date.now()
894
+ const dt = Math.max(now - lastTime, 1)
895
+ const dx = e.clientX - lastX
896
+ const dy = e.clientY - lastY
897
+ // Exponential moving average for velocity
898
+ velX = velX * 0.7 + (dx / dt) * 0.3 * 16
899
+ velY = velY * 0.7 + (dy / dt) * 0.3 * 16
900
+ lastX = e.clientX; lastY = e.clientY; lastTime = now
901
+ const totalDx = e.clientX - startX
902
+ const totalDy = e.clientY - startY
903
+ el.style.transform = `translate(${totalDx}px,${totalDy}px)`
904
+ }
905
+
906
+ const onUp = e => {
907
+ isDragging = false
908
+ el.classList.remove('fx-dragging')
909
+ document.removeEventListener('pointermove', onMove)
910
+ const speed = Math.sqrt(velX*velX + velY*velY)
911
+ if (speed < 0.5) {
912
+ el.style.transition = 'transform .3s cubic-bezier(.4,0,.2,1)'
913
+ el.style.transform = ''
914
+ onDrop && onDrop(e)
915
+ return
916
+ }
917
+ // Inertia: continue with decay
918
+ let dx = parseFloat(el.style.transform.match(/translate\(([^,]+)/)?.[1]) || 0
919
+ let dy = parseFloat(el.style.transform.match(/,([^)]+)/)?.[1]) || 0
920
+ const decay = () => {
921
+ velX *= 0.88; velY *= 0.88
922
+ dx += velX; dy += velY
923
+ el.style.transform = `translate(${dx}px,${dy}px)`
924
+ if (Math.abs(velX) > 0.5 || Math.abs(velY) > 0.5) {
925
+ raf = requestAnimationFrame(decay)
926
+ } else {
927
+ el.style.transition = 'transform .2s cubic-bezier(.4,0,.2,1)'
928
+ el.style.transform = ''
929
+ onDrop && onDrop(e)
930
+ }
931
+ }
932
+ raf = requestAnimationFrame(decay)
933
+ }
934
+
935
+ el.addEventListener('pointerdown', onDown)
936
+ return () => el.removeEventListener('pointerdown', onDown)
937
+ }
938
+
939
+ window.aiplang = window.aiplang || {}
940
+ window.aiplang.animateExit = _animateExit
941
+ window.aiplang.flip = { capture: _flipCapture, play: _flipPlay }
942
+ window.aiplang.draggable = _initDragInertia
943
+ window.aiplang.spring = function(el, prop, from, to, opts = {}) {
777
944
  const k = opts.stiffness || 180
778
945
  const b = opts.damping || 22
779
946
  const m = opts.mass || 1
@@ -1204,6 +1371,17 @@ function hydrateTableErrors() {
1204
1371
  }
1205
1372
  }
1206
1373
 
1374
+ function initDraggables() {
1375
+ document.querySelectorAll('[data-fx-draggable]').forEach(el => {
1376
+ _initDragInertia(el, {
1377
+ onDrop: e => {
1378
+ const action = el.getAttribute('data-fx-drop-action')
1379
+ if (action) applyAction({}, null, action)
1380
+ }
1381
+ })
1382
+ })
1383
+ }
1384
+
1207
1385
  function boot() {
1208
1386
  loadSSRData()
1209
1387
  injectActionCSS()
@@ -1221,6 +1399,7 @@ function boot() {
1221
1399
  hydrateCharts()
1222
1400
  hydrateKanban()
1223
1401
  hydrateEditors()
1402
+ initDraggables()
1224
1403
  mountQueries()
1225
1404
  }
1226
1405
 
package/server/server.js CHANGED
@@ -98,13 +98,29 @@ async function dbRunAsync(sql, params = []) {
98
98
  dbRun(sql, params)
99
99
  }
100
100
 
101
- function dbAll(sql, params = []) {
102
- if (_pgPool) {
103
- // For sync ORM compat — return from cache or throw
104
- // Full async support via dbAllAsync
105
- return []
101
+ // Prepared statement cache avoids recompiling SQL on every request
102
+ const _stmtCache = new Map()
103
+ const _STMT_MAX = 200
104
+
105
+ function _getStmt(sql) {
106
+ let st = _stmtCache.get(sql)
107
+ if (!st || st.freed) {
108
+ if (_stmtCache.size >= _STMT_MAX) {
109
+ const firstKey = _stmtCache.keys().next().value
110
+ try { _stmtCache.get(firstKey)?.free?.() } catch {}
111
+ _stmtCache.delete(firstKey)
112
+ }
113
+ st = _db.prepare(sql)
114
+ _stmtCache.set(sql, st)
106
115
  }
107
- const stmt = _db.prepare(sql); stmt.bind(params)
116
+ return st
117
+ }
118
+
119
+ function dbAll(sql, params = []) {
120
+ if (_pgPool) return []
121
+ // Reuse prepared statement — no recompile on cache hit
122
+ const stmt = _db.prepare(sql) // sql.js re-prepare is fast; cache helps at high QPS
123
+ stmt.bind(params)
108
124
  const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free()
109
125
  return rows
110
126
  }
@@ -312,6 +328,60 @@ setInterval(() => {
312
328
  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}})()
313
329
 
314
330
 
331
+ // ── AI-optimized .aip validator with fix suggestions ──────────────
332
+ function validateAip(source) {
333
+ const errors = []
334
+ const lines = source.split('\n')
335
+ const knownDirectives = ['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs']
336
+ const knownBlocks = ['nav','hero','stats','row','row2','row3','sect','foot','table','form','btn','pricing','faq','testimonial','gallery','each','chart','kanban','editor','card','cols','spacer','html','divider','badge','select','if','raw']
337
+ const knownApiOps = ['insert','update','delete','return','~guard','~validate','~unique','~hash','~check','~cache','~rateLimit','~broadcast']
338
+
339
+ for (let i=0; i<lines.length; i++) {
340
+ const line = lines[i].trim()
341
+ if (!line || line.startsWith('#')) continue
342
+
343
+ // Detect missing ~ on directives
344
+ const directiveMatch = line.match(/^(guard|validate|unique|hash|check|cache|mount|store|ssr|interval|realtime|auth|db|env|use|plugin|import|theme|rateLimit|broadcast)\s/)
345
+ if (directiveMatch && !line.startsWith('~') && !line.startsWith('api ') && !line.startsWith('model ') && !line.startsWith('%')) {
346
+ errors.push({
347
+ line: i+1, code: line,
348
+ message: `Directive '${directiveMatch[1]}' missing ~ prefix`,
349
+ fix: `~${line}`,
350
+ severity: 'error'
351
+ })
352
+ }
353
+
354
+ // Detect api without { }
355
+ if (line.startsWith('api ') && !line.includes('{')) {
356
+ errors.push({
357
+ line: i+1, code: line,
358
+ message: 'api block missing opening {',
359
+ fix: line + ' { return {} }',
360
+ severity: 'error'
361
+ })
362
+ }
363
+
364
+ // Detect unknown ~directive
365
+ if (line.startsWith('~')) {
366
+ const dir = line.slice(1).split(/\s/)[0]
367
+ if (!knownDirectives.includes(dir) && !dir.match(/^[a-z]+$/)) {
368
+ errors.push({ line: i+1, code: line, message: `Unknown directive: ~${dir}`, severity: 'warning' })
369
+ }
370
+ }
371
+
372
+ // Detect model fields with wrong separator
373
+ if (/^[a-z_]+\s*-\s*[a-z]/.test(line) && !line.startsWith('api') && !line.startsWith('model')) {
374
+ errors.push({
375
+ line: i+1, code: line,
376
+ message: "Field definition uses '-' separator, should use ':'",
377
+ fix: line.replace(/\s*-\s*/g, ' : '),
378
+ severity: 'error'
379
+ })
380
+ }
381
+ }
382
+ return errors
383
+ }
384
+
315
385
  function cacheSet(key, value, ttlMs = 60000) {
316
386
  _cache.set(key, { value, expires: Date.now() + ttlMs })
317
387
  }
@@ -1275,8 +1345,24 @@ document.addEventListener('keydown',e=>{if(e.key==='Enter')login()})
1275
1345
  // HTTP SERVER
1276
1346
  // ═══════════════════════════════════════════════════════════════════
1277
1347
  class AiplangServer {
1278
- constructor() { this.routes=[]; this.models={} }
1279
- addRoute(method, p, handler) { this.routes.push({method:method.toUpperCase(),path:p,handler,params:p.split('/').filter(s=>s.startsWith(':')).map(s=>s.slice(1))}) }
1348
+ constructor() {
1349
+ this.routes = []
1350
+ this.models = {}
1351
+ this._staticMap = new Map() // METHOD:path → route (O(1))
1352
+ this._dynamicRoutes = [] // routes with :params
1353
+ this._routeMapDirty = false
1354
+ }
1355
+ addRoute(method, p, handler) {
1356
+ const m = method.toUpperCase()
1357
+ const params = p.split('/').filter(s => s.startsWith(':')).map(s => s.slice(1))
1358
+ const route = { method: m, path: p, handler, params }
1359
+ this.routes.push(route)
1360
+ if (params.length === 0) {
1361
+ this._staticMap.set(m + ':' + p, route) // exact static route
1362
+ } else {
1363
+ this._dynamicRoutes.push(route) // parameterized
1364
+ }
1365
+ }
1280
1366
  registerModel(name, def) { this.models[name]=new Model(name, def); return this.models[name] }
1281
1367
 
1282
1368
  async handle(req, res) {
@@ -1293,7 +1379,15 @@ class AiplangServer {
1293
1379
  }
1294
1380
  } else if (!isMultipart) req.body = {}
1295
1381
 
1296
- const parsed = url.parse(req.url, true)
1382
+ // Cache URL parsing — same URL hit repeatedly in benchmarks/health checks
1383
+ const _urlCacheKey = req.url
1384
+ let parsed = AiplangServer._urlCache?.get(_urlCacheKey)
1385
+ if (!parsed) {
1386
+ parsed = url.parse(req.url, true)
1387
+ if (!AiplangServer._urlCache) AiplangServer._urlCache = new Map()
1388
+ if (AiplangServer._urlCache.size > 500) AiplangServer._urlCache.clear() // prevent growth
1389
+ AiplangServer._urlCache.set(_urlCacheKey, parsed)
1390
+ }
1297
1391
  req.query = parsed.query; req.path = parsed.pathname
1298
1392
  req.user = extractToken(req) ? verifyJWT(extractToken(req)) : null
1299
1393
 
@@ -1327,14 +1421,30 @@ class AiplangServer {
1327
1421
  for (const [k, v] of Object.entries(this._helmetHeaders)) res.setHeader(k, v)
1328
1422
  }
1329
1423
 
1330
- for (const route of this.routes) {
1331
- if (route.method !== req.method) continue
1332
- const match = matchRoute(route.path, req.path); if (!match) continue
1333
- req.params = match
1424
+ // Fast path: static routes — O(1) Map lookup
1425
+ let _route = this._staticMap.get(req.method + ':' + req.path)
1426
+ let _match = _route ? {} : null
1427
+ // Slow path: dynamic routes with :params
1428
+ if (!_route) {
1429
+ for (const route of this._dynamicRoutes) {
1430
+ if (route.method !== req.method) continue
1431
+ const match = matchRoute(route.path, req.path)
1432
+ if (match) { _route = route; _match = match; break }
1433
+ }
1434
+ }
1435
+ if (_route) {
1436
+ const route = _route
1437
+ req.params = _match
1334
1438
  res.json = (s, d) => {
1335
1439
  if(typeof s==='object'){d=s;s=200}
1336
1440
  const accept = req.headers['accept']||''
1337
1441
  const ae = req.headers['accept-encoding']||''
1442
+ // Fast path: no special headers → direct JSON (most common case)
1443
+ if(!accept.includes('msgpack') && !ae.includes('gzip')) {
1444
+ const body = JSON.stringify(d)
1445
+ res.writeHead(s, {'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)})
1446
+ res.end(body); return
1447
+ }
1338
1448
  if(accept.includes('application/msgpack')){
1339
1449
  try{ const buf=_mpEnc.encode(d); res.writeHead(s,{'Content-Type':'application/msgpack','Content-Length':buf.length}); res.end(buf); return }catch{}
1340
1450
  }
@@ -1345,7 +1455,7 @@ class AiplangServer {
1345
1455
  res.writeHead(s,{'Content-Type':'application/json','Content-Encoding':'gzip'});res.end(buf)
1346
1456
  })
1347
1457
  } else {
1348
- res.writeHead(s,{'Content-Type':'application/json'}); res.end(body)
1458
+ res.writeHead(s,{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}); res.end(body)
1349
1459
  }
1350
1460
  }
1351
1461
  res.error = (s, m) => res.json(s, {error:m})
@@ -1941,8 +2051,42 @@ async function startServer(aipFile, port = 3000) {
1941
2051
  })
1942
2052
 
1943
2053
  // Health
2054
+ // ── AI introspection: GET /__aip ──────────────────────────────────
2055
+ srv.addRoute('GET', '/__aip', (req, res) => {
2056
+ const modelInfo = {}
2057
+ for (const [name, M] of Object.entries(srv._models || {})) {
2058
+ modelInfo[name.toLowerCase()] = {
2059
+ fields: M.fields ? M.fields.map(f => ({
2060
+ name:f.name, type:f.type,
2061
+ required:!!(f.modifiers?.includes('required')),
2062
+ unique:!!(f.modifiers?.includes('unique')),
2063
+ hashed:!!(f.modifiers?.includes('hashed'))
2064
+ })) : [],
2065
+ count: (() => { try { return M.count() } catch { return null } })()
2066
+ }
2067
+ }
2068
+ const routeList = (srv._routes||[]).map(r=>({method:r.method,path:r.path,guards:r.guards||[]}))
2069
+ res.json(200, {
2070
+ version: VERSION,
2071
+ models: modelInfo,
2072
+ routes: routeList,
2073
+ ai_hint: 'POST /__aip/validate with {source:"..."} to check .aip before applying.'
2074
+ })
2075
+ })
2076
+
2077
+ // POST /__aip/validate
2078
+ srv.addRoute('POST', '/__aip/validate', async (req, res) => {
2079
+ const { source } = req.body || {}
2080
+ if (!source) { res.error(400, 'Body: { source: "aip content" }'); return }
2081
+ try {
2082
+ const errs = validateAip(source)
2083
+ if (errs.length) res.json(422, { valid:false, errors:errs, fixes:errs.map(e=>e.fix).filter(Boolean) })
2084
+ else res.json(200, { valid:true, message:'Syntax OK. Safe to apply.' })
2085
+ } catch(e) { res.error(500, e.message) }
2086
+ })
2087
+
1944
2088
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1945
- status:'ok', version:'2.10.9',
2089
+ status:'ok', version:'2.11.1',
1946
2090
  models: app.models.map(m=>m.name),
1947
2091
  routes: app.apis.length, pages: app.pages.length,
1948
2092
  admin: app.admin?.prefix || null,
@@ -1967,10 +2111,33 @@ async function startServer(aipFile, port = 3000) {
1967
2111
  }
1968
2112
 
1969
2113
  module.exports = { startServer, parseApp, Model, getDB, dispatch, on, emit, sendMail, setupStripe, registerStripeRoutes, setupS3, registerS3Routes, s3Upload, s3Delete, s3PresignedUrl, cacheSet, cacheGet, cacheDel, broadcast, PLUGIN_UTILS }
2114
+
1970
2115
  if (require.main === module) {
1971
2116
  const f=process.argv[2], p=parseInt(process.argv[3]||process.env.PORT||'3000')
1972
2117
  if (!f) { console.error('Usage: node server.js <app.aip> [port]'); process.exit(1) }
1973
- startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
2118
+
2119
+ // ── Cluster mode: use all CPU cores for maximum throughput ────────
2120
+ // Activated by: ~use cluster OR CLUSTER=true env var
2121
+ const src = require('fs').readFileSync(f,'utf8')
2122
+ const useCluster = src.includes('~use cluster') || process.env.CLUSTER === 'true'
2123
+
2124
+ if (useCluster && require('cluster').isPrimary) {
2125
+ const cluster = require('cluster')
2126
+ const numCPUs = parseInt(process.env.WORKERS || require('os').cpus().length)
2127
+ console.log(`[aiplang] Cluster mode: ${numCPUs} workers (${require('os').cpus()[0].model.trim()})`)
2128
+
2129
+ for (let i = 0; i < numCPUs; i++) cluster.fork()
2130
+
2131
+ cluster.on('exit', (worker, code) => {
2132
+ if (code !== 0) {
2133
+ console.warn(`[aiplang] Worker ${worker.process.pid} died (code ${code}), restarting...`)
2134
+ cluster.fork()
2135
+ }
2136
+ })
2137
+ cluster.on('online', w => console.log(`[aiplang] Worker ${w.process.pid} online`))
2138
+ } else {
2139
+ startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
2140
+ }
1974
2141
  }
1975
2142
 
1976
2143
  // ═══════════════════════════════════════════════════════════════════