refactor: group domain services into folders
All checks were successful
npm release / verify (push) Successful in 14s
npm release / publish to npm (push) Successful in 12s

This commit is contained in:
2026-05-11 19:38:02 +09:00
parent 684b6af5be
commit baea23b8b0
14 changed files with 315 additions and 146 deletions

306
src/availability/index.ts Normal file
View File

@@ -0,0 +1,306 @@
import { randomUUID } from 'node:crypto';
import type { Fact, IdentityDB, JsonValue } from 'identitydb';
import { dateOnly, getFactDomain, getJsonObject, jsonObject, listFactsInSpace } from '../core/facts';
import { persistFactDrafts } from '../memory';
import type {
BoxBrainAvailabilityEntry,
BoxBrainAvailabilityMode,
BoxBrainAvailabilitySnapshot,
BoxBrainAvailabilitySourceType,
} from '../core/types';
export interface SetAvailabilityStatusInput {
spaceName: string;
mode: BoxBrainAvailabilityMode;
reason?: string | undefined;
effectiveFrom: string;
until?: string | undefined;
sourceType?: Exclude<BoxBrainAvailabilitySourceType, 'default'> | undefined;
eventId?: string | undefined;
metadata?: JsonValue | null | undefined;
}
export interface GetAvailabilitySnapshotInput {
spaceName: string;
at: string;
}
export interface ListAvailabilityEntriesInput {
spaceName: string;
}
const EXPLICIT_SOURCE_PRIORITY: Record<Exclude<BoxBrainAvailabilitySourceType, 'default'>, number> = {
schedule: 1,
manual: 2,
tool: 3,
};
export class AvailabilityService {
constructor(private readonly db: IdentityDB) {}
async setStatus(input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
return setAvailabilityStatusWithDb(this.db, input);
}
async listEntries(input: ListAvailabilityEntriesInput): Promise<BoxBrainAvailabilityEntry[]> {
return listAvailabilityEntriesWithDb(this.db, input);
}
async getSnapshot(input: GetAvailabilitySnapshotInput): Promise<BoxBrainAvailabilitySnapshot> {
return getAvailabilitySnapshotWithDb(this.db, input);
}
}
export async function setAvailabilityStatus(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
return new AvailabilityService(db).setStatus(input);
}
async function setAvailabilityStatusWithDb(db: IdentityDB, input: SetAvailabilityStatusInput): Promise<BoxBrainAvailabilityEntry> {
assertAvailabilityMode(input.mode);
assertChronology(input.effectiveFrom, input.until);
const statusId = randomUUID();
const sourceType = input.sourceType ?? 'manual';
const [fact] = await persistFactDrafts(db, {
spaceName: input.spaceName,
domain: 'persona.availability',
source: `boxbrain:availability.${sourceType}`,
facts: [
{
statement: buildAvailabilityStatement(input.mode, input.reason, input.effectiveFrom, input.until),
metadata: jsonObject({
availabilityStatus: jsonObject({
id: statusId,
mode: input.mode,
reason: input.reason,
effectiveFrom: input.effectiveFrom,
until: input.until,
sourceType,
eventId: input.eventId,
customMetadata: input.metadata ?? null,
}),
}),
topics: [
{ name: dateOnly(input.effectiveFrom), category: 'temporal' },
{ name: input.mode, category: 'concept' },
{ name: 'availability', category: 'concept' },
],
},
],
});
const parsed = fact ? parseAvailabilityFact(fact) : null;
if (!parsed) {
throw new Error('Failed to persist BoxBrain availability status.');
}
return parsed;
}
export async function listAvailabilityEntries(
db: IdentityDB,
input: ListAvailabilityEntriesInput,
): Promise<BoxBrainAvailabilityEntry[]> {
return new AvailabilityService(db).listEntries(input);
}
async function listAvailabilityEntriesWithDb(
db: IdentityDB,
input: ListAvailabilityEntriesInput,
): Promise<BoxBrainAvailabilityEntry[]> {
const facts = await listFactsInSpace(db, input.spaceName);
const deletedScheduleEventIds = collectDeletedScheduleEventIds(facts);
return facts
.map(parseAvailabilityFact)
.filter((entry): entry is BoxBrainAvailabilityEntry => entry !== null)
.filter((entry) => entry.sourceType !== 'schedule' || !entry.eventId || !deletedScheduleEventIds.has(entry.eventId))
.sort(compareAvailabilityEntries);
}
export async function getAvailabilitySnapshot(
db: IdentityDB,
input: GetAvailabilitySnapshotInput,
): Promise<BoxBrainAvailabilitySnapshot> {
return new AvailabilityService(db).getSnapshot(input);
}
async function getAvailabilitySnapshotWithDb(
db: IdentityDB,
input: GetAvailabilitySnapshotInput,
): Promise<BoxBrainAvailabilitySnapshot> {
const entries = await listAvailabilityEntries(db, { spaceName: input.spaceName });
const current = selectAvailabilityAt(entries, input.at) ?? createDefaultOnlineAvailability(input.at);
const next = selectNextAvailabilityTransition(entries, input.at, current);
return { current, next };
}
function selectAvailabilityAt(entries: BoxBrainAvailabilityEntry[], at: string): BoxBrainAvailabilityEntry | null {
const atMs = Date.parse(at);
const applicable = entries.filter((entry) => {
const startMs = Date.parse(entry.effectiveFrom);
const endMs = entry.until ? Date.parse(entry.until) : Number.POSITIVE_INFINITY;
return Number.isFinite(startMs) && atMs >= startMs && atMs < endMs;
});
if (applicable.length === 0) {
return null;
}
applicable.sort((left, right) => {
const priority = priorityOf(right.sourceType) - priorityOf(left.sourceType);
if (priority !== 0) {
return priority;
}
if (right.effectiveFrom !== left.effectiveFrom) {
return right.effectiveFrom.localeCompare(left.effectiveFrom);
}
return (right.createdAt ?? '').localeCompare(left.createdAt ?? '');
});
return applicable[0] ?? null;
}
function selectNextAvailabilityTransition(
entries: BoxBrainAvailabilityEntry[],
at: string,
current: BoxBrainAvailabilityEntry,
): BoxBrainAvailabilityEntry | null {
const atMs = Date.parse(at);
const transitionTimes = Array.from(new Set(
entries.flatMap((entry) => [Date.parse(entry.effectiveFrom), entry.until ? Date.parse(entry.until) : Number.NaN]),
))
.filter((time): time is number => Number.isFinite(time) && time > atMs)
.sort((left, right) => left - right);
for (const transitionTime of transitionTimes) {
const transitionAt = new Date(transitionTime).toISOString();
const probeAt = new Date(transitionTime + 1).toISOString();
const candidate = selectAvailabilityAt(entries, probeAt) ?? createDefaultOnlineAvailability(transitionAt);
if (!sameAvailabilityState(candidate, current)) {
return {
...candidate,
effectiveFrom: transitionAt,
};
}
}
return null;
}
function createDefaultOnlineAvailability(at: string): BoxBrainAvailabilityEntry {
return {
id: 'default-online',
mode: 'online',
effectiveFrom: at,
sourceType: 'default',
};
}
function parseAvailabilityFact(fact: Fact): BoxBrainAvailabilityEntry | null {
if (getFactDomain(fact) !== 'persona.availability') {
return null;
}
const metadata = getJsonObject(fact.metadata);
const payload = getJsonObject(metadata?.availabilityStatus);
if (!payload) {
return null;
}
const id = typeof payload.id === 'string' ? payload.id : fact.id;
const mode = payload.mode;
const effectiveFrom = payload.effectiveFrom;
const sourceType = payload.sourceType;
if (typeof mode !== 'string' || typeof effectiveFrom !== 'string' || typeof sourceType !== 'string') {
return null;
}
assertAvailabilityMode(mode);
if (!isExplicitSourceType(sourceType)) {
return null;
}
return {
id,
mode,
reason: typeof payload.reason === 'string' ? payload.reason : undefined,
effectiveFrom,
until: typeof payload.until === 'string' ? payload.until : undefined,
sourceType,
eventId: typeof payload.eventId === 'string' ? payload.eventId : undefined,
createdAt: fact.createdAt,
metadata: payload.customMetadata ?? null,
};
}
function compareAvailabilityEntries(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): number {
if (left.effectiveFrom !== right.effectiveFrom) {
return left.effectiveFrom.localeCompare(right.effectiveFrom);
}
return (left.createdAt ?? '').localeCompare(right.createdAt ?? '');
}
function buildAvailabilityStatement(
mode: BoxBrainAvailabilityMode,
reason: string | undefined,
effectiveFrom: string,
until: string | undefined,
): string {
const reasonPart = reason ? ` because ${reason}` : '';
const untilPart = until ? ` until ${until}` : '';
return `Availability is ${mode} from ${effectiveFrom}${untilPart}${reasonPart}.`;
}
function collectDeletedScheduleEventIds(facts: Fact[]): Set<string> {
return new Set(
facts
.filter((fact) => getFactDomain(fact) === 'persona.schedule.deleted')
.map((fact) => getJsonObject(getJsonObject(fact.metadata)?.scheduleDeletion)?.eventId)
.filter((eventId): eventId is string => typeof eventId === 'string'),
);
}
function sameAvailabilityState(left: BoxBrainAvailabilityEntry, right: BoxBrainAvailabilityEntry): boolean {
return left.mode === right.mode
&& left.reason === right.reason
&& left.until === right.until
&& left.sourceType === right.sourceType
&& left.eventId === right.eventId;
}
function assertValidTimestamp(value: string, fieldName: string): void {
if (!Number.isFinite(Date.parse(value))) {
throw new Error(`Availability ${fieldName} must be a valid ISO timestamp.`);
}
}
function assertChronology(startAt: string, endAt: string | undefined): void {
assertValidTimestamp(startAt, 'effectiveFrom');
if (!endAt) {
return;
}
assertValidTimestamp(endAt, 'until');
if (Date.parse(endAt) <= Date.parse(startAt)) {
throw new Error('Availability until must be later than effectiveFrom.');
}
}
function assertAvailabilityMode(mode: string): asserts mode is BoxBrainAvailabilityMode {
if (mode !== 'online' && mode !== 'do_not_disturb' && mode !== 'offline') {
throw new Error(`Unsupported availability mode: ${mode}`);
}
}
function isExplicitSourceType(sourceType: string): sourceType is Exclude<BoxBrainAvailabilitySourceType, 'default'> {
return sourceType === 'schedule' || sourceType === 'manual' || sourceType === 'tool';
}
function priorityOf(sourceType: BoxBrainAvailabilitySourceType): number {
if (sourceType === 'default') {
return 0;
}
return EXPLICIT_SOURCE_PRIORITY[sourceType];
}