@dastyaresocial/ds-cs 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +134 -2
  2. package/bin/ds-cs.mjs +14 -0
  3. package/package.json +33 -4
  4. package/skills/README.md +71 -0
  5. package/skills/dev/add-component/SKILL.md +166 -0
  6. package/skills/dev/add-orpc-endpoint/SKILL.md +143 -0
  7. package/skills/dev/db-migration/SKILL.md +104 -0
  8. package/skills/dev/release/SKILL.md +106 -0
  9. package/skills/product/brand-config/SKILL.md +98 -0
  10. package/skills/product/manage-stories/SKILL.md +119 -0
  11. package/skills/product/mcp-connect/SKILL.md +116 -0
  12. package/skills/product/post-with-media/SKILL.md +197 -0
  13. package/skills/product/self-host-deploy/SKILL.md +112 -0
  14. package/skills/product/webhooks/SKILL.md +160 -0
  15. package/src/agents.mjs +130 -0
  16. package/src/args.mjs +111 -0
  17. package/src/auth.mjs +82 -0
  18. package/src/commands/add.mjs +118 -0
  19. package/src/commands/auth.mjs +87 -0
  20. package/src/commands/init.mjs +106 -0
  21. package/src/commands/list.mjs +89 -0
  22. package/src/commands/mcp.mjs +101 -0
  23. package/src/commands/remove.mjs +80 -0
  24. package/src/commands/search.mjs +51 -0
  25. package/src/commands/target.mjs +83 -0
  26. package/src/commands/update.mjs +94 -0
  27. package/src/index.mjs +176 -0
  28. package/src/install.mjs +174 -0
  29. package/src/lockfile.mjs +74 -0
  30. package/src/mcp-config.mjs +39 -0
  31. package/src/registry/archive.mjs +209 -0
  32. package/src/registry/fetch.mjs +114 -0
  33. package/src/registry/sources.mjs +395 -0
  34. package/src/skills/discover.mjs +170 -0
  35. package/src/skills/frontmatter.mjs +85 -0
  36. package/src/utils/errors.mjs +13 -0
  37. package/src/utils/fs.mjs +106 -0
  38. package/src/utils/log.mjs +85 -0
  39. package/src/utils/prompt.mjs +69 -0
