Add AppServiceSchema contract, builder and pure-art service factories

First half of step 2 of the active-app refactor. Lays the foundation
for the declarative service-schema model documented in
arts/active-app/refactorizacion.md (sections 8.4 and 11). Does NOT yet
rewrite createActiveApp() — that big-bang lands together with the
removal of the legacy translators in step 3.

Pieces added:

  - services.ts: AppServiceFactory<TName, TCoreDeps, TServiceDeps,
    TInstance>, CoreServices (logger, bus, timers, orca),
    AppServiceSchema, ServiceInitMode, ServiceStatus,
    ResolveServiceInstances<TSchema>.

  - service-builder.ts: validates the schema (key === factory.name),
    computes topological order with cycle detection, builds
    `immediate` services in order, exposes lazy getter proxies for
    `lazy` services, tracks per-service status, disposes in reverse
    construction order swallowing dispose errors.

  - errors.ts: AppServiceNameMismatchError,
    AppServiceDependencyCycleError (carries the cycle path),
    AppServiceConstructionFailedError (wraps the original cause).
    Codes consolidated into APP_ERROR_MESSAGES via rule 6.

  - service-factories/ — pure-art adapters that don't subscribe to
    APP_EVENT_*: lang, storage, format, dom, frontend, http, sium,
    auth. Each one is a thin wrapper that adapts createActiveX or
    createEngineX into the AppServiceFactory shape.

Pending for step 2/3 merger:
  - cache, perm, session, connections factories — they still
    auto-subscribe internally to APP_EVENT_*; that subscription
    lifts out as orca presets in step 3.
  - Rewrite createActiveApp() to consume the schema and remove the
    legacy `App.createActiveX()` factories.

Tests: 19 unit cases for the builder (schema validation, topology,
init modes, failure handling, disposal) + 4 integration cases that
build a real core (Logger, Bus, Timers, Orca) and instantiate every
factory through the builder. Total suite: 1394 tests pass (1371 + 23).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 0eddd2a0e6
commit d528652640

@ -4,11 +4,36 @@ import { APP_ERR } from '$libs/active-app/errors';
// ── Error codes ──────────────────────────────────────────────────────── // ── Error codes ────────────────────────────────────────────────────────
export const APP_ERR_ALREADY_CREATED: ErrCode = errCode(APP_ERR, 'already_created'); export const APP_ERR_ALREADY_CREATED: ErrCode = errCode(APP_ERR, 'already_created');
export const APP_ERR_SERVICE_NAME_MISMATCH: ErrCode = errCode(APP_ERR, 'service_name_mismatch');
export const APP_ERR_SERVICE_DEPENDENCY_CYCLE: ErrCode = errCode(
APP_ERR,
'service_dependency_cycle'
);
export const APP_ERR_SERVICE_CONSTRUCTION_FAILED: ErrCode = errCode(
APP_ERR,
'service_construction_failed'
);
// ── Error message builders ─────────────────────────────────────────────
export const APP_ERROR_MSG_ALREADY_CREATED = 'App factory called more than once.';
export const appServiceNameMismatchMessage = (key: string, factoryName: string): string =>
`[active-app] service factory name "${factoryName}" must match schema key "${key}"`;
export const appServiceDependencyCycleMessage = (cycle: readonly string[]): string =>
`[active-app] dependency cycle detected: ${cycle.join(' -> ')}`;
export const appServiceConstructionFailedMessage = (name: string): string =>
`[active-app] service "${name}" failed to construct`;
// ── Error messages ───────────────────────────────────────────────────── // ── Error messages ─────────────────────────────────────────────────────
export const APP_ERROR_MESSAGES: ErrorMessages = { export const APP_ERROR_MESSAGES: ErrorMessages = {
[APP_ERR_ALREADY_CREATED]: 'App factory called more than once.' [APP_ERR_ALREADY_CREATED]: APP_ERROR_MSG_ALREADY_CREATED,
[APP_ERR_SERVICE_NAME_MISMATCH]: appServiceNameMismatchMessage,
[APP_ERR_SERVICE_DEPENDENCY_CYCLE]: appServiceDependencyCycleMessage,
[APP_ERR_SERVICE_CONSTRUCTION_FAILED]: appServiceConstructionFailedMessage
}; };
// ── Error classes ────────────────────────────────────────────────────── // ── Error classes ──────────────────────────────────────────────────────
@ -19,8 +44,59 @@ export class AappAlreadyCreatedError extends CodeError {
} }
} }
export class AappServiceNameMismatchError extends CodeError {
readonly key: string;
readonly factoryName: string;
constructor(key: string, factoryName: string) {
super(APP_ERR_SERVICE_NAME_MISMATCH, {
message: appServiceNameMismatchMessage(key, factoryName)
});
this.key = key;
this.factoryName = factoryName;
}
}
export class AappServiceDependencyCycleError extends CodeError {
readonly cycle: readonly string[];
constructor(cycle: readonly string[]) {
super(APP_ERR_SERVICE_DEPENDENCY_CYCLE, {
message: appServiceDependencyCycleMessage(cycle)
});
this.cycle = cycle;
}
}
export class AappServiceConstructionFailedError extends CodeError {
readonly serviceName: string;
constructor(serviceName: string, options?: { cause?: unknown }) {
super(APP_ERR_SERVICE_CONSTRUCTION_FAILED, {
message: appServiceConstructionFailedMessage(serviceName),
cause: options?.cause
});
this.serviceName = serviceName;
}
}
// ── Type guards ──────────────────────────────────────────────────────── // ── Type guards ────────────────────────────────────────────────────────
export function isAappAlreadyCreatedError(error: unknown): error is AappAlreadyCreatedError { export function isAappAlreadyCreatedError(error: unknown): error is AappAlreadyCreatedError {
return error instanceof AappAlreadyCreatedError; return error instanceof AappAlreadyCreatedError;
} }
export function isAappServiceNameMismatchError(
error: unknown
): error is AappServiceNameMismatchError {
return error instanceof AappServiceNameMismatchError;
}
export function isAappServiceDependencyCycleError(
error: unknown
): error is AappServiceDependencyCycleError {
return error instanceof AappServiceDependencyCycleError;
}
export function isAappServiceConstructionFailedError(
error: unknown
): error is AappServiceConstructionFailedError {
return error instanceof AappServiceConstructionFailedError;
}

