aiplang 2.10.3 → 2.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.3'
8
+ const VERSION = '2.10.4'
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.3",
3
+ "version": "2.10.4",
4
4
  "description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
5
5
  "keywords": [
6
6
  "aiplang",
@@ -31,7 +31,16 @@ for (const [k, v] of Object.entries({ ...(cfg.state || {}), ..._boot })) {
31
31
  function get(key) { return _state[key] }
32
32
 
33
33
  function set(key, value, _persist) {
34
- if (JSON.stringify(_state[key]) === JSON.stringify(value)) return
34
+ // Fast equality check: primitives first (avoid JSON.stringify for numbers/strings)
35
+ const old = _state[key]
36
+ if (old === value) return
37
+ if (typeof value !== 'object' && old === value) return
38
+ if (typeof value === 'object' && value !== null && typeof old === 'object' && old !== null) {
39
+ // Only deep check for objects/arrays — skip if different length (fast exit)
40
+ if (Array.isArray(value) && Array.isArray(old) && value.length !== old.length) {
41
+ // Different length — definitely changed
42
+ } else if (JSON.stringify(old) === JSON.stringify(value)) return
43
+ }
35
44
  _state[key] = value
36
45
  if (_storeKeys.has(key) || _persist) syncStore(key, value)
37
46
  notify(key)
@@ -53,20 +62,26 @@ function watch(key, cb) {
53
62
 
54
63
  const _pending = new Set()
55
64
  let _batchScheduled = false
65
+ let _batchMode = 'raf' // 'raf' for animations, 'micro' for data updates
56
66
 
57
67
  function flushBatch() {
58
68
  _batchScheduled = false
59
- for (const key of _pending) {
69
+ const keys = [..._pending]
70
+ _pending.clear()
71
+ for (const key of keys) {
60
72
  ;(_watchers[key] || []).forEach(cb => cb(_state[key]))
61
73
  }
62
- _pending.clear()
63
74
  }
64
75
 
65
76
  function notify(key) {
66
77
  _pending.add(key)
67
78
  if (!_batchScheduled) {
68
79
  _batchScheduled = true
69
- requestAnimationFrame(flushBatch)
80
+ // Use microtask (Promise.resolve) for data fetches — fires faster than rAF
81
+ // Use rAF for user interaction (avoids mid-frame layout thrash)
82
+ Promise.resolve().then(() => {
83
+ if (_batchScheduled) requestAnimationFrame(flushBatch)
84
+ })
70
85
  }
71
86
  }
72
87
 
@@ -266,13 +281,27 @@ function hydrateTables() {
266
281
  }
267
282
  }
268
283
 
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}
288
+ const _colKeys = cols.map(c => c.key)
289
+ let _initialized = false
290
+
291
+ const renderRow = (row, idx) => {
292
+ // (defined above, used by virtual scroll too)
293
+ }
294
+
269
295
  const render = () => {
270
296
  const key = binding.startsWith('@') ? binding.slice(1) : binding
271
297
  let rows = get(key)
272
298
  if (!Array.isArray(rows)) rows = []
273
- tbody.innerHTML = ''
274
299
 
300
+ // Empty state
275
301
  if (!rows.length) {
302
+ tbody.innerHTML = ''
303
+ _rowCache.clear()
304
+ _initialized = false
276
305
  const tr = document.createElement('tr')
277
306
  const td = document.createElement('td')
278
307
  td.colSpan = cols.length + (editPath || delPath ? 1 : 0)
@@ -399,7 +428,128 @@ function hydrateTables() {
399
428
  tbody.appendChild(tr)
400
429
  }
401
430
 
402
- rows.forEach((row, idx) => renderRow(row, idx))
431
+ // ── Surgical update vs full initial build ────────────────────
432
+ if (!_initialized) {
433
+ // INITIAL: DocumentFragment for single layout pass
434
+ _initialized = true
435
+ tbody.innerHTML = ''
436
+ const frag = document.createDocumentFragment()
437
+ for (let i = 0; i < rows.length; i++) {
438
+ const row = rows[i]
439
+ const tr = document.createElement('tr')
440
+ tr.className = 'fx-tr'
441
+ tr.dataset.id = row.id || i
442
+
443
+ for (const col of cols) {
444
+ const td = document.createElement('td')
445
+ td.className = 'fx-td'
446
+ td.textContent = row[col.key] != null ? row[col.key] : ''
447
+ tr.appendChild(td)
448
+ }
449
+ // Action cells (edit + delete)
450
+ if (editPath || delPath) {
451
+ const actTd = document.createElement('td')
452
+ actTd.className = 'fx-td fx-td-actions'
453
+ actTd.style.cssText = 'white-space:nowrap'
454
+ if (editPath) {
455
+ const eb = document.createElement('button')
456
+ eb.className = 'fx-action-btn fx-edit-btn'; eb.textContent = '✎ Edit'
457
+ const _row = row, _i = i
458
+ eb.onclick = async () => {
459
+ const upd = await editModal(_row, cols, editPath, editMethod, key)
460
+ if (!upd) return
461
+ const arr = [...(get(key)||[])]; arr[_i]={..._row,...upd}; set(key, arr)
462
+ }
463
+ actTd.appendChild(eb)
464
+ }
465
+ if (delPath) {
466
+ const db = document.createElement('button')
467
+ db.className = 'fx-action-btn fx-delete-btn'; db.textContent = '✕ Delete'
468
+ const _row = row, _i = i
469
+ db.onclick = async () => {
470
+ if (!await confirm('Delete this record? This cannot be undone.')) return
471
+ const {ok,data} = await http('DELETE', resolvePath(delPath,_row), null)
472
+ if (ok) { set(key,(get(key)||[]).filter((_,j)=>j!==_i)); toast('Deleted','ok') }
473
+ else toast(data.message||'Error deleting','err')
474
+ }
475
+ actTd.appendChild(db)
476
+ }
477
+ tr.appendChild(actTd)
478
+ }
479
+
480
+ _rowCache.set(row.id != null ? row.id : i, {
481
+ vals: _colKeys.map(k => row[k]),
482
+ tr
483
+ })
484
+ frag.appendChild(tr)
485
+ }
486
+ tbody.appendChild(frag)
487
+ } else {
488
+ // UPDATE: surgical patches — only touch changed cells
489
+ const existingIds = new Set()
490
+ const tbodyRows = Array.from(tbody.querySelectorAll('tr.fx-tr'))
491
+
492
+ for (let i = 0; i < rows.length; i++) {
493
+ const row = rows[i]
494
+ const rowId = row.id != null ? row.id : i
495
+ existingIds.add(rowId)
496
+ const cached = _rowCache.get(rowId)
497
+
498
+ if (cached) {
499
+ // Row exists — only patch changed cells
500
+ const cells = cached.tr.querySelectorAll('.fx-td')
501
+ for (let c = 0; c < _colKeys.length; c++) {
502
+ const newVal = row[_colKeys[c]] != null ? String(row[_colKeys[c]]) : ''
503
+ if (String(cached.vals[c] != null ? cached.vals[c] : '') !== newVal) {
504
+ cells[c].textContent = newVal
505
+ cached.vals[c] = row[_colKeys[c]]
506
+ }
507
+ }
508
+ // Ensure tr is in correct position
509
+ if (tbodyRows[i] !== cached.tr) tbody.insertBefore(cached.tr, tbodyRows[i] || null)
510
+ } else {
511
+ // New row — create and insert
512
+ const tr = document.createElement('tr')
513
+ tr.className = 'fx-tr'
514
+ tr.dataset.id = rowId
515
+ for (const col of cols) {
516
+ const td = document.createElement('td')
517
+ td.className = 'fx-td'
518
+ td.textContent = row[col.key] != null ? row[col.key] : ''
519
+ tr.appendChild(td)
520
+ }
521
+ if (editPath || delPath) {
522
+ const actTd = document.createElement('td')
523
+ actTd.className = 'fx-td fx-td-actions'; actTd.style.cssText = 'white-space:nowrap'
524
+ if (editPath) {
525
+ const eb = document.createElement('button')
526
+ eb.className = 'fx-action-btn fx-edit-btn'; eb.textContent = '✎ Edit'
527
+ const _row = row, _i = i
528
+ eb.onclick = async () => { const upd = await editModal(_row,cols,editPath,editMethod,key); if(!upd) return; const arr=[...(get(key)||[])]; arr[_i]={..._row,...upd}; set(key,arr) }
529
+ actTd.appendChild(eb)
530
+ }
531
+ if (delPath) {
532
+ const db = document.createElement('button')
533
+ db.className = 'fx-action-btn fx-delete-btn'; db.textContent = '✕ Delete'
534
+ const _row = row, _i = i
535
+ db.onclick = async () => { if(!await confirm('Delete this record?')) return; const {ok,data}=await http('DELETE',resolvePath(delPath,_row),null); if(ok){set(key,(get(key)||[]).filter((_,j)=>j!==_i));toast('Deleted','ok')}else toast(data.message||'Error','err') }
536
+ actTd.appendChild(db)
537
+ }
538
+ tr.appendChild(actTd)
539
+ }
540
+ _rowCache.set(rowId, { vals: _colKeys.map(k => row[k]), tr })
541
+ tbody.insertBefore(tr, tbody.querySelectorAll('tr.fx-tr')[i] || null)
542
+ }
543
+ }
544
+
545
+ // Remove deleted rows
546
+ for (const [id, cached] of _rowCache.entries()) {
547
+ if (!existingIds.has(id)) {
548
+ cached.tr.remove()
549
+ _rowCache.delete(id)
550
+ }
551
+ }
552
+ }
403
553
  }
404
554
 
405
555
  const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
@@ -510,9 +660,22 @@ function hydrateBindings() {
510
660
  document.querySelectorAll('[data-fx-bind]').forEach(el => {
511
661
  const expr = el.getAttribute('data-fx-bind')
512
662
  const keys = (expr.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []).map(m => m.slice(1).split('.')[0])
513
- const update = () => { el.textContent = resolve(expr) }
514
- for (const key of keys) watch(key, update)
515
- update()
663
+ // Fast path: single key with simple path direct textContent assignment
664
+ const simpleM = expr.match(/^[@$]([a-zA-Z_][a-zA-Z0-9_.]*)$/)
665
+ if (simpleM) {
666
+ const path = simpleM[1].split('.')
667
+ const update = () => {
668
+ let v = get(path[0])
669
+ for (let i=1;i<path.length;i++) v = v?.[path[i]]
670
+ el.textContent = v != null ? v : ''
671
+ }
672
+ for (const key of keys) watch(key, update)
673
+ update()
674
+ } else {
675
+ const update = () => { el.textContent = resolve(expr) }
676
+ for (const key of keys) watch(key, update)
677
+ update()
678
+ }
516
679
  })
517
680
  }
518
681
 
package/server/server.js CHANGED
@@ -1839,7 +1839,7 @@ async function startServer(aipFile, port = 3000) {
1839
1839
 
1840
1840
  // Health
1841
1841
  srv.addRoute('GET', '/health', (req, res) => res.json(200, {
1842
- status:'ok', version:'2.10.3',
1842
+ status:'ok', version:'2.10.4',
1843
1843
  models: app.models.map(m=>m.name),
1844
1844
  routes: app.apis.length, pages: app.pages.length,
1845
1845
  admin: app.admin?.prefix || null,