@agnt-sdk/simon-server 0.0.0-stage → 0.1.0
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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +317 -2
- package/dist/account.d.ts +59 -0
- package/dist/account.js +244 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +96 -0
- package/dist/config.d.ts +219 -0
- package/dist/config.js +300 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +2 -0
- package/dist/continuity.d.ts +35 -0
- package/dist/continuity.js +74 -0
- package/dist/deliver.d.ts +22 -0
- package/dist/deliver.js +208 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +11 -0
- package/dist/extensions.d.ts +216 -0
- package/dist/extensions.js +75 -0
- package/dist/fanout.d.ts +32 -0
- package/dist/fanout.js +79 -0
- package/dist/http.d.ts +69 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +41 -0
- package/dist/net/dns.d.ts +31 -0
- package/dist/net/dns.js +100 -0
- package/dist/net/fetch.d.ts +48 -0
- package/dist/net/fetch.js +173 -0
- package/dist/net/ip.d.ts +38 -0
- package/dist/net/ip.js +159 -0
- package/dist/net/psl.d.ts +22 -0
- package/dist/net/psl.js +91 -0
- package/dist/net/public-suffixes.d.ts +10 -0
- package/dist/net/public-suffixes.js +6885 -0
- package/dist/notice.d.ts +5 -0
- package/dist/notice.js +61 -0
- package/dist/outbox.d.ts +14 -0
- package/dist/outbox.js +356 -0
- package/dist/parts.d.ts +29 -0
- package/dist/parts.js +443 -0
- package/dist/public.d.ts +43 -0
- package/dist/public.js +134 -0
- package/dist/published.d.ts +3 -0
- package/dist/published.js +140 -0
- package/dist/rate.d.ts +28 -0
- package/dist/rate.js +77 -0
- package/dist/readers.d.ts +31 -0
- package/dist/readers.js +48 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.js +401 -0
- package/dist/resolver.d.ts +140 -0
- package/dist/resolver.js +386 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +216 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +234 -0
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +137 -0
- package/dist/store/contract.d.ts +4 -0
- package/dist/store/contract.js +1177 -0
- package/dist/store/sqlite-migration.d.ts +82 -0
- package/dist/store/sqlite-migration.js +1019 -0
- package/dist/store/sqlite.d.ts +108 -0
- package/dist/store/sqlite.js +992 -0
- package/dist/store/store.d.ts +400 -0
- package/dist/store/store.js +23 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.js +199 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +87 -0
- package/dist/webhooks.d.ts +10 -0
- package/dist/webhooks.js +162 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +33 -0
- package/package.json +57 -4
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Store on node:sqlite. One connection, WAL, and every multi-statement change in one IMMEDIATE
|
|
3
|
+
* transaction, so each method is atomic for this process and serialised against any other process sharing
|
|
4
|
+
* the file. Times are stored as epoch milliseconds, so comparisons never depend on how a timestamp is spelled.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { canonicalize, header, namedFiles } from '@agnt-sdk/simon-protocol';
|
|
8
|
+
import { MAX_STORED_KEYS, StoreCapacityError, resolveRetractionCapacity } from "./store.js";
|
|
9
|
+
/** The schema's version, kept in `PRAGMA user_version`. A file made by another version is refused. */
|
|
10
|
+
const SCHEMA_VERSION = 9;
|
|
11
|
+
const SCHEMA = `
|
|
12
|
+
CREATE TABLE agents (address TEXT PRIMARY KEY, kind TEXT NOT NULL, principal TEXT, mailbox INTEGER NOT NULL,
|
|
13
|
+
created_at TEXT NOT NULL, retired_at TEXT, unpairing TEXT, authority_version INTEGER NOT NULL DEFAULT 0, recovery_key_cancelled_at TEXT, authorized_keys TEXT,
|
|
14
|
+
recovery_epoch INTEGER NOT NULL DEFAULT 0);
|
|
15
|
+
CREATE TABLE agent_keys (key TEXT PRIMARY KEY, address TEXT NOT NULL, added TEXT NOT NULL, revoked TEXT,
|
|
16
|
+
seq INTEGER NOT NULL, label TEXT);
|
|
17
|
+
CREATE INDEX agent_keys_address ON agent_keys (address);
|
|
18
|
+
CREATE TABLE messages (id TEXT PRIMARY KEY, json TEXT NOT NULL, from_address TEXT NOT NULL, public INTEGER NOT NULL,
|
|
19
|
+
in_reply_to TEXT, carrier TEXT NOT NULL, claims TEXT NOT NULL, signed_claims TEXT, received_at TEXT NOT NULL,
|
|
20
|
+
expires INTEGER NOT NULL, kept INTEGER NOT NULL, cursor INTEGER, taken INTEGER, generation TEXT NOT NULL);
|
|
21
|
+
CREATE INDEX messages_expires ON messages (expires);
|
|
22
|
+
CREATE INDEX messages_written ON messages (from_address, cursor) WHERE cursor IS NOT NULL;
|
|
23
|
+
CREATE INDEX messages_published ON messages (from_address) WHERE public = 1;
|
|
24
|
+
CREATE INDEX messages_in_reply_to ON messages (in_reply_to);
|
|
25
|
+
CREATE TABLE message_parts (id TEXT NOT NULL, sha256 TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY (id, sha256));
|
|
26
|
+
CREATE INDEX message_parts_sha ON message_parts (sha256);
|
|
27
|
+
CREATE TABLE retraction_admission(singleton INTEGER PRIMARY KEY CHECK(singleton=1),body TEXT NOT NULL);
|
|
28
|
+
INSERT INTO retraction_admission VALUES(1,'{}');
|
|
29
|
+
CREATE TABLE listing_retractions(owner TEXT NOT NULL,target TEXT NOT NULL,author TEXT NOT NULL,retraction TEXT NOT NULL,
|
|
30
|
+
PRIMARY KEY(owner,retraction));
|
|
31
|
+
CREATE INDEX listing_retractions_target ON listing_retractions(owner,target,author);
|
|
32
|
+
CREATE TABLE arrivals (id TEXT NOT NULL, recipient TEXT NOT NULL, hints TEXT NOT NULL, attempts INTEGER NOT NULL,
|
|
33
|
+
due INTEGER NOT NULL, PRIMARY KEY (id, recipient));
|
|
34
|
+
CREATE INDEX arrivals_due ON arrivals (due);
|
|
35
|
+
CREATE TABLE positions (n INTEGER NOT NULL);
|
|
36
|
+
INSERT INTO positions VALUES (0);
|
|
37
|
+
CREATE TABLE inbox (cursor INTEGER PRIMARY KEY, address TEXT NOT NULL, id TEXT NOT NULL, taken INTEGER NOT NULL,
|
|
38
|
+
event INTEGER NOT NULL, UNIQUE (address, id));
|
|
39
|
+
CREATE INDEX inbox_address ON inbox (address, cursor);
|
|
40
|
+
CREATE INDEX inbox_id ON inbox (id);
|
|
41
|
+
CREATE INDEX inbox_events ON inbox (cursor) WHERE event = 1;
|
|
42
|
+
CREATE TABLE signals (cursor INTEGER PRIMARY KEY, address TEXT NOT NULL, signal TEXT NOT NULL, expires INTEGER NOT NULL);
|
|
43
|
+
CREATE INDEX signals_address ON signals (address, cursor);
|
|
44
|
+
CREATE INDEX signals_expires ON signals (expires);
|
|
45
|
+
CREATE TABLE webhooks (id TEXT PRIMARY KEY, address TEXT NOT NULL, url TEXT NOT NULL, code_hash TEXT NOT NULL,
|
|
46
|
+
verified INTEGER NOT NULL, created_at TEXT NOT NULL, failing_since TEXT, due INTEGER, quiet_until INTEGER NOT NULL, lease TEXT, lease_until INTEGER);
|
|
47
|
+
CREATE INDEX webhooks_address ON webhooks (address);
|
|
48
|
+
CREATE INDEX webhooks_due ON webhooks (due) WHERE due IS NOT NULL;
|
|
49
|
+
CREATE TABLE deliveries (id TEXT NOT NULL, domain TEXT NOT NULL, recipients TEXT NOT NULL, hints TEXT NOT NULL,
|
|
50
|
+
server TEXT, status TEXT NOT NULL, reason TEXT, attempts INTEGER NOT NULL, next_attempt INTEGER NOT NULL,
|
|
51
|
+
created_at TEXT NOT NULL, deadline TEXT NOT NULL, lease TEXT, PRIMARY KEY (id, domain));
|
|
52
|
+
CREATE INDEX deliveries_due ON deliveries (status, next_attempt);
|
|
53
|
+
CREATE TABLE parts (sha256 TEXT NOT NULL, source TEXT NOT NULL, fetched INTEGER NOT NULL, size INTEGER NOT NULL,
|
|
54
|
+
bytes BLOB NOT NULL, created INTEGER NOT NULL, PRIMARY KEY (sha256, source));
|
|
55
|
+
CREATE TABLE fetches (sha256 TEXT NOT NULL, carrier TEXT NOT NULL, size INTEGER NOT NULL, attempts INTEGER NOT NULL,
|
|
56
|
+
due INTEGER NOT NULL, token TEXT, exhausted INTEGER, revision INTEGER NOT NULL DEFAULT 0, generation TEXT NOT NULL, PRIMARY KEY (sha256, carrier));
|
|
57
|
+
CREATE INDEX fetches_due ON fetches (due);
|
|
58
|
+
CREATE TABLE marks (scope TEXT NOT NULL, id TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY (scope, id));
|
|
59
|
+
CREATE TABLE counters (scope TEXT NOT NULL, id TEXT NOT NULL, n INTEGER NOT NULL, expires INTEGER NOT NULL,
|
|
60
|
+
PRIMARY KEY (scope, id));
|
|
61
|
+
CREATE TABLE vals (scope TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, expires INTEGER,
|
|
62
|
+
PRIMARY KEY (scope, id));
|
|
63
|
+
PRAGMA user_version = ${SCHEMA_VERSION};
|
|
64
|
+
`;
|
|
65
|
+
const ms = (t) => {
|
|
66
|
+
const n = Date.parse(t);
|
|
67
|
+
if (Number.isNaN(n))
|
|
68
|
+
throw new TypeError(`Not a timestamp: ${t}`);
|
|
69
|
+
return n;
|
|
70
|
+
};
|
|
71
|
+
const iso = (n) => new Date(n).toISOString();
|
|
72
|
+
/** A part's source as one column: the uploader's address, or the carrier's origin (never an address). */
|
|
73
|
+
const sourceKey = (s) => ('uploader' in s ? s.uploader : s.carrier);
|
|
74
|
+
/** Sources found along more than one path, once each. */
|
|
75
|
+
const uniqueSources = (sources) => [
|
|
76
|
+
...new Map(sources.map(s => [`${'uploader' in s ? 'u' : 'c'} ${sourceKey(s)} ${s.size}`, s])).values(),
|
|
77
|
+
];
|
|
78
|
+
/** A version 1 message's `inReplyTo`. Other versions point at nothing the server can see. */
|
|
79
|
+
const inReplyTo = (m) => (m.v === 1 ? (m.inReplyTo ?? null) : null);
|
|
80
|
+
/** A page of rows, newest first, fetched one beyond `limit` to tell whether there are more. */
|
|
81
|
+
const page = (rows, limit, record) => ({
|
|
82
|
+
records: rows.slice(0, limit).map(r => ({ cursor: r.id, message: record(r) })),
|
|
83
|
+
more: rows.length > limit,
|
|
84
|
+
});
|
|
85
|
+
/**
|
|
86
|
+
* The files a version 1 message names by content address, its own and those of messages it carries whole
|
|
87
|
+
* (`namedFiles`). Other versions name none the server can see.
|
|
88
|
+
*/
|
|
89
|
+
const namedParts = (m) => namedFiles(m);
|
|
90
|
+
export class SqliteStore {
|
|
91
|
+
#retractionCapacity;
|
|
92
|
+
#db;
|
|
93
|
+
constructor(path = ':memory:', options = {}) {
|
|
94
|
+
this.#retractionCapacity = resolveRetractionCapacity(options.retractions);
|
|
95
|
+
// node:sqlite is loaded when a SqliteStore is made, never on import: an application on another Store never
|
|
96
|
+
// loads it, nor sees the warnings some Node versions print for it.
|
|
97
|
+
const { DatabaseSync } = process.getBuiltinModule('node:sqlite');
|
|
98
|
+
this.#db = new DatabaseSync(path);
|
|
99
|
+
this.#db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = OFF;');
|
|
100
|
+
try {
|
|
101
|
+
this.#tx(() => {
|
|
102
|
+
// Creation and upgrade both recheck state under the same write lock.
|
|
103
|
+
if (!this.#get("SELECT 1 FROM sqlite_master WHERE type='table'")) {
|
|
104
|
+
this.#db.exec(SCHEMA);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Inspect and migrate again under the write lock: another opener may have upgraded first.
|
|
108
|
+
const locked = this.#get('PRAGMA user_version').user_version;
|
|
109
|
+
if (locked === SCHEMA_VERSION)
|
|
110
|
+
return;
|
|
111
|
+
if (locked !== 3 && locked !== 4 && locked !== 5 && locked !== 6 && locked !== 7 && locked !== 8) {
|
|
112
|
+
throw new Error(`${path} was made by another version of this server (schema ${locked}, not ${SCHEMA_VERSION}).`);
|
|
113
|
+
}
|
|
114
|
+
const columns = (table) => this.#all(`PRAGMA table_info(${table})`).map(c => c.name);
|
|
115
|
+
if (!columns('agents').includes('unpairing'))
|
|
116
|
+
this.#db.exec('ALTER TABLE agents ADD COLUMN unpairing TEXT;');
|
|
117
|
+
if (!columns('agents').includes('authority_version'))
|
|
118
|
+
this.#db.exec('ALTER TABLE agents ADD COLUMN authority_version INTEGER NOT NULL DEFAULT 0;');
|
|
119
|
+
if (!columns('agents').includes('recovery_key_cancelled_at'))
|
|
120
|
+
this.#db.exec('ALTER TABLE agents ADD COLUMN recovery_key_cancelled_at TEXT;');
|
|
121
|
+
if (!columns('agents').includes('authorized_keys'))
|
|
122
|
+
this.#db.exec('ALTER TABLE agents ADD COLUMN authorized_keys TEXT;');
|
|
123
|
+
if (!columns('agents').includes('recovery_epoch'))
|
|
124
|
+
this.#db.exec('ALTER TABLE agents ADD COLUMN recovery_epoch INTEGER NOT NULL DEFAULT 0;');
|
|
125
|
+
if (!columns('agent_keys').includes('label'))
|
|
126
|
+
this.#db.exec('ALTER TABLE agent_keys ADD COLUMN label TEXT;');
|
|
127
|
+
const historicalInboxTop = this.#get("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'")
|
|
128
|
+
? (this.#get("SELECT seq FROM sqlite_sequence WHERE name='inbox'")?.seq ?? 0)
|
|
129
|
+
: 0;
|
|
130
|
+
const messageColumns = columns('messages');
|
|
131
|
+
if (!messageColumns.includes('cursor')) {
|
|
132
|
+
this.#db.exec('ALTER TABLE messages ADD COLUMN cursor INTEGER; ALTER TABLE messages ADD COLUMN taken INTEGER;');
|
|
133
|
+
}
|
|
134
|
+
if (!columns('messages').includes('generation')) {
|
|
135
|
+
this.#db.exec('ALTER TABLE messages ADD COLUMN generation TEXT;');
|
|
136
|
+
for (const row of this.#all('SELECT id FROM messages'))
|
|
137
|
+
this.#run('UPDATE messages SET generation=? WHERE id=?', randomUUID(), row.id);
|
|
138
|
+
}
|
|
139
|
+
if (!columns('inbox').includes('taken')) {
|
|
140
|
+
this.#db.exec(`ALTER TABLE inbox RENAME TO inbox_legacy;
|
|
141
|
+
CREATE TABLE inbox(cursor INTEGER PRIMARY KEY, address TEXT NOT NULL, id TEXT NOT NULL, taken INTEGER NOT NULL,
|
|
142
|
+
event INTEGER NOT NULL, UNIQUE(address,id));
|
|
143
|
+
INSERT INTO inbox SELECT cursor,address,id,CAST(ROUND((julianday(received_at)-2440587.5)*86400000) AS INTEGER),event FROM inbox_legacy;
|
|
144
|
+
DROP TABLE inbox_legacy;
|
|
145
|
+
CREATE INDEX inbox_id ON inbox(id);
|
|
146
|
+
CREATE INDEX inbox_events ON inbox(cursor) WHERE event=1;
|
|
147
|
+
CREATE INDEX inbox_address ON inbox(address,cursor);`);
|
|
148
|
+
}
|
|
149
|
+
this.#db.exec(`CREATE TABLE IF NOT EXISTS positions (n INTEGER NOT NULL);
|
|
150
|
+
INSERT INTO positions SELECT COALESCE((SELECT MAX(cursor) FROM inbox), 0) WHERE NOT EXISTS (SELECT 1 FROM positions);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS messages_written ON messages(from_address, cursor) WHERE cursor IS NOT NULL;
|
|
152
|
+
CREATE TABLE IF NOT EXISTS signals(cursor INTEGER PRIMARY KEY, address TEXT NOT NULL, signal TEXT NOT NULL, expires INTEGER NOT NULL);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS signals_address ON signals(address, cursor);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS signals_expires ON signals(expires);
|
|
155
|
+
CREATE TABLE IF NOT EXISTS webhooks(id TEXT PRIMARY KEY, address TEXT NOT NULL, url TEXT NOT NULL, code_hash TEXT NOT NULL,
|
|
156
|
+
verified INTEGER NOT NULL, created_at TEXT NOT NULL, failing_since TEXT, due INTEGER, quiet_until INTEGER NOT NULL);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS webhooks_address ON webhooks(address);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS webhooks_due ON webhooks(due) WHERE due IS NOT NULL;`);
|
|
159
|
+
const high = this.#get('SELECT MAX(n) AS n FROM positions')?.n;
|
|
160
|
+
const surviving = this.#get('SELECT MAX(n) AS n FROM (SELECT MAX(cursor) AS n FROM inbox UNION ALL SELECT MAX(cursor) FROM messages UNION ALL SELECT MAX(cursor) FROM signals)')?.n;
|
|
161
|
+
this.#run('DELETE FROM positions');
|
|
162
|
+
this.#run('INSERT INTO positions VALUES(?)', Math.max(historicalInboxTop, high ?? 0, surviving ?? 0));
|
|
163
|
+
if (!columns('fetches').includes('revision'))
|
|
164
|
+
this.#db.exec('ALTER TABLE fetches ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;');
|
|
165
|
+
if (!columns('fetches').includes('generation')) {
|
|
166
|
+
this.#db.exec('ALTER TABLE fetches ADD COLUMN generation TEXT;');
|
|
167
|
+
for (const row of this.#all('SELECT sha256,carrier FROM fetches'))
|
|
168
|
+
this.#run('UPDATE fetches SET generation=? WHERE sha256=? AND carrier=?', randomUUID(), row.sha256, row.carrier);
|
|
169
|
+
}
|
|
170
|
+
if (!columns('fetches').includes('token'))
|
|
171
|
+
this.#db.exec('ALTER TABLE fetches ADD COLUMN token TEXT; ALTER TABLE fetches ADD COLUMN exhausted INTEGER;');
|
|
172
|
+
if (!columns('webhooks').includes('lease'))
|
|
173
|
+
this.#db.exec('ALTER TABLE webhooks ADD COLUMN lease TEXT; ALTER TABLE webhooks ADD COLUMN lease_until INTEGER;');
|
|
174
|
+
this.#db
|
|
175
|
+
.exec(`CREATE TABLE IF NOT EXISTS listing_retractions(owner TEXT NOT NULL,target TEXT NOT NULL,author TEXT NOT NULL,retraction TEXT NOT NULL,PRIMARY KEY(owner,retraction));
|
|
176
|
+
CREATE INDEX IF NOT EXISTS listing_retractions_target ON listing_retractions(owner,target,author);
|
|
177
|
+
CREATE TABLE IF NOT EXISTS retraction_admission(singleton INTEGER PRIMARY KEY CHECK(singleton=1),body TEXT NOT NULL);
|
|
178
|
+
INSERT OR IGNORE INTO retraction_admission VALUES(1,'{}');
|
|
179
|
+
PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
180
|
+
if (locked < 8)
|
|
181
|
+
this.#run("INSERT OR IGNORE INTO vals(scope,id,value,expires) SELECT 'sync-history-gap',address,'true',NULL FROM agents");
|
|
182
|
+
});
|
|
183
|
+
this.#tx(() => this.#bootstrapRetractions());
|
|
184
|
+
// Indexes that change no row: made on every open, so a store of any schema version has them.
|
|
185
|
+
this.#db.exec('CREATE INDEX IF NOT EXISTS fetches_carrier ON fetches (carrier, exhausted);');
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
this.#db.close();
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
#all(sql, ...params) {
|
|
193
|
+
return this.#db.prepare(sql).all(...params);
|
|
194
|
+
}
|
|
195
|
+
#get(sql, ...params) {
|
|
196
|
+
return this.#db.prepare(sql).get(...params);
|
|
197
|
+
}
|
|
198
|
+
#run(sql, ...params) {
|
|
199
|
+
return this.#db.prepare(sql).run(...params);
|
|
200
|
+
}
|
|
201
|
+
#tx(work) {
|
|
202
|
+
this.#db.exec('BEGIN IMMEDIATE');
|
|
203
|
+
try {
|
|
204
|
+
const result = work();
|
|
205
|
+
this.#db.exec('COMMIT');
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
this.#db.exec('ROLLBACK');
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Agents
|
|
214
|
+
#agent(address) {
|
|
215
|
+
const a = this.#get('SELECT * FROM agents WHERE address = ?', address);
|
|
216
|
+
if (!a)
|
|
217
|
+
return null;
|
|
218
|
+
const keys = this.#all('SELECT key, added, revoked, label FROM agent_keys WHERE address = ? ORDER BY seq', address);
|
|
219
|
+
return {
|
|
220
|
+
address: a.address,
|
|
221
|
+
...(a.authorized_keys == null ? {} : { authorizedKeys: JSON.parse(a.authorized_keys) }),
|
|
222
|
+
...(a.authority_version > 0 ? { authorityVersion: a.authority_version } : {}),
|
|
223
|
+
...(a.recovery_epoch > 0 ? { recoveryEpoch: a.recovery_epoch } : {}),
|
|
224
|
+
...(a.recovery_key_cancelled_at ? { recoveryKeyCancelledAt: a.recovery_key_cancelled_at } : {}),
|
|
225
|
+
kind: a.kind,
|
|
226
|
+
principal: a.principal,
|
|
227
|
+
keys: keys.map(k => ({
|
|
228
|
+
key: k.key,
|
|
229
|
+
added: k.added,
|
|
230
|
+
revoked: k.revoked,
|
|
231
|
+
...(k.label === null ? {} : { label: k.label }),
|
|
232
|
+
})),
|
|
233
|
+
mailbox: a.mailbox === 1,
|
|
234
|
+
createdAt: a.created_at,
|
|
235
|
+
retiredAt: a.retired_at,
|
|
236
|
+
unpairing: a.unpairing ?? null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
#keyTaken = (key) => this.#get('SELECT 1 FROM agent_keys WHERE key = ?', key) !== undefined;
|
|
240
|
+
#insertKey(address, k) {
|
|
241
|
+
const seq = this.#get('SELECT COUNT(*) AS n FROM agent_keys WHERE address = ?', address).n;
|
|
242
|
+
this.#run('INSERT INTO agent_keys VALUES (?, ?, ?, ?, ?, ?)', k.key, address, k.added, k.revoked, seq, k.label ?? null);
|
|
243
|
+
}
|
|
244
|
+
#activeKeys = (address) => this.#get('SELECT COUNT(*) AS n FROM agent_keys WHERE address = ? AND revoked IS NULL', address).n;
|
|
245
|
+
async createAgent(agent, admit) {
|
|
246
|
+
return this.#tx(() => {
|
|
247
|
+
if (this.#get('SELECT 1 FROM agents WHERE address = ?', agent.address))
|
|
248
|
+
return 'address_taken';
|
|
249
|
+
if (agent.keys.some(k => this.#keyTaken(k.key)))
|
|
250
|
+
return 'key_in_use';
|
|
251
|
+
if (agent.keys.filter(k => k.revoked === null).length > 32 || (admit && !admit(structuredClone(agent))))
|
|
252
|
+
return 'too_many_keys';
|
|
253
|
+
this.#run('INSERT INTO agents(address,kind,principal,mailbox,created_at,retired_at,unpairing,authority_version,recovery_key_cancelled_at,authorized_keys) VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?)', agent.address, agent.kind, agent.principal, agent.mailbox ? 1 : 0, agent.createdAt, agent.retiredAt, agent.unpairing ?? null, agent.authorizedKeys === undefined ? null : JSON.stringify(agent.authorizedKeys));
|
|
254
|
+
for (const k of agent.keys)
|
|
255
|
+
this.#insertKey(agent.address, k);
|
|
256
|
+
return 'created';
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async agentByAddress(address) {
|
|
260
|
+
return this.#agent(address);
|
|
261
|
+
}
|
|
262
|
+
async agentByKey(key) {
|
|
263
|
+
const row = this.#get('SELECT address FROM agent_keys WHERE key = ?', key);
|
|
264
|
+
return row ? this.#agent(row.address) : null;
|
|
265
|
+
}
|
|
266
|
+
async addKey(address, entry, maxActive, admit) {
|
|
267
|
+
return this.#tx(() => {
|
|
268
|
+
if (this.#keyTaken(entry.key))
|
|
269
|
+
return 'key_in_use';
|
|
270
|
+
const agent = this.#agent(address);
|
|
271
|
+
if (!agent ||
|
|
272
|
+
agent.retiredAt ||
|
|
273
|
+
this.#activeKeys(address) >= Math.min(32, maxActive) ||
|
|
274
|
+
agent.keys.length >= MAX_STORED_KEYS ||
|
|
275
|
+
(admit && !admit({ ...agent, keys: [...agent.keys, structuredClone(entry)] })))
|
|
276
|
+
return 'too_many_keys';
|
|
277
|
+
this.#insertKey(address, entry);
|
|
278
|
+
if (agent.authorizedKeys !== undefined)
|
|
279
|
+
this.#run('UPDATE agents SET authorized_keys=? WHERE address=?', JSON.stringify([...agent.authorizedKeys, entry.key]), address);
|
|
280
|
+
this.#bumpAuthority(address);
|
|
281
|
+
return 'added';
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async revokeKey(address, key, at, admit) {
|
|
285
|
+
return this.#tx(() => {
|
|
286
|
+
const current = this.#agent(address);
|
|
287
|
+
if (!current || current.retiredAt || (admit && !admit(current)))
|
|
288
|
+
return 'not_found';
|
|
289
|
+
if (!this.#get('SELECT 1 FROM agent_keys WHERE key = ? AND address = ? AND revoked IS NULL', key, address)) {
|
|
290
|
+
return 'not_found';
|
|
291
|
+
}
|
|
292
|
+
if (this.#activeKeys(address) === 1)
|
|
293
|
+
return 'last_key';
|
|
294
|
+
this.#run('UPDATE agent_keys SET revoked = ? WHERE key = ?', at, key);
|
|
295
|
+
const agent = this.#agent(address);
|
|
296
|
+
if (agent.authorizedKeys !== undefined)
|
|
297
|
+
this.#run('UPDATE agents SET authorized_keys=? WHERE address=?', JSON.stringify(agent.authorizedKeys.filter(k => k !== key)), address);
|
|
298
|
+
this.#bumpAuthority(address);
|
|
299
|
+
return 'revoked';
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async replaceKeys(address, entry, admit) {
|
|
303
|
+
return this.#tx(() => {
|
|
304
|
+
const agent = this.#agent(address);
|
|
305
|
+
if (!agent || agent.retiredAt)
|
|
306
|
+
return 'retired';
|
|
307
|
+
if (this.#keyTaken(entry.key))
|
|
308
|
+
return 'key_in_use';
|
|
309
|
+
const keys = [...agent.keys.map(k => ({ ...k, revoked: k.revoked ?? entry.added })), structuredClone(entry)];
|
|
310
|
+
if (admit && !admit({ ...agent, keys }))
|
|
311
|
+
return 'too_many_keys';
|
|
312
|
+
this.#run('UPDATE agent_keys SET revoked = ? WHERE address = ? AND revoked IS NULL', entry.added, address);
|
|
313
|
+
this.#insertKey(address, entry);
|
|
314
|
+
this.#clearRecovery(address);
|
|
315
|
+
this.#run('UPDATE agents SET authorized_keys=? WHERE address=?', JSON.stringify([entry.key]), address);
|
|
316
|
+
this.#bumpAuthority(address);
|
|
317
|
+
this.#advanceRecoveryEpoch(address);
|
|
318
|
+
return 'replaced';
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async keepOnlyKey(address, key, at) {
|
|
322
|
+
return this.#tx(() => {
|
|
323
|
+
const held = this.#get(`SELECT 1 FROM agent_keys k JOIN agents a ON a.address = k.address
|
|
324
|
+
WHERE k.key = ? AND k.address = ? AND k.revoked IS NULL AND a.retired_at IS NULL`, key, address);
|
|
325
|
+
if (!held)
|
|
326
|
+
return 'not_held';
|
|
327
|
+
this.#run('UPDATE agent_keys SET revoked = ? WHERE address = ? AND revoked IS NULL AND key <> ?', at, address, key);
|
|
328
|
+
this.#clearRecovery(address);
|
|
329
|
+
this.#run('UPDATE agents SET authorized_keys=? WHERE address=?', JSON.stringify([key]), address);
|
|
330
|
+
this.#bumpAuthority(address);
|
|
331
|
+
this.#advanceRecoveryEpoch(address);
|
|
332
|
+
return 'kept';
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
async retireAgent(address, at, unpairing) {
|
|
336
|
+
this.#tx(() => {
|
|
337
|
+
if (!this.#get('SELECT 1 FROM agents WHERE address = ? AND retired_at IS NULL', address))
|
|
338
|
+
return;
|
|
339
|
+
this.#run('UPDATE agent_keys SET revoked = ? WHERE address = ? AND revoked IS NULL', at, address);
|
|
340
|
+
this.#run("UPDATE agents SET retired_at = ?, unpairing = ?, authorized_keys='[]' WHERE address = ?", at, unpairing ?? null, address);
|
|
341
|
+
this.#clearRecovery(address);
|
|
342
|
+
this.#bumpAuthority(address);
|
|
343
|
+
this.#advanceRecoveryEpoch(address);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
#bumpAuthority(address) {
|
|
347
|
+
this.#run('UPDATE agents SET authority_version=authority_version+1 WHERE address=?', address);
|
|
348
|
+
}
|
|
349
|
+
/** A recovery ended or authority was reset: every mailbox code issued before now binds nothing. */
|
|
350
|
+
#advanceRecoveryEpoch(address) {
|
|
351
|
+
this.#run('UPDATE agents SET recovery_epoch=recovery_epoch+1 WHERE address=?', address);
|
|
352
|
+
}
|
|
353
|
+
#clearRecovery(address) {
|
|
354
|
+
this.#run("DELETE FROM vals WHERE scope='recovery' AND id=?", address);
|
|
355
|
+
}
|
|
356
|
+
async beginRecovery(address, value, expiresAt, now, admit) {
|
|
357
|
+
return this.#tx(() => {
|
|
358
|
+
const agent = this.#agent(address);
|
|
359
|
+
if (!agent || agent.retiredAt || (admit && !admit(agent)))
|
|
360
|
+
return null;
|
|
361
|
+
const held = this.#get("SELECT value FROM vals WHERE scope='recovery' AND id=? AND (expires IS NULL OR expires>?)", address, ms(now));
|
|
362
|
+
if (held)
|
|
363
|
+
return JSON.parse(held.value);
|
|
364
|
+
this.#run("INSERT INTO vals(scope,id,value,expires) VALUES('recovery',?,?,?) ON CONFLICT(scope,id) DO UPDATE SET value=excluded.value,expires=excluded.expires", address, JSON.stringify(value), expiresAt === null ? null : ms(expiresAt));
|
|
365
|
+
return structuredClone(value);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async activateRecovery(address, expectedRecovery, now) {
|
|
369
|
+
if (!expectedRecovery ||
|
|
370
|
+
typeof expectedRecovery !== 'object' ||
|
|
371
|
+
Array.isArray(expectedRecovery) ||
|
|
372
|
+
expectedRecovery.defended !== false)
|
|
373
|
+
return false;
|
|
374
|
+
return this.#tx(() => {
|
|
375
|
+
const agent = this.#agent(address);
|
|
376
|
+
if (!agent || agent.retiredAt)
|
|
377
|
+
return false;
|
|
378
|
+
const value = JSON.stringify(expectedRecovery), next = JSON.stringify({ ...JSON.parse(value), defended: true });
|
|
379
|
+
return (this.#run("UPDATE vals SET value=? WHERE scope='recovery' AND id=? AND value=? AND (expires IS NULL OR expires>?)", next, address, value, ms(now)).changes === 1);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
async completeRecovery(address, entry, expectedRecovery, admit) {
|
|
383
|
+
return this.#tx(() => {
|
|
384
|
+
const agent = this.#agent(address);
|
|
385
|
+
if (!agent || agent.retiredAt)
|
|
386
|
+
return 'retired';
|
|
387
|
+
if (!expectedRecovery ||
|
|
388
|
+
typeof expectedRecovery !== 'object' ||
|
|
389
|
+
expectedRecovery.key !== entry.key)
|
|
390
|
+
return 'not_pending';
|
|
391
|
+
const proof = JSON.stringify(expectedRecovery);
|
|
392
|
+
const pending = this.#get("SELECT value FROM vals WHERE scope='recovery' AND id=? AND value=? AND (expires IS NULL OR expires>?)", address, proof, ms(entry.added));
|
|
393
|
+
if (!pending || JSON.parse(pending.value).defended === false)
|
|
394
|
+
return 'not_pending';
|
|
395
|
+
if (this.#keyTaken(entry.key))
|
|
396
|
+
return 'key_in_use';
|
|
397
|
+
const keys = [...agent.keys.map(k => ({ ...k, revoked: k.revoked ?? entry.added })), structuredClone(entry)];
|
|
398
|
+
if (admit && !admit({ ...agent, keys }))
|
|
399
|
+
return 'too_many_keys';
|
|
400
|
+
this.#run('UPDATE agent_keys SET revoked=? WHERE address=? AND revoked IS NULL', entry.added, address);
|
|
401
|
+
this.#insertKey(address, entry);
|
|
402
|
+
this.#clearRecovery(address);
|
|
403
|
+
this.#run('UPDATE agents SET authorized_keys=? WHERE address=?', JSON.stringify([entry.key]), address);
|
|
404
|
+
this.#bumpAuthority(address);
|
|
405
|
+
this.#advanceRecoveryEpoch(address);
|
|
406
|
+
return 'replaced';
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
async cancelRecovery(address, expectedRecovery, now, admit, keyCancelledAt) {
|
|
410
|
+
return this.#tx(() => {
|
|
411
|
+
const agent = this.#agent(address);
|
|
412
|
+
if (!agent || agent.retiredAt || (admit && !admit(agent)))
|
|
413
|
+
return false;
|
|
414
|
+
const r = this.#run("DELETE FROM vals WHERE scope='recovery' AND id=? AND value=? AND (expires IS NULL OR expires>?)", address, JSON.stringify(expectedRecovery), ms(now));
|
|
415
|
+
if (!r.changes)
|
|
416
|
+
return false;
|
|
417
|
+
this.#bumpAuthority(address);
|
|
418
|
+
this.#advanceRecoveryEpoch(address);
|
|
419
|
+
if (keyCancelledAt !== undefined)
|
|
420
|
+
this.#run('UPDATE agents SET recovery_key_cancelled_at=? WHERE address=?', keyCancelledAt, address);
|
|
421
|
+
return true;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
#isRetraction(record) {
|
|
425
|
+
return (record.message.v === 1 &&
|
|
426
|
+
header(record.message, 'rel') === 'retract' &&
|
|
427
|
+
inReplyTo(record.message) !== null);
|
|
428
|
+
}
|
|
429
|
+
#receipts() {
|
|
430
|
+
return JSON.parse(this.#get('SELECT body FROM retraction_admission WHERE singleton=1').body);
|
|
431
|
+
}
|
|
432
|
+
#saveReceipts(receipts) {
|
|
433
|
+
this.#run('UPDATE retraction_admission SET body=? WHERE singleton=1', canonicalize(receipts));
|
|
434
|
+
}
|
|
435
|
+
#admitRetraction(record, rows) {
|
|
436
|
+
if (!this.#isRetraction(record))
|
|
437
|
+
return null;
|
|
438
|
+
const receipts = this.#receipts();
|
|
439
|
+
let receipt = Object.hasOwn(receipts, record.id) ? receipts[record.id] : undefined;
|
|
440
|
+
if (receipt && !receipt.active)
|
|
441
|
+
return receipt;
|
|
442
|
+
if (!receipt)
|
|
443
|
+
receipt = {
|
|
444
|
+
record: structuredClone(record),
|
|
445
|
+
author: record.message.from,
|
|
446
|
+
markers: this.#retractionsOf(record),
|
|
447
|
+
generation: randomUUID(),
|
|
448
|
+
active: true,
|
|
449
|
+
materialized: false,
|
|
450
|
+
knownDeliveries: [],
|
|
451
|
+
knownArrivals: [],
|
|
452
|
+
pendingDeliveries: {},
|
|
453
|
+
pendingArrivals: {},
|
|
454
|
+
};
|
|
455
|
+
let changed = !Object.hasOwn(receipts, record.id);
|
|
456
|
+
for (const row of rows.deliveries ?? [])
|
|
457
|
+
if (!receipt.knownDeliveries.includes(row.domain)) {
|
|
458
|
+
receipt.knownDeliveries.push(row.domain);
|
|
459
|
+
receipt.pendingDeliveries[row.domain] = structuredClone(row);
|
|
460
|
+
changed = true;
|
|
461
|
+
}
|
|
462
|
+
for (const row of rows.arrivals ?? [])
|
|
463
|
+
if (!receipt.knownArrivals.includes(row.recipient)) {
|
|
464
|
+
receipt.knownArrivals.push(row.recipient);
|
|
465
|
+
receipt.pendingArrivals[row.recipient] = structuredClone(row);
|
|
466
|
+
changed = true;
|
|
467
|
+
}
|
|
468
|
+
if (!changed)
|
|
469
|
+
return receipt;
|
|
470
|
+
receipts[record.id] = receipt;
|
|
471
|
+
const entries = Object.values(receipts), body = canonicalize(receipts), limits = this.#retractionCapacity;
|
|
472
|
+
// Reserve fixed space for future internal state changes; pending journals are included before acceptance.
|
|
473
|
+
if (entries.length > limits.maxRecords ||
|
|
474
|
+
entries.filter(r => r.author === record.message.from).length > limits.maxPerAuthor ||
|
|
475
|
+
Buffer.byteLength(body) + 16 * entries.length > limits.maxBytes)
|
|
476
|
+
throw new StoreCapacityError();
|
|
477
|
+
this.#saveReceipts(receipts);
|
|
478
|
+
return receipt;
|
|
479
|
+
}
|
|
480
|
+
#bootstrapRetractions() {
|
|
481
|
+
for (const row of this.#all('SELECT * FROM messages WHERE in_reply_to IS NOT NULL'))
|
|
482
|
+
this.#admitRetraction(this.#message(row), {});
|
|
483
|
+
const receipts = this.#receipts();
|
|
484
|
+
for (const [id, r] of Object.entries(receipts))
|
|
485
|
+
if (r.active) {
|
|
486
|
+
r.knownDeliveries = [
|
|
487
|
+
...new Set([
|
|
488
|
+
...r.knownDeliveries,
|
|
489
|
+
...this.#all('SELECT domain FROM deliveries WHERE id=?', id).map(d => d.domain),
|
|
490
|
+
]),
|
|
491
|
+
];
|
|
492
|
+
r.knownArrivals = [
|
|
493
|
+
...new Set([
|
|
494
|
+
...r.knownArrivals,
|
|
495
|
+
...this.#all('SELECT recipient FROM arrivals WHERE id=?', id).map(a => a.recipient),
|
|
496
|
+
]),
|
|
497
|
+
];
|
|
498
|
+
r.materialized = true;
|
|
499
|
+
}
|
|
500
|
+
for (const row of this.#all('SELECT * FROM listing_retractions')) {
|
|
501
|
+
const id = row.retraction;
|
|
502
|
+
if (!Object.hasOwn(receipts, id))
|
|
503
|
+
receipts[id] = {
|
|
504
|
+
record: null,
|
|
505
|
+
author: row.author,
|
|
506
|
+
markers: [],
|
|
507
|
+
generation: randomUUID(),
|
|
508
|
+
active: false,
|
|
509
|
+
materialized: true,
|
|
510
|
+
knownDeliveries: [],
|
|
511
|
+
knownArrivals: [],
|
|
512
|
+
pendingDeliveries: {},
|
|
513
|
+
pendingArrivals: {},
|
|
514
|
+
};
|
|
515
|
+
const marker = {
|
|
516
|
+
owner: row.owner,
|
|
517
|
+
target: row.target,
|
|
518
|
+
author: row.author,
|
|
519
|
+
retraction: id,
|
|
520
|
+
};
|
|
521
|
+
if (!receipts[id].markers.some(m => m.owner === marker.owner && m.target === marker.target && m.author === marker.author))
|
|
522
|
+
receipts[id].markers.push(marker);
|
|
523
|
+
}
|
|
524
|
+
const entries = Object.values(receipts), limits = this.#retractionCapacity;
|
|
525
|
+
const authors = new Map();
|
|
526
|
+
for (const r of entries)
|
|
527
|
+
authors.set(r.author, (authors.get(r.author) ?? 0) + 1);
|
|
528
|
+
if (entries.length > limits.maxRecords ||
|
|
529
|
+
[...authors.values()].some(n => n > limits.maxPerAuthor) ||
|
|
530
|
+
Buffer.byteLength(canonicalize(receipts)) + 16 * entries.length > limits.maxBytes)
|
|
531
|
+
throw new StoreCapacityError();
|
|
532
|
+
this.#saveReceipts(receipts);
|
|
533
|
+
}
|
|
534
|
+
// Messages
|
|
535
|
+
#message(row) {
|
|
536
|
+
return {
|
|
537
|
+
id: row.id,
|
|
538
|
+
message: JSON.parse(row.json),
|
|
539
|
+
carrier: row.carrier,
|
|
540
|
+
claims: JSON.parse(row.claims),
|
|
541
|
+
signedClaims: row.signed_claims === null ? null : JSON.parse(row.signed_claims),
|
|
542
|
+
receivedAt: row.received_at,
|
|
543
|
+
expiresAt: iso(row.expires),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async putMessage(m, rows = {}) {
|
|
547
|
+
return this.#tx(() => {
|
|
548
|
+
const receipt = this.#admitRetraction(m, rows);
|
|
549
|
+
if (receipt && !receipt.active)
|
|
550
|
+
return structuredClone(receipt.record ?? m);
|
|
551
|
+
if (receipt?.record)
|
|
552
|
+
m = receipt.record;
|
|
553
|
+
const existing = this.#get('SELECT * FROM messages WHERE id = ?', m.id);
|
|
554
|
+
if (!existing) {
|
|
555
|
+
const written = !rows.arrivals;
|
|
556
|
+
this.#run('INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', m.id, canonicalize(m.message), m.message.from, m.message.to.length === 0 ? 1 : 0, inReplyTo(m.message), m.carrier, JSON.stringify(m.claims), m.signedClaims && JSON.stringify(m.signedClaims), m.receivedAt, ms(m.expiresAt), written ? 1 : 0, written ? this.#position() : null, written ? ms(m.receivedAt) : null, randomUUID());
|
|
557
|
+
for (const p of namedParts(m.message)) {
|
|
558
|
+
this.#run('INSERT OR IGNORE INTO message_parts VALUES (?, ?, ?)', m.id, p.sha256, p.size);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for (const d of receipt ? Object.values(receipt.pendingDeliveries) : (rows.deliveries ?? [])) {
|
|
562
|
+
this.#run('INSERT OR IGNORE INTO deliveries VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)', d.id, d.domain, JSON.stringify(d.recipients), JSON.stringify(d.hints), d.server, d.status, d.reason, d.attempts, ms(d.nextAttemptAt), d.createdAt, d.deadline);
|
|
563
|
+
}
|
|
564
|
+
for (const a of receipt ? Object.values(receipt.pendingArrivals) : (rows.arrivals ?? [])) {
|
|
565
|
+
this.#run('INSERT OR IGNORE INTO arrivals VALUES (?, ?, ?, ?, ?)', a.id, a.recipient, JSON.stringify(a.hints), a.attempts, ms(a.dueAt));
|
|
566
|
+
}
|
|
567
|
+
if (receipt) {
|
|
568
|
+
const receipts = this.#receipts();
|
|
569
|
+
receipts[m.id] = { ...receipt, materialized: true, pendingDeliveries: {}, pendingArrivals: {} };
|
|
570
|
+
this.#saveReceipts(receipts);
|
|
571
|
+
}
|
|
572
|
+
return existing ? this.#message(existing) : structuredClone(m);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async message(id) {
|
|
576
|
+
const row = this.#get('SELECT * FROM messages WHERE id = ?', id);
|
|
577
|
+
return row ? this.#message(row) : null;
|
|
578
|
+
}
|
|
579
|
+
#retractionsOf(record) {
|
|
580
|
+
if (!this.#isRetraction(record))
|
|
581
|
+
return [];
|
|
582
|
+
return (record.message.to.length ? record.message.to : ['*']).map(owner => ({
|
|
583
|
+
owner,
|
|
584
|
+
target: inReplyTo(record.message),
|
|
585
|
+
author: record.message.from,
|
|
586
|
+
retraction: record.id,
|
|
587
|
+
}));
|
|
588
|
+
}
|
|
589
|
+
async listingRetracted(ownerAddress, id, replyAuthor) {
|
|
590
|
+
return Object.values(this.#receipts()).some(r => r.markers.some(m => (m.owner === ownerAddress || m.owner === '*') && m.target === id && m.author === replyAuthor));
|
|
591
|
+
}
|
|
592
|
+
async deleteMessage(id) {
|
|
593
|
+
this.#tx(() => this.#deleteMessages('id = ?', id));
|
|
594
|
+
}
|
|
595
|
+
async published(author, before, limit) {
|
|
596
|
+
const after = this.#get('SELECT rowid FROM messages WHERE id = ? AND from_address = ? AND public = 1', before, author);
|
|
597
|
+
const rows = this.#all('SELECT * FROM messages WHERE from_address = ? AND public = 1 AND rowid < ? ORDER BY rowid DESC LIMIT ?', author, before === null ? Number.MAX_SAFE_INTEGER : (after?.rowid ?? 0), limit + 1);
|
|
598
|
+
return page(rows, limit, r => this.#message(r));
|
|
599
|
+
}
|
|
600
|
+
async repliesTo(address, id, before, limit) {
|
|
601
|
+
const after = this.#get('SELECT cursor FROM inbox WHERE address = ? AND id = ?', address, before);
|
|
602
|
+
const rows = this.#all(`SELECT m.* FROM inbox i JOIN messages m ON m.id = i.id
|
|
603
|
+
WHERE i.address = ? AND m.in_reply_to = ? AND i.cursor < ? ORDER BY i.cursor DESC LIMIT ?`, address, id, before === null ? Number.MAX_SAFE_INTEGER : (after?.cursor ?? 0), limit + 1);
|
|
604
|
+
return page(rows, limit, r => this.#message(r));
|
|
605
|
+
}
|
|
606
|
+
/** Delete the messages a WHERE clause selects, and everything tied to them. */
|
|
607
|
+
#deleteMessages(where, ...params) {
|
|
608
|
+
const ids = `SELECT id FROM messages WHERE ${where}`;
|
|
609
|
+
const receipts = this.#receipts();
|
|
610
|
+
let changed = false;
|
|
611
|
+
for (const row of this.#all(`SELECT id FROM messages WHERE ${where}`, ...params)) {
|
|
612
|
+
const receipt = receipts[row.id];
|
|
613
|
+
if (receipt) {
|
|
614
|
+
receipt.active = false;
|
|
615
|
+
receipt.pendingDeliveries = {};
|
|
616
|
+
receipt.pendingArrivals = {};
|
|
617
|
+
changed = true;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (changed)
|
|
621
|
+
this.#saveReceipts(receipts);
|
|
622
|
+
for (const table of ['inbox', 'deliveries', 'arrivals', 'message_parts']) {
|
|
623
|
+
this.#run(`DELETE FROM ${table} WHERE id IN (${ids})`, ...params);
|
|
624
|
+
}
|
|
625
|
+
return Number(this.#run(`DELETE FROM messages WHERE ${where}`, ...params).changes);
|
|
626
|
+
}
|
|
627
|
+
// Arrivals
|
|
628
|
+
async arrivals(now, limit) {
|
|
629
|
+
const rows = this.#all(`SELECT m.*, a.recipient, a.hints, a.attempts, a.due FROM arrivals a JOIN messages m ON m.id = a.id
|
|
630
|
+
WHERE a.due <= ? ORDER BY a.due, a.rowid LIMIT ?`, ms(now), limit);
|
|
631
|
+
return rows.map(r => ({
|
|
632
|
+
arrival: {
|
|
633
|
+
id: r.id,
|
|
634
|
+
recipient: r.recipient,
|
|
635
|
+
hints: JSON.parse(r.hints),
|
|
636
|
+
attempts: r.attempts,
|
|
637
|
+
dueAt: iso(r.due),
|
|
638
|
+
},
|
|
639
|
+
message: this.#message(r),
|
|
640
|
+
generation: r.generation,
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
async arrivalRetry(id, recipient, at, generation) {
|
|
644
|
+
if (generation !== undefined && !this.#get('SELECT 1 FROM messages WHERE id=? AND generation=?', id, generation))
|
|
645
|
+
return;
|
|
646
|
+
this.#run('UPDATE arrivals SET attempts = attempts + 1, due = ? WHERE id = ? AND recipient = ? AND EXISTS(SELECT 1 FROM messages WHERE id=? AND (? IS NULL OR generation=?))', ms(at), id, recipient, id, generation ?? null, generation ?? null);
|
|
647
|
+
}
|
|
648
|
+
async arrivalDone(id, recipient, kept, generation) {
|
|
649
|
+
return this.#tx(() => {
|
|
650
|
+
if (generation !== undefined && !this.#get('SELECT 1 FROM messages WHERE id=? AND generation=?', id, generation))
|
|
651
|
+
return null;
|
|
652
|
+
this.#run('DELETE FROM arrivals WHERE id = ? AND recipient = ?', id, recipient);
|
|
653
|
+
if (kept) {
|
|
654
|
+
return Number(this.#run('UPDATE messages SET kept = 1 WHERE id = ? AND kept = 0', id).changes) ? 'kept' : null;
|
|
655
|
+
}
|
|
656
|
+
const unwanted = `id = ? AND kept = 0 AND NOT EXISTS (SELECT 1 FROM arrivals a WHERE a.id = messages.id)
|
|
657
|
+
AND NOT EXISTS (SELECT 1 FROM inbox i WHERE i.id = messages.id)`;
|
|
658
|
+
return this.#deleteMessages(unwanted, id) ? 'dropped' : null;
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
// Sync
|
|
662
|
+
/** The next position of the stream; positions are taken and written in one transaction. */
|
|
663
|
+
#position() {
|
|
664
|
+
return this.#get('UPDATE positions SET n = n + 1 RETURNING n').n;
|
|
665
|
+
}
|
|
666
|
+
async addToInbox(address, id, at, generation, minExpiresAt) {
|
|
667
|
+
return this.#tx(() => {
|
|
668
|
+
if (!this.#get('SELECT 1 FROM messages WHERE id=? AND (? IS NULL OR generation=?)', id, generation ?? null, generation ?? null))
|
|
669
|
+
return false;
|
|
670
|
+
if (minExpiresAt !== undefined)
|
|
671
|
+
this.#run('UPDATE messages SET expires=MAX(expires,?) WHERE id=?', ms(minExpiresAt), id);
|
|
672
|
+
if (this.#get('SELECT 1 FROM inbox WHERE address = ? AND id = ?', address, id))
|
|
673
|
+
return false;
|
|
674
|
+
this.#run('INSERT INTO inbox VALUES (?, ?, ?, ?, 1)', this.#position(), address, id, ms(at));
|
|
675
|
+
return true;
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/** A row of a message with its place in a stream: `position`, `holder` and `entered`. */
|
|
679
|
+
#entry(r) {
|
|
680
|
+
const m = this.#message(r);
|
|
681
|
+
return {
|
|
682
|
+
cursor: r.position,
|
|
683
|
+
address: r.holder,
|
|
684
|
+
id: m.id,
|
|
685
|
+
message: m.message,
|
|
686
|
+
carrier: m.carrier,
|
|
687
|
+
claims: m.claims,
|
|
688
|
+
signedClaims: m.signedClaims,
|
|
689
|
+
receivedAt: iso(r.entered),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
async sync(address, after, limit, now, from) {
|
|
693
|
+
// Read first: every position up to it is written, since a position is taken in the write that uses it.
|
|
694
|
+
const top = this.#get('SELECT n FROM positions').n;
|
|
695
|
+
const since = from === undefined ? Number.MIN_SAFE_INTEGER : ms(from);
|
|
696
|
+
const rows = this.#all(`SELECT m.*, s.position, ? AS holder, s.taken AS entered FROM (
|
|
697
|
+
SELECT cursor AS position, id, taken FROM inbox
|
|
698
|
+
WHERE address = ? AND cursor > ? AND cursor <= ? AND taken >= ?
|
|
699
|
+
UNION ALL
|
|
700
|
+
SELECT cursor, id, taken FROM messages
|
|
701
|
+
WHERE from_address = ? AND cursor > ? AND cursor <= ? AND taken >= ?
|
|
702
|
+
) s JOIN messages m ON m.id = s.id ORDER BY s.position LIMIT ?`, address, address, after, top, since, address, after, top, since, limit + 1);
|
|
703
|
+
const more = rows.length > limit;
|
|
704
|
+
const messages = rows.slice(0, limit).map(r => this.#entry(r));
|
|
705
|
+
const cursor = more ? messages.at(-1).cursor : top;
|
|
706
|
+
const signals = this.#all('SELECT cursor, signal FROM signals WHERE address = ? AND cursor > ? AND cursor <= ? AND expires > ? ORDER BY cursor', address, after, cursor, ms(now)).map(r => ({ cursor: r.cursor, ...JSON.parse(r.signal) }));
|
|
707
|
+
return { messages, signals, cursor, more };
|
|
708
|
+
}
|
|
709
|
+
async holdSignal(address, signal, expiresAt, now) {
|
|
710
|
+
this.#tx(() => {
|
|
711
|
+
this.#run('DELETE FROM signals WHERE expires <= ?', ms(now));
|
|
712
|
+
const { type, from, about, at } = signal;
|
|
713
|
+
const held = JSON.stringify({ type, from, about, at });
|
|
714
|
+
this.#run('INSERT INTO signals VALUES (?, ?, ?, ?)', this.#position(), address, held, ms(expiresAt));
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
async inbox(address, after, limit) {
|
|
718
|
+
return this.#all(`SELECT m.*, i.cursor AS position, i.address AS holder, i.taken AS entered
|
|
719
|
+
FROM inbox i JOIN messages m ON m.id=i.id WHERE i.address=? AND i.cursor>? ORDER BY i.cursor LIMIT ?`, address, after, limit).map(r => this.#entry(r));
|
|
720
|
+
}
|
|
721
|
+
async inInbox(address, id) {
|
|
722
|
+
return !!this.#get('SELECT 1 FROM inbox WHERE address = ? AND id = ?', address, id);
|
|
723
|
+
}
|
|
724
|
+
async wroteTo(author, recipient, self) {
|
|
725
|
+
return !!this.#get(`SELECT 1 FROM messages m, json_each(m.json, '$.to') t
|
|
726
|
+
WHERE m.from_address = ? AND m.carrier = ? AND t.value = ? LIMIT 1`, author, self, recipient);
|
|
727
|
+
}
|
|
728
|
+
async inboxEvents(limit) {
|
|
729
|
+
return this.#all(`SELECT m.*, i.cursor AS position, i.address AS holder, i.taken AS entered
|
|
730
|
+
FROM inbox i JOIN messages m ON m.id = i.id WHERE i.event = 1 ORDER BY i.cursor LIMIT ?`, limit).map(r => this.#entry(r));
|
|
731
|
+
}
|
|
732
|
+
async inboxEventDone(cursor) {
|
|
733
|
+
this.#run('UPDATE inbox SET event = 0 WHERE cursor = ?', cursor);
|
|
734
|
+
}
|
|
735
|
+
// Webhooks
|
|
736
|
+
#webhook = (r) => ({
|
|
737
|
+
id: r.id,
|
|
738
|
+
address: r.address,
|
|
739
|
+
url: r.url,
|
|
740
|
+
codeHash: r.code_hash,
|
|
741
|
+
verified: r.verified === 1,
|
|
742
|
+
createdAt: r.created_at,
|
|
743
|
+
failingSince: r.failing_since,
|
|
744
|
+
});
|
|
745
|
+
async addWebhook(w, max) {
|
|
746
|
+
return this.#tx(() => {
|
|
747
|
+
if (this.#get('SELECT COUNT(*) AS n FROM webhooks WHERE address = ?', w.address).n >= max) {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
this.#run('INSERT INTO webhooks VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL, NULL)', w.id, w.address, w.url, w.codeHash, w.verified ? 1 : 0, w.createdAt, w.failingSince);
|
|
751
|
+
return true;
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async webhook(id) {
|
|
755
|
+
const row = this.#get('SELECT * FROM webhooks WHERE id = ?', id);
|
|
756
|
+
return row ? this.#webhook(row) : null;
|
|
757
|
+
}
|
|
758
|
+
async verifyWebhook(id) {
|
|
759
|
+
this.#run('UPDATE webhooks SET verified = 1 WHERE id = ?', id);
|
|
760
|
+
}
|
|
761
|
+
async deleteWebhook(id) {
|
|
762
|
+
this.#run('DELETE FROM webhooks WHERE id = ?', id);
|
|
763
|
+
}
|
|
764
|
+
async wakeWebhooks(address, at) {
|
|
765
|
+
this.#run('UPDATE webhooks SET due = MAX(?, quiet_until) WHERE address = ? AND verified = 1 AND due IS NULL', ms(at), address);
|
|
766
|
+
}
|
|
767
|
+
async claimWebhooks(now, limit, quietUntil, lease) {
|
|
768
|
+
return this.#tx(() => {
|
|
769
|
+
// A crashed post remains due via its expired lease even if no new event woke it.
|
|
770
|
+
const rows = this.#all(`SELECT * FROM webhooks WHERE verified=1 AND (lease_until IS NULL OR lease_until<=?)
|
|
771
|
+
AND quiet_until<=? AND (due<=? OR lease_until<=?) ORDER BY COALESCE(due,lease_until) LIMIT ?`, ms(now), ms(now), ms(now), ms(now), limit);
|
|
772
|
+
for (const row of rows)
|
|
773
|
+
this.#run('UPDATE webhooks SET due=NULL,quiet_until=?,lease=?,lease_until=? WHERE id=?', ms(quietUntil), lease.token, ms(lease.until), row.id);
|
|
774
|
+
return rows.map(this.#webhook);
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async settleWebhook(id, failingSince, retryAt, token, disable = false) {
|
|
778
|
+
if (disable)
|
|
779
|
+
return Number(this.#run('DELETE FROM webhooks WHERE id=? AND lease=?', id, token).changes) === 1;
|
|
780
|
+
return (Number(this.#run(`UPDATE webhooks SET failing_since=?, due=COALESCE(due,?), lease=NULL,lease_until=NULL WHERE id=? AND lease=?`, failingSince, retryAt === null ? null : ms(retryAt), id, token).changes) === 1);
|
|
781
|
+
}
|
|
782
|
+
// Deliveries
|
|
783
|
+
#delivery(r) {
|
|
784
|
+
return {
|
|
785
|
+
id: r.id,
|
|
786
|
+
domain: r.domain,
|
|
787
|
+
recipients: JSON.parse(r.recipients),
|
|
788
|
+
hints: JSON.parse(r.hints),
|
|
789
|
+
server: r.server,
|
|
790
|
+
status: r.status,
|
|
791
|
+
reason: r.reason,
|
|
792
|
+
attempts: r.attempts,
|
|
793
|
+
nextAttemptAt: iso(r.next_attempt),
|
|
794
|
+
createdAt: r.created_at,
|
|
795
|
+
deadline: r.deadline,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
async claimDeliveries(now, limit, skip, lease) {
|
|
799
|
+
return this.#tx(() => this.#all(`UPDATE deliveries SET lease = ?, next_attempt = ? WHERE rowid IN (
|
|
800
|
+
SELECT rowid FROM deliveries WHERE status = 'pending' AND next_attempt <= ?
|
|
801
|
+
AND (server IS NULL OR server NOT IN (SELECT value FROM json_each(?)))
|
|
802
|
+
ORDER BY next_attempt LIMIT ?)
|
|
803
|
+
RETURNING *`, lease.token, ms(lease.until), ms(now), JSON.stringify(skip), limit).map(r => this.#delivery(r)));
|
|
804
|
+
}
|
|
805
|
+
async settleDelivery(d, token) {
|
|
806
|
+
const settled = this.#run(`UPDATE deliveries SET server = ?, status = ?, reason = ?, attempts = ?, next_attempt = ?, lease = NULL,
|
|
807
|
+
hints = CASE WHEN ? = 'pending' THEN hints ELSE '{}' END
|
|
808
|
+
WHERE id = ? AND domain = ? AND status = 'pending' AND lease = ?`, d.server, d.status, d.reason, d.attempts, ms(d.nextAttemptAt), d.status, d.id, d.domain, token);
|
|
809
|
+
return Number(settled.changes) === 1;
|
|
810
|
+
}
|
|
811
|
+
async deliveries(id) {
|
|
812
|
+
return this.#all('SELECT * FROM deliveries WHERE id = ? ORDER BY domain', id).map(r => this.#delivery(r));
|
|
813
|
+
}
|
|
814
|
+
// Parts
|
|
815
|
+
async putPart(sha256, bytes, source, at) {
|
|
816
|
+
this.#run(`INSERT INTO parts VALUES (?, ?, ?, ?, ?, ?)
|
|
817
|
+
ON CONFLICT (sha256, source) DO UPDATE SET created = excluded.created`, sha256, sourceKey(source), 'carrier' in source ? 1 : 0, bytes.length, bytes, ms(at));
|
|
818
|
+
}
|
|
819
|
+
async part(sha256, source) {
|
|
820
|
+
const row = this.#get('SELECT bytes FROM parts WHERE sha256 = ? AND source = ?', sha256, sourceKey(source));
|
|
821
|
+
return row ? Buffer.from(row.bytes) : null;
|
|
822
|
+
}
|
|
823
|
+
async heldFor(sha256, address, self) {
|
|
824
|
+
const sources = [];
|
|
825
|
+
const seen = new Set();
|
|
826
|
+
const pending = [address];
|
|
827
|
+
// Follow only inbox-author grants for this hash. Every address is visited once, even in a cycle.
|
|
828
|
+
for (const holder of pending) {
|
|
829
|
+
if (seen.has(holder))
|
|
830
|
+
continue;
|
|
831
|
+
seen.add(holder);
|
|
832
|
+
for (const source of this.#held(sha256, holder, self)) {
|
|
833
|
+
sources.push(source);
|
|
834
|
+
if ('uploader' in source && !seen.has(source.uploader))
|
|
835
|
+
pending.push(source.uploader);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return uniqueSources(sources);
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* An agent's own upload of a part, and for each message in its inbox naming it, the carrier's bytes, or the
|
|
842
|
+
* author's upload when the carrier is `self`.
|
|
843
|
+
*/
|
|
844
|
+
#held(sha256, address, self) {
|
|
845
|
+
const own = this.#get('SELECT size FROM parts WHERE sha256 = ? AND source = ? AND fetched = 0', sha256, address);
|
|
846
|
+
const received = this.#all(`SELECT DISTINCT m.carrier, m.from_address, mp.size FROM message_parts mp JOIN messages m ON m.id = mp.id
|
|
847
|
+
JOIN inbox i ON i.id = m.id AND i.address = ? WHERE mp.sha256 = ?`, address, sha256);
|
|
848
|
+
return [
|
|
849
|
+
...(own ? [{ uploader: address, size: own.size }] : []),
|
|
850
|
+
...received.map(r => r.carrier === self
|
|
851
|
+
? { uploader: r.from_address, size: r.size }
|
|
852
|
+
: { carrier: r.carrier, size: r.size }),
|
|
853
|
+
];
|
|
854
|
+
}
|
|
855
|
+
async partNamers(sha256, viewer, self) {
|
|
856
|
+
const seen = 'published' in viewer
|
|
857
|
+
? 'm.public = 1'
|
|
858
|
+
: `EXISTS (SELECT 1 FROM deliveries d WHERE d.id = m.id AND d.server = ? AND d.status = 'delivered')`;
|
|
859
|
+
const rows = this.#all(`SELECT DISTINCT m.from_address, m.json FROM message_parts mp JOIN messages m ON m.id = mp.id
|
|
860
|
+
WHERE mp.sha256 = ? AND m.carrier = ? AND ${seen}
|
|
861
|
+
AND m.from_address IN (SELECT address FROM agents WHERE retired_at IS NULL)`, sha256, self, ...('server' in viewer ? [viewer.server] : []));
|
|
862
|
+
return rows.map(r => ({ author: r.from_address, message: JSON.parse(r.json) }));
|
|
863
|
+
}
|
|
864
|
+
// Part fetches
|
|
865
|
+
#fetch = (r) => ({
|
|
866
|
+
sha256: r.sha256,
|
|
867
|
+
carrier: r.carrier,
|
|
868
|
+
size: r.size,
|
|
869
|
+
attempts: r.attempts,
|
|
870
|
+
dueAt: iso(r.due),
|
|
871
|
+
revision: r.revision,
|
|
872
|
+
generation: r.generation,
|
|
873
|
+
});
|
|
874
|
+
async queueFetch(f, now, max) {
|
|
875
|
+
return this.#tx(() => {
|
|
876
|
+
if (now !== undefined)
|
|
877
|
+
this.#run('DELETE FROM fetches WHERE sha256=? AND carrier=? AND exhausted<=?', f.sha256, f.carrier, ms(now));
|
|
878
|
+
if (max !== undefined &&
|
|
879
|
+
!this.#get('SELECT 1 FROM fetches WHERE sha256=? AND carrier=?', f.sha256, f.carrier) &&
|
|
880
|
+
this.#get('SELECT COUNT(*) AS n FROM (SELECT 1 FROM fetches WHERE carrier=? AND exhausted IS NULL LIMIT ?)', f.carrier, max).n >= max)
|
|
881
|
+
return null;
|
|
882
|
+
this.#run('INSERT OR IGNORE INTO fetches(sha256,carrier,size,attempts,due,token,exhausted,revision,generation) VALUES (?, ?, ?, ?, ?, NULL, NULL, 0, ?)', f.sha256, f.carrier, f.size, f.attempts, ms(f.dueAt), randomUUID());
|
|
883
|
+
return this.#fetch(this.#get('SELECT * FROM fetches WHERE sha256 = ? AND carrier = ?', f.sha256, f.carrier));
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
async fetches(now, limit) {
|
|
887
|
+
return this.#all(`SELECT * FROM (SELECT *, rowid AS seq, ROW_NUMBER() OVER (PARTITION BY carrier ORDER BY due, rowid) AS turn FROM fetches
|
|
888
|
+
WHERE due <= ? AND exhausted IS NULL) ORDER BY turn, due, seq LIMIT ?`, ms(now), limit).map(this.#fetch);
|
|
889
|
+
}
|
|
890
|
+
async claimFetch(f, now, lease) {
|
|
891
|
+
if (!f.generation || ms(lease.until) <= ms(now))
|
|
892
|
+
return false;
|
|
893
|
+
return (Number(this.#run(`UPDATE fetches SET due=?,token=?,revision=revision+1 WHERE sha256=? AND carrier=? AND due=? AND due<=? AND attempts=? AND revision=? AND generation=? AND exhausted IS NULL`, ms(lease.until), lease.token, f.sha256, f.carrier, ms(f.dueAt), ms(now), f.attempts, f.revision ?? 0, f.generation).changes) === 1);
|
|
894
|
+
}
|
|
895
|
+
async renewFetch(sha256, carrier, token, now, until) {
|
|
896
|
+
if (ms(until) <= ms(now))
|
|
897
|
+
return false;
|
|
898
|
+
return (Number(this.#run('UPDATE fetches SET due=?,revision=revision+1 WHERE sha256=? AND carrier=? AND token=? AND due>? AND exhausted IS NULL', ms(until), sha256, carrier, token, ms(now)).changes) === 1);
|
|
899
|
+
}
|
|
900
|
+
async fetchRetry(sha256, carrier, at, token) {
|
|
901
|
+
return (Number(this.#run('UPDATE fetches SET due=?,attempts=attempts+1,token=NULL,revision=revision+1 WHERE sha256=? AND carrier=? AND token=? AND exhausted IS NULL', ms(at), sha256, carrier, token).changes) === 1);
|
|
902
|
+
}
|
|
903
|
+
async fetchDefer(sha256, carrier, at, token) {
|
|
904
|
+
return (Number(this.#run('UPDATE fetches SET due=?,token=NULL,revision=revision+1 WHERE sha256=? AND carrier=? AND token=? AND exhausted IS NULL', ms(at), sha256, carrier, token).changes) === 1);
|
|
905
|
+
}
|
|
906
|
+
async fetchDone(sha256, carrier, token) {
|
|
907
|
+
return (Number(this.#run('DELETE FROM fetches WHERE sha256=? AND carrier=? AND token=? AND exhausted IS NULL', sha256, carrier, token).changes) === 1);
|
|
908
|
+
}
|
|
909
|
+
async fetchGiveUp(sha256, carrier, token, expiresAt) {
|
|
910
|
+
return (Number(this.#run('UPDATE fetches SET exhausted=?,token=NULL,revision=revision+1 WHERE sha256=? AND carrier=? AND token=? AND exhausted IS NULL', ms(expiresAt), sha256, carrier, token).changes) === 1);
|
|
911
|
+
}
|
|
912
|
+
async fetchExhausted(sha256, carrier, now) {
|
|
913
|
+
return !!this.#get('SELECT 1 FROM fetches WHERE sha256=? AND carrier=? AND exhausted>?', sha256, carrier, ms(now));
|
|
914
|
+
}
|
|
915
|
+
// Marks, counters, values
|
|
916
|
+
async mark(scope, id, expiresAt, now) {
|
|
917
|
+
return this.#tx(() => {
|
|
918
|
+
if (this.#get('SELECT 1 FROM marks WHERE scope = ? AND id = ? AND expires > ?', scope, id, ms(now)))
|
|
919
|
+
return false;
|
|
920
|
+
this.#run('INSERT OR REPLACE INTO marks VALUES (?, ?, ?)', scope, id, ms(expiresAt));
|
|
921
|
+
return true;
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
async marked(scope, id, now) {
|
|
925
|
+
return !!this.#get('SELECT 1 FROM marks WHERE scope = ? AND id = ? AND expires > ?', scope, id, ms(now));
|
|
926
|
+
}
|
|
927
|
+
async count(scope, id, expiresAt, now, by = 1) {
|
|
928
|
+
return this.#tx(() => {
|
|
929
|
+
this.#run('DELETE FROM counters WHERE scope = ? AND id = ? AND expires <= ?', scope, id, ms(now));
|
|
930
|
+
this.#run('INSERT INTO counters VALUES (?, ?, ?, ?) ON CONFLICT (scope, id) DO UPDATE SET n = n + excluded.n', scope, id, by, ms(expiresAt));
|
|
931
|
+
return this.#get('SELECT n FROM counters WHERE scope = ? AND id = ?', scope, id).n;
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
async value(scope, id, now) {
|
|
935
|
+
const row = this.#get('SELECT value FROM vals WHERE scope = ? AND id = ? AND (expires IS NULL OR expires > ?)', scope, id, ms(now));
|
|
936
|
+
return row ? JSON.parse(row.value) : null;
|
|
937
|
+
}
|
|
938
|
+
async setValue(scope, id, value, expiresAt) {
|
|
939
|
+
if (scope === 'recovery')
|
|
940
|
+
throw new Error('The recovery scope is reserved; use beginRecovery, completeRecovery or cancelRecovery.');
|
|
941
|
+
this.#run('INSERT OR REPLACE INTO vals VALUES (?, ?, ?, ?)', scope, id, JSON.stringify(value), expiresAt === null ? null : ms(expiresAt));
|
|
942
|
+
}
|
|
943
|
+
async releaseValue(scope, id, value) {
|
|
944
|
+
if (scope === 'recovery')
|
|
945
|
+
throw new Error('The recovery scope is reserved; use beginRecovery, completeRecovery or cancelRecovery.');
|
|
946
|
+
const r = this.#run('DELETE FROM vals WHERE scope = ? AND id = ? AND value = ?', scope, id, JSON.stringify(value));
|
|
947
|
+
return Number(r.changes) === 1;
|
|
948
|
+
}
|
|
949
|
+
async renewValue(scope, id, value, expiresAt) {
|
|
950
|
+
if (scope === 'recovery')
|
|
951
|
+
throw new Error('The recovery scope is reserved; use beginRecovery, completeRecovery or cancelRecovery.');
|
|
952
|
+
const r = this.#run('UPDATE vals SET expires = ? WHERE scope = ? AND id = ? AND value = ?', ms(expiresAt), scope, id, JSON.stringify(value));
|
|
953
|
+
return Number(r.changes) === 1;
|
|
954
|
+
}
|
|
955
|
+
async valueOnce(scope, id, value, expiresAt, now) {
|
|
956
|
+
if (scope === 'recovery')
|
|
957
|
+
throw new Error('The recovery scope is reserved; use beginRecovery, completeRecovery or cancelRecovery.');
|
|
958
|
+
return this.#tx(() => {
|
|
959
|
+
const row = this.#get('SELECT value FROM vals WHERE scope = ? AND id = ? AND (expires IS NULL OR expires > ?)', scope, id, ms(now));
|
|
960
|
+
if (row)
|
|
961
|
+
return JSON.parse(row.value);
|
|
962
|
+
this.#run('INSERT OR REPLACE INTO vals VALUES (?, ?, ?, ?)', scope, id, JSON.stringify(value), expiresAt === null ? null : ms(expiresAt));
|
|
963
|
+
return value;
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
async sweep(now, uploadsBefore) {
|
|
967
|
+
this.#run('DELETE FROM fetches WHERE exhausted<=?', ms(now));
|
|
968
|
+
return this.#tx(() => {
|
|
969
|
+
const t = ms(now);
|
|
970
|
+
const messages = this.#deleteMessages('expires <= ?', t);
|
|
971
|
+
// Fetches no message names any more, from its carrier, given up or still waiting.
|
|
972
|
+
this.#run(`DELETE FROM fetches WHERE NOT EXISTS (SELECT 1 FROM message_parts mp JOIN messages m ON m.id = mp.id
|
|
973
|
+
WHERE mp.sha256 = fetches.sha256 AND m.carrier = fetches.carrier)`);
|
|
974
|
+
const named = `SELECT 1 FROM message_parts mp JOIN messages m ON m.id = mp.id WHERE mp.sha256 = parts.sha256
|
|
975
|
+
AND CASE parts.fetched WHEN 1 THEN m.carrier ELSE m.from_address END = parts.source`;
|
|
976
|
+
const parts = Number(this.#run(`DELETE FROM parts WHERE (fetched = 1 OR created < ?) AND NOT EXISTS (${named})`, ms(uploadsBefore))
|
|
977
|
+
.changes);
|
|
978
|
+
let marks = 0;
|
|
979
|
+
for (const table of ['marks', 'counters'])
|
|
980
|
+
marks += Number(this.#run(`DELETE FROM ${table} WHERE expires <= ?`, t).changes);
|
|
981
|
+
// A recovery that lapsed ends as a cancelled or completed one does: its epoch advances.
|
|
982
|
+
this.#run("UPDATE agents SET recovery_epoch=recovery_epoch+1 WHERE address IN (SELECT id FROM vals WHERE scope='recovery' AND expires IS NOT NULL AND expires <= ?)", t);
|
|
983
|
+
marks += Number(this.#run('DELETE FROM vals WHERE expires IS NOT NULL AND expires <= ?', t).changes);
|
|
984
|
+
const signals = Number(this.#run('DELETE FROM signals WHERE expires <= ?', t).changes);
|
|
985
|
+
return { messages, parts, marks, signals };
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
async close() {
|
|
989
|
+
if (this.#db.isOpen)
|
|
990
|
+
this.#db.close();
|
|
991
|
+
}
|
|
992
|
+
}
|