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.
Files changed (87) hide show
  1. package/README.md +65 -0
  2. package/dist/cli/build.js +14 -1
  3. package/dist/cli/cli.js +41 -6
  4. package/dist/cli/dev.js +29 -1
  5. package/dist/cli/env.d.ts +5 -0
  6. package/dist/cli/env.js +39 -3
  7. package/dist/cli/project.d.ts +49 -7
  8. package/dist/cli/project.js +103 -19
  9. package/dist/cli/redis.d.ts +23 -0
  10. package/dist/cli/redis.js +57 -0
  11. package/dist/cli/start.js +43 -17
  12. package/dist/cli/test.d.ts +29 -0
  13. package/dist/cli/test.js +446 -0
  14. package/dist/commands/autocomplete.d.ts +1 -1
  15. package/dist/commands/autocomplete.js +37 -41
  16. package/dist/commands/router.d.ts +4 -1
  17. package/dist/commands/router.js +15 -17
  18. package/dist/components/router.d.ts +7 -2
  19. package/dist/components/router.js +16 -16
  20. package/dist/discord/types/channel.d.ts +9 -1
  21. package/dist/discord/types/guild.d.ts +5 -5
  22. package/dist/discord/types/permissions.d.ts +6 -0
  23. package/dist/discord/types/poll.d.ts +7 -1
  24. package/dist/events/registry.d.ts +252 -239
  25. package/dist/events/registry.js +125 -100
  26. package/dist/events/router.d.ts +9 -4
  27. package/dist/events/router.js +24 -11
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.js +1 -0
  30. package/dist/interactions/dispatch.d.ts +5 -1
  31. package/dist/interactions/dispatch.js +6 -2
  32. package/dist/loader/hot.js +22 -2
  33. package/dist/loader/loader.d.ts +7 -0
  34. package/dist/loader/loader.js +22 -12
  35. package/dist/ranking/declaration.d.ts +12 -0
  36. package/dist/ranking/declaration.js +48 -0
  37. package/dist/ranking/ranking.d.ts +102 -0
  38. package/dist/ranking/ranking.js +129 -0
  39. package/dist/redis/client.d.ts +36 -0
  40. package/dist/redis/client.js +252 -0
  41. package/dist/redis/protocol.d.ts +22 -0
  42. package/dist/redis/protocol.js +99 -0
  43. package/dist/redis/url.d.ts +24 -0
  44. package/dist/redis/url.js +50 -0
  45. package/dist/store/backend.d.ts +49 -4
  46. package/dist/store/backend.js +66 -11
  47. package/dist/store/declaration.d.ts +3 -0
  48. package/dist/store/declaration.js +8 -3
  49. package/dist/store/file-backend.d.ts +3 -1
  50. package/dist/store/file-backend.js +29 -0
  51. package/dist/store/ipc-backend.d.ts +24 -1
  52. package/dist/store/ipc-backend.js +49 -0
  53. package/dist/store/memory-backend.d.ts +17 -0
  54. package/dist/store/memory-backend.js +82 -0
  55. package/dist/store/ranking-backend.d.ts +45 -0
  56. package/dist/store/ranking-backend.js +82 -0
  57. package/dist/store/redis-backend.d.ts +38 -0
  58. package/dist/store/redis-backend.js +245 -0
  59. package/dist/store/redis-ranking.d.ts +15 -0
  60. package/dist/store/redis-ranking.js +69 -0
  61. package/dist/store/scope.d.ts +11 -0
  62. package/dist/store/scope.js +23 -0
  63. package/dist/store/store.d.ts +39 -5
  64. package/dist/store/store.js +83 -33
  65. package/dist/structures/invite.d.ts +6 -0
  66. package/dist/structures/invite.js +11 -0
  67. package/dist/tasks/scheduler.d.ts +6 -0
  68. package/dist/tasks/scheduler.js +9 -3
  69. package/dist/testing/failure.d.ts +9 -0
  70. package/dist/testing/failure.js +20 -0
  71. package/dist/testing/fake-discord.d.ts +59 -0
  72. package/dist/testing/fake-discord.js +120 -0
  73. package/dist/testing/fake-gateway.d.ts +59 -0
  74. package/dist/testing/fake-gateway.js +123 -0
  75. package/dist/testing/harness.d.ts +387 -0
  76. package/dist/testing/harness.js +1418 -0
  77. package/dist/testing/index.d.ts +4 -0
  78. package/dist/testing/index.js +5 -0
  79. package/dist/testing/scenario.d.ts +39 -0
  80. package/dist/testing/scenario.js +58 -0
  81. package/dist/testing/smoke.d.ts +20 -0
  82. package/dist/testing/smoke.js +659 -0
  83. package/dist/testing/websocket.d.ts +34 -0
  84. package/dist/testing/websocket.js +167 -0
  85. package/dist/testing/world.d.ts +145 -0
  86. package/dist/testing/world.js +1479 -0
  87. package/package.json +20 -3
