Stores
A store implements the SDK’s storage ports. Swapping one is meant to be a one-line change, and there is a conformance suite that makes that a real claim rather than an aspiration.
Available stores
| Store | Package | Use |
|---|---|---|
memoryStore() | @luno-oss/core | Development and tests. Loses everything on restart |
postgresStore | @luno-oss/store-postgres | Production. Driver-agnostic |
import { createLuno } from '@luno-oss/core'
import { postgresStore } from '@luno-oss/store-postgres'
const luno = createLuno({
store: postgresStore(pool),
secret: process.env.LUNO_SECRET!
})memoryStore() lives inside @luno-oss/core rather than in its own package. A
separate package would depend on core while core’s own tests depend on it, and
that build cycle buys nothing — keeping it inside also means createLuno
works the moment the package is installed.
The Postgres store
Built behind a tiny Queryable port — one method, query(text, params) — so it
binds unchanged to pg, a serverless HTTP driver, or PGlite:
interface Queryable {
query(text: string, params?: unknown[]): Promise<{ rows: any[] }>
}That means the same store works against a connection pool, a serverless Postgres HTTP endpoint, or an in-process PGlite instance for tests, with no adapter code in between.
The ports
Storage — PairingSessionStore, DeviceStore, MessageStore,
EventLogStore, EnrollmentStore. Each is a handful of methods rather than one
large interface, so a store can be implemented incrementally and a partial
implementation fails loudly rather than silently.
Atomicity is a contract, not an implementation detail
The highest-risk operation in the entire system is consuming a pairing
session. Under maxEnrollments: 1, two nodes submitting the same code
concurrently must produce exactly one enrolment.
A naive get() then put() port lets every store implementation race. The
bug appears only under load, only in production, and manifests as a duplicate
device — which is exactly the kind of failure that is expensive to reproduce
and easy to misdiagnose.
So the port does not expose read-modify-write at all. It exposes the atomic operation and states the requirement:
interface PairingSessionStore {
/**
* Atomically claim one enrolment slot. MUST be linearizable: concurrent
* callers with the same sessionId yield at most `maxEnrollments` successes.
* Postgres: SELECT … FOR UPDATE / UPDATE … WHERE remaining > 0 RETURNING.
* Firestore: runTransaction. D1: transaction. KV-only: CAS loop.
*/
claim(sessionId: string, now: number): Promise<ClaimResult>
}In @luno-oss/store-postgres, claim() is a single conditional
UPDATE … WHERE remaining > 0 … RETURNING, which Postgres makes linearizable
without an explicit transaction.
Conformance
Every store implementation must pass the suite exported from
@luno-oss/testing/store, which includes a concurrency test that hammers claim()
in parallel and asserts the invariant holds.
import { describe } from 'vitest'
import { describeStoreConformance } from '@luno-oss/testing/store'
import { postgresStore } from '@luno-oss/store-postgres'
describe('postgresStore', () => {
describeStoreConformance(() => postgresStore(testPool))
})@luno-oss/store-postgres passes it — including a 60-way concurrent-claim test —
against real Postgres semantics via PGlite.
This suite is what makes “swap the database” a supported operation. Writing a Firestore, D1 or MongoDB store is a matter of implementing the ports and running the suite until it is green.
Writing your own store
- Implement the storage ports.
- Make
claim()linearizable using whatever primitive your database offers — a transaction, a conditional update, or a compare-and-swap loop. - Run
describeStoreConformanceagainst it. - Run the fake node end to end through an adapter with your store behind it.
If your database offers no atomic primitive at all, claim() cannot be
implemented correctly, and pairing under concurrency will over-enrol. That is
a reason to pick a different store, not a reason to approximate it.