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.
- package/README.md +5 -3
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +17 -4
- package/dist/cli/dev.js +37 -6
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +61 -9
- package/dist/cli/project.js +250 -44
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +69 -0
- package/dist/cli/start.js +54 -18
- package/dist/cli/test.js +112 -18
- package/dist/commands/command.d.ts +140 -5
- package/dist/commands/command.js +17 -2
- package/dist/commands/declaration.d.ts +17 -4
- package/dist/commands/declaration.js +128 -2
- package/dist/commands/router.js +78 -18
- package/dist/commands/tree.d.ts +25 -4
- package/dist/commands/tree.js +95 -26
- package/dist/commands/twins.d.ts +8 -0
- package/dist/commands/twins.js +24 -6
- package/dist/components/button.d.ts +8 -3
- package/dist/components/declaration.d.ts +2 -2
- package/dist/components/declaration.js +16 -12
- package/dist/components/render.js +5 -0
- package/dist/components/router.js +41 -0
- package/dist/components/select.d.ts +12 -5
- package/dist/core/bot.d.ts +7 -0
- package/dist/core/bot.js +44 -0
- package/dist/discord/api.d.ts +2 -0
- package/dist/discord/api.js +2 -0
- package/dist/discord/permissions.d.ts +4 -4
- package/dist/discord/permissions.js +13 -0
- package/dist/events/registry.d.ts +265 -243
- package/dist/events/registry.js +131 -100
- package/dist/index.d.ts +7 -4
- package/dist/index.js +2 -1
- package/dist/interactions/dispatch.js +4 -2
- package/dist/loader/loader.d.ts +16 -0
- package/dist/loader/loader.js +52 -7
- package/dist/loader/usage.d.ts +15 -0
- package/dist/loader/usage.js +40 -0
- package/dist/loader/watch.d.ts +5 -3
- package/dist/loader/watch.js +88 -17
- package/dist/messages/commands.js +3 -0
- package/dist/messages/messages.d.ts +1 -0
- package/dist/messages/translate.js +5 -1
- package/dist/presence/declaration.d.ts +49 -2
- package/dist/presence/declaration.js +122 -39
- package/dist/presence/presence.d.ts +65 -6
- package/dist/presence/presence.js +11 -0
- package/dist/presence/rotation.d.ts +32 -0
- package/dist/presence/rotation.js +167 -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/rest/errors.d.ts +0 -1
- package/dist/rest/errors.js +15 -1
- package/dist/rest/rest.js +10 -1
- 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 -2
- package/dist/store/file-backend.js +47 -1
- 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 +17 -1
- package/dist/store/scope.js +32 -3
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +79 -32
- package/dist/structures/channel.d.ts +13 -0
- package/dist/structures/channel.js +24 -0
- package/dist/structures/context.d.ts +3 -0
- package/dist/structures/entities.js +1 -0
- package/dist/structures/interaction.d.ts +6 -0
- package/dist/structures/interaction.js +20 -0
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/dist/structures/message.d.ts +17 -0
- package/dist/structures/message.js +33 -0
- package/dist/structures/wait.d.ts +105 -0
- package/dist/structures/wait.js +85 -0
- package/dist/testing/harness.d.ts +48 -8
- package/dist/testing/harness.js +156 -36
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/smoke.js +112 -1
- package/dist/testing/world.js +51 -2
- package/dist/util/call-site.d.ts +4 -0
- package/dist/util/call-site.js +22 -0
- package/dist/voice/connection.d.ts +2 -0
- package/dist/voice/connection.js +15 -1
- package/dist/voice/usage.d.ts +3 -2
- package/dist/voice/usage.js +7 -19
- package/dist/waiting/usage.d.ts +5 -0
- package/dist/waiting/usage.js +19 -0
- package/dist/waiting/waiters.d.ts +48 -0
- package/dist/waiting/waiters.js +107 -0
- package/package.json +1 -1
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 {
|
|
@@ -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 ||
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|