Add cache/perm/session/connections service factories and orca presets

Closes the second half of step 2 of the active-app refactor: every
service in the schema now has a defineActiveX/defineEngineX factory,
and the orchestration that lived inside arts/cache and arts/perm as
internal bus subscriptions moves to opt-in presets registered via
App.Orca.

Service factories added (4):
  - defineActiveCache — passes only logger from core. The legacy
    autoInvalidateOn / bus options are still accepted for back-compat
    but no longer recommended.
  - defineActivePerm — same shape as cache; perm endpoint required
    via options.
  - defineActiveSession — wires core.bus into the session so
    SESSION_EVENT_LIFECYCLE_* events flow without per-app config.
  - defineActiveConnections — requires logger + timers from core.

Presets added in arts/active-app/presets/ (4):
  - applyCacheClearOnIdentityChange — listens to
    SESSION_EVENT_IDENTITY_CHANGED, calls cache.clear().
  - applyCacheClearOnRevoke — listens to SESSION_EVENT_REVOKED,
    calls cache.clear().
  - applyPermInvalidateOnIdentityChange — listens to
    SESSION_EVENT_IDENTITY_CHANGED, calls perm.invalidate().
  - applyStandardOrca — aggregator that registers every preset whose
    services are present on App. Returns a single detacher.

Each preset is a function (App: AppShape) => () => void, where
AppShape is structurally typed against the orca instance and the
specific services the preset needs. Presets cherry-pick what they
need from each service via Pick<…, 'clear' | 'invalidate'>.

Tests: 8 cases for the presets (publish event → assert imperative
API called, detacher unregisters, aggregator skips absent services,
errors recorded in run trace). Total suite: 1402 tests pass
(1394 + 8).

