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.
- 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 +142 -8
- 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.chat.js
CHANGED
|
@@ -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() {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/manifest.computed.js
CHANGED
|
@@ -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;
|