Prefs as schema-based core + lowercase App.* surface

Two structural changes that were overdue and got bundled because
they touched the same set of files.

## Prefs is now a schema, not a fixed shape

Previously every preference had to be declared in a closed
`PrefsCapabilities` interface (`languages`, `locales`, `currencies`,
`themes`, `densities`, `motions`, `timezones`, `unitSystems`). Adding
a new pref required forking `$libs/prefs` — bad framework design.

The redesign replaces the fixed shape with a schema:

    PrefsSchema = Record<string, PrefsDimension<TIntent, TEffective>>

Each dimension owns its own validator (`validate`), environment-fed
resolver (`resolve`) and optional sibling-derived value (`derive`).
The engine is generic over the schema and iterates it; it knows
nothing about "locale" or "theme" specifically.

Built-in dimensions live in `arts/prefs/dimensions/*` (locale,
language, theme, density, motion, timezone, currency, unit-system,
direction, plus boolean / enum / string / number primitives). The
`standardPrefsDimensions(catalog)` preset composes the canonical
set; apps spread it and add their own:

    const schema = {
        ...standardPrefsDimensions({ languages, locales, currencies }),
        sidebarCollapsed: booleanDimension({ default: false }),
        notificationLevel: enumDimension(
            ['all', 'mentions', 'none'] as const,
            { default: 'mentions' }
        )
    };

Active surface exposes one slot per schema key with uniform verbs:

    App.prefs.locale.get()
    App.prefs.locale.set('es-ES')
    App.prefs.locale.clear()
    App.prefs.locale.onChange((v) => …)
    App.prefs.sidebarCollapsed.set(true)

`setIntent('locale', value)` stays available as a low-level pass-
through (storage bridge consumes it generically) but UI code uses
the dimension surface.

## Lowercase core surface

`App.Logger`, `App.Bus`, `App.Timers`, `App.Orca`, `App.Prefs` are
gone. The "PascalCase for core, lowercase for services" rule was
visual signalling against JS convention, no technical benefit, and
created an asymmetry on the same object. All core members are now
lowercase, matching services:

    App.logger
    App.bus
    App.timers
    App.orca
    App.prefs

`createPrefsStorageBridge` keeps its old responsibilities; sources
helpers (`prefsLocaleSource`, …) are gone — the dimension API
replaces them.

## What changed

- `$libs/prefs`: fully generic schema-based types + resolver. Old
  fixed `PrefsCapabilities` / `PrefsIntent` / `PrefsEffective`
  removed; replaced by `PrefsDimension`, `PrefsSchema`,
  `PrefsEffectiveOf<S>`, `PrefsIntentOf<S>`.
- `arts/prefs`: engine + active wrapper rewritten to schema. Per-
  dimension active surface auto-built from schema keys. Sources file
  deleted (replaced by dimension surface). New
  `arts/prefs/dimensions/*` and `arts/prefs/standard.ts`. Storage
  bridge made schema-generic.
- `arts/active-app`: lowercase `CoreServices` / `ActiveAppCore`,
  `prefs?: ActiveAppPrefsOptions<S>` root option carrying the
  schema. `defineActivePrefs` deleted (prefs is core, not service).
  `lang` / `format` / `frontend` factories migrated to read
  `core.prefs.<dim>` directly via defensive `readSlot()` helpers
  (each dimension is optional from the factory's POV; if the app's
  schema omits one, the integration degrades gracefully).
- Presets, demos, web routes, README docstrings, `check-aliases.mjs`
  guards, marketing snippets all migrated.
- Tests: `engine-prefs`, `active-prefs`, `storage-bridge`,
  `resolve-prefs`, `validate-intent`, `prefs-consumer-wiring`,
  `service-factories` rewritten for the schema-based API.
  `sources.test.ts` deleted (sources file is gone).

## Verification

- `npm run check`: 0 errors, 0 warnings (1527 files).
- `npm test`: 1645 tests across 139 files, all green.
- `node scripts/check-aliases.mjs`: clean (lowercase enforced for
  every member of `App.*`, including `Logger`/`Bus`/`Timers`/`Orca`/
  `Prefs` which now flag as forbidden capitals).

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

@ -19,13 +19,12 @@ import {
defineActiveFormat, defineActiveFormat,
defineActiveFrontend, defineActiveFrontend,
defineActiveLang, defineActiveLang,
defineActivePrefs,
defineActiveSession, defineActiveSession,
defineActiveStorage, defineActiveStorage,
defineEngineHttp, defineEngineHttp,
defineEngineSium defineEngineSium
} from '$active-app/services'; } from '$active-app/services';
import type { PrefsCapabilities } from '$libs/prefs'; import { standardPrefsDimensions } from '$prefs';
import type { DatingUser } from './types.ts'; import type { DatingUser } from './types.ts';
import { createDatingApiClient, type DatingApiClient } from './api.ts'; import { createDatingApiClient, type DatingApiClient } from './api.ts';
@ -57,25 +56,24 @@ export const NEXO_LANG_SCHEMA = {
} }
} as const; } as const;
export const NEXO_PREFS_CAPABILITIES: PrefsCapabilities = { /**
languages: ['es', 'en'], * Nexo prefs schema. Composes the canonical built-in dimensions
locales: ['es-ES', 'en-US'], * (`language`, `locale`, `currency`, `theme`, …) around the demo's
currencies: ['EUR', 'USD'], * catalogs. App-specific prefs would join the spread; today the demo
unitSystems: ['metric', 'imperial'], * doesn't have any beyond the standard set.
themes: ['light', 'dark', 'system'], */
densities: ['compact', 'comfortable', 'spacious'], export const NEXO_PREFS_SCHEMA = {
motions: ['allow', 'reduce', 'system'], ...standardPrefsDimensions({
defaults: { languages: ['es', 'en'],
language: 'es', locales: ['es-ES', 'en-US'],
locale: 'es-ES', currencies: ['EUR', 'USD'],
currency: 'EUR', defaults: {
timezone: 'Europe/Madrid', language: 'es',
unitSystem: 'metric', locale: 'es-ES',
theme: 'light', currency: 'EUR',
density: 'comfortable', timezone: 'Europe/Madrid'
motion: 'allow', }
direction: 'ltr' })
}
}; };
export interface CreateDatingAppOptions { export interface CreateDatingAppOptions {
@ -106,13 +104,17 @@ export type DatingApp = ReturnType<typeof composeApp>;
function composeApp(options: CreateDatingAppOptions) { function composeApp(options: CreateDatingAppOptions) {
return createActiveApp({ return createActiveApp({
// Prefs is core. Each schema key becomes a typed dimension on
// `App.prefs.<key>` (`App.prefs.locale.set('en-US')` etc.).
// Persistence is a follow-up: when we wire `arts/storage` →
// `PrefsIntentStorage`, attach `storage` here.
prefs: { schema: NEXO_PREFS_SCHEMA },
services: { services: {
lang: defineActiveLang({ lang: defineActiveLang({
schema: NEXO_LANG_SCHEMA, schema: NEXO_LANG_SCHEMA,
defaultLocale: 'es', defaultLocale: 'es',
fallbackChain: ['en'] fallbackChain: ['en']
}), }),
prefs: defineActivePrefs({ capabilities: NEXO_PREFS_CAPABILITIES }),
storage: defineActiveStorage({ namespace: 'nexo' }), storage: defineActiveStorage({ namespace: 'nexo' }),
frontend: defineActiveFrontend({ frontend: defineActiveFrontend({
target: options.frontendTarget, target: options.frontendTarget,

@ -6,8 +6,9 @@
* the move from capitalized service slots (`App.Cache`) to lowercase * the move from capitalized service slots (`App.Cache`) to lowercase
* declarable services (`App.cache`). * declarable services (`App.cache`).
* *
* Allowed capitalised names on `App.*` are the ecosystem core: * Every member of `App.*` is lowercase, including the core
* `Logger`, `Bus`, `Timers`, `Orca`. Everything else is lowercase. * (`logger` / `bus` / `timers` / `orca` / `prefs`). PascalCase on the
* App proxy is forbidden.
* *
* Run: `node scripts/check-aliases.mjs` * Run: `node scripts/check-aliases.mjs`
* Exit code: 0 when clean, 1 when any forbidden token is found. * Exit code: 0 when clean, 1 when any forbidden token is found.
@ -38,6 +39,11 @@ const STALE_ALIAS_PATTERNS = [
]; ];
const APP_FORBIDDEN_CAPITALS = [ const APP_FORBIDDEN_CAPITALS = [
'Logger',
'Bus',
'Timers',
'Orca',
'Prefs',
'Cache', 'Cache',
'Sess', 'Sess',
'Session', 'Session',
@ -52,22 +58,34 @@ const APP_FORBIDDEN_CAPITALS = [
'Permissions', 'Permissions',
'Http', 'Http',
'Dom', 'Dom',
'Connections', 'Connections'
'Prefs'
]; ];
const APP_PATTERN = new RegExp(`\\bApp\\.(${APP_FORBIDDEN_CAPITALS.join('|')})\\b`, 'g'); const APP_PATTERN = new RegExp(`\\bApp\\.(${APP_FORBIDDEN_CAPITALS.join('|')})\\b`, 'g');
/** /**
* Pre-`createActiveApp({ services })` API surfaces. The current runtime * Pre-`createActiveApp({ services })` API surfaces. The current runtime
* routes locale through `App.lang` / `App.prefs` and validation * routes locale through `App.lang` / `App.prefs` and validation through
* through `App.sium`; these old method handles no longer exist on the * `App.sium`; these old method handles no longer exist on the App
* App proxy and call sites must be migrated. The audit explicitly * proxy and call sites must be migrated.
* flagged that the previous version of this guard missed them. *
* The dimension API on `App.prefs.<dim>.set(value)` supersedes
* `App.prefs.setIntent('<dim>', value)` for ergonomic call sites; the
* lower-level `setIntent` stays available for adapters but UI code
* should use the dimension surface.
*/ */
const OLD_APP_METHOD_PATTERNS = [ const OLD_APP_METHOD_PATTERNS = [
{ rx: /\bApp\.setLocale\b/g, hint: 'App.setLocale → App.lang.setLocale (or App.prefs.setIntent("language", ...))' }, {
{ rx: /\bApp\.getLocale\b/g, hint: 'App.getLocale → App.lang.getLocale (or App.prefs.effective().language)' }, rx: /\bApp\.setLocale\b/g,
{ rx: /\bApp\.createSiumEngine\b/g, hint: 'App.createSiumEngine → declare `sium: defineEngineSium()` in services and read App.sium' } hint: 'App.setLocale → App.lang.setLocale (or App.prefs.locale.set(...))'
},
{
rx: /\bApp\.getLocale\b/g,
hint: 'App.getLocale → App.lang.getLocale (or App.prefs.locale.get())'
},
{
rx: /\bApp\.createSiumEngine\b/g,
hint: 'App.createSiumEngine → declare `sium: defineEngineSium()` in services and read App.sium'
}
]; ];
const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', '.git', 'build', 'dist']); const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', '.git', 'build', 'dist']);

@ -161,7 +161,7 @@ error — the wrapping is cheap and uniform.
## Orchestration ## Orchestration
`App.Orca` is always present and inert. Reactions are not pre-wired — apps `App.orca` is always present and inert. Reactions are not pre-wired — apps
register them explicitly through orca presets in `arts/active-app/presets/`. register them explicitly through orca presets in `arts/active-app/presets/`.
```ts ```ts
@ -194,7 +194,7 @@ never on a sibling art.
The Svelte-context helper `setBus` / `getBus` lives in `$bus`, not here. The Svelte-context helper `setBus` / `getBus` lives in `$bus`, not here.
The bus is the semantic owner of the propagation pattern; App is just a The bus is the semantic owner of the propagation pattern; App is just a
consumer that calls `setBus(App.Bus)` once near the layout root. consumer that calls `setBus(App.bus)` once near the layout root.
```svelte ```svelte
<!-- app/+layout.svelte --> <!-- app/+layout.svelte -->
@ -202,7 +202,7 @@ consumer that calls `setBus(App.Bus)` once near the layout root.
import { setBus } from '$bus'; import { setBus } from '$bus';
import { App } from './app'; import { App } from './app';
setBus(App.Bus); setBus(App.bus);
</script> </script>
``` ```

@ -1,16 +1,30 @@
/** /**
* `createActiveApp()` — composed runtime root. * `createActiveApp()` — composed runtime root.
* *
* Builds the four pieces of the core (Logger, Bus, Timers, Orca) and * Builds the five pieces of the core (`logger`, `bus`, `timers`,
* then defers everything else to the declarative service schema. The * `orca`, `prefs`) and then defers everything else to the declarative
* function itself is short on purpose — every art-specific knob has * service schema. The function itself is short on purpose — every
* moved to its `defineActive*` / `defineEngine*` factory. * art-specific knob has moved to its `defineActive*` /
* `defineEngine*` factory.
*
* Lowercase core surface — `App.logger` / `App.bus` / `App.timers` /
* `App.orca` / `App.prefs`. The previous PascalCase rule for core
* members existed for visual signalling and went against JS property
* convention. Removed.
*/ */
import type { EngineBus } from '$bus'; import type { EngineBus } from '$bus';
import { createSvelteEngineBus } from '$bus'; import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger'; import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca'; import { createEngineOrca } from '$orca';
import {
createActivePrefs,
createPrefsStorageBridge,
NEUTRAL_PREFS_SCHEMA,
type ActivePrefs,
type PrefsStorageBridge
} from '$prefs';
import type { PrefsSchema } from '$libs/prefs';
import { createActiveTimers } from '$timer/active-timers.svelte'; import { createActiveTimers } from '$timer/active-timers.svelte';
import { APP_MODULE } from './consts.ts'; import { APP_MODULE } from './consts.ts';
@ -21,68 +35,74 @@ import type {
ActiveApp, ActiveApp,
ActiveAppBusEvents, ActiveAppBusEvents,
ActiveAppCore, ActiveAppCore,
ActiveAppOptions ActiveAppOptions,
ActiveAppPrefsOptions
} from './types.ts'; } from './types.ts';
export function createActiveApp<TSchema extends AppServiceSchema = AppServiceSchema>( export function createActiveApp<
options: ActiveAppOptions<TSchema> = {} TSchema extends AppServiceSchema = AppServiceSchema,
): ActiveApp<TSchema> { TPrefsSchema extends PrefsSchema = PrefsSchema
>(options: ActiveAppOptions<TSchema, TPrefsSchema> = {}): ActiveApp<TSchema, TPrefsSchema> {
// ── Core ──────────────────────────────────────────────────────────── // ── Core ────────────────────────────────────────────────────────────
const Logger = createEngineLogger(options.logger); const logger = createEngineLogger(options.logger);
const Timers = createActiveTimers({ const timers = createActiveTimers({
...options.timers, ...options.timers,
logger: Logger logger
}); });
const Bus = createSvelteEngineBus<ActiveAppBusEvents>({ const bus = createSvelteEngineBus<ActiveAppBusEvents>({
...options.bus, ...options.bus,
logger: Logger, logger,
clock: Timers.clock clock: timers.clock
}); });
const Orca = createEngineOrca({ const orca = createEngineOrca({
...options.orca, ...options.orca,
bus: Bus, bus,
timers: Timers, timers,
logger: Logger logger
}); });
const { engine: prefs, bridge: prefsBridge } = buildPrefs(options.prefs);
// ── Services ──────────────────────────────────────────────────────── // ── Services ────────────────────────────────────────────────────────
const core = coreForBuilder(Logger, Bus, Timers, Orca); const core = coreForBuilder(logger, bus, timers, orca, prefs);
const serviceBuilders = options.services const serviceBuilders = options.services
? buildServiceBuilders(options.services as AppServiceSchema, core) ? buildServiceBuilders(options.services as AppServiceSchema, core)
: undefined; : undefined;
let disposed = false; let disposed = false;
const baseApp: ActiveAppCore = { const baseApp: ActiveAppCore<TPrefsSchema> = {
Logger, logger,
Bus, bus,
Timers, timers,
Orca, orca,
prefs: prefs as ActivePrefs<TPrefsSchema>,
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
// Announce dispose BEFORE tearing anything down so subscribers // Announce dispose BEFORE tearing anything down so subscribers
// can still reach the bus and any service they depend on. // can still reach the bus and any service they depend on.
publishAppDisposeStarting(Bus, { cause: APP_MODULE }); publishAppDisposeStarting(bus, { cause: APP_MODULE });
// Schema-declared services first (reverse construction order // Schema-declared services first (reverse construction order
// is handled by the builder). // is handled by the builder).
serviceBuilders?.disposeAll(); serviceBuilders?.disposeAll();
// Core last, in reverse build order. // Prefs bridge tears down before the engine so a late storage
Orca.dispose(); // op can't race a disposed engine.
Bus.dispose(); prefsBridge?.dispose();
Timers.dispose(); prefs.dispose();
Logger.dispose(); // Remaining core last, in reverse build order.
orca.dispose();
bus.dispose();
timers.dispose();
logger.dispose();
} }
}; };
// Compose the final App: core + schema services + status const app = baseApp as ActiveApp<TSchema, TPrefsSchema>;
// introspection. Services are exposed as own properties via
// `Object.defineProperty` so lazy getters are preserved.
const app = baseApp as ActiveApp<TSchema>;
if (serviceBuilders !== undefined) { if (serviceBuilders !== undefined) {
for (const name of Object.keys(serviceBuilders.proxies)) { for (const name of Object.keys(serviceBuilders.proxies)) {
@ -112,6 +132,35 @@ export function createActiveApp<TSchema extends AppServiceSchema = AppServiceSch
return app; return app;
} }
/**
* Build the prefs engine and its optional storage bridge. When the
* caller omits `options.prefs` entirely, fall back to
* `NEUTRAL_PREFS_SCHEMA` so the core slot is always populated.
*/
function buildPrefs<S extends PrefsSchema>(
options: ActiveAppPrefsOptions<S> | undefined
): {
engine: ActivePrefs<S>;
bridge: PrefsStorageBridge | undefined;
} {
const schema =
(options?.schema as S | undefined) ?? (NEUTRAL_PREFS_SCHEMA as unknown as S);
const engine = createActivePrefs({
schema,
environment: options?.environment,
intent: options?.intent
});
let bridge: PrefsStorageBridge | undefined;
if (options?.storage !== undefined) {
bridge = createPrefsStorageBridge({
engine,
storage: options.storage,
onError: options.onStorageError
});
}
return { engine, bridge };
}
/** /**
* Adapt the App-level core to the generic `CoreServices` contract that * Adapt the App-level core to the generic `CoreServices` contract that
* service factories see. * service factories see.
@ -122,21 +171,19 @@ export function createActiveApp<TSchema extends AppServiceSchema = AppServiceSch
* - Services that need to publish App-owned events do so through the * - Services that need to publish App-owned events do so through the
* dedicated typed publishers (`publishAppDisposeStarting`, …), not * dedicated typed publishers (`publishAppDisposeStarting`, …), not
* via raw `bus.publish(type, payload)` against an arbitrary string. * via raw `bus.publish(type, payload)` against an arbitrary string.
*
* Keeping this in a named helper means the rationale stays attached to
* the cast instead of trailing as an inline comment that future edits
* might lose.
*/ */
function coreForBuilder( function coreForBuilder(
logger: ActiveAppCore['Logger'], logger: ActiveAppCore['logger'],
bus: ActiveAppCore['Bus'], bus: ActiveAppCore['bus'],
timers: ActiveAppCore['Timers'], timers: ActiveAppCore['timers'],
orca: ActiveAppCore['Orca'] orca: ActiveAppCore['orca'],
prefs: ActiveAppCore['prefs']
): CoreServices { ): CoreServices {
return { return {
logger, logger,
bus: bus as unknown as EngineBus, bus: bus as unknown as EngineBus,
timers, timers,
orca orca,
prefs
}; };
} }

@ -6,7 +6,7 @@
* republication of module-level events (session, connection, etc.) — * republication of module-level events (session, connection, etc.) —
* those republications have been removed. Apps that need to react to * those republications have been removed. Apps that need to react to
* facts owned by other modules subscribe to those modules' events * facts owned by other modules subscribe to those modules' events
* directly via `App.Bus.on(SESSION_EVENT_*, …)` or, more commonly, * directly via `App.bus.on(SESSION_EVENT_*, …)` or, more commonly,
* register an orca action via a preset. * register an orca action via a preset.
*/ */

@ -9,7 +9,7 @@
* factories for the declarative service schema. Loaded only by * factories for the declarative service schema. Loaded only by
* apps that declare services. * apps that declare services.
* - `$active-app/presets` — orchestration presets registered on * - `$active-app/presets` — orchestration presets registered on
* `App.Orca`. Loaded only by apps that opt into the standard * `App.orca`. Loaded only by apps that opt into the standard
* reactions. * reactions.
* *
* Splitting the entry points lets the bundler tree-shake each layer * Splitting the entry points lets the bundler tree-shake each layer
@ -18,7 +18,7 @@
* *
* The Svelte-context bridge (`setBus` / `getBus`) lives in `$bus`, not * The Svelte-context bridge (`setBus` / `getBus`) lives in `$bus`, not
* here. It is owned by the bus, which is the semantic origin of the * here. It is owned by the bus, which is the semantic origin of the
* pattern; App is a consumer that just calls `setBus(App.Bus)` once at * pattern; App is a consumer that just calls `setBus(App.bus)` once at
* the layout root. * the layout root.
*/ */
@ -54,6 +54,7 @@ export type {
ActiveAppBusEvents, ActiveAppBusEvents,
ActiveAppCore, ActiveAppCore,
ActiveAppOptions, ActiveAppOptions,
ActiveAppPrefsOptions,
ActiveAppServicesIntrospection ActiveAppServicesIntrospection
} from './types.ts'; } from './types.ts';

@ -6,7 +6,7 @@ import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'cache.clear-on-identity-change'; const ACTION_ID = 'cache.clear-on-identity-change';
const TOKEN_CLEARED = 'cache:cleared-on-identity'; const TOKEN_CLEARED = 'cache:cleared-on-identity';
export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'Orca'> { export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache: Pick<ActiveCache, 'clear'>; readonly cache: Pick<ActiveCache, 'clear'>;
} }
@ -24,7 +24,7 @@ export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'Orca
export function applyCacheClearOnIdentityChange( export function applyCacheClearOnIdentityChange(
App: CacheClearOnIdentityChangeApp App: CacheClearOnIdentityChangeApp
): () => void { ): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID, id: ACTION_ID,
stage: ORCA_STAGE_MAIN, stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLEARED], provides: [TOKEN_CLEARED],

@ -13,7 +13,7 @@ const TOKEN_CLEARED = 'cache:cleared-on-revoke';
* declares a compatible cache, regardless of what other services it * declares a compatible cache, regardless of what other services it
* has. * has.
*/ */
export interface CacheClearOnRevokeApp extends Pick<ActiveAppCore, 'Orca'> { export interface CacheClearOnRevokeApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache: Pick<ActiveCache, 'clear'>; readonly cache: Pick<ActiveCache, 'clear'>;
} }
@ -26,7 +26,7 @@ export interface CacheClearOnRevokeApp extends Pick<ActiveAppCore, 'Orca'> {
* Returns a detach function. Calling it unregisters the action. * Returns a detach function. Calling it unregisters the action.
*/ */
export function applyCacheClearOnRevoke(App: CacheClearOnRevokeApp): () => void { export function applyCacheClearOnRevoke(App: CacheClearOnRevokeApp): () => void {
return App.Orca.onEvent(SESSION_EVENT_REVOKED, { return App.orca.onEvent(SESSION_EVENT_REVOKED, {
id: ACTION_ID, id: ACTION_ID,
stage: ORCA_STAGE_MAIN, stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLEARED], provides: [TOKEN_CLEARED],

@ -10,7 +10,7 @@ const CLOSE_REASON = 'session-revoked';
/** /**
* Shape this preset requires from `App`. Only `closeAll()` is needed. * Shape this preset requires from `App`. Only `closeAll()` is needed.
*/ */
export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'Orca'> { export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'orca'> {
readonly connections: Pick<ActiveConnections, 'closeAll'>; readonly connections: Pick<ActiveConnections, 'closeAll'>;
} }
@ -25,7 +25,7 @@ export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'Orca'>
export function applyConnectionsCloseOnRevoke( export function applyConnectionsCloseOnRevoke(
App: ConnectionsCloseOnRevokeApp App: ConnectionsCloseOnRevokeApp
): () => void { ): () => void {
return App.Orca.onEvent(SESSION_EVENT_REVOKED, { return App.orca.onEvent(SESSION_EVENT_REVOKED, {
id: ACTION_ID, id: ACTION_ID,
stage: ORCA_STAGE_MAIN, stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLOSED], provides: [TOKEN_CLOSED],

@ -12,7 +12,7 @@ const TOKEN_REAUTHENTICATED = 'connections:reauthenticated-on-identity';
* inject any compatible adapter — no need to expose the full * inject any compatible adapter — no need to expose the full
* `ActiveConnections` surface. * `ActiveConnections` surface.
*/ */
export interface ConnectionsReauthOnIdentityChangeApp extends Pick<ActiveAppCore, 'Orca'> { export interface ConnectionsReauthOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly connections: Pick<ActiveConnections, 'reauthenticateAll'>; readonly connections: Pick<ActiveConnections, 'reauthenticateAll'>;
} }
@ -30,7 +30,7 @@ export interface ConnectionsReauthOnIdentityChangeApp extends Pick<ActiveAppCore
export function applyConnectionsReauthOnIdentityChange( export function applyConnectionsReauthOnIdentityChange(
App: ConnectionsReauthOnIdentityChangeApp App: ConnectionsReauthOnIdentityChangeApp
): () => void { ): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID, id: ACTION_ID,
stage: ORCA_STAGE_MAIN, stage: ORCA_STAGE_MAIN,
provides: [TOKEN_REAUTHENTICATED], provides: [TOKEN_REAUTHENTICATED],

@ -1,6 +1,6 @@
/** /**
* Orchestration presets for `arts/active-app`. Each `apply*` function * Orchestration presets for `arts/active-app`. Each `apply*` function
* registers one or more orca actions on `App.Orca` that react to * registers one or more orca actions on `App.orca` that react to
* canonical lifecycle events (`SESSION_EVENT_*`, future * canonical lifecycle events (`SESSION_EVENT_*`, future
* `CONNECTION_EVENT_*`, etc.) and call the imperative API of the * `CONNECTION_EVENT_*`, etc.) and call the imperative API of the
* affected service. * affected service.

@ -6,7 +6,7 @@ import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'perm.invalidate-on-identity-change'; const ACTION_ID = 'perm.invalidate-on-identity-change';
const TOKEN_INVALIDATED = 'perm:invalidated-on-identity'; const TOKEN_INVALIDATED = 'perm:invalidated-on-identity';
export interface PermInvalidateOnIdentityChangeApp extends Pick<ActiveAppCore, 'Orca'> { export interface PermInvalidateOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly perm: Pick<ActivePerms, 'invalidate'>; readonly perm: Pick<ActivePerms, 'invalidate'>;
} }
@ -20,7 +20,7 @@ export interface PermInvalidateOnIdentityChangeApp extends Pick<ActiveAppCore, '
export function applyPermInvalidateOnIdentityChange( export function applyPermInvalidateOnIdentityChange(
App: PermInvalidateOnIdentityChangeApp App: PermInvalidateOnIdentityChangeApp
): () => void { ): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID, id: ACTION_ID,
stage: ORCA_STAGE_MAIN, stage: ORCA_STAGE_MAIN,
provides: [TOKEN_INVALIDATED], provides: [TOKEN_INVALIDATED],

@ -9,12 +9,12 @@ import type { ActiveAppCore } from '../types.ts';
/** /**
* Shape this preset requires from `App`. Only the `session` slot is * Shape this preset requires from `App`. Only the `session` slot is
* needed; the core's `Timers` is consumed automatically so that the * needed; the core's `Timers` is consumed automatically so that the
* refresh ticker runs through `App.Timers` (one clock for the whole * refresh ticker runs through `App.timers` (one clock for the whole
* ecosystem) instead of the host `setInterval` fallback baked into * ecosystem) instead of the host `setInterval` fallback baked into
* `withAutoRefresh`. * `withAutoRefresh`.
*/ */
export interface SessionAutoRefreshApp<TUser, TCredential = undefined, TData = undefined> export interface SessionAutoRefreshApp<TUser, TCredential = undefined, TData = undefined>
extends Pick<ActiveAppCore, 'Timers'> { extends Pick<ActiveAppCore, 'timers'> {
readonly session: ActiveSession<TUser, TCredential, TData>; readonly session: ActiveSession<TUser, TCredential, TData>;
} }
@ -34,7 +34,7 @@ export function applySessionAutoRefresh<TUser, TCredential = undefined, TData =
): AutoRefreshCleanup { ): AutoRefreshCleanup {
return withAutoRefresh(App.session, { return withAutoRefresh(App.session, {
...options, ...options,
timers: options.timers ?? App.Timers, timers: options.timers ?? App.timers,
now: options.now ?? (() => App.Timers.clock.now()) now: options.now ?? (() => App.timers.clock.now())
}); });
} }

@ -15,7 +15,7 @@ import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identi
* pieces — so an App that only declares `cache` (without `perm`) gets * pieces — so an App that only declares `cache` (without `perm`) gets
* cache-related presets and nothing else. * cache-related presets and nothing else.
*/ */
export interface StandardOrcaApp extends Pick<ActiveAppCore, 'Orca'> { export interface StandardOrcaApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache?: Pick<ActiveCache, 'clear'>; readonly cache?: Pick<ActiveCache, 'clear'>;
readonly perm?: Pick<ActivePerms, 'invalidate'>; readonly perm?: Pick<ActivePerms, 'invalidate'>;
readonly connections?: Pick<ActiveConnections, 'reauthenticateAll' | 'closeAll'>; readonly connections?: Pick<ActiveConnections, 'reauthenticateAll' | 'closeAll'>;

@ -10,7 +10,7 @@ import type { AppServiceFactory } from '../services.ts';
* outside via orca presets (e.g. `applyCacheClearOnIdentityChange` in * outside via orca presets (e.g. `applyCacheClearOnIdentityChange` in
* `arts/active-app/presets/`). The factory wires `logger` and * `arts/active-app/presets/`). The factory wires `logger` and
* `clock` from the core — TTL evaluation and any other now-based * `clock` from the core — TTL evaluation and any other now-based
* math then flow through `App.Timers.clock`, the same time source * math then flow through `App.timers.clock`, the same time source
* the rest of the ecosystem uses. * the rest of the ecosystem uses.
*/ */
export function defineActiveCache( export function defineActiveCache(

@ -1,9 +1,6 @@
import { createActiveFormat } from '$format/active-formats.svelte'; import { createActiveFormat } from '$format/active-formats.svelte';
import type { ActiveFormat, ActiveFormatOptions } 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 { LocaleSource } from '$locale';
import { prefsLocaleSource } from '$prefs';
import type { ActivePrefs } from '$prefs';
import type { AppServiceFactory } from '../services.ts'; import type { AppServiceFactory } from '../services.ts';
/** /**
@ -13,43 +10,40 @@ import type { AppServiceFactory } from '../services.ts';
* Format runs entirely from a `LocaleSource`. Resolution priority: * Format runs entirely from a `LocaleSource`. Resolution priority:
* *
* 1. `options.localeSource` (explicit override — escape hatch). * 1. `options.localeSource` (explicit override — escape hatch).
* 2. `App.prefs.state.effective.locale` when `prefs` is in the schema. * 2. `App.prefs.locale` — the user's regional formatting locale,
* This is the user's regional-formatting locale, distinct from * always present when the prefs schema declares a `locale`
* `App.lang`'s translation locale. * dimension (true for `standardPrefsDimensions(...)` callers).
* 3. `App.lang.getLocale()` when `lang` is in the schema and `prefs` *
* is not. Preserves the historical wiring for apps that don't yet * Apps that ship a custom prefs schema without a `locale` dimension
* adopt `prefs`. * must pass `options.localeSource` explicitly.
* 4. Format's own default — when nothing else is available.
*/ */
export function defineActiveFormat( export function defineActiveFormat(
options: ActiveFormatOptions = {} options: ActiveFormatOptions = {}
): AppServiceFactory<'format', readonly ['timers'], readonly ['prefs', 'lang'], ActiveFormat> { ): AppServiceFactory<'format', readonly ['timers', 'prefs'], readonly [], ActiveFormat> {
return { return {
name: 'format', name: 'format',
coreDependencies: ['timers'], coreDependencies: ['timers', 'prefs'],
serviceDependencies: ['prefs', 'lang'],
initMode: 'lazy', initMode: 'lazy',
create({ core, services }): ActiveFormat { create({ core }): ActiveFormat {
const prefsInstance = services.prefs as ActivePrefs | undefined;
const langInstance = services.lang as ActiveLang | undefined;
let localeSource: LocaleSource | undefined = options.localeSource; let localeSource: LocaleSource | undefined = options.localeSource;
if (localeSource === undefined && prefsInstance !== undefined) { if (localeSource === undefined) {
localeSource = prefsLocaleSource(prefsInstance); const localeDim = (core.prefs as unknown as Record<string, unknown>)['locale'] as
} | {
if (localeSource === undefined && langInstance !== undefined) { get(): string;
localeSource = { onChange(handler: (value: string) => void): () => void;
get: () => langInstance.getLocale(), }
onChange: (fn) => langInstance.onLocaleChange(fn) | undefined;
}; if (localeDim !== undefined) {
localeSource = {
get: () => localeDim.get(),
onChange: (fn) => localeDim.onChange(fn)
};
}
} }
return createActiveFormat({ return createActiveFormat({
...options, ...options,
localeSource, localeSource,
// Caller-provided clock takes precedence; otherwise route the
// App's authoritative clock so rate-expiration math is
// deterministic in tests and consistent across modules.
clock: options.clock ?? core.timers.clock clock: options.clock ?? core.timers.clock
}); });
}, },

@ -1,64 +1,64 @@
import { createActiveFrontend } from '$frontend/active-frontend.svelte'; import { createActiveFrontend } from '$frontend/active-frontend.svelte';
import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte'; import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte';
import type { ActiveDom } from '$adom'; import type { ActiveDom } from '$adom';
import type { ActiveLang } from '$lang';
import type { LocaleSource } from '$locale'; import type { LocaleSource } from '$locale';
import {
prefsDensitySource,
prefsDirectionSource,
prefsLanguageSource,
prefsMotionSource,
prefsThemeSource
} from '$prefs';
import type { ActivePrefs } from '$prefs';
import type { AppServiceFactory } from '../services.ts'; import type { AppServiceFactory } from '../services.ts';
/**
* Active dimension shape accessed off `core.prefs.<key>`. Service
* factories stay defensive about which dimensions a given app declares
* — when a dimension is missing, the factory degrades gracefully.
*/
interface PrefsSlot<T> {
get(): T;
onChange(handler: (value: T) => void): () => void;
}
function readSlot<T>(prefs: unknown, key: string): PrefsSlot<T> | undefined {
const slot = (prefs as Record<string, unknown>)[key];
if (
slot !== null &&
typeof slot === 'object' &&
typeof (slot as { get?: unknown }).get === 'function' &&
typeof (slot as { onChange?: unknown }).onChange === 'function'
) {
return slot as PrefsSlot<T>;
}
return undefined;
}
/** /**
* `defineActiveFrontend(options)` produces a service factory for the * `defineActiveFrontend(options)` produces a service factory for the
* `frontend` slot. * `frontend` slot.
* *
* Frontend integrates with `dom`, `prefs` and `lang` automatically when * Frontend integrates with `core.prefs` for theme / density / motion /
* those services are declared in the schema. Resolution priority for * direction (when those dimensions are declared in the prefs schema)
* the locale source (which Frontend uses for `direction = auto` * and with `dom` when declared as a service. The locale source for
* derivation): * `direction = auto` derivation comes from `core.prefs.language` —
* Frontend follows the writing system, which is a property of the
* *language*, not the regional formatting locale.
* *
* 1. `options.localeSource` — explicit override. * Each integration is conditional on the dimension being present, so
* 2. `App.prefs.state.effective.language` — Frontend's `dir = auto` * apps with custom prefs schemas don't break by omitting one.
* follows the writing system, which is a property of the
* *language*, not the regional formatting locale. So we pipe
* `prefs.language` (NOT `prefs.locale`) here.
* 3. `App.lang.getLocale()` — historical fallback when prefs isn't
* declared.
*
* Note: `frontend` previously consumed `App.storage` to persist user
* preferences (theme/mode/density). That persistence layer used to
* live in `arts/active-app/integrations/frontend-storage.ts`. After the
* refactor, persistence is the application's concern: it can be
* implemented as an orca preset, as an integration helper, or built
* into a custom Frontend wrapper. The factory itself stays slim.
*/ */
export function defineActiveFrontend( export function defineActiveFrontend(
options: ActiveFrontendOptions = {} options: ActiveFrontendOptions = {}
): AppServiceFactory<'frontend', readonly [], readonly ['dom', 'prefs', 'lang'], ActiveFrontend> { ): AppServiceFactory<'frontend', readonly ['prefs'], readonly ['dom'], ActiveFrontend> {
const detachers: Array<() => void> = []; const detachers: Array<() => void> = [];
return { return {
name: 'frontend', name: 'frontend',
coreDependencies: [], coreDependencies: ['prefs'],
serviceDependencies: ['dom', 'prefs', 'lang'], serviceDependencies: ['dom'],
initMode: 'lazy', initMode: 'lazy',
create({ services }): ActiveFrontend { create({ core, services }): ActiveFrontend {
const dom = options.dom ?? (services.dom as ActiveDom | undefined); const dom = options.dom ?? (services.dom as ActiveDom | undefined);
const prefsInstance = services.prefs as ActivePrefs | undefined;
const langInstance = services.lang as ActiveLang | undefined;
const languageSlot = readSlot<string>(core.prefs, 'language');
let localeSource: LocaleSource | undefined = options.localeSource; let localeSource: LocaleSource | undefined = options.localeSource;
if (localeSource === undefined && prefsInstance !== undefined) { if (localeSource === undefined && languageSlot !== undefined) {
localeSource = prefsLanguageSource(prefsInstance);
}
if (localeSource === undefined && langInstance !== undefined) {
localeSource = { localeSource = {
get: () => langInstance.getLocale(), get: () => languageSlot.get(),
onChange: (fn) => langInstance.onLocaleChange(fn) onChange: (fn) => languageSlot.onChange(fn)
}; };
} }
@ -68,39 +68,38 @@ export function defineActiveFrontend(
localeSource localeSource
}); });
// When prefs is in the schema, route theme / density / motion / // Theme / density / motion / direction integrations are
// direction through it. Each subscription is per-dimension (the // per-dimension — each only fires when its own value
// capability sources only fire when their own field changes), so // changes. `prefs.theme` (light|dark|system) maps to
// theme writes don't wake up the density listener and vice versa. // Frontend.MODE — Frontend's "theme" is a deeper UI variant
// // name, "mode" is the light/dark scheme, and prefs's
// `prefs.theme` (light|dark) maps to Frontend.MODE — Frontend's // effective theme is exactly the latter.
// "theme" is a deeper UI variant name, "mode" is the light/dark const themeSlot = readSlot<'light' | 'dark'>(core.prefs, 'theme');
// scheme, and prefs's effective theme is exactly the latter. if (themeSlot !== undefined) {
// frontend.setMode(themeSlot.get());
// Initial values are applied before subscribing so the first detachers.push(themeSlot.onChange((value) => frontend.setMode(value)));
// paint reflects prefs without an extra commit. }
if (prefsInstance !== undefined) {
const themeSrc = prefsThemeSource(prefsInstance);
const densitySrc = prefsDensitySource(prefsInstance);
const motionSrc = prefsMotionSource(prefsInstance);
const directionSrc = prefsDirectionSource(prefsInstance);
frontend.setMode(themeSrc.get()); const densitySlot = readSlot<string>(core.prefs, 'density');
frontend.setDensity(densitySrc.get()); if (densitySlot !== undefined) {
frontend.setReducedMotion(motionSrc.get() === 'reduce'); frontend.setDensity(densitySlot.get() as never);
frontend.setDir(directionSrc.get()); detachers.push(
densitySlot.onChange((value) => frontend.setDensity(value as never))
);
}
const offTheme = themeSrc.onChange?.((value) => frontend.setMode(value)); const motionSlot = readSlot<'allow' | 'reduce'>(core.prefs, 'motion');
const offDensity = densitySrc.onChange?.((value) => frontend.setDensity(value)); if (motionSlot !== undefined) {
const offMotion = motionSrc.onChange?.((value) => frontend.setReducedMotion(motionSlot.get() === 'reduce');
frontend.setReducedMotion(value === 'reduce') detachers.push(
motionSlot.onChange((value) => frontend.setReducedMotion(value === 'reduce'))
); );
const offDirection = directionSrc.onChange?.((value) => frontend.setDir(value)); }
if (offTheme !== undefined) detachers.push(offTheme); const directionSlot = readSlot<'ltr' | 'rtl'>(core.prefs, 'direction');
if (offDensity !== undefined) detachers.push(offDensity); if (directionSlot !== undefined) {
if (offMotion !== undefined) detachers.push(offMotion); frontend.setDir(directionSlot.get());
if (offDirection !== undefined) detachers.push(offDirection); detachers.push(directionSlot.onChange((value) => frontend.setDir(value)));
} }
return frontend; return frontend;

@ -23,12 +23,10 @@ export { defineActiveFormat } from './format.ts';
export { defineActiveFrontend } from './frontend.ts'; export { defineActiveFrontend } from './frontend.ts';
export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts'; export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts';
export { defineActivePerm } from './perm.ts'; export { defineActivePerm } from './perm.ts';
export { // `prefs` is part of the core (see `arts/active-app/services.ts` →
defineActivePrefs, // `CoreServices.prefs`). It does not have a service-factory because
defineActivePrefsWithStorage, // every App ALWAYS has it; configure it via `createActiveApp({ prefs:
type DefineActivePrefsOptions, // { ... } })`.
type DefineActivePrefsWithStorageOptions
} from './prefs.ts';
export { defineActiveSession } from './session.ts'; export { defineActiveSession } from './session.ts';
export { defineActiveStorage } from './storage.ts'; export { defineActiveStorage } from './storage.ts';
export { defineEngineHttp } from './http.ts'; export { defineEngineHttp } from './http.ts';

@ -1,8 +1,6 @@
import { createActiveLang } from '$lang/active-lang.svelte'; import { createActiveLang } from '$lang/active-lang.svelte';
import type { ActiveLang } from '$lang'; import type { ActiveLang } from '$lang';
import type { LangNode, SupportedLocale } from '$libs/lang'; import type { LangNode, SupportedLocale } from '$libs/lang';
import { prefsLanguageSource } from '$prefs';
import type { ActivePrefs } from '$prefs';
import type { AppServiceFactory } from '../services.ts'; import type { AppServiceFactory } from '../services.ts';
/** /**
@ -21,26 +19,23 @@ export interface DefineActiveLangOptions<S extends LangNode> {
* a service factory for the `lang` slot. The schema generic flows * a service factory for the `lang` slot. The schema generic flows
* through to `App.lang` so `t('a.b.c')` keeps end-to-end type safety. * through to `App.lang` so `t('a.b.c')` keeps end-to-end type safety.
* *
* When the schema declares `prefs`, this factory drives `lang`'s active * Lang follows `App.prefs.language` automatically — it reads the
* locale from `prefs.state.effective.language`: the initial value is * dimension's current value at construction time, then subscribes via
* applied at construction time, and changes are forwarded via a * `language.onChange(...)` to forward future changes through
* subscription that the factory tears down on `dispose()`. Apps can * `lang.setLocale(...)`. Apps that compose their prefs schema without a
* still call `lang.setLocale(...)` directly — but the next prefs * `language` dimension still get a working `lang` (it falls back to the
* change wins, since prefs is the authoritative source. * configured `defaultLocale`); the contract is "if you want lang to
* * track user intent, declare a `language` dimension in prefs".
* Lang receives no core deps directly — it manages its own logger
* internally via `setLogger(core.logger)`.
*/ */
export function defineActiveLang<S extends LangNode>( export function defineActiveLang<S extends LangNode>(
options: DefineActiveLangOptions<S> options: DefineActiveLangOptions<S>
): AppServiceFactory<'lang', readonly ['logger'], readonly ['prefs'], ActiveLang<S>> { ): AppServiceFactory<'lang', readonly ['logger', 'prefs'], readonly [], ActiveLang<S>> {
let unsubscribePrefs: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
return { return {
name: 'lang', name: 'lang',
coreDependencies: ['logger'], coreDependencies: ['logger', 'prefs'],
serviceDependencies: ['prefs'],
initMode: 'lazy', initMode: 'lazy',
create({ core, services }): ActiveLang<S> { create({ core }): ActiveLang<S> {
const lang = createActiveLang<S>( const lang = createActiveLang<S>(
options.schema, options.schema,
options.defaultLocale ?? 'es', options.defaultLocale ?? 'es',
@ -48,17 +43,12 @@ export function defineActiveLang<S extends LangNode>(
); );
lang.setLogger(core.logger); lang.setLogger(core.logger);
const prefsInstance = services.prefs as ActivePrefs | undefined; const languageDim = (core.prefs as unknown as Record<string, unknown>)['language'] as
if (prefsInstance !== undefined) { | { get(): string; onChange(handler: (value: string) => void): () => void }
const source = prefsLanguageSource(prefsInstance); | undefined;
// `SupportedLocale` is a literal union derived from the schema's if (languageDim !== undefined) {
// locale keys. The cast trusts the app to keep lang.setLocale(languageDim.get() as SupportedLocale);
// `capabilities.languages` aligned with the schema — there is unsubscribe = languageDim.onChange((next) => {
// no runtime way to validate that without a separate registry,
// and Lang's own `t()` already falls through gracefully on
// unknown locales.
lang.setLocale(source.get() as SupportedLocale);
unsubscribePrefs = source.onChange?.((next) => {
lang.setLocale(next as SupportedLocale); lang.setLocale(next as SupportedLocale);
}); });
} }
@ -66,8 +56,8 @@ export function defineActiveLang<S extends LangNode>(
return lang; return lang;
}, },
dispose(instance) { dispose(instance) {
unsubscribePrefs?.(); unsubscribe?.();
unsubscribePrefs = undefined; unsubscribe = undefined;
instance.dispose(); instance.dispose();
} }
}; };

@ -12,7 +12,7 @@ import type { AppServiceFactory } from '../services.ts';
* `applyPermInvalidateOnIdentityChange` to react to identity changes. * `applyPermInvalidateOnIdentityChange` to react to identity changes.
* *
* The factory wires `logger` and `clock` from the core, so decision * The factory wires `logger` and `clock` from the core, so decision
* cache TTL math runs on `App.Timers.clock`. If `App` declares an `http` * cache TTL math runs on `App.timers.clock`. If `App` declares an `http`
* service, this factory also wires `App.http` as the perm client's * service, this factory also wires `App.http` as the perm client's
* `http` transport so retry/timeout/auth hooks composed at the App * `http` transport so retry/timeout/auth hooks composed at the App
* level apply uniformly. Apps that prefer their own transport can pass * level apply uniformly. Apps that prefer their own transport can pass

@ -1,95 +0,0 @@
import {
createActivePrefs,
createPrefsStorageBridge,
type ActivePrefs,
type EnginePrefsOptions,
type PrefsIntentStorage,
type PrefsStorageBridge,
type PrefsStorageOp
} from '$prefs';
import type { AppServiceFactory } from '../services.ts';
/**
* Options for `defineActivePrefs`. A thin alias of `EnginePrefsOptions`
* — exported here so callers can `import { type DefineActivePrefsOptions }
* from '$active-app'` without reaching into `$prefs`.
*/
export type DefineActivePrefsOptions = EnginePrefsOptions;
/**
* Options for `defineActivePrefsWithStorage`. Adds a `PrefsIntentStorage`
* port and an optional error reporter on top of the base prefs config.
*/
export interface DefineActivePrefsWithStorageOptions extends DefineActivePrefsOptions {
readonly storage: PrefsIntentStorage;
readonly onError?: (error: unknown, op: PrefsStorageOp) => void;
}
/**
* `defineActivePrefs(options)` produces a service factory for the
* `prefs` slot. Adds the runtime to the App schema as
* `App.prefs: ActivePrefs`, where downstream service factories
* (`defineActiveLang`, `defineActiveFormat`, `defineActiveFrontend`)
* read capability sources via the helpers in `$prefs/sources`.
*
* No core dependencies: the engine is pure data — capabilities,
* environment and intent are passed in by the caller. Environment
* detection lives in `$prefs/adapters/*` and is invoked by the
* application's bootstrap code, not by this factory.
*
* `initMode: 'immediate'` because consumer factories ask for `prefs`
* via `serviceDependencies` at construction time; deferring the build
* would make the dependency graph order-sensitive.
*/
export function defineActivePrefs(
options: DefineActivePrefsOptions
): AppServiceFactory<'prefs', readonly [], readonly [], ActivePrefs> {
return {
name: 'prefs',
coreDependencies: [],
initMode: 'immediate',
create(): ActivePrefs {
return createActivePrefs(options);
},
dispose(instance) {
instance.dispose();
}
};
}
/**
* `defineActivePrefsWithStorage(options)` is the storage-bundled variant
* of `defineActivePrefs`. Bootstraps the engine and immediately attaches
* a `PrefsStorageBridge` so persisted intent loads on hydrate and every
* subsequent commit is saved back. Bridge teardown runs before the
* engine disposes.
*
* Use this when the application has a `PrefsIntentStorage` ready to
* inject (typically a thin adapter over `arts/storage`). Apps that wire
* persistence by hand can stay on `defineActivePrefs(...)` and call
* `createPrefsStorageBridge(...)` themselves.
*/
export function defineActivePrefsWithStorage(
options: DefineActivePrefsWithStorageOptions
): AppServiceFactory<'prefs', readonly [], readonly [], ActivePrefs> {
let bridge: PrefsStorageBridge | undefined;
return {
name: 'prefs',
coreDependencies: [],
initMode: 'immediate',
create(): ActivePrefs {
const prefs = createActivePrefs(options);
bridge = createPrefsStorageBridge({
engine: prefs,
storage: options.storage,
onError: options.onError
});
return prefs;
},
dispose(instance) {
bridge?.dispose();
bridge = undefined;
instance.dispose();
}
};
}

@ -5,7 +5,7 @@ import type { AppServiceFactory } from '../services.ts';
/** /**
* `defineActiveStorage(options)` produces a service factory for the * `defineActiveStorage(options)` produces a service factory for the
* `storage` slot. Wires `logger` and `clock` from the core so * `storage` slot. Wires `logger` and `clock` from the core so
* envelope TTL math runs through `App.Timers.clock` — the same * envelope TTL math runs through `App.timers.clock` — the same
* time source the rest of the ecosystem uses. * time source the rest of the ecosystem uses.
*/ */
export function defineActiveStorage( export function defineActiveStorage(

@ -4,8 +4,12 @@
* `aapp` is built on top of two layers: * `aapp` is built on top of two layers:
* *
* - **Core** — fixed runtime infrastructure that always exists: * - **Core** — fixed runtime infrastructure that always exists:
* `logger`, `bus`, `timers` and `orca`. Configurable via the * `logger`, `bus`, `timers`, `orca` and `prefs`. Configurable via
* `ActiveAppOptions` root, never declared as a service. * the `ActiveAppOptions` root, never declared as a service. The
* core surface is exposed in lowercase on the App
* (`App.logger`, `App.bus`, `App.prefs`, …) — the same convention
* services use, because the asymmetric "PascalCase for core" rule
* was decorative and went against JS property convention.
* *
* - **Services** — opt-in runtime pieces that the application declares * - **Services** — opt-in runtime pieces that the application declares
* in `services: { … }`. If a service is not declared, it does not * in `services: { … }`. If a service is not declared, it does not
@ -21,20 +25,29 @@
import type { EngineBus } from '$bus'; import type { EngineBus } from '$bus';
import type { EngineLogger } from '$logger'; import type { EngineLogger } from '$logger';
import type { EngineOrca } from '$orca'; import type { EngineOrca } from '$orca';
import type { ActivePrefs } from '$prefs';
import type { PrefsSchema } from '$libs/prefs';
import type { ActiveTimers } from '$timer'; import type { ActiveTimers } from '$timer';
// ── Core ──────────────────────────────────────────────────────────────── // ── Core ────────────────────────────────────────────────────────────────
/** /**
* The four pieces of the core. Always built before any service. A factory * The five pieces of the core. Always built before any service. A factory
* may declare a subset of these as `coreDependencies`; the builder * may declare a subset of these as `coreDependencies`; the builder
* supplies only the declared keys to `create()`. * supplies only the declared keys to `create()`.
*
* `prefs` is generic over the user-defined `PrefsSchema`. Service
* factories that declare `coreDependencies: ['prefs']` see
* `core.prefs` typed as `ActivePrefs` (open) — they read dimensions
* defensively or document their schema requirements (e.g. "requires
* a `language` dimension").
*/ */
export interface CoreServices { export interface CoreServices<S extends PrefsSchema = PrefsSchema> {
readonly logger: EngineLogger; readonly logger: EngineLogger;
readonly bus: EngineBus; readonly bus: EngineBus;
readonly timers: ActiveTimers; readonly timers: ActiveTimers;
readonly orca: EngineOrca; readonly orca: EngineOrca;
readonly prefs: ActivePrefs<S>;
} }
export type CoreServiceKey = keyof CoreServices; export type CoreServiceKey = keyof CoreServices;

@ -46,10 +46,10 @@ interface User {
} }
interface FakeAppCore { interface FakeAppCore {
Logger: EngineLogger; logger: EngineLogger;
Bus: EngineBus<Record<string, unknown>>; bus: EngineBus<Record<string, unknown>>;
Timers: ActiveTimers; timers: ActiveTimers;
Orca: EngineOrca; orca: EngineOrca;
cache: ActiveCache; cache: ActiveCache;
perm: ActivePerms; perm: ActivePerms;
connections: ActiveConnections; connections: ActiveConnections;
@ -96,10 +96,10 @@ function buildApp(): FakeAppCore {
}) as unknown as ActiveSession<User>; }) as unknown as ActiveSession<User>;
return { return {
Logger, logger: Logger,
Bus, bus: Bus,
Timers, timers: Timers,
Orca, orca: Orca,
cache, cache,
perm, perm,
connections, connections,
@ -157,7 +157,7 @@ describe('ecosystem — cross-actor isolation', () => {
// User A logs in. Drain orca reactions to the initial null → A // User A logs in. Drain orca reactions to the initial null → A
// transition before we mutate the cache. // transition before we mutate the cache.
await app.session.adopt(sessionFor({ id: 'user-A' })); await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.Orca); await flush(app.orca);
(app.perm as ActivePerms & { __seedActor: (id: string) => void }).__seedActor('user-A'); (app.perm as ActivePerms & { __seedActor: (id: string) => void }).__seedActor('user-A');
// Cache something private for A. // Cache something private for A.
@ -173,7 +173,7 @@ describe('ecosystem — cross-actor isolation', () => {
// SESSION_EVENT_IDENTITY_CHANGED (cache.clear, perm.invalidate, // SESSION_EVENT_IDENTITY_CHANGED (cache.clear, perm.invalidate,
// connections.reauth) plus SESSION_EVENT_REVOKED (closeAll). // connections.reauth) plus SESSION_EVENT_REVOKED (closeAll).
await app.session.revoke(); await app.session.revoke();
await flush(app.Orca); await flush(app.orca);
expect(permApi.__currentActor()).toBeNull(); expect(permApi.__currentActor()).toBeNull();
expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined(); expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined();
@ -183,14 +183,14 @@ describe('ecosystem — cross-actor isolation', () => {
// Even without re-asserting cleanup, the previous step proved the // Even without re-asserting cleanup, the previous step proved the
// invariant: nothing belonging to A survives into B's session. // invariant: nothing belonging to A survives into B's session.
await app.session.adopt(sessionFor({ id: 'user-B' })); await app.session.adopt(sessionFor({ id: 'user-B' }));
await flush(app.Orca); await flush(app.orca);
expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined(); expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined();
}); });
it('revoke clears cache and closes connections', async () => { it('revoke clears cache and closes connections', async () => {
await app.session.adopt(sessionFor({ id: 'user-A' })); await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.Orca); await flush(app.orca);
await app.cache.set(['user-A:doc'], { title: 'Privado' }, { scope: 'public' }); await app.cache.set(['user-A:doc'], { title: 'Privado' }, { scope: 'public' });
// Revoke the session. The session art emits `SESSION_EVENT_REVOKED` // Revoke the session. The session art emits `SESSION_EVENT_REVOKED`
@ -198,7 +198,7 @@ describe('ecosystem — cross-actor isolation', () => {
// `SESSION_EVENT_IDENTITY_CHANGED` (user-A → null) which clears the // `SESSION_EVENT_IDENTITY_CHANGED` (user-A → null) which clears the
// cache via the same identity-change reaction. // cache via the same identity-change reaction.
await app.session.revoke(); await app.session.revoke();
await flush(app.Orca); await flush(app.orca);
expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined(); expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined();
expect(app.connections.closeAll).toHaveBeenCalled(); expect(app.connections.closeAll).toHaveBeenCalled();
@ -207,22 +207,22 @@ describe('ecosystem — cross-actor isolation', () => {
it('reauthenticateAll fires only on identity-state transitions', async () => { it('reauthenticateAll fires only on identity-state transitions', async () => {
// First adopt: none → identified → reauth fires once. // First adopt: none → identified → reauth fires once.
await app.session.adopt(sessionFor({ id: 'user-A' })); await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.Orca); await flush(app.orca);
expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1); expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1);
// adopt(user-B) on top of an active identified session does NOT // adopt(user-B) on top of an active identified session does NOT
// transition the identity state (still `identified`), so no // transition the identity state (still `identified`), so no
// extra reauth — that is the framework's documented contract. // extra reauth — that is the framework's documented contract.
await app.session.adopt(sessionFor({ id: 'user-B' })); await app.session.adopt(sessionFor({ id: 'user-B' }));
await flush(app.Orca); await flush(app.orca);
expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1); expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1);
// Logout + re-login: identified → none → identified counts as two // Logout + re-login: identified → none → identified counts as two
// transitions, so reauth fires twice more (3 total). // transitions, so reauth fires twice more (3 total).
await app.session.revoke(); await app.session.revoke();
await flush(app.Orca); await flush(app.orca);
await app.session.adopt(sessionFor({ id: 'user-C' })); await app.session.adopt(sessionFor({ id: 'user-C' }));
await flush(app.Orca); await flush(app.orca);
expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(3); expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(3);
}); });
@ -230,17 +230,17 @@ describe('ecosystem — cross-actor isolation', () => {
// Initial adopt + revoke runs the reactions (cache cleared on // Initial adopt + revoke runs the reactions (cache cleared on
// identity-state transition). // identity-state transition).
await app.session.adopt(sessionFor({ id: 'user-A' })); await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.Orca); await flush(app.orca);
await app.cache.set(['user-A:doc'], { title: 'doc' }, { scope: 'public' }); await app.cache.set(['user-A:doc'], { title: 'doc' }, { scope: 'public' });
await app.session.revoke(); await app.session.revoke();
await flush(app.Orca); await flush(app.orca);
expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined(); expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined();
// Detach. The next identity change should leave cache untouched. // Detach. The next identity change should leave cache untouched.
detachOrca(); detachOrca();
await app.cache.set(['user-A:doc'], { title: 'doc-2' }, { scope: 'public' }); await app.cache.set(['user-A:doc'], { title: 'doc-2' }, { scope: 'public' });
await app.session.adopt(sessionFor({ id: 'user-C' })); await app.session.adopt(sessionFor({ id: 'user-C' }));
await flush(app.Orca); await flush(app.orca);
expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toEqual({ title: 'doc-2' }); expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toEqual({ title: 'doc-2' });
// Re-attach so the afterEach detacher matches what's wired. // Re-attach so the afterEach detacher matches what's wired.

@ -116,7 +116,7 @@ describe('ecosystem orca — user A → user B switch', () => {
closeAll: connectionsClose closeAll: connectionsClose
} as unknown as ActiveConnections; } as unknown as ActiveConnections;
applyStandardOrca({ Orca: core.orca, cache, perm, connections }); applyStandardOrca({ orca: core.orca, cache, perm, connections });
// User A logs in. No identity change yet (anon → A is the first // User A logs in. No identity change yet (anon → A is the first
// adoption); the preset only listens to IDENTITY_CHANGED, so we // adoption); the preset only listens to IDENTITY_CHANGED, so we
@ -175,7 +175,7 @@ describe('ecosystem orca — user A → user B switch', () => {
closeAll: connectionsClose closeAll: connectionsClose
} as unknown as ActiveConnections; } as unknown as ActiveConnections;
applyStandardOrca({ Orca: core.orca, cache, perm, connections }); applyStandardOrca({ orca: core.orca, cache, perm, connections });
core.bus.publish(SESSION_EVENT_REVOKED, revokedB); core.bus.publish(SESSION_EVENT_REVOKED, revokedB);
await flush(); await flush();
@ -203,7 +203,7 @@ describe('ecosystem orca — user A → user B switch', () => {
closeAll: vi.fn() closeAll: vi.fn()
} as unknown as ActiveConnections; } as unknown as ActiveConnections;
applyStandardOrca({ Orca: core.orca, cache, perm, connections }); applyStandardOrca({ orca: core.orca, cache, perm, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB);
await flush(); await flush();
@ -230,7 +230,7 @@ describe('ecosystem orca — user A → user B switch', () => {
closeAll: vi.fn() closeAll: vi.fn()
} as unknown as ActiveConnections; } as unknown as ActiveConnections;
const detach = applyStandardOrca({ Orca: core.orca, cache, perm, connections }); const detach = applyStandardOrca({ orca: core.orca, cache, perm, connections });
detach(); detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB);

@ -1,230 +1,156 @@
/** /**
* Verifies the cross-cutting wiring done by the consumer factories * Verifies the cross-cutting wiring done by the consumer factories
* (`defineActiveLang`, `defineActiveFormat`, `defineActiveFrontend`): * (`defineActiveLang`, `defineActiveFormat`, `defineActiveFrontend`):
* when `prefs` is declared in the schema, those consumers must source * because `prefs` is part of the core, those consumers always source
* their locale/language from the prefs engine instead of from each * their locale / language / theme from the prefs engine — the wiring
* other or from their own defaults. * is not conditional on a service declaration.
*
* The "App-as-a-whole" lifecycle (storage bridge attached at root,
* disposed before the engine) is covered by the `service-factories`
* suite plus `arts/prefs/test/storage-bridge.test.ts`.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { PrefsCapabilities } from '$libs/prefs';
import { createSvelteEngineBus } from '$bus'; import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger'; import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca'; import { createEngineOrca } from '$orca';
import { createActivePrefs, standardPrefsDimensions } from '$prefs';
import { createActiveTimers } from '$timer/active-timers.svelte'; import { createActiveTimers } from '$timer/active-timers.svelte';
import { buildServiceBuilders } from '../service-builder.ts'; import { buildServiceBuilders } from '../service-builder.ts';
import { import {
defineActiveDom, defineActiveDom,
defineActiveFormat, defineActiveFormat,
defineActiveFrontend, defineActiveFrontend,
defineActiveLang, defineActiveLang
defineActivePrefs,
defineActivePrefsWithStorage
} from '../service-factories/index.ts'; } from '../service-factories/index.ts';
import type { PrefsIntent } from '$libs/prefs';
import type { PrefsIntentStorage } from '$prefs';
import type { CoreServices } from '../services.ts'; import type { CoreServices } from '../services.ts';
function buildCore(): CoreServices { const NEXO_SCHEMA = {
...standardPrefsDimensions({
languages: ['es-ES', 'en-US', 'ar-EG'],
locales: ['es-ES', 'en-US', 'ar-EG'],
currencies: ['EUR', 'USD'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid'
}
})
};
type Scene = { core: CoreServices; prefs: ReturnType<typeof buildPrefs> };
function buildPrefs() {
return createActivePrefs({ schema: NEXO_SCHEMA });
}
function buildScene(): Scene {
const logger = createEngineLogger({}); const logger = createEngineLogger({});
const timers = createActiveTimers({ logger }); const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus({ logger, clock: timers.clock }); const bus = createSvelteEngineBus({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger }); const orca = createEngineOrca({ bus, timers, logger });
return { logger, bus, timers, orca }; const prefs = buildPrefs();
return { core: { logger, bus, timers, orca, prefs }, prefs };
} }
const CAPS: PrefsCapabilities = {
languages: ['es-ES', 'en-US', 'ar-EG'],
locales: ['es-ES', 'en-US', 'ar-EG'],
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
const LANG_SCHEMA = { const LANG_SCHEMA = {
hello: { 'es-ES': 'Hola', 'en-US': 'Hello' } hello: { 'es-ES': 'Hola', 'en-US': 'Hello' }
}; };
describe('prefs → consumer wiring', () => { describe('prefs → consumer wiring', () => {
it('lang.setLocale fires when prefs.language changes', () => { it('lang.setLocale fires when prefs.language changes', () => {
const core = buildCore(); const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders( const builders = buildServiceBuilders(
{ { lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }) },
prefs: defineActivePrefs({ capabilities: CAPS }),
lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' })
},
core core
); );
const { prefs, lang } = builders.proxies as { const { lang } = builders.proxies as {
prefs: { setIntent: (k: 'language', v: string) => unknown }; lang: { getLocale(): string; t(k: 'hello'): string };
lang: { getLocale: () => string; t: (k: 'hello') => string };
}; };
// Initial language flows from prefs (defaults.language = 'es-ES').
expect(lang.getLocale()).toBe('es-ES'); expect(lang.getLocale()).toBe('es-ES');
expect(lang.t('hello')).toBe('Hola'); expect(lang.t('hello')).toBe('Hola');
// Switching prefs.language triggers lang.setLocale via the scene.prefs.language.set('en-US');
// factory's subscription.
prefs.setIntent('language', 'en-US');
expect(lang.getLocale()).toBe('en-US'); expect(lang.getLocale()).toBe('en-US');
expect(lang.t('hello')).toBe('Hello'); expect(lang.t('hello')).toBe('Hello');
builders.disposeAll(); builders.disposeAll();
core.prefs.dispose();
}); });
it('format follows prefs.locale instead of lang when both are declared', () => { it('format follows prefs.locale', () => {
const core = buildCore(); const scene = buildScene();
const builders = buildServiceBuilders( const core = scene.core;
{ const builders = buildServiceBuilders({ format: defineActiveFormat() }, core);
prefs: defineActivePrefs({ capabilities: CAPS }), const { format } = builders.proxies as { format: { getLocale(): string } };
lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }),
format: defineActiveFormat()
},
core
);
const { prefs, format } = builders.proxies as {
prefs: { setIntent: (k: 'locale', v: string) => unknown };
format: { getLocale: () => string };
};
expect(format.getLocale()).toBe('es-ES'); expect(format.getLocale()).toBe('es-ES');
prefs.setIntent('locale', 'en-US'); scene.prefs.locale.set('en-US');
expect(format.getLocale()).toBe('en-US'); expect(format.getLocale()).toBe('en-US');
builders.disposeAll(); builders.disposeAll();
core.prefs.dispose();
}); });
it('frontend follows prefs.language for direction derivation', () => { it('frontend follows prefs.language for direction derivation', () => {
const core = buildCore(); const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders( const builders = buildServiceBuilders(
{ {
prefs: defineActivePrefs({ capabilities: CAPS }),
dom: defineActiveDom(), dom: defineActiveDom(),
lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }),
frontend: defineActiveFrontend({ applyDom: false }) frontend: defineActiveFrontend({ applyDom: false })
}, },
core core
); );
const { prefs, frontend } = builders.proxies as { const { frontend } = builders.proxies as { frontend: { getLocale(): string } };
prefs: { setIntent: (k: 'language', v: string) => unknown };
frontend: { getLocale: () => string };
};
expect(frontend.getLocale()).toBe('es-ES'); expect(frontend.getLocale()).toBe('es-ES');
prefs.setIntent('language', 'en-US'); scene.prefs.language.set('en-US');
expect(frontend.getLocale()).toBe('en-US'); expect(frontend.getLocale()).toBe('en-US');
builders.disposeAll(); builders.disposeAll();
core.prefs.dispose();
}); });
it('frontend mode/density/motion/dir track prefs end-to-end', () => { it('frontend mode/density/motion/dir track prefs end-to-end', () => {
const core = buildCore(); const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders( const builders = buildServiceBuilders(
{ {
prefs: defineActivePrefs({ capabilities: CAPS }),
dom: defineActiveDom(), dom: defineActiveDom(),
frontend: defineActiveFrontend({ applyDom: false }) frontend: defineActiveFrontend({ applyDom: false })
}, },
core core
); );
const { prefs, frontend } = builders.proxies as { const { frontend } = builders.proxies as {
prefs: {
setIntent: (k: 'theme' | 'density' | 'motion' | 'language', v: string) => unknown;
};
frontend: { frontend: {
getMode: () => string; getMode(): string;
getDensity: () => string; getDensity(): string;
getReducedMotion: () => boolean; getReducedMotion(): boolean;
getDir: () => string; getDir(): string;
}; };
}; };
// Initial values flow from prefs.defaults at construction time.
expect(frontend.getMode()).toBe('light'); expect(frontend.getMode()).toBe('light');
expect(frontend.getDensity()).toBe('comfortable'); expect(frontend.getDensity()).toBe('comfortable');
expect(frontend.getReducedMotion()).toBe(false); expect(frontend.getReducedMotion()).toBe(false);
expect(frontend.getDir()).toBe('ltr'); expect(frontend.getDir()).toBe('ltr');
prefs.setIntent('theme', 'dark'); scene.prefs.theme.set('dark');
expect(frontend.getMode()).toBe('dark'); expect(frontend.getMode()).toBe('dark');
prefs.setIntent('density', 'compact'); scene.prefs.density.set('compact');
expect(frontend.getDensity()).toBe('compact'); expect(frontend.getDensity()).toBe('compact');
prefs.setIntent('motion', 'reduce'); scene.prefs.motion.set('reduce');
expect(frontend.getReducedMotion()).toBe(true); expect(frontend.getReducedMotion()).toBe(true);
prefs.setIntent('language', 'ar-EG'); scene.prefs.language.set('ar-EG');
expect(frontend.getDir()).toBe('rtl'); expect(frontend.getDir()).toBe('rtl');
builders.disposeAll(); builders.disposeAll();
}); core.prefs.dispose();
it('defineActivePrefsWithStorage hydrates intent and persists writes', async () => {
const core = buildCore();
const saved: PrefsIntent[] = [];
const storage: PrefsIntentStorage = {
load: () => ({ locale: 'en-US' }),
save: (intent) => {
saved.push(intent);
},
clear: () => {}
};
const builders = buildServiceBuilders(
{
prefs: defineActivePrefsWithStorage({ capabilities: CAPS, storage })
},
core
);
const { prefs } = builders.proxies as {
prefs: {
effective: () => { locale: string };
setIntent: (k: 'currency', v: string) => unknown;
};
};
// The bridge's `load()` always resolves via `await`, so hydrate
// applies on the next microtask even for synchronous storage.
// Yield once before asserting.
await Promise.resolve();
expect(prefs.effective().locale).toBe('en-US');
prefs.setIntent('currency', 'USD');
await new Promise((r) => setTimeout(r, 0));
expect(saved).toEqual([{ locale: 'en-US', currency: 'USD' }]);
builders.disposeAll();
});
it('format / frontend retain the lang fallback when prefs is not declared', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'en-US' }),
format: defineActiveFormat()
},
core
);
const { format } = builders.proxies as {
format: { getLocale: () => string };
};
// No prefs in schema → format falls back to lang's locale.
expect(format.getLocale()).toBe('en-US');
builders.disposeAll();
}); });
}); });

@ -80,7 +80,7 @@ describe('applyCacheClearOnIdentityChange', () => {
const clear = vi.fn(() => Promise.resolve()); const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache; const cache = { clear } as unknown as ActiveCache;
applyCacheClearOnIdentityChange({ Orca: core.orca, cache }); applyCacheClearOnIdentityChange({ orca: core.orca, cache });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -92,7 +92,7 @@ describe('applyCacheClearOnIdentityChange', () => {
const clear = vi.fn(() => Promise.resolve()); const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache; const cache = { clear } as unknown as ActiveCache;
const detach = applyCacheClearOnIdentityChange({ Orca: core.orca, cache }); const detach = applyCacheClearOnIdentityChange({ orca: core.orca, cache });
detach(); detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
@ -105,7 +105,7 @@ describe('applyCacheClearOnIdentityChange', () => {
const error = new Error('clear failed'); const error = new Error('clear failed');
const cache = { clear: vi.fn(() => Promise.reject(error)) } as unknown as ActiveCache; const cache = { clear: vi.fn(() => Promise.reject(error)) } as unknown as ActiveCache;
applyCacheClearOnIdentityChange({ Orca: core.orca, cache }); applyCacheClearOnIdentityChange({ orca: core.orca, cache });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -131,7 +131,7 @@ describe('applyPermInvalidateOnIdentityChange', () => {
const invalidate = vi.fn(); const invalidate = vi.fn();
const perm = { invalidate } as unknown as ActivePerms; const perm = { invalidate } as unknown as ActivePerms;
applyPermInvalidateOnIdentityChange({ Orca: core.orca, perm }); applyPermInvalidateOnIdentityChange({ orca: core.orca, perm });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -154,7 +154,7 @@ describe('applyCacheClearOnRevoke', () => {
const clear = vi.fn(() => Promise.resolve()); const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache; const cache = { clear } as unknown as ActiveCache;
applyCacheClearOnRevoke({ Orca: core.orca, cache }); applyCacheClearOnRevoke({ orca: core.orca, cache });
const revokePayload = { const revokePayload = {
...samplePayload, ...samplePayload,
@ -181,7 +181,7 @@ describe('applyConnectionsReauthOnIdentityChange', () => {
const reauthenticateAll = vi.fn(() => Promise.resolve([])); const reauthenticateAll = vi.fn(() => Promise.resolve([]));
const connections = { reauthenticateAll } as unknown as ActiveConnections; const connections = { reauthenticateAll } as unknown as ActiveConnections;
applyConnectionsReauthOnIdentityChange({ Orca: core.orca, connections }); applyConnectionsReauthOnIdentityChange({ orca: core.orca, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -195,7 +195,7 @@ describe('applyConnectionsReauthOnIdentityChange', () => {
reauthenticateAll: vi.fn(() => Promise.reject(error)) reauthenticateAll: vi.fn(() => Promise.reject(error))
} as unknown as ActiveConnections; } as unknown as ActiveConnections;
applyConnectionsReauthOnIdentityChange({ Orca: core.orca, connections }); applyConnectionsReauthOnIdentityChange({ orca: core.orca, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -221,7 +221,7 @@ describe('applyConnectionsCloseOnRevoke', () => {
const closeAll = vi.fn(); const closeAll = vi.fn();
const connections = { closeAll } as unknown as ActiveConnections; const connections = { closeAll } as unknown as ActiveConnections;
applyConnectionsCloseOnRevoke({ Orca: core.orca, connections }); applyConnectionsCloseOnRevoke({ orca: core.orca, connections });
const revokePayload = { const revokePayload = {
...samplePayload, ...samplePayload,
@ -251,7 +251,7 @@ describe('applyStandardOrca', () => {
const cache = { clear: cacheClear } as unknown as ActiveCache; const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms; const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
applyStandardOrca({ Orca: core.orca, cache, perm }); applyStandardOrca({ orca: core.orca, cache, perm });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -265,7 +265,7 @@ describe('applyStandardOrca', () => {
const closeAll = vi.fn(); const closeAll = vi.fn();
const connections = { reauthenticateAll, closeAll } as unknown as ActiveConnections; const connections = { reauthenticateAll, closeAll } as unknown as ActiveConnections;
applyStandardOrca({ Orca: core.orca, connections }); applyStandardOrca({ orca: core.orca, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush(); await flush();
@ -283,7 +283,7 @@ describe('applyStandardOrca', () => {
const permInvalidate = vi.fn(); const permInvalidate = vi.fn();
const perm = { invalidate: permInvalidate } as unknown as ActivePerms; const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
applyStandardOrca({ Orca: core.orca, perm }); applyStandardOrca({ orca: core.orca, perm });
// No cache action registered -> no error from publish // No cache action registered -> no error from publish
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
@ -298,7 +298,7 @@ describe('applyStandardOrca', () => {
const cache = { clear: cacheClear } as unknown as ActiveCache; const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms; const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
const detach = applyStandardOrca({ Orca: core.orca, cache, perm }); const detach = applyStandardOrca({ orca: core.orca, cache, perm });
detach(); detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload); core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);

@ -51,7 +51,7 @@ describe('createActiveApp — declarative service schema', () => {
App.dispose(); App.dispose();
}); });
it('exposes only the four core members alongside declared services', () => { it('exposes only the five core members alongside declared services', () => {
const App = createActiveApp({ const App = createActiveApp({
logger: SILENT_LOGGER, logger: SILENT_LOGGER,
services: { services: {
@ -60,10 +60,11 @@ describe('createActiveApp — declarative service schema', () => {
}); });
// Core: always present. // Core: always present.
expect(App.Logger).toBeDefined(); expect(App.logger).toBeDefined();
expect(App.Bus).toBeDefined(); expect(App.bus).toBeDefined();
expect(App.Timers).toBeDefined(); expect(App.timers).toBeDefined();
expect(App.Orca).toBeDefined(); expect(App.orca).toBeDefined();
expect(App.prefs).toBeDefined();
// Schema-declared service exposed as lowercase property. // Schema-declared service exposed as lowercase property.
expect(App.cache).toBeDefined(); expect(App.cache).toBeDefined();

@ -38,7 +38,9 @@ function mockCore(): CoreServices {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
timers: {} as any, timers: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
orca: {} as any orca: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prefs: {} as any
}; };
} }

@ -12,28 +12,42 @@ import {
defineActiveFormat, defineActiveFormat,
defineActiveFrontend, defineActiveFrontend,
defineActiveLang, defineActiveLang,
defineActivePrefs,
defineActiveStorage, defineActiveStorage,
defineEngineHttp, defineEngineHttp,
defineEngineSium defineEngineSium
} from '../service-factories/index.ts'; } from '../service-factories/index.ts';
import type { PrefsCapabilities } from '$libs/prefs';
import { createSvelteEngineBus } from '$bus'; import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger'; import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca'; import { createEngineOrca } from '$orca';
import { createActivePrefs, standardPrefsDimensions } from '$prefs';
import { createActiveTimers } from '$timer/active-timers.svelte'; import { createActiveTimers } from '$timer/active-timers.svelte';
import type { CoreServices } from '../services.ts'; import type { CoreServices } from '../services.ts';
const NEUTRAL_SCHEMA = {
...standardPrefsDimensions({
languages: ['es', 'en'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
defaults: {
language: 'es',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid'
}
})
};
function buildCore(): CoreServices { function buildCore(): CoreServices {
const logger = createEngineLogger({}); const logger = createEngineLogger({});
const timers = createActiveTimers({ logger }); const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus({ logger, clock: timers.clock }); const bus = createSvelteEngineBus({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger }); const orca = createEngineOrca({ bus, timers, logger });
return { logger, bus, timers, orca }; const prefs = createActivePrefs({ schema: NEUTRAL_SCHEMA });
return { logger, bus, timers, orca, prefs };
} }
describe('service-factories — integration', () => { describe('service-factories — integration', () => {
it('builds storage / format / dom / http / sium without core deps', () => { it('builds storage / format / dom / http / sium with declared core deps', () => {
const core = buildCore(); const core = buildCore();
const builders = buildServiceBuilders( const builders = buildServiceBuilders(
{ {
@ -80,11 +94,10 @@ describe('service-factories — integration', () => {
builders.disposeAll(); builders.disposeAll();
}); });
it('builds frontend after dom and lang in topological order', () => { it('builds frontend after dom in topological order', () => {
const core = buildCore(); const core = buildCore();
const builders = buildServiceBuilders( const builders = buildServiceBuilders(
{ {
lang: defineActiveLang({ schema: { greeting: { es: 'a', en: 'b' } } }),
dom: defineActiveDom(), dom: defineActiveDom(),
frontend: defineActiveFrontend({ applyDom: false }) frontend: defineActiveFrontend({ applyDom: false })
}, },
@ -93,7 +106,7 @@ describe('service-factories — integration', () => {
const status = builders.statusMap(); const status = builders.statusMap();
// All lazy: nothing built yet. // All lazy: nothing built yet.
expect(status).toEqual({ lang: 'absent', dom: 'absent', frontend: 'absent' }); expect(status).toEqual({ dom: 'absent', frontend: 'absent' });
// Touching frontend pulls it (and its declared deps if reachable). // Touching frontend pulls it (and its declared deps if reachable).
const fe = (builders.proxies as { frontend: object }).frontend; const fe = (builders.proxies as { frontend: object }).frontend;
@ -101,41 +114,18 @@ describe('service-factories — integration', () => {
builders.disposeAll(); builders.disposeAll();
}); });
it('builds prefs as an immediate-init service exposing the rune surface', () => { it('format reads its locale from core.prefs', () => {
// Prefs is core, so format gets its locale source through
// `core.prefs` without declaring a service dependency.
const core = buildCore(); const core = buildCore();
const caps: PrefsCapabilities = {
languages: ['es-ES', 'en-US'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
const builders = buildServiceBuilders( const builders = buildServiceBuilders(
{ { format: defineActiveFormat() },
prefs: defineActivePrefs({ capabilities: caps })
},
core core
); );
// `immediate` init: the slot is built before any access. const format = (builders.proxies as { format: { getLocale(): string } }).format;
expect(builders.statusMap().prefs).toBe('present'); expect(format.getLocale()).toBe('es-ES');
const prefs = (builders.proxies as { prefs: { kind: string; effective(): { locale: string } } }).prefs;
expect(prefs.kind).toBe('prefs');
expect(prefs.effective().locale).toBe('es-ES');
builders.disposeAll(); builders.disposeAll();
core.prefs.dispose();
}); });
it('reports failed status when a factory throws on construct', () => { it('reports failed status when a factory throws on construct', () => {
@ -155,5 +145,6 @@ describe('service-factories — integration', () => {
expect(() => (builders.proxies as { broken: unknown }).broken).toThrow(); expect(() => (builders.proxies as { broken: unknown }).broken).toThrow();
expect(builders.statusMap()).toEqual({ broken: 'failed' }); expect(builders.statusMap()).toEqual({ broken: 'failed' });
builders.disposeAll(); builders.disposeAll();
core.prefs.dispose();
}); });
}); });

@ -1,6 +1,6 @@
/** /**
* Tests for the `applySessionAutoRefresh` preset. The preset's job is * Tests for the `applySessionAutoRefresh` preset. The preset's job is
* to wire `App.Timers` and `App.Timers.clock` into `withAutoRefresh` * to wire `App.timers` and `App.timers.clock` into `withAutoRefresh`
* so the refresh ticker runs through the App's single time source * so the refresh ticker runs through the App's single time source
* instead of falling back to `setInterval` + `Date.now`. * instead of falling back to `setInterval` + `Date.now`.
*/ */
@ -25,10 +25,10 @@ function alice(expiresAt: number): Session<User> {
} }
interface Core { interface Core {
Logger: EngineLogger; logger: EngineLogger;
Bus: EngineBus<Record<string, unknown>>; bus: EngineBus<Record<string, unknown>>;
Timers: ActiveTimers; timers: ActiveTimers;
Orca: EngineOrca; orca: EngineOrca;
dispose: () => void; dispose: () => void;
} }
@ -41,10 +41,10 @@ function buildCore(): Core {
}); });
const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger }); const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger });
return { return {
Logger, logger: Logger,
Bus, bus: Bus,
Timers, timers: Timers,
Orca, orca: Orca,
dispose() { dispose() {
Orca.dispose(); Orca.dispose();
Bus.dispose(); Bus.dispose();
@ -72,7 +72,7 @@ describe('applySessionAutoRefresh', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('routes the refresh ticker through App.Timers (not setInterval)', async () => { it('routes the refresh ticker through App.timers (not setInterval)', async () => {
await session.adopt(alice(NOW + 60_000)); // 60s ahead, margin 90s await session.adopt(alice(NOW + 60_000)); // 60s ahead, margin 90s
const stop = applySessionAutoRefresh( const stop = applySessionAutoRefresh(
{ ...core, session }, { ...core, session },

@ -1,24 +1,33 @@
/** /**
* Public types for `arts/active-app`. * Public types for `arts/active-app`.
* *
* `ActiveApp<TSchema>` is the composed surface seen by the application: * `ActiveApp<TSchema, TPrefsSchema>` is the composed surface seen by
* the application:
* *
* - `ActiveAppCore` — `Logger`, `Bus`, `Timers`, `Orca`, `dispose`. * - `ActiveAppCore` — `logger`, `bus`, `timers`, `orca`, `prefs` plus
* Always present, never declared as a service. * `dispose`. Always present. Lowercase, like every other JS
* property — the previous PascalCase rule existed for visual
* signalling that the property was core, not because of any
* technical constraint.
* - `ResolveServiceInstances<TSchema>` — every entry the application * - `ResolveServiceInstances<TSchema>` — every entry the application
* declared in `services: { … }` is exposed as a lowercase property * declared in `services: { … }` is exposed as a property with the
* with the exact instance type returned by its factory. * exact instance type returned by its factory.
* - `ActiveAppServicesIntrospection` — `services` map for devtools. * - `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 { EngineBus, EngineBusOptions } from '$bus';
import type { EngineLogger, LoggerOptions } from '$logger'; import type { EngineLogger, LoggerOptions } from '$logger';
import type { EngineOrca, EngineOrcaOptions } from '$orca'; import type { EngineOrca, EngineOrcaOptions } from '$orca';
import type {
ActivePrefs,
PrefsIntentStorage,
PrefsStorageOp
} from '$prefs';
import type {
PrefsEnvironment,
PrefsIntentOf,
PrefsSchema
} from '$libs/prefs';
import type { ActiveTimers, EngineTimersOptions } from '$timer'; import type { ActiveTimers, EngineTimersOptions } from '$timer';
import type { AppEventMap } from './events.ts'; import type { AppEventMap } from './events.ts';
@ -29,7 +38,7 @@ import type {
} from './services.ts'; } from './services.ts';
/** /**
* Bus event map seen by `App.Bus`. Includes App-owned events and any * Bus event map seen by `App.bus`. Includes App-owned events and any
* module-level event maps that App is intended to surface. * module-level event maps that App is intended to surface.
* *
* Module event maps (e.g. `SessEventMap`, `ConnectionEventMap`) are * Module event maps (e.g. `SessEventMap`, `ConnectionEventMap`) are
@ -41,6 +50,23 @@ export interface ActiveAppBusEvents extends AppEventMap {}
// ── Options ──────────────────────────────────────────────────────────── // ── Options ────────────────────────────────────────────────────────────
/**
* Options for the App-wide `prefs` engine. Generic over the schema so
* dimension keys flow through to `App.prefs.<key>` autocomplete and
* to the `intent` / `storage` shapes.
*
* Apps that don't need persistence omit `storage`; apps that do supply
* a `PrefsIntentStorage<TSchema>` (typically a thin adapter over
* `arts/storage`) and an optional `onStorageError` reporter.
*/
export interface ActiveAppPrefsOptions<S extends PrefsSchema> {
readonly schema: S;
readonly environment?: PrefsEnvironment;
readonly intent?: PrefsIntentOf<S>;
readonly storage?: PrefsIntentStorage<S>;
readonly onStorageError?: (error: unknown, op: PrefsStorageOp) => void;
}
/** /**
* Options for `createActiveApp()`. All sections are optional. * Options for `createActiveApp()`. All sections are optional.
* *
@ -51,6 +77,11 @@ export interface ActiveAppBusEvents extends AppEventMap {}
* `logger` (and `clock` for the bus) automatically. * `logger` (and `clock` for the bus) automatically.
* - `orca` builds with engine defaults; App injects `bus`, `timers` * - `orca` builds with engine defaults; App injects `bus`, `timers`
* and `logger`. * and `logger`.
* - `prefs` defaults to a neutral `standardPrefsDimensions` preset
* (single `en` / `en-US` / `USD` baseline) so apps that don't care
* about preferences still get a valid `App.prefs` instance. Apps
* that do care declare their full `schema` here; the dimension
* keys flow through to `App.prefs.<key>`.
* - `services` declares the opt-in service schema. If omitted, only * - `services` declares the opt-in service schema. If omitted, only
* the core is built and `App.services` is `{}`. * the core is built and `App.services` is `{}`.
* *
@ -58,60 +89,42 @@ export interface ActiveAppBusEvents extends AppEventMap {}
* `frontend`, `dom`, `storage`, `http`, `cache`) now belongs in * `frontend`, `dom`, `storage`, `http`, `cache`) now belongs in
* `services` via the corresponding `defineActive*` factory. * `services` via the corresponding `defineActive*` factory.
*/ */
export interface ActiveAppOptions<TSchema extends AppServiceSchema = AppServiceSchema> { export interface ActiveAppOptions<
/** TSchema extends AppServiceSchema = AppServiceSchema,
* Logger options for the App-wide engine logger. App passes the TPrefsSchema extends PrefsSchema = PrefsSchema
* resulting instance to every service that declares `logger` as a > {
* core dependency.
*/
logger?: LoggerOptions; logger?: LoggerOptions;
/**
* Timer scheduler options. App injects `logger` automatically.
*/
timers?: Omit<EngineTimersOptions, 'logger'>; timers?: Omit<EngineTimersOptions, 'logger'>;
/**
* Cross-artifact event bus options. App injects `logger` and the
* shared timers `clock` automatically.
*/
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>; bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
/**
* Orca options. App injects `bus`, `timers` and `logger`
* automatically.
*/
orca?: Omit<EngineOrcaOptions, 'bus' | 'timers' | 'logger'>; orca?: Omit<EngineOrcaOptions, 'bus' | 'timers' | 'logger'>;
prefs?: ActiveAppPrefsOptions<TPrefsSchema>;
/**
* 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()`.
*/
services?: TSchema; services?: TSchema;
} }
// ── Surface ──────────────────────────────────────────────────────────── // ── Surface ────────────────────────────────────────────────────────────
/** /**
* The fixed core surface, present on every App: `Logger`, `Bus`, * The fixed core surface, present on every App: `logger`, `bus`,
* `Timers`, `Orca` plus the lifecycle helper `dispose`. None of these * `timers`, `orca`, `prefs` plus the lifecycle helper `dispose`. None
* are services — they are the substrate every service depends on. * of these are services — they are the substrate every service
* depends on. Lowercase, like all JS properties.
*/ */
export interface ActiveAppCore { export interface ActiveAppCore<TPrefsSchema extends PrefsSchema = PrefsSchema> {
readonly Logger: EngineLogger; readonly logger: EngineLogger;
readonly Bus: EngineBus<ActiveAppBusEvents>; readonly bus: EngineBus<ActiveAppBusEvents>;
readonly Timers: ActiveTimers; readonly timers: ActiveTimers;
/** /**
* Orchestration engine. Always present, inert until the application * Orchestration engine. Always present, inert until the application
* registers actions via `App.Orca.onEvent(...)` or applies presets * registers actions via `App.orca.onEvent(...)` or applies presets
* from `arts/active-app/presets/`. * from `arts/active-app/presets/`.
*/ */
readonly Orca: EngineOrca; readonly orca: EngineOrca;
/**
* Reactive preference state, generic over the user-defined schema.
* Each schema key becomes a typed dimension at `App.prefs.<key>`
* with `.get()` / `.set()` / `.clear()` / `.onChange()` verbs.
*/
readonly prefs: ActivePrefs<TPrefsSchema>;
/** /**
* Tear down every constructed service in reverse order, then the * Tear down every constructed service in reverse order, then the
@ -133,10 +146,15 @@ export interface ActiveAppServicesIntrospection {
* Composed application surface. * Composed application surface.
* *
* Type-safe access: * Type-safe access:
* - `App.Logger` / `App.Bus` / `App.Timers` / `App.Orca` always exist. * - `App.logger` / `App.bus` / `App.timers` / `App.orca` / `App.prefs`
* always exist.
* - `App.<serviceName>` exists IFF the service was declared in * - `App.<serviceName>` exists IFF the service was declared in
* `options.services`. Reading an undeclared name is a TypeScript * `options.services`. Reading an undeclared name is a TypeScript
* error. * error.
*/ */
export type ActiveApp<TSchema extends AppServiceSchema = AppServiceSchema> = export type ActiveApp<
ActiveAppCore & ResolveServiceInstances<TSchema> & ActiveAppServicesIntrospection; TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
> = ActiveAppCore<TPrefsSchema> &
ResolveServiceInstances<TSchema> &
ActiveAppServicesIntrospection;

@ -16,7 +16,7 @@ policy, and observability hooks. It does **not** know about `sess`,
business rule. business rule.
Applications never instantiate one bus per module. `aapp` creates a Applications never instantiate one bus per module. `aapp` creates a
single `App.Bus` per render scope and injects it. Artifacts that need single `App.bus` per render scope and injects it. Artifacts that need
to publish or listen receive that bus, or the smaller `EventPublisher` to publish or listen receive that bus, or the smaller `EventPublisher`
interface, from the composition root. interface, from the composition root.
@ -35,7 +35,7 @@ comes from each owner declaring constants, payload shapes, and typed
`publishX` / `onX` helpers. `publishX` / `onX` helpers.
The framework distinguishes two layers of events that share a single The framework distinguishes two layers of events that share a single
`App.Bus` instance: `App.bus` instance:
- **Module events** (`SESSION_EVENT_*`, `AUTH_EVENT_*`, `CACHE_EVENT_*`, …) - **Module events** (`SESSION_EVENT_*`, `AUTH_EVENT_*`, `CACHE_EVENT_*`, …)
— internal facts emitted by the artifact that owns them. They can — internal facts emitted by the artifact that owns them. They can
@ -346,9 +346,9 @@ on revoke, connections.reauth on identity change) are not bus
re-publications: they live as **orca actions** registered through the re-publications: they live as **orca actions** registered through the
presets in [arts/active-app/presets/](../active-app/presets/). Modules presets in [arts/active-app/presets/](../active-app/presets/). Modules
publish their own typed events (`SESSION_EVENT_*` etc.) directly on publish their own typed events (`SESSION_EVENT_*` etc.) directly on
`App.Bus`; orca subscribes and runs the registered actions. `App.bus`; orca subscribes and runs the registered actions.
## App.Bus is always-present per render scope ## App.bus is always-present per render scope
`aapp` creates the bus automatically; it is not a factory and never `aapp` creates the bus automatically; it is not a factory and never
optional. **The bus is per request on server, per root on client — optional. **The bus is per request on server, per root on client —
@ -402,7 +402,7 @@ flow. Never serialize the bus itself.
### Rule 2 — Inject by context, not by import ### Rule 2 — Inject by context, not by import
Inside Svelte components, `App.Bus` is consumed via context. The helper Inside Svelte components, `App.bus` is consumed via context. The helper
lives in `arts/bus/svelte/context.svelte.ts` and is re-exported from lives in `arts/bus/svelte/context.svelte.ts` and is re-exported from
`$bus`: `$bus`:
@ -428,7 +428,7 @@ Root component sets it; descendants read it:
<!-- src/routes/+layout.svelte --> <!-- src/routes/+layout.svelte -->
<script lang="ts"> <script lang="ts">
import { setBus } from '$bus'; import { setBus } from '$bus';
setBus(App.Bus); setBus(App.bus);
</script> </script>
``` ```
@ -571,7 +571,7 @@ removed that machinery entirely.
The model now is: The model now is:
```txt ```txt
module emits SESSION_EVENT_IDENTITY_CHANGED on App.Bus module emits SESSION_EVENT_IDENTITY_CHANGED on App.bus
-> orca picks up the event (it subscribed lazily on first -> orca picks up the event (it subscribed lazily on first
action registration) action registration)
-> orca runs every action registered for that event -> orca runs every action registered for that event
@ -615,7 +615,7 @@ re-publish, classify, or react. The "consumer rules" reduce to: the
consumer registers an orca action; the bus stays dumb. consumer registers an orca action; the bus stays dumb.
The consequence for module owners writing a new art: publish your The consequence for module owners writing a new art: publish your
`<MODULE>_EVENT_*` events directly on `App.Bus` and document them. If a `<MODULE>_EVENT_*` events directly on `App.bus` and document them. If a
later integration needs to react across modules, the integration ships later integration needs to react across modules, the integration ships
as an orca preset, not as code inside your art. as an orca preset, not as code inside your art.
@ -697,7 +697,7 @@ Two protections:
- Perceptual signals (taxis sema). `SemanticEngine` is a different - Perceptual signals (taxis sema). `SemanticEngine` is a different
registry for a different purpose; do not unify. registry for a different purpose; do not unify.
- Stor's internal entry-bus. Per-`EngineStorage` synchronization stays - Stor's internal entry-bus. Per-`EngineStorage` synchronization stays
inside `stor`; it is not `App.Bus`. inside `stor`; it is not `App.bus`.
## Testing patterns ## Testing patterns

@ -332,7 +332,7 @@ Required behavior:
- After reconnect, auth re-runs if configured. - After reconnect, auth re-runs if configured.
- Reconnect delay must be computed through the shared timer/backoff primitives - Reconnect delay must be computed through the shared timer/backoff primitives
(`$libs/timers.computeBackoffDelay`) and scheduled through injected (`$libs/timers.computeBackoffDelay`) and scheduled through injected
`TimerScheduler`/`App.Timers`, not raw `setTimeout` inside the connection `TimerScheduler`/`App.timers`, not raw `setTimeout` inside the connection
engine. A native fallback is allowed only in the standalone engine path. engine. A native fallback is allowed only in the standalone engine path.
### 1.1.9 Heartbeat ### 1.1.9 Heartbeat
@ -583,7 +583,7 @@ Connection state is runtime state. Do not persist connections in storage.
### 3.6 With `timr` ### 3.6 With `timr`
`arts/conn` should use `App.Timers` when built from `aapp`. Reconnect, `arts/conn` should use `App.timers` when built from `aapp`. Reconnect,
heartbeat and pending-ack timeouts must be keyed timers so app-level debug heartbeat and pending-ack timeouts must be keyed timers so app-level debug
panels and `App.dispose()` can see and cancel them uniformly. panels and `App.dispose()` can see and cancel them uniformly.

@ -78,8 +78,8 @@ const Connections = App.createActiveConnections<AppConnections>();
App inyecta: App inyecta:
- `App.Logger`, como `Logger` común de `$libs/logger`. - `App.logger`, como `Logger` común de `$libs/logger`.
- `App.Timers`, para reconexión, heartbeat y timeouts de ack. - `App.timers`, para reconexión, heartbeat y timeouts de ack.
### Reacción a cambios de identidad ### Reacción a cambios de identidad
@ -355,11 +355,11 @@ The full flow with the orca preset (`applyStandardOrca` or
`applyConnectionsCloseOnRevoke`) is: `applyConnectionsCloseOnRevoke`) is:
```txt ```txt
session -> SESSION_EVENT_IDENTITY_CHANGED on App.Bus session -> SESSION_EVENT_IDENTITY_CHANGED on App.bus
orca -> connections-reauth-on-identity action runs orca -> connections-reauth-on-identity action runs
-> App.connections.reauthenticateAll() -> App.connections.reauthenticateAll()
-> each connection calls auth() with the new credential -> each connection calls auth() with the new credential
session -> SESSION_EVENT_REVOKED on App.Bus session -> SESSION_EVENT_REVOKED on App.bus
orca -> connections-close-on-revoke action runs orca -> connections-close-on-revoke action runs
-> App.connections.closeAll('session-revoked') -> App.connections.closeAll('session-revoked')
``` ```

@ -363,7 +363,7 @@ export interface EngineConnectionsOptions {
* Timer scheduler driving heartbeats, ack timeouts, reconnect * Timer scheduler driving heartbeats, ack timeouts, reconnect
* backoff and reauthentication windows. Required: `arts/conn` does * backoff and reauthentication windows. Required: `arts/conn` does
* not construct its own scheduler. Composition roots pass * not construct its own scheduler. Composition roots pass
* `App.Timers`; standalone callers pass `createEngineTimers()` (or * `App.timers`; standalone callers pass `createEngineTimers()` (or
* a fake clock-driven double in tests). * a fake clock-driven double in tests).
*/ */
readonly timers: TimerScheduler; readonly timers: TimerScheduler;

@ -74,7 +74,7 @@ export interface ActiveCurrencyOptions extends Omit<EngineCurrencyOptions, 'loca
/** /**
* Shorthand for `rates: createRates({ ...ratesOptions, now })`. When set, * Shorthand for `rates: createRates({ ...ratesOptions, now })`. When set,
* the active wrapper builds the rates provider for you and threads the * the active wrapper builds the rates provider for you and threads the
* `clock` injected from `App.Timers` (when wired through * `clock` injected from `App.timers` (when wired through
* `defineActiveFormat`). Ignored when `rates` is also supplied. * `defineActiveFormat`). Ignored when `rates` is also supplied.
*/ */
ratesOptions?: Omit<RatesOptions, 'now'>; ratesOptions?: Omit<RatesOptions, 'now'>;

@ -184,7 +184,7 @@ const http = createEngineHttp({
afterResponse: [], afterResponse: [],
beforeError: [] beforeError: []
}, },
logger: App.Logger // wired automatically when used via App.http logger: App.logger // wired automatically when used via App.http
}); });
``` ```

@ -248,7 +248,7 @@ export interface RetryConfig {
* `Date.now` / `Math.random` / host `setTimeout` / `clearTimeout`; * `Date.now` / `Math.random` / host `setTimeout` / `clearTimeout`;
* tests inject deterministic doubles. App composition typically * tests inject deterministic doubles. App composition typically
* passes `core.timers.clock.now` for `now()` and the host * passes `core.timers.clock.now` for `now()` and the host
* `setTimeout` for timer scheduling — `App.Timers` schedules tasks by * `setTimeout` for timer scheduling — `App.timers` schedules tasks by
* key, which is a different shape from raw `setTimeout`. * key, which is a different shape from raw `setTimeout`.
*/ */
export interface HttpTimerPort { export interface HttpTimerPort {
@ -290,7 +290,7 @@ export interface EngineHttpOptions {
* Replacement for `setTimeout` used to schedule retry delays and * Replacement for `setTimeout` used to schedule retry delays and
* per-attempt / total timeouts. Defaults to `globalThis.setTimeout`. * per-attempt / total timeouts. Defaults to `globalThis.setTimeout`.
* Tests inject a fake-timer adapter; the App composition can route * Tests inject a fake-timer adapter; the App composition can route
* through `App.Timers` if needed. * through `App.timers` if needed.
*/ */
setTimeout?: (handler: () => void, ms?: number) => unknown; setTimeout?: (handler: () => void, ms?: number) => unknown;
/** Replacement for `clearTimeout` paired with `setTimeout` above. */ /** Replacement for `clearTimeout` paired with `setTimeout` above. */

@ -245,7 +245,7 @@ export interface LoggerOptions {
/** /**
* Optional clock used for `failureThrottleMs` window math and for the * Optional clock used for `failureThrottleMs` window math and for the
* `Date` stamp on every `LogEntry`. Defaults to `Date.now`. The Logger * `Date` stamp on every `LogEntry`. Defaults to `Date.now`. The Logger
* is created BEFORE `App.Timers`, so this option exists for tests and * is created BEFORE `App.timers`, so this option exists for tests and
* runtimes that need a deterministic time source — not for App-level * runtimes that need a deterministic time source — not for App-level
* wiring. * wiring.
*/ */

@ -833,9 +833,9 @@ al terminar el run o al hacer `dispose()`.
```ts ```ts
const Orca = createEngineOrca({ const Orca = createEngineOrca({
bus: App.Bus, bus: App.bus,
timers: App.Timers, timers: App.timers,
logger: App.Logger logger: App.logger
}); });
``` ```
@ -848,7 +848,7 @@ orcaTimerKey(runId, stage, actionId, ORCA_TIMER_ACTION_TIMEOUT);
Si `orca` crea timers sobre un scheduler inyectado, no es propietario del Si `orca` crea timers sobre un scheduler inyectado, no es propietario del
scheduler. `Orca.dispose()` cancela los timers registrados por `orca`, pero no scheduler. `Orca.dispose()` cancela los timers registrados por `orca`, pero no
destruye `App.Timers`. destruye `App.timers`.
## Transacciones ## Transacciones
@ -1006,7 +1006,7 @@ el bundle base. No se usara dynamic import para el nucleo v0; la complejidad de
un proxy async no compensa si el engine inerte es pequeno. un proxy async no compensa si el engine inerte es pequeno.
`Bus` tambien debe ser un recurso siempre presente e inerte. Si una app tiene `Bus` tambien debe ser un recurso siempre presente e inerte. Si una app tiene
`App.Orchestration`, debe tener `App.Bus`. `App.Orchestration`, debe tener `App.bus`.
Uso explicito: Uso explicito:
@ -1241,8 +1241,8 @@ cuando uno falla; y comprueba que el detach de
- `orca` no conoce módulos de negocio. - `orca` no conoce módulos de negocio.
- Los artefactos no consumen `orca`; solo publican eventos en `bus`. - Los artefactos no consumen `orca`; solo publican eventos en `bus`.
- La aplicación registra acciones en `orca`. - La aplicación registra acciones en `orca`.
- `App.Orca` existe siempre, pero no ejecuta nada sin acciones. - `App.orca` existe siempre, pero no ejecuta nada sin acciones.
- `App.Bus` debe existir si existe `App.Orca`. - `App.bus` debe existir si existe `App.orca`.
- Todas las strings públicas viven en constantes. - Todas las strings públicas viven en constantes.
- Los eventos son constantes, no strings inline. - Los eventos son constantes, no strings inline.
- Los tokens son constantes, no strings inline. - Los tokens son constantes, no strings inline.

@ -584,7 +584,7 @@ await App.perm.check({ action, resource, context });
``` ```
`defineActivePerm(...)` makes the App builder inject `App.http`, `defineActivePerm(...)` makes the App builder inject `App.http`,
`App.Logger` and `App.Bus`. The endpoint remains explicit because the `App.logger` and `App.bus`. The endpoint remains explicit because the
client is remote by design. client is remote by design.
Active client API (use `App.perm` once registered, or a freestanding Active client API (use `App.perm` once registered, or a freestanding
@ -684,7 +684,7 @@ applyPermInvalidateOnIdentityChange(App);
``` ```
Tenant switches and "permissions refreshed" notifications are app-defined Tenant switches and "permissions refreshed" notifications are app-defined
events on `App.Bus`. Register a custom orca action that calls events on `App.bus`. Register a custom orca action that calls
`App.perm.invalidate()` (and any other affected services) when those `App.perm.invalidate()` (and any other affected services) when those
events fire — there is no built-in preset for them yet. events fire — there is no built-in preset for them yet.

@ -1,10 +1,37 @@
# Prefs # Prefs
`prefs` is the Active preference resolution module. `prefs` is the Active preference resolution module. It is part of the
core (`App.prefs`) and is generic over a user-defined `PrefsSchema =
It is intentionally isolated for now. It is not wired into `active-app`, and no Record<string, PrefsDimension<TIntent, TEffective>>`.
existing artifact should consume it until the contracts are implemented and the
surrounding modules are ready to receive narrow preference ports. > **Heads-up:** the sections below describe the original four-layer
> design (capabilities + environment + intent → effective). The current
> implementation is **schema-based**: each preference is a
> `PrefsDimension` that owns its own validator, environment-fed
> resolver and (optionally) sibling-derived value. Built-in dimensions
> (`localeDimension`, `themeDimension`, …) live in
> `arts/prefs/dimensions/*` and the `standardPrefsDimensions(catalog)`
> preset composes the canonical set. The active surface exposes one
> slot per schema key with `.get()` / `.set()` / `.clear()` /
> `.onChange()` verbs:
>
> ```ts
> createActiveApp({
> prefs: {
> schema: {
> ...standardPrefsDimensions({ languages, locales, currencies }),
> sidebarCollapsed: booleanDimension({ default: false })
> }
> }
> });
>
> App.prefs.locale.get();
> App.prefs.locale.set('es-ES');
> App.prefs.sidebarCollapsed.set(true);
> ```
>
> The historical text below is kept for archival reference until this
> README is rewritten in full.
## Core Rule ## Core Rule
@ -533,7 +560,7 @@ Later, `active-app` can bridge this to `Bus`:
```ts ```ts
Prefs.subscribe((event) => { Prefs.subscribe((event) => {
App.Bus.publish(PREFS_EVENT_CHANGED, event); App.bus.publish(PREFS_EVENT_CHANGED, event);
}); });
``` ```

@ -1,12 +1,43 @@
import type { import type {
PrefsCapabilities, PrefsChangeHandler,
PrefsEffective, PrefsDimension,
PrefsEffectiveOf,
PrefsEnvironment, PrefsEnvironment,
PrefsIntent, PrefsIntentOf,
PrefsSnapshot PrefsSchema,
PrefsSnapshot,
PrefsUnsubscribe
} from '$libs/prefs'; } from '$libs/prefs';
import { createEnginePrefs } from './engine-prefs.ts'; import { createEnginePrefs } from './engine-prefs.ts';
import type { EnginePrefs, EnginePrefsOptions } from './types.ts'; import { PREFS_KIND } from './consts.ts';
import { PrefsReservedKeyError, reservedKeyErrorMessage } from './errors.ts';
import type { EnginePrefsOptions } from './types.ts';
/**
* Per-dimension active surface. Every key in the schema becomes one of
* these on the parent `ActivePrefs<S>`, addressable as
* `App.prefs.<key>`. The verbs are uniform across every dimension: get
* the effective value, set / clear user intent, listen for changes.
*/
export interface ActivePrefsDimension<TIntent, TEffective = TIntent> {
/** Current effective value (intent → environment → default). */
get(): TEffective;
/**
* Set explicit user intent for this dimension. Validates first;
* throws `PrefsIntentInvalidError` on rejection.
*/
set(value: TIntent): void;
/** Drop user intent. Falls back to environment / default. */
clear(): void;
/**
* Subscribe to commits where this dimension's effective value
* changed. Returns an unsubscribe function. Best-effort: a handler
* that throws does not block its peers.
*/
onChange(handler: (value: TEffective) => void): PrefsUnsubscribe;
/** Optional capability catalog when the dimension exposes one. */
catalog(): readonly TIntent[] | undefined;
}
/** /**
* Reactive view of `EnginePrefs.state`. Backed by `$state` cells inside * Reactive view of `EnginePrefs.state`. Backed by `$state` cells inside
@ -17,65 +48,138 @@ import type { EnginePrefs, EnginePrefsOptions } from './types.ts';
* storage bridge in particular). With a sync-only engine they stay * storage bridge in particular). With a sync-only engine they stay
* `false` / `null`. * `false` / `null`.
*/ */
export interface ActivePrefsState { export interface ActivePrefsState<S extends PrefsSchema> {
readonly snapshot: PrefsSnapshot; readonly snapshot: PrefsSnapshot<S>;
readonly effective: PrefsEffective; readonly effective: PrefsEffectiveOf<S>;
readonly capabilities: PrefsCapabilities;
readonly environment: PrefsEnvironment; readonly environment: PrefsEnvironment;
readonly intent: Readonly<PrefsIntent>; readonly intent: PrefsIntentOf<S>;
readonly version: number;
readonly pending: boolean; readonly pending: boolean;
readonly lastError: unknown; readonly lastError: unknown;
} }
/** /**
* Svelte rune adapter over `EnginePrefs`. Forwards every engine method * Reserved members of `ActivePrefs<S>`. A schema key that matches one
* verbatim and adds a reactive `state` block that templates can read * of these would shadow the active surface — the constructor throws
* without manual subscription. * `PrefsReservedKeyError` so the misconfiguration fails fast.
* */
* The contract is intentionally a superset of `EnginePrefs` — server export const ACTIVE_PREFS_RESERVED_KEYS: readonly string[] = [
* code that imports just the engine remains free of `.svelte.ts` 'kind',
* runtime, while UI code uses `ActivePrefs` and gets reactivity for 'schema',
* free. 'state',
'snapshot',
'environment',
'intent',
'effective',
'resetIntent',
'refreshEnvironment',
'patchEnvironment',
'subscribe',
'dispose'
];
const RESERVED_SET = new Set(ACTIVE_PREFS_RESERVED_KEYS);
/**
* Reactive Svelte adapter over `EnginePrefs<S>`. Adds the
* `ActivePrefsState` block and exposes one `ActivePrefsDimension` per
* schema key as a property — i.e. for `schema = { locale, theme }` the
* returned object has `.locale.get()` / `.locale.set(...)` /
* `.theme.get()` / etc., type-checked against each dimension's
* `TIntent` and `TEffective`.
*/
/**
* Top-level (non-dimension) surface. Always present regardless of the
* schema. Service factories see this shape via `core.prefs` and read
* specific dimensions defensively at runtime through their string key.
*/ */
export interface ActivePrefs extends EnginePrefs { export interface ActivePrefsBase<S extends PrefsSchema = PrefsSchema> {
readonly state: ActivePrefsState; readonly kind: typeof PREFS_KIND;
readonly schema: S;
readonly state: ActivePrefsState<S>;
snapshot(): PrefsSnapshot<S>;
environment(): PrefsEnvironment;
intent(): PrefsIntentOf<S>;
effective(): PrefsEffectiveOf<S>;
/**
* Low-level mutator. The recommended public API is
* `App.prefs.<dim>.set(value)` — this stays exposed only for
* adapters (storage bridge, devtools) that operate generically over
* dimension keys.
*/
setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S>;
/** Low-level mutator; prefer `App.prefs.<dim>.clear()`. */
clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S>;
resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S>;
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S>;
patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot<S>;
subscribe(handler: PrefsChangeHandler<S>): PrefsUnsubscribe;
dispose(): void;
} }
export function createActivePrefs(options: EnginePrefsOptions): ActivePrefs { /**
* Dimension surface — one slot per schema key. Only meaningful when
* `S` is a concrete schema literal. With the open `PrefsSchema`
* (`Record<string, PrefsDimension>`) the mapped type would collide
* with the index signature on the base, so we degrade to `unknown`
* for the open case (`X & unknown = X`).
*
* Service factories that need typed dimensions cast through their own
* schema generic; consumers that read `App.prefs.<key>` get the typed
* surface because `App` flows the concrete `TPrefsSchema`.
*/
export type ActivePrefsDimensions<S extends PrefsSchema> = string extends keyof S
? unknown
: {
readonly [K in keyof S]: S[K] extends PrefsDimension<infer TIntent, infer TEffective>
? ActivePrefsDimension<TIntent, TEffective>
: never;
};
export type ActivePrefs<S extends PrefsSchema = PrefsSchema> = ActivePrefsBase<S> &
ActivePrefsDimensions<S>;
export function createActivePrefs<S extends PrefsSchema>(
options: EnginePrefsOptions<S>
): ActivePrefs<S> {
for (const key of Object.keys(options.schema)) {
if (RESERVED_SET.has(key)) {
throw new PrefsReservedKeyError(key, reservedKeyErrorMessage(key));
}
}
const engine = createEnginePrefs(options); const engine = createEnginePrefs(options);
let snapshotCell = $state<PrefsSnapshot>(engine.snapshot()); let snapshotCell = $state<PrefsSnapshot<S>>(engine.snapshot());
// `pending` / `lastError` are placeholders today (sync engine has // `pending` / `lastError` are placeholders today (sync engine has
// nothing async to track). Declared as `let` so async adapters // nothing async to track). Declared as `let` so async adapters
// (storage bridge in particular) can flip them when wired in // (storage bridge in particular) can flip them when wired in
// without restructuring the rune layout. // without restructuring the rune layout.
let pendingCell = $state(false); const pendingCell = $state(false);
let lastErrorCell = $state<unknown>(null); const lastErrorCell = $state<unknown>(null);
// Mirror every commit into the reactive cell. Reading `snapshotCell`
// inside a `$derived` or template re-runs whenever a write produces
// a new snapshot; no-ops in the engine skip this notification, so
// we don't trigger spurious reactivity.
const detachCommit = engine.subscribe((event) => { const detachCommit = engine.subscribe((event) => {
snapshotCell = event.next; snapshotCell = event.next;
}); });
const state: ActivePrefsState = { const state: ActivePrefsState<S> = {
get snapshot() { get snapshot() {
return snapshotCell; return snapshotCell;
}, },
get effective() { get effective() {
return snapshotCell.effective; return snapshotCell.effective;
}, },
get capabilities() {
return snapshotCell.capabilities;
},
get environment() { get environment() {
return snapshotCell.environment; return snapshotCell.environment;
}, },
get intent() { get intent() {
return snapshotCell.intent; return snapshotCell.intent;
}, },
get version() {
return snapshotCell.version;
},
get pending() { get pending() {
return pendingCell; return pendingCell;
}, },
@ -84,54 +188,59 @@ export function createActivePrefs(options: EnginePrefsOptions): ActivePrefs {
} }
}; };
const active: ActivePrefs = { const dimensionMembers: Record<string, ActivePrefsDimension<unknown, unknown>> = {};
kind: engine.kind, for (const key of Object.keys(options.schema)) {
state, dimensionMembers[key] = makeDimension(engine, key);
}
snapshot() { const base = {
return engine.snapshot(); kind: PREFS_KIND as typeof PREFS_KIND,
}, schema: options.schema,
capabilities() { state,
return engine.capabilities(); snapshot: () => engine.snapshot(),
}, environment: () => engine.environment(),
environment() { intent: () => engine.intent(),
return engine.environment(); effective: () => engine.effective(),
}, setIntent: <K extends keyof S>(key: K, value: unknown) => engine.setIntent(key, value),
intent() { clearIntent: <K extends keyof S>(key: K) => engine.clearIntent(key),
return engine.intent(); resetIntent: (next?: PrefsIntentOf<S>) => engine.resetIntent(next),
}, refreshEnvironment: (next: PrefsEnvironment) => engine.refreshEnvironment(next),
effective() { patchEnvironment: (patch: Partial<PrefsEnvironment>) => engine.patchEnvironment(patch),
return engine.effective(); subscribe: (handler: PrefsChangeHandler<S>) => engine.subscribe(handler),
}, dispose: () => {
setIntent(key, value) {
return engine.setIntent(key, value);
},
clearIntent(key) {
return engine.clearIntent(key);
},
resetIntent(next) {
return engine.resetIntent(next);
},
refreshEnvironment(next) {
return engine.refreshEnvironment(next);
},
patchEnvironment(patch) {
return engine.patchEnvironment(patch);
},
setCapabilities(next) {
return engine.setCapabilities(next);
},
subscribe(handler) {
return engine.subscribe(handler);
},
dispose() {
detachCommit(); detachCommit();
engine.dispose(); engine.dispose();
} }
}; };
return active; return Object.assign(base, dimensionMembers) as unknown as ActivePrefs<S>;
}
function makeDimension(
engine: ReturnType<typeof createEnginePrefs>,
key: string
): ActivePrefsDimension<unknown, unknown> {
return {
get() {
return (engine.snapshot().effective as Record<string, unknown>)[key];
},
set(value: unknown) {
engine.setIntent(key, value);
},
clear() {
engine.clearIntent(key);
},
onChange(handler: (value: unknown) => void) {
return engine.subscribe((event) => {
const diff = event.effectiveDiff as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(diff, key)) {
handler(diff[key]);
}
});
},
catalog() {
const dim = engine.schema[key];
return dim?.catalog?.();
}
};
} }

@ -1,4 +1,4 @@
import type { PrefsEnvironment } from '$libs/prefs'; import type { PrefsEnvironment, PrefsSchema } from '$libs/prefs';
import type { EnginePrefs } from '../types.ts'; import type { EnginePrefs } from '../types.ts';
/** /**
@ -142,8 +142,8 @@ export function watchBrowserEnvironment(
* onMount(() => applyBrowserEnvironment(App.prefs)); * onMount(() => applyBrowserEnvironment(App.prefs));
* ``` * ```
*/ */
export function applyBrowserEnvironment( export function applyBrowserEnvironment<S extends PrefsSchema>(
engine: EnginePrefs, engine: EnginePrefs<S>,
overrides: BrowserEnvironmentOverrides = {} overrides: BrowserEnvironmentOverrides = {}
): () => void { ): () => void {
engine.refreshEnvironment(detectBrowserEnvironment(overrides)); engine.refreshEnvironment(detectBrowserEnvironment(overrides));

@ -1,4 +1,4 @@
import type { PrefsIntent } from '$libs/prefs'; import type { PrefsIntentOf, PrefsSchema } from '$libs/prefs';
import type { EnginePrefs } from '../types.ts'; import type { EnginePrefs } from '../types.ts';
/** /**
@ -14,18 +14,21 @@ import type { EnginePrefs } from '../types.ts';
* *
* Methods may be sync or async. The bridge always awaits the result * Methods may be sync or async. The bridge always awaits the result
* before applying. * before applying.
*
* Generic over the schema so persisted intent type-checks against the
* dimensions the engine knows about.
*/ */
export interface PrefsIntentStorage { export interface PrefsIntentStorage<S extends PrefsSchema = PrefsSchema> {
load(): PrefsIntent | null | undefined | Promise<PrefsIntent | null | undefined>; load(): PrefsIntentOf<S> | null | undefined | Promise<PrefsIntentOf<S> | null | undefined>;
save(intent: PrefsIntent): void | Promise<void>; save(intent: PrefsIntentOf<S>): void | Promise<void>;
clear(): void | Promise<void>; clear(): void | Promise<void>;
} }
export type PrefsStorageOp = 'load' | 'save' | 'clear'; export type PrefsStorageOp = 'load' | 'save' | 'clear';
export interface PrefsStorageBridgeOptions { export interface PrefsStorageBridgeOptions<S extends PrefsSchema = PrefsSchema> {
readonly engine: EnginePrefs; readonly engine: EnginePrefs<S>;
readonly storage: PrefsIntentStorage; readonly storage: PrefsIntentStorage<S>;
/** /**
* Called when any storage op throws. Storage failures must not * Called when any storage op throws. Storage failures must not
* corrupt the in-memory engine — the bridge swallows the error and * corrupt the in-memory engine — the bridge swallows the error and
@ -52,7 +55,7 @@ export interface PrefsStorageBridge {
} }
/** /**
* Wire an `EnginePrefs` to a storage backend. On construction: * Wire an `EnginePrefs<S>` to a storage backend. On construction:
* *
* 1. Subscribes to engine commits and persists `intent` (only intent — * 1. Subscribes to engine commits and persists `intent` (only intent —
* never `environment`, never `effective`). * never `environment`, never `effective`).
@ -69,8 +72,8 @@ export interface PrefsStorageBridge {
* persist via `save`. An empty intent calls `clear()` instead of * persist via `save`. An empty intent calls `clear()` instead of
* `save({})` so storage backends can drop the entry. * `save({})` so storage backends can drop the entry.
*/ */
export function createPrefsStorageBridge( export function createPrefsStorageBridge<S extends PrefsSchema>(
options: PrefsStorageBridgeOptions options: PrefsStorageBridgeOptions<S>
): PrefsStorageBridge { ): PrefsStorageBridge {
const { engine, storage, onError, skipHydrate = false } = options; const { engine, storage, onError, skipHydrate = false } = options;
@ -104,7 +107,8 @@ export function createPrefsStorageBridge(
if (event.previous.intent === event.next.intent) return; if (event.previous.intent === event.next.intent) return;
const next = event.next.intent; const next = event.next.intent;
const isEmpty = Object.keys(next).every((k) => (next as Record<string, unknown>)[k] === undefined); const nextRecord = next as Record<string, unknown>;
const isEmpty = Object.keys(nextRecord).every((k) => nextRecord[k] === undefined);
const op = isEmpty ? 'clear' : 'save'; const op = isEmpty ? 'clear' : 'save';
const run = async (): Promise<void> => { const run = async (): Promise<void> => {
@ -116,10 +120,9 @@ export function createPrefsStorageBridge(
} }
}; };
// Serialize saves so two rapid commits don't race in a // Serialize saves so two rapid commits don't race in a backend
// backend that doesn't internally guarantee order. The chain // that doesn't internally guarantee order. The chain is best-
// is best-effort — failures are reported and don't stall // effort — failures are reported and don't stall further saves.
// further saves.
pendingSave = (pendingSave ?? Promise.resolve()).then(run, run); pendingSave = (pendingSave ?? Promise.resolve()).then(run, run);
}); });

@ -28,4 +28,3 @@ export const PREFS_ENGINE_METHOD_CLEAR_INTENT = 'clearIntent';
export const PREFS_ENGINE_METHOD_RESET_INTENT = 'resetIntent'; export const PREFS_ENGINE_METHOD_RESET_INTENT = 'resetIntent';
export const PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT = 'refreshEnvironment'; export const PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT = 'refreshEnvironment';
export const PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT = 'patchEnvironment'; export const PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT = 'patchEnvironment';
export const PREFS_ENGINE_METHOD_SET_CAPABILITIES = 'setCapabilities';

@ -0,0 +1,41 @@
import { currencyFromLocales, type Currency } from '$libs/currency';
import type { PrefsDimension } from '$libs/prefs';
export interface CurrencyDimensionOptions {
readonly catalog: readonly Currency[];
readonly default?: Currency;
}
/**
* Built-in currency dimension. Validates against the catalog. Falls
* back to `env.currency` (when in catalog), then walks
* `env.locales[]` via `currencyFromLocales` to derive a regional
* default, then `default`.
*/
export function currencyDimension(
options: CurrencyDimensionOptions
): PrefsDimension<Currency> {
const catalog = options.catalog;
const fallback = options.default ?? catalog[0];
if (fallback === undefined) {
throw new TypeError('currencyDimension(): catalog must not be empty.');
}
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'unsupported_currency' };
if (!catalog.includes(value as Currency)) {
return { ok: false, reason: 'unsupported_currency' };
}
return { ok: true, value: value as Currency };
},
resolve(intent, env) {
if (intent !== undefined) return intent;
if (env.currency !== undefined && catalog.includes(env.currency)) {
return env.currency;
}
return currencyFromLocales(env.locales ?? [], catalog, fallback);
},
catalog: () => catalog
};
}

@ -0,0 +1,25 @@
import { DENSITIES, type Density } from '$libs/density';
import type { PrefsDimension } from '$libs/prefs';
export interface DensityDimensionOptions {
readonly catalog?: readonly Density[];
readonly default?: Density;
}
export function densityDimension(
options: DensityDimensionOptions = {}
): PrefsDimension<Density> {
const catalog = options.catalog ?? DENSITIES;
const fallback = options.default ?? 'comfortable';
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'unsupported_density' };
if (!catalog.includes(value as Density)) {
return { ok: false, reason: 'unsupported_density' };
}
return { ok: true, value: value as Density };
},
catalog: () => catalog
};
}

@ -0,0 +1,42 @@
import { directionFromLanguage, DIRECTIONS, type Direction } from '$libs/direction';
import type { Locale } from '$libs/locale';
import type { PrefsDimension } from '$libs/prefs';
export interface DirectionDimensionOptions {
/**
* Schema key for the language dimension that drives derivation.
* Defaults to `'language'`. Apps that name their language slot
* differently (or that want direction tied to `locale` instead of
* `language`) override this.
*/
readonly languageKey?: string;
readonly default?: Direction;
}
/**
* Built-in direction dimension. Derived from the resolved language
* (RTL languages → `'rtl'`, everything else → `'ltr'`). User intent is
* still allowed to override — the resolver checks intent first, then
* the derive hook, then the default.
*/
export function directionDimension(
options: DirectionDimensionOptions = {}
): PrefsDimension<Direction> {
const langKey = options.languageKey ?? 'language';
const fallback = options.default ?? 'ltr';
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'unsupported_direction' };
if (!DIRECTIONS.includes(value as Direction)) {
return { ok: false, reason: 'unsupported_direction' };
}
return { ok: true, value: value as Direction };
},
derive(effective) {
const resolvedLanguage = effective[langKey];
if (typeof resolvedLanguage !== 'string') return fallback;
return directionFromLanguage(resolvedLanguage as Locale);
}
};
}

@ -0,0 +1,27 @@
/**
* Built-in `PrefsDimension` catalog. Each dimension is its own factory
* — `localeDimension({...})`, `themeDimension({...})` — and the
* `standardPrefsDimensions(catalog)` preset composes the canonical set.
*
* Application-specific dimensions go alongside these, either by reusing
* the primitive factories (`booleanDimension`, `enumDimension`, …) or
* by writing a bespoke `PrefsDimension<TIntent, TEffective>` from
* scratch. The engine treats every dimension uniformly.
*/
export { localeDimension } from './locale.ts';
export { languageDimension } from './language.ts';
export { themeDimension } from './theme.ts';
export { densityDimension } from './density.ts';
export { motionDimension } from './motion.ts';
export { timezoneDimension } from './timezone.ts';
export { currencyDimension } from './currency.ts';
export { unitSystemDimension } from './unit-system.ts';
export { directionDimension } from './direction.ts';
export {
booleanDimension,
enumDimension,
numberDimension,
stringDimension
} from './primitive.ts';

@ -0,0 +1,47 @@
import { matchLocale, type Locale } from '$libs/locale';
import type { PrefsDimension } from '$libs/prefs';
export interface LanguageDimensionOptions {
/**
* BCP-47 tags Lang has translations for. Distinct from
* `localeDimension.catalog` — language is the i18n choice, locale is
* the regional formatting choice.
*/
readonly catalog: readonly Locale[];
readonly default?: Locale;
}
/**
* Built-in translation-language dimension. Mirrors `localeDimension`
* with a separate catalog so language and locale stay orthogonal.
*/
export function languageDimension(
options: LanguageDimensionOptions
): PrefsDimension<Locale> {
const catalog = options.catalog;
const fallback = options.default ?? catalog[0];
if (fallback === undefined) {
throw new TypeError('languageDimension(): catalog must not be empty.');
}
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') {
return { ok: false, reason: 'unsupported_language' };
}
if (!catalog.includes(value as Locale)) {
return { ok: false, reason: 'unsupported_language' };
}
return { ok: true, value: value as Locale };
},
resolve(intent, env) {
if (intent !== undefined) return intent;
return matchLocale({
candidates: env.locales ?? [],
available: catalog,
fallback
});
},
catalog: () => catalog
};
}

@ -0,0 +1,49 @@
import { matchLocale, type Locale } from '$libs/locale';
import type { PrefsDimension } from '$libs/prefs';
export interface LocaleDimensionOptions {
/**
* Allowed locales. Order is significant: `default` defaults to the
* first entry, and `matchLocale` walks the catalog in order.
*/
readonly catalog: readonly Locale[];
/**
* Final fallback. Must be present in `catalog`. Defaults to
* `catalog[0]` when omitted.
*/
readonly default?: Locale;
}
/**
* Built-in regional-formatting locale dimension. Validates against
* `catalog`, falls back to `matchLocale` on `environment.locales[]`
* when no intent is set.
*/
export function localeDimension(options: LocaleDimensionOptions): PrefsDimension<Locale> {
const catalog = options.catalog;
const fallback = options.default ?? catalog[0];
if (fallback === undefined) {
throw new TypeError('localeDimension(): catalog must not be empty.');
}
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') {
return { ok: false, reason: 'unsupported_locale' };
}
if (!catalog.includes(value as Locale)) {
return { ok: false, reason: 'unsupported_locale' };
}
return { ok: true, value: value as Locale };
},
resolve(intent, env) {
if (intent !== undefined) return intent;
return matchLocale({
candidates: env.locales ?? [],
available: catalog,
fallback
});
},
catalog: () => catalog
};
}

@ -0,0 +1,39 @@
import {
resolveMotion,
MOTIONS_INTENT,
type MotionEffective,
type MotionIntent
} from '$libs/motion';
import type { PrefsDimension } from '$libs/prefs';
export interface MotionDimensionOptions {
readonly intents?: readonly MotionIntent[];
readonly default?: MotionEffective;
}
/**
* Built-in motion dimension. Like theme, intent includes `'system'`
* but effective is narrower (`'allow' | 'reduce'`); the dimension's
* `resolve` folds intent + `env.reducedMotion` into the effective
* value.
*/
export function motionDimension(
options: MotionDimensionOptions = {}
): PrefsDimension<MotionIntent, MotionEffective> {
const intents = options.intents ?? MOTIONS_INTENT;
const fallback = options.default ?? 'allow';
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'unsupported_motion' };
if (!intents.includes(value as MotionIntent)) {
return { ok: false, reason: 'unsupported_motion' };
}
return { ok: true, value: value as MotionIntent };
},
resolve(intent, env) {
return resolveMotion(intent, env.reducedMotion, fallback);
},
catalog: () => intents
};
}

@ -0,0 +1,95 @@
import type { PrefsDimension } from '$libs/prefs';
/**
* Boolean dimension — for on/off toggles like `sidebarCollapsed`,
* `betaFeaturesEnabled`. Validation rejects everything that isn't a
* literal boolean.
*/
export function booleanDimension(options: {
default: boolean;
}): PrefsDimension<boolean> {
return {
defaultValue: options.default,
validate(value) {
if (typeof value !== 'boolean') return { ok: false, reason: 'invalid_boolean' };
return { ok: true, value };
}
};
}
/**
* Enum dimension over a fixed string union. Use `as const` on the
* `values` array so TypeScript infers a literal type.
*
* ```ts
* const dim = enumDimension(['all', 'mentions', 'none'] as const, {
* default: 'mentions'
* });
* App.prefs.notificationLevel.set('mentions'); // typed
* ```
*/
export function enumDimension<T extends string>(
values: readonly T[],
options: { default: T }
): PrefsDimension<T> {
return {
defaultValue: options.default,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'invalid_enum' };
if (!values.includes(value as T)) return { ok: false, reason: 'invalid_enum' };
return { ok: true, value: value as T };
},
catalog: () => values
};
}
/**
* Free-form string dimension. Optional `pattern` validation. Useful
* for "username" or other strings without a closed catalog.
*/
export function stringDimension(options: {
default: string;
pattern?: RegExp;
maxLength?: number;
}): PrefsDimension<string> {
const { default: fallback, pattern, maxLength } = options;
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'invalid_string' };
if (maxLength !== undefined && value.length > maxLength) {
return { ok: false, reason: 'invalid_string' };
}
if (pattern !== undefined && !pattern.test(value)) {
return { ok: false, reason: 'invalid_string' };
}
return { ok: true, value };
}
};
}
/**
* Number dimension. Optional `min` / `max` bounds and `integer`
* constraint. Rejects `NaN`, `Infinity` and non-number inputs.
*/
export function numberDimension(options: {
default: number;
min?: number;
max?: number;
integer?: boolean;
}): PrefsDimension<number> {
const { default: fallback, min, max, integer } = options;
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'number') return { ok: false, reason: 'invalid_number' };
if (!Number.isFinite(value)) return { ok: false, reason: 'invalid_number' };
if (integer === true && !Number.isInteger(value)) {
return { ok: false, reason: 'invalid_number' };
}
if (min !== undefined && value < min) return { ok: false, reason: 'invalid_number' };
if (max !== undefined && value > max) return { ok: false, reason: 'invalid_number' };
return { ok: true, value };
}
};
}

@ -0,0 +1,48 @@
import type { PrefsDimension } from '$libs/prefs';
import {
resolveTheme,
THEMES_INTENT,
type ThemeEffective,
type ThemeIntent
} from '$libs/theme';
export interface ThemeDimensionOptions {
/**
* Allowed intent values. Defaults to the full `THEMES_INTENT`
* (`'light' | 'dark' | 'system'`). Apps that don't want users to
* follow the OS pass `['light', 'dark']`.
*/
readonly intents?: readonly ThemeIntent[];
/**
* Final fallback applied when neither intent nor `env.colorScheme`
* provide a value. Must be a `ThemeEffective`.
*/
readonly default?: ThemeEffective;
}
/**
* Built-in theme dimension. `TIntent` includes `'system'`, `TEffective`
* does not — the dimension's `resolve` folds `'system'` (and the no-
* intent case) into a concrete `'light' | 'dark'` using
* `env.colorScheme`.
*/
export function themeDimension(
options: ThemeDimensionOptions = {}
): PrefsDimension<ThemeIntent, ThemeEffective> {
const intents = options.intents ?? THEMES_INTENT;
const fallback = options.default ?? 'light';
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'unsupported_theme' };
if (!intents.includes(value as ThemeIntent)) {
return { ok: false, reason: 'unsupported_theme' };
}
return { ok: true, value: value as ThemeIntent };
},
resolve(intent, env) {
return resolveTheme(intent, env.colorScheme, fallback);
},
catalog: () => intents
};
}

@ -0,0 +1,58 @@
import type { PrefsDimension } from '$libs/prefs';
import type { Timezone } from '$libs/timezone';
export interface TimezoneDimensionOptions {
/**
* Optional allowlist of IANA zones. When omitted, any value that
* canonicalises through `Intl.DateTimeFormat` is accepted.
*/
readonly catalog?: readonly Timezone[];
readonly default?: Timezone;
}
/**
* Canonicalise a timezone via `Intl.DateTimeFormat`. Returns
* `undefined` when the value is not a recognisable IANA zone — the
* dimension treats that as `'invalid_timezone'`.
*/
function canonicalize(value: string): string | undefined {
try {
return new Intl.DateTimeFormat('en-US', { timeZone: value })
.resolvedOptions()
.timeZone;
} catch {
return undefined;
}
}
export function timezoneDimension(
options: TimezoneDimensionOptions = {}
): PrefsDimension<Timezone> {
const catalog = options.catalog;
const fallback = options.default ?? ('UTC' as Timezone);
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'invalid_timezone' };
const canonical = canonicalize(value);
if (canonical === undefined) return { ok: false, reason: 'invalid_timezone' };
if (catalog !== undefined && !catalog.includes(canonical as Timezone)) {
return { ok: false, reason: 'unsupported_timezone' };
}
return { ok: true, value: canonical as Timezone };
},
resolve(intent, env) {
if (intent !== undefined) return intent;
if (env.timezone !== undefined) {
const canonical = canonicalize(env.timezone);
if (canonical !== undefined) {
if (catalog === undefined || catalog.includes(canonical as Timezone)) {
return canonical as Timezone;
}
}
}
return fallback;
},
catalog: catalog === undefined ? undefined : () => catalog
};
}

@ -0,0 +1,32 @@
import type { PrefsDimension } from '$libs/prefs';
import { unitSystemFromLocales, UNIT_SYSTEMS, type UnitSystem } from '$libs/units';
export interface UnitSystemDimensionOptions {
readonly catalog?: readonly UnitSystem[];
readonly default?: UnitSystem;
}
export function unitSystemDimension(
options: UnitSystemDimensionOptions = {}
): PrefsDimension<UnitSystem> {
const catalog = options.catalog ?? UNIT_SYSTEMS;
const fallback = options.default ?? 'metric';
return {
defaultValue: fallback,
validate(value) {
if (typeof value !== 'string') return { ok: false, reason: 'unsupported_unit_system' };
if (!catalog.includes(value as UnitSystem)) {
return { ok: false, reason: 'unsupported_unit_system' };
}
return { ok: true, value: value as UnitSystem };
},
resolve(intent, env) {
if (intent !== undefined) return intent;
if (env.unitSystem !== undefined && catalog.includes(env.unitSystem)) {
return env.unitSystem;
}
return unitSystemFromLocales(env.locales ?? [], catalog, fallback);
},
catalog: () => catalog
};
}

@ -1,65 +1,61 @@
import { import {
resolvePrefs, resolvePrefs,
validateIntentValue, sanitizeIntent,
type PrefsCapabilities,
type PrefsChangeCause, type PrefsChangeCause,
type PrefsChangeEvent, type PrefsChangeEvent,
type PrefsChangeHandler, type PrefsChangeHandler,
type PrefsEffective, type PrefsEffectiveOf,
type PrefsEnvironment, type PrefsEnvironment,
type PrefsIntent, type PrefsIntentOf,
type PrefsSchema,
type PrefsSnapshot, type PrefsSnapshot,
type PrefsUnsubscribe, type PrefsUnsubscribe
type PrefsValidationFailure
} from '$libs/prefs'; } from '$libs/prefs';
import { import {
PREFS_ENGINE_METHOD_CLEAR_INTENT, PREFS_ENGINE_METHOD_CLEAR_INTENT,
PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT, PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT,
PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT, PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT,
PREFS_ENGINE_METHOD_RESET_INTENT, PREFS_ENGINE_METHOD_RESET_INTENT,
PREFS_ENGINE_METHOD_SET_CAPABILITIES,
PREFS_ENGINE_METHOD_SET_INTENT, PREFS_ENGINE_METHOD_SET_INTENT,
PREFS_KIND PREFS_KIND
} from './consts.ts'; } from './consts.ts';
import { import {
PrefsCapabilitiesInvalidError,
PrefsDisposedError, PrefsDisposedError,
PrefsIntentInvalidError, PrefsIntentInvalidError,
capabilitiesInvalidErrorMessage, PrefsUnknownDimensionError,
disposedErrorMessage, disposedErrorMessage,
intentInvalidErrorMessage intentInvalidErrorMessage,
unknownDimensionErrorMessage
} from './errors.ts'; } from './errors.ts';
import type { EnginePrefs, EnginePrefsOptions } from './types.ts'; import type { EnginePrefs, EnginePrefsOptions } from './types.ts';
/** /**
* Build a fresh `EnginePrefs`. Runes-free — safe to import from * Build a fresh `EnginePrefs<S>` for the given schema. Runes-free —
* server-only modules. The Active wrapper layers reactive state on top. * safe to import from server-only modules. The Active wrapper layers
* reactive state and the per-dimension surface on top.
* *
* Internally maintains: * Internally maintains:
* - Three immutable layer cells (`capabilities`, `environment`, `intent`) * - The frozen `schema` reference (immutable for the engine's lifetime).
* replaced by reference on every commit. * - Two layer cells (`environment`, `intent`) replaced by reference on
* - A frozen `PrefsSnapshot` cell that bundles the layers plus the * every commit.
* resolved `effective` view; rebuilt only on commits so reads return * - A frozen `PrefsSnapshot<S>` cell with the resolved `effective`
* structurally-shared references. * view; rebuilt only on commits so reads return structurally-shared
* references.
* - A monotonic `version` counter bumped per commit. Stable across * - A monotonic `version` counter bumped per commit. Stable across
* no-op writes (see `commit()` short-circuit). * no-op writes (see `commit()` short-circuit).
* - A listener set notified with `{previous, next, effectiveDiff, cause}` * - A listener set notified with `{previous, next, effectiveDiff,
* only when at least one layer changed by reference. * cause}` only when at least one layer changed by reference.
*
* Validation is the same `validateIntentValue` the resolver uses — any
* value that survives `setIntent` is exactly a value the resolver could
* have stored on its own. This is important for the persisted-intent
* model: round-tripping intent through storage never produces a
* diverged effective view.
*/ */
export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs { export function createEnginePrefs<S extends PrefsSchema>(
let capabilities = options.capabilities; options: EnginePrefsOptions<S>
): EnginePrefs<S> {
const schema = options.schema;
let environment: PrefsEnvironment = options.environment ?? {}; let environment: PrefsEnvironment = options.environment ?? {};
let intent: PrefsIntent = options.intent ?? {}; let intent: PrefsIntentOf<S> = sanitiseInitialIntent(schema, options.intent);
let version = 0; let version = 0;
let snapshot: PrefsSnapshot = buildSnapshot(capabilities, environment, intent, version); let snapshot = buildSnapshot(schema, environment, intent, version);
let disposed = false; let disposed = false;
const listeners = new Set<PrefsChangeHandler>(); const listeners = new Set<PrefsChangeHandler<S>>();
function ensureLive(method: string): void { function ensureLive(method: string): void {
if (disposed) { if (disposed) {
@ -68,37 +64,27 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs {
} }
function commit( function commit(
nextCapabilities: PrefsCapabilities,
nextEnvironment: PrefsEnvironment, nextEnvironment: PrefsEnvironment,
nextIntent: PrefsIntent, nextIntent: PrefsIntentOf<S>,
cause: PrefsChangeCause cause: PrefsChangeCause
): PrefsSnapshot { ): PrefsSnapshot<S> {
// No-op short-circuit: if every layer is the same reference, the // No-op short-circuit: if every mutable layer is the same
// resolved view is by definition unchanged. Skip the recompute, // reference, the resolved view is by definition unchanged. Skip
// the version bump and the listener walk so callers can do // the recompute, the version bump and the listener walk.
// idempotent writes (re-emitting the same `setIntent`, if (nextEnvironment === environment && nextIntent === intent) {
// resubscribing a detector that fires on a no-op signal change)
// without spurious notifications.
if (
nextCapabilities === capabilities &&
nextEnvironment === environment &&
nextIntent === intent
) {
return snapshot; return snapshot;
} }
const previous = snapshot; const previous = snapshot;
capabilities = nextCapabilities;
environment = nextEnvironment; environment = nextEnvironment;
intent = nextIntent; intent = nextIntent;
version += 1; version += 1;
snapshot = buildSnapshot(capabilities, environment, intent, version); snapshot = buildSnapshot(schema, environment, intent, version);
const effectiveDiff = diffEffective(previous.effective, snapshot.effective); const event: PrefsChangeEvent<S> = {
const event: PrefsChangeEvent = {
previous, previous,
next: snapshot, next: snapshot,
effectiveDiff, effectiveDiff: diffEffective(previous.effective, snapshot.effective),
cause cause
}; };
// Snapshot the listener set so a handler that mutates the engine // Snapshot the listener set so a handler that mutates the engine
@ -106,19 +92,16 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs {
for (const listener of [...listeners]) { for (const listener of [...listeners]) {
listener(event); listener(event);
} }
return snapshot; return snapshot;
} }
const engine: EnginePrefs = { const engine: EnginePrefs<S> = {
kind: PREFS_KIND, kind: PREFS_KIND,
schema,
snapshot() { snapshot() {
return snapshot; return snapshot;
}, },
capabilities() {
return capabilities;
},
environment() { environment() {
return environment; return environment;
}, },
@ -129,57 +112,58 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs {
return snapshot.effective; return snapshot.effective;
}, },
setIntent(key, value) { setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S> {
ensureLive(PREFS_ENGINE_METHOD_SET_INTENT); ensureLive(PREFS_ENGINE_METHOD_SET_INTENT);
const result = validateIntentValue(key, value, capabilities); const stringKey = key as string;
const dim = schema[stringKey];
if (dim === undefined) {
throw new PrefsUnknownDimensionError(stringKey, unknownDimensionErrorMessage(stringKey));
}
const result = dim.validate(value);
if (!result.ok) { if (!result.ok) {
throw new PrefsIntentInvalidError( throw new PrefsIntentInvalidError(
key, stringKey,
result.reason, result.reason,
intentInvalidErrorMessage(key, result.reason) intentInvalidErrorMessage(stringKey, result.reason)
); );
} }
// Canonicalised value (timezone alias → IANA canonical) is const current = (intent as Record<string, unknown>)[stringKey];
// what gets stored, not the raw input. if (current === result.value) return snapshot;
if (intent[key] === result.value) return snapshot; const next = { ...(intent as Record<string, unknown>), [stringKey]: result.value };
const nextIntent: PrefsIntent = { ...intent, [key]: result.value }; return commit(environment, next as PrefsIntentOf<S>, 'intent:set');
return commit(capabilities, environment, nextIntent, 'intent:set');
}, },
clearIntent(key) { clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S> {
ensureLive(PREFS_ENGINE_METHOD_CLEAR_INTENT); ensureLive(PREFS_ENGINE_METHOD_CLEAR_INTENT);
if (intent[key] === undefined) return snapshot; const stringKey = key as string;
const nextIntent: PrefsIntent = { ...intent }; if (schema[stringKey] === undefined) {
delete (nextIntent as { [P in keyof PrefsIntent]?: PrefsIntent[P] })[key]; throw new PrefsUnknownDimensionError(stringKey, unknownDimensionErrorMessage(stringKey));
return commit(capabilities, environment, nextIntent, 'intent:clear'); }
if ((intent as Record<string, unknown>)[stringKey] === undefined) return snapshot;
const next = { ...(intent as Record<string, unknown>) };
delete next[stringKey];
return commit(environment, next as PrefsIntentOf<S>, 'intent:clear');
}, },
resetIntent(next) { resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S> {
ensureLive(PREFS_ENGINE_METHOD_RESET_INTENT); ensureLive(PREFS_ENGINE_METHOD_RESET_INTENT);
const nextIntent: PrefsIntent = next ?? {}; const sanitised = sanitizeIntent(schema, (next ?? {}) as Record<string, unknown>);
if (intentEqual(intent, nextIntent)) return snapshot; if (intentEqual(intent, sanitised)) return snapshot;
return commit(capabilities, environment, nextIntent, 'intent:reset'); return commit(environment, sanitised as PrefsIntentOf<S>, 'intent:reset');
}, },
refreshEnvironment(next) { refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S> {
ensureLive(PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT); ensureLive(PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT);
if (environmentEqual(environment, next)) return snapshot; if (environmentEqual(environment, next)) return snapshot;
return commit(capabilities, next, intent, 'environment:refresh'); return commit(next, intent, 'environment:refresh');
}, },
patchEnvironment(patch) { patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot<S> {
ensureLive(PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT); ensureLive(PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT);
const merged: PrefsEnvironment = { ...environment, ...patch }; const merged: PrefsEnvironment = { ...environment, ...patch };
if (environmentEqual(environment, merged)) return snapshot; if (environmentEqual(environment, merged)) return snapshot;
return commit(capabilities, merged, intent, 'environment:refresh'); return commit(merged, intent, 'environment:refresh');
},
setCapabilities(next) {
ensureLive(PREFS_ENGINE_METHOD_SET_CAPABILITIES);
validateDefaults(next);
if (next === capabilities) return snapshot;
return commit(next, environment, intent, 'capabilities:set');
}, },
subscribe(handler) { subscribe(handler) {
@ -205,80 +189,85 @@ export function createEnginePrefs(options: EnginePrefsOptions): EnginePrefs {
// Helpers // Helpers
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
function buildSnapshot( function sanitiseInitialIntent<S extends PrefsSchema>(
capabilities: PrefsCapabilities, schema: S,
intent: PrefsIntentOf<S> | undefined
): PrefsIntentOf<S> {
if (intent === undefined) return {} as PrefsIntentOf<S>;
return sanitizeIntent(schema, intent as Record<string, unknown>) as PrefsIntentOf<S>;
}
function buildSnapshot<S extends PrefsSchema>(
schema: S,
environment: PrefsEnvironment, environment: PrefsEnvironment,
intent: PrefsIntent, intent: PrefsIntentOf<S>,
version: number version: number
): PrefsSnapshot { ): PrefsSnapshot<S> {
const effective = resolvePrefs({ capabilities, environment, intent }); const effective = resolvePrefs({ schema, environment, intent }) as PrefsEffectiveOf<S>;
return Object.freeze({ return Object.freeze({
capabilities,
environment, environment,
intent, intent,
effective: Object.freeze(effective), effective,
version version
}); }) as PrefsSnapshot<S>;
} }
/** /**
* Shallow per-field diff over `PrefsEffective`. The result holds only * Shallow per-key diff over `effective`. Returns only keys whose value
* keys whose value changed — `Object.keys(diff).length === 0` is the * changed — `Object.keys(diff).length === 0` is the "effective
* "effective unchanged" signal subscribers can branch on. * unchanged" signal subscribers can branch on.
*/ */
function diffEffective( function diffEffective<S extends PrefsSchema>(
previous: PrefsEffective, previous: PrefsEffectiveOf<S>,
next: PrefsEffective next: PrefsEffectiveOf<S>
): Partial<PrefsEffective> { ): Partial<PrefsEffectiveOf<S>> {
const diff: { -readonly [K in keyof PrefsEffective]?: PrefsEffective[K] } = {}; const diff: Record<string, unknown> = {};
const keys = Object.keys(next) as Array<keyof PrefsEffective>; const previousMap = previous as unknown as Record<string, unknown>;
for (const key of keys) { const nextMap = next as unknown as Record<string, unknown>;
if (previous[key] !== next[key]) { for (const key of Object.keys(nextMap)) {
(diff[key] as PrefsEffective[typeof key]) = next[key]; if (previousMap[key] !== nextMap[key]) {
diff[key] = nextMap[key];
} }
} }
return diff; return diff as Partial<PrefsEffectiveOf<S>>;
} }
/** /**
* Sparse-map equality for `PrefsIntent`. Keys with `undefined` values * Sparse-map equality. Keys with `undefined` values are treated as
* are treated as absent so callers can write `{ locale: undefined }` * absent so callers can write `{ locale: undefined }` without
* without spuriously committing — but the canonical "clear" path is * spuriously committing — the canonical "clear" path is still
* still `clearIntent(key)`. * `clearIntent(key)`.
*/ */
function intentEqual(a: PrefsIntent, b: PrefsIntent): boolean { function intentEqual(a: object, b: object): boolean {
const aKeys = (Object.keys(a) as Array<keyof PrefsIntent>).filter( const aMap = a as Record<string, unknown>;
(k) => a[k] !== undefined const bMap = b as Record<string, unknown>;
); const aKeys = Object.keys(aMap).filter((k) => aMap[k] !== undefined);
const bKeys = (Object.keys(b) as Array<keyof PrefsIntent>).filter( const bKeys = Object.keys(bMap).filter((k) => bMap[k] !== undefined);
(k) => b[k] !== undefined
);
if (aKeys.length !== bKeys.length) return false; if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) { for (const key of aKeys) {
if (a[key] !== b[key]) return false; if (aMap[key] !== bMap[key]) return false;
} }
return true; return true;
} }
function environmentEqual(a: PrefsEnvironment, b: PrefsEnvironment): boolean { function environmentEqual(a: PrefsEnvironment, b: PrefsEnvironment): boolean {
if (a === b) return true; if (a === b) return true;
const aKeys = Object.keys(a) as Array<keyof PrefsEnvironment>; const aMap = a as Record<string, unknown>;
const bKeys = Object.keys(b) as Array<keyof PrefsEnvironment>; const bMap = b as Record<string, unknown>;
const aKeys = Object.keys(aMap);
const bKeys = Object.keys(bMap);
if (aKeys.length !== bKeys.length) return false; if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) { for (const key of aKeys) {
if (key === 'locales') { if (key === 'locales') {
if (!arrayEqual(a.locales, b.locales)) return false; if (!arrayEqual(a.locales, b.locales)) return false;
} else if (a[key] !== b[key]) { } else if (aMap[key] !== bMap[key]) {
return false; return false;
} }
} }
return true; return true;
} }
function arrayEqual<T>( function arrayEqual<T>(a: readonly T[] | undefined, b: readonly T[] | undefined): boolean {
a: readonly T[] | undefined,
b: readonly T[] | undefined
): boolean {
if (a === b) return true; if (a === b) return true;
if (a === undefined || b === undefined) return false; if (a === undefined || b === undefined) return false;
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
@ -287,45 +276,3 @@ function arrayEqual<T>(
} }
return true; return true;
} }
/**
* `capabilities.defaults` is the final fallback for every `effective`
* field. If a default is itself unsupported by the new sets, every
* resolution that lands on the fallback would produce an invalid view
* — surface the misconfiguration loudly instead of letting it propagate.
*
* Direction is intentionally not validated: it is derived from the
* resolved language by `resolvePrefs`, so any value passed in
* `defaults.direction` is overwritten anyway.
*/
function validateDefaults(capabilities: PrefsCapabilities): void {
const defaults = capabilities.defaults;
const checks: ReadonlyArray<{
key: keyof PrefsIntent;
value: NonNullable<PrefsIntent[keyof PrefsIntent]>;
}> = [
{ key: 'language', value: defaults.language },
{ key: 'locale', value: defaults.locale },
{ key: 'currency', value: defaults.currency },
{ key: 'unitSystem', value: defaults.unitSystem },
{ key: 'theme', value: defaults.theme },
{ key: 'density', value: defaults.density },
{ key: 'motion', value: defaults.motion },
{ key: 'timezone', value: defaults.timezone }
];
for (const { key, value } of checks) {
const result = validateIntentValue(key, value, capabilities);
if (!result.ok) {
throwCapabilitiesInvalid(key, result.reason);
}
}
}
function throwCapabilitiesInvalid(field: string, reason: PrefsValidationFailure): never {
throw new PrefsCapabilitiesInvalidError(
field,
reason,
capabilitiesInvalidErrorMessage(field, reason)
);
}

@ -6,7 +6,8 @@
* The engine surfaces lifecycle conditions via change events and * The engine surfaces lifecycle conditions via change events and
* commit-returns; exceptions are reserved for programmer/data errors * commit-returns; exceptions are reserved for programmer/data errors
* where catching at the call site is the right pattern (disposed-engine * where catching at the call site is the right pattern (disposed-engine
* misuse, capability-default mismatch, intent-validation failure). * misuse, intent-validation failure, schema-key collision with a
* reserved member, write to a key not in the schema).
*/ */
import { import {
@ -24,15 +25,17 @@ import { PREFS_MODULE, type PrefsValidationFailure } from '$libs/prefs';
export const PREFS_ERR: ModuleSeed = moduleSeed(PREFS_MODULE); export const PREFS_ERR: ModuleSeed = moduleSeed(PREFS_MODULE);
export const PREFS_ERR_DISPOSED: ErrCode = errCode(PREFS_ERR, 'disposed'); export const PREFS_ERR_DISPOSED: ErrCode = errCode(PREFS_ERR, 'disposed');
export const PREFS_ERR_INTENT_INVALID: ErrCode = errCode(PREFS_ERR, 'intent_invalid'); export const PREFS_ERR_INTENT_INVALID: ErrCode = errCode(PREFS_ERR, 'intent_invalid');
export const PREFS_ERR_CAPABILITIES_INVALID: ErrCode = errCode(PREFS_ERR, 'capabilities_invalid'); export const PREFS_ERR_RESERVED_KEY: ErrCode = errCode(PREFS_ERR, 'reserved_key');
export const PREFS_ERR_UNKNOWN_DIMENSION: ErrCode = errCode(PREFS_ERR, 'unknown_dimension');
// ── Error message strings ────────────────────────────────────────────── // ── Error message strings ──────────────────────────────────────────────
export const PREFS_ERROR_PREFIX = `[${PREFS_MODULE}] `; export const PREFS_ERROR_PREFIX = `[${PREFS_MODULE}] `;
export const PREFS_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed prefs engine'; export const PREFS_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed prefs engine';
export const PREFS_ERROR_MSG_INTENT_INVALID_PREFIX = 'setIntent() rejected: '; export const PREFS_ERROR_MSG_INTENT_INVALID_PREFIX = 'setIntent() rejected: ';
export const PREFS_ERROR_MSG_CAPABILITIES_INVALID_PREFIX = export const PREFS_ERROR_MSG_RESERVED_KEY_PREFIX =
'setCapabilities() rejected: defaults do not validate — '; 'schema key collides with a reserved active-prefs member: ';
export const PREFS_ERROR_MSG_UNKNOWN_DIMENSION_PREFIX = 'no such dimension in schema: ';
export function disposedErrorMessage(method: string): string { export function disposedErrorMessage(method: string): string {
return `${PREFS_ERROR_PREFIX}${method}${PREFS_ERROR_MSG_DISPOSED_SUFFIX}`; return `${PREFS_ERROR_PREFIX}${method}${PREFS_ERROR_MSG_DISPOSED_SUFFIX}`;
@ -45,19 +48,21 @@ export function intentInvalidErrorMessage(
return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_INTENT_INVALID_PREFIX}${key} (${reason})`; return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_INTENT_INVALID_PREFIX}${key} (${reason})`;
} }
export function capabilitiesInvalidErrorMessage( export function reservedKeyErrorMessage(key: string): string {
field: string, return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_RESERVED_KEY_PREFIX}${key}`;
reason: PrefsValidationFailure }
): string {
return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_CAPABILITIES_INVALID_PREFIX}defaults.${field} (${reason})`; export function unknownDimensionErrorMessage(key: string): string {
return `${PREFS_ERROR_PREFIX}${PREFS_ERROR_MSG_UNKNOWN_DIMENSION_PREFIX}${key}`;
} }
// ── Error messages ───────────────────────────────────────────────────── // ── Error messages ─────────────────────────────────────────────────────
export const PREFS_ERROR_MESSAGES: ErrorMessages = { export const PREFS_ERROR_MESSAGES: ErrorMessages = {
[PREFS_ERR_DISPOSED]: `${PREFS_ERROR_PREFIX}operation called on a disposed prefs engine`, [PREFS_ERR_DISPOSED]: `${PREFS_ERROR_PREFIX}operation called on a disposed prefs engine`,
[PREFS_ERR_INTENT_INVALID]: `${PREFS_ERROR_PREFIX}setIntent() received a value outside capabilities`, [PREFS_ERR_INTENT_INVALID]: `${PREFS_ERROR_PREFIX}setIntent() received a value rejected by the dimension's validator`,
[PREFS_ERR_CAPABILITIES_INVALID]: `${PREFS_ERROR_PREFIX}setCapabilities() received defaults that do not validate against the new capability sets` [PREFS_ERR_RESERVED_KEY]: `${PREFS_ERROR_PREFIX}schema declares a key that collides with a reserved active-prefs member`,
[PREFS_ERR_UNKNOWN_DIMENSION]: `${PREFS_ERROR_PREFIX}attempted to mutate a dimension that is not in the schema`
}; };
// ── Error classes ────────────────────────────────────────────────────── // ── Error classes ──────────────────────────────────────────────────────
@ -73,19 +78,14 @@ export class PrefsDisposedError extends CodeError {
} }
/** /**
* `setIntent()` was called with a value outside `capabilities`. Carries * `setIntent()` was called with a value rejected by the dimension's
* the structured `PrefsValidationFailure` reason from * `validate` callback. Carries the dimension key plus the
* `validateIntentValue` so UI can branch on a stable code instead of * `PrefsValidationFailure` reason for UI branches.
* parsing the message.
*/ */
export class PrefsIntentInvalidError extends CodeError { export class PrefsIntentInvalidError extends CodeError {
readonly key: keyof import('$libs/prefs').PrefsIntent; readonly key: string;
readonly reason: PrefsValidationFailure; readonly reason: PrefsValidationFailure;
constructor( constructor(key: string, reason: PrefsValidationFailure, message: string) {
key: keyof import('$libs/prefs').PrefsIntent,
reason: PrefsValidationFailure,
message: string
) {
super(PREFS_ERR_INTENT_INVALID, { message }); super(PREFS_ERR_INTENT_INVALID, { message });
this.key = key; this.key = key;
this.reason = reason; this.reason = reason;
@ -93,18 +93,29 @@ export class PrefsIntentInvalidError extends CodeError {
} }
/** /**
* `setCapabilities()` was called with `defaults` that fail validation * The provided schema declares a key that would shadow a reserved
* against the new capability sets — the engine cannot silently accept * active-prefs member (`state`, `dispose`, `subscribe`, `snapshot`,
* this because `capabilities.defaults` is the final fallback in every * etc.). Detected at construction time so the application fails fast
* `effective` projection and must itself be reachable. * rather than producing a confusingly-shaped runtime.
*/ */
export class PrefsCapabilitiesInvalidError extends CodeError { export class PrefsReservedKeyError extends CodeError {
readonly field: string; readonly key: string;
readonly reason: PrefsValidationFailure; constructor(key: string, message: string) {
constructor(field: string, reason: PrefsValidationFailure, message: string) { super(PREFS_ERR_RESERVED_KEY, { message });
super(PREFS_ERR_CAPABILITIES_INVALID, { message }); this.key = key;
this.field = field; }
this.reason = reason; }
/**
* A `setIntent` / `clearIntent` write targeted a key that is not in the
* schema. The engine cannot guess a validator for it, so the call is
* rejected.
*/
export class PrefsUnknownDimensionError extends CodeError {
readonly key: string;
constructor(key: string, message: string) {
super(PREFS_ERR_UNKNOWN_DIMENSION, { message });
this.key = key;
} }
} }
@ -118,8 +129,10 @@ export function isPrefsIntentInvalidError(value: unknown): value is PrefsIntentI
return value instanceof PrefsIntentInvalidError; return value instanceof PrefsIntentInvalidError;
} }
export function isPrefsCapabilitiesInvalidError( export function isPrefsReservedKeyError(value: unknown): value is PrefsReservedKeyError {
value: unknown return value instanceof PrefsReservedKeyError;
): value is PrefsCapabilitiesInvalidError { }
return value instanceof PrefsCapabilitiesInvalidError;
export function isPrefsUnknownDimensionError(value: unknown): value is PrefsUnknownDimensionError {
return value instanceof PrefsUnknownDimensionError;
} }

@ -1,42 +1,53 @@
/** // Public surface of the prefs artifact. Named re-exports (not `export *`)
* Public surface of `arts/prefs`. Re-exports the runtime engine // so the bundler can prove which symbols are reached from a given import.
* (`createEnginePrefs`), the engine contract (`EnginePrefs`) and the //
* artifact's error infrastructure. // The library lives in `$libs/prefs` (pure types + resolver). This
* // artifact adds the runtime engine, the reactive Svelte adapter, the
* The Svelte rune adapter (`active-prefs.svelte.ts`) and the IO // built-in dimension catalog, the standard preset and the IO adapters
* adapters (browser/server environment detectors, storage bridge) live // (browser/server environment detectors, storage bridge).
* in submodules and are imported through their own paths so that
* server-only callers can pull just the engine without dragging
* `.svelte.ts` files into the build.
*/
export { export {
PREFS_ENGINE_METHOD_CLEAR_INTENT, PREFS_ENGINE_METHOD_CLEAR_INTENT,
PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT, PREFS_ENGINE_METHOD_PATCH_ENVIRONMENT,
PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT, PREFS_ENGINE_METHOD_REFRESH_ENVIRONMENT,
PREFS_ENGINE_METHOD_RESET_INTENT, PREFS_ENGINE_METHOD_RESET_INTENT,
PREFS_ENGINE_METHOD_SET_CAPABILITIES,
PREFS_ENGINE_METHOD_SET_INTENT, PREFS_ENGINE_METHOD_SET_INTENT,
PREFS_KIND PREFS_KIND
} from './consts.ts'; } from './consts.ts';
export { createEnginePrefs } from './engine-prefs.ts'; export { createEnginePrefs } from './engine-prefs.ts';
export { createActivePrefs } from './active-prefs.svelte.ts'; export {
createActivePrefs,
ACTIVE_PREFS_RESERVED_KEYS,
type ActivePrefs,
type ActivePrefsDimension,
type ActivePrefsState
} from './active-prefs.svelte.ts';
export type { EnginePrefs, EnginePrefsOptions } from './types.ts'; export type { EnginePrefs, EnginePrefsOptions } from './types.ts';
export type { ActivePrefs, ActivePrefsState } from './active-prefs.svelte.ts';
export { export {
prefsCurrencySource, booleanDimension,
prefsDensitySource, currencyDimension,
prefsDirectionSource, densityDimension,
prefsLanguageSource, directionDimension,
prefsLocaleSource, enumDimension,
prefsMotionSource, languageDimension,
prefsThemeSource, localeDimension,
prefsTimezoneSource, motionDimension,
prefsUnitSystemSource numberDimension,
} from './sources.ts'; stringDimension,
themeDimension,
timezoneDimension,
unitSystemDimension
} from './dimensions/index.ts';
export {
NEUTRAL_PREFS_SCHEMA,
standardPrefsDimensions,
type StandardPrefsCatalog,
type StandardPrefsSchema
} from './standard.ts';
export { export {
applyBrowserEnvironment, applyBrowserEnvironment,
@ -61,15 +72,18 @@ export type {
export { export {
PREFS_ERR, PREFS_ERR,
PREFS_ERR_CAPABILITIES_INVALID,
PREFS_ERR_DISPOSED, PREFS_ERR_DISPOSED,
PREFS_ERR_INTENT_INVALID, PREFS_ERR_INTENT_INVALID,
PREFS_ERR_RESERVED_KEY,
PREFS_ERR_UNKNOWN_DIMENSION,
PREFS_ERROR_MESSAGES, PREFS_ERROR_MESSAGES,
PREFS_ERROR_PREFIX, PREFS_ERROR_PREFIX,
PrefsCapabilitiesInvalidError,
PrefsDisposedError, PrefsDisposedError,
PrefsIntentInvalidError, PrefsIntentInvalidError,
isPrefsCapabilitiesInvalidError, PrefsReservedKeyError,
PrefsUnknownDimensionError,
isPrefsDisposedError, isPrefsDisposedError,
isPrefsIntentInvalidError isPrefsIntentInvalidError,
isPrefsReservedKeyError,
isPrefsUnknownDimensionError
} from './errors.ts'; } from './errors.ts';

@ -1,86 +0,0 @@
/**
* Capability-source proxies. Each helper turns an `EnginePrefs` into a
* narrow `Source<T>` for one effective field — the shape `lang`,
* `format`, `frontend` etc. consume.
*
* Why these live in `arts/prefs` instead of in each consumer: the
* consumers should NOT know `prefs` exists. They depend on the abstract
* `Source<T>` port from `$libs/reactive` and accept any producer that
* satisfies it (a hand-written test stub, a Svelte `$state` wrapper, or
* one of these proxies). `arts/prefs` is the bridge — it knows about
* `prefs` and produces ports.
*
* `EnginePrefs` is enough — `subscribe()` and `effective()` are the only
* surfaces these proxies use, so the same helpers work with the runes
* adapter (`ActivePrefs`) too.
*/
import type { CurrencySource } from '$libs/currency';
import type { DensitySource } from '$libs/density';
import type { DirectionSource } from '$libs/direction';
import type { LocaleSource } from '$libs/locale';
import type { MotionSource } from '$libs/motion';
import type { PrefsEffective } from '$libs/prefs';
import type { Source } from '$libs/reactive';
import type { ThemeSource } from '$libs/theme';
import type { TimezoneSource } from '$libs/timezone';
import type { UnitSystemSource } from '$libs/units';
import type { EnginePrefs } from './types.ts';
/**
* Generic builder. Reads `field` from `effective`, and forwards
* `onChange` only when that specific field appears in the change
* event's `effectiveDiff`. Other-field changes do not wake the
* consumer up — the proxy delivers per-dimension reactivity.
*/
function fieldSource<K extends keyof PrefsEffective>(
prefs: EnginePrefs,
field: K
): Source<PrefsEffective[K]> {
return {
get: () => prefs.effective()[field],
onChange(fn) {
return prefs.subscribe((event) => {
if (field in event.effectiveDiff) {
fn(event.next.effective[field]);
}
});
}
};
}
export function prefsLanguageSource(prefs: EnginePrefs): LocaleSource {
return fieldSource(prefs, 'language');
}
export function prefsLocaleSource(prefs: EnginePrefs): LocaleSource {
return fieldSource(prefs, 'locale');
}
export function prefsCurrencySource(prefs: EnginePrefs): CurrencySource {
return fieldSource(prefs, 'currency');
}
export function prefsTimezoneSource(prefs: EnginePrefs): TimezoneSource {
return fieldSource(prefs, 'timezone');
}
export function prefsUnitSystemSource(prefs: EnginePrefs): UnitSystemSource {
return fieldSource(prefs, 'unitSystem');
}
export function prefsThemeSource(prefs: EnginePrefs): ThemeSource {
return fieldSource(prefs, 'theme');
}
export function prefsDensitySource(prefs: EnginePrefs): DensitySource {
return fieldSource(prefs, 'density');
}
export function prefsMotionSource(prefs: EnginePrefs): MotionSource {
return fieldSource(prefs, 'motion');
}
export function prefsDirectionSource(prefs: EnginePrefs): DirectionSource {
return fieldSource(prefs, 'direction');
}

@ -0,0 +1,85 @@
import type { Currency } from '$libs/currency';
import type { Locale } from '$libs/locale';
import type { Timezone } from '$libs/timezone';
import { currencyDimension } from './dimensions/currency.ts';
import { densityDimension } from './dimensions/density.ts';
import { directionDimension } from './dimensions/direction.ts';
import { languageDimension } from './dimensions/language.ts';
import { localeDimension } from './dimensions/locale.ts';
import { motionDimension } from './dimensions/motion.ts';
import { themeDimension } from './dimensions/theme.ts';
import { timezoneDimension } from './dimensions/timezone.ts';
import { unitSystemDimension } from './dimensions/unit-system.ts';
/**
* Catalog input for `standardPrefsDimensions`. Apps declare which
* languages, locales and currencies they support; the preset wires the
* canonical built-ins around them.
*/
export interface StandardPrefsCatalog {
readonly languages: readonly Locale[];
readonly locales: readonly Locale[];
readonly currencies: readonly Currency[];
/** Optional IANA zone allowlist; omit for "any zone Intl can canonicalize". */
readonly timezones?: readonly Timezone[];
/**
* Optional per-dimension defaults. When omitted each dimension picks
* its own — usually the first entry in its catalog.
*/
readonly defaults?: {
readonly language?: Locale;
readonly locale?: Locale;
readonly currency?: Currency;
readonly timezone?: Timezone;
};
}
/**
* Compose the canonical built-in dimensions around the application's
* catalogs. Returns the schema fragment so apps can spread it into
* their full schema:
*
* ```ts
* const schema = {
* ...standardPrefsDimensions({ languages, locales, currencies }),
* // App-specific dimensions
* sidebarCollapsed: booleanDimension({ default: false }),
* notificationLevel: enumDimension(['all','mentions','none'] as const, { default: 'mentions' })
* };
*
* createActiveApp({ prefs: { schema } });
* ```
*
* Apps that don't want a particular built-in (say, a single-currency
* app skipping `currency`) drop the entry after spreading or compose a
* subset by hand instead of using this preset.
*/
export function standardPrefsDimensions(catalog: StandardPrefsCatalog) {
const defaults = catalog.defaults ?? {};
return {
language: languageDimension({ catalog: catalog.languages, default: defaults.language }),
locale: localeDimension({ catalog: catalog.locales, default: defaults.locale }),
currency: currencyDimension({ catalog: catalog.currencies, default: defaults.currency }),
timezone: timezoneDimension({ catalog: catalog.timezones, default: defaults.timezone }),
unitSystem: unitSystemDimension(),
theme: themeDimension(),
density: densityDimension(),
motion: motionDimension(),
direction: directionDimension()
} as const;
}
/**
* Neutral baseline used by `createActiveApp` when the caller omits
* `options.prefs`. Keeps the core surface populated even for apps that
* don't think about preferences. Apps that DO care override
* `options.prefs.schema`.
*/
export const NEUTRAL_PREFS_SCHEMA = standardPrefsDimensions({
languages: ['en'],
locales: ['en-US'],
currencies: ['USD']
});
export type StandardPrefsSchema = ReturnType<typeof standardPrefsDimensions>;

@ -1,124 +1,91 @@
/** import { describe, expect, it, vi } from 'vitest';
* ActivePrefs reactive surface tests. Runs in the browser project so import {
* `$state` cells fire effects. booleanDimension,
*/ enumDimension,
localeDimension,
themeDimension
} from '$prefs';
import { createActivePrefs } from '../active-prefs.svelte.ts';
import { PrefsReservedKeyError } from '../errors.ts';
import { describe, expect, it } from 'vitest'; const schema = {
import { flushSync } from 'svelte'; locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }),
import type { PrefsCapabilities, PrefsEffective } from '$libs/prefs'; theme: themeDimension({ default: 'light' }),
import { createActivePrefs } from '../active-prefs.svelte'; sidebarCollapsed: booleanDimension({ default: false }),
notifications: enumDimension(['all', 'mentions', 'none'] as const, { default: 'mentions' })
const CAPS: PrefsCapabilities = {
languages: ['es-ES', 'en-US'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
}; };
/** describe('createActivePrefs — dimension-as-object surface', () => {
* Run the body inside an `$effect.root` and tear it down after the it('exposes one slot per schema key with get/set/clear/onChange', () => {
* body's returned promise resolves. Mirrors the helper in the session const prefs = createActivePrefs({ schema });
* tests so reactivity tracks across `await` points.
*/
async function inRoot(body: () => Promise<void> | void): Promise<void> {
let cleanup!: () => void;
const ready = new Promise<void>((resolve) => {
cleanup = $effect.root(() => {
Promise.resolve(body()).then(resolve);
});
});
await ready;
cleanup();
}
describe('createActivePrefs', () => { expect(prefs.locale.get()).toBe('es-ES');
it('exposes the engine surface and reflects defaults', () => { expect(prefs.theme.get()).toBe('light');
const Prefs = createActivePrefs({ capabilities: CAPS }); expect(prefs.sidebarCollapsed.get()).toBe(false);
expect(Prefs.kind).toBe('prefs'); expect(prefs.notifications.get()).toBe('mentions');
expect(Prefs.effective().locale).toBe('es-ES');
expect(Prefs.state.effective.locale).toBe('es-ES'); prefs.locale.set('en-US');
expect(Prefs.state.pending).toBe(false); expect(prefs.locale.get()).toBe('en-US');
expect(Prefs.state.lastError).toBeNull();
Prefs.dispose();
});
it('state.effective updates inside an effect when intent changes', async () => { prefs.sidebarCollapsed.set(true);
const Prefs = createActivePrefs({ capabilities: CAPS }); expect(prefs.sidebarCollapsed.get()).toBe(true);
const observed: Array<PrefsEffective['locale']> = [];
await inRoot(async () => { prefs.locale.clear();
$effect(() => { expect(prefs.locale.get()).toBe('es-ES');
observed.push(Prefs.state.effective.locale);
});
flushSync();
Prefs.setIntent('locale', 'en-US');
flushSync();
});
expect(observed).toEqual(['es-ES', 'en-US']); prefs.dispose();
Prefs.dispose();
}); });
it('state.snapshot version increments per commit', async () => { it('onChange fires only when the dimension itself changes', () => {
const Prefs = createActivePrefs({ capabilities: CAPS }); const prefs = createActivePrefs({ schema });
const versions: number[] = []; const localeChange = vi.fn();
const themeChange = vi.fn();
prefs.locale.onChange(localeChange);
prefs.theme.onChange(themeChange);
await inRoot(async () => { prefs.locale.set('en-US');
$effect(() => { expect(localeChange).toHaveBeenCalledWith('en-US');
versions.push(Prefs.state.snapshot.version); expect(themeChange).not.toHaveBeenCalled();
});
flushSync();
Prefs.setIntent('theme', 'dark');
flushSync();
Prefs.setIntent('density', 'compact');
flushSync();
});
expect(versions).toEqual([0, 1, 2]); prefs.theme.set('dark');
Prefs.dispose(); expect(themeChange).toHaveBeenCalledWith('dark');
prefs.dispose();
}); });
it('no-op writes do not trigger reactive updates', async () => { it('throws PrefsReservedKeyError when a schema key collides with a reserved member', () => {
const Prefs = createActivePrefs({ expect(() =>
capabilities: CAPS, createActivePrefs({
intent: { theme: 'dark' } schema: {
}); state: booleanDimension({ default: false })
let runs = 0; }
})
).toThrow(PrefsReservedKeyError);
});
await inRoot(async () => { it('catalog() returns the dimension catalog when one is declared', () => {
$effect(() => { const prefs = createActivePrefs({ schema });
// touch the snapshot so the effect tracks it expect(prefs.locale.catalog()).toEqual(['es-ES', 'en-US']);
void Prefs.state.snapshot; expect(prefs.notifications.catalog()).toEqual(['all', 'mentions', 'none']);
runs += 1; expect(prefs.sidebarCollapsed.catalog()).toBeUndefined();
}); prefs.dispose();
flushSync(); });
Prefs.setIntent('theme', 'dark'); // no-op
flushSync();
});
expect(runs).toBe(1); it('low-level setIntent / clearIntent stay available for adapters', () => {
Prefs.dispose(); const prefs = createActivePrefs({ schema });
prefs.setIntent('locale', 'en-US');
expect(prefs.locale.get()).toBe('en-US');
prefs.clearIntent('locale');
expect(prefs.locale.get()).toBe('es-ES');
prefs.dispose();
}); });
it('dispose detaches the engine subscription', () => { it('state.snapshot reflects the latest commit', () => {
const Prefs = createActivePrefs({ capabilities: CAPS }); const prefs = createActivePrefs({ schema });
Prefs.dispose(); const v0 = prefs.state.version;
// Engine mutations after dispose throw; the rune adapter's own prefs.theme.set('dark');
// state cell stays at the last commit. expect(prefs.state.version).toBe(v0 + 1);
expect(() => Prefs.setIntent('locale', 'en-US')).toThrow(); expect(prefs.state.effective.theme).toBe('dark');
prefs.dispose();
}); });
}); });

@ -1,366 +1,106 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { PrefsCapabilities, PrefsChangeEvent } from '$libs/prefs'; import { booleanDimension, enumDimension, localeDimension, themeDimension } from '$prefs';
import { createEnginePrefs } from '../engine-prefs.ts'; import { createEnginePrefs } from '../engine-prefs.ts';
import { PREFS_KIND } from '../consts.ts';
import { import {
PrefsCapabilitiesInvalidError,
PrefsDisposedError, PrefsDisposedError,
PrefsIntentInvalidError, PrefsIntentInvalidError,
isPrefsDisposedError, PrefsUnknownDimensionError
isPrefsIntentInvalidError
} from '../errors.ts'; } from '../errors.ts';
const CAPS: PrefsCapabilities = { const baseSchema = {
languages: ['es-ES', 'en-US', 'ar-EG'], locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }),
locales: ['es-ES', 'en-US', 'ar-EG'], theme: themeDimension({ default: 'light' }),
currencies: ['EUR', 'USD'], flag: booleanDimension({ default: false }),
unitSystems: ['metric', 'imperial'], mode: enumDimension(['compact', 'roomy'] as const, { default: 'roomy' })
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
timezones: ['Europe/Madrid', 'America/New_York'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
}; };
describe('createEnginePrefs — construction', () => { describe('createEnginePrefs — schema-generic engine', () => {
it('initial snapshot reflects defaults when env and intent are empty', () => { it('exposes the schema and computes effective from defaults', () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema: baseSchema });
const snap = engine.snapshot(); expect(engine.schema).toBe(baseSchema);
expect(snap.version).toBe(0); const eff = engine.effective();
expect(snap.effective).toEqual(CAPS.defaults); expect(eff.locale).toBe('es-ES');
expect(snap.intent).toEqual({}); expect(eff.flag).toBe(false);
expect(engine.kind).toBe(PREFS_KIND); expect(eff.mode).toBe('roomy');
engine.dispose();
}); });
it('honours initial environment and intent', () => { it('setIntent commits and notifies subscribers with effectiveDiff', () => {
const engine = createEnginePrefs({ const engine = createEnginePrefs({ schema: baseSchema });
capabilities: CAPS, const handler = vi.fn();
environment: { locales: ['en-US'] }, engine.subscribe(handler);
intent: { theme: 'dark' }
});
expect(engine.effective().language).toBe('en-US');
expect(engine.effective().locale).toBe('en-US');
expect(engine.effective().theme).toBe('dark');
});
it('returned snapshot, intent and effective are frozen', () => { engine.setIntent('locale', 'en-US');
const engine = createEnginePrefs({ capabilities: CAPS }); expect(engine.effective().locale).toBe('en-US');
const snap = engine.snapshot(); expect(handler).toHaveBeenCalledTimes(1);
expect(Object.isFrozen(snap)).toBe(true); expect(handler.mock.calls[0][0].effectiveDiff).toEqual({ locale: 'en-US' });
expect(Object.isFrozen(snap.effective)).toBe(true); engine.dispose();
}); });
});
describe('setIntent', () => { it('setIntent on an unknown key throws PrefsUnknownDimensionError', () => {
it('writes a valid intent and bumps version', () => { const engine = createEnginePrefs({ schema: baseSchema });
const engine = createEnginePrefs({ capabilities: CAPS }); expect(() => engine.setIntent('rogue' as never, 'x')).toThrow(PrefsUnknownDimensionError);
const before = engine.snapshot(); engine.dispose();
const after = engine.setIntent('locale', 'en-US');
expect(after.version).toBe(before.version + 1);
expect(after.intent.locale).toBe('en-US');
expect(after.effective.locale).toBe('en-US');
}); });
it('throws PrefsIntentInvalidError with the structured reason', () => { it('setIntent rejects invalid values with PrefsIntentInvalidError carrying the reason', () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema: baseSchema });
try { try {
engine.setIntent('locale', 'fr-FR'); engine.setIntent('locale', 'fr-FR');
expect.fail('should have thrown'); expect.fail('expected throw');
} catch (error) { } catch (error) {
expect(isPrefsIntentInvalidError(error)).toBe(true); expect(error).toBeInstanceOf(PrefsIntentInvalidError);
if (error instanceof PrefsIntentInvalidError) { expect((error as PrefsIntentInvalidError).key).toBe('locale');
expect(error.key).toBe('locale'); expect((error as PrefsIntentInvalidError).reason).toBe('unsupported_locale');
expect(error.reason).toBe('unsupported_locale');
}
} }
engine.dispose();
}); });
it('canonicalises timezone aliases on commit', () => { it('clearIntent drops the key and re-resolves through environment / defaults', () => {
const engine = createEnginePrefs({ capabilities: { ...CAPS, timezones: undefined } });
const after = engine.setIntent('timezone', 'America/New_York');
// Whatever the runtime canonicalises to is exactly what gets
// stored — exact equality with a target alias is runtime-dependent
// but the value MUST be equal between intent and effective.
expect(after.intent.timezone).toBe(after.effective.timezone);
});
it('is a no-op when the value matches the existing intent', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { theme: 'dark' }
});
const before = engine.snapshot();
const after = engine.setIntent('theme', 'dark');
expect(after).toBe(before);
expect(after.version).toBe(before.version);
});
});
describe('clearIntent', () => {
it('removes the key (not stored as undefined) and falls back', () => {
const engine = createEnginePrefs({ const engine = createEnginePrefs({
capabilities: CAPS, schema: baseSchema,
environment: { locales: ['en-US'] }, environment: { locales: ['en-US'] },
intent: { locale: 'es-ES' } intent: { locale: 'es-ES' }
}); });
const after = engine.clearIntent('locale'); expect(engine.effective().locale).toBe('es-ES');
expect(after.intent).toEqual({}); engine.clearIntent('locale');
expect('locale' in after.intent).toBe(false);
expect(after.effective.locale).toBe('en-US');
});
it('is a no-op when the key was already absent', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const before = engine.snapshot();
const after = engine.clearIntent('locale');
expect(after).toBe(before);
});
});
describe('resetIntent', () => {
it('replaces the whole intent map atomically', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { locale: 'en-US', theme: 'dark' }
});
const after = engine.resetIntent({ currency: 'USD' });
expect(after.intent).toEqual({ currency: 'USD' });
expect(after.effective.currency).toBe('USD');
expect(after.effective.locale).toBe(CAPS.defaults.locale);
});
it('clears every choice when called with no argument', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { theme: 'dark' }
});
const after = engine.resetIntent();
expect(after.intent).toEqual({});
});
it('is a no-op when the next intent equals the current', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { locale: 'en-US' }
});
const before = engine.snapshot();
const after = engine.resetIntent({ locale: 'en-US' });
expect(after).toBe(before);
});
});
describe('refreshEnvironment / patchEnvironment', () => {
it('refreshEnvironment replaces the env wholesale', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], colorScheme: 'dark' }
});
const after = engine.refreshEnvironment({ locales: ['ar-EG'] });
expect(after.environment.locales).toEqual(['ar-EG']);
expect(after.environment.colorScheme).toBeUndefined();
expect(after.effective.language).toBe('ar-EG');
});
it('patchEnvironment merges with existing env', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], colorScheme: 'light' }
});
const after = engine.patchEnvironment({ colorScheme: 'dark' });
expect(after.environment.locales).toEqual(['en-US']);
expect(after.environment.colorScheme).toBe('dark');
});
it('patchEnvironment is a no-op when the patch matches existing values', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
environment: { colorScheme: 'dark' }
});
const before = engine.snapshot();
const after = engine.patchEnvironment({ colorScheme: 'dark' });
expect(after).toBe(before);
});
it('environment may contain unsupported values; effective never does', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
environment: { locales: ['fr-FR'], currency: 'JPY' as 'JPY' }
});
// env preserved verbatim for diagnostics
expect(engine.environment().locales).toEqual(['fr-FR']);
expect(engine.environment().currency).toBe('JPY');
// effective falls through to defaults — no JPY, no fr-FR.
expect(engine.effective().currency).toBe(CAPS.defaults.currency);
expect(engine.effective().language).toBe(CAPS.defaults.language);
});
});
describe('setCapabilities', () => {
it('throws PrefsCapabilitiesInvalidError when defaults do not validate', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const broken: PrefsCapabilities = {
...CAPS,
currencies: ['USD'], // EUR no longer supported
defaults: { ...CAPS.defaults } // defaults.currency = 'EUR' → invalid
};
expect(() => engine.setCapabilities(broken)).toThrow(PrefsCapabilitiesInvalidError);
});
it('preserves existing intent when capabilities shrink', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { locale: 'en-US' }
});
// Keep en-US in defaults so the new caps validate, but drop
// it from the user-selectable locale list.
const shrunk: PrefsCapabilities = {
...CAPS,
locales: ['es-ES'],
languages: ['es-ES'],
defaults: { ...CAPS.defaults }
};
const after = engine.setCapabilities(shrunk);
// Intent kept verbatim — survives capability shrink.
expect(after.intent.locale).toBe('en-US');
// But effective falls back since intent.locale is no longer
// in capabilities.locales.
expect(after.effective.locale).toBe('es-ES');
});
it('drops invalid intent from effective immediately on commit', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { locale: 'en-US' }
});
expect(engine.effective().locale).toBe('en-US'); expect(engine.effective().locale).toBe('en-US');
engine.dispose();
// Drop en-US from locale capabilities — intent persists raw,
// but effective falls through to defaults since the intent no
// longer validates and no env hint is present.
const shrunk: PrefsCapabilities = {
...CAPS,
locales: ['es-ES'],
languages: ['es-ES']
};
const after = engine.setCapabilities(shrunk);
expect(after.intent.locale).toBe('en-US');
expect(after.effective.locale).toBe('es-ES');
});
});
describe('subscribe', () => {
it('notifies subscribers with previous/next/diff/cause on commit', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const listener = vi.fn<(event: PrefsChangeEvent) => void>();
engine.subscribe(listener);
const before = engine.snapshot();
engine.setIntent('locale', 'en-US');
expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock.calls[0][0];
expect(event.cause).toBe('intent:set');
expect(event.previous).toBe(before);
expect(event.next.effective.locale).toBe('en-US');
expect(event.effectiveDiff).toEqual({
locale: 'en-US'
});
});
it('does not notify on no-op writes', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
intent: { theme: 'dark' }
});
const listener = vi.fn();
engine.subscribe(listener);
engine.setIntent('theme', 'dark'); // same value
expect(listener).not.toHaveBeenCalled();
});
it('returned unsubscribe stops further notifications', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const listener = vi.fn();
const off = engine.subscribe(listener);
engine.setIntent('locale', 'en-US');
off();
engine.setIntent('locale', 'es-ES');
expect(listener).toHaveBeenCalledTimes(1);
});
it('iteration is safe when a handler unsubscribes itself mid-dispatch', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const otherListener = vi.fn();
let off: (() => void) | undefined;
const selfRemoving = vi.fn(() => off?.());
off = engine.subscribe(selfRemoving);
engine.subscribe(otherListener);
engine.setIntent('locale', 'en-US');
expect(selfRemoving).toHaveBeenCalledTimes(1);
expect(otherListener).toHaveBeenCalledTimes(1);
engine.setIntent('locale', 'es-ES');
expect(selfRemoving).toHaveBeenCalledTimes(1); // didn't fire again
expect(otherListener).toHaveBeenCalledTimes(2);
}); });
});
describe('dispose', () => { it('resetIntent sanitises through every dimension', () => {
it('is idempotent', () => { const engine = createEnginePrefs({ schema: baseSchema });
const engine = createEnginePrefs({ capabilities: CAPS }); engine.resetIntent({ locale: 'en-US', mode: 'unsupported' as never });
expect(() => { expect(engine.effective().locale).toBe('en-US');
engine.dispose(); expect(engine.effective().mode).toBe('roomy'); // dropped, fell back
engine.dispose(); engine.dispose();
}).not.toThrow();
}); });
it('clears listeners and rejects mutations', () => { it('refreshEnvironment publishes a commit', () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema: baseSchema });
const listener = vi.fn(); engine.refreshEnvironment({ locales: ['en-US'] });
engine.subscribe(listener); expect(engine.effective().locale).toBe('en-US');
engine.dispose(); engine.dispose();
expect(() => engine.setIntent('locale', 'en-US')).toThrow(PrefsDisposedError);
try {
engine.clearIntent('locale');
expect.fail('should have thrown');
} catch (error) {
expect(isPrefsDisposedError(error)).toBe(true);
}
expect(listener).not.toHaveBeenCalled();
}); });
it('subscribe after dispose returns a no-op unsubscribe', () => { it('mutators throw PrefsDisposedError after dispose()', () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema: baseSchema });
engine.dispose(); engine.dispose();
const off = engine.subscribe(vi.fn()); expect(() => engine.setIntent('locale', 'en-US')).toThrow(PrefsDisposedError);
expect(() => off()).not.toThrow(); expect(() => engine.clearIntent('locale')).toThrow(PrefsDisposedError);
expect(() => engine.resetIntent({})).toThrow(PrefsDisposedError);
}); });
});
describe('version', () => {
it('monotonically increments per commit and is stable across no-ops', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
expect(engine.snapshot().version).toBe(0);
it('snapshot.version increments per commit and stays stable across no-ops', () => {
const engine = createEnginePrefs({ schema: baseSchema });
const v0 = engine.snapshot().version;
engine.setIntent('locale', 'en-US'); engine.setIntent('locale', 'en-US');
expect(engine.snapshot().version).toBe(1); const v1 = engine.snapshot().version;
// Setting the same value again is a no-op.
engine.setIntent('locale', 'en-US'); // no-op engine.setIntent('locale', 'en-US');
expect(engine.snapshot().version).toBe(1); const v2 = engine.snapshot().version;
expect(v1).toBe(v0 + 1);
engine.setIntent('theme', 'dark'); expect(v2).toBe(v1);
expect(engine.snapshot().version).toBe(2); engine.dispose();
}); });
}); });

@ -1,124 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { PrefsCapabilities } from '$libs/prefs';
import { createEnginePrefs } from '../engine-prefs.ts';
import {
prefsCurrencySource,
prefsDirectionSource,
prefsLanguageSource,
prefsLocaleSource,
prefsThemeSource,
prefsUnitSystemSource
} from '../sources.ts';
const CAPS: PrefsCapabilities = {
languages: ['es-ES', 'en-US', 'ar-EG'],
locales: ['es-ES', 'en-US', 'ar-EG'],
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
describe('capability proxies', () => {
it('language source reflects effective.language and notifies on change', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const source = prefsLanguageSource(engine);
expect(source.get()).toBe('es-ES');
const fn = vi.fn();
const off = source.onChange?.(fn);
engine.setIntent('language', 'en-US');
expect(fn).toHaveBeenCalledWith('en-US');
expect(source.get()).toBe('en-US');
off?.();
});
it('locale source is independent of language source', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const language = prefsLanguageSource(engine);
const locale = prefsLocaleSource(engine);
const langFn = vi.fn();
const localeFn = vi.fn();
language.onChange?.(langFn);
locale.onChange?.(localeFn);
// Move only locale; language stays at default.
engine.setIntent('locale', 'en-US');
expect(localeFn).toHaveBeenCalledWith('en-US');
expect(langFn).not.toHaveBeenCalled();
});
it('currency source notifies only on currency change', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const source = prefsCurrencySource(engine);
const fn = vi.fn();
source.onChange?.(fn);
// Theme change must NOT fire the currency listener.
engine.setIntent('theme', 'dark');
expect(fn).not.toHaveBeenCalled();
engine.setIntent('currency', 'USD');
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith('USD');
});
it('theme source emits the effective theme (never the intent string)', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
environment: { colorScheme: 'dark' }
});
const source = prefsThemeSource(engine);
// Pin the engine to 'light' so the upcoming `setIntent('theme',
// 'system')` actually changes the effective value (otherwise
// the engine's no-op short-circuit fires and no event is sent).
engine.setIntent('theme', 'light');
const fn = vi.fn();
source.onChange?.(fn);
engine.setIntent('theme', 'system');
expect(fn).toHaveBeenCalledWith('dark');
});
it('direction source updates when language changes scripts', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const source = prefsDirectionSource(engine);
const fn = vi.fn();
source.onChange?.(fn);
engine.setIntent('language', 'ar-EG');
expect(fn).toHaveBeenCalledWith('rtl');
expect(source.get()).toBe('rtl');
});
it('unit-system source reflects locale-derived projection', () => {
const engine = createEnginePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'] }
});
const source = prefsUnitSystemSource(engine);
expect(source.get()).toBe('imperial');
});
it('returned unsubscribe stops further notifications', () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const source = prefsLocaleSource(engine);
const fn = vi.fn();
const off = source.onChange?.(fn);
off?.();
engine.setIntent('locale', 'en-US');
expect(fn).not.toHaveBeenCalled();
});
});

@ -1,239 +1,115 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { PrefsCapabilities, PrefsIntent } from '$libs/prefs'; import { booleanDimension, localeDimension } from '$prefs';
import { createEnginePrefs } from '../engine-prefs.ts'; import { createEnginePrefs } from '../engine-prefs.ts';
import { import {
createPrefsStorageBridge, createPrefsStorageBridge,
type PrefsIntentStorage, type PrefsIntentStorage
type PrefsStorageOp
} from '../adapters/storage-bridge.ts'; } from '../adapters/storage-bridge.ts';
const CAPS: PrefsCapabilities = { const schema = {
languages: ['es-ES', 'en-US'], locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }),
locales: ['es-ES', 'en-US'], flag: booleanDimension({ default: false })
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
}; };
interface MemoryStorage extends PrefsIntentStorage { type Schema = typeof schema;
readonly saved: PrefsIntent[];
readonly cleared: number; function inMemoryStorage(initial?: { locale?: 'es-ES' | 'en-US'; flag?: boolean }) {
current: PrefsIntent | null; const saves: Array<Record<string, unknown>> = [];
} const clears: number[] = [];
let value = initial ? { ...initial } : null;
function createMemoryStorage(initial: PrefsIntent | null = null): MemoryStorage { const storage: PrefsIntentStorage<Schema> = {
const saved: PrefsIntent[] = []; load: () => value,
let cleared = 0; save: (intent) => {
let current = initial; saves.push({ ...(intent as Record<string, unknown>) });
return { value = { ...(intent as Record<string, unknown>) } as typeof value;
get saved() {
return saved;
},
get cleared() {
return cleared;
},
get current() {
return current;
},
set current(value: PrefsIntent | null) {
current = value;
},
load() {
return current;
},
save(intent) {
saved.push(intent);
current = intent;
}, },
clear() { clear: () => {
cleared += 1; clears.push(clears.length + 1);
current = null; value = null;
} }
}; };
return { storage, saves, clears };
} }
describe('createPrefsStorageBridge — hydrate', () => { describe('createPrefsStorageBridge', () => {
it('applies the loaded intent on hydrate without echoing back to storage', async () => { it('hydrates intent from storage on construction', async () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema });
const storage = createMemoryStorage({ locale: 'en-US' }); const { storage } = inMemoryStorage({ locale: 'en-US' });
const bridge = createPrefsStorageBridge({ engine, storage }); const bridge = createPrefsStorageBridge({ engine, storage });
await bridge.hydrated; await bridge.hydrated;
expect(engine.intent()).toEqual({ locale: 'en-US' });
expect(engine.effective().locale).toBe('en-US'); expect(engine.effective().locale).toBe('en-US');
expect(storage.saved).toEqual([]); // no echo
bridge.dispose(); bridge.dispose();
engine.dispose();
}); });
it('does nothing when storage.load returns null', async () => { it('persists subsequent commits via save()', async () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema });
const storage = createMemoryStorage(null); const { storage, saves } = inMemoryStorage();
const bridge = createPrefsStorageBridge({ engine, storage });
await bridge.hydrated;
expect(engine.intent()).toEqual({});
bridge.dispose();
});
it('skips hydrate when skipHydrate=true', async () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const storage = createMemoryStorage({ locale: 'en-US' });
const loadSpy = vi.spyOn(storage, 'load');
const bridge = createPrefsStorageBridge({ engine, storage, skipHydrate: true });
await bridge.hydrated;
expect(loadSpy).not.toHaveBeenCalled();
expect(engine.intent()).toEqual({});
bridge.dispose();
});
it('local writes during hydrate win over the loaded value', async () => {
const engine = createEnginePrefs({ capabilities: CAPS });
// Async load: returns en-US after a microtask.
const storage: PrefsIntentStorage = {
async load() {
await Promise.resolve();
return { locale: 'en-US' };
},
save: vi.fn(),
clear: vi.fn()
};
const bridge = createPrefsStorageBridge({ engine, storage });
// User write hits before load resolves.
engine.setIntent('locale', 'es-ES');
await bridge.hydrated;
expect(engine.intent().locale).toBe('es-ES');
bridge.dispose();
});
});
describe('createPrefsStorageBridge — persistence', () => {
it('persists every commit that changes intent', async () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const storage = createMemoryStorage();
const bridge = createPrefsStorageBridge({ engine, storage }); const bridge = createPrefsStorageBridge({ engine, storage });
await bridge.hydrated; await bridge.hydrated;
engine.setIntent('locale', 'en-US'); engine.setIntent('locale', 'en-US');
engine.setIntent('theme', 'dark');
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(saves).toEqual([{ locale: 'en-US' }]);
expect(storage.saved).toEqual([ engine.setIntent('flag', true);
{ locale: 'en-US' },
{ locale: 'en-US', theme: 'dark' }
]);
bridge.dispose();
});
it('only persists `intent` — environment changes are ignored', async () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const storage = createMemoryStorage();
const bridge = createPrefsStorageBridge({ engine, storage });
await bridge.hydrated;
engine.refreshEnvironment({ locales: ['en-US'] });
engine.patchEnvironment({ colorScheme: 'dark' });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(saves).toHaveLength(2);
expect(saves[1]).toEqual({ locale: 'en-US', flag: true });
expect(storage.saved).toEqual([]);
bridge.dispose(); bridge.dispose();
engine.dispose();
}); });
it('calls clear() instead of save({}) when intent becomes empty', async () => { it('clears storage when the intent map empties out', async () => {
const engine = createEnginePrefs({ const engine = createEnginePrefs({ schema, intent: { locale: 'en-US' } });
capabilities: CAPS, const { storage, clears } = inMemoryStorage();
intent: { locale: 'en-US' } const bridge = createPrefsStorageBridge({ engine, storage });
});
const storage = createMemoryStorage({ locale: 'en-US' });
const bridge = createPrefsStorageBridge({ engine, storage, skipHydrate: true });
await bridge.hydrated; await bridge.hydrated;
engine.resetIntent(); engine.clearIntent('locale');
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(clears).toHaveLength(1);
expect(storage.cleared).toBe(1);
expect(storage.saved).toEqual([]);
bridge.dispose(); bridge.dispose();
engine.dispose();
}); });
it('reports save errors via onError without corrupting the engine', async () => { it('skips hydrate when the user wrote intent before load resolved', async () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema });
const errors: Array<{ error: unknown; op: PrefsStorageOp }> = []; let resolveLoad: (v: { locale: 'en-US' } | null) => void = () => {};
const storage: PrefsIntentStorage = { const storage: PrefsIntentStorage<Schema> = {
load: () => null, load: () => new Promise((r) => { resolveLoad = r; }),
save: () => { save: () => {},
throw new Error('disk full');
},
clear: () => {} clear: () => {}
}; };
const bridge = createPrefsStorageBridge({ const bridge = createPrefsStorageBridge({ engine, storage });
engine,
storage, engine.setIntent('locale', 'es-ES');
onError(error, op) { resolveLoad({ locale: 'en-US' });
errors.push({ error, op });
}
});
await bridge.hydrated; await bridge.hydrated;
engine.setIntent('locale', 'en-US'); expect(engine.effective().locale).toBe('es-ES');
await new Promise((r) => setTimeout(r, 0));
expect(errors).toHaveLength(1);
expect(errors[0].op).toBe('save');
expect(errors[0].error).toBeInstanceOf(Error);
// Engine state survived the failed save.
expect(engine.intent().locale).toBe('en-US');
bridge.dispose(); bridge.dispose();
engine.dispose();
}); });
it('reports load errors via onError', async () => { it('reports load errors via onError', async () => {
const engine = createEnginePrefs({ capabilities: CAPS }); const engine = createEnginePrefs({ schema });
const errors: PrefsStorageOp[] = []; const onError = vi.fn();
const storage: PrefsIntentStorage = { const storage: PrefsIntentStorage<Schema> = {
load: () => { load: () => {
throw new Error('quota exceeded'); throw new Error('boom');
}, },
save: () => {}, save: () => {},
clear: () => {} clear: () => {}
}; };
const bridge = createPrefsStorageBridge({ const bridge = createPrefsStorageBridge({ engine, storage, onError });
engine,
storage,
onError(_error, op) {
errors.push(op);
}
});
await bridge.hydrated; await bridge.hydrated;
expect(onError).toHaveBeenCalledTimes(1);
expect(errors).toEqual(['load']); expect(onError.mock.calls[0][1]).toBe('load');
expect(engine.intent()).toEqual({});
bridge.dispose(); bridge.dispose();
}); engine.dispose();
it('dispose stops further persistence', async () => {
const engine = createEnginePrefs({ capabilities: CAPS });
const storage = createMemoryStorage();
const bridge = createPrefsStorageBridge({ engine, storage });
await bridge.hydrated;
bridge.dispose();
engine.setIntent('locale', 'en-US');
await new Promise((r) => setTimeout(r, 0));
expect(storage.saved).toEqual([]);
}); });
}); });

@ -1,110 +1,89 @@
import type { import type {
PrefsCapabilities,
PrefsChangeHandler, PrefsChangeHandler,
PrefsEffective, PrefsEffectiveOf,
PrefsEnvironment, PrefsEnvironment,
PrefsIntent, PrefsIntentOf,
PrefsSchema,
PrefsSnapshot, PrefsSnapshot,
PrefsUnsubscribe PrefsUnsubscribe
} from '$libs/prefs'; } from '$libs/prefs';
import type { PREFS_KIND } from './consts.ts'; import type { PREFS_KIND } from './consts.ts';
/** /**
* Construction options for `createEnginePrefs`. Only `capabilities` is * Construction options for `createEnginePrefs`. Only `schema` is
* required — `environment` and `intent` default to empty (which means * required — `environment` and `intent` default to empty (which means
* "every effective field falls through to `capabilities.defaults`"). * "every effective field falls through to its dimension's
* `defaultValue` or `fromEnvironment`").
* *
* The engine never reads from globals, so server-only callers are safe; * The engine never reads from globals, so server-only callers are safe;
* detection of `navigator`/`matchMedia`/storage lives in adapters. * detection of `navigator`/`matchMedia`/storage lives in adapters.
*/ */
export interface EnginePrefsOptions { export interface EnginePrefsOptions<S extends PrefsSchema> {
readonly capabilities: PrefsCapabilities; readonly schema: S;
readonly environment?: PrefsEnvironment; readonly environment?: PrefsEnvironment;
readonly intent?: PrefsIntent; readonly intent?: PrefsIntentOf<S>;
} }
/** /**
* Runtime preference engine. Holds the four-layer state (capabilities, * Runtime preference engine, generic over the user-defined schema.
* environment, intent, effective) and exposes a small mutator surface. * Holds the layered state (schema, environment, intent, effective) and
* exposes a small mutator surface.
* *
* The engine is reactive only via `subscribe(handler)`. The Svelte * The engine is reactive only via `subscribe(handler)`. The Svelte
* adapter (`active-prefs.svelte.ts`) layers runes on top — the engine * adapter (`active-prefs.svelte.ts`) layers runes on top — the engine
* itself is runes-free and importable from server-only modules. * itself is runes-free and importable from server-only modules.
* *
* Reads (`snapshot`, `capabilities`, `environment`, `intent`, * Reads (`snapshot`, `environment`, `intent`, `effective`) return
* `effective`) return frozen, structurally-shared values. Each commit * frozen, structurally-shared values. Each commit publishes a new
* publishes a new snapshot — consumers can use `snapshot.version` as a * snapshot — consumers can use `snapshot.version` as a cheap optimistic
* cheap optimistic equality key. * equality key.
* *
* Mutators return the post-commit `PrefsSnapshot` so callers can chain * Mutators return the post-commit `PrefsSnapshot<S>` so callers can
* without an extra `engine.snapshot()` round trip: * chain without an extra `engine.snapshot()` round trip.
*
* ```ts
* const after = engine.setIntent('locale', 'es-ES');
* console.log(after.effective.locale); // 'es-ES'
* ```
*/ */
export interface EnginePrefs { export interface EnginePrefs<S extends PrefsSchema = PrefsSchema> {
readonly kind: typeof PREFS_KIND; readonly kind: typeof PREFS_KIND;
readonly schema: S;
snapshot(): PrefsSnapshot; snapshot(): PrefsSnapshot<S>;
capabilities(): PrefsCapabilities;
environment(): PrefsEnvironment; environment(): PrefsEnvironment;
intent(): Readonly<PrefsIntent>; intent(): PrefsIntentOf<S>;
effective(): PrefsEffective; effective(): PrefsEffectiveOf<S>;
/** /**
* Persist a single explicit user choice. The value is validated * Persist a single explicit user choice for the dimension at `key`.
* against `capabilities` synchronously and a `PrefsValidationError` * Validates via the dimension's `validate` callback; throws
* is thrown on failure — write-time rejection is part of the * `PrefsIntentInvalidError` on failure or
* contract so UI bugs surface immediately rather than silently * `PrefsUnknownDimensionError` when `key` is not in the schema.
* dropping at resolution time.
*/ */
setIntent<K extends keyof PrefsIntent>( setIntent<K extends keyof S>(key: K, value: unknown): PrefsSnapshot<S>;
key: K,
value: NonNullable<PrefsIntent[K]>
): PrefsSnapshot;
/** /**
* Drop the user's explicit choice for `key` so the resolver falls * Drop the user's explicit choice for `key` so the resolver falls
* back to environment/defaults. Distinct from `setIntent(key, * back to environment / dimension default. Distinct from
* undefined)` (not allowed) to keep "did not write" and "wrote * `setIntent(key, undefined)` (not allowed).
* undefined" distinguishable.
*/ */
clearIntent<K extends keyof PrefsIntent>(key: K): PrefsSnapshot; clearIntent<K extends keyof S>(key: K): PrefsSnapshot<S>;
/** /**
* Replace the entire intent map. Pass `{}` (or omit `next`) to * Replace the entire intent map. Pass `{}` (or omit `next`) to clear
* clear every explicit choice in one commit. * every explicit choice in one commit. Values are sanitised through
* each dimension's `validate` callback before applying.
*/ */
resetIntent(next?: PrefsIntent): PrefsSnapshot; resetIntent(next?: PrefsIntentOf<S>): PrefsSnapshot<S>;
/** /**
* Replace the detected environment wholesale. Used by detector * Replace the detected environment wholesale. Used by detector
* adapters when the underlying signals (`navigator.languages`, * adapters when the underlying signals change in bulk.
* `prefers-color-scheme`, …) change in bulk.
*/ */
refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot; refreshEnvironment(next: PrefsEnvironment): PrefsSnapshot<S>;
/** /**
* Patch a subset of `environment`. Existing fields not present in * Patch a subset of `environment`. Existing fields not present in
* `patch` survive — useful when one signal updates independently of * `patch` survive.
* the others (e.g. `matchMedia` color-scheme listener).
*/
patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot;
/**
* Replace `capabilities`. The new `defaults` are validated against
* the new sets — a `PrefsCapabilitiesInvalidError` is thrown if
* they would not pass `validateIntentValue`.
*
* Existing `intent` is NOT mutated. Entries that no longer validate
* are simply dropped from `effective` until capabilities allow them
* again or the app explicitly clears them. This matches the
* "persisted intent survives capability shrink" rule in the README.
*/ */
setCapabilities(next: PrefsCapabilities): PrefsSnapshot; patchEnvironment(patch: Partial<PrefsEnvironment>): PrefsSnapshot<S>;
subscribe(handler: PrefsChangeHandler): PrefsUnsubscribe; subscribe(handler: PrefsChangeHandler<S>): PrefsUnsubscribe;
dispose(): void; dispose(): void;
} }

@ -459,8 +459,8 @@ const Sess = createActiveSession<User, JwtCredential, CartData>({
storage: { adapter: localAdapter, key: 'aapp:session' }, storage: { adapter: localAdapter, key: 'aapp:session' },
onRefresh: async (current, ctx) => { ... }, onRefresh: async (current, ctx) => { ... },
onRevoke: async (current, ctx) => { ... }, onRevoke: async (current, ctx) => { ... },
logger: App.Logger, logger: App.logger,
bus: App.Bus, bus: App.bus,
broadcastChannel: 'my-app:sess' broadcastChannel: 'my-app:sess'
}); });
``` ```
@ -489,7 +489,7 @@ const stop = withAutoRefresh(Sess, {
marginMs: 90_000, marginMs: 90_000,
jitterMs: 5_000, jitterMs: 5_000,
refreshOnVisible: true, refreshOnVisible: true,
timers: App.Timers // optional when using aapp; omit for native interval timers: App.timers // optional when using aapp; omit for native interval
}); });
// ... later // ... later
stop(); stop();
@ -567,7 +567,7 @@ session `data`. Code that needs the full snapshot should use
`createEngineSession({ bus })` publishes from the engine. `createActiveSession` `createEngineSession({ bus })` publishes from the engine. `createActiveSession`
publishes from the active wrapper after `$state` has been updated, so consumers publishes from the active wrapper after `$state` has been updated, so consumers
that react through `App.Bus` see the latest `Sess.current` in the same tick. that react through `App.bus` see the latest `Sess.current` in the same tick.
When registered through the App service schema: When registered through the App service schema:
@ -583,7 +583,7 @@ await App.session.adopt({ user, credential, ... });
`defineActiveSession(...)` makes the App builder inject `Logger` and `Bus` `defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`
automatically. The session art publishes its own `SESSION_EVENT_*` events automatically. The session art publishes its own `SESSION_EVENT_*` events
on `App.Bus`; cross-module reactions live in orca presets at the App on `App.bus`; cross-module reactions live in orca presets at the App
level (`applyCacheClearOnIdentityChange`, level (`applyCacheClearOnIdentityChange`,
`applyPermInvalidateOnIdentityChange`, etc.) — the standard set is wired `applyPermInvalidateOnIdentityChange`, etc.) — the standard set is wired
by `applyStandardOrca(App)`. by `applyStandardOrca(App)`.
@ -723,7 +723,7 @@ applyStandardOrca(App); // cross-module reactions on identity changes
`defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`; `defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`;
`App.http` is reachable through closure capture inside the handlers. The `App.http` is reachable through closure capture inside the handlers. The
session art publishes `SESSION_EVENT_*` directly on `App.Bus`. session art publishes `SESSION_EVENT_*` directly on `App.bus`.
--- ---

@ -31,7 +31,7 @@ export const SESSION_DEFAULT_AUTO_REFRESH_MARGIN_MS = 90_000;
/** Default jitter applied to the auto-refresh margin (ms). */ /** Default jitter applied to the auto-refresh margin (ms). */
export const SESSION_DEFAULT_AUTO_REFRESH_JITTER_MS = 5_000; export const SESSION_DEFAULT_AUTO_REFRESH_JITTER_MS = 5_000;
/** Default timer key used when auto-refresh is driven by App.Timers. */ /** Default timer key used when auto-refresh is driven by App.timers. */
export const SESSION_AUTO_REFRESH_TIMER_KEY = 'session:auto-refresh'; export const SESSION_AUTO_REFRESH_TIMER_KEY = 'session:auto-refresh';
/** Browser events / document states used by auto-refresh and broadcast. */ /** Browser events / document states used by auto-refresh and broadcast. */

@ -17,7 +17,7 @@ El patron estandar es una linea al inicio del modulo de la pagina:
```ts ```ts
import { createEngineSium } from '$sium'; import { createEngineSium } from '$sium';
const sium = createEngineSium({ lang: App.lang, logger: App.Logger }); const sium = createEngineSium({ lang: App.lang, logger: App.logger });
``` ```
Asi cada formulario obtiene un engine afinado a sus necesidades (logger Asi cada formulario obtiene un engine afinado a sus necesidades (logger

@ -35,7 +35,7 @@ ship together:
- Validation via Standard Schema v1 (Sium schemas reuse out of the box) - Validation via Standard Schema v1 (Sium schemas reuse out of the box)
- Per-entry overrides for adapter, namespace, raw, TTL — mix cookies for - Per-entry overrides for adapter, namespace, raw, TTL — mix cookies for
some keys, localStorage for others some keys, localStorage for others
- `onError` with rich context routed through `App.Logger` when used from - `onError` with rich context routed through `App.logger` when used from
`aapp` `aapp`
## Architecture ## Architecture
@ -555,7 +555,7 @@ const Storage = createEngineStorage({
Operations: `read | write | remove | serialize | deserialize | migrate | validate`. Operations: `read | write | remove | serialize | deserialize | migrate | validate`.
Default handler: diagnostics-only. When used from `aapp`, the shared Default handler: diagnostics-only. When used from `aapp`, the shared
`App.Logger` is injected, so failures land through `StorageDiagnostics` with `App.logger` is injected, so failures land through `StorageDiagnostics` with
the storage diagnostic context. the storage diagnostic context.
## Auto-serializers ## Auto-serializers

@ -165,7 +165,7 @@ export interface EngineStorageOptions {
/** /**
* Injectable clock used by envelope TTL evaluation. Defaults to a * Injectable clock used by envelope TTL evaluation. Defaults to a
* `Date.now`-backed clock; the App composition wires * `Date.now`-backed clock; the App composition wires
* `core.timers.clock` so all time flows through `App.Timers`. Tests * `core.timers.clock` so all time flows through `App.timers`. Tests
* inject a fake clock for deterministic TTL boundaries. * inject a fake clock for deterministic TTL boundaries.
*/ */
clock?: { now(): number }; clock?: { now(): number };

@ -93,7 +93,7 @@ const App = createActiveApp({
timers: {} timers: {}
}); });
App.Timers.schedule(...); App.timers.schedule(...);
``` ```
Incorrecto: Incorrecto:
@ -1134,20 +1134,20 @@ export interface ActiveAppTimersOptions {
} }
``` ```
En la práctica `App.Timers` debería estar siempre presente. En la práctica `App.timers` debería estar siempre presente.
```ts ```ts
const App = createActiveApp({ const App = createActiveApp({
timers: {} timers: {}
}); });
App.Timers.schedule(...); App.timers.schedule(...);
``` ```
Si no se configuran timers: Si no se configuran timers:
```ts ```ts
App.Timers = createActiveTimers(); App.timers = createActiveTimers();
``` ```
### 15.2 Inyección a otros artifacts ### 15.2 Inyección a otros artifacts
@ -1161,7 +1161,7 @@ timers?: TimerScheduler;
Cuando se crean desde App: Cuando se crean desde App:
```ts ```ts
timers: App.Timers timers: App.timers
``` ```
No deben importar un scheduler global. No deben importar un scheduler global.
@ -1174,7 +1174,7 @@ No deben importar un scheduler global.
```ts ```ts
const stop = withAutoRefresh(Sess, { const stop = withAutoRefresh(Sess, {
timers: App.Timers, timers: App.timers,
tickMs, tickMs,
marginMs, marginMs,
jitterMs jitterMs
@ -1431,7 +1431,7 @@ const App = createActiveApp({
timers: {} timers: {}
}); });
App.Timers.schedule('sess:auto-refresh', 30_000, () => { App.timers.schedule('sess:auto-refresh', 30_000, () => {
return Sess.refresh(); return Sess.refresh();
}); });
``` ```
@ -1508,7 +1508,7 @@ No sabe nada de sesiones, conexiones, cache, usuarios, auth, UI o dominio.
Los demás artifacts expresan sus necesidades temporales mediante keys scoped. Los demás artifacts expresan sus necesidades temporales mediante keys scoped.
```txt ```txt
App.Timers App.timers
├── sess:auto-refresh ├── sess:auto-refresh
├── conn:main:reconnect ├── conn:main:reconnect
├── conn:main:heartbeat ├── conn:main:heartbeat

@ -43,7 +43,7 @@ Timers.cancelAll('conn:main');
## Why another one ## Why another one
- **Singleton-per-App, not module-global.** `App.Timers` is one instance - **Singleton-per-App, not module-global.** `App.timers` is one instance
per App/runtime — SSR-safe, test-isolated, multi-App-friendly, per App/runtime — SSR-safe, test-isolated, multi-App-friendly,
cleanup is deterministic (`App.dispose()` cascades to cleanup is deterministic (`App.dispose()` cascades to
`Timers.dispose()`). `Timers.dispose()`).
@ -330,7 +330,7 @@ scheduling during SSR should be intentional. If a request-scoped
EngineTimers schedules anything, the request must `dispose()` it EngineTimers schedules anything, the request must `dispose()` it
before completion — otherwise the timer leaks across requests. before completion — otherwise the timer leaks across requests.
There is no module-global singleton. `App.Timers` is owned by the There is no module-global singleton. `App.timers` is owned by the
`ActiveApp` instance and torn down by `App.dispose()`. `ActiveApp` instance and torn down by `App.dispose()`.
--- ---
@ -383,7 +383,7 @@ touching any cancellation, replace, reschedule or dispose path.
```ts ```ts
const stop = withAutoRefresh(Sess, { const stop = withAutoRefresh(Sess, {
timers: App.Timers, timers: App.timers,
tickMs: 30_000, tickMs: 30_000,
marginMs: 90_000 marginMs: 90_000
}); });
@ -466,7 +466,7 @@ export const Timers = createActiveTimers();
// ✓ one per App // ✓ one per App
const App = createActiveApp({ /* ... */ }); const App = createActiveApp({ /* ... */ });
App.Timers.schedule(...); App.timers.schedule(...);
``` ```
### Don't forget scoped cleanup ### Don't forget scoped cleanup

@ -34,7 +34,7 @@ export type MemoryCacheAdapterOptions = {
/** /**
* Route the production warning through a `$libs/logger.Logger` * Route the production warning through a `$libs/logger.Logger`
* (`logger.warn(category, message)`) instead of writing to * (`logger.warn(category, message)`) instead of writing to
* `console.warn`. The framework's hosts wire this from `App.Logger` * `console.warn`. The framework's hosts wire this from `App.logger`
* so the warning lands in the same transports as the rest of the * so the warning lands in the same transports as the rest of the
* runtime's diagnostics. * runtime's diagnostics.
*/ */

@ -14,13 +14,12 @@ export const PREFS_CHANGE_CAUSES = [
'intent:clear', 'intent:clear',
'intent:reset', 'intent:reset',
'environment:refresh', 'environment:refresh',
'capabilities:set',
'hydrate' 'hydrate'
] as const; ] as const;
/** /**
* Internal version constant carried in every `PrefsSnapshot`. Bumping * Internal version constant carried in every `PrefsSnapshot`. Bumped
* this is a hint to consumers that the snapshot shape changed in a * to 2 with the schema-based redesign — the snapshot now references a
* non-additive way; they can branch on it during migration windows. * caller-provided `PrefsSchema` instead of fixed `PrefsCapabilities`.
*/ */
export const PREFS_SNAPSHOT_VERSION = 1; export const PREFS_SNAPSHOT_VERSION = 2;

@ -1,13 +1,14 @@
/** /**
* Public surface of `libs/prefs`. Exposes the layered state model * Public surface of `libs/prefs`. The library defines the schema-based
* (`PrefsCapabilities`, `PrefsEnvironment`, `PrefsIntent`, * preference model: each preference is a `PrefsDimension<TIntent,
* `PrefsEffective`), snapshot/event types, the resolver input shape and * TEffective>` that owns its own validator, environment-fed resolver
* validation result types. * and (optionally) sibling-derived value. The engine in `arts/prefs`
* is generic over a `PrefsSchema = Record<string, PrefsDimension>` and
* exposes one slot per dimension.
* *
* Domain primitives (`Locale`, `Currency`, `ThemeIntent`, …) and their * Built-in dimensions (`localeDimension`, `themeDimension`, …) live in
* capability sources (`LocaleSource`, `CurrencySource`, …) live in their * `arts/prefs/dimensions/*` so the bundle can pick exactly which ones
* own libs (`$libs/locale`, `$libs/currency`, …) so consumers can depend * to ship.
* on a single capability port without importing `prefs`.
*/ */
export { export {
@ -20,14 +21,15 @@ export { resolvePrefs } from './resolve-prefs.ts';
export { sanitizeIntent, validateIntentValue } from './validate-intent.ts'; export { sanitizeIntent, validateIntentValue } from './validate-intent.ts';
export type { export type {
PrefsCapabilities,
PrefsChangeCause, PrefsChangeCause,
PrefsChangeEvent, PrefsChangeEvent,
PrefsChangeHandler, PrefsChangeHandler,
PrefsEffective, PrefsDimension,
PrefsEffectiveOf,
PrefsEnvironment, PrefsEnvironment,
PrefsIntent, PrefsIntentOf,
PrefsResolveInput, PrefsResolveInput,
PrefsSchema,
PrefsSnapshot, PrefsSnapshot,
PrefsUnsubscribe, PrefsUnsubscribe,
PrefsValidationFailure, PrefsValidationFailure,

@ -1,210 +1,88 @@
import { currencyFromLocales } from '$libs/currency';
import type { Currency } from '$libs/currency';
import type { Density } from '$libs/density';
import { directionFromLanguage } from '$libs/direction';
import type { Direction } from '$libs/direction';
import { matchLocale } from '$libs/locale';
import type { Locale } from '$libs/locale';
import { resolveMotion } from '$libs/motion';
import { resolveTheme } from '$libs/theme';
import type { Timezone } from '$libs/timezone';
import { unitSystemFromLocales } from '$libs/units';
import type { UnitSystem } from '$libs/units';
import type { import type {
PrefsCapabilities, PrefsDimension,
PrefsEffective, PrefsEffectiveOf,
PrefsEnvironment, PrefsEnvironment,
PrefsIntent, PrefsResolveInput,
PrefsResolveInput PrefsSchema
} from './types.ts'; } from './types.ts';
import { validateIntentValue } from './validate-intent.ts';
/** /**
* Pure orchestrator: compute `effective` from `(capabilities, * Pure orchestrator: compute `effective` from `(schema, environment,
* environment, intent)`. Never reads browser APIs, persists, emits * intent)`. Never reads browser APIs, persists, emits events or touches
* events or touches Svelte state — given the same input it always * Svelte state — given the same input it always returns the same
* returns the same output. * output.
* *
* Per-field strategy: each effective field is an INDEPENDENT projection * The resolver iterates the schema generically. Each dimension owns its
* of `environment.locales[]` against its own capability catalog, with * resolution logic in `dim.validate`, `dim.resolve` and (optionally)
* `intent` overriding the projection when it validates and a * `dim.derive`:
* field-specific environment hint (e.g. `environment.currency`) winning
* over the locale-derived guess when present.
* *
* The user's most-preferred locale wins per dimension, even when the * 1. **Pass 1 — non-derived dimensions.** For each dim without a
* overall locale fallback diverges: an `Accept-Language: es-MX, en-US` * `derive` hook: validate `intent[key]`; pass the validated value
* with `capabilities.locales = ['es-ES', 'en-US']` may resolve language * (or `undefined`) plus the environment to `dim.resolve(intent,
* to `es-ES` (closest match for `es-MX`) and currency to `USD` (no * env)`. When a dim has no `resolve`, fall back to
* region match for `MX` in the catalog → walk continues to `en-US`). * `intent ?? dim.defaultValue`.
* *
* Domain-specific helpers live in their respective capability libs * 2. **Pass 2 — derived dimensions.** For each dim with a `derive`:
* (`$libs/locale/match-locale`, `$libs/direction/from-language`, * validate `intent[key]` (intent always wins). When intent is
* `$libs/currency/from-locale`, `$libs/units/from-locale`, * absent, call `dim.derive(effective, env)` with the resolved
* `$libs/theme/resolve`, `$libs/motion/resolve`); this file is just * siblings from pass 1.
* composition over the prefs layered model.
* *
* `direction` is the one derivation: it follows from the resolved * Built-in dimensions encapsulate the domain rules that used to live
* `language` because direction is a property of the writing system, not * inline here (`matchLocale`, `currencyFromLocales`, theme/motion
* the region. * resolution) — keeping the resolver small lets app-defined dimensions
* compose without forking the framework.
*/ */
export function resolvePrefs(input: PrefsResolveInput): PrefsEffective { export function resolvePrefs<S extends PrefsSchema>(
const { capabilities, environment, intent } = input; input: PrefsResolveInput<S>
const defaults = capabilities.defaults; ): PrefsEffectiveOf<S> {
const envLocales = environment.locales ?? []; const { schema, environment, intent } = input;
const out: Record<string, unknown> = {};
const language = resolveFromLocales( for (const [key, dim] of Object.entries(schema)) {
'language', if (dim.derive !== undefined) continue;
capabilities, out[key] = resolveOne(dim, intent[key as keyof typeof intent], environment);
intent.language,
envLocales,
capabilities.languages,
defaults.language
);
const locale = resolveFromLocales(
'locale',
capabilities,
intent.locale,
envLocales,
capabilities.locales,
defaults.locale
);
const currency = resolveCurrency(capabilities, environment, intent, envLocales, defaults.currency);
const unitSystem = resolveUnitSystem(capabilities, environment, intent, envLocales, defaults.unitSystem);
const timezone = resolveTimezone(capabilities, environment, intent, defaults.timezone);
const density = resolveDensity(capabilities, intent, defaults.density);
const theme = resolveTheme(
validatedIntent('theme', intent.theme, capabilities),
environment.colorScheme,
defaults.theme
);
const motion = resolveMotion(
validatedIntent('motion', intent.motion, capabilities),
environment.reducedMotion,
defaults.motion
);
const direction: Direction = directionFromLanguage(language);
return {
language,
locale,
currency,
timezone,
unitSystem,
theme,
density,
motion,
direction
};
}
// ─────────────────────────────────────────────────────────────────────
// Per-field resolvers
// ─────────────────────────────────────────────────────────────────────
/**
* Shared shape for `language` and `locale`: validate intent, then walk
* `environment.locales[]` through `matchLocale` against the field's own
* capability catalog. Same input array, different available list — that
* is what makes language and locale orthogonal projections.
*/
function resolveFromLocales(
key: 'language' | 'locale',
capabilities: PrefsCapabilities,
intentValue: Locale | undefined,
envLocales: readonly Locale[],
available: readonly Locale[],
fallback: Locale
): Locale {
if (intentValue !== undefined) {
const r = validateIntentValue(key, intentValue, capabilities);
if (r.ok) return r.value;
} }
return matchLocale({ candidates: envLocales, available, fallback });
}
function resolveCurrency( for (const [key, dim] of Object.entries(schema)) {
capabilities: PrefsCapabilities, if (dim.derive === undefined) continue;
environment: PrefsEnvironment, out[key] = resolveDerived(dim, intent[key as keyof typeof intent], environment, out);
intent: PrefsIntent,
envLocales: readonly Locale[],
fallback: Currency
): Currency {
if (intent.currency !== undefined) {
const r = validateIntentValue('currency', intent.currency, capabilities);
if (r.ok) return r.value;
}
if (environment.currency !== undefined) {
const r = validateIntentValue('currency', environment.currency, capabilities);
if (r.ok) return r.value;
} }
return currencyFromLocales(envLocales, capabilities.currencies, fallback);
}
function resolveUnitSystem( return Object.freeze(out) as PrefsEffectiveOf<S>;
capabilities: PrefsCapabilities,
environment: PrefsEnvironment,
intent: PrefsIntent,
envLocales: readonly Locale[],
fallback: UnitSystem
): UnitSystem {
if (intent.unitSystem !== undefined) {
const r = validateIntentValue('unitSystem', intent.unitSystem, capabilities);
if (r.ok) return r.value;
}
if (environment.unitSystem !== undefined) {
const r = validateIntentValue('unitSystem', environment.unitSystem, capabilities);
if (r.ok) return r.value;
}
return unitSystemFromLocales(envLocales, capabilities.unitSystems, fallback);
} }
function resolveTimezone( function resolveOne<TIntent, TEffective>(
capabilities: PrefsCapabilities, dim: PrefsDimension<TIntent, TEffective>,
environment: PrefsEnvironment, candidateIntent: unknown,
intent: PrefsIntent, env: PrefsEnvironment
fallback: Timezone ): TEffective {
): Timezone { const validated = candidateIntent === undefined ? undefined : tryValidate(dim, candidateIntent);
if (intent.timezone !== undefined) { if (dim.resolve !== undefined) {
const r = validateIntentValue('timezone', intent.timezone, capabilities); return dim.resolve(validated, env);
if (r.ok) return r.value;
}
if (environment.timezone !== undefined) {
const r = validateIntentValue('timezone', environment.timezone, capabilities);
if (r.ok) return r.value;
} }
return fallback; return (validated ?? dim.defaultValue) as TEffective;
} }
function resolveDensity( function resolveDerived<TIntent, TEffective>(
capabilities: PrefsCapabilities, dim: PrefsDimension<TIntent, TEffective>,
intent: PrefsIntent, candidateIntent: unknown,
fallback: Density env: PrefsEnvironment,
): Density { resolvedSiblings: Readonly<Record<string, unknown>>
if (intent.density !== undefined) { ): TEffective {
const r = validateIntentValue('density', intent.density, capabilities); const validated = candidateIntent === undefined ? undefined : tryValidate(dim, candidateIntent);
if (r.ok) return r.value; if (validated !== undefined) {
return validated as unknown as TEffective;
} }
return fallback; if (dim.derive !== undefined) {
return dim.derive(resolvedSiblings, env);
}
return dim.defaultValue;
} }
/** function tryValidate<TIntent, TEffective>(
* Pass-through filter that drops an intent value when it fails dim: PrefsDimension<TIntent, TEffective>,
* validation against `capabilities`. Used for `theme` and `motion`, value: unknown
* whose dedicated `resolve*` helpers (`resolveTheme`, `resolveMotion`) ): TIntent | undefined {
* accept `intent | undefined` directly — passing `undefined` triggers const r = dim.validate(value);
* the helper's "fall back to system hint" branch, which is exactly what
* we want when the stored intent is no longer in capabilities.
*/
function validatedIntent<K extends keyof PrefsIntent>(
key: K,
value: PrefsIntent[K],
capabilities: PrefsCapabilities
): PrefsIntent[K] {
if (value === undefined) return undefined;
const r = validateIntentValue(
key,
value as NonNullable<PrefsIntent[K]>,
capabilities
);
return r.ok ? r.value : undefined; return r.ok ? r.value : undefined;
} }

@ -1,230 +1,81 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import {
booleanDimension,
directionDimension,
enumDimension,
languageDimension,
localeDimension,
themeDimension
} from '$prefs';
import { resolvePrefs } from '../resolve-prefs.ts'; import { resolvePrefs } from '../resolve-prefs.ts';
import type { PrefsCapabilities, PrefsEnvironment, PrefsIntent } from '../types.ts';
const CAPS: PrefsCapabilities = { describe('resolvePrefs — schema-generic resolver', () => {
languages: ['es-ES', 'en-US', 'ar-EG'], const schema = {
locales: ['es-ES', 'en-US', 'ar-EG'], language: languageDimension({ catalog: ['es', 'en'], default: 'es' }),
currencies: ['EUR', 'USD'], locale: localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' }),
unitSystems: ['metric', 'imperial'], theme: themeDimension({ default: 'light' }),
themes: ['light', 'dark', 'system'], direction: directionDimension(),
densities: ['compact', 'comfortable', 'spacious'], flag: booleanDimension({ default: false }),
motions: ['allow', 'reduce', 'system'], mode: enumDimension(['compact', 'roomy'] as const, { default: 'roomy' })
defaults: { };
language: 'es-ES',
locale: 'es-ES', it('returns dimension defaults when intent and environment are empty', () => {
currency: 'EUR', const eff = resolvePrefs({ schema, environment: {}, intent: {} });
timezone: 'Europe/Madrid', expect(eff.language).toBe('es');
unitSystem: 'metric', expect(eff.locale).toBe('es-ES');
theme: 'light', expect(eff.theme).toBe('light');
density: 'comfortable', expect(eff.flag).toBe(false);
motion: 'allow', expect(eff.mode).toBe('roomy');
direction: 'ltr' });
}
}; it('intent overrides environment and defaults', () => {
const eff = resolvePrefs({
const EMPTY_ENV: PrefsEnvironment = {}; schema,
describe('resolvePrefs', () => {
it('returns capabilities.defaults when intent and environment are empty', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: EMPTY_ENV,
intent: {}
});
expect(effective).toEqual(CAPS.defaults);
});
it('intent wins over environment when both are valid', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], currency: 'USD' },
intent: { language: 'es-ES', locale: 'es-ES', currency: 'EUR' }
});
expect(effective.language).toBe('es-ES');
expect(effective.locale).toBe('es-ES');
expect(effective.currency).toBe('EUR');
});
it('environment.locales wins when intent is absent', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'], currency: 'USD' },
intent: {}
});
expect(effective.language).toBe('en-US');
expect(effective.locale).toBe('en-US');
expect(effective.currency).toBe('USD');
});
it('falls through invalid intent to environment, then defaults', () => {
const intent: PrefsIntent = {
language: 'fr-FR',
locale: 'fr-FR',
currency: 'JPY'
};
const env: PrefsEnvironment = { locales: ['en-US'], currency: 'USD' };
const effective = resolvePrefs({
capabilities: CAPS,
environment: env,
intent
});
expect(effective.language).toBe('en-US');
expect(effective.locale).toBe('en-US');
expect(effective.currency).toBe('USD');
});
it('routes environment.locales through the matcher (es-MX → es-ES)', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['es-MX', 'en-US'] },
intent: {}
});
expect(effective.language).toBe('es-ES');
expect(effective.locale).toBe('es-ES');
});
it('language and locale resolve INDEPENDENTLY against their own catalogs', () => {
// capabilities.languages = ['es-ES'] only — but capabilities.locales
// supports en-US too. Same env.locales[] must produce different
// effective fields.
const splitCaps: PrefsCapabilities = {
...CAPS,
languages: ['es-ES'],
locales: ['es-ES', 'en-US']
};
const effective = resolvePrefs({
capabilities: splitCaps,
environment: { locales: ['en-US', 'es-ES'] },
intent: {}
});
// language can't be en-US (not in languages catalog) → falls to es-ES
expect(effective.language).toBe('es-ES');
// locale CAN be en-US (first valid candidate against locales catalog)
expect(effective.locale).toBe('en-US');
});
it('currency derives from environment.locales when intent and env.currency are absent', () => {
// es-MX has no region match in EUR/USD catalog → walk continues
// to en-US → USD.
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['es-MX', 'en-US'] },
intent: {}
});
expect(effective.currency).toBe('USD');
});
it('unitSystem derives from environment.locales when intent and env.unitSystem are absent', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: { locales: ['en-US'] }, environment: { locales: ['en-US'] },
intent: {} intent: { locale: 'es-ES', flag: true }
}); });
expect(effective.unitSystem).toBe('imperial'); expect(eff.locale).toBe('es-ES');
expect(eff.flag).toBe(true);
}); });
it('environment.currency wins over locale-derived currency', () => { it('environment fills dimensions that have a fromEnvironment hook', () => {
// en-US would derive USD via locale projection, but env.currency=EUR const eff = resolvePrefs({
// is the explicit hint and wins. schema,
const effective = resolvePrefs({ environment: { locales: ['en-US'], colorScheme: 'dark' },
capabilities: CAPS,
environment: { locales: ['en-US'], currency: 'EUR' },
intent: {} intent: {}
}); });
expect(effective.currency).toBe('EUR'); expect(eff.locale).toBe('en-US');
expect(eff.theme).toBe('dark');
}); });
it('resolves theme=system to the environment colorScheme', () => { it('drops invalid intent silently (resolver never throws)', () => {
const effectiveDark = resolvePrefs({ const eff = resolvePrefs({
capabilities: CAPS, schema,
environment: { colorScheme: 'dark' },
intent: { theme: 'system' }
});
expect(effectiveDark.theme).toBe('dark');
const effectiveLight = resolvePrefs({
capabilities: CAPS,
environment: { colorScheme: 'light' },
intent: { theme: 'system' }
});
expect(effectiveLight.theme).toBe('light');
});
it('resolves theme=system to defaults when environment lacks colorScheme', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: {}, environment: {},
intent: { theme: 'system' } intent: { mode: 'unsupported' as never }
}); });
expect(effective.theme).toBe(CAPS.defaults.theme); expect(eff.mode).toBe('roomy');
}); });
it('resolves motion=system through environment.reducedMotion', () => { it('derived dimensions read sibling effective values in pass 2', () => {
const reduce = resolvePrefs({ const arabicSchema = {
capabilities: CAPS, ...schema,
environment: { reducedMotion: true }, language: languageDimension({ catalog: ['ar', 'en'], default: 'en' })
intent: { motion: 'system' } };
}); const eff = resolvePrefs({
expect(reduce.motion).toBe('reduce'); schema: arabicSchema,
const allow = resolvePrefs({
capabilities: CAPS,
environment: { reducedMotion: false },
intent: { motion: 'system' }
});
expect(allow.motion).toBe('allow');
});
it('derives direction from the resolved language (RTL)', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: {},
intent: { language: 'ar-EG', locale: 'ar-EG' }
});
expect(effective.language).toBe('ar-EG');
expect(effective.direction).toBe('rtl');
});
it('direction follows language even when locale is LTR', () => {
// Pathological-but-legal: language ar-EG, locale en-US (the user
// wants Arabic UI but US-style number/date formatting). Direction
// must follow language.
const effective = resolvePrefs({
capabilities: CAPS,
environment: {}, environment: {},
intent: { language: 'ar-EG', locale: 'en-US' } intent: { language: 'ar' }
}); });
expect(effective.language).toBe('ar-EG'); expect(eff.direction).toBe('rtl');
expect(effective.locale).toBe('en-US');
expect(effective.direction).toBe('rtl');
}); });
it('density has no environment hint and falls back to default', () => { it('derived dimensions accept user intent overrides over derive', () => {
const effective = resolvePrefs({ const eff = resolvePrefs({
capabilities: CAPS, schema,
environment: {}, environment: {},
intent: {} intent: { direction: 'rtl' }
});
expect(effective.density).toBe(CAPS.defaults.density);
});
it('effective is total: every field present', () => {
const effective = resolvePrefs({
capabilities: CAPS,
environment: EMPTY_ENV,
intent: {}
}); });
expect(Object.keys(effective).sort()).toEqual([ expect(eff.direction).toBe('rtl');
'currency',
'density',
'direction',
'language',
'locale',
'motion',
'theme',
'timezone',
'unitSystem'
]);
}); });
}); });

@ -1,153 +1,53 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { PrefsCapabilities } from '../types.ts'; import { booleanDimension, enumDimension, localeDimension } from '$prefs';
import { sanitizeIntent, validateIntentValue } from '../validate-intent.ts'; import { sanitizeIntent, validateIntentValue } from '../validate-intent.ts';
const CAPS: PrefsCapabilities = { const localeDim = localeDimension({ catalog: ['es-ES', 'en-US'], default: 'es-ES' });
languages: ['es-ES', 'en-US'], const flagDim = booleanDimension({ default: false });
locales: ['es-ES', 'en-US'], const modeDim = enumDimension(['compact', 'roomy'] as const, { default: 'roomy' });
currencies: ['EUR', 'USD'],
unitSystems: ['metric', 'imperial'],
themes: ['light', 'dark', 'system'],
densities: ['compact', 'comfortable', 'spacious'],
motions: ['allow', 'reduce', 'system'],
timezones: ['Europe/Madrid', 'America/New_York'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid',
unitSystem: 'metric',
theme: 'light',
density: 'comfortable',
motion: 'allow',
direction: 'ltr'
}
};
describe('validateIntentValue', () => { describe('validateIntentValue — dispatches to dimension.validate', () => {
it('accepts a language that is in capabilities.languages', () => { it('passes through ok results', () => {
expect(validateIntentValue('language', 'en-US', CAPS)).toEqual({ expect(validateIntentValue(localeDim, 'es-ES')).toEqual({ ok: true, value: 'es-ES' });
ok: true,
value: 'en-US'
});
});
it('rejects a language that is NOT in capabilities.languages', () => {
expect(validateIntentValue('language', 'fr-FR', CAPS)).toEqual({
ok: false,
reason: 'unsupported_language'
});
});
it('language and locale validate against independent catalogs', () => {
// `capabilities.languages` does not have to equal
// `capabilities.locales` — a value in one list may legitimately
// be absent from the other.
const splitCaps: PrefsCapabilities = {
...CAPS,
languages: ['es-ES'],
locales: ['es-ES', 'en-US']
};
expect(validateIntentValue('language', 'en-US', splitCaps)).toEqual({
ok: false,
reason: 'unsupported_language'
});
expect(validateIntentValue('locale', 'en-US', splitCaps)).toEqual({
ok: true,
value: 'en-US'
});
});
it('accepts a locale that is in capabilities', () => {
expect(validateIntentValue('locale', 'en-US', CAPS)).toEqual({
ok: true,
value: 'en-US'
});
}); });
it('rejects a locale that is NOT in capabilities', () => { it('reports the dimension reason on failure', () => {
expect(validateIntentValue('locale', 'fr-FR', CAPS)).toEqual({ expect(validateIntentValue(localeDim, 'fr-FR')).toEqual({
ok: false, ok: false,
reason: 'unsupported_locale' reason: 'unsupported_locale'
}); });
}); });
it('rejects a currency outside the catalog', () => { it('rejects type-mismatch values', () => {
expect(validateIntentValue('currency', 'JPY', CAPS)).toEqual({ expect(validateIntentValue(flagDim, 'no')).toEqual({
ok: false,
reason: 'unsupported_currency'
});
});
it('accepts theme `system` when capabilities allow it', () => {
expect(validateIntentValue('theme', 'system', CAPS)).toEqual({
ok: true,
value: 'system'
});
});
it('rejects theme `system` when capabilities exclude it', () => {
const restrictedCaps: PrefsCapabilities = { ...CAPS, themes: ['light', 'dark'] };
expect(validateIntentValue('theme', 'system', restrictedCaps)).toEqual({
ok: false, ok: false,
reason: 'unsupported_theme' reason: 'invalid_boolean'
}); });
}); });
});
it('returns the runtime canonical form of a valid timezone', () => { describe('sanitizeIntent — schema-driven filter', () => {
// Whatever `Intl.DateTimeFormat(...).resolvedOptions().timeZone` const schema = { locale: localeDim, flag: flagDim, mode: modeDim };
// produces on this runtime is, by definition, the canonical
// form. The validator returns that exact string so callers
// persist a stable identifier — exact equality with a target
// alias is runtime-dependent and not part of the contract.
const result = validateIntentValue('timezone', 'Europe/Madrid', CAPS);
expect(result.ok).toBe(true);
if (result.ok) expect(result.value).toBe('Europe/Madrid');
});
it('rejects an unrecognizable timezone string with `invalid_timezone`', () => {
expect(validateIntentValue('timezone', 'Not/A/Zone', CAPS)).toEqual({
ok: false,
reason: 'invalid_timezone'
});
});
it('rejects a valid IANA timezone outside `capabilities.timezones`', () => { it('keeps valid entries and drops invalid ones', () => {
expect(validateIntentValue('timezone', 'Asia/Tokyo', CAPS)).toEqual({ const out = sanitizeIntent(schema, {
ok: false, locale: 'es-ES',
reason: 'unsupported_timezone' flag: true,
mode: 'unsupported'
}); });
expect(out).toEqual({ locale: 'es-ES', flag: true });
}); });
it('accepts any valid IANA timezone when `capabilities.timezones` is omitted', () => { it('drops keys that are not in the schema', () => {
const openCaps: PrefsCapabilities = { ...CAPS, timezones: undefined }; const out = sanitizeIntent(schema, {
expect(validateIntentValue('timezone', 'Asia/Tokyo', openCaps)).toEqual({ locale: 'en-US',
ok: true, rogue: 'x'
value: 'Asia/Tokyo'
}); });
}); expect(out).toEqual({ locale: 'en-US' });
});
describe('sanitizeIntent', () => {
it('drops fields whose value fails validation', () => {
const cleaned = sanitizeIntent(
{
locale: 'fr-FR', // not in caps
currency: 'EUR', // ok
theme: 'light' // ok
},
CAPS
);
expect(cleaned).toEqual({ currency: 'EUR', theme: 'light' });
});
it('preserves the canonical form returned by the validator', () => {
const cleaned = sanitizeIntent({ timezone: 'Europe/Madrid' }, CAPS);
expect(cleaned).toEqual({ timezone: 'Europe/Madrid' });
}); });
it('returns an empty object when the input is fully invalid', () => { it('skips undefined values without invoking the validator', () => {
const cleaned = sanitizeIntent({ locale: 'fr-FR', currency: 'JPY' }, CAPS); const out = sanitizeIntent(schema, { locale: undefined });
expect(cleaned).toEqual({}); expect(out).toEqual({});
}); });
}); });

@ -1,72 +1,20 @@
import type { Currency } from '$libs/currency'; import type { Currency } from '$libs/currency';
import type { Density } from '$libs/density';
import type { Direction } from '$libs/direction';
import type { Locale } from '$libs/locale'; import type { Locale } from '$libs/locale';
import type { MotionEffective, MotionIntent } from '$libs/motion';
import type { ThemeEffective, ThemeIntent } from '$libs/theme';
import type { Timezone } from '$libs/timezone'; import type { Timezone } from '$libs/timezone';
import type { UnitSystem } from '$libs/units'; import type { UnitSystem } from '$libs/units';
import type { PREFS_CHANGE_CAUSES } from './consts.ts'; import type { PREFS_CHANGE_CAUSES } from './consts.ts';
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Layered state // Detector-fed environment
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// //
// The four layers (`capabilities`, `environment`, `intent`, `effective`) // `PrefsEnvironment` is the raw observed context (Accept-Language,
// plus `defaults` are the model `prefs` owns. Domain primitives // `prefers-color-scheme`, system timezone, etc.). Adapters fill it; the
// (`Locale`, `Currency`, `Theme*`, …) live in their own libs so // engine never reads from globals. Each dimension's `resolve` /
// consumers can depend on a single capability port without importing // `fromEnvironment` decides which environment fields it cares about.
// `prefs`. // New dimensions may add new environment fields by extending this type
// (declaration merging) — the engine treats it as opaque data.
/**
* The legal universe of user-selectable values. The application
* composes this from its own configuration and from peer artifacts'
* advertised catalogs (Lang's translation set, the currency catalog,
* etc.). `prefs` does NOT discover capabilities by importing other
* modules — composition lives one layer above.
*
* `languages` and `locales` are independent lists with different
* sources and intent: `languages` is the i18n catalog (what Lang has
* translations for), `locales` is the regional formatting catalog
* (what Format / Intl-driven layout supports). They may overlap in
* simple apps but the framework treats them as orthogonal.
*/
export interface PrefsCapabilities {
/** BCP-47 tags Lang has translations for. Driven by i18n. */
readonly languages: readonly Locale[];
/** BCP-47 tags the app supports for regional formatting. Driven by product/legal/ops. */
readonly locales: readonly Locale[];
readonly currencies: readonly Currency[];
readonly unitSystems: readonly UnitSystem[];
readonly themes: readonly ThemeIntent[];
readonly densities: readonly Density[];
readonly motions: readonly MotionIntent[];
/**
* Optional allowlist of IANA time zones. When omitted, `prefs`
* accepts any value that canonicalizes through `Intl.DateTimeFormat`.
* When present, the canonicalized timezone must match an entry.
*/
readonly timezones?: readonly Timezone[];
/**
* Final fallback for every effective field. Must be valid against
* the rest of `capabilities` — `prefs` validates this at
* `setCapabilities()` time.
*/
readonly defaults: PrefsEffective;
}
/**
* Detected context — server header parsing, browser APIs, system
* settings. Different from `intent`: the user has not chosen these
* values, the runtime observed them.
*
* Fields are optional because detection is best-effort; SSR may know
* `locales` from `Accept-Language` but not have `prefers-color-scheme`.
*
* Values may fall outside `capabilities` (e.g. `Accept-Language: es-MX`
* when only `es-ES` is in `capabilities.locales`). The resolver bridges
* the gap; `environment` keeps the raw observation for diagnostics.
*/
export interface PrefsEnvironment { export interface PrefsEnvironment {
readonly locales?: readonly Locale[]; readonly locales?: readonly Locale[];
readonly timezone?: Timezone; readonly timezone?: Timezone;
@ -83,124 +31,146 @@ export interface PrefsEnvironment {
readonly source?: 'server' | 'browser' | 'mixed' | 'test'; readonly source?: 'server' | 'browser' | 'mixed' | 'test';
} }
// ─────────────────────────────────────────────────────────────────────
// Validation
// ─────────────────────────────────────────────────────────────────────
/** /**
* What the user explicitly selected. Sparse: a missing field means * Each dimension owns its own validation reasons. A free-form string
* "derive from environment + defaults", NOT "clear it". To clear an * keeps the codes per-dimension without forcing a closed union at the
* intent the engine exposes `clearIntent(key)` so callers cannot * library layer — built-in dimensions still publish stable codes
* confuse "did not write" with "wrote undefined". * (`unsupported_locale`, `invalid_timezone`, …) and app-defined
* dimensions choose their own.
*/ */
export interface PrefsIntent { export type PrefsValidationFailure = string;
readonly language?: Locale;
readonly locale?: Locale; export type PrefsValidationResult<T> =
readonly currency?: Currency; | { readonly ok: true; readonly value: T }
readonly timezone?: Timezone; | { readonly ok: false; readonly reason: PrefsValidationFailure };
readonly unitSystem?: UnitSystem;
readonly theme?: ThemeIntent; // ─────────────────────────────────────────────────────────────────────
readonly density?: Density; // Dimension contract
readonly motion?: MotionIntent; // ─────────────────────────────────────────────────────────────────────
//
// A dimension is the unit of preference. The engine knows nothing about
// "locale" or "theme" specifically — it iterates the schema and asks
// each dimension how to validate, resolve and (optionally) derive its
// value. Built-in dimensions live in `arts/prefs/dimensions/*` and the
// standard preset (`standardPrefsDimensions`) composes them.
//
// `TIntent` and `TEffective` are usually the same. They split for
// dimensions whose intent vocabulary is wider than the resolved one
// (e.g. theme accepts `'system'` as intent but resolves to
// `'light' | 'dark'` based on `environment.colorScheme`).
export interface PrefsDimension<TIntent, TEffective = TIntent> {
/**
* Final fallback when neither intent nor environment yields a value.
* Must be a value the dimension's resolver can return — there is no
* second-chance validation on the default.
*/
readonly defaultValue: TEffective;
/**
* Validate a candidate intent value. Called on every `setIntent()`
* write and on every persisted-intent hydrate. Returning `ok: false`
* triggers `PrefsIntentInvalidError` at the engine boundary.
*/
readonly validate: (value: unknown) => PrefsValidationResult<TIntent>;
/**
* Per-dimension resolver: take the validated intent (or `undefined`
* when no intent is set) plus the environment, return the effective
* value. Default behavior when omitted: `intent ?? defaultValue`.
*
* Theme uses `resolve` to fold `'system'` intent into a concrete
* `'light' | 'dark'` effective using `env.colorScheme`. Locale uses
* it to walk `env.locales[]` against the catalog when intent is
* unset.
*/
readonly resolve?: (intent: TIntent | undefined, env: PrefsEnvironment) => TEffective;
/**
* Derived dimension: read sibling dimensions' effective values.
* Direction uses this — it follows the resolved language. Runs in a
* second pass after non-derived dimensions are resolved. Cycles are
* not supported.
*/
readonly derive?: (
effective: Readonly<Record<string, unknown>>,
env: PrefsEnvironment
) => TEffective;
/**
* Optional capability catalog for introspection (devtools, settings
* UIs, audit reports). Returning `undefined` means "no enumerable
* catalog" — fine for free-form dimensions like `string`-typed
* preferences.
*/
readonly catalog?: () => readonly TIntent[];
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type PrefsSchema = Record<string, PrefsDimension<any, any>>;
// ─────────────────────────────────────────────────────────────────────
// Type derivations
// ─────────────────────────────────────────────────────────────────────
/** /**
* The resolved view of every preference, total and always valid against * Resolved view of every dimension. Total: every schema key is present
* `capabilities`. `prefs` produces this; consumers (or the wiring layer * with the dimension's `TEffective`. `App.prefs.<key>.get()` returns
* that builds capability proxies on top of it) read from here. * the value of that key.
*
* Each field is the result of an INDEPENDENT projection of
* `environment.locales[]` against its own capability catalog (with
* `intent` override). No field derives from another except `direction`,
* which derives from the resolved `language` (because direction is a
* property of the writing system, not of the region).
*
* - `language` (BCP-47) — what Lang reads for translations.
* - `locale` (BCP-47) — what Format / Intl reads for regional layout.
* - `theme` is `'light' | 'dark'` — `'system'` is an intent, not an
* effective value.
* - `motion` is `'allow' | 'reduce'` — same reason.
* - `direction` is derived from `language`; not independently
* selectable as intent.
*/ */
export interface PrefsEffective { export type PrefsEffectiveOf<S extends PrefsSchema> = {
readonly language: Locale; readonly [K in keyof S]: S[K] extends PrefsDimension<infer _TIntent, infer TEffective>
readonly locale: Locale; ? TEffective
readonly currency: Currency; : never;
readonly timezone: Timezone; };
readonly unitSystem: UnitSystem;
readonly theme: ThemeEffective; /**
readonly density: Density; * Sparse view of explicit user choices. A missing key means "fall back
readonly motion: MotionEffective; * to environment / default", NOT "clear it". To clear an intent the
readonly direction: Direction; * engine exposes `clearIntent(key)`.
} */
export type PrefsIntentOf<S extends PrefsSchema> = {
readonly [K in keyof S]?: S[K] extends PrefsDimension<infer TIntent, infer _TEffective>
? TIntent
: never;
};
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Snapshot / events // Snapshot / events
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
/** export interface PrefsSnapshot<S extends PrefsSchema = PrefsSchema> {
* Serializable, immutable view of every layer at one point in time.
* Used for SSR payloads, devtools panels, change-event diffs, and
* `getSnapshot()` reads. Consumers must not mutate the returned tree.
*/
export interface PrefsSnapshot {
readonly capabilities: PrefsCapabilities;
readonly environment: PrefsEnvironment; readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent; readonly intent: PrefsIntentOf<S>;
readonly effective: PrefsEffective; readonly effective: PrefsEffectiveOf<S>;
/**
* Bumped on every committed write. Equivalent snapshots compare
* unequal across writes, so consumers can use it as an optimistic
* version key without doing a deep compare.
*/
readonly version: number; readonly version: number;
} }
export type PrefsChangeCause = (typeof PREFS_CHANGE_CAUSES)[number]; export type PrefsChangeCause = (typeof PREFS_CHANGE_CAUSES)[number];
/** export interface PrefsChangeEvent<S extends PrefsSchema = PrefsSchema> {
* Payload delivered to every `subscribe()` listener. Carries the readonly previous: PrefsSnapshot<S>;
* previous and next snapshots PLUS a pre-computed shallow diff over readonly next: PrefsSnapshot<S>;
* `effective` — by far the most common consumer concern.
*/
export interface PrefsChangeEvent {
readonly previous: PrefsSnapshot;
readonly next: PrefsSnapshot;
/** Sparse map: `{ locale: 'es-ES' }` when only locale changed. */ /** Sparse map: `{ locale: 'es-ES' }` when only locale changed. */
readonly effectiveDiff: Partial<PrefsEffective>; readonly effectiveDiff: Partial<PrefsEffectiveOf<S>>;
readonly cause: PrefsChangeCause; readonly cause: PrefsChangeCause;
} }
export type PrefsChangeHandler = (event: PrefsChangeEvent) => void; export type PrefsChangeHandler<S extends PrefsSchema = PrefsSchema> = (
event: PrefsChangeEvent<S>
) => void;
export type PrefsUnsubscribe = () => void; export type PrefsUnsubscribe = () => void;
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Resolver / engine I/O // Resolver input
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
export interface PrefsResolveInput { export interface PrefsResolveInput<S extends PrefsSchema> {
readonly capabilities: PrefsCapabilities; readonly schema: S;
readonly environment: PrefsEnvironment; readonly environment: PrefsEnvironment;
readonly intent: PrefsIntent; readonly intent: PrefsIntentOf<S>;
} }
// ─────────────────────────────────────────────────────────────────────
// Validation
// ─────────────────────────────────────────────────────────────────────
/**
* Reasons `validateIntentValue` rejects a write. Stable string codes so
* tests and downstream UIs can branch on them without depending on
* message wording.
*/
export type PrefsValidationFailure =
| 'unsupported_language'
| 'unsupported_locale'
| 'unsupported_currency'
| 'unsupported_timezone'
| 'unsupported_unit_system'
| 'unsupported_theme'
| 'unsupported_density'
| 'unsupported_motion'
| 'invalid_timezone';
export type PrefsValidationResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly reason: PrefsValidationFailure };

@ -1,145 +1,38 @@
import type { Currency } from '$libs/currency'; import type { PrefsDimension, PrefsSchema, PrefsValidationResult } from './types.ts';
import type { Density } from '$libs/density';
import type { Locale } from '$libs/locale';
import type { MotionIntent } from '$libs/motion';
import type { ThemeIntent } from '$libs/theme';
import type { Timezone } from '$libs/timezone';
import type { UnitSystem } from '$libs/units';
import type {
PrefsCapabilities,
PrefsIntent,
PrefsValidationResult
} from './types.ts';
/** /**
* Canonicalize an IANA timezone string. Returns `undefined` when the * Per-dimension validation. The `validate` callback inside each dimension
* value is not a recognizable timezone — consumers treat that as * is the single source of truth for what counts as a legal intent value
* `'invalid_timezone'`. * — this helper just dispatches and shapes the result type. Used by the
* * engine on `setIntent` and by `sanitizeIntent` during hydrate.
* Uses `Intl.DateTimeFormat(...).resolvedOptions().timeZone` because it
* applies the same canonicalization the rest of the platform uses
* (`'Asia/Calcutta'` → `'Asia/Kolkata'`, etc.).
*/
function canonicalizeTimezone(timezone: string): string | undefined {
try {
return new Intl.DateTimeFormat('en-US', { timeZone: timezone })
.resolvedOptions()
.timeZone;
} catch {
return undefined;
}
}
/**
* Validate a single (key, value) pair against `capabilities`. Returns
* `{ ok: true, value }` (with `value` possibly canonicalized — only
* `timezone` does this today) or `{ ok: false, reason }` with a stable
* machine-readable reason code from `PrefsValidationFailure`.
*
* The function is the single source of truth for intent validation.
* Both the engine (`setIntent`, `resetIntent`) and the resolver call it,
* so an environment value that "passes" is exactly the same set of
* values an explicit intent could have stored.
*/ */
export function validateIntentValue<K extends keyof PrefsIntent>( export function validateIntentValue<TIntent, TEffective>(
key: K, dim: PrefsDimension<TIntent, TEffective>,
value: NonNullable<PrefsIntent[K]>, value: unknown
capabilities: PrefsCapabilities ): PrefsValidationResult<TIntent> {
): PrefsValidationResult<NonNullable<PrefsIntent[K]>> { return dim.validate(value);
switch (key) {
case 'language': {
const language = value as Locale;
if (capabilities.languages.includes(language)) {
return { ok: true, value: language as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_language' };
}
case 'locale': {
const locale = value as Locale;
if (capabilities.locales.includes(locale)) {
return { ok: true, value: locale as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_locale' };
}
case 'currency': {
const currency = value as Currency;
if (capabilities.currencies.includes(currency)) {
return { ok: true, value: currency as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_currency' };
}
case 'unitSystem': {
const unitSystem = value as UnitSystem;
if (capabilities.unitSystems.includes(unitSystem)) {
return { ok: true, value: unitSystem as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_unit_system' };
}
case 'theme': {
const theme = value as ThemeIntent;
if (capabilities.themes.includes(theme)) {
return { ok: true, value: theme as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_theme' };
}
case 'density': {
const density = value as Density;
if (capabilities.densities.includes(density)) {
return { ok: true, value: density as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_density' };
}
case 'motion': {
const motion = value as MotionIntent;
if (capabilities.motions.includes(motion)) {
return { ok: true, value: motion as NonNullable<PrefsIntent[K]> };
}
return { ok: false, reason: 'unsupported_motion' };
}
case 'timezone': {
const canonical = canonicalizeTimezone(value as string);
if (canonical === undefined) {
return { ok: false, reason: 'invalid_timezone' };
}
if (
capabilities.timezones !== undefined &&
!capabilities.timezones.includes(canonical as Timezone)
) {
return { ok: false, reason: 'unsupported_timezone' };
}
// Return the canonicalized form so callers can persist a
// stable identifier even when the input was an alias.
return { ok: true, value: canonical as NonNullable<PrefsIntent[K]> };
}
}
} }
/** /**
* Sanitize a sparse `PrefsIntent` map by dropping every field whose * Filter a raw intent map (typically loaded from storage) through the
* value fails validation. Used by `resolvePrefs` and at hydrate time so * schema: drop unknown keys and entries that fail their dimension's
* a stale persisted intent (e.g. the app shrank `capabilities.locales`) * validator, keep the canonicalised values produced by `validate`.
* does not poison the effective view.
* *
* Note: rejected entries are dropped, NOT replaced with environment or * Storage hydrate must use this helper rather than passing the raw map
* defaults — the resolver does that downstream. Keeps responsibilities * to `engine.resetIntent()` directly — the engine assumes intent values
* separated: validation says yes/no; resolution decides the substitute. * have already validated against the active schema.
*/ */
export function sanitizeIntent( export function sanitizeIntent<S extends PrefsSchema>(
intent: PrefsIntent, schema: S,
capabilities: PrefsCapabilities intent: Readonly<Record<string, unknown>>
): PrefsIntent { ): Record<string, unknown> {
const out: { -readonly [K in keyof PrefsIntent]?: PrefsIntent[K] } = {}; const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(intent)) {
for (const key of Object.keys(intent) as Array<keyof PrefsIntent>) { if (value === undefined) continue;
const raw = intent[key]; const dim = schema[key];
if (raw === undefined) continue; if (dim === undefined) continue;
const result = validateIntentValue( const result = dim.validate(value);
key, if (result.ok) out[key] = result.value;
raw as NonNullable<PrefsIntent[typeof key]>,
capabilities
);
if (result.ok) (out as Record<string, unknown>)[key] = result.value;
} }
return out; return out;
} }

@ -17,7 +17,7 @@ export const Perms = createEnginePerms({
policies, policies,
providers, providers,
compilers, compilers,
logger: App.Logger logger: App.logger
}); });
``` ```

@ -4,16 +4,30 @@
import PageNav from './_components/PageNav.svelte'; import PageNav from './_components/PageNav.svelte';
const composition = `import { createActiveApp } from '$active-app'; const composition = `import { createActiveApp } from '$active-app';
import {
defineActiveLang,
defineActiveFrontend,
defineActiveFormat
} from '$active-app/services';
const App = createActiveApp({ const App = createActiveApp({
lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: { level: LogLevel.INFO, transports: [consoleTransport()] }, logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
frontend: { theme: 'base' } prefs: {
capabilities,
environment,
intent: { language: 'es', locale: 'es-MX', theme: 'system' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
frontend: defineActiveFrontend({}),
format: defineActiveFormat({})
}
}); });
App.lang.t('common.ok'); App.lang.t('common.ok');
App.format.currency.format(99.5); App.format.currency.format(99.5);
App.lang.setLocale('es-MX');`; App.prefs.locale.set('es-MX');
App.bus.publish('app.ready', {});`;
const sections = [ const sections = [
{ {
@ -37,8 +51,8 @@ App.lang.setLocale('es-MX');`;
title: 'App', title: 'App',
alias: '$active-app', alias: '$active-app',
href: '/active/docs/aapp', href: '/active/docs/aapp',
description: 'Composes Lang, Logger, Format, Frontend, Dom, Storage, Http, Timers and Cache.', description: 'Builds the fixed core Logger, Bus, Timers, Orca and Prefs, then resolves typed services.',
factories: ['createActiveApp'] factories: ['createActiveApp', 'services']
} }
] ]
}, },
@ -106,6 +120,18 @@ App.lang.setLocale('es-MX');`;
} }
] ]
}, },
{
title: 'Preferences & Environment',
items: [
{
title: 'Prefs',
alias: '$prefs',
href: '/active/docs/prefs',
description: 'Cross-cutting user intent, environment defaults and effective runtime preferences.',
factories: ['createEnginePrefs', 'createActivePrefs']
}
]
},
{ {
title: 'I18n & Format', title: 'I18n & Format',
items: [ items: [
@ -159,6 +185,13 @@ App.lang.setLocale('es-MX');`;
{ {
title: 'Infrastructure', title: 'Infrastructure',
items: [ items: [
{
title: 'Bus',
alias: '$bus',
href: '/active/docs/buss',
description: 'Typed event bus used by App core and cross-module notifications.',
factories: ['createSvelteEngineBus']
},
{ {
title: 'Logger', title: 'Logger',
alias: '$logger', alias: '$logger',
@ -173,6 +206,13 @@ App.lang.setLocale('es-MX');`;
description: 'Deterministic timer scheduler: clock injection, intervals, snapshots.', description: 'Deterministic timer scheduler: clock injection, intervals, snapshots.',
factories: ['createActiveTimers', 'createEngineTimers'] factories: ['createActiveTimers', 'createEngineTimers']
}, },
{
title: 'Orca',
alias: '$orca',
href: '/active/docs/orca',
description: 'Cross-module orchestration: staged actions, queue policies, fan-in gates and App presets.',
factories: ['createEngineOrca', 'App.orca']
},
{ {
title: 'Connections', title: 'Connections',
alias: '$connection', alias: '$connection',
@ -195,13 +235,13 @@ App.lang.setLocale('es-MX');`;
<div class="hero-inner"> <div class="hero-inner">
<span class="eyebrow"> <span class="eyebrow">
<span class="dot"></span> <span class="dot"></span>
Active framework — v0.0.1 Active framework — 1.0 candidate
</span> </span>
<h1>The runtime that wires your <span class="hl">SvelteKit</span> app together.</h1> <h1>The runtime that wires your <span class="hl">SvelteKit</span> app together.</h1>
<p class="lead"> <p class="lead">
A composable set of <strong>fifteen runtime artifacts</strong> — A composable set of <strong>runtime artifacts</strong> — core App infrastructure,
i18n, logger, sessions, auth, permissions, cache, storage, HTTP, timers, formatting, and a reactive i18n, preferences, logger, sessions, auth, permissions, cache, storage, HTTP, timers,
frontend layer — built around two factories and one contract. formatting, orchestration, realtime connections and a reactive frontend layer.
</p> </p>
<div class="cta"> <div class="cta">
<a class="btn primary" href="/active/get-started/installation"> <a class="btn primary" href="/active/get-started/installation">
@ -222,7 +262,7 @@ App.lang.setLocale('es-MX');`;
<dl class="hero-stats"> <dl class="hero-stats">
<div> <div>
<dt>Artifacts</dt> <dt>Artifacts</dt>
<dd>15</dd> <dd>18</dd>
</div> </div>
<div> <div>
<dt>Bundle</dt> <dt>Bundle</dt>
@ -243,9 +283,9 @@ App.lang.setLocale('es-MX');`;
<section class="quick"> <section class="quick">
<h2>Quick look</h2> <h2>Quick look</h2>
<p> <p>
Every app starts from <code>$active-app</code>, which wires every artifact and exposes them on Every app starts from <code>$active-app</code>. It always builds the core
a single <code>App</code> object. Identity, permissions, cache and connections are factories <code>Logger</code>, <code>Bus</code>, <code>Timers</code> and <code>Orca</code>, then
built from the same <code>App</code>. declares feature modules as typed services on the same <code>App</code> object.
</p> </p>
<CodeBlock code={composition} lang="ts" title="App composition" /> <CodeBlock code={composition} lang="ts" title="App composition" />
</section> </section>
@ -294,7 +334,7 @@ App.lang.setLocale('es-MX');`;
/> />
<p> <p>
This makes every artifact testable, composable and disposable in the same way — and lets This makes every artifact testable, composable and disposable in the same way — and lets
the <code>aapp</code> composer wire them blindly. the <code>$active-app</code> composer wire them blindly.
</p> </p>
</section> </section>

@ -23,7 +23,7 @@
</ul> </ul>
<h2>Outline</h2> <h2>Outline</h2>
<p>The full page will follow the same shape as <a href="/active/docs/aapp">aapp</a> and <a href="/active/docs/lang">lang</a>:</p> <p>The full page will follow the same shape as <a href="/active/docs/aapp">$active-app</a> and <a href="/active/docs/lang">lang</a>:</p>
<ol> <ol>
<li>Overview — what the artifact is for and what it explicitly does not do.</li> <li>Overview — what the artifact is for and what it explicitly does not do.</li>
<li>Quick start — minimum useful example.</li> <li>Quick start — minimum useful example.</li>

@ -7,7 +7,7 @@
<a class="brand" href="/active" aria-label="Active home"> <a class="brand" href="/active" aria-label="Active home">
<span class="brand-mark" aria-hidden="true"></span> <span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">active</span> <span class="brand-name">active</span>
<span class="brand-version" aria-label="version">0.0.1</span> <span class="brand-version" aria-label="version">1.0</span>
</a> </a>
<nav class="primary" aria-label="Primary"> <nav class="primary" aria-label="Primary">

@ -147,7 +147,7 @@ const artifactApis = {
notes: 'Auth never owns the session cookie directly.' notes: 'Auth never owns the session cookie directly.'
}, },
{ {
name: 'ports.logr / ports.timer / ports.crypto', name: 'ports.logger / ports.timer / ports.crypto',
purpose: 'Logging, clock and crypto primitives.', purpose: 'Logging, clock and crypto primitives.',
notes: 'No direct Date.now() or random string shortcuts in flows.' notes: 'No direct Date.now() or random string shortcuts in flows.'
}, },
@ -224,7 +224,7 @@ const artifactApis = {
{ {
name: 'logger', name: 'logger',
purpose: 'Shared Logger contract.', purpose: 'Shared Logger contract.',
notes: 'App injects App.Logger into App.Bus.' notes: 'App injects App.logger into App.bus.'
}, },
{ {
name: 'clock', name: 'clock',
@ -259,7 +259,7 @@ const artifactApis = {
{ {
name: 'source', name: 'source',
purpose: 'Publisher identity.', purpose: 'Publisher identity.',
notes: 'Defaults to buss; app translators use their module source.' notes: 'Defaults to bus; module publishers can provide their own source.'
}, },
{ {
name: 'correlationId / causationId', name: 'correlationId / causationId',
@ -320,7 +320,7 @@ const artifactApis = {
{ {
name: 'onChange(listener)', name: 'onChange(listener)',
purpose: 'Subscribe to lifecycle changes.', purpose: 'Subscribe to lifecycle changes.',
notes: 'Local lifecycle stream. App.Bus integration uses safe sess.* events when bus is injected.' notes: 'Local lifecycle stream. App.bus integration uses safe session.* events when bus is injected.'
}, },
{ name: 'dispose()', purpose: 'Stop timers/listeners.', notes: 'Called by App.dispose().' } { name: 'dispose()', purpose: 'Stop timers/listeners.', notes: 'Called by App.dispose().' }
] ]
@ -352,7 +352,7 @@ const artifactApis = {
{ {
name: 'bus', name: 'bus',
purpose: 'Optional EventPublisher<SessEventMap>.', purpose: 'Optional EventPublisher<SessEventMap>.',
notes: 'App injects App.Bus so sess.* events can be translated to public app.* events.' notes: 'App injects App.bus so session.* events can be published directly; cross-module reactions are handled by Orca presets.'
}, },
{ {
name: 'broadcastChannel', name: 'broadcastChannel',
@ -687,7 +687,7 @@ const artifactApis = {
{ {
name: 'hooks', name: 'hooks',
purpose: 'Composable request/result middleware.', purpose: 'Composable request/result middleware.',
notes: 'No direct coupling to sess/auth/perm.' notes: 'No direct coupling to session/auth/perm.'
}, },
{ name: 'logger', purpose: 'Shared Logger contract.', notes: 'Injected by App.' } { name: 'logger', purpose: 'Shared Logger contract.', notes: 'Injected by App.' }
] ]
@ -1210,7 +1210,7 @@ const artifactApis = {
{ {
name: 'computeBackoffDelay(options)', name: 'computeBackoffDelay(options)',
purpose: 'Shared exponential/jitter delay.', purpose: 'Shared exponential/jitter delay.',
notes: 'Used by conn reconnect and other retry loops.' notes: 'Used by connection reconnect and other retry loops.'
}, },
{ {
name: 'DEFAULT_BACKOFF_*', name: 'DEFAULT_BACKOFF_*',
@ -1387,12 +1387,12 @@ export const artifactDocs = {
overview: [ overview: [
'Auth is split into a shared language package, a server-authoritative engine and an active Svelte client. The server engine owns identity proof, CSRF, password/recovery flows, OAuth/MFA primitives, device methods and security events. The active client currently exposes current, password, recovery, email verification, sign-out and device methods over HTTP.', 'Auth is split into a shared language package, a server-authoritative engine and an active Svelte client. The server engine owns identity proof, CSRF, password/recovery flows, OAuth/MFA primitives, device methods and security events. The active client currently exposes current, password, recovery, email verification, sign-out and device methods over HTTP.',
'Use Auth when the app needs to sign users in or out, load the current actor, request verification or reset flows, and bind that identity proof to session state. Do not use Auth to decide permissions or to store long-lived credentials in the browser.', 'Use Auth when the app needs to sign users in or out, load the current actor, request verification or reset flows, and bind that identity proof to session state. Do not use Auth to decide permissions or to store long-lived credentials in the browser.',
'In an App composition, Auth receives App.http for route calls, App.cache for invalidation and App.Logger for diagnostics.' 'In an App composition, Auth receives App.logger from the core and talks to server auth routes through the HTTP client supplied in its options. Cache invalidation is not wired inside Auth; it belongs to Orca presets.'
], ],
dynamics: [ dynamics: [
'The server creates an EngineAuth with ports. Those ports are the real integration points: store persists auth records, actors maps credentials to actor refs, sess starts or ends sessions, cach clears identity-scoped data, and logr records security events.', 'The server creates an EngineAuth with ports. Those ports are the real integration points: store persists auth records, actors maps credentials to actor refs, session starts or ends sessions, cache clears identity-scoped data, and logger records security events.',
'The browser creates ActiveAuth only as a reflector. It loads /current, sends CSRF-protected commands to server routes, updates current after successful responses and emits local state changes for UI. A protected server action must never trust ActiveAuth state.', 'The browser creates ActiveAuth only as a reflector. It loads /current, sends CSRF-protected commands to server routes, updates current after successful responses and emits local state changes for UI. A protected server action must never trust ActiveAuth state.',
'The normal request path is: server hook resolves current auth, page load serializes a safe AuthCurrentView, ActiveAuth hydrates that snapshot, user triggers sign-in/out, server mutates session, and app-event consumers react only when their own auto*On options opt in.' 'The normal request path is: server hook resolves current auth, page load serializes a safe AuthCurrentView, ActiveAuth hydrates that snapshot, user triggers sign-in/out, server mutates session, and any cross-module reactions run only when the app registers the corresponding Orca presets.'
], ],
commonMistakes: [ commonMistakes: [
{ {
@ -1440,7 +1440,7 @@ await App.auth.signOut();`
name: 'createEngineAuth(options)', name: 'createEngineAuth(options)',
purpose: 'Creates the server-side authority for auth flows.', purpose: 'Creates the server-side authority for auth flows.',
notes: notes:
'Lives in $svrs/auth and receives store, actors, sess, cach, logger, crypto and hasher ports.' 'Lives in $svrs/auth and receives store, actors, session, cache, logger, crypto and hasher ports.'
}, },
{ {
name: 'createActiveAuth(options)', name: 'createActiveAuth(options)',
@ -1468,7 +1468,7 @@ await App.auth.signOut();`
code: `// server code: `// server
const Auth = createEngineAuth({ const Auth = createEngineAuth({
security, security,
ports: { store, actors, sess, cach, logger, timer, crypto, passwordHasher } ports: { store, actors, sess, cache, logger, timer, crypto, passwordHasher }
}); });
export const GET = Auth.handlers.current; export const GET = Auth.handlers.current;
@ -1526,9 +1526,9 @@ const App = createActiveApp({
store, store,
actors, actors,
sess, sess,
cach, cache,
logger: App.Logger, logger,
timer: App.Timers, timer: App.timers,
crypto, crypto,
passwordHasher, passwordHasher,
mailer mailer
@ -1621,19 +1621,19 @@ const App = createActiveApp({
title: 'Bus', title: 'Bus',
alias: '$bus', alias: '$bus',
summary: summary:
'Typed event engine used by App.Bus for cross-artifact facts. Pure contracts live in $libs/bus; the engine implementation lives in $bus.', 'Typed event engine used by App.bus for cross-artifact facts. Pure contracts live in $libs/bus; the engine implementation lives in $bus.',
factories: ['createEngineBus'], factories: ['createEngineBus'],
dependsOn: ['$libs/logger (Logger interface, optional)'], dependsOn: ['$libs/logger (Logger interface, optional)'],
layer: 'EngineBus', layer: 'EngineBus',
status: { status: {
variant: 'tip', variant: 'tip',
title: 'Two-layer split', title: 'Two-layer split',
body: 'Modules import interfaces, error classes, SILENT_BUS and helpers from $libs/bus. The composition root (aapp) and tests import the createEngineBus implementation from $bus. Modules must never import from $bus directly — that mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.' body: 'Modules import interfaces, error classes, SILENT_BUS and helpers from $libs/bus. The composition root ($active-app) and tests import the createEngineBus implementation from $bus. Modules must never import from $bus directly — that mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.'
}, },
overview: [ overview: [
'Bus is the low-level event engine. It owns typed subscriptions, envelopes, listener error handling, listener leak warnings and disposal.', 'Bus is the low-level event engine. It owns typed subscriptions, envelopes, listener error handling, listener leak warnings and disposal.',
'The contract surface (EngineBus, EventPublisher, BusEnvelope, BusListener, error classes, SILENT_BUS, generic constants) lives in $libs/bus as pure types and helpers — no runtime state. The engine factory createEngineBus() lives in $bus and imports its types from $libs/bus.', 'The contract surface (EngineBus, EventPublisher, BusEnvelope, BusListener, error classes, SILENT_BUS, generic constants) lives in $libs/bus as pure types and helpers — no runtime state. The engine factory createEngineBus() lives in $bus and imports its types from $libs/bus.',
'The only event App owns is APP_EVENT_DISPOSE_STARTING (in arts/active-app/events.ts). Every other public event is owned by its module — SESSION_EVENT_IDENTITY_CHANGED, SESSION_EVENT_REVOKED, etc. Modules publish their own events directly on App.Bus.', 'The only event App owns is APP_EVENT_DISPOSE_STARTING (in arts/active-app/events.ts). Every other public event is owned by its module — SESSION_EVENT_IDENTITY_CHANGED, SESSION_EVENT_REVOKED, etc. Modules publish their own events directly on App.bus.',
'Cross-module reactions are explicit: cache.clear / perm.invalidate / connection.reauth on identity change live as orca actions registered through applyStandardOrca(App) or the cherry-picked apply* presets in $active-app/presets. The bus stays inert — it carries events; orca runs reactions.' 'Cross-module reactions are explicit: cache.clear / perm.invalidate / connection.reauth on identity change live as orca actions registered through applyStandardOrca(App) or the cherry-picked apply* presets in $active-app/presets. The bus stays inert — it carries events; orca runs reactions.'
], ],
dynamics: [ dynamics: [
@ -1646,7 +1646,7 @@ const App = createActiveApp({
{ {
name: 'importing from $bus in module code', name: 'importing from $bus in module code',
purpose: '$bus exposes the concrete engine; module code must depend only on the contract layer.', purpose: '$bus exposes the concrete engine; module code must depend only on the contract layer.',
notes: 'Import EngineBus, EventPublisher, BusSubscription, BusEnvelope, SILENT_BUS, etc. from $libs/bus. Only aapp and tests touch $bus.' notes: 'Import EngineBus, EventPublisher, BusSubscription, BusEnvelope, SILENT_BUS, etc. from $libs/bus. Only $active-app and tests touch $bus.'
}, },
{ {
name: 'putting secrets in app events', name: 'putting secrets in app events',
@ -1661,12 +1661,12 @@ const App = createActiveApp({
{ {
name: 'using bus for private in-module events', name: 'using bus for private in-module events',
purpose: 'It creates needless coupling and noise.', purpose: 'It creates needless coupling and noise.',
notes: 'Keep private event emitters inside the artifact; use App.Bus for cross-artifact facts.' notes: 'Keep private event emitters inside the artifact; use App.bus for cross-artifact facts.'
}, },
{ {
name: 'creating createEngineBus() inside modules', name: 'creating createEngineBus() inside modules',
purpose: 'It fragments the event graph and makes cross-artifact behavior invisible.', purpose: 'It fragments the event graph and makes cross-artifact behavior invisible.',
notes: 'Use App.Bus in application code; modules should accept an injected bus/publisher.' notes: 'Use App.bus in application code; modules should accept an injected bus/publisher.'
}, },
{ {
name: 'publishing inline event strings', name: 'publishing inline event strings',
@ -1675,11 +1675,11 @@ const App = createActiveApp({
} }
], ],
quickStart: { quickStart: {
title: 'Central App.Bus', title: 'Central App.bus',
code: `import { SESSION_EVENT_IDENTITY_CHANGED } from '$session'; code: `import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
// Direct subscription — UI-style reactions // Direct subscription — UI-style reactions
const sub = App.Bus.on(SESSION_EVENT_IDENTITY_CHANGED, (event) => { const sub = App.bus.on(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
console.log(event.payload.identity.to); console.log(event.payload.identity.to);
}); });
@ -1706,7 +1706,7 @@ sub.unsubscribe();`
notes: 'Importable only from $bus (the artifact). Used by App and tests; never inside an artifact module.' notes: 'Importable only from $bus (the artifact). Used by App and tests; never inside an artifact module.'
}, },
{ {
name: 'App.Bus', name: 'App.bus',
purpose: 'Application bus instance, always-present.', purpose: 'Application bus instance, always-present.',
notes: 'Created by createActiveApp with Logger and Timers.clock injected. Modules receive it through their factory options.' notes: 'Created by createActiveApp with Logger and Timers.clock injected. Modules receive it through their factory options.'
} }
@ -1718,7 +1718,7 @@ sub.unsubscribe();`
body: [ body: [
'libs/bus owns the pure contract: interfaces, generic constants, error classes, SILENT_BUS, helpers. Nothing here has runtime state. Module code (cache, session, perm, connection, …) imports types from $libs/bus exclusively — that is the rule.', 'libs/bus owns the pure contract: interfaces, generic constants, error classes, SILENT_BUS, helpers. Nothing here has runtime state. Module code (cache, session, perm, connection, …) imports types from $libs/bus exclusively — that is the rule.',
'arts/bus owns the engine implementation (createEngineBus, createSvelteEngineBus) and the Svelte-context bridge (setBus / getBus, exported from $bus). The composition root (active-app) and tests import the engine from $bus; nothing else does. The split mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.', 'arts/bus owns the engine implementation (createEngineBus, createSvelteEngineBus) and the Svelte-context bridge (setBus / getBus, exported from $bus). The composition root (active-app) and tests import the engine from $bus; nothing else does. The split mirrors the Logger / SILENT_LOGGER pattern in $libs/logger.',
'A module that needs to publish or subscribe accepts a bus through its factory options, typed against the interface from $libs/bus. The App service builder passes App.Bus when constructing services that declare bus as a coreDependency; otherwise the module falls back to SILENT_BUS so the publish path stays unconditional.' 'A module that needs to publish or subscribe accepts a bus through its factory options, typed against the interface from $libs/bus. The App service builder passes App.bus when constructing services that declare bus as a coreDependency; otherwise the module falls back to SILENT_BUS so the publish path stays unconditional.'
], ],
table: [ table: [
{ {
@ -1744,7 +1744,7 @@ sub.unsubscribe();`
], ],
code: { code: {
title: 'Canonical module factory pattern', title: 'Canonical module factory pattern',
code: `// arts/sess/types.ts code: `// arts/session/types.ts
import type { EventPublisher } from '$libs/bus'; import type { EventPublisher } from '$libs/bus';
export interface EngineSessionOptions<TUser, TCredential, TData> { export interface EngineSessionOptions<TUser, TCredential, TData> {
@ -1753,7 +1753,7 @@ export interface EngineSessionOptions<TUser, TCredential, TData> {
// ... // ...
} }
// arts/sess/engine-session.ts // arts/session/engine-session.ts
import { SILENT_BUS } from '$libs/bus'; import { SILENT_BUS } from '$libs/bus';
export function createEngineSession(options: EngineSessionOptions) { export function createEngineSession(options: EngineSessionOptions) {
@ -1767,8 +1767,8 @@ export function createEngineSession(options: EngineSessionOptions) {
title: 'Naming Convention — Scoped event values', title: 'Naming Convention — Scoped event values',
body: [ body: [
'Every event constant value across the framework is scoped with the artifact prefix to disambiguate aggregated logs, devtools and any future cross-bus serialization. A bare "delete" or "hit" leaves observers guessing which module emitted it; "cache.delete" or "cache.hit" does not.', 'Every event constant value across the framework is scoped with the artifact prefix to disambiguate aggregated logs, devtools and any future cross-bus serialization. A bare "delete" or "hit" leaves observers guessing which module emitted it; "cache.delete" or "cache.hit" does not.',
'Format: <artifact>.<concept> with snake_case for compound terms inside a level (cach.stale_if_error) and dots for hierarchy (cach.refresh.start). Same convention auth has used since v0 with AUTH_EVENT_NAMES.', 'Format: <artifact>.<concept> with snake_case for compound terms inside a level (cache.stale_if_error) and dots for hierarchy (cache.refresh.start). Same convention auth has used since v0 with AUTH_EVENT_NAMES.',
'Method labels passed to ensureLive(method) follow the same rule: sess.adopt, cach.invalidate, auth.signOut. Bare names like "adopt" or "invalidate" never appear in error messages.' 'Method labels passed to ensureLive(method) follow the same rule: session.adopt, cache.invalidate, auth.signOut. Bare names like "adopt" or "invalidate" never appear in error messages.'
], ],
table: [ table: [
{ {
@ -1792,7 +1792,7 @@ export function createEngineSession(options: EngineSessionOptions) {
notes: '"session.changed", "session.identity.changed", "session.revoked", …' notes: '"session.changed", "session.identity.changed", "session.revoked", …'
}, },
{ {
name: 'EVENT_* (sess lifecycle)', name: 'EVENT_* (session lifecycle)',
purpose: 'Discriminant values inside SessionChange payloads.', purpose: 'Discriminant values inside SessionChange payloads.',
notes: '"session.lifecycle.initial", "session.lifecycle.adopted", "session.lifecycle.revoked", …' notes: '"session.lifecycle.initial", "session.lifecycle.adopted", "session.lifecycle.revoked", …'
}, },
@ -1802,9 +1802,9 @@ export function createEngineSession(options: EngineSessionOptions) {
notes: '"auth.sign_in.succeeded", "auth.csrf.issued", "auth.password.changed", …' notes: '"auth.sign_in.succeeded", "auth.csrf.issued", "auth.password.changed", …'
}, },
{ {
name: 'APP_EVENT_*', name: 'APP_EVENT_DISPOSE_STARTING',
purpose: 'Public app contract events.', purpose: 'The App-owned lifecycle event.',
notes: '"app.user.identity.changed", "app.tenant.switched", …' notes: 'Published once at the start of App.dispose(); other lifecycle events belong to their owning modules.'
}, },
{ {
name: 'PERM_METHOD_* / CACHE_METHOD_* / AUTH_METHOD_* / ENGINE_METHOD_*', name: 'PERM_METHOD_* / CACHE_METHOD_* / AUTH_METHOD_* / ENGINE_METHOD_*',
@ -1829,7 +1829,7 @@ export function createEngineSession(options: EngineSessionOptions) {
{ {
title: 'Centralization rule', title: 'Centralization rule',
body: [ body: [
'There is one app-level bus per application: App.Bus, built by createActiveApp. Modules do not call createEngineBus() for their own private island. They accept an injected bus, EventPublisher or EventSubscriber from the composition root.', 'There is one app-level bus per application: App.bus, built by createActiveApp. Modules do not call createEngineBus() for their own private island. They accept an injected bus, EventPublisher or EventSubscriber from the composition root.',
'createEngineBus() remains public because App, isolated services and unit tests need to build a bus engine, but it is not the normal usage pattern inside framework modules.' 'createEngineBus() remains public because App, isolated services and unit tests need to build a bus engine, but it is not the normal usage pattern inside framework modules.'
] ]
}, },
@ -1903,7 +1903,7 @@ const Chat = App.connections.createConnection('chat', {
// Example transition: login, SSR hydration or actor switch received from server. // Example transition: login, SSR hydration or actor switch received from server.
App.session.adoptServer(nextSessionFromServer); App.session.adoptServer(nextSessionFromServer);
// 1. App.session updates state, then publishes SESSION_EVENT_IDENTITY_CHANGED on App.Bus. // 1. App.session updates state, then publishes SESSION_EVENT_IDENTITY_CHANGED on App.bus.
// 2. Orca runs the registered actions in a single trace: // 2. Orca runs the registered actions in a single trace:
// - cache-clear-on-identity -> App.cache.clear() // - cache-clear-on-identity -> App.cache.clear()
// - perm-invalidate-on-identity -> App.perm.invalidate() // - perm-invalidate-on-identity -> App.perm.invalidate()
@ -1917,19 +1917,19 @@ App.session.adoptServer(nextSessionFromServer);
], ],
tests: [ tests: [
{ {
name: 'src/arts/buss/test', name: 'src/arts/bus/test',
purpose: 'Bus core behavior.', purpose: 'Bus core behavior.',
notes: 'Publish, async publish, once, onAny, listener errors and disposal.' notes: 'Publish, async publish, once, onAny, listener errors and disposal.'
}, },
{ {
name: 'src/arts/aapp/test/active-app.test.ts', name: 'src/arts/active-app/test/ecosystem-orca.test.ts',
purpose: 'App translator behavior.', purpose: 'App/Orca event behavior.',
notes: 'Session changes publish public identity events and dispose publishes starting event.' notes: 'Session events are consumed by Orca presets rather than republished as App shadows.'
}, },
{ {
name: 'src/arts/aapp/test/ecosystem.integration.test.ts', name: 'src/arts/active-app/test/presets.test.ts',
purpose: 'Cross-artifact reactions.', purpose: 'Cross-artifact reactions.',
notes: 'Cache, Perms and Connections react only when their consumer options opt in.' notes: 'Cache, Perms and Connections react only when orca presets are registered.'
} }
] ]
}, },
@ -1946,7 +1946,7 @@ App.session.adoptServer(nextSessionFromServer);
overview: [ overview: [
'Session is not authentication. It does not verify passwords, OAuth callbacks or permissions. It keeps continuity once another layer has established identity.', 'Session is not authentication. It does not verify passwords, OAuth callbacks or permissions. It keeps continuity once another layer has established identity.',
'The session shape has three slots: user, credential and data. User is identity, credential is how the client can refresh or authenticate to the server, and data is session-scoped application state such as tenantId or cartId.', 'The session shape has three slots: user, credential and data. User is identity, credential is how the client can refresh or authenticate to the server, and data is session-scoped application state such as tenantId or cartId.',
'The engine is deterministic and testable: refresh can be deduped, storage is pluggable, timers can be injected and lifecycle changes can emit safe sess.* events when a bus is injected.' 'The engine is deterministic and testable: refresh can be deduped, storage is pluggable, timers can be injected and lifecycle changes can emit safe session.* events when a bus is injected.'
], ],
dynamics: [ dynamics: [
'Session starts from a trusted source: SSR data, an auth success response or a refresh callback. adoptServer() is for data already validated on the server; adopt() validates client-provided values through configured schemas.', 'Session starts from a trusted source: SSR data, an auth success response or a refresh callback. adoptServer() is for data already validated on the server; adopt() validates client-provided values through configured schemas.',
@ -2078,21 +2078,21 @@ App.session.adoptServer(data.session);`
{ {
title: 'Bus integration', title: 'Bus integration',
body: [ body: [
'The session art publishes its own SESSION_EVENT_* events on App.Bus directly. There are no longer any republished APP_EVENT_* shadows; the canonical events are the module ones.', 'The session art publishes its own SESSION_EVENT_* events on App.bus directly. There are no longer any republished APP_EVENT_* shadows; the canonical events are the module ones.',
'Cross-module reactions (cache.clear on revoke, perm.invalidate on identity change, connection reauth) live as orca actions registered through applyStandardOrca(App) or the cherry-picked apply* presets in $active-app/presets.' 'Cross-module reactions (cache.clear on revoke, perm.invalidate on identity change, connection reauth) live as orca actions registered through applyStandardOrca(App) or the cherry-picked apply* presets in $active-app/presets.'
] ]
} }
], ],
tests: [ tests: [
{ {
name: 'src/arts/sess/test', name: 'src/arts/session/test',
purpose: 'Lifecycle and integrations.', purpose: 'Lifecycle and integrations.',
notes: 'Refresh, revoke, actor metadata, HTTP and auto-refresh.' notes: 'Refresh, revoke, actor metadata, HTTP and auto-refresh.'
}, },
{ {
name: 'src/arts/aapp/test/ecosystem.integration.test.ts', name: 'src/arts/active-app/test/ecosystem-orca.test.ts',
purpose: 'Cross-module bus bridge.', purpose: 'Cross-module orchestration.',
notes: 'Session events become app identity events and opted-in consumers react.' notes: 'Session events drive orca presets for cache, permissions and connections.'
}, },
{ {
name: '/test/sess', name: '/test/sess',
@ -2388,7 +2388,7 @@ const Perms = createEnginePerms({
{ {
title: 'Creation and adapter wiring', title: 'Creation and adapter wiring',
body: [ body: [
'App.cache is always present and defaults to an in-memory adapter. That is safe for first use, tests and local UI state, but production private data should configure scopeResolver and an intentional adapter strategy.', 'When cache is declared with defineActiveCache(), App.cache is an ActiveCache service. Omitting adapter uses the in-memory default, which is fine for first use, tests and local UI state; production private data should configure scopeResolver and an intentional adapter strategy.',
'Server-side cache roots should be created from $svrs/cache. Client active cache roots are UI helpers and should not become the source of truth for permission-sensitive data.' 'Server-side cache roots should be created from $svrs/cache. Client active cache roots are UI helpers and should not become the source of truth for permission-sensitive data.'
], ],
code: { code: {
@ -2402,7 +2402,7 @@ const Perms = createEnginePerms({
actorId: Sess.current?.user?.id, actorId: Sess.current?.user?.id,
permissionHash: Perms.snapshot().version permissionHash: Perms.snapshot().version
}), }),
logger: App.Logger logger
});` });`
} }
}, },
@ -2528,7 +2528,7 @@ applyStandardOrca(App);
notes: 'Keys, policies, query, mutation, invalidation and refresh.' notes: 'Keys, policies, query, mutation, invalidation and refresh.'
}, },
{ {
name: 'src/arts/cach/test', name: 'src/arts/cache/test',
purpose: 'Active wrapper.', purpose: 'Active wrapper.',
notes: 'Reactive entries and operation state.' notes: 'Reactive entries and operation state.'
}, },
@ -2595,7 +2595,7 @@ const Storage = createActiveStorage({
namespace: 'app' namespace: 'app'
}); });
// In an application root, App.storage is already an ActiveStorage root. // In an application root, App.storage exists when storage is declared.
const draft = App.storage.entry('profile-draft', () => ({ const draft = App.storage.entry('profile-draft', () => ({
name: '', name: '',
bio: '' bio: ''
@ -2625,6 +2625,11 @@ const locale = App.storage.entry('locale', 'es', {
name: 'createActiveStorage(options)', name: 'createActiveStorage(options)',
purpose: 'Root factory: creates a reactive ActiveStorage.', purpose: 'Root factory: creates a reactive ActiveStorage.',
notes: 'Wraps EngineStorage and adds reactive entries.' notes: 'Wraps EngineStorage and adds reactive entries.'
},
{
name: 'defineActiveStorage(options)',
purpose: 'Service factory for App.storage.',
notes: 'Declared under createActiveApp({ services }).'
} }
], ],
api: artifactApis.stor, api: artifactApis.stor,
@ -2634,14 +2639,14 @@ const locale = App.storage.entry('locale', 'es', {
body: [ body: [
'Storage has one root and many entries. The root is created with createEngineStorage() or createActiveStorage(); entries are created through Storage.entry(key, defaults, options?).', 'Storage has one root and many entries. The root is created with createEngineStorage() or createActiveStorage(); entries are created through Storage.entry(key, defaults, options?).',
'Adapters are not roots. localAdapter, sessionAdapter, cookieAdapter and createMemoryAdapter() implement SyncStorageAdapter. They decide where strings are stored; the root decides namespaces, entry registry, diagnostics and lifecycle.', 'Adapters are not roots. localAdapter, sessionAdapter, cookieAdapter and createMemoryAdapter() implement SyncStorageAdapter. They decide where strings are stored; the root decides namespaces, entry registry, diagnostics and lifecycle.',
'aapp creates App.storage internally with createActiveStorage(). Feature code should normally use App.storage.entry(...). Create your own root only in tests, SSR helpers or isolated subsystems.' 'Inside App, storage is an opt-in service declared with defineActiveStorage(). Feature code should use App.storage.entry(...) only when that service exists. Create your own root in tests, SSR helpers or isolated subsystems.'
], ],
code: { code: {
title: 'Root vs adapter', title: 'Root vs adapter',
code: `const Storage = createActiveStorage({ code: `const Storage = createActiveStorage({
adapter: localAdapter, // backend used by default adapter: localAdapter, // backend used by default
namespace: 'app', // root-level key prefix namespace: 'app', // root-level key prefix
logger: App.Logger logger: App.logger
}); });
const theme = Storage.entry('theme', 'base'); const theme = Storage.entry('theme', 'base');
@ -2728,22 +2733,22 @@ const locale = Storage.entry('locale', 'es', {
} }
}, },
{ {
title: 'Frontend Persistence', title: 'Preference Persistence',
body: [ body: [
'aapp uses Storage to persist Frontend preferences when frontend.persist is enabled. fend owns preference semantics; aapp only bridges them to storage entries.' 'Frontend no longer persists preferences directly. User intent belongs to prefs (now part of the core); persist it by passing a storage adapter into the createActiveApp({ prefs: { storage } }) option, or by calling createPrefsStorageBridge() yourself.'
] ]
} }
], ],
tests: [ tests: [
{ {
name: 'src/arts/stor/test', name: 'src/arts/storage/test',
purpose: 'Entries, adapters and envelopes.', purpose: 'Entries, adapters and envelopes.',
notes: 'TTL, migrate, raw, validation, sync.' notes: 'TTL, migrate, raw, validation, sync.'
}, },
{ {
name: 'src/arts/aapp/test/storage-integration.test.ts', name: 'src/arts/active-app/test/service-factories.test.ts',
purpose: 'App integration.', purpose: 'App integration.',
notes: 'Frontend persistence and adapter overrides.' notes: 'defineActiveStorage wiring and service schema behavior.'
}, },
{ {
name: '/test/stor', name: '/test/stor',
@ -2806,7 +2811,7 @@ const locale = Storage.entry('locale', 'es', {
if (response.ok) { if (response.ok) {
console.log(response.value); console.log(response.value);
} else { } else {
App.Logger.warn('http', 'project request failed', { context: response }); App.logger.warn('http', 'project request failed', { context: response });
}` }`
}, },
factoryRows: [ factoryRows: [
@ -2822,8 +2827,8 @@ if (response.ok) {
}, },
{ {
name: 'App.http', name: 'App.http',
purpose: 'App-wired engine.', purpose: 'Schema-declared HTTP engine via defineEngineHttp.',
notes: 'Injects App.Logger and configured fetch/baseUrl.' notes: 'The App builder injects App.logger when the service is declared.'
} }
], ],
api: artifactApis.http, api: artifactApis.http,
@ -2831,7 +2836,7 @@ if (response.ok) {
{ {
title: 'Creation and request scoping', title: 'Creation and request scoping',
body: [ body: [
'App.http is the normal browser/client client. On the server, create a scoped child with event.fetch so SvelteKit cookies, internal routes and platform behavior are preserved.', 'When http is declared with defineEngineHttp(), App.http is the normal browser/client client. On the server, create a scoped child with event.fetch so SvelteKit cookies, internal routes and platform behavior are preserved.',
'Do not mutate a global Http instance with request-specific headers. Use with() to create a child client for one request, tenant or backend integration.' 'Do not mutate a global Http instance with request-specific headers. Use with() to create a child client for one request, tenant or backend integration.'
], ],
code: { code: {
@ -2891,7 +2896,7 @@ if (response.ok) {
notes: 'Retry, timeout, schemas, hooks and tagged errors.' notes: 'Retry, timeout, schemas, hooks and tagged errors.'
}, },
{ {
name: 'src/arts/sess/test/http-integration.test.ts', name: 'src/arts/session/test',
purpose: '401 rescue.', purpose: '401 rescue.',
notes: 'Session refresh integration.' notes: 'Session refresh integration.'
}, },
@ -2921,7 +2926,7 @@ if (response.ok) {
layer: 'EngineFormat / ActiveFormat', layer: 'EngineFormat / ActiveFormat',
overview: [ overview: [
'Format centralizes everything that depends on locale but is not text translation: numeric separators, currency, units and date/time conventions.', 'Format centralizes everything that depends on locale but is not text translation: numeric separators, currency, units and date/time conventions.',
'It deliberately does not depend on Lang. Both consume the same LocaleSource when composed through App, so changing App locale updates translations and formats from one source of truth.', 'It deliberately does not depend on Lang. When composed through App, Format prefers prefs.effective.locale; Lang prefers prefs.effective.language. Apps that do not declare prefs can still wire an explicit LocaleSource or fall back to Lang.',
'Each submodule can run as an engine or active wrapper. Auto values derive from locale until the user sets an explicit override.' 'Each submodule can run as an engine or active wrapper. Auto values derive from locale until the user sets an explicit override.'
], ],
dynamics: [ dynamics: [
@ -2953,7 +2958,7 @@ if (response.ok) {
], ],
quickStart: { quickStart: {
title: 'App formats', title: 'App formats',
code: `App.lang.setLocale('es-AR'); code: `App.prefs.locale.set('es-AR');
App.format.numbers.format(1234.5); App.format.numbers.format(1234.5);
App.format.currency.getCurrency(); // ARS App.format.currency.getCurrency(); // ARS
@ -2982,12 +2987,13 @@ App.format.dates.getDateOrder();`
{ {
title: 'Creation and locale source', title: 'Creation and locale source',
body: [ body: [
'When Format is created through App, it receives a LocaleSource backed by App.lang. That is the intended wiring: one locale change updates translations, numbers, currency, units, dates and Frontend direction.', 'When Format is created through App, it receives its LocaleSource from core App.prefs. That keeps regional formatting locale separate from translation language while still using one preference snapshot.',
'Create standalone sub-engines only when a non-UI service needs one formatting domain. UI code should prefer App.format so auto/manual state stays consistent.' 'Create standalone sub-engines only when a non-UI service needs one formatting domain. UI code should prefer App.format so auto/manual state stays consistent.'
], ],
code: { code: {
title: 'App-owned locale propagation', title: 'App-owned locale propagation',
code: `App.lang.setLocale('es-AR'); code: `App.prefs.language.set('es');
App.prefs.locale.set('es-AR');
App.lang.t('common.ok'); App.lang.t('common.ok');
App.format.currency.getCurrency(); // ARS App.format.currency.getCurrency(); // ARS
@ -3042,14 +3048,14 @@ Format.currency.getCurrency(); // USD, explicit user choice`
], ],
tests: [ tests: [
{ {
name: 'src/arts/fmts/test', name: 'src/arts/format/test',
purpose: 'Formatting engines.', purpose: 'Formatting engines.',
notes: 'Numbers, currency, units, dates and auto-state.' notes: 'Numbers, currency, units, dates and auto-state.'
}, },
{ {
name: 'src/arts/aapp/test/active-app.test.ts', name: 'src/arts/active-app/test/prefs-consumer-wiring.test.ts',
purpose: 'Locale propagation.', purpose: 'Prefs propagation.',
notes: 'App locale updates Format.' notes: 'Core Prefs drives Format locale when the Format service is declared.'
}, },
{ {
name: '/test/fmts', name: '/test/fmts',
@ -3071,12 +3077,12 @@ Format.currency.getCurrency(); // USD, explicit user choice`
overview: [ overview: [
'Frontend is not a component system. It owns global presentation preferences and writes stable attributes to the configured DOM target.', 'Frontend is not a component system. It owns global presentation preferences and writes stable attributes to the configured DOM target.',
'dir, mode and reducedMotion can be auto. theme, density and reducedSound are explicit preferences. The same auto/manual dynamic used by Format applies here.', 'dir, mode and reducedMotion can be auto. theme, density and reducedSound are explicit preferences. The same auto/manual dynamic used by Format applies here.',
'When built through App, Frontend consumes App.lang as LocaleSource and App.dom as the DOM writer.' 'When built through App, Frontend consumes core App.prefs plus optional App.dom and App.lang services. Prefs drives mode, density, motion and direction; Lang remains a fallback locale source only when the preference snapshot has no effective locale.'
], ],
dynamics: [ dynamics: [
'Frontend reads locale and environment preferences, resolves auto-capable values, and writes the result to DOM attributes through Dom.apply(). Components then style against those attributes instead of each component recalculating theme, direction or density.', 'Frontend reads locale and environment preferences, resolves auto-capable values, and writes the result to DOM attributes through Dom.apply(). Components then style against those attributes instead of each component recalculating theme, direction or density.',
'The auto/manual dynamic matches Format. While dir/mode/reducedMotion are auto, locale or media-query changes can update them. Once the user sets a value explicitly, later auto sources stop overriding it until clearX() is called.', 'The auto/manual dynamic matches Format. While dir/mode/reducedMotion are auto, locale or media-query changes can update them. Once the user sets a value explicitly, later auto sources stop overriding it until clearX() is called.',
'Persistence is not owned by Frontend. Frontend emits preference changes; aapp can bridge selected keys to Storage when frontend.persist is configured.' 'Persistence is not owned by Frontend. User intent belongs to prefs, and persistence is handled by the prefs storage bridge or application code.'
], ],
commonMistakes: [ commonMistakes: [
{ {
@ -3104,11 +3110,11 @@ Format.currency.getCurrency(); // USD, explicit user choice`
title: 'Direction and theme', title: 'Direction and theme',
code: `const Frontend = App.frontend; code: `const Frontend = App.frontend;
App.lang.setLocale('ar'); App.prefs.language.set('ar');
Frontend.getDir(); // rtl while dir is auto Frontend.getDir(); // rtl while dir is auto
Frontend.setDir('ltr'); // manual override Frontend.setDir('ltr'); // manual override
App.lang.setLocale('ar-EG'); App.prefs.language.set('ar');
Frontend.getDir(); // ltr Frontend.getDir(); // ltr
Frontend.clearDir(); Frontend.clearDir();
@ -3122,8 +3128,8 @@ Frontend.getDir(); // rtl`
}, },
{ {
name: 'App.frontend', name: 'App.frontend',
purpose: 'Always-present App root.', purpose: 'Schema-declared frontend service.',
notes: 'Wired to App.lang locale and App.dom.' notes: 'Wired to core App.prefs and optional App.dom/App.lang services when they exist.'
} }
], ],
api: artifactApis.fend, api: artifactApis.fend,
@ -3131,22 +3137,25 @@ Frontend.getDir(); // rtl`
{ {
title: 'Creation and app wiring', title: 'Creation and app wiring',
body: [ body: [
'Frontend should normally be created by App. App injects Lang as the locale source, Dom as the writer and Storage when persistence is enabled.', 'Frontend should normally be declared as an App service. The factory reads Dom from the service schema and Prefs from the core (always present).',
'Create ActiveFrontend directly only for tests or embedded widgets that intentionally own their own DOM target.' 'Create ActiveFrontend directly only for tests or embedded widgets that intentionally own their own DOM target.'
], ],
code: { code: {
title: 'App-wired frontend', title: 'App-wired frontend',
code: `const App = createActiveApp({ code: `const App = createActiveApp({
lang: { schema, defaultLocale: 'es' }, prefs: { capabilities, environment },
frontend: { services: {
theme: 'base', lang: defineActiveLang({ schema, defaultLocale: 'es' }),
mode: 'auto', dom: defineActiveDom(),
dir: 'auto', frontend: defineActiveFrontend({
persist: { keys: ['theme', 'mode', 'density'] } theme: 'base',
mode: 'auto',
dir: 'auto'
})
} }
}); });
App.lang.setLocale('ar'); App.prefs.language.set('ar');
App.frontend.getDir(); // rtl while dir remains auto` App.frontend.getDir(); // rtl while dir remains auto`
} }
}, },
@ -3168,15 +3177,19 @@ App.frontend.getDir(); // rtl while dir remains auto`
{ {
title: 'Persisting Preferences', title: 'Persisting Preferences',
body: [ body: [
'aapp can persist Frontend preferences through Storage. fend decides how to read user intent; aapp only bridges preferences to storage entries.' 'Frontend no longer owns preference persistence. User intent belongs to prefs; persist it by passing a storage adapter into the core prefs option, or by wiring createPrefsStorageBridge() yourself.'
], ],
code: { code: {
title: 'Persist preferences', title: 'Persist preferences',
code: `const App = createActiveApp({ code: `const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'app' }, prefs: {
frontend: { capabilities,
theme: 'base', environment,
persist: { keys: ['theme', 'mode', 'density'] } storage: prefsIntentStorage
},
services: {
storage: defineActiveStorage({ adapter: localAdapter, namespace: 'app' }),
frontend: defineActiveFrontend({ theme: 'base' })
} }
});` });`
} }
@ -3184,14 +3197,14 @@ App.frontend.getDir(); // rtl while dir remains auto`
], ],
tests: [ tests: [
{ {
name: 'src/arts/fend/test', name: 'src/arts/frontend/test',
purpose: 'ActiveFrontend behavior.', purpose: 'ActiveFrontend behavior.',
notes: 'DOM attrs, auto/manual and OS preferences.' notes: 'DOM attrs, auto/manual and OS preferences.'
}, },
{ {
name: 'src/arts/aapp/test/storage-integration.test.ts', name: 'src/arts/active-app/test/prefs-consumer-wiring.test.ts',
purpose: 'Persistence bridge.', purpose: 'Prefs bridge.',
notes: 'Storage seeding and write-back.' notes: 'Core Prefs drives Frontend mode, density, motion and direction when the Frontend service is declared.'
}, },
{ {
name: '/test/fend', name: '/test/fend',
@ -3244,7 +3257,7 @@ App.frontend.getDir(); // rtl while dir remains auto`
], ],
quickStart: { quickStart: {
title: 'Responsive value and attrs', title: 'Responsive value and attrs',
code: `const Dom = App.dom; code: `const Dom = App.dom; // when dom is declared as an App service
const size = Dom.resolve({ base: 'compact', md: 'comfortable' }); const size = Dom.resolve({ base: 'compact', md: 'comfortable' });
@ -3264,8 +3277,8 @@ Dom.apply({
}, },
{ {
name: 'App.dom', name: 'App.dom',
purpose: 'Always-present App root.', purpose: 'Schema-declared DOM service.',
notes: 'Shared by Frontend and consumers.' notes: 'Shared by Frontend and consumers when declared.'
} }
], ],
api: artifactApis.adom, api: artifactApis.adom,
@ -3273,7 +3286,7 @@ Dom.apply({
{ {
title: 'Creation and target ownership', title: 'Creation and target ownership',
body: [ body: [
'App.dom is the shared DOM service for the application. It should own viewport tracking, responsive resolution and global writes that other artifacts depend on.', 'When dom is declared with defineActiveDom(), App.dom is the shared DOM service for the application. It should own viewport tracking, responsive resolution and global writes that other artifacts depend on.',
'When using createActiveDom() directly, decide the target boundary explicitly: document-level app shell, an embedded widget root, or a test DOM. Do not let unrelated feature modules write global attributes independently.' 'When using createActiveDom() directly, decide the target boundary explicitly: document-level app shell, an embedded widget root, or a test DOM. Do not let unrelated feature modules write global attributes independently.'
], ],
code: { code: {
@ -3330,7 +3343,7 @@ const layout = Dom.resolve({ base: 'stack', md: 'split' });`
notes: 'Viewport, attrs, scroll and responsive resolution.' notes: 'Viewport, attrs, scroll and responsive resolution.'
}, },
{ {
name: 'src/arts/fend/test', name: 'src/arts/frontend/test',
purpose: 'Frontend integration.', purpose: 'Frontend integration.',
notes: 'Frontend applies attrs through Dom.' notes: 'Frontend applies attrs through Dom.'
}, },
@ -3352,9 +3365,9 @@ const layout = Dom.resolve({ base: 'stack', md: 'split' });`
dependsOn: ['$lang (optional)', '$logger (optional)', '$libs/days', '$libs/color'], dependsOn: ['$lang (optional)', '$logger (optional)', '$libs/days', '$libs/color'],
layer: 'EngineSium', layer: 'EngineSium',
overview: [ overview: [
'Sium is page-scoped by design. Forms live in pages and features, so App exposes createSiumEngine() instead of keeping a global validator alive for every route.', 'Sium is page/feature scoped by design. Forms live in pages and features, so use createEngineSium() directly or declare sium with defineEngineSium() only where the App needs a shared validator service.',
'The engine creates schemas, validates values, returns structured issues and carries metadata for UI generation. It supports Standard Schema interoperability.', 'The engine creates schemas, validates values, returns structured issues and carries metadata for UI generation. It supports Standard Schema interoperability.',
'When created from App, Sium receives App.lang and App.Logger. If Lang is not provided, its local resolver is only a fallback.' 'When declared through App, Sium receives App.logger from the core and App.lang when the lang service exists. If Lang is not provided, its local resolver is only a fallback.'
], ],
dynamics: [ dynamics: [
'Create a Sium engine close to the form or feature that needs it. Define schemas once, then call validate() for submitted values or Standard Schema consumers such as HTTP body validation.', 'Create a Sium engine close to the form or feature that needs it. Define schemas once, then call validate() for submitted values or Standard Schema consumers such as HTTP body validation.',
@ -3477,7 +3490,7 @@ const result = await Sium.validate(ProfileSchema, formValue);`
notes: 'Validation, pipes, metadata and translations.' notes: 'Validation, pipes, metadata and translations.'
}, },
{ {
name: 'src/arts/aapp/test/create-sium-engine.test.ts', name: 'src/arts/active-app/test/service-factories.test.ts',
purpose: 'App injection.', purpose: 'App injection.',
notes: 'Lang and Logger are wired into Sium.' notes: 'Lang and Logger are wired into Sium.'
}, },
@ -3499,7 +3512,7 @@ const result = await Sium.validate(ProfileSchema, formValue);`
dependsOn: ['$libs/logger'], dependsOn: ['$libs/logger'],
layer: 'EngineLogger / Logger contract', layer: 'EngineLogger / Logger contract',
overview: [ overview: [
'The minimal Logger interface lives in libs/logger and is what all modules receive. EngineLogger lives in arts/logr and extends that contract with transports, history, child loggers, timers and lifecycle.', 'The minimal Logger interface lives in libs/logger and is what all modules receive. EngineLogger lives in arts/logger and extends that contract with transports, history, child loggers, timers and lifecycle.',
'Diagnostics are a cataloged layer above Logger. They map internal framework events to normal logger calls without forcing every log to become an event.', 'Diagnostics are a cataloged layer above Logger. They map internal framework events to normal logger calls without forcing every log to become an event.',
'Transports can be filtered per level, buffered, throttled on failure and adapted to Sentry, Datadog, Loki, Logtail or OpenTelemetry.' 'Transports can be filtered per level, buffered, throttled on failure and adapted to Sentry, Datadog, Loki, Logtail or OpenTelemetry.'
], ],
@ -3554,7 +3567,7 @@ Logger.info('checkout', 'payment completed', {
{ {
name: 'createEngineLogger(options)', name: 'createEngineLogger(options)',
purpose: 'Creates the full logger runtime.', purpose: 'Creates the full logger runtime.',
notes: 'Use directly or through App.Logger.' notes: 'Use directly or through App.logger.'
}, },
{ {
name: '$libs/logger.createCatalogDiagnostics(options)', name: '$libs/logger.createCatalogDiagnostics(options)',
@ -3580,7 +3593,7 @@ Logger.info('checkout', 'payment completed', {
code: `import type { Logger } from '$libs/logger'; code: `import type { Logger } from '$libs/logger';
export function createFeature(options: { logger?: Logger }) { export function createFeature(options: { logger?: Logger }) {
const logger = options.logger ?? App.Logger; const logger = options.logger ?? App.logger;
logger.info('feature.started', { context: { source: 'profile' } }); logger.info('feature.started', { context: { source: 'profile' } });
}` }`
} }
@ -3605,7 +3618,7 @@ export function createFeature(options: { logger?: Logger }) {
const diagnostics = createCatalogDiagnostics({ const diagnostics = createCatalogDiagnostics({
logger, logger,
defaultCategory: 'conn', defaultCategory: 'connection',
catalog: { catalog: {
reconnect_exhausted: { reconnect_exhausted: {
level: LogLevel.WARN, level: LogLevel.WARN,
@ -3615,7 +3628,7 @@ const diagnostics = createCatalogDiagnostics({
}); });
diagnostics.emit({ diagnostics.emit({
artifact: 'conn', artifact: 'connection',
type: 'reconnect_exhausted', type: 'reconnect_exhausted',
meta: { attempts: 5 } meta: { attempts: 5 }
});` });`
@ -3624,7 +3637,7 @@ diagnostics.emit({
], ],
tests: [ tests: [
{ {
name: 'src/arts/logr/test', name: 'src/arts/logger/test',
purpose: 'Engine logger.', purpose: 'Engine logger.',
notes: 'Levels, transports, failures, buffers and adapters.' notes: 'Levels, transports, failures, buffers and adapters.'
}, },
@ -3684,7 +3697,7 @@ diagnostics.emit({
], ],
quickStart: { quickStart: {
title: 'Schedule work', title: 'Schedule work',
code: `const Timers = App.Timers; code: `const Timers = App.timers;
Timers.schedule('profile:refresh', 5_000, async () => { Timers.schedule('profile:refresh', 5_000, async () => {
await refreshProfile(); await refreshProfile();
@ -3707,7 +3720,7 @@ Timers.interval('sync', 30_000, syncInBackground, {
notes: 'Exposes entries() snapshots through Svelte state.' notes: 'Exposes entries() snapshots through Svelte state.'
}, },
{ {
name: 'App.Timers', name: 'App.timers',
purpose: 'Always-present App root.', purpose: 'Always-present App root.',
notes: 'Injected into Connections and available to consumers.' notes: 'Injected into Connections and available to consumers.'
} }
@ -3717,7 +3730,7 @@ Timers.interval('sync', 30_000, syncInBackground, {
{ {
title: 'Creation and ownership', title: 'Creation and ownership',
body: [ body: [
'App.Timers is the shared scheduler for browser-side artifacts. Connections, auto-refresh and debug panels should use this root instead of creating their own timer islands.', 'App.timers is the shared scheduler for browser-side artifacts. Connections, auto-refresh and debug panels should use this root instead of creating their own timer islands.',
'Create EngineTimers directly for deterministic unit tests, workers or server utilities that need an injected clock and do not need Svelte state.' 'Create EngineTimers directly for deterministic unit tests, workers or server utilities that need an injected clock and do not need Svelte state.'
], ],
code: { code: {
@ -3727,8 +3740,8 @@ Timers.interval('sync', 30_000, syncInBackground, {
replace: true replace: true
}); });
Timers.interval('conn:heartbeat', 30_000, heartbeat, { Timers.interval('connection:heartbeat', 30_000, heartbeat, {
scope: 'conn', scope: 'connection',
awaitTask: false awaitTask: false
}); });
@ -3766,7 +3779,7 @@ Timers.cancelScope('profile');`
notes: 'One-shots, intervals, cancellation, backoff and fake clocks.' notes: 'One-shots, intervals, cancellation, backoff and fake clocks.'
}, },
{ {
name: 'src/arts/conn/test', name: 'src/arts/connection/test',
purpose: 'Consumer integration.', purpose: 'Consumer integration.',
notes: 'Reconnect, heartbeat and ACK timeouts.' notes: 'Reconnect, heartbeat and ACK timeouts.'
}, },
@ -3816,7 +3829,7 @@ Timers.cancelScope('profile');`
notes: 'Use the injected Logger and module diagnostics/constants.' notes: 'Use the injected Logger and module diagnostics/constants.'
}, },
{ {
name: 'forgetting app-event reauth behavior', name: 'forgetting Orca reauth behavior',
purpose: 'Connections can keep old identity after login/logout/refresh.', purpose: 'Connections can keep old identity after login/logout/refresh.',
notes: 'Wire the orca preset (applyStandardOrca or applyConnectionsReauthOnIdentityChange / applyConnectionsCloseOnRevoke) so identity changes flow through reauthenticateAll/closeAll. For standalone connections without orca, use the per-connection session option.' notes: 'Wire the orca preset (applyStandardOrca or applyConnectionsReauthOnIdentityChange / applyConnectionsCloseOnRevoke) so identity changes flow through reauthenticateAll/closeAll. For standalone connections without orca, use the per-connection session option.'
} }
@ -3945,14 +3958,14 @@ await Chat.connect();`
], ],
tests: [ tests: [
{ {
name: 'src/arts/conn/test', name: 'src/arts/connection/test',
purpose: 'Connection runtime.', purpose: 'Connection runtime.',
notes: 'States, channels, websocket transport and app-event reauth.' notes: 'States, channels, websocket transport and reauth behavior.'
}, },
{ {
name: 'src/arts/aapp/test/ecosystem.integration.test.ts', name: 'src/arts/active-app/test/ecosystem-orca.test.ts',
purpose: 'App integration.', purpose: 'App orchestration.',
notes: 'Connections reauth/disconnect from public app identity events when opted in.' notes: 'Connections reauthenticate or close through orca presets when session events fire.'
}, },
{ {
name: '/test/conn', name: '/test/conn',

@ -41,7 +41,7 @@ export const nav: readonly NavSection[] = [
label: 'App', label: 'App',
alias: '$active-app', alias: '$active-app',
href: '/active/docs/aapp', href: '/active/docs/aapp',
description: 'App composition: wires every artifact together.' description: 'App composition: fixed Logger/Bus/Timers/Orca/Prefs core plus typed service schema.'
} }
] ]
}, },
@ -72,6 +72,10 @@ export const nav: readonly NavSection[] = [
{ label: 'Http', alias: '$http', href: '/active/docs/http' } { label: 'Http', alias: '$http', href: '/active/docs/http' }
] ]
}, },
{
title: 'Preferences & Environment',
items: [{ label: 'Prefs', alias: '$prefs', href: '/active/docs/prefs' }]
},
{ {
title: 'I18n & Format', title: 'I18n & Format',
items: [ items: [
@ -96,6 +100,7 @@ export const nav: readonly NavSection[] = [
{ label: 'Bus', alias: '$bus', href: '/active/docs/buss' }, { label: 'Bus', alias: '$bus', href: '/active/docs/buss' },
{ label: 'Logger', alias: '$logger', href: '/active/docs/logr' }, { label: 'Logger', alias: '$logger', href: '/active/docs/logr' },
{ label: 'Timers', alias: '$timer', href: '/active/docs/timr' }, { label: 'Timers', alias: '$timer', href: '/active/docs/timr' },
{ label: 'Orca', alias: '$orca', href: '/active/docs/orca' },
{ label: 'Connections', alias: '$connection', href: '/active/docs/conn' } { label: 'Connections', alias: '$connection', href: '/active/docs/conn' }
] ]
} }

@ -8,120 +8,147 @@
const composition = `import { createActiveApp } from '$active-app'; const composition = `import { createActiveApp } from '$active-app';
import { import {
defineActiveLang, defineActiveLang,
defineActiveFrontend defineActiveFormat,
defineActiveFrontend,
defineActiveCache,
defineActiveSession,
defineActivePerm,
defineActiveConnections
} from '$active-app/services'; } from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
import { LogLevel, consoleTransport } from '$logger'; import { LogLevel, consoleTransport } from '$logger';
export const App = createActiveApp({ export const App = createActiveApp({
logger: { level: LogLevel.INFO, transports: [consoleTransport()] }, logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
orca: { maxDepth: 24 },
prefs: { capabilities, environment, intent },
services: { services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }), lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
frontend: defineActiveFrontend({ theme: 'base' }) format: defineActiveFormat(),
frontend: defineActiveFrontend({ theme: 'base' }),
cache: defineActiveCache(),
session: defineActiveSession({ schemas, storage, onRefresh, onRevoke }),
perm: defineActivePerm({ endpoint: '/api/perm' }),
connections: defineActiveConnections()
} }
});`; });
applyStandardOrca(App);`;
const access = `App.lang.t('common.ok'); const access = `App.logger.info('checkout.paid', { orderId });
App.prefs.locale.set('es-MX');
App.lang.t('common.ok');
App.format.currency.format(99.5); App.format.currency.format(99.5);
App.Logger.info('checkout.paid', { orderId }); App.frontend.setMode('dark');
App.lang.setLocale('es-MX'); // notifies Format and Frontend via the locale source`; App.cache.clear();`;
const factories = `import {
defineActiveLang,
defineActiveFormat,
defineActiveFrontend,
defineActiveStorage,
defineEngineHttp,
defineEngineSium,
defineActiveCache,
defineActiveSession,
defineActiveAuth,
defineActivePerm,
defineActiveConnections
} from '$active-app/services';
const factories = `// Every service is declared in the schema; the App builder injects core deps.
const App = createActiveApp({ const App = createActiveApp({
prefs: { capabilities, environment },
services: { services: {
sium: defineEngineSium({}), // validation engine storage: defineActiveStorage(),
connections: defineActiveConnections({}), // realtime registry http: defineEngineHttp({ baseUrl: '/api' }),
auth: defineActiveAuth({ initial: data.auth }), lang: defineActiveLang({ schema }),
format: defineActiveFormat(),
frontend: defineActiveFrontend(),
sium: defineEngineSium({}),
cache: defineActiveCache(),
session: defineActiveSession({ schemas, storage, onRefresh, onRevoke }),
auth: defineActiveAuth({ endpoint: '/api/auth' }),
perm: defineActivePerm({ endpoint: '/api/perm' }), perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ connections: defineActiveConnections()
schemas: { user: userSchema },
storage: { adapter: localAdapter, key: 'session' },
onRefresh,
onRevoke
})
} }
}); });`;
// Reactions to identity changes / revoke live in orca presets. const eventFlow = `App.session publishes SESSION_EVENT_IDENTITY_CHANGED on App.bus
applyStandardOrca(App);`; App.orca receives the event through registered presets
-> applyCacheClearOnIdentityChange calls App.cache.clear()
-> applyPermInvalidateOnIdentityChange calls App.perm.invalidate()
-> applyConnectionsReauthOnIdentityChange calls App.connections.reauthenticateAll()
const eventFlow = `session publishes SESSION_EVENT_IDENTITY_CHANGED on App.Bus No preset registered, no destructive reaction runs.`;
orca runs every action registered for that event:
- applyCacheClearOnIdentityChange -> App.cache.clear()
- applyPermInvalidateOnIdentityChange -> App.perm.invalidate()
connections track identity through their own ConnectionSessionSource (built from App.session)`;
const disposal = `import { onDestroy } from 'svelte'; const disposal = `import { onDestroy } from 'svelte';
onDestroy(() => App.dispose());`; onDestroy(() => App.dispose());`;
const commonMistakes = [ const commonMistakes = [
{ {
name: 'Putting domain logic in App', name: 'Treating App as a domain service',
why: 'App becomes a god object and artifacts lose clear ownership.', why: 'The composition root becomes a mixed business object and module ownership disappears.',
fix: 'Keep App as composition root; domain behavior stays inside each artifact.' fix: 'Keep business behavior inside the artifact that owns it; App only wires runtime pieces.'
}, },
{ {
name: 'Creating multiple singleton roots', name: 'Expecting undeclared services to exist',
why: 'Session, Auth or Perms state can diverge inside one app.', why: 'Only core members are always present. Services are exposed only when declared in the schema.',
fix: 'Use the App factory once and read App.session/App.auth/App.perm afterwards.' fix: 'Declare each required module under services and let TypeScript enforce the App shape.'
}, },
{ {
name: 'Importing $active-app on the server as authority', name: 'Bypassing prefs for user intent',
why: '$active-app is browser/client composition. Server code needs server engines and request context.', why: 'Lang, Format and Frontend can disagree about language, locale, theme or direction.',
fix: 'Use $svrs/auth, $svrs/perm and $svrs/cache from server files.' fix: 'Use App.prefs.<dim>.set(value) (e.g. App.prefs.locale.set("es-ES")); downstream services follow the effective snapshot.'
}, },
{ {
name: 'Bypassing App locale propagation', name: 'Putting reactions inside Bus listeners by hand',
why: 'Lang, Format and Frontend can disagree about locale/dir/currency.', why: 'Lifecycle behavior becomes invisible and hard to test.',
fix: 'Call App.lang.setLocale() so every locale consumer updates from one source.' fix: 'Register orca presets in $active-app/presets or add explicit App.orca actions.'
}, },
{ {
name: 'Disposing providers before consumers', name: 'Importing $active-app as server authority',
why: 'Connections/Auth/Perms can call into already-disposed shared roots.', why: 'App is client/runtime composition; server trust belongs to engines and $svrs.',
fix: 'Keep the App.dispose() consumer-to-provider order.' fix: 'Use $svrs/auth, $svrs/perm, $svrs/cache and pure Engine factories from server files.'
} }
] as const; ] as const;
const aiAgentRows = [ const aiAgentRows = [
{ {
step: 'Composition surface', step: 'Composition surface',
where: 'src/arts/aapp/types.ts and src/arts/aapp/active-app.svelte.ts', where: 'src/arts/active-app/types.ts and src/arts/active-app/active-app.svelte.ts',
rule: 'Update options, public getters and factory wiring together. App composes artifacts; it must not absorb their domain logic.' rule: 'Update root options, core getters and service-schema typing together.'
}, },
{ {
step: 'Always-present roots', step: 'Fixed core',
where: 'createActiveApp()', where: 'createActiveApp()',
rule: 'Do not make root members nullable. Use structurally-compatible fallbacks for Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers, Bus and Cache.' rule: 'Logger, Bus, Timers, Orca and Prefs are always present and never declared as services.'
}, },
{ {
step: 'Scoped factories', step: 'Service factories',
where: 'createSiumEngine(), createActiveSession(), createActiveConnections(), createActiveAuth(), createActivePerms()', where: 'src/arts/active-app/service-factories/*.ts',
rule: 'Preserve each factory lifetime and keep app-event reactions opt-in through the consumer options.' rule: 'Each factory adapts one artifact to App; artifacts must not import App.'
}, },
{ {
step: 'Bus and orchestration', step: 'Orchestration',
where: 'App.Bus and src/arts/aapp/integrations/*-translator.ts', where: 'src/arts/active-app/presets/*.ts',
rule: 'App may translate module events into public app events; destructive reactions belong to Cache, Perms or Connections.' rule: 'Cross-module reactions belong to Orca presets, not hidden auto-subscribers inside Cache, Perm or Connections.'
}, },
{ {
step: 'Server boundary', step: 'Server boundary',
where: 'src/svrs/*', where: 'src/svrs/*',
rule: '$active-app is the client composition root. Server authority belongs in $svrs and shared contracts belong in $libs.' rule: '$active-app is the client composition root. Server authority belongs in $svrs and shared contracts belong in $libs.'
}, },
{
step: 'Disposal',
where: 'App.dispose()',
rule: 'Dispose consumers before providers and keep disposal idempotent.'
},
{ {
step: 'Tests', step: 'Tests',
where: 'src/arts/aapp/test and /test/ecosystem', where: 'src/arts/active-app/test',
rule: 'Any wiring change needs composition and cross-module verification.' rule: 'Composition, service ordering, prefs consumer wiring and orca presets need integration coverage.'
} }
] as const; ] as const;
</script> </script>
<svelte:head> <svelte:head>
<title>App ($active-app) — Active</title> <title>App ($active-app) - Active</title>
</svelte:head> </svelte:head>
<article class="article"> <article class="article">
@ -129,135 +156,117 @@ onDestroy(() => App.dispose());`;
section="Composition" section="Composition"
title="App" title="App"
alias="$active-app" alias="$active-app"
summary="App composition: wires Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers and Cache; exposes factories for Sium, Session, Connections, Auth and Perms." summary="Client composition root: fixed Logger, Bus, Timers, Orca and Prefs core plus a typed opt-in service schema."
factories={['createActiveApp']} factories={['createActiveApp']}
dependsOn={['every artifact above']} dependsOn={['$logger', '$bus', '$timer', '$orca', '$prefs', '$active-app/services']}
layer="ActiveApp" layer="ActiveApp"
/> />
<h2>Overview</h2> <h2>Overview</h2>
<p> <p>
<code>$active-app</code> is the single composition root. It owns nine always-present <code>$active-app</code> is the runtime composition root. It always builds five core
artifacts, exposes them through stable getters, and provides factories for the members - <code>App.logger</code>, <code>App.bus</code>, <code>App.timers</code> and
five feature-scoped artifacts. Every member of <code>App</code> is present whether <code>App.orca</code>, plus <code>App.prefs</code> - and then exposes only the services declared by the application.
or not the corresponding option was passed: missing configurations get a The old model where Lang, Format, Frontend, Dom, Storage, Http, Cache and Prefs were
structurally-identical fallback (mono-locale lang, console logger, default-locale always-present roots is gone.
formats), so call sites stay uniform.
</p> </p>
<h2>Mental model</h2> <h2>Mental model</h2>
<p> <p>
<code>aapp</code> is wiring, not business logic. It creates the always-present roots, App is wiring, not business logic. It creates the core, adapts artifacts through service
connects shared sources such as locale, logger, storage and timers, and exposes scoped factories, computes the service dependency order, exposes typed getters, and owns teardown.
factories for artifacts that should only exist when a feature/page needs them. If a Cross-module behavior is explicit: modules publish events on <code>App.bus</code>, while
behavior belongs to validation, cache, auth, permissions, realtime or formatting, it <code>App.orca</code> runs the registered reactions.
belongs in that artifact; <code>aapp</code> only decides how the pieces are assembled.
</p>
<p>
The most important dynamic is propagation. <code>App.lang.setLocale()</code> updates Lang,
then Format and Frontend react from the same LocaleSource. <code>App.Bus</code> carries
typed events from each module — sessions publish <code>SESSION_EVENT_*</code>, etc. — but
the bus itself is inert. Cross-module reactions live as orca actions registered by the
application through <code>applyStandardOrca(App)</code> or the cherry-picked
<code>apply*</code> presets in <code>$active-app/presets</code>. Disposal runs in the
opposite direction: consumers first, providers last.
</p> </p>
<Callout variant="info" title="The schema is the App contract">
<p>
If a service is not declared in <code>services</code>, it is not part of the App type.
This keeps feature surfaces honest: a checkout app can declare cache and session, while
a static marketing page can keep only the fixed core.
</p>
</Callout>
<h2>Quick start</h2> <h2>Quick start</h2>
<CodeBlock code={composition} lang="ts" title="src/lib/app.ts" /> <CodeBlock code={composition} lang="ts" title="src/lib/app.ts" />
<p>Then read state directly:</p> <p>Then read or mutate declared services directly:</p>
<CodeBlock code={access} lang="ts" /> <CodeBlock code={access} lang="ts" />
<h2>Core surface (always present)</h2> <h2>Fixed core</h2>
<p>
The four pieces of the core are always built and never declared as services. Configure
them via <code>{`createActiveApp({ logger, bus, timers, orca })`}</code>.
</p>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Member</th> <th>Member</th>
<th>Type</th> <th>Configured from</th>
<th>Purpose</th> <th>Notes</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><code>App.Logger</code></td><td><code>EngineLogger</code></td><td>Structured logger with transports.</td></tr> <tr><td><code>App.logger</code></td><td><code>logger</code></td><td>Engine logger. Injected into core-aware service factories.</td></tr>
<tr><td><code>App.Bus</code></td><td><code>EngineBus</code></td><td>Typed event bus carrying module events. Modules publish directly; consumers subscribe directly.</td></tr> <tr><td><code>App.bus</code></td><td><code>bus</code></td><td>Typed Svelte-safe event bus. App injects logger and clock.</td></tr>
<tr><td><code>App.Timers</code></td><td><code>ActiveTimers</code></td><td>Deterministic timer scheduler.</td></tr> <tr><td><code>App.timers</code></td><td><code>timers</code></td><td>Active timer scheduler. Used by services and Orca.</td></tr>
<tr><td><code>App.Orca</code></td><td><code>EngineOrca</code></td><td>Orchestration engine. Inert until presets register actions.</td></tr> <tr><td><code>App.orca</code></td><td><code>orca</code></td><td>Orchestration engine. App injects bus, timers and logger.</td></tr>
<tr><td><code>App.prefs</code></td><td><code>prefs</code></td><td>Preference engine. Always present; configured from root <code>prefs</code> options or neutral defaults.</td></tr>
</tbody> </tbody>
</table> </table>
<h2>Schema services (opt-in)</h2> <h2>Service schema</h2>
<p> <p>
Everything else is declared in <code>services: {`{ ... }`}</code>. The builder validates names, Services are adapted by <code>$active-app/services</code>. Factories declare the core
computes topological order, builds <code>immediate</code> services eagerly and exposes dependencies they consume, the services they can read, and whether they build lazily or
<code>lazy</code> services behind getters. Each service becomes a typed lowercase property immediately. The builder validates names, detects cycles, builds dependencies first and
on <code>App</code> — accessing one that wasn't declared is a TypeScript error. disposes constructed services in reverse construction order.
</p> </p>
<CodeBlock code={factories} lang="ts" />
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Factory (from <code>$active-app/services</code>)</th>
<th>Slot</th> <th>Slot</th>
<th>Notes</th> <th>Factory</th>
<th>Dependency behavior</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td><code>defineActiveLang(options)</code></td><td><code>App.lang</code></td><td>Schema is required; the builder injects <code>logger</code> from the core.</td></tr> <tr><td><code>lang</code></td><td><code>defineActiveLang()</code></td><td>Consumes <code>logger</code> and <code>prefs</code> from the core; follows <code>App.prefs.language.get()</code>.</td></tr>
<tr><td><code>defineActiveStorage(options)</code></td><td><code>App.storage</code></td><td>Memory adapter by default.</td></tr> <tr><td><code>format</code></td><td><code>defineActiveFormat()</code></td><td>Consumes <code>timers</code> and <code>prefs</code> from the core; resolves locale from explicit source or prefs.</td></tr>
<tr><td><code>defineActiveDom(props)</code></td><td><code>App.dom</code></td><td>Inert on the server.</td></tr> <tr><td><code>frontend</code></td><td><code>defineActiveFrontend()</code></td><td>Consumes <code>prefs</code> from the core and optionally <code>dom</code>; prefs drives mode, density, motion and direction.</td></tr>
<tr><td><code>defineActiveFormat(options)</code></td><td><code>App.format</code></td><td>Wires <code>localeSource</code> from <code>App.lang</code> automatically when both are declared.</td></tr> <tr><td><code>dom</code></td><td><code>defineActiveDom()</code></td><td>DOM integration, isolated as a service.</td></tr>
<tr><td><code>defineActiveFrontend(options)</code></td><td><code>App.frontend</code></td><td>Wires <code>dom</code> and <code>lang</code> automatically.</td></tr> <tr><td><code>storage</code></td><td><code>defineActiveStorage()</code></td><td>Storage runtime, typically used by session and prefs persistence bridges.</td></tr>
<tr><td><code>defineActiveCache(options)</code></td><td><code>App.cache</code></td><td>Passive runtime — invalidation is driven by orca presets.</td></tr> <tr><td><code>http</code></td><td><code>defineEngineHttp()</code></td><td>Pure HTTP engine adapted into the schema.</td></tr>
<tr><td><code>defineActiveSession&lt;TUser, TCredential?, TData?&gt;(options)</code></td><td><code>App.session</code></td><td>Publishes <code>SESSION_EVENT_*</code> on the bus.</td></tr> <tr><td><code>sium</code></td><td><code>defineEngineSium()</code></td><td>Validation engine; uses lang when present.</td></tr>
<tr><td><code>defineActivePerm(options)</code></td><td><code>App.perm</code></td><td>Auto-invalidation is OFF; use the orca preset.</td></tr> <tr><td><code>cache</code></td><td><code>defineActiveCache()</code></td><td>Cache runtime. Identity clears are Orca presets.</td></tr>
<tr><td><code>defineActiveAuth(options)</code></td><td><code>App.auth</code></td><td>Requires HTTP routes to the server authority (<code>$svrs/auth</code>).</td></tr> <tr><td><code>session</code></td><td><code>defineActiveSession()</code></td><td>Publishes typed session lifecycle events on App.bus.</td></tr>
<tr><td><code>defineActiveConnections(options)</code></td><td><code>App.connections</code></td><td>Identity tracking via <code>ConnectionSessionSource</code>.</td></tr> <tr><td><code>auth</code></td><td><code>defineActiveAuth()</code></td><td>Client auth reflector for server-backed flows.</td></tr>
<tr><td><code>defineEngineHttp(options)</code></td><td><code>App.http</code></td><td>Engine only — no Active wrapper.</td></tr> <tr><td><code>perm</code></td><td><code>defineActivePerm()</code></td><td>Permission reflector. Identity invalidation is an Orca preset.</td></tr>
<tr><td><code>defineEngineSium(options)</code></td><td><code>App.sium</code></td><td>Wires <code>lang</code> automatically when declared.</td></tr> <tr><td><code>connections</code></td><td><code>defineActiveConnections()</code></td><td>Realtime connection registry. Identity reauth and revoke close are Orca presets.</td></tr>
</tbody> </tbody>
</table> </table>
<h2>Declaring services</h2> <h2>Prefs propagation</h2>
<CodeBlock code={factories} lang="ts" />
<Callout variant="info" title="Single-instance by construction">
<p>
A duplicate slot in <code>services: {`{ ... }`}</code> is a JavaScript object-literal
error. There are no runtime "already created" exceptions because the schema makes
double-declaration impossible at the type level.
</p>
</Callout>
<h2>Locale propagation</h2>
<p> <p>
<code>App.lang.setLocale(locale)</code> updates Lang's internal locale, which then notifies <code>App.prefs</code> is always present and is the core source for user intent.
<code>App.format</code> and <code>App.frontend</code> through a shared <code>localeSource</code> <code>lang</code> follows <code>App.prefs.language.get()</code>,
bridge that the factories wire automatically. Consumers reading <code>App.lang.getLocale()</code>, <code>format</code> follows <code>App.prefs.locale.get()</code>, and
<code>App.format.currency.format(…)</code> or <code>App.frontend.dir</code> all agree on the <code>frontend</code> follows theme, density, motion and direction. Direct service
same BCP 47 tag without any extra wiring. overrides still work where the service exposes them, but the next Prefs update becomes
authoritative again.
</p> </p>
<h2>Event bus and orca</h2> <h2>Bus and Orca</h2>
<p> <p>
<code>App.Bus</code> is always present. Modules publish their own typed events on it <code>App.bus</code> is the event transport. <code>App.orca</code> is the policy runner.
(<code>SESSION_EVENT_IDENTITY_CHANGED</code>, <code>SESSION_EVENT_REVOKED</code>, etc.) — This separation matters: a module can publish a lifecycle event without silently clearing
the bus stays inert. Cross-module reactions live as <em>orca actions</em> registered by cache, invalidating permissions or reconnecting sockets. Those reactions exist only when
the application through presets in <code>$active-app/presets</code>. the application registers presets from <code>$active-app/presets</code>.
</p> </p>
<CodeBlock code={eventFlow} lang="txt" title="Identity event flow" /> <CodeBlock code={eventFlow} lang="txt" title="Identity event flow" />
<p>
<code>applyStandardOrca(App)</code> is the convenience aggregator: it registers every
standard preset whose required services are declared on <code>App</code>. Apps that want
a tailored set cherry-pick individual <code>apply*</code> functions instead.
</p>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Preset</th> <th>Preset</th>
<th>Triggered by</th> <th>Event</th>
<th>Effect</th> <th>Effect</th>
</tr> </tr>
</thead> </thead>
@ -265,44 +274,29 @@ onDestroy(() => App.dispose());`;
<tr><td><code>applyCacheClearOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.cache.clear()</code></td></tr> <tr><td><code>applyCacheClearOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.cache.clear()</code></td></tr>
<tr><td><code>applyCacheClearOnRevoke</code></td><td><code>SESSION_EVENT_REVOKED</code></td><td><code>App.cache.clear()</code></td></tr> <tr><td><code>applyCacheClearOnRevoke</code></td><td><code>SESSION_EVENT_REVOKED</code></td><td><code>App.cache.clear()</code></td></tr>
<tr><td><code>applyPermInvalidateOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.perm.invalidate()</code></td></tr> <tr><td><code>applyPermInvalidateOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.perm.invalidate()</code></td></tr>
<tr><td><code>applyConnectionsReauthOnIdentityChange</code></td><td><code>SESSION_EVENT_IDENTITY_CHANGED</code></td><td><code>App.connections.reauthenticateAll()</code></td></tr>
<tr><td><code>applyConnectionsCloseOnRevoke</code></td><td><code>SESSION_EVENT_REVOKED</code></td><td><code>App.connections.closeAll()</code></td></tr>
<tr><td><code>applySessionAutoRefresh</code></td><td>Timer action</td><td>Schedules session refresh through <code>App.orca</code>.</td></tr>
</tbody> </tbody>
</table> </table>
<Callout variant="warn" title="Public payloads only"> <Callout variant="warn" title="Public payloads only">
<p> <p>
Bus payloads are observable framework contracts. Do not put tokens, passwords, Bus payloads are observable contracts. Do not put tokens, passwords, authorization
authorization headers, refresh secrets or sensitive hashes in them. Use actor ids, headers, refresh secrets or sensitive hashes in them. Use actor ids, tenant ids,
tenant ids, causes and correlation ids. causes and correlation ids.
</p> </p>
</Callout> </Callout>
<h2>Disposal</h2> <h2>Disposal</h2>
<p> <p>
<code>App.dispose()</code> tears every artifact down in a <em>consumers → providers</em> <code>App.dispose()</code> publishes the dispose-starting event, disposes all constructed
order: <code>Auth</code> and <code>Perms</code> first, then <code>Connections</code>, services in reverse construction order, tears down the prefs storage bridge, then disposes
then <code>Sess</code>, then <code>Cache</code>, <code>Timers</code>, <code>Frontend</code>, <code>Prefs</code>, <code>Orca</code>, <code>Bus</code>, <code>Timers</code> and
<code>Dom</code>, <code>Format</code>, <code>Storage</code>, <code>Lang</code>, and <code>Logger</code>. Subsequent calls are no-ops.
finally <code>Logger</code>. Subsequent calls are no-ops.
</p> </p>
<CodeBlock code={disposal} lang="ts" /> <CodeBlock code={disposal} lang="ts" />
<h2>Limits</h2>
<ul>
<li>
<strong>No destructive defaults.</strong> Publishing an identity event does not clear
cache, invalidate permission decisions or reconnect sockets unless the consumer
<code>auto*On</code> option opts in.
</li>
<li>
<strong>Only identity and dispose have built-in sources today.</strong> Tenant,
permission-refresh, connectivity and cache-invalidation events are public typed
contracts that app code can publish explicitly until dedicated translators exist.
</li>
<li>
<code>aapp</code> is browser-first. Server entry points should consume the
<code>Engine*</code> factories directly from each artifact, not <code>$active-app</code>.
</li>
</ul>
<h2>Common mistakes</h2> <h2>Common mistakes</h2>
<table> <table>
<thead> <thead>
@ -325,13 +319,13 @@ onDestroy(() => App.dispose());`;
<h2>Testing</h2> <h2>Testing</h2>
<p> <p>
<code>$active-app/testing</code> exposes a deterministic clock and synchronous transports for Suites under <code>src/arts/active-app/test</code> cover schema construction, service
integration tests. Suites under <code>src/arts/aapp/test/</code> verify composition, ordering, prefs consumer wiring, orca presets, session auto refresh and cross-actor
factory enforcement, and disposal order. isolation.
</p> </p>
<AiAgentsBox <AiAgentsBox
intro="Before editing $active-app, verify the composition graph and the lifecycle of every injected artifact." intro="Before editing $active-app, verify the fixed core, the service schema and the Orca preset boundary."
rows={aiAgentRows} rows={aiAgentRows}
/> />

@ -25,7 +25,7 @@ export const schema = {
App.lang.t('common.ok'); // 'Aceptar' (when locale = 'es') App.lang.t('common.ok'); // 'Aceptar' (when locale = 'es')
App.lang.t('cart.items', { count: 3 }); // '3 artículos' App.lang.t('cart.items', { count: 3 }); // '3 artículos'
App.lang.setLocale('en'); App.prefs.language.set('en'); // core prefs drives lang
App.lang.t('common.ok'); // 'OK'`; App.lang.t('common.ok'); // 'OK'`;
const mentalModel = `// EngineLang: pure resolver. Locale is passed explicitly. const mentalModel = `// EngineLang: pure resolver. Locale is passed explicitly.
@ -37,15 +37,18 @@ const Lang = createActiveLang(schema, 'es', ['en']);
Lang.t('common.ok'); // uses current locale Lang.t('common.ok'); // uses current locale
Lang.setLocale('en-GB'); // re-renders consumers that read t(), ts() or getLocale() Lang.setLocale('en-GB'); // re-renders consumers that read t(), ts() or getLocale()
// App.lang: App-wired ActiveLang or mono-lang fallback. // App.lang: service declared through $active-app/services.
App.lang.setLocale('ar'); App.prefs.language.set('ar');
App.lang.t('home.title');`; App.lang.t('home.title');`;
const fallback = `createActiveApp({ const fallback = `createActiveApp({
lang: { prefs: { capabilities, environment },
schema, services: {
defaultLocale: 'es', lang: defineActiveLang({
fallbackChain: ['en'] // missing keys in 'es' fall back to 'en' schema,
defaultLocale: 'es',
fallbackChain: ['en'] // missing keys in 'es' fall back to 'en'
})
} }
});`; });`;
@ -73,13 +76,17 @@ export type AppLangSchema = typeof schema;
// src/lib/app.ts // src/lib/app.ts
import { createActiveApp } from '$active-app'; import { createActiveApp } from '$active-app';
import { defineActiveLang } from '$active-app/services';
import { schema } from '$lib/i18n/schema'; import { schema } from '$lib/i18n/schema';
export const App = createActiveApp<AppLangSchema>({ export const App = createActiveApp({
lang: { prefs: {
schema, capabilities,
defaultLocale: 'es', environment,
fallbackChain: ['en'] intent: { language: 'es' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] })
} }
});`; });`;
@ -189,7 +196,7 @@ CheckoutLang.t('checkout.title'); // typed path on the child
CheckoutLang.t('checkout.items', { count: 3 }); CheckoutLang.t('checkout.items', { count: 3 });
// The child follows the parent locale until disposed. // The child follows the parent locale until disposed.
App.lang.setLocale('en'); App.prefs.language.set('en');
CheckoutLang.t('checkout.title'); // 'Checkout' CheckoutLang.t('checkout.title'); // 'Checkout'
CheckoutLang.dispose();`; CheckoutLang.dispose();`;
@ -237,16 +244,18 @@ export function createCheckoutText(lang = App.lang) {
// Reads track automatically inside templates and $derived. // Reads track automatically inside templates and $derived.
<\/script> <\/script>
<button onclick={() => App.lang.setLocale('en')}>EN</button> <button onclick={() => App.prefs.language.set('en')}>EN</button>
<button onclick={() => App.lang.setLocale('es')}>ES</button> <button onclick={() => App.prefs.language.set('es')}>ES</button>
<h1>{App.lang.t('home.title')}</h1> <h1>{App.lang.t('home.title')}</h1>
<p>Locale: {App.lang.getLocale()}</p>`; <p>Locale: {App.lang.getLocale()}</p>`;
const monoLang = `// No 'lang' option → mono-lang fallback. Keys pass through verbatim. const monoLang = `// Explicit mono-lang fallback. Keys pass through verbatim.
const App = createActiveApp(); import { createActiveMonoLang } from '$lang/mono-lang.svelte';
const Lang = createActiveMonoLang();
App.lang.t('home.title'); // 'home.title'`; Lang.t('home.title'); // 'home.title'`;
const commonMistakes = [ const commonMistakes = [
{ {
@ -293,6 +302,11 @@ App.lang.t('home.title'); // 'home.title'`;
name: 'Expecting mono-lang to validate keys', name: 'Expecting mono-lang to validate keys',
why: 'Mono-lang has no schema, so it cannot type-check or warn about missing translation paths in the same way.', why: 'Mono-lang has no schema, so it cannot type-check or warn about missing translation paths in the same way.',
fix: 'Use a real schema for applications that depend on i18n correctness.' fix: 'Use a real schema for applications that depend on i18n correctness.'
},
{
name: 'Expecting App.lang without declaring lang',
why: 'The current App only exposes services that are declared in the service schema.',
fix: 'Add lang: defineActiveLang({ schema }) under services; configure core prefs with createActiveApp({ prefs }) when language intent needs app-specific capabilities.'
} }
] as const; ] as const;
@ -341,11 +355,12 @@ App.lang.t('home.title'); // 'home.title'`;
<h2>Overview</h2> <h2>Overview</h2>
<p> <p>
<code>lang</code> is one of the two zero-dependency roots of the framework <code>lang</code> is the translation runtime. It exposes a typed <code>t()</code>
(<code>logr</code> being the other). It exposes a typed <code>t()</code> function function over a tree of translations, resolves <strong>BCP 47</strong> tags through a
over a tree of translations, resolves <strong>BCP 47</strong> tags through a fallback fallback chain, supports <strong>CLDR plural rules</strong> per locale, and lets
chain, supports <strong>CLDR plural rules</strong> per locale, and lets translation translation strings reference each other through a small <code>#?key|fallback</code>
strings reference each other through a small <code>#?key|fallback</code> syntax. syntax. Inside <code>$active-app</code>, it is an opt-in service declared with
<code>defineActiveLang()</code>.
</p> </p>
<h2>Mental model</h2> <h2>Mental model</h2>
@ -354,8 +369,10 @@ App.lang.t('home.title'); // 'home.title'`;
call receives the locale explicitly. <code>ActiveLang</code> wraps that engine with a call receives the locale explicitly. <code>ActiveLang</code> wraps that engine with a
Svelte <code>$state</code> locale, so reads inside templates, <code>$derived</code> and Svelte <code>$state</code> locale, so reads inside templates, <code>$derived</code> and
<code>$effect</code> update when <code>setLocale()</code> changes the current locale. <code>$effect</code> update when <code>setLocale()</code> changes the current locale.
<code>App.lang</code> is the application instance wired by <code>aapp</code>; if no schema <code>App.lang</code> is the application instance wired by <code>$active-app/services</code>.
is configured, it is a mono-lang fallback that returns paths as readable strings. The factory follows core <code>App.prefs.language.get()</code>; direct
<code>setLocale()</code> calls still work, but the next <code>App.prefs</code> change
becomes authoritative again.
</p> </p>
<CodeBlock code={mentalModel} lang="ts" title="Engine vs Active vs App" /> <CodeBlock code={mentalModel} lang="ts" title="Engine vs Active vs App" />
@ -368,11 +385,11 @@ App.lang.t('home.title'); // 'home.title'`;
<h2>Creation and injection</h2> <h2>Creation and injection</h2>
<p> <p>
The normal application path is to inject the initial schema into <code>createActiveApp()</code>. The normal application path is to declare <code>lang</code> in
That creates <code>App.lang</code>, wires it to <code>App.lang.setLocale()</code>, and lets <code>createActiveApp()</code> with <code>defineActiveLang()</code>. User language intent
Format and Frontend react to the same locale source. If you are outside App, create the flows through core <code>App.prefs</code> into
pure engine with <code>createEngineLang()</code> or the Svelte wrapper with <code>App.lang</code>. If you are outside App, create the pure engine with
<code>createActiveLang()</code>. <code>createEngineLang()</code> or the Svelte wrapper with <code>createActiveLang()</code>.
</p> </p>
<CodeBlock code={appInjection} lang="ts" title="Initial schema through App" /> <CodeBlock code={appInjection} lang="ts" title="Initial schema through App" />
<CodeBlock code={directCreation} lang="ts" title="Direct creation" /> <CodeBlock code={directCreation} lang="ts" title="Direct creation" />
@ -562,17 +579,18 @@ App.lang.t('cart.items', { count: 0 }); // 'لا توجد عناصر' (ar)`}
<h2>Mono-lang fallback</h2> <h2>Mono-lang fallback</h2>
<p> <p>
When <code>aapp</code> is created without a <code>lang</code> option, it provides <code>createActiveMonoLang()</code> is still available as an explicit passthrough runtime
<code>createActiveMonoLang()</code> as a passthrough. <code>App.lang.t(key)</code> for apps or tests that want readable keys without a translation schema. App no longer
returns the key string, so call sites stay uniform whether or not i18n is configured. creates it implicitly: if <code>lang</code> is not declared in <code>services</code>,
<code>App.lang</code> is not part of the typed App surface.
</p> </p>
<CodeBlock code={monoLang} lang="ts" /> <CodeBlock code={monoLang} lang="ts" />
<Callout variant="warn" title="Mono-lang is type-loose"> <Callout variant="warn" title="Mono-lang is type-loose">
<p> <p>
<code>createActiveApp()</code> casts the mono-lang to <code>ActiveLang&lt;S&gt;</code> Do not rely on auto-completion for translation keys when you use mono-lang - there is
when no schema is given. Don't rely on auto-completion for translation keys when you no schema to derive types from. For typed apps, declare <code>defineActiveLang()</code>
use mono-lang — there is no schema to derive types from. with a real schema.
</p> </p>
</Callout> </Callout>
@ -585,7 +603,7 @@ App.lang.t('cart.items', { count: 0 }); // 'لا توجد عناصر' (ar)`}
<h2>Limits</h2> <h2>Limits</h2>
<ul> <ul>
<li>No runtime parsing of ICU MessageFormat. Use plural objects + refs.</li> <li>No runtime parser for free-form MessageFormat 2 patterns yet. For 1.0, model text with plural objects + refs and keep any MF2 parser as a dedicated adapter above the core resolver.</li>
<li>No async loading by default — translations are part of the bundle. For lazy locales, build a custom <code>EngineLang</code> with on-demand <code>setSchema()</code>.</li> <li>No async loading by default — translations are part of the bundle. For lazy locales, build a custom <code>EngineLang</code> with on-demand <code>setSchema()</code>.</li>
<li>HTML inside translations is not sanitised. Render with <code>{`{@html …}`}</code> only when you trust the source.</li> <li>HTML inside translations is not sanitised. Render with <code>{`{@html …}`}</code> only when you trust the source.</li>
</ul> </ul>

@ -0,0 +1,155 @@
<script lang="ts">
import CodeBlock from '../../_components/CodeBlock.svelte';
import ModuleHeader from '../../_components/ModuleHeader.svelte';
import PageNav from '../../_components/PageNav.svelte';
import Callout from '../../_components/Callout.svelte';
const quickStart = `import { createActiveApp } from '$active-app';
import { applyStandardOrca } from '$active-app/presets';
import {
defineActiveCache,
defineActiveConnections,
defineActivePerm,
defineActiveSession
} from '$active-app/services';
const App = createActiveApp({
services: {
cache: defineActiveCache({}),
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ onRefresh, onRevoke }),
connections: defineActiveConnections({})
}
});
applyStandardOrca(App);
App.session.adoptServer(nextSessionFromServer);
// session publishes session.identity.changed on App.bus;
// App.orca runs the presets registered by applyStandardOrca(App).`;
const customAction = `import {
ORCA_QUEUE_REPLACE_QUEUED,
ORCA_STAGE_POST,
orcaSuccess
} from '$orca';
App.orca.configureEvent('dating.match.created', {
queuePolicy: ORCA_QUEUE_REPLACE_QUEUED
});
App.orca.onEvent<{ userId: string }>('dating.match.created', {
id: 'dating.match.invalidate-feed',
stage: ORCA_STAGE_POST,
action: async (payload) => {
await App.cache.invalidate({
tags: [
{ type: 'dating:discover' },
{ type: 'dating:profile', id: payload.userId }
]
});
return orcaSuccess();
}
});`;
</script>
<svelte:head>
<title>Orca ($orca) - Active</title>
</svelte:head>
<article class="article">
<ModuleHeader
section="Infrastructure"
title="Orca"
alias="$orca"
summary="Orchestration engine for cross-module reactions, staged actions, queue policies, fan-in gates, validation and traceable runs."
factories={['createEngineOrca', 'createActiveOrca', 'App.orca']}
dependsOn={['$bus', '$timer', '$logger']}
layer="EngineOrca / ActiveOrca"
/>
<Callout variant="info" title="Core App service">
<p>
<code>Orca</code> is no longer optional documentation glue. <code>createActiveApp()</code>
builds <code>App.orca</code> as part of the fixed core alongside <code>Logger</code>,
<code>Bus</code>, <code>Timers</code> and <code>Prefs</code>. It stays inert until
actions or presets are registered.
</p>
</Callout>
<h2>Overview</h2>
<p>
Orca coordinates work that crosses module boundaries. Cache invalidation after identity
changes, permission refresh, connection reauth and feature-specific workflows should not
live inside those modules as hard-coded side effects. They are registered as Orca actions.
</p>
<p>
The engine listens through the shared Bus, schedules with Timers and reports through Logger.
A run is traceable: events produce staged actions, queue decisions, skipped gates, errors and
final run status.
</p>
<h2>Quick start</h2>
<CodeBlock code={quickStart} lang="ts" title="Standard App orchestration" />
<h2>Core concepts</h2>
<table>
<thead>
<tr><th>Concept</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>event</code></td><td>Trigger entering Orca.</td><td>Usually published through <code>App.bus</code>; derived events are emitted inside actions with <code>ctx.emit()</code>.</td></tr>
<tr><td><code>action</code></td><td>Unit of work for an event.</td><td>Has id, stage, guards, action function and error policy.</td></tr>
<tr><td><code>stage</code></td><td>Execution lane.</td><td>guard, pre, main, post, cleanup and finally keep ordering explicit.</td></tr>
<tr><td><code>queue</code></td><td>Concurrency policy.</td><td>Configured per event with <code>configureEvent()</code>: fifo, replace-queued, drop-latest and parallel.</td></tr>
<tr><td><code>fan-in</code></td><td>Wait for several tokens/events.</td><td>Useful for flows that need multiple prerequisites.</td></tr>
<tr><td><code>trace</code></td><td>Run identity and diagnostics.</td><td>Used by devtools, logs and tests.</td></tr>
</tbody>
</table>
<h2>Custom workflow</h2>
<CodeBlock code={customAction} lang="ts" title="Feature action" />
<h2>Standard presets</h2>
<p>
The presets under <code>$active-app/presets</code> are the canonical place for App-level
reactions. They wire identity/session events to cache clear, permission invalidation and
connection reauth/close without baking those reactions into the individual modules.
</p>
<table>
<thead>
<tr><th>Preset</th><th>Effect</th><th>Use when</th></tr>
</thead>
<tbody>
<tr><td><code>applyStandardOrca(App)</code></td><td>Registers the standard identity reactions.</td><td>Most client apps.</td></tr>
<tr><td><code>applyCacheClearOnIdentityChange(App)</code></td><td>Clears private cache after actor change.</td><td>Cache exists without full preset.</td></tr>
<tr><td><code>applyPermInvalidateOnIdentityChange(App)</code></td><td>Invalidates permission snapshots.</td><td>Permission decisions depend on actor state.</td></tr>
<tr><td><code>applyConnectionsReauthOnIdentityChange(App)</code></td><td>Reauthenticates connection registry.</td><td>Realtime channels depend on session identity.</td></tr>
<tr><td><code>applyConnectionsCloseOnRevoke(App)</code></td><td>Closes connections on revoke/logout.</td><td>Credentials must not survive session revocation.</td></tr>
</tbody>
</table>
<h2>Boundaries</h2>
<ul>
<li>Orca coordinates side effects; it does not own domain state.</li>
<li>Authorization decisions still belong to <code>$perm</code> / <code>$svrs/perm</code>.</li>
<li>Cache data still belongs to <code>$cache</code>.</li>
<li>Realtime transport still belongs to <code>$connection</code>.</li>
<li>Orca should make cross-module reactions visible, cancelable and testable.</li>
</ul>
<h2>Testing</h2>
<table>
<thead>
<tr><th>Target</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>src/arts/orca/test/engine-orca.test.ts</code></td><td>Core runtime.</td><td>Stages, queues, gates, fan-in, validation, reentry and error policies.</td></tr>
<tr><td><code>src/arts/orca/test/active-orca.svelte.test.ts</code></td><td>Reactive wrapper.</td><td>Active state and lifecycle.</td></tr>
<tr><td><code>src/arts/active-app/presets/*</code></td><td>App integration.</td><td>Identity reactions registered through Orca.</td></tr>
</tbody>
</table>
<PageNav />
</article>

@ -192,7 +192,7 @@ const Perms = createEnginePerms({
policies, policies,
providers: createPermProviders(db), providers: createPermProviders(db),
compilers: [createSqlCompiler({ relation: compileRelationForSql })], compilers: [createSqlCompiler({ relation: compileRelationForSql })],
logger: App.Logger logger: App.logger
});`; });`;
const databaseProviders = `function createPermProviders(db): PermProviders { const databaseProviders = `function createPermProviders(db): PermProviders {
@ -331,7 +331,7 @@ return rows;`;
} }
}, },
compilers: [createSqlCompiler()], compilers: [createSqlCompiler()],
logger: App.Logger logger: App.logger
});`; });`;
const serverCheck = `const actor = await resolveActorFromServerSession(event); const serverCheck = `const actor = await resolveActorFromServerSession(event);
@ -579,7 +579,7 @@ for (const obligation of decision.obligations ?? []) {
<p> <p>
Authorization is a server decision with a client mirror. The policy language in Authorization is a server decision with a client mirror. The policy language in
<code>$libs/perm</code> defines what can be said, <code>$svrs/perm</code> evaluates it against <code>$libs/perm</code> defines what can be said, <code>$svrs/perm</code> evaluates it against
trusted actor/resource/context data, and <code>$arts/perm</code> only mirrors decisions for UI trusted actor/resource/context data, and <code>$perm</code> only mirrors decisions for UI
responsiveness. If a route, action, WebSocket channel or job touches protected data, the server responsiveness. If a route, action, WebSocket channel or job touches protected data, the server
engine must decide again even if the button was hidden by engine must decide again even if the button was hidden by
<code>&lt;Can /&gt;</code>. <code>&lt;Can /&gt;</code>.
@ -989,16 +989,16 @@ for (const obligation of decision.obligations ?? []) {
<ul> <ul>
<li><code>auth</code> proves identity; <code>perm</code> decides access.</li> <li><code>auth</code> proves identity; <code>perm</code> decides access.</li>
<li> <li>
<code>sess</code> is where the actor comes from; never trust an actor sent by the browser. <code>$session</code> is where the actor comes from; never trust an actor sent by the browser.
</li> </li>
<li><code>http</code> is the active client's transport when composed through App.</li> <li><code>http</code> is the active client's transport when composed through App.</li>
<li> <li>
<code>cach</code> and permission snapshots must be invalidated after actor or permission changes. <code>$cache</code> and permission snapshots must be invalidated after actor or permission changes.
</li> </li>
<li> <li>
<code>conn</code> servers should call the engine before channel joins or privileged messages. <code>$connection</code> servers should call the engine before channel joins or privileged messages.
</li> </li>
<li><code>logr</code> receives decision, deny and indeterminate diagnostics when injected.</li> <li><code>$logger</code> receives decision, deny and indeterminate diagnostics when injected.</li>
</ul> </ul>
<h2>Common mistakes</h2> <h2>Common mistakes</h2>

@ -0,0 +1,135 @@
<script lang="ts">
import CodeBlock from '../../_components/CodeBlock.svelte';
import ModuleHeader from '../../_components/ModuleHeader.svelte';
import PageNav from '../../_components/PageNav.svelte';
const quickStart = `import { createActiveApp } from '$active-app';
import { defineActiveFrontend, defineActiveFormat } from '$active-app/services';
const App = createActiveApp({
prefs: {
capabilities,
environment,
intent: {
language: 'es',
locale: 'es-ES',
theme: 'system',
density: 'normal',
timezone: 'Europe/Madrid'
}
},
services: {
frontend: defineActiveFrontend({}),
format: defineActiveFormat({})
}
});
App.prefs.theme.set('dark');
App.prefs.density.set('compact');
const locale = App.prefs.locale.get();`;
const sources = `import {
prefsLocaleSource,
prefsDensitySource,
prefsThemeSource,
prefsDirectionSource
} from '$prefs';
const localeSource = prefsLocaleSource(App.prefs);
const densitySource = prefsDensitySource(App.prefs);`;
</script>
<svelte:head>
<title>Prefs ($prefs) - Active</title>
</svelte:head>
<article class="article">
<ModuleHeader
section="Preferences & Environment"
title="Prefs"
alias="$prefs"
summary="Core preference engine for user intent, environment defaults, effective values and bridges consumed by frontend, format and lang."
factories={['createEnginePrefs', 'createActivePrefs', 'createPrefsStorageBridge']}
dependsOn={['$storage bridge (optional)']}
layer="Core (App.prefs) / EnginePrefs / ActivePrefs"
/>
<h2>Overview</h2>
<p>
Prefs is part of the <code>$active-app</code> core. Every App has <code>App.prefs</code>,
even when the application does not declare any services. The engine separates what the user
wants from what the environment provides: it receives intent, capabilities and environment;
then resolves an effective preference snapshot for locale, language, timezone, theme,
density, motion, direction, currency and unit system.
</p>
<p>
Frontend, Format and Lang should read from Prefs through source helpers instead of each
module inventing its own settings model. Storage persistence is handled through the storage
bridge, not by frontend.
</p>
<h2>Quick start</h2>
<CodeBlock code={quickStart} lang="ts" title="App core prefs" />
<h2>Source helpers</h2>
<p>
The source helpers expose narrow contracts so other modules can subscribe to only the
preference they need.
</p>
<CodeBlock code={sources} lang="ts" title="Bridge into other modules" />
<table>
<thead>
<tr><th>Helper</th><th>Feeds</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>prefsLocaleSource()</code></td><td>Format</td><td>Regional BCP 47 locale for numbers, dates and currency.</td></tr>
<tr><td><code>prefsLanguageSource()</code></td><td>Lang / Frontend</td><td>Translation language and writing-system direction source.</td></tr>
<tr><td><code>prefsDirectionSource()</code></td><td>Frontend / Dom</td><td>LTR/RTL direction.</td></tr>
<tr><td><code>prefsThemeSource()</code></td><td>Frontend</td><td>Theme intent/effective value.</td></tr>
<tr><td><code>prefsDensitySource()</code></td><td>Frontend / UI</td><td>Density preference.</td></tr>
<tr><td><code>prefsTimezoneSource()</code></td><td>Format</td><td>Timezone for date/time display.</td></tr>
<tr><td><code>prefsCurrencySource()</code></td><td>Format</td><td>Currency preference.</td></tr>
<tr><td><code>prefsUnitSystemSource()</code></td><td>Format</td><td>Metric/imperial domain defaults.</td></tr>
</tbody>
</table>
<h2>Runtime API</h2>
<table>
<thead>
<tr><th>Member</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>state.snapshot</code></td><td>Full preference snapshot.</td><td>Reactive in ActivePrefs.</td></tr>
<tr><td><code>state.effective</code></td><td>Resolved values.</td><td>Combines intent, environment and capabilities.</td></tr>
<tr><td><code>setIntent(key, value)</code></td><td>Apply one user preference intent.</td><td>Use for UI settings controls.</td></tr>
<tr><td><code>clearIntent(key)</code></td><td>Return one preference to environment/defaults.</td><td>Useful for "system" mode.</td></tr>
<tr><td><code>resetIntent(next?)</code></td><td>Replace the whole intent map.</td><td>Use for hydration or full reset.</td></tr>
<tr><td><code>refreshEnvironment(next)</code></td><td>Update detected environment.</td><td>Browser/server adapters call this.</td></tr>
<tr><td><code>setCapabilities(capabilities)</code></td><td>Constrain valid values.</td><td>Prevents unsupported modes.</td></tr>
<tr><td><code>pending / lastError</code></td><td>Async bridge placeholders.</td><td>Reserved for storage bridge and future async adapters.</td></tr>
</tbody>
</table>
<h2>Adapters</h2>
<ul>
<li><code>detectBrowserEnvironment()</code> reads browser locale, timezone, color scheme and motion hints.</li>
<li><code>watchBrowserEnvironment()</code> keeps environment in sync when browser settings change.</li>
<li><code>detectServerEnvironment()</code> derives initial values from request headers.</li>
<li><code>createPrefsStorageBridge()</code> persists intent through the storage module.</li>
</ul>
<h2>Testing</h2>
<table>
<thead>
<tr><th>Target</th><th>Purpose</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>src/arts/prefs/test/engine-prefs.test.ts</code></td><td>Resolution engine.</td><td>Intent, environment, capabilities and reset behavior.</td></tr>
<tr><td><code>src/arts/prefs/test/sources.test.ts</code></td><td>Module bridges.</td><td>Locale/theme/density/direction sources.</td></tr>
<tr><td><code>src/arts/prefs/test/storage-bridge.test.ts</code></td><td>Persistence bridge.</td><td>Storage integration without UI coupling.</td></tr>
</tbody>
</table>
<PageNav />
</article>

@ -40,7 +40,7 @@ const Auth = createEngineAuth({
store: createMemoryAuthAdapter(), store: createMemoryAuthAdapter(),
actors: createMemoryAuthActors(), actors: createMemoryAuthActors(),
sess: createMemoryAuthSessPort(), sess: createMemoryAuthSessPort(),
logger: App.Logger, logger,
timer: { nowMs: () => Date.now() }, timer: { nowMs: () => Date.now() },
crypto: createWebCryptoAuthCrypto(), crypto: createWebCryptoAuthCrypto(),
passwordHasher: createTestPasswordHasher() passwordHasher: createTestPasswordHasher()
@ -66,7 +66,7 @@ const Perms = createEnginePerms({
policies: definePolicies(schema, [ policies: definePolicies(schema, [
allow('invoice.read').when(attr('actor.role').eq('admin')) allow('invoice.read').when(attr('actor.role').eq('admin'))
]), ]),
logger: App.Logger logger
}); });
const handlers = createPermHttpHandlers(Perms, resolveActorFromRequest);`; const handlers = createPermHttpHandlers(Perms, resolveActorFromRequest);`;
@ -86,7 +86,7 @@ const Cache = createEngineCache({
actorId: event.locals.auth?.actor?.id, actorId: event.locals.auth?.actor?.id,
permissionHash: event.locals.permissionsHash permissionHash: event.locals.permissionsHash
}), }),
logger: App.Logger logger
});`; });`;
const svelteKit = `// +hooks.server.ts const svelteKit = `// +hooks.server.ts
@ -132,7 +132,7 @@ export const POST = async ({ request }) => {
{ {
name: 'Mixing auth, permission and cache responsibilities', name: 'Mixing auth, permission and cache responsibilities',
why: 'Identity proof, access decisions and data freshness become impossible to reason about.', why: 'Identity proof, access decisions and data freshness become impossible to reason about.',
fix: 'Auth proves identity, perm authorizes, cach manages freshness/scopes.' fix: 'Auth proves identity, perm authorizes, cache manages freshness/scopes.'
} }
] as const; ] as const;
@ -155,7 +155,7 @@ export const POST = async ({ request }) => {
{ {
step: 'Security boundaries', step: 'Security boundaries',
where: '$svrs/auth, $svrs/perm, $svrs/cache', where: '$svrs/auth, $svrs/perm, $svrs/cache',
rule: 'Auth proves identity, perm authorizes actions and cach preserves private scopes. Do not merge those responsibilities.' rule: 'Auth proves identity, perm authorizes actions and cache preserves private scopes. Do not merge those responsibilities.'
}, },
{ {
step: 'Diagnostics', step: 'Diagnostics',
@ -199,7 +199,7 @@ export const POST = async ({ request }) => {
<code>$svrs/perm</code> and <code>$svrs/cache</code>. They exist because these artifacts <code>$svrs/perm</code> and <code>$svrs/cache</code>. They exist because these artifacts
have a backend half and a frontend half. The shared language lives in have a backend half and a frontend half. The shared language lives in
<code>$libs/*</code>, the server authority lives in <code>$svrs/*</code>, and the <code>$libs/*</code>, the server authority lives in <code>$svrs/*</code>, and the
reactive browser/client wrappers live in <code>$arts/*</code>. reactive browser/client wrappers live under <code>src/arts/*</code>.
</p> </p>
<p> <p>
This is not a duplicate of App. <code>$active-app</code> is a browser/client composition root. This is not a duplicate of App. <code>$active-app</code> is a browser/client composition root.
@ -211,7 +211,7 @@ export const POST = async ({ request }) => {
<p> <p>
Server modules exist for artifacts that have a real backend authority. The shared Server modules exist for artifacts that have a real backend authority. The shared
language lives in <code>$libs</code>, the server engine lives in <code>$svrs</code>, and language lives in <code>$libs</code>, the server engine lives in <code>$svrs</code>, and
the reactive browser facade lives in <code>$arts</code>. That split matters because only the reactive browser facade lives in <code>src/arts</code>. That split matters because only
the server has trusted request context, secure cookies, database access and private ports. the server has trusted request context, secure cookies, database access and private ports.
</p> </p>
<p> <p>
@ -269,7 +269,7 @@ export const POST = async ({ request }) => {
<tr><td>Engine</td><td><code>createEngineAuth</code>, <code>EngineAuth</code></td><td>Current view, password flows, CSRF, recovery, devices, OAuth/MFA primitives and event subscriptions.</td></tr> <tr><td>Engine</td><td><code>createEngineAuth</code>, <code>EngineAuth</code></td><td>Current view, password flows, CSRF, recovery, devices, OAuth/MFA primitives and event subscriptions.</td></tr>
<tr><td>Handlers</td><td><code>createAuthRouteHandlers</code>, <code>createSvelteKitAuthHandle</code></td><td>HTTP/SvelteKit integration. Default route handlers cover current, CSRF, password, recovery and sign-out.</td></tr> <tr><td>Handlers</td><td><code>createAuthRouteHandlers</code>, <code>createSvelteKitAuthHandle</code></td><td>HTTP/SvelteKit integration. Default route handlers cover current, CSRF, password, recovery and sign-out.</td></tr>
<tr><td>Adapters</td><td><code>createMemoryAuthAdapter</code>, <code>createDbAuthAdapter</code>, password/crypto/mailer/test adapters</td><td>Persistence and mechanism ports without hard dependency on an ORM/provider.</td></tr> <tr><td>Adapters</td><td><code>createMemoryAuthAdapter</code>, <code>createDbAuthAdapter</code>, password/crypto/mailer/test adapters</td><td>Persistence and mechanism ports without hard dependency on an ORM/provider.</td></tr>
<tr><td>Integrations</td><td><code>AuthSessPort</code>, <code>AuthCachePort</code>, <code>AuthPermsPort</code>, <code>AuthHttpPort</code></td><td>Ports for sess, cach, perm, http, timer and logr.</td></tr> <tr><td>Integrations</td><td><code>AuthSessPort</code>, <code>AuthCachePort</code>, <code>AuthPermsPort</code>, <code>AuthHttpPort</code></td><td>Ports for session, cache, perm, http, timer and logger.</td></tr>
</tbody> </tbody>
</table> </table>
@ -329,7 +329,7 @@ export const POST = async ({ request }) => {
<ul> <ul>
<li><code>$libs/*</code> defines shared contracts, constants and pure helpers.</li> <li><code>$libs/*</code> defines shared contracts, constants and pure helpers.</li>
<li><code>$svrs/*</code> owns server authority, secrets, ports and backend adapters.</li> <li><code>$svrs/*</code> owns server authority, secrets, ports and backend adapters.</li>
<li><code>$arts/*</code> owns active/client state and browser ergonomics.</li> <li><code>src/arts/*</code> owns active/client state and browser ergonomics.</li>
<li><code>$active-app</code> composes client roots; it should not be imported as the server authority.</li> <li><code>$active-app</code> composes client roots; it should not be imported as the server authority.</li>
<li>Do not expose server stores, password hashes, refresh tokens, CSRF secrets or provider tokens to active/client modules.</li> <li>Do not expose server stores, password hashes, refresh tokens, CSRF secrets or provider tokens to active/client modules.</li>
</ul> </ul>

@ -17,7 +17,7 @@
const layerRules = `libs/* → shared contracts, constants and pure helpers const layerRules = `libs/* → shared contracts, constants and pure helpers
svrs/* → server authority, ports, backend adapters, secrets svrs/* → server authority, ports, backend adapters, secrets
arts/* → public runtime artifacts and active/client wrappers arts/* → public runtime artifacts and active/client wrappers
aapp → client composition root, not server authority`; $active-app → client composition root: fixed core plus typed service schema, not server authority`;
const importRules = `// Good: modules depend on shared contracts const importRules = `// Good: modules depend on shared contracts
import type { Logger } from '$libs/logger'; import type { Logger } from '$libs/logger';
@ -55,7 +55,7 @@ logger.debug(LOGGER_CATEGORY, CONNECTION_LOG_MESSAGES.RECONNECT_SCHEDULED, {
}); });
// Bad // Bad
logger.debug('conn', 'reconnect scheduled', { name, attempt });`; logger.debug('connection', 'reconnect scheduled', { name, attempt });`;
const completionChecklist = `Before final response: const completionChecklist = `Before final response:
- focused tests for the touched module pass - focused tests for the touched module pass
@ -120,7 +120,7 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
<tr><td>Is this a constant, type or pure helper?</td><td><code>libs</code></td><td>Shared by server, active and tests.</td></tr> <tr><td>Is this a constant, type or pure helper?</td><td><code>libs</code></td><td>Shared by server, active and tests.</td></tr>
<tr><td>Does this decide identity, access or private cache scope?</td><td><code>svrs</code></td><td>The server is authoritative.</td></tr> <tr><td>Does this decide identity, access or private cache scope?</td><td><code>svrs</code></td><td>The server is authoritative.</td></tr>
<tr><td>Does this expose Svelte state or browser UX?</td><td><code>arts</code></td><td>Active wrappers live in <code>.svelte.ts</code>.</td></tr> <tr><td>Does this expose Svelte state or browser UX?</td><td><code>arts</code></td><td>Active wrappers live in <code>.svelte.ts</code>.</td></tr>
<tr><td>Does this wire existing modules for the app?</td><td><code>aapp</code></td><td>Composition, not new domain logic.</td></tr> <tr><td>Does this wire existing modules for the app?</td><td><code>$active-app</code></td><td>Composition, not new domain logic.</td></tr>
</tbody> </tbody>
</table> </table>
@ -143,13 +143,13 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
<tbody> <tbody>
<tr><td><code>createEngineXxx</code></td><td>Imperative/pure runtime, no Svelte state.</td><td><code>arts</code> or <code>svrs</code>, depending on authority.</td></tr> <tr><td><code>createEngineXxx</code></td><td>Imperative/pure runtime, no Svelte state.</td><td><code>arts</code> or <code>svrs</code>, depending on authority.</td></tr>
<tr><td><code>createActiveXxx</code></td><td>Reactive Svelte wrapper over runtime state.</td><td><code>arts/*/*.svelte.ts</code>.</td></tr> <tr><td><code>createActiveXxx</code></td><td>Reactive Svelte wrapper over runtime state.</td><td><code>arts/*/*.svelte.ts</code>.</td></tr>
<tr><td><code>App.createXxx</code></td><td>Composition helper that injects shared services.</td><td><code>aapp</code>.</td></tr> <tr><td><code>defineActiveXxx</code> / <code>defineEngineXxx</code></td><td>Service-schema factory for App composition.</td><td><code>$active-app/services</code>.</td></tr>
</tbody> </tbody>
</table> </table>
<Callout variant="info" title="Server-backed modules"> <Callout variant="info" title="Server-backed modules">
<p> <p>
<code>auth</code>, <code>perm</code> and <code>cach</code> have server modules under <code>auth</code>, <code>perm</code> and <code>cache</code> have server modules under
<code>$svrs</code>. Their active modules are client reflectors. Do not move <code>$svrs</code>. Their active modules are client reflectors. Do not move
authoritative logic into active/browser code. authoritative logic into active/browser code.
</p> </p>
@ -181,7 +181,7 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
<h2>Security rules</h2> <h2>Security rules</h2>
<ul> <ul>
<li><code>auth</code> proves identity; <code>sess</code> keeps continuity; <code>perm</code> decides access.</li> <li><code>auth</code> proves identity; <code>session</code> keeps continuity; <code>perm</code> decides access.</li>
<li>The browser never decides authorization. ActivePerms is UX only.</li> <li>The browser never decides authorization. ActivePerms is UX only.</li>
<li>Storage must not persist passwords, refresh tokens, OTPs, CSRF secrets or provider tokens.</li> <li>Storage must not persist passwords, refresh tokens, OTPs, CSRF secrets or provider tokens.</li>
<li>Private cache entries must include actor, tenant or permission scope when session/permission data exists.</li> <li>Private cache entries must include actor, tenant or permission scope when session/permission data exists.</li>
@ -232,11 +232,11 @@ logger.debug('conn', 'reconnect scheduled', { name, attempt });`;
code={`Audit this Active framework module against its ecosystem rules: code={`Audit this Active framework module against its ecosystem rules:
- Verify public docs match real exports and types. - Verify public docs match real exports and types.
- Check layer boundaries: libs vs svrs vs arts vs aapp. - Check layer boundaries: libs vs svrs vs arts vs active-app.
- Find magic strings in logs, events, errors, methods, protocol messages and routes. - Find magic strings in logs, events, errors, methods, protocol messages and routes.
- Check logger usage: modules should accept Logger from $libs/logger. - Check logger usage: modules should accept Logger from $libs/logger.
- Verify ActiveEngine consistency: loading, lastError, disposed, snapshot, clearError, onChange, dispose. - Verify ActiveEngine consistency: loading, lastError, disposed, snapshot, clearError, onChange, dispose.
- Verify server authority for auth, perm and cach. - Verify server authority for auth, perm and cache.
- Identify duplicated boilerplate, oversized files, missing constants and missing tests. - Identify duplicated boilerplate, oversized files, missing constants and missing tests.
- Do not propose new APIs without showing where they fit in the existing conventions. - Do not propose new APIs without showing where they fit in the existing conventions.
- Write findings with file paths, severity, rationale and concrete remediation.`} - Write findings with file paths, severity, rationale and concrete remediation.`}

@ -3,21 +3,80 @@
import Callout from '../../_components/Callout.svelte'; import Callout from '../../_components/Callout.svelte';
import PageNav from '../../_components/PageNav.svelte'; import PageNav from '../../_components/PageNav.svelte';
const dependencyDiagram = ` lang logr const dependencyDiagram = `createActiveApp()
\\ / | \\ core, always present:
\\ / | \\ Logger -> Bus
fmts http timer Logger -> Timers
\\ | /|\\ Logger + Bus + Timers -> Orca
adom ─── fend \\ | / | conn Prefs
\\ \\ \\ | / | \\
\\ \\ \\ | / auth perm services, opt-in:
──────────────── aapp ─ stor ─ cach core Prefs -> lang
: core Prefs -> format
sium`; core Prefs + lang + dom -> frontend
storage + http + timers -> session
session -> auth / perm / connections
lang -> sium
orchestration:
session events -> App.bus -> App.orca actions -> cache / perm / connections`;
const appExample = `import { createActiveApp } from '$active-app';
import {
defineActiveLang,
defineActiveFormat,
defineActiveFrontend,
defineActiveStorage,
defineEngineHttp,
defineActiveSession,
defineActiveCache
} from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
import { LogLevel, consoleTransport } from '$logger';
export const App = createActiveApp({
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
orca: { maxDepth: 24 },
prefs: {
capabilities,
environment,
intent: { language: 'es', locale: 'es-ES', theme: 'system' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
format: defineActiveFormat(),
frontend: defineActiveFrontend(),
storage: defineActiveStorage(),
http: defineEngineHttp({ baseUrl: '/api' }),
session: defineActiveSession({ schemas, storage, onRefresh, onRevoke }),
cache: defineActiveCache()
}
});
applyStandardOrca(App);`;
const orcaExample = `import {
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyConnectionsReauthOnIdentityChange
} from '$active-app/presets';
applyCacheClearOnIdentityChange(App);
applyPermInvalidateOnIdentityChange(App);
applyConnectionsReauthOnIdentityChange(App);`;
const prefsExample = `App.prefs.language.set('es');
App.prefs.locale.set('es-MX');
App.prefs.theme.set('dark');
// App.prefs drives downstream services when they are declared:
// - App.lang follows App.prefs.language.get()
// - App.format follows App.prefs.locale.get()
// - App.frontend follows App.prefs theme, density, motion and direction`;
</script> </script>
<svelte:head> <svelte:head>
<title>Composition — Active</title> <title>Composition - Active</title>
</svelte:head> </svelte:head>
<article class="article"> <article class="article">
@ -29,144 +88,123 @@ adom ─── fend \\ | / | conn
<h1>Composition</h1> <h1>Composition</h1>
<p class="lead"> <p class="lead">
Active is wired through one entry point — <code>$active-app</code> — and follows two factory Active is wired through <code>$active-app</code>. The current model is a fixed runtime
conventions and one shared contract. Once you internalise the three pieces, every artifact core plus a typed service schema: <code>Logger</code>, <code>Bus</code>,
looks the same. <code>Timers</code>, <code>Orca</code> and <code>Prefs</code> always exist; the rest of
the ecosystem is declared explicitly under <code>services</code>.
</p> </p>
<h2>Two factories</h2> <h2>Factories</h2>
<ul> <ul>
<li> <li>
<strong><code>createEngineXxx(options)</code></strong> — pure factory. Public methods <strong><code>createEngineXxx(options)</code></strong> - pure factory. It has no
over private state (or no state at all). No runes, safe to import from server-only Svelte runes and is safe for server code, tests and adapters.
modules.
</li> </li>
<li> <li>
<strong><code>createActiveXxx(options)</code></strong> — wraps an engine and exposes <strong><code>createActiveXxx(options)</code></strong> - reactive runtime wrapper. It
public reactive state. Lives in a <code>.svelte.ts</code> file because it owns lives in a <code>.svelte.ts</code> file when it owns <code>$state</code>.
<code>$state</code>. Imports must target the file directly to keep the rest of the </li>
artifact runes-free. <li>
<strong><code>defineActiveXxx()</code> / <code>defineEngineXxx()</code></strong> -
service-schema adapters used only by <code>$active-app/services</code>.
</li> </li>
</ul> </ul>
<Callout variant="info" title="Server vs client artifacts"> <Callout variant="info" title="Core is not a service">
<p> <p>
When an artifact has a true server-authoritative counterpart — <code>auth</code>, The App core is configured with root options on <code>createActiveApp()</code>. Do not
<code>perm</code>, <code>cach</code> — the engine lives under <code>$svrs/</code> and declare <code>logger</code>, <code>bus</code>, <code>timers</code>,
the client side keeps an Active reflector under <code>$arts/</code>. <code>orca</code> or <code>prefs</code> inside <code>services</code>.
</p> </p>
</Callout> </Callout>
<h2>One contract</h2> <h2>Fixed core</h2>
<p>Every Active root implements:</p> <table>
<thead>
<CodeBlock <tr>
lang="ts" <th>Member</th>
code={`interface ActiveEngine<TSnapshot, TError> { <th>Created by</th>
readonly loading: boolean; <th>Purpose</th>
readonly lastError: TError | null; </tr>
readonly disposed: boolean; </thead>
<tbody>
snapshot(): TSnapshot; <tr><td><code>App.logger</code></td><td><code>createEngineLogger()</code></td><td>Structured logs and transports.</td></tr>
clearError(): void; <tr><td><code>App.bus</code></td><td><code>createSvelteEngineBus()</code></td><td>Typed application event bus.</td></tr>
onChange(listener: (snapshot: TSnapshot) => void): () => void; <tr><td><code>App.timers</code></td><td><code>createActiveTimers()</code></td><td>Deterministic scheduler and shared clock.</td></tr>
dispose(): void; <tr><td><code>App.orca</code></td><td><code>createEngineOrca()</code></td><td>Cross-module orchestration. It is inert until actions or presets are registered.</td></tr>
}`} <tr><td><code>App.prefs</code></td><td><code>createActivePrefs()</code></td><td>Core preference engine. Uses root <code>prefs</code> options or neutral defaults.</td></tr>
/> </tbody>
</table>
<ul>
<li>Direct getters (<code>Auth.current</code>, <code>Cache.loading</code>) — no <code>.state</code> object.</li>
<li>Always <code>loading</code>, never <code>pending</code>.</li>
<li><code>dispose()</code> is idempotent and clears every owned listener / entry / resource.</li>
<li>Roots that create entries (<code>ActiveCache.entry()</code>, <code>ActiveConnections.connection()</code>) own those entries and dispose them when the root is disposed.</li>
</ul>
<h2>Cross-artifact dependencies</h2>
<p>
<code>lang</code> and <code>logr</code> are the dependency-free roots. Everything else
composes upward:
</p>
<CodeBlock lang="text" code={dependencyDiagram} title="Dependency map" />
<ul>
<li><code>fmts</code> consumes <code>logr</code> only inside its currency rate fetcher diagnostics.</li>
<li><code>sium</code> takes <code>lang</code> and <code>logr</code> via injection and falls back to local message interpolation when omitted.</li>
<li><code>timer</code> is the deterministic scheduler consumed by <code>sess</code> and <code>conn</code>.</li>
<li><code>http</code> takes <code>logr</code> via injection (auto-wired through <code>aapp</code>).</li>
<li><code>sess</code> uses <code>stor</code> for persistence, <code>timer</code> for auto-refresh, and <code>http</code> for 401-rescue integration.</li>
<li><code>conn</code> uses <code>timer</code> for reconnect / heartbeat / ack timeouts and accepts the App session bridge when composed through <code>aapp</code>.</li>
<li><code>auth</code> / <code>perm</code> / <code>cach</code> split cleanly: server side under <code>$svrs/</code>, client reflector under <code>$arts/</code>.</li>
</ul>
<h2>Always-present roots vs scoped factories</h2> <h2>Typed services</h2>
<p> <p>
The composer in <code>$active-app</code> distinguishes two kinds of artifacts: Every declared service becomes a typed lowercase property on <code>App</code>. Services can
be lazy or immediate, can request a subset of the core, and can depend on other declared
services. Undeclared services do not exist on the App type.
</p> </p>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Kind</th> <th>Service</th>
<th>Members</th> <th>Factory</th>
<th>Lifetime</th> <th>Important wiring</th>
<th>How to access</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr><td><code>lang</code></td><td><code>defineActiveLang()</code></td><td>Always follows <code>App.prefs.language.get()</code>.</td></tr>
<td>Always-present</td> <tr><td><code>format</code></td><td><code>defineActiveFormat()</code></td><td>Always reads its locale source from <code>App.prefs</code> (or an explicit <code>localeSource</code> override).</td></tr>
<td>Logger, Lang, Format, Frontend, Dom, Storage, Http, Timers, Cache</td> <tr><td><code>frontend</code></td><td><code>defineActiveFrontend()</code></td><td>Always consumes <code>App.prefs</code> for theme/density/motion/direction; optionally <code>dom</code>.</td></tr>
<td>App-scoped</td> <tr><td><code>dom</code></td><td><code>defineActiveDom()</code></td><td>Browser document adapter; inert on the server.</td></tr>
<td><code>App.lang</code>, <code>App.cache</code>, …</td> <tr><td><code>storage</code></td><td><code>defineActiveStorage()</code></td><td>Runtime storage adapter, memory-backed by default.</td></tr>
</tr> <tr><td><code>http</code></td><td><code>defineEngineHttp()</code></td><td>Engine service for API calls and request diagnostics.</td></tr>
<tr> <tr><td><code>cache</code></td><td><code>defineActiveCache()</code></td><td>Passive cache. Identity reactions belong to Orca presets.</td></tr>
<td>Schema services</td> <tr><td><code>sium</code></td><td><code>defineEngineSium()</code></td><td>Validation engine; consumes <code>lang</code> if declared.</td></tr>
<td>Cache, Format, Frontend, Dom, Storage, Http, Lang, Sium, Session, Auth, Perm, Connections</td> <tr><td><code>session</code></td><td><code>defineActiveSession()</code></td><td>Publishes session events on <code>App.bus</code>.</td></tr>
<td>App-scoped, opt-in</td> <tr><td><code>auth</code></td><td><code>defineActiveAuth()</code></td><td>Client reflector for server-authoritative auth flows.</td></tr>
<td><code>defineActiveSession({`{...}`})</code>, <code>defineEngineSium({`{}`})</code>, …</td> <tr><td><code>perm</code></td><td><code>defineActivePerm()</code></td><td>Permission reflector. Invalidation is opt-in through Orca.</td></tr>
</tr> <tr><td><code>connections</code></td><td><code>defineActiveConnections()</code></td><td>Realtime registry; reauth/close reactions are Orca presets.</td></tr>
</tbody> </tbody>
</table> </table>
<Callout variant="info" title="Single-instance by construction"> <h2>Dependency graph</h2>
<p> <CodeBlock lang="text" code={dependencyDiagram} title="Current composition map" />
A duplicate slot in <code>services: {`{ ... }`}</code> is a JavaScript object-literal
error — the schema makes "factory called twice" structurally impossible. There are no
runtime "already created" exceptions for the schema-driven services.
</p>
</Callout>
<h2>Disposal</h2> <h2>App composition</h2>
<CodeBlock lang="ts" code={appExample} title="src/lib/app.ts" />
<h2>Orca reactions</h2>
<p> <p>
<code>App.dispose()</code> tears down every artifact in the right order Module events are public typed contracts on <code>App.bus</code>. The bus does not run
(<em>consumers → providers</em>): <code>Auth</code> and <code>Perms</code> first, then destructive behavior by itself; <code>App.orca</code> owns those cross-module reactions.
<code>Connections</code>, then <code>Sess</code>, then <code>Cache</code>, Use <code>applyStandardOrca(App)</code> for the default set, or cherry-pick individual
<code>Timers</code>, <code>Frontend</code>, <code>Dom</code>, <code>Format</code>, presets when the application needs a narrower policy.
<code>Storage</code>, <code>Lang</code>, and finally <code>Logger</code>. Subsequent calls
are no-ops.
</p> </p>
<CodeBlock lang="ts" code={orcaExample} />
<CodeBlock <h2>Preferences propagation</h2>
lang="ts" <p>
code={`onDestroy(() => App.dispose());`} <code>App.prefs</code> is the ecosystem-wide source of user intent. Locale is no longer a
/> Lang-only concern: language drives translations, locale drives regional formats, and
Frontend receives theme, density, motion and direction from the same effective snapshot.
</p>
<CodeBlock lang="ts" code={prefsExample} />
<h2>Locale propagation</h2> <h2>Disposal</h2>
<p> <p>
<code>App.lang.setLocale(locale)</code> is the single source of truth. It propagates through <code>App.dispose()</code> publishes the dispose-starting event, disposes constructed
<code>Lang</code> first, which then notifies <code>Format</code> and <code>Frontend</code> services in reverse construction order, tears down the prefs storage bridge, then disposes
via the shared <code>localeSource</code> bridge, so every consumer ends up agreeing on the <code>Prefs</code>, <code>Orca</code>, <code>Bus</code>, <code>Timers</code> and finally
same BCP 47 tag. <code>Logger</code>. The operation is idempotent.
</p> </p>
<CodeBlock <CodeBlock lang="ts" code={`onDestroy(() => App.dispose());`} />
lang="ts"
code={`App.lang.setLocale('es-MX'); <h2>Server boundary</h2>
// → Lang resolves to 'es-MX' (with fallback chain) <p>
// → Format picks up the new locale for numbers / currency / dates <code>$active-app</code> is the client composition root. Server authority stays in
// → Frontend updates the document direction (LTR / RTL)`} <code>$svrs</code> and pure engines. Shared contracts belong in <code>$libs</code>.
/> </p>
<PageNav /> <PageNav />
</article> </article>

@ -8,10 +8,16 @@ src/svrs/* server-authoritative engines and backend adapters
src/arts/* client/runtime artifacts, active wrappers and browser ergonomics src/arts/* client/runtime artifacts, active wrappers and browser ergonomics
src/web/routes documentation, test pages and app routes src/web/routes documentation, test pages and app routes
$active-app client composition root that wires the active ecosystem`; $active-app client composition root: Logger, Bus, Timers, Orca, Prefs core + typed services`;
const appFlow = `const App = createActiveApp({ const appFlow = `const App = createActiveApp({
logger: { level: LogLevel.INFO, transports: [consoleTransport()] }, logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
orca: { maxDepth: 24 },
prefs: {
capabilities,
environment,
intent: { language: 'es', locale: 'es-ES', theme: 'system' }
},
services: { services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }), lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
storage: defineActiveStorage({ adapter: localAdapter, namespace: 'app' }), storage: defineActiveStorage({ adapter: localAdapter, namespace: 'app' }),
@ -26,7 +32,7 @@ $active-app client composition root that wires the active ecosystem`;
} }
}); });
applyStandardOrca(App); // wires cache.clear / perm.invalidate on identity events`; applyStandardOrca(App); // wires cache, perm and connections reactions through App.orca`;
const serverFlow = `const Auth = createEngineAuth({ const serverFlow = `const Auth = createEngineAuth({
security, security,
@ -60,18 +66,19 @@ const Cache = createEngineCache({
const identityFlow = `signInPassword() const identityFlow = `signInPassword()
→ ActiveAuth obtains CSRF and posts to server → ActiveAuth obtains CSRF and posts to server
→ EngineAuth validates credential and binds session through sess port → EngineAuth validates credential and binds session through the session port
→ Session state changes → Session state changes
→ Perms snapshot/cache must be invalidated for the new actor → Perms snapshot/cache must be invalidated for the new actor
→ Cache private scopes change actorId / permissionHash → Cache private scopes change actorId / permissionHash
→ Connections can reauthenticate or disconnect through the App session bridge`; → Connections can reauthenticate or disconnect through the App session bridge`;
const localeFlow = `App.lang.setLocale('ar-EG') const localeFlow = `App.prefs.language.set('ar')
→ App.lang.setLocale('ar-EG') App.prefs.locale.set('ar-EG')
→ App.format.setLocale('ar-EG') -> prefs resolves effective language and regional locale
→ App.frontend sees localeSource change -> App.lang follows App.prefs.language.get()
→ Frontend updates dir when dir is auto -> App.format follows App.prefs.locale.get()
→ Dom.apply writes dir="rtl" to the configured target`; -> App.frontend follows App.prefs language, theme, density, motion and direction
-> Dom writes dir="rtl" to the configured target when frontend direction changes`;
</script> </script>
<svelte:head> <svelte:head>
@ -125,9 +132,9 @@ const Cache = createEngineCache({
<td>No server secrets, no authoritative authorization, no password/token persistence.</td> <td>No server secrets, no authoritative authorization, no password/token persistence.</td>
</tr> </tr>
<tr> <tr>
<td><code>aapp</code></td> <td><code>$active-app</code></td>
<td>Client composition root.</td> <td>Client composition root with fixed core plus typed services.</td>
<td>No replacement for server engines; it wires active clients, not backend authority.</td> <td>No replacement for server engines; it wires runtime modules, not backend authority.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -155,13 +162,14 @@ const Cache = createEngineCache({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>Composition</td><td><code>aapp</code></td><td>Single client root, always-present services, feature factories, disposal order.</td></tr> <tr><td>Composition</td><td><code>$active-app</code></td><td>Single client root, fixed Logger/Bus/Timers/Orca/Prefs core, typed service schema and disposal order.</td></tr>
<tr><td>Identity</td><td><code>auth</code>, <code>sess</code>, <code>perm</code></td><td>Prove identity, keep session continuity, decide access.</td></tr> <tr><td>Identity</td><td><code>$auth</code>, <code>$session</code>, <code>$perm</code></td><td>Prove identity, keep session continuity, decide access.</td></tr>
<tr><td>Data</td><td><code>http</code>, <code>cache</code>, <code>stor</code></td><td>Remote calls, coherent cached data, safe local persistence.</td></tr> <tr><td>Data</td><td><code>$http</code>, <code>$cache</code>, <code>$storage</code></td><td>Remote calls, coherent cached data, safe local persistence.</td></tr>
<tr><td>I18n and formats</td><td><code>lang</code>, <code>fmts</code></td><td>Text translation plus locale-driven numbers, currency, units and dates.</td></tr> <tr><td>Preferences</td><td><code>$prefs</code></td><td>User intent, environment defaults and effective locale/theme/density/timezone values.</td></tr>
<tr><td>Frontend runtime</td><td><code>fend</code>, <code>adom</code></td><td>Global visual preferences, direction, theme, DOM writes and responsive helpers.</td></tr> <tr><td>I18n and formats</td><td><code>$lang</code>, <code>$format</code></td><td>Text translation plus preference-driven numbers, currency, units and dates.</td></tr>
<tr><td>Validation</td><td><code>sium</code></td><td>Page-scoped schemas, issues, metadata and translated validation messages.</td></tr> <tr><td>Frontend runtime</td><td><code>$frontend</code>, <code>$adom</code></td><td>Effective visual state, direction, theme, DOM writes and responsive helpers.</td></tr>
<tr><td>Infrastructure</td><td><code>logr</code>, <code>timer</code>, <code>conn</code></td><td>Structured logs, deterministic timers and realtime connections.</td></tr> <tr><td>Validation</td><td><code>$sium</code></td><td>Page-scoped schemas, issues, metadata and translated validation messages.</td></tr>
<tr><td>Infrastructure</td><td><code>$bus</code>, <code>$logger</code>, <code>$timer</code>, <code>$orca</code>, <code>$connection</code></td><td>Events, structured logs, deterministic timers, orchestration and realtime connections.</td></tr>
</tbody> </tbody>
</table> </table>
@ -184,15 +192,17 @@ const Cache = createEngineCache({
<tr><td><code>createEngineXxx()</code></td><td>You need deterministic, non-runes runtime logic.</td><td><code>createEngineHttp</code>, <code>createEngineTimers</code>, <code>createEngineSium</code></td></tr> <tr><td><code>createEngineXxx()</code></td><td>You need deterministic, non-runes runtime logic.</td><td><code>createEngineHttp</code>, <code>createEngineTimers</code>, <code>createEngineSium</code></td></tr>
<tr><td><code>createActiveXxx()</code></td><td>You need Svelte 5 reactive state and lifecycle.</td><td><code>createActiveStorage</code>, <code>createActiveConnections</code></td></tr> <tr><td><code>createActiveXxx()</code></td><td>You need Svelte 5 reactive state and lifecycle.</td><td><code>createActiveStorage</code>, <code>createActiveConnections</code></td></tr>
<tr><td><code>$svrs/createEngineXxx()</code></td><td>The result must be server-authoritative.</td><td><code>createEngineAuth</code>, <code>createEnginePerms</code>, <code>createEngineCache</code></td></tr> <tr><td><code>$svrs/createEngineXxx()</code></td><td>The result must be server-authoritative.</td><td><code>createEngineAuth</code>, <code>createEnginePerms</code>, <code>createEngineCache</code></td></tr>
<tr><td><code>App.logger / App.bus / App.timers / App.orca / App.prefs</code></td><td>You need the fixed App core.</td><td>Always present; configured from root options, never declared as services.</td></tr>
<tr><td><code>defineActiveXxx() / defineEngineXxx()</code></td><td>You want App to declare a service in its schema and inject core deps automatically.</td><td><code>defineEngineSium({`{}`})</code>, <code>defineActiveAuth({`{...}`})</code></td></tr> <tr><td><code>defineActiveXxx() / defineEngineXxx()</code></td><td>You want App to declare a service in its schema and inject core deps automatically.</td><td><code>defineEngineSium({`{}`})</code>, <code>defineActiveAuth({`{...}`})</code></td></tr>
</tbody> </tbody>
</table> </table>
<h2>App composition</h2> <h2>App composition</h2>
<p> <p>
<code>createActiveApp()</code> gives the browser/client side a stable surface. Some roots <code>createActiveApp()</code> gives the browser/client side a stable surface. The fixed
are always present; feature-scoped pieces are created explicitly so pages only pay for what core is always present: <code>Logger</code>, <code>Bus</code>, <code>Timers</code>,
they use. <code>Orca</code> and <code>Prefs</code>. Feature modules are declared explicitly in
<code>services</code>, so pages only pay for what the app composes.
</p> </p>
<CodeBlock code={appFlow} lang="ts" title="Client composition" /> <CodeBlock code={appFlow} lang="ts" title="Client composition" />
@ -213,16 +223,15 @@ const Cache = createEngineCache({
<h2>Identity flow</h2> <h2>Identity flow</h2>
<p> <p>
Identity-sensitive work follows a stricter chain. <code>auth</code> proves identity, Identity-sensitive work follows a stricter chain. <code>auth</code> proves identity,
<code>sess</code> keeps continuity, <code>perm</code> decides access, and <code>$session</code> keeps continuity, <code>$perm</code> decides access, and
<code>cache</code> must scope or invalidate private data. <code>cache</code> must scope or invalidate private data.
</p> </p>
<CodeBlock code={identityFlow} lang="text" /> <CodeBlock code={identityFlow} lang="text" />
<h2>Locale flow</h2> <h2>Locale flow</h2>
<p> <p>
Locale is another ecosystem-wide source of truth. Text, formatting and frontend direction Preferences are the ecosystem-wide source of user intent. Translation language, regional
all respond to the same locale source unless the user explicitly overrides a specific format locale and frontend direction can be related, but they are not the same value.
preference.
</p> </p>
<CodeBlock code={localeFlow} lang="text" /> <CodeBlock code={localeFlow} lang="text" />
@ -236,15 +245,15 @@ const Cache = createEngineCache({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>Translate labels, messages or fallbacks.</td><td><code>lang</code></td><td><code>fmts</code> or ad-hoc dictionaries.</td></tr> <tr><td>Translate labels, messages or fallbacks.</td><td><code>$lang</code></td><td><code>$format</code> or ad-hoc dictionaries.</td></tr>
<tr><td>Format numbers, dates, currency or units.</td><td><code>fmts</code></td><td><code>lang</code>.</td></tr> <tr><td>Format numbers, dates, currency or units.</td><td><code>$format</code></td><td><code>$lang</code>.</td></tr>
<tr><td>Persist non-secret preferences or drafts.</td><td><code>stor</code></td><td><code>sess</code>, localStorage calls spread through pages.</td></tr> <tr><td>Persist non-secret preferences or drafts.</td><td><code>$storage</code></td><td><code>$session</code>, localStorage calls spread through pages.</td></tr>
<tr><td>Keep logged-in continuity.</td><td><code>sess</code></td><td><code>auth</code> alone.</td></tr> <tr><td>Keep logged-in continuity.</td><td><code>$session</code></td><td><code>$auth</code> alone.</td></tr>
<tr><td>Prove identity or run login/recovery flows.</td><td><code>$svrs/auth</code> plus <code>$auth</code></td><td><code>perm</code> or client-only checks.</td></tr> <tr><td>Prove identity or run login/recovery flows.</td><td><code>$svrs/auth</code> plus <code>$auth</code></td><td><code>perm</code> or client-only checks.</td></tr>
<tr><td>Decide if an actor can do something.</td><td><code>$svrs/perm</code> plus <code>$perm</code></td><td><code>auth</code>, roles hard-coded in UI.</td></tr> <tr><td>Decide if an actor can do something.</td><td><code>$svrs/perm</code> plus <code>$perm</code></td><td><code>auth</code>, roles hard-coded in UI.</td></tr>
<tr><td>Cache data with scopes and invalidation.</td><td><code>$svrs/cache</code> or <code>$cache</code></td><td><code>stor</code> as a query cache.</td></tr> <tr><td>Cache data with scopes and invalidation.</td><td><code>$svrs/cache</code> or <code>$cache</code></td><td><code>$storage</code> as a query cache.</td></tr>
<tr><td>Schedule retries, refreshes or timeouts.</td><td><code>timer</code></td><td>raw <code>setTimeout</code> scattered across modules.</td></tr> <tr><td>Schedule retries, refreshes or timeouts.</td><td><code>timer</code></td><td>raw <code>setTimeout</code> scattered across modules.</td></tr>
<tr><td>Open realtime sockets and channels.</td><td><code>conn</code></td><td>custom WebSocket state in components.</td></tr> <tr><td>Open realtime sockets and channels.</td><td><code>$connection</code></td><td>custom WebSocket state in components.</td></tr>
<tr><td>Validate forms and generate issues.</td><td><code>sium</code></td><td><code>perm</code> or manual string errors.</td></tr> <tr><td>Validate forms and generate issues.</td><td><code>sium</code></td><td><code>perm</code> or manual string errors.</td></tr>
</tbody> </tbody>
</table> </table>
@ -285,8 +294,8 @@ const Cache = createEngineCache({
</thead> </thead>
<tbody> <tbody>
<tr><td>Unit</td><td><code>src/arts/*/test</code>, <code>src/libs/*/test</code>, <code>src/svrs/*/test</code></td><td>Each artifact obeys its own contract.</td></tr> <tr><td>Unit</td><td><code>src/arts/*/test</code>, <code>src/libs/*/test</code>, <code>src/svrs/*/test</code></td><td>Each artifact obeys its own contract.</td></tr>
<tr><td>Integration</td><td><code>src/arts/aapp/test</code></td><td>App wiring, locale propagation, session bridge, disposal order.</td></tr> <tr><td>Integration</td><td><code>src/arts/active-app/test</code></td><td>App wiring, prefs propagation, orca presets, session bridge, disposal order.</td></tr>
<tr><td>Scenario</td><td><code>/test/ecosystem</code></td><td>A realistic app story with auth, sess, perm, cache, http, sium, conn and UI state together.</td></tr> <tr><td>Scenario</td><td><code>/test/ecosystem</code></td><td>A realistic app story with auth, session, perm, cache, http, sium, connection and UI state together.</td></tr>
</tbody> </tbody>
</table> </table>

@ -40,24 +40,29 @@
title="svelte.config.js" title="svelte.config.js"
lang="js" lang="js"
code={`alias: { code={`alias: {
$active-app: 'src/arts/aapp', '$active-app/services': 'src/arts/active-app/service-factories',
$adom: 'src/arts/adom', '$active-app/presets': 'src/arts/active-app/presets',
$auth: 'src/arts/auth', '$active-app': 'src/arts/active-app',
$cache: 'src/arts/cach', $adom: 'src/arts/adom',
$connection: 'src/arts/conn', $auth: 'src/arts/auth',
$frontend: 'src/arts/fend', $bus: 'src/arts/bus',
$format: 'src/arts/fmts', $cache: 'src/arts/cache',
$http: 'src/arts/http', $connection: 'src/arts/connection',
$lang: 'src/arts/lang', $frontend: 'src/arts/frontend',
$logger: 'src/arts/logr', $format: 'src/arts/format',
$perm: 'src/arts/perm', $http: 'src/arts/http',
$session: 'src/arts/sess', $lang: 'src/arts/lang',
$sium: 'src/arts/sium', $logger: 'src/arts/logger',
$storage: 'src/arts/stor', $orca: 'src/arts/orca',
$svrs: 'src/svrs', $perm: 'src/arts/perm',
$timer: 'src/arts/timer', $prefs: 'src/arts/prefs',
$libs: 'src/libs', $session: 'src/arts/session',
$locale: 'src/libs/locale', $sium: 'src/arts/sium',
$storage: 'src/arts/storage',
$svrs: 'src/svrs',
$timer: 'src/arts/timer',
$libs: 'src/libs',
$locale: 'src/libs/locale',
$reactive: 'src/libs/reactive' $reactive: 'src/libs/reactive'
}`} }`}
/> />
@ -88,7 +93,7 @@
<p> <p>
As a result, the minimum <code>createActiveApp(&#123;&#125;)</code> import has a measured budget As a result, the minimum <code>createActiveApp(&#123;&#125;)</code> import has a measured budget
instead of a guess. <code>npm run test:bundle</code> builds a virtual Vite entry with OXC, instead of a guess. <code>npm run test:bundle</code> builds a virtual Vite entry with OXC,
gzips the emitted JavaScript and fails above the <code>0.1</code> budget. gzips the emitted JavaScript and fails above the configured budget.
</p> </p>
<CodeBlock <CodeBlock
@ -103,7 +108,7 @@ ACTIVE_BUNDLE_GZIP_LIMIT_KB=70 npm run test:bundle`}
<p> <p>
The default budget is <code>70 KB gzip</code>. That is intentionally a smoke gate, not a The default budget is <code>70 KB gzip</code>. That is intentionally a smoke gate, not a
micro-benchmark: it protects the root runtime from accidental graph explosions while still micro-benchmark: it protects the root runtime from accidental graph explosions while still
leaving room for the always-present App contract. leaving room for the fixed App core.
</p> </p>
<h2>First app</h2> <h2>First app</h2>
@ -119,32 +124,45 @@ ACTIVE_BUNDLE_GZIP_LIMIT_KB=70 npm run test:bundle`}
export const App = createActiveApp(); export const App = createActiveApp();
App.Logger.info('app.boot', 'Active app ready');`} App.logger.info('app.boot', 'Active app ready');`}
/> />
<p> <p>
Even with no options, every member of <code>App</code> is present. <code>App.lang</code> Even with no options, the fixed core is present: <code>App.logger</code>,
falls back to a passthrough mono-locale, <code>App.Logger</code> to a console transport, <code>App.bus</code>, <code>App.timers</code>, <code>App.orca</code> and
<code>App.format</code> to the default locale. Call sites stay uniform whether or not the <code>App.prefs</code>. Feature modules are added explicitly under <code>services</code>.
feature is configured.
</p> </p>
<h2>Realistic setup</h2> <h2>Realistic setup</h2>
<p>For a typed i18n schema, console logger, and persisted preferences:</p> <p>For typed i18n, preferences, formatting, frontend state and storage:</p>
<CodeBlock <CodeBlock
lang="ts" lang="ts"
title="src/lib/app.ts" title="src/lib/app.ts"
code={`import { createActiveApp } from '$active-app'; code={`import { createActiveApp } from '$active-app';
import {
defineActiveLang,
defineActiveFormat,
defineActiveFrontend,
defineActiveStorage
} from '$active-app/services';
import { LogLevel, consoleTransport } from '$logger'; import { LogLevel, consoleTransport } from '$logger';
import { localAdapter } from '$storage'; import { localAdapter } from '$storage';
import { schema } from './i18n/schema'; import { schema } from './i18n/schema';
export const App = createActiveApp({ export const App = createActiveApp({
lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: { level: LogLevel.INFO, transports: [consoleTransport()] }, logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
storage: { adapter: localAdapter }, prefs: {
frontend: { theme: 'base', persist: ['theme', 'mode'] } capabilities,
environment,
intent: { language: 'es', locale: 'es-ES', theme: 'system' }
},
services: {
lang: defineActiveLang({ schema, defaultLocale: 'es', fallbackChain: ['en'] }),
format: defineActiveFormat(),
frontend: defineActiveFrontend({ theme: 'base' }),
storage: defineActiveStorage({ adapter: localAdapter })
}
});`} });`}
/> />

@ -3,21 +3,27 @@
import Callout from '../../_components/Callout.svelte'; import Callout from '../../_components/Callout.svelte';
import PageNav from '../../_components/PageNav.svelte'; import PageNav from '../../_components/PageNav.svelte';
const stableSurface = `// Always-present roots are snapshot-tested for the 0.1 line. const stableSurface = `// Fixed App core for the 1.0 line.
const App = createActiveApp(); const App = createActiveApp();
App.Logger; App.logger;
App.lang; App.bus;
App.format; App.timers;
App.frontend; App.orca;
App.dom; App.prefs;
App.storage; App.dispose();`;
App.http;
App.Timers; const scopedSurface = `// Core options (logger, timers, bus, orca, prefs) and schema-declared
App.cache;`; // services are stable by slot name, but their option shapes may grow
// during 1.x if the change is additive.
const scopedSurface = `// Schema-declared services are stable by slot name, but their option createActiveApp({ prefs: { capabilities }, services: { /* ... */ } });
// shapes may still grow during 0.1.x if the change is additive.
defineActiveLang({ schema });
defineActiveFormat({});
defineActiveFrontend({});
defineActiveStorage({});
defineEngineHttp({});
defineActiveCache({});
defineEngineSium({}); defineEngineSium({});
defineActiveSession({ /* ... */ }); defineActiveSession({ /* ... */ });
defineActiveConnections({ /* ... */ }); defineActiveConnections({ /* ... */ });
@ -26,7 +32,7 @@ defineActivePerm({ /* ... */ });`;
const deprecation = `/** const deprecation = `/**
* @deprecated Use setCurrency('auto') or clearCurrency() instead. * @deprecated Use setCurrency('auto') or clearCurrency() instead.
* Deprecated in 0.1.3. Earliest removal: 0.2.0. * Deprecated in 1.0.3. Earliest removal: 2.0.0.
*/ */
function resetCurrency(): void { function resetCurrency(): void {
logger.warn('format.currency.deprecated.reset_currency', { logger.warn('format.currency.deprecated.reset_currency', {
@ -40,7 +46,7 @@ function resetCurrency(): void {
verifyRegistration verifyRegistration
}; };
// Experimental exports may change without the 0.1.x stability guarantee. // Experimental exports may change without the 1.x stability guarantee.
// They must be clearly named and documented as experimental.`; // They must be clearly named and documented as experimental.`;
</script> </script>
@ -57,33 +63,37 @@ function resetCurrency(): void {
<h1>Versioning</h1> <h1>Versioning</h1>
<p class="lead"> <p class="lead">
Active is pre-<code>0.1.0</code>. The first stable cut is not a promise of production Active is being hardened for <code>1.0</code>. The stable cut is a contract: public
maturity; it is a promise that the public runtime shape will stop moving silently. runtime shape, documented module boundaries and release checks must stop moving silently.
</p> </p>
<h2>What 0.1 means</h2> <h2>What 1.0 means</h2>
<p> <p>
<code>0.1.x</code> is the line where external developers can evaluate and build against <code>1.0.x</code> is the line where application code can build against the ecosystem
the framework without reading every commit. The guarantee is focused: always-present App without reading every commit. The guarantee is focused: the fixed App core keeps its
roots keep their public shape, security-sensitive exported methods either work or are not public shape, services are declared through stable schema slots, security-sensitive
exported, and breaking changes are announced through deprecation before removal. exported methods either work or are not exported, and breaking removals wait for a major
version.
</p> </p>
<Callout variant="info" title="Not production-ready"> <Callout variant="info" title="Stable contract, explicit scope">
<p> <p>
<code>0.1.0</code> does not mean OAuth provider catalog, MFA, production WebAuthn, <code>1.0</code> means the documented runtime contract is stable. It does not require
regulated workload readiness or npm distribution are complete. Those are explicit every possible product feature to exist: OAuth provider catalog, MFA, production
future milestones. WebAuthn, regulated workload readiness and npm distribution remain explicit future
milestones unless they are documented as part of the release.
</p> </p>
</Callout> </Callout>
<h2>Stable during 0.1.x</h2> <h2>Stable during 1.x</h2>
<p> <p>
The always-present App roots are the core contract. They are snapshot-tested in The fixed App core is the baseline contract: <code>Logger</code>, <code>Bus</code>,
<code>src/arts/aapp/test/active-app.test.ts</code> and changes should be additive unless <code>Timers</code>, <code>Orca</code>, <code>Prefs</code> and <code>dispose()</code>.
they go through the deprecation process. The service schema and preset behavior are covered under <code>src/arts/active-app/test</code>;
changes should be additive unless they go through the deprecation process and wait for the
next major.
</p> </p>
<CodeBlock code={stableSurface} lang="ts" title="Always-present App surface" /> <CodeBlock code={stableSurface} lang="ts" title="Fixed App core" />
<table> <table>
<thead> <thead>
@ -94,7 +104,7 @@ function resetCurrency(): void {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>Root property names</td><td>No silent removal or rename.</td><td>Additive new roots only after docs/tests.</td></tr> <tr><td>Core property names</td><td>No silent removal or rename.</td><td>Additive core members only after docs/tests.</td></tr>
<tr><td>Existing method names</td><td>No silent removal or semantic inversion.</td><td>Optional parameters and overloads.</td></tr> <tr><td>Existing method names</td><td>No silent removal or semantic inversion.</td><td>Optional parameters and overloads.</td></tr>
<tr><td>Error classes and guards</td><td>Keep type guards valid.</td><td>New error subclasses/codes.</td></tr> <tr><td>Error classes and guards</td><td>Keep type guards valid.</td><td>New error subclasses/codes.</td></tr>
<tr><td>Constants for public strings</td><td>Names stay searchable and centralized.</td><td>New constants for new events/routes/methods.</td></tr> <tr><td>Constants for public strings</td><td>Names stay searchable and centralized.</td><td>New constants for new events/routes/methods.</td></tr>
@ -104,11 +114,11 @@ function resetCurrency(): void {
<h2>Scoped factories</h2> <h2>Scoped factories</h2>
<p> <p>
Scoped factories are stable entry points, but some child surfaces are still younger than Service factories are stable entry points. Their child surfaces may grow during
the core roots. They may grow during <code>0.1.x</code>, especially <code>Auth</code>, <code>1.x</code> when the change is additive, especially <code>Auth</code>,
<code>Perms</code>, <code>Connections</code> and <code>Sium</code>. <code>Perm</code>, <code>Connections</code>, <code>Orca</code> and <code>Sium</code>.
</p> </p>
<CodeBlock code={scopedSurface} lang="ts" title="Scoped App factories" /> <CodeBlock code={scopedSurface} lang="ts" title="Service-schema factories" />
<p> <p>
Additive changes are allowed. Removing a method, changing a return shape, moving a method Additive changes are allowed. Removing a method, changing a return shape, moving a method
@ -117,8 +127,9 @@ function resetCurrency(): void {
<h2>Deprecation policy</h2> <h2>Deprecation policy</h2>
<p> <p>
Deprecated APIs remain compatible for at least one minor release. The deprecated path must Deprecated stable APIs remain compatible through the <code>1.x</code> line. The deprecated
have JSDoc, docs, tests, and a runtime warning only when the old path is actually used. path must have JSDoc, docs, tests, and a runtime warning only when the old path is actually
used. Removal belongs to <code>2.0</code> unless the API was explicitly experimental.
</p> </p>
<CodeBlock code={deprecation} lang="ts" title="Deprecation pattern" /> <CodeBlock code={deprecation} lang="ts" title="Deprecation pattern" />
@ -133,7 +144,7 @@ function resetCurrency(): void {
<tbody> <tbody>
<tr><td>Mark</td><td>Add <code>@deprecated</code> JSDoc with replacement and earliest removal.</td><td>Editors and docs surface the migration.</td></tr> <tr><td>Mark</td><td>Add <code>@deprecated</code> JSDoc with replacement and earliest removal.</td><td>Editors and docs surface the migration.</td></tr>
<tr><td>Warn</td><td>Emit a constant-backed warning when the old path is used.</td><td>Runtime users discover it without log spam.</td></tr> <tr><td>Warn</td><td>Emit a constant-backed warning when the old path is used.</td><td>Runtime users discover it without log spam.</td></tr>
<tr><td>Keep</td><td>Maintain compatibility through at least one minor.</td><td>No silent breakage inside <code>0.1.x</code>.</td></tr> <tr><td>Keep</td><td>Maintain compatibility through the current major.</td><td>No silent breakage inside <code>1.x</code>.</td></tr>
<tr><td>Test</td><td>Keep a regression test for old and new paths until removal.</td><td>Deprecation is behavior, not a comment.</td></tr> <tr><td>Test</td><td>Keep a regression test for old and new paths until removal.</td><td>Deprecation is behavior, not a comment.</td></tr>
<tr><td>Remove</td><td>Remove only after the documented window and changelog entry.</td><td>Consumers can plan upgrades.</td></tr> <tr><td>Remove</td><td>Remove only after the documented window and changelog entry.</td><td>Consumers can plan upgrades.</td></tr>
</tbody> </tbody>
@ -143,7 +154,7 @@ function resetCurrency(): void {
<p> <p>
If a feature is useful but not contract-ready, it must be exported under an explicit If a feature is useful but not contract-ready, it must be exported under an explicit
<code>__EXPERIMENTAL_*</code> name. Experimental APIs are not covered by the <code>__EXPERIMENTAL_*</code> name. Experimental APIs are not covered by the
<code>0.1.x</code> stability guarantee. <code>1.x</code> stability guarantee.
</p> </p>
<CodeBlock code={experimental} lang="ts" title="Experimental namespace" /> <CodeBlock code={experimental} lang="ts" title="Experimental namespace" />

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.