create-stitchkit 0.5.0 → 0.6.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 (37) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/UPGRADING.md +43 -0
  3. package/examples/repository/packages/backend/src/surface.snapshot.json +32 -0
  4. package/examples/repository/packages/backend/src/surface.ts +49 -2
  5. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +7 -0
  6. package/examples/repository/packages/shared/src/index.ts +3 -0
  7. package/examples/repository/project.json +10 -0
  8. package/package.json +1 -1
  9. package/template/.data/board.sqlite +0 -0
  10. package/template/README.md +11 -0
  11. package/template/_env.example +3 -0
  12. package/template/bun.lock +2 -5
  13. package/template/docs/ADDING_A_FEATURE.md +35 -11
  14. package/template/package.json +3 -2
  15. package/template/packages/backend/src/index.ts +10 -2
  16. package/template/packages/backend/src/lib/board.ts +99 -0
  17. package/template/packages/backend/src/lib/live.ts +73 -0
  18. package/template/packages/backend/src/surface.snapshot.json +32 -0
  19. package/template/packages/backend/src/surface.ts +53 -3
  20. package/template/packages/backend/src/transport/board-service.ts +18 -0
  21. package/template/packages/config/src/variables.ts +16 -0
  22. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
  23. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
  24. package/template/packages/frontend/src/features/board/board-live.ts +91 -0
  25. package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
  26. package/template/packages/shared/src/contracts/board.ts +54 -0
  27. package/template/packages/shared/src/contracts/live.ts +20 -0
  28. package/template/packages/shared/src/index.ts +3 -0
  29. package/template/packages/shared/src/schemas/board.ts +41 -0
  30. package/template/project.json +10 -0
  31. package/template/scripts/check-authored.test.ts +43 -0
  32. package/template/scripts/check-authored.ts +51 -20
  33. package/template/scripts/dev.ts +6 -1
  34. package/template/scripts/guide-paths.test.ts +37 -0
  35. package/template/scripts/guide-paths.ts +66 -0
  36. package/template/scripts/local-env.test.ts +50 -1
  37. package/template/scripts/local-env.ts +53 -9