@@ -16,13 +16,14 @@ 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';
23
24
  import { translation } from '../messages/translate.js';
24
25
  import { translateCommand, unknownCommands } from '../messages/commands.js';
25
- import { TimerScheduler, } from '../tasks/scheduler.js';
26
+ import { runTask, TimerScheduler, } from '../tasks/scheduler.js';
26
27
  import { createBot } from '../core/bot.js';
27
28
  import { watchMemory } from '../core/memory.js';
28
29
  import { GatewayIntent } from '../discord/intents.js';
@@ -68,15 +69,35 @@ 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()) {
77
98
  return true;
78
99
  }
79
- log.error('There is no src folder here.\nRun this command in the folder of your bot (the one with package.json), or create a project with "pnpm create chapter".');
100
+ log.error('There is no src folder here.\nRun this command in the folder of your bot (the one with package.json), or create a project with "pnpm create chapterjs".');
80
101
  return false;
81
102
  }
82
103
  /** The intents that only bring what happens in private messages. */
@@ -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,11 +487,15 @@ 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
- filesNeeding: intent => [...events.values()]
423
- .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
424
- .map(({ file }) => file),
493
+ // A file declaring several events that need the intent is named once.
494
+ filesNeeding: intent => [
495
+ ...new Set([...events.values()]
496
+ .filter(({ event }) => (intentsOf(event) & GatewayIntent[intent]) !== 0)
497
+ .map(({ file }) => file)),
498
+ ],
425
499
  intents: (privateEvents = true) => neededIntents() & (privateEvents ? ~0 : ~PRIVATE_INTENTS),
