@volter/teams 0.2.55 → 0.2.57

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.55",
3
+ "version": "0.2.57",
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
@@ -1,5 +1,6 @@
1
1
  import { deliverAppWebhooks } from './apps.mjs';
2
2
  import { reconcileMachinePresence, LINK_GRACE_MS } from './machine-events.mjs';
3
+ import { releaseRevokedSources } from './session-release.mjs';
3
4
  // The Teams server, independent of its host. `createTeamsApi` builds the API
4
5
  // object every route and door reads; a host attaches a store (`api.attach`),
5
6
  // bootstraps the server identity once, serves requests through http.mjs's
@@ -233,6 +234,12 @@ export async function maintain(api,{budgetMs=MAINTAIN_BUDGET_MS}={}){const now=a
233
234
  await step('webhooks',async()=>{if(await deliverAppWebhooks(api))more=true;});
234
235
  // Last, with what the budget leaves: the walks of rows written before migration 023 into its tables.
235
236
  await step('backfill',async()=>{if(!await backfill(api,{now,left}))more=true;});
237
+ // A team that says so has what its revoked sources left released (session-release.mjs); no team does by default.
238
+ // Last: it takes what the run's budget has left, after everything the server itself needs.
239
+ await step('revoked sources',async()=>{
240
+ if(!await walked(api,['version_sources'])){more=true;return;}
241
+ if(await releaseRevokedSources(api,{now,left,count}))more=true;
242
+ });
236
243
  const result={counts,failed,backlog:more};
237
244
  if(Object.keys(counts).length)console.log(`maintain: removed ${Object.entries(counts).map(([name,n])=>`${n} ${name}`).join(', ')}${more?' (backlog)':''}`);
238
245
  return result;}
package/server/http.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appRoute, appPreAuth, appAuthentication, enforceAppScope, appAuthGuards, installedIssuer, identityIssuers } from './apps.mjs';
2
2
  import { machineEventStream } from './machine-events.mjs';
3
+ import { releasePreview, releaseSetting, RELEASE_GRACE_DAYS } from './session-release.mjs';
3
4
  import { publishedEventRoute, OWN_SOURCE, sourceMachine } from './published-events.mjs';
4
5
  import { identityRecordRoute, identityAuthority } from './identity-record.mjs';
5
6
  import { AGENT_ACTIVE } from './agent-authority.mjs';
