Decouple cross-artifact runtime imports and finalize bus contract

Layer-boundary cleanup (audit-1-5.md section 1):

- fend → adom: move apply.ts to libs/dom; FrontendDom is now DomApplier;
  fend falls back to bare applyChange instead of constructing ActiveDom
- sium → lang: full split — pure code (consts, types, guards, helpers,
  errors, json, plural, plural_rules, diagnostics) moves to libs/lang;
  arts/lang keeps engine + active wrappers and re-exports for back-compat
- conn → timr: minimum split — types and public constants move to
  libs/timers; conn imports types from $libs/timers; EngineConnectionsOptions.timers
  is now required (no more silent createEngineTimers fallback). Tests
  updated to construct shared timers per beforeEach

Bus contract finishing touches (continued from prior session):

- subscribe() returns the unsubscribe function directly
- publishCausedBy() propagates correlationId/causationId
- invokeListener hook + Svelte adapter wraps listeners in untrack
- maxReentrancyDepth guard with BusReentrancyLimitError (fatal — bypasses
  listener-error trap)
- DEV-mode structuredClone payload check raising BusInvalidPayloadError
- BusListenerFailure carries envelopeId/correlationId/causationId
- createBusRecent active wrapper, bus-context.svelte.ts, APP_EVENT_RUNTIMES
  table with assertEventCanFire wired into every publishApp* helper
- App switches to createSvelteEngineBus

audit-1-5.md captures the full ecosystem audit (5 axes); section 1 is
now closed by these changes. Sections 2-5 remain open.

Verification: svelte-check 1395/0 errors; server 1230 tests passed;
client 19 tests passed.

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

