Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"pg": "^8.16.3",
"prisma": "^7.0.0",
"sharp": "^0.34.0",
"tslog": "^4.9.3"
"tslog": "^4.9.3",
"yaml": "^2.8.3"
},
"peerDependencies": {
"zod": "^4.0.0"
Expand Down
25 changes: 25 additions & 0 deletions pkg/config/adaptor/dummy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';

import { Config } from '../model/config.js';
import { DummyConfigStore } from './dummy.js';

const testConfig = Config.new({
instanceName: 'Pulsate Demo Server',
instanceFqdn: 'demo.pulsate.dev',
openRegistration: true,
maintainerAccount: '@pulsateprj@demo.pulsate.dev',
maintainerEmail: 'contact@pulsate.dev',
});

describe('DummyConfigStore', () => {
it('should return the config', () => {
const store = new DummyConfigStore(testConfig);
const config = store.fetch();

expect(config.getInstanceName()).toBe('Pulsate Demo Server');
expect(config.getInstanceFqdn()).toBe('demo.pulsate.dev');
expect(config.isOpenRegistration()).toBe(true);
expect(config.getMaintainerAccount()).toBe('@pulsateprj@demo.pulsate.dev');
expect(config.getMaintainerEmail()).toBe('contact@pulsate.dev');
});
});
18 changes: 18 additions & 0 deletions pkg/config/adaptor/dummy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Ether } from '@mikuroxina/mini-fn';
import { type ConfigStore, configStoreSymbol } from '../mod.js';
import type { Config } from '../model/config.js';

export class DummyConfigStore implements ConfigStore {
private readonly config: Config;

constructor(config: Config) {
this.config = config;
}

fetch(): Config {
return this.config;
}
}

export const dummyConfigStore = (config: Config) =>
Ether.newEther(configStoreSymbol, () => new DummyConfigStore(config));
64 changes: 64 additions & 0 deletions pkg/config/adaptor/local.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

import { ConfigInvalidError } from '../model/errors.js';
import { LocalConfigStore } from './local.js';

describe('LocalConfigStore', () => {
const createTempConfig = (content: string): string => {
const dir = mkdtempSync(join(tmpdir(), 'pulsate-config-test-'));
const filePath = join(dir, 'config.yaml');
writeFileSync(filePath, content);
return filePath;
};

it('should load config from a valid YAML file', () => {
const filePath = createTempConfig(`
instance_name: "Pulsate Demo Server"
instance_fqdn: "demo.pulsate.dev"
open_registration: true
maintainer_account: "@pulsateprj@demo.pulsate.dev"
maintainer_email: "contact@pulsate.dev"
`);

const store = new LocalConfigStore(filePath);
const config = store.fetch();

expect(config.getInstanceName()).toBe('Pulsate Demo Server');
expect(config.getInstanceFqdn()).toBe('demo.pulsate.dev');
expect(config.isOpenRegistration()).toBe(true);
expect(config.getMaintainerAccount()).toBe('@pulsateprj@demo.pulsate.dev');
expect(config.getMaintainerEmail()).toBe('contact@pulsate.dev');

rmSync(join(filePath, '..'), { recursive: true });
});

it('should throw when file does not exist', () => {
expect(() => new LocalConfigStore('/nonexistent/path.yaml')).toThrow();
});

it('should throw ConfigInvalidError when required field is empty', () => {
const filePath = createTempConfig(`
instance_name: ""
instance_fqdn: "demo.pulsate.dev"
open_registration: true
maintainer_account: "@pulsateprj@demo.pulsate.dev"
maintainer_email: "contact@pulsate.dev"
`);

expect(() => new LocalConfigStore(filePath)).toThrow(ConfigInvalidError);

rmSync(join(filePath, '..'), { recursive: true });
});

it('should throw when YAML is malformed', () => {
const filePath = createTempConfig('{{{invalid yaml');

expect(() => new LocalConfigStore(filePath)).toThrow();

rmSync(join(filePath, '..'), { recursive: true });
});
});
31 changes: 31 additions & 0 deletions pkg/config/adaptor/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';

import { Ether } from '@mikuroxina/mini-fn';
import { parse } from 'yaml';

import { type ConfigStore, configStoreSymbol } from '../mod.js';
import { type AccountName, Config } from '../model/config.js';

export class LocalConfigStore implements ConfigStore {
private readonly config: Config;

constructor(filePath: string) {
const content = readFileSync(filePath, 'utf-8');
const data = parse(content);

this.config = Config.new({
instanceName: data.instance_name,
instanceFqdn: data.instance_fqdn,
openRegistration: data.open_registration,
maintainerAccount: data.maintainer_account as AccountName,
maintainerEmail: data.maintainer_email,
});
}

fetch(): Config {
return this.config;
}
}

