@productcraft/social 1.0.3 → 1.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.
package/README.md CHANGED
@@ -1,229 +1,241 @@
1
1
  # @productcraft/social
2
2
 
3
- Typed Node.js SDK for [ProductCraft Social](https://productcraft.co) — social-as-a-service backend: communities, posts, ranked feeds, stories (with polls + close-friends), direct conversations, notifications, moderation, flags. Generic actor / object / edge primitives.
3
+ Node.js / TypeScript client for **ProductCraft Social**: communities, actors, posts and comments, ranked feeds, stories, the follow graph, direct conversations, notifications and moderation.
4
4
 
5
- ```bash
6
- npm install @productcraft/social
7
- ```
8
-
9
- Server-side only. Customer-facing apps integrate via a backend (BFF pattern) that holds the workspace PAK (`pcft_live_…`).
5
+ Every method, type, error class and mock is generated from the production OpenAPI spec. Coming from 1.0's raw `social.client.POST("/v1/…")` calls? `.client` still works unchanged; the [1.1 migration guide](https://github.com/clauderanelagh/productcraft-node/blob/main/docs/migrations/social-1.1.0.md) shows the resource method for every call shape.
10
6
 
11
- ## Two caller contexts
7
+ ## Install
12
8
 
13
- Social's surface splits two ways — both reach the same data but differ in auth + URL shape:
14
-
15
- | Caller | URL prefix | Auth | What you do |
16
- |---|---|---|---|
17
- | **PlatformUser admin** | `/v1/workspaces/{workspace_id}/...` | cookie or PAK | Create communities, run moderation, read analytics. |
18
- | **Customer backend** | `/v1/communities/{community_id}/...` | PAK + `X-Acting-As: <actor_id>` header | Day-to-day reads/writes on behalf of an EndUser. |
9
+ ```sh
10
+ npm install @productcraft/social
11
+ ```
19
12
 
20
- The customer backend acts on behalf of one of its EndUsers via the `X-Acting-As` header — the SDK passes whatever you put in headers straight through, so a per-request wrapper that injects the acting actor id is idiomatic.
13
+ Node 20.3 or later, Bun, Deno and edge runtimes. Server-side only: your backend holds the workspace key (`pcft_live_…`) and calls Social on behalf of your users. Or install the umbrella package for every product: `npm install productcraft`.
21
14
 
22
- ## Quick start — create a community (admin)
15
+ ## Quick start
23
16
 
24
- ```ts before
17
+ ```ts
25
18
  import { Social } from "@productcraft/social";
26
19
 
27
- // Reads PRODUCTCRAFT_API_KEY from the environment
20
+ // Reads PRODUCTCRAFT_API_KEY from the environment.
28
21
  const social = new Social();
29
22
 
30
- const { data, error } = await social.client.POST(
31
- "/v1/workspaces/{workspace_id}/communities",
32
- {
33
- params: { path: { workspace_id: "<workspace-uuid>" } },
34
- body: { display_name: "Founders Club", slug: "founders" },
35
- },
36
- );
37
- ```
23
+ // Workspace-level: communities themselves (the workspace id is resolved from the key, or pass workspaceId).
24
+ const { id: communityId } = await social.communities.create({ display_name: "Founders Club", slug: "founders" });
25
+
26
+ // Community-level: bind the community once, then act as one of its actors.
27
+ const community = social.community(communityId);
28
+ const { actor } = await community.actors.create({ external_id: "user_123", display_name: "Ada" }); // upsert by your user id
29
+ const c = community.as(actor.id); // fills actor_id in every body or query that declares it
38
30
 
39
- ## Quick start — post on behalf of an EndUser (customer backend)
40
-
41
- ```ts before
42
- const actor_id = "<actor-uuid>"; // the EndUser's Social actor id
43
-
44
- const { data, error } = await social.client.POST(
45
- "/v1/communities/{community_id}/posts",
46
- {
47
- params: {
48
- path: { community_id: "<community-uuid>" },
49
- header: { "X-Acting-As": actor_id },
50
- },
51
- body: {
52
- // `actor_id` is required in the body too — the header sets caller
53
- // identity; the body field is the author of the post (typically
54
- // the same UUID, but allowed to differ on admin-impersonation flows).
55
- actor_id,
56
- text: "Hello, world!",
57
- visibility: "public",
58
- },
59
- },
60
- );
31
+ const post = await c.posts.create({ body: "Hello from the SDK" });
32
+ await c.posts.comments.create(post.id, { body: "First!" });
33
+ for await (const item of c.actors.feed.list(actor.id, { limit: 50 })) console.log(item);
61
34
  ```
62
35
 
63
- Social's wire is snake_case both at the DTO level and in TS types — there's no name translation layer for this surface. Use snake_case keys in `body` to match what the API actually accepts.
36
+ ## Two levels: workspace and community
64
37
 
65
- ## Migrating from 0.x to 1.0
38
+ | Calls on | Reach them through | Typical caller |
39
+ |---|---|---|
40
+ | Communities (create, list, analytics), moderation, workspace webhooks, stats | `social.communities.*`, `social.webhooks.*`, `social.stats` | Your admin tooling |
41
+ | Everything inside one community: actors, posts, comments, feeds, follows, conversations, notifications, community webhooks | `social.community(communityId).…` | Your product backend, per request |
66
42
 
67
- 1.0 renames every **path parameter** to snake_case, to match the rest of the ProductCraft APIs: `{communityId}` is now `{community_id}`, `{actorId}` is `{actor_id}`, `{srcActorId}` is `{src_actor_id}`, and so on. Request URLs are unchanged, so nothing breaks at runtime; the change is in the TypeScript path keys and in `params.path`:
43
+ `.as(actorId)` returns a copy of the community scope that fills `actor_id` (the acting user) wherever the API declares it. A call that passes its own `actor_id` wins:
68
44
 
69
- ```ts before
70
- // 0.x
71
- await social.client.GET("/v1/communities/{communityId}/posts/{postId}", {
72
- params: { path: { communityId, postId } },
73
- });
45
+ ```ts
46
+ import { Social } from "@productcraft/social";
47
+ declare const communityId: string, adaId: string, graceId: string, postId: string;
48
+ const social = new Social();
74
49
 
75
- // 1.0
76
- await social.client.GET("/v1/communities/{community_id}/posts/{post_id}", {
77
- params: { path: { community_id: communityId, post_id: postId } },
78
- });
50
+ const asAda = social.community(communityId).as(adaId);
51
+ await asAda.follows.create({ dst_actor_id: graceId }); // actor_id: adaId
52
+ await asAda.posts.reactions.create(postId, { type: "like", actor_id: graceId }); // explicit wins
53
+ const unread = await asAda.actors.notifications.unreadCount(adaId);
54
+ const dm = await asAda.conversations.create({ kind: "direct", recipient_actor_id: graceId });
55
+ await asAda.conversations.messages.create(dm.id, { body: "Hi Grace" });
79
56
  ```
80
57
 
81
- Where a route names a target actor next to the acting `actor_id`, the target got its own name: `posts/{post_id}/tags/{tagged_actor_id}/approve`, `actors/{tagged_actor_id}/tagged`, `actors/{owner_actor_id}/highlights`, `lists/{list_id}/members/{member_actor_id}`, `conversations/{conversation_id}/members/{member_actor_id}`. A search-and-replace of the placeholders plus the `path` keys is the whole migration. Post `attributes` now read back with the keys exactly as you stored them (`imageUrl` stays `imageUrl`); before, the API returned them snake_cased.
58
+ Social's wire is snake_case and so are the request and response types: no re-casing. Positional arguments (`postId`, `actorId`) and client options are camelCase.
59
+
60
+ ## Credentials
61
+
62
+ | Credential | Pass | Use it for |
63
+ |---|---|---|
64
+ | Workspace API key (`pcft_live_…`) | `apiKey`, or `PRODUCTCRAFT_API_KEY` in the environment | Your backend, for everything |
65
+ | Platform session (a signed-in person) | `auth: { type: "bearer", token }` plus `workspaceId` | Admin tooling acting as a console user |
66
+
67
+ An explicit `apiKey` or `auth` always wins over the environment. The environment fallback is server-only: never put a key in `NEXT_PUBLIC_*` or any variable a bundler inlines into browser code. A keyed client constructed in a browser throws unless you set `dangerouslyAllowBrowser: true`.
68
+
69
+ <!-- generated:start (scripts/gen-sdk.mjs; edits between the markers are overwritten) -->
70
+
71
+ ## Resources
72
+
73
+ Full reference: [api.md](./api.md).
74
+
75
+ - `social.communities`: `actors.comments.list`, `actors.reactions.list`, `analytics`, `create`, `delete`, `list`, `moderation.actions.create`, `moderation.actors.shadowBan`, `moderation.actors.unshadowBan`, `moderation.audit.list`, `moderation.blockedTerms.create`, `moderation.blockedTerms.delete`, `moderation.blockedTerms.list`, `moderation.conversations.list`, `moderation.conversations.messages.list`, `moderation.flags.list`, `moderation.flags.resolve`, `moderation.shadowBanned.list`, `retrieve`, `update`, `webhooks.endpoints.create`, `webhooks.endpoints.delete`, `webhooks.endpoints.list`, `webhooks.endpoints.retrieve`, `webhooks.endpoints.rotateSecret`, `webhooks.endpoints.test`, `webhooks.endpoints.update`, `webhooks.eventTypes.list`, `webhooks.events.list`, `webhooks.events.replay`, `webhooks.events.retrieve`
76
+ - `social.community(…).actors`: `archived.list`, `assets.list`, `batch`, `blocks.list`, `bookmarks.list`, `byExternal.batch`, `byExternal.retrieve`, `closeFriends.list`, `counters`, `create`, `delete`, `dismissedSuggestions.list`, `drafts.list`, `feed.list`, `followRequests.list`, `followers.list`, `following.list`, `hashtagFollows.create`, `hashtagFollows.delete`, `hashtagFollows.list`, `hides.list`, `highlights.list`, `insights`, `list`, `mentions.list`, `mutedTerms.create`, `mutedTerms.delete`, `mutedTerms.list`, `mutes.list`, `notificationPrefs.list`, `notificationPrefs.update`, `notifications.list`, `notifications.markAllRead`, `notifications.unreadCount`, `notifications.update`, `postState`, `relationships.list`, `restricts.list`, `retrieve`, `scheduledPosts.list`, `storyTray.list`, `suggestedFollows.list`, `tagged.list`, `update`
77
+ - `social.community(…).assets`: `complete`, `create`, `delete`, `retrieve`
78
+ - `social.community(…).blocks`: `create`, `delete`
79
+ - `social.community(…).closeFriends`: `create`, `delete`
80
+ - `social.community(…).collections`: `create`, `delete`, `items.create`, `items.delete`, `items.list`, `items.update`, `list`, `retrieve`, `update`
81
+ - `social.community(…).connections`: `accept`, `create`, `delete`
82
+ - `social.community(…).conversations`: `create`, `delete`, `list`, `markRead`, `members.create`, `members.delete`, `members.update`, `messages.create`, `messages.delete`, `messages.list`, `messages.reactions.create`, `messages.reactions.delete`, `messages.reactions.list`, `messages.retrieve`, `messages.update`, `retrieve`, `unreadCount`, `update`
83
+ - `social.community(…).counters`: `list`
84
+ - `social.community(…).discoverFeed`: `list`
85
+ - `social.community(…).dismissedSuggestions`: `create`, `delete`
86
+ - `social.community(…).flags`: `create`, `list`
87
+ - `social.community(…).followRequests`: `approve`, `delete`
88
+ - `social.community(…).follows`: `batch`, `create`, `delete`
89
+ - `social.community(…).hashtags`: `list`, `posts.list`, `retrieve`
90
+ - `social.community(…).hides`: `create`, `delete`
91
+ - `social.community(…).lists`: `create`, `delete`, `feed.list`, `list`, `members.create`, `members.delete`, `members.list`, `retrieve`, `update`
92
+ - `social.community(…).mutes`: `create`, `delete`
93
+ - `social.community(…).posts`: `bookmark`, `comments.approve`, `comments.create`, `comments.delete`, `comments.list`, `comments.pin`, `comments.reactions.create`, `comments.reactions.delete`, `comments.reactions.list`, `comments.retrieve`, `comments.unpin`, `comments.update`, `create`, `delete`, `insights`, `list`, `pollResults`, `quotes.list`, `reactions.create`, `reactions.delete`, `reactions.list`, `recordImpressions`, `recordView`, `repost`, `reposts.list`, `retrieve`, `revisions.list`, `tags.approve`, `tags.delete`, `unbookmark`, `unrepost`, `update`, `viewers.list`, `voters.list`, `votes.create`, `votes.delete`, `votes.retrieve`
94
+ - `social.community(…).restricts`: `create`, `delete`
95
+ - `social.community(…).search`: `actors.list`, `hashtags.list`, `posts.list`
96
+ - `social.community(…).webhooks`: `endpoints.create`, `endpoints.delete`, `endpoints.list`, `endpoints.retrieve`, `endpoints.rotateSecret`, `endpoints.test`, `endpoints.update`, `eventTypes.list`, `events.list`, `events.replay`, `events.retrieve`
97
+ - `social.stats`: `retrieve`
98
+ - `social.webhooks`: `endpoints.create`, `endpoints.delete`, `endpoints.list`, `endpoints.retrieve`, `endpoints.rotateSecret`, `endpoints.test`, `endpoints.update`, `eventTypes.list`, `events.list`, `events.replay`, `events.retrieve`
99
+
100
+ ## Pagination
101
+
102
+ List methods return a `PagePromise`: `for await` walks every item across pages, `await` gives one page.
103
+
104
+ ```ts
105
+ import { Social } from "@productcraft/social";
106
+ const social = new Social({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
107
+ for await (const item of social.communities.list({ limit: 50 })) console.log(item);
108
+ const page = await social.communities.list();
109
+ if (page.hasNextPage()) await page.getNextPage();
110
+ const all = await social.communities.list().toArray({ maxItems: 500 });
111
+ ```
82
112
 
83
- ## Credentials from the environment
113
+ ## Errors
84
114
 
85
- Server-side, omit `auth` and the client reads your workspace key from the environment:
115
+ Every failure is a class you can catch. `APIError` carries `status`, `code`, `message`, `errors[]`, `requestId` and `docUrl`.
86
116
 
87
- ```bash
88
- # .env
89
- PRODUCTCRAFT_API_KEY=pcft_live_...
117
+ ```ts
118
+ import { Social, NotFoundError, RateLimitError, APIError } from "@productcraft/social";
119
+ const social = new Social();
120
+ try {
121
+ await social.communities.retrieve("00000000-0000-0000-0000-000000000000");
122
+ } catch (err) {
123
+ if (err instanceof NotFoundError) console.log(err.code, err.requestId, err.docUrl);
124
+ else if (err instanceof RateLimitError) console.log(err.retryAfter);
125
+ else if (err instanceof APIError) console.log(err.status, err.errors);
126
+ else throw err;
127
+ }
90
128
  ```
91
129
 
92
- - An explicit `auth` always wins over the environment.
93
- - Hosts are never read from the environment. Override them in code with `baseUrl`.
94
- - This is a server-only fallback. Never put a key in `NEXT_PUBLIC_*` or any other variable a bundler inlines into browser code.
130
+ ## Retries and timeouts
95
131
 
96
- ## Configuration
132
+ Network errors, 408, 429, 5xx and `409 IDEMPOTENCY_IN_PROGRESS` are retried twice by default with exponential backoff and jitter, honouring `Retry-After` up to 60 s. A POST or PATCH without an idempotency key is never retried. Each attempt times out after 60 s, so the worst case with defaults is about 3 minutes; pass `signal: AbortSignal.timeout(ms)` for an overall deadline.
97
133
 
98
- ```ts before
99
- new Social({
100
- // Omit to use PRODUCTCRAFT_API_KEY from the environment
101
- auth: { type: "apiKey", key: "pcft_live_..." }
102
- | { type: "bearer", token: "eyJ..." }
103
- | { type: "cookie", value: "auth_token=..." },
104
- baseUrl: "https://social.example.test", // optional override
105
- fetch: customFetch, // optional
106
- });
134
+ ```ts
135
+ import { Social } from "@productcraft/social";
136
+ const social = new Social({ maxRetries: 3, timeout: 20_000 });
137
+ await social.communities.list({}, { maxRetries: 0, signal: AbortSignal.timeout(5_000) });
107
138
  ```
108
139
 
109
- ## Common operations
140
+ ## Idempotency
110
141
 
111
- ### Posts + feeds
142
+ Writes that support it send an `Idempotency-Key` automatically and reuse it on every retry. Pass your own to make a retry across processes safe. If the API's idempotency store is unavailable it runs the request without it (fail-open), so a retry during that window can execute twice.
143
+
144
+ ```ts
145
+ import { Social } from "@productcraft/social";
146
+ const social = new Social({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
147
+ const community = await social.communities.create({ display_name: "My Product Community", slug: "my-product-community" }, { idempotencyKey: "order-1234" });
148
+ ```
112
149
 
113
- ```ts before
114
- // Create a post
115
- await social.client.POST(
116
- "/v1/communities/{community_id}/posts",
117
- { params: { path: { community_id: communityId } }, body: { text: "..." } },
118
- );
150
+ ## Webhooks
119
151
 
120
- // Get the actor's ranked home feed
121
- await social.client.GET(
122
- "/v1/communities/{community_id}/actors/{actor_id}/feed",
123
- { params: { path: { community_id: communityId, actor_id: actorId } } },
124
- );
152
+ `social.webhooks.unwrap()` verifies the signature against the **raw** body and returns a typed `Social.UnwrapWebhookEvent`. The secret comes from `{ secret }`, the client's `webhookSecret`, or `PRODUCTCRAFT_SOCIAL_WEBHOOK_SECRET`.
125
153
 
126
- // Discover (community-wide ranked feed, not personalised to follows)
127
- await social.client.GET(
128
- "/v1/communities/{community_id}/discover-feed",
129
- { params: { path: { community_id: communityId } } },
130
- );
154
+ Next.js route handler:
131
155
 
132
- // React / unreact / bookmark / quote / repost — same shape, different paths
156
+ ```ts
157
+ import { Social } from "@productcraft/social";
158
+ const social = new Social();
159
+ export async function POST(req: Request) {
160
+ const event = await social.webhooks.unwrap({ payload: await req.text(), headers: req.headers });
161
+ switch (event.type) {
162
+ case "actor.updated":
163
+ console.log(event.data);
164
+ break;
165
+ default:
166
+ break; // UnknownWebhookEvent: types added later
167
+ }
168
+ return new Response(null, { status: 204 });
169
+ }
133
170
  ```
134
171
 
135
- ### Stories
172
+ Express (keep the body raw):
136
173
 
137
- ```ts before
138
- // Post a story (24h TTL)
139
- await social.client.POST(
140
- "/v1/communities/{community_id}/stories",
141
- { params: { ... }, body: { media_url: "...", visibility: "close_friends" } },
142
- );
143
-
144
- // Story tray (the bubble row at the top of the home screen)
145
- await social.client.GET(
146
- "/v1/communities/{community_id}/actors/{actor_id}/story-tray",
147
- { ... },
148
- );
174
+ ```ts
175
+ import express from "express";
176
+ import { Social } from "@productcraft/social";
177
+ const social = new Social();
178
+ const app = express();
179
+ app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
180
+ const event = await social.webhooks.unwrap({ payload: req.body, headers: req.headers });
181
+ console.log(event.type);
182
+ res.sendStatus(204);
183
+ });
149
184
  ```
150
185
 
151
- ### Social graph
152
-
153
- ```ts before
154
- // Follow / unfollow
155
- await social.client.PUT(
156
- "/v1/communities/{community_id}/follows/{src_actor_id}/{dst_actor_id}",
157
- { ... },
158
- );
186
+ Hono:
159
187
 
160
- // Block / mute / restrict — same shape, different paths
161
- // Close friends, hashtag follows, muted terms — all under /actors/{actor_id}/...
188
+ ```ts
189
+ import { Hono } from "hono";
190
+ import { Social } from "@productcraft/social";
191
+ const social = new Social();
192
+ const app = new Hono();
193
+ app.post("/webhooks", async (c) => {
194
+ const event = await social.webhooks.unwrap({ payload: await c.req.text(), headers: c.req.raw.headers });
195
+ console.log(event.type);
196
+ return c.body(null, 204);
197
+ });
162
198
  ```
163
199
 
164
- ### Notifications
165
-
166
- ```ts before
167
- // List
168
- await social.client.GET(
169
- "/v1/communities/{community_id}/actors/{actor_id}/notifications",
170
- { ... },
171
- );
172
-
173
- // Unread count for the bell badge
174
- await social.client.GET(
175
- "/v1/communities/{community_id}/actors/{actor_id}/notifications/unread-count",
176
- { ... },
177
- );
178
-
179
- // Mark all read
180
- await social.client.POST(
181
- "/v1/communities/{community_id}/actors/{actor_id}/notifications/read-all",
182
- { ... },
183
- );
200
+ ## Raw responses
201
+
202
+ Every returned object has a non-enumerable `lastResponse` (`requestId`, `status`, `headers`). `.withResponse()` adds the `Response` and parsed rate-limit headers.
203
+
204
+ ```ts
205
+ import { Social } from "@productcraft/social";
206
+ const social = new Social({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
207
+ const id = "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b";
208
+ const obj = await social.communities.retrieve(id);
209
+ console.log(obj.lastResponse.requestId);
210
+ const { data, response, requestId, rateLimit } = await social.communities.retrieve(id).withResponse();
211
+ console.log(data, response.status, requestId, rateLimit?.remaining);
184
212
  ```
185
213
 
186
- ### Direct conversations
214
+ ## Testing
187
215
 
188
- ```ts before
189
- // Start / open a conversation
190
- await social.client.POST(
191
- "/v1/communities/{community_id}/conversations",
192
- { params: { ... }, body: { members: ["act_a", "act_b"] } },
193
- );
216
+ `@productcraft/social/testing` exports `createMockFetch()`, a `fetch` that answers every operation with its spec example. Override one operation by name.
194
217
 
195
- // Send a message
196
- await social.client.POST(
197
- "/v1/communities/{community_id}/conversations/{conversation_id}/messages",
198
- { ... },
199
- );
218
+ ```ts
219
+ import { Social } from "@productcraft/social";
220
+ import { createMockFetch } from "@productcraft/social/testing";
221
+ const fetch = createMockFetch();
222
+ const social = new Social({ apiKey: "test", workspaceId: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b", fetch });
223
+ await social.communities.list();
224
+ console.log(fetch.calls.length);
200
225
  ```
201
226
 
202
- ### Moderation (workspace admin)
203
-
204
- ```ts before
205
- // List flags
206
- await social.client.GET(
207
- "/v1/workspaces/{workspace_id}/communities/{community_id}/moderation/flags",
208
- { ... },
209
- );
210
-
211
- // Act on a flag (resolve / dismiss / take action on the target)
212
- await social.client.POST(
213
- "/v1/workspaces/{workspace_id}/communities/{community_id}/moderation/flags/{flag_id}/actions",
214
- { params: { ... }, body: { action: "remove_content", note: "..." } },
215
- );
216
-
217
- // Shadow-ban an actor
218
- await social.client.PUT(
219
- "/v1/workspaces/{workspace_id}/communities/{community_id}/moderation/actors/{actor_id}/shadow-ban",
220
- { ... },
221
- );
222
- ```
227
+ ## Escape hatch
228
+
229
+ `.client` is the typed `openapi-fetch` client over the same credential and base URL (no retries). Use it for anything the resource methods do not cover.
223
230
 
224
- ## How this SDK is built
231
+ ```ts
232
+ import { Social } from "@productcraft/social";
233
+ const social = new Social({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
234
+ const { data, error } = await social.client.GET("/v1/workspaces/{workspace_id}/communities", { params: { path: { workspace_id: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b" } } });
235
+ console.log(data, error);
236
+ ```
225
237
 
226
- Generated from the live OpenAPI spec at `https://social.productcraft.co/docs-json` via [`openapi-typescript`](https://openapi-ts.dev/) + [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/). The nightly `spec-refresh` workflow opens a PR whenever the spec changes.
238
+ <!-- generated:end -->
227
239
 
228
240
  ## License
229
241