Reduce App core to Logger/Bus/Timers/Orca; everything else is opt-in services

Big-bang replacement of the active-app composition: legacy uppercase surface
(App.Lang, App.Cache, App.Format, App.Frontend, App.Dom, App.Storage, App.Http)
removed entirely. All non-core artifacts are now opt-in via services schema:

  services: { cache: defineActiveCache(), lang: defineActiveLang(...), ... }

Schema services are exposed as lowercase properties (App.cache, App.lang, …)
with end-to-end type safety; accessing a service the schema didn't declare
is a compile error.

Three import paths split for honest tree-shaking:
  $active-app          createActiveApp + core types/errors/bus-context
  $active-app/services defineActive* / defineEngine* factories
  $active-app/presets  applyCache* / applyPerm* / applyStandardOrca

Service factories declare core deps (logger/bus/timers/orca) and sibling
service deps (e.g. format wires localeSource from lang automatically when
both are declared). The builder validates names, computes topological order,
detects cycles, builds immediates eagerly, and exposes lazy proxies that
materialise on first access. factory.create() runs inside untrack so
subscriptions wired during construction (e.g. lang.onLocaleChange) cannot
crash the outer reactive scope when triggered from a $derived.

Reactions to lifecycle events (cache.clear on revoke / identity change,
perm.invalidate on identity change) move from internal bus subscriptions
inside arts to opt-in orca presets registered by the application:

  applyStandardOrca(App)  // or cherry-pick individual apply* functions

Also lands a working showcase at /demo wiring 10 of 12 services
(everything except auth/connections, which need a real server) plus a
mocked perm fetcher and a real http client against jsonplaceholder.

Misc cleanup along the way:
  - libs/cache/{key,policy,scope}.ts: missing CACHE_VALIDATION_MESSAGES
    imports (the throw paths were never covered by tests, so the bug
    only surfaced via the demo)
  - All arts READMEs scrubbed of autoInvalidateOn / APP_EVENT_USER_*
    references; bus README rewritten around the orca-preset model
  - refactorizacion.md moved out of src/ into docs/

Verification: 1347/1347 vitest tests passing, demo loads and exercises
all wired services in a real browser with zero console errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent c6de4a11f1
commit eb6001fae4

@ -153,10 +153,16 @@ whether or not i18n was configured.
services, but construction is explicit at the call site.
```ts
const sium = createEngineSium({ lang: App.Lang, logger: App.Logger });
const Connections = App.createActiveConnections();
const Auth = App.createActiveAuth({ initial: data.auth });
const Perms = App.createActivePerms({ endpoint: '/permissions' });
const App = createActiveApp({
services: {
sium: defineEngineSium({}),
connections: defineActiveConnections({}),
auth: defineActiveAuth({ initial: data.auth }),
perm: defineActivePerm({ endpoint: '/perm' })
}
});
applyStandardOrca(App);
```
See `aapp/README.md` for the full composition contract.

