chapterjs 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +5 -3
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +17 -4
  5. package/dist/cli/dev.js +37 -6
  6. package/dist/cli/env.d.ts +5 -0
  7. package/dist/cli/env.js +39 -3
  8. package/dist/cli/project.d.ts +61 -9
  9. package/dist/cli/project.js +250 -44
  10. package/dist/cli/redis.d.ts +23 -0
  11. package/dist/cli/redis.js +69 -0
  12. package/dist/cli/start.js +54 -18
  13. package/dist/cli/test.js +112 -18
  14. package/dist/commands/command.d.ts +140 -5
  15. package/dist/commands/command.js +17 -2
  16. package/dist/commands/declaration.d.ts +17 -4
  17. package/dist/commands/declaration.js +128 -2
  18. package/dist/commands/router.js +78 -18
  19. package/dist/commands/tree.d.ts +25 -4
  20. package/dist/commands/tree.js +95 -26
  21. package/dist/commands/twins.d.ts +8 -0
  22. package/dist/commands/twins.js +24 -6
  23. package/dist/components/button.d.ts +8 -3
  24. package/dist/components/declaration.d.ts +2 -2
  25. package/dist/components/declaration.js +16 -12
  26. package/dist/components/render.js +5 -0
  27. package/dist/components/router.js +41 -0
  28. package/dist/components/select.d.ts +12 -5
  29. package/dist/core/bot.d.ts +7 -0
  30. package/dist/core/bot.js +44 -0
  31. package/dist/discord/api.d.ts +2 -0
  32. package/dist/discord/api.js +2 -0
  33. package/dist/discord/permissions.d.ts +4 -4
  34. package/dist/discord/permissions.js +13 -0
  35. package/dist/events/registry.d.ts +265 -243
  36. package/dist/events/registry.js +131 -100
  37. package/dist/index.d.ts +7 -4
  38. package/dist/index.js +2 -1
  39. package/dist/interactions/dispatch.js +4 -2
  40. package/dist/loader/loader.d.ts +16 -0
  41. package/dist/loader/loader.js +52 -7
  42. package/dist/loader/usage.d.ts +15 -0
  43. package/dist/loader/usage.js +40 -0
  44. package/dist/loader/watch.d.ts +5 -3
  45. package/dist/loader/watch.js +88 -17
  46. package/dist/messages/commands.js +3 -0
  47. package/dist/messages/messages.d.ts +1 -0
  48. package/dist/messages/translate.js +5 -1
  49. package/dist/presence/declaration.d.ts +49 -2
  50. package/dist/presence/declaration.js +122 -39
  51. package/dist/presence/presence.d.ts +65 -6
  52. package/dist/presence/presence.js +11 -0
  53. package/dist/presence/rotation.d.ts +32 -0
  54. package/dist/presence/rotation.js +167 -0
  55. package/dist/ranking/declaration.d.ts +12 -0
  56. package/dist/ranking/declaration.js +48 -0
  57. package/dist/ranking/ranking.d.ts +102 -0
  58. package/dist/ranking/ranking.js +129 -0
  59. package/dist/redis/client.d.ts +36 -0
  60. package/dist/redis/client.js +252 -0
  61. package/dist/redis/protocol.d.ts +22 -0
  62. package/dist/redis/protocol.js +99 -0
  63. package/dist/redis/url.d.ts +24 -0
  64. package/dist/redis/url.js +50 -0
  65. package/dist/rest/errors.d.ts +0 -1
  66. package/dist/rest/errors.js +15 -1
  67. package/dist/rest/rest.js +10 -1
  68. package/dist/store/backend.d.ts +49 -4
  69. package/dist/store/backend.js +66 -11
  70. package/dist/store/declaration.d.ts +3 -0
  71. package/dist/store/declaration.js +8 -3
  72. package/dist/store/file-backend.d.ts +3 -2
  73. package/dist/store/file-backend.js +47 -1
  74. package/dist/store/ipc-backend.d.ts +24 -1
  75. package/dist/store/ipc-backend.js +49 -0
  76. package/dist/store/memory-backend.d.ts +4 -2
  77. package/dist/store/memory-backend.js +27 -1
  78. package/dist/store/ranking-backend.d.ts +45 -0
  79. package/dist/store/ranking-backend.js +82 -0
  80. package/dist/store/redis-backend.d.ts +38 -0
  81. package/dist/store/redis-backend.js +245 -0
  82. package/dist/store/redis-ranking.d.ts +15 -0
  83. package/dist/store/redis-ranking.js +69 -0
  84. package/dist/store/scope.d.ts +17 -1
  85. package/dist/store/scope.js +32 -3
  86. package/dist/store/store.d.ts +39 -5
  87. package/dist/store/store.js +79 -32
  88. package/dist/structures/channel.d.ts +13 -0
  89. package/dist/structures/channel.js +24 -0
  90. package/dist/structures/context.d.ts +3 -0
  91. package/dist/structures/entities.js +1 -0
  92. package/dist/structures/interaction.d.ts +6 -0
  93. package/dist/structures/interaction.js +20 -0
  94. package/dist/structures/invite.d.ts +6 -0
  95. package/dist/structures/invite.js +11 -0
  96. package/dist/structures/message.d.ts +17 -0
  97. package/dist/structures/message.js +33 -0
  98. package/dist/structures/wait.d.ts +105 -0
  99. package/dist/structures/wait.js +85 -0
  100. package/dist/testing/harness.d.ts +48 -8
  101. package/dist/testing/harness.js +156 -36
  102. package/dist/testing/index.d.ts +1 -1
  103. package/dist/testing/smoke.js +112 -1
  104. package/dist/testing/world.js +51 -2
  105. package/dist/util/call-site.d.ts +4 -0
  106. package/dist/util/call-site.js +22 -0
  107. package/dist/voice/connection.d.ts +2 -0
  108. package/dist/voice/connection.js +15 -1
  109. package/dist/voice/usage.d.ts +3 -2
  110. package/dist/voice/usage.js +7 -19
  111. package/dist/waiting/usage.d.ts +5 -0
  112. package/dist/waiting/usage.js +19 -0
  113. package/dist/waiting/waiters.d.ts +48 -0
  114. package/dist/waiting/waiters.js +107 -0
  115. package/package.json +1 -1
