From eb6001fae4f6bc111422806119d0c1285150bc61 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 2 May 2026 22:35:01 +0200 Subject: [PATCH] Reduce App core to Logger/Bus/Timers/Orca; everything else is opt-in services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big-bang replacement of the active-app composition: legacy uppercase surface (App.Lang, App.Cache, App.Format, App.Frontend, App.Dom, App.Storage, App.Http) removed entirely. All non-core artifacts are now opt-in via services schema: services: { cache: defineActiveCache(), lang: defineActiveLang(...), ... } Schema services are exposed as lowercase properties (App.cache, App.lang, …) with end-to-end type safety; accessing a service the schema didn't declare is a compile error. Three import paths split for honest tree-shaking: $active-app createActiveApp + core types/errors/bus-context $active-app/services defineActive* / defineEngine* factories $active-app/presets applyCache* / applyPerm* / applyStandardOrca Service factories declare core deps (logger/bus/timers/orca) and sibling service deps (e.g. format wires localeSource from lang automatically when both are declared). The builder validates names, computes topological order, detects cycles, builds immediates eagerly, and exposes lazy proxies that materialise on first access. factory.create() runs inside untrack so subscriptions wired during construction (e.g. lang.onLocaleChange) cannot crash the outer reactive scope when triggered from a $derived. Reactions to lifecycle events (cache.clear on revoke / identity change, perm.invalidate on identity change) move from internal bus subscriptions inside arts to opt-in orca presets registered by the application: applyStandardOrca(App) // or cherry-pick individual apply* functions Also lands a working showcase at /demo wiring 10 of 12 services (everything except auth/connections, which need a real server) plus a mocked perm fetcher and a real http client against jsonplaceholder. Misc cleanup along the way: - libs/cache/{key,policy,scope}.ts: missing CACHE_VALIDATION_MESSAGES imports (the throw paths were never covered by tests, so the bug only surfaced via the demo) - All arts READMEs scrubbed of autoInvalidateOn / APP_EVENT_USER_* references; bus README rewritten around the orca-preset model - refactorizacion.md moved out of src/ into docs/ Verification: 1347/1347 vitest tests passing, demo loads and exercises all wired services in a real browser with zero console errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../active-app-refactorizacion.md | 0 src/arts/README.md | 14 +- src/arts/active-app/README.md | 767 ++++++------------ src/arts/active-app/active-app.svelte.ts | 169 ++-- src/arts/active-app/consts.ts | 6 + src/arts/active-app/errors.ts | 41 +- src/arts/active-app/events.ts | 23 +- src/arts/active-app/index.ts | 98 +-- .../integrations/frontend-storage.ts | 104 --- .../presets/cache-clear-on-identity-change.ts | 14 +- .../presets/cache-clear-on-revoke.ts | 16 +- src/arts/active-app/presets/index.ts | 17 +- .../perm-invalidate-on-identity-change.ts | 11 +- src/arts/active-app/presets/standard.ts | 28 +- src/arts/active-app/service-builder.ts | 25 +- src/arts/active-app/service-factories/auth.ts | 12 +- .../active-app/service-factories/cache.ts | 18 +- .../service-factories/connections.ts | 12 +- src/arts/active-app/service-factories/dom.ts | 3 + .../active-app/service-factories/format.ts | 34 +- .../active-app/service-factories/frontend.ts | 42 +- src/arts/active-app/service-factories/http.ts | 15 +- src/arts/active-app/service-factories/lang.ts | 15 +- src/arts/active-app/service-factories/perm.ts | 6 +- .../active-app/service-factories/session.ts | 18 +- src/arts/active-app/service-factories/sium.ts | 27 +- .../active-app/service-factories/storage.ts | 23 +- src/arts/active-app/services.ts | 15 +- src/arts/active-app/test/active-app.test.ts | 178 ---- .../test/schema-declarative.test.ts | 17 +- .../active-app/test/service-builder.test.ts | 615 +++++++------- .../test/storage-integration.test.ts | 146 ---- src/arts/active-app/test/test-app.test.ts | 137 ---- src/arts/active-app/testing/index.ts | 120 --- src/arts/active-app/types.ts | 268 +++--- src/arts/bus/README.md | 269 ++---- src/arts/cache/README.md | 77 +- src/arts/cache/test/engine-cache.test.ts | 13 +- src/arts/connection/README.md | 41 +- src/arts/perm/README.md | 144 ++-- src/arts/session/README.md | 69 +- src/arts/session/errors.ts | 8 +- src/libs/cache/key.ts | 2 +- src/libs/cache/policy.ts | 4 +- src/libs/cache/scope.ts | 4 +- src/web/routes/demo/+layout.svelte | 18 + src/web/routes/demo/+layout.ts | 6 + src/web/routes/demo/+page.svelte | 89 ++ src/web/routes/demo/_lib/app.svelte.ts | 184 +++++ .../routes/demo/_lib/components/BusLog.svelte | 114 +++ .../demo/_lib/components/CacheCards.svelte | 172 ++++ .../_lib/components/FormatShowcase.svelte | 43 + .../demo/_lib/components/LangSwitcher.svelte | 39 + .../demo/_lib/components/LoggerPanel.svelte | 61 ++ .../demo/_lib/components/PermInfo.svelte | 121 +++ .../demo/_lib/components/SessionCard.svelte | 103 +++ .../demo/_lib/components/ThemeSwitcher.svelte | 124 +++ .../demo/_lib/components/TimerWidget.svelte | 79 ++ .../_lib/components/ValidationForm.svelte | 109 +++ .../demo/_lib/components/ViewportInfo.svelte | 46 ++ src/web/routes/demo/_lib/lang-schema.ts | 87 ++ svelte.config.js | 5 + 62 files changed, 2724 insertions(+), 2361 deletions(-) rename src/arts/active-app/refactorizacion.md => docs/active-app-refactorizacion.md (100%) delete mode 100644 src/arts/active-app/integrations/frontend-storage.ts delete mode 100644 src/arts/active-app/test/active-app.test.ts delete mode 100644 src/arts/active-app/test/storage-integration.test.ts delete mode 100644 src/arts/active-app/test/test-app.test.ts delete mode 100644 src/arts/active-app/testing/index.ts create mode 100644 src/web/routes/demo/+layout.svelte create mode 100644 src/web/routes/demo/+layout.ts create mode 100644 src/web/routes/demo/+page.svelte create mode 100644 src/web/routes/demo/_lib/app.svelte.ts create mode 100644 src/web/routes/demo/_lib/components/BusLog.svelte create mode 100644 src/web/routes/demo/_lib/components/CacheCards.svelte create mode 100644 src/web/routes/demo/_lib/components/FormatShowcase.svelte create mode 100644 src/web/routes/demo/_lib/components/LangSwitcher.svelte create mode 100644 src/web/routes/demo/_lib/components/LoggerPanel.svelte create mode 100644 src/web/routes/demo/_lib/components/PermInfo.svelte create mode 100644 src/web/routes/demo/_lib/components/SessionCard.svelte create mode 100644 src/web/routes/demo/_lib/components/ThemeSwitcher.svelte create mode 100644 src/web/routes/demo/_lib/components/TimerWidget.svelte create mode 100644 src/web/routes/demo/_lib/components/ValidationForm.svelte create mode 100644 src/web/routes/demo/_lib/components/ViewportInfo.svelte create mode 100644 src/web/routes/demo/_lib/lang-schema.ts diff --git a/src/arts/active-app/refactorizacion.md b/docs/active-app-refactorizacion.md similarity index 100% rename from src/arts/active-app/refactorizacion.md rename to docs/active-app-refactorizacion.md diff --git a/src/arts/README.md b/src/arts/README.md index ce2bd9a..f8c52b9 100644 --- a/src/arts/README.md +++ b/src/arts/README.md @@ -153,10 +153,16 @@ whether or not i18n was configured. services, but construction is explicit at the call site. ```ts -const sium = createEngineSium({ lang: App.Lang, logger: App.Logger }); -const Connections = App.createActiveConnections(); -const Auth = App.createActiveAuth({ initial: data.auth }); -const Perms = App.createActivePerms({ endpoint: '/permissions' }); +const App = createActiveApp({ + services: { + sium: defineEngineSium({}), + connections: defineActiveConnections({}), + auth: defineActiveAuth({ initial: data.auth }), + perm: defineActivePerm({ endpoint: '/perm' }) + } +}); + +applyStandardOrca(App); ``` See `aapp/README.md` for the full composition contract. diff --git a/src/arts/active-app/README.md b/src/arts/active-app/README.md index 95a4b8d..ee59853 100644 --- a/src/arts/active-app/README.md +++ b/src/arts/active-app/README.md @@ -1,622 +1,323 @@ -# aapp — ActiveApp +# active-app -`aapp` is the application-level composition that wires the runtime artifacts -under a single namespace, with a single locale source of truth and a single -logger. +`arts/active-app` is the **composition layer** of the ecosystem. It builds the +fixed runtime core, composes opt-in services declared by the application, and +exposes the orchestration engine that wires them together. ```ts -import { createActiveApp } from '$aapp'; -import { translations } from './lang/schema'; +import { createActiveApp } from '$active-app'; +import { + defineActiveCache, + defineActiveLang, + defineActiveSession, + defineEngineHttp +} from '$active-app/services'; +import { applyStandardOrca } from '$active-app/presets'; const App = createActiveApp({ - lang: { schema: translations, defaultLocale: 'es', fallbackChain: ['en'] }, - logger: { - level: LogLevel.INFO, - globalContext: { appVersion: '1.0.0', env: 'prod' }, - transports: [consoleTransport()] - }, - frontend: { theme: 'base', mode: 'auto', density: 'normal' } -}); - -App.setLocale('es-MX'); -App.Lang.t('common.ok'); -App.Format.currency.format(12.5); -App.Frontend.setTheme('forest'); -App.Logger.info('boot', 'app ready'); - -App.dispose(); -``` - -## What it composes - -| Member | Always present | Default when not configured | -| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `App.Logger` | yes | engine default — `level: WARN` + `consoleTransport()`. Pass `{ level: LogLevel.NONE, transports: [] }` for silence | -| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, `t('a.b\|Fallback')` returns `'Fallback'`, and DEV warns once per unresolved path through Logger under `lang.mono` | -| `App.Format` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) | -| `App.Frontend` | yes | real with default theme/mode/density | -| `App.Dom` | yes | real with default breakpoints | -| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; storage diagnostics are wired through the shared Logger | -| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry }` | -| `App.Timers` | yes | `ActiveTimers` scheduler owned by App. Used by artifacts that need keyed runtime timers (`sess` auto-refresh, `conn` reconnect/heartbeat/ack) and disposed by `App.dispose()` | -| `App.Bus` | yes | `EngineBus` owned by App. `orchestration` only controls translators from module events to `app.*`; destructive reactions stay opt-in in each consumer | -| `App.Cache` | yes | `ActiveCache` backed by memory by default. Configure `cache: { adapter, policies, scopeResolver, autoInvalidateOn }` for persistence, policies or app-event reactions | -| `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic | -| `App.Auth` | no | created lazily through `App.createActiveAuth(...)`. App injects `Http`, `Cache` and `Logger`; the server authority remains `$svrs/auth` | -| `App.Perms` | no | created lazily through `App.createActivePerms(...)`. App injects `Http`, `Logger` and `Bus`; automatic invalidation uses `autoInvalidateOn` | -| `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and Bus; automatic reauth uses `autoReauthOn` plus each connection's own `session` option | - -Server-authoritative engines that have a browser reflector live under -`$svrs/*`: use `$svrs/auth` for `createEngineAuth()` and auth HTTP handlers, -`$svrs/perm` for `createEnginePerms()` and authorization handlers, and -`$svrs/cache` for `createEngineCache()` in services, repositories or server -load code. `aapp` composes only the active/client side. - -`Sium` is **not** a member of App. Validation is page-scoped — pages with -forms construct their own engine via the one-line `App.createSiumEngine()` -method that wires `App.Lang` and `App.Logger` automatically: - -```ts -const sium = App.createSiumEngine(); -const result = await sium.validate(LoginSchema, input); -``` - -Equivalent to `createEngineSium({ lang: App.Lang, logger: App.Logger, locale: App.Lang.getLocale() })`. -Each call returns a fresh engine. The method takes no arguments — Lang, -Logger and the active locale all flow from App, so there is nothing left to -override at this layer. - -The `locale` passed is a **snapshot** of `App.Lang.getLocale()` at the -moment of construction. It is the fallback used when the caller invokes -`sium.resolveIssue(issue)` without an explicit locale; the snapshot does -not react to subsequent `App.setLocale(...)` calls. Pages that need -locale-reactive issue messages either pass `App.Lang.getLocale()` per call -(`sium.resolveIssue(issue, App.Lang.getLocale())`) or rebuild the engine -inside an `$effect` that depends on the locale. If a page needs a custom -Sium engine (different logger category, different locale default, etc.) it -constructs `createEngineSium(...)` directly from `$sium`. - -The method lives on App rather than as a standalone helper because App is -already in scope on every page via context — `App.createSiumEngine()` is -the natural call site. - -`Connections` is also lazy, but for the opposite reason: realtime is -application-scoped infrastructure, while the connection map is app-specific and -benefits from call-site generics: - -```ts -const Connections = App.createActiveConnections(); -``` - -Each registry is disposed by `App.dispose()`. Individual connections decide -whether they react to app identity events via the registry's `autoReauthOn` -and the connection's own `session` option. - -## Event bus and orchestration - -`App.Bus` is always present. It is an `EngineBus` created -after `App.Timers`, with the shared `Logger` and `Timers.clock` injected: - -```ts -const App = createActiveApp({ - bus: { - listenerErrorMode: 'log-and-continue', - maxListenersPerEvent: 64 + logger: { level: LogLevel.INFO }, + services: { + lang: defineActiveLang({ schema: appLang, defaultLocale: 'es' }), + http: defineEngineHttp({ baseUrl: '/api' }), + cache: defineActiveCache(), + session: defineActiveSession({ + onRefresh, + onRevoke + }) } }); -App.Bus.on(APP_EVENT_USER_IDENTITY_CHANGED, (event) => { - console.log(event.payload.cause); -}); +applyStandardOrca(App); ``` -The bus does not make modules know each other. The model is: +## Two layers, three import paths -```txt -module event -> aapp translator -> app event -> consumer opt-in reaction -``` +`active-app` is layered to keep bundles small and the contract obvious. -Current identity flow: +| Layer | Path | Loaded when | +| --- | --- | --- | +| **Core** | `$active-app` | Always — every app needs `createActiveApp`. | +| **Service factories** | `$active-app/services` | The app declares any service in `services: { … }`. | +| **Orchestration presets** | `$active-app/presets` | The app opts into standard reactions or cherry-picks them. | -```txt -sess emits SESSION_EVENT_CHANGED -aapp's session translator publishes APP_EVENT_USER_IDENTITY_CHANGED -cach/perm/conn may react only when their own auto*On option opts in -``` +Each layer is a separate barrel. An app that builds only the core never pulls +service factories or presets into its bundle. -`orchestration` controls **translators only**: +## Core vs services -```ts -createActiveApp(); // same as orchestration: 'standard' -createActiveApp({ orchestration: 'silent' }); // no automatic app.* translation -createActiveApp({ orchestration: ['identity'] }); // only selected translators -``` +The composition has two layers: -Current translator contract: +- **Core** — `Logger`, `Bus`, `Timers`, `Orca`. Always built, never declared + as a service. Configurable via the `ActiveAppOptions` root. +- **Services** — opt-in pieces that the application declares in + `services: { … }`. If a service is not declared, it does not exist on + `App`, and TypeScript reports an error when consumers try to access it. -| Translator | Current source | Publishes | -| --- | --- | --- | -| `identity` | built-in: `SESSION_EVENT_CHANGED` from `App.createActiveSession(...)` | `APP_EVENT_USER_IDENTITY_CHANGED` | -| `dispose` | built-in: `App.dispose()` | `APP_EVENT_DISPOSE_STARTING` | -| `permissions-refresh` | typed public contract / explicit publish point | `APP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | -| `tenant-switched` | typed public contract / explicit publish point | `APP_EVENT_TENANT_SWITCHED` | -| `connectivity` | typed public contract / explicit publish point | `APP_EVENT_CONNECTIVITY_CHANGED` | +The legacy uppercase surface (`App.Lang`, `App.Cache`, `App.Format`, +`App.Frontend`, `App.Dom`, `App.Storage`, `App.Http`) has been removed. Those +pieces are now opt-in services. There is no migration period; the project did +not have external consumers when the cut happened. -It never decides destructive effects such as clearing cache, invalidating -permission decisions or reauthenticating sockets. Those live in the consumer: +## What the core provides ```ts -const App = createActiveApp({ - cache: { autoInvalidateOn: 'standard' }, - permissions: { - endpoint: '/api/permissions', - autoInvalidateOn: 'standard' - }, - connections: { autoReauthOn: 'standard' } -}); +interface ActiveAppCore { + readonly Logger: EngineLogger; + readonly Bus: EngineBus; + readonly Timers: ActiveTimers; + readonly Orca: EngineOrca; + dispose(): void; +} ``` -Defaults: - -| Layer | Default | -| --- | --- | -| `App.Bus` | always present | -| `orchestration` | `'standard'` translator set; currently identity and dispose have built-in sources | -| `cache.autoInvalidateOn` | none | -| `permissions.autoInvalidateOn` | none | -| `connections.autoReauthOn` | none | -| `orchestration: 'silent'` | disables translators, keeps the bus usable | - -`APP_EVENT_*` payloads are public and must not contain tokens, passwords, -authorization headers or sensitive hashes. If a consumer needs sensitive -context, it should resolve it from its own state or backend by correlation, -not from the event payload. - -## What it solves - -- **Single locale source.** `App.setLocale('es-MX')` propagates to `Lang`, - `Format` and `Frontend` through a shared `LocaleSource`. No bridge code per - call site. -- **Single logger.** Built once and piped into `Lang.setLogger` so every - artifact emits structured entries through the same transports (console, - Sentry, Datadog, ...). -- **Single event bus.** `App.Bus` carries app-level facts. App translates - module events to `app.*`; cache, permissions and connections only mutate - themselves when their own `auto*On` options opt in. -- **Uniform call sites.** `App.Lang.t(label)` and `App.Format.*` always work, - whether or not the caller configured i18n or fmts. No null checks. -- **Single lifecycle.** `App.dispose()` tears down the optional session, - bus, timers, persistence bridge, frontend, dom, formats, storage, lang and - logger in a deterministic order. - -## Composition order - -1. **Logger** — `createEngineLogger(options.logger)`. The engine applies its - own defaults when `options.logger` is undefined. -2. **Lang** — real `createActiveLang(...)` when `options.lang.schema` is - provided; mono otherwise. Both wire `Lang.setLogger` to the shared - Logger. -3. **Storage** — built next so Frontend can read persisted preferences - before construction. Storage diagnostics are wired to the shared Logger. -4. **Format** — built with a `localeSource` derived from Lang. -5. **Dom** — built before Frontend. -6. **Frontend** — receives Dom and the same `localeSource`. When - `frontend.persist` is configured, persisted values seed the initial - options and `onPreferenceChange` is wired to write back to Storage. -7. **Http** — built with the shared `Logger` injected automatically so - request/retry/error events land under category `'http'`. In SvelteKit - `load`, scope to the request via `App.Http.with({ fetch: event.fetch })`. -8. **Timers** — built with the shared `Logger`. This is the App-owned - scheduler used by long-lived runtime tasks; no module-global singleton. -9. **Bus** — built with the shared `Logger` and Timers clock. App uses it for - module-event translation and exposes it as `App.Bus`. -10. **Cache** — built with the shared `Logger` and `Bus`. It is always present with a - memory adapter unless `cache.adapter` is configured. Scopes remain explicit - through each query and can use `cache.scopeResolver`. `autoInvalidateOn` - controls reactions to public `app.*` events. -11. **Sess** — created lazily via `App.createActiveSession(...)`, not from - `createActiveApp(...)` options. App injects Logger and Bus, but the - consumer keeps auth policy explicit (`storage`, `onRefresh`, `onRevoke`, - HTTP hooks). -12. **Connections** — created lazily via `App.createActiveConnections(...)`. - App injects Logger, Timers and Bus; `autoReauthOn` controls app-event - reactions. -13. **Perms** — created lazily via `App.createActivePerms(...)`. - App injects Logger, Http and Bus; endpoint/defaults can be provided either - in `createActiveApp({ permissions })` or at the factory call site. -14. **Auth** — created lazily via `App.createActiveAuth(...)`. App injects - Http, Cache and Logger; `$svrs/auth` remains the server authority. - -`dispose()` runs in reverse order. - -## Common shapes - -### Full multilingual app +- `Logger` defaults to engine defaults (`level: WARN`, `consoleTransport()`). + Pass `{ level: NONE, transports: [] }` for silence. +- `Bus` and `Timers` are App-wide singletons. Services that need them + declare `'bus'` / `'timers'` in `coreDependencies`. +- `Orca` is always present, **inert until the application registers + actions**. Apps that don't use orchestration pay only for the engine's + empty maps. See [orca v0.0](../orca/README.md) for the supported + surface. +- `dispose()` publishes `APP_EVENT_DISPOSE_STARTING` first, then tears + every constructed service down in reverse order, then the core. -```ts -const App = createActiveApp({ - lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] }, - logger: { level: LogLevel.INFO, transports: [consoleTransport()] }, - frontend: { theme: 'base', mode: 'auto' } -}); -``` +## How services work -### Monolingual app with fixed currency +A service is anything an `AppServiceFactory` produces. Factories live in +`arts/active-app/service-factories/` and are exported from +`$active-app/services`. ```ts -const App = createActiveApp({ - formats: { currency: { currency: 'EUR' } }, - frontend: { theme: 'base' } -}); - -App.Format.currency.format(99.5); // "99,50 €" with default locale +interface AppServiceFactory { + readonly name: TName; + readonly coreDependencies: TCoreDeps; + readonly serviceDependencies?: TServiceDeps; + readonly initMode?: 'immediate' | 'lazy'; + create(deps: { core: …; services: … }): TInstance; + dispose?(instance: TInstance): void; +} ``` -`App.Lang` is mono — components calling `App.Lang.t('actions.save|Save')` -render `'Save'` without ever loading a translation table. - -### Headless / API surface +The schema is just an object literal: ```ts -const App = createActiveApp({ - logger: { level: LogLevel.WARN, transports: [httpTransport({ url })] } -}); +services: { + cache: defineActiveCache(), + session: defineActiveSession({ onRefresh, onRevoke }) +} ``` -Frontend and Dom are still constructed but their browser-only effects (viewport -tracking, attribute writes) no-op in SSR. +The builder validates the schema, computes a topological order, builds +`immediate` services right away, and exposes `lazy` ones behind getters +that materialise on first access. Construction order is dependency-first; +disposal runs in reverse. -## Locale flow +### Service init modes -Lang is the single source of truth. Format and Frontend subscribe to it via a -common `LocaleSource` (`$locale`). The locale value is BCP 47: - -```ts -App.setLocale('es'); // bare base -App.setLocale('es-MX'); // exact regional variant -App.setLocale('pt-BR'); // works end-to-end -``` - -See `$lang/README.md` for the BCP 47 resolution rules in `Lang.t()` / -`Lang.ts()`. - -When `lang` is not configured, `App.setLocale` still updates the mono lang's -internal locale and notifies Format/Frontend — locale switching keeps working. +| Mode | When the service is built | +| --- | --- | +| `lazy` (default) | First time `App.` is read. | +| `immediate` | During `createActiveApp()`, after the core is up. | -### SSR locale resolution + hydration +`immediate` is for services with construction-time side effects (subscribing +to `BroadcastChannel`, hydrating from storage on boot, etc.). Everything else +is `lazy`. -`createActiveApp(...)` does **not** read `navigator.language`. That is a -deliberate decision: reading the navigator on the client while the server -rendered with a different locale produces a hydration mismatch and a -one-frame text flash. Locale is the app's responsibility — resolve it on the -server, pass it as data to the client, and use it as `defaultLocale` when -constructing App. +### Service status -The canonical SvelteKit pattern: +Every declared service has an observable status: ```ts -// src/web/routes/+layout.server.ts -import type { LayoutServerLoad } from './$types'; - -const SUPPORTED = ['es', 'en', 'es-MX', 'es-AR', 'en-GB', 'pt-BR'] as const; -const DEFAULT = 'es'; - -function pickLocale(accept: string | null, supported: readonly string[]): string { - if (!accept) return DEFAULT; - const ranked = accept - .split(',') - .map((entry) => { - const [tag, q] = entry.trim().split(';q='); - return { tag: tag.toLowerCase(), q: q ? Number(q) : 1 }; - }) - .sort((a, b) => b.q - a.q); - for (const { tag } of ranked) { - // Exact BCP 47 match first, then base. - if (supported.includes(tag)) return tag; - const base = tag.split('-')[0]; - if (supported.includes(base)) return base; - } - return DEFAULT; -} +type ServiceStatus = 'absent' | 'present' | 'failed'; -export const load: LayoutServerLoad = ({ request, cookies }) => { - const cookie = cookies.get('locale'); - if (cookie && SUPPORTED.includes(cookie)) return { locale: cookie }; - const locale = pickLocale(request.headers.get('accept-language'), SUPPORTED); - return { locale }; -}; +App.services; // Readonly> ``` -```svelte - - - -{@render children()} -``` +If a factory's `create()` throws, the service status becomes `'failed'`. +Subsequent reads of `App.` re-throw the original error wrapped in +`AappServiceConstructionFailedError`. The first read sees the same wrapped +error — the wrapping is cheap and uniform. -Server and client agree on the locale on first render — no mismatch, no flash. +## Available services -To let the user change locale at runtime, persist the choice to a cookie so -the next request re-renders with the same value: +| Factory | Slot | Notes | +| --- | --- | --- | +| `defineActiveLang(options)` | `lang` | Schema is required. | +| `defineActiveStorage(options)` | `storage` | Memory adapter by default. | +| `defineActiveDom(props)` | `dom` | Inert on the server. | +| `defineActiveFormat(options)` | `format` | Wires to `lang` automatically when both are declared. | +| `defineActiveFrontend(options)` | `frontend` | Wires to `dom` and `lang` automatically. | +| `defineActiveCache(options)` | `cache` | Passive — invalidation is driven by orca presets. | +| `defineActiveSession(options)` | `session` | Publishes `SESSION_EVENT_*` on the bus. | +| `defineActivePerm(options)` | `perm` | Auto-invalidation is OFF; use orca preset. | +| `defineActiveAuth(options)` | `auth` | Requires an HTTP client in `options`. | +| `defineActiveConnections(options)` | `connections` | Identity tracking via orca preset. | +| `defineEngineHttp(options)` | `http` | Engine only — no Active wrapper. | +| `defineEngineSium(options)` | `sium` | Wires to `lang` automatically when declared. | + +## Orchestration + +`App.Orca` is always present and inert. Reactions are not pre-wired — apps +register them explicitly through orca presets in `arts/active-app/presets/`. ```ts -async function changeLocale(locale: SupportedLocale): Promise { - App.setLocale(locale); - document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`; -} +import { + applyCacheClearOnRevoke, + applyCacheClearOnIdentityChange, + applyPermInvalidateOnIdentityChange, + applyStandardOrca +} from '$active-app/presets'; + +// Cherry-pick: +applyCacheClearOnRevoke(App); +applyPermInvalidateOnIdentityChange(App); + +// Or all standard presets at once: +applyStandardOrca(App); ``` -If you genuinely want to honor `navigator.language` on first visit, do it -once in the server load when no cookie exists and no `Accept-Language` is -set — never on the client. - -## Storage - -`App.Storage` is always present. Without configuration it uses an in-memory -adapter — values exist for the lifetime of the App and never persist. For -real persistence, pass an adapter: +Each `apply*` returns a detach function for testing and hot-reload. -```ts -import { createActiveApp, localAdapter } from '$aapp'; - -const App = createActiveApp({ - storage: { adapter: localAdapter, namespace: 'my-app' } -}); +### Why presets live here, not inside arts -const cart = App.Storage.entry('cart', { items: [] as string[] }); -cart.update((p) => ({ ...p, items: [...p.items, 'sku-42'] })); -``` +An art (`arts/cache`, `arts/perm`, …) does not know about `arts/session` or +`arts/orca`. That knowledge belongs to the composition layer. Putting presets +in `arts/active-app/` keeps the inter-art dependency graph clean: every art +depends only on `libs/` and on the core (`logger`, `bus`, `timers`, `orca`), +never on a sibling art. -Per-entry overrides let you mix backends — cookies for SSR-readable values, -localStorage for the rest: +## Bus context bridge -```ts -import { createActiveApp, localAdapter, cookieAdapter } from '$aapp'; +For Svelte-side consumers that want to subscribe with `$effect`: -const App = createActiveApp({ - storage: { adapter: localAdapter, namespace: 'my-app' } -}); +```svelte + + ``` -Storage diagnostics are wired automatically through `StorageDiagnostics` and -the shared `App.Logger`; failures include `{ adapter, key, fullKey, op, error }` -in the diagnostic context. See `$stor/README.md` for the full API (adapters, -envelope, versioning, validation). - -### Reactive keys - -When the storage key tracks a runed variable (current user, active -workspace, route param), use `App.Storage.dynamicEntry()`: - ```svelte + ``` -Must run inside a Svelte component or `$effect.root` scope. +`getBus()` throws `AappBusNoContextError` if no bus is in scope. -### Cross-tab sync without polling +## Events -Wrap any adapter with `withBroadcast` (re-exported from `$aapp`) for -instant cross-tab synchronization through `BroadcastChannel`: +Only one event is owned by `arts/active-app`: ```ts -import { createActiveApp, localAdapter, cookieAdapter, withBroadcast } from '$aapp'; - -const App = createActiveApp({ - storage: { - adapter: withBroadcast(localAdapter, { channel: 'my-app' }), - namespace: 'my-app' - } -}); - -// Cookies + broadcast = changes propagate across tabs the moment they -// happen (browsers do not emit a native event for cookie mutations). -const session = withBroadcast(cookieAdapter({ path: '/', maxAge: 3600 }), { - channel: 'my-app:session' -}); +export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting'; ``` -### Persisting Frontend preferences +It fires once at the start of `App.dispose()`, before any service teardown, +so subscribers can flush, persist or detach while their dependencies still +exist. Everything else used to be a republication of module-level events; +those republications have been removed in favour of orca presets that listen +to the canonical events directly. -Theme, mode, density, dir, reducedMotion, reducedSound can be persisted with -a single flag: +`assertEventCanFire(type, where)` and `assertAppEventPayloadSafe(type, +payload)` are the safety nets used by typed publishers like +`publishAppDisposeStarting`. Both throw structured errors +(`AappInvalidEventRuntimeError`, `AappUnsafeEventPayloadError`) that +applications can catch. -```ts -const App = createActiveApp({ - storage: { adapter: localAdapter, namespace: 'my-app' }, - frontend: { theme: 'base', persist: true } -}); +## Errors -App.Frontend.setTheme('forest'); // → written to localStorage -// next reload → Frontend reads 'forest' from storage during construction -``` +| Error | When it fires | +| --- | --- | +| `AappServiceNameMismatchError` | Schema key !== `factory.name`. | +| `AappServiceDependencyCycleError` | A cycle is detected in `serviceDependencies`. | +| `AappServiceConstructionFailedError` | A factory's `create()` throws. | +| `AappBusNoContextError` | `getBus()` outside a tree that called `setBus()`. | +| `AappInvalidEventRuntimeError` | An `APP_EVENT_*` published in the wrong runtime. | +| `AappUnsafeEventPayloadError` | A sensitive key (`token`, `password`, `cookie`, …) is found in a payload. | -Selective + per-key overrides: +All of them extend `CodeError` from `$libs/errs` and have type guards +(`isAappServiceNameMismatchError`, …). -```ts -import { createActiveApp, localAdapter, cookieAdapter } from '$aapp'; +## Filesystem layout -const App = createActiveApp({ - storage: { adapter: localAdapter, namespace: 'my-app' }, - frontend: { - theme: 'base', - persist: { - keys: ['theme', 'density', 'mode'], - overrides: { - // theme to a cookie so the server can render the right palette - theme: { adapter: cookieAdapter({ path: '/' }), namespace: false, raw: true } - } - } - } -}); ``` - -When `persist` is set but no persistent adapter is configured (the default -in-memory adapter is in use), values still flow through Storage — they just -do not survive reload. No warning is emitted; the absence of persistence is -visible in the storage adapter the caller chose. - -## State primitive - -aapp does **not** include a stores system. Svelte 5 + runes already provide -the primitive: a `.svelte.ts` module with `$state` is your store, scoped to -import graph rather than to a global registry. - -```ts -// src/lib/stores/cart.svelte.ts -let items = $state([]); - -export const cart = { - get items() { - return items; - }, - add(item: CartItem) { - items.push(item); - }, - clear() { - items = []; - } -}; +src/arts/active-app/ +├── README.md ← this file +├── index.ts ← public entry point ($active-app) +├── consts.ts +├── errors.ts +├── events.ts ← APP_EVENT_DISPOSE_STARTING + safety helpers +├── services.ts ← AppServiceFactory contract +├── service-builder.ts ← topology, lazy proxies, dispose +├── active-app.svelte.ts ← createActiveApp() +├── bus-context.svelte.ts ← setBus / getBus +├── service-factories/ ← $active-app/services +│ ├── index.ts +│ ├── cache.ts +│ ├── lang.ts +│ ├── storage.ts +│ ├── dom.ts +│ ├── format.ts +│ ├── frontend.ts +│ ├── http.ts +│ ├── session.ts +│ ├── auth.ts +│ ├── perm.ts +│ ├── connections.ts +│ └── sium.ts +├── presets/ ← $active-app/presets +│ ├── index.ts +│ ├── cache-clear-on-revoke.ts +│ ├── cache-clear-on-identity-change.ts +│ ├── perm-invalidate-on-identity-change.ts +│ └── standard.ts +└── test/ + └── service-builder.test.ts ``` -Pages and components import `cart` directly. The "infrastructure" artifacts -(Logger, Lang, Format, Frontend, Dom) live in App because they are -cross-cutting and need uniform configuration. Domain state (current user, -cart, session, feature flags) is application-specific — putting it under -`App.Stores` would couple the framework to a bucket of unrelated nouns. - -For a Pinia/Zustand-style central registry, build it in user space — it does -not belong in `aapp`. - -## Testing - -Use `createTestApp(options)` instead of `createActiveApp(options)` in unit -tests. Same shape, plus: - -- silent logger by default (no console pollution) -- `captureLogs: true` attaches a sink and exposes entries as `App.entries` - -`createTestApp` lives at the `$aapp/testing` subpath so it does not ship with -production bundles that import the main `$aapp` barrel: - -```ts -import { createTestApp } from '$aapp/testing'; - -const App = createTestApp({ captureLogs: true, lang: { schema } }); +## Adding a new service -App.Logger.warn('auth', 'token expiring'); -expect(App.entries).toHaveLength(1); -expect(App.entries[0].category).toBe('auth'); - -App.dispose(); -``` +Three steps: -If the caller passes their own `logger.transports`, the capture transport is -appended — both sinks receive every entry. +1. **Build the art** as a normal `arts//` module. The art does not + know about `App` or `services`; it exposes a pure `createActive` + or `createEngine` factory. +2. **Write the `define*` factory** in + `arts/active-app/service-factories/.ts`. Declare which core deps + you read (`coreDependencies: ['logger', 'bus']`) and which sibling + services you optionally consume (`serviceDependencies: ['lang']`). + Export it from `service-factories/index.ts`. +3. **Optional — add presets** in `arts/active-app/presets/-…ts` + for any reactions the standard composition wants to ship. -## API +The service is then declarable from any application: ```ts -interface ActiveAppOptions { - logger?: LoggerOptions; - lang?: { schema: S; defaultLocale?: SupportedLocale; fallbackChain?: SupportedLocale[] }; - formats?: Omit; - frontend?: Omit & { - persist?: FrontendPersist; - }; - dom?: ActiveDomProps; - storage?: ActiveAppStorageOptions; - http?: Omit; - timers?: Omit; - bus?: Omit; - cache?: Omit; - connections?: Omit; - permissions?: Omit; - auth?: Omit; - orchestration?: 'standard' | 'silent' | false | readonly ActiveAppOrchestrationTranslator[]; -} - -interface ActiveApp { - readonly Logger: EngineLogger; - readonly Lang: ActiveLang; - readonly Format: ActiveFormat; - readonly Frontend: ActiveFrontend; - readonly Dom: ActiveDom; - readonly Storage: ActiveStorage; - readonly Http: EngineHttp; - readonly Timers: ActiveTimers; - readonly Bus: EngineBus; - readonly Cache: ActiveCache; - - getLocale(): SupportedLocale; - setLocale(locale: SupportedLocale): void; - onLocaleChange(fn: (locale: SupportedLocale) => void): () => void; - - createSiumEngine(): EngineSium; - createActiveSession( - options?: Omit, 'logger' | 'bus'> - ): ActiveSession; - createActiveConnections( - options?: Omit - ): ActiveConnections; - createActivePerms( - options?: Partial> - ): ActivePerms; - createActiveAuth(options?: Omit): ActiveAuth; - - readonly Sess: ActiveSession | undefined; - readonly Perms: ActivePerms | undefined; - readonly Auth: ActiveAuth | undefined; - - dispose(): void; +services: { + cart: defineActiveCart({ persistKey: 'cart' }) } ``` -## Why Sium is out - -Sium is a validation library that reaches into per-page data — login forms, -profile editors, signup wizards. Putting it in App would force every page -(including those without forms) to load the entire schema/types/issues machinery -just to use Lang or Format. Keeping Sium page-scoped means: +`App.cart` is now type-safe, lazy by default, and disposed in reverse order +when `App.dispose()` runs. -- Pages without validation pay nothing for it. -- Each form can use a sium engine tuned to its own needs (custom logger - category, validation context, etc.). -- App stays focused on the runtime contract every page needs. +## Test -The standard pattern is one line at the top of the page module: - -```ts -const sium = App.createSiumEngine(); +```bash +npx vitest run src/arts/active-app/test ``` + +The current suite covers the schema validation, topological ordering, +lazy/immediate construction, status reporting, dispose order, idempotence, +and core/service dependency injection. diff --git a/src/arts/active-app/active-app.svelte.ts b/src/arts/active-app/active-app.svelte.ts index 042c289..9e88fb1 100644 --- a/src/arts/active-app/active-app.svelte.ts +++ b/src/arts/active-app/active-app.svelte.ts @@ -1,92 +1,35 @@ -import { createActiveDom } from '$adom/active-dom.svelte'; +/** + * `createActiveApp()` — composed runtime root. + * + * Builds the four pieces of the core (Logger, Bus, Timers, Orca) and + * then defers everything else to the declarative service schema. The + * function itself is short on purpose — every art-specific knob has + * moved to its `defineActive*` / `defineEngine*` factory. + */ + +import type { EngineBus } from '$bus'; import { createSvelteEngineBus } from '$bus'; -import { createActiveCache } from '$cache/active-cache.svelte'; -import { createActiveFrontend } from '$frontend/active-frontend.svelte'; -import { createActiveFormat } from '$format/active-formats.svelte'; -import { createEngineHttp } from '$http/engine-http'; -import type { LangNode } from '$libs/lang'; -import type { ActiveLang } from '$lang'; -import { createActiveLang } from '$lang/active-lang.svelte'; -import { createActiveMonoLang } from '$lang/mono-lang.svelte'; import { createEngineLogger } from '$logger/engine-logger'; -import { createActiveStorage } from '$storage/active-storage.svelte'; -import { createActiveTimers } from '$timer/active-timers.svelte'; import { createEngineOrca } from '$orca'; -import { publishAppDisposeStarting } from './events.ts'; +import { createActiveTimers } from '$timer/active-timers.svelte'; -import { - applyFrontendPreferenceSnapshot, - bindFrontendStorage, - loadPersistedFrontendPreferences -} from './integrations/frontend-storage'; import { APP_MODULE } from './consts.ts'; +import { publishAppDisposeStarting } from './events.ts'; import { buildServiceBuilders } from './service-builder.ts'; import type { AppServiceSchema, CoreServices } from './services.ts'; import type { ActiveApp, ActiveAppBusEvents, - ActiveAppLegacy, + ActiveAppCore, ActiveAppOptions } from './types.ts'; -import type { EngineBus } from '$bus'; -/** - * Composed application surface. See `./types.ts` for the full contract. - */ -export function createActiveApp< - S extends LangNode = LangNode, - TSchema extends AppServiceSchema = AppServiceSchema ->(options: ActiveAppOptions = {}): ActiveApp { +export function createActiveApp( + options: ActiveAppOptions = {} +): ActiveApp { + // ── Core ──────────────────────────────────────────────────────────── const Logger = createEngineLogger(options.logger); - let Lang: ActiveLang; - if (options.lang) { - const real = createActiveLang( - options.lang.schema, - options.lang.defaultLocale, - options.lang.fallbackChain - ); - real.setLogger(Logger); - Lang = real; - } else { - Lang = createActiveMonoLang({ logger: Logger }) as unknown as ActiveLang; - } - - const Storage = createActiveStorage({ - adapter: options.storage?.adapter, - namespace: options.storage?.namespace, - logger: Logger - }); - - const localeSource = { - getLocale: () => Lang.getLocale(), - onLocaleChange: (fn: (locale: string) => void) => Lang.onLocaleChange(fn) - }; - - const Format = createActiveFormat({ - ...options.formats, - localeSource - }); - - const Dom = createActiveDom(options.dom); - - const { persist, ...frontendOptions } = options.frontend ?? {}; - const { snapshot, entries } = loadPersistedFrontendPreferences(Storage, persist, frontendOptions); - const persistedFrontendOptions = applyFrontendPreferenceSnapshot(frontendOptions, snapshot); - - const Frontend = createActiveFrontend({ - ...persistedFrontendOptions, - dom: Dom, - localeSource - }); - - const teardownPersistence = bindFrontendStorage(Frontend, entries); - - const Http = createEngineHttp({ - ...options.http, - logger: Logger - }); - const Timers = createActiveTimers({ ...options.timers, logger: Logger @@ -97,73 +40,50 @@ export function createActiveApp< logger: Logger, clock: Timers.clock }); + const Orca = createEngineOrca({ + ...options.orca, bus: Bus, timers: Timers, logger: Logger }); - const coreForServices: CoreServices = { - logger: Logger, - // `EngineBus` is structurally a richer bus; - // service factories accept the generic `EngineBus`. - bus: Bus as unknown as EngineBus, - timers: Timers, - orca: Orca - }; + // ── Services ──────────────────────────────────────────────────────── + const core = coreForBuilder(Logger, Bus, Timers, Orca); const serviceBuilders = options.services - ? buildServiceBuilders(options.services as AppServiceSchema, coreForServices) + ? buildServiceBuilders(options.services as AppServiceSchema, core) : undefined; - const Cache = createActiveCache({ - ...options.cache, - logger: Logger - }); - let disposed = false; - const baseApp: ActiveAppLegacy = { + const baseApp: ActiveAppCore = { Logger, - Lang, - Format, - Frontend, - Dom, - Storage, - Http, - Timers, Bus, + Timers, Orca, - Cache, - - getLocale: () => Lang.getLocale(), - setLocale: (locale) => Lang.setLocale(locale), - onLocaleChange: (fn) => Lang.onLocaleChange(fn), dispose() { if (disposed) return; disposed = true; + // Announce dispose BEFORE tearing anything down so subscribers + // can still reach the bus and any service they depend on. publishAppDisposeStarting(Bus, { cause: APP_MODULE }); // Schema-declared services first (reverse construction order // is handled by the builder). serviceBuilders?.disposeAll(); - Cache.dispose(); + // Core last, in reverse build order. Orca.dispose(); Bus.dispose(); Timers.dispose(); - teardownPersistence(); - Frontend.dispose(); - Dom.dispose(); - Format.dispose(); - Storage.dispose(); - Lang.dispose(); Logger.dispose(); } }; - // Compose the final App: legacy base + schema services + status + // Compose the final App: core + schema services + status // introspection. Services are exposed as own properties via // `Object.defineProperty` so lazy getters are preserved. - const app = baseApp as ActiveApp; + const app = baseApp as ActiveApp; + if (serviceBuilders !== undefined) { for (const name of Object.keys(serviceBuilders.proxies)) { Object.defineProperty(app, name, { @@ -191,3 +111,32 @@ export function createActiveApp< return app; } + +/** + * Adapt the App-level core to the generic `CoreServices` contract that + * service factories see. + * + * The downcast on `bus` is safe because: + * - `EngineBus` is structurally a more precise + * instance of `EngineBus`. + * - Services that need to publish App-owned events do so through the + * dedicated typed publishers (`publishAppDisposeStarting`, …), not + * via raw `bus.publish(type, payload)` against an arbitrary string. + * + * Keeping this in a named helper means the rationale stays attached to + * the cast instead of trailing as an inline comment that future edits + * might lose. + */ +function coreForBuilder( + logger: ActiveAppCore['Logger'], + bus: ActiveAppCore['Bus'], + timers: ActiveAppCore['Timers'], + orca: ActiveAppCore['Orca'] +): CoreServices { + return { + logger, + bus: bus as unknown as EngineBus, + timers, + orca + }; +} diff --git a/src/arts/active-app/consts.ts b/src/arts/active-app/consts.ts index acba70e..238c131 100644 --- a/src/arts/active-app/consts.ts +++ b/src/arts/active-app/consts.ts @@ -8,6 +8,12 @@ export const APP_MODULE = 'app'; export const APP_BUS_CONTEXT_KEY = 'arts.app.bus'; +/** + * Substrings that identify a sensitive payload key. Any property whose + * lowercased name contains one of these is rejected by + * `assertAppEventPayloadSafe()`. The list is intentionally + * conservative — App-level events are facts, not transports for secrets. + */ export const APP_EVENT_SENSITIVE_KEY_PARTS = [ 'authorization', 'cookie', diff --git a/src/arts/active-app/errors.ts b/src/arts/active-app/errors.ts index 1f1d7ad..6725a79 100644 --- a/src/arts/active-app/errors.ts +++ b/src/arts/active-app/errors.ts @@ -1,3 +1,24 @@ +/** + * Errors for `arts/active-app`. + * + * Two classes of errors live here: + * + * - **Schema validation** — thrown synchronously during + * `createActiveApp()` when the service schema is malformed + * (`AappServiceNameMismatchError`, + * `AappServiceDependencyCycleError`, + * `AappServiceConstructionFailedError`). + * - **Bus / events** — thrown by the bus-context bridge and the + * safe-publish helpers when callers misuse the App's bus + * (`AappBusNoContextError`, `AappInvalidEventRuntimeError`, + * `AappUnsafeEventPayloadError`). + * + * Note: the legacy `AappAlreadyCreatedError` is gone. The declarative + * service schema makes "factory called twice" structurally impossible — + * a duplicate service key is a JavaScript object-literal error, not a + * runtime concern. + */ + import { CodeError, errCode, @@ -11,7 +32,7 @@ import { APP_MODULE } from './consts.ts'; // ── Error codes ──────────────────────────────────────────────────────── export const APP_ERR: ModuleSeed = moduleSeed(APP_MODULE); -export const APP_ERR_ALREADY_CREATED: ErrCode = errCode(APP_ERR, 'already_created'); + export const APP_ERR_SERVICE_NAME_MISMATCH: ErrCode = errCode(APP_ERR, 'service_name_mismatch'); export const APP_ERR_SERVICE_DEPENDENCY_CYCLE: ErrCode = errCode( APP_ERR, @@ -29,17 +50,16 @@ export const APP_ERR_EVENT_UNSAFE_PAYLOAD: ErrCode = errCode(APP_ERR_EVENT, 'uns // ── Error message builders ───────────────────────────────────────────── -export const APP_ERROR_MSG_ALREADY_CREATED = 'App factory called more than once.'; export const APP_ERROR_MSG_BUS_NO_CONTEXT = `[${APP_MODULE}] no bus in context — call setBus(App.Bus) in a layout before getBus()`; export const appServiceNameMismatchMessage = (key: string, factoryName: string): string => - `[active-app] service factory name "${factoryName}" must match schema key "${key}"`; + `[${APP_MODULE}] service factory name "${factoryName}" must match schema key "${key}"`; export const appServiceDependencyCycleMessage = (cycle: readonly string[]): string => - `[active-app] dependency cycle detected: ${cycle.join(' -> ')}`; + `[${APP_MODULE}] dependency cycle detected: ${cycle.join(' -> ')}`; export const appServiceConstructionFailedMessage = (name: string): string => - `[active-app] service "${name}" failed to construct`; + `[${APP_MODULE}] service "${name}" failed to construct`; export const appInvalidEventRuntimeMessage = ( type: string, @@ -54,7 +74,6 @@ export const appUnsafeEventPayloadMessage = (type: string, path: string): string // ── Error messages ───────────────────────────────────────────────────── export const APP_ERROR_MESSAGES: ErrorMessages = { - [APP_ERR_ALREADY_CREATED]: APP_ERROR_MSG_ALREADY_CREATED, [APP_ERR_SERVICE_NAME_MISMATCH]: appServiceNameMismatchMessage, [APP_ERR_SERVICE_DEPENDENCY_CYCLE]: appServiceDependencyCycleMessage, [APP_ERR_SERVICE_CONSTRUCTION_FAILED]: appServiceConstructionFailedMessage, @@ -65,12 +84,6 @@ export const APP_ERROR_MESSAGES: ErrorMessages = { // ── Error classes ────────────────────────────────────────────────────── -export class AappAlreadyCreatedError extends CodeError { - constructor(message: string) { - super(APP_ERR_ALREADY_CREATED, { message }); - } -} - export class AappServiceNameMismatchError extends CodeError { readonly key: string; readonly factoryName: string; @@ -138,10 +151,6 @@ export class AappUnsafeEventPayloadError extends CodeError { // ── Type guards ──────────────────────────────────────────────────────── -export function isAappAlreadyCreatedError(error: unknown): error is AappAlreadyCreatedError { - return error instanceof AappAlreadyCreatedError; -} - export function isAappServiceNameMismatchError( error: unknown ): error is AappServiceNameMismatchError { diff --git a/src/arts/active-app/events.ts b/src/arts/active-app/events.ts index 7e4f360..f4370ae 100644 --- a/src/arts/active-app/events.ts +++ b/src/arts/active-app/events.ts @@ -1,3 +1,15 @@ +/** + * App-owned events. + * + * The only event whose owner is `arts/active-app` itself is + * `APP_EVENT_DISPOSE_STARTING`. Everything else used to be a + * republication of module-level events (session, connection, etc.) — + * those republications have been removed. Apps that need to react to + * facts owned by other modules subscribe to those modules' events + * directly via `App.Bus.on(SESSION_EVENT_*, …)` or, more commonly, + * register an orca action via a preset. + */ + import type { BusPublishOptions, EventPublisher, EventSubscriber } from '$libs/bus'; import { APP_EVENT_RUNTIME_BOTH, @@ -7,9 +19,9 @@ import { import { AappInvalidEventRuntimeError, AappUnsafeEventPayloadError } from './errors.ts'; /** - * The single App-owned event that survives the orca-based migration. - * Apps that need to react to teardown subscribe via `App.Bus.on(...)` or - * register an orca action. + * The single App-owned event. Fires once at the start of `App.dispose()` + * before any service teardown begins, so subscribers can flush, persist + * or detach before their dependencies disappear. */ export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting'; @@ -77,10 +89,7 @@ export interface AppEventMap { } /** - * Read-only contract for App's event bus. App publishes only - * `DISPOSE_STARTING` directly; everything else flows through the bus by - * other modules (session, connection, etc.) and is orchestrated via - * `App.Orca`. + * Read-only contract for App's event bus from the consumer side. */ export type AppEventBus = EventSubscriber; diff --git a/src/arts/active-app/index.ts b/src/arts/active-app/index.ts index a15d159..cfecffe 100644 --- a/src/arts/active-app/index.ts +++ b/src/arts/active-app/index.ts @@ -1,26 +1,59 @@ -export { createActiveApp } from './active-app.svelte'; -export { APP_MODULE } from './consts'; +/** + * Public entry point of `arts/active-app`. + * + * Three import paths exist: + * + * - `$active-app` — `createActiveApp`, types, errors, bus-context + * bridge. What every app needs. + * - `$active-app/services` — `defineActive*` / `defineEngine*` + * factories for the declarative service schema. Loaded only by + * apps that declare services. + * - `$active-app/presets` — orchestration presets registered on + * `App.Orca`. Loaded only by apps that opt into the standard + * reactions. + * + * Splitting the entry points lets the bundler tree-shake each layer + * independently. An app that only consumes the core never pulls in + * service factories or presets. + */ + +export { createActiveApp } from './active-app.svelte.ts'; +export { setBus, getBus } from './bus-context.svelte.ts'; + +export { APP_MODULE, APP_BUS_CONTEXT_KEY } from './consts.ts'; + export { - AappAlreadyCreatedError, + APP_EVENT_DISPOSE_STARTING, + publishAppDisposeStarting, + onAppDisposeStarting, + type AppDisposeStartingPayload, + type AppEventBus, + type AppEventMap, + type AppEventRuntime +} from './events.ts'; + +export { + AappBusNoContextError, + AappInvalidEventRuntimeError, AappServiceConstructionFailedError, AappServiceDependencyCycleError, AappServiceNameMismatchError, - isAappAlreadyCreatedError, + AappUnsafeEventPayloadError, + isAappBusNoContextError, + isAappInvalidEventRuntimeError, isAappServiceConstructionFailedError, isAappServiceDependencyCycleError, - isAappServiceNameMismatchError -} from './errors'; + isAappServiceNameMismatchError, + isAappUnsafeEventPayloadError +} from './errors.ts'; + export type { ActiveApp, ActiveAppBusEvents, - ActiveAppLegacy, + ActiveAppCore, ActiveAppOptions, - ActiveAppServicesIntrospection, - ActiveAppStorageOptions, - FrontendPersist, - FrontendPersistKey, - FrontendPersistKeyOverride -} from './types'; + ActiveAppServicesIntrospection +} from './types.ts'; export type { AppServiceFactory, @@ -30,40 +63,7 @@ export type { ResolveServiceInstances, ServiceInitMode, ServiceStatus -} from './services'; +} from './services.ts'; -export { buildServiceBuilders } from './service-builder'; - -// Service factories — declarative `services: { … }` schema entries. -export { - defineActiveAuth, - defineActiveCache, - defineActiveConnections, - defineActiveDom, - defineActiveFormat, - defineActiveFrontend, - defineActiveLang, - defineActivePerm, - defineActiveSession, - defineActiveStorage, - defineEngineHttp, - defineEngineSium -} from './service-factories'; - -// Orchestration presets — opt-in reactions registered on `App.Orca`. -export { - applyCacheClearOnIdentityChange, - applyCacheClearOnRevoke, - applyPermInvalidateOnIdentityChange, - applyStandardOrca -} from './presets'; - -// Common storage adapters re-exported for ergonomic single import. -// `createMemoryAdapter` lives in `$storage` only — testing-specific. -export { localAdapter, sessionAdapter, cookieAdapter, withBroadcast } from '$storage'; -export type { - CookieAdapterOptions, - ServerCookiesLike, - BroadcastAdapter, - BroadcastOptions -} from '$storage'; +export { buildServiceBuilders } from './service-builder.ts'; +export type { ServiceBuilders } from './service-builder.ts'; diff --git a/src/arts/active-app/integrations/frontend-storage.ts b/src/arts/active-app/integrations/frontend-storage.ts deleted file mode 100644 index f3df4f8..0000000 --- a/src/arts/active-app/integrations/frontend-storage.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - FRONTEND_PREFERENCE_KEYS, - applyFrontendPreferenceSnapshot, - readFrontendPreference, - resolveFrontendPreferenceDefault, - type ActiveFrontend, - type ActiveFrontendOptions, - type FrontendPreferenceKey, - type FrontendPreferenceSnapshot, - type FrontendPreferenceValue -} from '$frontend'; -import type { ActiveStorage, ActiveStorageEntry, SyncStorageAdapter } from '$storage'; - -import type { FrontendPersist, FrontendPersistKeyOverride } from '../types'; - -interface ResolvedFrontendPersist { - keys: ReadonlyArray; - adapter?: SyncStorageAdapter; - namespace?: string | false; - overrides: Partial>; -} - -function resolveFrontendPersist( - persist: FrontendPersist | undefined -): ResolvedFrontendPersist | undefined { - if (!persist) return undefined; - if (persist === true) return { keys: FRONTEND_PREFERENCE_KEYS, overrides: {} }; - return { - keys: persist.keys ?? FRONTEND_PREFERENCE_KEYS, - adapter: persist.adapter, - namespace: persist.namespace, - overrides: persist.overrides ?? {} - }; -} - -export type FrontendStorageEntries = Map>; - -/** - * Read persisted Frontend preferences before `ActiveFrontend` is constructed. - * This keeps first paint aligned with storage (not a post-hydration patch). - */ -export function loadPersistedFrontendPreferences( - storage: ActiveStorage, - persist: FrontendPersist | undefined, - defaults: Pick< - ActiveFrontendOptions, - 'theme' | 'mode' | 'density' | 'dir' | 'reducedMotion' | 'reducedSound' - > -): { snapshot: FrontendPreferenceSnapshot; entries: FrontendStorageEntries } { - const entries: FrontendStorageEntries = new Map(); - const snapshot: FrontendPreferenceSnapshot = {}; - const resolved = resolveFrontendPersist(persist); - if (!resolved) return { snapshot, entries }; - - const enabled = new Set(resolved.keys); - - function makeEntry( - key: K - ): ActiveStorageEntry> { - const override = resolved!.overrides[key]; - return storage.entry(key, resolveFrontendPreferenceDefault(key, defaults), { - adapter: override?.adapter ?? resolved!.adapter, - namespace: override?.namespace ?? resolved!.namespace, - raw: override?.raw === true ? true : undefined - }); - } - - for (const key of FRONTEND_PREFERENCE_KEYS) { - if (!enabled.has(key)) continue; - const entry = makeEntry(key); - entries.set(key, entry as ActiveStorageEntry); - if (entry.has()) { - (snapshot as Record)[key] = entry.current; - } - } - - return { snapshot, entries }; -} - -export { applyFrontendPreferenceSnapshot }; - -/** - * Wire Frontend preference changes to the storage entries created during - * construction. `fend` owns the preference semantics; this file only bridges - * them to `storage`. - */ -export function bindFrontendStorage( - frontend: ActiveFrontend, - entries: FrontendStorageEntries -): () => void { - if (entries.size === 0) return () => {}; - - const detach = frontend.onPreferenceChange(() => { - for (const [key, entry] of entries) { - entry.set(readFrontendPreference(frontend, key)); - } - }); - - return () => { - detach(); - for (const entry of entries.values()) entry.dispose(); - entries.clear(); - }; -} diff --git a/src/arts/active-app/presets/cache-clear-on-identity-change.ts b/src/arts/active-app/presets/cache-clear-on-identity-change.ts index dcd00e2..825d05e 100644 --- a/src/arts/active-app/presets/cache-clear-on-identity-change.ts +++ b/src/arts/active-app/presets/cache-clear-on-identity-change.ts @@ -1,13 +1,12 @@ import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca'; -import type { EngineOrca } from '$orca'; import { SESSION_EVENT_IDENTITY_CHANGED } from '$session'; import type { ActiveCache } from '$cache/types'; +import type { ActiveAppCore } from '../types.ts'; const ACTION_ID = 'cache.clear-on-identity-change'; const TOKEN_CLEARED = 'cache:cleared-on-identity'; -interface AppShape { - readonly Orca: EngineOrca; +export interface CacheClearOnIdentityChangeApp extends ActiveAppCore { readonly cache: Pick; } @@ -15,15 +14,16 @@ interface AppShape { * Registers an orca action that clears the active cache when the * session's actor identity changes. * - * Replaces the legacy `wireAutoInvalidation` subscription inside + * Replaces the legacy auto-invalidation that used to live inside * `arts/cache`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` (the - * canonical event from `arts/session`), not the deprecated - * `APP_EVENT_USER_IDENTITY_CHANGED` re-publication. + * canonical event from `arts/session`). * * Returns a detach function. Calling it unregisters the action; the * engine then stops reacting to the event. */ -export function applyCacheClearOnIdentityChange(App: AppShape): () => void { +export function applyCacheClearOnIdentityChange( + App: CacheClearOnIdentityChangeApp +): () => void { return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, diff --git a/src/arts/active-app/presets/cache-clear-on-revoke.ts b/src/arts/active-app/presets/cache-clear-on-revoke.ts index 1fafbbb..b4f025c 100644 --- a/src/arts/active-app/presets/cache-clear-on-revoke.ts +++ b/src/arts/active-app/presets/cache-clear-on-revoke.ts @@ -1,13 +1,19 @@ import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca'; -import type { EngineOrca } from '$orca'; import { SESSION_EVENT_REVOKED } from '$session'; import type { ActiveCache } from '$cache/types'; +import type { ActiveAppCore } from '../types.ts'; const ACTION_ID = 'cache.clear-on-revoke'; const TOKEN_CLEARED = 'cache:cleared-on-revoke'; -interface AppShape { - readonly Orca: EngineOrca; +/** + * Shape this preset requires from `App`. We only need `Orca` from the + * core and a `cache` service that exposes `clear()`. Defining the + * dependency this narrowly makes the preset usable from any App that + * declares a compatible cache, regardless of what other services it + * has. + */ +export interface CacheClearOnRevokeApp extends ActiveAppCore { readonly cache: Pick; } @@ -16,8 +22,10 @@ interface AppShape { * session is revoked. Pairs with `applyCacheClearOnIdentityChange` for * apps where revoke is independent of identity change (e.g. logout * without login of another actor). + * + * Returns a detach function. Calling it unregisters the action. */ -export function applyCacheClearOnRevoke(App: AppShape): () => void { +export function applyCacheClearOnRevoke(App: CacheClearOnRevokeApp): () => void { return App.Orca.onEvent(SESSION_EVENT_REVOKED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, diff --git a/src/arts/active-app/presets/index.ts b/src/arts/active-app/presets/index.ts index 00df1d2..5da1e5a 100644 --- a/src/arts/active-app/presets/index.ts +++ b/src/arts/active-app/presets/index.ts @@ -16,7 +16,16 @@ * registers every preset whose required services are declared in `App`. */ -export { applyCacheClearOnIdentityChange } from './cache-clear-on-identity-change.ts'; -export { applyCacheClearOnRevoke } from './cache-clear-on-revoke.ts'; -export { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change.ts'; -export { applyStandardOrca } from './standard.ts'; +export { + applyCacheClearOnIdentityChange, + type CacheClearOnIdentityChangeApp +} from './cache-clear-on-identity-change.ts'; +export { + applyCacheClearOnRevoke, + type CacheClearOnRevokeApp +} from './cache-clear-on-revoke.ts'; +export { + applyPermInvalidateOnIdentityChange, + type PermInvalidateOnIdentityChangeApp +} from './perm-invalidate-on-identity-change.ts'; +export { applyStandardOrca, type StandardOrcaApp } from './standard.ts'; diff --git a/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts b/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts index 5c4ed88..59f80cd 100644 --- a/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts +++ b/src/arts/active-app/presets/perm-invalidate-on-identity-change.ts @@ -1,13 +1,12 @@ import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca'; -import type { EngineOrca } from '$orca'; import { SESSION_EVENT_IDENTITY_CHANGED } from '$session'; import type { ActivePerms } from '$perm/types'; +import type { ActiveAppCore } from '../types.ts'; const ACTION_ID = 'perm.invalidate-on-identity-change'; const TOKEN_INVALIDATED = 'perm:invalidated-on-identity'; -interface AppShape { - readonly Orca: EngineOrca; +export interface PermInvalidateOnIdentityChangeApp extends ActiveAppCore { readonly perm: Pick; } @@ -15,10 +14,12 @@ interface AppShape { * Registers an orca action that invalidates the local permissions cache * when the session's actor identity changes. * - * Replaces the legacy `wireAutoInvalidation` subscription inside + * Replaces the legacy auto-invalidation that used to live inside * `arts/perm`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` directly. */ -export function applyPermInvalidateOnIdentityChange(App: AppShape): () => void { +export function applyPermInvalidateOnIdentityChange( + App: PermInvalidateOnIdentityChangeApp +): () => void { return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, { id: ACTION_ID, stage: ORCA_STAGE_MAIN, diff --git a/src/arts/active-app/presets/standard.ts b/src/arts/active-app/presets/standard.ts index fd73b49..f818bcd 100644 --- a/src/arts/active-app/presets/standard.ts +++ b/src/arts/active-app/presets/standard.ts @@ -1,33 +1,41 @@ -import type { EngineOrca } from '$orca'; import type { ActiveCache } from '$cache/types'; import type { ActivePerms } from '$perm/types'; +import type { ActiveAppCore } from '../types.ts'; import { applyCacheClearOnIdentityChange } from './cache-clear-on-identity-change.ts'; import { applyCacheClearOnRevoke } from './cache-clear-on-revoke.ts'; import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change.ts'; -interface AppShape { - readonly Orca: EngineOrca; +/** + * Optional shape passed to `applyStandardOrca`. Whatever services the + * application declares are picked up automatically; missing services + * are skipped. The shape is intentionally permissive — `Partial<>` + * pieces — so an App that only declares `cache` (without `perm`) gets + * cache-related presets and nothing else. + */ +export interface StandardOrcaApp extends ActiveAppCore { readonly cache?: Pick; readonly perm?: Pick; } /** - * Convenience aggregator: registers every standard preset whose required - * services are declared on `App`. Apps that want a tailored set of - * reactions can cherry-pick individual `apply*` functions instead. + * Registers every standard preset whose required services are declared + * on `App`. Apps that want a tailored set of reactions can cherry-pick + * individual `apply*` functions instead. * * Returns a single detach function that unregisters everything in * reverse order — convenient for tests and hot reloading. */ -export function applyStandardOrca(App: AppShape): () => void { +export function applyStandardOrca(App: StandardOrcaApp): () => void { const detachers: Array<() => void> = []; if (App.cache !== undefined) { - detachers.push(applyCacheClearOnIdentityChange(App as AppShape & { cache: NonNullable })); - detachers.push(applyCacheClearOnRevoke(App as AppShape & { cache: NonNullable })); + const cacheApp = App as StandardOrcaApp & { cache: NonNullable }; + detachers.push(applyCacheClearOnIdentityChange(cacheApp)); + detachers.push(applyCacheClearOnRevoke(cacheApp)); } if (App.perm !== undefined) { - detachers.push(applyPermInvalidateOnIdentityChange(App as AppShape & { perm: NonNullable })); + const permApp = App as StandardOrcaApp & { perm: NonNullable }; + detachers.push(applyPermInvalidateOnIdentityChange(permApp)); } return () => { diff --git a/src/arts/active-app/service-builder.ts b/src/arts/active-app/service-builder.ts index fac050d..204a355 100644 --- a/src/arts/active-app/service-builder.ts +++ b/src/arts/active-app/service-builder.ts @@ -1,6 +1,7 @@ /** * Runtime that turns an `AppServiceSchema` into a set of getters on the - * `App` object plus a `dispose()` that tears them down in reverse order. + * `App` object plus a `disposeAll()` that tears them down in reverse + * order. * * Responsibilities: * - Validate the schema (key === factory.name). @@ -11,8 +12,8 @@ * `App.` triggers `lazy` construction the first time. * - Track per-service `ServiceStatus` for introspection. * - Dispose services in reverse construction order; errors during - * dispose are swallowed (to mirror the engine convention used in - * timer / cache / etc.). + * dispose are swallowed (to mirror the engine convention used + * elsewhere in the ecosystem). * * Errors during construction propagate to the caller. The status of the * failing service is `'failed'` and stays that way; subsequent reads @@ -20,6 +21,7 @@ * `AappServiceConstructionFailedError`. */ +import { untrack } from 'svelte'; import { AappServiceConstructionFailedError, AappServiceDependencyCycleError, @@ -62,7 +64,6 @@ export function buildServiceBuilders( // Records construction sequence so dispose can run in reverse. const constructionLog: string[] = []; - // Initialize status for every declared service. for (const name of order) status.set(name, 'absent'); // Build immediate services in topological order, before exposing the @@ -96,10 +97,18 @@ export function buildServiceBuilders( } try { - const instance = factory.create({ - core: coreSubset, - services: serviceSubset - }); + // Lazy services may be constructed inside a `$derived` or + // template expression. If the factory subscribes to a $state- + // backed listener set during `create()` (e.g. format wiring + // `lang.onLocaleChange`), Svelte rejects the mutation. Run + // construction inside `untrack` so subscriptions wired here + // do not propagate into the outer reactive scope. + const instance = untrack(() => + factory.create({ + core: coreSubset, + services: serviceSubset + }) + ); instances.set(name, instance); status.set(name, 'present'); constructionLog.push(name); diff --git a/src/arts/active-app/service-factories/auth.ts b/src/arts/active-app/service-factories/auth.ts index f012330..75f6a20 100644 --- a/src/arts/active-app/service-factories/auth.ts +++ b/src/arts/active-app/service-factories/auth.ts @@ -4,12 +4,14 @@ import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveAuth(options)` produces a service factory for the `auth` - * slot. `auth` requires an `http` client supplied through options; it - * gets `logger` from the core when not overridden. + * slot. * - * Auto-orchestration with `cache` (invalidating cache on revoke etc.) is - * NOT wired here — that lives in `arts/active-app/presets/` once the - * orca-based migration ships in step 3 of the active-app refactor. + * Auth requires an `http` client supplied through `options` (the auth + * client only reflects server decisions — it cannot run without a + * backend). It receives `logger` from the core. + * + * Auto-orchestration with `cache` (invalidating cache on revoke etc.) + * is NOT wired here — that lives in `arts/active-app/presets/`. */ export function defineActiveAuth( options: Omit diff --git a/src/arts/active-app/service-factories/cache.ts b/src/arts/active-app/service-factories/cache.ts index f75d62b..d48d427 100644 --- a/src/arts/active-app/service-factories/cache.ts +++ b/src/arts/active-app/service-factories/cache.ts @@ -3,19 +3,13 @@ import type { ActiveCache, ActiveCacheOptions } from '$cache/types'; import type { AppServiceFactory } from '../services.ts'; /** - * `defineActiveCache(options)` produces a service factory for the `cache` - * slot. + * `defineActiveCache(options)` produces a service factory for the + * `cache` slot. * - * **Auto-invalidation is OFF by default** when registered via the schema. - * The legacy `autoInvalidateOn` and `bus` options on `ActiveCacheOptions` - * are still honored if the application explicitly passes them, but the - * recommended path is to omit them and use an orca preset - * (`applyCacheInvalidateOnIdentityChange` etc.) to react to events. - * - * The art still has `bus.on(APP_EVENT_*)` subscriptions internally - * gated by `autoInvalidateOn`. Step 3 of the active-app refactor will - * remove those entirely; until then, leaving the option unset keeps - * the behavior clean. + * The cache art is a passive runtime: invalidation is driven from the + * outside via orca presets (e.g. `applyCacheClearOnIdentityChange` in + * `arts/active-app/presets/`). The factory itself only wires `logger` + * from the core; everything else is opt-in through `options`. */ export function defineActiveCache( options: Omit = {} diff --git a/src/arts/active-app/service-factories/connections.ts b/src/arts/active-app/service-factories/connections.ts index 8558a0d..81950bd 100644 --- a/src/arts/active-app/service-factories/connections.ts +++ b/src/arts/active-app/service-factories/connections.ts @@ -1,14 +1,18 @@ import { createActiveConnections } from '$connection/active-connections.svelte'; -import type { ActiveConnections, ActiveConnectionsOptions } from '$connection/types'; +import type { + ActiveConnections, + ActiveConnectionsOptions +} from '$connection/types'; import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveConnections(options)` produces a service factory for the * `connections` slot. * - * Requires `timers` from the core. Identity tracking (formerly via - * `bus-session-source`) becomes an orca preset in step 3 — until then - * applications can pass `session` in options manually. + * Requires `logger` and `timers` from the core. Identity tracking + * (formerly via the `bus-session-source` shim) is now expected to come + * from an orca preset, or from the application passing `session` in + * options manually. */ export function defineActiveConnections( options: Omit = {} diff --git a/src/arts/active-app/service-factories/dom.ts b/src/arts/active-app/service-factories/dom.ts index d2dfb9d..f04cf62 100644 --- a/src/arts/active-app/service-factories/dom.ts +++ b/src/arts/active-app/service-factories/dom.ts @@ -16,6 +16,9 @@ export function defineActiveDom( initMode: 'lazy', create(): ActiveDom { return createActiveDom(props); + }, + dispose(instance) { + instance.dispose(); } }; } diff --git a/src/arts/active-app/service-factories/format.ts b/src/arts/active-app/service-factories/format.ts index 7d997fb..6548b15 100644 --- a/src/arts/active-app/service-factories/format.ts +++ b/src/arts/active-app/service-factories/format.ts @@ -1,21 +1,45 @@ import { createActiveFormat } from '$format/active-formats.svelte'; import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte'; +import type { ActiveLang } from '$lang'; +import type { LocaleSource } from '$locale'; import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveFormat(options)` produces a service factory for the - * `format` slot. Format runs entirely from its `LocaleSource` (provided - * in options); no core dependencies are required. + * `format` slot. + * + * Format runs entirely from a `LocaleSource`. If the application + * declares `lang` in its schema, this factory wires Format to the + * `ActiveLang` instance automatically. If not, the application must + * pass its own `localeSource` via `options`, otherwise Format falls + * back to its default locale. */ export function defineActiveFormat( options: ActiveFormatOptions = {} -): AppServiceFactory<'format', readonly [], readonly [], ActiveFormat> { +): AppServiceFactory<'format', readonly [], readonly ['lang'], ActiveFormat> { return { name: 'format', coreDependencies: [], + serviceDependencies: ['lang'], initMode: 'lazy', - create(): ActiveFormat { - return createActiveFormat(options); + create({ services }): ActiveFormat { + const langInstance = services.lang as ActiveLang | undefined; + const localeSource: LocaleSource | undefined = + options.localeSource ?? + (langInstance + ? { + getLocale: () => langInstance.getLocale(), + onLocaleChange: (fn) => langInstance.onLocaleChange(fn) + } + : undefined); + + return createActiveFormat({ + ...options, + localeSource + }); + }, + dispose(instance) { + instance.dispose(); } }; } diff --git a/src/arts/active-app/service-factories/frontend.ts b/src/arts/active-app/service-factories/frontend.ts index 7fad419..b3db9e2 100644 --- a/src/arts/active-app/service-factories/frontend.ts +++ b/src/arts/active-app/service-factories/frontend.ts @@ -1,18 +1,25 @@ import { createActiveFrontend } from '$frontend/active-frontend.svelte'; import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte'; +import type { ActiveDom } from '$adom'; +import type { ActiveLang } from '$lang'; +import type { LocaleSource } from '$locale'; import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveFrontend(options)` produces a service factory for the * `frontend` slot. * - * Frontend can integrate with `dom` and `lang` if those services are - * declared in the schema, but does not require them — `serviceDeps` are - * declared so the builder can pass them through if present. + * Frontend integrates with `dom` and `lang` automatically when those + * services are declared in the schema. Explicit `dom` / `localeSource` + * passed in `options` take precedence — that is the escape hatch for + * tests and non-standard wiring. * - * The application can still pass `localeSource` and `dom` directly in - * `options` to override the schema-resolved values; that is the intended - * escape hatch for tests and ad-hoc setups. + * Note: `frontend` previously consumed `App.Storage` to persist user + * preferences (theme/mode/density). That persistence layer used to + * live in `arts/active-app/integrations/frontend-storage.ts`. After the + * refactor, persistence is the application's concern: it can be + * implemented as an orca preset, as an integration helper, or built + * into a custom Frontend wrapper. The factory itself stays slim. */ export function defineActiveFrontend( options: ActiveFrontendOptions = {} @@ -22,12 +29,23 @@ export function defineActiveFrontend( coreDependencies: [], serviceDependencies: ['dom', 'lang'], initMode: 'lazy', - create(): ActiveFrontend { - // service-deps are not threaded automatically yet — applications - // pass `dom` and `localeSource` explicitly through options if they - // need them. The schema declaration ensures topology orders - // `dom`/`lang` before `frontend` if both are present. - return createActiveFrontend(options); + create({ services }): ActiveFrontend { + const dom = options.dom ?? (services.dom as ActiveDom | undefined); + const langInstance = services.lang as ActiveLang | undefined; + const localeSource: LocaleSource | undefined = + options.localeSource ?? + (langInstance + ? { + getLocale: () => langInstance.getLocale(), + onLocaleChange: (fn) => langInstance.onLocaleChange(fn) + } + : undefined); + + return createActiveFrontend({ + ...options, + dom, + localeSource + }); }, dispose(instance) { instance.dispose(); diff --git a/src/arts/active-app/service-factories/http.ts b/src/arts/active-app/service-factories/http.ts index f3a50fc..90a9024 100644 --- a/src/arts/active-app/service-factories/http.ts +++ b/src/arts/active-app/service-factories/http.ts @@ -6,16 +6,21 @@ import type { AppServiceFactory } from '../services.ts'; * `defineEngineHttp(options)` produces a service factory for the `http` * slot. `arts/http` is engine-only (no Active wrapper); reactive * consumers wrap responses themselves at the call site. + * + * Logger is wired from the core for diagnostics consistency. */ export function defineEngineHttp( - options: EngineHttpOptions = {} -): AppServiceFactory<'http', readonly [], readonly [], EngineHttp> { + options: Omit = {} +): AppServiceFactory<'http', readonly ['logger'], readonly [], EngineHttp> { return { name: 'http', - coreDependencies: [], + coreDependencies: ['logger'], initMode: 'lazy', - create(): EngineHttp { - return createEngineHttp(options); + create({ core }): EngineHttp { + return createEngineHttp({ + ...options, + logger: core.logger + }); } }; } diff --git a/src/arts/active-app/service-factories/lang.ts b/src/arts/active-app/service-factories/lang.ts index dab8533..6430c96 100644 --- a/src/arts/active-app/service-factories/lang.ts +++ b/src/arts/active-app/service-factories/lang.ts @@ -18,20 +18,27 @@ export interface DefineActiveLangOptions { * `defineActiveLang({ schema, defaultLocale?, fallbackChain? })` produces * a service factory for the `lang` slot. The schema generic flows * through to `App.lang` so `t('a.b.c')` keeps end-to-end type safety. + * + * Lang receives no core deps directly — it manages its own logger + * internally via `setLogger(core.logger)` if needed. To keep the schema + * contract clean, we leave that wiring to the application or to a + * later helper. */ export function defineActiveLang( options: DefineActiveLangOptions -): AppServiceFactory<'lang', readonly [], readonly [], ActiveLang> { +): AppServiceFactory<'lang', readonly ['logger'], readonly [], ActiveLang> { return { name: 'lang', - coreDependencies: [], + coreDependencies: ['logger'], initMode: 'lazy', - create(): ActiveLang { - return createActiveLang( + create({ core }): ActiveLang { + const lang = createActiveLang( options.schema, options.defaultLocale ?? 'es', options.fallbackChain ? [...options.fallbackChain] : undefined ); + lang.setLogger(core.logger); + return lang; }, dispose(instance) { instance.dispose(); diff --git a/src/arts/active-app/service-factories/perm.ts b/src/arts/active-app/service-factories/perm.ts index 0864364..cc6b4f2 100644 --- a/src/arts/active-app/service-factories/perm.ts +++ b/src/arts/active-app/service-factories/perm.ts @@ -6,9 +6,9 @@ import type { AppServiceFactory } from '../services.ts'; * `defineActivePerm(options)` produces a service factory for the `perm` * slot. * - * **Auto-invalidation is OFF by default** when registered via the schema - * — same reasoning as `defineActiveCache`. Use orca presets - * (`applyPermInvalidateOnIdentityChange`) to react to events. + * Auto-invalidation is OFF by default — same reasoning as + * `defineActiveCache`. Use the orca preset + * `applyPermInvalidateOnIdentityChange` to react to identity changes. * * The factory still requires the application to provide `endpoint` (via * the underlying `ActivePermsOptions`); the perm client cannot work diff --git a/src/arts/active-app/service-factories/session.ts b/src/arts/active-app/service-factories/session.ts index 7b39b17..1a0322b 100644 --- a/src/arts/active-app/service-factories/session.ts +++ b/src/arts/active-app/service-factories/session.ts @@ -6,20 +6,12 @@ import type { AppServiceFactory } from '../services.ts'; * `defineActiveSession(options)` produces a * service factory for the `session` slot. * - * The session art publishes its own `SESSION_EVENT_LIFECYCLE_*` events - * via `options.bus`. The factory wires `core.bus` into the session - * automatically so consumers (and orca presets) can subscribe to those - * events without extra configuration. - * - * Application-level `APP_EVENT_USER_IDENTITY_CHANGED` re-publishing - * (formerly handled by `wireSessionTranslator`) moves to an orca preset - * in step 3. + * Session publishes its own `SESSION_EVENT_*` events on `core.bus`. + * Orca presets in `arts/active-app/presets/` translate those into + * reactions across other services without `arts/session` having to know + * who listens. */ -export function defineActiveSession< - TUser, - TCredential = undefined, - TData = undefined ->( +export function defineActiveSession( options: Omit, 'logger' | 'bus'> ): AppServiceFactory< 'session', diff --git a/src/arts/active-app/service-factories/sium.ts b/src/arts/active-app/service-factories/sium.ts index d908cd7..3b063e7 100644 --- a/src/arts/active-app/service-factories/sium.ts +++ b/src/arts/active-app/service-factories/sium.ts @@ -1,24 +1,37 @@ -import { createEngineSium, type EngineSium, type EngineSiumOptions } from '$sium/engine-sium'; +import { + createEngineSium, + type EngineSium, + type EngineSiumOptions +} from '$sium/engine-sium'; +import type { EngineLang } from '$lang'; import type { AppServiceFactory } from '../services.ts'; /** * `defineEngineSium(options)` produces a service factory for the `sium` - * slot. The engine takes `logger` from the core when not supplied - * explicitly in `options`; it can also use a `lang` engine if the - * application provides one. + * slot. + * + * Sium is a validation engine. It takes `logger` from the core and, if + * the application declares `lang` in its schema, wires it through so + * issue messages can be translated. Without `lang`, Sium falls back to + * English literals. */ export function defineEngineSium( - options: Omit = {} + options: Omit = {} ): AppServiceFactory<'sium', readonly ['logger'], readonly ['lang'], EngineSium> { return { name: 'sium', coreDependencies: ['logger'], serviceDependencies: ['lang'], initMode: 'lazy', - create({ core }) { + create({ core, services }): EngineSium { + // `ActiveLang` extends `EngineLang` structurally but TS sees the + // `t()` signatures as distinct (optional vs required params), so + // cross via `unknown` to keep the contract loose at the boundary. + const lang = services.lang as unknown as EngineLang | undefined; return createEngineSium({ ...options, - logger: core.logger + logger: core.logger, + lang }); } }; diff --git a/src/arts/active-app/service-factories/storage.ts b/src/arts/active-app/service-factories/storage.ts index ea886d6..5643f35 100644 --- a/src/arts/active-app/service-factories/storage.ts +++ b/src/arts/active-app/service-factories/storage.ts @@ -1,23 +1,26 @@ import { createActiveStorage } from '$storage/active-storage.svelte'; -import type { ActiveStorage } from '$storage/types'; -import type { EngineStorageOptions } from '$storage/types'; +import type { ActiveStorage, EngineStorageOptions } from '$storage/types'; import type { AppServiceFactory } from '../services.ts'; /** * `defineActiveStorage(options)` produces a service factory for the - * `storage` slot of an `ActiveApp`. The art is built directly from the - * supplied options; the core (logger, bus, timers, orca) is not used by - * `arts/storage` today. + * `storage` slot. The art is built directly from the supplied options; + * `arts/storage` reads `logger` only when explicitly given, and we + * inject it here from the core for consistency with the rest of the + * ecosystem. */ export function defineActiveStorage( - options: EngineStorageOptions = {} -): AppServiceFactory<'storage', readonly [], readonly [], ActiveStorage> { + options: Omit = {} +): AppServiceFactory<'storage', readonly ['logger'], readonly [], ActiveStorage> { return { name: 'storage', - coreDependencies: [], + coreDependencies: ['logger'], initMode: 'lazy', - create(): ActiveStorage { - return createActiveStorage(options); + create({ core }): ActiveStorage { + return createActiveStorage({ + ...options, + logger: core.logger + }); }, dispose(instance) { instance.dispose(); diff --git a/src/arts/active-app/services.ts b/src/arts/active-app/services.ts index bc15302..6c13355 100644 --- a/src/arts/active-app/services.ts +++ b/src/arts/active-app/services.ts @@ -3,9 +3,9 @@ * * `aapp` is built on top of two layers: * - * - **Core** — fixed runtime infrastructure that always exists. Today - * that is `logger`, `bus`, `timers` and `orca`. Configurable via the - * options object root, never declared as a service. + * - **Core** — fixed runtime infrastructure that always exists: + * `logger`, `bus`, `timers` and `orca`. Configurable via the + * `ActiveAppOptions` root, never declared as a service. * * - **Services** — opt-in runtime pieces that the application declares * in `services: { … }`. If a service is not declared, it does not @@ -14,8 +14,8 @@ * * Each service is built from an `AppServiceFactory` produced by a * `defineActive*` / `defineEngine*` helper that lives in - * `arts/active-app/service-factories/`. Arts themselves stay pure — they - * do not know they are wired into a service. + * `arts/active-app/service-factories/`. The arts themselves stay pure — + * they do not know they are wired into a service. */ import type { EngineBus } from '$bus'; @@ -100,6 +100,11 @@ export interface AppServiceFactory< /** * A schema is a record `{ [name]: AppServiceFactory }`. The key MUST equal * `factory.name`; the builder validates this at construction time. + * + * Note on `any`: the wider `unknown` does not preserve the structural + * assignability we need for the factory-shape pattern. Concrete factories + * (returned by `defineActive*`) keep precise types; this widened alias is + * only used at the schema-of-schemas boundary. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AppServiceSchema = Record>; diff --git a/src/arts/active-app/test/active-app.test.ts b/src/arts/active-app/test/active-app.test.ts deleted file mode 100644 index 70205d6..0000000 --- a/src/arts/active-app/test/active-app.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * createActiveApp() composition tests — focuses on the core (Logger, - * Lang, Format, Frontend, Dom, Storage, Http, Timers, Bus, Orca, Cache). - * Service-schema integration is covered by `schema-declarative.test.ts`, - * orca presets by `presets.test.ts`, and the service builder by - * `service-builder.test.ts`. - */ - -import { describe, expect, it } from 'vitest'; -import { createActiveApp } from '../active-app.svelte'; -import { LogLevel, type LogEntry } from '$logger'; -import type { LangNode } from '$lang'; -import { LANG_MONO_LANG_CATEGORY } from '$lang/mono-lang.svelte'; - -const schema = { - greeting: { es: 'Hola', en: 'Hello', 'es-MX': 'Qué onda' }, - cart: { es: 'Carrito', en: 'Cart' } -} satisfies LangNode; - -const SILENT_LOGGER = { level: LogLevel.NONE, transports: [] }; - -describe('createActiveApp — core composition', () => { - it('exposes the always-on core surface', () => { - const App = createActiveApp({ logger: SILENT_LOGGER }); - - expect(App.Logger).toBeDefined(); - expect(App.Lang).toBeDefined(); - expect(App.Format).toBeDefined(); - expect(App.Frontend).toBeDefined(); - expect(App.Dom).toBeDefined(); - expect(App.Storage).toBeDefined(); - expect(App.Http).toBeDefined(); - expect(App.Timers).toBeDefined(); - expect(App.Bus).toBeDefined(); - expect(App.Orca).toBeDefined(); - expect(App.Cache).toBeDefined(); - expect(typeof App.dispose).toBe('function'); - expect(typeof App.getLocale).toBe('function'); - - App.dispose(); - }); - - it('builds with zero options', () => { - const App = createActiveApp(); - expect(App.Logger).toBeDefined(); - App.dispose(); - }); - - it('routes setLocale through Lang as the single source of truth', () => { - const App = createActiveApp({ - logger: SILENT_LOGGER, - lang: { schema, defaultLocale: 'es' } - }); - - expect(App.getLocale()).toBe('es'); - App.setLocale('en'); - expect(App.getLocale()).toBe('en'); - expect(App.Lang.getLocale()).toBe('en'); - - App.dispose(); - }); - - it('honors BCP 47 resolution through Lang.t()', () => { - const App = createActiveApp({ - logger: SILENT_LOGGER, - lang: { schema, defaultLocale: 'es-MX' } - }); - - // es-MX falls back to es when no exact match, but greeting has both - expect(App.Lang.t('greeting', undefined, 'es-MX')).toBe('Qué onda'); - // cart has only es / en, so es-MX falls back to es - expect(App.Lang.t('cart', undefined, 'es-MX')).toBe('Carrito'); - - App.dispose(); - }); - - it('mono Lang returns paths verbatim when no schema is configured', () => { - const App = createActiveApp({ logger: SILENT_LOGGER }); - // Mono lang returns the path key when no entry exists. - expect(App.Lang.t('any.path')).toBe('any.path'); - App.dispose(); - }); - - it('mono Lang warns once per unresolved path via the shared Logger', () => { - const entries: LogEntry[] = []; - const App = createActiveApp({ - logger: { - level: LogLevel.WARN, - transports: [ - { - name: 'capture', - write(entry) { - entries.push(entry); - } - } - ] - } - }); - - App.Lang.t('missing.path'); - App.Lang.t('missing.path'); - App.Lang.t('another.path'); - - const warnings = entries.filter((e) => e.category === LANG_MONO_LANG_CATEGORY); - expect(warnings).toHaveLength(2); - - App.dispose(); - }); - - it('mono Lang does not warn when |fallback is provided', () => { - const entries: LogEntry[] = []; - const App = createActiveApp({ - logger: { - level: LogLevel.WARN, - transports: [ - { - name: 'capture', - write(entry) { - entries.push(entry); - } - } - ] - } - }); - - App.Lang.t('with.fallback|the value'); - - const warnings = entries.filter((e) => e.category === LANG_MONO_LANG_CATEGORY); - expect(warnings).toHaveLength(0); - - App.dispose(); - }); - - it('Format receives the mono locale via localeSource', () => { - const App = createActiveApp({ logger: SILENT_LOGGER }); - // Default mono locale is the engine default (en-US) - expect(typeof App.Format).toBe('object'); - App.dispose(); - }); - - it('onLocaleChange fires when setLocale changes the value', () => { - const App = createActiveApp({ - logger: SILENT_LOGGER, - lang: { schema, defaultLocale: 'es' } - }); - - const changes: string[] = []; - const detach = App.onLocaleChange((locale) => changes.push(locale)); - - App.setLocale('en'); - App.setLocale('en'); // duplicate, should not fire - App.setLocale('es-MX'); - - expect(changes).toEqual(['en', 'es-MX']); - detach(); - App.setLocale('en'); - expect(changes).toEqual(['en', 'es-MX']); - - App.dispose(); - }); -}); - -describe('createActiveApp — dispose', () => { - it('is idempotent', () => { - const App = createActiveApp({ logger: SILENT_LOGGER }); - App.dispose(); - expect(() => App.dispose()).not.toThrow(); - }); - - it('disposes Orca alongside the core', () => { - const App = createActiveApp({ logger: SILENT_LOGGER }); - const orca = App.Orca; - expect(orca.disposed).toBe(false); - App.dispose(); - expect(orca.disposed).toBe(true); - }); - -}); diff --git a/src/arts/active-app/test/schema-declarative.test.ts b/src/arts/active-app/test/schema-declarative.test.ts index c93babb..39c9219 100644 --- a/src/arts/active-app/test/schema-declarative.test.ts +++ b/src/arts/active-app/test/schema-declarative.test.ts @@ -51,7 +51,7 @@ describe('createActiveApp — declarative service schema', () => { App.dispose(); }); - it('coexists with the legacy uppercase core (App.Cache, App.Bus, etc.)', () => { + it('exposes only the four core members alongside declared services', () => { const App = createActiveApp({ logger: SILENT_LOGGER, services: { @@ -59,12 +59,17 @@ describe('createActiveApp — declarative service schema', () => { } }); - // legacy: App.Cache always present - expect(App.Cache).toBeDefined(); - // declarative: App.cache is the schema-declared instance + // Core: always present. + expect(App.Logger).toBeDefined(); + expect(App.Bus).toBeDefined(); + expect(App.Timers).toBeDefined(); + expect(App.Orca).toBeDefined(); + + // Schema-declared service exposed as lowercase property. expect(App.cache).toBeDefined(); - // they are independent ActiveCache instances - expect(App.cache).not.toBe(App.Cache); + + // Legacy uppercase aliases are gone — `Cache` is no longer a core. + expect((App as Record).Cache).toBeUndefined(); App.dispose(); }); diff --git a/src/arts/active-app/test/service-builder.test.ts b/src/arts/active-app/test/service-builder.test.ts index 49110d6..5e4e796 100644 --- a/src/arts/active-app/test/service-builder.test.ts +++ b/src/arts/active-app/test/service-builder.test.ts @@ -1,384 +1,411 @@ +/** + * Unit tests for `service-builder.ts`. The builder is the load-bearing + * piece of `arts/active-app/`; if it has bugs, every service composed + * through it breaks. + * + * These tests use a synthetic `CoreServices` mock — they don't pull in + * real `arts/logger`, `arts/bus`, etc. The contract under test is + * purely: + * - schema validation + * - topological order with cycle detection + * - lazy / immediate construction + * - dispose order + * - status reporting + * - failure handling + */ + import { describe, expect, it, vi } from 'vitest'; -import { buildServiceBuilders } from '../service-builder.ts'; import { AappServiceConstructionFailedError, AappServiceDependencyCycleError, AappServiceNameMismatchError } from '../errors.ts'; +import { _internalsForTesting, buildServiceBuilders } from '../service-builder.ts'; import type { AppServiceFactory, AppServiceSchema, CoreServices } from '../services.ts'; -import type { EngineBus } from '$bus'; -import type { EngineLogger } from '$logger'; -import type { EngineOrca } from '$orca'; -import type { ActiveTimers } from '$timer'; -// ── Stubs of the core ────────────────────────────────────────────────── +// ── Test harness ─────────────────────────────────────────────────────── -const stubCore: CoreServices = { - logger: {} as EngineLogger, - bus: {} as EngineBus, - timers: {} as ActiveTimers, - orca: {} as EngineOrca -}; +function mockCore(): CoreServices { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + logger: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bus: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + timers: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + orca: {} as any + }; +} -// ── Helpers to build factory stubs ───────────────────────────────────── +interface FakeInstance { + name: string; + disposed: boolean; +} -function stubFactory( +function fakeFactory( name: TName, - value: TInstance, - options: { - coreDependencies?: readonly (keyof CoreServices)[]; - serviceDependencies?: readonly string[]; - initMode?: 'immediate' | 'lazy'; - dispose?: (instance: TInstance) => void; - } = {} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -): AppServiceFactory { + overrides: Partial> = {} +): AppServiceFactory { return { name, - coreDependencies: options.coreDependencies ?? [], - serviceDependencies: options.serviceDependencies, - initMode: options.initMode, + coreDependencies: [], + initMode: 'lazy', create() { - return value; + return { name, disposed: false }; }, - dispose: options.dispose + dispose(instance) { + instance.disposed = true; + }, + ...overrides }; } -// ── Tests ────────────────────────────────────────────────────────────── +// ── Schema validation ───────────────────────────────────────────────── describe('buildServiceBuilders — schema validation', () => { - it('throws AappServiceNameMismatchError when key does not match factory.name', () => { - const schema: AppServiceSchema = { - notCache: stubFactory('cache', { id: 1 }) - }; - expect(() => buildServiceBuilders(schema, stubCore)).toThrow(AappServiceNameMismatchError); + it('throws AappServiceNameMismatchError when key !== factory.name', () => { + const schema = { + cache: fakeFactory('not-cache') + } as unknown as AppServiceSchema; + + expect(() => buildServiceBuilders(schema, mockCore())).toThrow( + AappServiceNameMismatchError + ); }); - it('accepts an empty schema', () => { - const builders = buildServiceBuilders({}, stubCore); - expect(builders.statusMap()).toEqual({}); - expect(Object.keys(builders.proxies)).toEqual([]); + it('accepts a valid schema with matching keys', () => { + const schema = { + cache: fakeFactory('cache') + } as unknown as AppServiceSchema; + + expect(() => buildServiceBuilders(schema, mockCore())).not.toThrow(); }); }); -describe('buildServiceBuilders — topology', () => { - it('builds independent services in declared order', () => { - const schema: AppServiceSchema = { - a: stubFactory('a', 'instance-a'), - b: stubFactory('b', 'instance-b') - }; - const builders = buildServiceBuilders(schema, stubCore); - // Lazy: nothing built yet - expect(builders.statusMap()).toEqual({ a: 'absent', b: 'absent' }); - }); +// ── Topological order ───────────────────────────────────────────────── - it('builds dependency before dependent', () => { - const calls: string[] = []; - const schema: AppServiceSchema = { - leaf: { - name: 'leaf', - coreDependencies: [], - create() { - calls.push('leaf'); - return 'leaf-instance'; - } - }, - top: { - name: 'top', - coreDependencies: [], - serviceDependencies: ['leaf'], - initMode: 'immediate', - create() { - calls.push('top'); - return 'top-instance'; - } - } - }; - buildServiceBuilders(schema, stubCore); - expect(calls).toEqual(['leaf', 'top']); +describe('buildServiceBuilders — topological order', () => { + it('orders dependencies before dependents', () => { + const schema = { + a: fakeFactory('a'), + b: fakeFactory('b', { serviceDependencies: ['a'] }), + c: fakeFactory('c', { serviceDependencies: ['b'] }) + } as unknown as AppServiceSchema; + + const order = _internalsForTesting.topologicalOrder(schema); + expect(order.indexOf('a')).toBeLessThan(order.indexOf('b')); + expect(order.indexOf('b')).toBeLessThan(order.indexOf('c')); }); - it('throws AappServiceDependencyCycleError on cycle', () => { - const schema: AppServiceSchema = { - a: { - name: 'a', - coreDependencies: [], - serviceDependencies: ['b'], - create: () => 'a' - }, - b: { - name: 'b', - coreDependencies: [], - serviceDependencies: ['a'], - create: () => 'b' - } - }; - expect(() => buildServiceBuilders(schema, stubCore)).toThrow(AappServiceDependencyCycleError); + it('detects direct cycles and reports the cycle path', () => { + const schema = { + a: fakeFactory('a', { serviceDependencies: ['b'] }), + b: fakeFactory('b', { serviceDependencies: ['a'] }) + } as unknown as AppServiceSchema; + + expect(() => _internalsForTesting.topologicalOrder(schema)).toThrow( + AappServiceDependencyCycleError + ); }); - it('captures the cycle path in the error', () => { - const schema: AppServiceSchema = { - a: { - name: 'a', - coreDependencies: [], - serviceDependencies: ['b'], - create: () => 'a' - }, - b: { - name: 'b', - coreDependencies: [], - serviceDependencies: ['c'], - create: () => 'b' - }, - c: { - name: 'c', - coreDependencies: [], - serviceDependencies: ['a'], - create: () => 'c' - } - }; - try { - buildServiceBuilders(schema, stubCore); - expect.fail('expected throw'); - } catch (error) { - expect(error).toBeInstanceOf(AappServiceDependencyCycleError); - expect((error as AappServiceDependencyCycleError).cycle).toEqual(['a', 'b', 'c', 'a']); - } + it('detects indirect cycles', () => { + const schema = { + a: fakeFactory('a', { serviceDependencies: ['b'] }), + b: fakeFactory('b', { serviceDependencies: ['c'] }), + c: fakeFactory('c', { serviceDependencies: ['a'] }) + } as unknown as AppServiceSchema; + + expect(() => _internalsForTesting.topologicalOrder(schema)).toThrow( + AappServiceDependencyCycleError + ); }); - it('ignores dependencies that are not declared in the schema', () => { - const calls: string[] = []; - const schema: AppServiceSchema = { - service: { - name: 'service', - coreDependencies: [], - serviceDependencies: ['absent-dep'], - initMode: 'immediate', - create() { - calls.push('service'); - return 'instance'; - } - } - }; - buildServiceBuilders(schema, stubCore); - expect(calls).toEqual(['service']); + it('ignores missing dependencies (slot stays undefined at create-time)', () => { + const schema = { + a: fakeFactory('a', { serviceDependencies: ['missing'] }) + } as unknown as AppServiceSchema; + + expect(() => _internalsForTesting.topologicalOrder(schema)).not.toThrow(); }); }); -describe('buildServiceBuilders — initialization modes', () => { - it('immediate services build during buildServiceBuilders()', () => { - const create = vi.fn(() => 'instance'); - const schema: AppServiceSchema = { - eager: { - name: 'eager', - coreDependencies: [], - initMode: 'immediate', - create - } - }; - const builders = buildServiceBuilders(schema, stubCore); - expect(create).toHaveBeenCalledTimes(1); - expect(builders.statusMap()).toEqual({ eager: 'present' }); - }); +// ── Lazy construction ───────────────────────────────────────────────── - it('lazy services build on first access', () => { - const create = vi.fn(() => 'instance'); - const schema: AppServiceSchema = { - lazy: { - name: 'lazy', - coreDependencies: [], - create - } - }; - const builders = buildServiceBuilders(schema, stubCore); +describe('buildServiceBuilders — lazy construction', () => { + it('does not call create() until the proxy is read', () => { + const create = vi.fn(() => ({ name: 'cache', disposed: false })); + const schema = { + cache: fakeFactory('cache', { create }) + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); expect(create).not.toHaveBeenCalled(); - expect(builders.statusMap()).toEqual({ lazy: 'absent' }); - const value = (builders.proxies as { lazy: string }).lazy; - expect(value).toBe('instance'); + const value = builders.proxies.cache; expect(create).toHaveBeenCalledTimes(1); - expect(builders.statusMap()).toEqual({ lazy: 'present' }); + expect(value).toEqual({ name: 'cache', disposed: false }); }); - it('repeated access returns the same instance', () => { - const create = vi.fn(() => ({ id: Math.random() })); - const schema: AppServiceSchema = { - lazy: { - name: 'lazy', - coreDependencies: [], - create - } - }; - const builders = buildServiceBuilders(schema, stubCore); - const proxies = builders.proxies as { lazy: object }; - const a = proxies.lazy; - const b = proxies.lazy; - expect(a).toBe(b); + it('caches the constructed instance across repeated reads', () => { + const create = vi.fn(() => ({ name: 'cache', disposed: false })); + const schema = { + cache: fakeFactory('cache', { create }) + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + const a = builders.proxies.cache; + const b = builders.proxies.cache; + expect(create).toHaveBeenCalledTimes(1); + expect(a).toBe(b); }); }); -describe('buildServiceBuilders — core dependencies', () => { - it('passes only the declared subset of core', () => { - const seen: { core: object; services: object } | null = { core: {}, services: {} }; - const schema: AppServiceSchema = { - s: { - name: 's', - coreDependencies: ['logger', 'bus'], - create({ core, services }) { - seen.core = core; - seen.services = services; - return 'instance'; - } - } - }; - const builders = buildServiceBuilders(schema, stubCore); - void (builders.proxies as { s: string }).s; - expect(Object.keys(seen.core).sort()).toEqual(['bus', 'logger']); +// ── Immediate construction ──────────────────────────────────────────── + +describe('buildServiceBuilders — immediate construction', () => { + it('builds immediate services during buildServiceBuilders()', () => { + const create = vi.fn(() => ({ name: 'session', disposed: false })); + const schema = { + session: fakeFactory('session', { initMode: 'immediate', create }) + } as unknown as AppServiceSchema; + + buildServiceBuilders(schema, mockCore()); + expect(create).toHaveBeenCalledTimes(1); }); - it('passes resolved service dependencies', () => { - let dependentServices: Record = {}; - const schema: AppServiceSchema = { - leaf: { - name: 'leaf', - coreDependencies: [], - create: () => 'leaf-instance' - }, - top: { - name: 'top', - coreDependencies: [], - serviceDependencies: ['leaf'], - create({ services }) { - dependentServices = services; - return 'top-instance'; + it('builds immediate services in topological order', () => { + const log: string[] = []; + const schema = { + a: fakeFactory('a', { + initMode: 'immediate', + create() { + log.push('a'); + return { name: 'a', disposed: false }; } - } - }; - const builders = buildServiceBuilders(schema, stubCore); - void (builders.proxies as { top: string }).top; - expect(dependentServices).toEqual({ leaf: 'leaf-instance' }); + }), + b: fakeFactory('b', { + initMode: 'immediate', + serviceDependencies: ['a'], + create() { + log.push('b'); + return { name: 'b', disposed: false }; + } + }) + } as unknown as AppServiceSchema; + + buildServiceBuilders(schema, mockCore()); + expect(log).toEqual(['a', 'b']); }); }); -describe('buildServiceBuilders — failure handling', () => { - it('marks a service as failed when create() throws', () => { - const schema: AppServiceSchema = { - broken: { - name: 'broken', - coreDependencies: [], +// ── Status reporting ────────────────────────────────────────────────── + +describe('buildServiceBuilders — status', () => { + it('reports absent before construction, present after', () => { + const schema = { + cache: fakeFactory('cache') + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + expect(builders.statusMap()).toEqual({ cache: 'absent' }); + + void builders.proxies.cache; + expect(builders.statusMap()).toEqual({ cache: 'present' }); + }); + + it('reports failed when create() throws', () => { + const schema = { + cache: fakeFactory('cache', { create() { throw new Error('boom'); } - } - }; - const builders = buildServiceBuilders(schema, stubCore); - expect(() => (builders.proxies as { broken: unknown }).broken).toThrow( - AappServiceConstructionFailedError - ); - expect(builders.statusMap()).toEqual({ broken: 'failed' }); + }) + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + expect(() => builders.proxies.cache).toThrow(AappServiceConstructionFailedError); + expect(builders.statusMap()).toEqual({ cache: 'failed' }); }); - it('immediate failure propagates from buildServiceBuilders', () => { - const schema: AppServiceSchema = { - broken: { - name: 'broken', - coreDependencies: [], - initMode: 'immediate', + it('re-throws the same error on repeated access of a failed service', () => { + const schema = { + cache: fakeFactory('cache', { create() { throw new Error('boom'); } - } - }; - expect(() => buildServiceBuilders(schema, stubCore)).toThrow( - AappServiceConstructionFailedError - ); - }); + }) + } as unknown as AppServiceSchema; - it('repeated access to a failed service re-throws the construction error', () => { - const create = vi.fn(() => { - throw new Error('boom'); - }); - const schema: AppServiceSchema = { - broken: { - name: 'broken', - coreDependencies: [], - create + const builders = buildServiceBuilders(schema, mockCore()); + + const firstError = (() => { + try { + void builders.proxies.cache; + } catch (e) { + return e; } - }; - const builders = buildServiceBuilders(schema, stubCore); - expect(() => (builders.proxies as { broken: unknown }).broken).toThrow( - AappServiceConstructionFailedError - ); - expect(() => (builders.proxies as { broken: unknown }).broken).toThrow( - AappServiceConstructionFailedError - ); - expect(create).toHaveBeenCalledTimes(1); // not retried + })(); + const secondError = (() => { + try { + void builders.proxies.cache; + } catch (e) { + return e; + } + })(); + + expect(firstError).toBeInstanceOf(AappServiceConstructionFailedError); + expect(secondError).toBeInstanceOf(AappServiceConstructionFailedError); }); }); -describe('buildServiceBuilders — disposal', () => { - it('disposes services in reverse construction order', () => { - const calls: string[] = []; - const schema: AppServiceSchema = { - leaf: stubFactory('leaf', 'l', { +// ── Dispose order ───────────────────────────────────────────────────── + +describe('buildServiceBuilders — dispose', () => { + it('disposes constructed services in reverse construction order', () => { + const log: string[] = []; + const schema = { + a: fakeFactory('a', { initMode: 'immediate', - dispose: () => calls.push('dispose:leaf') + dispose() { + log.push('a'); + } }), - top: stubFactory('top', 't', { + b: fakeFactory('b', { initMode: 'immediate', - serviceDependencies: ['leaf'], - dispose: () => calls.push('dispose:top') + serviceDependencies: ['a'], + dispose() { + log.push('b'); + } + }), + c: fakeFactory('c', { + initMode: 'immediate', + serviceDependencies: ['b'], + dispose() { + log.push('c'); + } }) - }; - const builders = buildServiceBuilders(schema, stubCore); - builders.disposeAll(); - expect(calls).toEqual(['dispose:top', 'dispose:leaf']); - }); + } as unknown as AppServiceSchema; - it('dispose is idempotent', () => { - const dispose = vi.fn(); - const schema: AppServiceSchema = { - s: stubFactory('s', 'instance', { initMode: 'immediate', dispose }) - }; - const builders = buildServiceBuilders(schema, stubCore); - builders.disposeAll(); + const builders = buildServiceBuilders(schema, mockCore()); builders.disposeAll(); - expect(dispose).toHaveBeenCalledTimes(1); + expect(log).toEqual(['c', 'b', 'a']); }); it('does not dispose services that were never constructed', () => { const dispose = vi.fn(); - const schema: AppServiceSchema = { - lazy: stubFactory('lazy', 'instance', { dispose }) - }; - const builders = buildServiceBuilders(schema, stubCore); + const schema = { + cache: fakeFactory('cache', { dispose }) + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + // never read builders.proxies.cache builders.disposeAll(); expect(dispose).not.toHaveBeenCalled(); }); - it('swallows dispose errors', () => { - const calls: string[] = []; - const schema: AppServiceSchema = { - a: stubFactory('a', 'a', { + it('swallows errors thrown during dispose', () => { + const schema = { + a: fakeFactory('a', { initMode: 'immediate', - dispose: () => { - throw new Error('boom'); + dispose() { + throw new Error('dispose failed'); } }), - b: stubFactory('b', 'b', { - initMode: 'immediate', - dispose: () => calls.push('dispose:b') + b: fakeFactory('b', { + initMode: 'immediate' }) - }; - const builders = buildServiceBuilders(schema, stubCore); + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); expect(() => builders.disposeAll()).not.toThrow(); - // b was constructed first (no deps), disposed second - expect(calls).toEqual(['dispose:b']); + }); + + it('is idempotent', () => { + const dispose = vi.fn(); + const schema = { + cache: fakeFactory('cache', { initMode: 'immediate', dispose }) + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + builders.disposeAll(); + builders.disposeAll(); + expect(dispose).toHaveBeenCalledTimes(1); + }); +}); + +// ── Core dependency injection ───────────────────────────────────────── + +describe('buildServiceBuilders — core injection', () => { + it('passes only the declared core deps to create()', () => { + const create = vi.fn(({ core }) => ({ name: 'cache', disposed: false, deps: core })); + const schema = { + cache: { + name: 'cache', + coreDependencies: ['logger'], + initMode: 'lazy', + create + } as AppServiceFactory<'cache', readonly ['logger'], readonly [], unknown> + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + void builders.proxies.cache; + + const { core } = create.mock.calls[0][0] as { core: Record }; + expect(Object.keys(core)).toEqual(['logger']); + expect(core).not.toHaveProperty('bus'); + expect(core).not.toHaveProperty('timers'); + expect(core).not.toHaveProperty('orca'); + }); + + it('passes service deps that exist in the schema', () => { + const aInstance = { name: 'a', disposed: false }; + let capturedDeps: Record | undefined; + const schema = { + a: fakeFactory('a', { create: () => aInstance }), + b: { + name: 'b', + coreDependencies: [], + serviceDependencies: ['a'], + initMode: 'lazy', + create({ services }) { + capturedDeps = services; + return { name: 'b', disposed: false }; + } + } as AppServiceFactory<'b', readonly [], readonly ['a'], unknown> + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + void builders.proxies.b; + + expect(capturedDeps).toBeDefined(); + expect(capturedDeps?.a).toBe(aInstance); + }); + + it('leaves missing service deps as undefined', () => { + let capturedDeps: Record | undefined; + const schema = { + b: { + name: 'b', + coreDependencies: [], + serviceDependencies: ['missing'], + initMode: 'lazy', + create({ services }) { + capturedDeps = services; + return { name: 'b', disposed: false }; + } + } as AppServiceFactory<'b', readonly [], readonly ['missing'], unknown> + } as unknown as AppServiceSchema; + + const builders = buildServiceBuilders(schema, mockCore()); + void builders.proxies.b; + + expect(capturedDeps).toBeDefined(); + expect(capturedDeps?.missing).toBeUndefined(); }); }); diff --git a/src/arts/active-app/test/storage-integration.test.ts b/src/arts/active-app/test/storage-integration.test.ts deleted file mode 100644 index acad2b0..0000000 --- a/src/arts/active-app/test/storage-integration.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * App.Storage + frontend.persist — integration tests. - * - * Verifies that: - * - App.Storage is always present (memory adapter by default) - * - storage onError routes through App.Logger - * - frontend.persist seeds Frontend with persisted preferences - * - changes to Frontend write back to Storage - * - per-key overrides are respected - * - dispose tears everything down cleanly - */ - -import { describe, it, expect } from 'vitest'; -import { createActiveApp } from '../active-app.svelte'; -import { createMemoryAdapter, encodeEnvelope } from '$storage'; -import { LogLevel } from '$logger'; - -describe('App.Storage', () => { - it('is always present even without storage options', () => { - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] } - }); - expect(App.Storage).toBeDefined(); - expect(App.Storage.adapter.name).toBe('memory'); - App.dispose(); - }); - - it('uses the configured adapter and namespace', () => { - const adapter = createMemoryAdapter(); - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] }, - storage: { adapter, namespace: 'test' } - }); - const e = App.Storage.entry('theme', 'base'); - e.set('forest'); - expect(adapter.getItem('test:theme')).toBeTruthy(); - App.dispose(); - }); - - it('routes storage errors through App.Logger', () => { - const captured: { category: string; message: string }[] = []; - const broken = { - ...createMemoryAdapter(), - name: 'broken', - setItem: () => { - throw new Error('disk full'); - } - }; - const App = createActiveApp({ - logger: { - level: LogLevel.TRACE, - transports: [ - { - name: 'capture', - write(entry) { - captured.push({ category: entry.category, message: entry.message }); - } - } - ] - }, - storage: { adapter: broken } - }); - App.Storage.entry('x', 'd').set('v'); - expect(captured.some((e) => e.category === 'storage' && /write/.test(e.message))).toBe(true); - App.dispose(); - }); -}); - -describe('frontend.persist', () => { - it('seeds Frontend with persisted theme on construction', () => { - const adapter = createMemoryAdapter({ - 'app:theme': encodeEnvelope('forest', 1) - }); - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] }, - storage: { adapter, namespace: 'app' }, - frontend: { theme: 'base', persist: true } - }); - expect(App.Frontend.getTheme()).toBe('forest'); - App.dispose(); - }); - - it('writes preference changes back to Storage', () => { - const adapter = createMemoryAdapter(); - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] }, - storage: { adapter, namespace: 'app' }, - frontend: { theme: 'base', persist: true } - }); - App.Frontend.setTheme('sunset'); - const stored = adapter.getItem('app:theme'); - expect(stored).toContain('sunset'); - App.dispose(); - }); - - it('persists only the keys requested', () => { - const adapter = createMemoryAdapter(); - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] }, - storage: { adapter, namespace: 'app' }, - frontend: { theme: 'base', persist: { keys: ['theme'] } } - }); - App.Frontend.setTheme('sunset'); - App.Frontend.setDensity('compact'); - expect(adapter.getItem('app:theme')).toBeTruthy(); - expect(adapter.getItem('app:density')).toBeNull(); - App.dispose(); - }); - - it('per-key override targets a different adapter', () => { - const main = createMemoryAdapter(); - const cookie = createMemoryAdapter(); - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] }, - storage: { adapter: main, namespace: 'app' }, - frontend: { - theme: 'base', - persist: { - overrides: { - theme: { adapter: cookie, namespace: false, raw: true } - } - } - } - }); - App.Frontend.setTheme('forest'); - // Theme went to the cookie adapter as a raw value, no namespace. - expect(cookie.getItem('theme')).toBe('forest'); - expect(main.getItem('app:theme')).toBeNull(); - App.dispose(); - }); - - it('dispose detaches the persistence subscription', () => { - const adapter = createMemoryAdapter(); - const App = createActiveApp({ - logger: { level: LogLevel.NONE, transports: [] }, - storage: { adapter, namespace: 'app' }, - frontend: { theme: 'base', persist: true } - }); - App.Frontend.setTheme('sunset'); - App.dispose(); - - // After dispose, the entry registered for theme has been disposed; we - // cannot easily probe that without internals, so just assert no throw. - expect(() => adapter.setItem('app:theme', 'should-not-trigger')).not.toThrow(); - }); -}); diff --git a/src/arts/active-app/test/test-app.test.ts b/src/arts/active-app/test/test-app.test.ts deleted file mode 100644 index 1cafc54..0000000 --- a/src/arts/active-app/test/test-app.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * createTestApp — test helper smoke tests. - * - * Verifies the helper layers test-friendly defaults on top of createActiveApp: - * - silent logger by default (no console noise) - * - captureLogs mode attaches a sink and exposes entries - * - custom logger transports compose with the capture transport - * - non-logger sections (lang/formats/...) pass through unchanged - */ - -import { describe, it, expect } from 'vitest'; -import { createTestApp } from '../testing'; -import { LogLevel } from '$logger'; -import type { LangNode } from '$lang'; - -const schema = { - greeting: { es: 'Hola', en: 'Hello' } -} satisfies LangNode; - -describe('createTestApp', () => { - it('builds with zero options', () => { - const App = createTestApp(); - expect(App.Logger).toBeDefined(); - expect(App.Lang).toBeDefined(); - expect(App.Format).toBeDefined(); - expect(App.Frontend).toBeDefined(); - expect(App.Dom).toBeDefined(); - expect(App.entries).toEqual([]); - App.dispose(); - }); - - it('silences the logger by default', () => { - const App = createTestApp(); - expect(App.Logger.transports()).toHaveLength(0); - App.Logger.warn('test', 'should not surface'); - expect(App.entries).toHaveLength(0); - App.dispose(); - }); - - it('captures entries when captureLogs is true', () => { - const App = createTestApp({ captureLogs: true }); - - App.Logger.info('boot', 'ready'); - App.Logger.warn('auth', 'token expiring', { context: { userId: 1 } }); - - expect(App.entries).toHaveLength(2); - expect(App.entries[0].message).toBe('ready'); - expect(App.entries[0].category).toBe('boot'); - expect(App.entries[1].level).toBe(LogLevel.WARN); - expect(App.entries[1].context).toEqual({ userId: 1 }); - - App.dispose(); - }); - - it('captures every level when captureLogs is true (level=TRACE)', () => { - const App = createTestApp({ captureLogs: true }); - - App.Logger.trace('t', 'trace'); - App.Logger.debug('t', 'debug'); - App.Logger.info('t', 'info'); - App.Logger.warn('t', 'warn'); - App.Logger.error('t', 'error'); - - expect(App.entries.map((e) => e.message)).toEqual(['trace', 'debug', 'info', 'warn', 'error']); - - App.dispose(); - }); - - it('appends capture transport to the caller-provided transports', () => { - const userWrites: string[] = []; - const App = createTestApp({ - captureLogs: true, - logger: { - transports: [ - { - name: 'user-sink', - write(entry) { - userWrites.push(entry.message); - } - } - ] - } - }); - - App.Logger.info('demo', 'hello'); - - expect(userWrites).toEqual(['hello']); - expect(App.entries.map((e) => e.message)).toEqual(['hello']); - - App.dispose(); - }); - - it('respects caller-provided logger options when captureLogs is false', () => { - const App = createTestApp({ - logger: { level: LogLevel.WARN, transports: [] } - }); - - expect(App.Logger.transports()).toHaveLength(0); - App.Logger.info('quiet', 'below level'); - App.Logger.warn('loud', 'above level'); - // Without captureLogs the entries array stays empty regardless. - expect(App.entries).toHaveLength(0); - - App.dispose(); - }); - - it('propagates lang/formats/frontend options unchanged', () => { - const App = createTestApp({ - lang: { schema, defaultLocale: 'es' }, - formats: { currency: { currency: 'EUR' } } - }); - - expect(App.Lang.t('greeting')).toBe('Hola'); - expect(App.Format.currency.getCurrency()).toBe('EUR'); - - App.setLocale('en'); - expect(App.Lang.t('greeting')).toBe('Hello'); - - App.dispose(); - }); - - it('exposes entries as a non-enumerable property', () => { - const App = createTestApp({ captureLogs: true }); - expect(Object.keys(App)).not.toContain('entries'); - // But the value is still accessible and mutable. - App.Logger.info('cat', 'msg'); - expect(App.entries).toHaveLength(1); - App.dispose(); - }); - - it('dispose() works as in production', () => { - const App = createTestApp({ captureLogs: true }); - App.Logger.info('cat', 'msg'); - App.dispose(); - expect(() => App.dispose()).not.toThrow(); - }); -}); diff --git a/src/arts/active-app/testing/index.ts b/src/arts/active-app/testing/index.ts deleted file mode 100644 index 9ee95e7..0000000 --- a/src/arts/active-app/testing/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { LogLevel, type LogEntry, type LoggerOptions, type Transport } from '$logger'; -import type { LangNode } from '$libs/lang'; - -import { createActiveApp } from '../active-app.svelte'; -import type { ActiveApp, ActiveAppOptions } from '../types'; - -/** - * Options for `createTestApp()`. Same shape as `ActiveAppOptions` but every - * section is optional and the helper layers test-friendly defaults on top. - */ -export interface TestAppOptions - extends Partial> { - /** - * When `true`, attach a synchronous capture transport and route every - * entry to the returned `entries` array. The default logger level becomes - * `LogLevel.TRACE` so nothing is filtered out before reaching the sink. - * - * If a custom `logger.transports` is also provided, the capture transport - * is appended to it — both the test sink and the caller's transports - * receive every entry. - * - * @default false - */ - captureLogs?: boolean; -} - -/** - * Test handle returned by `createTestApp()`. Identical to `ActiveApp` plus the - * `entries` array — empty unless `captureLogs: true`. - */ -export interface TestAppHandle extends ActiveApp { - /** - * Captured log entries in order of arrival. Mutated in place by the - * capture transport so assertions can read it directly without polling. - */ - readonly entries: LogEntry[]; -} - -/** - * `createActiveApp()` with test-friendly defaults — the canonical way to - * build an `App` inside a unit/integration test. - * - * Lives under `$active-app/testing` so the helper does not ship with production - * bundles that import the main `$active-app` barrel. - * - * Defaults that differ from production: - * - * - **Logger silenced** unless `captureLogs: true`. Tests do not pollute the - * console with WARN+ entries from the real engine default. - * - **`captureLogs: true`** attaches an in-memory transport whose entries are - * exposed on the returned `entries` array, so assertions like - * `expect(App.entries.some(e => e.message === '...'))` are one-liners. - * - * Everything else passes through unchanged — the `lang`, `formats`, - * `frontend`, `dom` sections behave exactly as in production. - * - * @example - * // Smoke test — no logs needed - * import { createTestApp } from '$active-app/testing'; - * const App = createTestApp({ lang: { schema } }); - * expect(App.Lang.t('common.ok')).toBe('Aceptar'); - * App.dispose(); - * - * @example - * // Assert on emitted log entries - * const App = createTestApp({ captureLogs: true }); - * App.Logger.warn('auth', 'token expiring'); - * expect(App.entries).toHaveLength(1); - * expect(App.entries[0].category).toBe('auth'); - * App.dispose(); - * - * @example - * // Override only what's needed; everything else is the standard default. - * const App = createTestApp({ - * captureLogs: true, - * formats: { currency: { currency: 'EUR' } } - * }); - */ -export function createTestApp( - options: TestAppOptions = {} -): TestAppHandle { - const entries: LogEntry[] = []; - - let loggerOptions: LoggerOptions; - if (options.captureLogs) { - const captureTransport: Transport = { - name: 'test-capture', - write(entry) { - entries.push(entry); - } - }; - loggerOptions = { - level: LogLevel.TRACE, - ...options.logger, - transports: [...(options.logger?.transports ?? []), captureTransport] - }; - } else { - // Silent by default — tests should not emit to the real console - // unless the caller opts in or supplies their own transports. - loggerOptions = options.logger ?? { level: LogLevel.NONE, transports: [] }; - } - - const App = createActiveApp({ - ...options, - logger: loggerOptions - }); - - // Attach `entries` as a non-enumerable, frozen-reference field so it does - // not show up in `Object.keys(App)` and the array identity is stable - // across the test (push works because the property is the array itself, - // not a getter). - Object.defineProperty(App, 'entries', { - value: entries, - writable: false, - enumerable: false, - configurable: false - }); - - return App as TestAppHandle; -} diff --git a/src/arts/active-app/types.ts b/src/arts/active-app/types.ts index cbda6a1..9cf38a0 100644 --- a/src/arts/active-app/types.ts +++ b/src/arts/active-app/types.ts @@ -1,212 +1,142 @@ -import type { ActiveDom, ActiveDomProps } from '$adom'; +/** + * Public types for `arts/active-app`. + * + * `ActiveApp` is the composed surface seen by the application: + * + * - `ActiveAppCore` — `Logger`, `Bus`, `Timers`, `Orca`, `dispose`. + * Always present, never declared as a service. + * - `ResolveServiceInstances` — every entry the application + * declared in `services: { … }` is exposed as a lowercase property + * with the exact instance type returned by its factory. + * - `ActiveAppServicesIntrospection` — `services` map for devtools. + * + * The legacy uppercase surface (`App.Lang`, `App.Cache`, `App.Frontend`, + * `App.Format`, `App.Dom`, `App.Storage`, `App.Http`) has been removed. + * Those pieces are now opt-in services that the application declares + * via `defineActive*` / `defineEngine*` factories. + */ + import type { EngineBus, EngineBusOptions } from '$bus'; -import type { ActiveCache, ActiveCacheOptions } from '$cache'; -import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend'; -import type { FrontendPreferenceKey } from '$frontend'; -import type { ActiveFormat, ActiveFormatOptions } from '$format'; -import type { EngineHttp, EngineHttpOptions } from '$http'; -import type { AppEventMap } from './events.ts'; -import type { LangNode, SupportedLocale } from '$libs/lang'; -import type { ActiveLang } from '$lang'; import type { EngineLogger, LoggerOptions } from '$logger'; -import type { EngineOrca } from '$orca'; -import type { SessEventMap } from '$session'; -import type { ActiveStorage, SyncStorageAdapter } from '$storage'; +import type { EngineOrca, EngineOrcaOptions } from '$orca'; import type { ActiveTimers, EngineTimersOptions } from '$timer'; + +import type { AppEventMap } from './events.ts'; import type { AppServiceSchema, ResolveServiceInstances, ServiceStatus } from './services.ts'; -/** Frontend preferences eligible for App-managed persistence. */ -export type FrontendPersistKey = FrontendPreferenceKey; - -export interface ActiveAppBusEvents extends AppEventMap, SessEventMap {} - -/** - * Per-key persistence overrides — pick a different adapter, namespace or - * `raw` flag for individual preferences (e.g. cookie for theme to make it - * server-readable, while density and reducedMotion stay in localStorage). - */ -export interface FrontendPersistKeyOverride { - adapter?: SyncStorageAdapter; - namespace?: string | false; - raw?: boolean; -} - /** - * Frontend persistence config. `true` persists every supported key with the - * App's storage default. Pass an object to refine which keys are persisted - * and how. + * Bus event map seen by `App.Bus`. Includes App-owned events and any + * module-level event maps that App is intended to surface. + * + * Module event maps (e.g. `SessEventMap`, `ConnectionEventMap`) are + * intentionally NOT pre-declared here. Each app augments this type via + * declaration merging if it wants typed access; the default contract is + * App-level only. */ -export type FrontendPersist = - | boolean - | { - /** Subset to persist. Default: every supported key. */ - keys?: ReadonlyArray; - /** Adapter for all persisted keys. Default: App.Storage's adapter. */ - adapter?: SyncStorageAdapter; - /** Namespace prefix. Default: App.Storage's namespace. */ - namespace?: string | false; - /** Per-key overrides applied on top of the outer adapter/namespace. */ - overrides?: Partial>; - }; +export interface ActiveAppBusEvents extends AppEventMap {} -/** - * Storage section of `ActiveAppOptions`. When omitted, App still exposes - * `App.Storage` backed by an in-memory adapter — values do not survive - * reload. Configure `adapter: localAdapter` (or any `SyncStorageAdapter`) - * for real persistence. - */ -export interface ActiveAppStorageOptions { - adapter?: SyncStorageAdapter; - namespace?: string; -} +// ── Options ──────────────────────────────────────────────────────────── /** - * Options for `createActiveApp()`. Every section is optional. - * - * - `dom` and `frontend` are always built (with defaults if absent). - * - `logger` defaults to the engine's `level: WARN` + `consoleTransport()`. - * Pass `{ level: LogLevel.NONE, transports: [] }` for silence. - * - `lang` defaults to a mono lang when absent (single-language passthrough, - * reactive locale still owned so Format/Frontend stay in sync). - * - `formats` is always built; sub-engines accept their own knobs (currency, - * date order, etc.). When absent the locale falls back to `FORMAT_DEFAULT_LOCALE`. + * Options for `createActiveApp()`. All sections are optional. * - * Sium is intentionally **not** part of App — validation is page-scoped. - * Pages that need it construct an `EngineSium` directly: + * - `logger` defaults to engine defaults (`level: WARN`, + * `consoleTransport()`). Pass `{ level: NONE, transports: [] }` for + * silence. + * - `timers` and `bus` build with engine defaults; App injects + * `logger` (and `clock` for the bus) automatically. + * - `orca` builds with engine defaults; App injects `bus`, `timers` + * and `logger`. + * - `services` declares the opt-in service schema. If omitted, only + * the core is built and `App.services` is `{}`. * - * ```ts - * import { createEngineSium } from '$sium'; - * const sium = createEngineSium({ lang: App.Lang, logger: App.Logger }); - * ``` + * Whatever lived in the previous root-level options (`lang`, `formats`, + * `frontend`, `dom`, `storage`, `http`, `cache`) now belongs in + * `services` via the corresponding `defineActive*` factory. */ -export interface ActiveAppOptions< - S extends LangNode = LangNode, - TSchema extends AppServiceSchema = AppServiceSchema -> { +export interface ActiveAppOptions { /** - * Declarative service schema. Each entry is built by an - * `AppServiceFactory` from `arts/active-app/service-factories/`. - * Services are exposed as lowercase properties on the App - * (`App.cache`, `App.session`, …) and live alongside the legacy - * uppercase factories until those are removed. - * - * Lazy services build on first access; `immediate` services build - * during `createActiveApp()`. + * Logger options for the App-wide engine logger. App passes the + * resulting instance to every service that declares `logger` as a + * core dependency. */ - services?: TSchema; logger?: LoggerOptions; - lang?: { - schema: S; - defaultLocale?: SupportedLocale; - fallbackChain?: SupportedLocale[]; - }; - formats?: Omit; - frontend?: Omit & { - /** - * Persist user preferences (theme, mode, density, ...) through - * `App.Storage`. `true` persists every supported key; pass an object - * for per-key adapter/namespace overrides (e.g. cookie for `theme`). - * - * @default false - */ - persist?: FrontendPersist; - }; - dom?: ActiveDomProps; - storage?: ActiveAppStorageOptions; + /** - * Runtime timer scheduler defaults. App injects Logger automatically. - * Exposed as `App.Timers` and reused by integrations such as session - * auto-refresh. + * Timer scheduler options. App injects `logger` automatically. */ timers?: Omit; + /** - * Cross-artifact event bus defaults. App injects Logger and the shared - * Timers clock automatically. The bus is for inter-module facts only; - * private per-artifact listeners remain local to each artifact. + * Cross-artifact event bus options. App injects `logger` and the + * shared timers `clock` automatically. */ bus?: Omit; + /** - * HTTP client defaults — `baseUrl`, default headers, retry, timeout, hooks. - * When omitted, `App.Http` is still present with engine defaults - * (`globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s - * per-attempt timeout). The `logger` field is wired automatically. - * - * In SvelteKit `load` functions, scope the engine to the request via - * `App.Http.with({ fetch: event.fetch })` so cookies and relative-URL - * resolution flow through the framework. - */ - http?: Omit; - /** - * App-scoped data cache. When omitted, App still exposes `App.Cache` - * backed by an in-memory adapter. Pass a custom adapter/policies/scope - * resolver when data should persist, share across layers or segment by - * tenant/actor/permission. + * Orca options. App injects `bus`, `timers` and `logger` + * automatically. */ - cache?: Omit; -} - -/** - * Composed application surface. - * - * Every member is **always** present so call sites can use `App.Lang.t(...)` - * or `App.Format.currency.format(...)` without null checks. When the caller - * did not configure an artifact, App provides a structurally identical - * adapter: - * - * | Artifact | Configured | Default | - * | -------- | ---------- | ------------------------------------------------ | - * | Logger | real | `level: WARN` + `consoleTransport()` (engine default; pass `{ level: NONE, transports: [] }` for silence) | - * | Lang | real | mono — returns paths and `\|fallback` literals; warns once per path in DEV via Logger under `lang.mono` | - * | Format | real | real with locale = `FORMAT_DEFAULT_LOCALE` (`'en-US'`) | - * | Frontend | real | real with default theme/mode/density | - * | Dom | real | real with default breakpoints | - */ -export type ActiveApp< - S extends LangNode = LangNode, - TSchema extends AppServiceSchema = AppServiceSchema -> = ActiveAppLegacy & ResolveServiceInstances & ActiveAppServicesIntrospection; + orca?: Omit; -export interface ActiveAppServicesIntrospection { /** - * Snapshot of `{ [serviceName]: ServiceStatus }` for every declared - * service. Useful for devtools and tests. + * Declarative service schema. Each entry is built by an + * `AppServiceFactory` from `arts/active-app/service-factories/`. + * Services are exposed as lowercase properties on the App + * (`App.cache`, `App.session`, …). + * + * Lazy services build on first access; `immediate` services build + * during `createActiveApp()`. */ - readonly services: Readonly>; + services?: TSchema; } +// ── Surface ──────────────────────────────────────────────────────────── + /** - * The fixed core surface present on every App: Logger, Lang, Format, - * Frontend, Dom, Storage, Http, Timers, Bus, Orca, Cache, locale flow - * and `dispose`. Service-schema instances live alongside on - * `ActiveApp` (lowercase keys). + * The fixed core surface, present on every App: `Logger`, `Bus`, + * `Timers`, `Orca` plus the lifecycle helper `dispose`. None of these + * are services — they are the substrate every service depends on. */ -export interface ActiveAppLegacy { +export interface ActiveAppCore { readonly Logger: EngineLogger; - readonly Lang: ActiveLang; - readonly Format: ActiveFormat; - readonly Frontend: ActiveFrontend; - readonly Dom: ActiveDom; - readonly Storage: ActiveStorage; - readonly Http: EngineHttp; - readonly Timers: ActiveTimers; readonly Bus: EngineBus; + readonly Timers: ActiveTimers; /** - * Orchestration engine (orca v0.0). Always present, inert until the - * application registers actions via `App.Orca.onEvent(...)` or applies - * presets from `arts/active-app/presets/` (e.g. `applyStandardOrca`). + * Orchestration engine. Always present, inert until the application + * registers actions via `App.Orca.onEvent(...)` or applies presets + * from `arts/active-app/presets/`. */ readonly Orca: EngineOrca; - readonly Cache: ActiveCache; - /** Active locale. Sourced from Lang (real or mono). */ - getLocale: () => SupportedLocale; - setLocale: (locale: SupportedLocale) => void; - onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void; + /** + * Tear down every constructed service in reverse order, then the + * core in reverse build order. Idempotent. + */ + dispose(): void; +} - /** Tear down owned instances in reverse construction order. Idempotent. */ - dispose: () => void; +/** + * Service introspection — used by tests, devtools, and any UI that + * wants to render the current schema state. Most application code does + * not read this. + */ +export interface ActiveAppServicesIntrospection { + readonly services: Readonly>; } + +/** + * Composed application surface. + * + * Type-safe access: + * - `App.Logger` / `App.Bus` / `App.Timers` / `App.Orca` always exist. + * - `App.` exists IFF the service was declared in + * `options.services`. Reading an undeclared name is a TypeScript + * error. + */ +export type ActiveApp = + ActiveAppCore & ResolveServiceInstances & ActiveAppServicesIntrospection; diff --git a/src/arts/bus/README.md b/src/arts/bus/README.md index fadebe8..5cb4afd 100644 --- a/src/arts/bus/README.md +++ b/src/arts/bus/README.md @@ -335,23 +335,18 @@ export function onSessChanged( Module events **may change between minors** (rename, payload addition, deprecation). Only translators consume them. -### App events (public, stable) +### App-owned events -Universal facts about the app, defined in `libs/aapp/events.ts`. **Typed -payloads — never `any`. No credentials.** +The only event App publishes is `APP_EVENT_DISPOSE_STARTING`, fired at +the start of `App.dispose()` via `publishAppDisposeStarting()` in +[arts/active-app/events.ts](../active-app/events.ts). -```ts -// libs/aapp/events.ts -export const APP_EVENT_USER_IDENTITY_CHANGED = 'app.user.identity.changed'; -export const APP_EVENT_TENANT_SWITCHED = 'app.tenant.switched'; -export const APP_EVENT_PERMISSIONS_REFRESH_REQUESTED = 'app.permissions.refresh.requested'; -export const APP_EVENT_CONNECTIVITY_CHANGED = 'app.connectivity.changed'; -export const APP_EVENT_CACHE_INVALIDATE_REQUESTED = 'app.cache.invalidate.requested'; -export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting'; -``` - -Renaming any `APP_EVENT_*`, removing it, or changing its payload shape -non-additively is a **major bump**. Adding new app events is a minor. +Cross-module reactions (cache.clear on identity change, perm.invalidate +on revoke, connections.reauth on identity change) are not bus +re-publications: they live as **orca actions** registered through the +presets in [arts/active-app/presets/](../active-app/presets/). Modules +publish their own typed events (`SESSION_EVENT_*` etc.) directly on +`App.Bus`; orca subscribes and runs the registered actions. ## App.Bus is always-present per render scope @@ -441,11 +436,11 @@ function directly) inside `$effect`: ```svelte @@ -688,31 +697,33 @@ poisoning the engine state. ```ts const App = createActiveApp({ - /* ... */ -}); - -const Sess = App.createActiveSession({ - schemas: { user: UserSchema }, - storage: { adapter: localAdapter, key: 'aapp:session' }, - onRefresh: async (current) => { - const r = await App.Http.post('/api/refresh', { - body: { refreshToken: current.credential.refreshToken }, - schema: SessionResponseSchema - }); - return r.ok ? r.value : null; - }, - onRevoke: async (current) => { - const r = await App.Http.post('/api/logout', { - body: { refreshToken: current.credential.refreshToken } - }); - return r.ok; + services: { + session: defineActiveSession({ + schemas: { user: UserSchema }, + storage: { adapter: localAdapter, key: 'aapp:session' }, + onRefresh: async (current) => { + const r = await App.Http.post('/api/refresh', { + body: { refreshToken: current.credential.refreshToken }, + schema: SessionResponseSchema + }); + return r.ok ? r.value : null; + }, + onRevoke: async (current) => { + const r = await App.Http.post('/api/logout', { + body: { refreshToken: current.credential.refreshToken } + }); + return r.ok; + } + }) } }); + +applyStandardOrca(App); // cross-module reactions on identity changes ``` -The factory injects `App.Logger` automatically; `App.Http` is reachable -by closure (the lazy capture pattern handles the dependency cycle). It also -injects `App.Bus` so safe `sess.*` events can be translated by `aapp`. +`defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`; +`App.Http` is reachable through closure capture inside the handlers. The +session art publishes `SESSION_EVENT_*` directly on `App.Bus`. --- @@ -741,7 +752,7 @@ add cross-tab sync to any adapter. | ------------------------- | ---------------------------------------- | -------------------------------------- | | `SessionDisposedError` | mutator called after `dispose()` | **Thrown** + logged via `logger.error` | | `SessionInvalidError` | `adoptServer()` invariant violation | **Thrown** + logged via `logger.error` | -| `SessionAlreadyCreatedError` | `App.createActiveSession()` called twice | **Thrown** by App factory | +| `SessionAlreadyCreatedError` | session service registered twice | **Thrown** by App factory | Type guards: `isSessionDisposedError`, `isSessionInvalidError`, `isSessionAlreadyCreatedError`. diff --git a/src/arts/session/errors.ts b/src/arts/session/errors.ts index 7645116..9d8e803 100644 --- a/src/arts/session/errors.ts +++ b/src/arts/session/errors.ts @@ -56,7 +56,7 @@ export function adoptServerErrorMessage(invariant: string): string { export const SESSION_ERROR_MESSAGES: ErrorMessages = { [SESSION_ERR_DISPOSED]: `${SESSION_ERROR_PREFIX}operation called on a disposed session engine`, [SESSION_ERR_ALREADY_CREATED]: - `${SESSION_ERROR_PREFIX}App.createActiveSession() called more than once`, + `${SESSION_ERROR_PREFIX}session service registered more than once`, [SESSION_ERR_INVALID_SESSION]: `${SESSION_ERROR_PREFIX}adoptServer() received an invalid session` }; @@ -74,9 +74,9 @@ export class SessionDisposedError extends CodeError { } /** - * `App.createActiveSession()` was called more than once. The artifact - * supports a single session per App; multi-account scenarios are out of - * scope (would compose multiple App instances). + * The session service was registered more than once on the App service + * schema. The artifact supports a single session per App; multi-account + * scenarios are out of scope (would compose multiple App instances). */ export class SessionAlreadyCreatedError extends CodeError { constructor(message: string, options?: { cause?: unknown }) { diff --git a/src/libs/cache/key.ts b/src/libs/cache/key.ts index 964bdc7..4620e98 100644 --- a/src/libs/cache/key.ts +++ b/src/libs/cache/key.ts @@ -1,7 +1,7 @@ import { CACHE_CANONICAL_ROOT_PATH, - CACHE_ERROR_MESSAGES, CACHE_KEY_ENTRIES_FIELD, + CACHE_VALIDATION_MESSAGES, CACHE_KEY_TYPE_BIGINT, CACHE_KEY_TYPE_DATE, CACHE_KEY_TYPE_FIELD, diff --git a/src/libs/cache/policy.ts b/src/libs/cache/policy.ts index 62666dc..e3b5bcf 100644 --- a/src/libs/cache/policy.ts +++ b/src/libs/cache/policy.ts @@ -4,7 +4,6 @@ import { CACHE_DURATION_UNIT_MINUTE, CACHE_DURATION_UNIT_MS, CACHE_DURATION_UNIT_SECOND, - CACHE_ERROR_MESSAGES, CACHE_MS_DAY, CACHE_MS_HOUR, CACHE_MS_MINUTE, @@ -17,7 +16,8 @@ import { CACHE_READ_MODE_CACHE_FIRST, CACHE_READ_MODE_MUST_REVALIDATE, CACHE_READ_MODE_STALE_WHILE_REVALIDATE, - CACHE_DEFAULT_CACHE_POLICY + CACHE_DEFAULT_CACHE_POLICY, + CACHE_VALIDATION_MESSAGES } from './consts.ts'; import { CachePolicyError } from './errors.ts'; import type { CachePolicy, CacheReadMode, DurationInput, ResolvedCachePolicy } from './types.ts'; diff --git a/src/libs/cache/scope.ts b/src/libs/cache/scope.ts index 3a7209e..a349f8b 100644 --- a/src/libs/cache/scope.ts +++ b/src/libs/cache/scope.ts @@ -1,5 +1,4 @@ import { - CACHE_ERROR_MESSAGES, CACHE_SCOPE_ACTOR, CACHE_SCOPE_CUSTOM, CACHE_SCOPE_FIELD_ACTOR_ID, @@ -8,7 +7,8 @@ import { CACHE_SCOPE_FIELD_TENANT_ID, CACHE_SCOPE_PERMISSION, CACHE_SCOPE_PUBLIC, - CACHE_SCOPE_TENANT + CACHE_SCOPE_TENANT, + CACHE_VALIDATION_MESSAGES } from './consts.ts'; import { CacheScopeError } from './errors.ts'; import { stableHash, stableStringify } from './key.ts'; diff --git a/src/web/routes/demo/+layout.svelte b/src/web/routes/demo/+layout.svelte new file mode 100644 index 0000000..020ff2c --- /dev/null +++ b/src/web/routes/demo/+layout.svelte @@ -0,0 +1,18 @@ + + +{@render children()} diff --git a/src/web/routes/demo/+layout.ts b/src/web/routes/demo/+layout.ts new file mode 100644 index 0000000..c02f813 --- /dev/null +++ b/src/web/routes/demo/+layout.ts @@ -0,0 +1,6 @@ +// The demo runs entirely in the browser — no SSR, no prerender. Every art +// reads the DOM (frontend, dom), reactively persists in localStorage +// (storage), opens a mock WebSocket-like transport (connection) and +// keeps an `App` singleton alive for the page tree. +export const ssr = false; +export const prerender = false; diff --git a/src/web/routes/demo/+page.svelte b/src/web/routes/demo/+page.svelte new file mode 100644 index 0000000..493b93d --- /dev/null +++ b/src/web/routes/demo/+page.svelte @@ -0,0 +1,89 @@ + + + + Active framework — Demo + + +
+