@ -0,0 +1,259 @@
# Active framework — ecosystem audit (arts / libs / svrs)
Scope: `src/arts/`, `src/libs/`, `src/svrs/`. The `src/web/` folder is
explicitly excluded. Findings are grouped by audit axis. Severity tags:
- **bug** — incorrect runtime behaviour
- **violation** — breaks an established framework rule
- **drift** — code or docs out of sync with the rest
- **opportunity** — refactor / clean-up that is not a bug today
Each item includes file paths and line ranges so the fix can be applied
without re-searching. Items are ordered roughly by priority within each
section.
---
## 1. Layer-boundary violations
Rule: `arts/<X>` may import from `$libs/*` and from its own internal
files; cross-artifact imports (`$<other-artifact>`) are reserved for the
composition root `arts/aapp`. `libs/*` may not import from any
`$<artifact>` alias. `svrs/*` is similarly restricted.
### Real coupling (runtime, not test) — **violation**
- `src/arts/fend/active-frontend.svelte.ts:2` —
`import { createActiveDom, type ActiveDom } from '$adom';`
`arts/fend` reaches into `arts/adom`. The DOM port should live in
`libs/dom` and `fend` should accept an interface, leaving the DOM
engine wiring to `aapp`.
- `src/arts/sium/engine-resolver.ts:1-2` —
`import { ID_FALLBACK_SEPARATOR, parseLangRef } from '$lang';`
`arts/sium` imports runtime symbols from `arts/lang`. These two
values are pure helpers and belong in `libs/lang` so `sium` can
consume them without crossing artifacts.
- `src/arts/conn/engine-connections.ts:1` —
`import { createEngineTimers } from '$timr';`
`arts/conn` constructs its own timer engine instead of accepting a
`TimerScheduler` through options. The pattern elsewhere (sess, cach,
perm) is "App injects Timers"; conn should follow it.
### Type-only cross-artifact imports — **opportunity**
These compile away but still couple the two artifacts at the type
level. They should be promoted to `libs/<X>/types.ts` so consumers
import the contract without naming the engine.
- `src/arts/auth/client.ts:2` — `import type { EngineHttp, HttpBodyInit, HttpResult } from '$http';`
- `src/arts/sess/types.ts:33` — `import type { SyncStorageAdapter } from '$stor';`
- `src/arts/sium/engine-resolver.ts:1` — `import type { EngineLang, LangParams, SupportedLocale } from '$lang';`
- `src/arts/conn/types.ts` and related — `import type { TimerScheduler, ... } from '$timr';`
### `svrs/*` and `aapp` — clean
`src/svrs/*` does not import any `$<artifact>` alias. `arts/aapp` is
the composition root and is allowed to import every engine; the
imports there are intentional.
---
## 2. Naming convention drift
Rule: every module-event / method / diagnostic value must be a
**module-scoped lowercase string** (`'sess.lifecycle.adopted'`,
`'cach.delete'`, `'conn.connected'`, `'buss.event.published'`). Bare
names (`'delete'`, `'hit'`, `'auth_failed'`) collide across artifacts
once aggregated in logs or wired through the bus.
### Unscoped diagnostic event values — **violation**
- `src/arts/conn/consts.ts:4-20` — `CONNECTION_DIAGNOSTIC_EVENTS` has
15 bare values: `'auth_failed'`, `'browser_reconnect'`,
`'connect_failed'`, `'frame_decode_failed'`, `'frame_encode_failed'`,
`'heartbeat_timeout'`, `'listener_threw'`, `'reauth_failed'`,
`'reconnect_exhausted'`, `'send_failed'`, `'session_changed'`,
`'session_expired'`, `'session_refreshed'`, `'session_revoked'`,
`'transport_error'`. All should carry the `'conn.'` prefix.
- `src/arts/sess/consts.ts:4-20` — `SESSION_DIAGNOSTIC_EVENTS` has 15
bare values: `'adopt_server_invalid'`, `'disposed_access'`,
`'listener_threw'`, `'refresh_*'` (5×), `'revoke_global_*'` (3×),
`'storage_*'` (3×). All need `'sess.'`.
- `src/arts/perm/consts.ts:41-45` —
`PERMISSION_CLIENT_DIAGNOSTIC_EVENTS` has `'remote_batch_failed'`,
`'remote_check_failed'`, `'remote_what_failed'`. All need `'perm.'`.
- `src/svrs/perm/consts.ts:3-7` — `PERMISSION_DIAGNOSTIC_EVENTS` has
`'decision'`, `'denied'`, `'indeterminate'`. All need a `'perm.'`
prefix (or a server-specific scope, e.g. `'perm.server.decision'`).
### Unscoped method constants — **violation**
- `src/svrs/perm/consts.ts:19-25` — `PERMISSION_METHOD_*` constants
hold bare names (`'check'`, `'can'`, `'assert'`, `'explain'`,
`'what'`, `'who'`, `'filter'`). The client-side counterpart in
`arts/perm` already uses `'perm.check'`, `'perm.can'`, … — server
must align.
### Why this matters
`Logger.warn(category, message)` aggregates across artifacts. With
unscoped diagnostic strings, a value like `'listener_threw'` appears
under both `category: 'conn'` and `category: 'sess'` and is
indistinguishable in any flat log search.
---
## 3. Error-class and dispose-pattern consistency
### Hard-coded error `name` strings — **violation**
The convention is `this.name = <MOD>_ERROR_NAME_*` (read from
`consts.ts`).
- `src/arts/sium/core/types.ts:302` — `SiumValidationError` sets
`this.name = 'SiumValidationError'` literally.
- `src/arts/sium/core/types.ts:322` — `SiumAsyncSchemaError` same
pattern.
### Error classes without matching `is*Error` guards — **violation**
The pattern is "one class, one guard". Missing guards make `instanceof`
checks leak into call sites.
- `src/arts/conn/errors.ts:49` — `ConnChannelAlreadyExistsError` no
`isConnChannelAlreadyExistsError`.
- `src/arts/conn/errors.ts:58` — `ConnChannelNotFoundError` no
`isConnChannelNotFoundError`.
- `src/libs/auth/errors.ts:24-66` — 10 auth error classes have no
guards: `AuthAccountNotLinkedError`, `AuthSessionRevokedError`,
`AuthAssuranceRequiredError`, `AuthRateLimitedError`,
`AuthTenantBoundaryError`, `AuthOAuthStateInvalidError`,
`AuthOAuthProviderError`, `AuthOtpInvalidError`,
`AuthMfaRequiredError`, `AuthWebAuthnError`.
### `dispose()` without idempotency guard — **bug**
The convention is `if (disposed) return; disposed = true; …`. Calling
`dispose()` twice should be a no-op.
- `src/arts/conn/active-connections.svelte.ts:118` — second call would
re-traverse `detachers` (empty by then) and call `engine.dispose()`
again.
- `src/arts/fend/active-frontend.svelte.ts:185` — second call would
re-invoke `ownedDom?.dispose()` if the inner DOM was created here.
### `Logger` option not defaulting to `SILENT_LOGGER` — **violation**
Pattern in the codebase: `const logger = options.logger ?? SILENT_LOGGER;`
(see `arts/buss/engine-bus.ts:54`).
- `src/arts/stor/engine-storage.ts` — `createStorageDiagnostics(options.logger)`
passes the `undefined` straight through.
- `src/arts/timr/engine-timers.ts` — same issue with
`createTimerDiagnostics`.
- `src/arts/http/engine-options.ts` — same issue with
`createHttpDiagnostics`.
If diagnostics ever calls a method on the logger without guarding for
`undefined`, these three engines crash when used without a logger
(e.g. in unit tests or smoke harnesses). Verify each diagnostics
constructor; if it already null-checks internally, demote to
**opportunity** for consistency only.
### Generic `throw new Error(...)` in runtime code — **opportunity**
Typed error classes carry the discriminating `name` plus optional
fields (cause, code). Generic throws lose that.
- `src/arts/lang/engine-lang.ts:124, 133` — circular-reference message
thrown as plain `Error`.
- `src/libs/perm/runtime.ts:154, 181` — throws
`Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR)` where a
`PermissionInvalidFilterError` would fit.
---
## 4. Dead code, casts, and `any`
Codebase is largely clean. Specific findings worth acting on:
- `src/svrs/auth/adapters/db.ts:40, 64, 72, 79, 111, 134` — six
repetitions of `as unknown as Readonly<Record<string, unknown>>`.
Consolidate into a `DbRow` type alias or a `coerceRow()` helper.
**opportunity**.
- `src/libs/buss/silent-bus.ts:137` — `} as unknown as EngineBus;`.
Acceptable because the no-op surface is intentionally generic, but
worth replacing with proper generic constraints when the object
literal grows. **opportunity**.
- `src/arts/lang/types.ts:83` — `HasParams<T>` uses `any` deliberately
(contravariant variance). Comment is in place. **no action**.
- `src/libs/days/_vendor/**` — multiple `@ts-ignore` and `TODO`
comments. Vendor code, leave as-is. **no action**.
No dead exports were found in the spot checks of `libs/buss`,
`libs/auth`, `arts/conn`, `libs/aapp/events.ts`. No duplicate type
definitions detected across the audited modules.
---
## 5. Documentation drift
Only the high-traffic READMEs were sampled.
### `arts/buss/README.md`
- The session-translator example (around line 563) hardcodes `cause:
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED` for every event. The actual
translator (`arts/aapp/integrations/session-translator.ts:37`) calls
`resolveAppIdentityCause(payload.event)` to map each `EVENT_*` to
the correct cause. The example should mirror that mapping or it
teaches the wrong pattern. **drift**.
- The README was already partially refreshed earlier in this session;
re-check the "Implementation gaps → Done" list now that
`subscribe()`, `publishCausedBy()`, `invokeListener`, the depth
guard, the payload-cloneable check, the Svelte adapter, the
`createBusRecent` wrapper, the `bus-context.svelte.ts`, and the
`APP_EVENT_RUNTIMES` table all landed.
### `arts/sess/README.md`
- The README still references a `publishSessIdentityChanged` helper.
Actual export is `publishSessLifecycleEvent` in
`arts/sess/bus-helpers.ts`. Either rename in code or update the
README. **drift**.
### `arts/cach/README.md`
- README written in Spanish (a deliberate choice; not flagged as a
rule break). At lines 11–12 it suggests importing
`createEngineCache` from `$svrs/cach`, but the artifact's barrel
(`arts/cach/index.ts`) only exports `createActiveCache`. Add a one-
line note clarifying the engine vs active split. **drift**.
### Other artifacts (aapp, adom, auth, conn, fend, fmts, http, lang, logr, perm, sium, stor, timr)
Spot checks did not surface drift — claims match exports.
---
## Suggested fix order
1. **Coupling fixes** (Section 1, runtime violations). They constrain
every other refactor: until `fend → adom`, `sium → lang`, and
`conn → timr` are decoupled, those artifacts cannot be tested in
isolation.
2. **Naming convention pass** (Section 2). Mechanical, scoped to four
`consts.ts` files, and unblocks coherent log aggregation. Update
any test that hard-codes the literal values.
3. **Error guards + idempotent `dispose`** (Section 3). Low-risk and
localised; prevents subtle bugs if any consumer ever calls
`dispose()` twice or tries to discriminate auth errors.
4. **Logger defaults** (Section 3). Verify the three diagnostics
constructors and add `?? SILENT_LOGGER` where missing.
5. **Documentation refresh** (Section 5). Easiest after the code
changes above so the README reflects the final shape.
6. **Cast consolidation** (Section 4). Pure refactor, ship it whenever
convenient.
Items in Section 4 (vendor code, justified `any`, intentional cast)
require **no action**.

@ -9,7 +9,7 @@ import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from
import { createActiveFrontend } from '$fend/active-frontend.svelte';
import { createActiveFormats } from '$fmts/active-formats.svelte';
import { createEngineHttp } from '$http/engine-http';
import type { ActiveLang, LangNode } from '$lang/types';
import type { ActiveLang, LangNode } from '$libs/lang';
import { createActiveLang } from '$lang/active-lang.svelte';
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
import { createEngineLogger } from '$logr/engine-logger';

@ -1,5 +1,5 @@
import { LogLevel, type LogEntry, type LoggerOptions, type Transport } from '$logr';
import type { LangNode } from '$lang';
import type { LangNode } from '$libs/lang';
import { createActiveApp } from '../active-app.svelte';
import type { ActiveApp, ActiveAppOptions } from '../types';