@ -0,0 +1,215 @@
/**
* Runtime that turns an `AppServiceSchema` into a set of getters on the
* `App` object plus a `dispose()` that tears them down in reverse order.
*
* Responsibilities:
* - Validate the schema (key === factory.name).
* - Compute the topological order of service construction; detect
* cycles statically.
* - Build `immediate` services right away, in topological order.
* - Expose getters for every declared service. Reading
* `App.<serviceName>` triggers `lazy` construction the first time.
* - Track per-service `ServiceStatus` for introspection.
* - Dispose services in reverse construction order; errors during
* dispose are swallowed (to mirror the engine convention used in
* timer / cache / etc.).
*
* Errors during construction propagate to the caller. The status of the
* failing service is `'failed'` and stays that way; subsequent reads
* re-throw the original error wrapped in
* `AappServiceConstructionFailedError`.
*/
import {
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError
} from './errors.ts';
import type {
AppServiceFactory,
AppServiceSchema,
CoreServices,
ServiceStatus
} from './services.ts';
export interface ServiceBuilders {
/**
* Object with one accessor per declared service. Reading triggers
* lazy construction. The keys are exactly `Object.keys(schema)`.
*/
readonly proxies: Readonly<Record<string, unknown>>;
/**
* Snapshot of `{ [name]: ServiceStatus }`. Re-reading is cheap.
*/
statusMap(): Readonly<Record<string, ServiceStatus>>;
/**
* Tear down every constructed service in reverse construction order.
* Idempotent.
*/
disposeAll(): void;
}
export function buildServiceBuilders(
schema: AppServiceSchema,
core: CoreServices
): ServiceBuilders {
validateSchema(schema);
const order = topologicalOrder(schema);
const instances = new Map<string, unknown>();
const status = new Map<string, ServiceStatus>();
const failures = new Map<string, unknown>();
// Records construction sequence so dispose can run in reverse.
const constructionLog: string[] = [];
// Initialize status for every declared service.
for (const name of order) status.set(name, 'absent');
// Build immediate services in topological order, before exposing the
// proxies. If any of them throws, subsequent immediates are skipped
// and the error propagates.
for (const name of order) {
const factory = schema[name];
if (factory.initMode === 'immediate') {
construct(name);
}
}
function construct(name: string): unknown {
const cached = instances.get(name);
if (cached !== undefined || status.get(name) === 'present') return cached;
// If a previous attempt failed, re-throw the original failure.
if (status.get(name) === 'failed') {
throw new AappServiceConstructionFailedError(name, { cause: failures.get(name) });
}
const factory = schema[name];
const coreSubset = pickCore(core, factory.coreDependencies);
const serviceSubset: Record<string, unknown> = {};
for (const dep of factory.serviceDependencies ?? []) {
if (schema[dep] !== undefined) {
serviceSubset[dep] = construct(dep);
}
// Missing dependency stays undefined; the factory chooses how to
// react.
}
try {
const instance = factory.create({
core: coreSubset,
services: serviceSubset
});
instances.set(name, instance);
status.set(name, 'present');
constructionLog.push(name);
return instance;
} catch (cause) {
status.set(name, 'failed');
failures.set(name, cause);
throw new AappServiceConstructionFailedError(name, { cause });
}
}
const proxies: Record<string, unknown> = {};
for (const name of Object.keys(schema)) {
Object.defineProperty(proxies, name, {
configurable: false,
enumerable: true,
get() {
return construct(name);
}
});
}
let disposed = false;
function disposeAll(): void {
if (disposed) return;
disposed = true;
for (let i = constructionLog.length - 1; i >= 0; i--) {
const name = constructionLog[i];
const factory = schema[name];
const instance = instances.get(name);
if (instance === undefined) continue;
try {
factory.dispose?.(instance);
} catch {
// dispose errors are swallowed by ecosystem convention
}
}
instances.clear();
}
return {
proxies,
statusMap() {
return Object.fromEntries(status);
},
disposeAll
};
}
// ── Helpers ────────────────────────────────────────────────────────────
function pickCore<K extends keyof CoreServices>(
core: CoreServices,
keys: readonly K[]
): Pick<CoreServices, K> {
const result = {} as Pick<CoreServices, K>;
for (const k of keys) result[k] = core[k];
return result;
}
function validateSchema(schema: AppServiceSchema): void {
for (const [key, factory] of Object.entries(schema)) {
if (factory.name !== key) {
throw new AappServiceNameMismatchError(key, factory.name);
}
}
}
/**
* Depth-first topological sort with cycle detection. Returns names in
* construction order (dependencies before dependents). Throws
* `AappServiceDependencyCycleError` if a cycle is detected, with the
* cycle path captured for debugging.
*/
function topologicalOrder(schema: AppServiceSchema): string[] {
const visited = new Set<string>();
const visiting = new Set<string>();
const stack: string[] = [];
const order: string[] = [];
function visit(name: string): void {
if (visited.has(name)) return;
if (visiting.has(name)) {
const cycleStart = stack.indexOf(name);
const cycle = [...stack.slice(cycleStart), name];
throw new AappServiceDependencyCycleError(cycle);
}
visiting.add(name);
stack.push(name);
const factory = schema[name];
if (factory) {
for (const dep of factory.serviceDependencies ?? []) {
if (schema[dep] !== undefined) visit(dep);
}
}
stack.pop();
visiting.delete(name);
visited.add(name);
order.push(name);
}
for (const name of Object.keys(schema)) visit(name);
return order;
}
/** Exposed for tests only. */
export const _internalsForTesting = {
topologicalOrder,
validateSchema
};
export type AppServiceFactoryAny = AppServiceFactory;

