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 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
+ })