Logux architecture was designed to be peer-to-peer and flexible. You can build different architecture on top of core concepts depends on your needs. But in this docs, we will show how to use Logux core concepts for the most popular standard case with web clients and several servers.
Connecting
Logux client keeps only one WebSocket connection even if the user opens an application in multiple browser’s tabs. Logux clients in different tabs elect one leader to keep the connection. If the user closes the leader tab, other tabs will re-elect a leader.
When Logux client opens WebSocket connection, it sends a user ID and user token to the server.
Logux server is written in JS. You can use any database to store data.
server.auth(async ({ userId, token }) => {
return verifyJWT(token).userId === userId
})
After authenticating user server will calculate time difference between client and server. It is useful when the client has the wrong time settings.
Subscriptions
Because real-time are important parts of Logux idea, in Logux subscriptions is a way to request data from the server.
Use useSubscription
hook or wrap a component into subscribe
decorator.
export const User = (userId) => {
const isSubscribing = useSubscription([`user/${ userId }`])
…
}
Use useSubscription
composable function or wrap content in loguxComponent
component.
import { computed, toRefs } from 'vue'
import { useSubscription } from '@logux/vuex'
export default {
name: 'User',
props: ['userId'],
setup (props) {
let { userId } = toRefs(props)
let isSubscribing = useSubscription(() => [`user/${userId.value}`])
…
}
}
Every time, when component added to UI, Logux will subscribe for the channel with the data. Every time, when the component will be removed, Logux will unsubscribe from the channel.
Logux client sends logux/subscribe
action to the server:
{ type: 'logux/subscribe', channel: 'user/388' }
After receiving logux/subscribe
Logux server does three steps.
- Check that user has access to this channel.
- Load initial data (the current state) from the database and send an action with this data to the client.
- Subscribe the client to any new data changes. Any new action with this channel in
meta.channels
will be sent to this client.
server.channel('user/:id', {
access (ctx) {
// User can subscribe only to own data
return ctx.params.id === ctx.userId
},
async load (ctx) {
let name = await db.loadUserName(ctx.params.id)
// Creating action to set user name and sending it to subscriber
return { type: 'user/name', name }
}
})
Logux client shows loader while the server loads data. When the client will receive initial data, the client will apply data to the state and hide loader.
export const User = ({ userId }) => {
const isSubscribing = useSubscription([`user/${ userId }`])
const user = useSelector(state => state.users[userId])
if (isSubscribing) {
return <Loader />
} else {
return <Name>{user.name}</Name>
}
}
<template>
<h1 v-if="isSubscribing">Loading</h1>
<h1 v-else>{{ user.name }}</h1>
</template>
<script>
import { toRefs, computed } from 'vue'
import { useStore, useSubscription } from '@logux/vuex'
export default {
props: ['userId'],
setup (props) {
let store = useStore()
let { userId } = toRefs(props)
let isSubscribing = useSubscription(() => [`user/${userId.value}`])
let user = computed(() => store.state.users[userId])
return {
user,
isSubscribing
}
}
})
</script>
Changing Data
Clients or server should create an action to change data.
log.add(
{ type: 'user/name', name: 'New name', userId: 29 }, // Action
{ sync: true } // Meta
)
In the most popular case, Logux client use Redux-style reducers to reduce list of action to the state. Reducer is a pure function, which immutable change the state according to this new action:
function usersReducers (state = { }, action) {
if (action.type === 'user/name') {
return { ...state, name: action.name }
} else {
return state
}
}
Logux Vuex client use Vuex mutations to reduce list of action to the state. Mutation is the only way to change state.
const store = createStore({
…
mutations: {
'user/rename': (state, action) => {
return { ...state, name: action.name }
}
}
})
If the user changed their name in the form, the client does not need to show loader on the Save button. The client creates action and applies this action to the state immediately.
In the background, the client will send this new action to the server by WebSocket. While the client is waiting for the answer from the server, it is showing small “changes were not saved yet” warning.
When the server receives new action it does three things:
- Get
meta.channels
to find whom we should re-send the action after checking access. - Check user access to do this action.
- Re-send this action to all clients subscribed to
meta.channels
. - Apply this action to database.
- Clean server log from this action since server does not need it anymore. When other clients will connect to the server, server will create a new action for them as described in “Subscriptions” section.
server.type('user/name', {
access (ctx, action, meta) {
// User can change only own name
return action.userId === ctx.userId
},
resend (ctx, action, meta) {
// Resend this action to everyone who subscribed to this user
return `user/${ action.userId }`
},
async process (ctx, action, meta) {
let lastChanged = await db.getChangeTimeForUserName(action.userId)
// Ignore action if somebody already changed the name later
if (isFirstOlder(lastChanged, meta)) {
await db.saveUserName(action.userId, action.name)
}
}
})
After saving action to the database, the server will send logux/processed
action to origin client. When the client receives logux/processed
action, it hides “changes were not saved yet” warning.
{ type: 'logux/processed', id: meta.id }
Handling Errors
On any error with action processing, the server will send back to the client logux/undo
action.
{ type: 'logux/undo', id: undoneMeta.id, action: undoneAction reason: 'error' }
Logux client uses pure reducers for time traveling. When the client received logux/undo
, it rollbacks the state to the latest saved point and call reducers for all next action, except the action from logux/undo
.
An application can catch logux/undo
action and show some error warning. To help make error warnings more exact, Logux adds the original reverted action to the logux/undo
action.
Loader During Action Processing
Optimistic UI is great for UX. Some actions (like payments) require loader. Logux can be used for UI with blocking loader:
- When the user clicks on the “Pay” button, the client sends a
pay/request
action to the server and show loader. - After finishing the payment, the server sends
logux/processed
back. On this action, the client shows the done message. - If the user’s bank card did not pass server validation, the server sends
logux/undo
back, and client shows error.
Offline
Logux clients send pings messages to WebSocket to detect losing Internet and show “you are offline” warning.
Offline is a normal mode for Logux. The user can work with data and create an action to change the data. Unsent action be kept in the log and user will see “changes were not saved yet” warning.
When user get Internet back, Logux will reconnect to the server, send all actions and receive all data updates.
Merging Edit Conflicts
When you are working with any offline-first system, you should ask how it deals with edit conflicts. During offline two users can change the same document. Even if only one user works with the document, this user can change the document from different devices.
For instance, user A changed the title and publication date for the document. User B a few minutes later changed document’s title and tags. Because of offline, user A could synchronize their actions later, than user B.
To merge edit conflicts in Logux:
-
You need to use atomic actions. Separated actions for each changed property is better than sending the whole document in action. For tags it is better to have
document/tags/add
anddocument/tags/remove
actions, instead of one action to override whole tags list.{ type: 'document/set', docId: 12, prop: 'title', value: 'New title' } { type: 'document/tags/add', docId: 12, tag: 'crdt' }
-
Each action has a creation time. In our example, both users changed the title. The most popular merge strategy is to keep the latest change.
Logux client and server use different approaches to work with action’s order.
- Server stores last edit time for each document property. When it received action from user B, server applies their changes to the database (because last change of the document title was a few weeks ago). The server will receive action from user A. Because A’s action time is smaller, than latest title changes (B’s action time), the server will ignore A’s action.
- Logux client has time traveling. When user B received A’s action from (server re-sent it), the client will revert all recent action, including their title changes. Then it will apply A’s action and re-apply all reverted actions back. As a result, A’s action was placed in the correct moment of history. So, A’s title changes were overridden by later B’s action.