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 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-23 23:51:22 +00:00
1 parent c3abf27266
commit d7fc3aaec4
8 files changed
+133 -57

No files matched your search

+5 -6
View File
@@ -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
+31 -19
View File
@@ -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(
+1 -2
View File
@@ -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
+10 -4
View File
@@ -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(
+10 -5
View File
@@ -17,11 +17,6 @@ export abstract class DatastoreProvider<
> {
abstract save(data: Map<K, V>): Promise<void>
abstract load(): Promise<Map<K, V>>
// 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<K, V, [key: K, value: V, isDeleted?: boolean]> {
// Create a datastore populated with the data loaded from the provider
static async fromProvider<K extends DatastoreKey, V extends DatastoreValue>(provider: DatastoreProvider<K, V>) {
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<V extends SubscribableValue>(provider: DatastoreProvider<V["id"], V>) {
return new this(provider, await provider.load());
}
async load() {
await super.load();
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
+32
View File
@@ -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() {
+15
View File
@@ -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 });
+29 -21
View File
@@ -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++) {