export const localConfigStore = (filePath: string) =>
Ether.newEther(configStoreSymbol, () => new LocalConfigStore(filePath));
8 changes: 8 additions & 0 deletions pkg/config/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Ether } from '@mikuroxina/mini-fn';

import type { Config } from './model/config.js';

export interface ConfigStore {
fetch(): Config;
}
export const configStoreSymbol = Ether.newEtherSymbol<ConfigStore>();
48 changes: 48 additions & 0 deletions pkg/config/model/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';

import { Config } from './config.js';
import { ConfigInvalidError } from './errors.js';

const validArgs = {
instanceName: 'Pulsate Demo Server',
instanceFqdn: 'demo.pulsate.dev',
openRegistration: true,
maintainerAccount: '@pulsateprj@demo.pulsate.dev' as const,
maintainerEmail: 'contact@pulsate.dev',
};

describe('Config', () => {
it('should create config with valid args', () => {
const config = Config.new(validArgs);

expect(config.getInstanceName()).toBe('Pulsate Demo Server');
expect(config.getInstanceFqdn()).toBe('demo.pulsate.dev');
expect(config.isOpenRegistration()).toBe(true);
expect(config.getMaintainerAccount()).toBe('@pulsateprj@demo.pulsate.dev');
expect(config.getMaintainerEmail()).toBe('contact@pulsate.dev');
});

it('should throw when instanceName is empty', () => {
expect(() => Config.new({ ...validArgs, instanceName: '' })).toThrow(
ConfigInvalidError,
);
});

it('should throw when instanceFqdn is empty', () => {
expect(() => Config.new({ ...validArgs, instanceFqdn: '' })).toThrow(
ConfigInvalidError,
);
});

it('should throw when maintainerAccount is invalid format', () => {
expect(() =>
Config.new({ ...validArgs, maintainerAccount: '@@' as const }),
).toThrow(ConfigInvalidError);
});

it('should throw when maintainerEmail is empty', () => {
expect(() => Config.new({ ...validArgs, maintainerEmail: '' })).toThrow(
ConfigInvalidError,
);
});
});
76 changes: 76 additions & 0 deletions pkg/config/model/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ConfigInvalidError } from './errors.js';

export type AccountName = `@${string}@${string}`;

export interface CreateConfigArgs {
instanceName: string;
instanceFqdn: string;
openRegistration: boolean;
maintainerAccount: AccountName;
maintainerEmail: string;
}

export class Config {
private constructor(arg: CreateConfigArgs) {
this.instanceName = arg.instanceName;
this.instanceFqdn = arg.instanceFqdn;
this.openRegistration = arg.openRegistration;
this.maintainerAccount = arg.maintainerAccount;
this.maintainerEmail = arg.maintainerEmail;
}

private readonly instanceName: string;
getInstanceName(): string {
return this.instanceName;
}

private readonly instanceFqdn: string;
getInstanceFqdn(): string {
return this.instanceFqdn;
}

private readonly openRegistration: boolean;
isOpenRegistration(): boolean {
return this.openRegistration;
}

private readonly maintainerAccount: AccountName;
getMaintainerAccount(): AccountName {
return this.maintainerAccount;
}

private readonly maintainerEmail: string;
getMaintainerEmail(): string {
return this.maintainerEmail;
}

public static new(arg: CreateConfigArgs): Config {
if (arg.instanceName === '') {
throw new ConfigInvalidError('instanceName is required', {
cause: arg.instanceName,
});
}
if (arg.instanceFqdn === '') {
throw new ConfigInvalidError('instanceFqdn is required', {
cause: arg.instanceFqdn,
});
}

// validate maintainerAccount: must be `@user@host` with non-empty parts
const parts = arg.maintainerAccount.split('@').filter(Boolean);
if (parts.length !== 2) {
throw new ConfigInvalidError(
'maintainerAccount must be in @user@host format',
{ cause: arg.maintainerAccount },
);
}

if (arg.maintainerEmail === '') {
throw new ConfigInvalidError('maintainerEmail is required', {
cause: arg.maintainerEmail,
});
}

return new Config(arg);
}
}
7 changes: 7 additions & 0 deletions pkg/config/model/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class ConfigInvalidError extends Error {
constructor(message: string, options: { cause: unknown }) {
super(message);
this.name = 'ConfigInvalidError';
this.cause = options.cause;
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading