chapterjs 0.2.3 → 0.4.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/README.md +65 -0
- package/dist/cli/build.js +14 -1
- package/dist/cli/cli.js +41 -6
- package/dist/cli/dev.js +29 -1
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +49 -7
- package/dist/cli/project.js +103 -19
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +57 -0
- package/dist/cli/start.js +43 -17
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +446 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/events/router.d.ts +9 -4
- package/dist/events/router.js +24 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- package/dist/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -0
- package/dist/store/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -1
- package/dist/store/file-backend.js +29 -0
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +17 -0
- package/dist/store/memory-backend.js +82 -0
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +11 -0
- package/dist/store/scope.js +23 -0
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +83 -33
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// The backend of the stores declared with `storage: 'redis'`: each value
|
|
2
|
+
// is one Redis key, `<store>:<part>:<part>` (`dev:` in front of it under
|
|
3
|
+
// `chapterjs dev`, so a Redis shared with production is never mixed up),
|
|
4
|
+
// its JSON as the value and its expiration given to Redis itself, which
|
|
5
|
+
// forgets the key alone. Nothing is written that a store did not ask for.
|
|
6
|
+
// An update is a transaction: WATCH the key, read it, then MULTI, write,
|
|
7
|
+
// EXEC, which Redis refuses when anyone wrote the key in between; it is
|
|
8
|
+
// then tried again on what is there now. A transaction needs a connection
|
|
9
|
+
// of its own (WATCH and MULTI belong to the connection, and the main one
|
|
10
|
+
// carries every other command), so a few are opened when updates need
|
|
11
|
+
// them, and lent one update at a time.
|
|
12
|
+
// https://redis.io/docs/latest/commands/set/ (PX), /commands/pttl/,
|
|
13
|
+
// /commands/scan/ (MATCH, COUNT), /commands/mget/, /commands/del/,
|
|
14
|
+
// https://redis.io/docs/latest/develop/interact/transactions/ (WATCH)
|
|
15
|
+
import { RedisClient } from '../redis/client.js';
|
|
16
|
+
import { RedisRankingBackend } from './redis-ranking.js';
|
|
17
|
+
import { tooBusy, UPDATE_ATTEMPTS } from './ipc-backend.js';
|
|
18
|
+
import { KeyQueue, startsWith, } from './backend.js';
|
|
19
|
+
/** The prefix of every key written by `chapterjs dev`. */
|
|
20
|
+
export const DEV_PREFIX = 'dev:';
|
|
21
|
+
/**
|
|
22
|
+
* What a part of a key may not contain as is: `:` separates the parts,
|
|
23
|
+
* `*`, `?`, `[`, `]` and `\` mean something to SCAN, `%` is the escape.
|
|
24
|
+
*/
|
|
25
|
+
const ESCAPED = /[%:*?[\]\\]/g;
|
|
26
|
+
/** A part of a key, safe between two `:` and in a MATCH pattern. */
|
|
27
|
+
export const escapePart = (part) => part.replace(ESCAPED, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
|
|
28
|
+
/** The parts back from a key, after the prefix and the name of the store. */
|
|
29
|
+
export const unescapePart = (text) => text.replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
30
|
+
/** The data of every store with `storage: 'redis'`, in one Redis. */
|
|
31
|
+
export class RedisStoreBackend {
|
|
32
|
+
#client;
|
|
33
|
+
#prefix;
|
|
34
|
+
#scanCount;
|
|
35
|
+
#now;
|
|
36
|
+
#connect;
|
|
37
|
+
#maxLent;
|
|
38
|
+
/** Connections for updates that wait for one. */
|
|
39
|
+
#idle = [];
|
|
40
|
+
/** Every connection opened for updates, to close them. */
|
|
41
|
+
#opened = [];
|
|
42
|
+
/** Updates waiting for a connection, when every one is lent. */
|
|
43
|
+
#queue = [];
|
|
44
|
+
#updates = new KeyQueue();
|
|
45
|
+
#rankings = null;
|
|
46
|
+
constructor(client, options = {}) {
|
|
47
|
+
this.#client = client;
|
|
48
|
+
this.#prefix = options.prefix ?? '';
|
|
49
|
+
this.#scanCount = options.scanCount ?? 500;
|
|
50
|
+
this.#now = options.now ?? Date.now;
|
|
51
|
+
this.#maxLent = options.transactions ?? 4;
|
|
52
|
+
this.#connect = options.connect ?? (() => new RedisClient(client.address));
|
|
53
|
+
}
|
|
54
|
+
/** A connection of its own for one update, given back by `release`. */
|
|
55
|
+
async #lend() {
|
|
56
|
+
const idle = this.#idle.pop();
|
|
57
|
+
if (idle)
|
|
58
|
+
return idle;
|
|
59
|
+
if (this.#opened.length < this.#maxLent) {
|
|
60
|
+
const opened = this.#connect();
|
|
61
|
+
this.#opened.push(opened);
|
|
62
|
+
return opened;
|
|
63
|
+
}
|
|
64
|
+
return new Promise(resolve => this.#queue.push(resolve));
|
|
65
|
+
}
|
|
66
|
+
#release(client) {
|
|
67
|
+
const next = this.#queue.shift();
|
|
68
|
+
if (next)
|
|
69
|
+
next(client);
|
|
70
|
+
else
|
|
71
|
+
this.#idle.push(client);
|
|
72
|
+
}
|
|
73
|
+
/** Rankings as sorted sets: the key of a ranking is the key of its scope. */
|
|
74
|
+
rankings() {
|
|
75
|
+
this.#rankings ??= new RedisRankingBackend(this.#client, (name, scope) => this.key(name, scope));
|
|
76
|
+
return this.#rankings;
|
|
77
|
+
}
|
|
78
|
+
/** The client, for the preflight check of the CLI. */
|
|
79
|
+
get client() {
|
|
80
|
+
return this.#client;
|
|
81
|
+
}
|
|
82
|
+
/** The Redis key of a store and parts. */
|
|
83
|
+
key(store, parts) {
|
|
84
|
+
return [this.#prefix + store, ...parts.map(escapePart)].join(':');
|
|
85
|
+
}
|
|
86
|
+
/** The parts of a Redis key of `store`, or `null` for another key. */
|
|
87
|
+
#parts(store, key) {
|
|
88
|
+
const head = `${this.#prefix}${store}:`;
|
|
89
|
+
if (!key.startsWith(head))
|
|
90
|
+
return null;
|
|
91
|
+
return key.slice(head.length).split(':').map(unescapePart);
|
|
92
|
+
}
|
|
93
|
+
#entry(value, pttl) {
|
|
94
|
+
if (typeof value !== 'string')
|
|
95
|
+
return undefined;
|
|
96
|
+
// -2: the key is gone since; -1: no expiration.
|
|
97
|
+
if (pttl === -2)
|
|
98
|
+
return undefined;
|
|
99
|
+
return {
|
|
100
|
+
value: JSON.parse(value),
|
|
101
|
+
expiresAt: typeof pttl === 'number' && pttl >= 0 ? this.#now() + pttl : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async get(store, key) {
|
|
105
|
+
const name = this.key(store, key);
|
|
106
|
+
const [value, pttl] = await Promise.all([
|
|
107
|
+
this.#client.send('GET', name),
|
|
108
|
+
this.#client.send('PTTL', name),
|
|
109
|
+
]);
|
|
110
|
+
return this.#entry(value, pttl);
|
|
111
|
+
}
|
|
112
|
+
async set(store, key, entry) {
|
|
113
|
+
const name = this.key(store, key);
|
|
114
|
+
const json = JSON.stringify(entry.value);
|
|
115
|
+
if (entry.expiresAt === null) {
|
|
116
|
+
await this.#client.send('SET', name, json);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const ms = entry.expiresAt - this.#now();
|
|
120
|
+
// Already expired: there is nothing to keep, and nothing to leave.
|
|
121
|
+
if (ms <= 0) {
|
|
122
|
+
await this.#client.send('DEL', name);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await this.#client.send('SET', name, json, 'PX', ms);
|
|
126
|
+
}
|
|
127
|
+
update(store, key, change) {
|
|
128
|
+
const name = this.key(store, key);
|
|
129
|
+
return this.#updates.run(name, () => this.#update(store, name, change));
|
|
130
|
+
}
|
|
131
|
+
async #update(store, name, change) {
|
|
132
|
+
const client = await this.#lend();
|
|
133
|
+
try {
|
|
134
|
+
for (let attempt = 0; attempt < UPDATE_ATTEMPTS; attempt++) {
|
|
135
|
+
await client.send('WATCH', name);
|
|
136
|
+
const [value, pttl] = await Promise.all([
|
|
137
|
+
client.send('GET', name),
|
|
138
|
+
client.send('PTTL', name),
|
|
139
|
+
]);
|
|
140
|
+
const current = this.#entry(value, pttl);
|
|
141
|
+
let next;
|
|
142
|
+
try {
|
|
143
|
+
next = change(current);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await client.send('UNWATCH');
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
if (next === undefined) {
|
|
150
|
+
await client.send('UNWATCH');
|
|
151
|
+
return current;
|
|
152
|
+
}
|
|
153
|
+
const ms = next.expiresAt === null ? null : next.expiresAt - this.#now();
|
|
154
|
+
const write = ms === null
|
|
155
|
+
? ['SET', name, JSON.stringify(next.value)]
|
|
156
|
+
: ms <= 0
|
|
157
|
+
? ['DEL', name]
|
|
158
|
+
: ['SET', name, JSON.stringify(next.value), 'PX', ms];
|
|
159
|
+
const [, , done] = await Promise.all([
|
|
160
|
+
client.send('MULTI'),
|
|
161
|
+
client.send(...write),
|
|
162
|
+
client.send('EXEC'),
|
|
163
|
+
]);
|
|
164
|
+
// `null`: someone wrote the key since WATCH, nothing was written.
|
|
165
|
+
if (done !== null)
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(tooBusy(store));
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.#release(client);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async clear(store, prefix) {
|
|
175
|
+
let count = 0;
|
|
176
|
+
for (const keys of await this.#scan(store, prefix)) {
|
|
177
|
+
if (keys.length === 0)
|
|
178
|
+
continue;
|
|
179
|
+
const removed = await this.#client.send('DEL', ...keys.map(item => item.key));
|
|
180
|
+
if (typeof removed === 'number')
|
|
181
|
+
count += removed;
|
|
182
|
+
}
|
|
183
|
+
return count;
|
|
184
|
+
}
|
|
185
|
+
async delete(store, key) {
|
|
186
|
+
return (await this.#client.send('DEL', this.key(store, key))) === 1;
|
|
187
|
+
}
|
|
188
|
+
async entries(store, prefix) {
|
|
189
|
+
const listed = [];
|
|
190
|
+
for (const batch of await this.#scan(store, prefix)) {
|
|
191
|
+
if (batch.length === 0)
|
|
192
|
+
continue;
|
|
193
|
+
const [values, ...ttls] = await Promise.all([
|
|
194
|
+
this.#client.send('MGET', ...batch.map(item => item.key)),
|
|
195
|
+
...batch.map(item => this.#client.send('PTTL', item.key)),
|
|
196
|
+
]);
|
|
197
|
+
if (!Array.isArray(values))
|
|
198
|
+
continue;
|
|
199
|
+
batch.forEach((item, index) => {
|
|
200
|
+
const entry = this.#entry(values[index] ?? null, ttls[index] ?? -2);
|
|
201
|
+
if (entry)
|
|
202
|
+
listed.push({ key: item.parts, ...entry });
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return listed;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The keys of `store` that start with `prefix`, part by part, in batches
|
|
209
|
+
* of `scanCount`.
|
|
210
|
+
*/
|
|
211
|
+
async #scan(store, prefix) {
|
|
212
|
+
// Every key of the store, or the ones whose text starts like the
|
|
213
|
+
// prefix: `a:b*` also matches `a:bc`, so the parts are checked after.
|
|
214
|
+
const pattern = `${this.key(store, prefix)}${prefix.length === 0 ? ':' : ''}*`;
|
|
215
|
+
const keys = [];
|
|
216
|
+
let cursor = '0';
|
|
217
|
+
do {
|
|
218
|
+
const reply = await this.#client.send('SCAN', cursor, 'MATCH', pattern, 'COUNT', this.#scanCount);
|
|
219
|
+
if (!Array.isArray(reply) || reply.length !== 2) {
|
|
220
|
+
throw new Error(`Redis answered SCAN with something unexpected.`);
|
|
221
|
+
}
|
|
222
|
+
cursor = String(reply[0]);
|
|
223
|
+
for (const key of reply[1]) {
|
|
224
|
+
if (typeof key === 'string')
|
|
225
|
+
keys.push(key);
|
|
226
|
+
}
|
|
227
|
+
} while (cursor !== '0');
|
|
228
|
+
const batches = [];
|
|
229
|
+
// A key SCAN gave twice (it may, while keys are written) counts once.
|
|
230
|
+
const unique = [...new Set(keys)];
|
|
231
|
+
for (let at = 0; at < unique.length; at += this.#scanCount) {
|
|
232
|
+
batches.push(unique
|
|
233
|
+
.slice(at, at + this.#scanCount)
|
|
234
|
+
.map(key => ({ key, parts: this.#parts(store, key) }))
|
|
235
|
+
.filter((item) => item.parts !== null && startsWith(item.parts, prefix)));
|
|
236
|
+
}
|
|
237
|
+
return batches;
|
|
238
|
+
}
|
|
239
|
+
async close() {
|
|
240
|
+
await Promise.all([
|
|
241
|
+
this.#client.close(),
|
|
242
|
+
...this.#opened.map(client => client.close()),
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RedisClient } from '../redis/client.js';
|
|
2
|
+
import type { StoreKeyParts } from './backend.js';
|
|
3
|
+
import type { RankedScore, RankingBackend, RankingOrder } from './ranking-backend.js';
|
|
4
|
+
export declare class RedisRankingBackend implements RankingBackend {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(client: RedisClient, key: (name: string, scope: StoreKeyParts) => string);
|
|
7
|
+
add(name: string, scope: StoreKeyParts, id: string, by: number): Promise<number>;
|
|
8
|
+
set(name: string, scope: StoreKeyParts, id: string, score: number): Promise<void>;
|
|
9
|
+
score(name: string, scope: StoreKeyParts, id: string): Promise<number | undefined>;
|
|
10
|
+
rank(name: string, scope: StoreKeyParts, id: string, order: RankingOrder): Promise<number | undefined>;
|
|
11
|
+
range(name: string, scope: StoreKeyParts, order: RankingOrder, start: number, count: number): Promise<RankedScore[]>;
|
|
12
|
+
remove(name: string, scope: StoreKeyParts, id: string): Promise<boolean>;
|
|
13
|
+
size(name: string, scope: StoreKeyParts): Promise<number>;
|
|
14
|
+
clear(name: string, scope: StoreKeyParts): Promise<number>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The rankings kept in Redis, as sorted sets: one key per ranking and per
|
|
2
|
+
// server (`xp:<server id>`, `xp` for a global one, `dev:` in front under
|
|
3
|
+
// `chapterjs dev`), the ids as members and the scores as scores, so Redis
|
|
4
|
+
// itself keeps them in order and adds to a score in one go.
|
|
5
|
+
// https://redis.io/docs/latest/commands/zincrby/, /commands/zadd/,
|
|
6
|
+
// /commands/zscore/, /commands/zrevrank/, /commands/zrevrange/,
|
|
7
|
+
// /commands/zrange/, /commands/zrem/, /commands/zcard/
|
|
8
|
+
/** A score as Redis writes it: `"10"`, `"1.5"`, `"inf"`. */
|
|
9
|
+
const scoreOf = (reply) => {
|
|
10
|
+
const text = String(reply);
|
|
11
|
+
if (text === 'inf' || text === '+inf')
|
|
12
|
+
return Infinity;
|
|
13
|
+
if (text === '-inf')
|
|
14
|
+
return -Infinity;
|
|
15
|
+
return Number(text);
|
|
16
|
+
};
|
|
17
|
+
export class RedisRankingBackend {
|
|
18
|
+
#client;
|
|
19
|
+
#key;
|
|
20
|
+
constructor(client, key) {
|
|
21
|
+
this.#client = client;
|
|
22
|
+
this.#key = key;
|
|
23
|
+
}
|
|
24
|
+
async add(name, scope, id, by) {
|
|
25
|
+
return scoreOf(await this.#client.send('ZINCRBY', this.#key(name, scope), by, id));
|
|
26
|
+
}
|
|
27
|
+
async set(name, scope, id, score) {
|
|
28
|
+
await this.#client.send('ZADD', this.#key(name, scope), score, id);
|
|
29
|
+
}
|
|
30
|
+
async score(name, scope, id) {
|
|
31
|
+
const reply = await this.#client.send('ZSCORE', this.#key(name, scope), id);
|
|
32
|
+
return reply === null ? undefined : scoreOf(reply);
|
|
33
|
+
}
|
|
34
|
+
async rank(name, scope, id, order) {
|
|
35
|
+
const reply = await this.#client.send(order === 'highest' ? 'ZREVRANK' : 'ZRANK', this.#key(name, scope), id);
|
|
36
|
+
return typeof reply === 'number' ? reply : undefined;
|
|
37
|
+
}
|
|
38
|
+
async range(name, scope, order, start, count) {
|
|
39
|
+
if (count === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const reply = await this.#client.send(order === 'highest' ? 'ZREVRANGE' : 'ZRANGE', this.#key(name, scope), start, start + count - 1, 'WITHSCORES');
|
|
42
|
+
if (!Array.isArray(reply))
|
|
43
|
+
return [];
|
|
44
|
+
const ranked = [];
|
|
45
|
+
for (let index = 0; index + 1 < reply.length; index += 2) {
|
|
46
|
+
ranked.push({
|
|
47
|
+
id: String(reply[index]),
|
|
48
|
+
score: scoreOf(reply[index + 1] ?? null),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return ranked;
|
|
52
|
+
}
|
|
53
|
+
async remove(name, scope, id) {
|
|
54
|
+
return (await this.#client.send('ZREM', this.#key(name, scope), id)) === 1;
|
|
55
|
+
}
|
|
56
|
+
async size(name, scope) {
|
|
57
|
+
const reply = await this.#client.send('ZCARD', this.#key(name, scope));
|
|
58
|
+
return typeof reply === 'number' ? reply : 0;
|
|
59
|
+
}
|
|
60
|
+
async clear(name, scope) {
|
|
61
|
+
const key = this.#key(name, scope);
|
|
62
|
+
// Sent one after the other on one connection: counted, then removed.
|
|
63
|
+
const [count] = await Promise.all([
|
|
64
|
+
this.#client.send('ZCARD', key),
|
|
65
|
+
this.#client.send('DEL', key),
|
|
66
|
+
]);
|
|
67
|
+
return typeof count === 'number' ? count : 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/store/scope.d.ts
CHANGED
|
@@ -6,3 +6,14 @@ export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
|
|
|
6
6
|
* the framework runs.
|
|
7
7
|
*/
|
|
8
8
|
export declare function currentGuildId(): string | null | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The scope part of a key, for data per server or for the whole bot: the
|
|
11
|
+
* id of the server the code runs for (`guildOf`), or nothing for a global
|
|
12
|
+
* one. `what` names the kind of declaration in the message, when no server
|
|
13
|
+
* is known.
|
|
14
|
+
*/
|
|
15
|
+
export declare function scopeParts(what: string, name: string, scope: 'guild' | 'global', guildOf: () => string | null): string[];
|
|
16
|
+
/** The id of what `in()` was given: a server, or its id. */
|
|
17
|
+
export declare function guildIdOf(guild: {
|
|
18
|
+
id: string;
|
|
19
|
+
} | string): string;
|
package/dist/store/scope.js
CHANGED
|
@@ -18,3 +18,26 @@ export function withGuild(guildId, fn) {
|
|
|
18
18
|
export function currentGuildId() {
|
|
19
19
|
return scope.getStore();
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The scope part of a key, for data per server or for the whole bot: the
|
|
23
|
+
* id of the server the code runs for (`guildOf`), or nothing for a global
|
|
24
|
+
* one. `what` names the kind of declaration in the message, when no server
|
|
25
|
+
* is known.
|
|
26
|
+
*/
|
|
27
|
+
export function scopeParts(what, name, scope, guildOf) {
|
|
28
|
+
if (scope === 'global')
|
|
29
|
+
return [];
|
|
30
|
+
const guildId = guildOf();
|
|
31
|
+
if (!guildId) {
|
|
32
|
+
throw new Error(`${name} is a ${what} per server, and this code does not run in one: use ${name}.in(guild) to say which server, or declare the ${what} with scope: 'global'.`);
|
|
33
|
+
}
|
|
34
|
+
return [guildId];
|
|
35
|
+
}
|
|
36
|
+
/** The id of what `in()` was given: a server, or its id. */
|
|
37
|
+
export function guildIdOf(guild) {
|
|
38
|
+
const id = typeof guild === 'string' ? guild : guild?.id;
|
|
39
|
+
if (typeof id !== 'string' || id === '') {
|
|
40
|
+
throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
|
|
41
|
+
}
|
|
42
|
+
return id;
|
|
43
|
+
}
|
package/dist/store/store.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { Duration } from '../commands/command.js';
|
|
2
2
|
import type { Guild } from '../structures/guild.js';
|
|
3
|
+
import { type StoreStorage } from './backend.js';
|
|
3
4
|
/** Where the data of a store is kept apart: per server, or one for the whole bot. */
|
|
4
5
|
export type StoreScope = 'guild' | 'global';
|
|
6
|
+
export type { StoreStorage };
|
|
5
7
|
/** What a file gives to `store()`. */
|
|
6
8
|
export interface StoreConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Where the data is kept: `'file'` (the default), a file in the `data/`
|
|
11
|
+
* folder of the project, or `'redis'`, the Redis of `REDIS_URL` in
|
|
12
|
+
* `.env`. Only stores that say `'redis'` write anything there.
|
|
13
|
+
*/
|
|
14
|
+
storage?: StoreStorage;
|
|
7
15
|
/**
|
|
8
16
|
* `'guild'` (the default): the data is kept per server, and a function
|
|
9
17
|
* that runs in a server reads and writes the data of that server, with
|
|
@@ -46,13 +54,37 @@ export interface ScopedStore<T> {
|
|
|
46
54
|
/** Keeps `value` at `key`. The value must be JSON: no `Date`, `Map` or class instance. */
|
|
47
55
|
set(key: StoreKey, value: T, options?: StoreSetOptions): Promise<void>;
|
|
48
56
|
/**
|
|
49
|
-
* Reads the value at `key` and keeps what `change` returns, in one go
|
|
50
|
-
*
|
|
57
|
+
* Reads the value at `key` and keeps what `change` returns, in one go:
|
|
58
|
+
* nothing written meanwhile is lost, even by another process. `change`
|
|
59
|
+
* receives `undefined` when there is no value yet, and may run again
|
|
60
|
+
* when the value changed in between: keep it a plain computation. The
|
|
51
61
|
* expiration stays what it was, unless `options` say otherwise.
|
|
52
62
|
*/
|
|
53
63
|
update(key: StoreKey, change: (current: T | undefined) => T, options?: StoreSetOptions): Promise<T>;
|
|
64
|
+
/**
|
|
65
|
+
* Adds `by` (1 by default, negative to take away) to the number at
|
|
66
|
+
* `key`, starting from 0, in one go like `update()`, and resolves with
|
|
67
|
+
* the new number. Only for a store of numbers.
|
|
68
|
+
*/
|
|
69
|
+
increment(this: ScopedStore<number>, key: StoreKey, by?: number, options?: StoreSetOptions): Promise<number>;
|
|
54
70
|
/** Forgets the value at `key`. `true` when there was one. */
|
|
55
71
|
delete(key: StoreKey): Promise<boolean>;
|
|
72
|
+
/**
|
|
73
|
+
* The values at several keys at once, in their order: `undefined` for a
|
|
74
|
+
* key that has none.
|
|
75
|
+
*/
|
|
76
|
+
getMany(keys: readonly StoreKey[]): Promise<(T | undefined)[]>;
|
|
77
|
+
/**
|
|
78
|
+
* Keeps several values at once, each with the same `options`. Every
|
|
79
|
+
* value is checked before anything is written.
|
|
80
|
+
*/
|
|
81
|
+
setMany(values: readonly (readonly [key: StoreKey, value: T])[], options?: StoreSetOptions): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Forgets every value whose key starts with `prefix`, or every value of
|
|
84
|
+
* the store (of the server, for a store per server) without one.
|
|
85
|
+
* Resolves with how many there were.
|
|
86
|
+
*/
|
|
87
|
+
deleteAll(prefix?: StoreKey): Promise<number>;
|
|
56
88
|
/**
|
|
57
89
|
* Every entry, or the ones whose key starts with `prefix`, in no
|
|
58
90
|
* particular order. Expired ones are left out.
|
|
@@ -79,6 +111,7 @@ export interface StoreState {
|
|
|
79
111
|
name?: string;
|
|
80
112
|
/** What `read` found in the config. */
|
|
81
113
|
scope: StoreScope;
|
|
114
|
+
storage: StoreStorage;
|
|
82
115
|
/** The default expiration, in ms, or `null` for never. */
|
|
83
116
|
expires: number | null;
|
|
84
117
|
}
|
|
@@ -89,6 +122,7 @@ export declare const isStoreDeclaration: (value: unknown) => boolean;
|
|
|
89
122
|
export declare function bindStore(declared: unknown, bound: {
|
|
90
123
|
name: string;
|
|
91
124
|
scope: StoreScope;
|
|
125
|
+
storage: StoreStorage;
|
|
92
126
|
expires: number | null;
|
|
93
127
|
}): void;
|
|
94
128
|
/**
|
|
@@ -99,9 +133,9 @@ export declare function checkJson(value: unknown, path?: string): void;
|
|
|
99
133
|
/**
|
|
100
134
|
* Declares a store: data the bot keeps from one restart to the next,
|
|
101
135
|
* typed by what it holds. Export the result from any file of `src/`: the
|
|
102
|
-
* name of the export is the name of the store, and its file in `data
|
|
103
|
-
*
|
|
104
|
-
* `expires` is.
|
|
136
|
+
* name of the export is the name of the store, and its file in `data/`
|
|
137
|
+
* (or its keys in Redis, with `storage: 'redis'`). Per server unless
|
|
138
|
+
* `scope: 'global'` is given, with an expiration if `expires` is.
|
|
105
139
|
*
|
|
106
140
|
* ```ts
|
|
107
141
|
* import { store } from 'chapterjs';
|
package/dist/store/store.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the bot runs; the data itself lives in a backend (`backend.ts`).
|
|
7
7
|
import { parseDuration } from '../util/duration.js';
|
|
8
8
|
import { storeBackend, } from './backend.js';
|
|
9
|
-
import { currentGuildId } from './scope.js';
|
|
9
|
+
import { currentGuildId, guildIdOf, scopeParts } from './scope.js';
|
|
10
10
|
const STATE = Symbol.for('chapterjs.store');
|
|
11
11
|
/** The state of a declared store, if the value is one. */
|
|
12
12
|
export function storeStateOf(value) {
|
|
@@ -22,17 +22,21 @@ export function bindStore(declared, bound) {
|
|
|
22
22
|
throw new TypeError('Not a store.');
|
|
23
23
|
state.name = bound.name;
|
|
24
24
|
state.scope = bound.scope;
|
|
25
|
+
state.storage = bound.storage;
|
|
25
26
|
state.expires = bound.expires;
|
|
26
27
|
}
|
|
27
28
|
const parts = (key) => {
|
|
28
29
|
const list = typeof key === 'string' ? [key] : [...key];
|
|
29
30
|
if (list.length === 0) {
|
|
30
|
-
throw new TypeError('A key of a store is a text, or a list of texts with at least one.');
|
|
31
|
+
throw new TypeError('A key of a store is a text that is not empty, or a list of such texts with at least one.');
|
|
31
32
|
}
|
|
32
33
|
for (const part of list) {
|
|
33
34
|
if (typeof part !== 'string') {
|
|
34
35
|
throw new TypeError(`A key of a store is made of texts, got ${JSON.stringify(part) ?? typeof part}. Write an id as a text: member.id, not a number.`);
|
|
35
36
|
}
|
|
37
|
+
if (part === '') {
|
|
38
|
+
throw new TypeError('A key of a store is a text that is not empty, or a list of such texts with at least one.');
|
|
39
|
+
}
|
|
36
40
|
}
|
|
37
41
|
return list;
|
|
38
42
|
};
|
|
@@ -102,47 +106,95 @@ function scoped(state, guildOf) {
|
|
|
102
106
|
if (!state.name) {
|
|
103
107
|
throw new Error('This store was not loaded by ChapterJS: a store is exported from a file of src/ (export const name = store()), and used once the bot started.');
|
|
104
108
|
}
|
|
105
|
-
const backend = storeBackend();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
throw new Error(`${state.name} is a store per server, and this code does not run in one: use ${state.name}.in(guild) to say which server, or declare the store with scope: 'global'.`);
|
|
112
|
-
}
|
|
113
|
-
return { backend, name: state.name, prefix: [guildId] };
|
|
109
|
+
const backend = storeBackend(state.storage, state.name);
|
|
110
|
+
return {
|
|
111
|
+
backend,
|
|
112
|
+
name: state.name,
|
|
113
|
+
prefix: scopeParts('store', state.name, state.scope, guildOf),
|
|
114
|
+
};
|
|
114
115
|
};
|
|
115
116
|
const full = (prefix, key) => [
|
|
116
117
|
...prefix,
|
|
117
118
|
...parts(key),
|
|
118
119
|
];
|
|
119
|
-
const set = async (key, value, options
|
|
120
|
+
const set = async (key, value, options) => {
|
|
120
121
|
const { backend, name, prefix } = ready();
|
|
121
122
|
checkJson(value);
|
|
122
|
-
const
|
|
123
|
-
const at = keep !== undefined && (!options || options.expires === undefined)
|
|
124
|
-
? keep
|
|
125
|
-
: expiresAt(options, state.expires, now);
|
|
123
|
+
const at = expiresAt(options, state.expires, Date.now());
|
|
126
124
|
await backend.set(name, full(prefix, key), { value, expiresAt: at });
|
|
127
125
|
};
|
|
126
|
+
const update = async (key, change, options) => {
|
|
127
|
+
const { backend, name, prefix } = ready();
|
|
128
|
+
let kept;
|
|
129
|
+
// Read and kept by the backend with nothing written in between; run
|
|
130
|
+
// again when the value changed meanwhile.
|
|
131
|
+
await backend.update(name, full(prefix, key), current => {
|
|
132
|
+
const value = change(current?.value);
|
|
133
|
+
checkJson(value);
|
|
134
|
+
kept = value;
|
|
135
|
+
return {
|
|
136
|
+
value,
|
|
137
|
+
expiresAt: current && (!options || options.expires === undefined)
|
|
138
|
+
? current.expiresAt
|
|
139
|
+
: expiresAt(options, state.expires, Date.now()),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
return kept;
|
|
143
|
+
};
|
|
128
144
|
return {
|
|
129
145
|
async get(key) {
|
|
130
146
|
const { backend, name, prefix } = ready();
|
|
131
147
|
const entry = await backend.get(name, full(prefix, key));
|
|
132
148
|
return entry?.value;
|
|
133
149
|
},
|
|
134
|
-
set: (key, value, options) => set(key, value, options
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return
|
|
150
|
+
set: (key, value, options) => set(key, value, options),
|
|
151
|
+
update: (key, change, options) => update(key, change, options),
|
|
152
|
+
increment(key, by = 1, options) {
|
|
153
|
+
if (typeof by !== 'number' || !Number.isFinite(by)) {
|
|
154
|
+
return Promise.reject(new TypeError(`increment() adds a number, got ${JSON.stringify(by) ?? typeof by}.`));
|
|
155
|
+
}
|
|
156
|
+
return update(key, current => {
|
|
157
|
+
if (current !== undefined && typeof current !== 'number') {
|
|
158
|
+
throw new TypeError(`increment() adds to a number, and the value at ${JSON.stringify(parts(key))} is ${Array.isArray(current) ? 'a list' : current === null ? 'null' : `a ${typeof current}`}: use update() to change it.`);
|
|
159
|
+
}
|
|
160
|
+
return ((current ?? 0) + by);
|
|
161
|
+
}, options);
|
|
141
162
|
},
|
|
142
163
|
async delete(key) {
|
|
143
164
|
const { backend, name, prefix } = ready();
|
|
144
165
|
return backend.delete(name, full(prefix, key));
|
|
145
166
|
},
|
|
167
|
+
async getMany(keys) {
|
|
168
|
+
const { backend, name, prefix } = ready();
|
|
169
|
+
if (!Array.isArray(keys)) {
|
|
170
|
+
throw new TypeError('getMany() takes a list of keys, like warnings.getMany([a.id, b.id]).');
|
|
171
|
+
}
|
|
172
|
+
// Every key checked before anything is asked.
|
|
173
|
+
const all = keys.map(key => full(prefix, key));
|
|
174
|
+
const entries = await Promise.all(all.map(key => backend.get(name, key)));
|
|
175
|
+
return entries.map(entry => entry?.value);
|
|
176
|
+
},
|
|
177
|
+
async setMany(values, options) {
|
|
178
|
+
const { backend, name, prefix } = ready();
|
|
179
|
+
if (!Array.isArray(values)) {
|
|
180
|
+
throw new TypeError("setMany() takes a list of [key, value] pairs, like scores.setMany([['a', 1], ['b', 2]]).");
|
|
181
|
+
}
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const at = expiresAt(options, state.expires, now);
|
|
184
|
+
const checked = values.map((pair, index) => {
|
|
185
|
+
if (!Array.isArray(pair) || pair.length !== 2) {
|
|
186
|
+
throw new TypeError(`setMany() takes a list of [key, value] pairs, and item ${index} is not one.`);
|
|
187
|
+
}
|
|
188
|
+
const [key, value] = pair;
|
|
189
|
+
checkJson(value, `the value of item ${index}`);
|
|
190
|
+
return { key: full(prefix, key), value };
|
|
191
|
+
});
|
|
192
|
+
await Promise.all(checked.map(({ key, value }) => backend.set(name, key, { value, expiresAt: at })));
|
|
193
|
+
},
|
|
194
|
+
async deleteAll(prefixKey) {
|
|
195
|
+
const { backend, name, prefix } = ready();
|
|
196
|
+
return backend.clear(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
|
|
197
|
+
},
|
|
146
198
|
async entries(prefixKey) {
|
|
147
199
|
const { backend, name, prefix } = ready();
|
|
148
200
|
const listed = await backend.entries(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
|
|
@@ -166,9 +218,9 @@ function scoped(state, guildOf) {
|
|
|
166
218
|
/**
|
|
167
219
|
* Declares a store: data the bot keeps from one restart to the next,
|
|
168
220
|
* typed by what it holds. Export the result from any file of `src/`: the
|
|
169
|
-
* name of the export is the name of the store, and its file in `data
|
|
170
|
-
*
|
|
171
|
-
* `expires` is.
|
|
221
|
+
* name of the export is the name of the store, and its file in `data/`
|
|
222
|
+
* (or its keys in Redis, with `storage: 'redis'`). Per server unless
|
|
223
|
+
* `scope: 'global'` is given, with an expiration if `expires` is.
|
|
172
224
|
*
|
|
173
225
|
* ```ts
|
|
174
226
|
* import { store } from 'chapterjs';
|
|
@@ -180,13 +232,11 @@ function scoped(state, guildOf) {
|
|
|
180
232
|
* ```
|
|
181
233
|
*/
|
|
182
234
|
export function store(config = {}) {
|
|
183
|
-
const state = {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
return id;
|
|
235
|
+
const state = {
|
|
236
|
+
config,
|
|
237
|
+
scope: 'guild',
|
|
238
|
+
storage: 'file',
|
|
239
|
+
expires: null,
|
|
190
240
|
};
|
|
191
241
|
const declared = {
|
|
192
242
|
...scoped(state, () => currentGuildId() ?? null),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Snowflake } from '../discord/types/common.js';
|
|
2
2
|
import type { RawInvite, RawInviteMetadata } from '../discord/types/invite.js';
|
|
3
3
|
import { Structure } from './base.js';
|
|
4
|
+
import type { GuildChannel } from './channel.js';
|
|
4
5
|
import type { Context } from './context.js';
|
|
5
6
|
import type { User } from './user.js';
|
|
6
7
|
export type InviteData = Omit<RawInvite, 'inviter'> & Partial<RawInviteMetadata>;
|
|
@@ -20,6 +21,11 @@ export declare class Invite extends Structure<InviteData> {
|
|
|
20
21
|
get guildId(): Snowflake | null;
|
|
21
22
|
/** The id of the channel the invite leads to. */
|
|
22
23
|
get channelId(): Snowflake | null;
|
|
24
|
+
/**
|
|
25
|
+
* The channel the invite leads to, when the bot knows it. It never sends
|
|
26
|
+
* a request.
|
|
27
|
+
*/
|
|
28
|
+
get channel(): GuildChannel | null;
|
|
23
29
|
/** When the invite stops working; `null` when it never does. */
|
|
24
30
|
get expiresAt(): Date | null;
|
|
25
31
|
/** How many times the invite was used, when Discord says it. */
|