@@ -102,7 +103,7 @@ export function createHttpHandler(api){return async(req,res)=>{const requestId=a
102
103
  if(req.method==='POST'&&sub==='/invitations')return await createInvitation(api,req,res,auth,requestId);
103
104
  if(req.method==='POST'&&sub==='/credentials')return await createCredential(api,req,res,auth,requestId);
104
105
  if(req.method==='DELETE'&&(m=/^\/credentials\/([^/]+)$/.exec(sub)))return await revokeCredential(api,req,res,auth,m[1],requestId);
105
- if((req.method==='GET'||req.method==='PATCH')&&sub==='/settings')return await teamSettings(api,req,res,auth,requestId);
106
+ if((req.method==='GET'||req.method==='PATCH')&&sub==='/settings')return await teamSettings(api,req,res,auth,url,requestId);
106
107
  if(req.method==='GET'&&sub==='/members')return await listMembers(api,res,auth,url,requestId);
107
108
  if(req.method==='PATCH'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await updateMember(api,req,res,auth,m[1],requestId);
108
109
  if(req.method==='DELETE'&&(m=/^\/members\/([^/]+)$/.exec(sub)))return await removeMember(api,req,res,auth,m[1],requestId);
@@ -335,13 +336,24 @@ async function collectionPage(api,auth,url,name,items){
335
336
  }
336
337
  async function listMembers(api,res,auth,url,requestId){const rows=auth.role==='viewer'?await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.principal_id=? AND m.active=1',[auth.team_id,auth.principal_id]):await api.store.query('SELECT m.*,p.kind,p.display_name FROM memberships m JOIN principals p ON p.id=m.principal_id WHERE m.team_id=? AND m.active=1 ORDER BY p.display_name,m.principal_id',[auth.team_id]);send(res,200,await collectionPage(api,auth,url,'members',rows.map(membershipJson)),requestId);}
337
338
  // The team's own settings. native_capture: whether machines upload each session's native transcript (see migration 025).
338
- async function teamSettings(api,req,res,auth,requestId){
339
- const read=async()=>{const t=await api.store.one('SELECT native_capture FROM teams WHERE id=?',[auth.team_id]);return{native_capture:t.native_capture===1};};
340
- if(req.method==='GET')return send(res,200,result(api,auth,await read()),requestId);
339
+ // release_revoked_sources: whether retention releases what revoked sources left (migration 028), with the moment it
340
+ // was turned on (its cutoff) and the grace. An administrator's `?release_revoked_sources=preview` says what turning it
341
+ // on now would release, or what the standing cutoff releases, source by source.
342
+ async function teamSettings(api,req,res,auth,url,requestId){
343
+ const read=async()=>{const t=await api.store.one('SELECT native_capture,release_revoked_before FROM teams WHERE id=?',[auth.team_id]);return{native_capture:t.native_capture===1,release_revoked_sources:t.release_revoked_before!=null,release_revoked_sources_since:t.release_revoked_before,release_revoked_sources_grace_days:RELEASE_GRACE_DAYS};};
344
+ if(req.method==='GET'){
345
+ const settings=await read(),preview=url.searchParams.get('release_revoked_sources');
346
+ if(preview===null)return send(res,200,result(api,auth,settings),requestId);
347
+ if(preview!=='preview')fail(400,'invalid_request','release_revoked_sources reads only as preview');
348
+ if(!isAdmin(auth))fail(403,'forbidden','forbidden');
349
+ return send(res,200,result(api,auth,{...settings,release_preview:await releasePreview(api,auth.team_id,settings.release_revoked_sources_since??api.clock.nowMs())}),requestId);
350
+ }
341
351
  if(!isAdmin(auth))fail(403,'forbidden','forbidden');
342
- const body=await parseJson(req);exact(body,['native_capture']);if(typeof body.native_capture!=='boolean')fail(400,'invalid_request','native_capture must be a boolean');
343
- const now=api.clock.nowMs(),response=result(api,auth,{native_capture:body.native_capture});
344
- await guardedMutation(api,res,auth,authGuards(auth,now),[{sql:'UPDATE teams SET native_capture=? WHERE id=?',params:[body.native_capture?1:0,auth.team_id]}],{status:200,body:response,targetId:auth.team_id});
352
+ const body=await parseJson(req);exact(body,['native_capture','release_revoked_sources']);const set=['native_capture','release_revoked_sources'].filter(name=>body[name]!==undefined);if(!set.length)fail(400,'invalid_request','name a setting');for(const name of set)if(typeof body[name]!=='boolean')fail(400,'invalid_request',`${name} must be a boolean`);
353
+ const now=api.clock.nowMs(),response=result(api,auth,{...Object.fromEntries(set.map(name=>[name,body[name]])),...(body.release_revoked_sources!==undefined?{release_revoked_sources_since:body.release_revoked_sources?now:null}:{})});
354
+ // the record of the change says which setting and to what
355
+ res._auditRecord.operation+=` ${set.map(name=>`${name}=${body[name]}`).join(' ')}`;
356
+ await guardedMutation(api,res,auth,authGuards(auth,now),set.flatMap(name=>name==='release_revoked_sources'?releaseSetting(auth.team_id,body[name],now):[{sql:'UPDATE teams SET native_capture=? WHERE id=?',params:[body[name]?1:0,auth.team_id]}]),{status:200,body:response,targetId:auth.team_id});
345
357
  send(res,200,response,requestId);
346
358
  }
347
359
  async function updateMember(api,req,res,auth,principalId,requestId){await requireUnlinked(api,auth.team_id);
@@ -0,0 +1,15 @@
1
+ // A team may have retention release what its revoked sources left: the sessions only they wrote, and those sources'
2
+ // message ids and capture records (session-release.mjs). `release_revoked_before` is the moment the team turned it on,
3
+ // its cutoff; NULL, the default, is off: a retired machine's sessions are often the only copy of their transcripts, and
4
+ // nothing of them is deleted until the team says so. A source records when it was revoked, and forgets it when it is
5
+ // made active again, by the write that changes its state. Constant cost: two columns and two triggers, no row written.
6
+ export default `
7
+ ALTER TABLE teams ADD COLUMN release_revoked_before INTEGER;
8
+ ALTER TABLE sources ADD COLUMN revoked_at INTEGER;
9
+ CREATE TRIGGER source_revoked AFTER UPDATE OF state ON sources WHEN NEW.state='revoked' AND OLD.state<>'revoked' BEGIN
10
+ UPDATE sources SET revoked_at=CAST(strftime('%s','now') AS INTEGER)*1000 WHERE team_id=NEW.team_id AND id=NEW.id;
11
+ END;
12
+ CREATE TRIGGER source_unrevoked AFTER UPDATE OF state ON sources WHEN NEW.state<>'revoked' AND OLD.state='revoked' BEGIN
13
+ UPDATE sources SET revoked_at=NULL WHERE team_id=NEW.team_id AND id=NEW.id;
14
+ END;
15
+ `;
@@ -26,7 +26,8 @@ import m024 from './024.mjs';
26
26
  import m025 from './025.mjs';
27
27
  import m026 from './026.mjs';
28
28
  import m027 from './027.mjs';
29
- export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018, m019, m020, m021, m022, m023, m024, m025, m026, m027];
29
+ import m028 from './028.mjs';
30
+ export const MIGRATIONS = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018, m019, m020, m021, m022, m023, m024, m025, m026, m027, m028];
30
31
  /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
31
32
  export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
32
33
  /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */
@@ -12,6 +12,16 @@ const stmt = (sql, ...params) => ({sql, params});
12
12
  const batches = (list, size = 90) => Array.from({length: Math.ceil(list.length / size)}, (_, at) => list.slice(at * size, (at + 1) * size));
13
13
  const holes = list => list.map(() => '?').join(',');
14
14
  const EVENT_REPLAY_BATCH = 32;
15
+ // A publication is written about four times: the event, its resource's snapshot, its idempotent answer, and a
16
+ // webhook delivery. Past the host's maximum a write does not fail, it resets the server under every machine's link:
17
+ // an event that would not fit under the limit beside a mebibyte kept for retention is refused, and writes nothing.
18
+ const EVENT_COPIES = 4, EVENT_RESERVE = 1024 * 1024;
19
+ async function eventHeadroomSpent(api, res, bytes) {
20
+ const usage = await api.storageUsage?.();
21
+ if (!usage || usage.used + bytes * EVENT_COPIES + EVENT_RESERVE < usage.limit) return false;
22
+ res._auditRecord = null; res._idempotencyRecord = null;
23
+ return true;
24
+ }
15
25
  const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
16
26
  const text = (value,label) => {if(typeof value!=='string'||!value||value.length>256||/[\x00-\x1f]/.test(value))fail(400,'invalid_request',`${label} must be a nonempty string of at most 256 characters`);return value;};
17
27
  const eventOf = row => ({id:row.id,org:row.team_id,at:row.created_at,actor:JSON.parse(row.actor_json),kind:row.kind,resource:{kind:row.resource_kind,id:row.resource_id},data:JSON.parse(row.data_json)});
@@ -93,8 +103,10 @@ export async function publishedEventRoute(ctx) {
93
103
  if(body.actor!=null){exact(body.actor,['session']);if(body.actor.session!=null){text(body.actor.session,'actor.session');if(!body.actor.session.startsWith('session:'))fail(400,'invalid_request','actor.session must name a session');actor.session=body.actor.session;}}
94
104
  const source=await api.store.one(OWN_SOURCE,[auth.team_id,publication[1],auth.principal_id,sourceMachine(auth)]);
95
105
  if(!source)fail(404,'not_found','event source not found');
106
+ const spent=await eventHeadroomSpent(api,res,Buffer.byteLength(JSON.stringify(body.data)));
96
107
  const old=await api.store.one('SELECT * FROM org_events WHERE id=?',[body.id]);
97
108
  if(old){if(old.team_id!==auth.team_id||old.source_id!==publication[1]||old.kind!==body.kind||old.resource_id!==body.resource.id||old.source_revision!==body.revision||canonicalJson(JSON.parse(old.data_json))!==canonicalJson(body.data)||canonicalJson(JSON.parse(old.actor_json))!==canonicalJson(actor))fail(409,'idempotency_conflict','event id already names another event');send(res,200,result(api,auth,eventOf(old)),requestId);return true;}
109
+ if(spent){res.setHeader('Retry-After','60');throw Object.assign(new Error('event deferred while Teams storage is full'),{status:507,code:'storage_full',retryable:true});}
98
110
  const now=api.clock.nowMs(),guards=[...authorityGuards(auth,now),{sql:OWN_SOURCE,params:[auth.team_id,publication[1],auth.principal_id,sourceMachine(auth)],status:403,code:'forbidden',message:'event source authority changed'},
99
111
  {sql:'SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM event_resources WHERE team_id=? AND source_id=? AND resource_kind=? AND resource_id=? AND source_revision>=?)',params:[auth.team_id,publication[1],body.resource.kind,body.resource.id,body.revision],status:409,code:'revision_mismatch',message:'resource publication is older than its current snapshot'}];
100
112
  const event={id:body.id,org:auth.team_id,at:now,actor,kind:body.kind,resource:body.resource,data:body.data},response=result(api,auth,event);
@@ -0,0 +1,145 @@
1
+ // Releasing what a revoked source left: a team that turns `release_revoked_sources` on (migration 028) has every
2
+ // session only such sources wrote deleted, as a person's delete of it would, and then what a delete leaves behind for
3
+ // that source removed too (its message ids and capture records). Off, nothing here runs: a retired machine's sessions
4
+ // are often the only copy of their transcripts, so only the team's own word releases them.
5
+ //
6
+ // Which sources: those revoked at least the grace before the moment the setting was turned on (its cutoff). Revoking
7
+ // is how a machine is disconnected to be linked again, and connecting it again makes the same source active: a source
8
+ // revoked after the turning-on, or within the grace before it, is not released by it (that takes turning it on
9
+ // again), and a source made active again stops being released at once. The grace is a week: a machine taken off to be
10
+ // re-linked or rebuilt is back within days, and a week is what the server keeps of everything else it ages out.
11
+
12
+ export const RELEASE_GRACE_DAYS = 7;
13
+ const GRACE_MS = RELEASE_GRACE_DAYS * 86_400_000;
14
+ /** Origin rows a run examines; rows it deletes; rows one delete removes, of messages and message ids, and of captures
15
+ * (each cascades to its parts). */
16
+ const ORIGIN_BUDGET = 1000, ORIGIN_WALK = 200, ROW_BUDGET = 4000, ROW_CHUNK = 500, CAPTURE_CHUNK = 50;
17
+ const WALK = 'release_revoked_sources';
18
+
19
+ // When source `s` was revoked: its own record, or its machine's for a source revoked before sources recorded it.
20
+ const REVOKED_AT = 'COALESCE(s.revoked_at,(SELECT m.revoked_at FROM machines m WHERE m.team_id=s.team_id AND m.id=s.machine_id))';
21
+ /** SQL and parameters: source `s` is released under `cutoff`. */
22
+ const eligible = cutoff => ({ sql: `(s.state='revoked' AND ${REVOKED_AT}<=?)`, params: [cutoff - GRACE_MS] });
23
+ /** SQL and parameters: why session `se` is left whole under `cutoff` (`merged`: it is, or is named by, a correlation;
24
+ * `shared`: a version of it came from a source that is not released), or NULL when it is released. */
25
+ const kept = (team, cutoff) => {
26
+ const source = eligible(cutoff);
27
+ return { sql: `CASE WHEN se.canonical_id IS NOT NULL OR se.id IN (SELECT left_session_id FROM correlations WHERE team_id=? UNION SELECT right_session_id FROM correlations WHERE team_id=? UNION SELECT canonical_id FROM correlations WHERE team_id=?) THEN 'merged'
28
+ WHEN EXISTS (SELECT 1 FROM version_sources v WHERE v.team_id=se.team_id AND v.session_id=se.id AND v.source_id<>'' AND NOT EXISTS (SELECT 1 FROM sources s WHERE s.team_id=v.team_id AND s.id=v.source_id AND ${source.sql})) THEN 'shared' END`,
29
+ params: [team, team, team, ...source.params] };
30
+ };
31
+
32
+ /**
33
+ * What turning the setting on at `cutoff` releases, by revoked source: whether the source is released, and of its
34
+ * sessions how many are released, how many are left whole and why, and the capture records that go with the released.
35
+ * The same tests the release applies. It reads each revoked source's sessions through the origins' key, each session's
36
+ * row and its retained versions' sources, and counts captures through their key; it reads no message.
37
+ */
38
+ export async function releasePreview(api, teamId, cutoff) {
39
+ const source = eligible(cutoff), whole = kept(teamId, cutoff);
40
+ const rows = await api.store.query(`SELECT id,name,revoked_at,eligible,COUNT(session_id) sessions,COALESCE(SUM(kept='merged'),0) merged,COALESCE(SUM(kept='shared'),0) shared,
41
+ COALESCE(SUM(CASE WHEN session_id IS NOT NULL AND eligible AND kept IS NULL THEN (SELECT COUNT(*) FROM captures c WHERE c.team_id=h.team_id AND c.source_id=h.id AND c.harness=h.harness AND c.native_id=h.native_id) END),0) captures
42
+ FROM (SELECT s.team_id,s.id,s.name,${REVOKED_AT} revoked_at,${source.sql} eligible,o.harness,o.native_id,se.id session_id,
43
+ CASE WHEN se.id IS NOT NULL AND ${source.sql} THEN ${whole.sql} END kept
44
+ FROM sources s LEFT JOIN origins o ON o.team_id=s.team_id AND o.source_id=s.id AND o.deleted_at IS NULL
45
+ LEFT JOIN sessions se ON se.team_id=o.team_id AND se.id=o.session_id AND se.deleted_at IS NULL
46
+ WHERE s.team_id=? AND s.state='revoked') h GROUP BY id ORDER BY name,id`, [...source.params, ...source.params, ...whole.params, teamId]);
47
+ const sources = rows.map(row => ({ id: row.id, name: row.name, revoked_at: row.revoked_at, released: row.eligible === 1,
48
+ ...(row.eligible === 1 ? {} : { not_released: row.revoked_at == null ? 'no revocation time is recorded' : `revoked less than ${RELEASE_GRACE_DAYS} days before the cutoff` }),
49
+ sessions: { released: row.eligible === 1 ? row.sessions - row.merged - row.shared : 0, left_whole: row.eligible === 1 ? { merged: row.merged, shared: row.shared } : { source_not_released: row.sessions } },
50
+ captures_released: row.captures }));
51
+ return { cutoff, grace_days: RELEASE_GRACE_DAYS, sources,
52
+ totals: { sources_released: sources.filter(item => item.released).length, sessions_released: sources.reduce((sum, item) => sum + item.sessions.released, 0), captures_released: sources.reduce((sum, item) => sum + item.captures_released, 0) } };
53
+ }
54
+
55
+ /** The statements that turn a team's setting on (its cutoff is now) or off; either starts the walk over. */
56
+ export const releaseSetting = (teamId, on, now) => [
57
+ { sql: 'UPDATE teams SET release_revoked_before=? WHERE id=?', params: [on ? now : null, teamId] },
58
+ { sql: 'DELETE FROM maintain_backfill WHERE name=?', params: [WALK] },
59
+ ];
60
+
61
+ /**
62
+ * One run of the release, inside `maintain`'s budget (`left`). It walks the origins once by rowid, its place kept in
63
+ * `maintain_backfill` so a restart resumes there; a change of any team's setting starts it over. An origin is passed
64
+ * when nothing of it is left to do. A session is whole or deleted, never between: the transaction that deletes it
65
+ * tests, as it writes, that its source is still released and the session still stands alone, and only a deleted
66
+ * session's rows are removed, in bounded deletes found through a key and safe to repeat. Answers whether more is left.
67
+ */
68
+ export async function releaseRevokedSources(api, { now, left, count }) {
69
+ const one = (sql, params = []) => api.store.one(sql, params);
70
+ const cutoffs = new Map((await api.store.query('SELECT id,release_revoked_before cutoff FROM teams WHERE release_revoked_before IS NOT NULL')).map(row => [row.id, row.cutoff]));
71
+ if (!cutoffs.size) return false;
72
+ const walk = await one('SELECT after_rowid,done FROM maintain_backfill WHERE name=?', [WALK]);
73
+ if (walk?.done) return false;
74
+ const run = { now, one, count, cutoffs, released: new Map(), rows: ROW_BUDGET, left: () => run.rows > 0 && left(), deleted: false };
75
+ let after = walk?.after_rowid ?? 0, origins = ORIGIN_BUDGET, finished = false, stopped = false;
76
+ while (!stopped) {
77
+ const rows = await api.store.query(`SELECT rowid id,team_id,source_id,harness,native_id,session_id,deleted_at FROM origins WHERE rowid>? ORDER BY rowid LIMIT ${ORIGIN_WALK}`, [after]);
78
+ if (!rows.length) { finished = true; break; }
79
+ for (const origin of rows) {
80
+ if (origins-- <= 0 || !run.left()) { stopped = true; break; }
81
+ if (cutoffs.has(origin.team_id) && !await releaseOrigin(api, origin, run)) { stopped = true; break; }
82
+ after = origin.id;
83
+ }
84
+ }
85
+ if (run.deleted) await api.scheduleCapturePrefixWork?.();
86
+ await api.store.transaction([{ sql: 'INSERT INTO maintain_backfill(name,after_rowid,done) VALUES (?,?,?) ON CONFLICT(name) DO UPDATE SET after_rowid=excluded.after_rowid,done=excluded.done', params: [WALK, after, finished ? 1 : 0] }]);
87
+ return !finished;
88
+ }
89
+
90
+ /** Whether all of one origin is done (released, or left as it is); false when the run's budget ended first. */
91
+ async function releaseOrigin(api, origin, run) {
92
+ const { team_id: team, source_id: source, harness, native_id: native, session_id: session } = origin, { one, now } = run;
93
+ const cutoff = run.cutoffs.get(team), released = `${team}\0${source}`;
94
+ if (!run.released.has(released)) { const test = eligible(cutoff); run.released.set(released, Boolean(await one(`SELECT 1 ok FROM sources s WHERE s.team_id=? AND s.id=? AND ${test.sql}`, [team, source, ...test.params]))); }
95
+ const sourceReleased = run.released.get(released);
96
+ // An origin still standing matters only under a released source; a deleted one may be a deletion of ours to finish.
97
+ if (origin.deleted_at == null && !sourceReleased) return true;
98
+ let deleted = (await one('SELECT deleted_at FROM sessions WHERE team_id=? AND id=?', [team, session]))?.deleted_at != null;
99
+ if (!deleted) {
100
+ if (origin.deleted_at != null) return true;
101
+ // What a person's delete of the session writes (harness-routes mutateSession), but for the team's authorization
102
+ // epoch: every read already passes over a deleted session, and the epoch would end every open stream of the team.
103
+ const test = eligible(cutoff), whole = kept(team, cutoff), revision = { $journalRevision: true };
104
+ const guard = { sql: `SELECT 1 ok FROM sessions se JOIN origins o ON o.team_id=se.team_id AND o.session_id=se.id JOIN sources s ON s.team_id=o.team_id AND s.id=o.source_id
105
+ WHERE se.team_id=? AND se.id=? AND se.deleted_at IS NULL AND o.source_id=? AND o.harness=? AND o.native_id=? AND o.deleted_at IS NULL AND ${test.sql} AND ${whole.sql} IS NULL`,
106
+ params: [team, session, source, harness, native, ...test.params, ...whole.params], code: 'left_whole' };
107
+ if (!await one(guard.sql, guard.params)) return true;
108
+ let at;
109
+ try {
110
+ ({ revision: at } = await api.store.journalTransaction(team, [guard], [
111
+ { sql: 'UPDATE sessions SET deleted_at=?,revision=? WHERE team_id=? AND id=?', params: [now, revision, team, session] },
112
+ { sql: 'UPDATE origins SET deleted_at=? WHERE team_id=? AND session_id=? AND source_id=? AND harness=? AND native_id=?', params: [now, team, session, source, harness, native] },
113
+ { sql: 'INSERT OR IGNORE INTO capture_prefix_garbage SELECT team_id,session_id,source_id,generation FROM capture_prefix_state WHERE team_id=? AND session_id=?', params: [team, session] },
114
+ { sql: 'DELETE FROM capture_prefix_state WHERE team_id=? AND session_id=?', params: [team, session] },
115
+ { sql: 'DELETE FROM capture_prefix_warm_queue WHERE team_id=? AND session_id=?', params: [team, session] },
116
+ { sql: 'UPDATE teams SET journal_revision=? WHERE id=?', params: [revision, team] },
117
+ { sql: 'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)', params: [team, revision, session, 'delete', now] },
118
+ ]));
119
+ } catch (error) { if (error?.code === 'left_whole') return true; throw error; }
120
+ api.events.publish(team, { type: 'session_deleted', cursor: String(at), revision: at, session_id: session });
121
+ run.count('revoked-source sessions', 1); run.deleted = true; deleted = true;
122
+ }
123
+ // A deleted session's versions are the mark of a deletion of ours not yet finished: they go last.
124
+ const unfinished = Boolean(await one('SELECT 1 held FROM session_versions WHERE team_id=? AND session_id=? LIMIT 1', [team, session]));
125
+ if (!unfinished && !sourceReleased) return true;
126
+ const key = [team, source, harness, native];
127
+ const drain = async (sql, params, chunk, name) => {
128
+ for (;;) {
129
+ if (!run.left()) return false;
130
+ const { changes } = await api.store.exec(sql, params);
131
+ run.count(name, changes); run.rows -= changes;
132
+ if (changes < chunk) return true;
133
+ }
134
+ };
135
+ if (!await drain(`DELETE FROM message_ids WHERE rowid IN (SELECT rowid FROM message_ids WHERE team_id=? AND source_id=? AND harness=? AND native_id=? LIMIT ${ROW_CHUNK})`, key, ROW_CHUNK, 'revoked-source message ids')) return false;
136
+ if (!await drain(`DELETE FROM captures WHERE rowid IN (SELECT rowid FROM captures WHERE team_id=? AND source_id=? AND harness=? AND native_id=? LIMIT ${CAPTURE_CHUNK})`, key, CAPTURE_CHUNK, 'revoked-source captures')) return false;
137
+ if (!unfinished) return true;
138
+ if (!await drain(`DELETE FROM messages WHERE rowid IN (SELECT rowid FROM messages WHERE team_id=? AND session_id=? LIMIT ${ROW_CHUNK})`, [team, session], ROW_CHUNK, 'revoked-source messages')) return false;
139
+ await api.store.transaction([
140
+ { sql: 'DELETE FROM session_versions WHERE team_id=? AND session_id=?', params: [team, session] },
141
+ { sql: 'DELETE FROM version_prune_queue WHERE team_id=? AND session_id=?', params: [team, session] },
142
+ { sql: 'DELETE FROM session_activity WHERE team_id=? AND source_id=? AND harness=? AND native_id=?', params: key },
143
+ ]);
144
+ return true;
145
+ }