Skip to content

Instantly share code, notes, and snippets.

@ai
Last active November 15, 2020 16:55
Show Gist options
  • Save ai/dfea0fcbfe8fba1b1e11180a1e5d16e3 to your computer and use it in GitHub Desktop.
Save ai/dfea0fcbfe8fba1b1e11180a1e5d16e3 to your computer and use it in GitHub Desktop.

Logux Data API Proposal

Logux Data is a new state manager for Logux with: built-in CRDT types, GraphQL-like data loading, good tree-shaking and types support.

Client

Initialization

React

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')
)

Pure JS

import { CrossTabClient } from '@logux/client'
import { Date } from '@logux/client'

const client = new CrossTabClient()
const objects = new ObjectSpace(client)

Tests

import { TestObjectSpace } from '@logux/client'

let objects: TestObjectSpace
beforeEach(() => {
  objects = new TestObjectSpace()
})

Simple ToDo App

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)}
    />
  }
}

Server Errors

// 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 />
    }
  }
}

Pagination

// 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)}
  />
}

Local

// 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>
}

Custom Actions

// 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
    }
  }
}

Pessimistic UI

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()
    }}
  />
}

Pure JS

let task = await data.fromServer(Task, {
  id: taskId,
  fields: {
    name: true,
    finished: true
  }
})

let settings = data.local(Settings)

let unsubscribe = task.subscribe(updateUI)

Testing Controllers

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)
})

Server

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.

Initialization

Full Mode to Database

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:pass@example.com:5432/dbname')
  1. Define models to models/ or convert front-end models by npx @logux/server sync-models
  2. Run npx @logux/server migrate

Partail Mode

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:pass@example.com:5432/dbname'),
  state: actionSource()
}
  1. Define models to models/ or convert front-end models by npx @logux/server sync-models
  2. Run npx @logux/server migrate

REST Mode

Logux can load and change state via REST API.

// source/production.ts
import { databaseSource, httpSource } from "@logux/server"

export default {
  meta: databaseSource('postgres://user:pass@example.com:5432/dbname'),
  state: httpSource('http://localhost:8000/api/')
}

Define Model

// 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

Custom Fields and Table

// 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`
      }
    }
  }
})

Events

// models/example.ts
export default defineMap({
  source,
  modelName: 'example',
  fields: {},
  on: {
    create (ctx, model, action, meta) {
      
    },
    change (ctx, model, action, meta) {
      
    }
  }
})

DDoS Prevention

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.

Subprotocol Migrations

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')
  ]
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment