chapterjs 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quentin Truffy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,3 +1,4 @@
1
+ import type { StoreBackend } from '../store/backend.js';
1
2
  import { type IdentifyGate } from '../gateway/identify-queue.js';
2
3
  /** How many shards one process runs when nothing else is asked. */
3
4
  export declare const SHARDS_PER_PROCESS = 4;
@@ -50,6 +51,8 @@ export interface ClusterOptions {
50
51
  signal: AbortSignal;
51
52
  /** Shows a line printed by one of the processes. */
52
53
  write: (line: string) => void;
54
+ /** Where the data of the stores is: this process keeps it for the others. */
55
+ stores: StoreBackend;
53
56
  /** Every process is connected, for the first time. */
54
57
  onReady: (info: {
55
58
  guilds: number;
@@ -9,6 +9,7 @@
9
9
  // identify. A setup on several machines would give each process another
10
10
  // `IdentifyGate`, and nothing else here would change.
11
11
  // https://docs.discord.com/developers/events/gateway#sharding
12
+ import { serveStores, } from '../store/ipc-backend.js';
12
13
  import { fork } from 'node:child_process';
13
14
  import { availableParallelism } from 'node:os';
14
15
  import { createInterface } from 'node:readline';
@@ -134,8 +135,10 @@ export async function runCluster(options) {
134
135
  for (const stream of [child.stdout, child.stderr]) {
135
136
  createInterface({ input: stream }).on('line', line => options.write(`[${index + 1}] ${line}`));
136
137
  }
138
+ const stores = serveStores(options.stores, reply => tell(child, reply));
137
139
  child.on('message', (message) => {
138
- if (message.type === 'identify') {
140
+ stores(message);
141
+ if (message.type !== 'store' && message.type === 'identify') {
139
142
  void gate.wait(message.shard).then(() => {
140
143
  tell(child, { type: 'identify', id: message.id });
141
144
  });
package/dist/cli/dev.js CHANGED
@@ -255,6 +255,7 @@ export async function dev(options) {
255
255
  assets.close();
256
256
  memory.stop();
257
257
  await bot?.close();
258
+ await project.closeStores();
258
259
  if (code === 0)
259
260
  log.success('Disconnected');
260
261
  return code;
@@ -265,6 +266,7 @@ export async function dev(options) {
265
266
  assets.close();
266
267
  memory.stop();
267
268
  await bot?.close();
269
+ await project.closeStores();
268
270
  if (signal.aborted)
269
271
  return 0;
270
272
  explain(error, log);
@@ -1,5 +1,7 @@
1
1
  import { type CommandEntry } from '../commands/tree.js';
2
2
  import { type ComponentEntry } from '../components/router.js';
3
+ import { type LoadedStore } from '../store/declaration.js';
4
+ import { type StoreBackend } from '../store/backend.js';
3
5
  import { type LoadedPresence } from '../presence/declaration.js';
4
6
  import { type DeclaredCommand, type DeclaredLanguage } from '../messages/declaration.js';
5
7
  import { type LoadedMessages } from '../messages/translate.js';
@@ -26,6 +28,12 @@ export interface ProjectOptions {
26
28
  * Without it, the files of `src` are read on every load.
27
29
  */
28
30
  joinsVoice?: boolean;
31
+ /**
32
+ * Where the stores of the project keep their data. A file per store in
33
+ * the `data/` folder of the project by default; the first process of a
34
+ * cluster, through IPC, for the other processes.
35
+ */
36
+ stores?: StoreBackend;
29
37
  /** Only tests change this. */
30
38
  deferAfter?: number;
31
39
  }
@@ -55,6 +63,12 @@ export interface TypesInfo {
55
63
  export declare function writeTypes(cwd: string, info?: TypesInfo): Promise<number>;
56
64
  /** The kinds of declarations a project is made of: one per feature. */
57
65
  export declare const DECLARATIONS: readonly Declaration<unknown>[];
66
+ /** A store, with where it is declared. */
67
+ export interface StoreEntry {
68
+ file: string;
69
+ export: string;
70
+ store: LoadedStore;
71
+ }
58
72
  /** What a bot of the project is connected with. */
59
73
  export interface ConnectOptions {
60
74
  token: string;
@@ -82,6 +96,9 @@ export interface Project {
82
96
  readonly commands: ReadonlyMap<string, CommandEntry>;
83
97
  readonly components: ReadonlyMap<string, ComponentEntry>;
84
98
  readonly tasks: ReadonlyMap<string, TaskEntry>;
99
+ readonly stores: ReadonlyMap<string, StoreEntry>;
100
+ /** Where the stores keep their data: what a cluster serves to its other processes. */
101
+ readonly storeBackend: StoreBackend;
85
102
  /** The presence declared, or `null` without one. */
86
103
  readonly presence: LoadedPresence | null;
87
104
  /** The languages of the project, assembled, or `null` without any. */
@@ -127,6 +144,8 @@ export interface Project {
127
144
  watchMemory(current: () => Bot | null): {
128
145
  stop(): void;
129
146
  };
147
+ /** Writes what the stores still have to write. Nothing is kept after this. */
148
+ closeStores(): Promise<void>;
130
149
  }
131
150
  /** Whether the folder is a project: it has a `src` folder. */
132
151
  export declare function hasSources(cwd: string, log: Log): Promise<boolean>;
@@ -15,6 +15,9 @@ import { findConflicts } from '../commands/tree.js';
15
15
  import { componentDeclaration, findDuplicates, } from '../components/declaration.js';
16
16
  import { ComponentRouter } from '../components/router.js';
17
17
  import { taskDeclaration } from '../tasks/declaration.js';
18
+ import { storeDeclaration } from '../store/declaration.js';
19
+ import { setStoreBackend } from '../store/backend.js';
20
+ import { DATA_FOLDER, FileStoreBackend } from '../store/file-backend.js';
18
21
  import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
19
22
  import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
20
23
  import { translation } from '../messages/translate.js';
@@ -64,6 +67,7 @@ export const DECLARATIONS = [
64
67
  commandDeclaration,
65
68
  componentDeclaration,
66
69
  taskDeclaration,
70
+ storeDeclaration,
67
71
  presenceDeclaration,
68
72
  languageDeclaration,
69
73
  ];
@@ -117,6 +121,9 @@ export function createProject(options) {
117
121
  const { cwd, log } = options;
118
122
  // Where `asset()` reads the files of the project from.
119
123
  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);
120
127
  const report = (failure) => {
121
128
  const where = locate(failure.error, cwd);
122
129
  log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
@@ -142,6 +149,7 @@ export function createProject(options) {
142
149
  let commands = new Map();
143
150
  let components = new Map();
144
151
  let tasks = new Map();
152
+ let stores = new Map();
145
153
  let presence = null;
146
154
  /** The files the presence was declared in, to keep it when they break. */
147
155
  let presenceFiles = new Set();
@@ -171,6 +179,10 @@ export function createProject(options) {
171
179
  get tasks() {
172
180
  return tasks;
173
181
  },
182
+ get stores() {
183
+ return stores;
184
+ },
185
+ storeBackend,
174
186
  get presence() {
175
187
  return presence;
176
188
  },
@@ -201,11 +213,12 @@ export function createProject(options) {
201
213
  // then read, kind by kind.
202
214
  const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
203
215
  const read = (declaration) => readDeclarations(sources.modules, declaration);
204
- const [eventItems, commandItems, componentItems, taskItems, presenceItems, languageItems,] = [
216
+ const [eventItems, commandItems, componentItems, taskItems, storeItems, presenceItems, languageItems,] = [
205
217
  read(eventDeclaration),
206
218
  read(commandDeclaration),
207
219
  read(componentDeclaration),
208
220
  read(taskDeclaration),
221
+ read(storeDeclaration),
209
222
  read(presenceDeclaration),
210
223
  read(languageDeclaration),
211
224
  ];
@@ -259,6 +272,15 @@ export function createProject(options) {
259
272
  ], entry => entry.task.name, 'task');
260
273
  tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
261
274
  scheduler.set(tasks.values());
275
+ // Two stores with one name would share one file: the later is left out.
276
+ const sameStore = sameNames([
277
+ ...next(stores, storeItems, item => ({
278
+ file: item.file,
279
+ export: item.export,
280
+ store: item.value,
281
+ })).values(),
282
+ ], entry => entry.store.name, 'store');
283
+ stores = new Map(sameStore.valid.map(entry => [keyOf(entry), entry]));
262
284
  // One presence. A second one is left out.
263
285
  const [first, ...extra] = presenceItems.loaded;
264
286
  if (first)
@@ -346,11 +368,17 @@ export function createProject(options) {
346
368
  ...commandItems.failed,
347
369
  ...componentItems.failed,
348
370
  ...taskItems.failed,
371
+ ...storeItems.failed,
349
372
  ...presenceItems.failed,
350
373
  ...languageItems.failed,
351
374
  ...badTexts,
352
375
  ...twice,
353
- ...[...conflicts, ...unique.conflicts, ...sameTask.conflicts].map(({ file, export: exported, message }) => ({
376
+ ...[
377
+ ...conflicts,
378
+ ...unique.conflicts,
379
+ ...sameTask.conflicts,
380
+ ...sameStore.conflicts,
381
+ ].map(({ file, export: exported, message }) => ({
354
382
  file,
355
383
  export: exported,
356
384
  error: new TypeError(message),
@@ -375,6 +403,7 @@ export function createProject(options) {
375
403
  ? [count(components.size, 'component', 'components')]
376
404
  : []),
377
405
  ...(tasks.size > 0 ? [count(tasks.size, 'task', 'tasks')] : []),
406
+ ...(stores.size > 0 ? [count(stores.size, 'store', 'stores')] : []),
378
407
  ...(presence ? ['a presence'] : []),
379
408
  ...(messages
380
409
  ? [
@@ -387,6 +416,7 @@ export function createProject(options) {
387
416
  commands.size === 0 &&
388
417
  components.size === 0 &&
389
418
  tasks.size === 0 &&
419
+ stores.size === 0 &&
390
420
  presence === null &&
391
421
  messages === null,
392
422
  filesNeeding: intent => [...events.values()]
@@ -490,6 +520,7 @@ export function createProject(options) {
490
520
  stopTasks: () => scheduler.stop(),
491
521
  // The bot looks after its own memory: it remembers less before memory
492
522
  // is full, and only speaks up when it can't give up anything more.
523
+ closeStores: () => storeBackend.close(),
493
524
  watchMemory: current => watchMemory({
494
525
  cache: () => current()?.ctx.cache,
495
526
  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.`),
package/dist/cli/start.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // `chapterjs start`: runs for everyone the bot `chapterjs build` prepared.
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
+ import { IpcStoreBackend } from '../store/ipc-backend.js';
4
5
  import { registerCommands } from '../commands/register.js';
5
6
  import { buildCommands } from '../commands/tree.js';
6
7
  import { GLOBAL_REQUESTS_PER_SECOND } from '../discord/api.js';
@@ -54,6 +55,19 @@ export async function start(options) {
54
55
  log,
55
56
  built: files,
56
57
  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
+ : {}),
57
71
  ...(options.deferAfter === undefined
58
72
  ? {}
59
73
  : { deferAfter: options.deferAfter }),
@@ -132,14 +146,17 @@ export async function start(options) {
132
146
  signal,
133
147
  identifyInterval,
134
148
  write: options.write,
149
+ stores: project.storeBackend,
135
150
  onReady: ({ user, guilds }) => online(user, guilds),
136
151
  onRestart: (index, why) => log.warn(`Process ${index + 1} of ${processes} stopped by itself (${why}): starting it again. Its servers are back in a moment.`),
137
152
  });
153
+ await project.closeStores();
138
154
  if (code === 0)
139
155
  log.success('Disconnected');
140
156
  return code;
141
157
  }
142
158
  catch (error) {
159
+ await project.closeStores();
143
160
  if (signal.aborted)
144
161
  return 0;
145
162
  explain(error, log);
@@ -168,6 +185,7 @@ async function run(project, connection, stopped, connected, { tasks = true } = {
168
185
  project.stopTasks();
169
186
  memory.stop();
170
187
  await bot?.close();
188
+ await project.closeStores();
171
189
  }
172
190
  }
173
191
  /** The whole bot in this process. */
@@ -139,6 +139,7 @@ export class CommandRouter {
139
139
  name,
140
140
  what: 'command',
141
141
  reporter: this.#options,
142
+ guildId: place.guild?.id ?? null,
142
143
  defer: () => interaction.defer(),
143
144
  unanswered: 'finished without answering: the person sees "The application did not respond". Call interaction.reply() in run.',
144
145
  run: () => command.run(context),
@@ -150,6 +150,7 @@ export class ComponentRouter {
150
150
  name,
151
151
  what,
152
152
  reporter: this.#options,
153
+ guildId: place.guild?.id ?? null,
153
154
  // Nothing changes on screen while a component is answered; a form
154
155
  // sent is like a command used.
155
156
  defer: () => interaction instanceof ComponentInteraction
@@ -41,6 +41,9 @@ export declare class EventRouter {
41
41
  joined: boolean;
42
42
  before: unknown[] | undefined;
43
43
  }): void;
44
- /** Runs the handlers of an event with what they receive. */
45
- emit<Name extends EventName>(name: Name, context: EventContexts[Name]): void;
44
+ /**
45
+ * Runs the handlers of an event with what they receive, for the server
46
+ * it happened in (what a store per server reads).
47
+ */
48
+ emit<Name extends EventName>(name: Name, context: EventContexts[Name], guildId?: string | null): void;
46
49
  }
@@ -1,4 +1,5 @@
1
1
  // Delivers gateway events to the handlers of the project.
2
+ import { withGuild } from '../store/scope.js';
2
3
  import { DEFAULT_CACHE_LIMITS } from '../cache/cache.js';
3
4
  import { GatewayIntent } from '../discord/intents.js';
4
5
  import { audienceLocale, translation } from '../messages/translate.js';
@@ -106,7 +107,7 @@ export class EventRouter {
106
107
  this.emit(source.name, {
107
108
  ...context,
108
109
  ...translation(ctx, audienceLocale({ guild })),
109
- });
110
+ }, guild?.id ?? null);
110
111
  }
111
112
  };
112
113
  const preparing = source.prepare?.(ctx, data);
@@ -115,8 +116,11 @@ export class EventRouter {
115
116
  preparing.then(deliver, (error) => this.#onSkipped(source.name, error));
116
117
  });
117
118
  }
118
- /** Runs the handlers of an event with what they receive. */
119
- emit(name, context) {
119
+ /**
120
+ * Runs the handlers of an event with what they receive, for the server
121
+ * it happened in (what a store per server reads).
122
+ */
123
+ emit(name, context, guildId = null) {
120
124
  const handlers = this.#handlers.get(name);
121
125
  if (!handlers)
122
126
  return;
@@ -127,7 +131,7 @@ export class EventRouter {
127
131
  if (accepts && !accepts(frozen, event.options))
128
132
  continue;
129
133
  // One handler failing never stops the others, nor the bot.
130
- new Promise(resolve => resolve(event.handler(frozen))).catch((error) => this.#onError(file, error));
134
+ new Promise(resolve => resolve(withGuild(guildId, () => event.handler(frozen)))).catch((error) => this.#onError(file, error));
131
135
  }
132
136
  }
133
137
  }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export { language } from './messages/messages.js';
29
29
  export type { CommandsTranslations, FrameworkTexts, LanguageConfig, LanguageDeclaration, MergedMessages, MessagesOf, MessageText, MessageTexts, PluralText, ProjectCommands, ProjectLanguageDefaults, ParamsArgs, ParamsOf, Placeholders, ProjectLocale, ProjectLocales, LocaleGiven, ProjectMessages, TranslationContext, Translator, } from './messages/messages.js';
30
30
  export { presence } from './presence/presence.js';
31
31
  export type { Activity, ActivityKind, PlainActivity, PresenceConfig, PresenceDeclaration, PresenceStatusName, StreamingActivity, } from './presence/presence.js';
32
+ export { store } from './store/store.js';
33
+ export type { ScopedStore, Store, StoreConfig, StoreEntry, StoreKey, StoreScope, StoreSetOptions, } from './store/store.js';
32
34
  export { task } from './tasks/task.js';
33
35
  export type { CronTaskConfig, EveryTaskConfig, TaskConfig, TaskContext, TaskDeclaration, } from './tasks/task.js';
34
36
  export type { AssetFile, AssetOptions } from './assets/asset.js';
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ export { event } from './events/event.js';
21
21
  // Tasks: what the files of src/tasks/ are written with.
22
22
  export { language } from './messages/messages.js';
23
23
  export { presence } from './presence/presence.js';
24
+ export { store } from './store/store.js';
24
25
  export { task } from './tasks/task.js';
25
26
  // Errors handlers can catch.
26
27
  export { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from './rest/errors.js';
@@ -80,6 +80,8 @@ export interface RunOptions {
80
80
  defer: () => Promise<void>;
81
81
  /** What to tell the developer when `run` finishes without answering. */
82
82
  unanswered: string;
83
+ /** The server the function runs for: what a store per server reads. */
84
+ guildId: string | null;
83
85
  run: () => unknown;
84
86
  }
85
87
  /**
@@ -3,6 +3,7 @@
3
3
  // there, and how its function is run so that the person always gets an
4
4
  // answer and the developer always gets the error. Written once here;
5
5
  // `commands/router.ts` and `components/router.ts` add what is theirs.
6
+ import { withGuild } from '../store/scope.js';
6
7
  import { setTimeout as sleep } from 'node:timers/promises';
7
8
  import { phrase } from '../messages/phrases.js';
8
9
  import { DiscordApiError } from '../rest/errors.js';
@@ -81,7 +82,7 @@ export function runInteraction(options) {
81
82
  sleep(reporter.deferAfter ?? 2000, undefined, { signal: stop.signal })
82
83
  .then(() => options.defer())
83
84
  .catch(() => { });
84
- new Promise(resolve => resolve(options.run()))
85
+ new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
85
86
  .then(() => {
86
87
  if (!interaction.answered) {
87
88
  reporter.onWarning(file, `${name} ${options.unanswered}`);
@@ -0,0 +1,33 @@
1
+ /** One value of a store, with when it expires. */
2
+ export interface StoreEntry {
3
+ value: unknown;
4
+ /** When the value expires, in ms since the epoch, or `null` for never. */
5
+ expiresAt: number | null;
6
+ }
7
+ /** A key as the backend sees it: every part, the scope included. */
8
+ export type StoreKeyParts = readonly string[];
9
+ /** An entry with its key, as `entries()` lists them. */
10
+ export interface StoreListedEntry extends StoreEntry {
11
+ key: StoreKeyParts;
12
+ }
13
+ /** What keeps the data of every store. Expired entries are never given. */
14
+ export interface StoreBackend {
15
+ get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
16
+ set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
17
+ delete(store: string, key: StoreKeyParts): Promise<boolean>;
18
+ /** The entries whose key starts with `prefix`, in no particular order. */
19
+ entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
20
+ /** Writes what is pending, if anything. */
21
+ close(): Promise<void>;
22
+ }
23
+ /** The map key of a key: unambiguous whatever the parts hold. */
24
+ export declare const encodeKey: (key: StoreKeyParts) => string;
25
+ export declare const decodeKey: (encoded: string) => string[];
26
+ /** Whether `key` starts with every part of `prefix`. */
27
+ export declare function startsWith(key: StoreKeyParts, prefix: StoreKeyParts): boolean;
28
+ /** Whether an entry is still good at `now`. */
29
+ export declare const alive: (entry: StoreEntry, now?: number) => boolean;
30
+ /** Gives every store its backend: called by the CLI, once per process. */
31
+ export declare function setStoreBackend(backend: StoreBackend | null): void;
32
+ /** The backend of the process, or an error that says when stores work. */
33
+ export declare function storeBackend(): StoreBackend;
@@ -0,0 +1,27 @@
1
+ // Where the data of the stores is kept, behind an interface: a file per
2
+ // store by default (`file-backend.ts`), the first process of a cluster for
3
+ // the others (`ipc-backend.ts`), and later anything else. A backend knows
4
+ // nothing of servers: the scope of a store is part of the key it gets.
5
+ /** The map key of a key: unambiguous whatever the parts hold. */
6
+ export const encodeKey = (key) => JSON.stringify(key);
7
+ export const decodeKey = (encoded) => JSON.parse(encoded);
8
+ /** Whether `key` starts with every part of `prefix`. */
9
+ export function startsWith(key, prefix) {
10
+ if (prefix.length > key.length)
11
+ return false;
12
+ return prefix.every((part, index) => key[index] === part);
13
+ }
14
+ /** Whether an entry is still good at `now`. */
15
+ export const alive = (entry, now = Date.now()) => entry.expiresAt === null || entry.expiresAt > now;
16
+ let current = null;
17
+ /** Gives every store its backend: called by the CLI, once per process. */
18
+ export function setStoreBackend(backend) {
19
+ current = backend;
20
+ }
21
+ /** The backend of the process, or an error that says when stores work. */
22
+ export function storeBackend() {
23
+ if (!current) {
24
+ throw new Error('Stores only work while the bot runs: use a store in the functions of your files (run), not when a file loads.');
25
+ }
26
+ return current;
27
+ }
@@ -0,0 +1,15 @@
1
+ import type { Declaration } from '../loader/loader.js';
2
+ import { type StoreScope } from './store.js';
3
+ /** A store, checked: what the project keeps of it. */
4
+ export interface LoadedStore {
5
+ /** The name of the store: the name of the export that declares it. */
6
+ readonly name: string;
7
+ readonly scope: StoreScope;
8
+ /** The default expiration, in ms, or `null` for never. */
9
+ readonly expires: number | null;
10
+ }
11
+ /**
12
+ * A store, declared anywhere in `src/` with `store()` and exported: the
13
+ * name of the export is the name of the store.
14
+ */
15
+ export declare const storeDeclaration: Declaration<LoadedStore>;
@@ -0,0 +1,53 @@
1
+ // What a file declares with `store()`, checked when it loads, so a wrong
2
+ // scope or expiration is explained before anything is kept; the store is
3
+ // then bound to the name of its export, which names its data.
4
+ import { DURATION_EXAMPLE, parseDuration } from '../util/duration.js';
5
+ import { bindStore, isStoreDeclaration, storeStateOf, } from './store.js';
6
+ const fail = (message) => {
7
+ throw new TypeError(message);
8
+ };
9
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ const KEYS = ['scope', 'expires'];
11
+ const SCOPES = ['guild', 'global'];
12
+ /** What a name can be: a file in `data/` is called after it. */
13
+ const NAME = /^[\w.-]+$/;
14
+ /**
15
+ * A store, declared anywhere in `src/` with `store()` and exported: the
16
+ * name of the export is the name of the store.
17
+ */
18
+ export const storeDeclaration = {
19
+ one: 'store',
20
+ many: 'stores',
21
+ is: isStoreDeclaration,
22
+ list: false,
23
+ read(value, { name }) {
24
+ const config = storeStateOf(value)?.config;
25
+ if (!isRecord(config)) {
26
+ return fail(`store() takes an object, or nothing: store<Warning[]>({ expires: '30d' }).`);
27
+ }
28
+ for (const key of Object.keys(config)) {
29
+ if (!KEYS.includes(key)) {
30
+ fail(`"${key}" is not something a store has. It can have: ${KEYS.join(', ')}.`);
31
+ }
32
+ }
33
+ if (!NAME.test(name)) {
34
+ fail(`"${name}" can't name a store: the name of the export is the name of the store and of its file in data/, made of letters, digits, _ . and -.`);
35
+ }
36
+ const scope = (config.scope ?? 'guild');
37
+ if (!SCOPES.includes(scope)) {
38
+ fail(`"scope" is 'guild' (the data is kept per server, the default) or 'global' (one set of data for the whole bot), got ${JSON.stringify(config.scope)}.`);
39
+ }
40
+ let expires = null;
41
+ if (config.expires !== undefined) {
42
+ const ms = typeof config.expires === 'string'
43
+ ? parseDuration(config.expires)
44
+ : null;
45
+ if (ms === null || ms < 1000) {
46
+ fail(`"expires" is how long a value is kept: ${DURATION_EXAMPLE}, 1 s at least. Got ${JSON.stringify(config.expires)}.`);
47
+ }
48
+ expires = ms;
49
+ }
50
+ bindStore(value, { name, scope, expires });
51
+ return Object.freeze({ name, scope, expires });
52
+ },
53
+ };
@@ -0,0 +1,22 @@
1
+ import { type StoreBackend, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
2
+ /** The folder of a project where the stores keep their files. */
3
+ export declare const DATA_FOLDER = "data";
4
+ export interface FileStoreBackendOptions {
5
+ /** How long a change waits for others before the file is written. */
6
+ writeDelay?: number;
7
+ /** How often expired entries are dropped. */
8
+ sweepEvery?: number;
9
+ now?: () => number;
10
+ }
11
+ /** The data of every store, one file each, loaded on first use. */
12
+ export declare class FileStoreBackend implements StoreBackend {
13
+ #private;
14
+ constructor(dir: string, options?: FileStoreBackendOptions);
15
+ /** The file of a store. */
16
+ file(store: string): string;
17
+ get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
18
+ set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
19
+ delete(store: string, key: StoreKeyParts): Promise<boolean>;
20
+ entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
21
+ close(): Promise<void>;
22
+ }
@@ -0,0 +1,172 @@
1
+ // The default backend: one JSON file per store in the `data/` folder of
2
+ // the project, kept in memory and written back shortly after each change,
3
+ // atomically (a temporary file, then a rename), so a crash never leaves a
4
+ // half-written file. What is pending when the process exits is written
5
+ // then. Expired entries are dropped when a file is read, when they are
6
+ // asked for, and by a sweep every minute, so a file never grows forever.
7
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { alive, decodeKey, encodeKey, startsWith, } from './backend.js';
10
+ /** The folder of a project where the stores keep their files. */
11
+ export const DATA_FOLDER = 'data';
12
+ /** A name a file can be called: what an export is called. */
13
+ const FILE_NAME = /^[\w.-]+$/;
14
+ /** The data of every store, one file each, loaded on first use. */
15
+ export class FileStoreBackend {
16
+ #dir;
17
+ #stores = new Map();
18
+ #dirty = new Set();
19
+ #now;
20
+ #writeDelay;
21
+ #writeTimer = null;
22
+ #sweeper;
23
+ #onExit;
24
+ constructor(dir, options = {}) {
25
+ this.#dir = dir;
26
+ this.#now = options.now ?? Date.now;
27
+ this.#writeDelay = options.writeDelay ?? 100;
28
+ this.#sweeper = setInterval(() => this.#sweep(), options.sweepEvery ?? 60_000);
29
+ this.#sweeper.unref();
30
+ // What was changed in the last moments is not lost with the process.
31
+ this.#onExit = () => this.#flush();
32
+ process.on('exit', this.#onExit);
33
+ }
34
+ /** The file of a store. */
35
+ file(store) {
36
+ return join(this.#dir, `${store}.json`);
37
+ }
38
+ #load(store) {
39
+ let entries = this.#stores.get(store);
40
+ if (entries)
41
+ return entries;
42
+ if (!FILE_NAME.test(store)) {
43
+ throw new TypeError(`"${store}" can't name a store: its name is the name of its export, made of letters, digits, _ . and -.`);
44
+ }
45
+ entries = new Map();
46
+ let text = null;
47
+ try {
48
+ text = readFileSync(this.file(store), 'utf8');
49
+ }
50
+ catch (error) {
51
+ if (error.code !== 'ENOENT')
52
+ throw error;
53
+ }
54
+ if (text !== null) {
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(text);
58
+ if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) {
59
+ throw new Error('not a store file');
60
+ }
61
+ }
62
+ catch {
63
+ throw new Error(`${this.file(store)} is not a file ChapterJS wrote: fix it, or delete it to start the store empty.`);
64
+ }
65
+ const now = this.#now();
66
+ for (const [key, value, expiresAt] of parsed.entries) {
67
+ const entry = { value, expiresAt };
68
+ if (alive(entry, now))
69
+ entries.set(encodeKey(key), entry);
70
+ }
71
+ }
72
+ this.#stores.set(store, entries);
73
+ return entries;
74
+ }
75
+ #changed(store) {
76
+ this.#dirty.add(store);
77
+ if (this.#writeTimer)
78
+ return;
79
+ this.#writeTimer = setTimeout(() => this.#flush(), this.#writeDelay);
80
+ this.#writeTimer.unref();
81
+ }
82
+ /** Writes every store that changed. */
83
+ #flush() {
84
+ if (this.#writeTimer) {
85
+ clearTimeout(this.#writeTimer);
86
+ this.#writeTimer = null;
87
+ }
88
+ if (this.#dirty.size === 0)
89
+ return;
90
+ mkdirSync(this.#dir, { recursive: true });
91
+ for (const store of this.#dirty) {
92
+ const entries = this.#stores.get(store);
93
+ if (!entries)
94
+ continue;
95
+ const file = {
96
+ version: 1,
97
+ entries: [...entries].map(([key, { value, expiresAt }]) => [
98
+ decodeKey(key),
99
+ value,
100
+ expiresAt,
101
+ ]),
102
+ };
103
+ const path = this.file(store);
104
+ const temporary = `${path}.${process.pid}.tmp`;
105
+ writeFileSync(temporary, `${JSON.stringify(file)}\n`);
106
+ renameSync(temporary, path);
107
+ }
108
+ this.#dirty.clear();
109
+ }
110
+ #sweep() {
111
+ const now = this.#now();
112
+ for (const [store, entries] of this.#stores) {
113
+ let dropped = false;
114
+ for (const [key, entry] of entries) {
115
+ if (!alive(entry, now)) {
116
+ entries.delete(key);
117
+ dropped = true;
118
+ }
119
+ }
120
+ if (dropped)
121
+ this.#changed(store);
122
+ }
123
+ }
124
+ async get(store, key) {
125
+ const entries = this.#load(store);
126
+ const encoded = encodeKey(key);
127
+ const entry = entries.get(encoded);
128
+ if (!entry)
129
+ return undefined;
130
+ if (alive(entry, this.#now()))
131
+ return entry;
132
+ entries.delete(encoded);
133
+ this.#changed(store);
134
+ return undefined;
135
+ }
136
+ async set(store, key, entry) {
137
+ this.#load(store).set(encodeKey(key), entry);
138
+ this.#changed(store);
139
+ }
140
+ async delete(store, key) {
141
+ const entries = this.#load(store);
142
+ const encoded = encodeKey(key);
143
+ const entry = entries.get(encoded);
144
+ if (!entry)
145
+ return false;
146
+ entries.delete(encoded);
147
+ this.#changed(store);
148
+ return alive(entry, this.#now());
149
+ }
150
+ async entries(store, prefix) {
151
+ const entries = this.#load(store);
152
+ const now = this.#now();
153
+ const listed = [];
154
+ for (const [encoded, entry] of entries) {
155
+ const key = decodeKey(encoded);
156
+ if (!startsWith(key, prefix))
157
+ continue;
158
+ if (alive(entry, now))
159
+ listed.push({ key, ...entry });
160
+ else {
161
+ entries.delete(encoded);
162
+ this.#changed(store);
163
+ }
164
+ }
165
+ return listed;
166
+ }
167
+ async close() {
168
+ clearInterval(this.#sweeper);
169
+ process.off('exit', this.#onExit);
170
+ this.#flush();
171
+ }
172
+ }
@@ -0,0 +1,53 @@
1
+ import type { StoreBackend, StoreEntry, StoreKeyParts, StoreListedEntry } from './backend.js';
2
+ /** What a process asks the first one. */
3
+ export type StoreRequest = {
4
+ type: 'store';
5
+ id: number;
6
+ } & ({
7
+ op: 'get';
8
+ store: string;
9
+ key: StoreKeyParts;
10
+ } | {
11
+ op: 'set';
12
+ store: string;
13
+ key: StoreKeyParts;
14
+ entry: StoreEntry;
15
+ } | {
16
+ op: 'delete';
17
+ store: string;
18
+ key: StoreKeyParts;
19
+ } | {
20
+ op: 'entries';
21
+ store: string;
22
+ prefix: StoreKeyParts;
23
+ });
24
+ /** What the first process answers. */
25
+ export interface StoreReply {
26
+ type: 'store';
27
+ id: number;
28
+ result?: unknown;
29
+ error?: string;
30
+ }
31
+ /** The two ends of a channel, as a process and its child have them. */
32
+ export interface Channel<Out, In> {
33
+ send(message: Out): void;
34
+ on(listener: (message: In) => void): void;
35
+ }
36
+ export declare const isStoreRequest: (message: unknown) => message is StoreRequest;
37
+ export declare const isStoreReply: (message: unknown) => message is StoreReply;
38
+ /** The backend of a process that is not the first: every call is a message. */
39
+ export declare class IpcStoreBackend implements StoreBackend {
40
+ #private;
41
+ constructor(channel: Channel<StoreRequest, unknown>);
42
+ get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
43
+ set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
44
+ delete(store: string, key: StoreKeyParts): Promise<boolean>;
45
+ entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
46
+ close(): Promise<void>;
47
+ }
48
+ /**
49
+ * In the first process: answers the requests of one other process with
50
+ * the real backend. Returns what to call with each message received from
51
+ * that process; a message that is not a request is ignored.
52
+ */
53
+ export declare function serveStores(backend: StoreBackend, reply: (message: StoreReply) => void): (message: unknown) => void;
@@ -0,0 +1,87 @@
1
+ // A bot split over several processes has one copy of its data: the first
2
+ // process keeps it (with the file backend) and the others ask it over
3
+ // IPC, the channel they already share with it. Each process then sees the
4
+ // same thing, whichever shard a command was used on.
5
+ export const isStoreRequest = (message) => typeof message === 'object' &&
6
+ message !== null &&
7
+ message.type === 'store' &&
8
+ 'op' in message;
9
+ export const isStoreReply = (message) => typeof message === 'object' &&
10
+ message !== null &&
11
+ message.type === 'store' &&
12
+ !('op' in message);
13
+ /** The backend of a process that is not the first: every call is a message. */
14
+ export class IpcStoreBackend {
15
+ #channel;
16
+ #waiting = new Map();
17
+ #lastId = 0;
18
+ constructor(channel) {
19
+ this.#channel = channel;
20
+ channel.on(message => {
21
+ if (!isStoreReply(message))
22
+ return;
23
+ const pending = this.#waiting.get(message.id);
24
+ if (!pending)
25
+ return;
26
+ this.#waiting.delete(message.id);
27
+ if (message.error !== undefined)
28
+ pending.reject(new Error(message.error));
29
+ else
30
+ pending.resolve(message.result);
31
+ });
32
+ }
33
+ #ask(request) {
34
+ const id = ++this.#lastId;
35
+ return new Promise((resolve, reject) => {
36
+ this.#waiting.set(id, {
37
+ resolve: resolve,
38
+ reject,
39
+ });
40
+ this.#channel.send({ type: 'store', id, ...request });
41
+ });
42
+ }
43
+ get(store, key) {
44
+ return this.#ask({ op: 'get', store, key });
45
+ }
46
+ set(store, key, entry) {
47
+ return this.#ask({ op: 'set', store, key, entry });
48
+ }
49
+ delete(store, key) {
50
+ return this.#ask({ op: 'delete', store, key });
51
+ }
52
+ entries(store, prefix) {
53
+ return this.#ask({ op: 'entries', store, prefix });
54
+ }
55
+ async close() {
56
+ // Nothing is kept here: the first process writes.
57
+ }
58
+ }
59
+ /**
60
+ * In the first process: answers the requests of one other process with
61
+ * the real backend. Returns what to call with each message received from
62
+ * that process; a message that is not a request is ignored.
63
+ */
64
+ export function serveStores(backend, reply) {
65
+ return message => {
66
+ if (!isStoreRequest(message))
67
+ return;
68
+ const { id } = message;
69
+ const run = () => {
70
+ switch (message.op) {
71
+ case 'get':
72
+ return backend.get(message.store, message.key);
73
+ case 'set':
74
+ return backend.set(message.store, message.key, message.entry);
75
+ case 'delete':
76
+ return backend.delete(message.store, message.key);
77
+ case 'entries':
78
+ return backend.entries(message.store, message.prefix);
79
+ }
80
+ };
81
+ run().then(result => reply({ type: 'store', id, result }), (error) => reply({
82
+ type: 'store',
83
+ id,
84
+ error: error instanceof Error ? error.message : String(error),
85
+ }));
86
+ };
87
+ }
@@ -0,0 +1,8 @@
1
+ /** Runs `fn` for a server (or for none, with `null`). */
2
+ export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
3
+ /**
4
+ * The id of the server the current function runs for: `null` when it runs
5
+ * for none (a task, a private message), `undefined` outside any function
6
+ * the framework runs.
7
+ */
8
+ export declare function currentGuildId(): string | null | undefined;
@@ -0,0 +1,20 @@
1
+ // The server the code runs for, known to the framework and not written by
2
+ // the user: a store per server reads it, so `warnings.get(member.id)` in a
3
+ // command is the warnings of the server the command was used in. Set by
4
+ // the routers around every function they run, on an asynchronous context
5
+ // of Node, so it follows the `await`s into the files the function imports.
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
7
+ /** The id of the server the current function runs for, or `null` for none. */
8
+ const scope = new AsyncLocalStorage();
9
+ /** Runs `fn` for a server (or for none, with `null`). */
10
+ export function withGuild(guildId, fn) {
11
+ return scope.run(guildId, fn);
12
+ }
13
+ /**
14
+ * The id of the server the current function runs for: `null` when it runs
15
+ * for none (a task, a private message), `undefined` outside any function
16
+ * the framework runs.
17
+ */
18
+ export function currentGuildId() {
19
+ return scope.getStore();
20
+ }
@@ -0,0 +1,115 @@
1
+ import type { Duration } from '../commands/command.js';
2
+ import type { Guild } from '../structures/guild.js';
3
+ /** Where the data of a store is kept apart: per server, or one for the whole bot. */
4
+ export type StoreScope = 'guild' | 'global';
5
+ /** What a file gives to `store()`. */
6
+ export interface StoreConfig {
7
+ /**
8
+ * `'guild'` (the default): the data is kept per server, and a function
9
+ * that runs in a server reads and writes the data of that server, with
10
+ * nothing to say. `'global'`: one set of data for the whole bot.
11
+ */
12
+ scope?: StoreScope;
13
+ /**
14
+ * How long a value is kept, unless `set()` says otherwise: a number and
15
+ * a unit, `s`, `m`, `h` or `d`, like `'10m'`, `'1d'` or `'1h30m'`. Kept
16
+ * forever by default.
17
+ */
18
+ expires?: Duration;
19
+ }
20
+ /**
21
+ * A key of a store: a text, or a list of texts for a key made of several
22
+ * parts (`[channel.id, message.id]`), which `entries()` can then list by
23
+ * prefix.
24
+ */
25
+ export type StoreKey = string | readonly string[];
26
+ /** What `set()` and `update()` take besides the value. */
27
+ export interface StoreSetOptions {
28
+ /**
29
+ * How long the value is kept: a duration like `'10m'`, a `Date`, or
30
+ * `null` to keep it forever. The `expires` of the store by default.
31
+ */
32
+ expires?: Duration | Date | null;
33
+ }
34
+ /** One entry of a store, as `entries()` lists them. */
35
+ export interface StoreEntry<T> {
36
+ /** The key, every part of it (the server left out for a store per server). */
37
+ key: readonly string[];
38
+ value: T;
39
+ /** When the value expires, or `null` when it is kept forever. */
40
+ expires: Date | null;
41
+ }
42
+ /** A store, read and written for one server or for the whole bot. */
43
+ export interface ScopedStore<T> {
44
+ /** The value at `key`, or `undefined` when there is none, or it expired. */
45
+ get(key: StoreKey): Promise<T | undefined>;
46
+ /** Keeps `value` at `key`. The value must be JSON: no `Date`, `Map` or class instance. */
47
+ set(key: StoreKey, value: T, options?: StoreSetOptions): Promise<void>;
48
+ /**
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
51
+ * expiration stays what it was, unless `options` say otherwise.
52
+ */
53
+ update(key: StoreKey, change: (current: T | undefined) => T, options?: StoreSetOptions): Promise<T>;
54
+ /** Forgets the value at `key`. `true` when there was one. */
55
+ delete(key: StoreKey): Promise<boolean>;
56
+ /**
57
+ * Every entry, or the ones whose key starts with `prefix`, in no
58
+ * particular order. Expired ones are left out.
59
+ */
60
+ entries(prefix?: StoreKey): Promise<StoreEntry<T>[]>;
61
+ /**
62
+ * How long the value at `key` is still kept, in milliseconds: `null`
63
+ * when it is kept forever, `undefined` when there is no value.
64
+ */
65
+ ttl(key: StoreKey): Promise<number | null | undefined>;
66
+ }
67
+ /** What `store()` returns: a declaration the framework finds in the exports of a file. */
68
+ export interface Store<T> extends ScopedStore<T> {
69
+ /**
70
+ * The same store, for one server: what a task, or a function that runs
71
+ * outside a server, uses to read and write the data of that server.
72
+ */
73
+ in(guild: Guild | string): ScopedStore<T>;
74
+ }
75
+ /** A store as the loader knows it: its config, and once loaded, its name. */
76
+ export interface StoreState {
77
+ readonly config: unknown;
78
+ /** The name of the export that declares it, once bound by the loader. */
79
+ name?: string;
80
+ /** What `read` found in the config. */
81
+ scope: StoreScope;
82
+ /** The default expiration, in ms, or `null` for never. */
83
+ expires: number | null;
84
+ }
85
+ /** The state of a declared store, if the value is one. */
86
+ export declare function storeStateOf(value: unknown): StoreState | undefined;
87
+ export declare const isStoreDeclaration: (value: unknown) => boolean;
88
+ /** Binds a declared store to the name of its export. */
89
+ export declare function bindStore(declared: unknown, bound: {
90
+ name: string;
91
+ scope: StoreScope;
92
+ expires: number | null;
93
+ }): void;
94
+ /**
95
+ * Refuses what would not survive a trip through JSON: a `Date` would come
96
+ * back as a text, a `Map` as `{}`, a class instance as a plain object.
97
+ */
98
+ export declare function checkJson(value: unknown, path?: string): void;
99
+ /**
100
+ * Declares a store: data the bot keeps from one restart to the next,
101
+ * 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.
105
+ *
106
+ * ```ts
107
+ * import { store } from 'chapterjs';
108
+ *
109
+ * export const warnings = store<Warning[]>({ expires: '30d' });
110
+ *
111
+ * // In a command, in a server:
112
+ * await warnings.update(member.id, (list = []) => [...list, warning]);
113
+ * ```
114
+ */
115
+ export declare function store<T>(config?: StoreConfig): Store<T>;
@@ -0,0 +1,200 @@
1
+ // What a file declares with `store()`: a place to keep data from one
2
+ // restart to the next, per server by default, typed by what it holds,
3
+ // with an expiration if wanted. Named by its export, like a button:
4
+ // `export const warnings = store<Warning[]>()` is the store `warnings`.
5
+ // What `store()` returns works once the loader bound it to that name and
6
+ // the bot runs; the data itself lives in a backend (`backend.ts`).
7
+ import { parseDuration } from '../util/duration.js';
8
+ import { storeBackend, } from './backend.js';
9
+ import { currentGuildId } from './scope.js';
10
+ const STATE = Symbol.for('chapterjs.store');
11
+ /** The state of a declared store, if the value is one. */
12
+ export function storeStateOf(value) {
13
+ if (typeof value !== 'object' || value === null)
14
+ return undefined;
15
+ return value[STATE];
16
+ }
17
+ export const isStoreDeclaration = (value) => storeStateOf(value) !== undefined;
18
+ /** Binds a declared store to the name of its export. */
19
+ export function bindStore(declared, bound) {
20
+ const state = storeStateOf(declared);
21
+ if (!state)
22
+ throw new TypeError('Not a store.');
23
+ state.name = bound.name;
24
+ state.scope = bound.scope;
25
+ state.expires = bound.expires;
26
+ }
27
+ const parts = (key) => {
28
+ const list = typeof key === 'string' ? [key] : [...key];
29
+ if (list.length === 0) {
30
+ throw new TypeError('A key of a store is a text, or a list of texts with at least one.');
31
+ }
32
+ for (const part of list) {
33
+ if (typeof part !== 'string') {
34
+ throw new TypeError(`A key of a store is made of texts, got ${JSON.stringify(part) ?? typeof part}. Write an id as a text: member.id, not a number.`);
35
+ }
36
+ }
37
+ return list;
38
+ };
39
+ /**
40
+ * Refuses what would not survive a trip through JSON: a `Date` would come
41
+ * back as a text, a `Map` as `{}`, a class instance as a plain object.
42
+ */
43
+ export function checkJson(value, path = 'the value') {
44
+ if (value === undefined) {
45
+ throw new TypeError(`${path} is undefined: a store keeps JSON. To forget a value, use delete().`);
46
+ }
47
+ if (value === null)
48
+ return;
49
+ switch (typeof value) {
50
+ case 'string':
51
+ case 'boolean':
52
+ return;
53
+ case 'number':
54
+ if (!Number.isFinite(value)) {
55
+ throw new TypeError(`${path} is ${value}, which JSON can't hold: a store keeps JSON.`);
56
+ }
57
+ return;
58
+ case 'bigint':
59
+ case 'function':
60
+ case 'symbol':
61
+ throw new TypeError(`${path} is a ${typeof value}, which JSON can't hold: a store keeps JSON (texts, numbers, booleans, lists and plain objects).`);
62
+ }
63
+ if (Array.isArray(value)) {
64
+ value.forEach((item, index) => checkJson(item, `${path}[${index}]`));
65
+ return;
66
+ }
67
+ const proto = Object.getPrototypeOf(value);
68
+ if (proto !== null && proto !== Object.prototype) {
69
+ const name = value.constructor?.name ??
70
+ 'an object of a class';
71
+ throw new TypeError(`${path} is a ${name}, which JSON can't hold: a store keeps JSON (texts, numbers, booleans, lists and plain objects). Keep what you need of it: an id, a text, a number.`);
72
+ }
73
+ for (const [key, item] of Object.entries(value)) {
74
+ if (item !== undefined)
75
+ checkJson(item, `${path}.${key}`);
76
+ }
77
+ }
78
+ /** When a value set now expires, from what was asked. */
79
+ function expiresAt(options, fallback, now) {
80
+ if (!options || options.expires === undefined) {
81
+ return fallback === null ? null : now + fallback;
82
+ }
83
+ const { expires } = options;
84
+ if (expires === null)
85
+ return null;
86
+ if (expires instanceof Date) {
87
+ const ms = expires.getTime();
88
+ if (!Number.isFinite(ms)) {
89
+ throw new TypeError('"expires" is a Date that is not a valid date.');
90
+ }
91
+ return ms;
92
+ }
93
+ const ms = typeof expires === 'string' ? parseDuration(expires) : null;
94
+ if (ms === null || ms < 1000) {
95
+ throw new TypeError(`"expires" is a duration (a number and a unit: s, m, h or d, like '10m' or '1d', 1 s at least), a Date, or null to keep the value forever. Got ${JSON.stringify(expires)}.`);
96
+ }
97
+ return now + ms;
98
+ }
99
+ /** The store as its functions see it: the backend, the name, the scope prefix. */
100
+ function scoped(state, guildOf) {
101
+ const ready = () => {
102
+ if (!state.name) {
103
+ throw new Error('This store was not loaded by ChapterJS: a store is exported from a file of src/ (export const name = store()), and used once the bot started.');
104
+ }
105
+ const backend = storeBackend();
106
+ if (state.scope === 'global') {
107
+ return { backend, name: state.name, prefix: [] };
108
+ }
109
+ const guildId = guildOf();
110
+ if (!guildId) {
111
+ throw new Error(`${state.name} is a store per server, and this code does not run in one: use ${state.name}.in(guild) to say which server, or declare the store with scope: 'global'.`);
112
+ }
113
+ return { backend, name: state.name, prefix: [guildId] };
114
+ };
115
+ const full = (prefix, key) => [
116
+ ...prefix,
117
+ ...parts(key),
118
+ ];
119
+ const set = async (key, value, options, keep) => {
120
+ const { backend, name, prefix } = ready();
121
+ checkJson(value);
122
+ const now = Date.now();
123
+ const at = keep !== undefined && (!options || options.expires === undefined)
124
+ ? keep
125
+ : expiresAt(options, state.expires, now);
126
+ await backend.set(name, full(prefix, key), { value, expiresAt: at });
127
+ };
128
+ return {
129
+ async get(key) {
130
+ const { backend, name, prefix } = ready();
131
+ const entry = await backend.get(name, full(prefix, key));
132
+ return entry?.value;
133
+ },
134
+ set: (key, value, options) => set(key, value, options, undefined),
135
+ async update(key, change, options) {
136
+ const { backend, name, prefix } = ready();
137
+ const entry = await backend.get(name, full(prefix, key));
138
+ const next = change(entry?.value);
139
+ await set(key, next, options, entry ? entry.expiresAt : undefined);
140
+ return next;
141
+ },
142
+ async delete(key) {
143
+ const { backend, name, prefix } = ready();
144
+ return backend.delete(name, full(prefix, key));
145
+ },
146
+ async entries(prefixKey) {
147
+ const { backend, name, prefix } = ready();
148
+ const listed = await backend.entries(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
149
+ return listed.map(entry => ({
150
+ key: entry.key.slice(prefix.length),
151
+ value: entry.value,
152
+ expires: entry.expiresAt === null ? null : new Date(entry.expiresAt),
153
+ }));
154
+ },
155
+ async ttl(key) {
156
+ const { backend, name, prefix } = ready();
157
+ const entry = await backend.get(name, full(prefix, key));
158
+ if (!entry)
159
+ return undefined;
160
+ return entry.expiresAt === null
161
+ ? null
162
+ : Math.max(0, entry.expiresAt - Date.now());
163
+ },
164
+ };
165
+ }
166
+ /**
167
+ * Declares a store: data the bot keeps from one restart to the next,
168
+ * typed by what it holds. Export the result from any file of `src/`: the
169
+ * name of the export is the name of the store, and its file in `data/`.
170
+ * Per server unless `scope: 'global'` is given, with an expiration if
171
+ * `expires` is.
172
+ *
173
+ * ```ts
174
+ * import { store } from 'chapterjs';
175
+ *
176
+ * export const warnings = store<Warning[]>({ expires: '30d' });
177
+ *
178
+ * // In a command, in a server:
179
+ * await warnings.update(member.id, (list = []) => [...list, warning]);
180
+ * ```
181
+ */
182
+ export function store(config = {}) {
183
+ const state = { config, scope: 'guild', expires: null };
184
+ const guildIdOf = (guild) => {
185
+ const id = typeof guild === 'string' ? guild : guild?.id;
186
+ if (typeof id !== 'string' || id === '') {
187
+ throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
188
+ }
189
+ return id;
190
+ };
191
+ const declared = {
192
+ ...scoped(state, () => currentGuildId() ?? null),
193
+ in: guild => {
194
+ const id = guildIdOf(guild);
195
+ return scoped(state, () => id);
196
+ },
197
+ };
198
+ Object.defineProperty(declared, STATE, { value: state });
199
+ return declared;
200
+ }
@@ -1,6 +1,7 @@
1
1
  // Runs the tasks of the project when they are due. Behind an interface:
2
2
  // this one keeps timers in the process; a bot on several machines would
3
3
  // share one through another implementation, with no change to the files.
4
+ import { withGuild } from '../store/scope.js';
4
5
  import { describeSchedule, nextRun, scheduleKey } from './schedule.js';
5
6
  /** The longest a timer of Node can wait. */
6
7
  const MAX_DELAY = 2 ** 31 - 1;
@@ -98,7 +99,7 @@ export class TimerScheduler {
98
99
  return;
99
100
  }
100
101
  scheduled.running = true;
101
- new Promise(resolve => resolve(scheduled.entry.task.run(Object.freeze({ ...context, now }))))
102
+ new Promise(resolve => resolve(withGuild(null, () => scheduled.entry.task.run(Object.freeze({ ...context, now })))))
102
103
  .catch((error) => this.#options.onError(file, error))
103
104
  .finally(() => {
104
105
  scheduled.running = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapterjs",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "The Discord framework",
5
5
  "type": "module",
6
6
  "license": "MIT",