-
-
Notifications
You must be signed in to change notification settings - Fork 11
/
Store.ts
399 lines (350 loc) · 13.1 KB
/
Store.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
import { Collection } from '@discordjs/collection';
import { classExtends, isClass, type AbstractConstructor, type Constructor } from '@sapphire/utilities';
import { join } from 'path';
import { LoaderError, LoaderErrorType } from '../errors/LoaderError';
import { resolvePath, type Path } from '../internal/Path';
import { ManuallyRegisteredPiecesSymbol, VirtualPath } from '../internal/constants';
import { container, type Container } from '../shared/Container';
import type { HydratedModuleData, ILoaderResultEntry, ILoaderStrategy, ModuleData } from '../strategies/ILoaderStrategy';
import { LoaderStrategy } from '../strategies/LoaderStrategy';
import type { Piece } from './Piece';
import { StoreRegistry, type StoreRegistryEntries, type StoreRegistryKey } from './StoreRegistry';
/**
* The options for the store, this features both hooks (changes the behaviour) and handlers (similar to event listeners).
*/
export interface StoreOptions<T extends Piece, StoreName extends StoreRegistryKey = StoreRegistryKey> {
/**
* The name for this store.
*/
readonly name: StoreName;
/**
* The paths to load pieces from, should be absolute.
* @default []
*/
readonly paths?: readonly string[];
/**
* The strategy to be used for the loader.
* @default Store.defaultStrategy
*/
readonly strategy?: ILoaderStrategy<T>;
}
/**
* An interface representing a logger function.
*/
export interface StoreLogger {
/**
* @param value The string to print. All strings will be formatted with the format `[STORE => ${name}] [${type}] ${content}`,
* where the content may have identifiers (values or names of methods) surrounded by `'`. For example:
*
* - `[STORE => commands] [LOAD] Skipped piece '/home/user/bot/src/commands/foo.js' as 'LoaderStrategy#filter' returned 'null'.`
* - `[STORE => commands] [INSERT] Unloaded new piece 'foo' due to 'enabled' being 'false'.`
* - `[STORE => commands] [UNLOAD] Unloaded piece 'foo'.`
*/
(value: string): void;
}
const defaultStrategy = new LoaderStrategy();
/**
* The store class which contains {@link Piece}s.
*/
export class Store<T extends Piece, StoreName extends StoreRegistryKey = StoreRegistryKey> extends Collection<string, T> {
public readonly Constructor: AbstractConstructor<T>;
public readonly name: StoreName;
public readonly paths: Set<string>;
public readonly strategy: ILoaderStrategy<T>;
/**
* The queue of manually registered pieces to load.
*/
private readonly [ManuallyRegisteredPiecesSymbol] = new Map<string, StoreManuallyRegisteredPiece<StoreName>>();
/**
* Whether or not the store has called `loadAll` at least once.
*/
#calledLoadAll = false;
/**
* The walk function for the store.
*/
#walk: LoaderStrategy<T>['walk'];
/**
* @param constructor The piece constructor this store loads.
* @param options The options for the store.
*/
public constructor(constructor: AbstractConstructor<T>, options: StoreOptions<T, StoreName>) {
super();
this.Constructor = constructor;
this.name = options.name as StoreRegistryKey;
this.paths = new Set(options.paths ?? []);
this.strategy = options.strategy ?? Store.defaultStrategy;
this.#walk =
typeof this.strategy.walk === 'function' //
? this.strategy.walk.bind(this.strategy)
: defaultStrategy.walk.bind(defaultStrategy);
}
/**
* A reference to the {@link Container} object for ease of use.
* @see container
*/
public get container(): Container {
return container;
}
/**
* Registers a directory into the store.
* @param path The path to be added.
* @example
* ```typescript
* store
* .registerPath(resolve('commands'))
* .registerPath(resolve('third-party', 'commands'));
* ```
*/
public registerPath(path: Path): this {
const root = resolvePath(path);
this.paths.add(root);
Store.logger?.(`[STORE => ${this.name}] [REGISTER] Registered path '${root}'.`);
return this;
}
/**
* Adds a piece into the store's list of manually registered pieces. If {@linkcode Store.loadAll()} was called, the
* piece will be loaded immediately, otherwise it will be queued until {@linkcode Store.loadAll()} is called.
*
* All manually registered pieces will be kept even after they are loaded to ensure they can be loaded again if
* {@linkcode Store.loadAll()} is called again.
*
* @remarks
*
* - Pieces loaded this way will have their {@linkcode Piece.Context.root root} and
* {@linkcode Piece.Context.path path} set to {@linkcode VirtualPath}, and as such, cannot be reloaded.
* - This method is useful in environments where file system access is limited or unavailable, such as when using
* {@link https://en.wikipedia.org/wiki/Serverless_computing Serverless Computing}.
* - This method will always throw a {@link TypeError} if `entry.piece` is not a class.
* - This method will always throw a {@linkcode LoaderError} if the piece does not extend the
* {@linkcode Store#Constructor store's piece constructor}.
* - This operation is atomic, if any of the above errors are thrown, the piece will not be loaded.
*
* @seealso {@linkcode StoreRegistry.loadPiece()}
* @since 3.8.0
* @param entry The entry to load.
* @example
* ```typescript
* import { container } from '@sapphire/pieces';
*
* class PingCommand extends Command {
* // ...
* }
*
* container.stores.get('commands').loadPiece({
* name: 'ping',
* piece: PingCommand
* });
* ```
*/
public async loadPiece(entry: StoreManuallyRegisteredPiece<StoreName>) {
if (!isClass(entry.piece)) {
throw new TypeError(`The piece ${entry.name} is not a Class. ${String(entry.piece)}`);
}
// If the piece does not extend the store's Piece class, throw an error:
if (!classExtends(entry.piece, this.Constructor as Constructor<T>)) {
throw new LoaderError(LoaderErrorType.IncorrectType, `The piece ${entry.name} does not extend ${this.name}`);
}
this[ManuallyRegisteredPiecesSymbol].set(entry.name, entry);
if (this.#calledLoadAll) {
const piece = this.construct(entry.piece as unknown as Constructor<T>, {
name: entry.name,
root: VirtualPath,
path: VirtualPath,
extension: VirtualPath
});
await this.insert(piece);
}
}
/**
* Loads one or more pieces from a path.
* @param root The root directory the file is from.
* @param path The path of the file to load, relative to the `root`.
* @return All the loaded pieces.
*/
public async load(root: string, path: string): Promise<T[]> {
if (root === VirtualPath) {
throw new LoaderError(LoaderErrorType.VirtualPiece, `Cannot load a virtual file.`);
}
const full = join(root, path);
const data = this.strategy.filter(full);
if (data === null) {
Store.logger?.(`[STORE => ${this.name}] [LOAD] Skipped piece '${full}' as 'LoaderStrategy#filter' returned 'null'.`);
return [];
}
const promises: Promise<T>[] = [];
const finishedData = this.hydrateModuleData(root, data);
for await (const Ctor of this.strategy.load(this, finishedData)) {
promises.push(this.insert(this.construct(Ctor, finishedData)));
}
return Promise.all(promises);
}
/**
* Unloads a piece given its instance or its name.
* @param name The name of the file to load.
* @return Returns the piece that was unloaded.
*/
public async unload(name: string | T): Promise<T> {
const piece = this.resolve(name);
// Unload piece:
this.strategy.onUnload(this, piece);
await piece.onUnload();
Store.logger?.(`[STORE => ${this.name}] [UNLOAD] Unloaded piece '${piece.name}'.`);
// Remove from cache and return it:
this.delete(piece.name);
Store.logger?.(`[STORE => ${this.name}] [UNLOAD] Removed piece '${piece.name}'.`);
return piece;
}
/**
* Unloads all pieces from the store.
*/
public async unloadAll(): Promise<T[]> {
const promises: Promise<T>[] = [];
for (const piece of this.values()) {
promises.push(this.unload(piece));
}
const results = await Promise.all(promises);
this.strategy.onUnloadAll(this);
Store.logger?.(`[STORE => ${this.name}] [UNLOAD-ALL] Removed all pieces.`);
return results;
}
/**
* Loads all pieces from all directories specified by {@link paths}.
*/
public async loadAll(): Promise<void> {
this.#calledLoadAll = true;
const pieces: T[] = [];
for (const entry of this[ManuallyRegisteredPiecesSymbol].values()) {
const piece = this.construct(entry.piece as unknown as Constructor<T>, {
name: entry.name,
root: VirtualPath,
path: VirtualPath,
extension: VirtualPath
});
pieces.push(piece);
}
for (const path of this.paths) {
for await (const piece of this.loadPath(path)) {
pieces.push(piece);
}
}
Store.logger?.(`[STORE => ${this.name}] [LOAD-ALL] Found '${pieces.length}' pieces.`);
// Clear the store before inserting the new pieces:
await this.unloadAll();
Store.logger?.(`[STORE => ${this.name}] [LOAD-ALL] Cleared all pieces.`);
// Load each piece:
for (const piece of pieces) {
await this.insert(piece);
}
// Call onLoadAll:
this.strategy.onLoadAll(this);
Store.logger?.(`[STORE => ${this.name}] [LOAD-ALL] Successfully loaded '${this.size}' pieces.`);
}
/**
* Resolves a piece by its name or its instance.
* @param name The name of the piece or the instance itself.
* @return The resolved piece.
*/
public resolve(name: string | T): T {
if (typeof name === 'string') {
const result = this.get(name);
if (typeof result === 'undefined') throw new LoaderError(LoaderErrorType.UnloadedPiece, `The piece '${name}' does not exist.`);
return result;
}
if (name instanceof this.Constructor) return name;
throw new LoaderError(LoaderErrorType.IncorrectType, `The piece '${name.name}' is not an instance of '${this.Constructor.name}'.`);
}
/**
* Inserts a piece into the store.
* @param piece The piece to be inserted into the store.
* @return The inserted piece.
*/
public async insert(piece: T): Promise<T> {
if (!piece.enabled) return piece;
// Load piece:
this.strategy.onLoad(this, piece);
await piece.onLoad();
Store.logger?.(`[STORE => ${this.name}] [INSERT] Loaded new piece '${piece.name}'.`);
// If the onLoad disabled the piece, call unload and return it:
if (!piece.enabled) {
// Unload piece:
this.strategy.onUnload(this, piece);
await piece.onUnload();
Store.logger?.(`[STORE => ${this.name}] [INSERT] Unloaded new piece '${piece.name}' due to 'enabled' being 'false'.`);
return piece;
}
// Unload existing piece, if any:
const previous = super.get(piece.name);
if (previous) {
await this.unload(previous);
Store.logger?.(`[STORE => ${this.name}] [INSERT] Unloaded existing piece '${piece.name}' due to conflicting 'name'.`);
}
// Set the new piece and return it:
this.set(piece.name, piece);
Store.logger?.(`[STORE => ${this.name}] [INSERT] Inserted new piece '${piece.name}'.`);
return piece;
}
/**
* Constructs a {@link Piece} instance.
* @param Ctor The {@link Piece}'s constructor used to build the instance.
* @param data The module's information
* @return An instance of the constructed piece.
*/
public construct(Ctor: ILoaderResultEntry<T>, data: HydratedModuleData): T {
return new Ctor({ store: this, root: data.root, path: data.path, name: data.name }, { name: data.name, enabled: true });
}
/**
* Adds the final module data properties.
* @param root The root directory to add.
* @param data The module data returned from {@link ILoaderStrategy.filter}.
* @returns The finished module data.
*/
private hydrateModuleData(root: string, data: ModuleData): HydratedModuleData {
return { root, ...data };
}
/**
* Loads a directory into the store.
* @param root The directory to load the pieces from.
* @return An async iterator that yields the pieces to be loaded into the store.
*/
private async *loadPath(root: string): AsyncIterableIterator<T> {
Store.logger?.(`[STORE => ${this.name}] [WALK] Loading all pieces from '${root}'.`);
for await (const child of this.#walk(this, root, Store.logger)) {
const data = this.strategy.filter(child);
if (data === null) {
Store.logger?.(`[STORE => ${this.name}] [LOAD] Skipped piece '${child}' as 'LoaderStrategy#filter' returned 'null'.`);
continue;
}
try {
const finishedData = this.hydrateModuleData(root, data);
for await (const Ctor of this.strategy.load(this, finishedData)) {
yield this.construct(Ctor, finishedData);
}
} catch (error) {
this.strategy.onError(error as Error, data.path);
}
}
}
/**
* The default strategy, defaults to {@link LoaderStrategy}, which is constructed on demand when a store is constructed,
* when none was set beforehand.
*/
public static defaultStrategy: ILoaderStrategy<any> = defaultStrategy;
/**
* The default logger, defaults to `null`.
*/
public static logger: StoreLogger | null = null;
}
/**
* An entry for a manually registered piece using {@linkcode Store.loadPiece()}.
* @since 3.8.0
*/
export interface StoreManuallyRegisteredPiece<StoreName extends StoreRegistryKey> {
name: string;
piece: StoreRegistryEntries[StoreName] extends Store<infer Piece> ? Constructor<Piece> : never;
}
export namespace Store {
export const Registry = StoreRegistry;
export type Options<T extends Piece> = StoreOptions<T>;
export type Logger = StoreLogger;
export type RegistryEntries = StoreRegistryEntries;
}