chapterjs 0.3.0 → 0.5.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.
Files changed (115) hide show
  1. package/README.md +5 -3
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +17 -4
  5. package/dist/cli/dev.js +37 -6
  6. package/dist/cli/env.d.ts +5 -0
  7. package/dist/cli/env.js +39 -3
  8. package/dist/cli/project.d.ts +61 -9
  9. package/dist/cli/project.js +250 -44
  10. package/dist/cli/redis.d.ts +23 -0
  11. package/dist/cli/redis.js +69 -0
  12. package/dist/cli/start.js +54 -18
  13. package/dist/cli/test.js +112 -18
  14. package/dist/commands/command.d.ts +140 -5
  15. package/dist/commands/command.js +17 -2
  16. package/dist/commands/declaration.d.ts +17 -4
  17. package/dist/commands/declaration.js +128 -2
  18. package/dist/commands/router.js +78 -18
  19. package/dist/commands/tree.d.ts +25 -4
  20. package/dist/commands/tree.js +95 -26
  21. package/dist/commands/twins.d.ts +8 -0
  22. package/dist/commands/twins.js +24 -6
  23. package/dist/components/button.d.ts +8 -3
  24. package/dist/components/declaration.d.ts +2 -2
  25. package/dist/components/declaration.js +16 -12
  26. package/dist/components/render.js +5 -0
  27. package/dist/components/router.js +41 -0
  28. package/dist/components/select.d.ts +12 -5
  29. package/dist/core/bot.d.ts +7 -0
  30. package/dist/core/bot.js +44 -0
  31. package/dist/discord/api.d.ts +2 -0
  32. package/dist/discord/api.js +2 -0
  33. package/dist/discord/permissions.d.ts +4 -4
  34. package/dist/discord/permissions.js +13 -0
  35. package/dist/events/registry.d.ts +265 -243
  36. package/dist/events/registry.js +131 -100
  37. package/dist/index.d.ts +7 -4
  38. package/dist/index.js +2 -1
  39. package/dist/interactions/dispatch.js +4 -2
  40. package/dist/loader/loader.d.ts +16 -0
  41. package/dist/loader/loader.js +52 -7
  42. package/dist/loader/usage.d.ts +15 -0
  43. package/dist/loader/usage.js +40 -0
  44. package/dist/loader/watch.d.ts +5 -3
  45. package/dist/loader/watch.js +88 -17
  46. package/dist/messages/commands.js +3 -0
  47. package/dist/messages/messages.d.ts +1 -0
  48. package/dist/messages/translate.js +5 -1
  49. package/dist/presence/declaration.d.ts +49 -2
  50. package/dist/presence/declaration.js +122 -39
  51. package/dist/presence/presence.d.ts +65 -6
  52. package/dist/presence/presence.js +11 -0
  53. package/dist/presence/rotation.d.ts +32 -0
  54. package/dist/presence/rotation.js +167 -0
  55. package/dist/ranking/declaration.d.ts +12 -0
  56. package/dist/ranking/declaration.js +48 -0
  57. package/dist/ranking/ranking.d.ts +102 -0
  58. package/dist/ranking/ranking.js +129 -0
  59. package/dist/redis/client.d.ts +36 -0
  60. package/dist/redis/client.js +252 -0
  61. package/dist/redis/protocol.d.ts +22 -0
  62. package/dist/redis/protocol.js +99 -0
  63. package/dist/redis/url.d.ts +24 -0
  64. package/dist/redis/url.js +50 -0
  65. package/dist/rest/errors.d.ts +0 -1
  66. package/dist/rest/errors.js +15 -1
  67. package/dist/rest/rest.js +10 -1
  68. package/dist/store/backend.d.ts +49 -4
  69. package/dist/store/backend.js +66 -11
  70. package/dist/store/declaration.d.ts +3 -0
  71. package/dist/store/declaration.js +8 -3
  72. package/dist/store/file-backend.d.ts +3 -2
  73. package/dist/store/file-backend.js +47 -1
  74. package/dist/store/ipc-backend.d.ts +24 -1
  75. package/dist/store/ipc-backend.js +49 -0
  76. package/dist/store/memory-backend.d.ts +4 -2
  77. package/dist/store/memory-backend.js +27 -1
  78. package/dist/store/ranking-backend.d.ts +45 -0
  79. package/dist/store/ranking-backend.js +82 -0
  80. package/dist/store/redis-backend.d.ts +38 -0
  81. package/dist/store/redis-backend.js +245 -0
  82. package/dist/store/redis-ranking.d.ts +15 -0
  83. package/dist/store/redis-ranking.js +69 -0
  84. package/dist/store/scope.d.ts +17 -1
  85. package/dist/store/scope.js +32 -3
  86. package/dist/store/store.d.ts +39 -5
  87. package/dist/store/store.js +79 -32
  88. package/dist/structures/channel.d.ts +13 -0
  89. package/dist/structures/channel.js +24 -0
  90. package/dist/structures/context.d.ts +3 -0
  91. package/dist/structures/entities.js +1 -0
  92. package/dist/structures/interaction.d.ts +6 -0
  93. package/dist/structures/interaction.js +20 -0
  94. package/dist/structures/invite.d.ts +6 -0
  95. package/dist/structures/invite.js +11 -0
  96. package/dist/structures/message.d.ts +17 -0
  97. package/dist/structures/message.js +33 -0
  98. package/dist/structures/wait.d.ts +105 -0
  99. package/dist/structures/wait.js +85 -0
  100. package/dist/testing/harness.d.ts +48 -8
  101. package/dist/testing/harness.js +156 -36
  102. package/dist/testing/index.d.ts +1 -1
  103. package/dist/testing/smoke.js +112 -1
  104. package/dist/testing/world.js +51 -2
  105. package/dist/util/call-site.d.ts +4 -0
  106. package/dist/util/call-site.js +22 -0
  107. package/dist/voice/connection.d.ts +2 -0
  108. package/dist/voice/connection.js +15 -1
  109. package/dist/voice/usage.d.ts +3 -2
  110. package/dist/voice/usage.js +7 -19
  111. package/dist/waiting/usage.d.ts +5 -0
  112. package/dist/waiting/usage.js +19 -0
  113. package/dist/waiting/waiters.d.ts +48 -0
  114. package/dist/waiting/waiters.js +107 -0
  115. package/package.json +1 -1
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- /** Runs `fn` for a server (or for none, with `null`). */
1
+ /** Runs `fn` for a server (or for none, with `null`), as one run. */
2
2
  export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
