@mika/blackcurrant (0.3.2)

Published 2026-04-01 13:40:29 +00:00 by mika

Installation

@mika:registry=
npm install @mika/blackcurrant@0.3.2
"@mika/blackcurrant": "0.3.2"

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:

  • Communication
  • Persistence
  • Store

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:

  • request
  • ready
  • value
  • mutate(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 = null means 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
  • ready says nothing about where the data came from
  • ready is about the current requested slice, not the full durable corpus

value

value is always an array.

Every item must have:

  • id: string

Blackcurrant guarantees:

  • value contains exactly the currently requested slice
  • value is ordered by id
  • value keeps 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:

  • d requires a string id
  • a and m require an object with string id
  • a is invalid for an already-existing object
  • m and d are invalid for a missing object
  • once a local d is queued for an object, later a or m on 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:

  • name is the singular resource name used by journal and transport
  • path defaults to ${name}s and 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 ready and value
  • 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:

  • ready
  • value

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:

  • userId
  • online
  • pendingRequests
  • busy

userId has three states:

  • undefined: auth still being resolved
  • null: 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)
  • fetch(url, options)
  • mutate(name, op, itemOrId, { allowOffline })
  • subscribe(name, cb)
  • onClear(cb)
  • onStart(cb)

Connectivity Model

Blackcurrant uses a two-phase startup internally:

  1. imperative sync probe
  2. 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, direct fetch(...), and mutation execution
  • pendingRequests is the current in-flight count
  • busy turns true only after requests have remained in flight for more than 300 ms
  • busy turns false shortly after pendingRequests returns to 0, so tiny gaps between serialized requests do not flicker the signal

fetch(url, options) is the escape hatch for authenticated requests outside the resource/store paths:

  • injects Authorization: Bearer <token> when logged in
  • defaults to cache: "no-store" unless overridden
  • returns the raw fetch() response unchanged

Reconnect bookkeeping stays internal to Communication.

Resource Model

Blackcurrant currently assumes orthogonal resource endpoints:

  • GET /resources?... returns ids
  • GET /resources/:id returns one object
  • PUT /resources/:id upserts one object
  • DELETE /resources/:id deletes 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:

  1. create Communication
  2. create Persistence
  3. create stores with both injected
  4. wait for auth resolution
  5. declare eager store requests
  6. 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
Details
npm
2026-04-01 13:40:29 +00:00
5
14 KiB
Assets (1)
Versions (15) View all
4.0.0 2026-06-24
3.1.1 2026-06-17
3.0.2 2026-05-25
3.0.1 2026-05-19
3.0.0 2026-05-19