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
parent
0eddd2a0e6
commit
d528652640
@ -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…
Reference in new issue