3
3
  /**
4
4
  * The id of the server the current function runs for: `null` when it runs
@@ -6,3 +6,19 @@ 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 run of a function of the files the current code belongs to, or
11
+ * `undefined` outside any.
12
+ */
13
+ export declare function currentRun(): object | undefined;
14
+ /**
15
+ * The scope part of a key, for data per server or for the whole bot: the
16
+ * id of the server the code runs for (`guildOf`), or nothing for a global
17
+ * one. `what` names the kind of declaration in the message, when no server
18
+ * is known.
19
+ */
20
+ export declare function scopeParts(what: string, name: string, scope: 'guild' | 'global', guildOf: () => string | null): string[];
21
+ /** The id of what `in()` was given: a server, or its id. */
22
+ export declare function guildIdOf(guild: {
23
+ id: string;
24
+ } | string): string;
@@ -4,11 +4,10 @@
4
4
  // the routers around every function they run, on an asynchronous context
5
5
  // of Node, so it follows the `await`s into the files the function imports.
6
6
  import { AsyncLocalStorage } from 'node:async_hooks';
7
- /** The id of the server the current function runs for, or `null` for none. */
8
7
  const scope = new AsyncLocalStorage();
9
- /** Runs `fn` for a server (or for none, with `null`). */
8
+ /** Runs `fn` for a server (or for none, with `null`), as one run. */
10
9
  export function withGuild(guildId, fn) {
11
- return scope.run(guildId, fn);
10
+ return scope.run({ guildId }, fn);
12
11
  }
13
12
  /**
14
13
  * The id of the server the current function runs for: `null` when it runs
@@ -16,5 +15,35 @@ export function withGuild(guildId, fn) {
16
15
  * the framework runs.
17
16
  */
18
17
  export function currentGuildId() {
18
+ return scope.getStore()?.guildId;
19
+ }
20
+ /**
21
+ * The run of a function of the files the current code belongs to, or
22
+ * `undefined` outside any.
23
+ */
24
+ export function currentRun() {
19
25
  return scope.getStore();
20
26
  }
