aiplang 2.10.1 → 2.10.2

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.1'
8
+ const VERSION = '2.10.2'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -6,7 +6,6 @@
6
6
  const cfg = window.__AIPLANG_PAGE__
7
7
  if (!cfg) return
8
8
 
9
- // ── State ────────────────────────────────────────────────────────
10
9
  const _state = {}
11
10
  const _watchers = {}
12
11
 
@@ -28,7 +27,6 @@ function watch(key, cb) {
28
27
  return () => { _watchers[key] = _watchers[key].filter(f => f !== cb) }
29
28
  }
30
29
 
31
- // Batched notify — queues all pending updates and flushes in rAF (like React's batching)
32
30
  const _pending = new Set()
33
31
  let _batchScheduled = false
34
32
 
@@ -48,7 +46,6 @@ function notify(key) {
48
46
  }
49
47
  }
50
48
 
51
- // Force immediate flush (for critical updates like form submit)
52
49
  function notifySync(key) {
53
50
  ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
54
51
  }
@@ -63,12 +60,10 @@ function resolve(str) {
63
60
  })
64
61
  }
65
62
 
66
- // Resolve path with row data: /api/users/{id} + {id:1} → /api/users/1
67
63
  function resolvePath(tmpl, row) {
68
64
  return tmpl.replace(/\{([^}]+)\}/g, (_, k) => row?.[k] ?? get(k) ?? '')
69
65
  }
70
66
 
71
- // ── Query Engine ─────────────────────────────────────────────────
72
67
  const _intervals = []
73
68
 
