comwit 0.0.1 → 0.0.2
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/LICENSE +21 -0
- package/README.md +297 -1
- package/package.json +22 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 meursyphus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1 +1,297 @@
|
|
|
1
|
-
|
|
1
|
+
# comwit
|
|
2
|
+
|
|
3
|
+
State management for Next.js.
|
|
4
|
+
|
|
5
|
+
> Give this document to Claude Code. It sets up your project.
|
|
6
|
+
|
|
7
|
+
## 1. Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i comwit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 2. Setup Provider
|
|
14
|
+
|
|
15
|
+
Create `app/providers.tsx` and wrap your root layout.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
// app/providers.tsx
|
|
19
|
+
'use client'
|
|
20
|
+
|
|
21
|
+
import { useRouter } from 'next/navigation'
|
|
22
|
+
import { keepPreviousData, MuchaProvider } from 'comwit'
|
|
23
|
+
import { ReactNode } from 'react'
|
|
24
|
+
|
|
25
|
+
type AppContext = {
|
|
26
|
+
router: { push: (href: string) => void }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
30
|
+
const router = useRouter()
|
|
31
|
+
const context: AppContext = { router }
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<MuchaProvider
|
|
35
|
+
context={context}
|
|
36
|
+
defaultOptions={{
|
|
37
|
+
query: {
|
|
38
|
+
staleTime: 30_000,
|
|
39
|
+
cacheTime: 120_000,
|
|
40
|
+
gcTime: 180_000,
|
|
41
|
+
placeholderData: keepPreviousData,
|
|
42
|
+
},
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</MuchaProvider>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// app/layout.tsx
|
|
53
|
+
import { Providers } from './providers'
|
|
54
|
+
|
|
55
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
56
|
+
return (
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<body>
|
|
59
|
+
<Providers>{children}</Providers>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 3. Create `state/.ai.md`
|
|
67
|
+
|
|
68
|
+
Create `src/state/.ai.md` (or `state/.ai.md`) with the full content below.
|
|
69
|
+
|
|
70
|
+
## 4. Update `CLAUDE.md`
|
|
71
|
+
|
|
72
|
+
Add this to your project's `CLAUDE.md`:
|
|
73
|
+
|
|
74
|
+
```md
|
|
75
|
+
## State Management
|
|
76
|
+
|
|
77
|
+
This project uses comwit. When working on files in state/, always read state/.ai.md first.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## state/.ai.md Content
|
|
83
|
+
|
|
84
|
+
Copy everything between the ````fences into your`state/.ai.md`:
|
|
85
|
+
|
|
86
|
+
````md
|
|
87
|
+
## Structure
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
state/{domain}/
|
|
91
|
+
├── types.ts # State + Actions types
|
|
92
|
+
├── model.ts # model() with initial state
|
|
93
|
+
├── actions/
|
|
94
|
+
│ ├── crud.ts # CRUD operations
|
|
95
|
+
│ ├── load.ts # Data fetching via query()
|
|
96
|
+
│ ├── init.ts # SSR hydration via silent()
|
|
97
|
+
│ └── ... # One file per concern
|
|
98
|
+
└── index.ts # create() hook + re-exports
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Write order: **types.ts -> model.ts -> actions/\*.ts -> index.ts**
|
|
102
|
+
|
|
103
|
+
## Rules
|
|
104
|
+
|
|
105
|
+
- Dependencies: pages → state → api (one way)
|
|
106
|
+
- Pass domain objects whole: `<Card post={post} />`
|
|
107
|
+
|
|
108
|
+
## File Templates
|
|
109
|
+
|
|
110
|
+
### types.ts
|
|
111
|
+
|
|
112
|
+
**Read this file first.** JSDoc on each field/method guides the implementation.
|
|
113
|
+
|
|
114
|
+
- `Query<Data>` for client-fetched fields — provides `.data` `.isLoading` `.isError` `.error`
|
|
115
|
+
- `Query<Data, Arg>` — second generic = queryFn param type
|
|
116
|
+
- Plain types for SSR-hydrated fields
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { Query } from 'comwit'
|
|
120
|
+
|
|
121
|
+
export type Post = { id: string; ... }
|
|
122
|
+
|
|
123
|
+
export type PostState = {
|
|
124
|
+
posts: Query<Post[]> // client fetch
|
|
125
|
+
comments: Query<Comment[], string> // client fetch, second generic = queryFn arg
|
|
126
|
+
current: Post | null // SSR hydrated
|
|
127
|
+
// ...
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type PostActions = {
|
|
131
|
+
/** @description Fetch posts via query() */
|
|
132
|
+
loadPosts(): Promise<void>
|
|
133
|
+
/** @description Toggle like. Optimistic on list + current, toast on error */
|
|
134
|
+
like(postId: string): Promise<void>
|
|
135
|
+
// ...
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### model.ts
|
|
140
|
+
|
|
141
|
+
`Query<T>` fields use `query()` with `initialData` + `queryFn`. Plain fields get default values.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { model, query } from 'comwit'
|
|
145
|
+
|
|
146
|
+
export const post = model<PostState>({
|
|
147
|
+
posts: query<Post[]>({ initialData: [], queryFn: () => api.post.findAll() }),
|
|
148
|
+
comments: query<Comment[], string>({
|
|
149
|
+
initialData: [],
|
|
150
|
+
queryFn: (postId) => api.comment.findAll(postId),
|
|
151
|
+
}),
|
|
152
|
+
current: null,
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### actions/\*.ts
|
|
157
|
+
|
|
158
|
+
Actions are self-contained — resolve state via `state()`, side effects via decorators/context. UI only calls.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { action, silent, OnError, OnSuccess } from 'comwit'
|
|
162
|
+
import { user } from '@/state/user/model'
|
|
163
|
+
|
|
164
|
+
export const postActions = action<Pick<PostActions, '...'>, AppContext>(({ state, context }) => {
|
|
165
|
+
class PostActions {
|
|
166
|
+
private model = state(post)
|
|
167
|
+
private user = state(user) // cross-domain — read other model directly
|
|
168
|
+
|
|
169
|
+
// SSR hydrate — silent() suppresses re-render
|
|
170
|
+
init(data: Post) {
|
|
171
|
+
silent(() => {
|
|
172
|
+
this.model.current = data
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async loadPosts() {
|
|
177
|
+
await this.model.posts.query()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// guard: check auth from user domain, not from params
|
|
181
|
+
@OnSuccess(() => context.router.push('/posts'))
|
|
182
|
+
@OnError((e) => toast.error(e instanceof Error ? e.message : 'Failed'))
|
|
183
|
+
async create(title: string) {
|
|
184
|
+
if (!this.user.me) return
|
|
185
|
+
const created = await api.post.create({ userId: this.user.me.id, title })
|
|
186
|
+
this.model.posts.data.push(created)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return new PostActions()
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The pattern above can be automated with `createInterceptor`. See [Decorators](#decorators) below.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// reusable guard — state() must be initialized in the factory body
|
|
197
|
+
const LoginRequired = createInterceptor<AppContext>(({ state, context }) => {
|
|
198
|
+
const u = state(user)
|
|
199
|
+
return onAuthorized({
|
|
200
|
+
when: () => Boolean(u.me),
|
|
201
|
+
onDeny: () => context.router.push('/login'),
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// replaces the manual if (!this.user.me) check above:
|
|
206
|
+
@LoginRequired
|
|
207
|
+
@OnSuccess(() => context.router.push('/posts'))
|
|
208
|
+
async create(title: string) { /* ... */ }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
// call init outside useEffect — silent() makes it safe
|
|
213
|
+
function PostDetail({ initialPost }: { initialPost: Post }) {
|
|
214
|
+
const { actions } = usePost((s) => ({ actions: s.actions }))
|
|
215
|
+
actions.init(initialPost)
|
|
216
|
+
return <Article />
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### index.ts
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { create } from 'comwit'
|
|
224
|
+
import type { PostState, PostActions } from './types'
|
|
225
|
+
import { post } from './model'
|
|
226
|
+
import { postActions } from './actions/crud'
|
|
227
|
+
|
|
228
|
+
export const usePost = create<PostState, PostActions>(post, {
|
|
229
|
+
actions: [postActions /* ... */],
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## query() Reference
|
|
234
|
+
|
|
235
|
+
Methods: `.query(arg?)` · `.refetch()` · `.set(data)` · `.nextFetch()` · `.previousFetch()`
|
|
236
|
+
Flags: `isLoading` · `isFetching` · `isSuccess` · `isError` · `error`
|
|
237
|
+
Infinite-only flags: `cursor` · `hasMore` (nextFetch is no-op when `hasMore` is false)
|
|
238
|
+
Options: `staleTime` · `cacheTime` · `gcTime` · `placeholderData` · `force`
|
|
239
|
+
|
|
240
|
+
`queryFn` receives `(arg, context)` where `context.state` is a readonly snapshot of the current query state (includes `data`, `cursor`, etc.).
|
|
241
|
+
|
|
242
|
+
Infinite scroll — use `Query.Infinite<T>` + `query.infinite()`:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// types
|
|
246
|
+
trending: Query.Infinite<Post[]>
|
|
247
|
+
|
|
248
|
+
// model — cursor-based pagination
|
|
249
|
+
trending: query.infinite<Post[]>({
|
|
250
|
+
initialData: [],
|
|
251
|
+
queryFn: (_, { state }) => api.post.trending(state.cursor),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// action
|
|
255
|
+
async loadMoreTrending() { await this.model.trending.nextFetch() }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Usage
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
const { posts, current, actions } = usePost((s) => ({
|
|
262
|
+
posts: s.posts, // Query<Post[]> — has .data .isLoading .isError
|
|
263
|
+
current: s.current, // Post | null — plain state
|
|
264
|
+
actions: s.actions,
|
|
265
|
+
}))
|
|
266
|
+
|
|
267
|
+
// query fields — check flags, access data via .data
|
|
268
|
+
if (posts.isLoading) return <Skeleton />
|
|
269
|
+
if (posts.isError) return <p>Error: {posts.error}</p>
|
|
270
|
+
return posts.data.map((post) => <Card key={post.id} post={post} />)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Selectors use deep equality — only re-renders when selected values change.
|
|
274
|
+
|
|
275
|
+
## Decorators
|
|
276
|
+
|
|
277
|
+
All from `'comwit'`. Stack on class methods.
|
|
278
|
+
|
|
279
|
+
| Decorator | Purpose |
|
|
280
|
+
| ------------------------------- | ----------------------------------------------------- |
|
|
281
|
+
| `@OnError(fn)` | Error handler. Re-throw to propagate. |
|
|
282
|
+
| `@OnSuccess(fn)` | Success callback. |
|
|
283
|
+
| `@Debounce(ms)` | Debounce. |
|
|
284
|
+
| `@Throttle(ms)` | Throttle. |
|
|
285
|
+
| `@Authorized({ when, onDeny })` | Auth guard. `when: () => boolean \| Promise<boolean>` |
|
|
286
|
+
|
|
287
|
+
`createInterceptor(({ state, context }) => MethodDecorator)` — reusable decorator with `state`/`context` access, like action factories. See [actions](#actionsts) above for usage.
|
|
288
|
+
|
|
289
|
+
## Key Concepts
|
|
290
|
+
|
|
291
|
+
- `model(initial)` — global reactive store
|
|
292
|
+
- `action(factory)` — factory with `state`/`context` access
|
|
293
|
+
- `create(model, { actions })` — model + actions -> React hook
|
|
294
|
+
- `silent(fn)` — suppress re-renders (SSR)
|
|
295
|
+
- `query()` / `query.infinite()` — client fetch in model
|
|
296
|
+
- `state(model)` — mutable proxy in actions
|
|
297
|
+
````
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comwit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"homepage": "https://comwit.io",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,25 @@
|
|
|
14
14
|
"access": "public"
|
|
15
15
|
},
|
|
16
16
|
"type": "module",
|
|
17
|
-
"description": "
|
|
17
|
+
"description": "React state management for vibe coding. Less tokens. Built for Claude Code.",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react",
|
|
20
|
+
"state-management",
|
|
21
|
+
"vibe-coding",
|
|
22
|
+
"claude-code",
|
|
23
|
+
"llm",
|
|
24
|
+
"next.js",
|
|
25
|
+
"valtio",
|
|
26
|
+
"zustand",
|
|
27
|
+
"tanstack-query",
|
|
28
|
+
"oop",
|
|
29
|
+
"proxy"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "meursyphus",
|
|
18
36
|
"main": "dist/comwit.cjs",
|
|
19
37
|
"module": "dist/comwit.js",
|
|
20
38
|
"types": "dist/index.d.ts",
|
|
@@ -27,7 +45,8 @@
|
|
|
27
45
|
},
|
|
28
46
|
"files": [
|
|
29
47
|
"dist",
|
|
30
|
-
"README.md"
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
31
50
|
],
|
|
32
51
|
"scripts": {
|
|
33
52
|
"dev": "vite build --watch",
|