demos for spacedust
1import { readFileSync } from 'node:fs';
2import Database from 'better-sqlite3';
3
4const SUBS_PER_ACCOUNT_LIMIT = 5;
5const SECONDARY_FILTERS_LIMIT = 100;
6
7const SCHEMA_FNAME = './schema.sql';
8
9export class DB {
10 #stmt_insert_account;
11 #stmt_get_account;
12 #stmt_delete_account;
13 #stmt_insert_push_sub;
14 #stmt_get_all_sub_dids;
15 #stmt_get_push_subs;
16 #stmt_get_push_sub;
17 #stmt_update_push_sub;
18 #stmt_delete_push_sub;
19 #stmt_get_push_info;
20 #stmt_set_role;
21 #stmt_get_notify_account_globals;
22 #stmt_set_notify_account_globals;
23 #stmt_set_notification_filter;
24 #stmt_get_notification_filter;
25 #stmt_count_notification_filters;
26 #stmt_rm_notification_filter;
27
28 #stmt_admin_add_secret;
29 #stmt_admin_expire_secret;
30 #stmt_admin_get_secrets;
31 #stmt_admin_secret_accounts;
32 #stmt_admin_nonsecret_accounts;
33
34 #transactionally;
35 #db;
36
37 constructor(filename, init = false, handleExit = true) {
38 const db = new Database(filename);
39
40 db.pragma('journal_mode = WAL');
41 db.pragma('foreign_keys = ON');
42
43 if (init) {
44 const createSchema = readFileSync(SCHEMA_FNAME, 'utf8');
45 db.exec(createSchema);
46 }
47
48 if (handleExit) { // probably a better way to do this 🤷♀️
49 process.on('exit', () => db.close());
50 process.on('SIGHUP', () => process.exit(128 + 1));
51 process.on('SIGINT', () => process.exit(128 + 2));
52 process.on('SIGTERM', () => process.exit(128 + 15));
53 }
54
55 this.#stmt_insert_account = db.prepare(
56 `insert into accounts (did)
57 values (?)
58 on conflict do nothing`);
59
60 this.#stmt_get_account = db.prepare(
61 `select a.first_seen,
62 a.role,
63 count(*) as total_subs
64 from accounts a
65 left outer join push_subs p on (p.account_did = a.did)
66 where a.did = ?
67 group by a.did`);
68
69 this.#stmt_delete_account = db.prepare(
70 `delete from accounts
71 where did = ?`);
72
73 this.#stmt_insert_push_sub = db.prepare(
74 `insert into push_subs (account_did, session, subscription)
75 values (?, ?, ?)
76 on conflict do update
77 set subscription = excluded.subscription`);
78
79 this.#stmt_get_all_sub_dids = db.prepare(
80 `select distinct account_did
81 from push_subs`);
82
83 this.#stmt_get_push_subs = db.prepare(
84 `select session,
85 subscription,
86 (julianday(CURRENT_TIMESTAMP) - julianday(last_push)) * 24 * 60 * 60
87 as 'since_last_push'
88 from push_subs
89 where account_did = ?`);
90
91 this.#stmt_get_push_sub = db.prepare(
92 `select session,
93 subscription,
94 (julianday(CURRENT_TIMESTAMP) - julianday(last_push)) * 24 * 60 * 60
95 as 'since_last_push'
96 from push_subs
97 where session = ?`);
98
99 this.#stmt_update_push_sub = db.prepare(
100 `update push_subs
101 set last_push = CURRENT_TIMESTAMP,
102 total_pushes = total_pushes + 1
103 where session = ?`);
104
105 this.#stmt_delete_push_sub = db.prepare(
106 `delete from push_subs
107 where session = ?`);
108
109 this.#stmt_get_push_info = db.prepare(
110 `select created,
111 last_push,
112 total_pushes
113 from push_subs
114 where account_did = ?`);
115
116 this.#stmt_set_role = db.prepare(
117 `update accounts
118 set role = :role,
119 secret_password = :secret_password
120 where did = :did
121 and :secret_password in (select password
122 from top_secret_passwords)`);
123
124 this.#stmt_get_notify_account_globals = db.prepare(
125 `select notify_enabled,
126 notify_self
127 from accounts
128 where did = :did`);
129
130 this.#stmt_set_notify_account_globals = db.prepare(
131 `update accounts
132 set notify_enabled = :notify_enabled,
133 notify_self = :notify_self
134 where did = :did`);
135
136 this.#stmt_set_notification_filter = db.prepare(
137 `insert into notification_filters (account_did, selector, selection, notify)
138 values (:did, :selector, :selection, :notify)
139 on conflict do update
140 set notify = excluded.notify`);
141
142 this.#stmt_get_notification_filter = db.prepare(
143 `select notify
144 from notification_filters
145 where account_did = :did
146 and selector = :selector
147 and selection = :selection`);
148
149 this.#stmt_count_notification_filters = db.prepare(
150 `select count(*) as n
151 from notification_filters
152 where account_did = :did`);
153
154 this.#stmt_rm_notification_filter = db.prepare(
155 `delete from notification_filters
156 where account_did = :did
157 and selector = :selector
158 and selection = :selection`);
159
160
161 this.#stmt_admin_add_secret = db.prepare(
162 `insert into top_secret_passwords (password)
163 values (?)`);
164
165 this.#stmt_admin_expire_secret = db.prepare(
166 `update top_secret_passwords
167 set expired = CURRENT_TIMESTAMP
168 where expired is null
169 and password = ?`);
170
171 this.#stmt_admin_get_secrets = db.prepare(
172 `select password,
173 unixepoch(added) * 1000 as 'added',
174 unixepoch(expired) * 1000 as 'expired'
175 from top_secret_passwords
176 order by expired, added desc`);
177
178 this.#stmt_admin_secret_accounts = db.prepare(
179 `select did,
180 unixepoch(first_seen) * 1000 as 'first_seen',
181 role,
182 count(*) as 'active_subs',
183 sum(p.total_pushes) as 'total_pushes',
184 unixepoch(max(p.last_push)) * 1000 as 'last_push'
185 from accounts
186 left outer join push_subs p on (p.account_did = did)
187 where secret_password = :password
188 group by did
189 order by first_seen desc`);
190
191 this.#stmt_admin_nonsecret_accounts = db.prepare(
192 `select did,
193 unixepoch(first_seen) * 1000 as 'first_seen',
194 role,
195 count(*) as 'active_subs',
196 sum(p.total_pushes) as 'total_pushes',
197 unixepoch(max(p.last_push)) * 1000 as 'last_push'
198 from accounts
199 left outer join push_subs p on (p.account_did = did)
200 left outer join top_secret_passwords s on (s.password = secret_password)
201 where s.password is null
202 group by did
203 order by first_seen desc`);
204
205 this.#transactionally = t => db.transaction(t).immediate();
206 }
207
208 addAccount(did) {
209 this.#stmt_insert_account.run(did);
210 }
211
212 getAccount(did) {
213 return this.#stmt_get_account.get(did);
214 }
215
216 addPushSub(did, session, sub) {
217 this.#transactionally(() => {
218 const res = this.#stmt_get_account.get(did);
219 if (!res) {
220 throw new Error(`Could not find account for ${did}`);
221 }
222 if (res.total_subs >= SUBS_PER_ACCOUNT_LIMIT) {
223 throw new Error(`Too many subscriptions for ${did}`);
224 }
225 this.#stmt_insert_push_sub.run(did, session, sub);
226 });
227 }
228
229 getSubscribedDids() {
230 return this.#stmt_get_all_sub_dids.all().map(r => r.account_did);
231 }
232
233 getSubsByDid(did) {
234 return this.#stmt_get_push_subs.all(did);
235 }
236
237 getSubBySession(session) {
238 return this.#stmt_get_push_sub.get(session);
239 }
240
241 updateLastPush(session) {
242 this.#stmt_update_push_sub.run(session);
243 }
244
245 deleteSub(session) {
246 this.#stmt_delete_push_sub.run(session);
247 }
248
249 setRole(params) {
250 let res = this.#stmt_set_role.run(params);
251 return res.changes > 0;
252 }
253
254 getNotifyAccountGlobals(did) {
255 return this.#stmt_get_notify_account_globals.get({ did });
256 }
257
258 setNotifyAccountGlobals(did, globals) {
259 this.#transactionally(() => {
260 const update = this.getNotifyAccountGlobals(did);
261 if (globals.notify_enabled !== undefined) update.notify_enabled = +globals.notify_enabled;
262 if (globals.notify_self !== undefined) update.notify_self = +globals.notify_self;
263 update.did = did;
264 this.#stmt_set_notify_account_globals.run(update);
265 });
266 }
267
268 getNotificationFilter(did, selector, selection) {
269 const res = this.#stmt_get_notification_filter.get({ did, selector, selection });
270 const dbNotify = res?.notify;
271 if (dbNotify === 1) return true;
272 else if (dbNotify === 0) return false;
273 else return null;
274 }
275
276 setNotificationFilter(did, selector, selection, notify) {
277 if (notify === null) {
278 this.#stmt_rm_notification_filter.run({ did, selector, selection });
279 } else {
280 this.#transactionally(() => {
281 const { n } = this.#stmt_count_notification_filters.get({ did });
282 if (n >= SECONDARY_FILTERS_LIMIT) {
283 throw new Error('max filters set for account');
284 }
285 let dbNotify = null;
286 if (notify === true) dbNotify = 1;
287 else if (notify === false) dbNotify = 0;
288 this.#stmt_set_notification_filter.run({ did, selector, selection, notify: dbNotify });
289 });
290 }
291 }
292
293
294 addTopSecret(secretPassword) {
295 this.#stmt_admin_add_secret.run(secretPassword);
296 }
297
298 expireTopSecret(secretPassword) {
299 let res = this.#stmt_admin_expire_secret.run(secretPassword);
300 return res.changes > 0;
301 }
302
303 getSecrets() {
304 return this.#stmt_admin_get_secrets.all();
305 }
306
307 getSecretAccounts(secretPassword) {
308 return this.#stmt_admin_secret_accounts.all({ password: secretPassword });
309 }
310
311 getNonSecretAccounts() {
312 return this.#stmt_admin_nonsecret_accounts.all();
313 }
314}