aiplang 2.10.7 → 2.10.9

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.9'
9
9
  const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
10
10
  const cmd = process.argv[2]
11
11
  const args = process.argv.slice(3)
@@ -24,10 +24,6 @@ const ic = n => ICONS[n] || n
24
24
  const isDyn = s => s&&(s.includes('@')||s.includes('$'))
25
25
  const hSize = n => n<1024?`${n}B`:`${(n/1024).toFixed(1)}KB`
26
26
 
27
- // ─────────────────────────────────────────────────────────────────
28
- // CLI COMMANDS
29
- // ─────────────────────────────────────────────────────────────────
30
-
31
27
  if (!cmd||cmd==='--help'||cmd==='-h') {
32
28
  console.log(`
33
29
  aiplang v${VERSION}
@@ -74,18 +70,12 @@ if (!cmd||cmd==='--help'||cmd==='-h') {
74
70
  }
75
71
  if (cmd==='--version'||cmd==='-v') { console.log(`aiplang v${VERSION}`); process.exit(0) }
76
72
 
77
- // ─────────────────────────────────────────────────────────────────
78
- // TEMPLATE SYSTEM
79
- // Custom templates stored at ~/.aip/templates/<name>.aip
80
- // ─────────────────────────────────────────────────────────────────
81
-
82
73
  const TEMPLATES_DIR = path.join(require('os').homedir(), '.aip', 'templates')
83
74
 
84
75
  function ensureTemplatesDir() {
85
76
  if (!fs.existsSync(TEMPLATES_DIR)) fs.mkdirSync(TEMPLATES_DIR, { recursive: true })
86
77
  }
87
78
 
88
- // Built-in templates (interpolate {{name}} and {{year}})
89
79
  const BUILTIN_TEMPLATES = {
90
80
  saas: `# {{name}}
91
81
  ~db sqlite ./app.db
@@ -255,24 +245,20 @@ function applyTemplateVars(src, name, year) {
255
245
  function getTemplate(tplName, name, year) {
256
246
  ensureTemplatesDir()
257
247
 
258
- // 1. Local file path: --template ./my-template.aip or --template /abs/path.aip
259
248
  if (tplName.startsWith('./') || tplName.startsWith('../') || tplName.startsWith('/')) {
260
249
  const full = path.resolve(tplName)
261
250
  if (!fs.existsSync(full)) { console.error(`\n ✗ Template file not found: ${full}\n`); process.exit(1) }
262
251
  return applyTemplateVars(fs.readFileSync(full, 'utf8'), name, year)
263
252
  }
264
253
 
265
- // 2. User custom template: ~/.aip/templates/<name>.aip
266
254
  const customPath = path.join(TEMPLATES_DIR, tplName + '.aip')
267
255
  if (fs.existsSync(customPath)) {
268
256
  return applyTemplateVars(fs.readFileSync(customPath, 'utf8'), name, year)
269
257
  }
270
258
 
271
- // 3. Built-in template
272
259
  const builtin = BUILTIN_TEMPLATES[tplName]
273
260
  if (builtin) return applyTemplateVars(builtin, name, year)
274
261
 
275
- // Not found — show what's available
276
262
  const customs = fs.existsSync(TEMPLATES_DIR)
277
263
  ? fs.readdirSync(TEMPLATES_DIR).filter(f=>f.endsWith('.aip')).map(f=>f.replace('.aip',''))
278
264
  : []
@@ -297,17 +283,14 @@ function listTemplates() {
297
283
  console.log()
298
284
  }
299
285
 
300
- // ── template subcommand ──────────────────────────────────────────
301
286
  if (cmd === 'template') {
302
287
  const sub = args[0]
303
288
  ensureTemplatesDir()
304
289
 
305
- // aiplang template list
306
290
  if (!sub || sub === 'list' || sub === 'ls') {
307
291
  listTemplates(); process.exit(0)
308
292
  }
309
293
 
310
- // aiplang template save <name> [--from <file>]
311
294
  if (sub === 'save' || sub === 'add') {
312
295
  const tname = args[1]
313
296
  if (!tname) { console.error('\n ✗ Usage: aiplang template save <name> [--from <file>]\n'); process.exit(1) }
@@ -318,7 +301,7 @@ if (cmd === 'template') {
318
301
  if (!fs.existsSync(fp)) { console.error(`\n ✗ File not found: ${fp}\n`); process.exit(1) }
319
302
  src = fs.readFileSync(fp, 'utf8')
320
303
  } else {
321
- // Auto-detect: use pages/ directory or app.aip
304
+
322
305
  const sources = ['pages', 'app.aip', 'index.aip']
323
306
  const found = sources.find(s => fs.existsSync(s))
324
307
  if (!found) { console.error('\n ✗ No .aip files found. Use --from <file> to specify source.\n'); process.exit(1) }
@@ -335,7 +318,6 @@ if (cmd === 'template') {
335
318
  process.exit(0)
336
319
  }
337
320
 
338
- // aiplang template remove <name>
339
321
  if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
340
322
  const tname = args[1]
341
323
  if (!tname) { console.error('\n ✗ Usage: aiplang template remove <name>\n'); process.exit(1) }
@@ -345,13 +327,12 @@ if (cmd === 'template') {
345
327
  console.log(`\n ✓ Removed template: ${tname}\n`); process.exit(0)
346
328
  }
347
329
 
348
- // aiplang template edit <name>
349
330
  if (sub === 'edit' || sub === 'open') {
350
331
  const tname = args[1]
351
332
  if (!tname) { console.error('\n ✗ Usage: aiplang template edit <name>\n'); process.exit(1) }
352
333
  let dest = path.join(TEMPLATES_DIR, tname + '.aip')
353
334
  if (!fs.existsSync(dest)) {
354
- // create from built-in if exists
335
+
355
336
  const builtin = BUILTIN_TEMPLATES[tname]
356
337
  if (builtin) { fs.writeFileSync(dest, builtin); console.log(`\n ✓ Copied built-in "${tname}" to custom templates.\n`) }
357
338
  else { console.error(`\n ✗ Template "${tname}" not found.\n`); process.exit(1) }
@@ -362,7 +343,6 @@ if (cmd === 'template') {
362
343
  process.exit(0)
363
344
  }
364
345
 
365
- // aiplang template show <name>
366
346
  if (sub === 'show' || sub === 'cat') {
367
347
  const tname = args[1] || 'default'
368
348
  const customPath = path.join(TEMPLATES_DIR, tname + '.aip')
@@ -372,7 +352,6 @@ if (cmd === 'template') {
372
352
  console.error(`\n ✗ Template "${tname}" not found.\n`); process.exit(1)
373
353
  }
374
354
 
375
- // aiplang template export <name> [--out <file>]
376
355
  if (sub === 'export') {
377
356
  const tname = args[1]
378
357
  if (!tname) { console.error('\n ✗ Usage: aiplang template export <name>\n'); process.exit(1) }
@@ -390,7 +369,6 @@ if (cmd === 'template') {
390
369
  process.exit(1)
391
370
  }
392
371
 
393
- // ── Init ─────────────────────────────────────────────────────────
394
372
  if (cmd==='init') {
395
373
  const tplIdx = args.indexOf('--template')
396
374
  const tplName = tplIdx !== -1 ? args[tplIdx+1] : 'default'
@@ -399,15 +377,13 @@ if (cmd==='init') {
399
377
 
400
378
  if (fs.existsSync(dir)) { console.error(`\n ✗ Directory "${name}" already exists.\n`); process.exit(1) }
401
379
 
402
- // Get template source (built-in, custom, or file path)
403
380
  const tplSrc = getTemplate(tplName, name, year)
404
381
 
405
- // Check if template has full-stack backend (models/api blocks)
406
382
  const isFullStack = tplSrc.includes('\nmodel ') || tplSrc.includes('\napi ')
407
383
  const isMultiFile = tplSrc.includes('\n---\n')
408
384
 
409
385
  if (isFullStack) {
410
- // Full-stack project: single app.aip
386
+
411
387
  fs.mkdirSync(dir, { recursive: true })
412
388
  fs.writeFileSync(path.join(dir, 'app.aip'), tplSrc)
413
389
  fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
@@ -421,7 +397,7 @@ if (cmd==='init') {
421
397
  const label = tplName !== 'default' ? ` (template: ${tplName})` : ''
422
398
  console.log(`\n ✓ Created ${name}/${label}\n\n app.aip ← full-stack app (backend + frontend)\n\n Next:\n cd ${name} && npx aiplang start app.aip\n`)
423
399
  } else if (isMultiFile) {
424
- // Multi-page SSG project: pages/*.aip
400
+
425
401
  fs.mkdirSync(path.join(dir,'pages'), {recursive:true})
426
402
  fs.mkdirSync(path.join(dir,'public'), {recursive:true})
427
403
  for (const f of ['aiplang-runtime.js','aiplang-hydrate.js']) {
@@ -439,7 +415,7 @@ if (cmd==='init') {
439
415
  const files = fs.readdirSync(path.join(dir,'pages')).map(f=>f).join(', ')
440
416
  console.log(`\n ✓ Created ${name}/${label}\n\n pages/{${files}} ← edit these\n\n Next:\n cd ${name} && npx aiplang serve\n`)
441
417
  } else {
442
- // Single-page SSG project
418
+
443
419
  fs.mkdirSync(path.join(dir,'pages'), {recursive:true})
444
420
  fs.mkdirSync(path.join(dir,'public'), {recursive:true})
445
421
  for (const f of ['aiplang-runtime.js','aiplang-hydrate.js']) {
@@ -454,7 +430,6 @@ if (cmd==='init') {
454
430
  process.exit(0)
455
431
  }
456
432
 
457
- // ── New ───────────────────────────────────────────────────────────
458
433
  if (cmd==='new') {
459
434
  const name=args[0]; if(!name){console.error('\n ✗ Usage: aiplang new <page>\n');process.exit(1)}
460
435
  const dir=fs.existsSync('pages')?'pages':'.'
@@ -466,7 +441,6 @@ if (cmd==='new') {
466
441
  process.exit(0)
467
442
  }
468
443
 
469
- // ── Build ─────────────────────────────────────────────────────────
470
444
  if (cmd==='build') {
471
445
  const outIdx=args.indexOf('--out')
472
446
  const outDir=outIdx!==-1?args[outIdx+1]:'dist'
@@ -476,7 +450,7 @@ if (cmd==='build') {
476
450
  fs.readdirSync(input).filter(f=>f.endsWith('.aip')).forEach(f=>files.push(path.join(input,f)))
477
451
  } else if(input.endsWith('.aip')&&fs.existsSync(input)){ files.push(input) }
478
452
  if(!files.length){console.error(`\n ✗ No .aip files in: ${input}\n`);process.exit(1)}
479
- // Resolve ~import directives recursively
453
+
480
454
  function resolveImports(content, baseDir, seen=new Set()) {
481
455
  return content.replace(/^~import\s+["']?([^"'\n]+)["']?$/mg, (_, importPath) => {
482
456
  const resolved = path.resolve(baseDir, importPath.trim())
@@ -511,7 +485,6 @@ if (cmd==='build') {
511
485
  process.exit(0)
512
486
  }
513
487
 
514
- // ── Serve (hot reload) ────────────────────────────────────────────
515
488
  if (cmd==='serve'||cmd==='dev') {
516
489
  const root=path.resolve(args[0]||'.')
517
490
  const port=parseInt(process.env.PORT||'3000')
@@ -549,7 +522,6 @@ if (cmd==='serve'||cmd==='dev') {
549
522
  return
550
523
  }
551
524
 
552
- // ── Dev server (full-stack) ──────────────────────────────────────
553
525
  if (cmd === 'start' || cmd === 'run') {
554
526
  const aipFile = args[0]
555
527
  if (!aipFile || !fs.existsSync(aipFile)) {
@@ -573,10 +545,6 @@ if (cmd === 'start' || cmd === 'run') {
573
545
  console.error(`\n ✗ Unknown command: ${cmd}\n Run aiplang --help\n`)
574
546
  process.exit(1)
575
547
 
576
- // ═════════════════════════════════════════════════════════════════
577
- // PARSER
578
- // ═════════════════════════════════════════════════════════════════
579
-
580
548
  function parsePages(src) {
581
549
  return src.split(/\n---\n/).map(s=>parsePage(s.trim())).filter(Boolean)
582
550
  }
@@ -619,8 +587,7 @@ function parseQuery(s) {
619
587
  }
620
588
 
621
589
  function parseBlock(line) {
622
- // ── Extract suffix modifiers FIRST ──────────────────────────
623
- // animate:fade-up class:my-class (can appear at end of any block line)
590
+
624
591
  let extraClass=null, animate=null
625
592
  const _cm=line.match(/\bclass:(\S+)/)
626
593
  if(_cm){extraClass=_cm[1];line=line.replace(_cm[0],'').trim()}
@@ -636,12 +603,10 @@ function parseBlock(line) {
636
603
  const _bgm=line.match(/\bbg:(#[0-9a-fA-F]+|[a-z]+)/)
637
604
  if(_bgm){bg=_bgm[1];line=line.replace(_bgm[0],'').trim()}
638
605
 
639
- // ── raw{} HTML passthrough ──────────────────────────────────
640
606
  if(line.startsWith('raw{')) {
641
607
  return{kind:'raw',html:line.slice(4,line.lastIndexOf('}')),extraClass,animate}
642
608
  }
643
609
 
644
- // ── table ───────────────────────────────────────────────────
645
610
  if(line.startsWith('table ') || line.startsWith('table{')) {
646
611
  const idx=line.indexOf('{');if(idx===-1) return null
647
612
  const start=line.startsWith('table{')?6:6
@@ -659,7 +624,6 @@ function parseBlock(line) {
659
624
  return{kind:'table',binding,cols:Array.isArray(cols)?cols:[],empty:parseEmpty(clean),editPath:em?.[2]||null,editMethod:em?.[1]||'PUT',deletePath:dm?.[1]||null,deleteKey:'id',fallback:fallbackM?.[1]?.trim()||null,retry:retryM?.[1]||null,extraClass,animate,variant,style,bg}
660
625
  }
661
626
 
662
- // ── form ────────────────────────────────────────────────────
663
627
  if(line.startsWith('form ') || line.startsWith('form{')) {
664
628
  const bi=line.indexOf('{');if(bi===-1) return null
665
629
  let head=line.slice(line.startsWith('form{')?4:5,bi).trim()
@@ -667,7 +631,7 @@ function parseBlock(line) {
667
631
  let action='', optimistic=false; const ai=head.indexOf('=>')
668
632
  if(ai!==-1){
669
633
  action=head.slice(ai+2).trim()
670
- // Optimistic: => @list.optimistic($result)
634
+
671
635
  if(action.includes('.optimistic(')){optimistic=true;action=action.replace('.optimistic','')}
672
636
  head=head.slice(0,ai).trim()
673
637
  }
@@ -677,7 +641,6 @@ function parseBlock(line) {
677
641
  return{kind:'form',method,bpath,action,optimistic,fields:parseFields(content)||[],extraClass,animate,variant,style,bg}
678
642
  }
679
643
 
680
- // ── pricing ─────────────────────────────────────────────────
681
644
  if(line.startsWith('pricing{')) {
682
645
  const body=line.slice(8,line.lastIndexOf('}')).trim()
683
646
  const plans=body.split('|').map(p=>{
@@ -687,14 +650,12 @@ function parseBlock(line) {
687
650
  return{kind:'pricing',plans,extraClass,animate,variant,style,bg}
688
651
  }
689
652
 
690
- // ── faq ─────────────────────────────────────────────────────
691
653
  if(line.startsWith('faq{')) {
692
654
  const body=line.slice(4,line.lastIndexOf('}')).trim()
693
655
  const items=body.split('|').map(i=>{const idx=i.indexOf('>');return{q:i.slice(0,idx).trim(),a:i.slice(idx+1).trim()}}).filter(i=>i.q&&i.a)
694
656
  return{kind:'faq',items,extraClass,animate}
695
657
  }
696
658
 
697
- // ── testimonial ──────────────────────────────────────────────
698
659
  if(line.startsWith('testimonial{')) {
699
660
  const body=line.slice(12,line.lastIndexOf('}')).trim()
700
661
  const parts=body.split('|').map(x=>x.trim())
@@ -702,12 +663,10 @@ function parseBlock(line) {
702
663
  return{kind:'testimonial',author:parts[0],quote:parts[1]?.replace(/^"|"$/g,''),img:imgPart?.slice(4)||null,extraClass,animate}
703
664
  }
704
665
 
705
- // ── gallery ──────────────────────────────────────────────────
706
666
  if(line.startsWith('gallery{')) {
707
667
  return{kind:'gallery',imgs:line.slice(8,line.lastIndexOf('}')).trim().split('|').map(x=>x.trim()).filter(Boolean),extraClass,animate}
708
668
  }
709
669
 
710
- // ── btn ──────────────────────────────────────────────────────
711
670
  if(line.startsWith('btn{')) {
712
671
  const parts=line.slice(4,line.lastIndexOf('}')).split('>').map(p=>p.trim())
713
672
  const label=parts[0]||'Click', method=parts[1]?.split(' ')[0]||'POST'
@@ -717,7 +676,6 @@ function parseBlock(line) {
717
676
  return{kind:'btn',label,method,bpath,action,confirm,extraClass,animate}
718
677
  }
719
678
 
720
- // ── card{} — standalone card customizável ──────────────────
721
679
  if(line.startsWith('card{') || line.startsWith('card ')) {
722
680
  const bi=line.indexOf('{'); if(bi===-1) return null
723
681
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -731,7 +689,6 @@ function parseBlock(line) {
731
689
  return{kind:'card',title,subtitle,img:imgPart?.slice(4)||null,link:linkPart||null,badge,bind,extraClass,animate,variant,style,bg}
732
690
  }
733
691
 
734
- // ── cols{} — grid de conteúdo livre ─────────────────────────
735
692
  if(line.startsWith('cols{') || (line.startsWith('cols ') && line.includes('{'))) {
736
693
  const bi=line.indexOf('{'); if(bi===-1) return null
737
694
  const head=line.slice(0,bi).trim()
@@ -742,19 +699,16 @@ function parseBlock(line) {
742
699
  return{kind:'cols',n,items,extraClass,animate,variant,style,bg}
743
700
  }
744
701
 
745
- // ── divider{} — separador visual ─────────────────────────────
746
702
  if(line.startsWith('divider') || line.startsWith('hr{')) {
747
703
  const label=line.match(/\{([^}]*)\}/)?.[1]?.trim()||null
748
704
  return{kind:'divider',label,extraClass,animate,variant,style}
749
705
  }
750
706
 
751
- // ── badge{} — label/tag destacado ───────────────────────────
752
707
  if(line.startsWith('badge{') || line.startsWith('tag{')) {
753
708
  const content=line.slice(line.indexOf('{')+1,line.lastIndexOf('}')).trim()
754
709
  return{kind:'badge',content,extraClass,animate,variant,style}
755
710
  }
756
711
 
757
- // ── select ───────────────────────────────────────────────────
758
712
  if(line.startsWith('select ')) {
759
713
  const bi=line.indexOf('{')
760
714
  const varName=bi!==-1?line.slice(7,bi).trim():line.slice(7).trim()
@@ -762,13 +716,11 @@ function parseBlock(line) {
762
716
  return{kind:'select',binding:varName,options:body.split('|').map(o=>o.trim()).filter(Boolean),extraClass,animate}
763
717
  }
764
718
 
765
- // ── if ───────────────────────────────────────────────────────
766
719
  if(line.startsWith('if ')) {
767
720
  const bi=line.indexOf('{');if(bi===-1) return null
768
721
  return{kind:'if',cond:line.slice(3,bi).trim(),inner:line.slice(bi+1,line.lastIndexOf('}')).trim(),extraClass,animate}
769
722
  }
770
723
 
771
- // ── chart{} — data visualization ───────────────────────────────
772
724
  if(line.startsWith('chart{') || line.startsWith('chart ')) {
773
725
  const bi=line.indexOf('{'); if(bi===-1) return null
774
726
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -781,7 +733,6 @@ function parseBlock(line) {
781
733
  return{kind:'chart',type,binding,labels,values,title,extraClass,animate,variant,style}
782
734
  }
783
735
 
784
- // ── kanban{} — drag-and-drop board ───────────────────────────────
785
736
  if(line.startsWith('kanban{') || line.startsWith('kanban ')) {
786
737
  const bi=line.indexOf('{'); if(bi===-1) return null
787
738
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -793,7 +744,6 @@ function parseBlock(line) {
793
744
  return{kind:'kanban',binding,cols,statusField,updatePath,extraClass,animate,style}
794
745
  }
795
746
 
796
- // ── editor{} — rich text editor ──────────────────────────────────
797
747
  if(line.startsWith('editor{') || line.startsWith('editor ')) {
798
748
  const bi=line.indexOf('{'); if(bi===-1) return null
799
749
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -804,7 +754,6 @@ function parseBlock(line) {
804
754
  return{kind:'editor',name,placeholder,submitPath,extraClass,animate,style}
805
755
  }
806
756
 
807
- // ── each @list { template } — loop como React .map() ────────
808
757
  if(line.startsWith('each ')) {
809
758
  const bi=line.indexOf('{');if(bi===-1) return null
810
759
  const binding=line.slice(5,bi).trim()
@@ -812,18 +761,15 @@ function parseBlock(line) {
812
761
  return{kind:'each',binding,tpl,extraClass,animate,variant}
813
762
  }
814
763
 
815
- // ── spacer{} — espaçamento customizável ──────────────────────
816
764
  if(line.startsWith('spacer{') || line.startsWith('spacer ')) {
817
765
  const h=line.match(/[{\s](\S+)[}]?/)?.[1]||'3rem'
818
766
  return{kind:'spacer',height:h,extraClass,animate}
819
767
  }
820
768
 
821
- // ── html{} — HTML inline com interpolação de @state ──────────
822
769
  if(line.startsWith('html{')) {
823
770
  return{kind:'html',content:line.slice(5,line.lastIndexOf('}')),extraClass,animate}
824
771
  }
825
772
 
826
- // ── regular blocks (nav, hero, stats, rowN, sect, foot) ──────
827
773
  const bi=line.indexOf('{');if(bi===-1) return null
828
774
  const head=line.slice(0,bi).trim()
829
775
  const body=line.slice(bi+1,line.lastIndexOf('}')).trim()
@@ -846,25 +792,55 @@ function parseCols(s){return s.split('|').map(c=>{c=c.trim();if(c.startsWith('em
846
792
  function parseEmpty(s){const m=s.match(/empty:\s*([^|]+)/);return m?m[1].trim():'No data.'}
847
793
  function parseFields(s){return s.split('|').map(f=>{const[label,type,ph]=f.split(':').map(x=>x.trim());return label?{label,type:type||'text',placeholder:ph||'',name:label.toLowerCase().replace(/\s+/g,'_')}:null}).filter(Boolean)}
848
794
 
849
- // ═════════════════════════════════════════════════════════════════
850
- // RENDERER
851
- // ═════════════════════════════════════════════════════════════════
852
-
853
795
  function applyMods(html, b) {
854
796
  if(!html||(!b.extraClass&&!b.animate)) return html
855
797
  const cls=[(b.extraClass||''),(b.animate?'fx-anim-'+b.animate:'')].filter(Boolean).join(' ')
856
- // Inject into first tag's class attribute (handles multiline HTML)
798
+
857
799
  return html.replace(/class="([^"]*)"/, (_,c)=>`class="${c} ${cls}"`)
858
800
  }
859
801
 
860
802
  function renderPage(page, allPages) {
861
803
  const needsJS=page.queries.length>0||page.blocks.some(b=>['table','list','form','if','btn','select','faq'].includes(b.kind))
862
804
  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||{}}):''
805
+
806
+ const tableBlocks = page.blocks.filter(b => b.kind === 'table' && b.binding && b.cols && b.cols.length)
807
+ const numericKeys = ['score','count','total','amount','price','value','qty','age','rank','num','int','float','rate','pct','percent']
808
+ const compiledDiffs = tableBlocks.map(b => {
809
+ const binding = b.binding.replace(/^@/, '')
810
+
811
+ const safeId = s => (s||'').replace(/[^a-zA-Z0-9_]/g, '_').slice(0,64) || 'col'
812
+ const safeBinding = safeId(binding)
813
+ const colDefs = b.cols.map((col, j) => ({
814
+ key: safeId(col.key),
815
+ origKey: col.key,
816
+ idx: j,
817
+ numeric: numericKeys.some(kw => col.key.toLowerCase().includes(kw))
818
+ }))
819
+ const initParts = colDefs.map(d =>
820
+ d.numeric ? `c${d.idx}:new Float64Array(rows.map(r=>+(r.${JSON.stringify(d.origKey)}===undefined?r['${d.origKey}']:r[${JSON.stringify(d.origKey)}])||0))`
821
+ : `c${d.idx}:rows.map(r=>r[${JSON.stringify(d.origKey)}]??'')`
822
+ ).join(',')
823
+ const diffParts = colDefs.map(d => {
824
+ const k = JSON.stringify(d.origKey)
825
+ return d.numeric
826
+ ? `if(c${d.idx}[i]!==(r[${k}]||0)){c${d.idx}[i]=r[${k}]||0;p.push(i<<4|${d.idx})}`
827
+ : `if(c${d.idx}[i]!==r[${k}]){c${d.idx}[i]=r[${k}];p.push(i<<4|${d.idx})}`
828
+ }).join(';')
829
+ return [
830
+ `window.__aip_init_${safeBinding}=function(rows){return{${initParts}}};`,
831
+ `window.__aip_diff_${safeBinding}=function(rows,cache){`,
832
+ `const n=rows.length,p=[],${colDefs.map(d=>`c${d.idx}=cache.c${d.idx}`).join(',')};`,
833
+ `for(let i=0;i<n;i++){const r=rows[i];${diffParts}}return p};`
834
+ ].join('')
835
+ }).join('\n')
836
+ const compiledScript = compiledDiffs.length
837
+ ? `<script>/* aiplang compiled-diffs */\n${compiledDiffs}\n</script>`
838
+ : ''
839
+ 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(/^@/,'').replace(/[^a-zA-Z0-9_]/g,'_').slice(0,64))}):''
864
840
  const hydrate=needsJS?`\n<script>window.__AIPLANG_PAGE__=${config};</script>\n<script src="./aiplang-hydrate.js" defer></script>`:''
865
841
  const customVars=page.customTheme?genCustomThemeVars(page.customTheme):''
866
842
  const themeVarCSS=page.themeVars?genThemeVarCSS(page.themeVars):''
867
- // Extract app name from nav brand if available
843
+
868
844
  const _navBlock = page.blocks.find(b=>b.kind==='nav')
869
845
  const _brand = _navBlock?.brand || ''
870
846
  const _title = _brand ? `${esc(_brand)} — ${esc(page.id.charAt(0).toUpperCase()+page.id.slice(1))}` : esc(page.id.charAt(0).toUpperCase()+page.id.slice(1))
@@ -920,7 +896,6 @@ function renderBlock(b, page) {
920
896
  }
921
897
  }
922
898
 
923
- // ── Chart — lazy-loads Chart.js from CDN ────────────────────────
924
899
  function rChart(b) {
925
900
  const id = 'chart_' + Math.random().toString(36).slice(2,8)
926
901
  const binding = b.binding || ''
@@ -931,7 +906,6 @@ function rChart(b) {
931
906
  </div>\n`
932
907
  }
933
908
 
934
- // ── Kanban — drag-and-drop board ─────────────────────────────────
935
909
  function rKanban(b) {
936
910
  const cols = (b.cols||['Todo','In Progress','Done'])
937
911
  const colsHtml = cols.map(col => `
@@ -943,7 +917,6 @@ function rKanban(b) {
943
917
  return `<div class="fx-kanban" data-fx-kanban="${esc(b.binding||'')}" data-status-field="${esc(b.statusField||'status')}" data-update-path="${esc(b.updatePath||'')}"${style}>${colsHtml}</div>\n`
944
918
  }
945
919
 
946
- // ── Rich text editor ──────────────────────────────────────────────
947
920
  function rEditor(b) {
948
921
  const style = b.style ? ` style="${b.style.replace(/,/g,';')}"` : ''
949
922
  return `<div class="fx-editor-wrap"${style}>
@@ -1029,7 +1002,7 @@ function rRow(b) {
1029
1002
  pink:'#ec4899',cyan:'#06b6d4',lime:'#84cc16',amber:'#f59e0b'
1030
1003
  }
1031
1004
  const cards=(b.items||[]).map(item=>{
1032
- // First token can be color: red|rocket>Title>Body
1005
+
1033
1006
  let colorStyle='', firstIdx=0
1034
1007
  if(item[0]&&!item[0].isImg&&!item[0].isLink){
1035
1008
  const colorKey=item[0].text?.toLowerCase()
@@ -1150,7 +1123,6 @@ function rGallery(b) {
1150
1123
  return `<div class="fx-gallery">${imgs}</div>\n`
1151
1124
  }
1152
1125
 
1153
- // ── Theme helpers ─────────────────────────────────────────────────
1154
1126
  function genCustomThemeVars(ct) {
1155
1127
  return `body{background:${ct.bg};color:${ct.text}}.fx-nav{background:${ct.bg}cc;border-bottom:1px solid ${ct.text}18}.fx-cta,.fx-btn{background:${ct.accent};color:#fff}.fx-card{background:${ct.surface||ct.bg};border:1px solid ${ct.text}15}.fx-form{background:${ct.surface||ct.bg};border:1px solid ${ct.text}15}.fx-input{background:${ct.bg};border:1px solid ${ct.text}30;color:${ct.text}}.fx-stat-lbl,.fx-card-body,.fx-sub,.fx-sect-body,.fx-footer-text{color:${ct.text}88}.fx-th,.fx-nav-link{color:${ct.text}77}.fx-footer{border-top:1px solid ${ct.text}15}.fx-th{border-bottom:1px solid ${ct.text}15}`
1156
1128
  }
@@ -1170,10 +1142,6 @@ function genThemeVarCSS(t) {
1170
1142
  return r.join('')
1171
1143
  }
1172
1144
 
1173
- // ═════════════════════════════════════════════════════════════════
1174
- // CSS
1175
- // ═════════════════════════════════════════════════════════════════
1176
-
1177
1145
  function css(theme) {
1178
1146
  const base=`*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html{scroll-behavior:smooth}body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}a{text-decoration:none;color:inherit}input,button,select{font-family:inherit}img{max-width:100%;height:auto}.fx-nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2.5rem;position:sticky;top:0;z-index:50;backdrop-filter:blur(12px);flex-wrap:wrap;gap:.5rem}.fx-brand{font-size:1.25rem;font-weight:800;letter-spacing:-.03em}.fx-nav-links{display:flex;align-items:center;gap:1.75rem}.fx-nav-link{font-size:.875rem;font-weight:500;opacity:.65;transition:opacity .15s}.fx-nav-link:hover{opacity:1}.fx-hamburger{display:none;flex-direction:column;gap:5px;background:none;border:none;cursor:pointer;padding:.25rem}.fx-hamburger span{display:block;width:22px;height:2px;background:currentColor;transition:all .2s;border-radius:1px}.fx-hamburger.open span:nth-child(1){transform:rotate(45deg) translate(5px,5px)}.fx-hamburger.open span:nth-child(2){opacity:0}.fx-hamburger.open span:nth-child(3){transform:rotate(-45deg) translate(5px,-5px)}@media(max-width:640px){.fx-hamburger{display:flex}.fx-nav-links{display:none;width:100%;flex-direction:column;align-items:flex-start;gap:.75rem;padding:.75rem 0}.fx-nav-links.open{display:flex}}.fx-hero{display:flex;align-items:center;justify-content:center;min-height:92vh;padding:4rem 1.5rem}.fx-hero-split{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center;padding:4rem 2.5rem;min-height:70vh}@media(max-width:768px){.fx-hero-split{grid-template-columns:1fr}}.fx-hero-img{width:100%;border-radius:1.25rem;object-fit:cover;max-height:500px}.fx-hero-inner{max-width:56rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.5rem}.fx-hero-split .fx-hero-inner{text-align:left;align-items:flex-start;max-width:none}.fx-title{font-size:clamp(2.5rem,8vw,5.5rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-sub{font-size:clamp(1rem,2vw,1.25rem);line-height:1.75;max-width:40rem}.fx-cta{display:inline-flex;align-items:center;padding:.875rem 2.5rem;border-radius:.75rem;font-weight:700;font-size:1rem;letter-spacing:-.01em;transition:transform .15s;margin:.25rem}.fx-cta:hover{transform:translateY(-1px)}.fx-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:3rem;padding:5rem 2.5rem;text-align:center}.fx-stat-val{font-size:clamp(2.5rem,5vw,4rem);font-weight:900;letter-spacing:-.04em;line-height:1}.fx-stat-lbl{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.1em;margin-top:.5rem}.fx-grid{display:grid;gap:1.25rem;padding:1rem 2.5rem 5rem}.fx-grid-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.fx-grid-3{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}.fx-grid-4{grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}.fx-card{border-radius:1rem;padding:1.75rem;transition:transform .2s,box-shadow .2s}.fx-card:hover{transform:translateY(-2px)}.fx-card-img{width:100%;border-radius:.75rem;object-fit:cover;height:180px;margin-bottom:1rem}.fx-icon{font-size:2rem;margin-bottom:1rem}.fx-card-title{font-size:1.0625rem;font-weight:700;letter-spacing:-.02em;margin-bottom:.5rem}.fx-card-body{font-size:.875rem;line-height:1.65}.fx-card-link{font-size:.8125rem;font-weight:600;display:inline-block;margin-top:1rem;opacity:.6;transition:opacity .15s}.fx-card-link:hover{opacity:1}.fx-sect{padding:5rem 2.5rem}.fx-sect-title{font-size:clamp(1.75rem,4vw,3rem);font-weight:800;letter-spacing:-.04em;margin-bottom:1.5rem;text-align:center}.fx-sect-body{font-size:1rem;line-height:1.75;text-align:center;max-width:48rem;margin:0 auto}.fx-form-wrap{padding:3rem 2.5rem;display:flex;justify-content:center}.fx-form{width:100%;max-width:28rem;border-radius:1.25rem;padding:2.5rem}.fx-field{margin-bottom:1.25rem}.fx-label{display:block;font-size:.8125rem;font-weight:600;margin-bottom:.5rem}.fx-input{width:100%;padding:.75rem 1rem;border-radius:.625rem;font-size:.9375rem;outline:none;transition:box-shadow .15s}.fx-input:focus{box-shadow:0 0 0 3px rgba(37,99,235,.35)}.fx-btn{width:100%;padding:.875rem 1.5rem;border:none;border-radius:.625rem;font-size:.9375rem;font-weight:700;cursor:pointer;margin-top:.5rem;transition:transform .15s,opacity .15s;letter-spacing:-.01em}.fx-btn:hover{transform:translateY(-1px)}.fx-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}.fx-btn-wrap{padding:0 2.5rem 1.5rem}.fx-standalone-btn{width:auto;padding:.75rem 2rem;margin-top:0}.fx-form-msg{font-size:.8125rem;padding:.5rem 0;min-height:1.5rem;text-align:center}.fx-form-err{color:#f87171}.fx-form-ok{color:#4ade80}.fx-table-wrap{overflow-x:auto;padding:0 2.5rem 4rem}.fx-table{width:100%;border-collapse:collapse;font-size:.875rem}.fx-th{text-align:left;padding:.875rem 1.25rem;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.fx-th-actions{opacity:.6}.fx-tr{transition:background .1s}.fx-td{padding:.875rem 1.25rem}.fx-td-empty{padding:2rem 1.25rem;text-align:center;opacity:.4}.fx-td-actions{white-space:nowrap;padding:.5rem 1rem!important}.fx-action-btn{border:none;cursor:pointer;font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:.375rem;margin-right:.375rem;font-family:inherit;transition:opacity .15s}.fx-action-btn:hover{opacity:.85}.fx-edit-btn{background:#1e40af;color:#93c5fd}.fx-delete-btn{background:#7f1d1d;color:#fca5a5}.fx-select-wrap{padding:.5rem 2.5rem}.fx-select-block{width:auto;min-width:200px;margin-top:0}.fx-pricing{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.5rem;padding:2rem 2.5rem 5rem;align-items:start}.fx-pricing-card{border-radius:1.25rem;padding:2rem;position:relative;transition:transform .2s}.fx-pricing-featured{transform:scale(1.03)}.fx-pricing-badge{position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:#2563eb;color:#fff;font-size:.7rem;font-weight:700;padding:.25rem .875rem;border-radius:999px;white-space:nowrap;letter-spacing:.05em}.fx-pricing-name{font-size:.875rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem;opacity:.7}.fx-pricing-price{font-size:3rem;font-weight:900;letter-spacing:-.05em;line-height:1;margin-bottom:.75rem}.fx-pricing-desc{font-size:.875rem;line-height:1.65;margin-bottom:1.5rem;opacity:.7}.fx-pricing-cta{display:block;text-align:center;padding:.75rem;border-radius:.625rem;font-weight:700;font-size:.9rem;transition:opacity .15s}.fx-pricing-cta:hover{opacity:.85}.fx-faq{max-width:48rem;margin:0 auto}.fx-faq-item{border-radius:.75rem;margin-bottom:.625rem;cursor:pointer;overflow:hidden;transition:background .15s}.fx-faq-q{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.25rem;font-size:.9375rem;font-weight:600}.fx-faq-arrow{transition:transform .2s;font-size:.75rem;opacity:.5}.fx-faq-item.open .fx-faq-arrow{transform:rotate(90deg)}.fx-faq-a{max-height:0;overflow:hidden;padding:0 1.25rem;font-size:.875rem;line-height:1.7;transition:max-height .3s,padding .3s}.fx-faq-item.open .fx-faq-a{max-height:300px;padding:.75rem 1.25rem 1.25rem}.fx-testi-wrap{padding:5rem 2.5rem;display:flex;justify-content:center}.fx-testi{max-width:42rem;text-align:center;display:flex;flex-direction:column;align-items:center;gap:1.25rem}.fx-testi-img{width:64px;height:64px;border-radius:50%;object-fit:cover}.fx-testi-avatar{width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:700;background:#1e293b}.fx-testi-quote{font-size:1.25rem;line-height:1.7;font-style:italic;opacity:.9}.fx-testi-author{font-size:.875rem;font-weight:600;opacity:.5}.fx-gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.75rem;padding:1rem 2.5rem 4rem}.fx-gallery-item{border-radius:.75rem;overflow:hidden;aspect-ratio:4/3}.fx-gallery-item img{width:100%;height:100%;object-fit:cover;transition:transform .3s}.fx-gallery-item:hover img{transform:scale(1.04)}.fx-if-wrap{display:contents}.fx-footer{padding:3rem 2.5rem;text-align:center}.fx-footer-text{font-size:.8125rem}.fx-footer-link{font-size:.8125rem;margin:0 .75rem;opacity:.5;transition:opacity .15s}.fx-footer-link:hover{opacity:1}
1179
1147
  .fx-hero-minimal{min-height:50vh!important}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiplang",
3
- "version": "2.10.7",
3
+ "version": "2.10.9",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -6,23 +6,20 @@
6
6
  const cfg = window.__AIPLANG_PAGE__
7
7
  if (!cfg) return
8
8
 
9
- // ── Global Store — cross-page state (like React Context / Zustand) ─
10
9
  const _STORE_KEY = 'aiplang_store_v1'
11
10
  const _globalStore = (() => {
12
11
  try { return JSON.parse(sessionStorage.getItem(_STORE_KEY) || '{}') } catch { return {} }
13
12
  })()
14
13
  function syncStore(key, value) {
15
14
  _globalStore[key] = value
16
- try { sessionStorage.setItem(_STORE_KEY, JSON.stringify(_globalStore)) } catch {}
17
- try { new BroadcastChannel(_STORE_KEY).postMessage({ key, value }) } catch {}
15
+ try { sessionStorage.setItem(_STORE_KEY, JSON.stringify(_globalStore)) } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
16
+ try { new BroadcastChannel(_STORE_KEY).postMessage({ key, value }) } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
18
17
  }
19
18
 
20
- // ── Page-level State ─────────────────────────────────────────────
21
19
  const _state = {}
22
20
  const _watchers = {}
23
21
  const _storeKeys = new Set((cfg.stores || []).map(s => s.key))
24
22
 
25
- // Bootstrap state: SSR data > global store > page state declarations
26
23
  const _boot = { ...(window.__SSR_DATA__ || {}), ..._globalStore }
27
24
  for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
28
25
  try { _state[k] = typeof v === 'string' && (v.startsWith('[') || v.startsWith('{') || v === 'true' || v === 'false' || !isNaN(v)) ? JSON.parse(v) : v } catch { _state[k] = v }
@@ -31,14 +28,14 @@ for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
31
28
  function get(key) { return _state[key] }
32
29
 
33
30
  function set(key, value, _persist) {
34
- // Fast equality check: primitives first (avoid JSON.stringify for numbers/strings)
31
+
35
32
  const old = _state[key]
36
33
  if (old === value) return
37
34
  if (typeof value !== 'object' && old === value) return
38
35
  if (typeof value === 'object' && value !== null && typeof old === 'object' && old !== null) {
39
- // Only deep check for objects/arrays — skip if different length (fast exit)
36
+
40
37
  if (Array.isArray(value) && Array.isArray(old) && value.length !== old.length) {
41
- // Different length — definitely changed
38
+
42
39
  } else if (JSON.stringify(old) === JSON.stringify(value)) return
43
40
  }
44
41
  _state[key] = value
@@ -46,13 +43,12 @@ function set(key, value, _persist) {
46
43
  notify(key)
47
44
  }
48
45
 
49
- // Cross-tab store sync (other pages update when store changes)
50
46
  try {
51
47
  const _bc = new BroadcastChannel(_STORE_KEY)
52
48
  _bc.onmessage = ({ data: { key, value } }) => {
53
49
  _state[key] = value; notify(key)
54
50
  }
55
- } catch {}
51
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
56
52
 
57
53
  function watch(key, cb) {
58
54
  if (!_watchers[key]) _watchers[key] = []
@@ -77,8 +73,7 @@ function notify(key) {
77
73
  _pending.add(key)
78
74
  if (!_batchScheduled) {
79
75
  _batchScheduled = true
80
- // Use microtask (Promise.resolve) for data fetches — fires faster than rAF
81
- // Use rAF for user interaction (avoids mid-frame layout thrash)
76
+
82
77
  Promise.resolve().then(() => {
83
78
  if (_batchScheduled) requestAnimationFrame(flushBatch)
84
79
  })
@@ -105,14 +100,54 @@ function resolvePath(tmpl, row) {
105
100
 
106
101
  const _intervals = []
107
102
 
103
+ const _sid = Math.random().toString(36).slice(2)
104
+
108
105
  async function runQuery(q) {
109
106
  const path = resolve(q.path)
110
- const opts = { method: q.method, headers: { 'Content-Type': 'application/json' } }
107
+ const isGet = (q.method || 'GET').toUpperCase() === 'GET'
108
+ const opts = {
109
+ method: q.method || 'GET',
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ 'Accept': 'application/msgpack, application/json',
113
+ 'x-session-id': _sid
114
+ }
115
+ }
116
+
117
+ if (isGet && q._deltaReady) opts.headers['x-aiplang-delta'] = '1'
111
118
  if (q.body) opts.body = JSON.stringify(q.body)
112
119
  try {
113
- const res = await fetch(path, opts)
120
+ const res = await fetch(path, opts)
121
+
122
+ if (res.status === 304) { q._deltaReady = true; return get(q.target?.replace(/^@/,'')) || null }
114
123
  if (!res.ok) throw new Error('HTTP ' + res.status)
115
- const data = await res.json()
124
+ const ct = res.headers.get('Content-Type') || ''
125
+ let data
126
+ if (ct.includes('msgpack')) {
127
+ const buf = await res.arrayBuffer()
128
+ data = _mp.decode(new Uint8Array(buf))
129
+ } else {
130
+ data = await res.json()
131
+ }
132
+
133
+ if (data && data.__delta) {
134
+ const key = q.target ? q.target.replace(/^@/, '') : null
135
+ if (key) {
136
+ const current = [...(get(key) || [])]
137
+
138
+ for (const row of (data.changed || [])) {
139
+ const idx = current.findIndex(r => r.id === row.id)
140
+ if (idx >= 0) current[idx] = row; else current.push(row)
141
+ }
142
+
143
+ const delSet = new Set(data.deleted || [])
144
+ const merged = current.filter(r => !delSet.has(r.id))
145
+ set(key, merged)
146
+ q._deltaReady = true
147
+ return merged
148
+ }
149
+ }
150
+ q._deltaReady = true
116
151
  applyAction(data, q.target, q.action)
117
152
  return data
118
153
  } catch (e) {
@@ -147,7 +182,7 @@ function applyAction(data, target, action) {
147
182
  return true
148
183
  })
149
184
  set(fm[1], filtered)
150
- } catch {}
185
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
151
186
  return
152
187
  }
153
188
  const am = action.match(/^@([a-zA-Z_]+)\s*=\s*\$result$/)
@@ -281,15 +316,12 @@ function hydrateTables() {
281
316
  }
282
317
  }
283
318
 
284
- // ── Row cache for surgical DOM updates ──────────────────────────
285
- // First render: full DocumentFragment build (fast)
286
- // Re-renders: only update cells that actually changed
287
- const _rowCache = new Map() // id → {score, status, ..., tr element}
319
+ const _rowCache = new Map()
288
320
  const _colKeys = cols.map(c => c.key)
289
321
  let _initialized = false
290
322
 
291
323
  const renderRow = (row, idx) => {
292
- // (defined above, used by virtual scroll too)
324
+
293
325
  }
294
326
 
295
327
  const render = () => {
@@ -297,7 +329,6 @@ function hydrateTables() {
297
329
  let rows = get(key)
298
330
  if (!Array.isArray(rows)) rows = []
299
331
 
300
- // Empty state
301
332
  if (!rows.length) {
302
333
  tbody.innerHTML = ''
303
334
  _rowCache.clear()
@@ -428,9 +459,8 @@ function hydrateTables() {
428
459
  tbody.appendChild(tr)
429
460
  }
430
461
 
431
- // ── Surgical update vs full initial build ────────────────────
432
462
  if (!_initialized) {
433
- // INITIAL: DocumentFragment for single layout pass
463
+
434
464
  _initialized = true
435
465
  tbody.innerHTML = ''
436
466
  const frag = document.createDocumentFragment()
@@ -446,7 +476,7 @@ function hydrateTables() {
446
476
  td.textContent = row[col.key] != null ? row[col.key] : ''
447
477
  tr.appendChild(td)
448
478
  }
449
- // Action cells (edit + delete)
479
+
450
480
  if (editPath || delPath) {
451
481
  const actTd = document.createElement('td')
452
482
  actTd.className = 'fx-td fx-td-actions'
@@ -484,16 +514,19 @@ function hydrateTables() {
484
514
  frag.appendChild(tr)
485
515
  }
486
516
  tbody.appendChild(frag)
487
- // Build TypedArray cache for ultra-fast subsequent diffs (beats Vue Vapor)
517
+
488
518
  try {
489
519
  const tc = _buildTypedCache(rows, _colKeys)
490
520
  _rowCache._typed = tc
491
- } catch {}
521
+
522
+ const compiledInit = window['__aip_init_' + stateKey]
523
+ if (compiledInit) {
524
+ _rowCache._compiled = compiledInit(rows)
525
+ _rowCache._compiled_n = rows.length
526
+ _rowCache._ids = rows.map(r => r.id != null ? r.id : null)
527
+ }
528
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
492
529
  } else {
493
- // UPDATE: off-main-thread diff + requestIdleCallback
494
- // For 500+ rows: Worker computes diff on separate CPU core
495
- // For <500 rows: sync diff (worker overhead not worth it)
496
- // DOM patches always run on main thread but are minimal
497
530
 
498
531
  const _makeRow = (row, idx) => {
499
532
  const tr = document.createElement('tr')
@@ -531,10 +564,10 @@ function hydrateTables() {
531
564
  }
532
565
 
533
566
  if (rows.length >= 500) {
534
- // Large: worker diff → idle callback apply (zero main thread impact)
567
+
535
568
  _diffAsync(rows, _colKeys, _rowCache).then(result => _schedIdle(() => _applyResult(result)))
536
569
  } else {
537
- // Small: sync diff, immediate apply
570
+
538
571
  _applyResult(_diffSync(rows, _colKeys, _rowCache))
539
572
  }
540
573
  }
@@ -648,7 +681,7 @@ function hydrateBindings() {
648
681
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
649
682
  const expr = el.getAttribute('data-fx-bind')
650
683
  const keys = (expr.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []).map(m => m.slice(1).split('.')[0])
651
- // Fast path: single key with simple path — direct textContent assignment
684
+
652
685
  const simpleM = expr.match(/^[@$]([a-zA-Z_][a-zA-Z0-9_.]*)$/)
653
686
  if (simpleM) {
654
687
  const path = simpleM[1].split('.')
@@ -826,19 +859,20 @@ function injectActionCSS() {
826
859
  document.head.appendChild(style)
827
860
  }
828
861
 
829
- // ═══════════════════════════════════════════════════════════════════
830
- // OFF-MAIN-THREAD ENGINE — better than React Fiber
831
- // Fiber splits work across frames on the SAME thread.
832
- // This moves diff computation to a SEPARATE CPU core via Web Worker.
833
- // Main thread only handles tiny DOM patches — never competes with animations.
834
- // ═══════════════════════════════════════════════════════════════════
862
+ const _workerSrc = `
863
+ 'use strict'
864
+
865
+ let _sab = null, _sabView = null
835
866
 
836
- const _workerSrc = `'use strict'
837
867
  self.onmessage = function(e) {
838
- const { type, rows, colKeys, cache, reqId } = e.data
868
+ const { type, rows, colKeys, cache, reqId, sab } = e.data
869
+
870
+ if (sab) { _sab = sab; _sabView = new Int32Array(sab) }
871
+
839
872
  if (type !== 'diff') return
840
873
  const patches = [], inserts = [], deletes = []
841
874
  const seenIds = new Set()
875
+
842
876
  for (let i = 0; i < rows.length; i++) {
843
877
  const row = rows[i]
844
878
  const id = row.id != null ? row.id : i
@@ -852,15 +886,31 @@ self.onmessage = function(e) {
852
886
  }
853
887
  }
854
888
  for (const id in cache) {
855
- const nid = typeof id === 'number' ? id : (isNaN(id) ? id : Number(id))
889
+ const nid = isNaN(id) ? id : Number(id)
856
890
  if (!seenIds.has(String(id)) && !seenIds.has(nid)) deletes.push(id)
857
891
  }
858
- self.postMessage({ type: 'patches', patches, inserts, deletes, reqId })
859
- }`
892
+
893
+ if (_sabView && patches.length < 8000) {
894
+ Atomics.store(_sabView, 0, patches.length)
895
+ for (let i = 0; i < patches.length; i++) {
896
+
897
+ _sabView[1 + i*3] = typeof patches[i].id === 'number' ? patches[i].id : patches[i].id.length
898
+ _sabView[1 + i*3+1] = patches[i].col
899
+ _sabView[1 + i*3+2] = 0
900
+ }
901
+ Atomics.store(_sabView, 0, patches.length | 0x80000000)
902
+
903
+ self.postMessage({ type: 'patches', patches, inserts, deletes, reqId, usedSAB: true })
904
+ } else {
905
+ self.postMessage({ type: 'patches', patches, inserts, deletes, reqId, usedSAB: false })
906
+ }
907
+ }
908
+ `
860
909
 
861
910
  let _diffWorker = null
862
911
  const _wCbs = new Map()
863
912
  let _wReq = 0
913
+ let _wSAB = null
864
914
 
865
915
  function _getWorker() {
866
916
  if (_diffWorker) return _diffWorker
@@ -871,6 +921,12 @@ function _getWorker() {
871
921
  if (cb) { cb(e.data); _wCbs.delete(e.data.reqId) }
872
922
  }
873
923
  _diffWorker.onerror = () => { _diffWorker = null }
924
+
925
+ try {
926
+ const sab = new SharedArrayBuffer(8000 * 3 * 4 + 4)
927
+ _diffWorker.postMessage({ type: 'init_sab', sab }, [])
928
+ _wSAB = new Int32Array(sab)
929
+ } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
874
930
  } catch { _diffWorker = null }
875
931
  return _diffWorker
876
932
  }
@@ -887,35 +943,30 @@ function _diffAsync(rows, colKeys, rowCache) {
887
943
  })
888
944
  }
889
945
 
890
- // ── TypedArray positional cache — beats Vue Vapor at all sizes ───
891
- // Float64Array for numeric fields, Uint8Array for status/enum
892
- // No Map.get in hot loop — pure positional array scan
893
- // Strategy: string-compare first for status (cheap), only encode int on change
894
-
895
946
  function _buildTypedCache(rows, colKeys) {
896
947
  const n = rows.length
897
948
  const isNum = colKeys.map(k => {
898
949
  const v = rows[0]?.[k]; return typeof v === 'number' || (v != null && !isNaN(Number(v)) && typeof v !== 'string')
899
950
  })
900
951
  const scores = new Float64Array(n)
901
- const statuses = new Uint8Array(n) // status/enum col (first non-numeric)
902
- const strCols = [] // which colKeys are strings
952
+ const statuses = new Uint8Array(n)
953
+ const strCols = []
903
954
  colKeys.forEach((k,j) => {
904
955
  if (!isNum[j]) strCols.push(j)
905
956
  })
906
957
  rows.forEach((r,i) => {
907
- // Numeric fields → Float64Array
958
+
908
959
  colKeys.forEach((k,j) => { if(isNum[j]) { const buf=j===0?scores:null; if(buf) buf[i]=Number(r[k])||0 } })
909
- // Primary numeric col (usually score/value)
960
+
910
961
  const numIdx = colKeys.findIndex((_,j)=>isNum[j] && j>1)
911
962
  if(numIdx>=0) scores[i] = Number(rows[i][colKeys[numIdx]])||0
912
- // Primary enum col
963
+
913
964
  const enumIdx = colKeys.findIndex((_,j)=>!isNum[j] && j>1)
914
965
  if(enumIdx>=0) statuses[i] = _STATUS_INT[rows[i][colKeys[enumIdx]]]??0
915
966
  })
916
967
  return {
917
968
  scores, statuses,
918
- prevVals: colKeys.map(k => rows.map(r => r[k])), // string cache per column
969
+ prevVals: colKeys.map(k => rows.map(r => r[k])),
919
970
  isNum, colKeys, n,
920
971
  ids: rows.map(r => r.id)
921
972
  }
@@ -925,14 +976,31 @@ function _diffSync(rows, colKeys, rowCache) {
925
976
  const patches = [], inserts = [], deletes = [], seen = new Set()
926
977
  const nCols = colKeys.length
927
978
 
928
- // FAST PATH: TypedArray positional scan — beats Vue Vapor at all sizes
929
- // Condition: row count unchanged (typical for polling updates)
979
+ const compiledKey = stateKey
980
+ const compiledInit = window['__aip_init_' + compiledKey]
981
+ const compiledDiff = window['__aip_diff_' + compiledKey]
982
+
983
+ if (compiledDiff && rowCache._compiled && rows.length === rowCache._compiled_n
984
+ && rows.length > 0 && rowCache._ids?.length === rows.length) {
985
+
986
+ const raw = compiledDiff(rows, rowCache._compiled)
987
+ for (const pack of raw) {
988
+ const i = pack >> 4, col = pack & 0xf
989
+ const id = rowCache._ids[i]
990
+ if (id != null) {
991
+ seen.add(id)
992
+ patches.push({ id, col, val: rows[i][colKeys[col]] })
993
+ }
994
+ }
995
+ for (const [id] of rowCache) if (id !== '_compiled' && id !== '_ids' && id !== '_compiled_n' && !seen.has(id)) deletes.push(id)
996
+ return { patches, inserts, deletes }
997
+ }
998
+
930
999
  const tc = rowCache._typed
931
1000
  if (tc && rows.length === tc.n) {
932
1001
  for (let i = 0; i < rows.length; i++) {
933
1002
  const r = rows[i], id = tc.ids[i]
934
1003
  seen.add(id)
935
- // Per-column diff using per-column strategy
936
1004
  for (let j = 0; j < nCols; j++) {
937
1005
  const k = colKeys[j]
938
1006
  const newVal = r[k]
@@ -947,7 +1015,6 @@ function _diffSync(rows, colKeys, rowCache) {
947
1015
  return { patches, inserts, deletes }
948
1016
  }
949
1017
 
950
- // STANDARD PATH: Map-based diff (first render or variable-length data)
951
1018
  for (let i = 0; i < rows.length; i++) {
952
1019
  const r = rows[i], id = r.id != null ? r.id : i
953
1020
  seen.add(id)
@@ -971,8 +1038,6 @@ function _diffSync(rows, colKeys, rowCache) {
971
1038
  return { patches, inserts, deletes }
972
1039
  }
973
1040
 
974
- // requestIdleCallback scheduler — runs low-priority work when browser is idle
975
- // Polling updates (30s intervals) don't need to be urgent — let animations breathe
976
1041
  const _idleQ = []
977
1042
  let _idleSched = false
978
1043
  const _ric = window.requestIdleCallback
@@ -990,13 +1055,11 @@ function _schedIdle(fn) {
990
1055
  function _flushIdle(dl) {
991
1056
  _idleSched = false
992
1057
  while (_idleQ.length && dl.timeRemaining() > 1) {
993
- try { _idleQ.shift()() } catch {}
1058
+ try { _idleQ.shift()() } catch(_e) { if(typeof console !== 'undefined') console.debug('[aiplang]',_e?.message) }
994
1059
  }
995
1060
  if (_idleQ.length) { _idleSched = true; _ric(_flushIdle, { timeout: 5000 }) }
996
1061
  }
997
1062
 
998
- // Incremental renderer — processes rows in chunks between animation frames
999
- // Zero dropped frames on 100k+ row datasets
1000
1063
  async function _renderIncremental(items, renderFn, chunkSize = 200) {
1001
1064
  for (let i = 0; i < items.length; i += chunkSize) {
1002
1065
  const chunk = items.slice(i, i + chunkSize)
@@ -1007,9 +1070,6 @@ async function _renderIncremental(items, renderFn, chunkSize = 200) {
1007
1070
  }
1008
1071
  }
1009
1072
 
1010
-
1011
- // ── Global reusable TypedArray buffers — zero allocation in hot path ──
1012
- // Pre-allocated at startup, reused across every table render cycle
1013
1073
  const _MAX_ROWS = 100000
1014
1074
  const _scoreBuf = new Float64Array(_MAX_ROWS)
1015
1075
  const _statusBuf = new Uint8Array(_MAX_ROWS)
@@ -1018,6 +1078,45 @@ const _STATUS_INT = {active:0,inactive:1,pending:2,blocked:3,
1018
1078
  pending:2,done:3,todo:0,doing:1,done:2,
1019
1079
  new:0,open:1,closed:2,resolved:3}
1020
1080
 
1081
+ const _mp = (() => {
1082
+ const td = new TextDecoder()
1083
+ function decode(buf) {
1084
+ const b = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf
1085
+ return _read(b, {p:0})
1086
+ }
1087
+ function _read(b, s) {
1088
+ const t = b[s.p++]
1089
+ if (t <= 0x7f) return t
1090
+ if (t >= 0xe0) return t - 256
1091
+ if ((t & 0xe0) === 0xa0) return _str(b, s, t & 0x1f)
1092
+ if ((t & 0xf0) === 0x90) return _arr(b, s, t & 0xf)
1093
+ if ((t & 0xf0) === 0x80) return _map(b, s, t & 0xf)
1094
+ switch (t) {
1095
+ case 0xc0: return null
1096
+ case 0xc2: return false
1097
+ case 0xc3: return true
1098
+ case 0xca: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return v.getFloat32(0) }
1099
+ case 0xcb: { const v=new DataView(b.buffer,b.byteOffset+s.p,8); s.p+=8; return v.getFloat64(0) }
1100
+ case 0xcc: return b[s.p++]
1101
+ case 0xcd: { const v=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return v }
1102
+ case 0xce: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return v.getUint32(0) }
1103
+ case 0xd0: { const v=b[s.p++]; return v>127?v-256:v }
1104
+ case 0xd1: { const v=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return v>32767?v-65536:v }
1105
+ case 0xd2: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return v.getInt32(0) }
1106
+ case 0xd9: { const n=b[s.p++]; return _str(b,s,n) }
1107
+ case 0xda: { const n=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return _str(b,s,n) }
1108
+ case 0xdc: { const n=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return _arr(b,s,n) }
1109
+ case 0xdd: { const v=new DataView(b.buffer,b.byteOffset+s.p,4); s.p+=4; return _arr(b,s,v.getUint32(0)) }
1110
+ case 0xde: { const n=(b[s.p]<<8)|b[s.p+1]; s.p+=2; return _map(b,s,n) }
1111
+ default: return null
1112
+ }
1113
+ }
1114
+ function _str(b,s,n){const v=td.decode(b.subarray(s.p,s.p+n));s.p+=n;return v}
1115
+ function _arr(b,s,n){const a=[];for(let i=0;i<n;i++)a.push(_read(b,s));return a}
1116
+ 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}
1117
+ return { decode }
1118
+ })()
1119
+
1021
1120
  function loadSSRData() {
1022
1121
  const ssr = window.__SSR_DATA__
1023
1122
  if (!ssr) return
@@ -1077,10 +1176,21 @@ function hydrateTableErrors() {
1077
1176
  if (val === '__error__') {
1078
1177
  if (tbody) {
1079
1178
  const cols = JSON.parse(tbl.getAttribute('data-fx-cols') || '[]')
1080
- tbody.innerHTML = `<tr><td colspan="${cols.length + 2}" class="fx-td-empty" style="color:#f87171">
1081
- ${fallback}
1082
- ${retryPath ? `<button onclick="window.__aiplang_retry('${binding}','${retryPath}')" style="margin-left:.75rem;padding:.3rem .75rem;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);color:#f87171;border-radius:.375rem;cursor:pointer;font-size:.75rem">↻ Retry</button>` : ''}
1083
- </td></tr>`
1179
+
1180
+ tbody.innerHTML = ''
1181
+ const _fe = (() => {
1182
+ const tr=document.createElement('tr'), td=document.createElement('td')
1183
+ td.colSpan=cols.length+2; td.className='fx-td-empty'; td.style.color='#f87171'
1184
+ td.textContent=fallback
1185
+ if(retryPath){
1186
+ const btn=document.createElement('button')
1187
+ btn.textContent='↻ Retry'; btn.style.cssText='margin-left:.75rem;padding:.3rem .75rem;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.3);color:#f87171;border-radius:.375rem;cursor:pointer;font-size:.75rem'
1188
+ btn.onclick=()=>window.__aiplang_retry&&window.__aiplang_retry(binding,retryPath)
1189
+ td.appendChild(btn)
1190
+ }
1191
+ tr.appendChild(td); return tr
1192
+ })()
1193
+ tbody.appendChild(_fe)
1084
1194
  }
1085
1195
  }
1086
1196
  })
package/server/server.js CHANGED
@@ -246,6 +246,72 @@ 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
+ const _DELTA_MAX = 2000 // max concurrent sessions (prevent memory DoS)
255
+
256
+ function _rowHash(row) {
257
+ // FNV-1a 32-bit — fast, low collision for row change detection
258
+ // Only hashes enumerable own properties to avoid prototype pollution
259
+ let h = 2166136261
260
+ const keys = Object.keys(row).sort()
261
+ for (const k of keys) {
262
+ const v = row[k]
263
+ if (v === null || v === undefined) continue
264
+ const s = k + ':' + String(v)
265
+ for (let i = 0; i < s.length; i++) {
266
+ h = (h ^ s.charCodeAt(i)) >>> 0
267
+ h = Math.imul(h, 16777619) >>> 0
268
+ }
269
+ }
270
+ return h >>> 0
271
+ }
272
+
273
+ function _getDelta(sessionId, binding, rows) {
274
+ const key = sessionId + ':' + binding
275
+ const prev = _deltaCache.get(key)
276
+ // First request — send full dataset, store hashes
277
+ if (!prev) {
278
+ // Evict oldest entry when at capacity (LRU approximation)
279
+ if (_deltaCache.size >= _DELTA_MAX) {
280
+ let oldest = null, oldestTs = Infinity
281
+ for (const [k, v] of _deltaCache) if (v.ts < oldestTs) { oldest = k; oldestTs = v.ts }
282
+ if (oldest) _deltaCache.delete(oldest)
283
+ }
284
+ const hashes = new Map(rows.map(r => [r.id, _rowHash(r)]))
285
+ _deltaCache.set(key, { hashes, ts: Date.now() })
286
+ return { full: true, rows }
287
+ }
288
+ // Subsequent: compute delta
289
+ const changed = [], deleted = []
290
+ const newHashes = new Map()
291
+ for (const row of rows) {
292
+ const h = _rowHash(row)
293
+ newHashes.set(row.id, h)
294
+ if (prev.hashes.get(row.id) !== h) changed.push(row)
295
+ }
296
+ for (const [id] of prev.hashes) {
297
+ if (!newHashes.has(id)) deleted.push(id)
298
+ }
299
+ prev.hashes = newHashes; prev.ts = Date.now()
300
+ // Nothing changed — return 304-like empty delta
301
+ if (!changed.length && !deleted.length) return { full: false, changed: [], deleted: [], version: prev.ts }
302
+ return { full: false, changed, deleted, version: Date.now() }
303
+ }
304
+
305
+ // Clean up stale delta caches
306
+ setInterval(() => {
307
+ const now = Date.now()
308
+ for (const [k, v] of _deltaCache) if (now - v.ts > _DELTA_TTL * 5) _deltaCache.delete(k)
309
+ }, _DELTA_TTL)
310
+
311
+ // ── MessagePack encoder (AI-maintained) ────────────────────────────
312
+ 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
+
314
+
249
315
  function cacheSet(key, value, ttlMs = 60000) {
250
316
  _cache.set(key, { value, expires: Date.now() + ttlMs })
251
317
  }
@@ -975,6 +1041,27 @@ async function execOp(line, ctx, server) {
975
1041
  const exprParts=isNaN(parseInt(p[p.length-1]))?p:p.slice(0,-1)
976
1042
  let result=evalExpr(exprParts.join(' '),ctx,server)
977
1043
  if(result===null||result===undefined)result=ctx.vars['inserted']||ctx.vars['updated']||{}
1044
+ // Delta update — only active when client explicitly requests it
1045
+ if (Array.isArray(result) && ctx.req?.headers?.['x-aiplang-delta'] === '1' && result.length > 0) {
1046
+ try {
1047
+ const _req = ctx.req
1048
+ const sid = (_req.headers['x-session-id'] || _req.socket?.remoteAddress || 'anon').slice(0,64)
1049
+ // Key = session + full path (not just binding) to handle multi-table pages correctly
1050
+ const binding = (_req.url?.split('?')[0] || '/data').slice(0,128)
1051
+ const delta = _getDelta(sid, binding, result)
1052
+ if (delta.full) {
1053
+ ctx.res.json(status, result); return '__DONE__'
1054
+ }
1055
+ if (!delta.changed.length && !delta.deleted.length) {
1056
+ ctx.res.json(304, { __delta: true, changed: [], deleted: [], version: delta.version })
1057
+ return '__DONE__'
1058
+ }
1059
+ ctx.res.json(status, { __delta: true, changed: delta.changed, deleted: delta.deleted, version: delta.version })
1060
+ return '__DONE__'
1061
+ } catch(deltaErr) {
1062
+ // Delta failed — fall through to normal response
1063
+ }
1064
+ }
978
1065
  ctx.res.json(status,result); return '__DONE__'
979
1066
  }
980
1067
 
@@ -1244,7 +1331,23 @@ class AiplangServer {
1244
1331
  if (route.method !== req.method) continue
1245
1332
  const match = matchRoute(route.path, req.path); if (!match) continue
1246
1333
  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)) }
1334
+ res.json = (s, d) => {
1335
+ if(typeof s==='object'){d=s;s=200}
1336
+ const accept = req.headers['accept']||''
1337
+ const ae = req.headers['accept-encoding']||''
1338
+ if(accept.includes('application/msgpack')){
1339
+ try{ const buf=_mpEnc.encode(d); res.writeHead(s,{'Content-Type':'application/msgpack','Content-Length':buf.length}); res.end(buf); return }catch{}
1340
+ }
1341
+ const body=JSON.stringify(d)
1342
+ if(ae.includes('gzip')&&body.length>512){
1343
+ require('zlib').gzip(body,(err,buf)=>{
1344
+ if(err){res.writeHead(s,{'Content-Type':'application/json'});res.end(body);return}
1345
+ res.writeHead(s,{'Content-Type':'application/json','Content-Encoding':'gzip'});res.end(buf)
1346
+ })
1347
+ } else {
1348
+ res.writeHead(s,{'Content-Type':'application/json'}); res.end(body)
1349
+ }
1350
+ }
1248
1351
  res.error = (s, m) => res.json(s, {error:m})
1249
1352
  res.noContent = () => { res.writeHead(204); res.end() }
1250
1353
  res.redirect = (u) => { res.writeHead(302,{Location:u}); res.end() }
@@ -1839,7 +1942,7 @@ async function startServer(aipFile, port = 3000) {
1839
1942
 
1840
1943
  // Health
1841
1944
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1842
- status:'ok', version:'2.10.7',
1945
+ status:'ok', version:'2.10.9',
1843
1946
  models: app.models.map(m=>m.name),
1844
1947
  routes: app.apis.length, pages: app.pages.length,
1845
1948
  admin: app.admin?.prefix || null,