serverless-ircd 0.7.0 → 0.8.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/.github/workflows/ci.yml +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +546 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +5 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +112 -2
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +390 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +21 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +71 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +623 -12
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1-backed {@link PersistentServicesStore} for the Cloudflare adapter.
|
|
3
|
+
*
|
|
4
|
+
* Shares the `ACCOUNTS_DB` D1 binding from the SASL account store and
|
|
5
|
+
* lays out one table per services section so the whole
|
|
6
|
+
* {@link ServicesSnapshot} round-trips across a deploy:
|
|
7
|
+
*
|
|
8
|
+
* - `nickserv_accounts` — NickServ registrations (scrypt-hashed
|
|
9
|
+
* credential: never plaintext; the unified account record so a
|
|
10
|
+
* NickServ registration IS a SASL account).
|
|
11
|
+
* - `chanserv_channels` — ChanServ channel registrations (+ topic).
|
|
12
|
+
* - `chanserv_access` — ChanServ `(account, level)` access entries.
|
|
13
|
+
* - `chanserv_levels` — founder-redefined ChanServ level thresholds.
|
|
14
|
+
* - `hostserv_vhosts` — HostServ `(account, vhost)` entries.
|
|
15
|
+
* - `hostserv_pending_vhosts` — HostServ pending `(account, vhost)`
|
|
16
|
+
* requests awaiting oper approval.
|
|
17
|
+
* - `memoserv_memos` — MemoServ queued memos (globally-unique id).
|
|
18
|
+
* - `read_markers` — per-`(account, channel)` draft/read-marker.
|
|
19
|
+
* - `operserv_akills` — OperServ AKILL roster.
|
|
20
|
+
* - `operserv_jupes` — OperServ JUPE roster.
|
|
21
|
+
*
|
|
22
|
+
* Boot path: {@link loadD1ServicesStore} scans every table into a
|
|
23
|
+
* {@link ServicesSnapshot} (tolerating a fresh deploy where the tables do
|
|
24
|
+
* not exist yet) and hands it to a `D1ServicesStore`. The store mutates
|
|
25
|
+
* the inherited in-memory cache synchronously and enqueues one
|
|
26
|
+
* {@link ServicesWrite} per mutation; `flush()` drains the queue into a
|
|
27
|
+
* single `db.batch([...])` round-trip.
|
|
28
|
+
*
|
|
29
|
+
* Case-insensitive lookups: every PK column is the rfc1459-folded key
|
|
30
|
+
* (`*_key`) so a re-registration of `Alice` overwrites `alice`. Display
|
|
31
|
+
* spelling is preserved in a separate column so the wire renders the
|
|
32
|
+
* user's preferred casing after a reload.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
type AkillEntry,
|
|
37
|
+
type ChannelAccessLevel,
|
|
38
|
+
type ChannelLevelOp,
|
|
39
|
+
type Clock,
|
|
40
|
+
type JupeEntry,
|
|
41
|
+
type MemoEntry,
|
|
42
|
+
type NickEnforcePolicy,
|
|
43
|
+
type PersistedTopic,
|
|
44
|
+
PersistentServicesStore,
|
|
45
|
+
type RegisteredChannel,
|
|
46
|
+
type ServicesAccessRow,
|
|
47
|
+
type ServicesLevelRow,
|
|
48
|
+
type ServicesNickRow,
|
|
49
|
+
type ServicesPendingVhostRow,
|
|
50
|
+
type ServicesSnapshot,
|
|
51
|
+
type ServicesVhostRow,
|
|
52
|
+
type ServicesWrite,
|
|
53
|
+
SystemClock,
|
|
54
|
+
caseFold,
|
|
55
|
+
} from '@serverless-ircd/irc-core';
|
|
56
|
+
|
|
57
|
+
/** rfc1459 case-fold used for every storage key. */
|
|
58
|
+
function fold(value: string): string {
|
|
59
|
+
return caseFold('rfc1459', value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Minimal query surface shared by {@link D1Database} and
|
|
64
|
+
* {@link D1DatabaseSession}. The service loaders accept either so the
|
|
65
|
+
* snapshot hydrate can run through a session (for read-from-primary
|
|
66
|
+
* consistency) without the write path (`applyWrites`, which always targets
|
|
67
|
+
* the primary) caring which it is.
|
|
68
|
+
*/
|
|
69
|
+
interface D1QueryClient {
|
|
70
|
+
prepare(query: string): D1PreparedStatement;
|
|
71
|
+
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* DDL for the ten services tables, in dependency-free order. Exported so
|
|
76
|
+
* the seed tool, the provisioning docs, and the test `beforeEach` reference
|
|
77
|
+
* one schema definition. Each statement is `IF NOT EXISTS` so re-running
|
|
78
|
+
* the list on a warm deploy is a no-op.
|
|
79
|
+
*/
|
|
80
|
+
export const CREATE_NICKSERV_ACCOUNTS_SQL =
|
|
81
|
+
'CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
|
|
82
|
+
|
|
83
|
+
export const CREATE_SERVICES_TABLES_SQL: readonly string[] = [
|
|
84
|
+
CREATE_NICKSERV_ACCOUNTS_SQL,
|
|
85
|
+
'CREATE TABLE IF NOT EXISTS chanserv_channels (channel_key TEXT PRIMARY KEY, channel TEXT NOT NULL, founder TEXT NOT NULL, created_at INTEGER NOT NULL, mlock TEXT NOT NULL, restricted INTEGER NOT NULL, keep_topic INTEGER NOT NULL, topic_text TEXT, topic_setter TEXT, topic_set_at INTEGER)',
|
|
86
|
+
'CREATE TABLE IF NOT EXISTS chanserv_access (channel_key TEXT NOT NULL, account_key TEXT NOT NULL, channel TEXT NOT NULL, account TEXT NOT NULL, level INTEGER NOT NULL, PRIMARY KEY (channel_key, account_key))',
|
|
87
|
+
'CREATE TABLE IF NOT EXISTS chanserv_levels (channel_key TEXT NOT NULL, op TEXT NOT NULL, channel TEXT NOT NULL, level INTEGER NOT NULL, PRIMARY KEY (channel_key, op))',
|
|
88
|
+
'CREATE TABLE IF NOT EXISTS hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT NOT NULL, vhost TEXT NOT NULL)',
|
|
89
|
+
'CREATE TABLE IF NOT EXISTS hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT NOT NULL, vhost TEXT NOT NULL)',
|
|
90
|
+
'CREATE TABLE IF NOT EXISTS memoserv_memos (id INTEGER PRIMARY KEY, from_account TEXT NOT NULL, to_account TEXT NOT NULL, body TEXT NOT NULL, sent_at INTEGER NOT NULL, read INTEGER NOT NULL)',
|
|
91
|
+
'CREATE TABLE IF NOT EXISTS read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT NOT NULL, channel TEXT NOT NULL, msgid TEXT NOT NULL, PRIMARY KEY (account_key, channel_key))',
|
|
92
|
+
'CREATE TABLE IF NOT EXISTS operserv_akills (mask_key TEXT PRIMARY KEY, mask TEXT NOT NULL, reason TEXT NOT NULL, created_at INTEGER NOT NULL)',
|
|
93
|
+
'CREATE TABLE IF NOT EXISTS operserv_jupes (name_key TEXT PRIMARY KEY, name TEXT NOT NULL, reason TEXT NOT NULL, created_at INTEGER NOT NULL)',
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* D1-backed persistent services store. Extends the shared
|
|
98
|
+
* {@link PersistentServicesStore} write-behind base; the cache is hydrated
|
|
99
|
+
* from the snapshot loaded by {@link loadD1ServicesStore} at construction.
|
|
100
|
+
*
|
|
101
|
+
* `applyWrites` translates each {@link ServicesWrite} into one (or more)
|
|
102
|
+
* prepared statements and runs them in a single `db.batch()` round-trip
|
|
103
|
+
* per `flush()`.
|
|
104
|
+
*/
|
|
105
|
+
export class D1ServicesStore extends PersistentServicesStore {
|
|
106
|
+
private readonly db: D1Database;
|
|
107
|
+
|
|
108
|
+
constructor(opts: { db: D1Database; clock?: Clock; snapshot?: ServicesSnapshot }) {
|
|
109
|
+
const superOpts: { clock?: Clock; snapshot?: ServicesSnapshot } = {};
|
|
110
|
+
if (opts.clock !== undefined) superOpts.clock = opts.clock;
|
|
111
|
+
if (opts.snapshot !== undefined) superOpts.snapshot = opts.snapshot;
|
|
112
|
+
super(superOpts);
|
|
113
|
+
this.db = opts.db;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
protected async applyWrites(writes: ServicesWrite[]): Promise<void> {
|
|
117
|
+
const stmts: D1PreparedStatement[] = [];
|
|
118
|
+
for (const w of writes) {
|
|
119
|
+
for (const stmt of this.statementsFor(w)) stmts.push(stmt);
|
|
120
|
+
}
|
|
121
|
+
// The base class never calls applyWrites with an empty queue, and every
|
|
122
|
+
// ServicesWrite maps to ≥1 statement, so `stmts` is non-empty here.
|
|
123
|
+
await this.db.batch(stmts);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Maps a single {@link ServicesWrite} to its prepared statement(s).
|
|
128
|
+
* `deleteChannel` fans out into three deletes (channel + access +
|
|
129
|
+
* levels) so the backend wipes the channel's whole subtree in one
|
|
130
|
+
* batch.
|
|
131
|
+
*/
|
|
132
|
+
private statementsFor(w: ServicesWrite): D1PreparedStatement[] {
|
|
133
|
+
switch (w.kind) {
|
|
134
|
+
case 'upsertNick': {
|
|
135
|
+
const r = w.row;
|
|
136
|
+
return [
|
|
137
|
+
this.db
|
|
138
|
+
.prepare(
|
|
139
|
+
'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
140
|
+
)
|
|
141
|
+
.bind(
|
|
142
|
+
fold(r.nick),
|
|
143
|
+
r.nick,
|
|
144
|
+
r.account,
|
|
145
|
+
r.email,
|
|
146
|
+
r.createdAt,
|
|
147
|
+
r.enforce,
|
|
148
|
+
r.credential.algorithm,
|
|
149
|
+
r.credential.salt,
|
|
150
|
+
r.credential.hash,
|
|
151
|
+
),
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
case 'deleteNick':
|
|
155
|
+
return [
|
|
156
|
+
this.db.prepare('DELETE FROM nickserv_accounts WHERE nick_key = ?').bind(fold(w.nick)),
|
|
157
|
+
];
|
|
158
|
+
case 'upsertChannel': {
|
|
159
|
+
const r = w.row;
|
|
160
|
+
return [
|
|
161
|
+
this.db
|
|
162
|
+
.prepare(
|
|
163
|
+
'INSERT OR REPLACE INTO chanserv_channels (channel_key, channel, founder, created_at, mlock, restricted, keep_topic, topic_text, topic_setter, topic_set_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
164
|
+
)
|
|
165
|
+
.bind(
|
|
166
|
+
fold(r.channel),
|
|
167
|
+
r.channel,
|
|
168
|
+
r.founder,
|
|
169
|
+
r.createdAt,
|
|
170
|
+
r.mlock,
|
|
171
|
+
r.restricted ? 1 : 0,
|
|
172
|
+
r.keepTopic ? 1 : 0,
|
|
173
|
+
r.topic?.text ?? null,
|
|
174
|
+
r.topic?.setter ?? null,
|
|
175
|
+
r.topic?.setAt ?? null,
|
|
176
|
+
),
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
case 'deleteChannel':
|
|
180
|
+
return [
|
|
181
|
+
this.db
|
|
182
|
+
.prepare('DELETE FROM chanserv_channels WHERE channel_key = ?')
|
|
183
|
+
.bind(fold(w.channel)),
|
|
184
|
+
this.db
|
|
185
|
+
.prepare('DELETE FROM chanserv_access WHERE channel_key = ?')
|
|
186
|
+
.bind(fold(w.channel)),
|
|
187
|
+
this.db
|
|
188
|
+
.prepare('DELETE FROM chanserv_levels WHERE channel_key = ?')
|
|
189
|
+
.bind(fold(w.channel)),
|
|
190
|
+
];
|
|
191
|
+
case 'upsertChannelAccess': {
|
|
192
|
+
const r = w.row;
|
|
193
|
+
return [
|
|
194
|
+
this.db
|
|
195
|
+
.prepare(
|
|
196
|
+
'INSERT OR REPLACE INTO chanserv_access (channel_key, account_key, channel, account, level) VALUES (?, ?, ?, ?, ?)',
|
|
197
|
+
)
|
|
198
|
+
.bind(fold(r.channel), fold(r.account), r.channel, r.account, r.level),
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
case 'deleteChannelAccess':
|
|
202
|
+
return [
|
|
203
|
+
this.db
|
|
204
|
+
.prepare('DELETE FROM chanserv_access WHERE channel_key = ? AND account_key = ?')
|
|
205
|
+
.bind(fold(w.channel), fold(w.account)),
|
|
206
|
+
];
|
|
207
|
+
case 'upsertChannelLevel': {
|
|
208
|
+
const r = w.row;
|
|
209
|
+
return [
|
|
210
|
+
this.db
|
|
211
|
+
.prepare(
|
|
212
|
+
'INSERT OR REPLACE INTO chanserv_levels (channel_key, op, channel, level) VALUES (?, ?, ?, ?)',
|
|
213
|
+
)
|
|
214
|
+
.bind(fold(r.channel), r.op, r.channel, r.level),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
case 'resetChannelLevels':
|
|
218
|
+
return [
|
|
219
|
+
this.db
|
|
220
|
+
.prepare('DELETE FROM chanserv_levels WHERE channel_key = ?')
|
|
221
|
+
.bind(fold(w.channel)),
|
|
222
|
+
];
|
|
223
|
+
case 'upsertVhost': {
|
|
224
|
+
const r = w.row;
|
|
225
|
+
return [
|
|
226
|
+
this.db
|
|
227
|
+
.prepare(
|
|
228
|
+
'INSERT OR REPLACE INTO hostserv_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
|
|
229
|
+
)
|
|
230
|
+
.bind(fold(r.account), r.account, r.vhost),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
case 'deleteVhost':
|
|
234
|
+
return [
|
|
235
|
+
this.db
|
|
236
|
+
.prepare('DELETE FROM hostserv_vhosts WHERE account_key = ?')
|
|
237
|
+
.bind(fold(w.account)),
|
|
238
|
+
];
|
|
239
|
+
case 'upsertPendingVhost': {
|
|
240
|
+
const r = w.row;
|
|
241
|
+
return [
|
|
242
|
+
this.db
|
|
243
|
+
.prepare(
|
|
244
|
+
'INSERT OR REPLACE INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
|
|
245
|
+
)
|
|
246
|
+
.bind(fold(r.account), r.account, r.vhost),
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
case 'deletePendingVhost':
|
|
250
|
+
return [
|
|
251
|
+
this.db
|
|
252
|
+
.prepare('DELETE FROM hostserv_pending_vhosts WHERE account_key = ?')
|
|
253
|
+
.bind(fold(w.account)),
|
|
254
|
+
];
|
|
255
|
+
case 'insertMemo': {
|
|
256
|
+
const r = w.row;
|
|
257
|
+
return [
|
|
258
|
+
this.db
|
|
259
|
+
.prepare(
|
|
260
|
+
'INSERT OR REPLACE INTO memoserv_memos (id, from_account, to_account, body, sent_at, read) VALUES (?, ?, ?, ?, ?, ?)',
|
|
261
|
+
)
|
|
262
|
+
.bind(r.id, r.from, r.to, r.body, r.sentAt, 0),
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
case 'updateMemoRead':
|
|
266
|
+
return [this.db.prepare('UPDATE memoserv_memos SET read = 1 WHERE id = ?').bind(w.id)];
|
|
267
|
+
case 'deleteMemo':
|
|
268
|
+
return [this.db.prepare('DELETE FROM memoserv_memos WHERE id = ?').bind(w.id)];
|
|
269
|
+
case 'upsertReadMarker': {
|
|
270
|
+
const r = w.row;
|
|
271
|
+
return [
|
|
272
|
+
this.db
|
|
273
|
+
.prepare(
|
|
274
|
+
'INSERT OR REPLACE INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
|
|
275
|
+
)
|
|
276
|
+
.bind(fold(r.account), fold(r.channel), r.account, r.channel, r.msgid),
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
case 'upsertAkill': {
|
|
280
|
+
const r = w.row;
|
|
281
|
+
return [
|
|
282
|
+
this.db
|
|
283
|
+
.prepare(
|
|
284
|
+
'INSERT OR REPLACE INTO operserv_akills (mask_key, mask, reason, created_at) VALUES (?, ?, ?, ?)',
|
|
285
|
+
)
|
|
286
|
+
.bind(fold(r.mask), r.mask, r.reason, r.createdAt),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
case 'deleteAkill':
|
|
290
|
+
return [
|
|
291
|
+
this.db.prepare('DELETE FROM operserv_akills WHERE mask_key = ?').bind(fold(w.mask)),
|
|
292
|
+
];
|
|
293
|
+
case 'upsertJupe': {
|
|
294
|
+
const r = w.row;
|
|
295
|
+
return [
|
|
296
|
+
this.db
|
|
297
|
+
.prepare(
|
|
298
|
+
'INSERT OR REPLACE INTO operserv_jupes (name_key, name, reason, created_at) VALUES (?, ?, ?, ?)',
|
|
299
|
+
)
|
|
300
|
+
.bind(fold(r.name), r.name, r.reason, r.createdAt),
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
case 'deleteJupe':
|
|
304
|
+
return [
|
|
305
|
+
this.db.prepare('DELETE FROM operserv_jupes WHERE name_key = ?').bind(fold(w.name)),
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Scans every services table into a {@link ServicesSnapshot}, tolerating a
|
|
313
|
+
* fresh deploy where the tables do not exist yet (each `SELECT` that
|
|
314
|
+
* throws is treated as an empty section). Returns the empty snapshot when
|
|
315
|
+
* the binding is present but unprovisioned so the caller can still mutate
|
|
316
|
+
* + flush. Callers should run {@link ensureServicesTables} first to
|
|
317
|
+
* self-provision the schema; this loader stays defensive regardless.
|
|
318
|
+
*/
|
|
319
|
+
async function loadSnapshot(db: D1QueryClient): Promise<ServicesSnapshot> {
|
|
320
|
+
const [
|
|
321
|
+
nicks,
|
|
322
|
+
channels,
|
|
323
|
+
access,
|
|
324
|
+
levels,
|
|
325
|
+
vhosts,
|
|
326
|
+
pendingVhosts,
|
|
327
|
+
memos,
|
|
328
|
+
readMarkers,
|
|
329
|
+
akills,
|
|
330
|
+
jupes,
|
|
331
|
+
] = await Promise.all([
|
|
332
|
+
loadNicks(db),
|
|
333
|
+
loadChannels(db),
|
|
334
|
+
loadAccess(db),
|
|
335
|
+
loadLevels(db),
|
|
336
|
+
loadVhosts(db),
|
|
337
|
+
loadPendingVhosts(db),
|
|
338
|
+
loadMemos(db),
|
|
339
|
+
loadReadMarkers(db),
|
|
340
|
+
loadAkills(db),
|
|
341
|
+
loadJupes(db),
|
|
342
|
+
]);
|
|
343
|
+
return {
|
|
344
|
+
nicks,
|
|
345
|
+
channels,
|
|
346
|
+
access,
|
|
347
|
+
levels,
|
|
348
|
+
vhosts,
|
|
349
|
+
pendingVhosts,
|
|
350
|
+
memos,
|
|
351
|
+
readMarkers,
|
|
352
|
+
akills,
|
|
353
|
+
jupes,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
358
|
+
const rows = await safeSelect<{
|
|
359
|
+
nick: unknown;
|
|
360
|
+
account: unknown;
|
|
361
|
+
email: unknown;
|
|
362
|
+
created_at: unknown;
|
|
363
|
+
enforce: unknown;
|
|
364
|
+
algorithm: unknown;
|
|
365
|
+
salt: unknown;
|
|
366
|
+
hash: unknown;
|
|
367
|
+
}>(
|
|
368
|
+
db,
|
|
369
|
+
'SELECT nick, account, email, created_at, enforce, algorithm, salt, hash FROM nickserv_accounts',
|
|
370
|
+
);
|
|
371
|
+
const out: ServicesNickRow[] = [];
|
|
372
|
+
for (const r of rows) {
|
|
373
|
+
if (
|
|
374
|
+
typeof r.nick !== 'string' ||
|
|
375
|
+
typeof r.account !== 'string' ||
|
|
376
|
+
typeof r.email !== 'string' ||
|
|
377
|
+
typeof r.created_at !== 'number' ||
|
|
378
|
+
typeof r.enforce !== 'string' ||
|
|
379
|
+
typeof r.algorithm !== 'string' ||
|
|
380
|
+
typeof r.salt !== 'string' ||
|
|
381
|
+
typeof r.hash !== 'string'
|
|
382
|
+
) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
out.push({
|
|
386
|
+
nick: r.nick,
|
|
387
|
+
account: r.account,
|
|
388
|
+
email: r.email,
|
|
389
|
+
createdAt: r.created_at,
|
|
390
|
+
enforce: r.enforce as NickEnforcePolicy,
|
|
391
|
+
credential: {
|
|
392
|
+
account: r.account,
|
|
393
|
+
algorithm: 'scrypt',
|
|
394
|
+
salt: r.salt,
|
|
395
|
+
hash: r.hash,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function loadChannels(db: D1QueryClient): Promise<RegisteredChannel[]> {
|
|
403
|
+
const rows = await safeSelect<{
|
|
404
|
+
channel: unknown;
|
|
405
|
+
founder: unknown;
|
|
406
|
+
created_at: unknown;
|
|
407
|
+
mlock: unknown;
|
|
408
|
+
restricted: unknown;
|
|
409
|
+
keep_topic: unknown;
|
|
410
|
+
topic_text: unknown;
|
|
411
|
+
topic_setter: unknown;
|
|
412
|
+
topic_set_at: unknown;
|
|
413
|
+
}>(
|
|
414
|
+
db,
|
|
415
|
+
'SELECT channel, founder, created_at, mlock, restricted, keep_topic, topic_text, topic_setter, topic_set_at FROM chanserv_channels',
|
|
416
|
+
);
|
|
417
|
+
const out: RegisteredChannel[] = [];
|
|
418
|
+
for (const r of rows) {
|
|
419
|
+
if (
|
|
420
|
+
typeof r.channel !== 'string' ||
|
|
421
|
+
typeof r.founder !== 'string' ||
|
|
422
|
+
typeof r.created_at !== 'number' ||
|
|
423
|
+
typeof r.mlock !== 'string' ||
|
|
424
|
+
typeof r.restricted !== 'number' ||
|
|
425
|
+
typeof r.keep_topic !== 'number'
|
|
426
|
+
) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const channel: RegisteredChannel = {
|
|
430
|
+
channel: r.channel,
|
|
431
|
+
founder: r.founder,
|
|
432
|
+
createdAt: r.created_at,
|
|
433
|
+
mlock: r.mlock,
|
|
434
|
+
restricted: r.restricted !== 0,
|
|
435
|
+
keepTopic: r.keep_topic !== 0,
|
|
436
|
+
};
|
|
437
|
+
if (
|
|
438
|
+
typeof r.topic_text === 'string' &&
|
|
439
|
+
typeof r.topic_setter === 'string' &&
|
|
440
|
+
typeof r.topic_set_at === 'number'
|
|
441
|
+
) {
|
|
442
|
+
const topic: PersistedTopic = {
|
|
443
|
+
text: r.topic_text,
|
|
444
|
+
setter: r.topic_setter,
|
|
445
|
+
setAt: r.topic_set_at,
|
|
446
|
+
};
|
|
447
|
+
channel.topic = topic;
|
|
448
|
+
}
|
|
449
|
+
out.push(channel);
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function loadAccess(db: D1QueryClient): Promise<ServicesAccessRow[]> {
|
|
455
|
+
const rows = await safeSelect<{ channel: unknown; account: unknown; level: unknown }>(
|
|
456
|
+
db,
|
|
457
|
+
'SELECT channel, account, level FROM chanserv_access',
|
|
458
|
+
);
|
|
459
|
+
const out: ServicesAccessRow[] = [];
|
|
460
|
+
for (const r of rows) {
|
|
461
|
+
if (
|
|
462
|
+
typeof r.channel !== 'string' ||
|
|
463
|
+
typeof r.account !== 'string' ||
|
|
464
|
+
typeof r.level !== 'number'
|
|
465
|
+
)
|
|
466
|
+
continue;
|
|
467
|
+
out.push({ channel: r.channel, account: r.account, level: r.level as ChannelAccessLevel });
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function loadLevels(db: D1QueryClient): Promise<ServicesLevelRow[]> {
|
|
473
|
+
const rows = await safeSelect<{ channel: unknown; op: unknown; level: unknown }>(
|
|
474
|
+
db,
|
|
475
|
+
'SELECT channel, op, level FROM chanserv_levels',
|
|
476
|
+
);
|
|
477
|
+
const out: ServicesLevelRow[] = [];
|
|
478
|
+
for (const r of rows) {
|
|
479
|
+
if (typeof r.channel !== 'string' || typeof r.op !== 'string' || typeof r.level !== 'number')
|
|
480
|
+
continue;
|
|
481
|
+
out.push({ channel: r.channel, op: r.op as ChannelLevelOp, level: r.level });
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function loadVhosts(db: D1QueryClient): Promise<ServicesVhostRow[]> {
|
|
487
|
+
const rows = await safeSelect<{ account: unknown; vhost: unknown }>(
|
|
488
|
+
db,
|
|
489
|
+
'SELECT account, vhost FROM hostserv_vhosts',
|
|
490
|
+
);
|
|
491
|
+
const out: ServicesVhostRow[] = [];
|
|
492
|
+
for (const r of rows) {
|
|
493
|
+
if (typeof r.account !== 'string' || typeof r.vhost !== 'string') continue;
|
|
494
|
+
out.push({ account: r.account, vhost: r.vhost });
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function loadPendingVhosts(db: D1QueryClient): Promise<ServicesPendingVhostRow[]> {
|
|
500
|
+
const rows = await safeSelect<{ account: unknown; vhost: unknown }>(
|
|
501
|
+
db,
|
|
502
|
+
'SELECT account, vhost FROM hostserv_pending_vhosts',
|
|
503
|
+
);
|
|
504
|
+
const out: ServicesPendingVhostRow[] = [];
|
|
505
|
+
for (const r of rows) {
|
|
506
|
+
if (typeof r.account !== 'string' || typeof r.vhost !== 'string') continue;
|
|
507
|
+
out.push({ account: r.account, vhost: r.vhost });
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function loadMemos(db: D1QueryClient): Promise<MemoEntry[]> {
|
|
513
|
+
const rows = await safeSelect<{
|
|
514
|
+
id: unknown;
|
|
515
|
+
from_account: unknown;
|
|
516
|
+
to_account: unknown;
|
|
517
|
+
body: unknown;
|
|
518
|
+
sent_at: unknown;
|
|
519
|
+
read: unknown;
|
|
520
|
+
}>(db, 'SELECT id, from_account, to_account, body, sent_at, read FROM memoserv_memos');
|
|
521
|
+
const out: MemoEntry[] = [];
|
|
522
|
+
for (const r of rows) {
|
|
523
|
+
if (
|
|
524
|
+
typeof r.id !== 'number' ||
|
|
525
|
+
typeof r.from_account !== 'string' ||
|
|
526
|
+
typeof r.to_account !== 'string' ||
|
|
527
|
+
typeof r.body !== 'string' ||
|
|
528
|
+
typeof r.sent_at !== 'number' ||
|
|
529
|
+
typeof r.read !== 'number'
|
|
530
|
+
) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
out.push({
|
|
534
|
+
id: r.id,
|
|
535
|
+
from: r.from_account,
|
|
536
|
+
to: r.to_account,
|
|
537
|
+
body: r.body,
|
|
538
|
+
sentAt: r.sent_at,
|
|
539
|
+
read: r.read !== 0,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function loadReadMarkers(
|
|
546
|
+
db: D1QueryClient,
|
|
547
|
+
): Promise<{ account: string; channel: string; msgid: string }[]> {
|
|
548
|
+
const rows = await safeSelect<{ account: unknown; channel: unknown; msgid: unknown }>(
|
|
549
|
+
db,
|
|
550
|
+
'SELECT account, channel, msgid FROM read_markers',
|
|
551
|
+
);
|
|
552
|
+
const out: { account: string; channel: string; msgid: string }[] = [];
|
|
553
|
+
for (const r of rows) {
|
|
554
|
+
if (
|
|
555
|
+
typeof r.account !== 'string' ||
|
|
556
|
+
typeof r.channel !== 'string' ||
|
|
557
|
+
typeof r.msgid !== 'string'
|
|
558
|
+
)
|
|
559
|
+
continue;
|
|
560
|
+
out.push({ account: r.account, channel: r.channel, msgid: r.msgid });
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function loadAkills(db: D1QueryClient): Promise<AkillEntry[]> {
|
|
566
|
+
const rows = await safeSelect<{ mask: unknown; reason: unknown; created_at: unknown }>(
|
|
567
|
+
db,
|
|
568
|
+
'SELECT mask, reason, created_at FROM operserv_akills',
|
|
569
|
+
);
|
|
570
|
+
const out: AkillEntry[] = [];
|
|
571
|
+
for (const r of rows) {
|
|
572
|
+
if (
|
|
573
|
+
typeof r.mask !== 'string' ||
|
|
574
|
+
typeof r.reason !== 'string' ||
|
|
575
|
+
typeof r.created_at !== 'number'
|
|
576
|
+
)
|
|
577
|
+
continue;
|
|
578
|
+
out.push({ mask: r.mask, reason: r.reason, createdAt: r.created_at });
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function loadJupes(db: D1QueryClient): Promise<JupeEntry[]> {
|
|
584
|
+
const rows = await safeSelect<{ name: unknown; reason: unknown; created_at: unknown }>(
|
|
585
|
+
db,
|
|
586
|
+
'SELECT name, reason, created_at FROM operserv_jupes',
|
|
587
|
+
);
|
|
588
|
+
const out: JupeEntry[] = [];
|
|
589
|
+
for (const r of rows) {
|
|
590
|
+
if (
|
|
591
|
+
typeof r.name !== 'string' ||
|
|
592
|
+
typeof r.reason !== 'string' ||
|
|
593
|
+
typeof r.created_at !== 'number'
|
|
594
|
+
)
|
|
595
|
+
continue;
|
|
596
|
+
out.push({ name: r.name, reason: r.reason, createdAt: r.created_at });
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Runs `sql` against `db` and returns `result.results`, or `[]` when the
|
|
603
|
+
* table does not exist yet (fresh deploy) or the binding is unreachable.
|
|
604
|
+
* Silent-skip mirrors the DynamoDB loader: a missing table is treated as
|
|
605
|
+
* an empty section rather than a fatal boot error.
|
|
606
|
+
*/
|
|
607
|
+
async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
|
|
608
|
+
try {
|
|
609
|
+
const result = await db.prepare(sql).all<R>();
|
|
610
|
+
return result.results;
|
|
611
|
+
} catch (err) {
|
|
612
|
+
// A missing table on a fresh deploy legitimately yields no rows, but a
|
|
613
|
+
// real D1 error (transient unavailability, a lagging replica, a schema
|
|
614
|
+
// drift) would otherwise be masked as "empty section" — bricking
|
|
615
|
+
// NickServ for the whole connection (the snapshot is cached for life).
|
|
616
|
+
// Surface it so the failure shows up in `wrangler tail`.
|
|
617
|
+
console.error('[d1-services-store] snapshot read failed', { sql, error: String(err) });
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Loads the full services snapshot from D1 and returns a hydrated
|
|
624
|
+
* {@link D1ServicesStore}. Returns `undefined` only when the D1 binding is
|
|
625
|
+
* absent (services not configured) — an empty binding still returns a
|
|
626
|
+
* working store so reducers can register state and persist it on the next
|
|
627
|
+
* `flush()`.
|
|
628
|
+
*
|
|
629
|
+
* @param db The D1 binding (`undefined` when D1 is not configured).
|
|
630
|
+
* @param clock Optional injected clock (tests pass a {@link FakeClock};
|
|
631
|
+
* production leaves it at {@link SystemClock}).
|
|
632
|
+
*/
|
|
633
|
+
/**
|
|
634
|
+
* Idempotently creates the ten services tables so a fresh deploy
|
|
635
|
+
* self-provisions on first load without a manual DDL step. Every statement
|
|
636
|
+
* is `CREATE TABLE IF NOT EXISTS`, so a warm deploy re-runs the batch as a
|
|
637
|
+
* cheap no-op. Without this, `loadD1ServicesStore` loads an empty snapshot
|
|
638
|
+
* (the `SELECT`s hit `safeSelect`'s table-missing branch) and the first
|
|
639
|
+
* `flush()` throws on the missing table — registrations would appear to
|
|
640
|
+
* succeed (the NOTICE is emitted before flush) yet never persist, so a
|
|
641
|
+
* reconnecting client finds nothing in D1.
|
|
642
|
+
*
|
|
643
|
+
* Runs as one `db.batch()` round-trip (one D1 call per cold start / DO).
|
|
644
|
+
*/
|
|
645
|
+
async function ensureServicesTables(db: D1QueryClient): Promise<void> {
|
|
646
|
+
await db.batch(CREATE_SERVICES_TABLES_SQL.map((sql) => db.prepare(sql)));
|
|
647
|
+
await migrateServicesSchema(db);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* One-time schema migration for the `algorithm/salt/hash` unification
|
|
652
|
+
* (commit cfa5a3c). `CREATE TABLE IF NOT EXISTS` does NOT add columns to
|
|
653
|
+
* an existing table, so a D1 first provisioned by an older deploy keeps
|
|
654
|
+
* the original `nickserv_accounts (... enforce, password)` schema. Every
|
|
655
|
+
* current SELECT/INSERT references `algorithm`, so reads throw
|
|
656
|
+
* "no such column: algorithm" (masked by `safeSelect` as an empty
|
|
657
|
+
* snapshot) and writes throw inside `flush()` (after the success NOTICE).
|
|
658
|
+
*
|
|
659
|
+
* Detection: `PRAGMA table_info`. If `nickserv_accounts` lacks
|
|
660
|
+
* `algorithm`, it is the pre-unification schema. Those rows stored a
|
|
661
|
+
* non-scrypt `password` the current `verifyNick` cannot validate, so the
|
|
662
|
+
* table is dropped and recreated with the current schema (data loss is
|
|
663
|
+
* intentional and limited to already-unusable credentials). Idempotent:
|
|
664
|
+
* once the new schema is in place, `algorithm` is present and this is a
|
|
665
|
+
* no-op.
|
|
666
|
+
*/
|
|
667
|
+
async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
|
|
668
|
+
const nickservInfo = await db
|
|
669
|
+
.prepare('PRAGMA table_info(nickserv_accounts)')
|
|
670
|
+
.all<{ name: string }>();
|
|
671
|
+
const nickservCols = new Set(nickservInfo.results.map((r) => r.name));
|
|
672
|
+
if (nickservCols.has('algorithm')) return;
|
|
673
|
+
// Old schema detected (has `password`, lacks `algorithm`). The table is
|
|
674
|
+
// guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
|
|
675
|
+
// EXISTS); recreate it with the current schema.
|
|
676
|
+
await db.batch([
|
|
677
|
+
db.prepare('DROP TABLE nickserv_accounts'),
|
|
678
|
+
db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
|
|
679
|
+
]);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export async function loadD1ServicesStore(
|
|
683
|
+
db: D1Database | undefined,
|
|
684
|
+
clock: Clock = SystemClock,
|
|
685
|
+
): Promise<D1ServicesStore | undefined> {
|
|
686
|
+
if (db === undefined) return undefined;
|
|
687
|
+
// Read services state from the D1 PRIMARY. When read replication is
|
|
688
|
+
// enabled on the database, D1 may otherwise serve these SELECTs from a
|
|
689
|
+
// replica that is "arbitrarily out of date" (Cloudflare D1 consistency
|
|
690
|
+
// docs). A freshly-connecting client would then hydrate an empty/stale
|
|
691
|
+
// snapshot and cache it for the connection's whole life
|
|
692
|
+
// (`servicesResolved` guard in ConnectionDO) — so a NickServ IDENTIFY
|
|
693
|
+
// reports "not registered" even though the row exists in the primary.
|
|
694
|
+
// `first-primary` forces the first query to the primary; sequential
|
|
695
|
+
// consistency within the session keeps the rest of the load in sync.
|
|
696
|
+
// Writes (flush → `this.db.batch`) always target the primary regardless,
|
|
697
|
+
// so the store still holds the raw `db` for `applyWrites`.
|
|
698
|
+
// Safe per Cloudflare docs even when read replication is disabled.
|
|
699
|
+
const session = db.withSession('first-primary');
|
|
700
|
+
await ensureServicesTables(session);
|
|
701
|
+
const snapshot = await loadSnapshot(session);
|
|
702
|
+
return new D1ServicesStore({ db, clock, snapshot });
|
|
703
|
+
}
|