Logux Data is a new state manager for Logux with: built-in CRDT types, GraphQL-like data loading, good tree-shaking and types support.
import { CrossTabClient } from '@logux/client'
// Svelte, Vue, Preact integration will be also available
import { ObjectsProvider } from '@logux/client/react'
import { ObjectSpace } from '@logux/client'
export const client = new CrossTabClient(…)
const objects = new ObjectSpace(client)
render(
<ObjectsProvider data={data}>
<App />
</ObjectsProvider>,
document.getElementById('root')
)
import { CrossTabClient } from '@logux/client'
import { Date } from '@logux/client'
const client = new CrossTabClient(…)
const objects = new ObjectSpace(client)
import { TestObjectSpace } from '@logux/client'
let objects: TestObjectSpace
beforeEach(() => {
objects = new TestObjectSpace()
})
import { Map } from '@logux/client'
export class User extends Map {
// Will create actions like `users/add`, `users/change` and `users/:id` channel
static modelName = 'user'
name: string = ''
}
import { Map } from '@logux/client'
import { User } from './user'
export class Comment extends Map {
static modelName = 'comment'
text: string = ''
createdAt: number = Date.now()
user: User
constructor (id: string, user: User) {
super(id)
this.user = user
}
get createdTime () {
return new Date(this.createdAt)
}
}
// models/Task.js
import { Map, Vector, Text, Set. fieldParams } from '@logux/client'
import { Comment } from "./comment"
export class Task extends Map {
static modelName = 'task'
title: string = ''
finished: boolean = false
finishedAt: number | null = null
comments: Comment[] = []
description = new Text(this, 'description')
imageUrl: string = ''
tags = new Set(this, 'tags')
get finishedTime() {
return new Date(this.finishedAt)
}
on: {
change (field, value) {
if (field === 'finished') {
this.finishedAt = value ? Date.now() : 0
}
}
}
}
export function imageUrl (size: number) {
return fieldParams({ size })
}
// Vector is like an array in CRDT
export class TaskList extends Vector<Task> {
static modelName = 'taskList'
}
// controllers/TaskList.tsx
import { useFromServer } from "@logux/client/react"
import { Loader, TaskListPage } from "../components"
import { TaskList, Task } from "../models"
export const TaskListController = ({ userId }: { userId: string }) => {
// Will request server only if `data` have no any other active subscriptions
// for `taskList/{userId}`
const [isLoading, tasks] = useFromServer(TaskList, {
id: userId,
fields: {
// Will load only this fields from the server
values: {
name: true,
finished: true
}
}
})
if (isLoading) {
return <Loader />
} else {
return <TaskListPage
tasks={tasks.values}
onMove={(prev, moved) => tasks.move(prev, moved)}
onFinish={(task, value) => task.change('finished', value)}
onCreate={(title) => tasks.add(new Task({ title }))}
/>
}
}
// controllers/Task.tsx
import { useFromServer, params } from "@logux/client/react"
import { fieldParams } from "@logux/client"
import { Loader, TaskPage } from "../components"
import { Task, imageUrl } from "../models"
export const TaskController = ({ taskId }: { taskId: string }) => {
const [isLoading, task] = useFromServer(Task, {
id: taskId,
fields: {
// Required fields can be nested. Types support is built-in.
// https://twitter.com/sitnikcode/status/1312362066414575618
name: true,
finished: true,
description: true,
imageUrl: imageUrl(100), // Fields with parameters
comments: {
user: {
name: true
}
text: true
}
}
})
if (isLoading) {
return <Loader />
} else {
return <TaskPage
task={task}
onRename={newName => task.change('name', newName)}
onFinishedToggle={value => task.change('finished', value)}
/>
}
}
// controllers/app.js
import { Component } from "react"
import { Router } from "./router"
import { ErrorPage } from "../components"
class App extends Component {
constructor (props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError (error) {
return { hasError: error.name }
}
render () {
if (this.state.hasError === "LoguxNotFound") {
return <ErrorPage error={404} />
} else if (this.state.hasError === "LoguxNoAccess") {
return <ErrorPage error={403} />
} else {
return <Router />
}
}
}
// queries.ts
import { createQuery } from "@logux/client"
import type { Task } from "../models"
// Custom query with callback on the server. You do not need to define
// queries for simple key-value request.
export const searchText = createQuery<Task, {
text: string,
limit: number
}>('fullTextSearch')
// controllers/search.tsx
import { useState } from "react"
import { useServerQuery } from "@logux/client/react"
import { searchText } from "../queries"
import { Task } from "../models"
export const SearchController = () => {
let [finishedOnly, setFinishedOnly] = useState<boolean>(false)
let [textSearch, setTextSearch] = useState<string>('')
let [page, setPage] = useState<number>(1)
let [isLoading, tasks] = useServerQuery(
Task,
textSearch === ''
? { filter: { finished: true }, limit: page * 50 } // Simple filter
: searchText({ text: textSearch, limit: page * 50 }) // Custom filter
)
return <SearchPage
onFinishedFilter={filter => {
setFinishedOnly(filter)
setPage(1)
}}
onTextFilter={filter => {
setTextSearch(filter)
setPage(1)
}}
finishedFilter={finishedOnly}
textFilter={textSearch}
items={tasks}
loaderAfterItems={isLoading}
onNextPage{() => setPage(page + 1)}
/>
}
// models/settings.ts
import { Map } from "@logux/client"
export class Settings extends Map {
static modelName = 'settings'
// Means that model has no `id` and use `settings` channel instead of `settingses/:id`
static single = true
theme: 'auto' | 'light' | 'dark' = 'auto'
}
import { useCrossTab } from "@logux/client/react"
import { Settings } from "../models"
export const Layout = ({ children }) => {
const settings = useCrossTab(Settings)
return <Page theme={settings.theme} onThemeChange={theme => settings.change('theme', theme)}>
{children}
</Page>
}
// models/settings.ts
import { Map } from "@logux/client"
export class Settings extends Map {
static modelName = 'settings'
static single = true
paid: boolean = false
pay (cardDetails: CardDetails) {
return this.client.addSync({ type: 'settings/pay', cardDetails })
}
static customActions = {
'settings/paid': settings => {
settings.paid = true
}
}
}
import { useState } from "react"
import { useLocal } from "@logux/client/react"
import { Account } from "../models"
import { client } from "../"
export const SettingsPage = () => {
const [loader, setLoader] = useState<boolean>(false)
const account = useLocal(Account)
return <SettingsPage
onDeleteAccount={async () => {
setLoader(true)
await account.remove()
client.destroy()
location.reload()
}}
/>
}
let task = await data.fromServer(Task, {
id: taskId,
fields: {
name: true,
finished: true
}
})
let settings = data.local(Settings)
let unsubscribe = task.subscribe(updateUI)
it('loads tasks and change finished', () => {
let userId = 'user1'
renderPage(data, userId)
expect(data.subscriptions.taskLists[userId]).toBeDefined()
data.serverAnswer(new TaskList(…))
let props = renderPage(data, userId)
props.onFinish(data.objects.tasks['task1'], true)
expect(data.objects.tasks['task1']).toBe(true)
})
CRDT requires to keep extra data for each model. For instance, last changed time for each key in Map
or unique ID and prev symbol ID for each symbol in Text
.
Logux Server can keep CRDT data and latest state in own tables in RDBMS like PostgreSQL.
// sources/production.ts
import { fullDatabase } from "@logux/server"
export default fullDatabase('postgres://user:[email protected]:5432/dbname')
- Define models to
models/
or convert front-end models bynpx @logux/server sync-models
- Run
npx @logux/server migrate
Logux Server can keep only CRDT data in RDBMS or key-value database. Logux will convert CRDT actions to state actions (“symbol X was added to text” → “change full text to Y”). User’s code (in Logux Server API or other back-end server) will save state to any source.
// source/production.ts
import { databaseSource, actionSource } from "@logux/server"
export default {
meta: databaseSource('postgres://user:[email protected]:5432/dbname'),
state: actionSource()
}
- Define models to
models/
or convert front-end models bynpx @logux/server sync-models
- Run
npx @logux/server migrate
Logux can load and change state via REST API.
// source/production.ts
import { databaseSource, httpSource } from "@logux/server"
export default {
meta: databaseSource('postgres://user:[email protected]:5432/dbname'),
state: httpSource('http://localhost:8000/api/')
}
// models/Task.ts
import { defineMap, text, oneToMany } from '@logux/server'
import source from "../source"
import Comment from "./Comment"
export default defineMap({
source,
async access (ctx, id) {
let task = await loadTask()
if (task.userId === ctx.userId) {
return 'owner'
} else if (task.collaborators.includes(ctx.userId)) {
return 'collaborator'
} else {
return false
}
},
fields: {
name: 'string',
finished: {
type: 'boolean',
access: {
// By default properties are open for all non-false return in access()
write: 'owner'
}
}
description: text(),
comments: oneToMany(Comment)
},
queries: {
fullTextSearch ({ text, limit }) {
// Custom SQL queries
}
}
})
// models/TaskList.ts
import { defineVector } from '@logux/server'
import source from "../source"
import Task from "./Task"
export default defineVector({
source,
value: Task
})
CLI tool will synchronize and generate server models from client models (only for TypeScript):
npx @logux/server sync-models ../frontend/models/*.ts
Another CLI tool to verify server and client models:
npx @logux/server verify-models ../frontend/models/*.ts
// models/task.ts
import { defineMap, text, oneToMany } from '@logux/server'
import source from "../source"
export default defineMap({
source: source({ table: 'user_tasks' }),
modelName: 'task',
fields: {
keywords: {
type: 'string',
read (value) {
return value.join(',')
},
write () {
return value.split(',')
}
},
commentsCount: {
type: 'number',
requires: ['comments'],
virtual (ctx, model) {
return model.comments.length
}
},
imageUrl: {
type: 'string',
requires: ['id'],
virtual (ctx, model, { size }) {
return `/images/${model.id}/${ size || 600 }.jpg`
}
}
}
})
// models/example.ts
export default defineMap({
source,
modelName: 'example',
fields: {
…
},
on: {
create (ctx, model, action, meta) {
…
},
change (ctx, model, action, meta) {
…
}
}
})
Each source have complexity limit with default value.
// server.js
server.complixityLimit = 10
You can change the complexity value for each model and query.
// models/example.ts
export default defineMap({
source,
complexity: 2,
modelName: 'example',
If total complexity will be bigger than limit, server will reject the query.
Migrations is used on servers to support old clients and on clients to update old actions in offline cache.
// migrations/index.ts
import { renameMap, renameField } from "@logux/core"
export default [
'2.0.0': [
renameMap('admin', 'user'),
renameField('task', 'admin', 'user')
]
]