chapterjs 0.3.0 → 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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/build.js +14 -1
  3. package/dist/cli/dev.js +29 -1
  4. package/dist/cli/env.d.ts +5 -0
  5. package/dist/cli/env.js +39 -3
  6. package/dist/cli/project.d.ts +30 -7
  7. package/dist/cli/project.js +85 -11
  8. package/dist/cli/redis.d.ts +23 -0
  9. package/dist/cli/redis.js +57 -0
  10. package/dist/cli/start.js +43 -17
  11. package/dist/cli/test.js +4 -2
  12. package/dist/events/registry.d.ts +252 -239
  13. package/dist/events/registry.js +125 -100
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.js +1 -0
  16. package/dist/ranking/declaration.d.ts +12 -0
  17. package/dist/ranking/declaration.js +48 -0
  18. package/dist/ranking/ranking.d.ts +102 -0
  19. package/dist/ranking/ranking.js +129 -0
  20. package/dist/redis/client.d.ts +36 -0
  21. package/dist/redis/client.js +252 -0
  22. package/dist/redis/protocol.d.ts +22 -0
  23. package/dist/redis/protocol.js +99 -0
  24. package/dist/redis/url.d.ts +24 -0
  25. package/dist/redis/url.js +50 -0
  26. package/dist/store/backend.d.ts +49 -4
  27. package/dist/store/backend.js +66 -11
  28. package/dist/store/declaration.d.ts +3 -0
  29. package/dist/store/declaration.js +8 -3
  30. package/dist/store/file-backend.d.ts +3 -1
  31. package/dist/store/file-backend.js +29 -0
  32. package/dist/store/ipc-backend.d.ts +24 -1
  33. package/dist/store/ipc-backend.js +49 -0
  34. package/dist/store/memory-backend.d.ts +4 -2
  35. package/dist/store/memory-backend.js +27 -1
  36. package/dist/store/ranking-backend.d.ts +45 -0
  37. package/dist/store/ranking-backend.js +82 -0
  38. package/dist/store/redis-backend.d.ts +38 -0
  39. package/dist/store/redis-backend.js +245 -0
  40. package/dist/store/redis-ranking.d.ts +15 -0
  41. package/dist/store/redis-ranking.js +69 -0
  42. package/dist/store/scope.d.ts +11 -0
  43. package/dist/store/scope.js +23 -0
  44. package/dist/store/store.d.ts +39 -5
  45. package/dist/store/store.js +79 -32
  46. package/dist/structures/invite.d.ts +6 -0
  47. package/dist/structures/invite.js +11 -0
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -30,7 +30,7 @@ export default command({
30
30
  });
