Bloque C4 — service factories wire `clock` from `App.Timers`

Audit P2: cache, perm and storage already accepted `clock` in
their engine options, but their `defineActive*` factories only
threaded `logger` from the core. App-composed apps therefore fell
back to `Date.now`-backed clocks for TTL math, decision-cache
expiration and envelope expiration — out of band with the rest of
the ecosystem.

- `defineActiveCache` now declares `'timers'` as a core dependency
  and passes `clock: { now: () => core.timers.clock.now() }` (only
  when the user didn't override it themselves).
- `defineActivePerm` does the same for the perm client's decision
  cache TTL.
- `defineActiveStorage` does the same for envelope TTL. The
  underlying engine gains a real `EngineStorageOptions.clock`
  field (resolved to `Date.now` when omitted) and threads it
  through `entry-runtime.ts`'s `encodeEnvelope` /
  `decodeEnvelope` calls. New regression test pins the behaviour:
  two engines on the same adapter with different clocks see TTL
  through their own clock.

Format / rates: `createRates({ now })` was already injectable;
the format engine itself doesn't read `Date.now` anywhere. The
audit's note about format/rates clock injection was about user
documentation, not factory wiring.

Suite: 1512 / 1512 (+1 storage clock test).

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

@ -8,20 +8,23 @@ import type { AppServiceFactory } from '../services.ts';
*
* 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`.
* `arts/active-app/presets/`). The factory wires `logger` and
* `clock` from the core — TTL evaluation and any other now-based
* math then flow through `App.Timers.clock`, the same time source
* the rest of the ecosystem uses.
*/
export function defineActiveCache(
options: Omit<ActiveCacheOptions, 'logger'> = {}
): AppServiceFactory<'cache', readonly ['logger'], readonly [], ActiveCache> {
): AppServiceFactory<'cache', readonly ['logger', 'timers'], readonly [], ActiveCache> {
return {
name: 'cache',
coreDependencies: ['logger'],
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActiveCache {
return createActiveCache({
...options,
logger: core.logger
logger: core.logger,
clock: options.clock ?? { now: () => core.timers.clock.now() }
});
},
dispose(instance) {

@ -10,21 +10,23 @@ import type { AppServiceFactory } from '../services.ts';
* `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
* without a backend.
* The factory wires `logger` and `clock` from the core, so decision
* cache TTL math runs on `App.Timers.clock`. The application still
* needs to provide `endpoint` (or a custom `fetcher`) — the perm
* client cannot work without a backend.
*/
export function defineActivePerm(
options: Omit<ActivePermsOptions, 'logger'>
): AppServiceFactory<'perm', readonly ['logger'], readonly [], ActivePerms> {
): AppServiceFactory<'perm', readonly ['logger', 'timers'], readonly [], ActivePerms> {
return {
name: 'perm',
coreDependencies: ['logger'],
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActivePerms {
return createActivePerms({
...options,
logger: core.logger
logger: core.logger,
clock: options.clock ?? { now: () => core.timers.clock.now() }
});
},
dispose(instance) {

@ -4,22 +4,22 @@ import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveStorage(options)` produces a service factory for the
* `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.
* `storage` slot. Wires `logger` and `clock` from the core so
* envelope TTL math runs through `App.Timers.clock` — the same
* time source the rest of the ecosystem uses.
*/
export function defineActiveStorage(
options: Omit<EngineStorageOptions, 'logger'> = {}
): AppServiceFactory<'storage', readonly ['logger'], readonly [], ActiveStorage> {
): AppServiceFactory<'storage', readonly ['logger', 'timers'], readonly [], ActiveStorage> {
return {
name: 'storage',
coreDependencies: ['logger'],
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActiveStorage {
return createActiveStorage({
...options,
logger: core.logger
logger: core.logger,
clock: options.clock ?? { now: () => core.timers.clock.now() }
});
},
dispose(instance) {

@ -36,6 +36,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
const namespace = options.namespace;
const diagnostics = createStorageDiagnostics(options.logger);
const onError = options.onError;
const now: () => number = options.clock ? () => options.clock!.now() : () => Date.now();
const bus: EntryBus = createEntryBus();
const adapterRegistry = createStorageAdapterRegistry(bus);
const defaultsRegistry = createStorageDefaultsRegistry();
@ -84,7 +85,8 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
defaults,
entryOptions,
bus,
report
report,
now
});
const { initialDefault } = runtime;

@ -32,6 +32,13 @@ interface StorageEntryRuntimeOptions<T> {
readonly entryOptions: StorageEntryOptions<T>;
readonly bus: EntryBus;
readonly report: StorageEntryReport;
/**
* Engine-level clock used for TTL math when the entry has
* `ttlMs`. `entry-runtime` itself doesn't implement a default —
* the engine resolves it from `EngineStorageOptions.clock`
* (host-default `Date.now`) before constructing the runtime.
*/
readonly now: () => number;
}
export interface StorageEntryRuntime<T> {
@ -47,7 +54,7 @@ export interface StorageEntryRuntime<T> {
export function createStorageEntryRuntime<T>(
options: StorageEntryRuntimeOptions<T>
): StorageEntryRuntime<T> {
const { key, fullKey, busKey, adapter, defaults, entryOptions, bus, report } = options;
const { key, fullKey, busKey, adapter, defaults, entryOptions, bus, report, now } = options;
const values = createStorageEntryValueRuntime(defaults, entryOptions);
const { initialDefault, serializer } = values;
const isRaw = entryOptions.raw === true;
@ -73,7 +80,7 @@ export function createStorageEntryRuntime<T>(
report({ key, fullKey, op: STORAGE_OP_SERIALIZE, error }, adapter.name);
return;
}
const payload = isRaw ? serialized : encodeEnvelope(serialized, version, ttlMs);
const payload = isRaw ? serialized : encodeEnvelope(serialized, version, ttlMs, now());
persistRaw(payload);
}
@ -108,7 +115,7 @@ export function createStorageEntryRuntime<T>(
}
}
const decoded = decodeEnvelope(rawString);
const decoded = decodeEnvelope(rawString, now());
switch (decoded.kind) {
case 'envelope': {
if (decoded.version !== version) {
@ -162,7 +169,7 @@ export function createStorageEntryRuntime<T>(
return false;
}
}
const decoded = decodeEnvelope(stored);
const decoded = decodeEnvelope(stored, now());
if (decoded.kind === 'expired' || decoded.kind === 'invalid') return false;
if (decoded.kind === 'legacy') return entryOptions.migrate !== undefined;
try {

@ -550,6 +550,31 @@ describe('createEngineStorage — dispose', () => {
expect(() => s.dispose()).not.toThrow();
});
it('honors an injected clock for envelope TTL math', () => {
// Two engines on the same adapter, but the second one starts
// "later" — the entry written by the first one should look
// expired through the second's clock.
const adapter = createMemoryAdapter();
let now = 1_000;
const engineA = createEngineStorage({
adapter,
clock: { now: () => now }
});
const a = engineA.entry('preference', 'one', { ttlMs: 100 });
a.set('two');
// Move clock past the TTL boundary.
now = 5_000;
const engineB = createEngineStorage({
adapter,
clock: { now: () => now }
});
const b = engineB.entry('preference', 'one', { ttlMs: 100 });
expect(b.get()).toBe('one'); // expired → defaults
engineA.dispose();
engineB.dispose();
});
it('throws StorageDisposedError on entry/clear/entries after dispose', () => {
const s = createEngineStorage({ adapter: createMemoryAdapter() });
s.dispose();

@ -162,6 +162,13 @@ export interface EngineStorageOptions {
namespace?: string;
logger?: Logger;
onError?: StorageErrorHandler;
/**
* Injectable clock used by envelope TTL evaluation. Defaults to a
* `Date.now`-backed clock; the App composition wires
* `core.timers.clock` so all time flows through `App.Timers`. Tests
* inject a fake clock for deterministic TTL boundaries.
*/
clock?: { now(): number };
}
/**

Loading…
Cancel
Save

Powered by TurnKey Linux.