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.
352 lines
15 KiB
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.
|