{
  "name": "realtime-scheduled-app",
  "title": "Recipe: Realtime Collaborative App with a Scheduler",
  "description": "End-to-end recipe: live presence + messages, a function that persists to the DB, and a scheduled workflow that posts on a timer",
  "guid": "sk_plat_rtsa",
  "category": "App development",
  "requiredTools": [],
  "content": "# Recipe: Realtime Collaborative App with a Scheduler\n\nA common shape: many users see each other live, everyone's posts persist, and something posts on a timer (an AI commenter, a digest, a heartbeat). This wires the four pieces end-to-end so you don't have to assemble them from four separate skills; each links to its full reference.\n\nThe pieces:\n1. **Realtime** for live presence + broadcasting new posts ([app-realtime](app-realtime.md)).\n2. **A function + database** so posts persist and survive reconnects ([app-development](app-development.md)).\n3. **A scheduled workflow** that calls a function on a cron to post automatically ([workflow](workflow.md)).\n4. **Deploy** that provisions all of it from `gipity.yaml` ([deploy](deploy.md)).\n\n## 1. Realtime: presence + a messages channel\n\nBuild on the `@gipity/realtime` kit (`gipity add realtime`) - don't hand-roll Colyseus. Use a `presence` channel for who's-here and a `messages` channel to broadcast new posts to everyone live:\n\n```js\nimport { createRealtime } from '@gipity/realtime';\n\nconst rt = await createRealtime({ room: 'main' });\nconst here = rt.channel('presence', { sync: 'presence' });\nhere.set({ name: myName });                 // announce myself\nhere.onChange(peers => renderPresence(peers));\n\nconst feed = rt.channel('posts', { sync: 'messages' });\nfeed.onMessage(post => addPostToDom(post)); // live posts from anyone (incl. the scheduler)\n```\n\nRealtime is ephemeral - it makes posts *appear live* but does not store them. That's the database's job (next).\n\n## 2. Persist posts with a function + DB\n\nA `public` function writes the post and returns it; the client broadcasts it on the messages channel after a successful write. See [app-development](app-development.md) for the function/DB API.\n\n```js\n// functions/add-post.js\nexport default async function (ctx, { db }) {\n  const { author, body } = ctx.body;\n  if (!body) return { error: 'body required' };\n  const r = await db.query(\n    'INSERT INTO posts (author, body) VALUES ($1, $2) RETURNING id, author, body, created_at',\n    [author ?? 'anon', body],\n  );\n  return { post: r.rows[0] };\n}\n```\n\n```js\n// client: persist, then broadcast live\nconst { post } = await Gipity.fn('add-post', { author: myName, body: text });\nfeed.send(post);\n```\n\n## 3. A scheduled poster (the part with the trap)\n\nTo post automatically on a timer - an AI commenter, say - write a function that does the work, then drive it from a **scheduled workflow with a `function` step**. The scheduled function step gets the **full project context, including the database**, so `db.query(...)` works exactly as it does over HTTP:\n\n```js\n// functions/post-ai-comment.js  - reads recent posts, writes a new one\nexport default async function (ctx, { db }) {\n  const recent = await db.query('SELECT body FROM posts ORDER BY id DESC LIMIT 5', []);\n  const body = await generateComment(recent.rows); // your LLM call, etc.\n  const r = await db.query(\n    'INSERT INTO posts (author, body) VALUES ($1, $2) RETURNING id, author, body, created_at',\n    ['ai', body],\n  );\n  return { post: r.rows[0] };\n}\n```\n\n```yaml\n# workflows/post-ai-comment.yaml\nname: ai-poster            # unique within the project (enforced)\ntrigger: schedule          # `cron` is an accepted alias\ncron: \"* * * * *\"          # every minute\nsteps:\n  - name: post\n    step_type: function\n    config: { name: post-ai-comment }\n```\n\n**Use a `function` step, not a `webhook` hop.** A scheduled `step_type: function` runs your function with `db`/`secrets`/`fetch` injected - the same services it gets over HTTP. (Earlier this wasn't true and people routed through a webhook to reach the DB; that workaround is no longer needed.)\n\nTo make the scheduled post show up live for connected users, broadcast it too - either have the client re-fetch, or add a tiny relay so the function's new row reaches the `posts` channel.\n\n## 4. Deploy it all\n\nOne `gipity deploy` provisions the realtime room (declared by the kit), runs DB migrations, deploys the functions, and arms the workflow schedule. Declare the workflow phase in `gipity.yaml` (see [deploy](deploy.md) and [workflow](workflow.md)):\n\n```yaml\n# gipity.yaml\nversion: 1\ndeploy:\n  phases:\n    - name: database\n      type: database\n      source: migrations\n    - name: functions\n      type: functions\n      source: functions\n    - name: workflows\n      type: workflows\n      source: workflows\n```\n\n## Verify\n\n1. `gipity deploy` - all phases green.\n2. `gipity page inspect <dev-url>` - page renders, console clean (see [app-debugging](app-debugging.md)).\n3. Open two browsers - presence shows both; a post in one appears in the other.\n4. `gipity workflow runs ai-poster` - shows `completed` (not \"No database configured\"); a new `ai` post lands every minute.\n\n## Related skills\n\n- [app-realtime](app-realtime.md) - rooms, channels, presence, the realtime kit\n- [app-development](app-development.md) - functions, the `db` service, gipity.yaml function permissions\n- [workflow](workflow.md) - triggers, scheduled function steps, YAML shape\n- [deploy](deploy.md) - the deploy pipeline and gipity.yaml phases\n- [web-app-basics](web-app-basics.md) - frontend structure and patterns\n"
}
