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,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
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// The backend of the stores declared with `storage: 'redis'`: each value
|
|
2
|
+
// is one Redis key, `<store>:<part>:<part>` (`dev:` in front of it under
|
|
3
|
+
// `chapterjs dev`, so a Redis shared with production is never mixed up),
|
|
4
|
+
// its JSON as the value and its expiration given to Redis itself, which
|
|
5
|
+
// forgets the key alone. Nothing is written that a store did not ask for.
|
|
6
|
+
// An update is a transaction: WATCH the key, read it, then MULTI, write,
|
|
7
|
+
// EXEC, which Redis refuses when anyone wrote the key in between; it is
|
|
8
|
+
// then tried again on what is there now. A transaction needs a connection
|
|
9
|
+
// of its own (WATCH and MULTI belong to the connection, and the main one
|
|
10
|
+
// carries every other command), so a few are opened when updates need
|
|
11
|
+
// them, and lent one update at a time.
|
|
12
|
+
// https://redis.io/docs/latest/commands/set/ (PX), /commands/pttl/,
|
|
13
|
+
// /commands/scan/ (MATCH, COUNT), /commands/mget/, /commands/del/,
|
|
14
|
+
// https://redis.io/docs/latest/develop/interact/transactions/ (WATCH)
|
|
15
|
+
import { RedisClient } from '../redis/client.js';
|
|
16
|
+
import { RedisRankingBackend } from './redis-ranking.js';
|
|
17
|
+
import { tooBusy, UPDATE_ATTEMPTS } from './ipc-backend.js';
|
|
18
|
+
import { KeyQueue, startsWith, } from './backend.js';
|
|
19
|
+
/** The prefix of every key written by `chapterjs dev`. */
|
|
20
|
+
export const DEV_PREFIX = 'dev:';
|
|
21
|
+
/**
|
|
22
|
+
* What a part of a key may not contain as is: `:` separates the parts,
|
|
23
|
+
* `*`, `?`, `[`, `]` and `\` mean something to SCAN, `%` is the escape.
|
|
24
|
+
*/
|
|
25
|
+
const ESCAPED = /[%:*?[\]\\]/g;
|
|
26
|
+
/** A part of a key, safe between two `:` and in a MATCH pattern. */
|
|
27
|
+
export const escapePart = (part) => part.replace(ESCAPED, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
|
|
28
|
+
/** The parts back from a key, after the prefix and the name of the store. */
|
|
29
|
+
export const unescapePart = (text) => text.replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
30
|
+
/** The data of every store with `storage: 'redis'`, in one Redis. */
|
|
31
|
+
export class RedisStoreBackend {
|
|
32
|
+
#client;
|
|
33
|
+
#prefix;
|
|
34
|
+
#scanCount;
|
|
35
|
+
#now;
|
|
36
|
+
#connect;
|
|
37
|
+
#maxLent;
|
|
38
|
+
/** Connections for updates that wait for one. */
|
|
39
|
+
#idle = [];
|
|
40
|
+
/** Every connection opened for updates, to close them. */
|
|
41
|
+
#opened = [];
|
|
42
|
+
/** Updates waiting for a connection, when every one is lent. */
|
|
43
|
+
#queue = [];
|
|
44
|
+
#updates = new KeyQueue();
|
|
45
|
+
#rankings = null;
|
|
46
|
+
constructor(client, options = {}) {
|
|
47
|
+
this.#client = client;
|
|
48
|
+
this.#prefix = options.prefix ?? '';
|
|
49
|
+
this.#scanCount = options.scanCount ?? 500;
|
|
50
|
+
this.#now = options.now ?? Date.now;
|
|
51
|
+
this.#maxLent = options.transactions ?? 4;
|
|
52
|
+
this.#connect = options.connect ?? (() => new RedisClient(client.address));
|
|
53
|
+
}
|
|
54
|
+
/** A connection of its own for one update, given back by `release`. */
|
|
55
|
+
async #lend() {
|
|
56
|
+
const idle = this.#idle.pop();
|
|
57
|
+
if (idle)
|
|
58
|
+
return idle;
|
|
59
|
+
if (this.#opened.length < this.#maxLent) {
|
|
60
|
+
const opened = this.#connect();
|
|
61
|
+
this.#opened.push(opened);
|
|
62
|
+
return opened;
|
|
63
|
+
}
|
|
64
|
+
return new Promise(resolve => this.#queue.push(resolve));
|
|
65
|
+
}
|
|
66
|
+
#release(client) {
|
|
67
|
+
const next = this.#queue.shift();
|
|
68
|
+
if (next)
|
|
69
|
+
next(client);
|
|
70
|
+
else
|
|
71
|
+
this.#idle.push(client);
|
|
72
|
+
}
|
|
73
|
+
/** Rankings as sorted sets: the key of a ranking is the key of its scope. */
|
|
74
|
+
rankings() {
|
|
75
|
+
this.#rankings ??= new RedisRankingBackend(this.#client, (name, scope) => this.key(name, scope));
|
|
76
|
+
return this.#rankings;
|
|
77
|
+
}
|
|
78
|
+
/** The client, for the preflight check of the CLI. */
|
|
79
|
+
get client() {
|
|
80
|
+
return this.#client;
|
|
81
|
+
}
|
|
82
|
+
/** The Redis key of a store and parts. */
|
|
83
|
+
key(store, parts) {
|
|
84
|
+
return [this.#prefix + store, ...parts.map(escapePart)].join(':');
|
|
85
|
+
}
|
|
86
|
+
/** The parts of a Redis key of `store`, or `null` for another key. */
|
|
87
|
+
#parts(store, key) {
|
|
88
|
+
const head = `${this.#prefix}${store}:`;
|
|
89
|
+
if (!key.startsWith(head))
|
|
90
|
+
return null;
|
|
91
|
+
return key.slice(head.length).split(':').map(unescapePart);
|
|
92
|
+
}
|
|
93
|
+
#entry(value, pttl) {
|
|
94
|
+
if (typeof value !== 'string')
|
|
95
|
+
return undefined;
|
|
96
|
+
// -2: the key is gone since; -1: no expiration.
|
|
97
|
+
if (pttl === -2)
|
|
98
|
+
return undefined;
|
|
99
|
+
return {
|
|
100
|
+
value: JSON.parse(value),
|
|
101
|
+
expiresAt: typeof pttl === 'number' && pttl >= 0 ? this.#now() + pttl : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async get(store, key) {
|
|
105
|
+
const name = this.key(store, key);
|
|
106
|
+
const [value, pttl] = await Promise.all([
|
|
107
|
+
this.#client.send('GET', name),
|
|
108
|
+
this.#client.send('PTTL', name),
|
|
109
|
+
]);
|
|
110
|
+
return this.#entry(value, pttl);
|
|
111
|
+
}
|
|
112
|
+
async set(store, key, entry) {
|
|
113
|
+
const name = this.key(store, key);
|
|
114
|
+
const json = JSON.stringify(entry.value);
|
|
115
|
+
if (entry.expiresAt === null) {
|
|
116
|
+
await this.#client.send('SET', name, json);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const ms = entry.expiresAt - this.#now();
|
|
120
|
+
// Already expired: there is nothing to keep, and nothing to leave.
|
|
121
|
+
if (ms <= 0) {
|
|
122
|
+
await this.#client.send('DEL', name);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await this.#client.send('SET', name, json, 'PX', ms);
|
|
126
|
+
}
|
|
127
|
+
update(store, key, change) {
|
|
128
|
+
const name = this.key(store, key);
|
|
129
|
+
return this.#updates.run(name, () => this.#update(store, name, change));
|
|
130
|
+
}
|
|
131
|
+
async #update(store, name, change) {
|
|
132
|
+
const client = await this.#lend();
|
|
133
|
+
try {
|
|
134
|
+
for (let attempt = 0; attempt < UPDATE_ATTEMPTS; attempt++) {
|
|
135
|
+
await client.send('WATCH', name);
|
|
136
|
+
const [value, pttl] = await Promise.all([
|
|
137
|
+
client.send('GET', name),
|
|
138
|
+
client.send('PTTL', name),
|
|
139
|
+
]);
|
|
140
|
+
const current = this.#entry(value, pttl);
|
|
141
|
+
let next;
|
|
142
|
+
try {
|
|
143
|
+
next = change(current);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await client.send('UNWATCH');
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
if (next === undefined) {
|
|
150
|
+
await client.send('UNWATCH');
|
|
151
|
+
return current;
|
|
152
|
+
}
|
|
153
|
+
const ms = next.expiresAt === null ? null : next.expiresAt - this.#now();
|
|
154
|
+
const write = ms === null
|
|
155
|
+
? ['SET', name, JSON.stringify(next.value)]
|
|
156
|
+
: ms <= 0
|
|
157
|
+
? ['DEL', name]
|
|
158
|
+
: ['SET', name, JSON.stringify(next.value), 'PX', ms];
|
|
159
|
+
const [, , done] = await Promise.all([
|
|
160
|
+
client.send('MULTI'),
|
|
161
|
+
client.send(...write),
|
|
162
|
+
client.send('EXEC'),
|
|
163
|
+
]);
|
|
164
|
+
// `null`: someone wrote the key since WATCH, nothing was written.
|
|
165
|
+
if (done !== null)
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(tooBusy(store));
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.#release(client);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async clear(store, prefix) {
|
|
175
|
+
let count = 0;
|
|
176
|
+
for (const keys of await this.#scan(store, prefix)) {
|
|
177
|
+
if (keys.length === 0)
|
|
178
|
+
continue;
|
|
179
|
+
const removed = await this.#client.send('DEL', ...keys.map(item => item.key));
|
|
180
|
+
if (typeof removed === 'number')
|
|
181
|
+
count += removed;
|
|
182
|
+
}
|
|
183
|
+
return count;
|
|
184
|
+
}
|
|
185
|
+
async delete(store, key) {
|
|
186
|
+
return (await this.#client.send('DEL', this.key(store, key))) === 1;
|
|
187
|
+
}
|
|
188
|
+
async entries(store, prefix) {
|
|
189
|
+
const listed = [];
|
|
190
|
+
for (const batch of await this.#scan(store, prefix)) {
|
|
191
|
+
if (batch.length === 0)
|
|
192
|
+
continue;
|
|
193
|
+
const [values, ...ttls] = await Promise.all([
|
|
194
|
+
this.#client.send('MGET', ...batch.map(item => item.key)),
|
|
195
|
+
...batch.map(item => this.#client.send('PTTL', item.key)),
|
|
196
|
+
]);
|
|
197
|
+
if (!Array.isArray(values))
|
|
198
|
+
continue;
|
|
199
|
+
batch.forEach((item, index) => {
|
|
200
|
+
const entry = this.#entry(values[index] ?? null, ttls[index] ?? -2);
|
|
201
|
+
if (entry)
|
|
202
|
+
listed.push({ key: item.parts, ...entry });
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return listed;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The keys of `store` that start with `prefix`, part by part, in batches
|
|
209
|
+
* of `scanCount`.
|
|
210
|
+
*/
|
|
211
|
+
async #scan(store, prefix) {
|
|
212
|
+
// Every key of the store, or the ones whose text starts like the
|
|
213
|
+
// prefix: `a:b*` also matches `a:bc`, so the parts are checked after.
|
|
214
|
+
const pattern = `${this.key(store, prefix)}${prefix.length === 0 ? ':' : ''}*`;
|
|
215
|
+
const keys = [];
|
|
216
|
+
let cursor = '0';
|
|
217
|
+
do {
|
|
218
|
+
const reply = await this.#client.send('SCAN', cursor, 'MATCH', pattern, 'COUNT', this.#scanCount);
|
|
219
|
+
if (!Array.isArray(reply) || reply.length !== 2) {
|
|
220
|
+
throw new Error(`Redis answered SCAN with something unexpected.`);
|
|
221
|
+
}
|
|
222
|
+
cursor = String(reply[0]);
|
|
223
|
+
for (const key of reply[1]) {
|
|
224
|
+
if (typeof key === 'string')
|
|
225
|
+
keys.push(key);
|
|
226
|
+
}
|
|
227
|
+
} while (cursor !== '0');
|
|
228
|
+
const batches = [];
|
|
229
|
+
// A key SCAN gave twice (it may, while keys are written) counts once.
|
|
230
|
+
const unique = [...new Set(keys)];
|
|
231
|
+
for (let at = 0; at < unique.length; at += this.#scanCount) {
|
|
232
|
+
batches.push(unique
|
|
233
|
+
.slice(at, at + this.#scanCount)
|
|
234
|
+
.map(key => ({ key, parts: this.#parts(store, key) }))
|
|
235
|
+
.filter((item) => item.parts !== null && startsWith(item.parts, prefix)));
|
|
236
|
+
}
|
|
237
|
+
return batches;
|
|
238
|
+
}
|
|
239
|
+
async close() {
|
|
240
|
+
await Promise.all([
|
|
241
|
+
this.#client.close(),
|
|
242
|
+
...this.#opened.map(client => client.close()),
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RedisClient } from '../redis/client.js';
|
|
2
|
+
import type { StoreKeyParts } from './backend.js';
|
|
3
|
+
import type { RankedScore, RankingBackend, RankingOrder } from './ranking-backend.js';
|
|
4
|
+
export declare class RedisRankingBackend implements RankingBackend {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(client: RedisClient, key: (name: string, scope: StoreKeyParts) => string);
|
|
7
|
+
add(name: string, scope: StoreKeyParts, id: string, by: number): Promise<number>;
|
|
8
|
+
set(name: string, scope: StoreKeyParts, id: string, score: number): Promise<void>;
|
|
9
|
+
score(name: string, scope: StoreKeyParts, id: string): Promise<number | undefined>;
|
|
10
|
+
rank(name: string, scope: StoreKeyParts, id: string, order: RankingOrder): Promise<number | undefined>;
|
|
11
|
+
range(name: string, scope: StoreKeyParts, order: RankingOrder, start: number, count: number): Promise<RankedScore[]>;
|
|
12
|
+
remove(name: string, scope: StoreKeyParts, id: string): Promise<boolean>;
|
|
13
|
+
size(name: string, scope: StoreKeyParts): Promise<number>;
|
|
14
|
+
clear(name: string, scope: StoreKeyParts): Promise<number>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The rankings kept in Redis, as sorted sets: one key per ranking and per
|
|
2
|
+
// server (`xp:<server id>`, `xp` for a global one, `dev:` in front under
|
|
3
|
+
// `chapterjs dev`), the ids as members and the scores as scores, so Redis
|
|
4
|
+
// itself keeps them in order and adds to a score in one go.
|
|
5
|
+
// https://redis.io/docs/latest/commands/zincrby/, /commands/zadd/,
|
|
6
|
+
// /commands/zscore/, /commands/zrevrank/, /commands/zrevrange/,
|
|
7
|
+
// /commands/zrange/, /commands/zrem/, /commands/zcard/
|
|
8
|
+
/** A score as Redis writes it: `"10"`, `"1.5"`, `"inf"`. */
|
|
9
|
+
const scoreOf = (reply) => {
|
|
10
|
+
const text = String(reply);
|
|
11
|
+
if (text === 'inf' || text === '+inf')
|
|
12
|
+
return Infinity;
|
|
13
|
+
if (text === '-inf')
|
|
14
|
+
return -Infinity;
|
|
15
|
+
return Number(text);
|
|
16
|
+
};
|
|
17
|
+
export class RedisRankingBackend {
|
|
18
|
+
#client;
|
|
19
|
+
#key;
|
|
20
|
+
constructor(client, key) {
|
|
21
|
+
this.#client = client;
|
|
22
|
+
this.#key = key;
|
|
23
|
+
}
|
|
24
|
+
async add(name, scope, id, by) {
|
|
25
|
+
return scoreOf(await this.#client.send('ZINCRBY', this.#key(name, scope), by, id));
|
|
26
|
+
}
|
|
27
|
+
async set(name, scope, id, score) {
|
|
28
|
+
await this.#client.send('ZADD', this.#key(name, scope), score, id);
|
|
29
|
+
}
|
|
30
|
+
async score(name, scope, id) {
|
|
31
|
+
const reply = await this.#client.send('ZSCORE', this.#key(name, scope), id);
|
|
32
|
+
return reply === null ? undefined : scoreOf(reply);
|
|
33
|
+
}
|
|
34
|
+
async rank(name, scope, id, order) {
|
|
35
|
+
const reply = await this.#client.send(order === 'highest' ? 'ZREVRANK' : 'ZRANK', this.#key(name, scope), id);
|
|
36
|
+
return typeof reply === 'number' ? reply : undefined;
|
|
37
|
+
}
|
|
38
|
+
async range(name, scope, order, start, count) {
|
|
39
|
+
if (count === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const reply = await this.#client.send(order === 'highest' ? 'ZREVRANGE' : 'ZRANGE', this.#key(name, scope), start, start + count - 1, 'WITHSCORES');
|
|
42
|
+
if (!Array.isArray(reply))
|
|
43
|
+
return [];
|
|
44
|
+
const ranked = [];
|
|
45
|
+
for (let index = 0; index + 1 < reply.length; index += 2) {
|
|
46
|
+
ranked.push({
|
|
47
|
+
id: String(reply[index]),
|
|
48
|
+
score: scoreOf(reply[index + 1] ?? null),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return ranked;
|
|
52
|
+
}
|
|
53
|
+
async remove(name, scope, id) {
|
|
54
|
+
return (await this.#client.send('ZREM', this.#key(name, scope), id)) === 1;
|
|
55
|
+
}
|
|
56
|
+
async size(name, scope) {
|
|
57
|
+
const reply = await this.#client.send('ZCARD', this.#key(name, scope));
|
|
58
|
+
return typeof reply === 'number' ? reply : 0;
|
|
59
|
+
}
|
|
60
|
+
async clear(name, scope) {
|
|
61
|
+
const key = this.#key(name, scope);
|
|
62
|
+
// Sent one after the other on one connection: counted, then removed.
|
|
63
|
+
const [count] = await Promise.all([
|
|
64
|
+
this.#client.send('ZCARD', key),
|
|
65
|
+
this.#client.send('DEL', key),
|
|
66
|
+
]);
|
|
67
|
+
return typeof count === 'number' ? count : 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/store/scope.d.ts
CHANGED
|
@@ -6,3 +6,14 @@ export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
|
|
|
6
6
|
* the framework runs.
|
|
7
7
|
*/
|
|
8
8
|
export declare function currentGuildId(): string | null | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The scope part of a key, for data per server or for the whole bot: the
|
|
11
|
+
* id of the server the code runs for (`guildOf`), or nothing for a global
|
|
12
|
+
* one. `what` names the kind of declaration in the message, when no server
|
|
13
|
+
* is known.
|
|
14
|
+
*/
|
|
15
|
+
export declare function scopeParts(what: string, name: string, scope: 'guild' | 'global', guildOf: () => string | null): string[];
|
|
16
|
+
/** The id of what `in()` was given: a server, or its id. */
|
|
17
|
+
export declare function guildIdOf(guild: {
|
|
18
|
+
id: string;
|
|
19
|
+
} | string): string;
|
package/dist/store/scope.js
CHANGED
|
@@ -18,3 +18,26 @@ export function withGuild(guildId, fn) {
|
|
|
18
18
|
export function currentGuildId() {
|
|
19
19
|
return scope.getStore();
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The scope part of a key, for data per server or for the whole bot: the
|
|
23
|
+
* id of the server the code runs for (`guildOf`), or nothing for a global
|
|
24
|
+
* one. `what` names the kind of declaration in the message, when no server
|
|
25
|
+
* is known.
|
|
26
|
+
*/
|
|
27
|
+
export function scopeParts(what, name, scope, guildOf) {
|
|
28
|
+
if (scope === 'global')
|
|
29
|
+
return [];
|
|
30
|
+
const guildId = guildOf();
|
|
31
|
+
if (!guildId) {
|
|
32
|
+
throw new Error(`${name} is a ${what} per server, and this code does not run in one: use ${name}.in(guild) to say which server, or declare the ${what} with scope: 'global'.`);
|
|
33
|
+
}
|
|
34
|
+
return [guildId];
|
|
35
|
+
}
|
|
36
|
+
/** The id of what `in()` was given: a server, or its id. */
|
|
37
|
+
export function guildIdOf(guild) {
|
|
38
|
+
const id = typeof guild === 'string' ? guild : guild?.id;
|
|
39
|
+
if (typeof id !== 'string' || id === '') {
|
|
40
|
+
throw new TypeError('in() takes a server (a Guild) or its id, like warnings.in(guild) or warnings.in(guild.id).');
|
|
41
|
+
}
|
|
42
|
+
return id;
|
|
43
|
+
}
|
package/dist/store/store.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { Duration } from '../commands/command.js';
|
|
2
2
|
import type { Guild } from '../structures/guild.js';
|
|
3
|
+
import { type StoreStorage } from './backend.js';
|
|
3
4
|
/** Where the data of a store is kept apart: per server, or one for the whole bot. */
|
|
4
5
|
export type StoreScope = 'guild' | 'global';
|
|
6
|
+
export type { StoreStorage };
|
|
5
7
|
/** What a file gives to `store()`. */
|
|
6
8
|
export interface StoreConfig {
|
|
9
|
+
/**
|
|
10
|
+
* Where the data is kept: `'file'` (the default), a file in the `data/`
|
|
11
|
+
* folder of the project, or `'redis'`, the Redis of `REDIS_URL` in
|
|
12
|
+
* `.env`. Only stores that say `'redis'` write anything there.
|
|
13
|
+
*/
|
|
14
|
+
storage?: StoreStorage;
|
|
7
15
|
/**
|
|
8
16
|
* `'guild'` (the default): the data is kept per server, and a function
|
|
9
17
|
* that runs in a server reads and writes the data of that server, with
|
|
@@ -46,13 +54,37 @@ export interface ScopedStore<T> {
|
|
|
46
54
|
/** Keeps `value` at `key`. The value must be JSON: no `Date`, `Map` or class instance. */
|
|
47
55
|
set(key: StoreKey, value: T, options?: StoreSetOptions): Promise<void>;
|
|
48
56
|
/**
|
|
49
|
-
* Reads the value at `key` and keeps what `change` returns, in one go
|
|
50
|
-
*
|
|
57
|
+
* Reads the value at `key` and keeps what `change` returns, in one go:
|
|
58
|
+
* nothing written meanwhile is lost, even by another process. `change`
|
|
59
|
+
* receives `undefined` when there is no value yet, and may run again
|
|
60
|
+
* when the value changed in between: keep it a plain computation. The
|
|
51
61
|
* expiration stays what it was, unless `options` say otherwise.
|
|
52
62
|
*/
|
|
53
63
|
update(key: StoreKey, change: (current: T | undefined) => T, options?: StoreSetOptions): Promise<T>;
|
|
64
|
+
/**
|
|
65
|
+
* Adds `by` (1 by default, negative to take away) to the number at
|
|
66
|
+
* `key`, starting from 0, in one go like `update()`, and resolves with
|
|
67
|
+
* the new number. Only for a store of numbers.
|
|
68
|
+
*/
|
|
69
|
+
increment(this: ScopedStore<number>, key: StoreKey, by?: number, options?: StoreSetOptions): Promise<number>;
|
|
54
70
|
/** Forgets the value at `key`. `true` when there was one. */
|
|
55
71
|
delete(key: StoreKey): Promise<boolean>;
|
|
72
|
+
/**
|
|
73
|
+
* The values at several keys at once, in their order: `undefined` for a
|
|
74
|
+
* key that has none.
|
|
75
|
+
*/
|
|
76
|
+
getMany(keys: readonly StoreKey[]): Promise<(T | undefined)[]>;
|
|
77
|
+
/**
|
|
78
|
+
* Keeps several values at once, each with the same `options`. Every
|
|
79
|
+
* value is checked before anything is written.
|
|
80
|
+
*/
|
|
81
|
+
setMany(values: readonly (readonly [key: StoreKey, value: T])[], options?: StoreSetOptions): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Forgets every value whose key starts with `prefix`, or every value of
|
|
84
|
+
* the store (of the server, for a store per server) without one.
|
|
85
|
+
* Resolves with how many there were.
|
|
86
|
+
*/
|
|
87
|
+
deleteAll(prefix?: StoreKey): Promise<number>;
|
|
56
88
|
/**
|
|
57
89
|
* Every entry, or the ones whose key starts with `prefix`, in no
|
|
58
90
|
* particular order. Expired ones are left out.
|
|
@@ -79,6 +111,7 @@ export interface StoreState {
|
|
|
79
111
|
name?: string;
|
|
80
112
|
/** What `read` found in the config. */
|
|
81
113
|
scope: StoreScope;
|
|
114
|
+
storage: StoreStorage;
|
|
82
115
|
/** The default expiration, in ms, or `null` for never. */
|
|
83
116
|
expires: number | null;
|
|
84
117
|
}
|
|
@@ -89,6 +122,7 @@ export declare const isStoreDeclaration: (value: unknown) => boolean;
|
|
|
89
122
|
export declare function bindStore(declared: unknown, bound: {
|
|
90
123
|
name: string;
|
|
91
124
|
scope: StoreScope;
|
|
125
|
+
storage: StoreStorage;
|
|
92
126
|
expires: number | null;
|
|
93
127
|
}): void;
|
|
94
128
|
/**
|
|
@@ -99,9 +133,9 @@ export declare function checkJson(value: unknown, path?: string): void;
|
|
|
99
133
|
/**
|
|
100
134
|
* Declares a store: data the bot keeps from one restart to the next,
|
|
101
135
|
* typed by what it holds. Export the result from any file of `src/`: the
|
|
102
|
-
* name of the export is the name of the store, and its file in `data
|
|
103
|
-
*
|
|
104
|
-
* `expires` is.
|
|
136
|
+
* name of the export is the name of the store, and its file in `data/`
|
|
137
|
+
* (or its keys in Redis, with `storage: 'redis'`). Per server unless
|
|
138
|
+
* `scope: 'global'` is given, with an expiration if `expires` is.
|
|
105
139
|
*
|
|
106
140
|
* ```ts
|
|
107
141
|
* import { store } from 'chapterjs';
|
package/dist/store/store.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the bot runs; the data itself lives in a backend (`backend.ts`).
|
|
7
7
|
import { parseDuration } from '../util/duration.js';
|
|
8
8
|
import { storeBackend, } from './backend.js';
|
|
9
|
-
import { currentGuildId } from './scope.js';
|
|
9
|
+
import { currentGuildId, guildIdOf, scopeParts } from './scope.js';
|
|
10
10
|
const STATE = Symbol.for('chapterjs.store');
|
|
11
11
|
/** The state of a declared store, if the value is one. */
|
|
12
12
|
export function storeStateOf(value) {
|
|
@@ -22,6 +22,7 @@ export function bindStore(declared, bound) {
|
|
|
22
22
|
throw new TypeError('Not a store.');
|
|
23
23
|
state.name = bound.name;
|
|
24
24
|
state.scope = bound.scope;
|
|
25
|
+
state.storage = bound.storage;
|
|
25
26
|
state.expires = bound.expires;
|
|
26
27
|
}
|
|
27
28
|
const parts = (key) => {
|
|
@@ -105,47 +106,95 @@ function scoped(state, guildOf) {
|
|
|
105
106
|
if (!state.name) {
|
|
106
107
|
throw new Error('This store was not loaded by ChapterJS: a store is exported from a file of src/ (export const name = store()), and used once the bot started.');
|
|
107
108
|
}
|
|
108
|
-
const backend = storeBackend();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
throw new Error(`${state.name} is a store per server, and this code does not run in one: use ${state.name}.in(guild) to say which server, or declare the store with scope: 'global'.`);
|
|
115
|
-
}
|
|
116
|
-
return { backend, name: state.name, prefix: [guildId] };
|
|
109
|
+
const backend = storeBackend(state.storage, state.name);
|
|
110
|
+
return {
|
|
111
|
+
backend,
|
|
112
|
+
name: state.name,
|
|
113
|
+
prefix: scopeParts('store', state.name, state.scope, guildOf),
|
|
114
|
+
};
|
|
117
115
|
};
|
|
118
116
|
const full = (prefix, key) => [
|
|
119
117
|
...prefix,
|
|
120
118
|
...parts(key),
|
|
121
119
|
];
|
|
122
|
-
const set = async (key, value, options
|
|
120
|
+
const set = async (key, value, options) => {
|
|
123
121
|
const { backend, name, prefix } = ready();
|
|
124
122
|
checkJson(value);
|
|
125
|
-
const
|
|
126
|
-
const at = keep !== undefined && (!options || options.expires === undefined)
|
|
127
|
-
? keep
|
|
128
|
-
: expiresAt(options, state.expires, now);
|
|
123
|
+
const at = expiresAt(options, state.expires, Date.now());
|
|
129
124
|
await backend.set(name, full(prefix, key), { value, expiresAt: at });
|
|
130
125
|
};
|
|
126
|
+
const update = async (key, change, options) => {
|
|
127
|
+
const { backend, name, prefix } = ready();
|
|
128
|
+
let kept;
|
|
129
|
+
// Read and kept by the backend with nothing written in between; run
|
|
130
|
+
// again when the value changed meanwhile.
|
|
131
|
+
await backend.update(name, full(prefix, key), current => {
|
|
132
|
+
const value = change(current?.value);
|
|
133
|
+
checkJson(value);
|
|
134
|
+
kept = value;
|
|
135
|
+
return {
|
|
136
|
+
value,
|
|
137
|
+
expiresAt: current && (!options || options.expires === undefined)
|
|
138
|
+
? current.expiresAt
|
|
139
|
+
: expiresAt(options, state.expires, Date.now()),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
return kept;
|
|
143
|
+
};
|
|
131
144
|
return {
|
|
132
145
|
async get(key) {
|
|
133
146
|
const { backend, name, prefix } = ready();
|
|
134
147
|
const entry = await backend.get(name, full(prefix, key));
|
|
135
148
|
return entry?.value;
|
|
136
149
|
},
|
|
137
|
-
set: (key, value, options) => set(key, value, options
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return
|
|
150
|
+
set: (key, value, options) => set(key, value, options),
|
|
151
|
+
update: (key, change, options) => update(key, change, options),
|
|
152
|
+
increment(key, by = 1, options) {
|
|
153
|
+
if (typeof by !== 'number' || !Number.isFinite(by)) {
|
|
154
|
+
return Promise.reject(new TypeError(`increment() adds a number, got ${JSON.stringify(by) ?? typeof by}.`));
|
|
155
|
+
}
|
|
156
|
+
return update(key, current => {
|
|
157
|
+
if (current !== undefined && typeof current !== 'number') {
|
|
158
|
+
throw new TypeError(`increment() adds to a number, and the value at ${JSON.stringify(parts(key))} is ${Array.isArray(current) ? 'a list' : current === null ? 'null' : `a ${typeof current}`}: use update() to change it.`);
|
|
159
|
+
}
|
|
160
|
+
return ((current ?? 0) + by);
|
|
161
|
+
}, options);
|
|
144
162
|
},
|
|
145
163
|
async delete(key) {
|
|
146
164
|
const { backend, name, prefix } = ready();
|
|
147
165
|
return backend.delete(name, full(prefix, key));
|
|
148
166
|
},
|
|
167
|
+
async getMany(keys) {
|
|
168
|
+
const { backend, name, prefix } = ready();
|
|
169
|
+
if (!Array.isArray(keys)) {
|
|
170
|
+
throw new TypeError('getMany() takes a list of keys, like warnings.getMany([a.id, b.id]).');
|
|
171
|
+
}
|
|
172
|
+
// Every key checked before anything is asked.
|
|
173
|
+
const all = keys.map(key => full(prefix, key));
|
|
174
|
+
const entries = await Promise.all(all.map(key => backend.get(name, key)));
|
|
175
|
+
return entries.map(entry => entry?.value);
|
|
176
|
+
},
|
|
177
|
+
async setMany(values, options) {
|
|
178
|
+
const { backend, name, prefix } = ready();
|
|
179
|
+
if (!Array.isArray(values)) {
|
|
180
|
+
throw new TypeError("setMany() takes a list of [key, value] pairs, like scores.setMany([['a', 1], ['b', 2]]).");
|
|
181
|
+
}
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const at = expiresAt(options, state.expires, now);
|
|
184
|
+
const checked = values.map((pair, index) => {
|
|
185
|
+
if (!Array.isArray(pair) || pair.length !== 2) {
|
|
186
|
+
throw new TypeError(`setMany() takes a list of [key, value] pairs, and item ${index} is not one.`);
|
|
187
|
+
}
|
|
188
|
+
const [key, value] = pair;
|
|
189
|
+
checkJson(value, `the value of item ${index}`);
|
|
190
|
+
return { key: full(prefix, key), value };
|
|
191
|
+
});
|
|
192
|
+
await Promise.all(checked.map(({ key, value }) => backend.set(name, key, { value, expiresAt: at })));
|
|
193
|
+
},
|
|
194
|
+
async deleteAll(prefixKey) {
|
|
195
|
+
const { backend, name, prefix } = ready();
|
|
196
|
+
return backend.clear(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
|
|
197
|
+
},
|
|
149
198
|
async entries(prefixKey) {
|
|
150
199
|
const { backend, name, prefix } = ready();
|
|
151
200
|
const listed = await backend.entries(name, prefixKey === undefined ? prefix : full(prefix, prefixKey));
|
|
@@ -169,9 +218,9 @@ function scoped(state, guildOf) {
|
|
|
169
218
|
/**
|
|
170
219
|
* Declares a store: data the bot keeps from one restart to the next,
|
|
171
220
|
* typed by what it holds. Export the result from any file of `src/`: the
|
|
172
|
-
* name of the export is the name of the store, and its file in `data
|
|
173
|
-
*
|
|
174
|
-
* `expires` is.
|
|
221
|
+
* name of the export is the name of the store, and its file in `data/`
|
|
222
|
+
* (or its keys in Redis, with `storage: 'redis'`). Per server unless
|
|
223
|
+
* `scope: 'global'` is given, with an expiration if `expires` is.
|
|
175
224
|
*
|
|
176
225
|
* ```ts
|
|
177
226
|
* import { store } from 'chapterjs';
|
|
@@ -183,13 +232,11 @@ function scoped(state, guildOf) {
|
|
|
183
232
|
* ```
|
|
184
233
|
*/
|
|
185
234
|
export function store(config = {}) {
|
|
186
|
-
const state = {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
return id;
|
|
235
|
+
const state = {
|
|
236
|
+
config,
|
|
237
|
+
scope: 'guild',
|
|
238
|
+
storage: 'file',
|
|
239
|
+
expires: null,
|
|
193
240
|
};
|
|
194
241
|
const declared = {
|
|
195
242
|
...scoped(state, () => currentGuildId() ?? null),
|