Getting Started - TypeScript
A bicycle setup consists of three parts:
- A schema - this defines what data exists and how it is accessed and updated
- A server - this exposes the schema, and establishes the "context" of the query, such as which user is running the query
- A client - this connects to the server and runs queries and updates
Schema
Inside you're project's directory, create a folder called "src/bicycle-schema".
Schema Objects
In src/bicycle-schema/
create a new file, Root.ts
:
import {getTodo, getTodos} from 'todo-memory-store';
import BaseObject from 'bicycle/BaseObject';
import ID from './ID';
import Todo from './Todo';
export default class Root extends BaseObject<{}> {
$auth = {
public: ['todos', 'todoById'],
};
async todos(args: void, ctx: {user: {id: number}}): Promise<Todo[]> {
return (await getTodos()).map((t: any) => new Todo(t));
}
async todoById({id}: {id: ID}, ctx: {user: {id: number}}): Promise<Todo> {
return new Todo(await getTodo(id));
}
}
All queries start from the Root
object, so your bicycle schema must always have an object called Root
. The object consists of a number of "fields". These are methods on the class. To indicate to bicycle that the object is queryable, it must extend BicycleObject
. To expose a method as a queryable field, it must be included in the $auth.public
array.
In src/bicycle-schema/
create a new file, Todo.ts
:
import {addTodo, toggle, setTitle, destroy} from 'todo-memory-store';
import BaseObject from 'bicycle/BaseObject';
import ID from './ID';
export default class Todo extends BaseObject<{
id: ID;
title: string;
completed: boolean;
}> {
$auth = {
public: ['id', 'title', 'completed', 'notCompleted'],
};
notCompleted(_args: void, ctx: {user: {id: number}}): boolean {
return !this.data.completed;
}
static $auth = {
public: [
'addTodo',
'toggleAll',
'toggle',
'destroy',
'save',
'clearCompleted',
],
};
static async addTodo({
title,
completed,
}: {
title: string;
completed: boolean;
}): Promise<{id: ID}> {
return {id: await addTodo({title, completed})};
}
static async toggle({id, checked}: {id: ID; checked: boolean}) {
await toggle(id, checked);
}
static async setTitle({id, title}: {id: ID; title: string}) {
await setTitle(id, title);
}
static async destroy({id}: {id: ID}) {
await destroy(id);
}
}
Other than the Root
object, all objects have a raw record value called this.data
. You specify the type of that record within <...>
on the extend BicycleObject<...>
. You can expose any field on the raw data object, just by putting it in the $auth.public
array.
To include calculated fields, add methods, just like on the Root
object.
We can also specify static
mutations, which are methods you that can update the data. After a mutation is run, bicycle will automatically re-query the data to find out what changed. Mutations can also return data. Data returned from a mutation is available to the caller, but is not subscribed to.
Appart from the
Root
, all objects must have a property calledid
, that is either astring
or anumber
. This is used to normalize the data, so that only one copy of each object exists, even if they appear multiple times in the graph of results.
Schema Scalars
Bicycle can validate most of the built in TypeScript data types, including objects and enums, as long as they can be represented in JSON. We call these values "Scalars". You can define your own custom runtime validation by adding an "opaque type". We are going to add an id
type.
In src/bicycle-schema/
create a new file, ID.ts
:
export const enum IDBrand {}
type ID = IDBrand & string;
export function validateID(value: string): value is ID {
// validate that it matches the format of the values
//returned by uuid() in todo-memory-store
return /^[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}$/.test(value);
}
export default ID;
Generate Runtime
Run npx ts-bicycle src/bicycle-schema src/bicycle
. This will generate a new folder called src/bicycle
with a typed server and a typed client, based on the Objects and Scalars in src/bicycle-schema
.
Server
Create a file called server.ts
import express from 'express';
import browserify from 'browserify-middleware';
import babelify from 'babelify';
import BicycleServer from './bicycle/server';
const bicycle = new BicycleServer();
const app = express();
app.get('/', (req, res, next) => {
res.sendFile(__dirname + '/index.html');
});
app.get('/client.js', browserify(__dirname + '/client.js', {transform: [babelify]}));
// req is the express web request
// {id: 42} will be the "user" value in the bicycle schema
app.use('/bicycle', bicycle.createMiddleware(req => ({user: {id: 42}})));
app.listen(3000);
This serves up our client app as client.js
and index.html
. This example assumes you are using browserify
and babel
to compile your client side code. You can use webpack if you prefer, browserify just requries less config to show in the demo.
It also adds a /bicycle
endpoint that can be used for bicycle queries.
N.B. If you use cookies to store sessions for authentication you must add CSRF protection or your app will be insecure.
Client
Add an src/index.html
file:
<div>Open dev tools to see results of bicycle queries</div>
<script src="/client.js"></script>
Add a src/client.ts
file:
import BicycleClient from './bicycle/client';
import * as q from './bicycle/query';
// defaults to using the `/bicycle` path
const client = new BicycleClient();
const subscription = this._client.subscribe(
q.Root.todos(q.Todo.id.title.completed),
(result, loaded, errors) => {
if (loaded) { // ignore partial results
// this will be called each time the list
// of todo items changes
console.log(result);
}
},
);
async function run() {
const {id} = await client.update(Todo.addTodo({
title: 'Hello World',
completed: false,
}));
await client.update(Todo.toggle({id, completed: true}),
(mutation, cache) => {
// this function lets you update the cache optimistically
// its effects are reverted once the mutation has completed
// or failed
cache
.getObject('Todo', mutation.args.id)
.set('completed', mutation.args.completed);
}
);
// stop listening for updates from the server
subscription.unsubscribe();
}
setTimeout(() => {
run().catch(ex => console.error(ex.stack || ex.message || ex));
}, 1000);
React
To use with React, you can use react-bicycle
.
Update index.html
:
<div id="app"></div>
<script src="/client.js"></script>
Update client.ts
:
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import BicycleClient from './bicycle/client';
import * as q from './bicycle/query';
import {BicycleProvider, useClient, useQuery} from 'react-bicycle';
const client = new BicycleClient();
const TodoQuery = q.Todo.id.title.completed;
function Todo({todo}: {todo: typeof TodoQuery.$type}) {
const client = useClient();
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={e => client.update(Todo.toggle({
id: todo.id,
completed: e.target.checked
}))}
/>
{todo.title}
</li>
)
}
function App() {
const client = useClient();
const [newTitle, setNewTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
const query = useQuery(q.Root.todos(TodoQuery));
return (
<>
{
!query.loaded
? query.render() // render loading/error indicator
: (
<ul>
{query.result.todos.map(todo => <Todo key={todo.id} todo={todo} />)}
</ul>
)
}
<form
onSubmit={async e => {
e.preventDefault();
if (submitting || newTitle === '') return;
setSubmitting(true);
try {
await client.update(Todo.addTodo({title: newTitle, completed: false}));
} finally {
setSubmitting(false);
}
setNewTitle('');
}}
>
<input disabled={submitting} value={newTitle} onChange={e => setNewTitle(e.target.value)} />
<button disabled={submitting || newTitle === ''} type="submit">Add Todo</button>
</form>
</>
)
}
ReactDOM.render(
<BicycleProvider client={client}>
<App/>
</BicycleProvider>,
document.getElementById('app'),
);