426
500
  async connect(connection) {
427
501
  const created = createBot({
@@ -449,14 +523,17 @@ export function createProject(options) {
449
523
  },
450
524
  beforeDispatch: (event, data) => router.before(created.ctx, event, data),
451
525
  onDispatch(event, data, info) {
452
- router.dispatch(created.ctx, event, data, {
526
+ const handled = router.dispatch(created.ctx, event, data, {
453
527
  joined: info.joined,
454
528
  before: info.before,
455
529
  });
530
+ options.observe?.event?.(event, data, handled);
456
531
  if (event === 'INTERACTION_CREATE') {
457
532
  const interaction = data;
458
- commandRouter.dispatch(created.ctx, interaction);
459
- componentRouter.dispatch(created.ctx, interaction);
533
+ const answered = commandRouter.dispatch(created.ctx, interaction) ??
534
+ componentRouter.dispatch(created.ctx, interaction) ??
535
+ Promise.resolve();
536
+ options.observe?.interaction?.(interaction, answered);
460
537
  }
461
538
  },
462
539
  onEvent(event) {
@@ -518,9 +595,16 @@ export function createProject(options) {
518
595
  });
519
596
  },
520
597
  stopTasks: () => scheduler.stop(),
598
+ runTask(entry, bot, now = new Date()) {
599
+ const user = bot.ctx.cache.users.get(bot.ctx.self.userId);
600
+ return runTask(entry, { user, guilds: bot.ctx.cache.guilds, ...translation(bot.ctx, null) }, now, reporter.onError);
601
+ },
521
602
  // The bot looks after its own memory: it remembers less before memory
522
603
  // is full, and only speaks up when it can't give up anything more.
523
- closeStores: () => storeBackend.close(),
604
+ closeStores: async () => {
605
+ for (const backend of storeBackends.values())
606
+ await backend.close();
607
+ },
524
608
  watchMemory: current => watchMemory({
525
609
  cache: () => current()?.ctx.cache,
526
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
+ }
package/dist/cli/start.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // Nothing is reloaded, commands are registered for every server, and a
3
3
  // large bot is spread over several processes by itself.
4
4
  import { IpcStoreBackend } from '../store/ipc-backend.js';
5
+ import { NowhereToKeepError } from '../store/backend.js';
6
+ import { checkRedis, describeRedisStores, redisStores } from './redis.js';
5
7
  import { registerCommands } from '../commands/register.js';
6
8
  import { buildCommands } from '../commands/tree.js';
7
9
  import { GLOBAL_REQUESTS_PER_SECOND } from '../discord/api.js';
@@ -13,7 +15,7 @@ import { connectToPrimary, planProcesses, REFUSED, runCluster, } from './cluster
13
15
  import { loadBundle, readBuild } from './build.js';
14
16
  import { readStartEnv } from './env.js';
15
17
  import { describeIntents, ensurePrivilegedIntents, fetchApplication, } from './preflight.js';
16
- import { createProject, explain } from './project.js';
18
+ import { createProject, explain, reportLooseRejections, } from './project.js';
17
19
  /** Runs until stopped. Resolves with the exit code of the process. */
18
20
  export async function start(options) {
19
21
  const { cwd, log, signal } = options;
@@ -49,25 +51,30 @@ export async function start(options) {
49
51
  }
50
52
  return 1;
51
53
  }
54
+ // Every process reaches Redis itself: it is made to be shared.
55
+ const redis = read.env.redis ? redisStores(read.env.redis, log) : null;
52
56
  const project = createProject({
53
57
  cwd,
54
58
  version: options.version,
55
59
  log,
56
60
  built: files,
57
61
  joinsVoice: built.info.voice === true,
58
- // A process started by another keeps no data: the first one does, for
59
- // all of them.
60
- ...(assignment
61
- ? {
62
- stores: new IpcStoreBackend({
63
- send: message => {
64
- if (process.connected)
65
- process.send?.(message);
66
- },
67
- on: listener => process.on('message', listener),
68
- }),
69
- }
70
- : {}),
62
+ stores: {
63
+ // A process started by another keeps no file: the first one does,
64
+ // for all of them.
65
+ ...(assignment
66
+ ? {
67
+ file: new IpcStoreBackend({
68
+ send: message => {
69
+ if (process.connected)
70
+ process.send?.(message);
71
+ },
72
+ on: listener => process.on('message', listener),
73
+ }),
74
+ }
75
+ : {}),
76
+ ...(redis ? { redis } : {}),
77
+ },
71
78
  ...(options.deferAfter === undefined
72
79
  ? {}
73
80
  : { deferAfter: options.deferAfter }),
@@ -77,7 +84,12 @@ export async function start(options) {
77
84
  for (const failure of failures)
78
85
  project.report(failure);
79
86
  if (!assignment) {
80
- log.error(`${failures.length === 1 ? 'This file' : `These ${failures.length} files`} of the build can't run any more, so the bot was not started. Run "chapterjs build" again: it says what to fix.`);
87
+ // A store kept in Redis without REDIS_URL is about the environment,
88
+ // not the build.
89
+ const unkept = failures.every(failure => failure.error instanceof NowhereToKeepError);
90
+ log.error(unkept
91
+ ? 'The bot was not started. Give REDIS_URL to the bot the way your host does it (its "environment variables" or "secrets"), or in a .env file in this folder.'
92
+ : `${failures.length === 1 ? 'This file' : `These ${failures.length} files`} of the build can't run any more, so the bot was not started. Run "chapterjs build" again: it says what to fix.`);
81
93
  }
82
94
  return 1;
83
95
  }
@@ -94,10 +106,18 @@ export async function start(options) {
94
106
  ? { guildFilter: (id) => id !== devGuildId }
95
107
  : {}),
96
108
  };
109
+ // A promise of the files left alone is an error of the file, not the end
110
+ // of the bot: in every process, since every one runs the files.
111
+ const loose = reportLooseRejections(cwd, log);
97
112
  try {
98
113
  if (assignment)
99
114
  return await runShare(project, options, connection, assignment);
100
- // 3. What Discord must agree with, checked once for every process.
115
+ // 3. What Redis, then Discord, must agree with, checked once for every
116
+ // process.
117
+ if (!(await checkRedis(project, redis, log))) {
118
+ await project.closeStores();
119
+ return 1;
120
+ }
101
121
  const rest = new RestClient({
102
122
  token,
103
123
  version: options.version,
@@ -132,6 +152,9 @@ export async function start(options) {
132
152
  log.success(`Online as ${user} in ${guilds} ${guilds === 1 ? 'server' : 'servers'}${split ? ` (${split})` : ''}`);
133
153
  log.info(`Intents computed from your files: ${describeIntents(intents)}`);
134
154
  (project.isEmpty() ? log.info : log.success)(project.summary());
155
+ const kept = describeRedisStores(project, redis);
156
+ if (kept)
157
+ log.info(kept);
135
158
  };
136
159
  if (processes === 1) {
137
160
  return await runAlone(project, options, connection, online);
@@ -146,7 +169,7 @@ export async function start(options) {
146
169
  signal,
147
170
  identifyInterval,
148
171
  write: options.write,
149
- stores: project.storeBackend,
172
+ stores: project.storeBackends.get('file'),
150
173
  onReady: ({ user, guilds }) => online(user, guilds),
151
174
  onRestart: (index, why) => log.warn(`Process ${index + 1} of ${processes} stopped by itself (${why}): starting it again. Its servers are back in a moment.`),
152
175
  });
@@ -162,6 +185,9 @@ export async function start(options) {
162
185
  explain(error, log);
163
186
  return assignment ? REFUSED : 1;
164
187
  }
188
+ finally {
189
+ loose();
190
+ }
165
191
  }
166
192
  /** Runs a bot until it is stopped or Discord refuses it. */
167
193
  async function run(project, connection, stopped, connected, { tasks = true } = {}) {
@@ -0,0 +1,29 @@
1
+ import type { Log } from './log.js';
2
+ export interface TestOptions {
3
+ /** The folder of the project. */
4
+ cwd: string;
5
+ /** The version of the framework. */
6
+ version: string;
7
+ log: Log;
8
+ /** How long a scenario may take, in milliseconds. Default: 10 s. */
9
+ scenarioTimeout?: number;
10
+ /**
11
+ * Whether a person is watching a terminal: the check in progress is then
12
+ * shown, case by case, and replaced by its result once done.
13
+ */
14
+ progress?: boolean;
15
+ }
16
+ /**
17
+ * What a terminal shows while a file is tested: `↻ Testing src/close.ts`,
18
+ * then one line per thing and case as it is tried. Once the file is done,
19
+ * those lines are erased and its result takes their place, so a report
20
+ * that passed reads as if nothing had moved. When nobody is watching (the
21
+ * output is not a terminal), nothing is shown meanwhile.
22
+ */
23
+ export declare function checkProgress(enabled: boolean, log: Pick<Log, 'reload'>, write: (text: string) => void): {
24
+ start(title: string): void;
25
+ step(label: string): void;
26
+ clear(): void;
27
+ };
28
+ /** Resolves with the exit code of the process. */
29
+ export declare function test(options: TestOptions): Promise<number>;