{App.lang.t('app.title')}

+

{App.lang.t('app.subtitle')}

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

{App.lang.t('bus.title')}

+ +
+
+

Bus events

+ {#if entries.length === 0} + {App.lang.t('bus.empty')} + {:else} +
    + {#each [...entries].reverse() as e (e.id)} +
  • + {e.type} + {App.format.dates.formatTime(new Date(e.at))} +
  • + {/each} +
+ {/if} +
+ +
+

Orca runs

+ {#if recentRuns.length === 0} + {App.lang.t('bus.empty')} + {:else} +
    + {#each recentRuns as run (run.id)} +
  • + {run.event} + {run.status} ({run.actions.length} actions, {run.durationMs}ms) +
  • + {/each} +
+ {/if} +
+
+
+ + diff --git a/src/web/routes/demo/_lib/components/CacheCards.svelte b/src/web/routes/demo/_lib/components/CacheCards.svelte new file mode 100644 index 0000000..91c88f3 --- /dev/null +++ b/src/web/routes/demo/_lib/components/CacheCards.svelte @@ -0,0 +1,172 @@ + + +
+

{App.lang.t('cache.title')}

+

{App.lang.t('cache.stalePolicy')}

+
+ + + +
+ +
+
+

posts

+ {#if posts} +
    + {#each posts as post (post.id)} +
  • #{post.id} {post.title}
  • + {/each} +
+ {:else} + {App.lang.t('cache.empty')} + {/if} +
+
+

users

+ {#if users} +
    + {#each users as u (u.id)} +
  • {u.name} {u.email}
  • + {/each} +
+ {:else} + {App.lang.t('cache.empty')} + {/if} +
+
+
+ + diff --git a/src/web/routes/demo/_lib/components/FormatShowcase.svelte b/src/web/routes/demo/_lib/components/FormatShowcase.svelte new file mode 100644 index 0000000..0b21fcc --- /dev/null +++ b/src/web/routes/demo/_lib/components/FormatShowcase.svelte @@ -0,0 +1,43 @@ + + +
+

{App.lang.t('format.title')}

+
+
{App.lang.t('format.number')}
+
{App.format.numbers.format(123_456.789)}
+
{App.lang.t('format.currency')}
+
{App.format.currency.format(2499.5)}
+
{App.lang.t('format.date')}
+
{App.format.dates.formatDate(today)}
+
{App.lang.t('format.percent')}
+
{App.format.numbers.formatPercent(0.7325)}
+
+
+ + diff --git a/src/web/routes/demo/_lib/components/LangSwitcher.svelte b/src/web/routes/demo/_lib/components/LangSwitcher.svelte new file mode 100644 index 0000000..cac88dc --- /dev/null +++ b/src/web/routes/demo/_lib/components/LangSwitcher.svelte @@ -0,0 +1,39 @@ + + +
+

{App.lang.t('lang.title')}

+
+ {#each locales as loc (loc)} + + {/each} +
+
+ + diff --git a/src/web/routes/demo/_lib/components/LoggerPanel.svelte b/src/web/routes/demo/_lib/components/LoggerPanel.svelte new file mode 100644 index 0000000..1ccd5fa --- /dev/null +++ b/src/web/routes/demo/_lib/components/LoggerPanel.svelte @@ -0,0 +1,61 @@ + + +
+

{App.lang.t('logger.title')}

+ {#if entries.length === 0} + {App.lang.t('logger.empty')} + {:else} +
    + {#each lastEntries as entry, i (i)} +
  • + [{entry.category}] + {entry.message} +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/web/routes/demo/_lib/components/PermInfo.svelte b/src/web/routes/demo/_lib/components/PermInfo.svelte new file mode 100644 index 0000000..7e5783c --- /dev/null +++ b/src/web/routes/demo/_lib/components/PermInfo.svelte @@ -0,0 +1,121 @@ + + +
+

{App.lang.t('perm.title')}

+
    + {#each actions as action (action.id)} +
  • + {App.lang.t(action.labelKey)} + + {decisions[action.id] === 'allow' + ? '✓ ' + App.lang.t('perm.can') + : decisions[action.id] === 'deny' + ? '✗ ' + App.lang.t('perm.cannot') + : decisions[action.id] === 'pending' + ? '…' + : '?'} + +
  • + {/each} +
+
+ + +
+
+ + diff --git a/src/web/routes/demo/_lib/components/SessionCard.svelte b/src/web/routes/demo/_lib/components/SessionCard.svelte new file mode 100644 index 0000000..2acce0c --- /dev/null +++ b/src/web/routes/demo/_lib/components/SessionCard.svelte @@ -0,0 +1,103 @@ + + +
+

{App.lang.t('session.title')}

+
+
{App.lang.t('session.identity')}
+
+ {#if Sess.current} + {Sess.current.user.name} + ({Sess.current.user.role}) + {:else} + {App.lang.t('session.anonymous')} + {/if} +
+
{App.lang.t('session.generation')}
+
#{Sess.generation}
+
+
+ {#if Sess.current} + + + + {:else} + + {/if} +
+
+ + diff --git a/src/web/routes/demo/_lib/components/ThemeSwitcher.svelte b/src/web/routes/demo/_lib/components/ThemeSwitcher.svelte new file mode 100644 index 0000000..c74b199 --- /dev/null +++ b/src/web/routes/demo/_lib/components/ThemeSwitcher.svelte @@ -0,0 +1,124 @@ + + +
+

{App.lang.t('frontend.title')}

+ +
+ {App.lang.t('frontend.theme')} +
+ {#each themes as t (t)} + + {/each} +
+
+ +
+ {App.lang.t('frontend.mode')} +
+ {#each modes as m (m)} + + {/each} +
+
+ +
+ {App.lang.t('frontend.direction')} +
+ {#each directions as d (d)} + + {/each} +
+
+ +
+ {App.lang.t('frontend.density')} +
+ {#each densities as d (d)} + + {/each} +
+
+ +
+ theme={theme} + mode={mode}{modeAuto ? ' (auto)' : ''} + dir={dir}{dirAuto ? ' (auto)' : ''} + density={density} +
+
+ + diff --git a/src/web/routes/demo/_lib/components/TimerWidget.svelte b/src/web/routes/demo/_lib/components/TimerWidget.svelte new file mode 100644 index 0000000..40610b0 --- /dev/null +++ b/src/web/routes/demo/_lib/components/TimerWidget.svelte @@ -0,0 +1,79 @@ + + +
+

{App.lang.t('timer.title')}

+
+ {#if pending && countdown !== null} + {countdown}s + {:else if firedAt !== null} + ✓ {App.lang.t('timer.fired')} + ({App.format.dates.formatTime(new Date(firedAt))}) + {:else} + {App.lang.t('timer.none')} + {/if} +
+
+ + +
+
+ + diff --git a/src/web/routes/demo/_lib/components/ValidationForm.svelte b/src/web/routes/demo/_lib/components/ValidationForm.svelte new file mode 100644 index 0000000..89fc15e --- /dev/null +++ b/src/web/routes/demo/_lib/components/ValidationForm.svelte @@ -0,0 +1,109 @@ + + +
+

{App.lang.t('validation.title')}

+
{ + e.preventDefault(); + void submit(); + }} + > + + + +
+ + {#if result} + {#if result.ok} +

✓ {App.lang.t('validation.ok')}

+ {:else} +
+ {App.lang.t('validation.errors')}: +
    + {#each result.errors as err, i (i)} +
  • {err}
  • + {/each} +
+
+ {/if} + {/if} +
+ + diff --git a/src/web/routes/demo/_lib/components/ViewportInfo.svelte b/src/web/routes/demo/_lib/components/ViewportInfo.svelte new file mode 100644 index 0000000..ac1de2f --- /dev/null +++ b/src/web/routes/demo/_lib/components/ViewportInfo.svelte @@ -0,0 +1,46 @@ + + +
+

{App.lang.t('dom.title')}

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