31
31
  ```
32
32
 
33
- The framework loads every file of `src/`, wherever it is and whatever it is called, and runs what the files export: `command()`, `event()`, `button()`, `select()`, `modal()`, `embed()`, `task()`, `store()`, `presence()`, `language()`. The folders are yours to organise with.
33
+ The framework loads every file of `src/`, wherever it is and whatever it is called, and runs what the files export: `command()`, `event()`, `button()`, `select()`, `modal()`, `embed()`, `task()`, `store()`, `ranking()`, `presence()`, `language()`. The folders are yours to organise with.
34
34
 
35
35
  ## What it does
36
36
 
@@ -38,7 +38,7 @@ The framework loads every file of `src/`, wherever it is and whatever it is call
38
38
  - **Events**: the intents follow from the files you write, privileged ones included, with a direct link when one must be enabled.
39
39
  - **Components**: buttons, select menus, forms and embeds, with typed `data` carried in the message and no id to write.
40
40
  - **Tasks**: every so often or with a cron, in the timezone you choose.
41
- - **Storage**: `store<Warning[]>({ expires: '30d' })` keeps data from one restart to the next, per server, typed, in a JSON file written for you.
41
+ - **Storage**: `store<Warning[]>({ expires: '30d' })` keeps data from one restart to the next, per server, typed, in a JSON file written for you, or in Redis with `storage: 'redis'` and a `REDIS_URL`; `ranking()` keeps a score per member in order, for leaderboards.
42
42
  - **Translated messages**: `t('welcome', { name })` speaks the language of who will read it.
43
43
  - **Voice**: join a channel and play a file, an attachment or a link, end-to-end encrypted (DAVE).
44
44
  - **Tests**: `chapterjs test` runs the bot against a Discord in memory, no token needed: every file is used once by itself, then your scenarios (`scenario('ping answers Pong', async bot => { … })`) say what the bot should answer.
package/dist/cli/build.js CHANGED
@@ -15,6 +15,7 @@ import { pathToFileURL } from 'node:url';
15
15
  import { enableProjectLoader } from '../loader/hot.js';
16
16
  import { listSources } from '../loader/loader.js';
17
17
  import { sourcesJoinVoice } from '../voice/usage.js';
18
+ import { MemoryStoreBackend } from '../store/memory-backend.js';
18
19
  import { createProject, hasSources, writeTypes } from './project.js';
19
20
  /** Where the build of a project is, from its folder. */
20
21
  export const buildDir = (cwd) => join(cwd, '.chapterjs', 'build');
@@ -105,7 +106,18 @@ export async function build(options) {
105
106
  };
106
107
  // 1. Every file must run, one by one: each says what is wrong with it.
107
108
  enableProjectLoader(src, { reload: false });
108
- const sources = createProject({ cwd, version: options.version, log });
109
+ // The files run once and keep nothing: a store, wherever it says it
110
+ // keeps its data, has nowhere to go (so a build needs no Redis).
111
+ const nothing = {
112
+ file: new MemoryStoreBackend(),
113
+ redis: new MemoryStoreBackend(),
114
+ };
115
+ const sources = createProject({
116
+ cwd,
117
+ version: options.version,
118
+ log,
119
+ stores: nothing,
120
+ });
109
121
  const failures = await sources.load();
110
122
  // 2. Types: what the editor underlines, for the whole project. Written
111
123
  // once the files ran, so a language only offers the commands they do
@@ -181,6 +193,7 @@ export async function build(options) {
181
193
  version: options.version,
182
194
  log,
183
195
  built: await loadBundle(cwd),
196
+ stores: nothing,
184
197
  });
185
198
  const broken = await built.load();
186
199
  if (broken.length > 0) {
package/dist/cli/dev.js CHANGED
@@ -16,7 +16,9 @@ import { snapshotFolder, watchFolder } from '../loader/watch.js';
16
16
  import { RestClient } from '../rest/rest.js';
17
17
  import { readDevEnv } from './env.js';
18
18
  import { describeIntents, ensureInDevGuild, ensurePrivilegedIntents, fetchApplication, } from './preflight.js';
19
- import { createProject, explain, hasSources, writeTypes } from './project.js';
19
+ import { createProject, explain, hasSources, reportLooseRejections, writeTypes, } from './project.js';
20
+ import { checkRedis, describeRedisStores, redisStores } from './redis.js';
21
+ import { DEV_PREFIX } from '../store/redis-backend.js';
20
22
  import { checkForUpdate, describeUpdate } from './update-check.js';
21
23
  /** Runs until stopped. Resolves with the exit code of the process. */
22
24
  export async function dev(options) {
@@ -30,8 +32,16 @@ export async function dev(options) {
30
32
  }
31
33
  const { token, devGuildId } = read.env;
32
34
  const src = join(cwd, 'src');
35
+ // Every key written from here starts with `dev:`: a Redis shared with
36
+ // production, which the docs advise against, is never mixed up.
37
+ const redis = read.env.redis
38
+ ? redisStores(read.env.redis, log, { prefix: DEV_PREFIX })
39
+ : null;
33
40
  if (!(await hasSources(cwd, log)))
34
41
  return 1;
42
+ // A promise of the files left alone is an error of the file, not the end
43
+ // of the bot.
44
+ const loose = reportLooseRejections(cwd, log);
35
45
  // Asked now, said once the bot is online: the registry answers while the
36
46
  // files load and Discord is checked, so nobody waits for it.
37
47
  const update = checkForUpdate({
@@ -44,6 +54,7 @@ export async function dev(options) {
44
54
  cwd,
45
55
  version: options.version,
46
56
  log,
57
+ ...(redis ? { stores: { redis } } : {}),
47
58
  ...(options.deferAfter === undefined
48
59
  ? {}
49
60
  : { deferAfter: options.deferAfter }),
@@ -57,6 +68,12 @@ export async function dev(options) {
57
68
  // Now that the files ran, a language only offers the commands they do
58
69
  // not describe, and `t` is typed from the default language.
59
70
  await writeTypes(cwd, project.typesInfo());
71
+ // A store kept in Redis needs it now, not when someone uses the bot.
72
+ if (!(await checkRedis(project, redis, log))) {
73
+ await project.closeStores();
74
+ return 1;
75
+ }
76
+ let redisLine = describeRedisStores(project, redis);
60
77
  // 3. What Discord must agree with before connecting.
61
78
  const apiUrl = options.env.CHAPTERJS_API_URL;
62
79
  const rest = new RestClient({
@@ -154,6 +171,8 @@ export async function dev(options) {
154
171
  log.success(`Connected to ${guild.name} as ${self.username}`);
155
172
  log.info(`Intents computed from your files: ${describeIntents(connectedIntents)}`);
156
173
  (project.isEmpty() ? log.info : log.success)(project.summary());
174
+ if (redisLine)
175
+ log.info(redisLine);
157
176
  project.ready(bot);
158
177
  // Tasks follow the bot through reconnections: they read the current one.
159
178
  project.startTasks(() => bot);
@@ -215,6 +234,13 @@ export async function dev(options) {
215
234
  // offer it, or not.
216
235
  if ((await writeTypes(cwd, project.typesInfo())) > 0)
217
236
  log.reload('Types updated');
237
+ // A store moved to Redis, or out of it, is said once.
238
+ const kept = describeRedisStores(project, redis);
239
+ if (kept !== redisLine) {
240
+ redisLine = kept;
241
+ if (kept)
242
+ log.info(kept);
243
+ }
218
244
  const intents = project.intents(false);
219
245
  if ((intents & ~connectedIntents) !== 0 && bot) {
220
246
  // Intents are given when connecting: new ones need a new session.
@@ -256,6 +282,7 @@ export async function dev(options) {
256
282
  memory.stop();
257
283
  await bot?.close();
258
284
  await project.closeStores();
285
+ loose();
259
286
  if (code === 0)
260
287
  log.success('Disconnected');
261
288
  return code;
@@ -267,6 +294,7 @@ export async function dev(options) {
267
294
  memory.stop();
268
295
  await bot?.close();
269
296
  await project.closeStores();
297
+ loose();
270
298
  if (signal.aborted)
271
299
  return 0;
272
300
  explain(error, log);
package/dist/cli/env.d.ts CHANGED
@@ -1,12 +1,17 @@
1
+ import { type RedisAddress } from '../redis/url.js';
1
2
  export interface DevEnv {
2
3
  token: string;
3
4
  devGuildId: string;
5
+ /** Where the stores with `storage: 'redis'` go, when `REDIS_URL` is set. */
6
+ redis: RedisAddress | null;
4
7
  }
5
8
  /** What `chapterjs start` runs with. */
6
9
  export interface StartEnv {
7
10
  token: string;
8
11
  /** The dev server, when the project has one: production leaves it alone. */
9
12
  devGuildId: string | null;
13
+ /** Where the stores with `storage: 'redis'` go, when `REDIS_URL` is set. */
14
+ redis: RedisAddress | null;
10
15
  }
11
16
  /**
12
17
  * Reads what `chapterjs dev` needs from the `.env` file of the project (the
package/dist/cli/env.js CHANGED
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
4
  import { isSnowflake } from '../discord/snowflake.js';
5
+ import { parseRedisUrl } from '../redis/url.js';
5
6
  /**
6
7
  * Values people leave in a `.env` copied from an example. Whole values only:
7
8
  * a real token may start with any letters.
@@ -24,6 +25,27 @@ async function readEnv(projectDir, processEnv) {
24
25
  hasFile,
25
26
  };
26
27
  }
28
+ /**
29
+ * `REDIS_URL` and `REDIS_PASSWORD`, when they are there: nothing is needed
30
+ * unless a store asks for Redis, which the files say once they loaded.
31
+ */
32
+ function redisProblem(read, where) {
33
+ const url = read('REDIS_URL');
34
+ const password = read('REDIS_PASSWORD');
35
+ if (url === '') {
36
+ if (password !== '') {
37
+ return {
38
+ problem: `${where}: REDIS_PASSWORD is set, but REDIS_URL is not.\nAdd REDIS_URL, the address of your Redis (like redis://localhost:6379), or remove REDIS_PASSWORD.`,
39
+ };
40
+ }
41
+ return { redis: null };
42
+ }
43
+ const parsed = parseRedisUrl(url, password);
44
+ if ('problem' in parsed) {
45
+ return { problem: `${where}: REDIS_URL ${parsed.problem}` };
46
+ }
47
+ return { redis: parsed.address };
48
+ }
27
49
  const TOKEN_HELP = 'Copy the token of your bot from https://discord.com/developers/applications → your application → Bot → Reset Token.';
