create-foldkit-app 0.37.0 → 0.38.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 +1 -0
- package/dist/examples.js +6 -0
- package/dist/templates/base/FOLDKIT.md +2 -0
- package/dist/templates/examples/api-cache-query/package.json +27 -0
- package/dist/templates/examples/api-cache-query/src/data.ts +112 -0
- package/dist/templates/examples/api-cache-query/src/entry.ts +17 -0
- package/dist/templates/examples/api-cache-query/src/main.fixture.ts +136 -0
- package/dist/templates/examples/api-cache-query/src/main.ts +651 -0
- package/dist/templates/examples/api-cache-query/src/scene.test.ts +124 -0
- package/dist/templates/examples/api-cache-query/src/story.test.ts +297 -0
- package/dist/templates/examples/api-cache-query/src/styles.css +1 -0
- package/dist/templates/examples/api-cache-query/src/vitest-setup.ts +3 -0
- package/dist/templates/examples/generative-art/src/subscription.ts +3 -2
- package/dist/templates/examples/kanban/src/subscription.ts +2 -1
- package/dist/templates/examples/ui-showcase/src/main.ts +1 -1
- package/dist/templates/examples/ui-showcase/src/ui/subscriptions.ts +7 -6
- package/dist/templates/release.json +13 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ The starter examples apply to `spa` rendering. The `ssg` and `ssr` modes scaffol
|
|
|
40
40
|
| `job-application` | Multi-step form with async validation, file uploads, and per-step error indicators |
|
|
41
41
|
| `weather` | HTTP requests with async state handling |
|
|
42
42
|
| `api-cache` | Query caching in the Model with stale-while-revalidate, request deduplication, and interval refetching |
|
|
43
|
+
| `api-cache-query` | The API Cache app rewritten with experimental Query.define |
|
|
43
44
|
| `charting` | Live GitHub and npm telemetry rendered through an ECharts Mount adapter |
|
|
44
45
|
| `routing` | URL routing with parser combinators and route parameters |
|
|
45
46
|
| `upload` | Simulated file uploads with cancellable, restartable interruptible Commands |
|
package/dist/examples.js
CHANGED
|
@@ -9,6 +9,7 @@ export const EXAMPLE_VALUES = [
|
|
|
9
9
|
'job-application',
|
|
10
10
|
'weather',
|
|
11
11
|
'api-cache',
|
|
12
|
+
'api-cache-query',
|
|
12
13
|
'charting',
|
|
13
14
|
'routing',
|
|
14
15
|
'route-transitions',
|
|
@@ -81,6 +82,11 @@ export const examples = [
|
|
|
81
82
|
title: 'api-cache',
|
|
82
83
|
description: 'Query caching in the Model with stale-while-revalidate, request deduplication, invalidation, and interval refetching',
|
|
83
84
|
},
|
|
85
|
+
{
|
|
86
|
+
value: 'api-cache-query',
|
|
87
|
+
title: 'api-cache-query',
|
|
88
|
+
description: 'The API Cache app rewritten with experimental Query.define. Fetching and retained entries live in Query Submodels',
|
|
89
|
+
},
|
|
84
90
|
{
|
|
85
91
|
value: 'charting',
|
|
86
92
|
title: 'charting',
|
|
@@ -159,6 +159,8 @@ Messages are verb-first past-tense. Common prefixes: `Clicked*`, `Updated*` (inp
|
|
|
159
159
|
|
|
160
160
|
This project ships with `@foldkit/devtools-mcp` pre-wired. When the dev server is running and the app is open in a browser, `foldkit_*` MCP tools let you inspect Model, Message history, and time-travel. Reach for them before adding `console.log` whenever the question is about state or Message flow.
|
|
161
161
|
|
|
162
|
+
Treat those tools as privileged access to the application. They can return Models, Message payloads, and Command and Mount arguments to the agent. Keep credentials and private data out of browser state. Use Effect `Redacted` for sensitive values the browser must temporarily hold, and set `devToolsMcpPort: false` in the Foldkit Vite plugin options when an agent should not inspect the running application.
|
|
163
|
+
|
|
162
164
|
## Going Deeper
|
|
163
165
|
|
|
164
166
|
For Submodels and OutMessage, Subscriptions, Mount / ManagedResource / CustomElement, field validation, routing, accessibility, and the full convention set, read the live Foldkit code in `repos/foldkit/`. The `examples/` directory and the production apps (`packages/typing-game/`, `packages/website/`) are the highest-fidelity references for any specific pattern. The `foldkit-skills` plugin's `generate-program` and `audit-program` skills carry written snapshot guides if you want a structured walkthrough.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "api-cache-query-example",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@effect/platform-browser": "4.0.0",
|
|
13
|
+
"@foldkit/ui": "workspace:*",
|
|
14
|
+
"effect": "4.0.0",
|
|
15
|
+
"foldkit": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@foldkit/devtools": "workspace:*",
|
|
19
|
+
"@foldkit/vite-plugin": "workspace:*",
|
|
20
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
21
|
+
"happy-dom": "^20.14.5",
|
|
22
|
+
"tailwindcss": "^4.3.3",
|
|
23
|
+
"typescript": "^7.0.2",
|
|
24
|
+
"vite": "^8.3.1",
|
|
25
|
+
"vitest": "^5.0.2"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Array, Duration, Effect, Option, Random, Schema } from 'effect'
|
|
2
|
+
|
|
3
|
+
export const Post = Schema.Struct({
|
|
4
|
+
id: Schema.String,
|
|
5
|
+
title: Schema.String,
|
|
6
|
+
excerpt: Schema.String,
|
|
7
|
+
})
|
|
8
|
+
export type Post = typeof Post.Type
|
|
9
|
+
|
|
10
|
+
export const PostDetail = Schema.Struct({
|
|
11
|
+
id: Schema.String,
|
|
12
|
+
title: Schema.String,
|
|
13
|
+
author: Schema.String,
|
|
14
|
+
body: Schema.String,
|
|
15
|
+
})
|
|
16
|
+
export type PostDetail = typeof PostDetail.Type
|
|
17
|
+
|
|
18
|
+
export const Stats = Schema.Struct({
|
|
19
|
+
activeUsers: Schema.Number,
|
|
20
|
+
requestsPerSecond: Schema.Number,
|
|
21
|
+
cacheHitRatePercent: Schema.Number,
|
|
22
|
+
})
|
|
23
|
+
export type Stats = typeof Stats.Type
|
|
24
|
+
|
|
25
|
+
const SERVER_LATENCY = Duration.millis(700)
|
|
26
|
+
const UNAVAILABLE_POST_ID = 'unavailable-post'
|
|
27
|
+
|
|
28
|
+
type Article = Readonly<{
|
|
29
|
+
id: string
|
|
30
|
+
title: string
|
|
31
|
+
excerpt: string
|
|
32
|
+
author: string
|
|
33
|
+
body: string
|
|
34
|
+
}>
|
|
35
|
+
|
|
36
|
+
const articles: ReadonlyArray<Article> = [
|
|
37
|
+
{
|
|
38
|
+
id: 'model-is-the-cache',
|
|
39
|
+
title: 'The Model Is the Cache',
|
|
40
|
+
excerpt: 'Why a single source of truth needs no query client.',
|
|
41
|
+
author: 'Maya Okafor',
|
|
42
|
+
body: 'A cache is a place where fetched data lives between requests. In The Elm Architecture that place already exists: the Model. Store each query as a small state machine and every view reads the same truth.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'stale-while-revalidate',
|
|
46
|
+
title: 'Stale-While-Revalidate, Explained',
|
|
47
|
+
excerpt: 'Show the old data while the new data loads.',
|
|
48
|
+
author: 'Theo Lindqvist',
|
|
49
|
+
body: 'Dropping back to a spinner throws away perfectly good data. A Refreshing state carries the previous value while the fetch runs, so the screen never goes blank.',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'query-keys-are-names',
|
|
53
|
+
title: 'Query Keys Are Just Names',
|
|
54
|
+
excerpt: 'A Model field per query replaces stringly-typed keys.',
|
|
55
|
+
author: 'Priya Raman',
|
|
56
|
+
body: 'When queries are known statically, the field name is the key. Reach for a HashMap keyed by a domain identifier only when the entries are genuinely dynamic, like these post details.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'refresh-is-a-message',
|
|
60
|
+
title: 'Refresh Is a Message',
|
|
61
|
+
excerpt: 'Requesting newer data is a fact, not hidden framework policy.',
|
|
62
|
+
author: 'Jonas Weber',
|
|
63
|
+
body: 'Dispatch a Message, move the entry to Refreshing, and return the fetch Command. The old value stays available while the request runs, and the whole policy remains visible in update.',
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
const posts = [
|
|
68
|
+
...Array.map(articles, ({ id, title, excerpt }) =>
|
|
69
|
+
Post.make({ id, title, excerpt }),
|
|
70
|
+
),
|
|
71
|
+
Post.make({
|
|
72
|
+
id: UNAVAILABLE_POST_ID,
|
|
73
|
+
title: 'This Post Is Unavailable',
|
|
74
|
+
excerpt: 'Open it to see the Failure and Retry states.',
|
|
75
|
+
}),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
const postDetails = Array.map(articles, ({ id, title, author, body }) =>
|
|
79
|
+
PostDetail.make({ id, title, author, body }),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
export const fetchPosts = Effect.gen(function* () {
|
|
83
|
+
yield* Effect.sleep(SERVER_LATENCY)
|
|
84
|
+
|
|
85
|
+
return posts
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const fetchPostDetail = (
|
|
89
|
+
postId: string,
|
|
90
|
+
): Effect.Effect<PostDetail, string> =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
yield* Effect.sleep(SERVER_LATENCY)
|
|
93
|
+
|
|
94
|
+
return yield* Option.match(
|
|
95
|
+
Array.findFirst(postDetails, ({ id }) => id === postId),
|
|
96
|
+
{
|
|
97
|
+
onNone: () =>
|
|
98
|
+
Effect.fail('This post is unavailable. You can try again.'),
|
|
99
|
+
onSome: Effect.succeed,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
export const fetchStats = Effect.gen(function* () {
|
|
105
|
+
yield* Effect.sleep(SERVER_LATENCY)
|
|
106
|
+
|
|
107
|
+
const activeUsers = yield* Random.nextIntBetween(80, 140)
|
|
108
|
+
const requestsPerSecond = yield* Random.nextIntBetween(900, 1600)
|
|
109
|
+
const cacheHitRatePercent = yield* Random.nextIntBetween(86, 99)
|
|
110
|
+
|
|
111
|
+
return Stats.make({ activeUsers, requestsPerSecond, cacheHitRatePercent })
|
|
112
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Runtime } from 'foldkit'
|
|
2
|
+
|
|
3
|
+
import { Message, Model, init, subscriptions, update, view } from './main'
|
|
4
|
+
|
|
5
|
+
const application = Runtime.makeApplication({
|
|
6
|
+
Model,
|
|
7
|
+
init,
|
|
8
|
+
update,
|
|
9
|
+
view,
|
|
10
|
+
subscriptions,
|
|
11
|
+
container: document.getElementById('root'),
|
|
12
|
+
devTools: {
|
|
13
|
+
Message,
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
Runtime.run(application)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Option, Result } from 'effect'
|
|
2
|
+
import { modifyFields } from 'foldkit/struct'
|
|
3
|
+
|
|
4
|
+
import { Tabs } from '@foldkit/ui'
|
|
5
|
+
|
|
6
|
+
import type { Post, PostDetail, Stats } from './data'
|
|
7
|
+
import { Model, TABS_ID, postQuery, postsQuery, statsQuery } from './main'
|
|
8
|
+
|
|
9
|
+
export const FETCHED_AT = 1_750_000_000_000
|
|
10
|
+
|
|
11
|
+
export const fixturePosts: ReadonlyArray<Post> = [
|
|
12
|
+
{
|
|
13
|
+
id: 'first-post',
|
|
14
|
+
title: 'First Post',
|
|
15
|
+
excerpt: 'The first fixture post.',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'second-post',
|
|
19
|
+
title: 'Second Post',
|
|
20
|
+
excerpt: 'The second fixture post.',
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export const firstPostDetail: PostDetail = {
|
|
25
|
+
id: 'first-post',
|
|
26
|
+
title: 'First Post',
|
|
27
|
+
author: 'Grace Hopper',
|
|
28
|
+
body: 'The whole body of the first fixture post.',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const fixtureStats: Stats = {
|
|
32
|
+
activeUsers: 120,
|
|
33
|
+
requestsPerSecond: 1234,
|
|
34
|
+
cacheHitRatePercent: 97,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const loadingPostsQueryModel = postsQuery.revalidateOrLoad(
|
|
38
|
+
postsQuery.init(),
|
|
39
|
+
).model
|
|
40
|
+
|
|
41
|
+
export const loadingPostsModel = Model.make({
|
|
42
|
+
tabs: Tabs.init({ id: TABS_ID }),
|
|
43
|
+
activeTab: 'Posts',
|
|
44
|
+
posts: loadingPostsQueryModel,
|
|
45
|
+
postDetails: postQuery.init(),
|
|
46
|
+
maybeSelectedPostId: Option.none(),
|
|
47
|
+
stats: statsQuery.init(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const loadedPostsQueryModel = postsQuery.update(
|
|
51
|
+
loadingPostsQueryModel,
|
|
52
|
+
postsQuery.Message.CompletedFetch({
|
|
53
|
+
generation: loadingPostsQueryModel.generation,
|
|
54
|
+
result: Result.succeed({ posts: fixturePosts, fetchedAt: FETCHED_AT }),
|
|
55
|
+
}),
|
|
56
|
+
).model
|
|
57
|
+
|
|
58
|
+
export const loadedPostsModel = modifyFields(loadingPostsModel, {
|
|
59
|
+
posts: () => loadedPostsQueryModel,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const failedPostsQueryModel = postsQuery.update(
|
|
63
|
+
loadingPostsQueryModel,
|
|
64
|
+
postsQuery.Message.CompletedFetch({
|
|
65
|
+
generation: loadingPostsQueryModel.generation,
|
|
66
|
+
result: Result.fail('The server is down.'),
|
|
67
|
+
}),
|
|
68
|
+
).model
|
|
69
|
+
|
|
70
|
+
export const failedPostsModel = modifyFields(loadingPostsModel, {
|
|
71
|
+
posts: () => failedPostsQueryModel,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const firstPostArgs = { postId: 'first-post' }
|
|
75
|
+
const loadingFirstPostQueryModel = postQuery.loadIfMissing(
|
|
76
|
+
postQuery.init(),
|
|
77
|
+
firstPostArgs,
|
|
78
|
+
).model
|
|
79
|
+
const loadedFirstPostQueryModel = postQuery.update(
|
|
80
|
+
loadingFirstPostQueryModel,
|
|
81
|
+
postQuery.Message.CompletedFetch({
|
|
82
|
+
args: firstPostArgs,
|
|
83
|
+
generation: loadingFirstPostQueryModel.generation,
|
|
84
|
+
result: Result.succeed({
|
|
85
|
+
post: firstPostDetail,
|
|
86
|
+
fetchedAt: FETCHED_AT,
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
).model
|
|
90
|
+
|
|
91
|
+
export const cachedFirstPostModel = modifyFields(loadedPostsModel, {
|
|
92
|
+
postDetails: () => loadedFirstPostQueryModel,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const failedFirstPostQueryModel = postQuery.update(
|
|
96
|
+
loadingFirstPostQueryModel,
|
|
97
|
+
postQuery.Message.CompletedFetch({
|
|
98
|
+
args: firstPostArgs,
|
|
99
|
+
generation: loadingFirstPostQueryModel.generation,
|
|
100
|
+
result: Result.fail('The connection dropped.'),
|
|
101
|
+
}),
|
|
102
|
+
).model
|
|
103
|
+
|
|
104
|
+
export const failedFirstPostModel = modifyFields(loadedPostsModel, {
|
|
105
|
+
postDetails: () => failedFirstPostQueryModel,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const loadingStatsQueryModel = statsQuery.revalidateOrLoad(
|
|
109
|
+
statsQuery.init(),
|
|
110
|
+
).model
|
|
111
|
+
|
|
112
|
+
export const loadingStatsModel = modifyFields(loadedPostsModel, {
|
|
113
|
+
activeTab: () => 'Stats',
|
|
114
|
+
stats: () => loadingStatsQueryModel,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const loadedStatsQueryModel = statsQuery.update(
|
|
118
|
+
loadingStatsQueryModel,
|
|
119
|
+
statsQuery.Message.CompletedFetch({
|
|
120
|
+
generation: loadingStatsQueryModel.generation,
|
|
121
|
+
result: Result.succeed({ stats: fixtureStats, fetchedAt: FETCHED_AT }),
|
|
122
|
+
}),
|
|
123
|
+
).model
|
|
124
|
+
|
|
125
|
+
export const loadedStatsModel = modifyFields(loadedPostsModel, {
|
|
126
|
+
activeTab: () => 'Stats',
|
|
127
|
+
stats: () => loadedStatsQueryModel,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const refreshingStatsQueryModel = statsQuery.revalidate(
|
|
131
|
+
loadedStatsQueryModel,
|
|
132
|
+
).model
|
|
133
|
+
|
|
134
|
+
export const refreshingStatsModel = modifyFields(loadedStatsModel, {
|
|
135
|
+
stats: () => refreshingStatsQueryModel,
|
|
136
|
+
})
|