I realised the "block" word I was using, to give an address in memory corresponding to a given index and block on the disk, could just as well NOT directly map to memory: the first block need not be at the memory offset and the second need not be at memory offset + block size.
I could have the word itself manage allocated space, loading and flushing blocks to the disk as needed. A properly managed persistent storage to free my mind and applications from unnecessary bookkeeping.
To achieve this, I use a circular set of buffers for swapping in/out blocks from/to the disk. Whenever a block is requested, i.e. with "N block", it checks if it was loaded already. If it is, it returns the buffer storing it. If not, it flushes the oldest buffer to the disk and loads the requested block into its buffer.
It uses about 4kb of block buffers to hold the 4 most recently loaded blocks at a time.
First I define the block buffers and a couple words to assist with traversing them:
: blocks A << ;
4 blocks :array buffers
: buffer blocks buffers + ;
: clamp 3 && ;
: inc 1+ clamp ;
Next I have a separate array, mapping from buffer index to block id:
4 cells :array tracker
: t+ cells tracker + ;
: tracked t+ @ ;
: tracked! t+ ! ;
Then we have a word for searching for a block id and, if found, returning the address of the corresponding buffer:
:cell target
: match? tracked target @ = ;
: loop dup match? if buffer else inc when> jump loop ;
: loaded target ! 0 loop ;
The oldest buffer is always swapped out with new ones, so we define words managing it here:
:cell index
: oldest index @ ;
: oldest++ oldest inc index ! ;
: >tracked oldest tracked! oldest++ ;
As well as the necessary block (un)loaders:
: >core dup buffer swp tracked 1- >block ;
: core> oldest buffer dup target @ 1- swp block> ;
Note I'm decrementing the given block id to get the actual block index on disk, this is a 1-based system. This is because 0 is used to represent an empty buffer in the tracker array. It is completely unnecessary once at least 4 blocks are loaded, and a good alternative would include a separate check to indicate which ones are truly loaded. But I prefer this system, so it is what I use.
And finally the high level words describing the application:
: replace core> target @ >tracked ;
: load oldest unload replace ;
: block loaded or load ;