74
69
  async function runQuery(q) {
@@ -96,11 +91,11 @@ function applyAction(data, target, action) {
96
91
  if (pm) { set(pm[1], [...(get(pm[1]) || []), data]); return }
97
92
  const fm = action.match(/^@([a-zA-Z_]+)\.filter\((.+)\)$/)
98
93
  if (fm) {
99
- // Safe filter: @list.filter(item.status=active) style — no eval/new Function
94
+
100
95
  try {
101
96
  const expr = fm[2].trim()
102
97
  const filtered = (get(fm[1]) || []).filter(item => {
103
- // Support simple: field=value or field!=value
98
+
104
99
  const eq = expr.match(/^([a-zA-Z_.]+)\s*(!?=)\s*(.+)$/)
105
100
  if (eq) {
106
101
  const [, field, op, val] = eq
@@ -132,7 +127,6 @@ function mountQueries() {
132
127
  }
133
128
  }
134
129
 
135
- // ── HTTP helper ──────────────────────────────────────────────────
136
130
  async function http(method, path, body) {
137
131
  const opts = { method, headers: { 'Content-Type': 'application/json' } }
138
132
  if (body) opts.body = JSON.stringify(body)
@@ -141,7 +135,6 @@ async function http(method, path, body) {
141
135
  return { ok: res.ok, status: res.status, data }
142
136
  }
143
137
 
144
- // ── Toast notifications ──────────────────────────────────────────
145
138
  function toast(msg, type) {
146
139
  const t = document.createElement('div')
147
140
  t.textContent = msg
@@ -158,7 +151,6 @@ function toast(msg, type) {
158
151
  setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300) }, 2500)
159
152
  }
160
153
 
161
- // ── Confirm modal ────────────────────────────────────────────────
162
154
  function confirm(msg) {
163
155
  return new Promise(resolve => {
164
156
  const overlay = document.createElement('div')
@@ -179,7 +171,6 @@ function confirm(msg) {
179
171
  })
180
172
  }
181
173
 
182
- // ── Edit modal ───────────────────────────────────────────────────
183
174
  function editModal(row, cols, path, method, stateKey) {
184
175
  return new Promise(resolve => {
185
176
  const overlay = document.createElement('div')
@@ -228,21 +219,19 @@ function editModal(row, cols, path, method, stateKey) {
228
219
  })
229
220
  }
230
221
 
231
- // ── Hydrate tables with CRUD ─────────────────────────────────────
232
222
  function hydrateTables() {
233
223
  document.querySelectorAll('[data-fx-table]').forEach(tbl => {
234
224
  const binding = tbl.getAttribute('data-fx-table')
235
225
  const colsJSON = tbl.getAttribute('data-fx-cols')
236
- const editPath = tbl.getAttribute('data-fx-edit') // e.g. /api/users/{id}
226
+ const editPath = tbl.getAttribute('data-fx-edit')
237
227
  const editMethod= tbl.getAttribute('data-fx-edit-method') || 'PUT'
238
- const delPath = tbl.getAttribute('data-fx-delete') // e.g. /api/users/{id}
228
+ const delPath = tbl.getAttribute('data-fx-delete')
239
229
  const delKey = tbl.getAttribute('data-fx-delete-key') || 'id'
240
230
 
241
231
  const cols = colsJSON ? JSON.parse(colsJSON) : []
242
232
  const tbody = tbl.querySelector('tbody')
243
233
  if (!tbody) return
244
234
 
245
- // Add action column headers if needed
246
235
  if ((editPath || delPath) && tbl.querySelector('thead tr')) {
247
236
  const thead = tbl.querySelector('thead tr')
248
237
  if (!thead.querySelector('.fx-th-actions')) {
@@ -269,37 +258,71 @@ function hydrateTables() {
269
258
  return
270
259
  }
271
260
 
272
- // Virtual rendering: only render visible rows for large datasets
273
- const VIRTUAL_THRESHOLD = 100
274
- const ROW_HEIGHT = 44 // px
261
+ const VIRTUAL_THRESHOLD = 80
262
+ const OVERSCAN = 8
263
+ const colSpanTotal = cols.length + (editPath || delPath ? 1 : 0)
275
264
  const useVirtual = rows.length >= VIRTUAL_THRESHOLD
265
+ let rowHeights = null, totalHeight = 0, scrollListener = null
276
266
 
277
267
  if (useVirtual) {
278
- const wrapDiv = tbody.closest('.fx-table-wrap') || tbody.parentElement
279
- const visible = Math.ceil((wrapDiv.clientHeight || 400) / ROW_HEIGHT) + 10
280
- const scrollTop = wrapDiv.scrollTop || 0
281
- const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - 5)
282
- const endIdx = Math.min(rows.length - 1, startIdx + visible)
283
-
284
- // Spacer before
285
- if (startIdx > 0) {
286
- const spacerTr = document.createElement('tr')
287
- const spacerTd = document.createElement('td')
288
- spacerTd.colSpan = cols.length + (editPath || delPath ? 1 : 0)
289
- spacerTd.style.height = (startIdx * ROW_HEIGHT) + 'px'
290
- spacerTd.style.padding = '0'
291
- spacerTr.appendChild(spacerTd)
292
- tbody.appendChild(spacerTr)
268
+ const wrapDiv = tbl.closest('.fx-table-wrap') || tbl.parentElement
269
+ wrapDiv.style.cssText += ';max-height:520px;overflow-y:auto;position:relative'
270
+
271
+ const measureRow = rows[0]
272
+ const tempTr = document.createElement('tr')
273
+ tempTr.style.visibility = 'hidden'
274
+ cols.forEach(col => {
275
+ const td = document.createElement('td'); td.className = 'fx-td'
276
+ td.textContent = measureRow[col.key] || ''; tempTr.appendChild(td)
277
+ })
278
+ tbody.appendChild(tempTr)
279
+ const rowH = Math.max(tempTr.getBoundingClientRect().height, 40) || 44
280
+ tbody.removeChild(tempTr)
281
+
282
+ const viewH = wrapDiv.clientHeight || 480
283
+ const visibleCount = Math.ceil(viewH / rowH) + OVERSCAN * 2
284
+
285
+ const renderVirtual = () => {
286
+ const scrollTop = wrapDiv.scrollTop
287
+ const startRaw = Math.floor(scrollTop / rowH)
288
+ const start = Math.max(0, startRaw - OVERSCAN)
289
+ const end = Math.min(rows.length - 1, start + visibleCount)
290
+ const paddingTop = start * rowH
291
+ const paddingBot = Math.max(0, (rows.length - end - 1) * rowH)
292
+
293
+ tbody.innerHTML = ''
294
+
295
+ if (paddingTop > 0) {
296
+ const tr = document.createElement('tr')
297
+ const td = document.createElement('td')
298
+ td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingTop+'px;padding:0;border:none'
299
+ tr.appendChild(td); tbody.appendChild(tr)
300
+ }
301
+
302
+ for (let i = start; i <= end; i++) renderRow(rows[i], i)
303
+
304
+ if (paddingBot > 0) {
305
+ const tr = document.createElement('tr')
306
+ const td = document.createElement('td')
307
+ td.colSpan = colSpanTotal; td.style.cssText = 'height:'+paddingBot+'px;padding:0;border:none'
308
+ tr.appendChild(td); tbody.appendChild(tr)
309
+ }
293
310
  }
294
- rows = rows.slice(startIdx, endIdx + 1)
311
+
312
+ let rafPending = false
313
+ scrollListener = () => {
314
+ if (rafPending) return; rafPending = true
315
+ requestAnimationFrame(() => { rafPending = false; renderVirtual() })
316
+ }
317
+ wrapDiv.addEventListener('scroll', scrollListener, { passive: true })
318
+ renderVirtual()
319
+ return
295
320
  }
296
321
 
297
- rows.forEach((row, idx) => {
322
+ function renderRow(row, idx) {
298
323
  const tr = document.createElement('tr')
299
324
  tr.className = 'fx-tr'
300
- if (useVirtual) tr.style.height = ROW_HEIGHT + 'px'
301
325
 
302
- // Data cells
303
326
  for (const col of cols) {
304
327
  const td = document.createElement('td')
305
328
  td.className = 'fx-td'
@@ -307,7 +330,6 @@ function hydrateTables() {
307
330
  tr.appendChild(td)
308
331
  }
309
332
 
310
- // Action cell
311
333
  if (editPath || delPath) {
312
334
  const td = document.createElement('td')
313
335
  td.className = 'fx-td fx-td-actions'
@@ -351,7 +373,9 @@ function hydrateTables() {
351
373
  tr.appendChild(td)
352
374
  }
353
375
  tbody.appendChild(tr)
354
- })
376
+ }
377
+
378
+ rows.forEach((row, idx) => renderRow(row, idx))
355
379
  }
356
380
 
357
381
  const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
@@ -360,7 +384,6 @@ function hydrateTables() {
360
384
  })
361
385
  }
362
386
 
363
- // ── Hydrate lists ────────────────────────────────────────────────
364
387
  function hydrateLists() {
365
388
  document.querySelectorAll('[data-fx-list]').forEach(wrap => {
366
389
  const binding = wrap.getAttribute('data-fx-list')
@@ -389,7 +412,6 @@ function hydrateLists() {
389
412
  })
390
413
  }
391
414
 
392
- // ── Hydrate forms ─────────────────────────────────────────────────
393
415
  function hydrateForms() {
394
416
  document.querySelectorAll('[data-fx-form]').forEach(form => {
395
417
  const path = form.getAttribute('data-fx-form')
@@ -424,8 +446,6 @@ function hydrateForms() {
424
446
  })
425
447
  }
426
448
 
427
- // ── Hydrate btns ──────────────────────────────────────────────────
428
- // <button data-fx-btn="/api/path" data-fx-method="POST" data-fx-action="...">
429
449
  function hydrateBtns() {
430
450
  document.querySelectorAll('[data-fx-btn]').forEach(btn => {
431
451
  const path = btn.getAttribute('data-fx-btn')
@@ -452,8 +472,6 @@ function hydrateBtns() {
452
472
  })
453
473
  }
454
474
 
455
- // ── Hydrate select dropdowns ──────────────────────────────────────
456
- // <select data-fx-model="@filter"> sets @filter on change
457
475
  function hydrateSelects() {
458
476
  document.querySelectorAll('[data-fx-model]').forEach(sel => {
459
477
  const binding = sel.getAttribute('data-fx-model')
@@ -464,7 +482,6 @@ function hydrateSelects() {
464
482
  })
465
483
  }
466
484
 
467
- // ── Hydrate text bindings ─────────────────────────────────────────
468
485
  function hydrateBindings() {
469
486
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
470
487
  const expr = el.getAttribute('data-fx-bind')
@@ -475,7 +492,6 @@ function hydrateBindings() {
475
492
  })
476
493
  }
477
494
 
478
- // ── Hydrate conditionals ──────────────────────────────────────────
479
495
  function hydrateIfs() {
480
496
  document.querySelectorAll('[data-fx-if]').forEach(wrap => {
481
497
  const cond = wrap.getAttribute('data-fx-if')
@@ -496,9 +512,8 @@ function hydrateIfs() {
496
512
  })
497
513
  }
498
514
 
499
- // ── Advanced Animations (scroll-triggered + stagger) ─────────────
500
515
  function initAnimations() {
501
- // Extended animation presets — beyond what React ships by default
516
+
502
517
  const style = document.createElement('style')
503
518
  style.textContent = `
504
519
  @keyframes fx-blur-in { from{opacity:0;filter:blur(8px);transform:translateY(8px)} to{opacity:1;filter:blur(0);transform:none} }
@@ -532,7 +547,6 @@ function initAnimations() {
532
547
  `
533
548
  document.head.appendChild(style)
534
549
 
535
- // Intersection Observer — trigger when element scrolls into view (like Framer whileInView)
536
550
  const observer = new IntersectionObserver((entries) => {
537
551
  entries.forEach(entry => {
538
552
  if (entry.isIntersecting) {
@@ -543,14 +557,57 @@ function initAnimations() {
543
557
  }, { threshold: 0.12, rootMargin: '0px 0px -30px 0px' })
544
558
 
545
559
  document.querySelectorAll('[class*="fx-anim-"]').forEach(el => {
546
- // bounce and pulse run immediately
560
+
547
561
  if (el.classList.contains('fx-anim-bounce') || el.classList.contains('fx-anim-pulse')) {
548
562
  el.classList.add('fx-visible'); return
549
563
  }
550
564
  observer.observe(el)
551
565
  })
552
566
 
553
- // Counter animation numbers count up on scroll-in
567
+ window.aiplang = window.aiplang || {}
568
+ window.aiplang.spring = function(el, prop, from, to, opts = {}) {
569
+ const k = opts.stiffness || 180
570
+ const b = opts.damping || 22
571
+ const m = opts.mass || 1
572
+ let pos = from, vel = 0
573
+ const dt = 1/60
574
+ let raf
575
+
576
+ const tick = () => {
577
+ const F = -k * (pos - to) - b * vel
578
+ vel += (F / m) * dt
579
+ pos += vel * dt
580
+ if (Math.abs(pos - to) < 0.01 && Math.abs(vel) < 0.01) {
581
+ pos = to
582
+ el.style[prop] = pos + (opts.unit || 'px')
583
+ return
584
+ }
585
+ el.style[prop] = pos + (opts.unit || 'px')
586
+ raf = requestAnimationFrame(tick)
587
+ }
588
+ cancelAnimationFrame(raf)
589
+ requestAnimationFrame(tick)
590
+ }
591
+
592
+ const springObs = new IntersectionObserver(entries => {
593
+ entries.forEach(entry => {
594
+ if (!entry.isIntersecting) return
595
+ const el = entry.target
596
+ if (el.classList.contains('fx-anim-spring')) {
597
+ el.style.opacity = '1'
598
+ el.style.transform = 'translateY(0px)'
599
+ window.aiplang.spring(el, '--spring-y', 24, 0, { stiffness: 200, damping: 20, unit: 'px' })
600
+ springObs.unobserve(el)
601
+ }
602
+ })
603
+ }, { threshold: 0.1 })
604
+
605
+ document.querySelectorAll('.fx-anim-spring').forEach(el => {
606
+ el.style.opacity = '0'
607
+ el.style.transform = 'translateY(24px)'
608
+ springObs.observe(el)
609
+ })
610
+
554
611
  document.querySelectorAll('.fx-stat-val').forEach(el => {
555
612
  const target = parseFloat(el.textContent)
556
613
  if (isNaN(target) || target === 0) return
@@ -576,7 +633,6 @@ function initAnimations() {
576
633
  })
577
634
  }
578
635
 
579
- // ── Inject action column CSS ──────────────────────────────────────
580
636
  function injectActionCSS() {
581
637
  const style = document.createElement('style')
582
638
  style.textContent = `
@@ -595,9 +651,6 @@ function injectActionCSS() {
595
651
  document.head.appendChild(style)
596
652
  }
597
653
 
598
- // ── SSR Data Injection — pre-populate state from server data ────────
599
- // Server can inject window.__SSR_DATA__ = {users: [...], stats: {...}}
600
- // to avoid loading flash (like Next.js getServerSideProps)
601
654
  function loadSSRData() {
602
655
  const ssr = window.__SSR_DATA__
603
656
  if (!ssr) return
@@ -606,8 +659,6 @@ function loadSSRData() {
606
659
  }
607
660
  }
608
661
 
609
- // ── Optimistic UI ─────────────────────────────────────────────────
610
- // form data-fx-optimistic="true": updates state instantly, rolls back on error
611
662
  function hydrateOptimistic() {
612
663
  document.querySelectorAll('[data-fx-optimistic]').forEach(form => {
613
664
  const action = form.getAttribute('data-fx-action') || ''
@@ -616,7 +667,7 @@ function hydrateOptimistic() {
616
667
  const key = pm[1]
617
668
 
618
669
  form.addEventListener('submit', (e) => {
619
- // Inject a temp item optimistically before submit fires
670
+
620
671
  const body = {}
621
672
  form.querySelectorAll('input,select,textarea').forEach(inp => {
622
673
  if (inp.name) body[inp.name] = inp.value
@@ -626,26 +677,23 @@ function hydrateOptimistic() {
626
677
  const current = [...(get(key) || [])]
627
678
  set(key, [...current, optimisticItem])
628
679
 
629
- // After actual submit (handled by hydrateForms), remove temp if error
630
680
  const origAction = form.getAttribute('data-fx-action')
631
681
  form.setAttribute('data-fx-action-orig', origAction)
632
682
  form.setAttribute('data-fx-action', `@${key}._rollback_${tempId}`)
633
683
 
634
- // Restore action after tick
635
684
  setTimeout(() => {
636
685
  form.setAttribute('data-fx-action', origAction)
637
- // Clean up optimistic item if real item arrived
686
+
638
687
  setTimeout(() => {
639
688
  const arr = get(key) || []
640
689
  const hasReal = arr.some(i => !i._optimistic)
641
690
  if (hasReal) set(key, arr.filter(i => !i._optimistic || i.id !== tempId))
642
691
  }, 500)
643
692
  }, 50)
644
- }, true) // capture phase — before hydrateForms submit handler
693
+ }, true)
645
694
  })
646
695
  }
647
696
 
648
- // ── Error recovery — fallback + retry ────────────────────────────
649
697
  function hydrateTableErrors() {
650
698
  document.querySelectorAll('[data-fx-fallback]').forEach(tbl => {
651
699
  const fallback = tbl.getAttribute('data-fx-fallback')
@@ -656,7 +704,6 @@ function hydrateTableErrors() {
656
704
  const tbody = tbl.querySelector('tbody')
657
705
  const originalEmpty = tbl.getAttribute('data-fx-empty') || 'No data.'
658
706
 
659
- // Override runQuery to detect errors for this table's binding
660
707
  const key = binding?.replace(/^@/, '') || ''
661
708
  if (key) {
662
709
  const cleanup = watch(key, (val) => {
@@ -680,7 +727,6 @@ function hydrateTableErrors() {
680
727
  }
681
728
  }
682
729
 
683
- // ── Boot ──────────────────────────────────────────────────────────
684
730
  function boot() {
685
731
  loadSSRData()
686
732
  injectActionCSS()
@@ -698,7 +744,6 @@ function boot() {
698
744
  mountQueries()
699
745
  }
700
746
 
701
- // ── Hydrate each @list { template } ──────────────────────────────
702
747
  function hydrateEach() {
703
748
  document.querySelectorAll('[data-fx-each]').forEach(wrap => {
704
749
  const binding = wrap.getAttribute('data-fx-each')
@@ -721,7 +766,7 @@ function hydrateEach() {
721
766
  items.forEach(item => {
722
767
  const div = document.createElement('div')
723
768
  div.className = 'fx-each-item'
724
- // Interpolate {item.field} syntax in template
769
+
725
770
  const html = tpl.replace(/\{item\.([^}]+)\}/g, (_, field) => {
726
771
  const parts = field.split('.')
727
772
  let val = item
package/server/server.js CHANGED
@@ -613,7 +613,18 @@ function parseApp(src) {
613
613
  else if (line.startsWith('~belongs '))curModel.relationships.push({ type:'belongsTo', model:line.slice(9).trim() })
614
614
  else if (line.startsWith('~hook ')) curModel.hooks.push(line.slice(6).trim())
615
615
  else if (line === '~soft-delete') curModel.softDelete = true
616
- else if (line && line !== '{') curModel.fields.push(parseField(line))
616
+ else if (line && line !== '{') {
617
+ // Support both multi-line and compact single-line field defs
618
+ if (line.startsWith('~')) {
619
+ if (line === '~soft-delete') curModel.softDelete = true
620
+ else if (line.startsWith('~belongs ')) curModel.relationships.push({type:'belongsTo',model:line.slice(9).trim()})
621
+ } else if (!line.includes(' ') && line.includes(':')) {
622
+ // Compact: "email:text:unique:required"
623
+ curModel.fields.push(parseFieldCompact(line))
624
+ } else {
625
+ curModel.fields.push(parseField(line))
626
+ }
627
+ }
617
628
  i++; continue
618
629
  }
619
630
 
@@ -621,8 +632,12 @@ function parseApp(src) {
621
632
  if (inAPI && curAPI) app.apis.push(curAPI)
622
633
  const braceIdx = line.indexOf('{')
623
634
  const closeBraceIdx = line.lastIndexOf('}')
624
- const pts = line.slice(4, braceIdx).trim().split(/\s+/)
625
- curAPI = { method:pts[0], path:pts[1], guards:[], validate:[], query:[], body:[], return:null }
635
+ const rawHead = line.slice(4, braceIdx).trim()
636
+ // Shorthand: api GET /path => auth,admin { arrow guard syntax
637
+ const arrowM = rawHead.match(/^(\S+)\s+(\S+)\s*=>\s*([\w,]+)\s*$/)
638
+ const pts = (arrowM ? rawHead.slice(0, rawHead.indexOf('=>')).trim() : rawHead).split(/\s+/)
639
+ const inlineGuards = arrowM ? arrowM[3].split(',').map(g=>g.trim()) : []
640
+ curAPI = { method:pts[0], path:pts[1], guards:[...inlineGuards], validate:[], query:[], body:[], return:null }
626
641
  // Inline api: "api GET /path { ops }" — entire api on one line
627
642
  if (braceIdx !== -1 && closeBraceIdx > braceIdx) {
628
643
  const inlineBody = line.slice(braceIdx+1, closeBraceIdx).trim()
@@ -648,7 +663,7 @@ function parseApp(src) {
648
663
  }
649
664
 
650
665
  function parseEnvLine(s) { const p=s.split(/\s+/); const ev={name:'',required:false,default:null}; for(const x of p){if(x==='required')ev.required=true;else if(x.includes('=')){const[k,v]=x.split('=');ev.name=k;ev.default=v}else ev.name=x}; return ev }
651
- function parseDBLine(s) { const p=s.split(/\s+/); return{driver:p[0]||'sqlite',dsn:p[1]||'./app.db'} }
666
+ function parseDBLine(s) { const p=s.split(/\s+/); const d=p[0]||'sqlite'; return{driver:d==='pg'||d==='psql'?'postgres':d,dsn:p[1]||'./app.db'} }
652
667
  function parseAuthLine(s) { const p=s.split(/\s+/); const a={provider:'jwt',secret:p[1]||'$JWT_SECRET',expire:'7d',refresh:'30d'}; for(const x of p){if(x.startsWith('expire='))a.expire=x.slice(7);if(x.startsWith('refresh='))a.refresh=x.slice(8);if(x==='google')a.oauth=['google'];if(x==='github')a.oauth=[...(a.oauth||[]),'google']}; return a }
653
668
  function parseMailLine(s) { const parts=s.split(/\s+/); const m={driver:parts[0]||'smtp'}; for(const x of parts.slice(1)){const[k,v]=x.split('='); m[k]=v}; return m }
654
669
  function parseStripeLine(s) {
@@ -708,7 +723,6 @@ function parseEventLine(s) { const m=s.match(/^(\S+)\s*=>\s*(.+)$/); return{even
708
723
  function parseField(line) {
709
724
  const p=line.split(':').map(s=>s.trim())
710
725
  const f={name:p[0],type:p[1]||'text',modifiers:[],enumVals:[],default:null}
711
- // If type is enum, p[2] contains comma-separated values directly
712
726
  if (f.type === 'enum' && p[2] && !p[2].startsWith('default=') && !['required','unique','hashed','pk','auto','index'].includes(p[2])) {
713
727
  f.enumVals = p[2].split(',').map(v=>v.trim()).filter(Boolean)
714
728
  for(let j=3;j<p.length;j++){const x=p[j];if(x.startsWith('default='))f.default=x.slice(8);else if(x)f.modifiers.push(x)}
@@ -717,6 +731,19 @@ function parseField(line) {
717
731
  }
718
732
  return f
719
733
  }
734
+
735
+ // Compact model field: "email:text:unique:required" single-line
736
+ function parseFieldCompact(def) {
737
+ const parts = def.trim().split(':').map(s=>s.trim()).filter(Boolean)
738
+ const f = {name:parts[0], type:parts[1]||'text', modifiers:[], enumVals:[], default:null}
739
+ for (let i=2; i<parts.length; i++) {
740
+ const x = parts[i]
741
+ if (x.startsWith('default=')) f.default = x.slice(8)
742
+ else if (/^[a-z]+,[a-z]/.test(x)) f.enumVals = x.split(',').map(v=>v.trim())
743
+ else f.modifiers.push(x)
744
+ }
745
+ return f
746
+ }
720
747
  function parseAPILine(line, route) {
721
748
  if(line.startsWith('~guard ')) route.guards=line.slice(7).split('|').map(s=>s.trim())
722
749
  else if(line.startsWith('~validate ')) line.slice(10).split('|').forEach(v=>{const p=v.trim().split(/\s+/);if(p[0])route.validate.push({field:p[0],rules:p.slice(1)})})
@@ -727,10 +754,14 @@ function parseFrontPage(src) {
727
754
  const lines=src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
728
755
  const p={id:'page',theme:'dark',route:'/',themeVars:null,state:{},queries:[],blocks:[]}
729
756
  for(const line of lines){
730
- if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
757
+ if(line.startsWith('%')){const pts=line.slice(1).trim().split(/\s+/);p.id=pts[0]||'page';p.route=pts[2]||'/';const cachePt=pts.find(x=>x.startsWith('cache='));if(cachePt)p.cacheTTL=parseInt(cachePt.slice(6));const rt=pts[1]||'dark';if(rt.includes('#')){const c=rt.split(',');p.theme='custom';p.customTheme={bg:c[0],text:c[1]||'#f1f5f9',accent:c[2]||'#2563eb'}}else p.theme=rt}
731
758
  else if(line.startsWith('~theme ')){p.themeVars=p.themeVars||{};line.slice(7).trim().split(/\s+/).forEach(pair=>{const eq=pair.indexOf('=');if(eq!==-1)p.themeVars[pair.slice(0,eq)]=pair.slice(eq+1)})}
732
759
  else if(line.startsWith('@')&&line.includes('=')){const eq=line.indexOf('=');p.state[line.slice(1,eq).trim()]=line.slice(eq+1).trim()}
733
- else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount')p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?pts[3]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null});else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
760
+ else if(line.startsWith('~')){const pts=line.slice(1).trim().split(/\s+/);const ai=pts.indexOf('=>');if(pts[0]==='mount'){
761
+ // Auto-detect target from path if not specified: ~mount GET /api/users → @users
762
+ const autoTarget = pts[3] || ('@' + (pts[2]?.split('/').filter(Boolean).pop()?.split('?')[0]||'data'))
763
+ p.queries.push({trigger:'mount',method:pts[1],path:pts[2],target:ai===-1?autoTarget:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})
764
+ } else if(pts[0]==='interval')p.queries.push({trigger:'interval',interval:parseInt(pts[1]),method:pts[2],path:pts[3],target:ai===-1?pts[4]:null,action:ai!==-1?pts.slice(ai+1).join(' '):null})}
734
765
  else p.blocks.push({kind:blockKind(line),rawLine:line})
735
766
  }
736
767
  return p
@@ -1797,7 +1828,7 @@ async function startServer(aipFile, port = 3000) {
1797
1828
 
1798
1829
  // Health
1799
1830
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1800
- status:'ok', version:'2.10.1',
1831
+ status:'ok', version:'2.10.2',
1801
1832
  models: app.models.map(m=>m.name),
1802
1833
  routes: app.apis.length, pages: app.pages.length,
1803
1834
  admin: app.admin?.prefix || null,