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
parent
c6de4a11f1
commit
eb6001fae4
@ -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,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,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,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);
|
||||
});
|
||||
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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,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;
|
||||
Loading…
Reference in new issue