@danieljvdm/dev-kit 0.15.0 → 0.16.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 +73 -7
- package/package.json +1 -1
- package/skills/build-effect-apis/SKILL.md +13 -31
- package/skills/build-effect-apis/references/verification.md +3 -3
- package/skills/effect-atom-state/SKILL.md +97 -0
- package/skills/effect-atom-state/agents/openai.yaml +4 -0
- package/skills/effect-atom-state/references/effect-atom-workflows.md +180 -0
- package/src/bin/dev-kit.ts +21 -0
- package/src/catalog.ts +39 -15
- package/src/effect-source.ts +70 -4
- package/src/global-cache.ts +304 -0
- package/src/oxlint.js +23 -0
- package/src/oxlint.ts +37 -1
- package/src/project-package.ts +77 -13
- package/src/sync.ts +36 -2
- package/src/vite-plus.js +8 -1
- package/src/vite-plus.ts +15 -1
- /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-client.md +0 -0
- /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-lifecycle.md +0 -0
- /package/skills/{build-effect-apis → effect-atom-state}/references/effect-atom-testing.md +0 -0
- /package/skills/{build-effect-apis → effect-atom-state}/references/tanstack-start.md +0 -0
package/README.md
CHANGED
|
@@ -137,6 +137,7 @@ the root package itself.
|
|
|
137
137
|
| `dev-kit gitignore` | Add `.repos/` and `.dev-kit/` to `.gitignore`. |
|
|
138
138
|
| `dev-kit effect sync` | Sync `.repos/effect` to the installed Effect version. |
|
|
139
139
|
| `dev-kit tsgo patch` | Validate and patch Effect TypeScript-Go directly. |
|
|
140
|
+
| `dev-kit cache prune` | Evict stale machine-global cache content (`--all` wipes). |
|
|
140
141
|
| `dev-kit catalog refresh` | Maintainer command to approve current upstream refs. |
|
|
141
142
|
| `dev-kit catalog add <repository>` | Inspect a repository and approve selected skills. |
|
|
142
143
|
| `dev-kit catalog remove <source-or-skill>` | Revoke an approval (`--yes` outside a terminal). |
|
|
@@ -200,7 +201,8 @@ tool versions. A project-local process lock also prevents concurrent applies.
|
|
|
200
201
|
|
|
201
202
|
- `dev-kit` installs guidance for operating the toolkit itself.
|
|
202
203
|
- `effect` is a built-in family: the `effect-ts` bootstrap,
|
|
203
|
-
`effect-architecture-audit`, `build-effect-apis`,
|
|
204
|
+
`effect-architecture-audit`, `build-effect-apis`, `effect-atom-state`, and
|
|
205
|
+
`build-effect-clis`.
|
|
204
206
|
- External Git skills (`workers-best-practices`, `serve-sim`, …) are selected
|
|
205
207
|
individually after scanning the project for relevant technologies. An
|
|
206
208
|
approved source ID selects every skill from that source; use it only when
|
|
@@ -363,6 +365,53 @@ The scaffolded workflow performs one frozen, script-suppressed install, runs
|
|
|
363
365
|
[Vite Task run guide](https://viteplus.dev/guide/run) when maintaining the
|
|
364
366
|
templates.
|
|
365
367
|
|
|
368
|
+
### Absolute (path-alias) imports
|
|
369
|
+
|
|
370
|
+
Opt into enforcing path-alias imports for selected app sources. The factory
|
|
371
|
+
appends an Oxlint override that errors on `../` imports inside the given
|
|
372
|
+
globs, so those files import through tsconfig path aliases such as `@/*`:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
export default defineConfig(
|
|
376
|
+
createRecommendedVitePlusConfig({
|
|
377
|
+
absoluteImports: {
|
|
378
|
+
files: ["apps/app/src/**/*.{ts,tsx}", "apps/mobile/src/**/*.{ts,tsx}"],
|
|
379
|
+
},
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The lint rule only forbids relative parent imports; each covered app must map
|
|
385
|
+
the alias itself. Add the `paths` entry to the app's `tsconfig.json`:
|
|
386
|
+
|
|
387
|
+
```jsonc
|
|
388
|
+
{
|
|
389
|
+
"compilerOptions": {
|
|
390
|
+
"paths": { "@/*": ["./src/*"] },
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Vite-built apps also resolve the alias from the same tsconfig by setting
|
|
396
|
+
`resolve: { tsconfigPaths: true }` in the app's Vite config. Expo apps need no
|
|
397
|
+
Metro configuration: Expo SDK 49+ resolves tsconfig `paths` by default.
|
|
398
|
+
|
|
399
|
+
Standalone Oxlint projects compose the same override directly:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
// oxlint.config.ts
|
|
403
|
+
import {
|
|
404
|
+
createAbsoluteImportsOxlintOverride,
|
|
405
|
+
recommendedOxlintConfig,
|
|
406
|
+
} from "@danieljvdm/dev-kit/oxlint";
|
|
407
|
+
import { defineConfig } from "oxlint";
|
|
408
|
+
|
|
409
|
+
export default defineConfig({
|
|
410
|
+
extends: [recommendedOxlintConfig],
|
|
411
|
+
overrides: [createAbsoluteImportsOxlintOverride({ files: ["src/**/*.{ts,tsx}"] })],
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
366
415
|
## Worktrunk project config
|
|
367
416
|
|
|
368
417
|
Enable a scaffolded default [Worktrunk](https://worktrunk.dev) project config
|
|
@@ -413,9 +462,12 @@ Enable a local checkout of the exact installed Effect release in the manifest:
|
|
|
413
462
|
```
|
|
414
463
|
|
|
415
464
|
`dev-kit apply` reads `node_modules/effect/package.json`, then shallow-clones or
|
|
416
|
-
updates `.repos/effect` to the detached `effect@<version>` tag.
|
|
417
|
-
|
|
418
|
-
|
|
465
|
+
updates `.repos/effect` to the detached `effect@<version>` tag. Tag fetches go
|
|
466
|
+
through a shared clone in the machine-global cache, so a new worktree or
|
|
467
|
+
project reuses tags already cached on the machine and only contacts the
|
|
468
|
+
network for a tag the cache has never seen. It skips the checkout in CI,
|
|
469
|
+
leaves the repository in place when the task is disabled, and refuses to
|
|
470
|
+
switch a checkout with local changes or an unexpected origin.
|
|
419
471
|
|
|
420
472
|
The path, package name, and repository URL may be overridden for compatible
|
|
421
473
|
Effect package layouts. Use `dev-kit effect sync --dry-run` to inspect this
|
|
@@ -587,9 +639,23 @@ names and paths, rejects symlinks and collisions, extracts descriptions, and
|
|
|
587
639
|
updates `skill-sources.lock.json`.
|
|
588
640
|
|
|
589
641
|
When a project selects one of these Git-backed skills, Dev Kit fetches the
|
|
590
|
-
approved commit into
|
|
591
|
-
|
|
592
|
-
|
|
642
|
+
approved commit into a machine-global cache and installs it through the same
|
|
643
|
+
ownership-safe sync path. Cache entries are keyed by resolved commit SHA, so
|
|
644
|
+
every project and git worktree on the machine shares one download; a warm
|
|
645
|
+
`dev-kit plan` or `dev-kit apply` performs no network operations. The cache
|
|
646
|
+
lives in `$XDG_CACHE_HOME/dev-kit` (falling back to `~/Library/Caches/dev-kit`
|
|
647
|
+
on macOS and `~/.cache/dev-kit` elsewhere) and may be overridden with
|
|
648
|
+
`DEV_KIT_CACHE_DIR`; it is safe to delete at any time. Populating this
|
|
649
|
+
immutable commit-keyed cache is not project state, so planning and `--locked`
|
|
650
|
+
verification use it too. Only a reviewed catalog refresh changes the approved
|
|
651
|
+
Git content.
|
|
652
|
+
|
|
653
|
+
The cache does not grow without bounds: every use refreshes a recency stamp,
|
|
654
|
+
and `dev-kit apply` sweeps content unused for 30 days at most once a day.
|
|
655
|
+
Shared Effect repositories drop unused tags individually and are removed whole
|
|
656
|
+
once empty. Run `dev-kit cache prune` to sweep on demand (`--max-age-days` to
|
|
657
|
+
tune the threshold, `--all` to clear the cache entirely); an evicted entry is
|
|
658
|
+
simply fetched again the next time a project needs it.
|
|
593
659
|
|
|
594
660
|
## Oxlint and Oxfmt configurations
|
|
595
661
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: build-effect-apis
|
|
3
|
-
description: Build
|
|
3
|
+
description: Build contract-first Effect HTTP APIs. Use when defining shared HttpApiEndpoint/HttpApiGroup contracts, implementing HttpApiBuilder or HttpApiServer handlers and middleware, assembling server runtimes and OpenAPI docs, or serving on Cloudflare Workers/effect-cf. For consuming an API from client state, use $effect-atom-state.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Build Effect APIs
|
|
@@ -9,12 +9,11 @@ Treat the shared `HttpApi` value as the **contract spine**: schemas, server,
|
|
|
9
9
|
OpenAPI, and clients all derive from it. Keep transport contracts isomorphic;
|
|
10
10
|
keep runtime behavior in handlers, services, layers, and client state modules.
|
|
11
11
|
|
|
12
|
-
Effect HTTP
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from another version.
|
|
12
|
+
Effect HTTP APIs are version-sensitive. Read the target repository's manifests
|
|
13
|
+
and lockfile, inspect its existing imports, and confirm exact signatures from
|
|
14
|
+
the installed package declarations before editing. In current Effect v4 betas
|
|
15
|
+
the server module is `HttpApiBuilder`; a request that mentions `HttpApiServer`
|
|
16
|
+
may refer to the same server-building responsibility from another version.
|
|
18
17
|
|
|
19
18
|
## Build the contract spine
|
|
20
19
|
|
|
@@ -31,35 +30,21 @@ from another version.
|
|
|
31
30
|
decoded boundary adapters into application services and assemble all
|
|
32
31
|
requirements at the runtime edge. Finish when each endpoint identifier has
|
|
33
32
|
exactly one handler and every declared middleware has a provided layer.
|
|
34
|
-
4.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
atoms, and one reactivity-key vocabulary.
|
|
40
|
-
- For non-React Effect code, use the direct `HttpApiClient` branch in
|
|
41
|
-
[effect-atom-client.md](references/effect-atom-client.md).
|
|
42
|
-
|
|
43
|
-
Finish when consumers call the shared contract rather than redefining wire
|
|
44
|
-
types or using ad hoc `fetch` for declared endpoints.
|
|
45
|
-
|
|
33
|
+
4. Route consumer changes through the `$effect-atom-state` skill: deriving
|
|
34
|
+
`AtomHttpApi` or direct `HttpApiClient` clients, query and mutation atoms,
|
|
35
|
+
reactivity keys, and React integration all live there. Finish when consumers
|
|
36
|
+
call the shared contract rather than redefining wire types or using ad hoc
|
|
37
|
+
`fetch` for declared endpoints.
|
|
46
38
|
5. Read [verification.md](references/verification.md). Run the repository's
|
|
47
39
|
format, lint, typecheck, and test commands. Finish when changed schemas
|
|
48
|
-
round-trip, middleware failures use declared error channels, server and
|
|
49
|
-
client agree on every request shape
|
|
50
|
-
deterministic coverage.
|
|
40
|
+
round-trip, middleware failures use declared error channels, and server and
|
|
41
|
+
client agree on every request shape.
|
|
51
42
|
|
|
52
43
|
## Optional branches
|
|
53
44
|
|
|
54
45
|
- Read [runtime-assembly.md](references/runtime-assembly.md) when wiring a
|
|
55
46
|
conventional Node/Bun server, generated API docs, process entrypoint, or
|
|
56
47
|
serverless web handler.
|
|
57
|
-
- Read [effect-atom-testing.md](references/effect-atom-testing.md) when changing
|
|
58
|
-
Atom cache retention, SWR, polling, invalidation, cancellation, aggregation,
|
|
59
|
-
provider placement, SSR, or hydration behavior.
|
|
60
|
-
- Read [tanstack-start.md](references/tanstack-start.md) when the client is
|
|
61
|
-
TanStack Start, SSR, hydration, `ClientOnly`, loaders, server functions, or a
|
|
62
|
-
proxied separate API.
|
|
63
48
|
- Read [cloudflare-workers.md](references/cloudflare-workers.md) when the server
|
|
64
49
|
runs on Cloudflare Workers or uses `effect-cf`, bindings, Durable Objects,
|
|
65
50
|
Queues, WebSockets, streaming, or raw byte routes.
|
|
@@ -94,6 +79,3 @@ but the boundary and type reasoning remain the source of truth.
|
|
|
94
79
|
- Let handlers own transport-to-application mapping and boundary invariants.
|
|
95
80
|
- Let application services own orchestration, persistence, retries, and
|
|
96
81
|
transactions.
|
|
97
|
-
- Let client data modules own API services, query identity, cache policy,
|
|
98
|
-
invalidation keys, and mutation atoms; let UI action owners own navigation,
|
|
99
|
-
toasts, optimistic presentation, and form reset.
|
|
@@ -48,9 +48,9 @@ decoding are skipped.
|
|
|
48
48
|
`HttpApiMiddleware.layerClient` and assert that it transforms the request.
|
|
49
49
|
- Assert params, query, headers, payload, and expected errors at least once for
|
|
50
50
|
every changed request shape.
|
|
51
|
-
- For Atom clients, complete every applicable scenario
|
|
52
|
-
|
|
53
|
-
invalidation, and lifecycle remain observable.
|
|
51
|
+
- For Atom clients, complete every applicable scenario in the
|
|
52
|
+
`$effect-atom-state` skill's testing reference; use a deterministic HTTP
|
|
53
|
+
layer so request encoding, invalidation, and lifecycle remain observable.
|
|
54
54
|
|
|
55
55
|
## Completion matrix
|
|
56
56
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-atom-state
|
|
3
|
+
description: Manage client-side state and server data in React with Effect Atom, which fills the role TanStack/React Query, SWR, Zustand, Redux, or Jotai play in other stacks — use this skill instead of reaching for those libraries or hand-rolled useEffect fetching whenever a component needs shared state, data fetching, caching, mutations, or optimistic updates. Also use when reading or dispatching atoms (useAtomValue, useAtomSet, useAtom), refactoring promise chains or useState-held server state into atom workflows, choosing reactivity keys and invalidation, deriving AtomHttpApi or HttpApiClient clients from a shared contract, deciding where the Effect→Promise boundary sits, or wiring RegistryProvider and SSR with TanStack Start.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Effect Atom State
|
|
7
|
+
|
|
8
|
+
Effect Atom is the client-state and server-data layer in an Effect repository:
|
|
9
|
+
the role other stacks give TanStack Query, SWR, Zustand, Redux, or Jotai.
|
|
10
|
+
Every need that would reach for one of those libraries — or for hand-rolled
|
|
11
|
+
`useEffect` fetching — is atom work; never introduce a second state or query
|
|
12
|
+
library beside it.
|
|
13
|
+
|
|
14
|
+
Business logic stays in Effect for as long as possible. Components read
|
|
15
|
+
`AsyncResult` values and dispatch actions; workflows, optimistic updates, and
|
|
16
|
+
cache invalidation live in atoms, not in promise chains at the React boundary.
|
|
17
|
+
|
|
18
|
+
Effect Atom APIs are version-sensitive. Read the target repository's manifests
|
|
19
|
+
and lockfile, inspect its existing imports, and confirm exact signatures from
|
|
20
|
+
the installed `effect` and `@effect/atom-react` declarations before editing.
|
|
21
|
+
In current Effect v4 betas the atom modules live in
|
|
22
|
+
`effect/unstable/reactivity` and the React hooks in `@effect/atom-react`.
|
|
23
|
+
|
|
24
|
+
## Build the client state graph
|
|
25
|
+
|
|
26
|
+
1. Inventory the existing `RegistryProvider`, runtime factories,
|
|
27
|
+
`AtomHttpApi.Service` clients, query atoms and families, mutation and
|
|
28
|
+
workflow atoms, reactivity-key constructors, state atoms, and promise-mode
|
|
29
|
+
dispatch sites. Finish when every consumer of the affected state is
|
|
30
|
+
identified.
|
|
31
|
+
2. Read [effect-atom-client.md](references/effect-atom-client.md), then derive
|
|
32
|
+
one `AtomHttpApi.Service` per contract, stable query atoms or families,
|
|
33
|
+
mutation atoms, and one reactivity-key vocabulary. For non-React Effect
|
|
34
|
+
code, use the direct `HttpApiClient` branch. Finish when consumers call the
|
|
35
|
+
shared contract rather than redefining wire types or using ad hoc `fetch`
|
|
36
|
+
for declared endpoints.
|
|
37
|
+
3. Read [effect-atom-workflows.md](references/effect-atom-workflows.md), then
|
|
38
|
+
express every multi-step action — mutate then invalidate, optimistic echo
|
|
39
|
+
then rollback — as an `Atom.fn` effect composing other atoms through the fn
|
|
40
|
+
context. Finish when no component or route chains `.then`/`.catch` on a
|
|
41
|
+
dispatch and no `useState` holds state a workflow atom must own.
|
|
42
|
+
4. Read [effect-atom-lifecycle.md](references/effect-atom-lifecycle.md) when
|
|
43
|
+
changing registry scope, atom identity, retention, freshness, polling,
|
|
44
|
+
cancellation, or aggregate stability.
|
|
45
|
+
5. Read [effect-atom-testing.md](references/effect-atom-testing.md), then give
|
|
46
|
+
changed atom behavior deterministic coverage below React first, with a
|
|
47
|
+
deterministic HTTP layer so request encoding, invalidation, and lifecycle
|
|
48
|
+
remain observable. Run the repository's format, lint, typecheck, and test
|
|
49
|
+
commands. Finish when changed queries, mutations, invalidation, and
|
|
50
|
+
workflow atoms have deterministic tests and every repository check passes.
|
|
51
|
+
|
|
52
|
+
## Optional branches
|
|
53
|
+
|
|
54
|
+
- Read [tanstack-start.md](references/tanstack-start.md) when the client is
|
|
55
|
+
TanStack Start, SSR, hydration, `ClientOnly`, loaders, server functions, or a
|
|
56
|
+
proxied separate API.
|
|
57
|
+
- Use the `$build-effect-apis` skill when the change reaches the contract or
|
|
58
|
+
server: shared `HttpApiEndpoint`/`HttpApiGroup` definitions, handlers,
|
|
59
|
+
middleware, or runtime assembly.
|
|
60
|
+
|
|
61
|
+
## Keep the Promise boundary logic-free
|
|
62
|
+
|
|
63
|
+
The Effect→Promise boundary sits at the outermost edge and carries no logic.
|
|
64
|
+
|
|
65
|
+
- Return a promise-mode dispatch (`useAtomSet(action, { mode: "promise" })`)
|
|
66
|
+
bare to a leaf component whose contract is promise-shaped — a pending
|
|
67
|
+
button, a composer that owns its in-flight state. A `.then` or `.catch`
|
|
68
|
+
chain in a component or route is a violation: move that logic into the
|
|
69
|
+
action's Effect.
|
|
70
|
+
- Express multi-step workflows as `Atom.fn` effects composing other atoms
|
|
71
|
+
through the fn context: `get.setResult` awaits another fn atom, `get.set`
|
|
72
|
+
writes state atoms. Reads through the fn context callable are untracked, so
|
|
73
|
+
mutating a state atom from inside the effect cannot re-trigger the workflow.
|
|
74
|
+
- Declare cross-query invalidation as reactivity keys on the mutation; never
|
|
75
|
+
chain a manual refresh at a call site. When several `AtomHttpApi` services
|
|
76
|
+
share one Atom runtime, one `Reactivity` instance spans them, so a mutation
|
|
77
|
+
on one client invalidates another client's query keys.
|
|
78
|
+
- Keep optimistic UI state in `Atom.family` state atoms keyed by the entity,
|
|
79
|
+
not `useState`, so the workflow atom that writes it owns its lifecycle.
|
|
80
|
+
- Genuine view state — controlled inputs, open/closed toggles, reconciling
|
|
81
|
+
optimistic rows against rendered props — stays in React; do not force it
|
|
82
|
+
into Effect.
|
|
83
|
+
|
|
84
|
+
A repository may reinforce the boundary with a lint warning on `then` scoped
|
|
85
|
+
to component and route modules, with a documented local suppression for a
|
|
86
|
+
genuinely promise-shaped contract, but the boundary reasoning remains the
|
|
87
|
+
source of truth.
|
|
88
|
+
|
|
89
|
+
## Boundary rules
|
|
90
|
+
|
|
91
|
+
- Let client data modules own API services, query identity, cache policy,
|
|
92
|
+
invalidation keys, mutation atoms, and workflow atoms.
|
|
93
|
+
- Let workflow atoms own orchestration, optimistic echo, rollback, and
|
|
94
|
+
cross-query invalidation.
|
|
95
|
+
- Let UI action owners own navigation, toasts, form reset, and presentation
|
|
96
|
+
derived from `AsyncResult` state.
|
|
97
|
+
- Let React own view state that no atom needs to write.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Effect Atom workflows
|
|
2
|
+
|
|
3
|
+
Multi-step client actions are Effects composing atoms. Components dispatch
|
|
4
|
+
them and render `AsyncResult` state; no orchestration crosses the React
|
|
5
|
+
boundary. Confirm exact signatures against the installed
|
|
6
|
+
`effect/unstable/reactivity` declarations before copying.
|
|
7
|
+
|
|
8
|
+
- [Define workflow atoms with Atom.fn](#define-workflow-atoms-with-atomfn)
|
|
9
|
+
- [Compose atoms through the fn context](#compose-atoms-through-the-fn-context)
|
|
10
|
+
- [Overlay optimistic query values](#overlay-optimistic-query-values)
|
|
11
|
+
- [Dispatch from React](#dispatch-from-react)
|
|
12
|
+
- [Reinforce the boundary with a lint rule](#reinforce-the-boundary-with-a-lint-rule)
|
|
13
|
+
|
|
14
|
+
## Define workflow atoms with Atom.fn
|
|
15
|
+
|
|
16
|
+
`Atom.fn<Input>()(effect)` creates a writable atom: writing an input runs the
|
|
17
|
+
effect, and the atom's value is the `AsyncResult` of the latest run. The
|
|
18
|
+
effect receives `(input, get: Atom.FnContext)`.
|
|
19
|
+
|
|
20
|
+
Bare `Atom.fn` accepts no reactivity keys. When the workflow needs Effect
|
|
21
|
+
services or invalidation, create it through a runtime factory —
|
|
22
|
+
`AtomHttpApi.Service` exposes its own as `Client.runtime.fn`:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Effect } from "effect";
|
|
26
|
+
import { Atom, Reactivity } from "effect/unstable/reactivity";
|
|
27
|
+
|
|
28
|
+
export const updateProject = ApiClient.runtime.fn(
|
|
29
|
+
Effect.fnUntraced(function* (input: { readonly projectId: ProjectId; readonly patch: Patch }) {
|
|
30
|
+
const client = yield* ApiClient;
|
|
31
|
+
|
|
32
|
+
return yield* Reactivity.mutation(
|
|
33
|
+
client.projects.updateProject({
|
|
34
|
+
params: { projectId: input.projectId },
|
|
35
|
+
payload: input.patch,
|
|
36
|
+
}),
|
|
37
|
+
[...projectKeys.collection, ...projectKeys.project(input.projectId)],
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Prefer `Client.mutation(group, endpoint)` with call-site `reactivityKeys` for a
|
|
44
|
+
single request; wrap the client call in `runtime.fn` with
|
|
45
|
+
`Reactivity.mutation(effect, keys)` when the key set depends on the input or
|
|
46
|
+
the workflow spans several requests. Either way, keys invalidate only when the
|
|
47
|
+
effect succeeds.
|
|
48
|
+
|
|
49
|
+
Invalidation spans clients: every `AtomHttpApi.Service` built on the same
|
|
50
|
+
runtime factory shares one `Reactivity` instance, so a mutation on one client
|
|
51
|
+
invalidates query keys registered by another. A service given its own
|
|
52
|
+
`Atom.context()` gets a separate `Reactivity` and cannot invalidate the rest —
|
|
53
|
+
share one runtime factory across API services on purpose.
|
|
54
|
+
|
|
55
|
+
## Compose atoms through the fn context
|
|
56
|
+
|
|
57
|
+
Inside the effect, the fn context is the composition surface:
|
|
58
|
+
|
|
59
|
+
- `get.setResult(fnAtom, input)` writes another fn atom and returns an Effect
|
|
60
|
+
of its settled result — the await-another-workflow primitive;
|
|
61
|
+
- `get.set(stateAtom, value)` writes a state atom;
|
|
62
|
+
- `get(atom)` reads the current value **untracked** — the workflow never
|
|
63
|
+
subscribes, so mutating a state atom from inside the effect cannot
|
|
64
|
+
re-trigger it.
|
|
65
|
+
|
|
66
|
+
Optimistic echo with rollback, keyed by the owning entity so the atoms
|
|
67
|
+
dispose with it:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { Effect, Exit } from "effect";
|
|
71
|
+
|
|
72
|
+
interface PendingComment {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly body: string;
|
|
75
|
+
readonly status: "sending" | "queued";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const pendingCommentsAtom = Atom.family((_projectId: ProjectId) =>
|
|
79
|
+
Atom.make<ReadonlyArray<PendingComment>>([]),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const sendCommentWithEcho = Atom.family((projectId: ProjectId) =>
|
|
83
|
+
Atom.fn<{ readonly body: string }>()(
|
|
84
|
+
Effect.fnUntraced(function* (input, get) {
|
|
85
|
+
const pending = pendingCommentsAtom(projectId);
|
|
86
|
+
const id = yield* Effect.sync(() => crypto.randomUUID());
|
|
87
|
+
|
|
88
|
+
get.set(pending, [...get(pending), { id, body: input.body, status: "sending" }]);
|
|
89
|
+
|
|
90
|
+
const exit = yield* Effect.exit(get.setResult(sendComment, { projectId, body: input.body }));
|
|
91
|
+
|
|
92
|
+
get.set(
|
|
93
|
+
pending,
|
|
94
|
+
Exit.isSuccess(exit)
|
|
95
|
+
? get(pending).map((entry) =>
|
|
96
|
+
entry.id === id ? { ...entry, status: "queued" as const } : entry,
|
|
97
|
+
)
|
|
98
|
+
: get(pending).filter((entry) => entry.id !== id),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
yield* exit;
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The pending list lives in an `Atom.family` state atom, not component
|
|
108
|
+
`useState`, so the workflow owns append, settle, and rollback while any
|
|
109
|
+
component can render it. Re-raise the exit so the dispatching leaf still
|
|
110
|
+
observes failure.
|
|
111
|
+
|
|
112
|
+
## Overlay optimistic query values
|
|
113
|
+
|
|
114
|
+
When the optimistic value is the query's own value rather than a sidecar list,
|
|
115
|
+
wrap the query with `Atom.optimistic` and drive it with `Atom.optimisticFn`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { AsyncResult } from "effect/unstable/reactivity";
|
|
119
|
+
|
|
120
|
+
export const timelineAtom = Atom.family((projectId: ProjectId) =>
|
|
121
|
+
Atom.optimistic(timelineQuery(projectId)),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
export const sendMessage = Atom.family((projectId: ProjectId) =>
|
|
125
|
+
Atom.optimisticFn(timelineAtom(projectId), {
|
|
126
|
+
reducer: (current, message: MessageDto) =>
|
|
127
|
+
AsyncResult.map(current, (page) => ({
|
|
128
|
+
...page,
|
|
129
|
+
messages: [...page.messages, message],
|
|
130
|
+
})),
|
|
131
|
+
fn: submitMessage(projectId),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The reducer computes the provisional value shown while the mutation runs; a
|
|
137
|
+
successful transition refreshes the source query, and a failure rolls the
|
|
138
|
+
value back to the latest source value. Consumers render `timelineAtom` and
|
|
139
|
+
never see the seam.
|
|
140
|
+
|
|
141
|
+
## Dispatch from React
|
|
142
|
+
|
|
143
|
+
A promise-mode dispatch is handed bare to a leaf component whose contract is
|
|
144
|
+
promise-shaped; presentation derives from the atom's `AsyncResult`:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
const [sendResult, send] = useAtom(sendMessage(projectId), { mode: "promise" });
|
|
148
|
+
const hint = AsyncResult.isSuccess(sendResult) && !sendResult.waiting ? "sent" : undefined;
|
|
149
|
+
|
|
150
|
+
return <Composer hint={hint} onSubmit={(body) => send({ body })} />;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The returned promise resolves with the success value and rejects with the
|
|
154
|
+
squashed failure cause; use `mode: "promiseExit"` when the leaf needs the full
|
|
155
|
+
`Exit`. Anything more than returning the promise — chaining a refresh, echo
|
|
156
|
+
bookkeeping, sequencing a second mutation — belongs in the workflow atom.
|
|
157
|
+
|
|
158
|
+
## Reinforce the boundary with a lint rule
|
|
159
|
+
|
|
160
|
+
A repository can back the logic-free boundary with a lint warning on `then`
|
|
161
|
+
scoped to component and route modules, mirroring the typed-codec lint pattern:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
{
|
|
165
|
+
files: ["apps/web/src/components/**", "apps/web/src/routes/**"],
|
|
166
|
+
rules: {
|
|
167
|
+
"no-restricted-properties": [
|
|
168
|
+
"warn",
|
|
169
|
+
{
|
|
170
|
+
property: "then",
|
|
171
|
+
message:
|
|
172
|
+
"Compose the workflow in Effect (Atom.fn + reactivity keys) and return promise-mode dispatches bare to the leaf component.",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Keep any justified suppression local and documented; the boundary reasoning,
|
|
180
|
+
not the lint rule, remains the source of truth.
|
package/src/bin/dev-kit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { printError } from "../cli-ui.ts";
|
|
|
12
12
|
import { syncEffectSource } from "../effect-source.ts";
|
|
13
13
|
import { patchEffectTsgo } from "../effect-tsgo.ts";
|
|
14
14
|
import { patchProjectGitignore } from "../gitignore.ts";
|
|
15
|
+
import { CACHE_PRUNE_AGE_DAYS, runCachePrune } from "../global-cache.ts";
|
|
15
16
|
import {
|
|
16
17
|
addSkills,
|
|
17
18
|
chooseSkillsToAdd,
|
|
@@ -351,6 +352,25 @@ const catalogVerifyCommand = CliCommand.make(
|
|
|
351
352
|
refreshSkillCatalog({ locked: true, lockfilePath: lockfile, repoDir, sourcesPath: sources }),
|
|
352
353
|
).pipe(CliCommand.withDescription("Verify the committed catalog without advancing refs."));
|
|
353
354
|
|
|
355
|
+
const cachePruneCommand = CliCommand.make(
|
|
356
|
+
"prune",
|
|
357
|
+
{
|
|
358
|
+
all: Flag.boolean("all").pipe(
|
|
359
|
+
Flag.withDescription("Remove the entire cache instead of only stale content."),
|
|
360
|
+
),
|
|
361
|
+
maxAgeDays: Flag.integer("max-age-days").pipe(
|
|
362
|
+
Flag.withDefault(CACHE_PRUNE_AGE_DAYS),
|
|
363
|
+
Flag.withDescription("Evict content unused for this many days."),
|
|
364
|
+
),
|
|
365
|
+
},
|
|
366
|
+
({ all, maxAgeDays }) => runCachePrune({ all, maxAgeDays }),
|
|
367
|
+
).pipe(CliCommand.withDescription("Evict stale content from the machine-global source cache."));
|
|
368
|
+
|
|
369
|
+
const cacheCommand = CliCommand.make("cache").pipe(
|
|
370
|
+
CliCommand.withDescription("Manage the machine-global source cache."),
|
|
371
|
+
CliCommand.withSubcommands([cachePruneCommand] as const),
|
|
372
|
+
);
|
|
373
|
+
|
|
354
374
|
const catalogCommand = CliCommand.make("catalog").pipe(
|
|
355
375
|
CliCommand.withDescription("Maintain the approved upstream catalog."),
|
|
356
376
|
CliCommand.withSubcommands([
|
|
@@ -382,6 +402,7 @@ const command = CliCommand.make("dev-kit", projectFlags, ({ manifest, projectDir
|
|
|
382
402
|
effectCommand,
|
|
383
403
|
tsgoCommand,
|
|
384
404
|
catalogCommand,
|
|
405
|
+
cacheCommand,
|
|
385
406
|
],
|
|
386
407
|
},
|
|
387
408
|
] as const),
|
package/src/catalog.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
|
2
2
|
import { ChildProcess } from "effect/unstable/process";
|
|
3
3
|
import { parse as parseJsonc, type ParseError } from "jsonc-parser";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
commitCacheDirectory,
|
|
7
|
+
resolveGlobalCacheDirectory,
|
|
8
|
+
stampCacheEntryUsage,
|
|
9
|
+
} from "./global-cache.ts";
|
|
5
10
|
import {
|
|
6
11
|
discoverPackageSkills,
|
|
7
12
|
resolvePackageSkillSelector,
|
|
@@ -181,7 +186,13 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
|
|
|
181
186
|
});
|
|
182
187
|
}
|
|
183
188
|
const families: Readonly<Record<string, ReadonlyArray<string>>> = {
|
|
184
|
-
effect: [
|
|
189
|
+
effect: [
|
|
190
|
+
"effect-ts",
|
|
191
|
+
"effect-architecture-audit",
|
|
192
|
+
"build-effect-apis",
|
|
193
|
+
"effect-atom-state",
|
|
194
|
+
"build-effect-clis",
|
|
195
|
+
],
|
|
185
196
|
...Object.fromEntries(externalFamilies),
|
|
186
197
|
};
|
|
187
198
|
|
|
@@ -261,26 +272,35 @@ const materializePackageSkill = Effect.fn("materializePackageSkill")(function* (
|
|
|
261
272
|
return staged;
|
|
262
273
|
});
|
|
263
274
|
|
|
275
|
+
// Catalog checkouts are keyed by source id and resolved commit SHA, so the
|
|
276
|
+
// materialized content is immutable and shared machine-wide across projects
|
|
277
|
+
// and worktrees. Planning and locked verification populate the same cache:
|
|
278
|
+
// writing an immutable commit-keyed cache entry is not project state.
|
|
264
279
|
const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
265
|
-
projectDir: string,
|
|
266
280
|
source: LockedSkillSource,
|
|
267
281
|
selected: ReadonlyArray<string>,
|
|
268
|
-
cache: boolean,
|
|
269
282
|
) {
|
|
270
283
|
const fs = yield* FileSystem.FileSystem;
|
|
271
284
|
const path = yield* Path.Path;
|
|
272
|
-
const root =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
);
|
|
279
|
-
const checkout = path.join(root, "checkout");
|
|
285
|
+
const root = path.join(
|
|
286
|
+
yield* resolveGlobalCacheDirectory(),
|
|
287
|
+
"catalog",
|
|
288
|
+
source.id,
|
|
289
|
+
source.resolved,
|
|
290
|
+
);
|
|
280
291
|
const ready = path.join(root, ".ready");
|
|
281
292
|
|
|
282
293
|
if (!(yield* fs.exists(ready))) {
|
|
283
|
-
yield* fs.
|
|
294
|
+
yield* fs.makeDirectory(path.dirname(root), { recursive: true });
|
|
295
|
+
const staged = path.join(
|
|
296
|
+
yield* fs.makeTempDirectoryScoped({
|
|
297
|
+
directory: path.dirname(root),
|
|
298
|
+
prefix: ".dev-kit-catalog-stage-",
|
|
299
|
+
}),
|
|
300
|
+
source.resolved,
|
|
301
|
+
);
|
|
302
|
+
const checkout = path.join(staged, "checkout");
|
|
303
|
+
|
|
284
304
|
yield* fs.makeDirectory(checkout, { recursive: true });
|
|
285
305
|
yield* runGit(checkout, ["init", "--quiet"]);
|
|
286
306
|
yield* runGit(checkout, ["remote", "add", "origin", source.repository]);
|
|
@@ -302,7 +322,7 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
302
322
|
}
|
|
303
323
|
for (const skill of source.skills) {
|
|
304
324
|
const from = path.join(checkout, source.skillsPath, skill);
|
|
305
|
-
const to = path.join(
|
|
325
|
+
const to = path.join(staged, "skills", skill);
|
|
306
326
|
const observation = yield* observePath(from);
|
|
307
327
|
|
|
308
328
|
if (observation.kind !== "directory") {
|
|
@@ -320,8 +340,10 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
320
340
|
);
|
|
321
341
|
}
|
|
322
342
|
}
|
|
323
|
-
yield* fs.writeFileString(ready, `${source.resolved}\n`);
|
|
343
|
+
yield* fs.writeFileString(path.join(staged, ".ready"), `${source.resolved}\n`);
|
|
344
|
+
yield* commitCacheDirectory(staged, root, fs.exists(ready));
|
|
324
345
|
}
|
|
346
|
+
yield* stampCacheEntryUsage(root);
|
|
325
347
|
for (const skill of selected) {
|
|
326
348
|
const observation = yield* observePath(path.join(root, "skills", skill));
|
|
327
349
|
const approvedDigest = source.digests?.[skill];
|
|
@@ -351,6 +373,8 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
|
351
373
|
);
|
|
352
374
|
});
|
|
353
375
|
|
|
376
|
+
// The cache flag only affects package skills, whose staging area is project
|
|
377
|
+
// state under .dev-kit; catalog sources always use the machine-global cache.
|
|
354
378
|
export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
355
379
|
packageRoot: string,
|
|
356
380
|
projectDir: string,
|
|
@@ -370,7 +394,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
|
370
394
|
const wanted = source.skills.filter((skill) => selected.includes(skill));
|
|
371
395
|
|
|
372
396
|
if (wanted.length === 0) continue;
|
|
373
|
-
for (const [name, sourcePath] of yield* materializeSource(
|
|
397
|
+
for (const [name, sourcePath] of yield* materializeSource(source, wanted)) {
|
|
374
398
|
sources.set(name, sourcePath);
|
|
375
399
|
}
|
|
376
400
|
}
|