From d7fc3aaec4fce86fa01e556461cd2d6f7775fa2e Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:53:13 +0000 Subject: [PATCH] Replace bootstrap methods with async factories Controller.bootstrap and Host.bootstrap returned constructor arguments that the caller had to spread into new Controller/Host, and DatastoreProvider.bootstrap did the same for datastores. They are replaced by Controller.fromConfig, Host.fromConfig and KeyValueDatastore/SubscribableDatastore.fromProvider, which load the data and return the finished instance. Closes #1126 Co-Authored-By: Claude Opus 5.5 --- packages/controller/controller.ts | 11 +++--- packages/controller/src/Controller.ts | 50 +++++++++++++++++---------- packages/host/host.ts | 3 +- packages/host/src/Host.ts | 14 +++++--- packages/lib/src/datastore.ts | 15 +++++--- test/controller/Controller.js | 32 +++++++++++++++++ test/host/Host.test.js | 15 ++++++++ test/lib/datastore.js | 50 ++++++++++++++++----------- 8 files changed, 133 insertions(+), 57 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index a2efeb12..bc57c1db 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -147,15 +147,15 @@ async function handleBootstrapCommand( path.join(databaseDirectory, "roles.json"), ); - const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "roles.json"), lib.Role.fromJSON.bind(lib.Role), - ).bootstrap()); + )); - const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "users.json"), UserRecord.fromJSON.bind(UserRecord), - ).bootstrap()); + )); const userManager = new UserManager(users, roles, controllerConfig); @@ -474,13 +474,12 @@ async function startup() { return; } - controller = new Controller( + controller = await Controller.fromConfig( clusterLogger, pluginInfos, controllerConfig, Boolean(args.canRestart), Boolean(args.recovery), - ...await Controller.bootstrap(controllerConfig) ); // Refuse to start if there are no users loaded diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 84404f28..45a16a00 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -148,7 +148,14 @@ export default class Controller { } }); - static async bootstrap(config: lib.ControllerConfig) { + /** Create a controller with its datastores and mod store loaded from disk */ + static async fromConfig( + clusterLogger: winston.Logger, + pluginInfos: lib.PluginNodeEnvInfo[], + config: lib.ControllerConfig, + canRestart = false, + recoveryMode = false, + ) { let databaseDirectory = config.get("controller.database_directory"); await fs.mkdir(databaseDirectory, { recursive: true }); @@ -158,23 +165,23 @@ export default class Controller { path.join(databaseDirectory, "roles.json"), ); - const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const systems = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "systems.json"), lib.SystemInfo.fromJSON.bind(lib.SystemInfo), this.migrateSystems, - ).bootstrap()); + )); - const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const hosts = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "hosts.json"), HostRecord.fromJSON.bind(HostRecord), this.migrateHosts, this.finaliseHosts, - ).bootstrap()); + )); - const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const instances = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "instances.json"), InstanceRecord.fromJSON.bind(InstanceRecord), this.migrateInstances, this.finaliseInstances, - ).bootstrap()); + )); // Save plugin versions written while loading for (const instance of instances.values()) { if (instance.config.dirty) { @@ -182,33 +189,33 @@ export default class Controller { } } - const saves = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const saves = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "saves.json"), lib.SaveDetails.fromJSON.bind(lib.SaveDetails) - ).bootstrap()); + )); - const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const modPacks = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "mod-packs.json"), lib.ModPack.fromJSON.bind(lib.ModPack), this.migrateModPacks, - ).bootstrap()); + )); - const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "roles.json"), lib.Role.fromJSON.bind(lib.Role), this.migrateRoles, - ).bootstrap()); + )); - const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "users.json"), UserRecord.fromJSON.bind(UserRecord), undefined, this.finaliseUsers, - ).bootstrap()); + )); - const notes = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + const notes = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "notes.json"), lib.Note.fromJSON.bind(lib.Note), - ).bootstrap()); + )); let modsDirectory = config.get("controller.mods_directory"); await fs.mkdir(modsDirectory, { recursive: true }); @@ -223,7 +230,12 @@ export default class Controller { lib.Role.ensureDefaultAdminRole(roles); lib.Role.ensureDefaultPlayerRole(roles); - return [ + return new this( + clusterLogger, + pluginInfos, + config, + canRestart, + recoveryMode, systems, hosts, instances, @@ -233,7 +245,7 @@ export default class Controller { roles, users, notes, - ] as const; + ); } constructor( diff --git a/packages/host/host.ts b/packages/host/host.ts index da2ae862..566232ca 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -233,13 +233,12 @@ async function startHost() { } let hostConnector = new HostConnector(hostConfig, pluginInfos); - host = new Host( + host = await Host.fromConfig( hostConnector, hostConfig, pluginInfos, Boolean(args.canRestart), Boolean(args.recovery), - ...await Host.bootstrap(hostConfig) ); // Handle interrupts diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index f5ce4ad3..8d89c75a 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -329,12 +329,18 @@ export default class Host extends lib.Link { "_warning": "Changes to this file will be overwritten by the controller's copy.", }; - static async bootstrap(hostConfig: lib.HostConfig) { + /** Create a host with its mod store loaded from disk */ + static async fromConfig( + connector: HostConnector, + hostConfig: lib.HostConfig, + pluginInfos: lib.PluginNodeEnvInfo[], + canRestart = false, + recoveryMode = false, + ) { const modsDirectory = hostConfig.get("host.mods_directory"); await fs.mkdir(modsDirectory, { recursive: true }); - return [ - await lib.ModStore.fromDirectory(modsDirectory), - ] as const; + const modStore = await lib.ModStore.fromDirectory(modsDirectory); + return new this(connector, hostConfig, pluginInfos, canRestart, recoveryMode, modStore); } constructor( diff --git a/packages/lib/src/datastore.ts b/packages/lib/src/datastore.ts index 23c294e0..c5f58282 100644 --- a/packages/lib/src/datastore.ts +++ b/packages/lib/src/datastore.ts @@ -17,11 +17,6 @@ export abstract class DatastoreProvider< > { abstract save(data: Map): Promise abstract load(): Promise> - - // Load data from the source and return it, this should be passed to the constructor of a datastore - async bootstrap() { - return [this, await this.load()] as const; - } } // Store all data in memory, used as a default and should be avoided @@ -251,6 +246,11 @@ export class KeyValueDatastore< K extends DatastoreKey, V extends DatastoreValue, > extends Datastore { + // Create a datastore populated with the data loaded from the provider + static async fromProvider(provider: DatastoreProvider) { + return new this(provider, await provider.load()); + } + // Set the value in the datastore, be careful of race conditions if you await any functions before calling set set(key: K, value: V) { this.data.set(key, value); @@ -316,6 +316,11 @@ export class SubscribableDatastore< this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values()); } + // Create a datastore populated with the data loaded from the provider + static async fromProvider(provider: DatastoreProvider) { + return new this(provider, await provider.load()); + } + async load() { await super.load(); this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values()); diff --git a/test/controller/Controller.js b/test/controller/Controller.js index 7294ba94..76eec466 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -57,6 +57,38 @@ describe("controller/src/Controller", function() { assert(list.check(ip), `${ip} missing from list`); } } + describe(".fromConfig()", function() { + let dir; + beforeEach(async function() { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-controller-")); + }); + afterEach(async function() { + await fs.rm(dir, { recursive: true, force: true }); + }); + it("should load datastores and mods from disk", async function() { + const databaseDirectory = path.join(dir, "database"); + const modsDirectory = path.join(dir, "mods"); + await fs.mkdir(databaseDirectory); + await fs.writeFile(path.join(databaseDirectory, "notes.json"), JSON.stringify([ + { resourceType: "instance", resourceId: "100", content: "hello" }, + ])); + const config = new ControllerConfig("controller", { + "controller.version": controllerVersion, + "controller.database_directory": databaseDirectory, + "controller.mods_directory": modsDirectory, + }); + + const loaded = await Controller.fromConfig({}, [], config, true, false); + assert(loaded instanceof Controller); + assert.equal(loaded.canRestart, true); + assert.equal(loaded.recoveryMode, false); + assert.equal(loaded.notes.get("instance/100").content, "hello"); + assert.equal(loaded.modStore.modsDirectory, modsDirectory); + assert(loaded.roles.size > 0, "default roles were not added"); + assert(loaded.modPacks.size > 0, "default mod packs were not added"); + await fs.access(modsDirectory); + }); + }); describe(".loadJsonObject()", function() { let dir; before(async function() { diff --git a/test/host/Host.test.js b/test/host/Host.test.js index e442bc76..9bcfb32c 100644 --- a/test/host/Host.test.js +++ b/test/host/Host.test.js @@ -62,6 +62,21 @@ describe("host/src/Host", function() { }); }); describe("class Host", function() { + describe(".fromConfig()", function() { + const modsDirectory = path.join("temp", "test", "host_from_config", "mods"); + beforeEach(async function() { + await fs.rm(path.dirname(modsDirectory), { recursive: true, force: true }); + }); + it("should create the mods directory and load the mod store", async function() { + const hostConfig = new lib.HostConfig("host", { "host.mods_directory": modsDirectory }); + const host = await Host.fromConfig(new HostConnector(hostConfig, []), hostConfig, [], true, false); + assert(host instanceof Host); + assert.equal(host.canRestart, true); + assert.equal(host.recoveryMode, false); + assert.equal(host.modStore.modsDirectory, modsDirectory); + await fs.access(modsDirectory); + }); + }); describe(".handleSyncUserListsEvent()", function() { const hostAddress = lib.Address.fromShorthand({ hostId: 1 }); const instanceAddress = lib.Address.fromShorthand({ instanceId: 1 }); diff --git a/test/lib/datastore.js b/test/lib/datastore.js index 322450aa..4c829e76 100644 --- a/test/lib/datastore.js +++ b/test/lib/datastore.js @@ -50,13 +50,6 @@ describe("lib/datastore", function() { it("can be constructed", function() { assert.deepEqual(datastoreProvider.methodCalls[0], ["constructor"]); }); - - it("can be bootstrapped", async function() { - const rtn = await datastoreProvider.bootstrap(); - assert.equal(rtn[0], datastoreProvider); - assert.equal(rtn[1], datastoreProvider.value); - assert.deepEqual(datastoreProvider.methodCalls[1], ["load"]); - }); }); describe("class MemoryDatastoreProvider", function() { @@ -70,13 +63,13 @@ describe("lib/datastore", function() { describe("constructor", function() { it("has a default value", async function() { - const [, value] = await new lib.MemoryDatastoreProvider().bootstrap(); + const value = await new lib.MemoryDatastoreProvider().load(); assert.notEqual(value, undefined); assert.notEqual(value, null); }); it("can have a value given", async function() { const expectedValue = new Map([["foo", "bar"]]); - const [, value] = await new lib.MemoryDatastoreProvider(expectedValue).bootstrap(); + const value = await new lib.MemoryDatastoreProvider(expectedValue).load(); assert.deepEqual(value, expectedValue); }); }); @@ -116,7 +109,7 @@ describe("lib/datastore", function() { it("has default values", async function() { datastoreProvider = new lib.JsonDatastoreProvider(dataPath); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map(initialData)); }); it("can accept a fromJson method", async function() { @@ -125,7 +118,7 @@ describe("lib/datastore", function() { json => [json] ); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map(initialData.map(([k, v]) => [k, [v]]))); }); it("can accept a migration method", async function() { @@ -135,7 +128,7 @@ describe("lib/datastore", function() { rawJson => ({ c: "foo", ...rawJson }) ); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map([...initialData, ["c", "foo"]])); }); it("can accept a finalise method", async function() { @@ -146,7 +139,7 @@ describe("lib/datastore", function() { obj => [obj] ); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map(initialData.map(([k, v]) => [k, [v]]))); }); }); @@ -210,7 +203,7 @@ describe("lib/datastore", function() { it("has default values", async function() { datastoreProvider = new lib.JsonIdDatastoreProvider(dataPath); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map(initialData)); }); it("can accept a fromJson method", async function() { @@ -219,7 +212,7 @@ describe("lib/datastore", function() { json => ({...json, foo: true}) ); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map(initialData.map(v => ([v[0], {...v[1], foo: true}])))); }); it("can accept a migration method", async function() { @@ -229,7 +222,7 @@ describe("lib/datastore", function() { rawJson => ([{ id: "c" }, ...rawJson]) ); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map([...initialData, ["c", { id: "c" }]])); }); it("can accept a finalise method", async function() { @@ -240,7 +233,7 @@ describe("lib/datastore", function() { obj => ({...obj, foo: true}) ); - const [, value] = await datastoreProvider.bootstrap(); + const value = await datastoreProvider.load(); assert.deepEqual(value, new Map(initialData.map(v => ([v[0], {...v[1], foo: true}])))); }); }); @@ -306,10 +299,6 @@ describe("lib/datastore", function() { datastore = new MockDatastore(datastoreProvider, datastoreProvider.value); assert.deepEqual(datastore.getData(), datastoreProvider.value); }); - it("accepts the return of bootstrap", async function() { - datastore = new MockDatastore(...await datastoreProvider.bootstrap()); - assert.deepEqual(datastore.getData(), datastoreProvider.value); - }); }); describe("getFilePath", function() { @@ -478,6 +467,15 @@ describe("lib/datastore", function() { datastore.on("update", updates => eventsRaised.push(updates)); }); + describe("fromProvider", function() { + it("loads data from the provider", async function() { + datastore = await lib.KeyValueDatastore.fromProvider(datastoreProvider); + assert(datastore instanceof lib.KeyValueDatastore); + assert.equal(datastore.get("foo"), "fooValue"); + assert.deepEqual(datastoreProvider.methodCalls[1], ["load"]); + }); + }); + describe("set", function() { it("can set a value", function() { datastore.set("test", "testValue"); @@ -571,6 +569,16 @@ describe("lib/datastore", function() { datastore.on("update", updates => eventsRaised.push(updates)); }); + describe("fromProvider", function() { + it("loads data from the provider", async function() { + datastore = await lib.SubscribableDatastore.fromProvider(datastoreProvider); + assert(datastore instanceof lib.SubscribableDatastore); + assert.equal(datastore.get("foo").id, "foo"); + assert.equal(datastore.lastUpdatedAtMs, 10); + assert.deepEqual(datastoreProvider.methodCalls[1], ["load"]); + }); + }); + function forceFuture() { const future = {id: "future"}; for (let i = 1; i < 100; i++) {