Architecture Decision Records
ADR-001: Single npm package with conditional exports
- Status: Accepted
- Date: 2026-03-19
- Context: The framework has client, server, and shared code. Options: monorepo with separate packages, single package with subpath exports, or separate repos.
- Decision: Single npm package with
package.jsonexportsmap providingdatasole,datasole/client,datasole/serversubpaths. - Consequences: Simpler versioning (one version number), simpler installation (
npm install datasole), tree-shaking relies on bundlerexportssupport. Trade-off: larger package download includes both client and server code, though only used portions are bundled.
ADR-002: Worker-first WebSocket transport with SharedArrayBuffer
- Status: Accepted
- Date: 2026-03-19
- Context: WebSocket I/O on the main thread causes jank during heavy message processing. Web Workers offload I/O but add postMessage serialization overhead.
- Decision: WebSocket connection lives in a Web Worker. SharedArrayBuffer ring-buffer enables zero-copy data transfer when available; postMessage with Transferable ArrayBuffer as fallback.
- Consequences: Main thread stays free for UI rendering. Requires COOP/COEP headers for SAB. Adds complexity in worker lifecycle management. Fallback transport ensures compatibility with environments without Workers (React Native, older browsers).
ADR-003: Binary frames with pako compression end-to-end
- Status: Accepted
- Date: 2026-03-19
- Context: JSON text frames are human-readable but wasteful. WebSocket per-message-deflate is CPU-intensive on the server for many connections.
- Decision: Custom binary frame envelope (opcode + correlation ID + payload length + payload). Application-level pako compression above a configurable threshold (256 bytes). Same codec on client (in-worker) and server.
- Consequences: 60-80% smaller payloads than raw JSON. Compression runs in the worker thread (no main-thread CPU cost). Per-message-deflate is disabled. Trade-off: frames are not human-readable in browser DevTools without a decoder.
ADR-004: JSON Patch (RFC 6902) for state synchronization
- Status: Accepted
- Date: 2026-03-19
- Context: State sync options: CRDTs (complex, large library), OT (complex, server-centric), proprietary binary diff (non-standard), or JSON Patch (standard, simple).
- Decision: Use RFC 6902 JSON Patch for server-to-client state diffs.
fast-json-patchlibrary for production; minimal custom impl in shared/diff as fallback. - Consequences: Standard, debuggable patches. Smaller than full state snapshots for incremental changes. Trade-off: not as bandwidth-efficient as binary diffs for large arrays; no conflict resolution (server is authoritative).
ADR-005: Pluggable state backends via interface
- Status: Accepted
- Date: 2026-03-19
- Context: Different deployments need different persistence: in-memory for dev, Redis for multi-process, Postgres for persistence.
- Decision:
StateBackendinterface with get/set/delete/subscribe/publish. Built-in: MemoryBackend (default), RedisBackend (optional peer dep), PostgresBackend (optional peer dep). Interface is public for custom backends. - Consequences: Clean separation of state management from transport. Optional peer deps keep the core lightweight. Trade-off: each backend must implement pub/sub semantics, which is non-trivial for SQL databases.
ADR-006: Framework-agnostic client with no runtime framework deps
- Status: Accepted
- Date: 2026-03-19
- Context: The client must work with React, Vue 3, Svelte, React Native, and vanilla JS. Options: provide framework-specific wrappers, or keep the core framework-free.
- Decision:
DatasoleClientis a plain TypeScript class with no framework imports. Returns plain objects compatible with any reactivity system. Framework-specific hooks/composables can be built on top but are not part of the core. - Consequences: Maximum compatibility. No framework-specific dependencies in the client bundle. Trade-off: users must write their own integration glue (a few lines per framework).
ADR-007: Default WebSocket path /__ds
- Status: Accepted
- Date: 2026-03-19
- Context: Need a default path for the WebSocket endpoint that is unlikely to conflict with application routes.
- Decision: Default path is
/__ds(double-underscore prefix convention for framework internals, "ds" for datasole). Configurable viaDatasoleClientOptions.pathandDatasoleServerOptions.path. - Consequences: Avoids collision with common paths like
/ws,/socket,/api. The double-underscore convention signals "framework internal". Trade-off: slightly unconventional, but memorable and short.
ADR-008: ConnectionContext as shared per-connection state bag
- Status: Accepted
- Date: 2026-03-19
- Context: Multiple subsystems (RPC handlers, event handlers, rate limiters, session managers) need access to per-connection information: auth identity, metadata from upgrade headers, and arbitrary user-set state. Threading the auth result through every callback is ergonomically poor.
- Decision:
ConnectionContextinterface, instantiated once per connection from the upgrade auth result, passed as part ofRpcContextand available to event handlers. Provides typedget<T>/set/deletefor arbitrary state, plus immutableauth,userId,metadata,tags. - Consequences: Clean, single object for all per-connection state. Auth data from upgrade headers is automatically populated. Custom middleware can enrich context via
set(). Trade-off: mutable bag can accumulate unbounded state if not disciplined.
ADR-009: CRDT support for bidirectional client ↔ server synchronization
- Status: Accepted
- Date: 2026-03-19
- Context: JSON Patch (ADR-004) is server-authoritative, which is ideal for server→client live data. But collaborative editing, shared counters, and multi-client state require conflict-free bidirectional sync.
- Decision: Built-in CRDT primitives in
src/shared/crdt/:LWWRegister<T>(last-writer-wins scalar),PNCounter(positive-negative counter with per-node vector), andLWWMap<T>(last-writer-wins map of registers). All implement a commonCrdt<T>interface withapply(op),merge(state),state(), andvalue(). Operations are transmitted asCrdtOperationframes over the binary protocol. Client-sideCrdtStorequeues local ops for immediate local application + async server sync. Server merges all clients' ops and rebroadcasts the resolved state. - Consequences: Enables conflict-free collaborative state (counters, presence, shared documents). Reuses the existing binary frame protocol. Trade-off: CRDTs are eventually consistent—no strong ordering guarantees. Current primitives cover common cases; richer CRDTs (OR-Set, RGA for text) can be added later via the
Crdt<T>interface.
ADR-010: SyncChannel with configurable flush strategies
- Status: Accepted
- Date: 2026-03-19
- Context: Different real-time use cases need different synchronization granularities. A stock ticker needs immediate pushes; a dashboard can batch updates; a form auto-save should debounce.
- Decision:
SyncChannel<T>manages a queue ofStatePatchoperations with three flush strategies:immediate(flush on every enqueue),batched(flush after N ops or M milliseconds, whichever comes first), anddebounced(flush after M ms of inactivity). Channels are created per key viads.primitives.live.createSyncChannel()with direction (server-to-client,client-to-server,bidirectional) and mode (json-patch,crdt,snapshot). - Consequences: One API covers all real-time patterns—stock tickers, collaborative editing, form sync, live dashboards. Trade-off: batched/debounced strategies introduce latency;
immediateis the default for lowest-latency use cases.
ADR-011: Data flow patterns as composable primitives
- Status: Accepted
- Date: 2026-03-19
- Context: The framework must support a mix-and-match set of patterns: RPC, server→client events (broadcast), client→server events, bidirectional events (CRDTs), server→client live data structures (JSON Patch), client→server live data, and combinations thereof. Each pattern has different consistency, latency, and API characteristics.
- Decision: Data flow patterns are defined as the
DataFlowPatterndiscriminated union. Each pattern is served by a corresponding subsystem:RpcDispatcherfor RPC,EventBusfor events,SyncChannelwithjson-patchmode for live data structures,SyncChannelwithcrdtmode for bidirectional sync. The framework composes these freely—a single connection can use multiple patterns concurrently. The minimum viable set of use cases is: (1) pure RPC, (2) server event broadcast (e.g. stock ticker), (3) client→server RPC + server→client live state for seamless frontend data binding (React/Vue reactive model backed by server-side state). - Consequences: Users pick only the patterns they need. Composability avoids framework lock-in to a single paradigm. Trade-off: more concepts to learn, but each is independently useful and well-documented.
ADR-012: Decompose DatasoleServer into Composable Layers
Status: Accepted
Date: 2026-03-23
Context: DatasoleServer was a 452-line god class conflating transport, frame routing, rate limiting, domain primitives, data-flow orchestration, and lifecycle. The concurrency module was dead code. Backend usage was limited to StateManager/SessionManager. Auth and rate-limiting were standalone modules not integrated with the backend distribution layer.
Decision: Decompose into four layers:
- Transport — pure byte pipe (ServerTransport)
- Executor — compressed frame processing + isolation (AsyncExecutor, ThreadExecutor, PoolExecutor)
- Backends — distribution layer (StateBackend with factory + serializable config)
- Primitives — all backend-powered services (RPC, Events, State, CRDT, Sessions, Sync, Auth, Rate-limit, Data-flow)
Additionally:
- DatasoleServer/DatasoleClient become generic with required DatasoleContract type parameter
- All stateful services receive StateBackend via constructor injection
- EventBus, CrdtManager, SyncChannel, RateLimiter are backend-powered
- Old concurrency module replaced by ConnectionExecutor
Consequences: Breaking API change: all method calls changed (
ds.rpc.register()vsds.rpc()). DatasoleContract type parameter is required (no DefaultContract). All demos restructured withshared/contract.ts. Better testability via constructor injection and interface-first design. Better extensibility: new primitives implement RealtimePrimitive interface. Distribution via backend swap (MemoryBackend → RedisBackend).
ADR-013: Zero eslint-disable — fix types, don't suppress rules
Status: Accepted
Date: 2026-03-23
Context:
eslint-disablecomments are a code smell. They hide type errors behind suppressions instead of fixing the root cause. Even in test code,as anycasts propagate type-unsafety and make refactoring blind. Two eslint-disable comments existed: one for@typescript-eslint/no-explicit-anyin a unit test RPC handler that usedctx: anyinstead ofRpcContext, and one forno-control-regexin the build summary printer that matched ANSI escape sequences.Decision: Adopt a zero-tolerance policy for
eslint-disablecomments andas anycasts:- No
eslint-disable— if a rule fires, fix the code. If the rule is wrong for the project, disable it in.eslintrcglobally with justification. - No
as any— use proper generics,unknownwith type guards, or well-typed interfaces. The only acceptable type escape hatches areas never(for generic variance boundaries in internal plumbing) andas unknown as T(for test doubles where the full interface isn't needed). - Typed browser globals — E2E tests declare a
Windowaugmentation (test/e2e/types/test-window.d.ts) so Playwrightpage.evaluate()calls are fully typed instead of usingwindow as any. catch (e: unknown)— error handlers useunknownwithinstanceof Errorguards, nevercatch (e: any).
- No
Consequences: All type errors surface at compile time. Refactoring tools can follow types through the entire codebase including tests. No hidden suppressions. Trade-off: slightly more verbose catch blocks and occasionally verbose generic constraints, but this is a feature, not a cost — it forces explicit handling of edge cases.
ADR-014: Standardize demos into client/server/shared layout
- Status: Accepted
- Date: 2026-03-24
- Context: Demo projects used mixed top-level layouts (
public/,src/, rootserver.mjs) that made onboarding and framework comparison inconsistent. - Decision: All demos use top-level
client/,server/, andshared/directories. Theshared/contract is the single source of truth for RPC methods, events, state keys, and payload types. - Consequences: Better DX and clearer parity across vanilla, React+Express, and Vue+NestJS demos. Build tooling needs path updates for Vite and server entry points.
ADR-015: DatasoleServer serves client runtime assets
- Status: Accepted
- Date: 2026-03-24
- Context: Integrations and demos repeatedly implemented custom routes for
datasole.iife.min.jsanddatasole-worker.iife.min.js, causing duplication and drift. - Decision:
DatasoleServer.transport.attach()serves production client/worker runtime assets at the configured Datasole path with fixed filenames:{path}/datasole.iife.min.js{path}/datasole-worker.iife.min.jsServer responses include ETag,If-None-Matchhandling, and304 Not Modifiedsupport.
- Consequences: Integrations no longer need bespoke runtime asset routes. Server build packaging must always include the client and worker production bundles.
ADR-016: backendConfig wiring, init(), and thread executors delegating to async routing
- Status: Accepted (lifecycle entry superseded by ADR-019 naming; behavior unchanged)
- Date: 2026-03-24
- Context:
DatasoleServerOptions.backendConfigwas documented but ignored; Redis/Postgres backends requireconnect()before use;thread/thread-poolexecutors dropped frames on the floor while sharing the same public API asasync. - Decision:
- Resolve
stateBackendvsbackendConfig(mutually exclusive);createBackend(backendConfig)when no explicitstateBackendis passed. - Async startup is
await datasoleServer.init()(runs optional backendconnect(); no-op forMemoryBackend). See ADR-019. - Implement
ThreadExecutorandPoolExecutoras thin wrappers aroundAsyncExecutorso frame routing andwireFrameHandlersbehave identically until realworker_threadsisolation exists. - Validate
PostgresBackendtableNameas a safe SQL identifier before interpolation.
- Resolve
- Consequences: Declarative backend config works; distributed backends can be brought up safely; non-async executor models no longer silently break the protocol. Trade-off: thread models do not yet provide CPU isolation—only the same routing path as
async. - Worker asset headers:
datasole-worker.iife.min.jsuses the same COOP/COEP as the main bundle, butCross-Origin-Resource-Policy: cross-origininstead ofsame-origin.CORP: same-originon the worker script breaksnew Worker()for typical HTML pages that do not enable cross-origin isolation;cross-originkeeps workers loadable for both COEP test pages and normal SPAs.
ADR-017: Contract-first integration style for apps, docs, and generated code
- Status: Accepted
- Date: 2026-03-24
- Context: Raw string literals for RPC methods, event names, and state keys drift between client and server, break refactors, and tempt LLMs to invent inconsistent APIs. Tutorials and demos need a single pattern that is both type-safe and easy to copy.
- Decision: Production-oriented examples use a shared
AppContractextendingDatasoleContract, colocated withenum(or equivalent const-key maps) forRpcMethod,Event, andStateKey. The same module is imported on client and server. Public docs and agent-generated snippets should default to this pattern unless the snippet is intentionally minimal (e.g. one-liner hello world). - Consequences: End-to-end typing, grep-friendly method names, and alignment with
RpcParams/RpcResult/EventData/StateValuehelpers. Agents and humans produce compatible code more often. Trade-off: slightly more boilerplate than string-only examples.
ADR-018: Server extension surface — what exists vs. what agents must not invent
- Status: Accepted
- Date: 2026-03-24
- Context: Automated coding agents often hallucinate framework hooks (custom middleware chains, injectable rate-limiter classes, multiple backends per server). Documenting the actual extension points reduces bad patches and impossible APIs.
- Decision: Treat the following as canonical for analysis, codegen, and review:
- One
StateBackendperDatasoleServer— multi-store setups use a composite/facade implementingStateBackend, not multiplestateBackendoptions (see ADR-005). - Rate limiting — Default implementation is
DefaultRateLimiter, constructed with the sameStateBackendunlessrateLimiteris injected. Tune behavior withrateLimit(defaultRule,rules,keyExtractor). CustomRateLimiterimplementations may expose optionalconnect()for startup (see ADR-019). - Authentication —
authHandlerruns on the HTTP WebSocket upgrade only (IncomingMessage→AuthResult). There is no per-frame auth callback on the datasole protocol. - Metrics — in-process
MetricsCollectoronds.metrics; push to vendors by callingMetricsExporter.export(ds.metrics.snapshot())from application code (or your framework’s metrics hook). Application/business metrics belong in the host app’s observability stack.
- One
- Consequences: Agents can wire Redis/Postgres backends, inject or default the limiter, tune rules, and add JWT/cookie auth without inventing fake constructor options.
ADR-019: Hierarchical DatasoleServer API, DI, and init()
- Status: Accepted
- Date: 2026-03-25
- Context:
DatasoleServerexposed a flat surface mixing transport, orchestration, and primitives;DefaultRateLimiternaming did not signal default behavior; framework integrations (NestJS async providers, injected Redis clients) need a clear composition root and phased startup. - Decision:
- Hierarchy:
ds.transport(attach, connection count; wrapsServerTransport+ wire handlers),ds.rpc,ds.metrics,ds.primitives(state,events,live— setState/getState, sync/data channels,fanout— broadcast to clients,crdt,sessions,rateLimiter). Top-level orchestration methods are removed fromDatasoleServer. - Parent pointer:
DatasoleServerTransportFacadeexposesreadonly server: DatasoleServer<T>for nested code that needs the composition root. - Lifecycle:
await ds.init()replacesinitialize(); runsStateBackend.connect()when present and optionalRateLimiter.connect().ds.transport.attach(httpServer)afterinit()for distributed backends. - Rename:
DefaultRateLimiter→DefaultRateLimiter(same implementation; constructor takes the server’sStateBackend). - DI: Optional
rateLimiterinDatasoleServerOptions; defaultnew DefaultRateLimiter(backend).
- Hierarchy:
- Consequences: Breaking API for direct
ds.attach/ds.broadcast/ flat primitive fields; docs and demos updated. Phased NestJS setup: async providers for infra → factory constructsDatasoleServer→onModuleInit:await ds.init()thends.transport.attach.