@ -8,13 +8,14 @@ import type { FrontendPreferenceKey } from '$fend';
import type { ActiveFormats, ActiveFormatsOptions } from '$fmts';
import type { EngineHttp, EngineHttpOptions } from '$http';
import type { AppEventMap } from '$libs/aapp/events';
import type { ActiveLang, LangNode, SupportedLocale } from '$lang';
import type { ActiveLang, LangNode, SupportedLocale } from '$libs/lang';
import type { EngineLogger, LoggerOptions } from '$logr';
import type { ActivePermissions, ActivePermissionsOptions } from '$perm';
import type { ActiveSession, EngineSessionOptions, SessEventMap } from '$sess';
import type { EngineSium } from '$sium';
import type { ActiveStorage, SyncStorageAdapter } from '$stor';
import type { ActiveTimers, EngineTimersOptions } from '$timr';
import type { ActiveTimers } from '$timr';
import type { EngineTimersOptions } from '$libs/timers';
import type {
APP_ORCHESTRATION_SILENT,
APP_ORCHESTRATION_STANDARD,

@ -1,13 +1,15 @@
import { readableActive, type Active } from '$reactive';
import {
applyChange,
BREAKPOINTS_DEFAULT,
getCurrentBreakpoint,
removeAttrs,
resolveResponsiveProp,
type Breakpoint,
type Breakpoints,
type ResponsiveProp
type ResponsiveProp,
type StructuralChange
} from '$libs/dom';
import { applyChange, removeAttrs, type StructuralChange } from './apply.js';
import {
createViewportTracker,
getSharedViewport,

@ -7,8 +7,8 @@
export { createActiveDom } from './active-dom.svelte.js';
export type { ActiveDom, ActiveDomProps } from './active-dom.svelte.js';
export { applyChange, removeAttrs } from './apply.js';
export type { DomAttrValue, StructuralChange } from './apply.js';
export { applyChange, removeAttrs } from '$libs/dom';
export type { DomApplier, DomAttrValue, StructuralChange } from '$libs/dom';
export { BodyScrollLock } from './body-scroll-lock.svelte.js';
export type { BodyScrollLockOption } from './body-scroll-lock.svelte.js';

@ -12,8 +12,9 @@ import {
BusDisposedError,
BusInvalidPayloadError,
BusReentrancyLimitError,
logBusListenerFailure,
logBusListenerLeakWarning
createBusDiagnostics,
emitBusListenerFailureDiagnostic,
emitBusListenerLeakWarningDiagnostic
} from '$libs/buss';
import type {
BusEnvelope,
@ -52,6 +53,7 @@ export function createEngineBus<TEvents extends BusEventMap = BusEventMap>(
options: EngineBusOptions = {}
): EngineBus<TEvents> {
const logger = options.logger ?? SILENT_LOGGER;
const diagnostics = createBusDiagnostics(logger);
const clock = options.clock ?? DEFAULT_CLOCK;
const idFactory = options.idFactory ?? createIncrementalIdFactory(BUS_ID_PREFIX);
const maxListenersPerEvent =
@ -150,8 +152,8 @@ export function createEngineBus<TEvents extends BusEventMap = BusEventMap>(
function warnIfListenerLeak(type: string | typeof BUS_EVENT_ALL): void {
const count = listenerBucket(type).length;
if (count <= maxListenersPerEvent) return;
logBusListenerLeakWarning({
logger,
emitBusListenerLeakWarningDiagnostic({
diagnostics,
type,
count,
max: maxListenersPerEvent
@ -316,7 +318,7 @@ export function createEngineBus<TEvents extends BusEventMap = BusEventMap>(
};
errors.push(failure);
if (mode === BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE) {
logBusListenerFailure({ logger, envelope, failure });
emitBusListenerFailureDiagnostic({ diagnostics, envelope, failure });
}
}

@ -1,4 +1,4 @@
import type { TimerScheduler } from '$timr';
import type { TimerScheduler } from '$libs/timers';
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_REJECTED,

@ -17,7 +17,7 @@ import type {
} from './types.ts';
export function createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options: ActiveConnectionsOptions = {}
options: ActiveConnectionsOptions
): ActiveConnections<TConnections> {
const engine = createEngineConnections<TConnections>(options);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- detach callbacks are bookkeeping, not rendered state.

@ -1,4 +1,4 @@
import type { TimerScheduler } from '$timr';
import type { TimerScheduler } from '$libs/timers';
import { timerKey } from './helpers.ts';
export interface ConnectionTimerControls {

@ -1,4 +1,4 @@
import type { TimerScheduler } from '$timr';
import type { TimerScheduler } from '$libs/timers';
import { createConnectionAckRegistry } from './acks.ts';
import { wireBrowserReconnect } from './browser-reconnect.ts';
import { createConnectionChannelRegistry } from './channel-registry.ts';

@ -1,4 +1,3 @@
import { createEngineTimers } from '$timr';
import { createBusSessionSource } from './bus-session-source.ts';
import { createConnection } from './connection.ts';
import {
@ -81,16 +80,10 @@ function mergeConnectionOptions<TChannels extends ConnectionChannelMap>(
}
export function createEngineConnections<TConnections extends ConnectionMap = ConnectionMap>(
options: EngineConnectionsOptions = {}
options: EngineConnectionsOptions
): EngineConnections<TConnections> {
const connections = new Map<string, Connection>();
const timers =
options.timers ??
createEngineTimers({
clock: options.clock,
logger: options.logger
});
const ownsTimers = options.timers === undefined;
const { timers } = options;
const sessionSource =
options.session ??
(options.bus === undefined || !shouldWireBusSessionSource(options.autoReauthOn)
@ -182,7 +175,6 @@ export function createEngineConnections<TConnections extends ConnectionMap = Con
disposed = true;
for (const connection of connections.values()) connection.dispose();
connections.clear();
if (ownsTimers) timers.dispose();
}
};

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_IDLE,
@ -6,10 +6,20 @@ import {
createMockTransport
} from '../index.ts';
import { createActiveConnections } from '../active-connections.svelte.ts';
import { createEngineTimers } from '$timr';
import type { EngineTimers } from '$libs/timers';
let timers: EngineTimers;
beforeEach(() => {
timers = createEngineTimers();
});
afterEach(() => {
timers.dispose();
});
describe('createActiveConnections', () => {
it('tracks active names and connection state buckets', async () => {
const Connections = createActiveConnections();
const Connections = createActiveConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -48,7 +58,7 @@ describe('createActiveConnections', () => {
});
it('keeps active state updated when operations go through the active root', async () => {
const Connections = createActiveConnections();
const Connections = createActiveConnections({ timers });
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,

@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_TIMEOUT,
@ -26,7 +26,8 @@ import {
publishAppUserIdentityChanged,
type AppEventMap
} from '$libs/aapp/events';
import type { TimerClock } from '$timr';
import { createEngineTimers } from '$timr';
import type { EngineTimers, TimerClock } from '$libs/timers';
import type { ConnectionFrame, MockConnectionTransport } from '../index.ts';
function latestFrame(transport: MockConnectionTransport): ConnectionFrame {
@ -38,13 +39,18 @@ function emitFrame(transport: MockConnectionTransport, frame: ConnectionFrame):
transport.emitMessage(JSON.stringify(createFrame(frame)));
}
let timers: EngineTimers;
beforeEach(() => {
timers = createEngineTimers();
});
afterEach(() => {
timers.dispose();
vi.useRealTimers();
});
describe('Connection lifecycle', () => {
it('connects and disconnects through the transport', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -71,7 +77,8 @@ describe('Connection lifecycle', () => {
setTimeout: () => 0,
clearTimeout: () => {}
};
const Connections = createEngineConnections({ clock });
const localTimers = createEngineTimers({ clock });
const Connections = createEngineConnections({ timers: localTimers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -86,6 +93,7 @@ describe('Connection lifecycle', () => {
expect(Main.closedAt).toBe(4242);
Connections.dispose();
localTimers.dispose();
});
it('does not update closedAt when the transport repeats an intentional close', async () => {
@ -95,7 +103,8 @@ describe('Connection lifecycle', () => {
setTimeout: () => 0,
clearTimeout: () => {}
};
const Connections = createEngineConnections({ clock });
const localTimers = createEngineTimers({ clock });
const Connections = createEngineConnections({ timers: localTimers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -112,11 +121,12 @@ describe('Connection lifecycle', () => {
expect(Main.closedAt).toBe(100);
Connections.dispose();
localTimers.dispose();
});
it('does not reconnect after an intentional disconnect', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -135,7 +145,7 @@ describe('Connection lifecycle', () => {
it('reconnects after an unexpected close', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -159,7 +169,7 @@ describe('Connection lifecycle', () => {
it('closes the transport when connect-time auth fails', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -188,7 +198,7 @@ describe('Connection lifecycle', () => {
describe('Connection send/request', () => {
it('sends a serialized frame when open', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -210,7 +220,7 @@ describe('Connection send/request', () => {
});
it('fails fire-and-forget sends while closed unless buffering is enabled', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -225,7 +235,7 @@ describe('Connection send/request', () => {
});
it('buffers fire-and-forget frames and flushes on connect', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -249,7 +259,7 @@ describe('Connection send/request', () => {
});
it('resolves a request from an incoming replyTo frame', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -277,7 +287,7 @@ describe('Connection send/request', () => {
it('times out unanswered requests', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -294,7 +304,7 @@ describe('Connection send/request', () => {
});
it('closes pending requests on disconnect', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -313,7 +323,7 @@ describe('Connection send/request', () => {
describe('Connection channels', () => {
it('caches channel instances by name', () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -326,7 +336,7 @@ describe('Connection channels', () => {
});
it('routes topic frames to the matching channel', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -353,7 +363,7 @@ describe('Connection channels', () => {
});
it('join sends the standard channel join frame', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -376,7 +386,7 @@ describe('Connection channels', () => {
});
it('marks a disposed channel as left', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -399,7 +409,8 @@ describe('Connection session bridge', () => {
const Bus = createEngineBus<AppEventMap>();
const Connections = createEngineConnections({
bus: Bus,
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD
autoReauthOn: CONNECTION_AUTO_REAUTH_STANDARD,
timers
});
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
@ -432,7 +443,8 @@ describe('Connection session bridge', () => {
listener = undefined;
};
}
}
},
timers
});
const Main = Connections.createConnection('main', {
transport: createMockTransport(),

@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_OPEN,
@ -7,14 +7,21 @@ import {
isConnConnectionAlreadyExistsError,
isConnConnectionNotFoundError
} from '../index.ts';
import { createEngineTimers } from '$timr';
import type { EngineTimers } from '$libs/timers';
let timers: EngineTimers;
beforeEach(() => {
timers = createEngineTimers();
});
afterEach(() => {
timers.dispose();
vi.useRealTimers();
});
describe('createEngineConnections', () => {
it('creates, retrieves and lists named connections', () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
@ -30,7 +37,7 @@ describe('createEngineConnections', () => {
});
it('rejects duplicate names with a typed error', () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -51,7 +58,7 @@ describe('createEngineConnections', () => {
});
it('throws a typed error for missing names', () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
try {
Connections.connection('missing');
@ -63,7 +70,7 @@ describe('createEngineConnections', () => {
});
it('opens and closes every registered connection', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
@ -89,7 +96,7 @@ describe('createEngineConnections', () => {
});
it('keeps close() safe when the method is destructured', async () => {
const Connections = createEngineConnections();
const Connections = createEngineConnections({ timers });
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,

@ -1,6 +1,6 @@
import type { Logger } from '$libs/logr';
import type { AppEventBus } from '$libs/aapp/events';
import type { TimerClock, TimerScheduler } from '$timr';
import type { TimerScheduler } from '$libs/timers';
import type {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_INVALID_REPLY,
@ -357,8 +357,14 @@ export interface Connection<TChannels extends ConnectionChannelMap = ConnectionC
export interface EngineConnectionsOptions {
readonly logger?: Logger;
readonly timers?: TimerScheduler;
readonly clock?: TimerClock;
/**
* Timer scheduler driving heartbeats, ack timeouts, reconnect
* backoff and reauthentication windows. Required: `arts/conn` does
* not construct its own scheduler. Composition roots pass
* `App.Timers`; standalone callers pass `createEngineTimers()` (or
* a fake clock-driven double in tests).
*/
readonly timers: TimerScheduler;
readonly defaults?: Partial<ConnectionOptions>;
readonly bus?: AppEventBus;
readonly autoReauthOn?: ConnectionAutoReauthOn;

@ -1,5 +1,5 @@
import type { LocaleSource } from '$locale';
import { createActiveDom, type ActiveDom } from '$adom';
import { applyChange, type DomApplier } from '$libs/dom';
import { DEFAULT_DENSITY, DEFAULT_MODE, DEFAULT_THEME, FRONTEND_ATTRS } from './consts';
import { resolveDir } from './locale-defaults';
import type { Direction } from './locale-defaults';
@ -16,7 +16,15 @@ export type FrontendDirMode = Direction | 'auto';
export type FrontendLocaleSource = LocaleSource;
export type FrontendTarget = HTMLElement | (() => HTMLElement | null);
export type FrontendDom = Pick<ActiveDom, 'apply'>;
/**
* Anything that absorbs a `StructuralChange`. The full `ActiveDom`
* engine satisfies this; so does a plain object with an `apply()`
* method (test spies, custom batchers). When `Frontend` is used
* standalone without an external applier, it falls back to the bare
* `applyChange` helper from `$libs/dom`.
*/
export type FrontendDom = DomApplier;
export interface ActiveFrontendOptions {
locale?: string;
@ -79,9 +87,8 @@ function subscribeMedia(query: string, fn: (matches: boolean) => void): () => vo
export function createActiveFrontend(options: ActiveFrontendOptions = {}): ActiveFrontend {
const localeSource = options.localeSource;
const ownedDom =
options.applyDom === false || options.dom !== undefined ? undefined : createActiveDom();
const dom = options.applyDom === false ? undefined : (options.dom ?? ownedDom);
const dom: FrontendDom | undefined =
options.applyDom === false ? undefined : (options.dom ?? { apply: applyChange });
let currentLocale = $state(localeSource?.getLocale() ?? options.locale ?? '');
let dirOverride = $state<Direction | null>(
@ -227,7 +234,6 @@ export function createActiveFrontend(options: ActiveFrontendOptions = {}): Activ
unsubscribeLocale?.();
unsubscribeDarkMode();
unsubscribeReducedMotion();
ownedDom?.dispose();
listeners.clear();
}
};

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { StructuralChange } from '$libs/dom';
import { FRONTEND_ATTRS, createActiveFrontend } from '..';
import type { StructuralChange } from '../../adom';
describe('createActiveFrontend()', () => {
it('keeps auto and explicit values consistent across locale changes', () => {

@ -1,13 +1,13 @@
import { SvelteSet } from 'svelte/reactivity';
import {
type ActiveLang,
createEngineLang,
type EngineLang,
type LangNode,
type LangParams,
type LangString,
type SupportedLocale
} from './index';
import type {
ActiveLang,
EngineLang,
LangNode,
LangParams,
LangString,
SupportedLocale
} from '$libs/lang';
import { createEngineLang } from './engine-lang.ts';
import type { Logger } from '$libs/logr';
export type { LangNode, LangParams, LangString, SupportedLocale, ActiveLang };

@ -1,26 +1,28 @@
import type {
SupportedLocale,
LangBranch,
LangFn,
LangParams,
LangRecord,
LangString,
EngineLang,
LangNode
} from './types.ts';
import { LANG_ERRORS } from './errors.ts';
import { ID_PREFIX, LANG_DIAGNOSTIC_EVENTS, MAX_RESOLVE_DEPTH } from './consts.ts';
import { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
import { isLangRef, isLangRecord } from './guards.ts';
import {
createLangDiagnostics,
deepMerge,
emitLangDiagnostic,
ID_PREFIX,
interpolateTemplate,
resolvePath,
isLangBranch,
isLangRef,
isLangRecord,
LANG_DIAGNOSTIC_EVENTS,
LANG_ERRORS,
MAX_RESOLVE_DEPTH,
parseLangRef,
parsePathFallback,
deepMerge,
isLangBranch,
resolveLocaleInRecord
} from './helpers.ts';
resolveLocaleInRecord,
resolvePath,
type EngineLang,
type LangBranch,
type LangFn,
type LangNode,
type LangParams,
type LangRecord,
type LangString,
type SupportedLocale
} from '$libs/lang';
import { isDev as DEV } from '$libs/env';
import type { Logger } from '$libs/logr';

@ -1,71 +1,73 @@
// Public surface of the lang artifact. Named re-exports (not `export *`) so
// the bundler can statically prove which symbols are reached from a given
// import and tree-shake the rest. Active variants (`createActiveLang`,
// `createActiveMonoLang`) live in their own `.svelte.ts` files and must be
// imported from those paths directly so this barrel stays runes-free.
// Public surface of the lang artifact. The pure contracts (types,
// constants, helpers, plural rules, JSON serialization, diagnostics
// builders, error catalog) live in `$libs/lang`. This barrel mirrors
// them for back-compat — existing `import … from '$lang'` imports
// continue to resolve identically — and adds the runtime engine
// (`createEngineLang`).
//
// Active wrappers (`createActiveLang`, `createActiveMonoLang`) are not
// re-exported here on purpose; they live in `.svelte.ts` files and must
// be imported from those paths directly so this barrel stays
// runes-free.
export {
ID_PREFIX,
asLangString,
createLangDiagnostics,
deepMerge,
emitLangDiagnostic,
getPluralConfig,
ID_FALLBACK_SEPARATOR,
ID_PREFIX,
interpolateTemplate,
isLangBranch,
isLangRef,
isLangRecord,
isLangString,
JSONToLangNode,
LANG_DIAGNOSTIC_EVENTS,
LANG_ERRORS,
langNodeToJSON,
LOGGER_CATEGORY,
makeLangRecord,
MAX_RESOLVE_DEPTH,
MONO_LANG_CATEGORY
} from './consts.ts';
export { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
export { isLangRef, isLangRecord, isLangString } from './guards.ts';
MONO_LANG_CATEGORY,
p,
parseLangRef,
parsePathFallback,
pluralRule,
resolveLocaleInRecord,
resolvePath,
resolveRecordFallback
} from '$libs/lang';
export type {
ActiveLang,
EngineLang,
GetTypeAtPath,
HasParams,
LangBase,
SupportedLocale,
LangRef,
LangRecord,
LangParams,
LangFn,
LangPluralFn,
LangValue,
LangBranch,
LangDiagnosticEvent,
LangDiagnosticMeta,
LangDiagnostics,
LangDiagnosticType,
LangFn,
LangJSONObject,
LangJSONValue,
LangNode,
LangParams,
LangPluralFn,
LangRecord,
LangRef,
LangString,
LangValue,
LeafPaths,
GetTypeAtPath,
ParamsFor,
HasParams,
PluralForms,
PluralCategory,
PluralConfig,
EngineLang,
ActiveLang
} from './types.ts';
export type {
LangDiagnosticEvent,
LangDiagnosticMeta,
LangDiagnostics,
LangDiagnosticType
} from './diagnostics.ts';
export { pluralRule } from './plural_rules.ts';
export type { PluralCategory } from './plural_rules.ts';
export { p, getPluralConfig } from './plural.ts';
export {
resolvePath,
parseLangRef,
parsePathFallback,
makeLangRecord,
asLangString,
interpolateTemplate,
resolveRecordFallback,
resolveLocaleInRecord,
deepMerge,
isLangBranch
} from './helpers.ts';
export type { RecordLookup } from './helpers.ts';
PluralForms,
RecordLookup,
SupportedLocale
} from '$libs/lang';
export { createEngineLang } from './engine-lang.ts';
export { langNodeToJSON, JSONToLangNode } from './json.ts';
export type { LangJSONValue, LangJSONObject } from './json.ts';
export { LANG_ERRORS } from './errors.ts';

@ -1,19 +1,24 @@
import { SvelteSet } from 'svelte/reactivity';
import { interpolateTemplate, parseLangRef, parsePathFallback } from './helpers.ts';
import { isLangRecord, isLangRef } from './guards.ts';
import {
createLangDiagnostics,
emitLangDiagnostic,
interpolateTemplate,
isLangRecord,
isLangRef,
LANG_DIAGNOSTIC_EVENTS,
MONO_LANG_CATEGORY,
parseLangRef,
parsePathFallback,
type ActiveLang,
type LangNode,
type LangParams,
type LangString,
type SupportedLocale
} from '$libs/lang';
import { isDev as DEV } from '$libs/env';
import { LANG_DIAGNOSTIC_EVENTS, MONO_LANG_CATEGORY } from './consts.ts';
import { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
import type {
ActiveLang,
LangNode,
LangParams,
LangString,
SupportedLocale
} from './types.ts';
import type { Logger } from '$libs/logr';
export { MONO_LANG_CATEGORY } from './consts.ts';
export { MONO_LANG_CATEGORY } from '$libs/lang';
/**
* Default initial locale for `createActiveMonoLang()`. The mono lang does not

@ -13,22 +13,27 @@
*/
import { describe, it, expect, vi } from 'vitest';
import { isLangRef, isLangRecord, isLangString } from '../guards.ts';
import {
deepMerge,
getPluralConfig,
interpolateTemplate,
isLangRef,
isLangRecord,
isLangString,
JSONToLangNode,
langNodeToJSON,
makeLangRecord,
p,
parseLangRef,
parsePathFallback,
resolvePath,
makeLangRecord,
interpolateTemplate,
pluralRule,
resolveLocaleInRecord,
resolvePath,
resolveRecordFallback,
deepMerge
} from '../helpers.ts';
import { pluralRule } from '../plural_rules.ts';
import { p, getPluralConfig } from '../plural.ts';
type LangBranch,
type LangNode
} from '$libs/lang';
import { createEngineLang } from '../engine-lang.ts';
import { langNodeToJSON, JSONToLangNode } from '../json.ts';
import type { LangNode, LangBranch } from '../types.ts';
import type { Logger } from '$libs/logr';
function createTestLogger(overrides: Partial<Logger> = {}): Logger {

@ -1,5 +1,10 @@
import type { EngineLang, LangParams, SupportedLocale } from '$lang';
import { ID_FALLBACK_SEPARATOR, parseLangRef } from '$lang';
import {
ID_FALLBACK_SEPARATOR,
parseLangRef,
type EngineLang,
type LangParams,
type SupportedLocale
} from '$libs/lang';
import { SIUM_DIAGNOSTIC_EVENTS } from './consts';
import { emitSiumDiagnostic, type SiumDiagnostics } from './diagnostics';
import { resolve as resolveFallbackTemplate } from './langs';

@ -1,4 +1,4 @@
import type { EngineLang, LangParams, SupportedLocale } from '$lang';
import type { EngineLang, LangParams, SupportedLocale } from '$libs/lang';
import type { Logger } from '$libs/logr';
import { createSiumDiagnostics } from './diagnostics';
import { createSiumResolver } from './engine-resolver';

@ -1,5 +1,4 @@
import type { LangNode } from '$lang';
import { p } from '$lang/plural';
import { p, type LangNode } from '$libs/lang';
const lengthPlural = p({
en: {

@ -15,7 +15,7 @@
*/
import { createEngineTimers } from './engine-timers.ts';
import type { EngineTimers, EngineTimersOptions, TimerEntrySnapshot } from './types.ts';
import type { EngineTimers, EngineTimersOptions, TimerEntrySnapshot } from '$libs/timers';
export interface ActiveTimers extends EngineTimers {
readonly size: number;

@ -1,4 +1,4 @@
import type { TimerClock, TimerNativeHandle } from './types.ts';
import type { TimerClock, TimerNativeHandle } from '$libs/timers';
/**
* Default `TimerClock` backed by `globalThis.setTimeout` /

@ -1,74 +1,39 @@
/**
* Public constants for `arts/timr`.
* Engine-internal constants for `arts/timr`.
*
* Per the locked-in rule (no hardcoded literals in engine bodies), every
* status / kind / discriminator string the engine returns lives here as
* a named export. The corresponding TypeScript types in `types.ts`
* derive from these via `typeof CONST` so the literal lives in one
* place.
* Public values that appear in the artifact's type contract live in
* `$libs/timers` (re-exported here for back-compat so existing
* `import … from './consts.ts'` calls within the engine keep
* resolving). Engine-local strings (method names, error names, error
* message prefixes, log message prefixes) stay in this file.
*/
/** Logger category emitted by the engine. */
export const LOGGER_CATEGORY = 'timr';
export const TIMER_DIAGNOSTIC_EVENTS = {
LISTENER_THREW: 'listener_threw',
SCHEDULE_AT_PAST: 'schedule_at_past',
TASK_FAILED: 'task_failed'
} as const;
export {
DEFAULT_TIMER_SCOPE_SEPARATOR,
LOGGER_CATEGORY,
TIMER_DIAGNOSTIC_EVENTS,
TIMER_EVENT_CANCELLED,
TIMER_EVENT_COMPLETED,
TIMER_EVENT_DISPOSED,
TIMER_EVENT_FAILED,
TIMER_EVENT_RUNNING,
TIMER_EVENT_SCHEDULED,
TIMER_KIND_INTERVAL,
TIMER_KIND_TIMEOUT,
TIMER_KINDS,
TIMER_STATUS_CANCELLED,
TIMER_STATUS_COMPLETED,
TIMER_STATUS_FAILED,
TIMER_STATUS_PENDING,
TIMER_STATUS_RUNNING,
TIMER_STATUSES
} from '$libs/timers';
export const ENGINE_METHOD_SCHEDULE = 'schedule';
export const ENGINE_METHOD_SCHEDULE_AT = 'scheduleAt';
export const ENGINE_METHOD_CANCEL = 'cancel';
export const ENGINE_METHOD_CANCEL_ALL = 'cancelAll';
// ── Timer status ───────────────────────────────────────────────────────────
//
// Discrete values of `TimerStatus`. The engine moves an entry through
// `pending → running → (completed | failed)` for one-shots, and back to
// `pending` for intervals between ticks. `cancelled` is terminal.
export const TIMER_STATUS_PENDING = 'pending';
export const TIMER_STATUS_RUNNING = 'running';
export const TIMER_STATUS_CANCELLED = 'cancelled';
export const TIMER_STATUS_COMPLETED = 'completed';
export const TIMER_STATUS_FAILED = 'failed';
export const TIMER_STATUSES = [
TIMER_STATUS_PENDING,
TIMER_STATUS_RUNNING,
TIMER_STATUS_CANCELLED,
TIMER_STATUS_COMPLETED,
TIMER_STATUS_FAILED
] as const;
// ── Timer kind ─────────────────────────────────────────────────────────────
export const TIMER_KIND_TIMEOUT = 'timeout';
export const TIMER_KIND_INTERVAL = 'interval';
export const TIMER_KINDS = [TIMER_KIND_TIMEOUT, TIMER_KIND_INTERVAL] as const;
// ── Event types ────────────────────────────────────────────────────────────
//
// Events emitted via `EngineTimers.onChange`. Every state transition
// surfaces as exactly one of these. The Active wrapper subscribes to
// the same channel external listeners use — no parallel notification
// path.
// Timer event names. Scoped with the `timr.` prefix for log/devtool
// disambiguation against cache, connection or any other emitter.
export const TIMER_EVENT_SCHEDULED = 'timr.scheduled';
export const TIMER_EVENT_RUNNING = 'timr.running';
export const TIMER_EVENT_COMPLETED = 'timr.completed';
export const TIMER_EVENT_FAILED = 'timr.failed';
export const TIMER_EVENT_CANCELLED = 'timr.cancelled';
export const TIMER_EVENT_DISPOSED = 'timr.disposed';
// ── Scope helpers ──────────────────────────────────────────────────────────
export const DEFAULT_TIMER_SCOPE_SEPARATOR = ':';
// ── Backoff defaults ───────────────────────────────────────────────────────
export {

@ -14,7 +14,7 @@ import {
taskFailedMessage
} from './consts.ts';
import type { Logger } from '$libs/logr';
import type { TimerEvent } from './types.ts';
import type { TimerEvent } from '$libs/timers';
export type TimerDiagnosticType =
(typeof TIMER_DIAGNOSTIC_EVENTS)[keyof typeof TIMER_DIAGNOSTIC_EVENTS];

@ -62,7 +62,7 @@ import type {
TimerKind,
TimerOptions,
TimerTask
} from './types.ts';
} from '$libs/timers';
export function createEngineTimers(options: EngineTimersOptions = {}): EngineTimers {
const clock: TimerClock = options.clock ?? createSystemTimerClock();

@ -70,4 +70,4 @@ export type {
TimerStatus,
TimerTask,
TimerTaskContext
} from './types.ts';
} from '$libs/timers';

@ -21,7 +21,7 @@ import {
isTimrInvalidDelayError,
isTimrInvalidKeyError
} from '../errors.ts';
import type { TimerClock, TimerEvent, TimerNativeHandle, TimerTaskContext } from '../types.ts';
import type { TimerClock, TimerEvent, TimerNativeHandle, TimerTaskContext } from '$libs/timers';
// ── Fake clock ──────────────────────────────────────────────────────────────

@ -4,7 +4,7 @@ import {
timerEntrySnapshot,
type InternalTimerEntry
} from './timer-entry.ts';
import type { TimerClock, TimerEvent } from './types.ts';
import type { TimerClock, TimerEvent } from '$libs/timers';
export interface CancelTimerEntryInput {
readonly entry: InternalTimerEntry;

@ -6,7 +6,7 @@ import type {
TimerStatus,
TimerTask,
TimerTaskContext
} from './types.ts';
} from '$libs/timers';
export interface InternalTimerEntry {
id: number;

@ -1,6 +1,6 @@
import { TIMER_DIAGNOSTIC_EVENTS } from './consts.ts';
import { emitTimerDiagnostic, type TimerDiagnostics } from './diagnostics.ts';
import type { TimerEvent, TimerListener } from './types.ts';
import type { TimerEvent, TimerListener } from '$libs/timers';
export interface TimerEventBus {
emit(event: TimerEvent): void;

@ -2,7 +2,7 @@ import { inactiveTimerErrorMessage, TIMER_STATUS_PENDING, TIMER_STATUS_RUNNING }
import { TimrInactiveTimerError } from './errors.ts';
import type { InternalTimerEntry } from './timer-entry.ts';
import { assertTimerDelay } from './timer-validation.ts';
import type { TimerClock, TimerHandle } from './types.ts';
import type { TimerClock, TimerHandle } from '$libs/timers';
export interface TimerHandleRuntime {
readonly entries: Map<string, InternalTimerEntry>;

@ -18,7 +18,7 @@ import {
timerEntrySnapshot,
type InternalTimerEntry
} from './timer-entry.ts';
import type { TimerClock, TimerEvent } from './types.ts';
import type { TimerClock, TimerEvent } from '$libs/timers';
interface TimerRunnerOptions {
readonly entries: Map<string, InternalTimerEntry>;

@ -1,37 +1,92 @@
import {
BUS_DIAGNOSTIC_EVENTS,
BUS_LOG_CATEGORY,
BUS_LOG_MSG_LISTENER_FAILED,
BUS_LOG_MSG_LISTENER_LEAK_WARNING
} from './consts.ts';
import type { BusEnvelope, BusListenerFailure } from './types.ts';
import type { Logger } from '$libs/logr';
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logr';
export type BusDiagnosticType =
| typeof BUS_DIAGNOSTIC_EVENTS.LISTENER_FAILED
| typeof BUS_DIAGNOSTIC_EVENTS.LISTENER_LEAK_WARNING;
export type BusDiagnosticMeta =
| {
readonly envelope: BusEnvelope;
readonly listenerId?: string;
readonly eventType: string;
readonly error: unknown;
}
| {
readonly type: string;
readonly count: number;
readonly max: number;
};
export type BusDiagnosticEvent = DiagnosticEvent<BusDiagnosticType, BusDiagnosticMeta>;
export type BusDiagnostics = Diagnostics<BusDiagnosticEvent>;
const BUS_DIAGNOSTIC_LOGS: DiagnosticCatalog<BusDiagnosticEvent> = {
[BUS_DIAGNOSTIC_EVENTS.LISTENER_FAILED]: {
level: LogLevel.ERROR,
message: BUS_LOG_MSG_LISTENER_FAILED
},
[BUS_DIAGNOSTIC_EVENTS.LISTENER_LEAK_WARNING]: {
level: LogLevel.WARN,
message: BUS_LOG_MSG_LISTENER_LEAK_WARNING
}
};
export function createBusDiagnostics(logger?: Logger): BusDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: BUS_LOG_CATEGORY,
catalog: BUS_DIAGNOSTIC_LOGS
});
}
export function emitBusDiagnostic(
diagnostics: BusDiagnostics,
type: BusDiagnosticType,
meta: BusDiagnosticMeta
): void {
diagnostics.emit({
artifact: BUS_LOG_CATEGORY,
type,
meta
});
}
export function logBusListenerFailure(input: {
readonly logger: Logger;
export function emitBusListenerFailureDiagnostic(input: {
readonly diagnostics: BusDiagnostics;
readonly envelope: BusEnvelope;
readonly failure: BusListenerFailure;
}): void {
input.logger.error(BUS_LOG_CATEGORY, BUS_LOG_MSG_LISTENER_FAILED, {
error: input.failure.error,
context: {
envelope: input.envelope,
listenerId: input.failure.listenerId,
eventType: input.failure.type
}
emitBusDiagnostic(input.diagnostics, BUS_DIAGNOSTIC_EVENTS.LISTENER_FAILED, {
envelope: input.envelope,
...(input.failure.listenerId !== undefined ? { listenerId: input.failure.listenerId } : {}),
eventType: input.failure.type,
error: input.failure.error
});
}
export function logBusListenerLeakWarning(input: {
readonly logger: Logger;
export function emitBusListenerLeakWarningDiagnostic(input: {
readonly diagnostics: BusDiagnostics;
readonly type: string;
readonly count: number;
readonly max: number;
}): void {
input.logger.warn(BUS_LOG_CATEGORY, BUS_LOG_MSG_LISTENER_LEAK_WARNING, {
context: {
type: input.type,
count: input.count,
max: input.max
}
emitBusDiagnostic(input.diagnostics, BUS_DIAGNOSTIC_EVENTS.LISTENER_LEAK_WARNING, {
type: input.type,
count: input.count,
max: input.max
});
}

@ -49,7 +49,18 @@ export {
isBusReentrancyLimitError
} from './errors.ts';
export { logBusListenerFailure, logBusListenerLeakWarning } from './diagnostics.ts';
export {
createBusDiagnostics,
emitBusDiagnostic,
emitBusListenerFailureDiagnostic,
emitBusListenerLeakWarningDiagnostic
} from './diagnostics.ts';
export type {
BusDiagnosticEvent,
BusDiagnosticMeta,
BusDiagnostics,
BusDiagnosticType
} from './diagnostics.ts';
export { isBusAnyEvent } from './matching.ts';
export { SILENT_BUS } from './silent-bus.ts';

@ -1,8 +1,10 @@
/**
* `apply` / `remove` — the only mutation surface of `ActiveDom`.
* `apply` / `removeAttrs` — pure DOM attribute writers.
*
* `ActiveDom` doesn't know product frameworks. It receives instructions
* already resolved by upper layers and applies them to the DOM. Nothing else.
* Lives in `libs/dom` because it has no runtime state, no Svelte
* reactivity, and no dependency on other artifacts. Both the `arts/adom`
* engine (for its `apply()` method) and `arts/fend` (when no
* `ActiveDom` is wired) consume these helpers directly.
*
* Contract:
* - `string | number` → `setAttribute(name, String(value))`
@ -12,7 +14,7 @@
* No event dispatch, no batching, no observation. Pure mutations.
*/
import { isBrowser } from '$libs/dom';
import { isBrowser } from './core.ts';
export type DomAttrValue = string | number | boolean | null | undefined;
@ -21,6 +23,15 @@ export type StructuralChange = {
attrs: Record<string, DomAttrValue>;
};
/**
* Minimal applier contract. Anything that can absorb a
* `StructuralChange` qualifies — the full `ActiveDom` engine, a test
* spy, a no-op stub, or the bare `applyChange` helper exposed below.
*/
export interface DomApplier {
apply(change: StructuralChange): void;
}
function writeAttr(target: HTMLElement, name: string, value: DomAttrValue): void {
if (value === false || value === null || value === undefined) {
target.removeAttribute(name);

@ -1,3 +1,4 @@
export * from './apply'
export * from './core'
export * from './errors'
export * from './focus'

@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { applyChange, removeAttrs } from '../apply';
import { applyChange, removeAttrs } from '$libs/dom';
describe('apply', () => {
let target: HTMLElement;

@ -0,0 +1,79 @@
/**
* `libs/lang` — pure contracts and helpers for the lang artifact.
*
* Mirrors the `libs/buss` / `libs/logr` / `libs/dom` pattern: this module
* exports interfaces, generic constants, error catalogs, pure helpers,
* plural rules, JSON serialization, and diagnostics builders. It does
* NOT include the engine implementation; that lives in
* `arts/lang/engine-lang.ts` and the active wrappers in
* `arts/lang/active-lang.svelte.ts` / `arts/lang/mono-lang.svelte.ts`.
*
* Modules that need types or pure helpers from lang import from
* `$libs/lang`. Only the composition root (`aapp`) and tests touch
* `$lang` to reach the engine factories.
*/
export {
ID_FALLBACK_SEPARATOR,
ID_PREFIX,
LANG_DIAGNOSTIC_EVENTS,
LOGGER_CATEGORY,
MAX_RESOLVE_DEPTH,
MONO_LANG_CATEGORY
} from './consts.ts';
export { createLangDiagnostics, emitLangDiagnostic } from './diagnostics.ts';
export type {
LangDiagnosticEvent,
LangDiagnosticMeta,
LangDiagnostics,
LangDiagnosticType
} from './diagnostics.ts';
export { LANG_ERRORS } from './errors.ts';
export { isLangRef, isLangRecord, isLangString } from './guards.ts';
export {
asLangString,
deepMerge,
interpolateTemplate,
isLangBranch,
makeLangRecord,
parseLangRef,
parsePathFallback,
resolveLocaleInRecord,
resolvePath,
resolveRecordFallback
} from './helpers.ts';
export type { RecordLookup } from './helpers.ts';
export { JSONToLangNode, langNodeToJSON } from './json.ts';
export type { LangJSONObject, LangJSONValue } from './json.ts';
export { getPluralConfig, p } from './plural.ts';
export { pluralRule } from './plural_rules.ts';
export type { PluralCategory } from './plural_rules.ts';
export type {
ActiveLang,
EngineLang,
GetTypeAtPath,
HasParams,
LangBase,
LangBranch,
LangFn,
LangNode,
LangParams,
LangPluralFn,
LangRecord,
LangRef,
LangString,
LangValue,
LeafPaths,
ParamsFor,
PluralConfig,
PluralForms,
SupportedLocale
} from './types.ts';

@ -0,0 +1,66 @@
/**
* Public constants of the timers contract.
*
* Mirrors the `libs/buss` / `libs/lang` pattern: the discrete values
* exposed in public types live here so consumers can import them
* without crossing into `arts/timr`. Engine-internal constants (error
* messages, method names, log message prefixes) stay in
* `arts/timr/consts.ts`.
*/
/** Logger category emitted by the engine. */
export const LOGGER_CATEGORY = 'timr';
export const TIMER_DIAGNOSTIC_EVENTS = {
LISTENER_THREW: 'listener_threw',
SCHEDULE_AT_PAST: 'schedule_at_past',
TASK_FAILED: 'task_failed'
} as const;
// ── Timer status ───────────────────────────────────────────────────────────
//
// Discrete values of `TimerStatus`. The engine moves an entry through
// `pending → running → (completed | failed)` for one-shots, and back to
// `pending` for intervals between ticks. `cancelled` is terminal.
export const TIMER_STATUS_PENDING = 'pending';
export const TIMER_STATUS_RUNNING = 'running';
export const TIMER_STATUS_CANCELLED = 'cancelled';
export const TIMER_STATUS_COMPLETED = 'completed';
export const TIMER_STATUS_FAILED = 'failed';
export const TIMER_STATUSES = [
TIMER_STATUS_PENDING,
TIMER_STATUS_RUNNING,
TIMER_STATUS_CANCELLED,
TIMER_STATUS_COMPLETED,
TIMER_STATUS_FAILED
] as const;
// ── Timer kind ─────────────────────────────────────────────────────────────
export const TIMER_KIND_TIMEOUT = 'timeout';
export const TIMER_KIND_INTERVAL = 'interval';
export const TIMER_KINDS = [TIMER_KIND_TIMEOUT, TIMER_KIND_INTERVAL] as const;
// ── Event types ────────────────────────────────────────────────────────────
//
// Events emitted via `EngineTimers.onChange`. Every state transition
// surfaces as exactly one of these. The Active wrapper subscribes to
// the same channel external listeners use — no parallel notification
// path.
//
// Names are scoped with the `timr.` prefix for log/devtool
// disambiguation against cache, connection or any other emitter.
export const TIMER_EVENT_SCHEDULED = 'timr.scheduled';
export const TIMER_EVENT_RUNNING = 'timr.running';
export const TIMER_EVENT_COMPLETED = 'timr.completed';
export const TIMER_EVENT_FAILED = 'timr.failed';
export const TIMER_EVENT_CANCELLED = 'timr.cancelled';
export const TIMER_EVENT_DISPOSED = 'timr.disposed';
// ── Scope helpers ──────────────────────────────────────────────────────────
export const DEFAULT_TIMER_SCOPE_SEPARATOR = ':';

@ -1,2 +1,4 @@
export * from './debounce.ts';
export * from './backoff.ts';
export * from './consts.ts';
export * from './debounce.ts';
export * from './types.ts';

@ -1,14 +1,13 @@
/**
* Public type contract of `arts/timr`.
* Public type contract of the timers ecosystem.
*
* `arts/timr` is a runtime timer scheduler. Singleton-per-App, never
* module-global. Engine layer is runes-free; the Active wrapper layers
* `$state` on top in `active-timers.svelte.ts`.
*
* Other artifacts (`sess`, `conn`, future `cache`/`authn`) consume the
* minimal `TimerScheduler` interface so they accept either an
* `EngineTimers`, an `ActiveTimers`, or a fake clock-driven double in
* tests — none of them depend on Svelte runtime.
* `arts/timr` is a runtime timer scheduler. The engine layer is
* runes-free; the Active wrapper layers `$state` on top in
* `arts/timr/active-timers.svelte.ts`. Consumers (`sess`, `conn`,
* future engines) depend on the minimal `TimerScheduler` interface
* exported here so they accept any compliant implementation —
* `EngineTimers`, `ActiveTimers`, or a fake clock-driven double in
* tests — without depending on Svelte runtime.
*/
import type { Logger } from '$libs/logr';
@ -28,7 +27,7 @@ import type {
TIMER_STATUS_RUNNING
} from './consts.ts';
export type { BackoffOptions } from '$libs/timers';
export type { BackoffOptions } from './backoff.ts';
// ============================================================================
// CLOCK
Loading…
Cancel
Save

Powered by TurnKey Linux.