From 6b6a96fb11502a5437360f9926c4b26fe37d1b96 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 4 May 2026 23:45:59 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20C4=20=E2=80=94=20service=20factories?= =?UTF-8?q?=20wire=20`clock`=20from=20`App.Timers`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../active-app/service-factories/cache.ts | 13 ++++++---- src/arts/active-app/service-factories/perm.ts | 14 ++++++----- .../active-app/service-factories/storage.ts | 14 +++++------ src/arts/storage/engine-storage.ts | 4 ++- src/arts/storage/entry-runtime.ts | 15 ++++++++--- src/arts/storage/test/engine-storage.test.ts | 25 +++++++++++++++++++ src/arts/storage/types.ts | 7 ++++++ 7 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/arts/active-app/service-factories/cache.ts b/src/arts/active-app/service-factories/cache.ts index d48d427..3c7e495 100644 --- a/src/arts/active-app/service-factories/cache.ts +++ b/src/arts/active-app/service-factories/cache.ts @@ -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 = {} -): 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) { diff --git a/src/arts/active-app/service-factories/perm.ts b/src/arts/active-app/service-factories/perm.ts index cc6b4f2..c91a5ac 100644 --- a/src/arts/active-app/service-factories/perm.ts +++ b/src/arts/active-app/service-factories/perm.ts @@ -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 -): 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) { diff --git a/src/arts/active-app/service-factories/storage.ts b/src/arts/active-app/service-factories/storage.ts index 5643f35..976183d 100644 --- a/src/arts/active-app/service-factories/storage.ts +++ b/src/arts/active-app/service-factories/storage.ts @@ -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 = {} -): 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) { diff --git a/src/arts/storage/engine-storage.ts b/src/arts/storage/engine-storage.ts index 701127a..ea95db8 100644 --- a/src/arts/storage/engine-storage.ts +++ b/src/arts/storage/engine-storage.ts @@ -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; diff --git a/src/arts/storage/entry-runtime.ts b/src/arts/storage/entry-runtime.ts index f1aecad..b668361 100644 --- a/src/arts/storage/entry-runtime.ts +++ b/src/arts/storage/entry-runtime.ts @@ -32,6 +32,13 @@ interface StorageEntryRuntimeOptions { readonly entryOptions: StorageEntryOptions; 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 { @@ -47,7 +54,7 @@ export interface StorageEntryRuntime { export function createStorageEntryRuntime( options: StorageEntryRuntimeOptions ): StorageEntryRuntime { - 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( 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( } } - 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( 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 { diff --git a/src/arts/storage/test/engine-storage.test.ts b/src/arts/storage/test/engine-storage.test.ts index 0f6c6de..2c800d5 100644 --- a/src/arts/storage/test/engine-storage.test.ts +++ b/src/arts/storage/test/engine-storage.test.ts @@ -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(); diff --git a/src/arts/storage/types.ts b/src/arts/storage/types.ts index 0995ad3..36764f0 100644 --- a/src/arts/storage/types.ts +++ b/src/arts/storage/types.ts @@ -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 }; } /**