Skip to content

Commit

Permalink
refactor: add virtual file reload support
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet committed Nov 13, 2023
1 parent 16e6eda commit fd27655
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 64 deletions.
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import 'tslib';

export * from './lib/errors/LoaderError';
export * from './lib/errors/MissingExportsError';
export * from './lib/internal/constants';
export * from './lib/internal/RootScan';
export { VirtualPath } from './lib/internal/constants';
export * from './lib/shared/Container';
export * from './lib/strategies/ILoaderStrategy';
export * from './lib/strategies/LoaderStrategy';
Expand Down
1 change: 1 addition & 0 deletions src/lib/internal/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const VirtualPath = '::virtual::';
export const ManuallyRegisteredPiecesSymbol = Symbol('@sapphire/pieces:ManuallyRegisteredPieces');
97 changes: 92 additions & 5 deletions src/lib/structures/Store.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Collection } from '@discordjs/collection';
import type { AbstractConstructor } from '@sapphire/utilities';
import { Constructor, classExtends, isClass, type AbstractConstructor } from '@sapphire/utilities';
import { promises as fsp } from 'fs';
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 } from './StoreRegistry';
import { VirtualPath } from '../internal/constants';

/**
* The options for the store, this features both hooks (changes the behaviour) and handlers (similar to event listeners).
Expand Down Expand Up @@ -51,20 +51,30 @@ export interface StoreLogger {
/**
* The store class which contains {@link Piece}s.
*/
export class Store<T extends Piece> extends Collection<string, T> {
export class Store<T extends Piece, StoreName extends keyof StoreRegistryEntries = keyof StoreRegistryEntries> extends Collection<string, T> {
public readonly Constructor: AbstractConstructor<T>;
public readonly name: string;
public readonly name: keyof StoreRegistryEntries;
public readonly paths: Set<string>;
public readonly strategy: ILoaderStrategy<T>;

/**
* The queue of manually registered pieces to load.
*/
private readonly [ManuallyRegisteredPiecesSymbol]: StoreManuallyRegisteredPiece<StoreName>[] = [];

/**
* Whether or not the store has called `loadAll` at least once.
*/
#calledLoadAll = false;

/**
* @param constructor The piece constructor this store loads.
* @param options The options for the store.
*/
public constructor(constructor: AbstractConstructor<T>, options: StoreOptions<T>) {
super();
this.Constructor = constructor;
this.name = options.name;
this.name = options.name as keyof StoreRegistryEntries;
this.paths = new Set(options.paths ?? []);
this.strategy = options.strategy ?? Store.defaultStrategy;
}
Expand Down Expand Up @@ -95,6 +105,63 @@ export class Store<T extends Piece> extends Collection<string, T> {
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].push(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.
Expand Down Expand Up @@ -161,7 +228,18 @@ export class Store<T extends Piece> extends Collection<string, T> {
* 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]) {
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)) {
Expand Down Expand Up @@ -314,6 +392,15 @@ export class Store<T extends Piece> extends Collection<string, T> {
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 keyof StoreRegistryEntries> {
name: string;
piece: StoreRegistryEntries[StoreName] extends Store<infer Piece> ? Constructor<Piece> : never;
}

type ErrorWithCode = Error & { code: string };

export namespace Store {
Expand Down
112 changes: 54 additions & 58 deletions src/lib/structures/StoreRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Collection } from '@discordjs/collection';
import { Constructor, classExtends, isClass } from '@sapphire/utilities';
import { join } from 'path';
import { LoaderError, LoaderErrorType } from '../errors/LoaderError';
import { LoaderError } from '../errors/LoaderError';
import { resolvePath, type Path } from '../internal/Path';
import { getRootData } from '../internal/RootScan';
import { VirtualPath } from '../internal/constants';
import { ManuallyRegisteredPiecesSymbol, VirtualPath } from '../internal/constants';
import type { Piece } from './Piece';
import type { Store } from './Store';
import type { Store, StoreManuallyRegisteredPiece } from './Store';
import { isClass } from '@sapphire/utilities';

type Key = keyof StoreRegistryEntries;
type Value = StoreRegistryEntries[Key];
Expand Down Expand Up @@ -35,28 +35,17 @@ export class StoreRegistry extends Collection<Key, Value> {
/**
* The queue of pieces to load.
*/
readonly #loadQueue: StoreLoadPieceQueueEntry<keyof StoreRegistryEntries>[] = [];
/**
* Whether or not the registry is loaded.
*/
#calledLoad = false;
readonly #pendingManuallyRegisteredPieces = new Collection<
keyof StoreRegistryEntries,
StoreManuallyRegisteredPiece<keyof StoreRegistryEntries>[]
>();

/**
* Loads all the registered stores.
* @since 2.1.0
*/
public async load() {
this.#calledLoad = true;

const promises: Promise<unknown>[] = [];

// Load the queue first
for (const entry of this.#loadQueue) {
promises.push(this.#loadQueueEntry(entry));
}
this.#loadQueue.length = 0;

// Load from FS
for (const store of this.values() as IterableIterator<Store<Piece>>) {
promises.push(store.loadAll());
}
Expand Down Expand Up @@ -97,11 +86,38 @@ export class StoreRegistry extends Collection<Key, Value> {

/**
* Registers a store.
*
* @remarks
*
* - This method will allow {@linkcode StoreRegistry} to manage the store, meaning:
* - {@linkcode StoreRegistry.registerPath()} will call the store's
* {@linkcode Store.registerPath() registerPath()} method on call.
* - {@linkcode StoreRegistry.load()} will call the store's {@linkcode Store.load() load()} method on call.
* - {@linkcode StoreRegistry.loadPiece()} will call the store's {@linkcode Store.loadPiece() loadPiece()} method
* on call.
* - This will also add all the manually registered pieces by {@linkcode StoreRegistry.loadPiece()} in the store.
*
* It is generally recommended to register a store as early as possible, before any of the aforementioned methods
* are called, otherwise you will have to manually call the aforementioned methods for the store to work properly.
*
* If there were manually registered pieces for this store with {@linkcode StoreRegistry.loadPiece()}, this method
* will add them to the store and delete the queue. Note, however, that this method will not call the store's
* {@linkcode Store.loadPiece() loadPiece()} method, and as such, the pieces will not be loaded until
* {@linkcode Store.loadAll()} is called.
*
* @since 2.1.0
* @param store The store to register.
*/
public register<T extends Piece>(store: Store<T>): this {
this.set(store.name as Key, store as unknown as Value);

// If there was a queue for this store, add it to the store and delete the queue:
const queue = this.#pendingManuallyRegisteredPieces.get(store.name);
if (queue) {
store[ManuallyRegisteredPiecesSymbol].push(...queue);
this.#pendingManuallyRegisteredPieces.delete(store.name);
}

return this;
}

Expand All @@ -116,9 +132,8 @@ export class StoreRegistry extends Collection<Key, Value> {
}

/**
* If the registry's {@linkcode StoreRegistry.load()} method wasn't called yet, this method validates whether or not
* `entry.piece` is a class, throwing a {@link TypeError} if it isn't, otherwise it will queue the entry for later
* when {@linkcode StoreRegistry.load()} is called.
* If the store was {@link StoreRegistry.register registered}, this method will call the store's
* {@linkcode Store.loadPiece() loadPiece()} method.
*
* If it was called, the entry will be loaded immediately without queueing.
*
Expand All @@ -127,12 +142,15 @@ export class StoreRegistry extends Collection<Key, Value> {
* - 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}.
* - The loaded method will throw a {@linkcode LoaderError} if:
* - The store does not exist.
* - The piece does not extend the {@linkcode Store.Constructor store's piece constructor}.
* {@link https://en.wikipedia.org/wiki/Serverless_computing Serverless Computing}.
* - This method will not throw an error if a store with the given name does not exist, it will simply be queued
* until it's registered.
* - This method will always throw a {@link TypeError} if `entry.piece` is not a class.
* - If the store is registered, this method will always throw a {@linkcode LoaderError} if the piece does not
* extend the registered {@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 Store.loadPiece()}
* @since 3.8.0
* @param entry The entry to load.
* @example
Expand All @@ -150,38 +168,18 @@ export class StoreRegistry extends Collection<Key, Value> {
* });
* ```
*/
public async loadPiece<StoreName extends keyof StoreRegistryEntries>(entry: StoreLoadPieceQueueEntry<StoreName>) {
if (!isClass(entry.piece)) {
throw new TypeError(`The piece ${entry.name} is not a Class. ${String(entry.piece)}`);
}
public async loadPiece<StoreName extends keyof StoreRegistryEntries>(entry: StoreManagerManuallyRegisteredPiece<StoreName>) {
const store = this.get(entry.store) as Store<Piece, StoreName> | undefined;

if (this.#calledLoad) {
await this.#loadQueueEntry(entry);
if (store) {
await store.loadPiece(entry);
} else {
this.#loadQueue.push(entry);
}
}

/**
* Loads a {@link StoreLoadPieceQueueEntry}.
* @param entry The entry to load.
* @returns The loaded piece.
*/
#loadQueueEntry<StoreName extends keyof StoreRegistryEntries>(entry: StoreLoadPieceQueueEntry<StoreName>) {
const store = this.get(entry.store) as Store<Piece> | undefined;
if (!isClass(entry.piece)) {
throw new TypeError(`The piece ${entry.name} is not a Class. ${String(entry.piece)}`);
}

// If the store does not exist, throw an error:
if (!store) {
throw new LoaderError(LoaderErrorType.UnknownStore, `The store ${entry.store} does not exist.`);
this.#pendingManuallyRegisteredPieces.ensure(entry.store, () => []).push({ name: entry.name, piece: entry.piece });
}

// If the piece does not extend the store's Piece class, throw an error:
if (!classExtends(entry.piece, store.Constructor as Constructor<Piece>)) {
throw new LoaderError(LoaderErrorType.IncorrectType, `The piece ${entry.name} does not extend ${store.name}`);
}

const piece = store.construct(entry.piece, { name: entry.name, root: VirtualPath, path: VirtualPath, extension: VirtualPath });
return store.insert(piece);
}
}

Expand All @@ -199,12 +197,10 @@ export interface StoreRegistry {
export interface StoreRegistryEntries {}

/**
* The {@link StoreRegistry}'s load entry, use module augmentation against this interface when adding new stores.
* An entry for a manually registered piece using {@linkcode StoreRegistry.loadPiece()}.
* @seealso {@linkcode StoreRegistry.loadPiece()}
* @since 3.8.0
*/
export interface StoreLoadPieceQueueEntry<StoreName extends keyof StoreRegistryEntries> {
export interface StoreManagerManuallyRegisteredPiece<StoreName extends keyof StoreRegistryEntries> extends StoreManuallyRegisteredPiece<StoreName> {
store: StoreName;
name: string;
piece: StoreRegistryEntries[StoreName] extends Store<infer Piece> ? Constructor<Piece> : never;
}

0 comments on commit fd27655

Please sign in to comment.