package/CHANGELOG.md CHANGED
@@ -12,6 +12,89 @@ step is overwritten by the next release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.6.0] — 2026-09-02
16
+
17
+ The generated project stops being an empty frame with a to-do at the bottom of
18
+ the page. It ships **one vertical feature**, from schema to transport to UI —
19
+ and it was chosen so that every live primitive Stitchkit gained in 0.75–0.76 is
20
+ there because the feature needs it, not to demonstrate anything.
21
+
22
+ The demonstration is the second browser tab: post from it, and the first one
23
+ updates without asking. Nothing on that page polls, and nothing refetches after
24
+ a write.
25
+
26
+ ### Added
27
+
28
+ - **A live board.** `board.list` is a watched read and `board.post` writes to it.
29
+ Together they use, and only where they are needed:
30
+ - `defineEvents` — one topic, `board.changed`, declared in `shared` because the
31
+ server announces it and the browser subscribes to it;
32
+ - `defineKeyspace` / `openKeyspace` over SQLite — notes are authoritative in
33
+ memory and durable behind it, so a read in a handler needs no `await` and a
34
+ restart loses nothing;
35
+ - `createWatchHub` / `createWatchClient` — every browser asking the same
36
+ question is **one read** on the server, re-run when the topic says the answer
37
+ may have changed;
38
+ - `createTrustFence` — installed on **both** lanes when `TRUSTED_HOSTS` is set,
39
+ because the Socket.IO lane never reaches a lifecycle hook.
40
+
41
+ The comments say which of these to reach for and, more usefully, when not to:
42
+ a keyspace is for a small bounded set the process wants synchronously, and the
43
+ moment a thing wants queries, relations or unbounded growth it is a database
44
+ row and Prisma is already there for it.
45
+
46
+ - **Two environment variables**, both declared in the one place the project
47
+ declares variables: `BOARD_STORE_PATH` (defaulted, and the directory is created
48
+ by the application rather than by whoever deploys it) and `TRUSTED_HOSTS`
49
+ (unset means no fence, which is honest for a laptop and wrong for anything a
50
+ network can reach — a fence cannot invent the names it should answer to).
51
+
52
+ ### Changed
53
+
54
+ - The template now targets `stitchkit` `^0.76.1`, up from `^0.71.0`.
55
+
56
+ ## [0.5.1] — 2026-09-01
57
+
58
+ Three findings from someone setting up a new application on the starter from
59
+ scratch, as a consumer who had never seen it. All of them live between "the
60
+ scaffold is green" and "my first feature renders".
61
+
62
+ ### Fixed
63
+
64
+ - **The generated `.env` no longer looks ready when it is not.** `local-env.ts`
65
+ rendered the database name and left `USER:PASSWORD` literal, in a file a
66
+ generator had just written — and a generated file reads as finished.
67
+ `assertUsableEnvironment` now names the file, the line and the variable, and
68
+ covers `ACCEPTANCE_DATABASE_URL` as well as `DATABASE_URL`. It runs before the
69
+ supervisor check, so an unusable environment is reported instead of a pm2
70
+ error, and on every run rather than only the one that created the file.
71
+ Rendering still succeeds: a generator that refuses to generate would break
72
+ `--no-install` scaffolding.
73
+
74
+ - **`CREATEDB` is named where `DATABASE_URL` is named.** `prisma migrate dev`
75
+ creates a shadow database, so a least-privilege role — the sensible default
76
+ on a shared server — fails `db:migrate` with `P3014`. Neither the README nor
77
+ `_env.example` mentioned it.
78
+
79
+ - **`check:authored` no longer refuses `as const`.** The gate exists to catch a
80
+ cast that can *launder* a type; a const-assertion only narrows, introduces no
81
+ name and cannot widen. Five of the first fifteen findings on a real adoption
82
+ were this false positive. Findings now also name the sanctioned alternative
83
+ instead of only the sin.
84
+
85
+ - **`ADDING_A_FEATURE.md` no longer points at files the scaffold lacks.** Steps
86
+ 4 and 5 referenced `lib/api/client.ts` and a "shared realtime source" that a
87
+ generated project does not contain, phrased as "the same pattern used by the
88
+ application's other contracts" — of which there were none. Both steps now
89
+ create what they need, with the transport file given in full.
90
+
91
+ ### Added
92
+
93
+ - **`check:guides`**, part of `check`: every repository path a guide names must
94
+ exist, unless the guide declares it with `(created in this step)`. The guide
95
+ had five such references and three of them were legitimate; only a gate tells
96
+ those apart reliably.
97
+
15
98
  ## [0.5.0] — 2026-09-01
16
99
 
17
100
  ### ⚠️ Breaking changes
package/UPGRADING.md CHANGED
@@ -60,6 +60,49 @@ the first scaffolder release with a migration channel of its own.
60
60
 
61
61
  ---
62
62
 
