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.
328 lines
11 KiB
328 lines
11 KiB
|
5 months ago
|
# 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.
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { createActiveApp } from '$active-app';
|
||
|
|
import {
|
||
|
|
defineActiveCache,
|
||
|
|
defineActiveLang,
|
||
|
|
defineActiveSession,
|
||
|
|
defineEngineHttp
|
||
|
|
} from '$active-app/services';
|
||
|
|
import { applyStandardOrca } from '$active-app/presets';
|
||
|
|
|
||
|
|
const App = createActiveApp({
|
||
|
|
logger: { level: LogLevel.INFO },
|
||
|
|
services: {
|
||
|
|
lang: defineActiveLang({ 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/services` | 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 uppercase surface (`App.lang`, `App.cache`, `App.format`,
|
||
|
|
`App.frontend`, `App.dom`, `App.storage`, `App.http`) has been removed. Those
|
||
|
|
pieces are now opt-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;
|
||
|
|
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](../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/services`.
|
||
|
|
|
||
|
|
```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 |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `defineActiveLang(options)` | `lang` | Schema is required. |
|
||
|
|
| `defineActiveStorage(options)` | `storage` | Memory adapter by default. |
|
||
|
|
| `defineActiveDom(props)` | `dom` | Inert on the server. |
|
||
|
|
| `defineActiveFormat(options)` | `format` | Wires to `lang` automatically when both are declared. |
|
||
|
|
| `defineActiveFrontend(options)` | `frontend` | Wires to `dom` and `lang` automatically. |
|
||
|
|
| `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 `lang` automatically when declared. |
|
||
|
|
|
||
|
|
## 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 => …));
|
||
|
|
</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 ← this file
|
||
|
|
├── 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/services
|
||
|
|
│ ├── index.ts
|
||
|
|
│ ├── cache.ts
|
||
|
|
│ ├── lang.ts
|
||
|
|
│ ├── storage.ts
|
||
|
|
│ ├── dom.ts
|
||
|
|
│ ├── format.ts
|
||
|
|
│ ├── frontend.ts
|
||
|
|
│ ├── http.ts
|
||
|
|
│ ├── session.ts
|
||
|
|
│ ├── auth.ts
|
||
|
|
│ ├── perm.ts
|
||
|
|
│ ├── connections.ts
|
||
|
|
│ └── sium.ts
|
||
|
|
├── 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: ['lang']`).
|
||
|
|
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.
|