@mika/blackcurrant (3.0.1)

Published 2026-05-19 06:57:51 +00:00 by mika

Installation

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

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

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

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 as 1 are 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.
  • mutationRetryMs: retry delay after retryable mutation transport failures; defaults to 1000.
  • mutationTimeoutMs: per-request mutation transport timeout; defaults to 30000.
  • reconciliationTimeoutMs: reconciliation inactivity timeout; defaults to 30000 and is refreshed by response headers or streamed messages.
  • onlineLivenessWindowMs: server-online liveness window after observable sync or reconciliation response activity; defaults to 5000. Increase it for applications that need to tolerate longer quiet periods on poor connectivity.

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 as User or UserComment.
  • Table name: the local SQLite table, such as Users or UserComments.
  • URL path: the HTTP collection path, such as /users or /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 means false.
  • 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: false
  • path: "/" + 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 id primary 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, or delete statements 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.0.0
@sqlite.org/sqlite-wasm ^3.53.0-build1

Development dependencies

ID Version
@playwright/test ^1.59.1
Details
npm
2026-05-19 06:57:51 +00:00
29
200 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