63
+ ## Released migration: 0.6.0
64
+
65
+ The scaffolder gained a vertical feature. Adopting it in a project you already
66
+ own is optional — nothing breaks if you skip it — but two things are **operator
67
+ steps**, and skipping those with the feature adopted means the API will not
68
+ start.
69
+
70
+ ### 1. The store directory has to be writable
71
+
72
+ `BOARD_STORE_PATH` defaults to `.data/board.sqlite`, relative to the API role's
73
+ working directory. The application creates the directory itself; what it cannot
74
+ do is make a read-only volume writable.
75
+
76
+ ```bash
77
+ # on the machine, as the user the API runs as
78
+ test -w "$(dirname "${BOARD_STORE_PATH:-.data/board.sqlite}")" || echo "not writable"
79
+ ```
80
+
81
+ If the role runs from a read-only image, point `BOARD_STORE_PATH` at a mounted
82
+ volume instead.
83
+
84
+ ### 2. Decide about the trust fence, on purpose
85
+
86
+ `TRUSTED_HOSTS` is unset by default, and unset means **no fence**. That is
87
+ correct on a laptop and wrong on anything a network reaches — but a fence cannot
88
+ guess which names your deployment answers to, so it refuses to invent them.
89
+
90
+ ```bash
91
+ # every authority this deployment answers on, comma separated
92
+ TRUSTED_HOSTS=app.internal,app.internal:5181
93
+ ```
94
+
95
+ Set it and the fence is installed on both lanes: HTTP before routing, and the
96
+ realtime handshake, which never reaches a lifecycle hook on either runtime. If
97
+ your browser lives on another origin you already declared it as `CORS_ORIGIN`,
98
+ and the fence reads that one rather than asking you a second time.
99
+
100
+ ### 3. Nothing else
101
+
102
+ The rest of the feature is code you either copy or do not. The framework range
103
+ moved to `^0.76.1`; if you upgrade the dependency without taking the feature,
104
+ [the framework's own guide](../../docs/guide/upgrading.md) is the one to follow.
105
+
63
106
  ## Released migration: 0.5.0
64
107
 
65
108
  ### the approval policy names a tool that no longer exists
@@ -1,4 +1,36 @@
1
1
  [
2
+ {
3
+ "service": "board",
4
+ "action": "list",
5
+ "scope": "public",
6
+ "hasInput": false,
7
+ "hasOutput": true,
8
+ "inputShape": null,
9
+ "outputShape": "ce49fe198eb3412f",
10
+ "http": [
11
+ {
12
+ "method": "GET",
13
+ "path": "/api/board"
14
+ }
15
+ ],
16
+ "tools": {}
17
+ },
18
+ {
19
+ "service": "board",
20
+ "action": "post",
21
+ "scope": "public",
22
+ "hasInput": true,
23
+ "hasOutput": true,
24
+ "inputShape": "b1d8eb61c62cafed",
25
+ "outputShape": "ce49fe198eb3412f",
26
+ "http": [
27
+ {
28
+ "method": "POST",
29
+ "path": "/api/board"
30
+ }
31
+ ],
32
+ "tools": {}
33
+ },
2
34
  {
3
35
  "service": "repository",
4
36
  "action": "read",
@@ -1,16 +1,63 @@
1
1
  import { env } from '@app/config';
2
2
  import { repositoryRealtimeContract } from '@app/shared';
3
- import { bindRealtimeServer, createSocketIOServer } from 'stitchkit/server';
3
+ import type { TrustFence } from 'stitchkit/server';
4
+ import { bindRealtimeServer, createSocketIOServer, createTrustFence } from 'stitchkit/server';
5
+ import { type BoardRuntime, openBoard } from './lib/board';
6
+ import { bindLive } from './lib/live';
7
+ import { createBoardService } from './transport/board-service';
4
8
  import { createRepositoryService } from './transport/repository-service';
5
9
  import { createSystemService } from './transport/system-service';
6
10
 
11
+ /**
12
+ * The fence this deployment answers behind, or nothing.
13
+ *
14
+ * `trustedHosts` has no default and cannot have one: a fence compares the
15
+ * authority a request addressed against a list, and it cannot invent the list.
16
+ * Unset means no fence — honest for a checkout on a laptop, wrong for anything
17
+ * a network can reach.
18
+ */
19
+ function createFence(): TrustFence | undefined {
20
+ if (!env.TRUSTED_HOSTS) return undefined;
21
+ const browserOrigin = env.CORS_ORIGIN ? [new URL(env.CORS_ORIGIN).host] : [];
22
+ return createTrustFence({
23
+ trustedHosts: env.TRUSTED_HOSTS.split(',').map((entry) => entry.trim()),
24
+ ...(browserOrigin.length > 0 && { trustedOrigins: browserOrigin }),
25
+ onRefused: (refusal) => {
26
+ console.warn(
27
+ `Refused a ${refusal.lane} request: ${refusal.reason} (host ${refusal.host ?? 'absent'})`,
28
+ );
29
+ },
30
+ });
31
+ }
32
+
7
33
  export async function createSurface() {
34
+ const board: BoardRuntime = await openBoard();
35
+ const fence = createFence();
36
+
8
37
  const socket = await createSocketIOServer({
9
38
  cors: { origin: env.CORS_ORIGIN ?? [] },
39
+ // The socket's own admission point. `/socket.io/*` never reaches a lifecycle
40
+ // hook on either runtime, so a fence installed only in `hooks` would leave
41
+ // open the lane this app pushes its live data over.
42
+ ...(fence && { allowRequest: fence.allowRequest }),
10
43
  });
44
+
45
+ // Two bindings over one socket, deliberately. This example's own contract and
46
+ // the live one are separate declarations with separate event names, and
47
+ // merging them into a single registry here would mean editing the merge every
48
+ // time either side gains a topic.
11
49
  const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
50
+ const live = bindLive(board, socket);
51
+
12
52
  const repositoryService = createRepositoryService((snapshot) =>
13
53
  realtime.emit('repository:refreshed', snapshot),
14
54
  );
15
- return { socket, services: [createSystemService(), repositoryService] };
55
+
56
+ return {
57
+ socket,
58
+ fence,
59
+ board,
60
+ hub: live.hub,
61
+ services: [createSystemService(), createBoardService(board), repositoryService],
62
+ };
16
63
  }
@@ -4,6 +4,7 @@ import { BrandMark } from '@/components/brand-mark';
4
4
  import { RepositorySummary } from '@/components/repository-summary';
5
5
  import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
6
6
  import { buttonVariants } from '@/components/ui';
7
+ import { BoardPanel } from '@/features/board/board-panel';
7
8
  import type { AppLocale } from '@/i18n/locales';
8
9
  import { Link } from '@/i18n/navigation';
9
10
  import { absoluteSiteUrl } from '@/lib/seo/metadata';
@@ -15,6 +16,7 @@ interface StarterPageProps {
15
16
  applicationDescription: string;
16
17
  heroTitle: string;
17
18
  catalogueLabel: string;
19
+ realtimeOrigin?: string;
18
20
  locale: AppLocale;
19
21
  }
20
22
 
@@ -62,6 +64,7 @@ export async function StarterPage({
62
64
  applicationDescription,
63
65
  heroTitle,
64
66
  catalogueLabel,
67
+ realtimeOrigin,
65
68
  locale,
66
69
  }: StarterPageProps) {
67
70
  const homeSeo = getSeoPage('home', locale);
@@ -140,6 +143,10 @@ export async function StarterPage({
140
143
  </div>
141
144
  </div>
142
145
 
146
+ <div className='mt-6 w-full max-w-xl'>
147
+ <BoardPanel realtimeOrigin={realtimeOrigin} />
148
+ </div>
149
+
143
150
  <div className='mt-5 w-full'>
144
151
  <RepositorySummary />
145
152
  </div>
@@ -1,5 +1,8 @@
1
+ export * from './contracts/board';
2
+ export * from './contracts/live';
1
3
  export * from './contracts/repository';
2
4
  export * from './contracts/system';
3
5
  export * from './realtime/repository';
6
+ export * from './schemas/board';
4
7
  export * from './schemas/repository';
5
8
  export * from './schemas/system';
@@ -105,6 +105,11 @@
105
105
  "shape": "string",
106
106
  "required": false
107
107
  },
108
+ {
109
+ "name": "BOARD_STORE_PATH",
110
+ "shape": "string",
111
+ "required": false
112
+ },
108
113
  {
109
114
  "name": "CORS_ORIGIN",
110
115
  "shape": "url",
@@ -179,6 +184,11 @@
179
184
  "shape": "url",
180
185
  "required": false
181
186
  },
187
+ {
188
+ "name": "TRUSTED_HOSTS",
189
+ "shape": "string",
190
+ "required": false
191
+ },
182
192
  {
183
193
  "name": "WEB_PORT",
184
194
  "shape": "integer",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
Binary file
@@ -29,6 +29,17 @@ Point `DATABASE_URL` in `.env` at an existing PostgreSQL database, then run:
29
29
  bun run dev
30
30
  ```
31
31
 
32
+ `.env` is generated on first run with the database name filled in and the
33
+ credentials left as `USER:PASSWORD`. Replace them: `dev` refuses to start while
34
+ the placeholder is there, naming the file and the line, rather than letting the
35
+ driver fail on the first request.
36
+
37
+ **The role that runs `bun run db:migrate` needs `CREATEDB`.** `prisma migrate
38
+ dev` creates a throwaway shadow database to diff against, so a least-privilege
39
+ role — the sensible default for a shared server — fails with `P3014: could not
40
+ create the shadow database`. Grant `CREATEDB` to the development role, or point
41
+ Prisma at a shadow database you create yourself.
42
+
32
43
  The command validates the environment, generates the Prisma client, applies any
33
44
  database migrations you add and launches:
34
45
 
@@ -1,4 +1,7 @@
1
1
  NODE_ENV=development
2
+ # Replace USER:PASSWORD before the first `bun run dev` — it refuses to start
3
+ # while they are here. The role also needs CREATEDB if it will run
4
+ # `db:migrate`: `prisma migrate dev` creates a shadow database to diff against.
2
5
  DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
3
6
  # The throwaway database `bun run acceptance:local` creates and writes to. The
4
7
  # runtime gates WRITE, so they get one of their own: the harness refuses to
package/template/bun.lock CHANGED
@@ -139,7 +139,7 @@
139
139
  },
140
140
  },
141
141
  "catalog": {
142
- "stitchkit": "^0.71.0",
142
+ "stitchkit": "^0.76.1",
143
143
  },
144
144
  "packages": {
145
145
  "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.63", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.29", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D7BogSRg61QfTdr7AEcYn9h0I/e4QHvFXwIV1RW+DZZGJu1wSiX2cH06szZSYyKi7Eat50V4s4J8vggZUEs7eg=="],
@@ -1118,7 +1118,7 @@
1118
1118
 
1119
1119
  "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1120
1120
 
1121
- "stitchkit": ["stitchkit@0.71.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "grammy": "^1.45.1", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-HXqTD1Sv534rWt+KKJV1Gp535NTRzbGFxNMuRAvo9TzuU0kZxBDF18gu+WexF9O8Z4jkhlfvTjXeRy/oQrnHwA=="],
1121
+ "stitchkit": ["stitchkit@0.76.1", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "grammy": "^1.45.1", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-+Qmm4CdyWUJJP0MLEQX3M2Ltf0WX5Wi5rEHdZGfWmyIn4SW//x+llQgm516sCyMyJthr9HOaYzxIIP27mBnCmQ=="],
1122
1122
 
1123
1123
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
1124
1124
 
@@ -1190,9 +1190,6 @@
1190
1190
 
1191
1191
  "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
1192
1192
 
1193
-
1194
-
1195
-
1196
1193
  "@prisma/adapter-pg/@types/pg": ["@types/pg@8.21.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q=="],
1197
1194
 
1198
1195
  "@prisma/adapter-pg/pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="],
@@ -7,7 +7,7 @@ same files beside it.
7
7
 
8
8
  ## 1. Define the wire data
9
9
 
10
- Create `packages/shared/src/schemas/status.ts`:
10
+ Create `packages/shared/src/schemas/status.ts` (created in this step):
11
11
 
12
12
  ```ts
13
13
  import { z } from 'zod'
@@ -21,7 +21,7 @@ Export it from the shared package. Do not introduce a second handwritten DTO.
21
21
 
22
22
  ## 2. Define the HTTP/tool contract separately
23
23
 
24
- Create `packages/shared/src/contracts/status.ts` and import the named schemas:
24
+ Create `packages/shared/src/contracts/status.ts` (created in this step) and import the named schemas:
25
25
 
26
26
  ```ts
27
27
  import { defineContract } from 'stitchkit'
@@ -44,7 +44,7 @@ The contract owns transport identity. Do not add a raw route or duplicate path.
44
44
 
45
45
  ## 3. Implement and register the service
46
46
 
47
- Create `packages/backend/src/transport/status-service.ts` with `implement()`.
47
+ Create `packages/backend/src/transport/status-service.ts` (created in this step) with `implement()`.
48
48
  Keep persistence and business rules in a domain/service module; the contract
49
49
  handler calls that module once. Add the returned service to the `services` array
50
50
  in `packages/backend/src/surface.ts`. That one registration drives HTTP,
@@ -52,20 +52,44 @@ OpenAPI, MCP, agent tools and CLI discovery.
52
52
 
53
53
  ## 4. Add typed browser access
54
54
 
55
- Export `statusContract` from `packages/shared/src/index.ts`. In
56
- `packages/frontend/src/lib/api/client.ts`, create `statusApi` with the same
57
- `createClient(statusContract, http)` pattern used by the application's other
58
- contracts. Create the query key and react-query-kit query/mutation hooks in
59
- `packages/frontend/src/lib/api/status.ts`. On mutation success, update or
60
- invalidate that canonical key.
55
+ Export `statusContract` from `packages/shared/src/index.ts`.
56
+
57
+ The scaffold ships no browser transport layer, and that is the first thing this
58
+ step builds. It also makes one decision for you, because the starter already
59
+ made it: **no address is compiled into the artifact.** `packages/frontend/src/env.ts`
60
+ declares no `client` block on purpose — a `NEXT_PUBLIC_` variable is substituted
61
+ at build time, which freezes a value of the place into the bundle. So the client
62
+ is a factory over an origin the server reads per request, never a module-level
63
+ constant.
64
+
65
+ Create `packages/frontend/src/lib/api/client.ts` (created in this step):
66
+
67
+ ```ts
68
+ import { createClient, createHttpClient } from 'stitchkit'
69
+ import { statusContract } from '@app/shared'
70
+
71
+ /** One origin per request, supplied by the server — never read from the bundle. */
72
+ export function createStatusApi(origin: string) {
73
+ return createClient(statusContract, createHttpClient({ prefixUrl: origin }))
74
+ }
75
+ ```
76
+
77
+ Create `packages/frontend/src/lib/api/status.ts` (created in this step) for the
78
+ query key and the react-query-kit hooks, and keep the key canonical — one key
79
+ per resource, updated or invalidated on mutation success.
80
+
81
+ A server component reads `env.PUBLIC_API_ORIGIN` and hands it down as a prop;
82
+ the client component calls `createStatusApi(origin)`. That is the whole reason
83
+ `PUBLIC_API_ORIGIN` is a server variable rather than a public one.
61
84
 
62
85
  Render the hook from a feature component. Pages compose features; they do not
63
86
  call `fetch`, construct `/api/status` or decode error bodies themselves.
64
87
 
65
88
  ## 5. Add realtime only when another client must observe the change
66
89
 
67
- Declare the event in the shared realtime source and use a named Zod schema for
68
- its tuple. The server emits after the domain change succeeds; the frontend cache
90
+ Create `packages/shared/src/realtime.ts` (created in this step) and declare the
91
+ event there with a named Zod schema for its tuple — the scaffold ships no
92
+ realtime module, so this step introduces it rather than assuming it. The server emits after the domain change succeeds; the frontend cache
69
93
  bridge reacts by updating or invalidating the status query. Keep handshake auth,
70
94
  authorization and room membership in the application. Socket.IO delivery,
71
95
  reconnection, retained subscriptions and validation belong to Stitchkit.
@@ -7,12 +7,13 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.71.0"
10
+ "stitchkit": "^0.76.1"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
14
- "check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
14
+ "check": "bun run db:generate && bun run check:authored && bun run check:guides && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
15
15
  "check:authored": "bun scripts/check-authored.ts",
16
+ "check:guides": "bun scripts/guide-paths.ts",
16
17
  "test": "bun test scripts && bun run --filter '*' test",
17
18
  "build": "bun scripts/build-inputs.ts && bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build && bun scripts/build-stamp.ts",
18
19
  "start:api": "bun --filter @app/backend start",
@@ -3,6 +3,7 @@ import { apiRole, appDeclaration } from '@app/config/declaration';
3
3
  import { wrapInRequestContext } from 'stitchkit/observability';
4
4
  import {
5
5
  bindProcessSignals,
6
+ composeLifecycleHooks,
6
7
  createServer,
7
8
  generateOpenApiDocument,
8
9
  openApiRoute,
@@ -14,7 +15,7 @@ import { createSurface } from './surface';
14
15
  import { onError } from './transport/errors';
15
16
 
16
17
  async function main(): Promise<void> {
17
- const { services, socket } = await createSurface();
18
+ const { services, socket, fence, board, hub } = await createSurface();
18
19
  const mcp = createMcpHandler({
19
20
  serverInfo: {
20
21
  name: appDeclaration.identity.slug,
@@ -36,7 +37,10 @@ async function main(): Promise<void> {
36
37
  port: env.API_PORT,
37
38
  hostname: env.BIND_HOST,
38
39
  cors: env.CORS_ORIGIN ? { origin: env.CORS_ORIGIN } : undefined,
39
- hooks: { onError },
40
+ // The fence FIRST: hook composition stops at the first hook that answers,
41
+ // so a fence behind anything that can respond is a fence that sometimes
42
+ // does not run.
43
+ hooks: fence ? composeLifecycleHooks(fence.hooks, { onError }) : { onError },
40
44
  logging: { format: env.LOG_FORMAT },
41
45
  socket,
42
46
  rawRoutes: [
@@ -66,6 +70,10 @@ async function main(): Promise<void> {
66
70
  // SIGKILL — the one ending that runs no cleanup at all.
67
71
  const cleanup = await closeWithinBudget([
68
72
  { name: 'MCP', close: () => mcp.close() },
73
+ // Stops re-reading for browsers that are already gone, then lets the
74
+ // board finish the writes it accepted and say how many it could not.
75
+ { name: 'watch hub', close: async () => hub.close() },
76
+ { name: 'board', close: () => board.close() },
69
77
  { name: 'database', close: () => prisma.$disconnect() },
70
78
  ]);
71
79
  // Say how the drain ended. Without this an operator sees a process that
@@ -0,0 +1,99 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { env } from '@app/config';
5
+ import { type Board, boardEvents, type Note, NoteSchema, type PostNote } from '@app/shared';
6
+ import {
7
+ defineKeyspace,
8
+ type OpenedKeyspace,
9
+ openKeyspace,
10
+ sqliteKeyspaceBackend,
11
+ } from 'stitchkit/application';
12
+ import type { EventPayloads } from 'stitchkit/live';
13
+ import { createEventBus, type EventBus } from 'stitchkit/server';
14
+
15
+ /**
16
+ * The board's notes: authoritative in memory, durable behind it.
17
+ *
18
+ * A keyspace and not a Prisma model, deliberately, and the boundary is worth
19
+ * knowing rather than guessing. A keyspace is for a **small, bounded set the
20
+ * whole process wants synchronously** — read it in a handler without awaiting,
21
+ * write it and know the write survived. The moment a thing wants queries,
22
+ * relations, pagination or unbounded growth, it is a database row and this is
23
+ * the wrong home for it.
24
+ */
25
+ const notes = defineKeyspace('notes', {
26
+ schema: NoteSchema,
27
+ key: (note: Note) => note.id,
28
+ });
29
+
30
+ /** The board, and the lifecycle its owner drives. */
31
+ export interface BoardRuntime {
32
+ /** Every announcement this role makes. Subscribed by the watch hub. */
33
+ readonly events: EventBus<EventPayloads<typeof boardEvents>>;
34
+ /** Synchronous, from memory. This is what makes a watched read cheap. */
35
+ read(): Board;
36
+ post(input: PostNote): Promise<Board>;
37
+ close(): Promise<void>;
38
+ }
39
+
40
+ const MOST_RECENT = 50;
41
+
42
+ export async function openBoard(): Promise<BoardRuntime> {
43
+ // Closed by the declaration: an undeclared topic is refused rather than
44
+ // delivered to nobody, and a topic can only be announced by the verb its
45
+ // declaration chose.
46
+ const events = createEventBus<EventPayloads<typeof boardEvents>>({
47
+ topics: boardEvents.topics,
48
+ onListenerError: (error, event) => {
49
+ console.error(`Listener for ${event} failed`, error);
50
+ },
51
+ });
52
+
53
+ // The directory too, not only the file. A starter that requires an operator to
54
+ // create a folder before it will boot is a starter that fails on the first run
55
+ // with an error about SQLite rather than about what is missing.
56
+ mkdirSync(dirname(env.BOARD_STORE_PATH), { recursive: true });
57
+ const database = new Database(env.BOARD_STORE_PATH, { create: true });
58
+
59
+ // Opened directly rather than declared to a kernel, because this role owns
60
+ // its own lifecycle: it binds its signals and closes what it holds in the
61
+ // order `cleanup.ts` lists. An application built on `createApplication` would
62
+ // declare `keyspaceResource(notes, …)` instead and let the graph order it.
63
+ const opened: OpenedKeyspace<Note> = await openKeyspace(notes, {
64
+ backend: sqliteKeyspaceBackend(notes, { database }),
65
+ // After durability and after memory — never before either. A subscriber
66
+ // woken by this reads immediately, and an announcement that arrived first
67
+ // would be a wake-up to the previous value.
68
+ onChanged: (change) => events.emit('board.changed', { noteId: change.key }),
69
+ });
70
+
71
+ function read(): Board {
72
+ const all = [...opened.keyspace.list()].sort((left, right) =>
73
+ right.postedAt.localeCompare(left.postedAt),
74
+ );
75
+ return { notes: all.slice(0, MOST_RECENT), total: all.length };
76
+ }
77
+
78
+ return {
79
+ events,
80
+ read,
81
+ async post(input) {
82
+ await opened.keyspace.put({
83
+ id: crypto.randomUUID(),
84
+ body: input.body,
85
+ postedAt: new Date().toISOString(),
86
+ });
87
+ // The write resolved, so it is durable and in memory; this read cannot
88
+ // miss it. The watchers hear about it through the announcement above.
89
+ return read();
90
+ },
91
+ async close() {
92
+ opened.stopAdmission();
93
+ await opened.drain();
94
+ await opened.close();
95
+ database.close();
96
+ events.clear();
97
+ },
98
+ };
99
+ }