diff --git a/src/arts/active-app/active-app.svelte.ts b/src/arts/active-app/active-app.svelte.ts index 8325dc7..db2afff 100644 --- a/src/arts/active-app/active-app.svelte.ts +++ b/src/arts/active-app/active-app.svelte.ts @@ -48,20 +48,25 @@ import { APP_MODULE } from './consts.ts'; import { AappAlreadyCreatedError } from './errors.ts'; +import { buildServiceBuilders } from './service-builder.ts'; +import type { AppServiceSchema, CoreServices } from './services.ts'; import type { ActiveApp, ActiveAppBusEvents, + ActiveAppLegacy, ActiveAppOptions, ActiveAppOrchestrationOptions, ActiveAppOrchestrationTranslator } from './types.ts'; +import type { EngineBus } from '$bus'; /** * Composed application surface. See `./types.ts` for the full contract. */ -export function createActiveApp( - options: ActiveAppOptions = {} -): ActiveApp { +export function createActiveApp< + S extends LangNode = LangNode, + TSchema extends AppServiceSchema = AppServiceSchema +>(options: ActiveAppOptions = {}): ActiveApp { const Logger = createEngineLogger(options.logger); let Lang: ActiveLang; @@ -127,6 +132,19 @@ export function createActiveApp( timers: Timers, logger: Logger }); + + const coreForServices: CoreServices = { + logger: Logger, + // `EngineBus` is structurally a richer bus; + // service factories accept the generic `EngineBus`. + bus: Bus as unknown as EngineBus, + timers: Timers, + orca: Orca + }; + const serviceBuilders = options.services + ? buildServiceBuilders(options.services as AppServiceSchema, coreForServices) + : undefined; + const orchestration = resolveActiveAppOrchestration(options.orchestration); const detachSessionTranslator = orchestration.has(APP_ORCHESTRATION_TRANSLATOR_IDENTITY) ? wireSessionTranslator(Bus) @@ -145,7 +163,7 @@ export function createActiveApp( // eslint-disable-next-line svelte/prefer-svelte-reactivity -- disposal registry is not rendered state. const connectionRegistries = new Set(); - const app: ActiveApp = { + const baseApp: ActiveAppLegacy = { Logger, Lang, Format, @@ -264,6 +282,9 @@ export function createActiveApp( if (orchestration.has(APP_ORCHESTRATION_TRANSLATOR_DISPOSE)) { publishAppDisposeStarting(Bus, { cause: APP_MODULE }); } + // Schema-declared services first (reverse construction order + // is handled by the builder). + serviceBuilders?.disposeAll(); Auth?.dispose(); Auth = undefined; Perms?.dispose(); @@ -287,6 +308,35 @@ export function createActiveApp( } }; + // Compose the final App: legacy base + schema services + status + // introspection. Services are exposed as own properties via + // `Object.defineProperty` so lazy getters are preserved. + const app = baseApp as ActiveApp; + if (serviceBuilders !== undefined) { + for (const name of Object.keys(serviceBuilders.proxies)) { + Object.defineProperty(app, name, { + configurable: false, + enumerable: true, + get() { + return (serviceBuilders.proxies as Record)[name]; + } + }); + } + Object.defineProperty(app, 'services', { + configurable: false, + enumerable: true, + get() { + return serviceBuilders.statusMap(); + } + }); + } else { + Object.defineProperty(app, 'services', { + configurable: false, + enumerable: true, + value: Object.freeze({}) + }); + } + return app; } diff --git a/src/arts/active-app/test/active-app.test.ts b/src/arts/active-app/test/active-app.test.ts index 976f24d..0d9c8ae 100644 --- a/src/arts/active-app/test/active-app.test.ts +++ b/src/arts/active-app/test/active-app.test.ts @@ -88,6 +88,7 @@ describe('createActiveApp — composition', () => { 'dispose', 'getLocale', 'onLocaleChange', + 'services', 'setLocale' ]); diff --git a/src/arts/active-app/test/schema-declarative.test.ts b/src/arts/active-app/test/schema-declarative.test.ts new file mode 100644 index 0000000..c93babb --- /dev/null +++ b/src/arts/active-app/test/schema-declarative.test.ts @@ -0,0 +1,118 @@ +/** + * Verifies the new declarative service schema integrates with + * createActiveApp(): services are exposed as lowercase properties on + * the App, build lazily on first access, and are disposed in reverse + * order alongside the legacy core. + */ + +import { describe, expect, it } from 'vitest'; +import { LogLevel } from '$logger'; +import { createActiveApp } from '../active-app.svelte.ts'; +import { + defineActiveCache, + defineActiveDom, + defineActiveFormat, + defineActiveStorage, + defineEngineHttp +} from '../service-factories/index.ts'; + +const SILENT_LOGGER = { level: LogLevel.NONE, transports: [] }; + +describe('createActiveApp — declarative service schema', () => { + it('exposes declared services as lowercase properties', () => { + const App = createActiveApp({ + logger: SILENT_LOGGER, + services: { + cache: defineActiveCache(), + http: defineEngineHttp({ baseUrl: '/api' }) + } + }); + + expect(App.cache).toBeDefined(); + expect(App.http).toBeDefined(); + expect(typeof App.http.get).toBe('function'); + + App.dispose(); + }); + + it('builds services lazily by default', () => { + const App = createActiveApp({ + logger: SILENT_LOGGER, + services: { + cache: defineActiveCache() + } + }); + + expect(App.services.cache).toBe('absent'); + // touching it triggers construction + void App.cache; + expect(App.services.cache).toBe('present'); + + App.dispose(); + }); + + it('coexists with the legacy uppercase core (App.Cache, App.Bus, etc.)', () => { + const App = createActiveApp({ + logger: SILENT_LOGGER, + services: { + cache: defineActiveCache() + } + }); + + // legacy: App.Cache always present + expect(App.Cache).toBeDefined(); + // declarative: App.cache is the schema-declared instance + expect(App.cache).toBeDefined(); + // they are independent ActiveCache instances + expect(App.cache).not.toBe(App.Cache); + + App.dispose(); + }); + + it('disposes schema services on App.dispose()', () => { + let disposed = false; + const App = createActiveApp({ + logger: SILENT_LOGGER, + services: { + probe: { + name: 'probe' as const, + coreDependencies: [] as const, + initMode: 'immediate' as const, + create: () => ({ value: 1 }), + dispose: () => { + disposed = true; + } + } + } + }); + + App.dispose(); + expect(disposed).toBe(true); + }); + + it('exposes services introspection map via App.services', () => { + const App = createActiveApp({ + logger: SILENT_LOGGER, + services: { + cache: defineActiveCache(), + dom: defineActiveDom(), + format: defineActiveFormat(), + storage: defineActiveStorage() + } + }); + + expect(Object.keys(App.services).sort()).toEqual(['cache', 'dom', 'format', 'storage']); + // All lazy: nothing built yet. + for (const status of Object.values(App.services)) { + expect(status).toBe('absent'); + } + + App.dispose(); + }); + + it('returns an empty services map when no schema is declared', () => { + const App = createActiveApp({ logger: SILENT_LOGGER }); + expect(App.services).toEqual({}); + App.dispose(); + }); +}); diff --git a/src/arts/active-app/types.ts b/src/arts/active-app/types.ts index bedd4e7..14b6340 100644 --- a/src/arts/active-app/types.ts +++ b/src/arts/active-app/types.ts @@ -26,6 +26,11 @@ import type { APP_ORCHESTRATION_TRANSLATOR_PERM_REFRESH, APP_ORCHESTRATION_TRANSLATOR_TENANT_SWITCHED } from './consts.ts'; +import type { + AppServiceSchema, + ResolveServiceInstances, + ServiceStatus +} from './services.ts'; /** Frontend preferences eligible for App-managed persistence. */ export type FrontendPersistKey = FrontendPreferenceKey; @@ -107,7 +112,21 @@ export type ActiveAppOrchestrationOptions = * const sium = createEngineSium({ lang: App.Lang, logger: App.Logger }); * ``` */ -export interface ActiveAppOptions { +export interface ActiveAppOptions< + S extends LangNode = LangNode, + TSchema extends AppServiceSchema = AppServiceSchema +> { + /** + * Declarative service schema. Each entry is built by an + * `AppServiceFactory` from `arts/active-app/service-factories/`. + * Services are exposed as lowercase properties on the App + * (`App.cache`, `App.session`, …) and live alongside the legacy + * uppercase factories until those are removed. + * + * Lazy services build on first access; `immediate` services build + * during `createActiveApp()`. + */ + services?: TSchema; logger?: LoggerOptions; lang?: { schema: S; @@ -202,7 +221,20 @@ export interface ActiveAppOptions { * | Frontend | real | real with default theme/mode/density | * | Dom | real | real with default breakpoints | */ -export interface ActiveApp { +export type ActiveApp< + S extends LangNode = LangNode, + TSchema extends AppServiceSchema = AppServiceSchema +> = ActiveAppLegacy & ResolveServiceInstances & ActiveAppServicesIntrospection; + +export interface ActiveAppServicesIntrospection { + /** + * Snapshot of `{ [serviceName]: ServiceStatus }` for every declared + * service. Useful for devtools and tests. + */ + readonly services: Readonly>; +} + +export interface ActiveAppLegacy { readonly Logger: EngineLogger; readonly Lang: ActiveLang; readonly Format: ActiveFormat;