Instructions for AI agents working on this codebase.
Resync is a framework for building realtime universal (SSR + client) Reason React applications with PostgreSQL-backed state. The monorepo contains reusable packages and demo apps.
# Install dependencies
pnpm install
# Build everything (continuous watch)
pnpm run dune:watch
# Run ecommerce demo (requires separate terminal for each)
pnpm run ecommerce:watch # restarts server on UI rebuild
# Run todo demo
pnpm run todo:watch # restarts server on UI rebuild
# Build a specific target
dune build @app # ecommerce client (from demos/ecommerce)
dune build demos/ecommerce/server/src/server.exe # ecommerce server (from repo root)
dune build demos/todo/ui/src/.build_stamp # todo client (from repo root)
dune build demos/todo/server/src/server.exe # todo server (from repo root)For AI agents: Due to orchestration tool issues with running dune directly (stdout/stderr capture, timeout handling), agents should prefer using the Python wrapper:
python scripts/run_dune.py build @all-apps
python scripts/run_dune.py build packages/universal-reason-react/store/js
python scripts/run_dune.py exec ./packages/universal-reason-react/store/test/store_runtime_test.exe
python scripts/run_dune.py cleanThe wrapper runs dune from the repo root, captures output cleanly, and has a configurable timeout (default 120s, override with DUNE_TIMEOUT env var). End users should run dune directly.
After making changes, always rebuild to verify:
dune build @appfromdemos/ecommerce(or equivalent for the target you changed)dune build demos/<demo>/server/src/server.exefrom repo root
Each demo requires specific env vars in .envrc:
- Ecommerce:
DB_URL,API_BASE_URL,ECOMMERCE_DOC_ROOT - Todo:
TODO_DOC_ROOT(set to./_build/default/demos/todo/ui/src/) - Todo Multiplayer:
DB_URL,TODO_MP_DOC_ROOT(set to./_build/default/demos/todo-multiplayer/ui/src/)
Ecommerce demo uses Docker for PostgreSQL:
docker compose up -d # starts postgres with auto-initialized schema + triggersUse Playwright MCP for end-to-end browser testing. Do NOT create Node.js test scripts.
Browser test scripts compile with Melange and run with Node.js:
# Video chat demo browser tests
pnpm run video-chat:test:browserThe lsp_diagnostics tool has limited support for Reason (.re) and OCaml (.ml) files in this project, especially in test directories. It commonly returns:
No supported source files found in directorywhen scanning directories containing.reor.mlfilesNo diagnostics foundfor individual files even when build errors exist
Workaround: rely on dune build (via the Python wrapper) as the source of truth for compile errors and type issues, rather than LSP diagnostics.
packages/
realtime-schema/ # SQL annotation parser, codegen, PPX, CLI
src/
Realtime_schema_types.ml
Realtime_schema_parser.ml
Realtime_schema_codegen.ml
Realtime_schema_ppx.ml
Realtime_schema_caqti.ml
universal-reason-react/
router/ # Universal router (shared route tree for server + client)
store/ # Tilia-backed offline-first store with SSR, persistence, realtime sync
js/ # JS/Melange implementations
StoreBuilder.re # Public runtime store builders
StoreCrud.re # Generic CRUD helpers for realtime patches
StoreIndexedDB.re # IndexedDB confirmed-state and action-ledger storage
StoreOffline.re # Local and synced runtime implementations
StorePatch.re # Patch decoding infrastructure
StoreSource.re # Tilia source wrappers
native/ # Server-side copies
reason-realtime/
pgnotify-adapter/ # PostgreSQL LISTEN/NOTIFY adapter
dream-middleware/ # Dream websocket middleware
esbuild-plugin/ # Client component extraction
universal-reason-react/ # Also: components, lucide-icons, intl
demos/
ecommerce/ # Full demo: DB, realtime, SSR
server/sql/ # Annotated SQL files (source of truth)
generated/ # Auto-generated triggers, migrations, snapshots
server/src/ # Dream server
ui/src/ # Client UI (Reason React)
shared/js/ # Shared types (Model.re, RealtimeSchema.ml)
shared/native/ # Server-side shared types
todo/ # Minimal demo: SSR, hydration, no DB/realtime
This project uses server-reason-react to render the same ReasonReact code on both the server (native OCaml) and the client (JavaScript via Melange). This is not an isomorphic Node.js setup — the server is a compiled native binary.
- Melange compiles
.refiles to.jsin a(melange.emit ...)target directory (e.g.,app/) - esbuild bundles the Melange JS output into
Index.re.jsandIndex.re.cssfor the browser - The server compiles the same
.refiles as native OCaml viaserver-reason-react's PPX transforms - The server's dune file copies
.resources fromui/src/into the server build context using(copy_files) - Shared types live in
shared/js/(Melange) andshared/native/(native), each with their own dune library
Code that runs on both server and client must compile under both targets. This means:
- Use
Js.Array.*for array operations — these are polyfilled by server-reason-react for native. Do NOT useArray.append(OCaml stdlib) orList.*for store collections. - Use
->chaining forJs.Array.*methods — they use[@mel.this]binding:items->Js.Array.filter(~f=...) - Use
switch%platformfor platform-specific behavior (DOM access, localStorage, etc.) - Use
[@platform js]/[@platform native]for platform-specific module implementations (provided by server-reason-react PPX) - Do not use browser-only APIs outside of
[@platform js]blocks (e.g.,document,window,fetch) - Do not use Node.js APIs or npm packages — the server is native OCaml, not Node
Each package that needs to work on both targets has two dune libraries:
shared/
js/dune # (library ...) with melange PPX preprocessors
native/dune # (library ...) with native PPX preprocessors
The server depends on *_native libraries; the client depends on *_js libraries. Both compile the same .re source files.
- Reason (
*.re) for application code and store/router libraries - OCaml (
*.ml) for infrastructure (PPX, codegen, CLI, native adapters) - SQL (
*.sql) with comment annotations for schema definitions
All stores use the terminal value-level builders (StoreBuilder.buildLocal, StoreBuilder.buildSynced, or StoreBuilder.buildCrud) with the pipeline API. Do NOT define top-level bindings and then pass them through as let x = x:
// CORRECT: terminal builder with inline pipeline
module StoreDef =
(val StoreBuilder.buildLocal(
StoreBuilder.make()
|> StoreBuilder.withSchema({
storeName: "todo.simple",
emptyState: { /* ... */ },
reduce: (~state, ~action) => state,
makeStore: (~state, ~derive=?, ()) => { /* ... */ },
})
|> StoreBuilder.withJson(~state_of_json, ~state_to_json, ~action_of_json, ~action_to_json)
|> StoreBuilder.withLocalPersistence(
~storeName="todo.simple",
~scopeKeyOfState=_state => "default",
~timestampOfState=state => state.updated_at,
~stateElementId=None,
(),
)
));
include (
StoreDef:
StoreBuilder.Runtime.Exports
with type state := state
and type action := action
and type t := store
);
type t = store;
module Context = StoreDef.Context;
// WRONG: top-level bindings passed to functor
let emptyState = { /* ... */ };
let reduce = (~state, ~action) => state;
module Runtime = StoreBuilder.Runtime.Make({
let emptyState = emptyState;
let reduce = reduce;
});Runtime behavior:
StoreBuilder.buildLocalis local-only and persists confirmed snapshots to IndexedDB, then propagates newer confirmed state across tabs withBroadcastChannelStoreBuilder.buildSyncedandStoreBuilder.buildCrudpersist confirmed snapshots plus an IndexedDB action ledger, send typed JSON actions over websocket, and propagate optimistic actions plus confirmed snapshots across tabs- For synced stores, cross-tab updates should not depend solely on websocket delivery; optimistic replay comes from the shared IndexedDB ledger
Store state uses array throughout. Use Js.Array.* functions (which work in both Melange JS and native via server-reason-react):
Js.Array.filter(~f=..., items)— notList.filterJs.Array.map(~f=..., items)— notList.mapJs.Array.concat(~other=..., items)— notArray.appendorList.append- Use
->chaining forJs.Array.*methods (they use[@mel.this]):items->Js.Array.filter(~f=...) Array.lengthis fine for getting length
Use StoreCrud for standard CRUD tables. Do NOT write custom patch types, upsert/delete functions, or manual StorePatch.Pg.decodeAs wiring:
type patch = StoreCrud.patch(MyItem.t);
// Inside functor body:
let decodePatch =
StorePatch.compose([
StoreCrud.decodePatch(
~table=RealtimeSchema.table_name("items"),
~decodeRow=MyItem.of_json,
(),
),
]);
let updateOfPatch = StoreCrud.updateOfPatch(
~getId=(item: MyItem.t) => item.id,
~getItems=(config: config) => config.items,
~setItems=(config: config, items) => {...config, items},
);For multi-table stores, compose decoders and use a wrapped variant:
type patch =
| ItemsPatch(StoreCrud.patch(Item.t))
| UsersPatch(StoreCrud.patch(User.t));Annotated SQL files in server/sql/ are the source of truth. The PPX reads them at compile time.
Key annotations:
-- @table <name>— marks a table for realtime-- @id_column <col>— primary key column-- @broadcast_channel column=<col>— which column determines the NOTIFY channel-- @broadcast_parent table=<parent> query=<named_query>— child table triggers parent re-broadcast-- @composite_key <col1>, <col2>— composite primary key-- @query <name>— named query in block comments (/* ... */)-- @json_column <col>— column returned as::textthat needs JSON normalization in triggers
Especially package.json, watch scripts, or unrelated config files.
REALTIME_QUERY_REFACTOR.md— full architecture plan for the realtime schema systemdocs/universal-reason-react.store.md— store API reference and patternsdocs/dream-router-store-setup.md— step-by-step guide for Dream + Router + Store setupdocs/API_REFERENCE.md— complete API reference for all packagesdocs/reason-realtime.pgnotify-adapter.md— PostgreSQL adapter docsdocs/reason-realtime.dream-middleware.md— Dream websocket middleware docs