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.
- package/CHANGELOG.md +83 -0
- package/UPGRADING.md +43 -0
- package/examples/repository/packages/backend/src/surface.snapshot.json +32 -0
- package/examples/repository/packages/backend/src/surface.ts +49 -2
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +7 -0
- package/examples/repository/packages/shared/src/index.ts +3 -0
- package/examples/repository/project.json +10 -0
- package/package.json +1 -1
- package/template/.data/board.sqlite +0 -0
- package/template/README.md +11 -0
- package/template/_env.example +3 -0
- package/template/bun.lock +2 -5
- package/template/docs/ADDING_A_FEATURE.md +35 -11
- package/template/package.json +3 -2
- package/template/packages/backend/src/index.ts +10 -2
- package/template/packages/backend/src/lib/board.ts +99 -0
- package/template/packages/backend/src/lib/live.ts +73 -0
- package/template/packages/backend/src/surface.snapshot.json +32 -0
- package/template/packages/backend/src/surface.ts +53 -3
- package/template/packages/backend/src/transport/board-service.ts +18 -0
- package/template/packages/config/src/variables.ts +16 -0
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -0
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +9 -1
- package/template/packages/frontend/src/features/board/board-live.ts +91 -0
- package/template/packages/frontend/src/features/board/board-panel.tsx +112 -0
- package/template/packages/shared/src/contracts/board.ts +54 -0
- package/template/packages/shared/src/contracts/live.ts +20 -0
- package/template/packages/shared/src/index.ts +3 -0
- package/template/packages/shared/src/schemas/board.ts +41 -0
- package/template/project.json +10 -0
- package/template/scripts/check-authored.test.ts +43 -0
- package/template/scripts/check-authored.ts +51 -20
- package/template/scripts/dev.ts +6 -1
- package/template/scripts/guide-paths.test.ts +37 -0
- package/template/scripts/guide-paths.ts +66 -0
- package/template/scripts/local-env.test.ts +50 -1
- 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 {
|
|
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
|
-
|
|
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
|
Binary file
|
package/template/README.md
CHANGED
|
@@ -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
|
|
package/template/_env.example
CHANGED
|
@@ -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.
|
|
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.
|
|
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`.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`packages/frontend/src/
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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.
|
package/template/package.json
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
"packages/*"
|
|
8
8
|
],
|
|
9
9
|
"catalog": {
|
|
10
|
-
"stitchkit": "^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
|
-
|
|
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
|
+
}
|