Pending for the big-bang merge in a separate session:
  - Rewrite createActiveApp() to consume the schema and remove the
    legacy App.createActiveX() factories.
  - Delete wireSessionTranslator, createAuthCacheInvalidator and
    APP_ORCHESTRATION_*.
  - Delete APP_EVENT_USER_IDENTITY_CHANGED / TENANT_SWITCHED /
    PERMISSIONS_REFRESH_REQUESTED / CACHE_INVALIDATE_REQUESTED /
    CONNECTIVITY_CHANGED. Keep only DISPOSE_STARTING.
  - Migrate web/routes/* and the ecosystem integration tests.

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

@ -0,0 +1,41 @@
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';
const ACTION_ID = 'cache.clear-on-identity-change';
const TOKEN_CLEARED = 'cache:cleared-on-identity';
interface AppShape {
readonly Orca: EngineOrca;
readonly cache: Pick<ActiveCache, 'clear'>;
}
/**
* Registers an orca action that clears the active cache when the
* session's actor identity changes.
*
* Replaces the legacy `wireAutoInvalidation` subscription 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.
*
* Returns a detach function. Calling it unregisters the action; the
* engine then stops reacting to the event.
*/
export function applyCacheClearOnIdentityChange(App: AppShape): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLEARED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
try {
await App.cache.clear();
return orcaSuccess({ emits: [TOKEN_CLEARED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,35 @@
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';
const ACTION_ID = 'cache.clear-on-revoke';
const TOKEN_CLEARED = 'cache:cleared-on-revoke';
interface AppShape {
readonly Orca: EngineOrca;
readonly cache: Pick<ActiveCache, 'clear'>;
}
/**
* Registers an orca action that clears the active cache when the
* session is revoked. Pairs with `applyCacheClearOnIdentityChange` for
* apps where revoke is independent of identity change (e.g. logout
* without login of another actor).
*/
export function applyCacheClearOnRevoke(App: AppShape): () => void {
return App.Orca.onEvent(SESSION_EVENT_REVOKED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLEARED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
try {
await App.cache.clear();
return orcaSuccess({ emits: [TOKEN_CLEARED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,22 @@
/**
* Orchestration presets for `arts/active-app`. Each `apply*` function
* registers one or more orca actions on `App.Orca` that react to
* canonical lifecycle events (`SESSION_EVENT_*`, future
* `CONNECTION_EVENT_*`, etc.) and call the imperative API of the
* affected service.
*
* **Presets live here, not inside the arts.** An art (`arts/cache`,
* `arts/perm`, etc.) does not know about `arts/session` or
* `arts/orca` — that knowledge belongs to the composition layer
* (`arts/active-app`). Putting the presets here keeps the inter-art
* dependency inversion clean.
*
* Each preset is opt-in: applications register only the orchestration
* they need. `applyStandardOrca(App)` is a convenience aggregator that
* 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';

@ -0,0 +1,36 @@
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';
const ACTION_ID = 'perm.invalidate-on-identity-change';
const TOKEN_INVALIDATED = 'perm:invalidated-on-identity';
interface AppShape {
readonly Orca: EngineOrca;
readonly perm: Pick<ActivePerms, 'invalidate'>;
}
/**
* Registers an orca action that invalidates the local permissions cache
* when the session's actor identity changes.
*
* Replaces the legacy `wireAutoInvalidation` subscription inside
* `arts/perm`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` directly.
*/
export function applyPermInvalidateOnIdentityChange(App: AppShape): () => void {
return App.Orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_INVALIDATED],
onError: ORCA_ON_ERROR_CONTINUE,
action: () => {
try {
App.perm.invalidate();
return orcaSuccess({ emits: [TOKEN_INVALIDATED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,36 @@
import type { EngineOrca } from '$orca';
import type { ActiveCache } from '$cache/types';
import type { ActivePerms } from '$perm/types';
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;
readonly cache?: Pick<ActiveCache, 'clear'>;
readonly perm?: Pick<ActivePerms, 'invalidate'>;
}
/**
* 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.
*
* Returns a single detach function that unregisters everything in
* reverse order — convenient for tests and hot reloading.
*/
export function applyStandardOrca(App: AppShape): () => void {
const detachers: Array<() => void> = [];
if (App.cache !== undefined) {
detachers.push(applyCacheClearOnIdentityChange(App as AppShape & { cache: NonNullable<AppShape['cache']> }));
detachers.push(applyCacheClearOnRevoke(App as AppShape & { cache: NonNullable<AppShape['cache']> }));
}
if (App.perm !== undefined) {
detachers.push(applyPermInvalidateOnIdentityChange(App as AppShape & { perm: NonNullable<AppShape['perm']> }));
}
return () => {
for (let i = detachers.length - 1; i >= 0; i--) detachers[i]();
};
}

@ -0,0 +1,37 @@
import { createActiveCache } from '$cache/active-cache.svelte';
import type { ActiveCache, ActiveCacheOptions } from '$cache/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `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.
*/
export function defineActiveCache(
options: Omit<ActiveCacheOptions, 'logger'> = {}
): AppServiceFactory<'cache', readonly ['logger'], readonly [], ActiveCache> {
return {
name: 'cache',
coreDependencies: ['logger'],
initMode: 'lazy',
create({ core }): ActiveCache {
return createActiveCache({
...options,
logger: core.logger
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,36 @@
import { createActiveConnections } from '$connection/active-connections.svelte';
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.
*/
export function defineActiveConnections(
options: Omit<ActiveConnectionsOptions, 'logger' | 'timers'> = {}
): AppServiceFactory<
'connections',
readonly ['logger', 'timers'],
readonly [],
ActiveConnections
> {
return {
name: 'connections',
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActiveConnections {
return createActiveConnections({
...options,
logger: core.logger,
timers: core.timers
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -8,20 +8,22 @@
* sometimes from each other through the schema), and that crosses the
* "arts must not know about other arts" rule.
*
* Coverage as of this commit:
* - lang, storage, format, dom, frontend, http, sium, auth
*
* Pending (move in the orca-based step):
* - cache, perm, session, connections — these have legacy
* auto-subscriptions to `APP_EVENT_*` that will be replaced by
* orca presets in `arts/active-app/presets/`.
* For services that historically had `bus.on(APP_EVENT_*)` auto-
* subscriptions inside the art (cache, perm, connections), the factories
* intentionally do NOT enable those. The recommended path to react to
* lifecycle events is to register an orca preset from
* `arts/active-app/presets/`.
*/
export { defineActiveAuth } from './auth.ts';
export { defineActiveCache } from './cache.ts';
export { defineActiveConnections } from './connections.ts';
export { defineActiveDom } from './dom.ts';
export { defineActiveFormat } from './format.ts';
export { defineActiveFrontend } from './frontend.ts';
export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts';
export { defineActivePerm } from './perm.ts';
export { defineActiveSession } from './session.ts';
export { defineActiveStorage } from './storage.ts';
export { defineEngineHttp } from './http.ts';
export { defineEngineSium } from './sium.ts';

@ -0,0 +1,34 @@
import { createActivePerms } from '$perm/active-permissions.svelte';
import type { ActivePerms, ActivePermsOptions } from '$perm/types';
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.
*
* The factory still requires the application to provide `endpoint` (via
* the underlying `ActivePermsOptions`); the perm client cannot work
* without a backend.
*/
export function defineActivePerm(
options: Omit<ActivePermsOptions, 'logger'>
): AppServiceFactory<'perm', readonly ['logger'], readonly [], ActivePerms> {
return {
name: 'perm',
coreDependencies: ['logger'],
initMode: 'lazy',
create({ core }): ActivePerms {
return createActivePerms({
...options,
logger: core.logger
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,45 @@
import { createActiveSession } from '$session/active-session.svelte';
import type { ActiveSession, EngineSessionOptions } from '$session/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveSession<TUser, TCredential?, TData?>(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.
*/
export function defineActiveSession<
TUser,
TCredential = undefined,
TData = undefined
>(
options: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger' | 'bus'>
): AppServiceFactory<
'session',
readonly ['logger', 'bus'],
readonly [],
ActiveSession<TUser, TCredential, TData>
> {
return {
name: 'session',
coreDependencies: ['logger', 'bus'],
initMode: 'lazy',
create({ core }): ActiveSession<TUser, TCredential, TData> {
return createActiveSession<TUser, TCredential, TData>({
...(options as EngineSessionOptions<TUser, TCredential, TData>),
logger: core.logger,
bus: core.bus
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,224 @@
/**
* End-to-end tests for the orca-based presets in
* `arts/active-app/presets/`. Validate that:
* - Publishing a SESSION_EVENT_* on the bus triggers the registered
* orca action.
* - The action calls the imperative API on the relevant service.
* - Detachers actually unregister the action.
* - The aggregator only registers presets whose services are present.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyCacheClearOnIdentityChange,
applyCacheClearOnRevoke,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from '../presets/index.ts';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca, type EngineOrca } from '$orca';
import { createActiveTimers } from '$timer/active-timers.svelte';
import {
SESSION_EVENT_IDENTITY_CHANGED,
SESSION_EVENT_LIFECYCLE_REVOKED,
SESSION_EVENT_REVOKED
} from '$session';
import type { EngineLogger } from '$logger';
import type { EngineBus } from '$bus';
import type { ActiveTimers } from '$timer';
import type { ActiveCache } from '$cache/types';
import type { ActivePerms } from '$perm/types';
interface CoreState {
logger: EngineLogger;
bus: EngineBus;
timers: ActiveTimers;
orca: EngineOrca;
}
function buildCore(): CoreState {
const logger = createEngineLogger({});
const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger });
return { logger, bus, timers, orca };
}
function disposeCore(core: CoreState): void {
core.orca.dispose();
core.bus.dispose();
core.timers.dispose();
core.logger.dispose();
}
const samplePayload = {
event: 'session.lifecycle.adopted',
generation: 1,
identity: { from: 'anon', to: 'user-42' },
previousActorId: null,
nextActorId: 'user-42'
};
async function flush(rounds = 4) {
for (let i = 0; i < rounds; i++) {
await new Promise((r) => queueMicrotask(() => r(undefined)));
await new Promise((r) => setTimeout(r, 0));
}
}
describe('applyCacheClearOnIdentityChange', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('clears the cache when SESSION_EVENT_IDENTITY_CHANGED is published', async () => {
const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache;
applyCacheClearOnIdentityChange({ Orca: core.orca, cache });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(clear).toHaveBeenCalledTimes(1);
});
it('detacher unregisters the action', async () => {
const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache;
const detach = applyCacheClearOnIdentityChange({ Orca: core.orca, cache });
detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(clear).not.toHaveBeenCalled();
});
it('records action failure in run trace when clear() rejects', async () => {
const error = new Error('clear failed');
const cache = { clear: vi.fn(() => Promise.reject(error)) } as unknown as ActiveCache;
applyCacheClearOnIdentityChange({ Orca: core.orca, cache });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
const runs = core.orca.recentRuns();
expect(runs).toHaveLength(1);
expect(runs[0].actions[0].status).toBe('error');
expect(runs[0].actions[0].error).toBe(error);
});
});
describe('applyPermInvalidateOnIdentityChange', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('invalidates perms when SESSION_EVENT_IDENTITY_CHANGED is published', async () => {
const invalidate = vi.fn();
const perm = { invalidate } as unknown as ActivePerms;
applyPermInvalidateOnIdentityChange({ Orca: core.orca, perm });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(invalidate).toHaveBeenCalledTimes(1);
});
});
describe('applyCacheClearOnRevoke', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('clears the cache when SESSION_EVENT_REVOKED is published', async () => {
const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache;
applyCacheClearOnRevoke({ Orca: core.orca, cache });
const revokePayload = {
...samplePayload,
event: SESSION_EVENT_LIFECYCLE_REVOKED
};
core.bus.publish(SESSION_EVENT_REVOKED, revokePayload);
await flush();
expect(clear).toHaveBeenCalledTimes(1);
});
});
describe('applyStandardOrca', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('registers cache + perm reactions when both services are present', async () => {
const cacheClear = vi.fn(() => Promise.resolve());
const permInvalidate = vi.fn();
const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
applyStandardOrca({ Orca: core.orca, cache, perm });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(cacheClear).toHaveBeenCalledTimes(1);
expect(permInvalidate).toHaveBeenCalledTimes(1);
});
it('skips cache reactions when cache is absent', async () => {
const permInvalidate = vi.fn();
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
applyStandardOrca({ Orca: core.orca, perm });
// No cache action registered -> no error from publish
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(permInvalidate).toHaveBeenCalledTimes(1);
});
it('detach removes every registered preset', async () => {
const cacheClear = vi.fn(() => Promise.resolve());
const permInvalidate = vi.fn();
const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
const detach = applyStandardOrca({ Orca: core.orca, cache, perm });
detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(cacheClear).not.toHaveBeenCalled();
expect(permInvalidate).not.toHaveBeenCalled();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.