mnfst 0.5.198 → 0.5.199-next.0
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/lib/manifest.appwrite.data.js +2 -0
- package/lib/manifest.chat.js +412 -6
- package/lib/manifest.computed.js +11 -1
- package/lib/manifest.d.ts +5 -1
- package/lib/manifest.data.js +1004 -200
- package/lib/manifest.integrity.json +7 -6
- package/lib/manifest.js +119 -3
- package/lib/manifest.schema.json +98 -3
- package/lib/manifest.sw.js +307 -0
- package/lib/manifest.utilities.js +1 -1
- package/package.json +2 -1
package/lib/manifest.data.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
(function () {
|
|
4
4
|
|
|
5
|
+
const MANIFEST_BUILD_VERSION = '0.5.199-next.0';
|
|
6
|
+
|
|
5
7
|
/* Manifest Data Sources - Configuration */
|
|
6
8
|
|
|
7
9
|
// Load manifest if not already loaded (loader may set __manifestLoaded / registry.manifest).
|
|
@@ -282,6 +284,9 @@ let allDirty = true;
|
|
|
282
284
|
// Per-source freshness: `$fresh` resolves on the first network-fresh landing of this page-load
|
|
283
285
|
const freshness = new Map(); // source -> { promise, resolve, done }
|
|
284
286
|
|
|
287
|
+
// Per-source generation: bumped by resetSource so an in-flight load of the old scope never lands
|
|
288
|
+
const sourceGen = new Map();
|
|
289
|
+
|
|
285
290
|
// Deep seal so Alpine won't proxy (double-proxying causes recursion)
|
|
286
291
|
function deepSeal(obj) {
|
|
287
292
|
if (obj === null || typeof obj !== 'object') {
|
|
@@ -397,6 +402,38 @@ function markFresh(dataSourceName) {
|
|
|
397
402
|
if (!f.done) { f.done = true; f.resolve(); }
|
|
398
403
|
}
|
|
399
404
|
|
|
405
|
+
function sourceGeneration(dataSourceName) {
|
|
406
|
+
return sourceGen.get(dataSourceName) || 0;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Drop a source from memory (rows, caches, in-flight keys, freshness): the
|
|
410
|
+
// next read hydrates/fetches for the new scope; older loads are discarded
|
|
411
|
+
function resetSource(dataSourceName) {
|
|
412
|
+
sourceGen.set(dataSourceName, sourceGeneration(dataSourceName) + 1);
|
|
413
|
+
const prefix = `${dataSourceName}:`;
|
|
414
|
+
for (const key of [...dataSourceCache.keys()]) if (key.startsWith(prefix)) dataSourceCache.delete(key);
|
|
415
|
+
for (const key of [...loadingPromises.keys()]) if (key.startsWith(prefix)) loadingPromises.delete(key);
|
|
416
|
+
freshness.delete(dataSourceName);
|
|
417
|
+
pendingLocal.delete(dataSourceName);
|
|
418
|
+
for (let i = pendingLandings.length - 1; i >= 0; i--) {
|
|
419
|
+
if (pendingLandings[i].source !== dataSourceName) continue;
|
|
420
|
+
pendingLandings.splice(i, 1);
|
|
421
|
+
landingResolvers.splice(i, 1)[0]?.();
|
|
422
|
+
}
|
|
423
|
+
const store = typeof Alpine !== 'undefined' ? Alpine.store('data') : null;
|
|
424
|
+
if (!store) return;
|
|
425
|
+
ensureStoreShape(store);
|
|
426
|
+
store[dataSourceName] = null;
|
|
427
|
+
rawDataStore.delete(dataSourceName);
|
|
428
|
+
store[`_${dataSourceName}_state`] = { loading: false, error: null, ready: false, stale: true, errorTime: null };
|
|
429
|
+
const proxies = window.ManifestDataProxies;
|
|
430
|
+
proxies?.clearAccessCache?.(dataSourceName);
|
|
431
|
+
proxies?.clearArrayProxyCacheForDataSource?.(dataSourceName);
|
|
432
|
+
proxies?.clearRouteProxyCacheForDataSource?.(dataSourceName);
|
|
433
|
+
proxies?.clearNestedProxyCacheForDataSource?.(dataSourceName);
|
|
434
|
+
touchSources(store, [dataSourceName]);
|
|
435
|
+
}
|
|
436
|
+
|
|
400
437
|
// State-only write (loading/error) — rows stay live, no version bump
|
|
401
438
|
function setSourceState(dataSourceName, patch) {
|
|
402
439
|
const store = typeof Alpine !== 'undefined' ? Alpine.store('data') : null;
|
|
@@ -590,17 +627,19 @@ function flushLandings() {
|
|
|
590
627
|
|
|
591
628
|
const store = typeof Alpine !== 'undefined' ? Alpine.store('data') : null;
|
|
592
629
|
const touched = new Set();
|
|
630
|
+
const landed = new Set(); // network-origin landings (persistence snapshots these)
|
|
593
631
|
let settled = false;
|
|
594
632
|
if (store) {
|
|
595
633
|
for (const op of ops) {
|
|
596
634
|
try {
|
|
597
635
|
if (op.remove) {
|
|
598
|
-
if (removeRows(store, op.source, op.remove)) touched.add(op.source);
|
|
636
|
+
if (removeRows(store, op.source, op.remove)) { touched.add(op.source); landed.add(op.source); }
|
|
599
637
|
continue;
|
|
600
638
|
}
|
|
601
639
|
const state = writeSource(op.source, op.rows, op.options);
|
|
602
640
|
if (!state) continue;
|
|
603
641
|
touched.add(op.source);
|
|
642
|
+
if (!op.options.persistHydration && op.rows !== null && op.rows !== undefined) landed.add(op.source);
|
|
604
643
|
// Only load completions (explicit loading state) feed render-ready; realtime upserts never do
|
|
605
644
|
if (op.options.loading !== undefined && !state.loading) settled = true;
|
|
606
645
|
} catch (error) {
|
|
@@ -621,6 +660,7 @@ function flushLandings() {
|
|
|
621
660
|
if (touched.size) touchSources(store, touched);
|
|
622
661
|
}
|
|
623
662
|
resolvers.forEach(resolve => resolve());
|
|
663
|
+
if (landed.size) window.ManifestDataPersist?.onLanded?.(landed);
|
|
624
664
|
if (settled) checkAndDispatchRenderReady();
|
|
625
665
|
}
|
|
626
666
|
|
|
@@ -1435,6 +1475,9 @@ window.ManifestDataStore = {
|
|
|
1435
1475
|
setSourceState,
|
|
1436
1476
|
runDeduped,
|
|
1437
1477
|
sourceFreshness,
|
|
1478
|
+
// Persistence (§12.2)
|
|
1479
|
+
sourceGeneration,
|
|
1480
|
+
resetSource,
|
|
1438
1481
|
touchSource,
|
|
1439
1482
|
bumpAllVersions,
|
|
1440
1483
|
mergeRowFields,
|
|
@@ -1464,6 +1507,642 @@ window.ManifestDataStore = {
|
|
|
1464
1507
|
|
|
1465
1508
|
|
|
1466
1509
|
|
|
1510
|
+
/* Manifest Data Sources - Persistence (IndexedDB) */
|
|
1511
|
+
// Per-source snapshots hydrated before the network with fresh:false, keyed by
|
|
1512
|
+
// `${scope}|${source}` (PERF-PRIMITIVES-DESIGN.md §12.2). Off unless a source
|
|
1513
|
+
// opts in via manifest.json `persist`.
|
|
1514
|
+
|
|
1515
|
+
(function () {
|
|
1516
|
+
const STORE_NAME = 'sources';
|
|
1517
|
+
const DB_VERSION = 1;
|
|
1518
|
+
const WRITE_DEBOUNCE_MS = 500;
|
|
1519
|
+
const BOOT_HYDRATE_MAX_WAIT_MS = 100;
|
|
1520
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1521
|
+
const DEFAULT_MAX_ROWS = 1000;
|
|
1522
|
+
const SECRET_PATTERNS = ['*secret*', '*token*', '*password*', 'credentials*'];
|
|
1523
|
+
const NEVER_PERSIST_KEYS = new Set(['$files', '$filesLoading', '$filesError']);
|
|
1524
|
+
const AUTH_EVENTS = ['manifest:auth:login', 'manifest:auth:logout', 'manifest:auth:anonymous',
|
|
1525
|
+
'manifest:auth:session-cleared', 'manifest:auth:initialized', 'manifest:auth:teams-loaded'];
|
|
1526
|
+
const WIPE_EVENTS = new Set(['manifest:auth:logout', 'manifest:auth:session-cleared']);
|
|
1527
|
+
|
|
1528
|
+
const state = {
|
|
1529
|
+
configured: false,
|
|
1530
|
+
enabled: false, // at least one source opted in and IndexedDB exists
|
|
1531
|
+
external: false, // another primitive (chat windows) turned the store on
|
|
1532
|
+
disabled: false, // runtime failure → off for the rest of the session
|
|
1533
|
+
disabledReason: null,
|
|
1534
|
+
dbName: null,
|
|
1535
|
+
dbPromise: null,
|
|
1536
|
+
sources: new Map(), // source -> config (+ rows/savedAt bookkeeping)
|
|
1537
|
+
filters: new Map(), // source -> row filter
|
|
1538
|
+
scopeExpr: null,
|
|
1539
|
+
scopeFn: null,
|
|
1540
|
+
scope: '',
|
|
1541
|
+
generation: 0,
|
|
1542
|
+
pending: new Map(), // source -> time its debounced write is due
|
|
1543
|
+
writeTimer: null,
|
|
1544
|
+
hydrated: new Set(), // sources hydrated (or attempted) this generation
|
|
1545
|
+
fetchKicked: new Set(),
|
|
1546
|
+
watching: false,
|
|
1547
|
+
deployment: null,
|
|
1548
|
+
frameworkVersion: null,
|
|
1549
|
+
warned: new Set()
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
const dataStore = () => window.ManifestDataStore;
|
|
1553
|
+
const alpineData = () => (typeof Alpine !== 'undefined' && Alpine.store ? Alpine.store('data') : null);
|
|
1554
|
+
const keyOf = (scope, source) => `${scope}|${source}`;
|
|
1555
|
+
const liveLocale = () => (typeof document !== 'undefined' && document.documentElement?.lang)
|
|
1556
|
+
|| (typeof Alpine !== 'undefined' && Alpine.store?.('locale')?.current) || 'en';
|
|
1557
|
+
|
|
1558
|
+
function warnOnce(key, message, error) {
|
|
1559
|
+
if (state.warned.has(key)) return;
|
|
1560
|
+
state.warned.add(key);
|
|
1561
|
+
console.warn(`[Manifest Data] ${message}`, error || '');
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function disable(error) {
|
|
1565
|
+
if (state.disabled) return;
|
|
1566
|
+
state.disabled = true;
|
|
1567
|
+
state.disabledReason = error?.name || error?.message || String(error);
|
|
1568
|
+
cancelWrites();
|
|
1569
|
+
warnOnce('disabled', `persistence disabled for this session (${state.disabledReason})`);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// ---- config ----
|
|
1573
|
+
|
|
1574
|
+
function parseTtl(value) {
|
|
1575
|
+
if (typeof value === 'number') return value > 0 ? value : DEFAULT_TTL_MS;
|
|
1576
|
+
if (typeof value !== 'string') return DEFAULT_TTL_MS;
|
|
1577
|
+
const m = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)?$/i);
|
|
1578
|
+
if (!m) return DEFAULT_TTL_MS;
|
|
1579
|
+
const unit = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 }[(m[2] || 'ms').toLowerCase()];
|
|
1580
|
+
const ms = parseFloat(m[1]) * unit;
|
|
1581
|
+
return ms > 0 ? ms : DEFAULT_TTL_MS;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function globToRegExp(pattern) {
|
|
1585
|
+
const escaped = String(pattern).split('*').map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
|
|
1586
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function normalizeConfig(raw) {
|
|
1590
|
+
if (raw !== true && (!raw || typeof raw !== 'object')) return null;
|
|
1591
|
+
const opts = raw === true ? {} : raw;
|
|
1592
|
+
const strip = Array.isArray(opts.strip) ? opts.strip : (typeof opts.strip === 'string' ? [opts.strip] : []);
|
|
1593
|
+
const maxRows = Number.isFinite(opts.maxRows) && opts.maxRows > 0 ? Math.floor(opts.maxRows) : DEFAULT_MAX_ROWS;
|
|
1594
|
+
return {
|
|
1595
|
+
tier: opts.tier === 'lazy' ? 'lazy' : 'boot',
|
|
1596
|
+
maxRows,
|
|
1597
|
+
recent: typeof opts.recent === 'string' && opts.recent ? opts.recent : null,
|
|
1598
|
+
ttl: parseTtl(opts.ttl),
|
|
1599
|
+
stripPatterns: [...SECRET_PATTERNS, ...strip].map(globToRegExp),
|
|
1600
|
+
rows: null,
|
|
1601
|
+
savedAt: null
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function frameworkVersion() {
|
|
1606
|
+
if (typeof MANIFEST_BUILD_VERSION === 'string') return MANIFEST_BUILD_VERSION;
|
|
1607
|
+
const src = typeof document !== 'undefined' ? document.currentScript?.src : null;
|
|
1608
|
+
const m = src && src.match(/mnfst@(\d+\.\d+\.\d+[^/]*)/);
|
|
1609
|
+
return m ? m[1] : '0.0.0';
|
|
1610
|
+
}
|
|
1611
|
+
const buildVersion = frameworkVersion();
|
|
1612
|
+
|
|
1613
|
+
function majorMinor(version) {
|
|
1614
|
+
const m = String(version || '').match(/^(\d+)\.(\d+)/);
|
|
1615
|
+
return m ? `${m[1]}.${m[2]}` : null;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Reads `persist` per source and top-level `persistence`; true when anything opted in
|
|
1619
|
+
function configure(manifest) {
|
|
1620
|
+
state.configured = true;
|
|
1621
|
+
state.sources.clear();
|
|
1622
|
+
for (const [name, source] of Object.entries(manifest?.data || {})) {
|
|
1623
|
+
if (!source || typeof source !== 'object') continue;
|
|
1624
|
+
const cfg = normalizeConfig(source.persist);
|
|
1625
|
+
if (cfg) state.sources.set(name, cfg);
|
|
1626
|
+
}
|
|
1627
|
+
state.scopeExpr = typeof manifest?.persistence?.scope === 'string' && manifest.persistence.scope.trim()
|
|
1628
|
+
? manifest.persistence.scope.trim() : null;
|
|
1629
|
+
state.scopeFn = null;
|
|
1630
|
+
state.deployment = manifest?.deployment || null;
|
|
1631
|
+
state.frameworkVersion = buildVersion;
|
|
1632
|
+
const projectId = manifest?.projectId || manifest?.project?.id || null;
|
|
1633
|
+
const origin = (typeof location !== 'undefined' && location.origin) || 'null';
|
|
1634
|
+
state.dbName = `manifest:${origin}${projectId ? `:${projectId}` : ''}`;
|
|
1635
|
+
state.enabled = (state.sources.size > 0 || state.external) && typeof indexedDB !== 'undefined' && !!indexedDB;
|
|
1636
|
+
if (!state.enabled) return false;
|
|
1637
|
+
state.scope = evaluateScope();
|
|
1638
|
+
watchScope();
|
|
1639
|
+
return state.sources.size > 0;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Late enable (runtime registration, other primitives on the same store)
|
|
1643
|
+
function ensureEnabled() {
|
|
1644
|
+
state.external = true;
|
|
1645
|
+
if (state.enabled || state.disabled) return state.enabled;
|
|
1646
|
+
if (typeof indexedDB === 'undefined' || !indexedDB) return false;
|
|
1647
|
+
state.enabled = true;
|
|
1648
|
+
if (!state.dbName) state.dbName = `manifest:${(typeof location !== 'undefined' && location.origin) || 'null'}`;
|
|
1649
|
+
if (!state.frameworkVersion) state.frameworkVersion = buildVersion;
|
|
1650
|
+
state.scope = evaluateScope();
|
|
1651
|
+
watchScope();
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Runtime registration (harness/tests): same shape as manifest.json
|
|
1656
|
+
function register(source, config) {
|
|
1657
|
+
const cfg = normalizeConfig(config === undefined ? true : config);
|
|
1658
|
+
if (!cfg) { state.sources.delete(source); return false; }
|
|
1659
|
+
state.sources.set(source, cfg);
|
|
1660
|
+
state.hydrated.delete(source);
|
|
1661
|
+
state.fetchKicked.delete(source);
|
|
1662
|
+
ensureEnabled();
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function setScope(expression) {
|
|
1667
|
+
state.scopeExpr = typeof expression === 'string' && expression.trim() ? expression.trim() : null;
|
|
1668
|
+
state.scopeFn = null;
|
|
1669
|
+
watchScope();
|
|
1670
|
+
refreshScope();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// ---- scope ----
|
|
1674
|
+
|
|
1675
|
+
// Expression scope: `$name` → Alpine.store(name) (so `$auth` tracks reactively),
|
|
1676
|
+
// else window[$name] (`$x`); bare names → globals
|
|
1677
|
+
function magicScope() {
|
|
1678
|
+
return new Proxy(Object.create(null), {
|
|
1679
|
+
has: () => true,
|
|
1680
|
+
get(_, name) {
|
|
1681
|
+
if (typeof name !== 'string') return undefined;
|
|
1682
|
+
if (name[0] === '$') {
|
|
1683
|
+
if (name === '$store') return (n) => (typeof Alpine !== 'undefined' ? Alpine.store(n) : undefined);
|
|
1684
|
+
if (typeof Alpine !== 'undefined' && Alpine.store) {
|
|
1685
|
+
const s = Alpine.store(name.slice(1));
|
|
1686
|
+
if (s !== undefined) return s;
|
|
1687
|
+
}
|
|
1688
|
+
return typeof window !== 'undefined' ? window[name] : undefined;
|
|
1689
|
+
}
|
|
1690
|
+
return globalThis[name];
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function evaluateScope() {
|
|
1696
|
+
if (!state.scopeExpr) return '';
|
|
1697
|
+
try {
|
|
1698
|
+
if (!state.scopeFn) state.scopeFn = new Function('__scope', `with (__scope) { return (${state.scopeExpr}); }`);
|
|
1699
|
+
const value = state.scopeFn(magicScope());
|
|
1700
|
+
return value === null || value === undefined || value === false ? '' : String(value);
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
warnOnce('scope', `persistence scope "${state.scopeExpr}" failed to evaluate; using ""`, error);
|
|
1703
|
+
return '';
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function refreshScope() {
|
|
1708
|
+
if (!state.enabled) return;
|
|
1709
|
+
const next = evaluateScope();
|
|
1710
|
+
if (next !== state.scope) changeScope(next);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function watchScope() {
|
|
1714
|
+
if (state.watching || typeof window === 'undefined') return;
|
|
1715
|
+
state.watching = true;
|
|
1716
|
+
for (const type of AUTH_EVENTS) {
|
|
1717
|
+
window.addEventListener(type, () => {
|
|
1718
|
+
if (!state.enabled) return;
|
|
1719
|
+
if (WIPE_EVENTS.has(type)) deleteScope(state.scope);
|
|
1720
|
+
refreshScope();
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
// Reactive re-evaluation (team switches that fire no event); the switch itself runs outside the effect
|
|
1724
|
+
if (typeof Alpine !== 'undefined' && Alpine.effect) {
|
|
1725
|
+
Alpine.effect(() => {
|
|
1726
|
+
if (!state.scopeExpr) return;
|
|
1727
|
+
const next = evaluateScope();
|
|
1728
|
+
if (next !== state.scope) queueMicrotask(refreshScope);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Scope switch: previous entries wiped, memory rows of persisted sources
|
|
1734
|
+
// cleared (older loads discarded), new scope hydrates
|
|
1735
|
+
function changeScope(next) {
|
|
1736
|
+
const prev = state.scope;
|
|
1737
|
+
state.scope = next;
|
|
1738
|
+
state.generation++;
|
|
1739
|
+
cancelWrites();
|
|
1740
|
+
state.hydrated.clear();
|
|
1741
|
+
state.fetchKicked.clear();
|
|
1742
|
+
const ds = dataStore();
|
|
1743
|
+
for (const [source, cfg] of state.sources) {
|
|
1744
|
+
cfg.rows = null;
|
|
1745
|
+
cfg.savedAt = null;
|
|
1746
|
+
ds?.resetSource?.(source);
|
|
1747
|
+
}
|
|
1748
|
+
if (prev !== next) deleteScope(prev);
|
|
1749
|
+
hydrateBoot();
|
|
1750
|
+
if (typeof window !== 'undefined') {
|
|
1751
|
+
try { window.dispatchEvent(new CustomEvent('manifest:persist:scope', { detail: { scope: next, previous: prev } })); } catch { /* no-op */ }
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// ---- IndexedDB ----
|
|
1756
|
+
|
|
1757
|
+
function openDb() {
|
|
1758
|
+
if (state.dbPromise) return state.dbPromise;
|
|
1759
|
+
state.dbPromise = new Promise((resolve, reject) => {
|
|
1760
|
+
let request;
|
|
1761
|
+
try { request = indexedDB.open(state.dbName, DB_VERSION); } catch (error) { reject(error); return; }
|
|
1762
|
+
request.onupgradeneeded = () => {
|
|
1763
|
+
const db = request.result;
|
|
1764
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
|
1765
|
+
};
|
|
1766
|
+
request.onsuccess = () => {
|
|
1767
|
+
const db = request.result;
|
|
1768
|
+
db.onversionchange = () => { try { db.close(); } catch { /* no-op */ } state.dbPromise = null; };
|
|
1769
|
+
resolve(db);
|
|
1770
|
+
};
|
|
1771
|
+
request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));
|
|
1772
|
+
request.onblocked = () => reject(new Error('IndexedDB open blocked'));
|
|
1773
|
+
}).catch(error => { disable(error); return null; });
|
|
1774
|
+
return state.dbPromise;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const promisify = (request) => new Promise((resolve, reject) => {
|
|
1778
|
+
request.onsuccess = () => resolve(request.result);
|
|
1779
|
+
request.onerror = () => reject(request.error || new Error('IndexedDB request failed'));
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
// One transaction per call; any failure (quota included) disables persistence silently
|
|
1783
|
+
async function withStore(mode, fn) {
|
|
1784
|
+
if (!state.enabled || state.disabled) return undefined;
|
|
1785
|
+
const db = await openDb();
|
|
1786
|
+
if (!db || state.disabled) return undefined;
|
|
1787
|
+
try {
|
|
1788
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
1789
|
+
const done = new Promise((resolve, reject) => {
|
|
1790
|
+
tx.oncomplete = () => resolve();
|
|
1791
|
+
tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
|
|
1792
|
+
tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
|
|
1793
|
+
});
|
|
1794
|
+
const result = await fn(tx.objectStore(STORE_NAME), tx);
|
|
1795
|
+
await done;
|
|
1796
|
+
return result;
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
disable(error);
|
|
1799
|
+
return undefined;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function readRecords(keys) {
|
|
1804
|
+
return withStore('readonly', (store) => Promise.all(keys.map(key => promisify(store.get(key)))));
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function putRecord(record) {
|
|
1808
|
+
return withStore('readwrite', (store) => { store.put(record); });
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function deleteKeys(keys) {
|
|
1812
|
+
if (!keys.length) return Promise.resolve();
|
|
1813
|
+
return withStore('readwrite', (store) => { for (const key of keys) store.delete(key); });
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
async function deleteScope(scope) {
|
|
1817
|
+
const prefix = `${scope}|`;
|
|
1818
|
+
cancelWrites();
|
|
1819
|
+
return withStore('readwrite', async (store) => {
|
|
1820
|
+
const keys = await promisify(store.getAllKeys());
|
|
1821
|
+
for (const key of keys) if (typeof key === 'string' && key.startsWith(prefix)) store.delete(key);
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// ---- snapshot (write path) ----
|
|
1826
|
+
|
|
1827
|
+
function stripRow(row, patterns) {
|
|
1828
|
+
const out = {};
|
|
1829
|
+
for (const key of Object.keys(row)) {
|
|
1830
|
+
if (NEVER_PERSIST_KEYS.has(key)) continue;
|
|
1831
|
+
const value = row[key];
|
|
1832
|
+
if (typeof value === 'function') continue;
|
|
1833
|
+
if (patterns.some(re => re.test(key))) continue;
|
|
1834
|
+
out[key] = value;
|
|
1835
|
+
}
|
|
1836
|
+
return out;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function compareRecent(a, b) {
|
|
1840
|
+
if (a === b) return 0;
|
|
1841
|
+
if (a === undefined || a === null) return -1;
|
|
1842
|
+
if (b === undefined || b === null) return 1;
|
|
1843
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
1844
|
+
const sa = String(a), sb = String(b);
|
|
1845
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Keep the `maxRows` most recent by `recent` (or `$updatedAt`), in their original order;
|
|
1849
|
+
// no recency field → the last N inserted
|
|
1850
|
+
function capRows(rows, cfg) {
|
|
1851
|
+
if (rows.length <= cfg.maxRows) return rows;
|
|
1852
|
+
const field = cfg.recent || (rows.some(r => r && r.$updatedAt !== undefined) ? '$updatedAt' : null);
|
|
1853
|
+
if (!field) return rows.slice(rows.length - cfg.maxRows);
|
|
1854
|
+
const order = rows.map((row, i) => i).sort((i, j) => compareRecent(rows[j]?.[field], rows[i]?.[field]) || i - j);
|
|
1855
|
+
const keep = new Set(order.slice(0, cfg.maxRows));
|
|
1856
|
+
return rows.filter((_, i) => keep.has(i));
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function snapshotOf(source, raw, cfg) {
|
|
1860
|
+
const filter = state.filters.get(source);
|
|
1861
|
+
if (Array.isArray(raw)) {
|
|
1862
|
+
let rows = raw;
|
|
1863
|
+
if (filter) {
|
|
1864
|
+
rows = [];
|
|
1865
|
+
for (const row of raw) {
|
|
1866
|
+
if (row === null || typeof row !== 'object') { rows.push(row); continue; }
|
|
1867
|
+
const kept = filter(row);
|
|
1868
|
+
if (kept === null || kept === undefined) continue;
|
|
1869
|
+
rows.push(typeof kept === 'object' ? kept : row);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
rows = capRows(rows, cfg);
|
|
1873
|
+
return rows.map(row => (row && typeof row === 'object') ? stripRow(row, cfg.stripPatterns) : row);
|
|
1874
|
+
}
|
|
1875
|
+
if (raw && typeof raw === 'object') {
|
|
1876
|
+
const kept = filter ? filter(raw) : raw;
|
|
1877
|
+
if (kept === null || kept === undefined) return null;
|
|
1878
|
+
return stripRow(typeof kept === 'object' ? kept : raw, cfg.stripPatterns);
|
|
1879
|
+
}
|
|
1880
|
+
return null;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function snapshotRecord(source) {
|
|
1884
|
+
const cfg = state.sources.get(source);
|
|
1885
|
+
const ds = dataStore();
|
|
1886
|
+
if (!cfg || !ds) return null;
|
|
1887
|
+
const raw = ds.getRawData(source);
|
|
1888
|
+
if (raw === null || raw === undefined) return null;
|
|
1889
|
+
try {
|
|
1890
|
+
const snapshot = snapshotOf(source, raw, cfg);
|
|
1891
|
+
if (snapshot === null) return null;
|
|
1892
|
+
return {
|
|
1893
|
+
key: keyOf(state.scope, source),
|
|
1894
|
+
scope: state.scope,
|
|
1895
|
+
source,
|
|
1896
|
+
rows: JSON.parse(JSON.stringify(snapshot)),
|
|
1897
|
+
savedAt: Date.now(),
|
|
1898
|
+
frameworkVersion: state.frameworkVersion,
|
|
1899
|
+
deployment: state.deployment,
|
|
1900
|
+
locale: liveLocale()
|
|
1901
|
+
};
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
warnOnce(`snapshot:${source}`, `persistence skipped "${source}" (rows are not serialisable)`, error);
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Every source whose debounce has elapsed is written in ONE transaction
|
|
1909
|
+
function flush(sources) {
|
|
1910
|
+
for (const source of sources) state.pending.delete(source);
|
|
1911
|
+
if (!state.enabled || state.disabled) return Promise.resolve();
|
|
1912
|
+
const records = sources.map(snapshotRecord).filter(Boolean);
|
|
1913
|
+
if (!records.length) return Promise.resolve();
|
|
1914
|
+
const generation = state.generation;
|
|
1915
|
+
return withStore('readwrite', (store) => { for (const record of records) store.put(record); }).then(() => {
|
|
1916
|
+
if (generation !== state.generation || state.disabled) return;
|
|
1917
|
+
for (const record of records) {
|
|
1918
|
+
const cfg = state.sources.get(record.source);
|
|
1919
|
+
if (!cfg) continue;
|
|
1920
|
+
cfg.rows = Array.isArray(record.rows) ? record.rows.length : 1;
|
|
1921
|
+
cfg.savedAt = record.savedAt;
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function flushDue() {
|
|
1927
|
+
state.writeTimer = null;
|
|
1928
|
+
const now = Date.now();
|
|
1929
|
+
const due = [];
|
|
1930
|
+
for (const [source, at] of state.pending) if (at - now <= 5) due.push(source);
|
|
1931
|
+
const run = due.length ? flush(due) : Promise.resolve();
|
|
1932
|
+
scheduleWrites();
|
|
1933
|
+
return run.catch(() => { /* disabled */ });
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function scheduleWrites() {
|
|
1937
|
+
if (state.writeTimer !== null || !state.pending.size) return;
|
|
1938
|
+
let next = Infinity;
|
|
1939
|
+
for (const at of state.pending.values()) if (at < next) next = at;
|
|
1940
|
+
state.writeTimer = setTimeout(flushDue, Math.max(0, next - Date.now()));
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function cancelWrites(source) {
|
|
1944
|
+
if (source !== undefined) { state.pending.delete(source); return; }
|
|
1945
|
+
state.pending.clear();
|
|
1946
|
+
if (state.writeTimer !== null) { clearTimeout(state.writeTimer); state.writeTimer = null; }
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Landing hook (store flush): each landing restarts that source's 500ms debounce
|
|
1950
|
+
function onLanded(sources) {
|
|
1951
|
+
if (!state.enabled || state.disabled) return;
|
|
1952
|
+
const at = Date.now() + WRITE_DEBOUNCE_MS;
|
|
1953
|
+
for (const source of sources) if (state.sources.has(source)) state.pending.set(source, at);
|
|
1954
|
+
if (state.writeTimer !== null) { clearTimeout(state.writeTimer); state.writeTimer = null; }
|
|
1955
|
+
scheduleWrites();
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// ---- hydration (read path) ----
|
|
1959
|
+
|
|
1960
|
+
function validRecord(record, cfg) {
|
|
1961
|
+
if (!record || typeof record !== 'object' || record.scope !== state.scope) return { ok: false };
|
|
1962
|
+
if (record.rows === null || record.rows === undefined) return { ok: false, drop: true };
|
|
1963
|
+
if (typeof record.savedAt !== 'number' || Date.now() - record.savedAt > cfg.ttl) return { ok: false, drop: true };
|
|
1964
|
+
if (majorMinor(record.frameworkVersion) !== majorMinor(state.frameworkVersion)) return { ok: false, drop: true };
|
|
1965
|
+
if (record.locale && record.locale !== liveLocale()) return { ok: false };
|
|
1966
|
+
return { ok: true };
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// One transaction for every requested key; a hydration arriving after the
|
|
1970
|
+
// fresh landing (or after a scope change) is discarded
|
|
1971
|
+
async function hydrate(sources) {
|
|
1972
|
+
const ds = dataStore();
|
|
1973
|
+
const pending = sources.filter(source => state.sources.has(source) && !state.hydrated.has(source));
|
|
1974
|
+
if (!ds || !pending.length || !state.enabled || state.disabled) return;
|
|
1975
|
+
for (const source of pending) state.hydrated.add(source);
|
|
1976
|
+
const generation = state.generation;
|
|
1977
|
+
const scope = state.scope;
|
|
1978
|
+
const records = await readRecords(pending.map(source => keyOf(scope, source)));
|
|
1979
|
+
if (!records || generation !== state.generation) return;
|
|
1980
|
+
const drop = [];
|
|
1981
|
+
const landings = [];
|
|
1982
|
+
pending.forEach((source, i) => {
|
|
1983
|
+
const cfg = state.sources.get(source);
|
|
1984
|
+
const record = records[i];
|
|
1985
|
+
if (!cfg || !record) return;
|
|
1986
|
+
const check = validRecord(record, cfg);
|
|
1987
|
+
if (!check.ok) { if (check.drop) drop.push(record.key); return; }
|
|
1988
|
+
if (ds.sourceFreshness(source).done) return; // fresh landing already applied
|
|
1989
|
+
cfg.rows = Array.isArray(record.rows) ? record.rows.length : 1;
|
|
1990
|
+
cfg.savedAt = record.savedAt;
|
|
1991
|
+
landings.push(ds.landRows(source, record.rows, { mode: 'replace', ready: true, fresh: false, persistHydration: true, allowDuringInit: true }));
|
|
1992
|
+
});
|
|
1993
|
+
if (drop.length) deleteKeys(drop).catch(() => { /* disabled */ });
|
|
1994
|
+
await Promise.all(landings);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function bootSources() {
|
|
1998
|
+
const out = [];
|
|
1999
|
+
for (const [source, cfg] of state.sources) if (cfg.tier === 'boot') out.push(source);
|
|
2000
|
+
return out;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Boot tier: all keys in one read; the caller caps the wait (cold boot never blocks)
|
|
2004
|
+
function hydrateBoot(options = {}) {
|
|
2005
|
+
if (!state.enabled || state.disabled) return Promise.resolve();
|
|
2006
|
+
const run = hydrate(bootSources()).catch(() => { /* disabled */ });
|
|
2007
|
+
const maxWaitMs = options.maxWaitMs;
|
|
2008
|
+
if (!Number.isFinite(maxWaitMs)) return run;
|
|
2009
|
+
return Promise.race([run, new Promise(resolve => setTimeout(resolve, maxWaitMs))]);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// First `$x.<source>` read without rows → lazy-tier hydration
|
|
2013
|
+
function onRead(source) {
|
|
2014
|
+
if (!state.enabled || state.disabled) return;
|
|
2015
|
+
const cfg = state.sources.get(source);
|
|
2016
|
+
if (!cfg || state.hydrated.has(source)) return;
|
|
2017
|
+
hydrate([source]).catch(() => { /* disabled */ });
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// Hydrated (stale) rows still need this page-load's network landing: once per source per scope
|
|
2021
|
+
function needsFetch(source) {
|
|
2022
|
+
if (!state.enabled || state.disabled) return false;
|
|
2023
|
+
const cfg = state.sources.get(source);
|
|
2024
|
+
if (!cfg || state.fetchKicked.has(source)) return false;
|
|
2025
|
+
const ds = dataStore();
|
|
2026
|
+
if (!ds || ds.sourceFreshness(source).done) return false;
|
|
2027
|
+
const st = ds.rawOf(alpineData() || {})[`_${source}_state`];
|
|
2028
|
+
if (st && (st.loading || st.error)) return false;
|
|
2029
|
+
const prefix = `${source}:`;
|
|
2030
|
+
for (const key of ds.loadingPromises.keys()) if (key.startsWith(prefix)) return false;
|
|
2031
|
+
state.fetchKicked.add(source);
|
|
2032
|
+
return true;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// A kicked load that could not land (init window) may be re-kicked by the next read
|
|
2036
|
+
function onFetchSettled(source, result) {
|
|
2037
|
+
if (result === null || result === undefined) return;
|
|
2038
|
+
const ds = dataStore();
|
|
2039
|
+
if (ds && !ds.sourceFreshness(source).done) state.fetchKicked.delete(source);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// ---- public surface ----
|
|
2043
|
+
|
|
2044
|
+
// $x.$wipe(): current scope; $wipe(source); $wipe({ all: true }): every scope
|
|
2045
|
+
async function wipe(arg) {
|
|
2046
|
+
if (!state.enabled || state.disabled) return false;
|
|
2047
|
+
if (arg && typeof arg === 'object' && arg.all) {
|
|
2048
|
+
cancelWrites();
|
|
2049
|
+
for (const cfg of state.sources.values()) { cfg.rows = null; cfg.savedAt = null; }
|
|
2050
|
+
await withStore('readwrite', (store) => { store.clear(); });
|
|
2051
|
+
return !state.disabled;
|
|
2052
|
+
}
|
|
2053
|
+
if (typeof arg === 'string' && arg) {
|
|
2054
|
+
cancelWrites(arg);
|
|
2055
|
+
const cfg = state.sources.get(arg);
|
|
2056
|
+
if (cfg) { cfg.rows = null; cfg.savedAt = null; }
|
|
2057
|
+
await deleteKeys([keyOf(state.scope, arg)]);
|
|
2058
|
+
return !state.disabled;
|
|
2059
|
+
}
|
|
2060
|
+
for (const cfg of state.sources.values()) { cfg.rows = null; cfg.savedAt = null; }
|
|
2061
|
+
await deleteScope(state.scope);
|
|
2062
|
+
return !state.disabled;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function persistFilter(source, fn) {
|
|
2066
|
+
if (typeof fn === 'function') state.filters.set(source, fn);
|
|
2067
|
+
else state.filters.delete(source);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function persistence() {
|
|
2071
|
+
const getState = window.ManifestDataProxiesMagic?.getStateProperty;
|
|
2072
|
+
const sources = [];
|
|
2073
|
+
for (const [source, cfg] of state.sources) {
|
|
2074
|
+
sources.push({
|
|
2075
|
+
source,
|
|
2076
|
+
tier: cfg.tier,
|
|
2077
|
+
rows: cfg.rows,
|
|
2078
|
+
savedAt: cfg.savedAt,
|
|
2079
|
+
stale: getState ? getState('$stale', source) !== false : true
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
const out = { enabled: state.enabled && !state.disabled, scope: state.scope, sources };
|
|
2083
|
+
if (state.disabled) out.disabledReason = state.disabledReason;
|
|
2084
|
+
return out;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Tests/harness: write everything pending now
|
|
2088
|
+
async function flushPending() {
|
|
2089
|
+
const sources = [...state.pending.keys()];
|
|
2090
|
+
if (state.writeTimer !== null) { clearTimeout(state.writeTimer); state.writeTimer = null; }
|
|
2091
|
+
if (sources.length) await flush(sources).catch(() => { /* disabled */ });
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Shared record store for other primitives (chat windows, §12.2 "primitive 3"):
|
|
2095
|
+
// same database/object store, scope prefix, stamping and validity rules.
|
|
2096
|
+
// Keys are `${scope}|…`, so scope wipes and logout cover these records too.
|
|
2097
|
+
function stampRecord(record) {
|
|
2098
|
+
return {
|
|
2099
|
+
scope: state.scope, savedAt: Date.now(), frameworkVersion: state.frameworkVersion,
|
|
2100
|
+
deployment: state.deployment, locale: liveLocale(), ...record
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
const records = {
|
|
2104
|
+
enable: ensureEnabled,
|
|
2105
|
+
enabled: () => state.enabled && !state.disabled,
|
|
2106
|
+
scope: () => state.scope,
|
|
2107
|
+
key: (...parts) => [state.scope, ...parts].join('|'),
|
|
2108
|
+
get: (keys) => readRecords(keys), // one transaction; array aligned with keys (undefined = miss)
|
|
2109
|
+
put: (list) => withStore('readwrite', (store) => { for (const record of list) store.put(stampRecord(record)); }),
|
|
2110
|
+
delete: (keys) => deleteKeys(keys),
|
|
2111
|
+
keys: (prefix) => withStore('readonly', async (store) =>
|
|
2112
|
+
(await promisify(store.getAllKeys())).filter(key => typeof key === 'string' && key.startsWith(prefix))),
|
|
2113
|
+
clear: () => withStore('readwrite', (store) => { store.clear(); }),
|
|
2114
|
+
valid: (record, ttlMs) => validRecord(record, { ttl: Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS }).ok,
|
|
2115
|
+
stamp: stampRecord,
|
|
2116
|
+
ttl: parseTtl
|
|
2117
|
+
};
|
|
2118
|
+
|
|
2119
|
+
window.ManifestDataPersist = {
|
|
2120
|
+
BOOT_HYDRATE_MAX_WAIT_MS,
|
|
2121
|
+
WRITE_DEBOUNCE_MS,
|
|
2122
|
+
configure,
|
|
2123
|
+
register,
|
|
2124
|
+
setScope,
|
|
2125
|
+
refreshScope,
|
|
2126
|
+
hydrateBoot,
|
|
2127
|
+
hydrate,
|
|
2128
|
+
onRead,
|
|
2129
|
+
needsFetch,
|
|
2130
|
+
onFetchSettled,
|
|
2131
|
+
onLanded,
|
|
2132
|
+
flushPending,
|
|
2133
|
+
wipe,
|
|
2134
|
+
persistFilter,
|
|
2135
|
+
persistence,
|
|
2136
|
+
records,
|
|
2137
|
+
state
|
|
2138
|
+
};
|
|
2139
|
+
|
|
2140
|
+
window.ManifestData = window.ManifestData || {};
|
|
2141
|
+
window.ManifestData.persistFilter = persistFilter;
|
|
2142
|
+
window.ManifestData.persistence = persistence;
|
|
2143
|
+
})();
|
|
2144
|
+
|
|
2145
|
+
|
|
1467
2146
|
/* Manifest Data Sources - File Loaders */
|
|
1468
2147
|
|
|
1469
2148
|
// Key names that would walk into Object's prototype chain if used as nested-
|
|
@@ -2048,9 +2727,10 @@ async function loadFromAPI(dataSource) {
|
|
|
2048
2727
|
|
|
2049
2728
|
return data;
|
|
2050
2729
|
} catch (error) {
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2730
|
+
// Callers decide: a first load may land `defaultValue`, a reload keeps its live rows
|
|
2731
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2732
|
+
err.defaultValue = dataSource.defaultValue !== undefined ? dataSource.defaultValue : [];
|
|
2733
|
+
throw err;
|
|
2054
2734
|
}
|
|
2055
2735
|
}
|
|
2056
2736
|
|
|
@@ -3221,6 +3901,84 @@ function clearArrayProxyCacheForDataSource(dataSourceName) {
|
|
|
3221
3901
|
// Track which arrays have methods attached to avoid re-attaching
|
|
3222
3902
|
const arraysWithMethodsAttached = new WeakSet();
|
|
3223
3903
|
|
|
3904
|
+
// Operator memo: $search/$query/$route results cached per array, keyed by
|
|
3905
|
+
// the source version + op + args; a landing or local write bumps _v[source]
|
|
3906
|
+
// and invalidates. Uncached when the source has no version or args cannot
|
|
3907
|
+
// be stringified deterministically.
|
|
3908
|
+
const OP_CACHE_MAX = 8;
|
|
3909
|
+
const opCaches = new WeakMap(); // raw array -> { v, map (LRU) }
|
|
3910
|
+
|
|
3911
|
+
function stableStringify(value, depth = 0) {
|
|
3912
|
+
if (value === null) return 'null';
|
|
3913
|
+
const t = typeof value;
|
|
3914
|
+
if (t === 'string') return JSON.stringify(value);
|
|
3915
|
+
if (t === 'number' || t === 'boolean' || t === 'undefined') return String(value);
|
|
3916
|
+
if (t === 'bigint') return `${value}n`;
|
|
3917
|
+
if (t !== 'object' || depth > 8) return undefined;
|
|
3918
|
+
const tag = Object.prototype.toString.call(value);
|
|
3919
|
+
if (tag === '[object Date]') return `D${value.getTime()}`;
|
|
3920
|
+
if (Array.isArray(value)) {
|
|
3921
|
+
const parts = [];
|
|
3922
|
+
for (const item of value) {
|
|
3923
|
+
const s = stableStringify(item, depth + 1);
|
|
3924
|
+
if (s === undefined) return undefined;
|
|
3925
|
+
parts.push(s);
|
|
3926
|
+
}
|
|
3927
|
+
return `[${parts.join(',')}]`;
|
|
3928
|
+
}
|
|
3929
|
+
// Plain objects only (any realm): class instances, Map/Set etc. fall through
|
|
3930
|
+
const proto = Object.getPrototypeOf(value);
|
|
3931
|
+
if (tag !== '[object Object]' || (proto !== null && Object.getPrototypeOf(proto) !== null)) return undefined;
|
|
3932
|
+
const parts = [];
|
|
3933
|
+
for (const key of Object.keys(value).sort()) {
|
|
3934
|
+
const s = stableStringify(value[key], depth + 1);
|
|
3935
|
+
if (s === undefined) return undefined;
|
|
3936
|
+
parts.push(`${JSON.stringify(key)}:${s}`);
|
|
3937
|
+
}
|
|
3938
|
+
return `{${parts.join(',')}}`;
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
// Reactive read of _v[source]: subscribes the caller exactly like the raw $x read
|
|
3942
|
+
function sourceVersion(dataSourceName) {
|
|
3943
|
+
if (!dataSourceName || typeof Alpine === 'undefined') return undefined;
|
|
3944
|
+
const v = Alpine.store('data')?._v;
|
|
3945
|
+
return v ? v[dataSourceName] : undefined;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
// Run without tracking deps on the caller's effect (throwaway inner effect)
|
|
3949
|
+
function untracked(fn) {
|
|
3950
|
+
if (typeof Alpine === 'undefined' || !Alpine.effect) return fn();
|
|
3951
|
+
let out;
|
|
3952
|
+
const e = Alpine.effect(() => { out = fn(); });
|
|
3953
|
+
Alpine.release(e);
|
|
3954
|
+
return out;
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
function memoOp(array, dataSourceName, op, args, compute) {
|
|
3958
|
+
const version = sourceVersion(dataSourceName);
|
|
3959
|
+
if (version === undefined) return compute();
|
|
3960
|
+
const argsKey = stableStringify(args);
|
|
3961
|
+
if (argsKey === undefined) return compute();
|
|
3962
|
+
const raw = Alpine.raw ? Alpine.raw(array) : array;
|
|
3963
|
+
let entry = opCaches.get(raw);
|
|
3964
|
+
if (!entry || entry.v !== version) {
|
|
3965
|
+
entry = { v: version, map: new Map() };
|
|
3966
|
+
opCaches.set(raw, entry);
|
|
3967
|
+
}
|
|
3968
|
+
const key = `${version}|${op}|${argsKey}`;
|
|
3969
|
+
const map = entry.map;
|
|
3970
|
+
if (map.has(key)) {
|
|
3971
|
+
const hit = map.get(key);
|
|
3972
|
+
map.delete(key);
|
|
3973
|
+
map.set(key, hit);
|
|
3974
|
+
return hit;
|
|
3975
|
+
}
|
|
3976
|
+
const result = untracked(compute);
|
|
3977
|
+
map.set(key, result);
|
|
3978
|
+
if (map.size > OP_CACHE_MAX) map.delete(map.keys().next().value);
|
|
3979
|
+
return result;
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3224
3982
|
// Attach methods directly to the array (no proxy wrapper) so Alpine tracks it directly
|
|
3225
3983
|
function attachArrayMethods(array, dataSourceName, reloadDataSource) {
|
|
3226
3984
|
// Skip if already has methods attached
|
|
@@ -3309,58 +4067,62 @@ function attachArrayMethods(array, dataSourceName, reloadDataSource) {
|
|
|
3309
4067
|
return array;
|
|
3310
4068
|
}
|
|
3311
4069
|
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
// best-matching field's weight per whitespace-separated term (prefix
|
|
3316
|
-
// match gets a small boost); every term must match somewhere.
|
|
3317
|
-
const first = attributes[0];
|
|
3318
|
-
if (attributes.length === 1 && first && typeof first === 'object' && !Array.isArray(first)) {
|
|
3319
|
-
const fields = Object.keys(first);
|
|
3320
|
-
const terms = term.split(/\s+/).filter(Boolean);
|
|
3321
|
-
const scored = [];
|
|
3322
|
-
for (const item of array) {
|
|
3323
|
-
if (!item || typeof item !== 'object') continue;
|
|
3324
|
-
let total = 0, ok = true;
|
|
3325
|
-
for (const t of terms) {
|
|
3326
|
-
let best = 0;
|
|
3327
|
-
for (const f of fields) {
|
|
3328
|
-
const v = item[f];
|
|
3329
|
-
if (v == null) continue;
|
|
3330
|
-
const s = String(v).toLowerCase();
|
|
3331
|
-
if (!s.includes(t)) continue;
|
|
3332
|
-
let w = Number(first[f]) || 0;
|
|
3333
|
-
if (s.startsWith(t)) w *= 1.2;
|
|
3334
|
-
if (w > best) best = w;
|
|
3335
|
-
}
|
|
3336
|
-
if (!best) { ok = false; break; }
|
|
3337
|
-
total += best;
|
|
3338
|
-
}
|
|
3339
|
-
if (ok) scored.push([total, item]);
|
|
3340
|
-
}
|
|
3341
|
-
scored.sort((a, b) => b[0] - a[0]);
|
|
3342
|
-
const ranked = scored.map(e => e[1]);
|
|
3343
|
-
attachArrayMethods(ranked, dataSourceName, reloadDataSource);
|
|
3344
|
-
return ranked;
|
|
3345
|
-
}
|
|
3346
|
-
|
|
3347
|
-
const attrs = attributes.length > 0 ? attributes : Object.keys(array[0] || {});
|
|
4070
|
+
return memoOp(array, dataSourceName, 'search', [searchTerm, ...attributes], () => runSearch(searchTerm, attributes));
|
|
4071
|
+
}
|
|
4072
|
+
});
|
|
3348
4073
|
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
4074
|
+
function runSearch(searchTerm, attributes) {
|
|
4075
|
+
const term = searchTerm.toLowerCase().trim();
|
|
4076
|
+
|
|
4077
|
+
// Weighted mode: $search(term, { title: 3, body: 1 }) — ranks by the
|
|
4078
|
+
// best-matching field's weight per whitespace-separated term (prefix
|
|
4079
|
+
// match gets a small boost); every term must match somewhere.
|
|
4080
|
+
const first = attributes[0];
|
|
4081
|
+
if (attributes.length === 1 && first && typeof first === 'object' && !Array.isArray(first)) {
|
|
4082
|
+
const fields = Object.keys(first);
|
|
4083
|
+
const terms = term.split(/\s+/).filter(Boolean);
|
|
4084
|
+
const scored = [];
|
|
4085
|
+
for (const item of array) {
|
|
4086
|
+
if (!item || typeof item !== 'object') continue;
|
|
4087
|
+
let total = 0, ok = true;
|
|
4088
|
+
for (const t of terms) {
|
|
4089
|
+
let best = 0;
|
|
4090
|
+
for (const f of fields) {
|
|
4091
|
+
const v = item[f];
|
|
4092
|
+
if (v == null) continue;
|
|
4093
|
+
const s = String(v).toLowerCase();
|
|
4094
|
+
if (!s.includes(t)) continue;
|
|
4095
|
+
let w = Number(first[f]) || 0;
|
|
4096
|
+
if (s.startsWith(t)) w *= 1.2;
|
|
4097
|
+
if (w > best) best = w;
|
|
4098
|
+
}
|
|
4099
|
+
if (!best) { ok = false; break; }
|
|
4100
|
+
total += best;
|
|
4101
|
+
}
|
|
4102
|
+
if (ok) scored.push([total, item]);
|
|
4103
|
+
}
|
|
4104
|
+
scored.sort((a, b) => b[0] - a[0]);
|
|
4105
|
+
const ranked = scored.map(e => e[1]);
|
|
4106
|
+
attachArrayMethods(ranked, dataSourceName, reloadDataSource);
|
|
4107
|
+
return ranked;
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
const attrs = attributes.length > 0 ? attributes : Object.keys(array[0] || {});
|
|
4111
|
+
|
|
4112
|
+
const filtered = array.filter(item => {
|
|
4113
|
+
if (!item || typeof item !== 'object') return false;
|
|
4114
|
+
return attrs.some(attr => {
|
|
4115
|
+
const value = item[attr];
|
|
4116
|
+
if (value == null) return false;
|
|
4117
|
+
return String(value).toLowerCase().includes(term);
|
|
3356
4118
|
});
|
|
4119
|
+
});
|
|
3357
4120
|
|
|
3358
|
-
|
|
3359
|
-
|
|
4121
|
+
// Re-attach so chaining works (.$search().$query())
|
|
4122
|
+
attachArrayMethods(filtered, dataSourceName, reloadDataSource);
|
|
3360
4123
|
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
});
|
|
4124
|
+
return filtered;
|
|
4125
|
+
}
|
|
3364
4126
|
|
|
3365
4127
|
// Attach $route method
|
|
3366
4128
|
Object.defineProperty(array, '$route', {
|
|
@@ -3368,66 +4130,70 @@ function attachArrayMethods(array, dataSourceName, reloadDataSource) {
|
|
|
3368
4130
|
configurable: true,
|
|
3369
4131
|
writable: false,
|
|
3370
4132
|
value: function (pathKey) {
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
get() { return undefined; }
|
|
3375
|
-
});
|
|
3376
|
-
}
|
|
3377
|
-
if (array && typeof array === 'object') {
|
|
3378
|
-
// Prefer raw data — the real array, not the Alpine proxy
|
|
3379
|
-
const getRawData = window.ManifestDataStore?.getRawData;
|
|
3380
|
-
let dataToUse = array;
|
|
4133
|
+
return memoOp(array, dataSourceName, 'route', [pathKey], () => runRoute(pathKey));
|
|
4134
|
+
}
|
|
4135
|
+
});
|
|
3381
4136
|
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
4137
|
+
function runRoute(pathKey) {
|
|
4138
|
+
const createRouteProxy = window.ManifestDataProxies?.createRouteProxy;
|
|
4139
|
+
if (!createRouteProxy) {
|
|
4140
|
+
return new Proxy({}, {
|
|
4141
|
+
get() { return undefined; }
|
|
4142
|
+
});
|
|
4143
|
+
}
|
|
4144
|
+
if (array && typeof array === 'object') {
|
|
4145
|
+
// Prefer raw data — the real array, not the Alpine proxy
|
|
4146
|
+
const getRawData = window.ManifestDataStore?.getRawData;
|
|
4147
|
+
let dataToUse = array;
|
|
4148
|
+
|
|
4149
|
+
if (dataSourceName && getRawData) {
|
|
4150
|
+
const rawData = getRawData(dataSourceName);
|
|
4151
|
+
if (rawData && (Array.isArray(rawData) || (rawData.length !== undefined && rawData.length >= 0))) {
|
|
4152
|
+
dataToUse = rawData;
|
|
3387
4153
|
}
|
|
4154
|
+
}
|
|
3388
4155
|
|
|
3389
|
-
|
|
3390
|
-
|
|
4156
|
+
// If we still don't have raw data, try to convert the proxy to a real array
|
|
4157
|
+
if (!Array.isArray(dataToUse) && dataToUse && typeof dataToUse === 'object' && 'length' in dataToUse) {
|
|
4158
|
+
try {
|
|
4159
|
+
// Try Array.from first (works for most iterables including Alpine proxies)
|
|
4160
|
+
dataToUse = Array.from(dataToUse);
|
|
4161
|
+
} catch (e) {
|
|
4162
|
+
// Fallback: manual conversion
|
|
3391
4163
|
try {
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
dataToUse =
|
|
3402
|
-
} catch (e2) {
|
|
3403
|
-
// Last resort: try getting from store
|
|
3404
|
-
const store = Alpine.store('data');
|
|
3405
|
-
if (store && store[dataSourceName] && Array.isArray(store[dataSourceName])) {
|
|
3406
|
-
dataToUse = Array.from(store[dataSourceName]);
|
|
3407
|
-
}
|
|
4164
|
+
const arr = [];
|
|
4165
|
+
for (let i = 0; i < dataToUse.length; i++) {
|
|
4166
|
+
arr[i] = dataToUse[i];
|
|
4167
|
+
}
|
|
4168
|
+
dataToUse = arr;
|
|
4169
|
+
} catch (e2) {
|
|
4170
|
+
// Last resort: try getting from store
|
|
4171
|
+
const store = Alpine.store('data');
|
|
4172
|
+
if (store && store[dataSourceName] && Array.isArray(store[dataSourceName])) {
|
|
4173
|
+
dataToUse = Array.from(store[dataSourceName]);
|
|
3408
4174
|
}
|
|
3409
4175
|
}
|
|
3410
4176
|
}
|
|
4177
|
+
}
|
|
3411
4178
|
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
}
|
|
3417
|
-
|
|
3418
|
-
// Use the array directly - it should be the actual array, not a proxy
|
|
3419
|
-
// since attachArrayMethods is called on the array from the store
|
|
3420
|
-
return createRouteProxy(
|
|
3421
|
-
dataToUse,
|
|
3422
|
-
pathKey,
|
|
3423
|
-
dataSourceName || undefined // Always pass dataSourceName for proper raw data lookup
|
|
3424
|
-
);
|
|
4179
|
+
// Ensure we have a real array before passing to createRouteProxy
|
|
4180
|
+
if (!Array.isArray(dataToUse) && dataToUse && typeof dataToUse === 'object' && 'length' in dataToUse) {
|
|
4181
|
+
// Final attempt: convert to array
|
|
4182
|
+
dataToUse = Array.from(dataToUse);
|
|
3425
4183
|
}
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
4184
|
+
|
|
4185
|
+
// Use the array directly - it should be the actual array, not a proxy
|
|
4186
|
+
// since attachArrayMethods is called on the array from the store
|
|
4187
|
+
return createRouteProxy(
|
|
4188
|
+
dataToUse,
|
|
4189
|
+
pathKey,
|
|
4190
|
+
dataSourceName || undefined // Always pass dataSourceName for proper raw data lookup
|
|
4191
|
+
);
|
|
3429
4192
|
}
|
|
3430
|
-
|
|
4193
|
+
return new Proxy({}, {
|
|
4194
|
+
get() { return undefined; }
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
3431
4197
|
|
|
3432
4198
|
// Attach $files method (for tables)
|
|
3433
4199
|
if (dataSourceName) {
|
|
@@ -3483,106 +4249,111 @@ function attachArrayMethods(array, dataSourceName, reloadDataSource) {
|
|
|
3483
4249
|
if (!Array.isArray(queries) || queries.length === 0) {
|
|
3484
4250
|
return array;
|
|
3485
4251
|
}
|
|
4252
|
+
// orderRandom reshuffles per call — never memoized
|
|
4253
|
+
if (queries.some(q => Array.isArray(q) && q[0] === 'orderRandom')) return runQuery(queries);
|
|
4254
|
+
return memoOp(array, dataSourceName, 'query', [queries], () => runQuery(queries));
|
|
4255
|
+
}
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
3486
4258
|
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
}
|
|
3570
|
-
// Pagination methods
|
|
3571
|
-
else if (method === 'limit' && args.length >= 1) {
|
|
3572
|
-
const [limit] = args;
|
|
3573
|
-
result = result.slice(0, parseInt(limit, 10));
|
|
3574
|
-
} else if (method === 'offset' && args.length >= 1) {
|
|
3575
|
-
const [offset] = args;
|
|
3576
|
-
result = result.slice(parseInt(offset, 10));
|
|
3577
|
-
}
|
|
4259
|
+
function runQuery(queries) {
|
|
4260
|
+
let result = [...array];
|
|
4261
|
+
|
|
4262
|
+
// Process each query in order
|
|
4263
|
+
for (const query of queries) {
|
|
4264
|
+
if (!Array.isArray(query) || query.length === 0) continue;
|
|
4265
|
+
|
|
4266
|
+
const [method, ...args] = query;
|
|
4267
|
+
|
|
4268
|
+
// Filtering methods
|
|
4269
|
+
if (method === 'equal' && args.length >= 2) {
|
|
4270
|
+
const [attr, value] = args;
|
|
4271
|
+
result = result.filter(item => item && item[attr] === value);
|
|
4272
|
+
} else if (method === 'notEqual' && args.length >= 2) {
|
|
4273
|
+
const [attr, value] = args;
|
|
4274
|
+
result = result.filter(item => item && item[attr] !== value);
|
|
4275
|
+
} else if (method === 'greaterThan' && args.length >= 2) {
|
|
4276
|
+
const [attr, value] = args;
|
|
4277
|
+
result = result.filter(item => item && item[attr] != null && item[attr] > value);
|
|
4278
|
+
} else if (method === 'greaterThanOrEqual' && args.length >= 2) {
|
|
4279
|
+
const [attr, value] = args;
|
|
4280
|
+
result = result.filter(item => item && item[attr] != null && item[attr] >= value);
|
|
4281
|
+
} else if (method === 'lessThan' && args.length >= 2) {
|
|
4282
|
+
const [attr, value] = args;
|
|
4283
|
+
result = result.filter(item => item && item[attr] != null && item[attr] < value);
|
|
4284
|
+
} else if (method === 'lessThanOrEqual' && args.length >= 2) {
|
|
4285
|
+
const [attr, value] = args;
|
|
4286
|
+
result = result.filter(item => item && item[attr] != null && item[attr] <= value);
|
|
4287
|
+
} else if (method === 'contains' && args.length >= 2) {
|
|
4288
|
+
const [attr, value] = args;
|
|
4289
|
+
const searchValue = String(value).toLowerCase();
|
|
4290
|
+
result = result.filter(item => item && item[attr] != null && String(item[attr]).toLowerCase().includes(searchValue));
|
|
4291
|
+
} else if (method === 'startsWith' && args.length >= 2) {
|
|
4292
|
+
const [attr, value] = args;
|
|
4293
|
+
const searchValue = String(value).toLowerCase();
|
|
4294
|
+
result = result.filter(item => item && item[attr] != null && String(item[attr]).toLowerCase().startsWith(searchValue));
|
|
4295
|
+
} else if (method === 'endsWith' && args.length >= 2) {
|
|
4296
|
+
const [attr, value] = args;
|
|
4297
|
+
const searchValue = String(value).toLowerCase();
|
|
4298
|
+
result = result.filter(item => item && item[attr] != null && String(item[attr]).toLowerCase().endsWith(searchValue));
|
|
4299
|
+
} else if (method === 'isNull' && args.length >= 1) {
|
|
4300
|
+
const [attr] = args;
|
|
4301
|
+
result = result.filter(item => item && (item[attr] == null || item[attr] === ''));
|
|
4302
|
+
} else if (method === 'isNotNull' && args.length >= 1) {
|
|
4303
|
+
const [attr] = args;
|
|
4304
|
+
result = result.filter(item => item && item[attr] != null && item[attr] !== '');
|
|
4305
|
+
} else if (method === 'between' && args.length >= 3) {
|
|
4306
|
+
const [attr, min, max] = args;
|
|
4307
|
+
result = result.filter(item => item && item[attr] != null && item[attr] >= min && item[attr] <= max);
|
|
4308
|
+
}
|
|
4309
|
+
// Sorting methods
|
|
4310
|
+
else if (method === 'orderAsc' && args.length >= 1) {
|
|
4311
|
+
const [attr] = args;
|
|
4312
|
+
result.sort((a, b) => {
|
|
4313
|
+
const aVal = a && a[attr];
|
|
4314
|
+
const bVal = b && b[attr];
|
|
4315
|
+
if (aVal == null && bVal == null) return 0;
|
|
4316
|
+
if (aVal == null) return 1;
|
|
4317
|
+
if (bVal == null) return -1;
|
|
4318
|
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
4319
|
+
return aVal.localeCompare(bVal);
|
|
4320
|
+
}
|
|
4321
|
+
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
|
|
4322
|
+
});
|
|
4323
|
+
} else if (method === 'orderDesc' && args.length >= 1) {
|
|
4324
|
+
const [attr] = args;
|
|
4325
|
+
result.sort((a, b) => {
|
|
4326
|
+
const aVal = a && a[attr];
|
|
4327
|
+
const bVal = b && b[attr];
|
|
4328
|
+
if (aVal == null && bVal == null) return 0;
|
|
4329
|
+
if (aVal == null) return 1;
|
|
4330
|
+
if (bVal == null) return -1;
|
|
4331
|
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
4332
|
+
return bVal.localeCompare(aVal);
|
|
4333
|
+
}
|
|
4334
|
+
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
|
|
4335
|
+
});
|
|
4336
|
+
} else if (method === 'orderRandom') {
|
|
4337
|
+
// Fisher-Yates shuffle
|
|
4338
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
4339
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
4340
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
3578
4341
|
}
|
|
4342
|
+
}
|
|
4343
|
+
// Pagination methods
|
|
4344
|
+
else if (method === 'limit' && args.length >= 1) {
|
|
4345
|
+
const [limit] = args;
|
|
4346
|
+
result = result.slice(0, parseInt(limit, 10));
|
|
4347
|
+
} else if (method === 'offset' && args.length >= 1) {
|
|
4348
|
+
const [offset] = args;
|
|
4349
|
+
result = result.slice(parseInt(offset, 10));
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
3579
4352
|
|
|
3580
|
-
|
|
3581
|
-
|
|
4353
|
+
// Re-attach so chaining works (.$query().$search())
|
|
4354
|
+
attachArrayMethods(result, dataSourceName, reloadDataSource);
|
|
3582
4355
|
|
|
3583
|
-
|
|
3584
|
-
}
|
|
3585
|
-
});
|
|
4356
|
+
return result;
|
|
3586
4357
|
}
|
|
3587
4358
|
|
|
3588
4359
|
// Attach Appwrite methods ($create, $update, $delete, etc.)
|
|
@@ -8393,6 +9164,11 @@ function registerXMagicMethod(loadDataSource) {
|
|
|
8393
9164
|
};
|
|
8394
9165
|
}
|
|
8395
9166
|
|
|
9167
|
+
// $x.$wipe() / $wipe(source) / $wipe({ all: true }) — persisted snapshots (§12.2)
|
|
9168
|
+
if (prop === '$wipe') {
|
|
9169
|
+
return (arg) => window.ManifestDataPersist?.wipe?.(arg) ?? Promise.resolve(false);
|
|
9170
|
+
}
|
|
9171
|
+
|
|
8396
9172
|
// $x.all — lazy cross-source array, versioned by _v.all
|
|
8397
9173
|
if (prop === 'all') {
|
|
8398
9174
|
return window.ManifestDataStore?.getAll?.() ?? [];
|
|
@@ -8413,6 +9189,16 @@ function registerXMagicMethod(loadDataSource) {
|
|
|
8413
9189
|
}
|
|
8414
9190
|
const nestedCache = window.ManifestDataProxiesCore.nestedDataSourceProxyCache;
|
|
8415
9191
|
const hasData = rawValueEarly !== undefined && rawValueEarly !== null;
|
|
9192
|
+
|
|
9193
|
+
// Persisted source (§12.2): hydrated rows are stale until the network
|
|
9194
|
+
// lands, so the first read still fetches (hydration races it)
|
|
9195
|
+
const persist = window.ManifestDataPersist;
|
|
9196
|
+
if (hasData && persist?.needsFetch?.(prop)) {
|
|
9197
|
+
const locale = (typeof document !== 'undefined' && document.documentElement?.lang)
|
|
9198
|
+
|| (typeof Alpine !== 'undefined' && Alpine.store('locale')?.current) || 'en';
|
|
9199
|
+
Promise.resolve(loadDataSource(prop, locale))
|
|
9200
|
+
.then(result => persist.onFetchSettled?.(prop, result), () => persist.onFetchSettled?.(prop, null));
|
|
9201
|
+
}
|
|
8416
9202
|
{
|
|
8417
9203
|
if (nestedCache.has(prop) && hasData) {
|
|
8418
9204
|
const cachedProxy = nestedCache.get(prop);
|
|
@@ -8444,6 +9230,7 @@ function registerXMagicMethod(loadDataSource) {
|
|
|
8444
9230
|
// this source lands), return loading proxy. Reading only the
|
|
8445
9231
|
// version avoids re-entry/overflow.
|
|
8446
9232
|
if (!hasData) {
|
|
9233
|
+
persist?.onRead?.(prop); // lazy-tier hydration on first read
|
|
8447
9234
|
if (!pendingLoads.has(prop)) {
|
|
8448
9235
|
const locale = typeof document !== 'undefined' && document.documentElement
|
|
8449
9236
|
? document.documentElement.lang
|
|
@@ -10888,7 +11675,7 @@ function liveLocale() {
|
|
|
10888
11675
|
async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
|
|
10889
11676
|
locale = locale || 'en';
|
|
10890
11677
|
const cacheKey = `${dataSourceName}:${locale}`;
|
|
10891
|
-
const { dataSourceCache, loadingPromises, isInitializing, updateStore, landRows, setSourceState, runDeduped } = window.ManifestDataStore;
|
|
11678
|
+
const { dataSourceCache, loadingPromises, isInitializing, updateStore, landRows, setSourceState, runDeduped, sourceGeneration } = window.ManifestDataStore;
|
|
10892
11679
|
|
|
10893
11680
|
// Memory cache (this page-load's fetches). Serving cached data is fine, but
|
|
10894
11681
|
// writing an old locale to the live store would clobber a concurrent locale
|
|
@@ -10917,6 +11704,10 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
|
|
|
10917
11704
|
else updateStore(dataSourceName, null, { loading: true, error: null, ready: false });
|
|
10918
11705
|
}
|
|
10919
11706
|
|
|
11707
|
+
// Scope reset (persistence) during this load → its result is discarded
|
|
11708
|
+
const generation = sourceGeneration ? sourceGeneration(dataSourceName) : 0;
|
|
11709
|
+
const superseded = () => sourceGeneration && sourceGeneration(dataSourceName) !== generation;
|
|
11710
|
+
|
|
10920
11711
|
return runDeduped(cacheKey, async () => {
|
|
10921
11712
|
let landed = false;
|
|
10922
11713
|
try {
|
|
@@ -11167,6 +11958,8 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
|
|
|
11167
11958
|
enhancedData = [];
|
|
11168
11959
|
}
|
|
11169
11960
|
|
|
11961
|
+
if (superseded()) { landed = true; return null; }
|
|
11962
|
+
|
|
11170
11963
|
// Cache is always safe — the key carries this load's locale.
|
|
11171
11964
|
dataSourceCache.set(cacheKey, enhancedData);
|
|
11172
11965
|
|
|
@@ -11189,12 +11982,16 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
|
|
|
11189
11982
|
// Return unsealed version for our proxy system
|
|
11190
11983
|
return enhancedData;
|
|
11191
11984
|
} catch (error) {
|
|
11985
|
+
if (superseded()) { landed = true; return null; }
|
|
11192
11986
|
// Error state (timestamped to prevent rapid retries); live rows stay
|
|
11193
11987
|
if (!isInitializing) {
|
|
11194
11988
|
landed = true;
|
|
11195
11989
|
const errorMessage = error?.message || error?.toString() || `Failed to load dataSource "${dataSourceName}"`;
|
|
11196
11990
|
if (hasLiveRows(dataSourceName)) {
|
|
11197
11991
|
setSourceState(dataSourceName, { loading: false, error: errorMessage, errorTime: Date.now() });
|
|
11992
|
+
} else if (error?.defaultValue !== undefined) {
|
|
11993
|
+
// API-URL first load failed: its defaultValue stands in (not fresh), never over live rows
|
|
11994
|
+
await landRows(dataSourceName, error.defaultValue, { mode: 'replace', loading: false, error: errorMessage, ready: true, fresh: false });
|
|
11198
11995
|
} else {
|
|
11199
11996
|
updateStore(dataSourceName, null, {
|
|
11200
11997
|
loading: false,
|
|
@@ -11217,7 +12014,7 @@ async function loadDataSource(dataSourceName, locale = 'en', options = {}) {
|
|
|
11217
12014
|
return null;
|
|
11218
12015
|
} finally {
|
|
11219
12016
|
// Early exits (no source, stale locale) must not strand a live source in $loading
|
|
11220
|
-
if (!landed && keepRows && !isInitializing) setSourceState(dataSourceName, { loading: false });
|
|
12017
|
+
if (!landed && keepRows && !isInitializing && !superseded()) setSourceState(dataSourceName, { loading: false });
|
|
11221
12018
|
}
|
|
11222
12019
|
});
|
|
11223
12020
|
}
|
|
@@ -11394,6 +12191,13 @@ async function initializeDataSourcesPlugin() {
|
|
|
11394
12191
|
const locale = (typeof document !== 'undefined' && document.documentElement?.lang) || (typeof Alpine !== 'undefined' && Alpine.store('locale')?.current) || 'en';
|
|
11395
12192
|
const isAppwriteCollection = window.ManifestDataConfig.isAppwriteCollection;
|
|
11396
12193
|
|
|
12194
|
+
// Persisted $x (§12.2): boot-tier snapshots land before any network load
|
|
12195
|
+
// of those sources; capped so an IndexedDB miss never delays a cold boot
|
|
12196
|
+
const persist = window.ManifestDataPersist;
|
|
12197
|
+
if (persist && manifest && persist.configure(manifest)) {
|
|
12198
|
+
await persist.hydrateBoot({ maxWaitMs: persist.BOOT_HYDRATE_MAX_WAIT_MS });
|
|
12199
|
+
}
|
|
12200
|
+
|
|
11397
12201
|
if (manifest?.data) {
|
|
11398
12202
|
const preloadNames = [];
|
|
11399
12203
|
for (const [name, source] of Object.entries(manifest.data)) {
|