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.
dev 7adf93ca57
Prefs as schema-based core + lowercase App.* surface
5 months ago
..
presets Prefs as schema-based core + lowercase App.* surface 5 months ago
service-factories Prefs as schema-based core + lowercase App.* surface 5 months ago
test Prefs as schema-based core + lowercase App.* surface 5 months ago
README.md Prefs as schema-based core + lowercase App.* surface 5 months ago
active-app.svelte.ts Prefs as schema-based core + lowercase App.* surface 5 months ago
consts.ts Move setBus/getBus to $bus; the bus owns the propagation pattern 5 months ago
errors.ts Move setBus/getBus to $bus; the bus owns the propagation pattern 5 months ago
events.ts Prefs as schema-based core + lowercase App.* surface 5 months ago
index.ts Prefs as schema-based core + lowercase App.* surface 5 months ago
service-builder.ts Reduce App core to Logger/Bus/Timers/Orca; everything else is opt-in services 5 months ago
services.ts Prefs as schema-based core + lowercase App.* surface 5 months ago
types.ts Prefs as schema-based core + lowercase App.* surface 5 months ago

README.md

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.

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

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 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.

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:

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:

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/.

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.

<!-- app/+layout.svelte -->
<script lang="ts">
	import { setBus } from '$bus';
	import { App } from './app';

	setBus(App.bus);
</script>
<!-- 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:

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:

services: {
	cart: defineActiveCart({ persistKey: 'cart' })
}

App.cart is now type-safe, lazy by default, and disposed in reverse order when App.dispose() runs.

Test

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.