package/README.md CHANGED
@@ -1,3 +1,135 @@
1
- # Temporary Holding Version
1
+ # ds-cs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The CLI for [Dastyare Social — CS](https://cs.dastyare.social). Installs agent skills, wires up MCP, and authenticates to a channel.
4
+
5
+ ```bash
6
+ npx ds-cs init
7
+ npx ds-cs add post-with-media
8
+ ```
9
+
10
+ ## Why
11
+
12
+ DS-CS already speaks two agent protocols — a REST API and an MCP server — but neither tells an agent *how* to use them. The constraints that matter (upload before you post; `kind=` filters stories while `type=` counts them; the oRPC URL must be absolute) are scattered across a README an agent will not read.
13
+
14
+ Skills are that knowledge, in the form agents actually load. This CLI installs them.
15
+
16
+ ## Commands
17
+
18
+ | Command | What it does |
19
+ |---|---|
20
+ | `ds-cs init` | Create skills directories, optionally write MCP config |
21
+ | `ds-cs add <skill\|source>` | Install skills from DS-CS or anywhere else |
22
+ | `ds-cs list` | Show what is installed and where it came from |
23
+ | `ds-cs update [skill…]` | Re-fetch skills that changed upstream |
24
+ | `ds-cs remove <skill…>` | Uninstall from every agent |
25
+ | `ds-cs search [query]` | Search installed skills |
26
+ | `ds-cs mcp [agent]` | Print, or write, MCP server config |
27
+ | `ds-cs login` | Store a channel URL and API key |
28
+ | `ds-cs whoami` | Show the stored channel |
29
+ | `ds-cs logout` | Remove stored credentials |
30
+
31
+ ## Sources
32
+
33
+ ```bash
34
+ ds-cs add brand-config # from the DS-CS registry (the default)
35
+ ds-cs add owner/repo # any GitHub skills repo
36
+ ds-cs add owner/repo@skill # one skill from that repo
37
+ ds-cs add owner/repo@a,b # several
38
+ ds-cs add https://github.com/o/r/tree/main/skills/x
39
+ ds-cs add ./my-skills # a local directory
40
+ ds-cs add https://…/SKILL.md # a direct URL
41
+ ds-cs add https://…/archive.tar.gz # a zip or tarball of skills
42
+ ds-cs add git@github.com:o/r.git # any other git host
43
+ ds-cs add owner/repo --list # browse without installing
44
+ ```
45
+
46
+ A bare word is a skill name from the DS-CS registry, because knowing where a skill lives should not be the user's problem. Anything source-shaped — `owner/repo`, a URL, a path — is treated as a source.
47
+
48
+ GitHub repositories are fetched as tarballs rather than cloned: one HTTP request, no `git` binary required, and no repository hooks executed. Only non-GitHub git hosts fall back to `git clone`.
49
+
50
+ ## Where skills land
51
+
52
+ The canonical copy always goes to `.agents/skills/<name>` — the directory OpenCode, Cursor, Codex and a dozen others already read. Every other target is symlinked to it, so an update lands everywhere at once and there is one source of truth per project.
53
+
54
+ ```bash
55
+ ds-cs add brand-config -a claude-code # also link into .claude/skills
56
+ ds-cs add brand-config -a ./my-agent/skills # or any directory you name
57
+ ds-cs add brand-config --copy # copy instead of symlink
58
+ ```
59
+
60
+ Symlinks are the default because agents that disagree about skill content is a worse problem than agents that disagree about inode counts. `--copy` is there for filesystems where symlinks do not work; the CLI falls back to copying automatically if a symlink fails.
61
+
62
+ Run `ds-cs init --list-agents` for the full table.
63
+
64
+ ## Lockfile
65
+
66
+ Installs are recorded in `skills-lock.json`, in the same v1 format `npx skills` (vercel-labs) reads and writes. The two tools interoperate in one project without clobbering each other:
67
+
68
+ ```bash
69
+ ds-cs add brand-config # DS-CS skills
70
+ npx skills add emilkowalski/skill # third-party skills
71
+ ds-cs list # reports both
72
+ ```
73
+
74
+ Each entry records the source, the skill's path within it, and a content hash. `ds-cs update` compares against that hash and **refuses to overwrite a skill you have edited locally** — an agent's tweak to a skill is usually deliberate. `--force` overrides.
75
+
76
+ ## MCP
77
+
78
+ ```bash
79
+ ds-cs login --url https://cs.example.com --api-key "$API_KEY"
80
+ ds-cs mcp # print every client format
81
+ ds-cs mcp claude-code # write .mcp.json
82
+ ds-cs mcp opencode # write opencode.json
83
+ ```
84
+
85
+ An existing config file is never overwritten. The snippet is printed for you to merge — clobbering a working MCP config to add one server is a bad trade.
86
+
87
+ Remote (HTTP) works everywhere and is what most people want. Local (stdio, `bun run mcp`) is faster and works offline but needs a clone with `DATABASE_URL` set.
88
+
89
+ ## Credentials
90
+
91
+ `ds-cs login` verifies the channel before saving, so a mistyped key surfaces at login rather than mid-task inside an agent. Credentials land in `~/.config/ds-cs/auth.json` at mode `0600` — outside the project, because a key that can delete content is one `git add -A` away from being published.
92
+
93
+ ```bash
94
+ ds-cs whoami # shows a redacted key
95
+ ds-cs logout
96
+ ```
97
+
98
+ `DS_CS_URL` and `DS_CS_API_KEY` override the stored values. `DS_CS_CONFIG_DIR` relocates the file.
99
+
100
+ ## Where the registry comes from
101
+
102
+ `ds-cs add` resolves the DS-CS registry in this order:
103
+
104
+ 1. **A local checkout** — a DS-CS repo found by walking up from the current directory. Edit a skill and reinstall it and you get your edit, not whatever `main` says.
105
+ 2. **The copy bundled in this package** — `prepack` copies `skills/` into the published package. This is the normal path for an installed CLI, and it works with no network at all. It also guarantees the registry matches the CLI version you have.
106
+ 3. **GitHub** — a fallback for a CLI older than the skills it wants. Only the `skills/` subtree is fetched; the full repo tarball is ~28MB, almost all of it `public/` and generated assets, and reading it to get ten markdown files is not a trade worth making.
107
+
108
+ The repo's `skills/` is the source of truth. The copy inside this package is a build artefact, regenerated on `prepack` and gitignored.
109
+
110
+ ## Publishing a skill to the registry
111
+
112
+ DS-CS skills live in [`skills/`](../../skills) in this repository. A skill is a directory with a `SKILL.md` whose frontmatter carries a `name` and a `description`:
113
+
114
+ ```
115
+ skills/
116
+ dev/add-orpc-endpoint/SKILL.md
117
+ product/post-with-media/SKILL.md
118
+ ```
119
+
120
+ The description is the whole interface — it is what an agent reads to decide whether to load the skill. Write it as "use when X", and name the sibling skills it should *not* be used for. Reference files, scripts and templates sit next to `SKILL.md` in the same directory and travel with it.
121
+
122
+ Set `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTERNAL_SKILLS=1`.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ npm test # node:test, no dependencies
128
+ npm run dev -- --help
129
+ ```
130
+
131
+ While working inside a DS-CS checkout, `ds-cs add` prefers the local `skills/` directory over the published registry, so edits reinstall immediately. Pass a source path explicitly to bypass that.
132
+
133
+ ## Requirements
134
+
135
+ Node 18.17+. Zero runtime dependencies.
package/bin/ds-cs.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/index.mjs";
3
+
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ // Anything reaching here is a bug in the CLI, not a user error. Report it in
6
+ // a shape a maintainer can act on, then exit non-zero so scripts can branch.
7
+ if (process.env.DS_CS_DEBUG) {
8
+ console.error(error);
9
+ } else {
10
+ console.error(`\nds-cs: ${error?.message ?? error}`);
11
+ if (error?.hint) console.error(`hint: ${error.hint}`);
12
+ }
13
+ process.exit(1);
14
+ });
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@dastyaresocial/ds-cs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "CLI for Dastyare Social — CS. Install DS-CS skills and third-party agent skills, wire up MCP, and authenticate to a channel.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ds-cs": "bin/ds-cs.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "skills",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test test/*.test.mjs",
21
+ "dev": "node ./bin/ds-cs.mjs",
22
+ "sync-skills": "node ./scripts/sync-skills.mjs",
23
+ "prepack": "node ./scripts/sync-skills.mjs"
24
+ },
25
+ "keywords": [
26
+ "ds-cs",
27
+ "dastyare-social",
28
+ "agent-skills",
29
+ "skills",
30
+ "mcp",
31
+ "cli",
32
+ "ai"
33
+ ],
34
+ "dependencies": {}
35
+ }
@@ -0,0 +1,71 @@
1
+ # DS-CS agent skills
2
+
3
+ First-party skills for [Dastyare Social — CS](https://cs.dastyare.social). Install them with:
4
+
5
+ ```bash
6
+ npx ds-cs init
7
+ npx ds-cs add # browse everything
8
+ npx ds-cs add post-with-media # or install one by name
9
+ ```
10
+
11
+ ## What is here
12
+
13
+ ### Working on the codebase — `dev/`
14
+
15
+ For agents and humans changing DS-CS itself.
16
+
17
+ | Skill | Covers |
18
+ |---|---|
19
+ | [`dev/add-orpc-endpoint`](dev/add-orpc-endpoint) | Adding a procedure to the internal oRPC router |
20
+ | [`dev/add-component`](dev/add-component) | Components without breaking the design system |
21
+ | [`dev/db-migration`](dev/db-migration) | Drizzle schema changes and migrations |
22
+ | [`dev/release`](dev/release) | Version bump, annotated tag, push |
23
+
24
+ ### Operating a channel — `product/`
25
+
26
+ For agents acting *on* a live channel.
27
+
28
+ | Skill | Covers |
29
+ |---|---|
30
+ | [`product/post-with-media`](product/post-with-media) | Posts, media upload, pinning |
31
+ | [`product/manage-stories`](product/manage-stories) | Ephemeral stories |
32
+ | [`product/webhooks`](product/webhooks) | Signed event delivery |
33
+ | [`product/brand-config`](product/brand-config) | Channel identity and the `/about` page |
34
+ | [`product/self-host-deploy`](product/self-host-deploy) | Install, update, back up |
35
+ | [`product/mcp-connect`](product/mcp-connect) | Connecting an agent over MCP |
36
+
37
+ ## Writing one
38
+
39
+ A skill is a directory containing `SKILL.md`:
40
+
41
+ ```markdown
42
+ ---
43
+ name: my-skill
44
+ description: What it does and, more importantly, when to reach for it.
45
+ ---
46
+
47
+ # My Skill
48
+
49
+ Instructions.
50
+ ```
51
+
52
+ The `description` is the entire interface. It is the only thing an agent sees before deciding whether to load the skill, so it has to answer "is this my problem?" in one line. Write it as *use when…*, and name the neighbouring skills that should be used instead — an agent that loads the wrong skill is worse than one that loads none.
53
+
54
+ Keep `SKILL.md` to instructions. Reference material, scripts and templates go in the same directory under their own names; they travel with the skill and are loaded only when the instructions point at them.
55
+
56
+ Add `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTERNAL_SKILLS=1`.
57
+
58
+ ## Testing a change
59
+
60
+ While inside a DS-CS checkout, `ds-cs add` installs from this directory rather than from GitHub, so edits take effect immediately:
61
+
62
+ ```bash
63
+ npx ds-cs add brand-config --force
64
+ ```
65
+
66
+ Run it from a scratch directory to test what a user actually gets:
67
+
68
+ ```bash
69
+ mkdir /tmp/t && cd /tmp/t && git init -q
70
+ node /path/to/ds-cs/scripts/cli/bin/ds-cs.mjs add brand-config
71
+ ```
@@ -0,0 +1,166 @@
1
+ ---
2
+ name: add-component
3
+ description: Add or change a React component in DS-CS without breaking the design system — token architecture, the no-shadow and 60% corner-smoothing global rules, liquid-glass usage, cva variants, and the "use client" boundary. Use when adding a UI component, restyling one, or reviewing a component for design-rule violations. Read docs/design-system.md before deviating from these rules.
4
+ ---
5
+
6
+ # Adding a component
7
+
8
+ ## Read the two global rules first
9
+
10
+ These are enforced for the **whole codebase**, not per component. Violating them is the most common way a change gets rejected.
11
+
12
+ **1. No shadows, anywhere.** No `box-shadow`, no `text-shadow`, no `drop-shadow` filter. Surfaces sit flush with the page and never lift off it. `src/styles/globals.css` declares an *unlayered* reset so a `shadow-*` utility cannot override it — which means a stray `shadow-lg` in a `className` compiles, type-checks, and renders nothing. Remove those classes for clarity even though they are inert; they misdescribe intent.
13
+
14
+ The single exception is `.liquid-glass`, and only as an **inset** stroke. Inset shadows fade inward and cannot cast outside the element, so the no-depth rule still holds.
15
+
16
+ **2. Rounded content surfaces use 60% corner smoothing.** `border-radius` controls corner *size* (`rounded-xl`); `corner-shape` controls corner *shape* (the squircle). `superellipse()` takes an **exponent, not a percentage**: `2` is a plain circular arc, `2.5` is the continuous iOS/Figma corner, and anything **below `2` cuts the corner inward** — the opposite of smoothing. The app uses `2.5` everywhere via `--corner-smoothing`. It is declared once against every element except `.rounded-full` (true circles stay circles — smoothing pushes them to a square, squaring off every avatar, pill and round button) and `.w-full` (full-bleed bars — install/update banners, the pinned bar — keep circular corners). Never restate `superellipse()` in a component; override `--corner-smoothing` and say why.
17
+
18
+ ## Tokens
19
+
20
+ Two layers, and the split is the point:
21
+
22
+ - Raw values live in `:root` in `src/styles/globals.css`.
23
+ - `@theme inline` re-exports them under Tailwind's colour namespace so utilities resolve to the live value instead of a build-time copy.
24
+
25
+ **Add to `:root`.** Add an `@theme inline` mapping only when a Tailwind utility should reach it.
26
+
27
+ ```css
28
+ :root {
29
+ --button-background: color-mix(in srgb, var(--primary) 10%, transparent);
30
+ }
31
+
32
+ @theme inline {
33
+ --color-button-background: var(--button-background);
34
+ }
35
+ ```
36
+
37
+ Never hardcode a raw colour (`#fff`, `oklch(0.65 0.19 41)`) in a component. Severity and hierarchy come from blur, translucency, wash gradients, rim borders and contrast — never from elevation.
38
+
39
+ ## The shape of a component
40
+
41
+ `src/components/button.tsx` is the reference implementation:
42
+
43
+ ```tsx
44
+ import { Slot } from "@radix-ui/react-slot";
45
+ import { cva, type VariantProps } from "class-variance-authority";
46
+ import type * as React from "react";
47
+
48
+ import { cn } from "@/lib/utils";
49
+
50
+ const buttonVariants = cva(
51
+ "inline-flex items-center justify-center rounded-full liquid-glass transition-all disabled:pointer-events-none disabled:opacity-60 outline-none cursor-pointer select-none text-xl md:text-2xl px-5 py-2",
52
+ {
53
+ variants: {
54
+ variant: {
55
+ primary:
56
+ "[--liquid-glass-bg:var(--button-background)] [--liquid-glass-border-color:var(--button-border)] text-button-foreground hover:opacity-60",
57
+ secondary:
58
+ "[--liquid-glass-bg:color-mix(in_srgb,var(--button-background)_20%,transparent)] [--liquid-glass-border-width:2px] [--liquid-glass-border-color:color-mix(in_srgb,var(--button-border)_50%,transparent)] text-button-foreground hover:opacity-60",
59
+ },
60
+ },
61
+ defaultVariants: { variant: "primary" },
62
+ }
63
+ );
64
+
65
+ function Button({
66
+ className,
67
+ variant = "primary",
68
+ asChild = false,
69
+ ...props
70
+ }: React.ComponentProps<"button"> &
71
+ VariantProps<typeof buttonVariants> & {
72
+ asChild?: boolean;
73
+ }) {
74
+ const Comp = asChild ? Slot : "button";
75
+
76
+ return (
77
+ <Comp
78
+ data-slot="button"
79
+ data-variant={variant}
80
+ className={cn(buttonVariants({ variant, className }))}
81
+ {...props}
82
+ />
83
+ );
84
+ }
85
+
86
+ export { Button, buttonVariants };
87
+ ```
88
+
89
+ The conventions in it:
90
+
91
+ - Variants live in `cva`, not in `className` strings scattered across call sites.
92
+ - Glass is tinted by **overriding tokens** (`[--liquid-glass-bg:...]`), never by restating the effect.
93
+ - `data-slot` and `data-variant` are set so tests and Storybook can target elements without relying on classes.
94
+ - `cn()` from `@/lib/utils` is `twMerge(clsx(...))` — always use it for class composition.
95
+ - `asChild` via Radix `Slot` lets a component render as its child (`<Button asChild><Link/></Button>`). Preserve it when the element is interactive or navigable.
96
+
97
+ ## The client boundary
98
+
99
+ Components that fetch call the **action layer**, never oRPC directly:
100
+
101
+ ```tsx
102
+ "use client";
103
+
104
+ import { getPosts } from "@/lib/actions/posts";
105
+ ```
106
+
107
+ Server-side callers — the OG image route, for instance — import `@/lib/api/*` directly and never touch the RPC client. A relative `/api/rpc` URL has no meaning on the server.
108
+
109
+ ## Liquid glass
110
+
111
+ `.liquid-glass` is the reusable translucent material for buttons, cards, panels and any surface that should read as glass. It is made of exactly four things, in order: `backdrop-filter: blur() saturate()`, a vertical wash gradient, a translucent background, and a rim from border width + colour.
112
+
113
+ ```tsx
114
+ <div className="liquid-glass rounded-2xl px-6 py-4">…</div>
115
+ <div className="liquid-glass [--liquid-glass-bg:color-mix(in_srgb,var(--primary)_22%,transparent)]" />
116
+ ```
117
+
118
+ The stroke and the border are separate and complementary. `--liquid-glass-border-color` sets edge weight to match the install/update banners; the inset stroke adds the luminous rim. Do not merge them.
119
+
120
+ Glass is invisible on a flat white background. Review it over a busy backdrop using the `Design System/Liquid Glass` Storybook story, not an isolated white canvas.
121
+
122
+ ## Tests and stories
123
+
124
+ Component tests live in `src/components/__tests__/<name>.test.tsx` and use `bun:test`, `@testing-library/react`, and a `happy-dom` window set up per test:
125
+
126
+ ```tsx
127
+ import { describe, it, expect, beforeEach } from "bun:test";
128
+ import { render } from "@testing-library/react";
129
+ import { Button } from "../button";
130
+ import React from "react";
131
+ import "@testing-library/jest-dom/vitest";
132
+ import { Window } from "happy-dom";
133
+
134
+ beforeEach(() => {
135
+ const window = new Window();
136
+ globalThis.window = window as any;
137
+ globalThis.document = window.document as any;
138
+ globalThis.navigator = window.navigator as any;
139
+ document.body.innerHTML = "";
140
+ });
141
+
142
+ describe("Button Component", () => {
143
+ it("renders with primary variant by default", () => {
144
+ const { getByText } = render(<Button>Click me</Button>);
145
+ expect(getByText("Click me")).toHaveAttribute("data-variant", "primary");
146
+ });
147
+ });
148
+ ```
149
+
150
+ `test-setup.ts` mocks `next/font/local` globally so components can use local fonts under test.
151
+
152
+ Stories go in `src/components/<name>.stories.tsx` under the `Design System/*` namespace.
153
+
154
+ ## Accessibility
155
+
156
+ Radix primitives are already a dependency — use them instead of hand-rolling behaviour. Anything that opens a surface is a dialog or a context menu, not a `div` with `onClick`. Every interactive element needs a visible focus state and an accessible name.
157
+
158
+ ## Verify
159
+
160
+ ```bash
161
+ bun run test
162
+ bun run lint
163
+ bun run storybook # then look at your component against the design-system stories
164
+ ```
165
+
166
+ For animations, read the `animate` skill — duration, curve and interruptibility are decisions, not defaults.
@@ -0,0 +1,143 @@
1
+ ---
2
+ name: add-orpc-endpoint
3
+ description: Add an internal oRPC procedure to the DS-CS router — the typed channel between the React client and the server. Use when adding a posts/stories procedure, exposing new server logic to the UI, or when an oRPC call 404s at runtime. For external/public endpoints use the REST layer under src/app/api instead — this skill is internal-only.
4
+ ---
5
+
6
+ # Adding an oRPC procedure
7
+
8
+ ## What this layer is
9
+
10
+ oRPC is the **internal, typed RPC channel between the React client and the server** for posts and stories. It is not a public API — external integrations and AI agents use REST under `src/app/api`. Do not add external-facing endpoints here.
11
+
12
+ The transport path, end to end:
13
+
14
+ ```
15
+ "use client" component
16
+ │ import { getPosts } from "@/lib/actions/posts"
17
+ ▼
18
+ src/lib/actions/posts.ts thin 1:1 wrapper, owns analytics calls
19
+ │ orpc.posts.list(input)
20
+ ▼
21
+ RPCLink → POST /api/rpc/posts/list
22
+ ▼
23
+ src/app/api/rpc/[...path]/route.ts RPCHandler
24
+ ▼
25
+ src/lib/orpc/router.ts validates input, calls business logic
26
+ ▼
27
+ src/lib/api/posts/*.ts the actual work
28
+ ```
29
+
30
+ ## The five rules
31
+
32
+ 1. **Business logic goes in `src/lib/api/*`.** Never inline a database query in the router. The router validates and delegates.
33
+ 2. **Components import from `@/lib/actions/*`, never `@/lib/orpc/client`.** Transport concerns in a component make the client impossible to swap later.
34
+ 3. **One procedure, one action wrapper, 1:1.** No action wraps two procedures; no procedure has two actions.
35
+ 4. **Declare an input schema only if the procedure takes arguments.** `os.handler(...)` with no `.input()` for zero-arg procedures.
36
+ 5. **Add a test** to `src/lib/orpc/router.test.ts`.
37
+
38
+ ## Step 1 — the business logic
39
+
40
+ Add to the matching module, `src/lib/api/posts/index.ts` or `src/lib/api/stories/index.ts`:
41
+
42
+ ```ts
43
+ export async function getPostStats(id: string) {
44
+ const rows = await db.select().from(posts).where(eq(posts.id, id));
45
+ if (rows.length === 0) {
46
+ throw new Error("Post not found");
47
+ }
48
+ return { views: rows[0].views, media: rows[0].media };
49
+ }
50
+ ```
51
+
52
+ Throw a plain `Error` for the not-found case. Procedures throw plain `Error` throughout this codebase; oRPC masks the message as `INTERNAL_SERVER_ERROR` for the client, and no UI reads the text. If you need a machine-readable code, declare errors with `os.errors({ ... })` instead of relying on message text.
53
+
54
+ ## Step 2 — the procedure
55
+
56
+ Add to `postsRouter` or `storiesRouter` in `src/lib/orpc/router.ts`. Import the business function at the top of the file alongside its siblings.
57
+
58
+ With input:
59
+
60
+ ```ts
61
+ getStats: os.input(postIdInput).handler(async ({ input }) => {
62
+ return getPostStats(input.id);
63
+ }),
64
+ ```
65
+
66
+ Reuse an existing input schema when one fits — `postIdInput` is `z.object({ id: z.string().min(1) })`. Define a new one near the others only when the shape is genuinely different:
67
+
68
+ ```ts
69
+ const postStatsInput = z.object({
70
+ id: z.string().min(1),
71
+ includeDrafts: z.boolean().optional(),
72
+ });
73
+ ```
74
+
75
+ Without input:
76
+
77
+ ```ts
78
+ count: os.handler(async () => ({ total: await countPosts() })),
79
+ ```
80
+
81
+ Input schemas are plain Zod v4 objects passed straight to `os.input()`. Zod 4 implements Standard Schema v1 natively, so there is no adapter package to import.
82
+
83
+ **Output schemas are intentionally not declared.** Adding one is a behaviour change — oRPC will validate and strip on output. Do not add one as a drive-by.
84
+
85
+ ## Step 3 — the action wrapper
86
+
87
+ Add to `src/lib/actions/posts.ts` (or `stories.ts`). Thin, 1:1, with the same defaulting the sibling wrappers do:
88
+
89
+ ```ts
90
+ export async function getPostStats(id: string) {
91
+ const result = await orpc.posts.getStats({ id });
92
+ void captureClientEvent("post_stats_viewed", { post_id: id });
93
+ return result;
94
+ }
95
+ ```
96
+
97
+ Preserve the `captureClientEvent` call where sibling procedures in that file have one. Analytics calls live in the action layer, not the router — that is the whole reason the layer exists.
98
+
99
+ ## Step 4 — the test
100
+
101
+ `src/lib/orpc/router.test.ts` mounts the real router behind a real `RPCHandler` and drives it through a real `RPCLink`, mocking only the business-logic layer. Add a case alongside the existing ones:
102
+
103
+ ```ts
104
+ it("returns stats for a post", async () => {
105
+ const client = makeTestClient();
106
+ const result = await client.posts.getStats({ id: "post-1" });
107
+ expect(result.views).toBe("42");
108
+ });
109
+
110
+ it("rejects an empty post id", async () => {
111
+ const client = makeTestClient();
112
+ await expect(client.posts.getStats({ id: "" })).rejects.toThrow();
113
+ });
114
+ ```
115
+
116
+ Run `bun test` (never `bun test --isolate` alone — AGENTS.md requires the isolate flag via `bun run test`).
117
+
118
+ ## Two things that will bite you
119
+
120
+ ### The RPC URL must be absolute
121
+
122
+ oRPC's `StandardRPCLink` does `new URL(baseUrl)`, which throws on a relative path. `resolveRpcUrl()` in `src/lib/orpc/client.ts` converts `/api/rpc` to an absolute URL using `window.location.origin`. It is a **function**, not a string constant, because `RPCLink.url` only invokes it when a request is actually issued — which is what makes touching `window` safe during SSR. Do not "simplify" it to a constant; the failure appears only at runtime, while type-checking passes.
123
+
124
+ ### The catch-all route segment is required
125
+
126
+ The client addresses procedures as `/api/rpc/posts/list`. A route at `src/app/api/rpc/route.ts` matches only the bare `/api/rpc`, so every real call 404s into the not-found page. The `[...path]` segment is what makes `/api/rpc/...` resolve.
127
+
128
+ ## Dates survive on their own
129
+
130
+ oRPC's Standard RPC serializer handles `Date`, `BigInt`, `Map`, `Set`, `URL`, `RegExp`, `NaN` and `undefined` natively via type tags in the response `meta` array. `superjson` was removed and is not needed. `PostWithReactions.createdAt` arrives at the client as a real `Date` — do not re-parse it, and do not add a serialization library.
131
+
132
+ ## Service Worker coupling
133
+
134
+ `public/sw.js` intercepts `/api/rpc/` for the offline feed. `extractPostsFromResponse()` parses the `{ json, meta }` envelope and stores `createdAt`/`updatedAt` as ISO strings. If you add a procedure whose response the feed depends on, check whether the extractor needs to know about it.
135
+
136
+ ## Verify
137
+
138
+ ```bash
139
+ bun run test # full suite, isolated
140
+ bun run lint
141
+ ```
142
+
143
+ Then, if the procedure is reachable from a screen, run the app and click through the actual flow. A procedure that type-checks and passes its unit test can still 404 at runtime for the two reasons above.
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: db-migration
3
+ description: Add or change a Postgres table, column or index in DS-CS with Drizzle, including the generated migration, the drizzle-zod schemas, and applying it. Use when adding a table, adding a column to posts/stories, changing an index, or when "column does not exist" appears at runtime. Not for reading data — that is the oRPC/REST layer.
4
+ ---
5
+
6
+ # Database migrations
7
+
8
+ ## The layout
9
+
10
+ | Path | Role |
11
+ |---|---|
12
+ | `src/lib/db/schema/*.ts` | Table definitions — one file per table |
13
+ | `src/lib/db/schema/index.ts` | Barrel export |
14
+ | `src/lib/db/migrations/` | Generated SQL, committed |
15
+ | `src/lib/db/migrate.ts` | Runtime migrator, used by the container on boot |
16
+ | `drizzle.config.ts` | Points drizzle-kit at `schema` and `migrations` |
17
+
18
+ Tables use `pgTable` with a `text` primary key (UUIDs are generated in application code, not by a database default), `jsonb` for structured columns, and a drizzle-zod schema exported alongside each one.
19
+
20
+ ## Commands
21
+
22
+ ```bash
23
+ bun run db:generate # drizzle-kit generate → writes SQL into src/lib/db/migrations
24
+ bun run db:migrate # apply pending migrations
25
+ bun run db:push # push schema directly + seed (development shortcut)
26
+ bun run db:check # verify migration history is consistent
27
+ bun run db:studio # browse the database
28
+ ```
29
+
30
+ `db:push` is a development shortcut that skips the migration files. **Never** use it as the way to ship a schema change — the container runs `db:migrate` on boot, so a change that exists only in the live database is a change that vanishes on the next deploy.
31
+
32
+ ## Adding a table
33
+
34
+ 1. Create `src/lib/db/schema/<name>.ts`:
35
+
36
+ ```ts
37
+ import { index, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
38
+ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
39
+
40
+ import { toZodV4SchemaTyped } from "@/lib/zod-utils";
41
+
42
+ export const bookmarks = pgTable(
43
+ "bookmarks",
44
+ {
45
+ id: text("id").unique().primaryKey(),
46
+ postId: text("post_id").notNull(),
47
+ note: text("note"),
48
+ tags: jsonb("tags"),
49
+ createdAt: timestamp("created_at").$defaultFn(() => new Date()),
50
+ updatedAt: timestamp("updated_at")
51
+ .$defaultFn(() => new Date())
52
+ .$onUpdate(() => new Date()),
53
+ },
54
+ (table) => {
55
+ return {
56
+ bookmarksPostIdIdx: index("bookmarks_post_id_idx").on(table.postId),
57
+ };
58
+ }
59
+ );
60
+
61
+ export const selectBookmarksSchema = toZodV4SchemaTyped(createSelectSchema(bookmarks));
62
+
63
+ // Use pick() instead of omit() to avoid Zod v4 compatibility issues with createErrorSchema
64
+ export const insertBookmarksSchema = toZodV4SchemaTyped(
65
+ createInsertSchema(bookmarks).pick({ postId: true, note: true, tags: true })
66
+ );
67
+
68
+ export const patchBookmarksSchema = insertBookmarksSchema.partial();
69
+ ```
70
+
71
+ 2. Export it from `src/lib/db/schema/index.ts`.
72
+ 3. `bun run db:generate`, then review the generated SQL before applying.
73
+ 4. `bun run db:migrate`.
74
+
75
+ ## Adding a column
76
+
77
+ Edit the table in place, then `bun run db:generate`. drizzle-kit asks whether the change is safe; the answer matters:
78
+
79
+ - **Adding a column with a default or nullable** — safe, generates `ADD COLUMN`.
80
+ - **Adding a NOT NULL column without a default to a table that already has rows** — drizzle-kit will warn. Either add a default, or add it as nullable and backfill in a second migration. One migration that both adds and backfills leaves no window where the column is missing, but it also locks the table for the duration; on `posts` and `stories`, prefer two migrations.
81
+
82
+ Indexes are declared in the table's third argument with an explicit name (`postsCreatedAtIdx` → `posts_created_at_idx`). Name them consistently.
83
+
84
+ ## Conventions that are already decided
85
+
86
+ - `id` is `text`, unique, primary key — generated in application code.
87
+ - Timestamps use `$defaultFn(() => new Date())` and `$onUpdate(() => new Date())`.
88
+ - `views` and `likes` are stored as `text` with a numeric string default, not `integer`. This is deliberate: they are display counters, and keeping them out of the numeric range avoids contention on hot rows. Read them with `Number(...)` and write them back as strings.
89
+ - Enum-ish columns are `text({ enum: [...] })`, not a Postgres enum type.
90
+ - Structured data is `jsonb`. It is not validated at the database level — the drizzle-zod schemas and the oRPC input schemas are where validation happens.
91
+
92
+ ## Verify
93
+
94
+ ```bash
95
+ bun run db:check
96
+ bun run db:migrate
97
+ bun run test
98
+ ```
99
+
100
+ Confirm the migration folder contains the new SQL and that it is committed. A migration that exists only on your machine reproduces fine locally and fails for everyone else.
101
+
102
+ ## Deploying
103
+
104
+ The container runs `bun run db:migrate` before serving. For Vercel Fluid, the build script runs `db:migrate` as well. Either way the migration files are what ships — if they are not committed, deploys silently skip the change.