@volter/teams 0.2.24 → 0.2.26

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/checkout.mjs CHANGED
@@ -4,13 +4,14 @@
4
4
  // macOS guards, where the session that named the path does).
5
5
  import { realpathSync } from 'node:fs';
6
6
  import { resolve } from 'node:path';
7
- import { spawnSync } from 'node:child_process';
7
+ import { execFile } from 'node:child_process';
8
8
 
9
- const git = (path, args, optional = false) => {
10
- const r = spawnSync('git', ['-C', path, ...args], { encoding: 'utf8', timeout: 30_000 });
11
- if (r.status !== 0) { if (optional) return null; throw new Error(`git ${args[0]} in ${path}: ${(r.stderr || r.error?.message || 'failed').trim()}`); }
12
- return r.stdout.trim();
13
- };
9
+ // awaited: a caller's event loop never waits on git
10
+ const git = (path, args, optional = false) => new Promise((done, fail) => execFile('git', ['-C', path, ...args], { encoding: 'utf8', timeout: 30_000 }, (error, stdout, stderr) => {
11
+ if (!error) return done(String(stdout).trim());
12
+ if (optional) return done(null);
13
+ fail(new Error(`git ${args[0]} in ${path}: ${(stderr || error.message || 'failed').trim()}`));
14
+ }));
14
15
  export function repositoryId(value) {
15
16
  let s = String(value ?? '').trim().replace(/\.git\/?$/, '').replace(/\/$/, '');
16
17
  if (/^[^/@]+@[^:]+:/.test(s)) s = s.replace(/^[^@]+@/, '').replace(':', '/');
@@ -19,11 +20,11 @@ export function repositoryId(value) {
19
20
  if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(s) || s.split('/').some(p => p === '.' || p === '..')) throw new Error('repository must be a git remote identity, for example volter-ai/editor; not a machine path');
20
21
  return s;
21
22
  }
