Skip to content

Instantly share code, notes, and snippets.

@tantaman
Last active November 30, 2023 15:02
Show Gist options
  • Save tantaman/f5faa1c627b69aa68f45066f17eae2a1 to your computer and use it in GitHub Desktop.
Save tantaman/f5faa1c627b69aa68f45066f17eae2a1 to your computer and use it in GitHub Desktop.
live-store vs vulcan linearite
vulcan-linearite-sync.mov

I've implemented Linearlite in both LiveStore and Vulcan to compare APIs with Materialite next up.

Below are diffs of each component of Linearite, implemented with LiveStore then Vulcan.

- LiveStore code is in RED
+ Vulcan code is in GREEN

Some high level differences:

  1. Cross tab sync:
    1. Vulcan automatically supports cross tab sync
    2. LiveStore lacks cross tab sync
  2. Limits
    1. Vulcan pages data in as needed. We can support several gigs of issues and data and donly use 32MB of RAM.
    2. LiveStore requires all data to fit into memory. I.e., several gigs of issues -> several gigs of RAM + more for additional indices
  3. Perf
    1. Vulcan queries are async, not blocking the UI thread if desired. However we're limited to 1,000 writes per second.
    2. LiveStore can do 10x the writes per second given everything is in-memory
  4. Async
    1. For Vulcan, the DB can be in the main thread or in a worker and the user facing APIs remain the same.
    2. Given the nature of React controlled inputs, Vulcan requires an extra useState for text inputs to not cursor-jump
    3. N/a for LiveStore
  5. Type Safety
    1. Vulcan schema definition is normal SQL with types generated via https://github.com/vlcn-io/typed-sql. Read and writes are fully typed as typed-sql generates types for each query.
    2. LiveStore schema definition is a custom DSL and type safety is lacking on both read and write paths
  6. Migrations
    1. Vulcan auto-migrates your tables on changes to them, retaining your data
    2. LiveStore drops all data and re-creates all tables of schema change
  7. Vulcan supports fractional indexing inside of SQLite itself as a new index type, thus the kanban board is almost no code in Vulcan. This extension could be ported to LiveStore.

Reactivity Difference

  • Vulcan embraces React's reactivity system.
  • LiveStore bolts on its own.

This mean Vulcan just has one API: useQuery and LiveStore has useQuery, querySQL, get, pipe, useTemporaryQuery.

To make this concrete, lets look at the issue list.

In LiveStore:

const filterClause$ = querySQL<AppState>(`select * from app_state WHERE key = 'filter_state';`)
  .pipe((filterStates) => {
    if (filterStates.length === 0) return ''
    const filterStateObj = JSON.parse(filterStates[0]!.value)
    return filterStateToWhere(filterStateObj) + ' ' + filterStateToOrder(filterStateObj)
  })
