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.
- package/README.md +2 -2
- package/dist/cli/build.js +14 -1
- package/dist/cli/dev.js +29 -1
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +30 -7
- package/dist/cli/project.js +85 -11
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +57 -0
- package/dist/cli/start.js +43 -17
- package/dist/cli/test.js +4 -2
- package/dist/events/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -0
- package/dist/store/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -1
- package/dist/store/file-backend.js +29 -0
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +4 -2
- package/dist/store/memory-backend.js +27 -1
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +11 -0
- package/dist/store/scope.js +23 -0
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +79 -32
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- 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
|
+
}
|
package/dist/store/backend.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
package/dist/store/backend.js
CHANGED
|
@@ -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`),
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|