@mika/blackcurrant (0.3.1)
Installation
@mika:registry=npm install @mika/blackcurrant@0.3.1"@mika/blackcurrant": "0.3.1"About this package
@mika/blackcurrant
Blackcurrant is a client-side data runtime for request-driven collection stores, auth-aware startup, SSE sync, durable persistence, and optimistic/offline mutations.
Install
npm install @mika/blackcurrant @mika/signals uuid
@mika/signals is a peer dependency.
Exports
import {
createCommunication,
createIndexedDbStorage,
createPersistence,
createStore,
} from "@mika/blackcurrant"
Typical Setup
import {
createCommunication,
createIndexedDbStorage,
createPersistence,
createStore,
} from "@mika/blackcurrant"
const persistence = createPersistence({
storage: createIndexedDbStorage(),
})
const communication = createCommunication({
baseUrl: window.location.origin,
})
const users = createStore({
name: "user",
communication,
persistence,
})
Purpose
Blackcurrant is a client-side data system built around three cooperating parts:
CommunicationPersistenceStore
Together they provide:
- auth-aware startup resolution
- journal-backed SSE sync
- durable client data
- request-driven stores
- optimistic mutations
- offline mutation replay
Store Contract
Each store exposes:
requestreadyvaluemutate(op, itemOrId)
request
request is declarative demand.
Examples:
games.request = { from, to }
rules.request = {}
users.request = {}
Meaning:
- the app declares the minimum slice it wants
- the store fulfills that slice from persistence, network, and journal replay
request = nullmeans the store is currently inactive
Request Stability
Requests used for hydration must be stable across refreshes.
This matters because persisted request metadata only counts as immediately reusable when its stored request matches the current request.
Good:
- day-bounded ranges
- month-bounded ranges
- fixed filter objects
Bad:
- minute- or second-granularity windows
- raw
Date.now()-style moving ranges
ready
ready means the current requested slice has been satisfied.
Important semantics:
- an empty collection may still be ready
readysays nothing about where the data came fromreadyis about the current requested slice, not the full durable corpus
value
value is always an array.
Every item must have:
id: string
Blackcurrant guarantees:
valuecontains exactly the currently requested slicevalueis ordered byidvaluekeeps stable identity unless that slice materially changes
Stores are intentionally collection-shaped only. Singleton semantics are derived outside the store layer.
mutate(op, itemOrId)
Public mutation API:
responses.mutate(`a`, item)
responses.mutate(`m`, item)
responses.mutate(`d`, id)
Rules:
drequires a string idaandmrequire an object with stringidais invalid for an already-existing objectmanddare invalid for a missing object- once a local
dis queued for an object, lateraormon that object are invalid
Store configuration decides whether mutations are:
- optimistic
- allowed to queue offline
The caller does not choose that per mutation.
Store Shape
Stores are created as:
createStore({
name,
communication,
persistence,
path,
initialRequest,
serialize,
deserialize,
optimistic,
allowOfflineMutations,
mutateOps,
serializeMutation,
onError,
})
Important notes:
nameis the singular resource name used by journal and transportpathdefaults to${name}sand may be overridden for irregular endpoints- stores keep a durable corpus in
Persistence - stores keep a smaller in-memory working set for
value
Store Responsibilities
A store is responsible for:
- observing
request - hydrating persisted request state and individual objects
- waiting for
communication.onStart(...) - fulfilling requests from HTTP endpoints
- exposing
readyandvalue - applying journal entries
- applying optimistic local mutations
- rolling back failed optimistic mutations
- persisting individual objects and request metadata
- clearing its own in-memory and persisted state on
communication.onClear(...)
Stores do not own transport details. They delegate all network work to Communication.
Views
Views are derived from stores and other views.
They expose:
readyvalue
They do not expose request.
Views own:
- sorting
- grouping
- projections
- singleton derivations over collection stores
Communication
Communication is created as an instance:
const communication = createCommunication({
baseUrl,
onError,
fetch,
})
It owns:
- HTTP conventions
- the SSE journal stream
- startup auth/connectivity probing
- online state
- reconnect behavior
- mutation execution and replay
- auth token persistence
- sync frontier persistence
- mutation queue persistence
- broadcast journal and lifecycle events
Public State
Current public state includes:
userIdonlinependingRequestsbusy
userId has three states:
undefined: auth still being resolvednull: known logged out- string: known logged in
Public Methods
Current public methods include:
login(token)logout()register(name, { path })get(name, id)list(name, request)mutate(name, op, itemOrId, { allowOffline })subscribe(name, cb)onClear(cb)onStart(cb)
Connectivity Model
Blackcurrant uses a two-phase startup internally:
- imperative sync probe
- long-running journal stream
That lifecycle is internal to Communication. Consumers do not call probe(), start(), or stop() directly.
The probe learns:
- whether the server is reachable
- whether the token maps to a user
- the current
visibilityVersion - the current
seq
After initialization, the long-running SSE stream owns ongoing connectivity.
online is the UI-facing signal:
- probe failure sets it to false
- stream open sets it to true
- stream disconnect sets it to false
pendingRequests and busy are transport-facing sync signals:
- tracked around probe, resource reads, and mutation execution
pendingRequestsis the current in-flight countbusyturns true only after requests have remained in flight for more than 300 msbusyturns false shortly afterpendingRequestsreturns to 0, so tiny gaps between serialized requests do not flicker the signal
Reconnect bookkeeping stays internal to Communication.
Resource Model
Blackcurrant currently assumes orthogonal resource endpoints:
GET /resources?...returns idsGET /resources/:idreturns one objectPUT /resources/:idupserts one objectDELETE /resources/:iddeletes one object
The list endpoint is intentionally id-only. Full objects are cached, hydrated, and fetched individually.
Persistence
Persistence is created as an instance:
const persistence = createPersistence({
storage,
onError,
})
It owns:
- durable object storage by scope and resource name
- durable request metadata by scope and resource name
- scope clearing on logout/reset
- error isolation around the underlying storage backend
createIndexedDbStorage() is the default browser storage backend.
Runtime Order
The typical app integration order is:
- create
Communication - create
Persistence - create stores with both injected
- wait for auth resolution
- declare eager store requests
- mount UI
Journal Sync
Blackcurrant sync is journal-backed.
Steady-state assumptions:
- the server emits ordered entries with a monotonically increasing
seq - entries are resource-scoped by
name - visibility changes are represented through a
visibilityVersion - when client visibility is stale, the server emits
resetRequired
For local optimistic replay, journal events are reconciled against pending mutations so successful local writes are not immediately double-applied.
Auth Epoch Lifecycle
When the current auth/sync epoch becomes invalid, Communication emits onClear(...).
Stores should react by:
- clearing in-memory state
- clearing persisted request state
- clearing persisted objects in the current user scope
When a usable auth/sync epoch is available again, Communication emits onStart(...).
Stores should react by refulfilling active requests.
Offline Mutation Queue
Blackcurrant has a durable offline mutation queue.
Important properties:
- only stores configured for offline mutations may enqueue while offline
- offline mutation support requires optimistic updates
- queued mutations persist across refreshes
- when connectivity returns, queued mutations are replayed in order
- failed replay causes rollback for the affected optimistic chain
Boundaries
Blackcurrant does not own:
- application error presentation
- domain-specific views derived from stores
- shell asset caching
- routing
- UI rendering
Those belong to the consuming app.
Notes
- The package is plain ESM.
- There is no build step.
- App-specific runtime wiring, error handling, and domain stores should stay in the consuming app.
Dependencies
Dependencies
| ID | Version |
|---|---|
| uuid | ^13.0.0 |
Peer dependencies
| ID | Version |
|---|---|
| @mika/signals | ^0.1.0 |