Skip to content

refactor(appkit): make CacheManager per-app and delete the process-wide singleton - #566

Open
IamGalymzhan wants to merge 36 commits into
mainfrom
refactor/cache-manager-per-app
Open

refactor(appkit): make CacheManager per-app and delete the process-wide singleton#566
IamGalymzhan wants to merge 36 commits into
mainfrom
refactor/cache-manager-per-app

Conversation

@IamGalymzhan

@IamGalymzhan IamGalymzhan commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

CacheManager was a process-wide singleton reached through getInstance() / getInstanceSync(). It's now owned by the app: createApp builds one manager and injects it through PluginContext, so a plugin's this.cache is its own app's cache. Two apps in one process hold two independent managers, each honouring its own cache config.

The statics are deleted. CacheManager stays exported (it's the type of Plugin.cache), but with a private constructor and PluginContext.cache required, there is no process-wide accessor and no way for a plugin to get a cache other than its app's.

This unblocks #540 — the singleton was why test suites had to mock the cache module to keep one test's cached value out of the next.

What changes for a consumer

Most apps need nothing — if your plugins read this.cache and you build with createApp, this is a no-op.

  • CacheManager.getInstance() / getInstanceSync() are gone. Inside a plugin use this.cache; there's no process-wide handle to fetch.
  • this.cache is read-only. A plugin that assigned its own manager no longer compiles — set a per-plugin cache: { enabled, ttl } config instead.
  • An unattached plugin has no cache. Reading this.cache before the app binds it (a plugin built by hand in a test, or in a constructor) throws a named InitializationError instead of picking up whatever manager happened to exist.

Upgrade notes are in docs/plugins/caching.md.

Design

One manager per app is compiler-enforced:

  • Private constructor + two @internal factories (create async for the app, forStorage sync for tests) — no public construction path.
  • PluginContext.cache is required. Every context carries its app's cache; a cache-less one doesn't typecheck (pinned by a @ts-expect-error test).
  • Plugin.cache is a read-only getter that throws when read on an unattached plugin — the guard for the direct readers (analytics, files) that bypass execute().
  • The app-less path is context-less. Standalone runAgent calls attachContext({}); it binds telemetry and leaves the cache unbound, so only a cached execution fails — at the getter or _buildInterceptors.
  • close() closes only manager-built storage (ownsStorage). A caller who passes cache: { storage } keeps ownership, since PersistentStorage.close() is a permanent pool.end().

Fixes surfaced along the way

  • Failed-boot leak. A boot that failed after the manager was built leaked it; a Lakebase-backed manager leaked its pg.Pool. Now closed on the failure path.
  • Lakebase init leak. create() only ended the pool when the health check failed — a healthy connection whose initialize() threw leaked the pool while boot still "succeeded." Now ended on any throw.
  • Silent OBO cache doubles. Several suites faked the cache with a pass-through getOrExecute, so they could never observe a hit; the files suite's fake couldn't have caught a cross-user staleness regression. They now run on a real cache.

Test migration

26 suites moved off the cache-module mock onto a real cache from the testing kit. Each migration was verified by mutation — deleting the behaviour under test and confirming the assertion fails — so a detached cache handle can't leave a suite green but asserting nothing.

`_createApp` builds one manager per app and passes it through the app's
`PluginContext`, so a second app in one process no longer inherits the
first's cache and silently loses its own `cache` config.

Construction moves behind two statics — the existing async `create()` for
the app, and a new synchronous `@internal forStorage()` for the testing
kit, which cannot await. The constructor stays `private`, so a consumer
has no way to build a manager at all; that is what makes "exactly one
manager per app" a compiler-checked property rather than a convention.
`PluginContext.cache` is `readonly` for the same reason.

The manager reaches the context through a dedicated `AppKit` constructor
parameter, never the config bag and never `extraData`: both are spread
into every plugin's `baseConfig`, and `_buildExecutionConfig` deep-merges
a plugin's config into its execute options — so a manager arriving that
way would be merged into what `PluginExecuteConfig.cache` declares as a
`CacheConfig` and silently break the cache interceptor's gate. A test
pins that, rather than leaving it to reasoning. Passing the already-built
`PluginContext` also retires the one-key `mergedConfig` wrapper.

`getInstanceSync()` is untouched and still answers: the boot publishes
its manager into the deprecated ambient slot, first-wins, exactly as
before. Plugins keep reading it until they are rebound from the context,
which keeps this change releasable on its own.

Two suites that mock the cache module gain `create`/`_publishAmbient` so
their fakes match the module's shape; both are migrated off the mock
entirely later in this stack.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`attachContext` now takes the cache from the plugin's context, so every
plugin in an app shares the one manager that app built. `Plugin.cache`
becomes a read-only accessor over a private field: reads are unchanged,
and a subclass assignment is rejected by the compiler *and* throws at
runtime, so not even a JavaScript consumer can swap an app's cache. A
plugin wanting different behaviour sets a per-plugin `cache` config.

Telemetry is no longer gated behind the cache. The constructor used to
bind the cache first and return early if none existed, leaving
`this.telemetry` unset — so a plugin built before any app failed inside
the telemetry interceptor, far from the cause. `getProvider` never
throws, so it now binds unconditionally.

A plugin that never got `attachContext` has no cache, and `cache`'s
declared type is non-optional, so the compiler cannot catch it. Every
cached execution passes through `_buildInterceptors`, which now reports
an `InitializationError` naming the plugin instead of letting a
`TypeError` on `undefined.getOrExecute` surface inside a request handler.
Chain construction moved inside `execute`'s try block so that failure is
returned as a result: the method's contract is that it never throws.

The constructor keeps binding the deprecated ambient slot when one
exists, unchanged, so an app-less plugin behaves exactly as before and
this phase stays releasable on its own. That bind goes away with the slot
itself.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`_createApp` builds a manager and, until now, dropped it if any later
boot step threw — `ServiceContext.initialize`, resource validation,
plugin `setup()`, `onPluginsReady`, or the server's `listen`. Nothing else
held a reference afterwards, so the manager was unreachable, and one that
resolved to Lakebase owned a `pg.Pool` that was never ended: a leaked
pool per failed boot, holding the event loop open. The singleton masked
this, because the next boot reused the published manager. Everything past
the manager's construction now runs guarded, and the boot error is never
masked by a teardown failure.

`LifecycleManager` takes the manager as a constructor dependency instead
of looking it up during shutdown. A lookup resolves whatever occupies the
process-wide slot at that moment, which is not necessarily the app being
shut down once more than one app can exist.

Its test suite drops the cache-module mock entirely and passes a double,
which is what the injection makes possible. One test went with it: "a
never-initialized cache is skipped without error" has no subject anymore,
since the manager is a required dependency rather than a lookup that can
fail.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`close()` closed its storage unconditionally, so an app closing destroyed
storage its caller owned. Nothing exercised that path before: `getInstance`
was first-wins and silently discarded a second caller's `cache: { storage }`,
so the option only starts working now that each app builds its own manager.

Ownership is recorded per construction site rather than derived from
"was `config.storage` supplied?" — of the seven sites, two hand back the
caller's storage and five build their own, and two of those five build a
fresh `InMemoryStorage` *inside* the supplied-storage branch after the
caller's storage fails its health check. A supplied-vs-not flag would get
those two wrong and skip closing storage the manager owns.

The consequence differs by backend, which is why this stayed invisible:
`InMemoryStorage.close()` clears a `Map` and stays usable, while
`PersistentStorage.close()` is `pool.end()` and permanent.

Verified by mutation: removing the gate fails three of the six new tests,
and the three that still pass are the owned-storage cases that should
close regardless.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`TelemetryExamples` assigned `this.cache = new CacheManager({enabled, ttl},
this.telemetry)` — two arguments against a private `(storage, config)`
constructor, so the config landed where storage belongs. At runtime
`config.enabled` was `undefined`, `getOrExecute` short-circuited, and the
route returned correct answers while caching nothing. The assignment was
redundant as well as broken: the call site already passes `{ ttl: 60 }`,
so deleting it lets the route use the app's own cache, which is enabled by
default.

The reason this survived is that nothing type-checked the file. The app had
no `typecheck` script, and its `check: tsc` script has never run clean —
the root tsconfig had no `include`, so it pulled the React client in with
the server's config and produced a wall of JSX and DOM errors that would
have buried any real one.

So scope the config to the server, where the client has its own, and add
the `typecheck` script. That surfaced four genuine pre-existing
possibly-undefined errors in `server/index.ts`, fixed here since this
commit is what turns the gate on. Two further errors were artifacts of the
exports map sending `tsc` to a stale `dist`; `customConditions:
["development"]` resolves the same source the dev runtime uses.

Verified by mutation: reintroducing the original line now fails the gate
twice over — read-only `cache` and the private constructor.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The kit's context now carries a real in-memory `CacheManager` and exposes
it on the handle as `cache`. A plugin attached through `attach()` resolves
that same object, so a test can spy or read it — `vi.spyOn(mock.cache,
"getOrExecute")`, `generateKey`, `get` — and assert real caching against
production's own keying instead of a re-implemented fake.

`attach()` no longer seeds the process-wide slot to make a cache appear.
Each context builds its own, so two contexts in one file cannot see each
other's entries and attaching leaves nothing behind for a later test to
observe.

Built through the synchronous `forStorage`, because
`createTestPluginContext` is called at describe-body time and cannot
await; in-memory storage has no health check to wait for.

The identity between `mock.cache` and the plugin's `this.cache` is the
load-bearing part: were they different objects, every spy would record
nothing while the suite stayed green. Verified by mutation — detaching the
two fails exactly the two tests that assert it.

`analytics.test.ts` gains `forStorage` on its cache-module fake so the
fake still matches the module's shape; it is migrated off that mock later
in this stack.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`resetTestCache()` read `CacheManager.getInstanceSync()`, so it cleared
whatever occupied the process-wide slot — which is no longer the cache a
test is using. It now clears the caches this kit built for the current
file, tracked in a module-level set inside the kit rather than on
`CacheManager`: this is test-only bookkeeping, and Vitest isolates test
files in separate workers, so the set is per-file by construction.

A set rather than a single most-recent slot, because one file can hold
several test contexts and the zero-argument form is documented to work
mid-test, where "the newest one" would clear the wrong cache. Pass a
context (`resetTestCache(mock)`) or a manager
(`resetTestCache(mock.cache)`) to clear just one. The published
zero-argument call keeps compiling, and clearing nothing is still not an
error.

Its own tests move with it: the old pair forced the uninitialized branch
by making `getInstanceSync` throw, which no longer describes anything.
Verified by mutation — stubbing the clear out fails four of the five.

The testing guide's "the cache is a process-wide singleton" passage is
replaced by what is now true: each context owns its cache, `mock.cache` is
the object the plugin resolves, and it is the seam for asserting real
caching against production's own keying.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`docs:build` derives this from JSDoc. `Plugin.cache` moved from a mutable
protected property to a read-only accessor, so the reference documented a
property that no longer exists.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Thirteen suites replaced the internal `cache` module with a fake whose
`getOrExecute` just called `fn()`. The fake existed for one reason: the
`Plugin` constructor used to throw when no cache had been initialized, so
constructing a plugin at all required one. That is no longer true — the
constructor binds a cache when one exists and carries on when it does not
— and none of these suites assert anything about caching, so the mock has
no remaining job.

Two of them never had one: the lakebase `pool-manager` and `routing-pool`
tests mock a module the code under test does not touch.

Eight also left a `mockCacheInstance` fake behind in a separate
`vi.hoisted` block. Removed with the mocks: a dead fake reads like the
cache is still faked, and the suites now exercise the real one.

Suites that genuinely assert caching — files invalidation, jobs read
paths, and the store-backed analytics and ai-search fakes — are not in
this commit; they need a real cache wired in rather than a mock removed.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The files invalidation tests and the jobs read paths genuinely exercise
caching, so removing their mock is not enough — they need a cache. Each
file now builds one kit context and binds plugins to it through
`attachContext`, the same call `createApp` makes. `files/_test-helpers`
grows `filesPlugin()` and `testCache` so its four suites share one seam;
jobs keeps a local helper.

The keys these tests assert are now production's own `generateKey` output
rather than a re-implemented fake's, which is the point: three separate
hand-rolled key functions had drifted from each other and from
production.

Two consequences the fake was hiding, both fixed here rather than worked
around:

Entries outlive a test once the cache is real, so a cached run answered
the next test's read — `resetTestCache()` in the jobs `beforeEach` clears
between tests, exactly as the testing guide now documents.

Two jobs error tests threw a plain `Error` carrying a `statusCode`
property. The passthrough fake ran the work with no error handling, so the
look-alike's status leaked through; the real `getOrExecute` preserves
status only from `ApiError`/`AppKitError` and wraps anything else to 500.
The SDK throws genuine `ApiError`s, so the tests now do too.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
`metric.test.ts` carried a store-backed fake that re-implemented
`generateKey` — sha256 over `JSON.stringify([userKey, ...parts])`. It
matched production, but as a private copy it could drift silently while the
tests kept passing, and two sibling suites hold two more copies that
already disagree with each other.

The suite now binds its plugins to a kit context and asserts against that
cache. The invariant it exists to protect — injecting metric-views metadata
must not change the composed cache key — is checked with production's own
keying, so a change to `generateKey`'s shape would surface here instead of
sailing past.

`mockCacheStore.clear()` becomes `resetTestCache()`, the published call for
the same job.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Forty-nine of the suite's fifty-two `getInstance` calls only wanted a
manager over a given storage; they now call the `create` factory. That also
retires the wrinkle they were written around — `getInstance` returns any
existing instance and silently ignores the storage argument, so each test
had to reset the private statics by reflection first to get the storage it
asked for.

The three tests in the `singleton pattern` block are left alone: their
subject *is* the statics, so they are removed with them rather than
migrated. The reflection reset stays for the same reason, now with a
comment saying so.

The suite still bites: stubbing the cache read path to never serve a hit
fails its hit and per-user-key tests.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Seven agents suites reached for the process-wide CacheManager: five
monkeypatched `getInstanceSync`/`instance` with a hand-rolled fake, two
initialized the real singleton so `attachContext` had something to bind.

A probe that throws on every cached execution proves no test in this
directory reaches the cache path, so the five fakes were dead weight and
are simply gone. The two that attach a context now take the cache from
`createTestPluginContext` instead: discovery gets the kit's real context
(its provider registry is empty, so tool collection finds nothing, as it
did with no context at all), and the plugin suite's own fake context
carries `kit.cache`.

Nothing in the directory touches CacheManager's statics now.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
plugin.test.ts and asUser-proxy.test.ts both mocked the cache module so the
constructor's ambient bind would hand the plugin a fake. Neither needs the
process-wide slot.

plugin.test.ts genuinely exercises cached executions, so TestPlugin now
attaches a context carrying the double — the same path an app uses. The
assertion that the constructor called getInstanceSync is replaced by one
that the plugin binds the cache its context carried, which is the behaviour
worth pinning; mutating attachContext to ignore the context fails it along
with every execute test.

asUser-proxy.test.ts never reaches a cached execution (verified by a probe
that throws on the cache path), so its fake is deleted outright.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
analytics.test.ts hand-rolled a store-backed cache double whose generateKey
was a copy of production's, free to drift from it, and mocked the cache
module so the ambient bind would hand it over. It now attaches the kit's
context, like metric.test.ts already does.

The real getOrExecute passes the callback a composed signal, which exposed
that the abort test only ever worked because the fake called fn() with no
argument — that dropped the shared signal and left the route's own signal in
its place, which is the one the fallback checks. The test now fires the
close listener the route registered, aborting that signal the way a client
disconnect does. Without the abort the statement really does run twice, so
the assertion still bites.

That route no longer reaches deliverArrowBytes' abort guard, whose only
coverage it was, so result-delivery.test.ts pins it directly instead — at
the level it lives at, and it fails when the guard is removed. The sibling
guards on the EXTERNAL_LINKS and JSON paths were already uncovered before
this change.

appkit-as-user-exports.test.ts drops its cache mock outright and boots on a
real per-app cache.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The fake claimed to key "like the real CacheManager.generateKey, so tests
exercise real key composition", but keyed on
JSON.stringify([userKey, ...parts]) where production hashes — so the seven
caching tests were pinned to a double that could never agree with the real
thing. They now run on the kit's cache.

The real CacheManager instruments getOrExecute through the same telemetry
provider, which this file mocks, so the span stub had to become a whole span
rather than the three members the plugin alone touched.

Dropping executorKey from _cacheKeyFor fails the per-user isolation test, so
the real keying is load-bearing.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The last suite reaching for the cache module. Its double passed getOrExecute
straight through and returned undefined from generateKey, so no test here
ever saw a cache hit or a real key. The suite now attaches the kit's context,
matching the seam the nine sibling files suites already share, and its five
cache assertions spy on the real manager.

That the fake never cached was hiding coverage, not just fidelity: with the
read cache wrongly enabled on OBO volumes, the pass-through double failed two
tests, while the real cache fails three — the cross-user freshness test could
not have caught it before, because nothing was ever cached to go stale.

afterEach unpatches only the cache's own methods. vi.restoreAllMocks() is too
broad here: it also strips the implementations from the module-scope SDK
doubles, which fails three policy tests.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Nothing in plugin.test.ts reads the statics any more, so the module mock only
existed to keep the constructor's ambient bind from throwing — which it
already swallows. No test outside cache/ now mocks the cache module.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Deletes `instance`, `initPromise`, `getInstance`, `getInstanceSync`, and the
`_publishAmbient` writer the last of those needed. The class stays exported —
it is still the type of `Plugin.cache` — and with the constructor private and
only `create`/`forStorage` reachable, an app's manager has no public
construction path.

A plugin's cache now comes only from its context. `attachContext` throws a
named InitializationError when a context is supplied that carries no cache,
and a plugin that never got one throws at its first cached execution rather
than on `undefined` inside a handler.

Docs carry the 0.70.0 upgrade section: the removed statics and their
replacement, the unattached-plugin error, the read-only `this.cache`, and the
supported paths in production and in tests. The note lives in
docs/docs/plugins/caching.md — CHANGELOG.md sections are generated into place
by tools/finalize-release.ts, so a hand-added note there would pin itself
above every later release.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
resetTestCache's docstring still described clearing "AppKit's process-wide
cache singleton" and "the cache attach() seeds" — both gone. Its inline
comments were already accurate; only the doc comment lagged, and it ships as
part of the testing entry point. Same for one comment in the kit that referred
to the deleted slot.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
@IamGalymzhan
IamGalymzhan requested a review from a team as a code owner September 2, 2026 11:55
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size report

Compared against bundle-size-baseline.json (main).

@databricks/appkit

npm tarball (packed): 1.0 MB (+5.6 KB) — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 1.1 MB (+3.3 KB) 378 KB (+1.5 KB)
Type declarations 388 KB (+2.7 KB) 137 KB (+1.1 KB)
Source maps 2.1 MB (+7.6 KB) 708 KB (+3.0 KB)
Other 11 KB 3.7 KB
Total 3.5 MB (+14 KB) 1.2 MB (+5.6 KB)
Per-entry composition (own code — deps external (as shipped))
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
. 95 KB (+171 B) 2.5 KB 98 KB (+171 B) external 311 KB (+390 B)
./beta 72 KB (-3.2 KB) 457 B 72 KB (-3.2 KB) external 215 KB (-11 KB)
./testing 17 KB (+45 B) 0 B 17 KB (+45 B) external 50 KB (-35 B)
./tsdown 520 B 0 B 520 B external 813 B
./type-generator 22 KB 0 B 22 KB external 65 KB

Chunks:

Entry Chunk Load Size (gz)
. index.js initial 91 KB
. utils.js initial 4.0 KB
. remote-tunnel-manager.js lazy 2.5 KB
./beta beta.js initial 56 KB
./beta stream-manager.js initial 5.8 KB
./beta wide-event-emitter.js initial 3.2 KB
./beta databricks.js initial 3.2 KB
./beta configuration.js initial 2.1 KB
./beta service-context.js initial 1.3 KB
./beta client.js initial 434 B
./beta client-options.js initial 219 B
./beta supervisor-api.js lazy 193 B
./beta databricks.js lazy 142 B
./beta index.js lazy 122 B
./testing index.js initial 17 KB
./tsdown index.js initial 520 B
./type-generator index.js initial 22 KB

@databricks/appkit-ui

npm tarball (packed): 350 KB — gzipped download (dist + bin; excludes release-only docs/NOTICE).

dist raw gzip
JS (runtime) 395 KB 132 KB
Type declarations 229 KB 84 KB
Source maps 766 KB 253 KB
CSS 16 KB 3.2 KB
Total 1.4 MB 473 KB
Per-entry composition (consumer bundle — deps bundled, peerDeps external)
Entry Initial (gz) Lazy (gz) Total (gz) node_modules (min) Own code (min)
./js 5.3 KB 49 KB 55 KB 208 KB 14 KB
./js/beta 20 B 0 B 20 B 0 B 0 B
./react 432 KB 49 KB 481 KB 1.3 MB 177 KB
./react/beta 1.0 KB 0 B 1.0 KB 0 B 1.9 KB

Chunks:

Entry Chunk Load Size (gz)
./js index.js initial 5.2 KB
./js chunk initial 120 B
./js apache-arrow lazy 49 KB
./js/beta beta.js initial 20 B
./react index.js initial 430 KB
./react tslib initial 2.1 KB
./react apache-arrow lazy 49 KB
./react/beta beta.js initial 1.0 KB

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🤖 AppKit PR bot

🔬 Run evals

Start an eval for this PR from the evals-monitor app: Go to Evals Monitor →

📦 Try this PR's app template

Scaffolds a new app from this PR's SDK build. Run it in any folder (requires the GitHub CLI — gh auth login — and the Databricks CLI):

gh run download 33642625418 -R databricks/appkit -n appkit-template-0.70.0-pr.b639b12-refactor-cache-manager-per-app-566 -D appkit-pr-566 \
  && unzip -o "appkit-pr-566/appkit-template-0.70.0-pr.b639b12-refactor-cache-manager-per-app-566.zip" -d "appkit-pr-566" \
  && databricks apps init --template "appkit-pr-566"

The template pins @databricks/appkit and @databricks/appkit-ui to tarballs built from this branch, so the scaffolded app runs against this PR's code.

CacheManager.create's Lakebase branch only called pool.end() on the
!isHealthy path. If healthCheck() passed but initialize() then threw, the
bare catch swallowed the error and fell through to in-memory — leaking the
pg.Pool for the life of the process while boot still 'succeeded'. The PR's
failed-boot close could not cover it: that branch returns a usable manager,
so nothing signals failure. Now the healthy block ends the pool on any
throw before falling back.

Also drops the 18 dead (CacheManager as any).instance/initPromise reset
lines this suite carried — the statics they reset were deleted earlier in
this branch, and the file's own comment predicted 'This reset goes when
those statics do.'

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
databricks.test.ts mocked '@databricks-apps/cache' — a specifier that
resolves nowhere in this repo (the real module is ../../cache) — for
getInstance/getInstanceSync, statics that no longer exist. The mock has been
inert for a while, so it is pre-existing rather than caused here, but it is
the mock that made a147625's 'the last cache-module mock' claim untrue.
Its createApp() calls already hit the real cache; removing it changes
nothing.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The cache getter returned this._cache as CacheManager, casting away
undefined, so the only runtime guard was in _buildInterceptors — which
covers executions routed through execute()/executeStream() but not the two
production sites that read this.cache directly: analytics.ts's arrow caching
executor and files/plugin.ts's list-cache invalidation. On an unattached
plugin (the app-less runAgent path) both raised a bare TypeError deep in a
handler; the files site's debug-level catch made it silent.

The getter now throws a named InitializationError at the read. Removes the
lying cast, keeps every call site unchanged, and narrows the
_buildInterceptors comment that overstated its reach.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
run-agent.ts calls attachContext({}) on every standalone plugin, but every
double in this suite is duck-typed (implements ToolProvider without extending
Plugin), so Plugin.attachContext never ran under test. The two-case
attach contract — a context-less attach binds telemetry and leaves the cache
unbound without throwing, while a supplied cache-less context throws — had no
end-to-end coverage on the production call site.

Adds a real Plugin subclass routed through runAgent, asserting attachContext
and setup both ran (ready), and the cache is left unbound (a direct read
fails closed). Mutating attachContext to throw whenever no cache is reachable
fails this test.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
…in factories

attach() was async only because it once awaited CacheManager.getInstance();
that await is gone, so its body has zero awaits. Because it returned a
promise, seven suites bypassed it and hand-rolled
plugin.attachContext({ context: kit.ctx }) to get a synchronous bind —
which also skipped the registerPlugin/registerToolProvider that attach()
does, so they bound less like production than the kit offers.

attach() is now synchronous and returns P. The seven factories route through
kit.attach(new X(...)), gaining registry parity as a side effect. Two suites
that duplicated the kit+factory block verbatim (analytics.test.ts,
metric.test.ts) now share a new analytics/tests/_test-helpers.ts, and
files/plugin.test.ts imports the three symbols its own _test-helpers.ts
already exported instead of re-declaring them.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
InMemoryStorage's constructor demanded a CacheConfig but reads only maxSize,
so this branch had accumulated 'new InMemoryStorage({} as never)' casts at
ten call sites. Narrowing the parameter to Pick<CacheConfig, "maxSize"> with
a default says what it uses and lets callers write new InMemoryStorage() —
every cast is gone. Behaviour-identical: the body already defaulted a missing
maxSize, and the internal create() calls pass whole-config variables that
remain assignable.

Also drops the now-needless '{ cache } as never' cast in plugin.test.ts
(attachContext's context param is typed unknown).

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
… slot

Several comments this branch added or left still told the reader a
process-wide cache slot exists. plugin-context.ts:79 was outright false — it
promised a plugin 'falls back to the deprecated process-wide slot' where
attachContext actually throws. The rest were change-narration ('no longer',
'Previously') or defended a parameter against a lookup that no longer exists
(lifecycle-manager, appkit.ts's context @PARAM, two cache-injection test
comments, the storage-ownership historical note). Reworded to the durable
claim in each case; the legitimate historical contrast at
appkit-cache-injection.test.ts:120 is kept. Also trims kit-cache.ts's
14-line note on its Set to the load-bearing four.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The caching guide claimed an app's cache is unreachable outside a plugin
because 'the manager has no public constructor.' The private-constructor half
is true, but CacheManager.create()/forStorage() are @internal in JSDoc only —
stripInternal is not set, so both ship in dist/cache/index.d.ts on a
value-exported class, and a consumer can call CacheManager.create(). Reworded
to the guarantee that actually holds: the cache is handed only to registered
plugins and there is no process-wide accessor.

Also resolves a self-contradiction — the upgrade note listed reading the
cache in setup() as a way to hit the unattached error, while the section
above says to read it from setup(). Clarified that under createApp, setup()
and handlers run after the bind, so only the constructor or an unregistered
hand-built plugin is affected.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Follow-up to the pool-leak fix: the outer catch that falls back to in-memory
is silent, which is right for an unreachable Lakebase (the common, expected
case) but hides the surprising one — a connection that passed its health
check and then failed to initialize. Warn in the inner catch, where the case
is unambiguous, before ending the pool and re-throwing into the fallback.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Routing the test factories through kit.attach() (the previous commit)
re-registered the tool provider on every call. A file that builds many
instances of one plugin — the shared-kit factory pattern — tripped the
production "registered more than once" warning ~166 times across the suites,
training readers to ignore a diagnostic that exists to catch two plugins
claiming one tool namespace, and silently churning the plugin registry.

attach() now registers a given name once (guarding on ctx.hasPlugin), so
re-attaching another instance still binds it via attachContext but leaves the
registry and the warning alone. The production warning is untouched. Also
drops the now-needless 'enabled' excess-property cast on EndableStorage, which
extends InMemoryStorage and inherits its narrowed constructor.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The generated reference for Plugin.cache lagged the getter's new JSDoc (the
fail-closed accessor). Regenerated via docs:build; no hand edits.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
…e tests

A second simplification pass flagged the branch's added comments as
carrying too much change-narration — 'used to', 'previously', 'the old
symptom' — which ages badly and restates what the test name or assertion
already says. Cut those in cache-binding.test.ts and appkit-cache-injection.test.ts
(keeping the two genuinely load-bearing ones: why cache is read in-class, and
the boot-ordering dependency), rephrased the one disputed historical line as
the invariant it guards, trimmed the ownsStorage/forStorage JSDoc to the
non-obvious hazard, and dropped kit-cache.ts's redundant wrapper comments.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
kit-cache.ts existed only to hold a Set shared between createTestPluginContext
(which records each per-file cache) and resetTestCache (which clears them).
Both the Set and its trackedKitCaches() reader now live in fixtures.ts next to
resetTestCache, its only reader; test-plugin-context.ts imports registerKitCache
from fixtures.ts, which it already imports from. One @internal cross-file seam
remains (registerKitCache) instead of a standalone file with two, and the
kit-cache.js that shipped as unreachable dead code in the tarball is gone.

No behaviour change: same per-file registry, same resetTestCache semantics.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
The guard added in the previous fix registers a plugin name once, but the
comment still claimed registration makes getPlugins()/hasPlugin()/sibling
lookups 'behave as in production' — false for every instance after the first.
State the actual contract: first attach of a name wins, later instances of the
same name are bound but not re-registered, so a sibling lookup resolves the
first instance. Names why that is safe here (direct attach sites use a fresh
context per test; no factory suite does a dependent sibling lookup). Comment
only — no behaviour change.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Every PluginContext that exists carries a cache: the two production
construction sites both supply one (createApp and the testing kit), and
PluginContext is not exported to consumers, so there is no path that builds a
cache-less one. The app-less case is context-less (attachContext({})), not a
cache-less context. So the optionality guarded a state that cannot occur.

Making the field required turns 'one cache per context' into a
compiler-checked invariant and makes attachContext's runtime 'supplied context
with no cache' guard unreachable — deleted. The remaining guards stay and are
distinct: the Plugin.cache getter (direct readers) and _buildInterceptors (the
execute path), both for the genuinely cache-less context-less plugin. Five
test sites that built a bare PluginContext now pass a cache (a double where the
cache is not exercised); the old runtime-throw test becomes a @ts-expect-error
that fails if the field is made optional again.

Reverses the earlier deliberate 'keep it optional' call, on the design
review's argument — the objection (deviation from R4, error-timing) did not
hold: required is a stronger, compile-time form of R4's fail-loud intent, and
no non-test caller could ever hit the timing difference.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Resolves the one content conflict in plugin/tests/plugin.test.ts: keeps both
this branch's 'binds the cache its context carries' test and #568's two
streamConfig tests. plugin.ts auto-merged cleanly — new StreamManager(
config.streamConfig) from #568 sits alongside the per-app cache/telemetry
changes. Regenerated API docs pick up #568's streamConfig on the configs that
extend BasePluginConfig; caching.md's upgrade heading moves to 0.71.0 since
0.70.0 already shipped.

Signed-off-by: Galymzhan <zhangazy2004@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant