chapterjs 0.2.3 → 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 +65 -0
- package/dist/cli/build.js +14 -1
- package/dist/cli/cli.js +41 -6
- 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 +49 -7
- package/dist/cli/project.js +103 -19
- 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.d.ts +29 -0
- package/dist/cli/test.js +446 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/events/router.d.ts +9 -4
- package/dist/events/router.js +24 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- 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 +17 -0
- package/dist/store/memory-backend.js +82 -0
- 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 +83 -33
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
|
@@ -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({
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type StoreBackend, type StoreChange, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
|
|
2
|
+
/** The data of every store, in memory, forgotten with the process. */
|
|
3
|
+
export declare class MemoryStoreBackend implements StoreBackend {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
});
|
|
8
|
+
get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
|
|
9
|
+
set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
|
|
10
|
+
delete(store: string, key: StoreKeyParts): Promise<boolean>;
|
|
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>;
|
|
14
|
+
/** Forgets everything: what a test does with its stores stays in it. */
|
|
15
|
+
reset(): void;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// A backend that keeps the data of the stores in memory and nowhere else:
|
|
2
|
+
// what `chapterjs test` gives a bot under test, so a test never touches
|
|
3
|
+
// the `data/` folder of the project and starts from nothing every time.
|
|
4
|
+
import { alive, decodeKey, encodeKey, startsWith, } from './backend.js';
|
|
5
|
+
/** The data of every store, in memory, forgotten with the process. */
|
|
6
|
+
export class MemoryStoreBackend {
|
|
7
|
+
#stores = new Map();
|
|
8
|
+
#now;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.#now = options.now ?? Date.now;
|
|
11
|
+
}
|
|
12
|
+
#store(name) {
|
|
13
|
+
let store = this.#stores.get(name);
|
|
14
|
+
if (!store) {
|
|
15
|
+
store = new Map();
|
|
16
|
+
this.#stores.set(name, store);
|
|
17
|
+
}
|
|
18
|
+
return store;
|
|
19
|
+
}
|
|
20
|
+
async get(store, key) {
|
|
21
|
+
const entry = this.#store(store).get(encodeKey(key));
|
|
22
|
+
if (!entry)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (!alive(entry, this.#now())) {
|
|
25
|
+
this.#store(store).delete(encodeKey(key));
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
async set(store, key, entry) {
|
|
31
|
+
this.#store(store).set(encodeKey(key), entry);
|
|
32
|
+
}
|
|
33
|
+
async delete(store, key) {
|
|
34
|
+
return this.#store(store).delete(encodeKey(key));
|
|
35
|
+
}
|
|
36
|
+
async entries(store, prefix) {
|
|
37
|
+
const now = this.#now();
|
|
38
|
+
const result = [];
|
|
39
|
+
for (const [encoded, entry] of this.#store(store)) {
|
|
40
|
+
const key = decodeKey(encoded);
|
|
41
|
+
if (!startsWith(key, prefix))
|
|
42
|
+
continue;
|
|
43
|
+
if (!alive(entry, now)) {
|
|
44
|
+
this.#store(store).delete(encoded);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
result.push({ key, ...entry });
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
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
|
+
}
|
|
77
|
+
/** Forgets everything: what a test does with its stores stays in it. */
|
|
78
|
+
reset() {
|
|
79
|
+
this.#stores.clear();
|
|
80
|
+
}
|
|
81
|
+
async close() { }
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|