27
+ /**
28
+ * The scope part of a key, for data per server or for the whole bot: the
29
+ * id of the server the code runs for (`guildOf`), or nothing for a global
30
+ * one. `what` names the kind of declaration in the message, when no server
31
+ * is known.
32
+ */
33
+ export function scopeParts(what, name, scope, guildOf) {
34
+ if (scope === 'global')
35
+ return [];
36
+ const guildId = guildOf();
37
+ if (!guildId) {
38
+ 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'.`);
39
+ }
40
+ return [guildId];
41
+ }
42
+ /** The id of what `in()` was given: a server, or its id. */
43
+ export function guildIdOf(guild) {
44
+ const id = typeof guild === 'string' ? guild : guild?.id;
45
+ if (typeof id !== 'string' || id === '') {
46
+ throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
47
+ }
48
+ return id;
49
+ }
@@ -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
- * `change` receives `undefined` when there is no value yet. The
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
- * Per server unless `scope: 'global'` is given, with an expiration if
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';
@@ -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,6 +22,7 @@ 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) => {
@@ -105,47 +106,95 @@ function scoped(state, guildOf) {
105
106
  if (!state.name) {
106
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.');
107
108
  }
108
- const backend = storeBackend();
109
- if (state.scope === 'global') {
110
- return { backend, name: state.name, prefix: [] };
111
- }
112
- const guildId = guildOf();
113
- if (!guildId) {
114
- 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'.`);
115
- }
116
- 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
+ };
117
115
  };
118
116
  const full = (prefix, key) => [
119
117
  ...prefix,
120
118
  ...parts(key),
121
119
  ];
122
- const set = async (key, value, options, keep) => {
120
+ const set = async (key, value, options) => {
123
121
  const { backend, name, prefix } = ready();
124
122
  checkJson(value);
125
- const now = Date.now();
126
- const at = keep !== undefined && (!options || options.expires === undefined)
127
- ? keep
128
- : expiresAt(options, state.expires, now);
123
+ const at = expiresAt(options, state.expires, Date.now());
129
124
  await backend.set(name, full(prefix, key), { value, expiresAt: at });
130
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
+ };
131
144
  return {
132
145
  async get(key) {
133
146
  const { backend, name, prefix } = ready();
134
147
  const entry = await backend.get(name, full(prefix, key));
135
148
  return entry?.value;
136
149
  },
137
- set: (key, value, options) => set(key, value, options, undefined),
138
- async update(key, change, options) {
139
- const { backend, name, prefix } = ready();
140
- const entry = await backend.get(name, full(prefix, key));
141
- const next = change(entry?.value);
142
- await set(key, next, options, entry ? entry.expiresAt : undefined);
143
- return next;
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);
144
162
  },
145
163
  async delete(key) {
146
164
  const { backend, name, prefix } = ready();
147
165
  return backend.delete(name, full(prefix, key));
148
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
+ },
149
198
  async entries(prefixKey) {
150
199
  const { backend, name, prefix } = ready();
151
200
  const listed = await backend.entries(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
@@ -169,9 +218,9 @@ function scoped(state, guildOf) {
169
218
  /**
170
219
  * Declares a store: data the bot keeps from one restart to the next,
171
220
  * typed by what it holds. Export the result from any file of `src/`: the
172
- * name of the export is the name of the store, and its file in `data/`.
173
- * Per server unless `scope: 'global'` is given, with an expiration if
174
- * `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.
175
224
  *
176
225
  * ```ts
177
226
  * import { store } from 'chapterjs';
@@ -183,13 +232,11 @@ function scoped(state, guildOf) {
183
232
  * ```
184
233
  */
185
234
  export function store(config = {}) {
186
- const state = { config, scope: 'guild', expires: null };
187
- const guildIdOf = (guild) => {
188
- const id = typeof guild === 'string' ? guild : guild?.id;
189
- if (typeof id !== 'string' || id === '') {
190
- throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
191
- }
192
- return id;
235
+ const state = {
236
+ config,
237
+ scope: 'guild',
238
+ storage: 'file',
239
+ expires: null,
193
240
  };
194
241
  const declared = {
195
242
  ...scoped(state, () => currentGuildId() ?? null),