chapterjs 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/build.js +14 -1
  3. package/dist/cli/dev.js +29 -1
  4. package/dist/cli/env.d.ts +5 -0
  5. package/dist/cli/env.js +39 -3
  6. package/dist/cli/project.d.ts +30 -7
  7. package/dist/cli/project.js +85 -11
  8. package/dist/cli/redis.d.ts +23 -0
  9. package/dist/cli/redis.js +57 -0
  10. package/dist/cli/start.js +43 -17
  11. package/dist/cli/test.js +4 -2
  12. package/dist/events/registry.d.ts +252 -239
  13. package/dist/events/registry.js +125 -100
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.js +1 -0
  16. package/dist/ranking/declaration.d.ts +12 -0
  17. package/dist/ranking/declaration.js +48 -0
  18. package/dist/ranking/ranking.d.ts +102 -0
  19. package/dist/ranking/ranking.js +129 -0
  20. package/dist/redis/client.d.ts +36 -0
  21. package/dist/redis/client.js +252 -0
  22. package/dist/redis/protocol.d.ts +22 -0
  23. package/dist/redis/protocol.js +99 -0
  24. package/dist/redis/url.d.ts +24 -0
  25. package/dist/redis/url.js +50 -0
  26. package/dist/store/backend.d.ts +49 -4
  27. package/dist/store/backend.js +66 -11
  28. package/dist/store/declaration.d.ts +3 -0
  29. package/dist/store/declaration.js +8 -3
  30. package/dist/store/file-backend.d.ts +3 -1
  31. package/dist/store/file-backend.js +29 -0
  32. package/dist/store/ipc-backend.d.ts +24 -1
  33. package/dist/store/ipc-backend.js +49 -0
  34. package/dist/store/memory-backend.d.ts +4 -2
  35. package/dist/store/memory-backend.js +27 -1
  36. package/dist/store/ranking-backend.d.ts +45 -0
  37. package/dist/store/ranking-backend.js +82 -0
  38. package/dist/store/redis-backend.d.ts +38 -0
  39. package/dist/store/redis-backend.js +245 -0
  40. package/dist/store/redis-ranking.d.ts +15 -0
  41. package/dist/store/redis-ranking.js +69 -0
  42. package/dist/store/scope.d.ts +11 -0
  43. package/dist/store/scope.js +23 -0
  44. package/dist/store/store.d.ts +39 -5
  45. package/dist/store/store.js +79 -32
  46. package/dist/structures/invite.d.ts +6 -0
  47. package/dist/structures/invite.js +11 -0
  48. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ // What `REDIS_URL` (and `REDIS_PASSWORD`) say: where Redis is, how to
2
+ // reach it, and as what. Read once from the environment by the CLI and
3
+ // given to the client; a wrong value is explained in the words of `.env`.
4
+ // https://redis.io/docs/latest/develop/clients/nodejs/connect/ (the
5
+ // `redis://` and `rediss://` forms every client reads)
6
+ export const REDIS_URL_HELP = 'Write it like redis://localhost:6379 (rediss:// for a TLS connection, /1 at the end for a database other than 0), with the user and password when there are some: redis://user:password@host:6379.';
7
+ /** `host:port`, and the database when it is not the first: what messages show. Never the password. */
8
+ export const describeRedis = (address) => `${address.host}:${address.port}${address.db === 0 ? '' : `/${address.db}`}`;
9
+ /**
10
+ * Reads a `REDIS_URL`, with the `REDIS_PASSWORD` set beside it winning over
11
+ * the one of the URL. Answers what is wrong when it is not a Redis address.
12
+ */
13
+ export function parseRedisUrl(text, password) {
14
+ let url;
15
+ try {
16
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text))
17
+ throw new Error('no scheme');
18
+ url = new URL(text);
19
+ }
20
+ catch {
21
+ return { problem: `is not an address. ${REDIS_URL_HELP}` };
22
+ }
23
+ if (url.protocol !== 'redis:' && url.protocol !== 'rediss:') {
24
+ return {
25
+ problem: `starts with "${url.protocol}//", and a Redis address starts with redis:// or rediss://. ${REDIS_URL_HELP}`,
26
+ };
27
+ }
28
+ if (url.hostname === '') {
29
+ return { problem: `names no host. ${REDIS_URL_HELP}` };
30
+ }
31
+ const port = url.port === '' ? 6379 : Number(url.port);
32
+ const path = url.pathname.replace(/^\//, '');
33
+ if (path !== '' && !/^\d+$/.test(path)) {
34
+ return {
35
+ problem: `ends with "/${path}", and what follows the host is the number of the database. ${REDIS_URL_HELP}`,
36
+ };
37
+ }
38
+ const own = password !== '' ? password : decodeURIComponent(url.password);
39
+ return {
40
+ address: {
41
+ // A URL keeps IPv6 brackets around the host; a socket wants none.
42
+ host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
43
+ port,
44
+ tls: url.protocol === 'rediss:',
45
+ username: url.username === '' ? null : decodeURIComponent(url.username),
46
+ password: own === '' ? null : own,
47
+ db: path === '' ? 0 : Number(path),
48
+ },
49
+ };
50
+ }
@@ -1,3 +1,6 @@
1
+ /** Where a store keeps its data: what `storage` of its config names. */
2
+ export type StoreStorage = 'file' | 'redis';
3
+ export declare const STORAGES: readonly StoreStorage[];
1
4
  /** One value of a store, with when it expires. */
