@volter/teams 0.2.25 → 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/package.json +1 -1
- package/server/apps.mjs +8 -2
- package/server/core.mjs +228 -72
- package/server/http.mjs +7 -7
- package/server/machine-events.mjs +29 -7
- package/server/migrations/023.mjs +126 -0
- package/server/migrations/index.mjs +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
])
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
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?(
|
|
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
|
|
372
|
-
const rows=await api.store.query(`SELECT m
|
|
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
|
|
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,
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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). */
|