You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/architecture/active-app.md

352 lines
15 KiB

---
title: ActiveApp — the arts composition root
type: reference
audience: human + agent
authority: E1 architecture — how the arts ecosystem composes into a runtime App (fixed core + declared services + orchestration)
status: current
source: migrated from src/arts/active-app/README.md (2026-07-03, arts-docs-reconciliation B1)
---
# active-app
`arts/active-app` is the **composition layer** of the ecosystem. It builds the
fixed runtime core, composes opt-in services declared by the application, and
exposes the orchestration engine that wires them together.
> **Whole-system architecture**: [`architecture/active-architecture.md`](./active-architecture.md).
> **The analogue composition root for the component layers**:
> [`architecture/active-uix.md`](./active-uix.md).
> **Executable contract** between `ActiveApp`, `ActiveUix` and the UIX layers:
> [`src/uix/contracts.ts`](../../src/uix/contracts.ts).
## Quick start
`ActiveApp` keeps the core (`logger`, `bus`, `timers`, `orca`, `prefs`) and
composes only the services the application declares in `services`. It does not
absorb UIX-specific decisions — visual preferences (`theme`, `mode`, `density`)
belong to Eidos, not to `App.prefs`.
```ts
import { createActiveApp } from '$active-app';
import {
defineActiveCache,
defineActiveLangs,
defineActiveSession,
defineEngineHttp
} from '$active-app/service-factories';
import { applyStandardOrca } from '$active-app/presets';
const App = createActiveApp({
logger: { level: LogLevel.INFO },
services: {
langs: defineActiveLangs({ schema: appLang, defaultLocale: 'es' }),
http: defineEngineHttp({ baseUrl: '/api' }),
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({
onRefresh,
onRevoke
})
}
});
applyStandardOrca(App);
```
## Two layers, three import paths
`active-app` is layered to keep bundles small and the contract obvious.
| Layer | Path | Loaded when |
| ------------------------- | ------------------------------- | ---------------------------------------------------------- |
| **Core** | `$active-app` | Always — every app needs `createActiveApp`. |
| **Service factories** | `$active-app/service-factories` | The app declares any service in `services: { … }`. |
| **Orchestration presets** | `$active-app/presets` | The app opts into standard reactions or cherry-picks them. |
Each layer is a separate barrel. An app that builds only the core never pulls
service factories or presets into its bundle.
## Core vs services
The composition has two layers:
- **Core** — `Logger`, `Bus`, `Timers`, `Orca`. Always built, never declared
as a service. Configurable via the `ActiveAppOptions` root.
- **Services** — opt-in pieces that the application declares in
`services: { … }`. If a service is not declared, it does not exist on
`App`, and TypeScript reports an error when consumers try to access it.
The legacy always-present service surface has been removed. `App.langs`,
`App.cache`, `App.clipboard`, `App.format`, `App.dom`, `App.storage` and
`App.http` exist only when the application declares those slots in `services`.
There is no migration period; the project did not have external consumers
when the cut happened.
## What the core provides
```ts
interface ActiveAppCore {
readonly logger: EngineLogger;
readonly bus: EngineBus<ActiveAppBusEvents>;
readonly timers: ActiveTimers;
readonly orca: EngineOrca;
readonly prefs: ActivePrefs;
dispose(): void;
}
```
- `logger` defaults to engine defaults (`level: WARN`, `consoleTransport()`).
Pass `{ level: NONE, transports: [] }` for silence.
- `bus` and `timers` are App-wide singletons. Services that need them
declare `'bus'` / `'timers'` in `coreDependencies`.
- `orca` is always present, **inert until the application registers
actions**. Apps that don't use orchestration pay only for the engine's
empty maps. See the [orca README](../../src/arts/orca/README.md) for the
supported surface.
- `dispose()` publishes `APP_EVENT_DISPOSE_STARTING` first, then tears
every constructed service down in reverse order, then the core.
## How services work
A service is anything an `AppServiceFactory` produces. Factories live in
`arts/active-app/service-factories/` and are exported from
`$active-app/service-factories`.
```ts
interface AppServiceFactory<TName, TCoreDeps, TServiceDeps, TInstance> {
readonly name: TName;
readonly coreDependencies: TCoreDeps;
readonly serviceDependencies?: TServiceDeps;
readonly initMode?: 'immediate' | 'lazy';
create(deps: { core: …; services: … }): TInstance;
dispose?(instance: TInstance): void;
}
```
The schema is just an object literal:
```ts
services: {
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({ onRefresh, onRevoke })
}
```
The builder validates the schema, computes a topological order, builds
`immediate` services right away, and exposes `lazy` ones behind getters
that materialise on first access. Construction order is dependency-first;
disposal runs in reverse.
### Service init modes
| Mode | When the service is built |
| ---------------- | ------------------------------------------------- |
| `lazy` (default) | First time `App.<name>` is read. |
| `immediate` | During `createActiveApp()`, after the core is up. |
`immediate` is for services with construction-time side effects (subscribing
to `BroadcastChannel`, hydrating from storage on boot, etc.). Everything else
is `lazy`.
### Service status
Every declared service has an observable status:
```ts
type ServiceStatus = 'absent' | 'present' | 'failed';
App.services; // Readonly<Record<string, ServiceStatus>>
```
Mostly used by devtools and tests; application code rarely reads it.
### Failure handling
If a factory's `create()` throws, the service status becomes `'failed'`.
Subsequent reads of `App.<name>` re-throw the original error wrapped in
`AappServiceConstructionFailedError`. The first read sees the same wrapped
error — the wrapping is cheap and uniform.
## Available services
| Factory | Slot | Notes |
| ---------------------------------------- | ------------- | ---------------------------------------------------------------------------------------- |
| `defineActiveLangs(options)` | `langs` | Schema is required; follows `core.prefs.language` when that dimension exists. |
| `defineActiveStorage(options)` | `storage` | Memory adapter by default. |
| `defineActiveClipboard(options)` | `clipboard` | Lazy capability wrapper around `navigator.clipboard.writeText` or an injected writer. |
| `defineActiveDom(props)` | `dom` | Inert on the server. |
| `defineActiveFormat(options)` | `format` | Reads `core.prefs.locale` when that dimension exists. |
| `defineActiveCache(options)` | `cache` | Passive — invalidation is driven by orca presets. |
| `defineActiveSession<TUser, …>(options)` | `session` | Publishes `SESSION_EVENT_*` on the bus. |
| `defineActivePerm(options)` | `perm` | Auto-invalidation is OFF; use orca preset. |
| `defineActiveAuth(options)` | `auth` | Requires an HTTP client in `options`. |
| `defineActiveConnections(options)` | `connections` | Identity tracking via orca preset. |
| `defineEngineHttp(options)` | `http` | Engine only — no Active wrapper. |
| `defineEngineSium(options)` | `sium` | Wires to `langs` automatically when declared. |
| `defineActiveAgent(options)` | `agent` | `timers` + `logger` come from the App core; identity / policy / transport are the app's. |
| `defineEngineMotion(options)` | `motion` | Engine only. Declares `serviceDependencies: ['dom']`; degrades without it. |
| `defineEngineScene(options)` | `scene` | Engine only — the ambient-scene runtime the canon shares as `uix.scene`. |
| `defineEngineSound(options)` | `sound` | Engine only — the Web Audio runtime extracted from sema's `SoundChannel`. |
> This table is checked against the directory: `docs:check` (`I1-catalog`)
> fails when `service-factories/` grows a slot this list does not mention. It
> had fallen four behind before that guard existed.
## Orchestration
`App.orca` is always present and inert. Reactions are not pre-wired — apps
register them explicitly through orca presets in `arts/active-app/presets/`.
```ts
import {
applyCacheClearOnRevoke,
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from '$active-app/presets';
// Cherry-pick:
applyCacheClearOnRevoke(App);
applyPermInvalidateOnIdentityChange(App);
// Or all standard presets at once:
applyStandardOrca(App);
```
Each `apply*` returns a detach function for testing and hot-reload.
### Why presets live here, not inside arts
An art (`arts/cache`, `arts/perm`, …) does not know about `arts/session` or
`arts/orca`. That knowledge belongs to the composition layer. Putting presets
in `arts/active-app/` keeps the inter-art dependency graph clean: every art
depends only on `libs/` and on the core (`logger`, `bus`, `timers`, `orca`),
never on a sibling art.
## Bus context bridge
The Svelte-context helper `setBus` / `getBus` lives in `$bus`, not here.
The bus is the semantic owner of the propagation pattern; App is just a
consumer that calls `setBus(App.bus)` once near the layout root.
```svelte
<!-- app/+layout.svelte -->
<script lang="ts">
import { setBus } from '$bus';
import { App } from './app';
setBus(App.bus);
</script>
```
```svelte
<!-- somewhere deep in the tree -->
<script lang="ts">
import { getBus } from '$bus';
const Bus = getBus();
$effect(() => Bus.on('something', (payload) => console.debug(payload)));
</script>
```
`getBus()` throws `BusNoContextError` (from `$libs/bus`) if no bus is in
scope — forgetting `setBus()` is a wiring bug, not a degraded mode.
## Events
Only one event is owned by `arts/active-app`:
```ts
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
```
It fires once at the start of `App.dispose()`, before any service teardown,
so subscribers can flush, persist or detach while their dependencies still
exist. Everything else used to be a republication of module-level events;
those republications have been removed in favour of orca presets that listen
to the canonical events directly.
`assertEventCanFire(type, where)` and `assertAppEventPayloadSafe(type,
payload)` are the safety nets used by typed publishers like
`publishAppDisposeStarting`. Both throw structured errors
(`AappInvalidEventRuntimeError`, `AappUnsafeEventPayloadError`) that
applications can catch.
## Errors
| Error | When it fires |
| ------------------------------------ | ------------------------------------------------------------------------- |
| `AappServiceNameMismatchError` | Schema key !== `factory.name`. |
| `AappServiceDependencyCycleError` | A cycle is detected in `serviceDependencies`. |
| `AappServiceConstructionFailedError` | A factory's `create()` throws. |
| `AappInvalidEventRuntimeError` | An `APP_EVENT_*` published in the wrong runtime. |
| `AappUnsafeEventPayloadError` | A sensitive key (`token`, `password`, `cookie`, …) is found in a payload. |
`getBus()` throws `BusNoContextError` (from `$libs/bus`) when no bus is
in Svelte context — that error belongs to `arts/bus/`, not `active-app/`.
All of them extend `CodeError` from `$libs/errs` and have type guards
(`isAappServiceNameMismatchError`, …).
## Filesystem layout
```
src/arts/active-app/
├── README.md ← stub → docs/architecture/active-app.md
├── index.ts ← public entry point ($active-app)
├── consts.ts
├── errors.ts
├── events.ts ← APP_EVENT_DISPOSE_STARTING + safety helpers
├── services.ts ← AppServiceFactory contract
├── service-builder.ts ← topology, lazy proxies, dispose
├── active-app.svelte.ts ← createActiveApp()
├── service-factories/ ← $active-app/service-factories
│ ├── index.ts ← the barrel
│ └── {slot}.ts ← ONE file per slot; the directory IS the
│ catalog (see "Available services" above —
│ this tree used to re-enumerate it and fell
│ four behind)
├── presets/ ← $active-app/presets
│ ├── index.ts
│ ├── cache-clear-on-revoke.ts
│ ├── cache-clear-on-identity-change.ts
│ ├── perm-invalidate-on-identity-change.ts
│ └── standard.ts
└── test/
└── service-builder.test.ts
```
## Adding a new service
Three steps:
1. **Build the art** as a normal `arts/<name>/` module. The art does not
know about `App` or `services`; it exposes a pure `createActive<Name>`
or `createEngine<Name>` factory.
2. **Write the `define*` factory** in
`arts/active-app/service-factories/<name>.ts`. Declare which core deps
you read (`coreDependencies: ['logger', 'bus']`) and which sibling
services you optionally consume (`serviceDependencies: ['langs']`).
Export it from `service-factories/index.ts`.
3. **Optional — add presets** in `arts/active-app/presets/<name>-…ts`
for any reactions the standard composition wants to ship.
The service is then declarable from any application:
```ts
services: {
cart: defineActiveCart({ persistKey: 'cart' });
}
```
`App.cart` is now type-safe, lazy by default, and disposed in reverse order
when `App.dispose()` runs.
## Test
```bash
npx vitest run src/arts/active-app/test
```
The current suite covers the schema validation, topological ordering,
lazy/immediate construction, status reporting, dispose order, idempotence,
and core/service dependency injection.

Powered by TurnKey Linux.