2
5
  export interface StoreEntry {
3
6
  value: unknown;
@@ -10,6 +13,7 @@ export type StoreKeyParts = readonly string[];
10
13
  export interface StoreListedEntry extends StoreEntry {
11
14
  key: StoreKeyParts;
12
15
  }
16
+ import type { RankingBackend } from './ranking-backend.js';
13
17
  /** What keeps the data of every store. Expired entries are never given. */
14
18
  export interface StoreBackend {
15
19
  get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
@@ -17,9 +21,35 @@ export interface StoreBackend {
17
21
  delete(store: string, key: StoreKeyParts): Promise<boolean>;
18
22
  /** The entries whose key starts with `prefix`, in no particular order. */
19
23
  entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
24
+ /**
25
+ * Reads the entry at `key` and keeps what `change` returns, with nothing
26
+ * written there by anyone in between: two updates never lose one
27
+ * another, in one process or in several. `change` may run more than
28
+ * once, when the entry changed meanwhile; `undefined` writes nothing.
29
+ * Resolves with the entry kept.
30
+ */
31
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
32
+ /** Forgets every entry whose key starts with `prefix`; how many there were. */
33
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
34
+ /**
35
+ * A native way to keep rankings, when the backend has one. Without it,
36
+ * rankings are kept as entries of the backend (`StoreRankingBackend`).
37
+ */
38
+ rankings?(): RankingBackend;
20
39
  /** Writes what is pending, if anything. */
21
40
  close(): Promise<void>;
22
41
  }
42
+ /** What `update()` makes of the current entry: the next one, or `undefined` for none. */
43
+ export type StoreChange = (current: StoreEntry | undefined) => StoreEntry | undefined;
44
+ /**
45
+ * Runs what is given for one key one after the other, in this process:
46
+ * updates of one key here never compete with each other, only with the
47
+ * other processes. Keys that are not busy cost nothing.
48
+ */
49
+ export declare class KeyQueue {
50
+ #private;
51
+ run<T>(id: string, fn: () => Promise<T>): Promise<T>;
52
+ }
23
53
  /** The map key of a key: unambiguous whatever the parts hold. */
24
54
  export declare const encodeKey: (key: StoreKeyParts) => string;
25
55
  export declare const decodeKey: (encoded: string) => string[];
@@ -27,7 +57,22 @@ export declare const decodeKey: (encoded: string) => string[];
27
57
  export declare function startsWith(key: StoreKeyParts, prefix: StoreKeyParts): boolean;
28
58
  /** Whether an entry is still good at `now`. */
29
59
  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;
60
+ /**
61
+ * Gives every store of one storage its backend: called by the CLI, once
62
+ * per process and per storage the project has a backend for; `null`
63
+ * takes it away.
64
+ */
65
+ export declare function setStoreBackend(storage: StoreStorage, backend: StoreBackend | null): void;
66
+ /** The backends of the process, by storage. */
67
+ export declare const storeBackends: () => ReadonlyMap<StoreStorage, StoreBackend>;
68
+ /** A store of a storage the process has no backend for: nothing to fix in the file. */
69
+ export declare class NowhereToKeepError extends TypeError {
70
+ name: string;
71
+ }
72
+ /** Why a store of `storage` can't be used: the process has no backend for it. */
73
+ export declare function nowhereToKeep(storage: StoreStorage, name: string, what?: string): string;
74
+ /**
75
+ * The backend of a storage, or an error that says when stores work: for
76
+ * Redis, that the project gave no address.
77
+ */
78
+ export declare function storeBackend(storage: StoreStorage, name?: string): StoreBackend;
@@ -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 {
@@ -18,5 +18,7 @@ export declare class FileStoreBackend implements StoreBackend {
18
18
  set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
19
19
  delete(store: string, key: StoreKeyParts): Promise<boolean>;
20
20
  entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
21
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
22
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
21
23
  close(): Promise<void>;
22
24
  }
@@ -164,6 +164,35 @@ export class FileStoreBackend {
164
164
  }
165
165
  return listed;
166
166
  }
167
+ // Read and written without an await in between: nothing else runs there.
168
+ async update(store, key, change) {
169
+ const entries = this.#load(store);
170
+ const encoded = encodeKey(key);
171
+ let current = entries.get(encoded);
172
+ if (current && !alive(current, this.#now()))
173
+ current = undefined;
174
+ const next = change(current);
175
+ if (next === undefined)
176
+ return current;
177
+ entries.set(encoded, next);
178
+ this.#changed(store);
179
+ return next;
180
+ }
181
+ async clear(store, prefix) {
182
+ const entries = this.#load(store);
183
+ const now = this.#now();
184
+ let count = 0;
185
+ for (const [encoded, entry] of entries) {
186
+ if (!startsWith(decodeKey(encoded), prefix))
187
+ continue;
188
+ entries.delete(encoded);
189
+ if (alive(entry, now))
190
+ count++;
191
+ }
192
+ if (count > 0)
193
+ this.#changed(store);
194
+ return count;
195
+ }
167
196
  async close() {
168
197
  clearInterval(this.#sweeper);
169
198
  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
+ }