22
- export function inspectCheckout(path) {
23
- const raw = git(path, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
23
+ export async function inspectCheckout(path) {
24
+ const raw = await git(path, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir']);
24
25
  const [root, dir, common] = raw.split('\n').map(p => realpathSync(resolve(path, p)));
25
26
  // The repository the checkout declares (remote.origin.url), not where a URL rewrite (insteadOf: a mirror, a proxy, a
26
27
  // World's twin) sends its transport.
27
- const remote = git(root, ['config', '--get', 'remote.origin.url'], true);
28
- return { path: realpathSync(path), root, linked: dir !== common, repository: remote ? repositoryId(remote) : null, branch: git(root, ['branch', '--show-current']) || null };
28
+ const remote = await git(root, ['config', '--get', 'remote.origin.url'], true);
29
+ return { path: realpathSync(path), root, linked: dir !== common, repository: remote ? repositoryId(remote) : null, branch: (await git(root, ['branch', '--show-current'])) || null };
29
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/teams",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
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/apps.mjs CHANGED
@@ -363,7 +363,7 @@ export async function appRoute(ctx) {
363
363
 
364
364
  /** Standard Webhooks: stable delivery id across retries, timestamp refreshed and HMAC over exact body bytes. */
365
365
  export async function deliverAppWebhooks(api) {
366
- const due = await api.store.query("SELECT id FROM app_webhook_deliveries WHERE state='pending' AND next_at<=? AND (lease_until IS NULL OR lease_until<=?) ORDER BY next_at,id LIMIT 10", [api.clock.nowMs(), api.clock.nowMs()]);
366
+ const due = await api.store.query("SELECT id FROM app_webhook_deliveries WHERE state='pending' AND next_at<=? AND (lease_until IS NULL OR lease_until<=?) ORDER BY next_at LIMIT 10", [api.clock.nowMs(), api.clock.nowMs()]);
367
367
  for (const { id } of due) {
368
368
  const now = api.clock.nowMs(), lease = now + 30000;
369
369
  try {
@@ -397,6 +397,12 @@ export async function deliverAppWebhooks(api) {
397
397
  const state = delivered ? 'delivered' : row.attempts >= 8 ? 'failed' : 'pending';
398
398
  await api.store.transaction([statement("UPDATE app_webhook_deliveries SET state=?,last_status=?,lease_until=NULL,next_at=? WHERE id=? AND lease_until=? AND state='pending'", state, status, api.clock.nowMs() + Math.min(3600000, 1000 * 2 ** row.attempts), id, lease)]);
399
399
  }
400
- await api.store.transaction([statement('DELETE FROM app_install_codes WHERE expires_at<=?', api.clock.nowMs()), statement("DELETE FROM app_webhook_deliveries WHERE state!='pending' AND created_at<?", api.clock.nowMs() - 7 * 86400000)]);
400
+ // Week-old settled deliveries go from the front (oldest rowid first), a bounded batch per run, stopping at the first
401
+ // delivery younger than a week or still pending (it settles within its eight attempts). Expired install codes go
402
+ // with maintain's other expiries (t_6c01c966).
403
+ const front = await api.store.query('SELECT rowid AS id,state,created_at FROM app_webhook_deliveries ORDER BY rowid LIMIT 500', []);
404
+ let through = null; for (const row of front) { if (row.state === 'pending' || row.created_at >= api.clock.nowMs() - 7 * 86400000) break; through = row.id; }
405
+ if (through != null) await api.store.transaction([statement('DELETE FROM app_webhook_deliveries WHERE rowid<=? AND state<>\'pending\'', through)]);
406
+ if (through != null && front.length >= 500 && front.at(-1).id === through) return true;
401
407
  return due.length >= 10;
402
408
  }
package/server/core.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { deliverAppWebhooks } from './apps.mjs';
2
- import { reconcileMachinePresence } from './machine-events.mjs';
2
+ import { reconcileMachinePresence, LINK_GRACE_MS } from './machine-events.mjs';
3
3
  // The Teams server, independent of its host. `createTeamsApi` builds the API
4
4
  // object every route and door reads; a host attaches a store (`api.attach`),
5
5
  // bootstraps the server identity once, serves requests through http.mjs's
@@ -20,7 +20,7 @@ export function createTeamsApi({clock=productionClock,ids=productionIds,publicOr
20
20
  if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
21
21
  let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
22
22
  const events=new TeamEvents();
23
- return {clock,ids,events,publicOrigin,appRootSecret,uploadLimitBytes,rateState:new Map(),rateSpenders:new Map(),rateReported:new Map(),openStreams:new Map(),securityEvents:[],
23
+ return {clock,ids,events,publicOrigin,appRootSecret,uploadLimitBytes,activatedAt:clock.nowMs(),rateState:new Map(),rateSpenders:new Map(),rateReported:new Map(),openStreams:new Map(),securityEvents:[],
24
24
  get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
25
25
  attach(next){store=next.store??store;if(next.serverId!==undefined)serverId=next.serverId;if(next.localOrigin!==undefined)localOrigin=next.localOrigin;if(next.harnessRoutes!==undefined)harnessRoutes=next.harnessRoutes;},
26
26
  };
@@ -51,74 +51,230 @@ export async function bootstrapServer(api,{initTeam,bootstrap}){
51
51
  }
52
52
 
53
53
  /** Expire cursors, idempotency records, staged captures and uploads, and old journal rows. */
54
- export async function maintain(api){const now=api.clock.nowMs();
55
- // Replay records expire after a day; keeping them forever can fill the object and stop heartbeats.
56
- // Delete them before any retention step that may update a surviving version.
57
- const expired=await api.store.query('SELECT rowid FROM idempotency WHERE expires_at<=? LIMIT 1000',[now]);
58
- if(expired.length)await api.store.transaction([{sql:'DELETE FROM idempotency WHERE rowid IN (SELECT rowid FROM idempotency WHERE expires_at<=? LIMIT 1000)',params:[now]}]);
59
- // Deletes go first, each in a transaction of its own: on a full database an insert fails, and a run that begins
60
- // with one never reaches the deletes that would make room.
61
- // A committed capture's parts are its messages in transit; once committed, the session's version holds them, so
62
- // they go (a backlog of them filled the database).
63
- 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}`,[]);
64
- 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]})));
65
- // Retired versions release their native bundles. Keep every bundle still named by a version,
66
- // continuation (including completed receipts), or in-flight capture. Never-committed uploads retain their one-day grace.
67
- const orphaned=await api.store.query(`SELECT u.team_id,u.id FROM uploads u WHERE u.committed=1
68
- AND NOT EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=u.team_id AND a.upload_id=u.id)
69
- AND NOT EXISTS (SELECT 1 FROM continuations c WHERE c.team_id=u.team_id AND instr(c.value_json,u.id)>0)
70
- AND NOT EXISTS (SELECT 1 FROM captures c WHERE c.team_id=u.team_id AND c.state='staging' AND instr(c.begin_json,u.id)>0)
71
- LIMIT 10`,[]);
72
- for(const upload of orphaned)await api.store.transaction([{sql:`DELETE FROM uploads AS u WHERE team_id=? AND id=? AND committed=1
73
- AND NOT EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=u.team_id AND a.upload_id=u.id)
74
- AND NOT EXISTS (SELECT 1 FROM continuations c WHERE c.team_id=u.team_id AND instr(c.value_json,u.id)>0)
75
- AND NOT EXISTS (SELECT 1 FROM captures c WHERE c.team_id=u.team_id AND c.state='staging' AND instr(c.begin_json,u.id)>0)`,params:[upload.team_id,upload.id]}]);
76
- const unusedBlobs = await api.store.query(`SELECT team_id,sha256 FROM artifact_blobs b
77
- WHERE NOT EXISTS (SELECT 1 FROM artifact_chunk_refs r WHERE r.team_id=b.team_id AND r.sha256=b.sha256) LIMIT 50`);
78
- for (const blob of unusedBlobs) await api.store.transaction([{sql:`DELETE FROM artifact_blobs AS b WHERE team_id=? AND sha256=?
79
- AND NOT EXISTS (SELECT 1 FROM artifact_chunk_refs r WHERE r.team_id=b.team_id AND r.sha256=b.sha256)`,params:[blob.team_id,blob.sha256]}]);
80
- // Every capture writes its session a new version (its messages are carried, not copied: schema 9), so versions pile
81
- // up (hundreds per session) until listing them outgrew a Worker's memory. Kept: each session's current version, the newest
82
- // version from each of its sources (the next capture and a correlation read that one), and every version an
83
- // unexpired list, search or history cursor names. The rest go, a bounded batch per run.
84
- 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
85
- WHERE v.revision<>s.revision
86
- AND EXISTS (SELECT 1 FROM session_versions n WHERE n.team_id=v.team_id AND n.session_id=v.session_id AND n.revision>v.revision AND json_extract(n.source_projection_json,'$.source_id') IS json_extract(v.source_projection_json,'$.source_id'))
87
- AND (NOT EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=v.team_id AND a.session_id=v.session_id AND a.revision=v.revision AND a.provenance='capture')
88
- OR EXISTS (SELECT 1 FROM session_artifacts a JOIN session_versions n ON n.team_id=a.team_id AND n.session_id=a.session_id AND n.revision=a.revision
89
- WHERE a.team_id=v.team_id AND a.session_id=v.session_id AND a.revision>v.revision AND a.provenance='capture'
90
- AND json_extract(n.source_projection_json,'$.source_id') IS json_extract(v.source_projection_json,'$.source_id')))
91
- 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)
92
- 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)
93
- LIMIT ${VERSION_PRUNE_BATCH}`,[now,now]);
94
- // A message row lives from the version that wrote it until one that changed it (schema 9), so a row a later version
95
- // still holds moves to the next version that holds it before its own goes; the rest go with the version.
96
- for(const {team_id,session_id,revision} of stale)await api.store.transaction([
97
- {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]},
98
- ...['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]})),
99
- ]);
100
- await api.store.transaction([
101
- {sql:'INSERT INTO event_retention SELECT e.team_id,MAX(e.sequence) FROM org_events e LEFT JOIN (SELECT team_id,MIN(sequence) AS first FROM org_events WHERE created_at>=? GROUP BY team_id) n ON n.team_id=e.team_id WHERE e.created_at<? AND (n.first IS NULL OR e.sequence<n.first) GROUP BY e.team_id ON CONFLICT(team_id) DO UPDATE SET pruned_through=MAX(pruned_through,excluded.pruned_through)',params:[now-7*86400000,now-7*86400000]},
102
- {sql:'DELETE FROM org_events WHERE sequence<=(SELECT pruned_through FROM event_retention WHERE team_id=org_events.team_id) AND sequence NOT IN (SELECT sequence FROM event_resources)',params:[]},
103
- {sql:'DELETE FROM cursors WHERE expires_at<=?',params:[now]},
104
- {sql:'DELETE FROM global_cursors WHERE expires_at<=?',params:[now]},
105
- {sql:'DELETE FROM global_idempotency WHERE expires_at<=?',params:[now]},
106
- {sql:'DELETE FROM journal WHERE created_at+86400000<=?',params:[now]},
107
- ]).catch(()=>{});
108
- await api.store.transaction([
109
- {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) SELECT team_id,source_id,id,? FROM captures WHERE state=\'staging\' AND created_at+86400000<=?',params:[now,now]},
110
- {sql:'DELETE FROM captures WHERE state=\'staging\' AND created_at+86400000<=?',params:[now]},
111
- {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) SELECT team_id,source_id,id,? FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now,now]},
112
- {sql:'DELETE FROM uploads WHERE committed=0 AND created_at+86400000<=?',params:[now]},
113
- ]).catch(()=>{});
114
- // Presence reconciliation can INSERT. Never put it ahead of the deletes
115
- // that make room when the database is full.
116
- await reconcileMachinePresence(api);
117
- const webhookBacklog=await deliverAppWebhooks(api);
118
- const result={pruned_versions:stale.length,pruned_parts:parts.length,pruned_idempotency:expired.length,pruned_uploads:orphaned.length,pruned_blobs:unusedBlobs.length,backlog:webhookBacklog||unusedBlobs.length>=50||orphaned.length>=10||expired.length>=1000||stale.length>=VERSION_PRUNE_BATCH||parts.length>=PART_PRUNE_BATCH};
119
- if(result.pruned_versions||result.pruned_parts||result.pruned_idempotency||result.pruned_uploads)console.log(`maintain: pruned ${result.pruned_uploads} unreachable bundles, ${result.pruned_idempotency} expired replay records, ${result.pruned_parts} parts, ${result.pruned_versions} versions${result.backlog?' (backlog)':''}`);
54
+ /**
55
+ * The store's retention, run on the object's alarm every minute (and in seconds while a backlog remains). Each step
56
+ * costs what it removes, never the store (t_6c01c966): it reads through a key, the front of a table it deletes from
57
+ * the front, or a queue its writes fill (migration 023); it deletes by subquery or key, never a list of bound values
58
+ * (a Durable Object binds at most 100); it removes from a queue only the generation it read; and a run stops taking
59
+ * new work once its time budget is spent, saying the rest as `backlog`. Rows from before migration 023 are walked into
60
+ * its tables a bounded batch per run, resumably, and each step waits for the walks it relies on. A failing step is
61
+ * logged and the others still run.
62
+ */
63
+ export async function maintain(api,{budgetMs=MAINTAIN_BUDGET_MS}={}){const now=api.clock.nowMs(),deadline=Date.now()+budgetMs;
64
+ const failed=[];let more=false;const counts={};
65
+ const left=()=>Date.now()<deadline;
66
+ const q=(sql,params=[])=>api.store.query(sql,params),one=(sql,params=[])=>api.store.one(sql,params),tx=(statements)=>api.store.transaction(statements);
67
+ const step=async(name,work)=>{if(!left()){more=true;return;}try{await work();}catch(error){failed.push(name);console.error(`maintain ${name}: ${error.message}`);}};
68
+ const count=(name,n)=>{if(n>0)counts[name]=(counts[name]??0)+n;};
69
+ // Deletes go first, each in a transaction of its own: on a full database an insert fails, and a run that begins with
70
+ // one never reaches the deletes that would make room.
71
+ // Rows that expire in the order they were written go from the front: the first rows by rowid, while expired.
72
+ await step('expiries',async()=>{
73
+ for(const [table,column,cut] of [['idempotency','expires_at',now],['global_idempotency','expires_at',now],['journal','created_at',now-86400000],['app_install_codes','expires_at',now]]){
74
+ const front=await q(`SELECT rowid AS id,${column} AS at FROM ${table} ORDER BY rowid LIMIT ${EXPIRY_BATCH}`);
75
+ let through=null;for(const row of front){if(row.at>cut)break;through=row.id;}
76
+ if(through!=null){await tx([{sql:`DELETE FROM ${table} WHERE rowid<=?`,params:[through]}]);count(`expired ${table}`,front.findIndex(row=>row.id===through)+1);}
77
+ if(through!=null&&front.length>=EXPIRY_BATCH&&front.at(-1).id===through)more=true;
78
+ }
79
+ // cursors expire through their expiry index
80
+ for(const table of ['cursors','global_cursors']){
81
+ await tx([{sql:`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE expires_at<=? ORDER BY expires_at LIMIT ${EXPIRY_BATCH})`,params:[now]}]);
82
+ if(await one(`SELECT 1 AS more FROM ${table} WHERE expires_at<=? LIMIT 1`,[now]))more=true;
83
+ }
84
+ });
85
+ // A capture or an upload never committed within a day expires, recorded as expired (they are few: only those in flight).
86
+ await step('in flight',async()=>{
87
+ for(const [kept,table] of [['staging_captures','captures'],['pending_uploads','uploads']]){
88
+ const rows=await q(`SELECT team_id,id,source_id FROM ${kept} WHERE created_at<=? ORDER BY created_at LIMIT ${IN_FLIGHT_BATCH}`,[now-86400000]);
89
+ for(const row of rows)await tx([
90
+ {sql:'INSERT OR IGNORE INTO capture_expiries(team_id,source_id,id,expired_at) VALUES (?,?,?,?)',params:[row.team_id,row.source_id,row.id,now]},
91
+ {sql:`DELETE FROM ${table} WHERE team_id=? AND id=?`,params:[row.team_id,row.id]},
92
+ {sql:`DELETE FROM ${kept} WHERE team_id=? AND id=?`,params:[row.team_id,row.id]},
93
+ ]);
94
+ count(`expired ${table}`,rows.length);if(rows.length>=IN_FLIGHT_BATCH)more=true;
95
+ }
96
+ });
97
+ // A committed capture's parts are its messages in transit; once committed, the session's version holds them.
98
+ await step('parts',async()=>{
99
+ for(const {team_id,capture_id,generation} of await q(`SELECT team_id,capture_id,generation FROM parts_prune_queue LIMIT ${PARTS_CAPTURE_BATCH}`)){
100
+ if(!left()){more=true;break;}
101
+ await tx([{sql:`DELETE FROM capture_parts WHERE rowid IN (SELECT rowid FROM capture_parts WHERE team_id=? AND capture_id=? LIMIT ${PART_PRUNE_BATCH})`,params:[team_id,capture_id]}]);
102
+ if(await one('SELECT 1 AS left FROM capture_parts WHERE team_id=? AND capture_id=? LIMIT 1',[team_id,capture_id])){more=true;continue;}
103
+ await tx([{sql:'DELETE FROM parts_prune_queue WHERE team_id=? AND capture_id=? AND generation=?',params:[team_id,capture_id,generation]}]);
104
+ count('captures cleared of parts',1);
105
+ }
106
+ });
107
+ // A committed upload nothing names (no artifact, continuation or in-flight capture: upload_refs) releases its bundle.
108
+ await step('uploads',async()=>{
109
+ if(!await walked(api,['artifact_refs','continuation_refs','captures','uploads'])){more=true;return;}
110
+ const queued=await q(`SELECT team_id,upload_id,generation FROM upload_prune_queue LIMIT ${UPLOAD_PRUNE_BATCH}`);
111
+ for(const {team_id,upload_id,generation} of queued){
112
+ if(!left()){more=true;break;}
113
+ const named=await one('SELECT 1 AS named FROM upload_refs WHERE team_id=? AND upload_id=? LIMIT 1',[team_id,upload_id]);
114
+ const statements=[];
115
+ if(!named){statements.push({sql:'DELETE FROM uploads WHERE team_id=? AND id=? AND committed=1 AND NOT EXISTS (SELECT 1 FROM upload_refs r WHERE r.team_id=? AND r.upload_id=?)',params:[team_id,upload_id,team_id,upload_id]});count('released uploads',1);}
116
+ statements.push({sql:'DELETE FROM upload_prune_queue WHERE team_id=? AND upload_id=? AND generation=?',params:[team_id,upload_id,generation]});
117
+ await tx(statements);
118
+ }
119
+ if(queued.length>=UPLOAD_PRUNE_BATCH)more=true;
120
+ });
121
+ // A blob no chunk names.
122
+ await step('blobs',async()=>{
123
+ if(!await walked(api,['blobs'])){more=true;return;}
124
+ const queued=await q(`SELECT team_id,sha256,generation FROM blob_prune_queue LIMIT ${BLOB_PRUNE_BATCH}`);
125
+ for(const {team_id,sha256,generation} of queued){
126
+ if(!left()){more=true;break;}
127
+ await tx([
128
+ {sql:'DELETE FROM artifact_blobs WHERE team_id=? AND sha256=? AND NOT EXISTS (SELECT 1 FROM artifact_chunk_refs r WHERE r.team_id=? AND r.sha256=?)',params:[team_id,sha256,team_id,sha256]},
129
+ {sql:'DELETE FROM blob_prune_queue WHERE team_id=? AND sha256=? AND generation=?',params:[team_id,sha256,generation]},
130
+ ]);
131
+ }
132
+ if(queued.length>=BLOB_PRUNE_BATCH)more=true;
133
+ });
134
+ // Every capture writes its session a new version (its messages are carried, not copied: schema 9). Kept: the
135
+ // session's current version, the newest version from each of its sources, the newest one from each source that a
136
+ // capture's artifact names (the next capture and a correlation read those), and every version a live cursor pins.
137
+ await step('versions',async()=>{
138
+ if(!await walked(api,['version_sources','cursor_pins'])){more=true;return;}
139
+ const due=await q(`SELECT team_id,session_id,generation FROM version_prune_queue WHERE due_at<=? ORDER BY due_at LIMIT ${VERSION_SESSION_BATCH}`,[now]);
140
+ for(const {team_id,session_id,generation} of due){
141
+ if(!left()){more=true;break;}
142
+ const session=await one('SELECT revision FROM sessions WHERE team_id=? AND id=?',[team_id,session_id]);
143
+ let budget=VERSION_PRUNE_BATCH,full=false;
144
+ for(let source=null;session&&budget>0;){
145
+ const next=source===null?await one('SELECT source_id FROM version_sources WHERE team_id=? AND session_id=? ORDER BY source_id LIMIT 1',[team_id,session_id])
146
+ :await one('SELECT source_id FROM version_sources WHERE team_id=? AND session_id=? AND source_id>? ORDER BY source_id LIMIT 1',[team_id,session_id,source]);
147
+ if(!next)break;source=next.source_id;
148
+ const newest=(await one('SELECT revision FROM version_sources WHERE team_id=? AND session_id=? AND source_id=? ORDER BY revision DESC LIMIT 1',[team_id,session_id,source])).revision;
149
+ // the newest version of this source a capture's artifact names, walked down from the newest (the versions it
150
+ // passes are this source's prunable ones); not found within the walk, versions a capture names are kept
151
+ let capture=-1;
152
+ for(let below=newest+1,walkedRows=0;capture<0&&walkedRows<CAPTURE_WALK;){
153
+ const rows=await q(`SELECT vs.revision,EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=vs.team_id AND a.session_id=vs.session_id AND a.revision=vs.revision AND a.provenance='capture') AS captured
154
+ FROM version_sources vs WHERE vs.team_id=? AND vs.session_id=? AND vs.source_id=? AND vs.revision<? ORDER BY vs.revision DESC LIMIT 100`,[team_id,session_id,source,below]);
155
+ if(!rows.length)break;
156
+ const hit=rows.find(row=>row.captured);if(hit)capture=hit.revision;
157
+ below=rows.at(-1).revision;walkedRows+=rows.length;
158
+ }
159
+ const free=await q(`SELECT vs.revision FROM version_sources vs WHERE vs.team_id=? AND vs.session_id=? AND vs.source_id=? AND vs.revision<? AND vs.revision<>?
160
+ AND NOT EXISTS (SELECT 1 FROM cursor_pins p WHERE p.team_id=vs.team_id AND p.session_id=vs.session_id AND p.revision=vs.revision AND p.expires_at>?)
161
+ AND (vs.revision<? OR NOT EXISTS (SELECT 1 FROM session_artifacts a WHERE a.team_id=vs.team_id AND a.session_id=vs.session_id AND a.revision=vs.revision AND a.provenance='capture'))
162
+ ORDER BY vs.revision LIMIT ${budget}`,[team_id,session_id,source,newest,session.revision,now,capture]);
163
+ // A message row lives from the version that wrote it until one that changed it (schema 9), so a row a later
164
+ // version still holds moves to the next version that holds it before its own goes; the rest go with the version.
165
+ for(const {revision} of free)await tx([
166
+ {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]},
167
+ ...['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]})),
168
+ ]);
169
+ count('versions',free.length);budget-=free.length;if(budget<=0)full=true;
170
+ }
171
+ // more of this session: due now; else due when its first live pin expires (what it pins may then go); else done
172
+ const pin=full?null:await one('SELECT MIN(expires_at) AS at FROM cursor_pins WHERE team_id=? AND session_id=? AND expires_at>?',[team_id,session_id,now]);
173
+ const at=full?now:pin?.at??null;
174
+ if(full)more=true;
175
+ await tx([at==null
176
+ ?{sql:'DELETE FROM version_prune_queue WHERE team_id=? AND session_id=? AND generation=?',params:[team_id,session_id,generation]}
177
+ :{sql:'UPDATE version_prune_queue SET due_at=? WHERE team_id=? AND session_id=? AND generation=?',params:[at,team_id,session_id,generation]}]);
178
+ }
179
+ if(due.length>=VERSION_SESSION_BATCH)more=true;
180
+ });
181
+ // Events older than a week go, except each resource's current event (its replay). Retention walks forward by
182
+ // sequence from where it stopped: `pruned_through` over events older than the cut, the deletes behind it from
183
+ // `deleted_through`, each run examining a bounded window.
184
+ await step('events',async()=>{
185
+ const cut=now-7*86400000;
186
+ for(const {team_id} of await q('SELECT id AS team_id FROM teams')){
187
+ if(!left()){more=true;break;}
188
+ const kept=await one('SELECT pruned_through,deleted_through FROM event_retention WHERE team_id=?',[team_id]);
189
+ const from=kept?.pruned_through??0;
190
+ const ahead=await q(`SELECT sequence,created_at FROM org_events WHERE team_id=? AND sequence>? ORDER BY sequence LIMIT ${EVENT_BATCH}`,[team_id,from]);
191
+ let through=from;for(const row of ahead){if(row.created_at>=cut)break;through=row.sequence;}
192
+ if(ahead.length>=EVENT_BATCH&&ahead.at(-1).sequence===through)more=true;
193
+ const deletedFrom=kept?.deleted_through??0;
194
+ const window=await q(`SELECT sequence FROM org_events WHERE team_id=? AND sequence>? AND sequence<=? ORDER BY sequence LIMIT ${EVENT_BATCH}`,[team_id,deletedFrom,through]);
195
+ const deletedThrough=window.length>=EVENT_BATCH?window.at(-1).sequence:through;
196
+ if(window.length>=EVENT_BATCH)more=true;
197
+ await tx([
198
+ {sql:`DELETE FROM org_events WHERE team_id=? AND sequence>? AND sequence<=? AND NOT EXISTS (SELECT 1 FROM event_resources r WHERE r.team_id=org_events.team_id AND r.source_id=COALESCE(org_events.source_id,'') AND r.resource_kind=org_events.resource_kind AND r.resource_id=org_events.resource_id AND r.sequence=org_events.sequence)`,params:[team_id,deletedFrom,deletedThrough]},
199
+ {sql:'INSERT INTO event_retention(team_id,pruned_through,deleted_through) VALUES (?,?,?) ON CONFLICT(team_id) DO UPDATE SET pruned_through=MAX(pruned_through,excluded.pruned_through),deleted_through=MAX(deleted_through,excluded.deleted_through)',params:[team_id,through,deletedThrough]},
200
+ ]);
201
+ }
202
+ });
203
+ // Presence reconciliation can INSERT. Never put it ahead of the deletes that make room when the database is full.
204
+ await step('presence',()=>reconcileMachinePresence(api));
205
+ await step('webhooks',async()=>{if(await deliverAppWebhooks(api))more=true;});
206
+ // Last, with what the budget leaves: the walks of rows written before migration 023 into its tables.
207
+ await step('backfill',async()=>{if(!await backfill(api,{now,left}))more=true;});
208
+ const result={counts,failed,backlog:more};
209
+ if(Object.keys(counts).length)console.log(`maintain: removed ${Object.entries(counts).map(([name,n])=>`${n} ${name}`).join(', ')}${more?' (backlog)':''}`);
120
210
  return result;}
121
- /** Session versions `maintain` removes per run: small enough for one Durable Object alarm; a backlog runs again in seconds. */
122
- const VERSION_PRUNE_BATCH=20;
123
- /** Committed capture parts `maintain` removes per run. */
124
- const PART_PRUNE_BATCH=50;
211
+
212
+ /** Whether each named walk of migration 023's backfill is done. */
213
+ async function walked(api,names){
214
+ for(const name of names){const row=await api.store.one('SELECT done FROM maintain_backfill WHERE name=?',[name]);if(!row?.done)return false;}
215
+ return true;
216
+ }
217
+ /** The walks of rows written before migration 023 into its tables: each a bounded rowid range per batch, in SQL (no
218
+ * row comes into memory), resumed from where it stopped. Answers whether every walk is done. */
219
+ const BACKFILLS=[
220
+ ['version_sources','session_versions',[
221
+ "INSERT OR IGNORE INTO version_sources SELECT team_id,session_id,COALESCE(json_extract(source_projection_json,'$.source_id'),''),revision FROM session_versions WHERE rowid>? AND rowid<=?",
222
+ 'INSERT INTO version_prune_queue(team_id,session_id,due_at,generation) SELECT DISTINCT team_id,session_id,0,1 FROM session_versions WHERE rowid>? AND rowid<=? ON CONFLICT(team_id,session_id) DO UPDATE SET due_at=0,generation=generation+1']],
223
+ ['cursor_pins','cursors',[
224
+ "INSERT INTO cursor_pins SELECT c.id,c.team_id,json_extract(j.value,'$[0]'),json_extract(j.value,'$[1]'),c.expires_at FROM cursors c, json_each(c.snapshot_json) j WHERE c.rowid>? AND c.rowid<=? AND c.expires_at>NOWMS AND c.kind IN ('list','search') AND c.snapshot_json IS NOT NULL AND json_extract(j.value,'$[0]') IS NOT NULL AND json_extract(j.value,'$[1]') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM cursor_pins p WHERE p.cursor_id=c.id)",
225
+ "INSERT INTO cursor_pins SELECT c.id,c.team_id,json_extract(c.snapshot_json,'$.session_id'),json_extract(c.snapshot_json,'$.revision'),c.expires_at FROM cursors c WHERE c.rowid>? AND c.rowid<=? AND c.expires_at>NOWMS AND c.kind='history' AND json_extract(c.snapshot_json,'$.session_id') IS NOT NULL AND json_extract(c.snapshot_json,'$.revision') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM cursor_pins p WHERE p.cursor_id=c.id)"]],
226
+ ['artifact_refs','session_artifacts',[
227
+ "INSERT OR IGNORE INTO upload_refs SELECT team_id,upload_id,'a:'||session_id||':'||revision||':'||bundle_id FROM session_artifacts WHERE rowid>? AND rowid<=?"]],
228
+ ['continuation_refs','continuations',[
229
+ "INSERT OR IGNORE INTO upload_refs SELECT c.team_id,substr(t.value,instr(t.value,'upload_'),39),'c:'||c.id FROM continuations c, json_tree(c.value_json) t WHERE c.rowid>? AND c.rowid<=? AND t.type='text' AND instr(t.value,'upload_')>0"]],
230
+ ['captures','captures',[
231
+ "INSERT OR IGNORE INTO staging_captures SELECT team_id,id,source_id,created_at FROM captures WHERE rowid>? AND rowid<=? AND state='staging'",
232
+ "INSERT OR IGNORE INTO upload_refs SELECT c.team_id,substr(t.value,instr(t.value,'upload_'),39),'p:'||c.id FROM captures c, json_tree(c.begin_json) t WHERE c.rowid>? AND c.rowid<=? AND c.state='staging' AND t.type='text' AND instr(t.value,'upload_')>0",
233
+ "INSERT INTO parts_prune_queue SELECT c.team_id,c.id,1 FROM captures c WHERE c.rowid>? AND c.rowid<=? AND c.state='committed' AND EXISTS (SELECT 1 FROM capture_parts p WHERE p.team_id=c.team_id AND p.capture_id=c.id) ON CONFLICT(team_id,capture_id) DO UPDATE SET generation=generation+1"]],
234
+ ['uploads','uploads',[
235
+ 'INSERT INTO upload_prune_queue SELECT team_id,id,1 FROM uploads WHERE rowid>? AND rowid<=? AND committed=1 ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1',
236
+ 'INSERT OR IGNORE INTO pending_uploads SELECT team_id,id,source_id,created_at FROM uploads WHERE rowid>? AND rowid<=? AND committed=0']],
237
+ ['blobs','artifact_blobs',[
238
+ 'INSERT INTO blob_prune_queue SELECT team_id,sha256,1 FROM artifact_blobs WHERE rowid>? AND rowid<=? ON CONFLICT(team_id,sha256) DO UPDATE SET generation=generation+1']],
239
+ ];
240
+ async function backfill(api,{now,left}){
241
+ let done=true;
242
+ for(const [name,table,statements] of BACKFILLS){
243
+ const progress=await api.store.one('SELECT after_rowid,done FROM maintain_backfill WHERE name=?',[name]);
244
+ if(progress?.done)continue;
245
+ let after=progress?.after_rowid??0,finished=false;
246
+ while(left()){
247
+ const last=await api.store.one(`SELECT MAX(rowid) AS r,COUNT(*) AS n FROM (SELECT rowid FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT ${BACKFILL_BATCH})`,[after]);
248
+ finished=!last?.n||last.n<BACKFILL_BATCH;
249
+ const through=last?.r??after;
250
+ await api.store.transaction([
251
+ ...(last?.n?statements.map(sql=>({sql:sql.replaceAll('NOWMS',String(Math.floor(now))),params:[after,through]})):[]),
252
+ {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:[name,through,finished?1:0]},
253
+ ]);
254
+ after=through;
255
+ if(finished)break;
256
+ }
257
+ if(!finished)done=false;
258
+ }
259
+ return done;
260
+ }
261
+ /** How long the object waits before its next `maintain`: seconds while a backlog remains, else a minute; and never past
262
+ * the end of the link grace after it started, so a machine whose link did not dial back is said unlinked on time. */
263
+ export function maintainDelay(api,result,now=api.clock.nowMs()){
264
+ const base=result?.backlog?3_000:MAINTAIN_MS;
265
+ const graceEnds=(api.activatedAt??0)+LINK_GRACE_MS+1_000;
266
+ return graceEnds>now?Math.min(base,graceEnds-now):base;
267
+ }
268
+ /** A minute between runs; a run's own time budget (it stops taking new work after it, saying the rest as backlog). */
269
+ const MAINTAIN_MS=60_000,MAINTAIN_BUDGET_MS=250;
270
+ /** Sessions whose versions a run reads, and versions it removes from each; how far it walks down a source to find the
271
+ * newest version a capture names. */
272
+ const VERSION_SESSION_BATCH=10,VERSION_PRUNE_BATCH=20,CAPTURE_WALK=500;
273
+ /** Committed captures whose parts a run removes, and parts per capture per run. */
274
+ const PARTS_CAPTURE_BATCH=20,PART_PRUNE_BATCH=50;
275
+ /** Rows of one expiry a run removes; captures and uploads in flight a run expires. */
276
+ const EXPIRY_BATCH=500,IN_FLIGHT_BATCH=50;
277
+ /** Queued uploads and blobs a run decides. */
278
+ const UPLOAD_PRUNE_BATCH=20,BLOB_PRUNE_BATCH=50;
279
+ /** Events per team retention walks per run; rows per backfill batch. */
280
+ const EVENT_BATCH=1000,BACKFILL_BATCH=500;
package/server/http.mjs CHANGED
@@ -336,10 +336,10 @@ async function groupRoute(api,req,res,auth,groupId,principalId,url,requestId){
336
336
  else{const group=await api.store.one('SELECT revision FROM groups_table WHERE team_id=? AND id=?',[auth.team_id,groupId]);if(!group)fail(404,'not_found','group not found');requireRevision(req,group.revision,'group');guards.push({sql:'SELECT 1 ok FROM groups_table WHERE team_id=? AND id=? AND revision=?',params:[auth.team_id,groupId,group.revision],code:'revision_mismatch',status:409,message:'group revision changed'});statements.push({sql:'DELETE FROM session_grants WHERE team_id=? AND subject_kind=\'group\' AND subject_id=?',params:[auth.team_id,groupId]},{sql:'DELETE FROM group_members WHERE team_id=? AND group_id=?',params:[auth.team_id,groupId]},{sql:'DELETE FROM groups_table WHERE team_id=? AND id=?',params:[auth.team_id,groupId]});}
337
337
  statements.push({sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]});await guardedMutation(api,res,auth,guards,statements,{status,body:response,targetId:target});api.events.publish(auth.team_id,{type:'authorization_changed',group_id:groupId});if(req.method==='DELETE')closeChannelsFor(api,auth.team_id,{groupId,principalId:principalId??null,reason:'group_changed'});send(res,status,response,requestId);
338
338
  }
339
- async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(403,'forbidden','viewers cannot enroll machines');const b=await parseJson(req);exact(b,['name','enrollment_key']);if(!b.name||!b.enrollment_key)fail(400,'invalid_request','name and enrollment_key required');const byKey=()=>api.store.one('SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.enrollment_key=?',[auth.team_id,auth.principal_id,b.enrollment_key]);let machine=await byKey();
339
+ async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(403,'forbidden','viewers cannot enroll machines');const b=await parseJson(req);exact(b,['name','enrollment_key']);if(!b.name||!b.enrollment_key)fail(400,'invalid_request','name and enrollment_key required');const byKey=()=>api.store.one('SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.enrollment_key=?',[auth.team_id,auth.principal_id,b.enrollment_key]);let machine=await byKey();
340
340
  // Enrollment is a fresh, authenticated custodial act. A disconnect revoked the
341
341
  // old credentials, but must not permanently reserve this custodian's name.
342
- if(!machine) machine=await api.store.one('SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.name=?',[auth.team_id,auth.principal_id,b.name]);
342
+ if(!machine) machine=await api.store.one('SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id WHERE m.team_id=? AND m.custodian_id=? AND m.name=?',[auth.team_id,auth.principal_id,b.name]);
343
343
  if(machine){
344
344
  const now=api.clock.nowMs(),reviving=machine.revoked_at!=null;
345
345
  const changed=reviving||machine.enrollment_key!==b.enrollment_key;
@@ -363,20 +363,20 @@ async function enroll(api,req,res,auth,requestId){if(auth.role==='viewer')fail(4
363
363
  }
364
364
  const mid=api.ids.next('machine'),sid=api.ids.next('source'),cid=api.ids.next('credential'),token=makeToken(cid),now=api.clock.nowMs(),data={machine:{id:mid,name:b.name,custodian_id:auth.principal_id,enrollment:'active',connection:'offline',last_seen_at:null,incarnation_id:null,revision:1},source:{id:sid,name:`${b.name} connector`,kind:'connector',state:'active',machine_id:mid,custodian_id:auth.principal_id,default_visibility:'restricted',revision:1,last_received_at:null,decoder:null},credential:{token,kind:'machine',expires_at:now+2592000000}},response=result(api,auth,data);await guardedMutation(api,res,auth,[...authGuards(auth,now),{sql:'SELECT 1 ok WHERE NOT EXISTS (SELECT 1 FROM sources WHERE team_id=? AND name=?)',params:[auth.team_id,`${b.name} connector`],code:'conflict',status:409,message:`a machine named ${b.name} is already enrolled in this team; enroll this one under another --name`}],[{sql:'INSERT INTO machines(team_id,id,name,custodian_id,enrollment_key) VALUES (?,?,?,?,?)',params:[auth.team_id,mid,b.name,auth.principal_id,b.enrollment_key]},{sql:"INSERT INTO sources(team_id,id,name,kind,machine_id,custodian_id,visibility,state) VALUES (?,?,?,?,?,?,?,'active')",params:[auth.team_id,sid,`${b.name} connector`,'connector',mid,auth.principal_id,'restricted']},{sql:'INSERT INTO credentials(id,token_hash,principal_id,kind,team_id,source_id,machine_id,expires_at,created_at) VALUES (?,?,?,?,?,?,?,?,?)',params:[cid,hashToken(token),auth.principal_id,'machine',auth.team_id,sid,mid,now+2592000000,now]}],{status:201,body:response,targetId:mid});send(res,201,response,requestId);}
365
365
  // `online` is a machine that heartbeats and holds its link, so callers can reach it; `unlinked` heartbeats without one.
366
- const machineJson=(m,now,api)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?(!api||api.machineLinks?.has(`${m.team_id}\0${m.id}`)?'online':'unlinked'):'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
366
+ const machineJson=(m,now,api)=>({id:m.id,name:m.name,custodian_id:m.custodian_id,enrollment:m.revoked_at==null?'active':'revoked',connection:m.last_seen_at!=null&&m.last_seen_at+45000>now?(m.presence_linked===1?'online':'unlinked'):'offline',last_seen_at:m.last_seen_at,incarnation_id:m.incarnation_id,host_key_id:m.host_key_id??null,revision:m.revision});
367
367
  async function machineAdminRoute(api,req,res,auth,machineId,roots,requestId){
368
368
  // Custodians and admins see their machines, revoked ones included; every member sees the team's active machines,
369
369
  // each of which they can message (the team's standing mail grant, machine-link.mjs). Roots stay custodial.
370
370
  const custody=roots?'(m.custodian_id=? OR ?=1)':'(m.custodian_id=? OR ?=1 OR m.revoked_at IS NULL)',params=[auth.team_id,auth.principal_id,isAdmin(auth)?1:0];
371
- if(machineId){const machine=await api.store.one(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} AND m.id=?`,[...params,machineId]);if(!machine)fail(404,'not_found','not found');if(roots){const rows=await api.store.query('SELECT value_json FROM machine_roots WHERE team_id=? AND machine_id=? ORDER BY id',[auth.team_id,machineId]);return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>JSON.parse(row.value_json)),next_cursor:null},requestId);}return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs(),api)),requestId,{ETag:`"${machine.revision}"`});}
372
- const rows=await api.store.query(`SELECT m.* FROM machines m WHERE m.team_id=? AND ${custody} ORDER BY m.name,m.id`,params);send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs(),api)),next_cursor:null},requestId);
371
+ if(machineId){const machine=await api.store.one(`SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked FROM machines m WHERE m.team_id=? AND ${custody} AND m.id=?`,[...params,machineId]);if(!machine)fail(404,'not_found','not found');if(roots){const rows=await api.store.query('SELECT value_json FROM machine_roots WHERE team_id=? AND machine_id=? ORDER BY id',[auth.team_id,machineId]);return send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>JSON.parse(row.value_json)),next_cursor:null},requestId);}return send(res,200,result(api,auth,machineJson(machine,api.clock.nowMs(),api)),requestId,{ETag:`"${machine.revision}"`});}
372
+ const rows=await api.store.query(`SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked FROM machines m WHERE m.team_id=? AND ${custody} ORDER BY m.name,m.id`,params);send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},items:rows.map(row=>machineJson(row,api.clock.nowMs(),api)),next_cursor:null},requestId);
373
373
  }
374
374
  async function revokeMachine(api,req,res,auth,machineId,requestId){
375
375
  const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine)fail(404,'not_found','not found');if(machine.custodian_id!==auth.principal_id&&!isAdmin(auth))fail(403,'forbidden','forbidden');requireRevision(req,machine.revision,'machine');const now=api.clock.nowMs(),guards=[...authGuards(auth,now),{sql:'SELECT 1 ok FROM machines WHERE team_id=? AND id=? AND revision=? AND revoked_at IS NULL AND (custodian_id=? OR ?=1)',params:[auth.team_id,machineId,machine.revision,auth.principal_id,isAdmin(auth)?1:0],code:'revision_mismatch',status:409,message:'machine changed'}],statements=[{sql:'UPDATE machines SET revoked_at=?,revision=revision+1 WHERE team_id=? AND id=?',params:[now,auth.team_id,machineId]},{sql:'UPDATE credentials SET revoked_at=? WHERE team_id=? AND machine_id=? AND revoked_at IS NULL',params:[now,auth.team_id,machineId]},{sql:"UPDATE sources SET state='revoked',revision=revision+1 WHERE team_id=? AND machine_id=?",params:[auth.team_id,machineId]},{sql:"UPDATE sync_rules SET state='removed',revision=revision+1 WHERE team_id=? AND machine_id=? AND state!='removed'",params:[auth.team_id,machineId]},{sql:"UPDATE attachments SET state='revoked',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]},{sql:'UPDATE teams SET auth_epoch=auth_epoch+1 WHERE id=?',params:[auth.team_id]}];await guardedMutation(api,res,auth,guards,statements,{status:204,body:null,targetId:machineId});api.events.publish(auth.team_id,{type:'authorization_changed',machine_id:machineId});closeMachineChannels(api,auth.team_id,machineId,{reason:'machine_revoked'});send(res,204,null,requestId);
376
376
  }
377
377
  /** This machine's sessions someone is watching live (an open follow stream), as {harness, native_id}. */
378
378
  async function watchedFor(api,teamId,machineId){const ids=[...(api.watchers?.keys()??[])].filter(key=>key.startsWith(`${teamId}:`)).map(key=>key.slice(teamId.length+1)).slice(0,90);if(!ids.length)return [];return (await api.store.query(`SELECT o.harness,o.native_id FROM origins o JOIN sources s ON s.team_id=o.team_id AND s.id=o.source_id WHERE o.team_id=? AND s.machine_id=? AND o.session_id IN (${ids.map(()=>'?').join(',')})`,[teamId,machineId,...ids])).map(({harness,native_id})=>({harness,native_id}));}
379
- async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT * FROM machines WHERE team_id=? AND id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,{...machineJson(machine,api.clock.nowMs(),api),watched:await watchedFor(api,auth.team_id,machineId)}),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,JSON.stringify(b.bindings.map(binding=>binding.id)),auth.team_id,machineId,auth.team_id,machineId]});// What each synced session's harness is doing now: this heartbeat's report replaces the source's last one.
379
+ async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(403,'forbidden','exact machine credential required');const machine=await api.store.one('SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked FROM machines m WHERE m.team_id=? AND m.id=?',[auth.team_id,machineId]);if(!machine||machine.revoked_at!=null)fail(403,'forbidden','machine enrollment is not active');const b=await parseJson(req);exact(b,['incarnation_id','sequence','bindings','roots','host_key_id','activity']);if(b.activity!==undefined&&(!Array.isArray(b.activity)||b.activity.length>4096))fail(400,'invalid_request','invalid activity');if(b.host_key_id!==undefined&&(typeof b.host_key_id!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(b.host_key_id)))fail(400,'invalid_request','invalid host_key_id');if(typeof b.incarnation_id!=='string'||!Number.isSafeInteger(b.sequence)||b.sequence<1||!Array.isArray(b.bindings)||!Array.isArray(b.roots))fail(400,'invalid_request','invalid heartbeat');if(machine.incarnation_id===b.incarnation_id&&b.sequence<=machine.heartbeat_sequence)return send(res,200,result(api,auth,{...machineJson(machine,api.clock.nowMs(),api),watched:await watchedFor(api,auth.team_id,machineId)}),requestId);const now=api.clock.nowMs(),newIncarnation=machine.incarnation_id!==null&&machine.incarnation_id!==b.incarnation_id,statements=[{sql:'UPDATE machines SET incarnation_id=?,heartbeat_sequence=?,last_seen_at=?,host_key_id=COALESCE(?,host_key_id),revision=revision+1 WHERE team_id=? AND id=?',params:[b.incarnation_id,b.sequence,now,b.host_key_id??null,auth.team_id,machineId]},{sql:'DELETE FROM machine_roots WHERE team_id=? AND machine_id=?',params:[auth.team_id,machineId]}];if(newIncarnation)statements.push({sql:"UPDATE attachments SET state='closed',closed_at=? WHERE team_id=? AND machine_id=? AND state IN ('pending','active')",params:[now,auth.team_id,machineId]},{sql:"UPDATE operations SET state='unknown' WHERE team_id=? AND id IN (SELECT operation_id FROM native_effects WHERE team_id=? AND machine_id=? AND state IN ('pending','running'))",params:[auth.team_id,auth.team_id,machineId]},{sql:"UPDATE native_effects SET state='unknown',result_json=? WHERE team_id=? AND machine_id=? AND state IN ('pending','running')",params:[JSON.stringify({state:'unknown',error:null}),auth.team_id,machineId]});statements.push({sql:'DELETE FROM bindings WHERE team_id=? AND machine_id=? AND id NOT IN (SELECT value FROM json_each(?)) AND id NOT IN (SELECT binding_id FROM attachments WHERE team_id=? AND machine_id=?) AND id NOT IN (SELECT binding_id FROM native_effects WHERE team_id=? AND machine_id=? AND binding_id IS NOT NULL)',params:[auth.team_id,machineId,JSON.stringify(b.bindings.map(binding=>binding.id)),auth.team_id,machineId,auth.team_id,machineId]});// What each synced session's harness is doing now: this heartbeat's report replaces the source's last one.
380
380
  if(Array.isArray(b.activity)){const source=await api.store.one("SELECT id FROM sources WHERE team_id=? AND machine_id=? AND kind='connector'",[auth.team_id,machineId]);if(source){statements.push({sql:'DELETE FROM session_activity WHERE team_id=? AND source_id=?',params:[auth.team_id,source.id]});for(const item of b.activity){exact(item,['harness','native_id','turn','since'],'activity');if(typeof item.harness!=='string'||!item.harness||item.harness.length>64||typeof item.native_id!=='string'||!item.native_id||item.native_id.length>256||typeof item.turn!=='string'||!item.turn||item.turn.length>32||(item.since!=null&&!Number.isSafeInteger(item.since)))fail(400,'invalid_request','invalid activity');statements.push({sql:'INSERT OR REPLACE INTO session_activity(team_id,source_id,harness,native_id,turn,since,observed_at) VALUES (?,?,?,?,?,?,?)',params:[auth.team_id,source.id,item.harness,item.native_id,item.turn,item.since??null,now]});}}}
381
381
  for(const root of b.roots){exact(root,['id','machine_id','name','harnesses'],'root');if(root.machine_id!==machineId)fail(400,'invalid_request','root machine mismatch');statements.push({sql:'INSERT INTO machine_roots(team_id,machine_id,id,value_json) VALUES (?,?,?,?)',params:[auth.team_id,machineId,root.id,JSON.stringify(root)]});}// A binding is stored against the session its native id names; one naming no session this source published is
382
382
  // said back (unbound_bindings), never dropped in silence.
@@ -385,7 +385,7 @@ async function heartbeat(api,req,res,auth,machineId,requestId){if(auth.kind!=='m
385
385
  async function machineConfiguration(api,res,auth,machineId,requestId){
386
386
  if(auth.kind!=='machine'||auth.machine_id!==machineId)fail(auth.kind==='machine'?404:403,auth.kind==='machine'?'not_found':'forbidden','not found');
387
387
  const [machine,rules]=await api.store.snapshot([
388
- {kind:'one',sql:'SELECT m.*,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error,t.auth_epoch FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id JOIN teams t ON t.id=m.team_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id AND mem.active=1 WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL',params:[auth.team_id,machineId]},
388
+ {kind:'one',sql:'SELECT m.*,(SELECT linked FROM machine_presence p WHERE p.team_id=m.team_id AND p.machine_id=m.id) presence_linked,s.id source_id,s.name source_name,s.kind source_kind,s.state source_state,s.visibility,s.revision source_revision,s.last_received_at,s.decoder,s.counters_json,s.last_error,t.auth_epoch FROM machines m JOIN sources s ON s.team_id=m.team_id AND s.machine_id=m.id JOIN teams t ON t.id=m.team_id JOIN memberships mem ON mem.team_id=m.team_id AND mem.principal_id=m.custodian_id AND mem.active=1 WHERE m.team_id=? AND m.id=? AND m.revoked_at IS NULL',params:[auth.team_id,machineId]},
389
389
  {kind:'query',sql:'SELECT r.* FROM sync_rules r JOIN machines m ON m.team_id=r.team_id AND m.id=r.machine_id JOIN sources s ON s.team_id=r.team_id AND s.id=r.source_id WHERE r.team_id=? AND r.machine_id=? AND m.revoked_at IS NULL AND s.machine_id=m.id ORDER BY r.id',params:[auth.team_id,machineId]},
390
390
  ]);
391
391
  if(!machine)fail(404,'not_found','not found');
@@ -22,16 +22,38 @@ export function machineTransition(api, team, machine, linked) {
22
22
  return next;
23
23
  }
24
24
 
25
+ /** How long after this object starts a machine's link has to dial back before the store says it unlinked: a reset of the
26
+ * object drops every link, and each connector re-dials within its backoff's most (30 s); 15 s more is its grace. */
27
+ export const LINK_GRACE_MS = 45_000;
28
+ /** Presence events retention walks per team per run. */
29
+ const PRESENCE_BATCH = 1000;
30
+
31
+ /**
32
+ * The store's presence follows the links this object holds: each link it holds is linked, and a machine the store says
33
+ * linked whose link is not held is unlinked, once the grace after this object started has passed (until then its link
34
+ * may be dialling back). Presence is read from the store only (`machine_presence`), by the machine list and the
35
+ * machine event stream alike, so the two never disagree and no reset reads every machine as unlinked (t_6c01c966).
36
+ * Week-old presence events go, walking forward by sequence from where the last run stopped.
37
+ */
25
38
  export async function reconcileMachinePresence(api) {
26
39
  await api.machineTransitionWork;
27
- for(const id of api.machineLinks?.keys()??[]){const [team,machine]=id.split('\0');await machineTransition(api,team,machine,null);}
28
- const rows = await api.store.query('SELECT team_id,machine_id FROM machine_presence WHERE linked=1');
29
- for (const row of rows) if (!api.machineLinks?.has(`${row.team_id}\0${row.machine_id}`)) await machineTransition(api,row.team_id,row.machine_id,null);
40
+ for(const id of api.machineLinks?.keys()??[]){const [team,machine]=id.split('\0');await machineTransition(api,team,machine,true);}
41
+ if (api.clock.nowMs() - (api.activatedAt ?? 0) >= LINK_GRACE_MS) {
42
+ const rows = await api.store.query('SELECT team_id,machine_id FROM machine_presence WHERE linked=1');
43
+ for (const row of rows) if (!api.machineLinks?.has(`${row.team_id}\0${row.machine_id}`)) await machineTransition(api,row.team_id,row.machine_id,false);
44
+ }
30
45
  const before=api.clock.nowMs()-7*86400000;
31
- await api.store.transaction([
32
- {sql:'INSERT INTO machine_presence_cursors SELECT team_id,MAX(sequence) FROM machine_presence_events WHERE created_at<? GROUP BY team_id ON CONFLICT(team_id) DO UPDATE SET pruned_through=MAX(pruned_through,excluded.pruned_through)',params:[before]},
33
- {sql:'DELETE FROM machine_presence_events WHERE created_at<?',params:[before]},
34
- ]);
46
+ for (const { team_id } of await api.store.query('SELECT id AS team_id FROM teams')) {
47
+ const kept = await api.store.one('SELECT pruned_through,deleted_through FROM machine_presence_cursors WHERE team_id=?', [team_id]);
48
+ const from = kept?.pruned_through ?? 0;
49
+ const ahead = await api.store.query(`SELECT sequence,created_at FROM machine_presence_events WHERE team_id=? AND sequence>? ORDER BY sequence LIMIT ${PRESENCE_BATCH}`, [team_id, from]);
50
+ let through = from; for (const row of ahead) { if (row.created_at >= before) break; through = row.sequence; }
51
+ if (through === from && kept) continue;
52
+ await api.store.transaction([
53
+ {sql:'DELETE FROM machine_presence_events WHERE team_id=? AND sequence>? AND sequence<=?',params:[team_id,kept?.deleted_through??0,through]},
54
+ {sql:'INSERT INTO machine_presence_cursors(team_id,pruned_through,deleted_through) VALUES (?,?,?) ON CONFLICT(team_id) DO UPDATE SET pruned_through=MAX(pruned_through,excluded.pruned_through),deleted_through=MAX(deleted_through,excluded.deleted_through)',params:[team_id,through,through]},
55
+ ]);
56
+ }
35
57
  }
36
58
 
37
59
  export async function machineEventStream({api,req,res,auth,url,requestId,fail}) {
@@ -0,0 +1,126 @@
1
+ // `maintain` runs on the object's alarm every minute, so none of its steps may cost the store: each costs what it
2
+ // removes (t_6c01c966: the one Durable Object exceeded its memory about once a minute, and each reset dropped every
3
+ // machine's link). This migration runs at activation, before the object serves, so it is constant cost: it adds two
4
+ // columns, new tables and triggers, and builds no index over an existing table and copies no existing row.
5
+ // - What can only be decided across tables (a version no one holds, an upload nothing names, a blob no chunk names, a
6
+ // committed capture's parts) is queued by the write that may make it so. A queue row carries a generation each write
7
+ // advances, and `maintain` removes only the generation it read (a write between its read and its removal stays).
8
+ // - What `maintain` would otherwise find by searching (each version's source, an upload's references, a version a live
9
+ // cursor holds, the captures and uploads still in flight) is kept as rows by the writes that make it so.
10
+ // - Rows written before this migration are walked into those tables by `maintain` itself, a bounded batch per run,
11
+ // resumably (`maintain_backfill`); each consumer waits until the walks it relies on are done.
12
+ export default `
13
+ ALTER TABLE event_retention ADD COLUMN deleted_through INTEGER NOT NULL DEFAULT 0;
14
+ ALTER TABLE machine_presence_cursors ADD COLUMN deleted_through INTEGER NOT NULL DEFAULT 0;
15
+
16
+ CREATE TABLE maintain_backfill (name TEXT PRIMARY KEY, after_rowid INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0) STRICT;
17
+
18
+ -- each version's source (its projection's source_id), and the versions a live list, search or history cursor holds
19
+ CREATE TABLE version_sources (team_id TEXT NOT NULL, session_id TEXT NOT NULL, source_id TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(team_id, session_id, source_id, revision)) STRICT, WITHOUT ROWID;
20
+ CREATE TRIGGER version_sources_insert AFTER INSERT ON session_versions BEGIN
21
+ INSERT OR IGNORE INTO version_sources VALUES (NEW.team_id, NEW.session_id, COALESCE(json_extract(NEW.source_projection_json,'$.source_id'),''), NEW.revision);
22
+ INSERT INTO version_prune_queue(team_id,session_id,due_at,generation) VALUES (NEW.team_id,NEW.session_id,0,1)
23
+ ON CONFLICT(team_id,session_id) DO UPDATE SET due_at=0, generation=generation+1;
24
+ END;
25
+ CREATE TRIGGER version_sources_delete AFTER DELETE ON session_versions BEGIN
26
+ DELETE FROM version_sources WHERE team_id=OLD.team_id AND session_id=OLD.session_id AND source_id=COALESCE(json_extract(OLD.source_projection_json,'$.source_id'),'') AND revision=OLD.revision;
27
+ END;
28
+ CREATE TABLE cursor_pins (cursor_id TEXT NOT NULL, team_id TEXT NOT NULL, session_id TEXT NOT NULL, revision INTEGER NOT NULL, expires_at INTEGER NOT NULL) STRICT;
29
+ CREATE INDEX cursor_pins_version ON cursor_pins(team_id, session_id, revision, expires_at);
30
+ CREATE INDEX cursor_pins_session ON cursor_pins(team_id, session_id, expires_at);
31
+ CREATE INDEX cursor_pins_cursor ON cursor_pins(cursor_id);
32
+ CREATE TRIGGER cursor_pins_insert AFTER INSERT ON cursors WHEN NEW.snapshot_json IS NOT NULL BEGIN
33
+ INSERT INTO cursor_pins SELECT NEW.id,NEW.team_id,json_extract(j.value,'$[0]'),json_extract(j.value,'$[1]'),NEW.expires_at
34
+ FROM json_each(NEW.snapshot_json) j WHERE NEW.kind IN ('list','search') AND json_extract(j.value,'$[0]') IS NOT NULL AND json_extract(j.value,'$[1]') IS NOT NULL;
35
+ INSERT INTO cursor_pins SELECT NEW.id,NEW.team_id,json_extract(NEW.snapshot_json,'$.session_id'),json_extract(NEW.snapshot_json,'$.revision'),NEW.expires_at
36
+ WHERE NEW.kind='history' AND json_extract(NEW.snapshot_json,'$.session_id') IS NOT NULL AND json_extract(NEW.snapshot_json,'$.revision') IS NOT NULL;
37
+ END;
38
+ CREATE TRIGGER cursor_pins_update AFTER UPDATE OF snapshot_json, expires_at, kind ON cursors BEGIN
39
+ DELETE FROM cursor_pins WHERE cursor_id=OLD.id;
40
+ INSERT INTO cursor_pins SELECT NEW.id,NEW.team_id,json_extract(j.value,'$[0]'),json_extract(j.value,'$[1]'),NEW.expires_at
41
+ FROM json_each(COALESCE(NEW.snapshot_json,'[]')) j WHERE NEW.kind IN ('list','search') AND json_extract(j.value,'$[0]') IS NOT NULL AND json_extract(j.value,'$[1]') IS NOT NULL;
42
+ INSERT INTO cursor_pins SELECT NEW.id,NEW.team_id,json_extract(NEW.snapshot_json,'$.session_id'),json_extract(NEW.snapshot_json,'$.revision'),NEW.expires_at
43
+ WHERE NEW.kind='history' AND json_extract(NEW.snapshot_json,'$.session_id') IS NOT NULL AND json_extract(NEW.snapshot_json,'$.revision') IS NOT NULL;
44
+ END;
45
+ CREATE TRIGGER cursor_pins_delete AFTER DELETE ON cursors BEGIN
46
+ DELETE FROM cursor_pins WHERE cursor_id=OLD.id;
47
+ END;
48
+ CREATE TABLE version_prune_queue (team_id TEXT NOT NULL, session_id TEXT NOT NULL, due_at INTEGER NOT NULL, generation INTEGER NOT NULL, PRIMARY KEY(team_id, session_id)) STRICT;
49
+ CREATE INDEX version_prune_due ON version_prune_queue(due_at);
50
+
51
+ -- what names each upload: an artifact, a continuation, an in-flight capture (holder: a:, c:, p: and its key)
52
+ CREATE TABLE upload_refs (team_id TEXT NOT NULL, upload_id TEXT NOT NULL, holder TEXT NOT NULL, PRIMARY KEY(team_id, upload_id, holder)) STRICT, WITHOUT ROWID;
53
+ CREATE INDEX upload_refs_holder ON upload_refs(holder);
54
+ CREATE TABLE upload_prune_queue (team_id TEXT NOT NULL, upload_id TEXT NOT NULL, generation INTEGER NOT NULL, PRIMARY KEY(team_id, upload_id)) STRICT;
55
+ CREATE TRIGGER upload_queue_on_commit AFTER UPDATE OF committed ON uploads WHEN NEW.committed=1 BEGIN
56
+ INSERT INTO upload_prune_queue VALUES (NEW.team_id,NEW.id,1) ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
57
+ DELETE FROM pending_uploads WHERE team_id=NEW.team_id AND id=NEW.id;
58
+ END;
59
+ CREATE TRIGGER upload_queue_on_insert AFTER INSERT ON uploads BEGIN
60
+ INSERT INTO upload_prune_queue SELECT NEW.team_id,NEW.id,1 WHERE NEW.committed=1 ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
61
+ INSERT OR IGNORE INTO pending_uploads SELECT NEW.team_id,NEW.id,NEW.source_id,NEW.created_at WHERE NEW.committed=0;
62
+ END;
63
+ CREATE TRIGGER upload_queue_on_delete AFTER DELETE ON uploads BEGIN
64
+ DELETE FROM pending_uploads WHERE team_id=OLD.team_id AND id=OLD.id;
65
+ END;
66
+ CREATE TRIGGER upload_refs_artifact_insert AFTER INSERT ON session_artifacts BEGIN
67
+ INSERT OR IGNORE INTO upload_refs VALUES (NEW.team_id, NEW.upload_id, 'a:'||NEW.session_id||':'||NEW.revision||':'||NEW.bundle_id);
68
+ END;
69
+ CREATE TRIGGER upload_refs_artifact_delete AFTER DELETE ON session_artifacts BEGIN
70
+ DELETE FROM upload_refs WHERE team_id=OLD.team_id AND upload_id=OLD.upload_id AND holder='a:'||OLD.session_id||':'||OLD.revision||':'||OLD.bundle_id;
71
+ INSERT INTO upload_prune_queue VALUES (OLD.team_id,OLD.upload_id,1) ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
72
+ END;
73
+ CREATE TRIGGER upload_refs_continuation_insert AFTER INSERT ON continuations BEGIN
74
+ INSERT OR IGNORE INTO upload_refs SELECT NEW.team_id, substr(t.value, instr(t.value,'upload_'), 39), 'c:'||NEW.id
75
+ FROM json_tree(NEW.value_json) t WHERE t.type='text' AND instr(t.value,'upload_')>0;
76
+ END;
77
+ CREATE TRIGGER upload_refs_continuation_update AFTER UPDATE OF value_json ON continuations BEGIN
78
+ INSERT INTO upload_prune_queue SELECT team_id,upload_id,1 FROM upload_refs WHERE holder='c:'||OLD.id ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
79
+ DELETE FROM upload_refs WHERE holder='c:'||OLD.id;
80
+ INSERT OR IGNORE INTO upload_refs SELECT NEW.team_id, substr(t.value, instr(t.value,'upload_'), 39), 'c:'||NEW.id
81
+ FROM json_tree(NEW.value_json) t WHERE t.type='text' AND instr(t.value,'upload_')>0;
82
+ END;
83
+ CREATE TRIGGER upload_refs_continuation_delete AFTER DELETE ON continuations BEGIN
84
+ INSERT INTO upload_prune_queue SELECT team_id,upload_id,1 FROM upload_refs WHERE holder='c:'||OLD.id ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
85
+ DELETE FROM upload_refs WHERE holder='c:'||OLD.id;
86
+ END;
87
+
88
+ -- captures and uploads still in flight (expired after a day), and a committed capture's parts to remove
89
+ CREATE TABLE staging_captures (team_id TEXT NOT NULL, id TEXT NOT NULL, source_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(team_id, id)) STRICT;
90
+ CREATE INDEX staging_captures_created ON staging_captures(created_at);
91
+ CREATE TABLE pending_uploads (team_id TEXT NOT NULL, id TEXT NOT NULL, source_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(team_id, id)) STRICT;
92
+ CREATE INDEX pending_uploads_created ON pending_uploads(created_at);
93
+ CREATE TABLE parts_prune_queue (team_id TEXT NOT NULL, capture_id TEXT NOT NULL, generation INTEGER NOT NULL, PRIMARY KEY(team_id, capture_id)) STRICT;
94
+ CREATE TRIGGER capture_staging_insert AFTER INSERT ON captures WHEN NEW.state='staging' BEGIN
95
+ INSERT OR IGNORE INTO staging_captures VALUES (NEW.team_id, NEW.id, NEW.source_id, NEW.created_at);
96
+ INSERT OR IGNORE INTO upload_refs SELECT NEW.team_id, substr(t.value, instr(t.value,'upload_'), 39), 'p:'||NEW.id
97
+ FROM json_tree(NEW.begin_json) t WHERE t.type='text' AND instr(t.value,'upload_')>0;
98
+ END;
99
+ CREATE TRIGGER capture_staging_end AFTER UPDATE OF state ON captures WHEN OLD.state='staging' AND NEW.state<>'staging' BEGIN
100
+ DELETE FROM staging_captures WHERE team_id=OLD.team_id AND id=OLD.id;
101
+ INSERT INTO upload_prune_queue SELECT team_id,upload_id,1 FROM upload_refs WHERE holder='p:'||OLD.id ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
102
+ DELETE FROM upload_refs WHERE holder='p:'||OLD.id;
103
+ END;
104
+ CREATE TRIGGER capture_committed AFTER UPDATE OF state ON captures WHEN NEW.state='committed' BEGIN
105
+ INSERT INTO parts_prune_queue VALUES (NEW.team_id, NEW.id, 1) ON CONFLICT(team_id,capture_id) DO UPDATE SET generation=generation+1;
106
+ END;
107
+ CREATE TRIGGER capture_staging_delete AFTER DELETE ON captures WHEN OLD.state='staging' BEGIN
108
+ DELETE FROM staging_captures WHERE team_id=OLD.team_id AND id=OLD.id;
109
+ INSERT INTO upload_prune_queue SELECT team_id,upload_id,1 FROM upload_refs WHERE holder='p:'||OLD.id ON CONFLICT(team_id,upload_id) DO UPDATE SET generation=generation+1;
110
+ DELETE FROM upload_refs WHERE holder='p:'||OLD.id;
111
+ END;
112
+
113
+ -- a blob a chunk naming it no longer names
114
+ CREATE TABLE blob_prune_queue (team_id TEXT NOT NULL, sha256 TEXT NOT NULL, generation INTEGER NOT NULL, PRIMARY KEY(team_id, sha256)) STRICT;
115
+ CREATE TRIGGER blob_queue_on_chunk AFTER DELETE ON artifact_chunk_refs BEGIN
116
+ INSERT INTO blob_prune_queue VALUES (OLD.team_id, OLD.sha256, 1) ON CONFLICT(team_id,sha256) DO UPDATE SET generation=generation+1;
117
+ END;
118
+
119
+ -- an event kept only as its resource's current one (retention's deletes passed it) goes once a newer event replaces it
120
+ CREATE TRIGGER event_retention_superseded AFTER UPDATE OF sequence ON event_resources WHEN OLD.sequence<>NEW.sequence BEGIN
121
+ DELETE FROM org_events WHERE sequence=OLD.sequence AND sequence<=COALESCE((SELECT deleted_through FROM event_retention WHERE team_id=OLD.team_id),0);
122
+ END;
123
+ CREATE TRIGGER event_retention_released AFTER DELETE ON event_resources BEGIN
124
+ DELETE FROM org_events WHERE sequence=OLD.sequence AND sequence<=COALESCE((SELECT deleted_through FROM event_retention WHERE team_id=OLD.team_id),0);
125
+ END;
126
+ `;
@@ -21,7 +21,8 @@ import m019 from './019.mjs';
21
21
  import m020 from './020.mjs';
22
22
  import m021 from './021.mjs';
23
23
  import m022 from './022.mjs';
24
- 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];
24
+ import m023 from './023.mjs';
25
+ 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];
25
26
  /** A message row's `until_revision` while its session's current version still holds it (schema 9). */
26
27
  export const OPEN_REVISION = Number.MAX_SAFE_INTEGER;
27
28
  /** SQL: message row `m` belongs to the version at revision ?1 (bind the revision twice). */