28
50
  /** What is wrong with a token, if anything. */
29
51
  function tokenProblem(token, where) {
@@ -61,7 +83,12 @@ export async function readDevEnv(projectDir, processEnv) {
61
83
  else if (!isSnowflake(devGuildId)) {
62
84
  problems.push(`.env: DEV_GUILD_ID is "${devGuildId}", which is not a server ID (an ID only has digits).\nIn Discord, right-click your test server → Copy Server ID.`);
63
85
  }
64
- return problems.length > 0 ? { problems } : { env: { token, devGuildId } };
86
+ const redis = redisProblem(read, '.env');
87
+ if ('problem' in redis)
88
+ problems.push(redis.problem);
89
+ return problems.length > 0 || 'problem' in redis
90
+ ? { problems }
91
+ : { env: { token, devGuildId, redis: redis.redis } };
65
92
  }
66
93
  /**
67
94
  * Reads what `chapterjs start` needs. In production the values often come
@@ -87,7 +114,16 @@ export async function readStartEnv(projectDir, processEnv) {
87
114
  if (devGuildId !== '' && !isSnowflake(devGuildId)) {
88
115
  problems.push(`DEV_GUILD_ID is "${devGuildId}", which is not a server ID (an ID only has digits).\nRemove it, or copy it again: in Discord, right-click your test server → Copy Server ID.`);
89
116
  }
90
- return problems.length > 0
117
+ const redis = redisProblem(read, hasFile ? '.env' : 'Environment');
118
+ if ('problem' in redis)
119
+ problems.push(redis.problem);
120
+ return problems.length > 0 || 'problem' in redis
91
121
  ? { problems }
92
- : { env: { token, devGuildId: devGuildId === '' ? null : devGuildId } };
122
+ : {
123
+ env: {
124
+ token,
125
+ devGuildId: devGuildId === '' ? null : devGuildId,
126
+ redis: redis.redis,
127
+ },
128
+ };
93
129
  }
@@ -1,7 +1,8 @@
1
1
  import { type CommandEntry } from '../commands/tree.js';
2
2
  import { type ComponentEntry } from '../components/router.js';
3
3
  import { type LoadedStore } from '../store/declaration.js';
4
- import { type StoreBackend } from '../store/backend.js';
4
+ import { type LoadedRanking } from '../ranking/declaration.js';
5
+ import { type StoreBackend, type StoreStorage } from '../store/backend.js';
5
6
  import { type LoadedPresence } from '../presence/declaration.js';
6
7
  import { type DeclaredCommand, type DeclaredLanguage } from '../messages/declaration.js';
7
8
  import { type LoadedMessages } from '../messages/translate.js';
@@ -31,11 +32,13 @@ export interface ProjectOptions {
31
32
  */
32
33
  joinsVoice?: boolean;
33
34
  /**
34
- * Where the stores of the project keep their data. A file per store in
35
- * the `data/` folder of the project by default; the first process of a
36
- * cluster, through IPC, for the other processes.
35
+ * Where the stores of the project keep their data, by storage. `file`: a
36
+ * file per store in the `data/` folder of the project by default, the
37
+ * first process of a cluster through IPC for the other processes.
38
+ * `redis`: what the CLI made of `REDIS_URL`; without one, a store that
39
+ * asks for Redis is reported and left out.
37
40
  */
38
- stores?: StoreBackend;
41
+ stores?: Partial<Record<StoreStorage, StoreBackend>>;
39
42
  /** Only tests change this. */
40
43
  deferAfter?: number;
41
44
  /**
@@ -77,6 +80,12 @@ export interface TypesInfo {
77
80
  export declare function writeTypes(cwd: string, info?: TypesInfo): Promise<number>;
78
81
  /** The kinds of declarations a project is made of: one per feature. */
79
82
  export declare const DECLARATIONS: readonly Declaration<unknown>[];
83
+ /** A ranking, with where it is declared. */
84
+ export interface RankingEntry {
85
+ file: string;
86
+ export: string;
87
+ ranking: LoadedRanking;
88
+ }
80
89
  /** A store, with where it is declared. */
81
90
  export interface StoreEntry {
82
91
  file: string;
@@ -111,8 +120,14 @@ export interface Project {
111
120
  readonly components: ReadonlyMap<string, ComponentEntry>;
112
121
  readonly tasks: ReadonlyMap<string, TaskEntry>;
113
122
  readonly stores: ReadonlyMap<string, StoreEntry>;
114
- /** Where the stores keep their data: what a cluster serves to its other processes. */
115
- readonly storeBackend: StoreBackend;
123
+ readonly rankings: ReadonlyMap<string, RankingEntry>;
124
+ /** Where the stores keep their data, by storage: what a cluster serves to its other processes. */
125
+ readonly storeBackends: ReadonlyMap<StoreStorage, StoreBackend>;
126
+ /** What is kept in Redis: the names of the stores and of the rankings, sorted. */
127
+ redisStores(): {
128
+ stores: string[];
129
+ rankings: string[];
130
+ };
116
131
  /** The presence declared, or `null` without one. */
117
132
  readonly presence: LoadedPresence | null;
118
133
  /** The languages of the project, assembled, or `null` without any. */
@@ -166,6 +181,14 @@ export interface Project {
166
181
  /** Writes what the stores still have to write. Nothing is kept after this. */
167
182
  closeStores(): Promise<void>;
168
183
  }
184
+ /**
185
+ * A promise of the files that nobody waited for (`store.set()` without
186
+ * `await`, in a `run` that is not `async`) rejects with nobody to tell:
187
+ * Node would stop the bot. Reported instead, with the file and the line,
188
+ * and what to write. A rejection from outside the files stays what it is:
189
+ * a bug of the framework, loud. Returns what stops watching.
190
+ */
191
+ export declare function reportLooseRejections(cwd: string, log: Log): () => void;
169
192
  /** Whether the folder is a project: it has a `src` folder. */
170
193
  export declare function hasSources(cwd: string, log: Log): Promise<boolean>;
171
194
  export declare function createProject(options: ProjectOptions): Project;
@@ -16,7 +16,8 @@ import { componentDeclaration, findDuplicates, } from '../components/declaration
16
16
  import { ComponentRouter } from '../components/router.js';
17
17
  import { taskDeclaration } from '../tasks/declaration.js';
18
18
  import { storeDeclaration } from '../store/declaration.js';
19
- import { setStoreBackend } from '../store/backend.js';
19
+ import { rankingDeclaration, } from '../ranking/declaration.js';
20
+ import { nowhereToKeep, NowhereToKeepError, setStoreBackend, STORAGES, } from '../store/backend.js';
20
21
  import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
21
22
  import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
22
23
  import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
@@ -68,9 +69,29 @@ export const DECLARATIONS = [
68
69
  componentDeclaration,
69
70
  taskDeclaration,
70
71
  storeDeclaration,
72
+ rankingDeclaration,
71
73
  presenceDeclaration,
72
74
  languageDeclaration,
73
75
  ];
76
+ /**
77
+ * A promise of the files that nobody waited for (`store.set()` without
78
+ * `await`, in a `run` that is not `async`) rejects with nobody to tell:
79
+ * Node would stop the bot. Reported instead, with the file and the line,
80
+ * and what to write. A rejection from outside the files stays what it is:
81
+ * a bug of the framework, loud. Returns what stops watching.
82
+ */
83
+ export function reportLooseRejections(cwd, log) {
84
+ const listener = (reason) => {
85
+ const where = locate(reason, cwd);
86
+ if (!where)
87
+ throw reason;
88
+ log.error(`${where.file}:${where.line} ${messageOf(reason)}\nThis call was not awaited: write await in front of it (and async before run), so that your function waits for it.`);
89
+ };
90
+ process.on('unhandledRejection', listener);
91
+ return () => {
92
+ process.off('unhandledRejection', listener);
93
+ };
94
+ }
74
95
  /** Whether the folder is a project: it has a `src` folder. */
75
96
  export async function hasSources(cwd, log) {
76
97
  if ((await stat(join(cwd, 'src')).catch(() => null))?.isDirectory()) {
@@ -108,10 +129,11 @@ function sameNames(entries, nameOf, what) {
108
129
  seen.set(nameOf(entry), entry);
109
130
  return true;
110
131
  }
132
+ const kind = (one) => typeof what === 'string' ? what : what(one);
111
133
  conflicts.push({
112
134
  file: entry.file,
113
135
  export: entry.export,
114
- message: `There is already a ${what} named ${nameOf(entry)}, in ${first.file}: the name of the export is the name of the ${what}, so rename one of them.`,
136
+ message: `There is already a ${kind(first)} named ${nameOf(entry)}, in ${first.file}: the name of the export is the name of the ${kind(entry)}, so rename one of them.`,
115
137
  });
116
138
  return false;
117
139
  });
@@ -121,9 +143,18 @@ export function createProject(options) {
121
143
  const { cwd, log } = options;
122
144
  // Where `asset()` reads the files of the project from.
123
145
  setPublicDir(cwd);
124
- // Where the stores keep their data, for every store of the process.
125
- const storeBackend = options.stores ?? new FileStoreBackend(join(cwd, DATA_FOLDER));
126
- setStoreBackend(storeBackend);
146
+ // Where the stores keep their data, for every store of the process: a
147
+ // backend per storage the project has one for.
148
+ const storeBackends = new Map();
149
+ for (const storage of STORAGES) {
150
+ const backend = options.stores?.[storage] ??
151
+ (storage === 'file'
152
+ ? new FileStoreBackend(join(cwd, DATA_FOLDER))
153
+ : null);
154
+ setStoreBackend(storage, backend);
155
+ if (backend)
156
+ storeBackends.set(storage, backend);
157
+ }
127
158
  const report = (failure) => {
128
159
  const where = locate(failure.error, cwd);
129
160
  log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
@@ -150,6 +181,7 @@ export function createProject(options) {
150
181
  let components = new Map();
151
182
  let tasks = new Map();
152
183
  let stores = new Map();
184
+ let rankings = new Map();
153
185
  let presence = null;
154
186
  /** The files the presence was declared in, to keep it when they break. */
155
187
  let presenceFiles = new Set();
@@ -182,7 +214,20 @@ export function createProject(options) {
182
214
  get stores() {
183
215
  return stores;
184
216
  },
185
- storeBackend,
217
+ storeBackends,
218
+ get rankings() {
219
+ return rankings;
220
+ },
221
+ redisStores: () => ({
222
+ stores: [...stores.values()]
223
+ .filter(entry => entry.store.storage === 'redis')
224
+ .map(entry => entry.store.name)
225
+ .sort(),
226
+ rankings: [...rankings.values()]
227
+ .filter(entry => entry.ranking.storage === 'redis')
228
+ .map(entry => entry.ranking.name)
229
+ .sort(),
230
+ }),
186
231
  get presence() {
187
232
  return presence;
188
233
  },
@@ -213,12 +258,13 @@ export function createProject(options) {
213
258
  // then read, kind by kind.
214
259
  const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
215
260
  const read = (declaration) => readDeclarations(sources.modules, declaration);
216
- const [eventItems, commandItems, componentItems, taskItems, storeItems, presenceItems, languageItems,] = [
261
+ const [eventItems, commandItems, componentItems, taskItems, storeItems, rankingItems, presenceItems, languageItems,] = [
217
262
  read(eventDeclaration),
218
263
  read(commandDeclaration),
219
264
  read(componentDeclaration),
220
265
  read(taskDeclaration),
221
266
  read(storeDeclaration),
267
+ read(rankingDeclaration),
222
268
  read(presenceDeclaration),
223
269
  read(languageDeclaration),
224
270
  ];
@@ -272,15 +318,34 @@ export function createProject(options) {
272
318
  ], entry => entry.task.name, 'task');
273
319
  tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
274
320
  scheduler.set(tasks.values());
275
- // Two stores with one name would share one file: the later is left out.
321
+ const keptOf = (entry) => 'store' in entry ? entry.store : entry.ranking;
276
322
  const sameStore = sameNames([
277
323
  ...next(stores, storeItems, item => ({
278
324
  file: item.file,
279
325
  export: item.export,
280
326
  store: item.value,
281
327
  })).values(),
282
- ], entry => entry.store.name, 'store');
283
- stores = new Map(sameStore.valid.map(entry => [keyOf(entry), entry]));
328
+ ...next(rankings, rankingItems, item => ({
329
+ file: item.file,
330
+ export: item.export,
331
+ ranking: item.value,
332
+ })).values(),
333
+ ], entry => keptOf(entry).name, entry => ('store' in entry ? 'store' : 'ranking'));
334
+ // What is kept where nothing keeps it (Redis without REDIS_URL) is
335
+ // reported and left out: it says so itself when used.
336
+ const unkept = sameStore.valid.filter(entry => !storeBackends.has(keptOf(entry).storage));
337
+ const kept = sameStore.valid.filter(entry => !unkept.includes(entry));
338
+ stores = new Map(kept
339
+ .filter((entry) => 'store' in entry)
340
+ .map(entry => [keyOf(entry), entry]));
341
+ rankings = new Map(kept
342
+ .filter((entry) => 'ranking' in entry)
343
+ .map(entry => [keyOf(entry), entry]));
344
+ const nowhere = unkept.map(entry => ({
345
+ file: entry.file,
346
+ export: entry.export,
347
+ error: new NowhereToKeepError(nowhereToKeep(keptOf(entry).storage, keptOf(entry).name, 'store' in entry ? 'store' : 'ranking')),
348
+ }));
284
349
  // One presence. A second one is left out.
285
350
  const [first, ...extra] = presenceItems.loaded;
286
351
  if (first)
@@ -369,10 +434,12 @@ export function createProject(options) {
369
434
  ...componentItems.failed,
370
435
  ...taskItems.failed,
371
436
  ...storeItems.failed,
437
+ ...rankingItems.failed,
372
438
  ...presenceItems.failed,
373
439
  ...languageItems.failed,
374
440
  ...badTexts,
375
441
  ...twice,
442
+ ...nowhere,
376
443
  ...[
377
444
  ...conflicts,
378
445
  ...unique.conflicts,
@@ -404,6 +471,9 @@ export function createProject(options) {
404
471
  : []),
405
472
  ...(tasks.size > 0 ? [count(tasks.size, 'task', 'tasks')] : []),
406
473
  ...(stores.size > 0 ? [count(stores.size, 'store', 'stores')] : []),
474
+ ...(rankings.size > 0
475
+ ? [count(rankings.size, 'ranking', 'rankings')]
476
+ : []),
407
477
  ...(presence ? ['a presence'] : []),
408
478
  ...(messages
409
479
  ? [
@@ -417,6 +487,7 @@ export function createProject(options) {
417
487
  components.size === 0 &&
418
488
  tasks.size === 0 &&
419
489
  stores.size === 0 &&
490
+ rankings.size === 0 &&
420
491
  presence === null &&
421
492
  messages === null,
422
493
  // A file declaring several events that need the intent is named once.
@@ -530,7 +601,10 @@ export function createProject(options) {
530
601
  },
531
602
  // The bot looks after its own memory: it remembers less before memory
532
603
  // is full, and only speaks up when it can't give up anything more.
533
- closeStores: () => storeBackend.close(),
604
+ closeStores: async () => {
605
+ for (const backend of storeBackends.values())
606
+ await backend.close();
607
+ },
534
608
  watchMemory: current => watchMemory({
535
609
  cache: () => current()?.ctx.cache,
536
610
  onTightened: ({ members, forgotten }, { used, limit }) => log.info(`Memory was getting full (${megabytes(used)} of ${megabytes(limit)}): the bot now remembers ${members} members per server at most, and forgot ${forgotten} members and users it had not seen for the longest. Nothing changes for your code.`),
@@ -0,0 +1,23 @@
1
+ import type { RedisAddress } from '../redis/url.js';
2
+ import { RedisStoreBackend } from '../store/redis-backend.js';
3
+ import type { Log } from './log.js';
4
+ import type { Project } from './project.js';
5
+ /**
6
+ * The backend of the stores kept in Redis. Nothing is connected until a
7
+ * store is used, or `checkRedis()` asks; an outage is said once with `⚠`
8
+ * and its end with `↻`.
9
+ */
10
+ export declare function redisStores(address: RedisAddress, log: Log, options?: {
11
+ prefix?: string;
12
+ }): RedisStoreBackend;
13
+ /**
14
+ * The `ℹ` line naming the stores and rankings kept in Redis, or `null`
15
+ * without any: "The store sessions and the ranking xp are kept in Redis…".
16
+ */
17
+ export declare function describeRedisStores(project: Project, backend: RedisStoreBackend | null): string | null;
18
+ /**
19
+ * Makes sure Redis answers before the bot goes online, when a store is
20
+ * kept there: a Redis that can't be reached is an error now, not when
21
+ * someone uses the bot.
22
+ */
23
+ export declare function checkRedis(project: Project, backend: RedisStoreBackend | null, log: Log): Promise<boolean>;
@@ -0,0 +1,57 @@
1
+ // What `dev` and `start` share about Redis: the backend made of
2
+ // `REDIS_URL`, checked before Discord is reached when a store needs it,
3
+ // and the line that says which stores are kept there.
4
+ import { RedisClient } from '../redis/client.js';
5
+ import { RedisStoreBackend } from '../store/redis-backend.js';
6
+ /**
7
+ * The backend of the stores kept in Redis. Nothing is connected until a
8
+ * store is used, or `checkRedis()` asks; an outage is said once with `⚠`
9
+ * and its end with `↻`.
10
+ */
11
+ export function redisStores(address, log, options = {}) {
12
+ const client = new RedisClient(address, {
13
+ onError: message => log.warn(message),
14
+ onReconnect: () => log.reload(`Redis at ${client.where} is back`),
15
+ });
16
+ return new RedisStoreBackend(client, options);
17
+ }
18
+ /** `a`, `a and b`, `a, b and c`. */
19
+ const and = (items) => items.length <= 1
20
+ ? (items[0] ?? '')
21
+ : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
22
+ /**
23
+ * The `ℹ` line naming the stores and rankings kept in Redis, or `null`
24
+ * without any: "The store sessions and the ranking xp are kept in Redis…".
25
+ */
26
+ export function describeRedisStores(project, backend) {
27
+ const { stores, rankings } = project.redisStores();
28
+ const total = stores.length + rankings.length;
29
+ if (!backend || total === 0)
30
+ return null;
31
+ const group = (one, many, names) => names.length === 0
32
+ ? []
33
+ : [`the ${names.length === 1 ? one : many} ${and(names)}`];
34
+ const list = and([
35
+ ...group('store', 'stores', stores),
36
+ ...group('ranking', 'rankings', rankings),
37
+ ]);
38
+ return `${list[0].toUpperCase()}${list.slice(1)} ${total === 1 ? 'is' : 'are'} kept in Redis at ${backend.client.where}`;
39
+ }
40
+ /**
41
+ * Makes sure Redis answers before the bot goes online, when a store is
42
+ * kept there: a Redis that can't be reached is an error now, not when
43
+ * someone uses the bot.
44
+ */
45
+ export async function checkRedis(project, backend, log) {
46
+ const { stores, rankings } = project.redisStores();
47
+ if (!backend || stores.length + rankings.length === 0)
48
+ return true;
49
+ try {
50
+ await backend.client.ping();
51
+ return true;
52
+ }
53
+ catch (error) {
54
+ log.error(`${error instanceof Error ? error.message.replace(/ Trying again in \d+ s\.$/, '') : String(error)}`);
55
+ return false;
56
+ }
57
+ }