const visibleIssues$ = querySQL<Issue>((get) => sql`select * from issue ${get(filterClause$)}`)
function List({ showSearch = false }) {
  const issues = useQuery(visibleIssues$)
  ...

In Vulcan:

function List({ showSearch = false }) {
  const ctx = useDB(DBName)
  const filterState = first(useQuery(ctx, `SELECT * FROM filter_state`).data)
  const issues = useQuery(
    ctx, 
    `SELECT * FROM issues ${filterStateToWhere(filterState} ${filterStateToOrder(filterState)}`
  ).data ?? []
  ...

When filterState changes, the issues query will re-run by virtue of React's reactivity system.

LiveStore does have a huge advantage here, however. React's reactivity systems does not allow for atomic updates. The filter state changes, the component renders, then the issues changes, the component renders again. This leads to some flickering and UI artifacting. E.g., the readout about "X issues of Y issues" goes away before the issue list changes since the readout depends on filter state and issue list on issue state.

LiveStore doesn't render until both are updated since LiveStore controls the reactivity.

diff --git a/livestore/livestore/examples/linearlite/src/pages/Board/index.tsx b/vulcan/linearite/src/pages/Board/Index.tsx
index a8960c0..6ed7689 100644
--- a/livestore/livestore/examples/linearlite/src/pages/Board/index.tsx
+++ b/vulcan/linearite/src/pages/Board/Index.tsx
@@ -1,24 +1,14 @@
import TopFilter from '../../components/TopFilter'
import IssueBoard from './IssueBoard'
-// import { useFilterState } from '../../utils/filterState'
-import { Issue } from '../../types'
-import { querySQL, sql } from '@livestore/livestore'
-import { filterStateToWhere } from '../../utils/filterState'
-import { AppState } from '../../domain/schema'
-import { useQuery } from '@livestore/livestore/react'
-
-const filterClause$ = querySQL<AppState>(`select * from app_state WHERE key = 'filter_state';`)
- // .getFirstRow({defaultValue: undefined })
- .pipe((filterStates) => {
- // TODO this handling should be improved (see https://github.com/livestorejs/livestore/issues/22)
- if (filterStates.length === 0) return ''
- const filterStateObj = JSON.parse(filterStates[0]!.value)
- return filterStateToWhere(filterStateObj)
- })
-const issues$ = querySQL<Issue>((get) => sql`SELECT * FROM issue ${get(filterClause$)} ORDER BY kanbanorder ASC`)
+import { decodeFilterState } from '../../domain/SchemaType';
+import { first, useDB, useQuery2 } from '@vlcn.io/react';
+import { queries } from '../../domain/queries';
+import { DBName } from '../../domain/Schema';
function Board() {
- const issues = useQuery(issues$)
+ const ctx = useDB(DBName)
+ const filterState = decodeFilterState(first(useQuery2(ctx, queries.filterState).data))
+ const issues = useQuery2(ctx, queries.boardIssues(filterState)).data ?? []
return (
<div className="flex flex-col flex-1 overflow-hidden">
diff --git a/livestore/livestore/examples/linearlite/src/pages/Issue/Comments.tsx b/vulcan/linearite/src/pages/Issue/Comments.tsx
index db37a8a..4dbedd5 100644
--- a/livestore/livestore/examples/linearlite/src/pages/Issue/Comments.tsx
+++ b/vulcan/linearite/src/pages/Issue/Comments.tsx
@@ -4,10 +4,11 @@ import Editor from '../../components/editor/Editor'
import Avatar from '../../components/Avatar'
import { formatDate } from '../../utils/date'
import { showWarning } from '../../utils/notification'
-import { Comment, Issue } from '../../types'
-import { useStore, useTemporaryQuery } from '@livestore/livestore/react'
-import { querySQL, sql } from '@livestore/livestore'
-import { nanoid } from 'nanoid'
+import { Comment, Issue } from '../../domain/SchemaType'
+import { DBName, newID } from '../../domain/Schema'
+import { useDB, useQuery2 } from '@vlcn.io/react'
+import { queries } from '../../domain/queries'
+import { mutations } from '../../domain/mutations'
export interface CommentsProps {
issue: Issue
@@ -15,12 +16,8 @@ export interface CommentsProps {
function Comments({ issue }: CommentsProps) {
const [newCommentBody, setNewCommentBody] = useState<string>('')
- const makeCommentQuery = useCallback(
- () => querySQL<Comment>(() => sql`SELECT * FROM comment WHERE issueId = '${issue.id}' ORDER BY created ASC`),
- [issue.id],
- )
- const comments = useTemporaryQuery(makeCommentQuery)
- const { store } = useStore()
+ const ctx = useDB(DBName)
+ const comments = useQuery2(ctx, queries.issueComments, [issue.id]).data
const commentList = () => {
if (comments && comments.length > 0) {
@@ -39,19 +36,20 @@ function Comments({ issue }: CommentsProps) {
}
}
- const handlePost = () => {
+ const handlePost = async () => {
if (!newCommentBody) {
showWarning('Please enter a comment before submitting', 'Comment required')
return
}
- store.applyEvent('createComment', {
- id: nanoid(),
+ const comment: Comment = {
+ id: newID<Comment>(),
body: newCommentBody,
issueId: issue.id,
created: Date.now(),
- author: 'testuser',
- })
+ creator: 'testuser',
+ }
+ await mutations.createComment(ctx.db, comment)
setNewCommentBody('')
}
diff --git a/livestore/livestore/examples/linearlite/src/components/contextmenu/FilterMenu.tsx b/vulcan/linearite/src/components/contextmenu/FilterMenu.tsx
index 337bee0..b2fc4f1 100644
--- a/livestore/livestore/examples/linearlite/src/components/contextmenu/FilterMenu.tsx
+++ b/vulcan/linearite/src/components/contextmenu/FilterMenu.tsx
@@ -4,9 +4,11 @@ import { ContextMenuTrigger } from '@firefox-devtools/react-contextmenu'
import { BsCheck2 } from 'react-icons/bs'
import { Menu } from './menu'
import { PriorityOptions, PriorityType, StatusOptions, StatusType } from '../../types/issue'
-import { querySQL, sql } from '@livestore/livestore'
-import { FilterState } from '../../domain/schema'
-import { useQuery, useStore } from '@livestore/livestore/react'
+import { decodeFilterState } from '../../domain/SchemaType'
+import { first, useDB, useQuery2 } from '@vlcn.io/react'
+import { queries } from '../../domain/queries'
+import { DBName } from '../../domain/Schema'
+import { mutations } from '../../domain/mutations'
interface Props {
id: string
@@ -14,16 +16,10 @@ interface Props {
className?: string
}
-const filterState$ = querySQL<{ value: string }>((_) => sql`SELECT * FROM app_state WHERE "key" = 'filter_state'`)
- .getFirstRow({
- defaultValue: { value: '{}' },
- })
- .pipe<FilterState>((row) => JSON.parse(row.value))
-
function FilterMenu({ id, button, className }: Props) {
const [keyword, setKeyword] = useState('')
- const filterState = useQuery(filterState$)
- const { store } = useStore()
+ const ctx = useDB(DBName)
+ const filterState = decodeFilterState(first(useQuery2(ctx, queries.filterState).data))
let priorities = PriorityOptions
if (keyword !== '') {
@@ -67,13 +63,10 @@ function FilterMenu({ id, button, className }: Props) {
} else {
newPriority.push(priority)
}
- store.applyEvent('upsertAppAtom', {
- key: 'filter_state',
- value: JSON.stringify({
- ...filterState,
- priority: newPriority,
- }),
- })
+ mutations.putFilterState(ctx.db, {
+ ...filterState,
+ priority: newPriority,
+ });
}
const handleStatusSelect = (status: StatusType) => {
@@ -84,13 +77,10 @@ function FilterMenu({ id, button, className }: Props) {
} else {
newStatus.push(status)
}
- store.applyEvent('upsertAppAtom', {
- key: 'filter_state',
- value: JSON.stringify({
- ...filterState,
- status: newStatus,
- }),
- })
+ mutations.putFilterState(ctx.db, {
+ ...filterState,
+ status: newStatus,
+ });
}
return (
diff --git a/livestore/livestore/examples/linearlite/src/pages/Board/IssueBoard.tsx b/vulcan/linearite/src/pages/Board/IssueBoard.tsx
index b37b8b7..8a90468 100644
--- a/livestore/livestore/examples/linearlite/src/pages/Board/IssueBoard.tsx
+++ b/vulcan/linearite/src/pages/Board/IssueBoard.tsx
@@ -2,9 +2,11 @@ import { DragDropContext, DropResult } from 'react-beautiful-dnd'
import { useMemo, useState, useEffect } from 'react'
import { Status, StatusDisplay, StatusType } from '../../types/issue'
import IssueCol from './IssueCol'
-import { Issue } from '../../types'
-import { useStore } from '@livestore/livestore/react'
-import { generateKeyBetween } from 'fractional-indexing'
+import { Issue } from '../../domain/SchemaType'
+import { DBName } from '../../domain/Schema'
+import { useDB } from '@vlcn.io/react'
+import { mutations } from '../../domain/mutations'
+import { ID_of } from '@vlcn.io/id'
export interface IssueBoardProps {
issues: readonly Issue[]
@@ -18,7 +20,7 @@ interface MovedIssues {
}
export default function IssueBoard({ issues }: IssueBoardProps) {
- const { store } = useStore()
+ const ctx = useDB(DBName)
const [movedIssues, setMovedIssues] = useState<MovedIssues>({})
// Issues are coming from a live query, this may not have updated before we rerender
@@ -84,108 +86,34 @@ export default function IssueBoard({ issues }: IssueBoardProps) {
prevIssue = columnIssues[index - 1]
nextIssue = columnIssues[index]
}
- console.log('sameColumn', sameColumn)
- console.log('prevIssue', prevIssue)
- console.log('nextIssue', nextIssue)
return { prevIssue, nextIssue }
}
- /**
- * Fix duplicate kanbanorder, this is recursive so we can fix multiple consecutive
- * issues with the same kanbanorder.
- * @param issue The issue to fix the kanbanorder for
- * @param issueBefore The issue immediately before one that needs fixing
- * @returns The new kanbanorder that was set for the issue
- */
- const fixKanbanOrder = (issue: Issue, issueBefore: Issue) => {
- // First we find the issue immediately after the issue that needs fixing.
- const issueIndex = issuesByStatus[issue.status]?.indexOf(issue)
- const issueAfter = issuesByStatus[issue.status]?.[issueIndex || 0 + 1]
-
- // The kanbanorder of the issue before the issue that needs fixing
- const prevKanbanOrder = issueBefore?.kanbanorder
-
- // The kanbanorder of the issue after the issue that needs fixing
- let nextKanbanOrder = issueAfter?.kanbanorder
-
- // If the next issue has the same kanbanorder the next issue needs fixing too,
- // we recursively call fixKanbanOrder for that issue to fix it's kanbanorder.
- if (issueAfter && nextKanbanOrder && nextKanbanOrder === prevKanbanOrder) {
- nextKanbanOrder = fixKanbanOrder(issueAfter, issueBefore)
- }
-
- // Generate a new kanbanorder between the previous and next issues
- const kanbanorder = generateKeyBetween(prevKanbanOrder, nextKanbanOrder)
-
- // Keep track of moved issues so we can override the kanbanorder when sorting
- // We do this due to the momentary lag between updating the database and the live
- // query updating the issues.
- setMovedIssues((prev) => ({
- ...prev,
- [issue.id]: {
- kanbanorder: kanbanorder,
- },
- }))
-
- // Update the issue in the database
- store.applyEvent('updateIssueKanbanOrder', {
- id: issue.id,
- kanbanorder,
- })
-
- // Return the new kanbanorder
- return kanbanorder
- }
-
- /**
- * Get a new kanbanorder that sits between two other issues.
- * Used to generate a new kanbanorder when moving an issue.
- * @param issueBefore The issue immediately before the issue being moved
- * @param issueAfter The issue immediately after the issue being moved
- * @returns The new kanbanorder
- */
- const getNewKanbanOrder = (issueBefore: Issue, issueAfter: Issue) => {
- const prevKanbanOrder = issueBefore?.kanbanorder
- let nextKanbanOrder = issueAfter?.kanbanorder
- if (nextKanbanOrder && nextKanbanOrder === prevKanbanOrder) {
- // If the next issue has the same kanbanorder as the previous issue,
- // we need to fix the kanbanorder of the next issue.
- // This can happen when two users move issues into the same position at the same
- // time.
- nextKanbanOrder = fixKanbanOrder(issueAfter, issueBefore)
- }
- return generateKeyBetween(prevKanbanOrder, nextKanbanOrder)
- }
-
const onDragEnd = ({ source, destination, draggableId }: DropResult) => {
- console.log(source, destination, draggableId)
if (destination && destination.droppableId) {
- const { prevIssue, nextIssue } = adjacentIssues(
+ const { prevIssue } = adjacentIssues(
destination.droppableId as StatusType,
destination.index,
destination.droppableId === source.droppableId,
source.index,
)
- // Get a new kanbanorder between the previous and next issues
- const kanbanorder = getNewKanbanOrder(prevIssue, nextIssue)
- // Keep track of moved issues so we can override the status and kanbanorder when
- // sorting issues into columns.
- const modified = new Date()
+
setMovedIssues((prev) => ({
...prev,
[draggableId]: {
status: destination.droppableId as StatusType,
- kanbanorder,
- modified,
},
}))
// Update the issue in the database
- store.applyEvent('moveIssue', {
- id: draggableId,
- status: destination.droppableId,
- kanbanorder,
- })
+ if (prevIssue) {
+ mutations.moveIssue(ctx.db, draggableId as ID_of<Issue>, prevIssue.id, destination.droppableId as StatusType);
+ } else {
+ mutations.updateIssue(ctx.db, {
+ id: draggableId as ID_of<Issue>,
+ status: destination.droppableId as StatusType,
+ })
+ }
}
}
diff --git a/livestore/livestore/examples/linearlite/src/pages/Board/IssueItem.tsx b/vulcan/linearite/src/pages/Board/IssueItem.tsx
index 38cb424..344c724 100644
--- a/livestore/livestore/examples/linearlite/src/pages/Board/IssueItem.tsx
+++ b/vulcan/linearite/src/pages/Board/IssueItem.tsx
@@ -5,9 +5,11 @@ import { DraggableProvided } from 'react-beautiful-dnd'
import Avatar from '../../components/Avatar'
import PriorityMenu from '../../components/contextmenu/PriorityMenu'
import PriorityIcon from '../../components/PriorityIcon'
-import { Issue } from '../../types'
import { PriorityType } from '../../types/issue'
-import { useStore } from '@livestore/livestore/react'
+import { Issue } from '../../domain/SchemaType'
+import { DBName } from '../../domain/Schema'
+import { useDB } from '@vlcn.io/react'
+import { mutations } from '../../domain/mutations'
interface IssueProps {
issue: Issue
@@ -29,7 +31,7 @@ function getStyle(provided: DraggableProvided, style?: CSSProperties): CSSProper
// eslint-disable-next-line react-refresh/only-export-components
const IssueItem = ({ issue, style, isDragging, provided }: IssueProps) => {
- const { store } = useStore()
+ const ctx = useDB(DBName)
const navigate = useNavigate()
const priorityIcon = (
<span className="inline-block m-0.5 rounded-sm border border-gray-100 hover:border-gray-200 p-0.5">
@@ -37,12 +39,11 @@ const IssueItem = ({ issue, style, isDragging, provided }: IssueProps) => {
</span>
)
- const updatePriority = (priority: PriorityType) => {
- store.applyEvent('updateIssuePriority', {
+ const updatePriority = (priority: PriorityType) =>
+ mutations.updateIssue(ctx.db, {
id: issue.id,
priority,
})
- }
return (
<div
diff --git a/livestore/livestore/examples/linearlite/src/components/IssueModal.tsx b/vulcan/linearite/src/components/IssueModal.tsx
index 55c71c0..b279c57 100644
--- a/livestore/livestore/examples/linearlite/src/components/IssueModal.tsx
+++ b/vulcan/linearite/src/components/IssueModal.tsx
@@ -14,8 +14,10 @@ import StatusMenu from './contextmenu/StatusMenu'
import { Priority, Status, PriorityDisplay, StatusType, PriorityType } from '../types/issue'
import { showInfo, showWarning } from '../utils/notification'
-import { useStore } from '@livestore/livestore/react'
-import { nanoid } from 'nanoid'
+import { useDB } from '@vlcn.io/react'
+import { DBName, newID } from '../domain/Schema'
+import { mutations } from '../domain/mutations'
+import { Issue } from '../domain/SchemaType'
interface Props {
isOpen: boolean
@@ -29,7 +31,7 @@ function IssueModal({ isOpen, onDismiss }: Props) {
const [description, setDescription] = useState<string>()
const [priority, setPriority] = useState<PriorityType>(Priority.NONE)
const [status, setStatus] = useState<StatusType>(Status.BACKLOG)
- const { store } = useStore()
+ const ctx = useDB(DBName)
const handleSubmit = async () => {
if (title === '') {
@@ -37,25 +39,21 @@ function IssueModal({ isOpen, onDismiss }: Props) {
return
}
- const lastIssue = store.select(`SELECT kanbanorder FROM issue ORDER BY kanbanorder DESC LIMIT 1`)[0]
- const kanbanorder = generateKeyBetween(lastIssue?.kanbanorder, null)
-
const date = Date.now()
- const id = nanoid()
- store.applyEvent('createIssue', {
- id,
- title: title,
- username: 'testuser',
- priority: priority,
- status: status,
- modified: date,
- created: date,
- kanbanorder,
- })
- store.applyEvent('createDescription', {
- id,
- body: description ?? '',
- })
+ const id = newID<Issue>()
+ await mutations.createIssueWithDescription(ctx.db, {
+ id,
+ title: title,
+ creator: 'testuser',
+ priority: priority,
+ status: status,
+ modified: date,
+ created: date,
+ kanbanorder: 1 // 1 means end of list. It'll find the appropriate fract index
+ }, {
+ id,
+ body: description ?? '',
+ });
if (onDismiss) onDismiss()
reset()
diff --git a/livestore/livestore/examples/linearlite/src/pages/Issue/index.tsx b/vulcan/linearite/src/pages/Issue/index.tsx
index 2ba4028..708eef7 100644
--- a/livestore/livestore/examples/linearlite/src/pages/Issue/index.tsx
+++ b/vulcan/linearite/src/pages/Issue/index.tsx
@@ -7,33 +7,22 @@ import StatusMenu from '../../components/contextmenu/StatusMenu'
import PriorityIcon from '../../components/PriorityIcon'
import StatusIcon from '../../components/StatusIcon'
import Avatar from '../../components/Avatar'
-import { PriorityDisplay, StatusDisplay } from '../../types/issue'
+import { PriorityDisplay, PriorityType, StatusDisplay, StatusType } from '../../types/issue'
import Editor from '../../components/editor/Editor'
import DeleteModal from './DeleteModal'
import Comments from './Comments'
-import { Issue } from '../../types'
-import { querySQL, sql } from '@livestore/livestore'
-import { useStore, useTemporaryQuery } from '@livestore/livestore/react'
-
-// This would be best:
-// const issue$ = querySQL<Issue>((_) => sql`SELECT * FROM issue WHERE id = $id`).getFirstRow()
-// const issue = useQuery(issue$, { id })
+import { DBName } from '../../domain/Schema'
+import { first, useDB, useQuery2 } from '@vlcn.io/react'
+import { queries } from '../../domain/queries'
+import { mutations } from '../../domain/mutations'
function IssuePage() {
const navigate = useNavigate()
const { id } = useParams() || ''
- const makeIssueQuery = useCallback(
- () => querySQL<Issue>((_) => sql`SELECT * FROM issue WHERE id = '${id}'`).getFirstRow(),
- [id],
- )
- const makeDescriptionQuery = useCallback(
- () => querySQL<{ body: string }>((_) => sql`SELECT body FROM description WHERE id = '${id}'`).getFirstRow(),
- [id],
- )
- const issue = useTemporaryQuery(makeIssueQuery)
- const description = useTemporaryQuery(makeDescriptionQuery).body
- const { store } = useStore()
+ const ctx = useDB(DBName)
+ const issue = first(useQuery2(ctx, queries.issue, [id]).data)
+ const description = first(useQuery2(ctx, queries.issueDescription, [id]).data)?.body
const [showDeleteModal, setShowDeleteModal] = useState(false)
@@ -42,46 +31,38 @@ function IssuePage() {
} else if (issue === null) {
return <div className="p-8 w-full text-center">Issue not found</div>
}
- const handleStatusChange = (status: string) => {
- store.applyEvent('updateIssueStatus', {
+ const handleStatusChange = (status: StatusType) => {
+ mutations.updateIssue(ctx.db, {
id: issue.id,
status,
})
}
- const handlePriorityChange = (priority: string) => {
- store.applyEvent('updateIssuePriority', {
+ const handlePriorityChange = (priority: PriorityType) => {
+ mutations.updateIssue(ctx.db, {
id: issue.id,
priority,
})
}
const handleTitleChange = (title: string) => {
- store.applyEvent('updateIssueTitle', {
+ mutations.updateIssue(ctx.db, {
id: issue.id,
title,
})
}
const handleDescriptionChange = (body: string) => {
- store.applyEvent('updateDescription', {
+ mutations.updateDescription(ctx.db, {
id: issue.id,
body,
})
}
- const handleDelete = () => {
- // TODO: how to create a tx?
- store.applyEvent('deleteIssue', {
- id: issue.id,
- })
- store.applyEvent('deleteDescription', {
- id: issue.id,
- })
- store.applyEvent('deleteCommentsByIssueId', {
- issueId: issue.id,
- })
+ const handleDelete = async () => {
+ await mutations.deleteIssue(ctx.db, issue.id)
handleClose()
}
@@ -131,7 +112,7 @@ function IssuePage() {
<div className="flex flex-[2_0_0] mr-2 md-mr-0 items-center">Opened by</div>
<div className="flex flex-[3_0_0]">
<button className="inline-flex items-center h-6 ps-1.5 pe-2 text-gray-500border-none rounded hover:bg-gray-100">
- <Avatar name={issue.creator} />
+ <Avatar name={issue.creator ?? undefined} />
<span className="ml-1">{issue.creator}</span>
</button>
</div>
@@ -172,7 +153,7 @@ function IssuePage() {
<input
className="w-full px-3 py-1 text-lg font-semibold placeholder-gray-400 border-transparent rounded "
placeholder="Issue title"
- value={issue.title}
+ value={issue.title ?? undefined}
onChange={(e) => handleTitleChange(e.target.value)}
/>
diff --git a/livestore/livestore/examples/linearlite/src/pages/List/index.tsx b/vulcan/linearite/src/pages/List/index.tsx
index 9bc70c6..a4f5342 100644
--- a/livestore/livestore/examples/linearlite/src/pages/List/index.tsx
+++ b/vulcan/linearite/src/pages/List/index.tsx
@@ -1,24 +1,14 @@
import TopFilter from '../../components/TopFilter'
import IssueList from './IssueList'
-// import { useFilterState } from '../../utils/filterState'
-import { Issue } from '../../types'
-import { querySQL, sql } from '@livestore/livestore'
-import { AppState } from '../../domain/schema'
-import { filterStateToOrder, filterStateToWhere } from '../../utils/filterState'
-import { useQuery } from '@livestore/livestore/react'
-
-const filterClause$ = querySQL<AppState>(`select * from app_state WHERE key = 'filter_state';`)
- // .getFirstRow({defaultValue: undefined })
- .pipe((filterStates) => {
- // TODO this handling should be improved (see https://github.com/livestorejs/livestore/issues/22)
- if (filterStates.length === 0) return ''
- const filterStateObj = JSON.parse(filterStates[0]!.value)
- return filterStateToWhere(filterStateObj) + ' ' + filterStateToOrder(filterStateObj)
- })
-const visibleIssues$ = querySQL<Issue>((get) => sql`select * from issue ${get(filterClause$)}`)
+import { decodeFilterState } from '../../domain/SchemaType'
+import { first, useDB, useQuery2 } from '@vlcn.io/react'
+import { queries } from '../../domain/queries'
+import { DBName } from '../../domain/Schema'
function List({ showSearch = false }) {
- const issues = useQuery(visibleIssues$)
+ const ctx = useDB(DBName)
+ const filterState = decodeFilterState(first(useQuery2(ctx, queries.filterState).data))
+ const issues = useQuery2(ctx, queries.listIssues(filterState)).data ?? []
return (
<div className="flex flex-col flex-grow">
diff --git a/livestore/livestore/examples/linearlite/src/pages/List/IssueRow.tsx b/vulcan/linearite/src/pages/List/IssueRow.tsx
index 907a1f2..ef52d59 100644
--- a/livestore/livestore/examples/linearlite/src/pages/List/IssueRow.tsx
+++ b/vulcan/linearite/src/pages/List/IssueRow.tsx
@@ -7,9 +7,11 @@ import Avatar from '../../components/Avatar'
import { memo } from 'react'
import { useNavigate } from 'react-router-dom'
import { formatDate } from '../../utils/date'
-import { Issue } from '../../types'
import { PriorityType, StatusType } from '../../types/issue'
-import { useStore } from '@livestore/livestore/react'
+import { Issue } from '../../domain/SchemaType'
+import { useDB } from '@vlcn.io/react'
+import { DBName } from '../../domain/Schema'
+import { mutations } from '../../domain/mutations'
interface Props {
issue: Issue
@@ -18,23 +20,20 @@ interface Props {
// eslint-disable-next-line react-refresh/only-export-components
function IssueRow({ issue, style }: Props) {
- // const { db } = useElectric()!
const navigate = useNavigate()
- const { store } = useStore()
+ const ctx = useDB(DBName)
- const handleChangeStatus = (status: StatusType) => {
- store.applyEvent('updateIssueStatus', {
+ const handleChangeStatus = (status: StatusType) =>
+ mutations.updateIssue(ctx.db, {
id: issue.id,
status,
})
- }
- const handleChangePriority = (priority: PriorityType) => {
- store.applyEvent('updateIssuePriority', {
+ const handleChangePriority = (priority: PriorityType) =>
+ mutations.updateIssue(ctx.db, {
id: issue.id,
priority,
})
- }
return (
<div

Vulcan schema:

import { SchemaType } from './SchemaType.js'
import { schema } from '@vlcn.io/typed-sql'
import { nanoid } from 'nanoid';
import {ID_of} from '@vlcn.io/id';

export const SchemaName = 'schema.sql'
// DB name does not need to be static by any means. See other example apps and the vite-start for dynamic db names.
export const DBName = 'linear';
export function newID<T>(): ID_of<T> {
  return nanoid() as ID_of<T>;
}

export const Schema = schema<SchemaType>`
CREATE TABLE IF NOT EXISTS issue (
  "id" 'ID_of<Issue>' PRIMARY KEY NOT NULL,
  "title" TEXT DEFAULT '' NOT NULL,
  "creator" TEXT DEFAULT '' NOT NULL,
  "priority" '"none" | "urgent" | "high" | "low" | "medium"' DEFAULT 'none' NOT NULL,
  "status" '"backlog" | "todo" | "in_progress" | "done" | "canceled"' DEFAULT 'todo' NOT NULL,
  "created" INTEGER NOT NULL,
  "modified" INTEGER NOT NULL,
  "kanbanorder" NOT NULL
);

SELECT crsql_fract_as_ordered('issue', 'kanbanorder');

CREATE TABLE IF NOT EXISTS "description" (
  "id" 'ID_of<Issue>' PRIMARY KEY NOT NULL,
  "body" TEXT DEFAULT '' NOT NULL
);

CREATE TABLE IF NOT EXISTS "comment" (
  "id" 'ID_of<Comment>' PRIMARY KEY NOT NULL,
  "body" TEXT DEFAULT '' NOT NULL,
  "creator" TEXT DEFAULT '' NOT NULL,
  "issueId" 'ID_of<Issue>' NOT NULL,
  "created" INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS "filter_state" (
  "id" '"singleton"' PRIMARY KEY NOT NULL,
  "orderBy" TEXT DEFAULT 'created' NOT NULL,
  "orderDirection" TEXT DEFAULT 'asc' NOT NULL,
  "status" 'String_of<StatusType[]>',
  "priority" 'String_of<PriorityType[]>',
  "query" TEXT
);
`

LiveStore schema:

import { DbSchema, makeSchema, sql } from '@livestore/livestore'
import { Priority, PriorityType, Status, StatusType } from '../types/issue'

const issue = DbSchema.table('issue', {
  id: DbSchema.text({ primaryKey: true }),
  title: DbSchema.text({ default: '' }),
  creator: DbSchema.text({ default: '' }),
  priority: DbSchema.text({ default: Priority.NONE }),
  status: DbSchema.text({ default: Status.TODO }),
  created: DbSchema.integer(),
  modified: DbSchema.integer(),
  kanbanorder: DbSchema.text({ nullable: false }),
})

export interface FilterState {
  orderBy: string
  orderDirection: 'asc' | 'desc'
  status?: StatusType[]
  priority?: PriorityType[]
  query?: string
}

const description = DbSchema.table('description', {
  // TODO: id is also a foreign key to issue
  id: DbSchema.text({ primaryKey: true }),
  body: DbSchema.text({ default: '' }),
})

const comment = DbSchema.table(
  'comment',
  {
    id: DbSchema.text({ primaryKey: true }),
    body: DbSchema.text({ default: '' }),
    creator: DbSchema.text({ default: '' }),
    // TODO: issueId is a foreign key to issue
    issueId: DbSchema.text(),
    created: DbSchema.integer(),
    author: DbSchema.text({ nullable: false }),
  },
  [
    {
      name: 'issue_id',
      columns: ['issueId'],
    },
  ],
)

// TODO: move filter state into its own table?
const appState = DbSchema.table('app_state', {
  key: DbSchema.text({ primaryKey: true }),
  value: DbSchema.text(),
})

export type AppState = DbSchema.FromTable.RowDecoded<typeof appState>
export type Issue = DbSchema.FromTable.RowDecoded<typeof issue>
export type Description = DbSchema.FromTable.RowDecoded<typeof description>
export type Comment = DbSchema.FromTable.RowDecoded<typeof comment>

export const schema = makeSchema({
  // TODO get rid of `app_state` alias once fixed https://github.com/livestorejs/livestore/issues/25
  tables: { issue, description, comment, app_state: appState },
  actions: {
    createIssue: {
      statement: {
        sql: sql`INSERT INTO issue ("id", "title", "priority", "status", "created", "modified", "kanbanorder")
          VALUES ($id, $title, $priority, $status, $created, $modified, $kanbanorder)`,
        writeTables: ['issue'],
      },
    },
    createDescription: {
      statement: {
        sql: sql`INSERT INTO description ("id", "body") VALUES ($id, $body)`,
        writeTables: ['description'],
      },
    },
    createComment: {
      statement: {
        sql: sql`INSERT INTO comment ("id", "body", "issueId", "created", "author")
          VALUES ($id, $body, $issueId, $created, $author)`,
        writeTables: ['comment'],
      },
    },
    deleteIssue: {
      statement: {
        sql: sql`DELETE FROM issue WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    deleteDescriptin: {
      statement: {
        sql: sql`DELETE FROM description WHERE id = $id`,
        writeTables: ['description'],
      },
    },
    deleteComment: {
      statement: {
        sql: sql`DELETE FROM comment WHERE id = $id`,
        writeTables: ['comment'],
      },
    },
    deleteCommentsByIssueId: {
      statement: {
        sql: sql`DELETE FROM comment WHERE issueId = $issueId`,
        writeTables: ['comment'],
      },
    },
    updateIssue: {
      statement: {
        sql: sql`UPDATE issue SET title = $title, priority = $priority, status = $status, modified = $modified WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    updateIssueStatus: {
      statement: {
        sql: sql`UPDATE issue SET status = $status, modified = unixepoch() * 1000 WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    updateIssueKanbanOrder: {
      statement: {
        sql: sql`UPDATE issue SET kanbanorder = $kanbanorder, modified = unixepoch() * 1000 WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    updateIssueTitle: {
      statement: {
        sql: sql`UPDATE issue SET title = $title, modified = unixepoch() * 1000 WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    moveIssue: {
      statement: {
        sql: sql`UPDATE issue SET kanbanorder = $kanbanorder, status = $status, modified = unixepoch() * 1000 WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    updateIssuePriority: {
      statement: {
        sql: sql`UPDATE issue SET priority = $priority, modified = unixepoch() * 1000 WHERE id = $id`,
        writeTables: ['issue'],
      },
    },
    updateDescription: {
      statement: {
        sql: sql`UPDATE description SET body = $body WHERE id = $id`,
        writeTables: ['description'],
      },
    },
    upsertAppAtom: {
      statement: {
        sql: sql`INSERT INTO app_state (key, value) VALUES ($key, $value)
          ON CONFLICT (key) DO UPDATE SET value = $value`,
        writeTables: ['app_state'],
      },
    },
  },
})

Vulcan Mutations (LiveStore includes these in Schema):

import { TXAsync } from "@vlcn.io/xplat-api"
import { Issue, Description, Comment, DecodedFilterState, encodeFilterState, StatusType } from "./SchemaType"
import { ID_of } from "@vlcn.io/id"

function colNames(obj: { [key: string]: unknown }) {
  return Object.keys(obj).map(key => `"${key}"`).join(', ');
}

function placeholders(obj: { [key: string]: unknown }) {
  return Object.keys(obj).map(() => '?').join(', ');
}

function values(obj: { [key: string]: unknown }) {
  return Object.values(obj);
}

function set(obj: { [key: string]: unknown }) {
  return Object.keys(obj).map(key => `"${key}" = ?`).join(', ');
}

export const mutations = {
  createIssue(tx: TXAsync, issue: Issue) {
    return tx.exec(
      `INSERT INTO issue (${colNames(issue)}) VALUES (${placeholders(issue)})`,
      values(issue)
    );
  },

  createDescription(tx: TXAsync, desc: Description) {
    return tx.exec(
      `INSERT INTO description (${colNames(desc)}) VALUES (${placeholders(desc)})`,
      values(desc)
    );
  },

  createIssueWithDescription(tx: TXAsync, issue: Issue, desc: Description) {
    return tx.exec(
      `INSERT INTO issue (${colNames(issue)}) VALUES (${placeholders(issue)})`,
      values(issue)
    ).then(() => {
      return tx.exec(
        `INSERT INTO description (${colNames(desc)}) VALUES (${placeholders(desc)})`,
        values(desc)
      );
    });
  },

  createComment(tx: TXAsync, comment: Comment) {
    return tx.exec(
      `INSERT INTO comment (${colNames(comment)}) VALUES (${placeholders(comment)})`,
      values(comment)
    );
  },

  putFilterState(tx: TXAsync, filterState: DecodedFilterState) {
    const encoded = encodeFilterState(filterState)
    return tx.exec(
      `INSERT INTO filter_state (${colNames(encoded)}) VALUES (${placeholders(encoded)})
        ON CONFLICT DO UPDATE SET ${set(encoded)}`,
      [...values(encoded), ...values(encoded)]
    );
  },

  updateIssue(tx: TXAsync, issue: Partial<Issue>) {
    if (!issue.modified) {
      issue = {
        ...issue,
        modified: Date.now()
      }
    }
    return tx.exec(
      `UPDATE issue SET ${set(issue)} WHERE id = ?`,
      [...values(issue), issue.id]
    );
  },

  updateDescription(tx: TXAsync, desc: Description) {
    return tx.exec(
      `UPDATE description SET ${set(desc)} WHERE id = ?`,
      [...values(desc), desc.id]
    );
  },

  moveIssue(tx: TXAsync, id: ID_of<Issue>, afterId: ID_of<Issue> | null, newStatus: StatusType) {
    return tx.exec(
      `UPDATE issue_fractindex SET after_id = ?, status = ? WHERE id = ?`,
      [afterId, newStatus, id]
    )
  },

  async deleteIssue(tx: TXAsync, id: ID_of<Issue>) {
    await tx.exec(
      `DELETE FROM issue WHERE id = ?`,
      [id]
    );
    await tx.exec(
      `DELETE FROM description WHERE id = ?`,
      [id]
    );
    await tx.exec(
      `DELETE FROM comment WHERE issueId = ?`,
      [id]
    );
  }
};
diff --git a/livestore/livestore/examples/linearlite/src/components/TopFilter.tsx b/vulcan/linearite/src/components/TopFilter.tsx
index e4ff7e8..3d45eea 100644
--- a/livestore/livestore/examples/linearlite/src/components/TopFilter.tsx
+++ b/vulcan/linearite/src/components/TopFilter.tsx
@@ -5,10 +5,12 @@ import ViewOptionMenu from './ViewOptionMenu'
import { MenuContext } from '../App'
import FilterMenu from './contextmenu/FilterMenu'
import { PriorityDisplay, StatusDisplay } from '../types/issue'
-import { Issue } from '../types'
-import { querySQL, sql } from '@livestore/livestore'
-import { useQuery, useStore } from '@livestore/livestore/react'
-import { FilterState } from '../domain/schema'
+import { Issue, decodeFilterState } from '../domain/SchemaType'
+import { first, useDB, useQuery2 } from '@vlcn.io/react'
+import { DBName } from '../domain/Schema'
+import { queries } from '../domain/queries'
+import { mutations } from '../domain/mutations'
+import debounce from 'lodash.debounce'
interface Props {
issues: readonly Issue[]
@@ -17,34 +19,23 @@ interface Props {
title?: string
}
-const issueCount$ = querySQL<{ c: number }>((_) => sql`SELECT COUNT(id) AS c FROM issue`)
- .getFirstRow()
- .pipe((row) => row?.c ?? 0)
-const filterState$ = querySQL<{ value: string }>((_) => sql`SELECT * FROM app_state WHERE "key" = 'filter_state'`)
- .getFirstRow({
- defaultValue: { value: '{}' },
- })
- .pipe<FilterState>((row) => JSON.parse(row.value))
-
export default function TopFilter({ issues, hideSort, showSearch, title = 'All issues' }: Props) {
const [showViewOption, setShowViewOption] = useState(false)
const { showMenu, setShowMenu } = useContext(MenuContext)!
const [searchQuery, setSearchQuery] = useState('')
- const totalIssuesCount = useQuery(issueCount$)
- const filterState = useQuery(filterState$)
- const { store } = useStore()
+ const ctx = useDB(DBName)
+ const filterState = decodeFilterState(first(useQuery2(ctx, queries.filterState).data))
+ const totalIssuesCount = first(useQuery2(ctx, queries.totalIssueCount).data)?.c ?? 0
const filteredIssuesCount = issues.length
- const handleSearchInner = (query: string) => {
- store.applyEvent('upsertAppAtom', {
- key: 'filter_state',
- value: JSON.stringify({
- ...filterState,
- query: query,
- }),
+ // is debounce required?
+ const handleSearchInner = debounce((query: string) => {
+ mutations.putFilterState(ctx.db, {
+ ...filterState,
+ query: query,
})
- }
+ }, 100)
const handleSearch = (query: string) => {
setSearchQuery(query)
@@ -109,12 +100,9 @@ export default function TopFilter({ issues, hideSort, showSearch, title = 'All i
<span
className="px-1 bg-gray-300 rounded-r cursor-pointer flex items-center"
onClick={() => {
- store.applyEvent('upsertAppAtom', {
- key: 'filter_state',
- value: JSON.stringify({
- ...filterState,
- priority: undefined,
- }),
+ mutations.putFilterState(ctx.db, {
+ ...filterState,
+ priority: null,
})
}}
>
@@ -131,12 +119,9 @@ export default function TopFilter({ issues, hideSort, showSearch, title = 'All i
<span
className="px-1 bg-gray-300 rounded-r cursor-pointer flex items-center"
onClick={() => {
- store.applyEvent('upsertAppAtom', {
- key: 'filter_state',
- value: JSON.stringify({
- ...filterState,
- status: undefined,
- }),
+ mutations.putFilterState(ctx.db, {
+ ...filterState,
+ status: null,
})
}}
>
import { Query } from '@vlcn.io/react'
import { filterStateToOrder, filterStateToWhere } from '../utils/filterState'
import { Schema as S } from './Schema'
import { DecodedFilterState, Issue, PriorityType, StatusType, String_of } from './SchemaType'
import { ID_of } from '@vlcn.io/id'
// Types are auto-generated via `typed-sql` https://github.com/vlcn-io/typed-sql
// run `pnpm sql-watch` to generate types
export const queries = {
totalIssueCount: S.sql<{
c: number
}>`SELECT COUNT(*) AS c FROM issue`,
filterState: S.sql<{
id: 'singleton'
orderBy: string
orderDirection: string
status: String_of<StatusType[]> | null
priority: String_of<PriorityType[]> | null
query: string | null
}>`SELECT * FROM filter_state`,
boardIssues: (filters: DecodedFilterState) => {
return `SELECT * FROM issue ${filterStateToWhere(filters)} ORDER BY kanbanorder ASC` as Query<Issue>
},
listIssues: (filters: DecodedFilterState) => {
return `SELECT * FROM issue ${filterStateToWhere(filters)} ${filterStateToOrder(filters)}` as Query<Issue>
},
issue: S.sql<{
id: ID_of<Issue>
title: string
creator: string
priority: 'none' | 'urgent' | 'high' | 'low' | 'medium'
status: 'backlog' | 'todo' | 'in_progress' | 'done' | 'canceled'
created: number
modified: number
kanbanorder: any
}>`SELECT * FROM issue WHERE id = ?`,
issueDescription: S.sql<{
id: ID_of<Issue>
body: string
}>`SELECT * FROM description WHERE id = ?`,
issueComments: S.sql<{
id: ID_of<Comment>
body: string
creator: string
issueId: ID_of<Issue>
created: number
}>`SELECT * FROM comment WHERE issueId = ?`,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment