0152 — A keyspace is memory that nothing reaches before it is durable
Decision
defineKeyspace(name, { schema, key }) declares a record set. keyspaceResource
opens it as a ManagedResource whose start publishes a handle: get and
list read synchronously from memory, put and delete return a promise that
resolves when the change is durable.
The order is fixed and is the whole decision: backend, then memory, then the change event.
- Backend first is what makes the memory authoritative. A reader can never observe a value that did not survive. The reverse order makes writes feel instant and makes every rejected write a lie somebody has already read.
- The event last, after memory, because a subscriber woken by it will read immediately — an event emitted before the memory update is a wake-up to the old value.
Writes run on one chain per keyspace, so two writes to one key reach the
backend in the order they were accepted and their events follow that same order.
The queue is bounded and put rejects when it is full: an unbounded write queue
turns a slow backend into memory exhaustion, discovered when the process dies.
Why it is a resource rather than a function you call
The application kernel resolves its resource graph in its constructor and has no
way to register one afterwards. A keyspace opened by a bare openKeyspace()
inside another resource’s start is invisible to it: never drained, never
closed, never ordered against the things that write to it. Declared as a resource
it closes after its writers, because a writer that depends on it is stopped
first.
What “durable” is allowed to mean
It means the backend’s put resolved. It does not mean fsync, and this
boundary cannot make it mean that — SQLite in WAL mode with
synchronous = NORMAL returns success before the data reaches the disk. What an
acknowledgement is worth is the backend’s to state and the owner’s to configure.
Claiming durability without naming whose would be the more comfortable lie.
The same honesty applies to shutdown. Graceful close is deadline-bounded and a
forced close can cut a drain short, so a keyspace that closes with writes still
queued reports how many through onUnwritten. A number, because silence
there is a keyspace that lost records and said nothing.
The backend port is not a SQL port
KeyspaceBackend is load / put / delete / optional close. Typing it
against statements would make SQLite the only backend anyone could actually
write — everyone else would be forging a prepare(). sqliteKeyspaceBackend
is one implementation of that port, and a file, a bucket or a remote store are
equally expressible.
It does not close the database handle it was given. That handle was opened by the caller, may back several keyspaces and the agent-runtime store besides, and a backend that closes a connection it did not open takes the rest of the process with it.
The name, and a rename it forced
Not defineDomain. stitchkit/primitives already publishes nineteen Domain*
exports about domain events — a different meaning of the word — and two senses
of one word in one public surface make a search for it useless.
Shipping the SQLite backend also required the minimal exec/prepare/close
boundary the agent runtime already had. It was called
AgentRuntimeSqliteDatabase, which stopped being true the moment something other
than the agent runtime used it: a keyspace typed against an AgentRuntime*
boundary reads as a dependency on the agent runtime, which it is not. It moved to
a neutral module as SqliteDatabase / SqliteStatement / SqliteValue and the
old names were deleted in the same pass. No re-export under the old name — that
would be an alias, and one thing with two names is the defect this repository
refuses on principle.
Consequences
- A stored record that no longer satisfies the schema stops
startrather than being skipped. Skipping makes “absent” and “never written” the same observation for everything downstream. - A value that does not satisfy the schema never reaches the backend:
putparses before it queues, so a bad record is refused at the call site rather than discovered at load time on the next restart. - The change callback is a callback, not a bus dependency, so a keyspace works
without one. Wire it to a topic declared with
defineEventsand a watched read invalidates on it.