aiplang 2.10.3 → 2.10.5
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 +270 -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.5'
|
|
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,111 @@ 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: off-main-thread diff + requestIdleCallback
|
|
489
|
+
// For 500+ rows: Worker computes diff on separate CPU core
|
|
490
|
+
// For <500 rows: sync diff (worker overhead not worth it)
|
|
491
|
+
// DOM patches always run on main thread but are minimal
|
|
492
|
+
|
|
493
|
+
const _makeRow = (row, idx) => {
|
|
494
|
+
const tr = document.createElement('tr')
|
|
495
|
+
tr.className = 'fx-tr'; tr.dataset.id = row.id != null ? row.id : idx
|
|
496
|
+
for (const col of cols) {
|
|
497
|
+
const td = document.createElement('td'); td.className = 'fx-td'
|
|
498
|
+
td.textContent = row[col.key] != null ? row[col.key] : ''; tr.appendChild(td)
|
|
499
|
+
}
|
|
500
|
+
if (editPath || delPath) {
|
|
501
|
+
const actTd = document.createElement('td')
|
|
502
|
+
actTd.className = 'fx-td fx-td-actions'; actTd.style.cssText = 'white-space:nowrap'
|
|
503
|
+
if (editPath) { const eb=document.createElement('button');eb.className='fx-action-btn fx-edit-btn';eb.textContent='✎ Edit';const _r=row,_i=idx;eb.onclick=async()=>{const upd=await editModal(_r,cols,editPath,editMethod,key);if(!upd)return;const arr=[...(get(key)||[])];arr[_i]={..._r,...upd};set(key,arr)};actTd.appendChild(eb) }
|
|
504
|
+
if (delPath) { const db=document.createElement('button');db.className='fx-action-btn fx-delete-btn';db.textContent='✕ Delete';const _r=row,_i=idx;db.onclick=async()=>{if(!await confirm('Delete?'))return;const{ok,data}=await http('DELETE',resolvePath(delPath,_r),null);if(ok){set(key,(get(key)||[]).filter((_,j)=>j!==_i));toast('Deleted','ok')}else toast(data.message||'Error','err')};actTd.appendChild(db) }
|
|
505
|
+
tr.appendChild(actTd)
|
|
506
|
+
}
|
|
507
|
+
return tr
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const _applyResult = ({ patches, inserts, deletes }) => {
|
|
511
|
+
for (const { id, col, val } of patches) {
|
|
512
|
+
const rc = _rowCache.get(id) || _rowCache.get(isNaN(id)?id:Number(id))
|
|
513
|
+
if (!rc) continue
|
|
514
|
+
const cells = rc.tr.querySelectorAll('.fx-td')
|
|
515
|
+
if (cells[col]) { cells[col].textContent = val != null ? val : ''; rc.vals[col] = val }
|
|
516
|
+
}
|
|
517
|
+
for (const { id, row, idx } of inserts) {
|
|
518
|
+
const tr = _makeRow(row, idx)
|
|
519
|
+
_rowCache.set(id, { vals: _colKeys.map(k => row[k]), tr })
|
|
520
|
+
tbody.insertBefore(tr, tbody.querySelectorAll('tr.fx-tr')[idx] || null)
|
|
521
|
+
}
|
|
522
|
+
for (const id of deletes) {
|
|
523
|
+
const rc = _rowCache.get(id) || _rowCache.get(isNaN(id)?id:Number(id))
|
|
524
|
+
if (rc) { rc.tr.remove(); _rowCache.delete(id); _rowCache.delete(String(id)) }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (rows.length >= 500) {
|
|
529
|
+
// Large: worker diff → idle callback apply (zero main thread impact)
|
|
530
|
+
_diffAsync(rows, _colKeys, _rowCache).then(result => _schedIdle(() => _applyResult(result)))
|
|
531
|
+
} else {
|
|
532
|
+
// Small: sync diff, immediate apply
|
|
533
|
+
_applyResult(_diffSync(rows, _colKeys, _rowCache))
|
|
534
|
+
}
|
|
535
|
+
}
|
|
403
536
|
}
|
|
404
537
|
|
|
405
538
|
const stateKey = binding.startsWith('@') ? binding.slice(1) : binding
|
|
@@ -510,9 +643,22 @@ function hydrateBindings() {
|
|
|
510
643
|
document.querySelectorAll('[data-fx-bind]').forEach(el => {
|
|
511
644
|
const expr = el.getAttribute('data-fx-bind')
|
|
512
645
|
const keys = (expr.match(/[@$][a-zA-Z_][a-zA-Z0-9_.]*/g) || []).map(m => m.slice(1).split('.')[0])
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
646
|
+
// Fast path: single key with simple path — direct textContent assignment
|
|
647
|
+
const simpleM = expr.match(/^[@$]([a-zA-Z_][a-zA-Z0-9_.]*)$/)
|
|
648
|
+
if (simpleM) {
|
|
649
|
+
const path = simpleM[1].split('.')
|
|
650
|
+
const update = () => {
|
|
651
|
+
let v = get(path[0])
|
|
652
|
+
for (let i=1;i<path.length;i++) v = v?.[path[i]]
|
|
653
|
+
el.textContent = v != null ? v : ''
|
|
654
|
+
}
|
|
655
|
+
for (const key of keys) watch(key, update)
|
|
656
|
+
update()
|
|
657
|
+
} else {
|
|
658
|
+
const update = () => { el.textContent = resolve(expr) }
|
|
659
|
+
for (const key of keys) watch(key, update)
|
|
660
|
+
update()
|
|
661
|
+
}
|
|
516
662
|
})
|
|
517
663
|
}
|
|
518
664
|
|
|
@@ -675,6 +821,121 @@ function injectActionCSS() {
|
|
|
675
821
|
document.head.appendChild(style)
|
|
676
822
|
}
|
|
677
823
|
|
|
824
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
825
|
+
// OFF-MAIN-THREAD ENGINE — better than React Fiber
|
|
826
|
+
// Fiber splits work across frames on the SAME thread.
|
|
827
|
+
// This moves diff computation to a SEPARATE CPU core via Web Worker.
|
|
828
|
+
// Main thread only handles tiny DOM patches — never competes with animations.
|
|
829
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
830
|
+
|
|
831
|
+
const _workerSrc = `'use strict'
|
|
832
|
+
self.onmessage = function(e) {
|
|
833
|
+
const { type, rows, colKeys, cache, reqId } = e.data
|
|
834
|
+
if (type !== 'diff') return
|
|
835
|
+
const patches = [], inserts = [], deletes = []
|
|
836
|
+
const seenIds = new Set()
|
|
837
|
+
for (let i = 0; i < rows.length; i++) {
|
|
838
|
+
const row = rows[i]
|
|
839
|
+
const id = row.id != null ? row.id : i
|
|
840
|
+
seenIds.add(typeof id === 'number' ? id : String(id))
|
|
841
|
+
const cached = cache[id]
|
|
842
|
+
if (!cached) { inserts.push({ id, row, idx: i }); continue }
|
|
843
|
+
for (let c = 0; c < colKeys.length; c++) {
|
|
844
|
+
const nStr = row[colKeys[c]] != null ? String(row[colKeys[c]]) : ''
|
|
845
|
+
const oStr = cached[c] != null ? String(cached[c]) : ''
|
|
846
|
+
if (nStr !== oStr) patches.push({ id, col: c, val: row[colKeys[c]] })
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const id in cache) {
|
|
850
|
+
const nid = typeof id === 'number' ? id : (isNaN(id) ? id : Number(id))
|
|
851
|
+
if (!seenIds.has(String(id)) && !seenIds.has(nid)) deletes.push(id)
|
|
852
|
+
}
|
|
853
|
+
self.postMessage({ type: 'patches', patches, inserts, deletes, reqId })
|
|
854
|
+
}`
|
|
855
|
+
|
|
856
|
+
let _diffWorker = null
|
|
857
|
+
const _wCbs = new Map()
|
|
858
|
+
let _wReq = 0
|
|
859
|
+
|
|
860
|
+
function _getWorker() {
|
|
861
|
+
if (_diffWorker) return _diffWorker
|
|
862
|
+
try {
|
|
863
|
+
_diffWorker = new Worker(URL.createObjectURL(new Blob([_workerSrc], { type:'application/javascript' })))
|
|
864
|
+
_diffWorker.onmessage = (e) => {
|
|
865
|
+
const cb = _wCbs.get(e.data.reqId)
|
|
866
|
+
if (cb) { cb(e.data); _wCbs.delete(e.data.reqId) }
|
|
867
|
+
}
|
|
868
|
+
_diffWorker.onerror = () => { _diffWorker = null }
|
|
869
|
+
} catch { _diffWorker = null }
|
|
870
|
+
return _diffWorker
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function _diffAsync(rows, colKeys, rowCache) {
|
|
874
|
+
return new Promise(resolve => {
|
|
875
|
+
const w = _getWorker()
|
|
876
|
+
if (!w) { resolve(_diffSync(rows, colKeys, rowCache)); return }
|
|
877
|
+
const id = ++_wReq
|
|
878
|
+
_wCbs.set(id, resolve)
|
|
879
|
+
const cObj = {}
|
|
880
|
+
for (const [k, v] of rowCache.entries()) cObj[k] = v.vals
|
|
881
|
+
w.postMessage({ type:'diff', rows, colKeys, cache:cObj, reqId:id })
|
|
882
|
+
})
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function _diffSync(rows, colKeys, rowCache) {
|
|
886
|
+
const patches = [], inserts = [], deletes = [], seen = new Set()
|
|
887
|
+
for (let i = 0; i < rows.length; i++) {
|
|
888
|
+
const r = rows[i], id = r.id != null ? r.id : i
|
|
889
|
+
seen.add(id)
|
|
890
|
+
const c = rowCache.get(id)
|
|
891
|
+
if (!c) { inserts.push({ id, row:r, idx:i }); continue }
|
|
892
|
+
for (let j = 0; j < colKeys.length; j++) {
|
|
893
|
+
const n = r[colKeys[j]] != null ? String(r[colKeys[j]]) : ''
|
|
894
|
+
const o = c.vals[j] != null ? String(c.vals[j]) : ''
|
|
895
|
+
if (n !== o) patches.push({ id, col:j, val:r[colKeys[j]] })
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
for (const [id] of rowCache) if (!seen.has(id)) deletes.push(id)
|
|
899
|
+
return { patches, inserts, deletes }
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// requestIdleCallback scheduler — runs low-priority work when browser is idle
|
|
903
|
+
// Polling updates (30s intervals) don't need to be urgent — let animations breathe
|
|
904
|
+
const _idleQ = []
|
|
905
|
+
let _idleSched = false
|
|
906
|
+
const _ric = window.requestIdleCallback
|
|
907
|
+
? window.requestIdleCallback.bind(window)
|
|
908
|
+
: (cb) => setTimeout(() => cb({ timeRemaining: () => 16 }), 4)
|
|
909
|
+
|
|
910
|
+
function _schedIdle(fn) {
|
|
911
|
+
_idleQ.push(fn)
|
|
912
|
+
if (!_idleSched) {
|
|
913
|
+
_idleSched = true
|
|
914
|
+
_ric(_flushIdle, { timeout: 5000 })
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function _flushIdle(dl) {
|
|
919
|
+
_idleSched = false
|
|
920
|
+
while (_idleQ.length && dl.timeRemaining() > 1) {
|
|
921
|
+
try { _idleQ.shift()() } catch {}
|
|
922
|
+
}
|
|
923
|
+
if (_idleQ.length) { _idleSched = true; _ric(_flushIdle, { timeout: 5000 }) }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Incremental renderer — processes rows in chunks between animation frames
|
|
927
|
+
// Zero dropped frames on 100k+ row datasets
|
|
928
|
+
async function _renderIncremental(items, renderFn, chunkSize = 200) {
|
|
929
|
+
for (let i = 0; i < items.length; i += chunkSize) {
|
|
930
|
+
const chunk = items.slice(i, i + chunkSize)
|
|
931
|
+
chunk.forEach((item, j) => renderFn(item, i + j))
|
|
932
|
+
if (i + chunkSize < items.length) {
|
|
933
|
+
await new Promise(r => requestAnimationFrame(r))
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
|
|
678
939
|
function loadSSRData() {
|
|
679
940
|
const ssr = window.__SSR_DATA__
|
|
680
941
|
if (!ssr) return
|
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.5',
|
|
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,
|