Wire AppServiceSchema into createActiveApp() alongside legacy core

createActiveApp() now accepts an `options.services: TSchema` object
and exposes each declared service as a lowercase property on the
returned App. Schema services coexist with the legacy uppercase
core (App.Cache, App.Bus, App.createActiveSession() etc.) — neither
collides with the other and apps can adopt the schema gradually.

Type changes:
  - ActiveApp<S, TSchema = AppServiceSchema> intersects
    ActiveAppLegacy<S> (the uppercase core + factory methods),
    ResolveServiceInstances<TSchema> (schema instances), and
    ActiveAppServicesIntrospection ({ services: { … } status map }).
  - ActiveAppOptions<S, TSchema> adds the optional `services?` slot.

Runtime changes:
  - createActiveApp builds a CoreServices snapshot (logger, bus,
    timers, orca) after Bus + Timers + Orca are constructed.
  - When `services` is provided, buildServiceBuilders() is invoked
    and every key becomes a getter on the App; reading triggers
    lazy construction. Immediate services build during
    createActiveApp().
  - dispose() runs schema services first (in reverse construction
    order) and then the legacy core in its existing order.
  - When `services` is absent, App.services returns an empty frozen
    object so introspection still works.

Tests:
  - active-app.test.ts: API surface includes the new `services` key.
  - schema-declarative.test.ts (new, 6 cases): lowercase access,
    lazy construction, coexistence with legacy uppercase, dispose,
    introspection map, empty schema fallback.

Total suite: 1408 pass (1402 + 6 new). The legacy translators,
APP_EVENT_* publishers and App.createActiveX() factories are still
present; their removal lands in subsequent commits of step 3.

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

@ -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<S extends LangNode = LangNode>(
options: ActiveAppOptions<S> = {}
): ActiveApp<S> {
export function createActiveApp<
S extends LangNode = LangNode,
TSchema extends AppServiceSchema = AppServiceSchema
>(options: ActiveAppOptions<S, TSchema> = {}): ActiveApp<S, TSchema> {
const Logger = createEngineLogger(options.logger);
let Lang: ActiveLang<S>;
@ -127,6 +132,19 @@ export function createActiveApp<S extends LangNode = LangNode>(
timers: Timers,
logger: Logger
});
const coreForServices: CoreServices = {
logger: Logger,
// `EngineBus<ActiveAppBusEvents>` is structurally a richer bus;
// service factories accept the generic `EngineBus`.
bus: Bus as unknown as EngineBus,
timers: Timers,
orca: Orca
};
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<S extends LangNode = LangNode>(
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- disposal registry is not rendered state.
const connectionRegistries = new Set<ActiveConnections>();
const app: ActiveApp<S> = {
const baseApp: ActiveAppLegacy<S> = {
Logger,
Lang,
Format,
@ -264,6 +282,9 @@ export function createActiveApp<S extends LangNode = LangNode>(
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<S extends LangNode = LangNode>(
}
};
// 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<S, TSchema>;
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<string, unknown>)[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;
}

@ -88,6 +88,7 @@ describe('createActiveApp — composition', () => {
'dispose',
'getLocale',
'onLocaleChange',
'services',
'setLocale'
]);

@ -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();
});
});

@ -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<S extends LangNode = LangNode> {
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<S extends LangNode = LangNode> {
* | Frontend | real | real with default theme/mode/density |
* | Dom | real | real with default breakpoints |
*/
export interface ActiveApp<S extends LangNode = LangNode> {
export type ActiveApp<
S extends LangNode = LangNode,
TSchema extends AppServiceSchema = AppServiceSchema
> = ActiveAppLegacy<S> & ResolveServiceInstances<TSchema> & ActiveAppServicesIntrospection;
export interface ActiveAppServicesIntrospection {
/**
* Snapshot of `{ [serviceName]: ServiceStatus }` for every declared
* service. Useful for devtools and tests.
*/
readonly services: Readonly<Record<string, ServiceStatus>>;
}
export interface ActiveAppLegacy<S extends LangNode = LangNode> {
readonly Logger: EngineLogger;
readonly Lang: ActiveLang<S>;
readonly Format: ActiveFormat;

Loading…
Cancel
Save

Powered by TurnKey Linux.