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/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..20c557c --- /dev/null +++ b/apps/docs/src/content/docs/how-to/realtime.md @@ -0,0 +1,177 @@ +--- +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<() => 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<() => Promise>` — resolves with an **async** unsubscribe callback (PocketBase's `UnsubscribeFunc`); `await` it to surface unsubscribe errors + +## Usage example + +### Subscribe to all changes + +```ts +import { subscribeToArticles } from "./generated/realtime.gen" + +const unsub = await 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 = await subscribeToArticles( + (event) => { + console.log("Published article changed:", event.record.title) + }, + { filter: 'status = "published"' }, +) +``` + +### Subscribe to a specific record + +```ts +const unsub = await 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(() => { + let unsub: (() => Promise) | undefined + + subscribeToComments( + (event) => { + if (event.action === "create") { + setComments((prev) => [...prev, event.record]) + } + }, + { filter: `article = "${articleId}"` }, + ).then((fn) => { unsub = fn }) + + return () => { unsub?.() } + }, [articleId]) + + return ( +
    + {comments.map((c) => ( +
  • {c.content}
  • + ))} +
+ ) +} +``` + +Avoid interpolating untrusted input directly into filter strings. Prefer constructing filters server-side or validating IDs before inserting them. + +## 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..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", }, @@ -42,9 +42,23 @@ "typescript": "^6.0.0", }, }, + "packages/pbkit-realtime": { + "name": "@karnak19/pbkit-realtime", + "version": "0.0.0", + "dependencies": { + "@karnak19/pbkit": "workspace:*", + }, + "devDependencies": { + "@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:*", }, @@ -58,7 +72,7 @@ }, "packages/pbkit-zod": { "name": "@karnak19/pbkit-zod", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@karnak19/pbkit": "workspace:*", }, @@ -261,6 +275,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"], @@ -917,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=="], diff --git a/packages/pbkit-realtime/README.md b/packages/pbkit-realtime/README.md new file mode 100644 index 0000000..e11020f --- /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 (unsubscribe is async — await to surface errors) +await 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/package.json b/packages/pbkit-realtime/package.json new file mode 100644 index 0000000..6590278 --- /dev/null +++ b/packages/pbkit-realtime/package.json @@ -0,0 +1,49 @@ +{ + "name": "@karnak19/pbkit-realtime", + "version": "0.0.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", + "peerDependencies": { + "pocketbase": ">=0.21.0" + }, + "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..0c32e4f --- /dev/null +++ b/packages/pbkit-realtime/src/generate.ts @@ -0,0 +1,68 @@ +import type { SchemaIR, CollectionSchema } from "@karnak19/pbkit"; +import type { PbkitPlugin, PluginContext, PluginOutputFile } from "@karnak19/pbkit"; +import { isCollectionExcluded, pascalCase } from "@karnak19/pbkit"; + +function subscribeFunction(col: CollectionSchema): string[] { + const p = pascalCase(col.name); + const c = JSON.stringify(col.name); + const fnName = `subscribeTo${p}`; + const recordType = `${p}Record`; + + const lines: string[] = []; + + lines.push(`export async function ${fnName}(`); + lines.push(` callback: (event: RealtimeEvent<${recordType}>) => void,`); + lines.push(` options?: { filter?: string; id?: string },`); + 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} })`); + lines.push(` }, { filter: options?.filter })`); + 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`).sort(); + + parts.push("// Generated by pbkit-realtime — do not edit"); + parts.push(""); + parts.push(`import { client } from "./client.gen"`); + 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(""); + 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..df042ba --- /dev/null +++ b/packages/pbkit-realtime/src/test/__snapshots__/realtime.test.ts.snap @@ -0,0 +1,64 @@ +// 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 { ArticlesRecord, CategoriesRecord, CommentsRecord, UsersRecord } from "./types.gen" + +export type RealtimeAction = "create" | "update" | "delete" + +export interface RealtimeEvent { + action: RealtimeAction + record: T +} + +// --- Users --- + +export async function subscribeToUsers( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): Promise<() => Promise> { + const target = options?.id ?? "*" + return client.collection("users").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as UsersRecord }) + }, { filter: options?.filter }) +} + +// --- Categories --- + +export async function subscribeToCategories( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): Promise<() => Promise> { + const target = options?.id ?? "*" + return client.collection("categories").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as CategoriesRecord }) + }, { filter: options?.filter }) +} + +// --- Articles --- + +export async function subscribeToArticles( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): Promise<() => Promise> { + const target = options?.id ?? "*" + return client.collection("articles").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as ArticlesRecord }) + }, { filter: options?.filter }) +} + +// --- Comments --- + +export async function subscribeToComments( + callback: (event: RealtimeEvent) => void, + options?: { filter?: string; id?: string }, +): Promise<() => Promise> { + const target = options?.id ?? "*" + return client.collection("comments").subscribe(target, (e) => { + callback({ action: e.action as RealtimeAction, record: e.record as CommentsRecord }) + }, { filter: options?.filter }) +} +" +`; 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..59c163b --- /dev/null +++ b/packages/pbkit-realtime/src/test/realtime.test.ts @@ -0,0 +1,144 @@ +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"; + +const ir = parseJson(fullSchema); +const ctx = { + ir, + typesImport: "./types.gen", + sdkImport: "./sdk.gen", +} satisfies PluginContext; + +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 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", () => { + 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 Promise of async unsubscribe function", () => { + expect(output).toContain("): Promise<() => 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"); + }); +}); + +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")'); + }); +}); 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/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(""); +} diff --git a/skills/pbkit/SKILL.md b/skills/pbkit/SKILL.md index ff482de..1eacb22 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,14 @@ 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 -d @karnak19/pbkit-realtime +``` + +The plugin is build-time (devDependency); the generated helpers use the runtime `pocketbase` client. + For TanStack Query generation: ```bash @@ -190,6 +199,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 = await subscribeToArticles((event: RealtimeEvent) => { + if (event.action === "create") { + console.log("New article:", event.record.title) + } +}, { filter: 'status = "published"' }) + +// 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 `Promise<() => Promise>` — an async unsubscribe function. The plugin respects `collections` config — excluded collections are skipped. + ## TanStack Query Integration Add the plugin to `pbkit.config.ts`: @@ -267,16 +308,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 +362,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.