message-cp 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 +21 -0
- package/README.md +45 -2
- package/dist/accounts.js +279 -0
- package/dist/agents.js +507 -0
- package/dist/approve.js +260 -0
- package/dist/channels.js +262 -0
- package/dist/cli.js +523 -0
- package/dist/client-metadata.js +167 -0
- package/dist/connect.js +85 -0
- package/dist/dashboard.js +4961 -0
- package/dist/db.js +605 -0
- package/dist/http.js +1933 -0
- package/dist/index.js +21 -0
- package/dist/init.js +960 -0
- package/dist/login.js +530 -0
- package/dist/messages.js +513 -0
- package/dist/oauth.js +384 -0
- package/dist/server.js +420 -0
- package/dist/sessions.js +53 -0
- package/dist/settings.js +13 -0
- package/dist/wait.js +137 -0
- package/dist/webhooks.js +464 -0
- package/package.json +44 -4
package/dist/db.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { homeFile, setting } from "./settings.js";
|
|
6
|
+
/** The one database file shared by every server process on this machine. */
|
|
7
|
+
export function dbPath(env = process.env) {
|
|
8
|
+
return setting("DB", env) || homeFile("db.sqlite");
|
|
9
|
+
}
|
|
10
|
+
/** The account the migration creates: the token in MESSAGE_CP_HTTP_TOKEN, stdio and `message-cp wait` act as it. */
|
|
11
|
+
export const OWNER_ACCOUNT = 1;
|
|
12
|
+
// Agent names are unique within an account, so two accounts can each have a `planner`. NOCASE makes them unique
|
|
13
|
+
// regardless of case while keeping the first-registered spelling. The rebuild in addAccounts() creates the same table.
|
|
14
|
+
const agentsTable = (table) => `
|
|
15
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
16
|
+
id INTEGER PRIMARY KEY,
|
|
17
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
18
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
19
|
+
bio TEXT NOT NULL,
|
|
20
|
+
registered_at INTEGER NOT NULL,
|
|
21
|
+
last_seen INTEGER NOT NULL,
|
|
22
|
+
key_hash TEXT,
|
|
23
|
+
instance TEXT,
|
|
24
|
+
removed_at INTEGER,
|
|
25
|
+
removed_name TEXT,
|
|
26
|
+
person INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
admin INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
status TEXT,
|
|
29
|
+
status_at INTEGER,
|
|
30
|
+
UNIQUE (account_id, name)
|
|
31
|
+
);`;
|
|
32
|
+
// One row per recipient, so a broadcast has its own read state for each agent. A message belongs to its recipient's
|
|
33
|
+
// account. AUTOINCREMENT so an id, once handed out, never names a later message, even after the highest row is deleted:
|
|
34
|
+
// `in_reply_to`, `message_status` and `ask` refer to messages by id. `in_reply_to` is the id of the message a reply
|
|
35
|
+
// answers (\`send_message\` with \`in_reply_to\`), null for none; deleting that message sets it to null. The rebuild in
|
|
36
|
+
// rebuildMessages() creates the same table.
|
|
37
|
+
const messagesTable = (table) => `
|
|
38
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
recipient_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
41
|
+
sender TEXT NOT NULL,
|
|
42
|
+
body TEXT NOT NULL,
|
|
43
|
+
broadcast INTEGER NOT NULL,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
read_at INTEGER,
|
|
46
|
+
in_reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL
|
|
47
|
+
);`;
|
|
48
|
+
// Dropping a table drops its indexes, so rebuildMessages() creates these again.
|
|
49
|
+
const MESSAGES_INDEXES = `
|
|
50
|
+
CREATE INDEX IF NOT EXISTS messages_inbox ON messages (recipient_id, read_at, id);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS messages_history ON messages (recipient_id, id);
|
|
52
|
+
-- Deleting a conversation finds its messages by recipient and sender.
|
|
53
|
+
CREATE INDEX IF NOT EXISTS messages_by_sender ON messages (recipient_id, sender COLLATE NOCASE);
|
|
54
|
+
-- read_conversation reads a pair's messages newest first, so a page stops after its rows instead of sorting the pair's history.
|
|
55
|
+
CREATE INDEX IF NOT EXISTS messages_by_pair ON messages (recipient_id, sender COLLATE NOCASE, created_at, id);`;
|
|
56
|
+
// These need `in_reply_to`, which `migrate` adds to an older table, so `migrate` creates them rather than SCHEMA.
|
|
57
|
+
// rebuildMessages() creates them too, in its transaction, so the rebuilt table is never committed without them.
|
|
58
|
+
const MESSAGES_REPLY_INDEXES = `
|
|
59
|
+
-- Deleting a message finds the replies to it (ON DELETE SET NULL) through this, not by scanning the table.
|
|
60
|
+
CREATE INDEX IF NOT EXISTS messages_by_reply ON messages (in_reply_to);
|
|
61
|
+
-- \`ask\` finds the replies to one question sent to the asker through this, in id order, not by walking every reply to it.
|
|
62
|
+
CREATE INDEX IF NOT EXISTS messages_by_question ON messages (in_reply_to, recipient_id);`;
|
|
63
|
+
// One-time dashboard sign-in codes from `message-cp login-link`, and invites an admin person made (src/login.ts). Only
|
|
64
|
+
// the code's hash is kept. `person_id` is set for a link that signs in as that person; `used_at` once it has given its
|
|
65
|
+
// token or, for an invite, was cancelled. AUTOINCREMENT so an invite's id, which GET and DELETE /api/invites hand out,
|
|
66
|
+
// never names a later invite once expired codes are deleted. A table from before it is rebuilt by rebuildLoginCodes(),
|
|
67
|
+
// which creates the same table; the invite columns are added to it in migrate first.
|
|
68
|
+
const loginCodesTable = (table) => `
|
|
69
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
code_hash TEXT NOT NULL UNIQUE,
|
|
72
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
73
|
+
person_id INTEGER REFERENCES agents(id) ON DELETE CASCADE,
|
|
74
|
+
expires_at INTEGER NOT NULL,
|
|
75
|
+
used_at INTEGER,
|
|
76
|
+
invite INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
created_at INTEGER,
|
|
78
|
+
cancelled_at INTEGER
|
|
79
|
+
);`;
|
|
80
|
+
// Dropping a table drops its indexes, so rebuildLoginCodes() creates these again.
|
|
81
|
+
const LOGIN_CODES_INDEXES = `
|
|
82
|
+
CREATE INDEX IF NOT EXISTS login_codes_by_expiry ON login_codes (expires_at);
|
|
83
|
+
-- The same as mcp_authorizations_by_agent, for an agent's row, should one ever be deleted.
|
|
84
|
+
CREATE INDEX IF NOT EXISTS login_codes_by_person ON login_codes (person_id);`;
|
|
85
|
+
// The dashboard sign-ins a login link or an invite started (`redeemLoginCode` in src/login.ts), one per redeemed code.
|
|
86
|
+
// Only a hash of the session id is kept: the id is a bearer credential in place of its token, which `accountForToken`
|
|
87
|
+
// checks afresh on every request, so revoking the token or removing its person ends the session too. Tokens are never
|
|
88
|
+
// deleted, only revoked, so `token_id` needs no ON DELETE. `hash` is UNIQUE, which gives the lookup its index.
|
|
89
|
+
// AUTOINCREMENT so a sign-in's id, which GET and DELETE /api/signins hand out, never names a later sign-in once the
|
|
90
|
+
// highest one is ended. A table from before it is rebuilt by rebuildDashboardSessions(), which creates the same table.
|
|
91
|
+
const dashboardSessionsTable = (table) => `
|
|
92
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
94
|
+
hash TEXT NOT NULL UNIQUE,
|
|
95
|
+
token_id INTEGER NOT NULL REFERENCES tokens(id),
|
|
96
|
+
created_at INTEGER NOT NULL
|
|
97
|
+
);`;
|
|
98
|
+
// Dropping a table drops its indexes, so rebuildDashboardSessions() creates this again.
|
|
99
|
+
const DASHBOARD_SESSIONS_INDEXES = `
|
|
100
|
+
-- Adding a token checks its sessions (the foreign key) through this, not by scanning the table.
|
|
101
|
+
CREATE INDEX IF NOT EXISTS dashboard_sessions_by_token ON dashboard_sessions (token_id);`;
|
|
102
|
+
// Every table lives here so later changes add to one place.
|
|
103
|
+
const SCHEMA = `
|
|
104
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
105
|
+
id INTEGER PRIMARY KEY,
|
|
106
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
107
|
+
created_at INTEGER NOT NULL
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
-- Only a token's hash is stored; the token itself is shown once, when it is created.
|
|
111
|
+
CREATE TABLE IF NOT EXISTS tokens (
|
|
112
|
+
id INTEGER PRIMARY KEY,
|
|
113
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
114
|
+
hash TEXT NOT NULL UNIQUE,
|
|
115
|
+
created_at INTEGER NOT NULL,
|
|
116
|
+
revoked_at INTEGER,
|
|
117
|
+
read_only INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
agent_only INTEGER NOT NULL DEFAULT 0
|
|
119
|
+
);
|
|
120
|
+
${agentsTable("agents")}
|
|
121
|
+
|
|
122
|
+
${messagesTable("messages")}
|
|
123
|
+
${MESSAGES_INDEXES}
|
|
124
|
+
-- A channel belongs to one account and exists from its first post, or from the first agent joining it. Names follow the agent-name rules, unique per
|
|
125
|
+
-- account regardless of case, keeping the first spelling. None of these names may appear in DROP_REMOVED.
|
|
126
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
127
|
+
id INTEGER PRIMARY KEY,
|
|
128
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
129
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
130
|
+
created_at INTEGER NOT NULL,
|
|
131
|
+
UNIQUE (account_id, name)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
-- Append-only. The sender's row tells an agent's own posts from those of a later agent that registers the same name.
|
|
135
|
+
CREATE TABLE IF NOT EXISTS channel_posts (
|
|
136
|
+
id INTEGER PRIMARY KEY,
|
|
137
|
+
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
|
138
|
+
sender_id INTEGER NOT NULL REFERENCES agents(id),
|
|
139
|
+
sender TEXT NOT NULL,
|
|
140
|
+
body TEXT NOT NULL,
|
|
141
|
+
created_at INTEGER NOT NULL
|
|
142
|
+
);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS channel_posts_by_channel ON channel_posts (channel_id, id);
|
|
144
|
+
|
|
145
|
+
-- Each agent's read position in each channel: the id of the last post it has read. Keyed on the agent's row, so an
|
|
146
|
+
-- agent that unregisters and a later one with its name never share it. No row means it has read nothing.
|
|
147
|
+
-- \`joined\` is 1 while the agent has joined the channel, so that its unread posts wake the agent.
|
|
148
|
+
CREATE TABLE IF NOT EXISTS channel_reads (
|
|
149
|
+
agent_id INTEGER NOT NULL REFERENCES agents(id),
|
|
150
|
+
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
|
151
|
+
last_read_id INTEGER NOT NULL,
|
|
152
|
+
joined INTEGER NOT NULL DEFAULT 0,
|
|
153
|
+
PRIMARY KEY (agent_id, channel_id)
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
-- A post that names \`@<agent>\` records one row for each live agent of its account with that name, but the poster.
|
|
157
|
+
-- Keyed on the agent's row, like channel_reads, so a later agent that registers a removed agent's name never sees it.
|
|
158
|
+
-- \`read_at\` is set when read_inbox returns it.
|
|
159
|
+
CREATE TABLE IF NOT EXISTS mentions (
|
|
160
|
+
agent_id INTEGER NOT NULL REFERENCES agents(id),
|
|
161
|
+
post_id INTEGER NOT NULL REFERENCES channel_posts(id),
|
|
162
|
+
read_at INTEGER,
|
|
163
|
+
PRIMARY KEY (agent_id, post_id)
|
|
164
|
+
);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS mentions_unread ON mentions (agent_id, read_at, post_id);
|
|
166
|
+
-- GET /api/channel-posts reads a page of posts' mentions through this, not by scanning every mention.
|
|
167
|
+
CREATE INDEX IF NOT EXISTS mentions_by_post ON mentions (post_id);
|
|
168
|
+
|
|
169
|
+
-- Outgoing webhooks, added only with \`message-cp webhook\`; an admin person token also lists and removes them over
|
|
170
|
+
-- /api/webhooks. \`event\` is 'channel', 'mention' or 'agent' and \`name\` the channel or agent in the same account,
|
|
171
|
+
-- which need not exist yet. The secret is stored as is: deliveries sign with it.
|
|
172
|
+
-- So is the value of the one header a webhook may carry (\`header_name\`, \`header_value\`, added in \`migrate\`), since
|
|
173
|
+
-- deliveries send it. \`max_per_hour\` (also added in \`migrate\`, null for none) caps its delivery requests in a fixed
|
|
174
|
+
-- window: \`window_started_at\` is when the first request after the last window ended was claimed, and \`window_count\`
|
|
175
|
+
-- how many requests have been claimed since; the window lasts 60 minutes. \`fixed_body\` (also added in \`migrate\`) is the
|
|
176
|
+
-- JSON body the owner set with \`webhook add --body\`, sent in place of the event body, or null to send the event body.
|
|
177
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
178
|
+
id INTEGER PRIMARY KEY,
|
|
179
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
180
|
+
url TEXT NOT NULL,
|
|
181
|
+
event TEXT NOT NULL CHECK (event IN ('channel', 'mention', 'agent')),
|
|
182
|
+
name TEXT NOT NULL,
|
|
183
|
+
include_text INTEGER NOT NULL,
|
|
184
|
+
secret TEXT NOT NULL,
|
|
185
|
+
created_at INTEGER NOT NULL
|
|
186
|
+
);
|
|
187
|
+
CREATE INDEX IF NOT EXISTS webhooks_by_name ON webhooks (account_id, event, name COLLATE NOCASE);
|
|
188
|
+
|
|
189
|
+
-- What a webhook is owed: one row per webhook per message or post it is for, queued in the transaction that stores the
|
|
190
|
+
-- message or post. Only ids are kept, so the body is built when it is sent. \`serve\` sends them: \`attempts\` is counted
|
|
191
|
+
-- and \`claimed_until\` set when a sender claims a row, before its request, and \`delivered_at\` set on a 2xx answer.
|
|
192
|
+
-- \`delivery_uuid\` (added in \`migrate\`) is what a fixed body's placeholder stands for: a random UUID set on the first
|
|
193
|
+
-- request sent for a webhook with one and kept for every retry, so it does not repeat even when a row's id is reused.
|
|
194
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
195
|
+
id INTEGER PRIMARY KEY,
|
|
196
|
+
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
|
197
|
+
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
|
|
198
|
+
post_id INTEGER REFERENCES channel_posts(id) ON DELETE CASCADE,
|
|
199
|
+
created_at INTEGER NOT NULL,
|
|
200
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
201
|
+
delivered_at INTEGER,
|
|
202
|
+
CHECK ((message_id IS NULL) <> (post_id IS NULL))
|
|
203
|
+
);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS webhook_deliveries_pending ON webhook_deliveries (delivered_at, id);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS webhook_deliveries_by_webhook ON webhook_deliveries (webhook_id);
|
|
206
|
+
-- \`sendPending\` reads each webhook's oldest pending rows through this, up to what it may send, not the whole queue.
|
|
207
|
+
CREATE INDEX IF NOT EXISTS webhook_deliveries_pending_by_webhook ON webhook_deliveries (webhook_id, delivered_at, id);
|
|
208
|
+
-- Deleting a message finds its queued deliveries (ON DELETE CASCADE) through this, not by scanning the table.
|
|
209
|
+
CREATE INDEX IF NOT EXISTS webhook_deliveries_by_message ON webhook_deliveries (message_id);
|
|
210
|
+
-- \`listAccountMessages\` asks whether a message has a delivered delivery through this, reading only the delivered rows'
|
|
211
|
+
-- index entries rather than every delivery of the message.
|
|
212
|
+
CREATE INDEX IF NOT EXISTS webhook_deliveries_delivered_by_message ON webhook_deliveries (message_id, delivered_at);
|
|
213
|
+
|
|
214
|
+
-- The HTTP sessions that have registered, so a session keeps its agent when \`serve\` restarts. Only a hash of the
|
|
215
|
+
-- session id is kept: the id is a bearer credential alongside the token. The agent fields are the session's \`Identity\`,
|
|
216
|
+
-- checked again before a restore, so a removed registration is never restored. \`last_used\` is for the idle timeout.
|
|
217
|
+
-- \`ended\` is set once a DELETE ends the session; nothing clears it, so no server can make the id restorable again.
|
|
218
|
+
CREATE TABLE IF NOT EXISTS http_sessions (
|
|
219
|
+
hash TEXT PRIMARY KEY,
|
|
220
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
221
|
+
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
222
|
+
registered_at INTEGER NOT NULL,
|
|
223
|
+
instance TEXT,
|
|
224
|
+
last_used INTEGER NOT NULL,
|
|
225
|
+
ended INTEGER NOT NULL DEFAULT 0
|
|
226
|
+
);
|
|
227
|
+
CREATE INDEX IF NOT EXISTS http_sessions_by_last_used ON http_sessions (last_used);
|
|
228
|
+
|
|
229
|
+
${dashboardSessionsTable("dashboard_sessions")}
|
|
230
|
+
${DASHBOARD_SESSIONS_INDEXES}
|
|
231
|
+
-- GET /api/signins finds an account's live tokens through this, then each one's sessions through the index above, rather
|
|
232
|
+
-- than scanning every account's sessions.
|
|
233
|
+
CREATE INDEX IF NOT EXISTS tokens_live_by_account ON tokens (account_id) WHERE revoked_at IS NULL;
|
|
234
|
+
|
|
235
|
+
-- MCP clients that registered themselves for \`serve --public-url\`'s authorization flow (src/oauth.ts). \`redirect_uris\` is
|
|
236
|
+
-- a JSON array of the URIs as the client sent them. Belongs to no account. These names are not the removed GitHub
|
|
237
|
+
-- sign-in's \`oauth_*\` tables, which DROP_REMOVED drops, and may not appear there.
|
|
238
|
+
CREATE TABLE IF NOT EXISTS mcp_clients (
|
|
239
|
+
id INTEGER PRIMARY KEY,
|
|
240
|
+
client_id TEXT NOT NULL UNIQUE,
|
|
241
|
+
client_name TEXT,
|
|
242
|
+
redirect_uris TEXT NOT NULL,
|
|
243
|
+
created_at INTEGER NOT NULL
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
-- One authorization request from showing its page until its code is exchanged or it expires. Only hashes are kept of the
|
|
247
|
+
-- request handle, the client's \`state\` and the code. \`code_hash\`, \`account_id\` and \`agent_id\` are set when a person
|
|
248
|
+
-- approves it, with a new \`expires_at\` for the code.
|
|
249
|
+
CREATE TABLE IF NOT EXISTS mcp_authorizations (
|
|
250
|
+
id INTEGER PRIMARY KEY,
|
|
251
|
+
request_hash TEXT NOT NULL UNIQUE,
|
|
252
|
+
client_id INTEGER NOT NULL REFERENCES mcp_clients(id) ON DELETE CASCADE,
|
|
253
|
+
redirect_uri TEXT NOT NULL,
|
|
254
|
+
code_challenge TEXT NOT NULL,
|
|
255
|
+
resource TEXT,
|
|
256
|
+
state_hash TEXT,
|
|
257
|
+
expires_at INTEGER NOT NULL,
|
|
258
|
+
code_hash TEXT UNIQUE,
|
|
259
|
+
account_id INTEGER REFERENCES accounts(id),
|
|
260
|
+
agent_id INTEGER REFERENCES agents(id) ON DELETE CASCADE
|
|
261
|
+
);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS mcp_authorizations_by_expiry ON mcp_authorizations (expires_at);
|
|
263
|
+
-- Forgetting a client deletes its requests (ON DELETE CASCADE) through this, not by scanning the table.
|
|
264
|
+
CREATE INDEX IF NOT EXISTS mcp_authorizations_by_client ON mcp_authorizations (client_id);
|
|
265
|
+
-- The same for an agent's row, should one ever be deleted.
|
|
266
|
+
CREATE INDEX IF NOT EXISTS mcp_authorizations_by_agent ON mcp_authorizations (agent_id);
|
|
267
|
+
|
|
268
|
+
${loginCodesTable("login_codes")}
|
|
269
|
+
${LOGIN_CODES_INDEXES}
|
|
270
|
+
|
|
271
|
+
-- One listen token per agent, issued to a session connected by approval (\`issueListenToken\` in src/accounts.ts): it only
|
|
272
|
+
-- waits for that agent's mail on GET /wait. Only its hash is kept. \`token_id\` is the approval's token; the listen token
|
|
273
|
+
-- works only while that token is unrevoked and the agent is live, as \`accountForToken\` checks on each request, so
|
|
274
|
+
-- revoking the approval or removing the agent ends it in the transaction that does so. A later one replaces it.
|
|
275
|
+
CREATE TABLE IF NOT EXISTS listen_tokens (
|
|
276
|
+
id INTEGER PRIMARY KEY,
|
|
277
|
+
agent_id INTEGER NOT NULL UNIQUE REFERENCES agents(id) ON DELETE CASCADE,
|
|
278
|
+
token_id INTEGER NOT NULL REFERENCES tokens(id),
|
|
279
|
+
hash TEXT NOT NULL UNIQUE,
|
|
280
|
+
created_at INTEGER NOT NULL
|
|
281
|
+
);
|
|
282
|
+
-- The foreign key's check when a token row is deleted finds its listen tokens through this, not by scanning them.
|
|
283
|
+
CREATE INDEX IF NOT EXISTS listen_tokens_by_token ON listen_tokens (token_id);
|
|
284
|
+
`;
|
|
285
|
+
// CREATE TABLE IF NOT EXISTS leaves an existing table alone, so columns added later are added here, only when missing.
|
|
286
|
+
const hasColumn = (db, column, table = "agents") => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
|
|
287
|
+
function addColumn(db, column, definition, table = "agents") {
|
|
288
|
+
if (hasColumn(db, column, table))
|
|
289
|
+
return;
|
|
290
|
+
try {
|
|
291
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
// Another process added it between our check and the ALTER.
|
|
295
|
+
if (!(err instanceof Error && /duplicate column name/i.test(err.message)))
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Shared memories, the task board, the hive's run log and the GitHub sign-in were removed, so their tables go too.
|
|
300
|
+
// Dropping a table drops its indexes and triggers; the full-text index is a table of its own.
|
|
301
|
+
// IF EXISTS makes this a no-op once done, and harmless when several processes open the file at once.
|
|
302
|
+
const DROP_REMOVED = `
|
|
303
|
+
DROP TABLE IF EXISTS memories_fts;
|
|
304
|
+
DROP TABLE IF EXISTS memory_tags;
|
|
305
|
+
DROP TABLE IF EXISTS memories;
|
|
306
|
+
DROP TABLE IF EXISTS task_tags;
|
|
307
|
+
DROP TABLE IF EXISTS tasks;
|
|
308
|
+
DROP TABLE IF EXISTS hive_runs;
|
|
309
|
+
DROP TABLE IF EXISTS github_users;
|
|
310
|
+
DROP TABLE IF EXISTS oauth_clients;
|
|
311
|
+
DROP TABLE IF EXISTS oauth_pending;
|
|
312
|
+
DROP TABLE IF EXISTS oauth_codes;
|
|
313
|
+
DROP TABLE IF EXISTS oauth_grants;
|
|
314
|
+
`;
|
|
315
|
+
/** The copy of the database the accounts migration leaves next to it, taken just before it rebuilds `agents`. */
|
|
316
|
+
export const preAccountsCopy = (path) => `${path}.before-accounts`;
|
|
317
|
+
/** Flushes `path`, a file or a directory, to disk. */
|
|
318
|
+
function fsyncPath(path) {
|
|
319
|
+
const fd = openSync(path, "r");
|
|
320
|
+
try {
|
|
321
|
+
fsyncSync(fd);
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
closeSync(fd);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Copies the database at `path` to `preAccountsCopy(path)`, owner-only whatever the umask, replacing any copy there:
|
|
329
|
+
* it runs only when the migration is about to happen, so an existing copy is stale or from a migration that never
|
|
330
|
+
* committed. The copy is written under another name, flushed, renamed into place and the directory flushed, so it is
|
|
331
|
+
* never seen half-written and is on disk before the rebuild can commit.
|
|
332
|
+
*/
|
|
333
|
+
function copyBeforeAccounts(path) {
|
|
334
|
+
const copy = preAccountsCopy(path);
|
|
335
|
+
const partial = `${copy}.${randomBytes(6).toString("hex")}.partial`;
|
|
336
|
+
writeFileSync(partial, "", { mode: 0o600, flag: "wx" });
|
|
337
|
+
try {
|
|
338
|
+
chmodSync(partial, 0o600);
|
|
339
|
+
// A second connection, since VACUUM cannot run inside the caller's transaction. It reads the last commit, which
|
|
340
|
+
// nobody can change while the caller holds the write lock.
|
|
341
|
+
const source = new Database(path);
|
|
342
|
+
try {
|
|
343
|
+
source.pragma("busy_timeout = 5000");
|
|
344
|
+
source.prepare("VACUUM INTO ?").run(partial);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
source.close();
|
|
348
|
+
}
|
|
349
|
+
chmodSync(partial, 0o600);
|
|
350
|
+
fsyncPath(partial);
|
|
351
|
+
renameSync(partial, copy);
|
|
352
|
+
fsyncPath(dirname(copy));
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
rmSync(partial, { force: true });
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Gives every agent an account by rebuilding `agents` with an `account_id`, all existing rows going to the owner's
|
|
361
|
+
* account, so that names are unique per account. SQLite cannot change a table's UNIQUE constraint in place.
|
|
362
|
+
* The check, the copy and the rebuild all happen under the write lock, so when several processes open the file at
|
|
363
|
+
* once exactly one of them copies and rebuilds, and the others find the work done.
|
|
364
|
+
*/
|
|
365
|
+
function addAccounts(db, path) {
|
|
366
|
+
if (hasColumn(db, "account_id"))
|
|
367
|
+
return;
|
|
368
|
+
// Dropping the old table must not cascade to the messages that reference it; this cannot change inside a transaction.
|
|
369
|
+
const foreignKeys = db.pragma("foreign_keys", { simple: true });
|
|
370
|
+
db.pragma("foreign_keys = OFF");
|
|
371
|
+
try {
|
|
372
|
+
db.transaction(() => {
|
|
373
|
+
if (hasColumn(db, "account_id"))
|
|
374
|
+
return;
|
|
375
|
+
copyBeforeAccounts(path);
|
|
376
|
+
db.exec(agentsTable("agents_by_account"));
|
|
377
|
+
// `person` and `admin` are left to their defaults: no person can exist before accounts did. `migrate` has already
|
|
378
|
+
// added the status columns to the old table, so they come across too.
|
|
379
|
+
db.prepare(`INSERT INTO agents_by_account (id, account_id, name, bio, registered_at, last_seen, key_hash, instance, removed_at, removed_name, status, status_at)
|
|
380
|
+
SELECT id, ?, name, bio, registered_at, last_seen, key_hash, instance, removed_at, removed_name, status, status_at FROM agents`).run(OWNER_ACCOUNT);
|
|
381
|
+
db.exec("DROP TABLE agents");
|
|
382
|
+
db.exec("ALTER TABLE agents_by_account RENAME TO agents");
|
|
383
|
+
if (db.pragma("foreign_key_check").length > 0)
|
|
384
|
+
throw new Error("accounts migration broke a foreign key");
|
|
385
|
+
}).immediate();
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
db.pragma(`foreign_keys = ${foreignKeys ? "ON" : "OFF"}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const messagesAutoincrement = (db) => /\bAUTOINCREMENT\b/i.test(db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'").get().sql);
|
|
392
|
+
/**
|
|
393
|
+
* Rebuilds a `messages` table from before AUTOINCREMENT as `messagesTable`, keeping every row and its id, so that no id
|
|
394
|
+
* is handed out again once the highest message is deleted. SQLite cannot add AUTOINCREMENT to a table in place. Copying
|
|
395
|
+
* the rows with their ids sets the table's sqlite_sequence entry to the highest id kept. As in addAccounts(), the check
|
|
396
|
+
* and the rebuild happen under the write lock, so when several processes open the file at once exactly one rebuilds.
|
|
397
|
+
*/
|
|
398
|
+
function rebuildMessages(db) {
|
|
399
|
+
if (messagesAutoincrement(db))
|
|
400
|
+
return;
|
|
401
|
+
// Dropping the old table must not cascade to the webhook deliveries or replies that reference it.
|
|
402
|
+
const foreignKeys = db.pragma("foreign_keys", { simple: true });
|
|
403
|
+
db.pragma("foreign_keys = OFF");
|
|
404
|
+
try {
|
|
405
|
+
db.transaction(() => {
|
|
406
|
+
if (messagesAutoincrement(db))
|
|
407
|
+
return;
|
|
408
|
+
db.exec(messagesTable("messages_autoincrement"));
|
|
409
|
+
db.exec(`INSERT INTO messages_autoincrement (id, recipient_id, sender, body, broadcast, created_at, read_at, in_reply_to)
|
|
410
|
+
SELECT id, recipient_id, sender, body, broadcast, created_at, read_at, in_reply_to FROM messages ORDER BY id`);
|
|
411
|
+
db.exec("DROP TABLE messages");
|
|
412
|
+
db.exec("ALTER TABLE messages_autoincrement RENAME TO messages");
|
|
413
|
+
db.exec(MESSAGES_INDEXES);
|
|
414
|
+
db.exec(MESSAGES_REPLY_INDEXES);
|
|
415
|
+
if (db.pragma("foreign_key_check").length > 0)
|
|
416
|
+
throw new Error("messages migration broke a foreign key");
|
|
417
|
+
}).immediate();
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
db.pragma(`foreign_keys = ${foreignKeys ? "ON" : "OFF"}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const loginCodesAutoincrement = (db) => /\bAUTOINCREMENT\b/i.test(db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'login_codes'").get().sql);
|
|
424
|
+
/**
|
|
425
|
+
* Rebuilds a `login_codes` table from before AUTOINCREMENT as `loginCodesTable`, keeping every row and its id, as
|
|
426
|
+
* rebuildMessages() does for `messages`, so that no invite id is handed out again once the highest code is deleted,
|
|
427
|
+
* whichever server deletes it. Under the write lock, so exactly one process rebuilds. No table refers to `login_codes`.
|
|
428
|
+
*/
|
|
429
|
+
function rebuildLoginCodes(db) {
|
|
430
|
+
if (loginCodesAutoincrement(db))
|
|
431
|
+
return;
|
|
432
|
+
db.transaction(() => {
|
|
433
|
+
if (loginCodesAutoincrement(db))
|
|
434
|
+
return;
|
|
435
|
+
db.exec(loginCodesTable("login_codes_autoincrement"));
|
|
436
|
+
db.exec(`INSERT INTO login_codes_autoincrement (id, code_hash, account_id, person_id, expires_at, used_at, invite, created_at, cancelled_at)
|
|
437
|
+
SELECT id, code_hash, account_id, person_id, expires_at, used_at, invite, created_at, cancelled_at FROM login_codes ORDER BY id`);
|
|
438
|
+
db.exec("DROP TABLE login_codes");
|
|
439
|
+
db.exec("ALTER TABLE login_codes_autoincrement RENAME TO login_codes");
|
|
440
|
+
db.exec(LOGIN_CODES_INDEXES);
|
|
441
|
+
}).immediate();
|
|
442
|
+
}
|
|
443
|
+
const dashboardSessionsAutoincrement = (db) => /\bAUTOINCREMENT\b/i.test(db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'dashboard_sessions'").get().sql);
|
|
444
|
+
/**
|
|
445
|
+
* Rebuilds a `dashboard_sessions` table from before AUTOINCREMENT as `dashboardSessionsTable`, keeping every row and its
|
|
446
|
+
* id, as rebuildLoginCodes() does for `login_codes`, so that no sign-in id is handed out again once the highest sign-in
|
|
447
|
+
* is ended. Under the write lock, so exactly one process rebuilds. No table refers to `dashboard_sessions`.
|
|
448
|
+
*/
|
|
449
|
+
function rebuildDashboardSessions(db) {
|
|
450
|
+
if (dashboardSessionsAutoincrement(db))
|
|
451
|
+
return;
|
|
452
|
+
db.transaction(() => {
|
|
453
|
+
if (dashboardSessionsAutoincrement(db))
|
|
454
|
+
return;
|
|
455
|
+
db.exec(dashboardSessionsTable("dashboard_sessions_autoincrement"));
|
|
456
|
+
db.exec(`INSERT INTO dashboard_sessions_autoincrement (id, hash, token_id, created_at)
|
|
457
|
+
SELECT id, hash, token_id, created_at FROM dashboard_sessions ORDER BY id`);
|
|
458
|
+
db.exec("DROP TABLE dashboard_sessions");
|
|
459
|
+
db.exec("ALTER TABLE dashboard_sessions_autoincrement RENAME TO dashboard_sessions");
|
|
460
|
+
db.exec(DASHBOARD_SESSIONS_INDEXES);
|
|
461
|
+
}).immediate();
|
|
462
|
+
}
|
|
463
|
+
function migrate(db, path) {
|
|
464
|
+
addColumn(db, "key_hash", "TEXT");
|
|
465
|
+
// A random token per registration, so a session can tell its agent from a later one that reuses the id or name.
|
|
466
|
+
addColumn(db, "instance", "TEXT");
|
|
467
|
+
// A removed agent keeps its row, so the messages addressed to it stay and its id is never reused.
|
|
468
|
+
addColumn(db, "removed_at", "INTEGER");
|
|
469
|
+
addColumn(db, "removed_name", "TEXT");
|
|
470
|
+
// 1 for a person the operator added with `person add`; every row from before it existed is an agent.
|
|
471
|
+
addColumn(db, "person", "INTEGER NOT NULL DEFAULT 0");
|
|
472
|
+
// 1 for a person marked admin (`person add --admin`, `person admin`); every row from before it existed is not one, so
|
|
473
|
+
// a server from before it treats an admin person's token as a plain person token.
|
|
474
|
+
addColumn(db, "admin", "INTEGER NOT NULL DEFAULT 0");
|
|
475
|
+
// An agent's status line (`set_status`) and when it was set, in ms; null for no line. It is current for
|
|
476
|
+
// STATUS_TTL_MS after `status_at`, worked out when it is read.
|
|
477
|
+
addColumn(db, "status", "TEXT");
|
|
478
|
+
addColumn(db, "status_at", "INTEGER");
|
|
479
|
+
addColumn(db, "joined", "INTEGER NOT NULL DEFAULT 0", "channel_reads");
|
|
480
|
+
// 1 for a token that can only read (`token add --read-only`); tokens made before it existed keep full access.
|
|
481
|
+
addColumn(db, "read_only", "INTEGER NOT NULL DEFAULT 0", "tokens");
|
|
482
|
+
// 1 for a token that cannot act as a person (`token add --agent-only`); tokens made before it existed keep their access.
|
|
483
|
+
// Its row has read_only 1 as well, so a server from before this column sees it as read-only, not full.
|
|
484
|
+
addColumn(db, "agent_only", "INTEGER NOT NULL DEFAULT 0", "tokens");
|
|
485
|
+
// The agent an agent token acts as (`message-cp connect`), null for every other token. Its row is agent-only, so a
|
|
486
|
+
// server from before this column takes it for an agent token that is not bound to one agent.
|
|
487
|
+
addColumn(db, "agent_id", "INTEGER REFERENCES agents(id)", "tokens");
|
|
488
|
+
// The person a person token sends and posts as (`token add --person`), null for every other token. Its row is
|
|
489
|
+
// read-only, so a server from before this column takes it for a read-only token, never a full one.
|
|
490
|
+
addColumn(db, "person_id", "INTEGER REFERENCES agents(id)", "tokens");
|
|
491
|
+
// DELETE /api/people counts the removed person's live tokens, and through them their sign-ins, by this, not by
|
|
492
|
+
// scanning the account's tokens under the write lock (removePersonBy in login.ts).
|
|
493
|
+
db.exec("CREATE INDEX IF NOT EXISTS tokens_live_by_person ON tokens (person_id) WHERE revoked_at IS NULL");
|
|
494
|
+
// DELETE /api/agents counts the removed agent's live tokens by this, not by scanning the account's tokens under the
|
|
495
|
+
// write lock (removeAgentBy in login.ts).
|
|
496
|
+
db.exec("CREATE INDEX IF NOT EXISTS tokens_live_by_agent ON tokens (agent_id) WHERE revoked_at IS NULL");
|
|
497
|
+
// 1 for a token the OAuth code exchange issued (exchangeCode in oauth.ts), null for every other token and for one it
|
|
498
|
+
// issued before this column. With it, the client's `client_name` as stored then (null for none) and, for a client known
|
|
499
|
+
// by a Client ID Metadata Document, that URL's host (null otherwise). A server from before these columns ignores them.
|
|
500
|
+
addColumn(db, "oauth", "INTEGER", "tokens");
|
|
501
|
+
addColumn(db, "client_name", "TEXT", "tokens");
|
|
502
|
+
addColumn(db, "client_host", "TEXT", "tokens");
|
|
503
|
+
// 1 for an agent token an approval issued for a client that registers its own agents (exchangeCode in oauth.ts), 0 for
|
|
504
|
+
// every other token and every one from before it. Its row is agent-only, read-only and bound to no agent, so a server
|
|
505
|
+
// from before this column takes it for an unbound agent token, never for anything with more access.
|
|
506
|
+
addColumn(db, "registers", "INTEGER NOT NULL DEFAULT 0", "tokens");
|
|
507
|
+
// When a request last used the token, in ms, as `accountForToken` in accounts.ts records it to within
|
|
508
|
+
// TOKEN_USE_RESOLUTION_MS; null until a request is recorded, so for every token from before it at first. A server from
|
|
509
|
+
// before it ignores it.
|
|
510
|
+
addColumn(db, "last_used_at", "INTEGER", "tokens");
|
|
511
|
+
// 1 for an approval of a client that registers its own agents, which stores no agent_id; 0 for every other. A server
|
|
512
|
+
// from before this column refuses to exchange its code, as it finds no live agent for a null agent_id.
|
|
513
|
+
addColumn(db, "registers", "INTEGER NOT NULL DEFAULT 0", "mcp_authorizations");
|
|
514
|
+
// Until when the request of the sender that last claimed the delivery may be in flight; no sender takes it before then.
|
|
515
|
+
addColumn(db, "claimed_until", "INTEGER", "webhook_deliveries");
|
|
516
|
+
// The one header a webhook's deliveries carry (`webhook add --header-stdin`); both null for a webhook without one.
|
|
517
|
+
addColumn(db, "header_name", "TEXT", "webhooks");
|
|
518
|
+
addColumn(db, "header_value", "TEXT", "webhooks");
|
|
519
|
+
// A webhook's cap on delivery requests per window (`webhook add --max-per-hour`); null, so no cap, for one without.
|
|
520
|
+
addColumn(db, "max_per_hour", "INTEGER", "webhooks");
|
|
521
|
+
addColumn(db, "window_started_at", "INTEGER", "webhooks");
|
|
522
|
+
addColumn(db, "window_count", "INTEGER NOT NULL DEFAULT 0", "webhooks");
|
|
523
|
+
// The JSON body a webhook sends in place of the event body (`webhook add --body`); null, so the event body, for one without.
|
|
524
|
+
addColumn(db, "fixed_body", "TEXT", "webhooks");
|
|
525
|
+
addColumn(db, "delivery_uuid", "TEXT", "webhook_deliveries");
|
|
526
|
+
// Set once a DELETE ends an HTTP session, so no server restores it or records it again.
|
|
527
|
+
addColumn(db, "ended", "INTEGER NOT NULL DEFAULT 0", "http_sessions");
|
|
528
|
+
// The id of the message a reply answers (`send_message` with `in_reply_to`), null for none. Deleting that message sets
|
|
529
|
+
// it to null. Added here before rebuildMessages() copies it.
|
|
530
|
+
addColumn(db, "in_reply_to", "INTEGER REFERENCES messages(id) ON DELETE SET NULL", "messages");
|
|
531
|
+
// 1 for an invite an admin person made (POST /api/people and /api/invites, src/login.ts), which lasts INVITE_TTL_MS; 0
|
|
532
|
+
// for a `login-link` code and every code from before it. With it, when the code was made (null for one from before)
|
|
533
|
+
// and when it was cancelled (null for not), all added here before rebuildLoginCodes() copies them. Cancelling sets `used_at` as well, so a server from before these columns,
|
|
534
|
+
// which reads only `person_id`, `expires_at` and `used_at`, refuses a cancelled invite as used rather than redeeming it.
|
|
535
|
+
addColumn(db, "invite", "INTEGER NOT NULL DEFAULT 0", "login_codes");
|
|
536
|
+
addColumn(db, "created_at", "INTEGER", "login_codes");
|
|
537
|
+
addColumn(db, "cancelled_at", "INTEGER", "login_codes");
|
|
538
|
+
rebuildLoginCodes(db);
|
|
539
|
+
rebuildDashboardSessions(db);
|
|
540
|
+
// GET /api/invites pages an account's unused invites newest first, and storeCode counts its invites, through this.
|
|
541
|
+
db.exec("CREATE INDEX IF NOT EXISTS login_codes_invites ON login_codes (account_id, used_at, id) WHERE invite = 1");
|
|
542
|
+
// storeCode counts the unused `login-link` codes through this, not by scanning invites.
|
|
543
|
+
db.exec("CREATE INDEX IF NOT EXISTS login_codes_unused_links ON login_codes (used_at) WHERE invite = 0 AND used_at IS NULL");
|
|
544
|
+
rebuildMessages(db);
|
|
545
|
+
db.exec(MESSAGES_REPLY_INDEXES);
|
|
546
|
+
db.exec(DROP_REMOVED);
|
|
547
|
+
db.prepare("INSERT OR IGNORE INTO accounts (id, name, created_at) VALUES (?, 'owner', ?)").run(OWNER_ACCOUNT, Date.now());
|
|
548
|
+
addAccounts(db, path);
|
|
549
|
+
// Until when an agent counts as waiting for mail (`markWaiting` in agents.ts, renewed while a wait is held), in ms; null
|
|
550
|
+
// for never. Added after addAccounts(), whose rebuild of `agents` does not carry it. A delivery queued for an agent's
|
|
551
|
+
// message or mention while it is marked records the agent's id in `held_for`; `sendPending` sends it only once the mark
|
|
552
|
+
// has ended, and drops it instead if the agent has read the message or mention by then.
|
|
553
|
+
addColumn(db, "waiting_until", "INTEGER");
|
|
554
|
+
addColumn(db, "held_for", "INTEGER", "webhook_deliveries");
|
|
555
|
+
// The registering token (`tokens.registers`) whose session registered the agent, null for every other agent and every
|
|
556
|
+
// one from before it. That token may act again as the agent; nothing else follows from it. Added after addAccounts(),
|
|
557
|
+
// like waiting_until.
|
|
558
|
+
addColumn(db, "token_id", "INTEGER REFERENCES tokens(id)");
|
|
559
|
+
// The foreign key's check when a token row is added or changed finds its agents through this, not by scanning agents.
|
|
560
|
+
db.exec("CREATE INDEX IF NOT EXISTS agents_by_token ON agents (token_id)");
|
|
561
|
+
// Broadcast reads every live agent of one account in name order; this keeps it from scanning the removed rows too.
|
|
562
|
+
db.exec("CREATE INDEX IF NOT EXISTS agents_live ON agents (account_id, name) WHERE removed_at IS NULL");
|
|
563
|
+
}
|
|
564
|
+
// Several processes switching a new file to WAL at once make the losers fail with SQLITE_BUSY straight away,
|
|
565
|
+
// without waiting for the busy timeout, so the switch is retried here.
|
|
566
|
+
const WAL_RETRY_LIMIT_MS = 5000;
|
|
567
|
+
const WAL_RETRY_MAX_DELAY_MS = 50;
|
|
568
|
+
function isBusy(err) {
|
|
569
|
+
const code = err?.code;
|
|
570
|
+
return typeof code === "string" && code.startsWith("SQLITE_BUSY");
|
|
571
|
+
}
|
|
572
|
+
// openDb is synchronous, so the pause between retries blocks the thread.
|
|
573
|
+
function sleepSync(ms) {
|
|
574
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
575
|
+
}
|
|
576
|
+
function enableWal(db) {
|
|
577
|
+
const deadline = Date.now() + WAL_RETRY_LIMIT_MS;
|
|
578
|
+
let delay = 1;
|
|
579
|
+
for (;;) {
|
|
580
|
+
try {
|
|
581
|
+
db.pragma("journal_mode = WAL");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
if (!isBusy(err) || Date.now() >= deadline)
|
|
586
|
+
throw err;
|
|
587
|
+
sleepSync(delay * (0.5 + Math.random())); // jitter keeps the losers from retrying in step
|
|
588
|
+
delay = Math.min(delay * 2, WAL_RETRY_MAX_DELAY_MS);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
export function openDb(path = dbPath()) {
|
|
593
|
+
// Owner-only, whatever the umask: names and bios are not for other local accounts.
|
|
594
|
+
// WAL and journal files take the permissions of the main file.
|
|
595
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
596
|
+
if (!existsSync(path))
|
|
597
|
+
writeFileSync(path, "", { mode: 0o600, flag: "a" });
|
|
598
|
+
const db = new Database(path);
|
|
599
|
+
// WAL and a busy timeout let several processes share the file without lock errors; enableWal retries the first switch.
|
|
600
|
+
enableWal(db);
|
|
601
|
+
db.pragma("busy_timeout = 5000");
|
|
602
|
+
db.exec(SCHEMA);
|
|
603
|
+
migrate(db, path);
|
|
604
|
+
return db;
|
|
605
|
+
}
|