mnfst 0.5.198 → 0.5.199-next.1

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.1';
6
+
5
7
  /* Manifest Data Sources - Appwrite Integration */
6
8
 
7
9
  // Cache for Appwrite client (initialized once)
@@ -2,6 +2,328 @@
2
2
 
3
3
  (function () {
4
4
 
5
+ /* Manifest Chat — persisted windows
6
+ /* By Andrew Matlock under MIT license
7
+ /* https://manifestx.dev
8
+ /*
9
+ /* Last `messages` of the `conversations` most recently opened conversations,
10
+ /* on the persisted-$x record store (PERF-PRIMITIVES-DESIGN.md §12.2, primitive 3).
11
+ /* Keys `${scope}|chat|${conversationId}`; index `${scope}|chat` = { recent }.
12
+ /* Off unless manifest.json `chat.persist` (or `ai.persist`) opts in.
13
+ */
14
+
15
+ (function () {
16
+ 'use strict';
17
+
18
+ const DEFAULT_MESSAGES = 50;
19
+ const DEFAULT_CONVERSATIONS = 30;
20
+ const DEFAULT_TTL = '7d';
21
+ const WRITE_DEBOUNCE_MS = 500;
22
+ const CONFIGURE_WAIT_MS = 1500;
23
+ const SECRET_PATTERNS = ['*secret*', '*token*', '*password*', 'credentials*'];
24
+ const WIPE_EVENTS = ['manifest:auth:logout', 'manifest:auth:session-cleared'];
25
+ const INDEX = Symbol('index');
26
+
27
+ const state = {
28
+ config: null, // normalized { messages, conversations, ttl, strip } or null (off)
29
+ explicit: false, // configured at runtime (harness/tests) — manifest not consulted
30
+ ready: null, // Promise: config + store scope settled
31
+ handles: new Map(), // conversationId -> { snapshot, count, stale, reset }
32
+ recent: null, // index in memory (null until read)
33
+ indexPromise: null,
34
+ indexDirty: false,
35
+ pending: new Map(), // conversationId | INDEX -> due time
36
+ timer: null,
37
+ saved: new Map(), // conversationId -> { messages, savedAt }
38
+ generation: 0,
39
+ watching: false,
40
+ resetQueued: false
41
+ };
42
+
43
+ const noop = () => { };
44
+ const records = () => (window.ManifestDataPersist && window.ManifestDataPersist.records) || null;
45
+ function enabled() { const r = records(); return !!(state.config && r && r.enabled()); }
46
+
47
+ // ---- config ---------------------------------------------------------------
48
+ function globToRegExp(pattern) {
49
+ const escaped = String(pattern).split('*').map(p => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
50
+ return new RegExp('^' + escaped + '$', 'i');
51
+ }
52
+
53
+ // strip entries may be dotted paths (`meta.raw`); each segment is a glob
54
+ function compileStrip(list) {
55
+ return list.map(p => String(p).split('.').map(globToRegExp));
56
+ }
57
+
58
+ function normalize(raw) {
59
+ if (raw !== true && (!raw || typeof raw !== 'object')) return null;
60
+ const o = raw === true ? {} : raw;
61
+ const n = (v, d) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : d);
62
+ const strip = Array.isArray(o.strip) ? o.strip : (typeof o.strip === 'string' ? [o.strip] : []);
63
+ const r = records();
64
+ const ttlMs = r ? r.ttl(o.ttl === undefined ? DEFAULT_TTL : o.ttl) : 7 * 86400000;
65
+ return {
66
+ messages: n(o.messages, DEFAULT_MESSAGES),
67
+ conversations: n(o.conversations, DEFAULT_CONVERSATIONS),
68
+ ttl: ttlMs,
69
+ strip: compileStrip([...SECRET_PATTERNS, ...strip])
70
+ };
71
+ }
72
+
73
+ // Runtime opt-in (same shape as manifest.json `chat.persist`); null/false turns it off
74
+ function configure(raw) {
75
+ state.explicit = true;
76
+ state.config = normalize(raw);
77
+ state.ready = null;
78
+ return bootstrap();
79
+ }
80
+
81
+ function manifestConfig(m) {
82
+ if (!m || typeof m !== 'object') return undefined;
83
+ const block = (m.chat && typeof m.chat === 'object' && m.chat) || (m.ai && typeof m.ai === 'object' && m.ai) || null;
84
+ return block ? block.persist : undefined;
85
+ }
86
+
87
+ // Config from manifest.json unless configured explicitly; then wait for the
88
+ // data plugin's persist configure (scope expression) before touching the store
89
+ function bootstrap() {
90
+ if (state.ready) return state.ready;
91
+ state.ready = (async () => {
92
+ if (!state.explicit) {
93
+ let m = null;
94
+ try {
95
+ const cfg = window.ManifestDataConfig;
96
+ m = cfg && cfg.ensureManifest ? await cfg.ensureManifest()
97
+ : (window.__manifestLoaded || (window.ManifestComponentsRegistry && window.ManifestComponentsRegistry.manifest) || null);
98
+ } catch (_) { m = null; }
99
+ state.config = normalize(manifestConfig(m));
100
+ }
101
+ if (!state.config) return;
102
+ const dp = window.ManifestDataPersist;
103
+ if (!dp) { state.config = null; return; }
104
+ if (!state.explicit) {
105
+ const until = Date.now() + CONFIGURE_WAIT_MS;
106
+ while (!dp.state.configured && Date.now() < until) await new Promise(r => setTimeout(r, 10));
107
+ }
108
+ dp.records.enable();
109
+ watch();
110
+ })();
111
+ return state.ready;
112
+ }
113
+
114
+ // ---- scope / wipe -----------------------------------------------------------
115
+ function watch() {
116
+ if (state.watching) return;
117
+ state.watching = true;
118
+ const queue = () => {
119
+ if (state.resetQueued) return;
120
+ state.resetQueued = true;
121
+ queueMicrotask(() => { state.resetQueued = false; reset(); });
122
+ };
123
+ window.addEventListener('manifest:persist:scope', queue);
124
+ for (const type of WIPE_EVENTS) window.addEventListener(type, queue);
125
+ }
126
+
127
+ // Drop every in-memory window and the index; the store's scope prefix already
128
+ // covers the records. Handles go idle — the app re-opens for the new scope.
129
+ function reset() {
130
+ state.generation++;
131
+ state.recent = null;
132
+ state.indexPromise = null;
133
+ state.indexDirty = false;
134
+ state.saved.clear();
135
+ for (const h of state.handles.values()) { try { h.reset(); } catch (_) { } }
136
+ cancel();
137
+ }
138
+
139
+ // ---- index ------------------------------------------------------------------
140
+ function adoptIndex(rec) {
141
+ const r = records();
142
+ const ok = rec && typeof rec === 'object' && rec.scope === (r ? r.scope() : '') && Array.isArray(rec.recent);
143
+ state.recent = ok ? rec.recent.filter(id => typeof id === 'string' || typeof id === 'number') : [];
144
+ return state.recent;
145
+ }
146
+
147
+ function ensureIndex() {
148
+ if (state.recent) return Promise.resolve(state.recent);
149
+ if (state.indexPromise) return state.indexPromise;
150
+ const gen = state.generation;
151
+ state.indexPromise = (async () => {
152
+ await bootstrap();
153
+ if (!enabled()) return [];
154
+ const got = await records().get([records().key('chat')]);
155
+ if (gen !== state.generation) return [];
156
+ return adoptIndex(got && got[0]);
157
+ })().catch(() => (state.recent = state.recent || []));
158
+ return state.indexPromise;
159
+ }
160
+
161
+ // Recency bump; beyond the cap the oldest conversations' records are deleted
162
+ function opened(conversationId) {
163
+ if (conversationId == null) return;
164
+ const gen = state.generation;
165
+ ensureIndex().then(recent => {
166
+ if (gen !== state.generation || !enabled()) return;
167
+ const i = recent.indexOf(conversationId);
168
+ if (i > -1) recent.splice(i, 1);
169
+ recent.unshift(conversationId);
170
+ const evicted = recent.splice(state.config.conversations);
171
+ const r = records();
172
+ for (const id of evicted) { state.pending.delete(id); state.saved.delete(id); }
173
+ if (evicted.length) r.delete(evicted.map(id => r.key('chat', id))).catch(noop);
174
+ state.indexDirty = true;
175
+ state.pending.set(INDEX, Date.now() + WRITE_DEBOUNCE_MS);
176
+ schedule();
177
+ }).catch(noop);
178
+ }
179
+
180
+ // ---- hydrate (read path) ----------------------------------------------------
181
+ // Record + index in one transaction on the first open; later opens read the record only
182
+ function hydrate(conversationId) {
183
+ let indexRead = null;
184
+ if (!state.recent && !state.indexPromise) {
185
+ indexRead = { done: false };
186
+ state.indexPromise = new Promise(resolve => { indexRead.resolve = (v) => { indexRead.done = true; resolve(v); }; });
187
+ }
188
+ const gen = state.generation;
189
+ return hydrateRead(conversationId, gen, indexRead)
190
+ .catch(() => null)
191
+ .then(result => { if (indexRead && !indexRead.done) indexRead.resolve(gen === state.generation ? adoptIndex(null) : []); return result; });
192
+ }
193
+
194
+ async function hydrateRead(conversationId, gen, indexRead) {
195
+ await bootstrap();
196
+ if (!enabled() || conversationId == null || gen !== state.generation) return null;
197
+ const r = records();
198
+ const keys = [r.key('chat', conversationId)];
199
+ if (indexRead) keys.push(r.key('chat'));
200
+ let got;
201
+ try { got = await r.get(keys); } catch (_) { got = null; }
202
+ if (indexRead) indexRead.resolve(gen === state.generation ? adoptIndex(got && got[1]) : []);
203
+ if (!got || gen !== state.generation) return null;
204
+ const rec = got[0];
205
+ if (!rec) return null;
206
+ if (!r.valid(rec, state.config.ttl) || !Array.isArray(rec.rows)) {
207
+ if (rec.scope === r.scope()) r.delete([rec.key]).catch(noop);
208
+ return null;
209
+ }
210
+ state.saved.set(conversationId, { messages: rec.rows.length, savedAt: rec.savedAt });
211
+ return { messages: rec.rows, savedAt: rec.savedAt };
212
+ }
213
+
214
+ // ---- snapshot (write path) --------------------------------------------------
215
+ function stripObject(obj, patterns, depth) {
216
+ const out = {};
217
+ for (const key of Object.keys(obj)) {
218
+ const value = obj[key];
219
+ if (typeof value === 'function' || (depth === 0 && key[0] === '_')) continue;
220
+ const hits = patterns.filter(p => p[depth] && p[depth].test(key));
221
+ if (hits.some(p => p.length === depth + 1)) continue;
222
+ const deeper = hits.filter(p => p.length > depth + 1);
223
+ out[key] = deeper.length && value && typeof value === 'object' && !Array.isArray(value)
224
+ ? stripObject(value, deeper, depth + 1) : value;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ function snapshotOf(messages) {
230
+ const tail = messages.length > state.config.messages ? messages.slice(messages.length - state.config.messages) : messages;
231
+ return JSON.parse(JSON.stringify(tail.map(m => stripObject(m, state.config.strip, 0))));
232
+ }
233
+
234
+ function touch(conversationId) {
235
+ if (!state.config || !state.handles.has(conversationId)) return;
236
+ state.pending.set(conversationId, Date.now() + WRITE_DEBOUNCE_MS);
237
+ schedule();
238
+ }
239
+
240
+ function schedule() {
241
+ if (state.timer !== null || !state.pending.size) return;
242
+ let next = Infinity;
243
+ for (const at of state.pending.values()) if (at < next) next = at;
244
+ state.timer = setTimeout(flushDue, Math.max(0, next - Date.now()));
245
+ }
246
+
247
+ function cancel() {
248
+ state.pending.clear();
249
+ if (state.timer !== null) { clearTimeout(state.timer); state.timer = null; }
250
+ }
251
+
252
+ function flushDue() {
253
+ state.timer = null;
254
+ const now = Date.now();
255
+ const due = [];
256
+ for (const [key, at] of state.pending) if (at - now <= 5) due.push(key);
257
+ const run = due.length ? flush(due) : Promise.resolve();
258
+ schedule();
259
+ return run.catch(noop);
260
+ }
261
+
262
+ // Every due conversation (+ the index) in ONE transaction
263
+ async function flush(due) {
264
+ for (const key of due) state.pending.delete(key);
265
+ const recent = await ensureIndex();
266
+ if (!enabled()) return;
267
+ const gen = state.generation;
268
+ const r = records();
269
+ const savedAt = Date.now();
270
+ const list = [];
271
+ for (const id of due) {
272
+ if (id === INDEX) continue;
273
+ const h = state.handles.get(id);
274
+ if (!h || !recent.includes(id)) continue;
275
+ let rows = null;
276
+ try { const msgs = h.snapshot(); rows = msgs && msgs.length ? snapshotOf(msgs) : null; } catch (_) { rows = null; }
277
+ if (!rows) continue;
278
+ list.push({ key: r.key('chat', id), kind: 'chat', conversation: id, rows, savedAt });
279
+ }
280
+ if (state.indexDirty) { list.push({ key: r.key('chat'), kind: 'chat-index', recent: recent.slice(), savedAt }); state.indexDirty = false; }
281
+ if (!list.length) return;
282
+ await r.put(list);
283
+ if (gen !== state.generation || !enabled()) return;
284
+ for (const rec of list) if (rec.kind === 'chat') state.saved.set(rec.conversation, { messages: rec.rows.length, savedAt });
285
+ }
286
+
287
+ // Tests/harness: write everything pending now
288
+ async function flushPending() {
289
+ const due = [...state.pending.keys()];
290
+ if (state.timer !== null) { clearTimeout(state.timer); state.timer = null; }
291
+ if (due.length) await flush(due).catch(noop);
292
+ }
293
+
294
+ // ---- handles ----------------------------------------------------------------
295
+ function attach(conversationId, hooks) {
296
+ if (conversationId == null) return noop;
297
+ state.handles.set(conversationId, hooks);
298
+ return () => { if (state.handles.get(conversationId) === hooks) state.handles.delete(conversationId); };
299
+ }
300
+
301
+ function anyStale() {
302
+ for (const h of state.handles.values()) if (h.stale()) return true;
303
+ return false;
304
+ }
305
+
306
+ function persistence() {
307
+ if (!enabled()) return { enabled: false, conversations: [] };
308
+ const ids = [...(state.recent || [])];
309
+ for (const id of state.handles.keys()) if (!ids.includes(id)) ids.push(id);
310
+ const conversations = ids.map(id => {
311
+ const h = state.handles.get(id);
312
+ const s = state.saved.get(id);
313
+ return { id, messages: s ? s.messages : (h ? h.count() : null), savedAt: s ? s.savedAt : null, stale: h ? h.stale() : false };
314
+ });
315
+ return { enabled: enabled(), conversations };
316
+ }
317
+
318
+ window.ManifestChatPersist = {
319
+ WRITE_DEBOUNCE_MS,
320
+ configure, bootstrap, enabled, hydrate, opened, touch, attach, anyStale, persistence, flushPending, reset,
321
+ config: () => state.config,
322
+ state
323
+ };
324
+ })();
325
+
326
+
5
327
  /* Manifest Chat — store / engine
6
328
  /* By Andrew Matlock under MIT license
7
329
  /* https://manifestx.dev
@@ -83,6 +405,10 @@
83
405
 
84
406
  const _msgs = []; // plain source of truth
85
407
  const _byId = new Map(); // id -> plain msg
408
+ const _byExt = new Map(); // meta.externalId -> plain msg (secondary identity)
409
+ const P = () => window.ManifestChatPersist;
410
+ let landed = false; // first adapter load applied (later hydration is discarded)
411
+ let detach = null;
86
412
  const _participants = new Map(); // id -> participant
87
413
  const _typing = new Map(); // id -> participant
88
414
  let cursorOlder = null, cursorNewer = null, lastSeen = null, unsub = null;
@@ -90,13 +416,16 @@
90
416
  const state = A.reactive({
91
417
  messages: [], participants: [], me: null, typing: [],
92
418
  status: 'idle', live: false, atStart: false, atEnd: false,
93
- lastRehome: null, error: null
419
+ lastRehome: null, error: null, stale: false
94
420
  });
95
421
 
96
422
  function ordered() { return _msgs.slice().sort(byKey); }
97
423
  // Fresh per-message snapshots each commit so a keyed x-for re-renders
98
424
  // in-place mutations (streaming appends, status) that identity wouldn't trip.
99
- function commit() { state.messages = ordered().map(m => Object.assign({}, m, { body: Object.assign({}, m.body) })); rev().n++; }
425
+ function commit() {
426
+ state.messages = ordered().map(m => Object.assign({}, m, { body: Object.assign({}, m.body) })); rev().n++;
427
+ if (detach) P().touch(conversationId);
428
+ }
100
429
  function commitParticipants() { state.participants = [..._participants.values()]; rev().n++; }
101
430
  function commitTyping() { state.typing = [..._typing.values()]; rev().n++; }
102
431
 
@@ -113,20 +442,40 @@
113
442
  return _msgs.find(m => m._optimistic && m._clientId === cid) || null;
114
443
  }
115
444
 
445
+ // externalId is a platform id, unique only within a channel — scope it by meta.channel when present
446
+ const extOf = (m) => {
447
+ if (!m || !m.meta || m.meta.externalId == null) return null;
448
+ return m.meta.channel != null ? `${m.meta.channel}\u0000${m.meta.externalId}` : String(m.meta.externalId);
449
+ };
450
+ // a channel-scoped lookup falls back to a channel-less row with the same externalId
451
+ const extLookup = (m) => {
452
+ const e = extOf(m); if (e == null) return null;
453
+ return _byExt.get(e) || (m.meta.channel != null ? _byExt.get(String(m.meta.externalId)) : null) || null;
454
+ };
455
+ function indexExt(m) { const e = extOf(m); if (e != null) _byExt.set(e, m); }
456
+ function unindexExt(m) { const e = extOf(m); if (e != null && _byExt.get(e) === m) _byExt.delete(e); }
457
+
458
+ // identity: id first, then meta.externalId (a matching externalId re-keys the row)
116
459
  function upsert(raw) {
117
460
  const incoming = normalize(raw, ++seq);
118
- const prev = incoming.id != null ? _byId.get(incoming.id) : null;
461
+ let prev = incoming.id != null ? _byId.get(incoming.id) : null;
462
+ if (!prev) prev = extLookup(incoming);
119
463
  const pending = prev ? null : claimPending(incoming);
120
464
  if (prev) {
465
+ if (incoming.id != null && prev.id !== incoming.id) _byId.delete(prev.id);
121
466
  merge(prev, incoming);
467
+ if (prev.id != null) _byId.set(prev.id, prev);
468
+ indexExt(prev);
122
469
  } else if (pending) {
123
470
  _byId.delete(pending.id);
124
471
  merge(pending, incoming);
125
472
  pending._optimistic = false;
126
473
  if (pending.id != null) _byId.set(pending.id, pending);
474
+ indexExt(pending);
127
475
  } else {
128
476
  _msgs.push(incoming);
129
477
  if (incoming.id != null) _byId.set(incoming.id, incoming);
478
+ indexExt(incoming);
130
479
  seenAuthor(incoming.author);
131
480
  }
132
481
  lastSeen = incoming.id || lastSeen;
@@ -171,12 +520,62 @@
171
520
  try { ingestLoad(await adapter.load(conversationId, { after: since })); } catch (_) { }
172
521
  }
173
522
 
523
+ // ---- persisted window (ManifestChatPersist) -------------------------
524
+ // Hydration races the adapter load and lands only if first; the fresh
525
+ // set then reconciles by id / meta.externalId and clears `stale`.
526
+ function hydrateWindow(list) {
527
+ if (landed || !list.length) return;
528
+ for (const raw of list) {
529
+ const m = normalize(raw, ++seq);
530
+ if (m.id == null || _byId.has(m.id) || m._optimistic) continue;
531
+ m._hydrated = true;
532
+ _msgs.push(m); _byId.set(m.id, m); indexExt(m);
533
+ seenAuthor(m.author);
534
+ }
535
+ state.stale = true;
536
+ commit();
537
+ }
538
+
539
+ function reconcile(res) {
540
+ const ids = new Set(), exts = new Set();
541
+ for (const m of (res && res.messages) || []) { if (m.id != null) ids.add(m.id); const e = extOf(m); if (e != null) { exts.add(e); if (m.meta.channel != null) exts.add(String(m.meta.externalId)); } }
542
+ for (let i = _msgs.length - 1; i >= 0; i--) {
543
+ const m = _msgs[i];
544
+ if (!m._hydrated || ids.has(m.id) || (extOf(m) != null && exts.has(extOf(m)))) continue;
545
+ _msgs.splice(i, 1); _byId.delete(m.id); unindexExt(m);
546
+ }
547
+ ingestLoad(res);
548
+ for (const m of _msgs) delete m._hydrated;
549
+ state.stale = false;
550
+ commit();
551
+ }
552
+
553
+ function snapshot() { return state.stale ? null : _msgs.filter(m => !m._optimistic && m.id != null).sort(byKey); }
554
+
555
+ // Scope change / logout: drop the window, go idle (the app re-opens for the new scope)
556
+ function reset() {
557
+ try { unsub && unsub(); } catch (_) { }
558
+ unsub = null; landed = false;
559
+ _msgs.length = 0; _byId.clear(); _byExt.clear(); _participants.clear(); _typing.clear();
560
+ cursorOlder = null; cursorNewer = null; lastSeen = null;
561
+ state.stale = false; state.live = false; state.status = 'idle'; state.error = null; state.atStart = false; state.atEnd = false;
562
+ commit(); commitParticipants(); commitTyping();
563
+ }
564
+
565
+ if (P() && !isAggregate) detach = P().attach(conversationId, { snapshot, reset, stale: () => state.stale, count: () => _msgs.length });
566
+
174
567
  async function open() {
175
568
  state.status = 'loading';
569
+ if (detach) {
570
+ P().hydrate(conversationId).then(h => { if (h) hydrateWindow(h.messages); }).catch(() => { });
571
+ P().opened(conversationId);
572
+ }
176
573
  try {
177
574
  state.me = adapter.identity ? adapter.identity() : null;
178
575
  if (state.me) seenAuthor(state.me);
179
- ingestLoad(await adapter.load(conversationId, opts.around ? { around: opts.around } : undefined));
576
+ const res = await adapter.load(conversationId, opts.around ? { around: opts.around } : undefined);
577
+ landed = true;
578
+ if (state.stale) reconcile(res); else ingestLoad(res);
180
579
  if (adapter.subscribe) { unsub = adapter.subscribe(conversationId, handlers); state.live = true; }
181
580
  state.status = 'ready';
182
581
  // Settled tick: the seed load resolves amid the consumer's own
@@ -201,8 +600,10 @@
201
600
  if (echo && echo !== local) {
202
601
  merge(local, echo);
203
602
  const at = _msgs.indexOf(echo); if (at > -1) _msgs.splice(at, 1);
603
+ unindexExt(echo);
204
604
  }
205
605
  local.id = ack.id; local._optimistic = false;
606
+ indexExt(local);
206
607
  if (ack.ts != null) local.ts = ack.ts;
207
608
  if (local.status === 'pending') local.status = 'sent';
208
609
  if (ack.conversationId && ack.conversationId !== local.conversationId) {
@@ -231,6 +632,7 @@
231
632
  get typing() { void rev().n; return state.typing; },
232
633
  get version() { return rev().n; }, // guaranteed-trackable scalar; bumps on every commit
233
634
  get status() { return state.status; },
635
+ get stale() { return state.stale; }, // window is a persisted snapshot; the adapter has not landed yet
234
636
  get live() { return state.live; },
235
637
  get atStart() { return state.atStart; },
236
638
  get atEnd() { return state.atEnd; },
@@ -262,7 +664,7 @@
262
664
  loadReplies: (parentId) => adapter.loadReplies && adapter.loadReplies(conversationId, parentId),
263
665
  loadReactions: (id) => adapter.loadReactions && adapter.loadReactions(conversationId, id),
264
666
  clearRehome: () => { state.lastRehome = null; },
265
- close() { try { unsub && unsub(); } catch (_) { } }
667
+ close() { try { unsub && unsub(); } catch (_) { } if (detach) { detach(); detach = null; } }
266
668
  };
267
669
  open();
268
670
  return handle;
@@ -283,6 +685,7 @@
283
685
  get participants() { const seen = new Map(); for (const h of handles) for (const p of h.participants) seen.set(p.id, p); return [...seen.values()]; },
284
686
  get version() { return rev().n; },
285
687
  get status() { return handles.some(h => h.status === 'loading') ? 'loading' : (handles.every(h => h.status === 'ready') ? 'ready' : 'idle'); },
688
+ get stale() { return handles.some(h => h.stale); },
286
689
  get live() { return handles.some(h => h.live); },
287
690
  can: { send: handles.some(h => h.can.send) },
288
691
  send: (draft, target) => { const h = handles.find(x => x.conversationId === target) || handles[0]; return h.send(draft); },
@@ -780,7 +1183,7 @@
780
1183
  /*
781
1184
  /* Registers $chat. Renders nothing — the author drives their UI off the handle.
782
1185
  /* open(conversationId, { adapter, around?, aggregate? }) · merge(handles, { order })
783
- /* adapter(name, factory) · flatten(tree)
1186
+ /* adapter(name, factory) · flatten(tree) · stale · persistence()
784
1187
  */
785
1188
 
786
1189
  (function () {
@@ -800,6 +1203,8 @@
800
1203
  adapter(name, factory) { if (factory === undefined) return Adapters.resolve(name); Adapters.register(name, factory); },
801
1204
  flatten(tree) { return Store.flattenTree(tree); },
802
1205
  get version() { return Store.version; }, // shared revision — trackable even before any handle resolves
1206
+ get stale() { const P = window.ManifestChatPersist; return !!(P && P.anyStale()); }, // any open window still a persisted snapshot
1207
+ persistence() { const P = window.ManifestChatPersist; return P ? P.persistence() : { enabled: false, conversations: [] }; },
803
1208
  get sim() { return Adapters.sim; } // demo/sim hooks; harmless in prod (no callers)
804
1209
  };
805
1210
  }
@@ -817,6 +1222,7 @@
817
1222
  function ensureInitialized() {
818
1223
  if (!window.ManifestChatStore || !window.ManifestChatAdapters) return;
819
1224
  registerMagic();
1225
+ if (window.ManifestChatPersist) window.ManifestChatPersist.bootstrap();
820
1226
  }
821
1227
 
822
1228
  window.ensureManifestChatInitialized = ensureInitialized;
@@ -17,7 +17,7 @@ function install(data, key, fn) {
17
17
 
18
18
  const compute = () => {
19
19
  try {
20
- value = fn.call(data);
20
+ value = fn.call(data, data); // `this` and the first argument are both the scope
21
21
  failed = false;
22
22
  } catch (error) {
23
23
  // Keep the last good value; report once per failure streak
@@ -42,6 +42,16 @@ function install(data, key, fn) {
42
42
 
43
43
  function initializeComputedPlugin() {
44
44
  Alpine.magic('computed', () => computed);
45
+
46
+ // x-computed:name="expression" — a cached value on the nearest scope, recomputed once per change
47
+ Alpine.directive('computed', (el, { value, expression }, { effect, evaluateLater }) => {
48
+ if (!value) { console.warn('[Manifest Computed] x-computed needs a name: x-computed:total="a + b"'); return; }
49
+ const evaluate = evaluateLater(expression);
50
+ // Write to the NEAREST data object, not through the merged proxy (which puts new keys on the root scope)
51
+ const stack = Alpine.closestDataStack(el);
52
+ const scope = stack[0] || Alpine.$data(el);
53
+ effect(() => { evaluate((next) => { scope[value] = next; }); }); // a failed evaluation keeps the last value
54
+ }).before('init'); // so x-init / x-show on the same element can read the value
45
55
  }
46
56
 
47
57
  window.$computed = computed;
package/lib/manifest.d.ts CHANGED
@@ -412,7 +412,7 @@ export interface ManifestTextEdit {
412
412
  export type ManifestDirective =
413
413
  | 'x-route' | 'x-anchors'
414
414
  | 'x-icon' | 'x-svg' | 'x-markdown' | 'x-code' | 'x-code-group'
415
- | 'x-toast' | 'x-tooltip' | 'x-carousel' | 'x-virtual' | 'x-resize' | 'x-defer'
415
+ | 'x-toast' | 'x-tooltip' | 'x-carousel' | 'x-virtual' | 'x-resize' | 'x-computed' | 'x-defer'
416
416
  | 'x-chart' | 'x-date' | 'x-color' | 'x-colorpicker' | 'x-combobox'
417
417
  | 'x-export' | 'x-pay' | 'x-files' | 'x-data-files'
418
418
  | 'x-edit' | 'x-text-edit';
@@ -492,6 +492,10 @@ declare global {
492
492
  getPluginUrl(name: string, version?: string): string;
493
493
  /** Defer a closed container built at runtime (call before Alpine.initTree). */
494
494
  defer?(el: Element): unknown;
495
+ /** App-shell service worker diagnostics; kill() unregisters and clears its caches. */
496
+ sw?: { registered: boolean; version: string | null; kill(): Promise<void> };
497
+ /** The two-line same-origin /sw.js stub pinned to a framework version. */
498
+ swStub?(version?: string): string;
495
499
  };
496
500
  Alpine?: unknown;
497
501
  __manifestLoaded?: unknown;