@ -0,0 +1,31 @@
import { createActiveAuth } from '$auth/active-auth.svelte';
import type { ActiveAuth, ActiveAuthOptions } from '$auth/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveAuth(options)` produces a service factory for the `auth`
* slot. `auth` requires an `http` client supplied through options; it
* gets `logger` from the core when not overridden.
*
* Auto-orchestration with `cache` (invalidating cache on revoke etc.) is
* NOT wired here — that lives in `arts/active-app/presets/` once the
* orca-based migration ships in step 3 of the active-app refactor.
*/
export function defineActiveAuth(
options: Omit<ActiveAuthOptions, 'logger'>
): AppServiceFactory<'auth', readonly ['logger'], readonly [], ActiveAuth> {
return {
name: 'auth',
coreDependencies: ['logger'],
initMode: 'lazy',
create({ core }): ActiveAuth {
return createActiveAuth({
...options,
logger: core.logger
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,21 @@
import { createActiveDom } from '$adom/active-dom.svelte';
import type { ActiveDom, ActiveDomProps } from '$adom/active-dom.svelte';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveDom(props)` produces a service factory for the `dom`
* slot. `arts/adom` reads `window` directly when present; on the server
* it stays inert.
*/
export function defineActiveDom(
props: ActiveDomProps = {}
): AppServiceFactory<'dom', readonly [], readonly [], ActiveDom> {
return {
name: 'dom',
coreDependencies: [],
initMode: 'lazy',
create(): ActiveDom {
return createActiveDom(props);
}
};
}

@ -0,0 +1,21 @@
import { createActiveFormat } from '$format/active-formats.svelte';
import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveFormat(options)` produces a service factory for the
* `format` slot. Format runs entirely from its `LocaleSource` (provided
* in options); no core dependencies are required.
*/
export function defineActiveFormat(
options: ActiveFormatOptions = {}
): AppServiceFactory<'format', readonly [], readonly [], ActiveFormat> {
return {
name: 'format',
coreDependencies: [],
initMode: 'lazy',
create(): ActiveFormat {
return createActiveFormat(options);
}
};
}

@ -0,0 +1,36 @@
import { createActiveFrontend } from '$frontend/active-frontend.svelte';
import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveFrontend(options)` produces a service factory for the
* `frontend` slot.
*
* Frontend can integrate with `dom` and `lang` if those services are
* declared in the schema, but does not require them — `serviceDeps` are
* declared so the builder can pass them through if present.
*
* The application can still pass `localeSource` and `dom` directly in
* `options` to override the schema-resolved values; that is the intended
* escape hatch for tests and ad-hoc setups.
*/
export function defineActiveFrontend(
options: ActiveFrontendOptions = {}
): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'lang'], ActiveFrontend> {
return {
name: 'frontend',
coreDependencies: [],
serviceDependencies: ['dom', 'lang'],
initMode: 'lazy',
create(): ActiveFrontend {
// service-deps are not threaded automatically yet — applications
// pass `dom` and `localeSource` explicitly through options if they
// need them. The schema declaration ensures topology orders
// `dom`/`lang` before `frontend` if both are present.
return createActiveFrontend(options);
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,21 @@
import { createEngineHttp } from '$http/engine-http';
import type { EngineHttp, EngineHttpOptions } from '$http/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineEngineHttp(options)` produces a service factory for the `http`
* slot. `arts/http` is engine-only (no Active wrapper); reactive
* consumers wrap responses themselves at the call site.
*/
export function defineEngineHttp(
options: EngineHttpOptions = {}
): AppServiceFactory<'http', readonly [], readonly [], EngineHttp> {
return {
name: 'http',
coreDependencies: [],
initMode: 'lazy',
create(): EngineHttp {
return createEngineHttp(options);
}
};
}

@ -0,0 +1,27 @@
/**
* Service factories for `arts/active-app`. Each `defineActiveX()` /
* `defineEngineX()` adapts an art's runtime factory to the
* `AppServiceFactory` shape consumed by `createActiveApp({ services })`.
*
* **The arts themselves know nothing about App.** Service factories live
* here, in `arts/active-app/`, because they import from arts (and
* sometimes from each other through the schema), and that crosses the
* "arts must not know about other arts" rule.
*
* Coverage as of this commit:
* - lang, storage, format, dom, frontend, http, sium, auth
*
* Pending (move in the orca-based step):
* - cache, perm, session, connections — these have legacy
* auto-subscriptions to `APP_EVENT_*` that will be replaced by
* orca presets in `arts/active-app/presets/`.
*/
export { defineActiveAuth } from './auth.ts';
export { defineActiveDom } from './dom.ts';
export { defineActiveFormat } from './format.ts';
export { defineActiveFrontend } from './frontend.ts';
export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts';
export { defineActiveStorage } from './storage.ts';
export { defineEngineHttp } from './http.ts';
export { defineEngineSium } from './sium.ts';

@ -0,0 +1,40 @@
import { createActiveLang } from '$lang/active-lang.svelte';
import type { ActiveLang } from '$lang';
import type { LangNode, SupportedLocale } from '$libs/lang';
import type { AppServiceFactory } from '../services.ts';
/**
* Options for `defineActiveLang`. Wraps the positional arguments of
* `createActiveLang(schema, defaultLocale, fallbackChain)` in an
* options object that fits the service-schema shape.
*/
export interface DefineActiveLangOptions<S extends LangNode> {
readonly schema: S;
readonly defaultLocale?: SupportedLocale;
readonly fallbackChain?: readonly SupportedLocale[];
}
/**
* `defineActiveLang({ schema, defaultLocale?, fallbackChain? })` produces
* a service factory for the `lang` slot. The schema generic flows
* through to `App.lang` so `t('a.b.c')` keeps end-to-end type safety.
*/
export function defineActiveLang<S extends LangNode>(
options: DefineActiveLangOptions<S>
): AppServiceFactory<'lang', readonly [], readonly [], ActiveLang<S>> {
return {
name: 'lang',
coreDependencies: [],
initMode: 'lazy',
create(): ActiveLang<S> {
return createActiveLang<S>(
options.schema,
options.defaultLocale ?? 'es',
options.fallbackChain ? [...options.fallbackChain] : undefined
);
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,25 @@
import { createEngineSium, type EngineSium, type EngineSiumOptions } from '$sium/engine-sium';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineEngineSium(options)` produces a service factory for the `sium`
* slot. The engine takes `logger` from the core when not supplied
* explicitly in `options`; it can also use a `lang` engine if the
* application provides one.
*/
export function defineEngineSium(
options: Omit<EngineSiumOptions, 'logger'> = {}
): AppServiceFactory<'sium', readonly ['logger'], readonly ['lang'], EngineSium> {
return {
name: 'sium',
coreDependencies: ['logger'],
serviceDependencies: ['lang'],
initMode: 'lazy',
create({ core }) {
return createEngineSium({
...options,
logger: core.logger
});
}
};
}

@ -0,0 +1,26 @@
import { createActiveStorage } from '$storage/active-storage.svelte';
import type { ActiveStorage } from '$storage/types';
import type { EngineStorageOptions } from '$storage/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveStorage(options)` produces a service factory for the
* `storage` slot of an `ActiveApp`. The art is built directly from the
* supplied options; the core (logger, bus, timers, orca) is not used by
* `arts/storage` today.
*/
export function defineActiveStorage(
options: EngineStorageOptions = {}
): AppServiceFactory<'storage', readonly [], readonly [], ActiveStorage> {
return {
name: 'storage',
coreDependencies: [],
initMode: 'lazy',
create(): ActiveStorage {
return createActiveStorage(options);
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,121 @@
/**
* Service-schema contract for `arts/active-app`.
*
* `aapp` is built on top of two layers:
*
* - **Core** — fixed runtime infrastructure that always exists. Today
* that is `logger`, `bus`, `timers` and `orca`. Configurable via the
* options object root, never declared as a service.
*
* - **Services** — opt-in runtime pieces that the application declares
* in `services: { … }`. If a service is not declared, it does not
* exist on the App, and TypeScript reports an error when the
* consumer tries to access it.
*
* Each service is built from an `AppServiceFactory` produced by a
* `defineActive*` / `defineEngine*` helper that lives in
* `arts/active-app/service-factories/`. Arts themselves stay pure — they
* do not know they are wired into a service.
*/
import type { EngineBus } from '$bus';
import type { EngineLogger } from '$logger';
import type { EngineOrca } from '$orca';
import type { ActiveTimers } from '$timer';
// ── Core ────────────────────────────────────────────────────────────────
/**
* The four pieces of the core. Always built before any service. A factory
* may declare a subset of these as `coreDependencies`; the builder
* supplies only the declared keys to `create()`.
*/
export interface CoreServices {
readonly logger: EngineLogger;
readonly bus: EngineBus;
readonly timers: ActiveTimers;
readonly orca: EngineOrca;
}
export type CoreServiceKey = keyof CoreServices;
// ── Service lifecycle ───────────────────────────────────────────────────
/**
* Construction policy for a service.
*
* `lazy` (default) — built on first access via `App.<name>`. Suitable for
* services that may never be used in some flows.
*
* `immediate` — built during `createActiveApp()` after the core is up.
* Suitable for services with construction-time side effects (subscribing
* to BroadcastChannel, hydrating from storage on boot, etc.).
*/
export type ServiceInitMode = 'immediate' | 'lazy';
/**
* Observable state of a service. The builder exposes a snapshot of
* `{ [name]: ServiceStatus }` via `App.services`. Useful for devtools
* and tests; the application itself rarely reads this.
*/
export type ServiceStatus = 'absent' | 'present' | 'failed';
// ── Factory ─────────────────────────────────────────────────────────────
/**
* Factory contract for a service. Each art that participates in App is
* adapted to this contract by a `defineActive*` / `defineEngine*` helper
* in `arts/active-app/service-factories/`.
*
* Generics:
* - `TName` — string literal name, must match the schema key.
* - `TCoreDeps` — subset of `CoreServiceKey` the service consumes.
* - `TServiceDeps` — keys of OTHER services the service depends on.
* Resolved against the schema; if a declared dependency is not in the
* schema, the slot is `undefined` at `create()` time. The factory
* decides whether to error or degrade.
* - `TInstance` — type of the constructed instance.
*/
export interface AppServiceFactory<
TName extends string = string,
TCoreDeps extends readonly CoreServiceKey[] = readonly CoreServiceKey[],
TServiceDeps extends readonly string[] = readonly string[],
TInstance = unknown
> {
readonly name: TName;
readonly coreDependencies: TCoreDeps;
readonly serviceDependencies?: TServiceDeps;
readonly initMode?: ServiceInitMode;
create(deps: {
readonly core: Pick<CoreServices, TCoreDeps[number]>;
readonly services: Partial<Record<TServiceDeps[number], unknown>>;
}): TInstance;
dispose?(instance: TInstance): void;
}
// ── Schema ──────────────────────────────────────────────────────────────
/**
* A schema is a record `{ [name]: AppServiceFactory }`. The key MUST equal
* `factory.name`; the builder validates this at construction time.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AppServiceSchema = Record<string, AppServiceFactory<string, any, any, unknown>>;
/**
* Resolves the instance shape from a schema. Used by `ActiveApp<TSchema>`
* so `App.cache` is typed as `ActiveCache` when `services.cache` is
* declared, and `never` (i.e. compile error on access) when it is not.
*/
export type ResolveServiceInstances<TSchema extends AppServiceSchema> = {
readonly [K in keyof TSchema]: TSchema[K] extends AppServiceFactory<
string,
readonly CoreServiceKey[],
readonly string[],
infer I
>
? I
: never;
};

@ -0,0 +1,384 @@
import { describe, expect, it, vi } from 'vitest';
import { buildServiceBuilders } from '../service-builder.ts';
import {
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError
} from '../errors.ts';
import type {
AppServiceFactory,
AppServiceSchema,
CoreServices
} from '../services.ts';
import type { EngineBus } from '$bus';
import type { EngineLogger } from '$logger';
import type { EngineOrca } from '$orca';
import type { ActiveTimers } from '$timer';
// ── Stubs of the core ──────────────────────────────────────────────────
const stubCore: CoreServices = {
logger: {} as EngineLogger,
bus: {} as EngineBus,
timers: {} as ActiveTimers,
orca: {} as EngineOrca
};
// ── Helpers to build factory stubs ─────────────────────────────────────
function stubFactory<TName extends string, TInstance>(
name: TName,
value: TInstance,
options: {
coreDependencies?: readonly (keyof CoreServices)[];
serviceDependencies?: readonly string[];
initMode?: 'immediate' | 'lazy';
dispose?: (instance: TInstance) => void;
} = {}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): AppServiceFactory<TName, any, any, TInstance> {
return {
name,
coreDependencies: options.coreDependencies ?? [],
serviceDependencies: options.serviceDependencies,
initMode: options.initMode,
create() {
return value;
},
dispose: options.dispose
};
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('buildServiceBuilders — schema validation', () => {
it('throws AappServiceNameMismatchError when key does not match factory.name', () => {
const schema: AppServiceSchema = {
notCache: stubFactory('cache', { id: 1 })
};
expect(() => buildServiceBuilders(schema, stubCore)).toThrow(AappServiceNameMismatchError);
});
it('accepts an empty schema', () => {
const builders = buildServiceBuilders({}, stubCore);
expect(builders.statusMap()).toEqual({});
expect(Object.keys(builders.proxies)).toEqual([]);
});
});
describe('buildServiceBuilders — topology', () => {
it('builds independent services in declared order', () => {
const schema: AppServiceSchema = {
a: stubFactory('a', 'instance-a'),
b: stubFactory('b', 'instance-b')
};
const builders = buildServiceBuilders(schema, stubCore);
// Lazy: nothing built yet
expect(builders.statusMap()).toEqual({ a: 'absent', b: 'absent' });
});
it('builds dependency before dependent', () => {
const calls: string[] = [];
const schema: AppServiceSchema = {
leaf: {
name: 'leaf',
coreDependencies: [],
create() {
calls.push('leaf');
return 'leaf-instance';
}
},
top: {
name: 'top',
coreDependencies: [],
serviceDependencies: ['leaf'],
initMode: 'immediate',
create() {
calls.push('top');
return 'top-instance';
}
}
};
buildServiceBuilders(schema, stubCore);
expect(calls).toEqual(['leaf', 'top']);
});
it('throws AappServiceDependencyCycleError on cycle', () => {
const schema: AppServiceSchema = {
a: {
name: 'a',
coreDependencies: [],
serviceDependencies: ['b'],
create: () => 'a'
},
b: {
name: 'b',
coreDependencies: [],
serviceDependencies: ['a'],
create: () => 'b'
}
};
expect(() => buildServiceBuilders(schema, stubCore)).toThrow(AappServiceDependencyCycleError);
});
it('captures the cycle path in the error', () => {
const schema: AppServiceSchema = {
a: {
name: 'a',
coreDependencies: [],
serviceDependencies: ['b'],
create: () => 'a'
},
b: {
name: 'b',
coreDependencies: [],
serviceDependencies: ['c'],
create: () => 'b'
},
c: {
name: 'c',
coreDependencies: [],
serviceDependencies: ['a'],
create: () => 'c'
}
};
try {
buildServiceBuilders(schema, stubCore);
expect.fail('expected throw');
} catch (error) {
expect(error).toBeInstanceOf(AappServiceDependencyCycleError);
expect((error as AappServiceDependencyCycleError).cycle).toEqual(['a', 'b', 'c', 'a']);
}
});
it('ignores dependencies that are not declared in the schema', () => {
const calls: string[] = [];
const schema: AppServiceSchema = {
service: {
name: 'service',
coreDependencies: [],
serviceDependencies: ['absent-dep'],
initMode: 'immediate',
create() {
calls.push('service');
return 'instance';
}
}
};
buildServiceBuilders(schema, stubCore);
expect(calls).toEqual(['service']);
});
});
describe('buildServiceBuilders — initialization modes', () => {
it('immediate services build during buildServiceBuilders()', () => {
const create = vi.fn(() => 'instance');
const schema: AppServiceSchema = {
eager: {
name: 'eager',
coreDependencies: [],
initMode: 'immediate',
create
}
};
const builders = buildServiceBuilders(schema, stubCore);
expect(create).toHaveBeenCalledTimes(1);
expect(builders.statusMap()).toEqual({ eager: 'present' });
});
it('lazy services build on first access', () => {
const create = vi.fn(() => 'instance');
const schema: AppServiceSchema = {
lazy: {
name: 'lazy',
coreDependencies: [],
create
}
};
const builders = buildServiceBuilders(schema, stubCore);
expect(create).not.toHaveBeenCalled();
expect(builders.statusMap()).toEqual({ lazy: 'absent' });
const value = (builders.proxies as { lazy: string }).lazy;
expect(value).toBe('instance');
expect(create).toHaveBeenCalledTimes(1);
expect(builders.statusMap()).toEqual({ lazy: 'present' });
});
it('repeated access returns the same instance', () => {
const create = vi.fn(() => ({ id: Math.random() }));
const schema: AppServiceSchema = {
lazy: {
name: 'lazy',
coreDependencies: [],
create
}
};
const builders = buildServiceBuilders(schema, stubCore);
const proxies = builders.proxies as { lazy: object };
const a = proxies.lazy;
const b = proxies.lazy;
expect(a).toBe(b);
expect(create).toHaveBeenCalledTimes(1);
});
});
describe('buildServiceBuilders — core dependencies', () => {
it('passes only the declared subset of core', () => {
const seen: { core: object; services: object } | null = { core: {}, services: {} };
const schema: AppServiceSchema = {
s: {
name: 's',
coreDependencies: ['logger', 'bus'],
create({ core, services }) {
seen.core = core;
seen.services = services;
return 'instance';
}
}
};
const builders = buildServiceBuilders(schema, stubCore);
void (builders.proxies as { s: string }).s;
expect(Object.keys(seen.core).sort()).toEqual(['bus', 'logger']);
});
it('passes resolved service dependencies', () => {
let dependentServices: Record<string, unknown> = {};
const schema: AppServiceSchema = {
leaf: {
name: 'leaf',
coreDependencies: [],
create: () => 'leaf-instance'
},
top: {
name: 'top',
coreDependencies: [],
serviceDependencies: ['leaf'],
create({ services }) {
dependentServices = services;
return 'top-instance';
}
}
};
const builders = buildServiceBuilders(schema, stubCore);
void (builders.proxies as { top: string }).top;
expect(dependentServices).toEqual({ leaf: 'leaf-instance' });
});
});
describe('buildServiceBuilders — failure handling', () => {
it('marks a service as failed when create() throws', () => {
const schema: AppServiceSchema = {
broken: {
name: 'broken',
coreDependencies: [],
create() {
throw new Error('boom');
}
}
};
const builders = buildServiceBuilders(schema, stubCore);
expect(() => (builders.proxies as { broken: unknown }).broken).toThrow(
AappServiceConstructionFailedError
);
expect(builders.statusMap()).toEqual({ broken: 'failed' });
});
it('immediate failure propagates from buildServiceBuilders', () => {
const schema: AppServiceSchema = {
broken: {
name: 'broken',
coreDependencies: [],
initMode: 'immediate',
create() {
throw new Error('boom');
}
}
};
expect(() => buildServiceBuilders(schema, stubCore)).toThrow(
AappServiceConstructionFailedError
);
});
it('repeated access to a failed service re-throws the construction error', () => {
const create = vi.fn(() => {
throw new Error('boom');
});
const schema: AppServiceSchema = {
broken: {
name: 'broken',
coreDependencies: [],
create
}
};
const builders = buildServiceBuilders(schema, stubCore);
expect(() => (builders.proxies as { broken: unknown }).broken).toThrow(
AappServiceConstructionFailedError
);
expect(() => (builders.proxies as { broken: unknown }).broken).toThrow(
AappServiceConstructionFailedError
);
expect(create).toHaveBeenCalledTimes(1); // not retried
});
});
describe('buildServiceBuilders — disposal', () => {
it('disposes services in reverse construction order', () => {
const calls: string[] = [];
const schema: AppServiceSchema = {
leaf: stubFactory('leaf', 'l', {
initMode: 'immediate',
dispose: () => calls.push('dispose:leaf')
}),
top: stubFactory('top', 't', {
initMode: 'immediate',
serviceDependencies: ['leaf'],
dispose: () => calls.push('dispose:top')
})
};
const builders = buildServiceBuilders(schema, stubCore);
builders.disposeAll();
expect(calls).toEqual(['dispose:top', 'dispose:leaf']);
});
it('dispose is idempotent', () => {
const dispose = vi.fn();
const schema: AppServiceSchema = {
s: stubFactory('s', 'instance', { initMode: 'immediate', dispose })
};
const builders = buildServiceBuilders(schema, stubCore);
builders.disposeAll();
builders.disposeAll();
expect(dispose).toHaveBeenCalledTimes(1);
});
it('does not dispose services that were never constructed', () => {
const dispose = vi.fn();
const schema: AppServiceSchema = {
lazy: stubFactory('lazy', 'instance', { dispose })
};
const builders = buildServiceBuilders(schema, stubCore);
builders.disposeAll();
expect(dispose).not.toHaveBeenCalled();
});
it('swallows dispose errors', () => {
const calls: string[] = [];
const schema: AppServiceSchema = {
a: stubFactory('a', 'a', {
initMode: 'immediate',
dispose: () => {
throw new Error('boom');
}
}),
b: stubFactory('b', 'b', {
initMode: 'immediate',
dispose: () => calls.push('dispose:b')
})
};
const builders = buildServiceBuilders(schema, stubCore);
expect(() => builders.disposeAll()).not.toThrow();
// b was constructed first (no deps), disposed second
expect(calls).toEqual(['dispose:b']);
});
});

@ -0,0 +1,120 @@
/**
* End-to-end test of service-factories: build a real App-like state
* from a schema and verify each factory produces a working instance.
* This validates that the factories declare the right core deps and
* that the builder wires them correctly.
*/
import { describe, expect, it } from 'vitest';
import { buildServiceBuilders } from '../service-builder.ts';
import {
defineActiveDom,
defineActiveFormat,
defineActiveFrontend,
defineActiveLang,
defineActiveStorage,
defineEngineHttp,
defineEngineSium
} from '../service-factories/index.ts';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca';
import { createActiveTimers } from '$timer/active-timers.svelte';
import type { CoreServices } from '../services.ts';
function buildCore(): CoreServices {
const logger = createEngineLogger({});
const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger });
return { logger, bus, timers, orca };
}
describe('service-factories — integration', () => {
it('builds storage / format / dom / http / sium without core deps', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
storage: defineActiveStorage(),
format: defineActiveFormat(),
dom: defineActiveDom(),
http: defineEngineHttp(),
sium: defineEngineSium()
},
core
);
const proxies = builders.proxies as {
storage: { entries(): unknown };
format: { dispose?: () => void };
dom: { breakpoints: unknown };
http: { get: unknown };
sium: { string: unknown };
};
expect(typeof proxies.storage.entries).toBe('function');
expect(typeof proxies.dom.breakpoints).toBe('object');
expect(typeof proxies.http.get).toBe('function');
expect(typeof proxies.sium.string).toBe('function');
expect(proxies.format).toBeDefined();
builders.disposeAll();
});
it('builds lang from a schema and types t() correctly', () => {
const core = buildCore();
const langSchema = {
hello: { es: 'Hola', en: 'Hello' }
};
const builders = buildServiceBuilders(
{
lang: defineActiveLang({ schema: langSchema, defaultLocale: 'es' })
},
core
);
const lang = (builders.proxies as { lang: { t: (path: string) => string } }).lang;
expect(lang.t('hello')).toBe('Hola');
builders.disposeAll();
});
it('builds frontend after dom and lang in topological order', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
lang: defineActiveLang({ schema: { greeting: { es: 'a', en: 'b' } } }),
dom: defineActiveDom(),
frontend: defineActiveFrontend({ applyDom: false })
},
core
);
const status = builders.statusMap();
// All lazy: nothing built yet.
expect(status).toEqual({ lang: 'absent', dom: 'absent', frontend: 'absent' });
// Touching frontend pulls it (and its declared deps if reachable).
const fe = (builders.proxies as { frontend: object }).frontend;
expect(fe).toBeDefined();
builders.disposeAll();
});
it('reports failed status when a factory throws on construct', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
broken: {
name: 'broken' as const,
coreDependencies: [] as const,
create() {
throw new Error('cannot init');
}
}
},
core
);
expect(() => (builders.proxies as { broken: unknown }).broken).toThrow();
expect(builders.statusMap()).toEqual({ broken: 'failed' });
builders.disposeAll();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.