@mika/blackcurrant (4.0.0)
Installation
@mika:registry=npm install @mika/blackcurrant@4.0.0"@mika/blackcurrant": "4.0.0"About this package
@mika/blackcurrant
Blackcurrant is a browser-first client data runtime for synced applications. It provides durable local SQLite state, mutation queuing, server-sent-event sync, reactive SQL reads, and coordinated use across multiple tabs in the same browser profile.
Blackcurrant owns the local data runtime, not the whole application. Rendering, routing, pagination, domain projections, and final presentation state belong to the consuming app.
Status: the Blackcurrant 3.0 client runtime described here is implemented and covered by the current verification suite.
Further Reading
- Wire Contract
- Server Implementation Guide
- Architecture
- Requirements
- Workflow
- Conventions
- Verification Model
- PoliteWorker
Host Requirements
Browser hosts must provide ESM modules, module Workers, BroadcastChannel or
equivalent coordination support, OPFS support usable by SQLite WASM, and the
SQLite WASM assets served to workers.
Offline operation after active-worker replacement also depends on application asset availability. If an app needs a remaining tab to continue local Blackcurrant work after the tab that owned the active worker closes while the origin is unavailable, the app should cache the application shell, Blackcurrant modules, PoliteWorker worker entry, Blackcurrant worker target, and SQLite WASM assets. Blackcurrant does not install or manage a service worker; that caching and update lifecycle belongs to the consuming application.
SQLite WASM builds may require cross-origin isolation:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Node Test Mode
Blackcurrant is not a Node deployment runtime. For application tests that run
under Node, Blackcurrant({ test: true, fetch, ... }) enables a test harness
mode with the same public facade shape. Test mode requires caller-provided
fetch and uses it for sync, reconciliation, and mutation transport so the
test runner owns shared cookie and authentication state. Test mode also uses
PoliteWorker pass-through mode, in-memory SQLite instead of OPFS, and a
fetch-backed EventSource shim.
Test mode does not provide browser worker coordination, Web Locks, BroadcastChannel sharing, active-worker replacement behavior, or durable OPFS persistence. Those remain browser contracts and are proven by the browser verification suite.
Creating A Runtime
import { Blackcurrant } from "@mika/blackcurrant"
const blackcurrant = Blackcurrant({
version: "3.0.0",
baseUrl: window.location.origin,
})
Blackcurrant(options) accepts:
version: required app-provided runtime version string. Full SemVer strings such as"3.0.0"are recommended. Shorter numeric-segment strings such as"1"and"1.0"are accepted for apps that choose that style, but the app must use one version spelling style consistently across tabs and deploys. Mixing equivalent spellings such as"1","1.0", and"1.0.0"is undefined behavior. JavaScript numeric values such as1are rejected. Higher versions politely take over active workers from lower versions.baseUrl: server base URL for mutation transport and/sync; defaults to the current same-origin URL in browsers.fetch: test-mode fetch implementation for sync, mutation, and reconciliation transport. It is required whentestis exactlytrueand rejected otherwise. Normal browser runtimes use the active runtime environment's native fetch for fetch-backed mutation and reconciliation transport. Browser sync continues to use EventSource cookie transport.mutationRetryMs: retry delay after retryable mutation transport failures; defaults to1000.mutationTimeoutMs: per-request mutation transport timeout; defaults to30000.reconciliationTimeoutMs: reconciliation inactivity timeout; defaults to30000and is refreshed by response headers or streamed messages.onlineLivenessWindowMs: server-online liveness window after observable sync or reconciliation response activity; defaults to5000. Increase it for applications that need to tolerate longer quiet periods on poor connectivity.test: when exactlytrue, enables the Node-only test harness mode described above. Omit this option for browser/runtime use.
The runtime exposes:
- tagged
sql mutate(resource, op, objOrId)status()errors()- tagged
watch close()
Event-like surfaces use asynchronous generators. Breaking out of the for await
loop closes that subscription.
Server Manifest Setup
Managed setup is server-owned. The public constructor does not take managed
migrations or resources. Instead, Blackcurrant opens sync with:
GET /sync/<visibility>/<seq>?version=<runtime-version>
The first sync data message must be exactly one setup outcome:
{ type: "versionRequired", version: "3.1.0" }
or:
{
type: "manifest",
resources: {
User: {
table: "Users",
optimistic: true,
path: "/users",
fields: {
id: "uuid",
email: "citext",
avatarId: { type: "uuid", nullable: true },
createdAt: { type: "timestamp" }
}
}
},
migrations: [
{
id: 1,
name: "create_users",
sql: `
create table Users (
id text primary key,
email text collate nocase not null,
avatarId text,
createdAt text not null
)
`
}
]
}
versionRequired blocks the current runtime attempt. Local reads, watches, and
mutations reject with a version-required error, sync reconnects stop, and queued
mutations do not drain until the runtime is reloaded or otherwise
reinitialized with a compatible version.
An accepted manifest defines the current runtime version string's managed
resources, optional Undercurrent field metadata, and complete SQLite migration
list. Every accepted sync stream sends manifest before hello, heartbeat,
reconciliation, protocol error, frameStart, changes, or frameCommit.
Resources
Each managed kind of object has one resource name and two server-manifest identifiers:
- Resource name: the key in
manifest.resources, such asUserorUserComment. - Table name: the local SQLite table, such as
UsersorUserComments. - URL path: the HTTP collection path, such as
/usersor/user-comments.
Manifest resource fields:
table: SQLite table name used by local optimistic writes and sync.optimistic: whether local mutations update SQLite before server acceptance; omitted meansfalse.path: HTTP collection path used by mutation transport.fields: optional map of field names to Undercurrent field metadata.
Defaults are simple transforms:
table:PascalCase(resource) + "s"optimistic:falsepath:"/" + kebab-case(resource) + "s"
Only one resource may map to a SQLite table.
Resource visibility is server-owned. Resource configuration does not include a
public/private scope, and providing scope is rejected.
Supported Undercurrent scalar types in fields are citext, text, uuid,
integer, bigint, boolean, date, and timestamp. Each field entry may
be a scalar string such as "text" or a descriptor object in the shape
{ type: "text", nullable: true, readonly: false }. Scalar strings and
descriptors with omitted nullable or readonly mean nullable: false and
readonly: false. Descriptor objects reject missing type, unknown keys,
unsupported type values, non-boolean nullable values, and non-boolean
readonly values. The id field cannot be readonly. A resource with any
readonly field cannot be optimistic.
This metadata is validated, normalized, and persisted with the manifest for
diagnostics and future Blackcurrant behavior; SQLite schema validation still
comes from the actual migrated table schema. Nullable fields in full object
payloads are represented by explicit JSON null values rather than by omitting
the field.
Migrations And Schema
The compatible server owns the manifest migration list for each accepted runtime version. Blackcurrant applies those server-manifest migrations before local SQL reads, mutations, or sync frame application use the managed tables.
Applied migrations have stable content identities. Changing the SQL for an already-applied migration is schema drift and must fail startup rather than silently changing stored data.
Blackcurrant persists the accepted manifest per runtime version. A later startup with the same runtime version may use the persisted manifest to read already migrated local state while offline. If an accepted same-version manifest later differs in resources, field metadata, migration list, or migration content, setup fails instead of silently changing the local data contract.
Guidelines:
- Define an
idprimary key for every managed table. - Keep server resource snapshots aligned with local table columns.
- Add manifest migrations in order.
- Do not edit already-applied manifest migrations.
- Coordinate schema changes by shipping a new runtime version and a matching server manifest; schema changes do not implicitly clear local data.
Local Reads
Use sql for one-shot reads from committed local state:
const users = await blackcurrant.sql`
select id, email, name
from Users
order by name
`
SQL reads are local. They do not fetch rows from the server. Interpolated values are bound as SQLite parameters:
const rows = await blackcurrant.sql`
select id, email, name
from Users
where id = ${userId}
`
Table names, column names, and SQL fragments should come from application code, not user input.
While a sync frame is being applied, SQL reads wait until that frame commits or rolls back. Reads also wait while reconciliation commits. A read never observes a partially applied frame or reconciliation.
On first startup with no persisted same-version manifest, local reads, watches,
and mutations wait for manifest setup unless the runtime becomes blocked. After
manifest setup, managed local reads, watches, and mutations also require a
visibility context. That context is created by a persisted principal from a
previous run or by sync hello.principal.
Status
Use status() to observe runtime state:
for await (const status of blackcurrant.status()) {
console.log(status)
}
Status snapshots include:
{
online: true,
manifestReady: true,
blocked: false,
blockReason: null,
requiredVersion: null,
principal: "user:019cba16-f4d0-7214-bf70-79821608b64c",
syncing: true,
chunkIndex: 20,
chunkCount: 40,
progress: 0.5,
}
online means recently confirmed server connectivity through observable sync
SSE data-message activity, observable reconciliation response activity, or an
active in-flight reconciliation request. Any observable sync SSE data message
or reconciliation response activity refreshes the liveness window, including
the liveness-only { type: "heartbeat" } message and messages that later prove
protocol-invalid. Liveness means the server is reachable; it does not mean the
message was semantically accepted. A compatible server should send heartbeat
about every 3 seconds while an accepted stream would otherwise be quiet. A
quiet open sync stream without heartbeat or other data messages is not
confirmed connectivity after the liveness window elapses.
manifestReady means the runtime has applied or loaded the server manifest for
the current runtime version.
blocked means the current runtime attempt cannot continue. When
blockReason is "versionRequired", requiredVersion contains the server's
required replacement runtime version string.
principal is the opaque string established by sync hello. It is null
before Blackcurrant has a visibility context. Anonymous or logged-out server
state should still use a string principal such as "anonymous" or "public".
progress is best effort. 1 means Blackcurrant is online and not currently
applying a frame or reconciliation, so it is probably caught up. During sync or
reconciliation application, progress reflects committed changes chunks.
Principals And Visibility
Blackcurrant does not perform application login or logout. The host application owns login and logout routes, and the server owns cookies.
Every accepted sync stream starts with manifest setup and then
hello.principal. Blackcurrant compares principal strings only for equality and
never treats them as bearer credentials.
Login, logout, session revocation, membership changes, ACL changes, and similar
events are modeled as visibility changes. When a route changes the principal
for a browser profile, the server should close affected sync streams after
updating cookies or session state. The client reconnects, receives the new
principal in hello, reconciles, and exposes the new principal and repaired
state atomically after reconciliation commits.
If visibility changes without changing the principal, the server sends
{ type: "reconciliation" } on the open sync stream.
To find already-open sync streams for a browser profile, a compatible server
may set a first-party client correlation cookie in the HTTP response headers for
GET /sync/<visibility>/<seq>?version=<runtime-version> when that cookie is
absent. Blackcurrant never reads or writes this cookie. Application routes use
it only to find open sync streams for the same browser profile.
That correlation cookie is operational state, not an auth secret. It should be
random, high entropy, HttpOnly, Secure, SameSite=Lax or stricter,
session-only or short-lived, and not used for analytics, advertising,
cross-site tracking, or long-term profiling.
Sync Freshness
Blackcurrant-backed applications may read committed local state while sync is catching up. The local state can be stale, but it must not be a half-applied frame.
The local database is not a request-slice cache. It is complete as of the latest
committed { visibility, seq } frontier for the established principal.
Before any sync frame or reconciliation has committed, Blackcurrant requests
sequence lower bound -1; after a commit, stored frontiers use non-negative
sequence values, including 0.
Object GET and list endpoints are not part of the Blackcurrant object-state
contract. Authoritative snapshots and tombstones arrive through compact sync
frames or reconciliation.
Reactive Reads
Use watch for reactive local queries:
for await (const users of blackcurrant.watch`
select id, email, name
from Users
order by name
`) {
renderUsers(users)
}
A watch yields the current committed local result, then yields again after a committed local mutation, sync frame, or reconciliation changes the query result. Watches do not yield while a frame or reconciliation is in progress, and they do not yield unchanged result sets after unrelated or no-op committed changes.
Blackcurrant determines the tables used by a watch from the SQL query. The application does not pass an explicit table list. A watch must depend on at least one managed table. Views over managed tables may be watched when Blackcurrant can resolve the query to the managed underlying tables, but queries that depend on unmanaged or temporary tables reject during watch setup.
Mutations
Applications enqueue mutations through the runtime:
await blackcurrant.mutate("User", "a", user)
await blackcurrant.mutate("User", "m", user)
await blackcurrant.mutate("User", "d", user.id)
The operations are add (a), modify (m), and delete (d). Add and modify
require a full object with canonical UUID id; partial updates are not
supported. For non-optimistic resources with readonly fields, add and modify
objects include every non-readonly table column and must omit readonly columns;
queued mutation request bodies also exclude readonly fields. Delete requires a
canonical UUID id string.
mutate(...) resolves after local acceptance. Local acceptance means the
mutation is durably queued in SQLite and, for optimistic resources, the
optimistic SQLite write has committed. If local acceptance fails, mutate(...)
rejects. A successful call may return diagnostic metadata such as local mutation
id, principal, resource, operation, target id, and optimistic flag.
All mutations are first written to a durable global FIFO queue, including mutations created while online. No mutation may be sent to the server before every earlier queued mutation has reached a terminal server outcome or has been invalidated by a committed Blackcurrant rule such as a principal change or an authoritative server-wins conflict for retained queued work that has not yet been sent.
When an authoritative sync frame or reconciliation touches a resource/id whose
mutation request has already been sent by the current active Engine instance
and is still awaiting its HTTP outcome, Blackcurrant does not emit
mutationInvalidated for that target merely because the authoritative change
commits first. That change might be the client's own authoritative echo. The
sent mutation and later queued mutations for the same target remain governed by
FIFO mutation transport outcomes. This current-active-Engine exception is not
guaranteed to survive active worker replacement or runtime reopen, and
applications that need strict stale-write protection should enforce it on the
server with domain preconditions and terminal rejections.
Non-optimistic resources do not change SQLite on local mutation acceptance. Their visible row state changes only when authoritative sync frames or reconciliations commit.
Optimistic resources apply locally accepted mutations directly to the configured SQLite table. For rollback, Blackcurrant keeps an optimistic base snapshot per resource/id while queued optimistic mutations remain. If a queued optimistic mutation is rejected, Blackcurrant restores the latest base and replays later queued optimistic mutations in FIFO order.
When an optimistic head mutation is accepted by the server, Blackcurrant
normally promotes that mutation's effect into the base snapshot. If
authoritative sync or reconciliation refreshed the base for that specific sent
head's target while the request awaited its HTTP outcome in the current active
Engine instance, the later 2xx outcome does not overwrite the refreshed
authoritative base with the local request body. Later accepted FIFO mutations
for the same target still promote normally.
Server acceptance of a mutation is not authoritative object state. Accepted mutation effects still become authoritative through sync or reconciliation.
Errors
Use errors() for best-effort live runtime notifications:
for await (const error of blackcurrant.errors()) {
console.error(error)
}
Mutation rejection errors have this shape:
{
type: "mutationRejected",
mutationId,
resource,
op,
id,
principal,
optimistic,
status,
message,
details,
}
Mutation invalidation errors have this shape:
{
type: "mutationInvalidated",
mutationId,
resource,
op,
id,
principal,
optimistic,
message,
}
Sync errors have this shape:
{
type: "syncError",
code,
message,
}
Reconciliation errors have this shape:
{
type: "reconciliationError",
code,
status,
message,
details,
}
Error streams are live notifications, not a durable event log.
Boundaries
Applications must treat Blackcurrant as the boundary for managed data:
- Application code must not issue direct
insert,update, ordeletestatements against managed SQLite tables. Changes must go through Blackcurrant mutations so local state, optimistic state, queued work, server rejection, and sync remain ordered. - Schema changes must use server-manifest migrations. Do not change managed schemas through ad hoc SQL outside the documented migration path.
Closing
Call close() when a runtime is no longer needed:
await blackcurrant.close()
Closing stops local subscriptions and releases the runtime facade. Other runtimes in the same browser profile can continue using Blackcurrant.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @mika/polite-worker | ~1.1.0 |
| @sqlite.org/sqlite-wasm | ^3.53.0-build1 |
Development dependencies
| ID | Version |
|---|---|
| @playwright/test | ^1.59.1 |