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.

Revisions

  1. tantaman revised this gist Nov 30, 2023. 1 changed file with 4 additions and 0 deletions.
    4 changes: 4 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -70,3 +70,7 @@ function List({ showSearch = false }) {
    ```
    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.
  2. tantaman revised this gist Nov 29, 2023. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -31,7 +31,7 @@ Below are diffs of each component of Linearite, implemented with LiveStore then
    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 is almost 0 code in Vulcan. This extension could be ported to LiveStore.
    7. Vulcan supports fractional indexing inside of SQLite itself as a new index type, thus the [kanban board](https://gist.github.com/tantaman/f5faa1c627b69aa68f45066f17eae2a1#file-issueboard-tsx-diff) is almost no code in Vulcan. This extension could be ported to LiveStore.

    # Reactivity Difference

  3. tantaman revised this gist Nov 29, 2023. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -31,6 +31,7 @@ Below are diffs of each component of Linearite, implemented with LiveStore then
    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 is almost 0 code in Vulcan. This extension could be ported to LiveStore.

    # Reactivity Difference

  4. tantaman revised this gist Nov 29, 2023. 1 changed file with 4 additions and 2 deletions.
    6 changes: 4 additions & 2 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -4,8 +4,10 @@ I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main

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

    - LiveStore code is in <span style="color: red">RED</span>
    - Vulcan code is in <span style="color: green">GREEN</span>
    ```diff
    - LiveStore code is in RED
    + Vulcan code is in GREEN
    ```

    # Some high level differences:

  5. tantaman revised this gist Nov 29, 2023. 1 changed file with 3 additions and 0 deletions.
    3 changes: 3 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -4,6 +4,9 @@ I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main

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

    - LiveStore code is in <span style="color: red">RED</span>
    - Vulcan code is in <span style="color: green">GREEN</span>

    # Some high level differences:


  6. tantaman revised this gist Nov 29, 2023. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -14,10 +14,10 @@ Below are diffs of each component of Linearite, implemented with LiveStore then
    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. However we're limited to 1,000 writes per second.
    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. Vulcan is async. The DB can be in the main thread or in a worker and the user facing APIs remain the same.
    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
  7. tantaman revised this gist Nov 29, 2023. 1 changed file with 6 additions and 5 deletions.
    11 changes: 6 additions & 5 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -11,22 +11,23 @@ Below are diffs of each component of Linearite, implemented with LiveStore then
    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.
    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. 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 Issues?
    1. Given the nature of React controlled inputs, Vulcan requires an extra `useState` for these
    2. N/a for LiveStore
    4. Async
    1. Vulcan is async. 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

    # The last consipicuous difference is reactivity.
    # Reactivity Difference

    - Vulcan embraces React's reactivity system.
    - LiveStore bolts on its own.
  8. tantaman revised this gist Nov 29, 2023. 1 changed file with 4 additions and 4 deletions.
    8 changes: 4 additions & 4 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -4,7 +4,7 @@ I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main

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

    Some high level differences:
    # Some high level differences:


    1. Cross tab sync:
    @@ -26,7 +26,7 @@ Some high level differences:
    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

    Last consipicuous difference is reactivity.
    # The last consipicuous difference is reactivity.

    - Vulcan embraces React's reactivity system.
    - LiveStore bolts on its own.
    @@ -35,7 +35,7 @@ This mean Vulcan just has one API: `useQuery` and LiveStore has `useQuery`, `que

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

    In LiveStore:
    In **LiveStore**:

    ```ts
    const filterClause$ = querySQL<AppState>(`select * from app_state WHERE key = 'filter_state';`)
    @@ -50,7 +50,7 @@ function List({ showSearch = false }) {
    ...
    ```
    In Vulcan:
    In **Vulcan**:
    ```ts
    function List({ showSearch = false }) {
    const ctx = useDB(DBName)
  9. tantaman revised this gist Nov 29, 2023. 1 changed file with 44 additions and 3 deletions.
    47 changes: 44 additions & 3 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -1,8 +1,8 @@
    https://github.com/vlcn-io/js/assets/1009003/bd6f235b-ca94-46d0-a996-872c1cfc27e6

    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both [LiveStore](https://github.com/livestorejs/livestore) and [Vulcan](https://vlcn.io/) (Materialite next up).
    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both [LiveStore](https://github.com/livestorejs/livestore) and [Vulcan](https://vlcn.io/) to compare APIs with Materialite next up.

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

    Some high level differences:

    @@ -21,4 +21,45 @@ Some high level differences:
    2. 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
    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

    Last consipicuous difference is reactivity.

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

    ```ts
    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:
    ```ts
    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.
  10. tantaman revised this gist Nov 29, 2023. 3 changed files with 201 additions and 0 deletions.
    118 changes: 118 additions & 0 deletions pages_Issue_index.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,118 @@
    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)}
    />
    47 changes: 47 additions & 0 deletions pages_List_IssueRow.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,47 @@
    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
    36 changes: 36 additions & 0 deletions pages_List_index.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,36 @@
    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">
  11. tantaman revised this gist Nov 29, 2023. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,5 @@
    https://github.com/vlcn-io/js/assets/1009003/bd6f235b-ca94-46d0-a996-872c1cfc27e6

    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both [LiveStore](https://github.com/livestorejs/livestore) and [Vulcan](https://vlcn.io/) (Materialite next up).

    Below are diffs of each component. LiveStore vs Vulcan APIs.
  12. tantaman revised this gist Nov 29, 2023. 1 changed file with 4 additions and 4 deletions.
    8 changes: 4 additions & 4 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -9,11 +9,11 @@ Some high level differences:
    1. Vulcan automatically supports cross tab sync
    2. LiveStore lacks cross tab sync
    2. Limits
    1. Vulcan pages data in as needed, we can back this with a several gig database of issues if we wanted
    2. LiveStore requires all data to fit into memory
    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 UI thread. However we're limited to 1,000 writes per second.
    2. LiveStore can presumably do 10x the writes per second given everything is in-memory
    1. Vulcan queries are async, not blocking the UI thread. 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 Issues?
    1. Given the nature of React controlled inputs, Vulcan requires an extra `useState` for these
    2. N/a for LiveStore
  13. tantaman revised this gist Nov 29, 2023. 2 changed files with 22 additions and 3 deletions.
    21 changes: 20 additions & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,22 @@
    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both [LiveStore](https://github.com/livestorejs/livestore) and [Vulcan](https://vlcn.io/) (Materialite next up).

    Below are diffs of each component. LiveStore vs Vulcan APIs.
    Below are diffs of each component. LiveStore vs Vulcan APIs.

    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 back this with a several gig database of issues if we wanted
    2. LiveStore requires all data to fit into memory
    3. Perf
    1. Vulcan queries are async, not blocking UI thread. However we're limited to 1,000 writes per second.
    2. LiveStore can presumably do 10x the writes per second given everything is in-memory
    4. Async Issues?
    1. Given the nature of React controlled inputs, Vulcan requires an extra `useState` for these
    2. 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
    4 changes: 2 additions & 2 deletions queries.ts → vulcan-queries.ts
    Original file line number Diff line number Diff line change
    @@ -4,9 +4,9 @@ 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 = {
    // Types are auto-generated via `typed-sql`
    // run `pnpm sql-watch` to generate types
    totalIssueCount: S.sql<{
    c: number
    }>`SELECT COUNT(*) AS c FROM issue`,
  14. tantaman revised this gist Nov 29, 2023. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion readme.md
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,3 @@
    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both LiveStore and Vulcan (Materialite next up).
    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both [LiveStore](https://github.com/livestorejs/livestore) and [Vulcan](https://vlcn.io/) (Materialite next up).

    Below are diffs of each component. LiveStore vs Vulcan APIs.
  15. tantaman created this gist Nov 29, 2023.
    36 changes: 36 additions & 0 deletions Board_index.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,36 @@
    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">
    60 changes: 60 additions & 0 deletions Comments.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,60 @@
    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('')
    }
    74 changes: 74 additions & 0 deletions FilterMenu.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,74 @@
    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 (
    146 changes: 146 additions & 0 deletions IssueBoard.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,146 @@
    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,
    + })
    + }
    }
    }
    42 changes: 42 additions & 0 deletions IssueItem.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,42 @@
    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
    66 changes: 66 additions & 0 deletions IssueModal.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,66 @@
    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()
    97 changes: 97 additions & 0 deletions TopFilter.tsx.diff
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,97 @@
    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,
    })
    }}
    >
    54 changes: 54 additions & 0 deletions queries.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,54 @@
    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'

    export const queries = {
    // Types are auto-generated via `typed-sql`
    // run `pnpm sql-watch` to generate types
    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 = ?`,
    }
    3 changes: 3 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,3 @@
    I've implemented [Linearlite](https://github.com/electric-sql/electric/tree/main/examples/linearlite) in both LiveStore and Vulcan (Materialite next up).

    Below are diffs of each component. LiveStore vs Vulcan APIs.
    322 changes: 322 additions & 0 deletions schema.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,322 @@
    Vulcan schema:

    ```ts
    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:
    ```ts
    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):
    ```ts
    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]
    );
    }
    };
    ```