@@ -1,7 +1,36 @@
1
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.
2
+ // store by default (`file-backend.ts`), Redis when a store asks for it
3
+ // (`redis-backend.ts`), the first process of a cluster for the others
4
+ // (`ipc-backend.ts`), and later anything else. A backend knows nothing of
5
+ // servers: the scope of a store is part of the key it gets. Each kind of
6
+ // storage a store may name has one backend per process.
7
+ export const STORAGES = ['file', 'redis'];
8
+ /**
9
+ * Runs what is given for one key one after the other, in this process:
10
+ * updates of one key here never compete with each other, only with the
11
+ * other processes. Keys that are not busy cost nothing.
12
+ */
13
+ export class KeyQueue {
14
+ #tails = new Map();
15
+ // Chained with await, not .then: an error keeps the line of the file
16
+ // that asked (see Interaction).
17
+ async run(id, fn) {
18
+ const before = this.#tails.get(id);
19
+ let done;
20
+ const tail = new Promise(resolve => (done = resolve));
21
+ this.#tails.set(id, tail);
22
+ try {
23
+ if (before)
24
+ await before;
25
+ return await fn();
26
+ }
27
+ finally {
28
+ done();
29
+ if (this.#tails.get(id) === tail)
30
+ this.#tails.delete(id);
31
+ }
32
+ }
33
+ }
5
34
  /** The map key of a key: unambiguous whatever the parts hold. */
6
35
  export const encodeKey = (key) => JSON.stringify(key);
7
36
  export const decodeKey = (encoded) => JSON.parse(encoded);
@@ -13,15 +42,41 @@ export function startsWith(key, prefix) {
13
42
  }
14
43
  /** Whether an entry is still good at `now`. */
15
44
  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;
45
+ const current = new Map();
46
+ /**
47
+ * Gives every store of one storage its backend: called by the CLI, once
48
+ * per process and per storage the project has a backend for; `null`
49
+ * takes it away.
50
+ */
51
+ export function setStoreBackend(storage, backend) {
52
+ if (backend)
53
+ current.set(storage, backend);
54
+ else
55
+ current.delete(storage);
56
+ }
57
+ /** The backends of the process, by storage. */
58
+ export const storeBackends = () => current;
59
+ /** A store of a storage the process has no backend for: nothing to fix in the file. */
60
+ export class NowhereToKeepError extends TypeError {
61
+ name = 'NowhereToKeepError';
62
+ }
63
+ /** Why a store of `storage` can't be used: the process has no backend for it. */
64
+ export function nowhereToKeep(storage, name, what = 'store') {
65
+ if (storage === 'redis') {
66
+ return `${name} is kept in Redis (storage: 'redis'), but REDIS_URL is not set: add it to .env (like redis://localhost:6379) and start again, or remove storage: 'redis' to keep its ${what === 'store' ? 'data' : 'scores'} in data/.`;
67
+ }
68
+ return `Nothing keeps the data of ${name} (storage: '${storage}').`;
20
69
  }
21
- /** The backend of the process, or an error that says when stores work. */
22
- export function storeBackend() {
23
- if (!current) {
70
+ /**
71
+ * The backend of a storage, or an error that says when stores work: for
72
+ * Redis, that the project gave no address.
73
+ */
74
+ export function storeBackend(storage, name = 'This store') {
75
+ const backend = current.get(storage);
76
+ if (backend)
77
+ return backend;
78
+ if (current.size === 0) {
24
79
  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
80
  }
26
- return current;
81
+ throw new NowhereToKeepError(nowhereToKeep(storage, name));
27
82
  }
@@ -1,10 +1,13 @@
1
1
  import type { Declaration } from '../loader/loader.js';
2
+ import { type StoreStorage } from './backend.js';
2
3
  import { type StoreScope } from './store.js';
3
4
  /** A store, checked: what the project keeps of it. */
4
5
  export interface LoadedStore {
5
6
  /** The name of the store: the name of the export that declares it. */
6
7
  readonly name: string;
7
8
  readonly scope: StoreScope;
9
+ /** Where its data is: a file of `data/`, or Redis. */
10
+ readonly storage: StoreStorage;
8
11
  /** The default expiration, in ms, or `null` for never. */
9
12
  readonly expires: number | null;
10
13
  }
@@ -2,12 +2,13 @@
2
2
  // scope or expiration is explained before anything is kept; the store is
3
3
  // then bound to the name of its export, which names its data.
4
4
  import { DURATION_EXAMPLE, parseDuration } from '../util/duration.js';
5
+ import { STORAGES } from './backend.js';
5
6
  import { bindStore, isStoreDeclaration, storeStateOf, } from './store.js';
6
7
  const fail = (message) => {
7
8
  throw new TypeError(message);
8
9
  };
9
10
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
10
- const KEYS = ['scope', 'expires'];
11
+ const KEYS = ['scope', 'storage', 'expires'];
11
12
  const SCOPES = ['guild', 'global'];
12
13
  /** What a name can be: a file in `data/` is called after it. */
13
14
  const NAME = /^[\w.-]+$/;
@@ -37,6 +38,10 @@ export const storeDeclaration = {
37
38
  if (!SCOPES.includes(scope)) {
38
39
  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
  }
41
+ const storage = (config.storage ?? 'file');
42
+ if (!STORAGES.includes(storage)) {
43
+ fail(`"storage" is 'file' (a file in data/, the default) or 'redis' (the Redis of REDIS_URL in .env), got ${JSON.stringify(config.storage)}.`);
44
+ }
40
45
  let expires = null;
41
46
  if (config.expires !== undefined) {
42
47
  const ms = typeof config.expires === 'string'
@@ -47,7 +52,7 @@ export const storeDeclaration = {
47
52
  }
48
53
  expires = ms;
49
54
  }
50
- bindStore(value, { name, scope, expires });
51
- return Object.freeze({ name, scope, expires });
55
+ bindStore(value, { name, scope, storage, expires });
56
+ return Object.freeze({ name, scope, storage, expires });
52
57
  },
53
58
  };
@@ -1,4 +1,4 @@
1
- import { type StoreBackend, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
1
+ import { type StoreBackend, type StoreChange, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
2
2
  /** The folder of a project where the stores keep their files. */
3
3
  export declare const DATA_FOLDER = "data";
4
4
  export interface FileStoreBackendOptions {
@@ -8,7 +8,6 @@ export interface FileStoreBackendOptions {
8
8
  sweepEvery?: number;
9
9
  now?: () => number;
10
10
  }
11
- /** The data of every store, one file each, loaded on first use. */
12
11
  export declare class FileStoreBackend implements StoreBackend {
13
12
  #private;
14
13
  constructor(dir: string, options?: FileStoreBackendOptions);
@@ -18,5 +17,7 @@ export declare class FileStoreBackend implements StoreBackend {
18
17
  set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
19
18
  delete(store: string, key: StoreKeyParts): Promise<boolean>;
20
19
  entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
20
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
21
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
21
22
  close(): Promise<void>;
22
23
  }
@@ -12,6 +12,21 @@ export const DATA_FOLDER = 'data';
12
12
  /** A name a file can be called: what an export is called. */
13
13
  const FILE_NAME = /^[\w.-]+$/;
14
14
  /** The data of every store, one file each, loaded on first use. */
15
+ /**
16
+ * One entry as this backend writes it: `[key parts, value, expiresAt]`. An
17
+ * entry of another shape makes the whole file one it did not write, rather
18
+ * than an entry dropped without a word the next time the file is written.
19
+ */
20
+ function isEntry(entry) {
21
+ if (!Array.isArray(entry) || entry.length !== 3)
22
+ return false;
23
+ const [key, value, expiresAt] = entry;
24
+ return (Array.isArray(key) &&
25
+ key.length > 0 &&
26
+ key.every(part => typeof part === 'string') &&
27
+ value !== undefined &&
28
+ (expiresAt === null || typeof expiresAt === 'number'));
29
+ }
15
30
  export class FileStoreBackend {
16
31
  #dir;
17
32
  #stores = new Map();
@@ -55,7 +70,9 @@ export class FileStoreBackend {
55
70
  let parsed;
56
71
  try {
57
72
  parsed = JSON.parse(text);
58
- if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) {
73
+ if (parsed?.version !== 1 ||
74
+ !Array.isArray(parsed.entries) ||
75
+ !parsed.entries.every(isEntry)) {
59
76
  throw new Error('not a store file');
60
77
  }
61
78
  }
@@ -164,6 +181,35 @@ export class FileStoreBackend {
164
181
  }
165
182
  return listed;
166
183
  }
184
+ // Read and written without an await in between: nothing else runs there.
185
+ async update(store, key, change) {
186
+ const entries = this.#load(store);
187
+ const encoded = encodeKey(key);
188
+ let current = entries.get(encoded);
189
+ if (current && !alive(current, this.#now()))
190
+ current = undefined;
191
+ const next = change(current);
192
+ if (next === undefined)
193
+ return current;
194
+ entries.set(encoded, next);
195
+ this.#changed(store);
196
+ return next;
197
+ }
198
+ async clear(store, prefix) {
199
+ const entries = this.#load(store);
200
+ const now = this.#now();
201
+ let count = 0;
202
+ for (const [encoded, entry] of entries) {
203
+ if (!startsWith(decodeKey(encoded), prefix))
204
+ continue;
205
+ entries.delete(encoded);
206
+ if (alive(entry, now))
207
+ count++;
208
+ }
209
+ if (count > 0)
210
+ this.#changed(store);
211
+ return count;
212
+ }
167
213
  async close() {
168
214
  clearInterval(this.#sweeper);
169
215
  process.off('exit', this.#onExit);
@@ -1,4 +1,6 @@
1
- import type { StoreBackend, StoreEntry, StoreKeyParts, StoreListedEntry } from './backend.js';
1
+ import { type StoreBackend, type StoreChange, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
2
+ /** How many times an update is tried before giving up: the value changes faster than it can be read. */
3
+ export declare const UPDATE_ATTEMPTS = 50;
2
4
  /** What a process asks the first one. */
3
5
  export type StoreRequest = {
4
6
  type: 'store';
@@ -20,6 +22,18 @@ export type StoreRequest = {
20
22
  op: 'entries';
21
23
  store: string;
22
24
  prefix: StoreKeyParts;
25
+ } | {
26
+ op: 'clear';
27
+ store: string;
28
+ prefix: StoreKeyParts;
29
+ }
30
+ /** Keeps `entry` only if the entry is still `expected` (`null`: none). */
31
+ | {
32
+ op: 'swap';
33
+ store: string;
34
+ key: StoreKeyParts;
35
+ expected: StoreEntry | null;
36
+ entry: StoreEntry;
23
37
  });
24
38
  /** What the first process answers. */
25
39
  export interface StoreReply {
@@ -35,6 +49,8 @@ export interface Channel<Out, In> {
35
49
  }
36
50
  export declare const isStoreRequest: (message: unknown) => message is StoreRequest;
37
51
  export declare const isStoreReply: (message: unknown) => message is StoreReply;
52
+ /** What an update says when it never got through. */
53
+ export declare const tooBusy: (store: string) => string;
38
54
  /** The backend of a process that is not the first: every call is a message. */
39
55
  export declare class IpcStoreBackend implements StoreBackend {
40
56
  #private;
@@ -43,6 +59,13 @@ export declare class IpcStoreBackend implements StoreBackend {
43
59
  set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
44
60
  delete(store: string, key: StoreKeyParts): Promise<boolean>;
45
61
  entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
62
+ /**
63
+ * A change can't cross the channel: it runs here, on what the first
64
+ * process gave, and the first process keeps the result only if nobody
65
+ * wrote in between; otherwise it runs again on the new value.
66
+ */
67
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
68
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
46
69
  close(): Promise<void>;
47
70
  }
48
71
  /**
@@ -2,6 +2,11 @@
2
2
  // process keeps it (with the file backend) and the others ask it over
3
3
  // IPC, the channel they already share with it. Each process then sees the
4
4
  // same thing, whichever shard a command was used on.
5
+ import { encodeKey, KeyQueue, } from './backend.js';
6
+ /** How many times an update is tried before giving up: the value changes faster than it can be read. */
7
+ export const UPDATE_ATTEMPTS = 50;
8
+ /** Two entries are the same when their JSON is: what crossed the channel kept its order. */
9
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b);
5
10
  export const isStoreRequest = (message) => typeof message === 'object' &&
6
11
  message !== null &&
7
12
  message.type === 'store' &&
@@ -10,11 +15,14 @@ export const isStoreReply = (message) => typeof message === 'object' &&
10
15
  message !== null &&
11
16
  message.type === 'store' &&
12
17
  !('op' in message);
18
+ /** What an update says when it never got through. */
19
+ export const tooBusy = (store) => `The value of ${store} changed ${UPDATE_ATTEMPTS} times while update() was trying to change it: it is written too often by too many at once. Keep what changes this often under several keys.`;
13
20
  /** The backend of a process that is not the first: every call is a message. */
14
21
  export class IpcStoreBackend {
15
22
  #channel;
16
23
  #waiting = new Map();
17
24
  #lastId = 0;
25
+ #updates = new KeyQueue();
18
26
  constructor(channel) {
19
27
  this.#channel = channel;
20
28
  channel.on(message => {
@@ -52,6 +60,35 @@ export class IpcStoreBackend {
52
60
  entries(store, prefix) {
53
61
  return this.#ask({ op: 'entries', store, prefix });
54
62
  }
63
+ /**
64
+ * A change can't cross the channel: it runs here, on what the first
65
+ * process gave, and the first process keeps the result only if nobody
66
+ * wrote in between; otherwise it runs again on the new value.
67
+ */
68
+ update(store, key, change) {
69
+ return this.#updates.run(`${store}\u0000${encodeKey(key)}`, () => this.#update(store, key, change));
70
+ }
71
+ async #update(store, key, change) {
72
+ for (let attempt = 0; attempt < UPDATE_ATTEMPTS; attempt++) {
73
+ const current = await this.get(store, key);
74
+ const next = change(current);
75
+ if (next === undefined)
76
+ return current;
77
+ const kept = await this.#ask({
78
+ op: 'swap',
79
+ store,
80
+ key,
81
+ expected: current ?? null,
82
+ entry: next,
83
+ });
84
+ if (kept)
85
+ return next;
86
+ }
87
+ throw new Error(tooBusy(store));
88
+ }
89
+ clear(store, prefix) {
90
+ return this.#ask({ op: 'clear', store, prefix });
91
+ }
55
92
  async close() {
56
93
  // Nothing is kept here: the first process writes.
57
94
  }
@@ -76,6 +113,18 @@ export function serveStores(backend, reply) {
76
113
  return backend.delete(message.store, message.key);
77
114
  case 'entries':
78
115
  return backend.entries(message.store, message.prefix);
116
+ case 'clear':
117
+ return backend.clear(message.store, message.prefix);
118
+ case 'swap': {
119
+ let kept = false;
120
+ const { expected, entry } = message;
121
+ return backend
122
+ .update(message.store, message.key, current => {
123
+ kept = same(current, expected);
124
+ return kept ? entry : undefined;
125
+ })
126
+ .then(() => kept);
127
+ }
79
128
  }
80
129
  };
81
130
  run().then(result => reply({ type: 'store', id, result }), (error) => reply({
@@ -1,4 +1,4 @@
1
- import { type StoreBackend, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
1
+ import { type StoreBackend, type StoreChange, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
2
2
  /** The data of every store, in memory, forgotten with the process. */
3
3
  export declare class MemoryStoreBackend implements StoreBackend {
4
4
  #private;
@@ -9,7 +9,9 @@ export declare class MemoryStoreBackend implements StoreBackend {
9
9
  set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
10
10
  delete(store: string, key: StoreKeyParts): Promise<boolean>;
11
11
  entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
12
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
13
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
12
14
  /** Forgets everything: what a test does with its stores stays in it. */
13
- clear(): void;
15
+ reset(): void;
14
16
  close(): Promise<void>;
15
17
  }
@@ -48,8 +48,34 @@ export class MemoryStoreBackend {
48
48
  }
49
49
  return result;
50
50
  }
51
+ // Read and written without an await in between: nothing else runs there.
52
+ async update(store, key, change) {
53
+ const entries = this.#store(store);
54
+ const encoded = encodeKey(key);
55
+ let current = entries.get(encoded);
56
+ if (current && !alive(current, this.#now()))
57
+ current = undefined;
58
+ const next = change(current);
59
+ if (next === undefined)
60
+ return current;
61
+ entries.set(encoded, next);
62
+ return next;
63
+ }
64
+ async clear(store, prefix) {
65
+ const entries = this.#store(store);
66
+ const now = this.#now();
67
+ let count = 0;
68
+ for (const [encoded, entry] of entries) {
69
+ if (!startsWith(decodeKey(encoded), prefix))
70
+ continue;
71
+ entries.delete(encoded);
72
+ if (alive(entry, now))
73
+ count++;
74
+ }
75
+ return count;
76
+ }
51
77
  /** Forgets everything: what a test does with its stores stays in it. */
52
- clear() {
78
+ reset() {
53
79
  this.#stores.clear();
54
80
  }
55
81
  async close() { }
@@ -0,0 +1,45 @@
1
+ import type { StoreBackend, StoreKeyParts } from './backend.js';
2
+ /** Which end of a ranking comes first. */
3
+ export type RankingOrder = 'highest' | 'lowest';
4
+ /** A member of a ranking, and its score. */
5
+ export interface RankedScore {
6
+ id: string;
7
+ score: number;
8
+ }
9
+ /** What keeps the scores of every ranking. `scope` is the server, or nothing. */
10
+ export interface RankingBackend {
11
+ /** Adds `by` to the score of `id` (from 0), in one go; the new score. */
12
+ add(name: string, scope: StoreKeyParts, id: string, by: number): Promise<number>;
13
+ set(name: string, scope: StoreKeyParts, id: string, score: number): Promise<void>;
14
+ score(name: string, scope: StoreKeyParts, id: string): Promise<number | undefined>;
15
+ /** Where `id` is, from 0, in `order`; `undefined` when it has no score. */
16
+ rank(name: string, scope: StoreKeyParts, id: string, order: RankingOrder): Promise<number | undefined>;
17
+ /** `count` members from position `start` (from 0), in `order`. */
18
+ range(name: string, scope: StoreKeyParts, order: RankingOrder, start: number, count: number): Promise<RankedScore[]>;
19
+ remove(name: string, scope: StoreKeyParts, id: string): Promise<boolean>;
20
+ size(name: string, scope: StoreKeyParts): Promise<number>;
21
+ /** Forgets every score; how many there were. */
22
+ clear(name: string, scope: StoreKeyParts): Promise<number>;
23
+ }
24
+ /**
25
+ * The order of a ranking, ties included, as Redis sorts a sorted set: by
26
+ * score, then by the bytes of the id (ZRANGE), both reversed for the
27
+ * highest first (ZREVRANGE). Every backend ranks the same way.
28
+ * https://redis.io/docs/latest/commands/zrange/
29
+ */
30
+ export declare function compareRanked(order: RankingOrder): (a: RankedScore, b: RankedScore) => number;
31
+ /** The rankings of a backend of stores: one entry per member. */
32
+ export declare class StoreRankingBackend implements RankingBackend {
33
+ #private;
34
+ constructor(store: StoreBackend);
35
+ add(name: string, scope: StoreKeyParts, id: string, by: number): Promise<number>;
36
+ set(name: string, scope: StoreKeyParts, id: string, score: number): Promise<void>;
37
+ score(name: string, scope: StoreKeyParts, id: string): Promise<number | undefined>;
38
+ rank(name: string, scope: StoreKeyParts, id: string, order: RankingOrder): Promise<number | undefined>;
39
+ range(name: string, scope: StoreKeyParts, order: RankingOrder, start: number, count: number): Promise<RankedScore[]>;
40
+ remove(name: string, scope: StoreKeyParts, id: string): Promise<boolean>;
41
+ size(name: string, scope: StoreKeyParts): Promise<number>;
42
+ clear(name: string, scope: StoreKeyParts): Promise<number>;
43
+ }
44
+ /** The rankings of a backend of stores: its own when it has one. */
45
+ export declare function rankingsOf(store: StoreBackend): RankingBackend;
@@ -0,0 +1,82 @@
1
+ // Where the scores of a ranking are kept, behind an interface like the data
2
+ // of a store. Any backend of stores keeps rankings with
3
+ // `StoreRankingBackend`, one entry per member (`[...scope, id]` → score):
4
+ // a file, the first process of a cluster, memory, with nothing to write.
5
+ // A backend with a native way to rank gives its own with `rankings()`
6
+ // (Redis: sorted sets), and is then used instead.
7
+ /**
8
+ * The order of a ranking, ties included, as Redis sorts a sorted set: by
9
+ * score, then by the bytes of the id (ZRANGE), both reversed for the
10
+ * highest first (ZREVRANGE). Every backend ranks the same way.
11
+ * https://redis.io/docs/latest/commands/zrange/
12
+ */
13
+ export function compareRanked(order) {
14
+ const sign = order === 'highest' ? -1 : 1;
15
+ return (a, b) => a.score !== b.score
16
+ ? sign * (a.score - b.score)
17
+ : sign * Buffer.compare(Buffer.from(a.id), Buffer.from(b.id));
18
+ }
19
+ /** The rankings of a backend of stores: one entry per member. */
20
+ export class StoreRankingBackend {
21
+ #store;
22
+ constructor(store) {
23
+ this.#store = store;
24
+ }
25
+ async #all(name, scope, order) {
26
+ const entries = await this.#store.entries(name, scope);
27
+ return entries
28
+ .filter(entry => entry.key.length === scope.length + 1 &&
29
+ typeof entry.value === 'number')
30
+ .map(entry => ({
31
+ id: entry.key[scope.length],
32
+ score: entry.value,
33
+ }))
34
+ .sort(compareRanked(order));
35
+ }
36
+ async add(name, scope, id, by) {
37
+ const kept = await this.#store.update(name, [...scope, id], current => ({
38
+ value: (typeof current?.value === 'number' ? current.value : 0) + by,
39
+ expiresAt: null,
40
+ }));
41
+ return kept.value;
42
+ }
43
+ async set(name, scope, id, score) {
44
+ await this.#store.set(name, [...scope, id], {
45
+ value: score,
46
+ expiresAt: null,
47
+ });
48
+ }
49
+ async score(name, scope, id) {
50
+ const entry = await this.#store.get(name, [...scope, id]);
51
+ return typeof entry?.value === 'number' ? entry.value : undefined;
52
+ }
53
+ async rank(name, scope, id, order) {
54
+ const index = (await this.#all(name, scope, order)).findIndex(ranked => ranked.id === id);
55
+ return index === -1 ? undefined : index;
56
+ }
57
+ async range(name, scope, order, start, count) {
58
+ return (await this.#all(name, scope, order)).slice(start, start + count);
59
+ }
60
+ remove(name, scope, id) {
61
+ return this.#store.delete(name, [...scope, id]);
62
+ }
63
+ async size(name, scope) {
64
+ return (await this.#all(name, scope, 'highest')).length;
65
+ }
66
+ clear(name, scope) {
67
+ return this.#store.clear(name, scope);
68
+ }
69
+ }
70
+ const generic = new WeakMap();
71
+ /** The rankings of a backend of stores: its own when it has one. */
72
+ export function rankingsOf(store) {
73
+ const own = store.rankings?.();
74
+ if (own)
75
+ return own;
76
+ let found = generic.get(store);
77
+ if (!found) {
78
+ found = new StoreRankingBackend(store);
79
+ generic.set(store, found);
80
+ }
81
+ return found;
82
+ }
@@ -0,0 +1,38 @@
1
+ import { RedisClient } from '../redis/client.js';
2
+ import { RedisRankingBackend } from './redis-ranking.js';
3
+ import { type StoreBackend, type StoreChange, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
4
+ /** The prefix of every key written by `chapterjs dev`. */
5
+ export declare const DEV_PREFIX = "dev:";
6
+ export interface RedisStoreBackendOptions {
7
+ /** Put in front of every key: `dev:` under `chapterjs dev`, nothing in production. */
8
+ prefix?: string;
9
+ /** How many keys SCAN is asked for at a time. */
10
+ scanCount?: number;
11
+ /** How many connections updates may use at once, besides the main one. */
12
+ transactions?: number;
13
+ /** A new connection to the same Redis, for an update. */
14
+ connect?: () => RedisClient;
15
+ now?: () => number;
16
+ }
17
+ /** A part of a key, safe between two `:` and in a MATCH pattern. */
18
+ export declare const escapePart: (part: string) => string;
19
+ /** The parts back from a key, after the prefix and the name of the store. */
20
+ export declare const unescapePart: (text: string) => string;
21
+ /** The data of every store with `storage: 'redis'`, in one Redis. */
22
+ export declare class RedisStoreBackend implements StoreBackend {
23
+ #private;
24
+ constructor(client: RedisClient, options?: RedisStoreBackendOptions);
25
+ /** Rankings as sorted sets: the key of a ranking is the key of its scope. */
26
+ rankings(): RedisRankingBackend;
27
+ /** The client, for the preflight check of the CLI. */
28
+ get client(): RedisClient;
29
+ /** The Redis key of a store and parts. */
30
+ key(store: string, parts: StoreKeyParts): string;
31
+ get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
32
+ set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
33
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
34
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
35
+ delete(store: string, key: StoreKeyParts): Promise<boolean>;
36
+ entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
37
+ close(): Promise<void>;
38
+ }