runic-pocketbase-collection

A pocketbase collection wrapper for Svelte

  • rune based reactivity
  • real time sync
  • optimistic updates via diff

Installation

pnpm i -D runic-pocketbase-collection

Prerequisites

This library uses PocketBase's Batch API. Enable it in your PocketBase Admin UI:

SettingsApplication → Enable Batch requests

Collection Usage

import PocketBase, { type RecordModel } from "pocketbase"
import { Collection, pbid } from "runic-pocketbase-collection"

// minimal pocketbase setup
const pb = new PocketBase("http://127.0.0.1:8090")
pb.autoCancellation(false)
const tasks = new Collection<RecordModel & { text?: string; done?: boolean }>(
  pb.collection("tasks")
)

// creates 15 character alphanumeric id
const id = pbid()

// create
tasks.update({ [id]: { text: "New task" } })

// read
console.log(tasks.records[id]?.text) // logs: "New task"

// update
tasks.update({ [id]: { done: true } })

// delete
tasks.update({ [id]: null })

// reactivity
const task = $derived(tasks.records[id])
$effect(() => {
  console.log(`${task?.done ? "done" : "todo"} : ${task?.text}`)
})

Task App Example

example task app example task app
<script lang="ts">
  import { type RecordModel } from "pocketbase"
  import { Collection, pbid } from "runic-pocketbase-collection"

  let { tasks }: { tasks: Collection<RecordModel & { text?: string; done?: boolean }> } = $props()

  let taskList = $derived(Object.values(tasks.records))
  let newTaskText = $state("")
</script>

<table>
  <thead>
    <tr>
      <th>
        <input
          type="checkbox"
          indeterminate={taskList.some(task => task.done) && taskList.some(task => !task.done)}
          checked={!!taskList.length && taskList.every(task => task.done)}
          onclick={() =>
            tasks.update(
              Object.fromEntries(
                taskList.map(task => [task.id, { done: !taskList.every(task => task.done) }])
              )
            )}
        />
      </th>
      <th>
        {taskList.filter(task => task.done).length}/{taskList.length} done
      </th>
      <th>
        <button
          onclick={() => tasks.update(Object.fromEntries(taskList.map(task => [task.id, null])))}
        >

        </button>
      </th>
    </tr>
  </thead>
  <tbody>
    {#each taskList as task (task.id)}
      <tr>
        <td>
          <input
            type="checkbox"
            checked={task.done}
            onclick={() => tasks.update({ [task.id]: { done: !task.done } })}
          />
        </td>
        <td>
          <input
            type="text"
            value={task.text}
            oninput={event => tasks.update({ [task.id]: { text: event.currentTarget.value } })}
          />
        </td>
        <td><button onclick={() => tasks.update({ [task.id]: null })}></button></td>
      </tr>
    {/each}
  </tbody>

  <tfoot>
    <tr>
      <td></td>
      <td>
        <input type="text" bind:value={newTaskText} placeholder="New task" />
      </td>
      <td>
        <button
          disabled={!newTaskText}
          onclick={() => {
            tasks.update({ [pbid()]: { text: newTaskText } })
            newTaskText = ""
          }}
        >

        </button>
      </td>
    </tr>
  </tfoot>
</table>

<style>
  table,
  input[type="text"] {
    width: 100%;
  }
</style>

Advanced Collection Usage

import PocketBase from "pocketbase"
import { Collection, pbid } from "runic-pocketbase-collection"

// explicit model type
interface TaskModel {
  id: string
  collectionId: string
  collectionName: string
  created: string
  updated: string
  text?: string
  done?: boolean
  subtasks: string[]
  expand: { subtasks: TaskModel[] }
}

// minimal pocketbase setup
const pb = new PocketBase("http://127.0.0.1:8090")
pb.autoCancellation(false)

// wrap task collection and expand subtasks
const tasks = new Collection<TaskModel>(pb.collection("tasks"), {
  options: { expand: "subtasks" }
})

const parentTaskId = pbid()
const childTaskId = pbid()

// create a parent and child task
await tasks.update({
  [parentTaskId]: { text: "Parent task", subtasks: [childTaskId] },
  [childTaskId]: { text: "Child task" }
})

// read expanded task
console.log(
  tasks.records[parentTaskId]?.expand.subtasks.some(subtask => subtask.id === childTaskId)
) // logs: true

// update filter for only done tasks
tasks.updateSubscriptionOptions({ expand: "subtasks", filter: "done = true" })

Item Usage

import PocketBase, { type RecordModel } from "pocketbase"
import { Item } from "runic-pocketbase-collection"

// minimal pocketbase setup
const pb = new PocketBase("http://127.0.0.1:8090")
pb.autoCancellation(false)

// wrap a single task by id
const taskId = "abc123def456ghi" // existing task id
const task = new Item<RecordModel & { text?: string; done?: boolean }>(
  pb.collection("tasks"),
  taskId
)

// read
console.log(task.record?.text) // logs: "Existing task"

// update
task.update({ done: true })

// reactivity
const taskText = $derived(task.record?.text)
$effect(() => {
  console.log(`Task: ${taskText}`)
})

Advanced Item Usage

import { getAbortSignal } from "svelte"
import PocketBase from "pocketbase"
import { Item } from "runic-pocketbase-collection"

// explicit model type
interface TaskModel {
  id: string
  collectionId: string
  collectionName: string
  created: string
  updated: string
  text?: string
  done?: boolean
  priority?: number
}

// minimal pocketbase setup
const pb = new PocketBase("http://127.0.0.1:8090")
pb.autoCancellation(false)

// wrap a single task with subscription options
const taskId = "abc123def456ghi" // existing task id
const task = new Item<TaskModel>(pb.collection("tasks"), taskId, {
  // real-time subscription options
  options: { expand: "subtasks" }
})

// refetch on mount with cancellation
$effect(() => {
  task.refetch({
    options: { signal: getAbortSignal() },
    onError: error => console.error("Failed to refetch:", error.message)
  })
})

// optimistic update with override
await task.update(
  { priority: 1 },
  { onError: error => console.error("Failed to update:", error.message) }
)

// read updated record
console.log(task.record?.priority) // logs: 1

// update subscription options to truncate the text to 200 characters
task.updateSubscriptionOptions({ expand: "subtasks", fields: "*,text:excerpt(200,true)" })

Development

You'll need to add a pocketbase executable to the database directory.

The following start script assumes you have pnpm / tmux / fish installed.

pnpm i
pnpm run start
# pocketbase now running at http://127.0.0.1:8090/_
# sveltekit app now running at http://localhost:5173