@ -1,622 +1,323 @@
# aapp — ActiveApp
# active-app
`aapp` is the application-level composition that wires the runtime artifacts
under a single namespace, with a single locale source of truth and a single
logger.
`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 '$aapp';
import { translations } from './lang/schema';
import { createActiveApp } from '$active-app';
import {
defineActiveCache,
defineActiveLang,
defineActiveSession,
defineEngineHttp
} from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
const App = createActiveApp({
lang: { schema: translations, defaultLocale: 'es', fallbackChain: ['en'] },
logger: {
level: LogLevel.INFO,
globalContext: { appVersion: '1.0.0', env: 'prod' },
transports: [consoleTransport()]
},
frontend: { theme: 'base', mode: 'auto', density: 'normal' }
});
App.setLocale('es-MX');
App.Lang.t('common.ok');
App.Format.currency.format(12.5);
App.Frontend.setTheme('forest');
App.Logger.info('boot', 'app ready');
App.dispose();
```
## What it composes
| Member | Always present | Default when not configured |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `App.Logger` | yes | engine default — `level: WARN` + `consoleTransport()`. Pass `{ level: LogLevel.NONE, transports: [] }` for silence |
| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, `t('a.b\|Fallback')` returns `'Fallback'`, and DEV warns once per unresolved path through Logger under `lang.mono` |
| `App.Format` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) |
| `App.Frontend` | yes | real with default theme/mode/density |
| `App.Dom` | yes | real with default breakpoints |
| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; storage diagnostics are wired through the shared Logger |
| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry }` |
| `App.Timers` | yes | `ActiveTimers` scheduler owned by App. Used by artifacts that need keyed runtime timers (`sess` auto-refresh, `conn` reconnect/heartbeat/ack) and disposed by `App.dispose()` |
| `App.Bus` | yes | `EngineBus` owned by App. `orchestration` only controls translators from module events to `app.*`; destructive reactions stay opt-in in each consumer |
| `App.Cache` | yes | `ActiveCache` backed by memory by default. Configure `cache: { adapter, policies, scopeResolver, autoInvalidateOn }` for persistence, policies or app-event reactions |
| `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic |
| `App.Auth` | no | created lazily through `App.createActiveAuth(...)`. App injects `Http`, `Cache` and `Logger`; the server authority remains `$svrs/auth` |
| `App.Perms` | no | created lazily through `App.createActivePerms(...)`. App injects `Http`, `Logger` and `Bus`; automatic invalidation uses `autoInvalidateOn` |
| `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and Bus; automatic reauth uses `autoReauthOn` plus each connection's own `session` option |
Server-authoritative engines that have a browser reflector live under
`$svrs/*`: use `$svrs/auth` for `createEngineAuth()` and auth HTTP handlers,
`$svrs/perm` for `createEnginePerms()` and authorization handlers, and
`$svrs/cache` for `createEngineCache()` in services, repositories or server
load code. `aapp` composes only the active/client side.
`Sium` is **not** a member of App. Validation is page-scoped — pages with
forms construct their own engine via the one-line `App.createSiumEngine()`
method that wires `App.Lang` and `App.Logger` automatically:
```ts
const sium = App.createSiumEngine();
const result = await sium.validate(LoginSchema, input);
```
Equivalent to `createEngineSium({ lang: App.Lang, logger: App.Logger, locale: App.Lang.getLocale() })`.
Each call returns a fresh engine. The method takes no arguments — Lang,
Logger and the active locale all flow from App, so there is nothing left to
override at this layer.
The `locale` passed is a **snapshot** of `App.Lang.getLocale()` at the
moment of construction. It is the fallback used when the caller invokes
`sium.resolveIssue(issue)` without an explicit locale; the snapshot does
not react to subsequent `App.setLocale(...)` calls. Pages that need
locale-reactive issue messages either pass `App.Lang.getLocale()` per call
(`sium.resolveIssue(issue, App.Lang.getLocale())`) or rebuild the engine
inside an `$effect` that depends on the locale. If a page needs a custom
Sium engine (different logger category, different locale default, etc.) it
constructs `createEngineSium(...)` directly from `$sium`.
The method lives on App rather than as a standalone helper because App is
already in scope on every page via context — `App.createSiumEngine()` is
the natural call site.
`Connections` is also lazy, but for the opposite reason: realtime is
application-scoped infrastructure, while the connection map is app-specific and
benefits from call-site generics:
```ts
const Connections = App.createActiveConnections<AppConnections>();
```
Each registry is disposed by `App.dispose()`. Individual connections decide
whether they react to app identity events via the registry's `autoReauthOn`
and the connection's own `session` option.
## Event bus and orchestration
`App.Bus` is always present. It is an `EngineBus<ActiveAppBusEvents>` created
after `App.Timers`, with the shared `Logger` and `Timers.clock` injected:
```ts
const App = createActiveApp({
bus: {
listenerErrorMode: 'log-and-continue',
maxListenersPerEvent: 64
logger: { level: LogLevel.INFO },
services: {
lang: defineActiveLang({ schema: appLang, defaultLocale: 'es' }),
http: defineEngineHttp({ baseUrl: '/api' }),
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({
onRefresh,
onRevoke
})
}
});
App.Bus.on(APP_EVENT_USER_IDENTITY_CHANGED, (event) => {
console.log(event.payload.cause);
});
applyStandardOrca(App);
```
The bus does not make modules know each other. The model is:
## Two layers, three import paths
```txt
module event -> aapp translator -> app event -> consumer opt-in reaction
```
`active-app` is layered to keep bundles small and the contract obvious.
Current identity flow:
| 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. |
```txt
sess emits SESSION_EVENT_CHANGED
aapp's session translator publishes APP_EVENT_USER_IDENTITY_CHANGED
cach/perm/conn may react only when their own auto*On option opts in
```
Each layer is a separate barrel. An app that builds only the core never pulls
service factories or presets into its bundle.
`orchestration` controls **translators only**:
## Core vs services
```ts
createActiveApp(); // same as orchestration: 'standard'
createActiveApp({ orchestration: 'silent' }); // no automatic app.* translation
createActiveApp({ orchestration: ['identity'] }); // only selected translators
```
The composition has two layers:
Current translator contract:
- **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.
| Translator | Current source | Publishes |
| --- | --- | --- |
| `identity` | built-in: `SESSION_EVENT_CHANGED` from `App.createActiveSession(...)` | `APP_EVENT_USER_IDENTITY_CHANGED` |
| `dispose` | built-in: `App.dispose()` | `APP_EVENT_DISPOSE_STARTING` |
| `permissions-refresh` | typed public contract / explicit publish point | `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` |
| `tenant-switched` | typed public contract / explicit publish point | `APP_EVENT_TENANT_SWITCHED` |
| `connectivity` | typed public contract / explicit publish point | `APP_EVENT_CONNECTIVITY_CHANGED` |
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.
It never decides destructive effects such as clearing cache, invalidating
permission decisions or reauthenticating sockets. Those live in the consumer:
## What the core provides
```ts
const App = createActiveApp({
cache: { autoInvalidateOn: 'standard' },
permissions: {
endpoint: '/api/permissions',
autoInvalidateOn: 'standard'
},
connections: { autoReauthOn: 'standard' }
});
interface ActiveAppCore {
readonly Logger: EngineLogger;
readonly Bus: EngineBus<ActiveAppBusEvents>;
readonly Timers: ActiveTimers;
readonly Orca: EngineOrca;
dispose(): void;
}
```
Defaults:
| Layer | Default |
| --- | --- |
| `App.Bus` | always present |
| `orchestration` | `'standard'` translator set; currently identity and dispose have built-in sources |
| `cache.autoInvalidateOn` | none |
| `permissions.autoInvalidateOn` | none |
| `connections.autoReauthOn` | none |
| `orchestration: 'silent'` | disables translators, keeps the bus usable |
`APP_EVENT_*` payloads are public and must not contain tokens, passwords,
authorization headers or sensitive hashes. If a consumer needs sensitive
context, it should resolve it from its own state or backend by correlation,
not from the event payload.
## What it solves
- **Single locale source.** `App.setLocale('es-MX')` propagates to `Lang`,
`Format` and `Frontend` through a shared `LocaleSource`. No bridge code per
call site.
- **Single logger.** Built once and piped into `Lang.setLogger` so every
artifact emits structured entries through the same transports (console,
Sentry, Datadog, ...).
- **Single event bus.** `App.Bus` carries app-level facts. App translates
module events to `app.*`; cache, permissions and connections only mutate
themselves when their own `auto*On` options opt in.
- **Uniform call sites.** `App.Lang.t(label)` and `App.Format.*` always work,
whether or not the caller configured i18n or fmts. No null checks.
- **Single lifecycle.** `App.dispose()` tears down the optional session,
bus, timers, persistence bridge, frontend, dom, formats, storage, lang and
logger in a deterministic order.
## Composition order
1. **Logger** — `createEngineLogger(options.logger)`. The engine applies its
own defaults when `options.logger` is undefined.
2. **Lang** — real `createActiveLang(...)` when `options.lang.schema` is
provided; mono otherwise. Both wire `Lang.setLogger` to the shared
Logger.
3. **Storage** — built next so Frontend can read persisted preferences
before construction. Storage diagnostics are wired to the shared Logger.
4. **Format** — built with a `localeSource` derived from Lang.
5. **Dom** — built before Frontend.
6. **Frontend** — receives Dom and the same `localeSource`. When
`frontend.persist` is configured, persisted values seed the initial
options and `onPreferenceChange` is wired to write back to Storage.
7. **Http** — built with the shared `Logger` injected automatically so
request/retry/error events land under category `'http'`. In SvelteKit
`load`, scope to the request via `App.Http.with({ fetch: event.fetch })`.
8. **Timers** — built with the shared `Logger`. This is the App-owned
scheduler used by long-lived runtime tasks; no module-global singleton.
9. **Bus** — built with the shared `Logger` and Timers clock. App uses it for
module-event translation and exposes it as `App.Bus`.
10. **Cache** — built with the shared `Logger` and `Bus`. It is always present with a
memory adapter unless `cache.adapter` is configured. Scopes remain explicit
through each query and can use `cache.scopeResolver`. `autoInvalidateOn`
controls reactions to public `app.*` events.
11. **Sess** — created lazily via `App.createActiveSession(...)`, not from
`createActiveApp(...)` options. App injects Logger and Bus, but the
consumer keeps auth policy explicit (`storage`, `onRefresh`, `onRevoke`,
HTTP hooks).
12. **Connections** — created lazily via `App.createActiveConnections(...)`.
App injects Logger, Timers and Bus; `autoReauthOn` controls app-event
reactions.
13. **Perms** — created lazily via `App.createActivePerms(...)`.
App injects Logger, Http and Bus; endpoint/defaults can be provided either
in `createActiveApp({ permissions })` or at the factory call site.
14. **Auth** — created lazily via `App.createActiveAuth(...)`. App injects
Http, Cache and Logger; `$svrs/auth` remains the server authority.
`dispose()` runs in reverse order.
## Common shapes
### Full multilingual app
- `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 [orca v0.0](../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.
```ts
const App = createActiveApp({
lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
frontend: { theme: 'base', mode: 'auto' }
});
```
## How services work
### Monolingual app with fixed currency
A service is anything an `AppServiceFactory` produces. Factories live in
`arts/active-app/service-factories/` and are exported from
`$active-app/services`.
```ts
const App = createActiveApp({
formats: { currency: { currency: 'EUR' } },
frontend: { theme: 'base' }
});
App.Format.currency.format(99.5); // "99,50 €" with default locale
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;
}
```
`App.Lang` is mono — components calling `App.Lang.t('actions.save|Save')`
render `'Save'` without ever loading a translation table.
### Headless / API surface
The schema is just an object literal:
```ts
const App = createActiveApp({
logger: { level: LogLevel.WARN, transports: [httpTransport({ url })] }
});
services: {
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({ onRefresh, onRevoke })
}
```
Frontend and Dom are still constructed but their browser-only effects (viewport
tracking, attribute writes) no-op in SSR.
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.
## Locale flow
### Service init modes
Lang is the single source of truth. Format and Frontend subscribe to it via a
common `LocaleSource` (`$locale`). The locale value is BCP 47:
```ts
App.setLocale('es'); // bare base
App.setLocale('es-MX'); // exact regional variant
App.setLocale('pt-BR'); // works end-to-end
```
See `$lang/README.md` for the BCP 47 resolution rules in `Lang.t()` /
`Lang.ts()`.
When `lang` is not configured, `App.setLocale` still updates the mono lang's
internal locale and notifies Format/Frontend — locale switching keeps working.
| Mode | When the service is built |
| --- | --- |
| `lazy` (default) | First time `App.<name>` is read. |
| `immediate` | During `createActiveApp()`, after the core is up. |
### SSR locale resolution + hydration
`immediate` is for services with construction-time side effects (subscribing
to `BroadcastChannel`, hydrating from storage on boot, etc.). Everything else
is `lazy`.
`createActiveApp(...)` does **not** read `navigator.language`. That is a
deliberate decision: reading the navigator on the client while the server
rendered with a different locale produces a hydration mismatch and a
one-frame text flash. Locale is the app's responsibility — resolve it on the
server, pass it as data to the client, and use it as `defaultLocale` when
constructing App.
### Service status
The canonical SvelteKit pattern:
Every declared service has an observable status:
```ts
// src/web/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
const SUPPORTED = ['es', 'en', 'es-MX', 'es-AR', 'en-GB', 'pt-BR'] as const;
const DEFAULT = 'es';
function pickLocale(accept: string | null, supported: readonly string[]): string {
if (!accept) return DEFAULT;
const ranked = accept
.split(',')
.map((entry) => {
const [tag, q] = entry.trim().split(';q=');
return { tag: tag.toLowerCase(), q: q ? Number(q) : 1 };
})
.sort((a, b) => b.q - a.q);
for (const { tag } of ranked) {
// Exact BCP 47 match first, then base.
if (supported.includes(tag)) return tag;
const base = tag.split('-')[0];
if (supported.includes(base)) return base;
}
return DEFAULT;
}
type ServiceStatus = 'absent' | 'present' | 'failed';
export const load: LayoutServerLoad = ({ request, cookies }) => {
const cookie = cookies.get('locale');
if (cookie && SUPPORTED.includes(cookie)) return { locale: cookie };
const locale = pickLocale(request.headers.get('accept-language'), SUPPORTED);
return { locale };
};
App.services; // Readonly<Record<string, ServiceStatus>>
```
```svelte
<!-- src/web/routes/+layout.svelte -->
<script lang="ts">
import { setContext, onDestroy } from 'svelte';
import { createActiveApp } from '$aapp';
import { translations } from '$lib/lang/schema';
let { data, children } = $props();
Mostly used by devtools and tests; application code rarely reads it.
const App = createActiveApp({
lang: { schema: translations, defaultLocale: data.locale, fallbackChain: ['en'] },
logger: {
/* ... */
}
});
### Failure handling
setContext('app', App);
onDestroy(() => App.dispose());
</script>
{@render children()}
```
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.
Server and client agree on the locale on first render — no mismatch, no flash.
## Available services
To let the user change locale at runtime, persist the choice to a cookie so
the next request re-renders with the same value:
| 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
async function changeLocale(locale: SupportedLocale): Promise<void> {
App.setLocale(locale);
document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`;
}
import {
applyCacheClearOnRevoke,
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from '$active-app/presets';
// Cherry-pick:
applyCacheClearOnRevoke(App);
applyPermInvalidateOnIdentityChange(App);
// Or all standard presets at once:
applyStandardOrca(App);
```
If you genuinely want to honor `navigator.language` on first visit, do it
once in the server load when no cookie exists and no `Accept-Language` is
set — never on the client.
## Storage
`App.Storage` is always present. Without configuration it uses an in-memory
adapter — values exist for the lifetime of the App and never persist. For
real persistence, pass an adapter:
Each `apply*` returns a detach function for testing and hot-reload.
```ts
import { createActiveApp, localAdapter } from '$aapp';
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' }
});
### Why presets live here, not inside arts
const cart = App.Storage.entry('cart', { items: [] as string[] });
cart.update((p) => ({ ...p, items: [...p.items, 'sku-42'] }));
```
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.
Per-entry overrides let you mix backends — cookies for SSR-readable values,
localStorage for the rest:
## Bus context bridge
```ts
import { createActiveApp, localAdapter, cookieAdapter } from '$aapp';
For Svelte-side consumers that want to subscribe with `$effect`:
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' }
});
```svelte
<!-- app/+layout.svelte -->
<script lang="ts">
import { setBus } from '$active-app';
import { App } from './app';
const locale = App.Storage.entry('locale', 'es', {
adapter: cookieAdapter({ path: '/', maxAge: 31_536_000 }),
namespace: false,
raw: true
});
setBus(App.Bus);
</script>
```
Storage diagnostics are wired automatically through `StorageDiagnostics` and
the shared `App.Logger`; failures include `{ adapter, key, fullKey, op, error }`
in the diagnostic context. See `$stor/README.md` for the full API (adapters,
envelope, versioning, validation).
### Reactive keys
When the storage key tracks a runed variable (current user, active
workspace, route param), use `App.Storage.dynamicEntry()`:
```svelte
<!-- somewhere deep in the tree -->
<script lang="ts">
let userId = $state(1);
const profile = App.Storage.dynamicEntry(
() => `user-${userId}:profile`,
() => ({ name: '', cart: [] as string[] })
);
// userId = 2 → profile rebinds to 'user-2:profile' (previous entry
// disposed, onChange listeners migrate automatically).
import { getBus } from '$active-app';
const Bus = getBus();
$effect(() => Bus.on('something', payload => …));
</script>
```
Must run inside a Svelte component or `$effect.root` scope.
`getBus()` throws `AappBusNoContextError` if no bus is in scope.
### Cross-tab sync without polling
## Events
Wrap any adapter with `withBroadcast` (re-exported from `$aapp`) for
instant cross-tab synchronization through `BroadcastChannel`:
Only one event is owned by `arts/active-app`:
```ts
import { createActiveApp, localAdapter, cookieAdapter, withBroadcast } from '$aapp';
const App = createActiveApp({
storage: {
adapter: withBroadcast(localAdapter, { channel: 'my-app' }),
namespace: 'my-app'
}
});
// Cookies + broadcast = changes propagate across tabs the moment they
// happen (browsers do not emit a native event for cookie mutations).
const session = withBroadcast(cookieAdapter({ path: '/', maxAge: 3600 }), {
channel: 'my-app:session'
});
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
```
### Persisting Frontend preferences
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.
Theme, mode, density, dir, reducedMotion, reducedSound can be persisted with
a single flag:
`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.
```ts
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' },
frontend: { theme: 'base', persist: true }
});
## Errors
App.Frontend.setTheme('forest'); // → written to localStorage
// next reload → Frontend reads 'forest' from storage during construction
```
| Error | When it fires |
| --- | --- |
| `AappServiceNameMismatchError` | Schema key !== `factory.name`. |
| `AappServiceDependencyCycleError` | A cycle is detected in `serviceDependencies`. |
| `AappServiceConstructionFailedError` | A factory's `create()` throws. |
| `AappBusNoContextError` | `getBus()` outside a tree that called `setBus()`. |
| `AappInvalidEventRuntimeError` | An `APP_EVENT_*` published in the wrong runtime. |
| `AappUnsafeEventPayloadError` | A sensitive key (`token`, `password`, `cookie`, …) is found in a payload. |
Selective + per-key overrides:
All of them extend `CodeError` from `$libs/errs` and have type guards
(`isAappServiceNameMismatchError`, …).
```ts
import { createActiveApp, localAdapter, cookieAdapter } from '$aapp';
## Filesystem layout
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' },
frontend: {
theme: 'base',
persist: {
keys: ['theme', 'density', 'mode'],
overrides: {
// theme to a cookie so the server can render the right palette
theme: { adapter: cookieAdapter({ path: '/' }), namespace: false, raw: true }
}
}
}
});
```
When `persist` is set but no persistent adapter is configured (the default
in-memory adapter is in use), values still flow through Storage — they just
do not survive reload. No warning is emitted; the absence of persistence is
visible in the storage adapter the caller chose.
## State primitive
aapp does **not** include a stores system. Svelte 5 + runes already provide
the primitive: a `.svelte.ts` module with `$state` is your store, scoped to
import graph rather than to a global registry.
```ts
// src/lib/stores/cart.svelte.ts
let items = $state<CartItem[]>([]);
export const cart = {
get items() {
return items;
},
add(item: CartItem) {
items.push(item);
},
clear() {
items = [];
}
};
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()
├── bus-context.svelte.ts ← setBus / getBus
├── 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
```
Pages and components import `cart` directly. The "infrastructure" artifacts
(Logger, Lang, Format, Frontend, Dom) live in App because they are
cross-cutting and need uniform configuration. Domain state (current user,
cart, session, feature flags) is application-specific — putting it under
`App.Stores` would couple the framework to a bucket of unrelated nouns.
For a Pinia/Zustand-style central registry, build it in user space — it does
not belong in `aapp`.
## Testing
Use `createTestApp(options)` instead of `createActiveApp(options)` in unit
tests. Same shape, plus:
- silent logger by default (no console pollution)
- `captureLogs: true` attaches a sink and exposes entries as `App.entries`
`createTestApp` lives at the `$aapp/testing` subpath so it does not ship with
production bundles that import the main `$aapp` barrel:
```ts
import { createTestApp } from '$aapp/testing';
const App = createTestApp({ captureLogs: true, lang: { schema } });
## Adding a new service
App.Logger.warn('auth', 'token expiring');
expect(App.entries).toHaveLength(1);
expect(App.entries[0].category).toBe('auth');
App.dispose();
```
Three steps:
If the caller passes their own `logger.transports`, the capture transport is
appended — both sinks receive every entry.
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.
## API
The service is then declarable from any application:
```ts
interface ActiveAppOptions<S extends LangNode> {
logger?: LoggerOptions;
lang?: { schema: S; defaultLocale?: SupportedLocale; fallbackChain?: SupportedLocale[] };
formats?: Omit<ActiveFormatOptions, 'locale' | 'localeSource'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'> & {
persist?: FrontendPersist;
};
dom?: ActiveDomProps;
storage?: ActiveAppStorageOptions;
http?: Omit<EngineHttpOptions, 'logger'>;
timers?: Omit<EngineTimersOptions, 'logger'>;
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
cache?: Omit<ActiveCacheOptions, 'logger' | 'bus'>;
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session' | 'bus'>;
permissions?: Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>;
auth?: Omit<ActiveAuthOptions, 'http' | 'cache' | 'logger'>;
orchestration?: 'standard' | 'silent' | false | readonly ActiveAppOrchestrationTranslator[];
}
interface ActiveApp<S extends LangNode = LangNode> {
readonly Logger: EngineLogger;
readonly Lang: ActiveLang<S>;
readonly Format: ActiveFormat;
readonly Frontend: ActiveFrontend;
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
readonly Http: EngineHttp;
readonly Timers: ActiveTimers;
readonly Bus: EngineBus<ActiveAppBusEvents>;
readonly Cache: ActiveCache;
getLocale(): SupportedLocale;
setLocale(locale: SupportedLocale): void;
onLocaleChange(fn: (locale: SupportedLocale) => void): () => void;
createSiumEngine(): EngineSium;
createActiveSession<TUser, TCredential = undefined, TData = undefined>(
options?: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger' | 'bus'>
): ActiveSession<TUser, TCredential, TData>;
createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session' | 'bus'>
): ActiveConnections<TConnections>;
createActivePerms(
options?: Partial<Omit<ActivePermsOptions, 'logger' | 'http' | 'bus'>>
): ActivePerms;
createActiveAuth(options?: Omit<ActiveAuthOptions, 'http' | 'cache' | 'logger'>): ActiveAuth;
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
readonly Perms: ActivePerms | undefined;
readonly Auth: ActiveAuth | undefined;
dispose(): void;
services: {
cart: defineActiveCart({ persistKey: 'cart' })
}
```
## Why Sium is out
Sium is a validation library that reaches into per-page data — login forms,
profile editors, signup wizards. Putting it in App would force every page
(including those without forms) to load the entire schema/types/issues machinery
just to use Lang or Format. Keeping Sium page-scoped means:
`App.cart` is now type-safe, lazy by default, and disposed in reverse order
when `App.dispose()` runs.
- Pages without validation pay nothing for it.
- Each form can use a sium engine tuned to its own needs (custom logger
category, validation context, etc.).
- App stays focused on the runtime contract every page needs.
## Test
The standard pattern is one line at the top of the page module:
```ts
const sium = App.createSiumEngine();
```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.

@ -1,92 +1,35 @@
import { createActiveDom } from '$adom/active-dom.svelte';
/**
* `createActiveApp()` — composed runtime root.
*
* Builds the four pieces of the core (Logger, Bus, Timers, Orca) and
* then defers everything else to the declarative service schema. The
* function itself is short on purpose — every art-specific knob has
* moved to its `defineActive*` / `defineEngine*` factory.
*/
import type { EngineBus } from '$bus';
import { createSvelteEngineBus } from '$bus';
import { createActiveCache } from '$cache/active-cache.svelte';
import { createActiveFrontend } from '$frontend/active-frontend.svelte';
import { createActiveFormat } from '$format/active-formats.svelte';
import { createEngineHttp } from '$http/engine-http';
import type { LangNode } from '$libs/lang';
import type { ActiveLang } from '$lang';
import { createActiveLang } from '$lang/active-lang.svelte';
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
import { createEngineLogger } from '$logger/engine-logger';
import { createActiveStorage } from '$storage/active-storage.svelte';
import { createActiveTimers } from '$timer/active-timers.svelte';
import { createEngineOrca } from '$orca';
import { publishAppDisposeStarting } from './events.ts';
import { createActiveTimers } from '$timer/active-timers.svelte';
import {
applyFrontendPreferenceSnapshot,
bindFrontendStorage,
loadPersistedFrontendPreferences
} from './integrations/frontend-storage';
import { APP_MODULE } from './consts.ts';
import { publishAppDisposeStarting } from './events.ts';
import { buildServiceBuilders } from './service-builder.ts';
import type { AppServiceSchema, CoreServices } from './services.ts';
import type {
ActiveApp,
ActiveAppBusEvents,
ActiveAppLegacy,
ActiveAppCore,
ActiveAppOptions
} from './types.ts';
import type { EngineBus } from '$bus';
/**
* Composed application surface. See `./types.ts` for the full contract.
*/
export function createActiveApp<
S extends LangNode = LangNode,
TSchema extends AppServiceSchema = AppServiceSchema
>(options: ActiveAppOptions<S, TSchema> = {}): ActiveApp<S, TSchema> {
export function createActiveApp<TSchema extends AppServiceSchema = AppServiceSchema>(
options: ActiveAppOptions<TSchema> = {}
): ActiveApp<TSchema> {
// ── Core ────────────────────────────────────────────────────────────
const Logger = createEngineLogger(options.logger);
let Lang: ActiveLang<S>;
if (options.lang) {
const real = createActiveLang(
options.lang.schema,
options.lang.defaultLocale,
options.lang.fallbackChain
);
real.setLogger(Logger);
Lang = real;
} else {
Lang = createActiveMonoLang({ logger: Logger }) as unknown as ActiveLang<S>;
}
const Storage = createActiveStorage({
adapter: options.storage?.adapter,
namespace: options.storage?.namespace,
logger: Logger
});
const localeSource = {
getLocale: () => Lang.getLocale(),
onLocaleChange: (fn: (locale: string) => void) => Lang.onLocaleChange(fn)
};
const Format = createActiveFormat({
...options.formats,
localeSource
});
const Dom = createActiveDom(options.dom);
const { persist, ...frontendOptions } = options.frontend ?? {};
const { snapshot, entries } = loadPersistedFrontendPreferences(Storage, persist, frontendOptions);
const persistedFrontendOptions = applyFrontendPreferenceSnapshot(frontendOptions, snapshot);
const Frontend = createActiveFrontend({
...persistedFrontendOptions,
dom: Dom,
localeSource
});
const teardownPersistence = bindFrontendStorage(Frontend, entries);
const Http = createEngineHttp({
...options.http,
logger: Logger
});
const Timers = createActiveTimers({
...options.timers,
logger: Logger
@ -97,73 +40,50 @@ export function createActiveApp<
logger: Logger,
clock: Timers.clock
});
const Orca = createEngineOrca({
...options.orca,
bus: Bus,
timers: Timers,
logger: Logger
});
const coreForServices: CoreServices = {
logger: Logger,
// `EngineBus<ActiveAppBusEvents>` is structurally a richer bus;
// service factories accept the generic `EngineBus`.
bus: Bus as unknown as EngineBus,
timers: Timers,
orca: Orca
};
// ── Services ────────────────────────────────────────────────────────
const core = coreForBuilder(Logger, Bus, Timers, Orca);
const serviceBuilders = options.services
? buildServiceBuilders(options.services as AppServiceSchema, coreForServices)
? buildServiceBuilders(options.services as AppServiceSchema, core)
: undefined;
const Cache = createActiveCache({
...options.cache,
logger: Logger
});
let disposed = false;
const baseApp: ActiveAppLegacy<S> = {
const baseApp: ActiveAppCore = {
Logger,
Lang,
Format,
Frontend,
Dom,
Storage,
Http,
Timers,
Bus,
Timers,
Orca,
Cache,
getLocale: () => Lang.getLocale(),
setLocale: (locale) => Lang.setLocale(locale),
onLocaleChange: (fn) => Lang.onLocaleChange(fn),
dispose() {
if (disposed) return;
disposed = true;
// Announce dispose BEFORE tearing anything down so subscribers
// can still reach the bus and any service they depend on.
publishAppDisposeStarting(Bus, { cause: APP_MODULE });
// Schema-declared services first (reverse construction order
// is handled by the builder).
serviceBuilders?.disposeAll();
Cache.dispose();
// Core last, in reverse build order.
Orca.dispose();
Bus.dispose();
Timers.dispose();
teardownPersistence();
Frontend.dispose();
Dom.dispose();
Format.dispose();
Storage.dispose();
Lang.dispose();
Logger.dispose();
}
};
// Compose the final App: legacy base + schema services + status
// Compose the final App: core + schema services + status
// introspection. Services are exposed as own properties via
// `Object.defineProperty` so lazy getters are preserved.
const app = baseApp as ActiveApp<S, TSchema>;
const app = baseApp as ActiveApp<TSchema>;
if (serviceBuilders !== undefined) {
for (const name of Object.keys(serviceBuilders.proxies)) {
Object.defineProperty(app, name, {
@ -191,3 +111,32 @@ export function createActiveApp<
return app;
}
/**
* Adapt the App-level core to the generic `CoreServices` contract that
* service factories see.
*
* The downcast on `bus` is safe because:
* - `EngineBus<ActiveAppBusEvents>` is structurally a more precise
* instance of `EngineBus<BusEventMap>`.
* - Services that need to publish App-owned events do so through the
* dedicated typed publishers (`publishAppDisposeStarting`, …), not
* via raw `bus.publish(type, payload)` against an arbitrary string.
*
* Keeping this in a named helper means the rationale stays attached to
* the cast instead of trailing as an inline comment that future edits
* might lose.
*/
function coreForBuilder(
logger: ActiveAppCore['Logger'],
bus: ActiveAppCore['Bus'],
timers: ActiveAppCore['Timers'],
orca: ActiveAppCore['Orca']
): CoreServices {
return {
logger,
bus: bus as unknown as EngineBus,
timers,
orca
};
}

@ -8,6 +8,12 @@ export const APP_MODULE = 'app';
export const APP_BUS_CONTEXT_KEY = 'arts.app.bus';
/**
* Substrings that identify a sensitive payload key. Any property whose
* lowercased name contains one of these is rejected by
* `assertAppEventPayloadSafe()`. The list is intentionally
* conservative — App-level events are facts, not transports for secrets.
*/
export const APP_EVENT_SENSITIVE_KEY_PARTS = [
'authorization',
'cookie',

@ -1,3 +1,24 @@
/**
* Errors for `arts/active-app`.
*
* Two classes of errors live here:
*
* - **Schema validation** — thrown synchronously during
* `createActiveApp()` when the service schema is malformed
* (`AappServiceNameMismatchError`,
* `AappServiceDependencyCycleError`,
* `AappServiceConstructionFailedError`).
* - **Bus / events** — thrown by the bus-context bridge and the
* safe-publish helpers when callers misuse the App's bus
* (`AappBusNoContextError`, `AappInvalidEventRuntimeError`,
* `AappUnsafeEventPayloadError`).
*
* Note: the legacy `AappAlreadyCreatedError` is gone. The declarative
* service schema makes "factory called twice" structurally impossible —
* a duplicate service key is a JavaScript object-literal error, not a
* runtime concern.
*/
import {
CodeError,
errCode,
@ -11,7 +32,7 @@ import { APP_MODULE } from './consts.ts';
// ── Error codes ────────────────────────────────────────────────────────
export const APP_ERR: ModuleSeed = moduleSeed(APP_MODULE);
export const APP_ERR_ALREADY_CREATED: ErrCode = errCode(APP_ERR, 'already_created');
export const APP_ERR_SERVICE_NAME_MISMATCH: ErrCode = errCode(APP_ERR, 'service_name_mismatch');
export const APP_ERR_SERVICE_DEPENDENCY_CYCLE: ErrCode = errCode(
APP_ERR,
@ -29,17 +50,16 @@ export const APP_ERR_EVENT_UNSAFE_PAYLOAD: ErrCode = errCode(APP_ERR_EVENT, 'uns
// ── Error message builders ─────────────────────────────────────────────
export const APP_ERROR_MSG_ALREADY_CREATED = 'App factory called more than once.';
export const APP_ERROR_MSG_BUS_NO_CONTEXT = `[${APP_MODULE}] no bus in context — call setBus(App.Bus) in a layout before getBus()`;
export const appServiceNameMismatchMessage = (key: string, factoryName: string): string =>
`[active-app] service factory name "${factoryName}" must match schema key "${key}"`;
`[${APP_MODULE}] service factory name "${factoryName}" must match schema key "${key}"`;
export const appServiceDependencyCycleMessage = (cycle: readonly string[]): string =>
`[active-app] dependency cycle detected: ${cycle.join(' -> ')}`;
`[${APP_MODULE}] dependency cycle detected: ${cycle.join(' -> ')}`;
export const appServiceConstructionFailedMessage = (name: string): string =>
`[active-app] service "${name}" failed to construct`;
`[${APP_MODULE}] service "${name}" failed to construct`;
export const appInvalidEventRuntimeMessage = (
type: string,
@ -54,7 +74,6 @@ export const appUnsafeEventPayloadMessage = (type: string, path: string): string
// ── Error messages ─────────────────────────────────────────────────────
export const APP_ERROR_MESSAGES: ErrorMessages = {
[APP_ERR_ALREADY_CREATED]: APP_ERROR_MSG_ALREADY_CREATED,
[APP_ERR_SERVICE_NAME_MISMATCH]: appServiceNameMismatchMessage,
[APP_ERR_SERVICE_DEPENDENCY_CYCLE]: appServiceDependencyCycleMessage,
[APP_ERR_SERVICE_CONSTRUCTION_FAILED]: appServiceConstructionFailedMessage,
@ -65,12 +84,6 @@ export const APP_ERROR_MESSAGES: ErrorMessages = {
// ── Error classes ──────────────────────────────────────────────────────
export class AappAlreadyCreatedError extends CodeError {
constructor(message: string) {
super(APP_ERR_ALREADY_CREATED, { message });
}
}
export class AappServiceNameMismatchError extends CodeError {
readonly key: string;
readonly factoryName: string;
@ -138,10 +151,6 @@ export class AappUnsafeEventPayloadError extends CodeError {
// ── Type guards ────────────────────────────────────────────────────────
export function isAappAlreadyCreatedError(error: unknown): error is AappAlreadyCreatedError {
return error instanceof AappAlreadyCreatedError;
}
export function isAappServiceNameMismatchError(
error: unknown
): error is AappServiceNameMismatchError {

@ -1,3 +1,15 @@
/**
* App-owned events.
*
* The only event whose owner is `arts/active-app` itself is
* `APP_EVENT_DISPOSE_STARTING`. Everything else used to be a
* republication of module-level events (session, connection, etc.) —
* those republications have been removed. Apps that need to react to
* facts owned by other modules subscribe to those modules' events
* directly via `App.Bus.on(SESSION_EVENT_*, …)` or, more commonly,
* register an orca action via a preset.
*/
import type { BusPublishOptions, EventPublisher, EventSubscriber } from '$libs/bus';
import {
APP_EVENT_RUNTIME_BOTH,
@ -7,9 +19,9 @@ import {
import { AappInvalidEventRuntimeError, AappUnsafeEventPayloadError } from './errors.ts';
/**
* The single App-owned event that survives the orca-based migration.
* Apps that need to react to teardown subscribe via `App.Bus.on(...)` or
* register an orca action.
* The single App-owned event. Fires once at the start of `App.dispose()`
* before any service teardown begins, so subscribers can flush, persist
* or detach before their dependencies disappear.
*/
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
@ -77,10 +89,7 @@ export interface AppEventMap {
}
/**
* Read-only contract for App's event bus. App publishes only
* `DISPOSE_STARTING` directly; everything else flows through the bus by
* other modules (session, connection, etc.) and is orchestrated via
* `App.Orca`.
* Read-only contract for App's event bus from the consumer side.
*/
export type AppEventBus = EventSubscriber<AppEventMap>;

@ -1,26 +1,59 @@
export { createActiveApp } from './active-app.svelte';
export { APP_MODULE } from './consts';
/**
* Public entry point of `arts/active-app`.
*
* Three import paths exist:
*
* - `$active-app` — `createActiveApp`, types, errors, bus-context
* bridge. What every app needs.
* - `$active-app/services` — `defineActive*` / `defineEngine*`
* factories for the declarative service schema. Loaded only by
* apps that declare services.
* - `$active-app/presets` — orchestration presets registered on
* `App.Orca`. Loaded only by apps that opt into the standard
* reactions.
*
* Splitting the entry points lets the bundler tree-shake each layer
* independently. An app that only consumes the core never pulls in
* service factories or presets.
*/
export { createActiveApp } from './active-app.svelte.ts';
export { setBus, getBus } from './bus-context.svelte.ts';
export { APP_MODULE, APP_BUS_CONTEXT_KEY } from './consts.ts';
export {
AappAlreadyCreatedError,
APP_EVENT_DISPOSE_STARTING,
publishAppDisposeStarting,
onAppDisposeStarting,
type AppDisposeStartingPayload,
type AppEventBus,
type AppEventMap,
type AppEventRuntime
} from './events.ts';
export {
AappBusNoContextError,
AappInvalidEventRuntimeError,
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError,
isAappAlreadyCreatedError,
AappUnsafeEventPayloadError,
isAappBusNoContextError,
isAappInvalidEventRuntimeError,
isAappServiceConstructionFailedError,
isAappServiceDependencyCycleError,
isAappServiceNameMismatchError
} from './errors';
isAappServiceNameMismatchError,
isAappUnsafeEventPayloadError
} from './errors.ts';
export type {
ActiveApp,
ActiveAppBusEvents,
ActiveAppLegacy,
ActiveAppCore,
ActiveAppOptions,
ActiveAppServicesIntrospection,
ActiveAppStorageOptions,
FrontendPersist,
FrontendPersistKey,
FrontendPersistKeyOverride
} from './types';
ActiveAppServicesIntrospection
} from './types.ts';
export type {
AppServiceFactory,
@ -30,40 +63,7 @@ export type {
ResolveServiceInstances,
ServiceInitMode,
ServiceStatus
} from './services';
} from './services.ts';
export { buildServiceBuilders } from './service-builder';
// Service factories — declarative `services: { … }` schema entries.
export {
defineActiveAuth,
defineActiveCache,
defineActiveConnections,
defineActiveDom,
defineActiveFormat,
defineActiveFrontend,
defineActiveLang,
defineActivePerm,
defineActiveSession,
defineActiveStorage,
defineEngineHttp,
defineEngineSium
} from './service-factories';
// Orchestration presets — opt-in reactions registered on `App.Orca`.
export {
applyCacheClearOnIdentityChange,
applyCacheClearOnRevoke,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from './presets';
// Common storage adapters re-exported for ergonomic single import.
// `createMemoryAdapter` lives in `$storage` only — testing-specific.
export { localAdapter, sessionAdapter, cookieAdapter, withBroadcast } from '$storage';
export type {
CookieAdapterOptions,
ServerCookiesLike,
BroadcastAdapter,
BroadcastOptions
} from '$storage';
export { buildServiceBuilders } from './service-builder.ts';
export type { ServiceBuilders } from './service-builder.ts';

@ -1,104 +0,0 @@
import {
FRONTEND_PREFERENCE_KEYS,
applyFrontendPreferenceSnapshot,
readFrontendPreference,
resolveFrontendPreferenceDefault,
type ActiveFrontend,
type ActiveFrontendOptions,
type FrontendPreferenceKey,
type FrontendPreferenceSnapshot,
type FrontendPreferenceValue
} from '$frontend';
import type { ActiveStorage, ActiveStorageEntry, SyncStorageAdapter } from '$storage';
import type { FrontendPersist, FrontendPersistKeyOverride } from '../types';
interface ResolvedFrontendPersist {
keys: ReadonlyArray<FrontendPreferenceKey>;
adapter?: SyncStorageAdapter;
namespace?: string | false;
overrides: Partial<Record<FrontendPreferenceKey, FrontendPersistKeyOverride>>;
}
function resolveFrontendPersist(
persist: FrontendPersist | undefined
): ResolvedFrontendPersist | undefined {
if (!persist) return undefined;
if (persist === true) return { keys: FRONTEND_PREFERENCE_KEYS, overrides: {} };
return {
keys: persist.keys ?? FRONTEND_PREFERENCE_KEYS,
adapter: persist.adapter,
namespace: persist.namespace,
overrides: persist.overrides ?? {}
};
}
export type FrontendStorageEntries = Map<FrontendPreferenceKey, ActiveStorageEntry<unknown>>;
/**
* Read persisted Frontend preferences before `ActiveFrontend` is constructed.
* This keeps first paint aligned with storage (not a post-hydration patch).
*/
export function loadPersistedFrontendPreferences(
storage: ActiveStorage,
persist: FrontendPersist | undefined,
defaults: Pick<
ActiveFrontendOptions,
'theme' | 'mode' | 'density' | 'dir' | 'reducedMotion' | 'reducedSound'
>
): { snapshot: FrontendPreferenceSnapshot; entries: FrontendStorageEntries } {
const entries: FrontendStorageEntries = new Map();
const snapshot: FrontendPreferenceSnapshot = {};
const resolved = resolveFrontendPersist(persist);
if (!resolved) return { snapshot, entries };
const enabled = new Set(resolved.keys);
function makeEntry<K extends FrontendPreferenceKey>(
key: K
): ActiveStorageEntry<FrontendPreferenceValue<K>> {
const override = resolved!.overrides[key];
return storage.entry(key, resolveFrontendPreferenceDefault(key, defaults), {
adapter: override?.adapter ?? resolved!.adapter,
namespace: override?.namespace ?? resolved!.namespace,
raw: override?.raw === true ? true : undefined
});
}
for (const key of FRONTEND_PREFERENCE_KEYS) {
if (!enabled.has(key)) continue;
const entry = makeEntry(key);
entries.set(key, entry as ActiveStorageEntry<unknown>);
if (entry.has()) {
(snapshot as Record<FrontendPreferenceKey, unknown>)[key] = entry.current;
}
}
return { snapshot, entries };
}
export { applyFrontendPreferenceSnapshot };
/**
* Wire Frontend preference changes to the storage entries created during
* construction. `fend` owns the preference semantics; this file only bridges
* them to `storage`.
*/
export function bindFrontendStorage(
frontend: ActiveFrontend,
entries: FrontendStorageEntries
): () => void {
if (entries.size === 0) return () => {};
const detach = frontend.onPreferenceChange(() => {
for (const [key, entry] of entries) {
entry.set(readFrontendPreference(frontend, key));
}
});
return () => {
detach();
for (const entry of entries.values()) entry.dispose();
entries.clear();
};
}

@ -1,13 +1,12 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import type { EngineOrca } from '$orca';
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
import type { ActiveCache } from '$cache/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'cache.clear-on-identity-change';
const TOKEN_CLEARED = 'cache:cleared-on-identity';
interface AppShape {
readonly Orca: EngineOrca;
export interface CacheClearOnIdentityChangeApp extends ActiveAppCore {
readonly cache: Pick<ActiveCache, 'clear'>;
}
@ -15,15 +14,16 @@ interface AppShape {
* Registers an orca action that clears the active cache when the
* session's actor identity changes.
*
* Replaces the legacy `wireAutoInvalidation` subscription inside
* Replaces the legacy auto-invalidation that used to live inside
* `arts/cache`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` (the
* canonical event from `arts/session`), not the deprecated
* `APP_EVENT_USER_IDENTITY_CHANGED` re-publication.
* canonical event from `arts/session`).
*
* Returns a detach function. Calling it unregisters the action; the
* engine then stops reacting to the event.
*/
export function applyCacheClearOnIdentityChange(App: AppShape): () => void {
export function applyCacheClearOnIdentityChange(
App: CacheClearOnIdentityChangeApp
): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,

@ -1,13 +1,19 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import type { EngineOrca } from '$orca';
import { SESSION_EVENT_REVOKED } from '$session';
import type { ActiveCache } from '$cache/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'cache.clear-on-revoke';
const TOKEN_CLEARED = 'cache:cleared-on-revoke';
interface AppShape {
readonly Orca: EngineOrca;
/**
* Shape this preset requires from `App`. We only need `Orca` from the
* core and a `cache` service that exposes `clear()`. Defining the
* dependency this narrowly makes the preset usable from any App that
* declares a compatible cache, regardless of what other services it
* has.
*/
export interface CacheClearOnRevokeApp extends ActiveAppCore {
readonly cache: Pick<ActiveCache, 'clear'>;
}
@ -16,8 +22,10 @@ interface AppShape {
* session is revoked. Pairs with `applyCacheClearOnIdentityChange` for
* apps where revoke is independent of identity change (e.g. logout
* without login of another actor).
*
* Returns a detach function. Calling it unregisters the action.
*/
export function applyCacheClearOnRevoke(App: AppShape): () => void {
export function applyCacheClearOnRevoke(App: CacheClearOnRevokeApp): () => void {
return App.Orca.onEvent(SESSION_EVENT_REVOKED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,

@ -16,7 +16,16 @@
* registers every preset whose required services are declared in `App`.
*/
export { applyCacheClearOnIdentityChange } from './cache-clear-on-identity-change.ts';
export { applyCacheClearOnRevoke } from './cache-clear-on-revoke.ts';
export { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change.ts';
export { applyStandardOrca } from './standard.ts';
export {
applyCacheClearOnIdentityChange,
type CacheClearOnIdentityChangeApp
} from './cache-clear-on-identity-change.ts';
export {
applyCacheClearOnRevoke,
type CacheClearOnRevokeApp
} from './cache-clear-on-revoke.ts';
export {
applyPermInvalidateOnIdentityChange,
type PermInvalidateOnIdentityChangeApp
} from './perm-invalidate-on-identity-change.ts';
export { applyStandardOrca, type StandardOrcaApp } from './standard.ts';

@ -1,13 +1,12 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import type { EngineOrca } from '$orca';
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
import type { ActivePerms } from '$perm/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'perm.invalidate-on-identity-change';
const TOKEN_INVALIDATED = 'perm:invalidated-on-identity';
interface AppShape {
readonly Orca: EngineOrca;
export interface PermInvalidateOnIdentityChangeApp extends ActiveAppCore {
readonly perm: Pick<ActivePerms, 'invalidate'>;
}
@ -15,10 +14,12 @@ interface AppShape {
* Registers an orca action that invalidates the local permissions cache
* when the session's actor identity changes.
*
* Replaces the legacy `wireAutoInvalidation` subscription inside
* Replaces the legacy auto-invalidation that used to live inside
* `arts/perm`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` directly.
*/
export function applyPermInvalidateOnIdentityChange(App: AppShape): () => void {
export function applyPermInvalidateOnIdentityChange(
App: PermInvalidateOnIdentityChangeApp
): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,

@ -1,33 +1,41 @@
import type { EngineOrca } from '$orca';
import type { ActiveCache } from '$cache/types';
import type { ActivePerms } from '$perm/types';
import type { ActiveAppCore } from '../types.ts';
import { applyCacheClearOnIdentityChange } from './cache-clear-on-identity-change.ts';
import { applyCacheClearOnRevoke } from './cache-clear-on-revoke.ts';
import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change.ts';
interface AppShape {
readonly Orca: EngineOrca;
/**
* Optional shape passed to `applyStandardOrca`. Whatever services the
* application declares are picked up automatically; missing services
* are skipped. The shape is intentionally permissive — `Partial<>`
* pieces — so an App that only declares `cache` (without `perm`) gets
* cache-related presets and nothing else.
*/
export interface StandardOrcaApp extends ActiveAppCore {
readonly cache?: Pick<ActiveCache, 'clear'>;
readonly perm?: Pick<ActivePerms, 'invalidate'>;
}
/**
* Convenience aggregator: registers every standard preset whose required
* services are declared on `App`. Apps that want a tailored set of
* reactions can cherry-pick individual `apply*` functions instead.
* Registers every standard preset whose required services are declared
* on `App`. Apps that want a tailored set of reactions can cherry-pick
* individual `apply*` functions instead.
*
* Returns a single detach function that unregisters everything in
* reverse order — convenient for tests and hot reloading.
*/
export function applyStandardOrca(App: AppShape): () => void {
export function applyStandardOrca(App: StandardOrcaApp): () => void {
const detachers: Array<() => void> = [];
if (App.cache !== undefined) {
detachers.push(applyCacheClearOnIdentityChange(App as AppShape & { cache: NonNullable<AppShape['cache']> }));
detachers.push(applyCacheClearOnRevoke(App as AppShape & { cache: NonNullable<AppShape['cache']> }));
const cacheApp = App as StandardOrcaApp & { cache: NonNullable<StandardOrcaApp['cache']> };
detachers.push(applyCacheClearOnIdentityChange(cacheApp));
detachers.push(applyCacheClearOnRevoke(cacheApp));
}
if (App.perm !== undefined) {
detachers.push(applyPermInvalidateOnIdentityChange(App as AppShape & { perm: NonNullable<AppShape['perm']> }));
const permApp = App as StandardOrcaApp & { perm: NonNullable<StandardOrcaApp['perm']> };
detachers.push(applyPermInvalidateOnIdentityChange(permApp));
}
return () => {

@ -1,6 +1,7 @@
/**
* Runtime that turns an `AppServiceSchema` into a set of getters on the
* `App` object plus a `dispose()` that tears them down in reverse order.
* `App` object plus a `disposeAll()` that tears them down in reverse
* order.
*
* Responsibilities:
* - Validate the schema (key === factory.name).
@ -11,8 +12,8 @@
* `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.).
* dispose are swallowed (to mirror the engine convention used
* elsewhere in the ecosystem).
*
* Errors during construction propagate to the caller. The status of the
* failing service is `'failed'` and stays that way; subsequent reads
@ -20,6 +21,7 @@
* `AappServiceConstructionFailedError`.
*/
import { untrack } from 'svelte';
import {
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
@ -62,7 +64,6 @@ export function buildServiceBuilders(
// 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
@ -96,10 +97,18 @@ export function buildServiceBuilders(
}
try {
const instance = factory.create({
core: coreSubset,
services: serviceSubset
});
// Lazy services may be constructed inside a `$derived` or
// template expression. If the factory subscribes to a $state-
// backed listener set during `create()` (e.g. format wiring
// `lang.onLocaleChange`), Svelte rejects the mutation. Run
// construction inside `untrack` so subscriptions wired here
// do not propagate into the outer reactive scope.
const instance = untrack(() =>
factory.create({
core: coreSubset,
services: serviceSubset
})
);
instances.set(name, instance);
status.set(name, 'present');
constructionLog.push(name);

@ -4,12 +4,14 @@ 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.
* slot.
*
* 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.
* Auth requires an `http` client supplied through `options` (the auth
* client only reflects server decisions — it cannot run without a
* backend). It receives `logger` from the core.
*
* Auto-orchestration with `cache` (invalidating cache on revoke etc.)
* is NOT wired here — that lives in `arts/active-app/presets/`.
*/
export function defineActiveAuth(
options: Omit<ActiveAuthOptions, 'logger'>

@ -3,19 +3,13 @@ import type { ActiveCache, ActiveCacheOptions } from '$cache/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveCache(options)` produces a service factory for the `cache`
* slot.
* `defineActiveCache(options)` produces a service factory for the
* `cache` slot.
*
* **Auto-invalidation is OFF by default** when registered via the schema.
* The legacy `autoInvalidateOn` and `bus` options on `ActiveCacheOptions`
* are still honored if the application explicitly passes them, but the
* recommended path is to omit them and use an orca preset
* (`applyCacheInvalidateOnIdentityChange` etc.) to react to events.
*
* The art still has `bus.on(APP_EVENT_*)` subscriptions internally
* gated by `autoInvalidateOn`. Step 3 of the active-app refactor will
* remove those entirely; until then, leaving the option unset keeps
* the behavior clean.
* The cache art is a passive runtime: invalidation is driven from the
* outside via orca presets (e.g. `applyCacheClearOnIdentityChange` in
* `arts/active-app/presets/`). The factory itself only wires `logger`
* from the core; everything else is opt-in through `options`.
*/
export function defineActiveCache(
options: Omit<ActiveCacheOptions, 'logger'> = {}

@ -1,14 +1,18 @@
import { createActiveConnections } from '$connection/active-connections.svelte';
import type { ActiveConnections, ActiveConnectionsOptions } from '$connection/types';
import type {
ActiveConnections,
ActiveConnectionsOptions
} from '$connection/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveConnections(options)` produces a service factory for the
* `connections` slot.
*
* Requires `timers` from the core. Identity tracking (formerly via
* `bus-session-source`) becomes an orca preset in step 3 — until then
* applications can pass `session` in options manually.
* Requires `logger` and `timers` from the core. Identity tracking
* (formerly via the `bus-session-source` shim) is now expected to come
* from an orca preset, or from the application passing `session` in
* options manually.
*/
export function defineActiveConnections(
options: Omit<ActiveConnectionsOptions, 'logger' | 'timers'> = {}

@ -16,6 +16,9 @@ export function defineActiveDom(
initMode: 'lazy',
create(): ActiveDom {
return createActiveDom(props);
},
dispose(instance) {
instance.dispose();
}
};
}

@ -1,21 +1,45 @@
import { createActiveFormat } from '$format/active-formats.svelte';
import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte';
import type { ActiveLang } from '$lang';
import type { LocaleSource } from '$locale';
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.
* `format` slot.
*
* Format runs entirely from a `LocaleSource`. If the application
* declares `lang` in its schema, this factory wires Format to the
* `ActiveLang` instance automatically. If not, the application must
* pass its own `localeSource` via `options`, otherwise Format falls
* back to its default locale.
*/
export function defineActiveFormat(
options: ActiveFormatOptions = {}
): AppServiceFactory<'format', readonly [], readonly [], ActiveFormat> {
): AppServiceFactory<'format', readonly [], readonly ['lang'], ActiveFormat> {
return {
name: 'format',
coreDependencies: [],
serviceDependencies: ['lang'],
initMode: 'lazy',
create(): ActiveFormat {
return createActiveFormat(options);
create({ services }): ActiveFormat {
const langInstance = services.lang as ActiveLang | undefined;
const localeSource: LocaleSource | undefined =
options.localeSource ??
(langInstance
? {
getLocale: () => langInstance.getLocale(),
onLocaleChange: (fn) => langInstance.onLocaleChange(fn)
}
: undefined);
return createActiveFormat({
...options,
localeSource
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -1,18 +1,25 @@
import { createActiveFrontend } from '$frontend/active-frontend.svelte';
import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte';
import type { ActiveDom } from '$adom';
import type { ActiveLang } from '$lang';
import type { LocaleSource } from '$locale';
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.
* Frontend integrates with `dom` and `lang` automatically when those
* services are declared in the schema. Explicit `dom` / `localeSource`
* passed in `options` take precedence — that is the escape hatch for
* tests and non-standard wiring.
*
* 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.
* Note: `frontend` previously consumed `App.Storage` to persist user
* preferences (theme/mode/density). That persistence layer used to
* live in `arts/active-app/integrations/frontend-storage.ts`. After the
* refactor, persistence is the application's concern: it can be
* implemented as an orca preset, as an integration helper, or built
* into a custom Frontend wrapper. The factory itself stays slim.
*/
export function defineActiveFrontend(
options: ActiveFrontendOptions = {}
@ -22,12 +29,23 @@ export function defineActiveFrontend(
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);
create({ services }): ActiveFrontend {
const dom = options.dom ?? (services.dom as ActiveDom | undefined);
const langInstance = services.lang as ActiveLang | undefined;
const localeSource: LocaleSource | undefined =
options.localeSource ??
(langInstance
? {
getLocale: () => langInstance.getLocale(),
onLocaleChange: (fn) => langInstance.onLocaleChange(fn)
}
: undefined);
return createActiveFrontend({
...options,
dom,
localeSource
});
},
dispose(instance) {
instance.dispose();

@ -6,16 +6,21 @@ 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.
*
* Logger is wired from the core for diagnostics consistency.
*/
export function defineEngineHttp(
options: EngineHttpOptions = {}
): AppServiceFactory<'http', readonly [], readonly [], EngineHttp> {
options: Omit<EngineHttpOptions, 'logger'> = {}
): AppServiceFactory<'http', readonly ['logger'], readonly [], EngineHttp> {
return {
name: 'http',
coreDependencies: [],
coreDependencies: ['logger'],
initMode: 'lazy',
create(): EngineHttp {
return createEngineHttp(options);
create({ core }): EngineHttp {
return createEngineHttp({
...options,
logger: core.logger
});
}
};
}

@ -18,20 +18,27 @@ export interface DefineActiveLangOptions<S extends LangNode> {
* `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.
*
* Lang receives no core deps directly — it manages its own logger
* internally via `setLogger(core.logger)` if needed. To keep the schema
* contract clean, we leave that wiring to the application or to a
* later helper.
*/
export function defineActiveLang<S extends LangNode>(
options: DefineActiveLangOptions<S>
): AppServiceFactory<'lang', readonly [], readonly [], ActiveLang<S>> {
): AppServiceFactory<'lang', readonly ['logger'], readonly [], ActiveLang<S>> {
return {
name: 'lang',
coreDependencies: [],
coreDependencies: ['logger'],
initMode: 'lazy',
create(): ActiveLang<S> {
return createActiveLang<S>(
create({ core }): ActiveLang<S> {
const lang = createActiveLang<S>(
options.schema,
options.defaultLocale ?? 'es',
options.fallbackChain ? [...options.fallbackChain] : undefined
);
lang.setLogger(core.logger);
return lang;
},
dispose(instance) {
instance.dispose();

@ -6,9 +6,9 @@ import type { AppServiceFactory } from '../services.ts';
* `defineActivePerm(options)` produces a service factory for the `perm`
* slot.
*
* **Auto-invalidation is OFF by default** when registered via the schema
* — same reasoning as `defineActiveCache`. Use orca presets
* (`applyPermInvalidateOnIdentityChange`) to react to events.
* Auto-invalidation is OFF by default — same reasoning as
* `defineActiveCache`. Use the orca preset
* `applyPermInvalidateOnIdentityChange` to react to identity changes.
*
* The factory still requires the application to provide `endpoint` (via
* the underlying `ActivePermsOptions`); the perm client cannot work

@ -6,20 +6,12 @@ import type { AppServiceFactory } from '../services.ts';
* `defineActiveSession<TUser, TCredential?, TData?>(options)` produces a
* service factory for the `session` slot.
*
* The session art publishes its own `SESSION_EVENT_LIFECYCLE_*` events
* via `options.bus`. The factory wires `core.bus` into the session
* automatically so consumers (and orca presets) can subscribe to those
* events without extra configuration.
*
* Application-level `APP_EVENT_USER_IDENTITY_CHANGED` re-publishing
* (formerly handled by `wireSessionTranslator`) moves to an orca preset
* in step 3.
* Session publishes its own `SESSION_EVENT_*` events on `core.bus`.
* Orca presets in `arts/active-app/presets/` translate those into
* reactions across other services without `arts/session` having to know
* who listens.
*/
export function defineActiveSession<
TUser,
TCredential = undefined,
TData = undefined
>(
export function defineActiveSession<TUser, TCredential = undefined, TData = undefined>(
options: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger' | 'bus'>
): AppServiceFactory<
'session',

@ -1,24 +1,37 @@
import { createEngineSium, type EngineSium, type EngineSiumOptions } from '$sium/engine-sium';
import {
createEngineSium,
type EngineSium,
type EngineSiumOptions
} from '$sium/engine-sium';
import type { EngineLang } from '$lang';
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.
* slot.
*
* Sium is a validation engine. It takes `logger` from the core and, if
* the application declares `lang` in its schema, wires it through so
* issue messages can be translated. Without `lang`, Sium falls back to
* English literals.
*/
export function defineEngineSium(
options: Omit<EngineSiumOptions, 'logger'> = {}
options: Omit<EngineSiumOptions, 'logger' | 'lang'> = {}
): AppServiceFactory<'sium', readonly ['logger'], readonly ['lang'], EngineSium> {
return {
name: 'sium',
coreDependencies: ['logger'],
serviceDependencies: ['lang'],
initMode: 'lazy',
create({ core }) {
create({ core, services }): EngineSium {
// `ActiveLang` extends `EngineLang` structurally but TS sees the
// `t()` signatures as distinct (optional vs required params), so
// cross via `unknown` to keep the contract loose at the boundary.
const lang = services.lang as unknown as EngineLang | undefined;
return createEngineSium({
...options,
logger: core.logger
logger: core.logger,
lang
});
}
};

@ -1,23 +1,26 @@
import { createActiveStorage } from '$storage/active-storage.svelte';
import type { ActiveStorage } from '$storage/types';
import type { EngineStorageOptions } from '$storage/types';
import type { ActiveStorage, 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.
* `storage` slot. The art is built directly from the supplied options;
* `arts/storage` reads `logger` only when explicitly given, and we
* inject it here from the core for consistency with the rest of the
* ecosystem.
*/
export function defineActiveStorage(
options: EngineStorageOptions = {}
): AppServiceFactory<'storage', readonly [], readonly [], ActiveStorage> {
options: Omit<EngineStorageOptions, 'logger'> = {}
): AppServiceFactory<'storage', readonly ['logger'], readonly [], ActiveStorage> {
return {
name: 'storage',
coreDependencies: [],
coreDependencies: ['logger'],
initMode: 'lazy',
create(): ActiveStorage {
return createActiveStorage(options);
create({ core }): ActiveStorage {
return createActiveStorage({
...options,
logger: core.logger
});
},
dispose(instance) {
instance.dispose();

@ -3,9 +3,9 @@
*
* `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.
* - **Core** — fixed runtime infrastructure that always exists:
* `logger`, `bus`, `timers` and `orca`. Configurable via the
* `ActiveAppOptions` 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
@ -14,8 +14,8 @@
*
* 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.
* `arts/active-app/service-factories/`. The arts themselves stay pure —
* they do not know they are wired into a service.
*/
import type { EngineBus } from '$bus';
@ -100,6 +100,11 @@ export interface AppServiceFactory<
/**
* A schema is a record `{ [name]: AppServiceFactory }`. The key MUST equal
* `factory.name`; the builder validates this at construction time.
*
* Note on `any`: the wider `unknown` does not preserve the structural
* assignability we need for the factory-shape pattern. Concrete factories
* (returned by `defineActive*`) keep precise types; this widened alias is
* only used at the schema-of-schemas boundary.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AppServiceSchema = Record<string, AppServiceFactory<string, any, any, unknown>>;

@ -1,178 +0,0 @@
/**
* createActiveApp() composition tests — focuses on the core (Logger,
* Lang, Format, Frontend, Dom, Storage, Http, Timers, Bus, Orca, Cache).
* Service-schema integration is covered by `schema-declarative.test.ts`,
* orca presets by `presets.test.ts`, and the service builder by
* `service-builder.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { createActiveApp } from '../active-app.svelte';
import { LogLevel, type LogEntry } from '$logger';
import type { LangNode } from '$lang';
import { LANG_MONO_LANG_CATEGORY } from '$lang/mono-lang.svelte';
const schema = {
greeting: { es: 'Hola', en: 'Hello', 'es-MX': 'Qué onda' },
cart: { es: 'Carrito', en: 'Cart' }
} satisfies LangNode;
const SILENT_LOGGER = { level: LogLevel.NONE, transports: [] };
describe('createActiveApp — core composition', () => {
it('exposes the always-on core surface', () => {
const App = createActiveApp({ logger: SILENT_LOGGER });
expect(App.Logger).toBeDefined();
expect(App.Lang).toBeDefined();
expect(App.Format).toBeDefined();
expect(App.Frontend).toBeDefined();
expect(App.Dom).toBeDefined();
expect(App.Storage).toBeDefined();
expect(App.Http).toBeDefined();
expect(App.Timers).toBeDefined();
expect(App.Bus).toBeDefined();
expect(App.Orca).toBeDefined();
expect(App.Cache).toBeDefined();
expect(typeof App.dispose).toBe('function');
expect(typeof App.getLocale).toBe('function');
App.dispose();
});
it('builds with zero options', () => {
const App = createActiveApp();
expect(App.Logger).toBeDefined();
App.dispose();
});
it('routes setLocale through Lang as the single source of truth', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
lang: { schema, defaultLocale: 'es' }
});
expect(App.getLocale()).toBe('es');
App.setLocale('en');
expect(App.getLocale()).toBe('en');
expect(App.Lang.getLocale()).toBe('en');
App.dispose();
});
it('honors BCP 47 resolution through Lang.t()', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
lang: { schema, defaultLocale: 'es-MX' }
});
// es-MX falls back to es when no exact match, but greeting has both
expect(App.Lang.t('greeting', undefined, 'es-MX')).toBe('Qué onda');
// cart has only es / en, so es-MX falls back to es
expect(App.Lang.t('cart', undefined, 'es-MX')).toBe('Carrito');
App.dispose();
});
it('mono Lang returns paths verbatim when no schema is configured', () => {
const App = createActiveApp({ logger: SILENT_LOGGER });
// Mono lang returns the path key when no entry exists.
expect(App.Lang.t('any.path')).toBe('any.path');
App.dispose();
});
it('mono Lang warns once per unresolved path via the shared Logger', () => {
const entries: LogEntry[] = [];
const App = createActiveApp({
logger: {
level: LogLevel.WARN,
transports: [
{
name: 'capture',
write(entry) {
entries.push(entry);
}
}
]
}
});
App.Lang.t('missing.path');
App.Lang.t('missing.path');
App.Lang.t('another.path');
const warnings = entries.filter((e) => e.category === LANG_MONO_LANG_CATEGORY);
expect(warnings).toHaveLength(2);
App.dispose();
});
it('mono Lang does not warn when |fallback is provided', () => {
const entries: LogEntry[] = [];
const App = createActiveApp({
logger: {
level: LogLevel.WARN,
transports: [
{
name: 'capture',
write(entry) {
entries.push(entry);
}
}
]
}
});
App.Lang.t('with.fallback|the value');
const warnings = entries.filter((e) => e.category === LANG_MONO_LANG_CATEGORY);
expect(warnings).toHaveLength(0);
App.dispose();
});
it('Format receives the mono locale via localeSource', () => {
const App = createActiveApp({ logger: SILENT_LOGGER });
// Default mono locale is the engine default (en-US)
expect(typeof App.Format).toBe('object');
App.dispose();
});
it('onLocaleChange fires when setLocale changes the value', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
lang: { schema, defaultLocale: 'es' }
});
const changes: string[] = [];
const detach = App.onLocaleChange((locale) => changes.push(locale));
App.setLocale('en');
App.setLocale('en'); // duplicate, should not fire
App.setLocale('es-MX');
expect(changes).toEqual(['en', 'es-MX']);
detach();
App.setLocale('en');
expect(changes).toEqual(['en', 'es-MX']);
App.dispose();
});
});
describe('createActiveApp — dispose', () => {
it('is idempotent', () => {
const App = createActiveApp({ logger: SILENT_LOGGER });
App.dispose();
expect(() => App.dispose()).not.toThrow();
});
it('disposes Orca alongside the core', () => {
const App = createActiveApp({ logger: SILENT_LOGGER });
const orca = App.Orca;
expect(orca.disposed).toBe(false);
App.dispose();
expect(orca.disposed).toBe(true);
});
});

@ -51,7 +51,7 @@ describe('createActiveApp — declarative service schema', () => {
App.dispose();
});
it('coexists with the legacy uppercase core (App.Cache, App.Bus, etc.)', () => {
it('exposes only the four core members alongside declared services', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
services: {
@ -59,12 +59,17 @@ describe('createActiveApp — declarative service schema', () => {
}
});
// legacy: App.Cache always present
expect(App.Cache).toBeDefined();
// declarative: App.cache is the schema-declared instance
// Core: always present.
expect(App.Logger).toBeDefined();
expect(App.Bus).toBeDefined();
expect(App.Timers).toBeDefined();
expect(App.Orca).toBeDefined();
// Schema-declared service exposed as lowercase property.
expect(App.cache).toBeDefined();
// they are independent ActiveCache instances
expect(App.cache).not.toBe(App.Cache);
// Legacy uppercase aliases are gone — `Cache` is no longer a core.
expect((App as Record<string, unknown>).Cache).toBeUndefined();
App.dispose();
});

@ -1,384 +1,411 @@
/**
* Unit tests for `service-builder.ts`. The builder is the load-bearing
* piece of `arts/active-app/`; if it has bugs, every service composed
* through it breaks.
*
* These tests use a synthetic `CoreServices` mock — they don't pull in
* real `arts/logger`, `arts/bus`, etc. The contract under test is
* purely:
* - schema validation
* - topological order with cycle detection
* - lazy / immediate construction
* - dispose order
* - status reporting
* - failure handling
*/
import { describe, expect, it, vi } from 'vitest';
import { buildServiceBuilders } from '../service-builder.ts';
import {
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError
} from '../errors.ts';
import { _internalsForTesting, buildServiceBuilders } from '../service-builder.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 ──────────────────────────────────────────────────
// ── Test harness ───────────────────────────────────────────────────────
const stubCore: CoreServices = {
logger: {} as EngineLogger,
bus: {} as EngineBus,
timers: {} as ActiveTimers,
orca: {} as EngineOrca
};
function mockCore(): CoreServices {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bus: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
timers: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
orca: {} as any
};
}
// ── Helpers to build factory stubs ─────────────────────────────────────
interface FakeInstance {
name: string;
disposed: boolean;
}
function stubFactory<TName extends string, TInstance>(
function fakeFactory<TName extends string>(
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> {
overrides: Partial<AppServiceFactory<TName, readonly [], readonly [], FakeInstance>> = {}
): AppServiceFactory<TName, readonly [], readonly [], FakeInstance> {
return {
name,
coreDependencies: options.coreDependencies ?? [],
serviceDependencies: options.serviceDependencies,
initMode: options.initMode,
coreDependencies: [],
initMode: 'lazy',
create() {
return value;
return { name, disposed: false };
},
dispose: options.dispose
dispose(instance) {
instance.disposed = true;
},
...overrides
};
}
// ── Tests ──────────────────────────────────────────────────────────────
// ── Schema validation ─────────────────────────────────────────────────
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('throws AappServiceNameMismatchError when key !== factory.name', () => {
const schema = {
cache: fakeFactory('not-cache')
} as unknown as AppServiceSchema;
expect(() => buildServiceBuilders(schema, mockCore())).toThrow(
AappServiceNameMismatchError
);
});
it('accepts an empty schema', () => {
const builders = buildServiceBuilders({}, stubCore);
expect(builders.statusMap()).toEqual({});
expect(Object.keys(builders.proxies)).toEqual([]);
it('accepts a valid schema with matching keys', () => {
const schema = {
cache: fakeFactory('cache')
} as unknown as AppServiceSchema;
expect(() => buildServiceBuilders(schema, mockCore())).not.toThrow();
});
});
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' });
});
// ── Topological order ─────────────────────────────────────────────────
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']);
describe('buildServiceBuilders — topological order', () => {
it('orders dependencies before dependents', () => {
const schema = {
a: fakeFactory('a'),
b: fakeFactory('b', { serviceDependencies: ['a'] }),
c: fakeFactory('c', { serviceDependencies: ['b'] })
} as unknown as AppServiceSchema;
const order = _internalsForTesting.topologicalOrder(schema);
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
expect(order.indexOf('b')).toBeLessThan(order.indexOf('c'));
});
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('detects direct cycles and reports the cycle path', () => {
const schema = {
a: fakeFactory('a', { serviceDependencies: ['b'] }),
b: fakeFactory('b', { serviceDependencies: ['a'] })
} as unknown as AppServiceSchema;
expect(() => _internalsForTesting.topologicalOrder(schema)).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('detects indirect cycles', () => {
const schema = {
a: fakeFactory('a', { serviceDependencies: ['b'] }),
b: fakeFactory('b', { serviceDependencies: ['c'] }),
c: fakeFactory('c', { serviceDependencies: ['a'] })
} as unknown as AppServiceSchema;
expect(() => _internalsForTesting.topologicalOrder(schema)).toThrow(
AappServiceDependencyCycleError
);
});
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']);
it('ignores missing dependencies (slot stays undefined at create-time)', () => {
const schema = {
a: fakeFactory('a', { serviceDependencies: ['missing'] })
} as unknown as AppServiceSchema;
expect(() => _internalsForTesting.topologicalOrder(schema)).not.toThrow();
});
});
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' });
});
// ── Lazy construction ─────────────────────────────────────────────────
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);
describe('buildServiceBuilders — lazy construction', () => {
it('does not call create() until the proxy is read', () => {
const create = vi.fn(() => ({ name: 'cache', disposed: false }));
const schema = {
cache: fakeFactory('cache', { create })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(create).not.toHaveBeenCalled();
expect(builders.statusMap()).toEqual({ lazy: 'absent' });
const value = (builders.proxies as { lazy: string }).lazy;
expect(value).toBe('instance');
const value = builders.proxies.cache;
expect(create).toHaveBeenCalledTimes(1);
expect(builders.statusMap()).toEqual({ lazy: 'present' });
expect(value).toEqual({ name: 'cache', disposed: false });
});
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);
it('caches the constructed instance across repeated reads', () => {
const create = vi.fn(() => ({ name: 'cache', disposed: false }));
const schema = {
cache: fakeFactory('cache', { create })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
const a = builders.proxies.cache;
const b = builders.proxies.cache;
expect(create).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
});
});
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']);
// ── Immediate construction ────────────────────────────────────────────
describe('buildServiceBuilders — immediate construction', () => {
it('builds immediate services during buildServiceBuilders()', () => {
const create = vi.fn(() => ({ name: 'session', disposed: false }));
const schema = {
session: fakeFactory('session', { initMode: 'immediate', create })
} as unknown as AppServiceSchema;
buildServiceBuilders(schema, mockCore());
expect(create).toHaveBeenCalledTimes(1);
});
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';
it('builds immediate services in topological order', () => {
const log: string[] = [];
const schema = {
a: fakeFactory('a', {
initMode: 'immediate',
create() {
log.push('a');
return { name: 'a', disposed: false };
}
}
};
const builders = buildServiceBuilders(schema, stubCore);
void (builders.proxies as { top: string }).top;
expect(dependentServices).toEqual({ leaf: 'leaf-instance' });
}),
b: fakeFactory('b', {
initMode: 'immediate',
serviceDependencies: ['a'],
create() {
log.push('b');
return { name: 'b', disposed: false };
}
})
} as unknown as AppServiceSchema;
buildServiceBuilders(schema, mockCore());
expect(log).toEqual(['a', 'b']);
});
});
describe('buildServiceBuilders — failure handling', () => {
it('marks a service as failed when create() throws', () => {
const schema: AppServiceSchema = {
broken: {
name: 'broken',
coreDependencies: [],
// ── Status reporting ──────────────────────────────────────────────────
describe('buildServiceBuilders — status', () => {
it('reports absent before construction, present after', () => {
const schema = {
cache: fakeFactory('cache')
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(builders.statusMap()).toEqual({ cache: 'absent' });
void builders.proxies.cache;
expect(builders.statusMap()).toEqual({ cache: 'present' });
});
it('reports failed when create() throws', () => {
const schema = {
cache: fakeFactory('cache', {
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' });
})
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(() => builders.proxies.cache).toThrow(AappServiceConstructionFailedError);
expect(builders.statusMap()).toEqual({ cache: 'failed' });
});
it('immediate failure propagates from buildServiceBuilders', () => {
const schema: AppServiceSchema = {
broken: {
name: 'broken',
coreDependencies: [],
initMode: 'immediate',
it('re-throws the same error on repeated access of a failed service', () => {
const schema = {
cache: fakeFactory('cache', {
create() {
throw new Error('boom');
}
}
};
expect(() => buildServiceBuilders(schema, stubCore)).toThrow(
AappServiceConstructionFailedError
);
});
})
} as unknown as AppServiceSchema;
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, mockCore());
const firstError = (() => {
try {
void builders.proxies.cache;
} catch (e) {
return e;
}
};
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
})();
const secondError = (() => {
try {
void builders.proxies.cache;
} catch (e) {
return e;
}
})();
expect(firstError).toBeInstanceOf(AappServiceConstructionFailedError);
expect(secondError).toBeInstanceOf(AappServiceConstructionFailedError);
});
});
describe('buildServiceBuilders — disposal', () => {
it('disposes services in reverse construction order', () => {
const calls: string[] = [];
const schema: AppServiceSchema = {
leaf: stubFactory('leaf', 'l', {
// ── Dispose order ─────────────────────────────────────────────────────
describe('buildServiceBuilders — dispose', () => {
it('disposes constructed services in reverse construction order', () => {
const log: string[] = [];
const schema = {
a: fakeFactory('a', {
initMode: 'immediate',
dispose: () => calls.push('dispose:leaf')
dispose() {
log.push('a');
}
}),
top: stubFactory('top', 't', {
b: fakeFactory('b', {
initMode: 'immediate',
serviceDependencies: ['leaf'],
dispose: () => calls.push('dispose:top')
serviceDependencies: ['a'],
dispose() {
log.push('b');
}
}),
c: fakeFactory('c', {
initMode: 'immediate',
serviceDependencies: ['b'],
dispose() {
log.push('c');
}
})
};
const builders = buildServiceBuilders(schema, stubCore);
builders.disposeAll();
expect(calls).toEqual(['dispose:top', 'dispose:leaf']);
});
} as unknown as AppServiceSchema;
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();
const builders = buildServiceBuilders(schema, mockCore());
builders.disposeAll();
expect(dispose).toHaveBeenCalledTimes(1);
expect(log).toEqual(['c', 'b', 'a']);
});
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);
const schema = {
cache: fakeFactory('cache', { dispose })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
// never read builders.proxies.cache
builders.disposeAll();
expect(dispose).not.toHaveBeenCalled();
});
it('swallows dispose errors', () => {
const calls: string[] = [];
const schema: AppServiceSchema = {
a: stubFactory('a', 'a', {
it('swallows errors thrown during dispose', () => {
const schema = {
a: fakeFactory('a', {
initMode: 'immediate',
dispose: () => {
throw new Error('boom');
dispose() {
throw new Error('dispose failed');
}
}),
b: stubFactory('b', 'b', {
initMode: 'immediate',
dispose: () => calls.push('dispose:b')
b: fakeFactory('b', {
initMode: 'immediate'
})
};
const builders = buildServiceBuilders(schema, stubCore);
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(() => builders.disposeAll()).not.toThrow();
// b was constructed first (no deps), disposed second
expect(calls).toEqual(['dispose:b']);
});
it('is idempotent', () => {
const dispose = vi.fn();
const schema = {
cache: fakeFactory('cache', { initMode: 'immediate', dispose })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
builders.disposeAll();
builders.disposeAll();
expect(dispose).toHaveBeenCalledTimes(1);
});
});
// ── Core dependency injection ─────────────────────────────────────────
describe('buildServiceBuilders — core injection', () => {
it('passes only the declared core deps to create()', () => {
const create = vi.fn(({ core }) => ({ name: 'cache', disposed: false, deps: core }));
const schema = {
cache: {
name: 'cache',
coreDependencies: ['logger'],
initMode: 'lazy',
create
} as AppServiceFactory<'cache', readonly ['logger'], readonly [], unknown>
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
void builders.proxies.cache;
const { core } = create.mock.calls[0][0] as { core: Record<string, unknown> };
expect(Object.keys(core)).toEqual(['logger']);
expect(core).not.toHaveProperty('bus');
expect(core).not.toHaveProperty('timers');
expect(core).not.toHaveProperty('orca');
});
it('passes service deps that exist in the schema', () => {
const aInstance = { name: 'a', disposed: false };
let capturedDeps: Record<string, unknown> | undefined;
const schema = {
a: fakeFactory('a', { create: () => aInstance }),
b: {
name: 'b',
coreDependencies: [],
serviceDependencies: ['a'],
initMode: 'lazy',
create({ services }) {
capturedDeps = services;
return { name: 'b', disposed: false };
}
} as AppServiceFactory<'b', readonly [], readonly ['a'], unknown>
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
void builders.proxies.b;
expect(capturedDeps).toBeDefined();
expect(capturedDeps?.a).toBe(aInstance);
});
it('leaves missing service deps as undefined', () => {
let capturedDeps: Record<string, unknown> | undefined;
const schema = {
b: {
name: 'b',
coreDependencies: [],
serviceDependencies: ['missing'],
initMode: 'lazy',
create({ services }) {
capturedDeps = services;
return { name: 'b', disposed: false };
}
} as AppServiceFactory<'b', readonly [], readonly ['missing'], unknown>
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
void builders.proxies.b;
expect(capturedDeps).toBeDefined();
expect(capturedDeps?.missing).toBeUndefined();
});
});

@ -1,146 +0,0 @@
/**
* App.Storage + frontend.persist — integration tests.
*
* Verifies that:
* - App.Storage is always present (memory adapter by default)
* - storage onError routes through App.Logger
* - frontend.persist seeds Frontend with persisted preferences
* - changes to Frontend write back to Storage
* - per-key overrides are respected
* - dispose tears everything down cleanly
*/
import { describe, it, expect } from 'vitest';
import { createActiveApp } from '../active-app.svelte';
import { createMemoryAdapter, encodeEnvelope } from '$storage';
import { LogLevel } from '$logger';
describe('App.Storage', () => {
it('is always present even without storage options', () => {
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] }
});
expect(App.Storage).toBeDefined();
expect(App.Storage.adapter.name).toBe('memory');
App.dispose();
});
it('uses the configured adapter and namespace', () => {
const adapter = createMemoryAdapter();
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
storage: { adapter, namespace: 'test' }
});
const e = App.Storage.entry('theme', 'base');
e.set('forest');
expect(adapter.getItem('test:theme')).toBeTruthy();
App.dispose();
});
it('routes storage errors through App.Logger', () => {
const captured: { category: string; message: string }[] = [];
const broken = {
...createMemoryAdapter(),
name: 'broken',
setItem: () => {
throw new Error('disk full');
}
};
const App = createActiveApp({
logger: {
level: LogLevel.TRACE,
transports: [
{
name: 'capture',
write(entry) {
captured.push({ category: entry.category, message: entry.message });
}
}
]
},
storage: { adapter: broken }
});
App.Storage.entry('x', 'd').set('v');
expect(captured.some((e) => e.category === 'storage' && /write/.test(e.message))).toBe(true);
App.dispose();
});
});
describe('frontend.persist', () => {
it('seeds Frontend with persisted theme on construction', () => {
const adapter = createMemoryAdapter({
'app:theme': encodeEnvelope('forest', 1)
});
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
storage: { adapter, namespace: 'app' },
frontend: { theme: 'base', persist: true }
});
expect(App.Frontend.getTheme()).toBe('forest');
App.dispose();
});
it('writes preference changes back to Storage', () => {
const adapter = createMemoryAdapter();
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
storage: { adapter, namespace: 'app' },
frontend: { theme: 'base', persist: true }
});
App.Frontend.setTheme('sunset');
const stored = adapter.getItem('app:theme');
expect(stored).toContain('sunset');
App.dispose();
});
it('persists only the keys requested', () => {
const adapter = createMemoryAdapter();
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
storage: { adapter, namespace: 'app' },
frontend: { theme: 'base', persist: { keys: ['theme'] } }
});
App.Frontend.setTheme('sunset');
App.Frontend.setDensity('compact');
expect(adapter.getItem('app:theme')).toBeTruthy();
expect(adapter.getItem('app:density')).toBeNull();
App.dispose();
});
it('per-key override targets a different adapter', () => {
const main = createMemoryAdapter();
const cookie = createMemoryAdapter();
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
storage: { adapter: main, namespace: 'app' },
frontend: {
theme: 'base',
persist: {
overrides: {
theme: { adapter: cookie, namespace: false, raw: true }
}
}
}
});
App.Frontend.setTheme('forest');
// Theme went to the cookie adapter as a raw value, no namespace.
expect(cookie.getItem('theme')).toBe('forest');
expect(main.getItem('app:theme')).toBeNull();
App.dispose();
});
it('dispose detaches the persistence subscription', () => {
const adapter = createMemoryAdapter();
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] },
storage: { adapter, namespace: 'app' },
frontend: { theme: 'base', persist: true }
});
App.Frontend.setTheme('sunset');
App.dispose();
// After dispose, the entry registered for theme has been disposed; we
// cannot easily probe that without internals, so just assert no throw.
expect(() => adapter.setItem('app:theme', 'should-not-trigger')).not.toThrow();
});
});

@ -1,137 +0,0 @@
/**
* createTestApp — test helper smoke tests.
*
* Verifies the helper layers test-friendly defaults on top of createActiveApp:
* - silent logger by default (no console noise)
* - captureLogs mode attaches a sink and exposes entries
* - custom logger transports compose with the capture transport
* - non-logger sections (lang/formats/...) pass through unchanged
*/
import { describe, it, expect } from 'vitest';
import { createTestApp } from '../testing';
import { LogLevel } from '$logger';
import type { LangNode } from '$lang';
const schema = {
greeting: { es: 'Hola', en: 'Hello' }
} satisfies LangNode;
describe('createTestApp', () => {
it('builds with zero options', () => {
const App = createTestApp();
expect(App.Logger).toBeDefined();
expect(App.Lang).toBeDefined();
expect(App.Format).toBeDefined();
expect(App.Frontend).toBeDefined();
expect(App.Dom).toBeDefined();
expect(App.entries).toEqual([]);
App.dispose();
});
it('silences the logger by default', () => {
const App = createTestApp();
expect(App.Logger.transports()).toHaveLength(0);
App.Logger.warn('test', 'should not surface');
expect(App.entries).toHaveLength(0);
App.dispose();
});
it('captures entries when captureLogs is true', () => {
const App = createTestApp({ captureLogs: true });
App.Logger.info('boot', 'ready');
App.Logger.warn('auth', 'token expiring', { context: { userId: 1 } });
expect(App.entries).toHaveLength(2);
expect(App.entries[0].message).toBe('ready');
expect(App.entries[0].category).toBe('boot');
expect(App.entries[1].level).toBe(LogLevel.WARN);
expect(App.entries[1].context).toEqual({ userId: 1 });
App.dispose();
});
it('captures every level when captureLogs is true (level=TRACE)', () => {
const App = createTestApp({ captureLogs: true });
App.Logger.trace('t', 'trace');
App.Logger.debug('t', 'debug');
App.Logger.info('t', 'info');
App.Logger.warn('t', 'warn');
App.Logger.error('t', 'error');
expect(App.entries.map((e) => e.message)).toEqual(['trace', 'debug', 'info', 'warn', 'error']);
App.dispose();
});
it('appends capture transport to the caller-provided transports', () => {
const userWrites: string[] = [];
const App = createTestApp({
captureLogs: true,
logger: {
transports: [
{
name: 'user-sink',
write(entry) {
userWrites.push(entry.message);
}
}
]
}
});
App.Logger.info('demo', 'hello');
expect(userWrites).toEqual(['hello']);
expect(App.entries.map((e) => e.message)).toEqual(['hello']);
App.dispose();
});
it('respects caller-provided logger options when captureLogs is false', () => {
const App = createTestApp({
logger: { level: LogLevel.WARN, transports: [] }
});
expect(App.Logger.transports()).toHaveLength(0);
App.Logger.info('quiet', 'below level');
App.Logger.warn('loud', 'above level');
// Without captureLogs the entries array stays empty regardless.
expect(App.entries).toHaveLength(0);
App.dispose();
});
it('propagates lang/formats/frontend options unchanged', () => {
const App = createTestApp({
lang: { schema, defaultLocale: 'es' },
formats: { currency: { currency: 'EUR' } }
});
expect(App.Lang.t('greeting')).toBe('Hola');
expect(App.Format.currency.getCurrency()).toBe('EUR');
App.setLocale('en');
expect(App.Lang.t('greeting')).toBe('Hello');
App.dispose();
});
it('exposes entries as a non-enumerable property', () => {
const App = createTestApp({ captureLogs: true });
expect(Object.keys(App)).not.toContain('entries');
// But the value is still accessible and mutable.
App.Logger.info('cat', 'msg');
expect(App.entries).toHaveLength(1);
App.dispose();
});
it('dispose() works as in production', () => {
const App = createTestApp({ captureLogs: true });
App.Logger.info('cat', 'msg');
App.dispose();
expect(() => App.dispose()).not.toThrow();
});
});

@ -1,120 +0,0 @@
import { LogLevel, type LogEntry, type LoggerOptions, type Transport } from '$logger';
import type { LangNode } from '$libs/lang';
import { createActiveApp } from '../active-app.svelte';
import type { ActiveApp, ActiveAppOptions } from '../types';
/**
* Options for `createTestApp()`. Same shape as `ActiveAppOptions` but every
* section is optional and the helper layers test-friendly defaults on top.
*/
export interface TestAppOptions<S extends LangNode = LangNode>
extends Partial<ActiveAppOptions<S>> {
/**
* When `true`, attach a synchronous capture transport and route every
* entry to the returned `entries` array. The default logger level becomes
* `LogLevel.TRACE` so nothing is filtered out before reaching the sink.
*
* If a custom `logger.transports` is also provided, the capture transport
* is appended to it — both the test sink and the caller's transports
* receive every entry.
*
* @default false
*/
captureLogs?: boolean;
}
/**
* Test handle returned by `createTestApp()`. Identical to `ActiveApp` plus the
* `entries` array — empty unless `captureLogs: true`.
*/
export interface TestAppHandle<S extends LangNode = LangNode> extends ActiveApp<S> {
/**
* Captured log entries in order of arrival. Mutated in place by the
* capture transport so assertions can read it directly without polling.
*/
readonly entries: LogEntry[];
}
/**
* `createActiveApp()` with test-friendly defaults — the canonical way to
* build an `App` inside a unit/integration test.
*
* Lives under `$active-app/testing` so the helper does not ship with production
* bundles that import the main `$active-app` barrel.
*
* Defaults that differ from production:
*
* - **Logger silenced** unless `captureLogs: true`. Tests do not pollute the
* console with WARN+ entries from the real engine default.
* - **`captureLogs: true`** attaches an in-memory transport whose entries are
* exposed on the returned `entries` array, so assertions like
* `expect(App.entries.some(e => e.message === '...'))` are one-liners.
*
* Everything else passes through unchanged — the `lang`, `formats`,
* `frontend`, `dom` sections behave exactly as in production.
*
* @example
* // Smoke test — no logs needed
* import { createTestApp } from '$active-app/testing';
* const App = createTestApp({ lang: { schema } });
* expect(App.Lang.t('common.ok')).toBe('Aceptar');
* App.dispose();
*
* @example
* // Assert on emitted log entries
* const App = createTestApp({ captureLogs: true });
* App.Logger.warn('auth', 'token expiring');
* expect(App.entries).toHaveLength(1);
* expect(App.entries[0].category).toBe('auth');
* App.dispose();
*
* @example
* // Override only what's needed; everything else is the standard default.
* const App = createTestApp({
* captureLogs: true,
* formats: { currency: { currency: 'EUR' } }
* });
*/
export function createTestApp<S extends LangNode = LangNode>(
options: TestAppOptions<S> = {}
): TestAppHandle<S> {
const entries: LogEntry[] = [];
let loggerOptions: LoggerOptions;
if (options.captureLogs) {
const captureTransport: Transport = {
name: 'test-capture',
write(entry) {
entries.push(entry);
}
};
loggerOptions = {
level: LogLevel.TRACE,
...options.logger,
transports: [...(options.logger?.transports ?? []), captureTransport]
};
} else {
// Silent by default — tests should not emit to the real console
// unless the caller opts in or supplies their own transports.
loggerOptions = options.logger ?? { level: LogLevel.NONE, transports: [] };
}
const App = createActiveApp({
...options,
logger: loggerOptions
});
// Attach `entries` as a non-enumerable, frozen-reference field so it does
// not show up in `Object.keys(App)` and the array identity is stable
// across the test (push works because the property is the array itself,
// not a getter).
Object.defineProperty(App, 'entries', {
value: entries,
writable: false,
enumerable: false,
configurable: false
});
return App as TestAppHandle<S>;
}

@ -1,212 +1,142 @@
import type { ActiveDom, ActiveDomProps } from '$adom';
/**
* Public types for `arts/active-app`.
*
* `ActiveApp<TSchema>` is the composed surface seen by the application:
*
* - `ActiveAppCore` — `Logger`, `Bus`, `Timers`, `Orca`, `dispose`.
* Always present, never declared as a service.
* - `ResolveServiceInstances<TSchema>` — every entry the application
* declared in `services: { … }` is exposed as a lowercase property
* with the exact instance type returned by its factory.
* - `ActiveAppServicesIntrospection` — `services` map for devtools.
*
* The legacy uppercase surface (`App.Lang`, `App.Cache`, `App.Frontend`,
* `App.Format`, `App.Dom`, `App.Storage`, `App.Http`) has been removed.
* Those pieces are now opt-in services that the application declares
* via `defineActive*` / `defineEngine*` factories.
*/
import type { EngineBus, EngineBusOptions } from '$bus';
import type { ActiveCache, ActiveCacheOptions } from '$cache';
import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend';
import type { FrontendPreferenceKey } from '$frontend';
import type { ActiveFormat, ActiveFormatOptions } from '$format';
import type { EngineHttp, EngineHttpOptions } from '$http';
import type { AppEventMap } from './events.ts';
import type { LangNode, SupportedLocale } from '$libs/lang';
import type { ActiveLang } from '$lang';
import type { EngineLogger, LoggerOptions } from '$logger';
import type { EngineOrca } from '$orca';
import type { SessEventMap } from '$session';
import type { ActiveStorage, SyncStorageAdapter } from '$storage';
import type { EngineOrca, EngineOrcaOptions } from '$orca';
import type { ActiveTimers, EngineTimersOptions } from '$timer';
import type { AppEventMap } from './events.ts';
import type {
AppServiceSchema,
ResolveServiceInstances,
ServiceStatus
} from './services.ts';
/** Frontend preferences eligible for App-managed persistence. */
export type FrontendPersistKey = FrontendPreferenceKey;
export interface ActiveAppBusEvents extends AppEventMap, SessEventMap {}
/**
* Per-key persistence overrides — pick a different adapter, namespace or
* `raw` flag for individual preferences (e.g. cookie for theme to make it
* server-readable, while density and reducedMotion stay in localStorage).
*/
export interface FrontendPersistKeyOverride {
adapter?: SyncStorageAdapter;
namespace?: string | false;
raw?: boolean;
}
/**
* Frontend persistence config. `true` persists every supported key with the
* App's storage default. Pass an object to refine which keys are persisted
* and how.
* Bus event map seen by `App.Bus`. Includes App-owned events and any
* module-level event maps that App is intended to surface.
*
* Module event maps (e.g. `SessEventMap`, `ConnectionEventMap`) are
* intentionally NOT pre-declared here. Each app augments this type via
* declaration merging if it wants typed access; the default contract is
* App-level only.
*/
export type FrontendPersist =
| boolean
| {
/** Subset to persist. Default: every supported key. */
keys?: ReadonlyArray<FrontendPreferenceKey>;
/** Adapter for all persisted keys. Default: App.Storage's adapter. */
adapter?: SyncStorageAdapter;
/** Namespace prefix. Default: App.Storage's namespace. */
namespace?: string | false;
/** Per-key overrides applied on top of the outer adapter/namespace. */
overrides?: Partial<Record<FrontendPreferenceKey, FrontendPersistKeyOverride>>;
};
export interface ActiveAppBusEvents extends AppEventMap {}
/**
* Storage section of `ActiveAppOptions`. When omitted, App still exposes
* `App.Storage` backed by an in-memory adapter — values do not survive
* reload. Configure `adapter: localAdapter` (or any `SyncStorageAdapter`)
* for real persistence.
*/
export interface ActiveAppStorageOptions {
adapter?: SyncStorageAdapter;
namespace?: string;
}
// ── Options ────────────────────────────────────────────────────────────
/**
* Options for `createActiveApp()`. Every section is optional.
*
* - `dom` and `frontend` are always built (with defaults if absent).
* - `logger` defaults to the engine's `level: WARN` + `consoleTransport()`.
* Pass `{ level: LogLevel.NONE, transports: [] }` for silence.
* - `lang` defaults to a mono lang when absent (single-language passthrough,
* reactive locale still owned so Format/Frontend stay in sync).
* - `formats` is always built; sub-engines accept their own knobs (currency,
* date order, etc.). When absent the locale falls back to `FORMAT_DEFAULT_LOCALE`.
* Options for `createActiveApp()`. All sections are optional.
*
* Sium is intentionally **not** part of App — validation is page-scoped.
* Pages that need it construct an `EngineSium` directly:
* - `logger` defaults to engine defaults (`level: WARN`,
* `consoleTransport()`). Pass `{ level: NONE, transports: [] }` for
* silence.
* - `timers` and `bus` build with engine defaults; App injects
* `logger` (and `clock` for the bus) automatically.
* - `orca` builds with engine defaults; App injects `bus`, `timers`
* and `logger`.
* - `services` declares the opt-in service schema. If omitted, only
* the core is built and `App.services` is `{}`.
*
* ```ts
* import { createEngineSium } from '$sium';
* const sium = createEngineSium({ lang: App.Lang, logger: App.Logger });
* ```
* Whatever lived in the previous root-level options (`lang`, `formats`,
* `frontend`, `dom`, `storage`, `http`, `cache`) now belongs in
* `services` via the corresponding `defineActive*` factory.
*/
export interface ActiveAppOptions<
S extends LangNode = LangNode,
TSchema extends AppServiceSchema = AppServiceSchema
> {
export interface ActiveAppOptions<TSchema extends AppServiceSchema = AppServiceSchema> {
/**
* Declarative service schema. Each entry is built by an
* `AppServiceFactory` from `arts/active-app/service-factories/`.
* Services are exposed as lowercase properties on the App
* (`App.cache`, `App.session`, …) and live alongside the legacy
* uppercase factories until those are removed.
*
* Lazy services build on first access; `immediate` services build
* during `createActiveApp()`.
* Logger options for the App-wide engine logger. App passes the
* resulting instance to every service that declares `logger` as a
* core dependency.
*/
services?: TSchema;
logger?: LoggerOptions;
lang?: {
schema: S;
defaultLocale?: SupportedLocale;
fallbackChain?: SupportedLocale[];
};
formats?: Omit<ActiveFormatOptions, 'locale' | 'localeSource'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'> & {
/**
* Persist user preferences (theme, mode, density, ...) through
* `App.Storage`. `true` persists every supported key; pass an object
* for per-key adapter/namespace overrides (e.g. cookie for `theme`).
*
* @default false
*/
persist?: FrontendPersist;
};
dom?: ActiveDomProps;
storage?: ActiveAppStorageOptions;
/**
* Runtime timer scheduler defaults. App injects Logger automatically.
* Exposed as `App.Timers` and reused by integrations such as session
* auto-refresh.
* Timer scheduler options. App injects `logger` automatically.
*/
timers?: Omit<EngineTimersOptions, 'logger'>;
/**
* Cross-artifact event bus defaults. App injects Logger and the shared
* Timers clock automatically. The bus is for inter-module facts only;
* private per-artifact listeners remain local to each artifact.
* Cross-artifact event bus options. App injects `logger` and the
* shared timers `clock` automatically.
*/
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
/**
* HTTP client defaults — `baseUrl`, default headers, retry, timeout, hooks.
* When omitted, `App.Http` is still present with engine defaults
* (`globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s
* per-attempt timeout). The `logger` field is wired automatically.
*
* In SvelteKit `load` functions, scope the engine to the request via
* `App.Http.with({ fetch: event.fetch })` so cookies and relative-URL
* resolution flow through the framework.
*/
http?: Omit<EngineHttpOptions, 'logger'>;
/**
* App-scoped data cache. When omitted, App still exposes `App.Cache`
* backed by an in-memory adapter. Pass a custom adapter/policies/scope
* resolver when data should persist, share across layers or segment by
* tenant/actor/permission.
* Orca options. App injects `bus`, `timers` and `logger`
* automatically.
*/
cache?: Omit<ActiveCacheOptions, 'logger'>;
}
/**
* Composed application surface.
*
* Every member is **always** present so call sites can use `App.Lang.t(...)`
* or `App.Format.currency.format(...)` without null checks. When the caller
* did not configure an artifact, App provides a structurally identical
* adapter:
*
* | Artifact | Configured | Default |
* | -------- | ---------- | ------------------------------------------------ |
* | Logger | real | `level: WARN` + `consoleTransport()` (engine default; pass `{ level: NONE, transports: [] }` for silence) |
* | Lang | real | mono — returns paths and `\|fallback` literals; warns once per path in DEV via Logger under `lang.mono` |
* | Format | real | real with locale = `FORMAT_DEFAULT_LOCALE` (`'en-US'`) |
* | Frontend | real | real with default theme/mode/density |
* | Dom | real | real with default breakpoints |
*/
export type ActiveApp<
S extends LangNode = LangNode,
TSchema extends AppServiceSchema = AppServiceSchema
> = ActiveAppLegacy<S> & ResolveServiceInstances<TSchema> & ActiveAppServicesIntrospection;
orca?: Omit<EngineOrcaOptions, 'bus' | 'timers' | 'logger'>;
export interface ActiveAppServicesIntrospection {
/**
* Snapshot of `{ [serviceName]: ServiceStatus }` for every declared
* service. Useful for devtools and tests.
* Declarative service schema. Each entry is built by an
* `AppServiceFactory` from `arts/active-app/service-factories/`.
* Services are exposed as lowercase properties on the App
* (`App.cache`, `App.session`, …).
*
* Lazy services build on first access; `immediate` services build
* during `createActiveApp()`.
*/
readonly services: Readonly<Record<string, ServiceStatus>>;
services?: TSchema;
}
// ── Surface ────────────────────────────────────────────────────────────
/**
* The fixed core surface present on every App: Logger, Lang, Format,
* Frontend, Dom, Storage, Http, Timers, Bus, Orca, Cache, locale flow
* and `dispose`. Service-schema instances live alongside on
* `ActiveApp<S, TSchema>` (lowercase keys).
* The fixed core surface, present on every App: `Logger`, `Bus`,
* `Timers`, `Orca` plus the lifecycle helper `dispose`. None of these
* are services — they are the substrate every service depends on.
*/
export interface ActiveAppLegacy<S extends LangNode = LangNode> {
export interface ActiveAppCore {
readonly Logger: EngineLogger;
readonly Lang: ActiveLang<S>;
readonly Format: ActiveFormat;
readonly Frontend: ActiveFrontend;
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
readonly Http: EngineHttp;
readonly Timers: ActiveTimers;
readonly Bus: EngineBus<ActiveAppBusEvents>;
readonly Timers: ActiveTimers;
/**
* Orchestration engine (orca v0.0). Always present, inert until the
* application registers actions via `App.Orca.onEvent(...)` or applies
* presets from `arts/active-app/presets/` (e.g. `applyStandardOrca`).
* Orchestration engine. Always present, inert until the application
* registers actions via `App.Orca.onEvent(...)` or applies presets
* from `arts/active-app/presets/`.
*/
readonly Orca: EngineOrca;
readonly Cache: ActiveCache;
/** Active locale. Sourced from Lang (real or mono). */
getLocale: () => SupportedLocale;
setLocale: (locale: SupportedLocale) => void;
onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void;
/**
* Tear down every constructed service in reverse order, then the
* core in reverse build order. Idempotent.
*/
dispose(): void;
}
/** Tear down owned instances in reverse construction order. Idempotent. */
dispose: () => void;
/**
* Service introspection — used by tests, devtools, and any UI that
* wants to render the current schema state. Most application code does
* not read this.
*/
export interface ActiveAppServicesIntrospection {
readonly services: Readonly<Record<string, ServiceStatus>>;
}
/**
* Composed application surface.
*
* Type-safe access:
* - `App.Logger` / `App.Bus` / `App.Timers` / `App.Orca` always exist.
* - `App.<serviceName>` exists IFF the service was declared in
* `options.services`. Reading an undeclared name is a TypeScript
* error.
*/
export type ActiveApp<TSchema extends AppServiceSchema = AppServiceSchema> =
ActiveAppCore & ResolveServiceInstances<TSchema> & ActiveAppServicesIntrospection;

@ -335,23 +335,18 @@ export function onSessChanged(
Module events **may change between minors** (rename, payload addition,
deprecation). Only translators consume them.
### App events (public, stable)
### App-owned events
Universal facts about the app, defined in `libs/aapp/events.ts`. **Typed
payloads — never `any`. No credentials.**
The only event App publishes is `APP_EVENT_DISPOSE_STARTING`, fired at
the start of `App.dispose()` via `publishAppDisposeStarting()` in
[arts/active-app/events.ts](../active-app/events.ts).
```ts
// libs/aapp/events.ts
export const APP_EVENT_USER_IDENTITY_CHANGED = 'app.user.identity.changed';
export const APP_EVENT_TENANT_SWITCHED = 'app.tenant.switched';
export const APP_EVENT_PERMISSIONS_REFRESH_REQUESTED = 'app.permissions.refresh.requested';
export const APP_EVENT_CONNECTIVITY_CHANGED = 'app.connectivity.changed';
export const APP_EVENT_CACHE_INVALIDATE_REQUESTED = 'app.cache.invalidate.requested';
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
```
Renaming any `APP_EVENT_*`, removing it, or changing its payload shape
non-additively is a **major bump**. Adding new app events is a minor.
Cross-module reactions (cache.clear on identity change, perm.invalidate
on revoke, connections.reauth on identity change) are not bus
re-publications: they live as **orca actions** registered through the
presets in [arts/active-app/presets/](../active-app/presets/). Modules
publish their own typed events (`SESSION_EVENT_*` etc.) directly on
`App.Bus`; orca subscribes and runs the registered actions.
## App.Bus is always-present per render scope
@ -441,11 +436,11 @@ function directly) inside `$effect`:
```svelte
<script lang="ts">
import { APP_EVENT_USER_IDENTITY_CHANGED } from '$libs/aapp/events';
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
const Bus = getBus();
$effect(() =>
Bus.subscribe(APP_EVENT_USER_IDENTITY_CHANGED, (event) => {
Bus.subscribe(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
// react
})
);
@ -519,13 +514,8 @@ component's lifecycle.
Each `APP_EVENT_*` declares where it is allowed to fire:
```ts
// libs/aapp/events.ts
// arts/active-app/events.ts
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
[APP_EVENT_USER_IDENTITY_CHANGED]: APP_EVENT_RUNTIME_BOTH,
[APP_EVENT_TENANT_SWITCHED]: APP_EVENT_RUNTIME_BOTH,
[APP_EVENT_PERMISSIONS_REFRESH_REQUESTED]: APP_EVENT_RUNTIME_BOTH,
[APP_EVENT_CONNECTIVITY_CHANGED]: APP_EVENT_RUNTIME_CLIENT,
[APP_EVENT_CACHE_INVALIDATE_REQUESTED]: APP_EVENT_RUNTIME_BOTH,
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
};
@ -563,162 +553,64 @@ Eight tests are required for the v0.1 gate:
| **Runtime guard** | Publishing a `client`-only event on the server throws in DEV. |
| **Flush test** | `flushSync(() => Bus.publish(...))` makes DOM updates observable synchronously. |
## Translators
Cross-module reactions live in `arts/aapp/integrations/<x>-translator.ts`.
Their job is to **translate module events into app events**, not to
execute domain logic.
```ts
// arts/aapp/integrations/session-translator.ts
import {
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED,
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER,
APP_USER_IDENTITY_CAUSE_SESSION_EXPIRED,
APP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED,
APP_USER_IDENTITY_CAUSE_SESSION_REFRESHED,
APP_USER_IDENTITY_CAUSE_SESSION_REVOKED,
publishAppUserIdentityChanged
} from '$libs/aapp/events';
import {
SESSION_EVENT_CHANGED,
SESSION_EVENT_LIFECYCLE_ADOPTED,
SESSION_EVENT_LIFECYCLE_ADOPTED_SERVER,
SESSION_EVENT_LIFECYCLE_EXPIRED,
SESSION_EVENT_LIFECYCLE_EXTERNAL_CHANGED,
SESSION_EVENT_LIFECYCLE_REFRESHED,
SESSION_EVENT_LIFECYCLE_REVOKED
} from '$sess/consts';
import type { EngineBus } from '$libs/buss';
// Each lifecycle event maps to its own app-level cause. Hardcoding a
// single cause would mislabel five out of six transitions.
function resolveAppIdentityCause(event) {
if (event === SESSION_EVENT_LIFECYCLE_ADOPTED) return APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED;
if (event === SESSION_EVENT_LIFECYCLE_ADOPTED_SERVER) return APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER;
if (event === SESSION_EVENT_LIFECYCLE_REFRESHED) return APP_USER_IDENTITY_CAUSE_SESSION_REFRESHED;
if (event === SESSION_EVENT_LIFECYCLE_REVOKED) return APP_USER_IDENTITY_CAUSE_SESSION_REVOKED;
if (event === SESSION_EVENT_LIFECYCLE_EXPIRED) return APP_USER_IDENTITY_CAUSE_SESSION_EXPIRED;
if (event === SESSION_EVENT_LIFECYCLE_EXTERNAL_CHANGED) return APP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED;
return undefined;
}
export function wireSessionTranslator(bus: EngineBus): () => void {
const sub = bus.on(SESSION_EVENT_CHANGED, (event) => {
const cause = resolveAppIdentityCause(event.payload.event);
if (cause === undefined) return;
publishAppUserIdentityChanged(bus, {
event: event.payload.event,
generation: event.payload.generation,
identity: {
from: event.payload.identity.from,
to: event.payload.identity.to
},
cause
}, {
source: APP_MODULE
});
});
return () => sub.unsubscribe();
}
```
Rules for translators:
- They subscribe to **module events** and publish **app events**.
- They contain no domain logic. If you find yourself reading state from
three artifacts and computing a decision, you are writing a service.
- They publish app events through `publishApp*` helpers so runtime and
payload guards are applied. Use `publishCausedBy()` only when the
translator is threading an existing bus envelope and does not need the
app-event helper to enforce extra invariants.
- They are wired during App bootstrap, before any factory that publishes.
## Cross-module reactions live in orca, not on the bus
## Orchestration config
Earlier drafts of this art proposed a "translator" layer that turned
module events (`SESSION_EVENT_*`) into app events (`APP_EVENT_*`) and a
per-consumer `autoInvalidateOn` / `autoReauthOn` config that decided who
reacted to what. The big-bang refactor (commits `64ab1f0`, `c6de4a1`)
removed that machinery entirely.
Two independent layers configure runtime behaviour:
The model now is:
- **App level** decides which translators are wired (which `app.*` events
get published automatically).
- **Per consumer** decides which `app.*` events trigger automatic
side-effects in that consumer.
Publishing app events is observable and free; **destructive reactions
must be opt-in** (a consumer that auto-clears state without consent can
break user data).
### App level: which translators run
```ts
createActiveApp({ orchestration: 'standard' });
```txt
module emits SESSION_EVENT_IDENTITY_CHANGED on App.Bus
-> orca picks up the event (it subscribed lazily on first
action registration)
-> orca runs every action registered for that event
-> actions call App.cache.clear(), App.perm.invalidate(), etc.
```
| Value | Effect |
| -------------- | -------------------------------------------------------------------------- |
| `'standard'` | Wires the canonical translators below. Default when the option is omitted.|
| `'silent'` | Bus operative, no translator wired. App events fire only if you publish. |
| `string[]` | Wires only the named translators (e.g. `['identity']`). |
Translators wired by `'standard'`:
| Translator | Source events | Publishes |
| --------------- | -------------------------------------------------------------------------- | ------------------------------------ |
| `identity` | `SESSION_EVENT_CHANGED` from `App.createActiveSession(...)` | `APP_EVENT_USER_IDENTITY_CHANGED` |
| `dispose` | `App.dispose()` entry | `APP_EVENT_DISPOSE_STARTING` |
| `tenant-switched` | typed public contract / explicit publish point | `APP_EVENT_TENANT_SWITCHED` |
| `permissions-refresh` | typed public contract / explicit publish point | `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` |
| `connectivity` | typed public contract / explicit publish point | `APP_EVENT_CONNECTIVITY_CHANGED` |
### Per consumer: which app events auto-trigger side-effects
Reactions are declared **as orca actions** — registered through the
presets in [arts/active-app/presets/](../active-app/presets/) and wired
en bloc by `applyStandardOrca(App)`:
```ts
const Cache = App.createActiveCache({
autoInvalidateOn: 'standard'
});
import { createActiveApp, applyStandardOrca } from '$active-app';
const Perms = App.createActivePerms({
endpoint: '/permissions',
autoInvalidateOn: 'standard'
const App = createActiveApp({
services: {
cache: defineActiveCache({}),
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ ... })
}
});
const Connections = App.createActiveConnections({
autoReauthOn: 'standard'
});
applyStandardOrca(App);
// → SESSION_EVENT_IDENTITY_CHANGED runs cache.clear() + perm.invalidate()
// → SESSION_EVENT_REVOKED runs cache.clear()
```
Defaults for `'standard'`:
| Consumer | Reacts to (`'standard'`) | Action |
| --------------- | ----------------------------------------------------------------------- | ---------------------------- |
| `Cache` | `userIdentityChanged`, `tenantSwitched` | `Cache.invalidate()` |
| `Perms` | `userIdentityChanged`, `permissionsRefresh`, `tenantSwitched` | `Perms.invalidate()` |
| `Connections` | `userIdentityChanged` | `reauthenticateAll()` |
Override values: `'standard'` (table above), `'none'` (no auto-reactions),
`AppEventName[]` (explicit subset).
## Consumer rules
Consumers (`cach`, `perm`, `conn`, third-party plugins) listen **only**
to `app.*` events.
Cherry-pick when the standard set is too aggressive:
```ts
import { APP_EVENT_USER_IDENTITY_CHANGED } from '$libs/aapp/events';
import {
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange
} from '$active-app';
$effect(() =>
App.Bus.subscribe(APP_EVENT_USER_IDENTITY_CHANGED, () => {
Cache.invalidate();
})
);
applyCacheClearOnIdentityChange(App);
applyPermInvalidateOnIdentityChange(App);
```
This preserves the rule "modules don't know each other": consumers only
import public app event constants from `$libs/aapp/events`, never
private session/auth internals.
The bus's job is to **carry typed events between modules**. It does not
re-publish, classify, or react. The "consumer rules" reduce to: the
consumer registers an orca action; the bus stays dumb.
If a plugin needs to react to a fact that has no `app.*` event yet, the
right move is to **propose promoting the relevant module event to the
app layer**, not to subscribe directly to module events.
The consequence for module owners writing a new art: publish your
`<MODULE>_EVENT_*` events directly on `App.Bus` and document them. If a
later integration needs to react across modules, the integration ships
as an orca preset, not as code inside your art.
## Reactive wrappers (`active-bus.svelte.ts`)
@ -748,48 +640,43 @@ export function createBusRecent<TPayload>(bus: EngineBus, type: string) {
Surface stays minimal in v0.1: `lastEvent`, `count`, `clear()`,
`dispose`. Anything richer waits until real usage patterns emerge.
## Initial app event catalog
## App-owned event catalog
Only one event survived the big-bang refactor:
Start small. Six facts cover the universal needs:
| Constant | Meaning |
| ---------------------------- | ------------------------------------------------------ |
| `APP_EVENT_DISPOSE_STARTING` | `App.dispose()` entered, last chance to flush listeners |
| Constant | Meaning |
| ------------------------------------- | ----------------------------------------------------------- |
| `APP_EVENT_USER_IDENTITY_CHANGED` | Sign-in, sign-out, refresh, impersonation, … |
| `APP_EVENT_TENANT_SWITCHED` | Multi-tenant switch |
| `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | Policies changed, consumers may drop permission caches |
| `APP_EVENT_CONNECTIVITY_CHANGED` | Online ↔ offline (client-only) |
| `APP_EVENT_CACHE_INVALIDATE_REQUESTED` | Broad invalidation requested |
| `APP_EVENT_DISPOSE_STARTING` | App.dispose() entered, last chance to flush |
Everything else (identity changes, tenant switches, connectivity, cache
invalidation, permission refresh) is now expressed as **module events**
owned by their respective arts (`SESSION_EVENT_*`, etc.) and consumed
through orca actions registered at the App level.
Grow this list deliberately. A bus with 50 app events is the same
god-object problem at a different layer.
A bus with 50 app events is the same god-object problem at a different
layer; resist promoting module events to app events unless the fact is
genuinely cross-cutting.
## Credential safety in app events
## Credential safety on the bus
App events are public contract. They end up in logs, devtools, audit
Bus payloads are public contract. They end up in logs, devtools, audit
trails, and (in the future) outbox / replay. **Their payloads must
never carry credentials.**
Three protections, applied together:
Two protections:
1. **Typed payload restriction.** Payload interfaces in `libs/aapp/events.ts`
must not declare keys named `token`, `secret`, `password`, `hash`,
`authorization`, `credential` (case-insensitive).
1. **Typed payload restriction.** Payload interfaces must not declare
keys named `token`, `secret`, `password`, `hash`, `authorization`,
`credential` (case-insensitive).
2. **Pre-publish guard in app helpers.** `publishApp*` helpers call
`assertAppEventPayloadSafe(...)` and throw
2. **Pre-publish guard.** `publishAppDisposeStarting()` calls
`assertAppEventPayloadSafe(...)` (in
[arts/active-app/events.ts](../active-app/events.ts)) and throws
`AappUnsafeEventPayloadError` when a payload key contains a sensitive
part such as `token`, `secret`, `password`, `hash`, `authorization`,
`cookie`, `credential`, `csrf` or `header`.
3. **Documented invariant.** Every `APP_EVENT_*` declaration carries the
comment: _"App events never contain credentials. To pass sensitive
data, use `correlationId` and let the consumer resolve it against
`App.Sess` directly."_
If a real use case appears that needs sensitive payload, it does not
become an app event. It stays as a module event consumed only by a
translator with explicit access privileges.
`cookie`, `credential`, `csrf` or `header`. Module-event publishers
should apply the same hygiene — pass `correlationId` and let
subscribers resolve sensitive context from `App.session` themselves.
## What does **not** belong on the bus

@ -63,17 +63,18 @@ const value = await App.Cache.query({
});
```
Para apps reales, configura políticas, adapter o scope resolver:
For real apps, configure policies, adapter or scope resolver:
```ts
const App = createActiveApp({
cache: {
autoInvalidateOn: 'standard',
scopeResolver: () => ({
tenantId: App.Sess?.current?.data?.tenantId,
actorId: App.Sess?.current?.user?.id,
permissionHash: App.Perms?.currentSnapshot.version,
locale: App.getLocale()
services: {
cache: defineActiveCache({
scopeResolver: () => ({
tenantId: App.session?.current?.data?.tenantId,
actorId: App.session?.current?.user?.id,
permissionHash: App.perm?.currentSnapshot.version,
locale: App.Lang.getLocale()
})
})
}
});
@ -98,48 +99,43 @@ No uses esa opción para esconder una cache de producción accidental. En una ap
Si usas `scope: 'actor'` o `scope: 'permission'`, el resolver debe aportar los valores
necesarios. Si faltan, el motor falla en vez de mezclar datos de usuarios.
### App.Bus auto invalidation
`ActiveCache` can subscribe to public app events when a bus is injected. App
injects `App.Bus` automatically into `App.Cache`.
### Reacting to identity / session changes
Default is safe:
`ActiveCache` is a passive runtime: it never subscribes to the bus on its
own. Cross-module reactions live in **orca presets** declared at the App
level. The standard preset clears the cache on session identity change
and on session revoke:
```ts
const App = createActiveApp();
// App.Cache does not clear itself on identity changes by default.
```
import { createActiveApp, applyStandardOrca } from '$active-app';
Opt in when the app wants session/tenant transitions to wipe client cache:
```ts
const App = createActiveApp({
cache: {
autoInvalidateOn: 'standard'
services: {
cache: defineActiveCache({}),
session: defineActiveSession({ ... })
}
});
```
`standard` currently reacts to:
| App event | Effect |
| --- | --- |
| `APP_EVENT_USER_IDENTITY_CHANGED` | `Cache.clear()` |
| `APP_EVENT_TENANT_SWITCHED` | `Cache.clear()` |
applyStandardOrca(App);
// → on SESSION_EVENT_IDENTITY_CHANGED: App.cache.clear()
// → on SESSION_EVENT_REVOKED: App.cache.clear()
```
Fine-grained form:
Cherry-pick if the standard set is too aggressive:
```ts
const Cache = createActiveCache({
bus: App.Bus,
autoInvalidateOn: ['userIdentityChange']
});
import {
applyCacheClearOnIdentityChange,
applyCacheClearOnRevoke
} from '$active-app';
applyCacheClearOnIdentityChange(App);
// SESSION_EVENT_REVOKED is not handled — caches survive sign-out.
```
`cach` listens only to `app.*` events. It does not subscribe to private
`sess.*`, `auth.*` or `perm.*` events and it does not know how those events
were produced. This keeps cache invalidation policy local to the cache
consumer.
Cache itself listens to nothing. It exposes `clear()` / `invalidate()` and
trusts the orchestration layer to call them. This keeps cache invalidation
policy out of the cache art and on the App composition.
## Keys
@ -383,8 +379,9 @@ Defaults y recomendaciones:
- Usa `tenant`, `actor` o `permission` para datos privados.
- `privateSession` no persiste por defecto.
- Cambios de permisos deben invalidar scope `permission` o cambiar `permissionHash`.
- Logout o cambio de identidad debería llamar a `Cache.clear()` / invalidar scopes privados,
o activar `autoInvalidateOn: 'standard'` en el cliente.
- Logout o cambio de identidad debería llamar a `Cache.clear()` / invalidar scopes privados;
use `applyStandardOrca(App)` (or the individual presets) so this happens automatically
when the session art emits `SESSION_EVENT_*`.
- El cliente cachea para UX, no para seguridad. Las decisiones autoritativas viven en servidor.
## Página De Prueba
@ -417,7 +414,7 @@ v1 incluido:
- explain
- logger integration
- `App.Cache`
- `autoInvalidateOn` por `App.Bus`
- orca presets (`applyCacheClearOnIdentityChange`, `applyCacheClearOnRevoke`)
Siguiente:

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { createActiveApp } from '$active-app';
import { defineActiveCache } from '$active-app/services';
import {
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS,
@ -111,10 +112,14 @@ describe('createActiveCache', () => {
}
});
it('is always present on createActiveApp', () => {
const App = createActiveApp();
expect(App.Cache).toBeTruthy();
expect(App.Cache.stats().counters).toEqual({});
it('builds via defineActiveCache() in the App service schema', () => {
const App = createActiveApp({
services: {
cache: defineActiveCache()
}
});
expect(App.cache).toBeTruthy();
expect(App.cache.stats().counters).toEqual({});
App.dispose();
});
});

@ -83,16 +83,22 @@ App inyecta:
- `App.Bus`, para escuchar eventos públicos de aplicación cuando
`autoReauthOn` lo active.
El registro decide si crea un session source desde `App.Bus`:
The registry decides whether to subscribe to a session source. Wire one
explicitly when needed:
```ts
const Connections = App.createActiveConnections({
autoReauthOn: 'standard'
const App = createActiveApp({
services: {
connections: defineActiveConnections({
autoReauthOn: 'standard'
}),
session: defineActiveSession({ ... })
}
});
```
`standard` escucha `APP_EVENT_USER_IDENTITY_CHANGED`. Aun así, cada conexión
decide si quiere reaccionar con su propia opción `session`.
`standard` listens to `SESSION_EVENT_IDENTITY_CHANGED`. Each connection
still decides whether to react via its own `session` option.
Cada conexión decide si usa la sesión:
@ -327,25 +333,26 @@ El resultado de auth es tagged:
await Main.reauthenticate(); // { ok: true } | { ok: false, reason, error? }
```
Con `autoReauthOn` en el registro, `session.enabled` en la conexión y `auth`
definido, el session source derivado de `App.Bus` puede:
With `autoReauthOn` on the registry, `session.enabled` on the connection
and `auth` defined, the session source bound to the App's bus can:
- reautenticar cuando `aapp` publica `APP_EVENT_USER_IDENTITY_CHANGED` por
adopción, refresh o cambio externo;
- desconectar cuando ese evento representa expiración o revocación y
- reauthenticate when `session` publishes `SESSION_EVENT_IDENTITY_CHANGED`
on adoption, refresh, or external change;
- disconnect when that event represents expiration / revocation and
`disconnectOnExpire !== false`.
El flujo completo es:
The full flow is:
```txt
sess -> SESSION_EVENT_CHANGED
aapp -> APP_EVENT_USER_IDENTITY_CHANGED
conn -> reauth/disconnect si autoReauthOn + connection.session + connection.auth lo permiten
session -> SESSION_EVENT_IDENTITY_CHANGED
connection -> reauth/disconnect when autoReauthOn + connection.session +
connection.auth allow it
```
`conn` no escucha `sess.*` directamente y no conoce `auth`, `perm` ni `cach`.
Si quieres otro comportamiento, publica un `app.*` propio o llama a
`Connections.reauthenticateAll()` / `closeAll()` explícitamente.
`connection` does not subscribe to `session.*` directly: the wiring is the
session source the registry creates from `App.Bus`. If you want different
behaviour, register a custom orca action or call
`Connections.reauthenticateAll()` / `closeAll()` explicitly.
`conn` no crea sesiones ni decide permisos. En servidor, los joins/sends de un
canal deben validarse con `auth/sess/perm`.

@ -74,7 +74,8 @@ Main APIs:
- `createEnginePerms(options)` creates the authoritative engine from `$svrs/perm`.
- `createActivePerms(options)` creates a reactive client-side reflector from `$perm`.
- `App.createActivePerms(options)` creates an App-wired active client with `App.Http` and `App.Logger`.
- `defineActivePerm(options)` registers the perm slot on `App` via the
`services` schema; the App builder injects `Http`, `Logger` and `Bus`.
- `createPermHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain` from `$svrs/perm`.
- `<Can />` renders UI based on `Perms.can(...)`.
@ -567,50 +568,49 @@ const Perms = createActivePerms({
});
```
Or through App:
Or through App via the service schema:
```ts
const App = createActiveApp({
permissions: {
endpoint: '/api/permissions',
cacheTtlMs: 30_000
services: {
perm: defineActivePerm({
endpoint: '/api/perm',
cacheTtlMs: 30_000
})
}
});
const Perms = App.createActivePerms();
await App.perm.check({ action, resource, context });
```
`App.createActivePerms()` injects:
- `App.Http`
- `App.Logger`
- `App.Bus`
The endpoint remains explicit because the client is remote by design.
`defineActivePerm(...)` makes the App builder inject `App.Http`,
`App.Logger` and `App.Bus`. The endpoint remains explicit because the
client is remote by design.
Active client API:
Active client API (use `App.perm` once registered, or a freestanding
client returned by `createActivePerms(...)`):
```ts
await Perms.check({ action, resource, context });
await Perms.can({ action, resource, context });
await Perms.batch({ checks });
await Perms.what({ resource, actions, context });
await Perms.explain({ action, resource, context });
Perms.hydrate(snapshot);
Perms.snapshot();
Perms.invalidate();
Perms.onChange((snapshot) => {});
await App.perm.check({ action, resource, context });
await App.perm.can({ action, resource, context });
await App.perm.batch({ checks });
await App.perm.what({ resource, actions, context });
await App.perm.explain({ action, resource, context });
App.perm.hydrate(snapshot);
App.perm.snapshot();
App.perm.invalidate();
App.perm.onChange((snapshot) => {});
```
Reactive fields:
```ts
Perms.currentSnapshot;
Perms.decisions;
Perms.size;
Perms.loading;
Perms.lastError;
App.perm.currentSnapshot;
App.perm.decisions;
App.perm.size;
App.perm.loading;
App.perm.lastError;
```
## Snapshots And Cache
@ -619,13 +619,12 @@ The active client has a small decision cache.
```ts
const Perms = createActivePerms({
endpoint: '/api/permissions',
endpoint: '/api/perm',
initialSnapshot,
cacheTtlMs: 10_000,
nonAllowCacheTtlMs: 2_000,
remoteFailureBackoffMs: 1_000,
autoInvalidateOn: 'standard',
scopeKey: () => App.Sess?.current?.user?.id
scopeKey: () => App.session?.current?.user?.id
});
```
@ -655,47 +654,40 @@ Important:
- Mutations still require server-side `assert()`.
- Call `invalidate()` after actor/session/resource changes.
## App.Bus Auto Invalidation
`ActivePerms` can invalidate its local decision cache from public app
events. App injects `App.Bus` when you create permissions through
`App.createActivePerms(...)`.
## Reacting to session changes
Default is safe:
`ActivePerms` is a passive runtime: it never subscribes to the bus on
its own. Cross-module reactions live in **orca presets** declared at the
App level. The standard preset re-evaluates permissions when the
session art emits an identity change:
```ts
const Perms = App.createActivePerms({
endpoint: '/api/permissions'
});
// No automatic invalidation unless autoInvalidateOn is configured.
```
Opt in:
import { createActiveApp, applyStandardOrca } from '$active-app';
```ts
const Perms = App.createActivePerms({
endpoint: '/api/permissions',
autoInvalidateOn: 'standard'
const App = createActiveApp({
services: {
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ ... })
}
});
```
`standard` currently reacts to:
| App event | Effect |
| --- | --- |
| `APP_EVENT_USER_IDENTITY_CHANGED` | `Perms.invalidate()` |
| `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | `Perms.invalidate()` |
| `APP_EVENT_TENANT_SWITCHED` | `Perms.invalidate()` |
applyStandardOrca(App);
// → on SESSION_EVENT_IDENTITY_CHANGED: App.perm.invalidate()
```
Fine-grained form:
Cherry-pick when the standard set is too aggressive:
```ts
const Perms = App.createActivePerms({
endpoint: '/api/permissions',
autoInvalidateOn: ['userIdentityChange', 'permissionsRefresh']
});
import { applyPermInvalidateOnIdentityChange } from '$active-app';
applyPermInvalidateOnIdentityChange(App);
```
Tenant switches and "permissions refreshed" notifications are app-defined
events on `App.Bus`. Register a custom orca action that calls
`App.perm.invalidate()` (and any other affected services) when those
events fire — there is no built-in preset for them yet.
`perm` consumes only public `app.*` events. It does not subscribe to private
`sess.*`, `auth.*` or `cach.*` events, so it stays usable without session,
auth or cache modules. If an app has no session, do nothing; the decision cache
@ -711,8 +703,7 @@ It reads the active client from Svelte context:
```ts
import { setPermsContext } from '$perm';
const Perms = App.createActivePerms();
setPermsContext(Perms);
setPermsContext(App.perm);
```
Basic usage:
@ -888,21 +879,23 @@ Advice is similar but non-mandatory.
### aapp
`App.createActivePerms()` builds the UI client and injects `App.Http`,
`App.Logger` and `App.Bus`.
`defineActivePerm(...)` registers the perm slot via the App service
schema; the App builder injects `Http`, `Logger` and `Bus`. Reactions
to identity changes are wired through `applyStandardOrca(App)` (or
`applyPermInvalidateOnIdentityChange` directly).
```ts
const App = createActiveApp({
permissions: {
endpoint: '/api/permissions',
autoInvalidateOn: 'standard'
services: {
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ ... })
}
});
const Perms = App.createActivePerms();
applyStandardOrca(App);
```
`App.Perms` is `undefined` until `createActivePerms()` is called.
`App.perm` is `undefined` until the schema declares it.
### sess
@ -911,8 +904,8 @@ Session/authentication resolves the actor. `perm` does not log users in and does
Typical flow:
```ts
const actor = actorFromSession(App.Sess.current);
await Perms.assert({ actor, action, resource });
const actor = actorFromSession(App.session.current);
await App.perm.assert({ actor, action, resource });
```
On the server, resolve the actor from server-side session state, not from a client payload.
@ -965,7 +958,7 @@ There is an interactive page at:
It exercises:
- `createEnginePerms()`
- `App.createActivePerms()`
- `defineActivePerm()` via `App` services schema
- HTTP handlers
- client cache
- `<Can />`
@ -986,7 +979,8 @@ It exercises:
- Do not expose `explain()` details to users unless policy disclosure is acceptable.
- Apply obligations explicitly.
- Invalidate client cache after login, logout, session refresh, role change or resource mutation;
use `autoInvalidateOn: 'standard'` when those transitions are represented as app events.
`applyStandardOrca(App)` (or `applyPermInvalidateOnIdentityChange`) does this on
`SESSION_EVENT_IDENTITY_CHANGED` automatically.
## Minimal Complete Example

@ -569,15 +569,24 @@ session `data`. Code that needs the full snapshot should use
publishes from the active wrapper after `$state` has been updated, so consumers
that react through `App.Bus` see the latest `Sess.current` in the same tick.
When created through App:
When registered through the App service schema:
```ts
const Sess = App.createActiveSession<User, JwtCredential>();
const App = createActiveApp({
services: {
session: defineActiveSession<User, JwtCredential>({ ... })
}
});
await App.session.adopt({ user, credential, ... });
```
App injects both `Logger` and `Bus`. The default `aapp` session translator turns
`SESSION_EVENT_CHANGED` into `APP_EVENT_USER_IDENTITY_CHANGED`; cache,
permissions and connections react only if their own `auto*On` options opt in.
`defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`
automatically. The session art publishes its own `SESSION_EVENT_*` events
on `App.Bus`; cross-module reactions live in orca presets at the App
level (`applyCacheClearOnIdentityChange`,
`applyPermInvalidateOnIdentityChange`, etc.) — the standard set is wired
by `applyStandardOrca(App)`.
### Helpers
@ -670,8 +679,8 @@ export const load: LayoutServerLoad = ({ locals }) => ({
import { page } from '$app/state';
$effect(() => {
if (page.data.session && App.Sess?.current === null) {
App.Sess.adoptServer(page.data.session);
if (page.data.session && App.session?.current === null) {
App.session.adoptServer(page.data.session);
}
});
</script>
@ -688,31 +697,33 @@ poisoning the engine state.
```ts
const App = createActiveApp({
/* ... */
});
const Sess = App.createActiveSession<User, JwtCredential>({
schemas: { user: UserSchema },
storage: { adapter: localAdapter, key: 'aapp:session' },
onRefresh: async (current) => {
const r = await App.Http.post('/api/refresh', {
body: { refreshToken: current.credential.refreshToken },
schema: SessionResponseSchema
});
return r.ok ? r.value : null;
},
onRevoke: async (current) => {
const r = await App.Http.post('/api/logout', {
body: { refreshToken: current.credential.refreshToken }
});
return r.ok;
services: {
session: defineActiveSession<User, JwtCredential>({
schemas: { user: UserSchema },
storage: { adapter: localAdapter, key: 'aapp:session' },
onRefresh: async (current) => {
const r = await App.Http.post('/api/refresh', {
body: { refreshToken: current.credential.refreshToken },
schema: SessionResponseSchema
});
return r.ok ? r.value : null;
},
onRevoke: async (current) => {
const r = await App.Http.post('/api/logout', {
body: { refreshToken: current.credential.refreshToken }
});
return r.ok;
}
})
}
});
applyStandardOrca(App); // cross-module reactions on identity changes
```
The factory injects `App.Logger` automatically; `App.Http` is reachable
by closure (the lazy capture pattern handles the dependency cycle). It also
injects `App.Bus` so safe `sess.*` events can be translated by `aapp`.
`defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`;
`App.Http` is reachable through closure capture inside the handlers. The
session art publishes `SESSION_EVENT_*` directly on `App.Bus`.
---
@ -741,7 +752,7 @@ add cross-tab sync to any adapter.
| ------------------------- | ---------------------------------------- | -------------------------------------- |
| `SessionDisposedError` | mutator called after `dispose()` | **Thrown** + logged via `logger.error` |
| `SessionInvalidError` | `adoptServer()` invariant violation | **Thrown** + logged via `logger.error` |
| `SessionAlreadyCreatedError` | `App.createActiveSession()` called twice | **Thrown** by App factory |
| `SessionAlreadyCreatedError` | session service registered twice | **Thrown** by App factory |
Type guards: `isSessionDisposedError`, `isSessionInvalidError`,
`isSessionAlreadyCreatedError`.

@ -56,7 +56,7 @@ export function adoptServerErrorMessage(invariant: string): string {
export const SESSION_ERROR_MESSAGES: ErrorMessages = {
[SESSION_ERR_DISPOSED]: `${SESSION_ERROR_PREFIX}operation called on a disposed session engine`,
[SESSION_ERR_ALREADY_CREATED]:
`${SESSION_ERROR_PREFIX}App.createActiveSession() called more than once`,
`${SESSION_ERROR_PREFIX}session service registered more than once`,
[SESSION_ERR_INVALID_SESSION]: `${SESSION_ERROR_PREFIX}adoptServer() received an invalid session`
};
@ -74,9 +74,9 @@ export class SessionDisposedError extends CodeError {
}
/**
* `App.createActiveSession()` was called more than once. The artifact
* supports a single session per App; multi-account scenarios are out of
* scope (would compose multiple App instances).
* The session service was registered more than once on the App service
* schema. The artifact supports a single session per App; multi-account
* scenarios are out of scope (would compose multiple App instances).
*/
export class SessionAlreadyCreatedError extends CodeError {
constructor(message: string, options?: { cause?: unknown }) {

@ -1,7 +1,7 @@
import {
CACHE_CANONICAL_ROOT_PATH,
CACHE_ERROR_MESSAGES,
CACHE_KEY_ENTRIES_FIELD,
CACHE_VALIDATION_MESSAGES,
CACHE_KEY_TYPE_BIGINT,
CACHE_KEY_TYPE_DATE,
CACHE_KEY_TYPE_FIELD,

@ -4,7 +4,6 @@ import {
CACHE_DURATION_UNIT_MINUTE,
CACHE_DURATION_UNIT_MS,
CACHE_DURATION_UNIT_SECOND,
CACHE_ERROR_MESSAGES,
CACHE_MS_DAY,
CACHE_MS_HOUR,
CACHE_MS_MINUTE,
@ -17,7 +16,8 @@ import {
CACHE_READ_MODE_CACHE_FIRST,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_DEFAULT_CACHE_POLICY
CACHE_DEFAULT_CACHE_POLICY,
CACHE_VALIDATION_MESSAGES
} from './consts.ts';
import { CachePolicyError } from './errors.ts';
import type { CachePolicy, CacheReadMode, DurationInput, ResolvedCachePolicy } from './types.ts';

@ -1,5 +1,4 @@
import {
CACHE_ERROR_MESSAGES,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_CUSTOM,
CACHE_SCOPE_FIELD_ACTOR_ID,
@ -8,7 +7,8 @@ import {
CACHE_SCOPE_FIELD_TENANT_ID,
CACHE_SCOPE_PERMISSION,
CACHE_SCOPE_PUBLIC,
CACHE_SCOPE_TENANT
CACHE_SCOPE_TENANT,
CACHE_VALIDATION_MESSAGES
} from './consts.ts';
import { CacheScopeError } from './errors.ts';
import { stableHash, stableStringify } from './key.ts';

@ -0,0 +1,18 @@
<script lang="ts">
import { onDestroy, setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { setBus } from '$active-app/bus-context.svelte';
import { getDemoApp, disposeDemoApp } from './_lib/app.svelte';
let { children }: { children: Snippet } = $props();
const handle = getDemoApp();
setContext('demo:app', handle);
setBus(handle.App.Bus);
onDestroy(() => {
disposeDemoApp();
});
</script>
{@render children()}

@ -0,0 +1,6 @@
// The demo runs entirely in the browser — no SSR, no prerender. Every art
// reads the DOM (frontend, dom), reactively persists in localStorage
// (storage), opens a mock WebSocket-like transport (connection) and
// keeps an `App` singleton alive for the page tree.
export const ssr = false;
export const prerender = false;

@ -0,0 +1,89 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getDemoApp } from './_lib/app.svelte';
import SessionCard from './_lib/components/SessionCard.svelte';
import LangSwitcher from './_lib/components/LangSwitcher.svelte';
import ThemeSwitcher from './_lib/components/ThemeSwitcher.svelte';
import ViewportInfo from './_lib/components/ViewportInfo.svelte';
import TimerWidget from './_lib/components/TimerWidget.svelte';
import CacheCards from './_lib/components/CacheCards.svelte';
import PermInfo from './_lib/components/PermInfo.svelte';
import BusLog from './_lib/components/BusLog.svelte';
import FormatShowcase from './_lib/components/FormatShowcase.svelte';
import ValidationForm from './_lib/components/ValidationForm.svelte';
import LoggerPanel from './_lib/components/LoggerPanel.svelte';
const { App, logBuffer } =
(getContext<ReturnType<typeof getDemoApp>>('demo:app')) ?? getDemoApp();
</script>
<svelte:head>
<title>Active framework — Demo</title>
</svelte:head>
<header>
<h1>{App.lang.t('app.title')}</h1>
<p class="lead">{App.lang.t('app.subtitle')}</p>
<div class="surface">
<small>
services: <code>{Object.keys(App.services).join(', ')}</code>
</small>
</div>
</header>
<main>
<div class="grid">
<SessionCard {App} />
<LangSwitcher {App} />
<ThemeSwitcher {App} />
<ViewportInfo {App} />
<TimerWidget {App} />
<FormatShowcase {App} />
<PermInfo {App} />
<ValidationForm {App} />
</div>
<div class="full">
<CacheCards {App} />
</div>
<div class="full">
<BusLog {App} />
</div>
<div class="full">
<LoggerPanel {App} entries={logBuffer} />
</div>
</main>
<style>
header {
max-width: 1200px;
margin: 24px auto 16px;
padding: 0 16px;
}
header h1 {
margin: 0 0 4px;
}
.lead {
color: #666;
margin: 0 0 8px;
}
.surface code {
background: #f0f0f0;
padding: 1px 6px;
border-radius: 3px;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px 32px;
display: grid;
gap: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
</style>

@ -0,0 +1,184 @@
/**
* Builds the demo `App` with the framework's services wired up. Lives in a
* `.svelte.ts` so $state-backed services (Active*) work inside the
* Svelte runtime context.
*
* Mocks vs reality:
* - `http`: real `fetch` against jsonplaceholder.typicode.com (public).
* - `session`: in-memory state, refresh / revoke handlers simulate a
* server roundtrip with a 200ms delay.
* - `perm`: the demo wires a fake `fetcher` that resolves locally —
* see `mockPermFetch`. The art still goes through its full http
* path; only the network call is short-circuited.
* - `auth` and `connections` are intentionally **not** declared:
* - `auth` requires a real server (CSRF, cookies).
* - `connections` requires a real WebSocket; the demo creates a
* standalone connection with a mock transport in the panel
* that needs it.
*/
import { LogLevel, type LogEntry, type Transport } from '$logger';
import { createActiveApp } from '$active-app';
import {
defineActiveCache,
defineActiveDom,
defineActiveFormat,
defineActiveFrontend,
defineActiveLang,
defineActivePerm,
defineActiveSession,
defineActiveStorage,
defineEngineHttp,
defineEngineSium
} from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
import { localAdapter } from '$storage';
import { langSchema } from './lang-schema.ts';
export interface DemoUser {
readonly id: string;
readonly name: string;
readonly role: 'viewer' | 'editor' | 'admin';
}
export interface DemoCredential {
readonly accessToken: string;
}
export const DEMO_USERS: readonly DemoUser[] = [
{ id: 'user-ada', name: 'Ada Lovelace', role: 'admin' },
{ id: 'user-grace', name: 'Grace Hopper', role: 'editor' },
{ id: 'user-anon', name: 'Anonymous Viewer', role: 'viewer' }
];
/**
* Local mock that pretends to be the permission backend. Decisions
* depend on the `actor.role` passed in the `context` field.
*/
function mockPermFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const body = init?.body ? JSON.parse(String(init.body)) : {};
if (url.endsWith('/check')) {
const role = (body.context?.role ?? 'viewer') as DemoUser['role'];
const action = body.action as string;
const decision = decisionFor(action, role);
return Promise.resolve(
new Response(JSON.stringify(decision), {
status: 200,
headers: { 'content-type': 'application/json' }
})
);
}
if (url.endsWith('/batch')) {
const role = (body.context?.role ?? 'viewer') as DemoUser['role'];
const checks = (body.checks ?? []) as Array<{ action: string; resource?: unknown }>;
const decisions = Object.fromEntries(
checks.map((c) => [
`${c.action}:${JSON.stringify(c.resource ?? null)}`,
decisionFor(c.action, role)
])
);
return Promise.resolve(
new Response(JSON.stringify({ decisions }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
);
}
return Promise.resolve(new Response('{}', { status: 404 }));
}
function decisionFor(action: string, role: DemoUser['role']) {
return actionAllowedForRole(action, role)
? { effect: 'allow', policy: `demo:${action}` }
: { effect: 'not_applicable', reason: `role "${role}" cannot ${action}` };
}
function actionAllowedForRole(action: string, role: DemoUser['role']): boolean {
if (action === 'posts.read') return true;
if (action === 'posts.edit') return role === 'editor' || role === 'admin';
if (action === 'admin.access') return role === 'admin';
return false;
}
/** Reactive log buffer mirrored from the Logger transport. */
function createCaptureTransport(buffer: LogEntry[]): Transport {
return {
name: 'demo-capture',
write(entry) {
// Defer to escape any reactive read context the logger was
// invoked from — mutating `$state` during a `$derived` or
// template expression is forbidden in Svelte 5.
queueMicrotask(() => {
buffer.push(entry);
while (buffer.length > 50) buffer.shift();
});
}
};
}
export function buildDemoApp() {
const logBuffer: LogEntry[] = $state([]);
const App = createActiveApp({
logger: {
level: LogLevel.DEBUG,
transports: [createCaptureTransport(logBuffer)]
},
services: {
cache: defineActiveCache({}),
dom: defineActiveDom({}),
format: defineActiveFormat({ currency: { currency: 'EUR' } }),
frontend: defineActiveFrontend({ theme: 'system', applyDom: true }),
lang: defineActiveLang({ schema: langSchema, defaultLocale: 'es' }),
storage: defineActiveStorage({ adapter: localAdapter, namespace: 'demo' }),
http: defineEngineHttp({
baseUrl: 'https://jsonplaceholder.typicode.com'
}),
sium: defineEngineSium({}),
session: defineActiveSession<DemoUser, DemoCredential>({
broadcastChannel: 'demo:session',
onRefresh: async (current) => {
await new Promise((r) => setTimeout(r, 200));
return {
user: current.user,
credential: { accessToken: `tok-refreshed-${Date.now()}` },
expiresAt: Date.now() + 60_000,
issuedAt: Date.now()
};
},
onRevoke: async () => {
await new Promise((r) => setTimeout(r, 100));
return true;
}
}),
perm: defineActivePerm({
endpoint: 'demo://perm',
fetcher: mockPermFetch
})
}
});
// Register the standard orca presets — when session identity changes
// or revokes, cache.clear() and perm.invalidate() fire automatically.
const detachOrca = applyStandardOrca(App as never);
return { App, logBuffer, detachOrca };
}
let appHandle: ReturnType<typeof buildDemoApp> | undefined;
export function getDemoApp() {
if (!appHandle) appHandle = buildDemoApp();
return appHandle;
}
export function disposeDemoApp() {
if (!appHandle) return;
appHandle.detachOrca();
appHandle.App.dispose();
appHandle = undefined;
}

@ -0,0 +1,114 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
import type { BusEnvelope } from '$libs/bus';
import { onDestroy } from 'svelte';
let { App }: { App: ActiveApp } = $props();
interface BusEntry {
readonly id: number;
readonly type: string;
readonly at: number;
}
let entries = $state<BusEntry[]>([]);
let nextId = 0;
const detach = App.Bus.onAny((event: BusEnvelope<string, unknown>) => {
entries.push({ id: nextId++, type: event.type, at: event.at });
while (entries.length > 12) entries.shift();
});
let recentRuns = $derived(App.Orca.recentRuns().slice(-5).reverse());
function refresh() {
// recentRuns is computed each render — touching this triggers re-derive.
void App.Orca.recentRuns();
}
onDestroy(() => {
detach.unsubscribe();
});
</script>
<section class="card">
<h2>{App.lang.t('bus.title')}</h2>
<div class="grid">
<div>
<h3>Bus events</h3>
{#if entries.length === 0}
<em>{App.lang.t('bus.empty')}</em>
{:else}
<ul>
{#each [...entries].reverse() as e (e.id)}
<li>
<code>{e.type}</code>
<small>{App.format.dates.formatTime(new Date(e.at))}</small>
</li>
{/each}
</ul>
{/if}
</div>
<div>
<h3>Orca runs <button class="ghost" onclick={refresh}>↻</button></h3>
{#if recentRuns.length === 0}
<em>{App.lang.t('bus.empty')}</em>
{:else}
<ul>
{#each recentRuns as run (run.id)}
<li>
<code>{run.event}</code>
<small>{run.status} ({run.actions.length} actions, {run.durationMs}ms)</small>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
h3 {
font-size: 0.9em;
margin: 0 0 4px;
color: #666;
text-transform: uppercase;
display: flex;
justify-content: space-between;
}
ul {
margin: 0;
padding-left: 1.2em;
font-size: 0.85em;
}
li {
margin: 2px 0;
}
code {
background: #f0f0f0;
padding: 1px 5px;
border-radius: 3px;
}
small {
color: #888;
margin-left: 6px;
}
button.ghost {
border: none;
background: transparent;
cursor: pointer;
padding: 0;
}
</style>

@ -0,0 +1,172 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
import {
CACHE_POLICY_INTERACTIVE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_SCOPE_PUBLIC
} from '$cache';
import type { StandardSchemaV1 } from '$libs/standard-schema';
let { App }: { App: ActiveApp } = $props();
// Pass-through schema: the engine only parses+returns the JSON body
// when a schema is supplied. This is the demo's "trust the response"
// equivalent of `response.json()` without any extra validation.
function passthrough<T>(): StandardSchemaV1<unknown, T> {
return {
'~standard': {
version: 1,
vendor: 'demo-passthrough',
validate(value) {
return { value: value as T };
}
}
};
}
const cache = $derived(App.cache);
const http = $derived(App.http);
interface Post {
id: number;
title: string;
}
interface UserPlaceholder {
id: number;
name: string;
email: string;
}
let posts = $state<Post[] | null>(null);
let users = $state<UserPlaceholder[] | null>(null);
let busy = $state(false);
async function fetchPosts() {
busy = true;
try {
const result = await cache.query<Post[]>({
key: ['demo', 'posts'],
policy: CACHE_POLICY_INTERACTIVE,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
scope: CACHE_SCOPE_PUBLIC,
async fetcher() {
const r = await http.get('/posts?_limit=5', { schema: passthrough<Post[]>() });
if (!r.ok) throw new Error(`http ${r.kind}`);
return r.value;
}
});
posts = result ?? null;
} finally {
busy = false;
}
}
async function fetchUsers() {
busy = true;
try {
const result = await cache.query<UserPlaceholder[]>({
key: ['demo', 'users'],
policy: CACHE_POLICY_INTERACTIVE,
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
scope: CACHE_SCOPE_PUBLIC,
async fetcher() {
const r = await http.get('/users?_limit=3', {
schema: passthrough<UserPlaceholder[]>()
});
if (!r.ok) throw new Error(`http ${r.kind}`);
return r.value;
}
});
users = result ?? null;
} finally {
busy = false;
}
}
async function clearAll() {
busy = true;
try {
await cache.clear();
posts = null;
users = null;
} finally {
busy = false;
}
}
</script>
<section class="card">
<h2>{App.lang.t('cache.title')}</h2>
<p class="hint">{App.lang.t('cache.stalePolicy')}</p>
<div class="actions">
<button onclick={fetchPosts} disabled={busy}>{App.lang.t('cache.fetchPosts')}</button>
<button onclick={fetchUsers} disabled={busy}>{App.lang.t('cache.fetchUsers')}</button>
<button onclick={clearAll} disabled={busy}>{App.lang.t('cache.clear')}</button>
</div>
<div class="grid">
<div>
<h3>posts</h3>
{#if posts}
<ul>
{#each posts as post (post.id)}
<li><strong>#{post.id}</strong> {post.title}</li>
{/each}
</ul>
{:else}
<em>{App.lang.t('cache.empty')}</em>
{/if}
</div>
<div>
<h3>users</h3>
{#if users}
<ul>
{#each users as u (u.id)}
<li><strong>{u.name}</strong> <small>{u.email}</small></li>
{/each}
</ul>
{:else}
<em>{App.lang.t('cache.empty')}</em>
{/if}
</div>
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
.hint {
color: #888;
font-size: 0.85em;
margin: 0 0 8px;
}
.actions {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
h3 {
font-size: 0.9em;
margin: 0 0 4px;
color: #666;
text-transform: uppercase;
}
ul {
margin: 0;
padding-left: 1.2em;
font-size: 0.9em;
}
small {
color: #888;
}
</style>

@ -0,0 +1,43 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
let { App }: { App: ActiveApp } = $props();
const today = new Date();
</script>
<section class="card">
<h2>{App.lang.t('format.title')}</h2>
<dl>
<dt>{App.lang.t('format.number')}</dt>
<dd><code>{App.format.numbers.format(123_456.789)}</code></dd>
<dt>{App.lang.t('format.currency')}</dt>
<dd><code>{App.format.currency.format(2499.5)}</code></dd>
<dt>{App.lang.t('format.date')}</dt>
<dd><code>{App.format.dates.formatDate(today)}</code></dd>
<dt>{App.lang.t('format.percent')}</dt>
<dd><code>{App.format.numbers.formatPercent(0.7325)}</code></dd>
</dl>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 12px;
}
dt {
font-weight: 600;
color: #666;
}
code {
background: #f0f0f0;
padding: 1px 6px;
border-radius: 3px;
}
</style>

@ -0,0 +1,39 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
import type { SupportedLocale } from '$lang';
let { App }: { App: ActiveApp } = $props();
const locales: SupportedLocale[] = ['es', 'en'];
let current = $state<SupportedLocale>(App.lang.getLocale());
function setLocale(loc: SupportedLocale) {
current = loc;
App.lang.setLocale(loc);
}
</script>
<section class="card">
<h2>{App.lang.t('lang.title')}</h2>
<div class="actions">
{#each locales as loc (loc)}
<button class:active={current === loc} onclick={() => setLocale(loc)}>{loc}</button>
{/each}
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
.actions {
display: flex;
gap: 8px;
}
button.active {
background: #333;
color: white;
}
</style>

@ -0,0 +1,61 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
import type { LogEntry } from '$logger';
let { App, entries }: { App: ActiveApp; entries: LogEntry[] } = $props();
const lastEntries = $derived([...entries].reverse().slice(0, 10));
</script>
<section class="card">
<h2>{App.lang.t('logger.title')}</h2>
{#if entries.length === 0}
<em>{App.lang.t('logger.empty')}</em>
{:else}
<ul>
{#each lastEntries as entry, i (i)}
<li class={`level-${String(entry.level).toLowerCase()}`}>
<code>[{entry.category}]</code>
{entry.message}
</li>
{/each}
</ul>
{/if}
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
font-family: monospace;
font-size: 0.85em;
max-height: 200px;
overflow-y: auto;
}
li {
padding: 2px 4px;
border-bottom: 1px dashed #eee;
}
li.level-error,
li.level-fatal {
color: #c44;
}
li.level-warn {
color: #c80;
}
li.level-debug,
li.level-trace {
color: #888;
}
code {
background: #f0f0f0;
padding: 1px 4px;
border-radius: 2px;
}
</style>

@ -0,0 +1,121 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ActiveApp } from '$active-app';
let { App }: { App: ActiveApp } = $props();
const perm = $derived(App.perm);
const session = $derived(App.session);
const actions = [
{ id: 'posts.read', labelKey: 'perm.actions.read' },
{ id: 'posts.edit', labelKey: 'perm.actions.edit' },
{ id: 'admin.access', labelKey: 'perm.actions.admin' }
] as const;
let decisions = $state<Record<string, 'allow' | 'deny' | 'pending' | 'unknown'>>({});
async function probeAll() {
const user = session?.current?.user;
const role = user?.role ?? 'viewer';
const actor = { id: user?.id ?? 'anonymous', type: 'user' };
for (const { id } of actions) {
decisions[id] = 'pending';
try {
const result = (await perm?.check({
actor,
action: id,
context: { role }
})) as { effect?: string } | undefined;
decisions[id] = result?.effect === 'allow' ? 'allow' : 'deny';
} catch {
decisions[id] = 'unknown';
}
}
}
function clear() {
perm?.invalidate();
decisions = {};
}
$effect(() => {
// Re-probe when the session identity changes. Reading the role
// here registers the dependency; the probe itself runs untracked
// so its writes (and perm-runtime state mutations) do not feed
// back into this effect.
void session?.current?.user.role;
untrack(() => {
void probeAll();
});
});
</script>
<section class="card">
<h2>{App.lang.t('perm.title')}</h2>
<ul>
{#each actions as action (action.id)}
<li>
<span class="label">{App.lang.t(action.labelKey)}</span>
<span class={`badge ${decisions[action.id] ?? 'unknown'}`}>
{decisions[action.id] === 'allow'
? '✓ ' + App.lang.t('perm.can')
: decisions[action.id] === 'deny'
? '✗ ' + App.lang.t('perm.cannot')
: decisions[action.id] === 'pending'
? '…'
: '?'}
</span>
</li>
{/each}
</ul>
<div class="actions">
<button onclick={probeAll}>↻</button>
<button onclick={clear}>×</button>
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
ul {
list-style: none;
padding: 0;
margin: 0 0 8px;
}
li {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed #eee;
}
.badge {
padding: 1px 8px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
}
.badge.allow {
background: #2c8;
color: white;
}
.badge.deny {
background: #c44;
color: white;
}
.badge.pending {
background: #aaa;
color: white;
}
.badge.unknown {
background: #ddd;
color: #666;
}
.actions {
display: flex;
gap: 8px;
}
</style>

@ -0,0 +1,103 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
import { DEMO_USERS, type DemoCredential, type DemoUser } from '../app.svelte';
let { App }: { App: ActiveApp } = $props();
const Sess = $derived(App.session);
let userIndex = $state(0);
let busy = $state(false);
async function signIn() {
busy = true;
const user = DEMO_USERS[userIndex];
Sess.adopt({
user,
credential: { accessToken: `tok-${user.id}-${Date.now()}` },
expiresAt: Date.now() + 60_000,
issuedAt: Date.now()
});
busy = false;
}
async function switchUser() {
busy = true;
userIndex = (userIndex + 1) % DEMO_USERS.length;
await signIn();
}
async function refresh() {
busy = true;
try {
await Sess.refresh();
} finally {
busy = false;
}
}
async function signOut() {
busy = true;
try {
await Sess.revoke();
} finally {
busy = false;
}
}
</script>
<section class="card">
<h2>{App.lang.t('session.title')}</h2>
<dl>
<dt>{App.lang.t('session.identity')}</dt>
<dd>
{#if Sess.current}
<strong>{Sess.current.user.name}</strong>
<span class="role">({Sess.current.user.role})</span>
{:else}
<em>{App.lang.t('session.anonymous')}</em>
{/if}
</dd>
<dt>{App.lang.t('session.generation')}</dt>
<dd>#{Sess.generation}</dd>
</dl>
<div class="actions">
{#if Sess.current}
<button onclick={switchUser} disabled={busy}>{App.lang.t('session.switchUser')}</button>
<button onclick={refresh} disabled={busy}>{App.lang.t('session.refresh')}</button>
<button onclick={signOut} disabled={busy}>{App.lang.t('session.signOut')}</button>
{:else}
<button onclick={signIn} disabled={busy}>{App.lang.t('session.signIn')}</button>
{/if}
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 12px;
margin: 8px 0;
}
dt {
font-weight: 600;
color: #666;
}
.role {
color: #888;
font-size: 0.9em;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
padding: 4px 10px;
}
</style>

@ -0,0 +1,124 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ActiveApp } from '$active-app';
let { App }: { App: ActiveApp } = $props();
const themes = ['light', 'dark', 'system'] as const;
const modes = ['light', 'dark', 'auto'] as const;
const directions = ['ltr', 'rtl', 'auto'] as const;
const densities = ['compact', 'normal', 'comfortable'] as const;
// `App.frontend` exposes plain getters; track preference changes via the
// engine's own subscription so $effect-backed derivations stay correct.
let theme = $state(App.frontend.getTheme());
let mode = $state<string>(App.frontend.getMode());
let modeAuto = $state(App.frontend.isModeAuto());
let dir = $state<string>(App.frontend.getDir());
let dirAuto = $state(App.frontend.isDirAuto());
let density = $state<string>(App.frontend.getDensity());
const off = App.frontend.onPreferenceChange(() => {
theme = App.frontend.getTheme();
mode = App.frontend.getMode();
modeAuto = App.frontend.isModeAuto();
dir = App.frontend.getDir();
dirAuto = App.frontend.isDirAuto();
density = App.frontend.getDensity();
});
onDestroy(off);
function isActiveMode(value: string): boolean {
return value === 'auto' ? modeAuto : !modeAuto && mode === value;
}
function isActiveDir(value: string): boolean {
return value === 'auto' ? dirAuto : !dirAuto && dir === value;
}
</script>
<section class="card">
<h2>{App.lang.t('frontend.title')}</h2>
<div class="row">
<span class="lbl">{App.lang.t('frontend.theme')}</span>
<div class="actions">
{#each themes as t (t)}
<button class:active={theme === t} onclick={() => App.frontend.setTheme(t)}>{t}</button>
{/each}
</div>
</div>
<div class="row">
<span class="lbl">{App.lang.t('frontend.mode')}</span>
<div class="actions">
{#each modes as m (m)}
<button class:active={isActiveMode(m)} onclick={() => App.frontend.setMode(m)}>{m}</button>
{/each}
</div>
</div>
<div class="row">
<span class="lbl">{App.lang.t('frontend.direction')}</span>
<div class="actions">
{#each directions as d (d)}
<button class:active={isActiveDir(d)} onclick={() => App.frontend.setDir(d)}>{d}</button>
{/each}
</div>
</div>
<div class="row">
<span class="lbl">{App.lang.t('frontend.density')}</span>
<div class="actions">
{#each densities as d (d)}
<button class:active={density === d} onclick={() => App.frontend.setDensity(d)}>{d}</button>
{/each}
</div>
</div>
<div class="state">
<code>theme={theme}</code>
<code>mode={mode}{modeAuto ? ' (auto)' : ''}</code>
<code>dir={dir}{dirAuto ? ' (auto)' : ''}</code>
<code>density={density}</code>
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
.row {
display: grid;
grid-template-columns: 100px 1fr;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.lbl {
font-weight: 600;
color: #666;
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
button.active {
background: #333;
color: white;
}
.state {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
font-size: 0.8em;
}
.state code {
background: #f0f0f0;
padding: 1px 6px;
border-radius: 3px;
}
</style>

@ -0,0 +1,79 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
let { App }: { App: ActiveApp } = $props();
let firedAt = $state<number | null>(null);
let pending = $state(false);
let countdown = $state<number | null>(null);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
function schedule() {
if (pending) return;
pending = true;
firedAt = null;
countdown = 5;
countdownTimer = setInterval(() => {
if (countdown !== null && countdown > 0) countdown--;
}, 1000);
App.Timers.schedule('demo:fire', 5000, () => {
firedAt = Date.now();
pending = false;
countdown = null;
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
});
}
function cancel() {
App.Timers.cancel('demo:fire');
pending = false;
countdown = null;
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
</script>
<section class="card">
<h2>{App.lang.t('timer.title')}</h2>
<div class="state">
{#if pending && countdown !== null}
<strong>{countdown}s</strong>
{:else if firedAt !== null}
✓ {App.lang.t('timer.fired')}
<small>({App.format.dates.formatTime(new Date(firedAt))})</small>
{:else}
<em>{App.lang.t('timer.none')}</em>
{/if}
</div>
<div class="actions">
<button onclick={schedule} disabled={pending}>{App.lang.t('timer.schedule')}</button>
<button onclick={cancel} disabled={!pending}>{App.lang.t('timer.cancel')}</button>
</div>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
.state {
font-size: 1.5em;
min-height: 1.5em;
margin-bottom: 8px;
}
.state small {
font-size: 0.6em;
color: #888;
}
.actions {
display: flex;
gap: 8px;
}
</style>

@ -0,0 +1,109 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
let { App }: { App: ActiveApp } = $props();
const sium = $derived(App.sium);
let email = $state('');
let password = $state('');
let result = $state<{ ok: boolean; errors: string[] } | null>(null);
async function submit() {
if (!sium) return;
// Sium uses piped combinators rather than chained methods.
const Login = sium.object({
email: sium.pipe(sium.string(), sium.email()),
password: sium.pipe(sium.string(), sium.min(8))
});
const r = await sium.validate(Login, { email, password });
if (r.ok) {
result = { ok: true, errors: [] };
return;
}
const messages = sium.resolveIssues(r.issues);
result = {
ok: false,
errors: r.issues.map(
(issue, idx) => `${issue.path.join('.') || '(root)'}: ${messages[idx]}`
)
};
}
</script>
<section class="card">
<h2>{App.lang.t('validation.title')}</h2>
<form
onsubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<label>
<span>{App.lang.t('validation.email')}</span>
<input type="text" bind:value={email} />
</label>
<label>
<span>{App.lang.t('validation.password')}</span>
<input type="password" bind:value={password} />
</label>
<button type="submit">{App.lang.t('validation.submit')}</button>
</form>
{#if result}
{#if result.ok}
<p class="ok">✓ {App.lang.t('validation.ok')}</p>
{:else}
<div class="errors">
<strong>{App.lang.t('validation.errors')}:</strong>
<ul>
{#each result.errors as err, i (i)}
<li>{err}</li>
{/each}
</ul>
</div>
{/if}
{/if}
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
form {
display: grid;
gap: 8px;
margin-bottom: 8px;
}
label {
display: grid;
grid-template-columns: 100px 1fr;
align-items: center;
gap: 8px;
}
label span {
font-weight: 600;
color: #666;
}
input {
padding: 4px 6px;
border: 1px solid #ccc;
border-radius: 3px;
}
.ok {
color: #2c8;
font-weight: 600;
}
.errors {
background: #ffe6e6;
padding: 8px;
border-radius: 4px;
}
.errors ul {
margin: 4px 0 0;
padding-left: 1.4em;
}
</style>

@ -0,0 +1,46 @@
<script lang="ts">
import type { ActiveApp } from '$active-app';
let { App }: { App: ActiveApp } = $props();
const breakpoint = $derived(App.dom.currentBreakpoint.current);
const width = $derived(App.dom.viewport.width);
</script>
<section class="card">
<h2>{App.lang.t('dom.title')}</h2>
<dl>
<dt>{App.lang.t('dom.width')}</dt>
<dd><code>{width ?? '—'} px</code></dd>
<dt>{App.lang.t('dom.breakpoint')}</dt>
<dd>
{#if breakpoint}
<code>{breakpoint}</code>
{:else}
—
{/if}
</dd>
</dl>
</section>
<style>
.card {
border: 1px solid var(--border, #ccc);
border-radius: 6px;
padding: 12px;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 12px;
}
dt {
font-weight: 600;
color: #666;
}
code {
background: #f0f0f0;
padding: 1px 6px;
border-radius: 3px;
}
</style>

@ -0,0 +1,87 @@
/**
* Bilingual schema for the demo. Keys cover every widget so the language
* switcher shows real changes in every panel at once.
*/
export const langSchema = {
app: {
title: { es: 'Active framework — Demo', en: 'Active framework — Demo' },
subtitle: {
es: 'Todos los servicios del framework en una sola página, sin servidor.',
en: 'Every framework service on a single page, with no server.'
}
},
session: {
title: { es: 'Sesión', en: 'Session' },
anonymous: { es: 'Anónimo', en: 'Anonymous' },
signIn: { es: 'Iniciar sesión', en: 'Sign in' },
signOut: { es: 'Cerrar sesión', en: 'Sign out' },
refresh: { es: 'Renovar', en: 'Refresh' },
switchUser: { es: 'Cambiar de usuario', en: 'Switch user' },
identity: { es: 'Identidad', en: 'Identity' },
generation: { es: 'Generación', en: 'Generation' }
},
frontend: {
title: { es: 'Preferencias UI', en: 'UI preferences' },
theme: { es: 'Tema', en: 'Theme' },
mode: { es: 'Modo', en: 'Mode' },
density: { es: 'Densidad', en: 'Density' },
direction: { es: 'Dirección', en: 'Direction' }
},
lang: {
title: { es: 'Idioma', en: 'Language' },
select: { es: 'Selecciona idioma', en: 'Select language' }
},
dom: {
title: { es: 'Viewport', en: 'Viewport' },
breakpoint: { es: 'Punto de ruptura', en: 'Breakpoint' },
width: { es: 'Ancho', en: 'Width' }
},
timer: {
title: { es: 'Timer', en: 'Timer' },
schedule: { es: 'Programar 5 s', en: 'Schedule 5s' },
cancel: { es: 'Cancelar', en: 'Cancel' },
fired: { es: 'Disparado', en: 'Fired' },
none: { es: 'Sin programar', en: 'Not scheduled' }
},
cache: {
title: { es: 'Cache', en: 'Cache' },
fetchPosts: { es: 'Cargar posts', en: 'Load posts' },
fetchUsers: { es: 'Cargar usuarios', en: 'Load users' },
clear: { es: 'Limpiar', en: 'Clear' },
empty: { es: 'Vacío', en: 'Empty' },
stalePolicy: { es: 'Política: stale-while-revalidate', en: 'Policy: stale-while-revalidate' }
},
perm: {
title: { es: 'Permisos', en: 'Permissions' },
can: { es: 'Puede', en: 'Can' },
cannot: { es: 'No puede', en: 'Cannot' },
actions: {
read: { es: 'Leer posts', en: 'Read posts' },
edit: { es: 'Editar posts', en: 'Edit posts' },
admin: { es: 'Acceder al panel admin', en: 'Access admin panel' }
}
},
bus: {
title: { es: 'Bus + Orca', en: 'Bus + Orca' },
empty: { es: 'Sin eventos todavía', en: 'No events yet' }
},
format: {
title: { es: 'Formatos', en: 'Formats' },
number: { es: 'Número', en: 'Number' },
currency: { es: 'Moneda', en: 'Currency' },
date: { es: 'Fecha', en: 'Date' },
percent: { es: 'Porcentaje', en: 'Percent' }
},
validation: {
title: { es: 'Validación', en: 'Validation' },
email: { es: 'Email', en: 'Email' },
password: { es: 'Contraseña', en: 'Password' },
submit: { es: 'Validar', en: 'Validate' },
ok: { es: 'Válido', en: 'Valid' },
errors: { es: 'Errores', en: 'Errors' }
},
logger: {
title: { es: 'Logger', en: 'Logger' },
empty: { es: 'Sin entradas', en: 'No entries' }
}
} as const;

@ -15,6 +15,11 @@ const config = {
routes: 'src/web/routes'
},
alias: {
// Specific subpaths must come before the parent alias — Vite's
// alias resolver matches by prefix, so '$active-app' would
// shadow '$active-app/services' if listed first.
'$active-app/services': 'src/arts/active-app/service-factories',
'$active-app/presets': 'src/arts/active-app/presets',
'$active-app': 'src/arts/active-app',
$adom: 'src/arts/adom',
$auth: 'src/arts/auth',

Loading…
Cancel
Save

Powered by TurnKey Linux.