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.
@@ -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
- console.error(`[Manifest Data] Failed to load API dataSource:`, error);
2052
- // Return empty array/object to prevent breaking the UI
2053
- return Array.isArray(dataSource.defaultValue) ? dataSource.defaultValue : (dataSource.defaultValue || []);
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
- const term = searchTerm.toLowerCase().trim();
3313
-
3314
- // Weighted mode: $search(term, { title: 3, body: 1 }) — ranks by the
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
- const filtered = array.filter(item => {
3350
- if (!item || typeof item !== 'object') return false;
3351
- return attrs.some(attr => {
3352
- const value = item[attr];
3353
- if (value == null) return false;
3354
- return String(value).toLowerCase().includes(term);
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
- // Re-attach so chaining works (.$search().$query())
3359
- attachArrayMethods(filtered, dataSourceName, reloadDataSource);
4121
+ // Re-attach so chaining works (.$search().$query())
4122
+ attachArrayMethods(filtered, dataSourceName, reloadDataSource);
3360
4123
 
3361
- return filtered;
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
- const createRouteProxy = window.ManifestDataProxies?.createRouteProxy;
3372
- if (!createRouteProxy) {
3373
- return new Proxy({}, {
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
- if (dataSourceName && getRawData) {
3383
- const rawData = getRawData(dataSourceName);
3384
- if (rawData && (Array.isArray(rawData) || (rawData.length !== undefined && rawData.length >= 0))) {
3385
- dataToUse = rawData;
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
- // If we still don't have raw data, try to convert the proxy to a real array
3390
- if (!Array.isArray(dataToUse) && dataToUse && typeof dataToUse === 'object' && 'length' in dataToUse) {
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
- // Try Array.from first (works for most iterables including Alpine proxies)
3393
- dataToUse = Array.from(dataToUse);
3394
- } catch (e) {
3395
- // Fallback: manual conversion
3396
- try {
3397
- const arr = [];
3398
- for (let i = 0; i < dataToUse.length; i++) {
3399
- arr[i] = dataToUse[i];
3400
- }
3401
- dataToUse = arr;
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
- // Ensure we have a real array before passing to createRouteProxy
3413
- if (!Array.isArray(dataToUse) && dataToUse && typeof dataToUse === 'object' && 'length' in dataToUse) {
3414
- // Final attempt: convert to array
3415
- dataToUse = Array.from(dataToUse);
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
- return new Proxy({}, {
3427
- get() { return undefined; }
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
- let result = [...array];
3488
-
3489
- // Process each query in order
3490
- for (const query of queries) {
3491
- if (!Array.isArray(query) || query.length === 0) continue;
3492
-
3493
- const [method, ...args] = query;
3494
-
3495
- // Filtering methods
3496
- if (method === 'equal' && args.length >= 2) {
3497
- const [attr, value] = args;
3498
- result = result.filter(item => item && item[attr] === value);
3499
- } else if (method === 'notEqual' && args.length >= 2) {
3500
- const [attr, value] = args;
3501
- result = result.filter(item => item && item[attr] !== value);
3502
- } else if (method === 'greaterThan' && args.length >= 2) {
3503
- const [attr, value] = args;
3504
- result = result.filter(item => item && item[attr] != null && item[attr] > value);
3505
- } else if (method === 'greaterThanOrEqual' && args.length >= 2) {
3506
- const [attr, value] = args;
3507
- result = result.filter(item => item && item[attr] != null && item[attr] >= value);
3508
- } else if (method === 'lessThan' && args.length >= 2) {
3509
- const [attr, value] = args;
3510
- result = result.filter(item => item && item[attr] != null && item[attr] < value);
3511
- } else if (method === 'lessThanOrEqual' && args.length >= 2) {
3512
- const [attr, value] = args;
3513
- result = result.filter(item => item && item[attr] != null && item[attr] <= value);
3514
- } else if (method === 'contains' && args.length >= 2) {
3515
- const [attr, value] = args;
3516
- const searchValue = String(value).toLowerCase();
3517
- result = result.filter(item => item && item[attr] != null && String(item[attr]).toLowerCase().includes(searchValue));
3518
- } else if (method === 'startsWith' && args.length >= 2) {
3519
- const [attr, value] = args;
3520
- const searchValue = String(value).toLowerCase();
3521
- result = result.filter(item => item && item[attr] != null && String(item[attr]).toLowerCase().startsWith(searchValue));
3522
- } else if (method === 'endsWith' && args.length >= 2) {
3523
- const [attr, value] = args;
3524
- const searchValue = String(value).toLowerCase();
3525
- result = result.filter(item => item && item[attr] != null && String(item[attr]).toLowerCase().endsWith(searchValue));
3526
- } else if (method === 'isNull' && args.length >= 1) {
3527
- const [attr] = args;
3528
- result = result.filter(item => item && (item[attr] == null || item[attr] === ''));
3529
- } else if (method === 'isNotNull' && args.length >= 1) {
3530
- const [attr] = args;
3531
- result = result.filter(item => item && item[attr] != null && item[attr] !== '');
3532
- } else if (method === 'between' && args.length >= 3) {
3533
- const [attr, min, max] = args;
3534
- result = result.filter(item => item && item[attr] != null && item[attr] >= min && item[attr] <= max);
3535
- }
3536
- // Sorting methods
3537
- else if (method === 'orderAsc' && args.length >= 1) {
3538
- const [attr] = args;
3539
- result.sort((a, b) => {
3540
- const aVal = a && a[attr];
3541
- const bVal = b && b[attr];
3542
- if (aVal == null && bVal == null) return 0;
3543
- if (aVal == null) return 1;
3544
- if (bVal == null) return -1;
3545
- if (typeof aVal === 'string' && typeof bVal === 'string') {
3546
- return aVal.localeCompare(bVal);
3547
- }
3548
- return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
3549
- });
3550
- } else if (method === 'orderDesc' && args.length >= 1) {
3551
- const [attr] = args;
3552
- result.sort((a, b) => {
3553
- const aVal = a && a[attr];
3554
- const bVal = b && b[attr];
3555
- if (aVal == null && bVal == null) return 0;
3556
- if (aVal == null) return 1;
3557
- if (bVal == null) return -1;
3558
- if (typeof aVal === 'string' && typeof bVal === 'string') {
3559
- return bVal.localeCompare(aVal);
3560
- }
3561
- return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
3562
- });
3563
- } else if (method === 'orderRandom') {
3564
- // Fisher-Yates shuffle
3565
- for (let i = result.length - 1; i > 0; i--) {
3566
- const j = Math.floor(Math.random() * (i + 1));
3567
- [result[i], result[j]] = [result[j], result[i]];
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
- // Re-attach so chaining works (.$query().$search())
3581
- attachArrayMethods(result, dataSourceName, reloadDataSource);
4353
+ // Re-attach so chaining works (.$query().$search())
4354
+ attachArrayMethods(result, dataSourceName, reloadDataSource);
3582
4355
 
3583
- return result;
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)) {