From 5d52ee0513c202314065baef40238062005fd559 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Wed, 6 May 2026 14:25:52 +0200 Subject: [PATCH 1/8] feat: add @karnak19/pbkit-realtime plugin for typed realtime subscriptions --- .../how-to/migrate-from-pocketbase-typegen.md | 2 +- apps/docs/src/content/docs/how-to/realtime.md | 173 +++++++++ .../src/content/docs/how-to/write-a-plugin.md | 2 +- apps/playground/pbkit.config.ts | 3 +- bun.lock | 13 + packages/pbkit-realtime/package.json | 46 +++ packages/pbkit-realtime/src/generate.ts | 82 ++++ packages/pbkit-realtime/src/index.ts | 1 + .../test/__snapshots__/realtime.test.ts.snap | 68 ++++ .../src/test/fixtures/full-schema.json | 350 ++++++++++++++++++ .../pbkit-realtime/src/test/realtime.test.ts | 112 ++++++ packages/pbkit-realtime/tsconfig.json | 10 + skills/pbkit/SKILL.md | 63 +++- 13 files changed, 911 insertions(+), 14 deletions(-) create mode 100644 apps/docs/src/content/docs/how-to/realtime.md create mode 100644 packages/pbkit-realtime/package.json create mode 100644 packages/pbkit-realtime/src/generate.ts create mode 100644 packages/pbkit-realtime/src/index.ts create mode 100644 packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap create mode 100644 packages/pbkit-realtime/src/test/fixtures/full-schema.json create mode 100644 packages/pbkit-realtime/src/test/realtime.test.ts create mode 100644 packages/pbkit-realtime/tsconfig.json diff --git a/apps/docs/src/content/docs/how-to/migrate-from-pocketbase-typegen.md b/apps/docs/src/content/docs/how-to/migrate-from-pocketbase-typegen.md index 5b44a2f..c702f32 100644 --- a/apps/docs/src/content/docs/how-to/migrate-from-pocketbase-typegen.md +++ b/apps/docs/src/content/docs/how-to/migrate-from-pocketbase-typegen.md @@ -2,7 +2,7 @@ title: Migrating from pocketbase-typegen description: A step-by-step guide to moving an existing project from pocketbase-typegen to pbkit. sidebar: - order: 6 + order: 7 --- [pocketbase-typegen](https://github.com/patmood/pocketbase-typegen) generates a diff --git a/apps/docs/src/content/docs/how-to/realtime.md b/apps/docs/src/content/docs/how-to/realtime.md new file mode 100644 index 0000000..0c7a1d6 --- /dev/null +++ b/apps/docs/src/content/docs/how-to/realtime.md @@ -0,0 +1,173 @@ +--- +title: Add realtime subscriptions +description: Generate typed realtime subscription helpers for PocketBase SSE. +sidebar: + order: 5 +--- + +The `@karnak19/pbkit-realtime` package provides a plugin that generates typed realtime subscription helpers using PocketBase's built-in SSE system. + +## Install + +The plugin runs at generation time, so it is a dev dependency. `pocketbase` is +the runtime peer (already required by the generated client). + +```bash +bun add -d @karnak19/pbkit-realtime +``` + +## Setup + +Add the plugin to your `pbkit.config.ts`: + +```ts +import { realtimePlugin } from "@karnak19/pbkit-realtime" + +export default { + input: "https://my-pb.example.com", + output: "./src/generated", + plugins: [realtimePlugin], +} +``` + +After running `bunx pbkit generate`, a `realtime.gen.ts` file is created alongside `types.gen.ts`, `client.gen.ts`, and `sdk.gen.ts`. + +## Generated output + +The plugin generates shared types and one `subscribeTo{Collection}()` function per non-excluded collection: + +### Shared types + +```ts +export type RealtimeAction = "create" | "update" | "delete" + +export interface RealtimeEvent { + action: RealtimeAction + record: T +} +``` + +### Subscription functions + +```ts +export function subscribeToArticles( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): () => Promise +``` + +Each function: +- Accepts a typed callback receiving `RealtimeEvent<{Collection}Record>` +- Accepts optional `filter` (PocketBase filter string) and `id` (subscribe to a specific record) +- Returns an unsubscribe function `() => Promise` + +## Usage example + +### Subscribe to all changes + +```ts +import { subscribeToArticles } from "./generated/realtime.gen" + +const unsub = subscribeToArticles((event) => { + if (event.action === "create") { + console.log("New article:", event.record.title) + } + if (event.action === "update") { + console.log("Updated article:", event.record.id) + } + if (event.action === "delete") { + console.log("Deleted article:", event.record.id) + } +}) + +// Later, when you no longer need the subscription +await unsub() +``` + +### Subscribe with a filter + +```ts +const unsub = subscribeToArticles( + (event) => { + console.log("Published article changed:", event.record.title) + }, + { filter: 'status = "published"' }, +) +``` + +### Subscribe to a specific record + +```ts +const unsub = subscribeToArticles( + (event) => { + console.log("Article updated:", event.record.title) + }, + { id: "RECORD_ID" }, +) +``` + +### React component example + +```tsx +import { subscribeToComments } from "./generated/realtime.gen" +import { useEffect, useState } from "react" + +function LiveComments({ articleId }: { articleId: string }) { + const [comments, setComments] = useState([]) + + useEffect(() => { + const unsub = subscribeToComments( + (event) => { + if (event.action === "create") { + setComments((prev) => [...prev, event.record]) + } + }, + { filter: `article = "${articleId}"` }, + ) + + return () => { unsub() } + }, [articleId]) + + return ( +
    + {comments.map((c) => ( +
  • {c.content}
  • + ))} +
+ ) +} +``` + +## Collection filtering + +The plugin respects the `collections` config — excluded collections won't generate subscription functions: + +```ts +export default { + collections: { + _superusers: { exclude: true }, + logs: { exclude: true }, + }, + plugins: [realtimePlugin], +} +``` + +## Framework-agnostic + +The generated helpers use the PocketBase client directly via `client.collection(name).subscribe()`. No framework-specific imports — works with React, Vue, Svelte, Solid, or any JavaScript environment. + +## Combining with other plugins + +All plugins can be used together: + +```ts +import { tanstackPlugin } from "@karnak19/pbkit-tanstack" +import { zodPlugin } from "@karnak19/pbkit-zod" +import { realtimePlugin } from "@karnak19/pbkit-realtime" + +export default { + input: "https://my-pb.example.com", + output: "./src/generated", + plugins: [tanstackPlugin, zodPlugin, realtimePlugin], +} +``` diff --git a/apps/docs/src/content/docs/how-to/write-a-plugin.md b/apps/docs/src/content/docs/how-to/write-a-plugin.md index b30619f..3555125 100644 --- a/apps/docs/src/content/docs/how-to/write-a-plugin.md +++ b/apps/docs/src/content/docs/how-to/write-a-plugin.md @@ -2,7 +2,7 @@ title: Write a plugin description: How to write a custom pbkit plugin. sidebar: - order: 5 + order: 6 --- pbkit plugins receive the parsed schema and return generated files. A plugin is an object implementing the `PbkitPlugin` interface. diff --git a/apps/playground/pbkit.config.ts b/apps/playground/pbkit.config.ts index 84c94f4..299f55a 100644 --- a/apps/playground/pbkit.config.ts +++ b/apps/playground/pbkit.config.ts @@ -1,8 +1,9 @@ import type { PbkitConfig } from "@karnak19/pbkit" import { tanstackPlugin } from "@karnak19/pbkit-tanstack" +import { realtimePlugin } from "@karnak19/pbkit-realtime" export default { input: "./pb_schema.json", output: "./src/generated", - plugins: [tanstackPlugin], + plugins: [tanstackPlugin, realtimePlugin], } satisfies PbkitConfig diff --git a/bun.lock b/bun.lock index 8ade554..0b9eaae 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,17 @@ "typescript": "^6.0.0", }, }, + "packages/pbkit-realtime": { + "name": "@karnak19/pbkit-realtime", + "version": "0.1.0", + "dependencies": { + "@karnak19/pbkit": "workspace:*", + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "typescript": "^6.0.0", + }, + }, "packages/pbkit-tanstack": { "name": "@karnak19/pbkit-tanstack", "version": "0.1.3", @@ -261,6 +272,8 @@ "@karnak19/pbkit": ["@karnak19/pbkit@workspace:packages/pbkit"], + "@karnak19/pbkit-realtime": ["@karnak19/pbkit-realtime@workspace:packages/pbkit-realtime"], + "@karnak19/pbkit-tanstack": ["@karnak19/pbkit-tanstack@workspace:packages/pbkit-tanstack"], "@karnak19/pbkit-zod": ["@karnak19/pbkit-zod@workspace:packages/pbkit-zod"], diff --git a/packages/pbkit-realtime/package.json b/packages/pbkit-realtime/package.json new file mode 100644 index 0000000..a403e78 --- /dev/null +++ b/packages/pbkit-realtime/package.json @@ -0,0 +1,46 @@ +{ + "name": "@karnak19/pbkit-realtime", + "version": "0.1.0", + "description": "Realtime subscription plugin for pbkit", + "homepage": "https://karnak19.github.io/pbkit/", + "repository": { + "type": "git", + "url": "git+https://github.com/Karnak19/pbkit.git", + "directory": "packages/pbkit-realtime" + }, + "bugs": { + "url": "https://github.com/Karnak19/pbkit/issues" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "bun build src/index.ts --outdir=dist --target=node && tsc --emitDeclarationOnly", + "test": "bun test", + "test:watch": "bun test --watch", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "prepublishOnly": "bun run build" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "dependencies": { + "@karnak19/pbkit": "workspace:*" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "typescript": "^6.0.0" + } +} diff --git a/packages/pbkit-realtime/src/generate.ts b/packages/pbkit-realtime/src/generate.ts new file mode 100644 index 0000000..bd00687 --- /dev/null +++ b/packages/pbkit-realtime/src/generate.ts @@ -0,0 +1,82 @@ +import type { SchemaIR, CollectionSchema } from "@karnak19/pbkit"; +import type { PbkitPlugin, PluginContext, PluginOutputFile } from "@karnak19/pbkit"; +import { isCollectionExcluded } from "@karnak19/pbkit"; + +function pascalCase(name: string): string { + return name + .split(/[-_]/) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); +} + +function singularize(name: string): string { + if (name.endsWith("ies")) return name.slice(0, -3) + "y"; + if (name.endsWith("ses")) return name.slice(0, -2); + if (name.endsWith("s") && !name.endsWith("ss")) return name.slice(0, -1); + return name; +} + +function subscribeFunction(col: CollectionSchema): string[] { + const p = pascalCase(col.name); + const s = pascalCase(singularize(col.name)); + const c = JSON.stringify(col.name); + const fnName = `subscribeTo${p}`; + const recordType = `${p}Record`; + + const lines: string[] = []; + + lines.push(`export function ${fnName}(`); + lines.push(` callback: (event: RealtimeEvent<${recordType}>) => void,`); + lines.push(` options?: { filter?: string; id?: string },`); + lines.push(`): () => Promise {`); + lines.push(` const target = options?.id ?? "*"`); + lines.push(` client.collection(${c}).subscribe(target, (e) => {`); + lines.push(` callback({ action: e.action as RealtimeAction, record: e.record as ${recordType} })`); + lines.push(` }, { filter: options?.filter })`); + lines.push(` return () => client.collection(${c}).unsubscribe(target)`); + lines.push(`}`); + lines.push(""); + + return lines; +} + +export function generateRealtime(ir: SchemaIR, ctx: PluginContext): string { + const parts: string[] = []; + const cols = ir.collections.filter((c) => !isCollectionExcluded(c.name, ctx.collections)); + + const typeImports = cols.map((c) => `${pascalCase(c.name)}Record`); + + parts.push("// Generated by pbkit-realtime — do not edit"); + parts.push(""); + parts.push(`import { client } from "./client.gen"`); + parts.push(`import type { ${typeImports.join(", ")} } from "${ctx.typesImport}"`); + parts.push(""); + parts.push(`export type RealtimeAction = "create" | "update" | "delete"`); + parts.push(""); + parts.push(`export interface RealtimeEvent {`); + parts.push(` action: RealtimeAction`); + parts.push(` record: T`); + parts.push(`}`); + parts.push(""); + + for (const col of cols) { + const p = pascalCase(col.name); + parts.push(`// --- ${p} ---`); + parts.push(""); + parts.push(...subscribeFunction(col)); + } + + return parts.join("\n"); +} + +export const realtimePlugin: PbkitPlugin = { + name: "@karnak19/pbkit-realtime", + generate(ctx: PluginContext): PluginOutputFile[] { + return [ + { + path: "realtime.gen.ts", + content: generateRealtime(ctx.ir, ctx), + }, + ]; + }, +}; diff --git a/packages/pbkit-realtime/src/index.ts b/packages/pbkit-realtime/src/index.ts new file mode 100644 index 0000000..45c0f54 --- /dev/null +++ b/packages/pbkit-realtime/src/index.ts @@ -0,0 +1 @@ +export { generateRealtime, realtimePlugin } from "./generate" diff --git a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap new file mode 100644 index 0000000..a50ed3b --- /dev/null +++ b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap @@ -0,0 +1,68 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`generateRealtime snapshot 1`] = ` +"// Generated by pbkit-realtime — do not edit + +import { client } from "./client.gen" +import type { UsersRecord, CategoriesRecord, ArticlesRecord, CommentsRecord } from "./types.gen" + +export type RealtimeAction = "create" | "update" | "delete" + +export interface RealtimeEvent { + action: RealtimeAction + record: T +} + +// --- Users --- + +export function subscribeToUsers( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): () => Promise { + const target = options?.id ?? "*" + client.collection("users").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as UsersRecord }) + }, { filter: options?.filter }) + return () => client.collection("users").unsubscribe(target) +} + +// --- Categories --- + +export function subscribeToCategories( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): () => Promise { + const target = options?.id ?? "*" + client.collection("categories").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as CategoriesRecord }) + }, { filter: options?.filter }) + return () => client.collection("categories").unsubscribe(target) +} + +// --- Articles --- + +export function subscribeToArticles( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): () => Promise { + const target = options?.id ?? "*" + client.collection("articles").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as ArticlesRecord }) + }, { filter: options?.filter }) + return () => client.collection("articles").unsubscribe(target) +} + +// --- Comments --- + +export function subscribeToComments( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): () => Promise { + const target = options?.id ?? "*" + client.collection("comments").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as CommentsRecord }) + }, { filter: options?.filter }) + return () => client.collection("comments").unsubscribe(target) +} +" +`; diff --git a/packages/pbkit-realtime/src/test/fixtures/full-schema.json b/packages/pbkit-realtime/src/test/fixtures/full-schema.json new file mode 100644 index 0000000..bd2a33e --- /dev/null +++ b/packages/pbkit-realtime/src/test/fixtures/full-schema.json @@ -0,0 +1,350 @@ +[ + { + "id": "_pbc_users", + "name": "users", + "type": "auth", + "system": false, + "fields": [ + { + "id": "text_id", + "name": "id", + "type": "text", + "system": true, + "required": true, + "primaryKey": true, + "autogeneratePattern": "[a-z0-9]{15}", + "min": 15, + "max": 15, + "pattern": "^[a-z0-9]+$" + }, + { + "id": "password_id", + "name": "password", + "type": "password", + "system": true, + "required": true, + "min": 8 + }, + { + "id": "email_id", + "name": "email", + "type": "email", + "system": true, + "required": true + }, + { + "id": "bool_id", + "name": "emailVisibility", + "type": "bool", + "system": true, + "required": false + }, + { + "id": "bool_id2", + "name": "verified", + "type": "bool", + "system": true, + "required": false + }, + { + "id": "text_id2", + "name": "name", + "type": "text", + "system": false, + "required": false, + "max": 100 + }, + { + "id": "file_id", + "name": "avatar", + "type": "file", + "system": false, + "required": false, + "maxSelect": 1, + "maxSize": 5242880, + "mimeTypes": ["image/jpeg", "image/png", "image/svg+xml", "image/gif", "image/webp"] + }, + { + "id": "autodate_id", + "name": "created", + "type": "autodate", + "system": true, + "onCreate": true + }, + { + "id": "autodate_id2", + "name": "updated", + "type": "autodate", + "system": true, + "onCreate": true, + "onUpdate": true + } + ], + "indexes": [ + "CREATE UNIQUE INDEX idx_email ON users (email) WHERE email != ''" + ] + }, + { + "id": "_pbc_categories", + "name": "categories", + "type": "base", + "system": false, + "fields": [ + { + "id": "text_cat_id", + "name": "id", + "type": "text", + "system": true, + "required": true, + "primaryKey": true, + "autogeneratePattern": "[a-z0-9]{15}", + "min": 15, + "max": 15, + "pattern": "^[a-z0-9]+$" + }, + { + "id": "text_cat_name", + "name": "name", + "type": "text", + "system": false, + "required": true, + "min": 1, + "max": 100 + }, + { + "id": "text_cat_slug", + "name": "slug", + "type": "text", + "system": false, + "required": true, + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + { + "id": "autodate_cat_created", + "name": "created", + "type": "autodate", + "system": true, + "onCreate": true + }, + { + "id": "autodate_cat_updated", + "name": "updated", + "type": "autodate", + "system": true, + "onCreate": true, + "onUpdate": true + } + ], + "indexes": [ + "CREATE UNIQUE INDEX idx_categories_slug ON categories (slug)" + ] + }, + { + "id": "_pbc_articles", + "name": "articles", + "type": "base", + "system": false, + "fields": [ + { + "id": "text_art_id", + "name": "id", + "type": "text", + "system": true, + "required": true, + "primaryKey": true, + "autogeneratePattern": "[a-z0-9]{15}", + "min": 15, + "max": 15, + "pattern": "^[a-z0-9]+$" + }, + { + "id": "text_art_title", + "name": "title", + "type": "text", + "system": false, + "required": true, + "min": 1, + "max": 200 + }, + { + "id": "editor_art_content", + "name": "content", + "type": "editor", + "system": false, + "required": false, + "convertURLs": true + }, + { + "id": "select_art_status", + "name": "status", + "type": "select", + "system": false, + "required": true, + "maxSelect": 1, + "values": ["draft", "published", "archived"] + }, + { + "id": "select_art_tags", + "name": "tags", + "type": "select", + "system": false, + "required": false, + "maxSelect": 5, + "values": ["technology", "design", "business", "lifestyle", "programming"] + }, + { + "id": "relation_art_author", + "name": "author", + "type": "relation", + "system": false, + "required": true, + "maxSelect": 1, + "collectionId": "_pbc_users", + "cascadeDelete": true + }, + { + "id": "relation_art_categories", + "name": "categories", + "type": "relation", + "system": false, + "required": false, + "maxSelect": 5, + "collectionId": "_pbc_categories", + "cascadeDelete": false + }, + { + "id": "file_art_cover", + "name": "cover", + "type": "file", + "system": false, + "required": false, + "maxSelect": 1, + "maxSize": 10485760, + "mimeTypes": ["image/jpeg", "image/png", "image/webp"] + }, + { + "id": "number_art_views", + "name": "views", + "type": "number", + "system": false, + "required": false, + "min": 0, + "noDecimal": true + }, + { + "id": "bool_art_featured", + "name": "featured", + "type": "bool", + "system": false, + "required": false + }, + { + "id": "json_art_metadata", + "name": "metadata", + "type": "json", + "system": false, + "required": false + }, + { + "id": "date_art_published_at", + "name": "published_at", + "type": "date", + "system": false, + "required": false + }, + { + "id": "url_art_source", + "name": "source", + "type": "url", + "system": false, + "required": false + }, + { + "id": "autodate_art_created", + "name": "created", + "type": "autodate", + "system": true, + "onCreate": true + }, + { + "id": "autodate_art_updated", + "name": "updated", + "type": "autodate", + "system": true, + "onCreate": true, + "onUpdate": true + } + ], + "indexes": [ + "CREATE INDEX idx_articles_status ON articles (status)", + "CREATE INDEX idx_articles_author ON articles (author)" + ] + }, + { + "id": "_pbc_comments", + "name": "comments", + "type": "base", + "system": false, + "fields": [ + { + "id": "text_com_id", + "name": "id", + "type": "text", + "system": true, + "required": true, + "primaryKey": true, + "autogeneratePattern": "[a-z0-9]{15}", + "min": 15, + "max": 15, + "pattern": "^[a-z0-9]+$" + }, + { + "id": "text_com_content", + "name": "content", + "type": "text", + "system": false, + "required": true, + "min": 1, + "max": 1000 + }, + { + "id": "relation_com_article", + "name": "article", + "type": "relation", + "system": false, + "required": true, + "maxSelect": 1, + "collectionId": "_pbc_articles", + "cascadeDelete": true + }, + { + "id": "relation_com_author", + "name": "author", + "type": "relation", + "system": false, + "required": true, + "maxSelect": 1, + "collectionId": "_pbc_users", + "cascadeDelete": false + }, + { + "id": "autodate_com_created", + "name": "created", + "type": "autodate", + "system": true, + "onCreate": true + }, + { + "id": "autodate_com_updated", + "name": "updated", + "type": "autodate", + "system": true, + "onCreate": true, + "onUpdate": true + } + ], + "indexes": [ + "CREATE INDEX idx_comments_article ON comments (article)" + ] + } +] diff --git a/packages/pbkit-realtime/src/test/realtime.test.ts b/packages/pbkit-realtime/src/test/realtime.test.ts new file mode 100644 index 0000000..ee4f1c0 --- /dev/null +++ b/packages/pbkit-realtime/src/test/realtime.test.ts @@ -0,0 +1,112 @@ +import { describe, test, expect } from "bun:test"; +import { parseJson } from "@karnak19/pbkit"; +import { generateRealtime, realtimePlugin } from "../generate"; +import fullSchema from "./fixtures/full-schema.json"; + +const ir = parseJson(fullSchema); +const ctx = { + ir, + typesImport: "./types.gen", + sdkImport: "./sdk.gen", +}; + +describe("generateRealtime", () => { + const output = generateRealtime(ir, ctx); + + test("imports client from client.gen", () => { + expect(output).toContain('import { client } from "./client.gen"'); + }); + + test("imports record types from configurable path", () => { + expect(output).toContain('from "./types.gen"'); + expect(output).toContain("ArticlesRecord"); + expect(output).toContain("CommentsRecord"); + expect(output).toContain("UsersRecord"); + expect(output).toContain("CategoriesRecord"); + }); + + test("uses configurable types import path", () => { + const custom = generateRealtime(ir, { + ...ctx, + typesImport: "@karnak19/pbkit/types", + }); + expect(custom).toContain('from "@karnak19/pbkit/types"'); + }); + + test("exports RealtimeAction type", () => { + expect(output).toContain('export type RealtimeAction = "create" | "update" | "delete"'); + }); + + test("exports RealtimeEvent interface", () => { + expect(output).toContain("export interface RealtimeEvent"); + expect(output).toContain("action: RealtimeAction"); + expect(output).toContain("record: T"); + }); + + test("generates subscribe function per collection", () => { + expect(output).toContain("export function subscribeToArticles("); + expect(output).toContain("export function subscribeToComments("); + expect(output).toContain("export function subscribeToCategories("); + expect(output).toContain("export function subscribeToUsers("); + }); + + test("subscribe function accepts typed callback", () => { + expect(output).toContain("callback: (event: RealtimeEvent) => void"); + expect(output).toContain("callback: (event: RealtimeEvent) => void"); + }); + + test("subscribe function accepts options with filter and id", () => { + expect(output).toContain("options?: { filter?: string; id?: string }"); + }); + + test("subscribe function returns unsubscribe function", () => { + expect(output).toContain("): () => Promise"); + }); + + test("uses client.collection with correct name", () => { + expect(output).toContain('client.collection("articles")'); + expect(output).toContain('client.collection("comments")'); + expect(output).toContain('client.collection("users")'); + expect(output).toContain('client.collection("categories")'); + }); + + test("defaults target to * when no id provided", () => { + expect(output).toContain('const target = options?.id ?? "*"'); + }); + + test("casts event action and record", () => { + expect(output).toContain("e.action as RealtimeAction"); + expect(output).toContain("e.record as ArticlesRecord"); + }); + + test("passes filter option to subscribe", () => { + expect(output).toContain("{ filter: options?.filter }"); + }); + + test("snapshot", () => { + expect(output).toMatchSnapshot(); + }); + + test("skips excluded collections", () => { + const excluded = generateRealtime(ir, { + ...ctx, + collections: { comments: { exclude: true } }, + }); + expect(excluded).not.toContain("subscribeToComments"); + expect(excluded).not.toContain("CommentsRecord"); + expect(excluded).toContain("subscribeToArticles"); + }); +}); + +describe("realtimePlugin", () => { + test("has correct name", () => { + expect(realtimePlugin.name).toBe("@karnak19/pbkit-realtime"); + }); + + test("generates realtime.gen.ts file", () => { + const files = realtimePlugin.generate(ctx); + expect(files).toHaveLength(1); + expect(files[0].path).toBe("realtime.gen.ts"); + expect(files[0].content).toContain("subscribeToArticles"); + }); +}); diff --git a/packages/pbkit-realtime/tsconfig.json b/packages/pbkit-realtime/tsconfig.json new file mode 100644 index 0000000..7906cb9 --- /dev/null +++ b/packages/pbkit-realtime/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["bun"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/test"] +} diff --git a/skills/pbkit/SKILL.md b/skills/pbkit/SKILL.md index ff482de..d8ad48b 100644 --- a/skills/pbkit/SKILL.md +++ b/skills/pbkit/SKILL.md @@ -1,19 +1,20 @@ --- name: pbkit -description: Typed PocketBase SDK generator. Generates TypeScript types, SDK functions, TanStack Query options, and Zod schemas from a PocketBase schema. +description: Typed PocketBase SDK generator. Generates TypeScript types, SDK functions, realtime subscriptions, TanStack Query options, and Zod schemas from a PocketBase schema. metadata: - tags: pocketbase, typescript, sdk, codegen, tanstack-query, zod, database + tags: pocketbase, typescript, sdk, codegen, tanstack-query, zod, realtime, database --- # pbkit -Use pbkit when a project needs type-safe TypeScript access to a PocketBase backend. pbkit reads a PocketBase schema from a live API or exported JSON file and generates typed records, create/update payloads, SDK functions, a client singleton, and optional plugin output (TanStack Query options, Zod schemas). +Use pbkit when a project needs type-safe TypeScript access to a PocketBase backend. pbkit reads a PocketBase schema from a live API or exported JSON file and generates typed records, create/update payloads, SDK functions, a client singleton, and optional plugin output (realtime subscriptions, TanStack Query options, Zod schemas). ## What pbkit Generates - `types.gen.ts`: TypeScript types for each non-excluded collection, including `XxxRecord`, `XxxCreate`, `XxxUpdate`, `XxxExpand`, and `CollectionName`. - `client.gen.ts`: Default PocketBase client singleton and `PbClient` type export. - `sdk.gen.ts`: Typed CRUD functions using the singleton client (with optional per-call override). +- `realtime.gen.ts`: Typed realtime subscription helpers when using `@karnak19/pbkit-realtime`. - `tanstack.gen.ts`: TanStack Query options when using `@karnak19/pbkit-tanstack`. - `zod.gen.ts`: Zod schemas when using `@karnak19/pbkit-zod`. @@ -26,6 +27,12 @@ bun add pocketbase pbkit is a build-time code generator, so install it as a devDependency. `pocketbase` is a peer/runtime dependency for projects that use generated SDK functions, so it stays a regular dependency. +For Realtime subscription generation: + +```bash +bun add @karnak19/pbkit-realtime +``` + For TanStack Query generation: ```bash @@ -190,6 +197,38 @@ await listArticles({ page: 1, fetch }) Generated auth collections include helpers named from the singular collection name. For a `users` collection, pbkit generates helpers such as `authUserWithPassword`, `authUserWithOAuth2`, `authUserWithOTP`, `requestUserPasswordReset`, `confirmUserPasswordReset`, `requestUserVerification`, `confirmUserVerification`, `requestUserEmailChange`, `confirmUserEmailChange`, and `refreshUser`. For an `admins` collection, those names use `Admin` instead of `User`. +## Realtime Subscriptions + +Add the plugin to `pbkit.config.ts`: + +```ts +import { realtimePlugin } from "@karnak19/pbkit-realtime" + +export default { + input: "https://my-pb.example.com", + output: "./src/generated", + plugins: [realtimePlugin], +} +``` + +The plugin generates `realtime.gen.ts` with typed subscription helpers using PocketBase's built-in SSE system: + +```ts +import { subscribeToArticles } from "./generated/realtime.gen" +import type { RealtimeEvent } from "./generated/realtime.gen" + +const unsub = subscribeToArticles((event: RealtimeEvent) => { + if (event.action === "create") { + console.log("New article:", event.record.title) + } +}, { filter: 'status = "published"' }) + +// Later +await unsub() +``` + +Each `subscribeTo{Collection}()` function accepts a typed callback, optional `filter` (PocketBase filter string), optional `id` (subscribe to a specific record), and returns an unsubscribe function. The plugin respects `collections` config — excluded collections are skipped. + ## TanStack Query Integration Add the plugin to `pbkit.config.ts`: @@ -267,16 +306,17 @@ import { zodResolver } from "@hookform/resolvers/zod" const { register } = useForm({ resolver: zodResolver(ArticlesCreateSchema) }) ``` -Both plugins can be used together: +All plugins can be used together: ```ts import { zodPlugin } from "@karnak19/pbkit-zod" import { tanstackPlugin } from "@karnak19/pbkit-tanstack" +import { realtimePlugin } from "@karnak19/pbkit-realtime" export default { input: "https://my-pb.example.com", output: "./src/generated", - plugins: [zodPlugin, tanstackPlugin], + plugins: [zodPlugin, tanstackPlugin, realtimePlugin], } ``` @@ -320,9 +360,10 @@ When a project already uses [pocketbase-typegen](https://github.com/patmood/pock 1. Check for an existing `pbkit.config.ts` before adding a new one. 2. Install `@karnak19/pbkit` and `pocketbase` if the project does not already depend on them. -3. Add `@karnak19/pbkit-tanstack` only when the project uses TanStack Query or explicitly asks for query helpers. -4. Add `@karnak19/pbkit-zod` when the project needs runtime validation (forms, API responses). -5. Run `bunx pbkit generate` or `npx pbkit generate` after changing config or schema inputs. -6. Import from generated files (`.gen.ts` suffix) instead of recreating PocketBase access wrappers by hand. -7. For multi-client setups, leave `sdk.baseUrl` empty and pass `{ client }` override to SDK functions as needed. -8. If the project uses `pocketbase-typegen`, follow the migration mapping above rather than adding pbkit alongside it. +3. Add `@karnak19/pbkit-realtime` when the project needs realtime SSE subscriptions. +4. Add `@karnak19/pbkit-tanstack` only when the project uses TanStack Query or explicitly asks for query helpers. +5. Add `@karnak19/pbkit-zod` when the project needs runtime validation (forms, API responses). +6. Run `bunx pbkit generate` or `npx pbkit generate` after changing config or schema inputs. +7. Import from generated files (`.gen.ts` suffix) instead of recreating PocketBase access wrappers by hand. +8. For multi-client setups, leave `sdk.baseUrl` empty and pass `{ client }` override to SDK functions as needed. +9. If the project uses `pocketbase-typegen`, follow the migration mapping above rather than adding pbkit alongside it. From 14494ae0ae0d5a496df11195786080c4f7189a97 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Wed, 6 May 2026 14:48:13 +0200 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code,=20fix=20filter=20docs,=20add?= =?UTF-8?q?=20edge-case=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/src/content/docs/how-to/realtime.md | 2 ++ packages/pbkit-realtime/src/generate.ts | 12 ++----- .../pbkit-realtime/src/test/realtime.test.ts | 31 +++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/docs/src/content/docs/how-to/realtime.md b/apps/docs/src/content/docs/how-to/realtime.md index 0c7a1d6..91dde5c 100644 --- a/apps/docs/src/content/docs/how-to/realtime.md +++ b/apps/docs/src/content/docs/how-to/realtime.md @@ -138,6 +138,8 @@ function LiveComments({ articleId }: { articleId: string }) { } ``` +Note: PocketBase filter strings are passed directly to the server. Avoid interpolating untrusted user input into filter strings. Use PocketBase's parameterized filter syntax where needed. + ## Collection filtering The plugin respects the `collections` config — excluded collections won't generate subscription functions: diff --git a/packages/pbkit-realtime/src/generate.ts b/packages/pbkit-realtime/src/generate.ts index bd00687..a15c8f4 100644 --- a/packages/pbkit-realtime/src/generate.ts +++ b/packages/pbkit-realtime/src/generate.ts @@ -9,16 +9,8 @@ function pascalCase(name: string): string { .join(""); } -function singularize(name: string): string { - if (name.endsWith("ies")) return name.slice(0, -3) + "y"; - if (name.endsWith("ses")) return name.slice(0, -2); - if (name.endsWith("s") && !name.endsWith("ss")) return name.slice(0, -1); - return name; -} - function subscribeFunction(col: CollectionSchema): string[] { const p = pascalCase(col.name); - const s = pascalCase(singularize(col.name)); const c = JSON.stringify(col.name); const fnName = `subscribeTo${p}`; const recordType = `${p}Record`; @@ -49,7 +41,9 @@ export function generateRealtime(ir: SchemaIR, ctx: PluginContext): string { parts.push("// Generated by pbkit-realtime — do not edit"); parts.push(""); parts.push(`import { client } from "./client.gen"`); - parts.push(`import type { ${typeImports.join(", ")} } from "${ctx.typesImport}"`); + if (typeImports.length > 0) { + parts.push(`import type { ${typeImports.join(", ")} } from "${ctx.typesImport}"`); + } parts.push(""); parts.push(`export type RealtimeAction = "create" | "update" | "delete"`); parts.push(""); diff --git a/packages/pbkit-realtime/src/test/realtime.test.ts b/packages/pbkit-realtime/src/test/realtime.test.ts index ee4f1c0..2849a05 100644 --- a/packages/pbkit-realtime/src/test/realtime.test.ts +++ b/packages/pbkit-realtime/src/test/realtime.test.ts @@ -110,3 +110,34 @@ describe("realtimePlugin", () => { expect(files[0].content).toContain("subscribeToArticles"); }); }); + +describe("edge cases", () => { + test("handles empty schema", () => { + const emptyIr = parseJson([]); + const output = generateRealtime(emptyIr, ctx); + expect(output).toContain("Generated by pbkit-realtime"); + expect(output).toContain("RealtimeAction"); + expect(output).toContain("RealtimeEvent"); + expect(output).not.toContain("subscribeTo"); + expect(output).not.toContain("import type"); + }); + + test("handles collection names with separators", () => { + const schema = [ + { + id: "_pbc_beta_feedback", + name: "beta_feedback", + type: "base", + system: false, + fields: [ + { id: "text_id", name: "id", type: "text", system: true, required: true }, + { id: "text_title", name: "title", type: "text", system: false, required: true }, + ], + }, + ]; + const output = generateRealtime(parseJson(schema), ctx); + expect(output).toContain("subscribeToBetaFeedback"); + expect(output).toContain("BetaFeedbackRecord"); + expect(output).toContain('client.collection("beta_feedback")'); + }); +}); From a4eb0caf56bbdfe1d806c44f9e4d872be699c1c9 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Wed, 6 May 2026 15:19:21 +0200 Subject: [PATCH 3/8] refactor: extract pascalCase to core, address review feedback --- apps/docs/src/content/docs/how-to/realtime.md | 2 +- packages/pbkit-realtime/src/generate.ts | 11 ++--------- .../src/test/__snapshots__/realtime.test.ts.snap | 2 +- packages/pbkit-realtime/src/test/realtime.test.ts | 3 ++- packages/pbkit-tanstack/src/generate.ts | 9 +-------- packages/pbkit-zod/src/generate.ts | 9 +-------- packages/pbkit/src/index.ts | 2 ++ packages/pbkit/src/utils/index.ts | 1 + packages/pbkit/src/utils/pascalCase.ts | 6 ++++++ 9 files changed, 17 insertions(+), 28 deletions(-) create mode 100644 packages/pbkit/src/utils/index.ts create mode 100644 packages/pbkit/src/utils/pascalCase.ts diff --git a/apps/docs/src/content/docs/how-to/realtime.md b/apps/docs/src/content/docs/how-to/realtime.md index 91dde5c..35cf0d8 100644 --- a/apps/docs/src/content/docs/how-to/realtime.md +++ b/apps/docs/src/content/docs/how-to/realtime.md @@ -138,7 +138,7 @@ function LiveComments({ articleId }: { articleId: string }) { } ``` -Note: PocketBase filter strings are passed directly to the server. Avoid interpolating untrusted user input into filter strings. Use PocketBase's parameterized filter syntax where needed. +Avoid interpolating untrusted input directly into filter strings. Prefer constructing filters server-side or validating IDs before inserting them. ## Collection filtering diff --git a/packages/pbkit-realtime/src/generate.ts b/packages/pbkit-realtime/src/generate.ts index a15c8f4..c8f054e 100644 --- a/packages/pbkit-realtime/src/generate.ts +++ b/packages/pbkit-realtime/src/generate.ts @@ -1,13 +1,6 @@ import type { SchemaIR, CollectionSchema } from "@karnak19/pbkit"; import type { PbkitPlugin, PluginContext, PluginOutputFile } from "@karnak19/pbkit"; -import { isCollectionExcluded } from "@karnak19/pbkit"; - -function pascalCase(name: string): string { - return name - .split(/[-_]/) - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(""); -} +import { isCollectionExcluded, pascalCase } from "@karnak19/pbkit"; function subscribeFunction(col: CollectionSchema): string[] { const p = pascalCase(col.name); @@ -36,7 +29,7 @@ export function generateRealtime(ir: SchemaIR, ctx: PluginContext): string { const parts: string[] = []; const cols = ir.collections.filter((c) => !isCollectionExcluded(c.name, ctx.collections)); - const typeImports = cols.map((c) => `${pascalCase(c.name)}Record`); + const typeImports = cols.map((c) => `${pascalCase(c.name)}Record`).sort(); parts.push("// Generated by pbkit-realtime — do not edit"); parts.push(""); diff --git a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap index a50ed3b..5f0dadf 100644 --- a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap +++ b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap @@ -4,7 +4,7 @@ exports[`generateRealtime snapshot 1`] = ` "// Generated by pbkit-realtime — do not edit import { client } from "./client.gen" -import type { UsersRecord, CategoriesRecord, ArticlesRecord, CommentsRecord } from "./types.gen" +import type { ArticlesRecord, CategoriesRecord, CommentsRecord, UsersRecord } from "./types.gen" export type RealtimeAction = "create" | "update" | "delete" diff --git a/packages/pbkit-realtime/src/test/realtime.test.ts b/packages/pbkit-realtime/src/test/realtime.test.ts index 2849a05..53b34af 100644 --- a/packages/pbkit-realtime/src/test/realtime.test.ts +++ b/packages/pbkit-realtime/src/test/realtime.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import { parseJson } from "@karnak19/pbkit"; +import type { PluginContext } from "@karnak19/pbkit"; import { generateRealtime, realtimePlugin } from "../generate"; import fullSchema from "./fixtures/full-schema.json"; @@ -8,7 +9,7 @@ const ctx = { ir, typesImport: "./types.gen", sdkImport: "./sdk.gen", -}; +} satisfies PluginContext; describe("generateRealtime", () => { const output = generateRealtime(ir, ctx); diff --git a/packages/pbkit-tanstack/src/generate.ts b/packages/pbkit-tanstack/src/generate.ts index 9090829..0aa4966 100644 --- a/packages/pbkit-tanstack/src/generate.ts +++ b/packages/pbkit-tanstack/src/generate.ts @@ -1,15 +1,8 @@ import type { SchemaIR, CollectionSchema, CollectionsConfig } from "@karnak19/pbkit"; import type { PbkitPlugin, PluginContext, PluginOutputFile } from "@karnak19/pbkit"; -import { isCollectionExcluded, isOperationEnabled } from "@karnak19/pbkit"; +import { isCollectionExcluded, isOperationEnabled, pascalCase } from "@karnak19/pbkit"; import type { OperationName } from "@karnak19/pbkit"; -function pascalCase(name: string): string { - return name - .split(/[-_]/) - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(""); -} - function singularize(name: string): string { if (name.endsWith("ies")) return name.slice(0, -3) + "y"; if (name.endsWith("ses")) return name.slice(0, -2); diff --git a/packages/pbkit-zod/src/generate.ts b/packages/pbkit-zod/src/generate.ts index 6c262a1..a7c644a 100644 --- a/packages/pbkit-zod/src/generate.ts +++ b/packages/pbkit-zod/src/generate.ts @@ -5,14 +5,7 @@ import type { CollectionsConfig, } from "@karnak19/pbkit"; import type { PbkitPlugin, PluginContext, PluginOutputFile } from "@karnak19/pbkit"; -import { isCollectionExcluded, isMultipleField } from "@karnak19/pbkit"; - -function pascalCase(name: string): string { - return name - .split(/[-_]/) - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(""); -} +import { isCollectionExcluded, isMultipleField, pascalCase } from "@karnak19/pbkit"; const SYSTEM_SKIP = new Set(["tokenKey"]); const AUTH_SYSTEM = new Set(["email", "emailVisibility", "verified"]); diff --git a/packages/pbkit/src/index.ts b/packages/pbkit/src/index.ts index bbb5519..eeb7700 100644 --- a/packages/pbkit/src/index.ts +++ b/packages/pbkit/src/index.ts @@ -32,6 +32,8 @@ export type { PbkitPlugin, PluginContext, PluginOutputFile } from "./plugin"; export { isCollectionExcluded, isOperationEnabled, enabledOperations } from "./config"; export type { OperationName, CollectionConfig, CollectionsConfig } from "./config"; +export { pascalCase } from "./utils"; + export type { PbkitConfig, InputConfig } from "./config"; export { resolveConfigPath, findConfig } from "./config"; export { generateProject } from "./generate"; diff --git a/packages/pbkit/src/utils/index.ts b/packages/pbkit/src/utils/index.ts new file mode 100644 index 0000000..2e6b217 --- /dev/null +++ b/packages/pbkit/src/utils/index.ts @@ -0,0 +1 @@ +export { pascalCase } from "./pascalCase" diff --git a/packages/pbkit/src/utils/pascalCase.ts b/packages/pbkit/src/utils/pascalCase.ts new file mode 100644 index 0000000..2a97bc2 --- /dev/null +++ b/packages/pbkit/src/utils/pascalCase.ts @@ -0,0 +1,6 @@ +export function pascalCase(name: string): string { + return name + .split(/[-_]/) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); +} From bfbf8843118f7980e37454aba677bfbed5c2c961 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Thu, 7 May 2026 17:49:41 +0200 Subject: [PATCH 4/8] fix: use PocketBase UnsubscribeFunc, add README, update docs for async API --- apps/docs/src/content/docs/how-to/realtime.md | 20 ++++--- packages/pbkit-realtime/README.md | 55 +++++++++++++++++++ packages/pbkit-realtime/src/generate.ts | 7 +-- .../test/__snapshots__/realtime.test.ts.snap | 28 ++++------ .../pbkit-realtime/src/test/realtime.test.ts | 14 ++--- skills/pbkit/SKILL.md | 4 +- 6 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 packages/pbkit-realtime/README.md diff --git a/apps/docs/src/content/docs/how-to/realtime.md b/apps/docs/src/content/docs/how-to/realtime.md index 35cf0d8..636d7ef 100644 --- a/apps/docs/src/content/docs/how-to/realtime.md +++ b/apps/docs/src/content/docs/how-to/realtime.md @@ -53,13 +53,13 @@ export interface RealtimeEvent { export function subscribeToArticles( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): () => Promise +): Promise<() => void> ``` Each function: - Accepts a typed callback receiving `RealtimeEvent<{Collection}Record>` - Accepts optional `filter` (PocketBase filter string) and `id` (subscribe to a specific record) -- Returns an unsubscribe function `() => Promise` +- Returns `Promise<() => void>` — an async function that resolves with an unsubscribe callback ## Usage example @@ -68,7 +68,7 @@ Each function: ```ts import { subscribeToArticles } from "./generated/realtime.gen" -const unsub = subscribeToArticles((event) => { +const unsub = await subscribeToArticles((event) => { if (event.action === "create") { console.log("New article:", event.record.title) } @@ -81,13 +81,13 @@ const unsub = subscribeToArticles((event) => { }) // Later, when you no longer need the subscription -await unsub() +unsub() ``` ### Subscribe with a filter ```ts -const unsub = subscribeToArticles( +const unsub = await subscribeToArticles( (event) => { console.log("Published article changed:", event.record.title) }, @@ -98,7 +98,7 @@ const unsub = subscribeToArticles( ### Subscribe to a specific record ```ts -const unsub = subscribeToArticles( +const unsub = await subscribeToArticles( (event) => { console.log("Article updated:", event.record.title) }, @@ -116,16 +116,18 @@ function LiveComments({ articleId }: { articleId: string }) { const [comments, setComments] = useState([]) useEffect(() => { - const unsub = subscribeToComments( + let unsub: (() => void) | undefined + + subscribeToComments( (event) => { if (event.action === "create") { setComments((prev) => [...prev, event.record]) } }, { filter: `article = "${articleId}"` }, - ) + ).then((fn) => { unsub = fn }) - return () => { unsub() } + return () => { unsub?.() } }, [articleId]) return ( diff --git a/packages/pbkit-realtime/README.md b/packages/pbkit-realtime/README.md new file mode 100644 index 0000000..57b6ff0 --- /dev/null +++ b/packages/pbkit-realtime/README.md @@ -0,0 +1,55 @@ +# @karnak19/pbkit-realtime + +Realtime subscription plugin for `@karnak19/pbkit`. + +It generates typed SSE subscription helpers from your PocketBase schema using +PocketBase's built-in realtime system. + +## Install + +```bash +bun add @karnak19/pbkit @karnak19/pbkit-realtime +``` + +## Setup + +```ts +// pbkit.config.ts +import { realtimePlugin } from "@karnak19/pbkit-realtime" + +export default { + input: "./pb_schema.json", + output: "./src/generated", + sdk: { + baseUrl: "https://my-pocketbase.example.com", + }, + plugins: [realtimePlugin], +} +``` + +Run pbkit: + +```bash +bunx pbkit generate +``` + +The plugin writes `src/generated/realtime.gen.ts` alongside the core pbkit +generated files. + +## Usage + +```ts +import { subscribeToArticles } from "./generated/realtime.gen" + +const unsub = await subscribeToArticles((event) => { + if (event.action === "create") { + console.log("New article:", event.record.title) + } +}) + +// Later +unsub() +``` + +The generated helpers use the PocketBase client directly, so they work with any +framework (React, Solid, Svelte, Vue, etc.). diff --git a/packages/pbkit-realtime/src/generate.ts b/packages/pbkit-realtime/src/generate.ts index c8f054e..24a7e38 100644 --- a/packages/pbkit-realtime/src/generate.ts +++ b/packages/pbkit-realtime/src/generate.ts @@ -10,15 +10,14 @@ function subscribeFunction(col: CollectionSchema): string[] { const lines: string[] = []; - lines.push(`export function ${fnName}(`); + lines.push(`export async function ${fnName}(`); lines.push(` callback: (event: RealtimeEvent<${recordType}>) => void,`); lines.push(` options?: { filter?: string; id?: string },`); - lines.push(`): () => Promise {`); + lines.push(`): Promise<() => void> {`); lines.push(` const target = options?.id ?? "*"`); - lines.push(` client.collection(${c}).subscribe(target, (e) => {`); + lines.push(` return client.collection(${c}).subscribe(target, (e) => {`); lines.push(` callback({ action: e.action as RealtimeAction, record: e.record as ${recordType} })`); lines.push(` }, { filter: options?.filter })`); - lines.push(` return () => client.collection(${c}).unsubscribe(target)`); lines.push(`}`); lines.push(""); diff --git a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap index 5f0dadf..81ac0f8 100644 --- a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap +++ b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap @@ -15,54 +15,50 @@ export interface RealtimeEvent { // --- Users --- -export function subscribeToUsers( +export async function subscribeToUsers( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): () => Promise { +): Promise<() => void> { const target = options?.id ?? "*" - client.collection("users").subscribe(target, (e) => { + return client.collection("users").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as UsersRecord }) }, { filter: options?.filter }) - return () => client.collection("users").unsubscribe(target) } // --- Categories --- -export function subscribeToCategories( +export async function subscribeToCategories( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): () => Promise { +): Promise<() => void> { const target = options?.id ?? "*" - client.collection("categories").subscribe(target, (e) => { + return client.collection("categories").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as CategoriesRecord }) }, { filter: options?.filter }) - return () => client.collection("categories").unsubscribe(target) } // --- Articles --- -export function subscribeToArticles( +export async function subscribeToArticles( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): () => Promise { +): Promise<() => void> { const target = options?.id ?? "*" - client.collection("articles").subscribe(target, (e) => { + return client.collection("articles").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as ArticlesRecord }) }, { filter: options?.filter }) - return () => client.collection("articles").unsubscribe(target) } // --- Comments --- -export function subscribeToComments( +export async function subscribeToComments( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): () => Promise { +): Promise<() => void> { const target = options?.id ?? "*" - client.collection("comments").subscribe(target, (e) => { + return client.collection("comments").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as CommentsRecord }) }, { filter: options?.filter }) - return () => client.collection("comments").unsubscribe(target) } " `; diff --git a/packages/pbkit-realtime/src/test/realtime.test.ts b/packages/pbkit-realtime/src/test/realtime.test.ts index 53b34af..b23e1fd 100644 --- a/packages/pbkit-realtime/src/test/realtime.test.ts +++ b/packages/pbkit-realtime/src/test/realtime.test.ts @@ -44,11 +44,11 @@ describe("generateRealtime", () => { expect(output).toContain("record: T"); }); - test("generates subscribe function per collection", () => { - expect(output).toContain("export function subscribeToArticles("); - expect(output).toContain("export function subscribeToComments("); - expect(output).toContain("export function subscribeToCategories("); - expect(output).toContain("export function subscribeToUsers("); + test("generates async subscribe function per collection", () => { + expect(output).toContain("export async function subscribeToArticles("); + expect(output).toContain("export async function subscribeToComments("); + expect(output).toContain("export async function subscribeToCategories("); + expect(output).toContain("export async function subscribeToUsers("); }); test("subscribe function accepts typed callback", () => { @@ -60,8 +60,8 @@ describe("generateRealtime", () => { expect(output).toContain("options?: { filter?: string; id?: string }"); }); - test("subscribe function returns unsubscribe function", () => { - expect(output).toContain("): () => Promise"); + test("subscribe function returns Promise of unsubscribe function", () => { + expect(output).toContain("): Promise<() => void>"); }); test("uses client.collection with correct name", () => { diff --git a/skills/pbkit/SKILL.md b/skills/pbkit/SKILL.md index d8ad48b..e988c2c 100644 --- a/skills/pbkit/SKILL.md +++ b/skills/pbkit/SKILL.md @@ -217,14 +217,14 @@ The plugin generates `realtime.gen.ts` with typed subscription helpers using Poc import { subscribeToArticles } from "./generated/realtime.gen" import type { RealtimeEvent } from "./generated/realtime.gen" -const unsub = subscribeToArticles((event: RealtimeEvent) => { +const unsub = await subscribeToArticles((event: RealtimeEvent) => { if (event.action === "create") { console.log("New article:", event.record.title) } }, { filter: 'status = "published"' }) // Later -await unsub() +unsub() ``` Each `subscribeTo{Collection}()` function accepts a typed callback, optional `filter` (PocketBase filter string), optional `id` (subscribe to a specific record), and returns an unsubscribe function. The plugin respects `collections` config — excluded collections are skipped. From 10f7cc49e882de89a65bf031196d0b836e0508e3 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Mon, 1 Jun 2026 13:15:12 +0200 Subject: [PATCH 5/8] docs(skill): install realtime plugin as devDependency Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/pbkit/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/pbkit/SKILL.md b/skills/pbkit/SKILL.md index e988c2c..8b9a4e0 100644 --- a/skills/pbkit/SKILL.md +++ b/skills/pbkit/SKILL.md @@ -30,9 +30,11 @@ pbkit is a build-time code generator, so install it as a devDependency. `pocketb For Realtime subscription generation: ```bash -bun add @karnak19/pbkit-realtime +bun add -d @karnak19/pbkit-realtime ``` +The plugin is build-time (devDependency); the generated helpers use the runtime `pocketbase` client. + For TanStack Query generation: ```bash From 10be4477d9fd702834708579cedc230e9a3ccea2 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Mon, 1 Jun 2026 13:32:14 +0200 Subject: [PATCH 6/8] chore: add changeset for pbkit-realtime (+ pbkit pascalCase export) Publish @karnak19/pbkit-realtime at 0.1.0 and bump @karnak19/pbkit (minor) so the pascalCase export the plugin imports is available in a published version. Set realtime version to 0.0.0 so the minor changeset lands 0.1.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/pbkit-realtime.md | 10 ++++++++++ packages/pbkit-realtime/package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/pbkit-realtime.md diff --git a/.changeset/pbkit-realtime.md b/.changeset/pbkit-realtime.md new file mode 100644 index 0000000..2db87e6 --- /dev/null +++ b/.changeset/pbkit-realtime.md @@ -0,0 +1,10 @@ +--- +"@karnak19/pbkit-realtime": minor +"@karnak19/pbkit": minor +--- + +Add `@karnak19/pbkit-realtime` plugin for typed realtime subscriptions + +Generates a `subscribeTo{Collection}()` helper per non-excluded collection using PocketBase's built-in SSE system. Each helper takes a typed `(event: RealtimeEvent<{Collection}Record>) => void` callback plus optional `filter`/`id`, and returns an unsubscribe function. + +Also exports `pascalCase` from `@karnak19/pbkit` so plugins can share it — the realtime, TanStack Query, and Zod plugins now use the core helper instead of duplicating it. diff --git a/packages/pbkit-realtime/package.json b/packages/pbkit-realtime/package.json index a403e78..6397250 100644 --- a/packages/pbkit-realtime/package.json +++ b/packages/pbkit-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@karnak19/pbkit-realtime", - "version": "0.1.0", + "version": "0.0.0", "description": "Realtime subscription plugin for pbkit", "homepage": "https://karnak19.github.io/pbkit/", "repository": { From 19adf11349c876187eb7554c4f47c7752a5f4a13 Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Mon, 1 Jun 2026 14:46:25 +0200 Subject: [PATCH 7/8] fix(realtime): async unsubscribe return type + pocketbase peerDependency - Generated subscribe helpers now return Promise<() => Promise>, matching PocketBase's UnsubscribeFunc (unsubscribe is async). Update snapshot, tests, docs, README, and SKILL to await unsub(). - Declare pocketbase as a peerDependency, consistent with pbkit-zod and pbkit-tanstack. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/docs/src/content/docs/how-to/realtime.md | 8 ++++---- packages/pbkit-realtime/README.md | 4 ++-- packages/pbkit-realtime/package.json | 3 +++ packages/pbkit-realtime/src/generate.ts | 2 +- .../src/test/__snapshots__/realtime.test.ts.snap | 8 ++++---- packages/pbkit-realtime/src/test/realtime.test.ts | 4 ++-- skills/pbkit/SKILL.md | 6 +++--- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/docs/src/content/docs/how-to/realtime.md b/apps/docs/src/content/docs/how-to/realtime.md index 636d7ef..20c557c 100644 --- a/apps/docs/src/content/docs/how-to/realtime.md +++ b/apps/docs/src/content/docs/how-to/realtime.md @@ -53,13 +53,13 @@ export interface RealtimeEvent { export function subscribeToArticles( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): Promise<() => void> +): Promise<() => Promise> ``` Each function: - Accepts a typed callback receiving `RealtimeEvent<{Collection}Record>` - Accepts optional `filter` (PocketBase filter string) and `id` (subscribe to a specific record) -- Returns `Promise<() => void>` — an async function that resolves with an unsubscribe callback +- Returns `Promise<() => Promise>` — resolves with an **async** unsubscribe callback (PocketBase's `UnsubscribeFunc`); `await` it to surface unsubscribe errors ## Usage example @@ -81,7 +81,7 @@ const unsub = await subscribeToArticles((event) => { }) // Later, when you no longer need the subscription -unsub() +await unsub() ``` ### Subscribe with a filter @@ -116,7 +116,7 @@ function LiveComments({ articleId }: { articleId: string }) { const [comments, setComments] = useState([]) useEffect(() => { - let unsub: (() => void) | undefined + let unsub: (() => Promise) | undefined subscribeToComments( (event) => { diff --git a/packages/pbkit-realtime/README.md b/packages/pbkit-realtime/README.md index 57b6ff0..e11020f 100644 --- a/packages/pbkit-realtime/README.md +++ b/packages/pbkit-realtime/README.md @@ -47,8 +47,8 @@ const unsub = await subscribeToArticles((event) => { } }) -// Later -unsub() +// Later (unsubscribe is async — await to surface errors) +await unsub() ``` The generated helpers use the PocketBase client directly, so they work with any diff --git a/packages/pbkit-realtime/package.json b/packages/pbkit-realtime/package.json index 6397250..6590278 100644 --- a/packages/pbkit-realtime/package.json +++ b/packages/pbkit-realtime/package.json @@ -36,6 +36,9 @@ "access": "public" }, "license": "MIT", + "peerDependencies": { + "pocketbase": ">=0.21.0" + }, "dependencies": { "@karnak19/pbkit": "workspace:*" }, diff --git a/packages/pbkit-realtime/src/generate.ts b/packages/pbkit-realtime/src/generate.ts index 24a7e38..0c32e4f 100644 --- a/packages/pbkit-realtime/src/generate.ts +++ b/packages/pbkit-realtime/src/generate.ts @@ -13,7 +13,7 @@ function subscribeFunction(col: CollectionSchema): string[] { lines.push(`export async function ${fnName}(`); lines.push(` callback: (event: RealtimeEvent<${recordType}>) => void,`); lines.push(` options?: { filter?: string; id?: string },`); - lines.push(`): Promise<() => void> {`); + lines.push(`): Promise<() => Promise> {`); lines.push(` const target = options?.id ?? "*"`); lines.push(` return client.collection(${c}).subscribe(target, (e) => {`); lines.push(` callback({ action: e.action as RealtimeAction, record: e.record as ${recordType} })`); diff --git a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap index 81ac0f8..df042ba 100644 --- a/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap +++ b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap @@ -18,7 +18,7 @@ export interface RealtimeEvent { export async function subscribeToUsers( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): Promise<() => void> { +): Promise<() => Promise> { const target = options?.id ?? "*" return client.collection("users").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as UsersRecord }) @@ -30,7 +30,7 @@ export async function subscribeToUsers( export async function subscribeToCategories( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): Promise<() => void> { +): Promise<() => Promise> { const target = options?.id ?? "*" return client.collection("categories").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as CategoriesRecord }) @@ -42,7 +42,7 @@ export async function subscribeToCategories( export async function subscribeToArticles( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): Promise<() => void> { +): Promise<() => Promise> { const target = options?.id ?? "*" return client.collection("articles").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as ArticlesRecord }) @@ -54,7 +54,7 @@ export async function subscribeToArticles( export async function subscribeToComments( callback: (event: RealtimeEvent) => void, options?: { filter?: string; id?: string }, -): Promise<() => void> { +): Promise<() => Promise> { const target = options?.id ?? "*" return client.collection("comments").subscribe(target, (e) => { callback({ action: e.action as RealtimeAction, record: e.record as CommentsRecord }) diff --git a/packages/pbkit-realtime/src/test/realtime.test.ts b/packages/pbkit-realtime/src/test/realtime.test.ts index b23e1fd..59c163b 100644 --- a/packages/pbkit-realtime/src/test/realtime.test.ts +++ b/packages/pbkit-realtime/src/test/realtime.test.ts @@ -60,8 +60,8 @@ describe("generateRealtime", () => { expect(output).toContain("options?: { filter?: string; id?: string }"); }); - test("subscribe function returns Promise of unsubscribe function", () => { - expect(output).toContain("): Promise<() => void>"); + test("subscribe function returns Promise of async unsubscribe function", () => { + expect(output).toContain("): Promise<() => Promise>"); }); test("uses client.collection with correct name", () => { diff --git a/skills/pbkit/SKILL.md b/skills/pbkit/SKILL.md index 8b9a4e0..1eacb22 100644 --- a/skills/pbkit/SKILL.md +++ b/skills/pbkit/SKILL.md @@ -225,11 +225,11 @@ const unsub = await subscribeToArticles((event: RealtimeEvent) = } }, { filter: 'status = "published"' }) -// Later -unsub() +// Later (unsubscribe is async) +await unsub() ``` -Each `subscribeTo{Collection}()` function accepts a typed callback, optional `filter` (PocketBase filter string), optional `id` (subscribe to a specific record), and returns an unsubscribe function. The plugin respects `collections` config — excluded collections are skipped. +Each `subscribeTo{Collection}()` function accepts a typed callback, optional `filter` (PocketBase filter string), optional `id` (subscribe to a specific record), and returns `Promise<() => Promise>` — an async unsubscribe function. The plugin respects `collections` config — excluded collections are skipped. ## TanStack Query Integration From 5662ce59b528226920fc4443c9519a878edc025e Mon Sep 17 00:00:00 2001 From: Basile Vernouillet Date: Mon, 1 Jun 2026 14:50:01 +0200 Subject: [PATCH 8/8] chore: update bun.lock for pbkit-realtime pocketbase peerDependency Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 0b9eaae..a2b9a92 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/pbkit": { "name": "@karnak19/pbkit", - "version": "0.1.3", + "version": "0.2.0", "bin": { "pbkit": "./dist/cli/index.js", }, @@ -44,7 +44,7 @@ }, "packages/pbkit-realtime": { "name": "@karnak19/pbkit-realtime", - "version": "0.1.0", + "version": "0.0.0", "dependencies": { "@karnak19/pbkit": "workspace:*", }, @@ -52,10 +52,13 @@ "@types/bun": "^1.3.13", "typescript": "^6.0.0", }, + "peerDependencies": { + "pocketbase": ">=0.21.0", + }, }, "packages/pbkit-tanstack": { "name": "@karnak19/pbkit-tanstack", - "version": "0.1.3", + "version": "0.2.0", "dependencies": { "@karnak19/pbkit": "workspace:*", }, @@ -69,7 +72,7 @@ }, "packages/pbkit-zod": { "name": "@karnak19/pbkit-zod", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@karnak19/pbkit": "workspace:*", }, @@ -930,6 +933,8 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pocketbase": ["pocketbase@0.27.0", "", {}, "sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ=="], + "postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],