@volter/teams 0.1.33 → 0.1.35
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/package.json +1 -1
- package/server/core.mjs +8 -3
- package/server/migrations/009.mjs +8 -0
- package/server/migrations/index.mjs +6 -1
- package/viewer/viewer.mjs +23 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
package/server/core.mjs
CHANGED
|
@@ -64,8 +64,8 @@ export async function maintain(api){const now=api.clock.nowMs();
|
|
|
64
64
|
// they go (a backlog of them filled the database).
|
|
65
65
|
const parts=await api.store.query(`SELECT p.team_id,p.capture_id,p.part_index FROM capture_parts p JOIN captures c ON c.team_id=p.team_id AND c.id=p.capture_id WHERE c.state='committed' LIMIT ${PART_PRUNE_BATCH}`,[]);
|
|
66
66
|
for(let at=0;at<parts.length;at+=50)await api.store.transaction(parts.slice(at,at+50).map(({team_id,capture_id,part_index})=>({sql:'DELETE FROM capture_parts WHERE team_id=? AND capture_id=? AND part_index=?',params:[team_id,capture_id,part_index]})));
|
|
67
|
-
// Every capture writes its session a new version
|
|
68
|
-
// per session) until listing them outgrew a Worker's memory. Kept: each session's current version, the newest
|
|
67
|
+
// Every capture writes its session a new version (its messages are carried, not copied: schema 9), so versions pile
|
|
68
|
+
// up (hundreds per session) until listing them outgrew a Worker's memory. Kept: each session's current version, the newest
|
|
69
69
|
// version from each of its sources (the next capture and a correlation read that one), and every version an
|
|
70
70
|
// unexpired list, search or history cursor names. The rest go, a bounded batch per run.
|
|
71
71
|
const stale=await api.store.query(`SELECT v.team_id,v.session_id,v.revision FROM session_versions v JOIN sessions s ON s.team_id=v.team_id AND s.id=v.session_id
|
|
@@ -74,7 +74,12 @@ export async function maintain(api){const now=api.clock.nowMs();
|
|
|
74
74
|
AND NOT EXISTS (SELECT 1 FROM cursors c, json_each(c.snapshot_json) j WHERE c.team_id=v.team_id AND c.kind IN ('list','search') AND c.expires_at>? AND json_extract(j.value,'$[0]')=v.session_id AND json_extract(j.value,'$[1]')=v.revision)
|
|
75
75
|
AND NOT EXISTS (SELECT 1 FROM cursors c WHERE c.team_id=v.team_id AND c.kind='history' AND c.expires_at>? AND json_extract(c.snapshot_json,'$.session_id')=v.session_id AND json_extract(c.snapshot_json,'$.revision')=v.revision)
|
|
76
76
|
LIMIT ${VERSION_PRUNE_BATCH}`,[now,now]);
|
|
77
|
-
|
|
77
|
+
// A message row lives from the version that wrote it until one that changed it (schema 9), so a row a later version
|
|
78
|
+
// still holds moves to the next version that holds it before its own goes; the rest go with the version.
|
|
79
|
+
for(const {team_id,session_id,revision} of stale)await api.store.transaction([
|
|
80
|
+
{sql:'UPDATE messages SET revision=(SELECT MIN(k.revision) FROM session_versions k WHERE k.team_id=messages.team_id AND k.session_id=messages.session_id AND k.revision>messages.revision AND k.revision<COALESCE(messages.until_revision,messages.revision+1)) WHERE team_id=? AND session_id=? AND revision=? AND EXISTS (SELECT 1 FROM session_versions k WHERE k.team_id=messages.team_id AND k.session_id=messages.session_id AND k.revision>messages.revision AND k.revision<COALESCE(messages.until_revision,messages.revision+1))',params:[team_id,session_id,revision]},
|
|
81
|
+
...['messages','session_artifacts','session_versions'].map(table=>({sql:`DELETE FROM ${table} WHERE team_id=? AND session_id=? AND revision=?`,params:[team_id,session_id,revision]})),
|
|
82
|
+
]);
|
|
78
83
|
await api.store.transaction([
|
|
79
84
|
{sql:'DELETE FROM cursors WHERE expires_at<=?',params:[now]},
|
|
80
85
|
{sql:'DELETE FROM global_cursors WHERE expires_at<=?',params:[now]},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Schema migration 009 (SQLite DDL), applied in order by store-core migrate().
|
|
2
|
+
export default `-- Schema 9: a message row is written once and read by every version it belongs to. A row is part of each version
|
|
3
|
+
-- of its session from \`revision\` up to, not including, \`until_revision\`: open (OPEN_REVISION) while the session's
|
|
4
|
+
-- current version still holds it, closed at the revision of the version that changed or dropped it. A row without one
|
|
5
|
+
-- (every row written before schema 9) belongs to its own revision only. A capture writes only the messages that
|
|
6
|
+
-- changed; a version that changes no message writes none.
|
|
7
|
+
ALTER TABLE messages ADD COLUMN until_revision INTEGER;
|
|
8
|
+
`;
|
|
@@ -7,4 +7,9 @@ import m005 from './005.mjs';
|
|
|
7
7
|
import m006 from './006.mjs';
|
|
8
8
|
import m007 from './007.mjs';
|
|
9
9
|
import m008 from './008.mjs';
|
|
10
|
-
|
|
10
|
+
import m009 from './009.mjs';
|
|
11
|
+
export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
|
12
|
+
/** A message row's `until_revision` while its session's current version still holds it (schema 9). */
|
|
13
|
+
export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
|
|
14
|
+
/** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
|
|
15
|
+
export const MESSAGE_AT = (m = 'm') => `${m}.revision<=? AND ?<COALESCE(${m}.until_revision,${m}.revision+1)`;
|
package/viewer/viewer.mjs
CHANGED
|
@@ -17,6 +17,20 @@ let waiting = null;
|
|
|
17
17
|
|
|
18
18
|
const embedded = window.parent !== window;
|
|
19
19
|
const ask = (type) => { if (embedded) window.parent.postMessage({ type }, '*'); };
|
|
20
|
+
// Where a failure is reported: the session this page shows, once it knows it (the server logs it under the token's member).
|
|
21
|
+
let reportTo = null;
|
|
22
|
+
/**
|
|
23
|
+
* A failure this page cannot recover from by itself (a read, the follow, the page's own code): shown as a plain line,
|
|
24
|
+
* posted to the host as `viewer-error` { stage, status, message }, and sent to the server's log.
|
|
25
|
+
*/
|
|
26
|
+
function failed(stage, error, statusCode = null) {
|
|
27
|
+
const message = String(error?.message ?? error ?? 'unknown error').slice(0, 400);
|
|
28
|
+
status(message);
|
|
29
|
+
if (embedded) window.parent.postMessage({ type: 'viewer-error', stage, status: statusCode, message }, '*');
|
|
30
|
+
if (reportTo && token) fetch(reportTo, { method: 'POST', keepalive: true, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ stage, status: statusCode, message }) }).catch(() => {});
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener('error', (event) => failed('page', event.error ?? event.message));
|
|
33
|
+
window.addEventListener('unhandledrejection', (event) => failed('page', event.reason));
|
|
20
34
|
// The host's style, as supercode-ui is themed: its `--volter-*` brand tokens and `--scui-*` component tokens, set on
|
|
21
35
|
// the session's root (every component reads them from an ancestor). Nothing else is taken from the host.
|
|
22
36
|
function applyTheme(properties) {
|
|
@@ -120,8 +134,9 @@ async function showSession(session, control) {
|
|
|
120
134
|
stylesheet.rel = 'stylesheet';
|
|
121
135
|
stylesheet.href = '/view/session-view.css';
|
|
122
136
|
document.head.append(stylesheet);
|
|
123
|
-
const { mountSessionView } = await import('/view/session-view.mjs');
|
|
124
137
|
const base = `${location.origin}/api/v1/teams/${encodeURIComponent(session.team)}/sessions/${encodeURIComponent(session.id)}`;
|
|
138
|
+
reportTo = `${base}/viewer-errors`;
|
|
139
|
+
const { mountSessionView } = await import('/view/session-view.mjs').catch((error) => { throw Object.assign(error, { stage: 'load' }); });
|
|
125
140
|
const element = $('session');
|
|
126
141
|
element.hidden = false;
|
|
127
142
|
const view = mountSessionView(element, {
|
|
@@ -159,7 +174,7 @@ async function showSession(session, control) {
|
|
|
159
174
|
const read = () => (reading ??= (async () => {
|
|
160
175
|
try {
|
|
161
176
|
const response = await fetch(`${base}/messages?tail=300`, { headers: await headers() });
|
|
162
|
-
if (!response.ok) throw new Error(`reading the session failed (${response.status})`);
|
|
177
|
+
if (!response.ok) throw Object.assign(new Error(`reading the session failed (${response.status})`), { stage: 'read', status: response.status });
|
|
163
178
|
const body = await response.json();
|
|
164
179
|
apply(body);
|
|
165
180
|
return body;
|
|
@@ -173,7 +188,7 @@ async function showSession(session, control) {
|
|
|
173
188
|
try {
|
|
174
189
|
if (!cursor) cursor = String((await read()).event_cursor ?? '') || null;
|
|
175
190
|
const response = await fetch(`${base}/events?${cursor ? `after=${encodeURIComponent(cursor)}` : 'tail=1'}`, { headers: await headers() });
|
|
176
|
-
if (!response.ok) throw new Error(`following the session failed (${response.status})`);
|
|
191
|
+
if (!response.ok) throw Object.assign(new Error(`following the session failed (${response.status})`), { stage: 'follow', status: response.status });
|
|
177
192
|
view.update({ live: true });
|
|
178
193
|
status('');
|
|
179
194
|
const reader = response.body.getReader();
|
|
@@ -193,12 +208,14 @@ async function showSession(session, control) {
|
|
|
193
208
|
// The server ends a stream it can no longer resume (a slow reader, a retired cursor): start from a snapshot.
|
|
194
209
|
if (event.terminal && event.type === 'resync_required') cursor = null;
|
|
195
210
|
if (event.type === 'snapshot') apply(event.data);
|
|
196
|
-
else if (event.type === 'session_changed') read().catch((error) =>
|
|
211
|
+
else if (event.type === 'session_changed') read().catch((error) => failed('read', error, error.status ?? null));
|
|
197
212
|
else if (event.type === 'session_deleted') { view.update({ live: false, status: 'error', error: 'This session was deleted.' }); return; }
|
|
198
213
|
}
|
|
199
214
|
}
|
|
200
215
|
} catch (error) {
|
|
201
|
-
|
|
216
|
+
failed(error.stage ?? 'follow', error, error.status ?? null);
|
|
217
|
+
// Nothing drawn yet: the transcript says why instead of loading forever.
|
|
218
|
+
if (!cursor) view.update({ status: 'error', error: error.message });
|
|
202
219
|
}
|
|
203
220
|
view.update({ live: false });
|
|
204
221
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
@@ -320,4 +337,4 @@ async function showFolder(connect, root, control) {
|
|
|
320
337
|
await list(base);
|
|
321
338
|
}
|
|
322
339
|
|
|
323
|
-
main().catch((error) =>
|
|
340
|
+
main().catch((error) => failed(error?.stage ?? 'start', error, error?.status ?? null));
|