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 +1 -1
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +172 -9
- package/server/server.js +1 -1
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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.
|
|
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,
|